From 957427cb76c8515ed70c6849354945c40e3cb392 Mon Sep 17 00:00:00 2001 From: rmn-boiko Date: Tue, 15 Sep 2026 13:56:21 +0200 Subject: [PATCH 1/9] docs: mobile SDK design + one-Rust-core implementation plan Add the mobile SDK design doc and the implementation plan for a single Rust client SDK exposed to both Kotlin and Swift via uniffi. Design doc (docs/design/mobile-sdk-and-lightweight-uniffi.md) measures where the uniffi node's size goes on mobile (99.0 MB as CI ships it, 46.6 MB tuned) and concludes that most apps want the Minimal SDK variant rather than a node on the phone. The plan (docs/plans/20260915-minimal-mobile-rust-sdk.md) supersedes the design doc's separate Path A2 (native Kotlin) / Path A3 (native Swift) routes with one Rust crate serving both. Measured 2026-09-15: a minimal Rust client crate with the real derive/PSBT/sign paths reachable is 1.94 MB including uniffi scaffolding across 31 crates -- the same order as the doc's ~1.9 MB estimate for pure Kotlin, since both are dominated by the same libsecp256k1 precomputed tables (1.06 MB). With size neutral, implementation count decides. Key decisions recorded in the plan: no rgb-lib dependency (keeps the size budget and keeps the rgb-lib-generated parity fixture a real cross-check rather than a tautology); the TypeScript SDK stays hand-written as the independent second implementation; HTTP transport is a foreign trait implemented per platform, with intent binding staying in Rust. Co-Authored-By: Claude Opus 5 (1M context) --- .../mobile-sdk-and-lightweight-uniffi.md | 298 +++++++++ .../plans/20260915-minimal-mobile-rust-sdk.md | 580 ++++++++++++++++++ 2 files changed, 878 insertions(+) create mode 100644 docs/design/mobile-sdk-and-lightweight-uniffi.md create mode 100644 docs/plans/20260915-minimal-mobile-rust-sdk.md diff --git a/docs/design/mobile-sdk-and-lightweight-uniffi.md b/docs/design/mobile-sdk-and-lightweight-uniffi.md new file mode 100644 index 00000000..88eb0e54 --- /dev/null +++ b/docs/design/mobile-sdk-and-lightweight-uniffi.md @@ -0,0 +1,298 @@ +# Mobile SDK: minimal client + lightweight uniffi node + +Companion to `docs/design/minimal-sdk-and-lightweight-rln.md`, which covered the web +(wasm) side. That doc's Block 1 names "browser/mobile app" as the SDK target but only ever +analysed the TypeScript and wasm artifacts; the uniffi mobile SDK was never measured. This +one does that, and applies the same two-variant split to mobile. + +All figures below were measured in this tree on 2026-09-14 (branch `dev`). Method and raw +output are in Appendix B. Numbers labelled *estimate* are derived, not measured. + +## Executive summary + +- **The mobile SDK ships the entire RLN daemon, not an SDK.** The uniffi cdylib is built + from the root crate with `mod routes;` unconditional (`src/lib.rs:48`), so the axum REST + server, both chain backends, both indexers, two ORMs and a **PostgreSQL driver** compile + into the library that runs on a phone — while `src/uniffi_api/README.md:3` states "REST + is **not** part of the SDK surface." +- **What CI uploads today is 99.0 MB, unstripped.** `uniffi-artifacts.yaml:59` copies + `target/release/librgb_lightning_node.so` straight into the artifact. Stripping alone + takes it to 73.4 MB. +- **The root `Cargo.toml` has no `[profile.release]`** — only `[profile.reldebug]` + (`Cargo.toml:181`). This is the same gap the wasm crate had. Adding the three-line + profile the wasm work already validated takes the library from **73.4 MB → 54.2 MB raw, + 28.3 MB → 20.1 MB gzip** (measured, not estimated). +- **Feature gating is worth about as much again**: dropping VLS, VSS, the second indexer + and the PostgreSQL driver reaches **46.6 MB / 17.2 MB gzip** — a 53% cut from today, + with no architectural change. The biggest code-level win still unmeasured is gating + `mod routes` and converging the duplicated sea-orm/sqlx stacks. +- **For most mobile apps, none of that matters**, because the Minimal SDK variant applies + unchanged: the existing TypeScript client SDK is **185.5 KB** and runs on React Native + today with one polyfill. A native Kotlin equivalent costs about **1.9 MB** per ABI. That + is ~28× smaller than the best tuned uniffi node and ~52× smaller than what CI ships. +- **One deadline has already passed.** Google Play requires 16 KB page-size support for + native code targeting Android 15+; the extended deadline was 2026-05-31. The published + SDK's AGP (8.7.3) aligns automatically, but this has never been verified on the produced + `.so`, and the `android-e2e` harness is on AGP 8.3.0 — below the threshold. + +## Block 1 — Minimal mobile SDK (the default recommendation) + +The trust model, architecture and invariants I1–I4 are unchanged from the web design doc: +keys are generated and held client-side only, the server keeps per-user xpubs watch-only, +the gateway holds the only RLN credential, and the client verifies every server-prepared +PSBT before signing. Nothing about that is web-specific — only the implementation language +changes. + +### Path A1 — reuse the TypeScript SDK (React Native / Capacitor). Days of work. + +`minimal-sdk/packages/client-sdk` is already portable. Verified by inspection: it imports +only `@noble/*` and `@scure/*` (pure JS), and the sole platform touchpoints are +`globalThis.crypto.getRandomValues` (`src/gateway.ts:269`) and `globalThis.fetch` +(`src/gateway.ts:298`). There is no Node-only API anywhere in `src/`. + +| Artifact | Size | Note | +| --- | --- | --- | +| `@utexo/minimal-client-sdk`, esbuild minified | **185.5 KB** | measured; budget gate is 500 KB | +| `react-native-get-random-values` polyfill | ~10 KB | estimate; RN lacks WebCrypto | + +Capacitor needs no polyfill (it runs in a WebView with real WebCrypto). This is the +cheapest mobile path by a wide margin and the one to take unless the app is natively +written. + +### Path A2 — native Kotlin. Weeks of work. + +Reimplement the same five modules (`keys`, `derive`, `verify`, `sign`, `invoice`) over the +Kotlin equivalents of the `@scure` stack. Measured floor: + +| Dependency | Size | Note | +| --- | --- | --- | +| `fr.acinq.bitcoin:bitcoin-kmp-jvm` | 596 KB jar (1.27 MB / 327 classes unpacked) | BIP-32/39, taproot, PSBT | +| `secp256k1-kmp-jni-android`, arm64-v8a slice | **1.28 MB** stripped | `.text` is only 195 KB; 1.12 MB is the precomputed ecmult table | +| **Total, arm64 slice** | **≈ 1.9 MB** | vs 54.2 MB for the tuned uniffi node | + +Two things worth knowing. The secp256k1 library is 84% lookup table, so rebuilding it with +a smaller `--with-ecmult-window` trades signing speed for roughly 1 MB — usually not worth +it. And this path needs **no JNA**: `secp256k1-kmp` uses direct JNI, so the 518 KB +`net.java.dev.jna:jna:5.14.0@aar` that the uniffi SDK pulls in +(`bindings/kotlin-android-sdk/build.gradle.kts`) disappears entirely. That aar also still +ships dead `mips`/`mips64` slices (293 KB of its 518 KB) that Android dropped in NDK r17. + +### Path A3 — native Swift. Weeks of work. + +`swift-secp256k1` over the same libsecp256k1 C core; the size profile is dominated by the +same ecmult table, so expect the same order as A2 (*estimate*: 0.3–1.3 MB depending on the +ecmult window). iOS links statically and dead-strips at app link, so measure the app-size +delta rather than the library. + +### What this buys + +The custody boundary is identical to the web variant and must be stated the same way: the +client keys cover all at-rest user funds, the LN working balance on the shared node stays +custodial, and the mitigations are the capped float, the sweep threshold and the swap path. +Mobile changes none of that. + +## Block 2 — Lightweight uniffi node (when the app must run its own node) + +### Where the size goes (measured) + +`cargo build --release --features "uniffi,vls,vss"` (defaults on), host +`x86_64-unknown-linux-gnu`: + +| Build | `.so` raw | gzip | `.text` | +| --- | --- | --- | --- | +| What CI uploads today — **unstripped** (`uniffi-artifacts.yaml:59`) | 99.0 MB | — | 57.2 MB | +| Same, `strip --strip-all` | 73.4 MB | 28.3 MB | 57.2 MB | +| **+ `opt-level="z"` + thin LTO + `strip`** | **54.2 MB** | **20.1 MB** | 32.7 MB | +| + drop VLS, VSS, second indexer | 47.9 MB | 17.7 MB | 27.5 MB | +| **+ drop the PostgreSQL driver too** | **46.6 MB** | **17.2 MB** | 26.8 MB | +| + `panic="abort"` instead (measured, **not recommended** — see below) | 44.5 MB | 16.5 MB | 26.4 MB | + +So the realistic, recommendable floor with no architectural change is **46.6 MB raw / +17.2 MB gzip** — a 53% cut from what CI ships today, reached entirely through a build +profile, four feature flags and one dependency line. + +The iOS side ships a 391.9 MB static `.a` inside the XCFramework +(`scripts/ci/package_swift_xcframework.sh`), but that is an archive: the app linker +dead-strips it, so the meaningful iOS number is the app-size delta, which nobody in this +repo has measured yet. + +Attribution of the baseline `.text` (56.7 MB attributed across 93,494 symbols): + +| Subsystem | Share | +| --- | --- | +| Rust `core` + `alloc` + `std` | 27.1% | +| LDK (fork + companion crates) | 15.4% | +| RGB stack (rgb-lib, rgbcore/std, strict_encoding, amplify…) | 12.5% | +| Database stack — sea-orm ×2, sqlx ×2 **including PostgreSQL** | 5.7% | +| VLS validating signer + redb ×2 | 2.8% | +| Own code (`rgb_lightning_node`) | 2.9% | +| REST server (axum, tower, hyper, h2, biscuit-auth) | 1.7% | +| TLS/crypto stacks (rustls, aws-lc, ring) | 1.7% | +| Both indexers (electrum + esplora) | 0.8% | +| C code in the remainder: aws-lc 1.38 MB, sqlite3 0.66 MB, zstd/zlib 0.61 MB, **secp256k1 0.15 MB** | — | + +Two structural differences from the wasm build are worth internalising: + +- **Exports are not the problem here.** The cdylib exposes 234 dynamic symbols (156 + uniffi). The wasm build's 569 `wasm-bindgen` exports pinned its whole call graph; this + one is pinned by genuine reachability from 156 entry points instead. Trimming exports + will not help much — gating modules will. +- **Unwinding metadata is 23% of the tuned binary**: `.eh_frame` 7.56 MB + + `.eh_frame_hdr` 1.16 MB + `.gcc_except_table` 3.87 MB = 12.6 MB of 54.2 MB. This is the + price of `panic = "unwind"`, and it has no wasm equivalent. + +### The dead weight, specifically + +| Finding | Evidence | +| --- | --- | +| The REST server compiles into the phone library, contradicting the SDK's own README | `src/lib.rs:48` (`mod routes;` unconditional) vs `src/uniffi_api/README.md:3` | +| A PostgreSQL driver ships to mobile | `migration/Cargo.toml:15` (`sqlx-postgres`) | +| The ORM is compiled twice — rgb-lib pins sea-orm 2.0.2, RLN pins 1.1.20, so `sqlx-core`/`-postgres`/`-sqlite` each build at 0.8.6 **and** 0.9.0 | `Cargo.toml:44`, `migration/Cargo.toml:12`, `Cargo.lock` | +| Both chain backends are forced on, because the UDL enum has both variants — a phone never runs bitcoind | `src/lib.rs:16-19`, `bindings/rgb_lightning_node.udl:605-608` | +| Three crypto stacks resolve; vendored OpenSSL is **built** (this is why CI needs bindgen + cmake) but the linker strips it from the cdylib — pure build-time waste | `Cargo.toml:97`, `uniffi-artifacts.yaml:118-124`, symbol scan | +| aws-lc contributes 1.38 MB, of which 680 KB is x86 AES-GCM AVX-512 assembly — a TLS suite for a wallet that needs secp256k1 (0.15 MB) | symbol scan | +| 769 crates in the dependency graph | `Cargo.lock` | + +### Build checklist + +**Phase 0 — config only, one small PR. Measured: 73.4 MB → 54.2 MB raw, 28.3 → 20.1 MB gzip.** + +1. **Add `[profile.release]`** to the root `Cargo.toml`: `opt-level = "z"`, `lto = "thin"`, + `strip = "symbols"`. Three lines. This is the same change the wasm work already + validated. Verify the latency impact on signing paths before adopting `opt-level="z"` + over `"s"` — `z` disables loop vectorisation, and the RGB/LDK hot paths were never + profiled on ARM. +2. **Strip in CI regardless.** `uniffi-artifacts.yaml:59` ships an unstripped `.so`; that + alone is 25.6 MB of the current artifact. +3. **`--remap-path-prefix`** via `RUSTFLAGS` in the mobile workflows — same privacy leak + and reproducibility prerequisite as wasm checklist item 4. + +**Phase 1 — scoped feature gating. Measured 47.9 MB for the features alone; the code-level items are unmeasured.** + +4. **`#[cfg(feature = "rest")] mod routes;`** and move `axum`, `axum-extra`, `tower-http` + and `biscuit-auth` behind it. The layering already supports this — `src/uniffi_api/README.md` + documents `uniffi_api -> sdk -> ldk` with `routes` as a parallel compatibility layer that + uniffi never calls. +5. **Drop `sqlx-postgres`** from `migration/Cargo.toml` for mobile builds, and converge the + sea-orm major version with rgb-lib's to collapse the duplicate ORM and sqlx trees. +6. **Un-force the chain backend**: keep both `SdkLdkChainSync` variants in the UDL (stable + ABI) but return a runtime error for a backend that was not compiled, then drop the + `compile_error!` at `src/lib.rs:16-19`. Mobile builds `transaction-sync` only. +7. **One indexer per build**, and gate VLS/VSS off for integrators who do not use them. + +**Phase 2 — deeper, do last.** + +8. **`-Zbuild-std` + `panic_immediate_abort`** (nightly, CI-only lane) attacks the 27% + `core`/`alloc` share — the single largest slice, exactly as on the wasm side. +9. **`panic = "abort"` is measured at −9.7 MB (18%) but should not be adopted.** uniffi + converts Rust panics into Kotlin/Swift exceptions by catching them at the FFI boundary, + and the background-processor watchdog depends on `catch_unwind` (`src/ldk.rs:4379-4422`). + With `panic="abort"` a recoverable error becomes a hard app crash. The number is + recorded here so the trade is explicit, not so it gets taken. + +### Mobile packaging (this is where mobile differs from web) + +- **Google Play caps the per-device compressed download at 200 MB** for an app bundle, and + warns users on mobile data above that. With ABI splits each user downloads one `.so`, so + even today's build fits — size here is a product-quality problem, not a compliance one. +- **Install size is what users actually feel.** With the modern default + (`useLegacyPackaging = false`) the `.so` is stored uncompressed and page-mapped from the + APK, so the install footprint tracks the *raw* size — 54.2 MB tuned versus 73.4 MB + stripped versus 99.0 MB as shipped. This is the number to put in the checklist, not the + gzip column. +- **16 KB page size is now mandatory** for native code targeting Android 15+ (extended + deadline 2026-05-31, already passed). AGP ≥ 8.5.1 aligns at packaging and the published + SDK uses 8.7.3, so this is probably fine — but it has never been asserted on the produced + `.so`, and `android-e2e/build.gradle` is on AGP 8.3.0. Add a CI check that reads the + `LOAD` segment alignment of each `jniLibs` artifact. +- **iOS thins automatically**: the App Store ships only the device's slice, and the static + `.a` is dead-stripped at app link. The XCFramework currently carries device arm64 plus a + universal simulator slice (`scripts/ci/package_swift_xcframework.sh`), which is correct. +- **Nothing is published yet.** Maven Central has no `com.utexo` artifacts, so all of the + above can land before the first release rather than as a breaking change after it. + +## Block 3 — Mobile runtime constraints + +These have no web analogue and are not addressed anywhere in the current SDK: + +- **Process death is normal, not exceptional.** Android kills backgrounded processes freely + and iOS suspends them within seconds. A Lightning node that assumes a long-lived process + will miss channel updates. The uniffi surface is instance-based (`SdkNode.create`, per + `src/uniffi_api/README.md`) but there is no documented save/restore-on-suspend contract. +- **The threading model bridges sync FFI onto async internals** via `block_in_place + + handle.block_on` on a multi-thread runtime, or a dedicated runtime otherwise + (`src/uniffi_api/README.md`). Every uniffi call therefore blocks the calling thread — + calling one from the Android main thread will ANR. This needs to be stated in the SDK + README, and ideally enforced with a debug-build assertion. +- **The fail-fast watchdog is wrong for an app.** `src/ldk.rs:4422` calls + `std::process::exit` on an event-path panic. On a daemon that is correct; inside a host + app it terminates the user's whole application, bypassing every crash reporter. Mobile + builds should surface a fatal error through the FFI instead. This overlaps with item 5 of + the web doc's Block 3 fix list and should be designed alongside it. +- **Battery and data.** Both chain backends and both indexers being compiled in is a size + problem; which one is *used* is a battery problem. Mobile should default to + `transaction-sync` against an indexer with an explicit polling cadence, never a full + block sync. + +## Appendix + +### A. Citation index + +- Crate layout / profile gap: `Cargo.toml:7-9` (`crate-type`), `Cargo.toml:181` + (`reldebug`, no `[profile.release]`), `Cargo.toml:27-60` (features), `Cargo.toml:97` + (reqwest `native-tls`) +- Daemon in the SDK: `src/lib.rs:48` (`mod routes;`), `src/lib.rs:16-19` (`compile_error!` + forcing both chain backends), `src/uniffi_api/README.md:3` (REST is not the SDK surface) +- Bindings: `bindings/rgb_lightning_node.udl:605-608` (`SdkLdkChainSync`), 918 lines / 99 + interfaces+dictionaries+enums; `src/uniffi_api/mod.rs` (~110 public functions); + `uniffi.toml`, `uniffi-android.toml` +- CI: `.github/workflows/uniffi-artifacts.yaml:50,59` (release build, unstripped copy), + `:126-135` (Android JNI via `scripts/ci/build_android_jni.sh`), `:202-205` (Apple + targets), `scripts/ci/package_swift_xcframework.sh` +- Android packaging: `bindings/kotlin-android-sdk/build.gradle.kts` (AGP 8.7.3, JNA aar, + `isMinifyEnabled = false`), `android-e2e/build.gradle` (AGP 8.3.0) +- Database: `migration/Cargo.toml:12-18` (`cli`, `sqlx-postgres`, `sqlx-sqlite`) +- Watchdog / panic path: `src/ldk.rs:4379` (`catch_unwind`), `src/ldk.rs:4422` + (`process::exit`) +- Client SDK portability: `minimal-sdk/packages/client-sdk/src/gateway.ts:269,298` + +### B. Measured numbers and method + +Host `x86_64-unknown-linux-gnu`, Rust stable, this tree at `dev`, 2026-09-14. Profile +variants applied via `CARGO_PROFILE_RELEASE_*` environment overrides so no file was +modified. Sizes from `stat`/`size -A`; gzip is `gzip -9`. Symbol attribution from +`nm --print-size --size-sort -C` on the unstripped baseline, aggregating `t`/`w` symbols by +the leading path component of the demangled name; 6.08 MB of unmangled (C) symbols were +classified separately by name prefix. + +| # | Build | `.so` | gzip | `.text` | +| --- | --- | --- | --- | --- | +| 1 | `--release --features "uniffi,vls,vss"`, as CI ships it | 99.0 MB | — | 57.2 MB | +| 2 | #1 stripped | 73.4 MB | 28.3 MB | 57.2 MB | +| 3 | #1 + `opt-level=z`, `lto=thin`, `strip=symbols` | 54.2 MB | 20.1 MB | 32.7 MB | +| 4 | #3 + `--no-default-features --features "uniffi,electrum,block-sync,transaction-sync"` | 47.9 MB | 17.7 MB | 27.5 MB | +| 5 | #4 + `sqlx-postgres` removed from `migration/Cargo.toml` (edit reverted after the build) | 46.6 MB | 17.2 MB | 26.8 MB | +| 6 | #3 + `panic=abort` | 44.5 MB | 16.5 MB | 26.4 MB | + +Static library for iOS: 391.9 MB `.a`, 104.9 MB `.rlib`. Dynamic exports: 234 total, 156 +uniffi. Dependency graph: 769 crates. + +Section breakdown of build #3 (54.2 MB): `.text` 32.65, `.eh_frame` 7.56, `.rodata` 5.42, +`.rela.dyn` 3.86, `.gcc_except_table` 3.87, `.eh_frame_hdr` 1.16, `.data.rel.ro` 1.58 (MB). + +Third-party artifacts, downloaded from Maven Central and measured: `jna-5.14.0.aar` +517,894 B (arm64 slice 168,176 B; dead mips/mips64 slices 292,828 B); +`secp256k1-kmp-jni-android-0.15.0.aar` 4,902,570 B (arm64 `.so` 1,339,880 B, already +stripped: `.text` 194,820 B, `.rodata` 1,119,480 B); `bitcoin-kmp-jvm-0.20.0.jar` +596,300 B. TypeScript client SDK: 185.5 KB via the repo's own esbuild bundle gate. + +### C. What was not measured + +- **Android arm64 and iOS arm64 code size.** No NDK or macOS host was available here, so + every figure is the x86_64 host build. Rust `.text` on aarch64 is typically within ±15% + of x86_64, but the aws-lc assembly differs substantially (the 680 KB of AVX-512 AES-GCM + is replaced by smaller ARM crypto-extension code). **Re-measure on the real targets + before quoting these numbers externally.** +- **The app-size delta on iOS**, which is the only number that matters there. +- **Phase-1 code-level gating** (items 4–6): only the feature-flag portion was measured. +- **Runtime cost of `opt-level="z"`** on signing and sync paths. diff --git a/docs/plans/20260915-minimal-mobile-rust-sdk.md b/docs/plans/20260915-minimal-mobile-rust-sdk.md new file mode 100644 index 00000000..ddbf27f5 --- /dev/null +++ b/docs/plans/20260915-minimal-mobile-rust-sdk.md @@ -0,0 +1,580 @@ +# Minimal SDK for mobile: one Rust core, Kotlin and Swift bindings + +## Overview + +`docs/design/mobile-sdk-and-lightweight-uniffi.md` reaches one conclusion for mobile: **for +most apps the Minimal SDK variant is the answer, not a node on the phone.** The uniffi node CI +ships is 99.0 MB unstripped and carries the whole axum REST daemon; the TypeScript client SDK +that does the same user-facing job is 185.5 KB. + +The design doc offered two mobile routes for that client — Path A2 (native Kotlin) and Path A3 +(native Swift) — and costed each at "weeks of work", separately. This plan takes a third route +the doc did not cost: **one small Rust crate, exposed to both Kotlin and Swift through uniffi.** + +The reason the doc did not consider it is that it compared Kotlin against *the node* (28×–52× +smaller) rather than against a *minimal Rust crate*. Measured on 2026-09-15 (method in +"Verified facts" below), a minimal Rust client crate with the real derive/PSBT/sign paths +reachable is **1.94 MB with uniffi scaffolding included, across 31 crates** — the same order as +the doc's ≈1.9 MB estimate for pure Kotlin, because both are dominated by the *same* +libsecp256k1 precomputed tables. With size neutral, the deciding factor becomes how many times +we implement the code that guards user funds. + +So: **one implementation of keys, derivation, verify-before-sign, signing, invoice decoding and +intent binding**, in Rust, serving Android and iOS. Path A3 stops being a second project and +becomes a build target. + +**This is not the node.** No LDK, no rgb-lib, no axum, no sea-orm, no chain backend. The crate +depends on `bitcoin`, `bip39` and `uniffi` and nothing else. + +**Out of scope:** any change to the uniffi *node* or its build profile (Block 2 of the design +doc — a separate plan); any Rust change inside `rgb-lightning-node`; any gateway/server change; +replacing the TypeScript SDK; publishing to Maven Central or the Swift package registry; and +extraction of `minimal-sdk/` into its own repository. + +## Context + +- Authority for architecture and the size argument: `docs/design/mobile-sdk-and-lightweight-uniffi.md` + (Block 1 Paths A2/A3, Block 3's mobile runtime constraints). +- Authority for the trust model, invariants I1–I4 and the five verify-before-sign checks: + `docs/design/minimal-sdk-and-lightweight-rln.md`. +- The normative behavioural reference being ported: `minimal-sdk/packages/client-sdk/src/` + (1,701 LOC across 8 files). The Rust crate must reproduce its behaviour, not its structure. + +### Three design decisions that shape every task + +1. **The Rust crate must NOT depend on `rgb-lib`.** Two reasons, both load-bearing. Size: + rgb-lib is 12.5% of the node's `.text` and drags in sea-orm/sqlx — it would destroy the + 1.94 MB budget that justifies this approach. Correctness: the parity fixture is + rgb-lib-generated, so if the client *were* rgb-lib's code, agreement would be tautological + and a shared misreading of BIP-86 or the RGB coin types would be invisible. An independent + implementation checked against rgb-lib's output is the property worth keeping. +2. **The TypeScript SDK stays hand-written and stays shipped.** It is 185.5 KB of pure JS; + replacing it with wasm would be a large regression for web and React Native. The end state + is therefore **two** implementations (TS + Rust) rather than three (TS + Kotlin + Swift) — + this plan halves the duplication, it does not remove it, and the parity fixture is what + keeps the two honest. +3. **HTTP transport is a foreign trait, everything else is Rust.** The crate defines a + transport trait with `#[uniffi::export(with_foreign)]`; Kotlin implements it over OkHttp or + `HttpURLConnection`, Swift over `URLSession`. Networking stays idiomatic and async-friendly + per platform, Rust never links an HTTP stack or a TLS stack, and there is no async-over-FFI. + **Intent binding stays in Rust** — it is security-critical logic, not plumbing. + +### Verified facts (measured/re-verified 2026-09-15 on `dev`; re-check line numbers when citing) + +**The size measurement that motivates this plan.** Scratch crate with `bitcoin` 0.32 + +`bip39` 2.2, release profile `opt-level="z"` + `lto="thin"` + `strip="symbols"` + +`codegen-units=1`, host `x86_64-unknown-linux-gnu`, with BIP-39→BIP-32→taproot-derive→PSBT- +parse→key-path-sign→serialize all reachable from exported symbols so LTO cannot strip them: + +| Build | raw | gzip | crates | +| --- | --- | --- | --- | +| `bitcoin` + `bip39`, C-ABI exports | 1.89 MB | 1.42 MB | 30 | +| **+ uniffi scaffolding + exported fns** | **1.94 MB** | ~1.45 MB | 31 | +| Pure Kotlin (design doc, measured) | ≈1.9 MB | — | — | +| uniffi **node**, tuned / as CI ships | 46.6 / 99.0 MB | — | 769 | + +- **uniffi scaffolding costs 48 KB** (1,981,992 → 2,030,936 bytes). It is not a size factor. +- **1.06 MB of the 1.94 MB is secp256k1 precomputed tables**, confirmed by symbol: + `rustsecp256k1_v0_10_0_pre_g` 512 KB + `..._pre_g_128` 512 KB + `..._ecmult_gen_prec_table` + 64 KB. The design doc measured the same 1.12 MB of tables inside `secp256k1-kmp`'s `.so` — + it is the same C library either way, so no Kotlin/Rust choice removes it. +- **Actual Rust code is 460 KB of `.text`.** The remaining delta versus pure Kotlin is JNA + (below), not compiled code. +- ⚠️ **x86_64 host figures**, same caveat as the design doc's Appendix C. Re-measure on + arm64 before quoting externally; Task 8 records the real arm64 number. + +**The cost side, to keep the decision honest:** + +- uniffi's Kotlin bindings require **JNA** — `net.java.dev.jna:jna:5.14.0@aar` is 518 KB, of + which 293 KB is dead mips/mips64 slices Android dropped in NDK r17 + (`bindings/kotlin-android-sdk/build.gradle.kts`, design doc measurement). Pure Kotlin avoids + it entirely (`secp256k1-kmp` uses direct JNI). Honest comparison: **≈2.46 MB (Rust+JNA) vs + ≈1.9 MB (pure Kotlin)** on Android. Rust wins on implementation count, not on bytes. +- This plan **reintroduces the NDK and cross-compilation** that a pure-Kotlin SDK avoided. No + NDK is installed on the development machine (`~/Android/Sdk/ndk` absent); Task 8 installs it. + +**What is being ported** (`minimal-sdk/packages/client-sdk/src/`): `network.ts` 57, +`keys.ts` 90, `derive.ts` 117, `verify.ts` 383, `sign.ts` 91, `invoice.ts` 316, `gateway.ts` 566. + +- **The five checks** (`verify.ts:50-59`, implemented at `:221`, `:331`, `:349`, `:358`, `:366`): + `inputs-own`, `recipients-match`, `change-own`, `fee-budget`, `opret-zero`. +- **Two security properties that must survive the port**, both documented in the TS source as + the reason the code is shaped the way it is: + 1. `matchOriginPath` (`derive.ts:85`) is the **single source of truth for path acceptance**, + shared by verify (proving scripts) and `privateKeyForPath` (selecting signing keys). Two + independent path-acceptance implementations can drift, and verify would then approve a + path sign does not use. + 2. `ownDerivation` (`verify.ts:156`) selects the key-origin entry **by our master + fingerprint, never by path shape**, and `sign.ts:64` signs with the very entry verify + proved. Shape-matching alone lets a decoy entry carrying a foreign fingerprint, ordered + first, divert signing to a key no check covered. +- **Own OUTPUTS are restricted to keychain 0 at a bounded index; own INPUTS are not** + (`verify.ts:260-270`). Deliberate asymmetry — do not "tidy" it. +- **Check 2 re-derives the expected script from the human-readable address** and only + cross-checks the server's `scriptHex` (`verify.ts:283-297`); otherwise a hostile gateway + pairs the intended address with an attacker script and both "match". +- **Intent binding is what makes check 2 non-vacuous** (`gateway.ts:141-159`) — without it the + client verifies the server's PSBT against the server's own intent. + +**The parity fixture** — `minimal-sdk/packages/client-sdk/test/fixtures/rgblib-parity.json`, +generated from the real `@utexo/rgb-lib` binding: account xpubs + master fingerprint +(`73c5da0a`) for all four networks; 5 vanilla + 5 colored regtest addresses at indexes 0..4; +`signing.{unsignedPsbt,signedPsbt,txid,witnessSignature}`; +`witnessReceive.{invoice,recipientId,expirationTimestamp}`; the reference mnemonic and a +foreign one. + +- **BIP-340 signatures are randomized**, so witness bytes are NOT comparable + (`test/sign.test.ts:58-59` says so). The parity assertion is **same txid**, one 64-byte + witness element, and the signature **verifies** against the re-derived x-only key. + +**uniffi mechanics already proven in this repo — reuse, do not reinvent:** + +- Foreign-implemented traits: `#[uniffi::export(with_foreign)] pub trait ExternalSignerHost` + (`src/uniffi_api/mod.rs:32`). This is the model for the HTTP transport trait. +- **Library-mode binding generation** (no `.udl` to maintain): + `scripts/ci/uniffi_generate_from_library.sh ` uses + `uniffi-bindgen generate --library`. The new crate is proc-macro only — **do not add a + UDL file**. +- Android cross-compilation: `scripts/ci/build_android_jni.sh` uses `cargo ndk -t ` for + arm64-v8a, armeabi-v7a, x86_64. Copy the shape; the aws-lc cleanup it does is node-specific + and must NOT be carried over (this crate has no aws-lc). +- XCFramework packaging: `scripts/ci/package_swift_xcframework.sh` — `lipo` the simulator + slices, then `xcodebuild -create-xcframework` over **static** `.a` plus headers. +- Gradle/publishing conventions: `bindings/kotlin-android-sdk/build.gradle.kts` (AGP 8.7.3, + Kotlin 1.9.24, JDK 17, compileSdk 34, minSdk 24, `consumerProguardFiles`, + `singleVariant("release")`). Copy the shape; drop the uniffi-node `srcDir`/`jniLibs` wiring + and JReleaser (no publishing in this plan). + +**Toolchain verified on the development machine:** + +- Rust 1.98.0; all required crates present in the local registry cache (offline builds work). +- JDK **17.0.19 Temurin** at `~/.jdks/temurin-17` — **not on `PATH`**; set `JAVA_HOME`. +- Android SDK at `~/Android/Sdk`: platforms `android-34`/`android-35`, build-tools `34.0.0`, + `cmdline-tools/latest`. **No NDK** — Task 8 installs one. +- `android-e2e` is on AGP 8.3.0 with a Gradle 8.7 wrapper. AGP 8.7.3 needs Gradle **≥ 8.9**, so + the new Gradle project needs **its own wrapper**; do not reuse `android-e2e`'s. +- ⚠️ **No macOS host is available here.** Task 9 (Swift/XCFramework) cannot be built or + verified locally — see "Development Approach". +- `minimal-sdk/pnpm-workspace.yaml` globs `packages/*` only, so new Cargo/Gradle directories + under `minimal-sdk/` need no pnpm changes. + +### Files involved + +- Create (Rust): `minimal-sdk/rust-sdk/{Cargo.toml,Cargo.lock,README.md}`, + `minimal-sdk/rust-sdk/src/{lib.rs,network.rs,keys.rs,derive.rs,verify.rs,sign.rs,invoice.rs,gateway.rs,ffi.rs}`, + `minimal-sdk/rust-sdk/tests/parity.rs` +- Create (Android): `minimal-sdk/android/` — Gradle project, wrapper 8.9+, `build.gradle.kts`, + `consumer-rules.pro`, `src/main/AndroidManifest.xml`, `src/test/kotlin/…SmokeTest.kt` +- Create (Apple): `minimal-sdk/apple/` — `Package.swift`, `Tests/…SmokeTests.swift` +- Create (scripts): `minimal-sdk/scripts/{build_android.sh,build_apple.sh,generate_bindings.sh}` +- Create (CI): `.github/workflows/minimal-sdk-mobile.yaml` +- Read-only, never modified: `minimal-sdk/packages/client-sdk/src/**`, the parity fixture, + both design docs, everything under `src/` and `bindings/` +- Modify only in Task 11: `minimal-sdk/README.md`, `docs/design/mobile-sdk-and-lightweight-uniffi.md` + +### Dependencies + +`bitcoin` 0.32 (`std`, `rand-std`, `base64`; BIP-32, taproot, PSBT), `bip39` 2.2, +`uniffi` 0.28.3, `thiserror` 2.0. **Nothing else.** In particular: no `rgb-lib`, no `reqwest`, +no TLS stack, no `tokio`, no `serde_json` unless a task proves it cannot be avoided — every +addition is measured against the Task-1 size gate before it lands. + +Build-time only: `uniffi-bindgen` (reuse `bindings/uniffi-bindgen/`), `cargo-ndk`, Android NDK, +and on CI macOS runners Xcode for the XCFramework. + +## Development Approach + +- **Testing approach**: Regular (code first, then tests). The Rust crate carries the **full** + parity and adversarial suite (`cargo test`); the Kotlin and Swift sides carry **smoke** tests + only — enough to prove the generated binding surface works end to end, not a third and fourth + copy of the same assertions. This mirrors what the repo already does (`test/kotlin-e2e` is + one file, the Swift job is a smoke). +- Gate command for Tasks 1–7: `cargo test --manifest-path minimal-sdk/rust-sdk/Cargo.toml` +- Gate command for Task 8: `JAVA_HOME=~/.jdks/temurin-17 ./gradlew -p minimal-sdk/android test` +- **⚠️ Task 9 is not locally runnable.** Building the XCFramework needs macOS + Xcode, which + this machine does not have. The agent writes the script, the `Package.swift` and the CI job, + and verifies everything that *is* checkable on Linux (bindings generate, Swift sources are + produced, the packaging script passes `shellcheck`/`bash -n`). Actual XCFramework assembly + and the Swift smoke test are verified by the macOS CI job in Task 10 and signed off in + Post-Completion. Do not fake a local pass. +- **Parity rule, non-negotiable:** tests read + `minimal-sdk/packages/client-sdk/test/fixtures/rgblib-parity.json` **by relative path**. Do + not copy it, do not regenerate it, do not add a Rust fixture generator. One rgb-lib-authored + ground truth, checked by an implementation that shares no code with it. +- **Port behaviour, not line shape.** Where the TS source explains *why* (fingerprint-based + entry selection, the input/output asymmetry, address re-derivation in check 2), carry that + reasoning into the Rust doc comment so the two implementations can be diffed on intent. +- **Invariants that must hold in every task** (design doc I1–I4): + - the mnemonic and any xprv **never** leave the device and are never a gateway request field; + - only account xpubs + master fingerprint are ever sent to the gateway; + - nothing logs, `Debug`-prints or renders a mnemonic, seed, xprv or private key — including + error variants, which is where this leaks in practice. Assert it in tests, and prefer + manual `Debug` impls over `#[derive(Debug)]` on any type holding secrets. +- **No panics across the FFI boundary.** Every exported function returns `Result<_, SdkError>`; + `verify` returns a failed verdict rather than an error for hostile input; no `unwrap()` or + `expect()` on anything derived from caller or PSBT data. A panic here becomes a host-app + crash. +- Signature assertions: **txid parity + signature validity**, never witness byte-equality. +- **Size discipline:** the Task-1 size gate runs in every subsequent task's test run. A task + that pushes the crate over budget must justify it in the progress notes or drop the + dependency that caused it. +- **CRITICAL: every task MUST include new/updated tests** +- **CRITICAL: all tests must pass before starting next task** + +## Implementation Steps + +### Task 1: Rust crate skeleton, uniffi scaffolding, parity harness and size gate + +**Files:** +- Create: `minimal-sdk/rust-sdk/{Cargo.toml,src/lib.rs,src/ffi.rs,tests/parity.rs}` + +- [ ] Create `minimal-sdk/rust-sdk` as its **own Cargo workspace** (`[workspace]` in its + `Cargo.toml`, the pattern `bindings/c-ffi/Cargo.toml` uses) so it never pulls the + rgb-lightning-node dependency graph. `crate-type = ["cdylib", "staticlib", "lib"]` — + cdylib for Android, staticlib for the iOS XCFramework. Release profile + `opt-level="z"`, `lto="thin"`, `strip="symbols"`, `codegen-units=1`, + `panic="unwind"` (uniffi converts panics at the boundary; `panic="abort"` would turn a + recoverable error into an app crash — the design doc records this trade at Block 2 item 9) +- [ ] Add `uniffi::setup_scaffolding!()` and one trivial exported function; confirm + `scripts/ci/uniffi_generate_from_library.sh kotlin` and `… swift` both generate against + the built library with **no UDL file present** +- [ ] Define the shared error type `SdkError` (`#[derive(uniffi::Error)]`) with variants that + carry no key material, and the `SdkResult` alias every exported function returns +- [ ] Prove the parity harness: a test loading + `../packages/client-sdk/test/fixtures/rgblib-parity.json` by relative path and asserting + `masterFingerprint == "73c5da0a"` on Regtest. This pins the parity rule before any crypto + exists +- [ ] **Size gate** (runs in every later task): build release, assert the stripped cdylib is + under **2.5 MB**, and print the measured number. Baseline to compare against: 1.94 MB + measured 2026-09-15 for `bitcoin` + `bip39` + uniffi. Budget rationale is in the Context + section — 1.06 MB of it is secp256k1 tables and is irreducible +- [ ] Tests: the fixture-loading test, the size gate, and a dependency-creep guard asserting + the resolved graph contains **no** `rgb-lib`, `reqwest`, `tokio`, `rustls` or + `openssl` crate +- [ ] Run `cargo test --manifest-path minimal-sdk/rust-sdk/Cargo.toml` — must pass before Task 2 + +### Task 2: Network constants and key management (BIP-39 → BIP-32 accounts) + +**Files:** +- Create: `src/network.rs`, `src/keys.rs`; extend `tests/parity.rs` + +- [ ] Port `network.ts` behaviour: `BitcoinNetwork` enum, `PURPOSE = 86`, `ACCOUNT = 0`, + `KEYCHAIN = 0`, RGB coin types **827166** (mainnet) / **827167** (everything else), + xprv/xpub vs tprv/tpub version bytes, per-network address HRP (`bc`/`tb`/`tb`/`bcrt`); + `account_path(network, colored)` returns `[86', coin', 0']` +- [ ] Port `keys.ts` as `ClientKeys`: `generate(network)` (fresh 12-word mnemonic) and + `from_mnemonic(mnemonic, network)` returning `SdkError::InvalidMnemonic` on a bad + checksum; BIP-39 seed with an **empty passphrase**; vanilla + colored account keys; + expose `fingerprint` (8 lowercase hex chars) and `xpubs` (**public material only**) +- [ ] Add `private_key_for_path(path)` returning `None` (never an error, never a panic) for + foreign paths, delegating to the shared `match_origin_path` landing in Task 3 — leave a + TODO only if Task 3 has not landed, and close it there +- [ ] Secrets hygiene: `ClientKeys` must not expose the mnemonic, seed or any xprv; write a + **manual `Debug` impl** that renders no secret; no secret may reach an `SdkError` variant +- [ ] Tests: for **all four networks**, assert `accountXpubVanilla`, `accountXpubColored` and + `masterFingerprint` equal the fixture exactly; a bad-checksum mnemonic is rejected; the + fixture's `otherMnemonic` yields different xpubs; `generate()` round-trips through + `from_mnemonic`; `format!("{:?}", keys)` contains neither the mnemonic nor `xprv`/`tprv` +- [ ] Run the gate — must pass before Task 3 + +### Task 3: Taproot derivation and origin-path matching + +**Files:** +- Create: `src/derive.rs`; modify `src/keys.rs`; extend `tests/parity.rs` + +- [ ] Port `derive_taproot(account_xpub, keychain, index, network)`: derive the child key, take + the x-only internal key, build the BIP-341 `tr(key)` output script and the bech32m + address, return script hex + address + internal-key hex +- [ ] Port `match_origin_path(path, network)` as the **single source of truth for path + acceptance**: accepts only `[86', coin', 0', keychain, index]` under one of the two + accounts, rejects hardened keychain/index, returns `None` (never an error) on foreign + paths — it is fed attacker-controlled PSBT metadata +- [ ] Port `derive_for_origin_path` returning `None` when the path is not ours or derivation fails +- [ ] Wire `ClientKeys::private_key_for_path` to `match_origin_path`, with a comment stating + that what verify proves is exactly what sign derives +- [ ] Tests: the 5 `regtest.vanillaAddresses` and 5 `regtest.coloredAddresses` are reproduced + exactly at keychain 0, indexes 0..4; `match_origin_path` accepts both accounts and + rejects wrong purpose/coin/account, hardened keychain or index, and short and over-long + paths — returning `None` in every case rather than erroring; `private_key_for_path` + returns a key for exactly the paths `match_origin_path` accepts +- [ ] Run the gate — must pass before Task 4 + +### Task 4: Verify-before-sign — the five checks + +**Files:** +- Create: `src/verify.rs`; extend `tests/parity.rs` + +> No PSBT-codec task: `bitcoin::psbt::Psbt` already parses and re-serializes the taproot fields +> this SDK needs — verified 2026-09-15 by round-tripping the fixture PSBT through +> `Psbt::deserialize`, reading `tap_key_origins` / `tap_internal_key` / `witness_utxo`, signing +> and re-serializing. The first checkbox re-confirms the one property that matters. + +- [ ] **Confirm before building on it:** `Psbt::deserialize` preserves **every** + `tap_key_origins` entry, including a foreign-fingerprint entry alongside ours on the same + input. `tap_key_origins` is keyed by x-only pubkey, so distinct decoy keys should survive + — prove it with a test rather than assuming, because the decoy defence depends on it +- [ ] Port the intent types (`OnchainIntent`, `IntentRecipient`, `IntentAsset`, `IntentUtxos`) + and the verdict types (`CheckName`, `CheckResult`, `VerifyVerdict`) with the same five + check names: `inputs-own`, `recipients-match`, `change-own`, `fee-budget`, `opret-zero` +- [ ] Port `own_derivation`: select the key-origin entry **by our master fingerprint**, never by + path shape; expose it for Task 5 to reuse; carry the reasoning into the doc comment +- [ ] **Check 1 (`inputs-own`)** — every input has a `witness_utxo`, a key origin with our + fingerprint, a path re-deriving from **our** xpubs to the **exact** script being spent, + and (when present) a matching `tap_internal_key`; zero inputs fails +- [ ] **Check 2 (`recipients-match`)** — re-derive the expected script **from the recipient + address**, cross-check the intent's `scriptHex` against it, then match recipients against + outputs as a **multiset** (each output consumed at most once); for witness asset sends + require **exactly one** unaccounted, non-own, non-OP_RETURN output at exactly the + approved `witnessAmountSat` +- [ ] **Check 3 (`change-own`)** — every output that is not an accounted recipient, the + approved witness output, or an OP_RETURN must prove ownership. Preserve the asymmetry: + own **outputs** must be keychain 0 at a bounded index; own **inputs** are unrestricted. + Keep the lazy scan-window fallback for metadata-less change, derived only on demand +- [ ] **Check 4 (`fee-budget`)** — `fee = inputs − outputs`, ok only when `0 < fee ≤ max_fee_sat` +- [ ] **Check 5 (`opret-zero`)** — every OP_RETURN output carries 0 sats +- [ ] `verify_psbt` must **never** return an error and never panic: any parse or derivation + failure yields a verdict with **all five** checks failed and the reason in `detail` +- [ ] Tests, each asserting the *specific* check that fails: happy path on + `signing.unsignedPsbt` passes all five; a foreign input (fingerprint from + `otherMnemonic`) fails only `inputs-own`; a tampered recipient amount and a tampered + recipient address each fail only `recipients-match`; an intent whose `scriptHex` + contradicts its own address fails `recipients-match`; an unaccounted foreign output fails + only `change-own`; an own-looking output at keychain 1 or beyond the index bound fails + `change-own`; a fee above budget and a negative fee each fail only `fee-budget`; a funded + OP_RETURN fails only `opret-zero`; **the decoy test** — a foreign-fingerprint key-origin + entry present alongside ours must not divert selection; truncated and garbage PSBT bytes + return all-five-failed with no panic and no `Err` +- [ ] Run the gate — must pass before Task 5 + +### Task 5: Sign and finalize + +**Files:** +- Create: `src/sign.rs`; extend `tests/parity.rs` + +- [ ] Port `verify_and_sign_psbt(keys, psbt_base64, params)`: run `verify_psbt` **inside** this + function and return `SdkError::VerificationFailed` (carrying the failed check names) when + the verdict is not ok. There must be **no** exported entry point that signs without + verifying — no flag, no second function, no `pub` helper that skips it +- [ ] For each input, select the signing entry with `own_derivation` using our fingerprint — + **the same entry check 1 proved** — then derive via `private_key_for_path`; a missing key + is an error naming the input index, never a skip +- [ ] BIP-341 key-path sign every input, set `tap_key_sig`, finalize into + `final_script_witness`, and return the base64 signed+finalized PSBT, the txid and the verdict +- [ ] Tests: sign `signing.unsignedPsbt` and assert (a) the verdict is ok, (b) the txid equals + `signing.txid`, (c) exactly one 64-byte witness element, (d) **the signature verifies** as + BIP-340 over the BIP-341 sighash against the re-derived x-only key, and (e) rgb-lib's own + `signing.signedPsbt` parses to the same txid. **Do not** assert witness byte-equality + against `signing.witnessSignature` — BIP-340 signatures are randomized +- [ ] Tests (refusal paths): a tampered recipient amount returns `VerificationFailed` and + produces **no** signature; an input whose key-origin path is not ours errors; no error + message contains key material +- [ ] Run the gate — must pass before Task 6 + +### Task 6: Invoice decoding (BOLT-11 and RGB) + +**Files:** +- Create: `src/invoice.rs`; extend `tests/parity.rs` + +- [ ] Port `decode_bolt11`: bech32 decode with the length limit disabled, HRP → network and + amount (`m`/`u`/`n`/`p` multipliers over msat-per-BTC), tagged-field parsing, payee + recovery from the signature. Malformed input returns `SdkError::InvoiceDecode` — never a + panic; this is fed user-pasted strings +- [ ] Port `decode_rgb_invoice`, including the `~` omitted-field convention +- [ ] Prefer `bitcoin`'s existing bech32 and secp256k1 recovery over new dependencies; if a + dependency is genuinely required, record the size-gate delta in the progress notes +- [ ] Tests: decode `witnessReceive.invoice` and assert `recipientId` and + `expirationTimestamp` match the fixture; port the BOLT-11 vectors from + `packages/client-sdk/test/invoice.test.ts` — they are the reference — covering network, + amount (including a no-amount invoice), payment hash and recovered payee; malformed, + truncated and wrong-checksum input each return `InvoiceDecode` and nothing else +- [ ] Run the gate — must pass before Task 7 + +### Task 7: Gateway client, foreign HTTP transport, and intent binding + +**Files:** +- Create: `src/gateway.rs`; extend `tests/parity.rs` + +- [ ] Define the transport seam with `#[uniffi::export(with_foreign)] pub trait HttpTransport: + Send + Sync`, taking a request (method, path, headers, optional body) and returning a + response (status, body) or an error — modelled on `ExternalSignerHost` + (`src/uniffi_api/mod.rs:32`). Rust performs **no** network IO and links **no** HTTP or + TLS stack; the host supplies OkHttp/`URLSession`. The call is synchronous from Rust's + side, so there is no async-over-FFI +- [ ] Port `GatewayClient` over all **22** routes: `/v1/users`, `/v1/me`, `/v1/wallet/{xpubs, + address,balances,unspents,transfers,receive,sync}`, `/v1/onchain/{send-btc,send-asset, + create-utxos}/{prepare,complete}`, `/v1/ln/{deposit/prepare,pay,invoice,invoice/{hash}, + payments,balance,withdraw}`; bearer-token auth; an error carrying status and body +- [ ] Port **intent binding** for the three `prepare*` methods — the security core of this + task, not boilerplate. Assert every field the caller actually stated (recipient address + and amount, asset id/amount/recipient id, utxo shape), assert the shape negatives (no + asset on a send-btc intent, no recipients on an asset/utxo intent), assert caller-pinned + optional fields only when pinned, and return an error listing every mismatch. Carry over + the comment explaining that check 2 is vacuous without this +- [ ] Port idempotency-key generation as RFC-4122 v4 from a **cryptographic** RNG (`bitcoin` + already pulls `rand`; do not add a dependency), sent on the money-moving routes exactly + where the TS client sends it +- [ ] Secrets hygiene: assert in a test that no request body or header any method produces + contains the mnemonic, a seed or an `xprv`/`tprv`, and that the bearer token never + appears in an error message or `Debug` output +- [ ] Tests against a fake in-Rust `HttpTransport` (no network): one round-trip per route + asserting method, path, auth header and body shape; intent-mismatch errors for a swapped + recipient address, a changed amount, an asset smuggled onto a send-btc intent, an extra + recipient, and a foreign `witnessAmountSat`; status and body surface on 4xx/5xx; + idempotency keys are unique across calls and stable when supplied by the caller +- [ ] Run the gate — must pass before Task 8 + +### Task 8: Android packaging — bindings, JNI libs, AAR, 16 KB alignment + +**Files:** +- Create: `minimal-sdk/scripts/{generate_bindings.sh,build_android.sh}`, + `minimal-sdk/android/` (Gradle project, wrapper 8.9+, `build.gradle.kts`, + `consumer-rules.pro`, `src/main/AndroidManifest.xml`, + `src/test/kotlin/com/utexo/minimalsdk/SmokeTest.kt`) + +- [ ] Install an NDK via `sdkmanager` (none is present) and add `build_android.sh` modelled on + `scripts/ci/build_android_jni.sh`: `cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64`. + **Do not** copy that script's aws-lc cleanup — this crate has no aws-lc +- [ ] `generate_bindings.sh kotlin` wraps `uniffi-bindgen … --library` against the built + cdylib (library mode, no UDL), writing generated Kotlin into the Gradle source set +- [ ] Gradle project: `com.android.library`, AGP 8.7.3, Kotlin 1.9.24, JDK 17, + `compileSdk = 34`, `minSdk = 24`, namespace `com.utexo.minimalsdk`, **its own wrapper at + Gradle 8.9+**. Depends on JNA (uniffi requires it) and nothing else. ABI splits on, so a + consumer downloads one `.so` +- [ ] **16 KB page-alignment check** on every produced `.so`: assert each `PT_LOAD` segment + alignment is **≥ 16384**. Mandatory for native code on Android 15+ (extended deadline + **2026-05-31, already passed**), and the design doc notes nothing in this repo has ever + asserted it. A failure is a **release blocker** to record in the progress notes, with the + linker flag or NDK version that fixes it — do not ship a misaligned `.so` +- [ ] **Android size gate**: record the real **arm64** `.so` size (the Context table's 1.94 MB + is an x86_64 host figure) plus the AAR and JNA, and fail above **3.0 MB** for the single + arm64 slice. Note in the README that install size tracks the **raw**, not compressed, number +- [ ] `consumer-rules.pro` keeps only what R8 must not strip — the uniffi/JNA entry points. + **No** blanket `-keep class com.utexo.minimalsdk.** { *; }` +- [ ] Tests: a Kotlin **smoke** test through the generated bindings — restore from the fixture + mnemonic, assert the Regtest account xpubs and fingerprint match the fixture, derive + address index 0, and verify+sign the fixture PSBT to `signing.txid` using a Kotlin-side + `HttpTransport` stub for construction. Deep assertions stay in Rust; this proves the + binding surface, the JNI load and the `with_foreign` trait all work +- [ ] Run `JAVA_HOME=~/.jdks/temurin-17 ./gradlew -p minimal-sdk/android test` plus the + alignment and size checks — must pass before Task 9 + +### Task 9: Apple packaging — bindings, static libs, XCFramework, SwiftPM + +**Files:** +- Create: `minimal-sdk/scripts/build_apple.sh`, `minimal-sdk/apple/Package.swift`, + `minimal-sdk/apple/Tests/MinimalSdkTests/SmokeTests.swift` + +> **⚠️ Not verifiable on this machine.** XCFramework assembly needs macOS + Xcode. Write the +> scripts, the package manifest and the tests; verify on Linux everything that can be verified +> (Swift bindings generate from the built library, the scripts pass `bash -n`/`shellcheck`, the +> manifest is well-formed); mark the rest explicitly as CI-verified in Task 10 and human-signed +> in Post-Completion. **Do not claim a local pass.** + +- [ ] `generate_bindings.sh swift` in library mode, producing the Swift sources, the + `…FFI.h` header and the modulemap +- [ ] `build_apple.sh` modelled on `scripts/ci/package_swift_xcframework.sh`: build + `--release` **staticlib** for `aarch64-apple-ios` and the simulator targets, `lipo` the + simulator slices into one universal `.a`, then `xcodebuild -create-xcframework` with + device + simulator libraries and the headers directory +- [ ] `Package.swift` exposing a `binaryTarget` over the XCFramework plus a Swift target + carrying the generated bindings and a small `URLSession`-backed `HttpTransport` + conformance; iOS deployment target consistent with `minSdk`-equivalent policy and recorded + in the README +- [ ] Swift **smoke** test mirroring the Kotlin one: restore from the fixture mnemonic, assert + the Regtest xpubs/fingerprint, derive index 0, verify+sign the fixture PSBT to + `signing.txid` +- [ ] Note in the README that iOS links statically and dead-strips at app link, so the number + that matters is the **app-size delta**, not the `.a` size (the node's `.a` is 391.9 MB and + that figure has misled before) +- [ ] Verify locally what is verifiable: Swift bindings generate with no UDL; `bash -n` and + `shellcheck` pass on both scripts; `Package.swift` parses. Record in the progress notes + exactly which steps were **not** run and why +- [ ] Do not proceed to Task 10 until the above local checks pass + +### Task 10: CI for the mobile SDK + +**Files:** +- Create: `.github/workflows/minimal-sdk-mobile.yaml` + +- [ ] Workflow on push/PR **path-filtered** to `minimal-sdk/rust-sdk/**`, + `minimal-sdk/android/**`, `minimal-sdk/apple/**`, `minimal-sdk/scripts/**`, + `minimal-sdk/packages/client-sdk/test/fixtures/**` and the workflow file, plus + `workflow_dispatch`. Pin every action version; cache the cargo registry and the Gradle + user home +- [ ] Job 1 (ubuntu) — **the real gate**: `cargo test` for the Rust crate including the full + parity/adversarial suite, the size gate and the dependency-creep guard. Fast, no NDK +- [ ] Job 2 (ubuntu) — Android: NDK, `cargo ndk` for three ABIs, generate Kotlin bindings, + `./gradlew test`, the 16 KB alignment check, the arm64 size gate, `assembleRelease` +- [ ] Job 3 (**macos runner**) — Apple: Swift bindings, iOS + simulator static libs, + XCFramework, Swift smoke test. This is the job that first proves Task 9 +- [ ] The fixture path filter is deliberate: if `rgblib-parity.json` is regenerated the parity + suite must re-run. Say so in a comment in the workflow +- [ ] Tests: run Jobs 1 and 2's exact commands locally and confirm each exits zero on a clean + tree; then perturb one fixture value in the working tree, confirm the Rust parity suite + fails, and restore it — the gate must actually catch drift. Job 3 is verified by its + first CI run, not locally +- [ ] Run the local gates — must pass before Task 11 + +### Task 11: Verify acceptance criteria and update documentation + +**Files:** +- Create: `minimal-sdk/rust-sdk/README.md` +- Modify: `minimal-sdk/README.md`, `docs/design/mobile-sdk-and-lightweight-uniffi.md` + +- [ ] Full local gate green: `cargo test --manifest-path minimal-sdk/rust-sdk/Cargo.toml`, + `JAVA_HOME=~/.jdks/temurin-17 ./gradlew -p minimal-sdk/android test assembleRelease`, and + `pnpm -C minimal-sdk test` still green (this plan must not have touched the TS side — + confirm with `git status`) +- [ ] Verify acceptance item by item and record the evidence: all four networks' xpubs + + fingerprint match the fixture; all ten regtest addresses match; the fixture PSBT signs to + `signing.txid` with a verifying signature; all five checks fail individually on their own + tampering; the decoy test passes; intent binding fires on all five tamper shapes; **no** + `rgb-lib`/`reqwest`/`tokio`/TLS crate in the graph; the **measured arm64** `.so` size; the + 16 KB alignment result (pass, or the blocker); Kotlin smoke green; Swift smoke green **in + CI** (state the run) +- [ ] Write `minimal-sdk/rust-sdk/README.md`: quickstart (generate keys → register xpubs → + prepare → verify → sign → complete) with Kotlin **and** Swift snippets; the + verify-before-sign contract and what each of the five checks does; the **stated + limitation** that RGB allocations on spent inputs are not verifiable client-side + (`verify.ts:11-13`); the `HttpTransport` extension point; measured sizes for both + platforms with the raw-vs-compressed and app-size-delta notes +- [ ] **State the Block-3 mobile runtime constraints and this SDK's answer to each**: it is a + stateless client, so process death costs nothing beyond an in-flight request; every + binding call is **blocking** and must not run on the Android main thread or the iOS main + queue (say it plainly — the design doc flags ANR risk for the node SDK and it applies to + any uniffi surface); there is no node, so the fail-fast watchdog and battery/chain-backend + concerns do not apply. Being explicit about what does *not* apply is what makes the + minimal variant's case +- [ ] Update `docs/design/mobile-sdk-and-lightweight-uniffi.md`: record that Paths A2 and A3 + were **superseded by a single Rust core**, add the measured 1.94 MB host figure and the + measured arm64 figure, state the JNA cost honestly (≈2.46 MB vs ≈1.9 MB pure Kotlin) and + why implementation count won over ~500 KB, and record the 16 KB alignment finding in + Block 3 — the first time anything in this repo asserts it +- [ ] Update `minimal-sdk/README.md`: add the Rust core and both mobile packages to the + architecture section; note `pnpm-workspace.yaml` is unaffected; state that the TypeScript + SDK remains hand-written and is the **independent cross-check** on the Rust + implementation, not legacy to be replaced +- [ ] Confirm `git status` shows only files this plan names, and that no Rust file under + `src/`, no file under `bindings/`, no `packages/` file and no fixture was modified + +## Post-Completion (manual, not for the agent) + +- **Sign off the Apple path on real hardware.** Task 9 was written but never built locally, and + Task 10's macOS job only proves it assembles. Run the Swift smoke on a device and an iOS + simulator before any release. +- Build a throwaway consumer app on each platform with shrinking enabled and measure the + **app-size delta** — the number users feel, and the only meaningful iOS number. The task + gates measure artifacts, not deltas. +- Run one real end-to-end journey against a staging gateway from an Android device and an + iPhone: register xpubs, prepare a send, verify, sign, complete. The suites here are entirely + offline by design and prove parity, not integration. +- If the 16 KB alignment check failed, schedule the fix before any release targeting Android 15+. +- Decide publishing: Maven Central coordinates for the Android artifact and whether the Swift + package ships as a tagged repo or a binary release. Nothing is published yet, so names are + still free; coordinate with the node SDK's `rgb-lightning-node-android` line so the two are + not confused. +- Decide whether the React Native SDK (`rgb-sdk-rn`) should adopt this Rust core via a native + addon or stay on the TypeScript client SDK. Staying on TS preserves the independent + cross-check this plan deliberately keeps; adopting the core would remove it. +- Re-measure the uniffi **node** numbers on real arm64 before quoting them externally (design + doc Appendix C): every figure there is an x86_64 host build. From d58def557dd5a3633f678aae2b12a0db3538a9c6 Mon Sep 17 00:00:00 2001 From: rmn-boiko Date: Tue, 15 Sep 2026 18:05:27 +0200 Subject: [PATCH 2/9] Implement mobile monimal sdk --- .github/workflows/minimal-sdk-mobile.yaml | 261 + CLAUDE.md | 19 + README.md | 7 + .../mobile-sdk-and-lightweight-uniffi.md | 298 - .../plans/20260915-minimal-mobile-rust-sdk.md | 580 -- minimal-sdk/.gitignore | 4 + minimal-sdk/.prettierignore | 16 + minimal-sdk/.prettierrc.json | 5 + minimal-sdk/README.md | 177 + minimal-sdk/android/.gitignore | 6 + minimal-sdk/android/README.md | 92 + minimal-sdk/android/build.gradle.kts | 171 + minimal-sdk/android/consumer-rules.pro | 27 + minimal-sdk/android/gradle.properties | 3 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43504 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + minimal-sdk/android/gradlew | 252 + minimal-sdk/android/gradlew.bat | 94 + minimal-sdk/android/settings.gradle.kts | 32 + .../android/src/main/AndroidManifest.xml | 2 + .../kotlin/com/utexo/minimalsdk/SmokeTest.kt | 302 + minimal-sdk/apple/.gitignore | 7 + minimal-sdk/apple/Package.swift | 57 + minimal-sdk/apple/README.md | 100 + .../MinimalSdk/URLSessionHttpTransport.swift | 102 + .../Tests/MinimalSdkTests/SmokeTests.swift | 507 ++ minimal-sdk/eslint.config.mjs | 62 + minimal-sdk/package.json | 33 + minimal-sdk/packages/client-sdk/README.md | 111 + minimal-sdk/packages/client-sdk/package.json | 27 + .../scripts/generate-rgblib-fixtures.mjs | 175 + minimal-sdk/packages/client-sdk/src/derive.ts | 117 + .../packages/client-sdk/src/gateway.ts | 566 ++ minimal-sdk/packages/client-sdk/src/index.ts | 81 + .../packages/client-sdk/src/invoice.ts | 316 ++ minimal-sdk/packages/client-sdk/src/keys.ts | 90 + .../packages/client-sdk/src/network.ts | 57 + minimal-sdk/packages/client-sdk/src/sign.ts | 91 + minimal-sdk/packages/client-sdk/src/verify.ts | 383 ++ .../client-sdk/test/bundle-size.test.ts | 30 + .../client-sdk/test/derivation.test.ts | 96 + .../test/fixtures/rgblib-parity.json | 58 + .../packages/client-sdk/test/gateway.test.ts | 454 ++ .../packages/client-sdk/test/helpers.ts | 159 + .../packages/client-sdk/test/invoice.test.ts | 137 + .../packages/client-sdk/test/sign.test.ts | 208 + .../packages/client-sdk/test/smoke.test.ts | 8 + .../packages/client-sdk/test/verify.test.ts | 583 ++ minimal-sdk/packages/client-sdk/tsconfig.json | 11 + minimal-sdk/packages/e2e/README.md | 54 + minimal-sdk/packages/e2e/package.json | 21 + minimal-sdk/packages/e2e/src/flow.test.ts | 411 ++ minimal-sdk/packages/e2e/src/index.ts | 8 + minimal-sdk/packages/e2e/src/setup.ts | 448 ++ minimal-sdk/packages/e2e/test/smoke.test.ts | 8 + minimal-sdk/packages/e2e/tsconfig.json | 9 + minimal-sdk/packages/gateway/README.md | 267 + minimal-sdk/packages/gateway/package.json | 36 + .../gateway/scripts/generate-rln-types.mjs | 105 + minimal-sdk/packages/gateway/src/auth.ts | 83 + minimal-sdk/packages/gateway/src/config.ts | 161 + minimal-sdk/packages/gateway/src/db.ts | 164 + minimal-sdk/packages/gateway/src/errors.ts | 17 + .../packages/gateway/src/idempotency.ts | 185 + minimal-sdk/packages/gateway/src/index.ts | 119 + minimal-sdk/packages/gateway/src/ledger.ts | 184 + minimal-sdk/packages/gateway/src/main.ts | 27 + minimal-sdk/packages/gateway/src/queue.ts | 92 + .../packages/gateway/src/rln/client.ts | 254 + .../packages/gateway/src/rln/openapi.ts | 1349 +++++ .../packages/gateway/src/rln/scoping.ts | 129 + minimal-sdk/packages/gateway/src/rln/types.ts | 54 + minimal-sdk/packages/gateway/src/routes/ln.ts | 1063 ++++ .../packages/gateway/src/routes/onchain.ts | 92 + .../packages/gateway/src/routes/wallet.ts | 80 + .../packages/gateway/src/schemas/index.ts | 73 + .../packages/gateway/src/schemas/ln.ts | 246 + .../packages/gateway/src/schemas/onchain.ts | 189 + .../packages/gateway/src/schemas/scan.ts | 77 + .../packages/gateway/src/schemas/wallet.ts | 233 + minimal-sdk/packages/gateway/src/server.ts | 244 + .../packages/gateway/src/wallets/backend.ts | 194 + .../packages/gateway/src/wallets/pool.ts | 94 + .../packages/gateway/src/wallets/prepare.ts | 432 ++ .../packages/gateway/src/wallets/queue.ts | 27 + .../packages/gateway/src/wallets/rgblib.ts | 573 ++ .../packages/gateway/src/wallets/service.ts | 201 + .../packages/gateway/src/workers/deposits.ts | 264 + .../gateway/src/workers/reconciler.ts | 286 + .../packages/gateway/test/auth.test.ts | 85 + .../packages/gateway/test/config.test.ts | 98 + minimal-sdk/packages/gateway/test/db.test.ts | 140 + .../gateway/test/deposits-worker.test.ts | 365 ++ .../packages/gateway/test/fixture-signer.ts | 37 + minimal-sdk/packages/gateway/test/helpers.ts | 85 + .../gateway/test/i1-schema-scan.test.ts | 56 + .../packages/gateway/test/idempotency.test.ts | 343 ++ .../packages/gateway/test/ledger.test.ts | 140 + minimal-sdk/packages/gateway/test/ln-mocks.ts | 136 + .../packages/gateway/test/ln-routes.test.ts | 1318 +++++ .../packages/gateway/test/logging.test.ts | 83 + .../gateway/test/onchain-integration.test.ts | 367 ++ .../gateway/test/onchain-routes.test.ts | 550 ++ .../gateway/test/onchain-signer.test.ts | 197 + .../packages/gateway/test/queue.test.ts | 95 + .../packages/gateway/test/reconciler.test.ts | 439 ++ .../gateway/test/rgblib-mapping.test.ts | 217 + .../packages/gateway/test/rln-client.test.ts | 204 + .../packages/gateway/test/rln-scoping.test.ts | 166 + .../packages/gateway/test/smoke.test.ts | 8 + .../gateway/test/wallet-integration.test.ts | 129 + .../packages/gateway/test/wallet-mocks.ts | 180 + .../packages/gateway/test/wallet-pool.test.ts | 133 + .../gateway/test/wallet-queue.test.ts | 83 + .../gateway/test/wallet-routes.test.ts | 411 ++ minimal-sdk/packages/gateway/tsconfig.json | 9 + minimal-sdk/pnpm-lock.yaml | 3569 ++++++++++++ minimal-sdk/pnpm-workspace.yaml | 2 + minimal-sdk/rust-sdk/.gitignore | 1 + minimal-sdk/rust-sdk/Cargo.lock | 558 ++ minimal-sdk/rust-sdk/Cargo.toml | 46 + minimal-sdk/rust-sdk/README.md | 353 ++ minimal-sdk/rust-sdk/src/derive.rs | 363 ++ minimal-sdk/rust-sdk/src/ffi.rs | 110 + minimal-sdk/rust-sdk/src/gateway.rs | 1798 ++++++ minimal-sdk/rust-sdk/src/invoice.rs | 608 ++ minimal-sdk/rust-sdk/src/json.rs | 541 ++ minimal-sdk/rust-sdk/src/keys.rs | 246 + minimal-sdk/rust-sdk/src/lib.rs | 165 + minimal-sdk/rust-sdk/src/network.rs | 223 + minimal-sdk/rust-sdk/src/sign.rs | 298 + minimal-sdk/rust-sdk/src/verify.rs | 664 +++ minimal-sdk/rust-sdk/tests/parity.rs | 4868 +++++++++++++++++ minimal-sdk/rust-sdk/uniffi.toml | 11 + minimal-sdk/scripts/build_android.sh | 185 + minimal-sdk/scripts/build_apple.sh | 136 + minimal-sdk/scripts/generate_bindings.sh | 134 + minimal-sdk/scripts/preflight-a.cjs | 187 + minimal-sdk/tsconfig.base.json | 23 + 139 files changed, 35819 insertions(+), 878 deletions(-) create mode 100644 .github/workflows/minimal-sdk-mobile.yaml delete mode 100644 docs/design/mobile-sdk-and-lightweight-uniffi.md delete mode 100644 docs/plans/20260915-minimal-mobile-rust-sdk.md create mode 100644 minimal-sdk/.gitignore create mode 100644 minimal-sdk/.prettierignore create mode 100644 minimal-sdk/.prettierrc.json create mode 100644 minimal-sdk/README.md create mode 100644 minimal-sdk/android/.gitignore create mode 100644 minimal-sdk/android/README.md create mode 100644 minimal-sdk/android/build.gradle.kts create mode 100644 minimal-sdk/android/consumer-rules.pro create mode 100644 minimal-sdk/android/gradle.properties create mode 100644 minimal-sdk/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 minimal-sdk/android/gradle/wrapper/gradle-wrapper.properties create mode 100755 minimal-sdk/android/gradlew create mode 100644 minimal-sdk/android/gradlew.bat create mode 100644 minimal-sdk/android/settings.gradle.kts create mode 100644 minimal-sdk/android/src/main/AndroidManifest.xml create mode 100644 minimal-sdk/android/src/test/kotlin/com/utexo/minimalsdk/SmokeTest.kt create mode 100644 minimal-sdk/apple/.gitignore create mode 100644 minimal-sdk/apple/Package.swift create mode 100644 minimal-sdk/apple/README.md create mode 100644 minimal-sdk/apple/Sources/MinimalSdk/URLSessionHttpTransport.swift create mode 100644 minimal-sdk/apple/Tests/MinimalSdkTests/SmokeTests.swift create mode 100644 minimal-sdk/eslint.config.mjs create mode 100644 minimal-sdk/package.json create mode 100644 minimal-sdk/packages/client-sdk/README.md create mode 100644 minimal-sdk/packages/client-sdk/package.json create mode 100644 minimal-sdk/packages/client-sdk/scripts/generate-rgblib-fixtures.mjs create mode 100644 minimal-sdk/packages/client-sdk/src/derive.ts create mode 100644 minimal-sdk/packages/client-sdk/src/gateway.ts create mode 100644 minimal-sdk/packages/client-sdk/src/index.ts create mode 100644 minimal-sdk/packages/client-sdk/src/invoice.ts create mode 100644 minimal-sdk/packages/client-sdk/src/keys.ts create mode 100644 minimal-sdk/packages/client-sdk/src/network.ts create mode 100644 minimal-sdk/packages/client-sdk/src/sign.ts create mode 100644 minimal-sdk/packages/client-sdk/src/verify.ts create mode 100644 minimal-sdk/packages/client-sdk/test/bundle-size.test.ts create mode 100644 minimal-sdk/packages/client-sdk/test/derivation.test.ts create mode 100644 minimal-sdk/packages/client-sdk/test/fixtures/rgblib-parity.json create mode 100644 minimal-sdk/packages/client-sdk/test/gateway.test.ts create mode 100644 minimal-sdk/packages/client-sdk/test/helpers.ts create mode 100644 minimal-sdk/packages/client-sdk/test/invoice.test.ts create mode 100644 minimal-sdk/packages/client-sdk/test/sign.test.ts create mode 100644 minimal-sdk/packages/client-sdk/test/smoke.test.ts create mode 100644 minimal-sdk/packages/client-sdk/test/verify.test.ts create mode 100644 minimal-sdk/packages/client-sdk/tsconfig.json create mode 100644 minimal-sdk/packages/e2e/README.md create mode 100644 minimal-sdk/packages/e2e/package.json create mode 100644 minimal-sdk/packages/e2e/src/flow.test.ts create mode 100644 minimal-sdk/packages/e2e/src/index.ts create mode 100644 minimal-sdk/packages/e2e/src/setup.ts create mode 100644 minimal-sdk/packages/e2e/test/smoke.test.ts create mode 100644 minimal-sdk/packages/e2e/tsconfig.json create mode 100644 minimal-sdk/packages/gateway/README.md create mode 100644 minimal-sdk/packages/gateway/package.json create mode 100644 minimal-sdk/packages/gateway/scripts/generate-rln-types.mjs create mode 100644 minimal-sdk/packages/gateway/src/auth.ts create mode 100644 minimal-sdk/packages/gateway/src/config.ts create mode 100644 minimal-sdk/packages/gateway/src/db.ts create mode 100644 minimal-sdk/packages/gateway/src/errors.ts create mode 100644 minimal-sdk/packages/gateway/src/idempotency.ts create mode 100644 minimal-sdk/packages/gateway/src/index.ts create mode 100644 minimal-sdk/packages/gateway/src/ledger.ts create mode 100644 minimal-sdk/packages/gateway/src/main.ts create mode 100644 minimal-sdk/packages/gateway/src/queue.ts create mode 100644 minimal-sdk/packages/gateway/src/rln/client.ts create mode 100644 minimal-sdk/packages/gateway/src/rln/openapi.ts create mode 100644 minimal-sdk/packages/gateway/src/rln/scoping.ts create mode 100644 minimal-sdk/packages/gateway/src/rln/types.ts create mode 100644 minimal-sdk/packages/gateway/src/routes/ln.ts create mode 100644 minimal-sdk/packages/gateway/src/routes/onchain.ts create mode 100644 minimal-sdk/packages/gateway/src/routes/wallet.ts create mode 100644 minimal-sdk/packages/gateway/src/schemas/index.ts create mode 100644 minimal-sdk/packages/gateway/src/schemas/ln.ts create mode 100644 minimal-sdk/packages/gateway/src/schemas/onchain.ts create mode 100644 minimal-sdk/packages/gateway/src/schemas/scan.ts create mode 100644 minimal-sdk/packages/gateway/src/schemas/wallet.ts create mode 100644 minimal-sdk/packages/gateway/src/server.ts create mode 100644 minimal-sdk/packages/gateway/src/wallets/backend.ts create mode 100644 minimal-sdk/packages/gateway/src/wallets/pool.ts create mode 100644 minimal-sdk/packages/gateway/src/wallets/prepare.ts create mode 100644 minimal-sdk/packages/gateway/src/wallets/queue.ts create mode 100644 minimal-sdk/packages/gateway/src/wallets/rgblib.ts create mode 100644 minimal-sdk/packages/gateway/src/wallets/service.ts create mode 100644 minimal-sdk/packages/gateway/src/workers/deposits.ts create mode 100644 minimal-sdk/packages/gateway/src/workers/reconciler.ts create mode 100644 minimal-sdk/packages/gateway/test/auth.test.ts create mode 100644 minimal-sdk/packages/gateway/test/config.test.ts create mode 100644 minimal-sdk/packages/gateway/test/db.test.ts create mode 100644 minimal-sdk/packages/gateway/test/deposits-worker.test.ts create mode 100644 minimal-sdk/packages/gateway/test/fixture-signer.ts create mode 100644 minimal-sdk/packages/gateway/test/helpers.ts create mode 100644 minimal-sdk/packages/gateway/test/i1-schema-scan.test.ts create mode 100644 minimal-sdk/packages/gateway/test/idempotency.test.ts create mode 100644 minimal-sdk/packages/gateway/test/ledger.test.ts create mode 100644 minimal-sdk/packages/gateway/test/ln-mocks.ts create mode 100644 minimal-sdk/packages/gateway/test/ln-routes.test.ts create mode 100644 minimal-sdk/packages/gateway/test/logging.test.ts create mode 100644 minimal-sdk/packages/gateway/test/onchain-integration.test.ts create mode 100644 minimal-sdk/packages/gateway/test/onchain-routes.test.ts create mode 100644 minimal-sdk/packages/gateway/test/onchain-signer.test.ts create mode 100644 minimal-sdk/packages/gateway/test/queue.test.ts create mode 100644 minimal-sdk/packages/gateway/test/reconciler.test.ts create mode 100644 minimal-sdk/packages/gateway/test/rgblib-mapping.test.ts create mode 100644 minimal-sdk/packages/gateway/test/rln-client.test.ts create mode 100644 minimal-sdk/packages/gateway/test/rln-scoping.test.ts create mode 100644 minimal-sdk/packages/gateway/test/smoke.test.ts create mode 100644 minimal-sdk/packages/gateway/test/wallet-integration.test.ts create mode 100644 minimal-sdk/packages/gateway/test/wallet-mocks.ts create mode 100644 minimal-sdk/packages/gateway/test/wallet-pool.test.ts create mode 100644 minimal-sdk/packages/gateway/test/wallet-queue.test.ts create mode 100644 minimal-sdk/packages/gateway/test/wallet-routes.test.ts create mode 100644 minimal-sdk/packages/gateway/tsconfig.json create mode 100644 minimal-sdk/pnpm-lock.yaml create mode 100644 minimal-sdk/pnpm-workspace.yaml create mode 100644 minimal-sdk/rust-sdk/.gitignore create mode 100644 minimal-sdk/rust-sdk/Cargo.lock create mode 100644 minimal-sdk/rust-sdk/Cargo.toml create mode 100644 minimal-sdk/rust-sdk/README.md create mode 100644 minimal-sdk/rust-sdk/src/derive.rs create mode 100644 minimal-sdk/rust-sdk/src/ffi.rs create mode 100644 minimal-sdk/rust-sdk/src/gateway.rs create mode 100644 minimal-sdk/rust-sdk/src/invoice.rs create mode 100644 minimal-sdk/rust-sdk/src/json.rs create mode 100644 minimal-sdk/rust-sdk/src/keys.rs create mode 100644 minimal-sdk/rust-sdk/src/lib.rs create mode 100644 minimal-sdk/rust-sdk/src/network.rs create mode 100644 minimal-sdk/rust-sdk/src/sign.rs create mode 100644 minimal-sdk/rust-sdk/src/verify.rs create mode 100644 minimal-sdk/rust-sdk/tests/parity.rs create mode 100644 minimal-sdk/rust-sdk/uniffi.toml create mode 100755 minimal-sdk/scripts/build_android.sh create mode 100755 minimal-sdk/scripts/build_apple.sh create mode 100755 minimal-sdk/scripts/generate_bindings.sh create mode 100644 minimal-sdk/scripts/preflight-a.cjs create mode 100644 minimal-sdk/tsconfig.base.json diff --git a/.github/workflows/minimal-sdk-mobile.yaml b/.github/workflows/minimal-sdk-mobile.yaml new file mode 100644 index 00000000..cd57c893 --- /dev/null +++ b/.github/workflows/minimal-sdk-mobile.yaml @@ -0,0 +1,261 @@ +name: Minimal SDK Mobile + +# CI for the minimal mobile SDK (docs/plans/20260915-minimal-mobile-rust-sdk.md, +# Task 10): one small Rust crate (minimal-sdk/rust-sdk) exposed to Kotlin and +# Swift through uniffi. It shares no code with the node, so it runs only when +# its own inputs change. +# +# The fixture path filter is deliberate. minimal-sdk/packages/client-sdk/test/ +# fixtures/rgblib-parity.json is the rgb-lib-authored ground truth the Rust +# parity suite reads by relative path. If it is regenerated, the parity suite +# (and the Kotlin/Swift smoke tests, which read the same file) MUST re-run, +# even though no Rust source changed. +# +# bindings/uniffi-bindgen is the tool that generates the Kotlin and Swift +# bindings for jobs 2 and 3; a change there can break both packages. +# +# Every action is pinned to a commit SHA (the tag is in the comment). + +on: + push: + branches: ["master", "dev", "stage"] + paths: + - "minimal-sdk/rust-sdk/**" + - "minimal-sdk/android/**" + - "minimal-sdk/apple/**" + - "minimal-sdk/scripts/**" + - "minimal-sdk/packages/client-sdk/test/fixtures/**" + - "bindings/uniffi-bindgen/**" + - ".github/workflows/minimal-sdk-mobile.yaml" + pull_request: + branches: ["master", "dev", "stage"] + paths: + - "minimal-sdk/rust-sdk/**" + - "minimal-sdk/android/**" + - "minimal-sdk/apple/**" + - "minimal-sdk/scripts/**" + - "minimal-sdk/packages/client-sdk/test/fixtures/**" + - "bindings/uniffi-bindgen/**" + - ".github/workflows/minimal-sdk-mobile.yaml" + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + # Matches rust-version in minimal-sdk/rust-sdk/Cargo.toml and the toolchain + # the other workflows pin. + RUST_TOOLCHAIN: "1.94.0" + RUST_SDK_MANIFEST: minimal-sdk/rust-sdk/Cargo.toml + +jobs: + # --------------------------------------------------------------------------- + # Job 1: the real gate. Full parity + adversarial suite, the release-size + # gate and the dependency-creep guard (tests/parity.rs). Host only, no NDK. + # --------------------------------------------------------------------------- + rust-core: + name: Rust core (parity, size gate, dependency guard) + runs-on: warp-ubuntu-latest-x64-16x + timeout-minutes: 20 + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + components: rustfmt, clippy + rustflags: "" + # The crate is its own workspace; the action's built-in cache keys + # on the root Cargo.lock, so cache explicitly below instead. + cache: false + + - name: Cache cargo registry and build + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: minimal-sdk/rust-sdk -> target + key: rust-core + + - name: Format check + run: cargo fmt --manifest-path "$RUST_SDK_MANIFEST" --check + + - name: Clippy + run: cargo clippy --locked --manifest-path "$RUST_SDK_MANIFEST" --all-targets -- -D warnings + + - name: Test (parity suite, size gate, dependency-creep guard) + # --locked: Cargo.lock is the resolved graph the dependency-creep + # guard inspects; a silent re-resolution would defeat it. + run: cargo test --locked --manifest-path "$RUST_SDK_MANIFEST" + + # --------------------------------------------------------------------------- + # Job 2: Android. NDK, cargo ndk for the three ABIs, Kotlin bindings, + # ./gradlew test (JVM smoke test through JNA against the host cdylib), + # then assembleRelease, which runs minimal-sdk/scripts/build_android.sh + # (16 KB PT_LOAD alignment on every .so, exported uniffi entry points, + # arm64 raw-size budget) before bundling the AAR. The gates are re-printed + # as their own step so a failure is readable without digging through + # Gradle output. + # --------------------------------------------------------------------------- + android: + name: Android (JNI libs, 16 KB alignment, size gate, AAR) + runs-on: warp-ubuntu-latest-x64-16x + timeout-minutes: 45 + env: + # Consumed by nttld/setup-ndk's `ndk-version`; the resulting path is + # exported as ANDROID_NDK_ROOT/ANDROID_NDK_HOME below, which is what + # build_android.sh reads. + NDK_VERSION: r29 + ANDROID_PLATFORM: "platforms;android-34" + CARGO_NDK_VERSION: 4.1.2 + JAVA_VERSION: "17" + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + target: aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android + rustflags: "" + cache: false + + - name: Cache cargo registry and build + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + # rust-sdk: host + three Android targets. uniffi-bindgen: the + # binding generator, built with `cargo run` by generate_bindings.sh. + workspaces: | + minimal-sdk/rust-sdk -> target + bindings/uniffi-bindgen -> target + key: android + + - name: Setup Java + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + + - name: Cache Gradle user home + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('minimal-sdk/android/gradle/wrapper/gradle-wrapper.properties', 'minimal-sdk/android/**/*.gradle.kts', 'minimal-sdk/android/gradle.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Setup Android NDK + id: setup-ndk + uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1.6.0 + with: + ndk-version: ${{ env.NDK_VERSION }} + add-to-path: false + link-to-sdk: true + + - name: Install Android platform and verify the NDK + env: + NDK_PATH: ${{ steps.setup-ndk.outputs.ndk-path }} + # compileSdk 34 is installed explicitly, as release-sdk.yaml and + # sdk-e2e.yaml do, rather than assumed to be on the runner image. + run: | + set -euo pipefail + : "${ANDROID_HOME:?ANDROID_HOME must be set (Android SDK root)}" + SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + [ -x "$SDKMANAGER" ] || SDKMANAGER="$(command -v sdkmanager)" + yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true + "$SDKMANAGER" --install "$ANDROID_PLATFORM" >/dev/null + test -d "$ANDROID_HOME/platforms/android-34" + test -f "$NDK_PATH/source.properties" + sed -n 's/^Pkg.Revision = /NDK /p' "$NDK_PATH/source.properties" + { + echo "ANDROID_NDK_ROOT=$NDK_PATH" + echo "ANDROID_NDK_HOME=$NDK_PATH" + } >> "$GITHUB_ENV" + + - name: Install cargo-ndk + run: | + set -euo pipefail + if cargo ndk --version 2>/dev/null | grep -q "cargo-ndk $CARGO_NDK_VERSION"; then + echo "cargo-ndk $CARGO_NDK_VERSION already installed (cache)" + else + cargo install cargo-ndk --version "$CARGO_NDK_VERSION" --locked + fi + cargo ndk --version + + - name: Gradle test and assembleRelease + # `test` builds the host cdylib and generates the Kotlin bindings + # itself (uniffi library mode, no UDL) and runs the Kotlin smoke test. + # `assembleRelease` runs build_android.sh (cargo ndk for the three + # ABIs + the alignment and size gates) before bundling the AAR. + run: ./minimal-sdk/android/gradlew -p minimal-sdk/android --no-daemon --console=plain test assembleRelease + + - name: 16 KB alignment, exported entry points and arm64 size gate + run: ./minimal-sdk/scripts/build_android.sh --check-only minimal-sdk/android/generated/jniLibs + + - name: Upload Android artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: minimal-sdk-android + if-no-files-found: error + path: | + minimal-sdk/android/build/outputs/aar/*.aar + minimal-sdk/android/generated/jniLibs/** + minimal-sdk/android/generated/kotlin/** + + # --------------------------------------------------------------------------- + # Job 3: Apple. This is the job that first proves Task 9: the plan's dev + # machine has no Xcode, so the static libraries, lipo, the XCFramework and + # the Swift smoke test on the real XCFramework were never run locally. + # build_apple.sh builds release staticlibs for the iOS device target, the + # two simulator targets and the two macOS targets, generates the Swift + # bindings, lipos the simulator and macOS slices, runs + # `xcodebuild -create-xcframework` and checks the device slice exports the + # uniffi entry points. `swift test` then runs the smoke test on the macOS + # slice through the package's binaryTarget. + # --------------------------------------------------------------------------- + apple: + name: Apple (XCFramework, Swift smoke test) + runs-on: warp-macos-latest-arm64-6x + timeout-minutes: 45 + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + target: aarch64-apple-ios, aarch64-apple-ios-sim, x86_64-apple-ios, aarch64-apple-darwin, x86_64-apple-darwin + rustflags: "" + cache: false + + - name: Cache cargo registry and build + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: | + minimal-sdk/rust-sdk -> target + bindings/uniffi-bindgen -> target + key: apple + + - name: Toolchain versions + run: | + xcodebuild -version + swift --version + rustc --version + + - name: Build static libraries, Swift bindings and XCFramework + run: ./minimal-sdk/scripts/build_apple.sh + + - name: Swift smoke test (macOS slice of the XCFramework) + run: swift test --package-path minimal-sdk/apple + + - name: Upload Apple artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: minimal-sdk-apple + if-no-files-found: error + path: | + minimal-sdk/apple/MinimalSdkFFI.xcframework/** + minimal-sdk/apple/Sources/MinimalSdk/Generated/** + minimal-sdk/apple/Sources/MinimalSdkFFI/** diff --git a/CLAUDE.md b/CLAUDE.md index 4335af11..bde6b03c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,3 +16,22 @@ Repository conventions: - Prefer explicit errors over silent fallback behavior. - Avoid introducing breaking API changes unless the PR explicitly requires it. - Keep CI/workflow changes conservative and deterministic. + +Minimal mobile SDK (`minimal-sdk/rust-sdk`, `minimal-sdk/android`, `minimal-sdk/apple`): + +- A separate Cargo workspace with its own `Cargo.lock`; the root `cargo` commands do not + build it. Test with `cargo test --manifest-path minimal-sdk/rust-sdk/Cargo.toml`; + Android with `./minimal-sdk/android/gradlew -p minimal-sdk/android test assembleRelease`; + Apple with `minimal-sdk/scripts/build_apple.sh` (macOS only) and + `swift test --package-path minimal-sdk/apple`. +- Invariants to enforce in review: uniffi proc-macros only (no `.udl`); dependencies are + `bitcoin`, `bip39`, `thiserror`, `uniffi` and nothing else (no `rgb-lib`, HTTP, TLS or + async runtime; the release cdylib has a size gate in `tests/parity.rs`); + `verify_and_sign_psbt` is the only signing entry point and runs `verify_psbt` first; no + `SdkError` variant, `Debug` impl or log line may render a mnemonic, seed, xprv, private + key or bearer token; every exported function returns `SdkResult` and no panic crosses + the FFI, including from a foreign `HttpTransport` that throws. +- The parity fixture `minimal-sdk/packages/client-sdk/test/fixtures/rgblib-parity.json` + is rgb-lib-authored ground truth: read by relative path, never copied or regenerated + here. Changes to the mobile SDK must not touch `src/`, `bindings/` or + `minimal-sdk/packages/`. diff --git a/README.md b/README.md index f164b65f..b2c15f78 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,13 @@ docker build -t rgb-lightning-node . See [UniFFI SDK documentation](src/uniffi_api/README.md) for setup, runtime model, binding generation, test commands, and artifact packaging details. +That node SDK is not the only uniffi surface in this repository. +[`minimal-sdk/rust-sdk`](minimal-sdk/rust-sdk/README.md) is a separate, node-free Rust +crate (keys, taproot derivation, verify-before-sign, signing, invoice decoding and a +gateway client) packaged for Android (`minimal-sdk/android`) and iOS (`minimal-sdk/apple`). +It is its own Cargo workspace, built and tested by +`.github/workflows/minimal-sdk-mobile.yaml`, not by the root `cargo` commands. + ## AI code review The repository supports AI-assisted pull request reviews. Setup details for Claude, Codex, and extendable provider onboarding are documented in diff --git a/docs/design/mobile-sdk-and-lightweight-uniffi.md b/docs/design/mobile-sdk-and-lightweight-uniffi.md deleted file mode 100644 index 88eb0e54..00000000 --- a/docs/design/mobile-sdk-and-lightweight-uniffi.md +++ /dev/null @@ -1,298 +0,0 @@ -# Mobile SDK: minimal client + lightweight uniffi node - -Companion to `docs/design/minimal-sdk-and-lightweight-rln.md`, which covered the web -(wasm) side. That doc's Block 1 names "browser/mobile app" as the SDK target but only ever -analysed the TypeScript and wasm artifacts; the uniffi mobile SDK was never measured. This -one does that, and applies the same two-variant split to mobile. - -All figures below were measured in this tree on 2026-09-14 (branch `dev`). Method and raw -output are in Appendix B. Numbers labelled *estimate* are derived, not measured. - -## Executive summary - -- **The mobile SDK ships the entire RLN daemon, not an SDK.** The uniffi cdylib is built - from the root crate with `mod routes;` unconditional (`src/lib.rs:48`), so the axum REST - server, both chain backends, both indexers, two ORMs and a **PostgreSQL driver** compile - into the library that runs on a phone — while `src/uniffi_api/README.md:3` states "REST - is **not** part of the SDK surface." -- **What CI uploads today is 99.0 MB, unstripped.** `uniffi-artifacts.yaml:59` copies - `target/release/librgb_lightning_node.so` straight into the artifact. Stripping alone - takes it to 73.4 MB. -- **The root `Cargo.toml` has no `[profile.release]`** — only `[profile.reldebug]` - (`Cargo.toml:181`). This is the same gap the wasm crate had. Adding the three-line - profile the wasm work already validated takes the library from **73.4 MB → 54.2 MB raw, - 28.3 MB → 20.1 MB gzip** (measured, not estimated). -- **Feature gating is worth about as much again**: dropping VLS, VSS, the second indexer - and the PostgreSQL driver reaches **46.6 MB / 17.2 MB gzip** — a 53% cut from today, - with no architectural change. The biggest code-level win still unmeasured is gating - `mod routes` and converging the duplicated sea-orm/sqlx stacks. -- **For most mobile apps, none of that matters**, because the Minimal SDK variant applies - unchanged: the existing TypeScript client SDK is **185.5 KB** and runs on React Native - today with one polyfill. A native Kotlin equivalent costs about **1.9 MB** per ABI. That - is ~28× smaller than the best tuned uniffi node and ~52× smaller than what CI ships. -- **One deadline has already passed.** Google Play requires 16 KB page-size support for - native code targeting Android 15+; the extended deadline was 2026-05-31. The published - SDK's AGP (8.7.3) aligns automatically, but this has never been verified on the produced - `.so`, and the `android-e2e` harness is on AGP 8.3.0 — below the threshold. - -## Block 1 — Minimal mobile SDK (the default recommendation) - -The trust model, architecture and invariants I1–I4 are unchanged from the web design doc: -keys are generated and held client-side only, the server keeps per-user xpubs watch-only, -the gateway holds the only RLN credential, and the client verifies every server-prepared -PSBT before signing. Nothing about that is web-specific — only the implementation language -changes. - -### Path A1 — reuse the TypeScript SDK (React Native / Capacitor). Days of work. - -`minimal-sdk/packages/client-sdk` is already portable. Verified by inspection: it imports -only `@noble/*` and `@scure/*` (pure JS), and the sole platform touchpoints are -`globalThis.crypto.getRandomValues` (`src/gateway.ts:269`) and `globalThis.fetch` -(`src/gateway.ts:298`). There is no Node-only API anywhere in `src/`. - -| Artifact | Size | Note | -| --- | --- | --- | -| `@utexo/minimal-client-sdk`, esbuild minified | **185.5 KB** | measured; budget gate is 500 KB | -| `react-native-get-random-values` polyfill | ~10 KB | estimate; RN lacks WebCrypto | - -Capacitor needs no polyfill (it runs in a WebView with real WebCrypto). This is the -cheapest mobile path by a wide margin and the one to take unless the app is natively -written. - -### Path A2 — native Kotlin. Weeks of work. - -Reimplement the same five modules (`keys`, `derive`, `verify`, `sign`, `invoice`) over the -Kotlin equivalents of the `@scure` stack. Measured floor: - -| Dependency | Size | Note | -| --- | --- | --- | -| `fr.acinq.bitcoin:bitcoin-kmp-jvm` | 596 KB jar (1.27 MB / 327 classes unpacked) | BIP-32/39, taproot, PSBT | -| `secp256k1-kmp-jni-android`, arm64-v8a slice | **1.28 MB** stripped | `.text` is only 195 KB; 1.12 MB is the precomputed ecmult table | -| **Total, arm64 slice** | **≈ 1.9 MB** | vs 54.2 MB for the tuned uniffi node | - -Two things worth knowing. The secp256k1 library is 84% lookup table, so rebuilding it with -a smaller `--with-ecmult-window` trades signing speed for roughly 1 MB — usually not worth -it. And this path needs **no JNA**: `secp256k1-kmp` uses direct JNI, so the 518 KB -`net.java.dev.jna:jna:5.14.0@aar` that the uniffi SDK pulls in -(`bindings/kotlin-android-sdk/build.gradle.kts`) disappears entirely. That aar also still -ships dead `mips`/`mips64` slices (293 KB of its 518 KB) that Android dropped in NDK r17. - -### Path A3 — native Swift. Weeks of work. - -`swift-secp256k1` over the same libsecp256k1 C core; the size profile is dominated by the -same ecmult table, so expect the same order as A2 (*estimate*: 0.3–1.3 MB depending on the -ecmult window). iOS links statically and dead-strips at app link, so measure the app-size -delta rather than the library. - -### What this buys - -The custody boundary is identical to the web variant and must be stated the same way: the -client keys cover all at-rest user funds, the LN working balance on the shared node stays -custodial, and the mitigations are the capped float, the sweep threshold and the swap path. -Mobile changes none of that. - -## Block 2 — Lightweight uniffi node (when the app must run its own node) - -### Where the size goes (measured) - -`cargo build --release --features "uniffi,vls,vss"` (defaults on), host -`x86_64-unknown-linux-gnu`: - -| Build | `.so` raw | gzip | `.text` | -| --- | --- | --- | --- | -| What CI uploads today — **unstripped** (`uniffi-artifacts.yaml:59`) | 99.0 MB | — | 57.2 MB | -| Same, `strip --strip-all` | 73.4 MB | 28.3 MB | 57.2 MB | -| **+ `opt-level="z"` + thin LTO + `strip`** | **54.2 MB** | **20.1 MB** | 32.7 MB | -| + drop VLS, VSS, second indexer | 47.9 MB | 17.7 MB | 27.5 MB | -| **+ drop the PostgreSQL driver too** | **46.6 MB** | **17.2 MB** | 26.8 MB | -| + `panic="abort"` instead (measured, **not recommended** — see below) | 44.5 MB | 16.5 MB | 26.4 MB | - -So the realistic, recommendable floor with no architectural change is **46.6 MB raw / -17.2 MB gzip** — a 53% cut from what CI ships today, reached entirely through a build -profile, four feature flags and one dependency line. - -The iOS side ships a 391.9 MB static `.a` inside the XCFramework -(`scripts/ci/package_swift_xcframework.sh`), but that is an archive: the app linker -dead-strips it, so the meaningful iOS number is the app-size delta, which nobody in this -repo has measured yet. - -Attribution of the baseline `.text` (56.7 MB attributed across 93,494 symbols): - -| Subsystem | Share | -| --- | --- | -| Rust `core` + `alloc` + `std` | 27.1% | -| LDK (fork + companion crates) | 15.4% | -| RGB stack (rgb-lib, rgbcore/std, strict_encoding, amplify…) | 12.5% | -| Database stack — sea-orm ×2, sqlx ×2 **including PostgreSQL** | 5.7% | -| VLS validating signer + redb ×2 | 2.8% | -| Own code (`rgb_lightning_node`) | 2.9% | -| REST server (axum, tower, hyper, h2, biscuit-auth) | 1.7% | -| TLS/crypto stacks (rustls, aws-lc, ring) | 1.7% | -| Both indexers (electrum + esplora) | 0.8% | -| C code in the remainder: aws-lc 1.38 MB, sqlite3 0.66 MB, zstd/zlib 0.61 MB, **secp256k1 0.15 MB** | — | - -Two structural differences from the wasm build are worth internalising: - -- **Exports are not the problem here.** The cdylib exposes 234 dynamic symbols (156 - uniffi). The wasm build's 569 `wasm-bindgen` exports pinned its whole call graph; this - one is pinned by genuine reachability from 156 entry points instead. Trimming exports - will not help much — gating modules will. -- **Unwinding metadata is 23% of the tuned binary**: `.eh_frame` 7.56 MB + - `.eh_frame_hdr` 1.16 MB + `.gcc_except_table` 3.87 MB = 12.6 MB of 54.2 MB. This is the - price of `panic = "unwind"`, and it has no wasm equivalent. - -### The dead weight, specifically - -| Finding | Evidence | -| --- | --- | -| The REST server compiles into the phone library, contradicting the SDK's own README | `src/lib.rs:48` (`mod routes;` unconditional) vs `src/uniffi_api/README.md:3` | -| A PostgreSQL driver ships to mobile | `migration/Cargo.toml:15` (`sqlx-postgres`) | -| The ORM is compiled twice — rgb-lib pins sea-orm 2.0.2, RLN pins 1.1.20, so `sqlx-core`/`-postgres`/`-sqlite` each build at 0.8.6 **and** 0.9.0 | `Cargo.toml:44`, `migration/Cargo.toml:12`, `Cargo.lock` | -| Both chain backends are forced on, because the UDL enum has both variants — a phone never runs bitcoind | `src/lib.rs:16-19`, `bindings/rgb_lightning_node.udl:605-608` | -| Three crypto stacks resolve; vendored OpenSSL is **built** (this is why CI needs bindgen + cmake) but the linker strips it from the cdylib — pure build-time waste | `Cargo.toml:97`, `uniffi-artifacts.yaml:118-124`, symbol scan | -| aws-lc contributes 1.38 MB, of which 680 KB is x86 AES-GCM AVX-512 assembly — a TLS suite for a wallet that needs secp256k1 (0.15 MB) | symbol scan | -| 769 crates in the dependency graph | `Cargo.lock` | - -### Build checklist - -**Phase 0 — config only, one small PR. Measured: 73.4 MB → 54.2 MB raw, 28.3 → 20.1 MB gzip.** - -1. **Add `[profile.release]`** to the root `Cargo.toml`: `opt-level = "z"`, `lto = "thin"`, - `strip = "symbols"`. Three lines. This is the same change the wasm work already - validated. Verify the latency impact on signing paths before adopting `opt-level="z"` - over `"s"` — `z` disables loop vectorisation, and the RGB/LDK hot paths were never - profiled on ARM. -2. **Strip in CI regardless.** `uniffi-artifacts.yaml:59` ships an unstripped `.so`; that - alone is 25.6 MB of the current artifact. -3. **`--remap-path-prefix`** via `RUSTFLAGS` in the mobile workflows — same privacy leak - and reproducibility prerequisite as wasm checklist item 4. - -**Phase 1 — scoped feature gating. Measured 47.9 MB for the features alone; the code-level items are unmeasured.** - -4. **`#[cfg(feature = "rest")] mod routes;`** and move `axum`, `axum-extra`, `tower-http` - and `biscuit-auth` behind it. The layering already supports this — `src/uniffi_api/README.md` - documents `uniffi_api -> sdk -> ldk` with `routes` as a parallel compatibility layer that - uniffi never calls. -5. **Drop `sqlx-postgres`** from `migration/Cargo.toml` for mobile builds, and converge the - sea-orm major version with rgb-lib's to collapse the duplicate ORM and sqlx trees. -6. **Un-force the chain backend**: keep both `SdkLdkChainSync` variants in the UDL (stable - ABI) but return a runtime error for a backend that was not compiled, then drop the - `compile_error!` at `src/lib.rs:16-19`. Mobile builds `transaction-sync` only. -7. **One indexer per build**, and gate VLS/VSS off for integrators who do not use them. - -**Phase 2 — deeper, do last.** - -8. **`-Zbuild-std` + `panic_immediate_abort`** (nightly, CI-only lane) attacks the 27% - `core`/`alloc` share — the single largest slice, exactly as on the wasm side. -9. **`panic = "abort"` is measured at −9.7 MB (18%) but should not be adopted.** uniffi - converts Rust panics into Kotlin/Swift exceptions by catching them at the FFI boundary, - and the background-processor watchdog depends on `catch_unwind` (`src/ldk.rs:4379-4422`). - With `panic="abort"` a recoverable error becomes a hard app crash. The number is - recorded here so the trade is explicit, not so it gets taken. - -### Mobile packaging (this is where mobile differs from web) - -- **Google Play caps the per-device compressed download at 200 MB** for an app bundle, and - warns users on mobile data above that. With ABI splits each user downloads one `.so`, so - even today's build fits — size here is a product-quality problem, not a compliance one. -- **Install size is what users actually feel.** With the modern default - (`useLegacyPackaging = false`) the `.so` is stored uncompressed and page-mapped from the - APK, so the install footprint tracks the *raw* size — 54.2 MB tuned versus 73.4 MB - stripped versus 99.0 MB as shipped. This is the number to put in the checklist, not the - gzip column. -- **16 KB page size is now mandatory** for native code targeting Android 15+ (extended - deadline 2026-05-31, already passed). AGP ≥ 8.5.1 aligns at packaging and the published - SDK uses 8.7.3, so this is probably fine — but it has never been asserted on the produced - `.so`, and `android-e2e/build.gradle` is on AGP 8.3.0. Add a CI check that reads the - `LOAD` segment alignment of each `jniLibs` artifact. -- **iOS thins automatically**: the App Store ships only the device's slice, and the static - `.a` is dead-stripped at app link. The XCFramework currently carries device arm64 plus a - universal simulator slice (`scripts/ci/package_swift_xcframework.sh`), which is correct. -- **Nothing is published yet.** Maven Central has no `com.utexo` artifacts, so all of the - above can land before the first release rather than as a breaking change after it. - -## Block 3 — Mobile runtime constraints - -These have no web analogue and are not addressed anywhere in the current SDK: - -- **Process death is normal, not exceptional.** Android kills backgrounded processes freely - and iOS suspends them within seconds. A Lightning node that assumes a long-lived process - will miss channel updates. The uniffi surface is instance-based (`SdkNode.create`, per - `src/uniffi_api/README.md`) but there is no documented save/restore-on-suspend contract. -- **The threading model bridges sync FFI onto async internals** via `block_in_place + - handle.block_on` on a multi-thread runtime, or a dedicated runtime otherwise - (`src/uniffi_api/README.md`). Every uniffi call therefore blocks the calling thread — - calling one from the Android main thread will ANR. This needs to be stated in the SDK - README, and ideally enforced with a debug-build assertion. -- **The fail-fast watchdog is wrong for an app.** `src/ldk.rs:4422` calls - `std::process::exit` on an event-path panic. On a daemon that is correct; inside a host - app it terminates the user's whole application, bypassing every crash reporter. Mobile - builds should surface a fatal error through the FFI instead. This overlaps with item 5 of - the web doc's Block 3 fix list and should be designed alongside it. -- **Battery and data.** Both chain backends and both indexers being compiled in is a size - problem; which one is *used* is a battery problem. Mobile should default to - `transaction-sync` against an indexer with an explicit polling cadence, never a full - block sync. - -## Appendix - -### A. Citation index - -- Crate layout / profile gap: `Cargo.toml:7-9` (`crate-type`), `Cargo.toml:181` - (`reldebug`, no `[profile.release]`), `Cargo.toml:27-60` (features), `Cargo.toml:97` - (reqwest `native-tls`) -- Daemon in the SDK: `src/lib.rs:48` (`mod routes;`), `src/lib.rs:16-19` (`compile_error!` - forcing both chain backends), `src/uniffi_api/README.md:3` (REST is not the SDK surface) -- Bindings: `bindings/rgb_lightning_node.udl:605-608` (`SdkLdkChainSync`), 918 lines / 99 - interfaces+dictionaries+enums; `src/uniffi_api/mod.rs` (~110 public functions); - `uniffi.toml`, `uniffi-android.toml` -- CI: `.github/workflows/uniffi-artifacts.yaml:50,59` (release build, unstripped copy), - `:126-135` (Android JNI via `scripts/ci/build_android_jni.sh`), `:202-205` (Apple - targets), `scripts/ci/package_swift_xcframework.sh` -- Android packaging: `bindings/kotlin-android-sdk/build.gradle.kts` (AGP 8.7.3, JNA aar, - `isMinifyEnabled = false`), `android-e2e/build.gradle` (AGP 8.3.0) -- Database: `migration/Cargo.toml:12-18` (`cli`, `sqlx-postgres`, `sqlx-sqlite`) -- Watchdog / panic path: `src/ldk.rs:4379` (`catch_unwind`), `src/ldk.rs:4422` - (`process::exit`) -- Client SDK portability: `minimal-sdk/packages/client-sdk/src/gateway.ts:269,298` - -### B. Measured numbers and method - -Host `x86_64-unknown-linux-gnu`, Rust stable, this tree at `dev`, 2026-09-14. Profile -variants applied via `CARGO_PROFILE_RELEASE_*` environment overrides so no file was -modified. Sizes from `stat`/`size -A`; gzip is `gzip -9`. Symbol attribution from -`nm --print-size --size-sort -C` on the unstripped baseline, aggregating `t`/`w` symbols by -the leading path component of the demangled name; 6.08 MB of unmangled (C) symbols were -classified separately by name prefix. - -| # | Build | `.so` | gzip | `.text` | -| --- | --- | --- | --- | --- | -| 1 | `--release --features "uniffi,vls,vss"`, as CI ships it | 99.0 MB | — | 57.2 MB | -| 2 | #1 stripped | 73.4 MB | 28.3 MB | 57.2 MB | -| 3 | #1 + `opt-level=z`, `lto=thin`, `strip=symbols` | 54.2 MB | 20.1 MB | 32.7 MB | -| 4 | #3 + `--no-default-features --features "uniffi,electrum,block-sync,transaction-sync"` | 47.9 MB | 17.7 MB | 27.5 MB | -| 5 | #4 + `sqlx-postgres` removed from `migration/Cargo.toml` (edit reverted after the build) | 46.6 MB | 17.2 MB | 26.8 MB | -| 6 | #3 + `panic=abort` | 44.5 MB | 16.5 MB | 26.4 MB | - -Static library for iOS: 391.9 MB `.a`, 104.9 MB `.rlib`. Dynamic exports: 234 total, 156 -uniffi. Dependency graph: 769 crates. - -Section breakdown of build #3 (54.2 MB): `.text` 32.65, `.eh_frame` 7.56, `.rodata` 5.42, -`.rela.dyn` 3.86, `.gcc_except_table` 3.87, `.eh_frame_hdr` 1.16, `.data.rel.ro` 1.58 (MB). - -Third-party artifacts, downloaded from Maven Central and measured: `jna-5.14.0.aar` -517,894 B (arm64 slice 168,176 B; dead mips/mips64 slices 292,828 B); -`secp256k1-kmp-jni-android-0.15.0.aar` 4,902,570 B (arm64 `.so` 1,339,880 B, already -stripped: `.text` 194,820 B, `.rodata` 1,119,480 B); `bitcoin-kmp-jvm-0.20.0.jar` -596,300 B. TypeScript client SDK: 185.5 KB via the repo's own esbuild bundle gate. - -### C. What was not measured - -- **Android arm64 and iOS arm64 code size.** No NDK or macOS host was available here, so - every figure is the x86_64 host build. Rust `.text` on aarch64 is typically within ±15% - of x86_64, but the aws-lc assembly differs substantially (the 680 KB of AVX-512 AES-GCM - is replaced by smaller ARM crypto-extension code). **Re-measure on the real targets - before quoting these numbers externally.** -- **The app-size delta on iOS**, which is the only number that matters there. -- **Phase-1 code-level gating** (items 4–6): only the feature-flag portion was measured. -- **Runtime cost of `opt-level="z"`** on signing and sync paths. diff --git a/docs/plans/20260915-minimal-mobile-rust-sdk.md b/docs/plans/20260915-minimal-mobile-rust-sdk.md deleted file mode 100644 index ddbf27f5..00000000 --- a/docs/plans/20260915-minimal-mobile-rust-sdk.md +++ /dev/null @@ -1,580 +0,0 @@ -# Minimal SDK for mobile: one Rust core, Kotlin and Swift bindings - -## Overview - -`docs/design/mobile-sdk-and-lightweight-uniffi.md` reaches one conclusion for mobile: **for -most apps the Minimal SDK variant is the answer, not a node on the phone.** The uniffi node CI -ships is 99.0 MB unstripped and carries the whole axum REST daemon; the TypeScript client SDK -that does the same user-facing job is 185.5 KB. - -The design doc offered two mobile routes for that client — Path A2 (native Kotlin) and Path A3 -(native Swift) — and costed each at "weeks of work", separately. This plan takes a third route -the doc did not cost: **one small Rust crate, exposed to both Kotlin and Swift through uniffi.** - -The reason the doc did not consider it is that it compared Kotlin against *the node* (28×–52× -smaller) rather than against a *minimal Rust crate*. Measured on 2026-09-15 (method in -"Verified facts" below), a minimal Rust client crate with the real derive/PSBT/sign paths -reachable is **1.94 MB with uniffi scaffolding included, across 31 crates** — the same order as -the doc's ≈1.9 MB estimate for pure Kotlin, because both are dominated by the *same* -libsecp256k1 precomputed tables. With size neutral, the deciding factor becomes how many times -we implement the code that guards user funds. - -So: **one implementation of keys, derivation, verify-before-sign, signing, invoice decoding and -intent binding**, in Rust, serving Android and iOS. Path A3 stops being a second project and -becomes a build target. - -**This is not the node.** No LDK, no rgb-lib, no axum, no sea-orm, no chain backend. The crate -depends on `bitcoin`, `bip39` and `uniffi` and nothing else. - -**Out of scope:** any change to the uniffi *node* or its build profile (Block 2 of the design -doc — a separate plan); any Rust change inside `rgb-lightning-node`; any gateway/server change; -replacing the TypeScript SDK; publishing to Maven Central or the Swift package registry; and -extraction of `minimal-sdk/` into its own repository. - -## Context - -- Authority for architecture and the size argument: `docs/design/mobile-sdk-and-lightweight-uniffi.md` - (Block 1 Paths A2/A3, Block 3's mobile runtime constraints). -- Authority for the trust model, invariants I1–I4 and the five verify-before-sign checks: - `docs/design/minimal-sdk-and-lightweight-rln.md`. -- The normative behavioural reference being ported: `minimal-sdk/packages/client-sdk/src/` - (1,701 LOC across 8 files). The Rust crate must reproduce its behaviour, not its structure. - -### Three design decisions that shape every task - -1. **The Rust crate must NOT depend on `rgb-lib`.** Two reasons, both load-bearing. Size: - rgb-lib is 12.5% of the node's `.text` and drags in sea-orm/sqlx — it would destroy the - 1.94 MB budget that justifies this approach. Correctness: the parity fixture is - rgb-lib-generated, so if the client *were* rgb-lib's code, agreement would be tautological - and a shared misreading of BIP-86 or the RGB coin types would be invisible. An independent - implementation checked against rgb-lib's output is the property worth keeping. -2. **The TypeScript SDK stays hand-written and stays shipped.** It is 185.5 KB of pure JS; - replacing it with wasm would be a large regression for web and React Native. The end state - is therefore **two** implementations (TS + Rust) rather than three (TS + Kotlin + Swift) — - this plan halves the duplication, it does not remove it, and the parity fixture is what - keeps the two honest. -3. **HTTP transport is a foreign trait, everything else is Rust.** The crate defines a - transport trait with `#[uniffi::export(with_foreign)]`; Kotlin implements it over OkHttp or - `HttpURLConnection`, Swift over `URLSession`. Networking stays idiomatic and async-friendly - per platform, Rust never links an HTTP stack or a TLS stack, and there is no async-over-FFI. - **Intent binding stays in Rust** — it is security-critical logic, not plumbing. - -### Verified facts (measured/re-verified 2026-09-15 on `dev`; re-check line numbers when citing) - -**The size measurement that motivates this plan.** Scratch crate with `bitcoin` 0.32 + -`bip39` 2.2, release profile `opt-level="z"` + `lto="thin"` + `strip="symbols"` + -`codegen-units=1`, host `x86_64-unknown-linux-gnu`, with BIP-39→BIP-32→taproot-derive→PSBT- -parse→key-path-sign→serialize all reachable from exported symbols so LTO cannot strip them: - -| Build | raw | gzip | crates | -| --- | --- | --- | --- | -| `bitcoin` + `bip39`, C-ABI exports | 1.89 MB | 1.42 MB | 30 | -| **+ uniffi scaffolding + exported fns** | **1.94 MB** | ~1.45 MB | 31 | -| Pure Kotlin (design doc, measured) | ≈1.9 MB | — | — | -| uniffi **node**, tuned / as CI ships | 46.6 / 99.0 MB | — | 769 | - -- **uniffi scaffolding costs 48 KB** (1,981,992 → 2,030,936 bytes). It is not a size factor. -- **1.06 MB of the 1.94 MB is secp256k1 precomputed tables**, confirmed by symbol: - `rustsecp256k1_v0_10_0_pre_g` 512 KB + `..._pre_g_128` 512 KB + `..._ecmult_gen_prec_table` - 64 KB. The design doc measured the same 1.12 MB of tables inside `secp256k1-kmp`'s `.so` — - it is the same C library either way, so no Kotlin/Rust choice removes it. -- **Actual Rust code is 460 KB of `.text`.** The remaining delta versus pure Kotlin is JNA - (below), not compiled code. -- ⚠️ **x86_64 host figures**, same caveat as the design doc's Appendix C. Re-measure on - arm64 before quoting externally; Task 8 records the real arm64 number. - -**The cost side, to keep the decision honest:** - -- uniffi's Kotlin bindings require **JNA** — `net.java.dev.jna:jna:5.14.0@aar` is 518 KB, of - which 293 KB is dead mips/mips64 slices Android dropped in NDK r17 - (`bindings/kotlin-android-sdk/build.gradle.kts`, design doc measurement). Pure Kotlin avoids - it entirely (`secp256k1-kmp` uses direct JNI). Honest comparison: **≈2.46 MB (Rust+JNA) vs - ≈1.9 MB (pure Kotlin)** on Android. Rust wins on implementation count, not on bytes. -- This plan **reintroduces the NDK and cross-compilation** that a pure-Kotlin SDK avoided. No - NDK is installed on the development machine (`~/Android/Sdk/ndk` absent); Task 8 installs it. - -**What is being ported** (`minimal-sdk/packages/client-sdk/src/`): `network.ts` 57, -`keys.ts` 90, `derive.ts` 117, `verify.ts` 383, `sign.ts` 91, `invoice.ts` 316, `gateway.ts` 566. - -- **The five checks** (`verify.ts:50-59`, implemented at `:221`, `:331`, `:349`, `:358`, `:366`): - `inputs-own`, `recipients-match`, `change-own`, `fee-budget`, `opret-zero`. -- **Two security properties that must survive the port**, both documented in the TS source as - the reason the code is shaped the way it is: - 1. `matchOriginPath` (`derive.ts:85`) is the **single source of truth for path acceptance**, - shared by verify (proving scripts) and `privateKeyForPath` (selecting signing keys). Two - independent path-acceptance implementations can drift, and verify would then approve a - path sign does not use. - 2. `ownDerivation` (`verify.ts:156`) selects the key-origin entry **by our master - fingerprint, never by path shape**, and `sign.ts:64` signs with the very entry verify - proved. Shape-matching alone lets a decoy entry carrying a foreign fingerprint, ordered - first, divert signing to a key no check covered. -- **Own OUTPUTS are restricted to keychain 0 at a bounded index; own INPUTS are not** - (`verify.ts:260-270`). Deliberate asymmetry — do not "tidy" it. -- **Check 2 re-derives the expected script from the human-readable address** and only - cross-checks the server's `scriptHex` (`verify.ts:283-297`); otherwise a hostile gateway - pairs the intended address with an attacker script and both "match". -- **Intent binding is what makes check 2 non-vacuous** (`gateway.ts:141-159`) — without it the - client verifies the server's PSBT against the server's own intent. - -**The parity fixture** — `minimal-sdk/packages/client-sdk/test/fixtures/rgblib-parity.json`, -generated from the real `@utexo/rgb-lib` binding: account xpubs + master fingerprint -(`73c5da0a`) for all four networks; 5 vanilla + 5 colored regtest addresses at indexes 0..4; -`signing.{unsignedPsbt,signedPsbt,txid,witnessSignature}`; -`witnessReceive.{invoice,recipientId,expirationTimestamp}`; the reference mnemonic and a -foreign one. - -- **BIP-340 signatures are randomized**, so witness bytes are NOT comparable - (`test/sign.test.ts:58-59` says so). The parity assertion is **same txid**, one 64-byte - witness element, and the signature **verifies** against the re-derived x-only key. - -**uniffi mechanics already proven in this repo — reuse, do not reinvent:** - -- Foreign-implemented traits: `#[uniffi::export(with_foreign)] pub trait ExternalSignerHost` - (`src/uniffi_api/mod.rs:32`). This is the model for the HTTP transport trait. -- **Library-mode binding generation** (no `.udl` to maintain): - `scripts/ci/uniffi_generate_from_library.sh ` uses - `uniffi-bindgen generate --library`. The new crate is proc-macro only — **do not add a - UDL file**. -- Android cross-compilation: `scripts/ci/build_android_jni.sh` uses `cargo ndk -t ` for - arm64-v8a, armeabi-v7a, x86_64. Copy the shape; the aws-lc cleanup it does is node-specific - and must NOT be carried over (this crate has no aws-lc). -- XCFramework packaging: `scripts/ci/package_swift_xcframework.sh` — `lipo` the simulator - slices, then `xcodebuild -create-xcframework` over **static** `.a` plus headers. -- Gradle/publishing conventions: `bindings/kotlin-android-sdk/build.gradle.kts` (AGP 8.7.3, - Kotlin 1.9.24, JDK 17, compileSdk 34, minSdk 24, `consumerProguardFiles`, - `singleVariant("release")`). Copy the shape; drop the uniffi-node `srcDir`/`jniLibs` wiring - and JReleaser (no publishing in this plan). - -**Toolchain verified on the development machine:** - -- Rust 1.98.0; all required crates present in the local registry cache (offline builds work). -- JDK **17.0.19 Temurin** at `~/.jdks/temurin-17` — **not on `PATH`**; set `JAVA_HOME`. -- Android SDK at `~/Android/Sdk`: platforms `android-34`/`android-35`, build-tools `34.0.0`, - `cmdline-tools/latest`. **No NDK** — Task 8 installs one. -- `android-e2e` is on AGP 8.3.0 with a Gradle 8.7 wrapper. AGP 8.7.3 needs Gradle **≥ 8.9**, so - the new Gradle project needs **its own wrapper**; do not reuse `android-e2e`'s. -- ⚠️ **No macOS host is available here.** Task 9 (Swift/XCFramework) cannot be built or - verified locally — see "Development Approach". -- `minimal-sdk/pnpm-workspace.yaml` globs `packages/*` only, so new Cargo/Gradle directories - under `minimal-sdk/` need no pnpm changes. - -### Files involved - -- Create (Rust): `minimal-sdk/rust-sdk/{Cargo.toml,Cargo.lock,README.md}`, - `minimal-sdk/rust-sdk/src/{lib.rs,network.rs,keys.rs,derive.rs,verify.rs,sign.rs,invoice.rs,gateway.rs,ffi.rs}`, - `minimal-sdk/rust-sdk/tests/parity.rs` -- Create (Android): `minimal-sdk/android/` — Gradle project, wrapper 8.9+, `build.gradle.kts`, - `consumer-rules.pro`, `src/main/AndroidManifest.xml`, `src/test/kotlin/…SmokeTest.kt` -- Create (Apple): `minimal-sdk/apple/` — `Package.swift`, `Tests/…SmokeTests.swift` -- Create (scripts): `minimal-sdk/scripts/{build_android.sh,build_apple.sh,generate_bindings.sh}` -- Create (CI): `.github/workflows/minimal-sdk-mobile.yaml` -- Read-only, never modified: `minimal-sdk/packages/client-sdk/src/**`, the parity fixture, - both design docs, everything under `src/` and `bindings/` -- Modify only in Task 11: `minimal-sdk/README.md`, `docs/design/mobile-sdk-and-lightweight-uniffi.md` - -### Dependencies - -`bitcoin` 0.32 (`std`, `rand-std`, `base64`; BIP-32, taproot, PSBT), `bip39` 2.2, -`uniffi` 0.28.3, `thiserror` 2.0. **Nothing else.** In particular: no `rgb-lib`, no `reqwest`, -no TLS stack, no `tokio`, no `serde_json` unless a task proves it cannot be avoided — every -addition is measured against the Task-1 size gate before it lands. - -Build-time only: `uniffi-bindgen` (reuse `bindings/uniffi-bindgen/`), `cargo-ndk`, Android NDK, -and on CI macOS runners Xcode for the XCFramework. - -## Development Approach - -- **Testing approach**: Regular (code first, then tests). The Rust crate carries the **full** - parity and adversarial suite (`cargo test`); the Kotlin and Swift sides carry **smoke** tests - only — enough to prove the generated binding surface works end to end, not a third and fourth - copy of the same assertions. This mirrors what the repo already does (`test/kotlin-e2e` is - one file, the Swift job is a smoke). -- Gate command for Tasks 1–7: `cargo test --manifest-path minimal-sdk/rust-sdk/Cargo.toml` -- Gate command for Task 8: `JAVA_HOME=~/.jdks/temurin-17 ./gradlew -p minimal-sdk/android test` -- **⚠️ Task 9 is not locally runnable.** Building the XCFramework needs macOS + Xcode, which - this machine does not have. The agent writes the script, the `Package.swift` and the CI job, - and verifies everything that *is* checkable on Linux (bindings generate, Swift sources are - produced, the packaging script passes `shellcheck`/`bash -n`). Actual XCFramework assembly - and the Swift smoke test are verified by the macOS CI job in Task 10 and signed off in - Post-Completion. Do not fake a local pass. -- **Parity rule, non-negotiable:** tests read - `minimal-sdk/packages/client-sdk/test/fixtures/rgblib-parity.json` **by relative path**. Do - not copy it, do not regenerate it, do not add a Rust fixture generator. One rgb-lib-authored - ground truth, checked by an implementation that shares no code with it. -- **Port behaviour, not line shape.** Where the TS source explains *why* (fingerprint-based - entry selection, the input/output asymmetry, address re-derivation in check 2), carry that - reasoning into the Rust doc comment so the two implementations can be diffed on intent. -- **Invariants that must hold in every task** (design doc I1–I4): - - the mnemonic and any xprv **never** leave the device and are never a gateway request field; - - only account xpubs + master fingerprint are ever sent to the gateway; - - nothing logs, `Debug`-prints or renders a mnemonic, seed, xprv or private key — including - error variants, which is where this leaks in practice. Assert it in tests, and prefer - manual `Debug` impls over `#[derive(Debug)]` on any type holding secrets. -- **No panics across the FFI boundary.** Every exported function returns `Result<_, SdkError>`; - `verify` returns a failed verdict rather than an error for hostile input; no `unwrap()` or - `expect()` on anything derived from caller or PSBT data. A panic here becomes a host-app - crash. -- Signature assertions: **txid parity + signature validity**, never witness byte-equality. -- **Size discipline:** the Task-1 size gate runs in every subsequent task's test run. A task - that pushes the crate over budget must justify it in the progress notes or drop the - dependency that caused it. -- **CRITICAL: every task MUST include new/updated tests** -- **CRITICAL: all tests must pass before starting next task** - -## Implementation Steps - -### Task 1: Rust crate skeleton, uniffi scaffolding, parity harness and size gate - -**Files:** -- Create: `minimal-sdk/rust-sdk/{Cargo.toml,src/lib.rs,src/ffi.rs,tests/parity.rs}` - -- [ ] Create `minimal-sdk/rust-sdk` as its **own Cargo workspace** (`[workspace]` in its - `Cargo.toml`, the pattern `bindings/c-ffi/Cargo.toml` uses) so it never pulls the - rgb-lightning-node dependency graph. `crate-type = ["cdylib", "staticlib", "lib"]` — - cdylib for Android, staticlib for the iOS XCFramework. Release profile - `opt-level="z"`, `lto="thin"`, `strip="symbols"`, `codegen-units=1`, - `panic="unwind"` (uniffi converts panics at the boundary; `panic="abort"` would turn a - recoverable error into an app crash — the design doc records this trade at Block 2 item 9) -- [ ] Add `uniffi::setup_scaffolding!()` and one trivial exported function; confirm - `scripts/ci/uniffi_generate_from_library.sh kotlin` and `… swift` both generate against - the built library with **no UDL file present** -- [ ] Define the shared error type `SdkError` (`#[derive(uniffi::Error)]`) with variants that - carry no key material, and the `SdkResult` alias every exported function returns -- [ ] Prove the parity harness: a test loading - `../packages/client-sdk/test/fixtures/rgblib-parity.json` by relative path and asserting - `masterFingerprint == "73c5da0a"` on Regtest. This pins the parity rule before any crypto - exists -- [ ] **Size gate** (runs in every later task): build release, assert the stripped cdylib is - under **2.5 MB**, and print the measured number. Baseline to compare against: 1.94 MB - measured 2026-09-15 for `bitcoin` + `bip39` + uniffi. Budget rationale is in the Context - section — 1.06 MB of it is secp256k1 tables and is irreducible -- [ ] Tests: the fixture-loading test, the size gate, and a dependency-creep guard asserting - the resolved graph contains **no** `rgb-lib`, `reqwest`, `tokio`, `rustls` or - `openssl` crate -- [ ] Run `cargo test --manifest-path minimal-sdk/rust-sdk/Cargo.toml` — must pass before Task 2 - -### Task 2: Network constants and key management (BIP-39 → BIP-32 accounts) - -**Files:** -- Create: `src/network.rs`, `src/keys.rs`; extend `tests/parity.rs` - -- [ ] Port `network.ts` behaviour: `BitcoinNetwork` enum, `PURPOSE = 86`, `ACCOUNT = 0`, - `KEYCHAIN = 0`, RGB coin types **827166** (mainnet) / **827167** (everything else), - xprv/xpub vs tprv/tpub version bytes, per-network address HRP (`bc`/`tb`/`tb`/`bcrt`); - `account_path(network, colored)` returns `[86', coin', 0']` -- [ ] Port `keys.ts` as `ClientKeys`: `generate(network)` (fresh 12-word mnemonic) and - `from_mnemonic(mnemonic, network)` returning `SdkError::InvalidMnemonic` on a bad - checksum; BIP-39 seed with an **empty passphrase**; vanilla + colored account keys; - expose `fingerprint` (8 lowercase hex chars) and `xpubs` (**public material only**) -- [ ] Add `private_key_for_path(path)` returning `None` (never an error, never a panic) for - foreign paths, delegating to the shared `match_origin_path` landing in Task 3 — leave a - TODO only if Task 3 has not landed, and close it there -- [ ] Secrets hygiene: `ClientKeys` must not expose the mnemonic, seed or any xprv; write a - **manual `Debug` impl** that renders no secret; no secret may reach an `SdkError` variant -- [ ] Tests: for **all four networks**, assert `accountXpubVanilla`, `accountXpubColored` and - `masterFingerprint` equal the fixture exactly; a bad-checksum mnemonic is rejected; the - fixture's `otherMnemonic` yields different xpubs; `generate()` round-trips through - `from_mnemonic`; `format!("{:?}", keys)` contains neither the mnemonic nor `xprv`/`tprv` -- [ ] Run the gate — must pass before Task 3 - -### Task 3: Taproot derivation and origin-path matching - -**Files:** -- Create: `src/derive.rs`; modify `src/keys.rs`; extend `tests/parity.rs` - -- [ ] Port `derive_taproot(account_xpub, keychain, index, network)`: derive the child key, take - the x-only internal key, build the BIP-341 `tr(key)` output script and the bech32m - address, return script hex + address + internal-key hex -- [ ] Port `match_origin_path(path, network)` as the **single source of truth for path - acceptance**: accepts only `[86', coin', 0', keychain, index]` under one of the two - accounts, rejects hardened keychain/index, returns `None` (never an error) on foreign - paths — it is fed attacker-controlled PSBT metadata -- [ ] Port `derive_for_origin_path` returning `None` when the path is not ours or derivation fails -- [ ] Wire `ClientKeys::private_key_for_path` to `match_origin_path`, with a comment stating - that what verify proves is exactly what sign derives -- [ ] Tests: the 5 `regtest.vanillaAddresses` and 5 `regtest.coloredAddresses` are reproduced - exactly at keychain 0, indexes 0..4; `match_origin_path` accepts both accounts and - rejects wrong purpose/coin/account, hardened keychain or index, and short and over-long - paths — returning `None` in every case rather than erroring; `private_key_for_path` - returns a key for exactly the paths `match_origin_path` accepts -- [ ] Run the gate — must pass before Task 4 - -### Task 4: Verify-before-sign — the five checks - -**Files:** -- Create: `src/verify.rs`; extend `tests/parity.rs` - -> No PSBT-codec task: `bitcoin::psbt::Psbt` already parses and re-serializes the taproot fields -> this SDK needs — verified 2026-09-15 by round-tripping the fixture PSBT through -> `Psbt::deserialize`, reading `tap_key_origins` / `tap_internal_key` / `witness_utxo`, signing -> and re-serializing. The first checkbox re-confirms the one property that matters. - -- [ ] **Confirm before building on it:** `Psbt::deserialize` preserves **every** - `tap_key_origins` entry, including a foreign-fingerprint entry alongside ours on the same - input. `tap_key_origins` is keyed by x-only pubkey, so distinct decoy keys should survive - — prove it with a test rather than assuming, because the decoy defence depends on it -- [ ] Port the intent types (`OnchainIntent`, `IntentRecipient`, `IntentAsset`, `IntentUtxos`) - and the verdict types (`CheckName`, `CheckResult`, `VerifyVerdict`) with the same five - check names: `inputs-own`, `recipients-match`, `change-own`, `fee-budget`, `opret-zero` -- [ ] Port `own_derivation`: select the key-origin entry **by our master fingerprint**, never by - path shape; expose it for Task 5 to reuse; carry the reasoning into the doc comment -- [ ] **Check 1 (`inputs-own`)** — every input has a `witness_utxo`, a key origin with our - fingerprint, a path re-deriving from **our** xpubs to the **exact** script being spent, - and (when present) a matching `tap_internal_key`; zero inputs fails -- [ ] **Check 2 (`recipients-match`)** — re-derive the expected script **from the recipient - address**, cross-check the intent's `scriptHex` against it, then match recipients against - outputs as a **multiset** (each output consumed at most once); for witness asset sends - require **exactly one** unaccounted, non-own, non-OP_RETURN output at exactly the - approved `witnessAmountSat` -- [ ] **Check 3 (`change-own`)** — every output that is not an accounted recipient, the - approved witness output, or an OP_RETURN must prove ownership. Preserve the asymmetry: - own **outputs** must be keychain 0 at a bounded index; own **inputs** are unrestricted. - Keep the lazy scan-window fallback for metadata-less change, derived only on demand -- [ ] **Check 4 (`fee-budget`)** — `fee = inputs − outputs`, ok only when `0 < fee ≤ max_fee_sat` -- [ ] **Check 5 (`opret-zero`)** — every OP_RETURN output carries 0 sats -- [ ] `verify_psbt` must **never** return an error and never panic: any parse or derivation - failure yields a verdict with **all five** checks failed and the reason in `detail` -- [ ] Tests, each asserting the *specific* check that fails: happy path on - `signing.unsignedPsbt` passes all five; a foreign input (fingerprint from - `otherMnemonic`) fails only `inputs-own`; a tampered recipient amount and a tampered - recipient address each fail only `recipients-match`; an intent whose `scriptHex` - contradicts its own address fails `recipients-match`; an unaccounted foreign output fails - only `change-own`; an own-looking output at keychain 1 or beyond the index bound fails - `change-own`; a fee above budget and a negative fee each fail only `fee-budget`; a funded - OP_RETURN fails only `opret-zero`; **the decoy test** — a foreign-fingerprint key-origin - entry present alongside ours must not divert selection; truncated and garbage PSBT bytes - return all-five-failed with no panic and no `Err` -- [ ] Run the gate — must pass before Task 5 - -### Task 5: Sign and finalize - -**Files:** -- Create: `src/sign.rs`; extend `tests/parity.rs` - -- [ ] Port `verify_and_sign_psbt(keys, psbt_base64, params)`: run `verify_psbt` **inside** this - function and return `SdkError::VerificationFailed` (carrying the failed check names) when - the verdict is not ok. There must be **no** exported entry point that signs without - verifying — no flag, no second function, no `pub` helper that skips it -- [ ] For each input, select the signing entry with `own_derivation` using our fingerprint — - **the same entry check 1 proved** — then derive via `private_key_for_path`; a missing key - is an error naming the input index, never a skip -- [ ] BIP-341 key-path sign every input, set `tap_key_sig`, finalize into - `final_script_witness`, and return the base64 signed+finalized PSBT, the txid and the verdict -- [ ] Tests: sign `signing.unsignedPsbt` and assert (a) the verdict is ok, (b) the txid equals - `signing.txid`, (c) exactly one 64-byte witness element, (d) **the signature verifies** as - BIP-340 over the BIP-341 sighash against the re-derived x-only key, and (e) rgb-lib's own - `signing.signedPsbt` parses to the same txid. **Do not** assert witness byte-equality - against `signing.witnessSignature` — BIP-340 signatures are randomized -- [ ] Tests (refusal paths): a tampered recipient amount returns `VerificationFailed` and - produces **no** signature; an input whose key-origin path is not ours errors; no error - message contains key material -- [ ] Run the gate — must pass before Task 6 - -### Task 6: Invoice decoding (BOLT-11 and RGB) - -**Files:** -- Create: `src/invoice.rs`; extend `tests/parity.rs` - -- [ ] Port `decode_bolt11`: bech32 decode with the length limit disabled, HRP → network and - amount (`m`/`u`/`n`/`p` multipliers over msat-per-BTC), tagged-field parsing, payee - recovery from the signature. Malformed input returns `SdkError::InvoiceDecode` — never a - panic; this is fed user-pasted strings -- [ ] Port `decode_rgb_invoice`, including the `~` omitted-field convention -- [ ] Prefer `bitcoin`'s existing bech32 and secp256k1 recovery over new dependencies; if a - dependency is genuinely required, record the size-gate delta in the progress notes -- [ ] Tests: decode `witnessReceive.invoice` and assert `recipientId` and - `expirationTimestamp` match the fixture; port the BOLT-11 vectors from - `packages/client-sdk/test/invoice.test.ts` — they are the reference — covering network, - amount (including a no-amount invoice), payment hash and recovered payee; malformed, - truncated and wrong-checksum input each return `InvoiceDecode` and nothing else -- [ ] Run the gate — must pass before Task 7 - -### Task 7: Gateway client, foreign HTTP transport, and intent binding - -**Files:** -- Create: `src/gateway.rs`; extend `tests/parity.rs` - -- [ ] Define the transport seam with `#[uniffi::export(with_foreign)] pub trait HttpTransport: - Send + Sync`, taking a request (method, path, headers, optional body) and returning a - response (status, body) or an error — modelled on `ExternalSignerHost` - (`src/uniffi_api/mod.rs:32`). Rust performs **no** network IO and links **no** HTTP or - TLS stack; the host supplies OkHttp/`URLSession`. The call is synchronous from Rust's - side, so there is no async-over-FFI -- [ ] Port `GatewayClient` over all **22** routes: `/v1/users`, `/v1/me`, `/v1/wallet/{xpubs, - address,balances,unspents,transfers,receive,sync}`, `/v1/onchain/{send-btc,send-asset, - create-utxos}/{prepare,complete}`, `/v1/ln/{deposit/prepare,pay,invoice,invoice/{hash}, - payments,balance,withdraw}`; bearer-token auth; an error carrying status and body -- [ ] Port **intent binding** for the three `prepare*` methods — the security core of this - task, not boilerplate. Assert every field the caller actually stated (recipient address - and amount, asset id/amount/recipient id, utxo shape), assert the shape negatives (no - asset on a send-btc intent, no recipients on an asset/utxo intent), assert caller-pinned - optional fields only when pinned, and return an error listing every mismatch. Carry over - the comment explaining that check 2 is vacuous without this -- [ ] Port idempotency-key generation as RFC-4122 v4 from a **cryptographic** RNG (`bitcoin` - already pulls `rand`; do not add a dependency), sent on the money-moving routes exactly - where the TS client sends it -- [ ] Secrets hygiene: assert in a test that no request body or header any method produces - contains the mnemonic, a seed or an `xprv`/`tprv`, and that the bearer token never - appears in an error message or `Debug` output -- [ ] Tests against a fake in-Rust `HttpTransport` (no network): one round-trip per route - asserting method, path, auth header and body shape; intent-mismatch errors for a swapped - recipient address, a changed amount, an asset smuggled onto a send-btc intent, an extra - recipient, and a foreign `witnessAmountSat`; status and body surface on 4xx/5xx; - idempotency keys are unique across calls and stable when supplied by the caller -- [ ] Run the gate — must pass before Task 8 - -### Task 8: Android packaging — bindings, JNI libs, AAR, 16 KB alignment - -**Files:** -- Create: `minimal-sdk/scripts/{generate_bindings.sh,build_android.sh}`, - `minimal-sdk/android/` (Gradle project, wrapper 8.9+, `build.gradle.kts`, - `consumer-rules.pro`, `src/main/AndroidManifest.xml`, - `src/test/kotlin/com/utexo/minimalsdk/SmokeTest.kt`) - -- [ ] Install an NDK via `sdkmanager` (none is present) and add `build_android.sh` modelled on - `scripts/ci/build_android_jni.sh`: `cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64`. - **Do not** copy that script's aws-lc cleanup — this crate has no aws-lc -- [ ] `generate_bindings.sh kotlin` wraps `uniffi-bindgen … --library` against the built - cdylib (library mode, no UDL), writing generated Kotlin into the Gradle source set -- [ ] Gradle project: `com.android.library`, AGP 8.7.3, Kotlin 1.9.24, JDK 17, - `compileSdk = 34`, `minSdk = 24`, namespace `com.utexo.minimalsdk`, **its own wrapper at - Gradle 8.9+**. Depends on JNA (uniffi requires it) and nothing else. ABI splits on, so a - consumer downloads one `.so` -- [ ] **16 KB page-alignment check** on every produced `.so`: assert each `PT_LOAD` segment - alignment is **≥ 16384**. Mandatory for native code on Android 15+ (extended deadline - **2026-05-31, already passed**), and the design doc notes nothing in this repo has ever - asserted it. A failure is a **release blocker** to record in the progress notes, with the - linker flag or NDK version that fixes it — do not ship a misaligned `.so` -- [ ] **Android size gate**: record the real **arm64** `.so` size (the Context table's 1.94 MB - is an x86_64 host figure) plus the AAR and JNA, and fail above **3.0 MB** for the single - arm64 slice. Note in the README that install size tracks the **raw**, not compressed, number -- [ ] `consumer-rules.pro` keeps only what R8 must not strip — the uniffi/JNA entry points. - **No** blanket `-keep class com.utexo.minimalsdk.** { *; }` -- [ ] Tests: a Kotlin **smoke** test through the generated bindings — restore from the fixture - mnemonic, assert the Regtest account xpubs and fingerprint match the fixture, derive - address index 0, and verify+sign the fixture PSBT to `signing.txid` using a Kotlin-side - `HttpTransport` stub for construction. Deep assertions stay in Rust; this proves the - binding surface, the JNI load and the `with_foreign` trait all work -- [ ] Run `JAVA_HOME=~/.jdks/temurin-17 ./gradlew -p minimal-sdk/android test` plus the - alignment and size checks — must pass before Task 9 - -### Task 9: Apple packaging — bindings, static libs, XCFramework, SwiftPM - -**Files:** -- Create: `minimal-sdk/scripts/build_apple.sh`, `minimal-sdk/apple/Package.swift`, - `minimal-sdk/apple/Tests/MinimalSdkTests/SmokeTests.swift` - -> **⚠️ Not verifiable on this machine.** XCFramework assembly needs macOS + Xcode. Write the -> scripts, the package manifest and the tests; verify on Linux everything that can be verified -> (Swift bindings generate from the built library, the scripts pass `bash -n`/`shellcheck`, the -> manifest is well-formed); mark the rest explicitly as CI-verified in Task 10 and human-signed -> in Post-Completion. **Do not claim a local pass.** - -- [ ] `generate_bindings.sh swift` in library mode, producing the Swift sources, the - `…FFI.h` header and the modulemap -- [ ] `build_apple.sh` modelled on `scripts/ci/package_swift_xcframework.sh`: build - `--release` **staticlib** for `aarch64-apple-ios` and the simulator targets, `lipo` the - simulator slices into one universal `.a`, then `xcodebuild -create-xcframework` with - device + simulator libraries and the headers directory -- [ ] `Package.swift` exposing a `binaryTarget` over the XCFramework plus a Swift target - carrying the generated bindings and a small `URLSession`-backed `HttpTransport` - conformance; iOS deployment target consistent with `minSdk`-equivalent policy and recorded - in the README -- [ ] Swift **smoke** test mirroring the Kotlin one: restore from the fixture mnemonic, assert - the Regtest xpubs/fingerprint, derive index 0, verify+sign the fixture PSBT to - `signing.txid` -- [ ] Note in the README that iOS links statically and dead-strips at app link, so the number - that matters is the **app-size delta**, not the `.a` size (the node's `.a` is 391.9 MB and - that figure has misled before) -- [ ] Verify locally what is verifiable: Swift bindings generate with no UDL; `bash -n` and - `shellcheck` pass on both scripts; `Package.swift` parses. Record in the progress notes - exactly which steps were **not** run and why -- [ ] Do not proceed to Task 10 until the above local checks pass - -### Task 10: CI for the mobile SDK - -**Files:** -- Create: `.github/workflows/minimal-sdk-mobile.yaml` - -- [ ] Workflow on push/PR **path-filtered** to `minimal-sdk/rust-sdk/**`, - `minimal-sdk/android/**`, `minimal-sdk/apple/**`, `minimal-sdk/scripts/**`, - `minimal-sdk/packages/client-sdk/test/fixtures/**` and the workflow file, plus - `workflow_dispatch`. Pin every action version; cache the cargo registry and the Gradle - user home -- [ ] Job 1 (ubuntu) — **the real gate**: `cargo test` for the Rust crate including the full - parity/adversarial suite, the size gate and the dependency-creep guard. Fast, no NDK -- [ ] Job 2 (ubuntu) — Android: NDK, `cargo ndk` for three ABIs, generate Kotlin bindings, - `./gradlew test`, the 16 KB alignment check, the arm64 size gate, `assembleRelease` -- [ ] Job 3 (**macos runner**) — Apple: Swift bindings, iOS + simulator static libs, - XCFramework, Swift smoke test. This is the job that first proves Task 9 -- [ ] The fixture path filter is deliberate: if `rgblib-parity.json` is regenerated the parity - suite must re-run. Say so in a comment in the workflow -- [ ] Tests: run Jobs 1 and 2's exact commands locally and confirm each exits zero on a clean - tree; then perturb one fixture value in the working tree, confirm the Rust parity suite - fails, and restore it — the gate must actually catch drift. Job 3 is verified by its - first CI run, not locally -- [ ] Run the local gates — must pass before Task 11 - -### Task 11: Verify acceptance criteria and update documentation - -**Files:** -- Create: `minimal-sdk/rust-sdk/README.md` -- Modify: `minimal-sdk/README.md`, `docs/design/mobile-sdk-and-lightweight-uniffi.md` - -- [ ] Full local gate green: `cargo test --manifest-path minimal-sdk/rust-sdk/Cargo.toml`, - `JAVA_HOME=~/.jdks/temurin-17 ./gradlew -p minimal-sdk/android test assembleRelease`, and - `pnpm -C minimal-sdk test` still green (this plan must not have touched the TS side — - confirm with `git status`) -- [ ] Verify acceptance item by item and record the evidence: all four networks' xpubs + - fingerprint match the fixture; all ten regtest addresses match; the fixture PSBT signs to - `signing.txid` with a verifying signature; all five checks fail individually on their own - tampering; the decoy test passes; intent binding fires on all five tamper shapes; **no** - `rgb-lib`/`reqwest`/`tokio`/TLS crate in the graph; the **measured arm64** `.so` size; the - 16 KB alignment result (pass, or the blocker); Kotlin smoke green; Swift smoke green **in - CI** (state the run) -- [ ] Write `minimal-sdk/rust-sdk/README.md`: quickstart (generate keys → register xpubs → - prepare → verify → sign → complete) with Kotlin **and** Swift snippets; the - verify-before-sign contract and what each of the five checks does; the **stated - limitation** that RGB allocations on spent inputs are not verifiable client-side - (`verify.ts:11-13`); the `HttpTransport` extension point; measured sizes for both - platforms with the raw-vs-compressed and app-size-delta notes -- [ ] **State the Block-3 mobile runtime constraints and this SDK's answer to each**: it is a - stateless client, so process death costs nothing beyond an in-flight request; every - binding call is **blocking** and must not run on the Android main thread or the iOS main - queue (say it plainly — the design doc flags ANR risk for the node SDK and it applies to - any uniffi surface); there is no node, so the fail-fast watchdog and battery/chain-backend - concerns do not apply. Being explicit about what does *not* apply is what makes the - minimal variant's case -- [ ] Update `docs/design/mobile-sdk-and-lightweight-uniffi.md`: record that Paths A2 and A3 - were **superseded by a single Rust core**, add the measured 1.94 MB host figure and the - measured arm64 figure, state the JNA cost honestly (≈2.46 MB vs ≈1.9 MB pure Kotlin) and - why implementation count won over ~500 KB, and record the 16 KB alignment finding in - Block 3 — the first time anything in this repo asserts it -- [ ] Update `minimal-sdk/README.md`: add the Rust core and both mobile packages to the - architecture section; note `pnpm-workspace.yaml` is unaffected; state that the TypeScript - SDK remains hand-written and is the **independent cross-check** on the Rust - implementation, not legacy to be replaced -- [ ] Confirm `git status` shows only files this plan names, and that no Rust file under - `src/`, no file under `bindings/`, no `packages/` file and no fixture was modified - -## Post-Completion (manual, not for the agent) - -- **Sign off the Apple path on real hardware.** Task 9 was written but never built locally, and - Task 10's macOS job only proves it assembles. Run the Swift smoke on a device and an iOS - simulator before any release. -- Build a throwaway consumer app on each platform with shrinking enabled and measure the - **app-size delta** — the number users feel, and the only meaningful iOS number. The task - gates measure artifacts, not deltas. -- Run one real end-to-end journey against a staging gateway from an Android device and an - iPhone: register xpubs, prepare a send, verify, sign, complete. The suites here are entirely - offline by design and prove parity, not integration. -- If the 16 KB alignment check failed, schedule the fix before any release targeting Android 15+. -- Decide publishing: Maven Central coordinates for the Android artifact and whether the Swift - package ships as a tagged repo or a binary release. Nothing is published yet, so names are - still free; coordinate with the node SDK's `rgb-lightning-node-android` line so the two are - not confused. -- Decide whether the React Native SDK (`rgb-sdk-rn`) should adopt this Rust core via a native - addon or stay on the TypeScript client SDK. Staying on TS preserves the independent - cross-check this plan deliberately keeps; adopting the core would remove it. -- Re-measure the uniffi **node** numbers on real arm64 before quoting them externally (design - doc Appendix C): every figure there is an x86_64 host build. diff --git a/minimal-sdk/.gitignore b/minimal-sdk/.gitignore new file mode 100644 index 00000000..480ab16d --- /dev/null +++ b/minimal-sdk/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +coverage/ diff --git a/minimal-sdk/.prettierignore b/minimal-sdk/.prettierignore new file mode 100644 index 00000000..118236d5 --- /dev/null +++ b/minimal-sdk/.prettierignore @@ -0,0 +1,16 @@ +pnpm-lock.yaml +dist/ +node_modules/ +# Generated by `pnpm --filter @utexo/minimal-gateway generate:rln-types`. +packages/gateway/src/rln/openapi.ts +# Build output and generated bindings of the native mobile SDK. Gitignored by +# nested .gitignore files, which prettier does not read. +rust-sdk/target/ +android/build/ +android/.gradle/ +android/generated/ +apple/.build/ +apple/build/ +apple/Sources/MinimalSdk/Generated/ +apple/Sources/MinimalSdkFFI/ +apple/MinimalSdkFFI.xcframework/ diff --git a/minimal-sdk/.prettierrc.json b/minimal-sdk/.prettierrc.json new file mode 100644 index 00000000..2fec1f28 --- /dev/null +++ b/minimal-sdk/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/minimal-sdk/README.md b/minimal-sdk/README.md new file mode 100644 index 00000000..52538601 --- /dev/null +++ b/minimal-sdk/README.md @@ -0,0 +1,177 @@ +# @utexo/minimal-sdk + +**Temporary home.** This pnpm workspace implements the first minimum of the Minimal SDK +variant designed in `docs/design/minimal-sdk-and-lightweight-rln.md`: a TypeScript API +gateway (`packages/gateway`) in front of ONE shared RLN instance, a browser/mobile client +SDK (`packages/client-sdk`) that keeps all key material client-side (verify-before-sign), +and an e2e suite (`packages/e2e`) on the repo's regtest infrastructure. It lives inside +the rgb-lightning-node repository only to reuse the regtest stack and stay close to the +design doc; extraction into its own repository (with its own CI) is a later, mechanical +step — no code here depends on the Rust sources. + +## Architecture + +From the design doc: + +``` +browser / mobile app + └─ minimal TS SDK (keys, PSBT verify + sign, invoice decode) + │ HTTPS (REST today; GraphQL/gRPC are gateway-layer options) + API gateway (per-user auth, per-user scoping + FIFO queue, idempotency keys) + ├─ per-user watch-only rgb-lib wallets (PSBT preparation, balances, receives) + └─ single RLN instance (admin token; LN channels, swaps, RGB transfers) + └─ shared esplora + RGB proxy +``` + +The server prepares transactions on the user's watch-only wallet; the client verifies +against its stated intent and signs; the server broadcasts. Package READMEs: +`packages/gateway/README.md` (config reference, deployment, float caps, the custody +boundary) and `packages/client-sdk/README.md` (quickstart, the verify-before-sign +contract); `packages/e2e/README.md` covers the regtest journey suite. + +### Native mobile: one Rust core, two packages + +For natively written Android and iOS apps the same client role is filled by **one Rust +crate** exposed to both platforms through uniffi +(`docs/plans/20260915-minimal-mobile-rust-sdk.md`): + +``` +Android app (Kotlin) iOS app (Swift) + └─ android/ AAR + JNA └─ apple/ SwiftPM + XCFramework + └──────────── rust-sdk/ (keys, derive, verify-before-sign, sign, invoice, gateway client) + │ HTTP through a host-implemented HttpTransport (OkHttp / URLSession) + API gateway (same REST surface as above) +``` + +- `rust-sdk/` — the core: `bitcoin` + `bip39` + `thiserror` + `uniffi`, nothing else; no + rgb-lib, no HTTP or TLS stack. `cargo test` is the gate (99 tests: 68 parity and + adversarial cases in `tests/parity.rs` plus 31 unit tests, a release size gate, a + dependency-creep guard). README: `rust-sdk/README.md` (quickstart in Kotlin + and Swift, the five checks, the `HttpTransport` seam, sizes, mobile runtime constraints). +- `android/` — Gradle library `com.utexo.minimalsdk` (minSdk 24, JNA runtime dependency), + JNI libs for three ABIs with a 16 KB page-alignment gate and a 3.0 MB arm64 size gate. +- `apple/` — Swift package `MinimalSdk` (iOS 15 / macOS 12), static XCFramework built by + `scripts/build_apple.sh` on macOS. +- `scripts/` — `generate_bindings.sh` (uniffi library mode, no UDL), `build_android.sh`, + `build_apple.sh`. CI: `.github/workflows/minimal-sdk-mobile.yaml`. + +**The TypeScript SDK stays hand-written and stays shipped.** It is the behavioural +reference the Rust crate was ported from and is the **independent cross-check** on the +Rust implementation, not legacy to be replaced: both read the same rgb-lib-authored +`packages/client-sdk/test/fixtures/rgblib-parity.json` and share no code, so a shared +misreading of BIP-86 or the RGB coin types cannot hide. Web and React Native keep using +it (185.5 KB of pure JS); native Kotlin and Swift apps use the Rust core. + +`pnpm-workspace.yaml` is unaffected: it globs `packages/*` only, so the Cargo, Gradle and +SwiftPM directories above are invisible to pnpm and none of the workspace commands below +touch them. + +## Deliberately deferred + +- **RLN-side Block-3 fixes** (design doc items 4–7) — Rust changes; this workspace + changes no Rust code, they ship as a separate PR series before scaling user count. +- **Auto-sweep** of the LN float to user keys — the capped float bounds custodial + exposure first; sweeping adds a client-signing round-trip worth designing properly. +- **Submarine/atomic swaps** for trust-minimized float in/out — weeks of effort on the + existing swap API; the capped float is the interim control. +- **BOLT12 offers** — stay unexposed to avoid the known double-pay on retry + (`src/routes.rs:5093-5095`). +- **VSS / encrypted client backup** — the client is stateless between sessions; its only + durable secret is the mnemonic, backed up as a seed phrase. +- **GraphQL/gRPC front-end** — REST is what RLN speaks today; other transports are a + gateway-layer swap that changes no SDK surface. +- **Extraction to its own repository** — mechanical; it lives here only to reuse the + regtest stack during bring-up. + +## Workspace commands + +```sh +pnpm -C minimal-sdk install +pnpm -C minimal-sdk build # tsc build of all packages +pnpm -C minimal-sdk test # unit tests (gateway + client-sdk + the e2e package's + # infra-free smoke test; the regtest journey is excluded) +pnpm -C minimal-sdk lint # eslint + prettier check +pnpm -C minimal-sdk format # prettier --write +pnpm -C minimal-sdk e2e # regtest e2e journey (requires local infra, see below) +``` + +Two gateway suites additionally exercise the **real** rgb-lib backend against regtest and +are skipped unless opted in (`describe.runIf(process.env['WALLET_REGTEST'] === '1')`): + +```sh +ESPLORA=1 ./regtest.sh start # from the repo root, on a fresh chain +WALLET_REGTEST=1 pnpm --filter @utexo/minimal-gateway test wallet-integration +WALLET_REGTEST=1 pnpm --filter @utexo/minimal-gateway test onchain-integration +``` + +Run `wallet-integration` first: the two share a fixture account, and the on-chain suite +creates colorable UTXOs that would break the other's `INSUFFICIENT_FUNDS` assertion. + +### Generated files + +- `packages/gateway/src/rln/openapi.ts` — regenerate with + `pnpm --filter @utexo/minimal-gateway generate:rln-types` whenever the repo-root + `openapi.yaml` changes an endpoint the gateway calls. +- `packages/client-sdk/test/fixtures/rgblib-parity.json` — regenerate with + `pnpm --filter @utexo/minimal-client-sdk generate:fixtures` on an `@utexo/rgb-lib` bump. + +## Regtest bring-up (verified 2026-09-09, Preflight B) + +From the repository root: + +```sh +# 1. start bitcoind + electrs (tcp://localhost:50001) + RGB proxy (http://localhost:3000); +# ESPLORA=1 additionally starts the esplora REST API on http://localhost:3002 +ESPLORA=1 ./regtest.sh start + +# 2. build (if needed) and start an RLN instance +cargo build +mkdir -p /tmp/rln-data +target/debug/rgb-lightning-node /tmp/rln-data --daemon-listening-port 3101 \ + --ldk-peer-listening-port 9835 --network regtest --disable-authentication + +# 3. init + unlock, then verify the node answers +curl -X POST http://localhost:3101/init -H 'Content-Type: application/json' \ + -d '{"password":"nodepassword"}' +curl -X POST http://localhost:3101/unlock -H 'Content-Type: application/json' -d '{ + "password": "nodepassword", + "ldk_chain_sync": {"mode": "BlockSync", "config": { + "bitcoind_rpc_username": "user", "bitcoind_rpc_password": "password", + "bitcoind_rpc_host": "localhost", "bitcoind_rpc_port": 18443}}, + "indexer_url": "127.0.0.1:50001", + "proxy_endpoint": "rpc://127.0.0.1:3000/json-rpc", + "announce_addresses": []}' +curl http://localhost:3101/nodeinfo + +# teardown (stops containers and deletes data dirs) +./regtest.sh stop +``` + +`--disable-authentication` is acceptable strictly because RLN listens on localhost and +only the gateway talks to it (design doc invariant I4); never expose RLN publicly. + +Fund/mine helpers: `./regtest.sh sendtoaddress `, `./regtest.sh mine `. + +## rgb-lib binding status (verified 2026-09-09, Preflight A) + +`@utexo/rgb-lib@0.3.0-beta.18` supports **watch-only** (mnemonic-less) wallets, but its +published JS `wrapper.js` is stale: the compiled native module takes +`rgblib_new_wallet(walletData, keys)` (2 args) while the wrapper passes 1, and several +signatures differ (`send_begin` takes 8 args incl. `expiration_timestamp_opt`, +`blind_receive`/`witness_receive` take an expiration timestamp instead of a duration). +The gateway therefore calls the native module +(`@utexo/rgb-lib-linux-x64/rgblib`) directly through its own thin shim. Verified on +regtest end-to-end with an external signer: watch-only construction +(`keys.mnemonic: null`), `getAddress`, `listUnspents`, `issueAssetNIA`, `blindReceive`, +`witnessReceive`, `createUtxosBegin` → external `signPsbt` → `createUtxosEnd`, and +`sendBegin` → external `signPsbt` → `sendEnd` with the transfer settling on the +recipient. Marshalling rules for the shim: + +- all numbers are passed as strings (`feeRate`, `minConfirmations`, `num`, `size`); +- JSON payloads are camelCase (`WalletData`, `SinglesigKeys`, `Recipient`); +- `Assignment` is externally tagged with a JSON number: `{"Fungible": 100}`; +- JS `null` maps to a NULL pointer ONLY for params compiled with the nullable typemap + (`asset_id_opt`, `num`/`size` opts); `expiration_timestamp_opt` is NOT nullable — always + pass a concrete unix-timestamp string; +- `send_begin` returns a JSON-serialized object (take `.psbt`), while + `create_utxos_begin` returns the raw PSBT base64 string. diff --git a/minimal-sdk/android/.gitignore b/minimal-sdk/android/.gitignore new file mode 100644 index 00000000..23279970 --- /dev/null +++ b/minimal-sdk/android/.gitignore @@ -0,0 +1,6 @@ +# Generated by ../scripts/generate_bindings.sh and ../scripts/build_android.sh. +/generated/ +/build/ +/.gradle/ +/.kotlin/ +/local.properties diff --git a/minimal-sdk/android/README.md b/minimal-sdk/android/README.md new file mode 100644 index 00000000..9d6bda71 --- /dev/null +++ b/minimal-sdk/android/README.md @@ -0,0 +1,92 @@ +# UTEXO minimal SDK — Android package + +Kotlin bindings and JNI libraries for the minimal SDK core in +[`../rust-sdk`](../rust-sdk): keys, taproot derivation, verify-before-sign, +signing, invoice decoding and the gateway client, in one Rust implementation. +This is **not** the node: no LDK, no rgb-lib, no HTTP or TLS stack. + +- Namespace `com.utexo.minimalsdk`, `minSdk 24`, `compileSdk 34`, AGP 8.7.3, + Kotlin 1.9.24, JDK 17, own Gradle 8.9 wrapper. +- Runtime dependency: `net.java.dev.jna:jna:5.14.0@aar` only (uniffi's Kotlin + bindings call Rust through JNA). +- Networking is the app's: implement `HttpTransport` (one synchronous `send`) + over OkHttp or `HttpURLConnection` and hand it to `GatewayClient`. Rust + performs no IO. + +## Build + +```sh +export JAVA_HOME=~/.jdks/temurin-17 # any JDK 17 +../scripts/generate_bindings.sh kotlin # uniffi library mode → generated/kotlin +../scripts/build_android.sh # cargo ndk (3 ABIs) → generated/jniLibs, then gates +./gradlew test # JVM smoke test through the bindings +./gradlew assembleRelease # AAR in build/outputs/aar +``` + +`./gradlew test` and `assembleRelease` run the two scripts themselves when +their outputs are missing or stale. Requirements: Rust with the three Android +targets, `cargo-ndk`, an Android NDK (r28 or newer; `build_android.sh` picks +the newest under `$ANDROID_HOME/ndk`), and the Android SDK. The unit tests +load the **host** release cdylib through JNA, so `./gradlew test` needs cargo +but no NDK and no device. + +Script options: + +- `build_android.sh [--check-only] [jni_libs_dir]` — `--check-only` re-runs + the gates on an existing tree without building; `jni_libs_dir` defaults to + `generated/jniLibs`. Environment: `ANDROID_NDK_ROOT` / `ANDROID_NDK_HOME` + (pin an NDK instead of the newest installed), `ANDROID_API` (minSdk the + `.so` targets, default 24), `ANDROID_ARM64_BUDGET` (size gate in bytes, + default 3000000). +- `generate_bindings.sh kotlin [output_dir] [library_path]` — `output_dir` + defaults to `generated/kotlin`; pass `library_path` to generate from an + existing unstripped build instead of running `cargo build --lib`. + +## Release gates (`build_android.sh`, also `./gradlew checkAndroidJniLibs`) + +1. **16 KB page alignment.** Every `PT_LOAD` segment of every produced `.so` + must be aligned to at least 16384 bytes. Android 15+ requires it for native + code; a misaligned library is a release blocker. NDK r28+ aligns the 64-bit + ABIs by default but **not** `armeabi-v7a` (measured 4096 on r29), so the + script passes `-Wl,-z,max-page-size=16384` to every target explicitly. +2. **Exported entry points.** Every `.so` must export the uniffi entry points + (`llvm-nm -D` finds `uniffi_utexo_minimal_sdk_fn_func_sdk_version`); a + library that lost them links fine and fails at the first JNA call. +3. **Size.** The single `arm64-v8a` slice must stay under **3.0 MB raw**. + +Measured on 2026-09-15 with NDK r29 (29.0.14206865), release profile +`opt-level="z"`, thin LTO, stripped: + +| Artifact | Raw bytes | +| ------------------------------------------- | --------: | +| `arm64-v8a/libutexo_minimal_sdk.so` | 2 366 720 | +| `armeabi-v7a/libutexo_minimal_sdk.so` | 1 992 820 | +| `x86_64/libutexo_minimal_sdk.so` | 2 508 984 | +| AAR (all three ABIs + 514 KB `classes.jar`) | 5 449 477 | +| JNA 5.14.0 AAR (consumer-side dependency) | 517 894 | + +What a device installs is one slice plus JNA: **≈2.88 MB on arm64**, matching +the plan's honest comparison of ≈2.46 MB (x86_64 host estimate) against +≈1.9 MB for pure Kotlin. Run `build_android.sh --check-only` to reprint the +current numbers; `../rust-sdk/tests/parity.rs` gates the host build at 2.5 MB. + +**Install size tracks the raw `.so` number, not a compressed one.** APKs and +App Bundles store native libraries uncompressed (`extractNativeLibs=false`, +the default since API 23) so they can be mapped directly. About 1.06 MB of +each slice is libsecp256k1's precomputed tables, which any secp256k1 binding +(Rust or Kotlin) carries. + +## One `.so` per device + +The AAR carries all three ABIs. A consumer downloads one slice by shipping as +an **App Bundle** (Play serves the device's ABI) or by enabling ABI `splits` +on the app module; this library declares the same three-ABI split set so no +stray ABI is ever packaged. + +## R8 + +`consumer-rules.pro` keeps only the JNA entry points the generated bindings +reach reflectively (the `Library` interface, `Structure` and `ByReference` +subclasses, `Callback` implementations) plus `RuntimeVisibleAnnotations`, which +JNA needs to read `@Structure.FieldOrder`. There is no blanket keep of +`com.utexo.minimalsdk`; the rest of the package shrinks like any Kotlin code. diff --git a/minimal-sdk/android/build.gradle.kts b/minimal-sdk/android/build.gradle.kts new file mode 100644 index 00000000..24803743 --- /dev/null +++ b/minimal-sdk/android/build.gradle.kts @@ -0,0 +1,171 @@ +// Android packaging of the minimal SDK core (minimal-sdk/rust-sdk). +// +// Conventions follow bindings/kotlin-android-sdk/build.gradle.kts (AGP 8.7.3, +// Kotlin 1.9.24, JDK 17, compileSdk 34, minSdk 24, consumerProguardFiles, +// singleVariant("release")) without its uniffi-node source wiring and +// without JReleaser: nothing is published from this project. +// +// Inputs, both produced by scripts under ../scripts and gitignored here: +// generated/kotlin uniffi Kotlin bindings (library mode, no UDL) +// generated/jniLibs /libutexo_minimal_sdk.so for the three ABIs +// The JVM unit tests load the HOST release cdylib through JNA instead of the +// Android .so files, so `./gradlew test` needs cargo and no NDK. + +plugins { + id("com.android.library") version "8.7.3" + id("org.jetbrains.kotlin.android") version "1.9.24" +} + +group = "com.utexo" +version = "0.1.0" + +val rustSdkDir = layout.projectDirectory.dir("../rust-sdk") +val scriptsDir = layout.projectDirectory.dir("../scripts") +val generatedKotlinDir = layout.projectDirectory.dir("generated/kotlin") +val generatedJniLibsDir = layout.projectDirectory.dir("generated/jniLibs") +val hostReleaseDir = rustSdkDir.dir("target/release") +// The rgb-lib-authored parity fixture, read by RELATIVE path from the +// TypeScript client SDK. Never copied, never regenerated here. +val parityFixture = + layout.projectDirectory.file("../packages/client-sdk/test/fixtures/rgblib-parity.json") + +dependencies { + // uniffi's Kotlin bindings talk to the Rust library through JNA. This is + // the library's only dependency. + api("net.java.dev.jna:jna:5.14.0@aar") + + // Test-only: the plain JNA jar carries the host (linux/mac x86_64, arm64) + // jnidispatch the AAR lacks, so JVM unit tests can load the host cdylib. + testImplementation("net.java.dev.jna:jna:5.14.0") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.9.24") + testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20240303") +} + +android { + namespace = "com.utexo.minimalsdk" + compileSdk = 34 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + defaultConfig { + minSdk = 24 + consumerProguardFiles("consumer-rules.pro") + ndk { + abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") + } + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + // ABI splits. The AAR itself always carries all three slices; what makes + // a consumer download ONE .so is the app shipping as an App Bundle (Play + // serves the device's ABI) or configuring the same splits on the app. + // Declaring them here documents the supported set and keeps the AAR from + // ever carrying a stray ABI. + splits { + abi { + isEnable = true + reset() + include("arm64-v8a", "armeabi-v7a", "x86_64") + isUniversalApk = false + } + } + + sourceSets { + getByName("main") { + kotlin.srcDir(generatedKotlinDir) + jniLibs.srcDir(generatedJniLibsDir) + } + } + + publishing { + singleVariant("release") { + withSourcesJar() + } + } + + testOptions { + unitTests.all { test -> + // JNA resolves "utexo_minimal_sdk" against the host release build. + test.systemProperty("jna.library.path", hostReleaseDir.asFile.absolutePath) + test.systemProperty("utexo.minimalsdk.fixture", parityFixture.asFile.absolutePath) + test.systemProperty( + "utexo.minimalsdk.consumerRules", + layout.projectDirectory.file("consumer-rules.pro").asFile.absolutePath, + ) + test.testLogging { + events("passed", "failed", "skipped") + showStandardStreams = true + } + } + } +} + +// ---- Rust build steps, wired so `./gradlew test` and `./gradlew assemble` +// ---- are self-contained on a clean checkout. + +val rustSources = fileTree(rustSdkDir.dir("src")) + files(rustSdkDir.file("Cargo.toml"), rustSdkDir.file("Cargo.lock")) + +// Host release cdylib for the JVM unit tests (same profile the Rust size +// gate measures). +val buildHostLibrary by tasks.registering(Exec::class) { + description = "cargo build --release --lib of the minimal SDK core for the host" + group = "rust" + workingDir = rustSdkDir.asFile + commandLine("cargo", "build", "--release", "--lib") + // Always run: cargo is incremental itself, and Gradle's own up-to-date + // check would need inputs/outputs it cannot see (the cargo target dir). + outputs.upToDateWhen { false } +} + +val generateKotlinBindings by tasks.registering(Exec::class) { + description = "uniffi library-mode Kotlin bindings into generated/kotlin" + group = "rust" + commandLine("bash", scriptsDir.file("generate_bindings.sh").asFile.absolutePath, "kotlin", generatedKotlinDir.asFile.absolutePath) + inputs.files(rustSources) + inputs.file(rustSdkDir.file("uniffi.toml")) + outputs.dir(generatedKotlinDir) +} + +val buildAndroidJniLibs by tasks.registering(Exec::class) { + description = "cargo ndk for arm64-v8a, armeabi-v7a, x86_64 plus the 16 KB alignment, export and size gates" + group = "rust" + commandLine("bash", scriptsDir.file("build_android.sh").asFile.absolutePath, generatedJniLibsDir.asFile.absolutePath) + inputs.files(rustSources) + outputs.dir(generatedJniLibsDir) +} + +val checkAndroidJniLibs by tasks.registering(Exec::class) { + description = "Re-run the 16 KB alignment, export and arm64 size gates on generated/jniLibs" + group = "verification" + dependsOn(buildAndroidJniLibs) + commandLine("bash", scriptsDir.file("build_android.sh").asFile.absolutePath, "--check-only", generatedJniLibsDir.asFile.absolutePath) +} + +tasks.named("preBuild") { + dependsOn(generateKotlinBindings) +} + +// The .so files are only needed when the AAR is packaged, not for unit tests. +tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach { + dependsOn(buildAndroidJniLibs) +} +tasks.matching { it.name.startsWith("bundle") && it.name.endsWith("Aar") }.configureEach { + dependsOn(checkAndroidJniLibs) +} + +tasks.withType().configureEach { + dependsOn(buildHostLibrary) +} diff --git a/minimal-sdk/android/consumer-rules.pro b/minimal-sdk/android/consumer-rules.pro new file mode 100644 index 00000000..8c628ac5 --- /dev/null +++ b/minimal-sdk/android/consumer-rules.pro @@ -0,0 +1,27 @@ +# Consumer R8/ProGuard rules for the UTEXO minimal SDK. +# +# Only what R8 must not strip or rename: the JNA entry points the generated +# uniffi bindings rely on through reflection. Everything else in +# com.utexo.minimalsdk is ordinary Kotlin and may be shrunk and obfuscated by +# the consumer like any other library code. Deliberately NO blanket +# `-keep class com.utexo.minimalsdk.** { *; }`. + +# The `UniffiLib : Library` interface: JNA binds each method by NAME to a +# symbol exported from libutexo_minimal_sdk.so, so the names must survive. +-keep interface * extends com.sun.jna.Library { *; } +# JNA Structures (RustBuffer, ForeignBytes, UniffiRustCallStatus, ...) are +# laid out from their declared field names and order. The order comes from +# the runtime-retained @Structure.FieldOrder annotation, which JNA reads +# reflectively; R8 drops it unless the attribute is kept, and then every FFI +# call fails with "Structure.getFieldOrder() ... does not provide enough names". +-keep class * extends com.sun.jna.Structure { *; } +-keepattributes RuntimeVisibleAnnotations +-keep class * extends com.sun.jna.ptr.ByReference { *; } +# Foreign-trait callbacks (HttpTransport) are invoked from Rust through a +# JNA Callback whose single `callback` method JNA finds reflectively. +-keep class * implements com.sun.jna.Callback { *; } +-keep class * extends com.sun.jna.Callback { *; } +# JNA's own runtime is loaded reflectively (Native, Structure, ...). +-keep class com.sun.jna.** { *; } +# JNA references java.awt (absent on Android); silence the missing-class warning. +-dontwarn java.awt.** diff --git a/minimal-sdk/android/gradle.properties b/minimal-sdk/android/gradle.properties new file mode 100644 index 00000000..e6961679 --- /dev/null +++ b/minimal-sdk/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official diff --git a/minimal-sdk/android/gradle/wrapper/gradle-wrapper.jar b/minimal-sdk/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..2c3521197d7c4586c843d1d3e9090525f1898cde GIT binary patch literal 43504 zcma&N1CXTcmMvW9vTb(Rwr$&4wr$(C?dmSu>@vG-+vuvg^_??!{yS%8zW-#zn-LkA z5&1^$^{lnmUON?}LBF8_K|(?T0Ra(xUH{($5eN!MR#ZihR#HxkUPe+_R8Cn`RRs(P z_^*#_XlXmGv7!4;*Y%p4nw?{bNp@UZHv1?Um8r6)Fei3p@ClJn0ECfg1hkeuUU@Or zDaPa;U3fE=3L}DooL;8f;P0ipPt0Z~9P0)lbStMS)ag54=uL9ia-Lm3nh|@(Y?B`; zx_#arJIpXH!U{fbCbI^17}6Ri*H<>OLR%c|^mh8+)*h~K8Z!9)DPf zR2h?lbDZQ`p9P;&DQ4F0sur@TMa!Y}S8irn(%d-gi0*WxxCSk*A?3lGh=gcYN?FGl z7D=Js!i~0=u3rox^eO3i@$0=n{K1lPNU zwmfjRVmLOCRfe=seV&P*1Iq=^i`502keY8Uy-WNPwVNNtJFx?IwAyRPZo2Wo1+S(xF37LJZ~%i)kpFQ3Fw=mXfd@>%+)RpYQLnr}B~~zoof(JVm^^&f zxKV^+3D3$A1G;qh4gPVjhrC8e(VYUHv#dy^)(RoUFM?o%W-EHxufuWf(l*@-l+7vt z=l`qmR56K~F|v<^Pd*p~1_y^P0P^aPC##d8+HqX4IR1gu+7w#~TBFphJxF)T$2WEa zxa?H&6=Qe7d(#tha?_1uQys2KtHQ{)Qco)qwGjrdNL7thd^G5i8Os)CHqc>iOidS} z%nFEDdm=GXBw=yXe1W-ShHHFb?Cc70+$W~z_+}nAoHFYI1MV1wZegw*0y^tC*s%3h zhD3tN8b=Gv&rj}!SUM6|ajSPp*58KR7MPpI{oAJCtY~JECm)*m_x>AZEu>DFgUcby z1Qaw8lU4jZpQ_$;*7RME+gq1KySGG#Wql>aL~k9tLrSO()LWn*q&YxHEuzmwd1?aAtI zBJ>P=&$=l1efe1CDU;`Fd+_;&wI07?V0aAIgc(!{a z0Jg6Y=inXc3^n!U0Atk`iCFIQooHqcWhO(qrieUOW8X(x?(RD}iYDLMjSwffH2~tB z)oDgNBLB^AJBM1M^c5HdRx6fBfka`(LD-qrlh5jqH~);#nw|iyp)()xVYak3;Ybik z0j`(+69aK*B>)e_p%=wu8XC&9e{AO4c~O1U`5X9}?0mrd*m$_EUek{R?DNSh(=br# z#Q61gBzEpmy`$pA*6!87 zSDD+=@fTY7<4A?GLqpA?Pb2z$pbCc4B4zL{BeZ?F-8`s$?>*lXXtn*NC61>|*w7J* z$?!iB{6R-0=KFmyp1nnEmLsA-H0a6l+1uaH^g%c(p{iT&YFrbQ$&PRb8Up#X3@Zsk zD^^&LK~111%cqlP%!_gFNa^dTYT?rhkGl}5=fL{a`UViaXWI$k-UcHJwmaH1s=S$4 z%4)PdWJX;hh5UoK?6aWoyLxX&NhNRqKam7tcOkLh{%j3K^4Mgx1@i|Pi&}<^5>hs5 zm8?uOS>%)NzT(%PjVPGa?X%`N2TQCKbeH2l;cTnHiHppPSJ<7y-yEIiC!P*ikl&!B z%+?>VttCOQM@ShFguHVjxX^?mHX^hSaO_;pnyh^v9EumqSZTi+#f&_Vaija0Q-e*| z7ulQj6Fs*bbmsWp{`auM04gGwsYYdNNZcg|ph0OgD>7O}Asn7^Z=eI>`$2*v78;sj-}oMoEj&@)9+ycEOo92xSyY344^ z11Hb8^kdOvbf^GNAK++bYioknrpdN>+u8R?JxG=!2Kd9r=YWCOJYXYuM0cOq^FhEd zBg2puKy__7VT3-r*dG4c62Wgxi52EMCQ`bKgf*#*ou(D4-ZN$+mg&7$u!! z-^+Z%;-3IDwqZ|K=ah85OLwkO zKxNBh+4QHh)u9D?MFtpbl)us}9+V!D%w9jfAMYEb>%$A;u)rrI zuBudh;5PN}_6J_}l55P3l_)&RMlH{m!)ai-i$g)&*M`eN$XQMw{v^r@-125^RRCF0 z^2>|DxhQw(mtNEI2Kj(;KblC7x=JlK$@78`O~>V!`|1Lm-^JR$-5pUANAnb(5}B}JGjBsliK4& zk6y(;$e&h)lh2)L=bvZKbvh@>vLlreBdH8No2>$#%_Wp1U0N7Ank!6$dFSi#xzh|( zRi{Uw%-4W!{IXZ)fWx@XX6;&(m_F%c6~X8hx=BN1&q}*( zoaNjWabE{oUPb!Bt$eyd#$5j9rItB-h*5JiNi(v^e|XKAj*8(k<5-2$&ZBR5fF|JA z9&m4fbzNQnAU}r8ab>fFV%J0z5awe#UZ|bz?Ur)U9bCIKWEzi2%A+5CLqh?}K4JHi z4vtM;+uPsVz{Lfr;78W78gC;z*yTch~4YkLr&m-7%-xc ztw6Mh2d>_iO*$Rd8(-Cr1_V8EO1f*^@wRoSozS) zy1UoC@pruAaC8Z_7~_w4Q6n*&B0AjOmMWa;sIav&gu z|J5&|{=a@vR!~k-OjKEgPFCzcJ>#A1uL&7xTDn;{XBdeM}V=l3B8fE1--DHjSaxoSjNKEM9|U9#m2<3>n{Iuo`r3UZp;>GkT2YBNAh|b z^jTq-hJp(ebZh#Lk8hVBP%qXwv-@vbvoREX$TqRGTgEi$%_F9tZES@z8Bx}$#5eeG zk^UsLBH{bc2VBW)*EdS({yw=?qmevwi?BL6*=12k9zM5gJv1>y#ML4!)iiPzVaH9% zgSImetD@dam~e>{LvVh!phhzpW+iFvWpGT#CVE5TQ40n%F|p(sP5mXxna+Ev7PDwA zamaV4m*^~*xV+&p;W749xhb_X=$|LD;FHuB&JL5?*Y2-oIT(wYY2;73<^#46S~Gx| z^cez%V7x$81}UWqS13Gz80379Rj;6~WdiXWOSsdmzY39L;Hg3MH43o*y8ibNBBH`(av4|u;YPq%{R;IuYow<+GEsf@R?=@tT@!}?#>zIIn0CoyV!hq3mw zHj>OOjfJM3F{RG#6ujzo?y32m^tgSXf@v=J$ELdJ+=5j|=F-~hP$G&}tDZsZE?5rX ztGj`!S>)CFmdkccxM9eGIcGnS2AfK#gXwj%esuIBNJQP1WV~b~+D7PJTmWGTSDrR` zEAu4B8l>NPuhsk5a`rReSya2nfV1EK01+G!x8aBdTs3Io$u5!6n6KX%uv@DxAp3F@{4UYg4SWJtQ-W~0MDb|j-$lwVn znAm*Pl!?Ps&3wO=R115RWKb*JKoexo*)uhhHBncEDMSVa_PyA>k{Zm2(wMQ(5NM3# z)jkza|GoWEQo4^s*wE(gHz?Xsg4`}HUAcs42cM1-qq_=+=!Gk^y710j=66(cSWqUe zklbm8+zB_syQv5A2rj!Vbw8;|$@C!vfNmNV!yJIWDQ>{+2x zKjuFX`~~HKG~^6h5FntRpnnHt=D&rq0>IJ9#F0eM)Y-)GpRjiN7gkA8wvnG#K=q{q z9dBn8_~wm4J<3J_vl|9H{7q6u2A!cW{bp#r*-f{gOV^e=8S{nc1DxMHFwuM$;aVI^ zz6A*}m8N-&x8;aunp1w7_vtB*pa+OYBw=TMc6QK=mbA-|Cf* zvyh8D4LRJImooUaSb7t*fVfih<97Gf@VE0|z>NcBwBQze);Rh!k3K_sfunToZY;f2 z^HmC4KjHRVg+eKYj;PRN^|E0>Gj_zagfRbrki68I^#~6-HaHg3BUW%+clM1xQEdPYt_g<2K+z!$>*$9nQ>; zf9Bei{?zY^-e{q_*|W#2rJG`2fy@{%6u0i_VEWTq$*(ZN37|8lFFFt)nCG({r!q#9 z5VK_kkSJ3?zOH)OezMT{!YkCuSSn!K#-Rhl$uUM(bq*jY? zi1xbMVthJ`E>d>(f3)~fozjg^@eheMF6<)I`oeJYx4*+M&%c9VArn(OM-wp%M<-`x z7sLP1&3^%Nld9Dhm@$3f2}87!quhI@nwd@3~fZl_3LYW-B?Ia>ui`ELg z&Qfe!7m6ze=mZ`Ia9$z|ARSw|IdMpooY4YiPN8K z4B(ts3p%2i(Td=tgEHX z0UQ_>URBtG+-?0E;E7Ld^dyZ;jjw0}XZ(}-QzC6+NN=40oDb2^v!L1g9xRvE#@IBR zO!b-2N7wVfLV;mhEaXQ9XAU+>=XVA6f&T4Z-@AX!leJ8obP^P^wP0aICND?~w&NykJ#54x3_@r7IDMdRNy4Hh;h*!u(Ol(#0bJdwEo$5437-UBjQ+j=Ic>Q2z` zJNDf0yO6@mr6y1#n3)s(W|$iE_i8r@Gd@!DWDqZ7J&~gAm1#~maIGJ1sls^gxL9LLG_NhU!pTGty!TbhzQnu)I*S^54U6Yu%ZeCg`R>Q zhBv$n5j0v%O_j{QYWG!R9W?5_b&67KB$t}&e2LdMvd(PxN6Ir!H4>PNlerpBL>Zvyy!yw z-SOo8caEpDt(}|gKPBd$qND5#a5nju^O>V&;f890?yEOfkSG^HQVmEbM3Ugzu+UtH zC(INPDdraBN?P%kE;*Ae%Wto&sgw(crfZ#Qy(<4nk;S|hD3j{IQRI6Yq|f^basLY; z-HB&Je%Gg}Jt@={_C{L$!RM;$$|iD6vu#3w?v?*;&()uB|I-XqEKqZPS!reW9JkLewLb!70T7n`i!gNtb1%vN- zySZj{8-1>6E%H&=V}LM#xmt`J3XQoaD|@XygXjdZ1+P77-=;=eYpoEQ01B@L*a(uW zrZeZz?HJsw_4g0vhUgkg@VF8<-X$B8pOqCuWAl28uB|@r`19DTUQQsb^pfqB6QtiT z*`_UZ`fT}vtUY#%sq2{rchyfu*pCg;uec2$-$N_xgjZcoumE5vSI{+s@iLWoz^Mf; zuI8kDP{!XY6OP~q5}%1&L}CtfH^N<3o4L@J@zg1-mt{9L`s^z$Vgb|mr{@WiwAqKg zp#t-lhrU>F8o0s1q_9y`gQNf~Vb!F%70f}$>i7o4ho$`uciNf=xgJ>&!gSt0g;M>*x4-`U)ysFW&Vs^Vk6m%?iuWU+o&m(2Jm26Y(3%TL; zA7T)BP{WS!&xmxNw%J=$MPfn(9*^*TV;$JwRy8Zl*yUZi8jWYF>==j~&S|Xinsb%c z2?B+kpet*muEW7@AzjBA^wAJBY8i|#C{WtO_or&Nj2{=6JTTX05}|H>N2B|Wf!*3_ z7hW*j6p3TvpghEc6-wufFiY!%-GvOx*bZrhZu+7?iSrZL5q9}igiF^*R3%DE4aCHZ zqu>xS8LkW+Auv%z-<1Xs92u23R$nk@Pk}MU5!gT|c7vGlEA%G^2th&Q*zfg%-D^=f z&J_}jskj|Q;73NP4<4k*Y%pXPU2Thoqr+5uH1yEYM|VtBPW6lXaetokD0u z9qVek6Q&wk)tFbQ8(^HGf3Wp16gKmr>G;#G(HRBx?F`9AIRboK+;OfHaLJ(P>IP0w zyTbTkx_THEOs%Q&aPrxbZrJlio+hCC_HK<4%f3ZoSAyG7Dn`=X=&h@m*|UYO-4Hq0 z-Bq&+Ie!S##4A6OGoC~>ZW`Y5J)*ouaFl_e9GA*VSL!O_@xGiBw!AF}1{tB)z(w%c zS1Hmrb9OC8>0a_$BzeiN?rkPLc9%&;1CZW*4}CDDNr2gcl_3z+WC15&H1Zc2{o~i) z)LLW=WQ{?ricmC`G1GfJ0Yp4Dy~Ba;j6ZV4r{8xRs`13{dD!xXmr^Aga|C=iSmor% z8hi|pTXH)5Yf&v~exp3o+sY4B^^b*eYkkCYl*T{*=-0HniSA_1F53eCb{x~1k3*`W zr~};p1A`k{1DV9=UPnLDgz{aJH=-LQo<5%+Em!DNN252xwIf*wF_zS^!(XSm(9eoj z=*dXG&n0>)_)N5oc6v!>-bd(2ragD8O=M|wGW z!xJQS<)u70m&6OmrF0WSsr@I%T*c#Qo#Ha4d3COcX+9}hM5!7JIGF>7<~C(Ear^Sn zm^ZFkV6~Ula6+8S?oOROOA6$C&q&dp`>oR-2Ym3(HT@O7Sd5c~+kjrmM)YmgPH*tL zX+znN>`tv;5eOfX?h{AuX^LK~V#gPCu=)Tigtq9&?7Xh$qN|%A$?V*v=&-2F$zTUv z`C#WyIrChS5|Kgm_GeudCFf;)!WH7FI60j^0o#65o6`w*S7R@)88n$1nrgU(oU0M9 zx+EuMkC>(4j1;m6NoGqEkpJYJ?vc|B zOlwT3t&UgL!pX_P*6g36`ZXQ; z9~Cv}ANFnJGp(;ZhS(@FT;3e)0)Kp;h^x;$*xZn*k0U6-&FwI=uOGaODdrsp-!K$Ac32^c{+FhI-HkYd5v=`PGsg%6I`4d9Jy)uW0y%) zm&j^9WBAp*P8#kGJUhB!L?a%h$hJgQrx!6KCB_TRo%9{t0J7KW8!o1B!NC)VGLM5! zpZy5Jc{`r{1e(jd%jsG7k%I+m#CGS*BPA65ZVW~fLYw0dA-H_}O zrkGFL&P1PG9p2(%QiEWm6x;U-U&I#;Em$nx-_I^wtgw3xUPVVu zqSuKnx&dIT-XT+T10p;yjo1Y)z(x1fb8Dzfn8e yu?e%!_ptzGB|8GrCfu%p?(_ zQccdaaVK$5bz;*rnyK{_SQYM>;aES6Qs^lj9lEs6_J+%nIiuQC*fN;z8md>r_~Mfl zU%p5Dt_YT>gQqfr@`cR!$NWr~+`CZb%dn;WtzrAOI>P_JtsB76PYe*<%H(y>qx-`Kq!X_; z<{RpAqYhE=L1r*M)gNF3B8r(<%8mo*SR2hu zccLRZwGARt)Hlo1euqTyM>^!HK*!Q2P;4UYrysje@;(<|$&%vQekbn|0Ruu_Io(w4#%p6ld2Yp7tlA`Y$cciThP zKzNGIMPXX%&Ud0uQh!uQZz|FB`4KGD?3!ND?wQt6!n*f4EmCoJUh&b?;B{|lxs#F- z31~HQ`SF4x$&v00@(P+j1pAaj5!s`)b2RDBp*PB=2IB>oBF!*6vwr7Dp%zpAx*dPr zb@Zjq^XjN?O4QcZ*O+8>)|HlrR>oD*?WQl5ri3R#2?*W6iJ>>kH%KnnME&TT@ZzrHS$Q%LC?n|e>V+D+8D zYc4)QddFz7I8#}y#Wj6>4P%34dZH~OUDb?uP%-E zwjXM(?Sg~1!|wI(RVuxbu)-rH+O=igSho_pDCw(c6b=P zKk4ATlB?bj9+HHlh<_!&z0rx13K3ZrAR8W)!@Y}o`?a*JJsD+twZIv`W)@Y?Amu_u zz``@-e2X}27$i(2=9rvIu5uTUOVhzwu%mNazS|lZb&PT;XE2|B&W1>=B58#*!~D&) zfVmJGg8UdP*fx(>Cj^?yS^zH#o-$Q-*$SnK(ZVFkw+er=>N^7!)FtP3y~Xxnu^nzY zikgB>Nj0%;WOltWIob|}%lo?_C7<``a5hEkx&1ku$|)i>Rh6@3h*`slY=9U}(Ql_< zaNG*J8vb&@zpdhAvv`?{=zDedJ23TD&Zg__snRAH4eh~^oawdYi6A3w8<Ozh@Kw)#bdktM^GVb zrG08?0bG?|NG+w^&JvD*7LAbjED{_Zkc`3H!My>0u5Q}m!+6VokMLXxl`Mkd=g&Xx z-a>m*#G3SLlhbKB!)tnzfWOBV;u;ftU}S!NdD5+YtOjLg?X}dl>7m^gOpihrf1;PY zvll&>dIuUGs{Qnd- zwIR3oIrct8Va^Tm0t#(bJD7c$Z7DO9*7NnRZorrSm`b`cxz>OIC;jSE3DO8`hX955ui`s%||YQtt2 z5DNA&pG-V+4oI2s*x^>-$6J?p=I>C|9wZF8z;VjR??Icg?1w2v5Me+FgAeGGa8(3S z4vg*$>zC-WIVZtJ7}o9{D-7d>zCe|z#<9>CFve-OPAYsneTb^JH!Enaza#j}^mXy1 z+ULn^10+rWLF6j2>Ya@@Kq?26>AqK{A_| zQKb*~F1>sE*=d?A?W7N2j?L09_7n+HGi{VY;MoTGr_)G9)ot$p!-UY5zZ2Xtbm=t z@dpPSGwgH=QtIcEulQNI>S-#ifbnO5EWkI;$A|pxJd885oM+ zGZ0_0gDvG8q2xebj+fbCHYfAXuZStH2j~|d^sBAzo46(K8n59+T6rzBwK)^rfPT+B zyIFw)9YC-V^rhtK`!3jrhmW-sTmM+tPH+;nwjL#-SjQPUZ53L@A>y*rt(#M(qsiB2 zx6B)dI}6Wlsw%bJ8h|(lhkJVogQZA&n{?Vgs6gNSXzuZpEyu*xySy8ro07QZ7Vk1!3tJphN_5V7qOiyK8p z#@jcDD8nmtYi1^l8ml;AF<#IPK?!pqf9D4moYk>d99Im}Jtwj6c#+A;f)CQ*f-hZ< z=p_T86jog%!p)D&5g9taSwYi&eP z#JuEK%+NULWus;0w32-SYFku#i}d~+{Pkho&^{;RxzP&0!RCm3-9K6`>KZpnzS6?L z^H^V*s!8<>x8bomvD%rh>Zp3>Db%kyin;qtl+jAv8Oo~1g~mqGAC&Qi_wy|xEt2iz zWAJEfTV%cl2Cs<1L&DLRVVH05EDq`pH7Oh7sR`NNkL%wi}8n>IXcO40hp+J+sC!W?!krJf!GJNE8uj zg-y~Ns-<~D?yqbzVRB}G>0A^f0!^N7l=$m0OdZuqAOQqLc zX?AEGr1Ht+inZ-Qiwnl@Z0qukd__a!C*CKuGdy5#nD7VUBM^6OCpxCa2A(X;e0&V4 zM&WR8+wErQ7UIc6LY~Q9x%Sn*Tn>>P`^t&idaOEnOd(Ufw#>NoR^1QdhJ8s`h^|R_ zXX`c5*O~Xdvh%q;7L!_!ohf$NfEBmCde|#uVZvEo>OfEq%+Ns7&_f$OR9xsihRpBb z+cjk8LyDm@U{YN>+r46?nn{7Gh(;WhFw6GAxtcKD+YWV?uge>;+q#Xx4!GpRkVZYu zzsF}1)7$?%s9g9CH=Zs+B%M_)+~*j3L0&Q9u7!|+T`^O{xE6qvAP?XWv9_MrZKdo& z%IyU)$Q95AB4!#hT!_dA>4e@zjOBD*Y=XjtMm)V|+IXzjuM;(l+8aA5#Kaz_$rR6! zj>#&^DidYD$nUY(D$mH`9eb|dtV0b{S>H6FBfq>t5`;OxA4Nn{J(+XihF(stSche7$es&~N$epi&PDM_N`As;*9D^L==2Q7Z2zD+CiU(|+-kL*VG+&9!Yb3LgPy?A zm7Z&^qRG_JIxK7-FBzZI3Q<;{`DIxtc48k> zc|0dmX;Z=W$+)qE)~`yn6MdoJ4co;%!`ddy+FV538Y)j(vg}5*k(WK)KWZ3WaOG!8 z!syGn=s{H$odtpqFrT#JGM*utN7B((abXnpDM6w56nhw}OY}0TiTG1#f*VFZr+^-g zbP10`$LPq_;PvrA1XXlyx2uM^mrjTzX}w{yuLo-cOClE8MMk47T25G8M!9Z5ypOSV zAJUBGEg5L2fY)ZGJb^E34R2zJ?}Vf>{~gB!8=5Z) z9y$>5c)=;o0HeHHSuE4U)#vG&KF|I%-cF6f$~pdYJWk_dD}iOA>iA$O$+4%@>JU08 zS`ep)$XLPJ+n0_i@PkF#ri6T8?ZeAot$6JIYHm&P6EB=BiaNY|aA$W0I+nz*zkz_z zkEru!tj!QUffq%)8y0y`T&`fuus-1p>=^hnBiBqD^hXrPs`PY9tU3m0np~rISY09> z`P3s=-kt_cYcxWd{de@}TwSqg*xVhp;E9zCsnXo6z z?f&Sv^U7n4`xr=mXle94HzOdN!2kB~4=%)u&N!+2;z6UYKUDqi-s6AZ!haB;@&B`? z_TRX0%@suz^TRdCb?!vNJYPY8L_}&07uySH9%W^Tc&1pia6y1q#?*Drf}GjGbPjBS zbOPcUY#*$3sL2x4v_i*Y=N7E$mR}J%|GUI(>WEr+28+V z%v5{#e!UF*6~G&%;l*q*$V?&r$Pp^sE^i-0$+RH3ERUUdQ0>rAq2(2QAbG}$y{de( z>{qD~GGuOk559Y@%$?N^1ApVL_a704>8OD%8Y%8B;FCt%AoPu8*D1 zLB5X>b}Syz81pn;xnB}%0FnwazlWfUV)Z-~rZg6~b z6!9J$EcE&sEbzcy?CI~=boWA&eeIa%z(7SE^qgVLz??1Vbc1*aRvc%Mri)AJaAG!p z$X!_9Ds;Zz)f+;%s&dRcJt2==P{^j3bf0M=nJd&xwUGlUFn?H=2W(*2I2Gdu zv!gYCwM10aeus)`RIZSrCK=&oKaO_Ry~D1B5!y0R=%!i2*KfXGYX&gNv_u+n9wiR5 z*e$Zjju&ODRW3phN925%S(jL+bCHv6rZtc?!*`1TyYXT6%Ju=|X;6D@lq$8T zW{Y|e39ioPez(pBH%k)HzFITXHvnD6hw^lIoUMA;qAJ^CU?top1fo@s7xT13Fvn1H z6JWa-6+FJF#x>~+A;D~;VDs26>^oH0EI`IYT2iagy23?nyJ==i{g4%HrAf1-*v zK1)~@&(KkwR7TL}L(A@C_S0G;-GMDy=MJn2$FP5s<%wC)4jC5PXoxrQBFZ_k0P{{s@sz+gX`-!=T8rcB(=7vW}^K6oLWMmp(rwDh}b zwaGGd>yEy6fHv%jM$yJXo5oMAQ>c9j`**}F?MCry;T@47@r?&sKHgVe$MCqk#Z_3S z1GZI~nOEN*P~+UaFGnj{{Jo@16`(qVNtbU>O0Hf57-P>x8Jikp=`s8xWs^dAJ9lCQ z)GFm+=OV%AMVqVATtN@|vp61VVAHRn87}%PC^RAzJ%JngmZTasWBAWsoAqBU+8L8u z4A&Pe?fmTm0?mK-BL9t+{y7o(7jm+RpOhL9KnY#E&qu^}B6=K_dB}*VlSEiC9fn)+V=J;OnN)Ta5v66ic1rG+dGAJ1 z1%Zb_+!$=tQ~lxQrzv3x#CPb?CekEkA}0MYSgx$Jdd}q8+R=ma$|&1a#)TQ=l$1tQ z=tL9&_^vJ)Pk}EDO-va`UCT1m#Uty1{v^A3P~83_#v^ozH}6*9mIjIr;t3Uv%@VeW zGL6(CwCUp)Jq%G0bIG%?{_*Y#5IHf*5M@wPo6A{$Um++Co$wLC=J1aoG93&T7Ho}P z=mGEPP7GbvoG!uD$k(H3A$Z))+i{Hy?QHdk>3xSBXR0j!11O^mEe9RHmw!pvzv?Ua~2_l2Yh~_!s1qS`|0~0)YsbHSz8!mG)WiJE| z2f($6TQtt6L_f~ApQYQKSb=`053LgrQq7G@98#igV>y#i==-nEjQ!XNu9 z~;mE+gtj4IDDNQJ~JVk5Ux6&LCSFL!y=>79kE9=V}J7tD==Ga+IW zX)r7>VZ9dY=V&}DR))xUoV!u(Z|%3ciQi_2jl}3=$Agc(`RPb z8kEBpvY>1FGQ9W$n>Cq=DIpski};nE)`p3IUw1Oz0|wxll^)4dq3;CCY@RyJgFgc# zKouFh!`?Xuo{IMz^xi-h=StCis_M7yq$u) z?XHvw*HP0VgR+KR6wI)jEMX|ssqYvSf*_3W8zVTQzD?3>H!#>InzpSO)@SC8q*ii- z%%h}_#0{4JG;Jm`4zg};BPTGkYamx$Xo#O~lBirRY)q=5M45n{GCfV7h9qwyu1NxOMoP4)jjZMxmT|IQQh0U7C$EbnMN<3)Kk?fFHYq$d|ICu>KbY_hO zTZM+uKHe(cIZfEqyzyYSUBZa8;Fcut-GN!HSA9ius`ltNebF46ZX_BbZNU}}ZOm{M2&nANL9@0qvih15(|`S~z}m&h!u4x~(%MAO$jHRWNfuxWF#B)E&g3ghSQ9|> z(MFaLQj)NE0lowyjvg8z0#m6FIuKE9lDO~Glg}nSb7`~^&#(Lw{}GVOS>U)m8bF}x zVjbXljBm34Cs-yM6TVusr+3kYFjr28STT3g056y3cH5Tmge~ASxBj z%|yb>$eF;WgrcOZf569sDZOVwoo%8>XO>XQOX1OyN9I-SQgrm;U;+#3OI(zrWyow3 zk==|{lt2xrQ%FIXOTejR>;wv(Pb8u8}BUpx?yd(Abh6? zsoO3VYWkeLnF43&@*#MQ9-i-d0t*xN-UEyNKeyNMHw|A(k(_6QKO=nKMCxD(W(Yop zsRQ)QeL4X3Lxp^L%wzi2-WVSsf61dqliPUM7srDB?Wm6Lzn0&{*}|IsKQW;02(Y&| zaTKv|`U(pSzuvR6Rduu$wzK_W-Y-7>7s?G$)U}&uK;<>vU}^^ns@Z!p+9?St1s)dG zK%y6xkPyyS1$~&6v{kl?Md6gwM|>mt6Upm>oa8RLD^8T{0?HC!Z>;(Bob7el(DV6x zi`I)$&E&ngwFS@bi4^xFLAn`=fzTC;aimE^!cMI2n@Vo%Ae-ne`RF((&5y6xsjjAZ zVguVoQ?Z9uk$2ON;ersE%PU*xGO@T*;j1BO5#TuZKEf(mB7|g7pcEA=nYJ{s3vlbg zd4-DUlD{*6o%Gc^N!Nptgay>j6E5;3psI+C3Q!1ZIbeCubW%w4pq9)MSDyB{HLm|k zxv-{$$A*pS@csolri$Ge<4VZ}e~78JOL-EVyrbxKra^d{?|NnPp86!q>t<&IP07?Z z^>~IK^k#OEKgRH+LjllZXk7iA>2cfH6+(e&9ku5poo~6y{GC5>(bRK7hwjiurqAiZ zg*DmtgY}v83IjE&AbiWgMyFbaRUPZ{lYiz$U^&Zt2YjG<%m((&_JUbZcfJ22(>bi5 z!J?<7AySj0JZ&<-qXX;mcV!f~>G=sB0KnjWca4}vrtunD^1TrpfeS^4dvFr!65knK zZh`d;*VOkPs4*-9kL>$GP0`(M!j~B;#x?Ba~&s6CopvO86oM?-? zOw#dIRc;6A6T?B`Qp%^<U5 z19x(ywSH$_N+Io!6;e?`tWaM$`=Db!gzx|lQ${DG!zb1Zl&|{kX0y6xvO1o z220r<-oaS^^R2pEyY;=Qllqpmue|5yI~D|iI!IGt@iod{Opz@*ml^w2bNs)p`M(Io z|E;;m*Xpjd9l)4G#KaWfV(t8YUn@A;nK^#xgv=LtnArX|vWQVuw3}B${h+frU2>9^ z!l6)!Uo4`5k`<<;E(ido7M6lKTgWezNLq>U*=uz&s=cc$1%>VrAeOoUtA|T6gO4>UNqsdK=NF*8|~*sl&wI=x9-EGiq*aqV!(VVXA57 zw9*o6Ir8Lj1npUXvlevtn(_+^X5rzdR>#(}4YcB9O50q97%rW2me5_L=%ffYPUSRc z!vv?Kv>dH994Qi>U(a<0KF6NH5b16enCp+mw^Hb3Xs1^tThFpz!3QuN#}KBbww`(h z7GO)1olDqy6?T$()R7y%NYx*B0k_2IBiZ14&8|JPFxeMF{vW>HF-ViB*%t0;Thq2} z+qP}n=Cp0wwr%5S+qN<7?r+``=l(h0z2`^8j;g2~Q4u?{cIL{JYY%l|iw&YH4FL(8 z1-*E#ANDHi+1f%lMJbRfq*`nG)*#?EJEVoDH5XdfqwR-C{zmbQoh?E zhW!|TvYv~>R*OAnyZf@gC+=%}6N90yU@E;0b_OV#xL9B?GX(D&7BkujjFC@HVKFci zb_>I5e!yuHA1LC`xm&;wnn|3ht3h7|rDaOsh0ePhcg_^Wh8Bq|AGe`4t5Gk(9^F;M z8mFr{uCm{)Uq0Xa$Fw6+da`C4%)M_#jaX$xj;}&Lzc8wTc%r!Y#1akd|6FMf(a4I6 z`cQqS_{rm0iLnhMG~CfDZc96G3O=Tihnv8g;*w?)C4N4LE0m#H1?-P=4{KeC+o}8b zZX)x#(zEysFm$v9W8-4lkW%VJIjM~iQIVW)A*RCO{Oe_L;rQ3BmF*bhWa}!=wcu@# zaRWW{&7~V-e_$s)j!lJsa-J?z;54!;KnU3vuhp~(9KRU2GKYfPj{qA?;#}H5f$Wv-_ zGrTb(EAnpR0*pKft3a}6$npzzq{}ApC&=C&9KoM3Ge@24D^8ZWJDiXq@r{hP=-02& z@Qrn-cbr2YFc$7XR0j7{jAyR;4LLBf_XNSrmd{dV3;ae;fsEjds*2DZ&@#e)Qcc}w zLgkfW=9Kz|eeM$E`-+=jQSt}*kAwbMBn7AZSAjkHUn4n||NBq*|2QPcKaceA6m)g5 z_}3?DX>90X|35eI7?n+>f9+hl5b>#q`2+`FXbOu9Q94UX-GWH;d*dpmSFd~7WM#H2 zvKNxjOtC)U_tx*0(J)eAI8xAD8SvhZ+VRUA?)| zeJjvg9)vi`Qx;;1QP!c_6hJp1=J=*%!>ug}%O!CoSh-D_6LK0JyiY}rOaqSeja&jb#P|DR7 z_JannlfrFeaE$irfrRIiN|huXmQhQUN6VG*6`bzN4Z3!*G?FjN8!`ZTn6Wn4n=Ync z_|Sq=pO7+~{W2}599SfKz@umgRYj6LR9u0*BaHqdEw^i)dKo5HomT9zzB$I6w$r?6 zs2gu*wNOAMK`+5yPBIxSOJpL$@SN&iUaM zQ3%$EQt%zQBNd`+rl9R~utRDAH%7XP@2Z1s=)ks77I(>#FuwydE5>LzFx)8ye4ClM zb*e2i*E$Te%hTKh7`&rQXz;gvm4Dam(r-!FBEcw*b$U%Wo9DIPOwlC5Ywm3WRCM4{ zF42rnEbBzUP>o>MA){;KANhAW7=FKR=DKK&S1AqSxyP;k z;fp_GVuV}y6YqAd)5p=tJ~0KtaeRQv^nvO?*hZEK-qA;vuIo!}Xgec4QGW2ipf2HK z&G&ppF*1aC`C!FR9(j4&r|SHy74IiDky~3Ab)z@9r&vF+Bapx<{u~gb2?*J zSl{6YcZ$&m*X)X?|8<2S}WDrWN3yhyY7wlf*q`n^z3LT4T$@$y``b{m953kfBBPpQ7hT;zs(Nme`Qw@{_pUO0OG zfugi3N?l|jn-Du3Qn{Aa2#6w&qT+oof=YM!Zq~Xi`vlg<;^)Jreeb^x6_4HL-j}sU z1U^^;-WetwPLKMsdx4QZ$haq3)rA#ATpEh{NXto-tOXjCwO~nJ(Z9F%plZ{z(ZW!e zF>nv&4ViOTs58M+f+sGimF^9cB*9b(gAizwyu5|--SLmBOP-uftqVnVBd$f7YrkJ8!jm*QQEQC zEQ+@T*AA1kV@SPF6H5sT%^$$6!e5;#N((^=OA5t}bqIdqf`PiMMFEDhnV#AQWSfLp zX=|ZEsbLt8Sk&wegQU0&kMC|cuY`&@<#r{t2*sq2$%epiTVpJxWm#OPC^wo_4p++U zU|%XFYs+ZCS4JHSRaVET)jV?lbYAd4ouXx0Ka6*wIFBRgvBgmg$kTNQEvs0=2s^sU z_909)3`Ut!m}}@sv<63E@aQx}-!qVdOjSOnAXTh~MKvr$0nr(1Fj-3uS{U6-T9NG1Y(Ua)Nc}Mi< zOBQz^&^v*$BqmTIO^;r@kpaq3n!BI?L{#bw)pdFV&M?D0HKqC*YBxa;QD_4(RlawI z5wBK;7T^4dT7zt%%P<*-M~m?Et;S^tdNgQSn?4$mFvIHHL!`-@K~_Ar4vBnhy{xuy zigp!>UAwPyl!@~(bkOY;un&B~Evy@5#Y&cEmzGm+)L~4o4~|g0uu&9bh8N0`&{B2b zDj2>biRE1`iw}lv!rl$Smn(4Ob>j<{4dT^TfLe-`cm#S!w_9f;U)@aXWSU4}90LuR zVcbw;`2|6ra88#Cjf#u62xq?J)}I)_y{`@hzES(@mX~}cPWI8}SRoH-H;o~`>JWU$ zhLudK3ug%iS=xjv9tnmOdTXcq_?&o30O;(+VmC&p+%+pd_`V}RY4ibQMNE&N5O+hb3bQ8bxk^33Fu4DB2*~t1909gqoutQHx^plq~;@g$d_+rzS0`2;}2UR2h#?p35B=B*f0BZS4ysiWC!kw?4B-dM%m6_BfRbey1Wh? zT1!@>-y=U}^fxH0A`u1)Mz90G6-<4aW^a@l_9L6Y;cd$3<#xIrhup)XLkFi$W&Ohu z8_j~-VeVXDf9b&6aGelt$g*BzEHgzh)KDgII_Y zb$fcY8?XI6-GEGTZVWW%O;njZld)29a_&1QvNYJ@OpFrUH{er@mnh*}326TYAK7_Z zA={KnK_o3QLk|%m@bx3U#^tCChLxjPxMesOc5D4G+&mvp@Clicz^=kQlWp1|+z|V7 zkU#7l61m@^#`1`{+m2L{sZC#j?#>0)2z4}}kqGhB{NX%~+3{5jOyij!e$5-OAs zDvq+>I2(XsY9%NNhNvKiF<%!6t^7&k{L7~FLdkP9!h%=2Kt$bUt(Zwp*&xq_+nco5 zK#5RCM_@b4WBK*~$CsWj!N!3sF>ijS=~$}_iw@vbKaSp5Jfg89?peR@51M5}xwcHW z(@1TK_kq$c4lmyb=aX3-JORe+JmuNkPP=bM*B?};c=_;h2gT-nt#qbriPkpaqoF@q z<)!80iKvTu`T-B3VT%qKO^lfPQ#m5Ei6Y%Fs@%Pt!8yX&C#tL$=|Ma8i?*^9;}Fk> zyzdQQC5YTBO&gx6kB~yhUUT&%q3a3o+zueh>5D7tdByYVcMz@>j!C@Iyg{N1)veYl`SPshuH6Rk=O6pvVrI71rI5*%uU3u81DpD%qmXsbKWMFR@2m4vO_^l6MMbO9a()DcWmYT&?0B_ zuY~tDiQ6*X7;9B*5pj?;xy_B}*{G}LjW*qU&%*QAyt30@-@O&NQTARZ+%VScr>`s^KX;M!p; z?8)|}P}L_CbOn!u(A{c5?g{s31Kn#7i)U@+_KNU-ZyVD$H7rtOjSht8%N(ST-)%r` z63;Hyp^KIm-?D;E-EnpAAWgz2#z{fawTx_;MR7)O6X~*jm*VUkam7>ueT^@+Gb3-Y zN3@wZls8ibbpaoR2xH=$b3x1Ng5Tai=LT2@_P&4JuBQ!r#Py3ew!ZVH4~T!^TcdyC ze#^@k4a(nNe~G+y zI~yXK@1HHWU4pj{gWT6v@$c(x){cLq*KlFeKy?f$_u##)hDu0X_mwL6uKei~oPd9( zRaF_k&w(J3J8b_`F~?0(Ei_pH}U^c&r$uSYawB8Ybs-JZ|&;vKLWX! z|HFZ%-uBDaP*hMcQKf*|j5!b%H40SPD*#{A`kj|~esk@1?q}-O7WyAm3mD@-vHzw( zTSOlO(K9>GW;@?@xSwpk%X3Ui4_Psm;c*HF~RW+q+C#RO_VT5(x!5B#On-W`T|u z>>=t)W{=B-8wWZejxMaBC9sHzBZGv5uz_uu281kxHg2cll_sZBC&1AKD`CYh2vKeW zm#|MMdC}6A&^DX=>_(etx8f}9o}`(G?Y``M?D+aTPJbZqONmSs>y>WSbvs>7PE~cb zjO+1Y)PMi*!=06^$%< z*{b^66BIl{7zKvz^jut7ylDQBt)ba_F*$UkDgJ2gSNfHB6+`OEiz@xs$Tcrl>X4?o zu9~~b&Xl0?w(7lJXu8-9Yh6V|A3f?)1|~+u-q&6#YV`U2i?XIqUw*lc-QTXwuf@8d zSjMe1BhBKY`Mo{$s%Ce~Hv(^B{K%w{yndEtvyYjjbvFY^rn2>C1Lbi!3RV7F>&;zlSDSk}R>{twI}V zA~NK%T!z=^!qbw(OEgsmSj?#?GR&A$0&K>^(?^4iphc3rN_(xXA%joi)k~DmRLEXl zaWmwMolK%@YiyI|HvX{X$*Ei7y+zJ%m{b}$?N7_SN&p+FpeT%4Z_2`0CP=}Y3D-*@ zL|4W4ja#8*%SfkZzn5sfVknpJv&>glRk^oUqykedE8yCgIwCV)fC1iVwMr4hc#KcV!|M-r_N|nQWw@`j+0(Ywct~kLXQ)Qyncmi{Q4`Ur7A{Ep)n`zCtm8D zVX`kxa8Syc`g$6$($Qc-(_|LtQKWZXDrTir5s*pSVmGhk#dKJzCYT?vqA9}N9DGv> zw}N$byrt?Mk*ZZbN5&zb>pv;rU}EH@Rp54)vhZ=330bLvrKPEPu!WqR%yeM3LB!(E zw|J05Y!tajnZ9Ml*-aX&5T8YtuWDq@on)_*FMhz-?m|>RT0~e3OHllrEMthVY(KwQ zu>ijTc4>Xz-q1(g!ESjaZ+C+Zk5FgmF)rFX29_RmU!`7Pw+0}>8xK^=pOxtUDV)ok zw-=p=OvEH&VO3wToRdI!hPHc`qX+_{T_mj!NxcA&xOgkEuvz`-Aa`ZlNv>qnD0`YT1T3USO0ec!%{KE~UOGPJX%I5_rZDGx@|w zVIMsRPP+}^Xxa&{x!q{hY1wat8jDO7YP0(8xHWeEdrd79lUjB8%)v{X1pQu|1dr*y9M&a(J`038}4>lK&K zIM~6wnX{XA?pFHz{hOmEq{oYBnB@56twXqEcFrFqvCy)sH9B{pQ`G50o{W^t&onwY z-l{ur4#8ylPV5YRLD%%j^d0&_WI>0nmfZ8! zaZ&vo@7D`!=?215+Vk181*U@^{U>VyoXh2F&ZNzZx5tDDtlLc)gi2=|o=GC`uaH;< zFuuF?Q9Q`>S#c(~2p|s49RA`3242`2P+)F)t2N!CIrcl^0#gN@MLRDQ2W4S#MXZJO z8<(9P>MvW;rf2qZ$6sHxCVIr0B-gP?G{5jEDn%W#{T#2_&eIjvlVqm8J$*8A#n`5r zs6PuC!JuZJ@<8cFbbP{cRnIZs>B`?`rPWWL*A?1C3QqGEG?*&!*S0|DgB~`vo_xIo z&n_Sa(>6<$P7%Py{R<>n6Jy?3W|mYYoxe5h^b6C#+UoKJ(zl?^WcBn#|7wMI5=?S# zRgk8l-J`oM%GV&jFc)9&h#9mAyowg^v%Fc-7_^ou5$*YvELa!1q>4tHfX7&PCGqW* zu8In~5`Q5qQvMdToE$w+RP^_cIS2xJjghjCTp6Z(za_D<$S;0Xjt?mAE8~Ym{)zfb zV62v9|59XOvR}wEpm~Cnhyr`=JfC$*o15k?T`3s-ZqF6Gy;Gm+_6H$%oJPywWA^Wl zzn$L=N%{VT8DkQba0|2LqGR#O2Pw!b%LV4#Ojcx5`?Cm;+aLpkyZ=!r1z@E}V= z$2v6v%Ai)MMd`@IM&UD!%%(63VH8+m0Ebk<5Du#0=WeK(E<2~3@>8TceT$wy5F52n zRFtY>G9Gp~h#&R92{G{jLruZSNJ4)gNK+zg*$P zW@~Hf>_Do)tvfEAAMKE1nQ=8coTgog&S;wj(s?Xa0!r?UU5#2>18V#|tKvay1Ka53 zl$RxpMqrkv`Sv&#!_u8$8PMken`QL0_sD2)r&dZziefzSlAdKNKroVU;gRJE#o*}w zP_bO{F4g;|t!iroy^xf~(Q5qc8a3<+vBW%VIOQ1!??d;yEn1at1wpt}*n- z0iQtfu}Isw4ZfH~8p~#RQUKwf<$XeqUr-5?8TSqokdHL7tY|47R; z#d+4NS%Cqp>LQbvvAMIhcCX@|HozKXl)%*5o>P2ZegGuOerV&_MeA}|+o-3L!ZNJd z#1xB^(r!IfE~i>*5r{u;pIfCjhY^Oev$Y1MT16w8pJ0?9@&FH*`d;hS=c#F6fq z{mqsHd*xa;>Hg?j80MwZ%}anqc@&s&2v{vHQS68fueNi5Z(VD2eH>jmv4uvE|HEQm z^=b&?1R9?<@=kjtUfm*I!wPf5Xnma(4*DfPk}Es*H$%NGCIM1qt(LSvbl7&tV>e2$ zUqvZOTiwQyxDoxL(mn?n_x%Tre?L&!FYCOy0>o}#DTC3uSPnyGBv*}!*Yv5IV)Bg_t%V+UrTXfr!Q8+eX}ANR*YLzwme7Rl z@q_*fP7wP2AZ(3WG*)4Z(q@)~c{Je&7?w^?&Wy3)v0{TvNQRGle9mIG>$M2TtQ(Vf z3*PV@1mX)}beRTPjoG#&&IO#Mn(DLGp}mn)_0e=9kXDewC8Pk@yo<8@XZjFP-_zic z{mocvT9Eo)H4Oj$>1->^#DbbiJn^M4?v7XbK>co+v=7g$hE{#HoG6ZEat!s~I<^_s zlFee93KDSbJKlv_+GPfC6P8b>(;dlJ5r9&Pc4kC2uR(0{Kjf+SMeUktef``iXD}8` zGufkM9*Sx4>+5WcK#Vqm$g#5z1DUhc_#gLGe4_icSzN5GKr|J&eB)LS;jTXWA$?(k zy?*%U9Q#Y88(blIlxrtKp6^jksNF>-K1?8=pmYAPj?qq}yO5L>_s8CAv=LQMe3J6? zOfWD>Kx_5A4jRoIU}&aICTgdYMqC|45}St;@0~7>Af+uK3vps9D!9qD)1;Y6Fz>4^ zR1X$s{QNZl7l%}Zwo2wXP+Cj-K|^wqZW?)s1WUw_APZLhH55g{wNW3liInD)WHh${ zOz&K>sB*4inVY3m)3z8w!yUz+CKF%_-s2KVr7DpwTUuZjPS9k-em^;>H4*?*B0Bg7 zLy2nfU=ac5N}x1+Tlq^lkNmB~Dj+t&l#fO&%|7~2iw*N!*xBy+ZBQ>#g_;I*+J{W* z=@*15><)Bh9f>>dgQrEhkrr2FEJ;R2rH%`kda8sD-FY6e#7S-<)V*zQA>)Ps)L- zgUuu@5;Ych#jX_KZ+;qEJJbu{_Z9WSsLSo#XqLpCK$gFidk}gddW(9$v}iyGm_OoH ztn$pv81zROq686_7@avq2heXZnkRi4n(3{5jTDO?9iP%u8S4KEqGL?^uBeg(-ws#1 z9!!Y_2Q~D?gCL3MQZO!n$+Wy(Twr5AS3{F7ak2f)Bu0iG^k^x??0}b6l!>Vjp{e*F z8r*(Y?3ZDDoS1G?lz#J4`d9jAEc9YGq1LbpYoFl!W!(j8-33Ey)@yx+BVpDIVyvpZ zq5QgKy>P}LlV?Bgy@I)JvefCG)I69H1;q@{8E8Ytw^s-rC7m5>Q>ZO(`$`9@`49s2)q#{2eN0A?~qS8%wxh%P*99h*Sv` zW_z3<=iRZBQKaDsKw^TfN;6`mRck|6Yt&e$R~tMA0ix;qgw$n~fe=62aG2v0S`7mU zI}gR#W)f+Gn=e3mm*F^r^tcv&S`Rym`X`6K`i8g-a0!p|#69@Bl!*&)QJ9(E7ycxz z)5-m9v`~$N1zszFi^=m%vw}Y{ZyYub!-6^KIY@mwF|W+|t~bZ%@rifEZ-28I@s$C` z>E+k~R1JC-M>8iC_GR>V9f9+uL2wPRATL9bC(sxd;AMJ>v6c#PcG|Xx1N5^1>ISd0 z4%vf-SNOw+1%yQq1YP`>iqq>5Q590_pr?OxS|HbLjx=9~Y)QO37RihG%JrJ^=Nj>g zPTcO$6r{jdE_096b&L;Wm8vcxUVxF0mA%W`aZz4n6XtvOi($ zaL!{WUCh&{5ar=>u)!mit|&EkGY$|YG<_)ZD)I32uEIWwu`R-_ z`FVeKyrx3>8Ep#2~%VVrQ%u#exo!anPe`bc)-M=^IP1n1?L2UQ@# zpNjoq-0+XCfqXS!LwMgFvG$PkX}5^6yxW)6%`S8{r~BA2-c%-u5SE#%mQ~5JQ=o$c z%+qa0udVq9`|=2n=0k#M=yiEh_vp?(tB|{J{EhVLPM^S@f-O*Lgb390BvwK7{wfdMKqUc0uIXKj5>g^z z#2`5^)>T73Eci+=E4n&jl42E@VYF2*UDiWLUOgF#p9`E4&-A#MJLUa&^hB@g7KL+n zr_bz+kfCcLIlAevILckIq~RCwh6dc5@%yN@#f3lhHIx4fZ_yT~o0#3@h#!HCN(rHHC6#0$+1AMq?bY~(3nn{o5g8{*e_#4RhW)xPmK zTYBEntuYd)`?`bzDksI9*MG$=^w!iiIcWg1lD&kM1NF@qKha0fDVz^W7JCam^!AQFxY@7*`a3tfBwN0uK_~YBQ18@^i%=YB}K0Iq(Q3 z=7hNZ#!N@YErE7{T|{kjVFZ+f9Hn($zih;f&q^wO)PJSF`K)|LdT>!^JLf=zXG>>G z15TmM=X`1%Ynk&dvu$Vic!XyFC(c=qM33v&SIl|p+z6Ah9(XQ0CWE^N-LgE#WF6Z+ zb_v`7^Rz8%KKg_@B>5*s-q*TVwu~MCRiXvVx&_3#r1h&L+{rM&-H6 zrcgH@I>0eY8WBX#Qj}Vml+fpv?;EQXBbD0lx%L?E4)b-nvrmMQS^}p_CI3M24IK(f| zV?tWzkaJXH87MBz^HyVKT&oHB;A4DRhZy;fIC-TlvECK)nu4-3s7qJfF-ZZGt7+6C3xZt!ZX4`M{eN|q!y*d^B+cF5W- zc9C|FzL;$bAfh56fg&y0j!PF8mjBV!qA=z$=~r-orU-{0AcQUt4 zNYC=_9(MOWe$Br9_50i#0z!*a1>U6ZvH>JYS9U$kkrCt7!mEUJR$W#Jt5vT?U&LCD zd@)kn%y|rkV|CijnZ((B2=j_rB;`b}F9+E1T46sg_aOPp+&*W~44r9t3AI}z)yUFJ z+}z5E6|oq+oPC3Jli)EPh9)o^B4KUYkk~AU9!g`OvC`a!#Q>JmDiMLTx>96_iDD9h@nW%Je4%>URwYM%5YU1&Dcdulvv3IH3GSrA4$)QjlGwUt6 zsR6+PnyJ$1x{|R=ogzErr~U|X!+b+F8=6y?Yi`E$yjWXsdmxZa^hIqa)YV9ubUqOj&IGY}bk zH4*DEn({py@MG5LQCI;J#6+98GaZYGW-K-&C`(r5#?R0Z){DlY8ZZk}lIi$xG}Q@2 z0LJhzuus-7dLAEpG1Lf+KOxn&NSwO{wn_~e0=}dovX)T(|WRMTqacoW8;A>8tTDr+0yRa+U!LW z!H#Gnf^iCy$tTk3kBBC=r@xhskjf1}NOkEEM4*r+A4`yNAIjz`_JMUI#xTf$+{UA7 zpBO_aJkKz)iaKqRA{8a6AtpdUwtc#Y-hxtZnWz~i(sfjMk`lq|kGea=`62V6y)TMPZw8q}tFDDHrW_n(Z84ZxWvRrntcw;F|Mv4ff9iaM% z4IM{=*zw}vIpbg=9%w&v`sA+a3UV@Rpn<6`c&5h+8a7izP>E@7CSsCv*AAvd-izwU z!sGJQ?fpCbt+LK`6m2Z3&cKtgcElAl){*m0b^0U#n<7?`8ktdIe#ytZTvaZy728o6 z3GDmw=vhh*U#hCo0gb9s#V5(IILXkw>(6a?BFdIb0%3~Y*5FiMh&JWHd2n(|y@?F8 zL$%!)uFu&n+1(6)oW6Hx*?{d~y zBeR)N*Z{7*gMlhMOad#k4gf`37OzEJ&pH?h!Z4#mNNCfnDI@LbiU~&2Gd^q7ix8~Y6$a=B9bK(BaTEO0$Oh=VCkBPwt0 zf#QuB25&2!m7MWY5xV_~sf(0|Y*#Wf8+FQI(sl2wgdM5H7V{aH6|ntE+OcLsTC`u; zeyrlkJgzdIb5=n#SCH)+kjN)rYW7=rppN3Eb;q_^8Zi}6jtL@eZ2XO^w{mCwX(q!t ztM^`%`ndZ5c+2@?p>R*dDNeVk#v>rsn>vEo;cP2Ecp=@E>A#n0!jZACKZ1=D0`f|{ zZnF;Ocp;$j86m}Gt~N+Ch6CJo7+Wzv|nlsXBvm z?St-5Ke&6hbGAWoO!Z2Rd8ARJhOY|a1rm*sOif%Th`*=^jlgWo%e9`3sS51n*>+Mh(9C7g@*mE|r%h*3k6I_uo;C!N z7CVMIX4kbA#gPZf_0%m18+BVeS4?D;U$QC`TT;X zP#H}tMsa=zS6N7n#BA$Fy8#R7vOesiCLM@d1UO6Tsnwv^gb}Q9I}ZQLI?--C8ok&S z9Idy06+V(_aj?M78-*vYBu|AaJ9mlEJpFEIP}{tRwm?G{ag>6u(ReBKAAx zDR6qe!3G88NQP$i99DZ~CW9lzz}iGynvGA4!yL}_9t`l*SZbEL-%N{n$%JgpDHJRn zvh<{AqR7z@ylV`kXdk+uEu-WWAt^=A4n(J=A1e8DpeLzAd;Nl#qlmp#KcHU!8`YJY zvBZy@>WiBZpx*wQ8JzKw?@k}8l99Wo&H>__vCFL}>m~MTmGvae% zPTn9?iR=@7NJ)?e+n-4kx$V#qS4tLpVUX*Je0@`f5LICdxLnph&Vjbxd*|+PbzS(l zBqqMlUeNoo8wL&_HKnM^8{iDI3IdzJAt32UupSr6XXh9KH2LjWD)Pz+`cmps%eHeD zU%i1SbPuSddp6?th;;DfUlxYnjRpd~i7vQ4V`cD%4+a9*!{+#QRBr5^Q$5Ec?gpju zv@dk9;G>d7QNEdRy}fgeA?i=~KFeibDtYffy)^OP?Ro~-X!onDpm+uGpe&6)*f@xJ zE1I3Qh}`1<7aFB@TS#}ee={<#9%1wOL%cuvOd($y4MC2?`1Nin=pVLXPkknn*0kx> z!9XHW${hYEV;r6F#iz7W=fg|a@GY0UG5>>9>$3Bj5@!N{nWDD`;JOdz_ZaZVVIUgH zo+<=+n8VGL*U%M|J$A~#ll__<`y+jL>bv;TpC!&|d=q%E2B|5p=)b-Q+ZrFO%+D_u z4%rc8BmOAO6{n(i(802yZW93?U;K^ZZlo0Gvs7B+<%}R;$%O}pe*Gi;!xP-M73W`k zXLv473Ex_VPcM-M^JO|H>KD;!sEGJ|E}Qepen;yNG2 zXqgD5sjQUDI(XLM+^8ZX1s_(X+PeyQ$Q5RukRt|Kwr-FSnW!^9?OG64UYX1^bU9d8 zJ}8K&UEYG+Je^cThf8W*^RqG07nSCmp*o5Z;#F zS?jochDWX@p+%CZ%dOKUl}q{9)^U@}qkQtA3zBF)`I&zyIKgb{mv)KtZ}?_h{r#VZ z%C+hwv&nB?we0^H+H`OKGw-&8FaF;=ei!tAclS5Q?qH9J$nt+YxdKkbRFLnWvn7GH zezC6<{mK0dd763JlLFqy&Oe|7UXII;K&2pye~yG4jldY~N;M9&rX}m76NsP=R#FEw zt(9h+=m9^zfl=6pH*D;JP~OVgbJkXh(+2MO_^;%F{V@pc2nGn~=U)Qx|JEV-e=vXk zPxA2J<9~IH{}29#X~KW$(1reJv}lc4_1JF31gdev>!CddVhf_62nsr6%w)?IWxz}{ z(}~~@w>c07!r=FZANq4R!F2Qi2?QGavZ{)PCq~X}3x;4ylsd&m;dQe;0GFSn5 zZ*J<=Xg1fEGYYDZ0{Z4}Jh*xlXa}@412nlKSM#@wjMM z*0(k>Gfd1Mj)smUuX}EM6m)811%n5zzr}T?$ZzH~*3b`3q3gHSpA<3cbzTeRDi`SA zT{O)l3%bH(CN0EEF9ph1(Osw5y$SJolG&Db~uL!I3U{X`h(h%^KsL71`2B1Yn z7(xI+Fk?|xS_Y5)x?oqk$xmjG@_+JdErI(q95~UBTvOXTQaJs?lgrC6Wa@d0%O0cC zzvslIeWMo0|C0({iEWX{=5F)t4Z*`rh@-t0ZTMse3VaJ`5`1zeUK0~F^KRY zj2z-gr%sR<(u0@SNEp%Lj38AB2v-+cd<8pKdtRU&8t3eYH#h7qH%bvKup4cnnrN>l z!5fve)~Y5_U9US`uXDFoOtx2gI&Z!t&VPIoqiv>&H(&1;J9b}kZhcOX7EiW*Bujy#MaCl52%NO-l|@2$aRKvZ!YjwpXwC#nA(tJtd1p?jx&U|?&jcb!0MT6oBlWurVRyiSCX?sN3j}d zh3==XK$^*8#zr+U^wk(UkF}bta4bKVgr`elH^az{w(m}3%23;y7dsEnH*pp{HW$Uk zV9J^I9ea7vp_A}0F8qF{>|rj`CeHZ?lf%HImvEJF<@7cgc1Tw%vAUA47{Qe(sP^5M zT=z<~l%*ZjJvObcWtlN?0$b%NdAj&l`Cr|x((dFs-njsj9%IIqoN|Q?tYtJYlRNIu zY(LtC-F14)Og*_V@gjGH^tLV4uN?f^#=dscCFV~a`r8_o?$gj3HrSk=YK2k^UW)sJ z&=a&&JkMkWshp0sto$c6j8f$J!Bsn*MTjC`3cv@l@7cINa!}fNcu(0XF7ZCAYbX|WJIL$iGx8l zGFFQsw}x|i!jOZIaP{@sw0BrV5Z5u!TGe@JGTzvH$}55Gf<;rieZlz+6E1}z_o3m2 z(t;Cp^Geen7iSt)ZVtC`+tzuv^<6--M`^5JXBeeLXV)>2;f7=l%(-4?+<5~;@=Th{1#>rK3+rLn(44TAFS@u(}dunUSYu}~))W*fr` zkBL}3k_@a4pXJ#u*_N|e#1gTqxE&WPsfDa=`@LL?PRR()9^HxG?~^SNmeO#^-5tMw zeGEW&CuX(Uz#-wZOEt8MmF}hQc%14L)0=ebo`e$$G6nVrb)afh!>+Nfa5P;N zCCOQ^NRel#saUVt$Ds0rGd%gkKP2LsQRxq6)g*`-r(FGM!Q51c|9lk!ha8Um3ys1{ zWpT7XDWYshQ{_F!8D8@3hvXhQDw;GlkUOzni&T1>^uD){WH3wRONgjh$u4u7?+$(Y zqTXEF>1aPNZCXP0nJ;zs6_%6;+D&J_|ugcih**y(4ApT`RKAi5>SZe0Bz|+l7z>P14>0ljIH*LhK z@}2O#{?1RNa&!~sEPBvIkm-uIt^Pt#%JnsbJ`-T0%pb ze}d;dzJFu7oQ=i`VHNt%Sv@?7$*oO`Rt*bRNhXh{FArB`9#f%ksG%q?Z`_<19;dBW z5pIoIo-JIK9N$IE1)g8@+4}_`sE7;Lus&WNAJ^H&=4rGjeAJP%Dw!tn*koQ&PrNZw zY88=H7qpHz11f}oTD!0lWO>pMI;i4sauS`%_!zM!n@91sLH#rz1~iEAu#1b%LA zhB}7{1(8{1{V8+SEs=*f=FcRE^;`6Pxm$Hie~|aD~W1BYy#@Y$C?pxJh*cC!T@8C9{xx*T*8P zhbkRk3*6)Zbk%}u>^?ItOhxdmX$j9KyoxxN>NrYGKMkLF4*fLsL_PRjHNNHCyaUHN z7W8yEhf&ag07fc9FD>B{t0#Civsoy0hvVepDREX(NK1LbK0n*>UJp&1FygZMg7T^G z(02BS)g#qMOI{RJIh7}pGNS8WhSH@kG+4n=(8j<+gVfTur)s*hYus70AHUBS2bN6Zp_GOHYxsbg{-Rcet{@0gzE`t$M0_!ZIqSAIW53j+Ln7N~8J zLZ0DOUjp^j`MvX#hq5dFixo^1szoQ=FTqa|@m>9F@%>7OuF9&_C_MDco&-{wfLKNrDMEN4pRUS8-SD6@GP`>_7$;r>dJo>KbeXm>GfQS? zjFS+Y6^%pDCaI0?9(z^ELsAE1`WhbhNv5DJ$Y}~r;>FynHjmjmA{bfDbseZXsKUv`%Fekv)1@f%7ti;B5hhs}5db1dP+P0${1DgKtb(DvN}6H6;0*LP6blg*rpr;Z(7? zrve>M`x6ZI(wtQc4%lO?v5vr{0iTPl&JT!@k-7qUN8b$O9YuItu7zrQ*$?xJIN#~b z#@z|*5z&D7g5>!o(^v+3N?JnJns5O2W4EkF>re*q1uVjgT#6ROP5>Ho)XTJoHDNRC zuLC(Cd_ZM?FAFPoMw;3FM4Ln0=!+vgTYBx2TdXpM@EhDCorzTS6@2`swp4J^9C0)U zq?)H8)=D;i+H`EVYge>kPy8d*AxKl};iumYu^UeM+e_3>O+LY`D4?pD%;Vextj!(; zomJ(u+dR(0m>+-61HTV7!>03vqozyo@uY@Zh^KrW`w7^ENCYh86_P2VC|4}(ilMBe zwa&B|1a7%Qkd>d14}2*_yYr@8-N}^&?LfSwr)C~UUHr)ydENu=?ZHkvoLS~xTiBH= zD%A=OdoC+10l7@rXif~Z#^AvW+4M-(KQBj=Nhgts)>xmA--IJf1jSZF6>@Ns&nmv} zXRk`|`@P5_9W4O-SI|f^DCZ-n*yX@2gf6N)epc~lRWl7QgCyXdx|zr^gy>q`Vwn^y z&r3_zS}N=HmrVtTZhAQS`3$kBmVZDqr4+o(oNok?tqel9kn3;uUerFRti=k+&W{bb zT{ZtEf51Qf+|Jc*@(nyn#U+nr1SFpu4(I7<1a=)M_yPUAcKVF+(vK!|DTL2;P)yG~ zrI*7V)wN_92cM)j`PtAOFz_dO)jIfTeawh2{d@x0nd^#?pDkBTBzr0Oxgmvjt`U^$ zcTPl=iwuen=;7ExMVh7LLFSKUrTiPJpMB&*Ml32>wl} zYn(H0N4+>MCrm2BC4p{meYPafDEXd4yf$i%ylWpC|9%R4XZBUQiha(x%wgQ5iJ?K_wQBRfw z+pYuKoIameAWV7Ex4$PCd>bYD7)A9J`ri&bwTRN*w~7DR0EeLXW|I2()Zkl6vxiw? zFBX){0zT@w_4YUT4~@TXa;nPb^Tu$DJ=vluc~9)mZ}uHd#4*V_eS7)^eZ9oI%Wws_ z`;97^W|?_Z6xHSsE!3EKHPN<3IZ^jTJW=Il{rMmlnR#OuoE6dqOO1KOMpW84ZtDHNn)(pYvs=frO`$X}sY zKY0At$G85&2>B|-{*+B*aqQn&Mqjt*DVH2kdwEm5f}~Xwn9+tPt?EPwh8=8=VWA8rjt*bHEs1FJ92QohQ)Y z4sQH~AzB5!Pisyf?pVa0?L4gthx2;SKlrr?XRU`?Y>RJgUeJn!az#sNF7oDbzksrD zw8)f=f1t*UK&$}_ktf!yf4Rjt{56ffTA{A=9n})E7~iXaQkE+%GW4zqbmlYF(|hE@ z421q9`UQf$uA5yDLx67`=EnSTxdEaG!6C%9_obpb?;u-^QFX% zU1wQ}Li{PeT^fS;&Sk2#$ZM#Zpxrn7jsd<@qhfWy*H)cw9q!I9!fDOCw~4zg zbW`EHsTp9IQUCETUse)!ZmuRICx}0Oe1KVoqdK+u>67A8v`*X*!*_i5`_qTzYRkbYXg#4vT5~A{lK#bA}Oc4ePu5hr-@;i%Z!4Y;-(yR z(1rHYTc7i1h1aipP4DaIY3g2kF#MX{XW7g&zL!39ohO98=eo5nZtq+nz}2E$OZpxx z&OFaOM1O;?mxq+`%k>YS!-=H7BB&WhqSTUC{S!x*k9E zcB;u0I!h%3nEchQwu1GnNkaQxuWnW0D@Xq5j@5WE@E(WlgDU;FLsT*eV|Bh)aH0;~@^yygFj<=+Vu3p)LlF%1AA%y5z-Oh`2 z$RDKk_6r+f#I`8fQ%y#Wx%~de1qkWL2(q^~veLKwht-dIcpt(@lc>`~@mISRIPKPm zD!Za&aX@7dy*CT!&Z7JC1jP2@8+ro8SmlH>_gzRte%ojgiwfd?TR+%Ny0`sp`QRLy zl5TiQkFhIC!2aaJ&=Ua`c9UuOk9GkSFZ}!IGeMZ5MXrL zGtMj`m{(X9+l%=d|L zW2OY?8!_pyhvJ1@O!Chsf6}@3HmKq@)x;CFItPMpkSr@npO&8zMc_O?*|sqkuL^U? zV9+x3vbr|6;Ft0J^J>IH_xpa<{S5K?u-sQWC7FB9YFMwoCKK3WZ*gvO-wAApF`K%#7@1 z^sEj4*%hH`f0@sRDGI|#Dl20o$Z*gttP$q(_?#~2!H9(!d=)I93-3)?e%@$1^*F=t9t&OQ9!p84Z`+y<$yQ9wlamK~Hz2CRpS8dWJfBl@(M2qX!9d_F= zd|4A&U~8dX^M25wyC7$Swa22$G61V;fl{%Q4Lh!t_#=SP(sr_pvQ=wqOi`R)do~QX zk*_gsy75$xoi5XE&h7;-xVECk;DLoO0lJ3|6(Ba~ezi73_SYdCZPItS5MKaGE_1My zdQpx?h&RuoQ7I=UY{2Qf ziGQ-FpR%piffR_4X{74~>Q!=i`)J@T415!{8e`AXy`J#ZK)5WWm3oH?x1PVvcAqE@ zWI|DEUgxyN({@Y99vCJVwiGyx@9)y2jNg`R{$s2o;`4!^6nDX_pb~fTuzf>ZoPV@X zXKe1ehcZ+3dxCB+vikgKz8pvH?>ZzlOEObd{(-aWY;F0XIbuIjSA+!%TNy87a>BoX zsae$}Fcw&+)z@n{Fvzo;SkAw0U*}?unSO)^-+sbpNRjD8&qyfp%GNH;YKdHlz^)4( z;n%`#2Pw&DPA8tc)R9FW7EBR3?GDWhf@0(u3G4ijQV;{qp3B)`Fd}kMV}gB2U%4Sy z3x>YU&`V^PU$xWc4J!OG{Jglti@E3rdYo62K31iu!BU&pdo}S66Ctq{NB<88P92Y9 zTOqX$h6HH_8fKH(I>MEJZl1_2GB~xI+!|BLvN;CnQrjHuh?grzUO7h;1AbzLi|_O= z2S=(0tX#nBjN92gRsv;7`rDCATA!o(ZA}6)+;g;T#+1~HXGFD1@3D#|Ky9!E@)u=h z3@zg3Us0BCYmq(pB`^QTp|RB9!lX*{;7r|Z(^>J+av(0-oUmIdR78c4(q%hP#=R@W ze{;yy$T^8kXr(oC*#NQMZSQlgU)aa=BrZDwpLUk5tm&(AkNt&Gel`=ydcL*<@Ypx{ z2uOxl>2vSY2g3%Si&JU<9D5#{_z{9PzJh=miNH;STk^;5#%8iMRfPe#G~T>^U_zt? zgSE)`UQhb!G$at%yCf5MU)<&(L73(hY3*%qqPbX;`%QDHed3ZaWw^k)8Vjd#ePg@;I&pMe+A18k+S+bou|QX?8eQ`{P-0vrm=uR;Y(bHV>d>Gen4LHILqcm_ z3peDMRE3JMA8wWgPkSthI^K<|8aal38qvIcEgLjHAFB0P#IfqP2y}L>=8eBR}Fm^V*mw2Q4+o=exP@*#=Zs zIqHh@neG)Vy%v4cB1!L}w9J>IqAo}CsqbFPrUVc@;~Ld7t_2IIG=15mT7Itrjq#2~ zqX*&nwZP>vso$6W!#` z-YZ}jhBwQku-Qc>TIMpn%_z~`^u4v3Skyf)KA}V{`dr!Q;3xK1TuGYdl}$sKF^9X!*a-R*Oq1#tLq!W)gO}{q`1HM;oh1-k4FU@8W(qe>P05$+ z`ud2&;4IW4vq8#2yA{G>OH=G+pS_jctJ*BqD$j-MI#avR+<>m-`H1@{3VgKYn2_Ih z0`2_1qUMRuzgj_V^*;5Ax_0s{_3tYR>|$i#c!F7)#`oVGmsD*M2?%930cBSI4Mj>P zTm&JmUrvDXlB%zeA_7$&ogjGK3>SOlV$ct{4)P0k)Kua%*fx9?)_fkvz<(G=F`KCp zE`0j*=FzH$^Y@iUI}MM2Hf#Yr@oQdlJMB5xe0$aGNk%tgex;0)NEuVYtLEvOt{}ti zL`o$K9HnnUnl*;DTGTNiwr&ydfDp@3Y)g5$pcY9l1-9g;yn6SBr_S9MV8Xl+RWgwb zXL%kZLE4#4rUO(Pj484!=`jy74tQxD0Zg>99vvQ}R$7~GW)-0DVJR@$5}drsp3IQG zlrJL}M{+SdWbrO@+g2BY^a}0VdQtuoml`jJ2s6GsG5D@(^$5pMi3$27psEIOe^n=*Nj|Ug7VXN0OrwMrRq&@sR&vdnsRlI%*$vfmJ~)s z^?lstAT$Ked`b&UZ@A6I<(uCHGZ9pLqNhD_g-kj*Sa#0%(=8j}4zd;@!o;#vJ+Bsd z4&K4RIP>6It9Ir)ey?M6Gi6@JzKNg;=jM=$)gs2#u_WhvuTRwm1x2^*!e%l&j02xz zYInQgI$_V7Epzf3*BU~gos}|EurFj8l}hsI(!5yX!~ECL%cnYMS-e<`AKDL%(G)62 zPU;uF1(~(YbH2444JGh58coXT>(*CdEwaFuyvB|%CULgVQesH$ znB`vk3BMP<-QauWOZ0W6xB5y7?tE5cisG|V;bhY^8+*BH1T0ZLbn&gi12|a9Oa%;I zxvaxX_xe3@ng%;4C?zPHQ1v%dbhjA6Sl7w<*)Nr#F{Ahzj}%n9c&!g5HVrlvUO&R2C)_$x6M9 zahficAbeHL2%jILO>Pq&RPPxl;i{K5#O*Yt15AORTCvkjNfJ)LrN4K{sY7>tGuTQ@ z^?N*+xssG&sfp0c$^vV*H)U1O!fTHk8;Q7@42MT@z6UTd^&DKSxVcC-1OLjl7m63& zBb&goU!hes(GF^yc!107bkV6Pr%;A-WWd@DK2;&=zyiK*0i^0@f?fh2c)4&DRSjrI zk!W^=l^JKlPW9US{*yo?_XT@T2Bx+Cm^+r{*5LVcKVw*ll3+)lkebA-4)o z8f5xHWOx0!FDSs4nv@o@>mxTQrOeKzj@5uL`d>mXSp|#{FE54EE_!KtQNq>-G(&5) ztz?xkqPU16A-8@-quJ|SU^ClZ?bJ2kCJPB|6L>NTDYBprw$WcwCH{B z5qlJ6wK_9sT@Kl6G|Q&$gsl@WT>hE;nDAbH#%f1ZwuOkvWLj{qV$m3LF423&l!^iV zhym*>R>Yyens++~6F5+uZQTCz9t~PEW+e?w)XF2g!^^%6k?@Jcu;MG0FG9!T+Gx{Z zK;31y@(J{!-$k4E{5#Sv(2DGy3EZQY}G_*z*G&CZ_J?m&Fg4IBrvPx1w z1zAb3k}6nT?E)HNCi%}aR^?)%w-DcpBR*tD(r_c{QU6V&2vU-j0;{TVDN6los%YJZ z5C(*ZE#kv-BvlGLDf9>EO#RH_jtolA)iRJ>tSfJpF!#DO+tk% zBAKCwVZwO^p)(Rhk2en$XLfWjQQ`ix>K}Ru6-sn8Ih6k&$$y`zQ}}4dj~o@9gX9_= z#~EkchJqd5$**l}~~6mOl(q#GMIcFg&XCKO;$w>!K14 zko1egAORiG{r|8qj*FsN>?7d`han?*MD#xe^)sOqj;o;hgdaVnBH$BM{_73?znS+R z*G2VHM!Jw6#<FfJ-J%-9AuDW$@mc-Eyk~F{Jbvt` zn;(%DbBDnKIYr~|I>ZTvbH@cxUyw%bp*)OSs}lwO^HTJ2M#u5QsPF0?Jv*OVPfdKv z+t$Z5P!~jzZ~Y!d#iP?S{?M_g%Ua0Q)WawbIx+2uYpcf(7Im%W=rAu4dSceo7RZh# zN38=RmwOJQE$qbPXIuO^E`wSeJKCx3Q76irp~QS#19dusEVCWPrKhK9{7cbIMg9U} TZiJi*F`$tkWLn) literal 0 HcmV?d00001 diff --git a/minimal-sdk/android/gradle/wrapper/gradle-wrapper.properties b/minimal-sdk/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..09523c0e --- /dev/null +++ b/minimal-sdk/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/minimal-sdk/android/gradlew b/minimal-sdk/android/gradlew new file mode 100755 index 00000000..f5feea6d --- /dev/null +++ b/minimal-sdk/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/minimal-sdk/android/gradlew.bat b/minimal-sdk/android/gradlew.bat new file mode 100644 index 00000000..9b42019c --- /dev/null +++ b/minimal-sdk/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/minimal-sdk/android/settings.gradle.kts b/minimal-sdk/android/settings.gradle.kts new file mode 100644 index 00000000..826ac130 --- /dev/null +++ b/minimal-sdk/android/settings.gradle.kts @@ -0,0 +1,32 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "utexo-minimal-sdk-android" + +// AGP locates the SDK from ANDROID_HOME / ANDROID_SDK_ROOT or `sdk.dir` in +// local.properties (gitignored). CI sets the variable; for a developer +// machine with the SDK at its default location, write local.properties once +// so the plan's gate command needs no extra environment. +run { + val localProperties = rootDir.resolve("local.properties") + val fromEnv = System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT") + if (fromEnv == null && !localProperties.exists()) { + val defaultSdk = File(System.getProperty("user.home"), "Android/Sdk") + if (defaultSdk.resolve("platforms").isDirectory) { + localProperties.writeText("sdk.dir=${defaultSdk.absolutePath}\n") + } + } +} diff --git a/minimal-sdk/android/src/main/AndroidManifest.xml b/minimal-sdk/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8072ee00 --- /dev/null +++ b/minimal-sdk/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/minimal-sdk/android/src/test/kotlin/com/utexo/minimalsdk/SmokeTest.kt b/minimal-sdk/android/src/test/kotlin/com/utexo/minimalsdk/SmokeTest.kt new file mode 100644 index 00000000..85e76e5d --- /dev/null +++ b/minimal-sdk/android/src/test/kotlin/com/utexo/minimalsdk/SmokeTest.kt @@ -0,0 +1,302 @@ +package com.utexo.minimalsdk + +import org.json.JSONObject +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Smoke test through the generated uniffi bindings. + * + * Deep assertions (parity for every network and index, the adversarial + * verify suite, secrets hygiene) live in the Rust crate's `tests/parity.rs`. + * This test proves the binding surface, the native library load through JNA + * and the `with_foreign` `HttpTransport` trait work end to end, against the + * same rgb-lib-authored fixture the Rust suite reads, by relative path. + */ +class SmokeTest { + private companion object { + /** BOLT-11 spec vector: 2 500 µBTC, "1 cup coffee", 60 s expiry. */ + const val BOLT11_COFFEE = + "lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh" + const val ADDRESS = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080" + const val ATTACKER = "bcrt1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3" + const val GATEWAY = "https://gateway.example" + const val TOKEN = "secret-token" + val PREPARED_SEND_BTC = """{"opId":"op-1","psbt":"cHNidP8=","expiresAt":1700000000,""" + + """"intent":{"kind":"send_btc","feeRateSatPerVb":2,""" + + """"recipients":[{"address":"$ADDRESS","scriptHex":"0014deadbeef","amountSat":40000}],""" + + """"asset":null,"utxos":null}}""" + } + + private val fixture: JSONObject by lazy { + val path = System.getProperty("utexo.minimalsdk.fixture") + ?: "../packages/client-sdk/test/fixtures/rgblib-parity.json" + val file = File(path) + assertTrue(file.isFile, "parity fixture missing at ${file.absolutePath}") + JSONObject(file.readText()) + } + + private val regtestFixture: JSONObject get() = fixture.getJSONObject("networks").getJSONObject("Regtest") + + private fun ourKeys(): ClientKeys = + ClientKeys.fromMnemonic(fixture.getString("mnemonic"), BitcoinNetwork.REGTEST) + + private fun foreignKeys(): ClientKeys = + ClientKeys.fromMnemonic(fixture.getString("otherMnemonic"), BitcoinNetwork.REGTEST) + + /** The intent the fixture PSBT was prepared for: 40 000 sat to the foreign wallet's vanilla address 0. */ + private fun fixtureSendBtcIntent(): OnchainIntent { + val foreign = deriveTaprootAddress(foreignKeys().xpubs().vanilla, 0u, 0u, BitcoinNetwork.REGTEST) + return OnchainIntent( + kind = IntentKind.SEND_BTC, + feeRateSatPerVb = 2UL, + recipients = listOf( + IntentRecipient(address = foreign.address, scriptHex = foreign.scriptHex, amountSat = 40_000UL), + ), + asset = null, + utxos = null, + ) + } + + private fun fixtureParams(keys: ClientKeys, intent: OnchainIntent = fixtureSendBtcIntent()) = + VerifyParams(intent = intent, xpubs = keys.xpubs(), maxFeeSat = 2_000UL) + + @Test + fun nativeLibraryLoadsAndReportsVersion() { + val version = sdkVersion() + assertTrue(version.isNotEmpty()) + assertTrue(Regex("""\d+\.\d+\.\d+""").matches(version), "semver expected, got $version") + } + + @Test + fun restoreFromFixtureMnemonicMatchesRgbLibXpubsAndFingerprint() { + val keys = ourKeys() + val expected = regtestFixture + assertEquals(BitcoinNetwork.REGTEST, keys.network()) + assertEquals(expected.getString("masterFingerprint"), keys.fingerprint()) + + val xpubs = keys.xpubs() + assertEquals(BitcoinNetwork.REGTEST, xpubs.network) + assertEquals(expected.getString("masterFingerprint"), xpubs.fingerprint) + assertEquals(expected.getString("accountXpubVanilla"), xpubs.vanilla) + assertEquals(expected.getString("accountXpubColored"), xpubs.colored) + // Public material only crosses the boundary. + assertFalse(xpubs.vanilla.startsWith("tprv") || xpubs.colored.startsWith("tprv")) + } + + @Test + fun deriveIndexZeroMatchesRgbLibAddresses() { + val xpubs = ourKeys().xpubs() + val regtest = fixture.getJSONObject("regtest") + + val vanilla = deriveTaprootAddress(xpubs.vanilla, 0u, 0u, BitcoinNetwork.REGTEST) + assertEquals(regtest.getJSONArray("vanillaAddresses").getString(0), vanilla.address) + assertEquals(0u, vanilla.keychain) + assertEquals(0u, vanilla.index) + assertTrue(vanilla.scriptHex.startsWith("5120") && vanilla.scriptHex.length == 68) + + val colored = deriveTaprootAddress(xpubs.colored, 0u, 0u, BitcoinNetwork.REGTEST) + assertEquals(regtest.getJSONArray("coloredAddresses").getString(0), colored.address) + } + + @Test + fun verifyAndSignFixturePsbtReachesRgbLibTxid() { + val keys = ourKeys() + val signing = fixture.getJSONObject("signing") + val unsigned = signing.getString("unsignedPsbt") + val params = fixtureParams(keys) + + val verdict = verifyPsbt(unsigned, params) + assertTrue(verdict.ok, "verdict failed: ${verdict.checks.filter { !it.ok }}") + assertEquals(5, verdict.checks.size) + + val result = verifyAndSignPsbt(keys, unsigned, params) + assertEquals(signing.getString("txid"), result.txid) + assertTrue(result.verdict.ok) + assertTrue(result.signedPsbt.isNotEmpty()) + assertTrue(result.signedPsbt != unsigned, "signing must change the PSBT") + } + + @Test + fun signingRefusesWhenTheIntentDoesNotMatch() { + val keys = ourKeys() + val unsigned = fixture.getJSONObject("signing").getString("unsignedPsbt") + val tampered = fixtureSendBtcIntent().let { intent -> + intent.copy(recipients = listOf(intent.recipients[0].copy(amountSat = 40_001UL))) + } + val error = assertFailsWith { + verifyAndSignPsbt(keys, unsigned, fixtureParams(keys, tampered)) + } + assertEquals("recipients-match", error.check) + } + + @Test + fun errorsCrossTheBoundaryAsTypedExceptions() { + assertFailsWith { + ClientKeys.fromMnemonic("abandon abandon abandon", BitcoinNetwork.REGTEST) + } + assertFailsWith { + deriveTaprootAddress("not-an-xpub", 0u, 0u, BitcoinNetwork.REGTEST) + } + } + + /** A Kotlin-side transport: records the request Rust built and answers with a canned response. */ + private class StubTransport(private val status: UShort, private val body: String) : HttpTransport { + val requests = mutableListOf() + override fun send(request: HttpRequest): HttpResponse { + requests += request + return HttpResponse(status, body) + } + } + + @Test + fun gatewayClientDrivesAKotlinHttpTransport() { + val transport = StubTransport(200u, """{"userId":"user-1","createdAt":1700000000}""") + val client = GatewayClient("https://gateway.example", "secret-token", null, transport) + + val me = client.me() + assertEquals("user-1", me.userId) + assertEquals(1_700_000_000UL, me.createdAt) + + val request = transport.requests.single() + assertEquals(HttpMethod.GET, request.method) + assertEquals("https://gateway.example/v1/me", request.url) + assertEquals("Bearer secret-token", request.headers["authorization"]) + assertEquals(null, request.body) + assertTrue(request.timeoutMs > 0UL) + } + + @Test + fun gatewayErrorsSurfaceStatusAndCodeFromTheTransport() { + val transport = StubTransport(503u, """{"error":{"code":"MAINTENANCE","message":"back soon"}}""") + val client = GatewayClient("https://gateway.example", "secret-token", null, transport) + val error = assertFailsWith { client.me() } + assertEquals(503u.toUShort(), error.status) + assertEquals("MAINTENANCE", error.code) + assertFalse(error.toString().contains("secret-token"), "bearer token leaked into the error") + } + + /** A transport that throws instead of answering. */ + private class ThrowingTransport(private val error: Throwable) : HttpTransport { + override fun send(request: HttpRequest): HttpResponse = throw error + } + + @Test + fun transportErrorsCrossBackIntoRustAsTypedTransportExceptions() { + // The declared error type passes through unchanged. + val declared = GatewayClient(GATEWAY, TOKEN, null, ThrowingTransport(SdkException.Transport("dns failed"))) + assertEquals("dns failed", assertFailsWith { declared.me() }.reason) + + // A host exception that is not an SdkException must still surface as a + // typed Transport error, never as an internal (panic) exception. + val foreign = GatewayClient(GATEWAY, TOKEN, null, ThrowingTransport(IllegalStateException("pool closed"))) + val mapped = assertFailsWith { foreign.me() } + assertTrue(mapped.reason.contains("unexpected"), mapped.reason) + assertTrue(mapped.reason.contains("pool closed"), mapped.reason) + assertFalse(mapped.toString().contains(TOKEN), "bearer token leaked into the error") + } + + @Test + fun generatedKeysRestoreToTheSameXpubs() { + val generated = generateKeys(BitcoinNetwork.REGTEST) + assertEquals(12, generated.mnemonic.trim().split(" ").size) + val restored = ClientKeys.fromMnemonic(generated.mnemonic, BitcoinNetwork.REGTEST) + assertEquals(generated.keys.xpubs(), restored.xpubs()) + assertEquals(generated.keys.fingerprint(), restored.fingerprint()) + assertEquals(BitcoinNetwork.REGTEST, generated.keys.network()) + assertTrue(generated.keys.xpubs().vanilla.startsWith("tpub")) + // A fresh generation is a different wallet. + assertTrue(generateKeys(BitcoinNetwork.REGTEST).keys.xpubs() != generated.keys.xpubs()) + } + + @Test + fun invoiceDecodersCrossTheBoundaryWithOptionalsListsAndEnums() { + val bolt11 = decodeBolt11(BOLT11_COFFEE) + assertEquals(BitcoinNetwork.MAINNET, bolt11.network) + assertEquals(250_000_000UL, bolt11.amountMsat) + assertEquals("1 cup coffee", bolt11.description) + assertEquals(null, bolt11.descriptionHash) + assertEquals(60UL, bolt11.expirySeconds) + assertEquals(64, bolt11.paymentHash.length) + assertEquals(66, bolt11.payeeNodeId.length) + + val wr = fixture.getJSONObject("witnessReceive") + val rgb = decodeRgbInvoice(wr.getString("invoice")) + assertEquals(wr.getString("recipientId"), rgb.recipientId) + assertEquals(BeneficiaryKind.WITNESS, rgb.beneficiaryKind) + assertEquals(null, rgb.assetId) + assertEquals(null, rgb.amount) + assertEquals("bcrt", rgb.chain) + assertEquals(listOf("rpc://localhost:3000/json-rpc"), rgb.transportEndpoints) + assertEquals(wr.getLong("expirationTimestamp").toULong(), rgb.expiryTimestamp) + + assertFailsWith { decodeBolt11("lnbc1pvjluez") } + assertFailsWith { decodeRgbInvoice("not an invoice") } + } + + @Test + fun idempotencyKeysAreV4Uuids() { + val key = generateIdempotencyKey() + assertTrue(Regex("""[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}""").matches(key), key) + assertTrue(key != generateIdempotencyKey(), "keys must be unique per call") + } + + @Test + fun prepareSendBtcBindsTheIntentAndSurfacesNestedRecords() { + val transport = StubTransport(200u, PREPARED_SEND_BTC) + val client = GatewayClient(GATEWAY, TOKEN, null, transport) + val op = client.prepareSendBtc(PrepareSendBtcParams(ADDRESS, 40_000UL, null), null) + assertEquals("op-1", op.opId) + assertEquals("cHNidP8=", op.psbt) + assertEquals(1_700_000_000UL, op.expiresAt) + assertEquals(IntentKind.SEND_BTC, op.intent.kind) + assertEquals(2UL, op.intent.feeRateSatPerVb) + assertEquals(null, op.intent.asset) + assertEquals(null, op.intent.utxos) + assertEquals(IntentRecipient(ADDRESS, "0014deadbeef", 40_000UL), op.intent.recipients.single()) + val request = transport.requests.single() + assertEquals(HttpMethod.POST, request.method) + assertEquals("$GATEWAY/v1/onchain/send-btc/prepare", request.url) + assertEquals("application/json", request.headers["content-type"]) + assertTrue(request.headers.containsKey("idempotency-key")) + assertTrue(JSONObject(request.body!!).getString("address") == ADDRESS) + + // The same gateway response does not match a request for another address. + val tampered = GatewayClient(GATEWAY, TOKEN, null, StubTransport(200u, PREPARED_SEND_BTC)) + val error = assertFailsWith { + tampered.prepareSendBtc(PrepareSendBtcParams(ATTACKER, 40_000UL, null), null) + } + assertTrue(error.reason.contains("recipient address"), error.reason) + } + + @Test + fun lnBalanceMapsCrossTheBoundary() { + val transport = StubTransport(200u, """{"btcMsat":12345,"assets":{"rgb:a":7,"rgb:b":0}}""") + val balance = GatewayClient(GATEWAY, TOKEN, null, transport).getLnBalance() + assertEquals(12_345UL, balance.btcMsat) + assertEquals(mapOf("rgb:a" to 7UL, "rgb:b" to 0UL), balance.assets) + } + + @Test + fun consumerRulesKeepOnlyJnaEntryPoints() { + val rules = File(System.getProperty("utexo.minimalsdk.consumerRules") ?: "consumer-rules.pro") + assertTrue(rules.isFile, "consumer-rules.pro missing at ${rules.absolutePath}") + val lines = rules.readLines().map { it.trim() } + val keeps = lines.filter { it.startsWith("-keep ") } + assertTrue(keeps.isNotEmpty()) + // JNA reads @Structure.FieldOrder reflectively; R8 strips runtime annotations unless kept. + assertTrue( + lines.any { it.startsWith("-keepattributes") && it.contains("RuntimeVisibleAnnotations") }, + "consumer-rules.pro must keep RuntimeVisibleAnnotations for JNA @Structure.FieldOrder", + ) + assertTrue(keeps.none { it.contains("com.utexo.minimalsdk") }, "blanket keep of the SDK package: $keeps") + assertNotNull(keeps.find { it.contains("com.sun.jna.Library") }) + assertNotNull(keeps.find { it.contains("com.sun.jna.Callback") }) + assertNotNull(keeps.find { it.contains("com.sun.jna.Structure") }) + } +} diff --git a/minimal-sdk/apple/.gitignore b/minimal-sdk/apple/.gitignore new file mode 100644 index 00000000..c7542dd5 --- /dev/null +++ b/minimal-sdk/apple/.gitignore @@ -0,0 +1,7 @@ +# Generated by ../scripts/generate_bindings.sh swift and ../scripts/build_apple.sh. +/Sources/MinimalSdk/Generated/ +/Sources/MinimalSdkFFI/ +/MinimalSdkFFI.xcframework/ +/build/ +/.build/ +/.swiftpm/ diff --git a/minimal-sdk/apple/Package.swift b/minimal-sdk/apple/Package.swift new file mode 100644 index 00000000..4d2ce739 --- /dev/null +++ b/minimal-sdk/apple/Package.swift @@ -0,0 +1,57 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// UTEXO minimal SDK for Apple platforms: the Swift bindings generated by +// ../scripts/generate_bindings.sh swift on top of the Rust core packaged by +// ../scripts/build_apple.sh as MinimalSdkFFI.xcframework. Run those two +// scripts (build_apple.sh runs both) before building this package. +// +// Deployment targets are the SDK's floor policy (see README.md): iOS 15 and +// macOS 12. build_apple.sh pins the same versions into the static libraries. + +#if os(Linux) +// Host-only test aid, the same trick test/swift-e2e uses for the node: there +// is no XCFramework on Linux, so link the Rust cdylib from `cargo build +// --release` directly and expose the generated header through a +// systemLibrary target. This lets `swift test` run SmokeTests on a Linux +// box or runner. Never a shipping configuration; unsafeFlags also makes the +// package unusable as a dependency in this branch, which is intended. +let rustLibDir = Context.environment["UTEXO_MINIMAL_SDK_LIB_DIR"] + ?? "\(Context.packageDirectory)/../rust-sdk/target/release" +let ffiTargets: [Target] = [ + .systemLibrary(name: "MinimalSdkFFI", path: "Sources/MinimalSdkFFI"), +] +let coreLinkerSettings: [LinkerSetting] = [ + .unsafeFlags(["-L", rustLibDir, "-Xlinker", "-rpath", "-Xlinker", rustLibDir]), + .linkedLibrary("utexo_minimal_sdk"), +] +#else +let ffiTargets: [Target] = [ + .binaryTarget(name: "MinimalSdkFFI", path: "MinimalSdkFFI.xcframework"), +] +let coreLinkerSettings: [LinkerSetting] = [] +#endif + +let package = Package( + name: "MinimalSdk", + platforms: [ + .iOS(.v15), + .macOS(.v12), + ], + products: [ + .library(name: "MinimalSdk", targets: ["MinimalSdk"]), + ], + targets: ffiTargets + [ + .target( + name: "MinimalSdk", + dependencies: ["MinimalSdkFFI"], + path: "Sources/MinimalSdk", + linkerSettings: coreLinkerSettings + ), + .testTarget( + name: "MinimalSdkTests", + dependencies: ["MinimalSdk"], + path: "Tests/MinimalSdkTests" + ), + ] +) diff --git a/minimal-sdk/apple/README.md b/minimal-sdk/apple/README.md new file mode 100644 index 00000000..c62ba39c --- /dev/null +++ b/minimal-sdk/apple/README.md @@ -0,0 +1,100 @@ +# UTEXO minimal SDK — Apple package + +Swift bindings and an XCFramework for the minimal SDK core in +[`../rust-sdk`](../rust-sdk): keys, taproot derivation, verify-before-sign, +signing, invoice decoding and the gateway client, in one Rust implementation +shared with the [Android package](../android). This is **not** the node: no +LDK, no rgb-lib, no HTTP or TLS stack. + +- Swift package `MinimalSdk` (module `MinimalSdk`, C module `MinimalSdkFFI`), + swift-tools 5.9, Swift language mode 5. +- Deployment targets **iOS 15.0** and **macOS 12.0**, declared in + `Package.swift` and pinned into every static library by `build_apple.sh`. +- No third-party dependencies. uniffi's Swift bindings call the Rust static + library directly; there is no JNA equivalent to ship. +- Networking is the app's: `URLSessionHttpTransport` (one synchronous `send`, + ephemeral session) is provided and any `HttpTransport` conformance can + replace it. Rust performs no IO. + +## Deployment-target policy + +Both mobile packages use the same rule: the floor is the oldest OS version +that still covers about 98 % of the active installed base, so the SDK never +excludes a device its host app can reach. On Android that is API 24; on +Apple platforms that is iOS 15 (2021, the last iOS line Apple was still +patching in 2025) and its contemporary macOS 12 for the macOS slice. Raising +the floor is a one-line change in `Package.swift` plus the matching +`IOS_DEPLOYMENT_TARGET` / `MACOS_DEPLOYMENT_TARGET` in `build_apple.sh`; they +must move together. + +## Build (macOS + Xcode) + +```sh +rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios \ + aarch64-apple-darwin x86_64-apple-darwin +../scripts/build_apple.sh # cargo (5 targets) → bindings → lipo → MinimalSdkFFI.xcframework +swift test # smoke test, runs on the macOS slice +``` + +`build_apple.sh` takes no arguments; its only knobs are the deployment-target +environment variables above. It builds release **static** libraries for the iOS device +target, the two iOS simulator targets and the two macOS targets, runs +`../scripts/generate_bindings.sh swift` (uniffi library mode, no UDL), lipos +the simulator and macOS slices into universal archives and calls +`xcodebuild -create-xcframework` with the device, simulator and macOS +libraries, each with the same headers directory +(`Sources/MinimalSdkFFI/{MinimalSdkFFI.h,module.modulemap}`). The macOS +slice exists so `swift test` runs natively on a Mac; an iOS app links only +the device slice. + +Generated files (`Sources/MinimalSdk/Generated/`, `Sources/MinimalSdkFFI/`, +`MinimalSdkFFI.xcframework/`, `build/`) are not committed. + +## Test on Linux (no Xcode) + +The package has a Linux branch that mirrors what `test/swift-e2e` does for +the node: it links the host cdylib from `cargo build --release` through a +`systemLibrary` target instead of the XCFramework. It is a test aid only, not +a shipping configuration. + +```sh +cargo build --release --lib --manifest-path ../rust-sdk/Cargo.toml +../scripts/generate_bindings.sh swift +swift test # any Swift 5.9+ toolchain for Linux +``` + +Set `UTEXO_MINIMAL_SDK_LIB_DIR` to point at a different directory holding +`libutexo_minimal_sdk.so`. + +## Size: measure the app-size delta, not the archive + +iOS links statically and the app linker **dead-strips** the archive, so the +size of `libutexo_minimal_sdk.a` is not what the user downloads. The node's +XCFramework carries a 391.9 MB `.a` and that figure has misled before +(`docs/design/mobile-sdk-and-lightweight-uniffi.md`). The number that matters +for this package is the **delta in the app's installed size** with and +without the SDK linked, measured on a device build with App Thinning; expect +the same order as the Android arm64 slice (2.37 MB raw, of which about +1.06 MB is libsecp256k1's precomputed tables) as an upper bound, less after +dead-stripping of anything the app does not call. `build_apple.sh` prints +the archive sizes for the record and deliberately has no size gate; the Rust +size gate (`../rust-sdk/tests/parity.rs`, 2.5 MB host cdylib) and the Android +arm64 gate (`../scripts/build_android.sh`, 3.0 MB) bound the code that +reaches the app. + +## What is verified where + +The macOS column lists what the `apple` job of +`.github/workflows/minimal-sdk-mobile.yaml` runs; its current result is the +latest workflow run, not this table. + +| Check | Linux (this repo's dev machine) | macOS CI job | +| ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------ | +| Swift bindings generate in library mode, no UDL | yes | yes | +| `bash -n` and `shellcheck` on both scripts | yes | – | +| `Package.swift` parses (both `os()` branches) | yes | yes | +| Swift smoke test (17 cases) | yes, against the host cdylib | yes, against the macOS slice | +| `URLSessionHttpTransport` through an in-process `URLProtocol` stub (status pass-through, verbatim headers and body, non-UTF-8 body, bounded wait) | yes | yes | +| iOS device + simulator static libraries | **no** (needs Apple SDKs) | yes | +| `lipo` and `xcodebuild -create-xcframework` | **no** (needs Xcode) | yes | +| uniffi entry points exported by the device slice | **no** | yes (`nm` in `build_apple.sh`) | diff --git a/minimal-sdk/apple/Sources/MinimalSdk/URLSessionHttpTransport.swift b/minimal-sdk/apple/Sources/MinimalSdk/URLSessionHttpTransport.swift new file mode 100644 index 00000000..5e3b7b88 --- /dev/null +++ b/minimal-sdk/apple/Sources/MinimalSdk/URLSessionHttpTransport.swift @@ -0,0 +1,102 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// `HttpTransport` over `URLSession`. +/// +/// The Rust core builds every gateway request (method, URL, headers, JSON +/// body, timeout) and parses every response; this type only moves bytes. +/// It is the Swift twin of the Kotlin OkHttp/`HttpURLConnection` transport: +/// networking stays idiomatic per platform and Rust links no HTTP or TLS +/// stack. +/// +/// `send` is synchronous because the FFI trait is; it blocks the calling +/// thread on a semaphore until `URLSession` answers. Call `GatewayClient` +/// off the main thread (a `Task.detached` or a background queue), exactly +/// as you would a blocking socket. +public final class URLSessionHttpTransport: HttpTransport { + private let session: URLSession + + /// - Parameter session: defaults to an ephemeral session so nothing the + /// gateway returns is written to a disk cache. + public init(session: URLSession = URLSession(configuration: .ephemeral)) { + self.session = session + } + + public func send(request: HttpRequest) throws -> HttpResponse { + guard let url = URL(string: request.url) else { + throw SdkError.Transport(reason: "invalid URL") + } + var urlRequest = URLRequest(url: url) + urlRequest.timeoutInterval = TimeInterval(request.timeoutMs) / 1000 + switch request.method { + case .get: + urlRequest.httpMethod = "GET" + case .post: + urlRequest.httpMethod = "POST" + } + for (name, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + if let body = request.body { + urlRequest.httpBody = Data(body.utf8) + } + + let semaphore = DispatchSemaphore(value: 0) + let outcome = Outcome() + let task = session.dataTask(with: urlRequest) { data, response, error in + defer { semaphore.signal() } + if let error = error { + // URLError descriptions carry the host but never the + // request headers, so the bearer token cannot leak here. + outcome.set(.failure(.Transport(reason: error.localizedDescription))) + return + } + guard let http = response as? HTTPURLResponse else { + outcome.set(.failure(.Transport(reason: "non-HTTP response"))) + return + } + // The contract is "return Ok for any status the server answered": + // a non-UTF-8 error page still carries its HTTP status, so decode + // lossily instead of hiding the status behind a Transport error. + let body = String(decoding: data ?? Data(), as: UTF8.self) + guard http.statusCode >= 0, http.statusCode <= Int(UInt16.max) else { + outcome.set(.failure(.Transport(reason: "invalid HTTP status"))) + return + } + outcome.set(.success(HttpResponse(status: UInt16(http.statusCode), body: body))) + } + task.resume() + // `timeoutInterval` is URLSession's inactivity timeout; `timeoutMs` + // is the total budget Rust promised the caller. Bound the wait as + // well so a slow-drip server cannot hold the thread past it. + let budget = DispatchTime.now() + .milliseconds(Int(clamping: request.timeoutMs)) + if semaphore.wait(timeout: budget) == .timedOut { + task.cancel() + throw SdkError.Transport(reason: "timed out after \(request.timeoutMs) ms") + } + return try outcome.get() + } + + /// Hands the completion handler's result back to the blocked caller. + /// The lock makes the hand-off well-defined under Swift 6 strict + /// concurrency; the semaphore already orders the two accesses. + private final class Outcome: @unchecked Sendable { + private let lock = NSLock() + private var value: Result? + + func set(_ result: Result) { + lock.lock() + value = result + lock.unlock() + } + + func get() throws -> HttpResponse { + lock.lock() + defer { lock.unlock() } + guard let value = value else { throw SdkError.Transport(reason: "no response") } + return try value.get() + } + } +} diff --git a/minimal-sdk/apple/Tests/MinimalSdkTests/SmokeTests.swift b/minimal-sdk/apple/Tests/MinimalSdkTests/SmokeTests.swift new file mode 100644 index 00000000..c4f3268d --- /dev/null +++ b/minimal-sdk/apple/Tests/MinimalSdkTests/SmokeTests.swift @@ -0,0 +1,507 @@ +import Foundation +import XCTest +@testable import MinimalSdk +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Smoke test through the generated uniffi bindings. +/// +/// Deep assertions (parity for every network and index, the adversarial +/// verify suite, secrets hygiene) live in the Rust crate's `tests/parity.rs`. +/// This test proves the binding surface, the static link and the +/// `with_foreign` `HttpTransport` trait work end to end, against the same +/// rgb-lib-authored fixture the Rust suite reads, by relative path. It is the +/// Swift twin of the Kotlin `SmokeTest`. +final class SmokeTests: XCTestCase { + // MARK: fixture + + private struct FixtureError: Error, CustomStringConvertible { + let description: String + } + + /// Loaded once; a missing or malformed fixture fails each test that + /// needs it (via `throw`) instead of aborting the whole test process. + private static let fixtureResult: Result<[String: Any], Error> = { + let testFile = URL(fileURLWithPath: #filePath) + let path = ProcessInfo.processInfo.environment["UTEXO_MINIMALSDK_FIXTURE"].map { URL(fileURLWithPath: $0) } + ?? testFile + .deletingLastPathComponent() // MinimalSdkTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // apple + .deletingLastPathComponent() // minimal-sdk + .appendingPathComponent("packages/client-sdk/test/fixtures/rgblib-parity.json") + guard let data = FileManager.default.contents(atPath: path.path) else { + return .failure(FixtureError(description: "parity fixture missing at \(path.path)")) + } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return .failure(FixtureError(description: "parity fixture is not a JSON object")) + } + return .success(json) + }() + + private func fixture() throws -> [String: Any] { try Self.fixtureResult.get() } + + private func string(_ key: String, in object: [String: Any]) throws -> String { + guard let value = object[key] as? String else { + throw FixtureError(description: "fixture key \(key) missing or not a string") + } + return value + } + + private func object(_ key: String, in object: [String: Any]) throws -> [String: Any] { + guard let value = object[key] as? [String: Any] else { + throw FixtureError(description: "fixture key \(key) missing or not an object") + } + return value + } + + private func regtestNetworkFixture() throws -> [String: Any] { + try object("Regtest", in: object("networks", in: fixture())) + } + + private func regtestAddresses() throws -> [String: Any] { try object("regtest", in: fixture()) } + + private func signingFixture() throws -> [String: Any] { try object("signing", in: fixture()) } + + private func ourKeys() throws -> ClientKeys { + try ClientKeys.fromMnemonic(mnemonic: string("mnemonic", in: fixture()), network: .regtest) + } + + private func foreignKeys() throws -> ClientKeys { + try ClientKeys.fromMnemonic(mnemonic: string("otherMnemonic", in: fixture()), network: .regtest) + } + + /// The intent the fixture PSBT was prepared for: 40 000 sat to the + /// foreign wallet's vanilla address 0. + private func fixtureSendBtcIntent() throws -> OnchainIntent { + let foreign = try deriveTaprootAddress( + accountXpub: try foreignKeys().xpubs().vanilla, keychain: 0, index: 0, network: .regtest) + return OnchainIntent( + kind: .sendBtc, + feeRateSatPerVb: 2, + recipients: [ + IntentRecipient(address: foreign.address, scriptHex: foreign.scriptHex, amountSat: 40_000), + ], + asset: nil, + utxos: nil) + } + + private func fixtureParams(keys: ClientKeys, intent: OnchainIntent? = nil) throws -> VerifyParams { + VerifyParams( + intent: try intent ?? fixtureSendBtcIntent(), + xpubs: keys.xpubs(), + maxFeeSat: 2_000, + changeScanWindow: nil, + maxOwnOutputIndex: nil) + } + + // MARK: constants shared with the Kotlin smoke test + + /// BOLT-11 spec vector: 2 500 µBTC, "1 cup coffee", 60 s expiry. + private static let bolt11Coffee = + "lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh" + private static let address = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080" + private static let attacker = "bcrt1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3" + private static let gateway = "https://gateway.example" + private static let token = "secret-token" + private static let preparedSendBtc = + #"{"opId":"op-1","psbt":"cHNidP8=","expiresAt":1700000000,"intent":{"kind":"send_btc","feeRateSatPerVb":2,"# + + #""recipients":[{"address":"\#(address)","scriptHex":"0014deadbeef","amountSat":40000}],"asset":null,"utxos":null}}"# + + // MARK: tests + + func testNativeLibraryLoadsAndReportsVersion() { + let version = sdkVersion() + XCTAssertFalse(version.isEmpty) + XCTAssertNotNil(version.range(of: #"^\d+\.\d+\.\d+$"#, options: .regularExpression), "semver expected, got \(version)") + } + + func testRestoreFromFixtureMnemonicMatchesRgbLibXpubsAndFingerprint() throws { + let keys = try ourKeys() + let expected = try regtestNetworkFixture() + XCTAssertEqual(keys.network(), .regtest) + XCTAssertEqual(keys.fingerprint(), try string("masterFingerprint", in: expected)) + + let xpubs = keys.xpubs() + XCTAssertEqual(xpubs.network, .regtest) + XCTAssertEqual(xpubs.fingerprint, try string("masterFingerprint", in: expected)) + XCTAssertEqual(xpubs.vanilla, try string("accountXpubVanilla", in: expected)) + XCTAssertEqual(xpubs.colored, try string("accountXpubColored", in: expected)) + // Public material only crosses the boundary. + XCTAssertFalse(xpubs.vanilla.hasPrefix("tprv") || xpubs.colored.hasPrefix("tprv")) + } + + func testGeneratedKeysRestoreToTheSameXpubs() throws { + let generated = try generateKeys(network: .regtest) + XCTAssertEqual(generated.mnemonic.split(separator: " ").count, 12) + let restored = try ClientKeys.fromMnemonic(mnemonic: generated.mnemonic, network: .regtest) + XCTAssertEqual(generated.keys.xpubs(), restored.xpubs()) + XCTAssertEqual(generated.keys.fingerprint(), restored.fingerprint()) + XCTAssertEqual(generated.keys.network(), .regtest) + XCTAssertTrue(generated.keys.xpubs().vanilla.hasPrefix("tpub")) + // A fresh generation is a different wallet. + XCTAssertNotEqual(try generateKeys(network: .regtest).keys.xpubs(), generated.keys.xpubs()) + } + + func testDeriveIndexZeroMatchesRgbLibAddresses() throws { + let xpubs = try ourKeys().xpubs() + let addresses = try regtestAddresses() + let vanillaAddresses = addresses["vanillaAddresses"] as? [String] ?? [] + let coloredAddresses = addresses["coloredAddresses"] as? [String] ?? [] + + let vanilla = try deriveTaprootAddress(accountXpub: xpubs.vanilla, keychain: 0, index: 0, network: .regtest) + XCTAssertEqual(vanilla.address, vanillaAddresses.first) + XCTAssertEqual(vanilla.keychain, 0) + XCTAssertEqual(vanilla.index, 0) + XCTAssertTrue(vanilla.scriptHex.hasPrefix("5120") && vanilla.scriptHex.count == 68) + + let colored = try deriveTaprootAddress(accountXpub: xpubs.colored, keychain: 0, index: 0, network: .regtest) + XCTAssertEqual(colored.address, coloredAddresses.first) + } + + func testVerifyAndSignFixturePsbtReachesRgbLibTxid() throws { + let keys = try ourKeys() + let signing = try signingFixture() + let unsigned = try string("unsignedPsbt", in: signing) + let params = try fixtureParams(keys: keys) + + let verdict = verifyPsbt(psbt: unsigned, params: params) + XCTAssertTrue(verdict.ok, "verdict failed: \(verdict.checks.filter { !$0.ok })") + XCTAssertEqual(verdict.checks.count, 5) + + let result = try verifyAndSignPsbt(keys: keys, psbt: unsigned, params: params) + XCTAssertEqual(result.txid, try string("txid", in: signing)) + XCTAssertTrue(result.verdict.ok) + XCTAssertFalse(result.signedPsbt.isEmpty) + XCTAssertNotEqual(result.signedPsbt, unsigned, "signing must change the PSBT") + } + + func testSigningRefusesWhenTheIntentDoesNotMatch() throws { + let keys = try ourKeys() + let unsigned = try string("unsignedPsbt", in: signingFixture()) + var tampered = try fixtureSendBtcIntent() + tampered.recipients[0].amountSat = 40_001 + + XCTAssertThrowsError(try verifyAndSignPsbt(keys: keys, psbt: unsigned, params: try fixtureParams(keys: keys, intent: tampered))) { error in + guard case SdkError.VerificationFailed(let check, _) = error else { + return XCTFail("expected VerificationFailed, got \(error)") + } + XCTAssertEqual(check, "recipients-match") + } + } + + func testErrorsCrossTheBoundaryAsTypedErrors() { + XCTAssertThrowsError(try ClientKeys.fromMnemonic(mnemonic: "abandon abandon abandon", network: .regtest)) { error in + guard case SdkError.InvalidMnemonic = error else { + return XCTFail("expected InvalidMnemonic, got \(error)") + } + } + XCTAssertThrowsError(try deriveTaprootAddress(accountXpub: "not-an-xpub", keychain: 0, index: 0, network: .regtest)) { error in + guard case SdkError.InvalidInput = error else { + return XCTFail("expected InvalidInput, got \(error)") + } + } + } + + func testInvoiceDecodersCrossTheBoundaryWithOptionalsListsAndEnums() throws { + let bolt11 = try decodeBolt11(invoice: Self.bolt11Coffee) + XCTAssertEqual(bolt11.network, .mainnet) + XCTAssertEqual(bolt11.amountMsat, 250_000_000) + XCTAssertEqual(bolt11.description, "1 cup coffee") + XCTAssertNil(bolt11.descriptionHash) + XCTAssertEqual(bolt11.expirySeconds, 60) + XCTAssertEqual(bolt11.paymentHash.count, 64) + XCTAssertEqual(bolt11.payeeNodeId.count, 66) + + let wr = try object("witnessReceive", in: fixture()) + let rgb = try decodeRgbInvoice(invoice: try string("invoice", in: wr)) + XCTAssertEqual(rgb.recipientId, try string("recipientId", in: wr)) + XCTAssertEqual(rgb.beneficiaryKind, .witness) + XCTAssertNil(rgb.assetId) + XCTAssertNil(rgb.amount) + XCTAssertEqual(rgb.chain, "bcrt") + XCTAssertEqual(rgb.transportEndpoints, ["rpc://localhost:3000/json-rpc"]) + XCTAssertEqual(rgb.expiryTimestamp, (wr["expirationTimestamp"] as? NSNumber).map { UInt64(truncating: $0) }) + + XCTAssertThrowsError(try decodeBolt11(invoice: "lnbc1pvjluez")) { error in + guard case SdkError.InvoiceDecode = error else { return XCTFail("expected InvoiceDecode, got \(error)") } + } + XCTAssertThrowsError(try decodeRgbInvoice(invoice: "not an invoice")) { error in + guard case SdkError.InvoiceDecode = error else { return XCTFail("expected InvoiceDecode, got \(error)") } + } + } + + func testIdempotencyKeysAreV4Uuids() throws { + let key = try generateIdempotencyKey() + XCTAssertNotNil( + key.range(of: #"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"#, options: .regularExpression), + key) + XCTAssertNotEqual(key, try generateIdempotencyKey(), "keys must be unique per call") + } + + // MARK: foreign transport + + /// A Swift-side transport: records the request Rust built and answers with a canned response. + private final class StubTransport: HttpTransport { + let status: UInt16 + let body: String + private(set) var requests: [HttpRequest] = [] + + init(status: UInt16, body: String) { + self.status = status + self.body = body + } + + func send(request: HttpRequest) throws -> HttpResponse { + requests.append(request) + return HttpResponse(status: status, body: body) + } + } + + /// A transport that throws instead of answering. + private final class ThrowingTransport: HttpTransport { + let error: Error + init(error: Error) { self.error = error } + func send(request: HttpRequest) throws -> HttpResponse { throw error } + } + + private struct HostError: Error, CustomStringConvertible { + let description: String + } + + private func client(_ transport: HttpTransport) -> GatewayClient { + GatewayClient(baseUrl: Self.gateway, token: Self.token, timeoutMs: nil, transport: transport) + } + + func testGatewayClientDrivesASwiftHttpTransport() throws { + let transport = StubTransport(status: 200, body: #"{"userId":"user-1","createdAt":1700000000}"#) + let me = try client(transport).me() + XCTAssertEqual(me.userId, "user-1") + XCTAssertEqual(me.createdAt, 1_700_000_000) + + XCTAssertEqual(transport.requests.count, 1) + let request = try XCTUnwrap(transport.requests.first) + XCTAssertEqual(request.method, .get) + XCTAssertEqual(request.url, "https://gateway.example/v1/me") + XCTAssertEqual(request.headers["authorization"], "Bearer secret-token") + XCTAssertNil(request.body) + XCTAssertGreaterThan(request.timeoutMs, 0) + } + + func testGatewayErrorsSurfaceStatusAndCodeFromTheTransport() { + let transport = StubTransport(status: 503, body: #"{"error":{"code":"MAINTENANCE","message":"back soon"}}"#) + XCTAssertThrowsError(try client(transport).me()) { error in + guard case SdkError.Gateway(let status, let code, _) = error else { + return XCTFail("expected Gateway, got \(error)") + } + XCTAssertEqual(status, 503) + XCTAssertEqual(code, "MAINTENANCE") + XCTAssertFalse(String(describing: error).contains("secret-token"), "bearer token leaked into the error") + XCTAssertFalse(error.localizedDescription.contains("secret-token"), "bearer token leaked into the error") + } + } + + func testTransportErrorsCrossBackIntoRustAsTypedTransportErrors() { + // The declared error type passes through unchanged. + XCTAssertThrowsError(try client(ThrowingTransport(error: SdkError.Transport(reason: "dns failed"))).me()) { error in + guard case SdkError.Transport(let reason) = error else { + return XCTFail("expected Transport, got \(error)") + } + XCTAssertEqual(reason, "dns failed") + } + // A host error that is not an SdkError must still surface as a typed + // Transport error, never as an internal (panic) error. + XCTAssertThrowsError(try client(ThrowingTransport(error: HostError(description: "pool closed"))).me()) { error in + guard case SdkError.Transport(let reason) = error else { + return XCTFail("expected Transport, got \(error)") + } + XCTAssertTrue(reason.contains("unexpected"), reason) + XCTAssertTrue(reason.contains("pool closed"), reason) + XCTAssertFalse(String(describing: error).contains(Self.token), "bearer token leaked into the error") + } + } + + func testPrepareSendBtcBindsTheIntentAndSurfacesNestedRecords() throws { + let transport = StubTransport(status: 200, body: Self.preparedSendBtc) + let op = try client(transport).prepareSendBtc( + params: PrepareSendBtcParams(address: Self.address, amountSat: 40_000, feeRateSatPerVb: nil), + idempotencyKey: nil) + XCTAssertEqual(op.opId, "op-1") + XCTAssertEqual(op.psbt, "cHNidP8=") + XCTAssertEqual(op.expiresAt, 1_700_000_000) + XCTAssertEqual(op.intent.kind, .sendBtc) + XCTAssertEqual(op.intent.feeRateSatPerVb, 2) + XCTAssertNil(op.intent.asset) + XCTAssertNil(op.intent.utxos) + XCTAssertEqual(op.intent.recipients, [IntentRecipient(address: Self.address, scriptHex: "0014deadbeef", amountSat: 40_000)]) + let request = try XCTUnwrap(transport.requests.first) + XCTAssertEqual(request.method, .post) + XCTAssertEqual(request.url, "\(Self.gateway)/v1/onchain/send-btc/prepare") + XCTAssertEqual(request.headers["content-type"], "application/json") + XCTAssertNotNil(request.headers["idempotency-key"]) + let body = try JSONSerialization.jsonObject(with: Data(try XCTUnwrap(request.body).utf8)) as? [String: Any] + XCTAssertEqual(body?["address"] as? String, Self.address) + + // The same gateway response does not match a request for another address. + XCTAssertThrowsError(try client(StubTransport(status: 200, body: Self.preparedSendBtc)).prepareSendBtc( + params: PrepareSendBtcParams(address: Self.attacker, amountSat: 40_000, feeRateSatPerVb: nil), + idempotencyKey: nil)) { error in + guard case SdkError.IntentMismatch(let reason) = error else { + return XCTFail("expected IntentMismatch, got \(error)") + } + XCTAssertTrue(reason.contains("recipient address"), reason) + } + } + + func testLnBalanceMapsCrossTheBoundary() throws { + let transport = StubTransport(status: 200, body: #"{"btcMsat":12345,"assets":{"rgb:a":7,"rgb:b":0}}"#) + let balance = try client(transport).getLnBalance() + XCTAssertEqual(balance.btcMsat, 12_345) + XCTAssertEqual(balance.assets, ["rgb:a": 7, "rgb:b": 0]) + } + + // MARK: URLSessionHttpTransport + + /// In-process stand-in for the network: records what URLSession was asked + /// to send and answers with the configured status and body, so the shipped + /// transport is exercised without a socket. + private final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var _status = 200 + private var _body = Data() + private var _hang = false + private var _request: URLRequest? + private var _requestBody: Data? + + func configure(status: Int, body: Data, hang: Bool = false) { + lock.lock(); defer { lock.unlock() } + _status = status; _body = body; _hang = hang; _request = nil; _requestBody = nil + } + var status: Int { lock.lock(); defer { lock.unlock() }; return _status } + var body: Data { lock.lock(); defer { lock.unlock() }; return _body } + var hang: Bool { lock.lock(); defer { lock.unlock() }; return _hang } + var request: URLRequest? { lock.lock(); defer { lock.unlock() }; return _request } + var requestBody: Data? { lock.lock(); defer { lock.unlock() }; return _requestBody } + func record(_ request: URLRequest, body: Data?) { + lock.lock(); defer { lock.unlock() } + _request = request; _requestBody = body + } + } + + private static let recorder = Recorder() + + private final class StubURLProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let recorder = SmokeTests.recorder + recorder.record(request, body: request.httpBody ?? request.httpBodyStream.map(Self.drain)) + if recorder.hang { return } + guard let url = request.url, + let response = HTTPURLResponse(url: url, statusCode: recorder.status, httpVersion: "HTTP/1.1", headerFields: [:]) + else { + client?.urlProtocol(self, didFailWithError: HostError(description: "stub could not build a response")) + return + } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: recorder.body) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + private static func drain(_ stream: InputStream) -> Data { + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while stream.hasBytesAvailable { + let read = stream.read(&buffer, maxLength: buffer.count) + if read <= 0 { break } + data.append(buffer, count: read) + } + return data + } + } + + private func stubbedSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubURLProtocol.self] + return URLSession(configuration: configuration) + } + + func testUrlSessionTransportRejectsAnInvalidUrlWithoutTouchingTheNetwork() { + let transport = URLSessionHttpTransport() + let request = HttpRequest(method: .get, url: "not a url", headers: [:], body: nil, timeoutMs: 1_000) + XCTAssertThrowsError(try transport.send(request: request)) { error in + guard case SdkError.Transport = error else { + return XCTFail("expected Transport, got \(error)") + } + } + } + + func testUrlSessionTransportForwardsTheRequestVerbatimAndReturnsAnyStatus() throws { + let recorder = Self.recorder + let transport = URLSessionHttpTransport(session: stubbedSession()) + + // A 5xx is returned as a response (status intact), which Rust maps to + // a Gateway error; it is not a Transport error. + recorder.configure(status: 503, body: Data(#"{"error":{"code":"MAINTENANCE","message":"back soon"}}"#.utf8)) + let gateway = GatewayClient(baseUrl: Self.gateway, token: Self.token, timeoutMs: 1_500, transport: transport) + XCTAssertThrowsError(try gateway.me()) { error in + guard case SdkError.Gateway(let status, let code, _) = error else { + return XCTFail("expected Gateway, got \(error)") + } + XCTAssertEqual(status, 503) + XCTAssertEqual(code, "MAINTENANCE") + } + let get = try XCTUnwrap(recorder.request) + XCTAssertEqual(get.httpMethod, "GET") + XCTAssertEqual(get.url?.absoluteString, "\(Self.gateway)/v1/me") + XCTAssertEqual(get.value(forHTTPHeaderField: "authorization"), "Bearer \(Self.token)") + XCTAssertEqual(get.timeoutInterval, 1.5, accuracy: 0.001) + + // A POST carries the JSON body and headers Rust built, byte for byte. + recorder.configure(status: 200, body: Data(Self.preparedSendBtc.utf8)) + let op = try gateway.prepareSendBtc( + params: PrepareSendBtcParams(address: Self.address, amountSat: 40_000, feeRateSatPerVb: nil), + idempotencyKey: "11111111-2222-4333-8444-555555555555") + XCTAssertEqual(op.opId, "op-1") + let post = try XCTUnwrap(recorder.request) + XCTAssertEqual(post.httpMethod, "POST") + XCTAssertEqual(post.value(forHTTPHeaderField: "content-type"), "application/json") + XCTAssertEqual(post.value(forHTTPHeaderField: "idempotency-key"), "11111111-2222-4333-8444-555555555555") + let sent = try JSONSerialization.jsonObject(with: try XCTUnwrap(recorder.requestBody)) as? [String: Any] + XCTAssertEqual(sent?["address"] as? String, Self.address) + XCTAssertEqual((sent?["amountSat"] as? NSNumber).map { UInt64(truncating: $0) }, 40_000) + + // A non-UTF-8 error page keeps its status instead of becoming a + // Transport error. + recorder.configure(status: 500, body: Data([0xff, 0xfe, 0x00, 0xc3])) + XCTAssertThrowsError(try gateway.me()) { error in + guard case SdkError.Gateway(let status, let code, _) = error else { + return XCTFail("expected Gateway, got \(error)") + } + XCTAssertEqual(status, 500) + XCTAssertEqual(code, "UNKNOWN") + } + } + + func testUrlSessionTransportBoundsTheTotalWaitByTimeoutMs() { + let recorder = Self.recorder + defer { recorder.configure(status: 200, body: Data()) } + recorder.configure(status: 200, body: Data(), hang: true) + let transport = URLSessionHttpTransport(session: stubbedSession()) + let request = HttpRequest(method: .get, url: "\(Self.gateway)/v1/me", headers: [:], body: nil, timeoutMs: 300) + let started = Date() + XCTAssertThrowsError(try transport.send(request: request)) { error in + guard case SdkError.Transport(let reason) = error else { + return XCTFail("expected Transport, got \(error)") + } + XCTAssertTrue(reason.contains("timed out"), reason) + } + XCTAssertLessThan(Date().timeIntervalSince(started), 5, "the caller must not be held past the budget") + } +} diff --git a/minimal-sdk/eslint.config.mjs b/minimal-sdk/eslint.config.mjs new file mode 100644 index 00000000..3c3584cf --- /dev/null +++ b/minimal-sdk/eslint.config.mjs @@ -0,0 +1,62 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import prettier from 'eslint-config-prettier'; + +export default tseslint.config( + { + ignores: [ + '**/node_modules/', + '**/dist/', + '**/*.d.ts', + '**/coverage/', + // Generated by `pnpm --filter @utexo/minimal-gateway generate:rln-types`. + 'packages/gateway/src/rln/openapi.ts', + // Build output of the native mobile SDK (rustdoc emits .js under target/, + // Gradle and SwiftPM emit reports). Gitignored, but flat config does not + // read .gitignore, so `pnpm lint` after a local build would scan them. + 'rust-sdk/target/', + 'android/build/', + 'android/.gradle/', + 'apple/.build/', + 'apple/build/', + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + prettier, + { + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + '@typescript-eslint/explicit-module-boundary-types': 'off', + }, + }, + { + files: ['scripts/**/*.cjs'], + languageOptions: { + sourceType: 'commonjs', + globals: { + require: 'readonly', + module: 'readonly', + console: 'readonly', + process: 'readonly', + __dirname: 'readonly', + }, + }, + rules: { + '@typescript-eslint/no-require-imports': 'off', + }, + }, + { + files: ['**/scripts/**/*.mjs'], + languageOptions: { + globals: { + console: 'readonly', + process: 'readonly', + URL: 'readonly', + }, + }, + }, +); diff --git a/minimal-sdk/package.json b/minimal-sdk/package.json new file mode 100644 index 00000000..45cee8cd --- /dev/null +++ b/minimal-sdk/package.json @@ -0,0 +1,33 @@ +{ + "name": "@utexo/minimal-sdk-workspace", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@10.34.5", + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "pnpm -r run build", + "test": "pnpm -r run test", + "e2e": "pnpm -r run build && pnpm --filter @utexo/minimal-e2e run e2e", + "lint": "eslint . && prettier --check .", + "format": "prettier --write ." + }, + "pnpm": { + "onlyBuiltDependencies": [ + "@utexo/rgb-lib-darwin-arm64", + "@utexo/rgb-lib-linux-arm64", + "@utexo/rgb-lib-linux-x64", + "better-sqlite3" + ] + }, + "devDependencies": { + "@eslint/js": "^9.39.0", + "eslint": "^9.39.0", + "eslint-config-prettier": "^10.1.0", + "prettier": "^3.6.0", + "typescript": "^5.9.0", + "typescript-eslint": "^8.46.0", + "vitest": "^3.2.0" + } +} diff --git a/minimal-sdk/packages/client-sdk/README.md b/minimal-sdk/packages/client-sdk/README.md new file mode 100644 index 00000000..cf298747 --- /dev/null +++ b/minimal-sdk/packages/client-sdk/README.md @@ -0,0 +1,111 @@ +# @utexo/minimal-client-sdk + +Browser/mobile TypeScript SDK for the minimal gateway. No wasm, no chain sync, no LN +state — just key management, verify-before-sign PSBT handling, invoice decoding, and a +typed gateway client. Built on audited primitives (`@scure/bip39`, `@scure/bip32`, +`@scure/btc-signer`); the production bundle is gated below 500KB by the test suite. + +**The mnemonic is the user's only backup.** All key material is generated and stays on +the client (invariant I1); the gateway only ever sees account xpubs and the master +fingerprint. The client is stateless between sessions — there is no server-side or +VSS-encrypted key backup, so if the user loses the mnemonic, the vault funds are +unrecoverable. Show it once at generation time and make the user store it. + +## Quickstart + +```ts +import { + ClientKeys, + GatewayClient, + generateIdempotencyKey, + verifyAndSignPsbt, +} from '@utexo/minimal-client-sdk'; + +// 1. Generate keys client-side. Display `mnemonic` for backup — it never +// leaves the device. Restore later with ClientKeys.fromMnemonic(...). +const { mnemonic, keys } = ClientKeys.generate('Mainnet'); + +// 2. Connect to the gateway. The bearer token comes from the operator-run +// user bootstrap (POST /v1/users) and is returned exactly once. +const client = new GatewayClient({ baseUrl: 'https://gateway.example.com', token }); + +// 3. Register the account xpubs; the gateway builds a per-user watch-only +// wallet from them and returns the first vault address. +const { address } = await client.registerXpubs({ + vanilla: keys.xpubs.vanilla, + colored: keys.xpubs.colored, + fingerprint: keys.xpubs.fingerprint, +}); + +// 4. Receive RGB assets: hand `invoice` to the payer, then watch +// client.getTransfers() / client.getBalances() for settlement. +const receive = await client.receive({ mode: 'blind' }); + +// 5. Send BTC — the server prepares, the CLIENT verifies and signs, the +// server broadcasts. verifyAndSignPsbt refuses to sign (throws +// VerificationFailedError) unless every check passes; there is no bypass. +const prepared = await client.prepareSendBtc({ + address: 'bc1p…', + amountSat: 50_000, + feeRateSatPerVb: 2, +}); +const { signedPsbt } = verifyAndSignPsbt(keys, prepared.psbt, { + intent: prepared.intent, // machine-readable intent from the prepare response + xpubs: keys.xpubs, + maxFeeSat: 1_000, // the user-approved fee budget +}); +const { txid } = await client.completeSendBtc({ opId: prepared.opId, signedPsbt }); + +// 6. Pay a BOLT11 invoice from the custodial LN float. Money-moving calls +// take an idempotency key: replaying the same key + body returns the +// cached response for any completed outcome and never double-pays. After a +// 5xx the response is deliberately not cached, and the gateway reports the +// recorded state (409 PAYMENT_ALREADY_ATTEMPTED / WITHDRAWAL_UNRESOLVED) +// instead of re-sending — it never auto-retries a send. +const paid = await client.payLnInvoice({ invoice: 'lnbc…' }, generateIdempotencyKey()); +``` + +Asset sends (`prepareSendAsset`/`completeSendAsset`) and colorable-UTXO creation +(`prepareCreateUtxos`/`completeCreateUtxos`) follow the same prepare → verify-and-sign → +complete shape. LN float deposits (`prepareLnDeposit`), invoices (`createLnInvoice`, +`getLnInvoice`), payment listing (`listLnPayments`), balances (`getLnBalance`) and +withdrawals (`withdrawLn`) round out the surface; `decodeBolt11`/`decodeRgbInvoice` +decode invoices locally for display before paying. + +## The verify-before-sign contract + +Signing is never blind. `verifyAndSignPsbt` runs `verifyPsbt` internally and refuses to +sign unless the verdict is clean; `verifyPsbt` can also be called on its own to show the +user what they are about to authorize. The five checks (from the design doc, +`docs/design/minimal-sdk-and-lightweight-rln.md`): + +1. **Every input is ours** — each input's script re-derives from the client's own + descriptors, and the PSBT's key-origin fingerprint/path matches the user's master + fingerprint. +2. **Recipient outputs match intent** — script and amount, checked against the intent + summary returned by the prepare call. That summary is server-produced, so on its own + it would prove nothing against a hostile gateway: `prepareSendBtc`, `prepareSendAsset` + and `prepareCreateUtxos` therefore assert it field-by-field against the request you + passed and raise `IntentMismatchError` on any divergence, before the intent ever + reaches `verifyPsbt`. Build the intent some other way and that binding is yours to + make — verify it against what the user approved. +3. **Change pays only us, at a recoverable path** — every non-recipient output re-derives + from the client's own vanilla or colored descriptor, at keychain 0 and index at most + `maxOwnOutputIndex` (default 10 000). The index bound matters: any index under the + account xpub is technically the user's, but only indexes a descriptor wallet actually + scans are recoverable, so a hostile gateway must not be able to park "change" at index + 10⁸. Outputs carrying no key-origin metadata are accepted only if they match a script + re-derived within the first `changeScanWindow` indexes (default 30; set it to 0 to + require metadata on every output). Both are `VerifyParams` knobs. +4. **Fee within budget** — inputs − outputs is at most the user-approved `maxFeeSat`. +5. **OP_RETURN carries 0 sats** — the RGB opret commitment output may exist, but it may + not carry value. + +`verifyPsbt` returns a structured verdict (per-check pass/fail with detail) and never +throws on adversarial input — a malformed PSBT is a failed verdict, not a crash. + +Known limitation (documented, not solved at this layer): the checks verify all +bitcoin-value movement, but the RGB opret commitment itself is opaque to the client, so +every server-prepared **colored** spend trusts the server for the RGB state transition at +send time. See "The custody boundary, honestly" in the gateway README for the full +statement and mitigations. diff --git a/minimal-sdk/packages/client-sdk/package.json b/minimal-sdk/packages/client-sdk/package.json new file mode 100644 index 00000000..ac528bfb --- /dev/null +++ b/minimal-sdk/packages/client-sdk/package.json @@ -0,0 +1,27 @@ +{ + "name": "@utexo/minimal-client-sdk", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "sideEffects": false, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "generate:fixtures": "node scripts/generate-rgblib-fixtures.mjs" + }, + "dependencies": { + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0", + "@scure/base": "^2.4.0", + "@scure/bip32": "^2.4.0", + "@scure/bip39": "^2.4.0", + "@scure/btc-signer": "^2.4.1" + }, + "devDependencies": { + "esbuild": "^0.28.0", + "typescript": "^5.9.0", + "vitest": "^3.2.0" + } +} diff --git a/minimal-sdk/packages/client-sdk/scripts/generate-rgblib-fixtures.mjs b/minimal-sdk/packages/client-sdk/scripts/generate-rgblib-fixtures.mjs new file mode 100644 index 00000000..1b113eea --- /dev/null +++ b/minimal-sdk/packages/client-sdk/scripts/generate-rgblib-fixtures.mjs @@ -0,0 +1,175 @@ +// Regenerates test/fixtures/rgblib-parity.json from the REAL rgb-lib native +// binding (successor of Task 1's preflight script; see scripts/preflight-a.cjs +// at the workspace root). Everything here runs OFFLINE — no regtest needed: +// wallet construction, address derivation and BDK signing are all local. +// +// The fixture is the ground truth the client SDK must reproduce exactly: +// - account xpubs + master fingerprint per network (restore_keys) +// - vanilla- and colored-keychain addresses at indexes 0..4 (get_address; +// colored addresses via a wallet whose *vanilla* side is the colored xpub — +// both keychains use derivation child 0, verified in rgb-lib src/utils.rs) +// - a PSBT hand-built with @scure, signed+finalized by rgb-lib's own BDK +// signer (sign_psbt), with its txid and witness +// +// Run (needs the gateway package installed for the native module): +// pnpm --filter @utexo/minimal-client-sdk generate:fixtures +import { createRequire } from 'node:module'; +import { rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const OUT = new URL('../test/fixtures/rgblib-parity.json', import.meta.url); +const gatewayRequire = createRequire(new URL('../../gateway/package.json', import.meta.url)); +const lib = gatewayRequire('@utexo/rgb-lib-linux-x64/rgblib'); + +const sdkRequire = createRequire(new URL('../package.json', import.meta.url)); +const { Transaction, p2tr } = await import(sdkRequire.resolve('@scure/btc-signer')); +const { base64, hex } = await import(sdkRequire.resolve('@scure/base')); +const { HDKey } = await import(sdkRequire.resolve('@scure/bip32')); +const { mnemonicToSeedSync } = await import(sdkRequire.resolve('@scure/bip39')); + +// TEST fixture only — same BIP-39 reference mnemonic the gateway integration +// suite uses. Mnemonics are never a gateway input (I1/I2). +const MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; +// Distinct mnemonic for the wallets' unused "other side" (BDK rejects +// identical external/internal descriptors) and for foreign-key test material. +const OTHER_MNEMONIC = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'; + +const BASE = `${HERE}/../.fixture-tmp`; +rmSync(BASE, { recursive: true, force: true }); + +const networks = {}; +for (const net of ['Regtest', 'Testnet', 'Signet', 'Mainnet']) { + const keys = JSON.parse(lib.rgblib_restore_keys(net, MNEMONIC)); + networks[net] = { + xpub: keys.xpub, + accountXpubVanilla: keys.accountXpubVanilla, + accountXpubColored: keys.accountXpubColored, + masterFingerprint: keys.masterFingerprint, + }; +} +const k = networks.Regtest; +const other = JSON.parse(lib.rgblib_restore_keys('Regtest', OTHER_MNEMONIC)); + +function newWallet(dir, vanillaXpub, coloredXpub, mnemonic) { + mkdirSync(dir, { recursive: true }); + const walletData = JSON.stringify({ + dataDir: dir, + bitcoinNetwork: 'Regtest', + databaseType: 'Sqlite', + maxAllocationsPerUtxo: '1', + accountXpubVanilla: vanillaXpub, + accountXpubColored: coloredXpub, + vanillaKeychain: null, + supportedSchemas: ['Nia', 'Cfa', 'Uda', 'Ifa'], + }); + const keysJson = JSON.stringify({ + accountXpubVanilla: vanillaXpub, + accountXpubColored: coloredXpub, + vanillaKeychain: null, + masterFingerprint: k.masterFingerprint, + mnemonic, + }); + return lib.rgblib_new_wallet(walletData, keysJson); +} + +const addresses = (wallet, count) => + Array.from({ length: count }, () => lib.rgblib_get_address(wallet)); + +const W = newWallet(`${BASE}/vanilla`, k.accountXpubVanilla, k.accountXpubColored, null); +const vanillaAddresses = addresses(W, 5); +const C = newWallet(`${BASE}/colored`, k.accountXpubColored, other.accountXpubColored, null); +const coloredAddresses = addresses(C, 5); + +// Signing fixture: spend vanilla idx0, pay a foreign recipient, change to +// vanilla idx1, zero-value OP_RETURN — the exact shape verify-before-sign +// accepts. rgb-lib (BDK) signs and finalizes it; the SDK must produce the +// same txid from the same unsigned PSBT. +const REGTEST = { bech32: 'bcrt', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }; +const master = HDKey.fromMasterSeed(mnemonicToSeedSync(MNEMONIC)); +const account = master.derive("m/86'/1'/0'"); +const HARD = 0x80000000; +const pathFor = (index) => [HARD + 86, HARD + 1, HARD, 0, index]; +const keyAt = (index) => account.deriveChild(0).deriveChild(index); +const spendAt = (index) => p2tr(keyAt(index).publicKey.slice(1), undefined, REGTEST); + +const otherMaster = HDKey.fromMasterSeed(mnemonicToSeedSync(OTHER_MNEMONIC)); +const foreignKey = otherMaster.derive("m/86'/1'/0'/0/0"); +const foreignSpend = p2tr(foreignKey.publicKey.slice(1), undefined, REGTEST); + +const tx = new Transaction({ allowUnknownOutputs: true }); +tx.addInput({ + txid: '5e2b3c1f8a9d4e6b7c0f1a2d3e4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b', + index: 1, + witnessUtxo: { script: spendAt(0).script, amount: 100_000n }, + tapInternalKey: keyAt(0).publicKey.slice(1), + tapBip32Derivation: [ + [ + keyAt(0).publicKey.slice(1), + { hashes: [], der: { fingerprint: master.fingerprint, path: pathFor(0) } }, + ], + ], +}); +tx.addOutput({ script: foreignSpend.script, amount: 40_000n }); +tx.addOutputAddress(spendAt(1).address, 59_000n, REGTEST); +tx.updateOutput(1, { + tapInternalKey: keyAt(1).publicKey.slice(1), + tapBip32Derivation: [ + [ + keyAt(1).publicKey.slice(1), + { hashes: [], der: { fingerprint: master.fingerprint, path: pathFor(1) } }, + ], + ], +}); +tx.addOutput({ script: new Uint8Array([0x6a, 0x05, 1, 2, 3, 4, 5]), amount: 0n }); +const unsignedPsbt = base64.encode(tx.toPSBT()); + +const S = newWallet(`${BASE}/signer`, k.accountXpubVanilla, k.accountXpubColored, MNEMONIC); +const signedPsbt = lib.rgblib_sign_psbt(S, unsignedPsbt); +const signedTx = Transaction.fromPSBT(base64.decode(signedPsbt), { + allowUnknownInputs: true, + allowUnknownOutputs: true, +}); +const witness = signedTx.getInput(0).finalScriptWitness; +if (witness === undefined || witness.length !== 1 || witness[0].length !== 64) { + throw new Error('rgb-lib did not key-path-sign the fixture PSBT'); +} + +// Real witness invoice produced OFFLINE by the watch-only wallet (blind +// invoices need colorable UTXOs, so those stay synthetic in the tests). +const witnessInvoice = JSON.parse( + lib.rgblib_witness_receive( + W, + null, + JSON.stringify('Any'), + '2000000000', + JSON.stringify(['rpc://localhost:3000/json-rpc']), + '1', + ), +); + +const fixture = { + generatedBy: 'scripts/generate-rgblib-fixtures.mjs (@utexo/rgb-lib 0.3.0-beta.18)', + mnemonic: MNEMONIC, + otherMnemonic: OTHER_MNEMONIC, + networks, + regtest: { vanillaAddresses, coloredAddresses }, + signing: { + unsignedPsbt, + signedPsbt, + txid: signedTx.id, + witnessSignature: hex.encode(witness[0]), + }, + witnessReceive: { + invoice: witnessInvoice.invoice, + recipientId: witnessInvoice.recipientId, + expirationTimestamp: witnessInvoice.expirationTimestamp, + }, +}; + +mkdirSync(new URL('../test/fixtures', import.meta.url), { recursive: true }); +writeFileSync(OUT, `${JSON.stringify(fixture, null, 2)}\n`); +rmSync(BASE, { recursive: true, force: true }); +console.log(`fixture written: txid ${signedTx.id}`); +console.log(`witness invoice: ${witnessInvoice.invoice}`); diff --git a/minimal-sdk/packages/client-sdk/src/derive.ts b/minimal-sdk/packages/client-sdk/src/derive.ts new file mode 100644 index 00000000..98966ff3 --- /dev/null +++ b/minimal-sdk/packages/client-sdk/src/derive.ts @@ -0,0 +1,117 @@ +/** + * Re-derivation of taproot `tr(key)` scripts/addresses from account xpubs at + * arbitrary keychain/index — the client-side half of change verification + * (verify-before-sign check 3) and input-ownership proofs (check 1). + */ +import { HDKey } from '@scure/bip32'; +import { hex } from '@scure/base'; +import { p2tr } from '@scure/btc-signer'; +import { + accountPath, + addressNetwork, + HARDENED, + hdVersions, + type BitcoinNetwork, +} from './network.js'; + +export interface DerivedScript { + keychain: number; + index: number; + /** BIP-341 tweaked output script (OP_1 <32-byte key>). */ + scriptHex: string; + address: string; + /** Untweaked x-only internal key, 32 bytes hex. */ + internalKeyHex: string; +} + +export class InvalidXpubError extends Error { + constructor(detail: string) { + super(`invalid account xpub: ${detail}`); + this.name = 'InvalidXpubError'; + } +} + +export function parseAccountXpub(xpub: string, network: BitcoinNetwork): HDKey { + try { + return HDKey.fromExtendedKey(xpub, hdVersions(network)); + } catch (error) { + throw new InvalidXpubError(error instanceof Error ? error.message : String(error)); + } +} + +function xOnly(publicKey: Uint8Array): Uint8Array { + return publicKey.slice(1); +} + +/** Derive the taproot script/address at `keychain/index` under an account xpub. */ +export function deriveTaproot( + accountXpub: string | HDKey, + keychain: number, + index: number, + network: BitcoinNetwork, +): DerivedScript { + const account = + typeof accountXpub === 'string' ? parseAccountXpub(accountXpub, network) : accountXpub; + const key = account.deriveChild(keychain).deriveChild(index); + if (key.publicKey === null) throw new InvalidXpubError('cannot derive public key'); + const internalKey = xOnly(key.publicKey); + const payment = p2tr(internalKey, undefined, addressNetwork(network)); + return { + keychain, + index, + scriptHex: hex.encode(payment.script), + address: payment.address as string, + internalKeyHex: hex.encode(internalKey), + }; +} + +export interface OriginPathMatch { + colored: boolean; + keychain: number; + index: number; +} + +/** + * Match a full master-based key-origin path ([86', coin', 0', keychain, + * index]) against the user's two accounts. Single source of truth for path + * acceptance: verify.ts proves scripts with it and sign.ts (via + * ClientKeys.privateKeyForPath) picks signing keys with it — the two must + * never drift, or verify could approve what sign will not use. Returns null + * (never throws) on foreign paths — paths are attacker-controlled PSBT input. + */ +export function matchOriginPath(path: number[], network: BitcoinNetwork): OriginPathMatch | null { + for (const colored of [false, true]) { + const prefix = accountPath(network, colored); + if (path.length !== prefix.length + 2) continue; + if (!prefix.every((child, i) => path[i] === child)) continue; + const keychain = path[prefix.length]; + const index = path[prefix.length + 1]; + if (keychain === undefined || index === undefined) continue; + if (keychain >= HARDENED || index >= HARDENED) continue; + return { colored, keychain, index }; + } + return null; +} + +/** + * Re-derive the taproot script for a full master-based key-origin path + * against the matching account xpub; null when the path is not ours. + */ +export function deriveForOriginPath( + path: number[], + xpubs: { vanilla: string | HDKey; colored: string | HDKey }, + network: BitcoinNetwork, +): DerivedScript | null { + const match = matchOriginPath(path, network); + if (match === null) return null; + try { + return deriveTaproot( + match.colored ? xpubs.colored : xpubs.vanilla, + match.keychain, + match.index, + network, + ); + } catch { + return null; + } +} diff --git a/minimal-sdk/packages/client-sdk/src/gateway.ts b/minimal-sdk/packages/client-sdk/src/gateway.ts new file mode 100644 index 00000000..fa6f5658 --- /dev/null +++ b/minimal-sdk/packages/client-sdk/src/gateway.ts @@ -0,0 +1,566 @@ +/** + * Thin typed client for the minimal gateway API. Browser/mobile-friendly: + * global fetch + AbortController + crypto only, no Node imports. + * + * Money-moving calls carry an Idempotency-Key; one is generated per logical + * operation unless the caller pins its own (retry the SAME operation with the + * SAME key to get the cached response instead of a duplicate spend). + */ +import type { OnchainIntent } from './verify.js'; + +export interface GatewayClientOptions { + baseUrl: string; + /** Per-user bearer token; omitted only for createUser (operator bootstrap). */ + token?: string; + timeoutMs?: number; + /** Injectable for tests; defaults to globalThis.fetch. */ + fetchFn?: typeof fetch; +} + +export class GatewayError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + this.name = 'GatewayError'; + } +} + +export interface CreatedUser { + userId: string; + token: string; + createdAt: number; +} + +export interface RegisterXpubsParams { + vanilla: string; + colored: string; + fingerprint: string; +} + +export interface Balance { + settled: number; + future: number; + spendable: number; +} + +export interface WalletBalances { + btc: { vanilla: Balance; colored: Balance }; + assets: { + assetId: string; + schema: string; + ticker: string | null; + name: string; + precision: number; + balance: Balance; + }[]; +} + +export interface WalletUnspent { + txid: string; + vout: number; + amountSat: number; + colorable: boolean; + allocations: { assetId: string | null; amount: number | null; settled: boolean }[]; +} + +export interface WalletTransfer { + idx: number; + assetId: string | null; + amount: number | null; + kind: string; + status: string; + txid: string | null; + recipientId: string | null; + expiration: number | null; + createdAt: number; + updatedAt: number; +} + +export interface ReceiveParams { + mode: 'blind' | 'witness'; + assetId?: string; + amount?: number; + durationSeconds?: number; + minConfirmations?: number; +} + +export interface ReceiveResult { + invoice: string; + recipientId: string; + expirationTimestamp: number | null; + mode: 'blind' | 'witness'; +} + +export interface PreparedOp { + opId: string; + psbt: string; + intent: OnchainIntent; + expiresAt: number; +} + +export interface PrepareSendBtcParams { + address: string; + amountSat: number; + feeRateSatPerVb?: number; +} + +export interface PrepareSendAssetParams { + assetId: string; + amount: number; + recipientId: string; + witnessAmountSat?: number; + transportEndpoints?: string[]; + donation?: boolean; + minConfirmations?: number; + feeRateSatPerVb?: number; +} + +export interface PrepareCreateUtxosParams { + num?: number; + size?: number; + upTo?: boolean; + feeRateSatPerVb?: number; +} + +export interface CompleteParams { + opId: string; + signedPsbt: string; +} + +/** A prepare response whose intent summary contradicts the request that produced it. */ +export class IntentMismatchError extends Error { + constructor(readonly mismatches: string[]) { + super(`gateway intent does not match the request: ${mismatches.join('; ')}`); + this.name = 'IntentMismatchError'; + } +} + +/** + * Bind the gateway's intent summary to the caller's OWN request. + * + * verify.ts check 2 matches the PSBT's outputs against `intent`. If that + * intent is simply whatever the server chose to return, the check is vacuous + * against the threat verify-before-sign exists for: a hostile gateway pairs an + * attacker script in the PSBT with the same attacker address in the intent and + * both "match", while checks 1/3/4/5 stay clean (the output is accounted, so + * change-own skips it). The design doc calls for the *user's* stated intent + * (docs/design/minimal-sdk-and-lightweight-rln.md, check 2), so every field the + * caller actually stated is asserted here before the intent is handed on. + * + * Fields the caller left to the server (fee rate, transport endpoints, utxo + * shape) are asserted only when the caller pinned them: they carry no bitcoin + * value for check 2, and the absolute fee stays bounded by `maxFeeSat`. The + * shape assertions (no recipients on an asset/utxo intent, no asset on a + * send-btc intent) are not cosmetic — a foreign `asset.witnessAmountSat` or an + * extra recipient is exactly how a server would smuggle an unaccounted output + * past check 3. + */ +function checkedIntent( + prepared: PreparedOp, + kind: OnchainIntent['kind'], + compare: (intent: OnchainIntent, mismatch: (detail: string) => void) => void, +): PreparedOp { + const mismatches: string[] = []; + const intent = prepared.intent as OnchainIntent | undefined; + if (intent === undefined || intent === null || typeof intent !== 'object') { + throw new IntentMismatchError(['prepare response carries no intent summary']); + } + if (intent.kind !== kind) mismatches.push(`kind ${String(intent.kind)} != ${kind}`); + if (!Array.isArray(intent.recipients)) mismatches.push('intent.recipients is not an array'); + else compare(intent, (detail) => mismatches.push(detail)); + if (mismatches.length > 0) throw new IntentMismatchError(mismatches); + return prepared; +} + +function checkPinned(name: string, requested: T | undefined, returned: T): string | null { + if (requested === undefined || requested === returned) return null; + return `${name} ${String(returned)} != ${String(requested)}`; +} + +export interface LnDepositPrepareParams { + kind: 'btc' | 'rgb'; + /** Declared BTC deposit amount (cap check), required for kind 'btc'. */ + amountMsat?: number; + assetId?: string; + amount?: number; +} + +export interface LnDepositPrepareResult { + depositId: string; + kind: 'btc' | 'rgb'; + /** Node-owned BTC target address (kind 'btc'). */ + address: string | null; + /** RGB invoice to pay into the node (kind 'rgb'). */ + invoice: string | null; + recipientId: string | null; +} + +export interface LnPayParams { + invoice: string; + /** Required when the invoice carries no amount. */ + amtMsat?: number; + assetAmount?: number; +} + +export type LnPaymentStatus = 'pending' | 'succeeded' | 'failed'; + +export interface LnPayResult { + paymentHash: string; + status: LnPaymentStatus; +} + +export interface LnInvoiceCreateParams { + amtMsat?: number; + expirySec?: number; + assetId?: string; + assetAmount?: number; + description?: string; +} + +export interface LnInvoiceInfo { + paymentHash: string; + invoice: string; + state: 'pending' | 'settled' | 'expired'; + amtMsat: number | null; + assetId: string | null; + assetAmount: number | null; + createdAt: number; +} + +export interface LnPaymentEntry { + paymentHash: string; + direction: 'inbound' | 'outbound'; + status: LnPaymentStatus; + amtMsat: number | null; + assetId: string | null; + assetAmount: number | null; + createdAt: number; + updatedAt: number; +} + +export interface LnBalance { + btcMsat: number; + assets: Record; +} + +export interface LnWithdrawParams { + kind: 'btc' | 'rgb'; + address?: string; + amountSat?: number; + assetId?: string; + amount?: number; + recipientId?: string; + /** Required when recipientId is a witness (`:wvout:…`) beneficiary. */ + witnessAmountSat?: number; + transportEndpoints?: string[]; + feeRateSatPerVb?: number; +} + +export interface LnWithdrawResult { + withdrawalId: string; + txid: string; +} + +/** RFC-4122 v4 id from crypto.getRandomValues; used for idempotency keys. */ +export function generateIdempotencyKey(): string { + const cryptoObj = globalThis.crypto; + if (typeof cryptoObj.randomUUID === 'function') return cryptoObj.randomUUID(); + const bytes = cryptoObj.getRandomValues(new Uint8Array(16)); + bytes[6] = ((bytes[6] as number) & 0x0f) | 0x40; + bytes[8] = ((bytes[8] as number) & 0x3f) | 0x80; + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +const DEFAULT_TIMEOUT_MS = 30_000; + +interface RequestOptions { + method: 'GET' | 'POST'; + path: string; + body?: unknown; + headers?: Record; + auth?: boolean; +} + +export class GatewayClient { + private readonly baseUrl: string; + private readonly token: string | undefined; + private readonly timeoutMs: number; + private readonly fetchFn: typeof fetch; + + constructor(options: GatewayClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ''); + this.token = options.token; + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis); + } + + private async request(options: RequestOptions): Promise { + const headers: Record = { ...options.headers }; + if (options.auth !== false) { + if (this.token === undefined) { + throw new GatewayError(0, 'NO_TOKEN', 'gateway client has no bearer token configured'); + } + headers['authorization'] = `Bearer ${this.token}`; + } + if (options.body !== undefined) headers['content-type'] = 'application/json'; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + // The timer stays armed until the body is read: a stalled body must not + // hang the caller past the configured timeout. + let response: Response; + let payload: unknown = null; + try { + try { + response = await this.fetchFn(`${this.baseUrl}${options.path}`, { + method: options.method, + headers, + body: options.body === undefined ? null : JSON.stringify(options.body), + signal: controller.signal, + }); + } catch (error) { + if (controller.signal.aborted) { + throw new GatewayError(0, 'TIMEOUT', `request timed out after ${this.timeoutMs}ms`); + } + throw new GatewayError( + 0, + 'NETWORK_ERROR', + error instanceof Error ? error.message : String(error), + ); + } + try { + payload = await response.json(); + } catch { + if (controller.signal.aborted) { + throw new GatewayError(0, 'TIMEOUT', `request timed out after ${this.timeoutMs}ms`); + } + payload = null; + } + } finally { + clearTimeout(timer); + } + if (!response.ok) { + const errorBody = payload as { error?: { code?: string; message?: string } } | null; + throw new GatewayError( + response.status, + errorBody?.error?.code ?? 'UNKNOWN', + errorBody?.error?.message ?? `gateway returned ${response.status}`, + ); + } + return payload as T; + } + + private post(path: string, body: unknown, idempotencyKey?: string): Promise { + return this.request({ + method: 'POST', + path, + body, + headers: { 'idempotency-key': idempotencyKey ?? generateIdempotencyKey() }, + }); + } + + /** Bootstrap-only; requires the operator token, not a user token. */ + createUser(operatorToken: string): Promise { + return this.request({ + method: 'POST', + path: '/v1/users', + auth: false, + headers: { 'x-operator-token': operatorToken }, + }); + } + + me(): Promise<{ userId: string; createdAt: number }> { + return this.request({ method: 'GET', path: '/v1/me' }); + } + + registerXpubs(params: RegisterXpubsParams): Promise<{ fingerprint: string; address: string }> { + return this.request({ method: 'POST', path: '/v1/wallet/xpubs', body: params }); + } + + getAddress(): Promise<{ address: string }> { + return this.request({ method: 'GET', path: '/v1/wallet/address' }); + } + + getBalances(): Promise { + return this.request({ method: 'GET', path: '/v1/wallet/balances' }); + } + + getUnspents(): Promise<{ unspents: WalletUnspent[] }> { + return this.request({ method: 'GET', path: '/v1/wallet/unspents' }); + } + + getTransfers(assetId?: string): Promise<{ transfers: WalletTransfer[] }> { + const query = assetId === undefined ? '' : `?assetId=${encodeURIComponent(assetId)}`; + return this.request({ method: 'GET', path: `/v1/wallet/transfers${query}` }); + } + + receive(params: ReceiveParams): Promise { + return this.request({ method: 'POST', path: '/v1/wallet/receive', body: params }); + } + + sync(): Promise<{ status: 'ok' }> { + return this.request({ method: 'POST', path: '/v1/wallet/sync' }); + } + + /** Throws IntentMismatchError if the returned intent contradicts `params`. */ + async prepareSendBtc(params: PrepareSendBtcParams, idempotencyKey?: string): Promise { + const prepared = await this.post( + '/v1/onchain/send-btc/prepare', + params, + idempotencyKey, + ); + return checkedIntent(prepared, 'send_btc', (intent, mismatch) => { + const only = intent.recipients.length === 1 ? intent.recipients[0] : undefined; + if (only === undefined) { + mismatch(`expected exactly 1 recipient, got ${intent.recipients.length}`); + } else { + if (only.address !== params.address) { + mismatch(`recipient address ${only.address} != ${params.address}`); + } + if (only.amountSat !== params.amountSat) { + mismatch(`recipient amountSat ${only.amountSat} != ${params.amountSat}`); + } + } + if (intent.asset !== null) mismatch('send-btc intent carries an asset'); + if (intent.utxos !== null) mismatch('send-btc intent carries a utxo shape'); + const fee = checkPinned('feeRateSatPerVb', params.feeRateSatPerVb, intent.feeRateSatPerVb); + if (fee !== null) mismatch(fee); + }); + } + + completeSendBtc(params: CompleteParams, idempotencyKey?: string): Promise<{ txid: string }> { + return this.post('/v1/onchain/send-btc/complete', params, idempotencyKey); + } + + /** Throws IntentMismatchError if the returned intent contradicts `params`. */ + async prepareSendAsset( + params: PrepareSendAssetParams, + idempotencyKey?: string, + ): Promise { + const prepared = await this.post( + '/v1/onchain/send-asset/prepare', + params, + idempotencyKey, + ); + return checkedIntent(prepared, 'send_asset', (intent, mismatch) => { + if (intent.recipients.length !== 0) { + mismatch(`send-asset intent carries ${intent.recipients.length} bitcoin recipient(s)`); + } + if (intent.utxos !== null) mismatch('send-asset intent carries a utxo shape'); + const asset = intent.asset; + if (asset === null || typeof asset !== 'object') { + mismatch('send-asset intent carries no asset'); + return; + } + if (asset.assetId !== params.assetId) + mismatch(`assetId ${asset.assetId} != ${params.assetId}`); + if (asset.amount !== params.amount) mismatch(`amount ${asset.amount} != ${params.amount}`); + if (asset.recipientId !== params.recipientId) { + mismatch(`recipientId ${asset.recipientId} != ${params.recipientId}`); + } + // Always asserted, including the blind case: a witness amount the caller + // never asked for lets check 2 account for one foreign output of that + // size, which check 3 would otherwise have refused. + const witness = params.witnessAmountSat ?? null; + if (asset.witnessAmountSat !== witness) { + mismatch(`witnessAmountSat ${String(asset.witnessAmountSat)} != ${String(witness)}`); + } + const endpoints = params.transportEndpoints; + if ( + endpoints !== undefined && + (asset.transportEndpoints.length !== endpoints.length || + !endpoints.every((endpoint, i) => asset.transportEndpoints[i] === endpoint)) + ) { + mismatch( + `transportEndpoints ${asset.transportEndpoints.join(',')} != ${endpoints.join(',')}`, + ); + } + const fee = checkPinned('feeRateSatPerVb', params.feeRateSatPerVb, intent.feeRateSatPerVb); + if (fee !== null) mismatch(fee); + }); + } + + completeSendAsset(params: CompleteParams, idempotencyKey?: string): Promise<{ txid: string }> { + return this.post('/v1/onchain/send-asset/complete', params, idempotencyKey); + } + + /** Throws IntentMismatchError if the returned intent contradicts `params`. */ + async prepareCreateUtxos( + params: PrepareCreateUtxosParams, + idempotencyKey?: string, + ): Promise { + const prepared = await this.post( + '/v1/onchain/create-utxos/prepare', + params, + idempotencyKey, + ); + return checkedIntent(prepared, 'create_utxos', (intent, mismatch) => { + if (intent.recipients.length !== 0) { + mismatch(`create-utxos intent carries ${intent.recipients.length} recipient(s)`); + } + if (intent.asset !== null) mismatch('create-utxos intent carries an asset'); + const utxos = intent.utxos; + if (utxos === null || typeof utxos !== 'object') { + mismatch('create-utxos intent carries no utxo shape'); + return; + } + for (const detail of [ + checkPinned('num', params.num, utxos.num), + checkPinned('size', params.size, utxos.size), + checkPinned('upTo', params.upTo, utxos.upTo), + checkPinned('feeRateSatPerVb', params.feeRateSatPerVb, intent.feeRateSatPerVb), + ]) { + if (detail !== null) mismatch(detail); + } + }); + } + + completeCreateUtxos( + params: CompleteParams, + idempotencyKey?: string, + ): Promise<{ txid: string | null; utxosCreated: number }> { + return this.post('/v1/onchain/create-utxos/complete', params, idempotencyKey); + } + + /** Pin the idempotency key to retry a prepare without minting a second intent. */ + prepareLnDeposit( + params: LnDepositPrepareParams, + idempotencyKey?: string, + ): Promise { + return this.post('/v1/ln/deposit/prepare', params, idempotencyKey); + } + + /** Money-moving: pin the idempotency key to retry the SAME payment safely. */ + payLnInvoice(params: LnPayParams, idempotencyKey?: string): Promise { + return this.post('/v1/ln/pay', params, idempotencyKey); + } + + createLnInvoice( + params: LnInvoiceCreateParams, + ): Promise<{ invoice: string; paymentHash: string }> { + return this.request({ method: 'POST', path: '/v1/ln/invoice', body: params }); + } + + getLnInvoice(paymentHash: string): Promise { + return this.request({ + method: 'GET', + path: `/v1/ln/invoice/${encodeURIComponent(paymentHash)}`, + }); + } + + listLnPayments(): Promise<{ payments: LnPaymentEntry[] }> { + return this.request({ method: 'GET', path: '/v1/ln/payments' }); + } + + getLnBalance(): Promise { + return this.request({ method: 'GET', path: '/v1/ln/balance' }); + } + + withdrawLn(params: LnWithdrawParams, idempotencyKey?: string): Promise { + return this.post('/v1/ln/withdraw', params, idempotencyKey); + } +} diff --git a/minimal-sdk/packages/client-sdk/src/index.ts b/minimal-sdk/packages/client-sdk/src/index.ts new file mode 100644 index 00000000..dd05bbae --- /dev/null +++ b/minimal-sdk/packages/client-sdk/src/index.ts @@ -0,0 +1,81 @@ +/** + * @utexo/minimal-client-sdk — browser/mobile SDK: key management and + * verify-before-sign PSBT handling. No wasm; < 500KB bundle budget. + * + * The mnemonic is the user's only backup and never leaves the client (I1); + * the gateway sees only account xpubs and the master fingerprint (I2). + */ +export const SDK_NAME = '@utexo/minimal-client-sdk'; + +export { + accountPath, + addressNetwork, + coinType, + hdVersions, + type BitcoinNetwork, +} from './network.js'; +export { ClientKeys, InvalidMnemonicError, fingerprintHex, type AccountXpubs } from './keys.js'; +export { + deriveForOriginPath, + deriveTaproot, + parseAccountXpub, + InvalidXpubError, + type DerivedScript, +} from './derive.js'; +export { + verifyPsbt, + PSBT_PARSE_OPTIONS, + type CheckName, + type CheckResult, + type IntentAsset, + type IntentRecipient, + type IntentUtxos, + type OnchainIntent, + type VerifyParams, + type VerifyVerdict, +} from './verify.js'; +export { + verifyAndSignPsbt, + SigningError, + VerificationFailedError, + type SignParams, + type SignResult, +} from './sign.js'; +export { + decodeBolt11, + decodeRgbInvoice, + InvoiceDecodeError, + type Bolt11Invoice, + type RgbInvoice, +} from './invoice.js'; +export { + GatewayClient, + GatewayError, + generateIdempotencyKey, + IntentMismatchError, + type Balance, + type CompleteParams, + type CreatedUser, + type GatewayClientOptions, + type LnBalance, + type LnDepositPrepareParams, + type LnDepositPrepareResult, + type LnInvoiceCreateParams, + type LnInvoiceInfo, + type LnPayParams, + type LnPaymentEntry, + type LnPaymentStatus, + type LnPayResult, + type LnWithdrawParams, + type LnWithdrawResult, + type PreparedOp, + type PrepareCreateUtxosParams, + type PrepareSendAssetParams, + type PrepareSendBtcParams, + type ReceiveParams, + type ReceiveResult, + type RegisterXpubsParams, + type WalletBalances, + type WalletTransfer, + type WalletUnspent, +} from './gateway.js'; diff --git a/minimal-sdk/packages/client-sdk/src/invoice.ts b/minimal-sdk/packages/client-sdk/src/invoice.ts new file mode 100644 index 00000000..fcfe1536 --- /dev/null +++ b/minimal-sdk/packages/client-sdk/src/invoice.ts @@ -0,0 +1,316 @@ +/** + * Invoice decoding for intent construction: + * - BOLT11 (payee, amount, payment hash, description, expiry) — a minimal + * decoder over @scure/base bech32 + @noble secp256k1 signature recovery; + * - RGB invoices in the rgb-invoicing v0.11 grammar + * `rgb:///?expiry=&endpoints=` + * (asset id, amount, beneficiary kind, transports). In this grammar a + * witness beneficiary is a `wvout:` recipient id, not a plain address; + * full RGB validation is explicitly out of scope (design doc). + */ +import { bech32, hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { secp256k1 } from '@noble/curves/secp256k1.js'; +import type { BitcoinNetwork } from './network.js'; + +export class InvoiceDecodeError extends Error { + constructor(detail: string) { + super(`invalid invoice: ${detail}`); + this.name = 'InvoiceDecodeError'; + } +} + +// --------------------------------------------------------------------------- +// BOLT11 +// --------------------------------------------------------------------------- + +export interface Bolt11Invoice { + network: BitcoinNetwork; + /** Invoice amount in millisatoshi; null for any-amount invoices. */ + amountMsat: bigint | null; + /** 32-byte payment hash, lowercase hex. */ + paymentHash: string; + /** 32-byte payment secret, lowercase hex (null on legacy invoices). */ + paymentSecret: string | null; + /** Short description, or null when only a description hash is present. */ + description: string | null; + descriptionHash: string | null; + /** Payee node id (33-byte compressed pubkey hex), recovered or from `n`. */ + payeeNodeId: string; + /** Invoice creation time, unix seconds. */ + timestamp: number; + /** Seconds until expiry (BOLT11 default 3600). */ + expirySeconds: number; +} + +const HRP_NETWORKS: [string, BitcoinNetwork][] = [ + ['bcrt', 'Regtest'], + ['tbs', 'Signet'], + ['bc', 'Mainnet'], + ['tb', 'Testnet'], +]; + +const MSAT_PER_BTC = 100_000_000_000n; +const MULTIPLIERS: Record = { + m: MSAT_PER_BTC / 1_000n, + u: MSAT_PER_BTC / 1_000_000n, + n: MSAT_PER_BTC / 1_000_000_000n, +}; + +function parseHrp(hrp: string): { network: BitcoinNetwork; amountMsat: bigint | null } { + if (!hrp.startsWith('ln')) throw new InvoiceDecodeError(`not a BOLT11 HRP: ${hrp}`); + const rest = hrp.slice(2); + const entry = HRP_NETWORKS.find(([prefix]) => rest.startsWith(prefix)); + if (entry === undefined) throw new InvoiceDecodeError(`unknown network prefix: ${rest}`); + const [prefix, network] = entry; + const amountPart = rest.slice(prefix.length); + if (amountPart === '') return { network, amountMsat: null }; + const match = /^(\d+)([munp])?$/.exec(amountPart); + if (match === null) throw new InvoiceDecodeError(`malformed amount: ${amountPart}`); + const value = BigInt(match[1] as string); + const unit = match[2]; + if (unit === undefined) return { network, amountMsat: value * MSAT_PER_BTC }; + if (unit === 'p') { + if (value % 10n !== 0n) throw new InvoiceDecodeError('sub-millisatoshi pico amount'); + return { network, amountMsat: value / 10n }; + } + return { network, amountMsat: value * (MULTIPLIERS[unit] as bigint) }; +} + +/** Pack 5-bit words into bytes MSB-first, zero-padding the tail (BIP-173). */ +function wordsToBytesPadded(words: number[]): Uint8Array { + const bytes: number[] = []; + let acc = 0; + let bits = 0; + for (const word of words) { + acc = (acc << 5) | word; + bits += 5; + while (bits >= 8) { + bits -= 8; + bytes.push((acc >> bits) & 0xff); + } + } + if (bits > 0) bytes.push((acc << (8 - bits)) & 0xff); + return Uint8Array.from(bytes); +} + +/** Pack 5-bit words into bytes, DROPPING trailing pad bits (tagged fields). */ +function wordsToBytesTrimmed(words: number[]): Uint8Array { + const full = wordsToBytesPadded(words); + return full.slice(0, Math.floor((words.length * 5) / 8)); +} + +function wordsToInt(words: number[]): number { + return words.reduce((acc, word) => acc * 32 + word, 0); +} + +export function decodeBolt11(invoice: string): Bolt11Invoice { + let decoded: { prefix: string; words: number[] }; + try { + decoded = bech32.decode(invoice.toLowerCase() as `${string}1${string}`, false); + } catch (error) { + throw new InvoiceDecodeError( + `bech32 decode failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const { network, amountMsat } = parseHrp(decoded.prefix); + if (decoded.words.length < 104 + 7) throw new InvoiceDecodeError('data part too short'); + const signatureWords = decoded.words.slice(-104); + const dataWords = decoded.words.slice(0, -104); + const timestamp = wordsToInt(dataWords.slice(0, 7)); + + let paymentHash: string | null = null; + let paymentSecret: string | null = null; + let description: string | null = null; + let descriptionHash: string | null = null; + let payeeFromTag: string | null = null; + let expirySeconds = 3600; + + let cursor = 7; + while (cursor < dataWords.length) { + const type = dataWords[cursor] as number; + const high = dataWords[cursor + 1]; + const low = dataWords[cursor + 2]; + if (high === undefined || low === undefined) throw new InvoiceDecodeError('truncated tag'); + const length = high * 32 + low; + const data = dataWords.slice(cursor + 3, cursor + 3 + length); + if (data.length !== length) throw new InvoiceDecodeError('truncated tag data'); + cursor += 3 + length; + switch (type) { + case 1: // p — payment hash + if (length === 52 && paymentHash === null) + paymentHash = hex.encode(wordsToBytesTrimmed(data)); + break; + case 16: // s — payment secret + if (length === 52 && paymentSecret === null) + paymentSecret = hex.encode(wordsToBytesTrimmed(data)); + break; + case 13: // d — description + description = new TextDecoder().decode(wordsToBytesTrimmed(data)); + break; + case 23: // h — description hash + if (length === 52) descriptionHash = hex.encode(wordsToBytesTrimmed(data)); + break; + case 19: // n — payee node id + if (length === 53) payeeFromTag = hex.encode(wordsToBytesTrimmed(data)); + break; + case 6: // x — expiry + expirySeconds = wordsToInt(data); + break; + default: + break; // features, route hints, fallback, cltv — not needed for intent + } + } + if (paymentHash === null) throw new InvoiceDecodeError('missing payment hash'); + + const signature = wordsToBytesTrimmed(signatureWords); + if (signature.length !== 65) throw new InvoiceDecodeError('malformed signature'); + const recoveryId = signature[64] as number; + if (recoveryId > 3) throw new InvoiceDecodeError(`invalid recovery id ${recoveryId}`); + const hrpBytes = new TextEncoder().encode(decoded.prefix); + const message = new Uint8Array(hrpBytes.length + Math.ceil((dataWords.length * 5) / 8)); + message.set(hrpBytes, 0); + message.set(wordsToBytesPadded(dataWords), hrpBytes.length); + const digest = sha256(message); + + let payeeNodeId: string; + try { + if (payeeFromTag !== null) { + const valid = secp256k1.verify(signature.slice(0, 64), digest, hex.decode(payeeFromTag), { + prehash: false, + }); + if (!valid) throw new InvoiceDecodeError('signature does not match payee node id'); + payeeNodeId = payeeFromTag; + } else { + const recovered = secp256k1.Signature.fromBytes(signature.slice(0, 64), 'compact') + .addRecoveryBit(recoveryId) + .recoverPublicKey(digest); + payeeNodeId = hex.encode(recovered.toBytes(true)); + } + } catch (error) { + if (error instanceof InvoiceDecodeError) throw error; + throw new InvoiceDecodeError( + `signature recovery failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + return { + network, + amountMsat, + paymentHash, + paymentSecret, + description, + descriptionHash, + payeeNodeId, + timestamp, + expirySeconds, + }; +} + +// --------------------------------------------------------------------------- +// RGB invoices +// --------------------------------------------------------------------------- + +export interface RgbInvoice { + /** Full asset id (`rgb:…`), or null for asset-agnostic invoices. */ + assetId: string | null; + /** Schema id segment, or null when omitted. */ + schema: string | null; + /** Fungible amount, or null (omitted / non-fungible state). */ + amount: bigint | null; + /** Raw assignment-state segment when present and non-numeric. */ + assignmentRaw: string | null; + /** + * Chain-qualified beneficiary — exactly the `recipientId` the gateway's + * send-asset prepare expects (e.g. `bcrt:utxob:…` or `bcrt:wvout:…`). + */ + recipientId: string; + /** blind = utxob seal on an existing UTXO; witness = wvout, paid by a new output. */ + beneficiaryKind: 'blind' | 'witness' | 'unknown'; + /** Chain prefix of the beneficiary (bc, tb, bcrt, …). */ + chain: string | null; + /** Consignment transport endpoints from the `endpoints` query param. */ + transportEndpoints: string[]; + /** Unix-seconds expiry from the `expiry` query param. */ + expiryTimestamp: number | null; + assignmentName: string | null; +} + +const OMITTED = '~'; + +function percentDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +export function decodeRgbInvoice(invoice: string): RgbInvoice { + if (!invoice.startsWith('rgb:')) throw new InvoiceDecodeError('missing rgb: scheme'); + const body = invoice.slice('rgb:'.length); + const queryIndex = body.indexOf('?'); + const path = queryIndex === -1 ? body : body.slice(0, queryIndex); + const query = queryIndex === -1 ? '' : body.slice(queryIndex + 1); + + const segments = path.split('/'); + if (segments.length !== 4) { + throw new InvoiceDecodeError( + `expected contract/schema/state/beneficiary, got ${segments.length} segment(s)`, + ); + } + const [contractSeg, schemaSeg, stateSeg, beneficiarySeg] = segments as [ + string, + string, + string, + string, + ]; + if (beneficiarySeg === '') throw new InvoiceDecodeError('empty beneficiary'); + + const assetId = contractSeg === OMITTED ? null : `rgb:${contractSeg}`; + const schema = schemaSeg === OMITTED ? null : schemaSeg; + let amount: bigint | null = null; + let assignmentRaw: string | null = null; + if (stateSeg !== OMITTED) { + if (/^\d+$/.test(stateSeg)) amount = BigInt(stateSeg); + else assignmentRaw = stateSeg; + } + + const beneficiaryMatch = /^([a-z0-9]+):(utxob|wvout):(.+)$/.exec(beneficiarySeg); + const chain = beneficiaryMatch === null ? null : (beneficiaryMatch[1] as string); + const beneficiaryKind = + beneficiaryMatch === null ? 'unknown' : beneficiaryMatch[2] === 'utxob' ? 'blind' : 'witness'; + + let transportEndpoints: string[] = []; + let expiryTimestamp: number | null = null; + let assignmentName: string | null = null; + if (query !== '') { + for (const pair of query.split('&')) { + const eq = pair.indexOf('='); + if (eq === -1) throw new InvoiceDecodeError(`malformed query parameter: ${pair}`); + const key = percentDecode(pair.slice(0, eq)); + const value = percentDecode(pair.slice(eq + 1)); + if (key === 'endpoints') { + transportEndpoints = value.split(',').filter((endpoint) => endpoint !== ''); + } else if (key === 'expiry') { + if (!/^\d+$/.test(value)) throw new InvoiceDecodeError(`invalid expiry: ${value}`); + expiryTimestamp = Number(value); + } else if (key === 'assignment_name') { + assignmentName = value; + } + } + } + + return { + assetId, + schema, + amount, + assignmentRaw, + recipientId: beneficiarySeg, + beneficiaryKind, + chain, + transportEndpoints, + expiryTimestamp, + assignmentName, + }; +} diff --git a/minimal-sdk/packages/client-sdk/src/keys.ts b/minimal-sdk/packages/client-sdk/src/keys.ts new file mode 100644 index 00000000..e4148d88 --- /dev/null +++ b/minimal-sdk/packages/client-sdk/src/keys.ts @@ -0,0 +1,90 @@ +/** + * Key generation and restore, mirroring rgb-lib's derivation exactly + * (cross-checked against the rgb-lib-generated fixture in test/fixtures): + * BIP-39 seed (empty passphrase) → master → account xprv/xpub at + * m/86'/coin'/0' for the vanilla and colored keychains. + * + * The mnemonic is the user's ONLY backup and never leaves the client; + * only xpubs and the master fingerprint are shared with the gateway (I1/I2). + */ +import { HDKey } from '@scure/bip32'; +import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english.js'; +import { matchOriginPath } from './derive.js'; +import { accountPath, hdVersions, type BitcoinNetwork } from './network.js'; + +/** Public account material — safe to share, used to register with the gateway. */ +export interface AccountXpubs { + network: BitcoinNetwork; + /** Master key fingerprint, 8 lowercase hex chars. */ + fingerprint: string; + /** Account xpub for the vanilla (BTC) keychain, m/86'/coin'/0'. */ + vanilla: string; + /** Account xpub for the colored (RGB) keychain, m/86'/rgb-coin'/0'. */ + colored: string; +} + +export class InvalidMnemonicError extends Error { + constructor() { + super('invalid BIP-39 mnemonic'); + this.name = 'InvalidMnemonicError'; + } +} + +export function fingerprintHex(fingerprint: number): string { + return (fingerprint >>> 0).toString(16).padStart(8, '0'); +} + +/** Client-side key material. Holds the account xprvs needed for signing. */ +export class ClientKeys { + private constructor( + readonly network: BitcoinNetwork, + private readonly master: HDKey, + private readonly vanillaAccount: HDKey, + private readonly coloredAccount: HDKey, + ) {} + + /** Generate a fresh 12-word mnemonic and derive keys from it. */ + static generate(network: BitcoinNetwork): { mnemonic: string; keys: ClientKeys } { + const mnemonic = generateMnemonic(wordlist, 128); + return { mnemonic, keys: ClientKeys.fromMnemonic(mnemonic, network) }; + } + + /** Restore keys from an existing mnemonic (throws InvalidMnemonicError). */ + static fromMnemonic(mnemonic: string, network: BitcoinNetwork): ClientKeys { + if (!validateMnemonic(mnemonic, wordlist)) throw new InvalidMnemonicError(); + const seed = mnemonicToSeedSync(mnemonic); + const master = HDKey.fromMasterSeed(seed, hdVersions(network)); + const derive = (colored: boolean) => + accountPath(network, colored).reduce((key, child) => key.deriveChild(child), master); + return new ClientKeys(network, master, derive(false), derive(true)); + } + + get fingerprint(): string { + return fingerprintHex(this.master.fingerprint); + } + + /** Public registration material; never contains private keys. */ + get xpubs(): AccountXpubs { + return { + network: this.network, + fingerprint: this.fingerprint, + vanilla: this.vanillaAccount.publicExtendedKey, + colored: this.coloredAccount.publicExtendedKey, + }; + } + + /** + * Private key for a full master-based path [86', coin', 0', keychain, index] + * as found in PSBT key-origin metadata; null when the path is not one of + * this wallet's two accounts (never throws — used on adversarial input). + */ + privateKeyForPath(path: number[]): Uint8Array | null { + // Path acceptance is shared with verify.ts via matchOriginPath: what + // verify proved is exactly what sign derives. + const match = matchOriginPath(path, this.network); + if (match === null) return null; + const account = match.colored ? this.coloredAccount : this.vanillaAccount; + return account.deriveChild(match.keychain).deriveChild(match.index).privateKey; + } +} diff --git a/minimal-sdk/packages/client-sdk/src/network.ts b/minimal-sdk/packages/client-sdk/src/network.ts new file mode 100644 index 00000000..9e273f71 --- /dev/null +++ b/minimal-sdk/packages/client-sdk/src/network.ts @@ -0,0 +1,57 @@ +/** + * Network constants mirroring rgb-lib exactly (rgb-lib src/utils.rs:14-19, + * get_coin_type): BIP-86 purpose, account 0, keychain 0 on both the vanilla + * (standard coin type) and colored (RGB coin type 827166/827167) sides. + */ + +export type BitcoinNetwork = 'Mainnet' | 'Testnet' | 'Signet' | 'Regtest'; + +export const PURPOSE = 86; +export const ACCOUNT = 0; +export const KEYCHAIN = 0; +export const HARDENED = 0x80000000; + +const COIN_RGB_MAINNET = 827166; +const COIN_RGB_TESTNET = 827167; + +/** Version bytes for extended key serialization (xprv/xpub vs tprv/tpub). */ +export interface HdVersions { + private: number; + public: number; +} + +const MAINNET_VERSIONS: HdVersions = { private: 0x0488ade4, public: 0x0488b21e }; +const TESTNET_VERSIONS: HdVersions = { private: 0x04358394, public: 0x043587cf }; + +/** Address encoding parameters in the shape @scure/btc-signer expects. */ +export interface AddressNetwork { + bech32: string; + pubKeyHash: number; + scriptHash: number; + wif: number; +} + +const ADDRESS_NETWORKS: Record = { + Mainnet: { bech32: 'bc', pubKeyHash: 0x00, scriptHash: 0x05, wif: 0x80 }, + Testnet: { bech32: 'tb', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, + Signet: { bech32: 'tb', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, + Regtest: { bech32: 'bcrt', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, +}; + +export function addressNetwork(network: BitcoinNetwork): AddressNetwork { + return ADDRESS_NETWORKS[network]; +} + +export function hdVersions(network: BitcoinNetwork): HdVersions { + return network === 'Mainnet' ? MAINNET_VERSIONS : TESTNET_VERSIONS; +} + +export function coinType(network: BitcoinNetwork, colored: boolean): number { + if (colored) return network === 'Mainnet' ? COIN_RGB_MAINNET : COIN_RGB_TESTNET; + return network === 'Mainnet' ? 0 : 1; +} + +/** Hardened account derivation path children: [86', coin', 0']. */ +export function accountPath(network: BitcoinNetwork, colored: boolean): number[] { + return [HARDENED + PURPOSE, HARDENED + coinType(network, colored), HARDENED + ACCOUNT]; +} diff --git a/minimal-sdk/packages/client-sdk/src/sign.ts b/minimal-sdk/packages/client-sdk/src/sign.ts new file mode 100644 index 00000000..4ccb70ee --- /dev/null +++ b/minimal-sdk/packages/client-sdk/src/sign.ts @@ -0,0 +1,91 @@ +/** + * BIP-341 key-path sign + finalize via @scure/btc-signer. Refuses to sign + * unless verify-before-sign passed: verification runs inside this module and + * a failed verdict raises VerificationFailedError — there is no bypass. + * + * Keys are derived per input from the PSBT's own key-origin paths, but only + * after verify.ts proved each path re-derives to the exact script being spent. + */ +import { base64 } from '@scure/base'; +import { Transaction } from '@scure/btc-signer'; +import type { ClientKeys } from './keys.js'; +import { + ownDerivation, + PSBT_PARSE_OPTIONS, + verifyPsbt, + type TapDerivation, + type VerifyParams, + type VerifyVerdict, +} from './verify.js'; + +export class VerificationFailedError extends Error { + constructor(readonly verdict: VerifyVerdict) { + const failed = verdict.checks + .filter((c) => !c.ok) + .map((c) => `${c.check}${c.detail !== null ? ` (${c.detail})` : ''}`) + .join('; '); + super(`refusing to sign: verification failed: ${failed}`); + this.name = 'VerificationFailedError'; + } +} + +export class SigningError extends Error { + constructor(detail: string) { + super(`signing failed: ${detail}`); + this.name = 'SigningError'; + } +} + +export interface SignResult { + /** Signed AND finalized PSBT, base64 — what the gateway's complete expects. */ + signedPsbt: string; + txid: string; + verdict: VerifyVerdict; +} + +export type SignParams = Omit; + +/** + * Verify the PSBT against the intent, then key-path-sign every input and + * finalize. Throws VerificationFailedError when any of the 5 checks fails. + */ +export function verifyAndSignPsbt( + keys: ClientKeys, + psbtBase64: string, + params: SignParams, +): SignResult { + const verdict = verifyPsbt({ ...params, psbt: psbtBase64 }); + if (!verdict.ok) throw new VerificationFailedError(verdict); + + const ourFingerprint = Number.parseInt(keys.fingerprint, 16); + const tx = Transaction.fromPSBT(base64.decode(psbtBase64), PSBT_PARSE_OPTIONS); + for (let index = 0; index < tx.inputsLength; index += 1) { + const input = tx.getInput(index); + // Sign with the SAME entry verify.ts proved re-derives to the script being + // spent — selected by our master fingerprint, not by path shape. Matching + // on shape alone would pick a decoy entry carrying a foreign fingerprint + // when the PSBT lists one first, and signing would then fail against a key + // no check ever covered. + const derivation = ownDerivation(input.tapBip32Derivation as TapDerivation[], ourFingerprint); + const privateKey = derivation === null ? null : keys.privateKeyForPath(derivation[1].der.path); + if (privateKey === null) { + throw new SigningError(`input ${index}: no signable key-origin path`); + } + try { + if (!tx.signIdx(privateKey, index)) { + throw new SigningError(`input ${index}: signer declined the derived key`); + } + } catch (error) { + if (error instanceof SigningError) throw error; + throw new SigningError( + `input ${index}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + try { + tx.finalize(); + } catch (error) { + throw new SigningError(`finalize: ${error instanceof Error ? error.message : String(error)}`); + } + return { signedPsbt: base64.encode(tx.toPSBT()), txid: tx.id, verdict }; +} diff --git a/minimal-sdk/packages/client-sdk/src/verify.ts b/minimal-sdk/packages/client-sdk/src/verify.ts new file mode 100644 index 00000000..1339947e --- /dev/null +++ b/minimal-sdk/packages/client-sdk/src/verify.ts @@ -0,0 +1,383 @@ +/** + * Verify-before-sign: the design doc's 5 checks, evaluated against a parsed + * PSBT and the gateway's machine-readable intent summary. Signing is never + * blind — sign.ts refuses unless the verdict here is ok. + * + * All PSBT content (scripts, amounts, key-origin metadata) is treated as + * adversarial. Ownership is proven by RE-DERIVING scripts from the user's own + * account xpubs: a hostile server may supply any path it likes, but only a + * path under the user's accounts can re-derive to a script the user controls. + * This function never throws on malformed input — it returns a failed verdict. + * + * Known limitation (design doc, "send-time trust"): RGB allocations on the + * spent inputs are NOT verifiable here; only bitcoin-value movement is. + */ +import { base64, hex } from '@scure/base'; +import { Address, OutScript, Transaction } from '@scure/btc-signer'; +import { deriveForOriginPath, deriveTaproot, parseAccountXpub } from './derive.js'; +import type { AccountXpubs } from './keys.js'; +import { addressNetwork, KEYCHAIN, type BitcoinNetwork } from './network.js'; + +/** Mirrors the gateway's prepare-response intent summary (kept in sync). */ +export interface IntentRecipient { + address: string; + scriptHex: string; + amountSat: number; +} + +export interface IntentAsset { + assetId: string; + amount: number; + recipientId: string; + witnessAmountSat: number | null; + transportEndpoints: string[]; +} + +export interface IntentUtxos { + upTo: boolean; + num: number; + size: number; +} + +export interface OnchainIntent { + kind: 'send_btc' | 'send_asset' | 'create_utxos'; + feeRateSatPerVb: number; + recipients: IntentRecipient[]; + asset: IntentAsset | null; + utxos: IntentUtxos | null; +} + +export type CheckName = + 'inputs-own' | 'recipients-match' | 'change-own' | 'fee-budget' | 'opret-zero'; + +const ALL_CHECKS: CheckName[] = [ + 'inputs-own', + 'recipients-match', + 'change-own', + 'fee-budget', + 'opret-zero', +]; + +export interface CheckResult { + check: CheckName; + ok: boolean; + detail: string | null; +} + +export interface VerifyVerdict { + ok: boolean; + checks: CheckResult[]; + /** Absolute fee in sats, when computable. */ + feeSat: number | null; + /** Txid of the unsigned transaction, when parseable. */ + txid: string | null; +} + +export interface VerifyParams { + /** Base64 PSBT from the gateway's prepare response. */ + psbt: string; + /** + * Intent summary from the same prepare response. It is SERVER-produced: the + * caller must have bound it to the user's own request first, which + * GatewayClient's prepare methods do (IntentMismatchError). Check 2 is only + * as trustworthy as that binding. + */ + intent: OnchainIntent; + /** The user's own account xpubs + fingerprint (ClientKeys.xpubs). */ + xpubs: AccountXpubs; + /** User-approved absolute fee budget in sats (check 4). */ + maxFeeSat: number; + /** + * Fallback for own outputs lacking key-origin metadata: accept scripts + * re-derived at keychain 0, indexes 0..window-1, on both accounts. + * 0 disables the fallback (metadata-only). Default 30. + */ + changeScanWindow?: number; + /** + * Highest derivation index accepted for a metadata-proven own OUTPUT + * (check 3). Any index under the account xpub re-derives, but only indexes + * a descriptor wallet will actually scan are recoverable — without this + * bound a hostile gateway could steer "change" to e.g. index 9e8, where the + * funds are technically the user's yet invisible to every wallet scan. + * Kept far above realistic per-user usage while cheap to sweep in a + * recovery scan. Default 10000. + */ + maxOwnOutputIndex?: number; +} + +const DEFAULT_CHANGE_SCAN_WINDOW = 30; +const DEFAULT_MAX_OWN_OUTPUT_INDEX = 10_000; + +export const PSBT_PARSE_OPTIONS = { + allowUnknownInputs: true, + allowUnknownOutputs: true, +} as const; + +export type TapDerivation = [ + Uint8Array, + { hashes: Uint8Array[]; der: { fingerprint: number; path: number[] } }, +]; + +interface ParsedOutput { + index: number; + scriptHex: string; + amountSat: number; + isOpret: boolean; + /** Proven ours by re-derivation (metadata path or scan window). */ + isOwn: boolean; +} + +/** Output script (lowercase hex) for an address, or null if it does not decode. */ +function scriptHexFromAddress(address: string, network: BitcoinNetwork): string | null { + try { + return hex.encode(OutScript.encode(Address(addressNetwork(network)).decode(address))); + } catch { + return null; + } +} + +function failAll(detail: string): VerifyVerdict { + return { + ok: false, + checks: ALL_CHECKS.map((check) => ({ check, ok: false, detail })), + feeSat: null, + txid: null, + }; +} + +/** + * First metadata entry carrying the user's master fingerprint, if any. + * + * Exported because sign.ts MUST pick the signing key from the very entry this + * proved: selecting by path shape alone would let a decoy entry with a foreign + * fingerprint, ordered first, divert signing to a key the verdict never + * covered. + */ +export function ownDerivation( + derivations: TapDerivation[] | undefined, + fingerprint: number, +): TapDerivation | null { + for (const entry of derivations ?? []) { + if (entry[1]?.der?.fingerprint === fingerprint) return entry; + } + return null; +} + +export function verifyPsbt(params: VerifyParams): VerifyVerdict { + try { + return verifyParsed(params); + } catch (error) { + return failAll( + `psbt could not be safely parsed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function verifyParsed(params: VerifyParams): VerifyVerdict { + const { intent, xpubs, maxFeeSat } = params; + const network = xpubs.network; + const ourFingerprint = Number.parseInt(xpubs.fingerprint, 16); + const accounts = { + vanilla: parseAccountXpub(xpubs.vanilla, network), + colored: parseAccountXpub(xpubs.colored, network), + }; + const tx = Transaction.fromPSBT(base64.decode(params.psbt), PSBT_PARSE_OPTIONS); + const checks: CheckResult[] = []; + + // Check 1 — every input is ours: key-origin fingerprint matches AND the + // path re-derives (from OUR xpubs) to the exact script being spent. + const inputFailures: string[] = []; + let inputTotalSat = 0; + for (let i = 0; i < tx.inputsLength; i += 1) { + const input = tx.getInput(i); + const utxo = input.witnessUtxo; + if (utxo === undefined) { + inputFailures.push(`input ${i}: no witnessUtxo, cannot verify what is being spent`); + continue; + } + inputTotalSat += Number(utxo.amount); + const derivation = ownDerivation(input.tapBip32Derivation as TapDerivation[], ourFingerprint); + if (derivation === null) { + inputFailures.push(`input ${i}: no key origin with our fingerprint (foreign input)`); + continue; + } + const derived = deriveForOriginPath(derivation[1].der.path, accounts, network); + if (derived === null) { + inputFailures.push(`input ${i}: key-origin path is not under our accounts`); + continue; + } + if (derived.scriptHex !== hex.encode(utxo.script)) { + inputFailures.push(`input ${i}: spent script does not re-derive from our keys`); + continue; + } + if ( + input.tapInternalKey !== undefined && + hex.encode(input.tapInternalKey) !== derived.internalKeyHex + ) { + inputFailures.push(`input ${i}: tapInternalKey mismatch with re-derived key`); + } + } + checks.push({ + check: 'inputs-own', + ok: inputFailures.length === 0 && tx.inputsLength > 0, + detail: + tx.inputsLength === 0 + ? 'transaction has no inputs' + : inputFailures.length > 0 + ? inputFailures.join('; ') + : null, + }); + + // Own-script set for the metadata-less change fallback (keychain 0 only — + // the single keychain rgb-lib uses on both accounts). Built lazily: real + // rgb-lib PSBTs carry tapBip32Derivation on their change outputs, so the + // common path must not pay for 2 * scanWindow key derivations. + const scanWindow = params.changeScanWindow ?? DEFAULT_CHANGE_SCAN_WINDOW; + let scannedOwnScripts: Set | undefined; + const isScannedOwnScript = (scriptHex: string): boolean => { + if (scannedOwnScripts === undefined) { + scannedOwnScripts = new Set(); + for (const account of [accounts.vanilla, accounts.colored]) { + for (let index = 0; index < scanWindow; index += 1) { + scannedOwnScripts.add(deriveTaproot(account, 0, index, network).scriptHex); + } + } + } + return scannedOwnScripts.has(scriptHex); + }; + + const outputs: ParsedOutput[] = []; + let outputTotalSat = 0; + for (let i = 0; i < tx.outputsLength; i += 1) { + const output = tx.getOutput(i); + const script = output.script ?? new Uint8Array(); + const scriptHex = hex.encode(script); + const amountSat = Number(output.amount ?? 0n); + outputTotalSat += amountSat; + const isOpret = script[0] === 0x6a; + let isOwn = false; + const derivation = ownDerivation(output.tapBip32Derivation as TapDerivation[], ourFingerprint); + if (derivation !== null) { + const derived = deriveForOriginPath(derivation[1].der.path, accounts, network); + // Own OUTPUTS must live in the wallet's real descriptor space: keychain + // 0 (the single keychain rgb-lib scans, network.ts KEYCHAIN) at a + // bounded index. Inputs (check 1) stay unrestricted — spending from an + // odd path is proven safe by the script re-derivation alone. + isOwn = + derived !== null && + derived.scriptHex === scriptHex && + derived.keychain === KEYCHAIN && + derived.index <= (params.maxOwnOutputIndex ?? DEFAULT_MAX_OWN_OUTPUT_INDEX); + } + if (!isOwn && !isOpret) { + isOwn = isScannedOwnScript(scriptHex); + } + outputs.push({ index: i, scriptHex, amountSat, isOpret, isOwn }); + } + + // Check 2 — recipient outputs match the user-stated intent (script+amount), + // matched as a multiset; plus (witness asset sends) exactly one foreign + // output carrying exactly the approved witness amount. + // + // The expected script is RE-DERIVED from the human-readable address — the + // field the user actually reviews. The server-supplied scriptHex is only + // cross-checked: a hostile gateway could otherwise pair the intended + // address with an attacker script and both would "match". + const accounted = new Set(); + const recipientFailures: string[] = []; + for (const recipient of intent.recipients) { + const expectedScriptHex = scriptHexFromAddress(recipient.address, network); + if (expectedScriptHex === null) { + recipientFailures.push( + `recipient address ${recipient.address} does not decode on ${network}`, + ); + continue; + } + if (recipient.scriptHex.toLowerCase() !== expectedScriptHex) { + recipientFailures.push( + `intent scriptHex does not match recipient address ${recipient.address}`, + ); + continue; + } + const match = outputs.find( + (o) => + !accounted.has(o.index) && + o.scriptHex === expectedScriptHex && + o.amountSat === recipient.amountSat, + ); + if (match === undefined) { + recipientFailures.push( + `no output pays ${recipient.amountSat} sat to intended recipient ${recipient.address}`, + ); + } else { + accounted.add(match.index); + } + } + const witnessAmount = intent.asset?.witnessAmountSat ?? null; + if (witnessAmount !== null) { + const candidates = outputs.filter( + (o) => !accounted.has(o.index) && !o.isOwn && !o.isOpret && o.amountSat === witnessAmount, + ); + const first = candidates[0]; + if (candidates.length === 1 && first !== undefined) { + accounted.add(first.index); + } else { + recipientFailures.push( + `expected exactly one witness output of ${witnessAmount} sat, found ${candidates.length}`, + ); + } + } + checks.push({ + check: 'recipients-match', + ok: recipientFailures.length === 0, + detail: recipientFailures.length > 0 ? recipientFailures.join('; ') : null, + }); + + // Check 3 — change pays only re-derivable own scripts: everything that is + // not an intended recipient, the approved witness output, or an OP_RETURN + // must prove ownership. + const changeFailures: string[] = []; + for (const output of outputs) { + if (accounted.has(output.index) || output.isOpret) continue; + if (!output.isOwn) { + changeFailures.push( + `output ${output.index} (${output.amountSat} sat) does not re-derive from our keys`, + ); + } + } + checks.push({ + check: 'change-own', + ok: changeFailures.length === 0, + detail: changeFailures.length > 0 ? changeFailures.join('; ') : null, + }); + + // Check 4 — fee = inputs − outputs within the user-approved budget. + const feeSat = inputTotalSat - outputTotalSat; + const feeOk = feeSat > 0 && feeSat <= maxFeeSat; + checks.push({ + check: 'fee-budget', + ok: feeOk, + detail: feeOk ? null : `fee ${feeSat} sat outside budget (0, ${maxFeeSat}]`, + }); + + // Check 5 — OP_RETURN outputs carry 0 sats. + const opretFailures = outputs.filter((o) => o.isOpret && o.amountSat !== 0); + checks.push({ + check: 'opret-zero', + ok: opretFailures.length === 0, + detail: + opretFailures.length > 0 + ? opretFailures + .map((o) => `OP_RETURN output ${o.index} carries ${o.amountSat} sat`) + .join('; ') + : null, + }); + + let txid: string | null; + try { + txid = tx.id; + } catch { + txid = null; + } + return { ok: checks.every((c) => c.ok), checks, feeSat, txid }; +} diff --git a/minimal-sdk/packages/client-sdk/test/bundle-size.test.ts b/minimal-sdk/packages/client-sdk/test/bundle-size.test.ts new file mode 100644 index 00000000..6c9030a9 --- /dev/null +++ b/minimal-sdk/packages/client-sdk/test/bundle-size.test.ts @@ -0,0 +1,30 @@ +/** + * Bundle-size gate (Task 6): a minified browser production bundle of the + * whole SDK must stay under 500KB. Fails the suite when exceeded. + */ +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; +import { describe, expect, it } from 'vitest'; + +const BUDGET_BYTES = 500 * 1024; + +describe('bundle-size gate', () => { + it('minified browser bundle stays under 500KB', async () => { + const result = await build({ + entryPoints: [fileURLToPath(new URL('../src/index.ts', import.meta.url))], + bundle: true, + minify: true, + format: 'esm', + platform: 'browser', + target: 'es2022', + write: false, + logLevel: 'silent', + }); + const bytes = result.outputFiles.reduce((sum, file) => sum + file.contents.byteLength, 0); + console.info( + `client-sdk bundle: ${(bytes / 1024).toFixed(1)}KB of ${BUDGET_BYTES / 1024}KB budget`, + ); + expect(bytes).toBeGreaterThan(0); + expect(bytes).toBeLessThan(BUDGET_BYTES); + }); +}); diff --git a/minimal-sdk/packages/client-sdk/test/derivation.test.ts b/minimal-sdk/packages/client-sdk/test/derivation.test.ts new file mode 100644 index 00000000..b80ee3c5 --- /dev/null +++ b/minimal-sdk/packages/client-sdk/test/derivation.test.ts @@ -0,0 +1,96 @@ +/** + * Derivation parity against the rgb-lib-generated fixture: the SDK must + * reproduce rgb-lib's xpubs, fingerprint and addresses EXACTLY (Task 6 + * requirement — fixture from scripts/generate-rgblib-fixtures.mjs). + */ +import { describe, expect, it } from 'vitest'; +import { ClientKeys, InvalidMnemonicError } from '../src/keys.js'; +import { deriveForOriginPath, deriveTaproot, parseAccountXpub } from '../src/derive.js'; +import { accountPath, HARDENED } from '../src/network.js'; +import type { BitcoinNetwork } from '../src/network.js'; +import { COLORED_PATH, fixture, VANILLA_PATH } from './helpers.js'; + +const NETWORKS: BitcoinNetwork[] = ['Regtest', 'Testnet', 'Signet', 'Mainnet']; + +describe('key derivation parity with rgb-lib', () => { + for (const network of NETWORKS) { + it(`reproduces restore_keys on ${network}`, () => { + const keys = ClientKeys.fromMnemonic(fixture.mnemonic, network); + const expected = fixture.networks[network]; + expect(keys.xpubs.vanilla).toBe(expected.accountXpubVanilla); + expect(keys.xpubs.colored).toBe(expected.accountXpubColored); + expect(keys.fingerprint).toBe(expected.masterFingerprint); + expect(keys.xpubs.network).toBe(network); + }); + } + + it('reproduces rgb-lib vanilla-keychain addresses at indexes 0..4', () => { + const { accountXpubVanilla } = fixture.networks.Regtest; + fixture.regtest.vanillaAddresses.forEach((address, index) => { + expect(deriveTaproot(accountXpubVanilla, 0, index, 'Regtest').address).toBe(address); + }); + }); + + it('reproduces rgb-lib colored-keychain addresses at indexes 0..4', () => { + const { accountXpubColored } = fixture.networks.Regtest; + fixture.regtest.coloredAddresses.forEach((address, index) => { + expect(deriveTaproot(accountXpubColored, 0, index, 'Regtest').address).toBe(address); + }); + }); + + it('rejects an invalid mnemonic', () => { + expect(() => ClientKeys.fromMnemonic('not a mnemonic at all', 'Regtest')).toThrow( + InvalidMnemonicError, + ); + }); + + it('generates a fresh valid mnemonic whose keys restore identically', () => { + const { mnemonic, keys } = ClientKeys.generate('Regtest'); + expect(mnemonic.split(' ')).toHaveLength(12); + const restored = ClientKeys.fromMnemonic(mnemonic, 'Regtest'); + expect(restored.xpubs).toEqual(keys.xpubs); + }); + + it('uses the rgb-lib account paths (86h/coin-h/0h, colored 827166/827167)', () => { + expect(accountPath('Regtest', false)).toEqual(VANILLA_PATH); + expect(accountPath('Regtest', true)).toEqual(COLORED_PATH); + expect(accountPath('Mainnet', false)).toEqual([HARDENED + 86, HARDENED, HARDENED]); + expect(accountPath('Mainnet', true)).toEqual([HARDENED + 86, HARDENED + 827166, HARDENED]); + }); +}); + +describe('deriveForOriginPath', () => { + const xpubs = { + vanilla: fixture.networks.Regtest.accountXpubVanilla, + colored: fixture.networks.Regtest.accountXpubColored, + }; + + it('re-derives a vanilla-keychain origin path to the rgb-lib address', () => { + const derived = deriveForOriginPath([...VANILLA_PATH, 0, 0], xpubs, 'Regtest'); + expect(derived?.address).toBe(fixture.regtest.vanillaAddresses[0]); + }); + + it('re-derives a colored-keychain origin path to the rgb-lib address', () => { + const derived = deriveForOriginPath([...COLORED_PATH, 0, 2], xpubs, 'Regtest'); + expect(derived?.address).toBe(fixture.regtest.coloredAddresses[2]); + }); + + it('rejects paths outside our accounts', () => { + expect( + deriveForOriginPath([HARDENED + 84, HARDENED + 1, HARDENED, 0, 0], xpubs, 'Regtest'), + ).toBeNull(); + expect(deriveForOriginPath([...VANILLA_PATH, 0], xpubs, 'Regtest')).toBeNull(); + expect(deriveForOriginPath([...VANILLA_PATH, HARDENED, 0], xpubs, 'Regtest')).toBeNull(); + expect(deriveForOriginPath([], xpubs, 'Regtest')).toBeNull(); + }); + + it('accepts pre-parsed HDKey accounts', () => { + const account = parseAccountXpub(xpubs.vanilla, 'Regtest'); + const derived = deriveForOriginPath( + [...VANILLA_PATH, 0, 1], + { vanilla: account, colored: xpubs.colored }, + 'Regtest', + ); + expect(derived?.address).toBe(fixture.regtest.vanillaAddresses[1]); + }); +}); diff --git a/minimal-sdk/packages/client-sdk/test/fixtures/rgblib-parity.json b/minimal-sdk/packages/client-sdk/test/fixtures/rgblib-parity.json new file mode 100644 index 00000000..8db436c3 --- /dev/null +++ b/minimal-sdk/packages/client-sdk/test/fixtures/rgblib-parity.json @@ -0,0 +1,58 @@ +{ + "generatedBy": "scripts/generate-rgblib-fixtures.mjs (@utexo/rgb-lib 0.3.0-beta.18)", + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "otherMnemonic": "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong", + "networks": { + "Regtest": { + "xpub": "tpubD6NzVbkrYhZ4XYa9MoLt4BiMZ4gkt2faZ4BcmKu2a9te4LDpQmvEz2L2yDERivHxFPnxXXhqDRkUNnQCpZggCyEZLBktV7VaSmwayqMJy1s", + "accountXpubVanilla": "tpubDDfvzhdVV4unsoKt5aE6dcsNsfeWbTgmLZPi8LQDYU2xixrYemMfWJ3BaVneH3u7DBQePdTwhpybaKRU95pi6PMUtLPBJLVQRpzEnjfjZzX", + "accountXpubColored": "tpubDCtpoJs6YJcjLnr9gq6jYriYNMuWEu8mSDvEQU5st3ZkJbFqqzwpHUiPvxqD2366ciFAfpehk1k2d7Tyk7AJEr8uZva7KfnX4RpsiVSoEcZ", + "masterFingerprint": "73c5da0a" + }, + "Testnet": { + "xpub": "tpubD6NzVbkrYhZ4XYa9MoLt4BiMZ4gkt2faZ4BcmKu2a9te4LDpQmvEz2L2yDERivHxFPnxXXhqDRkUNnQCpZggCyEZLBktV7VaSmwayqMJy1s", + "accountXpubVanilla": "tpubDDfvzhdVV4unsoKt5aE6dcsNsfeWbTgmLZPi8LQDYU2xixrYemMfWJ3BaVneH3u7DBQePdTwhpybaKRU95pi6PMUtLPBJLVQRpzEnjfjZzX", + "accountXpubColored": "tpubDCtpoJs6YJcjLnr9gq6jYriYNMuWEu8mSDvEQU5st3ZkJbFqqzwpHUiPvxqD2366ciFAfpehk1k2d7Tyk7AJEr8uZva7KfnX4RpsiVSoEcZ", + "masterFingerprint": "73c5da0a" + }, + "Signet": { + "xpub": "tpubD6NzVbkrYhZ4XYa9MoLt4BiMZ4gkt2faZ4BcmKu2a9te4LDpQmvEz2L2yDERivHxFPnxXXhqDRkUNnQCpZggCyEZLBktV7VaSmwayqMJy1s", + "accountXpubVanilla": "tpubDDfvzhdVV4unsoKt5aE6dcsNsfeWbTgmLZPi8LQDYU2xixrYemMfWJ3BaVneH3u7DBQePdTwhpybaKRU95pi6PMUtLPBJLVQRpzEnjfjZzX", + "accountXpubColored": "tpubDCtpoJs6YJcjLnr9gq6jYriYNMuWEu8mSDvEQU5st3ZkJbFqqzwpHUiPvxqD2366ciFAfpehk1k2d7Tyk7AJEr8uZva7KfnX4RpsiVSoEcZ", + "masterFingerprint": "73c5da0a" + }, + "Mainnet": { + "xpub": "xpub661MyMwAqRbcFkPHucMnrGNzDwb6teAX1RbKQmqtEF8kK3Z7LZ59qafCjB9eCRLiTVG3uxBxgKvRgbubRhqSKXnGGb1aoaqLrpMBDrVxga8", + "accountXpubVanilla": "xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ", + "accountXpubColored": "xpub6CP2DfFuXBYGQsbdgnDhN42ceF42pSPRm6o8SsWXv2kRxNR1xvYBbhTwKWEzYMPtRGCfmGWm3jbUYhre1Fk8bR1SA35RP18o2sHSM7LE17L", + "masterFingerprint": "73c5da0a" + } + }, + "regtest": { + "vanillaAddresses": [ + "bcrt1p8wpt9v4frpf3tkn0srd97pksgsxc5hs52lafxwru9kgeephvs7rqjeprhg", + "bcrt1p90h6z3p36n9hrzy7580h5l429uwchyg8uc9sz4jwzhdtuhqdl5eqkcyx0f", + "bcrt1p40qqa84kpphe5vtcwd8zv7v6w7p62cmupf6f60mf8pxdkcv2455qgtw98j", + "bcrt1p5z45vylh6vue39806mze8wl7z360ynn0uhxd8cnr5p4swe6n5gts4pf74z", + "bcrt1p759ss4nwlk93nmljrpp24mqdtcc5e4f6u3nyteq2577m68ryas0qzc3amn" + ], + "coloredAddresses": [ + "bcrt1pame6a0kt2n84z0a9ayz3g8de3ehgc6alm34tzca5ea8yuknwjuaqzxjx7j", + "bcrt1pucnkkqp50ll47err5ljaqms6qxktkeveysfx7zjy4s3kgf4f20nsfpw2g4", + "bcrt1puxtnfghd9px7wmvg0f4yhsv0zua3x7z8pgxw5jh82mx8h744qmpql000vs", + "bcrt1pzjseymhunwkuz2t4mevh4keufjg9mchsz83evvvxyvvydsurulqqfa59qk", + "bcrt1px5x33scxkhgnjg4ldrwry62dsk3h07jtzl7n3kzf34s825pvhxks54ve2p" + ] + }, + "signing": { + "unsignedPsbt": "cHNidP8BAJkCAAAAASsaD56NfGtaTz4tHAuaj35tXEs+LRoPfGtOnYofPCteAQAAAAD/////A0CcAAAAAAAAIlEgp/BL/9hgrX1ypZEVNU9TJaLCAdEzLQBFn5DYK/Qg1bV45gAAAAAAACJRICvvoUQx1MtxiJ6h33p+qi8di5EH5gsBVk4V2r5cDf0yAAAAAAAAAAAHagUBAgMEBQAAAAAAAQEroIYBAAAAAAAiUSA7grKyqRhTFdpvgNpfBtBEDYpeFFf6kzh8LZGchuyHhiEWVTVcqDyXPx2Xzg44Q8hdeJBa8WtNxTG8SI5XIS0jARYZAHPF2gpWAACAAQAAgAAAAIAAAAAAAAAAAAEXIFU1XKg8lz8dl84OOEPIXXiQWvFrTcUxvEiOVyEtIwEWAAABBSAwWGefbWC4fvkh2Yoqmh8eB3na4nvtvRzbLxR6B4NaySEHMFhnn21guH75IdmKKpofHgd52uJ77b0c2y8UegeDWskZAHPF2gpWAACAAQAAgAAAAIAAAAAAAQAAAAAA", + "signedPsbt": "cHNidP8BAJkCAAAAASsaD56NfGtaTz4tHAuaj35tXEs+LRoPfGtOnYofPCteAQAAAAD/////A0CcAAAAAAAAIlEgp/BL/9hgrX1ypZEVNU9TJaLCAdEzLQBFn5DYK/Qg1bV45gAAAAAAACJRICvvoUQx1MtxiJ6h33p+qi8di5EH5gsBVk4V2r5cDf0yAAAAAAAAAAAHagUBAgMEBQAAAAAAAQEroIYBAAAAAAAiUSA7grKyqRhTFdpvgNpfBtBEDYpeFFf6kzh8LZGchuyHhgEIQgFA3Y6QnOK1brnmVDgx8z9SBeO6YK5zJPMAGjO5GlTzNgzJ+uueMei42KYHATBBOU9pCUzFrE5lU8piqVI1+6QrvwAAAQUgMFhnn21guH75IdmKKpofHgd52uJ77b0c2y8UegeDWskAAA==", + "txid": "ae72cb6b290010a54273798811c8b7d3f727f794c65e861ae73336689fff6a8f", + "witnessSignature": "dd8e909ce2b56eb9e6543831f33f5205e3ba60ae7324f3001a33b91a54f3360cc9faeb9e31e8b8d8a607013041394f69094cc5ac4e6553ca62a95235fba42bbf" + }, + "witnessReceive": { + "invoice": "rgb:~/~/~/bcrt:wvout:Be7zrr7L-VM9RP6X-pBRQduY-5ujGu~3-GqxY7TP-Tk5abpc-6cGRkrQ?expiry=2000000000&endpoints=rpc://localhost:3000/json-rpc", + "recipientId": "bcrt:wvout:Be7zrr7L-VM9RP6X-pBRQduY-5ujGu~3-GqxY7TP-Tk5abpc-6cGRkrQ", + "expirationTimestamp": 2000000000 + } +} diff --git a/minimal-sdk/packages/client-sdk/test/gateway.test.ts b/minimal-sdk/packages/client-sdk/test/gateway.test.ts new file mode 100644 index 00000000..3e2b3d33 --- /dev/null +++ b/minimal-sdk/packages/client-sdk/test/gateway.test.ts @@ -0,0 +1,454 @@ +/** + * GatewayClient: auth/idempotency headers, timeout and error mapping — all + * via the injectable fetchFn, no real network. + */ +import { describe, expect, it } from 'vitest'; +import { + GatewayClient, + GatewayError, + generateIdempotencyKey, + IntentMismatchError, +} from '../src/gateway.js'; + +interface SeenRequest { + url: string; + method: string; + headers: Record; + body: string | null; +} + +/** fetchFn stub that records requests and returns the queued responses. */ +function stubFetch(responses: Response[]): { seen: SeenRequest[]; fetchFn: typeof fetch } { + const seen: SeenRequest[] = []; + const fetchFn = ((url: string | URL | Request, init?: RequestInit) => { + seen.push({ + url: String(url), + method: init?.method ?? 'GET', + headers: Object.fromEntries( + Object.entries((init?.headers ?? {}) as Record).map(([k, v]) => [ + k.toLowerCase(), + v, + ]), + ), + body: typeof init?.body === 'string' ? init.body : null, + }); + const response = responses.shift(); + if (response === undefined) throw new Error('stubFetch: no response queued'); + return Promise.resolve(response); + }) as typeof fetch; + return { seen, fetchFn }; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe('generateIdempotencyKey', () => { + it('produces unique RFC-4122 v4 ids', () => { + const keys = new Set(Array.from({ length: 32 }, () => generateIdempotencyKey())); + expect(keys.size).toBe(32); + for (const key of keys) expect(key).toMatch(UUID_V4); + }); + + it('falls back to getRandomValues where crypto.randomUUID is missing', () => { + // crypto.randomUUID is unavailable in non-secure browser contexts, which + // is exactly this SDK's target; under Node the fallback never otherwise + // runs, so its version/variant bit-twiddling would go unverified. + const real = globalThis.crypto; + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { getRandomValues: (array: Uint8Array) => real.getRandomValues(array) }, + }); + try { + const keys = new Set(Array.from({ length: 32 }, () => generateIdempotencyKey())); + expect(keys.size).toBe(32); + for (const key of keys) expect(key).toMatch(UUID_V4); + } finally { + Object.defineProperty(globalThis, 'crypto', { configurable: true, value: real }); + } + }); +}); + +describe('GatewayClient', () => { + it('percent-encodes caller-supplied path and query values', async () => { + // RGB asset ids carry ':' and payment hashes come straight from callers: + // an unencoded value could retarget the request to a different route. + const { seen, fetchFn } = stubFetch([ + jsonResponse(200, { transfers: [] }), + jsonResponse(200, { transfers: [] }), + jsonResponse(200, {}), + ]); + const client = new GatewayClient({ baseUrl: 'http://gw.local', token: 't', fetchFn }); + await client.getTransfers('rgb:abc-123'); + await client.getTransfers(); + await client.getLnInvoice('../v1/ln/balance'); + expect(seen[0]?.url).toBe('http://gw.local/v1/wallet/transfers?assetId=rgb%3Aabc-123'); + expect(seen[1]?.url).toBe('http://gw.local/v1/wallet/transfers'); + expect(seen[2]?.url).toBe('http://gw.local/v1/ln/invoice/..%2Fv1%2Fln%2Fbalance'); + }); + + it('sends the bearer token and joins paths on a trailing-slash base URL', async () => { + const { seen, fetchFn } = stubFetch([jsonResponse(200, { address: 'bcrt1p...' })]); + const client = new GatewayClient({ baseUrl: 'http://gw.local/', token: 'tok-1', fetchFn }); + await client.getAddress(); + expect(seen[0]?.url).toBe('http://gw.local/v1/wallet/address'); + expect(seen[0]?.headers['authorization']).toBe('Bearer tok-1'); + }); + + it('refuses authenticated calls without a token, before any network IO', async () => { + const { seen, fetchFn } = stubFetch([]); + const client = new GatewayClient({ baseUrl: 'http://gw.local', fetchFn }); + await expect(client.getBalances()).rejects.toMatchObject({ code: 'NO_TOKEN' }); + expect(seen).toHaveLength(0); + }); + + it('createUser authenticates with the operator token only', async () => { + const { seen, fetchFn } = stubFetch([ + jsonResponse(201, { userId: 'u1', token: 't', createdAt: 1 }), + ]); + const client = new GatewayClient({ baseUrl: 'http://gw.local', fetchFn }); + await client.createUser('op-secret'); + expect(seen[0]?.headers['x-operator-token']).toBe('op-secret'); + expect(seen[0]?.headers['authorization']).toBeUndefined(); + }); + + it('generates a fresh idempotency key per money-moving call', async () => { + // The prepare methods bind the returned intent to the request, so both + // stubs have to answer with the intent the call actually asked for. + const op = (intent: unknown) => ({ opId: 'op', psbt: 'cHNidP8=', intent, expiresAt: 0 }); + const { seen, fetchFn } = stubFetch([ + jsonResponse( + 200, + op({ + kind: 'send_btc', + feeRateSatPerVb: 2, + recipients: [{ address: 'bcrt1p...', scriptHex: '5120ab', amountSat: 1_000 }], + asset: null, + utxos: null, + }), + ), + jsonResponse( + 200, + op({ + kind: 'create_utxos', + feeRateSatPerVb: 2, + recipients: [], + asset: null, + utxos: { upTo: false, num: 5, size: 1_000 }, + }), + ), + ]); + const client = new GatewayClient({ baseUrl: 'http://gw.local', token: 't', fetchFn }); + await client.prepareSendBtc({ address: 'bcrt1p...', amountSat: 1_000 }); + await client.prepareCreateUtxos({}); + const keys = seen.map((r) => r.headers['idempotency-key']); + expect(keys[0]).toMatch(UUID_V4); + expect(keys[1]).toMatch(UUID_V4); + expect(keys[0]).not.toBe(keys[1]); + expect(seen[0]?.headers['content-type']).toBe('application/json'); + }); + + it('honors a caller-pinned idempotency key (safe retry of the SAME op)', async () => { + const { seen, fetchFn } = stubFetch([ + jsonResponse(200, { txid: 'aa' }), + jsonResponse(200, { txid: 'aa' }), + ]); + const client = new GatewayClient({ baseUrl: 'http://gw.local', token: 't', fetchFn }); + const key = generateIdempotencyKey(); + await client.completeSendBtc({ opId: 'op', signedPsbt: 'cHNidP8=' }, key); + await client.completeSendBtc({ opId: 'op', signedPsbt: 'cHNidP8=' }, key); + expect(seen.map((r) => r.headers['idempotency-key'])).toEqual([key, key]); + }); + + it('maps a gateway error body to GatewayError with status and code', async () => { + const { fetchFn } = stubFetch([ + jsonResponse(409, { error: { code: 'IDEMPOTENCY_CONFLICT', message: 'key reused' } }), + ]); + const client = new GatewayClient({ baseUrl: 'http://gw.local', token: 't', fetchFn }); + const error = await client.sync().catch((e: unknown) => e); + expect(error).toBeInstanceOf(GatewayError); + expect(error).toMatchObject({ + status: 409, + code: 'IDEMPOTENCY_CONFLICT', + message: 'key reused', + }); + }); + + it('maps a non-JSON error response to UNKNOWN', async () => { + const { fetchFn } = stubFetch([new Response('bad gateway', { status: 502 })]); + const client = new GatewayClient({ baseUrl: 'http://gw.local', token: 't', fetchFn }); + await expect(client.me()).rejects.toMatchObject({ status: 502, code: 'UNKNOWN' }); + }); + + it('aborts a hung request after timeoutMs and reports TIMEOUT', async () => { + const fetchFn = ((_url: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + })) as typeof fetch; + const client = new GatewayClient({ + baseUrl: 'http://gw.local', + token: 't', + timeoutMs: 25, + fetchFn, + }); + await expect(client.me()).rejects.toMatchObject({ code: 'TIMEOUT' }); + }); + + it('maps a network failure to NETWORK_ERROR', async () => { + const fetchFn = (() => Promise.reject(new TypeError('fetch failed'))) as typeof fetch; + const client = new GatewayClient({ baseUrl: 'http://gw.local', token: 't', fetchFn }); + await expect(client.me()).rejects.toMatchObject({ code: 'NETWORK_ERROR' }); + }); + + it('LN money-moving calls (pay, withdraw) carry an idempotency key; reads do not', async () => { + const { seen, fetchFn } = stubFetch([ + jsonResponse(200, { paymentHash: 'a'.repeat(64), status: 'pending' }), + jsonResponse(201, { withdrawalId: 'w', txid: 'b'.repeat(64) }), + jsonResponse(200, { btcMsat: 0, assets: {} }), + jsonResponse(200, { payments: [] }), + ]); + const client = new GatewayClient({ baseUrl: 'http://gw.local', token: 't', fetchFn }); + const key = generateIdempotencyKey(); + await client.payLnInvoice({ invoice: 'lnbcrt1...' }, key); + await client.withdrawLn({ kind: 'btc', address: 'bcrt1q...', amountSat: 1_000 }); + await client.getLnBalance(); + await client.listLnPayments(); + expect(seen[0]?.url).toBe('http://gw.local/v1/ln/pay'); + expect(seen[0]?.headers['idempotency-key']).toBe(key); + expect(seen[1]?.headers['idempotency-key']).toMatch(UUID_V4); + expect(seen[2]?.headers['idempotency-key']).toBeUndefined(); + expect(seen[3]?.headers['idempotency-key']).toBeUndefined(); + }); + + it('LN deposit prepare and invoice endpoints hit the expected routes', async () => { + const hash = 'c'.repeat(64); + const { seen, fetchFn } = stubFetch([ + jsonResponse(201, { + depositId: 'd', + kind: 'btc', + address: 'bcrt1q...', + invoice: null, + recipientId: null, + }), + jsonResponse(201, { invoice: 'lnbcrt1...', paymentHash: hash }), + jsonResponse(200, { + paymentHash: hash, + invoice: 'lnbcrt1...', + state: 'pending', + amtMsat: 1000, + assetId: null, + assetAmount: null, + createdAt: 1, + }), + ]); + const client = new GatewayClient({ baseUrl: 'http://gw.local', token: 't', fetchFn }); + const prepared = await client.prepareLnDeposit({ kind: 'btc', amountMsat: 1_000_000 }); + expect(prepared.address).toBe('bcrt1q...'); + await client.createLnInvoice({ amtMsat: 1000 }); + const info = await client.getLnInvoice(hash); + expect(info.state).toBe('pending'); + expect(seen.map((r) => r.url)).toEqual([ + 'http://gw.local/v1/ln/deposit/prepare', + 'http://gw.local/v1/ln/invoice', + `http://gw.local/v1/ln/invoice/${hash}`, + ]); + }); +}); + +describe('prepare responses are bound to the request', () => { + // verify.ts check 2 matches the PSBT against the intent. A server-echoed + // intent makes that check vacuous against a hostile gateway — it can put an + // attacker output in the PSBT and the SAME attacker output in the intent, + // and every one of the 5 checks passes. These bind the intent to what the + // caller actually asked for, before it can reach verifyPsbt. + const ADDRESS = 'bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080'; + const ATTACKER = 'bcrt1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3'; + + function client(prepared: unknown): GatewayClient { + const { fetchFn } = stubFetch([jsonResponse(200, prepared)]); + return new GatewayClient({ baseUrl: 'http://gw.local', token: 't', fetchFn }); + } + + function sendBtcIntent(overrides: Partial> = {}): unknown { + return { + opId: 'op-1', + psbt: 'cHNidP8=', + expiresAt: 1, + intent: { + kind: 'send_btc', + feeRateSatPerVb: 2, + recipients: [{ address: ADDRESS, scriptHex: '0014deadbeef', amountSat: 40_000 }], + asset: null, + utxos: null, + ...overrides, + }, + }; + } + + it('accepts an intent that matches the request', async () => { + const prepared = await client(sendBtcIntent()).prepareSendBtc({ + address: ADDRESS, + amountSat: 40_000, + }); + expect(prepared.opId).toBe('op-1'); + }); + + it('rejects a redirected recipient address', async () => { + const swapped = sendBtcIntent({ + recipients: [{ address: ATTACKER, scriptHex: '0014deadbeef', amountSat: 40_000 }], + }); + await expect( + client(swapped).prepareSendBtc({ address: ADDRESS, amountSat: 40_000 }), + ).rejects.toThrow(IntentMismatchError); + }); + + it('rejects an inflated recipient amount', async () => { + const inflated = sendBtcIntent({ + recipients: [{ address: ADDRESS, scriptHex: '0014deadbeef', amountSat: 4_000_000 }], + }); + await expect( + client(inflated).prepareSendBtc({ address: ADDRESS, amountSat: 40_000 }), + ).rejects.toThrow(IntentMismatchError); + }); + + it('rejects an extra recipient smuggled into a send-btc intent', async () => { + const extra = sendBtcIntent({ + recipients: [ + { address: ADDRESS, scriptHex: '0014deadbeef', amountSat: 40_000 }, + { address: ATTACKER, scriptHex: '0014cafe', amountSat: 100_000 }, + ], + }); + await expect( + client(extra).prepareSendBtc({ address: ADDRESS, amountSat: 40_000 }), + ).rejects.toThrow(IntentMismatchError); + }); + + it('rejects a witness amount attached to a send-btc intent', async () => { + // asset.witnessAmountSat is how check 2 accounts for a FOREIGN output; + // on a plain BTC send there is no such approval to grant. + const witness = sendBtcIntent({ + asset: { + assetId: 'rgb:x', + amount: 1, + recipientId: 'wvout:y', + witnessAmountSat: 5_000_000, + transportEndpoints: [], + }, + }); + await expect( + client(witness).prepareSendBtc({ address: ADDRESS, amountSat: 40_000 }), + ).rejects.toThrow(IntentMismatchError); + }); + + it('rejects a pinned fee rate the server did not honour', async () => { + await expect( + client(sendBtcIntent()).prepareSendBtc({ + address: ADDRESS, + amountSat: 40_000, + feeRateSatPerVb: 5, + }), + ).rejects.toThrow(IntentMismatchError); + }); + + it('leaves server-chosen defaults alone', async () => { + // feeRateSatPerVb was not pinned by the caller, so the server's 2 stands. + const prepared = await client(sendBtcIntent()).prepareSendBtc({ + address: ADDRESS, + amountSat: 40_000, + }); + expect(prepared.intent.feeRateSatPerVb).toBe(2); + }); + + it('rejects a send-asset intent that changes the asset, amount or recipient', async () => { + const base = { + opId: 'op-2', + psbt: 'cHNidP8=', + expiresAt: 1, + intent: { + kind: 'send_asset', + feeRateSatPerVb: 2, + recipients: [], + asset: { + assetId: 'rgb:good', + amount: 10, + recipientId: 'utxob:me', + witnessAmountSat: null, + transportEndpoints: ['http://proxy'], + }, + utxos: null, + }, + }; + const request = { assetId: 'rgb:good', amount: 10, recipientId: 'utxob:me' }; + await expect(client(base).prepareSendAsset(request)).resolves.toMatchObject({ opId: 'op-2' }); + + for (const patch of [ + { assetId: 'rgb:evil' }, + { amount: 1_000 }, + { recipientId: 'utxob:attacker' }, + { witnessAmountSat: 5_000_000 }, + ]) { + const tampered = { + ...base, + intent: { ...base.intent, asset: { ...base.intent.asset, ...patch } }, + }; + await expect(client(tampered).prepareSendAsset(request)).rejects.toThrow(IntentMismatchError); + } + const withRecipient = { + ...base, + intent: { + ...base.intent, + recipients: [{ address: ATTACKER, scriptHex: '0014cafe', amountSat: 100_000 }], + }, + }; + await expect(client(withRecipient).prepareSendAsset(request)).rejects.toThrow( + IntentMismatchError, + ); + }); + + it('rejects a create-utxos intent that changes a pinned shape', async () => { + const base = { + opId: 'op-3', + psbt: 'cHNidP8=', + expiresAt: 1, + intent: { + kind: 'create_utxos', + feeRateSatPerVb: 2, + recipients: [], + asset: null, + utxos: { upTo: false, num: 4, size: 1_000 }, + }, + }; + await expect(client(base).prepareCreateUtxos({ num: 4 })).resolves.toMatchObject({ + opId: 'op-3', + }); + await expect(client(base).prepareCreateUtxos({ num: 8 })).rejects.toThrow(IntentMismatchError); + await expect(client(base).prepareCreateUtxos({ size: 32_000 })).rejects.toThrow( + IntentMismatchError, + ); + }); + + it('rejects a prepare response with no intent at all', async () => { + await expect( + client({ opId: 'op-4', psbt: 'cHNidP8=', expiresAt: 1 }).prepareSendBtc({ + address: ADDRESS, + amountSat: 40_000, + }), + ).rejects.toThrow(IntentMismatchError); + }); + + it('rejects an intent prepared for a different flow', async () => { + await expect( + client(sendBtcIntent({ kind: 'create_utxos' })).prepareSendBtc({ + address: ADDRESS, + amountSat: 40_000, + }), + ).rejects.toThrow(IntentMismatchError); + }); +}); diff --git a/minimal-sdk/packages/client-sdk/test/helpers.ts b/minimal-sdk/packages/client-sdk/test/helpers.ts new file mode 100644 index 00000000..88d27870 --- /dev/null +++ b/minimal-sdk/packages/client-sdk/test/helpers.ts @@ -0,0 +1,159 @@ +/** + * Shared test helpers: rgb-lib parity fixture access and a PSBT builder for + * the adversarial verify-before-sign suite. The fixture mnemonics are TEST + * material only — mnemonics are never a gateway input (I1/I2). + */ +import { readFileSync } from 'node:fs'; +import { HDKey } from '@scure/bip32'; +import { mnemonicToSeedSync } from '@scure/bip39'; +import { base64, hex } from '@scure/base'; +import { Address, OutScript, p2tr, Transaction } from '@scure/btc-signer'; +import { addressNetwork, HARDENED } from '../src/network.js'; + +export interface ParityFixture { + mnemonic: string; + otherMnemonic: string; + networks: Record< + 'Regtest' | 'Testnet' | 'Signet' | 'Mainnet', + { + xpub: string; + accountXpubVanilla: string; + accountXpubColored: string; + masterFingerprint: string; + } + >; + regtest: { vanillaAddresses: string[]; coloredAddresses: string[] }; + signing: { unsignedPsbt: string; signedPsbt: string; txid: string; witnessSignature: string }; + witnessReceive: { invoice: string; recipientId: string; expirationTimestamp: number }; +} + +export const fixture: ParityFixture = JSON.parse( + readFileSync(new URL('./fixtures/rgblib-parity.json', import.meta.url), 'utf8'), +) as ParityFixture; + +export const REGTEST = addressNetwork('Regtest'); + +/** Vanilla-account path children on regtest: m/86'/1'/0'. */ +export const VANILLA_PATH = [HARDENED + 86, HARDENED + 1, HARDENED]; +/** Colored-account path children on regtest: m/86'/827167'/0'. */ +export const COLORED_PATH = [HARDENED + 86, HARDENED + 827167, HARDENED]; + +export interface TestWallet { + master: HDKey; + fingerprint: number; + keyAt(path: number[], keychain: number, index: number): HDKey; + scriptAt(path: number[], keychain: number, index: number): Uint8Array; + xOnlyAt(path: number[], keychain: number, index: number): Uint8Array; +} + +export function walletFromMnemonic(mnemonic: string): TestWallet { + const master = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)); + const keyAt = (path: number[], keychain: number, index: number): HDKey => + [...path, keychain, index].reduce((key, child) => key.deriveChild(child), master); + const xOnlyAt = (path: number[], keychain: number, index: number): Uint8Array => + (keyAt(path, keychain, index).publicKey as Uint8Array).slice(1); + return { + master, + fingerprint: master.fingerprint, + keyAt, + xOnlyAt, + scriptAt: (path, keychain, index) => + p2tr(xOnlyAt(path, keychain, index), undefined, REGTEST).script, + }; +} + +export const ours = walletFromMnemonic(fixture.mnemonic); +export const foreign = walletFromMnemonic(fixture.otherMnemonic); + +export interface PsbtInputSpec { + /** Script actually being spent. */ + script: Uint8Array; + amount: bigint; + /** Key-origin metadata to attach (attacker-controlled in the threat model). */ + derivation?: { fingerprint: number; path: number[]; xOnly: Uint8Array } | undefined; + /** + * Extra key-origin entries listed BEFORE `derivation`. A hostile server can + * order the map freely, so this is how a decoy entry is emulated. + */ + decoyDerivations?: { fingerprint: number; path: number[]; xOnly: Uint8Array }[] | undefined; + tapInternalKey?: Uint8Array | undefined; +} + +export interface PsbtOutputSpec { + script: Uint8Array; + amount: bigint; + derivation?: { fingerprint: number; path: number[]; xOnly: Uint8Array } | undefined; +} + +/** + * Build a base64 PSBT with full control over scripts and metadata. Pass + * `forgeInvalid` to bypass btc-signer's own construction-time sanity checks + * and forge PSBTs no honest library would emit (hostile-server emulation). + */ +export function buildPsbt( + inputs: PsbtInputSpec[], + outputs: PsbtOutputSpec[], + options?: { forgeInvalid?: boolean }, +): string { + const tx = new Transaction({ + allowUnknownOutputs: true, + ...(options?.forgeInvalid === true ? { disableScriptCheck: true } : {}), + }); + inputs.forEach((input, i) => { + tx.addInput({ + txid: hex.decode(`${(i + 1).toString(16).padStart(2, '0')}`.repeat(32).slice(0, 64)), + index: i, + witnessUtxo: { script: input.script, amount: input.amount }, + ...(input.tapInternalKey !== undefined ? { tapInternalKey: input.tapInternalKey } : {}), + ...(input.derivation !== undefined + ? { + tapBip32Derivation: [...(input.decoyDerivations ?? []), input.derivation].map( + (entry) => + [ + entry.xOnly, + { hashes: [], der: { fingerprint: entry.fingerprint, path: entry.path } }, + ] as [ + Uint8Array, + { hashes: Uint8Array[]; der: { fingerprint: number; path: number[] } }, + ], + ), + } + : {}), + }); + }); + outputs.forEach((output, i) => { + tx.addOutput({ script: output.script, amount: output.amount }); + if (output.derivation !== undefined) { + tx.updateOutput(i, { + tapInternalKey: output.derivation.xOnly, + tapBip32Derivation: [ + [ + output.derivation.xOnly, + { + hashes: [], + der: { fingerprint: output.derivation.fingerprint, path: output.derivation.path }, + }, + ], + ], + }); + } + }); + return base64.encode(tx.toPSBT()); +} + +/** Standard zero-value OP_RETURN output used across cases. */ +export const OPRET_SCRIPT = new Uint8Array([0x6a, 0x05, 1, 2, 3, 4, 5]); + +/** Regtest address for an output script (intents must carry a real address). */ +export function addressFor(script: Uint8Array): string { + return Address(addressNetwork('Regtest')).encode(OutScript.decode(script)); +} + +export function ownDerivation(keychain: number, index: number, colored = false) { + const path = colored ? COLORED_PATH : VANILLA_PATH; + return { + fingerprint: ours.fingerprint, + path: [...path, keychain, index], + xOnly: ours.xOnlyAt(path, keychain, index), + }; +} diff --git a/minimal-sdk/packages/client-sdk/test/invoice.test.ts b/minimal-sdk/packages/client-sdk/test/invoice.test.ts new file mode 100644 index 00000000..2b14f3c5 --- /dev/null +++ b/minimal-sdk/packages/client-sdk/test/invoice.test.ts @@ -0,0 +1,137 @@ +/** + * Invoice decoding: official BOLT11 spec vectors (lightning/bolts + * 11-payment-encoding.md, all signed by the spec's reference key) and RGB + * invoices in the rgb-invoicing v0.11 grammar, including the real invoice + * rgb-lib generated in the parity fixture. + */ +import { describe, expect, it } from 'vitest'; +import { decodeBolt11, decodeRgbInvoice, InvoiceDecodeError } from '../src/invoice.js'; +import { fixture } from './helpers.js'; + +const SPEC_PAYEE = '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad'; +const SPEC_HASH = '0001020304050607080900010203040506070809000102030405060708090102'; +const SPEC_SECRET = '1111111111111111111111111111111111111111111111111111111111111111'; + +const VECTOR_ANY_AMOUNT = + 'lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql'; +const VECTOR_COFFEE = + 'lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh'; +const VECTOR_UTF8 = + 'lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpu9qrsgqhtjpauu9ur7fw2thcl4y9vfvh4m9wlfyz2gem29g5ghe2aak2pm3ps8fdhtceqsaagty2vph7utlgj48u0ged6a337aewvraedendscp573dxr'; +const VECTOR_HASHED = + 'lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp7ynn44'; +const VECTOR_TESTNET = + 'lntb20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfpp3x9et2e20v6pu37c5d9vax37wxq72un989qrsgqdj545axuxtnfemtpwkc45hx9d2ft7x04mt8q7y6t0k2dge9e7h8kpy9p34ytyslj3yu569aalz2xdk8xkd7ltxqld94u8h2esmsmacgpghe9k8'; + +describe('decodeBolt11 (spec vectors)', () => { + it('decodes the any-amount donation invoice', () => { + const invoice = decodeBolt11(VECTOR_ANY_AMOUNT); + expect(invoice.network).toBe('Mainnet'); + expect(invoice.amountMsat).toBeNull(); + expect(invoice.paymentHash).toBe(SPEC_HASH); + expect(invoice.paymentSecret).toBe(SPEC_SECRET); + expect(invoice.description).toBe('Please consider supporting this project'); + expect(invoice.payeeNodeId).toBe(SPEC_PAYEE); + expect(invoice.timestamp).toBe(1496314658); + expect(invoice.expirySeconds).toBe(3600); + }); + + it('decodes the 2500u coffee invoice with 60s expiry', () => { + const invoice = decodeBolt11(VECTOR_COFFEE); + expect(invoice.amountMsat).toBe(250_000_000n); + expect(invoice.description).toBe('1 cup coffee'); + expect(invoice.expirySeconds).toBe(60); + expect(invoice.payeeNodeId).toBe(SPEC_PAYEE); + }); + + it('decodes a UTF-8 description', () => { + const invoice = decodeBolt11(VECTOR_UTF8); + expect(invoice.description).toBe('ナンセンス 1杯'); + expect(invoice.amountMsat).toBe(250_000_000n); + expect(invoice.payeeNodeId).toBe(SPEC_PAYEE); + }); + + it('decodes a hashed-description 20m invoice', () => { + const invoice = decodeBolt11(VECTOR_HASHED); + expect(invoice.amountMsat).toBe(2_000_000_000n); + expect(invoice.description).toBeNull(); + expect(invoice.descriptionHash).toBe( + '3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1', + ); + expect(invoice.payeeNodeId).toBe(SPEC_PAYEE); + }); + + it('decodes a testnet invoice (and ignores the fallback tag)', () => { + const invoice = decodeBolt11(VECTOR_TESTNET); + expect(invoice.network).toBe('Testnet'); + expect(invoice.amountMsat).toBe(2_000_000_000n); + expect(invoice.payeeNodeId).toBe(SPEC_PAYEE); + }); + + it('rejects a corrupted invoice (checksum)', () => { + const corrupted = `${VECTOR_COFFEE.slice(0, -1)}${VECTOR_COFFEE.endsWith('h') ? 'k' : 'h'}`; + expect(() => decodeBolt11(corrupted)).toThrow(InvoiceDecodeError); + }); + + it('rejects a non-lightning bech32 string', () => { + expect(() => decodeBolt11('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4')).toThrow( + InvoiceDecodeError, + ); + }); + + it('rejects truncated data', () => { + expect(() => decodeBolt11('lnbc1pvjluez')).toThrow(InvoiceDecodeError); + }); +}); + +describe('decodeRgbInvoice', () => { + it('decodes the real rgb-lib witness invoice from the parity fixture', () => { + const invoice = decodeRgbInvoice(fixture.witnessReceive.invoice); + expect(invoice.assetId).toBeNull(); + expect(invoice.schema).toBeNull(); + expect(invoice.amount).toBeNull(); + expect(invoice.recipientId).toBe(fixture.witnessReceive.recipientId); + expect(invoice.beneficiaryKind).toBe('witness'); + expect(invoice.chain).toBe('bcrt'); + expect(invoice.expiryTimestamp).toBe(fixture.witnessReceive.expirationTimestamp); + expect(invoice.transportEndpoints).toEqual(['rpc://localhost:3000/json-rpc']); + }); + + it('decodes an asset-specific blind invoice with amount and endpoints', () => { + const invoice = decodeRgbInvoice( + 'rgb:erRCLIhl-nBu1DdF-M8YCRNH-Y3rB0W3-hLZH1DP-1AY9zpQ/RGB20Fixed/100/bcrt:utxob:4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?expiry=1700000000&endpoints=rpc://proxy-a/json-rpc,rpcs://proxy-b/json-rpc', + ); + expect(invoice.assetId).toBe('rgb:erRCLIhl-nBu1DdF-M8YCRNH-Y3rB0W3-hLZH1DP-1AY9zpQ'); + expect(invoice.schema).toBe('RGB20Fixed'); + expect(invoice.amount).toBe(100n); + expect(invoice.beneficiaryKind).toBe('blind'); + expect(invoice.recipientId).toBe( + 'bcrt:utxob:4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa', + ); + expect(invoice.expiryTimestamp).toBe(1700000000); + expect(invoice.transportEndpoints).toEqual([ + 'rpc://proxy-a/json-rpc', + 'rpcs://proxy-b/json-rpc', + ]); + }); + + it('keeps non-numeric assignment state raw and parses assignment_name', () => { + const invoice = decodeRgbInvoice( + 'rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/~/BF/bc:utxob:4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?assignment_name=assetOwner', + ); + expect(invoice.amount).toBeNull(); + expect(invoice.assignmentRaw).toBe('BF'); + expect(invoice.assignmentName).toBe('assetOwner'); + expect(invoice.chain).toBe('bc'); + }); + + it('rejects malformed invoices', () => { + expect(() => decodeRgbInvoice('rgbx:~/~/~/bcrt:utxob:x')).toThrow(InvoiceDecodeError); + expect(() => decodeRgbInvoice('rgb:~/~/bcrt:utxob:x')).toThrow(InvoiceDecodeError); + expect(() => decodeRgbInvoice('rgb:~/~/~/')).toThrow(InvoiceDecodeError); + expect(() => decodeRgbInvoice('rgb:~/~/~/bcrt:utxob:x?expiry=soon')).toThrow( + InvoiceDecodeError, + ); + expect(() => decodeRgbInvoice('rgb:~/~/~/bcrt:utxob:x?flag')).toThrow(InvoiceDecodeError); + }); +}); diff --git a/minimal-sdk/packages/client-sdk/test/sign.test.ts b/minimal-sdk/packages/client-sdk/test/sign.test.ts new file mode 100644 index 00000000..218f84ae --- /dev/null +++ b/minimal-sdk/packages/client-sdk/test/sign.test.ts @@ -0,0 +1,208 @@ +/** + * Sign+finalize parity with rgb-lib: the SDK signs the SAME unsigned PSBT + * that rgb-lib's own BDK signer signed in the fixture, and must produce the + * same txid with a structurally identical (64-byte key-path schnorr) witness. + * Also proves sign.ts refuses to sign anything verification rejects. + */ +import { base64, hex } from '@scure/base'; +import { Transaction } from '@scure/btc-signer'; +import { describe, expect, it } from 'vitest'; +import { ClientKeys } from '../src/keys.js'; +import { verifyAndSignPsbt, VerificationFailedError } from '../src/sign.js'; +import { PSBT_PARSE_OPTIONS, type OnchainIntent } from '../src/verify.js'; +import { + addressFor, + buildPsbt, + fixture, + foreign, + OPRET_SCRIPT, + ours, + ownDerivation, + VANILLA_PATH, +} from './helpers.js'; + +const keys = ClientKeys.fromMnemonic(fixture.mnemonic, 'Regtest'); + +const fixtureIntent: OnchainIntent = { + kind: 'send_btc', + feeRateSatPerVb: 2, + recipients: [ + { + address: addressFor(foreign.scriptAt(VANILLA_PATH, 0, 0)), + scriptHex: hex.encode(foreign.scriptAt(VANILLA_PATH, 0, 0)), + amountSat: 40_000, + }, + ], + asset: null, + utxos: null, +}; + +describe('verifyAndSignPsbt', () => { + it('signs the rgb-lib fixture PSBT to the same txid as rgb-lib', () => { + const result = verifyAndSignPsbt(keys, fixture.signing.unsignedPsbt, { + intent: fixtureIntent, + xpubs: keys.xpubs, + maxFeeSat: 2_000, + }); + expect(result.verdict.ok).toBe(true); + expect(result.txid).toBe(fixture.signing.txid); + + const signed = Transaction.fromPSBT(base64.decode(result.signedPsbt), PSBT_PARSE_OPTIONS); + expect(signed.id).toBe(fixture.signing.txid); + expect(signed.isFinal).toBe(true); + const witness = signed.getInput(0).finalScriptWitness; + expect(witness).toHaveLength(1); + expect((witness as Uint8Array[])[0]).toHaveLength(64); + + // rgb-lib's own signed fixture agrees on the txid (witness bytes differ: + // BIP-340 signatures are randomized). + const rgbLibSigned = Transaction.fromPSBT( + base64.decode(fixture.signing.signedPsbt), + PSBT_PARSE_OPTIONS, + ); + expect(rgbLibSigned.id).toBe(fixture.signing.txid); + expect( + hex.encode((rgbLibSigned.getInput(0).finalScriptWitness as Uint8Array[])[0] as Uint8Array), + ).toBe(fixture.signing.witnessSignature); + }); + + it('refuses to sign when verification fails (wrong recipient amount)', () => { + const tamperedIntent: OnchainIntent = { + ...fixtureIntent, + recipients: [ + { ...(fixtureIntent.recipients[0] as OnchainIntent['recipients'][0]), amountSat: 39_000 }, + ], + }; + expect(() => + verifyAndSignPsbt(keys, fixture.signing.unsignedPsbt, { + intent: tamperedIntent, + xpubs: keys.xpubs, + maxFeeSat: 2_000, + }), + ).toThrow(VerificationFailedError); + }); + + it('refuses to sign over budget and reports the failed check', () => { + try { + verifyAndSignPsbt(keys, fixture.signing.unsignedPsbt, { + intent: fixtureIntent, + xpubs: keys.xpubs, + maxFeeSat: 500, + }); + expect.unreachable('should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(VerificationFailedError); + const failed = (error as VerificationFailedError).verdict.checks.filter((c) => !c.ok); + expect(failed.map((c) => c.check)).toEqual(['fee-budget']); + } + }); + + it('refuses to sign a foreign input even when the intent matches', () => { + const psbt = buildPsbt( + [ + { + script: foreign.scriptAt(VANILLA_PATH, 0, 0), + amount: 100_000n, + derivation: ownDerivation(0, 0), + }, + ], + [ + { script: foreign.scriptAt(VANILLA_PATH, 0, 0), amount: 40_000n }, + { + script: ours.scriptAt(VANILLA_PATH, 0, 1), + amount: 59_000n, + derivation: ownDerivation(0, 1), + }, + ], + ); + expect(() => + verifyAndSignPsbt(keys, psbt, { intent: fixtureIntent, xpubs: keys.xpubs, maxFeeSat: 2_000 }), + ).toThrow(VerificationFailedError); + }); + + it('signs through a decoy key-origin entry ordered before ours', () => { + // verify.ts selects the entry carrying OUR master fingerprint; sign.ts must + // select the same one. Picking by path SHAPE alone would take this decoy — + // a foreign fingerprint on a well-formed path — and signing would fail + // against a key no check ever covered. + const psbt = buildPsbt( + [ + { + script: ours.scriptAt(VANILLA_PATH, 0, 0), + amount: 100_000n, + derivation: ownDerivation(0, 0), + // Index 1 is chosen because its x-only key sorts BEFORE ours: PSBT + // key-origin entries are keyed by pubkey, so this is the entry a + // shape-only match would reach first. + decoyDerivations: [ + { + fingerprint: foreign.fingerprint, + path: [...VANILLA_PATH, 0, 1], + xOnly: foreign.xOnlyAt(VANILLA_PATH, 0, 1), + }, + ], + tapInternalKey: ours.xOnlyAt(VANILLA_PATH, 0, 0), + }, + ], + [ + { script: foreign.scriptAt(VANILLA_PATH, 0, 0), amount: 40_000n }, + { + script: ours.scriptAt(VANILLA_PATH, 0, 1), + amount: 59_000n, + derivation: ownDerivation(0, 1), + }, + { script: OPRET_SCRIPT, amount: 0n }, + ], + ); + const result = verifyAndSignPsbt(keys, psbt, { + intent: fixtureIntent, + xpubs: keys.xpubs, + maxFeeSat: 2_000, + }); + expect(result.verdict.ok).toBe(true); + const signed = Transaction.fromPSBT(base64.decode(result.signedPsbt), PSBT_PARSE_OPTIONS); + expect(signed.isFinal).toBe(true); + expect((signed.getInput(0).finalScriptWitness as Uint8Array[])[0]).toHaveLength(64); + }); + + it('refuses to sign an input carrying no key-origin entry of ours', () => { + const psbt = buildPsbt( + [ + { + script: ours.scriptAt(VANILLA_PATH, 0, 0), + amount: 100_000n, + derivation: { + fingerprint: foreign.fingerprint, + path: [...VANILLA_PATH, 0, 0], + xOnly: foreign.xOnlyAt(VANILLA_PATH, 0, 0), + }, + tapInternalKey: ours.xOnlyAt(VANILLA_PATH, 0, 0), + }, + ], + [ + { script: foreign.scriptAt(VANILLA_PATH, 0, 0), amount: 40_000n }, + { + script: ours.scriptAt(VANILLA_PATH, 0, 1), + amount: 59_000n, + derivation: ownDerivation(0, 1), + }, + { script: OPRET_SCRIPT, amount: 0n }, + ], + ); + // Fails at verification, never reaching the signer: the input is not + // provably ours. + expect(() => + verifyAndSignPsbt(keys, psbt, { intent: fixtureIntent, xpubs: keys.xpubs, maxFeeSat: 2_000 }), + ).toThrow(VerificationFailedError); + }); + + it('never signs adversarial garbage', () => { + expect(() => + verifyAndSignPsbt(keys, 'garbage-not-a-psbt', { + intent: fixtureIntent, + xpubs: keys.xpubs, + maxFeeSat: 2_000, + }), + ).toThrow(VerificationFailedError); + }); +}); diff --git a/minimal-sdk/packages/client-sdk/test/smoke.test.ts b/minimal-sdk/packages/client-sdk/test/smoke.test.ts new file mode 100644 index 00000000..ceae5a5c --- /dev/null +++ b/minimal-sdk/packages/client-sdk/test/smoke.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { SDK_NAME } from '../src/index.js'; + +describe('client-sdk package smoke', () => { + it('imports resolve and exports are present', () => { + expect(SDK_NAME).toBe('@utexo/minimal-client-sdk'); + }); +}); diff --git a/minimal-sdk/packages/client-sdk/test/verify.test.ts b/minimal-sdk/packages/client-sdk/test/verify.test.ts new file mode 100644 index 00000000..0b7aceb3 --- /dev/null +++ b/minimal-sdk/packages/client-sdk/test/verify.test.ts @@ -0,0 +1,583 @@ +/** + * Adversarial verify-before-sign suite: each of the design doc's 5 checks + * must catch its attack, and verifyPsbt must never throw on hostile input. + */ +import { hex } from '@scure/base'; +import { describe, expect, it } from 'vitest'; +import type { AccountXpubs } from '../src/keys.js'; +import { verifyPsbt, type OnchainIntent, type VerifyVerdict } from '../src/verify.js'; +import { + addressFor, + buildPsbt, + COLORED_PATH, + fixture, + foreign, + OPRET_SCRIPT, + ours, + ownDerivation, + VANILLA_PATH, +} from './helpers.js'; + +const xpubs: AccountXpubs = { + network: 'Regtest', + fingerprint: fixture.networks.Regtest.masterFingerprint, + vanilla: fixture.networks.Regtest.accountXpubVanilla, + colored: fixture.networks.Regtest.accountXpubColored, +}; + +const recipientScript = foreign.scriptAt(VANILLA_PATH, 0, 0); + +const sendBtcIntent: OnchainIntent = { + kind: 'send_btc', + feeRateSatPerVb: 2, + recipients: [ + { + address: addressFor(recipientScript), + scriptHex: hex.encode(recipientScript), + amountSat: 40_000, + }, + ], + asset: null, + utxos: null, +}; + +/** Happy-path shape: own input, intended recipient, own change, 0-sat opret. */ +function happyPsbt(overrides?: { + changeScript?: Uint8Array; + changeDerivation?: ReturnType | undefined; + changeAmount?: bigint; + opretAmount?: bigint; +}): string { + return buildPsbt( + [ + { + script: ours.scriptAt(VANILLA_PATH, 0, 0), + amount: 100_000n, + derivation: ownDerivation(0, 0), + tapInternalKey: ours.xOnlyAt(VANILLA_PATH, 0, 0), + }, + ], + [ + { script: recipientScript, amount: 40_000n }, + { + script: overrides?.changeScript ?? ours.scriptAt(VANILLA_PATH, 0, 1), + amount: overrides?.changeAmount ?? 59_000n, + derivation: + overrides !== undefined && 'changeDerivation' in overrides + ? overrides.changeDerivation + : ownDerivation(0, 1), + }, + { script: OPRET_SCRIPT, amount: overrides?.opretAmount ?? 0n }, + ], + ); +} + +function check(verdict: VerifyVerdict, name: string): { ok: boolean; detail: string | null } { + const result = verdict.checks.find((c) => c.check === name); + if (result === undefined) throw new Error(`missing check ${name}`); + return result; +} + +describe('verify-before-sign: happy paths', () => { + it('accepts a well-formed send-btc PSBT', () => { + const verdict = verifyPsbt({ + psbt: happyPsbt(), + intent: sendBtcIntent, + xpubs, + maxFeeSat: 2_000, + }); + expect(verdict.checks.filter((c) => !c.ok)).toEqual([]); + expect(verdict.ok).toBe(true); + expect(verdict.feeSat).toBe(1_000); + expect(verdict.txid).toMatch(/^[0-9a-f]{64}$/); + }); + + it('accepts colored-keychain inputs and change (asset send, blind)', () => { + const psbt = buildPsbt( + [ + { + script: ours.scriptAt(COLORED_PATH, 0, 0), + amount: 30_000n, + derivation: ownDerivation(0, 0, true), + }, + ], + [ + { + script: ours.scriptAt(COLORED_PATH, 0, 1), + amount: 29_000n, + derivation: ownDerivation(0, 1, true), + }, + { script: OPRET_SCRIPT, amount: 0n }, + ], + ); + const intent: OnchainIntent = { + kind: 'send_asset', + feeRateSatPerVb: 2, + recipients: [], + asset: { + assetId: 'rgb:fixture', + amount: 5, + recipientId: 'bcrt:utxob:fixture', + witnessAmountSat: null, + transportEndpoints: ['rpc://localhost:3000/json-rpc'], + }, + utxos: null, + }; + const verdict = verifyPsbt({ psbt, intent, xpubs, maxFeeSat: 2_000 }); + expect(verdict.checks.filter((c) => !c.ok)).toEqual([]); + }); + + it('accepts exactly one foreign witness output of the approved amount', () => { + const witnessIntent: OnchainIntent = { + kind: 'send_asset', + feeRateSatPerVb: 2, + recipients: [], + asset: { + assetId: 'rgb:fixture', + amount: 5, + recipientId: 'bcrt:wvout:fixture', + witnessAmountSat: 3_000, + transportEndpoints: [], + }, + utxos: null, + }; + const psbt = buildPsbt( + [ + { + script: ours.scriptAt(COLORED_PATH, 0, 0), + amount: 30_000n, + derivation: ownDerivation(0, 0, true), + }, + ], + [ + { script: foreign.scriptAt(VANILLA_PATH, 0, 3), amount: 3_000n }, + { + script: ours.scriptAt(COLORED_PATH, 0, 1), + amount: 26_000n, + derivation: ownDerivation(0, 1, true), + }, + { script: OPRET_SCRIPT, amount: 0n }, + ], + ); + const verdict = verifyPsbt({ psbt, intent: witnessIntent, xpubs, maxFeeSat: 2_000 }); + expect(verdict.checks.filter((c) => !c.ok)).toEqual([]); + + // The same PSBT under a BLIND intent must fail: no foreign output allowed. + const blindIntent: OnchainIntent = { + ...witnessIntent, + asset: { + ...(witnessIntent.asset as NonNullable), + witnessAmountSat: null, + }, + }; + const blindVerdict = verifyPsbt({ psbt, intent: blindIntent, xpubs, maxFeeSat: 2_000 }); + expect(check(blindVerdict, 'change-own').ok).toBe(false); + }); + + it('accepts metadata-proven change beyond the scan window at a sane index', () => { + const psbt = happyPsbt({ + changeScript: ours.scriptAt(VANILLA_PATH, 0, 40), + changeDerivation: ownDerivation(0, 40), + }); + const verdict = verifyPsbt({ psbt, intent: sendBtcIntent, xpubs, maxFeeSat: 2_000 }); + expect(verdict.checks.filter((c) => !c.ok)).toEqual([]); + }); + + it('accepts a create-utxos PSBT whose outputs are all own, and catches a tampered one', () => { + const utxosIntent: OnchainIntent = { + kind: 'create_utxos', + feeRateSatPerVb: 2, + recipients: [], + asset: null, + utxos: { upTo: false, num: 2, size: 1000 }, + }; + const ownOutputs = [ + { + script: ours.scriptAt(VANILLA_PATH, 0, 1), + amount: 1_000n, + derivation: ownDerivation(0, 1), + }, + { + script: ours.scriptAt(VANILLA_PATH, 0, 2), + amount: 1_000n, + derivation: ownDerivation(0, 2), + }, + { + script: ours.scriptAt(VANILLA_PATH, 0, 3), + amount: 97_000n, + derivation: ownDerivation(0, 3), + }, + ]; + const input = { + script: ours.scriptAt(VANILLA_PATH, 0, 0), + amount: 100_000n, + derivation: ownDerivation(0, 0), + }; + const good = verifyPsbt({ + psbt: buildPsbt([input], ownOutputs), + intent: utxosIntent, + xpubs, + maxFeeSat: 2_000, + }); + expect(good.checks.filter((c) => !c.ok)).toEqual([]); + + const tampered = verifyPsbt({ + psbt: buildPsbt( + [input], + [ + ...ownOutputs.slice(0, 2), + { script: foreign.scriptAt(VANILLA_PATH, 0, 3), amount: 97_000n }, + ], + ), + intent: utxosIntent, + xpubs, + maxFeeSat: 2_000, + }); + expect(check(tampered, 'change-own').ok).toBe(false); + }); + + it('accepts metadata-less own change via the scan window, refuses with window 0', () => { + const psbt = happyPsbt({ changeDerivation: undefined }); + const withScan = verifyPsbt({ psbt, intent: sendBtcIntent, xpubs, maxFeeSat: 2_000 }); + expect(withScan.ok).toBe(true); + const noScan = verifyPsbt({ + psbt, + intent: sendBtcIntent, + xpubs, + maxFeeSat: 2_000, + changeScanWindow: 0, + }); + expect(check(noScan, 'change-own').ok).toBe(false); + }); +}); + +describe('verify-before-sign: adversarial cases', () => { + it('check 1 — foreign input claiming our fingerprint fails re-derivation', () => { + const psbt = buildPsbt( + [ + { + // Attacker spends THEIR utxo but labels it with our fingerprint/path. + script: foreign.scriptAt(VANILLA_PATH, 0, 0), + amount: 100_000n, + derivation: ownDerivation(0, 0), + }, + ], + [ + { script: recipientScript, amount: 40_000n }, + { + script: ours.scriptAt(VANILLA_PATH, 0, 1), + amount: 59_000n, + derivation: ownDerivation(0, 1), + }, + ], + ); + const verdict = verifyPsbt({ psbt, intent: sendBtcIntent, xpubs, maxFeeSat: 2_000 }); + expect(check(verdict, 'inputs-own').ok).toBe(false); + expect(check(verdict, 'inputs-own').detail).toContain('does not re-derive'); + expect(verdict.ok).toBe(false); + }); + + it('check 1 — input without our key origin is foreign', () => { + const psbt = buildPsbt( + [ + { + script: ours.scriptAt(VANILLA_PATH, 0, 0), + amount: 100_000n, + derivation: { + fingerprint: foreign.fingerprint, + path: [...VANILLA_PATH, 0, 0], + xOnly: ours.xOnlyAt(VANILLA_PATH, 0, 0), + }, + }, + ], + [{ script: recipientScript, amount: 40_000n }], + ); + const verdict = verifyPsbt({ psbt, intent: sendBtcIntent, xpubs, maxFeeSat: 100_000 }); + expect(check(verdict, 'inputs-own').detail).toContain('foreign input'); + }); + + it('check 1 — forged tapInternalKey mismatch fails closed at parse', () => { + // btc-signer refuses to CONSTRUCT this PSBT (the key does not commit to + // the spent script), so it is forged with sanity checks disabled; the + // SDK's strict parse then rejects it and verification fails closed. + const psbt = buildPsbt( + [ + { + script: ours.scriptAt(VANILLA_PATH, 0, 0), + amount: 100_000n, + derivation: ownDerivation(0, 0), + tapInternalKey: ours.xOnlyAt(VANILLA_PATH, 0, 4), + }, + ], + [ + { script: recipientScript, amount: 40_000n }, + { + script: ours.scriptAt(VANILLA_PATH, 0, 1), + amount: 59_000n, + derivation: ownDerivation(0, 1), + }, + ], + { forgeInvalid: true }, + ); + const verdict = verifyPsbt({ psbt, intent: sendBtcIntent, xpubs, maxFeeSat: 2_000 }); + expect(verdict.ok).toBe(false); + expect(verdict.checks.every((c) => !c.ok)).toBe(true); + expect(check(verdict, 'inputs-own').detail).toContain('could not be safely parsed'); + }); + + it('check 2 — swapped recipient script is caught', () => { + const swapped = buildPsbt( + [ + { + script: ours.scriptAt(VANILLA_PATH, 0, 0), + amount: 100_000n, + derivation: ownDerivation(0, 0), + }, + ], + [ + // Attacker redirects the payment to a different script. + { script: foreign.scriptAt(VANILLA_PATH, 0, 4), amount: 40_000n }, + { + script: ours.scriptAt(VANILLA_PATH, 0, 1), + amount: 59_000n, + derivation: ownDerivation(0, 1), + }, + { script: OPRET_SCRIPT, amount: 0n }, + ], + ); + const verdict = verifyPsbt({ psbt: swapped, intent: sendBtcIntent, xpubs, maxFeeSat: 2_000 }); + expect(check(verdict, 'recipients-match').ok).toBe(false); + expect(verdict.ok).toBe(false); + }); + + it('check 2 — hostile scriptHex paired with the intended address is caught', () => { + // A compromised gateway shows the user the REAL recipient address but + // pairs it with an attacker script and a PSBT paying that script. The + // expected script must be re-derived from the address, never trusted. + const attackerScript = foreign.scriptAt(VANILLA_PATH, 0, 4); + const hostileIntent: OnchainIntent = { + ...sendBtcIntent, + recipients: [ + { + address: addressFor(recipientScript), + scriptHex: hex.encode(attackerScript), + amountSat: 40_000, + }, + ], + }; + const psbt = buildPsbt( + [ + { + script: ours.scriptAt(VANILLA_PATH, 0, 0), + amount: 100_000n, + derivation: ownDerivation(0, 0), + }, + ], + [ + { script: attackerScript, amount: 40_000n }, + { + script: ours.scriptAt(VANILLA_PATH, 0, 1), + amount: 59_000n, + derivation: ownDerivation(0, 1), + }, + { script: OPRET_SCRIPT, amount: 0n }, + ], + ); + const verdict = verifyPsbt({ psbt, intent: hostileIntent, xpubs, maxFeeSat: 2_000 }); + expect(check(verdict, 'recipients-match').ok).toBe(false); + expect(check(verdict, 'recipients-match').detail).toContain('does not match'); + expect(verdict.ok).toBe(false); + }); + + it('check 2 — an undecodable intent address fails instead of being trusted', () => { + const hostileIntent: OnchainIntent = { + ...sendBtcIntent, + recipients: [ + { + address: 'not-an-address', + scriptHex: hex.encode(recipientScript), + amountSat: 40_000, + }, + ], + }; + const verdict = verifyPsbt({ + psbt: happyPsbt(), + intent: hostileIntent, + xpubs, + maxFeeSat: 2_000, + }); + expect(check(verdict, 'recipients-match').ok).toBe(false); + expect(check(verdict, 'recipients-match').detail).toContain('does not decode'); + }); + + it('check 2 — recipient amount tampering is caught', () => { + const psbt = buildPsbt( + [ + { + script: ours.scriptAt(VANILLA_PATH, 0, 0), + amount: 100_000n, + derivation: ownDerivation(0, 0), + }, + ], + [ + { script: recipientScript, amount: 90_000n }, + { + script: ours.scriptAt(VANILLA_PATH, 0, 1), + amount: 9_000n, + derivation: ownDerivation(0, 1), + }, + ], + ); + const verdict = verifyPsbt({ psbt, intent: sendBtcIntent, xpubs, maxFeeSat: 2_000 }); + expect(check(verdict, 'recipients-match').ok).toBe(false); + }); + + it('check 3 — tampered change (foreign script under our metadata) is caught', () => { + const psbt = happyPsbt({ changeScript: foreign.scriptAt(VANILLA_PATH, 0, 1) }); + const verdict = verifyPsbt({ psbt, intent: sendBtcIntent, xpubs, maxFeeSat: 2_000 }); + expect(check(verdict, 'change-own').ok).toBe(false); + expect(verdict.ok).toBe(false); + }); + + it('check 3 — "change" steered outside the descriptor keychain is caught', () => { + // The script genuinely re-derives from the user's xpub at keychain 7, but + // no descriptor wallet scans keychain 7 — accepting it would let a + // hostile gateway strand the change where the user can never find it. + const psbt = happyPsbt({ + changeScript: ours.scriptAt(VANILLA_PATH, 7, 0), + changeDerivation: ownDerivation(7, 0), + }); + const verdict = verifyPsbt({ psbt, intent: sendBtcIntent, xpubs, maxFeeSat: 2_000 }); + expect(check(verdict, 'change-own').ok).toBe(false); + expect(verdict.ok).toBe(false); + }); + + it('check 3 — "change" at an unrecoverable index is caught', () => { + const psbt = happyPsbt({ + changeScript: ours.scriptAt(VANILLA_PATH, 0, 900_000_000), + changeDerivation: ownDerivation(0, 900_000_000), + }); + const verdict = verifyPsbt({ psbt, intent: sendBtcIntent, xpubs, maxFeeSat: 2_000 }); + expect(check(verdict, 'change-own').ok).toBe(false); + expect(verdict.ok).toBe(false); + }); + + it('check 2 — witness intent fails on zero or multiple candidate outputs', () => { + const witnessIntent: OnchainIntent = { + kind: 'send_asset', + feeRateSatPerVb: 2, + recipients: [], + asset: { + assetId: 'rgb:fixture', + amount: 5, + recipientId: 'bcrt:wvout:fixture', + witnessAmountSat: 3_000, + transportEndpoints: [], + }, + utxos: null, + }; + const input = { + script: ours.scriptAt(COLORED_PATH, 0, 0), + amount: 30_000n, + derivation: ownDerivation(0, 0, true), + }; + const change = { + script: ours.scriptAt(COLORED_PATH, 0, 1), + amount: 23_000n, + derivation: ownDerivation(0, 1, true), + }; + // Zero candidates: the only foreign output pays the WRONG amount. + const wrongAmount = verifyPsbt({ + psbt: buildPsbt( + [input], + [{ script: foreign.scriptAt(VANILLA_PATH, 0, 3), amount: 2_999n }, change], + ), + intent: witnessIntent, + xpubs, + maxFeeSat: 5_000, + }); + expect(check(wrongAmount, 'recipients-match').ok).toBe(false); + expect(check(wrongAmount, 'recipients-match').detail).toContain('found 0'); + + // Two candidates at the approved amount: ambiguous, must fail. + const twoCandidates = verifyPsbt({ + psbt: buildPsbt( + [input], + [ + { script: foreign.scriptAt(VANILLA_PATH, 0, 3), amount: 3_000n }, + { script: foreign.scriptAt(VANILLA_PATH, 0, 4), amount: 3_000n }, + { ...change, amount: 22_000n }, + ], + ), + intent: witnessIntent, + xpubs, + maxFeeSat: 5_000, + }); + expect(check(twoCandidates, 'recipients-match').ok).toBe(false); + expect(check(twoCandidates, 'recipients-match').detail).toContain('found 2'); + }); + + it('check 3 — extra undeclared output is caught', () => { + const psbt = buildPsbt( + [ + { + script: ours.scriptAt(VANILLA_PATH, 0, 0), + amount: 100_000n, + derivation: ownDerivation(0, 0), + }, + ], + [ + { script: recipientScript, amount: 40_000n }, + { + script: ours.scriptAt(VANILLA_PATH, 0, 1), + amount: 54_000n, + derivation: ownDerivation(0, 1), + }, + { script: foreign.scriptAt(VANILLA_PATH, 0, 2), amount: 5_000n }, + ], + ); + const verdict = verifyPsbt({ psbt, intent: sendBtcIntent, xpubs, maxFeeSat: 2_000 }); + expect(check(verdict, 'change-own').ok).toBe(false); + }); + + it('check 4 — inflated fee is caught', () => { + const psbt = happyPsbt({ changeAmount: 20_000n }); // fee jumps to 40_000 + const verdict = verifyPsbt({ psbt, intent: sendBtcIntent, xpubs, maxFeeSat: 2_000 }); + expect(check(verdict, 'fee-budget').ok).toBe(false); + expect(verdict.feeSat).toBe(40_000); + }); + + it('check 4 — zero or negative fee is rejected', () => { + const psbt = happyPsbt({ changeAmount: 60_000n }); // outputs == inputs + const verdict = verifyPsbt({ psbt, intent: sendBtcIntent, xpubs, maxFeeSat: 2_000 }); + expect(check(verdict, 'fee-budget').ok).toBe(false); + }); + + it('check 5 — OP_RETURN carrying sats is caught', () => { + const psbt = happyPsbt({ opretAmount: 500n, changeAmount: 58_500n }); + const verdict = verifyPsbt({ psbt, intent: sendBtcIntent, xpubs, maxFeeSat: 2_000 }); + expect(check(verdict, 'opret-zero').ok).toBe(false); + expect(check(verdict, 'opret-zero').detail).toContain('carries 500 sat'); + expect(verdict.ok).toBe(false); + }); +}); + +describe('verify-before-sign: adversarial parsing', () => { + it.each([ + ['not base64', '!!!!not-base64!!!!'], + ['random bytes', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='], + ['empty string', ''], + ['truncated psbt magic', 'cHNidP8='], + ])('never throws on %s', (_label, psbt) => { + const verdict = verifyPsbt({ psbt, intent: sendBtcIntent, xpubs, maxFeeSat: 2_000 }); + expect(verdict.ok).toBe(false); + expect(verdict.checks).toHaveLength(5); + expect(verdict.checks.every((c) => !c.ok)).toBe(true); + }); + + it('fails closed on an inputless transaction', () => { + const psbt = buildPsbt([], [{ script: recipientScript, amount: 40_000n }]); + const verdict = verifyPsbt({ psbt, intent: sendBtcIntent, xpubs, maxFeeSat: 2_000 }); + expect(check(verdict, 'inputs-own').ok).toBe(false); + }); +}); diff --git a/minimal-sdk/packages/client-sdk/tsconfig.json b/minimal-sdk/packages/client-sdk/tsconfig.json new file mode 100644 index 00000000..e519364d --- /dev/null +++ b/minimal-sdk/packages/client-sdk/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"] +} diff --git a/minimal-sdk/packages/e2e/README.md b/minimal-sdk/packages/e2e/README.md new file mode 100644 index 00000000..585d08ee --- /dev/null +++ b/minimal-sdk/packages/e2e/README.md @@ -0,0 +1,54 @@ +# @utexo/minimal-e2e + +End-to-end suite for the minimal SDK: a scripted user journey that talks to the +gateway ONLY through `@utexo/minimal-client-sdk` over a real HTTP port, while +the harness (`src/setup.ts`) drives the outside world — the regtest faucet and +a counterparty RLN node. + +## Required local infrastructure + +- Docker with the compose plugin (the repo's `compose.yaml` provides bitcoind, + electrs on `127.0.0.1:50001`, the RGB proxy on `:3000` and esplora on `:3002`). +- The RLN debug binary: `cargo build` from the repo root + (`target/debug/rgb-lightning-node`). +- Host ports `3000`, `3002`, `18443`, `50001` free (compose), plus `3201`, + `3202`, `9901`, `9902` (the two RLN nodes) and `8490` (the gateway). +- Node 20+ and pnpm (`pnpm -C minimal-sdk install`). + +## Running + +From the repo root: + +```sh +cargo build # once, for the RLN binary +pnpm -C minimal-sdk e2e # builds the workspace, then runs the suite +``` + +The suite is excluded from the default unit-test command +(`pnpm -C minimal-sdk test`); it only runs via the `e2e` script. + +By default the harness runs `ESPLORA=1 ./regtest.sh start` itself (which stops +and wipes any previous regtest state) and leaves the containers running +afterwards. Knobs: + +- `E2E_SKIP_STACK=1` — reuse an already-running regtest stack (must have been + started with `ESPLORA=1`). The two RLN nodes and the gateway are always + started fresh, with fresh data dirs and fresh client keys, so a reused chain + is fine. +- `E2E_STOP_STACK=1` — run `./regtest.sh stop` during teardown. + +## What the journey covers + +create user → client-side key generation → register xpubs → fund the vault from +the faucet → create colorable UTXOs (prepare → verify-before-sign → complete) → +receive an RGB asset from the counterparty → send part of it back on chain +(prepare → verify-before-sign → complete; the only coverage of verify-before-sign +against a real rgb-lib **colored** spend) → BTC deposit into the LN float → +pay a counterparty BOLT11 invoice (with an idempotent replay that must not +double-debit) → create an invoice and get paid → withdraw the float back to the +vault → exact ledger reconciliation. + +Invariants asserted throughout: every gateway response is scanned at runtime +for key-material field names and client mnemonics (I1), a second user sees none +of the first user's invoices/payments/transfers (I3), and the RLN admin-token +sentinel and operator token appear in no response and no gateway log line (I4). diff --git a/minimal-sdk/packages/e2e/package.json b/minimal-sdk/packages/e2e/package.json new file mode 100644 index 00000000..c7ac57a0 --- /dev/null +++ b/minimal-sdk/packages/e2e/package.json @@ -0,0 +1,21 @@ +{ + "name": "@utexo/minimal-e2e", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run --dir test", + "e2e": "vitest run --dir src" + }, + "dependencies": { + "@utexo/minimal-client-sdk": "workspace:*", + "@utexo/minimal-gateway": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20.19.0", + "fastify": "^5.6.0", + "typescript": "^5.9.0", + "vitest": "^3.2.0" + } +} diff --git a/minimal-sdk/packages/e2e/src/flow.test.ts b/minimal-sdk/packages/e2e/src/flow.test.ts new file mode 100644 index 00000000..692d701f --- /dev/null +++ b/minimal-sdk/packages/e2e/src/flow.test.ts @@ -0,0 +1,411 @@ +/** + * Scripted user journey over the full stack (regtest + shared RLN + + * counterparty RLN + gateway). The journey uses ONLY the client SDK against + * the gateway's real HTTP port — never RLN directly; the counterparty node + * and the faucet are driven by the harness as "the outside world". + * + * Trust invariants are asserted while the journey runs: + * - I1: every gateway response is scanned for key-material field names and + * for the client mnemonics (scanning fetch installed in every client). + * - I3: a second user cannot see the first user's invoices/payments/ + * transfers. + * - I4: the RLN admin-token sentinel and operator token appear in no + * response and no captured gateway log line. + * - A replayed pay with the same idempotency key does not double-debit. + * + * The send-asset leg is load-bearing beyond the journey itself: it is the only + * place verifyAndSignPsbt runs against a PSBT rgb-lib actually emitted for a + * COLORED spend (opret output, colored-account key origins, RGB change). + * + * Run with `pnpm -C minimal-sdk e2e` (see packages/e2e/README.md). + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + ClientKeys, + GatewayClient, + GatewayError, + generateIdempotencyKey, + verifyAndSignPsbt, + type PreparedOp, +} from '@utexo/minimal-client-sdk'; +import { + forbidInLogs, + forbidMnemonic, + GATEWAY_URL, + makeScanningFetch, + mine, + OPERATOR_TOKEN, + regtest, + retryUntil, + rln, + setupHarness, + sleep, + type Harness, +} from './setup.js'; + +const VAULT_FUND_SAT = 100_000_000; // 1 BTC from the faucet +const UTXO_NUM = 5; +const UTXO_SIZE_SAT = 5_000; +const RGB_RECEIVE_UNITS = 100; +const RGB_SEND_BACK_UNITS = 40; +const DEPOSIT_SAT = 200_000; +const DEPOSIT_MSAT = DEPOSIT_SAT * 1000; +const PAY_MSAT = 50_000_000; +const INVOICE_MSAT = 30_000_000; +const WITHDRAW_SAT = 180_000; // deposit - pay + invoice, in whole sats +const MAX_FEE_SAT = 2_000; +const FEE_RATE = 2; + +interface JourneyUser { + client: GatewayClient; + keys: ClientKeys; + userId: string; + token: string; +} + +let harness: Harness; +let userA: JourneyUser; +let userB: JourneyUser; +let vaultAddress: string; +let payHash: string; +let inboundHash: string; + +/** Sign a prepared op with the journey user's keys — verify-before-sign. */ +function sign(user: JourneyUser, prepared: PreparedOp): string { + const { signedPsbt, verdict } = verifyAndSignPsbt(user.keys, prepared.psbt, { + intent: prepared.intent, + xpubs: user.keys.xpubs, + maxFeeSat: MAX_FEE_SAT, + }); + expect(verdict.ok).toBe(true); + return signedPsbt; +} + +async function createJourneyUser(name: string): Promise { + const fetchFn = makeScanningFetch(harness.recorder); + const bootstrap = new GatewayClient({ baseUrl: GATEWAY_URL, fetchFn }); + const created = await bootstrap.createUser(OPERATOR_TOKEN); + forbidInLogs(harness.recorder, `${name}-token`, created.token); + const { mnemonic, keys } = ClientKeys.generate('Regtest'); + forbidMnemonic(harness.recorder, `${name}-mnemonic`, mnemonic); + return { + client: new GatewayClient({ baseUrl: GATEWAY_URL, token: created.token, fetchFn }), + keys, + userId: created.userId, + token: created.token, + }; +} + +describe('minimal-sdk e2e journey on regtest', () => { + beforeAll(async () => { + harness = await setupHarness(); + }, 900_000); + + afterAll(async () => { + await harness?.teardown(); + }, 60_000); + + it('creates a user, registers client-side xpubs, funds the vault', async () => { + userA = await createJourneyUser('user-a'); + const xpubs = userA.keys.xpubs; + const registered = await userA.client.registerXpubs({ + vanilla: xpubs.vanilla, + colored: xpubs.colored, + fingerprint: xpubs.fingerprint, + }); + expect(registered.fingerprint).toBe(xpubs.fingerprint); + vaultAddress = registered.address; + // The gateway derived the address from the registered xpubs; the client + // must re-derive the identical script (watch-only parity). + expect(vaultAddress).toMatch(/^bcrt1p/); + + regtest(`sendtoaddress ${vaultAddress} 1`); + mine(1); + await retryUntil( + 'vault funded', + async () => { + await userA.client.sync(); + const balances = await userA.client.getBalances(); + return balances.btc.vanilla.settled >= VAULT_FUND_SAT ? true : undefined; + }, + 90_000, + ); + }, 180_000); + + it('creates colorable UTXOs via prepare -> verify -> sign -> complete', async () => { + const prepared = await userA.client.prepareCreateUtxos({ + num: UTXO_NUM, + size: UTXO_SIZE_SAT, + feeRateSatPerVb: FEE_RATE, + }); + expect(prepared.intent.kind).toBe('create_utxos'); + const completed = await userA.client.completeCreateUtxos({ + opId: prepared.opId, + signedPsbt: sign(userA, prepared), + }); + expect(completed.utxosCreated).toBe(UTXO_NUM); + mine(1); + await retryUntil( + 'colorable utxos settled', + async () => { + await userA.client.sync(); + const { unspents } = await userA.client.getUnspents(); + return unspents.filter((u) => u.colorable).length >= UTXO_NUM ? true : undefined; + }, + 90_000, + ); + }, 180_000); + + it('receives an RGB asset into the vault from the counterparty', async () => { + const receive = await userA.client.receive({ mode: 'blind' }); + expect(receive.invoice.length).toBeGreaterThan(20); + + await rln(harness.cp.base, '/sendrgb', { + donation: false, + fee_rate: FEE_RATE, + min_confirmations: 1, + recipient_map: { + [harness.assetId]: [ + { + recipient_id: receive.recipientId, + assignment: { type: 'Fungible', value: RGB_RECEIVE_UNITS }, + transport_endpoints: ['rpc://127.0.0.1:3000/json-rpc'], + }, + ], + }, + }); + + // Settlement dance (donation=false): receiver ACKs via refresh, the + // sender's refresh broadcasts, one confirmation settles both sides. + await retryUntil( + 'RGB asset settled in the vault', + async () => { + await userA.client.sync(); + await rln(harness.cp.base, '/refreshtransfers', { filter: [], skip_sync: false }); + const balances = await userA.client.getBalances(); + const asset = balances.assets.find((entry) => entry.assetId === harness.assetId); + if (asset !== undefined && asset.balance.settled >= RGB_RECEIVE_UNITS) return true; + mine(1); + return undefined; + }, + 180_000, + 2000, + ); + + const { transfers } = await userA.client.getTransfers(harness.assetId); + expect(transfers.some((t) => t.status === 'Settled' && t.amount === RGB_RECEIVE_UNITS)).toBe( + true, + ); + }, 240_000); + + it('sends the RGB asset back on chain via prepare -> verify -> sign -> complete', async () => { + // The only path that runs verify-before-sign against a real rgb-lib + // COLORED spend: the opret commitment output, the colored-account key + // origins and the RGB change output all have to satisfy verifyPsbt, which + // hand-built fixtures cannot prove. + const back = await rln<{ recipient_id: string }>(harness.cp.base, '/rgbinvoice', { + min_confirmations: 1, + asset_id: harness.assetId, + assignment: { type: 'Fungible', value: RGB_SEND_BACK_UNITS }, + witness: false, + transport_endpoints: ['rpc://127.0.0.1:3000/json-rpc'], + }); + + const prepared = await userA.client.prepareSendAsset( + { + assetId: harness.assetId, + amount: RGB_SEND_BACK_UNITS, + recipientId: back.recipient_id, + feeRateSatPerVb: FEE_RATE, + }, + generateIdempotencyKey(), + ); + expect(prepared.intent.kind).toBe('send_asset'); + expect(prepared.intent.asset?.amount).toBe(RGB_SEND_BACK_UNITS); + + const completed = await userA.client.completeSendAsset( + { opId: prepared.opId, signedPsbt: sign(userA, prepared) }, + generateIdempotencyKey(), + ); + expect(completed.txid).toMatch(/^[0-9a-f]{64}$/); + + await retryUntil( + 'RGB send settled on both sides', + async () => { + await userA.client.sync(); + await rln(harness.cp.base, '/refreshtransfers', { filter: [], skip_sync: false }); + const balances = await userA.client.getBalances(); + const asset = balances.assets.find((entry) => entry.assetId === harness.assetId); + if ( + asset !== undefined && + asset.balance.settled === RGB_RECEIVE_UNITS - RGB_SEND_BACK_UNITS + ) { + return true; + } + mine(1); + return undefined; + }, + 180_000, + 2000, + ); + }, 240_000); + + it('deposits BTC from the vault into the LN float', async () => { + const deposit = await userA.client.prepareLnDeposit({ kind: 'btc', amountMsat: DEPOSIT_MSAT }); + expect(deposit.kind).toBe('btc'); + const target = deposit.address as string; + expect(target).toMatch(/^bcrt1/); + + const prepared = await userA.client.prepareSendBtc({ + address: target, + amountSat: DEPOSIT_SAT, + feeRateSatPerVb: FEE_RATE, + }); + expect(prepared.intent.recipients[0]?.amountSat).toBe(DEPOSIT_SAT); + const completed = await userA.client.completeSendBtc({ + opId: prepared.opId, + signedPsbt: sign(userA, prepared), + }); + expect(completed.txid).toMatch(/^[0-9a-f]{64}$/); + mine(1); + + await retryUntil( + 'deposit credited to the float ledger', + async () => { + const balance = await userA.client.getLnBalance(); + return balance.btcMsat >= DEPOSIT_MSAT ? true : undefined; + }, + 90_000, + ); + const balance = await userA.client.getLnBalance(); + expect(balance.btcMsat).toBe(DEPOSIT_MSAT); + }, 180_000); + + it('pays a BOLT11 invoice from the counterparty; replay does not double-debit', async () => { + const cpInvoice = await rln<{ invoice: string }>(harness.cp.base, '/lninvoice', { + amt_msat: PAY_MSAT, + expiry_sec: 3600, + asset_id: null, + asset_amount: null, + }); + + const key = generateIdempotencyKey(); + const paid = await userA.client.payLnInvoice({ invoice: cpInvoice.invoice }, key); + expect(paid.status).not.toBe('failed'); + payHash = paid.paymentHash; + + // Same idempotency key + same body: cached response, no second debit. + const replayed = await userA.client.payLnInvoice({ invoice: cpInvoice.invoice }, key); + expect(replayed.paymentHash).toBe(payHash); + + await retryUntil( + 'counterparty saw the payment settle', + async () => { + const { status } = await rln<{ status: string }>(harness.cp.base, '/invoicestatus', { + invoice: cpInvoice.invoice, + }); + return status === 'Succeeded' ? true : undefined; + }, + 90_000, + ); + await retryUntil( + 'gateway reconciled the outbound payment', + async () => { + const { payments } = await userA.client.listLnPayments(); + const entry = payments.find((p) => p.paymentHash === payHash); + return entry?.status === 'succeeded' && entry.direction === 'outbound' ? true : undefined; + }, + 90_000, + ); + // Debited exactly once, despite the replay. + const balance = await userA.client.getLnBalance(); + expect(balance.btcMsat).toBe(DEPOSIT_MSAT - PAY_MSAT); + }, 180_000); + + it('creates an invoice and is paid by the counterparty', async () => { + const created = await userA.client.createLnInvoice({ amtMsat: INVOICE_MSAT }); + inboundHash = created.paymentHash; + + await rln(harness.cp.base, '/sendpayment', { invoice: created.invoice }); + + await retryUntil( + 'inbound invoice settled and credited', + async () => { + const info = await userA.client.getLnInvoice(inboundHash); + return info.state === 'settled' ? true : undefined; + }, + 90_000, + ); + const balance = await userA.client.getLnBalance(); + expect(balance.btcMsat).toBe(DEPOSIT_MSAT - PAY_MSAT + INVOICE_MSAT); + }, 180_000); + + it('scopes user A resources away from user B (I3)', async () => { + userB = await createJourneyUser('user-b'); + + const foreignInvoice = await userB.client.getLnInvoice(inboundHash).catch((e: unknown) => e); + expect(foreignInvoice).toBeInstanceOf(GatewayError); + expect((foreignInvoice as GatewayError).status).toBe(404); + + const { payments } = await userB.client.listLnPayments(); + expect(payments).toEqual([]); + + const balance = await userB.client.getLnBalance(); + expect(balance.btcMsat).toBe(0); + expect(balance.assets).toEqual({}); + + // B's own vault is empty: A's transfers/unspents are simply not there. + const xpubs = userB.keys.xpubs; + await userB.client.registerXpubs({ + vanilla: xpubs.vanilla, + colored: xpubs.colored, + fingerprint: xpubs.fingerprint, + }); + expect((await userB.client.getTransfers()).transfers).toEqual([]); + expect((await userB.client.getUnspents()).unspents).toEqual([]); + const balances = await userB.client.getBalances(); + expect(balances.btc.vanilla.settled).toBe(0); + expect(balances.assets).toEqual([]); + }, 120_000); + + it('withdraws the remaining float back to the vault; balances reconcile', async () => { + const before = await userA.client.getLnBalance(); + expect(before.btcMsat).toBe(WITHDRAW_SAT * 1000); + const vaultBefore = (await userA.client.getBalances()).btc.vanilla.settled; + + const withdrawn = await userA.client.withdrawLn({ + kind: 'btc', + address: vaultAddress, + amountSat: WITHDRAW_SAT, + feeRateSatPerVb: FEE_RATE, + }); + expect(withdrawn.txid).toMatch(/^[0-9a-f]{64}$/); + mine(1); + + await retryUntil( + 'withdrawal arrived in the vault', + async () => { + await userA.client.sync(); + const balances = await userA.client.getBalances(); + return balances.btc.vanilla.settled >= vaultBefore + WITHDRAW_SAT ? true : undefined; + }, + 90_000, + ); + + // Full reconciliation: deposit - pay + invoice - withdraw == 0. + const after = await userA.client.getLnBalance(); + expect(after.btcMsat).toBe(DEPOSIT_MSAT - PAY_MSAT + INVOICE_MSAT - WITHDRAW_SAT * 1000); + expect(after.btcMsat).toBe(0); + }, 180_000); + + it('never leaked key material or credentials (I1/I4)', async () => { + // Let in-flight worker log lines land before scanning. + await sleep(1500); + expect(harness.recorder.scanned).toBeGreaterThan(20); + expect(harness.recorder.violations).toEqual([]); + for (const [name, secret] of harness.recorder.logForbidden) { + const hit = harness.recorder.logs.find((line) => line.includes(secret)); + expect(hit, `secret "${name}" leaked into gateway logs`).toBeUndefined(); + } + }, 30_000); +}); diff --git a/minimal-sdk/packages/e2e/src/index.ts b/minimal-sdk/packages/e2e/src/index.ts new file mode 100644 index 00000000..eb1ed498 --- /dev/null +++ b/minimal-sdk/packages/e2e/src/index.ts @@ -0,0 +1,8 @@ +/** + * @utexo/minimal-e2e — end-to-end suite against the repo's regtest stack. + * + * Not part of the default unit-test run; execute with `pnpm -C minimal-sdk e2e` + * (see this package's README for the required local infrastructure). The + * harness lives in setup.ts, the scripted user journey in flow.test.ts. + */ +export const E2E_NAME = '@utexo/minimal-e2e'; diff --git a/minimal-sdk/packages/e2e/src/setup.ts b/minimal-sdk/packages/e2e/src/setup.ts new file mode 100644 index 00000000..1383aef6 --- /dev/null +++ b/minimal-sdk/packages/e2e/src/setup.ts @@ -0,0 +1,448 @@ +/** + * E2e harness: brings up the repo's regtest stack (bitcoind + electrs + RGB + * proxy + esplora), two RLN nodes (the shared node the gateway fronts and a + * counterparty), opens a plain-BTC channel between them and starts the gateway + * in-process on a real HTTP port. + * + * The user JOURNEY (flow.test.ts) talks ONLY to the gateway through the client + * SDK. This harness may drive the counterparty RLN node and the regtest faucet + * directly — that models the outside world, not the user. + * + * Env knobs (see README): E2E_SKIP_STACK=1 reuses an already-running regtest + * stack, E2E_STOP_STACK=1 tears the containers down afterwards. + */ +import { execSync, spawn, type ChildProcess } from 'node:child_process'; +import { mkdtempSync, openSync, closeSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { FastifyInstance } from 'fastify'; +import { buildServer, findKeyMaterialInValue, loadConfig } from '@utexo/minimal-gateway'; + +export const REPO_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)); +const RLN_BINARY = join(REPO_ROOT, 'target', 'debug', 'rgb-lightning-node'); + +export const ELECTRUM_URL = '127.0.0.1:50001'; +export const ESPLORA_URL = 'http://127.0.0.1:3002'; +export const PROXY_URL = 'rpc://127.0.0.1:3000/json-rpc'; + +export const SHARED_RLN_PORT = 3201; +export const SHARED_PEER_PORT = 9901; +export const CP_RLN_PORT = 3202; +export const CP_PEER_PORT = 9902; +export const GATEWAY_PORT = 8490; + +export const SHARED_RLN_URL = `http://127.0.0.1:${SHARED_RLN_PORT}`; +export const CP_RLN_URL = `http://127.0.0.1:${CP_RLN_PORT}`; +export const GATEWAY_URL = `http://127.0.0.1:${GATEWAY_PORT}`; + +export const OPERATOR_TOKEN = 'e2e-operator-token-0123456789abcdef'; +/** + * Sentinel credential (I4): RLN runs with --disable-authentication and ignores + * the Authorization header, so the gateway can carry a known sentinel that + * must never surface in any gateway response or log. + */ +export const RLN_ADMIN_TOKEN_SENTINEL = 'e2e-rln-admin-token-sentinel-do-not-leak'; + +const NODE_PASSWORD = 'e2epassword'; +const CHANNEL_CAPACITY_SAT = 1_000_000; +const CHANNEL_PUSH_MSAT = 300_000_000; + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function regtest(command: string): string { + return execSync(`./regtest.sh ${command}`, { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +export function mine(blocks: number): void { + regtest(`mine ${blocks}`); +} + +export async function retryUntil( + what: string, + probe: () => Promise, + timeoutMs: number, + intervalMs = 1000, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const value = await probe(); + if (value !== undefined) return value; + if (Date.now() > deadline) throw new Error(`timed out waiting for: ${what}`); + await sleep(intervalMs); + } +} + +/** + * Raw RLN call for HARNESS use only (faucet/counterparty side of the world). + * GET when body is undefined, POST otherwise. + */ +export async function rln(base: string, path: string, body?: unknown): Promise { + const response = await fetch(`${base}${path}`, { + method: body === undefined ? 'GET' : 'POST', + ...(body === undefined + ? {} + : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`RLN ${base}${path} -> ${response.status}: ${text}`); + } + return JSON.parse(text) as T; +} + +/** Every secret that must never appear in a gateway response or log line. */ +export interface InvariantRecorder { + /** Violations found while scanning gateway responses; must stay empty. */ + violations: string[]; + /** Number of gateway responses scanned (sanity: the scan actually ran). */ + scanned: number; + /** name -> secret scanned against every RESPONSE body. */ + responseForbidden: Map; + /** name -> secret scanned against captured gateway LOGS. */ + logForbidden: Map; + /** Captured gateway log lines. */ + logs: string[]; +} + +export function makeRecorder(): InvariantRecorder { + const recorder: InvariantRecorder = { + violations: [], + scanned: 0, + responseForbidden: new Map(), + logForbidden: new Map(), + logs: [], + }; + recorder.responseForbidden.set('rln-admin-token', RLN_ADMIN_TOKEN_SENTINEL); + recorder.responseForbidden.set('operator-token', OPERATOR_TOKEN); + recorder.logForbidden.set('rln-admin-token', RLN_ADMIN_TOKEN_SENTINEL); + recorder.logForbidden.set('operator-token', OPERATOR_TOKEN); + return recorder; +} + +/** Register a client-side mnemonic: forbidden in every response and log. */ +export function forbidMnemonic(recorder: InvariantRecorder, name: string, mnemonic: string): void { + recorder.responseForbidden.set(name, mnemonic); + recorder.logForbidden.set(name, mnemonic); +} + +/** User bearer tokens are returned ONCE by createUser but must never be logged. */ +export function forbidInLogs(recorder: InvariantRecorder, name: string, secret: string): void { + recorder.logForbidden.set(name, secret); +} + +/** + * fetch wrapper for ALL journey clients: scans every gateway response for + * key-material field names (mirrors the I1 schema-scan test at runtime) and + * for forbidden secret values (I4). + */ +export function makeScanningFetch(recorder: InvariantRecorder): typeof fetch { + const scanningFetch = (async (input: string | URL | Request, init?: RequestInit) => { + const response = await fetch(input as string | URL | Request, init); + const url = String(input); + let text = ''; + try { + text = await response.clone().text(); + } catch { + return response; + } + recorder.scanned += 1; + for (const [name, secret] of recorder.responseForbidden) { + if (secret !== '' && text.includes(secret)) { + recorder.violations.push(`secret "${name}" found in response of ${url}`); + } + } + try { + const parsed: unknown = JSON.parse(text); + for (const path of findKeyMaterialInValue(parsed)) { + recorder.violations.push(`key-material field ${path} in response of ${url}`); + } + } catch { + // non-JSON response: substring scan above is all we can do + } + return response; + }) as typeof fetch; + return scanningFetch; +} + +export interface RlnNode { + name: string; + base: string; + peerPort: number; + dataDir: string; + process: ChildProcess; + pubkey: string; +} + +async function startRlnNode(name: string, daemonPort: number, peerPort: number): Promise { + const dataDir = mkdtempSync(join(tmpdir(), `utexo-e2e-${name}-`)); + const logFd = openSync(join(dataDir, 'rln.log'), 'w'); + const child = spawn( + RLN_BINARY, + [ + dataDir, + '--daemon-listening-port', + String(daemonPort), + '--ldk-peer-listening-port', + String(peerPort), + '--network', + 'regtest', + '--disable-authentication', + ], + { stdio: ['ignore', logFd, logFd] }, + ); + closeSync(logFd); + const base = `http://127.0.0.1:${daemonPort}`; + + await retryUntil( + `${name} RLN HTTP up`, + async () => { + try { + await fetch(`${base}/nodeinfo`); + return true; + } catch { + return undefined; + } + }, + 60_000, + 500, + ); + + await rln(base, '/init', { password: NODE_PASSWORD }); + await rln(base, '/unlock', { + password: NODE_PASSWORD, + ldk_chain_sync: { + mode: 'BlockSync', + config: { + bitcoind_rpc_username: 'user', + bitcoind_rpc_password: 'password', + bitcoind_rpc_host: 'localhost', + bitcoind_rpc_port: 18443, + }, + }, + indexer_url: ELECTRUM_URL, + proxy_endpoint: PROXY_URL, + announce_addresses: [], + }); + const info = await retryUntil( + `${name} RLN unlocked`, + async () => { + try { + return await rln<{ pubkey: string }>(base, '/nodeinfo', undefined); + } catch { + return undefined; + } + }, + 60_000, + ); + return { name, base, peerPort, dataDir, process: child, pubkey: info.pubkey }; +} + +async function fundNode(node: RlnNode, amountBtc: string, targetSat: number): Promise { + const { address } = await rln<{ address: string }>(node.base, '/address', {}); + regtest(`sendtoaddress ${address} ${amountBtc}`); + mine(1); + await retryUntil( + `${node.name} on-chain funds`, + async () => { + const balance = await rln<{ vanilla: { settled: number } }>(node.base, '/btcbalance', { + skip_sync: false, + }); + return balance.vanilla.settled >= targetSat ? true : undefined; + }, + 60_000, + ); +} + +interface ChannelEntry { + ready: boolean; + is_usable: boolean; + peer_pubkey: string; +} + +async function openBtcChannel(shared: RlnNode, cp: RlnNode): Promise { + const peerUri = `${cp.pubkey}@127.0.0.1:${cp.peerPort}`; + await rln(shared.base, '/connectpeer', { peer_pubkey_and_addr: peerUri }); + await rln(shared.base, '/openchannel', { + peer_pubkey_and_opt_addr: peerUri, + capacity_sat: CHANNEL_CAPACITY_SAT, + push_msat: CHANNEL_PUSH_MSAT, + public: false, + with_anchors: true, + }); + await retryUntil( + 'channel usable on both nodes', + async () => { + mine(1); + const [a, b] = await Promise.all([ + rln<{ channels: ChannelEntry[] }>(shared.base, '/listchannels', undefined), + rln<{ channels: ChannelEntry[] }>(cp.base, '/listchannels', undefined), + ]); + const usable = (channels: ChannelEntry[], peer: string) => + channels.some((c) => c.peer_pubkey === peer && c.ready && c.is_usable); + return usable(a.channels, cp.pubkey) && usable(b.channels, shared.pubkey) ? true : undefined; + }, + 180_000, + 2000, + ); +} + +async function issueCounterpartyAsset(cp: RlnNode): Promise { + await rln(cp.base, '/createutxos', { + up_to: false, + num: 4, + size: 32_500, + fee_rate: 2, + skip_sync: false, + }); + mine(1); + const issued = await rln<{ asset: { asset_id: string } }>(cp.base, '/issueassetnia', { + ticker: 'E2E', + name: 'E2E Asset', + precision: 0, + amounts: [1000], + }); + return issued.asset.asset_id; +} + +export interface Harness { + shared: RlnNode; + cp: RlnNode; + app: FastifyInstance; + recorder: InvariantRecorder; + /** NIA asset issued on the counterparty, ready to be sent to the vault. */ + assetId: string; + teardown(): Promise; +} + +async function startStack(): Promise { + if (process.env['E2E_SKIP_STACK'] === '1') { + return; + } + execSync('ESPLORA=1 ./regtest.sh start', { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, ESPLORA: '1' }, + }); +} + +async function startGateway( + recorder: InvariantRecorder, + tempDirs: string[], +): Promise { + const dbDir = mkdtempSync(join(tmpdir(), 'utexo-e2e-gw-db-')); + const walletsDir = mkdtempSync(join(tmpdir(), 'utexo-e2e-gw-wallets-')); + tempDirs.push(dbDir, walletsDir); + const config = loadConfig({ + GATEWAY_PORT: String(GATEWAY_PORT), + RLN_URL: SHARED_RLN_URL, + RLN_ADMIN_TOKEN: RLN_ADMIN_TOKEN_SENTINEL, + ESPLORA_URL, + RGB_PROXY_URL: PROXY_URL, + GATEWAY_SQLITE_PATH: join(dbDir, 'gateway.sqlite'), + GATEWAY_WALLETS_DIR: walletsDir, + GATEWAY_WALLET_INDEXER_URL: ELECTRUM_URL, + GATEWAY_OPERATOR_TOKEN: OPERATOR_TOKEN, + GATEWAY_DEPOSIT_MIN_CONFIRMATIONS: '1', + GATEWAY_DEPOSITS_INTERVAL_MS: '1000', + GATEWAY_RECONCILER_INTERVAL_MS: '1000', + }); + const app = await buildServer({ + config, + loggerStream: { + write(msg: string) { + recorder.logs.push(msg); + }, + }, + startWorkers: true, + }); + await app.listen({ host: '127.0.0.1', port: GATEWAY_PORT }); + return app; +} + +function stopProcess(child: ChildProcess): Promise { + return new Promise((resolve) => { + if (child.exitCode !== null || child.killed) { + resolve(); + return; + } + const killTimer = setTimeout(() => { + child.kill('SIGKILL'); + }, 5000); + child.once('exit', () => { + clearTimeout(killTimer); + resolve(); + }); + child.kill('SIGTERM'); + }); +} + +export async function setupHarness(): Promise { + await startStack(); + const recorder = makeRecorder(); + + // Anything already started must be torn down if a later step throws: + // beforeAll never returns a harness in that case, so the test file's + // afterAll cannot clean up, and orphaned daemons hold the fixed ports — + // the next run would then fail on a bind error instead of the real cause. + const started: RlnNode[] = []; + const tempDirs: string[] = []; + let app: FastifyInstance | undefined; + const cleanup = async (): Promise => { + await app?.close(); + await Promise.all(started.map((node) => stopProcess(node.process))); + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + }; + + try { + // Node data dirs are deliberately NOT registered for removal: they hold + // rln.log, which is the first thing to read after a failed run. + const shared = await startRlnNode('shared', SHARED_RLN_PORT, SHARED_PEER_PORT); + started.push(shared); + const cp = await startRlnNode('cp', CP_RLN_PORT, CP_PEER_PORT); + started.push(cp); + + // The shared node funds the channel + withdraw fees; the counterparty funds + // colored UTXOs for issuance and its side of on-chain fees. + await fundNode(shared, '0.5', 45_000_000); + await fundNode(cp, '0.5', 45_000_000); + await rln(shared.base, '/createutxos', { + up_to: false, + num: 4, + size: 32_500, + fee_rate: 2, + skip_sync: false, + }); + mine(1); + const assetId = await issueCounterpartyAsset(cp); + await openBtcChannel(shared, cp); + + app = await startGateway(recorder, tempDirs); + + return { + shared, + cp, + app, + recorder, + assetId, + async teardown() { + await cleanup(); + if (process.env['E2E_STOP_STACK'] === '1') { + regtest('stop'); + } + }, + }; + } catch (error) { + await cleanup(); + throw error; + } +} diff --git a/minimal-sdk/packages/e2e/test/smoke.test.ts b/minimal-sdk/packages/e2e/test/smoke.test.ts new file mode 100644 index 00000000..4a145ae9 --- /dev/null +++ b/minimal-sdk/packages/e2e/test/smoke.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { E2E_NAME } from '../src/index.js'; + +describe('e2e package smoke', () => { + it('imports resolve and exports are present', () => { + expect(E2E_NAME).toBe('@utexo/minimal-e2e'); + }); +}); diff --git a/minimal-sdk/packages/e2e/tsconfig.json b/minimal-sdk/packages/e2e/tsconfig.json new file mode 100644 index 00000000..3fbff51d --- /dev/null +++ b/minimal-sdk/packages/e2e/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/minimal-sdk/packages/gateway/README.md b/minimal-sdk/packages/gateway/README.md new file mode 100644 index 00000000..ee1bcaad --- /dev/null +++ b/minimal-sdk/packages/gateway/README.md @@ -0,0 +1,267 @@ +# @utexo/minimal-gateway + +TypeScript API gateway in front of ONE shared RLN instance. It owns per-user auth, +per-user FIFO queues with a global concurrency cap, idempotency keys on money-moving +routes, per-user **watch-only** rgb-lib wallets for PSBT preparation, a capped-float +ledger for the custodial LN working balance, and the user↔resource scoping map. Every +RLN list/get response is filtered through that map before leaving the gateway — RLN +state is node-wide and is never proxied raw. + +Design authority: `docs/design/minimal-sdk-and-lightweight-rln.md` (repo root). Trust +invariants: no secret ever reaches the gateway (I1), one watch-only wallet per user +(I2), no cross-user visibility (I3), only the gateway holds the RLN credential (I4). + +## Running + +Requirements: Node 20+, pnpm, and a platform with a published rgb-lib native module — +`linux-x64`, `linux-arm64` or `darwin-arm64` (`src/wallets/rgblib.ts`). Elsewhere the +gateway still starts, but every wallet operation fails at first use with +`rgb-lib native module unavailable`. + +```sh +RLN_URL=http://127.0.0.1:3101 \ +ESPLORA_URL=http://127.0.0.1:3002 \ +RGB_PROXY_URL=rpc://127.0.0.1:3000/json-rpc \ +GATEWAY_SQLITE_PATH=/var/lib/minimal-gateway/gateway.sqlite \ +GATEWAY_WALLETS_DIR=/var/lib/minimal-gateway/wallets \ +GATEWAY_WALLET_INDEXER_URL=127.0.0.1:50001 \ +GATEWAY_OPERATOR_TOKEN= \ +node dist/main.js # or: pnpm --filter @utexo/minimal-gateway start +``` + +The production entrypoint (`src/main.ts`) runs `loadConfig` → `buildServer` with the +background workers (deposits watcher + payments reconciler) → `listen`. `buildServer` +starts the workers only when called with `startWorkers: true` — `main.ts` does this; a +library embedding must opt in explicitly. + +Users are created by the operator via `POST /v1/users` (guarded by the operator token); +the response contains the user's opaque bearer token exactly once — only its hash is +stored. + +## API + +Auth is one of: the operator token (`x-operator-token` header), the user's bearer token +(`Authorization: Bearer `), or none. Money-moving routes additionally require an +`Idempotency-Key` header (400 without it): a replay of the same key **and** the same +request body returns the stored response, and completed rows are garbage-collected after +7 days. + +Two details matter for retries. Responses with status ≥ 500 or 429 are deliberately +**not** cached — the claim is released so the request can be retried — and an in-flight +claim whose request never stored a response is reclaimable after 15 minutes, but only by +a retry of the _same_ request (a different body under that key is always 409 +`IDEMPOTENCY_KEY_REUSED`). After a 5xx on `/v1/ln/pay` or `/v1/ln/withdraw` the retry is +therefore answered by the flow's own replay guard rather than by a cached body: the +gateway reports the recorded state instead of sending again, because it never +auto-retries a send. + +| Method | Path | Auth | Idempotency-Key | +| ------ | ----------------------------------- | -------- | --------------- | +| GET | `/v1/health` | none | — | +| POST | `/v1/users` | operator | — | +| GET | `/v1/me` | bearer | — | +| POST | `/v1/wallet/xpubs` | bearer | — | +| GET | `/v1/wallet/address` | bearer | — | +| GET | `/v1/wallet/balances` | bearer | — | +| GET | `/v1/wallet/unspents` | bearer | — | +| GET | `/v1/wallet/transfers` | bearer | — | +| POST | `/v1/wallet/receive` | bearer | — | +| POST | `/v1/wallet/sync` | bearer | — | +| POST | `/v1/onchain/send-btc/prepare` | bearer | required | +| POST | `/v1/onchain/send-btc/complete` | bearer | required | +| POST | `/v1/onchain/send-asset/prepare` | bearer | required | +| POST | `/v1/onchain/send-asset/complete` | bearer | required | +| POST | `/v1/onchain/create-utxos/prepare` | bearer | required | +| POST | `/v1/onchain/create-utxos/complete` | bearer | required | +| POST | `/v1/ln/deposit/prepare` | bearer | required | +| POST | `/v1/ln/pay` | bearer | required | +| POST | `/v1/ln/invoice` | bearer | — | +| GET | `/v1/ln/invoice/:hash` | bearer | — | +| GET | `/v1/ln/payments` | bearer | — | +| GET | `/v1/ln/balance` | bearer | — | +| POST | `/v1/ln/withdraw` | bearer | required | + +### Errors + +Every error response is `{"error": {"code", "message"}}`. Notable codes: + +| Code | Status | Meaning | +| ---------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `QUEUE_FULL` | 429 | Per-user queue depth exceeded; a `retry-after` header accompanies it. | +| `AMOUNT_REQUIRED` | 400 | BTC invoices must declare `amtMsat`, asset invoices `assetAmount` (their `amtMsat` HTLC carrier defaults to `GATEWAY_ASSET_INVOICE_MIN_MSAT`), and paying an amount-less invoice requires `amtMsat` (a float-cap bypass). | +| `AMOUNT_MISMATCH` | 400 | `amtMsat` or `assetAmount` conflicts with the amount encoded in the invoice. | +| `AMOUNT_BELOW_MINIMUM` | 400 | An asset invoice's `amtMsat` HTLC carrier value is under `GATEWAY_ASSET_INVOICE_MIN_MSAT`; the node would refuse it. | +| `INVOICE_REJECTED` | 400 | The node rejected these invoice parameters. Detail stays server-side. | +| `INVALID_INVOICE` | 400 | The BOLT11 could not be decoded, or its amount is zero / outside the safe-integer range. | +| `PAYMENT_REJECTED` / `WITHDRAW_REJECTED` | 400 | The node rejected this payment or withdrawal (an RLN 4xx). The debit is refunded. Detail stays server-side. | +| `WITNESS_AMOUNT_REQUIRED` | 400 | An RGB withdraw to a `wvout:` recipient needs `witnessAmountSat` (rgb-lib rejects a witness beneficiary without it). The node funds that output, so its sats are debited from the caller's msat float. | +| `TRANSPORT_ENDPOINT_NOT_ALLOWED` | 400 | Consignment endpoint outside `GATEWAY_RGB_TRANSPORT_ALLOWLIST` (SSRF guard). | +| `WALLET_REQUEST_REJECTED` | 400 | rgb-lib rejected the request itself — bad recipient id, asset id, address, fee rate or transport endpoint. Detail stays server-side. | +| `INSUFFICIENT_FUNDS` | 400 | Not enough confirmed bitcoins, assets or allocation slots for an on-chain operation. | +| `PSBT_REJECTED` / `PSBT_MISMATCH` | 400 | The signed PSBT was rejected, or does not correspond to the prepared operation. | +| `FLOAT_CAP_EXCEEDED` | 409 | Per-user or global float cap would be exceeded by this new exposure. | +| `INSUFFICIENT_BALANCE` | 409 | The user's ledger balance is short. | +| `IDEMPOTENCY_KEY_REUSED` | 409 | Same key, different request body. | +| `IDEMPOTENCY_IN_FLIGHT` | 409 | A request with this key is still running. | +| `PAYMENT_CONFLICT` | 409 | Another user is already paying this invoice. | +| `PAYMENT_ALREADY_ATTEMPTED` | 409 | This invoice already failed and was refunded — request a fresh invoice rather than re-paying an old one. | +| `WITHDRAWAL_ALREADY_ATTEMPTED` | 409 | A withdrawal under this key failed and was refunded — use a fresh key. (A rejection answered 4xx is instead replayed from the idempotency cache.) | +| `WITHDRAWAL_UNRESOLVED` | 409 | A withdrawal under this key is unresolved and may have broadcast; operator resolution required (see Known limitations). | +| `DEPOSIT_ADDRESS_CONFLICT` | 502 | RLN returned a deposit address (or RGB `recipient_id`) already assigned to another intent — it is misconfigured (see the deployment note). | +| `OP_EXPIRED` | 410 | The prepared on-chain operation passed `GATEWAY_ONCHAIN_OP_TTL_SECONDS`; prepare again. | +| `UPSTREAM_ERROR` / `UPSTREAM_TIMEOUT` | 502 / 504 | Sanitized RLN failures. | +| `COMPLETE_AMBIGUOUS` | 502 | The wallet failed while completing an on-chain op, after rgb-lib may already have broadcast. The op stays pending with its txid recorded; retrying `complete` finishes the bookkeeping. | + +## Configuration reference + +All configuration comes from environment variables (`src/config.ts`). + +Required: + +| Variable | Meaning | +| ---------------------------- | ----------------------------------------------------------------------------- | +| `RLN_URL` | Base URL of the single shared RLN instance (reachable only from the gateway). | +| `ESPLORA_URL` | Esplora REST API; used by the deposits watcher to confirm BTC deposits. | +| `RGB_PROXY_URL` | RGB proxy endpoint handed to receive-invoice transport endpoints. | +| `GATEWAY_SQLITE_PATH` | Path to the gateway SQLite database (`:memory:` for tests). | +| `GATEWAY_WALLETS_DIR` | Gateway-owned directory holding one watch-only wallet data-dir per user. | +| `GATEWAY_WALLET_INDEXER_URL` | Electrum `host:port` passed to rgb-lib `goOnline` for user wallets. | +| `GATEWAY_OPERATOR_TOKEN` | Guards the bootstrap-only `POST /v1/users` route. Minimum 16 characters. | + +Optional (default in parentheses): + +| Variable | Meaning | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GATEWAY_HOST` (`127.0.0.1`) | Interface the gateway listens on. | +| `GATEWAY_PORT` (`8480`) | Gateway HTTP port. | +| `RLN_ADMIN_TOKEN` (empty) | Biscuit admin token; empty when RLN runs with `--disable-authentication` on localhost. Never logged, never echoed (I4). | +| `GATEWAY_BITCOIN_NETWORK` (`Regtest`) | rgb-lib network for user wallets: `Mainnet`, `Testnet`, `Signet`, `Regtest`. | +| `GATEWAY_WALLET_MAX_OPEN` (`32`) | LRU capacity of the open-wallet pool. | +| `GATEWAY_ONCHAIN_OP_TTL_SECONDS` (`600`) | Seconds an unsigned prepare-step PSBT stays completable before expiring. | +| `GATEWAY_FLOAT_CAP_PER_USER_MSAT` (`1000000000`) | Per-user ceiling on the custodial LN float. | +| `GATEWAY_FLOAT_CAP_GLOBAL_MSAT` (`10000000000`) | Global ceiling on the custodial LN float. | +| `GATEWAY_DEPOSIT_MIN_CONFIRMATIONS` (`1`) | Confirmations before a BTC/RGB deposit is credited to the ledger. | +| `GATEWAY_DEPOSIT_TTL_SECONDS` (`86400`) | Seconds an unfunded deposit intent stays pending (and counts against the float caps) before expiring. | +| `GATEWAY_ASSET_INVOICE_MIN_MSAT` (`3000000`) | HTLC carrier value (msat) put on asset invoices that declare no `amtMsat`. Must match the node's `channels.htlc_min_msat` (or the higher `inbound_htlc_minimum_msat` of the asset's channels) — RLN refuses an asset invoice below that floor. | +| `GATEWAY_RGB_TRANSPORT_ALLOWLIST` (empty) | Comma-separated extra RGB consignment-proxy endpoints users may pin in withdraw/send-asset requests. `RGB_PROXY_URL` is always allowlisted; anything else is rejected with 400 `TRANSPORT_ENDPOINT_NOT_ALLOWED` (SSRF guard — the node dials these). | +| `GATEWAY_DEPOSITS_INTERVAL_MS` (`10000`) | Poll interval of the deposits watcher. | +| `GATEWAY_RECONCILER_INTERVAL_MS` (`10000`) | Poll interval of the payments reconciler. | +| `GATEWAY_RECONCILER_GRACE_SECONDS` (`600`) | Age before the reconciler refunds a debited-pending send RLN does not know about (the send may still be queued behind RLN's global lock). | +| `GATEWAY_QUEUE_GLOBAL_CONCURRENCY` (`4`) | Max downstream operations running at once across all users. | +| `GATEWAY_QUEUE_PER_USER_DEPTH` (`16`) | Max queued+running operations per user before 429. | + +## Deployment note: RLN is gateway-only + +RLN is single-tenant and its API is node-wide: anyone who can reach it can see and move +everything. It must therefore be reachable **only from the gateway** — bind it to +localhost on the same host, or keep it on a private network segment with the gateway as +the sole peer. **Never expose RLN publicly.** The gateway holds the only RLN credential: +either a biscuit admin token (`RLN_ADMIN_TOKEN`), or RLN runs with +`--disable-authentication`, which is acceptable strictly on localhost behind the gateway +(invariant I4). The same applies to the esplora and RGB-proxy endpoints the node uses. + +**RLN must not run with `--reuse-addresses`** (nor `reuse_addresses = true` in its +`config.toml`). The deposits watcher attributes on-chain funds by address, so every +deposit intent needs an address no earlier intent has used; a reused address would let +one payment credit several users' ledgers. The gateway refuses such an intent with 502 +`DEPOSIT_ADDRESS_CONFLICT` — seeing that code means RLN is misconfigured. RGB deposit +intents are held to the same rule on the `recipient_id` the node blinds for them, for +the same reason (the watcher attributes RGB by recipient id). + +## Float-cap tuning + +The LN working balance is custodial (see the custody boundary below), so the caps are +the loss bound: `GATEWAY_FLOAT_CAP_PER_USER_MSAT` bounds what any one user can lose if +the node or gateway is compromised, and `GATEWAY_FLOAT_CAP_GLOBAL_MSAT` bounds the total +custodial exposure of the deployment. Caps are enforced where new custodial exposure is +created — deposit prepare and invoice creation, counting pending intents so parallel +prepares cannot overshoot — while settlement credits and refunds are never blocked +(refusing them would strand funds the node already holds). + +**The caps cover BTC only.** They are denominated in msat and are checked on the BTC +legs alone — `POST /v1/ln/deposit/prepare {kind:'rgb'}` and the `assetAmount` of an asset +invoice pass no headroom check, so RGB units enter the custodial float unbounded (only +the msat HTLC carrier of an asset invoice is capped). There is no meaningful common +denomination to cap heterogeneous RGB assets against, so per-asset ceilings are deferred +rather than guessed; until they land, the loss bound above is a bound on custodial +_bitcoin_, and RGB custodial exposure must be monitored out of band. + +One consequence is worth stating plainly: the cap is checked against the amount +_declared_ at deposit-prepare time, while the watcher credits what actually confirmed on +chain. A user who declares a small deposit and then over-funds the returned address is +credited the full confirmed amount, so the caps bound _accepted intents_, not the +absolute custodial total. Refusing the excess would strand funds the node already holds, +so the trade-off is deliberate; monitor for it rather than relying on the cap alone. + +Start small: set the global +cap to what the operation can afford to lose outright, the per-user cap to a routine +spending balance (defaults: 0.01 BTC per user, 0.1 BTC global), and revisit after +load-testing real usage. The global cap must also stay below the node's actual outbound +channel liquidity, or deposits will be accepted that cannot be spent. + +## The custody boundary, honestly + +Quoted verbatim from `docs/design/minimal-sdk-and-lightweight-rln.md`: + +> Client-held keys cover **at-rest user funds**: on-chain BTC and RGB allocations on user +> UTXOs cannot move without a client signature (for RGB, subject to the send-time limitation +> above). They do **not** cover the Lightning working balance — an LN protocol constraint, +> not an implementation gap: channel funds are controlled by the channel keys and the shared +> node is their single owner, so balances inside its channels (in-flight HTLCs, routed +> float, funds awaiting sweep) are custodial. +> +> Mitigations, cheapest first (effort classes are estimates): +> +> | Mitigation | Where | Effort | +> | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ------------ | +> | Capped float: hard per-user + global ceiling on custodial LN balance | gateway policy | days | +> | Auto-sweep: above a threshold, prepare on-chain sweep to user keys, client signs | gateway + client SDK | days | +> | Submarine/atomic swaps for trust-minimized in/out via the existing swap API (`/makerinit`, `/makerexecute`, `/taker`, `src/main.rs:228-229,246`) and UTEXO thunder-swap | gateway + existing RLN API | weeks | +> | Per-user signer over a WebSocket `ExternalSignerTransport` (`src/signer/transport.rs:56`; today framed TCP + mTLS, `src/signer/remote/mod.rs`, browser-unreachable; `/initexternalsigner`: `src/main.rs:255-256`) | RLN + SDK | weeks | +> | Per-user RLN nodes, or the wasm node in the browser (Block 2) — full self-custody | infra / SDK | weeks–months | + +The "send-time limitation above" it references, also verbatim: + +> Stated limitation — **send-time trust for RGB state**. The opret commitment bytes are +> opaque to the client, and in RGB the bitcoin-level spend of a colored input _is_ the +> authorization of whatever transition the server committed to it. The checks above verify +> all _bitcoin-value_ movement, but a compromised server can reassign **every RGB +> allocation on the spent inputs — amount sent and change alike — to seals it controls**, +> and such a transfer validates fine at its recipient: redirection (theft), not merely a +> burn, and the client cannot bound it (the server does coin selection). So RGB is safe +> **at rest** — nothing moves without a client signature — while every server-prepared +> colored spend exposes its inputs' allocations. Interim controls are server-side (hardened +> wallet service, independent consignment audit); removing the trust means shipping RGB +> validation client-side, i.e. the wasm variant. + +Of the mitigation table, this gateway implements the first row (the capped float); +the rest are deliberately deferred (see the workspace README). + +## Known limitations + +- **Synchronous wallet FFI.** rgb-lib wallet calls are synchronous native FFI on the + main thread — a slow indexer round-trip stalls all requests while it runs. Moving + wallet calls onto a worker-thread pool is future work. +- **Fee subsidy.** LN routing fees and on-chain withdrawal fees are paid by the node, + not debited from user balances — an operator subsidy whose exposure is bounded by the + float caps. The _value_ the node pays out is not subsidized: an RGB withdraw to a + `wvout:` recipient debits `witnessAmountSat` (in msat) from the caller's BTC float, + because rgb-lib funds that new output from the node's on-chain wallet into a script the + caller names — a withdraw the caller cannot cover answers 409 `INSUFFICIENT_BALANCE`. +- **One-shot deposit crediting.** A deposit intent expires after + `GATEWAY_DEPOSIT_TTL_SECONDS`, and each intent credits exactly once — on the first + confirmed poll. Later payments to the same address are not credited; prepare a fresh + deposit per payment. +- **Ambiguous withdrawals need manual resolution.** The reconciler drives LN _payments_ + to a terminal state but does not cover the `withdrawals` table. If RLN times out or + answers 5xx mid-`/sendbtc`/`/sendrgb` — rgb-lib broadcasts before writing its + bookkeeping, and every post-broadcast failure surfaces as a 500, so the transaction may + already be on the network — the row is marked `ambiguous`, the debit stands, and + same-key retries answer 409 `WITHDRAWAL_UNRESOLVED`. The operator must check the node + (`/listtransactions`, `/listtransfers`) for a matching broadcast and then, in the + gateway SQLite, either mark the row `sent` with its txid or mark it `failed` and post + a compensating `refund` ledger entry. There is no admin API for this yet. +- **Unattributable RGB deposits stay pending.** If a settled RGB transfer for a deposit + target carries no fungible assignment, the watcher cannot tell what was received and + leaves the intent pending with a warning rather than crediting the declared amount + (which would mint ledger units the node may not hold — RGB credits are not capped). diff --git a/minimal-sdk/packages/gateway/package.json b/minimal-sdk/packages/gateway/package.json new file mode 100644 index 00000000..fc33f75d --- /dev/null +++ b/minimal-sdk/packages/gateway/package.json @@ -0,0 +1,36 @@ +{ + "name": "@utexo/minimal-gateway", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/main.js", + "test": "vitest run", + "generate:rln-types": "node scripts/generate-rln-types.mjs" + }, + "dependencies": { + "@scure/base": "^2.4.0", + "@scure/btc-signer": "^2.4.1", + "@utexo/rgb-lib": "0.3.0-beta.18", + "better-sqlite3": "^12.4.0", + "fastify": "^5.6.0" + }, + "optionalDependencies": { + "@utexo/rgb-lib-darwin-arm64": "0.3.0-beta.18", + "@utexo/rgb-lib-linux-arm64": "0.3.0-beta.18", + "@utexo/rgb-lib-linux-x64": "0.3.0-beta.18" + }, + "devDependencies": { + "@scure/bip32": "^2.4.0", + "@scure/bip39": "^2.4.0", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^20.19.0", + "openapi-typescript": "^7.13.0", + "typescript": "^5.9.0", + "vitest": "^3.2.0", + "yaml": "^2.9.0" + } +} diff --git a/minimal-sdk/packages/gateway/scripts/generate-rln-types.mjs b/minimal-sdk/packages/gateway/scripts/generate-rln-types.mjs new file mode 100644 index 00000000..016293a0 --- /dev/null +++ b/minimal-sdk/packages/gateway/scripts/generate-rln-types.mjs @@ -0,0 +1,105 @@ +/** + * Generates TypeScript types for the RLN endpoint subset the gateway uses. + * + * Reads the repo-root openapi.yaml (read-only), prunes it down to RLN_PATHS + * plus every transitively $ref-erenced component, and runs openapi-typescript + * on the pruned document. Pruning keeps the generated file reviewable and + * sidesteps spec problems in endpoints the gateway never calls. + * + * Run with: pnpm --filter @utexo/minimal-gateway generate:rln-types + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse } from 'yaml'; +import openapiTS, { astToString } from 'openapi-typescript'; + +const here = dirname(fileURLToPath(import.meta.url)); +const specPath = join(here, '..', '..', '..', '..', 'openapi.yaml'); +const outPath = join(here, '..', 'src', 'rln', 'openapi.ts'); + +/** The RLN endpoints the gateway calls (plan Task 3). */ +const RLN_PATHS = [ + '/nodeinfo', + '/address', + '/btcbalance', + '/sendbtc', + '/createutxos', + '/rgbinvoice', + '/decodergbinvoice', + '/sendrgb', + '/listtransfers', + '/refreshtransfers', + '/lninvoice', + '/decodelninvoice', + '/invoicestatus', + '/sendpayment', + '/getpayment', + '/listpayments', + '/listchannels', +]; + +const spec = parse(readFileSync(specPath, 'utf8')); + +for (const path of RLN_PATHS) { + if (spec.paths?.[path] === undefined) { + throw new Error(`endpoint ${path} not found in openapi.yaml`); + } +} + +/** Collect '#/components/schemas/X' names reachable from a node. */ +function collectRefs(node, into) { + if (Array.isArray(node)) { + for (const item of node) collectRefs(item, into); + return; + } + if (node === null || typeof node !== 'object') return; + for (const [key, value] of Object.entries(node)) { + if (key === '$ref' && typeof value === 'string') { + const match = /^#\/components\/schemas\/(.+)$/.exec(value); + if (match === null) throw new Error(`unsupported $ref target: ${value}`); + into.add(match[1]); + } else { + collectRefs(value, into); + } + } +} + +const paths = Object.fromEntries(RLN_PATHS.map((p) => [p, spec.paths[p]])); +const needed = new Set(); +collectRefs(paths, needed); +// Fixpoint: schemas referenced by already-needed schemas. +let previousSize = 0; +while (needed.size !== previousSize) { + previousSize = needed.size; + for (const name of [...needed]) { + const schema = spec.components?.schemas?.[name]; + if (schema === undefined) throw new Error(`component schema ${name} not found`); + collectRefs(schema, needed); + } +} + +const pruned = { + openapi: spec.openapi, + info: spec.info, + paths, + components: { + schemas: Object.fromEntries( + [...needed].sort().map((name) => [name, spec.components.schemas[name]]), + ), + securitySchemes: spec.components.securitySchemes, + }, + security: spec.security, +}; + +const ast = await openapiTS(pruned); +const banner = `/** + * GENERATED FILE — do not edit by hand. + * + * RLN API types for the endpoint subset the gateway uses, generated from the + * repo-root openapi.yaml. Regenerate with: + * pnpm --filter @utexo/minimal-gateway generate:rln-types + */ +`; +writeFileSync(outPath, banner + astToString(ast)); +console.log(`wrote ${outPath} (${needed.size} schemas, ${RLN_PATHS.length} paths)`); diff --git a/minimal-sdk/packages/gateway/src/auth.ts b/minimal-sdk/packages/gateway/src/auth.ts new file mode 100644 index 00000000..730f4396 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/auth.ts @@ -0,0 +1,83 @@ +/** + * Opaque per-user bearer tokens, hashed at rest (invariant I4: only hashes are + * persisted; the plaintext token is returned exactly once at creation). + * + * Tokens carry 256 bits of entropy, so a single unsalted SHA-256 is a safe + * at-rest representation and gives O(1) indexed lookup. + */ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { GatewayDb } from './db.js'; +import { HttpError } from './errors.js'; + +export interface CreatedUser { + userId: string; + token: string; + createdAt: number; +} + +export interface UserRecord { + id: string; + createdAt: number; +} + +export function generateToken(): string { + return `utxg_${randomBytes(32).toString('base64url')}`; +} + +export function hashToken(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex'); +} + +/** Constant-time string comparison that does not leak length. */ +export function safeEqual(a: string, b: string): boolean { + const ha = createHash('sha256').update(a, 'utf8').digest(); + const hb = createHash('sha256').update(b, 'utf8').digest(); + return timingSafeEqual(ha, hb); +} + +export function createUser(db: GatewayDb, now: number = Date.now()): CreatedUser { + const userId = `u_${randomBytes(16).toString('hex')}`; + const token = generateToken(); + db.prepare('INSERT INTO users (id, token_hash, created_at) VALUES (?, ?, ?)').run( + userId, + hashToken(token), + now, + ); + return { userId, token, createdAt: now }; +} + +export function findUserByToken(db: GatewayDb, token: string): UserRecord | undefined { + const row = db + .prepare('SELECT id, created_at FROM users WHERE token_hash = ?') + .get(hashToken(token)) as { id: string; created_at: number } | undefined; + return row === undefined ? undefined : { id: row.id, createdAt: row.created_at }; +} + +export type PreHandler = (request: FastifyRequest, reply: FastifyReply) => Promise; + +/** Resolves the authenticated user on every non-bootstrap route. */ +export function makeAuthHook(db: GatewayDb): PreHandler { + return async (request: FastifyRequest, _reply: FastifyReply): Promise => { + const header = request.headers.authorization; + if (typeof header !== 'string' || !header.startsWith('Bearer ')) { + throw new HttpError(401, 'UNAUTHORIZED', 'missing bearer token'); + } + const token = header.slice('Bearer '.length).trim(); + const user = token === '' ? undefined : findUserByToken(db, token); + if (user === undefined) { + throw new HttpError(401, 'UNAUTHORIZED', 'invalid token'); + } + request.userId = user.id; + }; +} + +/** Guards the bootstrap-only user-creation route with the operator token. */ +export function makeOperatorGuard(operatorToken: string): PreHandler { + return async (request: FastifyRequest, _reply: FastifyReply): Promise => { + const provided = request.headers['x-operator-token']; + if (typeof provided !== 'string' || !safeEqual(provided, operatorToken)) { + throw new HttpError(401, 'OPERATOR_UNAUTHORIZED', 'missing or invalid operator token'); + } + }; +} diff --git a/minimal-sdk/packages/gateway/src/config.ts b/minimal-sdk/packages/gateway/src/config.ts new file mode 100644 index 00000000..52e1169c --- /dev/null +++ b/minimal-sdk/packages/gateway/src/config.ts @@ -0,0 +1,161 @@ +/** + * Typed gateway configuration loaded from environment variables. + * + * The RLN admin token and operator token are credentials: they must never be + * logged or echoed in any response (invariant I4). + */ + +export interface GatewayConfig { + /** Interface the gateway listens on. Defaults to loopback. */ + host: string; + port: number; + /** Base URL of the single shared RLN instance (reachable only from the gateway). */ + rlnUrl: string; + /** Biscuit admin token; empty when RLN runs with --disable-authentication on localhost. */ + rlnAdminToken: string; + esploraUrl: string; + rgbProxyUrl: string; + /** Path to the gateway SQLite database (':memory:' for tests). */ + sqlitePath: string; + /** Gateway-owned directory holding one watch-only wallet data-dir per user. */ + walletsDir: string; + /** + * Indexer URL passed to rgb-lib goOnline (electrum host:port — the variant + * verified in Preflight A; the shipped rgb-lib build has no esplora feature). + */ + walletIndexerUrl: string; + /** rgb-lib BitcoinNetwork for user wallets. */ + bitcoinNetwork: 'Mainnet' | 'Testnet' | 'Signet' | 'Regtest'; + /** LRU capacity of the open-wallet pool. */ + walletMaxOpen: number; + /** Seconds an unsigned prepare-step PSBT stays completable before expiring. */ + onchainOpTtlSeconds: number; + /** Guards the bootstrap-only POST /v1/users route. */ + operatorToken: string; + /** Custodial LN float caps (Block 3): enforced at ledger credit/debit time. */ + floatCapPerUserMsat: number; + floatCapGlobalMsat: number; + /** Confirmations before a BTC/RGB deposit is credited to the float ledger. */ + depositMinConfirmations: number; + /** Seconds an unfunded deposit intent stays pending (and counts against caps). */ + depositTtlSeconds: number; + /** + * HTLC carrier value (msat) put on asset invoices that declare no msat + * amount. RLN refuses an asset invoice below the channel HTLC minimum, so + * this must match the node's `channels.htlc_min_msat` (RLN default 3_000_000) + * or the higher `inbound_htlc_minimum_msat` of the asset's channels. + */ + assetInvoiceMinMsat: number; + /** + * RGB consignment-proxy endpoints users may name in withdraw/send-asset + * requests (SSRF guard — the node dials these). Always includes rgbProxyUrl. + */ + rgbTransportAllowlist: string[]; + /** Poll interval of the deposits watcher (ms). */ + depositsIntervalMs: number; + /** Poll interval of the payments reconciler (ms). */ + reconcilerIntervalMs: number; + /** + * Seconds a debited-pending outbound payment RLN does not know about must + * age before the reconciler refunds it (the send may still be queued behind + * RLN's global lock — refunding too early would double-spend the float). + */ + reconcilerGraceSeconds: number; + /** Max downstream operations running at once across all users. */ + queueGlobalConcurrency: number; + /** Max queued+running operations per user before 429. */ + queuePerUserDepth: number; +} + +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConfigError'; + } +} + +function requireString(env: Record, name: string): string { + const value = env[name]; + if (value === undefined || value.trim() === '') { + throw new ConfigError(`missing required environment variable ${name}`); + } + return value.trim(); +} + +function requireUrl(env: Record, name: string): string { + const value = requireString(env, name); + try { + new URL(value); + } catch { + throw new ConfigError(`environment variable ${name} is not a valid URL: ${value}`); + } + return value; +} + +function intWithDefault( + env: Record, + name: string, + fallback: number, +): number { + const raw = env[name]; + if (raw === undefined || raw.trim() === '') return fallback; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value <= 0) { + throw new ConfigError(`environment variable ${name} must be a positive integer, got: ${raw}`); + } + return value; +} + +const BITCOIN_NETWORKS = ['Mainnet', 'Testnet', 'Signet', 'Regtest'] as const; + +function bitcoinNetwork( + env: Record, +): (typeof BITCOIN_NETWORKS)[number] { + const raw = env['GATEWAY_BITCOIN_NETWORK']?.trim(); + if (raw === undefined || raw === '') return 'Regtest'; + const match = BITCOIN_NETWORKS.find((name) => name.toLowerCase() === raw.toLowerCase()); + if (match === undefined) { + throw new ConfigError( + `GATEWAY_BITCOIN_NETWORK must be one of ${BITCOIN_NETWORKS.join(', ')}, got: ${raw}`, + ); + } + return match; +} + +export function loadConfig(env: Record = process.env): GatewayConfig { + const operatorToken = requireString(env, 'GATEWAY_OPERATOR_TOKEN'); + if (operatorToken.length < 16) { + throw new ConfigError('GATEWAY_OPERATOR_TOKEN must be at least 16 characters'); + } + const rgbProxyUrl = requireUrl(env, 'RGB_PROXY_URL'); + const extraTransports = (env['GATEWAY_RGB_TRANSPORT_ALLOWLIST'] ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry !== ''); + return { + host: env['GATEWAY_HOST']?.trim() || '127.0.0.1', + port: intWithDefault(env, 'GATEWAY_PORT', 8480), + rlnUrl: requireUrl(env, 'RLN_URL'), + rlnAdminToken: env['RLN_ADMIN_TOKEN']?.trim() ?? '', + esploraUrl: requireUrl(env, 'ESPLORA_URL'), + rgbProxyUrl, + sqlitePath: requireString(env, 'GATEWAY_SQLITE_PATH'), + walletsDir: requireString(env, 'GATEWAY_WALLETS_DIR'), + walletIndexerUrl: requireString(env, 'GATEWAY_WALLET_INDEXER_URL'), + bitcoinNetwork: bitcoinNetwork(env), + walletMaxOpen: intWithDefault(env, 'GATEWAY_WALLET_MAX_OPEN', 32), + onchainOpTtlSeconds: intWithDefault(env, 'GATEWAY_ONCHAIN_OP_TTL_SECONDS', 600), + operatorToken, + floatCapPerUserMsat: intWithDefault(env, 'GATEWAY_FLOAT_CAP_PER_USER_MSAT', 1_000_000_000), + floatCapGlobalMsat: intWithDefault(env, 'GATEWAY_FLOAT_CAP_GLOBAL_MSAT', 10_000_000_000), + depositMinConfirmations: intWithDefault(env, 'GATEWAY_DEPOSIT_MIN_CONFIRMATIONS', 1), + depositTtlSeconds: intWithDefault(env, 'GATEWAY_DEPOSIT_TTL_SECONDS', 86_400), + assetInvoiceMinMsat: intWithDefault(env, 'GATEWAY_ASSET_INVOICE_MIN_MSAT', 3_000_000), + rgbTransportAllowlist: [rgbProxyUrl, ...extraTransports.filter((e) => e !== rgbProxyUrl)], + depositsIntervalMs: intWithDefault(env, 'GATEWAY_DEPOSITS_INTERVAL_MS', 10_000), + reconcilerIntervalMs: intWithDefault(env, 'GATEWAY_RECONCILER_INTERVAL_MS', 10_000), + reconcilerGraceSeconds: intWithDefault(env, 'GATEWAY_RECONCILER_GRACE_SECONDS', 600), + queueGlobalConcurrency: intWithDefault(env, 'GATEWAY_QUEUE_GLOBAL_CONCURRENCY', 4), + queuePerUserDepth: intWithDefault(env, 'GATEWAY_QUEUE_PER_USER_DEPTH', 16), + }; +} diff --git a/minimal-sdk/packages/gateway/src/db.ts b/minimal-sdk/packages/gateway/src/db.ts new file mode 100644 index 00000000..e04372d8 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/db.ts @@ -0,0 +1,164 @@ +/** + * SQLite persistence with a tiny forward-only migration runner. + * + * Schema version is tracked in PRAGMA user_version; each migration applies in + * one transaction so a crash mid-migration leaves the previous version intact. + * The pre-release development history was squashed into a single v1 before + * anything shipped; new migrations append from v2. + */ +import Database from 'better-sqlite3'; + +export type GatewayDb = Database.Database; + +interface Migration { + version: number; + sql: string; +} + +const MIGRATIONS: readonly Migration[] = [ + { + version: 1, + sql: ` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL + ) STRICT; + + CREATE TABLE user_xpubs ( + user_id TEXT PRIMARY KEY REFERENCES users(id), + vanilla TEXT NOT NULL, + colored TEXT NOT NULL, + fingerprint TEXT NOT NULL, + created_at INTEGER NOT NULL + ) STRICT; + + CREATE TABLE resource_map ( + kind TEXT NOT NULL CHECK (kind IN ('invoice', 'payment_hash', 'asset_transfer', 'swap')), + resource_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id), + state TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (kind, resource_id) + ) STRICT; + CREATE INDEX resource_map_user ON resource_map (user_id, kind); + + CREATE TABLE idempotency ( + user_id TEXT NOT NULL REFERENCES users(id), + key TEXT NOT NULL, + request_hash TEXT NOT NULL, + response TEXT, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, key) + ) STRICT; + + CREATE TABLE ledger ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id), + asset TEXT NOT NULL, + delta_msat_or_units INTEGER NOT NULL, + kind TEXT NOT NULL, + ref TEXT, + created_at INTEGER NOT NULL + ) STRICT; + CREATE INDEX ledger_user_asset ON ledger (user_id, asset); + -- Exactly-once backstop for ledger entries that carry a ref: replayed + -- workers/requests can never double-apply the same (user, asset, kind, ref). + CREATE UNIQUE INDEX ledger_once + ON ledger (user_id, asset, kind, ref) WHERE ref IS NOT NULL; + + CREATE TABLE pending_ops ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + kind TEXT NOT NULL CHECK (kind IN ('send_btc', 'send_asset', 'create_utxos')), + psbt TEXT NOT NULL, + intent TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'completed', 'expired')), + txid TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ) STRICT; + CREATE INDEX pending_ops_user_state ON pending_ops (user_id, state); + + CREATE TABLE pending_deposits ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + kind TEXT NOT NULL CHECK (kind IN ('btc', 'rgb')), + asset TEXT NOT NULL, + amount INTEGER NOT NULL, + target TEXT NOT NULL, + invoice TEXT, + state TEXT NOT NULL CHECK (state IN ('pending', 'credited', 'expired')), + credited_amount INTEGER, + txid TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ) STRICT; + CREATE INDEX pending_deposits_state ON pending_deposits (state, user_id); + + CREATE TABLE ln_invoices ( + payment_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + invoice TEXT NOT NULL, + amt_msat INTEGER, + asset_id TEXT, + asset_amount INTEGER, + state TEXT NOT NULL CHECK (state IN ('pending', 'settled', 'expired')), + created_at INTEGER NOT NULL + ) STRICT; + CREATE INDEX ln_invoices_user_state ON ln_invoices (user_id, state); + + CREATE TABLE withdrawals ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + asset TEXT NOT NULL, + amount INTEGER NOT NULL, + target TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'sent', 'failed', 'ambiguous')), + txid TEXT, + created_at INTEGER NOT NULL + ) STRICT; + CREATE INDEX withdrawals_user ON withdrawals (user_id); + + CREATE TABLE worker_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) STRICT; + `, + }, + { + version: 2, + // Invoice expiry, so a pending invoice that can no longer be paid stops + // counting against float-cap headroom even while the reconciler is behind + // (mirrors pending_deposits.expires_at). Rows written before this + // migration get their created_at as expiry: they are older than any + // supported BOLT11 expiry (max 86400s) by the time an upgrade lands, so + // treating them as expired is the safe, non-blocking default. + sql: ` + ALTER TABLE ln_invoices ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0; + UPDATE ln_invoices SET expires_at = created_at WHERE expires_at = 0; + `, + }, +]; + +export function schemaVersion(db: GatewayDb): number { + return db.pragma('user_version', { simple: true }) as number; +} + +export function migrate(db: GatewayDb): void { + for (const migration of MIGRATIONS) { + if (migration.version <= schemaVersion(db)) continue; + db.transaction(() => { + db.exec(migration.sql); + db.pragma(`user_version = ${migration.version}`); + })(); + } +} + +export function openDb(path: string): GatewayDb { + const db = new Database(path); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + migrate(db); + return db; +} diff --git a/minimal-sdk/packages/gateway/src/errors.ts b/minimal-sdk/packages/gateway/src/errors.ts new file mode 100644 index 00000000..d0bc34d2 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/errors.ts @@ -0,0 +1,17 @@ +/** Structured HTTP errors surfaced by the gateway's uniform error handler. */ +export class HttpError extends Error { + constructor( + readonly statusCode: number, + readonly code: string, + message: string, + /** + * Gateway-internal origin (the RLN or wallet failure this was mapped + * from). Logged by the central error handler, never sent to a client — + * the response body carries only `code` and `message` (invariant I4). + */ + options?: { cause?: unknown }, + ) { + super(message, options); + this.name = 'HttpError'; + } +} diff --git a/minimal-sdk/packages/gateway/src/idempotency.ts b/minimal-sdk/packages/gateway/src/idempotency.ts new file mode 100644 index 00000000..681609f1 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/idempotency.ts @@ -0,0 +1,185 @@ +/** + * Idempotency-key middleware for money-moving routes (Block 3 item 2). + * + * Contract per route that declares it: + * - `Idempotency-Key` header is required (400 without it); + * - first request claims the (user, key) row, executes, and caches the final + * response (status < 500 and not 429 — retryable outcomes are not cached); + * - replay with the same key + same request hash returns the cached response; + * - same key + different request hash returns 409 IDEMPOTENCY_KEY_REUSED; + * - a concurrent duplicate while the first is in flight returns 409 + * IDEMPOTENCY_IN_FLIGHT (never a second execution); + * - an in-flight claim older than IN_FLIGHT_RECLAIM_MS is treated as a crash + * leftover (the response was never stored) and is reclaimed by the next + * replay OF THE SAME REQUEST, so a crashed request does not brick its key + * forever while a different request under that key still gets 409. Resource + * guards (resource_map, pending_ops) still prevent double execution; + * - completed rows older than RETENTION_MS are garbage-collected lazily. + */ +import { createHash } from 'node:crypto'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { GatewayDb } from './db.js'; +import { HttpError } from './errors.js'; + +export interface IdempotencyClaim { + key: string; + requestHash: string; + fresh: boolean; +} + +export interface IdempotencyHooks { + preHandler: (request: FastifyRequest, reply: FastifyReply) => Promise; + onSend: (request: FastifyRequest, reply: FastifyReply, payload: unknown) => Promise; +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`); + return `{${entries.join(',')}}`; +} + +export function requestHash(method: string, url: string, body: unknown): string { + return createHash('sha256') + .update(method) + .update('\n') + .update(url) + .update('\n') + .update(stableStringify(body ?? null)) + .digest('hex'); +} + +interface IdempotencyRow { + request_hash: string; + response: string | null; + created_at: number; +} + +function isStale(createdAt: number, now: number): boolean { + return now - createdAt >= IN_FLIGHT_RECLAIM_MS; +} + +interface CachedResponse { + statusCode: number; + contentType: string | null; + body: string; +} + +/** In-flight claims older than this are crash leftovers and may be reclaimed. */ +export const IN_FLIGHT_RECLAIM_MS = 15 * 60_000; +/** Completed rows older than this are garbage-collected. */ +export const RETENTION_MS = 7 * 24 * 60 * 60_000; + +export function makeIdempotencyHooks(db: GatewayDb): IdempotencyHooks { + const insert = db.prepare( + 'INSERT OR IGNORE INTO idempotency (user_id, key, request_hash, response, created_at) VALUES (?, ?, ?, NULL, ?)', + ); + const select = db.prepare( + 'SELECT request_hash, response, created_at FROM idempotency WHERE user_id = ? AND key = ?', + ); + const store = db.prepare('UPDATE idempotency SET response = ? WHERE user_id = ? AND key = ?'); + const release = db.prepare('DELETE FROM idempotency WHERE user_id = ? AND key = ?'); + const reclaim = db.prepare( + `UPDATE idempotency SET created_at = ? + WHERE user_id = ? AND key = ? AND request_hash = ? AND response IS NULL AND created_at <= ?`, + ); + const purge = db.prepare( + 'DELETE FROM idempotency WHERE response IS NOT NULL AND created_at <= ?', + ); + + const preHandler = async (request: FastifyRequest, reply: FastifyReply): Promise => { + const userId = request.userId; + if (userId === undefined) { + throw new HttpError(500, 'INTERNAL', 'idempotency requires an authenticated route'); + } + const key = request.headers['idempotency-key']; + if (typeof key !== 'string' || key.length === 0 || key.length > 128) { + throw new HttpError(400, 'IDEMPOTENCY_KEY_REQUIRED', 'Idempotency-Key header is required'); + } + const now = Date.now(); + const hash = requestHash(request.method, request.routeOptions.url ?? request.url, request.body); + const claimed = insert.run(userId, key, hash, now).changes === 1; + if (claimed) { + purge.run(now - RETENTION_MS); + request.idempotency = { key, requestHash: hash, fresh: true }; + return; + } + const row = select.get(userId, key) as IdempotencyRow | undefined; + if (row === undefined) { + throw new HttpError(500, 'INTERNAL', 'idempotency claim vanished'); + } + // A different request under the same key is key reuse, full stop — the + // reclaim path below is for retrying the SAME request after a crash, not + // for repurposing a key. + if (row.request_hash !== hash) { + throw new HttpError( + 409, + 'IDEMPOTENCY_KEY_REUSED', + 'Idempotency-Key was already used with a different request', + ); + } + if (row.response === null) { + // Reclaim a crash leftover atomically; if another request beat us to it + // (created_at moved), fall through to IN_FLIGHT. + if ( + isStale(row.created_at, now) && + reclaim.run(now, userId, key, hash, now - IN_FLIGHT_RECLAIM_MS).changes === 1 + ) { + request.idempotency = { key, requestHash: hash, fresh: true }; + return; + } + throw new HttpError( + 409, + 'IDEMPOTENCY_IN_FLIGHT', + 'a request with this Idempotency-Key is still in flight', + ); + } + const cached = JSON.parse(row.response) as CachedResponse; + reply.header('x-idempotent-replay', 'true'); + if (cached.contentType !== null) reply.type(cached.contentType); + await reply.code(cached.statusCode).send(cached.body); + }; + + const onSend = async ( + request: FastifyRequest, + reply: FastifyReply, + payload: unknown, + ): Promise => { + const claim = request.idempotency; + if (claim === undefined || !claim.fresh) return payload; + const retryable = reply.statusCode >= 500 || reply.statusCode === 429; + if (retryable) { + release.run(request.userId, claim.key); + return payload; + } + // Fastify serializes JSON routes before onSend, so payload is a string + // (or Buffer). A stream cannot be cached: release the claim loudly rather + // than store an empty body that a replay would serve as the "response". + const body = + typeof payload === 'string' + ? payload + : Buffer.isBuffer(payload) + ? payload.toString('utf8') + : null; + if (body === null) { + request.log.error( + { idempotencyKey: claim.key }, + 'idempotency: uncacheable response payload type; claim released', + ); + release.run(request.userId, claim.key); + return payload; + } + const cached: CachedResponse = { + statusCode: reply.statusCode, + contentType: (reply.getHeader('content-type') as string | undefined) ?? null, + body, + }; + store.run(JSON.stringify(cached), request.userId, claim.key); + return payload; + }; + + return { preHandler, onSend }; +} diff --git a/minimal-sdk/packages/gateway/src/index.ts b/minimal-sdk/packages/gateway/src/index.ts new file mode 100644 index 00000000..ae6ddf4e --- /dev/null +++ b/minimal-sdk/packages/gateway/src/index.ts @@ -0,0 +1,119 @@ +/** + * @utexo/minimal-gateway — API gateway in front of one shared RLN instance. + */ +export const GATEWAY_NAME = '@utexo/minimal-gateway'; + +export { loadConfig, ConfigError, type GatewayConfig } from './config.js'; +export { openDb, migrate, schemaVersion, type GatewayDb } from './db.js'; +export { + createUser, + findUserByToken, + generateToken, + hashToken, + safeEqual, + makeAuthHook, + makeOperatorGuard, + type CreatedUser, + type PreHandler, +} from './auth.js'; +export { UserQueues, QueueFullError, type UserQueuesOptions } from './queue.js'; +export { + makeIdempotencyHooks, + requestHash, + type IdempotencyHooks, + type IdempotencyClaim, +} from './idempotency.js'; +export { HttpError } from './errors.js'; +export { buildServer, type BuildServerOptions, type RouteSchemaEntry } from './server.js'; +export { + findKeyMaterialFields, + findKeyMaterialInValue, + KEY_MATERIAL_FIELD_NAMES, +} from './schemas/scan.js'; +export { + RlnClient, + RlnHttpError, + RlnNetworkError, + RlnTimeoutError, + sanitizeRlnError, + DEFAULT_RLN_TIMEOUT_MS, + type RlnApi, + type RlnClientOptions, +} from './rln/client.js'; +export { + BTC_ASSET, + FloatCapExceededError, + InsufficientBalanceError, + Ledger, + type LedgerEntryParams, + type LedgerKind, + type LedgerRow, +} from './ledger.js'; +export { + LnFlows, + mapHtlcStatus, + refundOutboundPayment, + refundWithdrawal, + settleInboundInvoice, + type GatewayPaymentStatus, + type LnInvoiceRow, +} from './routes/ln.js'; +export { DepositsWorker, type DepositsWorkerOptions } from './workers/deposits.js'; +export { Reconciler, type ReconcilerOptions } from './workers/reconciler.js'; +export { + recordOwnership, + ownerOf, + updateResourceState, + filterOwned, + scopePayments, + scopeTransfers, + OwnershipConflictError, + type ResourceKind, + type ResourceRecord, +} from './rln/scoping.js'; +export type * from './rln/types.js'; +export { + WalletBackendError, + type Balance, + type CreateUtxosParams, + type ReceiveData, + type ReceiveRequest, + type SendAssetBeginRequest, + type UserXpubs, + type WalletAsset, + type WalletBackend, + type WalletBtcBalance, + type WalletHandle, + type WalletIdentity, + type WalletTransfer, + type WalletUnspent, +} from './wallets/backend.js'; +export { + NativeWalletBackend, + loadNativeRgbLib, + restoreKeysForTests, + type NativeWalletBackendOptions, +} from './wallets/rgblib.js'; +export { WalletOpQueues } from './wallets/queue.js'; +export { WalletPool, type WalletPoolOptions } from './wallets/pool.js'; +export { + WalletService, + type ReceiveParams, + type ReceiveResult, + type WalletBalances, +} from './wallets/service.js'; +export { + OnchainService, + addressToScriptHex, + txidFromPsbt, + type CompletedOp, + type IntentAsset, + type IntentRecipient, + type IntentUtxos, + type OnchainIntent, + type OnchainOpKind, + type PreparedOp, + type PrepareCreateUtxosParams, + type PrepareSendAssetParams, + type PrepareSendBtcParams, +} from './wallets/prepare.js'; diff --git a/minimal-sdk/packages/gateway/src/ledger.ts b/minimal-sdk/packages/gateway/src/ledger.ts new file mode 100644 index 00000000..efbc8fa3 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/ledger.ts @@ -0,0 +1,184 @@ +/** + * Capped-float ledger for the custodial LN working balance (Block 3 / Block 1). + * + * Append-only `ledger` rows; balances are always derived (SUM of deltas — + * never stored). + * + * Float caps deliberately do NOT live here. They are a policy on how much new + * custodial exposure the gateway accepts, and the only place that can decide + * it is where the exposure is created — LnFlows.assertBtcHeadroom (routes/ln.ts), + * which counts pending deposit intents and unexpired invoices alongside the + * settled balance. Once funds have actually arrived, a credit must always + * succeed: refusing it would strand money the node already holds, and refunds + * must never fail or money would vanish from the books. Keeping a second cap + * check here would only create two policies that can silently diverge. + * + * Exactly-once: rows carrying a `ref` are unique per (user, asset, kind, ref) + * — enforced by a partial unique index — so replayed workers and retried + * requests can call creditOnce freely; the second application is a no-op. + * + * All methods are synchronous (better-sqlite3); use inTx() to compose ledger + * mutations atomically with resource_map / other gateway writes. + */ +import type { GatewayDb } from './db.js'; + +/** Ledger asset name for the BTC msat balance; anything else is an RGB asset id. */ +export const BTC_ASSET = 'btc'; + +/** + * Refunds are namespaced per originating flow ('refund_ln_out' reverses an + * 'ln_out' debit, 'refund_withdraw' a 'withdraw' one) rather than sharing one + * 'refund' kind. Exactly-once dedupes on (user, asset, kind, ref), and the two + * refs are both 64-hex values a user can choose on both sides — a payment hash + * (decodeLnInvoice only checks the BOLT11 self-signature) and + * sha256(userId||'\n'||idempotencyKey). A shared kind would let the second + * refund collide with the first, and creditOnce would silently no-op it: + * the debit would stand un-refunded with no error and no log line. + */ +export type LedgerKind = + 'deposit' | 'ln_in' | 'ln_out' | 'refund_ln_out' | 'refund_withdraw' | 'withdraw'; + +export class FloatCapExceededError extends Error { + constructor(readonly scope: 'user' | 'global') { + super(`${scope} float cap exceeded`); + this.name = 'FloatCapExceededError'; + } +} + +export class InsufficientBalanceError extends Error { + constructor() { + super('insufficient ledger balance'); + this.name = 'InsufficientBalanceError'; + } +} + +export interface LedgerEntryParams { + userId: string; + /** BTC_ASSET (msat) or an RGB asset id (units). */ + asset: string; + /** Always positive; credit/debit decides the sign. */ + amount: number; + kind: LedgerKind; + /** Exactly-once key within (user, asset, kind); null refs are not deduplicated. */ + ref?: string; +} + +export interface LedgerRow { + id: number; + user_id: string; + asset: string; + delta_msat_or_units: number; + kind: string; + ref: string | null; + created_at: number; +} + +function assertPositiveAmount(amount: number): void { + if (!Number.isSafeInteger(amount) || amount <= 0) { + throw new RangeError(`ledger amount must be a positive integer, got ${amount}`); + } +} + +export class Ledger { + private readonly insert; + private readonly sumUserAsset; + private readonly sumAsset; + private readonly existsRef; + + constructor(private readonly db: GatewayDb) { + this.insert = db.prepare( + `INSERT INTO ledger (user_id, asset, delta_msat_or_units, kind, ref, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ); + this.sumUserAsset = db.prepare( + 'SELECT COALESCE(SUM(delta_msat_or_units), 0) AS total FROM ledger WHERE user_id = ? AND asset = ?', + ); + this.sumAsset = db.prepare( + 'SELECT COALESCE(SUM(delta_msat_or_units), 0) AS total FROM ledger WHERE asset = ?', + ); + this.existsRef = db.prepare( + 'SELECT 1 FROM ledger WHERE user_id = ? AND asset = ? AND kind = ? AND ref = ?', + ); + } + + /** Run fn atomically (nested calls join the outer transaction). */ + inTx(fn: () => T): T { + return this.db.transaction(fn)(); + } + + balance(userId: string, asset: string): number { + return (this.sumUserAsset.get(userId, asset) as { total: number }).total; + } + + globalBalance(asset: string): number { + return (this.sumAsset.get(asset) as { total: number }).total; + } + + /** Every non-zero balance of a user, keyed by asset. */ + balances(userId: string): Record { + const rows = this.db + .prepare( + `SELECT asset, COALESCE(SUM(delta_msat_or_units), 0) AS total + FROM ledger WHERE user_id = ? GROUP BY asset HAVING total != 0`, + ) + .all(userId) as Array<{ asset: string; total: number }>; + return Object.fromEntries(rows.map((row) => [row.asset, row.total])); + } + + /** Append a credit. */ + credit(params: LedgerEntryParams, now: number = Date.now()): void { + assertPositiveAmount(params.amount); + this.inTx(() => { + this.insert.run( + params.userId, + params.asset, + params.amount, + params.kind, + params.ref ?? null, + now, + ); + }); + } + + /** + * Credit exactly once per (user, asset, kind, ref). Returns true when the + * credit was applied, false when an identical entry already exists. + */ + creditOnce(params: LedgerEntryParams, now: number = Date.now()): boolean { + if (params.ref === undefined) { + throw new TypeError('creditOnce requires a ref'); + } + return this.inTx(() => { + if (this.existsRef.get(params.userId, params.asset, params.kind, params.ref) !== undefined) { + return false; + } + this.credit(params, now); + return true; + }); + } + + /** Append a debit; throws InsufficientBalanceError when the balance is short. */ + debit(params: LedgerEntryParams, now: number = Date.now()): void { + assertPositiveAmount(params.amount); + this.inTx(() => { + if (this.balance(params.userId, params.asset) < params.amount) { + throw new InsufficientBalanceError(); + } + this.insert.run( + params.userId, + params.asset, + -params.amount, + params.kind, + params.ref ?? null, + now, + ); + }); + } + + /** All entries for a user, oldest first (audit/tests). */ + entries(userId: string): LedgerRow[] { + return this.db + .prepare('SELECT * FROM ledger WHERE user_id = ? ORDER BY id') + .all(userId) as LedgerRow[]; + } +} diff --git a/minimal-sdk/packages/gateway/src/main.ts b/minimal-sdk/packages/gateway/src/main.ts new file mode 100644 index 00000000..ba5f8607 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/main.ts @@ -0,0 +1,27 @@ +/** + * Production entrypoint: load config from the environment, build the server + * with background workers, listen, and shut down cleanly on SIGINT/SIGTERM. + * Run with `node dist/main.js` (or `pnpm --filter @utexo/minimal-gateway start`). + */ +import { loadConfig } from './config.js'; +import { buildServer } from './server.js'; + +const config = loadConfig(); +const app = await buildServer({ config, startWorkers: true }); +await app.listen({ host: config.host, port: config.port }); + +let shuttingDown = false; +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => { + if (shuttingDown) return; + shuttingDown = true; + app.log.info({ signal }, 'shutting down'); + void app + .close() + .then(() => process.exit(0)) + .catch((error: unknown) => { + app.log.error({ err: error }, 'shutdown failed'); + process.exit(1); + }); + }); +} diff --git a/minimal-sdk/packages/gateway/src/queue.ts b/minimal-sdk/packages/gateway/src/queue.ts new file mode 100644 index 00000000..abed681f --- /dev/null +++ b/minimal-sdk/packages/gateway/src/queue.ts @@ -0,0 +1,92 @@ +/** + * Per-user FIFO queue with a global concurrency cap (Block 3 item 1). + * + * Every downstream operation (RLN call, wallet-service call) is funneled + * through enqueue(userId, fn). Guarantees: + * - per user: tasks run one at a time, strictly in enqueue order; + * - globally: at most `globalConcurrency` tasks run at once, granted in + * arrival order so no user can starve the others; + * - backpressure: a user with `perUserDepth` queued-or-running tasks gets an + * immediate QueueFullError (mapped to 429 + Retry-After by the server). + */ + +export class QueueFullError extends Error { + constructor(readonly retryAfterSeconds: number) { + super('per-user queue is full'); + this.name = 'QueueFullError'; + } +} + +class Semaphore { + private available: number; + private readonly waiters: Array<() => void> = []; + + constructor(capacity: number) { + this.available = capacity; + } + + async acquire(): Promise { + if (this.available > 0) { + this.available -= 1; + return; + } + await new Promise((resolve) => this.waiters.push(resolve)); + } + + release(): void { + const next = this.waiters.shift(); + if (next !== undefined) { + next(); + } else { + this.available += 1; + } + } +} + +export interface UserQueuesOptions { + globalConcurrency: number; + perUserDepth: number; +} + +export class UserQueues { + private readonly tails = new Map>(); + private readonly depths = new Map(); + private readonly semaphore: Semaphore; + + constructor(private readonly options: UserQueuesOptions) { + this.semaphore = new Semaphore(options.globalConcurrency); + } + + /** Queued-or-running task count for a user (0 when idle). */ + depth(userId: string): number { + return this.depths.get(userId) ?? 0; + } + + enqueue(userId: string, fn: () => Promise): Promise { + const depth = this.depth(userId); + if (depth >= this.options.perUserDepth) { + throw new QueueFullError(1); + } + this.depths.set(userId, depth + 1); + const previous = this.tails.get(userId) ?? Promise.resolve(); + const run: Promise = previous + .catch(() => undefined) + .then(async () => { + await this.semaphore.acquire(); + try { + return await fn(); + } finally { + this.semaphore.release(); + const remaining = this.depth(userId) - 1; + if (remaining <= 0) { + this.depths.delete(userId); + if (this.tails.get(userId) === run) this.tails.delete(userId); + } else { + this.depths.set(userId, remaining); + } + } + }); + this.tails.set(userId, run); + return run; + } +} diff --git a/minimal-sdk/packages/gateway/src/rln/client.ts b/minimal-sdk/packages/gateway/src/rln/client.ts new file mode 100644 index 00000000..d8015640 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/rln/client.ts @@ -0,0 +1,254 @@ +/** + * Thin typed fetch client for the single shared RLN instance. + * + * Every call carries a timeout. Failures surface as structured errors that + * keep the RLN error body for gateway-internal handling; nothing from an RLN + * error may reach an end user un-sanitized — call sanitizeRlnError() at the + * route boundary (invariant I4: the admin token and node internals stay inside + * the gateway). + */ +import { HttpError } from '../errors.js'; +import type { + AddressResponse, + DecodeLnInvoiceRequest, + DecodeLnInvoiceResponse, + GetPaymentRequest, + GetPaymentResponse, + InvoiceStatusRequest, + InvoiceStatusResponse, + ListPaymentsQuery, + ListPaymentsResponse, + ListTransfersRequest, + ListTransfersResponse, + LnInvoiceRequest, + LnInvoiceResponse, + NodeInfoResponse, + RefreshRequest, + RefreshResponse, + RgbInvoiceRequest, + RgbInvoiceResponse, + RlnErrorBody, + SendBtcRequest, + SendBtcResponse, + SendPaymentRequest, + SendPaymentResponse, + SendRgbRequest, + SendRgbResponse, +} from './types.js'; + +export const DEFAULT_RLN_TIMEOUT_MS = 30_000; + +/** + * The RLN surface the gateway's LN flows and workers depend on. Structural + * subset of RlnClient so tests can inject a plain-object fake. + */ +export type RlnApi = Pick< + RlnClient, + | 'address' + | 'rgbInvoice' + | 'sendBtc' + | 'sendRgb' + | 'lnInvoice' + | 'decodeLnInvoice' + | 'invoiceStatus' + | 'sendPayment' + | 'getPayment' + | 'listPayments' + | 'listTransfers' + | 'refreshTransfers' +>; + +/** RLN answered with a non-2xx status. Internal use only — sanitize at the boundary. */ +export class RlnHttpError extends Error { + constructor( + readonly path: string, + readonly status: number, + /** Parsed RLN error body when the response was `{error, code}`-shaped. */ + readonly body: RlnErrorBody | undefined, + /** Raw response text fallback for non-JSON error responses. */ + readonly rawBody: string, + ) { + super(`RLN ${path} failed with status ${status}${body !== undefined ? `: ${body.error}` : ''}`); + this.name = 'RlnHttpError'; + } +} + +/** The RLN call did not complete within the timeout. */ +export class RlnTimeoutError extends Error { + constructor( + readonly path: string, + readonly timeoutMs: number, + ) { + super(`RLN ${path} timed out after ${timeoutMs}ms`); + this.name = 'RlnTimeoutError'; + } +} + +/** The RLN call failed before an HTTP response existed (DNS, refused, reset). */ +export class RlnNetworkError extends Error { + constructor( + readonly path: string, + cause: unknown, + ) { + super(`RLN ${path} network failure`, { cause }); + this.name = 'RlnNetworkError'; + } +} + +/** + * Map any RLN client failure to a client-safe HttpError. RLN messages, status + * codes and bodies never pass through: end users learn only that the upstream + * call failed. The original error is attached as `cause` so the central error + * handler can log what actually went wrong — without it, a failed withdrawal + * left in 'ambiguous' state would have no gateway-side record at all. + */ +export function sanitizeRlnError(error: unknown): HttpError { + if (error instanceof RlnTimeoutError) { + return new HttpError(504, 'UPSTREAM_TIMEOUT', 'upstream node timed out', { cause: error }); + } + return new HttpError(502, 'UPSTREAM_ERROR', 'upstream node error', { cause: error }); +} + +export interface RlnClientOptions { + baseUrl: string; + /** Biscuit admin token; empty when RLN runs with --disable-authentication. */ + adminToken: string; + timeoutMs?: number; + /** Injectable for tests; defaults to global fetch. */ + fetchImpl?: typeof fetch; +} + +export class RlnClient { + private readonly baseUrl: string; + private readonly adminToken: string; + private readonly timeoutMs: number; + private readonly fetchImpl: typeof fetch; + + constructor(options: RlnClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ''); + this.adminToken = options.adminToken; + this.timeoutMs = options.timeoutMs ?? DEFAULT_RLN_TIMEOUT_MS; + this.fetchImpl = options.fetchImpl ?? fetch; + } + + private headers(hasBody: boolean): Record { + const headers: Record = {}; + if (hasBody) headers['content-type'] = 'application/json'; + if (this.adminToken !== '') headers['authorization'] = `Bearer ${this.adminToken}`; + return headers; + } + + private async request(method: 'GET' | 'POST', path: string, body?: unknown): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + // The timer stays armed until the BODY is read too: a response whose body + // stalls must not pin a queue slot forever. + let text: string; + let response: Response; + try { + try { + response = await this.fetchImpl(`${this.baseUrl}${path}`, { + method, + headers: this.headers(body !== undefined), + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + signal: controller.signal, + }); + text = await response.text(); + } catch (error) { + if (controller.signal.aborted) throw new RlnTimeoutError(path, this.timeoutMs); + throw new RlnNetworkError(path, error); + } + } finally { + clearTimeout(timer); + } + + if (!response.ok) { + let parsed: RlnErrorBody | undefined; + try { + const candidate: unknown = JSON.parse(text); + if ( + typeof candidate === 'object' && + candidate !== null && + typeof (candidate as { error?: unknown }).error === 'string' && + typeof (candidate as { code?: unknown }).code === 'number' + ) { + parsed = candidate as unknown as RlnErrorBody; + } + } catch { + // non-JSON error body; keep rawBody only + } + throw new RlnHttpError(path, response.status, parsed, text); + } + return JSON.parse(text) as T; + } + + private get(path: string, query?: Record): Promise { + let suffix = ''; + if (query !== undefined) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) params.set(key, String(value)); + } + const encoded = params.toString(); + if (encoded !== '') suffix = `?${encoded}`; + } + return this.request('GET', `${path}${suffix}`); + } + + private post(path: string, body?: unknown): Promise { + // RLN POST handlers expect a JSON body even when all fields are optional. + return this.request('POST', path, body ?? {}); + } + + nodeInfo(): Promise { + return this.get('/nodeinfo'); + } + + address(): Promise { + return this.post('/address'); + } + + sendBtc(request: SendBtcRequest): Promise { + return this.post('/sendbtc', request); + } + + rgbInvoice(request: RgbInvoiceRequest): Promise { + return this.post('/rgbinvoice', request); + } + + sendRgb(request: SendRgbRequest): Promise { + return this.post('/sendrgb', request); + } + + listTransfers(request: ListTransfersRequest): Promise { + return this.post('/listtransfers', request); + } + + refreshTransfers(request: RefreshRequest): Promise { + return this.post('/refreshtransfers', request); + } + + lnInvoice(request: LnInvoiceRequest): Promise { + return this.post('/lninvoice', request); + } + + decodeLnInvoice(request: DecodeLnInvoiceRequest): Promise { + return this.post('/decodelninvoice', request); + } + + invoiceStatus(request: InvoiceStatusRequest): Promise { + return this.post('/invoicestatus', request); + } + + sendPayment(request: SendPaymentRequest): Promise { + return this.post('/sendpayment', request); + } + + getPayment(request: GetPaymentRequest): Promise { + return this.post('/getpayment', request); + } + + listPayments(query: ListPaymentsQuery = {}): Promise { + return this.get('/listpayments', query); + } +} diff --git a/minimal-sdk/packages/gateway/src/rln/openapi.ts b/minimal-sdk/packages/gateway/src/rln/openapi.ts new file mode 100644 index 00000000..beebfb7d --- /dev/null +++ b/minimal-sdk/packages/gateway/src/rln/openapi.ts @@ -0,0 +1,1349 @@ +/** + * GENERATED FILE — do not edit by hand. + * + * RLN API types for the endpoint subset the gateway uses, generated from the + * repo-root openapi.yaml. Regenerate with: + * pnpm --filter @utexo/minimal-gateway generate:rln-types + */ +export interface paths { + "/nodeinfo": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get node info + * @description Get the LN node's info + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NodeInfoResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/address": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get a Bitcoin address + * @description Get a new Bitcoin address from the internal BDK wallet + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AddressResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/btcbalance": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get the BTC balance + * @description Get the node's bitcoin balance for the vanilla and colored wallets + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["BtcBalanceRequest"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BtcBalanceResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sendbtc": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Send BTC + * @description Send bitcoins on-chain + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SendBtcRequest"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SendBtcResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/createutxos": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create UTXOs + * @description Create UTXOs to be used for RGB operations + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateUtxosRequest"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmptyResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/rgbinvoice": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get an RGB invoice + * @description Get an RGB invoice to receive assets on-chain + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RgbInvoiceRequest"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RgbInvoiceResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/decodergbinvoice": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Decode an RGB invoice + * @description Decode the provided RGB invoice string + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["DecodeRGBInvoiceRequest"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DecodeRGBInvoiceResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sendrgb": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Send RGB assets + * @description Send RGB assets on-chain, supporting batch transfers to multiple recipients and/or multiple assets in a single transaction + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SendRgbRequest"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SendRgbResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/listtransfers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * List transfers + * @description List the node's RGB transfers, ordered most-recent first, scoped by an asset filter and optionally by txid. Supports index-based cursor pagination (idx) and optional filtering by status and creation time interval. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ListTransfersRequest"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListTransfersResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/refreshtransfers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Refresh transfers + * @description Refresh RGB pending transfers + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RefreshRequest"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RefreshResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/lninvoice": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get a LN invoice + * @description Get a LN invoice to receive a payment. Provide `payment_hash` to create a HODL invoice. Provide `description` to include a BOLT11 description, or `description_hash` to include an out-of-band BOLT11 description hash (mutually exclusive). Provide `min_final_cltv_expiry_delta` to request an explicit inbound final CLTV policy. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LNInvoiceRequest"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LNInvoiceResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/decodelninvoice": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Decode a LN invoice + * @description Decode the provided LN invoice string + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["DecodeLNInvoiceRequest"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DecodeLNInvoiceResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/invoicestatus": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get an invoice status + * @description Get the status of the provided LN invoice + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["InvoiceStatusRequest"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InvoiceStatusResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sendpayment": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Send a payment + * @description Pay the provided LN invoice + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SendPaymentRequest"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SendPaymentResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/getpayment": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get a payment + * @description Get a payment by its payment hash + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["GetPaymentRequest"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetPaymentResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/listpayments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List payments + * @description List the node's LN payments (inbound and outbound) ordered most-recent first. Supports index-based cursor pagination: pass the last_index_offset of a page as the next index_offset to fetch the next, older page. Optionally filter by status, direction (inbound/outbound) and creation time interval. + */ + get: { + parameters: { + query?: { + /** @description Exclusive upper-bound cursor: only payments with a lower index are returned. 0 or absent means start from the most recent payment. */ + index_offset?: number; + /** @description Maximum number of payments to return. Defaults to 100 when absent or zero. */ + max_payments?: number; + /** @description Return only payments with this HTLC status. */ + status?: "Pending" | "Succeeded" | "Failed" | "Claimable" | "Claiming" | "Cancelled"; + /** @description Return only inbound or only outbound payments. */ + direction?: "Inbound" | "Outbound"; + /** @description Return only payments created at or after this Unix timestamp (seconds). */ + created_after?: number; + /** @description Return only payments created at or before this Unix timestamp (seconds). */ + created_before?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListPaymentsResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/listchannels": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List channels + * @description List the node's LN channels + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListChannelsResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + AddressResponse: { + /** @example bcrt1qnc5y6j6dmejrkwy93farhvpezk0lf46gk7aecs */ + address: string; + }; + AssetFilter: components["schemas"]["AssetFilterAnyOrNone"] | components["schemas"]["AssetFilterNone"] | components["schemas"]["AssetFilterId"]; + AssetFilterAnyOrNone: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "AnyOrNone"; + }; + /** + * @example { + * "type": "Id", + * "value": "rgb:CJkb4YZw-jRiz2sk-~PARPio-wtVYI1c-XAEYCqO-wTfvRZ8" + * } + */ + AssetFilterId: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "Id"; + /** @example rgb:CJkb4YZw-jRiz2sk-~PARPio-wtVYI1c-XAEYCqO-wTfvRZ8 */ + value: string; + }; + AssetFilterNone: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "None"; + }; + /** @enum {string} */ + AssetSchema: "Nia" | "Uda" | "Cfa" | "Ifa"; + Assignment: components["schemas"]["AssignmentFungible"] | components["schemas"]["AssignmentNonFungible"] | components["schemas"]["AssignmentInflationRight"] | components["schemas"]["AssignmentAny"]; + AssignmentAny: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "Any"; + }; + /** + * @example { + * "type": "Fungible", + * "value": 42 + * } + */ + AssignmentFungible: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "Fungible"; + /** @example 42 */ + value: number; + }; + AssignmentInflationRight: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "InflationRight"; + /** @example 200 */ + value: number; + }; + AssignmentNonFungible: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "NonFungible"; + }; + /** + * @example Regtest + * @enum {string} + */ + BitcoinNetwork: "Mainnet" | "Testnet" | "Testnet4" | "Signet" | "SignetCustom" | "Regtest"; + BtcBalance: { + /** @example 777000 */ + settled: number; + /** @example 777000 */ + future: number; + /** @example 777000 */ + spendable: number; + }; + BtcBalanceRequest: { + /** @example false */ + skip_sync: boolean; + }; + BtcBalanceResponse: { + vanilla: components["schemas"]["BtcBalance"]; + colored: components["schemas"]["BtcBalance"]; + }; + Channel: { + /** @example 8129afe1b1d7cf60d5e1bf4c04b09bec925ed4df5417ceee0484e24f816a105a */ + channel_id: string; + /** @example 5a106a814fe28404eece1754dfd45e92ec9bb0044cbfe1d560cfd7b1e1af2981 */ + funding_txid?: string | null; + /** @example 03b79a4bc1ec365524b4fab9a39eb133753646babb5a1da5c4bc94c53110b7795d */ + peer_pubkey: string; + /** @example null */ + peer_alias?: string | null; + /** @example 120946279120896 */ + short_channel_id?: number | null; + status: components["schemas"]["ChannelStatus"]; + /** @example false */ + ready: boolean; + /** @example 30010 */ + capacity_sat: number; + /** @example 28616 */ + local_balance_sat: number; + /** @example 21616000 */ + outbound_balance_msat: number; + /** @example 6394000 */ + inbound_balance_msat: number; + /** @example 3001000 */ + next_outbound_htlc_limit_msat: number; + /** @example 1 */ + next_outbound_htlc_minimum_msat: number; + /** @example false */ + is_usable: boolean; + /** @example true */ + public: boolean; + /** @example rgb:CJkb4YZw-jRiz2sk-~PARPio-wtVYI1c-XAEYCqO-wTfvRZ8 */ + asset_id?: string | null; + /** @example 777 */ + asset_local_amount?: number | null; + /** @example 0 */ + asset_remote_amount?: number | null; + /** @example trusted_no_broadcast */ + virtual_open_mode?: string; + }; + /** @enum {string} */ + ChannelStatus: "Opening" | "Opened" | "Closing"; + CreateUtxosRequest: { + /** @example false */ + up_to: boolean; + /** @example 4 */ + num?: number | null; + /** @example 32500 */ + size?: number | null; + /** @example 5 */ + fee_rate: number; + /** @example false */ + skip_sync: boolean; + }; + DecodeLNInvoiceRequest: { + /** @example lnbcrt30u1pjv6yzndqud3jxktt5w46x7unfv9kz6mn0v3jsnp4qdpc280eur52luxppv6f3nnj8l6vnd9g2hnv3qv6mjhmhvlzf6327pp5tjjasx6g9dqptea3fhm6yllq5wxzycnnvp8l6wcq3d6j2uvpryuqsp5l8az8x3g8fe05dg7cmgddld3da09nfjvky8xftwsk4cj8p2l7kfq9qyysgqcqpcxqzdylzlwfnkyw3jv344x4rzwgkk53ng0fhxy5rdduk4g5tpvea8xa6rfckkza35va28xjn2tqkhgarcxep5umm4x5k56wfcdvu95eq7qzp20vrl4xz76syapsa3c09j7lg5gerkaj63llj0ark7ph8hfketn6fkqzm8laf66dhsncm23wkwm5l5377we9e8lnlknnkwje5eefkccusqm6rqt8 */ + invoice: string; + }; + DecodeLNInvoiceResponse: { + /** @example 3000000 */ + amt_msat?: number | null; + /** @example 420 */ + expiry_sec: number; + /** @example 1691160659 */ + timestamp: number; + /** @example rgb:CJkb4YZw-jRiz2sk-~PARPio-wtVYI1c-XAEYCqO-wTfvRZ8 */ + asset_id?: string | null; + /** @example 42 */ + asset_amount?: number | null; + /** @example 1 cup of coffee */ + description?: string | null; + /** @example 5ca5d81b482b4015e7b14df7a27fe0a38c226273604ffd3b008b752571811938 */ + description_hash?: string | null; + /** @example 5ca5d81b482b4015e7b14df7a27fe0a38c226273604ffd3b008b752571811938 */ + payment_hash: string; + /** @example f9fa239a283a72fa351ec6d0d6fdb16f5e59a64cb10e64add0b57123855ff592 */ + payment_secret: string; + /** @example 0343851df9e0e8aff0c10b3498ce723ff4c9b4a855e6c8819adcafbbb3e24ea2af */ + payee_pubkey?: string | null; + /** @example 144 */ + min_final_cltv_expiry_delta: number; + network: components["schemas"]["BitcoinNetwork"]; + }; + DecodeRGBInvoiceRequest: { + /** @example rgb:icfqnK9y-wObZKTu-XJcDL98-sKbE5Mh-OuDJhiI-brRJrzE/RWhwUfTMpuP2Zfx1~j4nswCANGeJrYOqDcKelaMV4zU/~/bcrt:utxob:cbgHUJ4e-7QyKY4U-Jsj5AZw-oI0gxZh-7fxQY2_-tFFUAZN-4CgpX?expiry=1749906951&endpoints=rpcs://proxy.iriswallet.com/0.2/json-rpc */ + invoice: string; + }; + DecodeRGBInvoiceResponse: { + /** @example bcrt:utxob:cbgHUJ4e-7QyKY4U-Jsj5AZw-oI0gxZh-7fxQY2_-tFFUAZN-4CgpX */ + recipient_id: string; + /** @example bcrt:utxob:cbgHUJ4e-7QyKY4U-Jsj5AZw-oI0gxZh-7fxQY2_-tFFUAZN-4CgpY */ + proxy_recipient_id: string; + recipient_type: components["schemas"]["RecipientType"]; + asset_schema?: components["schemas"]["AssetSchema"] | null; + /** @example rgb:icfqnK9y-wObZKTu-XJcDL98-sKbE5Mh-OuDJhiI-brRJrzE */ + asset_id?: string | null; + assignment: components["schemas"]["Assignment"]; + network: components["schemas"]["BitcoinNetwork"]; + /** @example 1698325849 */ + expiration_timestamp?: number | null; + transport_endpoints: string[]; + /** @example {} */ + unknown_query_params: { + [key: string]: string; + }; + }; + EmptyResponse: Record; + GetPaymentRequest: { + /** @example 5ca5d81b482b4015e7b14df7a27fe0a38c226273604ffd3b008b752571811938 */ + payment_hash: string; + payment_type: components["schemas"]["PaymentType"]; + }; + GetPaymentResponse: { + payment: components["schemas"]["Payment"]; + }; + /** @enum {string} */ + HTLCStatus: "Pending" | "Claimable" | "Claiming" | "Succeeded" | "Cancelled" | "Failed"; + /** @enum {string} */ + InvoiceStatus: "Pending" | "Claimable" | "Claiming" | "Succeeded" | "Cancelled" | "Failed" | "Expired"; + InvoiceStatusRequest: { + /** @example lnbcrt30u1pjv6yzndqud3jxktt5w46x7unfv9kz6mn0v3jsnp4qdpc280eur52luxppv6f3nnj8l6vnd9g2hnv3qv6mjhmhvlzf6327pp5tjjasx6g9dqptea3fhm6yllq5wxzycnnvp8l6wcq3d6j2uvpryuqsp5l8az8x3g8fe05dg7cmgddld3da09nfjvky8xftwsk4cj8p2l7kfq9qyysgqcqpcxqzdylzlwfnkyw3jv344x4rzwgkk53ng0fhxy5rdduk4g5tpvea8xa6rfckkza35va28xjn2tqkhgarcxep5umm4x5k56wfcdvu95eq7qzp20vrl4xz76syapsa3c09j7lg5gerkaj63llj0ark7ph8hfketn6fkqzm8laf66dhsncm23wkwm5l5377we9e8lnlknnkwje5eefkccusqm6rqt8 */ + invoice: string; + }; + InvoiceStatusResponse: { + status: components["schemas"]["InvoiceStatus"]; + }; + LNInvoiceRequest: { + /** @example 3000000 */ + amt_msat?: number | null; + /** @example 420 */ + expiry_sec: number; + /** @example rgb:CJkb4YZw-jRiz2sk-~PARPio-wtVYI1c-XAEYCqO-wTfvRZ8 */ + asset_id?: string | null; + /** @example 42 */ + asset_amount?: number | null; + /** + * @description Optional. When provided, the invoice is created as HODL. + * @example 3febfae1e68b190c15461f4c2a3290f9af1dae63fd7d620d2bd61601869026cd + */ + payment_hash?: string; + /** + * @description Optional. When provided, the invoice includes a BOLT11 description. Mutually exclusive with description_hash. + * @example 1 cup of coffee + */ + description?: string; + /** + * @description Optional. When provided, the invoice includes a BOLT11 description hash. Mutually exclusive with description. + * @example 5ca5d81b482b4015e7b14df7a27fe0a38c226273604ffd3b008b752571811938 + */ + description_hash?: string; + /** + * @description Optional. Requested inbound final CLTV policy in blocks. + * @example 144 + */ + min_final_cltv_expiry_delta?: number; + }; + LNInvoiceResponse: { + /** @example lnbcrt30u1pjv6yzndqud3jxktt5w46x7unfv9kz6mn0v3jsnp4qdpc280eur52luxppv6f3nnj8l6vnd9g2hnv3qv6mjhmhvlzf6327pp5tjjasx6g9dqptea3fhm6yllq5wxzycnnvp8l6wcq3d6j2uvpryuqsp5l8az8x3g8fe05dg7cmgddld3da09nfjvky8xftwsk4cj8p2l7kfq9qyysgqcqpcxqzdylzlwfnkyw3jv344x4rzwgkk53ng0fhxy5rdduk4g5tpvea8xa6rfckkza35va28xjn2tqkhgarcxep5umm4x5k56wfcdvu95eq7qzp20vrl4xz76syapsa3c09j7lg5gerkaj63llj0ark7ph8hfketn6fkqzm8laf66dhsncm23wkwm5l5377we9e8lnlknnkwje5eefkccusqm6rqt8 */ + invoice: string; + }; + ListChannelsResponse: { + channels: components["schemas"]["Channel"][]; + }; + ListPaymentsResponse: { + payments: components["schemas"]["Payment"][]; + /** + * Format: uint64 + * @description Index of the first (most recent) payment in the returned page, or 0 when the page is empty. + * @example 100 + */ + first_index_offset: number; + /** + * Format: uint64 + * @description Index of the last (oldest) payment in the returned page, or 0 when the page is empty. Pass this as index_offset to fetch the next, older page. + * @example 51 + */ + last_index_offset: number; + }; + /** @description asset_filter selects the asset scope: Id (that asset's transfers), None (only transfers not tied to an asset) or AnyOrNone (no asset restriction). txid further restricts the result to the transfers committed by that on-chain transaction, across all assets; combined with an Id filter it acts as an intersection. AnyOrNone without a txid is rejected, so narrow by asset_filter, by txid, or by both. */ + ListTransfersRequest: { + asset_filter: components["schemas"]["AssetFilter"]; + /** + * @description Return the transfers committed by the on-chain transaction with this txid. Combined with an Id asset_filter it acts as an intersection. + * @example 47ee0f5b7bd5b0dd7f10ce54a94fee1b5cd54e5241b0f70f9f373d10e7a3c3e2 + */ + txid?: string | null; + /** + * Format: uint64 + * @description Exclusive upper-bound cursor: only transfers with a lower idx are returned. 0 or absent means start from the most recent. + */ + index_offset?: number | null; + /** + * Format: uint64 + * @description Maximum number to return. Defaults to 100 when absent or zero. + */ + max_transfers?: number | null; + /** + * @description Return only transfers with this status. + * @enum {string|null} + */ + status?: "Initiated" | "WaitingCounterparty" | "WaitingSafeHeight" | "WaitingConfirmations" | "Settled" | "Failed" | null; + /** + * Format: uint64 + * @description Return only transfers created at or after this Unix timestamp (seconds). + */ + created_after?: number | null; + /** + * Format: uint64 + * @description Return only transfers created at or before this Unix timestamp (seconds). + */ + created_before?: number | null; + }; + ListTransfersResponse: { + transfers: components["schemas"]["Transfer"][]; + /** + * Format: uint64 + * @description Index (idx) of the first transfer in the page, or 0 when empty. + */ + first_index_offset: number; + /** + * Format: uint64 + * @description Index (idx) of the last transfer in the page, or 0 when empty. Pass as index_offset to fetch the next, older page. + */ + last_index_offset: number; + }; + NodeInfoResponse: { + /** @example 02270dadcd6e7ba0ef707dac72acccae1a3607453a8dd2aef36ff3be4e0d31f043 */ + pubkey: string; + /** @example 1 */ + num_channels: number; + /** @example 0 */ + num_usable_channels: number; + /** @example 28616 */ + local_balance_sat: number; + /** @example 892 */ + eventual_close_fees_sat: number; + /** @example 7852 */ + pending_outbound_payments_sat: number; + /** @example 1 */ + num_peers: number; + /** @example tpubDDfzqHEET3ksD81qshMHkw35yp6TuLP1kr5rWWeJcLAqDfMXKDJzmDwAnda6DCqw7kkkhPphuDZFE2a6Sw8h5ZA5NwmtTssEnjMqN7xMzSd */ + account_xpub_vanilla: string; + /** @example tpubDDcdKhaxwVV2T6xwigti7dSY1a7LHFwZmKAaLWtNhzrvuTXqjjzo8U7YQkUuPah5yHvnk3cbXmb18ZRFwHEKTFUQmA9dij1nPVA2LCJCiEa */ + account_xpub_colored: string; + /** @example 5 */ + max_media_upload_size_mb: number; + /** @example 3000000 */ + rgb_htlc_min_msat: number; + /** @example 30010 */ + rgb_channel_capacity_min_sat: number; + /** @example 5506 */ + channel_capacity_min_sat: number; + /** @example 16777215 */ + channel_capacity_max_sat: number; + /** @example 1 */ + channel_asset_min_amount: number; + /** @example 18446744073709552000 */ + channel_asset_max_amount: number; + /** @example 987226 */ + network_nodes: number; + /** @example 7812821 */ + network_channels: number; + /** + * @description Unix timestamp of the most recently applied Rapid Gossip Sync snapshot. Null when the node sources gossip via P2P or has not yet applied an RGS snapshot. + * @example 1715731200 + */ + latest_rgs_snapshot_timestamp?: number | null; + }; + Payment: { + /** @example 3000000 */ + amt_msat?: number | null; + /** @example 42 */ + asset_amount?: number | null; + /** @example rgb:CJkb4YZw-jRiz2sk-~PARPio-wtVYI1c-XAEYCqO-wTfvRZ8 */ + asset_id?: string | null; + /** @example 3febfae1e68b190c15461f4c2a3290f9af1dae63fd7d620d2bd61601869026cd */ + payment_hash: string; + payment_type: components["schemas"]["PaymentType"]; + status: components["schemas"]["HTLCStatus"]; + /** @example 1691160765 */ + created_at: number; + /** @example 1691162674 */ + updated_at: number; + /** @example 03b79a4bc1ec365524b4fab9a39eb133753646babb5a1da5c4bc94c53110b7795d */ + payee_pubkey: string; + /** @example 89d28bd306aa9bb906fd0ac31092d04c37c919a171b343083167e2a3cdc60578 */ + preimage?: string; + /** + * @description BOLT11 description (the d tag), if present + * @example 1 cup of coffee + */ + description?: string | null; + /** + * @description BOLT11 description hash (the h tag), hex-encoded, if present + * @example 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff + */ + description_hash?: string | null; + }; + /** @enum {string} */ + PaymentType: "Outbound" | "InboundAutoClaim" | "InboundHodl"; + Recipient: { + /** @example bcrt:utxob:2FZsSuk-iyVQLVuU4-Gc6J4qkE8-mLS17N4jd-MEx6cWz9F-MFkyE1n */ + recipient_id: string; + witness_data?: components["schemas"]["WitnessData"] | null; + assignment: components["schemas"]["Assignment"]; + transport_endpoints: string[]; + }; + /** @enum {string} */ + RecipientType: "Blind" | "Witness"; + RefreshFailure: { + /** @example InvalidConsignment */ + name: string; + /** @example Invalid consignment */ + message: string; + }; + RefreshFilter: { + status: components["schemas"]["RefreshTransferStatus"]; + incoming: boolean; + }; + RefreshRequest: { + /** @example rgb:2dkSTbr-jFhznbPmo-TQafzswCN-av4gTsJjX-ttx6CNou5-M98k8Zd */ + asset_id?: string | null; + /** @example [] */ + filter: components["schemas"]["RefreshFilter"][]; + /** @example false */ + skip_sync: boolean; + }; + RefreshResponse: { + transfers: { + [key: string]: components["schemas"]["RefreshedTransfer"]; + }; + }; + /** @enum {string} */ + RefreshTransferStatus: "WaitingCounterparty" | "WaitingConfirmations"; + RefreshedTransfer: { + /** + * @example WaitingBroadcast + * @enum {string|null} + */ + updated_status?: "Initiated" | "WaitingCounterparty" | "WaitingSafeHeight" | "WaitingConfirmations" | "WaitingBroadcast" | "Settled" | "Failed" | null; + failure?: components["schemas"]["RefreshFailure"] | null; + }; + RgbInvoiceRequest: { + /** @example 1 */ + min_confirmations: number; + /** @example rgb:CJkb4YZw-jRiz2sk-~PARPio-wtVYI1c-XAEYCqO-wTfvRZ8 */ + asset_id?: string | null; + /** @example null */ + assignment?: components["schemas"]["Assignment"] | null; + /** @example 1695811760 */ + expiration_timestamp?: number; + /** @example false */ + witness: boolean; + /** + * @example [ + * "rpc://127.0.0.1:3000/json-rpc" + * ] + */ + transport_endpoints: string[]; + }; + RgbInvoiceResponse: { + /** @example bcrt:utxob:cbgHUJ4e-7QyKY4U-Jsj5AZw-oI0gxZh-7fxQY2_-tFFUAZN-4CgpX */ + recipient_id: string; + /** @example rgb:~/~/~/bcrt:utxob:cbgHUJ4e-7QyKY4U-Jsj5AZw-oI0gxZh-7fxQY2_-tFFUAZN-4CgpX?expiry=1695811760&endpoints=rpc://127.0.0.1:3000/json-rpc */ + invoice: string; + /** @example 1695811760 */ + expiration_timestamp: number; + /** @example 1 */ + batch_transfer_idx: number; + }; + SendBtcRequest: { + /** @example 16900 */ + amount: number; + /** @example bcrt1qwxht5tut39dws8tjcf649tp908r8fr2j75c94k */ + address: string; + /** @example 5 */ + fee_rate: number; + /** @example false */ + skip_sync: boolean; + }; + SendBtcResponse: { + /** @example 7c2c95b9c2aa0a7d140495b664de7973b76561de833f0dd84def3efa08941664 */ + txid: string; + }; + SendPaymentRequest: { + /** @example lnbcrt30u1pjv6yzndqud3jxktt5w46x7unfv9kz6mn0v3jsnp4qdpc280eur52luxppv6f3nnj8l6vnd9g2hnv3qv6mjhmhvlzf6327pp5tjjasx6g9dqptea3fhm6yllq5wxzycnnvp8l6wcq3d6j2uvpryuqsp5l8az8x3g8fe05dg7cmgddld3da09nfjvky8xftwsk4cj8p2l7kfq9qyysgqcqpcxqzdylzlwfnkyw3jv344x4rzwgkk53ng0fhxy5rdduk4g5tpvea8xa6rfckkza35va28xjn2tqkhgarcxep5umm4x5k56wfcdvu95eq7qzp20vrl4xz76syapsa3c09j7lg5gerkaj63llj0ark7ph8hfketn6fkqzm8laf66dhsncm23wkwm5l5377we9e8lnlknnkwje5eefkccusqm6rqt8 */ + invoice: string; + /** @example 3000000 */ + amt_msat?: number | null; + /** @example rgb:CJkb4YZw-jRiz2sk-~PARPio-wtVYI1c-XAEYCqO-wTfvRZ8 */ + asset_id?: string | null; + /** @example 100 */ + asset_amount?: number | null; + }; + SendPaymentResponse: { + /** @example 3febfae1e68b190c15461f4c2a3290f9af1dae63fd7d620d2bd61601869026cd */ + payment_id: string; + /** @example 3febfae1e68b190c15461f4c2a3290f9af1dae63fd7d620d2bd61601869026cd */ + payment_hash?: string | null; + /** @example 777a7756c620868199ed5fdc35bee4095b5709d543e5c2bf0494396bf27d2ea2 */ + payment_secret?: string | null; + status: components["schemas"]["HTLCStatus"]; + }; + SendRgbRequest: { + /** @example false */ + donation: boolean; + /** @example 5 */ + fee_rate: number; + /** @example 1 */ + min_confirmations: number; + /** @example 1695811760 */ + expiration_timestamp?: number; + /** + * @example { + * "rgb:CJkb4YZw-jRiz2sk-~PARPio-wtVYI1c-XAEYCqO-wTfvRZ8": [ + * { + * "recipient_id": "utxob:2FjRqgQ-eEWCVHY5-zmpFtYzT-gGm3MdR-sTnxNcS-7RtUbY9-4NYuuh", + * "assignment": { + * "type": "Fungible", + * "value": 400 + * }, + * "transport_endpoints": [ + * "rpc://127.0.0.1:3000/json-rpc" + * ] + * }, + * { + * "recipient_id": "utxob:3GkRrhR-fFXDLIZ6-0anqGuzU-hHn4NeS-tUoyOdT-8SuVcZ0-5OZvvi", + * "assignment": { + * "type": "Fungible", + * "value": 200 + * }, + * "transport_endpoints": [ + * "rpc://127.0.0.1:3000/json-rpc" + * ] + * } + * ], + * "rgb:d8qDVS5X-ICVG2uM-CPr3yO4-lfBhgjt-7FN1EPE-ApY1LcM": [ + * { + * "recipient_id": "utxob:4HlSsiS-gGYEMKA7-1borHvaV-iIo5OfT-uVpzPeU-9TvWdA1-6PAwwj", + * "assignment": { + * "type": "Fungible", + * "value": 100 + * }, + * "transport_endpoints": [ + * "rpc://127.0.0.1:3000/json-rpc" + * ] + * } + * ] + * } + */ + recipient_map: { + [key: string]: components["schemas"]["Recipient"][]; + }; + }; + SendRgbResponse: { + /** @example 7c2c95b9c2aa0a7d140495b664de7973b76561de833f0dd84def3efa08941664 */ + txid: string; + }; + Transfer: { + /** @example 1 */ + idx: number; + /** @example 1691160765 */ + created_at: number; + /** @example 1691162674 */ + updated_at: number; + status: components["schemas"]["TransferStatus"]; + requested_assignment?: components["schemas"]["Assignment"] | null; + assignments: components["schemas"]["Assignment"][]; + kind: components["schemas"]["TransferKind"]; + /** @example 7c2c95b9c2aa0a7d140495b664de7973b76561de833f0dd84def3efa08941664 */ + txid?: string | null; + /** @example 61qsVbWtkNmU54F2i6qtB9uSmEGsPoaeypCi5uC5uctZ */ + recipient_id?: string | null; + /** @example 61qsVbWtkNmU54F2i6qtB9uSmEGsPoaeypCi5uC5ucX */ + proxy_recipient_id?: string | null; + /** @example efed66f5309396ff43c8a09941c8103d9d5bbffd473ad9f13013ac89fb6b4671:0 */ + receive_utxo?: string | null; + /** @example null */ + change_utxo?: string | null; + /** @example 1691171612 */ + expiration_timestamp?: number | null; + transport_endpoints: components["schemas"]["TransferTransportEndpoint"][]; + }; + /** + * @example ReceiveBlind + * @enum {string} + */ + TransferKind: "Issuance" | "ReceiveBlind" | "ReceiveWitness" | "Send" | "Inflation" | "Burn"; + /** @enum {string} */ + TransferStatus: "Initiated" | "WaitingCounterparty" | "WaitingSafeHeight" | "WaitingConfirmations" | "WaitingBroadcast" | "Settled" | "Failed"; + TransferTransportEndpoint: { + /** @example http://127.0.0.1:3000/json-rpc */ + endpoint: string; + transport_type: components["schemas"]["TransportType"]; + /** @example false */ + used: boolean; + }; + /** @enum {string} */ + TransportType: "JsonRpc"; + WitnessData: { + /** @example 1000 */ + amount_sat: number; + /** @example 439017309 */ + blinding?: number | null; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/minimal-sdk/packages/gateway/src/rln/scoping.ts b/minimal-sdk/packages/gateway/src/rln/scoping.ts new file mode 100644 index 00000000..dd3530c7 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/rln/scoping.ts @@ -0,0 +1,129 @@ +/** + * User↔resource scoping over the shared single-tenant RLN node. + * + * RLN has no notion of users: every invoice, payment and transfer it reports + * is node-wide. The gateway records ownership in resource_map at creation time + * and every RLN list/get response MUST pass through these filters before + * leaving the gateway (invariant I3: one user must never see another user's + * resources). Never proxy an RLN response raw. + */ +import type { GatewayDb } from '../db.js'; +import type { Payment, Transfer } from './types.js'; + +export type ResourceKind = 'invoice' | 'payment_hash' | 'asset_transfer' | 'swap'; + +export interface ResourceRecord { + kind: ResourceKind; + resourceId: string; + userId: string; + state: string; +} + +/** Ownership is claimed by a different user — a bug or an attack, never expected. */ +export class OwnershipConflictError extends Error { + constructor( + readonly kind: ResourceKind, + readonly resourceId: string, + ) { + super(`resource ${kind}:${resourceId} is already owned by another user`); + this.name = 'OwnershipConflictError'; + } +} + +/** + * Record that a resource belongs to a user, at creation time. Re-recording by + * the same owner updates the state; a different owner is a hard error + * (ownership never transfers). + */ +export function recordOwnership( + db: GatewayDb, + record: ResourceRecord, + now: number = Date.now(), +): void { + const existing = ownerOf(db, record.kind, record.resourceId); + if (existing !== undefined && existing !== record.userId) { + throw new OwnershipConflictError(record.kind, record.resourceId); + } + db.prepare( + `INSERT INTO resource_map (kind, resource_id, user_id, state, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (kind, resource_id) DO UPDATE SET state = excluded.state + WHERE resource_map.user_id = excluded.user_id`, + ).run(record.kind, record.resourceId, record.userId, record.state, now); +} + +export function ownerOf(db: GatewayDb, kind: ResourceKind, resourceId: string): string | undefined { + const row = db + .prepare('SELECT user_id FROM resource_map WHERE kind = ? AND resource_id = ?') + .get(kind, resourceId) as { user_id: string } | undefined; + return row?.user_id; +} + +/** Update the tracked state of an owned resource (no-op for foreign/unknown ones). */ +export function updateResourceState( + db: GatewayDb, + kind: ResourceKind, + resourceId: string, + userId: string, + state: string, +): boolean { + const result = db + .prepare('UPDATE resource_map SET state = ? WHERE kind = ? AND resource_id = ? AND user_id = ?') + .run(state, kind, resourceId, userId); + return result.changes === 1; +} + +/** + * Keep only items whose id resolves to a resource the user owns under one of + * the given kinds. Items with no extractable id are dropped: unattributable + * node-wide state must never leak. + */ +export function filterOwned( + db: GatewayDb, + userId: string, + kinds: readonly ResourceKind[] | ((item: T) => readonly ResourceKind[]), + items: readonly T[], + idOf: (item: T) => string | null | undefined, +): T[] { + const statement = db.prepare( + 'SELECT 1 FROM resource_map WHERE kind = ? AND resource_id = ? AND user_id = ?', + ); + const kindsOf = typeof kinds === 'function' ? kinds : (): readonly ResourceKind[] => kinds; + return items.filter((item) => { + const id = idOf(item); + if (id === null || id === undefined || id === '') return false; + return kindsOf(item).some((kind) => statement.get(kind, id, userId) !== undefined); + }); +} + +/** + * Scope an RLN payment list to one user. Ownership is matched per DIRECTION, + * never as a union over kinds: an outbound send is claimed under + * 'payment_hash' (LnFlows.pay), an inbound one under 'invoice' + * (LnFlows.createInvoice). Accepting either kind for either direction would + * break invariant I3 — RLN's /decodelninvoice only checks the BOLT11 + * self-signature, so any user can claim any hash under 'payment_hash' and + * would then be handed the INBOUND payment another user owns under it. + */ +export function scopePayments(db: GatewayDb, userId: string, payments: readonly Payment[]) { + return filterOwned( + db, + userId, + (payment: Payment) => (payment.payment_type === 'Outbound' ? ['payment_hash'] : ['invoice']), + payments, + (p) => p.payment_hash, + ); +} + +/** + * Scope an RLN transfer list to one user. Receives are matched by the + * recipient_id issued at invoice time; sends by txid recorded at send time. + */ +export function scopeTransfers(db: GatewayDb, userId: string, transfers: readonly Transfer[]) { + const statement = db.prepare( + "SELECT 1 FROM resource_map WHERE kind = 'asset_transfer' AND resource_id = ? AND user_id = ?", + ); + const isOwned = (id: string | null | undefined): boolean => + id !== null && id !== undefined && id !== '' && statement.get(id, userId) !== undefined; + return transfers.filter((t) => isOwned(t.recipient_id) || isOwned(t.txid)); +} diff --git a/minimal-sdk/packages/gateway/src/rln/types.ts b/minimal-sdk/packages/gateway/src/rln/types.ts new file mode 100644 index 00000000..c9129405 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/rln/types.ts @@ -0,0 +1,54 @@ +/** + * Friendly aliases over the generated RLN OpenAPI types (./openapi.ts) for the + * endpoint subset the gateway uses. Regenerate the underlying file with + * `pnpm --filter @utexo/minimal-gateway generate:rln-types`. + */ +import type { components, paths } from './openapi.js'; + +export type RlnComponents = components; +export type RlnPaths = paths; + +/** Error body every RLN endpoint returns on failure (src/error.rs APIErrorResponse). */ +export interface RlnErrorBody { + error: string; + code: number; + /** APIError variant name (e.g. 'PaymentNotFound') — the only reliable discriminator. */ + name?: string; +} + +export type NodeInfoResponse = components['schemas']['NodeInfoResponse']; +export type AddressResponse = components['schemas']['AddressResponse']; +export type BtcBalanceRequest = components['schemas']['BtcBalanceRequest']; +export type BtcBalanceResponse = components['schemas']['BtcBalanceResponse']; +export type SendBtcRequest = components['schemas']['SendBtcRequest']; +export type SendBtcResponse = components['schemas']['SendBtcResponse']; +export type CreateUtxosRequest = components['schemas']['CreateUtxosRequest']; +export type EmptyResponse = components['schemas']['EmptyResponse']; +export type RgbInvoiceRequest = components['schemas']['RgbInvoiceRequest']; +export type RgbInvoiceResponse = components['schemas']['RgbInvoiceResponse']; +export type DecodeRgbInvoiceRequest = components['schemas']['DecodeRGBInvoiceRequest']; +export type DecodeRgbInvoiceResponse = components['schemas']['DecodeRGBInvoiceResponse']; +export type SendRgbRequest = components['schemas']['SendRgbRequest']; +export type SendRgbResponse = components['schemas']['SendRgbResponse']; +export type ListTransfersRequest = components['schemas']['ListTransfersRequest']; +export type ListTransfersResponse = components['schemas']['ListTransfersResponse']; +export type Transfer = components['schemas']['Transfer']; +export type RefreshRequest = components['schemas']['RefreshRequest']; +export type RefreshResponse = components['schemas']['RefreshResponse']; +export type LnInvoiceRequest = components['schemas']['LNInvoiceRequest']; +export type LnInvoiceResponse = components['schemas']['LNInvoiceResponse']; +export type DecodeLnInvoiceRequest = components['schemas']['DecodeLNInvoiceRequest']; +export type DecodeLnInvoiceResponse = components['schemas']['DecodeLNInvoiceResponse']; +export type InvoiceStatusRequest = components['schemas']['InvoiceStatusRequest']; +export type InvoiceStatusResponse = components['schemas']['InvoiceStatusResponse']; +export type SendPaymentRequest = components['schemas']['SendPaymentRequest']; +export type SendPaymentResponse = components['schemas']['SendPaymentResponse']; +export type GetPaymentRequest = components['schemas']['GetPaymentRequest']; +export type GetPaymentResponse = components['schemas']['GetPaymentResponse']; +export type Payment = components['schemas']['Payment']; +export type HtlcStatus = components['schemas']['HTLCStatus']; +export type LnInvoiceStatus = components['schemas']['InvoiceStatus']; +export type ListPaymentsResponse = components['schemas']['ListPaymentsResponse']; +export type ListPaymentsQuery = NonNullable; +export type ListChannelsResponse = components['schemas']['ListChannelsResponse']; +export type Channel = components['schemas']['Channel']; diff --git a/minimal-sdk/packages/gateway/src/routes/ln.ts b/minimal-sdk/packages/gateway/src/routes/ln.ts new file mode 100644 index 00000000..f8a873fc --- /dev/null +++ b/minimal-sdk/packages/gateway/src/routes/ln.ts @@ -0,0 +1,1063 @@ +/** + * LN float flows: deposits into the custodial working balance, payments out, + * invoices in, withdrawals back to the user vault. + * + * State machines (persisted, crash-safe): + * - outbound payment (resource_map kind 'payment_hash'): + * debited_pending → sent → succeeded | failed(refunded) + * The debit happens BEFORE /sendpayment in one SQLite transaction with the + * ownership record. A definite RLN rejection refunds immediately; an + * ambiguous outcome (timeout/network) leaves debited_pending for the + * reconciler — the gateway NEVER auto-retries a send (Block 3 double-pay + * caution). A replayed request finds the existing state and does not send + * again; a hash in terminal 'failed' state answers 409 — get a fresh + * invoice rather than silently re-paying an old one. + * - inbound invoice (resource_map kind 'invoice' + ln_invoices): + * pending → settled(credited exactly-once) | expired + * + * Float caps are enforced where NEW custodial exposure is created (deposit + * prepare, invoice creation) counting pending intents; settlement credits and + * refunds are never blocked (the node already holds those funds). + * + * Fee policy: LN routing fees and on-chain withdrawal fees are paid by the + * node and NOT debited from the user's float balance — the operator subsidizes + * them (documented in the gateway README; caps bound the total exposure). + */ +import { createHash, randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import type { GatewayDb } from '../db.js'; +import { HttpError } from '../errors.js'; +import { BTC_ASSET, FloatCapExceededError, type Ledger } from '../ledger.js'; +import { RlnHttpError, sanitizeRlnError, type RlnApi } from '../rln/client.js'; +import { ownerOf, recordOwnership, scopePayments, updateResourceState } from '../rln/scoping.js'; +import type { GatewayConfig } from '../config.js'; +import type { HtlcStatus, Payment } from '../rln/types.js'; +import { + depositPrepareRouteSchema, + invoiceCreateRouteSchema, + invoiceGetRouteSchema, + lnBalanceRouteSchema, + paymentsListRouteSchema, + payRouteSchema, + withdrawRouteSchema, +} from '../schemas/ln.js'; + +export type GatewayPaymentStatus = 'pending' | 'succeeded' | 'failed'; + +export function mapHtlcStatus(status: HtlcStatus): GatewayPaymentStatus { + switch (status) { + case 'Succeeded': + return 'succeeded'; + case 'Cancelled': + case 'Failed': + return 'failed'; + default: + return 'pending'; + } +} + +export interface LnInvoiceRow { + payment_hash: string; + user_id: string; + invoice: string; + amt_msat: number | null; + asset_id: string | null; + asset_amount: number | null; + state: 'pending' | 'settled' | 'expired'; + created_at: number; + /** BOLT11 expiry (ms). A pending invoice past it stops pinning cap headroom. */ + expires_at: number; +} + +/** + * Credit a settled inbound invoice exactly once and mark it settled. Safe to + * call from every observer (reconciler, live status reads, replays): the + * ledger's (user, asset, kind, ref) uniqueness makes the second call a no-op. + */ +export function settleInboundInvoice( + db: GatewayDb, + ledger: Ledger, + row: Pick, + amtMsat: number, + assetAmount: number | null, +): void { + ledger.inTx(() => { + ledger.creditOnce({ + userId: row.user_id, + asset: BTC_ASSET, + amount: amtMsat, + kind: 'ln_in', + ref: row.payment_hash, + }); + if (row.asset_id !== null && assetAmount !== null && assetAmount > 0) { + ledger.creditOnce({ + userId: row.user_id, + asset: row.asset_id, + amount: assetAmount, + kind: 'ln_in', + ref: row.payment_hash, + }); + } + db.prepare("UPDATE ln_invoices SET state = 'settled' WHERE payment_hash = ?").run( + row.payment_hash, + ); + updateResourceState(db, 'invoice', row.payment_hash, row.user_id, 'settled'); + }); +} + +/** + * Settle a pending invoice using the ACTUAL received amounts from RLN's + * payment record (falls back to the declared row amounts when the lookup + * fails), so the user is credited what was really paid rather than what was + * asked for. An ASSET invoice is never settled from declared amounts alone: + * RGB credits are not bounded by the float caps, so guessing the units would + * mint ledger balance. Those stay pending for a later pass. + */ +export async function settleInvoiceWithLiveAmounts( + db: GatewayDb, + ledger: Ledger, + rln: Pick, + row: LnInvoiceRow, + log?: { warn(obj: unknown, msg: string): void }, +): Promise { + let payment: Payment | undefined; + try { + // Gateway invoices come from /lninvoice, which are auto-claim inbound. + payment = ( + await rln.getPayment({ payment_hash: row.payment_hash, payment_type: 'InboundAutoClaim' }) + ).payment; + } catch { + // Fall back to the declared amounts below. + } + if (row.asset_id !== null && (payment?.asset_amount ?? null) === null) { + log?.warn( + { paymentHash: row.payment_hash }, + 'settled asset invoice has no live asset amount; leaving pending', + ); + return false; + } + const amtMsat = payment?.amt_msat ?? row.amt_msat; + if (amtMsat === null || amtMsat === undefined) { + log?.warn({ paymentHash: row.payment_hash }, 'settled invoice has no resolvable amount'); + return false; + } + settleInboundInvoice(db, ledger, row, amtMsat, payment?.asset_amount ?? row.asset_amount); + return true; +} + +/** + * Refund every ln_out debit recorded for a payment hash (exactly once) and + * mark the payment failed. + */ +export function refundOutboundPayment( + db: GatewayDb, + ledger: Ledger, + userId: string, + paymentHash: string, +): void { + ledger.inTx(() => { + const debits = db + .prepare( + "SELECT asset, delta_msat_or_units FROM ledger WHERE user_id = ? AND kind = 'ln_out' AND ref = ?", + ) + .all(userId, paymentHash) as Array<{ asset: string; delta_msat_or_units: number }>; + for (const debit of debits) { + ledger.creditOnce({ + userId, + asset: debit.asset, + amount: -debit.delta_msat_or_units, + kind: 'refund_ln_out', + ref: paymentHash, + }); + } + updateResourceState(db, 'payment_hash', paymentHash, userId, 'failed'); + }); +} + +/** + * Refund every `withdraw` debit recorded for a withdrawal id (exactly once) + * and mark the row failed. A witness RGB withdraw debits TWO assets — the RGB + * units and the msat that funds the node-paid witness output — so the refund + * has to mirror what was actually debited rather than the row's single + * asset/amount pair. + */ +export function refundWithdrawal( + db: GatewayDb, + ledger: Ledger, + userId: string, + withdrawalId: string, +): void { + ledger.inTx(() => { + const debits = db + .prepare( + "SELECT asset, delta_msat_or_units FROM ledger WHERE user_id = ? AND kind = 'withdraw' AND ref = ?", + ) + .all(userId, withdrawalId) as Array<{ asset: string; delta_msat_or_units: number }>; + for (const debit of debits) { + ledger.creditOnce({ + userId, + asset: debit.asset, + amount: -debit.delta_msat_or_units, + kind: 'refund_withdraw', + ref: withdrawalId, + }); + } + db.prepare("UPDATE withdrawals SET state = 'failed' WHERE id = ?").run(withdrawalId); + }); +} + +interface DepositPrepareBody { + kind: 'btc' | 'rgb'; + amountMsat?: number; + assetId?: string; + amount?: number; +} + +interface PayBody { + invoice: string; + amtMsat?: number; + assetAmount?: number; +} + +interface InvoiceCreateBody { + amtMsat?: number; + expirySec?: number; + assetId?: string; + assetAmount?: number; + description?: string; +} + +interface WithdrawBody { + kind: 'btc' | 'rgb'; + address?: string; + amountSat?: number; + assetId?: string; + amount?: number; + recipientId?: string; + witnessAmountSat?: number; + transportEndpoints?: string[]; + feeRateSatPerVb?: number; +} + +const DEFAULT_INVOICE_EXPIRY_SEC = 3600; +const DEFAULT_WITHDRAW_FEE_RATE = 2; +/** + * A witness recipient id (`:wvout:…`) is paid by a NEW output, so + * rgb-lib demands its sat value up front and rejects the send outright without + * it; a blind one (`:utxob:…`) rejects the send if given one. + */ +const WITNESS_RECIPIENT_PATTERN = /^([a-z0-9]+:)?wvout:/; + +/** + * Classify an RLN failure on a money route. A 4xx means the node rejected + * THESE parameters, not that it is unhealthy: 502 would tell the caller to + * retry something that can never succeed, and — because the idempotency + * middleware treats 5xx as retryable — would release the claim for a request + * whose own row is already terminal. Anything else stays sanitized (502/504). + */ +function rlnRejection(error: unknown, code: string, message: string): HttpError { + if (error instanceof RlnHttpError && error.status >= 400 && error.status < 500) { + return new HttpError(400, code, message, { cause: error }); + } + return sanitizeRlnError(error); +} +/** Page size for the node-wide /listpayments walk behind GET /v1/ln/payments. */ +const PAYMENTS_PAGE_SIZE = 100; +/** + * Slack applied to the /listpayments age floor. RLN reports payment timestamps + * in unix seconds from its own clock, so the floor must tolerate gateway/node + * clock drift rather than cut a real payment out of the listing. + */ +const PAYMENT_WALK_SKEW_SEC = 86_400; + +export class LnFlows { + constructor( + private readonly db: GatewayDb, + private readonly ledger: Ledger, + private readonly rln: RlnApi, + private readonly config: GatewayConfig, + ) {} + + /** + * BTC cap headroom check for NEW exposure, counting not-yet-settled intents + * (pending deposits and pending fixed-amount invoices) so parallel prepares + * cannot overshoot the caps. + */ + private pendingBtcExposure(userId?: string, now: number = Date.now()): number { + const scope = userId === undefined ? '' : 'AND user_id = ?'; + const params = userId === undefined ? [] : [userId]; + // Expired deposit intents stop counting even before the worker sweeps + // them, so abandoned prepares cannot pin cap headroom forever. + const deposits = this.db + .prepare( + `SELECT COALESCE(SUM(amount), 0) AS total FROM pending_deposits + WHERE asset = ? AND state = 'pending' AND expires_at > ? ${scope}`, + ) + .get(BTC_ASSET, now, ...params) as { total: number }; + // Same reasoning for invoices: an invoice past its BOLT11 expiry can no + // longer be paid, so it must stop pinning headroom even when the + // reconciler has not yet reached RLN to flip it to 'expired'. + const invoices = this.db + .prepare( + `SELECT COALESCE(SUM(amt_msat), 0) AS total FROM ln_invoices + WHERE state = 'pending' AND amt_msat IS NOT NULL AND expires_at > ? ${scope}`, + ) + .get(now, ...params) as { total: number }; + return deposits.total + invoices.total; + } + + private assertBtcHeadroom(userId: string, amountMsat: number): void { + const userExposure = this.ledger.balance(userId, BTC_ASSET) + this.pendingBtcExposure(userId); + if (userExposure + amountMsat > this.config.floatCapPerUserMsat) { + throw new FloatCapExceededError('user'); + } + const globalExposure = this.ledger.globalBalance(BTC_ASSET) + this.pendingBtcExposure(); + if (globalExposure + amountMsat > this.config.floatCapGlobalMsat) { + throw new FloatCapExceededError('global'); + } + } + + async prepareDeposit(userId: string, body: DepositPrepareBody) { + const depositId = randomUUID(); + if (body.kind === 'btc') { + if (body.amountMsat === undefined) { + throw new HttpError(400, 'BAD_REQUEST', 'amountMsat is required for a btc deposit'); + } + // Fast-fail before the RLN round-trip; re-checked atomically below. + this.assertBtcHeadroom(userId, body.amountMsat); + let address: string; + try { + address = (await this.rln.address()).address; + } catch (error) { + throw sanitizeRlnError(error); + } + // Re-check + insert in ONE transaction: other users' inserts between the + // pre-check and here must not let the global cap overshoot. + this.ledger.inTx(() => { + // The deposits worker attributes on-chain funds by address, so each + // intent needs an address NEVER used by any earlier intent — not just + // by a currently-pending one. pollBtcDeposit sums every confirmed tx + // paying the address with no lower time bound, so reusing the address + // of an already-credited (or expired) deposit would re-credit that + // same payment to a second intent (money creation). RLN only pins + // addresses when run with --reuse-addresses (forbidden in the gateway + // README's deployment note); a collision here means that + // misconfiguration. + const clash = this.db + .prepare('SELECT 1 FROM pending_deposits WHERE target = ?') + .get(address); + if (clash !== undefined) { + throw new HttpError( + 502, + 'DEPOSIT_ADDRESS_CONFLICT', + 'node returned an address already assigned to a pending deposit', + ); + } + this.assertBtcHeadroom(userId, body.amountMsat as number); + this.insertPendingDeposit(depositId, userId, 'btc', BTC_ASSET, body.amountMsat as number, { + target: address, + invoice: null, + }); + }); + return { depositId, kind: 'btc' as const, address, invoice: null, recipientId: null }; + } + if (body.assetId === undefined || body.amount === undefined) { + throw new HttpError(400, 'BAD_REQUEST', 'assetId and amount are required for an rgb deposit'); + } + let invoice: string; + let recipientId: string; + try { + const response = await this.rln.rgbInvoice({ + min_confirmations: this.config.depositMinConfirmations, + asset_id: body.assetId, + assignment: { type: 'Fungible', value: body.amount }, + witness: false, + transport_endpoints: [this.config.rgbProxyUrl], + }); + invoice = response.invoice; + recipientId = response.recipient_id; + } catch (error) { + throw sanitizeRlnError(error); + } + // Same target-uniqueness rule as the BTC branch, and for the same reason: + // the deposits worker attributes RGB by recipient_id (findSettledTransfer + // matches transfers on it with no lower time bound), so two intents sharing + // one recipient id would credit the SAME settled transfer twice — units + // minted on the ledger the node does not hold. rgb-lib blinds a fresh + // secret per /rgbinvoice so a repeat is not expected; the check makes the + // invariant the deposits worker documents actually hold rather than assumed. + const assetId = body.assetId; + const amount = body.amount; + this.ledger.inTx(() => { + const clash = this.db + .prepare('SELECT 1 FROM pending_deposits WHERE target = ?') + .get(recipientId); + if (clash !== undefined) { + throw new HttpError( + 502, + 'DEPOSIT_ADDRESS_CONFLICT', + 'node returned a recipient id already assigned to a pending deposit', + ); + } + this.insertPendingDeposit(depositId, userId, 'rgb', assetId, amount, { + target: recipientId, + invoice, + }); + }); + return { depositId, kind: 'rgb' as const, address: null, invoice, recipientId }; + } + + private insertPendingDeposit( + depositId: string, + userId: string, + kind: 'btc' | 'rgb', + asset: string, + amount: number, + details: { target: string; invoice: string | null }, + now: number = Date.now(), + ): void { + this.db + .prepare( + `INSERT INTO pending_deposits (id, user_id, kind, asset, amount, target, invoice, state, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, + ) + .run( + depositId, + userId, + kind, + asset, + amount, + details.target, + details.invoice, + now, + now + this.config.depositTtlSeconds * 1000, + ); + } + + async pay(userId: string, body: PayBody) { + let decoded; + try { + decoded = await this.rln.decodeLnInvoice({ invoice: body.invoice }); + } catch (error) { + throw error instanceof RlnHttpError + ? new HttpError(400, 'INVALID_INVOICE', 'invoice could not be decoded') + : sanitizeRlnError(error); + } + const paymentHash = decoded.payment_hash; + const invoiceAmtMsat = decoded.amt_msat ?? null; + if (invoiceAmtMsat !== null && body.amtMsat !== undefined && body.amtMsat !== invoiceAmtMsat) { + throw new HttpError(400, 'AMOUNT_MISMATCH', 'amtMsat conflicts with the invoice amount'); + } + const amtMsat = invoiceAmtMsat ?? body.amtMsat; + if (amtMsat === undefined) { + throw new HttpError(400, 'AMOUNT_REQUIRED', 'invoice has no amount; amtMsat is required'); + } + // A decoded amount is attacker-supplied and NOT covered by the body schema: + // `lnbc0…` decodes to 0 (lightning-invoice only rejects amounts that are + // not a whole msat) and an out-of-range one to an unsafe integer. Both + // reach Ledger.debit, whose RangeError would surface as a 500 instead of + // the 400 a malformed invoice deserves. + if (!Number.isSafeInteger(amtMsat) || amtMsat <= 0) { + throw new HttpError(400, 'INVALID_INVOICE', 'invoice amount is out of range'); + } + const assetId = decoded.asset_id ?? null; + const invoiceAssetAmount = decoded.asset_amount ?? null; + if ( + invoiceAssetAmount !== null && + body.assetAmount !== undefined && + body.assetAmount !== invoiceAssetAmount + ) { + // Same rule as the msat leg above: the invoice wins, so a caller whose + // stated amount differs must be told rather than have the invoice's + // amount silently debited from their asset float. + throw new HttpError(400, 'AMOUNT_MISMATCH', 'assetAmount conflicts with the invoice amount'); + } + const assetAmount = invoiceAssetAmount ?? body.assetAmount ?? null; + if (assetId !== null && assetAmount === null) { + throw new HttpError( + 400, + 'AMOUNT_REQUIRED', + 'asset invoice has no amount; assetAmount is required', + ); + } + // Same reasoning as the msat leg: an RGB amount decoded from the invoice + // bypasses the body schema, so it is range-checked before the debit. + if (assetAmount !== null && (!Number.isSafeInteger(assetAmount) || assetAmount <= 0)) { + throw new HttpError(400, 'INVALID_INVOICE', 'invoice asset amount is out of range'); + } + + // Claim phase: debit + ownership atomically, or observe the existing state. + const existingState = this.ledger.inTx((): string | undefined => { + const owner = ownerOf(this.db, 'payment_hash', paymentHash); + if (owner !== undefined && owner !== userId) { + throw new HttpError(409, 'PAYMENT_CONFLICT', 'this invoice is already being handled'); + } + // decodeLnInvoice only verifies the BOLT11 self-signature, so the hash is + // attacker-chosen: refuse to hang an outbound claim off a hash another + // user owns as an inbound invoice, which would otherwise lock that + // user's real payer out with a 409 for the price of one refunded send. + const invoiceOwner = ownerOf(this.db, 'invoice', paymentHash); + if (invoiceOwner !== undefined && invoiceOwner !== userId) { + throw new HttpError(409, 'PAYMENT_CONFLICT', 'this invoice is already being handled'); + } + if (owner === userId) { + const row = this.db + .prepare("SELECT state FROM resource_map WHERE kind = 'payment_hash' AND resource_id = ?") + .get(paymentHash) as { state: string }; + return row.state; + } + this.debitForPay(userId, paymentHash, amtMsat, assetId, assetAmount); + recordOwnership(this.db, { + kind: 'payment_hash', + resourceId: paymentHash, + userId, + state: 'debited_pending', + }); + return undefined; + }); + if (existingState !== undefined) { + // Never auto-retry: report the known state instead of sending again. + if (existingState === 'succeeded') return { paymentHash, status: 'succeeded' as const }; + if (existingState === 'failed') { + throw new HttpError( + 409, + 'PAYMENT_ALREADY_ATTEMPTED', + 'this invoice already failed; request a fresh invoice', + ); + } + return { paymentHash, status: 'pending' as const }; + } + + let response; + try { + response = await this.rln.sendPayment({ + invoice: body.invoice, + ...(invoiceAmtMsat === null ? { amt_msat: amtMsat } : {}), + ...(assetId !== null && invoiceAssetAmount === null && assetAmount !== null + ? { asset_amount: assetAmount } + : {}), + }); + } catch (error) { + if (error instanceof RlnHttpError) { + // Definite rejection: RLN answered and did not accept the payment. + refundOutboundPayment(this.db, this.ledger, userId, paymentHash); + throw rlnRejection(error, 'PAYMENT_REJECTED', 'the node rejected this payment'); + } + // Ambiguous (timeout / network): leave debited_pending for the reconciler. + throw sanitizeRlnError(error); + } + const status = mapHtlcStatus(response.status); + if (status === 'failed') { + refundOutboundPayment(this.db, this.ledger, userId, paymentHash); + } else { + updateResourceState( + this.db, + 'payment_hash', + paymentHash, + userId, + status === 'succeeded' ? 'succeeded' : 'sent', + ); + } + return { paymentHash, status }; + } + + // InsufficientBalanceError escapes to the central error handler (server.ts), + // which maps it to 409 INSUFFICIENT_BALANCE. + private debitForPay( + userId: string, + paymentHash: string, + amtMsat: number, + assetId: string | null, + assetAmount: number | null, + ): void { + this.ledger.debit({ + userId, + asset: BTC_ASSET, + amount: amtMsat, + kind: 'ln_out', + ref: paymentHash, + }); + if (assetId !== null && assetAmount !== null) { + this.ledger.debit({ + userId, + asset: assetId, + amount: assetAmount, + kind: 'ln_out', + ref: paymentHash, + }); + } + } + + async createInvoice(userId: string, body: InvoiceCreateBody) { + // A BTC invoice MUST declare its amount: the float cap is enforced against + // declared amounts at creation, and an amount-less invoice would accept + // arbitrarily large payments outside any cap. Asset invoices declare the + // asset amount instead (their BTC leg is the tiny HTLC carrier value). + if (body.assetId === undefined && body.amtMsat === undefined) { + throw new HttpError(400, 'AMOUNT_REQUIRED', 'amtMsat is required for a btc invoice'); + } + if (body.assetId !== undefined && body.assetAmount === undefined) { + throw new HttpError(400, 'AMOUNT_REQUIRED', 'assetAmount is required for an asset invoice'); + } + // RLN rejects an asset invoice whose msat amount is under the channel HTLC + // floor (`amt_msat.unwrap_or(0) < htlc_min_msat`), so an asset invoice with + // no declared msat amount can never be created. Default the HTLC carrier + // value to the configured floor, and reject a smaller explicit one here + // with an actionable error instead of an opaque upstream failure. + const amtMsat = + body.assetId === undefined + ? (body.amtMsat as number) + : (body.amtMsat ?? this.config.assetInvoiceMinMsat); + if (body.assetId !== undefined && amtMsat < this.config.assetInvoiceMinMsat) { + throw new HttpError( + 400, + 'AMOUNT_BELOW_MINIMUM', + `an asset invoice must carry at least ${this.config.assetInvoiceMinMsat} msat`, + ); + } + // Fast-fail before the RLN round-trip; re-checked atomically below. + this.assertBtcHeadroom(userId, amtMsat); + const expirySec = body.expirySec ?? DEFAULT_INVOICE_EXPIRY_SEC; + let invoice: string; + let paymentHash: string; + try { + const created = await this.rln.lnInvoice({ + amt_msat: amtMsat, + expiry_sec: expirySec, + asset_id: body.assetId ?? null, + asset_amount: body.assetAmount ?? null, + ...(body.description !== undefined ? { description: body.description } : {}), + }); + invoice = created.invoice; + paymentHash = (await this.rln.decodeLnInvoice({ invoice })).payment_hash; + } catch (error) { + throw rlnRejection(error, 'INVOICE_REJECTED', 'the node rejected these invoice parameters'); + } + this.ledger.inTx(() => { + // Re-check inside the insert transaction so parallel creations by + // different users cannot jointly overshoot the global cap. Accepted + // tradeoff: a failure here leaves the just-created invoice orphaned on + // the node — harmless, because the invoice string never reached the + // caller, so nobody can pay it. + this.assertBtcHeadroom(userId, amtMsat); + recordOwnership(this.db, { + kind: 'invoice', + resourceId: paymentHash, + userId, + state: 'pending', + }); + const now = Date.now(); + this.db + .prepare( + `INSERT INTO ln_invoices (payment_hash, user_id, invoice, amt_msat, asset_id, asset_amount, state, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, + ) + .run( + paymentHash, + userId, + invoice, + amtMsat, + body.assetId ?? null, + body.assetAmount ?? null, + now, + now + expirySec * 1000, + ); + }); + return { invoice, paymentHash }; + } + + invoiceRow(paymentHash: string): LnInvoiceRow | undefined { + return this.db.prepare('SELECT * FROM ln_invoices WHERE payment_hash = ?').get(paymentHash) as + LnInvoiceRow | undefined; + } + + async getInvoice(userId: string, hash: string) { + const row = this.invoiceRow(hash); + if (row === undefined || row.user_id !== userId) { + throw new HttpError(404, 'NOT_FOUND', 'resource not found'); + } + let state: LnInvoiceRow['state'] = row.state; + if (row.state === 'pending') { + let live; + try { + live = (await this.rln.invoiceStatus({ invoice: row.invoice })).status; + } catch (error) { + throw sanitizeRlnError(error); + } + if (live === 'Succeeded') { + const settled = await settleInvoiceWithLiveAmounts(this.db, this.ledger, this.rln, row); + if (settled) state = 'settled'; + } else if (live === 'Expired') { + this.ledger.inTx(() => { + this.db + .prepare("UPDATE ln_invoices SET state = 'expired' WHERE payment_hash = ?") + .run(hash); + updateResourceState(this.db, 'invoice', hash, userId, 'expired'); + }); + state = 'expired'; + } + } + return { + paymentHash: row.payment_hash, + invoice: row.invoice, + state, + amtMsat: row.amt_msat, + assetId: row.asset_id, + assetAmount: row.asset_amount, + createdAt: row.created_at, + }; + } + + /** + * The user's payments. RLN's /listpayments is node-wide and paginated + * (newest first, 100 per page by default), so a single unpaginated call + * would silently hide a user's history behind 100 newer payments from other + * users. Walk pages newest→oldest, scoping each one, and stop as soon as + * every payment_hash/invoice this user owns has been seen. + */ + async listPayments(userId: string) { + const owned = this.db + .prepare( + `SELECT DISTINCT resource_id, created_at FROM resource_map + WHERE user_id = ? AND kind IN ('payment_hash', 'invoice')`, + ) + .all(userId) as { resource_id: string; created_at: number }[]; + // Two independent stop conditions, because neither alone terminates. + // + // `remaining` is the fast one: stop as soon as every owned id has been + // seen. It cannot be a count comparison — a resource_map row can exist for + // a payment RLN never learned about (pay records ownership BEFORE + // /sendpayment, and the reconciler refunds and leaves the row 'failed' when + // the send never landed), so `seen === owned.length` is permanently + // unreachable for that user. + // + // The age floor is the backstop for exactly that case: pages come + // newest-first, so once an entire page predates the user's oldest owned + // resource, no older page can hold one of their payments. Without it a + // single unfindable id walks the node's whole payment history on EVERY + // call. The skew margin absorbs the gateway/node clock difference and the + // fact that RLN stamps an invoice's payment before the row is inserted. + const remaining = new Set(owned.map((row) => row.resource_id)); + const oldestOwnedMs = owned.reduce( + (min, row) => Math.min(min, row.created_at), + Number.POSITIVE_INFINITY, + ); + const oldestOwnedSec = Math.floor(oldestOwnedMs / 1000) - PAYMENT_WALK_SKEW_SEC; + const scoped: Payment[] = []; + let pageCursor: number | undefined; + while (remaining.size > 0) { + let page; + try { + page = await this.rln.listPayments({ + ...(pageCursor !== undefined ? { index_offset: pageCursor } : {}), + max_payments: PAYMENTS_PAGE_SIZE, + }); + } catch (error) { + throw sanitizeRlnError(error); + } + for (const payment of scopePayments(this.db, userId, page.payments)) { + scoped.push(payment); + remaining.delete(payment.payment_hash); + } + // Every payment on this page predates anything the user owns: older + // pages cannot match either. + if (page.payments.every((payment) => payment.created_at < oldestOwnedSec)) break; + // Short page, no cursor to advance to, or a non-progressing upstream + // cursor (would loop forever): the walk is done. + if (page.payments.length < PAYMENTS_PAGE_SIZE || page.last_index_offset === 0) break; + if (pageCursor !== undefined && page.last_index_offset >= pageCursor) break; + pageCursor = page.last_index_offset; + } + return { + payments: scoped.map((payment: Payment) => ({ + paymentHash: payment.payment_hash, + direction: + payment.payment_type === 'Outbound' ? ('outbound' as const) : ('inbound' as const), + status: mapHtlcStatus(payment.status), + amtMsat: payment.amt_msat ?? null, + assetId: payment.asset_id ?? null, + assetAmount: payment.asset_amount ?? null, + createdAt: payment.created_at, + updatedAt: payment.updated_at, + })), + }; + } + + /** + * Withdraw from the float back to the user. Unlike pay (payment_hash) and + * on-chain complete (pending_ops), a withdrawal has no natural resource id, + * so the idempotency KEY is the resource: the withdrawal id derives from + * (user, key) and the withdrawals row is the replay guard. The idempotency + * middleware alone is NOT enough — it releases claims on 5xx (an ambiguous + * RLN timeout is a 504) and reclaims stale ones after a crash, and either + * path would re-debit and re-broadcast on the documented same-key retry. + */ + async withdraw(userId: string, body: WithdrawBody, idempotencyKey: string) { + const withdrawalId = createHash('sha256') + .update(userId) + .update('\n') + .update(idempotencyKey) + .digest('hex'); + const feeRate = body.feeRateSatPerVb ?? DEFAULT_WITHDRAW_FEE_RATE; + let asset: string; + let amount: number; + let target: string; + if (body.kind === 'btc') { + if (body.address === undefined || body.amountSat === undefined) { + throw new HttpError( + 400, + 'BAD_REQUEST', + 'address and amountSat are required for a btc withdraw', + ); + } + asset = BTC_ASSET; + amount = body.amountSat * 1000; + target = body.address; + } else { + if ( + body.assetId === undefined || + body.amount === undefined || + body.recipientId === undefined + ) { + throw new HttpError( + 400, + 'BAD_REQUEST', + 'assetId, amount and recipientId are required for an rgb withdraw', + ); + } + if (body.assetId === BTC_ASSET) { + // BTC_ASSET is the ledger's reserved key for the msat float. Letting it + // through would debit that float in msat units for an RGB send that + // rgb-lib can never resolve, and an ambiguous (timeout) outcome leaves + // that debit standing permanently. + throw new HttpError(400, 'BAD_REQUEST', `assetId must not be '${BTC_ASSET}'`); + } + asset = body.assetId; + amount = body.amount; + target = body.recipientId; + } + const isWitnessRecipient = body.kind === 'rgb' && WITNESS_RECIPIENT_PATTERN.test(target); + if (body.kind === 'rgb') { + if (isWitnessRecipient && body.witnessAmountSat === undefined) { + throw new HttpError( + 400, + 'WITNESS_AMOUNT_REQUIRED', + 'witnessAmountSat is required for a wvout recipient', + ); + } + if (!isWitnessRecipient && body.witnessAmountSat !== undefined) { + throw new HttpError( + 400, + 'BAD_REQUEST', + 'witnessAmountSat is only valid for a wvout recipient', + ); + } + } + // SSRF guard: consignment endpoints make the node dial out; only + // operator-allowlisted proxies are accepted (explicit error, no fallback). + const transportEndpoints = body.transportEndpoints ?? [this.config.rgbProxyUrl]; + if (body.kind === 'rgb') { + const denied = transportEndpoints.filter( + (endpoint) => !this.config.rgbTransportAllowlist.includes(endpoint), + ); + if (denied.length > 0) { + throw new HttpError( + 400, + 'TRANSPORT_ENDPOINT_NOT_ALLOWED', + 'transportEndpoints must be on the operator allowlist', + ); + } + } + + // A `wvout:` recipient is paid by a NEW output that the NODE's on-chain + // wallet funds: rgb-lib pushes (script, amount_sat) as an extra output of + // the vanilla transaction (wallet/online.rs, Beneficiary::WitnessVout), and + // the script is encoded in the caller-supplied recipient id. Those sats + // land in the user's own vault, so they are a BTC withdrawal in all but + // name and are debited from the user's msat float alongside the RGB units. + // Without this a caller could name any sat value and drain the node's + // on-chain balance into a script they control for the price of one asset + // unit. The two debits share the withdrawal ref but differ in asset, so + // the ledger's (user, asset, kind, ref) uniqueness still holds. + const witnessMsat = isWitnessRecipient ? (body.witnessAmountSat as number) * 1000 : 0; + + // Claim phase: debit + row atomically, or observe the recorded state of a + // previous attempt under the same key. InsufficientBalanceError escapes to + // the central handler (409). + const existing = this.ledger.inTx((): { state: string; txid: string | null } | undefined => { + const row = this.db + .prepare('SELECT state, txid FROM withdrawals WHERE id = ?') + .get(withdrawalId) as { state: string; txid: string | null } | undefined; + if (row !== undefined) return row; + this.ledger.debit({ userId, asset, amount, kind: 'withdraw', ref: withdrawalId }); + if (witnessMsat > 0) { + this.ledger.debit({ + userId, + asset: BTC_ASSET, + amount: witnessMsat, + kind: 'withdraw', + ref: withdrawalId, + }); + } + this.db + .prepare( + `INSERT INTO withdrawals (id, user_id, asset, amount, target, state, created_at) + VALUES (?, ?, ?, ?, ?, 'pending', ?)`, + ) + .run(withdrawalId, userId, asset, amount, target, Date.now()); + return undefined; + }); + if (existing !== undefined) { + // Never auto-retry a send: report the recorded outcome instead. + if (existing.state === 'sent' && existing.txid !== null) { + return { withdrawalId, txid: existing.txid }; + } + if (existing.state === 'failed') { + throw new HttpError( + 409, + 'WITHDRAWAL_ALREADY_ATTEMPTED', + 'a withdrawal with this Idempotency-Key already failed and was refunded; use a fresh key', + ); + } + // pending (crashed mid-flight) or ambiguous: the send may have broadcast. + throw new HttpError( + 409, + 'WITHDRAWAL_UNRESOLVED', + 'a withdrawal with this Idempotency-Key is unresolved and may have broadcast; contact the operator', + ); + } + + let txid: string; + try { + if (body.kind === 'btc') { + txid = ( + await this.rln.sendBtc({ + amount: body.amountSat as number, + address: target, + fee_rate: feeRate, + skip_sync: false, + }) + ).txid; + } else { + txid = ( + await this.rln.sendRgb({ + donation: false, + fee_rate: feeRate, + min_confirmations: this.config.depositMinConfirmations, + recipient_map: { + [asset]: [ + { + recipient_id: target, + assignment: { type: 'Fungible', value: amount }, + transport_endpoints: transportEndpoints, + ...(isWitnessRecipient + ? { + witness_data: { + amount_sat: body.witnessAmountSat as number, + blinding: null, + }, + } + : {}), + }, + ], + }, + }) + ).txid; + } + } catch (error) { + if (error instanceof RlnHttpError && error.status < 500) { + // Definite rejection: RLN refused THESE parameters before broadcasting + // anything (every 4xx variant is raised pre-broadcast). Refund and mark + // failed. + refundWithdrawal(this.db, this.ledger, userId, withdrawalId); + } else { + // Ambiguous: leave the debit in place for operator resolution — the + // send may have broadcast. Never auto-retry. A 5xx counts as ambiguous, + // not as a rejection: rgb-lib broadcasts FIRST and then writes its + // bookkeeping (broadcast_psbt -> BDK persist -> txo/DB updates -> + // commit), and every one of those post-broadcast failure points reaches + // RLN as APIError::IO/Unexpected, i.e. a 500 for a transaction that is + // already on the network. Refunding it would hand back money the node + // actually paid out. + this.db + .prepare("UPDATE withdrawals SET state = 'ambiguous' WHERE id = ?") + .run(withdrawalId); + } + throw rlnRejection(error, 'WITHDRAW_REJECTED', 'the node rejected this withdrawal'); + } + this.db + .prepare("UPDATE withdrawals SET state = 'sent', txid = ? WHERE id = ?") + .run(txid, withdrawalId); + return { withdrawalId, txid }; + } +} + +export function registerLnRoutes(app: FastifyInstance): void { + const auth = { onRequest: [app.authenticate] }; + const moneyMoving = { + onRequest: [app.authenticate], + preHandler: [app.idempotency.preHandler], + onSend: app.idempotency.onSend, + }; + + // Money-moving in the sense the idempotency middleware guards: each call + // burns a node address and pins float-cap headroom for the deposit TTL, so a + // retried prepare must return the first intent rather than mint a new one. + app.post( + '/v1/ln/deposit/prepare', + { schema: depositPrepareRouteSchema, ...moneyMoving }, + (request, reply) => { + const userId = request.userId as string; + const body = request.body as DepositPrepareBody; + return app.queues.enqueue(userId, async () => { + const prepared = await app.ln.prepareDeposit(userId, body); + return reply.code(201).send(prepared); + }); + }, + ); + + app.post('/v1/ln/pay', { schema: payRouteSchema, ...moneyMoving }, (request) => { + const userId = request.userId as string; + const body = request.body as PayBody; + return app.queues.enqueue(userId, () => app.ln.pay(userId, body)); + }); + + app.post('/v1/ln/invoice', { schema: invoiceCreateRouteSchema, ...auth }, (request, reply) => { + const userId = request.userId as string; + const body = request.body as InvoiceCreateBody; + return app.queues.enqueue(userId, async () => { + const created = await app.ln.createInvoice(userId, body); + return reply.code(201).send(created); + }); + }); + + app.get('/v1/ln/invoice/:hash', { schema: invoiceGetRouteSchema, ...auth }, (request) => { + const userId = request.userId as string; + const { hash } = request.params as { hash: string }; + return app.queues.enqueue(userId, () => app.ln.getInvoice(userId, hash)); + }); + + app.get('/v1/ln/payments', { schema: paymentsListRouteSchema, ...auth }, (request) => { + const userId = request.userId as string; + return app.queues.enqueue(userId, () => app.ln.listPayments(userId)); + }); + + app.get('/v1/ln/balance', { schema: lnBalanceRouteSchema, ...auth }, (request) => { + const userId = request.userId as string; + const { [BTC_ASSET]: btcMsat = 0, ...assets } = app.ledger.balances(userId); + return { btcMsat, assets }; + }); + + app.post('/v1/ln/withdraw', { schema: withdrawRouteSchema, ...moneyMoving }, (request, reply) => { + const userId = request.userId as string; + const body = request.body as WithdrawBody; + const idempotencyKey = request.idempotency?.key; + if (idempotencyKey === undefined) { + throw new HttpError(500, 'INTERNAL', 'withdraw requires the idempotency preHandler'); + } + return app.queues.enqueue(userId, async () => { + const result = await app.ln.withdraw(userId, body, idempotencyKey); + return reply.code(201).send(result); + }); + }); +} diff --git a/minimal-sdk/packages/gateway/src/routes/onchain.ts b/minimal-sdk/packages/gateway/src/routes/onchain.ts new file mode 100644 index 00000000..a06dac09 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/routes/onchain.ts @@ -0,0 +1,92 @@ +/** + * On-chain prepare/complete routes: server prepares, client signs, server + * completes. All money-moving, so every route requires an Idempotency-Key; + * auth runs onRequest (before body parsing) and all downstream work goes + * through the per-user FIFO queue. + */ +import type { FastifyInstance, FastifySchema } from 'fastify'; +import { + createUtxosCompleteRouteSchema, + createUtxosPrepareRouteSchema, + sendAssetCompleteRouteSchema, + sendAssetPrepareRouteSchema, + sendBtcCompleteRouteSchema, + sendBtcPrepareRouteSchema, +} from '../schemas/onchain.js'; +import type { + OnchainOpKind, + PrepareCreateUtxosParams, + PrepareSendAssetParams, + PrepareSendBtcParams, +} from '../wallets/prepare.js'; + +interface CompleteBody { + opId: string; + signedPsbt: string; +} + +export function registerOnchainRoutes(app: FastifyInstance): void { + const routeOptions = (schema: FastifySchema) => ({ + schema, + onRequest: [app.authenticate], + preHandler: [app.idempotency.preHandler], + onSend: app.idempotency.onSend, + }); + + const completeRoute = (url: string, schema: FastifySchema, kind: OnchainOpKind): void => { + app.post(url, routeOptions(schema), (request) => { + const userId = request.userId as string; + const { opId, signedPsbt } = request.body as CompleteBody; + return app.queues.enqueue(userId, async () => { + const { txid, utxosCreated } = await app.onchain.complete(userId, kind, opId, signedPsbt); + return kind === 'create_utxos' ? { txid, utxosCreated } : { txid }; + }); + }); + }; + + app.post( + '/v1/onchain/send-btc/prepare', + routeOptions(sendBtcPrepareRouteSchema), + (request, reply) => { + const userId = request.userId as string; + const params = request.body as PrepareSendBtcParams; + return app.queues.enqueue(userId, async () => { + const prepared = await app.onchain.prepareSendBtc(userId, params); + return reply.code(201).send(prepared); + }); + }, + ); + completeRoute('/v1/onchain/send-btc/complete', sendBtcCompleteRouteSchema, 'send_btc'); + + app.post( + '/v1/onchain/send-asset/prepare', + routeOptions(sendAssetPrepareRouteSchema), + (request, reply) => { + const userId = request.userId as string; + const params = request.body as PrepareSendAssetParams; + return app.queues.enqueue(userId, async () => { + const prepared = await app.onchain.prepareSendAsset(userId, params); + return reply.code(201).send(prepared); + }); + }, + ); + completeRoute('/v1/onchain/send-asset/complete', sendAssetCompleteRouteSchema, 'send_asset'); + + app.post( + '/v1/onchain/create-utxos/prepare', + routeOptions(createUtxosPrepareRouteSchema), + (request, reply) => { + const userId = request.userId as string; + const params = request.body as PrepareCreateUtxosParams; + return app.queues.enqueue(userId, async () => { + const prepared = await app.onchain.prepareCreateUtxos(userId, params); + return reply.code(201).send(prepared); + }); + }, + ); + completeRoute( + '/v1/onchain/create-utxos/complete', + createUtxosCompleteRouteSchema, + 'create_utxos', + ); +} diff --git a/minimal-sdk/packages/gateway/src/routes/wallet.ts b/minimal-sdk/packages/gateway/src/routes/wallet.ts new file mode 100644 index 00000000..04ad0e41 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/routes/wallet.ts @@ -0,0 +1,80 @@ +/** + * Wallet routes: every handler is authenticated and funneled through the + * request-level per-user FIFO (Task 2); wallet calls additionally serialize + * on the per-wallet operation queue inside the pool. + */ +import type { FastifyInstance } from 'fastify'; +import type { UserXpubs } from '../wallets/backend.js'; +import type { ReceiveParams } from '../wallets/service.js'; +import { + registerXpubsRouteSchema, + walletAddressRouteSchema, + walletBalancesRouteSchema, + walletReceiveRouteSchema, + walletSyncRouteSchema, + walletTransfersRouteSchema, + walletUnspentsRouteSchema, +} from '../schemas/wallet.js'; + +export function registerWalletRoutes(app: FastifyInstance): void { + // onRequest (not preHandler) so unauthenticated calls are rejected before + // body parsing and schema validation ever run. + const auth = { onRequest: [app.authenticate] }; + + app.post('/v1/wallet/xpubs', { schema: registerXpubsRouteSchema, ...auth }, (request, reply) => { + const userId = request.userId as string; + const xpubs = request.body as UserXpubs; + return app.queues.enqueue(userId, async () => { + const result = await app.wallets.registerXpubs(userId, xpubs); + return reply.code(201).send({ fingerprint: xpubs.fingerprint, address: result.address }); + }); + }); + + app.get('/v1/wallet/address', { schema: walletAddressRouteSchema, ...auth }, (request) => { + const userId = request.userId as string; + return app.queues.enqueue(userId, async () => ({ + address: await app.wallets.getAddress(userId), + })); + }); + + app.get('/v1/wallet/balances', { schema: walletBalancesRouteSchema, ...auth }, (request) => { + const userId = request.userId as string; + return app.queues.enqueue(userId, () => app.wallets.getBalances(userId)); + }); + + app.get('/v1/wallet/unspents', { schema: walletUnspentsRouteSchema, ...auth }, (request) => { + const userId = request.userId as string; + return app.queues.enqueue(userId, async () => ({ + unspents: await app.wallets.listUnspents(userId), + })); + }); + + app.get('/v1/wallet/transfers', { schema: walletTransfersRouteSchema, ...auth }, (request) => { + const userId = request.userId as string; + const { assetId } = request.query as { assetId?: string }; + return app.queues.enqueue(userId, async () => ({ + transfers: await app.wallets.listTransfers(userId, assetId), + })); + }); + + app.post( + '/v1/wallet/receive', + { schema: walletReceiveRouteSchema, ...auth }, + (request, reply) => { + const userId = request.userId as string; + const params = request.body as ReceiveParams; + return app.queues.enqueue(userId, async () => { + const result = await app.wallets.receive(userId, params); + return reply.code(201).send(result); + }); + }, + ); + + app.post('/v1/wallet/sync', { schema: walletSyncRouteSchema, ...auth }, (request) => { + const userId = request.userId as string; + return app.queues.enqueue(userId, async () => { + await app.wallets.sync(userId); + return { status: 'ok' }; + }); + }); +} diff --git a/minimal-sdk/packages/gateway/src/schemas/index.ts b/minimal-sdk/packages/gateway/src/schemas/index.ts new file mode 100644 index 00000000..090cfa7c --- /dev/null +++ b/minimal-sdk/packages/gateway/src/schemas/index.ts @@ -0,0 +1,73 @@ +/** + * Fastify JSON schemas for every gateway route. Response schemas are strict + * (additionalProperties: false) so serialization strips anything undeclared — + * a structural backstop for invariants I1/I4. + */ + +export const errorBodySchema = { + type: 'object', + properties: { + error: { + type: 'object', + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['code', 'message'], + additionalProperties: false, + }, + }, + required: ['error'], + additionalProperties: false, +} as const; + +export const healthRouteSchema = { + response: { + 200: { + type: 'object', + properties: { status: { type: 'string', enum: ['ok'] } }, + required: ['status'], + additionalProperties: false, + }, + }, +} as const; + +/** + * Bootstrap-only user creation, guarded by the operator token. The bearer + * token is returned exactly once here and stored only as a hash. + */ +export const createUserRouteSchema = { + headers: { + type: 'object', + properties: { 'x-operator-token': { type: 'string' } }, + }, + response: { + 201: { + type: 'object', + properties: { + userId: { type: 'string' }, + token: { type: 'string' }, + createdAt: { type: 'integer' }, + }, + required: ['userId', 'token', 'createdAt'], + additionalProperties: false, + }, + 400: errorBodySchema, + 401: errorBodySchema, + }, +} as const; + +export const meRouteSchema = { + response: { + 200: { + type: 'object', + properties: { + userId: { type: 'string' }, + createdAt: { type: 'integer' }, + }, + required: ['userId', 'createdAt'], + additionalProperties: false, + }, + 401: errorBodySchema, + }, +} as const; diff --git a/minimal-sdk/packages/gateway/src/schemas/ln.ts b/minimal-sdk/packages/gateway/src/schemas/ln.ts new file mode 100644 index 00000000..74359ae2 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/schemas/ln.ts @@ -0,0 +1,246 @@ +/** + * JSON schemas for the LN float routes. Responses are strict + * (additionalProperties: false) so nothing undeclared — payment secrets, + * preimages, RLN internals — can ever leak (I1/I4 backstop). + */ +import { errorBodySchema } from './index.js'; + +const paymentHashSchema = { type: 'string', pattern: '^[0-9a-f]{64}$' } as const; +const uuidSchema = { type: 'string', pattern: '^[0-9a-f-]{36}$' } as const; +const feeRateSchema = { type: 'integer', minimum: 1, maximum: 1000 } as const; +const paymentStatusSchema = { + type: 'string', + enum: ['pending', 'succeeded', 'failed'], +} as const; +const invoiceStateSchema = { + type: 'string', + enum: ['pending', 'settled', 'expired'], +} as const; + +const commonErrorResponses = { + 400: errorBodySchema, + 401: errorBodySchema, + 409: errorBodySchema, + 502: errorBodySchema, + 504: errorBodySchema, +} as const; + +export const depositPrepareRouteSchema = { + body: { + type: 'object', + properties: { + kind: { type: 'string', enum: ['btc', 'rgb'] }, + amountMsat: { type: 'integer', minimum: 1000 }, + assetId: { type: 'string', minLength: 1 }, + amount: { type: 'integer', minimum: 1 }, + }, + required: ['kind'], + additionalProperties: false, + }, + response: { + 201: { + type: 'object', + properties: { + depositId: uuidSchema, + kind: { type: 'string', enum: ['btc', 'rgb'] }, + address: { type: ['string', 'null'] }, + invoice: { type: ['string', 'null'] }, + recipientId: { type: ['string', 'null'] }, + }, + required: ['depositId', 'kind', 'address', 'invoice', 'recipientId'], + additionalProperties: false, + }, + ...commonErrorResponses, + }, +} as const; + +export const payRouteSchema = { + body: { + type: 'object', + properties: { + invoice: { type: 'string', minLength: 20, maxLength: 8192 }, + amtMsat: { type: 'integer', minimum: 1 }, + assetAmount: { type: 'integer', minimum: 1 }, + }, + required: ['invoice'], + additionalProperties: false, + }, + response: { + 200: { + type: 'object', + properties: { + paymentHash: paymentHashSchema, + status: paymentStatusSchema, + }, + required: ['paymentHash', 'status'], + additionalProperties: false, + }, + ...commonErrorResponses, + }, +} as const; + +export const invoiceCreateRouteSchema = { + body: { + type: 'object', + properties: { + amtMsat: { type: 'integer', minimum: 1 }, + expirySec: { type: 'integer', minimum: 60, maximum: 86_400 }, + assetId: { type: 'string', minLength: 1 }, + assetAmount: { type: 'integer', minimum: 1 }, + description: { type: 'string', maxLength: 256 }, + }, + additionalProperties: false, + }, + response: { + 201: { + type: 'object', + properties: { + invoice: { type: 'string' }, + paymentHash: paymentHashSchema, + }, + required: ['invoice', 'paymentHash'], + additionalProperties: false, + }, + ...commonErrorResponses, + }, +} as const; + +export const invoiceGetRouteSchema = { + params: { + type: 'object', + properties: { hash: paymentHashSchema }, + required: ['hash'], + additionalProperties: false, + }, + response: { + 200: { + type: 'object', + properties: { + paymentHash: paymentHashSchema, + invoice: { type: 'string' }, + state: invoiceStateSchema, + amtMsat: { type: ['integer', 'null'] }, + assetId: { type: ['string', 'null'] }, + assetAmount: { type: ['integer', 'null'] }, + createdAt: { type: 'integer' }, + }, + required: [ + 'paymentHash', + 'invoice', + 'state', + 'amtMsat', + 'assetId', + 'assetAmount', + 'createdAt', + ], + additionalProperties: false, + }, + 401: errorBodySchema, + 404: errorBodySchema, + 502: errorBodySchema, + 504: errorBodySchema, + }, +} as const; + +export const paymentsListRouteSchema = { + response: { + 200: { + type: 'object', + properties: { + payments: { + type: 'array', + items: { + type: 'object', + properties: { + paymentHash: paymentHashSchema, + direction: { type: 'string', enum: ['inbound', 'outbound'] }, + status: paymentStatusSchema, + amtMsat: { type: ['integer', 'null'] }, + assetId: { type: ['string', 'null'] }, + assetAmount: { type: ['integer', 'null'] }, + createdAt: { type: 'integer' }, + updatedAt: { type: 'integer' }, + }, + required: [ + 'paymentHash', + 'direction', + 'status', + 'amtMsat', + 'assetId', + 'assetAmount', + 'createdAt', + 'updatedAt', + ], + additionalProperties: false, + }, + }, + }, + required: ['payments'], + additionalProperties: false, + }, + 401: errorBodySchema, + 502: errorBodySchema, + 504: errorBodySchema, + }, +} as const; + +export const lnBalanceRouteSchema = { + response: { + 200: { + type: 'object', + properties: { + btcMsat: { type: 'integer' }, + assets: { type: 'object', additionalProperties: { type: 'integer' } }, + }, + required: ['btcMsat', 'assets'], + additionalProperties: false, + }, + 401: errorBodySchema, + }, +} as const; + +export const withdrawRouteSchema = { + body: { + type: 'object', + properties: { + kind: { type: 'string', enum: ['btc', 'rgb'] }, + address: { type: 'string', pattern: '^[a-zA-Z0-9]{14,90}$' }, + // Upper bound keeps amountSat * 1000 a safe integer: the ledger works in + // msat and rejects unsafe amounts with a RangeError, which would surface + // as a 500 rather than the 400 a malformed request deserves. + amountSat: { type: 'integer', minimum: 294, maximum: 9_007_199_254_740 }, + assetId: { type: 'string', minLength: 1 }, + amount: { type: 'integer', minimum: 1 }, + recipientId: { type: 'string', minLength: 1 }, + // Sat value of the new output that pays a `wvout:` recipient; required + // for one, rejected for a blind (`utxob:`) one. Dust bound matches the + // on-chain send-asset route. Unlike that route — where the user's own + // wallet funds the output — here the NODE funds it, so the gateway + // debits it from the user's msat float; the upper bound keeps + // witnessAmountSat * 1000 a safe integer, like amountSat above. + witnessAmountSat: { type: 'integer', minimum: 294, maximum: 9_007_199_254_740 }, + transportEndpoints: { + type: 'array', + items: { type: 'string', minLength: 1 }, + minItems: 1, + maxItems: 4, + }, + feeRateSatPerVb: feeRateSchema, + }, + required: ['kind'], + additionalProperties: false, + }, + response: { + 201: { + type: 'object', + properties: { + // sha256(userId, Idempotency-Key): the withdrawal's replay guard. + withdrawalId: { type: 'string', pattern: '^[0-9a-f]{64}$' }, + txid: { type: 'string' }, + }, + required: ['withdrawalId', 'txid'], + additionalProperties: false, + }, + ...commonErrorResponses, + }, +} as const; diff --git a/minimal-sdk/packages/gateway/src/schemas/onchain.ts b/minimal-sdk/packages/gateway/src/schemas/onchain.ts new file mode 100644 index 00000000..d606c2fc --- /dev/null +++ b/minimal-sdk/packages/gateway/src/schemas/onchain.ts @@ -0,0 +1,189 @@ +/** + * JSON schemas for the on-chain prepare/complete routes. Strict responses + * (additionalProperties: false) so nothing undeclared can leak (I1 backstop). + */ +import { errorBodySchema } from './index.js'; + +/** Base64 PSBT; the body limit (64KB) bounds overall size. */ +const psbtSchema = { type: 'string', pattern: '^[A-Za-z0-9+/=]+$', minLength: 20 } as const; +const opIdSchema = { type: 'string', pattern: '^[0-9a-f-]{36}$' } as const; +const feeRateSchema = { type: 'integer', minimum: 1, maximum: 1000 } as const; + +const intentSchema = { + type: 'object', + properties: { + kind: { type: 'string', enum: ['send_btc', 'send_asset', 'create_utxos'] }, + feeRateSatPerVb: { type: 'integer' }, + recipients: { + type: 'array', + items: { + type: 'object', + properties: { + address: { type: 'string' }, + scriptHex: { type: 'string' }, + amountSat: { type: 'integer' }, + }, + required: ['address', 'scriptHex', 'amountSat'], + additionalProperties: false, + }, + }, + asset: { + type: ['object', 'null'], + properties: { + assetId: { type: 'string' }, + amount: { type: 'integer' }, + recipientId: { type: 'string' }, + witnessAmountSat: { type: ['integer', 'null'] }, + transportEndpoints: { type: 'array', items: { type: 'string' } }, + }, + required: ['assetId', 'amount', 'recipientId', 'witnessAmountSat', 'transportEndpoints'], + additionalProperties: false, + }, + utxos: { + type: ['object', 'null'], + properties: { + upTo: { type: 'boolean' }, + num: { type: 'integer' }, + size: { type: 'integer' }, + }, + required: ['upTo', 'num', 'size'], + additionalProperties: false, + }, + }, + required: ['kind', 'feeRateSatPerVb', 'recipients', 'asset', 'utxos'], + additionalProperties: false, +} as const; + +const preparedResponseSchema = { + type: 'object', + properties: { + opId: opIdSchema, + psbt: { type: 'string' }, + intent: intentSchema, + expiresAt: { type: 'integer' }, + }, + required: ['opId', 'psbt', 'intent', 'expiresAt'], + additionalProperties: false, +} as const; + +const completeBodySchema = { + type: 'object', + properties: { + opId: opIdSchema, + signedPsbt: psbtSchema, + }, + required: ['opId', 'signedPsbt'], + additionalProperties: false, +} as const; + +const prepareErrorResponses = { + 400: errorBodySchema, + 401: errorBodySchema, + 404: errorBodySchema, + 409: errorBodySchema, +} as const; + +const completeErrorResponses = { + 400: errorBodySchema, + 401: errorBodySchema, + 404: errorBodySchema, + 409: errorBodySchema, + 410: errorBodySchema, +} as const; + +export const sendBtcPrepareRouteSchema = { + body: { + type: 'object', + properties: { + address: { type: 'string', pattern: '^[a-zA-Z0-9]{14,90}$' }, + amountSat: { type: 'integer', minimum: 294 }, + feeRateSatPerVb: feeRateSchema, + }, + required: ['address', 'amountSat'], + additionalProperties: false, + }, + response: { 201: preparedResponseSchema, ...prepareErrorResponses }, +} as const; + +export const sendBtcCompleteRouteSchema = { + body: completeBodySchema, + response: { + 200: { + type: 'object', + properties: { txid: { type: 'string' } }, + required: ['txid'], + additionalProperties: false, + }, + ...completeErrorResponses, + }, +} as const; + +export const sendAssetPrepareRouteSchema = { + body: { + type: 'object', + properties: { + assetId: { type: 'string', minLength: 1 }, + // Integer, not number: an RGB fungible amount is a u64 on the rgb-lib + // side, and a fractional one fails serde deserialization there — a 500 + // for what is really a malformed request. + amount: { type: 'integer', minimum: 1 }, + recipientId: { type: 'string', minLength: 1 }, + witnessAmountSat: { type: 'integer', minimum: 294 }, + transportEndpoints: { + type: 'array', + items: { type: 'string', minLength: 1 }, + minItems: 1, + maxItems: 4, + }, + donation: { type: 'boolean' }, + minConfirmations: { type: 'integer', minimum: 0, maximum: 100 }, + feeRateSatPerVb: feeRateSchema, + }, + required: ['assetId', 'amount', 'recipientId'], + additionalProperties: false, + }, + response: { 201: preparedResponseSchema, ...prepareErrorResponses }, +} as const; + +export const sendAssetCompleteRouteSchema = { + body: completeBodySchema, + response: { + 200: { + type: 'object', + properties: { txid: { type: 'string' } }, + required: ['txid'], + additionalProperties: false, + }, + ...completeErrorResponses, + }, +} as const; + +export const createUtxosPrepareRouteSchema = { + body: { + type: 'object', + properties: { + num: { type: 'integer', minimum: 1, maximum: 50 }, + size: { type: 'integer', minimum: 294 }, + upTo: { type: 'boolean' }, + feeRateSatPerVb: feeRateSchema, + }, + additionalProperties: false, + }, + response: { 201: preparedResponseSchema, ...prepareErrorResponses }, +} as const; + +export const createUtxosCompleteRouteSchema = { + body: completeBodySchema, + response: { + 200: { + type: 'object', + properties: { + txid: { type: ['string', 'null'] }, + utxosCreated: { type: 'integer' }, + }, + required: ['txid', 'utxosCreated'], + additionalProperties: false, + }, + ...completeErrorResponses, + }, +} as const; diff --git a/minimal-sdk/packages/gateway/src/schemas/scan.ts b/minimal-sdk/packages/gateway/src/schemas/scan.ts new file mode 100644 index 00000000..92ae86ef --- /dev/null +++ b/minimal-sdk/packages/gateway/src/schemas/scan.ts @@ -0,0 +1,77 @@ +/** + * Invariant I1 enforcement helper: no gateway API schema may declare a + * key-material field. Used by the schema-scan test and re-usable at runtime + * (the e2e suite scans live responses with the same field list). + */ + +export const KEY_MATERIAL_FIELD_NAMES: readonly string[] = [ + 'mnemonic', + 'seed', + 'xprv', + 'privatekey', + 'signingkey', + 'password', +]; + +function isKeyMaterialName(name: string): boolean { + const normalized = name.toLowerCase().replace(/[_-]/g, ''); + return KEY_MATERIAL_FIELD_NAMES.some((forbidden) => normalized.includes(forbidden)); +} + +/** + * Walks a JSON schema (or any nested structure) and returns the JSON paths of + * declared property names that look like key material. Empty array = clean. + */ +export function findKeyMaterialFields(schema: unknown, path = '$'): string[] { + if (schema === null || typeof schema !== 'object') return []; + const found: string[] = []; + if (Array.isArray(schema)) { + schema.forEach((item, index) => { + found.push(...findKeyMaterialFields(item, `${path}[${index}]`)); + }); + return found; + } + for (const [key, value] of Object.entries(schema as Record)) { + if (key === 'properties' && value !== null && typeof value === 'object') { + for (const [propertyName, propertySchema] of Object.entries( + value as Record, + )) { + if (isKeyMaterialName(propertyName)) { + found.push(`${path}.properties.${propertyName}`); + } + found.push(...findKeyMaterialFields(propertySchema, `${path}.properties.${propertyName}`)); + } + continue; + } + if (key === 'required' && Array.isArray(value)) { + value.forEach((entry) => { + if (typeof entry === 'string' && isKeyMaterialName(entry)) { + found.push(`${path}.required[${entry}]`); + } + }); + continue; + } + found.push(...findKeyMaterialFields(value, `${path}.${key}`)); + } + return found; +} + +/** + * Runtime variant for live payloads: flags any object key in a decoded JSON + * value that looks like key material. + */ +export function findKeyMaterialInValue(value: unknown, path = '$'): string[] { + if (value === null || typeof value !== 'object') return []; + const found: string[] = []; + if (Array.isArray(value)) { + value.forEach((item, index) => { + found.push(...findKeyMaterialInValue(item, `${path}[${index}]`)); + }); + return found; + } + for (const [key, nested] of Object.entries(value as Record)) { + if (isKeyMaterialName(key)) found.push(`${path}.${key}`); + found.push(...findKeyMaterialInValue(nested, `${path}.${key}`)); + } + return found; +} diff --git a/minimal-sdk/packages/gateway/src/schemas/wallet.ts b/minimal-sdk/packages/gateway/src/schemas/wallet.ts new file mode 100644 index 00000000..e8f0dc3a --- /dev/null +++ b/minimal-sdk/packages/gateway/src/schemas/wallet.ts @@ -0,0 +1,233 @@ +/** + * JSON schemas for the wallet routes. Strict responses + * (additionalProperties: false) strip anything undeclared — nothing from the + * rgb-lib layer reaches a client unless declared here (I1 backstop). + */ +import { errorBodySchema } from './index.js'; + +const balanceSchema = { + type: 'object', + properties: { + settled: { type: 'number' }, + future: { type: 'number' }, + spendable: { type: 'number' }, + }, + required: ['settled', 'future', 'spendable'], + additionalProperties: false, +} as const; + +/** Base58 account xpub (tpub/xpub/vpub...); length bounds, not full checksum. */ +const xpubPattern = '^[a-zA-Z0-9]{100,120}$'; + +export const registerXpubsRouteSchema = { + body: { + type: 'object', + properties: { + vanilla: { type: 'string', pattern: xpubPattern }, + colored: { type: 'string', pattern: xpubPattern }, + fingerprint: { type: 'string', pattern: '^[0-9a-f]{8}$' }, + }, + required: ['vanilla', 'colored', 'fingerprint'], + additionalProperties: false, + }, + response: { + 201: { + type: 'object', + properties: { + fingerprint: { type: 'string' }, + address: { type: 'string' }, + }, + required: ['fingerprint', 'address'], + additionalProperties: false, + }, + 400: errorBodySchema, + 401: errorBodySchema, + 409: errorBodySchema, + }, +} as const; + +export const walletAddressRouteSchema = { + response: { + 200: { + type: 'object', + properties: { address: { type: 'string' } }, + required: ['address'], + additionalProperties: false, + }, + 401: errorBodySchema, + 404: errorBodySchema, + }, +} as const; + +export const walletBalancesRouteSchema = { + response: { + 200: { + type: 'object', + properties: { + btc: { + type: 'object', + properties: { vanilla: balanceSchema, colored: balanceSchema }, + required: ['vanilla', 'colored'], + additionalProperties: false, + }, + assets: { + type: 'array', + items: { + type: 'object', + properties: { + assetId: { type: 'string' }, + schema: { type: 'string' }, + ticker: { type: ['string', 'null'] }, + name: { type: 'string' }, + precision: { type: 'integer' }, + balance: balanceSchema, + }, + required: ['assetId', 'schema', 'ticker', 'name', 'precision', 'balance'], + additionalProperties: false, + }, + }, + }, + required: ['btc', 'assets'], + additionalProperties: false, + }, + 401: errorBodySchema, + 404: errorBodySchema, + }, +} as const; + +export const walletUnspentsRouteSchema = { + response: { + 200: { + type: 'object', + properties: { + unspents: { + type: 'array', + items: { + type: 'object', + properties: { + txid: { type: 'string' }, + vout: { type: 'integer' }, + amountSat: { type: 'number' }, + colorable: { type: 'boolean' }, + allocations: { + type: 'array', + items: { + type: 'object', + properties: { + assetId: { type: ['string', 'null'] }, + amount: { type: ['number', 'null'] }, + settled: { type: 'boolean' }, + }, + required: ['assetId', 'amount', 'settled'], + additionalProperties: false, + }, + }, + }, + required: ['txid', 'vout', 'amountSat', 'colorable', 'allocations'], + additionalProperties: false, + }, + }, + }, + required: ['unspents'], + additionalProperties: false, + }, + 401: errorBodySchema, + 404: errorBodySchema, + }, +} as const; + +export const walletTransfersRouteSchema = { + querystring: { + type: 'object', + properties: { assetId: { type: 'string', minLength: 1 } }, + additionalProperties: false, + }, + response: { + 200: { + type: 'object', + properties: { + transfers: { + type: 'array', + items: { + type: 'object', + properties: { + idx: { type: 'integer' }, + assetId: { type: ['string', 'null'] }, + amount: { type: ['number', 'null'] }, + kind: { type: 'string' }, + status: { type: 'string' }, + txid: { type: ['string', 'null'] }, + recipientId: { type: ['string', 'null'] }, + expiration: { type: ['number', 'null'] }, + createdAt: { type: 'number' }, + updatedAt: { type: 'number' }, + }, + required: [ + 'idx', + 'assetId', + 'amount', + 'kind', + 'status', + 'txid', + 'recipientId', + 'expiration', + 'createdAt', + 'updatedAt', + ], + additionalProperties: false, + }, + }, + }, + required: ['transfers'], + additionalProperties: false, + }, + 401: errorBodySchema, + 404: errorBodySchema, + }, +} as const; + +export const walletReceiveRouteSchema = { + body: { + type: 'object', + properties: { + mode: { type: 'string', enum: ['blind', 'witness'] }, + assetId: { type: 'string', minLength: 1 }, + // Integer for the same reason as sendAssetPrepareRouteSchema: rgb-lib + // deserializes a fungible assignment into a u64. + amount: { type: 'integer', minimum: 1 }, + durationSeconds: { type: 'integer', minimum: 60, maximum: 2_592_000 }, + minConfirmations: { type: 'integer', minimum: 0, maximum: 100 }, + }, + required: ['mode'], + additionalProperties: false, + }, + response: { + 201: { + type: 'object', + properties: { + invoice: { type: 'string' }, + recipientId: { type: 'string' }, + expirationTimestamp: { type: ['number', 'null'] }, + mode: { type: 'string', enum: ['blind', 'witness'] }, + }, + required: ['invoice', 'recipientId', 'expirationTimestamp', 'mode'], + additionalProperties: false, + }, + 400: errorBodySchema, + 401: errorBodySchema, + 404: errorBodySchema, + }, +} as const; + +export const walletSyncRouteSchema = { + response: { + 200: { + type: 'object', + properties: { status: { type: 'string', enum: ['ok'] } }, + required: ['status'], + additionalProperties: false, + }, + 401: errorBodySchema, + 404: errorBodySchema, + }, +} as const; diff --git a/minimal-sdk/packages/gateway/src/server.ts b/minimal-sdk/packages/gateway/src/server.ts new file mode 100644 index 00000000..5c13efeb --- /dev/null +++ b/minimal-sdk/packages/gateway/src/server.ts @@ -0,0 +1,244 @@ +/** + * Gateway server skeleton: Fastify + SQLite + auth + per-user FIFO queue + + * idempotency hooks. Later tasks register RLN, wallet, on-chain and LN routes + * on top of the decorators wired here. + */ +import Fastify, { type FastifyInstance } from 'fastify'; +import type { GatewayConfig } from './config.js'; +import { openDb, type GatewayDb } from './db.js'; +import { createUser, makeAuthHook, makeOperatorGuard, type PreHandler } from './auth.js'; +import { + makeIdempotencyHooks, + type IdempotencyClaim, + type IdempotencyHooks, +} from './idempotency.js'; +import { QueueFullError, UserQueues } from './queue.js'; +import { HttpError } from './errors.js'; +import { createUserRouteSchema, healthRouteSchema, meRouteSchema } from './schemas/index.js'; +import type { WalletBackend } from './wallets/backend.js'; +import { NativeWalletBackend } from './wallets/rgblib.js'; +import { WalletOpQueues } from './wallets/queue.js'; +import { WalletPool } from './wallets/pool.js'; +import { WalletService } from './wallets/service.js'; +import { OnchainService } from './wallets/prepare.js'; +import { registerWalletRoutes } from './routes/wallet.js'; +import { registerOnchainRoutes } from './routes/onchain.js'; +import { FloatCapExceededError, InsufficientBalanceError, Ledger } from './ledger.js'; +import { RlnClient, type RlnApi } from './rln/client.js'; +import { LnFlows, registerLnRoutes } from './routes/ln.js'; +import { DepositsWorker } from './workers/deposits.js'; +import { Reconciler } from './workers/reconciler.js'; + +export interface RouteSchemaEntry { + method: string | string[]; + url: string; + schema: unknown; +} + +declare module 'fastify' { + interface FastifyInstance { + db: GatewayDb; + gatewayConfig: GatewayConfig; + queues: UserQueues; + authenticate: PreHandler; + idempotency: IdempotencyHooks; + routeSchemas: RouteSchemaEntry[]; + wallets: WalletService; + walletPool: WalletPool; + onchain: OnchainService; + rln: RlnApi; + ledger: Ledger; + ln: LnFlows; + depositsWorker: DepositsWorker; + reconciler: Reconciler; + } + interface FastifyRequest { + userId?: string; + idempotency?: IdempotencyClaim; + } +} + +export interface BuildServerOptions { + config: GatewayConfig; + /** Pre-opened database (tests pass ':memory:'-backed instances). */ + db?: GatewayDb; + /** Capture stream for log output (tests assert secrets never appear). */ + loggerStream?: { write(msg: string): void }; + loggerLevel?: string; + /** Wallet backend override (unit tests inject a mock; defaults to rgb-lib). */ + walletBackend?: WalletBackend; + /** RLN client override (unit tests inject a fake; defaults to RlnClient). */ + rlnClient?: RlnApi; + /** Esplora fetch override for the deposits watcher (tests inject a stub). */ + esploraFetch?: typeof fetch; + /** Start the background workers (deposits watcher + reconciler). Off in tests. */ + startWorkers?: boolean; +} + +export async function buildServer(options: BuildServerOptions): Promise { + const { config } = options; + const db = options.db ?? openDb(config.sqlitePath); + + const app = Fastify({ + logger: { + level: options.loggerLevel ?? 'info', + // Safety net: these headers carry credentials and must never be logged. + redact: { + paths: [ + 'req.headers.authorization', + 'req.headers["x-operator-token"]', + 'reqHeaders.authorization', + 'reqHeaders["x-operator-token"]', + ], + censor: '[redacted]', + }, + ...(options.loggerStream !== undefined ? { stream: options.loggerStream } : {}), + }, + bodyLimit: 64 * 1024, + }); + + const routeSchemas: RouteSchemaEntry[] = []; + app.addHook('onRoute', (route) => { + routeSchemas.push({ method: route.method, url: route.url, schema: route.schema }); + }); + + app.decorate('db', db); + app.decorate('gatewayConfig', config); + app.decorate( + 'queues', + new UserQueues({ + globalConcurrency: config.queueGlobalConcurrency, + perUserDepth: config.queuePerUserDepth, + }), + ); + app.decorate('authenticate', makeAuthHook(db)); + app.decorate('idempotency', makeIdempotencyHooks(db)); + app.decorate('routeSchemas', routeSchemas); + + const walletBackend = + options.walletBackend ?? + new NativeWalletBackend({ + baseDir: config.walletsDir, + network: config.bitcoinNetwork, + indexerUrl: config.walletIndexerUrl, + }); + const walletPool = new WalletPool(walletBackend, new WalletOpQueues(), { + maxOpen: config.walletMaxOpen, + }); + app.decorate('walletPool', walletPool); + const walletService = new WalletService(db, walletPool, config); + app.decorate('wallets', walletService); + app.decorate('onchain', new OnchainService(db, walletPool, walletService, config)); + + const rln = + options.rlnClient ?? + new RlnClient({ baseUrl: config.rlnUrl, adminToken: config.rlnAdminToken }); + app.decorate('rln', rln); + const ledger = new Ledger(db); + app.decorate('ledger', ledger); + app.decorate('ln', new LnFlows(db, ledger, rln, config)); + const depositsWorker = new DepositsWorker({ + db, + ledger, + rln, + esploraUrl: config.esploraUrl, + minConfirmations: config.depositMinConfirmations, + ...(options.esploraFetch !== undefined ? { fetchImpl: options.esploraFetch } : {}), + log: app.log, + }); + app.decorate('depositsWorker', depositsWorker); + const reconciler = new Reconciler({ + db, + ledger, + rln, + outboundGraceSeconds: config.reconcilerGraceSeconds, + log: app.log, + }); + app.decorate('reconciler', reconciler); + + app.setErrorHandler((error, request, reply) => { + if (error instanceof QueueFullError) { + void reply + .header('retry-after', String(error.retryAfterSeconds)) + .code(429) + .send({ error: { code: 'QUEUE_FULL', message: 'too many pending requests, retry later' } }); + return; + } + if (error instanceof HttpError) { + // The client-facing body stays opaque (invariant I4), so the `cause` — + // the RLN or wallet failure this was mapped from — is the only record + // an operator has of why a money-moving call failed. It must reach the + // log, or an 'ambiguous' withdrawal is unresolvable. + if (error.statusCode >= 500 || error.cause !== undefined) { + request.log.error( + { err: error.cause ?? error, code: error.code, statusCode: error.statusCode }, + 'request failed', + ); + } + void reply + .code(error.statusCode) + .send({ error: { code: error.code, message: error.message } }); + return; + } + if (error instanceof FloatCapExceededError) { + void reply.code(409).send({ error: { code: 'FLOAT_CAP_EXCEEDED', message: error.message } }); + return; + } + if (error instanceof InsufficientBalanceError) { + void reply + .code(409) + .send({ error: { code: 'INSUFFICIENT_BALANCE', message: error.message } }); + return; + } + const maybeValidation = error as { validation?: unknown; message?: unknown }; + if (maybeValidation.validation !== undefined) { + const message = + typeof maybeValidation.message === 'string' ? maybeValidation.message : 'invalid request'; + void reply.code(400).send({ error: { code: 'BAD_REQUEST', message } }); + return; + } + // Unknown failure: log server-side, return an opaque error (no internals leak). + request.log.error({ err: error }, 'unhandled error'); + void reply.code(500).send({ error: { code: 'INTERNAL', message: 'internal error' } }); + }); + + app.get('/v1/health', { schema: healthRouteSchema }, async () => ({ status: 'ok' })); + + const operatorGuard = makeOperatorGuard(config.operatorToken); + app.post( + '/v1/users', + { schema: createUserRouteSchema, preHandler: [operatorGuard] }, + async (request, reply) => { + const created = createUser(db); + request.log.info({ userId: created.userId }, 'user created'); + return reply.code(201).send(created); + }, + ); + + app.get('/v1/me', { schema: meRouteSchema, preHandler: [app.authenticate] }, async (request) => { + const row = db.prepare('SELECT created_at FROM users WHERE id = ?').get(request.userId) as + { created_at: number } | undefined; + if (row === undefined) { + throw new HttpError(401, 'UNAUTHORIZED', 'unknown user'); + } + return { userId: request.userId, createdAt: row.created_at }; + }); + + registerWalletRoutes(app); + registerOnchainRoutes(app); + registerLnRoutes(app); + + if (options.startWorkers === true) { + depositsWorker.start(config.depositsIntervalMs); + reconciler.start(config.reconcilerIntervalMs); + } + + app.addHook('onClose', async () => { + depositsWorker.stop(); + reconciler.stop(); + await walletPool.closeAll(); + db.close(); + }); + + return app; +} diff --git a/minimal-sdk/packages/gateway/src/wallets/backend.ts b/minimal-sdk/packages/gateway/src/wallets/backend.ts new file mode 100644 index 00000000..c51809ea --- /dev/null +++ b/minimal-sdk/packages/gateway/src/wallets/backend.ts @@ -0,0 +1,194 @@ +/** + * Backend abstraction over per-user watch-only rgb-lib wallets. + * + * The gateway never holds user key material (invariant I2): a wallet is + * constructed from the account xpubs registered by the client and can prepare + * but never sign. The production implementation (./rgblib.ts) drives the + * native rgb-lib binding; unit tests substitute a mock. + */ +import { HttpError } from '../errors.js'; + +export interface UserXpubs { + vanilla: string; + colored: string; + fingerprint: string; +} + +export interface WalletIdentity { + userId: string; + xpubs: UserXpubs; +} + +/** rgb-lib Balance triple, used for both BTC (sats) and assets (units). */ +export interface Balance { + settled: number; + future: number; + spendable: number; +} + +export interface WalletBtcBalance { + vanilla: Balance; + colored: Balance; +} + +export interface WalletAsset { + assetId: string; + schema: string; + ticker: string | null; + name: string; + precision: number; + balance: Balance; +} + +export interface UnspentAllocation { + assetId: string | null; + amount: number | null; + settled: boolean; +} + +export interface WalletUnspent { + txid: string; + vout: number; + amountSat: number; + colorable: boolean; + allocations: UnspentAllocation[]; +} + +export interface WalletTransfer { + idx: number; + assetId: string | null; + amount: number | null; + kind: string; + status: string; + txid: string | null; + recipientId: string | null; + expiration: number | null; + createdAt: number; + updatedAt: number; +} + +export interface ReceiveRequest { + mode: 'blind' | 'witness'; + /** null = asset-agnostic invoice. */ + assetId: string | null; + /** null = amount-less invoice (rgb-lib Assignment "Any"). */ + amount: number | null; + expirationTimestamp: number; + transportEndpoints: string[]; + minConfirmations: number; +} + +export interface ReceiveData { + invoice: string; + recipientId: string; + expirationTimestamp: number | null; +} + +export interface SendAssetBeginRequest { + assetId: string; + /** Blinded UTXO or witness recipient id from the recipient's invoice. */ + recipientId: string; + amount: number; + /** Sats carried by the witness output; null for blind recipients. */ + witnessAmountSat: number | null; + transportEndpoints: string[]; + donation: boolean; + feeRateSatPerVb: number; + minConfirmations: number; + /** Unix seconds after which the wallet-side pending transfer expires. */ + expirationTimestamp: number; +} + +export interface CreateUtxosParams { + upTo: boolean; + num: number; + size: number; + feeRateSatPerVb: number; +} + +/** + * One open watch-only wallet. All calls MUST be serialized through the + * per-wallet operation queue; the handle itself is not concurrency-safe. + */ +export interface WalletHandle { + getAddress(): Promise; + getBtcBalance(): Promise; + listAssets(): Promise; + listUnspents(): Promise; + listTransfers(assetId: string | null): Promise; + receive(request: ReceiveRequest): Promise; + /** Prepare an unsigned BTC-send PSBT (client signs externally). */ + sendBtcBegin(address: string, amountSat: number, feeRateSatPerVb: number): Promise; + /** Broadcast a signed BTC-send PSBT; returns the txid. */ + sendBtcEnd(signedPsbt: string): Promise; + /** Prepare an unsigned asset-send PSBT (opret commitment already embedded). */ + sendAssetBegin(request: SendAssetBeginRequest): Promise; + /** Post consignment + broadcast a signed asset-send PSBT; returns the txid. */ + sendAssetEnd(signedPsbt: string): Promise; + /** Prepare an unsigned PSBT creating colorable UTXOs. */ + createUtxosBegin(params: CreateUtxosParams): Promise; + /** Broadcast a signed create-utxos PSBT; returns how many UTXOs were created. */ + createUtxosEnd(signedPsbt: string): Promise; + /** RGB-side refresh: advances pending transfers. */ + refresh(): Promise; + /** BTC-side sync against the shared indexer. */ + sync(): Promise; + close(): Promise; +} + +export interface WalletBackend { + open(identity: WalletIdentity): Promise; +} + +/** + * Wallet-layer failure. `detail` stays gateway-internal (logged, never sent + * to clients); `insufficientFunds` and `clientError` mark the failures caused + * by the caller's own request rather than by the gateway or the node. + */ +export class WalletBackendError extends Error { + constructor( + message: string, + readonly detail: string, + readonly insufficientFunds: boolean = false, + /** rgb-lib rejected the REQUEST (bad recipient/asset/address/fee/endpoint). */ + readonly clientError: boolean = false, + ) { + super(message); + this.name = 'WalletBackendError'; + } +} + +/** + * Single mapper from wallet-layer failures to client responses, shared by the + * wallet service and the on-chain prepare flows. A request the caller got + * wrong must answer 4xx: letting an rgb-lib rejection fall through to the + * central handler would report bad input as 500 INTERNAL and log it as an + * unhandled error. Anything not classified here is genuinely unexpected and + * is deliberately left to become a 500. + * + * The `complete` path keeps its own mapping: a failure that proves the + * transaction never broadcast is the caller's fault and carries a more useful + * code (PSBT_REJECTED), while anything unclassified there may have happened + * with the transaction already on the network and must not be blamed on the + * signature (see PSBT_REJECTION_VARIANTS in ./prepare.ts). + */ +export function walletHttpError(error: unknown): unknown { + if (!(error instanceof WalletBackendError)) return error; + if (error.insufficientFunds) { + return new HttpError( + 400, + 'INSUFFICIENT_FUNDS', + 'not enough confirmed bitcoins, assets or allocation slots for this operation', + { cause: error }, + ); + } + if (error.clientError) { + return new HttpError( + 400, + 'WALLET_REQUEST_REJECTED', + 'the wallet rejected this request (bad recipient, asset, address, fee rate or transport endpoint)', + { cause: error }, + ); + } + return error; +} diff --git a/minimal-sdk/packages/gateway/src/wallets/pool.ts b/minimal-sdk/packages/gateway/src/wallets/pool.ts new file mode 100644 index 00000000..863a8438 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/wallets/pool.ts @@ -0,0 +1,94 @@ +/** + * Lazy-open LRU pool of per-user watch-only wallets. + * + * A wallet opens on first use (construction + goOnline) and stays cached; when + * more than maxOpen wallets are open, the least-recently-used one is closed. + * All access — including open and close — runs on the wallet's single-flight + * queue, so an eviction never interrupts an in-flight operation and a reopen + * queued behind a close observes the closed state, not a stale handle. + */ +import type { WalletBackend, WalletHandle, WalletIdentity } from './backend.js'; +import type { WalletOpQueues } from './queue.js'; + +export interface WalletPoolOptions { + maxOpen: number; +} + +export class WalletPool { + /** Insertion order doubles as LRU order (oldest first). */ + private readonly open = new Map(); + + constructor( + private readonly backend: WalletBackend, + private readonly queues: WalletOpQueues, + private readonly options: WalletPoolOptions, + ) {} + + /** Number of currently open wallets (for tests and metrics). */ + openCount(): number { + return this.open.size; + } + + /** + * Run one operation against the user's wallet, opening it if needed. The + * whole acquire+operate sequence holds the wallet's queue slot. + */ + async withWallet( + identity: WalletIdentity, + fn: (wallet: WalletHandle) => Promise, + ): Promise { + return this.queues.run(identity.userId, async () => { + const handle = await this.acquire(identity); + return fn(handle); + }); + } + + /** Must only be called from within the wallet's queue slot. */ + private async acquire(identity: WalletIdentity): Promise { + const existing = this.open.get(identity.userId); + if (existing !== undefined) { + this.open.delete(identity.userId); + this.open.set(identity.userId, existing); + return existing; + } + const handle = await this.backend.open(identity); + this.open.set(identity.userId, handle); + this.evictOverflow(identity.userId); + return handle; + } + + private evictOverflow(inUseUserId: string): void { + while (this.open.size > this.options.maxOpen) { + const lru = this.open.keys().next().value; + if (lru === undefined || lru === inUseUserId) return; + const handle = this.open.get(lru); + this.open.delete(lru); + if (handle === undefined) continue; + // Close behind any queued work for that wallet; a later request reopens. + void this.queues.run(lru, () => handle.close()).catch(() => undefined); + } + } + + /** + * Drop a user's cached wallet (closed behind any queued work). Used when a + * registration attempt fails after the smoke-open: the cached handle would + * otherwise serve addresses derived from xpubs that were never persisted. + */ + evict(userId: string): void { + const handle = this.open.get(userId); + if (handle === undefined) return; + this.open.delete(userId); + void this.queues.run(userId, () => handle.close()).catch(() => undefined); + } + + /** Close every open wallet (server shutdown). */ + async closeAll(): Promise { + const entries = [...this.open.entries()]; + this.open.clear(); + await Promise.all( + entries.map(([userId, handle]) => + this.queues.run(userId, () => handle.close()).catch(() => undefined), + ), + ); + } +} diff --git a/minimal-sdk/packages/gateway/src/wallets/prepare.ts b/minimal-sdk/packages/gateway/src/wallets/prepare.ts new file mode 100644 index 00000000..2362e9dd --- /dev/null +++ b/minimal-sdk/packages/gateway/src/wallets/prepare.ts @@ -0,0 +1,432 @@ +/** + * On-chain prepare/complete flows: the server prepares an unsigned PSBT on the + * user's watch-only wallet, the client verifies and signs it externally, the + * server completes (finalize + broadcast via rgb-lib). + * + * Every prepare records a pending operation with a TTL so an unsigned PSBT + * expires, and returns a machine-readable intent summary built from the + * gateway's OWN inputs (request fields + config) — never echoed from RLN or + * parsed back out of the PSBT — for the client's verify-before-sign checks. + */ +import { randomUUID } from 'node:crypto'; +import { base64, hex } from '@scure/base'; +import { Address, OutScript, Transaction } from '@scure/btc-signer'; +import type { GatewayConfig } from '../config.js'; +import type { GatewayDb } from '../db.js'; +import { HttpError } from '../errors.js'; +import { recordOwnership } from '../rln/scoping.js'; +import { + WalletBackendError, + walletHttpError, + type WalletHandle, + type WalletIdentity, +} from './backend.js'; +import type { WalletPool } from './pool.js'; +import type { WalletService } from './service.js'; + +export type OnchainOpKind = 'send_btc' | 'send_asset' | 'create_utxos'; + +export interface IntentRecipient { + address: string; + scriptHex: string; + amountSat: number; +} + +export interface IntentAsset { + assetId: string; + amount: number; + recipientId: string; + witnessAmountSat: number | null; + transportEndpoints: string[]; +} + +export interface IntentUtxos { + upTo: boolean; + num: number; + size: number; +} + +/** What the client verifies the PSBT against before signing (design doc, 5 checks). */ +export interface OnchainIntent { + kind: OnchainOpKind; + feeRateSatPerVb: number; + recipients: IntentRecipient[]; + asset: IntentAsset | null; + utxos: IntentUtxos | null; +} + +export interface PreparedOp { + opId: string; + psbt: string; + intent: OnchainIntent; + /** Unix milliseconds; completion after this fails with 410 OP_EXPIRED. */ + expiresAt: number; +} + +export interface CompletedOp { + /** Broadcast txid; null only for create-utxos when the PSBT is unparsable. */ + txid: string | null; + /** Only set for create-utxos completions. */ + utxosCreated: number | null; +} + +export interface PrepareSendBtcParams { + address: string; + amountSat: number; + feeRateSatPerVb?: number | undefined; +} + +export interface PrepareSendAssetParams { + assetId: string; + amount: number; + recipientId: string; + witnessAmountSat?: number | undefined; + transportEndpoints?: string[] | undefined; + donation?: boolean | undefined; + minConfirmations?: number | undefined; + feeRateSatPerVb?: number | undefined; +} + +export interface PrepareCreateUtxosParams { + num?: number | undefined; + size?: number | undefined; + upTo?: boolean | undefined; + feeRateSatPerVb?: number | undefined; +} + +const DEFAULT_FEE_RATE_SAT_PER_VB = 2; +const DEFAULT_UTXO_NUM = 5; +const DEFAULT_UTXO_SIZE = 1000; +const DEFAULT_MIN_CONFIRMATIONS = 1; + +/** Address version bytes / bech32 prefixes per rgb-lib BitcoinNetwork. */ +const ADDRESS_NETWORKS: Record< + GatewayConfig['bitcoinNetwork'], + { bech32: string; pubKeyHash: number; scriptHash: number; wif: number } +> = { + Mainnet: { bech32: 'bc', pubKeyHash: 0x00, scriptHash: 0x05, wif: 0x80 }, + Testnet: { bech32: 'tb', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, + Signet: { bech32: 'tb', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, + Regtest: { bech32: 'bcrt', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, +}; + +/** + * Decode a recipient address to its output script (hex) for the intent + * summary; throws 400 INVALID_ADDRESS on garbage or a wrong-network address. + */ +export function addressToScriptHex( + address: string, + network: GatewayConfig['bitcoinNetwork'], +): string { + try { + const decoded = Address(ADDRESS_NETWORKS[network]).decode(address); + return hex.encode(OutScript.encode(decoded)); + } catch { + throw new HttpError(400, 'INVALID_ADDRESS', `not a valid ${network} bitcoin address`); + } +} + +/** + * Txid of a (finalized or merely signed) PSBT. Witness data does not affect + * the txid, so the unsigned tx inside the PSBT already determines it. + */ +export function txidFromPsbt(psbtBase64: string): string | null { + try { + const tx = Transaction.fromPSBT(base64.decode(psbtBase64), { + allowUnknownInputs: true, + allowUnknownOutputs: true, + }); + return tx.id; + } catch { + return null; + } +} + +/** + * rgb-lib failures that prove the transaction NEVER reached the network, so + * blaming the signed PSBT is honest. + * + * `send_btc_end`/`create_utxos_end` broadcast FIRST and write their + * bookkeeping after (`broadcast_psbt` -> BDK `persist` -> colored-txo/spent + * updates -> `finalize_vanilla_wallet_transaction` -> `update_backup_info` -> + * `commit`; rgb-lib `wallet/online.rs:89-118,315-322,3750-3754`, + * `wallet/singlesig.rs:716-726,1103-1113`). Every one of those post-broadcast + * failure points surfaces as an unclassified `Database`/`IO`/`Internal` + * error for a transaction that is already on the network — the same hazard + * the LN withdraw path handles by refusing to treat a 5xx as a rejection. + * Calling those PSBT_REJECTED would tell the user their signature was bad + * about money that actually moved, cache that 400 under their idempotency key + * (4xx is not retryable in `idempotency.onSend`), and leave no server-side + * trace, since the central handler logs only 5xx-or-caused errors. + * + * A broadcast the indexer refused is `FailedBroadcast`, raised only after + * rgb-lib re-checks that the txid has no confirmations (`online.rs:79-84`) — + * so tampering, the case this mapping exists for, still lands on a clean 400. + */ +const PSBT_REJECTION_VARIANTS = + /\b(FailedBroadcast|InvalidPsbt|CannotFinalizePsbt|CannotCombinePsbts)\b/; + +interface PendingOpRow { + id: string; + user_id: string; + kind: OnchainOpKind; + psbt: string; + intent: string; + state: 'pending' | 'completed' | 'expired'; + txid: string | null; + expires_at: number; +} + +export class OnchainService { + constructor( + private readonly db: GatewayDb, + private readonly pool: WalletPool, + private readonly wallets: WalletService, + private readonly config: GatewayConfig, + ) {} + + private async run( + identity: WalletIdentity, + fn: (wallet: WalletHandle) => Promise, + ): Promise { + try { + return await this.pool.withWallet(identity, fn); + } catch (error) { + throw walletHttpError(error); + } + } + + private storePrepared( + userId: string, + kind: OnchainOpKind, + psbt: string, + intent: OnchainIntent, + now: number, + ): PreparedOp { + // Lazy hygiene: retire this user's stale unsigned ops on every prepare. + this.db + .prepare( + `UPDATE pending_ops SET state = 'expired' + WHERE user_id = ? AND state = 'pending' AND expires_at <= ?`, + ) + .run(userId, now); + const opId = randomUUID(); + const expiresAt = now + this.config.onchainOpTtlSeconds * 1000; + this.db + .prepare( + `INSERT INTO pending_ops (id, user_id, kind, psbt, intent, state, txid, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, 'pending', NULL, ?, ?)`, + ) + .run(opId, userId, kind, psbt, JSON.stringify(intent), now, expiresAt); + return { opId, psbt, intent, expiresAt }; + } + + async prepareSendBtc( + userId: string, + params: PrepareSendBtcParams, + now: number = Date.now(), + ): Promise { + const identity = this.wallets.identityOf(userId); + const feeRateSatPerVb = params.feeRateSatPerVb ?? DEFAULT_FEE_RATE_SAT_PER_VB; + const scriptHex = addressToScriptHex(params.address, this.config.bitcoinNetwork); + const psbt = await this.run(identity, (wallet) => + wallet.sendBtcBegin(params.address, params.amountSat, feeRateSatPerVb), + ); + const intent: OnchainIntent = { + kind: 'send_btc', + feeRateSatPerVb, + recipients: [{ address: params.address, scriptHex, amountSat: params.amountSat }], + asset: null, + utxos: null, + }; + return this.storePrepared(userId, 'send_btc', psbt, intent, now); + } + + async prepareSendAsset( + userId: string, + params: PrepareSendAssetParams, + now: number = Date.now(), + ): Promise { + const identity = this.wallets.identityOf(userId); + const feeRateSatPerVb = params.feeRateSatPerVb ?? DEFAULT_FEE_RATE_SAT_PER_VB; + // Consignment endpoints make the wallet dial out (SSRF guard): only + // operator-allowlisted proxies are accepted — explicit error, no fallback. + const transportEndpoints = params.transportEndpoints ?? [this.config.rgbProxyUrl]; + const denied = transportEndpoints.filter( + (endpoint) => !this.config.rgbTransportAllowlist.includes(endpoint), + ); + if (denied.length > 0) { + throw new HttpError( + 400, + 'TRANSPORT_ENDPOINT_NOT_ALLOWED', + 'transportEndpoints must be on the operator allowlist', + ); + } + const witnessAmountSat = params.witnessAmountSat ?? null; + const expiresAtSeconds = Math.floor((now + this.config.onchainOpTtlSeconds * 1000) / 1000); + const psbt = await this.run(identity, (wallet) => + wallet.sendAssetBegin({ + assetId: params.assetId, + recipientId: params.recipientId, + amount: params.amount, + witnessAmountSat, + transportEndpoints, + donation: params.donation ?? false, + feeRateSatPerVb, + minConfirmations: params.minConfirmations ?? DEFAULT_MIN_CONFIRMATIONS, + // Wallet-side pending transfer expires together with the op. + expirationTimestamp: expiresAtSeconds, + }), + ); + const intent: OnchainIntent = { + kind: 'send_asset', + feeRateSatPerVb, + recipients: [], + asset: { + assetId: params.assetId, + amount: params.amount, + recipientId: params.recipientId, + witnessAmountSat, + transportEndpoints, + }, + utxos: null, + }; + return this.storePrepared(userId, 'send_asset', psbt, intent, now); + } + + async prepareCreateUtxos( + userId: string, + params: PrepareCreateUtxosParams, + now: number = Date.now(), + ): Promise { + const identity = this.wallets.identityOf(userId); + const feeRateSatPerVb = params.feeRateSatPerVb ?? DEFAULT_FEE_RATE_SAT_PER_VB; + const utxos: IntentUtxos = { + upTo: params.upTo ?? false, + num: params.num ?? DEFAULT_UTXO_NUM, + size: params.size ?? DEFAULT_UTXO_SIZE, + }; + const psbt = await this.run(identity, (wallet) => + wallet.createUtxosBegin({ ...utxos, feeRateSatPerVb }), + ); + const intent: OnchainIntent = { + kind: 'create_utxos', + feeRateSatPerVb, + recipients: [], + asset: null, + utxos, + }; + return this.storePrepared(userId, 'create_utxos', psbt, intent, now); + } + + /** + * Complete a prepared op with the client-signed PSBT: rgb-lib finalizes and + * broadcasts. A rejected PSBT (tampering, missing signature) surfaces as a + * clean 400 and the op stays pending until its TTL — the client may retry + * with a fresh Idempotency-Key and a correctly signed PSBT. A wallet failure + * that cannot be pinned on the PSBT is reported as an ambiguous 502 instead, + * because rgb-lib broadcasts before it finishes its bookkeeping. + */ + async complete( + userId: string, + kind: OnchainOpKind, + opId: string, + signedPsbt: string, + now: number = Date.now(), + ): Promise { + const identity = this.wallets.identityOf(userId); + const row = this.db.prepare('SELECT * FROM pending_ops WHERE id = ?').get(opId) as + PendingOpRow | undefined; + // A foreign user's op is indistinguishable from a missing one (I3). + if (row === undefined || row.user_id !== userId) { + throw new HttpError(404, 'OP_NOT_FOUND', 'no such prepared operation'); + } + if (row.kind !== kind) { + throw new HttpError(409, 'OP_KIND_MISMATCH', 'operation was prepared for a different flow'); + } + if (row.state === 'completed') { + throw new HttpError(409, 'OP_ALREADY_COMPLETED', 'operation was already completed'); + } + if (row.state === 'expired' || row.expires_at <= now) { + this.db.prepare(`UPDATE pending_ops SET state = 'expired' WHERE id = ?`).run(opId); + throw new HttpError(410, 'OP_EXPIRED', 'prepared operation expired; prepare again'); + } + // Bind the signed PSBT to THIS op: signatures do not change the txid, so a + // signed PSBT for a different prepared transaction must be rejected — + // otherwise this op's bookkeeping (intent, txid, transfer ownership) would + // describe a transaction that never broadcast. rgb-lib prepared PSBTs + // always parse; whenever the prepared side does, an unparsable or + // different-txid signed PSBT is a hard 400 — never a silently skipped + // check. (An unparsable PREPARED psbt only occurs with mock backends; a + // client could not have verified or signed it anyway.) + const preparedTxid = txidFromPsbt(row.psbt); + const signedTxid = txidFromPsbt(signedPsbt); + if (preparedTxid !== null && preparedTxid !== signedTxid) { + throw new HttpError( + 400, + 'PSBT_MISMATCH', + 'the signed PSBT does not correspond to this prepared operation', + ); + } + + let txid: string | null = null; + let utxosCreated: number | null = null; + try { + await this.pool.withWallet(identity, async (wallet) => { + if (kind === 'send_btc') { + txid = await wallet.sendBtcEnd(signedPsbt); + } else if (kind === 'send_asset') { + txid = await wallet.sendAssetEnd(signedPsbt); + } else { + utxosCreated = await wallet.createUtxosEnd(signedPsbt); + txid = txidFromPsbt(signedPsbt); + } + }); + } catch (error) { + if (error instanceof WalletBackendError) { + if (error.insufficientFunds) { + throw new HttpError(400, 'INSUFFICIENT_FUNDS', 'operation no longer affordable', { + cause: error, + }); + } + if (error.clientError || PSBT_REJECTION_VARIANTS.test(error.detail)) { + throw new HttpError( + 400, + 'PSBT_REJECTED', + 'the signed PSBT was rejected (bad signature, tampering, or mismatch with the prepared transaction)', + { cause: error }, + ); + } + // Ambiguous: the transaction may already be on the network. Record the + // txid so an operator can resolve it, leave the op pending, and answer + // 5xx — logged by the central handler, retryable in the idempotency + // middleware. Retrying `complete` is the recovery path and is safe: + // rebroadcasting a transaction the indexer already knows succeeds + // (rgb-lib `online.rs:79-84`), so the retry just redoes the + // bookkeeping that failed here. + this.db + .prepare(`UPDATE pending_ops SET txid = ? WHERE id = ? AND state = 'pending'`) + .run(signedTxid, opId); + throw new HttpError( + 502, + 'COMPLETE_AMBIGUOUS', + 'the wallet failed after the transaction may have been broadcast; retry this operation', + { cause: error }, + ); + } + throw error; + } + + this.db + .prepare(`UPDATE pending_ops SET state = 'completed', txid = ? WHERE id = ?`) + .run(txid, opId); + if (kind === 'send_asset' && txid !== null) { + recordOwnership( + this.db, + { kind: 'asset_transfer', resourceId: txid, userId, state: 'sent' }, + now, + ); + } + return { txid, utxosCreated }; + } +} diff --git a/minimal-sdk/packages/gateway/src/wallets/queue.ts b/minimal-sdk/packages/gateway/src/wallets/queue.ts new file mode 100644 index 00000000..b5302f88 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/wallets/queue.ts @@ -0,0 +1,27 @@ +/** + * Per-wallet single-flight operation queue (Block 3 item 3). + * + * rgb-lib wallet handles are not concurrency-safe: every operation on a + * user's wallet — request-driven calls and background syncs alike — runs + * through run(userId, fn), which chains strictly FIFO per wallet. Unlike the + * request-level UserQueues (../queue.ts) there is no depth cap or global + * semaphore here: backpressure is applied at the request layer, and workers + * enqueue at most one background operation per wallet at a time. + */ + +export class WalletOpQueues { + private readonly tails = new Map>(); + + run(walletId: string, fn: () => Promise): Promise { + const previous = this.tails.get(walletId) ?? Promise.resolve(); + const run: Promise = previous.catch(() => undefined).then(fn); + this.tails.set(walletId, run); + // Drop the tail entry once the chain drains (a newer run replaces it). + void run + .catch(() => undefined) + .finally(() => { + if (this.tails.get(walletId) === run) this.tails.delete(walletId); + }); + return run; + } +} diff --git a/minimal-sdk/packages/gateway/src/wallets/rgblib.ts b/minimal-sdk/packages/gateway/src/wallets/rgblib.ts new file mode 100644 index 00000000..b0b3511b --- /dev/null +++ b/minimal-sdk/packages/gateway/src/wallets/rgblib.ts @@ -0,0 +1,573 @@ +/** + * Native rgb-lib backend (Preflight A decision, Task 1): the published + * `@utexo/rgb-lib` JS wrapper is stale, so this shim calls the compiled + * platform module directly with the verified signatures. Marshalling rules + * (documented in minimal-sdk/README.md): + * - numeric inputs are passed as strings; + * - JSON payloads are camelCase; + * - Assignment is externally tagged with a JSON number ({"Fungible": 100}) + * or a bare string for unit variants ("Any"); + * - JS null maps to a native NULL only for nullable-typemap params + * (asset ids); expiration timestamps must always be concrete strings; + * - watch-only construction passes keys JSON with `mnemonic: null`. + */ +import { mkdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { + WalletBackendError, + type Balance, + type CreateUtxosParams, + type ReceiveData, + type ReceiveRequest, + type SendAssetBeginRequest, + type UnspentAllocation, + type WalletAsset, + type WalletBackend, + type WalletBtcBalance, + type WalletHandle, + type WalletIdentity, + type WalletTransfer, + type WalletUnspent, +} from './backend.js'; + +type OpaqueHandle = unknown; + +interface NativeRgbLib { + rgblib_new_wallet(walletDataJson: string, keysJson: string): OpaqueHandle; + rgblib_go_online( + wallet: OpaqueHandle, + skipConsistencyCheck: boolean, + indexerUrl: string, + ): OpaqueHandle; + rgblib_get_address(wallet: OpaqueHandle): string; + rgblib_get_btc_balance(wallet: OpaqueHandle, online: OpaqueHandle, skipSync: boolean): string; + rgblib_list_assets(wallet: OpaqueHandle, filterAssetSchemasJson: string): string; + rgblib_list_unspents( + wallet: OpaqueHandle, + online: OpaqueHandle, + settledOnly: boolean, + skipSync: boolean, + ): string; + rgblib_list_transfers(wallet: OpaqueHandle, assetId: string | null): string; + rgblib_blind_receive( + wallet: OpaqueHandle, + assetId: string | null, + assignmentJson: string, + expirationTimestamp: string, + transportEndpointsJson: string, + minConfirmations: string, + ): string; + rgblib_witness_receive( + wallet: OpaqueHandle, + assetId: string | null, + assignmentJson: string, + expirationTimestamp: string, + transportEndpointsJson: string, + minConfirmations: string, + ): string; + rgblib_send_btc_begin( + wallet: OpaqueHandle, + online: OpaqueHandle, + address: string, + amount: string, + feeRate: string, + skipSync: boolean, + ): string; + rgblib_send_btc_end( + wallet: OpaqueHandle, + online: OpaqueHandle, + signedPsbt: string, + skipSync: boolean, + ): string; + rgblib_send_begin( + wallet: OpaqueHandle, + online: OpaqueHandle, + recipientMapJson: string, + donation: boolean, + feeRate: string, + minConfirmations: string, + expirationTimestamp: string, + dryRun: boolean, + ): string; + rgblib_send_end( + wallet: OpaqueHandle, + online: OpaqueHandle, + signedPsbt: string, + skipSync: boolean, + ): string; + rgblib_create_utxos_begin( + wallet: OpaqueHandle, + online: OpaqueHandle, + upTo: boolean, + num: string, + size: string, + feeRate: string, + skipSync: boolean, + ): string; + rgblib_create_utxos_end( + wallet: OpaqueHandle, + online: OpaqueHandle, + signedPsbt: string, + skipSync: boolean, + ): string; + rgblib_refresh( + wallet: OpaqueHandle, + online: OpaqueHandle, + assetId: string | null, + filterJson: string, + skipSync: boolean, + ): string; + rgblib_sync(wallet: OpaqueHandle, online: OpaqueHandle): unknown; + rgblib_restore_keys(network: string, mnemonic: string): string; + free_wallet(wallet: OpaqueHandle): void; + free_online(online: OpaqueHandle): void; +} + +const PLATFORM_PACKAGES: Record = { + 'linux-x64': '@utexo/rgb-lib-linux-x64', + 'linux-arm64': '@utexo/rgb-lib-linux-arm64', + 'darwin-arm64': '@utexo/rgb-lib-darwin-arm64', +}; + +let nativeModule: NativeRgbLib | undefined; + +/** Lazily require the compiled platform module (never loaded by unit tests). */ +export function loadNativeRgbLib(): NativeRgbLib { + if (nativeModule !== undefined) return nativeModule; + const key = `${process.platform}-${process.arch}`; + const packageName = PLATFORM_PACKAGES[key]; + if (packageName === undefined) { + throw new WalletBackendError( + 'rgb-lib native module unavailable', + `no rgb-lib platform package for ${key}`, + ); + } + const require = createRequire(import.meta.url); + nativeModule = require(`${packageName}/rgblib`) as NativeRgbLib; + return nativeModule; +} + +function toDetail(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * rgb-lib error variants that mean "the REQUEST was wrong", as opposed to a + * gateway bug or a node/indexer failure. Matched on the variant name because + * the native binding surfaces Rust debug strings (`RgbLib(InvalidAddress)`). + * Keep in sync with rgb-lib's `src/error.rs` on version bumps; an unlisted + * variant degrades to 500, never to a wrong 400. + */ +const CLIENT_ERROR_VARIANTS = + /\b(AssetNotFound|BitcoinNetworkMismatch|Invalid(Address|AmountZero|Assignment|BitcoinNetwork|Expiration|FeeRate|ProxyProtocol|RecipientData|RecipientID|RecipientMap|RecipientNetwork|TransportEndpoints?|Txid|WitnessVersion)|MaxFeeExceeded|MinFeeNotMet|NoValidTransportEndpoint|OutputBelowDustLimit|RecipientIDAlreadyUsed|RecipientIDDuplicated|UnknownRgbSchema|UnsupportedSchema|UnsupportedTransportType)\b/; + +/** Wrap a native failure; rgb-lib errors are Rust debug strings. Exported for unit tests. */ +export function wrapNativeError(operation: string, error: unknown): WalletBackendError { + const detail = toDetail(error); + const insufficient = /Insufficient(Bitcoins|AllocationSlots|SpendableAssets|TotalAssets)/.test( + detail, + ); + return new WalletBackendError( + `wallet ${operation} failed`, + detail, + insufficient, + !insufficient && CLIENT_ERROR_VARIANTS.test(detail), + ); +} + +function asRecord(value: unknown): Record { + return value !== null && typeof value === 'object' ? (value as Record) : {}; +} + +function numberOrZero(value: unknown): number { + return typeof value === 'number' ? value : 0; +} + +function numberOrNull(value: unknown): number | null { + return typeof value === 'number' ? value : null; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === 'string' && value !== '' ? value : null; +} + +/** Strip JSON quoting if the native layer returned a serialized string. Exported for unit tests. */ +export function unquote(value: string): string { + if (value.startsWith('"') && value.endsWith('"')) { + try { + return JSON.parse(value) as string; + } catch { + return value; + } + } + return value; +} + +function mapBalance(value: unknown): Balance { + const record = asRecord(value); + return { + settled: numberOrZero(record['settled']), + future: numberOrZero(record['future']), + spendable: numberOrZero(record['spendable']), + }; +} + +/** Fungible amount of an externally tagged rgb-lib Assignment, if any. */ +function assignmentAmount(value: unknown): number | null { + const record = asRecord(value); + return numberOrNull(record['Fungible']); +} + +/** Exported for unit tests (response-shape drift must fail loudly, not corrupt data). */ +export function mapAssets(raw: unknown): WalletAsset[] { + const bySchema = asRecord(raw); + const assets: WalletAsset[] = []; + for (const [schemaKey, list] of Object.entries(bySchema)) { + if (!Array.isArray(list)) continue; + for (const entry of list) { + const record = asRecord(entry); + const assetId = stringOrNull(record['assetId']); + if (assetId === null) continue; + assets.push({ + assetId, + schema: schemaKey, + ticker: stringOrNull(record['ticker']), + name: typeof record['name'] === 'string' ? record['name'] : '', + precision: numberOrZero(record['precision']), + balance: mapBalance(record['balance']), + }); + } + } + return assets; +} + +export function mapUnspents(raw: unknown): WalletUnspent[] { + if (!Array.isArray(raw)) return []; + return raw.map((entry) => { + const record = asRecord(entry); + const utxo = asRecord(record['utxo']); + const outpoint = asRecord(utxo['outpoint']); + const allocationsRaw = record['rgbAllocations']; + const allocations: UnspentAllocation[] = Array.isArray(allocationsRaw) + ? allocationsRaw.map((allocation) => { + const allocationRecord = asRecord(allocation); + return { + assetId: stringOrNull(allocationRecord['assetId']), + amount: assignmentAmount(allocationRecord['assignment']), + settled: allocationRecord['settled'] === true, + }; + }) + : []; + return { + txid: typeof outpoint['txid'] === 'string' ? outpoint['txid'] : '', + vout: numberOrZero(outpoint['vout']), + amountSat: numberOrZero(utxo['btcAmount']), + colorable: utxo['colorable'] === true, + allocations, + }; + }); +} + +export function mapTransfers(raw: unknown, assetId: string | null): WalletTransfer[] { + if (!Array.isArray(raw)) return []; + return raw.map((entry) => { + const record = asRecord(entry); + let amount = assignmentAmount(record['requestedAssignment']); + if (amount === null && Array.isArray(record['assignments'])) { + const total = record['assignments'] + .map((assignment) => assignmentAmount(assignment)) + .filter((value): value is number => value !== null) + .reduce((sum, value) => sum + value, 0); + amount = total > 0 ? total : null; + } + return { + idx: numberOrZero(record['idx']), + assetId, + amount, + kind: typeof record['kind'] === 'string' ? record['kind'] : 'Unknown', + status: typeof record['status'] === 'string' ? record['status'] : 'Unknown', + txid: stringOrNull(record['txid']), + recipientId: stringOrNull(record['recipientId']), + expiration: numberOrNull(record['expiration']), + createdAt: numberOrZero(record['createdAt']), + updatedAt: numberOrZero(record['updatedAt']), + }; + }); +} + +export interface NativeWalletBackendOptions { + /** Gateway-owned directory; each user's wallet lives in `/`. */ + baseDir: string; + /** rgb-lib BitcoinNetwork name: Regtest | Testnet | Signet | Mainnet. */ + network: string; + /** Indexer URL for goOnline (electrum host:port verified in Preflight A). */ + indexerUrl: string; + maxAllocationsPerUtxo?: number; +} + +export class NativeWalletBackend implements WalletBackend { + constructor(private readonly options: NativeWalletBackendOptions) {} + + async open(identity: WalletIdentity): Promise { + const lib = loadNativeRgbLib(); + const dataDir = join(this.options.baseDir, identity.userId); + mkdirSync(dataDir, { recursive: true }); + const walletData = JSON.stringify({ + dataDir, + bitcoinNetwork: this.options.network, + databaseType: 'Sqlite', + maxAllocationsPerUtxo: String(this.options.maxAllocationsPerUtxo ?? 1), + accountXpubVanilla: identity.xpubs.vanilla, + accountXpubColored: identity.xpubs.colored, + vanillaKeychain: null, + supportedSchemas: ['Nia', 'Cfa', 'Uda', 'Ifa'], + }); + // Watch-only: mnemonic stays null; the client signs externally (I2). + const keys = JSON.stringify({ + accountXpubVanilla: identity.xpubs.vanilla, + accountXpubColored: identity.xpubs.colored, + vanillaKeychain: null, + masterFingerprint: identity.xpubs.fingerprint, + mnemonic: null, + }); + let wallet: OpaqueHandle; + try { + wallet = lib.rgblib_new_wallet(walletData, keys); + } catch (error) { + throw wrapNativeError('construction', error); + } + let online: OpaqueHandle; + try { + online = lib.rgblib_go_online(wallet, false, this.options.indexerUrl); + } catch (error) { + lib.free_wallet(wallet); + throw wrapNativeError('goOnline', error); + } + return new NativeWalletHandle(lib, wallet, online); + } +} + +class NativeWalletHandle implements WalletHandle { + private closed = false; + + constructor( + private readonly lib: NativeRgbLib, + private readonly wallet: OpaqueHandle, + private readonly online: OpaqueHandle, + ) {} + + private call(operation: string, fn: () => T): T { + if (this.closed) { + throw new WalletBackendError(`wallet ${operation} failed`, 'wallet handle is closed'); + } + try { + return fn(); + } catch (error) { + throw wrapNativeError(operation, error); + } + } + + async getAddress(): Promise { + return this.call('getAddress', () => this.lib.rgblib_get_address(this.wallet)); + } + + async getBtcBalance(): Promise { + const raw = this.call('getBtcBalance', () => + JSON.parse(this.lib.rgblib_get_btc_balance(this.wallet, this.online, false)), + ) as Record; + return { vanilla: mapBalance(raw['vanilla']), colored: mapBalance(raw['colored']) }; + } + + async listAssets(): Promise { + const raw = this.call('listAssets', () => + JSON.parse(this.lib.rgblib_list_assets(this.wallet, JSON.stringify([]))), + ); + return mapAssets(raw); + } + + async listUnspents(): Promise { + const raw = this.call('listUnspents', () => + JSON.parse(this.lib.rgblib_list_unspents(this.wallet, this.online, false, false)), + ); + return mapUnspents(raw); + } + + async listTransfers(assetId: string | null): Promise { + const raw = this.call('listTransfers', () => + JSON.parse(this.lib.rgblib_list_transfers(this.wallet, assetId)), + ); + return mapTransfers(raw, assetId); + } + + async receive(request: ReceiveRequest): Promise { + const assignment = + request.amount === null + ? JSON.stringify('Any') + : JSON.stringify({ Fungible: request.amount }); + const raw = this.call(`${request.mode}Receive`, () => { + const fn = + request.mode === 'blind' ? this.lib.rgblib_blind_receive : this.lib.rgblib_witness_receive; + return JSON.parse( + fn( + this.wallet, + request.assetId, + assignment, + String(request.expirationTimestamp), + JSON.stringify(request.transportEndpoints), + String(request.minConfirmations), + ), + ) as Record; + }); + const invoice = stringOrNull(raw['invoice']); + const recipientId = stringOrNull(raw['recipientId']); + if (invoice === null || recipientId === null) { + throw new WalletBackendError( + 'wallet receive failed', + `unexpected receive response shape: ${Object.keys(raw).join(',')}`, + ); + } + return { invoice, recipientId, expirationTimestamp: numberOrNull(raw['expirationTimestamp']) }; + } + + async sendBtcBegin(address: string, amountSat: number, feeRateSatPerVb: number): Promise { + return this.call('sendBtcBegin', () => + unquote( + this.lib.rgblib_send_btc_begin( + this.wallet, + this.online, + address, + String(amountSat), + String(feeRateSatPerVb), + false, + ), + ), + ); + } + + async sendBtcEnd(signedPsbt: string): Promise { + return this.call('sendBtcEnd', () => + unquote(this.lib.rgblib_send_btc_end(this.wallet, this.online, signedPsbt, false)), + ); + } + + async sendAssetBegin(request: SendAssetBeginRequest): Promise { + const recipientMap = JSON.stringify({ + [request.assetId]: [ + { + recipientId: request.recipientId, + witnessData: + request.witnessAmountSat === null + ? null + : { amountSat: request.witnessAmountSat, blinding: null }, + assignment: { Fungible: request.amount }, + transportEndpoints: request.transportEndpoints, + }, + ], + }); + // send_begin returns JSON {psbt, batchTransferIdx, details}; only the + // PSBT leaves this layer (details carry gateway-internal paths). + const raw = this.call('sendAssetBegin', () => + JSON.parse( + this.lib.rgblib_send_begin( + this.wallet, + this.online, + recipientMap, + request.donation, + String(request.feeRateSatPerVb), + String(request.minConfirmations), + String(request.expirationTimestamp), + false, + ), + ), + ) as Record; + const psbt = stringOrNull(raw['psbt']); + if (psbt === null) { + throw new WalletBackendError( + 'wallet sendAssetBegin failed', + `unexpected send-begin response shape: ${Object.keys(raw).join(',')}`, + ); + } + return psbt; + } + + async sendAssetEnd(signedPsbt: string): Promise { + const raw = this.call('sendAssetEnd', () => + JSON.parse(this.lib.rgblib_send_end(this.wallet, this.online, signedPsbt, false)), + ) as Record; + const txid = stringOrNull(raw['txid']); + if (txid === null) { + throw new WalletBackendError( + 'wallet sendAssetEnd failed', + `unexpected send result shape: ${Object.keys(raw).join(',')}`, + ); + } + return txid; + } + + async createUtxosBegin(params: CreateUtxosParams): Promise { + return this.call('createUtxosBegin', () => + unquote( + this.lib.rgblib_create_utxos_begin( + this.wallet, + this.online, + params.upTo, + String(params.num), + String(params.size), + String(params.feeRateSatPerVb), + false, + ), + ), + ); + } + + async createUtxosEnd(signedPsbt: string): Promise { + const raw = this.call('createUtxosEnd', () => + unquote(this.lib.rgblib_create_utxos_end(this.wallet, this.online, signedPsbt, false)), + ); + const created = Number(raw); + if (!Number.isFinite(created)) { + throw new WalletBackendError( + 'wallet createUtxosEnd failed', + `unexpected create-utxos result: ${raw}`, + ); + } + return created; + } + + async refresh(): Promise { + this.call('refresh', () => + this.lib.rgblib_refresh(this.wallet, this.online, null, JSON.stringify([]), false), + ); + } + + async sync(): Promise { + this.call('sync', () => this.lib.rgblib_sync(this.wallet, this.online)); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.lib.free_online(this.online); + this.lib.free_wallet(this.wallet); + } +} + +/** + * Test-fixture helper: deterministic xpubs from a mnemonic. Used ONLY by the + * integration/e2e suites to build a client-side signer fixture — mnemonics are + * never a gateway input (I1/I2). + */ +export function restoreKeysForTests( + network: string, + mnemonic: string, +): { vanilla: string; colored: string; fingerprint: string; xpub: string } { + const lib = loadNativeRgbLib(); + const keys = JSON.parse(lib.rgblib_restore_keys(network, mnemonic)) as Record; + return { + vanilla: String(keys['accountXpubVanilla']), + colored: String(keys['accountXpubColored']), + fingerprint: String(keys['masterFingerprint']), + xpub: String(keys['xpub']), + }; +} diff --git a/minimal-sdk/packages/gateway/src/wallets/service.ts b/minimal-sdk/packages/gateway/src/wallets/service.ts new file mode 100644 index 00000000..9c110ac2 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/wallets/service.ts @@ -0,0 +1,201 @@ +/** + * Per-user watch-only wallet service: xpub registration, wallet reads and + * receive-invoice creation. Balances, unspents and transfers come from the + * USER's wallet, never from the shared RLN node. + */ +import type { GatewayConfig } from '../config.js'; +import type { GatewayDb } from '../db.js'; +import { HttpError } from '../errors.js'; +import { recordOwnership } from '../rln/scoping.js'; +import { + WalletBackendError, + walletHttpError, + type ReceiveData, + type UserXpubs, + type WalletAsset, + type WalletBtcBalance, + type WalletHandle, + type WalletIdentity, + type WalletTransfer, + type WalletUnspent, +} from './backend.js'; +import type { WalletPool } from './pool.js'; + +export interface WalletBalances { + btc: WalletBtcBalance; + assets: WalletAsset[]; +} + +export interface ReceiveParams { + mode: 'blind' | 'witness'; + assetId?: string | undefined; + amount?: number | undefined; + durationSeconds?: number | undefined; + minConfirmations?: number | undefined; +} + +export interface ReceiveResult extends ReceiveData { + mode: 'blind' | 'witness'; +} + +const DEFAULT_RECEIVE_DURATION_SECONDS = 86_400; +const DEFAULT_MIN_CONFIRMATIONS = 1; + +export class WalletService { + constructor( + private readonly db: GatewayDb, + private readonly pool: WalletPool, + private readonly config: GatewayConfig, + ) {} + + /** Registered xpubs for a user, or undefined when none are registered. */ + xpubsOf(userId: string): UserXpubs | undefined { + const row = this.db + .prepare('SELECT vanilla, colored, fingerprint FROM user_xpubs WHERE user_id = ?') + .get(userId) as UserXpubs | undefined; + return row; + } + + /** Identity for wallet operations; 404 when no xpubs are registered. */ + identityOf(userId: string): WalletIdentity { + const xpubs = this.xpubsOf(userId); + if (xpubs === undefined) { + throw new HttpError(404, 'WALLET_NOT_REGISTERED', 'no xpubs registered for this user'); + } + return { userId, xpubs }; + } + + private async run( + identity: WalletIdentity, + fn: (wallet: WalletHandle) => Promise, + ): Promise { + try { + return await this.pool.withWallet(identity, fn); + } catch (error) { + throw walletHttpError(error); + } + } + + /** + * Register a user's account xpubs. Registration is permanent: re-registering + * identical values is an idempotent success, anything else is a conflict + * (a different fingerprint means a different master key — never allowed). + */ + async registerXpubs( + userId: string, + xpubs: UserXpubs, + now: number = Date.now(), + ): Promise<{ created: boolean; address: string }> { + const existing = this.xpubsOf(userId); + if (existing !== undefined) { + if ( + existing.fingerprint === xpubs.fingerprint && + existing.vanilla === xpubs.vanilla && + existing.colored === xpubs.colored + ) { + const address = await this.run({ userId, xpubs: existing }, (wallet) => + wallet.getAddress(), + ); + return { created: false, address }; + } + throw new HttpError( + 409, + 'XPUBS_MISMATCH', + 'different xpubs are already registered for this user', + ); + } + // Smoke-open before persisting so malformed xpubs are rejected up front. + let address: string; + try { + address = await this.pool.withWallet({ userId, xpubs }, (wallet) => wallet.getAddress()); + } catch (error) { + if (error instanceof WalletBackendError) { + throw new HttpError(400, 'INVALID_XPUBS', 'wallet construction from these xpubs failed'); + } + throw error; + } + try { + this.db + .prepare( + `INSERT INTO user_xpubs (user_id, vanilla, colored, fingerprint, created_at) + VALUES (?, ?, ?, ?, ?)`, + ) + .run(userId, xpubs.vanilla, xpubs.colored, xpubs.fingerprint, now); + } catch (error) { + // The smoke-open cached a wallet for xpubs that were never persisted; a + // retry with different xpubs must not be served from that stale handle. + this.pool.evict(userId); + throw error; + } + return { created: true, address }; + } + + async getAddress(userId: string): Promise { + return this.run(this.identityOf(userId), (wallet) => wallet.getAddress()); + } + + async getBalances(userId: string): Promise { + return this.run(this.identityOf(userId), async (wallet) => ({ + btc: await wallet.getBtcBalance(), + assets: await wallet.listAssets(), + })); + } + + async listUnspents(userId: string): Promise { + return this.run(this.identityOf(userId), (wallet) => wallet.listUnspents()); + } + + /** + * All transfers across the user's assets, plus asset-less ones (pending + * receives created before an asset was known). With assetId given, only + * that asset's transfers. + */ + async listTransfers(userId: string, assetId?: string): Promise { + return this.run(this.identityOf(userId), async (wallet) => { + if (assetId !== undefined) { + return wallet.listTransfers(assetId); + } + const transfers = await wallet.listTransfers(null); + for (const asset of await wallet.listAssets()) { + transfers.push(...(await wallet.listTransfers(asset.assetId))); + } + return transfers; + }); + } + + async receive( + userId: string, + params: ReceiveParams, + now: number = Date.now(), + ): Promise { + const identity = this.identityOf(userId); + const expirationTimestamp = + Math.floor(now / 1000) + (params.durationSeconds ?? DEFAULT_RECEIVE_DURATION_SECONDS); + const data = await this.run(identity, (wallet) => + wallet.receive({ + mode: params.mode, + assetId: params.assetId ?? null, + amount: params.amount ?? null, + expirationTimestamp, + transportEndpoints: [this.config.rgbProxyUrl], + minConfirmations: params.minConfirmations ?? DEFAULT_MIN_CONFIRMATIONS, + }), + ); + // Scoping (I3): the recipient id attributes the future transfer to this + // user in every node-wide listing. + recordOwnership( + this.db, + { kind: 'asset_transfer', resourceId: data.recipientId, userId, state: 'pending' }, + now, + ); + return { ...data, mode: params.mode }; + } + + /** BTC-side sync plus RGB refresh, on the user's wallet. */ + async sync(userId: string): Promise { + await this.run(this.identityOf(userId), async (wallet) => { + await wallet.sync(); + await wallet.refresh(); + }); + } +} diff --git a/minimal-sdk/packages/gateway/src/workers/deposits.ts b/minimal-sdk/packages/gateway/src/workers/deposits.ts new file mode 100644 index 00000000..c2c99ab3 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/workers/deposits.ts @@ -0,0 +1,264 @@ +/** + * Deposits watcher: polls esplora (BTC) and RLN transfers (RGB) for pending + * deposits and credits the float ledger after N confirmations. + * + * Exactly-once under replays and crashes: the credit uses the deposit id as + * the ledger ref, so a poll that crashes between credit and state update (or + * runs twice) can never double-credit — the second creditOnce is a no-op and + * the state update is retried on the next tick. + * + * Cap policy: the cap was enforced against the DECLARED amount at prepare + * time; the confirmed on-chain amount is credited without a cap check — + * refusing it would strand funds the node already holds. Over-funding an + * intent therefore credits more than the declared amount and can exceed the + * float caps (documented in the gateway README's float-cap section). + * + * One-shot semantics: a deposit intent credits exactly once — the first poll + * that sees confirmed value credits the total confirmed at that moment and + * closes the intent. Later payments to the same address are NOT credited + * (documented in the gateway README): prepare a fresh deposit per payment. + * Deposit targets are never reused across intents (prepare rejects a repeat + * target in ANY state), so re-summing an already-credited payment is + * impossible. + * + * Expiry: intents past expires_at flip to 'expired' at the END of each pass — + * after one last-chance poll, so boundary-funded deposits are credited — and + * stop counting against float-cap headroom (the exposure query also filters + * them out by expires_at, so a stopped worker cannot pin headroom either). + */ +import type { GatewayDb } from '../db.js'; +import { Ledger } from '../ledger.js'; +import type { RlnApi } from '../rln/client.js'; +import type { Transfer } from '../rln/types.js'; + +/** Page size for the node-wide /listtransfers walk. */ +const TRANSFERS_PAGE_SIZE = 100; +/** Default deadline for one esplora call. */ +const ESPLORA_TIMEOUT_MS = 15_000; + +interface PendingDepositRow { + id: string; + user_id: string; + kind: 'btc' | 'rgb'; + asset: string; + amount: number; + target: string; + state: string; +} + +/** Esplora /address/:addr/txs item subset the watcher reads. */ +interface EsploraTx { + txid: string; + status: { confirmed: boolean; block_height?: number }; + vout: Array<{ scriptpubkey_address?: string; value: number }>; +} + +export interface DepositsWorkerOptions { + db: GatewayDb; + ledger: Ledger; + rln: RlnApi; + esploraUrl: string; + minConfirmations: number; + /** Per-esplora-call timeout; defaults to ESPLORA_TIMEOUT_MS. */ + esploraTimeoutMs?: number; + fetchImpl?: typeof fetch; + log?: { warn(obj: unknown, msg: string): void }; +} + +export class DepositsWorker { + private readonly db: GatewayDb; + private readonly ledger: Ledger; + private readonly rln: RlnApi; + private readonly esploraUrl: string; + private readonly minConfirmations: number; + private readonly esploraTimeoutMs: number; + private readonly fetchImpl: typeof fetch; + private readonly log: DepositsWorkerOptions['log']; + private timer: NodeJS.Timeout | undefined; + private running = false; + + constructor(options: DepositsWorkerOptions) { + this.db = options.db; + this.ledger = options.ledger; + this.rln = options.rln; + this.esploraUrl = options.esploraUrl.replace(/\/+$/, ''); + this.minConfirmations = options.minConfirmations; + this.esploraTimeoutMs = options.esploraTimeoutMs ?? ESPLORA_TIMEOUT_MS; + this.fetchImpl = options.fetchImpl ?? fetch; + this.log = options.log; + } + + start(intervalMs: number): void { + if (this.timer !== undefined) return; + this.timer = setInterval(() => { + void this.runOnce(); + }, intervalMs); + this.timer.unref(); + } + + stop(): void { + if (this.timer !== undefined) clearInterval(this.timer); + this.timer = undefined; + } + + /** + * One poll pass over all pending deposits. Errors are per-deposit and + * logged; the pass itself is also caught — start() invokes this with + * `void`, so an escaped rejection (db closed during shutdown, transient + * SQLite error) would otherwise crash the whole process. + */ + async runOnce(): Promise { + if (this.running) return; + this.running = true; + try { + const now = Date.now(); + const pending = this.db + .prepare("SELECT * FROM pending_deposits WHERE state = 'pending'") + .all() as PendingDepositRow[]; + let refreshed = false; + for (const deposit of pending) { + try { + if (deposit.kind === 'btc') { + await this.pollBtcDeposit(deposit); + } else { + if (!refreshed) { + await this.rln.refreshTransfers({ filter: [], skip_sync: false }); + refreshed = true; + } + await this.pollRgbDeposit(deposit); + } + } catch (error) { + this.log?.warn({ err: error, depositId: deposit.id }, 'deposit poll failed'); + } + } + // Expire AFTER polling: a past-due intent gets one last-chance poll, so + // a deposit whose funding confirmed near the TTL boundary is credited + // rather than expired. Only still-unfunded intents flip to 'expired'. + this.db + .prepare( + "UPDATE pending_deposits SET state = 'expired' WHERE state = 'pending' AND expires_at <= ?", + ) + .run(now); + } catch (error) { + this.log?.warn({ err: error }, 'deposits pass failed'); + } finally { + this.running = false; + } + } + + /** + * Bounded esplora GET. Without an explicit deadline a stalled indexer holds + * the pass open for undici's 300s default — per pending deposit — and the + * `running` guard turns that into a worker that simply stops making + * progress. The timer stays armed until the BODY is read, like RlnClient. + */ + private async esploraJson(path: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.esploraTimeoutMs); + try { + const response = await this.fetchImpl(`${this.esploraUrl}${path}`, { + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`esplora ${path} failed with status ${response.status}`); + } + return (await response.json()) as T; + } catch (error) { + if (controller.signal.aborted) { + throw new Error(`esplora ${path} timed out after ${this.esploraTimeoutMs}ms`); + } + throw error; + } finally { + clearTimeout(timer); + } + } + + private async pollBtcDeposit(deposit: PendingDepositRow): Promise { + const tipHeight = await this.esploraJson('/blocks/tip/height'); + const txs = await this.esploraJson(`/address/${deposit.target}/txs`); + let totalSat = 0; + let fundingTxid: string | undefined; + for (const tx of txs) { + if (!tx.status.confirmed || tx.status.block_height === undefined) continue; + if (tipHeight - tx.status.block_height + 1 < this.minConfirmations) continue; + const paid = tx.vout + .filter((out) => out.scriptpubkey_address === deposit.target) + .reduce((sum, out) => sum + out.value, 0); + if (paid > 0) { + totalSat += paid; + fundingTxid ??= tx.txid; + } + } + if (totalSat === 0) return; + this.credit(deposit, totalSat * 1000, fundingTxid ?? null); + } + + /** + * RLN's /listtransfers is node-wide and paginated (newest first, 100 per + * page by default), so a single unpaginated call would stop finding this + * deposit's transfer once 100 newer Settled transfers exist for the same + * asset — the deposit would then never be credited. Walk pages + * newest→oldest until the recipient id is found or the list is exhausted. + */ + private async findSettledTransfer(deposit: PendingDepositRow): Promise { + let pageCursor: number | undefined; + for (;;) { + const page = await this.rln.listTransfers({ + asset_filter: { type: 'Id', value: deposit.asset }, + status: 'Settled', + max_transfers: TRANSFERS_PAGE_SIZE, + ...(pageCursor !== undefined ? { index_offset: pageCursor } : {}), + }); + // The status filter is applied node-side too; re-checking here means a + // node that ignores it can never make us credit an unsettled transfer. + const match = page.transfers.find( + (transfer) => transfer.recipient_id === deposit.target && transfer.status === 'Settled', + ); + if (match !== undefined) return match; + // Short page, no cursor to advance to, or a non-progressing upstream + // cursor (would loop forever): the walk is done. + if (page.transfers.length < TRANSFERS_PAGE_SIZE || page.last_index_offset === 0) return; + if (pageCursor !== undefined && page.last_index_offset >= pageCursor) return; + pageCursor = page.last_index_offset; + } + } + + private async pollRgbDeposit(deposit: PendingDepositRow): Promise { + const settled = await this.findSettledTransfer(deposit); + if (settled === undefined) return; + const fungible = settled.assignments.find( + (assignment): assignment is { type: 'Fungible'; value: number } => + assignment.type === 'Fungible', + ); + if (fungible === undefined) { + // No fungible assignment means we cannot tell what was actually + // received. Crediting the amount the USER declared at prepare time + // would mint units on the ledger that the node may not hold (RGB + // credits are exempt from the float caps), so leave the intent pending + // and surface it instead of guessing. + this.log?.warn( + { depositId: deposit.id, recipientId: deposit.target }, + 'settled RGB transfer carries no fungible assignment; deposit not credited', + ); + return; + } + this.credit(deposit, fungible.value, settled.txid ?? null); + } + + private credit(deposit: PendingDepositRow, amount: number, txid: string | null): void { + this.ledger.inTx(() => { + this.ledger.creditOnce({ + userId: deposit.user_id, + asset: deposit.asset, + amount, + kind: 'deposit', + ref: deposit.id, + }); + this.db + .prepare( + "UPDATE pending_deposits SET state = 'credited', credited_amount = ?, txid = ? WHERE id = ? AND state = 'pending'", + ) + .run(amount, txid, deposit.id); + }); + } +} diff --git a/minimal-sdk/packages/gateway/src/workers/reconciler.ts b/minimal-sdk/packages/gateway/src/workers/reconciler.ts new file mode 100644 index 00000000..1ba78da0 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/workers/reconciler.ts @@ -0,0 +1,286 @@ +/** + * Payments reconciler: drives every non-terminal LN state to its outcome. + * + * - Outbound debited_pending/sent payments are checked individually via + * /getpayment: Succeeded settles (debit stands), Failed/Cancelled refunds. + * A payment RLN does not know about is refunded only after a grace period + * (the send may still be queued behind RLN's global lock) and only when it + * never reached the 'sent' state. + * - Inbound settlements come from a cursor-based walk of /listpayments: + * pages are fetched newest→oldest until the persisted cursor is reached, + * settled inbound payments credit their invoice owner exactly once, then + * the cursor advances — but only past regions where every gateway invoice + * is already resolved, because an inbound payment's RLN index is stamped at + * INVOICE CREATION and does not move when it settles. Re-processing a seen + * payment is a no-op (ledger ref uniqueness), so crashing before the cursor + * persists is safe. + * - Pending invoices are checked via /invoicestatus for expiry (and for + * settlement of fixed-amount invoices, same idempotent credit path). + * + * The reconciler NEVER retries a send — it only settles or refunds recorded + * state (Block 3 double-pay caution). + */ +import type { GatewayDb } from '../db.js'; +import { Ledger } from '../ledger.js'; +import { RlnHttpError, type RlnApi } from '../rln/client.js'; +import { + refundOutboundPayment, + settleInboundInvoice, + settleInvoiceWithLiveAmounts, + type LnInvoiceRow, +} from '../routes/ln.js'; +import { updateResourceState } from '../rln/scoping.js'; +import type { Payment } from '../rln/types.js'; + +const CURSOR_KEY = 'ln_reconciler_cursor'; +const PAGE_SIZE = 100; + +export interface ReconcilerOptions { + db: GatewayDb; + ledger: Ledger; + rln: RlnApi; + /** Seconds before an RLN-unknown debited_pending payment is refunded. */ + outboundGraceSeconds: number; + log?: { warn(obj: unknown, msg: string): void }; +} + +interface PendingOutboundRow { + resource_id: string; + user_id: string; + state: string; + created_at: number; +} + +export class Reconciler { + private readonly db: GatewayDb; + private readonly ledger: Ledger; + private readonly rln: RlnApi; + private readonly graceMs: number; + private readonly log: ReconcilerOptions['log']; + private timer: NodeJS.Timeout | undefined; + private running = false; + + constructor(options: ReconcilerOptions) { + this.db = options.db; + this.ledger = options.ledger; + this.rln = options.rln; + this.graceMs = options.outboundGraceSeconds * 1000; + this.log = options.log; + } + + start(intervalMs: number): void { + if (this.timer !== undefined) return; + this.timer = setInterval(() => { + void this.runOnce(); + }, intervalMs); + this.timer.unref(); + } + + stop(): void { + if (this.timer !== undefined) clearInterval(this.timer); + this.timer = undefined; + } + + cursor(): number { + const row = this.db.prepare('SELECT value FROM worker_state WHERE key = ?').get(CURSOR_KEY) as + { value: string } | undefined; + return row === undefined ? 0 : Number(row.value); + } + + private persistCursor(value: number): void { + this.db + .prepare( + 'INSERT INTO worker_state (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value', + ) + .run(CURSOR_KEY, String(value)); + } + + /** One reconciliation pass. Each sub-step fails independently and is logged. */ + async runOnce(): Promise { + if (this.running) return; + this.running = true; + try { + // Every phase catches its own failure: start() invokes this with `void`, + // so an escaped rejection would crash the whole process (unhandled + // rejection) on a transient RLN outage. + for (const phase of [ + () => this.resolveOutbound(), + () => this.settleInboundFromListPayments(), + () => this.expirePendingInvoices(), + ]) { + try { + await phase(); + } catch (error) { + this.log?.warn({ err: error }, 'reconciler phase failed'); + } + } + } finally { + this.running = false; + } + } + + private async resolveOutbound(): Promise { + const pending = this.db + .prepare( + `SELECT resource_id, user_id, state, created_at FROM resource_map + WHERE kind = 'payment_hash' AND state IN ('debited_pending', 'sent')`, + ) + .all() as PendingOutboundRow[]; + for (const row of pending) { + try { + let payment; + try { + payment = ( + await this.rln.getPayment({ + payment_hash: row.resource_id, + payment_type: 'Outbound', + }) + ).payment; + } catch (error) { + if ( + error instanceof RlnHttpError && + // Only an explicit PaymentNotFound proves RLN never learned about + // this payment. Any other HTTP error (LockedNode, ChangingState, + // 500s — RLN maps them all to non-2xx too) may hide a payment that + // is still settling: refunding on those would double-spend the + // float. Retry on the next pass instead. + error.body?.name === 'PaymentNotFound' && + row.state === 'debited_pending' && + Date.now() - row.created_at >= this.graceMs + ) { + // RLN never learned about this payment and it is old enough that a + // queued send would have surfaced by now: the debit is refunded. + this.log?.warn( + { paymentHash: row.resource_id, userId: row.user_id }, + 'refunding debited_pending payment unknown to RLN after grace period', + ); + refundOutboundPayment(this.db, this.ledger, row.user_id, row.resource_id); + continue; + } + throw error; + } + if (payment.status === 'Succeeded') { + updateResourceState(this.db, 'payment_hash', row.resource_id, row.user_id, 'succeeded'); + } else if (payment.status === 'Failed' || payment.status === 'Cancelled') { + refundOutboundPayment(this.db, this.ledger, row.user_id, row.resource_id); + } + // Pending/Claimable/Claiming: leave for the next pass. + } catch (error) { + this.log?.warn({ err: error, paymentHash: row.resource_id }, 'outbound reconcile failed'); + } + } + } + + private async settleInboundFromListPayments(): Promise { + const storedCursor = this.cursor(); + let pageCursor: number | undefined; + let newestIndex = storedCursor; + // Lowest safe watermark for this pass. RLN stamps an inbound payment's + // index when the invoice is CREATED (add_inbound_payment → + // stamp_payment_idx) and never restamps it on settlement, so advancing the + // cursor to the newest index on sight would step over invoices that are + // still pending and never revisit them. /listpayments does not return a + // per-payment index, so a page holding an unresolved invoice pins the + // watermark just below that entire page. + let watermarkFloor: number | undefined; + // Walk newest → oldest until the page dips at or below the stored cursor. + for (;;) { + const page = await this.rln.listPayments({ + ...(pageCursor !== undefined ? { index_offset: pageCursor } : {}), + max_payments: PAGE_SIZE, + }); + if (page.payments.length === 0) break; + if (pageCursor === undefined) { + newestIndex = Math.max(newestIndex, page.first_index_offset); + } + let pageHasUnresolved = false; + for (const payment of page.payments) { + // Per-payment isolation, like the other two phases: one malformed + // payment must not abort the walk, because the cursor is persisted + // only after it completes — an escaping throw would freeze the cursor + // permanently and re-fail on every pass. + try { + if (!this.settleInboundPayment(payment)) pageHasUnresolved = true; + } catch (error) { + // A settlement that threw is not resolved either: hold the watermark + // so the next pass sees this page again. + pageHasUnresolved = true; + this.log?.warn( + { err: error, paymentHash: payment.payment_hash }, + 'inbound settlement failed', + ); + } + } + if (pageHasUnresolved) { + watermarkFloor = Math.max(0, page.last_index_offset - 1); + } + if (page.last_index_offset <= storedCursor + 1 || page.last_index_offset === 0) break; + // Guard against a non-progressing upstream cursor (would loop forever). + if (pageCursor !== undefined && page.last_index_offset >= pageCursor) break; + pageCursor = page.last_index_offset; + } + const target = + watermarkFloor === undefined ? newestIndex : Math.min(newestIndex, watermarkFloor); + if (target > storedCursor) this.persistCursor(target); + } + + /** + * Credit a settled inbound payment to its invoice owner. Returns whether the + * payment is RESOLVED from the gateway's side; `false` means a gateway + * invoice for it is still pending and the cursor must not advance past it. + */ + private settleInboundPayment(payment: Payment): boolean { + if (payment.payment_type === 'Outbound') return true; + const row = this.db + .prepare("SELECT * FROM ln_invoices WHERE payment_hash = ? AND state = 'pending'") + .get(payment.payment_hash) as LnInvoiceRow | undefined; + if (row === undefined) return true; + if (payment.status !== 'Succeeded') return false; + if (row.asset_id !== null && (payment.asset_amount ?? null) === null) { + // RGB credits are not bounded by the float caps, so the units are never + // guessed from the declared row: leave it for settleInvoiceWithLiveAmounts. + return false; + } + const amtMsat = payment.amt_msat ?? row.amt_msat; + if (amtMsat === null || amtMsat === undefined) { + // No later pass can resolve this from /listpayments alone; the invoice + // stays pending and expirePendingInvoices owns it from here. + this.log?.warn({ paymentHash: payment.payment_hash }, 'settled invoice has no amount'); + return true; + } + settleInboundInvoice( + this.db, + this.ledger, + row, + amtMsat, + payment.asset_amount ?? row.asset_amount, + ); + return true; + } + + private async expirePendingInvoices(): Promise { + const pending = this.db + .prepare("SELECT * FROM ln_invoices WHERE state = 'pending'") + .all() as LnInvoiceRow[]; + for (const row of pending) { + try { + const { status } = await this.rln.invoiceStatus({ invoice: row.invoice }); + if (status === 'Expired') { + this.ledger.inTx(() => { + this.db + .prepare("UPDATE ln_invoices SET state = 'expired' WHERE payment_hash = ?") + .run(row.payment_hash); + updateResourceState(this.db, 'invoice', row.payment_hash, row.user_id, 'expired'); + }); + } else if (status === 'Succeeded') { + await settleInvoiceWithLiveAmounts(this.db, this.ledger, this.rln, row, this.log); + } + } catch (error) { + this.log?.warn( + { err: error, paymentHash: row.payment_hash }, + 'invoice status check failed', + ); + } + } + } +} diff --git a/minimal-sdk/packages/gateway/test/auth.test.ts b/minimal-sdk/packages/gateway/test/auth.test.ts new file mode 100644 index 00000000..aae093db --- /dev/null +++ b/minimal-sdk/packages/gateway/test/auth.test.ts @@ -0,0 +1,85 @@ +import { createHash } from 'node:crypto'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { OPERATOR_TOKEN, createTestUser, testServer } from './helpers.js'; + +let app: FastifyInstance; + +afterEach(async () => { + await app.close(); +}); + +describe('user bootstrap (POST /v1/users)', () => { + it('rejects a missing operator token', async () => { + app = await testServer(); + const response = await app.inject({ method: 'POST', url: '/v1/users' }); + expect(response.statusCode).toBe(401); + expect(response.json().error.code).toBe('OPERATOR_UNAUTHORIZED'); + }); + + it('rejects a wrong operator token', async () => { + app = await testServer(); + const response = await app.inject({ + method: 'POST', + url: '/v1/users', + headers: { 'x-operator-token': 'wrong-operator-token-123' }, + }); + expect(response.statusCode).toBe(401); + }); + + it('creates a user and stores only the token hash', async () => { + app = await testServer(); + const user = await createTestUser(app); + expect(user.userId).toMatch(/^u_/); + expect(user.token).toMatch(/^utxg_/); + const row = app.db.prepare('SELECT token_hash FROM users WHERE id = ?').get(user.userId) as { + token_hash: string; + }; + expect(row.token_hash).not.toBe(user.token); + expect(row.token_hash).toBe(createHash('sha256').update(user.token).digest('hex')); + }); +}); + +describe('bearer auth (GET /v1/me)', () => { + it('accepts a valid token and resolves the right user', async () => { + app = await testServer(); + const user = await createTestUser(app); + const response = await app.inject({ + method: 'GET', + url: '/v1/me', + headers: { authorization: `Bearer ${user.token}` }, + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ userId: user.userId, createdAt: user.createdAt }); + }); + + it('rejects missing, malformed and unknown tokens', async () => { + app = await testServer(); + await createTestUser(app); + for (const headers of [ + {}, + { authorization: 'Bearer ' }, + { authorization: 'Basic abc' }, + { authorization: 'Bearer utxg_definitely-not-a-real-token' }, + { authorization: `Bearer ${OPERATOR_TOKEN}` }, + ]) { + const response = await app.inject({ method: 'GET', url: '/v1/me', headers }); + expect(response.statusCode).toBe(401); + expect(response.json().error.code).toBe('UNAUTHORIZED'); + } + }); + + it('never resolves one user from another user token', async () => { + app = await testServer(); + const alice = await createTestUser(app); + const bob = await createTestUser(app); + expect(alice.token).not.toBe(bob.token); + const asBob = await app.inject({ + method: 'GET', + url: '/v1/me', + headers: { authorization: `Bearer ${bob.token}` }, + }); + expect(asBob.json().userId).toBe(bob.userId); + expect(asBob.json().userId).not.toBe(alice.userId); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/config.test.ts b/minimal-sdk/packages/gateway/test/config.test.ts new file mode 100644 index 00000000..91b8d7ac --- /dev/null +++ b/minimal-sdk/packages/gateway/test/config.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { ConfigError, loadConfig } from '../src/config.js'; +import { testEnv } from './helpers.js'; + +describe('loadConfig', () => { + it('loads a full config with defaults applied', () => { + const config = loadConfig(testEnv()); + expect(config.rlnUrl).toBe('http://127.0.0.1:3001'); + expect(config.host).toBe('127.0.0.1'); + expect(config.queueGlobalConcurrency).toBe(4); + expect(config.queuePerUserDepth).toBe(16); + expect(config.floatCapPerUserMsat).toBe(1_000_000_000); + expect(config.floatCapGlobalMsat).toBe(10_000_000_000); + }); + + it('parses numeric overrides', () => { + const config = loadConfig( + testEnv({ GATEWAY_QUEUE_GLOBAL_CONCURRENCY: '2', GATEWAY_FLOAT_CAP_PER_USER_MSAT: '5000' }), + ); + expect(config.queueGlobalConcurrency).toBe(2); + expect(config.floatCapPerUserMsat).toBe(5000); + }); + + it('defaults the RGB transport allowlist to the configured proxy only', () => { + expect(loadConfig(testEnv()).rgbTransportAllowlist).toEqual(['rpc://127.0.0.1:3000/json-rpc']); + }); + + it('parses, trims and dedupes extra RGB transport endpoints', () => { + // Operator-configured endpoints are the SSRF allowlist: a parsing slip + // either locks out a legitimate proxy or widens the allowed set. + const config = loadConfig( + testEnv({ + GATEWAY_RGB_TRANSPORT_ALLOWLIST: + ' rpc://proxy-a/json-rpc , rpc://proxy-b/json-rpc ,, rpc://127.0.0.1:3000/json-rpc ', + }), + ); + expect(config.rgbTransportAllowlist).toEqual([ + 'rpc://127.0.0.1:3000/json-rpc', + 'rpc://proxy-a/json-rpc', + 'rpc://proxy-b/json-rpc', + ]); + }); + + it('rejects a missing RLN URL', () => { + const env = testEnv(); + delete (env as Record)['RLN_URL']; + expect(() => loadConfig(env)).toThrow(ConfigError); + }); + + it('rejects an invalid URL', () => { + expect(() => loadConfig(testEnv({ ESPLORA_URL: 'not a url' }))).toThrow(ConfigError); + }); + + it('rejects a short operator token', () => { + expect(() => loadConfig(testEnv({ GATEWAY_OPERATOR_TOKEN: 'short' }))).toThrow(ConfigError); + }); + + it('rejects non-positive or non-integer numeric variables', () => { + expect(() => loadConfig(testEnv({ GATEWAY_QUEUE_PER_USER_DEPTH: '0' }))).toThrow(ConfigError); + expect(() => loadConfig(testEnv({ GATEWAY_PORT: 'eighty' }))).toThrow(ConfigError); + }); + + it('loads wallet settings with defaults', () => { + const config = loadConfig(testEnv()); + expect(config.walletIndexerUrl).toBe('127.0.0.1:50001'); + expect(config.bitcoinNetwork).toBe('Regtest'); + expect(config.walletMaxOpen).toBe(32); + expect(config.onchainOpTtlSeconds).toBe(600); + }); + + it('parses the on-chain op TTL override', () => { + expect(loadConfig(testEnv({ GATEWAY_ONCHAIN_OP_TTL_SECONDS: '60' })).onchainOpTtlSeconds).toBe( + 60, + ); + expect(() => loadConfig(testEnv({ GATEWAY_ONCHAIN_OP_TTL_SECONDS: '0' }))).toThrow(ConfigError); + }); + + it('requires the wallets directory and indexer URL', () => { + for (const name of ['GATEWAY_WALLETS_DIR', 'GATEWAY_WALLET_INDEXER_URL']) { + const env = testEnv(); + delete (env as Record)[name]; + expect(() => loadConfig(env)).toThrow(ConfigError); + } + }); + + it('validates the bitcoin network name', () => { + expect(loadConfig(testEnv({ GATEWAY_BITCOIN_NETWORK: 'signet' })).bitcoinNetwork).toBe( + 'Signet', + ); + expect(() => loadConfig(testEnv({ GATEWAY_BITCOIN_NETWORK: 'litecoin' }))).toThrow(ConfigError); + }); + + it('allows an empty RLN admin token (localhost --disable-authentication mode)', () => { + const env = testEnv(); + delete (env as Record)['RLN_ADMIN_TOKEN']; + expect(loadConfig(env).rlnAdminToken).toBe(''); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/db.test.ts b/minimal-sdk/packages/gateway/test/db.test.ts new file mode 100644 index 00000000..53edfbbc --- /dev/null +++ b/minimal-sdk/packages/gateway/test/db.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; +import { migrate, openDb, schemaVersion } from '../src/db.js'; + +/** Bump alongside a new MIGRATIONS entry in src/db.ts. */ +const LATEST_SCHEMA_VERSION = 2; + +describe('db migrations', () => { + it('upgrades a v1 database in place, backfilling ln_invoices.expires_at', () => { + const db = openDb(':memory:'); + // Simulate a database created before migration v2. + db.exec('ALTER TABLE ln_invoices DROP COLUMN expires_at'); + db.pragma('user_version = 1'); + db.prepare('INSERT INTO users (id, token_hash, created_at) VALUES (?, ?, ?)').run( + 'u_1', + 'hash', + 1, + ); + db.prepare( + `INSERT INTO ln_invoices (payment_hash, user_id, invoice, amt_msat, asset_id, asset_amount, state, created_at) + VALUES ('h1', 'u_1', 'lnbc1', 1000, NULL, NULL, 'pending', 4242)`, + ).run(); + + migrate(db); + + expect(schemaVersion(db)).toBe(LATEST_SCHEMA_VERSION); + // Legacy rows inherit created_at, i.e. they are already expired and stop + // pinning float-cap headroom rather than pinning it forever. + const row = db.prepare('SELECT expires_at FROM ln_invoices WHERE payment_hash = ?').get('h1'); + expect((row as { expires_at: number }).expires_at).toBe(4242); + db.close(); + }); + + it('creates the full schema on open', () => { + const db = openDb(':memory:'); + expect(schemaVersion(db)).toBe(LATEST_SCHEMA_VERSION); + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + .all() + .map((row) => (row as { name: string }).name); + for (const table of [ + 'users', + 'user_xpubs', + 'resource_map', + 'idempotency', + 'ledger', + 'pending_ops', + 'pending_deposits', + 'ln_invoices', + 'withdrawals', + 'worker_state', + ]) { + expect(tables).toContain(table); + } + const depositColumns = db + .prepare('PRAGMA table_info(pending_deposits)') + .all() + .map((row) => (row as { name: string }).name); + expect(depositColumns).toContain('expires_at'); + const invoiceColumns = db + .prepare('PRAGMA table_info(ln_invoices)') + .all() + .map((row) => (row as { name: string }).name); + expect(invoiceColumns).toContain('expires_at'); + db.close(); + }); + + it('is idempotent when re-run', () => { + const db = openDb(':memory:'); + migrate(db); + migrate(db); + expect(schemaVersion(db)).toBe(LATEST_SCHEMA_VERSION); + db.close(); + }); + + it('enforces the ledger exactly-once index on refs', () => { + const db = openDb(':memory:'); + db.prepare('INSERT INTO users (id, token_hash, created_at) VALUES (?, ?, ?)').run( + 'u_1', + 'hash', + 1, + ); + const insert = db.prepare( + `INSERT INTO ledger (user_id, asset, delta_msat_or_units, kind, ref, created_at) + VALUES ('u_1', 'btc', 5, 'deposit', ?, 1)`, + ); + expect(() => insert.run('ref-1')).not.toThrow(); + expect(() => insert.run('ref-1')).toThrow(/UNIQUE/); + // NULL refs are exempt from deduplication. + expect(() => insert.run(null)).not.toThrow(); + expect(() => insert.run(null)).not.toThrow(); + db.close(); + }); + + it('rejects unknown pending-op kinds and states via CHECK constraints', () => { + const db = openDb(':memory:'); + db.prepare('INSERT INTO users (id, token_hash, created_at) VALUES (?, ?, ?)').run( + 'u_1', + 'hash', + 1, + ); + const insert = db.prepare( + `INSERT INTO pending_ops (id, user_id, kind, psbt, intent, state, txid, created_at, expires_at) + VALUES (?, 'u_1', ?, 'p', '{}', ?, NULL, 1, 2)`, + ); + expect(() => insert.run('op1', 'send_btc', 'pending')).not.toThrow(); + expect(() => insert.run('op2', 'bogus', 'pending')).toThrow(/CHECK/); + expect(() => insert.run('op3', 'send_asset', 'bogus')).toThrow(/CHECK/); + db.close(); + }); + + it('rejects unknown resource kinds via CHECK constraint', () => { + const db = openDb(':memory:'); + db.prepare('INSERT INTO users (id, token_hash, created_at) VALUES (?, ?, ?)').run( + 'u_1', + 'hash', + 1, + ); + const insert = db.prepare( + 'INSERT INTO resource_map (kind, resource_id, user_id, state, created_at) VALUES (?, ?, ?, ?, ?)', + ); + expect(() => insert.run('invoice', 'r1', 'u_1', 'pending', 1)).not.toThrow(); + expect(() => insert.run('bogus', 'r2', 'u_1', 'pending', 1)).toThrow(/CHECK/); + db.close(); + }); + + it('enforces one idempotency row per (user, key)', () => { + const db = openDb(':memory:'); + db.prepare('INSERT INTO users (id, token_hash, created_at) VALUES (?, ?, ?)').run( + 'u_1', + 'hash', + 1, + ); + const insert = db.prepare( + 'INSERT INTO idempotency (user_id, key, request_hash, response, created_at) VALUES (?, ?, ?, NULL, ?)', + ); + insert.run('u_1', 'k1', 'h1', 1); + expect(() => insert.run('u_1', 'k1', 'h2', 2)).toThrow(/UNIQUE|PRIMARY/); + db.close(); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/deposits-worker.test.ts b/minimal-sdk/packages/gateway/test/deposits-worker.test.ts new file mode 100644 index 00000000..a6667af2 --- /dev/null +++ b/minimal-sdk/packages/gateway/test/deposits-worker.test.ts @@ -0,0 +1,365 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { openDb, type GatewayDb } from '../src/db.js'; +import { BTC_ASSET, Ledger } from '../src/ledger.js'; +import { DepositsWorker } from '../src/workers/deposits.js'; +import { fakeRln, type RlnOverrides } from './ln-mocks.js'; + +const USER = 'user-a'; +const ADDRESS = 'bcrt1qdeposittarget'; +const RECIPIENT = 'bcrt:utxob:testRecipient'; +const ASSET = 'rgb:testAsset-000'; + +interface EsploraFixture { + tipHeight: number; + txs: unknown[]; +} + +function esploraFetch(fixture: EsploraFixture): typeof fetch { + return (async (input: string | URL | Request) => { + const url = String(input); + const body = url.endsWith('/blocks/tip/height') + ? JSON.stringify(fixture.tipHeight) + : JSON.stringify(fixture.txs); + return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }); + }) as typeof fetch; +} + +function confirmedTx(txid: string, blockHeight: number, address: string, valueSat: number) { + return { + txid, + status: { confirmed: true, block_height: blockHeight }, + vout: [ + { scriptpubkey_address: address, value: valueSat }, + { scriptpubkey_address: 'bcrt1qsomeoneelse', value: 999 }, + ], + }; +} + +describe('DepositsWorker', () => { + let db: GatewayDb; + let ledger: Ledger; + + beforeEach(() => { + db = openDb(':memory:'); + db.prepare('INSERT INTO users (id, token_hash, created_at) VALUES (?, ?, ?)').run( + USER, + 'hash-a', + Date.now(), + ); + ledger = new Ledger(db); + }); + + afterEach(() => { + db.close(); + }); + + function insertDeposit( + kind: 'btc' | 'rgb', + asset: string, + amount: number, + target: string, + expiresAt: number = Date.now() + 86_400_000, + ): string { + const id = `dep-${kind}-${target}`; + db.prepare( + `INSERT INTO pending_deposits (id, user_id, kind, asset, amount, target, invoice, state, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?)`, + ).run(id, USER, kind, asset, amount, target, Date.now(), expiresAt); + return id; + } + + function worker(fixture: EsploraFixture, overrides: RlnOverrides = {}, minConf = 1) { + return new DepositsWorker({ + db, + ledger, + rln: fakeRln(overrides), + esploraUrl: 'http://esplora.test', + minConfirmations: minConf, + fetchImpl: esploraFetch(fixture), + }); + } + + function depositState(id: string): { state: string; credited_amount: number | null } { + return db + .prepare('SELECT state, credited_amount FROM pending_deposits WHERE id = ?') + .get(id) as { state: string; credited_amount: number | null }; + } + + it('credits a confirmed btc deposit exactly once across replayed runs', async () => { + const id = insertDeposit('btc', BTC_ASSET, 500_000, ADDRESS); + const w = worker({ tipHeight: 105, txs: [confirmedTx('tx1', 103, ADDRESS, 700)] }); + await w.runOnce(); + expect(ledger.balance(USER, BTC_ASSET)).toBe(700_000); + expect(depositState(id)).toEqual({ state: 'credited', credited_amount: 700_000 }); + + // Replays (same watcher and a fresh one) never double-credit. + await w.runOnce(); + const fresh = worker({ tipHeight: 110, txs: [confirmedTx('tx1', 103, ADDRESS, 700)] }); + await fresh.runOnce(); + expect(ledger.balance(USER, BTC_ASSET)).toBe(700_000); + }); + + it('ignores unconfirmed or under-confirmed transactions', async () => { + const id = insertDeposit('btc', BTC_ASSET, 500_000, ADDRESS); + const unconfirmed = { + txid: 'tx-mempool', + status: { confirmed: false }, + vout: [{ scriptpubkey_address: ADDRESS, value: 700 }], + }; + const w = worker({ tipHeight: 105, txs: [unconfirmed] }, {}, 1); + await w.runOnce(); + expect(ledger.balance(USER, BTC_ASSET)).toBe(0); + expect(depositState(id).state).toBe('pending'); + + // Confirmed but below the required depth. + const shallow = worker({ tipHeight: 105, txs: [confirmedTx('tx1', 104, ADDRESS, 700)] }, {}, 3); + await shallow.runOnce(); + expect(ledger.balance(USER, BTC_ASSET)).toBe(0); + }); + + it('credits a settled rgb deposit exactly once across replayed runs', async () => { + const id = insertDeposit('rgb', ASSET, 42, RECIPIENT); + const overrides: RlnOverrides = { + refreshTransfers: () => ({ transfers: [] }), + listTransfers: () => ({ + transfers: [ + { + idx: 1, + created_at: 0, + updated_at: 0, + status: 'Settled', + requested_assignment: null, + assignments: [{ type: 'Fungible', value: 42 }], + kind: 'ReceiveBlind', + txid: 'rgb-txid', + recipient_id: RECIPIENT, + transport_endpoints: [], + }, + ], + first_index_offset: 1, + last_index_offset: 1, + }), + }; + const w = worker({ tipHeight: 0, txs: [] }, overrides); + await w.runOnce(); + expect(ledger.balance(USER, ASSET)).toBe(42); + expect(depositState(id)).toEqual({ state: 'credited', credited_amount: 42 }); + + await w.runOnce(); + expect(ledger.balance(USER, ASSET)).toBe(42); + }); + + it('expires a stale unfunded deposit', async () => { + const id = insertDeposit('btc', BTC_ASSET, 500_000, ADDRESS, Date.now() - 1); + const w = worker({ tipHeight: 105, txs: [] }); + await w.runOnce(); + expect(depositState(id).state).toBe('expired'); + expect(ledger.balance(USER, BTC_ASSET)).toBe(0); + }); + + it('credits a funded past-due deposit on the last-chance poll instead of expiring it', async () => { + // Funding confirmed right at the TTL boundary: the pass polls BEFORE the + // expiry sweep, so funds the node already holds are credited, not lost. + const id = insertDeposit('btc', BTC_ASSET, 500_000, ADDRESS, Date.now() - 1); + const w = worker({ tipHeight: 105, txs: [confirmedTx('tx1', 103, ADDRESS, 500)] }); + await w.runOnce(); + expect(depositState(id)).toEqual({ state: 'credited', credited_amount: 500_000 }); + expect(ledger.balance(USER, BTC_ASSET)).toBe(500_000); + }); + + it('credits an underpayment at its confirmed amount and closes the intent (one-shot)', async () => { + const id = insertDeposit('btc', BTC_ASSET, 500_000, ADDRESS); + const w = worker({ tipHeight: 105, txs: [confirmedTx('tx1', 103, ADDRESS, 300)] }); + await w.runOnce(); + // Documented one-shot semantics: the first confirmed poll credits what + // arrived (even below the declared amount) and closes the intent — a + // second payment needs a fresh deposit prepare. + expect(depositState(id)).toEqual({ state: 'credited', credited_amount: 300_000 }); + expect(ledger.balance(USER, BTC_ASSET)).toBe(300_000); + }); + + it('keeps a deposit pending and logs when esplora answers non-OK', async () => { + const id = insertDeposit('btc', BTC_ASSET, 500_000, ADDRESS); + const warnings: unknown[] = []; + const w = new DepositsWorker({ + db, + ledger, + rln: fakeRln({}), + esploraUrl: 'http://esplora.test', + minConfirmations: 1, + fetchImpl: (async () => new Response('boom', { status: 500 })) as typeof fetch, + log: { warn: (obj) => warnings.push(obj) }, + }); + await w.runOnce(); + expect(depositState(id).state).toBe('pending'); + expect(warnings).toHaveLength(1); + }); + + it('skips an overlapping pass while one is already running', async () => { + insertDeposit('btc', BTC_ASSET, 500_000, ADDRESS); + let resolveFetch: (() => void) | undefined; + let fetches = 0; + const w = new DepositsWorker({ + db, + ledger, + rln: fakeRln({}), + esploraUrl: 'http://esplora.test', + minConfirmations: 1, + fetchImpl: (async (input: string | URL | Request) => { + fetches += 1; + // Only the first fetch parks; later ones answer so the pass finishes. + if (fetches === 1) { + await new Promise((resolve) => { + resolveFetch = resolve; + }); + } + const body = String(input).endsWith('/blocks/tip/height') ? '0' : '[]'; + return new Response(body, { status: 200 }); + }) as typeof fetch, + }); + const first = w.runOnce(); + // Wait until the first pass is parked inside its fetch. + while (resolveFetch === undefined) await new Promise((r) => setTimeout(r, 1)); + await w.runOnce(); // must return immediately without a second fetch + expect(fetches).toBe(1); + resolveFetch(); + await first; + }); + + it('sums every confirmed payment to the address and records the first txid', async () => { + const id = insertDeposit('btc', BTC_ASSET, 500_000, ADDRESS); + const w = worker({ + tipHeight: 105, + txs: [confirmedTx('tx-newer', 104, ADDRESS, 200), confirmedTx('tx-older', 101, ADDRESS, 500)], + }); + await w.runOnce(); + expect(depositState(id)).toEqual({ state: 'credited', credited_amount: 700_000 }); + const txid = db.prepare('SELECT txid FROM pending_deposits WHERE id = ?').get(id); + expect((txid as { txid: string }).txid).toBe('tx-newer'); + }); + + it('pages past newer settled transfers of the same asset to find the deposit', async () => { + // RLN's /listtransfers is node-wide and capped at 100 per page: without + // pagination this deposit is invisible once 100 newer settled transfers + // exist for the asset, and would never be credited. + const id = insertDeposit('rgb', ASSET, 42, RECIPIENT); + const filler = (idx: number) => ({ + idx, + created_at: 0, + updated_at: 0, + status: 'Settled', + requested_assignment: null, + assignments: [{ type: 'Fungible', value: 1 }], + kind: 'ReceiveBlind', + txid: `filler-${idx}`, + recipient_id: `other-${idx}`, + transport_endpoints: [], + }); + const seenOffsets: (number | undefined)[] = []; + const w = worker( + { tipHeight: 0, txs: [] }, + { + refreshTransfers: () => ({ transfers: [] }), + listTransfers: (request: { index_offset?: number; status?: string }) => { + seenOffsets.push(request.index_offset); + expect(request.status).toBe('Settled'); + if (request.index_offset === undefined) { + return { + transfers: Array.from({ length: 100 }, (_, i) => filler(200 - i)), + first_index_offset: 200, + last_index_offset: 101, + }; + } + return { + transfers: [ + { + ...filler(1), + recipient_id: RECIPIENT, + txid: 'rgb-txid', + assignments: [{ type: 'Fungible', value: 42 }], + }, + ], + first_index_offset: 1, + last_index_offset: 1, + }; + }, + }, + ); + await w.runOnce(); + expect(seenOffsets).toEqual([undefined, 101]); + expect(ledger.balance(USER, ASSET)).toBe(42); + expect(depositState(id)).toEqual({ state: 'credited', credited_amount: 42 }); + }); + + it('does not credit a settled transfer that carries no fungible assignment', async () => { + // Crediting the user-declared amount here would mint units the node may + // not hold: RGB credits are exempt from the float caps, so the only safe + // answer is to leave the intent pending and surface it. + const id = insertDeposit('rgb', ASSET, 42, RECIPIENT); + const warnings: unknown[] = []; + const w = new DepositsWorker({ + db, + ledger, + rln: fakeRln({ + refreshTransfers: () => ({ transfers: [] }), + listTransfers: () => ({ + transfers: [ + { + idx: 1, + created_at: 0, + updated_at: 0, + status: 'Settled', + requested_assignment: null, + assignments: [], + kind: 'ReceiveBlind', + txid: 'rgb-txid', + recipient_id: RECIPIENT, + transport_endpoints: [], + }, + ], + first_index_offset: 1, + last_index_offset: 1, + }), + }), + esploraUrl: 'http://esplora.test', + minConfirmations: 1, + fetchImpl: esploraFetch({ tipHeight: 0, txs: [] }), + log: { warn: (obj) => warnings.push(obj) }, + }); + await w.runOnce(); + expect(ledger.balance(USER, ASSET)).toBe(0); + expect(depositState(id).state).toBe('pending'); + expect(warnings).toHaveLength(1); + }); + + it('leaves an rgb deposit pending until its transfer settles', async () => { + const id = insertDeposit('rgb', ASSET, 42, RECIPIENT); + const w = worker( + { tipHeight: 0, txs: [] }, + { + refreshTransfers: () => ({ transfers: [] }), + listTransfers: () => ({ + transfers: [ + { + idx: 1, + created_at: 0, + updated_at: 0, + status: 'WaitingConfirmations', + requested_assignment: null, + assignments: [], + kind: 'ReceiveBlind', + txid: null, + recipient_id: RECIPIENT, + transport_endpoints: [], + }, + ], + first_index_offset: 1, + last_index_offset: 1, + }), + }, + ); + await w.runOnce(); + expect(ledger.balance(USER, ASSET)).toBe(0); + expect(depositState(id).state).toBe('pending'); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/fixture-signer.ts b/minimal-sdk/packages/gateway/test/fixture-signer.ts new file mode 100644 index 00000000..120d480c --- /dev/null +++ b/minimal-sdk/packages/gateway/test/fixture-signer.ts @@ -0,0 +1,37 @@ +/** + * Test-only client-side signer: derives keys from a fixture mnemonic and + * signs a prepared PSBT purely from its own key-origin metadata — the same + * loop the Task-6 client SDK implements. Mnemonics are never a gateway input. + */ +import { HDKey } from '@scure/bip32'; +import { mnemonicToSeedSync } from '@scure/bip39'; +import { base64 } from '@scure/base'; +import { Transaction } from '@scure/btc-signer'; + +export const PSBT_PARSE_OPTIONS = { + allowUnknownInputs: true, + allowUnknownOutputs: true, +} as const; + +export function masterFromMnemonic(mnemonic: string): HDKey { + return HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)); +} + +/** + * BIP-341 key-path sign + finalize every input whose key origin matches the + * master fingerprint; paths (vanilla or colored keychain) come from the PSBT. + */ +export function signPsbtWithMaster(master: HDKey, psbtBase64: string): string { + const tx = Transaction.fromPSBT(base64.decode(psbtBase64), PSBT_PARSE_OPTIONS); + for (let index = 0; index < tx.inputsLength; index += 1) { + const input = tx.getInput(index); + for (const [, derivation] of input.tapBip32Derivation ?? []) { + if (derivation.der.fingerprint !== master.fingerprint) continue; + let key = master; + for (const step of derivation.der.path) key = key.deriveChild(step); + tx.signIdx(key.privateKey!, index); + } + } + tx.finalize(); + return base64.encode(tx.toPSBT()); +} diff --git a/minimal-sdk/packages/gateway/test/helpers.ts b/minimal-sdk/packages/gateway/test/helpers.ts new file mode 100644 index 00000000..06d397f1 --- /dev/null +++ b/minimal-sdk/packages/gateway/test/helpers.ts @@ -0,0 +1,85 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { FastifyInstance } from 'fastify'; +import { loadConfig, type GatewayConfig } from '../src/config.js'; +import { buildServer } from '../src/server.js'; +import type { WalletBackend } from '../src/wallets/backend.js'; +import type { RlnApi } from '../src/rln/client.js'; + +export const OPERATOR_TOKEN = 'test-operator-token-0123456789'; +export const RLN_ADMIN_TOKEN = 'test-rln-admin-token-abcdef'; + +export function testEnv(overrides: Record = {}): Record { + return { + RLN_URL: 'http://127.0.0.1:3001', + RLN_ADMIN_TOKEN, + ESPLORA_URL: 'http://127.0.0.1:3002', + RGB_PROXY_URL: 'rpc://127.0.0.1:3000/json-rpc', + GATEWAY_SQLITE_PATH: ':memory:', + GATEWAY_WALLETS_DIR: join(tmpdir(), 'utexo-gateway-test-wallets'), + GATEWAY_WALLET_INDEXER_URL: '127.0.0.1:50001', + GATEWAY_OPERATOR_TOKEN: OPERATOR_TOKEN, + ...overrides, + }; +} + +export function testConfig(overrides: Partial = {}): GatewayConfig { + return { ...loadConfig(testEnv()), ...overrides }; +} + +export interface TestServerOptions { + configOverrides?: Partial; + loggerStream?: { write(msg: string): void }; + walletBackend?: WalletBackend; + rlnClient?: RlnApi; + esploraFetch?: typeof fetch; +} + +export async function testServer(options: TestServerOptions = {}): Promise { + const config = testConfig(options.configOverrides ?? {}); + return buildServer({ + config, + ...(options.loggerStream !== undefined ? { loggerStream: options.loggerStream } : {}), + ...(options.walletBackend !== undefined ? { walletBackend: options.walletBackend } : {}), + ...(options.rlnClient !== undefined ? { rlnClient: options.rlnClient } : {}), + ...(options.esploraFetch !== undefined ? { esploraFetch: options.esploraFetch } : {}), + }); +} + +export interface TestUser { + userId: string; + token: string; + createdAt: number; +} + +export async function createTestUser(app: FastifyInstance): Promise { + const response = await app.inject({ + method: 'POST', + url: '/v1/users', + headers: { 'x-operator-token': OPERATOR_TOKEN }, + }); + if (response.statusCode !== 201) { + throw new Error(`user bootstrap failed: ${response.statusCode} ${response.body}`); + } + return response.json() as TestUser; +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +export function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} diff --git a/minimal-sdk/packages/gateway/test/i1-schema-scan.test.ts b/minimal-sdk/packages/gateway/test/i1-schema-scan.test.ts new file mode 100644 index 00000000..df743378 --- /dev/null +++ b/minimal-sdk/packages/gateway/test/i1-schema-scan.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { findKeyMaterialFields, findKeyMaterialInValue } from '../src/schemas/scan.js'; +import { testServer } from './helpers.js'; + +let app: FastifyInstance | undefined; + +// Only the first test builds a server; the pure-function tests below must not +// trip over an unassigned (or already-closed) instance. +afterEach(async () => { + await app?.close(); + app = undefined; +}); + +describe('invariant I1: no key material in the gateway API surface', () => { + it('no registered route schema declares a key-material field', async () => { + app = await testServer(); + await app.ready(); + expect(app.routeSchemas.length).toBeGreaterThan(0); + for (const entry of app.routeSchemas) { + const violations = findKeyMaterialFields(entry.schema); + expect.soft(violations, `route ${JSON.stringify(entry.method)} ${entry.url}`).toEqual([]); + } + }); + + it('the scanner itself catches key-material fields (self-check)', () => { + const poisoned = { + body: { + type: 'object', + properties: { + mnemonic: { type: 'string' }, + nested: { type: 'object', properties: { privateKey: { type: 'string' } } }, + }, + required: ['mnemonic'], + }, + response: { + 200: { + type: 'object', + properties: { signing_key: { type: 'string' } }, + }, + }, + }; + const violations = findKeyMaterialFields(poisoned); + expect(violations).toContain('$.body.properties.mnemonic'); + expect(violations).toContain('$.body.properties.nested.properties.privateKey'); + expect(violations).toContain('$.body.required[mnemonic]'); + expect(violations).toContain('$.response.200.properties.signing_key'); + }); + + it('the runtime value scanner catches key material in live payloads', () => { + expect(findKeyMaterialInValue({ ok: true, data: [{ seed: 'abc' }] })).toEqual([ + '$.data[0].seed', + ]); + expect(findKeyMaterialInValue({ userId: 'u_1', xpub: 'tpub...', amountMsat: 5 })).toEqual([]); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/idempotency.test.ts b/minimal-sdk/packages/gateway/test/idempotency.test.ts new file mode 100644 index 00000000..51c22841 --- /dev/null +++ b/minimal-sdk/packages/gateway/test/idempotency.test.ts @@ -0,0 +1,343 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import type { GatewayConfig } from '../src/config.js'; +import { IN_FLIGHT_RECLAIM_MS, RETENTION_MS, requestHash } from '../src/idempotency.js'; +import { createTestUser, deferred, sleep, testServer, type TestUser } from './helpers.js'; + +let app: FastifyInstance; + +afterEach(async () => { + await app.close(); +}); + +interface PayFixture { + app: FastifyInstance; + user: TestUser; + calls: () => number; + gate: () => Promise | undefined; + setGate: (p: Promise) => void; + failNext: () => void; +} + +/** + * Registers a fake money-moving route wired exactly like the real ones will + * be: auth preHandler → idempotency preHandler → per-user FIFO enqueue, + * with the idempotency onSend hook capturing the response. + */ +async function payFixture(configOverrides: Partial = {}): Promise { + const server = await testServer({ configOverrides }); + let calls = 0; + let gate: Promise | undefined; + let shouldFail = false; + server.post( + '/v1/test/pay', + { + schema: { + body: { + type: 'object', + properties: { amountMsat: { type: 'integer' }, memo: { type: 'string' } }, + required: ['amountMsat'], + additionalProperties: false, + }, + response: { + 200: { + type: 'object', + properties: { paid: { type: 'boolean' }, execution: { type: 'integer' } }, + required: ['paid', 'execution'], + additionalProperties: false, + }, + }, + }, + preHandler: [server.authenticate, server.idempotency.preHandler], + onSend: server.idempotency.onSend, + }, + async (request) => + server.queues.enqueue(request.userId as string, async () => { + if (shouldFail) { + shouldFail = false; + throw new Error('downstream exploded'); + } + calls += 1; + if (gate !== undefined) await gate; + return { paid: true, execution: calls }; + }), + ); + await server.ready(); + const user = await createTestUser(server); + return { + app: server, + user, + calls: () => calls, + gate: () => gate, + setGate: (p) => (gate = p), + failNext: () => (shouldFail = true), + }; +} + +function pay( + fixture: PayFixture, + options: { key?: string; amountMsat?: number; token?: string } = {}, +) { + const headers: Record = { + authorization: `Bearer ${options.token ?? fixture.user.token}`, + }; + if (options.key !== undefined) headers['idempotency-key'] = options.key; + return fixture.app.inject({ + method: 'POST', + url: '/v1/test/pay', + headers, + payload: { amountMsat: options.amountMsat ?? 1000 }, + }); +} + +describe('idempotency middleware', () => { + it('requires the Idempotency-Key header', async () => { + const fixture = await payFixture(); + app = fixture.app; + const response = await pay(fixture); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('IDEMPOTENCY_KEY_REQUIRED'); + expect(fixture.calls()).toBe(0); + }); + + it('replays the cached response for the same key and request', async () => { + const fixture = await payFixture(); + app = fixture.app; + const first = await pay(fixture, { key: 'k1' }); + expect(first.statusCode).toBe(200); + expect(first.json()).toEqual({ paid: true, execution: 1 }); + const replay = await pay(fixture, { key: 'k1' }); + expect(replay.statusCode).toBe(200); + expect(replay.json()).toEqual({ paid: true, execution: 1 }); + expect(replay.headers['x-idempotent-replay']).toBe('true'); + expect(fixture.calls()).toBe(1); + }); + + it('returns 409 for the same key with a different request', async () => { + const fixture = await payFixture(); + app = fixture.app; + await pay(fixture, { key: 'k1', amountMsat: 1000 }); + const conflict = await pay(fixture, { key: 'k1', amountMsat: 2000 }); + expect(conflict.statusCode).toBe(409); + expect(conflict.json().error.code).toBe('IDEMPOTENCY_KEY_REUSED'); + expect(fixture.calls()).toBe(1); + }); + + it('scopes keys per user: two users may use the same key', async () => { + const fixture = await payFixture(); + app = fixture.app; + const other = await createTestUser(fixture.app); + const first = await pay(fixture, { key: 'shared' }); + const second = await pay(fixture, { key: 'shared', token: other.token }); + expect(first.statusCode).toBe(200); + expect(second.statusCode).toBe(200); + expect(fixture.calls()).toBe(2); + }); + + it('rejects a concurrent duplicate while the first request is in flight', async () => { + const fixture = await payFixture(); + app = fixture.app; + const barrier = deferred(); + fixture.setGate(barrier.promise); + const firstPromise = pay(fixture, { key: 'k1' }); + // Wait until the first request has claimed the key and entered the handler. + while (fixture.calls() === 0) await sleep(2); + const concurrent = await pay(fixture, { key: 'k1' }); + expect(concurrent.statusCode).toBe(409); + expect(concurrent.json().error.code).toBe('IDEMPOTENCY_IN_FLIGHT'); + barrier.resolve(); + const first = await firstPromise; + expect(first.statusCode).toBe(200); + expect(fixture.calls()).toBe(1); + }); + + it('does not cache 5xx responses: a retry re-executes', async () => { + const fixture = await payFixture(); + app = fixture.app; + fixture.failNext(); + const failed = await pay(fixture, { key: 'k1' }); + expect(failed.statusCode).toBe(500); + expect(failed.json().error.code).toBe('INTERNAL'); + const retried = await pay(fixture, { key: 'k1' }); + expect(retried.statusCode).toBe(200); + expect(retried.json()).toEqual({ paid: true, execution: 1 }); + }); + + it('returns 429 with Retry-After when the per-user queue is full (and does not burn the key)', async () => { + const fixture = await payFixture({ queuePerUserDepth: 1, queueGlobalConcurrency: 1 }); + app = fixture.app; + const barrier = deferred(); + fixture.setGate(barrier.promise); + const firstPromise = pay(fixture, { key: 'k1' }); + while (fixture.calls() === 0) await sleep(2); + const overflow = await pay(fixture, { key: 'k2' }); + expect(overflow.statusCode).toBe(429); + expect(overflow.headers['retry-after']).toBeDefined(); + expect(overflow.json().error.code).toBe('QUEUE_FULL'); + barrier.resolve(); + await firstPromise; + // The rejected key was released, so a later retry succeeds. + const retried = await pay(fixture, { key: 'k2' }); + expect(retried.statusCode).toBe(200); + }); + + it('rejects a key longer than 128 characters', async () => { + const fixture = await payFixture(); + app = fixture.app; + const response = await pay(fixture, { key: 'k'.repeat(129) }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('IDEMPOTENCY_KEY_REQUIRED'); + expect(fixture.calls()).toBe(0); + }); + + it('replays when the same body arrives with reordered JSON keys', async () => { + const fixture = await payFixture(); + app = fixture.app; + const inject = (payload: string) => + fixture.app.inject({ + method: 'POST', + url: '/v1/test/pay', + headers: { + authorization: `Bearer ${fixture.user.token}`, + 'idempotency-key': 'k1', + 'content-type': 'application/json', + }, + payload, + }); + const first = await inject('{"amountMsat":1000,"memo":"m"}'); + expect(first.statusCode).toBe(200); + // Same request, different key order: stableStringify canonicalizes, so + // this is a replay — not an IDEMPOTENCY_KEY_REUSED conflict. + const replay = await inject('{"memo":"m","amountMsat":1000}'); + expect(replay.statusCode).toBe(200); + expect(replay.headers['x-idempotent-replay']).toBe('true'); + expect(replay.json()).toEqual(first.json()); + expect(fixture.calls()).toBe(1); + }); + + it('reclaims a stale in-flight claim but keeps a fresh one locked', async () => { + const fixture = await payFixture(); + app = fixture.app; + const insert = app.db.prepare( + 'INSERT INTO idempotency (user_id, key, request_hash, response, created_at) VALUES (?, ?, ?, NULL, ?)', + ); + // Crash leftover for THIS request: response never stored, older than the + // reclaim window. Retrying the same request must go through. + const hash = requestHash('POST', '/v1/test/pay', { amountMsat: 1000 }); + insert.run(fixture.user.userId, 'k-stale', hash, Date.now() - IN_FLIGHT_RECLAIM_MS - 1_000); + const reclaimed = await pay(fixture, { key: 'k-stale' }); + expect(reclaimed.statusCode).toBe(200); + expect(fixture.calls()).toBe(1); + + // A claim younger than the window with a matching request hash is still + // treated as in flight. + insert.run(fixture.user.userId, 'k-young', hash, Date.now() - 1_000); + const blocked = await pay(fixture, { key: 'k-young' }); + expect(blocked.statusCode).toBe(409); + expect(blocked.json().error.code).toBe('IDEMPOTENCY_IN_FLIGHT'); + expect(fixture.calls()).toBe(1); + }); + + it('never reclaims a stale claim for a DIFFERENT request', async () => { + const fixture = await payFixture(); + app = fixture.app; + // Staleness is a crash-recovery allowance for retrying the same request, + // not a licence to repurpose a key: a different body must still conflict, + // however old the abandoned claim is. + app.db + .prepare( + 'INSERT INTO idempotency (user_id, key, request_hash, response, created_at) VALUES (?, ?, ?, NULL, ?)', + ) + .run( + fixture.user.userId, + 'k-stale-other', + requestHash('POST', '/v1/test/pay', { amountMsat: 999_999 }), + Date.now() - IN_FLIGHT_RECLAIM_MS - 1_000, + ); + const conflict = await pay(fixture, { key: 'k-stale-other' }); + expect(conflict.statusCode).toBe(409); + expect(conflict.json().error.code).toBe('IDEMPOTENCY_KEY_REUSED'); + expect(fixture.calls()).toBe(0); + }); + + it('caches Buffer payloads and replays them intact', async () => { + app = await testServer(); + app.post( + '/v1/test/binary', + { + preHandler: [app.authenticate, app.idempotency.preHandler], + onSend: app.idempotency.onSend, + }, + async (_request, reply) => reply.type('application/octet-stream').send(Buffer.from('blob')), + ); + const user = await createTestUser(app); + const inject = () => + app.inject({ + method: 'POST', + url: '/v1/test/binary', + headers: { authorization: `Bearer ${user.token}`, 'idempotency-key': 'kb' }, + }); + const first = await inject(); + expect(first.statusCode).toBe(200); + expect(first.body).toBe('blob'); + const replay = await inject(); + expect(replay.headers['x-idempotent-replay']).toBe('true'); + expect(replay.body).toBe('blob'); + }); + + it('releases the claim (fails loud, not corrupt) for an uncacheable stream payload', async () => { + app = await testServer(); + const { Readable } = await import('node:stream'); + let executions = 0; + app.post( + '/v1/test/stream', + { + preHandler: [app.authenticate, app.idempotency.preHandler], + onSend: app.idempotency.onSend, + }, + async (_request, reply) => { + executions += 1; + return reply.type('application/octet-stream').send(Readable.from(['chunk'])); + }, + ); + const user = await createTestUser(app); + const inject = () => + app.inject({ + method: 'POST', + url: '/v1/test/stream', + headers: { authorization: `Bearer ${user.token}`, 'idempotency-key': 'ks' }, + }); + const first = await inject(); + expect(first.statusCode).toBe(200); + // No cached row: the claim was released rather than storing an empty body. + expect( + app.db.prepare('SELECT response FROM idempotency WHERE key = ?').get('ks'), + ).toBeUndefined(); + // A retry re-executes instead of replaying a corrupt empty response. + await inject(); + expect(executions).toBe(2); + }); + + it('purges completed rows past retention when a new key is claimed', async () => { + const fixture = await payFixture(); + app = fixture.app; + app.db + .prepare( + 'INSERT INTO idempotency (user_id, key, request_hash, response, created_at) VALUES (?, ?, ?, ?, ?)', + ) + .run( + fixture.user.userId, + 'k-old', + 'h', + '{"statusCode":200,"contentType":null,"body":""}', + Date.now() - RETENTION_MS - 1_000, + ); + const response = await pay(fixture, { key: 'k-new' }); + expect(response.statusCode).toBe(200); + expect( + app.db.prepare('SELECT key FROM idempotency WHERE key = ?').get('k-old'), + ).toBeUndefined(); + // The fresh row itself survives. + expect(app.db.prepare('SELECT key FROM idempotency WHERE key = ?').get('k-new')).toBeDefined(); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/ledger.test.ts b/minimal-sdk/packages/gateway/test/ledger.test.ts new file mode 100644 index 00000000..1484ca2c --- /dev/null +++ b/minimal-sdk/packages/gateway/test/ledger.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { openDb, type GatewayDb } from '../src/db.js'; +import { BTC_ASSET, InsufficientBalanceError, Ledger } from '../src/ledger.js'; + +// Float caps are NOT a ledger concern (see the ledger.ts header): they are a +// policy on NEW exposure, enforced by LnFlows.assertBtcHeadroom, and covered +// per-user and globally in ln-routes.test.ts. Once funds have arrived, a +// credit must always succeed, so there is nothing to cap here. +const ASSET = 'rgb:testAsset-000'; + +describe('Ledger', () => { + let db: GatewayDb; + let ledger: Ledger; + + beforeEach(() => { + db = openDb(':memory:'); + for (const id of ['user-a', 'user-b', 'user-c']) { + db.prepare('INSERT INTO users (id, token_hash, created_at) VALUES (?, ?, ?)').run( + id, + `hash-${id}`, + Date.now(), + ); + } + ledger = new Ledger(db); + }); + + afterEach(() => { + db.close(); + }); + + const credit = (userId: string, amount: number, ref?: string) => + ledger.credit({ + userId, + asset: BTC_ASSET, + amount, + kind: 'deposit', + ...(ref !== undefined ? { ref } : {}), + }); + + it('tracks balances as the sum of deltas', () => { + credit('user-a', 600_000); + ledger.debit({ userId: 'user-a', asset: BTC_ASSET, amount: 250_000, kind: 'ln_out' }); + expect(ledger.balance('user-a', BTC_ASSET)).toBe(350_000); + expect(ledger.balance('user-b', BTC_ASSET)).toBe(0); + }); + + it('rejects debits beyond the balance', () => { + credit('user-a', 100_000); + expect(() => + ledger.debit({ userId: 'user-a', asset: BTC_ASSET, amount: 100_001, kind: 'ln_out' }), + ).toThrowError(InsufficientBalanceError); + expect(ledger.balance('user-a', BTC_ASSET)).toBe(100_000); + }); + + it('keeps per-asset balances independent', () => { + credit('user-a', 100_000); + ledger.credit({ userId: 'user-a', asset: ASSET, amount: 42, kind: 'deposit', ref: 'd2' }); + expect(() => + ledger.debit({ userId: 'user-a', asset: ASSET, amount: 43, kind: 'ln_out' }), + ).toThrowError(InsufficientBalanceError); + expect(ledger.balances('user-a')).toEqual({ [BTC_ASSET]: 100_000, [ASSET]: 42 }); + }); + + it('applies creditOnce exactly once per ref', () => { + const params = { + userId: 'user-a', + asset: BTC_ASSET, + amount: 50_000, + kind: 'deposit' as const, + ref: 'dep-1', + }; + expect(ledger.creditOnce(params)).toBe(true); + expect(ledger.creditOnce(params)).toBe(false); + expect(ledger.balance('user-a', BTC_ASSET)).toBe(50_000); + }); + + it('requires a ref on creditOnce', () => { + expect(() => + ledger.creditOnce({ userId: 'user-a', asset: BTC_ASSET, amount: 1, kind: 'deposit' }), + ).toThrowError(TypeError); + }); + + it('rejects zero, negative and non-integer amounts', () => { + for (const amount of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, 2 ** 53]) { + expect(() => + ledger.credit({ userId: 'user-a', asset: BTC_ASSET, amount, kind: 'deposit' }), + ).toThrowError(RangeError); + expect(() => + ledger.debit({ userId: 'user-a', asset: BTC_ASSET, amount, kind: 'withdraw' }), + ).toThrowError(RangeError); + } + expect(ledger.entries('user-a')).toEqual([]); + }); + + it('backstops exactly-once at the database level (unique index)', () => { + credit('user-a', 10_000, 'dup-ref'); + expect(() => + db + .prepare( + `INSERT INTO ledger (user_id, asset, delta_msat_or_units, kind, ref, created_at) + VALUES ('user-a', ?, 10000, 'deposit', 'dup-ref', 0)`, + ) + .run(BTC_ASSET), + ).toThrowError(/UNIQUE/); + }); + + it('property: the balance always equals the sum of deltas', () => { + // Deterministic LCG so failures are reproducible. + let seed = 0xdecafbad; + const rand = () => { + seed = (seed * 1_664_525 + 1_013_904_223) >>> 0; + return seed / 0x1_0000_0000; + }; + const users = ['user-a', 'user-b']; + const assets = [BTC_ASSET, ASSET]; + const expected = new Map(); + for (let i = 0; i < 400; i += 1) { + const userId = users[Math.floor(rand() * users.length)] as string; + const asset = assets[Math.floor(rand() * assets.length)] as string; + const key = `${userId}/${asset}`; + const current = expected.get(key) ?? 0; + const amount = 1 + Math.floor(rand() * 10_000); + if (rand() < 0.5 && current >= amount) { + ledger.debit({ userId, asset, amount, kind: 'ln_out' }); + expected.set(key, current - amount); + } else { + ledger.credit({ userId, asset, amount, kind: 'ln_in' }); + expected.set(key, current + amount); + } + } + for (const [key, value] of expected) { + const [userId, asset] = key.split('/') as [string, string]; + expect(ledger.balance(userId, asset)).toBe(value); + } + for (const asset of assets) { + const total = users.reduce((sum, u) => sum + (expected.get(`${u}/${asset}`) ?? 0), 0); + expect(ledger.globalBalance(asset)).toBe(total); + } + }); +}); diff --git a/minimal-sdk/packages/gateway/test/ln-mocks.ts b/minimal-sdk/packages/gateway/test/ln-mocks.ts new file mode 100644 index 00000000..40200902 --- /dev/null +++ b/minimal-sdk/packages/gateway/test/ln-mocks.ts @@ -0,0 +1,136 @@ +/** + * Test fakes for the LN flows: a programmable RlnApi where every method not + * explicitly provided fails loudly, plus error constructors matching the real + * client's failure modes. + */ +import type { RlnApi } from '../src/rln/client.js'; +import { RlnHttpError, RlnNetworkError, RlnTimeoutError } from '../src/rln/client.js'; + +export type RlnOverrides = { + [K in keyof RlnApi]?: (...args: Parameters) => unknown; +}; + +export interface FakeRln extends RlnApi { + /** Method-name log of every call, in order. */ + calls: string[]; +} + +const RLN_METHODS: ReadonlyArray = [ + 'address', + 'rgbInvoice', + 'sendBtc', + 'sendRgb', + 'lnInvoice', + 'decodeLnInvoice', + 'invoiceStatus', + 'sendPayment', + 'getPayment', + 'listPayments', + 'listTransfers', + 'refreshTransfers', +]; + +export function fakeRln(overrides: RlnOverrides): FakeRln { + const calls: string[] = []; + const fake = { calls } as unknown as FakeRln; + for (const method of RLN_METHODS) { + const impl = overrides[method]; + (fake as unknown as Record)[method] = async (...args: unknown[]) => { + calls.push(method); + if (impl === undefined) { + throw new Error(`unexpected RLN call: ${method}(${JSON.stringify(args)})`); + } + return (impl as (...a: unknown[]) => unknown)(...args); + }; + } + return fake; +} + +export function rlnRejection(status = 400): RlnHttpError { + return new RlnHttpError('/test', status, { error: 'rejected by node', code: status }, ''); +} + +/** RLN's actual "payment does not exist" answer (403 + name discriminator). */ +export function rlnPaymentNotFound(): RlnHttpError { + return new RlnHttpError( + '/getpayment', + 403, + { error: 'Payment not found', code: 403, name: 'PaymentNotFound' }, + '', + ); +} + +export function rlnTimeout(): RlnTimeoutError { + return new RlnTimeoutError('/test', 30_000); +} + +export function rlnNetworkFailure(): RlnNetworkError { + return new RlnNetworkError('/test', new Error('connection refused')); +} + +/** A syntactically plausible regtest BOLT11 string (fakes only decode it via RLN). */ +export const TEST_INVOICE = + 'lnbcrt30u1pjv6yzntestinvoice00000000000000000000000000000000000000000000'; + +export const HASH_A = 'a'.repeat(64); +export const HASH_B = 'b'.repeat(64); + +export function decodedInvoice( + paymentHash: string, + amtMsat: number | null, + asset?: { assetId: string; amount: number | null }, +) { + return { + amt_msat: amtMsat, + expiry_sec: 3600, + timestamp: 1_700_000_000, + asset_id: asset?.assetId ?? null, + asset_amount: asset?.amount ?? null, + payment_hash: paymentHash, + payment_secret: 'f'.repeat(64), + payee_pubkey: null, + min_final_cltv_expiry_delta: 144, + network: 'Regtest' as const, + }; +} + +/** + * RLN reports payment timestamps in unix SECONDS from its own clock, and the + * gateway's /listpayments walk uses them as an age floor against resource_map + * rows stamped with Date.now(). Mock payments therefore default to "now", not + * a frozen constant, or every fixture would look older than any owned resource. + */ +export function nowSec(): number { + return Math.floor(Date.now() / 1000); +} + +export function outboundPayment( + paymentHash: string, + status: string, + amtMsat: number | null, + createdAtSec: number = nowSec(), +) { + return { + amt_msat: amtMsat, + asset_amount: null, + asset_id: null, + payment_hash: paymentHash, + payment_type: 'Outbound' as const, + status, + created_at: createdAtSec, + updated_at: createdAtSec + 100, + payee_pubkey: '02' + '1'.repeat(64), + }; +} + +export function inboundPayment( + paymentHash: string, + status: string, + amtMsat: number | null, + createdAtSec: number = nowSec(), +) { + return { + ...outboundPayment(paymentHash, status, amtMsat, createdAtSec), + payment_type: 'InboundAutoClaim', + }; +} diff --git a/minimal-sdk/packages/gateway/test/ln-routes.test.ts b/minimal-sdk/packages/gateway/test/ln-routes.test.ts new file mode 100644 index 00000000..945c198e --- /dev/null +++ b/minimal-sdk/packages/gateway/test/ln-routes.test.ts @@ -0,0 +1,1318 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createTestUser, testServer, type TestServerOptions, type TestUser } from './helpers.js'; +import { + decodedInvoice, + fakeRln, + HASH_A, + HASH_B, + inboundPayment, + outboundPayment, + nowSec, + rlnRejection, + rlnTimeout, + TEST_INVOICE, + type RlnOverrides, +} from './ln-mocks.js'; +import { BTC_ASSET } from '../src/ledger.js'; +import { recordOwnership } from '../src/rln/scoping.js'; + +const ASSET = 'rgb:AssetTest111111111111111111111'; +/** Matches PAYMENTS_PAGE_SIZE in src/routes/ln.ts. */ +const PAGE = 100; + +describe('LN routes', () => { + let app: FastifyInstance; + + afterEach(async () => { + await app.close(); + }); + + async function setup(overrides: RlnOverrides, options: TestServerOptions = {}) { + const rln = fakeRln(overrides); + app = await testServer({ ...options, rlnClient: rln }); + const user = await createTestUser(app); + return { rln, user }; + } + + function fund(user: TestUser, amountMsat: number, asset = BTC_ASSET): void { + app.ledger.credit({ + userId: user.userId, + asset, + amount: amountMsat, + kind: 'deposit', + ref: randomUUID(), + }); + } + + function pay(user: TestUser, key: string, body: Record = {}) { + return app.inject({ + method: 'POST', + url: '/v1/ln/pay', + headers: { authorization: `Bearer ${user.token}`, 'idempotency-key': key }, + payload: { invoice: TEST_INVOICE, ...body }, + }); + } + + function withdrawFor(user: TestUser, key: string, body: Record) { + return app.inject({ + method: 'POST', + url: '/v1/ln/withdraw', + headers: { authorization: `Bearer ${user.token}`, 'idempotency-key': key }, + payload: body, + }); + } + + function balance(user: TestUser, asset = BTC_ASSET): number { + return app.ledger.balance(user.userId, asset); + } + + describe('pay', () => { + it('debits, sends and reports pending on the happy path', async () => { + const { rln, user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, 100_000), + sendPayment: () => ({ payment_id: HASH_A, status: 'Pending' }), + }); + fund(user, 500_000); + const response = await pay(user, 'pay-1'); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ paymentHash: HASH_A, status: 'pending' }); + expect(balance(user)).toBe(400_000); + expect(rln.calls).toEqual(['decodeLnInvoice', 'sendPayment']); + }); + + it('rejects an assetAmount that conflicts with the invoice', async () => { + const { rln, user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, 3_000, { assetId: ASSET, amount: 500 }), + }); + fund(user, 500_000); + fund(user, 500, ASSET); + const response = await pay(user, 'pay-1', { assetAmount: 50 }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('AMOUNT_MISMATCH'); + // Nothing sent, nothing debited: the caller never approved 500 units. + expect(rln.calls).not.toContain('sendPayment'); + expect(balance(user, ASSET)).toBe(500); + }); + + it('rejects a zero-amount invoice with 400 rather than an unhandled 500', async () => { + // `lnbc0…` decodes to amt_msat 0 — lightning-invoice only rejects amounts + // that are not a whole msat — and 0 slips past a `?? body.amtMsat` + // fallback straight into the ledger's RangeError. + const { rln, user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, 0), + }); + fund(user, 500_000); + const response = await pay(user, 'pay-1'); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('INVALID_INVOICE'); + expect(balance(user)).toBe(500_000); + expect(rln.calls).not.toContain('sendPayment'); + }); + + it('rejects an out-of-range invoice amount with 400', async () => { + const { rln, user } = await setup({ + // Above 2^53: representable as a double, but not a safe integer. + decodeLnInvoice: () => decodedInvoice(HASH_A, Number.MAX_SAFE_INTEGER + 1), + }); + fund(user, 500_000); + const response = await pay(user, 'pay-1'); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('INVALID_INVOICE'); + expect(rln.calls).not.toContain('sendPayment'); + }); + + it('rejects an out-of-range invoice asset amount with 400', async () => { + const { rln, user } = await setup({ + decodeLnInvoice: () => + decodedInvoice(HASH_A, 3_000, { assetId: ASSET, amount: Number.MAX_SAFE_INTEGER + 1 }), + }); + fund(user, 500_000); + fund(user, 500, ASSET); + const response = await pay(user, 'pay-1'); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('INVALID_INVOICE'); + expect(balance(user, ASSET)).toBe(500); + expect(rln.calls).not.toContain('sendPayment'); + }); + + it('rejects when the ledger balance is too low, without sending', async () => { + const { rln, user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, 100_000), + }); + fund(user, 99_999); + const response = await pay(user, 'pay-1'); + expect(response.statusCode).toBe(409); + expect(response.json().error.code).toBe('INSUFFICIENT_BALANCE'); + expect(balance(user)).toBe(99_999); + expect(rln.calls).not.toContain('sendPayment'); + }); + + it('refunds the debit when RLN definitively rejects the send', async () => { + const { user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, 100_000), + sendPayment: () => { + throw rlnRejection(400); + }, + }); + fund(user, 500_000); + const response = await pay(user, 'pay-1'); + // An RLN 4xx is a rejection of THIS payment, not an unhealthy node. + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('PAYMENT_REJECTED'); + expect(balance(user)).toBe(500_000); + const row = app.db + .prepare("SELECT state FROM resource_map WHERE kind = 'payment_hash' AND resource_id = ?") + .get(HASH_A) as { state: string }; + expect(row.state).toBe('failed'); + }); + + it('leaves an ambiguous send debited-pending and never auto-retries', async () => { + let sends = 0; + const { rln, user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, 100_000), + sendPayment: () => { + sends += 1; + throw rlnTimeout(); + }, + }); + fund(user, 500_000); + const first = await pay(user, 'pay-1'); + expect(first.statusCode).toBe(504); + expect(balance(user)).toBe(400_000); + const row = app.db + .prepare("SELECT state FROM resource_map WHERE kind = 'payment_hash' AND resource_id = ?") + .get(HASH_A) as { state: string }; + expect(row.state).toBe('debited_pending'); + + // A retry (new key) observes the pending state; sendPayment is NOT called again. + const second = await pay(user, 'pay-2'); + expect(second.statusCode).toBe(200); + expect(second.json()).toEqual({ paymentHash: HASH_A, status: 'pending' }); + expect(sends).toBe(1); + expect(balance(user)).toBe(400_000); + expect(rln.calls.filter((c) => c === 'sendPayment')).toHaveLength(1); + }); + + it('resolves an ambiguous payment via the reconciler: success keeps the debit', async () => { + const { user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, 100_000), + sendPayment: () => { + throw rlnTimeout(); + }, + getPayment: () => ({ payment: outboundPayment(HASH_A, 'Succeeded', 100_000) }), + listPayments: () => ({ payments: [], first_index_offset: 0, last_index_offset: 0 }), + }); + fund(user, 500_000); + await pay(user, 'pay-1'); + await app.reconciler.runOnce(); + expect(balance(user)).toBe(400_000); + const row = app.db + .prepare("SELECT state FROM resource_map WHERE kind = 'payment_hash' AND resource_id = ?") + .get(HASH_A) as { state: string }; + expect(row.state).toBe('succeeded'); + }); + + it('resolves an ambiguous payment via the reconciler: failure refunds', async () => { + const { user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, 100_000), + sendPayment: () => { + throw rlnTimeout(); + }, + getPayment: () => ({ payment: outboundPayment(HASH_A, 'Failed', 100_000) }), + listPayments: () => ({ payments: [], first_index_offset: 0, last_index_offset: 0 }), + }); + fund(user, 500_000); + await pay(user, 'pay-1'); + await app.reconciler.runOnce(); + expect(balance(user)).toBe(500_000); + }); + + it('replaying the same idempotency key does not double-debit', async () => { + const { rln, user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, 100_000), + sendPayment: () => ({ payment_id: HASH_A, status: 'Succeeded' }), + }); + fund(user, 500_000); + const first = await pay(user, 'pay-1'); + expect(first.statusCode).toBe(200); + const replay = await pay(user, 'pay-1'); + expect(replay.statusCode).toBe(200); + expect(replay.headers['x-idempotent-replay']).toBe('true'); + expect(replay.json()).toEqual(first.json()); + expect(balance(user)).toBe(400_000); + expect(rln.calls.filter((c) => c === 'sendPayment')).toHaveLength(1); + }); + + it('refuses to re-pay an invoice that already failed', async () => { + const { user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, 100_000), + sendPayment: () => { + throw rlnRejection(400); + }, + }); + fund(user, 500_000); + await pay(user, 'pay-1'); + const retry = await pay(user, 'pay-2'); + expect(retry.statusCode).toBe(409); + expect(retry.json().error.code).toBe('PAYMENT_ALREADY_ATTEMPTED'); + expect(balance(user)).toBe(500_000); + }); + + it('rejects a foreign in-flight invoice with a conflict', async () => { + const { user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, 100_000), + sendPayment: () => ({ payment_id: HASH_A, status: 'Pending' }), + }); + const other = await createTestUser(app); + fund(user, 500_000); + fund(other, 500_000); + await pay(user, 'pay-1'); + const conflicting = await pay(other, 'pay-2'); + expect(conflicting.statusCode).toBe(409); + expect(conflicting.json().error.code).toBe('PAYMENT_CONFLICT'); + expect(balance(other)).toBe(500_000); + }); + + it('refuses to claim a hash another user owns as an inbound invoice', async () => { + // decodeLnInvoice only verifies the BOLT11 self-signature, so the hash is + // the caller's to choose. Claiming a hash another user is waiting to be + // paid on would lock that user's real payer out with a 409. + const { rln, user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, 100_000), + sendPayment: () => ({ payment_id: HASH_A, status: 'Pending' }), + }); + const other = await createTestUser(app); + recordOwnership(app.db, { + kind: 'invoice', + resourceId: HASH_A, + userId: other.userId, + state: 'pending', + }); + fund(user, 500_000); + const response = await pay(user, 'pay-1'); + expect(response.statusCode).toBe(409); + expect(response.json().error.code).toBe('PAYMENT_CONFLICT'); + expect(balance(user)).toBe(500_000); + expect(rln.calls).not.toContain('sendPayment'); + }); + + it('requires an amount when the invoice carries none', async () => { + const { user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, null), + }); + fund(user, 500_000); + const response = await pay(user, 'pay-1'); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('AMOUNT_REQUIRED'); + }); + + it('pays an asset invoice: debits both the btc and the asset leg', async () => { + const { user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, 3_000, { assetId: ASSET, amount: 50 }), + sendPayment: () => ({ payment_id: HASH_A, status: 'Pending' }), + }); + fund(user, 500_000); + fund(user, 80, ASSET); + const response = await pay(user, 'pay-1'); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ paymentHash: HASH_A, status: 'pending' }); + expect(balance(user)).toBe(497_000); + expect(balance(user, ASSET)).toBe(30); + }); + + it('restores both legs when RLN definitively rejects an asset payment', async () => { + const { user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, 3_000, { assetId: ASSET, amount: 50 }), + sendPayment: () => { + throw rlnRejection(400); + }, + }); + fund(user, 500_000); + fund(user, 80, ASSET); + const response = await pay(user, 'pay-1'); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('PAYMENT_REJECTED'); + expect(balance(user)).toBe(500_000); + expect(balance(user, ASSET)).toBe(80); + const row = app.db + .prepare("SELECT state FROM resource_map WHERE kind = 'payment_hash' AND resource_id = ?") + .get(HASH_A) as { state: string }; + expect(row.state).toBe('failed'); + }); + + it('requires an asset amount for an asset invoice that carries none', async () => { + const { rln, user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, 3_000, { assetId: ASSET, amount: null }), + }); + fund(user, 500_000); + fund(user, 80, ASSET); + const response = await pay(user, 'pay-1'); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('AMOUNT_REQUIRED'); + expect(balance(user)).toBe(500_000); + expect(balance(user, ASSET)).toBe(80); + expect(rln.calls).not.toContain('sendPayment'); + }); + + it('refunds immediately when sendPayment answers with status Failed', async () => { + const { user } = await setup({ + decodeLnInvoice: () => decodedInvoice(HASH_A, 100_000), + sendPayment: () => ({ payment_id: HASH_A, status: 'Failed' }), + }); + fund(user, 500_000); + const response = await pay(user, 'pay-1'); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ paymentHash: HASH_A, status: 'failed' }); + expect(balance(user)).toBe(500_000); + const row = app.db + .prepare("SELECT state FROM resource_map WHERE kind = 'payment_hash' AND resource_id = ?") + .get(HASH_A) as { state: string }; + expect(row.state).toBe('failed'); + }); + }); + + describe('invoices', () => { + const invoiceOverrides: RlnOverrides = { + lnInvoice: () => ({ invoice: TEST_INVOICE }), + decodeLnInvoice: () => decodedInvoice(HASH_A, 250_000), + }; + + function createInvoice(user: TestUser, body: Record = { amtMsat: 250_000 }) { + return app.inject({ + method: 'POST', + url: '/v1/ln/invoice', + headers: { authorization: `Bearer ${user.token}` }, + payload: body, + }); + } + + it('creates a scoped invoice and hides it from other users', async () => { + const { user } = await setup(invoiceOverrides); + const other = await createTestUser(app); + const created = await createInvoice(user); + expect(created.statusCode).toBe(201); + expect(created.json()).toEqual({ invoice: TEST_INVOICE, paymentHash: HASH_A }); + + const foreign = await app.inject({ + method: 'GET', + url: `/v1/ln/invoice/${HASH_A}`, + headers: { authorization: `Bearer ${other.token}` }, + }); + expect(foreign.statusCode).toBe(404); + }); + + it('reports live status and credits a settled fixed-amount invoice once', async () => { + const { user } = await setup({ + ...invoiceOverrides, + invoiceStatus: () => ({ status: 'Succeeded' }), + }); + await createInvoice(user); + const read = () => + app.inject({ + method: 'GET', + url: `/v1/ln/invoice/${HASH_A}`, + headers: { authorization: `Bearer ${user.token}` }, + }); + const first = await read(); + expect(first.statusCode).toBe(200); + expect(first.json().state).toBe('settled'); + expect(balance(user)).toBe(250_000); + // A second read must not credit again. + const second = await read(); + expect(second.json().state).toBe('settled'); + expect(balance(user)).toBe(250_000); + }); + + it('rejects invoice creation beyond the float cap', async () => { + const { user } = await setup(invoiceOverrides, { + configOverrides: { floatCapPerUserMsat: 200_000 }, + }); + const response = await createInvoice(user, { amtMsat: 250_000 }); + expect(response.statusCode).toBe(409); + expect(response.json().error.code).toBe('FLOAT_CAP_EXCEEDED'); + }); + + it('requires an amount at creation: btc without amtMsat, asset without assetAmount', async () => { + const { rln, user } = await setup({}); + const btc = await createInvoice(user, {}); + expect(btc.statusCode).toBe(400); + expect(btc.json().error.code).toBe('AMOUNT_REQUIRED'); + const asset = await createInvoice(user, { assetId: ASSET }); + expect(asset.statusCode).toBe(400); + expect(asset.json().error.code).toBe('AMOUNT_REQUIRED'); + expect(rln.calls).toHaveLength(0); + }); + + it('counts pending invoices as exposure: two invoices jointly overshooting the cap', async () => { + let decodes = 0; + const { user } = await setup( + { + lnInvoice: () => ({ invoice: TEST_INVOICE }), + decodeLnInvoice: () => decodedInvoice(decodes++ === 0 ? HASH_A : HASH_B, 250_000), + }, + { configOverrides: { floatCapPerUserMsat: 400_000 } }, + ); + const first = await createInvoice(user, { amtMsat: 250_000 }); + expect(first.statusCode).toBe(201); + const second = await createInvoice(user, { amtMsat: 250_000 }); + expect(second.statusCode).toBe(409); + expect(second.json().error.code).toBe('FLOAT_CAP_EXCEEDED'); + }); + + it('marks an invoice expired when RLN reports Expired live', async () => { + const { user } = await setup({ + ...invoiceOverrides, + invoiceStatus: () => ({ status: 'Expired' }), + }); + await createInvoice(user); + const read = await app.inject({ + method: 'GET', + url: `/v1/ln/invoice/${HASH_A}`, + headers: { authorization: `Bearer ${user.token}` }, + }); + expect(read.statusCode).toBe(200); + expect(read.json().state).toBe('expired'); + const row = app.db + .prepare('SELECT state FROM ln_invoices WHERE payment_hash = ?') + .get(HASH_A) as { state: string }; + expect(row.state).toBe('expired'); + expect(balance(user)).toBe(0); + }); + + it('carries the configured minimum msat on an asset invoice with no amtMsat', async () => { + // RLN refuses an asset invoice whose amt_msat is under the channel HTLC + // floor, so an amount-less asset invoice could never be created. + let seen: { amt_msat: number | null } | undefined; + const { user } = await setup({ + lnInvoice: (body) => { + seen = body as { amt_msat: number | null }; + return { invoice: TEST_INVOICE }; + }, + decodeLnInvoice: () => decodedInvoice(HASH_A, 3_000, { assetId: ASSET, amount: 50 }), + }); + const created = await createInvoice(user, { assetId: ASSET, assetAmount: 50 }); + expect(created.statusCode).toBe(201); + expect(seen?.amt_msat).toBe(3_000_000); + const row = app.db + .prepare('SELECT amt_msat FROM ln_invoices WHERE payment_hash = ?') + .get(HASH_A) as { amt_msat: number }; + expect(row.amt_msat).toBe(3_000_000); + }); + + it('rejects an asset invoice whose amtMsat is under the HTLC floor', async () => { + const { rln, user } = await setup({}); + const created = await createInvoice(user, { + assetId: ASSET, + assetAmount: 50, + amtMsat: 1_000, + }); + expect(created.statusCode).toBe(400); + expect(created.json().error.code).toBe('AMOUNT_BELOW_MINIMUM'); + expect(rln.calls).toHaveLength(0); + }); + + it('maps an RLN 4xx on invoice creation to 400, not an opaque 502', async () => { + const { user } = await setup({ + lnInvoice: () => { + throw rlnRejection(400); + }, + }); + const created = await createInvoice(user, { amtMsat: 250_000 }); + expect(created.statusCode).toBe(400); + expect(created.json().error.code).toBe('INVOICE_REJECTED'); + // Node internals stay inside the gateway (invariant I4). + expect(JSON.stringify(created.json())).not.toContain('rejected by node'); + }); + + it('settles an asset invoice with the live amounts from getPayment', async () => { + const { user } = await setup({ + lnInvoice: () => ({ invoice: TEST_INVOICE }), + decodeLnInvoice: () => decodedInvoice(HASH_A, null, { assetId: ASSET, amount: 50 }), + invoiceStatus: () => ({ status: 'Succeeded' }), + getPayment: () => ({ + payment: { + ...inboundPayment(HASH_A, 'Succeeded', 3_000), + asset_id: ASSET, + asset_amount: 50, + }, + }), + }); + const created = await createInvoice(user, { assetId: ASSET, assetAmount: 50 }); + expect(created.statusCode).toBe(201); + const read = await app.inject({ + method: 'GET', + url: `/v1/ln/invoice/${HASH_A}`, + headers: { authorization: `Bearer ${user.token}` }, + }); + expect(read.statusCode).toBe(200); + expect(read.json().state).toBe('settled'); + // Both legs are credited from the live payment record (the invoice row + // itself declared no msat amount). + expect(balance(user)).toBe(3_000); + expect(balance(user, ASSET)).toBe(50); + }); + + it('leaves an asset invoice pending (uncredited) when its amount cannot be resolved', async () => { + // RLN says Succeeded but /getpayment fails, so the RECEIVED asset units + // are unknown. RGB credits are not bounded by the float caps, so they + // must NOT be guessed from the declared row — the invoice stays pending + // for a later pass to resolve. + const { user } = await setup({ + lnInvoice: () => ({ invoice: TEST_INVOICE }), + decodeLnInvoice: () => decodedInvoice(HASH_A, null, { assetId: ASSET, amount: 50 }), + invoiceStatus: () => ({ status: 'Succeeded' }), + getPayment: () => { + throw rlnTimeout(); + }, + }); + const created = await createInvoice(user, { assetId: ASSET, assetAmount: 50 }); + expect(created.statusCode).toBe(201); + const read = await app.inject({ + method: 'GET', + url: `/v1/ln/invoice/${HASH_A}`, + headers: { authorization: `Bearer ${user.token}` }, + }); + expect(read.statusCode).toBe(200); + expect(read.json().state).toBe('pending'); + expect(balance(user)).toBe(0); + expect(balance(user, ASSET)).toBe(0); + }); + + it('scopes the payments list to the requesting user', async () => { + const { user } = await setup({ + ...invoiceOverrides, + listPayments: () => ({ + payments: [ + inboundPayment(HASH_A, 'Succeeded', 250_000), + outboundPayment('c'.repeat(64), 'Succeeded', 10_000), + ], + first_index_offset: 2, + last_index_offset: 1, + }), + }); + const other = await createTestUser(app); + await createInvoice(user); + const mine = await app.inject({ + method: 'GET', + url: '/v1/ln/payments', + headers: { authorization: `Bearer ${user.token}` }, + }); + expect(mine.statusCode).toBe(200); + expect(mine.json().payments).toHaveLength(1); + expect(mine.json().payments[0].paymentHash).toBe(HASH_A); + + const theirs = await app.inject({ + method: 'GET', + url: '/v1/ln/payments', + headers: { authorization: `Bearer ${other.token}` }, + }); + expect(theirs.json().payments).toHaveLength(0); + }); + + it("pages past newer node-wide payments to find the user's own", async () => { + // RLN's /listpayments is node-wide and capped at 100 per page. A single + // unpaginated call would return only other users' newer payments and + // report an empty history for this user. + const filler = (i: number) => + outboundPayment(i.toString(16).padStart(64, '0'), 'Succeeded', 1_000); + const pages: Record[]> = { + 0: Array.from({ length: 100 }, (_, i) => filler(200 - i)), + 101: [inboundPayment(HASH_A, 'Succeeded', 250_000)], + }; + const seenOffsets: (number | undefined)[] = []; + const { user } = await setup({ + ...invoiceOverrides, + listPayments: (query: { index_offset?: number }) => { + seenOffsets.push(query.index_offset); + const payments = pages[query.index_offset ?? 0] ?? []; + return { + payments, + first_index_offset: payments.length > 0 ? 200 : 0, + last_index_offset: query.index_offset === undefined ? 101 : 1, + }; + }, + }); + await createInvoice(user); + const response = await app.inject({ + method: 'GET', + url: '/v1/ln/payments', + headers: { authorization: `Bearer ${user.token}` }, + }); + expect(response.statusCode).toBe(200); + expect(response.json().payments).toHaveLength(1); + expect(response.json().payments[0].paymentHash).toBe(HASH_A); + expect(seenOffsets).toEqual([undefined, 101]); + }); + + it('stops at the age floor when an owned payment is unknown to RLN', async () => { + // pay() records ownership BEFORE /sendpayment, so a send that never + // reached RLN leaves a resource_map row nothing in /listpayments can ever + // match. A count- or set-only stop condition would then walk the node's + // entire history on every call; the age floor bounds it. + const old = nowSec() - 30 * 86_400; + const filler = (i: number, createdAt: number) => + outboundPayment(i.toString(16).padStart(64, '0'), 'Succeeded', 1_000, createdAt); + let pagesServed = 0; + const { rln, user } = await setup({ + ...invoiceOverrides, + listPayments: () => { + pagesServed += 1; + return { + payments: Array.from({ length: PAGE }, (_, i) => filler(pagesServed * 1000 + i, old)), + first_index_offset: 200, + last_index_offset: 200 - pagesServed, + }; + }, + }); + // An owned resource RLN will never return. + app.db + .prepare( + 'INSERT INTO resource_map (kind, resource_id, user_id, state, created_at) VALUES (?, ?, ?, ?, ?)', + ) + .run('payment_hash', HASH_B, user.userId, 'failed', Date.now()); + const response = await app.inject({ + method: 'GET', + url: '/v1/ln/payments', + headers: { authorization: `Bearer ${user.token}` }, + }); + expect(response.statusCode).toBe(200); + expect(response.json().payments).toEqual([]); + // One page, not the whole history: every payment on it predates the + // user's own resource by more than the skew margin. + expect(pagesServed).toBe(1); + expect(rln.calls.filter((c) => c === 'listPayments')).toHaveLength(1); + }); + + it('makes no RLN call when the user owns no payments', async () => { + const { rln, user } = await setup({}); + const response = await app.inject({ + method: 'GET', + url: '/v1/ln/payments', + headers: { authorization: `Bearer ${user.token}` }, + }); + expect(response.statusCode).toBe(200); + expect(response.json().payments).toEqual([]); + expect(rln.calls).toHaveLength(0); + }); + }); + + describe('auth and idempotency requirements', () => { + const getRoutes = [`/v1/ln/invoice/${HASH_A}`, '/v1/ln/payments', '/v1/ln/balance']; + const postRoutes = [ + '/v1/ln/deposit/prepare', + '/v1/ln/pay', + '/v1/ln/invoice', + '/v1/ln/withdraw', + ]; + + it('rejects unauthenticated calls on every route', async () => { + await setup({}); + for (const url of getRoutes) { + const response = await app.inject({ method: 'GET', url }); + expect(response.statusCode, url).toBe(401); + } + for (const url of postRoutes) { + const response = await app.inject({ method: 'POST', url, payload: {} }); + expect(response.statusCode, url).toBe(401); + } + }); + + it('requires an Idempotency-Key on the money-moving routes', async () => { + const { rln, user } = await setup({}); + const bodies: Record = { + '/v1/ln/pay': { invoice: TEST_INVOICE }, + '/v1/ln/withdraw': { kind: 'btc', address: 'bcrt1qtestaddress0000', amountSat: 5_000 }, + }; + for (const [url, payload] of Object.entries(bodies)) { + const response = await app.inject({ + method: 'POST', + url, + headers: { authorization: `Bearer ${user.token}` }, + payload, + }); + expect(response.statusCode, url).toBe(400); + expect(response.json().error.code, url).toBe('IDEMPOTENCY_KEY_REQUIRED'); + } + // Rejected before any node call: the key guard runs ahead of the handler. + expect(rln.calls).toHaveLength(0); + }); + }); + + describe('deposits', () => { + it('prepares a btc deposit and rejects one beyond the per-user cap', async () => { + const { user } = await setup( + { address: () => ({ address: 'bcrt1qtestdepositaddr' }) }, + { configOverrides: { floatCapPerUserMsat: 1_000_000 } }, + ); + const ok = await app.inject({ + method: 'POST', + url: '/v1/ln/deposit/prepare', + headers: { authorization: `Bearer ${user.token}`, 'idempotency-key': 'dep-1' }, + payload: { kind: 'btc', amountMsat: 600_000 }, + }); + expect(ok.statusCode).toBe(201); + expect(ok.json().address).toBe('bcrt1qtestdepositaddr'); + + // The first pending deposit counts as exposure: this one would overshoot. + const over = await app.inject({ + method: 'POST', + url: '/v1/ln/deposit/prepare', + headers: { authorization: `Bearer ${user.token}`, 'idempotency-key': 'dep-2' }, + payload: { kind: 'btc', amountMsat: 500_000 }, + }); + expect(over.statusCode).toBe(409); + expect(over.json().error.code).toBe('FLOAT_CAP_EXCEEDED'); + }); + + it('rejects a deposit beyond the global cap even under the user cap', async () => { + const { user } = await setup( + { address: () => ({ address: 'bcrt1qtestdepositaddr' }) }, + { configOverrides: { floatCapPerUserMsat: 1_000_000, floatCapGlobalMsat: 1_200_000 } }, + ); + const other = await createTestUser(app); + fund(user, 900_000); + const response = await app.inject({ + method: 'POST', + url: '/v1/ln/deposit/prepare', + headers: { authorization: `Bearer ${other.token}`, 'idempotency-key': 'dep-3' }, + payload: { kind: 'btc', amountMsat: 400_000 }, + }); + expect(response.statusCode).toBe(409); + expect(response.json().error.code).toBe('FLOAT_CAP_EXCEEDED'); + }); + + it('prepares an rgb deposit with an invoice and an expiring pending row', async () => { + const { user } = await setup({ + rgbInvoice: () => ({ + invoice: 'rgb:inv-1', + recipient_id: 'utxob:rcpt-1', + expiration_timestamp: null, + batch_transfer_idx: 1, + }), + }); + const response = await app.inject({ + method: 'POST', + url: '/v1/ln/deposit/prepare', + headers: { authorization: `Bearer ${user.token}`, 'idempotency-key': 'dep-4' }, + payload: { kind: 'rgb', assetId: ASSET, amount: 40 }, + }); + expect(response.statusCode).toBe(201); + const body = response.json(); + expect(body.invoice).toBe('rgb:inv-1'); + expect(body.recipientId).toBe('utxob:rcpt-1'); + const row = app.db + .prepare('SELECT kind, asset, amount, expires_at FROM pending_deposits WHERE user_id = ?') + .get(user.userId) as { kind: string; asset: string; amount: number; expires_at: number }; + expect(row.kind).toBe('rgb'); + expect(row.asset).toBe(ASSET); + expect(row.amount).toBe(40); + expect(row.expires_at).toBeGreaterThan(Date.now()); + }); + + it('rejects an rgb prepare when the node repeats a recipient id already pending', async () => { + // The watcher attributes RGB by recipient_id with no lower time bound, + // so two intents on one id would credit the same settled transfer twice. + const { user } = await setup({ + rgbInvoice: () => ({ + invoice: 'rgb:inv-pinned', + recipient_id: 'utxob:pinned', + expiration_timestamp: null, + batch_transfer_idx: 1, + }), + }); + const other = await createTestUser(app); + const first = await app.inject({ + method: 'POST', + url: '/v1/ln/deposit/prepare', + headers: { authorization: `Bearer ${user.token}`, 'idempotency-key': 'dep-rgb-1' }, + payload: { kind: 'rgb', assetId: ASSET, amount: 40 }, + }); + expect(first.statusCode).toBe(201); + const second = await app.inject({ + method: 'POST', + url: '/v1/ln/deposit/prepare', + headers: { authorization: `Bearer ${other.token}`, 'idempotency-key': 'dep-rgb-2' }, + payload: { kind: 'rgb', assetId: ASSET, amount: 40 }, + }); + expect(second.statusCode).toBe(502); + expect(second.json().error.code).toBe('DEPOSIT_ADDRESS_CONFLICT'); + expect(app.db.prepare('SELECT COUNT(*) AS n FROM pending_deposits').get()).toEqual({ n: 1 }); + }); + + it('rejects a btc prepare when the node repeats an address already pending', async () => { + // RLN pins its address under --reuse-addresses; accepting the repeat + // would let one on-chain payment credit several deposit intents. + const { user } = await setup({ address: () => ({ address: 'bcrt1qpinnedaddr' }) }); + const other = await createTestUser(app); + const first = await app.inject({ + method: 'POST', + url: '/v1/ln/deposit/prepare', + headers: { authorization: `Bearer ${user.token}`, 'idempotency-key': 'dep-5' }, + payload: { kind: 'btc', amountMsat: 100_000 }, + }); + expect(first.statusCode).toBe(201); + const second = await app.inject({ + method: 'POST', + url: '/v1/ln/deposit/prepare', + headers: { authorization: `Bearer ${other.token}`, 'idempotency-key': 'dep-6' }, + payload: { kind: 'btc', amountMsat: 100_000 }, + }); + expect(second.statusCode).toBe(502); + expect(second.json().error.code).toBe('DEPOSIT_ADDRESS_CONFLICT'); + expect(app.db.prepare('SELECT COUNT(*) AS n FROM pending_deposits').get()).toEqual({ n: 1 }); + }); + + it('replays a deposit prepare under the same key without minting a second intent', async () => { + // Each prepare burns a node address and pins float-cap headroom for the + // deposit TTL, so a retry must return the first intent. + let addresses = 0; + const { user } = await setup({ + address: () => ({ address: `bcrt1qaddr${(addresses += 1)}` }), + }); + const send = () => + app.inject({ + method: 'POST', + url: '/v1/ln/deposit/prepare', + headers: { authorization: `Bearer ${user.token}`, 'idempotency-key': 'dep-same' }, + payload: { kind: 'btc', amountMsat: 100_000 }, + }); + const first = await send(); + expect(first.statusCode).toBe(201); + const replay = await send(); + expect(replay.statusCode).toBe(201); + expect(replay.json()).toEqual(first.json()); + expect(addresses).toBe(1); + expect(app.db.prepare('SELECT COUNT(*) AS n FROM pending_deposits').get()).toEqual({ n: 1 }); + }); + + it('rejects prepares missing their manual-validation fields', async () => { + const { rln, user } = await setup({}); + const btc = await app.inject({ + method: 'POST', + url: '/v1/ln/deposit/prepare', + headers: { authorization: `Bearer ${user.token}`, 'idempotency-key': 'dep-7' }, + payload: { kind: 'btc' }, + }); + expect(btc.statusCode).toBe(400); + expect(btc.json().error.code).toBe('BAD_REQUEST'); + const rgb = await app.inject({ + method: 'POST', + url: '/v1/ln/deposit/prepare', + headers: { authorization: `Bearer ${user.token}`, 'idempotency-key': 'dep-8' }, + payload: { kind: 'rgb' }, + }); + expect(rgb.statusCode).toBe(400); + expect(rgb.json().error.code).toBe('BAD_REQUEST'); + expect(rln.calls).toHaveLength(0); + }); + }); + + describe('refund namespacing', () => { + it('refunds a failed pay whose hash collides with an earlier withdrawal id', async () => { + // Both refs are 64-hex values the caller controls: the withdrawal id is + // sha256(userId||'\n'||idempotencyKey), and decodeLnInvoice only checks + // the BOLT11 self-signature so the payment hash is attacker-chosen. A + // shared 'refund' ledger kind made the second creditOnce a silent no-op, + // leaving the debit un-refunded with no error and no log line. + let collidingHash = HASH_A; + const { user } = await setup({ + sendBtc: () => { + throw rlnRejection(403); + }, + decodeLnInvoice: () => decodedInvoice(collidingHash, 100_000), + sendPayment: () => { + throw rlnRejection(400); + }, + }); + collidingHash = createHash('sha256') + .update(user.userId) + .update('\n') + .update('wd-collide') + .digest('hex'); + fund(user, 500_000); + + const wd = await withdrawFor(user, 'wd-collide', { + kind: 'btc', + address: 'bcrt1qvaultaddr000000', + amountSat: 300, + }); + expect(wd.statusCode).toBe(400); + expect(balance(user)).toBe(500_000); + + const paid = await pay(user, 'pay-collide'); + expect(paid.statusCode).toBe(400); + // The pay debit must be refunded too, not swallowed by the withdrawal's + // refund row. + expect(balance(user)).toBe(500_000); + }); + }); + + describe('withdraw', () => { + function withdraw(user: TestUser, key: string, body: Record) { + return app.inject({ + method: 'POST', + url: '/v1/ln/withdraw', + headers: { authorization: `Bearer ${user.token}`, 'idempotency-key': key }, + payload: body, + }); + } + const btcBody = { kind: 'btc', address: 'bcrt1qvaultaddr000000', amountSat: 300 }; + + it('debits and reports the txid on the happy path', async () => { + const { user } = await setup({ sendBtc: () => ({ txid: 'txid-1' }) }); + fund(user, 500_000); + const response = await withdraw(user, 'wd-1', btcBody); + expect(response.statusCode).toBe(201); + expect(response.json().txid).toBe('txid-1'); + expect(balance(user)).toBe(200_000); + + // Idempotent replay does not debit again. + const replay = await withdraw(user, 'wd-1', btcBody); + expect(replay.headers['x-idempotent-replay']).toBe('true'); + expect(balance(user)).toBe(200_000); + }); + + it('refunds when RLN definitively rejects the send', async () => { + const { user } = await setup({ + sendBtc: () => { + throw rlnRejection(403); + }, + }); + fund(user, 500_000); + const response = await withdraw(user, 'wd-1', btcBody); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('WITHDRAW_REJECTED'); + expect(balance(user)).toBe(500_000); + const row = app.db.prepare('SELECT state FROM withdrawals').get() as { state: string }; + expect(row.state).toBe('failed'); + }); + + it('keeps the debit and marks ambiguous on an RLN 5xx', async () => { + // rgb-lib broadcasts BEFORE its post-broadcast bookkeeping, and every + // failure point after the broadcast reaches RLN as a 500 — so a 5xx may + // mean the transaction is already on the network. Refunding it would + // hand back money the node actually paid out. + const { user } = await setup({ + sendBtc: () => { + throw rlnRejection(500); + }, + }); + fund(user, 500_000); + const response = await withdraw(user, 'wd-1', btcBody); + expect(response.statusCode).toBe(502); + expect(balance(user)).toBe(200_000); + const row = app.db.prepare('SELECT state FROM withdrawals').get() as { state: string }; + expect(row.state).toBe('ambiguous'); + }); + + it('keeps the debit and marks ambiguous on a timeout', async () => { + const { user } = await setup({ + sendBtc: () => { + throw rlnTimeout(); + }, + }); + fund(user, 500_000); + const response = await withdraw(user, 'wd-1', btcBody); + expect(response.statusCode).toBe(504); + expect(balance(user)).toBe(200_000); + const row = app.db.prepare('SELECT state FROM withdrawals').get() as { state: string }; + expect(row.state).toBe('ambiguous'); + }); + + it('never re-executes a same-key retry after an ambiguous outcome', async () => { + const { rln, user } = await setup({ + sendBtc: () => { + throw rlnTimeout(); + }, + }); + fund(user, 500_000); + const first = await withdraw(user, 'wd-1', btcBody); + expect(first.statusCode).toBe(504); + // The 504 released the idempotency claim, so the documented same-key + // retry re-enters the handler — the withdrawals row must stop it from + // debiting and broadcasting again. + const retry = await withdraw(user, 'wd-1', btcBody); + expect(retry.statusCode).toBe(409); + expect(retry.json().error.code).toBe('WITHDRAWAL_UNRESOLVED'); + expect(balance(user)).toBe(200_000); + expect(rln.calls.filter((c) => c === 'sendBtc')).toHaveLength(1); + expect(app.db.prepare('SELECT COUNT(*) AS n FROM withdrawals').get()).toEqual({ n: 1 }); + }); + + it('replays the recorded result on a same-key retry after a crash-reclaimed success', async () => { + const { rln, user } = await setup({ sendBtc: () => ({ txid: 'txid-1' }) }); + fund(user, 500_000); + const first = await withdraw(user, 'wd-1', btcBody); + expect(first.statusCode).toBe(201); + // Simulate a crash after broadcast but before the response was cached: + // the idempotency claim is gone, so the retry executes the handler. + app.db.prepare('DELETE FROM idempotency').run(); + const retry = await withdraw(user, 'wd-1', btcBody); + expect(retry.statusCode).toBe(201); + expect(retry.json()).toEqual(first.json()); + expect(balance(user)).toBe(200_000); + expect(rln.calls.filter((c) => c === 'sendBtc')).toHaveLength(1); + }); + + it('never re-sends a same-key retry of a failed (refunded) withdrawal', async () => { + const { rln, user } = await setup({ + sendBtc: () => { + throw rlnRejection(403); + }, + }); + fund(user, 500_000); + const first = await withdraw(user, 'wd-1', btcBody); + expect(first.statusCode).toBe(400); + expect(first.json().error.code).toBe('WITHDRAW_REJECTED'); + expect(balance(user)).toBe(500_000); + // 400 is not retryable, so the idempotency claim is kept and the retry is + // answered from the cache: the handler is never re-entered and the send + // is never repeated. + const retry = await withdraw(user, 'wd-1', btcBody); + expect(retry.statusCode).toBe(400); + expect(retry.headers['x-idempotent-replay']).toBe('true'); + expect(balance(user)).toBe(500_000); + expect(rln.calls.filter((c) => c === 'sendBtc')).toHaveLength(1); + }); + + it('refuses a fresh-key retry of a failed (refunded) withdrawal that RLN timed out', async () => { + // The withdrawals row — not the idempotency cache — is the replay guard + // when the claim IS released (5xx): a same-key retry hits the terminal row. + const { rln, user } = await setup({ + sendBtc: () => { + throw new Error('boom'); + }, + }); + fund(user, 500_000); + const first = await withdraw(user, 'wd-1', btcBody); + expect(first.statusCode).toBe(502); + const retry = await withdraw(user, 'wd-1', btcBody); + expect(retry.statusCode).toBe(409); + expect(retry.json().error.code).toBe('WITHDRAWAL_UNRESOLVED'); + expect(rln.calls.filter((c) => c === 'sendBtc')).toHaveLength(1); + }); + + it('rejects a withdraw beyond the balance', async () => { + const { user } = await setup({}); + fund(user, 100_000); + const response = await withdraw(user, 'wd-1', btcBody); + expect(response.statusCode).toBe(409); + expect(response.json().error.code).toBe('INSUFFICIENT_BALANCE'); + expect(balance(user)).toBe(100_000); + }); + + it('sends an rgb withdraw through the allowlisted proxy and debits the asset', async () => { + let sent: unknown; + const { user } = await setup({ + sendRgb: (request) => { + sent = request; + return { txid: 'txid-rgb-1' }; + }, + }); + fund(user, 100, ASSET); + const response = await withdraw(user, 'wd-1', { + kind: 'rgb', + assetId: ASSET, + amount: 40, + recipientId: 'utxob:rcpt-1', + }); + expect(response.statusCode).toBe(201); + expect(response.json().txid).toBe('txid-rgb-1'); + expect(sent).toMatchObject({ + recipient_map: { + [ASSET]: [ + { + recipient_id: 'utxob:rcpt-1', + assignment: { type: 'Fungible', value: 40 }, + transport_endpoints: [app.gatewayConfig.rgbProxyUrl], + }, + ], + }, + }); + expect(balance(user, ASSET)).toBe(60); + const row = app.db.prepare('SELECT state FROM withdrawals').get() as { state: string }; + expect(row.state).toBe('sent'); + }); + + it('sends witness_data for a wvout recipient', async () => { + // rgb-lib rejects a witness beneficiary with no witness data outright + // ("missing witness data for a witness recipient"), so a withdraw to the + // wvout invoice /v1/wallet/receive hands out must carry the sat value. + let sent: { recipient_map: Record } | undefined; + const { user } = await setup({ + sendRgb: (request) => { + sent = request as typeof sent; + return { txid: 'txid-rgb-2' }; + }, + }); + fund(user, 100, ASSET); + fund(user, 5_000_000); + const response = await withdraw(user, 'wd-1', { + kind: 'rgb', + assetId: ASSET, + amount: 40, + recipientId: 'bcrt:wvout:rcpt-w1', + witnessAmountSat: 1000, + }); + expect(response.statusCode).toBe(201); + expect(sent?.recipient_map[ASSET]?.[0]).toMatchObject({ + recipient_id: 'bcrt:wvout:rcpt-w1', + witness_data: { amount_sat: 1000, blinding: null }, + }); + expect(balance(user, ASSET)).toBe(60); + // The witness output is funded by the NODE's on-chain wallet, so its + // sats are debited from the user's msat float too. + expect(balance(user)).toBe(5_000_000 - 1000 * 1000); + }); + + it('refuses a witness withdraw the caller cannot pay for in msat', async () => { + // Without the msat debit, witnessAmountSat would let a caller move an + // arbitrary amount of the node's BTC into a script they control for the + // price of one asset unit. + const { rln, user } = await setup({}); + fund(user, 100, ASSET); + fund(user, 1_000_000); + const response = await withdraw(user, 'wd-1', { + kind: 'rgb', + assetId: ASSET, + amount: 1, + recipientId: 'bcrt:wvout:rcpt-w1', + witnessAmountSat: 40_000_000, + }); + expect(response.statusCode).toBe(409); + expect(response.json().error.code).toBe('INSUFFICIENT_BALANCE'); + expect(balance(user, ASSET)).toBe(100); + expect(balance(user)).toBe(1_000_000); + expect(rln.calls).toHaveLength(0); + }); + + it('refunds both legs of a witness withdraw the node rejects', async () => { + const { user } = await setup({ + sendRgb: () => { + throw rlnRejection(403); + }, + }); + fund(user, 100, ASSET); + fund(user, 5_000_000); + const response = await withdraw(user, 'wd-1', { + kind: 'rgb', + assetId: ASSET, + amount: 40, + recipientId: 'bcrt:wvout:rcpt-w1', + witnessAmountSat: 1000, + }); + expect(response.statusCode).toBe(400); + expect(balance(user, ASSET)).toBe(100); + expect(balance(user)).toBe(5_000_000); + const row = app.db.prepare('SELECT state FROM withdrawals').get() as { state: string }; + expect(row.state).toBe('failed'); + }); + + it('rejects a witnessAmountSat whose msat value would not be a safe integer', async () => { + const { rln, user } = await setup({}); + fund(user, 100, ASSET); + const response = await withdraw(user, 'wd-1', { + kind: 'rgb', + assetId: ASSET, + amount: 40, + recipientId: 'bcrt:wvout:rcpt-w1', + witnessAmountSat: 9_007_199_254_741, + }); + expect(response.statusCode).toBe(400); + expect(rln.calls).toHaveLength(0); + expect(balance(user, ASSET)).toBe(100); + }); + + it('rejects a wvout recipient with no witnessAmountSat before any debit', async () => { + const { rln, user } = await setup({}); + fund(user, 100, ASSET); + const response = await withdraw(user, 'wd-1', { + kind: 'rgb', + assetId: ASSET, + amount: 40, + recipientId: 'bcrt:wvout:rcpt-w1', + }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('WITNESS_AMOUNT_REQUIRED'); + expect(balance(user, ASSET)).toBe(100); + expect(rln.calls).toHaveLength(0); + }); + + it('rejects witnessAmountSat on a blind recipient before any debit', async () => { + // rgb-lib's mirror error: "cannot provide witness data for a blinded + // recipient". + const { rln, user } = await setup({}); + fund(user, 100, ASSET); + const response = await withdraw(user, 'wd-1', { + kind: 'rgb', + assetId: ASSET, + amount: 40, + recipientId: 'bcrt:utxob:rcpt-1', + witnessAmountSat: 1000, + }); + expect(response.statusCode).toBe(400); + expect(balance(user, ASSET)).toBe(100); + expect(rln.calls).toHaveLength(0); + }); + + it("rejects an rgb withdraw naming the reserved 'btc' asset before any debit", async () => { + // BTC_ASSET keys the msat float. Left through, this debits that float in + // msat units for a send rgb-lib can never resolve — and an ambiguous + // (timeout) outcome leaves the debit standing permanently. + const { rln, user } = await setup({}); + fund(user, 500_000); + const response = await withdraw(user, 'wd-1', { + kind: 'rgb', + assetId: BTC_ASSET, + amount: 40, + recipientId: 'bcrt:utxob:rcpt-1', + }); + expect(response.statusCode).toBe(400); + expect(balance(user)).toBe(500_000); + expect(rln.calls).toHaveLength(0); + expect(app.db.prepare('SELECT COUNT(*) AS n FROM withdrawals').get()).toEqual({ n: 0 }); + }); + + it('rejects a non-allowlisted transport endpoint before any debit', async () => { + const { rln, user } = await setup({}); + fund(user, 100, ASSET); + const response = await withdraw(user, 'wd-1', { + kind: 'rgb', + assetId: ASSET, + amount: 40, + recipientId: 'utxob:rcpt-1', + transportEndpoints: ['rpc://evil.internal/x'], + }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('TRANSPORT_ENDPOINT_NOT_ALLOWED'); + expect(balance(user, ASSET)).toBe(100); + expect(app.db.prepare('SELECT COUNT(*) AS n FROM withdrawals').get()).toEqual({ n: 0 }); + expect(rln.calls).toHaveLength(0); + }); + + it('rejects a btc withdraw missing address and amountSat', async () => { + const { rln, user } = await setup({}); + fund(user, 500_000); + const response = await withdraw(user, 'wd-1', { kind: 'btc' }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('BAD_REQUEST'); + expect(balance(user)).toBe(500_000); + expect(rln.calls).toHaveLength(0); + }); + }); + + it('reports ledger balances', async () => { + const { user } = await setup({}); + fund(user, 123_000); + fund(user, 42, 'rgb:someAsset'); + const response = await app.inject({ + method: 'GET', + url: '/v1/ln/balance', + headers: { authorization: `Bearer ${user.token}` }, + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ btcMsat: 123_000, assets: { 'rgb:someAsset': 42 } }); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/logging.test.ts b/minimal-sdk/packages/gateway/test/logging.test.ts new file mode 100644 index 00000000..4738fa19 --- /dev/null +++ b/minimal-sdk/packages/gateway/test/logging.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { OPERATOR_TOKEN, RLN_ADMIN_TOKEN, createTestUser, testServer } from './helpers.js'; +import { fakeRln, rlnTimeout } from './ln-mocks.js'; +import { BTC_ASSET } from '../src/ledger.js'; + +let app: FastifyInstance; + +afterEach(async () => { + await app.close(); +}); + +describe('invariant I4: credentials never reach the logs', () => { + it('user tokens, the operator token and the RLN admin token are absent from log output', async () => { + const chunks: string[] = []; + app = await testServer({ + loggerStream: { write: (msg: string) => void chunks.push(msg) }, + }); + const user = await createTestUser(app); + await app.inject({ + method: 'GET', + url: '/v1/me', + headers: { authorization: `Bearer ${user.token}` }, + }); + // Error paths log more; make sure they do not leak headers either. + await app.inject({ + method: 'POST', + url: '/v1/users', + headers: { 'x-operator-token': 'wrong-operator-token-123' }, + }); + const logs = chunks.join(''); + expect(logs.length).toBeGreaterThan(0); + expect(logs).not.toContain(user.token); + expect(logs).not.toContain(OPERATOR_TOKEN); + expect(logs).not.toContain('wrong-operator-token-123'); + expect(logs).not.toContain(RLN_ADMIN_TOKEN); + // The user id is fine to log; the token hash is not stored in logs either. + const row = app.db.prepare('SELECT token_hash FROM users WHERE id = ?').get(user.userId) as { + token_hash: string; + }; + expect(logs).not.toContain(row.token_hash); + }); +}); + +describe('failed money-moving calls leave an operator-readable record', () => { + it('logs the upstream cause behind an opaque client error', async () => { + const chunks: string[] = []; + app = await testServer({ + loggerStream: { write: (msg: string) => void chunks.push(msg) }, + rlnClient: fakeRln({ + sendBtc: () => { + throw rlnTimeout(); + }, + }), + }); + const user = await createTestUser(app); + app.ledger.credit({ + userId: user.userId, + asset: BTC_ASSET, + amount: 1_000_000, + kind: 'deposit', + ref: 'd1', + }); + const response = await app.inject({ + method: 'POST', + url: '/v1/ln/withdraw', + headers: { authorization: `Bearer ${user.token}`, 'idempotency-key': 'w-1' }, + payload: { kind: 'btc', address: 'bcrt1qvaultaddr000000', amountSat: 300 }, + }); + // The client learns nothing about the node... + expect(response.statusCode).toBe(504); + expect(JSON.stringify(response.json())).not.toContain('/sendbtc'); + // ...but the withdrawal is left 'ambiguous', so the operator must be able + // to see what RLN actually said. + const withdrawal = app.db.prepare('SELECT state FROM withdrawals').get() as { state: string }; + expect(withdrawal.state).toBe('ambiguous'); + const logs = chunks.join(''); + expect(logs).toContain('RlnTimeoutError'); + expect(logs).toContain('UPSTREAM_TIMEOUT'); + expect(logs).not.toContain(RLN_ADMIN_TOKEN); + expect(logs).not.toContain(user.token); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/onchain-integration.test.ts b/minimal-sdk/packages/gateway/test/onchain-integration.test.ts new file mode 100644 index 00000000..cced8ff2 --- /dev/null +++ b/minimal-sdk/packages/gateway/test/onchain-integration.test.ts @@ -0,0 +1,367 @@ +/** + * On-chain prepare→sign→complete flows against the real regtest stack: + * bitcoind + electrs (127.0.0.1:50001) + RGB proxy (127.0.0.1:3000). Run with: + * + * ./regtest.sh start # from the repo root + * WALLET_REGTEST=1 pnpm --filter @utexo/minimal-gateway test onchain-integration + * + * The gateway user is a watch-only wallet (fixture mnemonic #1 signs client- + * side via @scure); the counterparty is a keys-bearing native rgb-lib wallet + * driven directly (test-only — mnemonics are never a gateway input). + * + * Needs a freshly reset chain. When combined with wallet-integration (same + * fixture account), run that suite FIRST: this one creates colorable UTXOs, + * which would break its INSUFFICIENT_FUNDS assertion. + */ +import { execSync } from 'node:child_process'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { loadNativeRgbLib, restoreKeysForTests } from '../src/wallets/rgblib.js'; +import { masterFromMnemonic, signPsbtWithMaster } from './fixture-signer.js'; +import { createTestUser, sleep, testServer, type TestUser } from './helpers.js'; + +const REPO_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)); +const ELECTRUM_URL = '127.0.0.1:50001'; +const PROXY_URL = 'rpc://127.0.0.1:3000/json-rpc'; + +// TEST fixture only (same as wallet-integration): client-side signer material. +const FIXTURE_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + +const master = masterFromMnemonic(FIXTURE_MNEMONIC); + +function regtest(command: string): string { + return execSync(`./regtest.sh ${command}`, { cwd: REPO_ROOT, encoding: 'utf8' }).trim(); +} + +type Opaque = unknown; + +/** One-shot native calls the gateway interface deliberately excludes. */ +interface CounterpartyLib { + rgblib_new_wallet(walletData: string, keys: string): Opaque; + rgblib_go_online(wallet: Opaque, skip: boolean, indexer: string): Opaque; + rgblib_generate_keys(network: string): string; + rgblib_get_address(wallet: Opaque): string; + rgblib_get_btc_balance(wallet: Opaque, online: Opaque, skipSync: boolean): string; + rgblib_get_asset_balance(wallet: Opaque, assetId: string): string; + rgblib_create_utxos( + wallet: Opaque, + online: Opaque, + upTo: boolean, + num: string, + size: string, + feeRate: string, + skipSync: boolean, + ): string; + rgblib_issue_asset_nia( + wallet: Opaque, + ticker: string, + name: string, + precision: string, + amounts: string, + ): string; + rgblib_blind_receive( + wallet: Opaque, + assetId: string | null, + assignment: string, + expiration: string, + transportEndpoints: string, + minConfirmations: string, + ): string; + rgblib_send( + wallet: Opaque, + online: Opaque, + recipientMap: string, + donation: boolean, + feeRate: string, + minConfirmations: string, + expirationTimestamp: string, + skipSync: boolean, + ): string; + rgblib_refresh( + wallet: Opaque, + online: Opaque, + assetId: string | null, + filter: string, + skipSync: boolean, + ): string; + free_wallet(wallet: Opaque): void; + free_online(online: Opaque): void; +} + +interface Counterparty { + lib: CounterpartyLib; + wallet: Opaque; + online: Opaque; + address: string; +} + +function openCounterparty(): Counterparty { + const lib = loadNativeRgbLib() as unknown as CounterpartyLib; + const keysJson = lib.rgblib_generate_keys('Regtest'); + const keys = JSON.parse(keysJson) as Record; + const dataDir = mkdtempSync(join(tmpdir(), 'utexo-onchain-cp-')); + const walletData = JSON.stringify({ + dataDir, + bitcoinNetwork: 'Regtest', + databaseType: 'Sqlite', + maxAllocationsPerUtxo: '1', + accountXpubVanilla: keys['accountXpubVanilla'], + accountXpubColored: keys['accountXpubColored'], + vanillaKeychain: null, + supportedSchemas: ['Nia', 'Cfa', 'Uda'], + }); + const wallet = lib.rgblib_new_wallet( + walletData, + JSON.stringify({ ...(JSON.parse(keysJson) as object), vanillaKeychain: null }), + ); + const online = lib.rgblib_go_online(wallet, false, ELECTRUM_URL); + const address = lib.rgblib_get_address(wallet); + return { lib, wallet, online, address }; +} + +async function retryUntil( + probe: () => Promise, + timeoutMs: number, + intervalMs = 2000, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const value = await probe(); + if (value !== undefined) return value; + if (Date.now() > deadline) throw new Error('condition not reached before timeout'); + await sleep(intervalMs); + } +} + +describe.runIf(process.env['WALLET_REGTEST'] === '1')('on-chain flows on regtest', () => { + let app: FastifyInstance; + let user: TestUser; + let cp: Counterparty; + let assetId: string; + + beforeAll(async () => { + app = await testServer({ + configOverrides: { + walletsDir: mkdtempSync(join(tmpdir(), 'utexo-onchain-it-')), + walletIndexerUrl: ELECTRUM_URL, + rgbProxyUrl: PROXY_URL, + }, + }); + user = await createTestUser(app); + const xpubs = restoreKeysForTests('Regtest', FIXTURE_MNEMONIC); + const registered = await app.inject({ + method: 'POST', + url: '/v1/wallet/xpubs', + headers: authed(), + payload: { vanilla: xpubs.vanilla, colored: xpubs.colored, fingerprint: xpubs.fingerprint }, + }); + expect(registered.statusCode).toBe(201); + const address = (registered.json() as { address: string }).address; + + // Faucet-fund the user's vault and wait for the watch-only wallet to see it. + regtest(`sendtoaddress ${address} 1`); + regtest('mine 1'); + await retryUntil(async () => { + await gateway('POST', '/v1/wallet/sync'); + const balances = (await gateway('GET', '/v1/wallet/balances')) as { + btc: { vanilla: { settled: number } }; + }; + return balances.btc.vanilla.settled >= 100_000_000 ? true : undefined; + }, 60_000); + + cp = openCounterparty(); + }, 180_000); + + afterAll(async () => { + if (cp !== undefined) { + cp.lib.free_online(cp.online); + cp.lib.free_wallet(cp.wallet); + } + await app?.close(); + }); + + function authed() { + return { authorization: `Bearer ${user.token}` }; + } + + let keyCounter = 0; + async function gateway(method: 'GET' | 'POST', url: string, payload?: object): Promise { + keyCounter += 1; + const response = await app.inject({ + method, + url, + headers: { ...authed(), 'idempotency-key': `it-${Date.now()}-${keyCounter}` }, + ...(payload !== undefined ? { payload } : {}), + }); + if (response.statusCode >= 400) { + throw new Error(`${method} ${url} -> ${response.statusCode} ${response.body}`); + } + return response.json(); + } + + function cpRefresh(): void { + cp.lib.rgblib_refresh(cp.wallet, cp.online, null, JSON.stringify([]), false); + } + + it('send-btc: prepare, reject unsigned, sign client-side, complete, funds arrive', async () => { + const prepared = (await gateway('POST', '/v1/onchain/send-btc/prepare', { + address: cp.address, + amountSat: 1_000_000, + feeRateSatPerVb: 2, + })) as { opId: string; psbt: string; intent: { recipients: { scriptHex: string }[] } }; + expect(prepared.psbt.length).toBeGreaterThan(100); + expect(prepared.intent.recipients[0]!.scriptHex).toMatch(/^5120[0-9a-f]{64}$/); + + // Unsigned completion must fail via rgb-lib's own finalize error, not a 500. + const unsigned = await app.inject({ + method: 'POST', + url: '/v1/onchain/send-btc/complete', + headers: { ...authed(), 'idempotency-key': `it-tamper-${Date.now()}` }, + payload: { opId: prepared.opId, signedPsbt: prepared.psbt }, + }); + expect(unsigned.statusCode).toBe(400); + expect((unsigned.json() as { error: { code: string } }).error.code).toBe('PSBT_REJECTED'); + + const signedPsbt = signPsbtWithMaster(master, prepared.psbt); + const completed = (await gateway('POST', '/v1/onchain/send-btc/complete', { + opId: prepared.opId, + signedPsbt, + })) as { txid: string }; + expect(completed.txid).toMatch(/^[0-9a-f]{64}$/); + + regtest('mine 1'); + await retryUntil(async () => { + const balance = JSON.parse(cp.lib.rgblib_get_btc_balance(cp.wallet, cp.online, false)) as { + vanilla: { settled: number }; + }; + return balance.vanilla.settled >= 1_000_000 ? true : undefined; + }, 60_000); + }, 120_000); + + it('create-utxos: prepare, sign client-side, complete; colorable utxos appear', async () => { + const prepared = (await gateway('POST', '/v1/onchain/create-utxos/prepare', { + num: 5, + size: 5000, + })) as { opId: string; psbt: string }; + const signedPsbt = signPsbtWithMaster(master, prepared.psbt); + const completed = (await gateway('POST', '/v1/onchain/create-utxos/complete', { + opId: prepared.opId, + signedPsbt, + })) as { txid: string | null; utxosCreated: number }; + expect(completed.utxosCreated).toBe(5); + expect(completed.txid).toMatch(/^[0-9a-f]{64}$/); + + regtest('mine 1'); + await retryUntil(async () => { + await gateway('POST', '/v1/wallet/sync'); + const { unspents } = (await gateway('GET', '/v1/wallet/unspents')) as { + unspents: { colorable: boolean }[]; + }; + return unspents.filter((u) => u.colorable).length >= 5 ? true : undefined; + }, 60_000); + }, 120_000); + + it('receives a real RGB asset from the counterparty into the vault', async () => { + // Counterparty side: colorable utxos + NIA issuance (no signing needed). + cp.lib.rgblib_create_utxos(cp.wallet, cp.online, false, '5', '5000', '2', false); + regtest('mine 1'); + const issued = JSON.parse( + cp.lib.rgblib_issue_asset_nia(cp.wallet, 'ITEST', 'Integration Test', '0', '["1000"]'), + ) as { assetId: string }; + assetId = issued.assetId; + expect(assetId.length).toBeGreaterThan(10); + + const receive = (await gateway('POST', '/v1/wallet/receive', { mode: 'blind' })) as { + recipientId: string; + }; + const recipientMap = JSON.stringify({ + [assetId]: [ + { + recipientId: receive.recipientId, + witnessData: null, + assignment: { Fungible: 100 }, + transportEndpoints: [PROXY_URL], + }, + ], + }); + const sent = JSON.parse( + cp.lib.rgblib_send( + cp.wallet, + cp.online, + recipientMap, + false, + '2', + '1', + String(Math.floor(Date.now() / 1000) + 3600), + false, + ), + ) as { txid: string }; + expect(sent.txid).toMatch(/^[0-9a-f]{64}$/); + + // Settlement dance (donation=false): receiver ACKs via refresh, sender's + // refresh then broadcasts, and a confirmation settles both sides — so + // sync/refresh/mine run inside the poll loop. + await retryUntil(async () => { + await gateway('POST', '/v1/wallet/sync'); + cpRefresh(); + const balances = (await gateway('GET', '/v1/wallet/balances')) as { + assets: { assetId: string; balance: { settled: number } }[]; + }; + const asset = balances.assets.find((entry) => entry.assetId === assetId); + if (asset !== undefined && asset.balance.settled >= 100) return true; + regtest('mine 1'); + return undefined; + }, 90_000); + }, 180_000); + + it('send-asset: prepare, sign client-side (colored keychain), complete, settle', async () => { + const back = JSON.parse( + cp.lib.rgblib_blind_receive( + cp.wallet, + null, + JSON.stringify('Any'), + String(Math.floor(Date.now() / 1000) + 3600), + JSON.stringify([PROXY_URL]), + '1', + ), + ) as { recipientId: string }; + + const prepared = (await gateway('POST', '/v1/onchain/send-asset/prepare', { + assetId, + amount: 40, + recipientId: back.recipientId, + transportEndpoints: [PROXY_URL], + })) as { opId: string; psbt: string; intent: { asset: { amount: number } } }; + expect(prepared.intent.asset.amount).toBe(40); + + const signedPsbt = signPsbtWithMaster(master, prepared.psbt); + const completed = (await gateway('POST', '/v1/onchain/send-asset/complete', { + opId: prepared.opId, + signedPsbt, + })) as { txid: string }; + expect(completed.txid).toMatch(/^[0-9a-f]{64}$/); + + // Ownership recorded for I3 scoping. + const owned = app.db + .prepare('SELECT user_id, state FROM resource_map WHERE kind = ? AND resource_id = ?') + .get('asset_transfer', completed.txid) as { user_id: string; state: string }; + expect(owned).toEqual({ user_id: user.userId, state: 'sent' }); + + // Same ACK dance in reverse: cp ACKs, the user's refresh broadcasts. + await retryUntil(async () => { + cpRefresh(); + await gateway('POST', '/v1/wallet/sync'); + const balance = JSON.parse(cp.lib.rgblib_get_asset_balance(cp.wallet, assetId)) as { + settled: number; + }; + if (balance.settled >= 40) return true; + regtest('mine 1'); + return undefined; + }, 90_000); + }, 180_000); +}); diff --git a/minimal-sdk/packages/gateway/test/onchain-routes.test.ts b/minimal-sdk/packages/gateway/test/onchain-routes.test.ts new file mode 100644 index 00000000..ca09a68f --- /dev/null +++ b/minimal-sdk/packages/gateway/test/onchain-routes.test.ts @@ -0,0 +1,550 @@ +/** + * On-chain prepare/complete route tests with a mocked wallet backend: intent + * summaries, pending-op lifecycle (TTL, kind, ownership), idempotency, and + * clean error mapping for rejected PSBTs. + */ +import type { FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { base64, hex } from '@scure/base'; +import { Transaction } from '@scure/btc-signer'; +import { WalletBackendError } from '../src/wallets/backend.js'; +import { txidFromPsbt } from '../src/wallets/prepare.js'; +import { createTestUser, testServer, type TestUser } from './helpers.js'; +import { MockWalletBackend } from './wallet-mocks.js'; + +const XPUBS = { + vanilla: + 'tpubDDfvzhdVV4unsoKt5aE6dcsNsfeWbTgmLZPi8LQDYU2xixrYemMfWJ3BaVneH3u7DBQePdTwhpybaKRU95pi6PMUtLPBJLVQRpzEnjfjZzX', + colored: + 'tpubDCtpoJs6YJcjLnr9gq6jYriYNMuWEu8mSDvEQU5st3ZkJbFqqzwpHUiPvxqD2366ciFAfpehk1k2d7Tyk7AJEr8uZva7KfnX4RpsiVSoEcZ', + fingerprint: '73c5da0a', +}; + +const RECIPIENT_ADDRESS = 'bcrt1p8wpt9v4frpf3tkn0srd97pksgsxc5hs52lafxwru9kgeephvs7rqjeprhg'; +// Independently computed p2tr output script for RECIPIENT_ADDRESS (bech32m). +const RECIPIENT_SCRIPT_HEX = '51203b82b2b2a9185315da6f80da5f06d0440d8a5e1457fa93387c2d919c86ec8786'; +const SIGNED_PSBT = 'cHNidP8BAAAAAAAAAAAAAAAAAAAAAAAA'; +const ASSET_ID = 'rgb:aaaaaaa-bbbbbbb-ccccccc'; + +/** Minimal parsable PSBT spending outpoint `prevTxidHexChar.repeat(64)`:0. */ +function parsablePsbt(prevTxidHexChar: string): string { + const tx = new Transaction({ allowUnknownInputs: true, allowUnknownOutputs: true }); + tx.addInput({ txid: prevTxidHexChar.repeat(64), index: 0 }); + tx.addOutput({ script: hex.decode(RECIPIENT_SCRIPT_HEX), amount: 40_000n }); + return base64.encode(tx.toPSBT()); +} +const PARSABLE_PSBT_A = parsablePsbt('a'); +const PARSABLE_PSBT_B = parsablePsbt('b'); + +describe('on-chain prepare/complete routes', () => { + let app: FastifyInstance; + let backend: MockWalletBackend; + let user: TestUser; + + beforeEach(async () => { + backend = new MockWalletBackend(); + app = await testServer({ walletBackend: backend }); + user = await createTestUser(app); + const registered = await app.inject({ + method: 'POST', + url: '/v1/wallet/xpubs', + headers: { authorization: `Bearer ${user.token}` }, + payload: XPUBS, + }); + expect(registered.statusCode).toBe(201); + }); + + afterEach(async () => { + await app.close(); + }); + + function headers(options: { token?: string; key?: string } = {}) { + return { + authorization: `Bearer ${options.token ?? user.token}`, + 'idempotency-key': options.key ?? randomUUID(), + }; + } + + async function prepareSendBtc(options: { key?: string; payload?: object; token?: string } = {}) { + return app.inject({ + method: 'POST', + url: '/v1/onchain/send-btc/prepare', + headers: headers(options), + payload: options.payload ?? { address: RECIPIENT_ADDRESS, amountSat: 40_000 }, + }); + } + + async function complete( + flow: string, + opId: string, + options: { key?: string; token?: string; signedPsbt?: string } = {}, + ) { + return app.inject({ + method: 'POST', + url: `/v1/onchain/${flow}/complete`, + headers: headers(options), + payload: { opId, signedPsbt: options.signedPsbt ?? SIGNED_PSBT }, + }); + } + + function opRow(opId: string) { + return app.db.prepare('SELECT * FROM pending_ops WHERE id = ?').get(opId) as + { state: string; txid: string | null; user_id: string; kind: string } | undefined; + } + + describe('auth and idempotency requirements', () => { + const routes = [ + '/v1/onchain/send-btc/prepare', + '/v1/onchain/send-btc/complete', + '/v1/onchain/send-asset/prepare', + '/v1/onchain/send-asset/complete', + '/v1/onchain/create-utxos/prepare', + '/v1/onchain/create-utxos/complete', + ]; + + it('rejects unauthenticated calls on every route', async () => { + for (const url of routes) { + const response = await app.inject({ method: 'POST', url, payload: {} }); + expect(response.statusCode, url).toBe(401); + } + }); + + it('requires an Idempotency-Key on every route', async () => { + const validBodies: Record = { + '/v1/onchain/send-btc/prepare': { address: RECIPIENT_ADDRESS, amountSat: 40_000 }, + '/v1/onchain/send-btc/complete': { opId: randomUUID(), signedPsbt: SIGNED_PSBT }, + '/v1/onchain/send-asset/prepare': { assetId: ASSET_ID, amount: 1, recipientId: 'r1' }, + '/v1/onchain/send-asset/complete': { opId: randomUUID(), signedPsbt: SIGNED_PSBT }, + '/v1/onchain/create-utxos/prepare': {}, + '/v1/onchain/create-utxos/complete': { opId: randomUUID(), signedPsbt: SIGNED_PSBT }, + }; + for (const url of routes) { + const response = await app.inject({ + method: 'POST', + url, + headers: { authorization: `Bearer ${user.token}` }, + payload: validBodies[url], + }); + expect(response.statusCode, url).toBe(400); + expect(response.json().error.code, url).toBe('IDEMPOTENCY_KEY_REQUIRED'); + } + }); + }); + + describe('send-btc', () => { + it('prepares with an intent summary built from request inputs', async () => { + const before = Date.now(); + const response = await prepareSendBtc(); + expect(response.statusCode).toBe(201); + const body = response.json(); + expect(body.psbt).toBe(`mock-psbt-${user.userId}`); + expect(body.intent).toEqual({ + kind: 'send_btc', + feeRateSatPerVb: 2, + recipients: [ + { + address: RECIPIENT_ADDRESS, + scriptHex: RECIPIENT_SCRIPT_HEX, + amountSat: 40_000, + }, + ], + asset: null, + utxos: null, + }); + expect(body.expiresAt).toBeGreaterThanOrEqual(before + 600_000); + expect(opRow(body.opId)?.state).toBe('pending'); + expect(backend.handleFor(user.userId)?.lastSendBtcArgs).toEqual({ + address: RECIPIENT_ADDRESS, + amountSat: 40_000, + feeRateSatPerVb: 2, + }); + }); + + it('honors an explicit fee rate', async () => { + const response = await prepareSendBtc({ + payload: { address: RECIPIENT_ADDRESS, amountSat: 40_000, feeRateSatPerVb: 7 }, + }); + expect(response.json().intent.feeRateSatPerVb).toBe(7); + expect(backend.handleFor(user.userId)?.lastSendBtcArgs?.feeRateSatPerVb).toBe(7); + }); + + it('rejects a malformed and a wrong-network address with 400 INVALID_ADDRESS', async () => { + for (const address of [ + 'notanaddressnotanaddress', + 'bc1p8wpt9v4frpf3tkn0srd97pksgsxc5hs52lafxwru9kgeephvs7rqu9khtt', + ]) { + const response = await prepareSendBtc({ payload: { address, amountSat: 40_000 } }); + expect(response.statusCode, address).toBe(400); + expect(response.json().error.code, address).toBe('INVALID_ADDRESS'); + } + }); + + it('maps insufficient funds at prepare time to 400 without leaking detail', async () => { + const handle = backend.handleFor(user.userId); + expect(handle).toBeDefined(); + handle!.failWith = new WalletBackendError('boom', 'InsufficientBitcoins { needed: 1 }', true); + const response = await prepareSendBtc(); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('INSUFFICIENT_FUNDS'); + expect(response.body).not.toContain('InsufficientBitcoins'); + }); + + it('maps a client-caused rgb-lib rejection at prepare time to 400, not 500', async () => { + const handle = backend.handleFor(user.userId); + expect(handle).toBeDefined(); + handle!.failWith = new WalletBackendError( + 'wallet sendAssetBegin failed', + 'RgbLib(InvalidRecipientNetwork)', + false, + true, + ); + const response = await prepareSendBtc(); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('WALLET_REQUEST_REJECTED'); + // The rgb-lib detail stays server-side. + expect(response.body).not.toContain('InvalidRecipientNetwork'); + }); + + it('still reports an unclassified wallet failure as 500 INTERNAL', async () => { + const handle = backend.handleFor(user.userId); + handle!.failWith = new WalletBackendError( + 'wallet sendBtcBegin failed', + 'RgbLib(Internal { details: "stash corrupted" })', + ); + const response = await prepareSendBtc(); + expect(response.statusCode).toBe(500); + expect(response.json().error.code).toBe('INTERNAL'); + expect(response.body).not.toContain('stash corrupted'); + }); + + it('completes with the signed PSBT and reports the broadcast txid', async () => { + const { opId } = (await prepareSendBtc()).json(); + const response = await complete('send-btc', opId); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ txid: 'mock-btc-txid' }); + expect(backend.handleFor(user.userId)?.lastSignedPsbt).toBe(SIGNED_PSBT); + expect(opRow(opId)).toMatchObject({ state: 'completed', txid: 'mock-btc-txid' }); + }); + + it('replays an identical complete idempotently and refuses re-completion', async () => { + const { opId } = (await prepareSendBtc()).json(); + const key = randomUUID(); + const first = await complete('send-btc', opId, { key }); + expect(first.statusCode).toBe(200); + const replay = await complete('send-btc', opId, { key }); + expect(replay.statusCode).toBe(200); + expect(replay.headers['x-idempotent-replay']).toBe('true'); + expect(replay.json()).toEqual({ txid: 'mock-btc-txid' }); + // The wallet ran exactly once. + const ends = backend + .handleFor(user.userId)! + .operations.filter((op) => op === 'sendBtcEnd').length; + expect(ends).toBe(1); + const again = await complete('send-btc', opId); + expect(again.statusCode).toBe(409); + expect(again.json().error.code).toBe('OP_ALREADY_COMPLETED'); + }); + + it('replays an identical prepare idempotently (same opId, no second wallet call)', async () => { + const key = randomUUID(); + const first = (await prepareSendBtc({ key })).json(); + const replay = await prepareSendBtc({ key }); + expect(replay.json().opId).toBe(first.opId); + expect(replay.headers['x-idempotent-replay']).toBe('true'); + const begins = backend + .handleFor(user.userId)! + .operations.filter((op) => op === 'sendBtcBegin').length; + expect(begins).toBe(1); + }); + + it('rejects reuse of an idempotency key with a different request', async () => { + const key = randomUUID(); + await prepareSendBtc({ key }); + const conflict = await prepareSendBtc({ + key, + payload: { address: RECIPIENT_ADDRESS, amountSat: 50_000 }, + }); + expect(conflict.statusCode).toBe(409); + expect(conflict.json().error.code).toBe('IDEMPOTENCY_KEY_REUSED'); + }); + + it('hides another user’s op (404, not 409/410)', async () => { + const { opId } = (await prepareSendBtc()).json(); + const stranger = await createTestUser(app); + await app.inject({ + method: 'POST', + url: '/v1/wallet/xpubs', + headers: { authorization: `Bearer ${stranger.token}` }, + payload: { ...XPUBS, fingerprint: 'deadbeef' }, + }); + const response = await complete('send-btc', opId, { token: stranger.token }); + expect(response.statusCode).toBe(404); + expect(response.json().error.code).toBe('OP_NOT_FOUND'); + expect(opRow(opId)?.state).toBe('pending'); + }); + + it('expires a stale op with 410 and marks it expired', async () => { + const { opId } = (await prepareSendBtc()).json(); + app.db + .prepare('UPDATE pending_ops SET expires_at = ? WHERE id = ?') + .run(Date.now() - 1, opId); + const response = await complete('send-btc', opId); + expect(response.statusCode).toBe(410); + expect(response.json().error.code).toBe('OP_EXPIRED'); + expect(opRow(opId)?.state).toBe('expired'); + }); + + it('rejects completion through the wrong flow with 409 OP_KIND_MISMATCH', async () => { + const { opId } = (await prepareSendBtc()).json(); + const response = await complete('create-utxos', opId); + expect(response.statusCode).toBe(409); + expect(response.json().error.code).toBe('OP_KIND_MISMATCH'); + }); + + it('maps a wallet-rejected PSBT to a clean 400 and keeps the op pending', async () => { + const { opId } = (await prepareSendBtc()).json(); + const handle = backend.handleFor(user.userId)!; + handle.failWith = new WalletBackendError( + 'wallet sendBtcEnd failed', + 'RgbLib BdkError InvalidPsbt: signature verification failed', + ); + const rejected = await complete('send-btc', opId); + expect(rejected.statusCode).toBe(400); + expect(rejected.json().error.code).toBe('PSBT_REJECTED'); + expect(rejected.body).not.toContain('RgbLib'); + expect(opRow(opId)?.state).toBe('pending'); + // A corrected retry (fresh key) succeeds against the same op. + handle.failWith = undefined; + const retry = await complete('send-btc', opId); + expect(retry.statusCode).toBe(200); + expect(opRow(opId)?.state).toBe('completed'); + }); + + it('maps a wallet failure that may have broadcast to 502 COMPLETE_AMBIGUOUS', async () => { + backend.dataFor = () => ({ preparedPsbt: PARSABLE_PSBT_A }); + app.walletPool.evict(user.userId); // reopen so the fixture PSBT applies + const { opId } = (await prepareSendBtc()).json(); + const handle = backend.handleFor(user.userId)!; + // rgb-lib broadcasts BEFORE it writes its bookkeeping, so a DB/IO failure + // here can belong to a transaction that is already on the network: + // blaming the signature would be a lie, and 4xx would cache it forever. + handle.failWith = new WalletBackendError( + 'wallet sendBtcEnd failed', + 'RgbLib(Database { details: "error returned from database: disk I/O error" })', + ); + const ambiguous = await complete('send-btc', opId, { signedPsbt: PARSABLE_PSBT_A }); + expect(ambiguous.statusCode).toBe(502); + expect(ambiguous.json().error.code).toBe('COMPLETE_AMBIGUOUS'); + expect(ambiguous.body).not.toContain('RgbLib'); + // Op stays pending with the txid recorded, so a retry can finish the + // bookkeeping and an operator can find the transaction either way. + const row = opRow(opId); + expect(row?.state).toBe('pending'); + expect(row?.txid).toBe(txidFromPsbt(PARSABLE_PSBT_A)); + expect(row?.txid).toMatch(/^[0-9a-f]{64}$/); + + handle.failWith = undefined; + const retry = await complete('send-btc', opId, { signedPsbt: PARSABLE_PSBT_A }); + expect(retry.statusCode).toBe(200); + expect(opRow(opId)?.state).toBe('completed'); + }); + + it('maps a no-longer-affordable completion to 400 INSUFFICIENT_FUNDS', async () => { + const { opId } = (await prepareSendBtc()).json(); + backend.handleFor(user.userId)!.failWith = new WalletBackendError( + 'wallet sendBtcEnd failed', + 'InsufficientBitcoins { needed: 1, available: 0 }', + true, + ); + const response = await complete('send-btc', opId); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('INSUFFICIENT_FUNDS'); + expect(response.body).not.toContain('InsufficientBitcoins'); + }); + + it('rejects a signed PSBT that belongs to a different prepared transaction', async () => { + backend.dataFor = () => ({ preparedPsbt: PARSABLE_PSBT_A }); + app.walletPool.evict(user.userId); // reopen so the fixture PSBT applies + const { opId } = (await prepareSendBtc()).json(); + const mismatch = await complete('send-btc', opId, { signedPsbt: PARSABLE_PSBT_B }); + expect(mismatch.statusCode).toBe(400); + expect(mismatch.json().error.code).toBe('PSBT_MISMATCH'); + expect(opRow(opId)?.state).toBe('pending'); + // The matching PSBT still completes the op. + const ok = await complete('send-btc', opId, { signedPsbt: PARSABLE_PSBT_A }); + expect(ok.statusCode).toBe(200); + expect(opRow(opId)?.state).toBe('completed'); + }); + + it('rejects an unparsable signed PSBT instead of skipping the binding check', async () => { + backend.dataFor = () => ({ preparedPsbt: PARSABLE_PSBT_A }); + app.walletPool.evict(user.userId); + const { opId } = (await prepareSendBtc()).json(); + // Well-formed base64 that is not a PSBT: passes the schema, so the + // binding check itself must reject it rather than treat an unparsable + // txid as “no opinion”. + const garbage = await complete('send-btc', opId, { signedPsbt: 'A'.repeat(24) }); + expect(garbage.statusCode).toBe(400); + expect(garbage.json().error.code).toBe('PSBT_MISMATCH'); + expect(opRow(opId)?.state).toBe('pending'); + // Non-base64 never reaches the service: the request schema rejects it. + const nonBase64 = await complete('send-btc', opId, { signedPsbt: 'not-a-psbt-at-all!!!!' }); + expect(nonBase64.statusCode).toBe(400); + expect(nonBase64.json().error.code).toBe('BAD_REQUEST'); + expect(opRow(opId)?.state).toBe('pending'); + }); + + it('retires the user’s stale pending ops lazily on every prepare', async () => { + const { opId: staleId } = (await prepareSendBtc()).json(); + app.db + .prepare('UPDATE pending_ops SET expires_at = ? WHERE id = ?') + .run(Date.now() - 1, staleId); + const { opId: freshId } = (await prepareSendBtc()).json(); + expect(opRow(staleId)?.state).toBe('expired'); + expect(opRow(freshId)?.state).toBe('pending'); + }); + }); + + describe('send-asset', () => { + async function prepareSendAsset(payload: object = {}) { + return app.inject({ + method: 'POST', + url: '/v1/onchain/send-asset/prepare', + headers: headers(), + payload: { + assetId: ASSET_ID, + amount: 25, + recipientId: 'utxob:fixture-recipient', + ...payload, + }, + }); + } + + it('rejects a fractional asset amount at the schema layer', async () => { + // An RGB fungible amount deserializes into a u64 on the rgb-lib side, so + // a fractional one fails there and surfaces as an unclassified 500. + const response = await prepareSendAsset({ amount: 1.5 }); + expect(response.statusCode).toBe(400); + }); + + it('prepares with asset intent and gateway-controlled defaults', async () => { + const response = await prepareSendAsset(); + expect(response.statusCode).toBe(201); + const body = response.json(); + expect(body.intent).toEqual({ + kind: 'send_asset', + feeRateSatPerVb: 2, + recipients: [], + asset: { + assetId: ASSET_ID, + amount: 25, + recipientId: 'utxob:fixture-recipient', + witnessAmountSat: null, + transportEndpoints: [app.gatewayConfig.rgbProxyUrl], + }, + utxos: null, + }); + const request = backend.handleFor(user.userId)?.lastSendAssetRequest; + expect(request).toMatchObject({ + assetId: ASSET_ID, + amount: 25, + donation: false, + minConfirmations: 1, + witnessAmountSat: null, + }); + // The wallet-side pending transfer expires together with the op. + expect(request!.expirationTimestamp).toBe(Math.floor(body.expiresAt / 1000)); + }); + + it('passes through witness data and pinned allowlisted transport endpoints', async () => { + const allowlisted = app.gatewayConfig.rgbProxyUrl; + const response = await prepareSendAsset({ + witnessAmountSat: 1000, + transportEndpoints: [allowlisted], + }); + expect(response.json().intent.asset).toMatchObject({ + witnessAmountSat: 1000, + transportEndpoints: [allowlisted], + }); + }); + + it('rejects transport endpoints outside the operator allowlist (SSRF guard)', async () => { + const response = await prepareSendAsset({ + transportEndpoints: ['rpc://attacker.internal/json-rpc'], + }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('TRANSPORT_ENDPOINT_NOT_ALLOWED'); + }); + + it('records the completed transfer in resource_map for scoping', async () => { + const { opId } = (await prepareSendAsset()).json(); + const response = await complete('send-asset', opId); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ txid: 'mock-asset-txid' }); + const owned = app.db + .prepare('SELECT user_id, state FROM resource_map WHERE kind = ? AND resource_id = ?') + .get('asset_transfer', 'mock-asset-txid') as { user_id: string; state: string }; + expect(owned).toEqual({ user_id: user.userId, state: 'sent' }); + }); + }); + + describe('create-utxos', () => { + it('prepares with defaults and completes reporting count and txid', async () => { + const prepared = await app.inject({ + method: 'POST', + url: '/v1/onchain/create-utxos/prepare', + headers: headers(), + payload: {}, + }); + expect(prepared.statusCode).toBe(201); + const body = prepared.json(); + expect(body.intent).toEqual({ + kind: 'create_utxos', + feeRateSatPerVb: 2, + recipients: [], + asset: null, + utxos: { upTo: false, num: 5, size: 1000 }, + }); + expect(backend.handleFor(user.userId)?.lastCreateUtxosParams).toEqual({ + upTo: false, + num: 5, + size: 1000, + feeRateSatPerVb: 2, + }); + const response = await complete('create-utxos', body.opId); + expect(response.statusCode).toBe(200); + // SIGNED_PSBT is not a parsable PSBT, so the txid is null (best-effort). + expect(response.json()).toEqual({ txid: null, utxosCreated: 5 }); + }); + + it('honors explicit num/size/upTo', async () => { + const prepared = await app.inject({ + method: 'POST', + url: '/v1/onchain/create-utxos/prepare', + headers: headers(), + payload: { num: 3, size: 32_000, upTo: true }, + }); + expect(prepared.json().intent.utxos).toEqual({ upTo: true, num: 3, size: 32_000 }); + }); + }); + + describe('input validation', () => { + it('rejects a non-base64 signed PSBT at the schema layer', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/onchain/send-btc/complete', + headers: headers(), + payload: { opId: randomUUID(), signedPsbt: 'not base64 !!!' }, + }); + expect(response.statusCode).toBe(400); + }); + + it('rejects a prepare for an unregistered wallet with 404', async () => { + const stranger = await createTestUser(app); + const response = await prepareSendBtc({ token: stranger.token }); + expect(response.statusCode).toBe(404); + expect(response.json().error.code).toBe('WALLET_NOT_REGISTERED'); + }); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/onchain-signer.test.ts b/minimal-sdk/packages/gateway/test/onchain-signer.test.ts new file mode 100644 index 00000000..dfe7838d --- /dev/null +++ b/minimal-sdk/packages/gateway/test/onchain-signer.test.ts @@ -0,0 +1,197 @@ +/** + * Full prepare→sign→complete round-trip in-process with a fixture signer: + * the mocked backend serves a REAL taproot PSBT (key origins included, as + * BDK/rgb-lib produce), the "client" signs it from a test-only mnemonic via + * @scure — exactly what the Task-6 SDK will do — and completion verifies a + * finalized schnorr signature is present before reporting the txid. + * + * The fixture mnemonic is test-only: mnemonics are never a gateway input. + */ +import type { FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import type { HDKey } from '@scure/bip32'; +import { base64, hex } from '@scure/base'; +import { p2tr, Transaction } from '@scure/btc-signer'; +import { WalletBackendError } from '../src/wallets/backend.js'; +import { masterFromMnemonic, signPsbtWithMaster, PSBT_PARSE_OPTIONS } from './fixture-signer.js'; +import { createTestUser, testServer, type TestUser } from './helpers.js'; +import { MockWalletBackend } from './wallet-mocks.js'; + +const FIXTURE_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; +const REGTEST = { bech32: 'bcrt', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }; +/** Vanilla keychain account for regtest (BIP-86, coin type 1). */ +const ACCOUNT_PATH = "m/86'/1'/0'"; + +const XPUBS = { + vanilla: + 'tpubDDfvzhdVV4unsoKt5aE6dcsNsfeWbTgmLZPi8LQDYU2xixrYemMfWJ3BaVneH3u7DBQePdTwhpybaKRU95pi6PMUtLPBJLVQRpzEnjfjZzX', + colored: + 'tpubDCtpoJs6YJcjLnr9gq6jYriYNMuWEu8mSDvEQU5st3ZkJbFqqzwpHUiPvxqD2366ciFAfpehk1k2d7Tyk7AJEr8uZva7KfnX4RpsiVSoEcZ', + fingerprint: '73c5da0a', +}; + +const master = masterFromMnemonic(FIXTURE_MNEMONIC); + +function accountKey(change: number, index: number): HDKey { + return master.derive(`${ACCOUNT_PATH}/${change}/${index}`); +} + +function xOnly(key: HDKey): Uint8Array { + return key.publicKey!.slice(1); +} + +function pathOf(change: number, index: number): number[] { + const h = 0x80000000; + return [86 + h, 1 + h, 0 + h, change, index]; +} + +/** + * Build the unsigned PSBT the wallet service would prepare: one own p2tr + * input (with key origin), one recipient output, one own change output. + */ +function buildUnsignedPsbt(recipientScriptHex: string, amountSat: bigint): string { + const own = accountKey(0, 0); + const ownSpend = p2tr(xOnly(own), undefined, REGTEST); + const change = accountKey(1, 0); + const changeSpend = p2tr(xOnly(change), undefined, REGTEST); + const tx = new Transaction({ allowUnknownOutputs: true }); + tx.addInput({ + txid: new Uint8Array(32).fill(7), + index: 0, + witnessUtxo: { script: ownSpend.script, amount: 100_000n }, + tapInternalKey: xOnly(own), + tapBip32Derivation: [ + [xOnly(own), { hashes: [], der: { fingerprint: master.fingerprint, path: pathOf(0, 0) } }], + ], + }); + tx.addOutput({ script: hex.decode(recipientScriptHex), amount: amountSat }); + tx.addOutput({ + script: changeSpend.script, + amount: 100_000n - amountSat - 500n, + }); + return base64.encode(tx.toPSBT()); +} + +function signPsbt(psbtBase64: string): string { + return signPsbtWithMaster(master, psbtBase64); +} + +describe('prepare→sign→complete round-trip with a fixture signer', () => { + let app: FastifyInstance; + let backend: MockWalletBackend; + let user: TestUser; + const recipientScriptHex = '51203b82b2b2a9185315da6f80da5f06d0440d8a5e1457fa93387c2d919c86ec8786'; + const recipientAddress = 'bcrt1p8wpt9v4frpf3tkn0srd97pksgsxc5hs52lafxwru9kgeephvs7rqjeprhg'; + + beforeEach(async () => { + backend = new MockWalletBackend(); + app = await testServer({ walletBackend: backend }); + user = await createTestUser(app); + await app.inject({ + method: 'POST', + url: '/v1/wallet/xpubs', + headers: { authorization: `Bearer ${user.token}` }, + payload: XPUBS, + }); + }); + + afterEach(async () => { + await app.close(); + }); + + function headers() { + return { + authorization: `Bearer ${user.token}`, + 'idempotency-key': randomUUID(), + }; + } + + it('signs the prepared PSBT from PSBT key origins and completes with the real txid', async () => { + const unsignedPsbt = buildUnsignedPsbt(recipientScriptHex, 40_000n); + const expectedTxid = Transaction.fromPSBT(base64.decode(unsignedPsbt), PSBT_PARSE_OPTIONS).id; + backend.dataFor = () => ({ preparedPsbt: unsignedPsbt }); + // Reopen with fixture data: evict the smoke-open handle by closing all. + await app.walletPool.closeAll(); + + const prepared = await app.inject({ + method: 'POST', + url: '/v1/onchain/send-btc/prepare', + headers: headers(), + payload: { address: recipientAddress, amountSat: 40_000 }, + }); + expect(prepared.statusCode).toBe(201); + const { opId, psbt, intent } = prepared.json(); + expect(psbt).toBe(unsignedPsbt); + + // Client-side: the recipient script in the intent matches the PSBT output. + const parsed = Transaction.fromPSBT(base64.decode(psbt), PSBT_PARSE_OPTIONS); + expect(hex.encode(parsed.getOutput(0).script!)).toBe(intent.recipients[0].scriptHex); + expect(parsed.getOutput(0).amount).toBe(BigInt(intent.recipients[0].amountSat)); + + const signedPsbt = signPsbt(psbt); + + // "Broadcast": assert a finalized 64/65-byte schnorr witness is present. + const handle = backend.handleFor(user.userId)!; + const originalEnd = handle.sendBtcEnd.bind(handle); + handle.sendBtcEnd = async (received: string) => { + const done = Transaction.fromPSBT(base64.decode(received), PSBT_PARSE_OPTIONS); + const witness = done.getInput(0).finalScriptWitness; + if (witness === undefined || witness.length !== 1 || witness[0]!.length < 64) { + throw new WalletBackendError('wallet sendBtcEnd failed', 'missing schnorr signature'); + } + await originalEnd(received); + return done.id; + }; + + const completed = await app.inject({ + method: 'POST', + url: '/v1/onchain/send-btc/complete', + headers: headers(), + payload: { opId, signedPsbt }, + }); + expect(completed.statusCode).toBe(200); + expect(completed.json()).toEqual({ txid: expectedTxid }); + }); + + it('rejects an unsigned PSBT at the finalize boundary as a clean 400', async () => { + const unsignedPsbt = buildUnsignedPsbt(recipientScriptHex, 40_000n); + backend.dataFor = () => ({ preparedPsbt: unsignedPsbt }); + await app.walletPool.closeAll(); + + const prepared = await app.inject({ + method: 'POST', + url: '/v1/onchain/send-btc/prepare', + headers: headers(), + payload: { address: recipientAddress, amountSat: 40_000 }, + }); + const { opId } = prepared.json(); + + const handle = backend.handleFor(user.userId)!; + handle.sendBtcEnd = async (received: string) => { + const done = Transaction.fromPSBT(base64.decode(received), PSBT_PARSE_OPTIONS); + if (done.getInput(0).finalScriptWitness === undefined) { + // What real rgb-lib raises here: extract_tx succeeds on a witness-less + // PSBT, the indexer refuses the transaction, and broadcast_tx confirms + // the txid has no confirmations before reporting FailedBroadcast + // (rgb-lib wallet/online.rs:47-86). + throw new WalletBackendError( + 'wallet sendBtcEnd failed', + 'RgbLib(FailedBroadcast { details: "sendrawtransaction: non-mandatory-script-verify-flag" })', + ); + } + return done.id; + }; + + // Skip signing entirely: send the unsigned PSBT back. + const completed = await app.inject({ + method: 'POST', + url: '/v1/onchain/send-btc/complete', + headers: headers(), + payload: { opId, signedPsbt: unsignedPsbt }, + }); + expect(completed.statusCode).toBe(400); + expect(completed.json().error.code).toBe('PSBT_REJECTED'); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/queue.test.ts b/minimal-sdk/packages/gateway/test/queue.test.ts new file mode 100644 index 00000000..65e13ab2 --- /dev/null +++ b/minimal-sdk/packages/gateway/test/queue.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { QueueFullError, UserQueues } from '../src/queue.js'; +import { sleep } from './helpers.js'; + +describe('UserQueues', () => { + it('runs one user tasks strictly in FIFO order under parallel load', async () => { + const queues = new UserQueues({ globalConcurrency: 8, perUserDepth: 100 }); + const completed: number[] = []; + const tasks: Promise[] = []; + for (let i = 0; i < 25; i++) { + tasks.push( + queues.enqueue('alice', async () => { + // Random delay: with concurrency > 1 across users, only per-user + // chaining can keep this order stable. + await sleep(Math.floor(11 * ((i * 7) % 5)) / 5); + completed.push(i); + }), + ); + } + await Promise.all(tasks); + expect(completed).toEqual([...Array(25).keys()]); + }); + + it('interleaves users but preserves per-user order', async () => { + const queues = new UserQueues({ globalConcurrency: 4, perUserDepth: 100 }); + const perUser = new Map(); + const tasks: Promise[] = []; + for (const user of ['a', 'b', 'c']) perUser.set(user, []); + for (let i = 0; i < 15; i++) { + for (const user of ['a', 'b', 'c']) { + tasks.push( + queues.enqueue(user, async () => { + await sleep((i + user.charCodeAt(0)) % 3); + perUser.get(user)?.push(i); + }), + ); + } + } + await Promise.all(tasks); + for (const order of perUser.values()) { + expect(order).toEqual([...Array(15).keys()]); + } + }); + + it('never exceeds the global concurrency cap', async () => { + const cap = 3; + const queues = new UserQueues({ globalConcurrency: cap, perUserDepth: 100 }); + let running = 0; + let peak = 0; + const tasks: Promise[] = []; + for (let i = 0; i < 40; i++) { + tasks.push( + queues.enqueue(`user-${i}`, async () => { + running += 1; + peak = Math.max(peak, running); + await sleep(2); + running -= 1; + }), + ); + } + await Promise.all(tasks); + expect(peak).toBeLessThanOrEqual(cap); + expect(peak).toBeGreaterThan(1); + }); + + it('rejects with QueueFullError once per-user depth is reached, then recovers', async () => { + const queues = new UserQueues({ globalConcurrency: 2, perUserDepth: 2 }); + let release: () => void = () => {}; + const gate = new Promise((resolve) => (release = resolve)); + const first = queues.enqueue('alice', () => gate); + const second = queues.enqueue('alice', async () => 'second'); + expect(() => queues.enqueue('alice', async () => 'third')).toThrow(QueueFullError); + try { + queues.enqueue('alice', async () => 'fourth'); + } catch (error) { + expect((error as QueueFullError).retryAfterSeconds).toBeGreaterThanOrEqual(1); + } + // A different user must not be affected by another user's full queue. + await expect(queues.enqueue('bob', async () => 'ok')).resolves.toBe('ok'); + release(); + await first; + await expect(second).resolves.toBe('second'); + await expect(queues.enqueue('alice', async () => 'fifth')).resolves.toBe('fifth'); + }); + + it('keeps serving a user after one of their tasks throws', async () => { + const queues = new UserQueues({ globalConcurrency: 2, perUserDepth: 10 }); + const failing = queues.enqueue('alice', async () => { + throw new Error('boom'); + }); + await expect(failing).rejects.toThrow('boom'); + await expect(queues.enqueue('alice', async () => 42)).resolves.toBe(42); + expect(queues.depth('alice')).toBe(0); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/reconciler.test.ts b/minimal-sdk/packages/gateway/test/reconciler.test.ts new file mode 100644 index 00000000..4f58d7a7 --- /dev/null +++ b/minimal-sdk/packages/gateway/test/reconciler.test.ts @@ -0,0 +1,439 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { openDb, type GatewayDb } from '../src/db.js'; +import { BTC_ASSET, Ledger } from '../src/ledger.js'; +import { recordOwnership } from '../src/rln/scoping.js'; +import { Reconciler } from '../src/workers/reconciler.js'; +import { + fakeRln, + HASH_A, + HASH_B, + inboundPayment, + rlnNetworkFailure, + rlnPaymentNotFound, + rlnRejection, + TEST_INVOICE, + type RlnOverrides, +} from './ln-mocks.js'; + +const USER = 'user-a'; +const ASSET = 'rgb:testAsset-000'; + +describe('Reconciler', () => { + let db: GatewayDb; + let ledger: Ledger; + + beforeEach(() => { + db = openDb(':memory:'); + db.prepare('INSERT INTO users (id, token_hash, created_at) VALUES (?, ?, ?)').run( + USER, + 'hash-a', + Date.now(), + ); + ledger = new Ledger(db); + }); + + afterEach(() => { + db.close(); + }); + + function reconciler(overrides: RlnOverrides, graceSeconds = 600): Reconciler { + return new Reconciler({ + db, + ledger, + rln: fakeRln(overrides), + outboundGraceSeconds: graceSeconds, + }); + } + + function pendingInvoice( + hash: string, + amtMsat: number | null, + asset?: { assetId: string; assetAmount: number }, + ): void { + recordOwnership(db, { kind: 'invoice', resourceId: hash, userId: USER, state: 'pending' }); + const now = Date.now(); + db.prepare( + `INSERT INTO ln_invoices (payment_hash, user_id, invoice, amt_msat, asset_id, asset_amount, state, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, + ).run( + hash, + USER, + TEST_INVOICE, + amtMsat, + asset?.assetId ?? null, + asset?.assetAmount ?? null, + now, + now + 3_600_000, + ); + } + + function invoiceState(hash: string): string { + const row = db.prepare('SELECT state FROM ln_invoices WHERE payment_hash = ?').get(hash) as { + state: string; + }; + return row.state; + } + + const emptyPage = { payments: [], first_index_offset: 0, last_index_offset: 0 }; + + it('credits a settled inbound invoice exactly once across replayed runs', async () => { + pendingInvoice(HASH_A, 250_000); + let listCalls = 0; + const worker = reconciler({ + listPayments: () => { + listCalls += 1; + return { + payments: [inboundPayment(HASH_A, 'Succeeded', 250_000)], + first_index_offset: 1, + last_index_offset: 1, + }; + }, + invoiceStatus: () => ({ status: 'Pending' }), + }); + await worker.runOnce(); + expect(ledger.balance(USER, BTC_ASSET)).toBe(250_000); + expect(invoiceState(HASH_A)).toBe('settled'); + + // Replays — including one that re-reads the same page — are no-ops. + await worker.runOnce(); + await worker.runOnce(); + expect(ledger.balance(USER, BTC_ASSET)).toBe(250_000); + expect(listCalls).toBe(3); + }); + + it('persists the cursor and survives a restart without re-crediting', async () => { + pendingInvoice(HASH_A, 250_000); + const overrides: RlnOverrides = { + // Like real RLN: paging past the oldest payment yields an empty page. + listPayments: (query) => + query?.index_offset !== undefined + ? emptyPage + : { + payments: [inboundPayment(HASH_A, 'Succeeded', 250_000)], + first_index_offset: 7, + last_index_offset: 7, + }, + invoiceStatus: () => ({ status: 'Pending' }), + }; + const first = reconciler(overrides); + await first.runOnce(); + expect(first.cursor()).toBe(7); + + // A fresh instance over the same database (simulated restart). + const second = reconciler(overrides); + expect(second.cursor()).toBe(7); + await second.runOnce(); + expect(ledger.balance(USER, BTC_ASSET)).toBe(250_000); + }); + + it('walks multiple pages down to the stored cursor', async () => { + pendingInvoice(HASH_A, 100_000); + pendingInvoice(HASH_B, 200_000); + const pages: Record = { + newest: { + payments: [inboundPayment(HASH_B, 'Succeeded', 200_000)], + first_index_offset: 4, + last_index_offset: 3, + }, + older: { + payments: [inboundPayment(HASH_A, 'Succeeded', 100_000)], + first_index_offset: 2, + last_index_offset: 1, + }, + }; + const worker = reconciler({ + listPayments: (query) => + query?.index_offset === undefined ? pages['newest'] : pages['older'], + invoiceStatus: () => ({ status: 'Pending' }), + }); + await worker.runOnce(); + expect(ledger.balance(USER, BTC_ASSET)).toBe(300_000); + expect(worker.cursor()).toBe(4); + }); + + it('settles an outbound payment RLN reports as succeeded', async () => { + ledger.credit({ userId: USER, asset: BTC_ASSET, amount: 500_000, kind: 'deposit', ref: 'd1' }); + ledger.debit({ userId: USER, asset: BTC_ASSET, amount: 100_000, kind: 'ln_out', ref: HASH_A }); + recordOwnership(db, { + kind: 'payment_hash', + resourceId: HASH_A, + userId: USER, + state: 'debited_pending', + }); + const worker = reconciler({ + getPayment: () => ({ + payment: { ...inboundPayment(HASH_A, 'Succeeded', 100_000), payment_type: 'Outbound' }, + }), + listPayments: () => emptyPage, + }); + await worker.runOnce(); + expect(ledger.balance(USER, BTC_ASSET)).toBe(400_000); + const row = db + .prepare("SELECT state FROM resource_map WHERE kind = 'payment_hash' AND resource_id = ?") + .get(HASH_A) as { state: string }; + expect(row.state).toBe('succeeded'); + }); + + function debitedPayment(state = 'debited_pending'): void { + ledger.credit({ userId: USER, asset: BTC_ASSET, amount: 500_000, kind: 'deposit', ref: 'd1' }); + ledger.debit({ userId: USER, asset: BTC_ASSET, amount: 100_000, kind: 'ln_out', ref: HASH_A }); + recordOwnership(db, { kind: 'payment_hash', resourceId: HASH_A, userId: USER, state }); + } + + function paymentState(hash: string): string { + const row = db + .prepare("SELECT state FROM resource_map WHERE kind = 'payment_hash' AND resource_id = ?") + .get(hash) as { state: string }; + return row.state; + } + + it('refunds an RLN-unknown debited-pending payment only after the grace period', async () => { + debitedPayment(); + const overrides: RlnOverrides = { + getPayment: () => { + throw rlnPaymentNotFound(); + }, + listPayments: () => emptyPage, + }; + + // Inside the grace period: nothing changes. + const patient = reconciler(overrides, 600); + await patient.runOnce(); + expect(ledger.balance(USER, BTC_ASSET)).toBe(400_000); + + // Past the grace period: the debit is refunded, exactly once. + const expired = reconciler(overrides, 0); + await expired.runOnce(); + expect(ledger.balance(USER, BTC_ASSET)).toBe(500_000); + await expired.runOnce(); + expect(ledger.balance(USER, BTC_ASSET)).toBe(500_000); + }); + + it('never refunds on an RLN error that is not PaymentNotFound (locked node)', async () => { + debitedPayment(); + // A locked/erroring node answers 403 without the PaymentNotFound name — + // the payment may well be settling; refunding would double-spend the float. + const worker = reconciler( + { + getPayment: () => { + throw rlnRejection(403); + }, + listPayments: () => emptyPage, + }, + 0, + ); + await worker.runOnce(); + expect(ledger.balance(USER, BTC_ASSET)).toBe(400_000); + expect(paymentState(HASH_A)).toBe('debited_pending'); + }); + + it('never refunds a payment that reached the sent state on PaymentNotFound', async () => { + debitedPayment('sent'); + const worker = reconciler( + { + getPayment: () => { + throw rlnPaymentNotFound(); + }, + listPayments: () => emptyPage, + }, + 0, + ); + await worker.runOnce(); + expect(ledger.balance(USER, BTC_ASSET)).toBe(400_000); + expect(paymentState(HASH_A)).toBe('sent'); + }); + + it('refunds an outbound payment RLN reports as Cancelled', async () => { + debitedPayment('sent'); + const worker = reconciler({ + getPayment: () => ({ + payment: { ...inboundPayment(HASH_A, 'Cancelled', 100_000), payment_type: 'Outbound' }, + }), + listPayments: () => emptyPage, + }); + await worker.runOnce(); + expect(ledger.balance(USER, BTC_ASSET)).toBe(500_000); + expect(paymentState(HASH_A)).toBe('failed'); + }); + + it('survives a listPayments outage without an unhandled rejection', async () => { + pendingInvoice(HASH_A, 250_000); + const worker = reconciler({ + listPayments: () => { + throw rlnNetworkFailure(); + }, + invoiceStatus: () => ({ status: 'Expired' }), + }); + // Must not reject — start() invokes runOnce with `void`, so a rejection + // here would crash the process. Later phases still run. + await expect(worker.runOnce()).resolves.toBeUndefined(); + expect(invoiceState(HASH_A)).toBe('expired'); + }); + + it('marks an expired invoice expired', async () => { + pendingInvoice(HASH_A, 250_000); + const worker = reconciler({ + listPayments: () => emptyPage, + invoiceStatus: () => ({ status: 'Expired' }), + }); + await worker.runOnce(); + expect(invoiceState(HASH_A)).toBe('expired'); + expect(ledger.balance(USER, BTC_ASSET)).toBe(0); + }); + + it('settles an asset invoice in the background from live /getpayment amounts', async () => { + // Asset invoices declare no amt_msat, so the /listpayments walk cannot + // settle them ("settled invoice has no amount"). Without this branch they + // stay pending — and uncredited — unless the user happens to GET them. + pendingInvoice(HASH_A, null, { assetId: ASSET, assetAmount: 50 }); + const worker = reconciler({ + listPayments: () => emptyPage, + invoiceStatus: () => ({ status: 'Succeeded' }), + getPayment: () => ({ + payment: { + ...inboundPayment(HASH_A, 'Succeeded', 3_000), + asset_id: ASSET, + asset_amount: 50, + }, + }), + }); + await worker.runOnce(); + expect(invoiceState(HASH_A)).toBe('settled'); + expect(ledger.balance(USER, BTC_ASSET)).toBe(3_000); + expect(ledger.balance(USER, ASSET)).toBe(50); + + // Replays are no-ops (ledger ref uniqueness). + await worker.runOnce(); + expect(ledger.balance(USER, ASSET)).toBe(50); + }); + + it('does not advance the cursor past a still-pending invoice, and settles it later', async () => { + // RLN stamps an inbound payment's index when the INVOICE is created and + // never restamps it on settlement, so a cursor that jumped to the newest + // index would step over this invoice and never look at it again. + pendingInvoice(HASH_A, 250_000); + let status: 'Pending' | 'Succeeded' = 'Pending'; + const worker = reconciler({ + listPayments: () => ({ + payments: [inboundPayment(HASH_A, status, 250_000)], + first_index_offset: 7, + last_index_offset: 7, + }), + invoiceStatus: () => ({ status: 'Pending' }), + }); + await worker.runOnce(); + expect(invoiceState(HASH_A)).toBe('pending'); + // Pinned just below the page holding the unresolved invoice, not at 7. + expect(worker.cursor()).toBe(6); + + // Same index, now settled: the walk still covers it. + status = 'Succeeded'; + await worker.runOnce(); + expect(invoiceState(HASH_A)).toBe('settled'); + expect(ledger.balance(USER, BTC_ASSET)).toBe(250_000); + // Everything in view is resolved, so the watermark is free to advance. + expect(worker.cursor()).toBe(7); + }); + + it('never credits asset units the payment record does not confirm', async () => { + // RGB credits are not bounded by the float caps: a declared-but-unconfirmed + // amount would mint ledger units. + pendingInvoice(HASH_A, 3_000_000, { assetId: ASSET, assetAmount: 50 }); + const worker = reconciler({ + listPayments: () => ({ + // Settled, but /listpayments could not read the RGB payment info. + payments: [inboundPayment(HASH_A, 'Succeeded', 3_000_000)], + first_index_offset: 4, + last_index_offset: 4, + }), + invoiceStatus: () => ({ status: 'Pending' }), + }); + await worker.runOnce(); + expect(invoiceState(HASH_A)).toBe('pending'); + expect(ledger.balance(USER, ASSET)).toBe(0); + expect(ledger.balance(USER, BTC_ASSET)).toBe(0); + expect(worker.cursor()).toBe(3); + }); + + it('keeps walking /listpayments when one payment fails to settle', async () => { + // The cursor is persisted only after the walk finishes, so an escaping + // throw would freeze it permanently and re-fail on every pass. The page + // still holds an unresolved invoice, so the watermark must NOT advance + // past it either — inbound indexes are stamped at invoice creation and + // never move, so a cursor that ran ahead would never look here again. + pendingInvoice(HASH_A, 0); + pendingInvoice(HASH_B, 250_000); + const warnings: unknown[] = []; + const worker = new Reconciler({ + db, + ledger, + rln: fakeRln({ + listPayments: () => ({ + // A zero-amount settled payment makes the ledger credit throw + // (RangeError from assertPositiveAmount) mid-walk. + payments: [ + inboundPayment(HASH_A, 'Succeeded', 0), + inboundPayment(HASH_B, 'Succeeded', 250_000), + ], + first_index_offset: 2, + last_index_offset: 1, + }), + invoiceStatus: () => ({ status: 'Pending' }), + }), + outboundGraceSeconds: 600, + log: { warn: (obj) => warnings.push(obj) }, + }); + await worker.runOnce(); + expect(invoiceState(HASH_A)).toBe('pending'); + expect(invoiceState(HASH_B)).toBe('settled'); + expect(ledger.balance(USER, BTC_ASSET)).toBe(250_000); + expect(worker.cursor()).toBe(0); + expect(warnings).toHaveLength(1); + }); + + it('isolates per-row failures: an erroring payment does not block the next one', async () => { + ledger.credit({ userId: USER, asset: BTC_ASSET, amount: 500_000, kind: 'deposit', ref: 'd1' }); + ledger.debit({ userId: USER, asset: BTC_ASSET, amount: 100_000, kind: 'ln_out', ref: HASH_A }); + ledger.debit({ userId: USER, asset: BTC_ASSET, amount: 100_000, kind: 'ln_out', ref: HASH_B }); + for (const hash of [HASH_A, HASH_B]) { + recordOwnership(db, { + kind: 'payment_hash', + resourceId: hash, + userId: USER, + state: 'debited_pending', + }); + } + const worker = reconciler({ + getPayment: (query) => { + if (query.payment_hash === HASH_A) throw rlnNetworkFailure(); + return { + payment: { ...inboundPayment(HASH_B, 'Succeeded', 100_000), payment_type: 'Outbound' }, + }; + }, + listPayments: () => emptyPage, + }); + await worker.runOnce(); + expect(paymentState(HASH_A)).toBe('debited_pending'); + expect(paymentState(HASH_B)).toBe('succeeded'); + // Both debits stand: the failed row waits for the next pass. + expect(ledger.balance(USER, BTC_ASSET)).toBe(300_000); + }); + + it('breaks out of a non-progressing listPayments cursor', { timeout: 5_000 }, async () => { + let listCalls = 0; + const worker = reconciler({ + // Upstream that never progresses: every page echoes the requested + // index_offset back as last_index_offset. + listPayments: (query) => { + listCalls += 1; + return { + payments: [inboundPayment(HASH_A, 'Pending', 100_000)], + first_index_offset: 10, + last_index_offset: query?.index_offset ?? 5, + }; + }, + }); + await expect(worker.runOnce()).resolves.toBeUndefined(); + expect(listCalls).toBe(2); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/rgblib-mapping.test.ts b/minimal-sdk/packages/gateway/test/rgblib-mapping.test.ts new file mode 100644 index 00000000..ced8cf78 --- /dev/null +++ b/minimal-sdk/packages/gateway/test/rgblib-mapping.test.ts @@ -0,0 +1,217 @@ +/** + * Unit tests for the pure marshalling layer of the native rgb-lib backend: + * error classification (which decides 400 INSUFFICIENT_FUNDS vs opaque 500) + * and the camelCase JSON → gateway-shape mappers. No native module needed — + * loadNativeRgbLib() is lazy and never called here. + */ +import { describe, expect, it } from 'vitest'; +import { + mapAssets, + mapTransfers, + mapUnspents, + unquote, + wrapNativeError, +} from '../src/wallets/rgblib.js'; + +describe('wrapNativeError', () => { + it.each([ + 'RgbLib(InsufficientBitcoins { needed: 15000, available: 4000 })', + 'RgbLib(InsufficientAllocationSlots)', + 'RgbLib(InsufficientSpendableAssets { asset_id: "rgb:abc" })', + 'RgbLib(InsufficientTotalAssets { asset_id: "rgb:abc" })', + ])('classifies %s as insufficient funds', (detail) => { + const wrapped = wrapNativeError('sendBtcBegin', new Error(detail)); + expect(wrapped.insufficientFunds).toBe(true); + expect(wrapped.message).toBe('wallet sendBtcBegin failed'); + expect(wrapped.detail).toContain(detail); + }); + + it.each([ + 'RgbLib(InvalidAddress { details: "bad checksum" })', + 'RgbLib(InvalidRecipientID)', + 'RgbLib(InvalidRecipientNetwork)', + 'RgbLib(InvalidRecipientData { details: "witness data on a blind recipient" })', + 'RgbLib(AssetNotFound { asset_id: "rgb:abc" })', + 'RgbLib(InvalidTransportEndpoint { details: "bad scheme" })', + 'RgbLib(RecipientIDDuplicated)', + 'RgbLib(InvalidFeeRate { details: "below minimum" })', + ])('classifies %s as a client error, not insufficient funds', (detail) => { + const wrapped = wrapNativeError('sendAssetBegin', new Error(detail)); + expect(wrapped.insufficientFunds).toBe(false); + expect(wrapped.clientError).toBe(true); + }); + + it.each([ + 'RgbLib(Internal { details: "stash corrupted" })', + 'RgbLib(FailedBdkSync { details: "indexer unreachable" })', + 'RgbLib(Electrum { details: "connection reset" })', + ])('leaves %s unclassified so it surfaces as a server error', (detail) => { + const wrapped = wrapNativeError('sync', new Error(detail)); + expect(wrapped.insufficientFunds).toBe(false); + expect(wrapped.clientError).toBe(false); + }); + + it('stringifies non-Error throwables into the detail', () => { + const wrapped = wrapNativeError('sync', 'plain-string failure'); + expect(wrapped.detail).toBe('plain-string failure'); + expect(wrapped.insufficientFunds).toBe(false); + expect(wrapped.clientError).toBe(false); + }); +}); + +describe('unquote', () => { + it('strips JSON string quoting', () => { + expect(unquote('"cHNidP8BAA=="')).toBe('cHNidP8BAA=='); + }); + + it('passes through unquoted and malformed values unchanged', () => { + expect(unquote('cHNidP8BAA==')).toBe('cHNidP8BAA=='); + expect(unquote('"unterminated')).toBe('"unterminated'); + }); +}); + +describe('mapAssets', () => { + it('flattens the per-schema camelCase asset map', () => { + const raw = { + nia: [ + { + assetId: 'rgb:asset-1', + ticker: 'TST', + name: 'Test Asset', + precision: 0, + balance: { settled: 100, future: 100, spendable: 100 }, + }, + ], + cfa: [ + // No assetId → dropped rather than emitted half-formed. + { ticker: 'BAD', name: 'No Id', precision: 0 }, + ], + uda: null, + }; + expect(mapAssets(raw)).toEqual([ + { + assetId: 'rgb:asset-1', + schema: 'nia', + ticker: 'TST', + name: 'Test Asset', + precision: 0, + balance: { settled: 100, future: 100, spendable: 100 }, + }, + ]); + }); + + it('returns an empty list for a non-object payload', () => { + expect(mapAssets(null)).toEqual([]); + expect(mapAssets('garbage')).toEqual([]); + }); +}); + +describe('mapUnspents', () => { + it('maps utxos with their rgb allocations', () => { + const raw = [ + { + utxo: { + outpoint: { txid: 'txid-1', vout: 2 }, + btcAmount: 998, + colorable: true, + }, + rgbAllocations: [ + { assetId: 'rgb:asset-1', assignment: { Fungible: 42 }, settled: true }, + { assetId: null, assignment: 'Any', settled: false }, + ], + }, + ]; + expect(mapUnspents(raw)).toEqual([ + { + txid: 'txid-1', + vout: 2, + amountSat: 998, + colorable: true, + allocations: [ + { assetId: 'rgb:asset-1', amount: 42, settled: true }, + { assetId: null, amount: null, settled: false }, + ], + }, + ]); + }); + + it('returns an empty list for a non-array payload', () => { + expect(mapUnspents({ not: 'an array' })).toEqual([]); + }); +}); + +describe('mapTransfers', () => { + it('prefers the requested assignment amount and falls back to summed assignments', () => { + const raw = [ + { + idx: 1, + requestedAssignment: { Fungible: 50 }, + assignments: [], + kind: 'ReceiveBlind', + status: 'Settled', + txid: 'txid-1', + recipientId: 'utxob:recipient', + expiration: 1_700_000_000, + createdAt: 1, + updatedAt: 2, + }, + { + idx: 2, + requestedAssignment: null, + assignments: [{ Fungible: 10 }, { Fungible: 15 }, 'Any'], + kind: 'Send', + status: 'WaitingConfirmations', + txid: null, + recipientId: null, + expiration: null, + createdAt: 3, + updatedAt: 4, + }, + ]; + const mapped = mapTransfers(raw, 'rgb:asset-1'); + expect(mapped).toEqual([ + { + idx: 1, + assetId: 'rgb:asset-1', + amount: 50, + kind: 'ReceiveBlind', + status: 'Settled', + txid: 'txid-1', + recipientId: 'utxob:recipient', + expiration: 1_700_000_000, + createdAt: 1, + updatedAt: 2, + }, + { + idx: 2, + assetId: 'rgb:asset-1', + amount: 25, + kind: 'Send', + status: 'WaitingConfirmations', + txid: null, + recipientId: null, + expiration: null, + createdAt: 3, + updatedAt: 4, + }, + ]); + }); + + it('defaults missing fields instead of throwing on shape drift', () => { + const mapped = mapTransfers([{}], null); + expect(mapped).toEqual([ + { + idx: 0, + assetId: null, + amount: null, + kind: 'Unknown', + status: 'Unknown', + txid: null, + recipientId: null, + expiration: null, + createdAt: 0, + updatedAt: 0, + }, + ]); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/rln-client.test.ts b/minimal-sdk/packages/gateway/test/rln-client.test.ts new file mode 100644 index 00000000..5323013c --- /dev/null +++ b/minimal-sdk/packages/gateway/test/rln-client.test.ts @@ -0,0 +1,204 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + RlnClient, + RlnHttpError, + RlnNetworkError, + RlnTimeoutError, + sanitizeRlnError, +} from '../src/rln/client.js'; +import { HttpError } from '../src/errors.js'; + +interface SeenRequest { + method: string; + url: string; + headers: IncomingMessage['headers']; + body: string; +} + +type Handler = (req: SeenRequest, res: ServerResponse) => void; + +const ADMIN_TOKEN = 'test-rln-admin-token-abcdef'; + +describe('RlnClient', () => { + let server: Server; + let baseUrl: string; + let seen: SeenRequest[]; + let handler: Handler; + const pendingTimers: NodeJS.Timeout[] = []; + + beforeEach(async () => { + seen = []; + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{}'); + }; + server = createServer((req, res) => { + let body = ''; + req.on('data', (chunk: Buffer) => (body += chunk.toString())); + req.on('end', () => { + const request: SeenRequest = { + method: req.method ?? '', + url: req.url ?? '', + headers: req.headers, + body, + }; + seen.push(request); + res.on('error', () => {}); + handler(request, res); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterEach(async () => { + for (const timer of pendingTimers) clearTimeout(timer); + pendingTimers.length = 0; + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + }); + + function client(overrides: { adminToken?: string; timeoutMs?: number } = {}): RlnClient { + return new RlnClient({ + baseUrl, + adminToken: overrides.adminToken ?? ADMIN_TOKEN, + ...(overrides.timeoutMs !== undefined ? { timeoutMs: overrides.timeoutMs } : {}), + }); + } + + it('sends the admin token as a bearer header and parses the response', async () => { + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ pubkey: 'abc', num_channels: 0 })); + }; + const info = await client().nodeInfo(); + expect(info.pubkey).toBe('abc'); + expect(seen[0]?.method).toBe('GET'); + expect(seen[0]?.url).toBe('/nodeinfo'); + expect(seen[0]?.headers['authorization']).toBe(`Bearer ${ADMIN_TOKEN}`); + }); + + it('omits the authorization header when the admin token is empty', async () => { + await client({ adminToken: '' }).nodeInfo(); + expect(seen[0]?.headers['authorization']).toBeUndefined(); + }); + + it('POSTs a JSON body with content-type', async () => { + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ payment_hash: 'aa', status: 'Pending' })); + }; + await client().sendPayment({ invoice: 'lnbcrt1...' }); + expect(seen[0]?.method).toBe('POST'); + expect(seen[0]?.url).toBe('/sendpayment'); + expect(seen[0]?.headers['content-type']).toBe('application/json'); + expect(JSON.parse(seen[0]?.body ?? '')).toEqual({ invoice: 'lnbcrt1...' }); + }); + + it('sends an empty JSON object for body-less POST endpoints', async () => { + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ address: 'bcrt1q...' })); + }; + await client().address(); + expect(seen[0]?.body).toBe('{}'); + }); + + it('serializes listpayments query parameters and skips undefined ones', async () => { + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ payments: [], first_index_offset: 0, last_index_offset: 0 })); + }; + await client().listPayments({ index_offset: 42, status: 'Succeeded' }); + expect(seen[0]?.url).toBe('/listpayments?index_offset=42&status=Succeeded'); + await client().listPayments(); + expect(seen[1]?.url).toBe('/listpayments'); + }); + + it('times out slow calls with RlnTimeoutError', async () => { + handler = (_req, res) => { + const timer = setTimeout(() => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{}'); + }, 5_000); + pendingTimers.push(timer); + }; + const error = await client({ timeoutMs: 50 }) + .nodeInfo() + .then( + () => undefined, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(RlnTimeoutError); + expect((error as RlnTimeoutError).path).toBe('/nodeinfo'); + expect((error as RlnTimeoutError).timeoutMs).toBe(50); + }); + + it('passes the RLN error body through on RlnHttpError', async () => { + handler = (_req, res) => { + res.writeHead(400, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid invoice: bad checksum', code: 400 })); + }; + const error = await client() + .decodeLnInvoice({ invoice: 'nope' }) + .then( + () => undefined, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(RlnHttpError); + const httpError = error as RlnHttpError; + expect(httpError.status).toBe(400); + expect(httpError.body).toEqual({ error: 'Invalid invoice: bad checksum', code: 400 }); + }); + + it('keeps the raw body when an error response is not RLN-shaped JSON', async () => { + handler = (_req, res) => { + res.writeHead(502, { 'content-type': 'text/plain' }); + res.end('Bad Gateway'); + }; + const error = await client() + .nodeInfo() + .then( + () => undefined, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(RlnHttpError); + expect((error as RlnHttpError).body).toBeUndefined(); + expect((error as RlnHttpError).rawBody).toBe('Bad Gateway'); + }); + + it('reports connection failures as RlnNetworkError', async () => { + const port = (server.address() as AddressInfo).port; + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + const dead = new RlnClient({ baseUrl: `http://127.0.0.1:${port}`, adminToken: '' }); + await expect(dead.nodeInfo()).rejects.toBeInstanceOf(RlnNetworkError); + // Recreate so afterEach can close it. + server = createServer(() => {}); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + }); + + it('sanitizes RLN failures so no upstream detail reaches clients', () => { + const upstream = new RlnHttpError( + '/sendpayment', + 400, + { error: 'secret internal detail', code: 400 }, + '', + ); + const sanitized = sanitizeRlnError(upstream); + expect(sanitized).toBeInstanceOf(HttpError); + expect(sanitized.statusCode).toBe(502); + expect(sanitized.code).toBe('UPSTREAM_ERROR'); + expect(sanitized.message).not.toContain('secret'); + + const timeout = sanitizeRlnError(new RlnTimeoutError('/nodeinfo', 30_000)); + expect(timeout.statusCode).toBe(504); + expect(timeout.code).toBe('UPSTREAM_TIMEOUT'); + + const network = sanitizeRlnError(new RlnNetworkError('/nodeinfo', new Error('ECONNREFUSED'))); + expect(network.statusCode).toBe(502); + expect(network.message).not.toContain('ECONNREFUSED'); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/rln-scoping.test.ts b/minimal-sdk/packages/gateway/test/rln-scoping.test.ts new file mode 100644 index 00000000..3f761adf --- /dev/null +++ b/minimal-sdk/packages/gateway/test/rln-scoping.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createUser } from '../src/auth.js'; +import { openDb, type GatewayDb } from '../src/db.js'; +import { + filterOwned, + ownerOf, + OwnershipConflictError, + recordOwnership, + scopePayments, + scopeTransfers, + updateResourceState, +} from '../src/rln/scoping.js'; +import type { Payment, Transfer } from '../src/rln/types.js'; + +function payment(hash: string, paymentType: Payment['payment_type'] = 'Outbound'): Payment { + return { + payment_hash: hash, + payment_type: paymentType, + status: 'Succeeded', + created_at: 1_700_000_000, + updated_at: 1_700_000_100, + payee_pubkey: '03b79a4bc1ec365524b4fab9a39eb133753646babb5a1da5c4bc94c53110b7795d', + }; +} + +function transfer( + idx: number, + ids: { recipientId?: string | null; txid?: string | null }, +): Transfer { + return { + idx, + created_at: 1_700_000_000, + updated_at: 1_700_000_100, + status: 'Settled', + assignments: [], + kind: 'ReceiveBlind', + transport_endpoints: [], + recipient_id: ids.recipientId ?? null, + txid: ids.txid ?? null, + }; +} + +describe('rln scoping', () => { + let db: GatewayDb; + let alice: string; + let bob: string; + + beforeEach(() => { + db = openDb(':memory:'); + alice = createUser(db).userId; + bob = createUser(db).userId; + }); + + afterEach(() => { + db.close(); + }); + + it('records ownership at creation time and resolves the owner', () => { + recordOwnership(db, { kind: 'invoice', resourceId: 'hash1', userId: alice, state: 'pending' }); + expect(ownerOf(db, 'invoice', 'hash1')).toBe(alice); + expect(ownerOf(db, 'invoice', 'hash1')).not.toBe(bob); + expect(ownerOf(db, 'payment_hash', 'hash1')).toBeUndefined(); + }); + + it('lets the owner re-record with a new state but never transfers ownership', () => { + recordOwnership(db, { kind: 'invoice', resourceId: 'hash1', userId: alice, state: 'pending' }); + recordOwnership(db, { kind: 'invoice', resourceId: 'hash1', userId: alice, state: 'settled' }); + const row = db + .prepare("SELECT state FROM resource_map WHERE kind = 'invoice' AND resource_id = 'hash1'") + .get() as { state: string }; + expect(row.state).toBe('settled'); + expect(() => + recordOwnership(db, { kind: 'invoice', resourceId: 'hash1', userId: bob, state: 'pending' }), + ).toThrow(OwnershipConflictError); + expect(ownerOf(db, 'invoice', 'hash1')).toBe(alice); + }); + + it('updates state only for the owner', () => { + recordOwnership(db, { + kind: 'payment_hash', + resourceId: 'hash2', + userId: alice, + state: 'pending', + }); + expect(updateResourceState(db, 'payment_hash', 'hash2', bob, 'settled')).toBe(false); + expect(updateResourceState(db, 'payment_hash', 'hash2', alice, 'settled')).toBe(true); + const row = db + .prepare( + "SELECT state FROM resource_map WHERE kind = 'payment_hash' AND resource_id = 'hash2'", + ) + .get() as { state: string }; + expect(row.state).toBe('settled'); + }); + + it('scopes payments: a foreign payment_hash is invisible to another user', () => { + recordOwnership(db, { kind: 'payment_hash', resourceId: 'aaa', userId: alice, state: 'sent' }); + recordOwnership(db, { kind: 'invoice', resourceId: 'bbb', userId: alice, state: 'pending' }); + recordOwnership(db, { kind: 'payment_hash', resourceId: 'ccc', userId: bob, state: 'sent' }); + const nodeWide = [ + payment('aaa'), + payment('bbb', 'InboundAutoClaim'), + payment('ccc'), + payment('unowned'), + ]; + + const aliceView = scopePayments(db, alice, nodeWide); + expect(aliceView.map((p) => p.payment_hash)).toEqual(['aaa', 'bbb']); + + const bobView = scopePayments(db, bob, nodeWide); + expect(bobView.map((p) => p.payment_hash)).toEqual(['ccc']); + expect(bobView.some((p) => p.payment_hash === 'aaa')).toBe(false); + }); + + it('scopes payments per direction, so an outbound claim cannot read an inbound one', () => { + // Alice created the invoice; Bob claimed the same hash under + // 'payment_hash' (decodeLnInvoice accepts any self-signed BOLT11, so the + // hash is his to choose). Bob must still see nothing. + recordOwnership(db, { kind: 'invoice', resourceId: 'aaa', userId: alice, state: 'pending' }); + recordOwnership(db, { kind: 'payment_hash', resourceId: 'aaa', userId: bob, state: 'failed' }); + const inbound = [payment('aaa', 'InboundAutoClaim')]; + + expect(scopePayments(db, alice, inbound).map((p) => p.payment_hash)).toEqual(['aaa']); + expect(scopePayments(db, bob, inbound)).toEqual([]); + // ...and symmetrically, Alice's invoice claim does not expose Bob's send. + const outbound = [payment('aaa', 'Outbound')]; + expect(scopePayments(db, bob, outbound).map((p) => p.payment_hash)).toEqual(['aaa']); + expect(scopePayments(db, alice, outbound)).toEqual([]); + }); + + it('scopes transfers by recipient_id and by txid, dropping unattributable ones', () => { + recordOwnership(db, { + kind: 'asset_transfer', + resourceId: 'recipient-a', + userId: alice, + state: 'pending', + }); + recordOwnership(db, { + kind: 'asset_transfer', + resourceId: 'txid-a', + userId: alice, + state: 'sent', + }); + recordOwnership(db, { + kind: 'asset_transfer', + resourceId: 'recipient-b', + userId: bob, + state: 'pending', + }); + const nodeWide = [ + transfer(1, { recipientId: 'recipient-a' }), + transfer(2, { txid: 'txid-a' }), + transfer(3, { recipientId: 'recipient-b', txid: 'txid-b' }), + transfer(4, {}), + ]; + + expect(scopeTransfers(db, alice, nodeWide).map((t) => t.idx)).toEqual([1, 2]); + expect(scopeTransfers(db, bob, nodeWide).map((t) => t.idx)).toEqual([3]); + }); + + it('filterOwned drops items with missing or empty ids', () => { + recordOwnership(db, { kind: 'invoice', resourceId: 'x', userId: alice, state: 'pending' }); + const items = [{ id: 'x' }, { id: '' }, { id: null }, { id: undefined }]; + const kept = filterOwned(db, alice, ['invoice'], items, (item) => item.id); + expect(kept).toEqual([{ id: 'x' }]); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/smoke.test.ts b/minimal-sdk/packages/gateway/test/smoke.test.ts new file mode 100644 index 00000000..b39414f4 --- /dev/null +++ b/minimal-sdk/packages/gateway/test/smoke.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { GATEWAY_NAME } from '../src/index.js'; + +describe('gateway package smoke', () => { + it('imports resolve and exports are present', () => { + expect(GATEWAY_NAME).toBe('@utexo/minimal-gateway'); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/wallet-integration.test.ts b/minimal-sdk/packages/gateway/test/wallet-integration.test.ts new file mode 100644 index 00000000..e39c0608 --- /dev/null +++ b/minimal-sdk/packages/gateway/test/wallet-integration.test.ts @@ -0,0 +1,129 @@ +/** + * Integration test against the repo's regtest stack (Preflight B bring-up): + * requires bitcoind + electrs on 127.0.0.1:50001. Run with: + * + * ESPLORA=1 ./regtest.sh start # from the repo root + * WALLET_REGTEST=1 pnpm --filter @utexo/minimal-gateway test wallet-integration + * + * Skipped (not failed) when WALLET_REGTEST is unset so the default unit-test + * command needs no infrastructure. + */ +import { execSync } from 'node:child_process'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { restoreKeysForTests } from '../src/wallets/rgblib.js'; +import { createTestUser, sleep, testServer, type TestUser } from './helpers.js'; + +const REPO_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)); +const ELECTRUM_URL = '127.0.0.1:50001'; + +// TEST fixture only: mnemonics are never a gateway input (I1/I2). The client +// SDK (Task 6) must reproduce these xpubs and this first address exactly. +const FIXTURE_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; +const FIXTURE_FIRST_ADDRESS = 'bcrt1p8wpt9v4frpf3tkn0srd97pksgsxc5hs52lafxwru9kgeephvs7rqjeprhg'; + +const FUND_BTC = 0.5; +const FUND_SAT = 50_000_000; + +function regtest(command: string): string { + return execSync(`./regtest.sh ${command}`, { cwd: REPO_ROOT, encoding: 'utf8' }).trim(); +} + +describe.runIf(process.env['WALLET_REGTEST'] === '1')('wallet service on regtest', () => { + let app: FastifyInstance; + let user: TestUser; + + beforeAll(async () => { + app = await testServer({ + configOverrides: { + walletsDir: mkdtempSync(join(tmpdir(), 'utexo-gateway-it-')), + walletIndexerUrl: ELECTRUM_URL, + }, + }); + user = await createTestUser(app); + }); + + afterAll(async () => { + await app?.close(); + }); + + function authed() { + return { authorization: `Bearer ${user.token}` }; + } + + it('registers fixture xpubs and derives the expected first address', async () => { + const xpubs = restoreKeysForTests('Regtest', FIXTURE_MNEMONIC); + const response = await app.inject({ + method: 'POST', + url: '/v1/wallet/xpubs', + headers: authed(), + payload: { vanilla: xpubs.vanilla, colored: xpubs.colored, fingerprint: xpubs.fingerprint }, + }); + expect(response.statusCode).toBe(201); + expect(response.json()).toEqual({ + fingerprint: '73c5da0a', + address: FIXTURE_FIRST_ADDRESS, + }); + }, 60_000); + + it('sees funded balance on the watch-only wallet after sync', async () => { + const before = ( + await app.inject({ method: 'GET', url: '/v1/wallet/balances', headers: authed() }) + ).json() as { btc: { vanilla: { settled: number } } }; + + regtest(`sendtoaddress ${FIXTURE_FIRST_ADDRESS} ${FUND_BTC}`); + regtest('mine 1'); + + let settled = before.btc.vanilla.settled; + for (let attempt = 0; attempt < 20; attempt += 1) { + await sleep(1500); + const syncResponse = await app.inject({ + method: 'POST', + url: '/v1/wallet/sync', + headers: authed(), + }); + expect(syncResponse.statusCode).toBe(200); + const balances = ( + await app.inject({ method: 'GET', url: '/v1/wallet/balances', headers: authed() }) + ).json() as { btc: { vanilla: { settled: number } } }; + settled = balances.btc.vanilla.settled; + if (settled >= before.btc.vanilla.settled + FUND_SAT) break; + } + expect(settled).toBeGreaterThanOrEqual(before.btc.vanilla.settled + FUND_SAT); + + const unspents = ( + await app.inject({ method: 'GET', url: '/v1/wallet/unspents', headers: authed() }) + ).json() as { unspents: { txid: string; amountSat: number }[] }; + expect(unspents.unspents.length).toBeGreaterThanOrEqual(1); + expect(unspents.unspents.some((u) => u.amountSat === FUND_SAT)).toBe(true); + }, 120_000); + + it('maps a real rgb-lib insufficient-allocation failure to a clean 400', async () => { + // No colored utxos were created (that needs client signing — Task 5), so a + // receive must fail with the sanitized INSUFFICIENT_FUNDS error, not a 500. + const response = await app.inject({ + method: 'POST', + url: '/v1/wallet/receive', + headers: authed(), + payload: { mode: 'blind' }, + }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('INSUFFICIENT_FUNDS'); + expect(response.body).not.toContain('RgbLib'); + }, 60_000); + + it('lists transfers (empty but well-formed) on the fresh wallet', async () => { + const response = await app.inject({ + method: 'GET', + url: '/v1/wallet/transfers', + headers: authed(), + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ transfers: [] }); + }, 60_000); +}); diff --git a/minimal-sdk/packages/gateway/test/wallet-mocks.ts b/minimal-sdk/packages/gateway/test/wallet-mocks.ts new file mode 100644 index 00000000..4c60c8a2 --- /dev/null +++ b/minimal-sdk/packages/gateway/test/wallet-mocks.ts @@ -0,0 +1,180 @@ +/** Configurable in-memory wallet backend for unit tests. */ +import type { + CreateUtxosParams, + ReceiveData, + ReceiveRequest, + SendAssetBeginRequest, + WalletAsset, + WalletBackend, + WalletBtcBalance, + WalletHandle, + WalletIdentity, + WalletTransfer, + WalletUnspent, +} from '../src/wallets/backend.js'; + +export const EMPTY_BALANCE = { settled: 0, future: 0, spendable: 0 }; + +export interface MockWalletData { + address?: string; + btcBalance?: WalletBtcBalance; + assets?: WalletAsset[]; + unspents?: WalletUnspent[]; + /** Keyed by assetId; 'null' key holds asset-less transfers. */ + transfersByAsset?: Record; + /** Unsigned PSBT returned by every *Begin call. */ + preparedPsbt?: string; + sendBtcTxid?: string; + sendAssetTxid?: string; + utxosCreated?: number; +} + +export class MockWalletHandle implements WalletHandle { + readonly operations: string[] = []; + syncCount = 0; + refreshCount = 0; + receiveCount = 0; + closed = false; + /** Set to make every operation wait (concurrency probes). */ + gate: (() => Promise) | undefined; + /** Set to make operations fail. */ + failWith: Error | undefined; + + constructor( + readonly identity: WalletIdentity, + private readonly data: MockWalletData = {}, + ) {} + + private async op(name: string, result: () => T): Promise { + this.operations.push(name); + if (this.closed) throw new Error(`operation ${name} on closed mock wallet`); + if (this.gate !== undefined) await this.gate(); + if (this.failWith !== undefined) throw this.failWith; + return result(); + } + + getAddress(): Promise { + return this.op('getAddress', () => this.data.address ?? `addr-${this.identity.userId}`); + } + + getBtcBalance(): Promise { + return this.op( + 'getBtcBalance', + () => this.data.btcBalance ?? { vanilla: EMPTY_BALANCE, colored: EMPTY_BALANCE }, + ); + } + + listAssets(): Promise { + return this.op('listAssets', () => this.data.assets ?? []); + } + + listUnspents(): Promise { + return this.op('listUnspents', () => this.data.unspents ?? []); + } + + listTransfers(assetId: string | null): Promise { + return this.op( + `listTransfers:${assetId ?? 'null'}`, + () => this.data.transfersByAsset?.[assetId ?? 'null'] ?? [], + ); + } + + lastReceiveRequest: ReceiveRequest | undefined; + + receive(request: ReceiveRequest): Promise { + return this.op(`receive:${request.mode}`, () => { + this.receiveCount += 1; + this.lastReceiveRequest = request; + return { + invoice: `mock-invoice-${this.identity.userId}-${this.receiveCount}`, + recipientId: `mock-recipient-${this.identity.userId}-${this.receiveCount}`, + expirationTimestamp: request.expirationTimestamp, + }; + }); + } + + lastSendBtcArgs: { address: string; amountSat: number; feeRateSatPerVb: number } | undefined; + lastSendAssetRequest: SendAssetBeginRequest | undefined; + lastCreateUtxosParams: CreateUtxosParams | undefined; + lastSignedPsbt: string | undefined; + + sendBtcBegin(address: string, amountSat: number, feeRateSatPerVb: number): Promise { + return this.op('sendBtcBegin', () => { + this.lastSendBtcArgs = { address, amountSat, feeRateSatPerVb }; + return this.data.preparedPsbt ?? `mock-psbt-${this.identity.userId}`; + }); + } + + sendBtcEnd(signedPsbt: string): Promise { + return this.op('sendBtcEnd', () => { + this.lastSignedPsbt = signedPsbt; + return this.data.sendBtcTxid ?? 'mock-btc-txid'; + }); + } + + sendAssetBegin(request: SendAssetBeginRequest): Promise { + return this.op('sendAssetBegin', () => { + this.lastSendAssetRequest = request; + return this.data.preparedPsbt ?? `mock-psbt-${this.identity.userId}`; + }); + } + + sendAssetEnd(signedPsbt: string): Promise { + return this.op('sendAssetEnd', () => { + this.lastSignedPsbt = signedPsbt; + return this.data.sendAssetTxid ?? 'mock-asset-txid'; + }); + } + + createUtxosBegin(params: CreateUtxosParams): Promise { + return this.op('createUtxosBegin', () => { + this.lastCreateUtxosParams = params; + return this.data.preparedPsbt ?? `mock-psbt-${this.identity.userId}`; + }); + } + + createUtxosEnd(signedPsbt: string): Promise { + return this.op('createUtxosEnd', () => { + this.lastSignedPsbt = signedPsbt; + return this.data.utxosCreated ?? 5; + }); + } + + refresh(): Promise { + return this.op('refresh', () => { + this.refreshCount += 1; + }); + } + + sync(): Promise { + return this.op('sync', () => { + this.syncCount += 1; + }); + } + + async close(): Promise { + this.operations.push('close'); + this.closed = true; + } +} + +export class MockWalletBackend implements WalletBackend { + readonly openCalls: WalletIdentity[] = []; + readonly handles: MockWalletHandle[] = []; + /** Per-user fixture data applied at open time. */ + dataFor: (identity: WalletIdentity) => MockWalletData = () => ({}); + /** Set to make open() fail (e.g. invalid-xpub simulation). */ + openError: Error | undefined; + + async open(identity: WalletIdentity): Promise { + this.openCalls.push(identity); + if (this.openError !== undefined) throw this.openError; + const handle = new MockWalletHandle(identity, this.dataFor(identity)); + this.handles.push(handle); + return handle; + } + + handleFor(userId: string): MockWalletHandle | undefined { + return [...this.handles].reverse().find((handle) => handle.identity.userId === userId); + } +} diff --git a/minimal-sdk/packages/gateway/test/wallet-pool.test.ts b/minimal-sdk/packages/gateway/test/wallet-pool.test.ts new file mode 100644 index 00000000..62952030 --- /dev/null +++ b/minimal-sdk/packages/gateway/test/wallet-pool.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import type { WalletIdentity } from '../src/wallets/backend.js'; +import { WalletPool } from '../src/wallets/pool.js'; +import { WalletOpQueues } from '../src/wallets/queue.js'; +import { deferred } from './helpers.js'; +import { MockWalletBackend } from './wallet-mocks.js'; + +function identity(userId: string): WalletIdentity { + return { + userId, + xpubs: { vanilla: `van-${userId}`, colored: `col-${userId}`, fingerprint: 'aabbccdd' }, + }; +} + +function makePool(maxOpen: number) { + const backend = new MockWalletBackend(); + const queues = new WalletOpQueues(); + const pool = new WalletPool(backend, queues, { maxOpen }); + return { backend, queues, pool }; +} + +describe('WalletPool', () => { + it('opens lazily and reuses the open handle', async () => { + const { backend, pool } = makePool(4); + await pool.withWallet(identity('u1'), (w) => w.getAddress()); + await pool.withWallet(identity('u1'), (w) => w.getAddress()); + expect(backend.openCalls).toHaveLength(1); + expect(pool.openCount()).toBe(1); + }); + + it('evicts the least-recently-used wallet beyond maxOpen and closes it', async () => { + const { backend, pool } = makePool(2); + await pool.withWallet(identity('u1'), (w) => w.getAddress()); + await pool.withWallet(identity('u2'), (w) => w.getAddress()); + // Touch u1 so u2 becomes the LRU. + await pool.withWallet(identity('u1'), (w) => w.getAddress()); + await pool.withWallet(identity('u3'), (w) => w.getAddress()); + + expect(pool.openCount()).toBe(2); + // Eviction close is queued; give it a tick. + await new Promise((resolve) => setImmediate(resolve)); + expect(backend.handleFor('u2')?.closed).toBe(true); + expect(backend.handleFor('u1')?.closed).toBe(false); + expect(backend.handleFor('u3')?.closed).toBe(false); + }); + + it('reopens an evicted wallet on next use', async () => { + const { backend, pool } = makePool(1); + await pool.withWallet(identity('u1'), (w) => w.getAddress()); + await pool.withWallet(identity('u2'), (w) => w.getAddress()); + await pool.withWallet(identity('u1'), (w) => w.getAddress()); + const u1Opens = backend.openCalls.filter((call) => call.userId === 'u1'); + expect(u1Opens).toHaveLength(2); + }); + + it('serializes operations per wallet (single-flight through the queue)', async () => { + const { backend, pool } = makePool(4); + await pool.withWallet(identity('u1'), (w) => w.getAddress()); + const handle = backend.handleFor('u1'); + if (handle === undefined) throw new Error('no handle'); + + const gate = deferred(); + let firstDone = false; + handle.gate = () => gate.promise; + const first = pool.withWallet(identity('u1'), async (w) => { + await w.sync(); + firstDone = true; + }); + const second = pool.withWallet(identity('u1'), async (w) => { + // Must observe the first operation completed (strict FIFO). + expect(firstDone).toBe(true); + await w.refresh(); + }); + gate.resolve(); + await Promise.all([first, second]); + expect(handle.syncCount).toBe(1); + expect(handle.refreshCount).toBe(1); + }); + + it('does not interrupt an in-flight operation when evicting', async () => { + const { backend, pool } = makePool(1); + await pool.withWallet(identity('u1'), (w) => w.getAddress()); + const handle = backend.handleFor('u1'); + if (handle === undefined) throw new Error('no handle'); + + const gate = deferred(); + handle.gate = () => gate.promise; + const slow = pool.withWallet(identity('u1'), (w) => w.sync()); + // Trigger eviction of u1 while its sync is queued/running. + const other = pool.withWallet(identity('u2'), (w) => w.getAddress()); + gate.resolve(); + await Promise.all([slow, other]); + // close must come after the in-flight sync on the operation log. + const closeIndex = handle.operations.indexOf('close'); + const syncIndex = handle.operations.indexOf('sync'); + expect(closeIndex).toBeGreaterThan(syncIndex); + expect(handle.syncCount).toBe(1); + }); + + it('evict closes the cached wallet and forces a reopen', async () => { + // WalletService.registerXpubs relies on this after a failed persist: the + // handle was opened for xpubs that were never stored, so a retry with + // different xpubs must not be served from it. + const { backend, pool } = makePool(4); + await pool.withWallet(identity('u1'), (w) => w.getAddress()); + const first = backend.handleFor('u1'); + + pool.evict('u1'); + expect(pool.openCount()).toBe(0); + await new Promise((resolve) => setImmediate(resolve)); + expect(first?.closed).toBe(true); + + await pool.withWallet(identity('u1'), (w) => w.getAddress()); + expect(backend.openCalls.filter((call) => call.userId === 'u1')).toHaveLength(2); + expect(backend.handleFor('u1')).not.toBe(first); + }); + + it('evict is a no-op for a user with no open wallet', () => { + const { backend, pool } = makePool(4); + expect(() => pool.evict('never-opened')).not.toThrow(); + expect(backend.openCalls).toHaveLength(0); + }); + + it('closeAll closes every open wallet', async () => { + const { backend, pool } = makePool(4); + await pool.withWallet(identity('u1'), (w) => w.getAddress()); + await pool.withWallet(identity('u2'), (w) => w.getAddress()); + await pool.closeAll(); + expect(pool.openCount()).toBe(0); + expect(backend.handleFor('u1')?.closed).toBe(true); + expect(backend.handleFor('u2')?.closed).toBe(true); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/wallet-queue.test.ts b/minimal-sdk/packages/gateway/test/wallet-queue.test.ts new file mode 100644 index 00000000..cc512cee --- /dev/null +++ b/minimal-sdk/packages/gateway/test/wallet-queue.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { WalletOpQueues } from '../src/wallets/queue.js'; +import { deferred, sleep } from './helpers.js'; + +describe('WalletOpQueues', () => { + it('runs operations for one wallet strictly one at a time, in FIFO order', async () => { + const queues = new WalletOpQueues(); + const events: string[] = []; + let running = 0; + let maxRunning = 0; + + const tasks = [0, 1, 2, 3, 4].map((index) => + queues.run('w1', async () => { + running += 1; + maxRunning = Math.max(maxRunning, running); + events.push(`start-${index}`); + await sleep(5 - index); + events.push(`end-${index}`); + running -= 1; + }), + ); + await Promise.all(tasks); + + expect(maxRunning).toBe(1); + expect(events).toEqual([ + 'start-0', + 'end-0', + 'start-1', + 'end-1', + 'start-2', + 'end-2', + 'start-3', + 'end-3', + 'start-4', + 'end-4', + ]); + }); + + it('lets different wallets run concurrently', async () => { + const queues = new WalletOpQueues(); + const gate = deferred(); + let otherFinished = false; + + const blocked = queues.run('w1', async () => { + await gate.promise; + }); + await queues.run('w2', async () => { + otherFinished = true; + }); + + expect(otherFinished).toBe(true); + gate.resolve(); + await blocked; + }); + + it('keeps the chain alive after a failing operation', async () => { + const queues = new WalletOpQueues(); + await expect( + queues.run('w1', async () => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + await expect(queues.run('w1', async () => 'recovered')).resolves.toBe('recovered'); + }); + + it('drains queued work even when enqueued while an operation is in flight', async () => { + const queues = new WalletOpQueues(); + const gate = deferred(); + const order: string[] = []; + const first = queues.run('w1', async () => { + await gate.promise; + order.push('first'); + }); + const second = queues.run('w1', async () => { + order.push('second'); + }); + gate.resolve(); + await Promise.all([first, second]); + expect(order).toEqual(['first', 'second']); + // A later run on the drained queue starts immediately. + await expect(queues.run('w1', async () => 'fresh')).resolves.toBe('fresh'); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/wallet-routes.test.ts b/minimal-sdk/packages/gateway/test/wallet-routes.test.ts new file mode 100644 index 00000000..b15b8def --- /dev/null +++ b/minimal-sdk/packages/gateway/test/wallet-routes.test.ts @@ -0,0 +1,411 @@ +import type { FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WalletBackendError } from '../src/wallets/backend.js'; +import { createTestUser, testServer, type TestUser } from './helpers.js'; +import { EMPTY_BALANCE, MockWalletBackend } from './wallet-mocks.js'; + +// Real regtest account xpubs (deterministic fixture, Preflight A script). +const XPUBS = { + vanilla: + 'tpubDDfvzhdVV4unsoKt5aE6dcsNsfeWbTgmLZPi8LQDYU2xixrYemMfWJ3BaVneH3u7DBQePdTwhpybaKRU95pi6PMUtLPBJLVQRpzEnjfjZzX', + colored: + 'tpubDCtpoJs6YJcjLnr9gq6jYriYNMuWEu8mSDvEQU5st3ZkJbFqqzwpHUiPvxqD2366ciFAfpehk1k2d7Tyk7AJEr8uZva7KfnX4RpsiVSoEcZ', + fingerprint: '73c5da0a', +}; + +describe('wallet routes', () => { + let app: FastifyInstance; + let backend: MockWalletBackend; + let user: TestUser; + + beforeEach(async () => { + backend = new MockWalletBackend(); + app = await testServer({ walletBackend: backend }); + user = await createTestUser(app); + }); + + afterEach(async () => { + await app.close(); + }); + + function authed(token: string = user.token) { + return { authorization: `Bearer ${token}` }; + } + + async function register(token: string = user.token) { + return app.inject({ + method: 'POST', + url: '/v1/wallet/xpubs', + headers: authed(token), + payload: XPUBS, + }); + } + + describe('xpub registration', () => { + it('registers xpubs and returns the first address', async () => { + const response = await register(); + expect(response.statusCode).toBe(201); + expect(response.json()).toEqual({ + fingerprint: XPUBS.fingerprint, + address: `addr-${user.userId}`, + }); + expect(backend.openCalls[0]?.xpubs).toEqual(XPUBS); + }); + + it('is idempotent for identical re-registration', async () => { + await register(); + const again = await register(); + expect(again.statusCode).toBe(201); + expect(again.json().fingerprint).toBe(XPUBS.fingerprint); + }); + + it('rejects re-registration with a different fingerprint', async () => { + await register(); + const response = await app.inject({ + method: 'POST', + url: '/v1/wallet/xpubs', + headers: authed(), + payload: { ...XPUBS, fingerprint: 'deadbeef' }, + }); + expect(response.statusCode).toBe(409); + expect(response.json().error.code).toBe('XPUBS_MISMATCH'); + }); + + it('rejects re-registration with different xpubs even under the same fingerprint', async () => { + await register(); + const response = await app.inject({ + method: 'POST', + url: '/v1/wallet/xpubs', + headers: authed(), + payload: { ...XPUBS, vanilla: XPUBS.colored }, + }); + expect(response.statusCode).toBe(409); + }); + + it('rejects malformed fingerprints and xpubs at the schema layer', async () => { + const bad = await app.inject({ + method: 'POST', + url: '/v1/wallet/xpubs', + headers: authed(), + payload: { ...XPUBS, fingerprint: 'nothex!!' }, + }); + expect(bad.statusCode).toBe(400); + const short = await app.inject({ + method: 'POST', + url: '/v1/wallet/xpubs', + headers: authed(), + payload: { ...XPUBS, vanilla: 'tooshort' }, + }); + expect(short.statusCode).toBe(400); + }); + + it('maps wallet-construction failure to 400 INVALID_XPUBS and persists nothing', async () => { + backend.openError = new WalletBackendError('wallet construction failed', 'bad xpub'); + const response = await register(); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('INVALID_XPUBS'); + + backend.openError = undefined; + const retry = await register(); + expect(retry.statusCode).toBe(201); + }); + + it('evicts the smoke-opened wallet when persisting the xpubs fails', async () => { + // The pool keys handles by userId alone, so a handle cached for xpubs + // that were never persisted would serve addresses derived from the WRONG + // key on a retry with different xpubs — funds sent to an address the + // registered wallet never watches. + const otherXpubs = { + vanilla: + 'tpubDCtpoJs6YJcjLnr9gq6jYriYNMuWEu8mSDvEQU5st3ZkJbFqqzwpHUiPvxqD2366ciFAfpehk1k2d7Tyk7AJEr8uZva7KfnX4RpsiVSoEcZ', + colored: + 'tpubDDfvzhdVV4unsoKt5aE6dcsNsfeWbTgmLZPi8LQDYU2xixrYemMfWJ3BaVneH3u7DBQePdTwhpybaKRU95pi6PMUtLPBJLVQRpzEnjfjZzX', + fingerprint: '73c5da0a', + }; + // Fail the INSERT specifically (reads still work, so the smoke-open runs). + app.db.exec( + `CREATE TRIGGER fail_xpubs_insert BEFORE INSERT ON user_xpubs + BEGIN SELECT RAISE(ABORT, 'simulated persistence failure'); END`, + ); + const failed = await register(); + expect(failed.statusCode).toBe(500); + expect(app.walletPool.openCount()).toBe(0); + await new Promise((resolve) => setImmediate(resolve)); + expect(backend.handleFor(user.userId)?.closed).toBe(true); + + // Restore persistence and register DIFFERENT xpubs: the address must come + // from a wallet reopened with those xpubs, not from the cached handle. + app.db.exec('DROP TRIGGER fail_xpubs_insert'); + const retry = await app.inject({ + method: 'POST', + url: '/v1/wallet/xpubs', + headers: authed(), + payload: otherXpubs, + }); + expect(retry.statusCode).toBe(201); + expect(backend.openCalls).toHaveLength(2); + expect(backend.openCalls[1]?.xpubs).toEqual(otherXpubs); + }); + }); + + describe('wallet reads', () => { + it('answers 404 WALLET_NOT_REGISTERED before registration', async () => { + for (const url of [ + '/v1/wallet/address', + '/v1/wallet/balances', + '/v1/wallet/unspents', + '/v1/wallet/transfers', + ]) { + const response = await app.inject({ method: 'GET', url, headers: authed() }); + expect(response.statusCode).toBe(404); + expect(response.json().error.code).toBe('WALLET_NOT_REGISTERED'); + } + }); + + it('requires auth on every wallet route', async () => { + for (const [method, url] of [ + ['POST', '/v1/wallet/xpubs'], + ['GET', '/v1/wallet/address'], + ['GET', '/v1/wallet/balances'], + ['GET', '/v1/wallet/unspents'], + ['GET', '/v1/wallet/transfers'], + ['POST', '/v1/wallet/receive'], + ['POST', '/v1/wallet/sync'], + ] as const) { + const response = await app.inject({ method, url }); + expect(response.statusCode).toBe(401); + } + }); + + it('returns the address of the registered wallet', async () => { + await register(); + const response = await app.inject({ + method: 'GET', + url: '/v1/wallet/address', + headers: authed(), + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ address: `addr-${user.userId}` }); + }); + + it('reports balances from the user wallet (BTC + per-asset)', async () => { + backend.dataFor = () => ({ + btcBalance: { + vanilla: { settled: 5000, future: 5000, spendable: 5000 }, + colored: { settled: 700, future: 700, spendable: 700 }, + }, + assets: [ + { + assetId: 'rgb:asset-1', + schema: 'nia', + ticker: 'TST', + name: 'Test', + precision: 0, + balance: { settled: 100, future: 100, spendable: 100 }, + }, + ], + }); + await register(); + const response = await app.inject({ + method: 'GET', + url: '/v1/wallet/balances', + headers: authed(), + }); + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.btc.vanilla.settled).toBe(5000); + expect(body.assets).toEqual([ + { + assetId: 'rgb:asset-1', + schema: 'nia', + ticker: 'TST', + name: 'Test', + precision: 0, + balance: { settled: 100, future: 100, spendable: 100 }, + }, + ]); + }); + + it('lists unspents', async () => { + backend.dataFor = () => ({ + unspents: [ + { + txid: 'ab'.repeat(32), + vout: 1, + amountSat: 9999, + colorable: true, + allocations: [{ assetId: 'rgb:asset-1', amount: 42, settled: true }], + }, + ], + }); + await register(); + const response = await app.inject({ + method: 'GET', + url: '/v1/wallet/unspents', + headers: authed(), + }); + expect(response.statusCode).toBe(200); + expect(response.json().unspents[0]).toMatchObject({ vout: 1, amountSat: 9999 }); + }); + + it('aggregates transfers across assets, and filters by assetId when asked', async () => { + const transfer = (idx: number, assetId: string | null) => ({ + idx, + assetId, + amount: 10, + kind: 'ReceiveBlind', + status: 'Settled', + txid: null, + recipientId: `rcpt-${idx}`, + expiration: null, + createdAt: 1, + updatedAt: 2, + }); + backend.dataFor = () => ({ + assets: [ + { + assetId: 'rgb:a1', + schema: 'nia', + ticker: null, + name: 'A1', + precision: 0, + balance: EMPTY_BALANCE, + }, + ], + transfersByAsset: { + null: [transfer(1, null)], + 'rgb:a1': [transfer(2, 'rgb:a1')], + }, + }); + await register(); + + const all = await app.inject({ + method: 'GET', + url: '/v1/wallet/transfers', + headers: authed(), + }); + expect(all.statusCode).toBe(200); + expect(all.json().transfers.map((t: { idx: number }) => t.idx)).toEqual([1, 2]); + + const filtered = await app.inject({ + method: 'GET', + url: '/v1/wallet/transfers?assetId=rgb:a1', + headers: authed(), + }); + expect(filtered.json().transfers.map((t: { idx: number }) => t.idx)).toEqual([2]); + }); + }); + + describe('receive', () => { + it('creates a blind receive and records ownership of the recipient id', async () => { + await register(); + const response = await app.inject({ + method: 'POST', + url: '/v1/wallet/receive', + headers: authed(), + payload: { mode: 'blind', assetId: 'rgb:a1', amount: 5 }, + }); + expect(response.statusCode).toBe(201); + const body = response.json(); + expect(body.mode).toBe('blind'); + expect(body.invoice).toContain('mock-invoice'); + + const row = app.db + .prepare('SELECT user_id, state FROM resource_map WHERE kind = ? AND resource_id = ?') + .get('asset_transfer', body.recipientId) as { user_id: string; state: string }; + expect(row.user_id).toBe(user.userId); + expect(row.state).toBe('pending'); + }); + + it('passes gateway-controlled transport endpoints and expiration to the wallet', async () => { + await register(); + const before = Math.floor(Date.now() / 1000); + await app.inject({ + method: 'POST', + url: '/v1/wallet/receive', + headers: authed(), + payload: { mode: 'witness', durationSeconds: 600 }, + }); + const handle = backend.handleFor(user.userId); + expect(handle?.operations).toContain('receive:witness'); + const request = handle?.lastReceiveRequest; + if (request === undefined) throw new Error('receive request not recorded'); + // Transport endpoints are gateway-controlled, never client input. + expect(request.transportEndpoints).toEqual([app.gatewayConfig.rgbProxyUrl]); + expect(request.minConfirmations).toBe(1); + // expirationTimestamp = floor(now/1000) + durationSeconds (±5s tolerance). + expect(request.expirationTimestamp).toBeGreaterThanOrEqual(before + 600); + expect(request.expirationTimestamp).toBeLessThanOrEqual(before + 600 + 5); + }); + + it('rejects a fractional receive amount at the schema layer', async () => { + await register(); + const response = await app.inject({ + method: 'POST', + url: '/v1/wallet/receive', + headers: authed(), + payload: { mode: 'blind', assetId: 'rgb:asset', amount: 1.5 }, + }); + expect(response.statusCode).toBe(400); + }); + + it('rejects an unknown mode at the schema layer', async () => { + await register(); + const response = await app.inject({ + method: 'POST', + url: '/v1/wallet/receive', + headers: authed(), + payload: { mode: 'psychic' }, + }); + expect(response.statusCode).toBe(400); + }); + + it('maps insufficient-funds failures to a clean 400, not a 500', async () => { + await register(); + const handle = backend.handleFor(user.userId); + if (handle === undefined) throw new Error('no handle'); + handle.failWith = new WalletBackendError( + 'wallet blindReceive failed', + 'RgbLib(InsufficientBitcoins { needed: 2000, available: 0 })', + true, + ); + const response = await app.inject({ + method: 'POST', + url: '/v1/wallet/receive', + headers: authed(), + payload: { mode: 'blind' }, + }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('INSUFFICIENT_FUNDS'); + // The rgb-lib detail must not leak to the client. + expect(response.body).not.toContain('RgbLib'); + }); + }); + + describe('sync and isolation', () => { + it('syncs then refreshes the user wallet', async () => { + await register(); + const response = await app.inject({ + method: 'POST', + url: '/v1/wallet/sync', + headers: authed(), + }); + expect(response.statusCode).toBe(200); + const handle = backend.handleFor(user.userId); + expect(handle?.syncCount).toBe(1); + expect(handle?.refreshCount).toBe(1); + }); + + it("keeps users isolated: a second user never touches the first user's wallet", async () => { + await register(); + const other = await createTestUser(app); + const response = await app.inject({ + method: 'GET', + url: '/v1/wallet/address', + headers: authed(other.token), + }); + expect(response.statusCode).toBe(404); + expect(backend.openCalls.every((call) => call.userId === user.userId)).toBe(true); + }); + }); +}); diff --git a/minimal-sdk/packages/gateway/tsconfig.json b/minimal-sdk/packages/gateway/tsconfig.json new file mode 100644 index 00000000..3fbff51d --- /dev/null +++ b/minimal-sdk/packages/gateway/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/minimal-sdk/pnpm-lock.yaml b/minimal-sdk/pnpm-lock.yaml new file mode 100644 index 00000000..b3f81956 --- /dev/null +++ b/minimal-sdk/pnpm-lock.yaml @@ -0,0 +1,3569 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@eslint/js': + specifier: ^9.39.0 + version: 9.39.5 + eslint: + specifier: ^9.39.0 + version: 9.39.5 + eslint-config-prettier: + specifier: ^10.1.0 + version: 10.1.8(eslint@9.39.5) + prettier: + specifier: ^3.6.0 + version: 3.9.6 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + typescript-eslint: + specifier: ^8.46.0 + version: 8.70.0(eslint@9.39.5)(typescript@5.9.3) + vitest: + specifier: ^3.2.0 + version: 3.2.7(@types/node@20.19.43)(yaml@2.9.0) + + packages/client-sdk: + dependencies: + '@noble/curves': + specifier: ^2.4.0 + version: 2.4.0 + '@noble/hashes': + specifier: ^2.4.0 + version: 2.4.0 + '@scure/base': + specifier: ^2.4.0 + version: 2.4.0 + '@scure/bip32': + specifier: ^2.4.0 + version: 2.4.0 + '@scure/bip39': + specifier: ^2.4.0 + version: 2.4.0 + '@scure/btc-signer': + specifier: ^2.4.1 + version: 2.4.1 + devDependencies: + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.2.0 + version: 3.2.7(@types/node@20.19.43)(yaml@2.9.0) + + packages/e2e: + dependencies: + '@utexo/minimal-client-sdk': + specifier: workspace:* + version: link:../client-sdk + '@utexo/minimal-gateway': + specifier: workspace:* + version: link:../gateway + devDependencies: + '@types/node': + specifier: ^20.19.0 + version: 20.19.43 + fastify: + specifier: ^5.6.0 + version: 5.12.3 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.2.0 + version: 3.2.7(@types/node@20.19.43)(yaml@2.9.0) + + packages/gateway: + dependencies: + '@scure/base': + specifier: ^2.4.0 + version: 2.4.0 + '@scure/btc-signer': + specifier: ^2.4.1 + version: 2.4.1 + '@utexo/rgb-lib': + specifier: 0.3.0-beta.18 + version: 0.3.0-beta.18 + better-sqlite3: + specifier: ^12.4.0 + version: 12.11.1 + fastify: + specifier: ^5.6.0 + version: 5.12.3 + devDependencies: + '@scure/bip32': + specifier: ^2.4.0 + version: 2.4.0 + '@scure/bip39': + specifier: ^2.4.0 + version: 2.4.0 + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + '@types/node': + specifier: ^20.19.0 + version: 20.19.43 + openapi-typescript: + specifier: ^7.13.0 + version: 7.13.0(typescript@5.9.3) + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.2.0 + version: 3.2.7(@types/node@20.19.43)(yaml@2.9.0) + yaml: + specifier: ^2.9.0 + version: 2.9.0 + optionalDependencies: + '@utexo/rgb-lib-darwin-arm64': + specifier: 0.3.0-beta.18 + version: 0.3.0-beta.18 + '@utexo/rgb-lib-linux-arm64': + specifier: 0.3.0-beta.18 + version: 0.3.0-beta.18 + '@utexo/rgb-lib-linux-x64': + specifier: 0.3.0-beta.18 + version: 0.3.0-beta.18 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.7': + resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@fastify/ajv-compiler@4.0.6': + resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.1.0': + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} + + '@fastify/forwarded@3.0.2': + resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@noble/curves@2.4.0': + resolution: {integrity: sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + + '@npmcli/agent@3.0.0': + resolution: {integrity: sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==} + engines: {node: ^18.17.0 || >=20.5.0} + + '@npmcli/fs@4.0.0': + resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} + engines: {node: ^18.17.0 || >=20.5.0} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.19': + resolution: {integrity: sha512-o/0VgsBXgwcY1lyeqcVtSGdTQAPnVggo0fbFVPlxl5XVDKUcVH0OLRqt3CbkwByT5FU305E0iE0O7MzThjDblw==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + + '@rollup/rollup-android-arm-eabi@4.63.1': + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.1': + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.1': + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.1': + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.1': + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.1': + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.1': + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.1': + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.1': + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.1': + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.1': + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.1': + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.1': + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.1': + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} + cpu: [x64] + os: [win32] + + '@scure/base@2.4.0': + resolution: {integrity: sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==} + + '@scure/bip32@2.4.0': + resolution: {integrity: sha512-i3DS0CptAocyvqE4n3SUkpzeQK4vJMFwWLofTwRiiKo2aWojBOfyMCgfKw9HVpO6fSY5AK86sHS/Uzn8kK9Few==} + + '@scure/bip39@2.4.0': + resolution: {integrity: sha512-82dxFbZUYboyOf0AXiydsQrFQ5Q4h9mX+O2UkE91ROYmsc0BKMGZLwDmy96Jpa2+vrtoxomjUhy1RPIgH/r2nA==} + + '@scure/btc-signer@2.4.1': + resolution: {integrity: sha512-ch6h66o9nPhEoe/KciMMUJeH2MBPB8UAJI1if35Fxl6885lKTsc9YoCRqTW0/RjI0Vv9GSg1tmKtHHODyAw81g==} + + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@typescript-eslint/eslint-plugin@8.70.0': + resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.70.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.70.0': + resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.70.0': + resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.70.0': + resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.70.0': + resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.70.0': + resolution: {integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.70.0': + resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.70.0': + resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.70.0': + resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.70.0': + resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@utexo/rgb-lib-darwin-arm64@0.3.0-beta.18': + resolution: {integrity: sha512-L+KYbHwc699XYcb3PecoFU93dljQETbdK3fg6nYuz6musB1dOPxm0ditI4BnwliEecJeH8G7r1INbFMZY093aQ==} + cpu: [arm64] + os: [darwin] + + '@utexo/rgb-lib-linux-arm64@0.3.0-beta.18': + resolution: {integrity: sha512-lyAzY/bemHwl3ILuGvO/0S9RXYRemG/peCwevEzUj/lcqElSGF83SueTVRVZsyiob84Hx+feK2EHAPWyBH3K2A==} + cpu: [arm64] + os: [linux] + + '@utexo/rgb-lib-linux-x64@0.3.0-beta.18': + resolution: {integrity: sha512-xerhMOzKhPnBUQp7mzqZgQG8vUJlH06AwiT2zZsLT5wuXIiahNIA2PqojcG3F2yl0+SUJ+VmgCQwHFAU46ylYA==} + cpu: [x64] + os: [linux] + + '@utexo/rgb-lib@0.3.0-beta.18': + resolution: {integrity: sha512-bShT/zvDQsN/REvhyZHcr+xiENpIwSiYozJovskaiPCaykhrr+k9vCc4vy/NIXUbelipxJSP5aQcioBHhvo5yg==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + abbrev@3.0.1: + resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} + engines: {node: ^18.17.0 || >=20.5.0} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.3.0: + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3@12.11.1: + resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + + big.js@6.2.2: + resolution: {integrity: sha512-y/ie+Faknx7sZA5MfGA2xKlu0GDv8RWrXGsmlteyJQ2lvoKv9GBK/fpRMc2qlSoBAgNxrixICFCBefIq8WCQpQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cacache@19.0.1: + resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-json-stringify@7.0.1: + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} + + fast-uri@4.1.4: + resolution: {integrity: sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==} + + fastify@5.12.3: + resolution: {integrity: sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==} + + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + find-my-way@9.9.0: + resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} + engines: {node: '>=20'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-minipass@3.0.3: + resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.9: + resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ip-address@10.7.0: + resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} + engines: {node: '>= 12'} + + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-fetch-happen@14.0.3: + resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + micro-packed@0.11.1: + resolution: {integrity: sha512-agQGBnBCw8OoKQZMUxcaQPaOowdfxyEmBsq4CNAXAXCyB8KxlRG0lTl5GADJIsMEDaFb0trhXBHKYsjTWa5kuw==} + engines: {node: '>= 20.19.0'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass-collect@2.0.1: + resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass-fetch@4.0.1: + resolution: {integrity: sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@1.0.3: + resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + node-abi@3.96.0: + resolution: {integrity: sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==} + engines: {node: '>=10'} + + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} + engines: {node: ^18 || ^20 || >= 21} + + node-gyp@11.5.0: + resolution: {integrity: sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + nopt@8.1.0: + resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@7.0.7: + resolution: {integrity: sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==} + engines: {node: '>=18'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + proc-log@5.0.0: + resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup@4.63.1: + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.10: + resolution: {integrity: sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + ssri@12.0.0: + resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript-eslint@8.70.0: + resolution: {integrity: sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unique-filename@4.0.0: + resolution: {integrity: sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + unique-slug@5.0.0: + resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==} + engines: {node: ^18.17.0 || >=20.5.0} + + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5)': + dependencies: + eslint: 9.39.5 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3(supports-color@10.2.2) + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.7': + dependencies: + ajv: 6.15.0 + debug: 4.4.3(supports-color@10.2.2) + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.2 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@fastify/ajv-compiler@4.0.6': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.4 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.1.0': + dependencies: + fast-json-stringify: 7.0.1 + + '@fastify/forwarded@3.0.2': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.2 + ipaddr.js: 2.5.0 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + optional: true + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + optional: true + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@noble/curves@2.4.0': + dependencies: + '@noble/hashes': 2.4.0 + + '@noble/hashes@2.4.0': {} + + '@npmcli/agent@3.0.0': + dependencies: + agent-base: 7.1.4 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + lru-cache: 10.4.3 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + optional: true + + '@npmcli/fs@4.0.0': + dependencies: + semver: 7.8.5 + optional: true + + '@pinojs/redact@0.4.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.19(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.3.1 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + + '@rollup/rollup-android-arm-eabi@4.63.1': + optional: true + + '@rollup/rollup-android-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-x64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.1': + optional: true + + '@scure/base@2.4.0': {} + + '@scure/bip32@2.4.0': + dependencies: + '@noble/curves': 2.4.0 + '@noble/hashes': 2.4.0 + '@scure/base': 2.4.0 + + '@scure/bip39@2.4.0': + dependencies: + '@noble/hashes': 2.4.0 + + '@scure/btc-signer@2.4.1': + dependencies: + '@noble/curves': 2.4.0 + '@noble/hashes': 2.4.0 + '@scure/base': 2.4.0 + micro-packed: 0.11.1 + + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 20.19.43 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/type-utils': 8.70.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.70.0 + eslint: 9.39.5 + ignore: 7.0.9 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.70.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.70.0 + debug: 4.4.3(supports-color@10.2.2) + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.70.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.9.3) + '@typescript-eslint/types': 8.70.0 + debug: 4.4.3(supports-color@10.2.2) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.70.0': + dependencies: + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/visitor-keys': 8.70.0 + + '@typescript-eslint/tsconfig-utils@8.70.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.70.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5)(typescript@5.9.3) + debug: 4.4.3(supports-color@10.2.2) + eslint: 9.39.5 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.70.0': {} + + '@typescript-eslint/typescript-estree@8.70.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.70.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.9.3) + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/visitor-keys': 8.70.0 + debug: 4.4.3(supports-color@10.2.2) + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.70.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.70.0': + dependencies: + '@typescript-eslint/types': 8.70.0 + eslint-visitor-keys: 5.0.1 + + '@utexo/rgb-lib-darwin-arm64@0.3.0-beta.18': + dependencies: + big.js: 6.2.2 + node-addon-api: 8.9.2 + node-gyp: 11.5.0 + transitivePeerDependencies: + - supports-color + optional: true + + '@utexo/rgb-lib-linux-arm64@0.3.0-beta.18': + dependencies: + big.js: 6.2.2 + node-addon-api: 8.9.2 + node-gyp: 11.5.0 + transitivePeerDependencies: + - supports-color + optional: true + + '@utexo/rgb-lib-linux-x64@0.3.0-beta.18': + dependencies: + big.js: 6.2.2 + node-addon-api: 8.9.2 + node-gyp: 11.5.0 + transitivePeerDependencies: + - supports-color + optional: true + + '@utexo/rgb-lib@0.3.0-beta.18': + optionalDependencies: + '@utexo/rgb-lib-darwin-arm64': 0.3.0-beta.18 + '@utexo/rgb-lib-linux-arm64': 0.3.0-beta.18 + '@utexo/rgb-lib-linux-x64': 0.3.0-beta.18 + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@20.19.43)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@20.19.43)(yaml@2.9.0) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + abbrev@3.0.1: + optional: true + + abstract-logging@2.0.1: {} + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.7 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: + optional: true + + ansi-regex@6.3.0: + optional: true + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: + optional: true + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + atomic-sleep@1.0.0: {} + + avvio@9.3.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.3 + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + better-sqlite3@12.11.1: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + big.js@6.2.2: + optional: true + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + cac@6.7.14: {} + + cacache@19.0.1: + dependencies: + '@npmcli/fs': 4.0.0 + fs-minipass: 3.0.3 + glob: 10.5.0 + lru-cache: 10.4.3 + minipass: 7.1.3 + minipass-collect: 2.0.1 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + p-map: 7.0.7 + ssri: 12.0.0 + tar: 7.5.22 + unique-filename: 4.0.0 + optional: true + + callsites@3.1.0: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + change-case@5.4.4: {} + + check-error@2.1.3: {} + + chownr@1.1.4: {} + + chownr@3.0.0: + optional: true + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@1.4.0: {} + + concat-map@0.0.1: {} + + content-type@2.1.0: + optional: true + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-eql@5.0.2: {} + + deep-extend@0.6.0: {} + + deep-is@0.1.4: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + eastasianwidth@0.2.0: + optional: true + + emoji-regex@8.0.0: + optional: true + + emoji-regex@9.2.2: + optional: true + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + env-paths@2.2.1: + optional: true + + err-code@2.0.3: + optional: true + + es-module-lexer@1.7.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@9.39.5): + dependencies: + eslint: 9.39.5 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.7 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@10.2.2) + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expand-template@2.0.3: {} + + expect-type@1.4.0: {} + + exponential-backoff@3.1.3: + optional: true + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-json-stringify@7.0.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.4 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-levenshtein@2.0.6: {} + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.7: {} + + fast-uri@4.1.4: {} + + fastify@5.12.3: + dependencies: + '@fastify/ajv-compiler': 4.0.6 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.1.0 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.3.0 + fast-json-stringify: 7.0.1 + find-my-way: 9.9.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.1.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.4 + + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-uri-to-path@1.0.0: {} + + find-my-way@9.9.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + optional: true + + fs-constants@1.0.0: {} + + fs-minipass@3.0.3: + dependencies: + minipass: 7.1.3 + optional: true + + fsevents@2.3.3: + optional: true + + github-from-package@0.0.0: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + optional: true + + globals@14.0.0: {} + + graceful-fs@4.2.11: + optional: true + + has-flag@4.0.0: {} + + http-cache-semantics@4.2.0: + optional: true + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + optional: true + + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.9: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + index-to-position@1.2.0: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ip-address@10.7.0: + optional: true + + ipaddr.js@2.5.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: + optional: true + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + isexe@3.1.5: + optional: true + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + optional: true + + js-levenshtein@1.1.6: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.3.1: + dependencies: + argparse: 2.0.1 + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loupe@3.2.1: {} + + lru-cache@10.4.3: + optional: true + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + make-fetch-happen@14.0.3: + dependencies: + '@npmcli/agent': 3.0.0 + cacache: 19.0.1 + http-cache-semantics: 4.2.0 + minipass: 7.1.3 + minipass-fetch: 4.0.1 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + negotiator: 1.1.0 + proc-log: 5.0.0 + promise-retry: 2.0.1 + ssri: 12.0.0 + transitivePeerDependencies: + - supports-color + optional: true + + micro-packed@0.11.1: + dependencies: + '@scure/base': 2.4.0 + + mimic-response@3.1.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.4 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + optional: true + + minimist@1.2.8: {} + + minipass-collect@2.0.1: + dependencies: + minipass: 7.1.3 + optional: true + + minipass-fetch@4.0.1: + dependencies: + minipass: 7.1.3 + minipass-sized: 1.0.3 + minizlib: 3.1.0 + optionalDependencies: + encoding: 0.1.13 + optional: true + + minipass-flush@1.0.7: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-sized@1.0.3: + dependencies: + minipass: 3.3.6 + optional: true + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + optional: true + + minipass@7.1.3: + optional: true + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + optional: true + + mkdirp-classic@0.5.3: {} + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + napi-build-utils@2.0.0: {} + + natural-compare@1.4.0: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + optional: true + + node-abi@3.96.0: + dependencies: + semver: 7.8.5 + + node-addon-api@8.9.2: + optional: true + + node-gyp@11.5.0: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + make-fetch-happen: 14.0.3 + nopt: 8.1.0 + proc-log: 5.0.0 + semver: 7.8.5 + tar: 7.5.22 + tinyglobby: 0.2.17 + which: 5.0.0 + transitivePeerDependencies: + - supports-color + optional: true + + nopt@8.1.0: + dependencies: + abbrev: 3.0.1 + optional: true + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.19(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@7.0.7: + optional: true + + package-json-from-dist@1.0.1: + optional: true + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + optional: true + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pluralize@8.0.0: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.96.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + + prelude-ls@1.2.1: {} + + prettier@3.9.6: {} + + proc-log@5.0.0: + optional: true + + process-warning@4.0.1: {} + + process-warning@5.1.0: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + optional: true + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + quick-format-unescaped@4.0.4: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + ret@0.5.0: {} + + retry@0.12.0: + optional: true + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rollup@4.63.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.1 + '@rollup/rollup-android-arm64': 4.63.1 + '@rollup/rollup-darwin-arm64': 4.63.1 + '@rollup/rollup-darwin-x64': 4.63.1 + '@rollup/rollup-freebsd-arm64': 4.63.1 + '@rollup/rollup-freebsd-x64': 4.63.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 + '@rollup/rollup-linux-arm-musleabihf': 4.63.1 + '@rollup/rollup-linux-arm64-gnu': 4.63.1 + '@rollup/rollup-linux-arm64-musl': 4.63.1 + '@rollup/rollup-linux-loong64-gnu': 4.63.1 + '@rollup/rollup-linux-loong64-musl': 4.63.1 + '@rollup/rollup-linux-ppc64-gnu': 4.63.1 + '@rollup/rollup-linux-ppc64-musl': 4.63.1 + '@rollup/rollup-linux-riscv64-gnu': 4.63.1 + '@rollup/rollup-linux-riscv64-musl': 4.63.1 + '@rollup/rollup-linux-s390x-gnu': 4.63.1 + '@rollup/rollup-linux-x64-gnu': 4.63.1 + '@rollup/rollup-linux-x64-musl': 4.63.1 + '@rollup/rollup-openbsd-x64': 4.63.1 + '@rollup/rollup-openharmony-arm64': 4.63.1 + '@rollup/rollup-win32-arm64-msvc': 4.63.1 + '@rollup/rollup-win32-ia32-msvc': 4.63.1 + '@rollup/rollup-win32-x64-gnu': 4.63.1 + '@rollup/rollup-win32-x64-msvc': 4.63.1 + fsevents: 2.3.3 + + safe-buffer@5.2.1: {} + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: + optional: true + + secure-json-parse@4.1.0: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: + optional: true + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + smart-buffer@4.2.0: + optional: true + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + socks: 2.8.10 + transitivePeerDependencies: + - supports-color + optional: true + + socks@2.8.10: + dependencies: + ip-address: 10.7.0 + smart-buffer: 4.2.0 + optional: true + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + ssri@12.0.0: + dependencies: + minipass: 7.1.3 + optional: true + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + optional: true + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + optional: true + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + optional: true + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + optional: true + + strip-json-comments@2.0.1: {} + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + supports-color@10.2.2: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + optional: true + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.6: {} + + toad-cache@3.7.4: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@4.41.0: {} + + typescript-eslint@8.70.0(eslint@9.39.5)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5)(typescript@5.9.3) + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unique-filename@4.0.0: + dependencies: + unique-slug: 5.0.0 + optional: true + + unique-slug@5.0.0: + dependencies: + imurmurhash: 0.1.4 + optional: true + + uri-js-replace@1.0.1: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite-node@3.2.4(@types/node@20.19.43)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@10.2.2) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@20.19.43)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@20.19.43)(yaml@2.9.0): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + yaml: 2.9.0 + + vitest@3.2.7(@types/node@20.19.43)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@20.19.43)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3(supports-color@10.2.2) + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@20.19.43)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@20.19.43)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.43 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@5.0.0: + dependencies: + isexe: 3.1.5 + optional: true + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + optional: true + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + optional: true + + wrappy@1.0.2: {} + + yallist@4.0.0: + optional: true + + yallist@5.0.0: + optional: true + + yaml-ast-parser@0.0.43: {} + + yaml@2.9.0: {} + + yargs-parser@21.1.1: {} + + yocto-queue@0.1.0: {} diff --git a/minimal-sdk/pnpm-workspace.yaml b/minimal-sdk/pnpm-workspace.yaml new file mode 100644 index 00000000..924b55f4 --- /dev/null +++ b/minimal-sdk/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/minimal-sdk/rust-sdk/.gitignore b/minimal-sdk/rust-sdk/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/minimal-sdk/rust-sdk/.gitignore @@ -0,0 +1 @@ +/target diff --git a/minimal-sdk/rust-sdk/Cargo.lock b/minimal-sdk/rust-sdk/Cargo.lock new file mode 100644 index 00000000..42e33ec7 --- /dev/null +++ b/minimal-sdk/rust-sdk/Cargo.lock @@ -0,0 +1,558 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base58ck" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "365c0acd5b2e8dd0111a46c4faea83fb3cfb6e39a49a7c73a06e090db7b2eff0" +dependencies = [ + "bitcoin_hashes", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "rand", + "rand_core", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin" +version = "0.32.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0ce8bd5baaa0d303a19915a6d93afed161f528654e42da2a7a97d05c59499a" +dependencies = [ + "base58ck", + "base64", + "bech32", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative 0.2.3", + "hex_lit", + "secp256k1", +] + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.3.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin-units" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb95693f371d089a4b5b6fc41c6f3ea6e01ee8c15388335dfac8ea685173b51" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.3", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hex-conservative" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "rand", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "uniffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cb08c58c7ed7033150132febe696bef553f891b1ede57424b40d87a89e3c170" +dependencies = [ + "anyhow", + "uniffi_core", + "uniffi_macros", +] + +[[package]] +name = "uniffi_checksum_derive" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802d2051a700e3ec894c79f80d2705b69d85844dafbbe5d1a92776f8f48b563a" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "uniffi_core" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7687007d2546c454d8ae609b105daceb88175477dac280707ad6d95bcd6f1f" +dependencies = [ + "anyhow", + "bytes", + "log", + "once_cell", + "paste", + "static_assertions", +] + +[[package]] +name = "uniffi_macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c65a5b12ec544ef136693af8759fb9d11aefce740fb76916721e876639033b" +dependencies = [ + "bincode", + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a74ed96c26882dac1ca9b93ca23c827e284bacbd7ec23c6f0b0372f747d59e4" +dependencies = [ + "anyhow", + "bytes", + "siphasher", + "uniffi_checksum_derive", +] + +[[package]] +name = "utexo-minimal-sdk" +version = "0.1.0" +dependencies = [ + "bip39", + "bitcoin", + "serde_json", + "thiserror", + "uniffi", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/minimal-sdk/rust-sdk/Cargo.toml b/minimal-sdk/rust-sdk/Cargo.toml new file mode 100644 index 00000000..ff032bd7 --- /dev/null +++ b/minimal-sdk/rust-sdk/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "utexo-minimal-sdk" +version = "0.1.0" +edition = "2021" +rust-version = "1.94.0" +publish = false +description = "Minimal UTEXO client SDK core: keys, taproot derivation, verify-before-sign, invoices, gateway client. One Rust implementation exposed to Kotlin and Swift through uniffi." +license = "MIT" + +[lib] +name = "utexo_minimal_sdk" +# cdylib: Android JNI library. staticlib: iOS XCFramework. lib: Rust tests and +# consumers (the uniffi-bindgen tool links nothing, it reads metadata symbols). +crate-type = ["cdylib", "staticlib", "lib"] + +[dependencies] +# The whole allowed dependency set. No rgb-lib, no HTTP or TLS stack, no +# async runtime. Every addition is measured against the size gate in +# tests/parity.rs before it lands (docs/plans/20260915-minimal-mobile-rust-sdk.md). +# `secp-recovery` is a feature of the secp256k1 dependency bitcoin already +# carries (BOLT-11 payee recovery, Task 6), not a new crate. +bitcoin = { version = "0.32", default-features = false, features = ["std", "rand-std", "base64", "secp-recovery"] } +bip39 = { version = "2.2", default-features = false, features = ["std", "rand"] } +thiserror = "2.0" +# default-features = false drops `cargo-metadata` (a bindgen-side helper that +# would pull serde_json/semver into the shipped library graph). +uniffi = { version = "0.28.3", default-features = false } + +[dev-dependencies] +# Test-only: parses the rgb-lib parity fixture. Must never become a normal +# dependency; tests/parity.rs asserts it is absent from the library graph. +serde_json = "1.0" + +[profile.release] +opt-level = "z" +lto = "thin" +codegen-units = 1 +strip = "symbols" +# uniffi converts a Rust panic into a foreign exception at the FFI boundary. +# `panic = "abort"` would turn every recoverable error into a host-app crash +# (design doc, Block 2 item 9). +panic = "unwind" + +# Own workspace: never pull the rgb-lightning-node dependency graph +# (same pattern as bindings/c-ffi/Cargo.toml). +[workspace] diff --git a/minimal-sdk/rust-sdk/README.md b/minimal-sdk/rust-sdk/README.md new file mode 100644 index 00000000..8ea2310a --- /dev/null +++ b/minimal-sdk/rust-sdk/README.md @@ -0,0 +1,353 @@ +# utexo-minimal-sdk — the Rust core of the minimal mobile SDK + +One Rust implementation of everything that guards user funds in the Minimal SDK +variant — key management, taproot derivation, verify-before-sign, signing, invoice +decoding and the gateway client — exposed to **Kotlin** ([`../android`](../android)) +and **Swift** ([`../apple`](../apple)) through uniffi. Plan and rationale: +`docs/plans/20260915-minimal-mobile-rust-sdk.md`; architecture and size argument: +`docs/design/mobile-sdk-and-lightweight-uniffi.md`; trust model and invariants I1–I4: +`docs/design/minimal-sdk-and-lightweight-rln.md`. + +**This is not the node.** No LDK, no rgb-lib, no axum, no sea-orm, no chain backend, +no HTTP or TLS stack, no async runtime. The library graph is `bitcoin`, `bip39`, +`thiserror` and `uniffi`; `tests/parity.rs` fails the build if anything banned +(`rgb-lib`, `reqwest`, `tokio`, `rustls`, `openssl`) ever resolves. + +**The TypeScript client SDK** (`../packages/client-sdk`) is the behavioural reference +this crate was ported from and stays shipped. It is the independent cross-check on this +implementation, not legacy: both read the same rgb-lib-authored parity fixture and share +no code. + +**The mnemonic is the user's only backup.** Keys are generated and stay on the device +(invariant I1); the gateway only ever sees the two account xpubs and the master +fingerprint (I2). Nothing in this crate logs, `Debug`-prints or renders a mnemonic, +seed, xprv or private key, including error variants (I3) — the tests assert it. + +## Layout + +| Module | What it holds | TS reference | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | +| `network.rs` | the four networks, BIP-32 version bytes, rgb-lib coin types, account paths `m/86'/coin'/0'` | `network.ts` | +| `keys.rs` | `ClientKeys`: BIP-39 → BIP-32 vanilla + colored accounts; xpubs and fingerprint are the only public material | `keys.ts` | +| `derive.rs` | taproot `tr(key)` derivation and `match_origin_path`, the single source of truth for path acceptance | `derive.ts` | +| `verify.rs` | the five checks, `own_derivation` (fingerprint-based key-origin selection) | `verify.ts` | +| `sign.rs` | `verify_and_sign_psbt`, the only signing entry point; BIP-341 key-path sign + finalize | `sign.ts` | +| `invoice.rs` | BOLT-11 and RGB invoice decoding | `invoice.ts` | +| `gateway.rs` | `GatewayClient`, the `HttpTransport` foreign trait, intent binding | `gateway.ts` | +| `ffi.rs` | the exported free functions (`sdk_version`, `generate_keys`, `derive_taproot_address`, `verify_psbt`, `verify_and_sign_psbt`, `decode_bolt11`, `decode_rgb_invoice`) | — | + +Bindings are generated in uniffi **library mode** from the built library; there is no +UDL file and none may be added (`tests/parity.rs::no_udl_file_exists`). + +## Quickstart + +The journey is the same on both platforms: generate keys → register xpubs → prepare → +verify → sign → complete. Every call below is **blocking** and must run off the UI +thread (see "Mobile runtime constraints"). + +### Kotlin + +```kotlin +import com.utexo.minimalsdk.* + +// 1. Generate keys on the device. Show `mnemonic` once for backup; it never leaves +// the phone. Restore later with ClientKeys.fromMnemonic(mnemonic, network). +val generated = generateKeys(BitcoinNetwork.MAINNET) +val keys = generated.keys + +// 2. Connect. The bearer token comes from the operator-run user bootstrap +// (POST /v1/users) and is returned exactly once. The transport is yours (below). +val client = GatewayClient("https://gateway.example.com", token, null, OkHttpTransport()) + +// 3. Register the account xpubs; the gateway builds a per-user watch-only wallet +// from them and returns the first vault address. Public material only. +val xpubs = keys.xpubs() +val registered = client.registerXpubs( + RegisterXpubsParams(vanilla = xpubs.vanilla, colored = xpubs.colored, fingerprint = xpubs.fingerprint), +) + +// 4. Send BTC: the server prepares, the CLIENT verifies and signs, the server +// broadcasts. prepareSendBtc binds the returned intent to the request you made +// (SdkException.IntentMismatch otherwise); verifyAndSignPsbt refuses to sign +// unless all five checks pass (SdkException.VerificationFailed names the check). +val idem = generateIdempotencyKey() +val prepared = client.prepareSendBtc( + PrepareSendBtcParams(address = "bc1p…", amountSat = 50_000UL, feeRateSatPerVb = 2UL), + idem, +) +val signed = verifyAndSignPsbt( + keys, + prepared.psbt, + VerifyParams(intent = prepared.intent, xpubs = xpubs, maxFeeSat = 1_000UL), // user-approved fee budget +) +val txid = client.completeSendBtc(CompleteParams(opId = prepared.opId, signedPsbt = signed.signedPsbt), idem).txid +``` + +A transport over `HttpURLConnection` (OkHttp works the same way; only `send` exists): + +```kotlin +class OkHttpTransport : HttpTransport { + override fun send(request: HttpRequest): HttpResponse { + val conn = (java.net.URL(request.url).openConnection() as java.net.HttpURLConnection).apply { + requestMethod = if (request.method == HttpMethod.GET) "GET" else "POST" + connectTimeout = request.timeoutMs.toInt() + readTimeout = request.timeoutMs.toInt() + request.headers.forEach { (name, value) -> setRequestProperty(name, value) } + } + try { + request.body?.let { body -> conn.doOutput = true; conn.outputStream.use { it.write(body.toByteArray()) } } + val status = conn.responseCode + val stream = if (status < 400) conn.inputStream else conn.errorStream + val body = stream?.bufferedReader()?.use { it.readText() } ?: "" + return HttpResponse(status.toUShort(), body) + } catch (e: java.io.IOException) { + throw SdkException.Transport(e.message ?: "io error") // never include headers: they carry the token + } finally { + conn.disconnect() + } + } +} +``` + +### Swift + +```swift +import MinimalSdk + +// 1. Generate keys on the device. Show `mnemonic` once for backup; it never leaves +// the phone. Restore later with ClientKeys.fromMnemonic(mnemonic:network:). +let generated = try generateKeys(network: .mainnet) +let keys = generated.keys + +// 2. Connect. URLSessionHttpTransport ships with the package; any HttpTransport +// conformance can replace it. +let client = GatewayClient(baseUrl: "https://gateway.example.com", token: token, + timeoutMs: nil, transport: URLSessionHttpTransport()) + +// 3. Register the account xpubs (public material only). +let xpubs = keys.xpubs() +let registered = try client.registerXpubs( + params: RegisterXpubsParams(vanilla: xpubs.vanilla, colored: xpubs.colored, fingerprint: xpubs.fingerprint)) + +// 4. Send BTC: prepare (intent bound to the request, SdkError.IntentMismatch +// otherwise) → verify and sign (SdkError.VerificationFailed names the failed +// check; nothing is signed) → complete. +let idem = try generateIdempotencyKey() +let prepared = try client.prepareSendBtc( + params: PrepareSendBtcParams(address: "bc1p…", amountSat: 50_000, feeRateSatPerVb: 2), + idempotencyKey: idem) +let signed = try verifyAndSignPsbt( + keys: keys, psbt: prepared.psbt, + params: VerifyParams(intent: prepared.intent, xpubs: xpubs, maxFeeSat: 1_000, // user-approved fee budget + changeScanWindow: nil, maxOwnOutputIndex: nil)) +let txid = try client.completeSendBtc( + params: CompleteParams(opId: prepared.opId, signedPsbt: signed.signedPsbt), idempotencyKey: idem).txid +``` + +Asset sends (`prepareSendAsset` / `completeSendAsset`) and colorable-UTXO creation +(`prepareCreateUtxos` / `completeCreateUtxos`) follow the same prepare → verify-and-sign +→ complete shape. Receiving (`receive`), balances, unspents, transfers, LN float +deposits, invoices, payments and withdrawals round out `GatewayClient`; +`decodeBolt11` / `decodeRgbInvoice` decode invoices locally for display before paying. +Money-moving calls take an idempotency key: retry the **same** operation with the +**same** key to get the cached response instead of a duplicate spend. + +## The verify-before-sign contract + +Signing is never blind. `verify_and_sign_psbt` is the only signing entry point; it runs +`verify_psbt` internally and refuses on any failure, before a single input is signed. +`verify_psbt` can also be called on its own to show the user what they are about to +approve. It never errors and never panics on hostile input: a malformed PSBT is a +verdict with all five checks failed, not a crash. The five checks, in evaluation order +and with the same wire names as the TypeScript SDK: + +1. **`inputs-own` — every input is ours.** The input's key-origin entry is selected **by + our master fingerprint, never by path shape**, and its path must re-derive from _our_ + xpubs to the exact script being spent. Inputs are accepted on either account and at + any index: this is what a hostile PSBT can least fake. +2. **`recipients-match` — recipient outputs match the user-stated intent.** Script and + amount, matched as a multiset. The expected script is **re-derived from the + human-readable address** the user reviews; the server's `script_hex` is only + cross-checked against it, otherwise a hostile gateway could pair the intended address + with an attacker script and both would "match". Witness asset sends additionally + require exactly one foreign output carrying exactly the approved witness amount. +3. **`change-own` — change pays only us, at a recoverable path.** Every output that is + not an intended recipient, the approved witness output or an OP_RETURN must re-derive + from our vanilla or colored account at **keychain 0 and index ≤ `max_own_output_index`** + (default 10 000). Any index under the account xpub is technically the user's, but only + indexes a descriptor wallet scans are recoverable, so a hostile gateway must not be + able to park "change" at index 10⁸. Outputs with no key-origin metadata are accepted + only if they match a script within the first `change_scan_window` indexes (default 30; + 0 requires metadata on every output). The input/output asymmetry (inputs: any path; + outputs: keychain 0, bounded) is deliberate. +4. **`fee-budget` — inputs − outputs is positive and at most `max_fee_sat`**, the fee + budget the user approved. +5. **`opret-zero` — OP_RETURN outputs carry 0 sats.** The RGB opret commitment may exist + but may not carry value. + +**Intent binding is what makes check 2 non-vacuous.** The intent is server-produced; on +its own it proves nothing against a hostile gateway, which can put an attacker output in +the PSBT and the same attacker output in the intent. `prepare_send_btc`, +`prepare_send_asset` and `prepare_create_utxos` therefore compare the returned intent +field by field against the request you made (kind, recipient address and amount, asset +id, amount and recipient id, witness amount, recipient count, no smuggled asset or UTXO +shape, and any optional you pinned such as the fee rate) and return `IntentMismatch` +listing every divergence before the intent can reach `verify_psbt`. Build an intent some +other way and that binding is yours to make. + +**Decoys.** A PSBT may carry several key-origin entries per input. Verify selects the +entry by our fingerprint, and sign signs with the very entry verify proved, so a decoy +entry with a foreign fingerprint ordered first cannot divert signing to a key no check +covered. `match_origin_path` is the single path-acceptance rule shared by verify and by +`private_key_for_path`, so the two cannot drift apart. + +**Stated limitation.** The checks verify all _bitcoin-value_ movement. RGB allocations +on the spent inputs are **not verifiable client-side**: the opret commitment is opaque +here, so every server-prepared **colored** spend trusts the gateway for the RGB state +transition at send time (design doc, "send-time trust"; `verify.ts:11-13`). The +mitigations are the gateway's, not this crate's. + +## The `HttpTransport` extension point + +Rust performs no network IO. `GatewayClient` builds every request (method, absolute +percent-encoded URL, lowercase headers including `authorization`, `idempotency-key` and +`content-type`, optional UTF-8 JSON body, timeout) and parses every response; the host +app moves the bytes through one foreign-implemented method: + +```rust +#[uniffi::export(with_foreign)] +pub trait HttpTransport: Send + Sync { + fn send(&self, request: HttpRequest) -> Result; +} +``` + +- Return `HttpResponse { status, body }` for **any** HTTP status, 4xx and 5xx included; + Rust turns those into `SdkError::Gateway { status, code, detail }`. +- Return `SdkError::Transport { reason }` only when no HTTP status exists (DNS, TLS, + timeout). Never put request headers in the reason: they carry the bearer token. +- Anything else the transport throws (a Kotlin `RuntimeException`, a Swift `Error` that is + not `SdkError`) is mapped by Rust to `SdkError::Transport` with uniffi's diagnostic as + the reason. It never surfaces as an internal error or a crash; the smoke tests on both + bindings throw a foreign exception through Rust to prove it. +- The call is synchronous from Rust's side, so there is no async-over-FFI; the transport + may block. Kotlin: OkHttp or `HttpURLConnection`. Swift: the shipped + `URLSessionHttpTransport` (ephemeral session, blocks on a semaphore bounded by + `timeout_ms`). +- Rust never renders the bearer token into an error, and its `Debug` for `HttpRequest` + and `CreatedUser` redacts it (asserted in `tests/parity.rs`; the binding smoke tests + assert every error is token-free). That redaction stops at the FFI: over uniffi + `HttpRequest` is a plain Kotlin `data class` / Swift `struct` whose default `toString()` + prints the headers verbatim, so a transport must never log the request it receives. + +## Mobile runtime constraints (design doc Block 3) and this SDK's answer + +The design doc lists four constraints for a node on the phone. Being explicit about +which apply here is what makes the minimal variant's case. + +- **Process death is normal.** _Applies, cheaply._ This is a **stateless** client: it + holds no channel state, no database, no background task. Process death costs nothing + beyond an in-flight request. Hold the mnemonic in the platform keystore and re-create + `ClientKeys` with `fromMnemonic` on restore; `GatewayClient` is re-created for free. + A prepared-but-unsigned operation simply expires on the gateway (`PreparedOp.expires_at`). +- **Every binding call is blocking.** _Applies, and is stated plainly:_ every uniffi call + runs on the calling thread. `verify_and_sign_psbt` is milliseconds of secp256k1 work; + every `GatewayClient` call blocks for the whole HTTP round trip inside your transport. + **Never call the SDK on the Android main thread** (an ANR, exactly as the design doc + flags for the node SDK — it applies to any uniffi surface) **or on the iOS main queue.** + Use a coroutine on `Dispatchers.IO`, a `Task.detached`, or a background queue. +- **The fail-fast watchdog.** _Does not apply._ There is no node, no event loop and no + `process::exit`. The release profile keeps `panic = "unwind"` so uniffi converts any + Rust panic into a foreign exception; every exported function returns a typed error and + `verify_psbt` returns a failed verdict on hostile input. +- **Battery, data and chain backends.** _Does not apply._ No chain sync, no indexer + polling, no peer connections. The only network traffic is the HTTPS calls the app makes + through its own transport, when the user acts. + +## Measured sizes + +Release profile `opt-level = "z"`, thin LTO, `codegen-units = 1`, `strip = "symbols"`, +measured 2026-09-15. + +| Artifact | Raw | gzip -9 | Note | +| ------------------------------------------- | ------------------------: | ----------: | --------------------------------------------------------------------------------------- | +| Host `x86_64-unknown-linux-gnu` cdylib | 2 493 352 B | 1 682 403 B | size gate in `tests/parity.rs`: < 2 500 000 B | +| Android `arm64-v8a` `.so` (NDK r29) | 2 366 720 B | 1 689 588 B | gate in `../scripts/build_android.sh`: < 3 000 000 B; `.text` 640 KB, `.rodata` 1.30 MB | +| Android `armeabi-v7a` / `x86_64` `.so` | 1 992 820 B / 2 508 984 B | — | | +| Android AAR (3 ABIs + 514 KB `classes.jar`) | 5 449 477 B | — | a device installs one slice | +| JNA 5.14.0 AAR (consumer-side dependency) | 517 894 B | — | uniffi's Kotlin bindings need it | +| iOS | not an artifact number | | see below | + +About 1.06 MB of every slice is libsecp256k1's precomputed tables, which every secp256k1 +binding (Rust or Kotlin) carries. The plan's scratch measurement (derive/PSBT/sign only) +was 1.94 MB; the finished crate adds the gateway client, the JSON codec and both invoice +decoders for the difference. + +- **Raw vs compressed.** Android install size tracks the **raw** number: APKs and App + Bundles store native libraries uncompressed (`extractNativeLibs=false`, default since + API 23) so they can be page-mapped. The gzip column is what a download costs, not + what the device keeps. What an arm64 device installs is one slice plus JNA, + **≈ 2.88 MB**, against ≈ 1.9 MB for a pure-Kotlin SDK that avoids JNA. +- **iOS: measure the app-size delta, not the archive.** iOS links statically and the app + linker dead-strips the archive, so the `.a` size is not what the user downloads. The + number that matters is the delta in the app's installed size with and without the SDK + linked, on a device build with App Thinning; expect the Android arm64 slice as an + upper bound, less after dead-stripping. `build_apple.sh` prints archive sizes and has + no gate by design. +- **16 KB page alignment.** Every `PT_LOAD` segment of every Android `.so` is aligned to + 16 384 bytes (measured `0x4000` on all three ABIs). NDK r28+ aligns the 64-bit ABIs by + default but leaves `armeabi-v7a` at 4 096, so `build_android.sh` passes + `-Wl,-z,max-page-size=16384` to every target and fails the build on any misaligned + segment. Required for native code targeting Android 15+. + +## Building and testing + +```sh +# Rust: the real gate — 99 tests (31 unit + 68 in tests/parity.rs), the size gate and +# the dependency-creep guard. +cargo test --manifest-path minimal-sdk/rust-sdk/Cargo.toml + +# Android: bindings, host cdylib, Kotlin smoke, cargo ndk for 3 ABIs, gates, AAR. +# The Gradle wrapper lives in minimal-sdk/android; there is none at the repo root. +JAVA_HOME=~/.jdks/temurin-17 ./minimal-sdk/android/gradlew -p minimal-sdk/android test assembleRelease + +# Apple (macOS + Xcode): 5 targets → XCFramework → Swift smoke on the macOS slice. +minimal-sdk/scripts/build_apple.sh && swift test --package-path minimal-sdk/apple +``` + +`minimal-sdk/scripts/generate_bindings.sh [output_dir] [library_path]` +regenerates bindings from an unstripped host debug build (library mode reads the +`UNIFFI_META_*` symbols, which the release profile strips; the metadata is +target-independent); pass `library_path` to reuse an existing build. The other two +scripts' options are listed in `../android/README.md` (`build_android.sh`) and +`../apple/README.md` (`build_apple.sh`). CI is +`.github/workflows/minimal-sdk-mobile.yaml`: the Rust job is the gate, the Android job +runs the Gradle command above plus the alignment and size checks, the macOS job is the +only place the XCFramework is assembled. + +### Parity: what is verified, and where + +The tests read `../packages/client-sdk/test/fixtures/rgblib-parity.json` by relative +path. It was generated from the real `@utexo/rgb-lib` binding and is never copied or +regenerated here: one rgb-lib-authored ground truth, checked by an implementation that +shares no code with it. Regenerating the fixture re-runs the whole suite in CI. + +| Acceptance item | Test (`tests/parity.rs` unless noted) | Result 2026-09-15 | +| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Account xpubs + master fingerprint `73c5da0a` for all four networks | `keys_all_four_networks_match_rgb_lib_fixture_exactly` | pass | +| All ten regtest addresses (5 vanilla + 5 colored, indexes 0..4) | `derive_reproduces_rgb_lib_regtest_addresses_at_keychain_0_indexes_0_to_4` | pass | +| Fixture PSBT signs to `signing.txid` with a verifying BIP-340 signature | `sign_fixture_psbt_matches_rgb_lib_txid_with_a_valid_key_path_signature` | pass; txid parity + signature validity, never witness bytes (BIP-340 is randomized) | +| Each of the five checks fails alone on its own tampering | `check1_…inputs_own`, `check2_…recipients_match` (×3), `check3_…change_own` (×2), `check4_…fee_budget`, `check5_…opret_zero` | pass | +| Decoy key-origin entry cannot divert selection or signing | `decoy_key_origin_entry_does_not_divert_selection`, `sign_through_a_decoy_key_origin_entry_ordered_before_ours` | pass | +| Intent binding fires on every tamper shape | `intent_binding_send_btc_…`, `intent_binding_send_asset_…`, `intent_binding_create_utxos_…` | pass | +| Hostile or truncated PSBTs never panic | `garbage_and_truncated_psbts_fail_all_five_without_panicking`, `gateway_hostile_bodies_never_panic` | pass | +| No secret ever renders | `keys_debug_and_xpubs_render_no_secret`, `sign_errors_never_render_key_material_and_never_panic`, `gateway_requests_never_carry_secrets_and_token_never_renders` | pass | +| No `rgb-lib` / `reqwest` / `tokio` / TLS crate in the graph | `dependency_creep_guard` (+ `cargo tree -i` for each: no match) | pass; 59 graph entries | +| Size gate | `size_gate_release_cdylib_under_budget` | pass, 2 491 688 B | +| Kotlin smoke (15 cases) through JNA, both variants | `../android/src/test/kotlin/…/SmokeTest.kt` | pass | +| Swift smoke (17 cases, 3 of them on the shipped `URLSessionHttpTransport`) | `../apple/Tests/MinimalSdkTests/SmokeTests.swift` | pass on Linux (Swift 6.0.3, host cdylib); on macOS the `apple` job of `minimal-sdk-mobile.yaml` runs the same suite against the XCFramework slice | + +The Kotlin and Swift suites are deliberately smoke-only: they prove the binding surface +(every lowering shape the API uses: objects, records with optionals, lists, maps and +enums, the typed errors), the native load and the foreign transport end to end, in both +directions (a transport that throws the declared error and one that throws a foreign +exception). Every deep assertion lives once, in Rust. diff --git a/minimal-sdk/rust-sdk/src/derive.rs b/minimal-sdk/rust-sdk/src/derive.rs new file mode 100644 index 00000000..27d6e8ed --- /dev/null +++ b/minimal-sdk/rust-sdk/src/derive.rs @@ -0,0 +1,363 @@ +//! Taproot re-derivation and origin-path matching. +//! +//! Re-derivation of taproot `tr(key)` scripts/addresses from account xpubs at +//! arbitrary keychain/index is the client-side half of change verification +//! (verify-before-sign check 3) and input-ownership proofs (check 1). It is +//! also how the app produces receive addresses without asking the gateway. +//! +//! Behavioural reference: `minimal-sdk/packages/client-sdk/src/derive.ts`. +//! Parity against rgb-lib's addresses is checked in `tests/parity.rs`. + +use bitcoin::bip32::{ChildNumber, Xpub}; +use bitcoin::hex::DisplayHex; +use bitcoin::key::XOnlyPublicKey; +use bitcoin::{Address, ScriptBuf}; + +use crate::keys::AccountXpubs; +use crate::network::{account_path_u32, BitcoinNetwork, HARDENED}; +use crate::{secp, SdkError, SdkResult}; + +/// A taproot `tr(key)` output re-derived at `keychain/index` under one of the +/// two account xpubs. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct DerivedScript { + pub keychain: u32, + pub index: u32, + /// BIP-341 tweaked output script (`OP_1 <32-byte key>`), lowercase hex. + pub script_hex: String, + /// Bech32m address for the network the xpub was derived on. + pub address: String, + /// Untweaked x-only internal key, 32 bytes lowercase hex. + pub internal_key_hex: String, +} + +/// Parse an account xpub string and check its version bytes belong to +/// `network` (xpub on mainnet, tpub everywhere else) — mirroring +/// `HDKey.fromExtendedKey(xpub, hdVersions(network))`, which rejects a key +/// serialized for a different network. Hardened derivation below an xpub is +/// impossible, so the account must already be `m/86'/coin'/0'`. +/// BIP-32 depth of an account key: `m / 86' / coin' / account'`. +const ACCOUNT_DEPTH: u8 = 3; + +pub fn parse_account_xpub(xpub: &str, network: BitcoinNetwork) -> SdkResult { + let parsed: Xpub = xpub.parse().map_err(|_| SdkError::InvalidInput { + reason: "invalid account xpub: not a BIP-32 extended public key".into(), + })?; + if parsed.network != network.network_kind() { + return Err(SdkError::InvalidInput { + reason: format!("invalid account xpub: version bytes are not for {network}"), + }); + } + // Only an account-level key (`m/86'/coin'/account'`, depth 3, hardened + // last step) makes `keychain/index` land where rgb-lib's addresses are. + // A master or deeper key would derive silently wrong addresses. + if parsed.depth != ACCOUNT_DEPTH || !parsed.child_number.is_hardened() { + return Err(SdkError::InvalidInput { + reason: format!( + "invalid account xpub: expected an account-level key (depth {ACCOUNT_DEPTH}, hardened), got depth {}", + parsed.depth + ), + }); + } + Ok(parsed) +} + +/// The two account xpubs as parsed keys, the form verify and +/// [`derive_for_origin_path`] consume. Public material only. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedAccounts { + pub network: BitcoinNetwork, + pub vanilla: Xpub, + pub colored: Xpub, +} + +impl ParsedAccounts { + /// Parse the registration material returned by + /// [`crate::keys::ClientKeys::xpubs`] (or received back from the gateway). + pub fn parse(xpubs: &AccountXpubs) -> SdkResult { + Ok(ParsedAccounts { + network: xpubs.network, + vanilla: parse_account_xpub(&xpubs.vanilla, xpubs.network)?, + colored: parse_account_xpub(&xpubs.colored, xpubs.network)?, + }) + } + + /// The account xpub for the colored (RGB) or vanilla (BTC) side. + pub fn account(&self, colored: bool) -> &Xpub { + if colored { + &self.colored + } else { + &self.vanilla + } + } +} + +/// Derive the taproot script/address at `keychain/index` under an account +/// xpub: derive the two unhardened children, take the x-only internal key, +/// build the BIP-341 `tr(key)` script (key-path only, no script tree) and its +/// bech32m address for `network`. +/// +/// Errors only on a hardened `keychain`/`index` (impossible from an xpub). +/// It never panics: the values may come from attacker-controlled PSBT +/// metadata. +pub fn derive_taproot( + account_xpub: &Xpub, + keychain: u32, + index: u32, + network: BitcoinNetwork, +) -> SdkResult { + let child = |what: &str, value: u32| { + ChildNumber::from_normal_idx(value).map_err(|_| SdkError::DerivationFailed { + reason: format!("{what} {value} is not an unhardened child index"), + }) + }; + let children = [child("keychain", keychain)?, child("index", index)?]; + let key = + account_xpub + .derive_pub(secp(), &children) + .map_err(|_| SdkError::DerivationFailed { + reason: format!("public derivation at {keychain}/{index} failed"), + })?; + let internal_key: XOnlyPublicKey = key.public_key.into(); + let script = ScriptBuf::new_p2tr(secp(), internal_key, None); + let address = Address::p2tr(secp(), internal_key, None, network.to_bitcoin()); + Ok(DerivedScript { + keychain, + index, + script_hex: script.as_bytes().to_lower_hex_string(), + address: address.to_string(), + internal_key_hex: internal_key.serialize().to_lower_hex_string(), + }) +} + +/// Which of the wallet's two accounts a key-origin path belongs to, and the +/// unhardened `keychain/index` under it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OriginPathMatch { + pub colored: bool, + pub keychain: u32, + pub index: u32, +} + +/// Match a full master-based key-origin path (`[86', coin', 0', keychain, +/// index]`) against the user's two accounts. +/// +/// **Single source of truth for path acceptance.** Verify proves scripts with +/// it (through [`derive_for_origin_path`]) and sign (via +/// [`crate::keys::ClientKeys::private_key_for_path`]) picks signing keys with +/// it. The two must never drift, or verify could approve a path that sign +/// will not use. Accepts exactly five children: the three hardened account +/// children of one of the two accounts for `network`, then an unhardened +/// keychain and an unhardened index. Returns `None` — never an error, never a +/// panic — on foreign paths: paths are attacker-controlled PSBT input. +pub fn match_origin_path(path: &[u32], network: BitcoinNetwork) -> Option { + for colored in [false, true] { + let prefix = account_path_u32(network, colored); + if path.len() != prefix.len() + 2 { + continue; + } + if path[..prefix.len()] != prefix { + continue; + } + let keychain = path[prefix.len()]; + let index = path[prefix.len() + 1]; + if keychain >= HARDENED || index >= HARDENED { + continue; + } + return Some(OriginPathMatch { + colored, + keychain, + index, + }); + } + None +} + +/// Re-derive the taproot script for a full master-based key-origin path +/// against the matching account xpub; `None` when the path is not ours or +/// the derivation fails. Never errors, never panics — the path is PSBT input. +pub fn derive_for_origin_path( + path: &[u32], + accounts: &ParsedAccounts, + network: BitcoinNetwork, +) -> Option { + let m = match_origin_path(path, network)?; + derive_taproot(accounts.account(m.colored), m.keychain, m.index, network).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + const H: u32 = HARDENED; + + #[test] + fn matches_both_accounts_only() { + let net = BitcoinNetwork::Regtest; + assert_eq!( + match_origin_path(&[H + 86, H + 1, H, 0, 7], net), + Some(OriginPathMatch { + colored: false, + keychain: 0, + index: 7 + }) + ); + assert_eq!( + match_origin_path(&[H + 86, H + 827_167, H, 1, 3], net), + Some(OriginPathMatch { + colored: true, + keychain: 1, + index: 3 + }) + ); + // Mainnet coin types are foreign on regtest and vice versa. + assert_eq!(match_origin_path(&[H + 86, H, H, 0, 0], net), None); + assert_eq!( + match_origin_path(&[H + 86, H + 827_166, H, 0, 0], net), + None + ); + assert_eq!( + match_origin_path(&[H + 86, H, H, 0, 0], BitcoinNetwork::Mainnet), + Some(OriginPathMatch { + colored: false, + keychain: 0, + index: 0 + }) + ); + } + + #[test] + fn rejects_foreign_shapes_without_erroring() { + let net = BitcoinNetwork::Regtest; + let cases: &[&[u32]] = &[ + &[], + &[H + 86], + &[H + 86, H + 1, H], + &[H + 86, H + 1, H, 0], + &[H + 86, H + 1, H, 0, 0, 0], + &[H + 84, H + 1, H, 0, 0], + &[86, H + 1, H, 0, 0], + &[H + 86, 1, H, 0, 0], + &[H + 86, H + 1, 0, 0, 0], + &[H + 86, H + 1, H + 1, 0, 0], + &[H + 86, H + 1, H, H, 0], + &[H + 86, H + 1, H, 0, H], + &[H + 86, H + 1, H, u32::MAX, 0], + ]; + for case in cases { + assert_eq!(match_origin_path(case, net), None, "{case:?}"); + } + } + + /// A deterministic account xpub for `kind`, from a fixed 32-byte seed. + fn test_xpub(kind: bitcoin::NetworkKind) -> Xpub { + let master = bitcoin::bip32::Xpriv::new_master(kind, &[7u8; 32]).unwrap(); + let account = master + .derive_priv( + secp(), + &account_path_u32(BitcoinNetwork::Mainnet, false).map(ChildNumber::from), + ) + .unwrap(); + Xpub::from_priv(secp(), &account) + } + + #[test] + fn derive_taproot_rejects_hardened_children_without_panicking() { + let xpub = test_xpub(bitcoin::NetworkKind::Main); + for (keychain, index) in [(HARDENED, 0), (0, HARDENED), (u32::MAX, u32::MAX)] { + assert!(matches!( + derive_taproot(&xpub, keychain, index, BitcoinNetwork::Mainnet), + Err(SdkError::DerivationFailed { .. }) + )); + } + let ok = derive_taproot(&xpub, 0, 0, BitcoinNetwork::Mainnet).unwrap(); + assert!(ok.address.starts_with("bc1p")); + assert_eq!(ok.script_hex.len(), 68); + assert!(ok.script_hex.starts_with("5120")); + assert_eq!(ok.script_hex, ok.script_hex.to_lowercase()); + assert_eq!(ok.internal_key_hex.len(), 64); + // Deterministic. + assert_eq!( + derive_taproot(&xpub, 0, 0, BitcoinNetwork::Mainnet).unwrap(), + ok + ); + assert_ne!( + derive_taproot(&xpub, 0, 1, BitcoinNetwork::Mainnet).unwrap(), + ok + ); + } + + #[test] + fn parse_account_xpub_checks_version_bytes() { + let mainnet = test_xpub(bitcoin::NetworkKind::Main).to_string(); + let testnet = test_xpub(bitcoin::NetworkKind::Test).to_string(); + assert!(mainnet.starts_with("xpub")); + assert!(testnet.starts_with("tpub")); + assert!(parse_account_xpub(&mainnet, BitcoinNetwork::Mainnet).is_ok()); + assert!(matches!( + parse_account_xpub(&testnet, BitcoinNetwork::Mainnet), + Err(SdkError::InvalidInput { .. }) + )); + for net in [ + BitcoinNetwork::Testnet, + BitcoinNetwork::Signet, + BitcoinNetwork::Regtest, + ] { + assert!(parse_account_xpub(&testnet, net).is_ok(), "{net}"); + assert!(matches!( + parse_account_xpub(&mainnet, net), + Err(SdkError::InvalidInput { .. }) + )); + } + for bad in ["not-an-xpub", "", "xpub", &mainnet[..mainnet.len() - 1]] { + let err = parse_account_xpub(bad, BitcoinNetwork::Mainnet).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput { .. }), "{bad:?}"); + // The error never echoes the caller's input. + assert!(!err.to_string().contains(&mainnet[..20])); + } + } + + #[test] + fn parse_account_xpub_rejects_keys_that_are_not_account_level() { + let master = + bitcoin::bip32::Xpriv::new_master(bitcoin::NetworkKind::Main, &[7u8; 32]).unwrap(); + let account = master + .derive_priv( + secp(), + &account_path_u32(BitcoinNetwork::Mainnet, false).map(ChildNumber::from), + ) + .unwrap(); + let master_xpub = Xpub::from_priv(secp(), &master).to_string(); + let account_xpub = Xpub::from_priv(secp(), &account); + let keychain_xpub = account_xpub + .derive_pub(secp(), &[ChildNumber::from_normal_idx(0).unwrap()]) + .unwrap() + .to_string(); + let unhardened_depth3 = Xpub::from_priv( + secp(), + &master + .derive_priv( + secp(), + &[ + ChildNumber::from_hardened_idx(86).unwrap(), + ChildNumber::from_hardened_idx(0).unwrap(), + ChildNumber::from_normal_idx(0).unwrap(), + ], + ) + .unwrap(), + ) + .to_string(); + assert!(parse_account_xpub(&account_xpub.to_string(), BitcoinNetwork::Mainnet).is_ok()); + for (what, bad) in [ + ("master (depth 0)", master_xpub), + ("keychain (depth 4)", keychain_xpub), + ("unhardened depth 3", unhardened_depth3), + ] { + let err = parse_account_xpub(&bad, BitcoinNetwork::Mainnet).unwrap_err(); + assert!( + matches!(&err, SdkError::InvalidInput { reason } if reason.contains("account-level")), + "{what}: {err:?}" + ); + } + } +} diff --git a/minimal-sdk/rust-sdk/src/ffi.rs b/minimal-sdk/rust-sdk/src/ffi.rs new file mode 100644 index 00000000..87bb93cb --- /dev/null +++ b/minimal-sdk/rust-sdk/src/ffi.rs @@ -0,0 +1,110 @@ +//! The exported free-function surface. Exported *objects* (`ClientKeys`) keep +//! their `#[uniffi::export] impl` block next to the type, in their own module. +//! +//! Every exported function must return a plain value or [`crate::SdkResult`] +//! and must never panic on caller-controlled input: a panic here becomes a +//! host-app crash (uniffi turns it into a foreign exception at best). +//! +//! Bindings are generated in **library mode** (`uniffi-bindgen generate +//! --library`); there is no UDL file and none may be added. +//! `uniffi::setup_scaffolding!()` lives in `lib.rs` because uniffi requires +//! it at the crate root. +//! +//! Generate bindings from an **unstripped host build** (`cargo build --lib`, +//! i.e. `target/debug/libutexo_minimal_sdk.so`): library mode reads the +//! `UNIFFI_META_*` entries from the ELF `.symtab`, which the release profile's +//! `strip = "symbols"` removes. The metadata is profile- and target-independent, +//! so bindings generated from the debug host build match the stripped release +//! and cross-compiled libraries exactly. + +use crate::derive::{derive_taproot, parse_account_xpub, DerivedScript}; +use crate::invoice::{Bolt11Invoice, RgbInvoice}; +use crate::keys::{ClientKeys, GeneratedKeys}; +use crate::network::BitcoinNetwork; +use crate::sign::SignResult; +use crate::verify::{VerifyParams, VerifyVerdict}; +use crate::SdkResult; +use std::sync::Arc; + +/// Version of this SDK core, from `Cargo.toml`. Trivial export used to prove +/// the binding pipeline end to end (Kotlin/Swift smoke tests call it first). +#[uniffi::export] +pub fn sdk_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +/// Generate a fresh 12-word mnemonic and the keys derived from it. The +/// returned mnemonic is the user's only backup: show it once, never send it. +#[uniffi::export] +pub fn generate_keys(network: BitcoinNetwork) -> SdkResult { + ClientKeys::generate(network) +} + +/// Re-derive the taproot `tr(key)` script and address at `keychain/index` +/// under an **account xpub** (public material only). This is how the app +/// produces receive addresses locally and how it can cross-check an address +/// the gateway hands back. `InvalidInput` when the xpub is malformed or +/// serialized for another network; `DerivationFailed` on a hardened +/// `keychain`/`index`. +#[uniffi::export] +pub fn derive_taproot_address( + account_xpub: String, + keychain: u32, + index: u32, + network: BitcoinNetwork, +) -> SdkResult { + let xpub = parse_account_xpub(&account_xpub, network)?; + derive_taproot(&xpub, keychain, index, network) +} + +/// Run the five verify-before-sign checks on a base64 PSBT against the +/// gateway's intent summary and the user's own xpubs. Never fails and never +/// panics: hostile or unparseable input is a verdict with every check failed +/// and the reason in each `detail`. Signing (`verify_and_sign_psbt`) runs +/// this internally and refuses on any failure; call it directly to show the +/// user what they are about to approve. +#[uniffi::export(name = "verify_psbt")] +pub fn ffi_verify_psbt(psbt: String, params: VerifyParams) -> VerifyVerdict { + crate::verify::verify_psbt(&psbt, ¶ms) +} + +/// Verify the PSBT against the intent, then BIP-341 key-path sign every +/// input and finalize. The **only** signing entry point: verification runs +/// inside and `VerificationFailed` (naming the failed checks) is returned +/// before anything is signed. `params.xpubs` must be `keys.xpubs()`. +/// Returns the signed+finalized base64 PSBT, its txid and the passing verdict. +#[uniffi::export(name = "verify_and_sign_psbt")] +pub fn ffi_verify_and_sign_psbt( + keys: Arc, + psbt: String, + params: VerifyParams, +) -> SdkResult { + crate::sign::verify_and_sign_psbt(&keys, &psbt, ¶ms) +} + +/// Decode a BOLT-11 lightning invoice (network, amount, payment hash and +/// secret, description, recovered payee, timestamp, expiry). Fed user-pasted +/// strings: any malformed input is `InvoiceDecode`, never a panic. +#[uniffi::export(name = "decode_bolt11")] +pub fn ffi_decode_bolt11(invoice: String) -> SdkResult { + crate::invoice::decode_bolt11(&invoice) +} + +/// Decode an RGB invoice (rgb-invoicing v0.11 grammar, `~` = omitted +/// segment): asset id, amount, chain-qualified recipient id, beneficiary +/// kind, transport endpoints, expiry. Malformed input is `InvoiceDecode`. +#[uniffi::export(name = "decode_rgb_invoice")] +pub fn ffi_decode_rgb_invoice(invoice: String) -> SdkResult { + crate::invoice::decode_rgb_invoice(&invoice) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sdk_version_matches_manifest() { + assert_eq!(sdk_version(), env!("CARGO_PKG_VERSION")); + assert!(!sdk_version().is_empty()); + } +} diff --git a/minimal-sdk/rust-sdk/src/gateway.rs b/minimal-sdk/rust-sdk/src/gateway.rs new file mode 100644 index 00000000..8352e06d --- /dev/null +++ b/minimal-sdk/rust-sdk/src/gateway.rs @@ -0,0 +1,1798 @@ +//! Thin typed client for the minimal gateway API, and the intent binding +//! that makes verify-before-sign's check 2 non-vacuous. +//! +//! **Rust performs no network IO.** The host app supplies an +//! [`HttpTransport`] (OkHttp/`HttpURLConnection` on Android, `URLSession` +//! on iOS) through uniffi's foreign-trait mechanism; this crate links no HTTP +//! or TLS stack. The call is synchronous from Rust's side, so there is no +//! async-over-FFI: the host is expected to call the client off its UI thread. +//! +//! Money-moving calls carry an `Idempotency-Key`; one is generated per +//! logical operation unless the caller pins its own (retry the SAME operation +//! with the SAME key to get the cached response instead of a duplicate spend). +//! +//! Invariants (design doc I1–I4): only account xpubs and the master +//! fingerprint ever appear in a request; no mnemonic, seed or xprv is a +//! request field or reachable from this module; the bearer token is never +//! rendered into an error or `Debug` output. +//! +//! Behavioural reference: `minimal-sdk/packages/client-sdk/src/gateway.ts`. + +use std::collections::HashMap; +use std::fmt; +use std::sync::Arc; + +use bitcoin::hex::DisplayHex; +use bitcoin::secp256k1::rand::{rngs::OsRng, RngCore}; + +use crate::json::{self, Json, ObjectBuilder}; +use crate::verify::{IntentAsset, IntentKind, IntentRecipient, IntentUtxos, OnchainIntent}; +use crate::{SdkError, SdkResult}; + +/// Default request timeout handed to the transport, as in the TS client. +const DEFAULT_TIMEOUT_MS: u64 = 30_000; + +// --------------------------------------------------------------------------- +// Transport seam +// --------------------------------------------------------------------------- + +/// HTTP method. The gateway API uses only these two. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum HttpMethod { + Get, + Post, +} + +impl HttpMethod { + pub fn name(self) -> &'static str { + match self { + HttpMethod::Get => "GET", + HttpMethod::Post => "POST", + } + } +} + +/// One request for the host transport to perform, exactly as built: the +/// transport must send it verbatim (method, absolute URL, every header, the +/// body if any) and must not add, drop or rewrite headers. +/// +/// The redacting `Debug` below exists only on the Rust side. Over uniffi this +/// is a Kotlin `data class` / Swift `struct` whose default `toString()` / +/// `String(describing:)` prints `headers` verbatim, bearer token included: +/// a host transport must never log the request it receives. +#[derive(Clone, PartialEq, Eq, uniffi::Record)] +pub struct HttpRequest { + pub method: HttpMethod, + /// Absolute URL, already percent-encoded. + pub url: String, + /// Lowercase header names. Includes `authorization` on authenticated + /// calls, `idempotency-key` on money-moving calls and `content-type` + /// when a body is present. + pub headers: HashMap, + /// UTF-8 JSON body, `None` for GET and body-less POSTs. + pub body: Option, + /// Total time the transport may spend before giving up with + /// `SdkError::Transport`. + pub timeout_ms: u64, +} + +/// Header names whose values are credentials: redacted in `Debug`. +const SECRET_HEADERS: &[&str] = &["authorization", "x-operator-token"]; + +/// Renders every header **name** but redacts credential values, so a host +/// app that logs requests never logs the bearer or operator token. +impl fmt::Debug for HttpRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut names: Vec<(&str, &str)> = self + .headers + .iter() + .map(|(k, v)| { + if SECRET_HEADERS.contains(&k.as_str()) { + (k.as_str(), "[redacted]") + } else { + (k.as_str(), v.as_str()) + } + }) + .collect(); + names.sort_unstable(); + f.debug_struct("HttpRequest") + .field("method", &self.method) + .field("url", &self.url) + .field("headers", &names) + .field("body", &self.body) + .field("timeout_ms", &self.timeout_ms) + .finish() + } +} + +/// What the transport got back. Any status is acceptable here: status +/// mapping (2xx vs error body) is done in Rust. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct HttpResponse { + pub status: u16, + /// Response body as text (may be empty or non-JSON; Rust copes). + pub body: String, +} + +/// The network seam, implemented by the host app (modelled on the node's +/// `ExternalSignerHost`). Return `Ok` for **any** HTTP status the server +/// answered with; return `Err(SdkError::Transport { .. })` only when no +/// response was obtained (DNS, connect, TLS, timeout). Never include +/// request headers in the error reason. +#[uniffi::export(with_foreign)] +pub trait HttpTransport: Send + Sync { + fn send(&self, request: HttpRequest) -> Result; +} + +// --------------------------------------------------------------------------- +// Wire types (kept in sync with the gateway's OpenAPI schema) +// --------------------------------------------------------------------------- + +#[derive(Clone, PartialEq, Eq, uniffi::Record)] +pub struct CreatedUser { + pub user_id: String, + /// The per-user bearer token. Store it in the platform keystore. + pub token: String, + pub created_at: u64, +} + +/// Renders the token as `[redacted]`: this is the one record that carries a +/// credential, and a host app may log the value it just received. +impl fmt::Debug for CreatedUser { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CreatedUser") + .field("user_id", &self.user_id) + .field("token", &"[redacted]") + .field("created_at", &self.created_at) + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct MeInfo { + pub user_id: String, + pub created_at: u64, +} + +/// Public registration material: account xpubs and the master fingerprint +/// (`ClientKeys::xpubs()`). The only key material that ever leaves the device. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct RegisterXpubsParams { + pub vanilla: String, + pub colored: String, + pub fingerprint: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct RegisteredXpubs { + pub fingerprint: String, + pub address: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct WalletAddress { + pub address: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct Balance { + pub settled: u64, + pub future: u64, + pub spendable: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct BtcBalances { + pub vanilla: Balance, + pub colored: Balance, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct AssetBalance { + pub asset_id: String, + pub schema: String, + pub ticker: Option, + pub name: String, + pub precision: u8, + pub balance: Balance, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct WalletBalances { + pub btc: BtcBalances, + pub assets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct UnspentAllocation { + pub asset_id: Option, + pub amount: Option, + pub settled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct WalletUnspent { + pub txid: String, + pub vout: u32, + pub amount_sat: u64, + pub colorable: bool, + pub allocations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct WalletUnspents { + pub unspents: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct WalletTransfer { + pub idx: u64, + pub asset_id: Option, + pub amount: Option, + pub kind: String, + pub status: String, + pub txid: Option, + pub recipient_id: Option, + pub expiration: Option, + pub created_at: u64, + pub updated_at: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct WalletTransfers { + pub transfers: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum ReceiveMode { + Blind, + Witness, +} + +impl ReceiveMode { + fn wire(self) -> &'static str { + match self { + ReceiveMode::Blind => "blind", + ReceiveMode::Witness => "witness", + } + } + + fn from_wire(s: &str) -> Option { + match s { + "blind" => Some(ReceiveMode::Blind), + "witness" => Some(ReceiveMode::Witness), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct ReceiveParams { + pub mode: ReceiveMode, + pub asset_id: Option, + pub amount: Option, + pub duration_seconds: Option, + pub min_confirmations: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct ReceiveResult { + pub invoice: String, + pub recipient_id: String, + pub expiration_timestamp: Option, + pub mode: ReceiveMode, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct SyncResult { + pub status: String, +} + +/// A prepared on-chain operation whose `intent` has already been bound to +/// the caller's request (see [`GatewayClient::prepare_send_btc`]). Hand +/// `intent` to `verify_and_sign_psbt` as-is. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct PreparedOp { + pub op_id: String, + pub psbt: String, + pub intent: OnchainIntent, + pub expires_at: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct PrepareSendBtcParams { + pub address: String, + pub amount_sat: u64, + pub fee_rate_sat_per_vb: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct PrepareSendAssetParams { + pub asset_id: String, + pub amount: u64, + pub recipient_id: String, + /// Required for witness (`wvout`) recipients; must be unset for blind. + pub witness_amount_sat: Option, + pub transport_endpoints: Option>, + pub donation: Option, + pub min_confirmations: Option, + pub fee_rate_sat_per_vb: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct PrepareCreateUtxosParams { + pub num: Option, + pub size: Option, + pub up_to: Option, + pub fee_rate_sat_per_vb: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct CompleteParams { + pub op_id: String, + /// The signed+finalized PSBT from `verify_and_sign_psbt`. + pub signed_psbt: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct CompleteResult { + pub txid: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct CreateUtxosCompleteResult { + pub txid: Option, + pub utxos_created: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum LnAssetKind { + Btc, + Rgb, +} + +impl LnAssetKind { + fn wire(self) -> &'static str { + match self { + LnAssetKind::Btc => "btc", + LnAssetKind::Rgb => "rgb", + } + } + + fn from_wire(s: &str) -> Option { + match s { + "btc" => Some(LnAssetKind::Btc), + "rgb" => Some(LnAssetKind::Rgb), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct LnDepositPrepareParams { + pub kind: LnAssetKind, + /// Declared BTC deposit amount (cap check), required for kind `Btc`. + pub amount_msat: Option, + pub asset_id: Option, + pub amount: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct LnDepositPrepareResult { + pub deposit_id: String, + pub kind: LnAssetKind, + /// Node-owned BTC target address (kind `Btc`). + pub address: Option, + /// RGB invoice to pay into the node (kind `Rgb`). + pub invoice: Option, + pub recipient_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct LnPayParams { + pub invoice: String, + /// Required when the invoice carries no amount. + pub amt_msat: Option, + pub asset_amount: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum LnPaymentStatus { + Pending, + Succeeded, + Failed, +} + +impl LnPaymentStatus { + fn from_wire(s: &str) -> Option { + match s { + "pending" => Some(LnPaymentStatus::Pending), + "succeeded" => Some(LnPaymentStatus::Succeeded), + "failed" => Some(LnPaymentStatus::Failed), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct LnPayResult { + pub payment_hash: String, + pub status: LnPaymentStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct LnInvoiceCreateParams { + pub amt_msat: Option, + pub expiry_sec: Option, + pub asset_id: Option, + pub asset_amount: Option, + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct LnInvoiceCreated { + pub invoice: String, + pub payment_hash: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum LnInvoiceState { + Pending, + Settled, + Expired, +} + +impl LnInvoiceState { + fn from_wire(s: &str) -> Option { + match s { + "pending" => Some(LnInvoiceState::Pending), + "settled" => Some(LnInvoiceState::Settled), + "expired" => Some(LnInvoiceState::Expired), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct LnInvoiceInfo { + pub payment_hash: String, + pub invoice: String, + pub state: LnInvoiceState, + pub amt_msat: Option, + pub asset_id: Option, + pub asset_amount: Option, + pub created_at: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum LnPaymentDirection { + Inbound, + Outbound, +} + +impl LnPaymentDirection { + fn from_wire(s: &str) -> Option { + match s { + "inbound" => Some(LnPaymentDirection::Inbound), + "outbound" => Some(LnPaymentDirection::Outbound), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct LnPaymentEntry { + pub payment_hash: String, + pub direction: LnPaymentDirection, + pub status: LnPaymentStatus, + pub amt_msat: Option, + pub asset_id: Option, + pub asset_amount: Option, + pub created_at: u64, + pub updated_at: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct LnPayments { + pub payments: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct LnBalance { + pub btc_msat: u64, + /// Asset id → amount. + pub assets: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct LnWithdrawParams { + pub kind: LnAssetKind, + pub address: Option, + pub amount_sat: Option, + pub asset_id: Option, + pub amount: Option, + pub recipient_id: Option, + /// Required when `recipient_id` is a witness (`:wvout:…`) beneficiary. + pub witness_amount_sat: Option, + pub transport_endpoints: Option>, + pub fee_rate_sat_per_vb: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct LnWithdrawResult { + pub withdrawal_id: String, + pub txid: String, +} + +// --------------------------------------------------------------------------- +// Idempotency keys and URL encoding +// --------------------------------------------------------------------------- + +/// RFC-4122 version-4 id from the OS cryptographic RNG (the `rand` that +/// `bitcoin`'s `rand-std` feature already links; no new dependency). Used +/// for idempotency keys: unguessable, so a third party cannot collide a +/// retry and read back another user's cached response. +pub fn generate_idempotency_key() -> SdkResult { + let mut bytes = [0u8; 16]; + OsRng + .try_fill_bytes(&mut bytes) + .map_err(|_| SdkError::Internal { + reason: "operating system RNG unavailable".into(), + })?; + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + let hex = bytes.to_lower_hex_string(); + Ok(format!( + "{}-{}-{}-{}-{}", + &hex[0..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..] + )) +} + +/// `encodeURIComponent`: everything but `A-Z a-z 0-9 - _ . ! ~ * ' ( )` is +/// percent-encoded as UTF-8 bytes. RGB asset ids carry `:` and payment +/// hashes come straight from callers: an unencoded value could retarget the +/// request to a different route. +pub fn encode_uri_component(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'_' + | b'.' + | b'!' + | b'~' + | b'*' + | b'\'' + | b'(' + | b')' => out.push(byte as char), + _ => { + // `write!` into a String cannot fail. + use std::fmt::Write as _; + let _ = write!(out, "%{byte:02X}"); + } + } + } + out +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/// Typed gateway client. Construct once per user session with the bearer +/// token; `create_user` is the only call that works without one. +#[derive(uniffi::Object)] +pub struct GatewayClient { + base_url: String, + token: Option, + timeout_ms: u64, + transport: Arc, +} + +/// Renders the base URL and whether a token is configured — never the token. +impl fmt::Debug for GatewayClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GatewayClient") + .field("base_url", &self.base_url) + .field("token", &self.token.as_ref().map(|_| "[redacted]")) + .field("timeout_ms", &self.timeout_ms) + .finish() + } +} + +struct RequestSpec<'a> { + method: HttpMethod, + path: String, + body: Option, + extra_headers: Vec<(&'static str, &'a str)>, + auth: bool, +} + +impl<'a> RequestSpec<'a> { + fn get(path: impl Into) -> Self { + RequestSpec { + method: HttpMethod::Get, + path: path.into(), + body: None, + extra_headers: Vec::new(), + auth: true, + } + } + + fn post(path: impl Into, body: Option) -> Self { + RequestSpec { + method: HttpMethod::Post, + path: path.into(), + body, + extra_headers: Vec::new(), + auth: true, + } + } +} + +impl GatewayClient { + /// `base_url` with trailing slashes removed; `token` may be omitted only + /// for the operator bootstrap (`create_user`). + pub fn new( + base_url: &str, + token: Option, + timeout_ms: Option, + transport: Arc, + ) -> GatewayClient { + GatewayClient { + base_url: base_url.trim_end_matches('/').to_owned(), + token, + timeout_ms: timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS), + transport, + } + } + + /// Build the request, hand it to the transport and map the response: + /// 2xx → parsed JSON body (`Null` if empty/non-JSON, as the TS client's + /// `payload = null`); anything else → `Gateway { status, code, detail }` + /// from the `{ error: { code, message } }` body when present. + fn request(&self, spec: RequestSpec<'_>) -> SdkResult { + let mut headers: HashMap = HashMap::new(); + for (name, value) in spec.extra_headers { + headers.insert(name.to_owned(), value.to_owned()); + } + if spec.auth { + let token = self.token.as_ref().ok_or_else(|| SdkError::Gateway { + status: 0, + code: "NO_TOKEN".into(), + detail: "gateway client has no bearer token configured".into(), + })?; + headers.insert("authorization".into(), format!("Bearer {token}")); + } + let body = spec.body.map(|json| json.to_json_string()); + if body.is_some() { + headers.insert("content-type".into(), "application/json".into()); + } + let request = HttpRequest { + method: spec.method, + url: format!("{}{}", self.base_url, spec.path), + headers, + body, + timeout_ms: self.timeout_ms, + }; + let response = self.transport.send(request)?; + let payload = json::parse(&response.body).unwrap_or(Json::Null); + if !(200..300).contains(&response.status) { + let error = payload.get("error"); + let code = error + .and_then(|e| e.get("code")) + .and_then(Json::as_str) + .unwrap_or("UNKNOWN") + .to_owned(); + let detail = error + .and_then(|e| e.get("message")) + .and_then(Json::as_str) + .map(str::to_owned) + .unwrap_or_else(|| format!("gateway returned {}", response.status)); + return Err(SdkError::Gateway { + status: response.status, + code, + detail, + }); + } + Ok(payload) + } + + /// Money-moving POST: carries an `Idempotency-Key`, generated unless the + /// caller pinned one. + fn post_idempotent( + &self, + path: &str, + body: Json, + idempotency_key: Option, + ) -> SdkResult { + let key = match idempotency_key { + Some(key) => key, + None => generate_idempotency_key()?, + }; + let mut spec = RequestSpec::post(path, Some(body)); + spec.extra_headers.push(("idempotency-key", key.as_str())); + self.request(spec) + } + + /// Bootstrap-only; requires the operator token, not a user token. + pub fn create_user(&self, operator_token: &str) -> SdkResult { + let mut spec = RequestSpec::post("/v1/users", None); + spec.auth = false; + spec.extra_headers + .push(("x-operator-token", operator_token)); + let v = self.request(spec)?; + Ok(CreatedUser { + user_id: field_str(&v, "userId")?, + token: field_str(&v, "token")?, + created_at: field_u64(&v, "createdAt")?, + }) + } + + pub fn me(&self) -> SdkResult { + let v = self.request(RequestSpec::get("/v1/me"))?; + Ok(MeInfo { + user_id: field_str(&v, "userId")?, + created_at: field_u64(&v, "createdAt")?, + }) + } + + /// Register the account xpubs and master fingerprint. This is the only + /// key-related request the client ever makes, and it carries public + /// material only. + pub fn register_xpubs(&self, params: &RegisterXpubsParams) -> SdkResult { + let body = ObjectBuilder::new() + .field("vanilla", params.vanilla.as_str()) + .field("colored", params.colored.as_str()) + .field("fingerprint", params.fingerprint.as_str()) + .build(); + let v = self.request(RequestSpec::post("/v1/wallet/xpubs", Some(body)))?; + Ok(RegisteredXpubs { + fingerprint: field_str(&v, "fingerprint")?, + address: field_str(&v, "address")?, + }) + } + + pub fn get_address(&self) -> SdkResult { + let v = self.request(RequestSpec::get("/v1/wallet/address"))?; + Ok(WalletAddress { + address: field_str(&v, "address")?, + }) + } + + pub fn get_balances(&self) -> SdkResult { + let v = self.request(RequestSpec::get("/v1/wallet/balances"))?; + let btc = field(&v, "btc")?; + let assets = field_array(&v, "assets")? + .iter() + .map(|a| { + Ok(AssetBalance { + asset_id: field_str(a, "assetId")?, + schema: field_str(a, "schema")?, + ticker: field_opt_str(a, "ticker")?, + name: field_str(a, "name")?, + precision: u8::try_from(field_u64(a, "precision")?) + .map_err(|_| malformed("assets[].precision out of range"))?, + balance: parse_balance(field(a, "balance")?)?, + }) + }) + .collect::>>()?; + Ok(WalletBalances { + btc: BtcBalances { + vanilla: parse_balance(field(btc, "vanilla")?)?, + colored: parse_balance(field(btc, "colored")?)?, + }, + assets, + }) + } + + pub fn get_unspents(&self) -> SdkResult { + let v = self.request(RequestSpec::get("/v1/wallet/unspents"))?; + let unspents = field_array(&v, "unspents")? + .iter() + .map(|u| { + let allocations = field_array(u, "allocations")? + .iter() + .map(|a| { + Ok(UnspentAllocation { + asset_id: field_opt_str(a, "assetId")?, + amount: field_opt_u64(a, "amount")?, + settled: field_bool(a, "settled")?, + }) + }) + .collect::>>()?; + Ok(WalletUnspent { + txid: field_str(u, "txid")?, + vout: u32::try_from(field_u64(u, "vout")?) + .map_err(|_| malformed("unspents[].vout out of range"))?, + amount_sat: field_u64(u, "amountSat")?, + colorable: field_bool(u, "colorable")?, + allocations, + }) + }) + .collect::>>()?; + Ok(WalletUnspents { unspents }) + } + + pub fn get_transfers(&self, asset_id: Option<&str>) -> SdkResult { + let query = match asset_id { + Some(id) => format!("?assetId={}", encode_uri_component(id)), + None => String::new(), + }; + let v = self.request(RequestSpec::get(format!("/v1/wallet/transfers{query}")))?; + let transfers = field_array(&v, "transfers")? + .iter() + .map(|t| { + Ok(WalletTransfer { + idx: field_u64(t, "idx")?, + asset_id: field_opt_str(t, "assetId")?, + amount: field_opt_u64(t, "amount")?, + kind: field_str(t, "kind")?, + status: field_str(t, "status")?, + txid: field_opt_str(t, "txid")?, + recipient_id: field_opt_str(t, "recipientId")?, + expiration: field_opt_u64(t, "expiration")?, + created_at: field_u64(t, "createdAt")?, + updated_at: field_u64(t, "updatedAt")?, + }) + }) + .collect::>>()?; + Ok(WalletTransfers { transfers }) + } + + pub fn receive(&self, params: &ReceiveParams) -> SdkResult { + let body = ObjectBuilder::new() + .field("mode", params.mode.wire()) + .optional("assetId", params.asset_id.clone()) + .optional("amount", params.amount) + .optional("durationSeconds", params.duration_seconds) + .optional("minConfirmations", params.min_confirmations) + .build(); + let v = self.request(RequestSpec::post("/v1/wallet/receive", Some(body)))?; + let mode = field_str(&v, "mode")?; + Ok(ReceiveResult { + invoice: field_str(&v, "invoice")?, + recipient_id: field_str(&v, "recipientId")?, + expiration_timestamp: field_opt_u64(&v, "expirationTimestamp")?, + mode: ReceiveMode::from_wire(&mode) + .ok_or_else(|| malformed(&format!("unknown receive mode {mode:?}")))?, + }) + } + + pub fn sync(&self) -> SdkResult { + let v = self.request(RequestSpec::post("/v1/wallet/sync", None))?; + Ok(SyncResult { + status: field_str(&v, "status")?, + }) + } + + /// Prepare a BTC send. `IntentMismatch` if the returned intent + /// contradicts `params` (see `checked_intent`). + pub fn prepare_send_btc( + &self, + params: &PrepareSendBtcParams, + idempotency_key: Option, + ) -> SdkResult { + let body = ObjectBuilder::new() + .field("address", params.address.as_str()) + .field("amountSat", params.amount_sat) + .optional("feeRateSatPerVb", params.fee_rate_sat_per_vb) + .build(); + let v = self.post_idempotent("/v1/onchain/send-btc/prepare", body, idempotency_key)?; + checked_intent(&v, IntentKind::SendBtc, |intent, mismatch| { + let recipients = intent.recipients; + if recipients.len() == 1 { + let only = &recipients[0]; + let address = only.get("address").and_then(Json::as_str); + if address != Some(params.address.as_str()) { + mismatch(format!( + "recipient address {} != {}", + display(only.get("address")), + params.address + )); + } + let amount = only.get("amountSat").and_then(Json::as_u64); + if amount != Some(params.amount_sat) { + mismatch(format!( + "recipient amountSat {} != {}", + display(only.get("amountSat")), + params.amount_sat + )); + } + } else { + mismatch(format!( + "expected exactly 1 recipient, got {}", + recipients.len() + )); + } + if !intent.asset.is_null() { + mismatch("send-btc intent carries an asset".into()); + } + if !intent.utxos.is_null() { + mismatch("send-btc intent carries a utxo shape".into()); + } + check_pinned_u64( + "feeRateSatPerVb", + params.fee_rate_sat_per_vb, + intent.fee_rate, + mismatch, + ); + }) + } + + pub fn complete_send_btc( + &self, + params: &CompleteParams, + idempotency_key: Option, + ) -> SdkResult { + let v = self.post_idempotent( + "/v1/onchain/send-btc/complete", + complete_body(params), + idempotency_key, + )?; + Ok(CompleteResult { + txid: field_str(&v, "txid")?, + }) + } + + /// Prepare an RGB asset send. `IntentMismatch` if the returned intent + /// contradicts `params` (see `checked_intent`). + pub fn prepare_send_asset( + &self, + params: &PrepareSendAssetParams, + idempotency_key: Option, + ) -> SdkResult { + let body = ObjectBuilder::new() + .field("assetId", params.asset_id.as_str()) + .field("amount", params.amount) + .field("recipientId", params.recipient_id.as_str()) + .optional("witnessAmountSat", params.witness_amount_sat) + .optional("transportEndpoints", params.transport_endpoints.clone()) + .optional("donation", params.donation) + .optional("minConfirmations", params.min_confirmations) + .optional("feeRateSatPerVb", params.fee_rate_sat_per_vb) + .build(); + let v = self.post_idempotent("/v1/onchain/send-asset/prepare", body, idempotency_key)?; + checked_intent(&v, IntentKind::SendAsset, |intent, mismatch| { + if !intent.recipients.is_empty() { + mismatch(format!( + "send-asset intent carries {} bitcoin recipient(s)", + intent.recipients.len() + )); + } + if !intent.utxos.is_null() { + mismatch("send-asset intent carries a utxo shape".into()); + } + let asset = intent.asset; + if asset.as_object().is_none() { + mismatch("send-asset intent carries no asset".into()); + return; + } + if asset.get("assetId").and_then(Json::as_str) != Some(params.asset_id.as_str()) { + mismatch(format!( + "assetId {} != {}", + display(asset.get("assetId")), + params.asset_id + )); + } + if asset.get("amount").and_then(Json::as_u64) != Some(params.amount) { + mismatch(format!( + "amount {} != {}", + display(asset.get("amount")), + params.amount + )); + } + if asset.get("recipientId").and_then(Json::as_str) != Some(params.recipient_id.as_str()) + { + mismatch(format!( + "recipientId {} != {}", + display(asset.get("recipientId")), + params.recipient_id + )); + } + // Always asserted, including the blind case: a witness amount the + // caller never asked for lets check 2 account for one foreign + // output of that size, which check 3 would otherwise have refused. + // A missing field is a mismatch too (`undefined !== null`). + let witness_ok = match (asset.get("witnessAmountSat"), params.witness_amount_sat) { + (Some(v), None) => v.is_null(), + (Some(v), Some(expected)) => v.as_u64() == Some(expected), + (None, _) => false, + }; + if !witness_ok { + mismatch(format!( + "witnessAmountSat {} != {}", + display(asset.get("witnessAmountSat")), + params + .witness_amount_sat + .map_or_else(|| "null".to_owned(), |v| v.to_string()) + )); + } + if let Some(endpoints) = ¶ms.transport_endpoints { + let returned: Option> = asset + .get("transportEndpoints") + .and_then(Json::as_array) + .map(|items| items.iter().filter_map(Json::as_str).collect()); + let same = returned.as_ref().is_some_and(|r| { + r.len() == endpoints.len() && r.iter().zip(endpoints).all(|(a, b)| a == b) + }); + if !same { + mismatch(format!( + "transportEndpoints {} != {}", + returned.map_or_else(|| "null".to_owned(), |r| r.join(",")), + endpoints.join(",") + )); + } + } + check_pinned_u64( + "feeRateSatPerVb", + params.fee_rate_sat_per_vb, + intent.fee_rate, + mismatch, + ); + }) + } + + pub fn complete_send_asset( + &self, + params: &CompleteParams, + idempotency_key: Option, + ) -> SdkResult { + let v = self.post_idempotent( + "/v1/onchain/send-asset/complete", + complete_body(params), + idempotency_key, + )?; + Ok(CompleteResult { + txid: field_str(&v, "txid")?, + }) + } + + /// Prepare a UTXO-creation transaction. `IntentMismatch` if the returned + /// intent contradicts `params` (see `checked_intent`). + pub fn prepare_create_utxos( + &self, + params: &PrepareCreateUtxosParams, + idempotency_key: Option, + ) -> SdkResult { + let body = ObjectBuilder::new() + .optional("num", params.num) + .optional("size", params.size) + .optional("upTo", params.up_to) + .optional("feeRateSatPerVb", params.fee_rate_sat_per_vb) + .build(); + let v = self.post_idempotent("/v1/onchain/create-utxos/prepare", body, idempotency_key)?; + checked_intent(&v, IntentKind::CreateUtxos, |intent, mismatch| { + if !intent.recipients.is_empty() { + mismatch(format!( + "create-utxos intent carries {} recipient(s)", + intent.recipients.len() + )); + } + if !intent.asset.is_null() { + mismatch("create-utxos intent carries an asset".into()); + } + let utxos = intent.utxos; + if utxos.as_object().is_none() { + mismatch("create-utxos intent carries no utxo shape".into()); + return; + } + check_pinned_u64("num", params.num.map(u64::from), utxos.get("num"), mismatch); + check_pinned_u64("size", params.size, utxos.get("size"), mismatch); + if let Some(up_to) = params.up_to { + if utxos.get("upTo").and_then(Json::as_bool) != Some(up_to) { + mismatch(format!("upTo {} != {up_to}", display(utxos.get("upTo")))); + } + } + check_pinned_u64( + "feeRateSatPerVb", + params.fee_rate_sat_per_vb, + intent.fee_rate, + mismatch, + ); + }) + } + + pub fn complete_create_utxos( + &self, + params: &CompleteParams, + idempotency_key: Option, + ) -> SdkResult { + let v = self.post_idempotent( + "/v1/onchain/create-utxos/complete", + complete_body(params), + idempotency_key, + )?; + Ok(CreateUtxosCompleteResult { + txid: field_opt_str(&v, "txid")?, + utxos_created: u32::try_from(field_u64(&v, "utxosCreated")?) + .map_err(|_| malformed("utxosCreated out of range"))?, + }) + } + + /// Pin the idempotency key to retry a prepare without minting a second + /// deposit target. + pub fn prepare_ln_deposit( + &self, + params: &LnDepositPrepareParams, + idempotency_key: Option, + ) -> SdkResult { + let body = ObjectBuilder::new() + .field("kind", params.kind.wire()) + .optional("amountMsat", params.amount_msat) + .optional("assetId", params.asset_id.clone()) + .optional("amount", params.amount) + .build(); + let v = self.post_idempotent("/v1/ln/deposit/prepare", body, idempotency_key)?; + let kind = field_str(&v, "kind")?; + Ok(LnDepositPrepareResult { + deposit_id: field_str(&v, "depositId")?, + kind: LnAssetKind::from_wire(&kind) + .ok_or_else(|| malformed(&format!("unknown deposit kind {kind:?}")))?, + address: field_opt_str(&v, "address")?, + invoice: field_opt_str(&v, "invoice")?, + recipient_id: field_opt_str(&v, "recipientId")?, + }) + } + + /// Money-moving: pin the idempotency key to retry the SAME payment safely. + pub fn pay_ln_invoice( + &self, + params: &LnPayParams, + idempotency_key: Option, + ) -> SdkResult { + let body = ObjectBuilder::new() + .field("invoice", params.invoice.as_str()) + .optional("amtMsat", params.amt_msat) + .optional("assetAmount", params.asset_amount) + .build(); + let v = self.post_idempotent("/v1/ln/pay", body, idempotency_key)?; + let status = field_str(&v, "status")?; + Ok(LnPayResult { + payment_hash: field_str(&v, "paymentHash")?, + status: LnPaymentStatus::from_wire(&status) + .ok_or_else(|| malformed(&format!("unknown payment status {status:?}")))?, + }) + } + + pub fn create_ln_invoice(&self, params: &LnInvoiceCreateParams) -> SdkResult { + let body = ObjectBuilder::new() + .optional("amtMsat", params.amt_msat) + .optional("expirySec", params.expiry_sec) + .optional("assetId", params.asset_id.clone()) + .optional("assetAmount", params.asset_amount) + .optional("description", params.description.clone()) + .build(); + let v = self.request(RequestSpec::post("/v1/ln/invoice", Some(body)))?; + Ok(LnInvoiceCreated { + invoice: field_str(&v, "invoice")?, + payment_hash: field_str(&v, "paymentHash")?, + }) + } + + pub fn get_ln_invoice(&self, payment_hash: &str) -> SdkResult { + let v = self.request(RequestSpec::get(format!( + "/v1/ln/invoice/{}", + encode_uri_component(payment_hash) + )))?; + let state = field_str(&v, "state")?; + Ok(LnInvoiceInfo { + payment_hash: field_str(&v, "paymentHash")?, + invoice: field_str(&v, "invoice")?, + state: LnInvoiceState::from_wire(&state) + .ok_or_else(|| malformed(&format!("unknown invoice state {state:?}")))?, + amt_msat: field_opt_u64(&v, "amtMsat")?, + asset_id: field_opt_str(&v, "assetId")?, + asset_amount: field_opt_u64(&v, "assetAmount")?, + created_at: field_u64(&v, "createdAt")?, + }) + } + + pub fn list_ln_payments(&self) -> SdkResult { + let v = self.request(RequestSpec::get("/v1/ln/payments"))?; + let payments = field_array(&v, "payments")? + .iter() + .map(|p| { + let direction = field_str(p, "direction")?; + let status = field_str(p, "status")?; + Ok(LnPaymentEntry { + payment_hash: field_str(p, "paymentHash")?, + direction: LnPaymentDirection::from_wire(&direction).ok_or_else(|| { + malformed(&format!("unknown payment direction {direction:?}")) + })?, + status: LnPaymentStatus::from_wire(&status) + .ok_or_else(|| malformed(&format!("unknown payment status {status:?}")))?, + amt_msat: field_opt_u64(p, "amtMsat")?, + asset_id: field_opt_str(p, "assetId")?, + asset_amount: field_opt_u64(p, "assetAmount")?, + created_at: field_u64(p, "createdAt")?, + updated_at: field_u64(p, "updatedAt")?, + }) + }) + .collect::>>()?; + Ok(LnPayments { payments }) + } + + pub fn get_ln_balance(&self) -> SdkResult { + let v = self.request(RequestSpec::get("/v1/ln/balance"))?; + let assets = field(&v, "assets")? + .as_object() + .ok_or_else(|| malformed("assets is not an object"))? + .iter() + .map(|(id, amount)| { + let amount = amount + .as_u64() + .ok_or_else(|| malformed(&format!("assets[{id:?}] is not an integer")))?; + Ok((id.clone(), amount)) + }) + .collect::>>()?; + Ok(LnBalance { + btc_msat: field_u64(&v, "btcMsat")?, + assets, + }) + } + + /// Money-moving: pin the idempotency key to retry the SAME withdrawal. + pub fn withdraw_ln( + &self, + params: &LnWithdrawParams, + idempotency_key: Option, + ) -> SdkResult { + let body = ObjectBuilder::new() + .field("kind", params.kind.wire()) + .optional("address", params.address.clone()) + .optional("amountSat", params.amount_sat) + .optional("assetId", params.asset_id.clone()) + .optional("amount", params.amount) + .optional("recipientId", params.recipient_id.clone()) + .optional("witnessAmountSat", params.witness_amount_sat) + .optional("transportEndpoints", params.transport_endpoints.clone()) + .optional("feeRateSatPerVb", params.fee_rate_sat_per_vb) + .build(); + let v = self.post_idempotent("/v1/ln/withdraw", body, idempotency_key)?; + Ok(LnWithdrawResult { + withdrawal_id: field_str(&v, "withdrawalId")?, + txid: field_str(&v, "txid")?, + }) + } +} + +fn complete_body(params: &CompleteParams) -> Json { + ObjectBuilder::new() + .field("opId", params.op_id.as_str()) + .field("signedPsbt", params.signed_psbt.as_str()) + .build() +} + +// --------------------------------------------------------------------------- +// Intent binding +// --------------------------------------------------------------------------- + +/// The server's intent, still untyped, as handed to the per-kind comparison. +struct RawIntent<'a> { + recipients: &'a [Json], + asset: &'a Json, + utxos: &'a Json, + fee_rate: Option<&'a Json>, +} + +/// Bind the gateway's intent summary to the caller's OWN request. +/// +/// `verify` check 2 matches the PSBT's outputs against the intent. If that +/// intent is simply whatever the server chose to return, the check is +/// vacuous against the threat verify-before-sign exists for: a hostile +/// gateway pairs an attacker script in the PSBT with the same attacker +/// address in the intent and both "match", while checks 1/3/4/5 stay clean +/// (the output is accounted, so change-own skips it). The design doc calls +/// for the *user's* stated intent (`minimal-sdk-and-lightweight-rln.md`, +/// check 2), so every field the caller actually stated is asserted here +/// before the intent is handed on. +/// +/// Fields the caller left to the server (fee rate, transport endpoints, utxo +/// shape) are asserted only when the caller pinned them: they carry no +/// bitcoin value for check 2, and the absolute fee stays bounded by +/// `max_fee_sat`. The shape assertions (no recipients on an asset/utxo +/// intent, no asset on a send-btc intent) are not cosmetic — a foreign +/// `asset.witnessAmountSat` or an extra recipient is exactly how a server +/// would smuggle an unaccounted output past check 3. +/// +/// The comparison runs on the **untyped** JSON so that a malformed field is +/// reported as a mismatch (fail closed) rather than silently coerced; the +/// typed [`OnchainIntent`] is built only after every assertion passed, and +/// any field it cannot represent is a mismatch too. All mismatches are +/// collected and returned together as one `IntentMismatch`. +fn checked_intent( + prepared: &Json, + kind: IntentKind, + compare: impl FnOnce(RawIntent<'_>, &mut dyn FnMut(String)), +) -> SdkResult { + let intent = match prepared.get("intent") { + Some(intent) if intent.as_object().is_some() => intent, + _ => { + return Err(SdkError::IntentMismatch { + reason: "prepare response carries no intent summary".into(), + }) + } + }; + let mut mismatches: Vec = Vec::new(); + let returned_kind = intent.get("kind").and_then(Json::as_str); + if returned_kind != Some(kind.name()) { + mismatches.push(format!( + "kind {} != {kind}", + returned_kind.unwrap_or("undefined") + )); + } + match intent.get("recipients").and_then(Json::as_array) { + None => mismatches.push("intent.recipients is not an array".into()), + Some(recipients) => { + let null = Json::Null; + let raw = RawIntent { + recipients, + asset: intent.get("asset").unwrap_or(&null), + utxos: intent.get("utxos").unwrap_or(&null), + fee_rate: intent.get("feeRateSatPerVb"), + }; + compare(raw, &mut |detail| mismatches.push(detail)); + } + } + let mismatch_error = |mismatches: &[String]| SdkError::IntentMismatch { + reason: format!( + "gateway intent does not match the request: {}", + mismatches.join("; ") + ), + }; + if !mismatches.is_empty() { + return Err(mismatch_error(&mismatches)); + } + let intent = typed_intent(intent, kind).map_err(|detail| mismatch_error(&[detail]))?; + Ok(PreparedOp { + op_id: field_str(prepared, "opId")?, + psbt: field_str(prepared, "psbt")?, + intent, + expires_at: field_u64(prepared, "expiresAt")?, + }) +} + +/// Convert the (already compared) server intent into the typed record +/// `verify` consumes. Any field that does not fit is reported by name. +fn typed_intent(intent: &Json, kind: IntentKind) -> Result { + let fee_rate_sat_per_vb = intent + .get("feeRateSatPerVb") + .and_then(Json::as_u64) + .ok_or("intent.feeRateSatPerVb is not an integer")?; + let recipients = intent + .get("recipients") + .and_then(Json::as_array) + .ok_or("intent.recipients is not an array")? + .iter() + .map(|r| { + Ok(IntentRecipient { + address: r + .get("address") + .and_then(Json::as_str) + .ok_or("recipient.address is not a string")? + .to_owned(), + script_hex: r + .get("scriptHex") + .and_then(Json::as_str) + .ok_or("recipient.scriptHex is not a string")? + .to_owned(), + amount_sat: r + .get("amountSat") + .and_then(Json::as_u64) + .ok_or("recipient.amountSat is not an integer")?, + }) + }) + .collect::, String>>()?; + let asset = match intent.get("asset") { + None | Some(Json::Null) => None, + Some(a) => { + let witness = match a.get("witnessAmountSat") { + None | Some(Json::Null) => None, + Some(v) => Some( + v.as_u64() + .ok_or("asset.witnessAmountSat is not an integer")?, + ), + }; + let transport_endpoints = a + .get("transportEndpoints") + .and_then(Json::as_array) + .ok_or("asset.transportEndpoints is not an array")? + .iter() + .map(|e| { + e.as_str() + .map(str::to_owned) + .ok_or_else(|| "asset.transportEndpoints[] is not a string".to_owned()) + }) + .collect::, String>>()?; + Some(IntentAsset { + asset_id: a + .get("assetId") + .and_then(Json::as_str) + .ok_or("asset.assetId is not a string")? + .to_owned(), + amount: a + .get("amount") + .and_then(Json::as_u64) + .ok_or("asset.amount is not an integer")?, + recipient_id: a + .get("recipientId") + .and_then(Json::as_str) + .ok_or("asset.recipientId is not a string")? + .to_owned(), + witness_amount_sat: witness, + transport_endpoints, + }) + } + }; + let utxos = match intent.get("utxos") { + None | Some(Json::Null) => None, + Some(u) => Some(IntentUtxos { + up_to: u + .get("upTo") + .and_then(Json::as_bool) + .ok_or("utxos.upTo is not a boolean")?, + num: u + .get("num") + .and_then(Json::as_u64) + .and_then(|n| u32::try_from(n).ok()) + .ok_or("utxos.num is not a u32")?, + size: u + .get("size") + .and_then(Json::as_u64) + .ok_or("utxos.size is not an integer")?, + }), + }; + Ok(OnchainIntent { + kind, + fee_rate_sat_per_vb, + recipients, + asset, + utxos, + }) +} + +/// `checkPinned`: a caller-pinned integer must come back exactly. +fn check_pinned_u64( + name: &str, + requested: Option, + returned: Option<&Json>, + mismatch: &mut dyn FnMut(String), +) { + if let Some(expected) = requested { + if returned.and_then(Json::as_u64) != Some(expected) { + mismatch(format!("{name} {} != {expected}", display(returned))); + } + } +} + +/// Render a JSON scalar for a mismatch message (`String(x)` in TS). +fn display(value: Option<&Json>) -> String { + match value { + None => "undefined".into(), + Some(Json::Null) => "null".into(), + Some(Json::Bool(b)) => b.to_string(), + Some(Json::Number(n)) => n.clone(), + Some(Json::String(s)) => s.clone(), + Some(other) => other.to_json_string(), + } +} + +// --------------------------------------------------------------------------- +// Response field access +// --------------------------------------------------------------------------- + +/// A 2xx body that does not have the documented shape. +fn malformed(detail: &str) -> SdkError { + SdkError::Gateway { + status: 0, + code: "MALFORMED_RESPONSE".into(), + detail: format!("gateway response malformed: {detail}"), + } +} + +fn field<'a>(v: &'a Json, key: &str) -> SdkResult<&'a Json> { + v.get(key) + .ok_or_else(|| malformed(&format!("missing field {key}"))) +} + +fn field_str(v: &Json, key: &str) -> SdkResult { + field(v, key)? + .as_str() + .map(str::to_owned) + .ok_or_else(|| malformed(&format!("{key} is not a string"))) +} + +fn field_opt_str(v: &Json, key: &str) -> SdkResult> { + match v.get(key) { + None | Some(Json::Null) => Ok(None), + Some(Json::String(s)) => Ok(Some(s.clone())), + Some(_) => Err(malformed(&format!("{key} is not a string or null"))), + } +} + +fn field_u64(v: &Json, key: &str) -> SdkResult { + field(v, key)? + .as_u64() + .ok_or_else(|| malformed(&format!("{key} is not a non-negative integer"))) +} + +fn field_opt_u64(v: &Json, key: &str) -> SdkResult> { + match v.get(key) { + None | Some(Json::Null) => Ok(None), + Some(n) => n + .as_u64() + .map(Some) + .ok_or_else(|| malformed(&format!("{key} is not an integer or null"))), + } +} + +fn field_bool(v: &Json, key: &str) -> SdkResult { + field(v, key)? + .as_bool() + .ok_or_else(|| malformed(&format!("{key} is not a boolean"))) +} + +fn field_array<'a>(v: &'a Json, key: &str) -> SdkResult<&'a [Json]> { + field(v, key)? + .as_array() + .ok_or_else(|| malformed(&format!("{key} is not an array"))) +} + +fn parse_balance(v: &Json) -> SdkResult { + Ok(Balance { + settled: field_u64(v, "settled")?, + future: field_u64(v, "future")?, + spendable: field_u64(v, "spendable")?, + }) +} + +// --------------------------------------------------------------------------- +// uniffi surface +// --------------------------------------------------------------------------- + +/// The uniffi surface of [`GatewayClient`]: one method per gateway route. +/// `idempotency_key` may be `null` (a fresh v4 UUID is generated) or pinned +/// by the caller to retry the same operation safely. +#[uniffi::export] +impl GatewayClient { + /// `base_url` like `https://gw.example.com` (trailing slashes ignored), + /// the user's bearer token (`null` only for `create_user`), an optional + /// timeout in milliseconds (default 30 000) and the host transport. + #[uniffi::constructor(name = "new")] + pub fn ffi_new( + base_url: String, + token: Option, + timeout_ms: Option, + transport: Arc, + ) -> Arc { + Arc::new(Self::new(&base_url, token, timeout_ms, transport)) + } + + #[uniffi::method(name = "create_user")] + pub fn ffi_create_user(&self, operator_token: String) -> SdkResult { + self.create_user(&operator_token) + } + + #[uniffi::method(name = "me")] + pub fn ffi_me(&self) -> SdkResult { + self.me() + } + + #[uniffi::method(name = "register_xpubs")] + pub fn ffi_register_xpubs(&self, params: RegisterXpubsParams) -> SdkResult { + self.register_xpubs(¶ms) + } + + #[uniffi::method(name = "get_address")] + pub fn ffi_get_address(&self) -> SdkResult { + self.get_address() + } + + #[uniffi::method(name = "get_balances")] + pub fn ffi_get_balances(&self) -> SdkResult { + self.get_balances() + } + + #[uniffi::method(name = "get_unspents")] + pub fn ffi_get_unspents(&self) -> SdkResult { + self.get_unspents() + } + + #[uniffi::method(name = "get_transfers")] + pub fn ffi_get_transfers(&self, asset_id: Option) -> SdkResult { + self.get_transfers(asset_id.as_deref()) + } + + #[uniffi::method(name = "receive")] + pub fn ffi_receive(&self, params: ReceiveParams) -> SdkResult { + self.receive(¶ms) + } + + #[uniffi::method(name = "sync")] + pub fn ffi_sync(&self) -> SdkResult { + self.sync() + } + + #[uniffi::method(name = "prepare_send_btc")] + pub fn ffi_prepare_send_btc( + &self, + params: PrepareSendBtcParams, + idempotency_key: Option, + ) -> SdkResult { + self.prepare_send_btc(¶ms, idempotency_key) + } + + #[uniffi::method(name = "complete_send_btc")] + pub fn ffi_complete_send_btc( + &self, + params: CompleteParams, + idempotency_key: Option, + ) -> SdkResult { + self.complete_send_btc(¶ms, idempotency_key) + } + + #[uniffi::method(name = "prepare_send_asset")] + pub fn ffi_prepare_send_asset( + &self, + params: PrepareSendAssetParams, + idempotency_key: Option, + ) -> SdkResult { + self.prepare_send_asset(¶ms, idempotency_key) + } + + #[uniffi::method(name = "complete_send_asset")] + pub fn ffi_complete_send_asset( + &self, + params: CompleteParams, + idempotency_key: Option, + ) -> SdkResult { + self.complete_send_asset(¶ms, idempotency_key) + } + + #[uniffi::method(name = "prepare_create_utxos")] + pub fn ffi_prepare_create_utxos( + &self, + params: PrepareCreateUtxosParams, + idempotency_key: Option, + ) -> SdkResult { + self.prepare_create_utxos(¶ms, idempotency_key) + } + + #[uniffi::method(name = "complete_create_utxos")] + pub fn ffi_complete_create_utxos( + &self, + params: CompleteParams, + idempotency_key: Option, + ) -> SdkResult { + self.complete_create_utxos(¶ms, idempotency_key) + } + + #[uniffi::method(name = "prepare_ln_deposit")] + pub fn ffi_prepare_ln_deposit( + &self, + params: LnDepositPrepareParams, + idempotency_key: Option, + ) -> SdkResult { + self.prepare_ln_deposit(¶ms, idempotency_key) + } + + #[uniffi::method(name = "pay_ln_invoice")] + pub fn ffi_pay_ln_invoice( + &self, + params: LnPayParams, + idempotency_key: Option, + ) -> SdkResult { + self.pay_ln_invoice(¶ms, idempotency_key) + } + + #[uniffi::method(name = "create_ln_invoice")] + pub fn ffi_create_ln_invoice( + &self, + params: LnInvoiceCreateParams, + ) -> SdkResult { + self.create_ln_invoice(¶ms) + } + + #[uniffi::method(name = "get_ln_invoice")] + pub fn ffi_get_ln_invoice(&self, payment_hash: String) -> SdkResult { + self.get_ln_invoice(&payment_hash) + } + + #[uniffi::method(name = "list_ln_payments")] + pub fn ffi_list_ln_payments(&self) -> SdkResult { + self.list_ln_payments() + } + + #[uniffi::method(name = "get_ln_balance")] + pub fn ffi_get_ln_balance(&self) -> SdkResult { + self.get_ln_balance() + } + + #[uniffi::method(name = "withdraw_ln")] + pub fn ffi_withdraw_ln( + &self, + params: LnWithdrawParams, + idempotency_key: Option, + ) -> SdkResult { + self.withdraw_ln(¶ms, idempotency_key) + } +} + +/// Fresh RFC-4122 v4 idempotency key, for callers that want to pin one +/// across a retry loop. +#[uniffi::export(name = "generate_idempotency_key")] +pub fn ffi_generate_idempotency_key() -> SdkResult { + generate_idempotency_key() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn idempotency_keys_are_v4_uuids() { + let key = generate_idempotency_key().unwrap(); + assert_eq!(key.len(), 36); + let parts: Vec<&str> = key.split('-').collect(); + assert_eq!( + parts.iter().map(|p| p.len()).collect::>(), + [8, 4, 4, 4, 12] + ); + assert!(parts[2].starts_with('4')); + assert!(matches!(parts[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b')); + assert!(key.bytes().all(|b| b == b'-' || b.is_ascii_hexdigit())); + } + + #[test] + fn encode_uri_component_matches_javascript() { + assert_eq!(encode_uri_component("rgb:abc-123"), "rgb%3Aabc-123"); + assert_eq!( + encode_uri_component("../v1/ln/balance"), + "..%2Fv1%2Fln%2Fbalance" + ); + assert_eq!(encode_uri_component("a b&c=d?e#f"), "a%20b%26c%3Dd%3Fe%23f"); + assert_eq!(encode_uri_component("-_.!~*'()"), "-_.!~*'()"); + assert_eq!(encode_uri_component("é"), "%C3%A9"); + } + + #[test] + fn request_debug_redacts_credentials() { + let mut headers = HashMap::new(); + headers.insert("authorization".to_owned(), "Bearer sekrit".to_owned()); + headers.insert("x-operator-token".to_owned(), "op-sekrit".to_owned()); + headers.insert("idempotency-key".to_owned(), "k".to_owned()); + let request = HttpRequest { + method: HttpMethod::Post, + url: "http://gw/v1/x".into(), + headers, + body: None, + timeout_ms: 1, + }; + let rendered = format!("{request:?}"); + assert!(!rendered.contains("sekrit"), "{rendered}"); + assert!(rendered.contains("authorization")); + assert!(rendered.contains("[redacted]")); + assert!(rendered.contains("\"k\"")); + } + + #[test] + fn created_user_debug_redacts_the_token() { + let created = CreatedUser { + user_id: "u".into(), + token: "tok-sekrit".into(), + created_at: 1, + }; + let rendered = format!("{created:?}"); + assert!(!rendered.contains("sekrit"), "{rendered}"); + assert!(rendered.contains("[redacted]")); + assert!(rendered.contains("\"u\"")); + } +} diff --git a/minimal-sdk/rust-sdk/src/invoice.rs b/minimal-sdk/rust-sdk/src/invoice.rs new file mode 100644 index 00000000..6e214f02 --- /dev/null +++ b/minimal-sdk/rust-sdk/src/invoice.rs @@ -0,0 +1,608 @@ +//! Invoice decoding for intent construction: +//! +//! - **BOLT-11** (payee, amount, payment hash, payment secret, description, +//! expiry): a minimal decoder over the `bech32` crate `bitcoin` already +//! re-exports, plus secp256k1 signature recovery for the payee. +//! - **RGB invoices** in the rgb-invoicing v0.11 grammar +//! `rgb:///?expiry=&endpoints=` +//! (asset id, amount, beneficiary kind, transports). A witness beneficiary is +//! a `wvout:` recipient id, not a plain address. Full RGB validation is +//! explicitly out of scope (design doc): this parses the grammar, it does not +//! validate the contract. +//! +//! Both decoders are fed **user-pasted strings**. Every malformed input is +//! [`SdkError::InvoiceDecode`] and nothing else; no path can panic (asserted +//! in `tests/parity.rs` by truncating and corrupting real invoices at every +//! position). +//! +//! Behavioural reference: `minimal-sdk/packages/client-sdk/src/invoice.ts`. +//! +//! **Why the bech32 checksum is run by hand.** The `bech32` crate's checked +//! decoder enforces the BIP-173 code length (1023 characters), but BOLT-11 +//! explicitly lifts that limit — invoices carrying route hints routinely +//! exceed it. The TypeScript reference decodes with the length limit disabled; +//! this port uses the crate's character/HRP validation and its checksum engine +//! directly, which validates the same polynomial without the length cap. + +use bitcoin::bech32::primitives::checksum::{Checksum, Engine}; +use bitcoin::bech32::primitives::decode::UncheckedHrpstring; +use bitcoin::bech32::{Bech32, Fe32}; +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::hex::DisplayHex; +use bitcoin::secp256k1::ecdsa::{RecoverableSignature, RecoveryId, Signature}; +use bitcoin::secp256k1::{Message, PublicKey}; + +use crate::network::BitcoinNetwork; +use crate::{secp, SdkError, SdkResult}; + +fn invalid(reason: impl Into) -> SdkError { + SdkError::InvoiceDecode { + reason: reason.into(), + } +} + +// --------------------------------------------------------------------------- +// BOLT-11 +// --------------------------------------------------------------------------- + +/// A decoded BOLT-11 invoice: the fields intent construction needs. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct Bolt11Invoice { + pub network: BitcoinNetwork, + /// Invoice amount in millisatoshi; `None` for any-amount invoices. + pub amount_msat: Option, + /// 32-byte payment hash, lowercase hex. + pub payment_hash: String, + /// 32-byte payment secret, lowercase hex (`None` on legacy invoices). + pub payment_secret: Option, + /// Short description, or `None` when only a description hash is present. + pub description: Option, + pub description_hash: Option, + /// Payee node id (33-byte compressed pubkey, lowercase hex), recovered + /// from the signature or taken from the `n` tag after verifying the + /// signature against it. + pub payee_node_id: String, + /// Invoice creation time, unix seconds. + pub timestamp: u64, + /// Seconds until expiry (BOLT-11 default 3600). + pub expiry_seconds: u64, +} + +/// HRP network prefixes, longest-prefix-first where one is a prefix of another +/// (`bcrt` before `bc`, `tbs` before `tb`). +const HRP_NETWORKS: [(&str, BitcoinNetwork); 4] = [ + ("bcrt", BitcoinNetwork::Regtest), + ("tbs", BitcoinNetwork::Signet), + ("bc", BitcoinNetwork::Mainnet), + ("tb", BitcoinNetwork::Testnet), +]; + +const MSAT_PER_BTC: u64 = 100_000_000_000; + +/// BOLT-11 tagged-field types this decoder reads. +const TAG_PAYMENT_HASH: u8 = 1; // p +const TAG_EXPIRY: u8 = 6; // x +const TAG_DESCRIPTION: u8 = 13; // d +const TAG_PAYMENT_SECRET: u8 = 16; // s +const TAG_PAYEE: u8 = 19; // n +const TAG_DESCRIPTION_HASH: u8 = 23; // h + +/// Words in a 65-byte recoverable signature (65 * 8 / 5). +const SIGNATURE_WORDS: usize = 104; +/// Words in the 35-bit timestamp. +const TIMESTAMP_WORDS: usize = 7; + +fn parse_hrp(hrp: &str) -> SdkResult<(BitcoinNetwork, Option)> { + let rest = hrp + .strip_prefix("ln") + .ok_or_else(|| invalid(format!("not a BOLT11 HRP: {hrp}")))?; + let (prefix, network) = HRP_NETWORKS + .iter() + .find(|(prefix, _)| rest.starts_with(prefix)) + .copied() + .ok_or_else(|| invalid(format!("unknown network prefix: {rest}")))?; + let amount_part = &rest[prefix.len()..]; + if amount_part.is_empty() { + return Ok((network, None)); + } + // `^(\d+)([munp])?$` + let (digits, unit) = match amount_part.as_bytes().last() { + Some(b'm' | b'u' | b'n' | b'p') => { + let (d, u) = amount_part.split_at(amount_part.len() - 1); + (d, Some(u)) + } + _ => (amount_part, None), + }; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return Err(invalid(format!("malformed amount: {amount_part}"))); + } + let value: u64 = digits + .parse() + .map_err(|_| invalid(format!("amount out of range: {amount_part}")))?; + let msat = match unit { + None => value.checked_mul(MSAT_PER_BTC), + Some("m") => value.checked_mul(MSAT_PER_BTC / 1_000), + Some("u") => value.checked_mul(MSAT_PER_BTC / 1_000_000), + Some("n") => value.checked_mul(MSAT_PER_BTC / 1_000_000_000), + Some("p") => { + if !value.is_multiple_of(10) { + return Err(invalid("sub-millisatoshi pico amount")); + } + Some(value / 10) + } + Some(_) => return Err(invalid(format!("malformed amount: {amount_part}"))), + }; + let msat = msat.ok_or_else(|| invalid(format!("amount out of range: {amount_part}")))?; + Ok((network, Some(msat))) +} + +/// Pack 5-bit words into bytes MSB-first, zero-padding the tail (BIP-173). +fn words_to_bytes_padded(words: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(words.len() * 5 / 8 + 1); + let mut acc: u32 = 0; + let mut bits: u32 = 0; + for &word in words { + acc = (acc << 5) | u32::from(word & 0x1f); + bits += 5; + while bits >= 8 { + bits -= 8; + bytes.push(((acc >> bits) & 0xff) as u8); + } + } + if bits > 0 { + bytes.push(((acc << (8 - bits)) & 0xff) as u8); + } + bytes +} + +/// Pack 5-bit words into bytes, DROPPING trailing pad bits (tagged fields). +fn words_to_bytes_trimmed(words: &[u8]) -> Vec { + let mut bytes = words_to_bytes_padded(words); + bytes.truncate(words.len() * 5 / 8); + bytes +} + +/// Big-endian base-32 integer; `None` on overflow (a hostile tag length). +fn words_to_u64(words: &[u8]) -> Option { + words.iter().try_fold(0u64, |acc, &w| { + acc.checked_mul(32)?.checked_add(u64::from(w & 0x1f)) + }) +} + +/// Bech32 decode with the code-length limit disabled: character, case, HRP +/// and separator validation from the `bech32` crate, checksum from its +/// engine. Returns the lowercase HRP and the 5-bit data words without the +/// checksum. +fn bech32_decode_unlimited(invoice: &str) -> SdkResult<(String, Vec)> { + let lower = invoice.to_lowercase(); + let unchecked = UncheckedHrpstring::new(&lower) + .map_err(|e| invalid(format!("bech32 decode failed: {e}")))?; + let hrp = unchecked.hrp(); + let ascii = unchecked.data_part_ascii(); + if ascii.len() < Bech32::CHECKSUM_LENGTH { + return Err(invalid( + "bech32 decode failed: data part shorter than checksum", + )); + } + let mut engine = Engine::::new(); + engine.input_hrp(hrp); + // Characters were validated by `UncheckedHrpstring::new`. + let words: Vec = ascii + .iter() + .map(|&b| Fe32::from_char_unchecked(b)) + .inspect(|fe| engine.input_fe(*fe)) + .map(Fe32::to_u8) + .collect(); + if *engine.residue() != Bech32::TARGET_RESIDUE { + return Err(invalid("bech32 decode failed: invalid checksum")); + } + let data_len = words.len() - Bech32::CHECKSUM_LENGTH; + let mut words = words; + words.truncate(data_len); + Ok((hrp.to_lowercase(), words)) +} + +/// Decode a BOLT-11 invoice. Malformed input — wrong checksum, truncated +/// data, unknown HRP, bad tag lengths, an unrecoverable signature — is +/// `InvoiceDecode`; nothing here panics on hostile input. +pub fn decode_bolt11(invoice: &str) -> SdkResult { + let (hrp, words) = bech32_decode_unlimited(invoice)?; + let (network, amount_msat) = parse_hrp(&hrp)?; + if words.len() < SIGNATURE_WORDS + TIMESTAMP_WORDS { + return Err(invalid("data part too short")); + } + let (data_words, signature_words) = words.split_at(words.len() - SIGNATURE_WORDS); + let timestamp = words_to_u64(&data_words[..TIMESTAMP_WORDS]) + .ok_or_else(|| invalid("timestamp out of range"))?; + + let mut payment_hash: Option = None; + let mut payment_secret: Option = None; + let mut description: Option = None; + let mut description_hash: Option = None; + let mut payee_from_tag: Option> = None; + let mut expiry_seconds: u64 = 3600; + + let mut cursor = TIMESTAMP_WORDS; + while cursor < data_words.len() { + let tag_type = data_words[cursor]; + let (high, low) = match (data_words.get(cursor + 1), data_words.get(cursor + 2)) { + (Some(h), Some(l)) => (*h, *l), + _ => return Err(invalid("truncated tag")), + }; + let length = usize::from(high) * 32 + usize::from(low); + let start = cursor + 3; + let data = data_words + .get(start..start + length) + .ok_or_else(|| invalid("truncated tag data"))?; + cursor = start + length; + match tag_type { + TAG_PAYMENT_HASH => { + if length == 52 && payment_hash.is_none() { + payment_hash = Some(words_to_bytes_trimmed(data).to_lower_hex_string()); + } + } + TAG_PAYMENT_SECRET => { + if length == 52 && payment_secret.is_none() { + payment_secret = Some(words_to_bytes_trimmed(data).to_lower_hex_string()); + } + } + TAG_DESCRIPTION => { + description = + Some(String::from_utf8_lossy(&words_to_bytes_trimmed(data)).into_owned()); + } + TAG_DESCRIPTION_HASH => { + if length == 52 { + description_hash = Some(words_to_bytes_trimmed(data).to_lower_hex_string()); + } + } + TAG_PAYEE => { + if length == 53 { + payee_from_tag = Some(words_to_bytes_trimmed(data)); + } + } + TAG_EXPIRY => { + expiry_seconds = + words_to_u64(data).ok_or_else(|| invalid("expiry out of range"))?; + } + // Features, route hints, fallback address, min-final-cltv, metadata: + // not needed for intent construction. + _ => {} + } + } + let payment_hash = payment_hash.ok_or_else(|| invalid("missing payment hash"))?; + + let signature = words_to_bytes_trimmed(signature_words); + if signature.len() != 65 { + return Err(invalid("malformed signature")); + } + let recovery_id = signature[64]; + if recovery_id > 3 { + return Err(invalid(format!("invalid recovery id {recovery_id}"))); + } + // BOLT-11: the signature commits to sha256(hrp || data-part bytes), the + // data part packed MSB-first with zero padding. + let mut message = hrp.clone().into_bytes(); + message.extend_from_slice(&words_to_bytes_padded(data_words)); + let digest = Message::from_digest(sha256::Hash::hash(&message).to_byte_array()); + + let payee_node_id = match payee_from_tag { + Some(payee_bytes) => { + let payee = PublicKey::from_slice(&payee_bytes) + .map_err(|_| invalid("malformed payee node id"))?; + let sig = Signature::from_compact(&signature[..64]) + .map_err(|_| invalid("signature recovery failed: malformed signature"))?; + secp() + .verify_ecdsa(&digest, &sig, &payee) + .map_err(|_| invalid("signature does not match payee node id"))?; + payee.serialize().to_lower_hex_string() + } + None => { + let recid = RecoveryId::from_i32(i32::from(recovery_id)) + .map_err(|_| invalid(format!("invalid recovery id {recovery_id}")))?; + let sig = RecoverableSignature::from_compact(&signature[..64], recid) + .map_err(|_| invalid("signature recovery failed: malformed signature"))?; + let recovered = secp() + .recover_ecdsa(&digest, &sig) + .map_err(|_| invalid("signature recovery failed"))?; + recovered.serialize().to_lower_hex_string() + } + }; + + Ok(Bolt11Invoice { + network, + amount_msat, + payment_hash, + payment_secret, + description, + description_hash, + payee_node_id, + timestamp, + expiry_seconds, + }) +} + +// --------------------------------------------------------------------------- +// RGB invoices +// --------------------------------------------------------------------------- + +/// How the RGB beneficiary is paid. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum BeneficiaryKind { + /// `utxob:` — a blinded seal on an existing UTXO of the receiver. + Blind, + /// `wvout:` — paid by a new output of the sender's witness transaction. + Witness, + /// Neither grammar matched; the recipient id is passed through untouched. + Unknown, +} + +/// A decoded RGB invoice (rgb-invoicing v0.11 grammar). +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct RgbInvoice { + /// Full asset id (`rgb:…`), or `None` for asset-agnostic invoices. + pub asset_id: Option, + /// Schema id segment, or `None` when omitted. + pub schema: Option, + /// Fungible amount, or `None` (omitted / non-fungible state). + pub amount: Option, + /// Raw assignment-state segment when present and non-numeric. + pub assignment_raw: Option, + /// Chain-qualified beneficiary — exactly the `recipient_id` the gateway's + /// send-asset prepare expects (e.g. `bcrt:utxob:…` or `bcrt:wvout:…`). + pub recipient_id: String, + pub beneficiary_kind: BeneficiaryKind, + /// Chain prefix of the beneficiary (`bc`, `tb`, `bcrt`, …). + pub chain: Option, + /// Consignment transport endpoints from the `endpoints` query param. + pub transport_endpoints: Vec, + /// Unix-seconds expiry from the `expiry` query param. + pub expiry_timestamp: Option, + pub assignment_name: Option, +} + +/// The rgb-invoicing placeholder for an omitted path segment. +const OMITTED: &str = "~"; + +/// `decodeURIComponent` semantics: `%XX` sequences become bytes and the +/// result must be valid UTF-8; otherwise the value is returned unchanged. +/// `+` is **not** a space. +fn percent_decode(value: &str) -> String { + let bytes = value.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let hex = match bytes.get(i + 1..i + 3) { + Some(h) => h, + None => return value.to_owned(), + }; + // `from_str_radix` would accept a sign (`%+4`); decodeURIComponent + // does not, so require two hex digits explicitly. + let decoded = match std::str::from_utf8(hex) + .ok() + .filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())) + .and_then(|h| u8::from_str_radix(h, 16).ok()) + { + Some(b) => b, + None => return value.to_owned(), + }; + out.push(decoded); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).unwrap_or_else(|_| value.to_owned()) +} + +fn is_all_digits(s: &str) -> bool { + !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) +} + +/// `^([a-z0-9]+):(utxob|wvout):(.+)$` +fn parse_beneficiary(seg: &str) -> Option<(String, BeneficiaryKind)> { + let (chain, rest) = seg.split_once(':')?; + if chain.is_empty() + || !chain + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + { + return None; + } + let (kind, id) = rest.split_once(':')?; + let kind = match kind { + "utxob" => BeneficiaryKind::Blind, + "wvout" => BeneficiaryKind::Witness, + _ => return None, + }; + if id.is_empty() { + return None; + } + Some((chain.to_owned(), kind)) +} + +/// Decode an RGB invoice. The `~` placeholder marks an omitted contract, +/// schema or state segment. Malformed input is `InvoiceDecode`. +pub fn decode_rgb_invoice(invoice: &str) -> SdkResult { + let body = invoice + .strip_prefix("rgb:") + .ok_or_else(|| invalid("missing rgb: scheme"))?; + let (path, query) = match body.split_once('?') { + Some((p, q)) => (p, q), + None => (body, ""), + }; + + let segments: Vec<&str> = path.split('/').collect(); + if segments.len() != 4 { + return Err(invalid(format!( + "expected contract/schema/state/beneficiary, got {} segment(s)", + segments.len() + ))); + } + let (contract_seg, schema_seg, state_seg, beneficiary_seg) = + (segments[0], segments[1], segments[2], segments[3]); + if beneficiary_seg.is_empty() { + return Err(invalid("empty beneficiary")); + } + + let asset_id = (contract_seg != OMITTED).then(|| format!("rgb:{contract_seg}")); + let schema = (schema_seg != OMITTED).then(|| schema_seg.to_owned()); + let mut amount: Option = None; + let mut assignment_raw: Option = None; + if state_seg != OMITTED { + if is_all_digits(state_seg) { + amount = Some( + state_seg + .parse() + .map_err(|_| invalid(format!("amount out of range: {state_seg}")))?, + ); + } else { + assignment_raw = Some(state_seg.to_owned()); + } + } + + let (chain, beneficiary_kind) = match parse_beneficiary(beneficiary_seg) { + Some((chain, kind)) => (Some(chain), kind), + None => (None, BeneficiaryKind::Unknown), + }; + + let mut transport_endpoints: Vec = Vec::new(); + let mut expiry_timestamp: Option = None; + let mut assignment_name: Option = None; + if !query.is_empty() { + for pair in query.split('&') { + let (key, value) = pair + .split_once('=') + .ok_or_else(|| invalid(format!("malformed query parameter: {pair}")))?; + let key = percent_decode(key); + let value = percent_decode(value); + match key.as_str() { + "endpoints" => { + transport_endpoints = value + .split(',') + .filter(|endpoint| !endpoint.is_empty()) + .map(str::to_owned) + .collect(); + } + "expiry" => { + if !is_all_digits(&value) { + return Err(invalid(format!("invalid expiry: {value}"))); + } + expiry_timestamp = Some( + value + .parse() + .map_err(|_| invalid(format!("invalid expiry: {value}")))?, + ); + } + "assignment_name" => assignment_name = Some(value), + _ => {} + } + } + } + + Ok(RgbInvoice { + asset_id, + schema, + amount, + assignment_raw, + recipient_id: beneficiary_seg.to_owned(), + beneficiary_kind, + chain, + transport_endpoints, + expiry_timestamp, + assignment_name, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hrp_amounts_follow_bolt11_multipliers() { + assert_eq!(parse_hrp("lnbc").unwrap(), (BitcoinNetwork::Mainnet, None)); + assert_eq!( + parse_hrp("lnbc1").unwrap(), + (BitcoinNetwork::Mainnet, Some(MSAT_PER_BTC)) + ); + assert_eq!( + parse_hrp("lnbc2500u").unwrap(), + (BitcoinNetwork::Mainnet, Some(250_000_000)) + ); + assert_eq!( + parse_hrp("lntb20m").unwrap(), + (BitcoinNetwork::Testnet, Some(2_000_000_000)) + ); + assert_eq!( + parse_hrp("lnbcrt10n").unwrap(), + (BitcoinNetwork::Regtest, Some(1_000)) + ); + assert_eq!( + parse_hrp("lntbs250p").unwrap(), + (BitcoinNetwork::Signet, Some(25)) + ); + for bad in [ + "bc", + "lnxx1", + "lnbc25a", + "lnbcm", + "lnbc251p", + "lnbc999999999999999999999", + ] { + assert!( + matches!(parse_hrp(bad), Err(SdkError::InvoiceDecode { .. })), + "{bad}" + ); + } + } + + #[test] + fn word_packing_matches_bip173() { + // 8 words = 40 bits = 5 bytes exactly. + assert_eq!( + words_to_bytes_padded(&[31, 31, 31, 31, 31, 31, 31, 31]), + vec![0xff; 5] + ); + // 7 words = 35 bits: padded gives 5 bytes, trimmed gives 4. + assert_eq!(words_to_bytes_padded(&[0; 7]).len(), 5); + assert_eq!(words_to_bytes_trimmed(&[0; 7]).len(), 4); + assert_eq!(words_to_u64(&[1, 0]), Some(32)); + assert_eq!(words_to_u64(&[31; 13]), None); + } + + #[test] + fn percent_decode_matches_decode_uri_component() { + assert_eq!(percent_decode("a%20b"), "a b"); + assert_eq!(percent_decode("rpc%3A%2F%2Fx"), "rpc://x"); + assert_eq!(percent_decode("a+b"), "a+b"); + // Malformed escapes are returned unchanged, never an error. + assert_eq!(percent_decode("bad%zz"), "bad%zz"); + assert_eq!(percent_decode("trail%2"), "trail%2"); + assert_eq!(percent_decode("%ff"), "%ff"); + // A sign is not a hex digit (u8::from_str_radix would accept it). + assert_eq!(percent_decode("%+4"), "%+4"); + assert_eq!(percent_decode("a%+41"), "a%+41"); + } + + #[test] + fn beneficiary_grammar() { + assert_eq!( + parse_beneficiary("bcrt:wvout:abc"), + Some(("bcrt".into(), BeneficiaryKind::Witness)) + ); + assert_eq!( + parse_beneficiary("bc:utxob:abc"), + Some(("bc".into(), BeneficiaryKind::Blind)) + ); + for bad in [ + "bcrt:other:abc", + "BC:utxob:abc", + ":utxob:abc", + "bc:utxob:", + "bc1q...", + ] { + assert_eq!(parse_beneficiary(bad), None, "{bad}"); + } + } +} diff --git a/minimal-sdk/rust-sdk/src/json.rs b/minimal-sdk/rust-sdk/src/json.rs new file mode 100644 index 00000000..490637f4 --- /dev/null +++ b/minimal-sdk/rust-sdk/src/json.rs @@ -0,0 +1,541 @@ +//! Minimal JSON value, parser and serializer for the gateway wire format. +//! +//! Hand-written so the crate keeps its dependency set to `bitcoin`, `bip39`, +//! `uniffi` and `thiserror`: `serde_json` stays a dev-dependency (the plan's +//! dependency-creep guard asserts it never reaches the library graph). +//! +//! Everything parsed here comes from the network and is treated as hostile: +//! the parser never panics, bounds nesting depth, keeps numbers as their raw +//! token (so `as_u64` is an exact integer parse with no float round trip), +//! and reports every malformation as a plain error string. Serialization +//! matches `JSON.stringify` for the value shapes the TypeScript client emits +//! (integers, booleans, strings, arrays, objects; `undefined` fields are +//! simply not emitted). + +use std::fmt::Write as _; + +/// Maximum nesting depth accepted by the parser. The gateway's deepest +/// response (`wallet/balances`) nests four levels; anything past this bound +/// is a hostile or broken body, not a real document. +const MAX_DEPTH: usize = 32; + +/// A JSON document. Objects preserve key order; lookups return the **last** +/// occurrence of a duplicated key, matching `JSON.parse`. +#[derive(Debug, Clone, PartialEq)] +pub enum Json { + Null, + Bool(bool), + /// Raw number token exactly as it appeared (`-12`, `1.5`, `1e3`). + Number(String), + String(String), + Array(Vec), + Object(Vec<(String, Json)>), +} + +impl Json { + /// Object field lookup (`None` for non-objects and missing keys). + pub fn get(&self, key: &str) -> Option<&Json> { + match self { + Json::Object(fields) => fields.iter().rev().find(|(k, _)| k == key).map(|(_, v)| v), + _ => None, + } + } + + pub fn is_null(&self) -> bool { + matches!(self, Json::Null) + } + + pub fn as_str(&self) -> Option<&str> { + match self { + Json::String(s) => Some(s), + _ => None, + } + } + + pub fn as_bool(&self) -> Option { + match self { + Json::Bool(b) => Some(*b), + _ => None, + } + } + + /// Exact non-negative integer. Fractions, exponents and negatives are + /// `None`: every amount on the wire is an integer count of units. + pub fn as_u64(&self) -> Option { + match self { + Json::Number(raw) => raw.parse::().ok(), + _ => None, + } + } + + pub fn as_array(&self) -> Option<&[Json]> { + match self { + Json::Array(items) => Some(items), + _ => None, + } + } + + pub fn as_object(&self) -> Option<&[(String, Json)]> { + match self { + Json::Object(fields) => Some(fields), + _ => None, + } + } + + /// Unsigned integer literal. + pub fn u64(value: u64) -> Json { + Json::Number(value.to_string()) + } + + /// Serialize compactly, exactly as `JSON.stringify` would. + pub fn to_json_string(&self) -> String { + let mut out = String::new(); + self.write_to(&mut out); + out + } + + fn write_to(&self, out: &mut String) { + match self { + Json::Null => out.push_str("null"), + Json::Bool(true) => out.push_str("true"), + Json::Bool(false) => out.push_str("false"), + Json::Number(raw) => out.push_str(raw), + Json::String(s) => write_string(s, out), + Json::Array(items) => { + out.push('['); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + item.write_to(out); + } + out.push(']'); + } + Json::Object(fields) => { + out.push('{'); + for (i, (key, value)) in fields.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_string(key, out); + out.push(':'); + value.write_to(out); + } + out.push('}'); + } + } + } +} + +impl From<&str> for Json { + fn from(s: &str) -> Json { + Json::String(s.to_owned()) + } +} + +impl From for Json { + fn from(s: String) -> Json { + Json::String(s) + } +} + +impl From for Json { + fn from(b: bool) -> Json { + Json::Bool(b) + } +} + +impl From for Json { + fn from(n: u64) -> Json { + Json::u64(n) + } +} + +impl From for Json { + fn from(n: u32) -> Json { + Json::u64(u64::from(n)) + } +} + +impl From> for Json { + fn from(items: Vec) -> Json { + Json::Array(items.into_iter().map(Json::String).collect()) + } +} + +/// Builder for request bodies: `undefined` (unset optional) fields are left +/// out, exactly as `JSON.stringify` drops them. +#[derive(Debug, Default)] +pub struct ObjectBuilder { + fields: Vec<(String, Json)>, +} + +impl ObjectBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn field(mut self, key: &str, value: impl Into) -> Self { + self.fields.push((key.to_owned(), value.into())); + self + } + + /// Emit the field only when the caller set it. + pub fn optional>(self, key: &str, value: Option) -> Self { + match value { + Some(v) => self.field(key, v), + None => self, + } + } + + pub fn build(self) -> Json { + Json::Object(self.fields) + } +} + +fn write_string(s: &str, out: &mut String) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + c if (c as u32) < 0x20 => { + // `write!` into a String cannot fail. + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); +} + +/// Parse a complete JSON document. Trailing non-whitespace is an error. +pub fn parse(text: &str) -> Result { + let mut parser = Parser { + bytes: text.as_bytes(), + pos: 0, + }; + parser.skip_ws(); + let value = parser.value(0)?; + parser.skip_ws(); + if parser.pos != parser.bytes.len() { + return Err(format!("trailing characters at offset {}", parser.pos)); + } + Ok(value) +} + +struct Parser<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl<'a> Parser<'a> { + fn peek(&self) -> Option { + self.bytes.get(self.pos).copied() + } + + fn skip_ws(&mut self) { + while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) { + self.pos += 1; + } + } + + fn expect_literal(&mut self, literal: &str) -> Result<(), String> { + let end = self.pos + literal.len(); + if self.bytes.get(self.pos..end) == Some(literal.as_bytes()) { + self.pos = end; + Ok(()) + } else { + Err(format!("expected {literal} at offset {}", self.pos)) + } + } + + fn value(&mut self, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(format!("nesting deeper than {MAX_DEPTH}")); + } + match self.peek() { + None => Err("unexpected end of input".into()), + Some(b'n') => self.expect_literal("null").map(|_| Json::Null), + Some(b't') => self.expect_literal("true").map(|_| Json::Bool(true)), + Some(b'f') => self.expect_literal("false").map(|_| Json::Bool(false)), + Some(b'"') => self.string().map(Json::String), + Some(b'[') => self.array(depth), + Some(b'{') => self.object(depth), + Some(b'-' | b'0'..=b'9') => self.number(), + Some(other) => Err(format!( + "unexpected byte 0x{other:02x} at offset {}", + self.pos + )), + } + } + + fn number(&mut self) -> Result { + let start = self.pos; + if self.peek() == Some(b'-') { + self.pos += 1; + } + match self.peek() { + Some(b'0') => self.pos += 1, + Some(b'1'..=b'9') => self.digits(), + _ => return Err(format!("malformed number at offset {start}")), + } + if self.peek() == Some(b'.') { + self.pos += 1; + if !matches!(self.peek(), Some(b'0'..=b'9')) { + return Err(format!("malformed number at offset {start}")); + } + self.digits(); + } + if matches!(self.peek(), Some(b'e' | b'E')) { + self.pos += 1; + if matches!(self.peek(), Some(b'+' | b'-')) { + self.pos += 1; + } + if !matches!(self.peek(), Some(b'0'..=b'9')) { + return Err(format!("malformed number at offset {start}")); + } + self.digits(); + } + let raw = std::str::from_utf8(&self.bytes[start..self.pos]) + .map_err(|_| format!("malformed number at offset {start}"))?; + Ok(Json::Number(raw.to_owned())) + } + + fn digits(&mut self) { + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.pos += 1; + } + } + + fn hex4(&mut self) -> Result { + let end = self.pos + 4; + let slice = self + .bytes + .get(self.pos..end) + .ok_or_else(|| "truncated \\u escape".to_string())?; + // `from_str_radix` accepts a leading sign; JSON.parse does not. + if !slice.iter().all(u8::is_ascii_hexdigit) { + return Err("malformed \\u escape".to_string()); + } + let text = std::str::from_utf8(slice).map_err(|_| "malformed \\u escape".to_string())?; + let value = + u32::from_str_radix(text, 16).map_err(|_| "malformed \\u escape".to_string())?; + self.pos = end; + Ok(value) + } + + fn string(&mut self) -> Result { + // Opening quote. + self.pos += 1; + let mut out = String::new(); + loop { + let start = self.pos; + while let Some(b) = self.peek() { + if b == b'"' || b == b'\\' || b < 0x20 { + break; + } + self.pos += 1; + } + let chunk = std::str::from_utf8(&self.bytes[start..self.pos]) + .map_err(|_| "invalid UTF-8 in string".to_string())?; + out.push_str(chunk); + match self.peek() { + None => return Err("unterminated string".into()), + Some(b'"') => { + self.pos += 1; + return Ok(out); + } + Some(b'\\') => { + self.pos += 1; + let escaped = self + .peek() + .ok_or_else(|| "unterminated escape".to_string())?; + self.pos += 1; + match escaped { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{8}'), + b'f' => out.push('\u{c}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + let mut code = self.hex4()?; + if (0xD800..0xDC00).contains(&code) { + // High surrogate: a low surrogate must follow. + self.expect_literal("\\u") + .map_err(|_| "unpaired surrogate".to_string())?; + let low = self.hex4()?; + if !(0xDC00..0xE000).contains(&low) { + return Err("unpaired surrogate".into()); + } + code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00); + } + let c = char::from_u32(code) + .ok_or_else(|| "invalid unicode escape".to_string())?; + out.push(c); + } + other => return Err(format!("invalid escape \\{}", other as char)), + } + } + Some(_) => return Err("control character in string".into()), + } + } + } + + fn array(&mut self, depth: usize) -> Result { + self.pos += 1; + let mut items = Vec::new(); + self.skip_ws(); + if self.peek() == Some(b']') { + self.pos += 1; + return Ok(Json::Array(items)); + } + loop { + self.skip_ws(); + items.push(self.value(depth + 1)?); + self.skip_ws(); + match self.peek() { + Some(b',') => self.pos += 1, + Some(b']') => { + self.pos += 1; + return Ok(Json::Array(items)); + } + _ => return Err(format!("expected , or ] at offset {}", self.pos)), + } + } + } + + fn object(&mut self, depth: usize) -> Result { + self.pos += 1; + let mut fields = Vec::new(); + self.skip_ws(); + if self.peek() == Some(b'}') { + self.pos += 1; + return Ok(Json::Object(fields)); + } + loop { + self.skip_ws(); + if self.peek() != Some(b'"') { + return Err(format!("expected object key at offset {}", self.pos)); + } + let key = self.string()?; + self.skip_ws(); + if self.peek() != Some(b':') { + return Err(format!("expected : at offset {}", self.pos)); + } + self.pos += 1; + self.skip_ws(); + let value = self.value(depth + 1)?; + fields.push((key, value)); + self.skip_ws(); + match self.peek() { + Some(b',') => self.pos += 1, + Some(b'}') => { + self.pos += 1; + return Ok(Json::Object(fields)); + } + _ => return Err(format!("expected , or }} at offset {}", self.pos)), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_the_shapes_the_gateway_uses() { + let text = r#"{"a":[1,2,{"b":null}],"c":"x\"y\\z\n\u00e9\ud83d\ude00","d":true,"e":false,"f":-3,"g":1.5e2}"#; + let parsed = parse(text).unwrap(); + assert_eq!(parsed.get("c").and_then(Json::as_str), Some("x\"y\\z\né😀")); + assert_eq!(parsed.get("d").and_then(Json::as_bool), Some(true)); + assert_eq!(parsed.get("f").and_then(Json::as_u64), None); + assert_eq!(parsed.get("g").and_then(Json::as_u64), None); + let a = parsed.get("a").and_then(Json::as_array).unwrap(); + assert_eq!(a[0].as_u64(), Some(1)); + assert!(a[2].get("b").is_some_and(Json::is_null)); + // Serialization is compact and escapes like JSON.stringify. + let again = parse(&parsed.to_json_string()).unwrap(); + assert_eq!(again, parsed); + assert_eq!( + Json::from("tab\t\u{1}").to_json_string(), + "\"tab\\t\\u0001\"" + ); + } + + #[test] + fn duplicate_keys_resolve_to_the_last_like_json_parse() { + let parsed = parse(r#"{"k":1,"k":2}"#).unwrap(); + assert_eq!(parsed.get("k").and_then(Json::as_u64), Some(2)); + } + + #[test] + fn malformed_documents_are_errors_not_panics() { + for bad in [ + "", + " ", + "{", + "}", + "[1,]", + "{\"a\":}", + "{a:1}", + "\"unterminated", + "\"bad\\q\"", + "\"\\ud83d\"", + "\"\\ud83dx\"", + "\"\\u12\"", + "\"\\u+041\"", + "\"\\u-041\"", + "tru", + "nul", + "01", + "-", + "1.", + "1e", + "1 2", + "\u{1}", + "\"\u{1}\"", + &"[".repeat(MAX_DEPTH + 2), + &format!( + "{}1{}", + "[".repeat(MAX_DEPTH + 1), + "]".repeat(MAX_DEPTH + 1) + ), + ] { + assert!(parse(bad).is_err(), "{bad:?} should not parse"); + } + // Depth exactly at the bound still parses. + let ok = format!("{}1{}", "[".repeat(MAX_DEPTH), "]".repeat(MAX_DEPTH)); + assert!(parse(&ok).is_ok()); + } + + #[test] + fn builder_omits_unset_optionals() { + let body = ObjectBuilder::new() + .field("mode", "blind") + .optional("assetId", None::) + .optional("amount", Some(5u64)) + .build(); + assert_eq!(body.to_json_string(), r#"{"mode":"blind","amount":5}"#); + } + + #[test] + fn as_u64_is_an_exact_integer_parse() { + assert_eq!( + Json::Number("18446744073709551615".into()).as_u64(), + Some(u64::MAX) + ); + assert_eq!(Json::Number("18446744073709551616".into()).as_u64(), None); + assert_eq!(Json::Number("1e3".into()).as_u64(), None); + assert_eq!(Json::String("5".into()).as_u64(), None); + } +} diff --git a/minimal-sdk/rust-sdk/src/keys.rs b/minimal-sdk/rust-sdk/src/keys.rs new file mode 100644 index 00000000..cbdcf90d --- /dev/null +++ b/minimal-sdk/rust-sdk/src/keys.rs @@ -0,0 +1,246 @@ +//! Key generation and restore, mirroring rgb-lib's derivation exactly +//! (cross-checked against the rgb-lib-generated fixture in +//! `minimal-sdk/packages/client-sdk/test/fixtures/rgblib-parity.json`): +//! BIP-39 seed (empty passphrase) → master → account xprv/xpub at +//! `m/86'/coin'/0'` for the vanilla and colored keychains. +//! +//! The mnemonic is the user's ONLY backup and never leaves the client; only +//! xpubs and the master fingerprint are shared with the gateway (design doc +//! I1/I2). Behavioural reference: `minimal-sdk/packages/client-sdk/src/keys.ts`. +//! +//! Secrets hygiene, enforced here and asserted in `tests/parity.rs`: +//! - [`ClientKeys`] holds the two account xprvs and nothing else: the mnemonic, +//! the seed and the master xprv are wiped and dropped at the end of +//! construction and are not retrievable through any method; the account +//! xprvs are wiped again when the `ClientKeys` is dropped, so key material +//! does not linger in freed memory of a long-lived mobile process; +//! - [`ClientKeys`] has a **manual** `Debug` impl that renders only public +//! material; the same goes for [`GeneratedKeys`]; +//! - no error path formats the words supplied: a bad mnemonic is +//! [`SdkError::InvalidMnemonic`] with no payload. + +use std::fmt; +use std::sync::Arc; + +use bip39::Mnemonic; +use bitcoin::bip32::{ChildNumber, Fingerprint, Xpriv, Xpub}; +use bitcoin::hex::DisplayHex; +use bitcoin::secp256k1::SecretKey; + +use crate::derive::{match_origin_path, ParsedAccounts}; +use crate::network::{account_path, BitcoinNetwork}; +use crate::{secp, SdkError, SdkResult}; + +/// Word count of a freshly generated mnemonic (128 bits of entropy). +const GENERATED_WORD_COUNT: usize = 12; + +/// Public account material — safe to share, used to register with the gateway. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct AccountXpubs { + pub network: BitcoinNetwork, + /// Master key fingerprint, 8 lowercase hex chars. + pub fingerprint: String, + /// Account xpub for the vanilla (BTC) keychain, `m/86'/coin'/0'`. + pub vanilla: String, + /// Account xpub for the colored (RGB) keychain, `m/86'/rgb-coin'/0'`. + pub colored: String, +} + +/// Result of [`ClientKeys::generate`]: the mnemonic the user must back up, +/// and the keys derived from it. This is the **only** place the mnemonic is +/// ever handed back, and only because the caller has to show it once. +#[derive(uniffi::Record)] +pub struct GeneratedKeys { + pub mnemonic: String, + pub keys: Arc, +} + +impl fmt::Debug for GeneratedKeys { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GeneratedKeys") + .field("mnemonic", &"[redacted]") + .field("keys", &self.keys) + .finish() + } +} + +/// Client-side key material. Holds the account xprvs needed for signing. +#[derive(uniffi::Object)] +pub struct ClientKeys { + network: BitcoinNetwork, + fingerprint: Fingerprint, + vanilla_account: Xpriv, + colored_account: Xpriv, +} + +/// Renders no secret: network, fingerprint and the two account **xpubs**. +impl fmt::Debug for ClientKeys { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ClientKeys") + .field("network", &self.network) + .field("fingerprint", &self.fingerprint_hex()) + .field("vanilla_xpub", &self.vanilla_xpub().to_string()) + .field("colored_xpub", &self.colored_xpub().to_string()) + .finish() + } +} + +impl ClientKeys { + /// Generate a fresh 12-word mnemonic and derive keys from it. + pub fn generate(network: BitcoinNetwork) -> SdkResult { + let mnemonic = + Mnemonic::generate(GENERATED_WORD_COUNT).map_err(|_| SdkError::Internal { + reason: "mnemonic generation failed".into(), + })?; + let keys = Self::from_parsed(&mnemonic, network)?; + Ok(GeneratedKeys { + mnemonic: mnemonic.to_string(), + keys: Arc::new(keys), + }) + } + + /// Restore keys from an existing mnemonic; `InvalidMnemonic` on a bad + /// word, word count or checksum. The error deliberately carries nothing + /// about the words supplied. + pub fn from_mnemonic(mnemonic: &str, network: BitcoinNetwork) -> SdkResult { + let parsed = Mnemonic::parse(mnemonic).map_err(|_| SdkError::InvalidMnemonic)?; + Self::from_parsed(&parsed, network) + } + + fn from_parsed(mnemonic: &Mnemonic, network: BitcoinNetwork) -> SdkResult { + // Empty passphrase, exactly as rgb-lib derives. + let mut seed = mnemonic.to_seed(""); + let result = Self::from_seed(&seed, network); + // Wipe the seed whether or not derivation succeeded; `master` is + // wiped inside `from_seed`. Only the two account xprvs survive. + wipe(&mut seed); + result + } + + fn from_seed(seed: &[u8; 64], network: BitcoinNetwork) -> SdkResult { + let mut master = Xpriv::new_master(network.network_kind(), seed).map_err(|_| { + SdkError::DerivationFailed { + reason: "master key".into(), + } + })?; + let derive = |colored: bool| { + master + .derive_priv(secp(), &account_path(network, colored)) + .map_err(|_| SdkError::DerivationFailed { + reason: format!("account (colored={colored})"), + }) + }; + let keys = ClientKeys { + network, + fingerprint: master.fingerprint(secp()), + vanilla_account: derive(false)?, + colored_account: derive(true)?, + }; + master.private_key.non_secure_erase(); + Ok(keys) + } + + /// Master key fingerprint as raw bytes (PSBT key-origin comparison). + pub fn fingerprint(&self) -> Fingerprint { + self.fingerprint + } + + /// Account xpub for the vanilla (BTC) keychain. + pub fn vanilla_xpub(&self) -> Xpub { + Xpub::from_priv(secp(), &self.vanilla_account) + } + + /// Account xpub for the colored (RGB) keychain. + pub fn colored_xpub(&self) -> Xpub { + Xpub::from_priv(secp(), &self.colored_account) + } + + /// The two account xpubs as parsed keys, ready for + /// [`crate::derive::derive_for_origin_path`]. Public material only. + pub fn accounts(&self) -> ParsedAccounts { + ParsedAccounts { + network: self.network, + vanilla: self.vanilla_xpub(), + colored: self.colored_xpub(), + } + } + + /// Private key for a full master-based path `[86', coin', 0', keychain, + /// index]` as found in PSBT key-origin metadata; `None` when the path is + /// not one of this wallet's two accounts (never errors, never panics — + /// used on adversarial input). + pub fn private_key_for_path(&self, path: &[u32]) -> Option { + // Path acceptance is delegated to `match_origin_path`, the single + // source of truth shared with verify (`derive_for_origin_path`). + // There is deliberately no second path check here: what verify + // proved is exactly what sign derives, and the private key returned + // for an accepted path is the key whose x-only public key verify + // re-derived as the input's internal key (asserted in tests/parity.rs). + let m = match_origin_path(path, self.network)?; + let account = if m.colored { + &self.colored_account + } else { + &self.vanilla_account + }; + let children = [ + ChildNumber::from_normal_idx(m.keychain).ok()?, + ChildNumber::from_normal_idx(m.index).ok()?, + ]; + Some(account.derive_priv(secp(), &children).ok()?.private_key) + } +} + +/// The uniffi surface of [`ClientKeys`]. Only public material crosses the +/// boundary: no method returns a mnemonic, seed, xprv or private key. +#[uniffi::export] +impl ClientKeys { + /// Restore keys from an existing mnemonic (`InvalidMnemonic` on a bad + /// checksum, word or word count). + #[uniffi::constructor(name = "from_mnemonic")] + pub fn ffi_from_mnemonic(mnemonic: String, network: BitcoinNetwork) -> SdkResult> { + Ok(Arc::new(Self::from_mnemonic(&mnemonic, network)?)) + } + + /// Network these keys were derived for. + pub fn network(&self) -> BitcoinNetwork { + self.network + } + + /// Master key fingerprint, 8 lowercase hex chars. + #[uniffi::method(name = "fingerprint")] + pub fn fingerprint_hex(&self) -> String { + self.fingerprint.as_bytes().to_lower_hex_string() + } + + /// Public registration material; never contains private keys. + pub fn xpubs(&self) -> AccountXpubs { + AccountXpubs { + network: self.network, + fingerprint: self.fingerprint_hex(), + vanilla: self.vanilla_xpub().to_string(), + colored: self.colored_xpub().to_string(), + } + } +} + +/// Wipe the account private keys when the keys go away. `non_secure_erase` +/// is secp256k1's best-effort overwrite (volatile writes the optimiser cannot +/// elide); it is not a guarantee against every copy the allocator made. The +/// chain codes are left: with the private key gone they only reproduce the +/// public xpub, which is shared with the gateway anyway. +impl Drop for ClientKeys { + fn drop(&mut self) { + self.vanilla_account.private_key.non_secure_erase(); + self.colored_account.private_key.non_secure_erase(); + } +} + +/// Overwrite a secret buffer with zeros through volatile writes so the +/// store is not optimised away as dead. +fn wipe(bytes: &mut [u8]) { + for b in bytes.iter_mut() { + // SAFETY: `b` is a valid, aligned, exclusive reference for the write. + unsafe { std::ptr::write_volatile(b, 0) }; + } + std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst); +} diff --git a/minimal-sdk/rust-sdk/src/lib.rs b/minimal-sdk/rust-sdk/src/lib.rs new file mode 100644 index 00000000..c6092413 --- /dev/null +++ b/minimal-sdk/rust-sdk/src/lib.rs @@ -0,0 +1,165 @@ +//! Minimal UTEXO client SDK core. +//! +//! One Rust implementation of key management, taproot derivation, +//! verify-before-sign PSBT checks, signing, invoice decoding and the gateway +//! client, exposed to Kotlin and Swift through uniffi. This crate is **not** +//! the node: no LDK, no rgb-lib, no HTTP or TLS stack. +//! +//! Behavioural reference: `minimal-sdk/packages/client-sdk/src/` (TypeScript). +//! Parity against rgb-lib is checked in `tests/parity.rs` from the fixture +//! `minimal-sdk/packages/client-sdk/test/fixtures/rgblib-parity.json`. +//! +//! Invariants that hold everywhere in this crate (design doc I1–I4): +//! - the mnemonic and any xprv never leave the device and are never a gateway +//! request field; +//! - only account xpubs and the master fingerprint are ever sent to the gateway; +//! - nothing logs, `Debug`-prints or renders a mnemonic, seed, xprv or private +//! key, including error variants; +//! - no panic crosses the FFI boundary: every exported function returns +//! [`SdkResult`]. + +use std::sync::OnceLock; + +use bitcoin::secp256k1::{All, Secp256k1}; + +uniffi::setup_scaffolding!(); + +pub mod derive; +pub mod ffi; +pub mod gateway; +pub mod invoice; +pub(crate) mod json; +pub mod keys; +pub mod network; +pub mod sign; +pub mod verify; + +pub use derive::{ + derive_for_origin_path, derive_taproot, match_origin_path, parse_account_xpub, DerivedScript, + OriginPathMatch, ParsedAccounts, +}; +pub use ffi::{derive_taproot_address, generate_keys, sdk_version}; +pub use gateway::{ + encode_uri_component, generate_idempotency_key, GatewayClient, HttpMethod, HttpRequest, + HttpResponse, HttpTransport, PreparedOp, +}; +pub use invoice::{decode_bolt11, decode_rgb_invoice, BeneficiaryKind, Bolt11Invoice, RgbInvoice}; +pub use keys::{AccountXpubs, ClientKeys, GeneratedKeys}; +pub use network::BitcoinNetwork; +pub use sign::{verify_and_sign_psbt, SignResult}; +pub use verify::{ + own_derivation, verify_psbt, CheckName, CheckResult, IntentAsset, IntentKind, IntentRecipient, + IntentUtxos, OnchainIntent, TapKeyOrigins, VerifyParams, VerifyVerdict, +}; + +/// Process-wide secp256k1 context (signing + verification). Built once; the +/// precomputed tables are the bulk of the crate's size and are shared. +pub(crate) fn secp() -> &'static Secp256k1 { + static SECP: OnceLock> = OnceLock::new(); + SECP.get_or_init(Secp256k1::new) +} + +/// The single error type every exported function returns. +/// +/// Variants carry only diagnostic strings and status codes. **Never** format a +/// mnemonic, seed, xprv, private key, or raw PSBT/transaction bytes derived from +/// them into a variant: errors are the place secrets leak in practice, and the +/// host app may log them verbatim. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] +pub enum SdkError { + /// A caller-supplied argument was malformed (bad network name, bad hex, ...). + #[error("invalid input: {reason}")] + InvalidInput { reason: String }, + /// The mnemonic did not pass BIP-39 validation. Deliberately carries no + /// detail about the words supplied. + #[error("invalid mnemonic")] + InvalidMnemonic, + /// A BIP-32 / taproot derivation failed (hardened path requested from an + /// xpub, index out of range, ...). + #[error("derivation failed: {reason}")] + DerivationFailed { reason: String }, + /// The PSBT could not be parsed at all. Hostile-but-parseable PSBTs are a + /// failed verification verdict, not an error. + #[error("invalid psbt: {reason}")] + InvalidPsbt { reason: String }, + /// One of the five verify-before-sign checks failed; signing was refused. + #[error("verification failed at check '{check}': {reason}")] + VerificationFailed { check: String, reason: String }, + /// Signing or finalization failed after verification passed. + #[error("signing failed: {reason}")] + SigningFailed { reason: String }, + /// A BOLT-11 or RGB invoice could not be decoded. The only error the + /// invoice decoders return: they are fed user-pasted strings. + #[error("invalid invoice: {reason}")] + InvoiceDecode { reason: String }, + /// The foreign HTTP transport reported a failure (no HTTP status available). + #[error("transport error: {reason}")] + Transport { reason: String }, + /// The gateway answered with a non-success status. `detail` is the + /// server's human-readable message (`{ error: { message } }` in the + /// body). It is deliberately not named `message`: uniffi renders error + /// variants as `kotlin.Exception` subclasses, where a `message` field + /// collides with `Throwable.message` and the generated Kotlin does not + /// compile (found by the Task 8 Android build). + #[error("gateway error {status}: {code}: {detail}")] + Gateway { + status: u16, + code: String, + detail: String, + }, + /// The gateway's returned intent did not match what the client asked for. + #[error("intent mismatch: {reason}")] + IntentMismatch { reason: String }, + /// An internal invariant was violated. Reported instead of panicking so the + /// host app gets an error rather than a crash. + #[error("internal error: {reason}")] + Internal { reason: String }, +} + +/// Result alias every exported function returns. +pub type SdkResult = Result; + +/// A foreign `HttpTransport` that throws anything other than the declared +/// `SdkError` (a Kotlin `RuntimeException`, a Swift `Error` that is not +/// `SdkError`, a failure to lift the error buffer) reaches Rust as +/// `UnexpectedUniFFICallbackError`. Without this conversion uniffi's generic +/// path panics inside `GatewayClient`, which the export boundary re-raises as +/// an untyped internal exception: the one place a host bug would break the +/// "no panic crosses the FFI" invariant. Mapping it to `Transport` keeps the +/// error typed; `reason` is uniffi's own diagnostic and carries no headers. +impl From for SdkError { + fn from(e: uniffi::UnexpectedUniFFICallbackError) -> Self { + SdkError::Transport { + reason: format!("transport raised an unexpected error: {}", e.reason), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_display_carries_only_diagnostics() { + let err = SdkError::VerificationFailed { + check: "fee-budget".into(), + reason: "fee 1200 > max 1000".into(), + }; + assert_eq!( + err.to_string(), + "verification failed at check 'fee-budget': fee 1200 > max 1000" + ); + assert_eq!(SdkError::InvalidMnemonic.to_string(), "invalid mnemonic"); + } + + #[test] + fn unexpected_foreign_callback_errors_become_transport_errors() { + let err: SdkError = uniffi::UnexpectedUniFFICallbackError::new("boom").into(); + assert_eq!( + err, + SdkError::Transport { + reason: "transport raised an unexpected error: boom".into() + } + ); + } +} diff --git a/minimal-sdk/rust-sdk/src/network.rs b/minimal-sdk/rust-sdk/src/network.rs new file mode 100644 index 00000000..6cf30483 --- /dev/null +++ b/minimal-sdk/rust-sdk/src/network.rs @@ -0,0 +1,223 @@ +//! Network constants mirroring rgb-lib exactly (rgb-lib `src/utils.rs`, +//! `get_coin_type`): BIP-86 purpose, account 0, keychain 0 on both the vanilla +//! (standard coin type) and colored (RGB coin type 827166/827167) sides. +//! +//! Behavioural reference: `minimal-sdk/packages/client-sdk/src/network.ts`. + +use std::fmt; + +use bitcoin::bip32::ChildNumber; +use bitcoin::{Network, NetworkKind}; + +/// BIP-86 purpose (`86'`). +pub const PURPOSE: u32 = 86; +/// Account index (`0'`) for both the vanilla and the colored account. +pub const ACCOUNT: u32 = 0; +/// External keychain (`0`) — the only keychain own *outputs* may use. +pub const KEYCHAIN: u32 = 0; +/// BIP-32 hardened-index threshold. +pub const HARDENED: u32 = 0x8000_0000; + +/// RGB coin type on mainnet (rgb-lib `get_coin_type`). +pub const COIN_RGB_MAINNET: u32 = 827_166; +/// RGB coin type on every non-mainnet network. +pub const COIN_RGB_TESTNET: u32 = 827_167; + +/// BIP-44 coin type for vanilla (BTC) keys on mainnet. +pub const COIN_BTC_MAINNET: u32 = 0; +/// BIP-44 coin type for vanilla (BTC) keys on every non-mainnet network. +pub const COIN_BTC_TESTNET: u32 = 1; + +/// The four networks the gateway and rgb-lib know about. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, uniffi::Enum)] +pub enum BitcoinNetwork { + Mainnet, + Testnet, + Signet, + Regtest, +} + +/// Version bytes for extended key serialization (xprv/xpub vs tprv/tpub). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HdVersions { + pub private: u32, + pub public: u32, +} + +const MAINNET_VERSIONS: HdVersions = HdVersions { + private: 0x0488_ade4, + public: 0x0488_b21e, +}; +const TESTNET_VERSIONS: HdVersions = HdVersions { + private: 0x0435_8394, + public: 0x0435_87cf, +}; + +impl BitcoinNetwork { + /// All networks, in the order the parity fixture lists them. + pub const ALL: [BitcoinNetwork; 4] = [ + BitcoinNetwork::Mainnet, + BitcoinNetwork::Testnet, + BitcoinNetwork::Signet, + BitcoinNetwork::Regtest, + ]; + + /// Canonical name as used by the gateway API and the parity fixture. + pub fn name(self) -> &'static str { + match self { + BitcoinNetwork::Mainnet => "Mainnet", + BitcoinNetwork::Testnet => "Testnet", + BitcoinNetwork::Signet => "Signet", + BitcoinNetwork::Regtest => "Regtest", + } + } + + /// The `bitcoin` crate network (address encoding, script rules). + pub fn to_bitcoin(self) -> Network { + match self { + BitcoinNetwork::Mainnet => Network::Bitcoin, + BitcoinNetwork::Testnet => Network::Testnet, + BitcoinNetwork::Signet => Network::Signet, + BitcoinNetwork::Regtest => Network::Regtest, + } + } + + /// Main vs Test: selects the xprv/xpub or tprv/tpub serialization. + pub fn network_kind(self) -> NetworkKind { + match self { + BitcoinNetwork::Mainnet => NetworkKind::Main, + _ => NetworkKind::Test, + } + } + + /// Extended-key version bytes for this network. + pub fn hd_versions(self) -> HdVersions { + match self { + BitcoinNetwork::Mainnet => MAINNET_VERSIONS, + _ => TESTNET_VERSIONS, + } + } + + /// Bech32 human-readable part for addresses: `bc` / `tb` / `tb` / `bcrt`. + pub fn bech32_hrp(self) -> &'static str { + match self { + BitcoinNetwork::Mainnet => "bc", + BitcoinNetwork::Testnet | BitcoinNetwork::Signet => "tb", + BitcoinNetwork::Regtest => "bcrt", + } + } + + /// Whether this is mainnet (real funds). + pub fn is_mainnet(self) -> bool { + self == BitcoinNetwork::Mainnet + } +} + +impl fmt::Display for BitcoinNetwork { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// BIP-44 coin type: the standard BTC coin type for vanilla keys, the RGB +/// coin type for colored keys. +pub fn coin_type(network: BitcoinNetwork, colored: bool) -> u32 { + match (colored, network.is_mainnet()) { + (true, true) => COIN_RGB_MAINNET, + (true, false) => COIN_RGB_TESTNET, + (false, true) => COIN_BTC_MAINNET, + (false, false) => COIN_BTC_TESTNET, + } +} + +/// Hardened account derivation path children: `[86', coin', 0']`. +pub fn account_path(network: BitcoinNetwork, colored: bool) -> [ChildNumber; 3] { + [ + ChildNumber::Hardened { index: PURPOSE }, + ChildNumber::Hardened { + index: coin_type(network, colored), + }, + ChildNumber::Hardened { index: ACCOUNT }, + ] +} + +/// The same path as raw `u32` children (`HARDENED + x`), the form PSBT +/// key-origin metadata and the TypeScript SDK use. +pub fn account_path_u32(network: BitcoinNetwork, colored: bool) -> [u32; 3] { + account_path(network, colored).map(u32::from) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coin_types_match_rgb_lib() { + assert_eq!(coin_type(BitcoinNetwork::Mainnet, false), 0); + assert_eq!(coin_type(BitcoinNetwork::Mainnet, true), 827_166); + for net in [ + BitcoinNetwork::Testnet, + BitcoinNetwork::Signet, + BitcoinNetwork::Regtest, + ] { + assert_eq!(coin_type(net, false), 1, "{net}"); + assert_eq!(coin_type(net, true), 827_167, "{net}"); + } + } + + #[test] + fn account_path_is_hardened_86_coin_0() { + assert_eq!( + account_path_u32(BitcoinNetwork::Regtest, false), + [HARDENED + 86, HARDENED + 1, HARDENED] + ); + assert_eq!( + account_path_u32(BitcoinNetwork::Regtest, true), + [HARDENED + 86, HARDENED + 827_167, HARDENED] + ); + assert_eq!( + account_path_u32(BitcoinNetwork::Mainnet, true), + [HARDENED + 86, HARDENED + 827_166, HARDENED] + ); + for child in account_path(BitcoinNetwork::Mainnet, false) { + assert!(child.is_hardened()); + } + assert_eq!( + account_path(BitcoinNetwork::Mainnet, false) + .iter() + .map(ToString::to_string) + .collect::>(), + ["86'", "0'", "0'"] + ); + } + + #[test] + fn versions_hrp_and_kind() { + assert_eq!(BitcoinNetwork::Mainnet.hd_versions().private, 0x0488_ade4); + assert_eq!(BitcoinNetwork::Mainnet.hd_versions().public, 0x0488_b21e); + for net in [ + BitcoinNetwork::Testnet, + BitcoinNetwork::Signet, + BitcoinNetwork::Regtest, + ] { + assert_eq!(net.hd_versions().private, 0x0435_8394); + assert_eq!(net.hd_versions().public, 0x0435_87cf); + assert_eq!(net.network_kind(), NetworkKind::Test); + } + assert_eq!(BitcoinNetwork::Mainnet.network_kind(), NetworkKind::Main); + assert_eq!(BitcoinNetwork::Mainnet.bech32_hrp(), "bc"); + assert_eq!(BitcoinNetwork::Testnet.bech32_hrp(), "tb"); + assert_eq!(BitcoinNetwork::Signet.bech32_hrp(), "tb"); + assert_eq!(BitcoinNetwork::Regtest.bech32_hrp(), "bcrt"); + assert_eq!(BitcoinNetwork::Regtest.to_bitcoin(), Network::Regtest); + } + + #[test] + fn names_are_distinct_and_display_as_themselves() { + let names: Vec<&str> = BitcoinNetwork::ALL.iter().map(|n| n.name()).collect(); + assert_eq!(names, ["Mainnet", "Testnet", "Signet", "Regtest"]); + for net in BitcoinNetwork::ALL { + assert_eq!(net.to_string(), net.name()); + } + } +} diff --git a/minimal-sdk/rust-sdk/src/sign.rs b/minimal-sdk/rust-sdk/src/sign.rs new file mode 100644 index 00000000..06d95e11 --- /dev/null +++ b/minimal-sdk/rust-sdk/src/sign.rs @@ -0,0 +1,298 @@ +//! BIP-341 key-path sign + finalize. Refuses to sign unless verify-before-sign +//! passed: verification runs **inside** [`verify_and_sign_psbt`] and a failed +//! verdict is [`SdkError::VerificationFailed`] — there is no bypass. This +//! module exports exactly one function and no flag, second entry point or +//! `pub` helper that signs without verifying (asserted in `tests/parity.rs`). +//! +//! Keys are derived per input from the PSBT's own key-origin paths, but only +//! after [`crate::verify`] check 1 proved each path re-derives to the exact +//! script being spent, and only from the entry [`own_derivation`] selects by +//! our master fingerprint — the same entry check 1 proved. +//! +//! Behavioural reference: `minimal-sdk/packages/client-sdk/src/sign.ts`. +//! Parity with rgb-lib's own signer is **txid + signature validity**, never +//! witness byte-equality: BIP-340 signatures are randomized. + +use bitcoin::hashes::Hash; +use bitcoin::key::{Keypair, TapTweak, XOnlyPublicKey}; +use bitcoin::psbt::Psbt; +use bitcoin::sighash::{Prevouts, SighashCache}; +use bitcoin::taproot; +use bitcoin::{TapSighashType, TxOut, Witness}; + +use crate::keys::ClientKeys; +use crate::verify::{own_derivation, verify_psbt, VerifyParams, VerifyVerdict}; +use crate::{secp, SdkError, SdkResult}; + +/// What [`verify_and_sign_psbt`] returns on success. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct SignResult { + /// Signed AND finalized PSBT, base64 — what the gateway's `complete` + /// expects. + pub signed_psbt: String, + pub txid: String, + /// The passing verdict signing was conditioned on. + pub verdict: VerifyVerdict, +} + +/// Verify the PSBT against the intent, then key-path-sign every input and +/// finalize. +/// +/// Errors, in order of evaluation: +/// - `InvalidInput` when `params.xpubs` are not the xpubs of `keys`: the +/// verdict would then be proven for a different wallet than the one +/// signing, breaking "sign with the entry verify proved". Checked first +/// because it is a caller bug, not a property of the PSBT. +/// - `VerificationFailed` when any of the five checks fails: `check` names +/// the first failed check, `reason` lists every failed check with its +/// detail. Nothing is signed. +/// - `SigningFailed` naming the input index when an input has no signable +/// key-origin entry of ours (never a skip), carries a sighash type other +/// than `DEFAULT`/`ALL` (a `NONE`/`SINGLE`/`ANYONECANPAY` signature would +/// not commit to the outputs the verdict approved), or the produced +/// signature does not verify against the script being spent. +/// +/// No error carries key material; every reason is an input index, a check +/// name or a fixed diagnostic. +pub fn verify_and_sign_psbt( + keys: &ClientKeys, + psbt_base64: &str, + params: &VerifyParams, +) -> SdkResult { + // The verdict is proven against `params.xpubs`; the signature comes + // from `keys`. They must be the same wallet, or check 1's "this path + // re-derives to the spent script" says nothing about the key used below. + if params.xpubs != keys.xpubs() { + return Err(SdkError::InvalidInput { + reason: "params.xpubs are not the xpubs of the signing keys".into(), + }); + } + let verdict = verify_psbt(psbt_base64, params); + if !verdict.ok { + return Err(refusal(&verdict)); + } + + let mut psbt: Psbt = psbt_base64.parse().map_err(|_| SdkError::InvalidPsbt { + reason: "psbt did not re-parse after verification".into(), + })?; + let txid = psbt.unsigned_tx.compute_txid().to_string(); + if verdict.txid.as_deref() != Some(txid.as_str()) { + return Err(SdkError::Internal { + reason: "verdict txid does not match the transaction being signed".into(), + }); + } + + // Check 1 guarantees every input carries a witness_utxo; collect them + // once for the BIP-341 sighash (which commits to all prevouts). + let prevouts: Vec = psbt + .inputs + .iter() + .enumerate() + .map(|(i, input)| { + input.witness_utxo.clone().ok_or(SdkError::SigningFailed { + reason: format!("input {i}: no witnessUtxo"), + }) + }) + .collect::>()?; + let prevouts = Prevouts::All(&prevouts); + let mut cache = SighashCache::new(&psbt.unsigned_tx); + + let mut signatures: Vec = Vec::with_capacity(psbt.inputs.len()); + for (index, input) in psbt.inputs.iter().enumerate() { + // Sign with the SAME entry verify proved re-derives to the script + // being spent — selected by our master fingerprint, not by path + // shape. Matching on shape alone would pick a decoy entry carrying a + // foreign fingerprint when the PSBT lists one first, and signing + // would then fail against a key no check ever covered. + let Some((_, source)) = own_derivation(&input.tap_key_origins, keys.fingerprint()) else { + return Err(SdkError::SigningFailed { + reason: format!("input {index}: no signable key-origin path"), + }); + }; + let Some(secret_key) = keys.private_key_for_path(&source.1.to_u32_vec()) else { + return Err(SdkError::SigningFailed { + reason: format!("input {index}: key-origin path is not under our accounts"), + }); + }; + + // Only DEFAULT (absent) or ALL: anything else would let the + // transaction be altered after the verdict approved it. + let sighash_type = match input.sighash_type { + None => TapSighashType::Default, + Some(ty) => match ty.taproot_hash_ty() { + Ok(TapSighashType::Default) => TapSighashType::Default, + Ok(TapSighashType::All) => TapSighashType::All, + _ => { + return Err(SdkError::SigningFailed { + reason: format!("input {index}: unsupported sighash type {ty}"), + }) + } + }, + }; + let sighash = cache + .taproot_key_spend_signature_hash(index, &prevouts, sighash_type) + .map_err(|_| SdkError::SigningFailed { + reason: format!("input {index}: sighash computation failed"), + })?; + let message = bitcoin::secp256k1::Message::from_digest(sighash.to_byte_array()); + + // BIP-341 key-path spend of a `tr(key)` output with no script tree: + // tweak the derived internal key with an empty merkle root — the + // construction check 1 re-derived the spent script from. + let keypair = Keypair::from_secret_key(secp(), &secret_key) + .tap_tweak(secp(), None) + .to_keypair(); + let signature = secp().sign_schnorr(&message, &keypair); + + // End-to-end proof the signature spends THIS output: verify it + // against the output key inside the spent script, not against + // anything the PSBT metadata claims. + let output_key = spent_output_key(&input.witness_utxo).ok_or(SdkError::SigningFailed { + reason: format!("input {index}: spent script is not a taproot output"), + })?; + if secp() + .verify_schnorr(&signature, &message, &output_key) + .is_err() + { + return Err(SdkError::SigningFailed { + reason: format!( + "input {index}: signature does not verify against the spent script" + ), + }); + } + signatures.push(taproot::Signature { + signature, + sighash_type, + }); + } + + // Signer role: record every key-path signature on its input. + for (input, signature) in psbt.inputs.iter_mut().zip(signatures) { + input.tap_key_sig = Some(signature); + } + // Finalizer role: build the one-element key-path witness from + // tap_key_sig and, as BIP-174 requires, drop the signing metadata the + // final witness supersedes. Only the UTXO fields and the final witness + // stay — exactly what rgb-lib's own finalized fixture PSBT retains. + for (index, input) in psbt.inputs.iter_mut().enumerate() { + let signature = input.tap_key_sig.take().ok_or(SdkError::Internal { + reason: format!("input {index}: signed but no tap_key_sig to finalize"), + })?; + input.final_script_witness = Some(Witness::p2tr_key_spend(&signature)); + input.partial_sigs.clear(); + input.sighash_type = None; + input.redeem_script = None; + input.witness_script = None; + input.bip32_derivation.clear(); + input.tap_script_sigs.clear(); + input.tap_scripts.clear(); + input.tap_key_origins.clear(); + input.tap_internal_key = None; + input.tap_merkle_root = None; + } + + Ok(SignResult { + signed_psbt: psbt.to_string(), + txid, + verdict, + }) +} + +/// The 32-byte output key of a `OP_1 <32 bytes>` script, if that is what +/// the spent output is. +fn spent_output_key(witness_utxo: &Option) -> Option { + let script = witness_utxo.as_ref()?.script_pubkey.as_bytes(); + if !(script.len() == 34 && script[0] == 0x51 && script[1] == 0x20) { + return None; + } + XOnlyPublicKey::from_slice(&script[2..]).ok() +} + +/// The refusal error for a failed verdict: `check` is the first failed +/// check, `reason` lists every failed check with its detail (which carry +/// indexes, amounts, addresses and check names only — never key material). +fn refusal(verdict: &VerifyVerdict) -> SdkError { + let failed: Vec = verdict + .checks + .iter() + .filter(|c| !c.ok) + .map(|c| match &c.detail { + Some(detail) => format!("{} ({detail})", c.check), + None => c.check.to_string(), + }) + .collect(); + SdkError::VerificationFailed { + check: verdict + .first_failure() + .map(|c| c.check.to_string()) + .unwrap_or_default(), + reason: format!("refusing to sign: {}", failed.join("; ")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::verify::{CheckName, CheckResult}; + + #[test] + fn refusal_names_the_first_failure_and_lists_them_all() { + let verdict = VerifyVerdict { + ok: false, + checks: CheckName::ALL + .iter() + .map(|&check| CheckResult { + check, + ok: !matches!(check, CheckName::RecipientsMatch | CheckName::FeeBudget), + detail: match check { + CheckName::RecipientsMatch => Some("no output pays 39000 sat".into()), + CheckName::FeeBudget => Some("fee 5000 sat outside budget".into()), + _ => None, + }, + }) + .collect(), + fee_sat: Some(5000), + txid: None, + }; + let err = refusal(&verdict); + assert_eq!( + err, + SdkError::VerificationFailed { + check: "recipients-match".into(), + reason: "refusing to sign: recipients-match (no output pays 39000 sat); \ + fee-budget (fee 5000 sat outside budget)" + .into(), + } + ); + } + + #[test] + fn spent_output_key_accepts_only_p2tr_scripts() { + let key = XOnlyPublicKey::from_slice(&[2u8; 32]).unwrap(); + let p2tr = bitcoin::ScriptBuf::new_p2tr_tweaked( + bitcoin::key::TweakedPublicKey::dangerous_assume_tweaked(key), + ); + let utxo = |script: bitcoin::ScriptBuf| { + Some(TxOut { + value: bitcoin::Amount::from_sat(1), + script_pubkey: script, + }) + }; + assert_eq!(spent_output_key(&utxo(p2tr)), Some(key)); + assert_eq!(spent_output_key(&None), None); + assert_eq!(spent_output_key(&utxo(bitcoin::ScriptBuf::new())), None); + // P2WPKH (OP_0 <20 bytes>) and a 33-byte OP_1 push are not taproot. + assert_eq!( + spent_output_key(&utxo(bitcoin::ScriptBuf::from_bytes( + [vec![0x00, 0x14], vec![7u8; 20]].concat() + ))), + None + ); + assert_eq!( + spent_output_key(&utxo(bitcoin::ScriptBuf::from_bytes( + [vec![0x51, 0x21], vec![2u8; 33]].concat() + ))), + None + ); + } +} diff --git a/minimal-sdk/rust-sdk/src/verify.rs b/minimal-sdk/rust-sdk/src/verify.rs new file mode 100644 index 00000000..4bdb71a4 --- /dev/null +++ b/minimal-sdk/rust-sdk/src/verify.rs @@ -0,0 +1,664 @@ +//! Verify-before-sign: the design doc's five checks, evaluated against a +//! parsed PSBT and the gateway's machine-readable intent summary. Signing is +//! never blind — `sign` refuses unless the verdict here is ok. +//! +//! All PSBT content (scripts, amounts, key-origin metadata) is treated as +//! adversarial. Ownership is proven by **re-deriving** scripts from the +//! user's own account xpubs: a hostile server may supply any path it likes, +//! but only a path under the user's accounts can re-derive to a script the +//! user controls. [`verify_psbt`] never returns an error and never panics on +//! malformed input — it returns a failed verdict. +//! +//! Known limitation (design doc, "send-time trust"): RGB allocations on the +//! spent inputs are NOT verifiable here; only bitcoin-value movement is. +//! +//! Behavioural reference: `minimal-sdk/packages/client-sdk/src/verify.ts`. +//! Two properties from that source must survive any refactor: +//! +//! 1. [`own_derivation`] selects the key-origin entry **by our master +//! fingerprint, never by path shape**, and sign signs with the very entry +//! verify proved. +//! 2. Own **outputs** are restricted to keychain 0 at a bounded index; own +//! **inputs** are not. The asymmetry is deliberate (see check 3). + +use std::cell::OnceCell; +use std::collections::{BTreeMap, HashSet}; +use std::fmt; +use std::str::FromStr; + +use bitcoin::bip32::{Fingerprint, KeySource}; +use bitcoin::hex::DisplayHex; +use bitcoin::key::XOnlyPublicKey; +use bitcoin::psbt::Psbt; +use bitcoin::taproot::TapLeafHash; +use bitcoin::Address; + +use crate::derive::{derive_for_origin_path, derive_taproot, ParsedAccounts}; +use crate::keys::AccountXpubs; +use crate::network::{BitcoinNetwork, KEYCHAIN}; + +/// Which gateway operation an intent summarises. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum IntentKind { + SendBtc, + SendAsset, + CreateUtxos, +} + +impl IntentKind { + /// The gateway's wire name (`send_btc`, `send_asset`, `create_utxos`). + pub fn name(self) -> &'static str { + match self { + IntentKind::SendBtc => "send_btc", + IntentKind::SendAsset => "send_asset", + IntentKind::CreateUtxos => "create_utxos", + } + } +} + +impl fmt::Display for IntentKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// A bitcoin recipient in the gateway's intent summary. Mirrors the +/// gateway's prepare-response schema (kept in sync). +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct IntentRecipient { + /// Human-readable address — the field the user actually reviews and the + /// one check 2 re-derives the expected script from. + pub address: String, + /// Server-supplied output script, lowercase hex. Only cross-checked + /// against the address; never trusted on its own. + pub script_hex: String, + pub amount_sat: u64, +} + +/// The RGB side of a `send_asset` intent. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct IntentAsset { + pub asset_id: String, + pub amount: u64, + pub recipient_id: String, + /// `Some` for witness (`wvout`) beneficiaries: the exact sat amount the + /// one foreign witness output must carry. `None` for blind sends, where + /// no foreign output is allowed at all. + pub witness_amount_sat: Option, + pub transport_endpoints: Vec, +} + +/// The shape of a `create_utxos` intent. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct IntentUtxos { + pub up_to: bool, + pub num: u32, + pub size: u64, +} + +/// The gateway's machine-readable summary of what a prepared PSBT does. +/// +/// It is SERVER-produced. The caller must bind it to the user's own request +/// first (the gateway client's `prepare*` methods do); check 2 is only as +/// trustworthy as that binding. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct OnchainIntent { + pub kind: IntentKind, + pub fee_rate_sat_per_vb: u64, + pub recipients: Vec, + pub asset: Option, + pub utxos: Option, +} + +/// The five verify-before-sign checks, in evaluation order. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, uniffi::Enum)] +pub enum CheckName { + InputsOwn, + RecipientsMatch, + ChangeOwn, + FeeBudget, + OpretZero, +} + +impl CheckName { + /// All five checks, in the order every verdict lists them. + pub const ALL: [CheckName; 5] = [ + CheckName::InputsOwn, + CheckName::RecipientsMatch, + CheckName::ChangeOwn, + CheckName::FeeBudget, + CheckName::OpretZero, + ]; + + /// The check's wire name, identical to the TypeScript SDK's `CheckName`. + pub fn name(self) -> &'static str { + match self { + CheckName::InputsOwn => "inputs-own", + CheckName::RecipientsMatch => "recipients-match", + CheckName::ChangeOwn => "change-own", + CheckName::FeeBudget => "fee-budget", + CheckName::OpretZero => "opret-zero", + } + } +} + +impl fmt::Display for CheckName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// Outcome of one check. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct CheckResult { + pub check: CheckName, + pub ok: bool, + /// Human-readable reason when `ok` is false. Carries indexes, amounts, + /// addresses and check names only — never key material. + pub detail: Option, +} + +/// The verdict: `ok` only when all five checks pass. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct VerifyVerdict { + pub ok: bool, + /// Exactly five entries, in [`CheckName::ALL`] order. + pub checks: Vec, + /// Absolute fee in sats (`inputs − outputs`), when computable. Negative + /// when the outputs exceed the inputs. + pub fee_sat: Option, + /// Txid of the unsigned transaction, when parseable. + pub txid: Option, +} + +impl VerifyVerdict { + /// The result of one named check. + pub fn check(&self, name: CheckName) -> Option<&CheckResult> { + self.checks.iter().find(|c| c.check == name) + } + + /// Names of the checks that failed, in evaluation order. + pub fn failed(&self) -> Vec { + self.checks + .iter() + .filter(|c| !c.ok) + .map(|c| c.check) + .collect() + } + + /// The first failed check, if any — what a refusal error reports. + pub fn first_failure(&self) -> Option<&CheckResult> { + self.checks.iter().find(|c| !c.ok) + } +} + +/// Everything [`verify_psbt`] needs besides the PSBT itself. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct VerifyParams { + /// Intent summary from the prepare response, already bound to the + /// user's own request. + pub intent: OnchainIntent, + /// The user's own account xpubs + master fingerprint + /// ([`crate::keys::ClientKeys::xpubs`]). + pub xpubs: AccountXpubs, + /// User-approved absolute fee budget in sats (check 4). + pub max_fee_sat: u64, + /// Fallback for own outputs lacking key-origin metadata: accept scripts + /// re-derived at keychain 0, indexes `0..window`, on both accounts. + /// `Some(0)` disables the fallback (metadata-only). `None` = 30. + #[uniffi(default = None)] + pub change_scan_window: Option, + /// Highest derivation index accepted for a metadata-proven own OUTPUT + /// (check 3). Any index under the account xpub re-derives, but only + /// indexes a descriptor wallet will actually scan are recoverable — + /// without this bound a hostile gateway could steer "change" to e.g. + /// index 9e8, where the funds are technically the user's yet invisible + /// to every wallet scan. Kept far above realistic per-user usage while + /// cheap to sweep in a recovery scan. `None` = 10 000. + #[uniffi(default = None)] + pub max_own_output_index: Option, +} + +/// Default for [`VerifyParams::change_scan_window`]. +pub const DEFAULT_CHANGE_SCAN_WINDOW: u32 = 30; +/// Default for [`VerifyParams::max_own_output_index`]. +pub const DEFAULT_MAX_OWN_OUTPUT_INDEX: u32 = 10_000; + +/// The taproot key-origin map a PSBT input or output carries, keyed by +/// x-only public key (so distinct decoy keys are distinct entries). +pub type TapKeyOrigins = BTreeMap, KeySource)>; + +/// First key-origin entry carrying the user's master fingerprint, if any: +/// the x-only key it is filed under and its `(fingerprint, path)` source. +/// +/// Public because sign MUST pick the signing key from the very entry this +/// proved: selecting by path *shape* alone would let a decoy entry with a +/// foreign fingerprint, ordered first, divert signing to a key the verdict +/// never covered. The path inside the returned entry is still +/// attacker-controlled and is only ever accepted through +/// [`crate::derive::match_origin_path`]. +pub fn own_derivation( + origins: &TapKeyOrigins, + fingerprint: Fingerprint, +) -> Option<(XOnlyPublicKey, &KeySource)> { + origins + .iter() + .find(|(_, (_, source))| source.0 == fingerprint) + .map(|(key, (_, source))| (*key, source)) +} + +/// One transaction output as the checks see it. +struct ParsedOutput { + index: usize, + script_hex: String, + amount_sat: u64, + is_opret: bool, + /// Proven ours by re-derivation (metadata path or scan window). + is_own: bool, +} + +/// Output script (lowercase hex) for an address on `network`, or `None` +/// when it does not decode there. +fn script_hex_from_address(address: &str, network: BitcoinNetwork) -> Option { + let parsed = Address::from_str(address).ok()?; + let checked = parsed.require_network(network.to_bitcoin()).ok()?; + Some(checked.script_pubkey().to_hex_string()) +} + +fn fail_all(detail: &str) -> VerifyVerdict { + VerifyVerdict { + ok: false, + checks: CheckName::ALL + .iter() + .map(|&check| CheckResult { + check, + ok: false, + detail: Some(detail.to_owned()), + }) + .collect(), + fee_sat: None, + txid: None, + } +} + +fn result(check: CheckName, failures: Vec) -> CheckResult { + CheckResult { + check, + ok: failures.is_empty(), + detail: if failures.is_empty() { + None + } else { + Some(failures.join("; ")) + }, + } +} + +/// Run the five checks. **Never** returns an error and never panics: a PSBT +/// that cannot be parsed, an xpub or fingerprint that cannot be read, or an +/// internal failure of any kind yields a verdict with all five checks failed +/// and the reason in every `detail`. +pub fn verify_psbt(psbt: &str, params: &VerifyParams) -> VerifyVerdict { + // Belt and braces: every line below is written not to panic on hostile + // input, but a panic here would be a host-app crash, so the whole + // evaluation is fenced as well. + let outcome = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| verify_parsed(psbt, params))); + match outcome { + Ok(Ok(verdict)) => verdict, + Ok(Err(reason)) => fail_all(&format!("psbt could not be safely parsed: {reason}")), + Err(_) => fail_all("psbt could not be safely parsed: internal failure"), + } +} + +fn verify_parsed(psbt: &str, params: &VerifyParams) -> Result { + let intent = ¶ms.intent; + let network = params.xpubs.network; + let our_fingerprint = Fingerprint::from_str(¶ms.xpubs.fingerprint) + .map_err(|_| "master fingerprint is not 8 hex chars".to_owned())?; + let accounts = ParsedAccounts::parse(¶ms.xpubs).map_err(|e| e.to_string())?; + let psbt = Psbt::from_str(psbt).map_err(|e| e.to_string())?; + let mut checks: Vec = Vec::with_capacity(CheckName::ALL.len()); + + // Check 1 — every input is ours: key-origin fingerprint matches AND the + // path re-derives (from OUR xpubs) to the exact script being spent. + let mut input_failures: Vec = Vec::new(); + let mut input_total_sat: u128 = 0; + for (i, input) in psbt.inputs.iter().enumerate() { + let Some(utxo) = &input.witness_utxo else { + input_failures.push(format!( + "input {i}: no witnessUtxo, cannot verify what is being spent" + )); + continue; + }; + input_total_sat += u128::from(utxo.value.to_sat()); + let Some((_, source)) = own_derivation(&input.tap_key_origins, our_fingerprint) else { + input_failures.push(format!( + "input {i}: no key origin with our fingerprint (foreign input)" + )); + continue; + }; + let Some(derived) = derive_for_origin_path(&source.1.to_u32_vec(), &accounts, network) + else { + input_failures.push(format!( + "input {i}: key-origin path is not under our accounts" + )); + continue; + }; + if derived.script_hex != utxo.script_pubkey.to_hex_string() { + input_failures.push(format!( + "input {i}: spent script does not re-derive from our keys" + )); + continue; + } + if let Some(internal_key) = input.tap_internal_key { + if internal_key.serialize().to_lower_hex_string() != derived.internal_key_hex { + input_failures.push(format!( + "input {i}: tapInternalKey mismatch with re-derived key" + )); + } + } + } + if psbt.inputs.is_empty() { + input_failures.push("transaction has no inputs".to_owned()); + } + checks.push(result(CheckName::InputsOwn, input_failures)); + + // Own-script set for the metadata-less change fallback (keychain 0 only — + // the single keychain rgb-lib uses on both accounts). Built lazily: real + // rgb-lib PSBTs carry key-origin metadata on their change outputs, so the + // common path must not pay for 2 × scan_window key derivations. + let scan_window = params + .change_scan_window + .unwrap_or(DEFAULT_CHANGE_SCAN_WINDOW); + let scanned_own_scripts: OnceCell> = OnceCell::new(); + let is_scanned_own_script = |script_hex: &str| { + scanned_own_scripts + .get_or_init(|| { + let mut set = HashSet::new(); + for account in [&accounts.vanilla, &accounts.colored] { + for index in 0..scan_window { + if let Ok(derived) = derive_taproot(account, KEYCHAIN, index, network) { + set.insert(derived.script_hex); + } + } + } + set + }) + .contains(script_hex) + }; + let max_own_output_index = params + .max_own_output_index + .unwrap_or(DEFAULT_MAX_OWN_OUTPUT_INDEX); + + let mut outputs: Vec = Vec::with_capacity(psbt.outputs.len()); + let mut output_total_sat: u128 = 0; + for (index, (txout, output)) in psbt + .unsigned_tx + .output + .iter() + .zip(psbt.outputs.iter()) + .enumerate() + { + let script_hex = txout.script_pubkey.to_hex_string(); + let amount_sat = txout.value.to_sat(); + output_total_sat += u128::from(amount_sat); + let is_opret = txout.script_pubkey.as_bytes().first() == Some(&0x6a); + let mut is_own = false; + if let Some((_, source)) = own_derivation(&output.tap_key_origins, our_fingerprint) { + // Own OUTPUTS must live in the wallet's real descriptor space: + // keychain 0 (the single keychain rgb-lib scans, `KEYCHAIN`) at a + // bounded index. Inputs (check 1) stay unrestricted — spending + // from an odd path is proven safe by the script re-derivation + // alone. Do not "tidy" this asymmetry. + is_own = derive_for_origin_path(&source.1.to_u32_vec(), &accounts, network) + .is_some_and(|derived| { + derived.script_hex == script_hex + && derived.keychain == KEYCHAIN + && derived.index <= max_own_output_index + }); + } + if !is_own && !is_opret { + is_own = is_scanned_own_script(&script_hex); + } + outputs.push(ParsedOutput { + index, + script_hex, + amount_sat, + is_opret, + is_own, + }); + } + + // Check 2 — recipient outputs match the user-stated intent + // (script + amount), matched as a multiset; plus (witness asset sends) + // exactly one foreign output carrying exactly the approved witness amount. + // + // The expected script is RE-DERIVED from the human-readable address — the + // field the user actually reviews. The server-supplied script_hex is only + // cross-checked: a hostile gateway could otherwise pair the intended + // address with an attacker script and both would "match". + let mut accounted: HashSet = HashSet::new(); + let mut recipient_failures: Vec = Vec::new(); + for recipient in &intent.recipients { + let Some(expected_script_hex) = script_hex_from_address(&recipient.address, network) else { + recipient_failures.push(format!( + "recipient address {} does not decode on {network}", + recipient.address + )); + continue; + }; + if recipient.script_hex.to_lowercase() != expected_script_hex { + recipient_failures.push(format!( + "intent scriptHex does not match recipient address {}", + recipient.address + )); + continue; + } + let matched = outputs.iter().find(|o| { + !accounted.contains(&o.index) + && o.script_hex == expected_script_hex + && o.amount_sat == recipient.amount_sat + }); + match matched { + Some(output) => { + accounted.insert(output.index); + } + None => recipient_failures.push(format!( + "no output pays {} sat to intended recipient {}", + recipient.amount_sat, recipient.address + )), + } + } + if let Some(witness_amount) = intent.asset.as_ref().and_then(|a| a.witness_amount_sat) { + let candidates: Vec = outputs + .iter() + .filter(|o| { + !accounted.contains(&o.index) + && !o.is_own + && !o.is_opret + && o.amount_sat == witness_amount + }) + .map(|o| o.index) + .collect(); + match candidates.as_slice() { + [only] => { + accounted.insert(*only); + } + _ => recipient_failures.push(format!( + "expected exactly one witness output of {witness_amount} sat, found {}", + candidates.len() + )), + } + } + checks.push(result(CheckName::RecipientsMatch, recipient_failures)); + + // Check 3 — change pays only re-derivable own scripts: everything that is + // not an intended recipient, the approved witness output, or an + // OP_RETURN must prove ownership. + let change_failures: Vec = outputs + .iter() + .filter(|o| !accounted.contains(&o.index) && !o.is_opret && !o.is_own) + .map(|o| { + format!( + "output {} ({} sat) does not re-derive from our keys", + o.index, o.amount_sat + ) + }) + .collect(); + checks.push(result(CheckName::ChangeOwn, change_failures)); + + // Check 4 — fee = inputs − outputs within the user-approved budget. + let fee: i128 = input_total_sat as i128 - output_total_sat as i128; + let fee_ok = fee > 0 && fee <= i128::from(params.max_fee_sat); + checks.push(result( + CheckName::FeeBudget, + if fee_ok { + Vec::new() + } else { + vec![format!( + "fee {fee} sat outside budget (0, {}]", + params.max_fee_sat + )] + }, + )); + + // Check 5 — OP_RETURN outputs carry 0 sats. + let opret_failures: Vec = outputs + .iter() + .filter(|o| o.is_opret && o.amount_sat != 0) + .map(|o| format!("OP_RETURN output {} carries {} sat", o.index, o.amount_sat)) + .collect(); + checks.push(result(CheckName::OpretZero, opret_failures)); + + Ok(VerifyVerdict { + ok: checks.iter().all(|c| c.ok), + checks, + fee_sat: i64::try_from(fee).ok(), + txid: Some(psbt.unsigned_tx.compute_txid().to_string()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::bip32::{ChildNumber, DerivationPath}; + + fn origins(entries: &[(XOnlyPublicKey, Fingerprint, &[u32])]) -> TapKeyOrigins { + entries + .iter() + .map(|(key, fp, path)| { + let path: DerivationPath = path + .iter() + .map(|&c| ChildNumber::from(c)) + .collect::>() + .into(); + (*key, (Vec::new(), (*fp, path))) + }) + .collect() + } + + fn xonly(byte: u8) -> XOnlyPublicKey { + let sk = bitcoin::secp256k1::SecretKey::from_slice(&[byte; 32]).unwrap(); + let (key, _) = sk.x_only_public_key(crate::secp()); + key + } + + #[test] + fn own_derivation_selects_by_fingerprint_not_position_or_shape() { + let ours = Fingerprint::from([0x73, 0xc5, 0xda, 0x0a]); + let theirs = Fingerprint::from([1, 2, 3, 4]); + let our_key = xonly(9); + let decoy_key = xonly(2); + let our_path: &[u32] = &[0x8000_0056, 0x8000_0001, 0x8000_0000, 0, 0]; + // The decoy carries a *well-formed* own-looking path and a key that + // sorts first; only the fingerprint tells them apart. + let map = origins(&[(decoy_key, theirs, our_path), (our_key, ours, our_path)]); + assert!(map.keys().next() == Some(&decoy_key) || map.keys().next() == Some(&our_key)); + let (key, source) = own_derivation(&map, ours).expect("our entry"); + assert_eq!(key, our_key); + assert_eq!(source.0, ours); + assert_eq!(source.1.to_u32_vec(), our_path); + + // Only a foreign entry: nothing, even on a perfect path shape. + let only_decoy = origins(&[(decoy_key, theirs, our_path)]); + assert!(own_derivation(&only_decoy, ours).is_none()); + assert!(own_derivation(&TapKeyOrigins::new(), ours).is_none()); + } + + #[test] + fn check_names_match_the_typescript_sdk() { + let names: Vec<&str> = CheckName::ALL.iter().map(|c| c.name()).collect(); + assert_eq!( + names, + [ + "inputs-own", + "recipients-match", + "change-own", + "fee-budget", + "opret-zero" + ] + ); + let kinds: Vec<&str> = [ + IntentKind::SendBtc, + IntentKind::SendAsset, + IntentKind::CreateUtxos, + ] + .iter() + .map(|k| k.name()) + .collect(); + assert_eq!(kinds, ["send_btc", "send_asset", "create_utxos"]); + } + + #[test] + fn fail_all_lists_every_check_with_the_reason() { + let verdict = fail_all("boom"); + assert!(!verdict.ok); + assert_eq!(verdict.checks.len(), 5); + assert_eq!(verdict.failed(), CheckName::ALL); + assert!(verdict + .checks + .iter() + .all(|c| !c.ok && c.detail.as_deref() == Some("boom"))); + assert_eq!(verdict.fee_sat, None); + assert_eq!(verdict.txid, None); + assert_eq!( + verdict.first_failure().map(|c| c.check), + Some(CheckName::InputsOwn) + ); + } + + #[test] + fn script_hex_from_address_requires_the_network() { + let key = xonly(9); + let script = bitcoin::ScriptBuf::new_p2tr(crate::secp(), key, None).to_hex_string(); + for network in BitcoinNetwork::ALL { + let address = Address::p2tr(crate::secp(), key, None, network.to_bitcoin()).to_string(); + assert_eq!( + script_hex_from_address(&address, network).as_deref(), + Some(script.as_str()), + "{network}" + ); + // Mainnet and regtest addresses are foreign everywhere else; + // testnet and signet share the `tb` prefix, as in bitcoin itself. + let foreign = match network { + BitcoinNetwork::Mainnet => BitcoinNetwork::Testnet, + _ => BitcoinNetwork::Mainnet, + }; + assert!( + script_hex_from_address(&address, foreign).is_none(), + "{network}" + ); + } + for bad in [ + "not-an-address", + "", + "bcrt1p", + "bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + ] { + assert!( + script_hex_from_address(bad, BitcoinNetwork::Regtest).is_none(), + "{bad:?}" + ); + } + } +} diff --git a/minimal-sdk/rust-sdk/tests/parity.rs b/minimal-sdk/rust-sdk/tests/parity.rs new file mode 100644 index 00000000..d0343253 --- /dev/null +++ b/minimal-sdk/rust-sdk/tests/parity.rs @@ -0,0 +1,4868 @@ +//! Parity harness, size gate and dependency-creep guard. +//! +//! Parity rule (non-negotiable): the fixture is read **by relative path** from +//! the TypeScript client SDK. It is never copied, regenerated or replaced by a +//! Rust generator. One rgb-lib-authored ground truth, checked by an +//! implementation that shares no code with it. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use bitcoin::bip32::{ChildNumber, Xpub}; +use bitcoin::key::XOnlyPublicKey; +use bitcoin::secp256k1::{PublicKey, Secp256k1}; +use bitcoin::{Address, ScriptBuf}; +use utexo_minimal_sdk::network::HARDENED; +use utexo_minimal_sdk::{ + derive_for_origin_path, derive_taproot, derive_taproot_address, match_origin_path, + parse_account_xpub, BitcoinNetwork, ClientKeys, ParsedAccounts, SdkError, +}; + +/// Stripped release cdylib budget. Baseline measured 2026-09-15 on x86_64 for +/// `bitcoin` + `bip39` + uniffi with derive/PSBT/sign reachable: 1.94 MB, of +/// which 1.06 MB is secp256k1 precomputed tables and irreducible. +const SIZE_BUDGET_BYTES: u64 = 2_500_000; + +/// Crates that must never appear anywhere in the resolved graph, dev or not. +const BANNED_CRATES: &[&str] = &["rgb-lib", "reqwest", "tokio", "rustls", "openssl"]; + +/// Crates allowed as dev-dependencies only; they must not reach the library. +const DEV_ONLY_CRATES: &[&str] = &["serde_json"]; + +fn manifest_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn fixture_path() -> PathBuf { + manifest_dir().join("../packages/client-sdk/test/fixtures/rgblib-parity.json") +} + +fn load_fixture() -> serde_json::Value { + let path = fixture_path(); + let text = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("cannot read parity fixture {}: {e}", path.display())); + serde_json::from_str(&text) + .unwrap_or_else(|e| panic!("parity fixture {} is not JSON: {e}", path.display())) +} + +fn cargo() -> Command { + Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) +} + +fn target_dir() -> PathBuf { + std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| manifest_dir().join("target")) +} + +fn release_cdylib_path() -> PathBuf { + let name = if cfg!(target_os = "macos") { + "libutexo_minimal_sdk.dylib" + } else if cfg!(target_os = "windows") { + "utexo_minimal_sdk.dll" + } else { + "libutexo_minimal_sdk.so" + }; + target_dir().join("release").join(name) +} + +#[test] +fn fixture_is_read_by_relative_path_and_pins_master_fingerprint() { + let path = fixture_path(); + assert!( + path.starts_with(manifest_dir().join("..")), + "fixture must live in the TypeScript client SDK, not in this crate" + ); + assert!( + !manifest_dir() + .join("tests") + .join("rgblib-parity.json") + .exists(), + "do not copy the parity fixture into the Rust crate" + ); + + let fixture = load_fixture(); + let generated_by = fixture["generatedBy"].as_str().unwrap_or_default(); + assert!( + generated_by.contains("@utexo/rgb-lib"), + "fixture must be rgb-lib generated, got generatedBy={generated_by:?}" + ); + let regtest = &fixture["networks"]["Regtest"]; + assert_eq!(regtest["masterFingerprint"].as_str(), Some("73c5da0a")); + for network in ["Regtest", "Testnet", "Signet", "Mainnet"] { + let entry = &fixture["networks"][network]; + assert_eq!( + entry["masterFingerprint"].as_str(), + Some("73c5da0a"), + "master fingerprint is network independent ({network})" + ); + for key in ["accountXpubVanilla", "accountXpubColored"] { + assert!( + entry[key].is_string(), + "{network}.{key} missing from fixture" + ); + } + } +} + +#[test] +fn size_gate_release_cdylib_under_budget() { + let status = cargo() + .args(["build", "--release", "--lib", "--manifest-path"]) + .arg(manifest_dir().join("Cargo.toml")) + .status() + .expect("failed to spawn cargo build --release"); + assert!(status.success(), "release build failed"); + + let path = release_cdylib_path(); + let bytes = fs::metadata(&path) + .unwrap_or_else(|e| panic!("release cdylib missing at {}: {e}", path.display())) + .len(); + println!( + "size gate: {} = {} bytes ({:.2} MB) of {:.2} MB budget", + path.display(), + bytes, + bytes as f64 / 1_000_000.0, + SIZE_BUDGET_BYTES as f64 / 1_000_000.0 + ); + assert!(bytes > 0, "release cdylib is empty"); + assert!( + bytes < SIZE_BUDGET_BYTES, + "release cdylib {bytes} bytes exceeds budget {SIZE_BUDGET_BYTES}; \ + justify the dependency in the progress notes or drop it" + ); +} + +fn lockfile_package_names(lock: &str) -> Vec { + lock.lines() + .filter_map(|line| line.strip_prefix("name = \"")) + .filter_map(|rest| rest.strip_suffix('"')) + .map(str::to_owned) + .collect() +} + +#[test] +fn dependency_creep_guard() { + let lock_path = manifest_dir().join("Cargo.lock"); + let lock = fs::read_to_string(&lock_path).expect("Cargo.lock must be committed"); + let names = lockfile_package_names(&lock); + assert!( + names.iter().any(|n| n == "utexo-minimal-sdk"), + "Cargo.lock does not describe this crate" + ); + for banned in BANNED_CRATES { + assert!( + !names.iter().any(|n| n == banned), + "banned crate `{banned}` is in Cargo.lock (resolved graph)" + ); + } + + // Library graph only (no dev-dependencies): dev-only helpers must not ship. + let output = cargo() + .args([ + "tree", + "-e", + "normal", + "--prefix", + "none", + "--locked", + "--manifest-path", + ]) + .arg(manifest_dir().join("Cargo.toml")) + .output() + .expect("failed to run cargo tree"); + assert!( + output.status.success(), + "cargo tree failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let tree = String::from_utf8_lossy(&output.stdout); + let library_crates: Vec<&str> = tree + .lines() + .filter_map(|line| line.split_whitespace().next()) + .collect(); + assert!( + library_crates.contains(&"utexo-minimal-sdk"), + "cargo tree output did not include the crate:\n{tree}" + ); + for banned in BANNED_CRATES.iter().chain(DEV_ONLY_CRATES) { + assert!( + !library_crates.contains(banned), + "`{banned}` reached the library dependency graph:\n{tree}" + ); + } + println!( + "dependency guard: {} crates in the library graph", + library_crates + .iter() + .collect::>() + .len() + ); +} + +#[test] +fn no_udl_file_exists() { + // Bindings are generated in library mode from proc-macro metadata only. + let src = manifest_dir().join("src"); + let udl: Vec = fs::read_dir(&src) + .expect("src dir") + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|ext| ext == "udl")) + .collect(); + assert!(udl.is_empty(), "UDL files are not allowed: {udl:?}"); + assert!(!Path::new(&manifest_dir().join("src/utexo_minimal_sdk.udl")).exists()); +} + +// --------------------------------------------------------------------------- +// Task 2: keys — BIP-39 → BIP-32 accounts, checked against rgb-lib's output. +// --------------------------------------------------------------------------- + +fn fixture_str<'a>(v: &'a serde_json::Value, what: &str) -> &'a str { + v.as_str() + .unwrap_or_else(|| panic!("fixture field {what} is not a string")) +} + +fn fixture_mnemonic(fixture: &serde_json::Value) -> String { + fixture_str(&fixture["mnemonic"], "mnemonic").to_owned() +} + +/// Secret-looking substrings that must never appear in any rendered form. +fn secret_markers(mnemonic: &str) -> Vec { + let mut markers = vec![mnemonic.to_owned()]; + markers.extend(["xprv", "tprv", "SecretKey", "private_key"].map(str::to_owned)); + markers +} + +/// No secret marker appears as a substring, and no run of two consecutive +/// mnemonic words appears as consecutive tokens. A single word is not +/// evidence: a random BIP-39 word (`network`, `address`, `fee`, ...) is +/// legitimately a Debug field name or diagnostic word, and generated +/// mnemonics made the single-token form of this check flaky. Any real leak +/// renders the phrase, or at least a run of it, in order. +fn assert_no_secret(rendered: &str, mnemonic: &str, what: &str) { + for marker in secret_markers(mnemonic) { + assert!( + !rendered.contains(&marker), + "{what} leaks {marker:?}: {rendered}" + ); + } + let tokens: Vec<&str> = rendered + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|t| !t.is_empty()) + .collect(); + let words: Vec<&str> = mnemonic.split_whitespace().collect(); + for pair in words.windows(2) { + assert!( + !tokens.windows(2).any(|t| t == pair), + "{what} leaks mnemonic words {pair:?}: {rendered}" + ); + } +} + +#[test] +fn keys_all_four_networks_match_rgb_lib_fixture_exactly() { + let fixture = load_fixture(); + let mnemonic = fixture_mnemonic(&fixture); + for network in BitcoinNetwork::ALL { + let entry = &fixture["networks"][network.name()]; + assert!(entry.is_object(), "fixture has no entry for {network}"); + let keys = ClientKeys::from_mnemonic(&mnemonic, network) + .unwrap_or_else(|e| panic!("from_mnemonic failed on {network}: {e}")); + let xpubs = keys.xpubs(); + assert_eq!(xpubs.network, network); + assert_eq!( + xpubs.vanilla, + fixture_str(&entry["accountXpubVanilla"], "accountXpubVanilla"), + "{network} vanilla account xpub" + ); + assert_eq!( + xpubs.colored, + fixture_str(&entry["accountXpubColored"], "accountXpubColored"), + "{network} colored account xpub" + ); + assert_eq!( + xpubs.fingerprint, + fixture_str(&entry["masterFingerprint"], "masterFingerprint"), + "{network} master fingerprint" + ); + assert_eq!(keys.fingerprint_hex(), xpubs.fingerprint); + assert_eq!(keys.network(), network); + + // Serialization version bytes are the network's xpub/tpub bytes. + let expected_prefix = if network.is_mainnet() { "xpub" } else { "tpub" }; + for xpub in [&xpubs.vanilla, &xpubs.colored] { + assert!(xpub.starts_with(expected_prefix), "{network}: {xpub}"); + let decoded: Xpub = xpub.parse().expect("our xpub must round-trip"); + let version = u32::from_be_bytes(decoded.encode()[..4].try_into().unwrap()); + assert_eq!(version, network.hd_versions().public, "{network}"); + assert_eq!(decoded.depth, 3, "account xpub is m/86'/coin'/0'"); + assert_eq!( + decoded.child_number, + ChildNumber::Hardened { index: 0 }, + "account child is 0'" + ); + } + } +} + +#[test] +fn keys_fingerprint_is_eight_lowercase_hex_chars() { + let fixture = load_fixture(); + let keys = + ClientKeys::from_mnemonic(&fixture_mnemonic(&fixture), BitcoinNetwork::Regtest).unwrap(); + let fp = keys.fingerprint_hex(); + assert_eq!(fp.len(), 8); + assert!(fp + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); + assert_eq!(fp, "73c5da0a"); +} + +#[test] +fn keys_bad_mnemonics_are_rejected_without_detail() { + let fixture = load_fixture(); + let good = fixture_mnemonic(&fixture); + let words: Vec<&str> = good.split_whitespace().collect(); + // Bad checksum: swap the last word for a valid word that breaks the checksum. + let bad_checksum = [&words[..11], &["abandon"]].concat().join(" "); + // Unknown word, wrong word count, and a 12-word all-valid but wrong-checksum + // phrase with the last word swapped from a different valid phrase. + let unknown_word = [&words[..11], &["notaword"]].concat().join(" "); + let short = words[..11].join(" "); + let long = [&words[..], &["about"]].concat().join(" "); + for (label, bad) in [ + ("bad checksum", bad_checksum), + ("unknown word", unknown_word), + ("11 words", short), + ("13 words", long), + ("empty", String::new()), + ] { + for network in BitcoinNetwork::ALL { + let err = ClientKeys::from_mnemonic(&bad, network) + .err() + .unwrap_or_else(|| panic!("{label} accepted on {network}")); + assert_eq!(err, SdkError::InvalidMnemonic, "{label} on {network}"); + // The error renders nothing about the words supplied. + let rendered = format!("{err} / {err:?}"); + for word in bad.split_whitespace() { + assert!(!rendered.contains(word), "{label}: error leaks {word:?}"); + } + } + } + // Whitespace variations of a valid mnemonic are still valid (BIP-39 + // normalization), as in the TypeScript SDK's validateMnemonic. + let spaced = format!(" {} ", words.join(" ")); + let a = ClientKeys::from_mnemonic(&spaced, BitcoinNetwork::Regtest).unwrap(); + let b = ClientKeys::from_mnemonic(&good, BitcoinNetwork::Regtest).unwrap(); + assert_eq!(a.xpubs(), b.xpubs()); +} + +#[test] +fn keys_other_mnemonic_yields_different_material() { + let fixture = load_fixture(); + let other = fixture_str(&fixture["otherMnemonic"], "otherMnemonic"); + let reference = + ClientKeys::from_mnemonic(&fixture_mnemonic(&fixture), BitcoinNetwork::Regtest).unwrap(); + let foreign = ClientKeys::from_mnemonic(other, BitcoinNetwork::Regtest) + .expect("otherMnemonic is a valid BIP-39 phrase"); + assert_ne!(reference.fingerprint_hex(), foreign.fingerprint_hex()); + assert_ne!(reference.xpubs().vanilla, foreign.xpubs().vanilla); + assert_ne!(reference.xpubs().colored, foreign.xpubs().colored); + // The colored and vanilla accounts are distinct keys, not aliases. + assert_ne!(reference.xpubs().vanilla, reference.xpubs().colored); + // The same mnemonic on mainnet vs regtest: same fingerprint (network + // independent), different xpubs (different coin types + version bytes). + let mainnet = + ClientKeys::from_mnemonic(&fixture_mnemonic(&fixture), BitcoinNetwork::Mainnet).unwrap(); + assert_eq!(mainnet.fingerprint_hex(), reference.fingerprint_hex()); + assert_ne!(mainnet.xpubs().vanilla, reference.xpubs().vanilla); +} + +#[test] +fn keys_generate_round_trips_through_from_mnemonic() { + for network in BitcoinNetwork::ALL { + let generated = ClientKeys::generate(network).expect("generate"); + assert_eq!( + generated.mnemonic.split_whitespace().count(), + 12, + "fresh mnemonics are 12 words" + ); + let restored = ClientKeys::from_mnemonic(&generated.mnemonic, network).unwrap(); + assert_eq!(restored.xpubs(), generated.keys.xpubs()); + assert_eq!(restored.fingerprint_hex(), generated.keys.fingerprint_hex()); + assert_eq!(generated.keys.network(), network); + // Two generations never collide. + let again = ClientKeys::generate(network).unwrap(); + assert_ne!(again.mnemonic, generated.mnemonic); + assert_ne!(again.keys.xpubs(), generated.keys.xpubs()); + } + // The exported free function is the same thing. + let via_ffi = utexo_minimal_sdk::generate_keys(BitcoinNetwork::Regtest).unwrap(); + assert!(ClientKeys::from_mnemonic(&via_ffi.mnemonic, BitcoinNetwork::Regtest).is_ok()); +} + +#[test] +fn keys_debug_and_xpubs_render_no_secret() { + let fixture = load_fixture(); + let mnemonic = fixture_mnemonic(&fixture); + for network in BitcoinNetwork::ALL { + let keys = ClientKeys::from_mnemonic(&mnemonic, network).unwrap(); + let debug = format!("{keys:?}"); + assert_no_secret(&debug, &mnemonic, "ClientKeys Debug"); + assert!(debug.contains("ClientKeys"), "{debug}"); + assert!( + debug.contains("73c5da0a"), + "Debug should still identify the wallet: {debug}" + ); + let pretty = format!("{keys:#?}"); + assert_no_secret(&pretty, &mnemonic, "ClientKeys pretty Debug"); + + let xpubs = keys.xpubs(); + let rendered = format!( + "{xpubs:?} {} {} {}", + xpubs.fingerprint, xpubs.vanilla, xpubs.colored + ); + assert_no_secret(&rendered, &mnemonic, "AccountXpubs"); + } + // GeneratedKeys deliberately holds the mnemonic; its Debug must not print it. + let generated = ClientKeys::generate(BitcoinNetwork::Regtest).unwrap(); + let debug = format!("{generated:?}"); + assert_no_secret(&debug, &generated.mnemonic, "GeneratedKeys Debug"); + assert!(debug.contains("[redacted]"), "{debug}"); +} + +#[test] +fn keys_private_key_for_path_follows_match_origin_path() { + let fixture = load_fixture(); + let network = BitcoinNetwork::Regtest; + let keys = ClientKeys::from_mnemonic(&fixture_mnemonic(&fixture), network).unwrap(); + let secp = Secp256k1::new(); + let h = HARDENED; + + // Own paths under both accounts: the private key's public key equals the + // public derivation from the shared account xpub — proving the xpubs the + // gateway sees and the keys we sign with are the same accounts. + for (colored, coin) in [(false, 1u32), (true, 827_167u32)] { + let xpub: Xpub = if colored { + keys.xpubs().colored.parse().unwrap() + } else { + keys.xpubs().vanilla.parse().unwrap() + }; + for (keychain, index) in [(0u32, 0u32), (0, 4), (1, 9), (0, 123_456)] { + let path = [h + 86, h + coin, h, keychain, index]; + let sk = keys + .private_key_for_path(&path) + .unwrap_or_else(|| panic!("own path {path:?} rejected")); + let expected = xpub + .derive_pub( + &secp, + &[ + ChildNumber::from_normal_idx(keychain).unwrap(), + ChildNumber::from_normal_idx(index).unwrap(), + ], + ) + .unwrap() + .public_key; + assert_eq!(PublicKey::from_secret_key(&secp, &sk), expected, "{path:?}"); + } + } + + // Foreign paths: None, never an error, never a panic. + let foreign: &[&[u32]] = &[ + &[], + &[h + 86, h + 1, h], + &[h + 86, h + 1, h, 0], + &[h + 86, h + 1, h, 0, 0, 0], + &[h + 84, h + 1, h, 0, 0], + &[h + 86, h, h, 0, 0], // mainnet coin on regtest keys + &[h + 86, h + 827_166, h, 0, 0], // mainnet RGB coin on regtest keys + &[h + 86, h + 1, h + 1, 0, 0], // account 1' + &[h + 86, h + 1, h, h, 0], // hardened keychain + &[h + 86, h + 1, h, 0, h], // hardened index + &[u32::MAX; 5], + ]; + for path in foreign { + assert!(keys.private_key_for_path(path).is_none(), "{path:?}"); + } +} + +// --------------------------------------------------------------------------- +// Task 3: taproot derivation and origin-path matching, checked against the +// rgb-lib-generated regtest addresses. +// --------------------------------------------------------------------------- + +fn fixture_addresses(fixture: &serde_json::Value, key: &str) -> Vec { + let list = fixture["regtest"][key] + .as_array() + .unwrap_or_else(|| panic!("fixture regtest.{key} is not an array")); + assert_eq!(list.len(), 5, "fixture regtest.{key} has 5 addresses"); + list.iter() + .enumerate() + .map(|(i, v)| fixture_str(v, &format!("regtest.{key}[{i}]")).to_owned()) + .collect() +} + +fn regtest_accounts(fixture: &serde_json::Value) -> ParsedAccounts { + let entry = &fixture["networks"]["Regtest"]; + ParsedAccounts { + network: BitcoinNetwork::Regtest, + vanilla: parse_account_xpub( + fixture_str(&entry["accountXpubVanilla"], "accountXpubVanilla"), + BitcoinNetwork::Regtest, + ) + .unwrap(), + colored: parse_account_xpub( + fixture_str(&entry["accountXpubColored"], "accountXpubColored"), + BitcoinNetwork::Regtest, + ) + .unwrap(), + } +} + +#[test] +fn derive_reproduces_rgb_lib_regtest_addresses_at_keychain_0_indexes_0_to_4() { + let fixture = load_fixture(); + let network = BitcoinNetwork::Regtest; + let accounts = regtest_accounts(&fixture); + let entry = &fixture["networks"]["Regtest"]; + + for (colored, key) in [(false, "vanillaAddresses"), (true, "coloredAddresses")] { + let expected = fixture_addresses(&fixture, key); + let xpub_str = fixture_str( + &entry[if colored { + "accountXpubColored" + } else { + "accountXpubVanilla" + }], + key, + ); + for (index, address) in expected.iter().enumerate() { + let index = index as u32; + let derived = derive_taproot(accounts.account(colored), 0, index, network) + .unwrap_or_else(|e| panic!("{key}[{index}]: {e}")); + assert_eq!(&derived.address, address, "{key}[{index}] address"); + assert_eq!(derived.keychain, 0); + assert_eq!(derived.index, index); + + // The script is the BIP-341 tr(key) output for that address, the + // internal key is the untweaked x-only key, and both are lowercase hex. + let parsed: Address = address + .parse::>() + .unwrap() + .require_network(network.to_bitcoin()) + .unwrap(); + let script_hex = parsed.script_pubkey().to_hex_string(); + assert_eq!(derived.script_hex, script_hex, "{key}[{index}] script"); + assert!(derived.script_hex.starts_with("5120"), "OP_1 PUSH32"); + assert_eq!(derived.script_hex.len(), 68); + assert_eq!(derived.internal_key_hex.len(), 64); + for hex in [&derived.script_hex, &derived.internal_key_hex] { + assert!(hex + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); + } + let internal: XOnlyPublicKey = derived.internal_key_hex.parse().unwrap(); + let secp = Secp256k1::new(); + assert_eq!( + ScriptBuf::new_p2tr(&secp, internal, None).to_hex_string(), + derived.script_hex, + "internal key tweaks to the output script" + ); + assert_ne!( + &derived.script_hex[4..], + derived.internal_key_hex, + "output key must be the tweaked key, not the raw internal key" + ); + + // The exported FFI function gives the identical answer from the string xpub. + assert_eq!( + derive_taproot_address(xpub_str.to_owned(), 0, index, network).unwrap(), + derived + ); + } + } + + // The vanilla and colored accounts never produce the same address. + let vanilla = fixture_addresses(&fixture, "vanillaAddresses"); + let colored = fixture_addresses(&fixture, "coloredAddresses"); + assert!(vanilla.iter().all(|a| !colored.contains(a))); + // Same accounts, other keychain/index: different, still regtest bech32m. + let other = derive_taproot(&accounts.vanilla, 1, 0, network).unwrap(); + assert!(!vanilla.contains(&other.address)); + assert!(other.address.starts_with("bcrt1p")); +} + +#[test] +fn derive_address_hrp_follows_network() { + let fixture = load_fixture(); + let mnemonic = fixture_mnemonic(&fixture); + for network in BitcoinNetwork::ALL { + let keys = ClientKeys::from_mnemonic(&mnemonic, network).unwrap(); + let derived = derive_taproot(&keys.accounts().vanilla, 0, 0, network).unwrap(); + let prefix = format!("{}1p", network.bech32_hrp()); + assert!( + derived.address.starts_with(&prefix), + "{network}: {}", + derived.address + ); + // Same string xpub through the FFI path; wrong-network xpub is refused. + let xpubs = keys.xpubs(); + assert_eq!( + derive_taproot_address(xpubs.vanilla.clone(), 0, 0, network).unwrap(), + derived + ); + let other = if network.is_mainnet() { + BitcoinNetwork::Regtest + } else { + BitcoinNetwork::Mainnet + }; + assert!(matches!( + derive_taproot_address(xpubs.vanilla.clone(), 0, 0, other), + Err(SdkError::InvalidInput { .. }) + )); + } + // Hardened keychain/index through the FFI surface: an error, never a panic. + let keys = ClientKeys::from_mnemonic(&mnemonic, BitcoinNetwork::Regtest).unwrap(); + let xpub = keys.xpubs().vanilla; + assert!(matches!( + derive_taproot_address(xpub.clone(), HARDENED, 0, BitcoinNetwork::Regtest), + Err(SdkError::DerivationFailed { .. }) + )); + assert!(matches!( + derive_taproot_address(xpub, 0, HARDENED, BitcoinNetwork::Regtest), + Err(SdkError::DerivationFailed { .. }) + )); + assert!(matches!( + derive_taproot_address("garbage".into(), 0, 0, BitcoinNetwork::Regtest), + Err(SdkError::InvalidInput { .. }) + )); +} + +#[test] +fn derive_for_origin_path_re_derives_rgb_lib_addresses_and_rejects_foreign_paths() { + let fixture = load_fixture(); + let network = BitcoinNetwork::Regtest; + let accounts = regtest_accounts(&fixture); + let h = HARDENED; + let vanilla_path = [h + 86, h + 1, h]; + let colored_path = [h + 86, h + 827_167, h]; + let vanilla = fixture_addresses(&fixture, "vanillaAddresses"); + let colored = fixture_addresses(&fixture, "coloredAddresses"); + + for index in 0..5u32 { + let v = derive_for_origin_path( + &[vanilla_path[0], vanilla_path[1], vanilla_path[2], 0, index], + &accounts, + network, + ) + .unwrap(); + assert_eq!(v.address, vanilla[index as usize]); + let c = derive_for_origin_path( + &[colored_path[0], colored_path[1], colored_path[2], 0, index], + &accounts, + network, + ) + .unwrap(); + assert_eq!(c.address, colored[index as usize]); + } + // ClientKeys::accounts() is the same public material. + let keys = ClientKeys::from_mnemonic(&fixture_mnemonic(&fixture), network).unwrap(); + assert_eq!(keys.accounts(), accounts); + + let foreign: &[&[u32]] = &[ + &[], + &[h + 84, h + 1, h, 0, 0], + &[h + 86, h + 1, h, 0], + &[h + 86, h + 1, h, h, 0], + &[h + 86, h + 1, h, 0, h], + &[h + 86, h + 1, h, 0, 0, 0], + &[h + 86, h, h, 0, 0], + &[h + 86, h + 827_166, h, 0, 0], + &[h + 86, h + 1, h + 1, 0, 0], + ]; + for path in foreign { + assert!( + derive_for_origin_path(path, &accounts, network).is_none(), + "{path:?}" + ); + } +} + +#[test] +fn match_origin_path_accepts_both_accounts_and_rejects_everything_else() { + let h = HARDENED; + for network in BitcoinNetwork::ALL { + let (btc, rgb) = if network.is_mainnet() { + (0u32, 827_166u32) + } else { + (1u32, 827_167u32) + }; + let v = match_origin_path(&[h + 86, h + btc, h, 0, 5], network).unwrap(); + assert!(!v.colored); + assert_eq!((v.keychain, v.index), (0, 5)); + let c = match_origin_path(&[h + 86, h + rgb, h, 1, h - 1], network).unwrap(); + assert!(c.colored); + assert_eq!((c.keychain, c.index), (1, h - 1)); + + let rejected: Vec> = vec![ + vec![], // empty + vec![h + 86, h + btc, h], // account only + vec![h + 86, h + btc, h, 0], // short + vec![h + 86, h + btc, h, 0, 0, 0], // over-long + vec![h + 84, h + btc, h, 0, 0], // wrong purpose + vec![86, h + btc, h, 0, 0], // unhardened purpose + vec![h + 86, h + btc + 2, h, 0, 0], // wrong coin + vec![h + 86, btc, h, 0, 0], // unhardened coin + vec![h + 86, h + btc, h + 1, 0, 0], // wrong account + vec![h + 86, h + btc, 0, 0, 0], // unhardened account + vec![h + 86, h + btc, h, h, 0], // hardened keychain + vec![h + 86, h + btc, h, 0, h], // hardened index + vec![h + 86, h + rgb, h, u32::MAX, 0], // hardened keychain (colored) + vec![h + 86, h + rgb, h, 0, u32::MAX], // hardened index (colored) + vec![u32::MAX; 5], + ]; + for path in &rejected { + assert_eq!(match_origin_path(path, network), None, "{network} {path:?}"); + } + // The other network's coin types are foreign here. + let (obtc, orgb) = if network.is_mainnet() { + (1, 827_167) + } else { + (0, 827_166) + }; + assert_eq!( + match_origin_path(&[h + 86, h + obtc, h, 0, 0], network), + None + ); + assert_eq!( + match_origin_path(&[h + 86, h + orgb, h, 0, 0], network), + None + ); + } +} + +#[test] +fn private_key_for_path_signs_exactly_what_verify_re_derives() { + // The property the single-source-of-truth design exists for: for every + // path, `private_key_for_path` returns a key iff `match_origin_path` + // accepts it, and that key's x-only public key is precisely the internal + // key `derive_for_origin_path` (verify's view) re-derives for the path. + let fixture = load_fixture(); + let mnemonic = fixture_mnemonic(&fixture); + let secp = Secp256k1::new(); + let h = HARDENED; + for network in BitcoinNetwork::ALL { + let keys = ClientKeys::from_mnemonic(&mnemonic, network).unwrap(); + let accounts = keys.accounts(); + let (btc, rgb) = if network.is_mainnet() { + (0u32, 827_166u32) + } else { + (1u32, 827_167u32) + }; + let mut paths: Vec> = vec![ + vec![], + vec![h + 86, h + btc, h], + vec![h + 86, h + btc, h, 0], + vec![h + 86, h + btc, h, 0, 0, 0], + vec![h + 84, h + btc, h, 0, 0], + vec![h + 86, h + btc, h + 1, 0, 0], + vec![h + 86, h + btc, h, h, 0], + vec![h + 86, h + btc, h, 0, h], + vec![h + 86, h + btc + 2, h, 0, 0], + ]; + for coin in [btc, rgb] { + for (keychain, index) in [(0, 0), (0, 4), (1, 0), (1, 7), (9, 123_456), (0, h - 1)] { + paths.push(vec![h + 86, h + coin, h, keychain, index]); + } + } + let mut accepted = 0; + for path in &paths { + let matched = match_origin_path(path, network); + let sk = keys.private_key_for_path(path); + let derived = derive_for_origin_path(path, &accounts, network); + assert_eq!(matched.is_some(), sk.is_some(), "{network} {path:?}"); + assert_eq!(matched.is_some(), derived.is_some(), "{network} {path:?}"); + if let (Some(sk), Some(derived)) = (sk, derived) { + accepted += 1; + let (xonly, _) = PublicKey::from_secret_key(&secp, &sk).x_only_public_key(); + assert_eq!( + xonly.serialize().to_vec(), + hex_decode(&derived.internal_key_hex), + "{network} {path:?}: signing key != verified internal key" + ); + let m = matched.unwrap(); + assert_eq!((m.keychain, m.index), (derived.keychain, derived.index)); + } + } + assert_eq!(accepted, 12, "{network}: 6 own paths per account"); + } +} + +fn hex_decode(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() +} + +// --------------------------------------------------------------------------- +// Task 4: verify-before-sign — the five checks, against the rgb-lib fixture +// PSBT and adversarial PSBTs built here. Each attack must trip exactly the +// check that owns it; hostile bytes must never panic or error. +// --------------------------------------------------------------------------- + +mod psbt_builder { + //! A PSBT builder with full control over scripts and key-origin metadata, + //! mirroring `minimal-sdk/packages/client-sdk/test/helpers.ts`. Metadata + //! is attacker-controlled in the threat model, so this deliberately lets + //! a test attach any fingerprint/path/key to any input or output. + + use bitcoin::absolute::LockTime; + use bitcoin::bip32::{ChildNumber, DerivationPath, Fingerprint}; + use bitcoin::hashes::Hash; + use bitcoin::key::XOnlyPublicKey; + use bitcoin::psbt::Psbt; + use bitcoin::transaction::Version; + use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; + use utexo_minimal_sdk::network::HARDENED; + use utexo_minimal_sdk::{derive_taproot, BitcoinNetwork, ClientKeys}; + + pub const NETWORK: BitcoinNetwork = BitcoinNetwork::Regtest; + /// Vanilla-account path children on regtest: m/86'/1'/0'. + pub const VANILLA_PATH: [u32; 3] = [HARDENED + 86, HARDENED + 1, HARDENED]; + /// Colored-account path children on regtest: m/86'/827167'/0'. + pub const COLORED_PATH: [u32; 3] = [HARDENED + 86, HARDENED + 827_167, HARDENED]; + + /// Standard zero-value OP_RETURN output used across cases. + pub fn opret_script() -> ScriptBuf { + ScriptBuf::from_bytes(vec![0x6a, 0x05, 1, 2, 3, 4, 5]) + } + + /// A test wallet: the script and internal key at any keychain/index of + /// either account, plus the master fingerprint. + pub struct TestWallet { + pub keys: ClientKeys, + } + + impl TestWallet { + pub fn from_mnemonic(mnemonic: &str) -> Self { + TestWallet { + keys: ClientKeys::from_mnemonic(mnemonic, NETWORK).expect("fixture mnemonic"), + } + } + + pub fn fingerprint(&self) -> Fingerprint { + self.keys.fingerprint() + } + + pub fn script_at(&self, colored: bool, keychain: u32, index: u32) -> ScriptBuf { + let derived = derive_taproot( + self.keys.accounts().account(colored), + keychain, + index, + NETWORK, + ) + .expect("unhardened derivation"); + ScriptBuf::from_hex(&derived.script_hex).unwrap() + } + + pub fn address_at(&self, colored: bool, keychain: u32, index: u32) -> String { + derive_taproot( + self.keys.accounts().account(colored), + keychain, + index, + NETWORK, + ) + .expect("unhardened derivation") + .address + } + + pub fn xonly_at(&self, colored: bool, keychain: u32, index: u32) -> XOnlyPublicKey { + let derived = derive_taproot( + self.keys.accounts().account(colored), + keychain, + index, + NETWORK, + ) + .expect("unhardened derivation"); + derived.internal_key_hex.parse().unwrap() + } + + /// Key-origin metadata claiming this wallet owns `keychain/index`. + pub fn origin(&self, colored: bool, keychain: u32, index: u32) -> KeyOrigin { + let account = if colored { COLORED_PATH } else { VANILLA_PATH }; + KeyOrigin { + fingerprint: self.fingerprint(), + path: [account[0], account[1], account[2], keychain, index].to_vec(), + xonly: self.xonly_at(colored, keychain, index), + } + } + } + + #[derive(Clone)] + pub struct KeyOrigin { + pub fingerprint: Fingerprint, + pub path: Vec, + pub xonly: XOnlyPublicKey, + } + + impl KeyOrigin { + pub fn derivation_path(&self) -> DerivationPath { + self.path + .iter() + .map(|&c| ChildNumber::from(c)) + .collect::>() + .into() + } + } + + pub struct InputSpec { + /// Script actually being spent. + pub script: ScriptBuf, + pub amount: u64, + /// Key-origin entries to attach, decoys included (the map is keyed + /// by x-only key, so a decoy must carry a distinct key). + pub origins: Vec, + pub tap_internal_key: Option, + } + + impl InputSpec { + pub fn new(script: ScriptBuf, amount: u64, origin: Option) -> Self { + InputSpec { + script, + amount, + origins: origin.into_iter().collect(), + tap_internal_key: None, + } + } + } + + pub struct OutputSpec { + pub script: ScriptBuf, + pub amount: u64, + pub origin: Option, + } + + impl OutputSpec { + pub fn new(script: ScriptBuf, amount: u64, origin: Option) -> Self { + OutputSpec { + script, + amount, + origin, + } + } + } + + /// Build a base64 PSBT: v2 transaction, synthetic outpoints, and exactly + /// the metadata each spec asks for. No sanity checks — hostile-server + /// emulation. + pub fn build_psbt(inputs: &[InputSpec], outputs: &[OutputSpec]) -> String { + build_psbt_raw(inputs, outputs).to_string() + } + + pub fn build_psbt_raw(inputs: &[InputSpec], outputs: &[OutputSpec]) -> Psbt { + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: inputs + .iter() + .enumerate() + .map(|(i, _)| TxIn { + previous_output: OutPoint { + txid: Txid::from_byte_array([(i + 1) as u8; 32]), + vout: i as u32, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }) + .collect(), + output: outputs + .iter() + .map(|o| TxOut { + value: Amount::from_sat(o.amount), + script_pubkey: o.script.clone(), + }) + .collect(), + }; + let mut psbt = Psbt::from_unsigned_tx(tx).expect("unsigned tx"); + for (spec, input) in inputs.iter().zip(psbt.inputs.iter_mut()) { + input.witness_utxo = Some(TxOut { + value: Amount::from_sat(spec.amount), + script_pubkey: spec.script.clone(), + }); + input.tap_internal_key = spec.tap_internal_key; + for origin in &spec.origins { + input.tap_key_origins.insert( + origin.xonly, + (Vec::new(), (origin.fingerprint, origin.derivation_path())), + ); + } + } + for (spec, output) in outputs.iter().zip(psbt.outputs.iter_mut()) { + if let Some(origin) = &spec.origin { + output.tap_internal_key = Some(origin.xonly); + output.tap_key_origins.insert( + origin.xonly, + (Vec::new(), (origin.fingerprint, origin.derivation_path())), + ); + } + } + psbt + } +} + +use bitcoin::psbt::Psbt; +use psbt_builder::{ + build_psbt, build_psbt_raw, opret_script, InputSpec, KeyOrigin, OutputSpec, TestWallet, +}; +use utexo_minimal_sdk::{ + own_derivation, verify_psbt, CheckName, IntentAsset, IntentKind, IntentRecipient, IntentUtxos, + OnchainIntent, VerifyParams, VerifyVerdict, +}; + +struct Wallets { + ours: TestWallet, + foreign: TestWallet, + fixture: serde_json::Value, +} + +fn wallets() -> Wallets { + let fixture = load_fixture(); + let ours = TestWallet::from_mnemonic(&fixture_mnemonic(&fixture)); + let foreign = + TestWallet::from_mnemonic(fixture_str(&fixture["otherMnemonic"], "otherMnemonic")); + assert_ne!(ours.fingerprint(), foreign.fingerprint()); + Wallets { + ours, + foreign, + fixture, + } +} + +/// The intent the rgb-lib fixture PSBT was prepared for: 40 000 sat to the +/// foreign wallet's vanilla address 0 (`test/sign.test.ts`). +fn send_btc_intent(w: &Wallets) -> OnchainIntent { + OnchainIntent { + kind: IntentKind::SendBtc, + fee_rate_sat_per_vb: 2, + recipients: vec![IntentRecipient { + address: w.foreign.address_at(false, 0, 0), + script_hex: w.foreign.script_at(false, 0, 0).to_hex_string(), + amount_sat: 40_000, + }], + asset: None, + utxos: None, + } +} + +fn params(w: &Wallets, intent: OnchainIntent, max_fee_sat: u64) -> VerifyParams { + VerifyParams { + intent, + xpubs: w.ours.keys.xpubs(), + max_fee_sat, + change_scan_window: None, + max_own_output_index: None, + } +} + +fn check(verdict: &VerifyVerdict, name: CheckName) -> &utexo_minimal_sdk::CheckResult { + verdict + .check(name) + .unwrap_or_else(|| panic!("verdict has no {name} check: {verdict:?}")) +} + +fn detail(verdict: &VerifyVerdict, name: CheckName) -> String { + check(verdict, name).detail.clone().unwrap_or_default() +} + +/// Assert exactly `expected` failed, every other check passed, and the +/// verdict is not ok (unless `expected` is empty). +fn assert_only_fails(verdict: &VerifyVerdict, expected: &[CheckName]) { + assert_eq!(verdict.checks.len(), 5, "{verdict:?}"); + assert_eq!( + verdict.checks.iter().map(|c| c.check).collect::>(), + CheckName::ALL, + "checks are always listed in evaluation order" + ); + assert_eq!(verdict.failed(), expected, "{verdict:#?}"); + assert_eq!(verdict.ok, expected.is_empty(), "{verdict:#?}"); + for c in &verdict.checks { + assert_eq!(c.detail.is_some(), !c.ok, "detail iff failed: {c:?}"); + } +} + +fn assert_all_pass(verdict: &VerifyVerdict) { + assert_only_fails(verdict, &[]); +} + +struct Happy { + change_script: Option, + change_origin: Option>, + change_amount: u64, + opret_amount: u64, + extra_outputs: Vec, + input_origins: Option>, + input_internal_key: Option, +} + +impl Happy { + fn default() -> Self { + Happy { + change_script: None, + change_origin: None, + change_amount: 59_000, + opret_amount: 0, + extra_outputs: Vec::new(), + input_origins: None, + input_internal_key: None, + } + } +} + +/// Happy-path shape, same as `test/verify.test.ts` `happyPsbt`: own input +/// 100 000 sat at vanilla 0/0, intended recipient 40 000, own change 59 000 +/// at vanilla 0/1, 0-sat OP_RETURN. Fee 1 000. +fn happy_psbt(w: &Wallets, h: Happy) -> String { + let mut input = InputSpec::new( + w.ours.script_at(false, 0, 0), + 100_000, + Some(w.ours.origin(false, 0, 0)), + ); + if let Some(origins) = h.input_origins { + input.origins = origins; + } + input.tap_internal_key = h.input_internal_key; + let mut outputs = vec![ + OutputSpec::new(w.foreign.script_at(false, 0, 0), 40_000, None), + OutputSpec::new( + h.change_script + .unwrap_or_else(|| w.ours.script_at(false, 0, 1)), + h.change_amount, + h.change_origin + .unwrap_or_else(|| Some(w.ours.origin(false, 0, 1))), + ), + OutputSpec::new(opret_script(), h.opret_amount, None), + ]; + outputs.extend(h.extra_outputs); + build_psbt(&[input], &outputs) +} + +#[test] +fn psbt_deserialize_preserves_every_tap_key_origin_entry_including_decoys() { + // The decoy defence depends on `tap_key_origins` surviving a round trip + // with a foreign-fingerprint entry alongside ours on the same input, and + // on outputs. Proved rather than assumed. + let w = wallets(); + let ours = w.ours.origin(false, 0, 0); + let decoy = w.foreign.origin(false, 0, 1); + let decoy2 = w.foreign.origin(true, 0, 0); + let mut input = InputSpec::new(w.ours.script_at(false, 0, 0), 100_000, None); + input.origins = vec![decoy.clone(), ours.clone(), decoy2.clone()]; + input.tap_internal_key = Some(ours.xonly); + let psbt_b64 = build_psbt( + &[input], + &[OutputSpec::new( + w.ours.script_at(false, 0, 1), + 99_000, + Some(w.ours.origin(false, 0, 1)), + )], + ); + + let parsed: Psbt = psbt_b64.parse().expect("round trip"); + let origins = &parsed.inputs[0].tap_key_origins; + assert_eq!(origins.len(), 3, "all three entries survive: {origins:?}"); + for entry in [&decoy, &ours, &decoy2] { + let (_, (fp, path)) = origins + .get(&entry.xonly) + .unwrap_or_else(|| panic!("entry {} missing", entry.xonly)); + assert_eq!(*fp, entry.fingerprint); + assert_eq!(path.to_u32_vec(), entry.path); + } + assert_eq!(parsed.inputs[0].tap_internal_key, Some(ours.xonly)); + assert_eq!( + parsed.inputs[0] + .witness_utxo + .as_ref() + .map(|u| u.value.to_sat()), + Some(100_000) + ); + assert_eq!(parsed.outputs[0].tap_key_origins.len(), 1); + // Byte-exact re-serialization: nothing is dropped or reordered. + assert_eq!(parsed.to_string(), psbt_b64); + assert_eq!(Psbt::deserialize(&parsed.serialize()).unwrap(), parsed); + + // `own_derivation` on the parsed map picks ours regardless of key order. + let (key, source) = own_derivation(origins, w.ours.fingerprint()).expect("ours"); + assert_eq!(key, ours.xonly); + assert_eq!(source.1.to_u32_vec(), ours.path); + let (dkey, _) = own_derivation(origins, w.foreign.fingerprint()).expect("theirs"); + assert!(dkey == decoy.xonly || dkey == decoy2.xonly); +} + +#[test] +fn verify_fixture_psbt_passes_all_five_checks_with_rgb_lib_txid() { + let w = wallets(); + let unsigned = fixture_str( + &w.fixture["signing"]["unsignedPsbt"], + "signing.unsignedPsbt", + ); + let verdict = verify_psbt(unsigned, ¶ms(&w, send_btc_intent(&w), 2_000)); + assert_all_pass(&verdict); + assert_eq!(verdict.fee_sat, Some(1_000)); + assert_eq!( + verdict.txid.as_deref(), + Some(fixture_str(&w.fixture["signing"]["txid"], "signing.txid")) + ); + // Same answer through the exported function with owned values. + let via_ffi = utexo_minimal_sdk::ffi::ffi_verify_psbt( + unsigned.to_owned(), + params(&w, send_btc_intent(&w), 2_000), + ); + assert_eq!(via_ffi, verdict); + // The fixture PSBT has the rgb-lib shape: metadata on input 0 and on the + // change output, both under our fingerprint. + let parsed: Psbt = unsigned.parse().unwrap(); + assert!(own_derivation(&parsed.inputs[0].tap_key_origins, w.ours.fingerprint()).is_some()); + assert!(own_derivation(&parsed.outputs[1].tap_key_origins, w.ours.fingerprint()).is_some()); + // Nothing rendered from a verdict ever carries key material. + let mnemonic = fixture_mnemonic(&w.fixture); + assert_no_secret(&format!("{verdict:?}"), &mnemonic, "VerifyVerdict Debug"); + assert_no_secret( + &format!("{:?}", params(&w, send_btc_intent(&w), 2_000)), + &mnemonic, + "VerifyParams Debug", + ); +} + +#[test] +fn verify_happy_psbt_built_here_matches_the_fixture_shape() { + let w = wallets(); + let verdict = verify_psbt( + &happy_psbt(&w, Happy::default()), + ¶ms(&w, send_btc_intent(&w), 2_000), + ); + assert_all_pass(&verdict); + assert_eq!(verdict.fee_sat, Some(1_000)); + assert_eq!(verdict.txid.as_ref().map(String::len), Some(64)); + + // Colored-keychain input and change (blind asset send): all pass. + let blind = OnchainIntent { + kind: IntentKind::SendAsset, + fee_rate_sat_per_vb: 2, + recipients: vec![], + asset: Some(IntentAsset { + asset_id: "rgb:fixture".into(), + amount: 5, + recipient_id: "bcrt:utxob:fixture".into(), + witness_amount_sat: None, + transport_endpoints: vec!["rpc://localhost:3000/json-rpc".into()], + }), + utxos: None, + }; + let colored = build_psbt( + &[InputSpec::new( + w.ours.script_at(true, 0, 0), + 30_000, + Some(w.ours.origin(true, 0, 0)), + )], + &[ + OutputSpec::new( + w.ours.script_at(true, 0, 1), + 29_000, + Some(w.ours.origin(true, 0, 1)), + ), + OutputSpec::new(opret_script(), 0, None), + ], + ); + assert_all_pass(&verify_psbt(&colored, ¶ms(&w, blind, 2_000))); +} + +#[test] +fn check1_foreign_input_fails_only_inputs_own() { + let w = wallets(); + // (a) Our script, but the only key origin carries the OTHER mnemonic's + // fingerprint: not provably ours. + let mut foreign_fp = w.foreign.origin(false, 0, 0); + foreign_fp.path = w.ours.origin(false, 0, 0).path; + let verdict = verify_psbt( + &happy_psbt( + &w, + Happy { + input_origins: Some(vec![foreign_fp]), + ..Happy::default() + }, + ), + ¶ms(&w, send_btc_intent(&w), 2_000), + ); + assert_only_fails(&verdict, &[CheckName::InputsOwn]); + assert!(detail(&verdict, CheckName::InputsOwn).contains("foreign input")); + + // (b) Attacker spends THEIR utxo but labels it with our fingerprint/path. + let psbt = build_psbt( + &[InputSpec::new( + w.foreign.script_at(false, 0, 0), + 100_000, + Some(w.ours.origin(false, 0, 0)), + )], + &[ + OutputSpec::new(w.foreign.script_at(false, 0, 0), 40_000, None), + OutputSpec::new( + w.ours.script_at(false, 0, 1), + 59_000, + Some(w.ours.origin(false, 0, 1)), + ), + ], + ); + let verdict = verify_psbt(&psbt, ¶ms(&w, send_btc_intent(&w), 2_000)); + assert_only_fails(&verdict, &[CheckName::InputsOwn]); + assert!(detail(&verdict, CheckName::InputsOwn).contains("does not re-derive")); + + // (c) Our fingerprint on a path outside our accounts (account 1'). + let mut odd = w.ours.origin(false, 0, 0); + odd.path[2] = HARDENED + 1; + let verdict = verify_psbt( + &happy_psbt( + &w, + Happy { + input_origins: Some(vec![odd]), + ..Happy::default() + }, + ), + ¶ms(&w, send_btc_intent(&w), 2_000), + ); + assert_only_fails(&verdict, &[CheckName::InputsOwn]); + assert!(detail(&verdict, CheckName::InputsOwn).contains("not under our accounts")); + + // (d) No metadata at all. + let verdict = verify_psbt( + &happy_psbt( + &w, + Happy { + input_origins: Some(vec![]), + ..Happy::default() + }, + ), + ¶ms(&w, send_btc_intent(&w), 2_000), + ); + assert_only_fails(&verdict, &[CheckName::InputsOwn]); + + // (e) Forged tapInternalKey that does not match the re-derived key. + let verdict = verify_psbt( + &happy_psbt( + &w, + Happy { + input_internal_key: Some(w.ours.xonly_at(false, 0, 4)), + ..Happy::default() + }, + ), + ¶ms(&w, send_btc_intent(&w), 2_000), + ); + assert_only_fails(&verdict, &[CheckName::InputsOwn]); + assert!(detail(&verdict, CheckName::InputsOwn).contains("tapInternalKey mismatch")); + // ...while the genuine internal key passes. + assert_all_pass(&verify_psbt( + &happy_psbt( + &w, + Happy { + input_internal_key: Some(w.ours.xonly_at(false, 0, 0)), + ..Happy::default() + }, + ), + ¶ms(&w, send_btc_intent(&w), 2_000), + )); + + // (f) No witness_utxo: cannot know what is being spent. + let mut psbt = build_psbt_raw( + &[InputSpec::new( + w.ours.script_at(false, 0, 0), + 100_000, + Some(w.ours.origin(false, 0, 0)), + )], + &[OutputSpec::new( + w.ours.script_at(false, 0, 1), + 99_000, + Some(w.ours.origin(false, 0, 1)), + )], + ); + psbt.inputs[0].witness_utxo = None; + let intent = OnchainIntent { + recipients: vec![], + ..send_btc_intent(&w) + }; + let verdict = verify_psbt(&psbt.to_string(), ¶ms(&w, intent, 2_000)); + assert!(detail(&verdict, CheckName::InputsOwn).contains("no witnessUtxo")); + assert!(verdict.failed().contains(&CheckName::InputsOwn)); + + // (g) Own inputs are NOT restricted to keychain 0 or a bounded index: + // spending from an odd path is proven safe by re-derivation alone. + let odd_input = InputSpec::new( + w.ours.script_at(false, 7, 900_000_000), + 100_000, + Some(w.ours.origin(false, 7, 900_000_000)), + ); + let psbt = build_psbt( + &[odd_input], + &[ + OutputSpec::new(w.foreign.script_at(false, 0, 0), 40_000, None), + OutputSpec::new( + w.ours.script_at(false, 0, 1), + 59_000, + Some(w.ours.origin(false, 0, 1)), + ), + ], + ); + assert_all_pass(&verify_psbt(&psbt, ¶ms(&w, send_btc_intent(&w), 2_000))); +} + +#[test] +fn check1_inputless_transaction_fails_closed() { + let w = wallets(); + let psbt = build_psbt( + &[], + &[OutputSpec::new( + w.foreign.script_at(false, 0, 0), + 40_000, + None, + )], + ); + let verdict = verify_psbt(&psbt, ¶ms(&w, send_btc_intent(&w), 2_000)); + assert!(!check(&verdict, CheckName::InputsOwn).ok); + assert!(detail(&verdict, CheckName::InputsOwn).contains("no inputs")); + assert!(!verdict.ok); + assert_eq!(verdict.fee_sat, Some(-40_000)); +} + +/// A self-send: the intended recipient is one of OUR keychain-0 addresses, +/// carrying metadata. Tampering the intent then trips check 2 alone, because +/// the mismatched output still proves ownership under check 3. +fn self_send(w: &Wallets) -> (String, OnchainIntent) { + let psbt = build_psbt( + &[InputSpec::new( + w.ours.script_at(false, 0, 0), + 100_000, + Some(w.ours.origin(false, 0, 0)), + )], + &[ + OutputSpec::new( + w.ours.script_at(false, 0, 2), + 40_000, + Some(w.ours.origin(false, 0, 2)), + ), + OutputSpec::new( + w.ours.script_at(false, 0, 1), + 59_000, + Some(w.ours.origin(false, 0, 1)), + ), + OutputSpec::new(opret_script(), 0, None), + ], + ); + let intent = OnchainIntent { + kind: IntentKind::SendBtc, + fee_rate_sat_per_vb: 2, + recipients: vec![IntentRecipient { + address: w.ours.address_at(false, 0, 2), + script_hex: w.ours.script_at(false, 0, 2).to_hex_string(), + amount_sat: 40_000, + }], + asset: None, + utxos: None, + }; + (psbt, intent) +} + +#[test] +fn check2_tampered_recipient_amount_fails_only_recipients_match() { + let w = wallets(); + let (psbt, mut intent) = self_send(&w); + assert_all_pass(&verify_psbt(&psbt, ¶ms(&w, intent.clone(), 2_000))); + intent.recipients[0].amount_sat = 39_000; + let verdict = verify_psbt(&psbt, ¶ms(&w, intent, 2_000)); + assert_only_fails(&verdict, &[CheckName::RecipientsMatch]); + assert!(detail(&verdict, CheckName::RecipientsMatch).contains("no output pays 39000 sat")); + + // The realistic case on the rgb-lib fixture PSBT: the intent's amount is + // tampered, so recipients-match fails, and the now-unaccounted foreign + // output is flagged by change-own too (defence in depth). + let unsigned = fixture_str( + &w.fixture["signing"]["unsignedPsbt"], + "signing.unsignedPsbt", + ); + let mut tampered = send_btc_intent(&w); + tampered.recipients[0].amount_sat = 39_000; + let verdict = verify_psbt(unsigned, ¶ms(&w, tampered, 2_000)); + assert_eq!( + verdict.failed(), + [CheckName::RecipientsMatch, CheckName::ChangeOwn] + ); + // PSBT-side amount tampering (recipient paid 90 000, change 9 000). + let psbt = build_psbt( + &[InputSpec::new( + w.ours.script_at(false, 0, 0), + 100_000, + Some(w.ours.origin(false, 0, 0)), + )], + &[ + OutputSpec::new(w.foreign.script_at(false, 0, 0), 90_000, None), + OutputSpec::new( + w.ours.script_at(false, 0, 1), + 9_000, + Some(w.ours.origin(false, 0, 1)), + ), + ], + ); + let verdict = verify_psbt(&psbt, ¶ms(&w, send_btc_intent(&w), 2_000)); + assert!(!check(&verdict, CheckName::RecipientsMatch).ok); + assert!(!verdict.ok); +} + +#[test] +fn check2_tampered_recipient_address_fails_only_recipients_match() { + let w = wallets(); + let (psbt, mut intent) = self_send(&w); + // Intent names a different (own) address, consistent with its script. + intent.recipients[0].address = w.ours.address_at(false, 0, 3); + intent.recipients[0].script_hex = w.ours.script_at(false, 0, 3).to_hex_string(); + let verdict = verify_psbt(&psbt, ¶ms(&w, intent, 2_000)); + assert_only_fails(&verdict, &[CheckName::RecipientsMatch]); + assert!(detail(&verdict, CheckName::RecipientsMatch).contains("no output pays")); + + // Swapped recipient script in the PSBT (payment redirected). + let swapped = build_psbt( + &[InputSpec::new( + w.ours.script_at(false, 0, 0), + 100_000, + Some(w.ours.origin(false, 0, 0)), + )], + &[ + OutputSpec::new(w.foreign.script_at(false, 0, 4), 40_000, None), + OutputSpec::new( + w.ours.script_at(false, 0, 1), + 59_000, + Some(w.ours.origin(false, 0, 1)), + ), + OutputSpec::new(opret_script(), 0, None), + ], + ); + let verdict = verify_psbt(&swapped, ¶ms(&w, send_btc_intent(&w), 2_000)); + assert!(!check(&verdict, CheckName::RecipientsMatch).ok); + assert!(!verdict.ok); + + // An undecodable intent address fails instead of being trusted. + let mut bad = send_btc_intent(&w); + bad.recipients[0].address = "not-an-address".into(); + let verdict = verify_psbt(&happy_psbt(&w, Happy::default()), ¶ms(&w, bad, 2_000)); + assert!(!check(&verdict, CheckName::RecipientsMatch).ok); + assert!(detail(&verdict, CheckName::RecipientsMatch).contains("does not decode")); + + // A mainnet address for the same key does not decode on regtest. + let mut wrong_net = send_btc_intent(&w); + let key = w.foreign.xonly_at(false, 0, 0); + wrong_net.recipients[0].address = + bitcoin::Address::p2tr(&Secp256k1::new(), key, None, bitcoin::Network::Bitcoin).to_string(); + let verdict = verify_psbt( + &happy_psbt(&w, Happy::default()), + ¶ms(&w, wrong_net, 2_000), + ); + assert!(detail(&verdict, CheckName::RecipientsMatch).contains("does not decode on Regtest")); +} + +#[test] +fn check2_intent_script_hex_contradicting_its_address_fails_recipients_match() { + let w = wallets(); + // A compromised gateway shows the user the REAL recipient address but + // pairs it with an attacker script and a PSBT paying that script. The + // expected script must be re-derived from the address, never trusted. + let attacker_script = w.foreign.script_at(false, 0, 4); + let mut hostile = send_btc_intent(&w); + hostile.recipients[0].script_hex = attacker_script.to_hex_string(); + let psbt = build_psbt( + &[InputSpec::new( + w.ours.script_at(false, 0, 0), + 100_000, + Some(w.ours.origin(false, 0, 0)), + )], + &[ + OutputSpec::new(attacker_script, 40_000, None), + OutputSpec::new( + w.ours.script_at(false, 0, 1), + 59_000, + Some(w.ours.origin(false, 0, 1)), + ), + OutputSpec::new(opret_script(), 0, None), + ], + ); + let verdict = verify_psbt(&psbt, ¶ms(&w, hostile.clone(), 2_000)); + assert!(!check(&verdict, CheckName::RecipientsMatch).ok); + assert!(detail(&verdict, CheckName::RecipientsMatch).contains("does not match")); + assert!(!verdict.ok); + + // Isolated: same contradiction on a self-send trips only check 2. + let (psbt, mut intent) = self_send(&w); + intent.recipients[0].script_hex = w.ours.script_at(false, 0, 1).to_hex_string(); + let verdict = verify_psbt(&psbt, ¶ms(&w, intent.clone(), 2_000)); + assert_only_fails(&verdict, &[CheckName::RecipientsMatch]); + // Case-insensitive script_hex comparison, as in the TypeScript SDK. + intent.recipients[0].script_hex = w.ours.script_at(false, 0, 2).to_hex_string().to_uppercase(); + assert_all_pass(&verify_psbt(&psbt, ¶ms(&w, intent, 2_000))); +} + +#[test] +fn check2_recipients_are_matched_as_a_multiset() { + let w = wallets(); + // Two identical recipients need two outputs; one output cannot satisfy both. + let recipient = IntentRecipient { + address: w.foreign.address_at(false, 0, 0), + script_hex: w.foreign.script_at(false, 0, 0).to_hex_string(), + amount_sat: 20_000, + }; + let intent = OnchainIntent { + recipients: vec![recipient.clone(), recipient], + ..send_btc_intent(&w) + }; + let two = build_psbt( + &[InputSpec::new( + w.ours.script_at(false, 0, 0), + 100_000, + Some(w.ours.origin(false, 0, 0)), + )], + &[ + OutputSpec::new(w.foreign.script_at(false, 0, 0), 20_000, None), + OutputSpec::new(w.foreign.script_at(false, 0, 0), 20_000, None), + OutputSpec::new( + w.ours.script_at(false, 0, 1), + 59_000, + Some(w.ours.origin(false, 0, 1)), + ), + ], + ); + assert_all_pass(&verify_psbt(&two, ¶ms(&w, intent.clone(), 2_000))); + let one = build_psbt( + &[InputSpec::new( + w.ours.script_at(false, 0, 0), + 100_000, + Some(w.ours.origin(false, 0, 0)), + )], + &[ + OutputSpec::new(w.foreign.script_at(false, 0, 0), 20_000, None), + OutputSpec::new( + w.ours.script_at(false, 0, 1), + 79_000, + Some(w.ours.origin(false, 0, 1)), + ), + ], + ); + let verdict = verify_psbt(&one, ¶ms(&w, intent, 2_000)); + assert_only_fails(&verdict, &[CheckName::RecipientsMatch]); +} + +fn witness_intent(amount: Option) -> OnchainIntent { + OnchainIntent { + kind: IntentKind::SendAsset, + fee_rate_sat_per_vb: 2, + recipients: vec![], + asset: Some(IntentAsset { + asset_id: "rgb:fixture".into(), + amount: 5, + recipient_id: "bcrt:wvout:fixture".into(), + witness_amount_sat: amount, + transport_endpoints: vec![], + }), + utxos: None, + } +} + +#[test] +fn check2_witness_send_requires_exactly_one_foreign_output_of_the_approved_amount() { + let w = wallets(); + let input = || { + InputSpec::new( + w.ours.script_at(true, 0, 0), + 30_000, + Some(w.ours.origin(true, 0, 0)), + ) + }; + let change = |amount: u64| { + OutputSpec::new( + w.ours.script_at(true, 0, 1), + amount, + Some(w.ours.origin(true, 0, 1)), + ) + }; + let psbt = build_psbt( + &[input()], + &[ + OutputSpec::new(w.foreign.script_at(false, 0, 3), 3_000, None), + change(26_000), + OutputSpec::new(opret_script(), 0, None), + ], + ); + assert_all_pass(&verify_psbt( + &psbt, + ¶ms(&w, witness_intent(Some(3_000)), 2_000), + )); + // The same PSBT under a BLIND intent: no foreign output allowed. + let blind = verify_psbt(&psbt, ¶ms(&w, witness_intent(None), 2_000)); + assert_only_fails(&blind, &[CheckName::ChangeOwn]); + + // Zero candidates: the only foreign output pays the WRONG amount. + let wrong = build_psbt( + &[input()], + &[ + OutputSpec::new(w.foreign.script_at(false, 0, 3), 2_999, None), + change(23_000), + ], + ); + let verdict = verify_psbt(&wrong, ¶ms(&w, witness_intent(Some(3_000)), 5_000)); + assert!(detail(&verdict, CheckName::RecipientsMatch).contains("found 0")); + assert_eq!( + verdict.failed(), + [CheckName::RecipientsMatch, CheckName::ChangeOwn] + ); + + // Two candidates at the approved amount: ambiguous, must fail. + let two = build_psbt( + &[input()], + &[ + OutputSpec::new(w.foreign.script_at(false, 0, 3), 3_000, None), + OutputSpec::new(w.foreign.script_at(false, 0, 4), 3_000, None), + change(22_000), + ], + ); + let verdict = verify_psbt(&two, ¶ms(&w, witness_intent(Some(3_000)), 5_000)); + assert!(detail(&verdict, CheckName::RecipientsMatch).contains("found 2")); + assert!(!verdict.ok); +} + +#[test] +fn check3_unaccounted_foreign_output_fails_only_change_own() { + let w = wallets(); + // Extra undeclared output skimming 5 000 sat (change reduced to keep the fee). + let verdict = verify_psbt( + &happy_psbt( + &w, + Happy { + change_amount: 54_000, + extra_outputs: vec![OutputSpec::new( + w.foreign.script_at(false, 0, 2), + 5_000, + None, + )], + ..Happy::default() + }, + ), + ¶ms(&w, send_btc_intent(&w), 2_000), + ); + assert_only_fails(&verdict, &[CheckName::ChangeOwn]); + assert!(detail(&verdict, CheckName::ChangeOwn).contains("output 3 (5000 sat)")); + + // Tampered change: foreign script under our metadata. + let verdict = verify_psbt( + &happy_psbt( + &w, + Happy { + change_script: Some(w.foreign.script_at(false, 0, 1)), + ..Happy::default() + }, + ), + ¶ms(&w, send_btc_intent(&w), 2_000), + ); + assert_only_fails(&verdict, &[CheckName::ChangeOwn]); + + // create_utxos: all-own outputs pass; one swapped output is caught. + let utxos_intent = OnchainIntent { + kind: IntentKind::CreateUtxos, + fee_rate_sat_per_vb: 2, + recipients: vec![], + asset: None, + utxos: Some(IntentUtxos { + up_to: false, + num: 2, + size: 1_000, + }), + }; + let input = || { + InputSpec::new( + w.ours.script_at(false, 0, 0), + 100_000, + Some(w.ours.origin(false, 0, 0)), + ) + }; + let own = |index: u32, amount: u64| { + OutputSpec::new( + w.ours.script_at(false, 0, index), + amount, + Some(w.ours.origin(false, 0, index)), + ) + }; + let good = build_psbt(&[input()], &[own(1, 1_000), own(2, 1_000), own(3, 97_000)]); + assert_all_pass(&verify_psbt( + &good, + ¶ms(&w, utxos_intent.clone(), 2_000), + )); + let tampered = build_psbt( + &[input()], + &[ + own(1, 1_000), + own(2, 1_000), + OutputSpec::new(w.foreign.script_at(false, 0, 3), 97_000, None), + ], + ); + assert_only_fails( + &verify_psbt(&tampered, ¶ms(&w, utxos_intent, 2_000)), + &[CheckName::ChangeOwn], + ); +} + +#[test] +fn check3_own_output_outside_keychain_0_or_index_bound_fails_only_change_own() { + let w = wallets(); + // The script genuinely re-derives from our xpub at keychain 1, but no + // descriptor wallet scans keychain 1 — accepting it would let a hostile + // gateway strand the change where the user can never find it. + let verdict = verify_psbt( + &happy_psbt( + &w, + Happy { + change_script: Some(w.ours.script_at(false, 1, 0)), + change_origin: Some(Some(w.ours.origin(false, 1, 0))), + ..Happy::default() + }, + ), + ¶ms(&w, send_btc_intent(&w), 2_000), + ); + assert_only_fails(&verdict, &[CheckName::ChangeOwn]); + + // Beyond the index bound: index 9e8 is ours yet unrecoverable. + let verdict = verify_psbt( + &happy_psbt( + &w, + Happy { + change_script: Some(w.ours.script_at(false, 0, 900_000_000)), + change_origin: Some(Some(w.ours.origin(false, 0, 900_000_000))), + ..Happy::default() + }, + ), + ¶ms(&w, send_btc_intent(&w), 2_000), + ); + assert_only_fails(&verdict, &[CheckName::ChangeOwn]); + + // The bound is inclusive at the default 10 000 and honours an override. + let at = |index: u32, max: Option| { + let mut p = params(&w, send_btc_intent(&w), 2_000); + p.max_own_output_index = max; + verify_psbt( + &happy_psbt( + &w, + Happy { + change_script: Some(w.ours.script_at(false, 0, index)), + change_origin: Some(Some(w.ours.origin(false, 0, index))), + ..Happy::default() + }, + ), + &p, + ) + }; + assert_all_pass(&at(10_000, None)); + assert_only_fails(&at(10_001, None), &[CheckName::ChangeOwn]); + assert_all_pass(&at(10_001, Some(10_001))); + assert_only_fails(&at(50, Some(49)), &[CheckName::ChangeOwn]); + // Metadata-proven change beyond the scan window at a sane index passes. + assert_all_pass(&at(40, None)); +} + +#[test] +fn check3_metadata_less_change_uses_the_scan_window_on_demand() { + let w = wallets(); + let psbt = happy_psbt( + &w, + Happy { + change_origin: Some(None), + ..Happy::default() + }, + ); + assert_all_pass(&verify_psbt(&psbt, ¶ms(&w, send_btc_intent(&w), 2_000))); + let mut no_scan = params(&w, send_btc_intent(&w), 2_000); + no_scan.change_scan_window = Some(0); + assert_only_fails(&verify_psbt(&psbt, &no_scan), &[CheckName::ChangeOwn]); + // Index 1 is inside a window of 2 but outside a window of 1. + let mut narrow = params(&w, send_btc_intent(&w), 2_000); + narrow.change_scan_window = Some(2); + assert_all_pass(&verify_psbt(&psbt, &narrow)); + narrow.change_scan_window = Some(1); + assert_only_fails(&verify_psbt(&psbt, &narrow), &[CheckName::ChangeOwn]); + // The scan window covers the colored account too, keychain 0 only. + let colored_change = build_psbt( + &[InputSpec::new( + w.ours.script_at(false, 0, 0), + 100_000, + Some(w.ours.origin(false, 0, 0)), + )], + &[ + OutputSpec::new(w.foreign.script_at(false, 0, 0), 40_000, None), + OutputSpec::new(w.ours.script_at(true, 0, 29), 59_000, None), + ], + ); + assert_all_pass(&verify_psbt( + &colored_change, + ¶ms(&w, send_btc_intent(&w), 2_000), + )); + let keychain_1 = build_psbt( + &[InputSpec::new( + w.ours.script_at(false, 0, 0), + 100_000, + Some(w.ours.origin(false, 0, 0)), + )], + &[ + OutputSpec::new(w.foreign.script_at(false, 0, 0), 40_000, None), + OutputSpec::new(w.ours.script_at(false, 1, 0), 59_000, None), + ], + ); + assert_only_fails( + &verify_psbt(&keychain_1, ¶ms(&w, send_btc_intent(&w), 2_000)), + &[CheckName::ChangeOwn], + ); +} + +#[test] +fn check4_fee_outside_budget_fails_only_fee_budget() { + let w = wallets(); + // Inflated: change 20 000 → fee 40 000. + let verdict = verify_psbt( + &happy_psbt( + &w, + Happy { + change_amount: 20_000, + ..Happy::default() + }, + ), + ¶ms(&w, send_btc_intent(&w), 2_000), + ); + assert_only_fails(&verdict, &[CheckName::FeeBudget]); + assert_eq!(verdict.fee_sat, Some(40_000)); + assert!( + detail(&verdict, CheckName::FeeBudget).contains("fee 40000 sat outside budget (0, 2000]") + ); + + // Zero fee: outputs == inputs. + let verdict = verify_psbt( + &happy_psbt( + &w, + Happy { + change_amount: 60_000, + ..Happy::default() + }, + ), + ¶ms(&w, send_btc_intent(&w), 2_000), + ); + assert_only_fails(&verdict, &[CheckName::FeeBudget]); + assert_eq!(verdict.fee_sat, Some(0)); + + // Negative fee: outputs exceed inputs. + let verdict = verify_psbt( + &happy_psbt( + &w, + Happy { + change_amount: 70_000, + ..Happy::default() + }, + ), + ¶ms(&w, send_btc_intent(&w), 2_000), + ); + assert_only_fails(&verdict, &[CheckName::FeeBudget]); + assert_eq!(verdict.fee_sat, Some(-10_000)); + + // Budget is inclusive; the fixture PSBT's 1 000 sat fee at budget 1 000 + // passes and at 999 fails. + let unsigned = fixture_str( + &w.fixture["signing"]["unsignedPsbt"], + "signing.unsignedPsbt", + ); + assert_all_pass(&verify_psbt( + unsigned, + ¶ms(&w, send_btc_intent(&w), 1_000), + )); + assert_only_fails( + &verify_psbt(unsigned, ¶ms(&w, send_btc_intent(&w), 999)), + &[CheckName::FeeBudget], + ); + assert_only_fails( + &verify_psbt(unsigned, ¶ms(&w, send_btc_intent(&w), 0)), + &[CheckName::FeeBudget], + ); +} + +#[test] +fn check5_funded_op_return_fails_only_opret_zero() { + let w = wallets(); + let verdict = verify_psbt( + &happy_psbt( + &w, + Happy { + opret_amount: 500, + change_amount: 58_500, + ..Happy::default() + }, + ), + ¶ms(&w, send_btc_intent(&w), 2_000), + ); + assert_only_fails(&verdict, &[CheckName::OpretZero]); + assert!(detail(&verdict, CheckName::OpretZero).contains("OP_RETURN output 2 carries 500 sat")); +} + +#[test] +fn decoy_key_origin_entry_does_not_divert_selection() { + let w = wallets(); + // A foreign-fingerprint entry on a well-formed own-looking path sits in + // the same map as ours. Selecting by fingerprint ignores it; selecting by + // shape would pick whichever sorts first. Both key orders are exercised + // by using decoys at two different indexes. + for decoy_index in [1u32, 2, 3, 4] { + let mut decoy = w.foreign.origin(false, 0, decoy_index); + decoy.path = w.ours.origin(false, 0, decoy_index).path; + let psbt = happy_psbt( + &w, + Happy { + input_origins: Some(vec![decoy.clone(), w.ours.origin(false, 0, 0)]), + input_internal_key: Some(w.ours.xonly_at(false, 0, 0)), + ..Happy::default() + }, + ); + let verdict = verify_psbt(&psbt, ¶ms(&w, send_btc_intent(&w), 2_000)); + assert_all_pass(&verdict); + + let parsed: Psbt = psbt.parse().unwrap(); + let origins = &parsed.inputs[0].tap_key_origins; + assert_eq!(origins.len(), 2); + let (key, source) = own_derivation(origins, w.ours.fingerprint()).unwrap(); + assert_eq!(key, w.ours.xonly_at(false, 0, 0), "decoy at {decoy_index}"); + assert_eq!(source.1.to_u32_vec(), w.ours.origin(false, 0, 0).path); + // What verify proved is what sign will derive: the private key for + // that path is the key behind the selected entry. + let sk = w + .ours + .keys + .private_key_for_path(&source.1.to_u32_vec()) + .unwrap(); + assert_eq!(sk.x_only_public_key(&Secp256k1::new()).0, key); + } + + // A decoy on the OUTPUT side must not make foreign change look own either: + // the entry carries our fingerprint but the script is theirs. + let mut lying = w.ours.origin(false, 0, 1); + lying.xonly = w.foreign.xonly_at(false, 0, 1); + let verdict = verify_psbt( + &happy_psbt( + &w, + Happy { + change_script: Some(w.foreign.script_at(false, 0, 1)), + change_origin: Some(Some(lying)), + ..Happy::default() + }, + ), + ¶ms(&w, send_btc_intent(&w), 2_000), + ); + assert_only_fails(&verdict, &[CheckName::ChangeOwn]); +} + +#[test] +fn garbage_and_truncated_psbts_fail_all_five_without_panicking() { + let w = wallets(); + let p = params(&w, send_btc_intent(&w), 2_000); + let unsigned = fixture_str( + &w.fixture["signing"]["unsignedPsbt"], + "signing.unsignedPsbt", + ); + let mut cases: Vec<(String, String)> = vec![ + ("not base64".into(), "!!!!not-base64!!!!".into()), + ( + "random bytes".into(), + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==".into(), + ), + ("empty string".into(), String::new()), + ("truncated psbt magic".into(), "cHNidP8=".into()), + ("whitespace".into(), " ".into()), + ("magic only".into(), "cHNidP8AAA==".into()), + ]; + // Truncations of the real fixture PSBT at every 4-char base64 boundary. + for len in (4..unsigned.len()).step_by(4) { + cases.push((format!("truncated at {len}"), unsigned[..len].to_owned())); + } + // Bit flips through the fixture PSBT's bytes. + let bytes: Psbt = unsigned.parse().unwrap(); + let raw = bytes.serialize(); + for i in (0..raw.len()).step_by(3) { + let mut flipped = raw.clone(); + flipped[i] ^= 0x55; + cases.push((format!("bit flip at {i}"), { + use bitcoin::base64::Engine; + bitcoin::base64::prelude::BASE64_STANDARD.encode(&flipped) + })); + } + let mnemonic = fixture_mnemonic(&w.fixture); + let mut unparseable = 0usize; + for (label, psbt) in cases { + let verdict = verify_psbt(&psbt, &p); + assert_eq!(verdict.checks.len(), 5, "{label}"); + assert_no_secret(&format!("{verdict:?}"), &mnemonic, &label); + if verdict.txid.is_none() { + // Unparseable: every check fails with the same parse reason. + unparseable += 1; + assert!(!verdict.ok, "{label}"); + assert_eq!(verdict.failed(), CheckName::ALL, "{label}: {verdict:?}"); + assert_eq!(verdict.fee_sat, None, "{label}"); + for c in &verdict.checks { + assert!( + c.detail + .as_deref() + .is_some_and(|d| d.starts_with("psbt could not be safely parsed")), + "{label}: {c:?}" + ); + } + } else if !label.starts_with("bit flip") { + panic!("{label} parsed as a PSBT: {verdict:?}"); + } + // A bit flip that still parses is judged on its merits: a flip in a + // field no check covers (e.g. the tx version) legitimately passes; + // a flip in an amount, script or key-origin field fails the check + // that owns it. What matters here is: no panic, no `Err`, five + // checks, no secret rendered. + } + assert!( + unparseable > 10, + "expected most corruptions to be unparseable" + ); + + // Bad xpub material fails all five too, rather than erroring. + let mut bad_xpubs = params(&w, send_btc_intent(&w), 2_000); + bad_xpubs.xpubs.fingerprint = "zz".into(); + let verdict = verify_psbt(unsigned, &bad_xpubs); + assert_eq!(verdict.failed(), CheckName::ALL); + assert!(detail(&verdict, CheckName::InputsOwn).contains("fingerprint")); + let mut bad_xpubs = params(&w, send_btc_intent(&w), 2_000); + bad_xpubs.xpubs.vanilla = "tpubgarbage".into(); + let verdict = verify_psbt(unsigned, &bad_xpubs); + assert_eq!(verdict.failed(), CheckName::ALL); + // Wrong network for the xpubs: the fixture's regtest tpubs are not + // mainnet material, and an intent address of the wrong network fails. + let mut wrong_net = params(&w, send_btc_intent(&w), 2_000); + wrong_net.xpubs.network = BitcoinNetwork::Mainnet; + assert_eq!(verify_psbt(unsigned, &wrong_net).failed(), CheckName::ALL); + + // The foreign wallet's own xpubs see the fixture PSBT as entirely foreign. + let theirs = VerifyParams { + intent: send_btc_intent(&w), + xpubs: w.foreign.keys.xpubs(), + max_fee_sat: 2_000, + change_scan_window: None, + max_own_output_index: None, + }; + let verdict = verify_psbt(unsigned, &theirs); + assert_eq!( + verdict.failed(), + [CheckName::InputsOwn, CheckName::ChangeOwn] + ); +} + +// --------------------------------------------------------------------------- +// Task 5: sign and finalize — txid parity with rgb-lib's own signer, +// signature validity, and refusal on anything verification rejects. +// --------------------------------------------------------------------------- + +use bitcoin::hashes::Hash; +use bitcoin::key::TapTweak; +use bitcoin::secp256k1::Message; +use bitcoin::sighash::{Prevouts, SighashCache}; +use bitcoin::TapSighashType; +use utexo_minimal_sdk::{verify_and_sign_psbt, SignResult}; + +fn fixture_unsigned(w: &Wallets) -> &str { + fixture_str( + &w.fixture["signing"]["unsignedPsbt"], + "signing.unsignedPsbt", + ) +} + +/// Every check a signed+finalized PSBT must satisfy, independent of which +/// wallet signed it: one 64-byte (SIGHASH_DEFAULT) key-path witness element +/// per input that verifies as BIP-340 over the BIP-341 key-spend sighash +/// against the output key in the spent script, signing metadata dropped, +/// and the transaction extracts to `expected_txid`. +fn assert_signed_and_finalized(signed_psbt: &str, expected_txid: &str) -> Psbt { + let psbt: Psbt = signed_psbt.parse().expect("signed psbt parses"); + let secp = Secp256k1::new(); + let prevouts: Vec = psbt + .inputs + .iter() + .map(|i| i.witness_utxo.clone().expect("witness_utxo retained")) + .collect(); + let mut cache = SighashCache::new(&psbt.unsigned_tx); + for (index, input) in psbt.inputs.iter().enumerate() { + let witness = input + .final_script_witness + .as_ref() + .unwrap_or_else(|| panic!("input {index} not finalized")); + assert_eq!(witness.len(), 1, "input {index}: key-path witness"); + let sig_bytes = witness.nth(0).unwrap(); + assert_eq!( + sig_bytes.len(), + 64, + "input {index}: 64-byte SIGHASH_DEFAULT sig" + ); + let signature = bitcoin::secp256k1::schnorr::Signature::from_slice(sig_bytes).unwrap(); + let sighash = cache + .taproot_key_spend_signature_hash( + index, + &Prevouts::All(&prevouts), + TapSighashType::Default, + ) + .unwrap(); + let script = prevouts[index].script_pubkey.as_bytes(); + assert_eq!(&script[..2], &[0x51, 0x20]); + let output_key = XOnlyPublicKey::from_slice(&script[2..]).unwrap(); + secp.verify_schnorr( + &signature, + &Message::from_digest(sighash.to_byte_array()), + &output_key, + ) + .unwrap_or_else(|e| panic!("input {index}: signature invalid: {e}")); + // Finalizer role: signing metadata dropped, UTXO retained. + assert!(input.tap_key_sig.is_none(), "input {index}"); + assert!(input.tap_key_origins.is_empty(), "input {index}"); + assert!(input.tap_internal_key.is_none(), "input {index}"); + assert!(input.sighash_type.is_none(), "input {index}"); + assert!(input.partial_sigs.is_empty() && input.bip32_derivation.is_empty()); + } + let tx = psbt.clone().extract_tx().expect("finalized psbt extracts"); + assert_eq!(tx.compute_txid().to_string(), expected_txid); + psbt +} + +#[test] +fn sign_fixture_psbt_matches_rgb_lib_txid_with_a_valid_key_path_signature() { + let w = wallets(); + let expected_txid = fixture_str(&w.fixture["signing"]["txid"], "signing.txid"); + let result = verify_and_sign_psbt( + &w.ours.keys, + fixture_unsigned(&w), + ¶ms(&w, send_btc_intent(&w), 2_000), + ) + .expect("fixture PSBT signs"); + + // (a) the verdict is ok and is the one signing was conditioned on. + assert_all_pass(&result.verdict); + assert_eq!(result.verdict.fee_sat, Some(1_000)); + // (b) txid parity with rgb-lib. + assert_eq!(result.txid, expected_txid); + assert_eq!(result.verdict.txid.as_deref(), Some(expected_txid)); + // (c)+(d) exactly one 64-byte witness element that verifies as BIP-340 + // over the BIP-341 sighash against the spent output key ... + let signed = assert_signed_and_finalized(&result.signed_psbt, expected_txid); + // ... which is the re-derived x-only key at the fixture input's path + // (vanilla 0/0), tweaked with no script tree. + let unsigned: Psbt = fixture_unsigned(&w).parse().unwrap(); + let (entry_key, source) = + own_derivation(&unsigned.inputs[0].tap_key_origins, w.ours.fingerprint()).unwrap(); + let internal = w.ours.xonly_at(false, 0, 0); + assert_eq!(entry_key, internal); + assert_eq!(source.1.to_u32_vec(), w.ours.origin(false, 0, 0).path); + let (output_key, _) = internal.tap_tweak(&Secp256k1::new(), None); + let script = &signed.inputs[0] + .witness_utxo + .as_ref() + .unwrap() + .script_pubkey; + assert_eq!(&script.as_bytes()[2..], &output_key.serialize()); + + // (e) rgb-lib's own signed fixture parses to the same txid and has the + // same witness shape; its bytes are the fixture's recorded signature. + // Ours are NOT compared byte-for-byte: BIP-340 signatures are randomized. + let rgb_lib_signed = fixture_str(&w.fixture["signing"]["signedPsbt"], "signing.signedPsbt"); + let theirs = assert_signed_and_finalized(rgb_lib_signed, expected_txid); + let their_sig = theirs.inputs[0] + .final_script_witness + .as_ref() + .unwrap() + .nth(0) + .unwrap(); + assert_eq!( + their_sig.to_vec(), + hex_decode(fixture_str( + &w.fixture["signing"]["witnessSignature"], + "signing.witnessSignature" + )) + ); + let our_sig = signed.inputs[0] + .final_script_witness + .as_ref() + .unwrap() + .nth(0) + .unwrap(); + assert_ne!( + our_sig, their_sig, + "randomized nonces: byte-equality is not the parity claim" + ); + + // Signing twice gives two different valid signatures for one txid. + let again = verify_and_sign_psbt( + &w.ours.keys, + fixture_unsigned(&w), + ¶ms(&w, send_btc_intent(&w), 2_000), + ) + .unwrap(); + assert_eq!(again.txid, result.txid); + assert_ne!(again.signed_psbt, result.signed_psbt); + assert_signed_and_finalized(&again.signed_psbt, expected_txid); +} + +#[test] +fn sign_handles_multiple_inputs_across_both_accounts() { + let w = wallets(); + // Own inputs on vanilla 0/3, colored 0/1 and vanilla 1/5 (an odd + // keychain is fine for inputs), recipient 40 000, own change, OP_RETURN. + let psbt = build_psbt( + &[ + InputSpec::new( + w.ours.script_at(false, 0, 3), + 50_000, + Some(w.ours.origin(false, 0, 3)), + ), + InputSpec::new( + w.ours.script_at(true, 0, 1), + 30_000, + Some(w.ours.origin(true, 0, 1)), + ), + InputSpec::new( + w.ours.script_at(false, 1, 5), + 20_000, + Some(w.ours.origin(false, 1, 5)), + ), + ], + &[ + OutputSpec::new(w.foreign.script_at(false, 0, 0), 40_000, None), + OutputSpec::new( + w.ours.script_at(true, 0, 2), + 59_000, + Some(w.ours.origin(true, 0, 2)), + ), + OutputSpec::new(opret_script(), 0, None), + ], + ); + let result = verify_and_sign_psbt(&w.ours.keys, &psbt, ¶ms(&w, send_btc_intent(&w), 2_000)) + .expect("three own inputs sign"); + assert_all_pass(&result.verdict); + let unsigned: Psbt = psbt.parse().unwrap(); + assert_eq!(result.txid, unsigned.unsigned_tx.compute_txid().to_string()); + let signed = assert_signed_and_finalized(&result.signed_psbt, &result.txid); + assert_eq!(signed.inputs.len(), 3); + // Outputs are the finalizer's business only on inputs: their metadata + // survives for the gateway to read. + assert_eq!(signed.outputs[1].tap_key_origins.len(), 1); +} + +#[test] +fn sign_refuses_tampered_recipient_amount_and_produces_no_signature() { + let w = wallets(); + let mut tampered = send_btc_intent(&w); + tampered.recipients[0].amount_sat = 39_000; + let err = verify_and_sign_psbt( + &w.ours.keys, + fixture_unsigned(&w), + ¶ms(&w, tampered, 2_000), + ) + .expect_err("tampered amount must be refused"); + let SdkError::VerificationFailed { check, reason } = &err else { + panic!("expected VerificationFailed, got {err:?}"); + }; + // The failed check names are carried: the first in `check`, all in + // `reason` (recipients-match, plus change-own on the now-unaccounted + // foreign output). + assert_eq!(check, "recipients-match"); + assert!( + reason.starts_with("refusing to sign: recipients-match ("), + "{reason}" + ); + assert!(reason.contains("no output pays 39000 sat"), "{reason}"); + assert!(reason.contains("; change-own ("), "{reason}"); + assert!(!reason.contains("fee-budget"), "{reason}"); + // Nothing was signed: the refusal is an error, not a result, and the + // only signing entry point is the one that just refused (see + // `sign_has_exactly_one_entry_point_and_it_verifies_first`). + assert!(matches!(err, SdkError::VerificationFailed { .. })); + + // Over budget: fee-budget is the (only) failed check. + let err = verify_and_sign_psbt( + &w.ours.keys, + fixture_unsigned(&w), + ¶ms(&w, send_btc_intent(&w), 500), + ) + .unwrap_err(); + assert_eq!( + err, + SdkError::VerificationFailed { + check: "fee-budget".into(), + reason: "refusing to sign: fee-budget (fee 1000 sat outside budget (0, 500])".into(), + } + ); +} + +#[test] +fn sign_refuses_inputs_whose_key_origin_path_is_not_ours() { + let w = wallets(); + let p = params(&w, send_btc_intent(&w), 2_000); + + // (a) A foreign input (their script, their fingerprint) — the intent + // matches, but check 1 fails before any signing. + let mut spec = InputSpec::new( + w.foreign.script_at(false, 0, 0), + 100_000, + Some(w.foreign.origin(false, 0, 0)), + ); + spec.tap_internal_key = Some(w.foreign.xonly_at(false, 0, 0)); + let psbt = build_psbt( + &[spec], + &[ + OutputSpec::new(w.foreign.script_at(false, 0, 0), 40_000, None), + OutputSpec::new( + w.ours.script_at(false, 0, 1), + 59_000, + Some(w.ours.origin(false, 0, 1)), + ), + OutputSpec::new(opret_script(), 0, None), + ], + ); + let err = verify_and_sign_psbt(&w.ours.keys, &psbt, &p).unwrap_err(); + assert!( + matches!(&err, SdkError::VerificationFailed { check, .. } if check == "inputs-own"), + "{err:?}" + ); + + // (b) Our fingerprint on a path outside our accounts (account 1'). + let mut odd = w.ours.origin(false, 0, 0); + odd.path[2] = HARDENED + 1; + let psbt = happy_psbt( + &w, + Happy { + input_origins: Some(vec![odd]), + ..Happy::default() + }, + ); + let err = verify_and_sign_psbt(&w.ours.keys, &psbt, &p).unwrap_err(); + assert!( + matches!(&err, SdkError::VerificationFailed { check, reason } + if check == "inputs-own" && reason.contains("not under our accounts")), + "{err:?}" + ); + + // (c) An input carrying no key-origin entry at all. + let psbt = happy_psbt( + &w, + Happy { + input_origins: Some(vec![]), + ..Happy::default() + }, + ); + let err = verify_and_sign_psbt(&w.ours.keys, &psbt, &p).unwrap_err(); + assert!( + matches!(&err, SdkError::VerificationFailed { check, .. } if check == "inputs-own"), + "{err:?}" + ); + + // (d) The verdict belongs to another wallet: the foreign wallet's own + // PSBT verifies fine against the foreign xpubs, but our keys must not + // sign on the strength of someone else's verdict. + let foreign_psbt = build_psbt( + &[InputSpec::new( + w.foreign.script_at(false, 0, 0), + 100_000, + Some(w.foreign.origin(false, 0, 0)), + )], + &[ + OutputSpec::new(w.ours.script_at(false, 0, 0), 40_000, None), + OutputSpec::new( + w.foreign.script_at(false, 0, 1), + 59_000, + Some(w.foreign.origin(false, 0, 1)), + ), + OutputSpec::new(opret_script(), 0, None), + ], + ); + let foreign_intent = OnchainIntent { + kind: IntentKind::SendBtc, + fee_rate_sat_per_vb: 2, + recipients: vec![IntentRecipient { + address: w.ours.address_at(false, 0, 0), + script_hex: w.ours.script_at(false, 0, 0).to_hex_string(), + amount_sat: 40_000, + }], + asset: None, + utxos: None, + }; + let foreign_params = VerifyParams { + intent: foreign_intent, + xpubs: w.foreign.keys.xpubs(), + max_fee_sat: 2_000, + change_scan_window: None, + max_own_output_index: None, + }; + assert_all_pass(&verify_psbt(&foreign_psbt, &foreign_params)); + let err = verify_and_sign_psbt(&w.ours.keys, &foreign_psbt, &foreign_params).unwrap_err(); + assert_eq!( + err, + SdkError::InvalidInput { + reason: "params.xpubs are not the xpubs of the signing keys".into() + } + ); + // The foreign wallet itself signs it. + let result = verify_and_sign_psbt(&w.foreign.keys, &foreign_psbt, &foreign_params).unwrap(); + assert_signed_and_finalized(&result.signed_psbt, &result.txid); + // Foreign keys with OUR params: refused up front as a caller bug, before + // any verdict is even computed. + let err = verify_and_sign_psbt(&w.foreign.keys, fixture_unsigned(&w), &p).unwrap_err(); + assert!(matches!(&err, SdkError::InvalidInput { .. }), "{err:?}"); + // Foreign keys with THEIR params see our fixture PSBT as foreign input. + let mut theirs = p.clone(); + theirs.xpubs = w.foreign.keys.xpubs(); + let err = verify_and_sign_psbt(&w.foreign.keys, fixture_unsigned(&w), &theirs).unwrap_err(); + assert!( + matches!(&err, SdkError::VerificationFailed { check, .. } if check == "inputs-own"), + "{err:?}" + ); +} + +#[test] +fn sign_through_a_decoy_key_origin_entry_ordered_before_ours() { + let w = wallets(); + // verify selects the entry carrying OUR master fingerprint; sign must + // use that same entry. Selecting by path shape would pick the decoy (a + // foreign fingerprint on a well-formed own path) whenever it sorts + // first, and signing would fail against a key no check covered. + for decoy_index in [1u32, 2, 3, 4] { + let mut decoy = w.foreign.origin(false, 0, decoy_index); + decoy.path = w.ours.origin(false, 0, decoy_index).path; + let psbt = happy_psbt( + &w, + Happy { + input_origins: Some(vec![decoy, w.ours.origin(false, 0, 0)]), + input_internal_key: Some(w.ours.xonly_at(false, 0, 0)), + ..Happy::default() + }, + ); + let result = + verify_and_sign_psbt(&w.ours.keys, &psbt, ¶ms(&w, send_btc_intent(&w), 2_000)) + .unwrap_or_else(|e| panic!("decoy at {decoy_index}: {e}")); + assert_all_pass(&result.verdict); + assert_signed_and_finalized(&result.signed_psbt, &result.txid); + } +} + +#[test] +fn sign_refuses_sighash_types_that_do_not_commit_to_the_verdict() { + let w = wallets(); + let p = params(&w, send_btc_intent(&w), 2_000); + let base: Psbt = fixture_unsigned(&w).parse().unwrap(); + // DEFAULT and ALL commit to every output the verdict approved; both sign. + for ty in [TapSighashType::Default, TapSighashType::All] { + let mut psbt = base.clone(); + psbt.inputs[0].sighash_type = Some(ty.into()); + let result = verify_and_sign_psbt(&w.ours.keys, &psbt.to_string(), &p) + .unwrap_or_else(|e| panic!("{ty}: {e}")); + let signed: Psbt = result.signed_psbt.parse().unwrap(); + let sig = signed.inputs[0] + .final_script_witness + .as_ref() + .unwrap() + .nth(0) + .unwrap(); + // ALL is encoded with a trailing sighash byte (65 bytes), DEFAULT as + // 64 bytes — both are valid key-path witnesses. + assert_eq!( + sig.len(), + if ty == TapSighashType::Default { + 64 + } else { + 65 + }, + "{ty}" + ); + assert_eq!(result.txid, base.unsigned_tx.compute_txid().to_string()); + } + // NONE / SINGLE / ANYONECANPAY variants would let outputs change after + // approval: refused after verification, with the input index named. + for ty in [ + TapSighashType::None, + TapSighashType::Single, + TapSighashType::AllPlusAnyoneCanPay, + TapSighashType::NonePlusAnyoneCanPay, + TapSighashType::SinglePlusAnyoneCanPay, + ] { + let mut psbt = base.clone(); + psbt.inputs[0].sighash_type = Some(ty.into()); + let err = verify_and_sign_psbt(&w.ours.keys, &psbt.to_string(), &p).unwrap_err(); + assert!( + matches!(&err, SdkError::SigningFailed { reason } + if reason.starts_with("input 0: unsupported sighash type")), + "{ty}: {err:?}" + ); + } + // A raw byte that is no taproot sighash type at all takes the same + // refusal path (the catch-all arm), not a panic. + for raw in [4u32, 0x7f, 0x100] { + let mut psbt = base.clone(); + psbt.inputs[0].sighash_type = Some(bitcoin::psbt::PsbtSighashType::from_u32(raw)); + let err = verify_and_sign_psbt(&w.ours.keys, &psbt.to_string(), &p).unwrap_err(); + assert!( + matches!(&err, SdkError::SigningFailed { reason } + if reason.starts_with("input 0: unsupported sighash type")), + "raw {raw:#x}: {err:?}" + ); + } +} + +#[test] +fn sign_errors_never_render_key_material_and_never_panic() { + let w = wallets(); + let mnemonic = fixture_mnemonic(&w.fixture); + let p = params(&w, send_btc_intent(&w), 2_000); + let mut tampered = send_btc_intent(&w); + tampered.recipients[0].amount_sat = 39_000; + let unsigned = fixture_unsigned(&w); + let mut errors: Vec = vec![ + verify_and_sign_psbt(&w.ours.keys, unsigned, ¶ms(&w, tampered, 2_000)).unwrap_err(), + verify_and_sign_psbt(&w.ours.keys, unsigned, ¶ms(&w, send_btc_intent(&w), 1)) + .unwrap_err(), + verify_and_sign_psbt(&w.ours.keys, "!!!not a psbt!!!", &p).unwrap_err(), + verify_and_sign_psbt(&w.ours.keys, "", &p).unwrap_err(), + verify_and_sign_psbt(&w.ours.keys, "cHNidP8=", &p).unwrap_err(), + ]; + for len in (4..unsigned.len()).step_by(20) { + errors.push(verify_and_sign_psbt(&w.ours.keys, &unsigned[..len], &p).unwrap_err()); + } + for err in &errors { + // Hostile PSBTs are refused as a failed verdict, never a parse error + // or panic, so verification stays the single gate. + assert!( + matches!(err, SdkError::VerificationFailed { .. }), + "{err:?}" + ); + } + // Mismatched keys/xpubs and unsupported sighash types are the other two + // error shapes; their messages are fixed diagnostics plus an index. + errors.push(verify_and_sign_psbt(&w.foreign.keys, unsigned, &p).unwrap_err()); + let mut bad_xpubs = p.clone(); + bad_xpubs.xpubs.fingerprint = "zz".into(); + errors.push(verify_and_sign_psbt(&w.ours.keys, unsigned, &bad_xpubs).unwrap_err()); + let mut none: Psbt = unsigned.parse().unwrap(); + none.inputs[0].sighash_type = Some(TapSighashType::None.into()); + errors.push(verify_and_sign_psbt(&w.ours.keys, &none.to_string(), &p).unwrap_err()); + let other_mnemonic = fixture_str(&w.fixture["otherMnemonic"], "otherMnemonic"); + for err in &errors { + for m in [mnemonic.as_str(), other_mnemonic] { + assert_no_secret(&err.to_string(), m, "error display"); + assert_no_secret(&format!("{err:?}"), m, "error debug"); + } + } + // A successful result renders no secret either. + let ok: SignResult = verify_and_sign_psbt(&w.ours.keys, unsigned, &p).unwrap(); + assert_no_secret(&format!("{ok:?}"), &mnemonic, "sign result"); +} + +#[test] +fn sign_has_exactly_one_entry_point_and_it_verifies_first() { + // Structural guard for "no exported entry point signs without + // verifying": the schnorr signing call exists in exactly one place in + // the library, `src/sign.rs`, whose only `pub fn` is + // `verify_and_sign_psbt`, and the first thing that function does is run + // `verify_psbt`. Add a second signing path and this test names it. + let src = manifest_dir().join("src"); + let mut signing_sites: Vec<(String, usize)> = Vec::new(); + let mut pub_fns_in_sign: Vec = Vec::new(); + for entry in fs::read_dir(&src).unwrap().filter_map(Result::ok) { + let path = entry.path(); + if path.extension().is_none_or(|e| e != "rs") { + continue; + } + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + let text = fs::read_to_string(&path).unwrap(); + for (n, line) in text.lines().enumerate() { + let code = line.split("//").next().unwrap_or(""); + if code.contains("sign_schnorr") { + signing_sites.push((name.clone(), n + 1)); + } + if name == "sign.rs" && code.trim_start().starts_with("pub fn ") { + pub_fns_in_sign.push(code.trim().to_owned()); + } + } + } + assert_eq!( + signing_sites.len(), + 1, + "exactly one schnorr signing site: {signing_sites:?}" + ); + assert_eq!(signing_sites[0].0, "sign.rs"); + assert_eq!(pub_fns_in_sign, ["pub fn verify_and_sign_psbt("]); + let sign_rs = fs::read_to_string(src.join("sign.rs")).unwrap(); + let body_start = sign_rs.find("pub fn verify_and_sign_psbt(").unwrap(); + let first_verify = sign_rs[body_start..] + .find("verify_psbt(psbt_base64, params)") + .unwrap(); + let first_sign = sign_rs[body_start..].find("sign_schnorr(").unwrap(); + assert!(first_verify < first_sign, "verification precedes signing"); + // No opt-out anywhere in the module's code (comments may name what they + // rule out). + let code_only = |text: &str| -> String { + text.lines() + .filter(|l| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n") + }; + let sign_code = code_only(&sign_rs); + for forbidden in ["skip_verify", "unchecked", "force", "unsafe "] { + assert!( + !sign_code.contains(forbidden), + "sign.rs contains {forbidden:?}" + ); + } + // The uniffi surface exposes the same single function under the same name. + let ffi_rs = fs::read_to_string(src.join("ffi.rs")).unwrap(); + let sign_exports: Vec<&str> = ffi_rs + .lines() + .filter(|l| l.contains("#[uniffi::export") && l.contains("sign")) + .collect(); + assert_eq!( + sign_exports, + ["#[uniffi::export(name = \"verify_and_sign_psbt\")]"], + "exactly one signing export, and it verifies" + ); + // The only things ffi.rs reaches in the sign module are its result type + // and the verifying entry point: no other signer is exported. + let ffi_code = code_only(&ffi_rs); + for (at, _) in ffi_code.match_indices("sign::") { + let rest = &ffi_code[at + "sign::".len()..]; + assert!( + rest.starts_with("SignResult") || rest.starts_with("verify_and_sign_psbt("), + "ffi.rs reaches into sign:: at an unexpected item: {}", + &rest[..rest.len().min(40)] + ); + } +} + +// --------------------------------------------------------------------------- +// Task 6: invoice decoding — BOLT-11 spec vectors (the reference is +// packages/client-sdk/test/invoice.test.ts) and the real rgb-lib RGB invoice +// from the parity fixture. Every malformed input is `InvoiceDecode`, only. +// --------------------------------------------------------------------------- + +use utexo_minimal_sdk::{decode_bolt11, decode_rgb_invoice, BeneficiaryKind}; + +const SPEC_PAYEE: &str = "03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad"; +const SPEC_HASH: &str = "0001020304050607080900010203040506070809000102030405060708090102"; +const SPEC_SECRET: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + +const VECTOR_ANY_AMOUNT: &str = "lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql"; +const VECTOR_COFFEE: &str = "lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh"; +const VECTOR_UTF8: &str = "lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpu9qrsgqhtjpauu9ur7fw2thcl4y9vfvh4m9wlfyz2gem29g5ghe2aak2pm3ps8fdhtceqsaagty2vph7utlgj48u0ged6a337aewvraedendscp573dxr"; +const VECTOR_HASHED: &str = "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp7ynn44"; +const VECTOR_TESTNET: &str = "lntb20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfpp3x9et2e20v6pu37c5d9vax37wxq72un989qrsgqdj545axuxtnfemtpwkc45hx9d2ft7x04mt8q7y6t0k2dge9e7h8kpy9p34ytyslj3yu569aalz2xdk8xkd7ltxqld94u8h2esmsmacgpghe9k8"; + +fn assert_invoice_decode_error(result: Result, what: &str) { + match result { + Err(SdkError::InvoiceDecode { reason }) => { + assert!(!reason.is_empty(), "{what}: empty reason") + } + other => panic!("{what}: expected InvoiceDecode, got {other:?}"), + } +} + +#[test] +fn bolt11_any_amount_donation_vector() { + let inv = decode_bolt11(VECTOR_ANY_AMOUNT).unwrap(); + assert_eq!(inv.network, BitcoinNetwork::Mainnet); + assert_eq!(inv.amount_msat, None); + assert_eq!(inv.payment_hash, SPEC_HASH); + assert_eq!(inv.payment_secret.as_deref(), Some(SPEC_SECRET)); + assert_eq!( + inv.description.as_deref(), + Some("Please consider supporting this project") + ); + assert_eq!(inv.description_hash, None); + assert_eq!(inv.payee_node_id, SPEC_PAYEE); + assert_eq!(inv.timestamp, 1_496_314_658); + assert_eq!(inv.expiry_seconds, 3600); +} + +#[test] +fn bolt11_coffee_vector_amount_and_expiry() { + let inv = decode_bolt11(VECTOR_COFFEE).unwrap(); + assert_eq!(inv.network, BitcoinNetwork::Mainnet); + assert_eq!(inv.amount_msat, Some(250_000_000)); + assert_eq!(inv.description.as_deref(), Some("1 cup coffee")); + assert_eq!(inv.expiry_seconds, 60); + assert_eq!(inv.payee_node_id, SPEC_PAYEE); + assert_eq!(inv.payment_hash, SPEC_HASH); +} + +#[test] +fn bolt11_utf8_description_vector() { + let inv = decode_bolt11(VECTOR_UTF8).unwrap(); + assert_eq!(inv.description.as_deref(), Some("ナンセンス 1杯")); + assert_eq!(inv.amount_msat, Some(250_000_000)); + assert_eq!(inv.payee_node_id, SPEC_PAYEE); +} + +#[test] +fn bolt11_hashed_description_vector() { + let inv = decode_bolt11(VECTOR_HASHED).unwrap(); + assert_eq!(inv.amount_msat, Some(2_000_000_000)); + assert_eq!(inv.description, None); + assert_eq!( + inv.description_hash.as_deref(), + Some("3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1") + ); + assert_eq!(inv.payee_node_id, SPEC_PAYEE); +} + +#[test] +fn bolt11_testnet_vector_ignores_fallback_tag() { + let inv = decode_bolt11(VECTOR_TESTNET).unwrap(); + assert_eq!(inv.network, BitcoinNetwork::Testnet); + assert_eq!(inv.amount_msat, Some(2_000_000_000)); + assert_eq!(inv.payee_node_id, SPEC_PAYEE); + assert_eq!(inv.payment_hash, SPEC_HASH); +} + +#[test] +fn bolt11_uppercase_input_decodes_identically() { + let lower = decode_bolt11(VECTOR_COFFEE).unwrap(); + let upper = decode_bolt11(&VECTOR_COFFEE.to_uppercase()).unwrap(); + assert_eq!(lower, upper); + // Mixed case is lowercased before decoding, as the TS reference does + // (`invoice.toLowerCase()`): pasted invoices with a capitalised prefix + // still decode, and the signature message uses the lowercase HRP. + let mut mixed = VECTOR_COFFEE.to_owned(); + mixed.replace_range(0..2, "LN"); + assert_eq!(decode_bolt11(&mixed).unwrap(), lower); +} + +#[test] +fn bolt11_malformed_inputs_are_invoice_decode_only() { + // Wrong checksum: flip the last character. + let last = VECTOR_COFFEE.chars().last().unwrap(); + let corrupted = format!( + "{}{}", + &VECTOR_COFFEE[..VECTOR_COFFEE.len() - 1], + if last == 'h' { 'k' } else { 'h' } + ); + assert_invoice_decode_error(decode_bolt11(&corrupted), "checksum"); + // Non-lightning bech32 (a segwit address). + assert_invoice_decode_error( + decode_bolt11("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"), + "segwit address", + ); + // Truncated data. + assert_invoice_decode_error(decode_bolt11("lnbc1pvjluez"), "truncated"); + // Not bech32 at all. + for junk in [ + "", + "1", + "lnbc", + "lnbc1", + "rgb:~/~/~/x", + "lnbc1\u{00e9}", + " lnbc1pvjluez", + ] { + assert_invoice_decode_error(decode_bolt11(junk), junk); + } +} + +#[test] +fn bolt11_every_truncation_and_corruption_is_ok_or_invoice_decode() { + // User-pasted strings: no position may panic or surface any other + // error variant. Truncations at every length, and one-character + // corruption at every position, of a real vector. + let charset: Vec = "qpzry9x8gf2tvdw0s3jn54khce6mua7l".chars().collect(); + let mut decoded_ok = 0usize; + for vector in [VECTOR_COFFEE, VECTOR_TESTNET] { + for len in 0..=vector.len() { + match decode_bolt11(&vector[..len]) { + Ok(_) => decoded_ok += 1, + Err(SdkError::InvoiceDecode { .. }) => {} + Err(other) => panic!("truncation {len}: {other:?}"), + } + } + let chars: Vec = vector.chars().collect(); + for pos in 0..chars.len() { + let mut mutated = chars.clone(); + let replacement = charset.iter().copied().find(|c| *c != chars[pos]).unwrap(); + mutated[pos] = replacement; + let s: String = mutated.into_iter().collect(); + match decode_bolt11(&s) { + Ok(_) => decoded_ok += 1, + Err(SdkError::InvoiceDecode { .. }) => {} + Err(other) => panic!("corruption at {pos}: {other:?}"), + } + } + } + // Only the untruncated originals can decode (2), never a corruption: + // the checksum catches every single-character change. + assert_eq!(decoded_ok, 2, "corrupted vectors must not decode"); +} + +// --- Test-side BOLT-11 encoder: needed to prove the length limit is off and +// --- to exercise the `n` (payee) tag, which none of the spec vectors carries. + +fn bytes_to_words(bytes: &[u8]) -> Vec { + let mut words = Vec::new(); + let mut acc: u64 = 0; + let mut bits = 0u32; + for &b in bytes { + acc = ((acc << 8) | u64::from(b)) & 0xffff_ffff; + bits += 8; + while bits >= 5 { + bits -= 5; + words.push(((acc >> bits) & 0x1f) as u8); + } + } + if bits > 0 { + words.push(((acc << (5 - bits)) & 0x1f) as u8); + } + words +} + +fn words_to_bytes_padded(words: &[u8]) -> Vec { + let mut bytes = Vec::new(); + let mut acc: u64 = 0; + let mut bits = 0u32; + for &w in words { + acc = ((acc << 5) | u64::from(w)) & 0xffff_ffff; + bits += 5; + while bits >= 8 { + bits -= 8; + bytes.push(((acc >> bits) & 0xff) as u8); + } + } + if bits > 0 { + bytes.push(((acc << (8 - bits)) & 0xff) as u8); + } + bytes +} + +fn tag(tag_type: u8, data: Vec) -> Vec { + // A tag length is 10 bits: at most 1023 words per tagged field. + assert!( + data.len() < 1024, + "tag {tag_type} data too long: {} words", + data.len() + ); + let mut out = vec![tag_type, (data.len() / 32) as u8, (data.len() % 32) as u8]; + out.extend(data); + out +} + +/// Encode and sign a BOLT-11 invoice over `hrp` and pre-signature data words. +/// Uses the same checksum engine the library uses, deliberately without the +/// crate's length-limited `encode`. +fn encode_bolt11(hrp: &str, data_words: &[u8], secret: &bitcoin::secp256k1::SecretKey) -> String { + use bitcoin::bech32::primitives::checksum::{Checksum, Engine, PackedFe32}; + use bitcoin::bech32::{Bech32, Fe32, Hrp}; + use bitcoin::hashes::{sha256, Hash}; + use bitcoin::secp256k1::Message; + + let secp = Secp256k1::new(); + let mut message = hrp.as_bytes().to_vec(); + message.extend_from_slice(&words_to_bytes_padded(data_words)); + let digest = Message::from_digest(sha256::Hash::hash(&message).to_byte_array()); + let (recid, compact) = secp + .sign_ecdsa_recoverable(&digest, secret) + .serialize_compact(); + let mut sig = compact.to_vec(); + sig.push(recid.to_i32() as u8); + let mut words = data_words.to_vec(); + words.extend(bytes_to_words(&sig)); + assert_eq!(words.len(), data_words.len() + 104); + + let charset: Vec = "qpzry9x8gf2tvdw0s3jn54khce6mua7l".chars().collect(); + let parsed_hrp = Hrp::parse(hrp).unwrap(); + let mut engine = Engine::::new(); + engine.input_hrp(parsed_hrp); + for &w in &words { + engine.input_fe(Fe32::from_char(charset[usize::from(w)]).unwrap()); + } + engine.input_target_residue(); + let residue = *engine.residue(); + let mut out = format!("{hrp}1"); + for &w in &words { + out.push(charset[usize::from(w)]); + } + for i in 0..Bech32::CHECKSUM_LENGTH { + out.push(charset[usize::from(residue.unpack(Bech32::CHECKSUM_LENGTH - i - 1))]); + } + out +} + +fn spec_like_data_words(description: &str, payee_tag: Option<&PublicKey>) -> Vec { + let mut data = bytes_to_words(&1_496_314_658u64.to_be_bytes()[3..]); // 5 bytes = 8 words + // Timestamp is 7 words (35 bits): drop the leading zero word. + data.remove(0); + assert_eq!(data.len(), 7); + let hash: Vec = (0..32).map(|i| i as u8).collect(); + data.extend(tag(1, bytes_to_words(&hash))); + data.extend(tag(16, bytes_to_words(&[0x11; 32]))); + data.extend(tag(13, bytes_to_words(description.as_bytes()))); + if let Some(pk) = payee_tag { + data.extend(tag(19, bytes_to_words(&pk.serialize()))); + } + data.extend(tag(6, vec![1, 28])); // expiry 60 + data +} + +#[test] +fn bolt11_length_limit_is_disabled_and_payee_recovers() { + // A 580-byte description plus a 600-word route-hint tag (type 3, which + // the decoder skips) make the invoice far longer than the 1023-char + // bech32 code length that the crate's checked decoder enforces; BOLT-11 + // lifts that limit and so must we (route-hinted invoices exceed it). + let secp = Secp256k1::new(); + let secret = bitcoin::secp256k1::SecretKey::from_slice(&[7u8; 32]).unwrap(); + let payee = PublicKey::from_secret_key(&secp, &secret); + let description: String = "route-hint sized description ".repeat(20); + assert!(description.len() > 570); + let mut data = spec_like_data_words(&description, None); + data.extend(tag(3, (0..600u32).map(|i| (i % 32) as u8).collect())); + let invoice = encode_bolt11("lnbcrt2500u", &data, &secret); + assert!( + invoice.len() > 1023, + "invoice must exceed the bech32 code length ({} chars)", + invoice.len() + ); + let inv = decode_bolt11(&invoice).unwrap(); + assert_eq!(inv.network, BitcoinNetwork::Regtest); + assert_eq!(inv.amount_msat, Some(250_000_000)); + assert_eq!(inv.payee_node_id, payee.to_string()); + assert_eq!(inv.description.as_deref(), Some(description.as_str())); + assert_eq!(inv.payment_secret.as_deref(), Some(SPEC_SECRET)); + assert_eq!(inv.timestamp, 1_496_314_658); + assert_eq!(inv.expiry_seconds, 60); + // A flipped payload character on the long invoice still fails checksum. + let mut chars: Vec = invoice.chars().collect(); + let mid = chars.len() / 2; + chars[mid] = if chars[mid] == 'q' { 'p' } else { 'q' }; + let corrupted: String = chars.into_iter().collect(); + assert_invoice_decode_error(decode_bolt11(&corrupted), "long invoice checksum"); +} + +#[test] +fn bolt11_payee_tag_is_verified_against_the_signature() { + let secp = Secp256k1::new(); + let secret = bitcoin::secp256k1::SecretKey::from_slice(&[9u8; 32]).unwrap(); + let payee = PublicKey::from_secret_key(&secp, &secret); + let other = PublicKey::from_secret_key( + &secp, + &bitcoin::secp256k1::SecretKey::from_slice(&[10u8; 32]).unwrap(), + ); + // `n` tag matching the signer: accepted, payee taken from the tag. + let data = spec_like_data_words("with n tag", Some(&payee)); + let inv = decode_bolt11(&encode_bolt11("lntbs10n", &data, &secret)).unwrap(); + assert_eq!(inv.network, BitcoinNetwork::Signet); + assert_eq!(inv.amount_msat, Some(1_000)); + assert_eq!(inv.payee_node_id, payee.to_string()); + // `n` tag naming a different key than the signer: refused. + let data = spec_like_data_words("with wrong n tag", Some(&other)); + assert_invoice_decode_error( + decode_bolt11(&encode_bolt11("lntbs10n", &data, &secret)), + "payee tag mismatch", + ); + // No payment hash at all: refused even though the signature is valid. + let mut data = bytes_to_words(&1_496_314_658u64.to_be_bytes()[3..]); + data.remove(0); + data.extend(tag(13, bytes_to_words(b"no hash"))); + assert_invoice_decode_error( + decode_bolt11(&encode_bolt11("lnbc", &data, &secret)), + "missing payment hash", + ); +} + +#[test] +fn bolt11_wrong_length_and_duplicate_hash_tags_follow_the_spec() { + // BOLT-11: a reader MUST skip `p`/`s`/`h` fields of the wrong length and + // MUST use the first of duplicated known fields. + let secret = bitcoin::secp256k1::SecretKey::from_slice(&[11u8; 32]).unwrap(); + let timestamp = || { + let mut data = bytes_to_words(&1_496_314_658u64.to_be_bytes()[3..]); + data.remove(0); + data + }; + // A 51-word `p` tag is skipped, which leaves the invoice without a hash. + let mut data = timestamp(); + data.extend(tag(1, vec![1; 51])); + data.extend(tag(16, bytes_to_words(&[0x11; 32]))); + data.extend(tag(13, bytes_to_words(b"short hash"))); + assert_invoice_decode_error( + decode_bolt11(&encode_bolt11("lnbc", &data, &secret)), + "wrong-length payment hash", + ); + // A 53-word `p` tag is skipped too; a well-formed one after it is used. + let hash: Vec = (0..32).map(|i| i as u8).collect(); + let mut data = timestamp(); + data.extend(tag(1, vec![1; 53])); + data.extend(tag(1, bytes_to_words(&hash))); + data.extend(tag(16, bytes_to_words(&[0x11; 32]))); + data.extend(tag(13, bytes_to_words(b"long then good"))); + let hash_hex: String = hash.iter().map(|b| format!("{b:02x}")).collect(); + let inv = decode_bolt11(&encode_bolt11("lnbc", &data, &secret)).unwrap(); + assert_eq!(inv.payment_hash, hash_hex); + // Two well-formed `p` tags: the first wins, the second is ignored. + let other: Vec = (0..32).map(|i| 0xff - i as u8).collect(); + let mut data = timestamp(); + data.extend(tag(1, bytes_to_words(&hash))); + data.extend(tag(1, bytes_to_words(&other))); + data.extend(tag(16, bytes_to_words(&[0x11; 32]))); + data.extend(tag(16, bytes_to_words(&[0x22; 32]))); + data.extend(tag(13, bytes_to_words(b"duplicates"))); + let inv = decode_bolt11(&encode_bolt11("lnbc", &data, &secret)).unwrap(); + assert_eq!(inv.payment_hash, hash_hex); + assert_eq!(inv.payment_secret.as_deref(), Some(SPEC_SECRET)); + // A wrong-length `s` tag leaves the secret absent rather than garbled. + let mut data = timestamp(); + data.extend(tag(1, bytes_to_words(&hash))); + data.extend(tag(16, vec![2; 50])); + data.extend(tag(13, bytes_to_words(b"short secret"))); + let inv = decode_bolt11(&encode_bolt11("lnbc", &data, &secret)).unwrap(); + assert_eq!(inv.payment_secret, None); +} + +#[test] +fn rgb_invoice_fixture_witness_receive_matches_rgb_lib() { + let fixture = load_fixture(); + let wr = &fixture["witnessReceive"]; + let invoice = fixture_str(&wr["invoice"], "witnessReceive.invoice"); + let inv = decode_rgb_invoice(invoice).unwrap(); + assert_eq!(inv.asset_id, None); + assert_eq!(inv.schema, None); + assert_eq!(inv.amount, None); + assert_eq!(inv.assignment_raw, None); + assert_eq!( + inv.recipient_id, + fixture_str(&wr["recipientId"], "witnessReceive.recipientId") + ); + assert_eq!(inv.beneficiary_kind, BeneficiaryKind::Witness); + assert_eq!(inv.chain.as_deref(), Some("bcrt")); + assert_eq!( + inv.expiry_timestamp, + Some( + wr["expirationTimestamp"] + .as_u64() + .expect("expirationTimestamp") + ) + ); + assert_eq!(inv.transport_endpoints, ["rpc://localhost:3000/json-rpc"]); + assert_eq!(inv.assignment_name, None); +} + +#[test] +fn rgb_invoice_blind_with_asset_amount_and_endpoints() { + let inv = decode_rgb_invoice( + "rgb:erRCLIhl-nBu1DdF-M8YCRNH-Y3rB0W3-hLZH1DP-1AY9zpQ/RGB20Fixed/100/bcrt:utxob:4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?expiry=1700000000&endpoints=rpc://proxy-a/json-rpc,rpcs://proxy-b/json-rpc", + ) + .unwrap(); + assert_eq!( + inv.asset_id.as_deref(), + Some("rgb:erRCLIhl-nBu1DdF-M8YCRNH-Y3rB0W3-hLZH1DP-1AY9zpQ") + ); + assert_eq!(inv.schema.as_deref(), Some("RGB20Fixed")); + assert_eq!(inv.amount, Some(100)); + assert_eq!(inv.beneficiary_kind, BeneficiaryKind::Blind); + assert_eq!( + inv.recipient_id, + "bcrt:utxob:4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa" + ); + assert_eq!(inv.chain.as_deref(), Some("bcrt")); + assert_eq!(inv.expiry_timestamp, Some(1_700_000_000)); + assert_eq!( + inv.transport_endpoints, + ["rpc://proxy-a/json-rpc", "rpcs://proxy-b/json-rpc"] + ); +} + +#[test] +fn rgb_invoice_non_numeric_state_and_assignment_name() { + let inv = decode_rgb_invoice( + "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/~/BF/bc:utxob:4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?assignment_name=assetOwner", + ) + .unwrap(); + assert_eq!(inv.amount, None); + assert_eq!(inv.assignment_raw.as_deref(), Some("BF")); + assert_eq!(inv.assignment_name.as_deref(), Some("assetOwner")); + assert_eq!(inv.chain.as_deref(), Some("bc")); + assert_eq!(inv.schema, None); + assert_eq!(inv.transport_endpoints, Vec::::new()); + assert_eq!(inv.expiry_timestamp, None); + // Unknown beneficiary grammar passes the id through, kind Unknown. + let inv = decode_rgb_invoice("rgb:~/~/~/bcrt1qsomething").unwrap(); + assert_eq!(inv.beneficiary_kind, BeneficiaryKind::Unknown); + assert_eq!(inv.chain, None); + assert_eq!(inv.recipient_id, "bcrt1qsomething"); + // Percent-encoded endpoints are decoded; empty endpoints dropped. + let inv = + decode_rgb_invoice("rgb:~/~/~/bcrt:utxob:x?endpoints=rpc%3A%2F%2Fa%2Fjson-rpc,,").unwrap(); + assert_eq!(inv.transport_endpoints, ["rpc://a/json-rpc"]); +} + +#[test] +fn rgb_invoice_malformed_inputs_are_invoice_decode_only() { + for bad in [ + "rgbx:~/~/~/bcrt:utxob:x", + "rgb:~/~/bcrt:utxob:x", + "rgb:~/~/~/", + "rgb:~/~/~/bcrt:utxob:x?expiry=soon", + "rgb:~/~/~/bcrt:utxob:x?flag", + "rgb:~/~/~/~/bcrt:utxob:x", + "rgb:~/~/99999999999999999999999/bcrt:utxob:x", + "rgb:~/~/~/bcrt:utxob:x?expiry=99999999999999999999999", + "", + "rgb:", + "lnbc1pvjluez", + ] { + assert_invoice_decode_error(decode_rgb_invoice(bad), bad); + } + // Every truncation of the fixture invoice is Ok or InvoiceDecode. + let fixture = load_fixture(); + let invoice = fixture_str(&fixture["witnessReceive"]["invoice"], "invoice"); + for len in 0..=invoice.len() { + if !invoice.is_char_boundary(len) { + continue; + } + match decode_rgb_invoice(&invoice[..len]) { + Ok(_) | Err(SdkError::InvoiceDecode { .. }) => {} + Err(other) => panic!("truncation {len}: {other:?}"), + } + } +} + +#[test] +fn invoice_decoders_are_exported_and_error_variant_is_named_invoice_decode() { + let ffi_rs = fs::read_to_string(manifest_dir().join("src/ffi.rs")).unwrap(); + assert!(ffi_rs.contains("#[uniffi::export(name = \"decode_bolt11\")]")); + assert!(ffi_rs.contains("#[uniffi::export(name = \"decode_rgb_invoice\")]")); + let lib_rs = fs::read_to_string(manifest_dir().join("src/lib.rs")).unwrap(); + assert!(lib_rs.contains("InvoiceDecode { reason: String }")); + assert!(!lib_rs.contains("InvalidInvoice")); + // The invoice module returns no other variant. + let invoice_rs = fs::read_to_string(manifest_dir().join("src/invoice.rs")).unwrap(); + for variant in [ + "InvalidInput", + "InvalidPsbt", + "SigningFailed", + "Internal {", + "Transport", + "Gateway", + "unwrap()", + "expect(", + ] { + let body = invoice_rs.split("#[cfg(test)]").next().unwrap(); + assert!(!body.contains(variant), "invoice.rs uses {variant}"); + } +} + +// --------------------------------------------------------------------------- +// Task 7: gateway client — every route through a fake in-Rust transport (no +// network), intent binding for the three prepare* methods, idempotency keys, +// error mapping and secrets hygiene. The reference is +// packages/client-sdk/test/gateway.test.ts. +// --------------------------------------------------------------------------- + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use utexo_minimal_sdk::gateway::{ + CompleteParams, LnAssetKind, LnDepositPrepareParams, LnInvoiceCreateParams, LnInvoiceState, + LnPayParams, LnPaymentDirection, LnPaymentStatus, LnWithdrawParams, PrepareCreateUtxosParams, + PrepareSendAssetParams, PrepareSendBtcParams, ReceiveMode, ReceiveParams, RegisterXpubsParams, +}; +use utexo_minimal_sdk::{ + encode_uri_component, generate_idempotency_key, GatewayClient, HttpMethod, HttpRequest, + HttpResponse, HttpTransport, +}; + +/// Records every request and answers from a queue. Implements the same +/// foreign trait Kotlin/Swift will, so the client is exercised exactly as it +/// is over uniffi, minus the FFI hop. +#[derive(Default)] +struct FakeTransport { + seen: Mutex>, + responses: Mutex>>, +} + +impl FakeTransport { + fn with(responses: Vec>) -> Arc { + Arc::new(FakeTransport { + seen: Mutex::new(Vec::new()), + responses: Mutex::new(responses.into()), + }) + } + + fn seen(&self) -> Vec { + self.seen.lock().unwrap().clone() + } + + fn push(&self, response: Result) { + self.responses.lock().unwrap().push_back(response); + } +} + +impl HttpTransport for FakeTransport { + fn send(&self, request: HttpRequest) -> Result { + self.seen.lock().unwrap().push(request); + self.responses + .lock() + .unwrap() + .pop_front() + .expect("FakeTransport: no response queued") + } +} + +fn ok(status: u16, body: serde_json::Value) -> Result { + Ok(HttpResponse { + status, + body: body.to_string(), + }) +} + +const UUID_V4_LEN: usize = 36; + +fn assert_uuid_v4(key: &str) { + assert_eq!(key.len(), UUID_V4_LEN, "{key}"); + let parts: Vec<&str> = key.split('-').collect(); + assert_eq!( + parts.iter().map(|p| p.len()).collect::>(), + [8, 4, 4, 4, 12], + "{key}" + ); + assert!( + key.bytes() + .all(|b| b == b'-' || (b.is_ascii_hexdigit() && !b.is_ascii_uppercase())), + "{key}" + ); + assert!(parts[2].starts_with('4'), "version nibble: {key}"); + assert!( + matches!(parts[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b'), + "variant bits: {key}" + ); +} + +fn client(transport: &Arc, token: Option<&str>) -> GatewayClient { + GatewayClient::new( + "http://gw.local", + token.map(str::to_owned), + None, + transport.clone(), + ) +} + +fn body_json(request: &HttpRequest) -> serde_json::Value { + serde_json::from_str(request.body.as_deref().expect("request has a body")).unwrap() +} + +fn assert_no_idempotency_key(request: &HttpRequest) { + assert!( + !request.headers.contains_key("idempotency-key"), + "{} carries an idempotency key", + request.url + ); +} + +/// The intent the fake gateway returns for the fixture PSBT: the same +/// 40 000 sat send to the foreign wallet that `send_btc_intent` describes. +fn fixture_send_btc_intent_json(w: &Wallets) -> serde_json::Value { + serde_json::json!({ + "kind": "send_btc", + "feeRateSatPerVb": 2, + "recipients": [{ + "address": w.foreign.address_at(false, 0, 0), + "scriptHex": w.foreign.script_at(false, 0, 0).to_hex_string(), + "amountSat": 40_000 + }], + "asset": null, + "utxos": null + }) +} + +fn prepared(intent: serde_json::Value) -> serde_json::Value { + serde_json::json!({ "opId": "op-1", "psbt": "cHNidP8=", "expiresAt": 1, "intent": intent }) +} + +const ADDRESS: &str = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080"; +const ATTACKER: &str = "bcrt1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3"; + +fn send_btc_intent_json() -> serde_json::Value { + serde_json::json!({ + "kind": "send_btc", + "feeRateSatPerVb": 2, + "recipients": [{ "address": ADDRESS, "scriptHex": "0014deadbeef", "amountSat": 40_000 }], + "asset": null, + "utxos": null + }) +} + +fn send_asset_intent_json() -> serde_json::Value { + serde_json::json!({ + "kind": "send_asset", + "feeRateSatPerVb": 2, + "recipients": [], + "asset": { + "assetId": "rgb:good", + "amount": 10, + "recipientId": "utxob:me", + "witnessAmountSat": null, + "transportEndpoints": ["http://proxy"] + }, + "utxos": null + }) +} + +fn create_utxos_intent_json() -> serde_json::Value { + serde_json::json!({ + "kind": "create_utxos", + "feeRateSatPerVb": 2, + "recipients": [], + "asset": null, + "utxos": { "upTo": false, "num": 4, "size": 1_000 } + }) +} + +fn send_btc_params() -> PrepareSendBtcParams { + PrepareSendBtcParams { + address: ADDRESS.into(), + amount_sat: 40_000, + fee_rate_sat_per_vb: None, + } +} + +fn send_asset_params() -> PrepareSendAssetParams { + PrepareSendAssetParams { + asset_id: "rgb:good".into(), + amount: 10, + recipient_id: "utxob:me".into(), + witness_amount_sat: None, + transport_endpoints: None, + donation: None, + min_confirmations: None, + fee_rate_sat_per_vb: None, + } +} + +fn assert_intent_mismatch( + result: Result, + expected_detail: &str, + what: &str, +) { + match result { + Err(SdkError::IntentMismatch { reason }) => assert!( + reason.contains(expected_detail), + "{what}: mismatch reason {reason:?} does not mention {expected_detail:?}" + ), + other => panic!("{what}: expected IntentMismatch, got {other:?}"), + } +} + +#[test] +fn gateway_every_route_round_trips_with_method_path_auth_and_body_shape() { + let hash = "c".repeat(64); + let transport = FakeTransport::with(vec![ + ok(200, serde_json::json!({ "userId": "u1", "createdAt": 7 })), + ok( + 200, + serde_json::json!({ "fingerprint": "73c5da0a", "address": "bcrt1p...a" }), + ), + ok(200, serde_json::json!({ "address": "bcrt1p...b" })), + ok( + 200, + serde_json::json!({ + "btc": { + "vanilla": { "settled": 1, "future": 2, "spendable": 3 }, + "colored": { "settled": 4, "future": 5, "spendable": 6 } + }, + "assets": [{ + "assetId": "rgb:x", "schema": "NIA", "ticker": null, "name": "X", + "precision": 8, "balance": { "settled": 7, "future": 8, "spendable": 9 } + }] + }), + ), + ok( + 200, + serde_json::json!({ "unspents": [{ + "txid": "aa", "vout": 1, "amountSat": 1000, "colorable": true, + "allocations": [{ "assetId": "rgb:x", "amount": 5, "settled": true }, + { "assetId": null, "amount": null, "settled": false }] + }] }), + ), + ok( + 200, + serde_json::json!({ "transfers": [{ + "idx": 3, "assetId": "rgb:x", "amount": 5, "kind": "issuance", "status": "settled", + "txid": null, "recipientId": null, "expiration": null, "createdAt": 1, "updatedAt": 2 + }] }), + ), + ok(200, serde_json::json!({ "transfers": [] })), + ok( + 201, + serde_json::json!({ + "invoice": "rgb:~/~/~/bcrt:utxob:x", "recipientId": "bcrt:utxob:x", + "expirationTimestamp": 99, "mode": "blind" + }), + ), + ok(200, serde_json::json!({ "status": "ok" })), + ok(200, prepared(send_btc_intent_json())), + ok(200, serde_json::json!({ "txid": "bb" })), + ok(200, prepared(send_asset_intent_json())), + ok(200, serde_json::json!({ "txid": "cc" })), + ok(200, prepared(create_utxos_intent_json())), + ok(200, serde_json::json!({ "txid": null, "utxosCreated": 4 })), + ok( + 201, + serde_json::json!({ + "depositId": "d", "kind": "btc", "address": "bcrt1q...", "invoice": null, + "recipientId": null + }), + ), + ok( + 200, + serde_json::json!({ "paymentHash": hash, "status": "pending" }), + ), + ok( + 201, + serde_json::json!({ "invoice": "lnbcrt1...", "paymentHash": hash }), + ), + ok( + 200, + serde_json::json!({ + "paymentHash": hash, "invoice": "lnbcrt1...", "state": "pending", + "amtMsat": 1000, "assetId": null, "assetAmount": null, "createdAt": 1 + }), + ), + ok( + 200, + serde_json::json!({ "payments": [{ + "paymentHash": hash, "direction": "outbound", "status": "succeeded", + "amtMsat": 1000, "assetId": null, "assetAmount": null, "createdAt": 1, + "updatedAt": 2 + }] }), + ), + ok( + 200, + serde_json::json!({ "btcMsat": 5000, "assets": { "rgb:x": 12 } }), + ), + ok( + 201, + serde_json::json!({ "withdrawalId": "w", "txid": "dd" }), + ), + ]); + let c = client(&transport, Some("tok-1")); + + assert_eq!(c.me().unwrap().user_id, "u1"); + let registered = c + .register_xpubs(&RegisterXpubsParams { + vanilla: "tpubV".into(), + colored: "tpubC".into(), + fingerprint: "73c5da0a".into(), + }) + .unwrap(); + assert_eq!(registered.address, "bcrt1p...a"); + assert_eq!(c.get_address().unwrap().address, "bcrt1p...b"); + let balances = c.get_balances().unwrap(); + assert_eq!(balances.btc.colored.spendable, 6); + assert_eq!(balances.assets[0].ticker, None); + assert_eq!(balances.assets[0].balance.settled, 7); + let unspents = c.get_unspents().unwrap(); + assert_eq!(unspents.unspents[0].allocations[0].amount, Some(5)); + assert_eq!(unspents.unspents[0].allocations[1].asset_id, None); + let transfers = c.get_transfers(Some("rgb:abc-123")).unwrap(); + assert_eq!(transfers.transfers[0].idx, 3); + assert!(c.get_transfers(None).unwrap().transfers.is_empty()); + let received = c + .receive(&ReceiveParams { + mode: ReceiveMode::Blind, + asset_id: Some("rgb:x".into()), + amount: Some(5), + duration_seconds: None, + min_confirmations: Some(1), + }) + .unwrap(); + assert_eq!(received.mode, ReceiveMode::Blind); + assert_eq!(received.expiration_timestamp, Some(99)); + assert_eq!(c.sync().unwrap().status, "ok"); + let op = c.prepare_send_btc(&send_btc_params(), None).unwrap(); + assert_eq!(op.op_id, "op-1"); + assert_eq!(op.intent.kind, IntentKind::SendBtc); + let complete = CompleteParams { + op_id: "op-1".into(), + signed_psbt: "cHNidP8=".into(), + }; + assert_eq!(c.complete_send_btc(&complete, None).unwrap().txid, "bb"); + let op = c.prepare_send_asset(&send_asset_params(), None).unwrap(); + assert_eq!(op.intent.asset.as_ref().unwrap().asset_id, "rgb:good"); + assert_eq!(c.complete_send_asset(&complete, None).unwrap().txid, "cc"); + let op = c + .prepare_create_utxos( + &PrepareCreateUtxosParams { + num: Some(4), + size: None, + up_to: None, + fee_rate_sat_per_vb: None, + }, + None, + ) + .unwrap(); + assert_eq!(op.intent.utxos.as_ref().unwrap().num, 4); + let created = c.complete_create_utxos(&complete, None).unwrap(); + assert_eq!((created.txid, created.utxos_created), (None, 4)); + let deposit = c + .prepare_ln_deposit( + &LnDepositPrepareParams { + kind: LnAssetKind::Btc, + amount_msat: Some(1_000_000), + asset_id: None, + amount: None, + }, + None, + ) + .unwrap(); + assert_eq!(deposit.address.as_deref(), Some("bcrt1q...")); + assert_eq!(deposit.kind, LnAssetKind::Btc); + let paid = c + .pay_ln_invoice( + &LnPayParams { + invoice: "lnbcrt1...".into(), + amt_msat: None, + asset_amount: None, + }, + None, + ) + .unwrap(); + assert_eq!(paid.status, LnPaymentStatus::Pending); + let invoice = c + .create_ln_invoice(&LnInvoiceCreateParams { + amt_msat: Some(1000), + expiry_sec: None, + asset_id: None, + asset_amount: None, + description: Some("coffee".into()), + }) + .unwrap(); + assert_eq!(invoice.payment_hash, hash); + let info = c.get_ln_invoice("../v1/ln/balance").unwrap(); + assert_eq!(info.state, LnInvoiceState::Pending); + let payments = c.list_ln_payments().unwrap(); + assert_eq!(payments.payments[0].direction, LnPaymentDirection::Outbound); + assert_eq!(payments.payments[0].status, LnPaymentStatus::Succeeded); + let ln_balance = c.get_ln_balance().unwrap(); + assert_eq!(ln_balance.btc_msat, 5000); + assert_eq!(ln_balance.assets.get("rgb:x"), Some(&12)); + let withdrawn = c + .withdraw_ln( + &LnWithdrawParams { + kind: LnAssetKind::Btc, + address: Some("bcrt1q...".into()), + amount_sat: Some(1_000), + asset_id: None, + amount: None, + recipient_id: None, + witness_amount_sat: None, + transport_endpoints: None, + fee_rate_sat_per_vb: None, + }, + None, + ) + .unwrap(); + assert_eq!(withdrawn.txid, "dd"); + + // Method, path, auth header and body shape, in call order. + let seen = transport.seen(); + let expected: Vec<(HttpMethod, &str, Option, bool)> = vec![ + (HttpMethod::Get, "/v1/me", None, false), + ( + HttpMethod::Post, + "/v1/wallet/xpubs", + Some( + serde_json::json!({ "vanilla": "tpubV", "colored": "tpubC", "fingerprint": "73c5da0a" }), + ), + false, + ), + (HttpMethod::Get, "/v1/wallet/address", None, false), + (HttpMethod::Get, "/v1/wallet/balances", None, false), + (HttpMethod::Get, "/v1/wallet/unspents", None, false), + ( + HttpMethod::Get, + "/v1/wallet/transfers?assetId=rgb%3Aabc-123", + None, + false, + ), + (HttpMethod::Get, "/v1/wallet/transfers", None, false), + ( + HttpMethod::Post, + "/v1/wallet/receive", + Some( + serde_json::json!({ "mode": "blind", "assetId": "rgb:x", "amount": 5, "minConfirmations": 1 }), + ), + false, + ), + (HttpMethod::Post, "/v1/wallet/sync", None, false), + ( + HttpMethod::Post, + "/v1/onchain/send-btc/prepare", + Some(serde_json::json!({ "address": ADDRESS, "amountSat": 40_000 })), + true, + ), + ( + HttpMethod::Post, + "/v1/onchain/send-btc/complete", + Some(serde_json::json!({ "opId": "op-1", "signedPsbt": "cHNidP8=" })), + true, + ), + ( + HttpMethod::Post, + "/v1/onchain/send-asset/prepare", + Some( + serde_json::json!({ "assetId": "rgb:good", "amount": 10, "recipientId": "utxob:me" }), + ), + true, + ), + ( + HttpMethod::Post, + "/v1/onchain/send-asset/complete", + Some(serde_json::json!({ "opId": "op-1", "signedPsbt": "cHNidP8=" })), + true, + ), + ( + HttpMethod::Post, + "/v1/onchain/create-utxos/prepare", + Some(serde_json::json!({ "num": 4 })), + true, + ), + ( + HttpMethod::Post, + "/v1/onchain/create-utxos/complete", + Some(serde_json::json!({ "opId": "op-1", "signedPsbt": "cHNidP8=" })), + true, + ), + ( + HttpMethod::Post, + "/v1/ln/deposit/prepare", + Some(serde_json::json!({ "kind": "btc", "amountMsat": 1_000_000 })), + true, + ), + ( + HttpMethod::Post, + "/v1/ln/pay", + Some(serde_json::json!({ "invoice": "lnbcrt1..." })), + true, + ), + ( + HttpMethod::Post, + "/v1/ln/invoice", + Some(serde_json::json!({ "amtMsat": 1000, "description": "coffee" })), + false, + ), + ( + HttpMethod::Get, + "/v1/ln/invoice/..%2Fv1%2Fln%2Fbalance", + None, + false, + ), + (HttpMethod::Get, "/v1/ln/payments", None, false), + (HttpMethod::Get, "/v1/ln/balance", None, false), + ( + HttpMethod::Post, + "/v1/ln/withdraw", + Some(serde_json::json!({ "kind": "btc", "address": "bcrt1q...", "amountSat": 1_000 })), + true, + ), + ]; + assert_eq!(seen.len(), expected.len(), "one request per route"); + for (request, (method, path, body, money_moving)) in seen.iter().zip(expected) { + assert_eq!(request.method, method, "{path}"); + assert_eq!(request.url, format!("http://gw.local{path}")); + assert_eq!( + request.headers.get("authorization").map(String::as_str), + Some("Bearer tok-1"), + "{path}" + ); + assert_eq!(request.timeout_ms, 30_000); + match body { + Some(expected_body) => { + assert_eq!(body_json(request), expected_body, "{path} body"); + assert_eq!( + request.headers.get("content-type").map(String::as_str), + Some("application/json"), + "{path}" + ); + } + None => { + assert_eq!(request.body, None, "{path} must carry no body"); + assert!(!request.headers.contains_key("content-type"), "{path}"); + } + } + if money_moving { + assert_uuid_v4(request.headers.get("idempotency-key").expect(path)); + } else { + assert_no_idempotency_key(request); + } + } + // 22 distinct routes were covered (transfers and invoice/{hash} twice). + let routes: std::collections::BTreeSet<&str> = seen + .iter() + .map(|r| { + r.url + .trim_start_matches("http://gw.local") + .split('?') + .next() + .unwrap() + }) + .map(|p| { + if p.starts_with("/v1/ln/invoice/") { + "/v1/ln/invoice/{hash}" + } else { + p + } + }) + .collect(); + assert_eq!(routes.len(), 21); + // create_user is the 22nd: operator token, no bearer, no body. + let transport = FakeTransport::with(vec![ok( + 201, + serde_json::json!({ "userId": "u1", "token": "t-new", "createdAt": 1 }), + )]); + let created = client(&transport, None).create_user("op-secret").unwrap(); + assert_eq!(created.token, "t-new"); + let request = &transport.seen()[0]; + assert_eq!(request.method, HttpMethod::Post); + assert_eq!(request.url, "http://gw.local/v1/users"); + assert_eq!( + request.headers.get("x-operator-token").map(String::as_str), + Some("op-secret") + ); + assert!(!request.headers.contains_key("authorization")); + assert_eq!(request.body, None); + assert_no_idempotency_key(request); +} + +#[test] +fn gateway_base_url_trailing_slash_percent_encoding_and_no_token_guard() { + let transport = FakeTransport::with(vec![ok(200, serde_json::json!({ "address": "x" }))]); + let c = GatewayClient::new( + "http://gw.local///", + Some("tok-1".into()), + Some(5), + transport.clone(), + ); + c.get_address().unwrap(); + let request = &transport.seen()[0]; + assert_eq!(request.url, "http://gw.local/v1/wallet/address"); + assert_eq!(request.timeout_ms, 5); + assert_eq!(encode_uri_component("rgb:abc-123"), "rgb%3Aabc-123"); + + // Authenticated calls without a token fail before any network IO. + let transport = FakeTransport::with(vec![]); + let c = client(&transport, None); + match c.get_balances() { + Err(SdkError::Gateway { status, code, .. }) => { + assert_eq!((status, code.as_str()), (0, "NO_TOKEN")); + } + other => panic!("expected NO_TOKEN, got {other:?}"), + } + assert!(transport.seen().is_empty(), "no request may be sent"); +} + +#[test] +fn gateway_status_and_body_surface_on_4xx_and_5xx() { + let transport = FakeTransport::with(vec![ + ok( + 409, + serde_json::json!({ "error": { "code": "IDEMPOTENCY_CONFLICT", "message": "key reused" } }), + ), + Ok(HttpResponse { + status: 502, + body: "bad gateway".into(), + }), + Ok(HttpResponse { + status: 500, + body: String::new(), + }), + ok(400, serde_json::json!({ "error": { "code": 5 } })), + Err(SdkError::Transport { + reason: "connection refused".into(), + }), + // A 2xx that is not the documented shape is an explicit error, not a + // default value. + ok(200, serde_json::json!({ "address": 5 })), + Ok(HttpResponse { + status: 200, + body: "not json".into(), + }), + ]); + let c = client(&transport, Some("tok-1")); + assert_eq!( + c.sync().unwrap_err(), + SdkError::Gateway { + status: 409, + code: "IDEMPOTENCY_CONFLICT".into(), + detail: "key reused".into() + } + ); + assert_eq!( + c.me().unwrap_err(), + SdkError::Gateway { + status: 502, + code: "UNKNOWN".into(), + detail: "gateway returned 502".into() + } + ); + assert_eq!( + c.me().unwrap_err(), + SdkError::Gateway { + status: 500, + code: "UNKNOWN".into(), + detail: "gateway returned 500".into() + } + ); + assert_eq!( + c.me().unwrap_err(), + SdkError::Gateway { + status: 400, + code: "UNKNOWN".into(), + detail: "gateway returned 400".into() + } + ); + assert_eq!( + c.me().unwrap_err(), + SdkError::Transport { + reason: "connection refused".into() + } + ); + // Any declared error variant the transport returns is passed through + // untouched, not re-wrapped as Transport. + let foreign = SdkError::Gateway { + status: 0, + code: "HOST_CANCELLED".into(), + detail: "the app cancelled the request".into(), + }; + let cancelled = FakeTransport::with(vec![Err(foreign.clone())]); + assert_eq!(client(&cancelled, Some("tok-1")).me().unwrap_err(), foreign); + for what in ["wrong type", "non-json"] { + match c.get_address() { + Err(SdkError::Gateway { + status, + code, + detail, + }) => { + assert_eq!((status, code.as_str()), (0, "MALFORMED_RESPONSE"), "{what}"); + assert!(detail.contains("address"), "{what}: {detail}"); + } + other => panic!("{what}: expected MALFORMED_RESPONSE, got {other:?}"), + } + } + // Unknown enum values on the wire are refused rather than mapped. + transport.push(ok( + 200, + serde_json::json!({ "paymentHash": "a", "status": "maybe" }), + )); + let err = c + .pay_ln_invoice( + &LnPayParams { + invoice: "x".into(), + amt_msat: None, + asset_amount: None, + }, + None, + ) + .unwrap_err(); + assert!( + matches!(err, SdkError::Gateway { ref code, .. } if code == "MALFORMED_RESPONSE"), + "{err:?}" + ); +} + +#[test] +fn gateway_idempotency_keys_are_unique_per_call_and_stable_when_pinned() { + let mut keys = std::collections::HashSet::new(); + for _ in 0..64 { + let key = generate_idempotency_key().unwrap(); + assert_uuid_v4(&key); + assert!(keys.insert(key), "duplicate idempotency key"); + } + + let transport = FakeTransport::with(vec![ + ok(200, prepared(send_btc_intent_json())), + ok(200, prepared(create_utxos_intent_json())), + ok(200, serde_json::json!({ "txid": "aa" })), + ok(200, serde_json::json!({ "txid": "aa" })), + ok( + 200, + serde_json::json!({ "paymentHash": "a", "status": "pending" }), + ), + ok(200, serde_json::json!({ "withdrawalId": "w", "txid": "b" })), + ok(200, serde_json::json!({ "btcMsat": 0, "assets": {} })), + ok(200, serde_json::json!({ "payments": [] })), + ]); + let c = client(&transport, Some("t")); + c.prepare_send_btc(&send_btc_params(), None).unwrap(); + c.prepare_create_utxos( + &PrepareCreateUtxosParams { + num: None, + size: None, + up_to: None, + fee_rate_sat_per_vb: None, + }, + None, + ) + .unwrap(); + let pinned = generate_idempotency_key().unwrap(); + let complete = CompleteParams { + op_id: "op".into(), + signed_psbt: "cHNidP8=".into(), + }; + c.complete_send_btc(&complete, Some(pinned.clone())) + .unwrap(); + c.complete_send_btc(&complete, Some(pinned.clone())) + .unwrap(); + c.pay_ln_invoice( + &LnPayParams { + invoice: "lnbcrt1...".into(), + amt_msat: None, + asset_amount: None, + }, + Some(pinned.clone()), + ) + .unwrap(); + c.withdraw_ln( + &LnWithdrawParams { + kind: LnAssetKind::Btc, + address: Some("bcrt1q...".into()), + amount_sat: Some(1_000), + asset_id: None, + amount: None, + recipient_id: None, + witness_amount_sat: None, + transport_endpoints: None, + fee_rate_sat_per_vb: None, + }, + None, + ) + .unwrap(); + c.get_ln_balance().unwrap(); + c.list_ln_payments().unwrap(); + + let seen = transport.seen(); + let key = |i: usize| seen[i].headers.get("idempotency-key").cloned(); + assert_uuid_v4(&key(0).unwrap()); + assert_uuid_v4(&key(1).unwrap()); + assert_ne!(key(0), key(1), "fresh key per money-moving call"); + assert_eq!(key(2).as_deref(), Some(pinned.as_str())); + assert_eq!(key(3).as_deref(), Some(pinned.as_str())); + assert_eq!(key(4).as_deref(), Some(pinned.as_str())); + assert_uuid_v4(&key(5).unwrap()); + assert_eq!(key(6), None, "reads carry no idempotency key"); + assert_eq!(key(7), None); +} + +#[test] +fn intent_binding_send_btc_rejects_every_tampered_field_and_accepts_a_match() { + // verify check 2 matches the PSBT against the intent. A server-echoed + // intent makes that check vacuous against a hostile gateway: it can put + // an attacker output in the PSBT and the SAME attacker output in the + // intent, and every one of the 5 checks passes. These bind the intent to + // what the caller actually asked for, before it can reach verify_psbt. + let run = |intent: serde_json::Value, params: &PrepareSendBtcParams| { + let transport = FakeTransport::with(vec![ok(200, prepared(intent))]); + client(&transport, Some("t")).prepare_send_btc(params, None) + }; + let with = |patch: serde_json::Value| { + let mut intent = send_btc_intent_json(); + for (k, v) in patch.as_object().unwrap() { + intent[k] = v.clone(); + } + intent + }; + + let op = run(send_btc_intent_json(), &send_btc_params()).unwrap(); + assert_eq!(op.op_id, "op-1"); + assert_eq!(op.psbt, "cHNidP8="); + assert_eq!(op.expires_at, 1); + // Server-chosen defaults are left alone: feeRateSatPerVb was not pinned. + assert_eq!(op.intent.fee_rate_sat_per_vb, 2); + assert_eq!(op.intent.recipients[0].script_hex, "0014deadbeef"); + + let swapped = with(serde_json::json!({ + "recipients": [{ "address": ATTACKER, "scriptHex": "0014deadbeef", "amountSat": 40_000 }] + })); + assert_intent_mismatch( + run(swapped, &send_btc_params()), + &format!("recipient address {ATTACKER} != {ADDRESS}"), + "swapped recipient address", + ); + + let inflated = with(serde_json::json!({ + "recipients": [{ "address": ADDRESS, "scriptHex": "0014deadbeef", "amountSat": 4_000_000 }] + })); + assert_intent_mismatch( + run(inflated, &send_btc_params()), + "recipient amountSat 4000000 != 40000", + "changed amount", + ); + + let extra = with(serde_json::json!({ + "recipients": [ + { "address": ADDRESS, "scriptHex": "0014deadbeef", "amountSat": 40_000 }, + { "address": ATTACKER, "scriptHex": "0014cafe", "amountSat": 100_000 } + ] + })); + assert_intent_mismatch( + run(extra, &send_btc_params()), + "expected exactly 1 recipient, got 2", + "extra recipient", + ); + + // asset.witnessAmountSat is how check 2 accounts for a FOREIGN output; + // on a plain BTC send there is no such approval to grant. + let smuggled = with(serde_json::json!({ + "asset": { + "assetId": "rgb:x", "amount": 1, "recipientId": "wvout:y", + "witnessAmountSat": 5_000_000, "transportEndpoints": [] + } + })); + assert_intent_mismatch( + run(smuggled, &send_btc_params()), + "send-btc intent carries an asset", + "asset smuggled onto a send-btc intent", + ); + let utxo_shape = with(serde_json::json!({ "utxos": { "upTo": true, "num": 1, "size": 1 } })); + assert_intent_mismatch( + run(utxo_shape, &send_btc_params()), + "send-btc intent carries a utxo shape", + "utxo shape on a send-btc intent", + ); + + // A pinned fee rate the server did not honour. + let pinned_fee = PrepareSendBtcParams { + fee_rate_sat_per_vb: Some(5), + ..send_btc_params() + }; + assert_intent_mismatch( + run(send_btc_intent_json(), &pinned_fee), + "feeRateSatPerVb 2 != 5", + "pinned fee rate", + ); + let honoured = with(serde_json::json!({ "feeRateSatPerVb": 5 })); + assert_eq!( + run(honoured, &pinned_fee) + .unwrap() + .intent + .fee_rate_sat_per_vb, + 5 + ); + + // No intent at all, an intent that is not an object, a different flow. + for (body, detail) in [ + ( + serde_json::json!({ "opId": "op-4", "psbt": "cHNidP8=", "expiresAt": 1 }), + "prepare response carries no intent summary", + ), + ( + serde_json::json!({ "opId": "op-4", "psbt": "cHNidP8=", "expiresAt": 1, "intent": null }), + "prepare response carries no intent summary", + ), + ( + serde_json::json!({ "opId": "op-4", "psbt": "cHNidP8=", "expiresAt": 1, "intent": "send_btc" }), + "prepare response carries no intent summary", + ), + ( + prepared(with(serde_json::json!({ "kind": "create_utxos" }))), + "kind create_utxos != send_btc", + ), + ( + prepared(with(serde_json::json!({ "recipients": "none" }))), + "intent.recipients is not an array", + ), + ] { + let transport = FakeTransport::with(vec![ok(200, body)]); + assert_intent_mismatch( + client(&transport, Some("t")).prepare_send_btc(&send_btc_params(), None), + detail, + detail, + ); + } + + // Every mismatch is listed, not just the first. + let everything = with(serde_json::json!({ + "kind": "send_asset", + "recipients": [{ "address": ATTACKER, "scriptHex": "0014deadbeef", "amountSat": 1 }], + "utxos": { "upTo": true, "num": 1, "size": 1 } + })); + let reason = match run(everything, &pinned_fee) { + Err(SdkError::IntentMismatch { reason }) => reason, + other => panic!("expected IntentMismatch, got {other:?}"), + }; + for detail in [ + "kind send_asset != send_btc", + &format!("recipient address {ATTACKER} != {ADDRESS}"), + "recipient amountSat 1 != 40000", + "send-btc intent carries a utxo shape", + "feeRateSatPerVb 2 != 5", + ] { + assert!(reason.contains(detail), "{reason:?} lacks {detail:?}"); + } + assert_eq!(reason.matches("; ").count(), 4, "{reason}"); + + // A field verify cannot represent is a mismatch too (fail closed). + let stringly = with(serde_json::json!({ + "recipients": [{ "address": ADDRESS, "scriptHex": "0014deadbeef", "amountSat": "40000" }] + })); + assert_intent_mismatch( + run(stringly, &send_btc_params()), + "recipient amountSat 40000 != 40000", + "string amount is not a number", + ); + let no_script = with(serde_json::json!({ + "recipients": [{ "address": ADDRESS, "amountSat": 40_000 }] + })); + assert_intent_mismatch( + run(no_script, &send_btc_params()), + "recipient.scriptHex is not a string", + "missing scriptHex", + ); +} + +#[test] +fn intent_binding_send_asset_rejects_changed_asset_amount_recipient_witness_and_recipients() { + let run = |intent: serde_json::Value, params: &PrepareSendAssetParams| { + let transport = FakeTransport::with(vec![ok(200, prepared(intent))]); + client(&transport, Some("t")).prepare_send_asset(params, None) + }; + let with_asset = |patch: serde_json::Value| { + let mut intent = send_asset_intent_json(); + for (k, v) in patch.as_object().unwrap() { + intent["asset"][k] = v.clone(); + } + intent + }; + + let op = run(send_asset_intent_json(), &send_asset_params()).unwrap(); + let asset = op.intent.asset.unwrap(); + assert_eq!(asset.witness_amount_sat, None); + assert_eq!(asset.transport_endpoints, ["http://proxy"]); + assert_eq!(op.intent.recipients, Vec::::new()); + + for (patch, detail) in [ + ( + serde_json::json!({ "assetId": "rgb:evil" }), + "assetId rgb:evil != rgb:good", + ), + (serde_json::json!({ "amount": 1_000 }), "amount 1000 != 10"), + ( + serde_json::json!({ "recipientId": "utxob:attacker" }), + "recipientId utxob:attacker != utxob:me", + ), + // A foreign witness amount on a blind send: check 2 would account + // for one foreign output of that size. + ( + serde_json::json!({ "witnessAmountSat": 5_000_000 }), + "witnessAmountSat 5000000 != null", + ), + ] { + assert_intent_mismatch(run(with_asset(patch), &send_asset_params()), detail, detail); + } + // A missing witnessAmountSat is not "null": `undefined !== null`. + let mut missing = send_asset_intent_json(); + missing["asset"] + .as_object_mut() + .unwrap() + .remove("witnessAmountSat"); + assert_intent_mismatch( + run(missing, &send_asset_params()), + "witnessAmountSat undefined != null", + "missing witnessAmountSat", + ); + // The witness case: the caller's amount must come back exactly. + let witness_params = PrepareSendAssetParams { + witness_amount_sat: Some(1_000), + ..send_asset_params() + }; + assert_intent_mismatch( + run(send_asset_intent_json(), &witness_params), + "witnessAmountSat null != 1000", + "witness amount dropped", + ); + let witness_ok = run( + with_asset(serde_json::json!({ "witnessAmountSat": 1_000 })), + &witness_params, + ) + .unwrap(); + assert_eq!( + witness_ok.intent.asset.unwrap().witness_amount_sat, + Some(1_000) + ); + + // Bitcoin recipients on an asset send, a utxo shape, no asset at all. + let mut with_recipient = send_asset_intent_json(); + with_recipient["recipients"] = + serde_json::json!([{ "address": ATTACKER, "scriptHex": "0014cafe", "amountSat": 100_000 }]); + assert_intent_mismatch( + run(with_recipient, &send_asset_params()), + "send-asset intent carries 1 bitcoin recipient(s)", + "recipient on an asset send", + ); + let mut with_utxos = send_asset_intent_json(); + with_utxos["utxos"] = serde_json::json!({ "upTo": false, "num": 1, "size": 1 }); + assert_intent_mismatch( + run(with_utxos, &send_asset_params()), + "send-asset intent carries a utxo shape", + "utxo shape on an asset send", + ); + let mut no_asset = send_asset_intent_json(); + no_asset["asset"] = serde_json::Value::Null; + assert_intent_mismatch( + run(no_asset, &send_asset_params()), + "send-asset intent carries no asset", + "asset dropped", + ); + + // Transport endpoints are asserted only when pinned. + let pinned = PrepareSendAssetParams { + transport_endpoints: Some(vec!["http://mine".into()]), + ..send_asset_params() + }; + assert_intent_mismatch( + run(send_asset_intent_json(), &pinned), + "transportEndpoints http://proxy != http://mine", + "pinned endpoints", + ); + assert!(run( + with_asset(serde_json::json!({ "transportEndpoints": ["http://mine"] })), + &pinned + ) + .is_ok()); + let pinned_fee = PrepareSendAssetParams { + fee_rate_sat_per_vb: Some(9), + ..send_asset_params() + }; + assert_intent_mismatch( + run(send_asset_intent_json(), &pinned_fee), + "feeRateSatPerVb 2 != 9", + "pinned fee", + ); + // The request body carries every pinned field and omits unset ones. + let transport = FakeTransport::with(vec![ok(200, prepared(send_asset_intent_json()))]); + let _ = client(&transport, Some("t")).prepare_send_asset( + &PrepareSendAssetParams { + donation: Some(true), + min_confirmations: Some(3), + ..send_asset_params() + }, + None, + ); + assert_eq!( + body_json(&transport.seen()[0]), + serde_json::json!({ + "assetId": "rgb:good", "amount": 10, "recipientId": "utxob:me", + "donation": true, "minConfirmations": 3 + }) + ); +} + +#[test] +fn intent_binding_create_utxos_asserts_only_pinned_shape_fields() { + let run = |intent: serde_json::Value, params: &PrepareCreateUtxosParams| { + let transport = FakeTransport::with(vec![ok(200, prepared(intent))]); + client(&transport, Some("t")).prepare_create_utxos(params, None) + }; + let unpinned = PrepareCreateUtxosParams { + num: None, + size: None, + up_to: None, + fee_rate_sat_per_vb: None, + }; + let op = run(create_utxos_intent_json(), &unpinned).unwrap(); + let utxos = op.intent.utxos.unwrap(); + assert_eq!((utxos.up_to, utxos.num, utxos.size), (false, 4, 1_000)); + assert!(run( + create_utxos_intent_json(), + &PrepareCreateUtxosParams { + num: Some(4), + ..unpinned.clone() + } + ) + .is_ok()); + for (params, detail) in [ + ( + PrepareCreateUtxosParams { + num: Some(8), + ..unpinned.clone() + }, + "num 4 != 8", + ), + ( + PrepareCreateUtxosParams { + size: Some(32_000), + ..unpinned.clone() + }, + "size 1000 != 32000", + ), + ( + PrepareCreateUtxosParams { + up_to: Some(true), + ..unpinned.clone() + }, + "upTo false != true", + ), + ( + PrepareCreateUtxosParams { + fee_rate_sat_per_vb: Some(1), + ..unpinned.clone() + }, + "feeRateSatPerVb 2 != 1", + ), + ] { + assert_intent_mismatch(run(create_utxos_intent_json(), ¶ms), detail, detail); + } + let mut with_recipient = create_utxos_intent_json(); + with_recipient["recipients"] = + serde_json::json!([{ "address": ATTACKER, "scriptHex": "0014cafe", "amountSat": 1 }]); + assert_intent_mismatch( + run(with_recipient, &unpinned), + "create-utxos intent carries 1 recipient(s)", + "recipient on create-utxos", + ); + let mut with_asset = create_utxos_intent_json(); + with_asset["asset"] = send_asset_intent_json()["asset"].clone(); + assert_intent_mismatch( + run(with_asset, &unpinned), + "create-utxos intent carries an asset", + "asset on create-utxos", + ); + let mut no_shape = create_utxos_intent_json(); + no_shape["utxos"] = serde_json::Value::Null; + assert_intent_mismatch( + run(no_shape, &unpinned), + "create-utxos intent carries no utxo shape", + "shape dropped", + ); +} + +#[test] +fn gateway_prepared_intent_feeds_verify_and_sign_to_the_rgb_lib_txid() { + // End to end through the client: the bound intent the fake gateway + // returned for the fixture PSBT is the very value verify consumes, and + // signing it reproduces rgb-lib's txid; the signed PSBT then goes back + // through complete_send_btc. + let w = wallets(); + let expected_txid = fixture_str(&w.fixture["signing"]["txid"], "signing.txid"); + let transport = FakeTransport::with(vec![ + ok( + 200, + serde_json::json!({ + "opId": "op-fixture", "psbt": fixture_unsigned(&w), "expiresAt": 1, + "intent": fixture_send_btc_intent_json(&w) + }), + ), + ok(200, serde_json::json!({ "txid": expected_txid })), + ]); + let c = client(&transport, Some("t")); + let op = c + .prepare_send_btc( + &PrepareSendBtcParams { + address: w.foreign.address_at(false, 0, 0), + amount_sat: 40_000, + fee_rate_sat_per_vb: Some(2), + }, + None, + ) + .unwrap(); + assert_eq!(op.intent, send_btc_intent(&w)); + let signed = verify_and_sign_psbt(&w.ours.keys, &op.psbt, ¶ms(&w, op.intent, 2_000)) + .expect("bound intent verifies and signs"); + assert_eq!(signed.txid, expected_txid); + let completed = c + .complete_send_btc( + &CompleteParams { + op_id: op.op_id, + signed_psbt: signed.signed_psbt.clone(), + }, + None, + ) + .unwrap(); + assert_eq!(completed.txid, expected_txid); + let body = body_json(&transport.seen()[1]); + assert_eq!(body["opId"], "op-fixture"); + assert_eq!(body["signedPsbt"], signed.signed_psbt); +} + +#[test] +fn gateway_requests_never_carry_secrets_and_token_never_renders() { + let fixture = load_fixture(); + let mnemonic = fixture_mnemonic(&fixture); + let keys = ClientKeys::from_mnemonic(&mnemonic, BitcoinNetwork::Regtest).unwrap(); + let xpubs = keys.xpubs(); + let token = "tok-SECRET-bearer-8f2a"; + let operator = "op-SECRET-9c1d"; + + // One response per call, in the order below. + let transport = FakeTransport::with(vec![ + ok( + 201, + serde_json::json!({ "userId": "u", "token": token, "createdAt": 1 }), + ), + ok(200, serde_json::json!({ "userId": "u", "createdAt": 1 })), + ok( + 200, + serde_json::json!({ "fingerprint": xpubs.fingerprint, "address": "a" }), + ), + ok(200, serde_json::json!({ "address": "a" })), + ok( + 200, + serde_json::json!({ "btc": { "vanilla": { "settled": 0, "future": 0, "spendable": 0 }, "colored": { "settled": 0, "future": 0, "spendable": 0 } }, "assets": [] }), + ), + ok(200, serde_json::json!({ "unspents": [] })), + ok(200, serde_json::json!({ "transfers": [] })), + ok( + 200, + serde_json::json!({ "invoice": "i", "recipientId": "r", "expirationTimestamp": null, "mode": "witness" }), + ), + ok(200, serde_json::json!({ "status": "ok" })), + ok(200, prepared(send_btc_intent_json())), + ok(200, serde_json::json!({ "txid": "t" })), + ok(200, prepared(send_asset_intent_json())), + ok(200, serde_json::json!({ "txid": "t" })), + ok(200, prepared(create_utxos_intent_json())), + ok(200, serde_json::json!({ "txid": null, "utxosCreated": 0 })), + ok( + 200, + serde_json::json!({ "depositId": "d", "kind": "rgb", "address": null, "invoice": "i", "recipientId": "r" }), + ), + ok( + 200, + serde_json::json!({ "paymentHash": "h", "status": "failed" }), + ), + ok( + 200, + serde_json::json!({ "invoice": "i", "paymentHash": "h" }), + ), + ok( + 200, + serde_json::json!({ "paymentHash": "h", "invoice": "i", "state": "expired", "amtMsat": null, "assetId": null, "assetAmount": null, "createdAt": 1 }), + ), + ok(200, serde_json::json!({ "payments": [] })), + ok(200, serde_json::json!({ "btcMsat": 0, "assets": {} })), + ok(200, serde_json::json!({ "withdrawalId": "w", "txid": "t" })), + ]); + let c = client(&transport, Some(token)); + let created = c.create_user(operator).unwrap(); + assert_eq!(created.token, token); + // The one record carrying a credential redacts it in Debug. + let rendered = format!("{created:?}"); + assert!(!rendered.contains(token), "{rendered}"); + assert!(rendered.contains("[redacted]")); + c.me().unwrap(); + c.register_xpubs(&RegisterXpubsParams { + vanilla: xpubs.vanilla.clone(), + colored: xpubs.colored.clone(), + fingerprint: xpubs.fingerprint.clone(), + }) + .unwrap(); + c.get_address().unwrap(); + c.get_balances().unwrap(); + c.get_unspents().unwrap(); + c.get_transfers(Some("rgb:x")).unwrap(); + c.receive(&ReceiveParams { + mode: ReceiveMode::Witness, + asset_id: None, + amount: None, + duration_seconds: Some(60), + min_confirmations: None, + }) + .unwrap(); + c.sync().unwrap(); + c.prepare_send_btc(&send_btc_params(), None).unwrap(); + let complete = CompleteParams { + op_id: "op-1".into(), + signed_psbt: "cHNidP8=".into(), + }; + c.complete_send_btc(&complete, None).unwrap(); + c.prepare_send_asset(&send_asset_params(), None).unwrap(); + c.complete_send_asset(&complete, None).unwrap(); + c.prepare_create_utxos( + &PrepareCreateUtxosParams { + num: None, + size: None, + up_to: None, + fee_rate_sat_per_vb: None, + }, + None, + ) + .unwrap(); + c.complete_create_utxos(&complete, None).unwrap(); + c.prepare_ln_deposit( + &LnDepositPrepareParams { + kind: LnAssetKind::Rgb, + amount_msat: None, + asset_id: Some("rgb:x".into()), + amount: Some(1), + }, + None, + ) + .unwrap(); + c.pay_ln_invoice( + &LnPayParams { + invoice: "lnbcrt1...".into(), + amt_msat: Some(1), + asset_amount: None, + }, + None, + ) + .unwrap(); + c.create_ln_invoice(&LnInvoiceCreateParams { + amt_msat: None, + expiry_sec: Some(60), + asset_id: Some("rgb:x".into()), + asset_amount: Some(1), + description: None, + }) + .unwrap(); + c.get_ln_invoice("h").unwrap(); + c.list_ln_payments().unwrap(); + c.get_ln_balance().unwrap(); + c.withdraw_ln( + &LnWithdrawParams { + kind: LnAssetKind::Rgb, + address: None, + amount_sat: None, + asset_id: Some("rgb:x".into()), + amount: Some(1), + recipient_id: Some("bcrt:wvout:x".into()), + witness_amount_sat: Some(1_000), + transport_endpoints: Some(vec!["rpc://p".into()]), + fee_rate_sat_per_vb: Some(2), + }, + None, + ) + .unwrap(); + + let seen = transport.seen(); + assert_eq!(seen.len(), 22, "all 22 routes exercised"); + let mut registered_xpubs = 0; + for request in &seen { + // No body or header carries the mnemonic, a seed or an xprv/tprv. + let body = request.body.clone().unwrap_or_default(); + assert_no_secret(&body, &mnemonic, &format!("{} body", request.url)); + for (name, value) in &request.headers { + assert_no_secret(value, &mnemonic, &format!("{} header {name}", request.url)); + } + assert_no_secret(&request.url, &mnemonic, "url"); + // The bearer token travels in exactly one header of authenticated + // calls and nowhere else (never in a body, url or other header). + let bearer = format!("Bearer {token}"); + for (name, value) in &request.headers { + if name == "authorization" { + assert_eq!(value, &bearer); + } else { + assert!(!value.contains(token), "{name} carries the token"); + } + } + assert!(!body.contains(token) && !request.url.contains(token)); + if request.url.ends_with("/v1/wallet/xpubs") { + registered_xpubs += 1; + let sent = body_json(request); + assert_eq!(sent["vanilla"], xpubs.vanilla); + assert_eq!(sent["colored"], xpubs.colored); + assert_eq!(sent["fingerprint"], xpubs.fingerprint); + assert!(xpubs.vanilla.starts_with("tpub") && xpubs.colored.starts_with("tpub")); + } + // Debug output of a request redacts credential values. + let rendered = format!("{request:?}"); + assert!(!rendered.contains(token), "{rendered}"); + assert!(!rendered.contains(operator), "{rendered}"); + assert_no_secret(&rendered, &mnemonic, "request Debug"); + } + assert_eq!(registered_xpubs, 1); + + // The bearer token never renders in Debug or any error. + let rendered = format!("{c:?}"); + assert!(!rendered.contains(token), "{rendered}"); + assert!(rendered.contains("[redacted]"), "{rendered}"); + let transport = FakeTransport::with(vec![ + ok( + 401, + serde_json::json!({ "error": { "code": "UNAUTHORIZED", "message": "bad token" } }), + ), + Err(SdkError::Transport { + reason: "tls handshake failed".into(), + }), + ok(200, serde_json::json!({ "opId": 1 })), + ok(200, prepared(send_btc_intent_json())), + ok(200, serde_json::json!({})), + ]); + let c = client(&transport, Some(token)); + let errors = vec![ + c.me().unwrap_err(), + c.me().unwrap_err(), + c.prepare_send_btc(&send_btc_params(), None).unwrap_err(), + c.prepare_send_btc( + &PrepareSendBtcParams { + address: ATTACKER.into(), + ..send_btc_params() + }, + None, + ) + .unwrap_err(), + c.get_address().unwrap_err(), + client(&FakeTransport::with(vec![]), None).me().unwrap_err(), + ]; + assert!(errors + .iter() + .any(|e| matches!(e, SdkError::Gateway { status: 401, .. }))); + assert!(errors + .iter() + .any(|e| matches!(e, SdkError::Transport { .. }))); + assert!(errors + .iter() + .any(|e| matches!(e, SdkError::IntentMismatch { .. }))); + for error in errors { + for rendered in [format!("{error}"), format!("{error:?}")] { + assert!(!rendered.contains(token), "{rendered}"); + assert!(!rendered.contains(operator), "{rendered}"); + assert_no_secret(&rendered, &mnemonic, "gateway error"); + } + } +} + +#[test] +fn gateway_source_links_no_network_stack_and_reaches_no_key_material() { + let src = manifest_dir().join("src"); + let gateway_rs = fs::read_to_string(src.join("gateway.rs")).unwrap(); + // Code only: doc comments legitimately name the invariants they uphold. + let strip_comments = |text: &str| -> String { + text.split("#[cfg(test)]") + .next() + .unwrap() + .lines() + .filter(|l| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n") + }; + let body = strip_comments(&gateway_rs); + // The transport is a foreign trait: Rust performs no IO. + assert!(body.contains("#[uniffi::export(with_foreign)]\npub trait HttpTransport: Send + Sync")); + for forbidden in [ + "std::net", + "TcpStream", + "reqwest", + "ureq", + "hyper", + "rustls", + "openssl", + "tokio", + "async fn", + ".await", + "unwrap()", + "expect(", + "panic!", + "unreachable!", + // Key material is unreachable from this module by construction: + // it never names the mnemonic, seed or private-key types. + "ClientKeys", + "Xpriv", + "SecretKey", + "Mnemonic", + "mnemonic", + "seed", + ] { + assert!( + !body.contains(forbidden), + "gateway.rs contains {forbidden:?}" + ); + } + // json.rs is the only other module the client depends on and is just as + // strict: no panics on hostile input. + let json_rs = fs::read_to_string(src.join("json.rs")).unwrap(); + let json_body = strip_comments(&json_rs); + for forbidden in ["unwrap()", "expect(", "panic!", "unreachable!"] { + assert!( + !json_body.contains(forbidden), + "json.rs contains {forbidden:?}" + ); + } + // Idempotency keys come from the OS CSPRNG that bitcoin already links. + assert!(body.contains("OsRng")); + let manifest = fs::read_to_string(manifest_dir().join("Cargo.toml")).unwrap(); + for crate_name in ["uuid", "rand =", "serde_json =", "reqwest", "ureq"] { + let deps = manifest.split("[dev-dependencies]").next().unwrap(); + assert!( + !deps.contains(crate_name), + "Cargo.toml gained {crate_name:?}" + ); + } + // The uniffi surface exposes the client, the 22 routes and the key generator. + let route_exports = body.matches("#[uniffi::method(name = \"").count(); + assert_eq!(route_exports, 22, "one uniffi method per route"); + assert!(body.contains("#[uniffi::export(name = \"generate_idempotency_key\")]")); +} + +#[test] +fn gateway_hostile_bodies_never_panic() { + // Every response body here is either accepted or an SdkError, never a + // panic; the parser also survives deep nesting and junk bytes. + let bodies = [ + "", + "null", + "[]", + "{", + "{\"address\":", + "\u{0}", + "{\"address\":\"\\ud800\"}", + "{\"unspents\":[{\"txid\":1}]}", + "{\"transfers\":[{}]}", + "{\"btc\":null,\"assets\":[]}", + "{\"assets\":{\"rgb:x\":-1},\"btcMsat\":0}", + "{\"assets\":{\"rgb:x\":1.5},\"btcMsat\":0}", + "{\"intent\":{\"kind\":\"send_btc\",\"recipients\":[null]}}", + "{\"intent\":{\"kind\":\"send_btc\",\"recipients\":[{}],\"asset\":[],\"utxos\":{}}}", + "{\"opId\":\"o\",\"psbt\":\"p\",\"expiresAt\":-1,\"intent\":{\"kind\":\"send_btc\",\"feeRateSatPerVb\":2,\"recipients\":[{\"address\":\"bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080\",\"scriptHex\":\"00\",\"amountSat\":40000}],\"asset\":null,\"utxos\":null}}", + &"[".repeat(10_000), + &format!("{{\"assets\":{}}}", "[".repeat(100)), + ]; + for body in bodies { + let responses: Vec> = (0..7) + .map(|_| { + Ok(HttpResponse { + status: 200, + body: body.to_owned(), + }) + }) + .collect(); + let transport = FakeTransport::with(responses); + let c = client(&transport, Some("t")); + // None of these bodies is a valid response for the route it is fed + // to, so every call must be an explicit error (a malformed-response + // `Gateway` or an `IntentMismatch`), never a defaulted value. + let outcomes: Vec> = vec![ + c.get_address().map(|v| format!("{v:?}")), + c.get_balances().map(|v| format!("{v:?}")), + c.get_unspents().map(|v| format!("{v:?}")), + c.get_transfers(None).map(|v| format!("{v:?}")), + c.get_ln_balance().map(|v| format!("{v:?}")), + c.prepare_send_btc(&send_btc_params(), None) + .map(|v| format!("{v:?}")), + c.list_ln_payments().map(|v| format!("{v:?}")), + ]; + for outcome in outcomes { + assert!( + matches!( + &outcome, + Err(SdkError::Gateway { code, .. }) if code == "MALFORMED_RESPONSE" + ) || matches!(&outcome, Err(SdkError::IntentMismatch { .. })), + "body {body:?}: expected an explicit error, got {outcome:?}" + ); + } + } +} diff --git a/minimal-sdk/rust-sdk/uniffi.toml b/minimal-sdk/rust-sdk/uniffi.toml new file mode 100644 index 00000000..bde40565 --- /dev/null +++ b/minimal-sdk/rust-sdk/uniffi.toml @@ -0,0 +1,11 @@ +# Binding configuration for library-mode generation (no UDL file exists or +# may be added; every export is a proc macro in src/). +[bindings.kotlin] +package_name = "com.utexo.minimalsdk" +cdylib_name = "utexo_minimal_sdk" + +[bindings.swift] +module_name = "MinimalSdk" +ffi_module_name = "MinimalSdkFFI" +ffi_module_filename = "MinimalSdkFFI" +cdylib_name = "utexo_minimal_sdk" diff --git a/minimal-sdk/scripts/build_android.sh b/minimal-sdk/scripts/build_android.sh new file mode 100755 index 00000000..0c7e5a06 --- /dev/null +++ b/minimal-sdk/scripts/build_android.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# Cross-compile the minimal SDK core for Android and check the result. +# +# Usage: build_android.sh [--check-only] [jni_libs_dir] +# +# Modelled on scripts/ci/build_android_jni.sh (`cargo ndk` per ABI). The aws-lc +# cleanup that script performs is node-specific and deliberately NOT carried +# over: this crate has no aws-lc. +# +# After building (or with --check-only, on an existing jniLibs tree) this +# script enforces three release gates on every produced .so: +# +# 1. 16 KB page alignment: every PT_LOAD segment must be aligned to >= 16384. +# Android 15+ requires it for native code (Play deadline 2026-05-31, passed). +# A misaligned library is a release blocker; the fix is NDK r28+ (aligns by +# default) or `-Wl,-z,max-page-size=16384` on older NDKs. +# 2. Exports: every .so must export the uniffi entry points (a wrong +# crate-type or LTO dropping them links fine and fails at first call). +# 3. Size: the single arm64-v8a slice must stay under ANDROID_ARM64_BUDGET +# bytes (3.0 MB). Install size tracks this RAW number, not a compressed one. +# +# Environment: +# ANDROID_NDK_ROOT / ANDROID_NDK_HOME NDK to use (default: newest under +# $ANDROID_HOME/ndk or ~/Android/Sdk/ndk) +# ANDROID_API minSdk the .so targets (default 24) +# ANDROID_ARM64_BUDGET arm64-v8a raw-size budget in bytes +# (default 3000000) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +RUST_SDK_DIR="$REPO_ROOT/minimal-sdk/rust-sdk" +ANDROID_DIR="$REPO_ROOT/minimal-sdk/android" +LIB_NAME="libutexo_minimal_sdk.so" +ABIS=(arm64-v8a armeabi-v7a x86_64) +ANDROID_API="${ANDROID_API:-24}" +# Raw bytes for the arm64-v8a slice. The Rust size gate (tests/parity.rs) is +# 2.5 MB on the x86_64 host build; arm64 code is denser but the budget leaves +# room for the NDK's unwind tables and alignment padding. +ANDROID_ARM64_BUDGET="${ANDROID_ARM64_BUDGET:-3000000}" +REQUIRED_PAGE_ALIGN=16384 + +CHECK_ONLY=0 +if [[ "${1:-}" == "--check-only" ]]; then + CHECK_ONLY=1 + shift +fi +OUT_DIR="${1:-$ANDROID_DIR/generated/jniLibs}" + +die() { + echo "ERROR: $*" >&2 + exit 1 +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "required command '$1' not found" +} + +resolve_ndk() { + local candidate="${ANDROID_NDK_ROOT:-${ANDROID_NDK_HOME:-}}" + if [[ -z "$candidate" ]]; then + local sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}" + if [[ -d "$sdk/ndk" ]]; then + candidate="$(find "$sdk/ndk" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)" + fi + fi + [[ -n "$candidate" && -f "$candidate/source.properties" ]] || + die "no Android NDK found; install one with 'sdkmanager \"ndk;\"' or set ANDROID_NDK_ROOT" + echo "$candidate" +} + +NDK_ROOT="$(resolve_ndk)" +NDK_VERSION="$(sed -n 's/^Pkg.Revision = //p' "$NDK_ROOT/source.properties")" +NDK_BIN="$NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin" +if [[ ! -d "$NDK_BIN" ]]; then + NDK_BIN="$(find "$NDK_ROOT/toolchains/llvm/prebuilt" -mindepth 1 -maxdepth 1 -type d | head -n 1)/bin" +fi +READELF="$NDK_BIN/llvm-readelf" +[[ -x "$READELF" ]] || READELF="$(command -v llvm-readelf || command -v readelf || true)" +[[ -n "$READELF" ]] || die "no readelf available (expected $NDK_BIN/llvm-readelf)" +NM="$NDK_BIN/llvm-nm" +[[ -x "$NM" ]] || NM="$(command -v llvm-nm || command -v nm || true)" +[[ -n "$NM" ]] || die "no nm available (expected $NDK_BIN/llvm-nm)" + +# Portable file size in bytes (GNU stat -c and BSD stat -f differ; wc does not). +file_size() { + wc -c <"$1" | tr -d '[:space:]' +} + +if [[ "$CHECK_ONLY" == "0" ]]; then + need_cmd cargo + cargo ndk --version >/dev/null 2>&1 || die "cargo-ndk not installed: cargo install cargo-ndk" + export ANDROID_NDK_ROOT="$NDK_ROOT" + export ANDROID_NDK_HOME="$NDK_ROOT" + echo "Using NDK $NDK_VERSION at $NDK_ROOT (API $ANDROID_API)" + rm -rf "$OUT_DIR" + mkdir -p "$OUT_DIR" + # NDK r28+ links the 64-bit ABIs with 16 KB max-page-size by default but + # leaves 32-bit arm at 4 KB (measured on r29: armeabi-v7a PT_LOAD align + # 0x1000). Android's 16 KB page mode only exists on 64-bit devices, so that + # is not a runtime bug, but the gate below applies to EVERY shipped .so so + # the AAR is uniformly aligned. Ask the linker explicitly for all targets; + # for arm64/x86_64 this restates the default. + PAGE_ALIGN_FLAG="-C link-arg=-Wl,-z,max-page-size=$REQUIRED_PAGE_ALIGN" + export CARGO_TARGET_AARCH64_LINUX_ANDROID_RUSTFLAGS="${CARGO_TARGET_AARCH64_LINUX_ANDROID_RUSTFLAGS:-} $PAGE_ALIGN_FLAG" + export CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_RUSTFLAGS="${CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_RUSTFLAGS:-} $PAGE_ALIGN_FLAG" + export CARGO_TARGET_X86_64_LINUX_ANDROID_RUSTFLAGS="${CARGO_TARGET_X86_64_LINUX_ANDROID_RUSTFLAGS:-} $PAGE_ALIGN_FLAG" + # One invocation for all three ABIs; cargo-ndk lays the output out as + # //$LIB_NAME, the jniLibs layout Gradle expects. + ( + cd "$RUST_SDK_DIR" + cargo ndk \ + --platform "$ANDROID_API" \ + -t arm64-v8a -t armeabi-v7a -t x86_64 \ + -o "$OUT_DIR" \ + build --release --lib + ) +fi + +# ---- Gate 1: 16 KB page alignment on every PT_LOAD segment of every .so ---- +check_alignment() { + local so="$1" + local ok=1 + local found=0 + local align + # llvm-readelf -lW prints one " LOAD off vaddr paddr filesz memsz flags align" line per segment. + while read -r align; do + found=1 + align=$(( align )) # readelf prints hex (0x4000); bash arithmetic parses it + if (( align < REQUIRED_PAGE_ALIGN )); then + echo " FAIL $so: PT_LOAD align $align < $REQUIRED_PAGE_ALIGN" >&2 + ok=0 + fi + done < <("$READELF" -lW "$so" | awk '$1 == "LOAD" { print $NF }') + (( found == 1 )) || die "$so has no PT_LOAD segments (not an ELF shared object?)" + (( ok == 1 )) +} + +echo "Checking 16 KB page alignment (PT_LOAD align >= $REQUIRED_PAGE_ALIGN):" +ALIGN_OK=1 +for abi in "${ABIS[@]}"; do + so="$OUT_DIR/$abi/$LIB_NAME" + [[ -f "$so" ]] || die "missing $so" + if check_alignment "$so"; then + echo " ok $abi" + else + ALIGN_OK=0 + fi +done +(( ALIGN_OK == 1 )) || + die "16 KB alignment check failed (release blocker). Use NDK r28+ or add -Wl,-z,max-page-size=16384; NDK in use: $NDK_VERSION" + +# ---- Gate 2: the uniffi entry points are exported from every .so ----------- +# JNA resolves these by name at first call; the same check build_apple.sh +# runs on the iOS device slice. +ENTRY_POINT="uniffi_utexo_minimal_sdk_fn_func_sdk_version" +echo "Checking exported uniffi entry points ($ENTRY_POINT):" +for abi in "${ABIS[@]}"; do + so="$OUT_DIR/$abi/$LIB_NAME" + "$NM" -D --defined-only "$so" 2>/dev/null | awk '{ print $NF }' | grep -qx "$ENTRY_POINT" || + die "$so does not export $ENTRY_POINT (wrong crate-type or stripped exports?)" + echo " ok $abi" +done + +# ---- Gate 3: arm64 raw size budget, plus the numbers worth recording ---- +echo "Android library sizes (raw bytes; install size tracks raw, not compressed):" +for abi in "${ABIS[@]}"; do + so="$OUT_DIR/$abi/$LIB_NAME" + printf ' %-12s %10d bytes\n' "$abi" "$(file_size "$so")" +done +for aar in "$ANDROID_DIR"/build/outputs/aar/*.aar; do + [[ -f "$aar" ]] || continue + printf ' %-12s %10d bytes (%s)\n' "aar" "$(file_size "$aar")" "$(basename "$aar")" +done +jna_aar="$(find "${GRADLE_USER_HOME:-$HOME/.gradle}/caches/modules-2/files-2.1/net.java.dev.jna/jna/5.14.0" -name 'jna-5.14.0.aar' 2>/dev/null | head -n 1 || true)" +if [[ -n "$jna_aar" ]]; then + printf ' %-12s %10d bytes (net.java.dev.jna:jna:5.14.0@aar, consumer-side dependency)\n' "jna" "$(file_size "$jna_aar")" +fi + +ARM64_BYTES="$(file_size "$OUT_DIR/arm64-v8a/$LIB_NAME")" +if (( ARM64_BYTES >= ANDROID_ARM64_BUDGET )); then + die "arm64-v8a $LIB_NAME is $ARM64_BYTES bytes, over the $ANDROID_ARM64_BUDGET byte budget" +fi +echo "Android size gate: arm64-v8a $ARM64_BYTES bytes < $ANDROID_ARM64_BUDGET budget (NDK $NDK_VERSION)" +echo "Android JNI libs OK in $OUT_DIR" diff --git a/minimal-sdk/scripts/build_apple.sh b/minimal-sdk/scripts/build_apple.sh new file mode 100755 index 00000000..8e609951 --- /dev/null +++ b/minimal-sdk/scripts/build_apple.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Build the minimal SDK core for Apple platforms and package it as an +# XCFramework for the Swift package in minimal-sdk/apple. +# +# Usage: build_apple.sh +# +# Modelled on scripts/ci/package_swift_xcframework.sh. Steps: +# 1. cargo build --release --lib (staticlib) for the iOS device target, +# the two iOS simulator targets and the two macOS targets +# 2. generate_bindings.sh swift (library mode, from the unstripped host +# build); the header + modulemap land in +# minimal-sdk/apple/Sources/MinimalSdkFFI which doubles as the +# XCFramework headers directory +# 3. lipo the simulator slices into one universal .a, same for macOS +# 4. xcodebuild -create-xcframework with device + simulator + macOS +# libraries, each with the headers directory +# 5. sanity: the device slice exports the uniffi symbols; print sizes +# +# The macOS slice exists so `swift test` can run the smoke test natively on +# a macOS host; the iOS app links the device slice only. +# +# Sizes printed here are archive sizes and are NOT the number that matters: +# iOS links statically and dead-strips at app link, so the meaningful figure +# is the app-size delta. There is deliberately no size gate in this script; +# the Rust size gate (tests/parity.rs) and the Android arm64 gate +# (build_android.sh) bound the code that ends up in the app. +# +# Requires macOS with Xcode (xcodebuild, lipo, nm) and the five Rust targets +# (rustup target add ...). Not runnable on Linux; the Swift package's Linux +# branch links the host cdylib directly instead. +# +# Environment: +# IOS_DEPLOYMENT_TARGET minimum iOS (default 15.0, must match Package.swift) +# MACOS_DEPLOYMENT_TARGET minimum macOS (default 12.0, must match Package.swift) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +RUST_SDK_DIR="$REPO_ROOT/minimal-sdk/rust-sdk" +APPLE_DIR="$REPO_ROOT/minimal-sdk/apple" +LIB_BASENAME="libutexo_minimal_sdk.a" +FFI_MODULE="MinimalSdkFFI" +HEADERS_DIR="$APPLE_DIR/Sources/$FFI_MODULE" +BUILD_DIR="$APPLE_DIR/build" +XCFRAMEWORK="$APPLE_DIR/$FFI_MODULE.xcframework" +IOS_DEPLOYMENT_TARGET="${IOS_DEPLOYMENT_TARGET:-15.0}" +MACOS_DEPLOYMENT_TARGET="${MACOS_DEPLOYMENT_TARGET:-12.0}" + +IOS_DEVICE_TARGET=aarch64-apple-ios +IOS_SIM_TARGETS=(aarch64-apple-ios-sim x86_64-apple-ios) +MACOS_TARGETS=(aarch64-apple-darwin x86_64-apple-darwin) +ALL_TARGETS=("$IOS_DEVICE_TARGET" "${IOS_SIM_TARGETS[@]}" "${MACOS_TARGETS[@]}") + +[[ $# -eq 0 ]] || { + echo "usage: $0 (no arguments; see the header for environment variables)" >&2 + exit 1 +} + +die() { + echo "ERROR: $*" >&2 + exit 1 +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "required command '$1' not found" +} + +[[ "$(uname -s)" == "Darwin" ]] || + die "XCFramework packaging needs macOS + Xcode; on Linux use 'swift test' in minimal-sdk/apple against the host cdylib" +need_cmd cargo +need_cmd rustup +need_cmd xcodebuild +need_cmd lipo +need_cmd nm + +INSTALLED_TARGETS="$(rustup target list --installed)" +for t in "${ALL_TARGETS[@]}"; do + grep -qx "$t" <<<"$INSTALLED_TARGETS" || die "Rust target $t not installed: rustup target add $t" +done + +TARGET_DIR="${CARGO_TARGET_DIR:-$RUST_SDK_DIR/target}" + +# ---- 1. Static libraries ------------------------------------------------- +# The deployment targets are pinned so every object in the archive records +# the same minimum OS as Package.swift declares; an archive built for a +# newer minimum than the app fails at link with an unhelpful ld warning. +export IPHONEOS_DEPLOYMENT_TARGET="$IOS_DEPLOYMENT_TARGET" +export MACOSX_DEPLOYMENT_TARGET="$MACOS_DEPLOYMENT_TARGET" +echo "Building release static libraries (iOS >= $IOS_DEPLOYMENT_TARGET, macOS >= $MACOS_DEPLOYMENT_TARGET)" +for t in "${ALL_TARGETS[@]}"; do + echo " cargo build --release --lib --target $t" + cargo build --release --lib --manifest-path "$RUST_SDK_DIR/Cargo.toml" --target "$t" + [[ -f "$TARGET_DIR/$t/release/$LIB_BASENAME" ]] || die "missing $TARGET_DIR/$t/release/$LIB_BASENAME" +done + +# ---- 2. Swift bindings, header and modulemap --------------------------- +"$SCRIPT_DIR/generate_bindings.sh" swift +[[ -f "$HEADERS_DIR/$FFI_MODULE.h" && -f "$HEADERS_DIR/module.modulemap" ]] || + die "generate_bindings.sh swift did not produce $HEADERS_DIR/{$FFI_MODULE.h,module.modulemap}" + +# ---- 3. Universal simulator and macOS archives --------------------------- +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR/ios-simulator" "$BUILD_DIR/macos" +SIM_INPUTS=() +for t in "${IOS_SIM_TARGETS[@]}"; do + SIM_INPUTS+=("$TARGET_DIR/$t/release/$LIB_BASENAME") +done +lipo -create "${SIM_INPUTS[@]}" -output "$BUILD_DIR/ios-simulator/$LIB_BASENAME" +MAC_INPUTS=() +for t in "${MACOS_TARGETS[@]}"; do + MAC_INPUTS+=("$TARGET_DIR/$t/release/$LIB_BASENAME") +done +lipo -create "${MAC_INPUTS[@]}" -output "$BUILD_DIR/macos/$LIB_BASENAME" + +# ---- 4. XCFramework ------------------------------------------------------ +DEVICE_LIB="$TARGET_DIR/$IOS_DEVICE_TARGET/release/$LIB_BASENAME" +rm -rf "$XCFRAMEWORK" +xcodebuild -create-xcframework \ + -library "$DEVICE_LIB" -headers "$HEADERS_DIR" \ + -library "$BUILD_DIR/ios-simulator/$LIB_BASENAME" -headers "$HEADERS_DIR" \ + -library "$BUILD_DIR/macos/$LIB_BASENAME" -headers "$HEADERS_DIR" \ + -output "$XCFRAMEWORK" + +# ---- 5. Sanity and numbers ---------------------------------------------- +# An archive that lost its exported uniffi entry points (wrong crate-type, +# LTO dropping them) links fine and fails at first call; catch it here. +nm -g "$DEVICE_LIB" 2>/dev/null | grep -q "_uniffi_utexo_minimal_sdk_fn_func_sdk_version" || + die "$DEVICE_LIB does not export the uniffi entry points" + +echo "Apple static library sizes (archive bytes; NOT install size, see README):" +for t in "${ALL_TARGETS[@]}"; do + printf ' %-24s %10d bytes\n' "$t" "$(stat -f %z "$TARGET_DIR/$t/release/$LIB_BASENAME")" +done +printf ' %-24s %10d bytes (lipo)\n' "ios-simulator universal" "$(stat -f %z "$BUILD_DIR/ios-simulator/$LIB_BASENAME")" +printf ' %-24s %10d bytes (lipo)\n' "macos universal" "$(stat -f %z "$BUILD_DIR/macos/$LIB_BASENAME")" +echo "Packaged $XCFRAMEWORK" diff --git a/minimal-sdk/scripts/generate_bindings.sh b/minimal-sdk/scripts/generate_bindings.sh new file mode 100755 index 00000000..88c576f0 --- /dev/null +++ b/minimal-sdk/scripts/generate_bindings.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Generate Kotlin or Swift bindings for the minimal SDK core in uniffi +# LIBRARY MODE: the crate is proc-macro only, there is no UDL file and none +# may be added (docs/plans/20260915-minimal-mobile-rust-sdk.md, Task 1). +# +# Usage: generate_bindings.sh [output_dir] [library_path] +# +# Library mode reads the UNIFFI_META_* symbols from the built library's +# symbol table. The release profile strips symbols, so bindings are generated +# from an UNSTRIPPED host DEBUG build (`cargo build --lib`). The metadata is +# profile- and target-independent, so the output matches the stripped release +# and the cross-compiled Android/iOS libraries exactly. +# +# Output layout: +# kotlin /com/utexo/minimalsdk/utexo_minimal_sdk.kt +# (default output_dir: minimal-sdk/android/generated/kotlin) +# swift /MinimalSdk.swift the Swift sources +# /MinimalSdkFFI.h the C header +# /module.modulemap the modulemap +# (default output_dir: minimal-sdk/apple/Sources/MinimalSdk/Generated; +# ffi_dir is always minimal-sdk/apple/Sources/MinimalSdkFFI) +# +# library_path defaults to an unstripped host debug build made here; pass a +# path to reuse an existing build instead. +# +# The Swift header and modulemap are split out of the Swift source directory +# because they serve two masters that must see the same bytes: on Apple +# platforms build_apple.sh hands to `xcodebuild -create-xcframework` +# as the XCFramework's headers; on Linux Package.swift points a systemLibrary +# target at it so `swift test` can run the smoke test against the host cdylib. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +RUST_SDK_DIR="$REPO_ROOT/minimal-sdk/rust-sdk" +APPLE_DIR="$REPO_ROOT/minimal-sdk/apple" +BINDGEN_MANIFEST="$REPO_ROOT/bindings/uniffi-bindgen/Cargo.toml" +CONFIG_PATH="$RUST_SDK_DIR/uniffi.toml" +CRATE_NAME="utexo_minimal_sdk" +SWIFT_MODULE="MinimalSdk" +SWIFT_FFI_MODULE="MinimalSdkFFI" + +LANGUAGE="${1:-}" +if [[ "$LANGUAGE" != "kotlin" && "$LANGUAGE" != "swift" ]]; then + echo "usage: $0 [output_dir] [library_path]" >&2 + exit 1 +fi + +OUT_DIR="${2:-}" +if [[ -z "$OUT_DIR" ]]; then + case "$LANGUAGE" in + kotlin) OUT_DIR="$REPO_ROOT/minimal-sdk/android/generated/kotlin" ;; + swift) OUT_DIR="$APPLE_DIR/Sources/$SWIFT_MODULE/Generated" ;; + esac +fi +SWIFT_FFI_DIR="$APPLE_DIR/Sources/$SWIFT_FFI_MODULE" + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "ERROR: required command '$1' not found" >&2 + exit 1 + } +} +need_cmd cargo + +# Guard the library-mode contract: a UDL file would silently take precedence +# in the node's scripts and would drift from the proc-macro surface here. +if compgen -G "$RUST_SDK_DIR/src/*.udl" >/dev/null; then + echo "ERROR: $RUST_SDK_DIR/src contains a .udl file; the minimal SDK is proc-macro only" >&2 + exit 1 +fi + +LIB_PATH="${3:-}" +if [[ -z "$LIB_PATH" ]]; then + echo "Building unstripped host library for uniffi metadata..." + cargo build --lib --manifest-path "$RUST_SDK_DIR/Cargo.toml" + TARGET_DIR="${CARGO_TARGET_DIR:-$RUST_SDK_DIR/target}" + for candidate in \ + "$TARGET_DIR/debug/lib${CRATE_NAME}.so" \ + "$TARGET_DIR/debug/lib${CRATE_NAME}.dylib"; do + if [[ -f "$candidate" ]]; then + LIB_PATH="$candidate" + break + fi + done +fi +if [[ -z "$LIB_PATH" || ! -f "$LIB_PATH" ]]; then + echo "ERROR: built library not found (pass it as the 3rd argument)" >&2 + exit 1 +fi + +rm -rf "$OUT_DIR" +mkdir -p "$OUT_DIR" + +cargo run --quiet --manifest-path "$BINDGEN_MANIFEST" -- \ + generate "$LIB_PATH" \ + --library \ + --crate "$CRATE_NAME" \ + --language "$LANGUAGE" \ + --config "$CONFIG_PATH" \ + -o "$OUT_DIR" + +case "$LANGUAGE" in + kotlin) + GENERATED="$OUT_DIR/com/utexo/minimalsdk/${CRATE_NAME}.kt" + ;; + swift) + GENERATED="$OUT_DIR/$SWIFT_MODULE.swift" + ;; +esac +if [[ ! -f "$GENERATED" ]]; then + echo "ERROR: expected generated file missing: $GENERATED" >&2 + exit 1 +fi + +if [[ "$LANGUAGE" == "swift" ]]; then + HEADER="$OUT_DIR/$SWIFT_FFI_MODULE.h" + MODULEMAP="$OUT_DIR/$SWIFT_FFI_MODULE.modulemap" + for f in "$HEADER" "$MODULEMAP"; do + [[ -f "$f" ]] || { + echo "ERROR: expected generated file missing: $f" >&2 + exit 1 + } + done + # A systemLibrary target and an XCFramework headers directory both want + # the modulemap under its canonical name, next to the header it declares. + rm -rf "$SWIFT_FFI_DIR" + mkdir -p "$SWIFT_FFI_DIR" + mv "$HEADER" "$SWIFT_FFI_DIR/$SWIFT_FFI_MODULE.h" + mv "$MODULEMAP" "$SWIFT_FFI_DIR/module.modulemap" + echo "Swift FFI header and modulemap in $SWIFT_FFI_DIR" +fi + +echo "Generated $LANGUAGE bindings from $LIB_PATH into $OUT_DIR" diff --git a/minimal-sdk/scripts/preflight-a.cjs b/minimal-sdk/scripts/preflight-a.cjs new file mode 100644 index 00000000..8a80a10b --- /dev/null +++ b/minimal-sdk/scripts/preflight-a.cjs @@ -0,0 +1,187 @@ +// Preflight A (Task 1, ran green 2026-09-09): verify @utexo/rgb-lib@0.3.0-beta.18 +// supports a watch-only (mnemonic-less) wallet for: getAddress, listUnspents, +// blindReceive, witnessReceive, createUtxosBegin/End, sendBegin/End — signing done +// by a separate wallet instance standing in for the client-side signer. +// +// NOTE: the published wrapper.js is stale (1-arg new_wallet, 6-arg sendBegin); +// this script calls the native module directly with the compiled signatures. +// See minimal-sdk/README.md ("rgb-lib binding status") for the marshalling rules. +// +// To run: bring up regtest (README), then from minimal-sdk/ (which ships the +// dependency): `node scripts/preflight-a.cjs`. Linux-x64 only (the require +// below loads the linux-x64 native package directly). +// Task 6 reuses this script to generate rgb-lib derivation fixtures. +const lib = require('@utexo/rgb-lib-linux-x64/rgblib'); +const fs = require('fs'); +const { execSync } = require('child_process'); + +const ELECTRUM = 'localhost:50001'; +const PROXY = 'rpc://localhost:3000/json-rpc'; +const path = require('path'); +const RLN_REPO = path.join(__dirname, '..', '..'); +const BASE = '/tmp/preflight-rgb-lib/run'; + +fs.rmSync(BASE, { recursive: true, force: true }); +for (const d of ['watch', 'signer', 'recipient']) fs.mkdirSync(`${BASE}/${d}`, { recursive: true }); + +const sh = (cmd) => execSync(cmd, { cwd: RLN_REPO, encoding: 'utf8' }).trim(); +const step = (msg) => console.log(`\n== ${msg}`); + +const wd = (dir) => + JSON.stringify({ + dataDir: dir, + bitcoinNetwork: 'Regtest', + databaseType: 'Sqlite', + maxAllocationsPerUtxo: '1', + accountXpubVanilla: k1.accountXpubVanilla, + accountXpubColored: k1.accountXpubColored, + vanillaKeychain: null, + supportedSchemas: ['Nia', 'Cfa', 'Uda', 'Ifa'], + }); +const keysJson = (k, withMnemonic) => + JSON.stringify({ + accountXpubVanilla: k.accountXpubVanilla, + accountXpubColored: k.accountXpubColored, + vanillaKeychain: null, + masterFingerprint: k.masterFingerprint, + mnemonic: withMnemonic ? k.mnemonic : null, + }); + +step('generate keys (two users)'); +const k1 = JSON.parse(lib.rgblib_generate_keys('Regtest')); +const k2 = JSON.parse(lib.rgblib_generate_keys('Regtest')); +console.log('k1 fingerprint:', k1.masterFingerprint); + +step('construct wallets: watch-only (server), signer (client stand-in), recipient'); +const W = lib.rgblib_new_wallet(wd(`${BASE}/watch`), keysJson(k1, false)); +const S = lib.rgblib_new_wallet(wd(`${BASE}/signer`), keysJson(k1, true)); +const wdR = JSON.stringify({ + dataDir: `${BASE}/recipient`, + bitcoinNetwork: 'Regtest', + databaseType: 'Sqlite', + maxAllocationsPerUtxo: '1', + accountXpubVanilla: k2.accountXpubVanilla, + accountXpubColored: k2.accountXpubColored, + vanillaKeychain: null, + supportedSchemas: ['Nia', 'Cfa', 'Uda', 'Ifa'], +}); +const R = lib.rgblib_new_wallet(wdR, keysJson(k2, true)); +console.log('wallets constructed OK (watch-only construction: PASS)'); + +step('goOnline'); +const onW = lib.rgblib_go_online(W, false, ELECTRUM); +const onR = lib.rgblib_go_online(R, false, ELECTRUM); +console.log('online OK'); + +step('getAddress (watch-only)'); +const addrW = lib.rgblib_get_address(W); +const addrR = lib.rgblib_get_address(R); +console.log('addrW:', addrW); + +step('fund both wallets from regtest miner'); +sh(`./regtest.sh sendtoaddress ${addrW} 1`); +sh(`./regtest.sh sendtoaddress ${addrR} 1`); +sh(`./regtest.sh mine 1`); + +step('listUnspents (watch-only, with sync)'); +const unspents = JSON.parse(lib.rgblib_list_unspents(W, onW, false, false)); +console.log('watch-only unspents:', unspents.length); +if (unspents.length < 1) throw new Error('no unspents after funding'); + +step('createUtxosBegin (watch-only) -> external sign -> createUtxosEnd'); +const cuPsbt = lib.rgblib_create_utxos_begin(W, onW, false, '5', '10000', '2', false); +console.log('unsigned PSBT (base64) length:', cuPsbt.length); +const cuSigned = lib.rgblib_sign_psbt(S, cuPsbt); +const cuCount = lib.rgblib_create_utxos_end(W, onW, cuSigned, false); +console.log('createUtxos count:', cuCount, '(PASS)'); +sh(`./regtest.sh mine 1`); + +step('recipient createUtxos (full wallet, internal signing)'); +lib.rgblib_create_utxos(R, onR, false, '5', '10000', '2', false); +sh(`./regtest.sh mine 1`); + +step('issueAssetNIA on watch-only wallet'); +const asset = JSON.parse( + lib.rgblib_issue_asset_nia(W, 'PRE', 'Preflight', '0', JSON.stringify(['1000'])), +); +const assetId = asset.assetId; +console.log('assetId:', assetId); + +step('blindReceive + witnessReceive on watch-only wallet'); +const exp = String(Math.floor(Date.now() / 1000) + 86400); // SWIG rejects JS null for char*: optionals must be materialized +const blindW = JSON.parse( + lib.rgblib_blind_receive( + W, + assetId, + JSON.stringify({ Fungible: 1 }), + exp, + JSON.stringify([PROXY]), + '1', + ), +); +console.log('blindReceive OK:', blindW.recipientId); +const witnessW = JSON.parse( + lib.rgblib_witness_receive( + W, + assetId, + JSON.stringify({ Fungible: 1 }), + exp, + JSON.stringify([PROXY]), + '1', + ), +); +console.log('witnessReceive OK:', witnessW.recipientId); + +step('recipient blindReceive (target for the send)'); +// recipient does not know the asset yet -> asset-agnostic invoice (assetId null works via SWIG) +const blindR = JSON.parse( + lib.rgblib_blind_receive( + R, + null, + JSON.stringify({ Fungible: 100 }), + exp, + JSON.stringify([PROXY]), + '1', + ), +); +console.log('recipient blind invoice:', blindR.invoice.slice(0, 60), '...'); + +step('sendBegin (watch-only) -> external sign -> sendEnd'); +const recipientMap = { + [assetId]: [ + { + recipientId: blindR.recipientId, + witnessData: null, + assignment: { Fungible: 100 }, + transportEndpoints: [PROXY], + }, + ], +}; +// NB: last arg is dry_run (not skip_sync); result is JSON-serialized (unlike create_utxos_begin) +const sendBeginRaw = lib.rgblib_send_begin( + W, + onW, + JSON.stringify(recipientMap), + false, + '2', + '1', + exp, + false, +); +const sendBeginRes = JSON.parse(sendBeginRaw); +const sendPsbt = typeof sendBeginRes === 'string' ? sendBeginRes : sendBeginRes.psbt; +console.log('send_begin result type:', typeof sendBeginRes, '| PSBT length:', sendPsbt.length); +const sendSigned = lib.rgblib_sign_psbt(S, sendPsbt); +const sendResult = JSON.parse(lib.rgblib_send_end(W, onW, sendSigned, false)); +console.log('sendEnd OK, txid:', sendResult.txid, '(PASS)'); + +step('settle: refresh both sides, mine, refresh again'); +lib.rgblib_refresh(R, onR, null, JSON.stringify([]), false); +lib.rgblib_refresh(W, onW, null, JSON.stringify([]), false); +sh(`./regtest.sh mine 1`); +lib.rgblib_refresh(R, onR, null, JSON.stringify([]), false); +lib.rgblib_refresh(W, onW, null, JSON.stringify([]), false); +const balR = JSON.parse(lib.rgblib_get_asset_balance(R, assetId)); +console.log('recipient asset balance:', JSON.stringify(balR)); + +console.log('\nPREFLIGHT A: ALL CHECKS PASSED'); diff --git a/minimal-sdk/tsconfig.base.json b/minimal-sdk/tsconfig.base.json new file mode 100644 index 00000000..3ead9986 --- /dev/null +++ b/minimal-sdk/tsconfig.base.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true + } +} From 8eebc40a44ff519bd21c818a7fbe7c11d3275e8c Mon Sep 17 00:00:00 2001 From: rmn-boiko Date: Tue, 15 Sep 2026 18:13:53 +0200 Subject: [PATCH 3/9] Clean workflow --- .github/workflows/minimal-sdk-mobile.yaml | 43 ++--------------------- 1 file changed, 2 insertions(+), 41 deletions(-) diff --git a/.github/workflows/minimal-sdk-mobile.yaml b/.github/workflows/minimal-sdk-mobile.yaml index cd57c893..efa3c985 100644 --- a/.github/workflows/minimal-sdk-mobile.yaml +++ b/.github/workflows/minimal-sdk-mobile.yaml @@ -48,46 +48,7 @@ env: jobs: # --------------------------------------------------------------------------- - # Job 1: the real gate. Full parity + adversarial suite, the release-size - # gate and the dependency-creep guard (tests/parity.rs). Host only, no NDK. - # --------------------------------------------------------------------------- - rust-core: - name: Rust core (parity, size gate, dependency guard) - runs-on: warp-ubuntu-latest-x64-16x - timeout-minutes: 20 - steps: - - name: Checkout repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - - name: Setup Rust - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - components: rustfmt, clippy - rustflags: "" - # The crate is its own workspace; the action's built-in cache keys - # on the root Cargo.lock, so cache explicitly below instead. - cache: false - - - name: Cache cargo registry and build - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - workspaces: minimal-sdk/rust-sdk -> target - key: rust-core - - - name: Format check - run: cargo fmt --manifest-path "$RUST_SDK_MANIFEST" --check - - - name: Clippy - run: cargo clippy --locked --manifest-path "$RUST_SDK_MANIFEST" --all-targets -- -D warnings - - - name: Test (parity suite, size gate, dependency-creep guard) - # --locked: Cargo.lock is the resolved graph the dependency-creep - # guard inspects; a silent re-resolution would defeat it. - run: cargo test --locked --manifest-path "$RUST_SDK_MANIFEST" - - # --------------------------------------------------------------------------- - # Job 2: Android. NDK, cargo ndk for the three ABIs, Kotlin bindings, + # Job 1: Android. NDK, cargo ndk for the three ABIs, Kotlin bindings, # ./gradlew test (JVM smoke test through JNA against the host cdylib), # then assembleRelease, which runs minimal-sdk/scripts/build_android.sh # (16 KB PT_LOAD alignment on every .so, exported uniffi entry points, @@ -204,7 +165,7 @@ jobs: minimal-sdk/android/generated/kotlin/** # --------------------------------------------------------------------------- - # Job 3: Apple. This is the job that first proves Task 9: the plan's dev + # Job 2: Apple. This is the job that first proves Task 9: the plan's dev # machine has no Xcode, so the static libraries, lipo, the XCFramework and # the Swift smoke test on the real XCFramework were never run locally. # build_apple.sh builds release staticlibs for the iOS device target, the From 7f318a80372ae3bdc7e2f54cd6cd4b7b5d734d9d Mon Sep 17 00:00:00 2001 From: rmn-boiko Date: Fri, 18 Sep 2026 15:12:56 +0200 Subject: [PATCH 4/9] Add operations get endpoit --- minimal-sdk/packages/client-sdk/README.md | 6 + .../packages/client-sdk/src/gateway.ts | 33 +++++ minimal-sdk/packages/client-sdk/src/index.ts | 1 + .../packages/client-sdk/test/gateway.test.ts | 32 +++++ minimal-sdk/packages/gateway/README.md | 94 +++++++++---- .../packages/gateway/src/routes/onchain.ts | 18 +++ .../packages/gateway/src/schemas/onchain.ts | 42 ++++++ .../packages/gateway/src/wallets/prepare.ts | 61 +++++++++ .../gateway/test/onchain-routes.test.ts | 114 ++++++++++++++++ minimal-sdk/rust-sdk/README.md | 48 +++++-- minimal-sdk/rust-sdk/src/gateway.rs | 86 ++++++++++++ minimal-sdk/rust-sdk/src/lib.rs | 2 +- minimal-sdk/rust-sdk/src/verify.rs | 10 ++ minimal-sdk/rust-sdk/tests/parity.rs | 126 +++++++++++++++++- 14 files changed, 632 insertions(+), 41 deletions(-) diff --git a/minimal-sdk/packages/client-sdk/README.md b/minimal-sdk/packages/client-sdk/README.md index cf298747..4809012c 100644 --- a/minimal-sdk/packages/client-sdk/README.md +++ b/minimal-sdk/packages/client-sdk/README.md @@ -65,6 +65,12 @@ const { txid } = await client.completeSendBtc({ opId: prepared.opId, signedPsbt const paid = await client.payLnInvoice({ invoice: 'lnbc…' }, generateIdempotencyKey()); ``` +If a `complete` call is lost — a timeout, a crash, or a 502 `COMPLETE_AMBIGUOUS` — do not +prepare a replacement. Read the operation back instead: `getOnchainOperation(opId)` is an +unqueued, key-free read, so it answers even while the completion is still running. A +`mayHaveBroadcast: true` response means the transaction may already be on the network; +retry `complete` to finish the bookkeeping rather than sending again. + Asset sends (`prepareSendAsset`/`completeSendAsset`) and colorable-UTXO creation (`prepareCreateUtxos`/`completeCreateUtxos`) follow the same prepare → verify-and-sign → complete shape. LN float deposits (`prepareLnDeposit`), invoices (`createLnInvoice`, diff --git a/minimal-sdk/packages/client-sdk/src/gateway.ts b/minimal-sdk/packages/client-sdk/src/gateway.ts index fa6f5658..93eb9628 100644 --- a/minimal-sdk/packages/client-sdk/src/gateway.ts +++ b/minimal-sdk/packages/client-sdk/src/gateway.ts @@ -130,6 +130,27 @@ export interface CompleteParams { signedPsbt: string; } +/** + * Durable state of a prepared on-chain operation, for recovery after the + * outcome of `complete` was lost (timeout, crash, 502 COMPLETE_AMBIGUOUS). + * + * `mayHaveBroadcast` is the field that matters after a failed complete: the + * gateway's wallet broadcasts before it finishes its bookkeeping, so a txid can + * be recorded on an operation that never completed. When it is true the + * transaction may already be on the network — retry `complete` (which is safe + * and finishes the bookkeeping) rather than preparing a second operation. + */ +export interface OperationStatus { + opId: string; + kind: OnchainIntent['kind']; + state: 'pending' | 'completed' | 'expired'; + txid: string | null; + mayHaveBroadcast: boolean; + intent: OnchainIntent; + createdAt: number; + expiresAt: number; +} + /** A prepare response whose intent summary contradicts the request that produced it. */ export class IntentMismatchError extends Error { constructor(readonly mismatches: string[]) { @@ -526,6 +547,18 @@ export class GatewayClient { return this.post('/v1/onchain/create-utxos/complete', params, idempotencyKey); } + /** + * Read back a prepared operation. Safe to poll: it is a read, takes no + * idempotency key, and is not queued behind the gateway's wallet work, so it + * answers even while a `complete` for the same user is still running. + */ + getOnchainOperation(opId: string): Promise { + return this.request({ + method: 'GET', + path: `/v1/onchain/operations/${encodeURIComponent(opId)}`, + }); + } + /** Pin the idempotency key to retry a prepare without minting a second intent. */ prepareLnDeposit( params: LnDepositPrepareParams, diff --git a/minimal-sdk/packages/client-sdk/src/index.ts b/minimal-sdk/packages/client-sdk/src/index.ts index dd05bbae..21d458b9 100644 --- a/minimal-sdk/packages/client-sdk/src/index.ts +++ b/minimal-sdk/packages/client-sdk/src/index.ts @@ -68,6 +68,7 @@ export { type LnPayResult, type LnWithdrawParams, type LnWithdrawResult, + type OperationStatus, type PreparedOp, type PrepareCreateUtxosParams, type PrepareSendAssetParams, diff --git a/minimal-sdk/packages/client-sdk/test/gateway.test.ts b/minimal-sdk/packages/client-sdk/test/gateway.test.ts index 3e2b3d33..3e665680 100644 --- a/minimal-sdk/packages/client-sdk/test/gateway.test.ts +++ b/minimal-sdk/packages/client-sdk/test/gateway.test.ts @@ -207,6 +207,38 @@ describe('GatewayClient', () => { await expect(client.me()).rejects.toMatchObject({ code: 'NETWORK_ERROR' }); }); + it('getOnchainOperation reads the op route with no idempotency key', async () => { + const opId = '11111111-2222-4333-8444-555555555555'; + const intent = { + kind: 'send_btc' as const, + feeRateSatPerVb: 2, + recipients: [], + asset: null, + utxos: null, + }; + const { seen, fetchFn } = stubFetch([ + jsonResponse(200, { + opId, + kind: 'send_btc', + state: 'pending', + txid: 'd'.repeat(64), + mayHaveBroadcast: true, + intent, + createdAt: 1, + expiresAt: 2, + }), + ]); + const client = new GatewayClient({ baseUrl: 'http://gw.local', token: 't', fetchFn }); + const status = await client.getOnchainOperation(opId); + expect(seen[0]?.method).toBe('GET'); + expect(seen[0]?.url).toBe(`http://gw.local/v1/onchain/operations/${opId}`); + // A read: polling it must never consume or require an idempotency key. + expect(seen[0]?.headers['idempotency-key']).toBeUndefined(); + // The recovery signal after a lost complete. + expect(status.mayHaveBroadcast).toBe(true); + expect(status.txid).toBe('d'.repeat(64)); + }); + it('LN money-moving calls (pay, withdraw) carry an idempotency key; reads do not', async () => { const { seen, fetchFn } = stubFetch([ jsonResponse(200, { paymentHash: 'a'.repeat(64), status: 'pending' }), diff --git a/minimal-sdk/packages/gateway/README.md b/minimal-sdk/packages/gateway/README.md index ee1bcaad..3f867ca6 100644 --- a/minimal-sdk/packages/gateway/README.md +++ b/minimal-sdk/packages/gateway/README.md @@ -73,6 +73,7 @@ auto-retries a send. | POST | `/v1/onchain/send-asset/complete` | bearer | required | | POST | `/v1/onchain/create-utxos/prepare` | bearer | required | | POST | `/v1/onchain/create-utxos/complete` | bearer | required | +| GET | `/v1/onchain/operations/:opId` | bearer | — | | POST | `/v1/ln/deposit/prepare` | bearer | required | | POST | `/v1/ln/pay` | bearer | required | | POST | `/v1/ln/invoice` | bearer | — | @@ -85,32 +86,73 @@ auto-retries a send. Every error response is `{"error": {"code", "message"}}`. Notable codes: -| Code | Status | Meaning | -| ---------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `QUEUE_FULL` | 429 | Per-user queue depth exceeded; a `retry-after` header accompanies it. | -| `AMOUNT_REQUIRED` | 400 | BTC invoices must declare `amtMsat`, asset invoices `assetAmount` (their `amtMsat` HTLC carrier defaults to `GATEWAY_ASSET_INVOICE_MIN_MSAT`), and paying an amount-less invoice requires `amtMsat` (a float-cap bypass). | -| `AMOUNT_MISMATCH` | 400 | `amtMsat` or `assetAmount` conflicts with the amount encoded in the invoice. | -| `AMOUNT_BELOW_MINIMUM` | 400 | An asset invoice's `amtMsat` HTLC carrier value is under `GATEWAY_ASSET_INVOICE_MIN_MSAT`; the node would refuse it. | -| `INVOICE_REJECTED` | 400 | The node rejected these invoice parameters. Detail stays server-side. | -| `INVALID_INVOICE` | 400 | The BOLT11 could not be decoded, or its amount is zero / outside the safe-integer range. | -| `PAYMENT_REJECTED` / `WITHDRAW_REJECTED` | 400 | The node rejected this payment or withdrawal (an RLN 4xx). The debit is refunded. Detail stays server-side. | -| `WITNESS_AMOUNT_REQUIRED` | 400 | An RGB withdraw to a `wvout:` recipient needs `witnessAmountSat` (rgb-lib rejects a witness beneficiary without it). The node funds that output, so its sats are debited from the caller's msat float. | -| `TRANSPORT_ENDPOINT_NOT_ALLOWED` | 400 | Consignment endpoint outside `GATEWAY_RGB_TRANSPORT_ALLOWLIST` (SSRF guard). | -| `WALLET_REQUEST_REJECTED` | 400 | rgb-lib rejected the request itself — bad recipient id, asset id, address, fee rate or transport endpoint. Detail stays server-side. | -| `INSUFFICIENT_FUNDS` | 400 | Not enough confirmed bitcoins, assets or allocation slots for an on-chain operation. | -| `PSBT_REJECTED` / `PSBT_MISMATCH` | 400 | The signed PSBT was rejected, or does not correspond to the prepared operation. | -| `FLOAT_CAP_EXCEEDED` | 409 | Per-user or global float cap would be exceeded by this new exposure. | -| `INSUFFICIENT_BALANCE` | 409 | The user's ledger balance is short. | -| `IDEMPOTENCY_KEY_REUSED` | 409 | Same key, different request body. | -| `IDEMPOTENCY_IN_FLIGHT` | 409 | A request with this key is still running. | -| `PAYMENT_CONFLICT` | 409 | Another user is already paying this invoice. | -| `PAYMENT_ALREADY_ATTEMPTED` | 409 | This invoice already failed and was refunded — request a fresh invoice rather than re-paying an old one. | -| `WITHDRAWAL_ALREADY_ATTEMPTED` | 409 | A withdrawal under this key failed and was refunded — use a fresh key. (A rejection answered 4xx is instead replayed from the idempotency cache.) | -| `WITHDRAWAL_UNRESOLVED` | 409 | A withdrawal under this key is unresolved and may have broadcast; operator resolution required (see Known limitations). | -| `DEPOSIT_ADDRESS_CONFLICT` | 502 | RLN returned a deposit address (or RGB `recipient_id`) already assigned to another intent — it is misconfigured (see the deployment note). | -| `OP_EXPIRED` | 410 | The prepared on-chain operation passed `GATEWAY_ONCHAIN_OP_TTL_SECONDS`; prepare again. | -| `UPSTREAM_ERROR` / `UPSTREAM_TIMEOUT` | 502 / 504 | Sanitized RLN failures. | -| `COMPLETE_AMBIGUOUS` | 502 | The wallet failed while completing an on-chain op, after rgb-lib may already have broadcast. The op stays pending with its txid recorded; retrying `complete` finishes the bookkeeping. | +| Code | Status | Meaning | +| ---------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `QUEUE_FULL` | 429 | Per-user queue depth exceeded; a `retry-after` header accompanies it. | +| `AMOUNT_REQUIRED` | 400 | BTC invoices must declare `amtMsat`, asset invoices `assetAmount` (their `amtMsat` HTLC carrier defaults to `GATEWAY_ASSET_INVOICE_MIN_MSAT`), and paying an amount-less invoice requires `amtMsat` (a float-cap bypass). | +| `AMOUNT_MISMATCH` | 400 | `amtMsat` or `assetAmount` conflicts with the amount encoded in the invoice. | +| `AMOUNT_BELOW_MINIMUM` | 400 | An asset invoice's `amtMsat` HTLC carrier value is under `GATEWAY_ASSET_INVOICE_MIN_MSAT`; the node would refuse it. | +| `INVOICE_REJECTED` | 400 | The node rejected these invoice parameters. Detail stays server-side. | +| `INVALID_INVOICE` | 400 | The BOLT11 could not be decoded, or its amount is zero / outside the safe-integer range. | +| `PAYMENT_REJECTED` / `WITHDRAW_REJECTED` | 400 | The node rejected this payment or withdrawal (an RLN 4xx). The debit is refunded. Detail stays server-side. | +| `WITNESS_AMOUNT_REQUIRED` | 400 | An RGB withdraw to a `wvout:` recipient needs `witnessAmountSat` (rgb-lib rejects a witness beneficiary without it). The node funds that output, so its sats are debited from the caller's msat float. | +| `TRANSPORT_ENDPOINT_NOT_ALLOWED` | 400 | Consignment endpoint outside `GATEWAY_RGB_TRANSPORT_ALLOWLIST` (SSRF guard). | +| `WALLET_REQUEST_REJECTED` | 400 | rgb-lib rejected the request itself — bad recipient id, asset id, address, fee rate or transport endpoint. Detail stays server-side. | +| `INSUFFICIENT_FUNDS` | 400 | Not enough confirmed bitcoins, assets or allocation slots for an on-chain operation. | +| `PSBT_REJECTED` / `PSBT_MISMATCH` | 400 | The signed PSBT was rejected, or does not correspond to the prepared operation. | +| `FLOAT_CAP_EXCEEDED` | 409 | Per-user or global float cap would be exceeded by this new exposure. | +| `INSUFFICIENT_BALANCE` | 409 | The user's ledger balance is short. | +| `IDEMPOTENCY_KEY_REUSED` | 409 | Same key, different request body. | +| `IDEMPOTENCY_IN_FLIGHT` | 409 | A request with this key is still running. | +| `PAYMENT_CONFLICT` | 409 | Another user is already paying this invoice. | +| `PAYMENT_ALREADY_ATTEMPTED` | 409 | This invoice already failed and was refunded — request a fresh invoice rather than re-paying an old one. | +| `WITHDRAWAL_ALREADY_ATTEMPTED` | 409 | A withdrawal under this key failed and was refunded — use a fresh key. (A rejection answered 4xx is instead replayed from the idempotency cache.) | +| `WITHDRAWAL_UNRESOLVED` | 409 | A withdrawal under this key is unresolved and may have broadcast; operator resolution required (see Known limitations). | +| `DEPOSIT_ADDRESS_CONFLICT` | 502 | RLN returned a deposit address (or RGB `recipient_id`) already assigned to another intent — it is misconfigured (see the deployment note). | +| `OP_EXPIRED` | 410 | The prepared on-chain operation passed `GATEWAY_ONCHAIN_OP_TTL_SECONDS`; prepare again. | +| `UPSTREAM_ERROR` / `UPSTREAM_TIMEOUT` | 502 / 504 | Sanitized RLN failures. | +| `COMPLETE_AMBIGUOUS` | 502 | The wallet failed while completing an on-chain op, after rgb-lib may already have broadcast. The op stays pending with its txid recorded; read it back with `GET /v1/onchain/operations/:opId` and retry `complete` to finish the bookkeeping. | + +## Recovering a lost on-chain completion + +`complete` can leave a client without an answer: a client-side timeout, a crash, or the +502 `COMPLETE_AMBIGUOUS` the gateway returns when the wallet fails _after_ rgb-lib may +already have broadcast (rgb-lib broadcasts before it writes its bookkeeping). The +operation row is the durable record of what happened, and +`GET /v1/onchain/operations/:opId` reads it back. + +```jsonc +{ + "opId": "…", "kind": "send_btc", + "state": "pending", // pending | completed | expired + "txid": "…", // final when completed; possibly-broadcast otherwise + "mayHaveBroadcast": true, // state != completed AND a txid is recorded + "intent": { … }, // the same summary prepare returned + "createdAt": 1700000000000, "expiresAt": 1700000900000 +} +``` + +How to read it: + +| `state` | `mayHaveBroadcast` | Meaning | Do | +| ----------- | ------------------ | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `completed` | `false` | Done; `txid` is final | nothing | +| `pending` | `false` | Prepared, never broadcast | sign and `complete`, or let it expire | +| `pending` | `true` | **The transaction may already be on the network** | retry `complete` — it is safe (rebroadcasting a transaction the indexer knows succeeds) and finishes the bookkeeping | +| `expired` | `true` | Past TTL, but a txid was recorded | do **not** prepare a replacement before checking `txid` on-chain; needs operator resolution | + +Three properties worth relying on. It takes **no** `Idempotency-Key` and is not queued +behind the per-user wallet work, so it answers even while the `complete` you are asking +about is still running — safe to poll. It never echoes the PSBT; the client already holds +it from `prepare`. And an operation belonging to another user is reported as `404 +OP_NOT_FOUND`, identical to one that does not exist (I3). + +Expiry is **derived** on read, never written: an op past `expiresAt` reports `expired` +here while the stored row stays `pending` until `complete` flips it. Note the last row of +the table — an op that expires while ambiguous can no longer be completed (`complete` +answers 410), so its bookkeeping stays unfinished even though the transaction may be +confirmed. Raising `GATEWAY_ONCHAIN_OP_TTL_SECONDS` above the worst-case wallet stall is +the mitigation today. ## Configuration reference diff --git a/minimal-sdk/packages/gateway/src/routes/onchain.ts b/minimal-sdk/packages/gateway/src/routes/onchain.ts index a06dac09..522ecef9 100644 --- a/minimal-sdk/packages/gateway/src/routes/onchain.ts +++ b/minimal-sdk/packages/gateway/src/routes/onchain.ts @@ -8,6 +8,7 @@ import type { FastifyInstance, FastifySchema } from 'fastify'; import { createUtxosCompleteRouteSchema, createUtxosPrepareRouteSchema, + operationGetRouteSchema, sendAssetCompleteRouteSchema, sendAssetPrepareRouteSchema, sendBtcCompleteRouteSchema, @@ -44,6 +45,23 @@ export function registerOnchainRoutes(app: FastifyInstance): void { }); }; + // Read-only recovery route: the outcome of `complete` can be lost to a + // timeout, a crash, or the 502 COMPLETE_AMBIGUOUS that rgb-lib's + // broadcast-before-bookkeeping ordering makes possible, and the txid is + // recorded on the op in exactly that case. Deliberately NOT on the per-user + // queue and with no Idempotency-Key: it is a single SQLite read, and queueing + // it behind the very wallet call that is stuck would make it unavailable + // precisely when a client needs it. + app.get( + '/v1/onchain/operations/:opId', + { schema: operationGetRouteSchema, onRequest: [app.authenticate] }, + (request) => { + const userId = request.userId as string; + const { opId } = request.params as { opId: string }; + return app.onchain.getOperation(userId, opId); + }, + ); + app.post( '/v1/onchain/send-btc/prepare', routeOptions(sendBtcPrepareRouteSchema), diff --git a/minimal-sdk/packages/gateway/src/schemas/onchain.ts b/minimal-sdk/packages/gateway/src/schemas/onchain.ts index d606c2fc..91e419fa 100644 --- a/minimal-sdk/packages/gateway/src/schemas/onchain.ts +++ b/minimal-sdk/packages/gateway/src/schemas/onchain.ts @@ -91,6 +91,48 @@ const completeErrorResponses = { 410: errorBodySchema, } as const; +/** + * Read-only operation status. Strict like every other response here: the PSBT + * is deliberately NOT echoed — the client already holds it from `prepare`, and + * a 64KB blob has no place in a recovery poll. + */ +export const operationGetRouteSchema = { + params: { + type: 'object', + properties: { opId: opIdSchema }, + required: ['opId'], + additionalProperties: false, + }, + response: { + 200: { + type: 'object', + properties: { + opId: opIdSchema, + kind: { type: 'string', enum: ['send_btc', 'send_asset', 'create_utxos'] }, + state: { type: 'string', enum: ['pending', 'completed', 'expired'] }, + txid: { type: ['string', 'null'] }, + mayHaveBroadcast: { type: 'boolean' }, + intent: intentSchema, + createdAt: { type: 'integer' }, + expiresAt: { type: 'integer' }, + }, + required: [ + 'opId', + 'kind', + 'state', + 'txid', + 'mayHaveBroadcast', + 'intent', + 'createdAt', + 'expiresAt', + ], + additionalProperties: false, + }, + 401: errorBodySchema, + 404: errorBodySchema, + }, +} as const; + export const sendBtcPrepareRouteSchema = { body: { type: 'object', diff --git a/minimal-sdk/packages/gateway/src/wallets/prepare.ts b/minimal-sdk/packages/gateway/src/wallets/prepare.ts index 2362e9dd..f79fe3de 100644 --- a/minimal-sdk/packages/gateway/src/wallets/prepare.ts +++ b/minimal-sdk/packages/gateway/src/wallets/prepare.ts @@ -70,6 +70,31 @@ export interface CompletedOp { utxosCreated: number | null; } +/** + * Durable state of a prepared operation, readable after the client lost the + * outcome of `complete` (timeout, crash, 502 COMPLETE_AMBIGUOUS). + * + * `state` is the stored lifecycle state; `txid` is the transaction this op + * broadcast or may have broadcast. The combination that matters for recovery + * is `state: 'pending'` WITH a non-null `txid`: rgb-lib broadcasts before it + * finishes its bookkeeping, so that op's transaction may already be on the + * network even though the op never completed. `mayHaveBroadcast` surfaces + * exactly that case so a client does not have to re-derive it from the pair. + */ +export interface OperationStatus { + opId: string; + kind: OnchainOpKind; + state: 'pending' | 'completed' | 'expired'; + /** Final txid once completed; the possibly-broadcast txid while ambiguous. */ + txid: string | null; + /** True only while `state` is 'pending' and a txid is already recorded. */ + mayHaveBroadcast: boolean; + /** The same intent summary `prepare` returned, for re-verification. */ + intent: OnchainIntent; + createdAt: number; + expiresAt: number; +} + export interface PrepareSendBtcParams { address: string; amountSat: number; @@ -174,6 +199,7 @@ interface PendingOpRow { intent: string; state: 'pending' | 'completed' | 'expired'; txid: string | null; + created_at: number; expires_at: number; } @@ -319,6 +345,41 @@ export class OnchainService { return this.storePrepared(userId, 'create_utxos', psbt, intent, now); } + /** + * Durable state of one prepared op, for a client that lost the outcome of + * `complete` — a timeout, a crash, or the 502 COMPLETE_AMBIGUOUS that rgb-lib's + * broadcast-before-bookkeeping ordering makes possible. + * + * A pure SQLite read: no wallet FFI and no RLN call, so it deliberately does + * NOT go through the per-user queue (see `routes/onchain.ts`). + * + * Expiry is derived, not written: a read must not mutate, and reporting the + * stored 'pending' for an op already past its TTL would contradict what a + * subsequent `complete` returns (410 OP_EXPIRED). `mayHaveBroadcast` survives + * that derivation on purpose — an expired op whose txid was recorded may still + * have its transaction on the network, and that is the fact worth reporting. + */ + getOperation(userId: string, opId: string, now: number = Date.now()): OperationStatus { + const row = this.db.prepare('SELECT * FROM pending_ops WHERE id = ?').get(opId) as + PendingOpRow | undefined; + // A foreign user's op is indistinguishable from a missing one (I3) — same + // response as `complete`, so this route leaks no existence information. + if (row === undefined || row.user_id !== userId) { + throw new HttpError(404, 'OP_NOT_FOUND', 'no such prepared operation'); + } + const state = row.state === 'pending' && row.expires_at <= now ? 'expired' : row.state; + return { + opId: row.id, + kind: row.kind, + state, + txid: row.txid, + mayHaveBroadcast: row.txid !== null && state !== 'completed', + intent: JSON.parse(row.intent) as OnchainIntent, + createdAt: row.created_at, + expiresAt: row.expires_at, + }; + } + /** * Complete a prepared op with the client-signed PSBT: rgb-lib finalizes and * broadcasts. A rejected PSBT (tampering, missing signature) surfaces as a diff --git a/minimal-sdk/packages/gateway/test/onchain-routes.test.ts b/minimal-sdk/packages/gateway/test/onchain-routes.test.ts index ca09a68f..015fd123 100644 --- a/minimal-sdk/packages/gateway/test/onchain-routes.test.ts +++ b/minimal-sdk/packages/gateway/test/onchain-routes.test.ts @@ -529,6 +529,120 @@ describe('on-chain prepare/complete routes', () => { }); }); + describe('GET /v1/onchain/operations/:opId', () => { + async function getOp(opId: string, token?: string) { + return app.inject({ + method: 'GET', + url: `/v1/onchain/operations/${opId}`, + headers: { authorization: `Bearer ${token ?? user.token}` }, + }); + } + + it('reports a freshly prepared op as pending with no txid', async () => { + const prepared = (await prepareSendBtc()).json(); + const response = await getOp(prepared.opId); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + opId: prepared.opId, + kind: 'send_btc', + state: 'pending', + txid: null, + mayHaveBroadcast: false, + intent: prepared.intent, + createdAt: expect.any(Number), + expiresAt: prepared.expiresAt, + }); + }); + + it('reports the final txid once the op completed', async () => { + const { opId } = (await prepareSendBtc()).json(); + const completed = await complete('send-btc', opId); + expect(completed.statusCode).toBe(200); + const body = (await getOp(opId)).json(); + expect(body.state).toBe('completed'); + expect(body.txid).toBe(completed.json().txid); + expect(body.mayHaveBroadcast).toBe(false); + }); + + // The reason this route exists: after a 502 the client does not know whether + // its money moved, and the op carries the answer. + it('surfaces mayHaveBroadcast with the txid after COMPLETE_AMBIGUOUS', async () => { + backend.dataFor = () => ({ preparedPsbt: PARSABLE_PSBT_A }); + app.walletPool.evict(user.userId); + const { opId } = (await prepareSendBtc()).json(); + backend.handleFor(user.userId)!.failWith = new WalletBackendError( + 'wallet sendBtcEnd failed', + 'RgbLib(Database { details: "error returned from database: disk I/O error" })', + ); + const ambiguous = await complete('send-btc', opId, { signedPsbt: PARSABLE_PSBT_A }); + expect(ambiguous.statusCode).toBe(502); + + const body = (await getOp(opId)).json(); + expect(body.state).toBe('pending'); + expect(body.txid).toBe(txidFromPsbt(PARSABLE_PSBT_A)); + expect(body.mayHaveBroadcast).toBe(true); + + // And it follows the op through the documented recovery: retry completes. + backend.handleFor(user.userId)!.failWith = undefined; + expect((await complete('send-btc', opId, { signedPsbt: PARSABLE_PSBT_A })).statusCode).toBe( + 200, + ); + const after = (await getOp(opId)).json(); + expect(after.state).toBe('completed'); + expect(after.mayHaveBroadcast).toBe(false); + }); + + it('derives expiry past the TTL without writing to the row', async () => { + const { opId } = (await prepareSendBtc()).json(); + app.db + .prepare('UPDATE pending_ops SET expires_at = ? WHERE id = ?') + .run(Date.now() - 1, opId); + expect((await getOp(opId)).json().state).toBe('expired'); + // A read must not mutate: the stored state is still what complete() will + // find and flip itself. + expect(opRow(opId)?.state).toBe('pending'); + }); + + // An expired op whose txid was recorded may still have its transaction on + // the network, so the signal must survive the derived expiry. + it('keeps mayHaveBroadcast on an expired op that recorded a txid', async () => { + const { opId } = (await prepareSendBtc()).json(); + app.db + .prepare('UPDATE pending_ops SET expires_at = ?, txid = ? WHERE id = ?') + .run(Date.now() - 1, 'f'.repeat(64), opId); + const body = (await getOp(opId)).json(); + expect(body.state).toBe('expired'); + expect(body.mayHaveBroadcast).toBe(true); + }); + + it("hides another user's op behind the same 404 as a missing one (I3)", async () => { + const { opId } = (await prepareSendBtc()).json(); + const stranger = await createTestUser(app); + const foreign = await getOp(opId, stranger.token); + const missing = await getOp(randomUUID(), stranger.token); + expect(foreign.statusCode).toBe(404); + expect(missing.statusCode).toBe(404); + expect(foreign.json()).toEqual(missing.json()); + expect(foreign.json().error.code).toBe('OP_NOT_FOUND'); + }); + + it('requires auth and never echoes the PSBT', async () => { + const prepared = (await prepareSendBtc()).json(); + const unauthenticated = await app.inject({ + method: 'GET', + url: `/v1/onchain/operations/${prepared.opId}`, + }); + expect(unauthenticated.statusCode).toBe(401); + const body = (await getOp(prepared.opId)).body; + expect(body).not.toContain(prepared.psbt); + expect(JSON.parse(body)).not.toHaveProperty('psbt'); + }); + + it('rejects a malformed opId at the schema layer', async () => { + expect((await getOp('not-a-uuid')).statusCode).toBe(400); + }); + }); + describe('input validation', () => { it('rejects a non-base64 signed PSBT at the schema layer', async () => { const response = await app.inject({ diff --git a/minimal-sdk/rust-sdk/README.md b/minimal-sdk/rust-sdk/README.md index 8ea2310a..a9e87951 100644 --- a/minimal-sdk/rust-sdk/README.md +++ b/minimal-sdk/rust-sdk/README.md @@ -70,17 +70,27 @@ val registered = client.registerXpubs( // broadcasts. prepareSendBtc binds the returned intent to the request you made // (SdkException.IntentMismatch otherwise); verifyAndSignPsbt refuses to sign // unless all five checks pass (SdkException.VerificationFailed names the check). -val idem = generateIdempotencyKey() +// Each phase takes its OWN key: the gateway hashes method + path + body, so +// one key reused across prepare and complete is 409 IDEMPOTENCY_KEY_REUSED. val prepared = client.prepareSendBtc( PrepareSendBtcParams(address = "bc1p…", amountSat = 50_000UL, feeRateSatPerVb = 2UL), - idem, + generateIdempotencyKey(), ) val signed = verifyAndSignPsbt( keys, prepared.psbt, VerifyParams(intent = prepared.intent, xpubs = xpubs, maxFeeSat = 1_000UL), // user-approved fee budget ) -val txid = client.completeSendBtc(CompleteParams(opId = prepared.opId, signedPsbt = signed.signedPsbt), idem).txid +val txid = client.completeSendBtc( + CompleteParams(opId = prepared.opId, signedPsbt = signed.signedPsbt), + generateIdempotencyKey(), +).txid + +// 5. If that complete is lost — timeout, crash, or 502 COMPLETE_AMBIGUOUS — do +// NOT prepare a replacement. Read the operation back; mayHaveBroadcast true +// means the transaction may already be on the network, so retry complete. +val status = client.getOnchainOperation(prepared.opId) +if (status.mayHaveBroadcast) { /* retry completeSendBtc with a fresh key */ } ``` A transport over `HttpURLConnection` (OkHttp works the same way; only `send` exists): @@ -132,16 +142,24 @@ let registered = try client.registerXpubs( // 4. Send BTC: prepare (intent bound to the request, SdkError.IntentMismatch // otherwise) → verify and sign (SdkError.VerificationFailed names the failed // check; nothing is signed) → complete. -let idem = try generateIdempotencyKey() +// Each phase takes its OWN key: the gateway hashes method + path + body, so +// one key reused across prepare and complete is 409 IDEMPOTENCY_KEY_REUSED. let prepared = try client.prepareSendBtc( params: PrepareSendBtcParams(address: "bc1p…", amountSat: 50_000, feeRateSatPerVb: 2), - idempotencyKey: idem) + idempotencyKey: try generateIdempotencyKey()) let signed = try verifyAndSignPsbt( keys: keys, psbt: prepared.psbt, params: VerifyParams(intent: prepared.intent, xpubs: xpubs, maxFeeSat: 1_000, // user-approved fee budget changeScanWindow: nil, maxOwnOutputIndex: nil)) let txid = try client.completeSendBtc( - params: CompleteParams(opId: prepared.opId, signedPsbt: signed.signedPsbt), idempotencyKey: idem).txid + params: CompleteParams(opId: prepared.opId, signedPsbt: signed.signedPsbt), + idempotencyKey: try generateIdempotencyKey()).txid + +// 5. If that complete is lost — timeout, crash, or 502 COMPLETE_AMBIGUOUS — do +// NOT prepare a replacement. Read the operation back; mayHaveBroadcast true +// means the transaction may already be on the network, so retry complete. +let status = try client.getOnchainOperation(opId: prepared.opId) +if status.mayHaveBroadcast { /* retry completeSendBtc with a fresh key */ } ``` Asset sends (`prepareSendAsset` / `completeSendAsset`) and colorable-UTXO creation @@ -149,8 +167,18 @@ Asset sends (`prepareSendAsset` / `completeSendAsset`) and colorable-UTXO creati → complete shape. Receiving (`receive`), balances, unspents, transfers, LN float deposits, invoices, payments and withdrawals round out `GatewayClient`; `decodeBolt11` / `decodeRgbInvoice` decode invoices locally for display before paying. -Money-moving calls take an idempotency key: retry the **same** operation with the -**same** key to get the cached response instead of a duplicate spend. +Money-moving calls take an idempotency key: retry the **same** call with the **same** +key to get the cached response instead of a duplicate spend. The key is scoped to one +request — the gateway hashes method, path and body — so `prepare` and `complete` each +need their own; reusing one across both is `409 IDEMPOTENCY_KEY_REUSED`. + +`getOnchainOperation(opId)` reads a prepared operation's durable state and is the +recovery path when a `complete` result is lost. It is a read: no idempotency key, and the +gateway does not queue it behind that user's wallet work, so it answers even while the +completion it asks about is still running. `mayHaveBroadcast == true` means the wallet +failed _after_ rgb-lib may already have broadcast — retry `complete` to finish the +bookkeeping rather than sending again. It stays true on an expired operation, because +expiry does not un-broadcast a transaction. ## The verify-before-sign contract @@ -266,11 +294,11 @@ which apply here is what makes the minimal variant's case. ## Measured sizes Release profile `opt-level = "z"`, thin LTO, `codegen-units = 1`, `strip = "symbols"`, -measured 2026-09-15. +measured 2026-09-15; host cdylib re-measured 2026-09-18. | Artifact | Raw | gzip -9 | Note | | ------------------------------------------- | ------------------------: | ----------: | --------------------------------------------------------------------------------------- | -| Host `x86_64-unknown-linux-gnu` cdylib | 2 493 352 B | 1 682 403 B | size gate in `tests/parity.rs`: < 2 500 000 B | +| Host `x86_64-unknown-linux-gnu` cdylib | 2 502 000 B | 1 685 630 B | size gate in `tests/parity.rs`: < 2 600 000 B | | Android `arm64-v8a` `.so` (NDK r29) | 2 366 720 B | 1 689 588 B | gate in `../scripts/build_android.sh`: < 3 000 000 B; `.text` 640 KB, `.rodata` 1.30 MB | | Android `armeabi-v7a` / `x86_64` `.so` | 1 992 820 B / 2 508 984 B | — | | | Android AAR (3 ABIs + 514 KB `classes.jar`) | 5 449 477 B | — | a device installs one slice | diff --git a/minimal-sdk/rust-sdk/src/gateway.rs b/minimal-sdk/rust-sdk/src/gateway.rs index 8352e06d..95361626 100644 --- a/minimal-sdk/rust-sdk/src/gateway.rs +++ b/minimal-sdk/rust-sdk/src/gateway.rs @@ -345,6 +345,49 @@ pub struct CreateUtxosCompleteResult { pub utxos_created: u32, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum OperationState { + Pending, + Completed, + Expired, +} + +impl OperationState { + fn from_wire(s: &str) -> Option { + match s { + "pending" => Some(OperationState::Pending), + "completed" => Some(OperationState::Completed), + "expired" => Some(OperationState::Expired), + _ => None, + } + } +} + +/// Durable state of a prepared on-chain operation, for recovery after the +/// outcome of a `complete` call was lost — a timeout, a crash, or the 502 +/// `COMPLETE_AMBIGUOUS` the gateway returns when its wallet failed *after* +/// rgb-lib may already have broadcast. +/// +/// `may_have_broadcast` is the field that matters in that case: rgb-lib +/// broadcasts before it writes its bookkeeping, so a txid can be recorded on an +/// operation that never completed. When it is true the transaction may already +/// be on the network — retry `complete` (safe, and it finishes the bookkeeping) +/// rather than preparing a second operation. It stays true on an `Expired` +/// operation, because expiry does not un-broadcast a transaction. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct OperationStatus { + pub op_id: String, + pub kind: IntentKind, + pub state: OperationState, + /// Final txid once completed; the possibly-broadcast txid while ambiguous. + pub txid: Option, + pub may_have_broadcast: bool, + /// The same intent summary `prepare` returned. + pub intent: OnchainIntent, + pub created_at: u64, + pub expires_at: u64, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] pub enum LnAssetKind { Btc, @@ -1117,6 +1160,44 @@ impl GatewayClient { }) } + /// Read back a prepared operation's durable state. + /// + /// Safe to poll: a read, no idempotency key, and the gateway does not queue + /// it behind that user's wallet work — so it answers even while the + /// `complete` being asked about is still running. It never returns the + /// PSBT; the caller already holds it from `prepare`. + pub fn get_onchain_operation(&self, op_id: &str) -> SdkResult { + let v = self.request(RequestSpec::get(format!( + "/v1/onchain/operations/{}", + encode_uri_component(op_id) + )))?; + let state_wire = field_str(&v, "state")?; + let state = OperationState::from_wire(&state_wire) + .ok_or_else(|| malformed(&format!("unknown operation state {state_wire:?}")))?; + let kind_wire = field_str(&v, "kind")?; + let kind = IntentKind::from_wire(&kind_wire) + .ok_or_else(|| malformed(&format!("unknown operation kind {kind_wire:?}")))?; + let intent = v + .get("intent") + .ok_or_else(|| malformed("operation carries no intent summary"))?; + // Reuse the same typed decoder `prepare` uses, so a field this SDK + // cannot represent fails here rather than surfacing as a silent default. + let intent = typed_intent(intent, kind).map_err(|e| malformed(&e))?; + Ok(OperationStatus { + op_id: field_str(&v, "opId")?, + kind, + state, + txid: field_opt_str(&v, "txid")?, + may_have_broadcast: v + .get("mayHaveBroadcast") + .and_then(Json::as_bool) + .ok_or_else(|| malformed("mayHaveBroadcast is not a boolean"))?, + intent, + created_at: field_u64(&v, "createdAt")?, + expires_at: field_u64(&v, "expiresAt")?, + }) + } + /// Pin the idempotency key to retry a prepare without minting a second /// deposit target. pub fn prepare_ln_deposit( @@ -1675,6 +1756,11 @@ impl GatewayClient { self.complete_create_utxos(¶ms, idempotency_key) } + #[uniffi::method(name = "get_onchain_operation")] + pub fn ffi_get_onchain_operation(&self, op_id: String) -> SdkResult { + self.get_onchain_operation(&op_id) + } + #[uniffi::method(name = "prepare_ln_deposit")] pub fn ffi_prepare_ln_deposit( &self, diff --git a/minimal-sdk/rust-sdk/src/lib.rs b/minimal-sdk/rust-sdk/src/lib.rs index c6092413..88bf4772 100644 --- a/minimal-sdk/rust-sdk/src/lib.rs +++ b/minimal-sdk/rust-sdk/src/lib.rs @@ -41,7 +41,7 @@ pub use derive::{ pub use ffi::{derive_taproot_address, generate_keys, sdk_version}; pub use gateway::{ encode_uri_component, generate_idempotency_key, GatewayClient, HttpMethod, HttpRequest, - HttpResponse, HttpTransport, PreparedOp, + HttpResponse, HttpTransport, OperationState, OperationStatus, PreparedOp, }; pub use invoice::{decode_bolt11, decode_rgb_invoice, BeneficiaryKind, Bolt11Invoice, RgbInvoice}; pub use keys::{AccountXpubs, ClientKeys, GeneratedKeys}; diff --git a/minimal-sdk/rust-sdk/src/verify.rs b/minimal-sdk/rust-sdk/src/verify.rs index 4bdb71a4..1f9c1da3 100644 --- a/minimal-sdk/rust-sdk/src/verify.rs +++ b/minimal-sdk/rust-sdk/src/verify.rs @@ -54,6 +54,16 @@ impl IntentKind { IntentKind::CreateUtxos => "create_utxos", } } + + /// Inverse of [`IntentKind::name`], for decoding a gateway response. + pub fn from_wire(s: &str) -> Option { + match s { + "send_btc" => Some(IntentKind::SendBtc), + "send_asset" => Some(IntentKind::SendAsset), + "create_utxos" => Some(IntentKind::CreateUtxos), + _ => None, + } + } } impl fmt::Display for IntentKind { diff --git a/minimal-sdk/rust-sdk/tests/parity.rs b/minimal-sdk/rust-sdk/tests/parity.rs index d0343253..b80ea9a2 100644 --- a/minimal-sdk/rust-sdk/tests/parity.rs +++ b/minimal-sdk/rust-sdk/tests/parity.rs @@ -22,7 +22,14 @@ use utexo_minimal_sdk::{ /// Stripped release cdylib budget. Baseline measured 2026-09-15 on x86_64 for /// `bitcoin` + `bip39` + uniffi with derive/PSBT/sign reachable: 1.94 MB, of /// which 1.06 MB is secp256k1 precomputed tables and irreducible. -const SIZE_BUDGET_BYTES: u64 = 2_500_000; +/// +/// Raised 2026-09-18 from 2_500_000 when `get_onchain_operation` was added: +/// the crate then measured 2 493 352 B, so a single new uniffi route — one +/// `Record`, one `Enum` and the lift/lower scaffolding uniffi generates per +/// field — cost 8 648 B and breached a budget with 0.27% headroom. The move to +/// 2.6 MB buys room for roughly ten more routes; it is NOT licence to add a +/// dependency, which the banned-crate guards below still forbid outright. +const SIZE_BUDGET_BYTES: u64 = 2_600_000; /// Crates that must never appear anywhere in the resolved graph, dev or not. const BANNED_CRATES: &[&str] = &["rgb-lib", "reqwest", "tokio", "rustls", "openssl"]; @@ -3283,7 +3290,7 @@ use utexo_minimal_sdk::gateway::{ }; use utexo_minimal_sdk::{ encode_uri_component, generate_idempotency_key, GatewayClient, HttpMethod, HttpRequest, - HttpResponse, HttpTransport, + HttpResponse, HttpTransport, OperationState, }; /// Records every request and answers from a queue. Implements the same @@ -3848,6 +3855,117 @@ fn gateway_every_route_round_trips_with_method_path_auth_and_body_shape() { assert_no_idempotency_key(request); } +#[test] +fn gateway_get_onchain_operation_reads_durable_state_after_a_lost_complete() { + let op_id = "11111111-2222-4333-8444-555555555555"; + let status_json = |state: &str, txid: serde_json::Value, may: bool| { + serde_json::json!({ + "opId": op_id, + "kind": "send_btc", + "state": state, + "txid": txid, + "mayHaveBroadcast": may, + "intent": send_btc_intent_json(), + "createdAt": 1_700_000_000_000u64, + "expiresAt": 1_700_000_900_000u64, + }) + }; + let transport = FakeTransport::with(vec![ + ok(200, status_json("pending", serde_json::Value::Null, false)), + // The case this route exists for: the wallet failed after rgb-lib may + // already have broadcast, so a txid is recorded on an op that never + // completed. + ok( + 200, + status_json("pending", serde_json::json!("d".repeat(64)), true), + ), + // Expiry must not clear the signal: an expired op whose txid was + // recorded may still have its transaction confirmed. + ok( + 200, + status_json("expired", serde_json::json!("d".repeat(64)), true), + ), + ok( + 200, + status_json("completed", serde_json::json!("e".repeat(64)), false), + ), + ]); + let c = client(&transport, Some("tok-1")); + + let fresh = c.get_onchain_operation(op_id).unwrap(); + assert_eq!(fresh.op_id, op_id); + assert_eq!(fresh.kind, IntentKind::SendBtc); + assert_eq!(fresh.state, OperationState::Pending); + assert_eq!(fresh.txid, None); + assert!(!fresh.may_have_broadcast); + // The intent comes back through the same typed decoder `prepare` uses. + assert_eq!(fresh.intent.kind, IntentKind::SendBtc); + assert_eq!(fresh.intent.recipients[0].amount_sat, 40_000); + assert_eq!(fresh.created_at, 1_700_000_000_000); + assert_eq!(fresh.expires_at, 1_700_000_900_000); + + let ambiguous = c.get_onchain_operation(op_id).unwrap(); + assert_eq!(ambiguous.state, OperationState::Pending); + assert!(ambiguous.may_have_broadcast); + assert_eq!(ambiguous.txid, Some("d".repeat(64))); + + let expired = c.get_onchain_operation(op_id).unwrap(); + assert_eq!(expired.state, OperationState::Expired); + assert!(expired.may_have_broadcast, "expiry does not un-broadcast"); + + let done = c.get_onchain_operation(op_id).unwrap(); + assert_eq!(done.state, OperationState::Completed); + assert!(!done.may_have_broadcast); + + // A read: GET, percent-encoded path, bearer auth, no body, and no + // idempotency key — so polling it can never consume one. + for request in transport.seen() { + assert_eq!(request.method, HttpMethod::Get); + assert_eq!( + request.url, + format!("http://gw.local/v1/onchain/operations/{op_id}") + ); + assert_eq!(request.body, None); + assert_no_idempotency_key(&request); + assert_eq!( + request.headers.get("authorization").map(String::as_str), + Some("Bearer tok-1") + ); + } +} + +#[test] +fn gateway_get_onchain_operation_fails_closed_on_unknown_state_or_kind() { + let base = |state: &str, kind: &str| { + serde_json::json!({ + "opId": "11111111-2222-4333-8444-555555555555", + "kind": kind, + "state": state, + "txid": null, + "mayHaveBroadcast": false, + "intent": send_btc_intent_json(), + "createdAt": 1u64, + "expiresAt": 2u64, + }) + }; + // A state or kind this SDK does not know must be an error, never a silent + // default — the caller decides recovery on these fields. + let mut missing_flag = base("pending", "send_btc"); + missing_flag["mayHaveBroadcast"] = serde_json::Value::Null; + let transport = FakeTransport::with(vec![ + ok(200, base("teleported", "send_btc")), + ok(200, base("pending", "send_dogecoin")), + ok(200, missing_flag), + ]); + let c = client(&transport, Some("tok-1")); + for _ in 0..3 { + assert!(matches!( + c.get_onchain_operation("11111111-2222-4333-8444-555555555555"), + Err(SdkError::Gateway { .. }) + )); + } +} + #[test] fn gateway_base_url_trailing_slash_percent_encoding_and_no_token_guard() { let transport = FakeTransport::with(vec![ok(200, serde_json::json!({ "address": "x" }))]); @@ -4802,9 +4920,9 @@ fn gateway_source_links_no_network_stack_and_reaches_no_key_material() { "Cargo.toml gained {crate_name:?}" ); } - // The uniffi surface exposes the client, the 22 routes and the key generator. + // The uniffi surface exposes the client, the 23 routes and the key generator. let route_exports = body.matches("#[uniffi::method(name = \"").count(); - assert_eq!(route_exports, 22, "one uniffi method per route"); + assert_eq!(route_exports, 23, "one uniffi method per route"); assert!(body.contains("#[uniffi::export(name = \"generate_idempotency_key\")]")); } From bf50f88b7259653a18d845e0eaee8dd01ad4f799 Mon Sep 17 00:00:00 2001 From: rmn-boiko Date: Mon, 28 Sep 2026 14:27:11 +0200 Subject: [PATCH 5/9] Cleanup code --- .github/workflows/minimal-sdk-mobile.yaml | 222 - .github/workflows/minimal-sdk.yaml | 78 + CLAUDE.md | 30 +- README.md | 9 +- minimal-sdk/README.md | 54 +- minimal-sdk/android/.gitignore | 6 - minimal-sdk/android/README.md | 92 - minimal-sdk/android/build.gradle.kts | 171 - minimal-sdk/android/consumer-rules.pro | 27 - minimal-sdk/android/gradle.properties | 3 - .../android/gradle/wrapper/gradle-wrapper.jar | Bin 43504 -> 0 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 - minimal-sdk/android/gradlew | 252 - minimal-sdk/android/gradlew.bat | 94 - minimal-sdk/android/settings.gradle.kts | 32 - .../android/src/main/AndroidManifest.xml | 2 - .../kotlin/com/utexo/minimalsdk/SmokeTest.kt | 302 - minimal-sdk/apple/.gitignore | 7 - minimal-sdk/apple/Package.swift | 57 - minimal-sdk/apple/README.md | 100 - .../MinimalSdk/URLSessionHttpTransport.swift | 102 - .../Tests/MinimalSdkTests/SmokeTests.swift | 507 -- minimal-sdk/eslint.config.mjs | 5 - .../packages/client-sdk/src/gateway.ts | 90 +- minimal-sdk/packages/client-sdk/src/index.ts | 5 + minimal-sdk/packages/gateway/README.md | 115 +- minimal-sdk/packages/gateway/package.json | 2 +- minimal-sdk/packages/gateway/src/config.ts | 9 + minimal-sdk/packages/gateway/src/db.ts | 22 + minimal-sdk/packages/gateway/src/fees.ts | 156 + .../packages/gateway/src/routes/onchain.ts | 19 + .../packages/gateway/src/routes/wallet.ts | 4 +- .../packages/gateway/src/schemas/onchain.ts | 34 + .../packages/gateway/src/schemas/wallet.ts | 95 +- minimal-sdk/packages/gateway/src/server.ts | 45 +- .../packages/gateway/src/wallets/backend.ts | 41 + .../gateway/src/wallets/derivation.ts | 136 + .../packages/gateway/src/wallets/prepare.ts | 122 +- .../packages/gateway/src/wallets/rgblib.ts | 156 +- .../packages/gateway/src/wallets/service.ts | 46 +- .../gateway/src/workers/completions.ts | 140 + .../gateway/test/completions-worker.test.ts | 298 + minimal-sdk/packages/gateway/test/db.test.ts | 49 +- .../packages/gateway/test/derivation.test.ts | 135 + .../gateway/test/fee-estimate.test.ts | 314 ++ .../gateway/test/rgblib-mapping.test.ts | 135 +- .../packages/gateway/test/wallet-mocks.ts | 1 + .../gateway/test/wallet-routes.test.ts | 114 +- minimal-sdk/pnpm-lock.yaml | 6 +- minimal-sdk/rust-sdk/.gitignore | 1 - minimal-sdk/rust-sdk/Cargo.lock | 558 -- minimal-sdk/rust-sdk/Cargo.toml | 46 - minimal-sdk/rust-sdk/README.md | 381 -- minimal-sdk/rust-sdk/src/derive.rs | 363 -- minimal-sdk/rust-sdk/src/ffi.rs | 110 - minimal-sdk/rust-sdk/src/gateway.rs | 1884 ------- minimal-sdk/rust-sdk/src/invoice.rs | 608 -- minimal-sdk/rust-sdk/src/json.rs | 541 -- minimal-sdk/rust-sdk/src/keys.rs | 246 - minimal-sdk/rust-sdk/src/lib.rs | 165 - minimal-sdk/rust-sdk/src/network.rs | 223 - minimal-sdk/rust-sdk/src/sign.rs | 298 - minimal-sdk/rust-sdk/src/verify.rs | 674 --- minimal-sdk/rust-sdk/tests/parity.rs | 4986 ----------------- minimal-sdk/rust-sdk/uniffi.toml | 11 - minimal-sdk/scripts/build_android.sh | 185 - minimal-sdk/scripts/build_apple.sh | 136 - minimal-sdk/scripts/generate_bindings.sh | 134 - 68 files changed, 2318 insertions(+), 13680 deletions(-) delete mode 100644 .github/workflows/minimal-sdk-mobile.yaml create mode 100644 .github/workflows/minimal-sdk.yaml delete mode 100644 minimal-sdk/android/.gitignore delete mode 100644 minimal-sdk/android/README.md delete mode 100644 minimal-sdk/android/build.gradle.kts delete mode 100644 minimal-sdk/android/consumer-rules.pro delete mode 100644 minimal-sdk/android/gradle.properties delete mode 100644 minimal-sdk/android/gradle/wrapper/gradle-wrapper.jar delete mode 100644 minimal-sdk/android/gradle/wrapper/gradle-wrapper.properties delete mode 100755 minimal-sdk/android/gradlew delete mode 100644 minimal-sdk/android/gradlew.bat delete mode 100644 minimal-sdk/android/settings.gradle.kts delete mode 100644 minimal-sdk/android/src/main/AndroidManifest.xml delete mode 100644 minimal-sdk/android/src/test/kotlin/com/utexo/minimalsdk/SmokeTest.kt delete mode 100644 minimal-sdk/apple/.gitignore delete mode 100644 minimal-sdk/apple/Package.swift delete mode 100644 minimal-sdk/apple/README.md delete mode 100644 minimal-sdk/apple/Sources/MinimalSdk/URLSessionHttpTransport.swift delete mode 100644 minimal-sdk/apple/Tests/MinimalSdkTests/SmokeTests.swift create mode 100644 minimal-sdk/packages/gateway/src/fees.ts create mode 100644 minimal-sdk/packages/gateway/src/wallets/derivation.ts create mode 100644 minimal-sdk/packages/gateway/src/workers/completions.ts create mode 100644 minimal-sdk/packages/gateway/test/completions-worker.test.ts create mode 100644 minimal-sdk/packages/gateway/test/derivation.test.ts create mode 100644 minimal-sdk/packages/gateway/test/fee-estimate.test.ts delete mode 100644 minimal-sdk/rust-sdk/.gitignore delete mode 100644 minimal-sdk/rust-sdk/Cargo.lock delete mode 100644 minimal-sdk/rust-sdk/Cargo.toml delete mode 100644 minimal-sdk/rust-sdk/README.md delete mode 100644 minimal-sdk/rust-sdk/src/derive.rs delete mode 100644 minimal-sdk/rust-sdk/src/ffi.rs delete mode 100644 minimal-sdk/rust-sdk/src/gateway.rs delete mode 100644 minimal-sdk/rust-sdk/src/invoice.rs delete mode 100644 minimal-sdk/rust-sdk/src/json.rs delete mode 100644 minimal-sdk/rust-sdk/src/keys.rs delete mode 100644 minimal-sdk/rust-sdk/src/lib.rs delete mode 100644 minimal-sdk/rust-sdk/src/network.rs delete mode 100644 minimal-sdk/rust-sdk/src/sign.rs delete mode 100644 minimal-sdk/rust-sdk/src/verify.rs delete mode 100644 minimal-sdk/rust-sdk/tests/parity.rs delete mode 100644 minimal-sdk/rust-sdk/uniffi.toml delete mode 100755 minimal-sdk/scripts/build_android.sh delete mode 100755 minimal-sdk/scripts/build_apple.sh delete mode 100755 minimal-sdk/scripts/generate_bindings.sh diff --git a/.github/workflows/minimal-sdk-mobile.yaml b/.github/workflows/minimal-sdk-mobile.yaml deleted file mode 100644 index efa3c985..00000000 --- a/.github/workflows/minimal-sdk-mobile.yaml +++ /dev/null @@ -1,222 +0,0 @@ -name: Minimal SDK Mobile - -# CI for the minimal mobile SDK (docs/plans/20260915-minimal-mobile-rust-sdk.md, -# Task 10): one small Rust crate (minimal-sdk/rust-sdk) exposed to Kotlin and -# Swift through uniffi. It shares no code with the node, so it runs only when -# its own inputs change. -# -# The fixture path filter is deliberate. minimal-sdk/packages/client-sdk/test/ -# fixtures/rgblib-parity.json is the rgb-lib-authored ground truth the Rust -# parity suite reads by relative path. If it is regenerated, the parity suite -# (and the Kotlin/Swift smoke tests, which read the same file) MUST re-run, -# even though no Rust source changed. -# -# bindings/uniffi-bindgen is the tool that generates the Kotlin and Swift -# bindings for jobs 2 and 3; a change there can break both packages. -# -# Every action is pinned to a commit SHA (the tag is in the comment). - -on: - push: - branches: ["master", "dev", "stage"] - paths: - - "minimal-sdk/rust-sdk/**" - - "minimal-sdk/android/**" - - "minimal-sdk/apple/**" - - "minimal-sdk/scripts/**" - - "minimal-sdk/packages/client-sdk/test/fixtures/**" - - "bindings/uniffi-bindgen/**" - - ".github/workflows/minimal-sdk-mobile.yaml" - pull_request: - branches: ["master", "dev", "stage"] - paths: - - "minimal-sdk/rust-sdk/**" - - "minimal-sdk/android/**" - - "minimal-sdk/apple/**" - - "minimal-sdk/scripts/**" - - "minimal-sdk/packages/client-sdk/test/fixtures/**" - - "bindings/uniffi-bindgen/**" - - ".github/workflows/minimal-sdk-mobile.yaml" - workflow_dispatch: - -env: - CARGO_TERM_COLOR: always - # Matches rust-version in minimal-sdk/rust-sdk/Cargo.toml and the toolchain - # the other workflows pin. - RUST_TOOLCHAIN: "1.94.0" - RUST_SDK_MANIFEST: minimal-sdk/rust-sdk/Cargo.toml - -jobs: - # --------------------------------------------------------------------------- - # Job 1: Android. NDK, cargo ndk for the three ABIs, Kotlin bindings, - # ./gradlew test (JVM smoke test through JNA against the host cdylib), - # then assembleRelease, which runs minimal-sdk/scripts/build_android.sh - # (16 KB PT_LOAD alignment on every .so, exported uniffi entry points, - # arm64 raw-size budget) before bundling the AAR. The gates are re-printed - # as their own step so a failure is readable without digging through - # Gradle output. - # --------------------------------------------------------------------------- - android: - name: Android (JNI libs, 16 KB alignment, size gate, AAR) - runs-on: warp-ubuntu-latest-x64-16x - timeout-minutes: 45 - env: - # Consumed by nttld/setup-ndk's `ndk-version`; the resulting path is - # exported as ANDROID_NDK_ROOT/ANDROID_NDK_HOME below, which is what - # build_android.sh reads. - NDK_VERSION: r29 - ANDROID_PLATFORM: "platforms;android-34" - CARGO_NDK_VERSION: 4.1.2 - JAVA_VERSION: "17" - steps: - - name: Checkout repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - - name: Setup Rust - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - target: aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android - rustflags: "" - cache: false - - - name: Cache cargo registry and build - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - # rust-sdk: host + three Android targets. uniffi-bindgen: the - # binding generator, built with `cargo run` by generate_bindings.sh. - workspaces: | - minimal-sdk/rust-sdk -> target - bindings/uniffi-bindgen -> target - key: android - - - name: Setup Java - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 - with: - distribution: temurin - java-version: ${{ env.JAVA_VERSION }} - - - name: Cache Gradle user home - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('minimal-sdk/android/gradle/wrapper/gradle-wrapper.properties', 'minimal-sdk/android/**/*.gradle.kts', 'minimal-sdk/android/gradle.properties') }} - restore-keys: | - gradle-${{ runner.os }}- - - - name: Setup Android NDK - id: setup-ndk - uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1.6.0 - with: - ndk-version: ${{ env.NDK_VERSION }} - add-to-path: false - link-to-sdk: true - - - name: Install Android platform and verify the NDK - env: - NDK_PATH: ${{ steps.setup-ndk.outputs.ndk-path }} - # compileSdk 34 is installed explicitly, as release-sdk.yaml and - # sdk-e2e.yaml do, rather than assumed to be on the runner image. - run: | - set -euo pipefail - : "${ANDROID_HOME:?ANDROID_HOME must be set (Android SDK root)}" - SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" - [ -x "$SDKMANAGER" ] || SDKMANAGER="$(command -v sdkmanager)" - yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true - "$SDKMANAGER" --install "$ANDROID_PLATFORM" >/dev/null - test -d "$ANDROID_HOME/platforms/android-34" - test -f "$NDK_PATH/source.properties" - sed -n 's/^Pkg.Revision = /NDK /p' "$NDK_PATH/source.properties" - { - echo "ANDROID_NDK_ROOT=$NDK_PATH" - echo "ANDROID_NDK_HOME=$NDK_PATH" - } >> "$GITHUB_ENV" - - - name: Install cargo-ndk - run: | - set -euo pipefail - if cargo ndk --version 2>/dev/null | grep -q "cargo-ndk $CARGO_NDK_VERSION"; then - echo "cargo-ndk $CARGO_NDK_VERSION already installed (cache)" - else - cargo install cargo-ndk --version "$CARGO_NDK_VERSION" --locked - fi - cargo ndk --version - - - name: Gradle test and assembleRelease - # `test` builds the host cdylib and generates the Kotlin bindings - # itself (uniffi library mode, no UDL) and runs the Kotlin smoke test. - # `assembleRelease` runs build_android.sh (cargo ndk for the three - # ABIs + the alignment and size gates) before bundling the AAR. - run: ./minimal-sdk/android/gradlew -p minimal-sdk/android --no-daemon --console=plain test assembleRelease - - - name: 16 KB alignment, exported entry points and arm64 size gate - run: ./minimal-sdk/scripts/build_android.sh --check-only minimal-sdk/android/generated/jniLibs - - - name: Upload Android artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: minimal-sdk-android - if-no-files-found: error - path: | - minimal-sdk/android/build/outputs/aar/*.aar - minimal-sdk/android/generated/jniLibs/** - minimal-sdk/android/generated/kotlin/** - - # --------------------------------------------------------------------------- - # Job 2: Apple. This is the job that first proves Task 9: the plan's dev - # machine has no Xcode, so the static libraries, lipo, the XCFramework and - # the Swift smoke test on the real XCFramework were never run locally. - # build_apple.sh builds release staticlibs for the iOS device target, the - # two simulator targets and the two macOS targets, generates the Swift - # bindings, lipos the simulator and macOS slices, runs - # `xcodebuild -create-xcframework` and checks the device slice exports the - # uniffi entry points. `swift test` then runs the smoke test on the macOS - # slice through the package's binaryTarget. - # --------------------------------------------------------------------------- - apple: - name: Apple (XCFramework, Swift smoke test) - runs-on: warp-macos-latest-arm64-6x - timeout-minutes: 45 - steps: - - name: Checkout repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - - name: Setup Rust - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - target: aarch64-apple-ios, aarch64-apple-ios-sim, x86_64-apple-ios, aarch64-apple-darwin, x86_64-apple-darwin - rustflags: "" - cache: false - - - name: Cache cargo registry and build - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - workspaces: | - minimal-sdk/rust-sdk -> target - bindings/uniffi-bindgen -> target - key: apple - - - name: Toolchain versions - run: | - xcodebuild -version - swift --version - rustc --version - - - name: Build static libraries, Swift bindings and XCFramework - run: ./minimal-sdk/scripts/build_apple.sh - - - name: Swift smoke test (macOS slice of the XCFramework) - run: swift test --package-path minimal-sdk/apple - - - name: Upload Apple artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: minimal-sdk-apple - if-no-files-found: error - path: | - minimal-sdk/apple/MinimalSdkFFI.xcframework/** - minimal-sdk/apple/Sources/MinimalSdk/Generated/** - minimal-sdk/apple/Sources/MinimalSdkFFI/** diff --git a/.github/workflows/minimal-sdk.yaml b/.github/workflows/minimal-sdk.yaml new file mode 100644 index 00000000..cf8ec4ce --- /dev/null +++ b/.github/workflows/minimal-sdk.yaml @@ -0,0 +1,78 @@ +name: Minimal SDK + +# CI for the minimal-sdk pnpm workspace: the REST gateway +# (minimal-sdk/packages/gateway), the TypeScript client SDK that is the in-repo +# reference for its contract, and the e2e package's infra-free smoke test. +# +# This matters more than it looks. The gateway's HTTP surface has an +# out-of-repo consumer — the rgb-sdk-kotlin-light Android SDK — and its response +# schemas are strict, so removing or retyping a field is a breaking change for a +# client this repository cannot test against. The unit suites are the only guard +# on that contract, and they need nothing but Node. +# +# Deliberately NOT here: the regtest e2e journey (`pnpm e2e`) and the two +# WALLET_REGTEST gateway suites, which need bitcoind, electrs and an RLN +# instance. They stay a local/manual step, as documented in minimal-sdk/README.md. +# +# It shares no code with the node, so it runs only when its own inputs change. + +on: + push: + branches: ["master", "dev", "stage"] + paths: + - "minimal-sdk/**" + - ".github/workflows/minimal-sdk.yaml" + pull_request: + branches: ["master", "dev", "stage"] + paths: + - "minimal-sdk/**" + - ".github/workflows/minimal-sdk.yaml" + workflow_dispatch: + +# A newer push to the same ref makes an in-flight run obsolete. +concurrency: + group: minimal-sdk-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + workspace: + name: Build, lint and test the minimal-sdk workspace + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: minimal-sdk + steps: + - uses: actions/checkout@v4 + + # No `version`: the action reads `packageManager` from + # minimal-sdk/package.json, so CI uses the same pnpm that wrote the lockfile. + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: minimal-sdk/package.json + + - name: Setup Node + uses: actions/setup-node@v4 + with: + # minimal-sdk/package.json engines and the gateway README require 20+. + node-version: "20" + cache: pnpm + cache-dependency-path: minimal-sdk/pnpm-lock.yaml + + # --frozen-lockfile so a dependency change that was not committed to the + # lockfile fails here rather than silently resolving to something else. + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Lint and format check + run: pnpm lint + + - name: Unit tests + run: pnpm test diff --git a/CLAUDE.md b/CLAUDE.md index bde6b03c..95a6ee6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,21 +17,21 @@ Repository conventions: - Avoid introducing breaking API changes unless the PR explicitly requires it. - Keep CI/workflow changes conservative and deterministic. -Minimal mobile SDK (`minimal-sdk/rust-sdk`, `minimal-sdk/android`, `minimal-sdk/apple`): +Minimal SDK workspace (`minimal-sdk/`, a pnpm workspace; the root `cargo` commands do +not build it): -- A separate Cargo workspace with its own `Cargo.lock`; the root `cargo` commands do not - build it. Test with `cargo test --manifest-path minimal-sdk/rust-sdk/Cargo.toml`; - Android with `./minimal-sdk/android/gradlew -p minimal-sdk/android test assembleRelease`; - Apple with `minimal-sdk/scripts/build_apple.sh` (macOS only) and - `swift test --package-path minimal-sdk/apple`. -- Invariants to enforce in review: uniffi proc-macros only (no `.udl`); dependencies are - `bitcoin`, `bip39`, `thiserror`, `uniffi` and nothing else (no `rgb-lib`, HTTP, TLS or - async runtime; the release cdylib has a size gate in `tests/parity.rs`); - `verify_and_sign_psbt` is the only signing entry point and runs `verify_psbt` first; no - `SdkError` variant, `Debug` impl or log line may render a mnemonic, seed, xprv, private - key or bearer token; every exported function returns `SdkResult` and no panic crosses - the FFI, including from a foreign `HttpTransport` that throws. +- `packages/gateway` is the REST gateway in front of one shared RLN. Its native mobile + client is the separate `rgb-sdk-kotlin-light` repository, which ships its own Rust + signing core; this repo holds no mobile SDK any more. Treat the gateway's HTTP surface + as a published contract: response schemas are strict (`additionalProperties: false`), + so removing or retyping a field is breaking, while adding one is not. +- `packages/client-sdk` is the TypeScript client (web / React Native) and the behavioural + reference for that contract; `packages/e2e` is the regtest journey suite. Test with + `pnpm -C minimal-sdk test`. - The parity fixture `minimal-sdk/packages/client-sdk/test/fixtures/rgblib-parity.json` is rgb-lib-authored ground truth: read by relative path, never copied or regenerated - here. Changes to the mobile SDK must not touch `src/`, `bindings/` or - `minimal-sdk/packages/`. + here. +- Invariants to enforce in review: no secret ever reaches the gateway (I1), one watch-only + rgb-lib wallet per user (I2), no cross-user visibility (I3), only the gateway holds the + RLN credential (I4). No `SdkError`, error body or log line may render a mnemonic, seed, + xprv, private key or bearer token. diff --git a/README.md b/README.md index b2c15f78..a543771d 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,9 @@ docker build -t rgb-lightning-node . See [UniFFI SDK documentation](src/uniffi_api/README.md) for setup, runtime model, binding generation, test commands, and artifact packaging details. -That node SDK is not the only uniffi surface in this repository. -[`minimal-sdk/rust-sdk`](minimal-sdk/rust-sdk/README.md) is a separate, node-free Rust -crate (keys, taproot derivation, verify-before-sign, signing, invoice decoding and a -gateway client) packaged for Android (`minimal-sdk/android`) and iOS (`minimal-sdk/apple`). -It is its own Cargo workspace, built and tested by -`.github/workflows/minimal-sdk-mobile.yaml`, not by the root `cargo` commands. +Native mobile clients are no longer built here: the Android SDK lives in its own +`rgb-sdk-kotlin-light` repository with its own signing core, and talks to +[`minimal-sdk/packages/gateway`](minimal-sdk/packages/gateway/README.md) over REST. ## AI code review The repository supports AI-assisted pull request reviews. diff --git a/minimal-sdk/README.md b/minimal-sdk/README.md index 52538601..8d6c0672 100644 --- a/minimal-sdk/README.md +++ b/minimal-sdk/README.md @@ -29,42 +29,34 @@ against its stated intent and signs; the server broadcasts. Package READMEs: boundary) and `packages/client-sdk/README.md` (quickstart, the verify-before-sign contract); `packages/e2e/README.md` covers the regtest journey suite. -### Native mobile: one Rust core, two packages +### Native mobile: an out-of-repo Kotlin SDK -For natively written Android and iOS apps the same client role is filled by **one Rust -crate** exposed to both platforms through uniffi -(`docs/plans/20260915-minimal-mobile-rust-sdk.md`): +Natively written Android apps fill the same client role from a **separate repository**, +`rgb-sdk-kotlin-light` (`com.utexo.rgb.sdk`), which ships its own Rust signing core and +speaks the REST surface above: ``` -Android app (Kotlin) iOS app (Swift) - └─ android/ AAR + JNA └─ apple/ SwiftPM + XCFramework - └──────────── rust-sdk/ (keys, derive, verify-before-sign, sign, invoice, gateway client) - │ HTTP through a host-implemented HttpTransport (OkHttp / URLSession) - API gateway (same REST surface as above) +Android app (Kotlin) + └─ rgb-sdk-kotlin-light (Keystore seed custody, operation journal, local PSBT + │ verify + sign via its own `utexo-local-signer` core) + │ HTTPS / OkHttp + API gateway (same REST surface as above) ``` -- `rust-sdk/` — the core: `bitcoin` + `bip39` + `thiserror` + `uniffi`, nothing else; no - rgb-lib, no HTTP or TLS stack. `cargo test` is the gate (99 tests: 68 parity and - adversarial cases in `tests/parity.rs` plus 31 unit tests, a release size gate, a - dependency-creep guard). README: `rust-sdk/README.md` (quickstart in Kotlin - and Swift, the five checks, the `HttpTransport` seam, sizes, mobile runtime constraints). -- `android/` — Gradle library `com.utexo.minimalsdk` (minSdk 24, JNA runtime dependency), - JNI libs for three ABIs with a 16 KB page-alignment gate and a 3.0 MB arm64 size gate. -- `apple/` — Swift package `MinimalSdk` (iOS 15 / macOS 12), static XCFramework built by - `scripts/build_apple.sh` on macOS. -- `scripts/` — `generate_bindings.sh` (uniffi library mode, no UDL), `build_android.sh`, - `build_apple.sh`. CI: `.github/workflows/minimal-sdk-mobile.yaml`. - -**The TypeScript SDK stays hand-written and stays shipped.** It is the behavioural -reference the Rust crate was ported from and is the **independent cross-check** on the -Rust implementation, not legacy to be replaced: both read the same rgb-lib-authored -`packages/client-sdk/test/fixtures/rgblib-parity.json` and share no code, so a shared -misreading of BIP-86 or the RGB coin types cannot hide. Web and React Native keep using -it (185.5 KB of pure JS); native Kotlin and Swift apps use the Rust core. - -`pnpm-workspace.yaml` is unaffected: it globs `packages/*` only, so the Cargo, Gradle and -SwiftPM directories above are invisible to pnpm and none of the workspace commands below -touch them. +Two consequences for work in this workspace: + +- **The gateway's HTTP surface is a published contract with an out-of-repo consumer.** + Response schemas are strict (`additionalProperties: false`), so removing a field, + retyping one, or narrowing an accepted range is breaking; adding a field is not. That + client validates response shapes strictly and refuses anything it cannot map, so a + silent default is worse than an explicit error. +- **`packages/client-sdk` is the in-repo reference for that contract.** It is the + behavioural spec the gateway is tested against and the only client exercised by + `packages/e2e`; web and React Native keep using it (185.5 KB of pure JS). It also reads + the rgb-lib-authored `packages/client-sdk/test/fixtures/rgblib-parity.json`, which is + what keeps this repo's reading of BIP-86 and the RGB coin types honest. + +`pnpm-workspace.yaml` globs `packages/*` only. ## Deliberately deferred diff --git a/minimal-sdk/android/.gitignore b/minimal-sdk/android/.gitignore deleted file mode 100644 index 23279970..00000000 --- a/minimal-sdk/android/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -# Generated by ../scripts/generate_bindings.sh and ../scripts/build_android.sh. -/generated/ -/build/ -/.gradle/ -/.kotlin/ -/local.properties diff --git a/minimal-sdk/android/README.md b/minimal-sdk/android/README.md deleted file mode 100644 index 9d6bda71..00000000 --- a/minimal-sdk/android/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# UTEXO minimal SDK — Android package - -Kotlin bindings and JNI libraries for the minimal SDK core in -[`../rust-sdk`](../rust-sdk): keys, taproot derivation, verify-before-sign, -signing, invoice decoding and the gateway client, in one Rust implementation. -This is **not** the node: no LDK, no rgb-lib, no HTTP or TLS stack. - -- Namespace `com.utexo.minimalsdk`, `minSdk 24`, `compileSdk 34`, AGP 8.7.3, - Kotlin 1.9.24, JDK 17, own Gradle 8.9 wrapper. -- Runtime dependency: `net.java.dev.jna:jna:5.14.0@aar` only (uniffi's Kotlin - bindings call Rust through JNA). -- Networking is the app's: implement `HttpTransport` (one synchronous `send`) - over OkHttp or `HttpURLConnection` and hand it to `GatewayClient`. Rust - performs no IO. - -## Build - -```sh -export JAVA_HOME=~/.jdks/temurin-17 # any JDK 17 -../scripts/generate_bindings.sh kotlin # uniffi library mode → generated/kotlin -../scripts/build_android.sh # cargo ndk (3 ABIs) → generated/jniLibs, then gates -./gradlew test # JVM smoke test through the bindings -./gradlew assembleRelease # AAR in build/outputs/aar -``` - -`./gradlew test` and `assembleRelease` run the two scripts themselves when -their outputs are missing or stale. Requirements: Rust with the three Android -targets, `cargo-ndk`, an Android NDK (r28 or newer; `build_android.sh` picks -the newest under `$ANDROID_HOME/ndk`), and the Android SDK. The unit tests -load the **host** release cdylib through JNA, so `./gradlew test` needs cargo -but no NDK and no device. - -Script options: - -- `build_android.sh [--check-only] [jni_libs_dir]` — `--check-only` re-runs - the gates on an existing tree without building; `jni_libs_dir` defaults to - `generated/jniLibs`. Environment: `ANDROID_NDK_ROOT` / `ANDROID_NDK_HOME` - (pin an NDK instead of the newest installed), `ANDROID_API` (minSdk the - `.so` targets, default 24), `ANDROID_ARM64_BUDGET` (size gate in bytes, - default 3000000). -- `generate_bindings.sh kotlin [output_dir] [library_path]` — `output_dir` - defaults to `generated/kotlin`; pass `library_path` to generate from an - existing unstripped build instead of running `cargo build --lib`. - -## Release gates (`build_android.sh`, also `./gradlew checkAndroidJniLibs`) - -1. **16 KB page alignment.** Every `PT_LOAD` segment of every produced `.so` - must be aligned to at least 16384 bytes. Android 15+ requires it for native - code; a misaligned library is a release blocker. NDK r28+ aligns the 64-bit - ABIs by default but **not** `armeabi-v7a` (measured 4096 on r29), so the - script passes `-Wl,-z,max-page-size=16384` to every target explicitly. -2. **Exported entry points.** Every `.so` must export the uniffi entry points - (`llvm-nm -D` finds `uniffi_utexo_minimal_sdk_fn_func_sdk_version`); a - library that lost them links fine and fails at the first JNA call. -3. **Size.** The single `arm64-v8a` slice must stay under **3.0 MB raw**. - -Measured on 2026-09-15 with NDK r29 (29.0.14206865), release profile -`opt-level="z"`, thin LTO, stripped: - -| Artifact | Raw bytes | -| ------------------------------------------- | --------: | -| `arm64-v8a/libutexo_minimal_sdk.so` | 2 366 720 | -| `armeabi-v7a/libutexo_minimal_sdk.so` | 1 992 820 | -| `x86_64/libutexo_minimal_sdk.so` | 2 508 984 | -| AAR (all three ABIs + 514 KB `classes.jar`) | 5 449 477 | -| JNA 5.14.0 AAR (consumer-side dependency) | 517 894 | - -What a device installs is one slice plus JNA: **≈2.88 MB on arm64**, matching -the plan's honest comparison of ≈2.46 MB (x86_64 host estimate) against -≈1.9 MB for pure Kotlin. Run `build_android.sh --check-only` to reprint the -current numbers; `../rust-sdk/tests/parity.rs` gates the host build at 2.5 MB. - -**Install size tracks the raw `.so` number, not a compressed one.** APKs and -App Bundles store native libraries uncompressed (`extractNativeLibs=false`, -the default since API 23) so they can be mapped directly. About 1.06 MB of -each slice is libsecp256k1's precomputed tables, which any secp256k1 binding -(Rust or Kotlin) carries. - -## One `.so` per device - -The AAR carries all three ABIs. A consumer downloads one slice by shipping as -an **App Bundle** (Play serves the device's ABI) or by enabling ABI `splits` -on the app module; this library declares the same three-ABI split set so no -stray ABI is ever packaged. - -## R8 - -`consumer-rules.pro` keeps only the JNA entry points the generated bindings -reach reflectively (the `Library` interface, `Structure` and `ByReference` -subclasses, `Callback` implementations) plus `RuntimeVisibleAnnotations`, which -JNA needs to read `@Structure.FieldOrder`. There is no blanket keep of -`com.utexo.minimalsdk`; the rest of the package shrinks like any Kotlin code. diff --git a/minimal-sdk/android/build.gradle.kts b/minimal-sdk/android/build.gradle.kts deleted file mode 100644 index 24803743..00000000 --- a/minimal-sdk/android/build.gradle.kts +++ /dev/null @@ -1,171 +0,0 @@ -// Android packaging of the minimal SDK core (minimal-sdk/rust-sdk). -// -// Conventions follow bindings/kotlin-android-sdk/build.gradle.kts (AGP 8.7.3, -// Kotlin 1.9.24, JDK 17, compileSdk 34, minSdk 24, consumerProguardFiles, -// singleVariant("release")) without its uniffi-node source wiring and -// without JReleaser: nothing is published from this project. -// -// Inputs, both produced by scripts under ../scripts and gitignored here: -// generated/kotlin uniffi Kotlin bindings (library mode, no UDL) -// generated/jniLibs /libutexo_minimal_sdk.so for the three ABIs -// The JVM unit tests load the HOST release cdylib through JNA instead of the -// Android .so files, so `./gradlew test` needs cargo and no NDK. - -plugins { - id("com.android.library") version "8.7.3" - id("org.jetbrains.kotlin.android") version "1.9.24" -} - -group = "com.utexo" -version = "0.1.0" - -val rustSdkDir = layout.projectDirectory.dir("../rust-sdk") -val scriptsDir = layout.projectDirectory.dir("../scripts") -val generatedKotlinDir = layout.projectDirectory.dir("generated/kotlin") -val generatedJniLibsDir = layout.projectDirectory.dir("generated/jniLibs") -val hostReleaseDir = rustSdkDir.dir("target/release") -// The rgb-lib-authored parity fixture, read by RELATIVE path from the -// TypeScript client SDK. Never copied, never regenerated here. -val parityFixture = - layout.projectDirectory.file("../packages/client-sdk/test/fixtures/rgblib-parity.json") - -dependencies { - // uniffi's Kotlin bindings talk to the Rust library through JNA. This is - // the library's only dependency. - api("net.java.dev.jna:jna:5.14.0@aar") - - // Test-only: the plain JNA jar carries the host (linux/mac x86_64, arm64) - // jnidispatch the AAR lacks, so JVM unit tests can load the host cdylib. - testImplementation("net.java.dev.jna:jna:5.14.0") - testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.9.24") - testImplementation("junit:junit:4.13.2") - testImplementation("org.json:json:20240303") -} - -android { - namespace = "com.utexo.minimalsdk" - compileSdk = 34 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = "17" - } - - defaultConfig { - minSdk = 24 - consumerProguardFiles("consumer-rules.pro") - ndk { - abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") - } - } - - buildTypes { - release { - isMinifyEnabled = false - } - } - - // ABI splits. The AAR itself always carries all three slices; what makes - // a consumer download ONE .so is the app shipping as an App Bundle (Play - // serves the device's ABI) or configuring the same splits on the app. - // Declaring them here documents the supported set and keeps the AAR from - // ever carrying a stray ABI. - splits { - abi { - isEnable = true - reset() - include("arm64-v8a", "armeabi-v7a", "x86_64") - isUniversalApk = false - } - } - - sourceSets { - getByName("main") { - kotlin.srcDir(generatedKotlinDir) - jniLibs.srcDir(generatedJniLibsDir) - } - } - - publishing { - singleVariant("release") { - withSourcesJar() - } - } - - testOptions { - unitTests.all { test -> - // JNA resolves "utexo_minimal_sdk" against the host release build. - test.systemProperty("jna.library.path", hostReleaseDir.asFile.absolutePath) - test.systemProperty("utexo.minimalsdk.fixture", parityFixture.asFile.absolutePath) - test.systemProperty( - "utexo.minimalsdk.consumerRules", - layout.projectDirectory.file("consumer-rules.pro").asFile.absolutePath, - ) - test.testLogging { - events("passed", "failed", "skipped") - showStandardStreams = true - } - } - } -} - -// ---- Rust build steps, wired so `./gradlew test` and `./gradlew assemble` -// ---- are self-contained on a clean checkout. - -val rustSources = fileTree(rustSdkDir.dir("src")) + files(rustSdkDir.file("Cargo.toml"), rustSdkDir.file("Cargo.lock")) - -// Host release cdylib for the JVM unit tests (same profile the Rust size -// gate measures). -val buildHostLibrary by tasks.registering(Exec::class) { - description = "cargo build --release --lib of the minimal SDK core for the host" - group = "rust" - workingDir = rustSdkDir.asFile - commandLine("cargo", "build", "--release", "--lib") - // Always run: cargo is incremental itself, and Gradle's own up-to-date - // check would need inputs/outputs it cannot see (the cargo target dir). - outputs.upToDateWhen { false } -} - -val generateKotlinBindings by tasks.registering(Exec::class) { - description = "uniffi library-mode Kotlin bindings into generated/kotlin" - group = "rust" - commandLine("bash", scriptsDir.file("generate_bindings.sh").asFile.absolutePath, "kotlin", generatedKotlinDir.asFile.absolutePath) - inputs.files(rustSources) - inputs.file(rustSdkDir.file("uniffi.toml")) - outputs.dir(generatedKotlinDir) -} - -val buildAndroidJniLibs by tasks.registering(Exec::class) { - description = "cargo ndk for arm64-v8a, armeabi-v7a, x86_64 plus the 16 KB alignment, export and size gates" - group = "rust" - commandLine("bash", scriptsDir.file("build_android.sh").asFile.absolutePath, generatedJniLibsDir.asFile.absolutePath) - inputs.files(rustSources) - outputs.dir(generatedJniLibsDir) -} - -val checkAndroidJniLibs by tasks.registering(Exec::class) { - description = "Re-run the 16 KB alignment, export and arm64 size gates on generated/jniLibs" - group = "verification" - dependsOn(buildAndroidJniLibs) - commandLine("bash", scriptsDir.file("build_android.sh").asFile.absolutePath, "--check-only", generatedJniLibsDir.asFile.absolutePath) -} - -tasks.named("preBuild") { - dependsOn(generateKotlinBindings) -} - -// The .so files are only needed when the AAR is packaged, not for unit tests. -tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach { - dependsOn(buildAndroidJniLibs) -} -tasks.matching { it.name.startsWith("bundle") && it.name.endsWith("Aar") }.configureEach { - dependsOn(checkAndroidJniLibs) -} - -tasks.withType().configureEach { - dependsOn(buildHostLibrary) -} diff --git a/minimal-sdk/android/consumer-rules.pro b/minimal-sdk/android/consumer-rules.pro deleted file mode 100644 index 8c628ac5..00000000 --- a/minimal-sdk/android/consumer-rules.pro +++ /dev/null @@ -1,27 +0,0 @@ -# Consumer R8/ProGuard rules for the UTEXO minimal SDK. -# -# Only what R8 must not strip or rename: the JNA entry points the generated -# uniffi bindings rely on through reflection. Everything else in -# com.utexo.minimalsdk is ordinary Kotlin and may be shrunk and obfuscated by -# the consumer like any other library code. Deliberately NO blanket -# `-keep class com.utexo.minimalsdk.** { *; }`. - -# The `UniffiLib : Library` interface: JNA binds each method by NAME to a -# symbol exported from libutexo_minimal_sdk.so, so the names must survive. --keep interface * extends com.sun.jna.Library { *; } -# JNA Structures (RustBuffer, ForeignBytes, UniffiRustCallStatus, ...) are -# laid out from their declared field names and order. The order comes from -# the runtime-retained @Structure.FieldOrder annotation, which JNA reads -# reflectively; R8 drops it unless the attribute is kept, and then every FFI -# call fails with "Structure.getFieldOrder() ... does not provide enough names". --keep class * extends com.sun.jna.Structure { *; } --keepattributes RuntimeVisibleAnnotations --keep class * extends com.sun.jna.ptr.ByReference { *; } -# Foreign-trait callbacks (HttpTransport) are invoked from Rust through a -# JNA Callback whose single `callback` method JNA finds reflectively. --keep class * implements com.sun.jna.Callback { *; } --keep class * extends com.sun.jna.Callback { *; } -# JNA's own runtime is loaded reflectively (Native, Structure, ...). --keep class com.sun.jna.** { *; } -# JNA references java.awt (absent on Android); silence the missing-class warning. --dontwarn java.awt.** diff --git a/minimal-sdk/android/gradle.properties b/minimal-sdk/android/gradle.properties deleted file mode 100644 index e6961679..00000000 --- a/minimal-sdk/android/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -android.useAndroidX=true -kotlin.code.style=official diff --git a/minimal-sdk/android/gradle/wrapper/gradle-wrapper.jar b/minimal-sdk/android/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 2c3521197d7c4586c843d1d3e9090525f1898cde..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43504 zcma&N1CXTcmMvW9vTb(Rwr$&4wr$(C?dmSu>@vG-+vuvg^_??!{yS%8zW-#zn-LkA z5&1^$^{lnmUON?}LBF8_K|(?T0Ra(xUH{($5eN!MR#ZihR#HxkUPe+_R8Cn`RRs(P z_^*#_XlXmGv7!4;*Y%p4nw?{bNp@UZHv1?Um8r6)Fei3p@ClJn0ECfg1hkeuUU@Or zDaPa;U3fE=3L}DooL;8f;P0ipPt0Z~9P0)lbStMS)ag54=uL9ia-Lm3nh|@(Y?B`; zx_#arJIpXH!U{fbCbI^17}6Ri*H<>OLR%c|^mh8+)*h~K8Z!9)DPf zR2h?lbDZQ`p9P;&DQ4F0sur@TMa!Y}S8irn(%d-gi0*WxxCSk*A?3lGh=gcYN?FGl z7D=Js!i~0=u3rox^eO3i@$0=n{K1lPNU zwmfjRVmLOCRfe=seV&P*1Iq=^i`502keY8Uy-WNPwVNNtJFx?IwAyRPZo2Wo1+S(xF37LJZ~%i)kpFQ3Fw=mXfd@>%+)RpYQLnr}B~~zoof(JVm^^&f zxKV^+3D3$A1G;qh4gPVjhrC8e(VYUHv#dy^)(RoUFM?o%W-EHxufuWf(l*@-l+7vt z=l`qmR56K~F|v<^Pd*p~1_y^P0P^aPC##d8+HqX4IR1gu+7w#~TBFphJxF)T$2WEa zxa?H&6=Qe7d(#tha?_1uQys2KtHQ{)Qco)qwGjrdNL7thd^G5i8Os)CHqc>iOidS} z%nFEDdm=GXBw=yXe1W-ShHHFb?Cc70+$W~z_+}nAoHFYI1MV1wZegw*0y^tC*s%3h zhD3tN8b=Gv&rj}!SUM6|ajSPp*58KR7MPpI{oAJCtY~JECm)*m_x>AZEu>DFgUcby z1Qaw8lU4jZpQ_$;*7RME+gq1KySGG#Wql>aL~k9tLrSO()LWn*q&YxHEuzmwd1?aAtI zBJ>P=&$=l1efe1CDU;`Fd+_;&wI07?V0aAIgc(!{a z0Jg6Y=inXc3^n!U0Atk`iCFIQooHqcWhO(qrieUOW8X(x?(RD}iYDLMjSwffH2~tB z)oDgNBLB^AJBM1M^c5HdRx6fBfka`(LD-qrlh5jqH~);#nw|iyp)()xVYak3;Ybik z0j`(+69aK*B>)e_p%=wu8XC&9e{AO4c~O1U`5X9}?0mrd*m$_EUek{R?DNSh(=br# z#Q61gBzEpmy`$pA*6!87 zSDD+=@fTY7<4A?GLqpA?Pb2z$pbCc4B4zL{BeZ?F-8`s$?>*lXXtn*NC61>|*w7J* z$?!iB{6R-0=KFmyp1nnEmLsA-H0a6l+1uaH^g%c(p{iT&YFrbQ$&PRb8Up#X3@Zsk zD^^&LK~111%cqlP%!_gFNa^dTYT?rhkGl}5=fL{a`UViaXWI$k-UcHJwmaH1s=S$4 z%4)PdWJX;hh5UoK?6aWoyLxX&NhNRqKam7tcOkLh{%j3K^4Mgx1@i|Pi&}<^5>hs5 zm8?uOS>%)NzT(%PjVPGa?X%`N2TQCKbeH2l;cTnHiHppPSJ<7y-yEIiC!P*ikl&!B z%+?>VttCOQM@ShFguHVjxX^?mHX^hSaO_;pnyh^v9EumqSZTi+#f&_Vaija0Q-e*| z7ulQj6Fs*bbmsWp{`auM04gGwsYYdNNZcg|ph0OgD>7O}Asn7^Z=eI>`$2*v78;sj-}oMoEj&@)9+ycEOo92xSyY344^ z11Hb8^kdOvbf^GNAK++bYioknrpdN>+u8R?JxG=!2Kd9r=YWCOJYXYuM0cOq^FhEd zBg2puKy__7VT3-r*dG4c62Wgxi52EMCQ`bKgf*#*ou(D4-ZN$+mg&7$u!! z-^+Z%;-3IDwqZ|K=ah85OLwkO zKxNBh+4QHh)u9D?MFtpbl)us}9+V!D%w9jfAMYEb>%$A;u)rrI zuBudh;5PN}_6J_}l55P3l_)&RMlH{m!)ai-i$g)&*M`eN$XQMw{v^r@-125^RRCF0 z^2>|DxhQw(mtNEI2Kj(;KblC7x=JlK$@78`O~>V!`|1Lm-^JR$-5pUANAnb(5}B}JGjBsliK4& zk6y(;$e&h)lh2)L=bvZKbvh@>vLlreBdH8No2>$#%_Wp1U0N7Ank!6$dFSi#xzh|( zRi{Uw%-4W!{IXZ)fWx@XX6;&(m_F%c6~X8hx=BN1&q}*( zoaNjWabE{oUPb!Bt$eyd#$5j9rItB-h*5JiNi(v^e|XKAj*8(k<5-2$&ZBR5fF|JA z9&m4fbzNQnAU}r8ab>fFV%J0z5awe#UZ|bz?Ur)U9bCIKWEzi2%A+5CLqh?}K4JHi z4vtM;+uPsVz{Lfr;78W78gC;z*yTch~4YkLr&m-7%-xc ztw6Mh2d>_iO*$Rd8(-Cr1_V8EO1f*^@wRoSozS) zy1UoC@pruAaC8Z_7~_w4Q6n*&B0AjOmMWa;sIav&gu z|J5&|{=a@vR!~k-OjKEgPFCzcJ>#A1uL&7xTDn;{XBdeM}V=l3B8fE1--DHjSaxoSjNKEM9|U9#m2<3>n{Iuo`r3UZp;>GkT2YBNAh|b z^jTq-hJp(ebZh#Lk8hVBP%qXwv-@vbvoREX$TqRGTgEi$%_F9tZES@z8Bx}$#5eeG zk^UsLBH{bc2VBW)*EdS({yw=?qmevwi?BL6*=12k9zM5gJv1>y#ML4!)iiPzVaH9% zgSImetD@dam~e>{LvVh!phhzpW+iFvWpGT#CVE5TQ40n%F|p(sP5mXxna+Ev7PDwA zamaV4m*^~*xV+&p;W749xhb_X=$|LD;FHuB&JL5?*Y2-oIT(wYY2;73<^#46S~Gx| z^cez%V7x$81}UWqS13Gz80379Rj;6~WdiXWOSsdmzY39L;Hg3MH43o*y8ibNBBH`(av4|u;YPq%{R;IuYow<+GEsf@R?=@tT@!}?#>zIIn0CoyV!hq3mw zHj>OOjfJM3F{RG#6ujzo?y32m^tgSXf@v=J$ELdJ+=5j|=F-~hP$G&}tDZsZE?5rX ztGj`!S>)CFmdkccxM9eGIcGnS2AfK#gXwj%esuIBNJQP1WV~b~+D7PJTmWGTSDrR` zEAu4B8l>NPuhsk5a`rReSya2nfV1EK01+G!x8aBdTs3Io$u5!6n6KX%uv@DxAp3F@{4UYg4SWJtQ-W~0MDb|j-$lwVn znAm*Pl!?Ps&3wO=R115RWKb*JKoexo*)uhhHBncEDMSVa_PyA>k{Zm2(wMQ(5NM3# z)jkza|GoWEQo4^s*wE(gHz?Xsg4`}HUAcs42cM1-qq_=+=!Gk^y710j=66(cSWqUe zklbm8+zB_syQv5A2rj!Vbw8;|$@C!vfNmNV!yJIWDQ>{+2x zKjuFX`~~HKG~^6h5FntRpnnHt=D&rq0>IJ9#F0eM)Y-)GpRjiN7gkA8wvnG#K=q{q z9dBn8_~wm4J<3J_vl|9H{7q6u2A!cW{bp#r*-f{gOV^e=8S{nc1DxMHFwuM$;aVI^ zz6A*}m8N-&x8;aunp1w7_vtB*pa+OYBw=TMc6QK=mbA-|Cf* zvyh8D4LRJImooUaSb7t*fVfih<97Gf@VE0|z>NcBwBQze);Rh!k3K_sfunToZY;f2 z^HmC4KjHRVg+eKYj;PRN^|E0>Gj_zagfRbrki68I^#~6-HaHg3BUW%+clM1xQEdPYt_g<2K+z!$>*$9nQ>; zf9Bei{?zY^-e{q_*|W#2rJG`2fy@{%6u0i_VEWTq$*(ZN37|8lFFFt)nCG({r!q#9 z5VK_kkSJ3?zOH)OezMT{!YkCuSSn!K#-Rhl$uUM(bq*jY? zi1xbMVthJ`E>d>(f3)~fozjg^@eheMF6<)I`oeJYx4*+M&%c9VArn(OM-wp%M<-`x z7sLP1&3^%Nld9Dhm@$3f2}87!quhI@nwd@3~fZl_3LYW-B?Ia>ui`ELg z&Qfe!7m6ze=mZ`Ia9$z|ARSw|IdMpooY4YiPN8K z4B(ts3p%2i(Td=tgEHX z0UQ_>URBtG+-?0E;E7Ld^dyZ;jjw0}XZ(}-QzC6+NN=40oDb2^v!L1g9xRvE#@IBR zO!b-2N7wVfLV;mhEaXQ9XAU+>=XVA6f&T4Z-@AX!leJ8obP^P^wP0aICND?~w&NykJ#54x3_@r7IDMdRNy4Hh;h*!u(Ol(#0bJdwEo$5437-UBjQ+j=Ic>Q2z` zJNDf0yO6@mr6y1#n3)s(W|$iE_i8r@Gd@!DWDqZ7J&~gAm1#~maIGJ1sls^gxL9LLG_NhU!pTGty!TbhzQnu)I*S^54U6Yu%ZeCg`R>Q zhBv$n5j0v%O_j{QYWG!R9W?5_b&67KB$t}&e2LdMvd(PxN6Ir!H4>PNlerpBL>Zvyy!yw z-SOo8caEpDt(}|gKPBd$qND5#a5nju^O>V&;f890?yEOfkSG^HQVmEbM3Ugzu+UtH zC(INPDdraBN?P%kE;*Ae%Wto&sgw(crfZ#Qy(<4nk;S|hD3j{IQRI6Yq|f^basLY; z-HB&Je%Gg}Jt@={_C{L$!RM;$$|iD6vu#3w?v?*;&()uB|I-XqEKqZPS!reW9JkLewLb!70T7n`i!gNtb1%vN- zySZj{8-1>6E%H&=V}LM#xmt`J3XQoaD|@XygXjdZ1+P77-=;=eYpoEQ01B@L*a(uW zrZeZz?HJsw_4g0vhUgkg@VF8<-X$B8pOqCuWAl28uB|@r`19DTUQQsb^pfqB6QtiT z*`_UZ`fT}vtUY#%sq2{rchyfu*pCg;uec2$-$N_xgjZcoumE5vSI{+s@iLWoz^Mf; zuI8kDP{!XY6OP~q5}%1&L}CtfH^N<3o4L@J@zg1-mt{9L`s^z$Vgb|mr{@WiwAqKg zp#t-lhrU>F8o0s1q_9y`gQNf~Vb!F%70f}$>i7o4ho$`uciNf=xgJ>&!gSt0g;M>*x4-`U)ysFW&Vs^Vk6m%?iuWU+o&m(2Jm26Y(3%TL; zA7T)BP{WS!&xmxNw%J=$MPfn(9*^*TV;$JwRy8Zl*yUZi8jWYF>==j~&S|Xinsb%c z2?B+kpet*muEW7@AzjBA^wAJBY8i|#C{WtO_or&Nj2{=6JTTX05}|H>N2B|Wf!*3_ z7hW*j6p3TvpghEc6-wufFiY!%-GvOx*bZrhZu+7?iSrZL5q9}igiF^*R3%DE4aCHZ zqu>xS8LkW+Auv%z-<1Xs92u23R$nk@Pk}MU5!gT|c7vGlEA%G^2th&Q*zfg%-D^=f z&J_}jskj|Q;73NP4<4k*Y%pXPU2Thoqr+5uH1yEYM|VtBPW6lXaetokD0u z9qVek6Q&wk)tFbQ8(^HGf3Wp16gKmr>G;#G(HRBx?F`9AIRboK+;OfHaLJ(P>IP0w zyTbTkx_THEOs%Q&aPrxbZrJlio+hCC_HK<4%f3ZoSAyG7Dn`=X=&h@m*|UYO-4Hq0 z-Bq&+Ie!S##4A6OGoC~>ZW`Y5J)*ouaFl_e9GA*VSL!O_@xGiBw!AF}1{tB)z(w%c zS1Hmrb9OC8>0a_$BzeiN?rkPLc9%&;1CZW*4}CDDNr2gcl_3z+WC15&H1Zc2{o~i) z)LLW=WQ{?ricmC`G1GfJ0Yp4Dy~Ba;j6ZV4r{8xRs`13{dD!xXmr^Aga|C=iSmor% z8hi|pTXH)5Yf&v~exp3o+sY4B^^b*eYkkCYl*T{*=-0HniSA_1F53eCb{x~1k3*`W zr~};p1A`k{1DV9=UPnLDgz{aJH=-LQo<5%+Em!DNN252xwIf*wF_zS^!(XSm(9eoj z=*dXG&n0>)_)N5oc6v!>-bd(2ragD8O=M|wGW z!xJQS<)u70m&6OmrF0WSsr@I%T*c#Qo#Ha4d3COcX+9}hM5!7JIGF>7<~C(Ear^Sn zm^ZFkV6~Ula6+8S?oOROOA6$C&q&dp`>oR-2Ym3(HT@O7Sd5c~+kjrmM)YmgPH*tL zX+znN>`tv;5eOfX?h{AuX^LK~V#gPCu=)Tigtq9&?7Xh$qN|%A$?V*v=&-2F$zTUv z`C#WyIrChS5|Kgm_GeudCFf;)!WH7FI60j^0o#65o6`w*S7R@)88n$1nrgU(oU0M9 zx+EuMkC>(4j1;m6NoGqEkpJYJ?vc|B zOlwT3t&UgL!pX_P*6g36`ZXQ; z9~Cv}ANFnJGp(;ZhS(@FT;3e)0)Kp;h^x;$*xZn*k0U6-&FwI=uOGaODdrsp-!K$Ac32^c{+FhI-HkYd5v=`PGsg%6I`4d9Jy)uW0y%) zm&j^9WBAp*P8#kGJUhB!L?a%h$hJgQrx!6KCB_TRo%9{t0J7KW8!o1B!NC)VGLM5! zpZy5Jc{`r{1e(jd%jsG7k%I+m#CGS*BPA65ZVW~fLYw0dA-H_}O zrkGFL&P1PG9p2(%QiEWm6x;U-U&I#;Em$nx-_I^wtgw3xUPVVu zqSuKnx&dIT-XT+T10p;yjo1Y)z(x1fb8Dzfn8e yu?e%!_ptzGB|8GrCfu%p?(_ zQccdaaVK$5bz;*rnyK{_SQYM>;aES6Qs^lj9lEs6_J+%nIiuQC*fN;z8md>r_~Mfl zU%p5Dt_YT>gQqfr@`cR!$NWr~+`CZb%dn;WtzrAOI>P_JtsB76PYe*<%H(y>qx-`Kq!X_; z<{RpAqYhE=L1r*M)gNF3B8r(<%8mo*SR2hu zccLRZwGARt)Hlo1euqTyM>^!HK*!Q2P;4UYrysje@;(<|$&%vQekbn|0Ruu_Io(w4#%p6ld2Yp7tlA`Y$cciThP zKzNGIMPXX%&Ud0uQh!uQZz|FB`4KGD?3!ND?wQt6!n*f4EmCoJUh&b?;B{|lxs#F- z31~HQ`SF4x$&v00@(P+j1pAaj5!s`)b2RDBp*PB=2IB>oBF!*6vwr7Dp%zpAx*dPr zb@Zjq^XjN?O4QcZ*O+8>)|HlrR>oD*?WQl5ri3R#2?*W6iJ>>kH%KnnME&TT@ZzrHS$Q%LC?n|e>V+D+8D zYc4)QddFz7I8#}y#Wj6>4P%34dZH~OUDb?uP%-E zwjXM(?Sg~1!|wI(RVuxbu)-rH+O=igSho_pDCw(c6b=P zKk4ATlB?bj9+HHlh<_!&z0rx13K3ZrAR8W)!@Y}o`?a*JJsD+twZIv`W)@Y?Amu_u zz``@-e2X}27$i(2=9rvIu5uTUOVhzwu%mNazS|lZb&PT;XE2|B&W1>=B58#*!~D&) zfVmJGg8UdP*fx(>Cj^?yS^zH#o-$Q-*$SnK(ZVFkw+er=>N^7!)FtP3y~Xxnu^nzY zikgB>Nj0%;WOltWIob|}%lo?_C7<``a5hEkx&1ku$|)i>Rh6@3h*`slY=9U}(Ql_< zaNG*J8vb&@zpdhAvv`?{=zDedJ23TD&Zg__snRAH4eh~^oawdYi6A3w8<Ozh@Kw)#bdktM^GVb zrG08?0bG?|NG+w^&JvD*7LAbjED{_Zkc`3H!My>0u5Q}m!+6VokMLXxl`Mkd=g&Xx z-a>m*#G3SLlhbKB!)tnzfWOBV;u;ftU}S!NdD5+YtOjLg?X}dl>7m^gOpihrf1;PY zvll&>dIuUGs{Qnd- zwIR3oIrct8Va^Tm0t#(bJD7c$Z7DO9*7NnRZorrSm`b`cxz>OIC;jSE3DO8`hX955ui`s%||YQtt2 z5DNA&pG-V+4oI2s*x^>-$6J?p=I>C|9wZF8z;VjR??Icg?1w2v5Me+FgAeGGa8(3S z4vg*$>zC-WIVZtJ7}o9{D-7d>zCe|z#<9>CFve-OPAYsneTb^JH!Enaza#j}^mXy1 z+ULn^10+rWLF6j2>Ya@@Kq?26>AqK{A_| zQKb*~F1>sE*=d?A?W7N2j?L09_7n+HGi{VY;MoTGr_)G9)ot$p!-UY5zZ2Xtbm=t z@dpPSGwgH=QtIcEulQNI>S-#ifbnO5EWkI;$A|pxJd885oM+ zGZ0_0gDvG8q2xebj+fbCHYfAXuZStH2j~|d^sBAzo46(K8n59+T6rzBwK)^rfPT+B zyIFw)9YC-V^rhtK`!3jrhmW-sTmM+tPH+;nwjL#-SjQPUZ53L@A>y*rt(#M(qsiB2 zx6B)dI}6Wlsw%bJ8h|(lhkJVogQZA&n{?Vgs6gNSXzuZpEyu*xySy8ro07QZ7Vk1!3tJphN_5V7qOiyK8p z#@jcDD8nmtYi1^l8ml;AF<#IPK?!pqf9D4moYk>d99Im}Jtwj6c#+A;f)CQ*f-hZ< z=p_T86jog%!p)D&5g9taSwYi&eP z#JuEK%+NULWus;0w32-SYFku#i}d~+{Pkho&^{;RxzP&0!RCm3-9K6`>KZpnzS6?L z^H^V*s!8<>x8bomvD%rh>Zp3>Db%kyin;qtl+jAv8Oo~1g~mqGAC&Qi_wy|xEt2iz zWAJEfTV%cl2Cs<1L&DLRVVH05EDq`pH7Oh7sR`NNkL%wi}8n>IXcO40hp+J+sC!W?!krJf!GJNE8uj zg-y~Ns-<~D?yqbzVRB}G>0A^f0!^N7l=$m0OdZuqAOQqLc zX?AEGr1Ht+inZ-Qiwnl@Z0qukd__a!C*CKuGdy5#nD7VUBM^6OCpxCa2A(X;e0&V4 zM&WR8+wErQ7UIc6LY~Q9x%Sn*Tn>>P`^t&idaOEnOd(Ufw#>NoR^1QdhJ8s`h^|R_ zXX`c5*O~Xdvh%q;7L!_!ohf$NfEBmCde|#uVZvEo>OfEq%+Ns7&_f$OR9xsihRpBb z+cjk8LyDm@U{YN>+r46?nn{7Gh(;WhFw6GAxtcKD+YWV?uge>;+q#Xx4!GpRkVZYu zzsF}1)7$?%s9g9CH=Zs+B%M_)+~*j3L0&Q9u7!|+T`^O{xE6qvAP?XWv9_MrZKdo& z%IyU)$Q95AB4!#hT!_dA>4e@zjOBD*Y=XjtMm)V|+IXzjuM;(l+8aA5#Kaz_$rR6! zj>#&^DidYD$nUY(D$mH`9eb|dtV0b{S>H6FBfq>t5`;OxA4Nn{J(+XihF(stSche7$es&~N$epi&PDM_N`As;*9D^L==2Q7Z2zD+CiU(|+-kL*VG+&9!Yb3LgPy?A zm7Z&^qRG_JIxK7-FBzZI3Q<;{`DIxtc48k> zc|0dmX;Z=W$+)qE)~`yn6MdoJ4co;%!`ddy+FV538Y)j(vg}5*k(WK)KWZ3WaOG!8 z!syGn=s{H$odtpqFrT#JGM*utN7B((abXnpDM6w56nhw}OY}0TiTG1#f*VFZr+^-g zbP10`$LPq_;PvrA1XXlyx2uM^mrjTzX}w{yuLo-cOClE8MMk47T25G8M!9Z5ypOSV zAJUBGEg5L2fY)ZGJb^E34R2zJ?}Vf>{~gB!8=5Z) z9y$>5c)=;o0HeHHSuE4U)#vG&KF|I%-cF6f$~pdYJWk_dD}iOA>iA$O$+4%@>JU08 zS`ep)$XLPJ+n0_i@PkF#ri6T8?ZeAot$6JIYHm&P6EB=BiaNY|aA$W0I+nz*zkz_z zkEru!tj!QUffq%)8y0y`T&`fuus-1p>=^hnBiBqD^hXrPs`PY9tU3m0np~rISY09> z`P3s=-kt_cYcxWd{de@}TwSqg*xVhp;E9zCsnXo6z z?f&Sv^U7n4`xr=mXle94HzOdN!2kB~4=%)u&N!+2;z6UYKUDqi-s6AZ!haB;@&B`? z_TRX0%@suz^TRdCb?!vNJYPY8L_}&07uySH9%W^Tc&1pia6y1q#?*Drf}GjGbPjBS zbOPcUY#*$3sL2x4v_i*Y=N7E$mR}J%|GUI(>WEr+28+V z%v5{#e!UF*6~G&%;l*q*$V?&r$Pp^sE^i-0$+RH3ERUUdQ0>rAq2(2QAbG}$y{de( z>{qD~GGuOk559Y@%$?N^1ApVL_a704>8OD%8Y%8B;FCt%AoPu8*D1 zLB5X>b}Syz81pn;xnB}%0FnwazlWfUV)Z-~rZg6~b z6!9J$EcE&sEbzcy?CI~=boWA&eeIa%z(7SE^qgVLz??1Vbc1*aRvc%Mri)AJaAG!p z$X!_9Ds;Zz)f+;%s&dRcJt2==P{^j3bf0M=nJd&xwUGlUFn?H=2W(*2I2Gdu zv!gYCwM10aeus)`RIZSrCK=&oKaO_Ry~D1B5!y0R=%!i2*KfXGYX&gNv_u+n9wiR5 z*e$Zjju&ODRW3phN925%S(jL+bCHv6rZtc?!*`1TyYXT6%Ju=|X;6D@lq$8T zW{Y|e39ioPez(pBH%k)HzFITXHvnD6hw^lIoUMA;qAJ^CU?top1fo@s7xT13Fvn1H z6JWa-6+FJF#x>~+A;D~;VDs26>^oH0EI`IYT2iagy23?nyJ==i{g4%HrAf1-*v zK1)~@&(KkwR7TL}L(A@C_S0G;-GMDy=MJn2$FP5s<%wC)4jC5PXoxrQBFZ_k0P{{s@sz+gX`-!=T8rcB(=7vW}^K6oLWMmp(rwDh}b zwaGGd>yEy6fHv%jM$yJXo5oMAQ>c9j`**}F?MCry;T@47@r?&sKHgVe$MCqk#Z_3S z1GZI~nOEN*P~+UaFGnj{{Jo@16`(qVNtbU>O0Hf57-P>x8Jikp=`s8xWs^dAJ9lCQ z)GFm+=OV%AMVqVATtN@|vp61VVAHRn87}%PC^RAzJ%JngmZTasWBAWsoAqBU+8L8u z4A&Pe?fmTm0?mK-BL9t+{y7o(7jm+RpOhL9KnY#E&qu^}B6=K_dB}*VlSEiC9fn)+V=J;OnN)Ta5v66ic1rG+dGAJ1 z1%Zb_+!$=tQ~lxQrzv3x#CPb?CekEkA}0MYSgx$Jdd}q8+R=ma$|&1a#)TQ=l$1tQ z=tL9&_^vJ)Pk}EDO-va`UCT1m#Uty1{v^A3P~83_#v^ozH}6*9mIjIr;t3Uv%@VeW zGL6(CwCUp)Jq%G0bIG%?{_*Y#5IHf*5M@wPo6A{$Um++Co$wLC=J1aoG93&T7Ho}P z=mGEPP7GbvoG!uD$k(H3A$Z))+i{Hy?QHdk>3xSBXR0j!11O^mEe9RHmw!pvzv?Ua~2_l2Yh~_!s1qS`|0~0)YsbHSz8!mG)WiJE| z2f($6TQtt6L_f~ApQYQKSb=`053LgrQq7G@98#igV>y#i==-nEjQ!XNu9 z~;mE+gtj4IDDNQJ~JVk5Ux6&LCSFL!y=>79kE9=V}J7tD==Ga+IW zX)r7>VZ9dY=V&}DR))xUoV!u(Z|%3ciQi_2jl}3=$Agc(`RPb z8kEBpvY>1FGQ9W$n>Cq=DIpski};nE)`p3IUw1Oz0|wxll^)4dq3;CCY@RyJgFgc# zKouFh!`?Xuo{IMz^xi-h=StCis_M7yq$u) z?XHvw*HP0VgR+KR6wI)jEMX|ssqYvSf*_3W8zVTQzD?3>H!#>InzpSO)@SC8q*ii- z%%h}_#0{4JG;Jm`4zg};BPTGkYamx$Xo#O~lBirRY)q=5M45n{GCfV7h9qwyu1NxOMoP4)jjZMxmT|IQQh0U7C$EbnMN<3)Kk?fFHYq$d|ICu>KbY_hO zTZM+uKHe(cIZfEqyzyYSUBZa8;Fcut-GN!HSA9ius`ltNebF46ZX_BbZNU}}ZOm{M2&nANL9@0qvih15(|`S~z}m&h!u4x~(%MAO$jHRWNfuxWF#B)E&g3ghSQ9|> z(MFaLQj)NE0lowyjvg8z0#m6FIuKE9lDO~Glg}nSb7`~^&#(Lw{}GVOS>U)m8bF}x zVjbXljBm34Cs-yM6TVusr+3kYFjr28STT3g056y3cH5Tmge~ASxBj z%|yb>$eF;WgrcOZf569sDZOVwoo%8>XO>XQOX1OyN9I-SQgrm;U;+#3OI(zrWyow3 zk==|{lt2xrQ%FIXOTejR>;wv(Pb8u8}BUpx?yd(Abh6? zsoO3VYWkeLnF43&@*#MQ9-i-d0t*xN-UEyNKeyNMHw|A(k(_6QKO=nKMCxD(W(Yop zsRQ)QeL4X3Lxp^L%wzi2-WVSsf61dqliPUM7srDB?Wm6Lzn0&{*}|IsKQW;02(Y&| zaTKv|`U(pSzuvR6Rduu$wzK_W-Y-7>7s?G$)U}&uK;<>vU}^^ns@Z!p+9?St1s)dG zK%y6xkPyyS1$~&6v{kl?Md6gwM|>mt6Upm>oa8RLD^8T{0?HC!Z>;(Bob7el(DV6x zi`I)$&E&ngwFS@bi4^xFLAn`=fzTC;aimE^!cMI2n@Vo%Ae-ne`RF((&5y6xsjjAZ zVguVoQ?Z9uk$2ON;ersE%PU*xGO@T*;j1BO5#TuZKEf(mB7|g7pcEA=nYJ{s3vlbg zd4-DUlD{*6o%Gc^N!Nptgay>j6E5;3psI+C3Q!1ZIbeCubW%w4pq9)MSDyB{HLm|k zxv-{$$A*pS@csolri$Ge<4VZ}e~78JOL-EVyrbxKra^d{?|NnPp86!q>t<&IP07?Z z^>~IK^k#OEKgRH+LjllZXk7iA>2cfH6+(e&9ku5poo~6y{GC5>(bRK7hwjiurqAiZ zg*DmtgY}v83IjE&AbiWgMyFbaRUPZ{lYiz$U^&Zt2YjG<%m((&_JUbZcfJ22(>bi5 z!J?<7AySj0JZ&<-qXX;mcV!f~>G=sB0KnjWca4}vrtunD^1TrpfeS^4dvFr!65knK zZh`d;*VOkPs4*-9kL>$GP0`(M!j~B;#x?Ba~&s6CopvO86oM?-? zOw#dIRc;6A6T?B`Qp%^<U5 z19x(ywSH$_N+Io!6;e?`tWaM$`=Db!gzx|lQ${DG!zb1Zl&|{kX0y6xvO1o z220r<-oaS^^R2pEyY;=Qllqpmue|5yI~D|iI!IGt@iod{Opz@*ml^w2bNs)p`M(Io z|E;;m*Xpjd9l)4G#KaWfV(t8YUn@A;nK^#xgv=LtnArX|vWQVuw3}B${h+frU2>9^ z!l6)!Uo4`5k`<<;E(ido7M6lKTgWezNLq>U*=uz&s=cc$1%>VrAeOoUtA|T6gO4>UNqsdK=NF*8|~*sl&wI=x9-EGiq*aqV!(VVXA57 zw9*o6Ir8Lj1npUXvlevtn(_+^X5rzdR>#(}4YcB9O50q97%rW2me5_L=%ffYPUSRc z!vv?Kv>dH994Qi>U(a<0KF6NH5b16enCp+mw^Hb3Xs1^tThFpz!3QuN#}KBbww`(h z7GO)1olDqy6?T$()R7y%NYx*B0k_2IBiZ14&8|JPFxeMF{vW>HF-ViB*%t0;Thq2} z+qP}n=Cp0wwr%5S+qN<7?r+``=l(h0z2`^8j;g2~Q4u?{cIL{JYY%l|iw&YH4FL(8 z1-*E#ANDHi+1f%lMJbRfq*`nG)*#?EJEVoDH5XdfqwR-C{zmbQoh?E zhW!|TvYv~>R*OAnyZf@gC+=%}6N90yU@E;0b_OV#xL9B?GX(D&7BkujjFC@HVKFci zb_>I5e!yuHA1LC`xm&;wnn|3ht3h7|rDaOsh0ePhcg_^Wh8Bq|AGe`4t5Gk(9^F;M z8mFr{uCm{)Uq0Xa$Fw6+da`C4%)M_#jaX$xj;}&Lzc8wTc%r!Y#1akd|6FMf(a4I6 z`cQqS_{rm0iLnhMG~CfDZc96G3O=Tihnv8g;*w?)C4N4LE0m#H1?-P=4{KeC+o}8b zZX)x#(zEysFm$v9W8-4lkW%VJIjM~iQIVW)A*RCO{Oe_L;rQ3BmF*bhWa}!=wcu@# zaRWW{&7~V-e_$s)j!lJsa-J?z;54!;KnU3vuhp~(9KRU2GKYfPj{qA?;#}H5f$Wv-_ zGrTb(EAnpR0*pKft3a}6$npzzq{}ApC&=C&9KoM3Ge@24D^8ZWJDiXq@r{hP=-02& z@Qrn-cbr2YFc$7XR0j7{jAyR;4LLBf_XNSrmd{dV3;ae;fsEjds*2DZ&@#e)Qcc}w zLgkfW=9Kz|eeM$E`-+=jQSt}*kAwbMBn7AZSAjkHUn4n||NBq*|2QPcKaceA6m)g5 z_}3?DX>90X|35eI7?n+>f9+hl5b>#q`2+`FXbOu9Q94UX-GWH;d*dpmSFd~7WM#H2 zvKNxjOtC)U_tx*0(J)eAI8xAD8SvhZ+VRUA?)| zeJjvg9)vi`Qx;;1QP!c_6hJp1=J=*%!>ug}%O!CoSh-D_6LK0JyiY}rOaqSeja&jb#P|DR7 z_JannlfrFeaE$irfrRIiN|huXmQhQUN6VG*6`bzN4Z3!*G?FjN8!`ZTn6Wn4n=Ync z_|Sq=pO7+~{W2}599SfKz@umgRYj6LR9u0*BaHqdEw^i)dKo5HomT9zzB$I6w$r?6 zs2gu*wNOAMK`+5yPBIxSOJpL$@SN&iUaM zQ3%$EQt%zQBNd`+rl9R~utRDAH%7XP@2Z1s=)ks77I(>#FuwydE5>LzFx)8ye4ClM zb*e2i*E$Te%hTKh7`&rQXz;gvm4Dam(r-!FBEcw*b$U%Wo9DIPOwlC5Ywm3WRCM4{ zF42rnEbBzUP>o>MA){;KANhAW7=FKR=DKK&S1AqSxyP;k z;fp_GVuV}y6YqAd)5p=tJ~0KtaeRQv^nvO?*hZEK-qA;vuIo!}Xgec4QGW2ipf2HK z&G&ppF*1aC`C!FR9(j4&r|SHy74IiDky~3Ab)z@9r&vF+Bapx<{u~gb2?*J zSl{6YcZ$&m*X)X?|8<2S}WDrWN3yhyY7wlf*q`n^z3LT4T$@$y``b{m953kfBBPpQ7hT;zs(Nme`Qw@{_pUO0OG zfugi3N?l|jn-Du3Qn{Aa2#6w&qT+oof=YM!Zq~Xi`vlg<;^)Jreeb^x6_4HL-j}sU z1U^^;-WetwPLKMsdx4QZ$haq3)rA#ATpEh{NXto-tOXjCwO~nJ(Z9F%plZ{z(ZW!e zF>nv&4ViOTs58M+f+sGimF^9cB*9b(gAizwyu5|--SLmBOP-uftqVnVBd$f7YrkJ8!jm*QQEQC zEQ+@T*AA1kV@SPF6H5sT%^$$6!e5;#N((^=OA5t}bqIdqf`PiMMFEDhnV#AQWSfLp zX=|ZEsbLt8Sk&wegQU0&kMC|cuY`&@<#r{t2*sq2$%epiTVpJxWm#OPC^wo_4p++U zU|%XFYs+ZCS4JHSRaVET)jV?lbYAd4ouXx0Ka6*wIFBRgvBgmg$kTNQEvs0=2s^sU z_909)3`Ut!m}}@sv<63E@aQx}-!qVdOjSOnAXTh~MKvr$0nr(1Fj-3uS{U6-T9NG1Y(Ua)Nc}Mi< zOBQz^&^v*$BqmTIO^;r@kpaq3n!BI?L{#bw)pdFV&M?D0HKqC*YBxa;QD_4(RlawI z5wBK;7T^4dT7zt%%P<*-M~m?Et;S^tdNgQSn?4$mFvIHHL!`-@K~_Ar4vBnhy{xuy zigp!>UAwPyl!@~(bkOY;un&B~Evy@5#Y&cEmzGm+)L~4o4~|g0uu&9bh8N0`&{B2b zDj2>biRE1`iw}lv!rl$Smn(4Ob>j<{4dT^TfLe-`cm#S!w_9f;U)@aXWSU4}90LuR zVcbw;`2|6ra88#Cjf#u62xq?J)}I)_y{`@hzES(@mX~}cPWI8}SRoH-H;o~`>JWU$ zhLudK3ug%iS=xjv9tnmOdTXcq_?&o30O;(+VmC&p+%+pd_`V}RY4ibQMNE&N5O+hb3bQ8bxk^33Fu4DB2*~t1909gqoutQHx^plq~;@g$d_+rzS0`2;}2UR2h#?p35B=B*f0BZS4ysiWC!kw?4B-dM%m6_BfRbey1Wh? zT1!@>-y=U}^fxH0A`u1)Mz90G6-<4aW^a@l_9L6Y;cd$3<#xIrhup)XLkFi$W&Ohu z8_j~-VeVXDf9b&6aGelt$g*BzEHgzh)KDgII_Y zb$fcY8?XI6-GEGTZVWW%O;njZld)29a_&1QvNYJ@OpFrUH{er@mnh*}326TYAK7_Z zA={KnK_o3QLk|%m@bx3U#^tCChLxjPxMesOc5D4G+&mvp@Clicz^=kQlWp1|+z|V7 zkU#7l61m@^#`1`{+m2L{sZC#j?#>0)2z4}}kqGhB{NX%~+3{5jOyij!e$5-OAs zDvq+>I2(XsY9%NNhNvKiF<%!6t^7&k{L7~FLdkP9!h%=2Kt$bUt(Zwp*&xq_+nco5 zK#5RCM_@b4WBK*~$CsWj!N!3sF>ijS=~$}_iw@vbKaSp5Jfg89?peR@51M5}xwcHW z(@1TK_kq$c4lmyb=aX3-JORe+JmuNkPP=bM*B?};c=_;h2gT-nt#qbriPkpaqoF@q z<)!80iKvTu`T-B3VT%qKO^lfPQ#m5Ei6Y%Fs@%Pt!8yX&C#tL$=|Ma8i?*^9;}Fk> zyzdQQC5YTBO&gx6kB~yhUUT&%q3a3o+zueh>5D7tdByYVcMz@>j!C@Iyg{N1)veYl`SPshuH6Rk=O6pvVrI71rI5*%uU3u81DpD%qmXsbKWMFR@2m4vO_^l6MMbO9a()DcWmYT&?0B_ zuY~tDiQ6*X7;9B*5pj?;xy_B}*{G}LjW*qU&%*QAyt30@-@O&NQTARZ+%VScr>`s^KX;M!p; z?8)|}P}L_CbOn!u(A{c5?g{s31Kn#7i)U@+_KNU-ZyVD$H7rtOjSht8%N(ST-)%r` z63;Hyp^KIm-?D;E-EnpAAWgz2#z{fawTx_;MR7)O6X~*jm*VUkam7>ueT^@+Gb3-Y zN3@wZls8ibbpaoR2xH=$b3x1Ng5Tai=LT2@_P&4JuBQ!r#Py3ew!ZVH4~T!^TcdyC ze#^@k4a(nNe~G+y zI~yXK@1HHWU4pj{gWT6v@$c(x){cLq*KlFeKy?f$_u##)hDu0X_mwL6uKei~oPd9( zRaF_k&w(J3J8b_`F~?0(Ei_pH}U^c&r$uSYawB8Ybs-JZ|&;vKLWX! z|HFZ%-uBDaP*hMcQKf*|j5!b%H40SPD*#{A`kj|~esk@1?q}-O7WyAm3mD@-vHzw( zTSOlO(K9>GW;@?@xSwpk%X3Ui4_Psm;c*HF~RW+q+C#RO_VT5(x!5B#On-W`T|u z>>=t)W{=B-8wWZejxMaBC9sHzBZGv5uz_uu281kxHg2cll_sZBC&1AKD`CYh2vKeW zm#|MMdC}6A&^DX=>_(etx8f}9o}`(G?Y``M?D+aTPJbZqONmSs>y>WSbvs>7PE~cb zjO+1Y)PMi*!=06^$%< z*{b^66BIl{7zKvz^jut7ylDQBt)ba_F*$UkDgJ2gSNfHB6+`OEiz@xs$Tcrl>X4?o zu9~~b&Xl0?w(7lJXu8-9Yh6V|A3f?)1|~+u-q&6#YV`U2i?XIqUw*lc-QTXwuf@8d zSjMe1BhBKY`Mo{$s%Ce~Hv(^B{K%w{yndEtvyYjjbvFY^rn2>C1Lbi!3RV7F>&;zlSDSk}R>{twI}V zA~NK%T!z=^!qbw(OEgsmSj?#?GR&A$0&K>^(?^4iphc3rN_(xXA%joi)k~DmRLEXl zaWmwMolK%@YiyI|HvX{X$*Ei7y+zJ%m{b}$?N7_SN&p+FpeT%4Z_2`0CP=}Y3D-*@ zL|4W4ja#8*%SfkZzn5sfVknpJv&>glRk^oUqykedE8yCgIwCV)fC1iVwMr4hc#KcV!|M-r_N|nQWw@`j+0(Ywct~kLXQ)Qyncmi{Q4`Ur7A{Ep)n`zCtm8D zVX`kxa8Syc`g$6$($Qc-(_|LtQKWZXDrTir5s*pSVmGhk#dKJzCYT?vqA9}N9DGv> zw}N$byrt?Mk*ZZbN5&zb>pv;rU}EH@Rp54)vhZ=330bLvrKPEPu!WqR%yeM3LB!(E zw|J05Y!tajnZ9Ml*-aX&5T8YtuWDq@on)_*FMhz-?m|>RT0~e3OHllrEMthVY(KwQ zu>ijTc4>Xz-q1(g!ESjaZ+C+Zk5FgmF)rFX29_RmU!`7Pw+0}>8xK^=pOxtUDV)ok zw-=p=OvEH&VO3wToRdI!hPHc`qX+_{T_mj!NxcA&xOgkEuvz`-Aa`ZlNv>qnD0`YT1T3USO0ec!%{KE~UOGPJX%I5_rZDGx@|w zVIMsRPP+}^Xxa&{x!q{hY1wat8jDO7YP0(8xHWeEdrd79lUjB8%)v{X1pQu|1dr*y9M&a(J`038}4>lK&K zIM~6wnX{XA?pFHz{hOmEq{oYBnB@56twXqEcFrFqvCy)sH9B{pQ`G50o{W^t&onwY z-l{ur4#8ylPV5YRLD%%j^d0&_WI>0nmfZ8! zaZ&vo@7D`!=?215+Vk181*U@^{U>VyoXh2F&ZNzZx5tDDtlLc)gi2=|o=GC`uaH;< zFuuF?Q9Q`>S#c(~2p|s49RA`3242`2P+)F)t2N!CIrcl^0#gN@MLRDQ2W4S#MXZJO z8<(9P>MvW;rf2qZ$6sHxCVIr0B-gP?G{5jEDn%W#{T#2_&eIjvlVqm8J$*8A#n`5r zs6PuC!JuZJ@<8cFbbP{cRnIZs>B`?`rPWWL*A?1C3QqGEG?*&!*S0|DgB~`vo_xIo z&n_Sa(>6<$P7%Py{R<>n6Jy?3W|mYYoxe5h^b6C#+UoKJ(zl?^WcBn#|7wMI5=?S# zRgk8l-J`oM%GV&jFc)9&h#9mAyowg^v%Fc-7_^ou5$*YvELa!1q>4tHfX7&PCGqW* zu8In~5`Q5qQvMdToE$w+RP^_cIS2xJjghjCTp6Z(za_D<$S;0Xjt?mAE8~Ym{)zfb zV62v9|59XOvR}wEpm~Cnhyr`=JfC$*o15k?T`3s-ZqF6Gy;Gm+_6H$%oJPywWA^Wl zzn$L=N%{VT8DkQba0|2LqGR#O2Pw!b%LV4#Ojcx5`?Cm;+aLpkyZ=!r1z@E}V= z$2v6v%Ai)MMd`@IM&UD!%%(63VH8+m0Ebk<5Du#0=WeK(E<2~3@>8TceT$wy5F52n zRFtY>G9Gp~h#&R92{G{jLruZSNJ4)gNK+zg*$P zW@~Hf>_Do)tvfEAAMKE1nQ=8coTgog&S;wj(s?Xa0!r?UU5#2>18V#|tKvay1Ka53 zl$RxpMqrkv`Sv&#!_u8$8PMken`QL0_sD2)r&dZziefzSlAdKNKroVU;gRJE#o*}w zP_bO{F4g;|t!iroy^xf~(Q5qc8a3<+vBW%VIOQ1!??d;yEn1at1wpt}*n- z0iQtfu}Isw4ZfH~8p~#RQUKwf<$XeqUr-5?8TSqokdHL7tY|47R; z#d+4NS%Cqp>LQbvvAMIhcCX@|HozKXl)%*5o>P2ZegGuOerV&_MeA}|+o-3L!ZNJd z#1xB^(r!IfE~i>*5r{u;pIfCjhY^Oev$Y1MT16w8pJ0?9@&FH*`d;hS=c#F6fq z{mqsHd*xa;>Hg?j80MwZ%}anqc@&s&2v{vHQS68fueNi5Z(VD2eH>jmv4uvE|HEQm z^=b&?1R9?<@=kjtUfm*I!wPf5Xnma(4*DfPk}Es*H$%NGCIM1qt(LSvbl7&tV>e2$ zUqvZOTiwQyxDoxL(mn?n_x%Tre?L&!FYCOy0>o}#DTC3uSPnyGBv*}!*Yv5IV)Bg_t%V+UrTXfr!Q8+eX}ANR*YLzwme7Rl z@q_*fP7wP2AZ(3WG*)4Z(q@)~c{Je&7?w^?&Wy3)v0{TvNQRGle9mIG>$M2TtQ(Vf z3*PV@1mX)}beRTPjoG#&&IO#Mn(DLGp}mn)_0e=9kXDewC8Pk@yo<8@XZjFP-_zic z{mocvT9Eo)H4Oj$>1->^#DbbiJn^M4?v7XbK>co+v=7g$hE{#HoG6ZEat!s~I<^_s zlFee93KDSbJKlv_+GPfC6P8b>(;dlJ5r9&Pc4kC2uR(0{Kjf+SMeUktef``iXD}8` zGufkM9*Sx4>+5WcK#Vqm$g#5z1DUhc_#gLGe4_icSzN5GKr|J&eB)LS;jTXWA$?(k zy?*%U9Q#Y88(blIlxrtKp6^jksNF>-K1?8=pmYAPj?qq}yO5L>_s8CAv=LQMe3J6? zOfWD>Kx_5A4jRoIU}&aICTgdYMqC|45}St;@0~7>Af+uK3vps9D!9qD)1;Y6Fz>4^ zR1X$s{QNZl7l%}Zwo2wXP+Cj-K|^wqZW?)s1WUw_APZLhH55g{wNW3liInD)WHh${ zOz&K>sB*4inVY3m)3z8w!yUz+CKF%_-s2KVr7DpwTUuZjPS9k-em^;>H4*?*B0Bg7 zLy2nfU=ac5N}x1+Tlq^lkNmB~Dj+t&l#fO&%|7~2iw*N!*xBy+ZBQ>#g_;I*+J{W* z=@*15><)Bh9f>>dgQrEhkrr2FEJ;R2rH%`kda8sD-FY6e#7S-<)V*zQA>)Ps)L- zgUuu@5;Ych#jX_KZ+;qEJJbu{_Z9WSsLSo#XqLpCK$gFidk}gddW(9$v}iyGm_OoH ztn$pv81zROq686_7@avq2heXZnkRi4n(3{5jTDO?9iP%u8S4KEqGL?^uBeg(-ws#1 z9!!Y_2Q~D?gCL3MQZO!n$+Wy(Twr5AS3{F7ak2f)Bu0iG^k^x??0}b6l!>Vjp{e*F z8r*(Y?3ZDDoS1G?lz#J4`d9jAEc9YGq1LbpYoFl!W!(j8-33Ey)@yx+BVpDIVyvpZ zq5QgKy>P}LlV?Bgy@I)JvefCG)I69H1;q@{8E8Ytw^s-rC7m5>Q>ZO(`$`9@`49s2)q#{2eN0A?~qS8%wxh%P*99h*Sv` zW_z3<=iRZBQKaDsKw^TfN;6`mRck|6Yt&e$R~tMA0ix;qgw$n~fe=62aG2v0S`7mU zI}gR#W)f+Gn=e3mm*F^r^tcv&S`Rym`X`6K`i8g-a0!p|#69@Bl!*&)QJ9(E7ycxz z)5-m9v`~$N1zszFi^=m%vw}Y{ZyYub!-6^KIY@mwF|W+|t~bZ%@rifEZ-28I@s$C` z>E+k~R1JC-M>8iC_GR>V9f9+uL2wPRATL9bC(sxd;AMJ>v6c#PcG|Xx1N5^1>ISd0 z4%vf-SNOw+1%yQq1YP`>iqq>5Q590_pr?OxS|HbLjx=9~Y)QO37RihG%JrJ^=Nj>g zPTcO$6r{jdE_096b&L;Wm8vcxUVxF0mA%W`aZz4n6XtvOi($ zaL!{WUCh&{5ar=>u)!mit|&EkGY$|YG<_)ZD)I32uEIWwu`R-_ z`FVeKyrx3>8Ep#2~%VVrQ%u#exo!anPe`bc)-M=^IP1n1?L2UQ@# zpNjoq-0+XCfqXS!LwMgFvG$PkX}5^6yxW)6%`S8{r~BA2-c%-u5SE#%mQ~5JQ=o$c z%+qa0udVq9`|=2n=0k#M=yiEh_vp?(tB|{J{EhVLPM^S@f-O*Lgb390BvwK7{wfdMKqUc0uIXKj5>g^z z#2`5^)>T73Eci+=E4n&jl42E@VYF2*UDiWLUOgF#p9`E4&-A#MJLUa&^hB@g7KL+n zr_bz+kfCcLIlAevILckIq~RCwh6dc5@%yN@#f3lhHIx4fZ_yT~o0#3@h#!HCN(rHHC6#0$+1AMq?bY~(3nn{o5g8{*e_#4RhW)xPmK zTYBEntuYd)`?`bzDksI9*MG$=^w!iiIcWg1lD&kM1NF@qKha0fDVz^W7JCam^!AQFxY@7*`a3tfBwN0uK_~YBQ18@^i%=YB}K0Iq(Q3 z=7hNZ#!N@YErE7{T|{kjVFZ+f9Hn($zih;f&q^wO)PJSF`K)|LdT>!^JLf=zXG>>G z15TmM=X`1%Ynk&dvu$Vic!XyFC(c=qM33v&SIl|p+z6Ah9(XQ0CWE^N-LgE#WF6Z+ zb_v`7^Rz8%KKg_@B>5*s-q*TVwu~MCRiXvVx&_3#r1h&L+{rM&-H6 zrcgH@I>0eY8WBX#Qj}Vml+fpv?;EQXBbD0lx%L?E4)b-nvrmMQS^}p_CI3M24IK(f| zV?tWzkaJXH87MBz^HyVKT&oHB;A4DRhZy;fIC-TlvECK)nu4-3s7qJfF-ZZGt7+6C3xZt!ZX4`M{eN|q!y*d^B+cF5W- zc9C|FzL;$bAfh56fg&y0j!PF8mjBV!qA=z$=~r-orU-{0AcQUt4 zNYC=_9(MOWe$Br9_50i#0z!*a1>U6ZvH>JYS9U$kkrCt7!mEUJR$W#Jt5vT?U&LCD zd@)kn%y|rkV|CijnZ((B2=j_rB;`b}F9+E1T46sg_aOPp+&*W~44r9t3AI}z)yUFJ z+}z5E6|oq+oPC3Jli)EPh9)o^B4KUYkk~AU9!g`OvC`a!#Q>JmDiMLTx>96_iDD9h@nW%Je4%>URwYM%5YU1&Dcdulvv3IH3GSrA4$)QjlGwUt6 zsR6+PnyJ$1x{|R=ogzErr~U|X!+b+F8=6y?Yi`E$yjWXsdmxZa^hIqa)YV9ubUqOj&IGY}bk zH4*DEn({py@MG5LQCI;J#6+98GaZYGW-K-&C`(r5#?R0Z){DlY8ZZk}lIi$xG}Q@2 z0LJhzuus-7dLAEpG1Lf+KOxn&NSwO{wn_~e0=}dovX)T(|WRMTqacoW8;A>8tTDr+0yRa+U!LW z!H#Gnf^iCy$tTk3kBBC=r@xhskjf1}NOkEEM4*r+A4`yNAIjz`_JMUI#xTf$+{UA7 zpBO_aJkKz)iaKqRA{8a6AtpdUwtc#Y-hxtZnWz~i(sfjMk`lq|kGea=`62V6y)TMPZw8q}tFDDHrW_n(Z84ZxWvRrntcw;F|Mv4ff9iaM% z4IM{=*zw}vIpbg=9%w&v`sA+a3UV@Rpn<6`c&5h+8a7izP>E@7CSsCv*AAvd-izwU z!sGJQ?fpCbt+LK`6m2Z3&cKtgcElAl){*m0b^0U#n<7?`8ktdIe#ytZTvaZy728o6 z3GDmw=vhh*U#hCo0gb9s#V5(IILXkw>(6a?BFdIb0%3~Y*5FiMh&JWHd2n(|y@?F8 zL$%!)uFu&n+1(6)oW6Hx*?{d~y zBeR)N*Z{7*gMlhMOad#k4gf`37OzEJ&pH?h!Z4#mNNCfnDI@LbiU~&2Gd^q7ix8~Y6$a=B9bK(BaTEO0$Oh=VCkBPwt0 zf#QuB25&2!m7MWY5xV_~sf(0|Y*#Wf8+FQI(sl2wgdM5H7V{aH6|ntE+OcLsTC`u; zeyrlkJgzdIb5=n#SCH)+kjN)rYW7=rppN3Eb;q_^8Zi}6jtL@eZ2XO^w{mCwX(q!t ztM^`%`ndZ5c+2@?p>R*dDNeVk#v>rsn>vEo;cP2Ecp=@E>A#n0!jZACKZ1=D0`f|{ zZnF;Ocp;$j86m}Gt~N+Ch6CJo7+Wzv|nlsXBvm z?St-5Ke&6hbGAWoO!Z2Rd8ARJhOY|a1rm*sOif%Th`*=^jlgWo%e9`3sS51n*>+Mh(9C7g@*mE|r%h*3k6I_uo;C!N z7CVMIX4kbA#gPZf_0%m18+BVeS4?D;U$QC`TT;X zP#H}tMsa=zS6N7n#BA$Fy8#R7vOesiCLM@d1UO6Tsnwv^gb}Q9I}ZQLI?--C8ok&S z9Idy06+V(_aj?M78-*vYBu|AaJ9mlEJpFEIP}{tRwm?G{ag>6u(ReBKAAx zDR6qe!3G88NQP$i99DZ~CW9lzz}iGynvGA4!yL}_9t`l*SZbEL-%N{n$%JgpDHJRn zvh<{AqR7z@ylV`kXdk+uEu-WWAt^=A4n(J=A1e8DpeLzAd;Nl#qlmp#KcHU!8`YJY zvBZy@>WiBZpx*wQ8JzKw?@k}8l99Wo&H>__vCFL}>m~MTmGvae% zPTn9?iR=@7NJ)?e+n-4kx$V#qS4tLpVUX*Je0@`f5LICdxLnph&Vjbxd*|+PbzS(l zBqqMlUeNoo8wL&_HKnM^8{iDI3IdzJAt32UupSr6XXh9KH2LjWD)Pz+`cmps%eHeD zU%i1SbPuSddp6?th;;DfUlxYnjRpd~i7vQ4V`cD%4+a9*!{+#QRBr5^Q$5Ec?gpju zv@dk9;G>d7QNEdRy}fgeA?i=~KFeibDtYffy)^OP?Ro~-X!onDpm+uGpe&6)*f@xJ zE1I3Qh}`1<7aFB@TS#}ee={<#9%1wOL%cuvOd($y4MC2?`1Nin=pVLXPkknn*0kx> z!9XHW${hYEV;r6F#iz7W=fg|a@GY0UG5>>9>$3Bj5@!N{nWDD`;JOdz_ZaZVVIUgH zo+<=+n8VGL*U%M|J$A~#ll__<`y+jL>bv;TpC!&|d=q%E2B|5p=)b-Q+ZrFO%+D_u z4%rc8BmOAO6{n(i(802yZW93?U;K^ZZlo0Gvs7B+<%}R;$%O}pe*Gi;!xP-M73W`k zXLv473Ex_VPcM-M^JO|H>KD;!sEGJ|E}Qepen;yNG2 zXqgD5sjQUDI(XLM+^8ZX1s_(X+PeyQ$Q5RukRt|Kwr-FSnW!^9?OG64UYX1^bU9d8 zJ}8K&UEYG+Je^cThf8W*^RqG07nSCmp*o5Z;#F zS?jochDWX@p+%CZ%dOKUl}q{9)^U@}qkQtA3zBF)`I&zyIKgb{mv)KtZ}?_h{r#VZ z%C+hwv&nB?we0^H+H`OKGw-&8FaF;=ei!tAclS5Q?qH9J$nt+YxdKkbRFLnWvn7GH zezC6<{mK0dd763JlLFqy&Oe|7UXII;K&2pye~yG4jldY~N;M9&rX}m76NsP=R#FEw zt(9h+=m9^zfl=6pH*D;JP~OVgbJkXh(+2MO_^;%F{V@pc2nGn~=U)Qx|JEV-e=vXk zPxA2J<9~IH{}29#X~KW$(1reJv}lc4_1JF31gdev>!CddVhf_62nsr6%w)?IWxz}{ z(}~~@w>c07!r=FZANq4R!F2Qi2?QGavZ{)PCq~X}3x;4ylsd&m;dQe;0GFSn5 zZ*J<=Xg1fEGYYDZ0{Z4}Jh*xlXa}@412nlKSM#@wjMM z*0(k>Gfd1Mj)smUuX}EM6m)811%n5zzr}T?$ZzH~*3b`3q3gHSpA<3cbzTeRDi`SA zT{O)l3%bH(CN0EEF9ph1(Osw5y$SJolG&Db~uL!I3U{X`h(h%^KsL71`2B1Yn z7(xI+Fk?|xS_Y5)x?oqk$xmjG@_+JdErI(q95~UBTvOXTQaJs?lgrC6Wa@d0%O0cC zzvslIeWMo0|C0({iEWX{=5F)t4Z*`rh@-t0ZTMse3VaJ`5`1zeUK0~F^KRY zj2z-gr%sR<(u0@SNEp%Lj38AB2v-+cd<8pKdtRU&8t3eYH#h7qH%bvKup4cnnrN>l z!5fve)~Y5_U9US`uXDFoOtx2gI&Z!t&VPIoqiv>&H(&1;J9b}kZhcOX7EiW*Bujy#MaCl52%NO-l|@2$aRKvZ!YjwpXwC#nA(tJtd1p?jx&U|?&jcb!0MT6oBlWurVRyiSCX?sN3j}d zh3==XK$^*8#zr+U^wk(UkF}bta4bKVgr`elH^az{w(m}3%23;y7dsEnH*pp{HW$Uk zV9J^I9ea7vp_A}0F8qF{>|rj`CeHZ?lf%HImvEJF<@7cgc1Tw%vAUA47{Qe(sP^5M zT=z<~l%*ZjJvObcWtlN?0$b%NdAj&l`Cr|x((dFs-njsj9%IIqoN|Q?tYtJYlRNIu zY(LtC-F14)Og*_V@gjGH^tLV4uN?f^#=dscCFV~a`r8_o?$gj3HrSk=YK2k^UW)sJ z&=a&&JkMkWshp0sto$c6j8f$J!Bsn*MTjC`3cv@l@7cINa!}fNcu(0XF7ZCAYbX|WJIL$iGx8l zGFFQsw}x|i!jOZIaP{@sw0BrV5Z5u!TGe@JGTzvH$}55Gf<;rieZlz+6E1}z_o3m2 z(t;Cp^Geen7iSt)ZVtC`+tzuv^<6--M`^5JXBeeLXV)>2;f7=l%(-4?+<5~;@=Th{1#>rK3+rLn(44TAFS@u(}dunUSYu}~))W*fr` zkBL}3k_@a4pXJ#u*_N|e#1gTqxE&WPsfDa=`@LL?PRR()9^HxG?~^SNmeO#^-5tMw zeGEW&CuX(Uz#-wZOEt8MmF}hQc%14L)0=ebo`e$$G6nVrb)afh!>+Nfa5P;N zCCOQ^NRel#saUVt$Ds0rGd%gkKP2LsQRxq6)g*`-r(FGM!Q51c|9lk!ha8Um3ys1{ zWpT7XDWYshQ{_F!8D8@3hvXhQDw;GlkUOzni&T1>^uD){WH3wRONgjh$u4u7?+$(Y zqTXEF>1aPNZCXP0nJ;zs6_%6;+D&J_|ugcih**y(4ApT`RKAi5>SZe0Bz|+l7z>P14>0ljIH*LhK z@}2O#{?1RNa&!~sEPBvIkm-uIt^Pt#%JnsbJ`-T0%pb ze}d;dzJFu7oQ=i`VHNt%Sv@?7$*oO`Rt*bRNhXh{FArB`9#f%ksG%q?Z`_<19;dBW z5pIoIo-JIK9N$IE1)g8@+4}_`sE7;Lus&WNAJ^H&=4rGjeAJP%Dw!tn*koQ&PrNZw zY88=H7qpHz11f}oTD!0lWO>pMI;i4sauS`%_!zM!n@91sLH#rz1~iEAu#1b%LA zhB}7{1(8{1{V8+SEs=*f=FcRE^;`6Pxm$Hie~|aD~W1BYy#@Y$C?pxJh*cC!T@8C9{xx*T*8P zhbkRk3*6)Zbk%}u>^?ItOhxdmX$j9KyoxxN>NrYGKMkLF4*fLsL_PRjHNNHCyaUHN z7W8yEhf&ag07fc9FD>B{t0#Civsoy0hvVepDREX(NK1LbK0n*>UJp&1FygZMg7T^G z(02BS)g#qMOI{RJIh7}pGNS8WhSH@kG+4n=(8j<+gVfTur)s*hYus70AHUBS2bN6Zp_GOHYxsbg{-Rcet{@0gzE`t$M0_!ZIqSAIW53j+Ln7N~8J zLZ0DOUjp^j`MvX#hq5dFixo^1szoQ=FTqa|@m>9F@%>7OuF9&_C_MDco&-{wfLKNrDMEN4pRUS8-SD6@GP`>_7$;r>dJo>KbeXm>GfQS? zjFS+Y6^%pDCaI0?9(z^ELsAE1`WhbhNv5DJ$Y}~r;>FynHjmjmA{bfDbseZXsKUv`%Fekv)1@f%7ti;B5hhs}5db1dP+P0${1DgKtb(DvN}6H6;0*LP6blg*rpr;Z(7? zrve>M`x6ZI(wtQc4%lO?v5vr{0iTPl&JT!@k-7qUN8b$O9YuItu7zrQ*$?xJIN#~b z#@z|*5z&D7g5>!o(^v+3N?JnJns5O2W4EkF>re*q1uVjgT#6ROP5>Ho)XTJoHDNRC zuLC(Cd_ZM?FAFPoMw;3FM4Ln0=!+vgTYBx2TdXpM@EhDCorzTS6@2`swp4J^9C0)U zq?)H8)=D;i+H`EVYge>kPy8d*AxKl};iumYu^UeM+e_3>O+LY`D4?pD%;Vextj!(; zomJ(u+dR(0m>+-61HTV7!>03vqozyo@uY@Zh^KrW`w7^ENCYh86_P2VC|4}(ilMBe zwa&B|1a7%Qkd>d14}2*_yYr@8-N}^&?LfSwr)C~UUHr)ydENu=?ZHkvoLS~xTiBH= zD%A=OdoC+10l7@rXif~Z#^AvW+4M-(KQBj=Nhgts)>xmA--IJf1jSZF6>@Ns&nmv} zXRk`|`@P5_9W4O-SI|f^DCZ-n*yX@2gf6N)epc~lRWl7QgCyXdx|zr^gy>q`Vwn^y z&r3_zS}N=HmrVtTZhAQS`3$kBmVZDqr4+o(oNok?tqel9kn3;uUerFRti=k+&W{bb zT{ZtEf51Qf+|Jc*@(nyn#U+nr1SFpu4(I7<1a=)M_yPUAcKVF+(vK!|DTL2;P)yG~ zrI*7V)wN_92cM)j`PtAOFz_dO)jIfTeawh2{d@x0nd^#?pDkBTBzr0Oxgmvjt`U^$ zcTPl=iwuen=;7ExMVh7LLFSKUrTiPJpMB&*Ml32>wl} zYn(H0N4+>MCrm2BC4p{meYPafDEXd4yf$i%ylWpC|9%R4XZBUQiha(x%wgQ5iJ?K_wQBRfw z+pYuKoIameAWV7Ex4$PCd>bYD7)A9J`ri&bwTRN*w~7DR0EeLXW|I2()Zkl6vxiw? zFBX){0zT@w_4YUT4~@TXa;nPb^Tu$DJ=vluc~9)mZ}uHd#4*V_eS7)^eZ9oI%Wws_ z`;97^W|?_Z6xHSsE!3EKHPN<3IZ^jTJW=Il{rMmlnR#OuoE6dqOO1KOMpW84ZtDHNn)(pYvs=frO`$X}sY zKY0At$G85&2>B|-{*+B*aqQn&Mqjt*DVH2kdwEm5f}~Xwn9+tPt?EPwh8=8=VWA8rjt*bHEs1FJ92QohQ)Y z4sQH~AzB5!Pisyf?pVa0?L4gthx2;SKlrr?XRU`?Y>RJgUeJn!az#sNF7oDbzksrD zw8)f=f1t*UK&$}_ktf!yf4Rjt{56ffTA{A=9n})E7~iXaQkE+%GW4zqbmlYF(|hE@ z421q9`UQf$uA5yDLx67`=EnSTxdEaG!6C%9_obpb?;u-^QFX% zU1wQ}Li{PeT^fS;&Sk2#$ZM#Zpxrn7jsd<@qhfWy*H)cw9q!I9!fDOCw~4zg zbW`EHsTp9IQUCETUse)!ZmuRICx}0Oe1KVoqdK+u>67A8v`*X*!*_i5`_qTzYRkbYXg#4vT5~A{lK#bA}Oc4ePu5hr-@;i%Z!4Y;-(yR z(1rHYTc7i1h1aipP4DaIY3g2kF#MX{XW7g&zL!39ohO98=eo5nZtq+nz}2E$OZpxx z&OFaOM1O;?mxq+`%k>YS!-=H7BB&WhqSTUC{S!x*k9E zcB;u0I!h%3nEchQwu1GnNkaQxuWnW0D@Xq5j@5WE@E(WlgDU;FLsT*eV|Bh)aH0;~@^yygFj<=+Vu3p)LlF%1AA%y5z-Oh`2 z$RDKk_6r+f#I`8fQ%y#Wx%~de1qkWL2(q^~veLKwht-dIcpt(@lc>`~@mISRIPKPm zD!Za&aX@7dy*CT!&Z7JC1jP2@8+ro8SmlH>_gzRte%ojgiwfd?TR+%Ny0`sp`QRLy zl5TiQkFhIC!2aaJ&=Ua`c9UuOk9GkSFZ}!IGeMZ5MXrL zGtMj`m{(X9+l%=d|L zW2OY?8!_pyhvJ1@O!Chsf6}@3HmKq@)x;CFItPMpkSr@npO&8zMc_O?*|sqkuL^U? zV9+x3vbr|6;Ft0J^J>IH_xpa<{S5K?u-sQWC7FB9YFMwoCKK3WZ*gvO-wAApF`K%#7@1 z^sEj4*%hH`f0@sRDGI|#Dl20o$Z*gttP$q(_?#~2!H9(!d=)I93-3)?e%@$1^*F=t9t&OQ9!p84Z`+y<$yQ9wlamK~Hz2CRpS8dWJfBl@(M2qX!9d_F= zd|4A&U~8dX^M25wyC7$Swa22$G61V;fl{%Q4Lh!t_#=SP(sr_pvQ=wqOi`R)do~QX zk*_gsy75$xoi5XE&h7;-xVECk;DLoO0lJ3|6(Ba~ezi73_SYdCZPItS5MKaGE_1My zdQpx?h&RuoQ7I=UY{2Qf ziGQ-FpR%piffR_4X{74~>Q!=i`)J@T415!{8e`AXy`J#ZK)5WWm3oH?x1PVvcAqE@ zWI|DEUgxyN({@Y99vCJVwiGyx@9)y2jNg`R{$s2o;`4!^6nDX_pb~fTuzf>ZoPV@X zXKe1ehcZ+3dxCB+vikgKz8pvH?>ZzlOEObd{(-aWY;F0XIbuIjSA+!%TNy87a>BoX zsae$}Fcw&+)z@n{Fvzo;SkAw0U*}?unSO)^-+sbpNRjD8&qyfp%GNH;YKdHlz^)4( z;n%`#2Pw&DPA8tc)R9FW7EBR3?GDWhf@0(u3G4ijQV;{qp3B)`Fd}kMV}gB2U%4Sy z3x>YU&`V^PU$xWc4J!OG{Jglti@E3rdYo62K31iu!BU&pdo}S66Ctq{NB<88P92Y9 zTOqX$h6HH_8fKH(I>MEJZl1_2GB~xI+!|BLvN;CnQrjHuh?grzUO7h;1AbzLi|_O= z2S=(0tX#nBjN92gRsv;7`rDCATA!o(ZA}6)+;g;T#+1~HXGFD1@3D#|Ky9!E@)u=h z3@zg3Us0BCYmq(pB`^QTp|RB9!lX*{;7r|Z(^>J+av(0-oUmIdR78c4(q%hP#=R@W ze{;yy$T^8kXr(oC*#NQMZSQlgU)aa=BrZDwpLUk5tm&(AkNt&Gel`=ydcL*<@Ypx{ z2uOxl>2vSY2g3%Si&JU<9D5#{_z{9PzJh=miNH;STk^;5#%8iMRfPe#G~T>^U_zt? zgSE)`UQhb!G$at%yCf5MU)<&(L73(hY3*%qqPbX;`%QDHed3ZaWw^k)8Vjd#ePg@;I&pMe+A18k+S+bou|QX?8eQ`{P-0vrm=uR;Y(bHV>d>Gen4LHILqcm_ z3peDMRE3JMA8wWgPkSthI^K<|8aal38qvIcEgLjHAFB0P#IfqP2y}L>=8eBR}Fm^V*mw2Q4+o=exP@*#=Zs zIqHh@neG)Vy%v4cB1!L}w9J>IqAo}CsqbFPrUVc@;~Ld7t_2IIG=15mT7Itrjq#2~ zqX*&nwZP>vso$6W!#` z-YZ}jhBwQku-Qc>TIMpn%_z~`^u4v3Skyf)KA}V{`dr!Q;3xK1TuGYdl}$sKF^9X!*a-R*Oq1#tLq!W)gO}{q`1HM;oh1-k4FU@8W(qe>P05$+ z`ud2&;4IW4vq8#2yA{G>OH=G+pS_jctJ*BqD$j-MI#avR+<>m-`H1@{3VgKYn2_Ih z0`2_1qUMRuzgj_V^*;5Ax_0s{_3tYR>|$i#c!F7)#`oVGmsD*M2?%930cBSI4Mj>P zTm&JmUrvDXlB%zeA_7$&ogjGK3>SOlV$ct{4)P0k)Kua%*fx9?)_fkvz<(G=F`KCp zE`0j*=FzH$^Y@iUI}MM2Hf#Yr@oQdlJMB5xe0$aGNk%tgex;0)NEuVYtLEvOt{}ti zL`o$K9HnnUnl*;DTGTNiwr&ydfDp@3Y)g5$pcY9l1-9g;yn6SBr_S9MV8Xl+RWgwb zXL%kZLE4#4rUO(Pj484!=`jy74tQxD0Zg>99vvQ}R$7~GW)-0DVJR@$5}drsp3IQG zlrJL}M{+SdWbrO@+g2BY^a}0VdQtuoml`jJ2s6GsG5D@(^$5pMi3$27psEIOe^n=*Nj|Ug7VXN0OrwMrRq&@sR&vdnsRlI%*$vfmJ~)s z^?lstAT$Ked`b&UZ@A6I<(uCHGZ9pLqNhD_g-kj*Sa#0%(=8j}4zd;@!o;#vJ+Bsd z4&K4RIP>6It9Ir)ey?M6Gi6@JzKNg;=jM=$)gs2#u_WhvuTRwm1x2^*!e%l&j02xz zYInQgI$_V7Epzf3*BU~gos}|EurFj8l}hsI(!5yX!~ECL%cnYMS-e<`AKDL%(G)62 zPU;uF1(~(YbH2444JGh58coXT>(*CdEwaFuyvB|%CULgVQesH$ znB`vk3BMP<-QauWOZ0W6xB5y7?tE5cisG|V;bhY^8+*BH1T0ZLbn&gi12|a9Oa%;I zxvaxX_xe3@ng%;4C?zPHQ1v%dbhjA6Sl7w<*)Nr#F{Ahzj}%n9c&!g5HVrlvUO&R2C)_$x6M9 zahficAbeHL2%jILO>Pq&RPPxl;i{K5#O*Yt15AORTCvkjNfJ)LrN4K{sY7>tGuTQ@ z^?N*+xssG&sfp0c$^vV*H)U1O!fTHk8;Q7@42MT@z6UTd^&DKSxVcC-1OLjl7m63& zBb&goU!hes(GF^yc!107bkV6Pr%;A-WWd@DK2;&=zyiK*0i^0@f?fh2c)4&DRSjrI zk!W^=l^JKlPW9US{*yo?_XT@T2Bx+Cm^+r{*5LVcKVw*ll3+)lkebA-4)o z8f5xHWOx0!FDSs4nv@o@>mxTQrOeKzj@5uL`d>mXSp|#{FE54EE_!KtQNq>-G(&5) ztz?xkqPU16A-8@-quJ|SU^ClZ?bJ2kCJPB|6L>NTDYBprw$WcwCH{B z5qlJ6wK_9sT@Kl6G|Q&$gsl@WT>hE;nDAbH#%f1ZwuOkvWLj{qV$m3LF423&l!^iV zhym*>R>Yyens++~6F5+uZQTCz9t~PEW+e?w)XF2g!^^%6k?@Jcu;MG0FG9!T+Gx{Z zK;31y@(J{!-$k4E{5#Sv(2DGy3EZQY}G_*z*G&CZ_J?m&Fg4IBrvPx1w z1zAb3k}6nT?E)HNCi%}aR^?)%w-DcpBR*tD(r_c{QU6V&2vU-j0;{TVDN6los%YJZ z5C(*ZE#kv-BvlGLDf9>EO#RH_jtolA)iRJ>tSfJpF!#DO+tk% zBAKCwVZwO^p)(Rhk2en$XLfWjQQ`ix>K}Ru6-sn8Ih6k&$$y`zQ}}4dj~o@9gX9_= z#~EkchJqd5$**l}~~6mOl(q#GMIcFg&XCKO;$w>!K14 zko1egAORiG{r|8qj*FsN>?7d`han?*MD#xe^)sOqj;o;hgdaVnBH$BM{_73?znS+R z*G2VHM!Jw6#<FfJ-J%-9AuDW$@mc-Eyk~F{Jbvt` zn;(%DbBDnKIYr~|I>ZTvbH@cxUyw%bp*)OSs}lwO^HTJ2M#u5QsPF0?Jv*OVPfdKv z+t$Z5P!~jzZ~Y!d#iP?S{?M_g%Ua0Q)WawbIx+2uYpcf(7Im%W=rAu4dSceo7RZh# zN38=RmwOJQE$qbPXIuO^E`wSeJKCx3Q76irp~QS#19dusEVCWPrKhK9{7cbIMg9U} TZiJi*F`$tkWLn) diff --git a/minimal-sdk/android/gradle/wrapper/gradle-wrapper.properties b/minimal-sdk/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 09523c0e..00000000 --- a/minimal-sdk/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/minimal-sdk/android/gradlew b/minimal-sdk/android/gradlew deleted file mode 100755 index f5feea6d..00000000 --- a/minimal-sdk/android/gradlew +++ /dev/null @@ -1,252 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/minimal-sdk/android/gradlew.bat b/minimal-sdk/android/gradlew.bat deleted file mode 100644 index 9b42019c..00000000 --- a/minimal-sdk/android/gradlew.bat +++ /dev/null @@ -1,94 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/minimal-sdk/android/settings.gradle.kts b/minimal-sdk/android/settings.gradle.kts deleted file mode 100644 index 826ac130..00000000 --- a/minimal-sdk/android/settings.gradle.kts +++ /dev/null @@ -1,32 +0,0 @@ -pluginManagement { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - google() - mavenCentral() - } -} - -rootProject.name = "utexo-minimal-sdk-android" - -// AGP locates the SDK from ANDROID_HOME / ANDROID_SDK_ROOT or `sdk.dir` in -// local.properties (gitignored). CI sets the variable; for a developer -// machine with the SDK at its default location, write local.properties once -// so the plan's gate command needs no extra environment. -run { - val localProperties = rootDir.resolve("local.properties") - val fromEnv = System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT") - if (fromEnv == null && !localProperties.exists()) { - val defaultSdk = File(System.getProperty("user.home"), "Android/Sdk") - if (defaultSdk.resolve("platforms").isDirectory) { - localProperties.writeText("sdk.dir=${defaultSdk.absolutePath}\n") - } - } -} diff --git a/minimal-sdk/android/src/main/AndroidManifest.xml b/minimal-sdk/android/src/main/AndroidManifest.xml deleted file mode 100644 index 8072ee00..00000000 --- a/minimal-sdk/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/minimal-sdk/android/src/test/kotlin/com/utexo/minimalsdk/SmokeTest.kt b/minimal-sdk/android/src/test/kotlin/com/utexo/minimalsdk/SmokeTest.kt deleted file mode 100644 index 85e76e5d..00000000 --- a/minimal-sdk/android/src/test/kotlin/com/utexo/minimalsdk/SmokeTest.kt +++ /dev/null @@ -1,302 +0,0 @@ -package com.utexo.minimalsdk - -import org.json.JSONObject -import java.io.File -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -/** - * Smoke test through the generated uniffi bindings. - * - * Deep assertions (parity for every network and index, the adversarial - * verify suite, secrets hygiene) live in the Rust crate's `tests/parity.rs`. - * This test proves the binding surface, the native library load through JNA - * and the `with_foreign` `HttpTransport` trait work end to end, against the - * same rgb-lib-authored fixture the Rust suite reads, by relative path. - */ -class SmokeTest { - private companion object { - /** BOLT-11 spec vector: 2 500 µBTC, "1 cup coffee", 60 s expiry. */ - const val BOLT11_COFFEE = - "lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh" - const val ADDRESS = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080" - const val ATTACKER = "bcrt1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3" - const val GATEWAY = "https://gateway.example" - const val TOKEN = "secret-token" - val PREPARED_SEND_BTC = """{"opId":"op-1","psbt":"cHNidP8=","expiresAt":1700000000,""" + - """"intent":{"kind":"send_btc","feeRateSatPerVb":2,""" + - """"recipients":[{"address":"$ADDRESS","scriptHex":"0014deadbeef","amountSat":40000}],""" + - """"asset":null,"utxos":null}}""" - } - - private val fixture: JSONObject by lazy { - val path = System.getProperty("utexo.minimalsdk.fixture") - ?: "../packages/client-sdk/test/fixtures/rgblib-parity.json" - val file = File(path) - assertTrue(file.isFile, "parity fixture missing at ${file.absolutePath}") - JSONObject(file.readText()) - } - - private val regtestFixture: JSONObject get() = fixture.getJSONObject("networks").getJSONObject("Regtest") - - private fun ourKeys(): ClientKeys = - ClientKeys.fromMnemonic(fixture.getString("mnemonic"), BitcoinNetwork.REGTEST) - - private fun foreignKeys(): ClientKeys = - ClientKeys.fromMnemonic(fixture.getString("otherMnemonic"), BitcoinNetwork.REGTEST) - - /** The intent the fixture PSBT was prepared for: 40 000 sat to the foreign wallet's vanilla address 0. */ - private fun fixtureSendBtcIntent(): OnchainIntent { - val foreign = deriveTaprootAddress(foreignKeys().xpubs().vanilla, 0u, 0u, BitcoinNetwork.REGTEST) - return OnchainIntent( - kind = IntentKind.SEND_BTC, - feeRateSatPerVb = 2UL, - recipients = listOf( - IntentRecipient(address = foreign.address, scriptHex = foreign.scriptHex, amountSat = 40_000UL), - ), - asset = null, - utxos = null, - ) - } - - private fun fixtureParams(keys: ClientKeys, intent: OnchainIntent = fixtureSendBtcIntent()) = - VerifyParams(intent = intent, xpubs = keys.xpubs(), maxFeeSat = 2_000UL) - - @Test - fun nativeLibraryLoadsAndReportsVersion() { - val version = sdkVersion() - assertTrue(version.isNotEmpty()) - assertTrue(Regex("""\d+\.\d+\.\d+""").matches(version), "semver expected, got $version") - } - - @Test - fun restoreFromFixtureMnemonicMatchesRgbLibXpubsAndFingerprint() { - val keys = ourKeys() - val expected = regtestFixture - assertEquals(BitcoinNetwork.REGTEST, keys.network()) - assertEquals(expected.getString("masterFingerprint"), keys.fingerprint()) - - val xpubs = keys.xpubs() - assertEquals(BitcoinNetwork.REGTEST, xpubs.network) - assertEquals(expected.getString("masterFingerprint"), xpubs.fingerprint) - assertEquals(expected.getString("accountXpubVanilla"), xpubs.vanilla) - assertEquals(expected.getString("accountXpubColored"), xpubs.colored) - // Public material only crosses the boundary. - assertFalse(xpubs.vanilla.startsWith("tprv") || xpubs.colored.startsWith("tprv")) - } - - @Test - fun deriveIndexZeroMatchesRgbLibAddresses() { - val xpubs = ourKeys().xpubs() - val regtest = fixture.getJSONObject("regtest") - - val vanilla = deriveTaprootAddress(xpubs.vanilla, 0u, 0u, BitcoinNetwork.REGTEST) - assertEquals(regtest.getJSONArray("vanillaAddresses").getString(0), vanilla.address) - assertEquals(0u, vanilla.keychain) - assertEquals(0u, vanilla.index) - assertTrue(vanilla.scriptHex.startsWith("5120") && vanilla.scriptHex.length == 68) - - val colored = deriveTaprootAddress(xpubs.colored, 0u, 0u, BitcoinNetwork.REGTEST) - assertEquals(regtest.getJSONArray("coloredAddresses").getString(0), colored.address) - } - - @Test - fun verifyAndSignFixturePsbtReachesRgbLibTxid() { - val keys = ourKeys() - val signing = fixture.getJSONObject("signing") - val unsigned = signing.getString("unsignedPsbt") - val params = fixtureParams(keys) - - val verdict = verifyPsbt(unsigned, params) - assertTrue(verdict.ok, "verdict failed: ${verdict.checks.filter { !it.ok }}") - assertEquals(5, verdict.checks.size) - - val result = verifyAndSignPsbt(keys, unsigned, params) - assertEquals(signing.getString("txid"), result.txid) - assertTrue(result.verdict.ok) - assertTrue(result.signedPsbt.isNotEmpty()) - assertTrue(result.signedPsbt != unsigned, "signing must change the PSBT") - } - - @Test - fun signingRefusesWhenTheIntentDoesNotMatch() { - val keys = ourKeys() - val unsigned = fixture.getJSONObject("signing").getString("unsignedPsbt") - val tampered = fixtureSendBtcIntent().let { intent -> - intent.copy(recipients = listOf(intent.recipients[0].copy(amountSat = 40_001UL))) - } - val error = assertFailsWith { - verifyAndSignPsbt(keys, unsigned, fixtureParams(keys, tampered)) - } - assertEquals("recipients-match", error.check) - } - - @Test - fun errorsCrossTheBoundaryAsTypedExceptions() { - assertFailsWith { - ClientKeys.fromMnemonic("abandon abandon abandon", BitcoinNetwork.REGTEST) - } - assertFailsWith { - deriveTaprootAddress("not-an-xpub", 0u, 0u, BitcoinNetwork.REGTEST) - } - } - - /** A Kotlin-side transport: records the request Rust built and answers with a canned response. */ - private class StubTransport(private val status: UShort, private val body: String) : HttpTransport { - val requests = mutableListOf() - override fun send(request: HttpRequest): HttpResponse { - requests += request - return HttpResponse(status, body) - } - } - - @Test - fun gatewayClientDrivesAKotlinHttpTransport() { - val transport = StubTransport(200u, """{"userId":"user-1","createdAt":1700000000}""") - val client = GatewayClient("https://gateway.example", "secret-token", null, transport) - - val me = client.me() - assertEquals("user-1", me.userId) - assertEquals(1_700_000_000UL, me.createdAt) - - val request = transport.requests.single() - assertEquals(HttpMethod.GET, request.method) - assertEquals("https://gateway.example/v1/me", request.url) - assertEquals("Bearer secret-token", request.headers["authorization"]) - assertEquals(null, request.body) - assertTrue(request.timeoutMs > 0UL) - } - - @Test - fun gatewayErrorsSurfaceStatusAndCodeFromTheTransport() { - val transport = StubTransport(503u, """{"error":{"code":"MAINTENANCE","message":"back soon"}}""") - val client = GatewayClient("https://gateway.example", "secret-token", null, transport) - val error = assertFailsWith { client.me() } - assertEquals(503u.toUShort(), error.status) - assertEquals("MAINTENANCE", error.code) - assertFalse(error.toString().contains("secret-token"), "bearer token leaked into the error") - } - - /** A transport that throws instead of answering. */ - private class ThrowingTransport(private val error: Throwable) : HttpTransport { - override fun send(request: HttpRequest): HttpResponse = throw error - } - - @Test - fun transportErrorsCrossBackIntoRustAsTypedTransportExceptions() { - // The declared error type passes through unchanged. - val declared = GatewayClient(GATEWAY, TOKEN, null, ThrowingTransport(SdkException.Transport("dns failed"))) - assertEquals("dns failed", assertFailsWith { declared.me() }.reason) - - // A host exception that is not an SdkException must still surface as a - // typed Transport error, never as an internal (panic) exception. - val foreign = GatewayClient(GATEWAY, TOKEN, null, ThrowingTransport(IllegalStateException("pool closed"))) - val mapped = assertFailsWith { foreign.me() } - assertTrue(mapped.reason.contains("unexpected"), mapped.reason) - assertTrue(mapped.reason.contains("pool closed"), mapped.reason) - assertFalse(mapped.toString().contains(TOKEN), "bearer token leaked into the error") - } - - @Test - fun generatedKeysRestoreToTheSameXpubs() { - val generated = generateKeys(BitcoinNetwork.REGTEST) - assertEquals(12, generated.mnemonic.trim().split(" ").size) - val restored = ClientKeys.fromMnemonic(generated.mnemonic, BitcoinNetwork.REGTEST) - assertEquals(generated.keys.xpubs(), restored.xpubs()) - assertEquals(generated.keys.fingerprint(), restored.fingerprint()) - assertEquals(BitcoinNetwork.REGTEST, generated.keys.network()) - assertTrue(generated.keys.xpubs().vanilla.startsWith("tpub")) - // A fresh generation is a different wallet. - assertTrue(generateKeys(BitcoinNetwork.REGTEST).keys.xpubs() != generated.keys.xpubs()) - } - - @Test - fun invoiceDecodersCrossTheBoundaryWithOptionalsListsAndEnums() { - val bolt11 = decodeBolt11(BOLT11_COFFEE) - assertEquals(BitcoinNetwork.MAINNET, bolt11.network) - assertEquals(250_000_000UL, bolt11.amountMsat) - assertEquals("1 cup coffee", bolt11.description) - assertEquals(null, bolt11.descriptionHash) - assertEquals(60UL, bolt11.expirySeconds) - assertEquals(64, bolt11.paymentHash.length) - assertEquals(66, bolt11.payeeNodeId.length) - - val wr = fixture.getJSONObject("witnessReceive") - val rgb = decodeRgbInvoice(wr.getString("invoice")) - assertEquals(wr.getString("recipientId"), rgb.recipientId) - assertEquals(BeneficiaryKind.WITNESS, rgb.beneficiaryKind) - assertEquals(null, rgb.assetId) - assertEquals(null, rgb.amount) - assertEquals("bcrt", rgb.chain) - assertEquals(listOf("rpc://localhost:3000/json-rpc"), rgb.transportEndpoints) - assertEquals(wr.getLong("expirationTimestamp").toULong(), rgb.expiryTimestamp) - - assertFailsWith { decodeBolt11("lnbc1pvjluez") } - assertFailsWith { decodeRgbInvoice("not an invoice") } - } - - @Test - fun idempotencyKeysAreV4Uuids() { - val key = generateIdempotencyKey() - assertTrue(Regex("""[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}""").matches(key), key) - assertTrue(key != generateIdempotencyKey(), "keys must be unique per call") - } - - @Test - fun prepareSendBtcBindsTheIntentAndSurfacesNestedRecords() { - val transport = StubTransport(200u, PREPARED_SEND_BTC) - val client = GatewayClient(GATEWAY, TOKEN, null, transport) - val op = client.prepareSendBtc(PrepareSendBtcParams(ADDRESS, 40_000UL, null), null) - assertEquals("op-1", op.opId) - assertEquals("cHNidP8=", op.psbt) - assertEquals(1_700_000_000UL, op.expiresAt) - assertEquals(IntentKind.SEND_BTC, op.intent.kind) - assertEquals(2UL, op.intent.feeRateSatPerVb) - assertEquals(null, op.intent.asset) - assertEquals(null, op.intent.utxos) - assertEquals(IntentRecipient(ADDRESS, "0014deadbeef", 40_000UL), op.intent.recipients.single()) - val request = transport.requests.single() - assertEquals(HttpMethod.POST, request.method) - assertEquals("$GATEWAY/v1/onchain/send-btc/prepare", request.url) - assertEquals("application/json", request.headers["content-type"]) - assertTrue(request.headers.containsKey("idempotency-key")) - assertTrue(JSONObject(request.body!!).getString("address") == ADDRESS) - - // The same gateway response does not match a request for another address. - val tampered = GatewayClient(GATEWAY, TOKEN, null, StubTransport(200u, PREPARED_SEND_BTC)) - val error = assertFailsWith { - tampered.prepareSendBtc(PrepareSendBtcParams(ATTACKER, 40_000UL, null), null) - } - assertTrue(error.reason.contains("recipient address"), error.reason) - } - - @Test - fun lnBalanceMapsCrossTheBoundary() { - val transport = StubTransport(200u, """{"btcMsat":12345,"assets":{"rgb:a":7,"rgb:b":0}}""") - val balance = GatewayClient(GATEWAY, TOKEN, null, transport).getLnBalance() - assertEquals(12_345UL, balance.btcMsat) - assertEquals(mapOf("rgb:a" to 7UL, "rgb:b" to 0UL), balance.assets) - } - - @Test - fun consumerRulesKeepOnlyJnaEntryPoints() { - val rules = File(System.getProperty("utexo.minimalsdk.consumerRules") ?: "consumer-rules.pro") - assertTrue(rules.isFile, "consumer-rules.pro missing at ${rules.absolutePath}") - val lines = rules.readLines().map { it.trim() } - val keeps = lines.filter { it.startsWith("-keep ") } - assertTrue(keeps.isNotEmpty()) - // JNA reads @Structure.FieldOrder reflectively; R8 strips runtime annotations unless kept. - assertTrue( - lines.any { it.startsWith("-keepattributes") && it.contains("RuntimeVisibleAnnotations") }, - "consumer-rules.pro must keep RuntimeVisibleAnnotations for JNA @Structure.FieldOrder", - ) - assertTrue(keeps.none { it.contains("com.utexo.minimalsdk") }, "blanket keep of the SDK package: $keeps") - assertNotNull(keeps.find { it.contains("com.sun.jna.Library") }) - assertNotNull(keeps.find { it.contains("com.sun.jna.Callback") }) - assertNotNull(keeps.find { it.contains("com.sun.jna.Structure") }) - } -} diff --git a/minimal-sdk/apple/.gitignore b/minimal-sdk/apple/.gitignore deleted file mode 100644 index c7542dd5..00000000 --- a/minimal-sdk/apple/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Generated by ../scripts/generate_bindings.sh swift and ../scripts/build_apple.sh. -/Sources/MinimalSdk/Generated/ -/Sources/MinimalSdkFFI/ -/MinimalSdkFFI.xcframework/ -/build/ -/.build/ -/.swiftpm/ diff --git a/minimal-sdk/apple/Package.swift b/minimal-sdk/apple/Package.swift deleted file mode 100644 index 4d2ce739..00000000 --- a/minimal-sdk/apple/Package.swift +++ /dev/null @@ -1,57 +0,0 @@ -// swift-tools-version: 5.9 -import PackageDescription - -// UTEXO minimal SDK for Apple platforms: the Swift bindings generated by -// ../scripts/generate_bindings.sh swift on top of the Rust core packaged by -// ../scripts/build_apple.sh as MinimalSdkFFI.xcframework. Run those two -// scripts (build_apple.sh runs both) before building this package. -// -// Deployment targets are the SDK's floor policy (see README.md): iOS 15 and -// macOS 12. build_apple.sh pins the same versions into the static libraries. - -#if os(Linux) -// Host-only test aid, the same trick test/swift-e2e uses for the node: there -// is no XCFramework on Linux, so link the Rust cdylib from `cargo build -// --release` directly and expose the generated header through a -// systemLibrary target. This lets `swift test` run SmokeTests on a Linux -// box or runner. Never a shipping configuration; unsafeFlags also makes the -// package unusable as a dependency in this branch, which is intended. -let rustLibDir = Context.environment["UTEXO_MINIMAL_SDK_LIB_DIR"] - ?? "\(Context.packageDirectory)/../rust-sdk/target/release" -let ffiTargets: [Target] = [ - .systemLibrary(name: "MinimalSdkFFI", path: "Sources/MinimalSdkFFI"), -] -let coreLinkerSettings: [LinkerSetting] = [ - .unsafeFlags(["-L", rustLibDir, "-Xlinker", "-rpath", "-Xlinker", rustLibDir]), - .linkedLibrary("utexo_minimal_sdk"), -] -#else -let ffiTargets: [Target] = [ - .binaryTarget(name: "MinimalSdkFFI", path: "MinimalSdkFFI.xcframework"), -] -let coreLinkerSettings: [LinkerSetting] = [] -#endif - -let package = Package( - name: "MinimalSdk", - platforms: [ - .iOS(.v15), - .macOS(.v12), - ], - products: [ - .library(name: "MinimalSdk", targets: ["MinimalSdk"]), - ], - targets: ffiTargets + [ - .target( - name: "MinimalSdk", - dependencies: ["MinimalSdkFFI"], - path: "Sources/MinimalSdk", - linkerSettings: coreLinkerSettings - ), - .testTarget( - name: "MinimalSdkTests", - dependencies: ["MinimalSdk"], - path: "Tests/MinimalSdkTests" - ), - ] -) diff --git a/minimal-sdk/apple/README.md b/minimal-sdk/apple/README.md deleted file mode 100644 index c62ba39c..00000000 --- a/minimal-sdk/apple/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# UTEXO minimal SDK — Apple package - -Swift bindings and an XCFramework for the minimal SDK core in -[`../rust-sdk`](../rust-sdk): keys, taproot derivation, verify-before-sign, -signing, invoice decoding and the gateway client, in one Rust implementation -shared with the [Android package](../android). This is **not** the node: no -LDK, no rgb-lib, no HTTP or TLS stack. - -- Swift package `MinimalSdk` (module `MinimalSdk`, C module `MinimalSdkFFI`), - swift-tools 5.9, Swift language mode 5. -- Deployment targets **iOS 15.0** and **macOS 12.0**, declared in - `Package.swift` and pinned into every static library by `build_apple.sh`. -- No third-party dependencies. uniffi's Swift bindings call the Rust static - library directly; there is no JNA equivalent to ship. -- Networking is the app's: `URLSessionHttpTransport` (one synchronous `send`, - ephemeral session) is provided and any `HttpTransport` conformance can - replace it. Rust performs no IO. - -## Deployment-target policy - -Both mobile packages use the same rule: the floor is the oldest OS version -that still covers about 98 % of the active installed base, so the SDK never -excludes a device its host app can reach. On Android that is API 24; on -Apple platforms that is iOS 15 (2021, the last iOS line Apple was still -patching in 2025) and its contemporary macOS 12 for the macOS slice. Raising -the floor is a one-line change in `Package.swift` plus the matching -`IOS_DEPLOYMENT_TARGET` / `MACOS_DEPLOYMENT_TARGET` in `build_apple.sh`; they -must move together. - -## Build (macOS + Xcode) - -```sh -rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios \ - aarch64-apple-darwin x86_64-apple-darwin -../scripts/build_apple.sh # cargo (5 targets) → bindings → lipo → MinimalSdkFFI.xcframework -swift test # smoke test, runs on the macOS slice -``` - -`build_apple.sh` takes no arguments; its only knobs are the deployment-target -environment variables above. It builds release **static** libraries for the iOS device -target, the two iOS simulator targets and the two macOS targets, runs -`../scripts/generate_bindings.sh swift` (uniffi library mode, no UDL), lipos -the simulator and macOS slices into universal archives and calls -`xcodebuild -create-xcframework` with the device, simulator and macOS -libraries, each with the same headers directory -(`Sources/MinimalSdkFFI/{MinimalSdkFFI.h,module.modulemap}`). The macOS -slice exists so `swift test` runs natively on a Mac; an iOS app links only -the device slice. - -Generated files (`Sources/MinimalSdk/Generated/`, `Sources/MinimalSdkFFI/`, -`MinimalSdkFFI.xcframework/`, `build/`) are not committed. - -## Test on Linux (no Xcode) - -The package has a Linux branch that mirrors what `test/swift-e2e` does for -the node: it links the host cdylib from `cargo build --release` through a -`systemLibrary` target instead of the XCFramework. It is a test aid only, not -a shipping configuration. - -```sh -cargo build --release --lib --manifest-path ../rust-sdk/Cargo.toml -../scripts/generate_bindings.sh swift -swift test # any Swift 5.9+ toolchain for Linux -``` - -Set `UTEXO_MINIMAL_SDK_LIB_DIR` to point at a different directory holding -`libutexo_minimal_sdk.so`. - -## Size: measure the app-size delta, not the archive - -iOS links statically and the app linker **dead-strips** the archive, so the -size of `libutexo_minimal_sdk.a` is not what the user downloads. The node's -XCFramework carries a 391.9 MB `.a` and that figure has misled before -(`docs/design/mobile-sdk-and-lightweight-uniffi.md`). The number that matters -for this package is the **delta in the app's installed size** with and -without the SDK linked, measured on a device build with App Thinning; expect -the same order as the Android arm64 slice (2.37 MB raw, of which about -1.06 MB is libsecp256k1's precomputed tables) as an upper bound, less after -dead-stripping of anything the app does not call. `build_apple.sh` prints -the archive sizes for the record and deliberately has no size gate; the Rust -size gate (`../rust-sdk/tests/parity.rs`, 2.5 MB host cdylib) and the Android -arm64 gate (`../scripts/build_android.sh`, 3.0 MB) bound the code that -reaches the app. - -## What is verified where - -The macOS column lists what the `apple` job of -`.github/workflows/minimal-sdk-mobile.yaml` runs; its current result is the -latest workflow run, not this table. - -| Check | Linux (this repo's dev machine) | macOS CI job | -| ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------ | -| Swift bindings generate in library mode, no UDL | yes | yes | -| `bash -n` and `shellcheck` on both scripts | yes | – | -| `Package.swift` parses (both `os()` branches) | yes | yes | -| Swift smoke test (17 cases) | yes, against the host cdylib | yes, against the macOS slice | -| `URLSessionHttpTransport` through an in-process `URLProtocol` stub (status pass-through, verbatim headers and body, non-UTF-8 body, bounded wait) | yes | yes | -| iOS device + simulator static libraries | **no** (needs Apple SDKs) | yes | -| `lipo` and `xcodebuild -create-xcframework` | **no** (needs Xcode) | yes | -| uniffi entry points exported by the device slice | **no** | yes (`nm` in `build_apple.sh`) | diff --git a/minimal-sdk/apple/Sources/MinimalSdk/URLSessionHttpTransport.swift b/minimal-sdk/apple/Sources/MinimalSdk/URLSessionHttpTransport.swift deleted file mode 100644 index 5e3b7b88..00000000 --- a/minimal-sdk/apple/Sources/MinimalSdk/URLSessionHttpTransport.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -/// `HttpTransport` over `URLSession`. -/// -/// The Rust core builds every gateway request (method, URL, headers, JSON -/// body, timeout) and parses every response; this type only moves bytes. -/// It is the Swift twin of the Kotlin OkHttp/`HttpURLConnection` transport: -/// networking stays idiomatic per platform and Rust links no HTTP or TLS -/// stack. -/// -/// `send` is synchronous because the FFI trait is; it blocks the calling -/// thread on a semaphore until `URLSession` answers. Call `GatewayClient` -/// off the main thread (a `Task.detached` or a background queue), exactly -/// as you would a blocking socket. -public final class URLSessionHttpTransport: HttpTransport { - private let session: URLSession - - /// - Parameter session: defaults to an ephemeral session so nothing the - /// gateway returns is written to a disk cache. - public init(session: URLSession = URLSession(configuration: .ephemeral)) { - self.session = session - } - - public func send(request: HttpRequest) throws -> HttpResponse { - guard let url = URL(string: request.url) else { - throw SdkError.Transport(reason: "invalid URL") - } - var urlRequest = URLRequest(url: url) - urlRequest.timeoutInterval = TimeInterval(request.timeoutMs) / 1000 - switch request.method { - case .get: - urlRequest.httpMethod = "GET" - case .post: - urlRequest.httpMethod = "POST" - } - for (name, value) in request.headers { - urlRequest.setValue(value, forHTTPHeaderField: name) - } - if let body = request.body { - urlRequest.httpBody = Data(body.utf8) - } - - let semaphore = DispatchSemaphore(value: 0) - let outcome = Outcome() - let task = session.dataTask(with: urlRequest) { data, response, error in - defer { semaphore.signal() } - if let error = error { - // URLError descriptions carry the host but never the - // request headers, so the bearer token cannot leak here. - outcome.set(.failure(.Transport(reason: error.localizedDescription))) - return - } - guard let http = response as? HTTPURLResponse else { - outcome.set(.failure(.Transport(reason: "non-HTTP response"))) - return - } - // The contract is "return Ok for any status the server answered": - // a non-UTF-8 error page still carries its HTTP status, so decode - // lossily instead of hiding the status behind a Transport error. - let body = String(decoding: data ?? Data(), as: UTF8.self) - guard http.statusCode >= 0, http.statusCode <= Int(UInt16.max) else { - outcome.set(.failure(.Transport(reason: "invalid HTTP status"))) - return - } - outcome.set(.success(HttpResponse(status: UInt16(http.statusCode), body: body))) - } - task.resume() - // `timeoutInterval` is URLSession's inactivity timeout; `timeoutMs` - // is the total budget Rust promised the caller. Bound the wait as - // well so a slow-drip server cannot hold the thread past it. - let budget = DispatchTime.now() + .milliseconds(Int(clamping: request.timeoutMs)) - if semaphore.wait(timeout: budget) == .timedOut { - task.cancel() - throw SdkError.Transport(reason: "timed out after \(request.timeoutMs) ms") - } - return try outcome.get() - } - - /// Hands the completion handler's result back to the blocked caller. - /// The lock makes the hand-off well-defined under Swift 6 strict - /// concurrency; the semaphore already orders the two accesses. - private final class Outcome: @unchecked Sendable { - private let lock = NSLock() - private var value: Result? - - func set(_ result: Result) { - lock.lock() - value = result - lock.unlock() - } - - func get() throws -> HttpResponse { - lock.lock() - defer { lock.unlock() } - guard let value = value else { throw SdkError.Transport(reason: "no response") } - return try value.get() - } - } -} diff --git a/minimal-sdk/apple/Tests/MinimalSdkTests/SmokeTests.swift b/minimal-sdk/apple/Tests/MinimalSdkTests/SmokeTests.swift deleted file mode 100644 index c4f3268d..00000000 --- a/minimal-sdk/apple/Tests/MinimalSdkTests/SmokeTests.swift +++ /dev/null @@ -1,507 +0,0 @@ -import Foundation -import XCTest -@testable import MinimalSdk -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -/// Smoke test through the generated uniffi bindings. -/// -/// Deep assertions (parity for every network and index, the adversarial -/// verify suite, secrets hygiene) live in the Rust crate's `tests/parity.rs`. -/// This test proves the binding surface, the static link and the -/// `with_foreign` `HttpTransport` trait work end to end, against the same -/// rgb-lib-authored fixture the Rust suite reads, by relative path. It is the -/// Swift twin of the Kotlin `SmokeTest`. -final class SmokeTests: XCTestCase { - // MARK: fixture - - private struct FixtureError: Error, CustomStringConvertible { - let description: String - } - - /// Loaded once; a missing or malformed fixture fails each test that - /// needs it (via `throw`) instead of aborting the whole test process. - private static let fixtureResult: Result<[String: Any], Error> = { - let testFile = URL(fileURLWithPath: #filePath) - let path = ProcessInfo.processInfo.environment["UTEXO_MINIMALSDK_FIXTURE"].map { URL(fileURLWithPath: $0) } - ?? testFile - .deletingLastPathComponent() // MinimalSdkTests - .deletingLastPathComponent() // Tests - .deletingLastPathComponent() // apple - .deletingLastPathComponent() // minimal-sdk - .appendingPathComponent("packages/client-sdk/test/fixtures/rgblib-parity.json") - guard let data = FileManager.default.contents(atPath: path.path) else { - return .failure(FixtureError(description: "parity fixture missing at \(path.path)")) - } - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return .failure(FixtureError(description: "parity fixture is not a JSON object")) - } - return .success(json) - }() - - private func fixture() throws -> [String: Any] { try Self.fixtureResult.get() } - - private func string(_ key: String, in object: [String: Any]) throws -> String { - guard let value = object[key] as? String else { - throw FixtureError(description: "fixture key \(key) missing or not a string") - } - return value - } - - private func object(_ key: String, in object: [String: Any]) throws -> [String: Any] { - guard let value = object[key] as? [String: Any] else { - throw FixtureError(description: "fixture key \(key) missing or not an object") - } - return value - } - - private func regtestNetworkFixture() throws -> [String: Any] { - try object("Regtest", in: object("networks", in: fixture())) - } - - private func regtestAddresses() throws -> [String: Any] { try object("regtest", in: fixture()) } - - private func signingFixture() throws -> [String: Any] { try object("signing", in: fixture()) } - - private func ourKeys() throws -> ClientKeys { - try ClientKeys.fromMnemonic(mnemonic: string("mnemonic", in: fixture()), network: .regtest) - } - - private func foreignKeys() throws -> ClientKeys { - try ClientKeys.fromMnemonic(mnemonic: string("otherMnemonic", in: fixture()), network: .regtest) - } - - /// The intent the fixture PSBT was prepared for: 40 000 sat to the - /// foreign wallet's vanilla address 0. - private func fixtureSendBtcIntent() throws -> OnchainIntent { - let foreign = try deriveTaprootAddress( - accountXpub: try foreignKeys().xpubs().vanilla, keychain: 0, index: 0, network: .regtest) - return OnchainIntent( - kind: .sendBtc, - feeRateSatPerVb: 2, - recipients: [ - IntentRecipient(address: foreign.address, scriptHex: foreign.scriptHex, amountSat: 40_000), - ], - asset: nil, - utxos: nil) - } - - private func fixtureParams(keys: ClientKeys, intent: OnchainIntent? = nil) throws -> VerifyParams { - VerifyParams( - intent: try intent ?? fixtureSendBtcIntent(), - xpubs: keys.xpubs(), - maxFeeSat: 2_000, - changeScanWindow: nil, - maxOwnOutputIndex: nil) - } - - // MARK: constants shared with the Kotlin smoke test - - /// BOLT-11 spec vector: 2 500 µBTC, "1 cup coffee", 60 s expiry. - private static let bolt11Coffee = - "lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh" - private static let address = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080" - private static let attacker = "bcrt1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3" - private static let gateway = "https://gateway.example" - private static let token = "secret-token" - private static let preparedSendBtc = - #"{"opId":"op-1","psbt":"cHNidP8=","expiresAt":1700000000,"intent":{"kind":"send_btc","feeRateSatPerVb":2,"# + - #""recipients":[{"address":"\#(address)","scriptHex":"0014deadbeef","amountSat":40000}],"asset":null,"utxos":null}}"# - - // MARK: tests - - func testNativeLibraryLoadsAndReportsVersion() { - let version = sdkVersion() - XCTAssertFalse(version.isEmpty) - XCTAssertNotNil(version.range(of: #"^\d+\.\d+\.\d+$"#, options: .regularExpression), "semver expected, got \(version)") - } - - func testRestoreFromFixtureMnemonicMatchesRgbLibXpubsAndFingerprint() throws { - let keys = try ourKeys() - let expected = try regtestNetworkFixture() - XCTAssertEqual(keys.network(), .regtest) - XCTAssertEqual(keys.fingerprint(), try string("masterFingerprint", in: expected)) - - let xpubs = keys.xpubs() - XCTAssertEqual(xpubs.network, .regtest) - XCTAssertEqual(xpubs.fingerprint, try string("masterFingerprint", in: expected)) - XCTAssertEqual(xpubs.vanilla, try string("accountXpubVanilla", in: expected)) - XCTAssertEqual(xpubs.colored, try string("accountXpubColored", in: expected)) - // Public material only crosses the boundary. - XCTAssertFalse(xpubs.vanilla.hasPrefix("tprv") || xpubs.colored.hasPrefix("tprv")) - } - - func testGeneratedKeysRestoreToTheSameXpubs() throws { - let generated = try generateKeys(network: .regtest) - XCTAssertEqual(generated.mnemonic.split(separator: " ").count, 12) - let restored = try ClientKeys.fromMnemonic(mnemonic: generated.mnemonic, network: .regtest) - XCTAssertEqual(generated.keys.xpubs(), restored.xpubs()) - XCTAssertEqual(generated.keys.fingerprint(), restored.fingerprint()) - XCTAssertEqual(generated.keys.network(), .regtest) - XCTAssertTrue(generated.keys.xpubs().vanilla.hasPrefix("tpub")) - // A fresh generation is a different wallet. - XCTAssertNotEqual(try generateKeys(network: .regtest).keys.xpubs(), generated.keys.xpubs()) - } - - func testDeriveIndexZeroMatchesRgbLibAddresses() throws { - let xpubs = try ourKeys().xpubs() - let addresses = try regtestAddresses() - let vanillaAddresses = addresses["vanillaAddresses"] as? [String] ?? [] - let coloredAddresses = addresses["coloredAddresses"] as? [String] ?? [] - - let vanilla = try deriveTaprootAddress(accountXpub: xpubs.vanilla, keychain: 0, index: 0, network: .regtest) - XCTAssertEqual(vanilla.address, vanillaAddresses.first) - XCTAssertEqual(vanilla.keychain, 0) - XCTAssertEqual(vanilla.index, 0) - XCTAssertTrue(vanilla.scriptHex.hasPrefix("5120") && vanilla.scriptHex.count == 68) - - let colored = try deriveTaprootAddress(accountXpub: xpubs.colored, keychain: 0, index: 0, network: .regtest) - XCTAssertEqual(colored.address, coloredAddresses.first) - } - - func testVerifyAndSignFixturePsbtReachesRgbLibTxid() throws { - let keys = try ourKeys() - let signing = try signingFixture() - let unsigned = try string("unsignedPsbt", in: signing) - let params = try fixtureParams(keys: keys) - - let verdict = verifyPsbt(psbt: unsigned, params: params) - XCTAssertTrue(verdict.ok, "verdict failed: \(verdict.checks.filter { !$0.ok })") - XCTAssertEqual(verdict.checks.count, 5) - - let result = try verifyAndSignPsbt(keys: keys, psbt: unsigned, params: params) - XCTAssertEqual(result.txid, try string("txid", in: signing)) - XCTAssertTrue(result.verdict.ok) - XCTAssertFalse(result.signedPsbt.isEmpty) - XCTAssertNotEqual(result.signedPsbt, unsigned, "signing must change the PSBT") - } - - func testSigningRefusesWhenTheIntentDoesNotMatch() throws { - let keys = try ourKeys() - let unsigned = try string("unsignedPsbt", in: signingFixture()) - var tampered = try fixtureSendBtcIntent() - tampered.recipients[0].amountSat = 40_001 - - XCTAssertThrowsError(try verifyAndSignPsbt(keys: keys, psbt: unsigned, params: try fixtureParams(keys: keys, intent: tampered))) { error in - guard case SdkError.VerificationFailed(let check, _) = error else { - return XCTFail("expected VerificationFailed, got \(error)") - } - XCTAssertEqual(check, "recipients-match") - } - } - - func testErrorsCrossTheBoundaryAsTypedErrors() { - XCTAssertThrowsError(try ClientKeys.fromMnemonic(mnemonic: "abandon abandon abandon", network: .regtest)) { error in - guard case SdkError.InvalidMnemonic = error else { - return XCTFail("expected InvalidMnemonic, got \(error)") - } - } - XCTAssertThrowsError(try deriveTaprootAddress(accountXpub: "not-an-xpub", keychain: 0, index: 0, network: .regtest)) { error in - guard case SdkError.InvalidInput = error else { - return XCTFail("expected InvalidInput, got \(error)") - } - } - } - - func testInvoiceDecodersCrossTheBoundaryWithOptionalsListsAndEnums() throws { - let bolt11 = try decodeBolt11(invoice: Self.bolt11Coffee) - XCTAssertEqual(bolt11.network, .mainnet) - XCTAssertEqual(bolt11.amountMsat, 250_000_000) - XCTAssertEqual(bolt11.description, "1 cup coffee") - XCTAssertNil(bolt11.descriptionHash) - XCTAssertEqual(bolt11.expirySeconds, 60) - XCTAssertEqual(bolt11.paymentHash.count, 64) - XCTAssertEqual(bolt11.payeeNodeId.count, 66) - - let wr = try object("witnessReceive", in: fixture()) - let rgb = try decodeRgbInvoice(invoice: try string("invoice", in: wr)) - XCTAssertEqual(rgb.recipientId, try string("recipientId", in: wr)) - XCTAssertEqual(rgb.beneficiaryKind, .witness) - XCTAssertNil(rgb.assetId) - XCTAssertNil(rgb.amount) - XCTAssertEqual(rgb.chain, "bcrt") - XCTAssertEqual(rgb.transportEndpoints, ["rpc://localhost:3000/json-rpc"]) - XCTAssertEqual(rgb.expiryTimestamp, (wr["expirationTimestamp"] as? NSNumber).map { UInt64(truncating: $0) }) - - XCTAssertThrowsError(try decodeBolt11(invoice: "lnbc1pvjluez")) { error in - guard case SdkError.InvoiceDecode = error else { return XCTFail("expected InvoiceDecode, got \(error)") } - } - XCTAssertThrowsError(try decodeRgbInvoice(invoice: "not an invoice")) { error in - guard case SdkError.InvoiceDecode = error else { return XCTFail("expected InvoiceDecode, got \(error)") } - } - } - - func testIdempotencyKeysAreV4Uuids() throws { - let key = try generateIdempotencyKey() - XCTAssertNotNil( - key.range(of: #"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"#, options: .regularExpression), - key) - XCTAssertNotEqual(key, try generateIdempotencyKey(), "keys must be unique per call") - } - - // MARK: foreign transport - - /// A Swift-side transport: records the request Rust built and answers with a canned response. - private final class StubTransport: HttpTransport { - let status: UInt16 - let body: String - private(set) var requests: [HttpRequest] = [] - - init(status: UInt16, body: String) { - self.status = status - self.body = body - } - - func send(request: HttpRequest) throws -> HttpResponse { - requests.append(request) - return HttpResponse(status: status, body: body) - } - } - - /// A transport that throws instead of answering. - private final class ThrowingTransport: HttpTransport { - let error: Error - init(error: Error) { self.error = error } - func send(request: HttpRequest) throws -> HttpResponse { throw error } - } - - private struct HostError: Error, CustomStringConvertible { - let description: String - } - - private func client(_ transport: HttpTransport) -> GatewayClient { - GatewayClient(baseUrl: Self.gateway, token: Self.token, timeoutMs: nil, transport: transport) - } - - func testGatewayClientDrivesASwiftHttpTransport() throws { - let transport = StubTransport(status: 200, body: #"{"userId":"user-1","createdAt":1700000000}"#) - let me = try client(transport).me() - XCTAssertEqual(me.userId, "user-1") - XCTAssertEqual(me.createdAt, 1_700_000_000) - - XCTAssertEqual(transport.requests.count, 1) - let request = try XCTUnwrap(transport.requests.first) - XCTAssertEqual(request.method, .get) - XCTAssertEqual(request.url, "https://gateway.example/v1/me") - XCTAssertEqual(request.headers["authorization"], "Bearer secret-token") - XCTAssertNil(request.body) - XCTAssertGreaterThan(request.timeoutMs, 0) - } - - func testGatewayErrorsSurfaceStatusAndCodeFromTheTransport() { - let transport = StubTransport(status: 503, body: #"{"error":{"code":"MAINTENANCE","message":"back soon"}}"#) - XCTAssertThrowsError(try client(transport).me()) { error in - guard case SdkError.Gateway(let status, let code, _) = error else { - return XCTFail("expected Gateway, got \(error)") - } - XCTAssertEqual(status, 503) - XCTAssertEqual(code, "MAINTENANCE") - XCTAssertFalse(String(describing: error).contains("secret-token"), "bearer token leaked into the error") - XCTAssertFalse(error.localizedDescription.contains("secret-token"), "bearer token leaked into the error") - } - } - - func testTransportErrorsCrossBackIntoRustAsTypedTransportErrors() { - // The declared error type passes through unchanged. - XCTAssertThrowsError(try client(ThrowingTransport(error: SdkError.Transport(reason: "dns failed"))).me()) { error in - guard case SdkError.Transport(let reason) = error else { - return XCTFail("expected Transport, got \(error)") - } - XCTAssertEqual(reason, "dns failed") - } - // A host error that is not an SdkError must still surface as a typed - // Transport error, never as an internal (panic) error. - XCTAssertThrowsError(try client(ThrowingTransport(error: HostError(description: "pool closed"))).me()) { error in - guard case SdkError.Transport(let reason) = error else { - return XCTFail("expected Transport, got \(error)") - } - XCTAssertTrue(reason.contains("unexpected"), reason) - XCTAssertTrue(reason.contains("pool closed"), reason) - XCTAssertFalse(String(describing: error).contains(Self.token), "bearer token leaked into the error") - } - } - - func testPrepareSendBtcBindsTheIntentAndSurfacesNestedRecords() throws { - let transport = StubTransport(status: 200, body: Self.preparedSendBtc) - let op = try client(transport).prepareSendBtc( - params: PrepareSendBtcParams(address: Self.address, amountSat: 40_000, feeRateSatPerVb: nil), - idempotencyKey: nil) - XCTAssertEqual(op.opId, "op-1") - XCTAssertEqual(op.psbt, "cHNidP8=") - XCTAssertEqual(op.expiresAt, 1_700_000_000) - XCTAssertEqual(op.intent.kind, .sendBtc) - XCTAssertEqual(op.intent.feeRateSatPerVb, 2) - XCTAssertNil(op.intent.asset) - XCTAssertNil(op.intent.utxos) - XCTAssertEqual(op.intent.recipients, [IntentRecipient(address: Self.address, scriptHex: "0014deadbeef", amountSat: 40_000)]) - let request = try XCTUnwrap(transport.requests.first) - XCTAssertEqual(request.method, .post) - XCTAssertEqual(request.url, "\(Self.gateway)/v1/onchain/send-btc/prepare") - XCTAssertEqual(request.headers["content-type"], "application/json") - XCTAssertNotNil(request.headers["idempotency-key"]) - let body = try JSONSerialization.jsonObject(with: Data(try XCTUnwrap(request.body).utf8)) as? [String: Any] - XCTAssertEqual(body?["address"] as? String, Self.address) - - // The same gateway response does not match a request for another address. - XCTAssertThrowsError(try client(StubTransport(status: 200, body: Self.preparedSendBtc)).prepareSendBtc( - params: PrepareSendBtcParams(address: Self.attacker, amountSat: 40_000, feeRateSatPerVb: nil), - idempotencyKey: nil)) { error in - guard case SdkError.IntentMismatch(let reason) = error else { - return XCTFail("expected IntentMismatch, got \(error)") - } - XCTAssertTrue(reason.contains("recipient address"), reason) - } - } - - func testLnBalanceMapsCrossTheBoundary() throws { - let transport = StubTransport(status: 200, body: #"{"btcMsat":12345,"assets":{"rgb:a":7,"rgb:b":0}}"#) - let balance = try client(transport).getLnBalance() - XCTAssertEqual(balance.btcMsat, 12_345) - XCTAssertEqual(balance.assets, ["rgb:a": 7, "rgb:b": 0]) - } - - // MARK: URLSessionHttpTransport - - /// In-process stand-in for the network: records what URLSession was asked - /// to send and answers with the configured status and body, so the shipped - /// transport is exercised without a socket. - private final class Recorder: @unchecked Sendable { - private let lock = NSLock() - private var _status = 200 - private var _body = Data() - private var _hang = false - private var _request: URLRequest? - private var _requestBody: Data? - - func configure(status: Int, body: Data, hang: Bool = false) { - lock.lock(); defer { lock.unlock() } - _status = status; _body = body; _hang = hang; _request = nil; _requestBody = nil - } - var status: Int { lock.lock(); defer { lock.unlock() }; return _status } - var body: Data { lock.lock(); defer { lock.unlock() }; return _body } - var hang: Bool { lock.lock(); defer { lock.unlock() }; return _hang } - var request: URLRequest? { lock.lock(); defer { lock.unlock() }; return _request } - var requestBody: Data? { lock.lock(); defer { lock.unlock() }; return _requestBody } - func record(_ request: URLRequest, body: Data?) { - lock.lock(); defer { lock.unlock() } - _request = request; _requestBody = body - } - } - - private static let recorder = Recorder() - - private final class StubURLProtocol: URLProtocol { - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - let recorder = SmokeTests.recorder - recorder.record(request, body: request.httpBody ?? request.httpBodyStream.map(Self.drain)) - if recorder.hang { return } - guard let url = request.url, - let response = HTTPURLResponse(url: url, statusCode: recorder.status, httpVersion: "HTTP/1.1", headerFields: [:]) - else { - client?.urlProtocol(self, didFailWithError: HostError(description: "stub could not build a response")) - return - } - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: recorder.body) - client?.urlProtocolDidFinishLoading(self) - } - - override func stopLoading() {} - - private static func drain(_ stream: InputStream) -> Data { - stream.open() - defer { stream.close() } - var data = Data() - var buffer = [UInt8](repeating: 0, count: 4096) - while stream.hasBytesAvailable { - let read = stream.read(&buffer, maxLength: buffer.count) - if read <= 0 { break } - data.append(buffer, count: read) - } - return data - } - } - - private func stubbedSession() -> URLSession { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [StubURLProtocol.self] - return URLSession(configuration: configuration) - } - - func testUrlSessionTransportRejectsAnInvalidUrlWithoutTouchingTheNetwork() { - let transport = URLSessionHttpTransport() - let request = HttpRequest(method: .get, url: "not a url", headers: [:], body: nil, timeoutMs: 1_000) - XCTAssertThrowsError(try transport.send(request: request)) { error in - guard case SdkError.Transport = error else { - return XCTFail("expected Transport, got \(error)") - } - } - } - - func testUrlSessionTransportForwardsTheRequestVerbatimAndReturnsAnyStatus() throws { - let recorder = Self.recorder - let transport = URLSessionHttpTransport(session: stubbedSession()) - - // A 5xx is returned as a response (status intact), which Rust maps to - // a Gateway error; it is not a Transport error. - recorder.configure(status: 503, body: Data(#"{"error":{"code":"MAINTENANCE","message":"back soon"}}"#.utf8)) - let gateway = GatewayClient(baseUrl: Self.gateway, token: Self.token, timeoutMs: 1_500, transport: transport) - XCTAssertThrowsError(try gateway.me()) { error in - guard case SdkError.Gateway(let status, let code, _) = error else { - return XCTFail("expected Gateway, got \(error)") - } - XCTAssertEqual(status, 503) - XCTAssertEqual(code, "MAINTENANCE") - } - let get = try XCTUnwrap(recorder.request) - XCTAssertEqual(get.httpMethod, "GET") - XCTAssertEqual(get.url?.absoluteString, "\(Self.gateway)/v1/me") - XCTAssertEqual(get.value(forHTTPHeaderField: "authorization"), "Bearer \(Self.token)") - XCTAssertEqual(get.timeoutInterval, 1.5, accuracy: 0.001) - - // A POST carries the JSON body and headers Rust built, byte for byte. - recorder.configure(status: 200, body: Data(Self.preparedSendBtc.utf8)) - let op = try gateway.prepareSendBtc( - params: PrepareSendBtcParams(address: Self.address, amountSat: 40_000, feeRateSatPerVb: nil), - idempotencyKey: "11111111-2222-4333-8444-555555555555") - XCTAssertEqual(op.opId, "op-1") - let post = try XCTUnwrap(recorder.request) - XCTAssertEqual(post.httpMethod, "POST") - XCTAssertEqual(post.value(forHTTPHeaderField: "content-type"), "application/json") - XCTAssertEqual(post.value(forHTTPHeaderField: "idempotency-key"), "11111111-2222-4333-8444-555555555555") - let sent = try JSONSerialization.jsonObject(with: try XCTUnwrap(recorder.requestBody)) as? [String: Any] - XCTAssertEqual(sent?["address"] as? String, Self.address) - XCTAssertEqual((sent?["amountSat"] as? NSNumber).map { UInt64(truncating: $0) }, 40_000) - - // A non-UTF-8 error page keeps its status instead of becoming a - // Transport error. - recorder.configure(status: 500, body: Data([0xff, 0xfe, 0x00, 0xc3])) - XCTAssertThrowsError(try gateway.me()) { error in - guard case SdkError.Gateway(let status, let code, _) = error else { - return XCTFail("expected Gateway, got \(error)") - } - XCTAssertEqual(status, 500) - XCTAssertEqual(code, "UNKNOWN") - } - } - - func testUrlSessionTransportBoundsTheTotalWaitByTimeoutMs() { - let recorder = Self.recorder - defer { recorder.configure(status: 200, body: Data()) } - recorder.configure(status: 200, body: Data(), hang: true) - let transport = URLSessionHttpTransport(session: stubbedSession()) - let request = HttpRequest(method: .get, url: "\(Self.gateway)/v1/me", headers: [:], body: nil, timeoutMs: 300) - let started = Date() - XCTAssertThrowsError(try transport.send(request: request)) { error in - guard case SdkError.Transport(let reason) = error else { - return XCTFail("expected Transport, got \(error)") - } - XCTAssertTrue(reason.contains("timed out"), reason) - } - XCTAssertLessThan(Date().timeIntervalSince(started), 5, "the caller must not be held past the budget") - } -} diff --git a/minimal-sdk/eslint.config.mjs b/minimal-sdk/eslint.config.mjs index 3c3584cf..1d5d4785 100644 --- a/minimal-sdk/eslint.config.mjs +++ b/minimal-sdk/eslint.config.mjs @@ -14,11 +14,6 @@ export default tseslint.config( // Build output of the native mobile SDK (rustdoc emits .js under target/, // Gradle and SwiftPM emit reports). Gitignored, but flat config does not // read .gitignore, so `pnpm lint` after a local build would scan them. - 'rust-sdk/target/', - 'android/build/', - 'android/.gradle/', - 'apple/.build/', - 'apple/build/', ], }, js.configs.recommended, diff --git a/minimal-sdk/packages/client-sdk/src/gateway.ts b/minimal-sdk/packages/client-sdk/src/gateway.ts index 93eb9628..e1b8bbcb 100644 --- a/minimal-sdk/packages/client-sdk/src/gateway.ts +++ b/minimal-sdk/packages/client-sdk/src/gateway.ts @@ -46,16 +46,54 @@ export interface Balance { spendable: number; } +/** + * An rgb-lib Assignment as a variant name plus its fungible value. `kind` + * carries the variant verbatim (`Fungible`, `Any`, `InflationRight`, ...), so a + * variant this SDK predates still arrives named rather than dropped. + */ +export interface Assignment { + kind: string; + amount: number | null; +} + +export interface WalletAsset { + assetId: string; + schema: string; + ticker: string | null; + name: string; + details: string | null; + precision: number; + /** Total issued amount; null for schemas without one (UDA). */ + issuedSupply: number | null; + /** Unix seconds of asset genesis. */ + timestamp: number; + /** Unix seconds the gateway wallet imported the asset. */ + addedAt: number; + balance: Balance; +} + export interface WalletBalances { btc: { vanilla: Balance; colored: Balance }; - assets: { - assetId: string; - schema: string; - ticker: string | null; - name: string; - precision: number; - balance: Balance; - }[]; + assets: WalletAsset[]; +} + +/** + * Derivation an address came from. Re-derive it from your own keys + * (`deriveTaproot`) and compare before showing it as a deposit target: the + * gateway holds the xpubs and could otherwise return any address. Null means the + * gateway could not attribute it — treat that as unproven, not as safe. + */ +export interface AddressDerivation { + account: 'vanilla'; + keychain: number; + index: number; + derivationPath: string; + scriptHex: string; +} + +export interface WalletAddress { + address: string; + derivation: AddressDerivation | null; } export interface WalletUnspent { @@ -63,13 +101,23 @@ export interface WalletUnspent { vout: number; amountSat: number; colorable: boolean; - allocations: { assetId: string | null; amount: number | null; settled: boolean }[]; + /** Blind receives already promised against this UTXO (reserved slots). */ + pendingBlinded: number; + allocations: { + assetId: string | null; + amount: number | null; + assignment: Assignment; + settled: boolean; + }[]; } export interface WalletTransfer { idx: number; + /** Batch this transfer belongs to; rgb-lib keys refresh/fail/delete by it. */ + batchTransferIdx: number | null; assetId: string | null; amount: number | null; + assignments: Assignment[]; kind: string; status: string; txid: string | null; @@ -79,6 +127,15 @@ export interface WalletTransfer { updatedAt: number; } +export interface FeeEstimate { + /** Confirmation target asked for. */ + blocks: number; + /** Whole sat/vB, ready to pass back as `feeRateSatPerVb`. */ + feeRateSatPerVb: number; + /** Indexer ladder target the estimate came from (at or below `blocks`). */ + sourceBlocks: number; +} + export interface ReceiveParams { mode: 'blind' | 'witness'; assetId?: string; @@ -91,6 +148,8 @@ export interface ReceiveResult { invoice: string; recipientId: string; expirationTimestamp: number | null; + /** Batch index of the created receive; null only on rgb-lib shape drift. */ + batchTransferIdx: number | null; mode: 'blind' | 'witness'; } @@ -402,10 +461,21 @@ export class GatewayClient { return this.request({ method: 'POST', path: '/v1/wallet/xpubs', body: params }); } - getAddress(): Promise<{ address: string }> { + getAddress(): Promise { return this.request({ method: 'GET', path: '/v1/wallet/address' }); } + /** + * Fee estimate from the gateway's indexer, which clients cannot reach + * directly. Already a whole sat/vB in the range every fee-taking route + * accepts, rounded up so signing at exactly this rate never lands below the + * estimate. + */ + getFeeEstimate(blocks?: number): Promise { + const query = blocks === undefined ? '' : `?blocks=${encodeURIComponent(String(blocks))}`; + return this.request({ method: 'GET', path: `/v1/onchain/fee-estimate${query}` }); + } + getBalances(): Promise { return this.request({ method: 'GET', path: '/v1/wallet/balances' }); } diff --git a/minimal-sdk/packages/client-sdk/src/index.ts b/minimal-sdk/packages/client-sdk/src/index.ts index 21d458b9..4a91003a 100644 --- a/minimal-sdk/packages/client-sdk/src/index.ts +++ b/minimal-sdk/packages/client-sdk/src/index.ts @@ -53,9 +53,12 @@ export { GatewayError, generateIdempotencyKey, IntentMismatchError, + type AddressDerivation, + type Assignment, type Balance, type CompleteParams, type CreatedUser, + type FeeEstimate, type GatewayClientOptions, type LnBalance, type LnDepositPrepareParams, @@ -76,6 +79,8 @@ export { type ReceiveParams, type ReceiveResult, type RegisterXpubsParams, + type WalletAddress, + type WalletAsset, type WalletBalances, type WalletTransfer, type WalletUnspent, diff --git a/minimal-sdk/packages/gateway/README.md b/minimal-sdk/packages/gateway/README.md index 3f867ca6..9c5b176f 100644 --- a/minimal-sdk/packages/gateway/README.md +++ b/minimal-sdk/packages/gateway/README.md @@ -74,6 +74,7 @@ auto-retries a send. | POST | `/v1/onchain/create-utxos/prepare` | bearer | required | | POST | `/v1/onchain/create-utxos/complete` | bearer | required | | GET | `/v1/onchain/operations/:opId` | bearer | — | +| GET | `/v1/onchain/fee-estimate` | bearer | — | | POST | `/v1/ln/deposit/prepare` | bearer | required | | POST | `/v1/ln/pay` | bearer | required | | POST | `/v1/ln/invoice` | bearer | — | @@ -82,6 +83,12 @@ auto-retries a send. | GET | `/v1/ln/balance` | bearer | — | | POST | `/v1/ln/withdraw` | bearer | required | +Every response carries an `x-request-id` header (a fresh random UUID per +request; a caller-supplied one is never adopted). Error bodies carry only `code` +and `message` — the RLN or wallet failure they were mapped from is logged under +that id and nowhere else, so it is the handle to quote when asking an operator +about a `COMPLETE_AMBIGUOUS` or `WITHDRAWAL_UNRESOLVED`. + ### Errors Every error response is `{"error": {"code", "message"}}`. Notable codes: @@ -111,8 +118,48 @@ Every error response is `{"error": {"code", "message"}}`. Notable codes: | `DEPOSIT_ADDRESS_CONFLICT` | 502 | RLN returned a deposit address (or RGB `recipient_id`) already assigned to another intent — it is misconfigured (see the deployment note). | | `OP_EXPIRED` | 410 | The prepared on-chain operation passed `GATEWAY_ONCHAIN_OP_TTL_SECONDS`; prepare again. | | `UPSTREAM_ERROR` / `UPSTREAM_TIMEOUT` | 502 / 504 | Sanitized RLN failures. | +| `AMOUNT_NOT_REPRESENTABLE` | 502 | The wallet holds an amount above 2^53−1, which JSON numbers cannot carry exactly. Permanent, and not the caller's fault: reported rather than silently rounded (see Known limitations). | +| `FEE_ESTIMATE_UNAVAILABLE` | 502 | The indexer gave no usable fee estimate. Detail stays server-side. | | `COMPLETE_AMBIGUOUS` | 502 | The wallet failed while completing an on-chain op, after rgb-lib may already have broadcast. The op stays pending with its txid recorded; read it back with `GET /v1/onchain/operations/:opId` and retry `complete` to finish the bookkeeping. | +## What the wallet reads carry for a verifying client + +A client that keeps its own keys has to be able to check what the gateway tells it, and to +act on RGB state afterwards. Three things in the wallet responses exist for that: + +- `GET /v1/wallet/address` returns `derivation` — `account`, `keychain`, `index`, + `derivationPath` and `scriptHex` — so the address can be re-derived from the client's own + seed before it is shown as a deposit target. `null` means the gateway could not attribute + it; see Known limitations. +- `GET /v1/wallet/transfers` and `POST /v1/wallet/receive` return `batchTransferIdx`. + rgb-lib keys refresh, fail and delete by the **batch** index, not by `idx`, so without it + a client cannot address the transfer it just read or created. +- `GET /v1/wallet/unspents` returns `pendingBlinded` per UTXO, and every allocation carries + its full `assignment` (`{kind, amount}`) alongside the fungible `amount` summary. Free + allocation slots cannot be counted from `allocations` alone — a pending blind receive + reserves a slot before it holds an allocation — and a non-fungible assignment has no + `amount` to summarize. + +`assignment.kind` is the rgb-lib variant name verbatim (`Fungible`, `Any`, +`InflationRight`, `NonFungible`, ...), so a variant added upstream arrives named rather +than dropped. + +## Fee estimates + +`GET /v1/onchain/fee-estimate?blocks=N` (default `N=6`, range 1..1008) answers +`{blocks, feeRateSatPerVb, sourceBlocks}` from the shared indexer. Clients cannot reach +esplora themselves — it sits behind the gateway with RLN and the RGB proxy — so without +this they either hardcode a rate or send none and take the 2 sat/vB default. + +`feeRateSatPerVb` is a whole sat/vB in the same 1..1000 range every fee-taking route +accepts, so it can be passed straight back. Two roundings are deliberate, both away from +under-paying: the fractional rate is rounded **up**, and a target the indexer does not +publish resolves to the nearest published target **at or below** it (the more urgent, so +never cheaper, neighbour) — which `sourceBlocks` reports. The answer is cached for 30s, so +a burst of clients is one indexer call. It needs no `Idempotency-Key`, no registered +wallet, and is not queued behind the per-user wallet work: a client needs a fee rate +_before_ it can prepare anything. + ## Recovering a lost on-chain completion `complete` can leave a client without an answer: a client-side timeout, a crash, or the @@ -134,12 +181,12 @@ operation row is the durable record of what happened, and How to read it: -| `state` | `mayHaveBroadcast` | Meaning | Do | -| ----------- | ------------------ | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `completed` | `false` | Done; `txid` is final | nothing | -| `pending` | `false` | Prepared, never broadcast | sign and `complete`, or let it expire | -| `pending` | `true` | **The transaction may already be on the network** | retry `complete` — it is safe (rebroadcasting a transaction the indexer knows succeeds) and finishes the bookkeeping | -| `expired` | `true` | Past TTL, but a txid was recorded | do **not** prepare a replacement before checking `txid` on-chain; needs operator resolution | +| `state` | `mayHaveBroadcast` | Meaning | Do | +| ----------- | ------------------ | ------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `completed` | `false` | Done; `txid` is final | nothing | +| `pending` | `false` | Prepared, never broadcast | sign and `complete`, or let it expire | +| `pending` | `true` | **The transaction may already be on the network** | nothing — **the gateway finishes this itself** (below); poll until it reads `completed` | +| `expired` | `true` | Past TTL while ambiguous | same: the gateway still finishes it, and completion is not blocked by the TTL | Three properties worth relying on. It takes **no** `Idempotency-Key` and is not queued behind the per-user wallet work, so it answers even while the `complete` you are asking @@ -148,11 +195,36 @@ it from `prepare`. And an operation belonging to another user is reported as `40 OP_NOT_FOUND`, identical to one that does not exist (I3). Expiry is **derived** on read, never written: an op past `expiresAt` reports `expired` -here while the stored row stays `pending` until `complete` flips it. Note the last row of -the table — an op that expires while ambiguous can no longer be completed (`complete` -answers 410), so its bookkeeping stays unfinished even though the transaction may be -confirmed. Raising `GATEWAY_ONCHAIN_OP_TTL_SECONDS` above the worst-case wallet stall is -the mitigation today. +here while the stored row stays `pending` until it is completed. + +### The gateway resolves ambiguous completions itself + +A client that gets a 502 is entitled never to retry: a 5xx is not proof the send failed, +and a retry it cannot prove safe could, for all it knows, send twice. So the side that +_can_ prove it is safe does it. On the ambiguous path the gateway retains the signed PSBT +next to the recorded txid, and a background worker (`src/workers/completions.ts`) replays +the wallet's end-call until the bookkeeping lands. Replaying is safe because rgb-lib +re-broadcasts and treats a transaction the indexer already knows as success, so the retry +only redoes the write that failed. + +Three boundaries on that: + +- It only ever replays a PSBT **the client itself already submitted** to `complete`, for + an op whose txid is already recorded. It never prepares, re-signs, or broadcasts + anything the client did not authorize, and an op that never reached broadcast (no txid) + is left alone. +- The retained PSBT must still be the transaction the row's txid names, checked before any + wallet call; otherwise the op is left for an operator rather than completed under a + transaction its intent does not describe. +- It stops after `GATEWAY_COMPLETIONS_MAX_ATTEMPTS`. An op that runs out of attempts stays + `pending` with its txid and needs a human: check the node for the recorded txid, then + mark the row completed or resolve it by hand. `CompletionsWorker.unresolved()` lists + exactly those rows and is what a health check should alert on. + +The op TTL deliberately does **not** apply here. The TTL exists so an _unsigned_ PSBT +stops being completable; an ambiguous op's transaction is signed and probably confirmed, +and refusing to record that after ten minutes would strand the bookkeeping for money that +already moved. ## Configuration reference @@ -187,6 +259,8 @@ Optional (default in parentheses): | `GATEWAY_ASSET_INVOICE_MIN_MSAT` (`3000000`) | HTLC carrier value (msat) put on asset invoices that declare no `amtMsat`. Must match the node's `channels.htlc_min_msat` (or the higher `inbound_htlc_minimum_msat` of the asset's channels) — RLN refuses an asset invoice below that floor. | | `GATEWAY_RGB_TRANSPORT_ALLOWLIST` (empty) | Comma-separated extra RGB consignment-proxy endpoints users may pin in withdraw/send-asset requests. `RGB_PROXY_URL` is always allowlisted; anything else is rejected with 400 `TRANSPORT_ENDPOINT_NOT_ALLOWED` (SSRF guard — the node dials these). | | `GATEWAY_DEPOSITS_INTERVAL_MS` (`10000`) | Poll interval of the deposits watcher. | +| `GATEWAY_COMPLETIONS_INTERVAL_MS` (`30000`) | Poll interval of the ambiguous-completion worker. | +| `GATEWAY_COMPLETIONS_MAX_ATTEMPTS` (`5`) | Attempts at finishing one ambiguous operation before it is left to an operator. | | `GATEWAY_RECONCILER_INTERVAL_MS` (`10000`) | Poll interval of the payments reconciler. | | `GATEWAY_RECONCILER_GRACE_SECONDS` (`600`) | Age before the reconciler refunds a debited-pending send RLN does not know about (the send may still be queued behind RLN's global lock). | | `GATEWAY_QUEUE_GLOBAL_CONCURRENCY` (`4`) | Max downstream operations running at once across all users. | @@ -294,8 +368,16 @@ the rest are deliberately deferred (see the workspace README). `GATEWAY_DEPOSIT_TTL_SECONDS`, and each intent credits exactly once — on the first confirmed poll. Later payments to the same address are not credited; prepare a fresh deposit per payment. -- **Ambiguous withdrawals need manual resolution.** The reconciler drives LN _payments_ - to a terminal state but does not cover the `withdrawals` table. If RLN times out or +- **Amounts above 2^53−1 are reported, not rounded.** rgb-lib counts assets in u64 while + JSON numbers are doubles, so a balance or assignment above `Number.MAX_SAFE_INTEGER` + cannot round-trip. It is detected exactly (any such u64 decodes to at least 2^53) and + answered with 502 `AMOUNT_NOT_REPRESENTABLE` rather than served as a wrong number. + Carrying such amounts losslessly needs decimal-string fields on the wire, which is a + breaking change to the wallet read routes and is deferred; until then an asset with a + supply that large is unusable through this gateway rather than silently mis-reported. +- **Ambiguous withdrawals need manual resolution.** The completions worker covers the + on-chain `pending_ops` table; the LN `withdrawals` table has no equivalent. The + reconciler drives LN _payments_ to a terminal state but does not cover `withdrawals`. If RLN times out or answers 5xx mid-`/sendbtc`/`/sendrgb` — rgb-lib broadcasts before writing its bookkeeping, and every post-broadcast failure surfaces as a 500, so the transaction may already be on the network — the row is marked `ambiguous`, the debit stands, and @@ -303,6 +385,13 @@ the rest are deliberately deferred (see the workspace README). (`/listtransactions`, `/listtransfers`) for a matching broadcast and then, in the gateway SQLite, either mark the row `sent` with its txid or mark it `failed` and post a compensating `refund` ledger entry. There is no admin API for this yet. +- **Address attribution is best-effort.** `GET /v1/wallet/address` reports the derivation + it recovered by re-deriving candidates from the registered xpub and matching the script, + because rgb-lib's `get_address` returns only the encoded address. The scan follows the + user's own high-water mark plus a window, so it covers sequential use; a wallet restored + far past that window, or an rgb-lib `reuse_addresses` deployment, reports `derivation: +null`. A client that needs ownership proof must treat null as unproven — it is the same + position it was in when the route returned a bare string. - **Unattributable RGB deposits stay pending.** If a settled RGB transfer for a deposit target carries no fungible assignment, the watcher cannot tell what was received and leaves the intent pending with a warning rather than crediting the declared amount diff --git a/minimal-sdk/packages/gateway/package.json b/minimal-sdk/packages/gateway/package.json index fc33f75d..711409de 100644 --- a/minimal-sdk/packages/gateway/package.json +++ b/minimal-sdk/packages/gateway/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@scure/base": "^2.4.0", + "@scure/bip32": "^2.4.0", "@scure/btc-signer": "^2.4.1", "@utexo/rgb-lib": "0.3.0-beta.18", "better-sqlite3": "^12.4.0", @@ -24,7 +25,6 @@ "@utexo/rgb-lib-linux-x64": "0.3.0-beta.18" }, "devDependencies": { - "@scure/bip32": "^2.4.0", "@scure/bip39": "^2.4.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^20.19.0", diff --git a/minimal-sdk/packages/gateway/src/config.ts b/minimal-sdk/packages/gateway/src/config.ts index 52e1169c..27859cd4 100644 --- a/minimal-sdk/packages/gateway/src/config.ts +++ b/minimal-sdk/packages/gateway/src/config.ts @@ -53,6 +53,13 @@ export interface GatewayConfig { rgbTransportAllowlist: string[]; /** Poll interval of the deposits watcher (ms). */ depositsIntervalMs: number; + /** Poll interval of the ambiguous-completion worker (ms). */ + completionsIntervalMs: number; + /** + * Attempts the completions worker makes at finishing one ambiguous operation + * before leaving it to an operator. + */ + completionsMaxAttempts: number; /** Poll interval of the payments reconciler (ms). */ reconcilerIntervalMs: number; /** @@ -153,6 +160,8 @@ export function loadConfig(env: Record = process.env assetInvoiceMinMsat: intWithDefault(env, 'GATEWAY_ASSET_INVOICE_MIN_MSAT', 3_000_000), rgbTransportAllowlist: [rgbProxyUrl, ...extraTransports.filter((e) => e !== rgbProxyUrl)], depositsIntervalMs: intWithDefault(env, 'GATEWAY_DEPOSITS_INTERVAL_MS', 10_000), + completionsIntervalMs: intWithDefault(env, 'GATEWAY_COMPLETIONS_INTERVAL_MS', 30_000), + completionsMaxAttempts: intWithDefault(env, 'GATEWAY_COMPLETIONS_MAX_ATTEMPTS', 5), reconcilerIntervalMs: intWithDefault(env, 'GATEWAY_RECONCILER_INTERVAL_MS', 10_000), reconcilerGraceSeconds: intWithDefault(env, 'GATEWAY_RECONCILER_GRACE_SECONDS', 600), queueGlobalConcurrency: intWithDefault(env, 'GATEWAY_QUEUE_GLOBAL_CONCURRENCY', 4), diff --git a/minimal-sdk/packages/gateway/src/db.ts b/minimal-sdk/packages/gateway/src/db.ts index e04372d8..5f410b45 100644 --- a/minimal-sdk/packages/gateway/src/db.ts +++ b/minimal-sdk/packages/gateway/src/db.ts @@ -139,6 +139,28 @@ const MIGRATIONS: readonly Migration[] = [ UPDATE ln_invoices SET expires_at = created_at WHERE expires_at = 0; `, }, + { + version: 3, + // Server-side completion recovery, plus address attribution state. + // + // `signed_psbt` is retained ONLY on the ambiguous path, where rgb-lib may + // already have broadcast but its bookkeeping did not finish: without the + // signed PSBT the gateway cannot finish that bookkeeping itself and the row + // is stuck forever unless the client retries `complete` (which a client is + // entitled not to do — a 502 is not proof the send failed). It carries no + // secret: signatures are public and the transaction is already on the + // network. `completion_attempts` bounds the retries so a permanently + // unfinishable op stops burning wallet time and surfaces to an operator. + // + // `address_index_high_water` is the ceiling of the address-attribution scan + // (see wallets/derivation.ts), so attribution keeps working as a user's + // revealed index grows instead of silently falling off a fixed window. + sql: ` + ALTER TABLE pending_ops ADD COLUMN signed_psbt TEXT; + ALTER TABLE pending_ops ADD COLUMN completion_attempts INTEGER NOT NULL DEFAULT 0; + ALTER TABLE user_xpubs ADD COLUMN address_index_high_water INTEGER NOT NULL DEFAULT 0; + `, + }, ]; export function schemaVersion(db: GatewayDb): number { diff --git a/minimal-sdk/packages/gateway/src/fees.ts b/minimal-sdk/packages/gateway/src/fees.ts new file mode 100644 index 00000000..caf6cfd1 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/fees.ts @@ -0,0 +1,156 @@ +/** + * Fee-rate estimates from the shared esplora instance. + * + * Clients cannot reach esplora (it sits behind the gateway with RLN and the RGB + * proxy), so without this route a client has no fee source at all and either + * hardcodes a rate or sends none and accepts the gateway's 2 sat/vB default. + * + * Two deliberate roundings. Esplora reports fractional sat/vB, while every + * gateway fee input is an integer 1..1000 — so the answer is rounded UP, never + * down: a client that signs at exactly this rate must not end up below the + * estimate it asked for. And esplora publishes a fixed ladder of confirmation + * targets (1..25, 144, 504, 1008), so a target it does not publish resolves to + * the largest published target at or below it, which is the more urgent — and + * therefore never cheaper — neighbour. + */ +import { HttpError } from './errors.js'; + +/** Widest target esplora publishes; also the ceiling this route accepts. */ +export const MAX_TARGET_BLOCKS = 1008; +/** Matches the `feeRateSatPerVb` bounds on every fee-taking route. */ +export const MIN_FEE_RATE_SAT_PER_VB = 1; +export const MAX_FEE_RATE_SAT_PER_VB = 1000; +/** Estimates move slowly; a short cache keeps a burst of clients off esplora. */ +export const CACHE_TTL_MS = 30_000; +const ESPLORA_TIMEOUT_MS = 10_000; + +export interface FeeEstimate { + /** Confirmation target the answer is for (as requested). */ + blocks: number; + /** Whole sat/vB, rounded up and clamped into the accepted range. */ + feeRateSatPerVb: number; + /** Esplora target the estimate was actually read from. */ + sourceBlocks: number; +} + +export interface FeeEstimatorOptions { + esploraUrl: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; + now?: () => number; +} + +/** Esplora `GET /fee-estimates`: confirmation target (as a string) → sat/vB. */ +type EsploraFeeEstimates = Record; + +export class FeeEstimator { + private readonly esploraUrl: string; + private readonly fetchImpl: typeof fetch; + private readonly timeoutMs: number; + private readonly now: () => number; + private cached: { at: number; estimates: Map } | undefined; + + constructor(options: FeeEstimatorOptions) { + this.esploraUrl = options.esploraUrl.replace(/\/+$/, ''); + this.fetchImpl = options.fetchImpl ?? fetch; + this.timeoutMs = options.timeoutMs ?? ESPLORA_TIMEOUT_MS; + this.now = options.now ?? Date.now; + } + + async estimate(blocks: number): Promise { + const estimates = await this.load(); + // Largest published target at or below the request: more urgent, so its + // rate is an upper bound on the requested target's rate. + let sourceBlocks: number | undefined; + for (const target of estimates.keys()) { + if (target > blocks) continue; + if (sourceBlocks === undefined || target > sourceBlocks) sourceBlocks = target; + } + // Nothing at or below the request means the ladder starts above it, which + // only happens on a truncated or empty payload — the fastest target + // published is then the closest honest answer. + if (sourceBlocks === undefined) { + sourceBlocks = [...estimates.keys()].sort((a, b) => a - b)[0]; + } + const raw = sourceBlocks === undefined ? undefined : estimates.get(sourceBlocks); + if (sourceBlocks === undefined || raw === undefined) { + throw new HttpError(502, 'FEE_ESTIMATE_UNAVAILABLE', 'no fee estimate is available'); + } + const clamped = Math.min( + MAX_FEE_RATE_SAT_PER_VB, + Math.max(MIN_FEE_RATE_SAT_PER_VB, Math.ceil(raw)), + ); + return { blocks, feeRateSatPerVb: clamped, sourceBlocks }; + } + + private async load(): Promise> { + const cached = this.cached; + if (cached !== undefined && this.now() - cached.at < CACHE_TTL_MS) return cached.estimates; + const estimates = this.parse(await this.fetchEstimates()); + if (estimates.size === 0) { + throw new HttpError( + 502, + 'FEE_ESTIMATE_UNAVAILABLE', + 'the indexer returned no usable fee estimates', + ); + } + this.cached = { at: this.now(), estimates }; + return estimates; + } + + /** Keep only finite, positive numeric entries under integer targets. */ + private parse(raw: EsploraFeeEstimates): Map { + const estimates = new Map(); + for (const [key, value] of Object.entries(raw)) { + const target = Number(key); + if (!Number.isInteger(target) || target < 1 || target > MAX_TARGET_BLOCKS) continue; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) continue; + estimates.set(target, value); + } + return estimates; + } + + /** + * Bounded esplora GET. Same shape as the deposits watcher's: without an + * explicit deadline a stalled indexer holds the request open for undici's + * 300s default, and this route runs on the caller's request. + */ + private async fetchEstimates(): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetchImpl(`${this.esploraUrl}/fee-estimates`, { + signal: controller.signal, + }); + if (!response.ok) { + throw new HttpError( + 502, + 'FEE_ESTIMATE_UNAVAILABLE', + 'the indexer rejected the fee-estimate request', + { cause: new Error(`esplora /fee-estimates status ${response.status}`) }, + ); + } + const body = (await response.json()) as unknown; + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + throw new HttpError( + 502, + 'FEE_ESTIMATE_UNAVAILABLE', + 'the indexer returned an unreadable fee-estimate payload', + ); + } + return body as EsploraFeeEstimates; + } catch (error) { + if (error instanceof HttpError) throw error; + throw new HttpError( + controller.signal.aborted ? 504 : 502, + controller.signal.aborted ? 'UPSTREAM_TIMEOUT' : 'FEE_ESTIMATE_UNAVAILABLE', + controller.signal.aborted + ? 'the indexer did not answer the fee-estimate request in time' + : 'the fee-estimate request failed', + { cause: error }, + ); + } finally { + clearTimeout(timer); + } + } +} diff --git a/minimal-sdk/packages/gateway/src/routes/onchain.ts b/minimal-sdk/packages/gateway/src/routes/onchain.ts index 522ecef9..77bb8abe 100644 --- a/minimal-sdk/packages/gateway/src/routes/onchain.ts +++ b/minimal-sdk/packages/gateway/src/routes/onchain.ts @@ -8,6 +8,7 @@ import type { FastifyInstance, FastifySchema } from 'fastify'; import { createUtxosCompleteRouteSchema, createUtxosPrepareRouteSchema, + feeEstimateRouteSchema, operationGetRouteSchema, sendAssetCompleteRouteSchema, sendAssetPrepareRouteSchema, @@ -26,6 +27,9 @@ interface CompleteBody { signedPsbt: string; } +/** Confirmation target used when a fee-estimate request names none. */ +const DEFAULT_FEE_TARGET_BLOCKS = 6; + export function registerOnchainRoutes(app: FastifyInstance): void { const routeOptions = (schema: FastifySchema) => ({ schema, @@ -45,6 +49,21 @@ export function registerOnchainRoutes(app: FastifyInstance): void { }); }; + // Fee source for clients, which cannot reach esplora themselves. Read-only, + // no Idempotency-Key and not queued behind the per-user wallet work: it makes + // no wallet call, and a client needs a fee rate BEFORE it can prepare + // anything — queueing it behind that user's stalled wallet operation would + // deny it precisely when it is about to be used. Cached in the estimator, so + // a burst of clients does not become a burst of indexer calls. + app.get( + '/v1/onchain/fee-estimate', + { schema: feeEstimateRouteSchema, onRequest: [app.authenticate] }, + (request) => { + const { blocks } = request.query as { blocks?: number }; + return app.fees.estimate(blocks ?? DEFAULT_FEE_TARGET_BLOCKS); + }, + ); + // Read-only recovery route: the outcome of `complete` can be lost to a // timeout, a crash, or the 502 COMPLETE_AMBIGUOUS that rgb-lib's // broadcast-before-bookkeeping ordering makes possible, and the txid is diff --git a/minimal-sdk/packages/gateway/src/routes/wallet.ts b/minimal-sdk/packages/gateway/src/routes/wallet.ts index 04ad0e41..5d40624a 100644 --- a/minimal-sdk/packages/gateway/src/routes/wallet.ts +++ b/minimal-sdk/packages/gateway/src/routes/wallet.ts @@ -32,9 +32,7 @@ export function registerWalletRoutes(app: FastifyInstance): void { app.get('/v1/wallet/address', { schema: walletAddressRouteSchema, ...auth }, (request) => { const userId = request.userId as string; - return app.queues.enqueue(userId, async () => ({ - address: await app.wallets.getAddress(userId), - })); + return app.queues.enqueue(userId, () => app.wallets.getAddress(userId)); }); app.get('/v1/wallet/balances', { schema: walletBalancesRouteSchema, ...auth }, (request) => { diff --git a/minimal-sdk/packages/gateway/src/schemas/onchain.ts b/minimal-sdk/packages/gateway/src/schemas/onchain.ts index 91e419fa..a3f2d609 100644 --- a/minimal-sdk/packages/gateway/src/schemas/onchain.ts +++ b/minimal-sdk/packages/gateway/src/schemas/onchain.ts @@ -133,6 +133,40 @@ export const operationGetRouteSchema = { }, } as const; +/** + * Fee estimate from the shared indexer. Clients cannot reach esplora directly, + * so without this they have no fee source; the answer is a whole sat/vB in the + * same 1..1000 range every fee-taking route accepts, so it can be passed + * straight back as `feeRateSatPerVb`. + */ +export const feeEstimateRouteSchema = { + querystring: { + type: 'object', + properties: { + // 1008 is the widest target esplora publishes. + blocks: { type: 'integer', minimum: 1, maximum: 1008 }, + }, + additionalProperties: false, + }, + response: { + 200: { + type: 'object', + properties: { + blocks: { type: 'integer' }, + feeRateSatPerVb: feeRateSchema, + /** Esplora ladder target the estimate was read from (≤ `blocks`). */ + sourceBlocks: { type: 'integer' }, + }, + required: ['blocks', 'feeRateSatPerVb', 'sourceBlocks'], + additionalProperties: false, + }, + 400: errorBodySchema, + 401: errorBodySchema, + 502: errorBodySchema, + 504: errorBodySchema, + }, +} as const; + export const sendBtcPrepareRouteSchema = { body: { type: 'object', diff --git a/minimal-sdk/packages/gateway/src/schemas/wallet.ts b/minimal-sdk/packages/gateway/src/schemas/wallet.ts index e8f0dc3a..48f9853e 100644 --- a/minimal-sdk/packages/gateway/src/schemas/wallet.ts +++ b/minimal-sdk/packages/gateway/src/schemas/wallet.ts @@ -19,6 +19,21 @@ const balanceSchema = { /** Base58 account xpub (tpub/xpub/vpub...); length bounds, not full checksum. */ const xpubPattern = '^[a-zA-Z0-9]{100,120}$'; +/** + * An rgb-lib Assignment as a variant name plus its fungible value. `kind` + * carries the variant verbatim, so a new rgb-lib assignment variant reaches a + * client as itself rather than as a silently dropped field. + */ +const assignmentSchema = { + type: 'object', + properties: { + kind: { type: 'string' }, + amount: { type: ['integer', 'null'] }, + }, + required: ['kind', 'amount'], + additionalProperties: false, +} as const; + export const registerXpubsRouteSchema = { body: { type: 'object', @@ -50,12 +65,34 @@ export const walletAddressRouteSchema = { response: { 200: { type: 'object', - properties: { address: { type: 'string' } }, - required: ['address'], + properties: { + address: { type: 'string' }, + /** + * Derivation the address came from, so a client can re-derive it from + * its own seed before trusting it as a deposit target — the gateway + * holds the xpubs and could otherwise return any address. Null when it + * could not be attributed (see wallets/derivation.ts): the address is + * still rgb-lib's, it just carries no proof. + */ + derivation: { + type: ['object', 'null'], + properties: { + account: { type: 'string', enum: ['vanilla'] }, + keychain: { type: 'integer' }, + index: { type: 'integer' }, + derivationPath: { type: 'string' }, + scriptHex: { type: 'string' }, + }, + required: ['account', 'keychain', 'index', 'derivationPath', 'scriptHex'], + additionalProperties: false, + }, + }, + required: ['address', 'derivation'], additionalProperties: false, }, 401: errorBodySchema, 404: errorBodySchema, + 502: errorBodySchema, }, } as const; @@ -79,10 +116,28 @@ export const walletBalancesRouteSchema = { schema: { type: 'string' }, ticker: { type: ['string', 'null'] }, name: { type: 'string' }, + details: { type: ['string', 'null'] }, precision: { type: 'integer' }, + /** Total issued amount; null for schemas without one (UDA). */ + issuedSupply: { type: ['integer', 'null'] }, + /** Unix seconds of asset genesis. */ + timestamp: { type: 'integer' }, + /** Unix seconds this wallet imported the asset. */ + addedAt: { type: 'integer' }, balance: balanceSchema, }, - required: ['assetId', 'schema', 'ticker', 'name', 'precision', 'balance'], + required: [ + 'assetId', + 'schema', + 'ticker', + 'name', + 'details', + 'precision', + 'issuedSupply', + 'timestamp', + 'addedAt', + 'balance', + ], additionalProperties: false, }, }, @@ -92,6 +147,7 @@ export const walletBalancesRouteSchema = { }, 401: errorBodySchema, 404: errorBodySchema, + 502: errorBodySchema, }, } as const; @@ -109,6 +165,13 @@ export const walletUnspentsRouteSchema = { vout: { type: 'integer' }, amountSat: { type: 'number' }, colorable: { type: 'boolean' }, + /** + * Blind receives already promised against this UTXO. A client + * counting free allocation slots cannot derive this from + * `allocations`: a pending blind receive reserves a slot before it + * holds an allocation. + */ + pendingBlinded: { type: 'integer' }, allocations: { type: 'array', items: { @@ -116,14 +179,15 @@ export const walletUnspentsRouteSchema = { properties: { assetId: { type: ['string', 'null'] }, amount: { type: ['number', 'null'] }, + assignment: assignmentSchema, settled: { type: 'boolean' }, }, - required: ['assetId', 'amount', 'settled'], + required: ['assetId', 'amount', 'assignment', 'settled'], additionalProperties: false, }, }, }, - required: ['txid', 'vout', 'amountSat', 'colorable', 'allocations'], + required: ['txid', 'vout', 'amountSat', 'colorable', 'pendingBlinded', 'allocations'], additionalProperties: false, }, }, @@ -133,6 +197,7 @@ export const walletUnspentsRouteSchema = { }, 401: errorBodySchema, 404: errorBodySchema, + 502: errorBodySchema, }, } as const; @@ -152,8 +217,16 @@ export const walletTransfersRouteSchema = { type: 'object', properties: { idx: { type: 'integer' }, + /** + * Batch this transfer belongs to. rgb-lib keys refresh, fail and + * delete by the BATCH index, not by `idx`, so a client that only + * sees `idx` cannot act on the transfer it just read. + */ + batchTransferIdx: { type: ['integer', 'null'] }, assetId: { type: ['string', 'null'] }, amount: { type: ['number', 'null'] }, + /** Full assignment list; `amount` is only its fungible summary. */ + assignments: { type: 'array', items: assignmentSchema }, kind: { type: 'string' }, status: { type: 'string' }, txid: { type: ['string', 'null'] }, @@ -164,8 +237,10 @@ export const walletTransfersRouteSchema = { }, required: [ 'idx', + 'batchTransferIdx', 'assetId', 'amount', + 'assignments', 'kind', 'status', 'txid', @@ -183,6 +258,7 @@ export const walletTransfersRouteSchema = { }, 401: errorBodySchema, 404: errorBodySchema, + 502: errorBodySchema, }, } as const; @@ -208,14 +284,21 @@ export const walletReceiveRouteSchema = { invoice: { type: 'string' }, recipientId: { type: 'string' }, expirationTimestamp: { type: ['number', 'null'] }, + /** + * Batch index of the created receive; rgb-lib keys refresh, fail and + * delete by it, so without this a client cannot manage the receive it + * just asked for. Null only on rgb-lib shape drift. + */ + batchTransferIdx: { type: ['integer', 'null'] }, mode: { type: 'string', enum: ['blind', 'witness'] }, }, - required: ['invoice', 'recipientId', 'expirationTimestamp', 'mode'], + required: ['invoice', 'recipientId', 'expirationTimestamp', 'batchTransferIdx', 'mode'], additionalProperties: false, }, 400: errorBodySchema, 401: errorBodySchema, 404: errorBodySchema, + 502: errorBodySchema, }, } as const; diff --git a/minimal-sdk/packages/gateway/src/server.ts b/minimal-sdk/packages/gateway/src/server.ts index 5c13efeb..d11b8f14 100644 --- a/minimal-sdk/packages/gateway/src/server.ts +++ b/minimal-sdk/packages/gateway/src/server.ts @@ -3,6 +3,7 @@ * idempotency hooks. Later tasks register RLN, wallet, on-chain and LN routes * on top of the decorators wired here. */ +import { randomUUID } from 'node:crypto'; import Fastify, { type FastifyInstance } from 'fastify'; import type { GatewayConfig } from './config.js'; import { openDb, type GatewayDb } from './db.js'; @@ -28,6 +29,8 @@ import { RlnClient, type RlnApi } from './rln/client.js'; import { LnFlows, registerLnRoutes } from './routes/ln.js'; import { DepositsWorker } from './workers/deposits.js'; import { Reconciler } from './workers/reconciler.js'; +import { CompletionsWorker } from './workers/completions.js'; +import { FeeEstimator } from './fees.js'; export interface RouteSchemaEntry { method: string | string[]; @@ -51,6 +54,8 @@ declare module 'fastify' { ln: LnFlows; depositsWorker: DepositsWorker; reconciler: Reconciler; + completionsWorker: CompletionsWorker; + fees: FeeEstimator; } interface FastifyRequest { userId?: string; @@ -69,7 +74,7 @@ export interface BuildServerOptions { walletBackend?: WalletBackend; /** RLN client override (unit tests inject a fake; defaults to RlnClient). */ rlnClient?: RlnApi; - /** Esplora fetch override for the deposits watcher (tests inject a stub). */ + /** Esplora fetch override for the deposits watcher and fee estimator. */ esploraFetch?: typeof fetch; /** Start the background workers (deposits watcher + reconciler). Off in tests. */ startWorkers?: boolean; @@ -80,6 +85,14 @@ export async function buildServer(options: BuildServerOptions): Promise randomUUID(), + // Never adopt a caller-supplied id: it would let a client choose the value + // that ties together every log line for its own requests. Fastify v5 + // already defaults this off; pinning it keeps that true across upgrades. + requestIdHeader: false, logger: { level: options.loggerLevel ?? 'info', // Safety net: these headers carry credentials and must never be logged. @@ -102,6 +115,17 @@ export async function buildServer(options: BuildServerOptions): Promise { + void reply.header('x-request-id', request.id); + }); + app.decorate('db', db); app.decorate('gatewayConfig', config); app.decorate( @@ -128,7 +152,15 @@ export async function buildServer(options: BuildServerOptions): Promise { if (error instanceof QueueFullError) { @@ -231,11 +270,13 @@ export async function buildServer(options: BuildServerOptions): Promise { depositsWorker.stop(); reconciler.stop(); + completionsWorker.stop(); await walletPool.closeAll(); db.close(); }); diff --git a/minimal-sdk/packages/gateway/src/wallets/backend.ts b/minimal-sdk/packages/gateway/src/wallets/backend.ts index c51809ea..d1374028 100644 --- a/minimal-sdk/packages/gateway/src/wallets/backend.ts +++ b/minimal-sdk/packages/gateway/src/wallets/backend.ts @@ -31,18 +31,34 @@ export interface WalletBtcBalance { colored: Balance; } +/** An rgb-lib Assignment flattened to a variant name plus its fungible value. */ +export interface Assignment { + kind: string; + amount: number | null; +} + export interface WalletAsset { assetId: string; schema: string; ticker: string | null; name: string; + details: string | null; precision: number; + /** Total issued amount; null for schemas without one (UDA). */ + issuedSupply: number | null; + /** Unix seconds of asset genesis. */ + timestamp: number; + /** Unix seconds this wallet imported the asset. */ + addedAt: number; balance: Balance; } export interface UnspentAllocation { assetId: string | null; + /** Fungible amount, when the assignment carries one. */ amount: number | null; + /** The full assignment, so non-fungible variants are not silently dropped. */ + assignment: Assignment; settled: boolean; } @@ -51,13 +67,18 @@ export interface WalletUnspent { vout: number; amountSat: number; colorable: boolean; + /** Blind receives already promised against this UTXO (reserved slots). */ + pendingBlinded: number; allocations: UnspentAllocation[]; } export interface WalletTransfer { idx: number; + /** Batch this transfer belongs to; rgb-lib keys refresh/fail/delete by it. */ + batchTransferIdx: number | null; assetId: string | null; amount: number | null; + assignments: Assignment[]; kind: string; status: string; txid: string | null; @@ -82,6 +103,8 @@ export interface ReceiveData { invoice: string; recipientId: string; expirationTimestamp: number | null; + /** Batch index of the created receive; null only on rgb-lib shape drift. */ + batchTransferIdx: number | null; } export interface SendAssetBeginRequest { @@ -152,6 +175,13 @@ export class WalletBackendError extends Error { readonly insufficientFunds: boolean = false, /** rgb-lib rejected the REQUEST (bad recipient/asset/address/fee/endpoint). */ readonly clientError: boolean = false, + /** + * rgb-lib returned a value this API cannot carry losslessly — in practice a + * u64 asset amount above 2^53-1, which `JSON.parse` has already rounded. + * Nobody's request is at fault and no retry helps; it is reported rather + * than rounded (see `safeInteger` in ./rgblib.ts). + */ + readonly unrepresentable: boolean = false, ) { super(message); this.name = 'WalletBackendError'; @@ -174,6 +204,17 @@ export class WalletBackendError extends Error { */ export function walletHttpError(error: unknown): unknown { if (!(error instanceof WalletBackendError)) return error; + if (error.unrepresentable) { + // 502 like DEPOSIT_ADDRESS_CONFLICT: an upstream-shaped, permanent problem + // that no client request can fix. It is deliberately NOT a 4xx — the caller + // did nothing wrong — and deliberately not a rounded success. + return new HttpError( + 502, + 'AMOUNT_NOT_REPRESENTABLE', + 'the wallet holds an amount this API cannot represent exactly', + { cause: error }, + ); + } if (error.insufficientFunds) { return new HttpError( 400, diff --git a/minimal-sdk/packages/gateway/src/wallets/derivation.ts b/minimal-sdk/packages/gateway/src/wallets/derivation.ts new file mode 100644 index 00000000..9f2f28bf --- /dev/null +++ b/minimal-sdk/packages/gateway/src/wallets/derivation.ts @@ -0,0 +1,136 @@ +/** + * Attribute an address rgb-lib handed out to the derivation path it came from. + * + * `GET /v1/wallet/address` used to return a bare string, which is unusable to a + * client that must prove it owns the address before showing it as a deposit + * target: the gateway holds the xpubs and could return anything. rgb-lib's + * `get_address` returns only the encoded address (no index), so the path is + * recovered here by re-deriving candidates from the SAME account xpub the user + * registered and matching the script — the client can then repeat the + * derivation from its own seed and compare. + * + * Which account: rgb-lib builds one BDK wallet as + * `BdkWallet::create(desc_colored, desc_vanilla)` (rgb-lib `wallet/core.rs`), + * so BDK's *Internal* keychain is the vanilla descriptor — and `get_address` + * asks for `KeychainKind::Internal` (`wallet/offline.rs`). So the address is + * always VANILLA, and the branch is the vanilla descriptor's own keychain, + * `KEYCHAIN_BTC = 0` (`rgb-lib/src/utils.rs`). "Internal" is BDK's slot name, + * not a BIP-44 change branch: the derivation is `m/86'/coin'/0'/0/index`. + * + * Attribution is best-effort by construction — BDK reveals indices + * sequentially, so a bounded forward scan finds any address it has handed out, + * but a wallet restored past the window, or a `reuse_addresses` deployment, can + * legitimately fall outside it. The route reports `null` rather than guessing; + * a client treats that exactly as it treated the old bare-string response. + */ +import { HDKey } from '@scure/bip32'; +import { hex } from '@scure/base'; +import { p2tr } from '@scure/btc-signer'; +import type { GatewayConfig } from '../config.js'; + +/** rgb-lib `PURPOSE` (BIP-86) / `ACCOUNT` / `KEYCHAIN_BTC` (src/utils.rs). */ +const PURPOSE = 86; +const ACCOUNT = 0; +export const VANILLA_KEYCHAIN = 0; +const HARDENED = 0x80000000; + +/** Extended-key version bytes; xpub/tpub differ only by these. */ +const HD_VERSIONS = { + mainnet: { private: 0x0488ade4, public: 0x0488b21e }, + testnet: { private: 0x04358394, public: 0x043587cf }, +} as const; + +/** Address encoding parameters in the shape @scure/btc-signer expects. */ +const ADDRESS_NETWORKS: Record< + GatewayConfig['bitcoinNetwork'], + { bech32: string; pubKeyHash: number; scriptHash: number; wif: number } +> = { + Mainnet: { bech32: 'bc', pubKeyHash: 0x00, scriptHash: 0x05, wif: 0x80 }, + Testnet: { bech32: 'tb', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, + Signet: { bech32: 'tb', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, + Regtest: { bech32: 'bcrt', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, +}; + +/** rgb-lib `get_coin_type(network, rgb: false)` (src/utils.rs). */ +function vanillaCoinType(network: GatewayConfig['bitcoinNetwork']): number { + return network === 'Mainnet' ? 0 : 1; +} + +/** How far past the highest index seen so far a scan looks. */ +export const ATTRIBUTION_WINDOW = 200; + +export interface AddressDerivation { + /** rgb-lib's `get_address` is always the vanilla side. */ + account: 'vanilla'; + /** Vanilla descriptor keychain; rgb-lib pins this to 0. */ + keychain: number; + index: number; + /** Full master-based path, as it appears in PSBT key-origin metadata. */ + derivationPath: string; + /** BIP-341 tweaked output script, so the client can compare bytes not text. */ + scriptHex: string; +} + +/** + * Parse an account xpub. rgb-lib normalizes the network kind of a registered + * xpub (`str_to_xpub`), so a client may legitimately register an `xpub` against + * a Regtest gateway or a `tpub` against mainnet; both version sets are tried + * rather than rejecting a key rgb-lib itself accepted. + */ +function parseAccountXpub(xpub: string): HDKey | null { + for (const versions of [HD_VERSIONS.testnet, HD_VERSIONS.mainnet]) { + try { + return HDKey.fromExtendedKey(xpub, versions); + } catch { + continue; + } + } + return null; +} + +function taproot( + account: HDKey, + index: number, + network: GatewayConfig['bitcoinNetwork'], +): { address: string; scriptHex: string } | null { + const key = account.deriveChild(VANILLA_KEYCHAIN).deriveChild(index); + if (key.publicKey === null) return null; + // x-only: drop the compressed-point parity byte. + const payment = p2tr(key.publicKey.slice(1), undefined, ADDRESS_NETWORKS[network]); + return { address: payment.address as string, scriptHex: hex.encode(payment.script) }; +} + +/** + * Find the vanilla derivation `address` was produced at, or null when it is not + * in `[0, searchTo]`. Never throws: a malformed stored xpub or an address from + * an unexpected keychain must degrade to "no evidence", not fail the route that + * was only asked for an address. + */ +export function attributeVanillaAddress( + xpubVanilla: string, + network: GatewayConfig['bitcoinNetwork'], + address: string, + searchTo: number, +): AddressDerivation | null { + const account = parseAccountXpub(xpubVanilla); + if (account === null) return null; + const coin = vanillaCoinType(network); + for (let index = 0; index <= searchTo && index < HARDENED; index += 1) { + let derived; + try { + derived = taproot(account, index, network); + } catch { + return null; + } + if (derived === null) return null; + if (derived.address !== address) continue; + return { + account: 'vanilla', + keychain: VANILLA_KEYCHAIN, + index, + derivationPath: `m/${PURPOSE}'/${coin}'/${ACCOUNT}'/${VANILLA_KEYCHAIN}/${index}`, + scriptHex: derived.scriptHex, + }; + } + return null; +} diff --git a/minimal-sdk/packages/gateway/src/wallets/prepare.ts b/minimal-sdk/packages/gateway/src/wallets/prepare.ts index f79fe3de..a522ac3f 100644 --- a/minimal-sdk/packages/gateway/src/wallets/prepare.ts +++ b/minimal-sdk/packages/gateway/src/wallets/prepare.ts @@ -191,7 +191,7 @@ export function txidFromPsbt(psbtBase64: string): string | null { const PSBT_REJECTION_VARIANTS = /\b(FailedBroadcast|InvalidPsbt|CannotFinalizePsbt|CannotCombinePsbts)\b/; -interface PendingOpRow { +export interface PendingOpRow { id: string; user_id: string; kind: OnchainOpKind; @@ -199,6 +199,9 @@ interface PendingOpRow { intent: string; state: 'pending' | 'completed' | 'expired'; txid: string | null; + /** Retained only on the ambiguous path, so the gateway can finish the job. */ + signed_psbt: string | null; + completion_attempts: number; created_at: number; expires_at: number; } @@ -433,16 +436,7 @@ export class OnchainService { let txid: string | null = null; let utxosCreated: number | null = null; try { - await this.pool.withWallet(identity, async (wallet) => { - if (kind === 'send_btc') { - txid = await wallet.sendBtcEnd(signedPsbt); - } else if (kind === 'send_asset') { - txid = await wallet.sendAssetEnd(signedPsbt); - } else { - utxosCreated = await wallet.createUtxosEnd(signedPsbt); - txid = txidFromPsbt(signedPsbt); - } - }); + ({ txid, utxosCreated } = await this.runEnd(identity, kind, signedPsbt)); } catch (error) { if (error instanceof WalletBackendError) { if (error.insufficientFunds) { @@ -465,9 +459,17 @@ export class OnchainService { // rebroadcasting a transaction the indexer already knows succeeds // (rgb-lib `online.rs:79-84`), so the retry just redoes the // bookkeeping that failed here. + // The signed PSBT is retained with the txid so the completions worker + // can finish this op's bookkeeping without the client coming back. A + // client is entitled to treat a 5xx as unresolved and never retry, and + // the row would otherwise stay pending forever with its transaction on + // the network. Signatures are public and the transaction is already + // broadcast, so retaining it reveals nothing. this.db - .prepare(`UPDATE pending_ops SET txid = ? WHERE id = ? AND state = 'pending'`) - .run(signedTxid, opId); + .prepare( + `UPDATE pending_ops SET txid = ?, signed_psbt = ? WHERE id = ? AND state = 'pending'`, + ) + .run(signedTxid, signedPsbt, opId); throw new HttpError( 502, 'COMPLETE_AMBIGUOUS', @@ -478,8 +480,49 @@ export class OnchainService { throw error; } + this.settleCompleted(opId, kind, userId, txid, now); + return { txid, utxosCreated }; + } + + /** + * The wallet half of a completion. Shared verbatim with the completions + * worker so a server-side finish is the same call the client's retry would + * have made — the recovery path must not be a second, differently-behaving + * implementation of `complete`. + */ + private async runEnd( + identity: WalletIdentity, + kind: OnchainOpKind, + signedPsbt: string, + ): Promise { + let txid: string | null = null; + let utxosCreated: number | null = null; + await this.pool.withWallet(identity, async (wallet) => { + if (kind === 'send_btc') { + txid = await wallet.sendBtcEnd(signedPsbt); + } else if (kind === 'send_asset') { + txid = await wallet.sendAssetEnd(signedPsbt); + } else { + utxosCreated = await wallet.createUtxosEnd(signedPsbt); + txid = txidFromPsbt(signedPsbt); + } + }); + return { txid, utxosCreated }; + } + + /** Terminal bookkeeping for a finished op; shared with the worker. */ + private settleCompleted( + opId: string, + kind: OnchainOpKind, + userId: string, + txid: string | null, + now: number, + ): void { + // Completed: drop the retained signed PSBT, it has no further purpose. this.db - .prepare(`UPDATE pending_ops SET state = 'completed', txid = ? WHERE id = ?`) + .prepare( + `UPDATE pending_ops SET state = 'completed', txid = ?, signed_psbt = NULL WHERE id = ?`, + ) .run(txid, opId); if (kind === 'send_asset' && txid !== null) { recordOwnership( @@ -488,6 +531,55 @@ export class OnchainService { now, ); } - return { txid, utxosCreated }; + } + + /** + * Finish the bookkeeping of an op left ambiguous by a failed `complete`, + * server-side. Driven by the completions worker; see + * ../workers/completions.ts for why this cannot wait for the client. + * + * Deliberately NOT subject to the op TTL. The TTL exists so an UNSIGNED PSBT + * stops being completable; this op's transaction is already signed and (very + * likely) on the network, and refusing to record that after ten minutes would + * strand the bookkeeping for a transaction that confirmed anyway. + * + * Re-running the wallet end-call is safe: rgb-lib re-broadcasts and treats a + * transaction the indexer already knows as success (`online.rs:79-84`), so + * this redoes exactly the bookkeeping that failed. + * + * Returns whether the op reached a terminal state. Every failure is the + * caller's to log; the attempt counter is bumped either way so a permanently + * unfinishable op stops consuming wallet time. + */ + async resumeAmbiguous(stale: PendingOpRow, now: number = Date.now()): Promise { + // Re-read rather than trust the caller's snapshot. The worker selects a + // batch and then works through it, so by the time a row's turn comes the + // client's own `complete` retry may have finished it — acting on the stale + // row would spend a wallet slot and an attempt re-broadcasting a + // transaction that is already recorded as done. + const row = this.db.prepare('SELECT * FROM pending_ops WHERE id = ?').get(stale.id) as + PendingOpRow | undefined; + if (row === undefined) return false; + const signedPsbt = row.signed_psbt; + // Mirrors the worker's SQL filter, for a direct caller. + if (row.state !== 'pending' || row.txid === null || signedPsbt === null) return false; + // Counted before the checks below, not after: a row that can never be + // finished must walk itself out of the worker's queue rather than be + // re-examined on every pass forever. + this.db + .prepare('UPDATE pending_ops SET completion_attempts = completion_attempts + 1 WHERE id = ?') + .run(row.id); + // The retained PSBT must BE the transaction this row says may have + // broadcast. If it is not, the row's intent, txid and transfer ownership + // would end up describing a transaction that never went out — so this is + // checked against retained state before any wallet call, rather than + // against whatever the wallet happens to return afterwards. + if (txidFromPsbt(signedPsbt) !== row.txid) return false; + const identity = this.wallets.identityOf(row.user_id); + // Same terminal bookkeeping as `complete`, including taking the txid from + // the wallet rather than from the row. + const { txid } = await this.runEnd(identity, row.kind, signedPsbt); + this.settleCompleted(row.id, row.kind, row.user_id, txid ?? row.txid, now); + return true; } } diff --git a/minimal-sdk/packages/gateway/src/wallets/rgblib.ts b/minimal-sdk/packages/gateway/src/wallets/rgblib.ts index b0b3511b..98422d94 100644 --- a/minimal-sdk/packages/gateway/src/wallets/rgblib.ts +++ b/minimal-sdk/packages/gateway/src/wallets/rgblib.ts @@ -180,12 +180,40 @@ function asRecord(value: unknown): Record { return value !== null && typeof value === 'object' ? (value as Record) : {}; } -function numberOrZero(value: unknown): number { - return typeof value === 'number' ? value : 0; +/** + * A JSON number from rgb-lib that must round-trip losslessly. + * + * rgb-lib counts assets in u64 and `JSON.parse` decodes into a double, so any + * value above 2^53-1 is already rounded by the time this sees it — it cannot be + * recovered, only detected. Detection is exact: a u64 above the safe range + * always parses to at least 2^53, which `Number.isSafeInteger` rejects. The + * alternative was to keep returning the rounded value, which would report a + * balance the wallet does not hold; a loud failure is the lesser evil, and the + * caller (`walletHttpError`) maps it to 502 AMOUNT_NOT_REPRESENTABLE. + * + * Absence is a separate concern and stays with each caller: some fields are + * genuinely optional (an amount-less RGB assignment) and some are drift. + */ +function safeInteger(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new WalletBackendError( + 'wallet value is not representable', + `${field} is not a safe integer: ${String(value)}`, + false, + false, + true, + ); + } + return value; } -function numberOrNull(value: unknown): number | null { - return typeof value === 'number' ? value : null; +/** Drift-tolerant read for fields rgb-lib may omit; a present value is still range-checked. */ +function safeIntegerOrZero(value: unknown, field: string): number { + return value === undefined || value === null ? 0 : safeInteger(value, field); +} + +function safeIntegerOrNull(value: unknown, field: string): number | null { + return value === undefined || value === null ? null : safeInteger(value, field); } function stringOrNull(value: unknown): string | null { @@ -204,19 +232,44 @@ export function unquote(value: string): string { return value; } -function mapBalance(value: unknown): Balance { +function mapBalance(value: unknown, field: string): Balance { const record = asRecord(value); return { - settled: numberOrZero(record['settled']), - future: numberOrZero(record['future']), - spendable: numberOrZero(record['spendable']), + settled: safeIntegerOrZero(record['settled'], `${field}.settled`), + future: safeIntegerOrZero(record['future'], `${field}.future`), + spendable: safeIntegerOrZero(record['spendable'], `${field}.spendable`), }; } /** Fungible amount of an externally tagged rgb-lib Assignment, if any. */ -function assignmentAmount(value: unknown): number | null { +function assignmentAmount(value: unknown, field: string): number | null { const record = asRecord(value); - return numberOrNull(record['Fungible']); + return safeIntegerOrNull(record['Fungible'], `${field}.Fungible`); +} + +/** + * An rgb-lib Assignment as a flat pair. The wire form is externally tagged — + * `{"Fungible": 100}`, `{"InflationRight": 100}`, `{"NonFungible": …}` or the + * bare string `"Any"` / `"ReplaceRight"` for unit variants — which is awkward + * to declare in a strict response schema and awkward to consume. `kind` carries + * the variant name verbatim so a new rgb-lib variant surfaces as itself rather + * than as a dropped field; `amount` is set only for the fungible-valued ones. + */ +export function mapAssignment( + value: unknown, + field: string, +): { kind: string; amount: number | null } { + if (typeof value === 'string') return { kind: value, amount: null }; + const entries = Object.entries(asRecord(value)); + const first = entries[0]; + if (entries.length !== 1 || first === undefined) { + throw new WalletBackendError( + 'wallet returned an unreadable assignment', + `${field} is not an externally tagged assignment: ${JSON.stringify(value)}`, + ); + } + const [kind, payload] = first; + return { kind, amount: typeof payload === 'number' ? safeInteger(payload, field) : null }; } /** Exported for unit tests (response-shape drift must fail loudly, not corrupt data). */ @@ -229,13 +282,28 @@ export function mapAssets(raw: unknown): WalletAsset[] { const record = asRecord(entry); const assetId = stringOrNull(record['assetId']); if (assetId === null) continue; + // Every rgb-lib asset schema declares a non-optional, non-empty name. + // Substituting '' here used to hide the drift and then hand a client a + // nameless asset it cannot display or validate; fail loudly instead. + const name = stringOrNull(record['name']); + if (name === null) { + throw new WalletBackendError( + 'wallet returned an unnamed asset', + `asset ${assetId} (${schemaKey}) has no name`, + ); + } assets.push({ assetId, schema: schemaKey, ticker: stringOrNull(record['ticker']), - name: typeof record['name'] === 'string' ? record['name'] : '', - precision: numberOrZero(record['precision']), - balance: mapBalance(record['balance']), + name, + details: stringOrNull(record['details']), + precision: safeIntegerOrZero(record['precision'], `asset ${assetId} precision`), + // UDA has no issued supply; NIA/CFA/IFA do. + issuedSupply: safeIntegerOrNull(record['issuedSupply'], `asset ${assetId} issuedSupply`), + timestamp: safeIntegerOrZero(record['timestamp'], `asset ${assetId} timestamp`), + addedAt: safeIntegerOrZero(record['addedAt'], `asset ${assetId} addedAt`), + balance: mapBalance(record['balance'], `asset ${assetId} balance`), }); } } @@ -250,20 +318,27 @@ export function mapUnspents(raw: unknown): WalletUnspent[] { const outpoint = asRecord(utxo['outpoint']); const allocationsRaw = record['rgbAllocations']; const allocations: UnspentAllocation[] = Array.isArray(allocationsRaw) - ? allocationsRaw.map((allocation) => { + ? allocationsRaw.map((allocation, index) => { const allocationRecord = asRecord(allocation); + const field = `unspent allocation[${index}]`; return { assetId: stringOrNull(allocationRecord['assetId']), - amount: assignmentAmount(allocationRecord['assignment']), + amount: assignmentAmount(allocationRecord['assignment'], field), + assignment: mapAssignment(allocationRecord['assignment'], `${field}.assignment`), settled: allocationRecord['settled'] === true, }; }) : []; return { txid: typeof outpoint['txid'] === 'string' ? outpoint['txid'] : '', - vout: numberOrZero(outpoint['vout']), - amountSat: numberOrZero(utxo['btcAmount']), + vout: safeIntegerOrZero(outpoint['vout'], 'unspent vout'), + amountSat: safeIntegerOrZero(utxo['btcAmount'], 'unspent btcAmount'), colorable: utxo['colorable'] === true, + // Blind receives already promised against this UTXO. A client choosing + // inputs or counting free allocation slots cannot do it from + // `allocations` alone: a pending blind receive reserves a slot without + // yet holding an allocation. + pendingBlinded: safeIntegerOrZero(record['pendingBlinded'], 'unspent pendingBlinded'), allocations, }; }); @@ -273,25 +348,36 @@ export function mapTransfers(raw: unknown, assetId: string | null): WalletTransf if (!Array.isArray(raw)) return []; return raw.map((entry) => { const record = asRecord(entry); - let amount = assignmentAmount(record['requestedAssignment']); - if (amount === null && Array.isArray(record['assignments'])) { - const total = record['assignments'] - .map((assignment) => assignmentAmount(assignment)) + let amount = assignmentAmount(record['requestedAssignment'], 'transfer requestedAssignment'); + const rawAssignments = record['assignments']; + const assignments = Array.isArray(rawAssignments) + ? rawAssignments.map((assignment, index) => + mapAssignment(assignment, `transfer assignments[${index}]`), + ) + : []; + if (amount === null && Array.isArray(rawAssignments)) { + const total = rawAssignments + .map((assignment, index) => assignmentAmount(assignment, `transfer assignments[${index}]`)) .filter((value): value is number => value !== null) .reduce((sum, value) => sum + value, 0); - amount = total > 0 ? total : null; + amount = total > 0 ? safeInteger(total, 'transfer assignments total') : null; } return { - idx: numberOrZero(record['idx']), + idx: safeIntegerOrZero(record['idx'], 'transfer idx'), + // The batch transfer this one belongs to. rgb-lib's own operations + // (refresh, fail, delete) are keyed by the BATCH index, not `idx`, so a + // client that only sees `idx` cannot address the transfer it just read. + batchTransferIdx: safeIntegerOrNull(record['batchTransferIdx'], 'transfer batchTransferIdx'), assetId, amount, + assignments, kind: typeof record['kind'] === 'string' ? record['kind'] : 'Unknown', status: typeof record['status'] === 'string' ? record['status'] : 'Unknown', txid: stringOrNull(record['txid']), recipientId: stringOrNull(record['recipientId']), - expiration: numberOrNull(record['expiration']), - createdAt: numberOrZero(record['createdAt']), - updatedAt: numberOrZero(record['updatedAt']), + expiration: safeIntegerOrNull(record['expiration'], 'transfer expiration'), + createdAt: safeIntegerOrZero(record['createdAt'], 'transfer createdAt'), + updatedAt: safeIntegerOrZero(record['updatedAt'], 'transfer updatedAt'), }; }); } @@ -376,7 +462,10 @@ class NativeWalletHandle implements WalletHandle { const raw = this.call('getBtcBalance', () => JSON.parse(this.lib.rgblib_get_btc_balance(this.wallet, this.online, false)), ) as Record; - return { vanilla: mapBalance(raw['vanilla']), colored: mapBalance(raw['colored']) }; + return { + vanilla: mapBalance(raw['vanilla'], 'btc balance vanilla'), + colored: mapBalance(raw['colored'], 'btc balance colored'), + }; } async listAssets(): Promise { @@ -427,7 +516,18 @@ class NativeWalletHandle implements WalletHandle { `unexpected receive response shape: ${Object.keys(raw).join(',')}`, ); } - return { invoice, recipientId, expirationTimestamp: numberOrNull(raw['expirationTimestamp']) }; + return { + invoice, + recipientId, + expirationTimestamp: safeIntegerOrNull( + raw['expirationTimestamp'], + 'receive expirationTimestamp', + ), + // rgb-lib keys refresh/fail/delete by batch index, so a client that wants + // to manage the receive it just created needs this. It is non-optional in + // rgb-lib's ReceiveData; null here only means shape drift. + batchTransferIdx: safeIntegerOrNull(raw['batchTransferIdx'], 'receive batchTransferIdx'), + }; } async sendBtcBegin(address: string, amountSat: number, feeRateSatPerVb: number): Promise { diff --git a/minimal-sdk/packages/gateway/src/wallets/service.ts b/minimal-sdk/packages/gateway/src/wallets/service.ts index 9c110ac2..1eec0eb8 100644 --- a/minimal-sdk/packages/gateway/src/wallets/service.ts +++ b/minimal-sdk/packages/gateway/src/wallets/service.ts @@ -7,6 +7,11 @@ import type { GatewayConfig } from '../config.js'; import type { GatewayDb } from '../db.js'; import { HttpError } from '../errors.js'; import { recordOwnership } from '../rln/scoping.js'; +import { + attributeVanillaAddress, + ATTRIBUTION_WINDOW, + type AddressDerivation, +} from './derivation.js'; import { WalletBackendError, walletHttpError, @@ -26,6 +31,17 @@ export interface WalletBalances { assets: WalletAsset[]; } +export interface WalletAddress { + address: string; + /** + * Where the address came from, so a client can re-derive it from its own seed + * before trusting it as a deposit target. Null when attribution failed (see + * wallets/derivation.ts) — the address is still rgb-lib's, it just carries no + * proof, and a client that requires proof should refuse it. + */ + derivation: AddressDerivation | null; +} + export interface ReceiveParams { mode: 'blind' | 'witness'; assetId?: string | undefined; @@ -130,8 +146,34 @@ export class WalletService { return { created: true, address }; } - async getAddress(userId: string): Promise { - return this.run(this.identityOf(userId), (wallet) => wallet.getAddress()); + /** + * Next address from the user's vanilla wallet, with the derivation it came + * from where that can be established. + * + * The scan ceiling follows the user's own revealed indices: rgb-lib reveals + * sequentially, so the address just handed out is at most one past the + * highest one previously attributed. The high-water mark is persisted so the + * window travels with the wallet rather than expiring at a fixed index. + */ + async getAddress(userId: string): Promise { + const identity = this.identityOf(userId); + const address = await this.run(identity, (wallet) => wallet.getAddress()); + const row = this.db + .prepare('SELECT address_index_high_water AS highWater FROM user_xpubs WHERE user_id = ?') + .get(userId) as { highWater: number } | undefined; + const highWater = row?.highWater ?? 0; + const derivation = attributeVanillaAddress( + identity.xpubs.vanilla, + this.config.bitcoinNetwork, + address, + highWater + ATTRIBUTION_WINDOW, + ); + if (derivation !== null && derivation.index > highWater) { + this.db + .prepare('UPDATE user_xpubs SET address_index_high_water = ? WHERE user_id = ?') + .run(derivation.index, userId); + } + return { address, derivation }; } async getBalances(userId: string): Promise { diff --git a/minimal-sdk/packages/gateway/src/workers/completions.ts b/minimal-sdk/packages/gateway/src/workers/completions.ts new file mode 100644 index 00000000..2fe97688 --- /dev/null +++ b/minimal-sdk/packages/gateway/src/workers/completions.ts @@ -0,0 +1,140 @@ +/** + * Completions worker: finishes the bookkeeping of on-chain operations left + * ambiguous by a failed `complete`. + * + * Why the gateway has to own this. rgb-lib broadcasts before it writes its + * bookkeeping, so any post-broadcast failure surfaces as a 502 + * COMPLETE_AMBIGUOUS over a transaction that is probably already on the network + * (`wallets/prepare.ts`). The documented recovery used to be "the client + * retries `complete`" — but a client is entitled to treat a 5xx as unresolved + * and never retry, because a retry it cannot prove is safe could, for all it + * knows, send twice. A correct client that refuses to retry therefore left the + * row pending forever: the transaction confirms, the wallet's colored-txo and + * spent state never advance, and `GET /v1/onchain/operations/:opId` reports + * `pending` + `mayHaveBroadcast` until the TTL turns it into `expired` — which + * `complete` then refuses with 410. Nothing in the system resolved it. + * + * So the side that CAN prove the retry is safe does it. The gateway knows the + * op, the signed PSBT and the recorded txid, and rgb-lib's re-broadcast of a + * transaction the indexer already knows succeeds rather than double-spending + * (`online.rs:79-84`), so replaying the wallet end-call only redoes the write + * that failed. Each pass makes at most one attempt per op and stops after + * `maxAttempts`, leaving a genuinely unfinishable op for an operator instead of + * pinning the wallet queue forever. + * + * This does NOT retry anything the client has not already signed and submitted: + * it only ever replays a PSBT the client itself sent to `complete`, for an op + * whose txid is already recorded. It never prepares, never re-signs and never + * broadcasts a transaction the client did not authorize. + */ +import type { GatewayDb } from '../db.js'; +import type { OnchainService, PendingOpRow } from '../wallets/prepare.js'; + +/** Attempts before an op is left to an operator. */ +export const DEFAULT_MAX_ATTEMPTS = 5; + +export interface CompletionsWorkerOptions { + db: GatewayDb; + onchain: OnchainService; + maxAttempts?: number; + log?: { + warn(obj: unknown, msg: string): void; + info(obj: unknown, msg: string): void; + }; +} + +export class CompletionsWorker { + private readonly db: GatewayDb; + private readonly onchain: OnchainService; + private readonly maxAttempts: number; + private readonly log: CompletionsWorkerOptions['log']; + private timer: NodeJS.Timeout | undefined; + private running = false; + + constructor(options: CompletionsWorkerOptions) { + this.db = options.db; + this.onchain = options.onchain; + this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + this.log = options.log; + } + + start(intervalMs: number): void { + if (this.timer !== undefined) return; + this.timer = setInterval(() => { + void this.runOnce(); + }, intervalMs); + this.timer.unref(); + } + + stop(): void { + if (this.timer !== undefined) clearInterval(this.timer); + this.timer = undefined; + } + + /** + * Ops awaiting a server-side finish: still pending, a txid already recorded + * (so rgb-lib reached broadcast), the signed PSBT retained, and attempts left. + * Expiry is deliberately not a filter — see `OnchainService.resumeAmbiguous`. + */ + pending(): PendingOpRow[] { + return this.db + .prepare( + `SELECT * FROM pending_ops + WHERE state = 'pending' AND txid IS NOT NULL AND signed_psbt IS NOT NULL + AND completion_attempts < ? + ORDER BY created_at`, + ) + .all(this.maxAttempts) as PendingOpRow[]; + } + + /** + * One pass. Per-op isolation: `start` invokes this with `void`, so an escaped + * rejection would take the process down on a transient wallet failure, and one + * stuck op must not stop the others from resolving. + */ + async runOnce(): Promise { + if (this.running) return; + this.running = true; + try { + for (const row of this.pending()) { + try { + const settled = await this.onchain.resumeAmbiguous(row); + if (settled) { + this.log?.info( + { opId: row.id, kind: row.kind, txid: row.txid }, + 'ambiguous on-chain operation completed server-side', + ); + } else { + this.log?.warn( + { opId: row.id, kind: row.kind, txid: row.txid }, + 'ambiguous on-chain operation could not be completed; left for an operator', + ); + } + } catch (error) { + // The attempt counter was already bumped, so a permanently failing op + // walks itself out of the queue rather than being retried forever. + this.log?.warn( + { err: error, opId: row.id, kind: row.kind, txid: row.txid }, + 'ambiguous on-chain completion attempt failed', + ); + } + } + } finally { + this.running = false; + } + } + + /** + * Ops that ran out of attempts and need a human. Surfaced for the operator + * runbook in the gateway README; also what a health check should alert on. + */ + unresolved(): PendingOpRow[] { + return this.db + .prepare( + `SELECT * FROM pending_ops + WHERE state = 'pending' AND txid IS NOT NULL AND completion_attempts >= ? + ORDER BY created_at`, + ) + .all(this.maxAttempts) as PendingOpRow[]; + } +} diff --git a/minimal-sdk/packages/gateway/test/completions-worker.test.ts b/minimal-sdk/packages/gateway/test/completions-worker.test.ts new file mode 100644 index 00000000..d030e9f0 --- /dev/null +++ b/minimal-sdk/packages/gateway/test/completions-worker.test.ts @@ -0,0 +1,298 @@ +/** + * Completions worker: the gateway finishing the bookkeeping of an on-chain + * operation that `complete` left ambiguous. + * + * The behaviour under test is the whole reason the worker exists. A client that + * receives 502 COMPLETE_AMBIGUOUS is entitled never to retry — a 5xx is not + * proof the send failed, and a retry it cannot prove safe could, as far as it + * knows, send twice. Before this worker such an op stayed `pending` forever with + * its transaction on the network, then aged into `expired`, at which point + * `complete` refused it with 410 and nothing could ever resolve it. + */ +import type { FastifyInstance } from 'fastify'; +import { randomUUID } from 'node:crypto'; +import { base64, hex } from '@scure/base'; +import { Transaction } from '@scure/btc-signer'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WalletBackendError } from '../src/wallets/backend.js'; +import { txidFromPsbt } from '../src/wallets/prepare.js'; +import { createTestUser, testServer, type TestUser } from './helpers.js'; +import { MockWalletBackend } from './wallet-mocks.js'; + +const XPUBS = { + vanilla: + 'tpubDDfvzhdVV4unsoKt5aE6dcsNsfeWbTgmLZPi8LQDYU2xixrYemMfWJ3BaVneH3u7DBQePdTwhpybaKRU95pi6PMUtLPBJLVQRpzEnjfjZzX', + colored: + 'tpubDCtpoJs6YJcjLnr9gq6jYriYNMuWEu8mSDvEQU5st3ZkJbFqqzwpHUiPvxqD2366ciFAfpehk1k2d7Tyk7AJEr8uZva7KfnX4RpsiVSoEcZ', + fingerprint: '73c5da0a', +}; + +const RECIPIENT_ADDRESS = 'bcrt1p8wpt9v4frpf3tkn0srd97pksgsxc5hs52lafxwru9kgeephvs7rqjeprhg'; +const RECIPIENT_SCRIPT_HEX = '51203b82b2b2a9185315da6f80da5f06d0440d8a5e1457fa93387c2d919c86ec8786'; + +/** Minimal parsable PSBT spending outpoint `char.repeat(64)`:0. */ +function parsablePsbt(char: string): string { + const tx = new Transaction({ allowUnknownInputs: true, allowUnknownOutputs: true }); + tx.addInput({ txid: char.repeat(64), index: 0 }); + tx.addOutput({ script: hex.decode(RECIPIENT_SCRIPT_HEX), amount: 40_000n }); + return base64.encode(tx.toPSBT()); +} +const PSBT_A = parsablePsbt('a'); +const PSBT_B = parsablePsbt('b'); + +/** rgb-lib failure that cannot be pinned on the PSBT: may already have broadcast. */ +function postBroadcastFailure(): WalletBackendError { + return new WalletBackendError( + 'wallet sendBtcEnd failed', + 'RgbLib(Database { details: "error returned from database: disk I/O error" })', + ); +} + +describe('completions worker', () => { + let app: FastifyInstance; + let backend: MockWalletBackend; + let user: TestUser; + + beforeEach(async () => { + backend = new MockWalletBackend(); + backend.dataFor = () => ({ preparedPsbt: PSBT_A }); + app = await testServer({ walletBackend: backend }); + user = await createTestUser(app); + const registered = await app.inject({ + method: 'POST', + url: '/v1/wallet/xpubs', + headers: { authorization: `Bearer ${user.token}` }, + payload: XPUBS, + }); + expect(registered.statusCode).toBe(201); + }); + + afterEach(async () => { + await app.close(); + }); + + function headers(key: string = randomUUID()) { + return { authorization: `Bearer ${user.token}`, 'idempotency-key': key }; + } + + async function prepareSendBtc() { + const response = await app.inject({ + method: 'POST', + url: '/v1/onchain/send-btc/prepare', + headers: headers(), + payload: { address: RECIPIENT_ADDRESS, amountSat: 40_000, feeRateSatPerVb: 2 }, + }); + expect(response.statusCode).toBe(201); + return response.json() as { opId: string }; + } + + function opRow(opId: string) { + return app.db.prepare('SELECT * FROM pending_ops WHERE id = ?').get(opId) as + | { + state: string; + txid: string | null; + signed_psbt: string | null; + completion_attempts: number; + } + | undefined; + } + + async function getOp(opId: string) { + return app.inject({ + method: 'GET', + url: `/v1/onchain/operations/${opId}`, + headers: { authorization: `Bearer ${user.token}` }, + }); + } + + /** Drive an op to the ambiguous state the worker is meant to resolve. */ + async function makeAmbiguous(): Promise { + // Prepare must succeed, so clear any failure a previous call left armed. + const open = backend.handleFor(user.userId); + if (open !== undefined) open.failWith = undefined; + const { opId } = await prepareSendBtc(); + backend.handleFor(user.userId)!.failWith = postBroadcastFailure(); + const response = await app.inject({ + method: 'POST', + url: '/v1/onchain/send-btc/complete', + headers: headers(), + payload: { opId, signedPsbt: PSBT_A }, + }); + expect(response.statusCode).toBe(502); + expect(response.json().error.code).toBe('COMPLETE_AMBIGUOUS'); + return opId; + } + + it('retains the signed PSBT with the txid when a completion goes ambiguous', async () => { + const opId = await makeAmbiguous(); + const row = opRow(opId); + expect(row?.state).toBe('pending'); + expect(row?.txid).toBe(txidFromPsbt(PSBT_A)); + // Without this the gateway cannot finish the job itself. It is not a secret: + // signatures are public and the transaction is already broadcast. + expect(row?.signed_psbt).toBe(PSBT_A); + }); + + it('finishes an ambiguous operation without the client ever coming back', async () => { + const opId = await makeAmbiguous(); + // The wallet recovers (rgb-lib re-broadcast of a known transaction succeeds). + backend.handleFor(user.userId)!.failWith = undefined; + + await app.completionsWorker.runOnce(); + + const row = opRow(opId); + expect(row?.state).toBe('completed'); + // The txid comes from the wallet, exactly as in `complete` (the mock returns + // a constant; rgb-lib returns the broadcast txid of the retained PSBT). + expect(row?.txid).toBe('mock-btc-txid'); + // Retained PSBT dropped once it has no further purpose. + expect(row?.signed_psbt).toBeNull(); + // And the client's own recovery read now terminalizes instead of reporting + // an ambiguity forever. + const status = (await getOp(opId)).json(); + expect(status.state).toBe('completed'); + expect(status.mayHaveBroadcast).toBe(false); + }); + + it('resolves an ambiguous operation that has already passed its TTL', async () => { + // The TTL exists so an UNSIGNED PSBT stops being completable. This one is + // signed and on the network: refusing to record it would strand the + // bookkeeping for a transaction that confirmed anyway, which is exactly the + // dead end the old "retry complete" recovery had (complete answers 410). + const opId = await makeAmbiguous(); + app.db.prepare('UPDATE pending_ops SET expires_at = ? WHERE id = ?').run(Date.now() - 1, opId); + expect((await getOp(opId)).json().state).toBe('expired'); + backend.handleFor(user.userId)!.failWith = undefined; + + await app.completionsWorker.runOnce(); + + expect(opRow(opId)?.state).toBe('completed'); + }); + + it('leaves an op alone while it is still failing, and counts the attempt', async () => { + const opId = await makeAmbiguous(); + expect(opRow(opId)?.completion_attempts).toBe(0); + + await app.completionsWorker.runOnce(); + + expect(opRow(opId)?.state).toBe('pending'); + expect(opRow(opId)?.completion_attempts).toBe(1); + // Still eligible: the next pass tries again. + expect(app.completionsWorker.pending().map((row) => row.id)).toEqual([opId]); + }); + + it('gives up after the attempt cap and reports the op as needing an operator', async () => { + const opId = await makeAmbiguous(); + for (let pass = 0; pass < 6; pass += 1) await app.completionsWorker.runOnce(); + + const row = opRow(opId); + expect(row?.state).toBe('pending'); + // Capped at 5 attempts (GATEWAY_COMPLETIONS_MAX_ATTEMPTS): a permanently + // unfinishable op must stop consuming the user's wallet queue every pass. + expect(row?.completion_attempts).toBe(5); + expect(app.completionsWorker.pending()).toEqual([]); + expect(app.completionsWorker.unresolved().map((entry) => entry.id)).toEqual([opId]); + }); + + it('ignores ops that never reached broadcast', async () => { + // No txid recorded means rgb-lib failed before broadcast (or the op was + // merely prepared): there is nothing to finish, and replaying a PSBT here + // would be the gateway sending on its own initiative. + const { opId } = await prepareSendBtc(); + expect(app.completionsWorker.pending()).toEqual([]); + + await app.completionsWorker.runOnce(); + + expect(opRow(opId)?.state).toBe('pending'); + expect(opRow(opId)?.completion_attempts).toBe(0); + }); + + it('ignores an ambiguous op with no retained PSBT (pre-upgrade rows)', async () => { + const opId = await makeAmbiguous(); + app.db.prepare('UPDATE pending_ops SET signed_psbt = NULL WHERE id = ?').run(opId); + expect(app.completionsWorker.pending()).toEqual([]); + + await app.completionsWorker.runOnce(); + + expect(opRow(opId)?.state).toBe('pending'); + }); + + it('refuses to broadcast a retained PSBT that is not the recorded transaction', async () => { + // A retained PSBT whose txid disagrees with the recorded one would mean the + // row's intent describes a different transaction. The check runs on retained + // state BEFORE any wallet call, so nothing is broadcast. + const opId = await makeAmbiguous(); + app.db.prepare('UPDATE pending_ops SET signed_psbt = ? WHERE id = ?').run(PSBT_B, opId); + const handle = backend.handleFor(user.userId)!; + handle.failWith = undefined; + const before = handle.operations.length; + + await app.completionsWorker.runOnce(); + + expect(handle.operations.slice(before)).toEqual([]); + expect(opRow(opId)?.state).toBe('pending'); + expect(opRow(opId)?.completion_attempts).toBe(1); + + // And it walks out to the operator queue instead of being retried forever. + for (let pass = 0; pass < 5; pass += 1) await app.completionsWorker.runOnce(); + expect(app.completionsWorker.pending()).toEqual([]); + expect(app.completionsWorker.unresolved().map((entry) => entry.id)).toEqual([opId]); + }); + + it('skips a row a client finished after the batch was selected', async () => { + // The worker selects a batch and then works through it; a client's own + // retry can land in between. Acting on the stale row would spend a wallet + // slot and an attempt re-broadcasting a transaction already recorded done. + const opId = await makeAmbiguous(); + const batch = app.completionsWorker.pending(); + expect(batch).toHaveLength(1); + const handle = backend.handleFor(user.userId)!; + handle.failWith = undefined; + app.db + .prepare("UPDATE pending_ops SET state = 'completed', signed_psbt = NULL WHERE id = ?") + .run(opId); + const before = handle.operations.length; + + expect(await app.onchain.resumeAmbiguous(batch[0]!)).toBe(false); + + expect(handle.operations.slice(before)).toEqual([]); + expect(opRow(opId)?.completion_attempts).toBe(0); + }); + + it('never touches a completed op', async () => { + const { opId } = await prepareSendBtc(); + const completed = await app.inject({ + method: 'POST', + url: '/v1/onchain/send-btc/complete', + headers: headers(), + payload: { opId, signedPsbt: PSBT_A }, + }); + expect(completed.statusCode).toBe(200); + expect(app.completionsWorker.pending()).toEqual([]); + + await app.completionsWorker.runOnce(); + + expect(opRow(opId)?.completion_attempts).toBe(0); + }); + + it('isolates a failing op from the rest of the pass', async () => { + const first = await makeAmbiguous(); + const second = await makeAmbiguous(); + const handle = backend.handleFor(user.userId)!; + // A non-WalletBackendError escaping resumeAmbiguous must not abort the pass; + // start() calls runOnce with `void`, so an escape would kill the process. + let calls = 0; + handle.failWith = undefined; + handle.gate = async () => { + calls += 1; + if (calls === 1) throw new Error('unexpected wallet explosion'); + }; + + await app.completionsWorker.runOnce(); + + expect(calls).toBe(2); + const states = [first, second].map((id) => opRow(id)?.state); + expect(states).toContain('completed'); + expect(states).toContain('pending'); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/db.test.ts b/minimal-sdk/packages/gateway/test/db.test.ts index 53edfbbc..6dbb7385 100644 --- a/minimal-sdk/packages/gateway/test/db.test.ts +++ b/minimal-sdk/packages/gateway/test/db.test.ts @@ -2,13 +2,17 @@ import { describe, expect, it } from 'vitest'; import { migrate, openDb, schemaVersion } from '../src/db.js'; /** Bump alongside a new MIGRATIONS entry in src/db.ts. */ -const LATEST_SCHEMA_VERSION = 2; +const LATEST_SCHEMA_VERSION = 3; describe('db migrations', () => { it('upgrades a v1 database in place, backfilling ln_invoices.expires_at', () => { const db = openDb(':memory:'); - // Simulate a database created before migration v2. + // Simulate a database created before migration v2: every later migration's + // columns have to go, or re-running them fails on the ones openDb added. db.exec('ALTER TABLE ln_invoices DROP COLUMN expires_at'); + db.exec('ALTER TABLE pending_ops DROP COLUMN signed_psbt'); + db.exec('ALTER TABLE pending_ops DROP COLUMN completion_attempts'); + db.exec('ALTER TABLE user_xpubs DROP COLUMN address_index_high_water'); db.pragma('user_version = 1'); db.prepare('INSERT INTO users (id, token_hash, created_at) VALUES (?, ?, ?)').run( 'u_1', @@ -61,6 +65,47 @@ describe('db migrations', () => { .all() .map((row) => (row as { name: string }).name); expect(invoiceColumns).toContain('expires_at'); + // v3: server-side completion recovery and address attribution state. + const opColumns = db + .prepare('PRAGMA table_info(pending_ops)') + .all() + .map((row) => (row as { name: string }).name); + expect(opColumns).toContain('signed_psbt'); + expect(opColumns).toContain('completion_attempts'); + const xpubColumns = db + .prepare('PRAGMA table_info(user_xpubs)') + .all() + .map((row) => (row as { name: string }).name); + expect(xpubColumns).toContain('address_index_high_water'); + db.close(); + }); + + it('upgrades a v2 database in place, adding the completion-recovery columns', () => { + const db = openDb(':memory:'); + db.exec('ALTER TABLE pending_ops DROP COLUMN signed_psbt'); + db.exec('ALTER TABLE pending_ops DROP COLUMN completion_attempts'); + db.exec('ALTER TABLE user_xpubs DROP COLUMN address_index_high_water'); + db.pragma('user_version = 2'); + db.prepare('INSERT INTO users (id, token_hash, created_at) VALUES (?, ?, ?)').run( + 'u_1', + 'hash', + 1, + ); + db.prepare( + `INSERT INTO pending_ops (id, user_id, kind, psbt, intent, state, txid, created_at, expires_at) + VALUES ('op-1', 'u_1', 'send_btc', 'psbt', '{}', 'pending', 'txid-1', 1, 2)`, + ).run(); + + migrate(db); + + expect(schemaVersion(db)).toBe(LATEST_SCHEMA_VERSION); + // An op that was already ambiguous before the upgrade has no retained PSBT, + // so the worker cannot finish it — it stays an operator job, not a crash. + const row = db + .prepare('SELECT signed_psbt, completion_attempts FROM pending_ops WHERE id = ?') + .get('op-1') as { signed_psbt: string | null; completion_attempts: number }; + expect(row.signed_psbt).toBeNull(); + expect(row.completion_attempts).toBe(0); db.close(); }); diff --git a/minimal-sdk/packages/gateway/test/derivation.test.ts b/minimal-sdk/packages/gateway/test/derivation.test.ts new file mode 100644 index 00000000..416e55ff --- /dev/null +++ b/minimal-sdk/packages/gateway/test/derivation.test.ts @@ -0,0 +1,135 @@ +/** + * Address attribution: recovering the derivation an rgb-lib address came from. + * + * The point of the module is to give a client something it can check, so these + * tests derive the expected addresses here with an independent code path + * (@scure/bip32 + p2tr directly) rather than reusing the module's own helper. + * + * The derivation under test mirrors rgb-lib exactly: BIP-86 purpose, vanilla + * coin type (0 mainnet / 1 otherwise), account 0, keychain 0 — see + * `rgb-lib/src/utils.rs` (`PURPOSE`, `get_coin_type`, `KEYCHAIN_BTC`). + */ +import { hex } from '@scure/base'; +import { HDKey } from '@scure/bip32'; +import { p2tr } from '@scure/btc-signer'; +import { describe, expect, it } from 'vitest'; +import { + attributeVanillaAddress, + ATTRIBUTION_WINDOW, + VANILLA_KEYCHAIN, +} from '../src/wallets/derivation.js'; + +/** Real regtest account xpubs (deterministic fixture, Preflight A script). */ +const VANILLA_TPUB = + 'tpubDDfvzhdVV4unsoKt5aE6dcsNsfeWbTgmLZPi8LQDYU2xixrYemMfWJ3BaVneH3u7DBQePdTwhpybaKRU95pi6PMUtLPBJLVQRpzEnjfjZzX'; +const COLORED_TPUB = + 'tpubDCtpoJs6YJcjLnr9gq6jYriYNMuWEu8mSDvEQU5st3ZkJbFqqzwpHUiPvxqD2366ciFAfpehk1k2d7Tyk7AJEr8uZva7KfnX4RpsiVSoEcZ'; + +const TESTNET_VERSIONS = { private: 0x04358394, public: 0x043587cf }; +const MAINNET_VERSIONS = { private: 0x0488ade4, public: 0x0488b21e }; +const HARDENED = 0x80000000; +const REGTEST_ADDRESS = { bech32: 'bcrt', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }; +const TESTNET_ADDRESS = { bech32: 'tb', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }; +const MAINNET_ADDRESS = { bech32: 'bc', pubKeyHash: 0x00, scriptHash: 0x05, wif: 0x80 }; + +/** Independent derivation of `xpub/keychain/index` as a taproot address. */ +function derive( + xpub: string, + keychain: number, + index: number, + network: typeof REGTEST_ADDRESS, +): { address: string; scriptHex: string } { + const key = HDKey.fromExtendedKey(xpub, TESTNET_VERSIONS) + .deriveChild(keychain) + .deriveChild(index); + const payment = p2tr(key.publicKey!.slice(1), undefined, network); + return { address: payment.address as string, scriptHex: hex.encode(payment.script) }; +} + +describe('attributeVanillaAddress', () => { + it('attributes the first revealed address', () => { + const expected = derive(VANILLA_TPUB, VANILLA_KEYCHAIN, 0, REGTEST_ADDRESS); + expect(attributeVanillaAddress(VANILLA_TPUB, 'Regtest', expected.address, 20)).toEqual({ + account: 'vanilla', + keychain: 0, + index: 0, + derivationPath: "m/86'/1'/0'/0/0", + scriptHex: expected.scriptHex, + }); + }); + + it('attributes a later index and reports the path for it', () => { + const expected = derive(VANILLA_TPUB, VANILLA_KEYCHAIN, 13, REGTEST_ADDRESS); + expect(attributeVanillaAddress(VANILLA_TPUB, 'Regtest', expected.address, 20)).toMatchObject({ + index: 13, + derivationPath: "m/86'/1'/0'/0/13", + scriptHex: expected.scriptHex, + }); + }); + + it('uses the mainnet coin type only on mainnet', () => { + // rgb-lib's get_coin_type(network, rgb: false) is 0 on mainnet and 1 for + // every test network, so the reported path must differ by network even + // though the xpub and the derived key do not. + const testnet = derive(VANILLA_TPUB, VANILLA_KEYCHAIN, 0, TESTNET_ADDRESS); + expect(attributeVanillaAddress(VANILLA_TPUB, 'Testnet', testnet.address, 5)).toMatchObject({ + derivationPath: "m/86'/1'/0'/0/0", + }); + expect(attributeVanillaAddress(VANILLA_TPUB, 'Signet', testnet.address, 5)).toMatchObject({ + derivationPath: "m/86'/1'/0'/0/0", + }); + }); + + it('returns null past the scan ceiling rather than guessing', () => { + const expected = derive(VANILLA_TPUB, VANILLA_KEYCHAIN, 30, REGTEST_ADDRESS); + expect(attributeVanillaAddress(VANILLA_TPUB, 'Regtest', expected.address, 10)).toBeNull(); + expect(attributeVanillaAddress(VANILLA_TPUB, 'Regtest', expected.address, 30)).not.toBeNull(); + }); + + it('returns null for an address that is not this wallet at all', () => { + // A colored-account address, a wrong-network encoding of the right key, and + // plain garbage must all be unproven rather than mis-attributed. + const colored = derive(COLORED_TPUB, VANILLA_KEYCHAIN, 0, REGTEST_ADDRESS); + const wrongNetwork = derive(VANILLA_TPUB, VANILLA_KEYCHAIN, 0, TESTNET_ADDRESS); + for (const address of [ + colored.address, + wrongNetwork.address, + 'bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080', + 'not-an-address', + '', + ]) { + expect(attributeVanillaAddress(VANILLA_TPUB, 'Regtest', address, 20)).toBeNull(); + } + }); + + it('returns null instead of throwing on an unusable stored xpub', () => { + // The route was only asked for an address; a malformed stored xpub must + // degrade to "no evidence", not fail the request. + for (const xpub of ['', 'not-an-xpub', VANILLA_TPUB.slice(0, -4)]) { + expect(attributeVanillaAddress(xpub, 'Regtest', 'whatever', 5)).toBeNull(); + } + }); + + it('parses a mainnet-serialized xpub as well as a tpub', () => { + // rgb-lib normalizes a registered xpub's network kind (`str_to_xpub`), so + // both serializations reach the wallet; attribution must not reject a key + // rgb-lib itself accepted. On mainnet the coin type is 0, not 1. + const account = HDKey.fromMasterSeed(new Uint8Array(32).fill(7), MAINNET_VERSIONS) + .deriveChild(HARDENED + 86) + .deriveChild(HARDENED + 0) + .deriveChild(HARDENED + 0); + const xpub = account.publicExtendedKey; + expect(xpub.startsWith('xpub')).toBe(true); + const child = account.deriveChild(VANILLA_KEYCHAIN).deriveChild(0); + const address = p2tr(child.publicKey!.slice(1), undefined, MAINNET_ADDRESS).address as string; + + expect(attributeVanillaAddress(xpub, 'Mainnet', address, 5)).toMatchObject({ + index: 0, + derivationPath: "m/86'/0'/0'/0/0", + }); + }); + + it('exposes a scan window wide enough to be useful', () => { + expect(ATTRIBUTION_WINDOW).toBeGreaterThanOrEqual(100); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/fee-estimate.test.ts b/minimal-sdk/packages/gateway/test/fee-estimate.test.ts new file mode 100644 index 00000000..841f773b --- /dev/null +++ b/minimal-sdk/packages/gateway/test/fee-estimate.test.ts @@ -0,0 +1,314 @@ +/** + * Fee-estimate route and estimator. + * + * Clients cannot reach esplora — it sits behind the gateway with RLN and the RGB + * proxy — so without this route a client either hardcodes a fee rate or sends + * none and takes the gateway's 2 sat/vB default. Two roundings are deliberate + * and tested here: up to a whole sat/vB (never below the estimate the caller + * asked for), and down the esplora target ladder to the nearest published target + * at or below the request (never cheaper than the target asked for). + */ +import type { FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { CACHE_TTL_MS, FeeEstimator } from '../src/fees.js'; +import { HttpError } from '../src/errors.js'; +import { createTestUser, testServer, type TestUser } from './helpers.js'; + +/** The ladder esplora actually publishes, trimmed to the interesting rungs. */ +const LADDER = { + '1': 24.61, + '2': 18.3, + '3': 12.0, + '6': 5.42, + '10': 3.1, + '144': 1.9, + '504': 1.2, + '1008': 1.0, +}; + +function esploraStub( + body: unknown, + init: { status?: number; json?: () => Promise } = {}, +): { fetchImpl: typeof fetch; calls: string[] } { + const calls: string[] = []; + const fetchImpl = (async (input: string | URL) => { + calls.push(String(input)); + return { + ok: (init.status ?? 200) < 400, + status: init.status ?? 200, + json: init.json ?? (async () => body), + } as unknown as Response; + }) as unknown as typeof fetch; + return { fetchImpl, calls }; +} + +describe('FeeEstimator', () => { + it('rounds a fractional rate UP to a whole sat/vB', async () => { + const { fetchImpl } = esploraStub(LADDER); + const estimator = new FeeEstimator({ esploraUrl: 'http://esplora', fetchImpl }); + // 5.42 -> 6, not 5: a client signing at exactly this rate must not land + // below the estimate it asked for. + expect(await estimator.estimate(6)).toEqual({ + blocks: 6, + feeRateSatPerVb: 6, + sourceBlocks: 6, + }); + }); + + it('falls back to the nearest published target at or below the request', async () => { + const { fetchImpl } = esploraStub(LADDER); + const estimator = new FeeEstimator({ esploraUrl: 'http://esplora', fetchImpl }); + // 5 is not published; 3 is, and it is the more urgent (so never cheaper) + // neighbour. Answering with the 6-block rate would under-pay for 5 blocks. + expect(await estimator.estimate(5)).toEqual({ + blocks: 5, + feeRateSatPerVb: 12, + sourceBlocks: 3, + }); + }); + + it('clamps into the range every fee-taking route accepts', async () => { + const { fetchImpl } = esploraStub({ '1': 4000.5, '1008': 0.2 }); + const estimator = new FeeEstimator({ esploraUrl: 'http://esplora', fetchImpl }); + expect((await estimator.estimate(1)).feeRateSatPerVb).toBe(1000); + // 0.2 rounds up to 1, which is also the floor — a 0 would be rejected by + // every route this value is meant to be passed to. + expect((await estimator.estimate(1008)).feeRateSatPerVb).toBe(1); + }); + + it('answers a target below the whole ladder with the fastest published rung', async () => { + const { fetchImpl } = esploraStub({ '144': 1.9, '1008': 1.0 }); + const estimator = new FeeEstimator({ esploraUrl: 'http://esplora', fetchImpl }); + expect(await estimator.estimate(6)).toEqual({ + blocks: 6, + feeRateSatPerVb: 2, + sourceBlocks: 144, + }); + }); + + it('ignores unusable rungs', async () => { + const { fetchImpl } = esploraStub({ + '1': 'not a number', + '2': 0, + '3': -1, + '4': null, + 'not-a-target': 5, + '0': 99, + '2016': 99, + '6': 5.0, + }); + const estimator = new FeeEstimator({ esploraUrl: 'http://esplora', fetchImpl }); + expect(await estimator.estimate(6)).toEqual({ + blocks: 6, + feeRateSatPerVb: 5, + sourceBlocks: 6, + }); + }); + + it('caches for the TTL so a burst of clients is one indexer call', async () => { + const { fetchImpl, calls } = esploraStub(LADDER); + let now = 1_000_000; + const estimator = new FeeEstimator({ + esploraUrl: 'http://esplora/', + fetchImpl, + now: () => now, + }); + await estimator.estimate(6); + await estimator.estimate(1); + expect(calls).toEqual(['http://esplora/fee-estimates']); + now += CACHE_TTL_MS; + await estimator.estimate(6); + expect(calls).toHaveLength(2); + }); + + it('reports an indexer failure as 502, not as a made-up rate', async () => { + for (const stub of [ + esploraStub(undefined, { status: 503 }), + esploraStub([1, 2, 3]), + esploraStub({}), + esploraStub(undefined, { + json: async () => { + throw new Error('malformed body'); + }, + }), + ]) { + const estimator = new FeeEstimator({ + esploraUrl: 'http://esplora', + fetchImpl: stub.fetchImpl, + }); + await expect(estimator.estimate(6)).rejects.toThrow(HttpError); + await expect(estimator.estimate(6)).rejects.toMatchObject({ statusCode: 502 }); + } + }); + + it('does not cache a failure', async () => { + let attempt = 0; + const fetchImpl = (async () => { + attempt += 1; + if (attempt === 1) return { ok: false, status: 503 } as unknown as Response; + return { ok: true, status: 200, json: async () => LADDER } as unknown as Response; + }) as unknown as typeof fetch; + const estimator = new FeeEstimator({ esploraUrl: 'http://esplora', fetchImpl }); + await expect(estimator.estimate(6)).rejects.toThrow(HttpError); + expect((await estimator.estimate(6)).feeRateSatPerVb).toBe(6); + }); + + it('times out rather than holding the caller open on a stalled indexer', async () => { + const fetchImpl = (async (_input: string | URL, init?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('aborted'))); + })) as unknown as typeof fetch; + const estimator = new FeeEstimator({ esploraUrl: 'http://esplora', fetchImpl, timeoutMs: 5 }); + await expect(estimator.estimate(6)).rejects.toMatchObject({ + statusCode: 504, + code: 'UPSTREAM_TIMEOUT', + }); + }); +}); + +describe('GET /v1/onchain/fee-estimate', () => { + let app: FastifyInstance; + let user: TestUser; + + beforeEach(async () => { + const { fetchImpl } = esploraStub(LADDER); + app = await testServer({ esploraFetch: fetchImpl }); + user = await createTestUser(app); + }); + + afterEach(async () => { + await app.close(); + }); + + function authed() { + return { authorization: `Bearer ${user.token}` }; + } + + it('requires authentication', async () => { + const response = await app.inject({ method: 'GET', url: '/v1/onchain/fee-estimate' }); + expect(response.statusCode).toBe(401); + }); + + it('answers a whole sat/vB a client can pass straight back as feeRateSatPerVb', async () => { + const response = await app.inject({ + method: 'GET', + url: '/v1/onchain/fee-estimate?blocks=6', + headers: authed(), + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ blocks: 6, feeRateSatPerVb: 6, sourceBlocks: 6 }); + }); + + it('defaults to a six-block target', async () => { + const response = await app.inject({ + method: 'GET', + url: '/v1/onchain/fee-estimate', + headers: authed(), + }); + expect(response.json()).toEqual({ blocks: 6, feeRateSatPerVb: 6, sourceBlocks: 6 }); + }); + + it('rejects a target outside the esplora ladder', async () => { + for (const blocks of ['0', '1009', 'six', '1.5']) { + const response = await app.inject({ + method: 'GET', + url: `/v1/onchain/fee-estimate?blocks=${blocks}`, + headers: authed(), + }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('BAD_REQUEST'); + } + }); + + it('needs no wallet registration and no Idempotency-Key', async () => { + // A client needs a fee rate BEFORE it can prepare anything, so this must not + // depend on wallet state or on the per-user wallet queue. + const response = await app.inject({ + method: 'GET', + url: '/v1/onchain/fee-estimate?blocks=1', + headers: authed(), + }); + expect(response.statusCode).toBe(200); + expect(response.json().feeRateSatPerVb).toBe(25); + }); + + it('keeps the indexer failure detail server-side', async () => { + const { fetchImpl } = esploraStub(undefined, { status: 503 }); + const failing = await testServer({ esploraFetch: fetchImpl }); + const failingUser = await createTestUser(failing); + const response = await failing.inject({ + method: 'GET', + url: '/v1/onchain/fee-estimate', + headers: { authorization: `Bearer ${failingUser.token}` }, + }); + expect(response.statusCode).toBe(502); + expect(response.json()).toEqual({ + error: { code: 'FEE_ESTIMATE_UNAVAILABLE', message: expect.any(String) }, + }); + expect(response.body).not.toContain('503'); + await failing.close(); + }); +}); + +describe('x-request-id', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = await testServer(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('is present on success and on errors, and is a fresh random id', async () => { + // Client-facing error bodies carry only code and message (I4); the failure + // they were mapped from is logged under this id and nowhere else, so without + // the header a client holding a 502 has nothing an operator can grep for. + const seen = new Set(); + for (const url of ['/v1/health', '/v1/me']) { + const response = await app.inject({ method: 'GET', url }); + const id = response.headers['x-request-id']; + expect(typeof id).toBe('string'); + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + seen.add(id as string); + } + expect(seen.size).toBe(2); + }); + + it('never adopts a caller-supplied id', async () => { + // Otherwise a client could choose the value that ties together every log + // line for its own requests. + const response = await app.inject({ + method: 'GET', + url: '/v1/health', + headers: { 'request-id': 'attacker-chosen', 'x-request-id': 'attacker-chosen' }, + }); + expect(response.headers['x-request-id']).not.toBe('attacker-chosen'); + }); + + it('is set on an idempotency replay too', async () => { + const user = await createTestUser(app); + const headers = { + authorization: `Bearer ${user.token}`, + 'idempotency-key': 'replayed-key', + }; + const payload = { address: 'bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080', amountSat: 1000 }; + const first = await app.inject({ + method: 'POST', + url: '/v1/onchain/send-btc/prepare', + headers, + payload, + }); + const replay = await app.inject({ + method: 'POST', + url: '/v1/onchain/send-btc/prepare', + headers, + payload, + }); + expect(replay.statusCode).toBe(first.statusCode); + expect(replay.headers['x-idempotent-replay']).toBe('true'); + expect(typeof replay.headers['x-request-id']).toBe('string'); + expect(replay.headers['x-request-id']).not.toBe(first.headers['x-request-id']); + }); +}); diff --git a/minimal-sdk/packages/gateway/test/rgblib-mapping.test.ts b/minimal-sdk/packages/gateway/test/rgblib-mapping.test.ts index ced8cf78..1de4f19d 100644 --- a/minimal-sdk/packages/gateway/test/rgblib-mapping.test.ts +++ b/minimal-sdk/packages/gateway/test/rgblib-mapping.test.ts @@ -7,11 +7,17 @@ import { describe, expect, it } from 'vitest'; import { mapAssets, + mapAssignment, mapTransfers, mapUnspents, unquote, wrapNativeError, } from '../src/wallets/rgblib.js'; +import { walletHttpError, WalletBackendError } from '../src/wallets/backend.js'; +import { HttpError } from '../src/errors.js'; + +/** Smallest integer a u64 above the safe range can decode to. */ +const UNSAFE_AMOUNT = Number.MAX_SAFE_INTEGER + 1; describe('wrapNativeError', () => { it.each([ @@ -70,6 +76,33 @@ describe('unquote', () => { }); }); +describe('mapAssignment', () => { + it('flattens externally tagged fungible and unit variants', () => { + expect(mapAssignment({ Fungible: 42 }, 'a')).toEqual({ kind: 'Fungible', amount: 42 }); + expect(mapAssignment('Any', 'a')).toEqual({ kind: 'Any', amount: null }); + }); + + it('carries an unknown variant through by name instead of dropping it', () => { + expect(mapAssignment({ InflationRight: 7 }, 'a')).toEqual({ + kind: 'InflationRight', + amount: 7, + }); + expect(mapAssignment({ NonFungible: { token: 1 } }, 'a')).toEqual({ + kind: 'NonFungible', + amount: null, + }); + }); + + it('rejects a payload that is not a single-key tagged object', () => { + expect(() => mapAssignment({}, 'a')).toThrow(WalletBackendError); + expect(() => mapAssignment({ Fungible: 1, Any: 2 }, 'a')).toThrow(WalletBackendError); + }); + + it('refuses a fungible amount outside the safe integer range', () => { + expect(() => mapAssignment({ Fungible: UNSAFE_AMOUNT }, 'a')).toThrow(WalletBackendError); + }); +}); + describe('mapAssets', () => { it('flattens the per-schema camelCase asset map', () => { const raw = { @@ -78,7 +111,11 @@ describe('mapAssets', () => { assetId: 'rgb:asset-1', ticker: 'TST', name: 'Test Asset', + details: 'a test asset', precision: 0, + issuedSupply: 1000, + timestamp: 1_700_000_000, + addedAt: 1_700_000_100, balance: { settled: 100, future: 100, spendable: 100 }, }, ], @@ -94,12 +131,69 @@ describe('mapAssets', () => { schema: 'nia', ticker: 'TST', name: 'Test Asset', + details: 'a test asset', precision: 0, + issuedSupply: 1000, + timestamp: 1_700_000_000, + addedAt: 1_700_000_100, balance: { settled: 100, future: 100, spendable: 100 }, }, ]); }); + it('leaves issuance metadata null when the schema has none (UDA)', () => { + const [asset] = mapAssets({ + uda: [ + { + assetId: 'rgb:uda-1', + ticker: 'UDA', + name: 'Unique', + precision: 0, + balance: { settled: 1, future: 1, spendable: 1 }, + }, + ], + }); + expect(asset?.issuedSupply).toBeNull(); + expect(asset?.details).toBeNull(); + expect(asset?.timestamp).toBe(0); + }); + + it('refuses an asset with no name rather than emitting an empty one', () => { + // A nameless asset is undisplayable and unvalidatable by a client; the old + // '' substitution hid rgb-lib shape drift behind a usable-looking response. + expect(() => + mapAssets({ + nia: [{ assetId: 'rgb:asset-1', ticker: 'TST', precision: 0 }], + }), + ).toThrow(WalletBackendError); + }); + + it('refuses a balance outside the safe integer range instead of rounding it', () => { + let thrown: unknown; + try { + mapAssets({ + nia: [ + { + assetId: 'rgb:asset-1', + ticker: 'TST', + name: 'Test Asset', + precision: 0, + balance: { settled: UNSAFE_AMOUNT, future: 0, spendable: 0 }, + }, + ], + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(WalletBackendError); + expect((thrown as WalletBackendError).unrepresentable).toBe(true); + // The route answer is an explicit 502, not a silently wrong balance. + const mapped = walletHttpError(thrown); + expect(mapped).toBeInstanceOf(HttpError); + expect((mapped as HttpError).statusCode).toBe(502); + expect((mapped as HttpError).code).toBe('AMOUNT_NOT_REPRESENTABLE'); + }); + it('returns an empty list for a non-object payload', () => { expect(mapAssets(null)).toEqual([]); expect(mapAssets('garbage')).toEqual([]); @@ -115,6 +209,7 @@ describe('mapUnspents', () => { btcAmount: 998, colorable: true, }, + pendingBlinded: 1, rgbAllocations: [ { assetId: 'rgb:asset-1', assignment: { Fungible: 42 }, settled: true }, { assetId: null, assignment: 'Any', settled: false }, @@ -127,14 +222,32 @@ describe('mapUnspents', () => { vout: 2, amountSat: 998, colorable: true, + pendingBlinded: 1, allocations: [ - { assetId: 'rgb:asset-1', amount: 42, settled: true }, - { assetId: null, amount: null, settled: false }, + { + assetId: 'rgb:asset-1', + amount: 42, + assignment: { kind: 'Fungible', amount: 42 }, + settled: true, + }, + { + assetId: null, + amount: null, + assignment: { kind: 'Any', amount: null }, + settled: false, + }, ], }, ]); }); + it('defaults a missing pendingBlinded to zero', () => { + const [unspent] = mapUnspents([ + { utxo: { outpoint: { txid: 't', vout: 0 }, btcAmount: 1, colorable: false } }, + ]); + expect(unspent?.pendingBlinded).toBe(0); + }); + it('returns an empty list for a non-array payload', () => { expect(mapUnspents({ not: 'an array' })).toEqual([]); }); @@ -145,6 +258,7 @@ describe('mapTransfers', () => { const raw = [ { idx: 1, + batchTransferIdx: 11, requestedAssignment: { Fungible: 50 }, assignments: [], kind: 'ReceiveBlind', @@ -157,6 +271,7 @@ describe('mapTransfers', () => { }, { idx: 2, + batchTransferIdx: 12, requestedAssignment: null, assignments: [{ Fungible: 10 }, { Fungible: 15 }, 'Any'], kind: 'Send', @@ -172,8 +287,10 @@ describe('mapTransfers', () => { expect(mapped).toEqual([ { idx: 1, + batchTransferIdx: 11, assetId: 'rgb:asset-1', amount: 50, + assignments: [], kind: 'ReceiveBlind', status: 'Settled', txid: 'txid-1', @@ -184,8 +301,14 @@ describe('mapTransfers', () => { }, { idx: 2, + batchTransferIdx: 12, assetId: 'rgb:asset-1', amount: 25, + assignments: [ + { kind: 'Fungible', amount: 10 }, + { kind: 'Fungible', amount: 15 }, + { kind: 'Any', amount: null }, + ], kind: 'Send', status: 'WaitingConfirmations', txid: null, @@ -197,13 +320,21 @@ describe('mapTransfers', () => { ]); }); + it('refuses a transfer amount outside the safe integer range', () => { + expect(() => + mapTransfers([{ idx: 1, requestedAssignment: { Fungible: UNSAFE_AMOUNT } }], null), + ).toThrow(WalletBackendError); + }); + it('defaults missing fields instead of throwing on shape drift', () => { const mapped = mapTransfers([{}], null); expect(mapped).toEqual([ { idx: 0, + batchTransferIdx: null, assetId: null, amount: null, + assignments: [], kind: 'Unknown', status: 'Unknown', txid: null, diff --git a/minimal-sdk/packages/gateway/test/wallet-mocks.ts b/minimal-sdk/packages/gateway/test/wallet-mocks.ts index 4c60c8a2..0c77fe17 100644 --- a/minimal-sdk/packages/gateway/test/wallet-mocks.ts +++ b/minimal-sdk/packages/gateway/test/wallet-mocks.ts @@ -89,6 +89,7 @@ export class MockWalletHandle implements WalletHandle { invoice: `mock-invoice-${this.identity.userId}-${this.receiveCount}`, recipientId: `mock-recipient-${this.identity.userId}-${this.receiveCount}`, expirationTimestamp: request.expirationTimestamp, + batchTransferIdx: this.receiveCount, }; }); } diff --git a/minimal-sdk/packages/gateway/test/wallet-routes.test.ts b/minimal-sdk/packages/gateway/test/wallet-routes.test.ts index b15b8def..34d1c237 100644 --- a/minimal-sdk/packages/gateway/test/wallet-routes.test.ts +++ b/minimal-sdk/packages/gateway/test/wallet-routes.test.ts @@ -1,4 +1,7 @@ import type { FastifyInstance } from 'fastify'; +import { hex } from '@scure/base'; +import { HDKey } from '@scure/bip32'; +import { p2tr } from '@scure/btc-signer'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WalletBackendError } from '../src/wallets/backend.js'; import { createTestUser, testServer, type TestUser } from './helpers.js'; @@ -13,6 +16,26 @@ const XPUBS = { fingerprint: '73c5da0a', }; +/** + * Re-derive the fixture wallet's vanilla addresses the way a client would, so + * the attribution the route reports is checked against an independent + * derivation rather than against the gateway's own helper. + */ +function deriveVanilla(index: number): { address: string; scriptHex: string } { + const account = HDKey.fromExtendedKey(XPUBS.vanilla, { + private: 0x04358394, + public: 0x043587cf, + }); + const key = account.deriveChild(0).deriveChild(index); + const payment = p2tr(key.publicKey!.slice(1), undefined, { + bech32: 'bcrt', + pubKeyHash: 0x6f, + scriptHash: 0xc4, + wif: 0xef, + }); + return { address: payment.address as string, scriptHex: hex.encode(payment.script) }; +} + describe('wallet routes', () => { let app: FastifyInstance; let backend: MockWalletBackend; @@ -185,7 +208,54 @@ describe('wallet routes', () => { headers: authed(), }); expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ address: `addr-${user.userId}` }); + // The mock hands out a synthetic address, so it cannot be attributed to a + // derivation under the registered xpub: null evidence, not a guess. + expect(response.json()).toEqual({ address: `addr-${user.userId}`, derivation: null }); + }); + + it('attributes a real rgb-lib address to its vanilla derivation', async () => { + // Derived from the fixture xpub at m/86'/1'/0'/0/0 — the first address + // rgb-lib reveals on the vanilla keychain for these xpubs. + const derived = deriveVanilla(0); + backend.dataFor = () => ({ address: derived.address }); + await register(); + const response = await app.inject({ + method: 'GET', + url: '/v1/wallet/address', + headers: authed(), + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + address: derived.address, + derivation: { + account: 'vanilla', + keychain: 0, + index: 0, + derivationPath: "m/86'/1'/0'/0/0", + scriptHex: derived.scriptHex, + }, + }); + }); + + it('attributes an address past the first index and advances the scan window', async () => { + const derived = deriveVanilla(7); + backend.dataFor = () => ({ address: derived.address }); + await register(); + const response = await app.inject({ + method: 'GET', + url: '/v1/wallet/address', + headers: authed(), + }); + expect(response.json().derivation).toMatchObject({ + index: 7, + derivationPath: "m/86'/1'/0'/0/7", + }); + // High-water mark persisted, so attribution keeps working as the wallet's + // revealed index grows instead of falling off a fixed window. + const row = app.db + .prepare('SELECT address_index_high_water AS h FROM user_xpubs WHERE user_id = ?') + .get(user.userId) as { h: number }; + expect(row.h).toBe(7); }); it('reports balances from the user wallet (BTC + per-asset)', async () => { @@ -200,7 +270,11 @@ describe('wallet routes', () => { schema: 'nia', ticker: 'TST', name: 'Test', + details: null, precision: 0, + issuedSupply: 1000, + timestamp: 1_700_000_000, + addedAt: 1_700_000_100, balance: { settled: 100, future: 100, spendable: 100 }, }, ], @@ -220,7 +294,11 @@ describe('wallet routes', () => { schema: 'nia', ticker: 'TST', name: 'Test', + details: null, precision: 0, + issuedSupply: 1000, + timestamp: 1_700_000_000, + addedAt: 1_700_000_100, balance: { settled: 100, future: 100, spendable: 100 }, }, ]); @@ -234,7 +312,15 @@ describe('wallet routes', () => { vout: 1, amountSat: 9999, colorable: true, - allocations: [{ assetId: 'rgb:asset-1', amount: 42, settled: true }], + pendingBlinded: 2, + allocations: [ + { + assetId: 'rgb:asset-1', + amount: 42, + assignment: { kind: 'Fungible', amount: 42 }, + settled: true, + }, + ], }, ], }); @@ -245,14 +331,32 @@ describe('wallet routes', () => { headers: authed(), }); expect(response.statusCode).toBe(200); - expect(response.json().unspents[0]).toMatchObject({ vout: 1, amountSat: 9999 }); + // pendingBlinded and the full assignment survive serialization: a client + // counting free allocation slots needs both. + expect(response.json().unspents[0]).toEqual({ + txid: 'ab'.repeat(32), + vout: 1, + amountSat: 9999, + colorable: true, + pendingBlinded: 2, + allocations: [ + { + assetId: 'rgb:asset-1', + amount: 42, + assignment: { kind: 'Fungible', amount: 42 }, + settled: true, + }, + ], + }); }); it('aggregates transfers across assets, and filters by assetId when asked', async () => { const transfer = (idx: number, assetId: string | null) => ({ idx, + batchTransferIdx: idx + 100, assetId, amount: 10, + assignments: [{ kind: 'Fungible', amount: 10 }], kind: 'ReceiveBlind', status: 'Settled', txid: null, @@ -268,7 +372,11 @@ describe('wallet routes', () => { schema: 'nia', ticker: null, name: 'A1', + details: null, precision: 0, + issuedSupply: null, + timestamp: 0, + addedAt: 0, balance: EMPTY_BALANCE, }, ], diff --git a/minimal-sdk/pnpm-lock.yaml b/minimal-sdk/pnpm-lock.yaml index b3f81956..52f99820 100644 --- a/minimal-sdk/pnpm-lock.yaml +++ b/minimal-sdk/pnpm-lock.yaml @@ -88,6 +88,9 @@ importers: '@scure/base': specifier: ^2.4.0 version: 2.4.0 + '@scure/bip32': + specifier: ^2.4.0 + version: 2.4.0 '@scure/btc-signer': specifier: ^2.4.1 version: 2.4.1 @@ -101,9 +104,6 @@ importers: specifier: ^5.6.0 version: 5.12.3 devDependencies: - '@scure/bip32': - specifier: ^2.4.0 - version: 2.4.0 '@scure/bip39': specifier: ^2.4.0 version: 2.4.0 diff --git a/minimal-sdk/rust-sdk/.gitignore b/minimal-sdk/rust-sdk/.gitignore deleted file mode 100644 index ea8c4bf7..00000000 --- a/minimal-sdk/rust-sdk/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target diff --git a/minimal-sdk/rust-sdk/Cargo.lock b/minimal-sdk/rust-sdk/Cargo.lock deleted file mode 100644 index 42e33ec7..00000000 --- a/minimal-sdk/rust-sdk/Cargo.lock +++ /dev/null @@ -1,558 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "base58ck" -version = "0.1.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "365c0acd5b2e8dd0111a46c4faea83fb3cfb6e39a49a7c73a06e090db7b2eff0" -dependencies = [ - "bitcoin_hashes", -] - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "bech32" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" - -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[package]] -name = "bip39" -version = "2.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" -dependencies = [ - "bitcoin_hashes", - "rand", - "rand_core", - "serde", - "unicode-normalization", -] - -[[package]] -name = "bitcoin" -version = "0.32.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0ce8bd5baaa0d303a19915a6d93afed161f528654e42da2a7a97d05c59499a" -dependencies = [ - "base58ck", - "base64", - "bech32", - "bitcoin-io", - "bitcoin-units", - "bitcoin_hashes", - "hex-conservative 0.2.3", - "hex_lit", - "secp256k1", -] - -[[package]] -name = "bitcoin-consensus-encoding" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" -dependencies = [ - "bitcoin-internals", - "hex-conservative 1.3.0", - "serde", -] - -[[package]] -name = "bitcoin-internals" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" - -[[package]] -name = "bitcoin-io" -version = "0.1.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" -dependencies = [ - "bitcoin-consensus-encoding", -] - -[[package]] -name = "bitcoin-units" -version = "0.1.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cb95693f371d089a4b5b6fc41c6f3ea6e01ee8c15388335dfac8ea685173b51" -dependencies = [ - "bitcoin-consensus-encoding", -] - -[[package]] -name = "bitcoin_hashes" -version = "0.14.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" -dependencies = [ - "bitcoin-io", - "hex-conservative 0.2.3", -] - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "camino" -version = "1.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" - -[[package]] -name = "cc" -version = "1.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "find-msvc-tools" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" - -[[package]] -name = "fs-err" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" -dependencies = [ - "autocfg", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "hex-conservative" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "hex-conservative" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "hex_lit" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] - -[[package]] -name = "secp256k1" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" -dependencies = [ - "bitcoin_hashes", - "rand", - "secp256k1-sys", -] - -[[package]] -name = "secp256k1-sys" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" -dependencies = [ - "cc", -] - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "tinyvec" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" - -[[package]] -name = "toml" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" -dependencies = [ - "serde", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "uniffi" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cb08c58c7ed7033150132febe696bef553f891b1ede57424b40d87a89e3c170" -dependencies = [ - "anyhow", - "uniffi_core", - "uniffi_macros", -] - -[[package]] -name = "uniffi_checksum_derive" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "802d2051a700e3ec894c79f80d2705b69d85844dafbbe5d1a92776f8f48b563a" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "uniffi_core" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7687007d2546c454d8ae609b105daceb88175477dac280707ad6d95bcd6f1f" -dependencies = [ - "anyhow", - "bytes", - "log", - "once_cell", - "paste", - "static_assertions", -] - -[[package]] -name = "uniffi_macros" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12c65a5b12ec544ef136693af8759fb9d11aefce740fb76916721e876639033b" -dependencies = [ - "bincode", - "camino", - "fs-err", - "once_cell", - "proc-macro2", - "quote", - "serde", - "syn 2.0.119", - "toml", - "uniffi_meta", -] - -[[package]] -name = "uniffi_meta" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a74ed96c26882dac1ca9b93ca23c827e284bacbd7ec23c6f0b0372f747d59e4" -dependencies = [ - "anyhow", - "bytes", - "siphasher", - "uniffi_checksum_derive", -] - -[[package]] -name = "utexo-minimal-sdk" -version = "0.1.0" -dependencies = [ - "bip39", - "bitcoin", - "serde_json", - "thiserror", - "uniffi", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "zerocopy" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/minimal-sdk/rust-sdk/Cargo.toml b/minimal-sdk/rust-sdk/Cargo.toml deleted file mode 100644 index ff032bd7..00000000 --- a/minimal-sdk/rust-sdk/Cargo.toml +++ /dev/null @@ -1,46 +0,0 @@ -[package] -name = "utexo-minimal-sdk" -version = "0.1.0" -edition = "2021" -rust-version = "1.94.0" -publish = false -description = "Minimal UTEXO client SDK core: keys, taproot derivation, verify-before-sign, invoices, gateway client. One Rust implementation exposed to Kotlin and Swift through uniffi." -license = "MIT" - -[lib] -name = "utexo_minimal_sdk" -# cdylib: Android JNI library. staticlib: iOS XCFramework. lib: Rust tests and -# consumers (the uniffi-bindgen tool links nothing, it reads metadata symbols). -crate-type = ["cdylib", "staticlib", "lib"] - -[dependencies] -# The whole allowed dependency set. No rgb-lib, no HTTP or TLS stack, no -# async runtime. Every addition is measured against the size gate in -# tests/parity.rs before it lands (docs/plans/20260915-minimal-mobile-rust-sdk.md). -# `secp-recovery` is a feature of the secp256k1 dependency bitcoin already -# carries (BOLT-11 payee recovery, Task 6), not a new crate. -bitcoin = { version = "0.32", default-features = false, features = ["std", "rand-std", "base64", "secp-recovery"] } -bip39 = { version = "2.2", default-features = false, features = ["std", "rand"] } -thiserror = "2.0" -# default-features = false drops `cargo-metadata` (a bindgen-side helper that -# would pull serde_json/semver into the shipped library graph). -uniffi = { version = "0.28.3", default-features = false } - -[dev-dependencies] -# Test-only: parses the rgb-lib parity fixture. Must never become a normal -# dependency; tests/parity.rs asserts it is absent from the library graph. -serde_json = "1.0" - -[profile.release] -opt-level = "z" -lto = "thin" -codegen-units = 1 -strip = "symbols" -# uniffi converts a Rust panic into a foreign exception at the FFI boundary. -# `panic = "abort"` would turn every recoverable error into a host-app crash -# (design doc, Block 2 item 9). -panic = "unwind" - -# Own workspace: never pull the rgb-lightning-node dependency graph -# (same pattern as bindings/c-ffi/Cargo.toml). -[workspace] diff --git a/minimal-sdk/rust-sdk/README.md b/minimal-sdk/rust-sdk/README.md deleted file mode 100644 index a9e87951..00000000 --- a/minimal-sdk/rust-sdk/README.md +++ /dev/null @@ -1,381 +0,0 @@ -# utexo-minimal-sdk — the Rust core of the minimal mobile SDK - -One Rust implementation of everything that guards user funds in the Minimal SDK -variant — key management, taproot derivation, verify-before-sign, signing, invoice -decoding and the gateway client — exposed to **Kotlin** ([`../android`](../android)) -and **Swift** ([`../apple`](../apple)) through uniffi. Plan and rationale: -`docs/plans/20260915-minimal-mobile-rust-sdk.md`; architecture and size argument: -`docs/design/mobile-sdk-and-lightweight-uniffi.md`; trust model and invariants I1–I4: -`docs/design/minimal-sdk-and-lightweight-rln.md`. - -**This is not the node.** No LDK, no rgb-lib, no axum, no sea-orm, no chain backend, -no HTTP or TLS stack, no async runtime. The library graph is `bitcoin`, `bip39`, -`thiserror` and `uniffi`; `tests/parity.rs` fails the build if anything banned -(`rgb-lib`, `reqwest`, `tokio`, `rustls`, `openssl`) ever resolves. - -**The TypeScript client SDK** (`../packages/client-sdk`) is the behavioural reference -this crate was ported from and stays shipped. It is the independent cross-check on this -implementation, not legacy: both read the same rgb-lib-authored parity fixture and share -no code. - -**The mnemonic is the user's only backup.** Keys are generated and stay on the device -(invariant I1); the gateway only ever sees the two account xpubs and the master -fingerprint (I2). Nothing in this crate logs, `Debug`-prints or renders a mnemonic, -seed, xprv or private key, including error variants (I3) — the tests assert it. - -## Layout - -| Module | What it holds | TS reference | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | -| `network.rs` | the four networks, BIP-32 version bytes, rgb-lib coin types, account paths `m/86'/coin'/0'` | `network.ts` | -| `keys.rs` | `ClientKeys`: BIP-39 → BIP-32 vanilla + colored accounts; xpubs and fingerprint are the only public material | `keys.ts` | -| `derive.rs` | taproot `tr(key)` derivation and `match_origin_path`, the single source of truth for path acceptance | `derive.ts` | -| `verify.rs` | the five checks, `own_derivation` (fingerprint-based key-origin selection) | `verify.ts` | -| `sign.rs` | `verify_and_sign_psbt`, the only signing entry point; BIP-341 key-path sign + finalize | `sign.ts` | -| `invoice.rs` | BOLT-11 and RGB invoice decoding | `invoice.ts` | -| `gateway.rs` | `GatewayClient`, the `HttpTransport` foreign trait, intent binding | `gateway.ts` | -| `ffi.rs` | the exported free functions (`sdk_version`, `generate_keys`, `derive_taproot_address`, `verify_psbt`, `verify_and_sign_psbt`, `decode_bolt11`, `decode_rgb_invoice`) | — | - -Bindings are generated in uniffi **library mode** from the built library; there is no -UDL file and none may be added (`tests/parity.rs::no_udl_file_exists`). - -## Quickstart - -The journey is the same on both platforms: generate keys → register xpubs → prepare → -verify → sign → complete. Every call below is **blocking** and must run off the UI -thread (see "Mobile runtime constraints"). - -### Kotlin - -```kotlin -import com.utexo.minimalsdk.* - -// 1. Generate keys on the device. Show `mnemonic` once for backup; it never leaves -// the phone. Restore later with ClientKeys.fromMnemonic(mnemonic, network). -val generated = generateKeys(BitcoinNetwork.MAINNET) -val keys = generated.keys - -// 2. Connect. The bearer token comes from the operator-run user bootstrap -// (POST /v1/users) and is returned exactly once. The transport is yours (below). -val client = GatewayClient("https://gateway.example.com", token, null, OkHttpTransport()) - -// 3. Register the account xpubs; the gateway builds a per-user watch-only wallet -// from them and returns the first vault address. Public material only. -val xpubs = keys.xpubs() -val registered = client.registerXpubs( - RegisterXpubsParams(vanilla = xpubs.vanilla, colored = xpubs.colored, fingerprint = xpubs.fingerprint), -) - -// 4. Send BTC: the server prepares, the CLIENT verifies and signs, the server -// broadcasts. prepareSendBtc binds the returned intent to the request you made -// (SdkException.IntentMismatch otherwise); verifyAndSignPsbt refuses to sign -// unless all five checks pass (SdkException.VerificationFailed names the check). -// Each phase takes its OWN key: the gateway hashes method + path + body, so -// one key reused across prepare and complete is 409 IDEMPOTENCY_KEY_REUSED. -val prepared = client.prepareSendBtc( - PrepareSendBtcParams(address = "bc1p…", amountSat = 50_000UL, feeRateSatPerVb = 2UL), - generateIdempotencyKey(), -) -val signed = verifyAndSignPsbt( - keys, - prepared.psbt, - VerifyParams(intent = prepared.intent, xpubs = xpubs, maxFeeSat = 1_000UL), // user-approved fee budget -) -val txid = client.completeSendBtc( - CompleteParams(opId = prepared.opId, signedPsbt = signed.signedPsbt), - generateIdempotencyKey(), -).txid - -// 5. If that complete is lost — timeout, crash, or 502 COMPLETE_AMBIGUOUS — do -// NOT prepare a replacement. Read the operation back; mayHaveBroadcast true -// means the transaction may already be on the network, so retry complete. -val status = client.getOnchainOperation(prepared.opId) -if (status.mayHaveBroadcast) { /* retry completeSendBtc with a fresh key */ } -``` - -A transport over `HttpURLConnection` (OkHttp works the same way; only `send` exists): - -```kotlin -class OkHttpTransport : HttpTransport { - override fun send(request: HttpRequest): HttpResponse { - val conn = (java.net.URL(request.url).openConnection() as java.net.HttpURLConnection).apply { - requestMethod = if (request.method == HttpMethod.GET) "GET" else "POST" - connectTimeout = request.timeoutMs.toInt() - readTimeout = request.timeoutMs.toInt() - request.headers.forEach { (name, value) -> setRequestProperty(name, value) } - } - try { - request.body?.let { body -> conn.doOutput = true; conn.outputStream.use { it.write(body.toByteArray()) } } - val status = conn.responseCode - val stream = if (status < 400) conn.inputStream else conn.errorStream - val body = stream?.bufferedReader()?.use { it.readText() } ?: "" - return HttpResponse(status.toUShort(), body) - } catch (e: java.io.IOException) { - throw SdkException.Transport(e.message ?: "io error") // never include headers: they carry the token - } finally { - conn.disconnect() - } - } -} -``` - -### Swift - -```swift -import MinimalSdk - -// 1. Generate keys on the device. Show `mnemonic` once for backup; it never leaves -// the phone. Restore later with ClientKeys.fromMnemonic(mnemonic:network:). -let generated = try generateKeys(network: .mainnet) -let keys = generated.keys - -// 2. Connect. URLSessionHttpTransport ships with the package; any HttpTransport -// conformance can replace it. -let client = GatewayClient(baseUrl: "https://gateway.example.com", token: token, - timeoutMs: nil, transport: URLSessionHttpTransport()) - -// 3. Register the account xpubs (public material only). -let xpubs = keys.xpubs() -let registered = try client.registerXpubs( - params: RegisterXpubsParams(vanilla: xpubs.vanilla, colored: xpubs.colored, fingerprint: xpubs.fingerprint)) - -// 4. Send BTC: prepare (intent bound to the request, SdkError.IntentMismatch -// otherwise) → verify and sign (SdkError.VerificationFailed names the failed -// check; nothing is signed) → complete. -// Each phase takes its OWN key: the gateway hashes method + path + body, so -// one key reused across prepare and complete is 409 IDEMPOTENCY_KEY_REUSED. -let prepared = try client.prepareSendBtc( - params: PrepareSendBtcParams(address: "bc1p…", amountSat: 50_000, feeRateSatPerVb: 2), - idempotencyKey: try generateIdempotencyKey()) -let signed = try verifyAndSignPsbt( - keys: keys, psbt: prepared.psbt, - params: VerifyParams(intent: prepared.intent, xpubs: xpubs, maxFeeSat: 1_000, // user-approved fee budget - changeScanWindow: nil, maxOwnOutputIndex: nil)) -let txid = try client.completeSendBtc( - params: CompleteParams(opId: prepared.opId, signedPsbt: signed.signedPsbt), - idempotencyKey: try generateIdempotencyKey()).txid - -// 5. If that complete is lost — timeout, crash, or 502 COMPLETE_AMBIGUOUS — do -// NOT prepare a replacement. Read the operation back; mayHaveBroadcast true -// means the transaction may already be on the network, so retry complete. -let status = try client.getOnchainOperation(opId: prepared.opId) -if status.mayHaveBroadcast { /* retry completeSendBtc with a fresh key */ } -``` - -Asset sends (`prepareSendAsset` / `completeSendAsset`) and colorable-UTXO creation -(`prepareCreateUtxos` / `completeCreateUtxos`) follow the same prepare → verify-and-sign -→ complete shape. Receiving (`receive`), balances, unspents, transfers, LN float -deposits, invoices, payments and withdrawals round out `GatewayClient`; -`decodeBolt11` / `decodeRgbInvoice` decode invoices locally for display before paying. -Money-moving calls take an idempotency key: retry the **same** call with the **same** -key to get the cached response instead of a duplicate spend. The key is scoped to one -request — the gateway hashes method, path and body — so `prepare` and `complete` each -need their own; reusing one across both is `409 IDEMPOTENCY_KEY_REUSED`. - -`getOnchainOperation(opId)` reads a prepared operation's durable state and is the -recovery path when a `complete` result is lost. It is a read: no idempotency key, and the -gateway does not queue it behind that user's wallet work, so it answers even while the -completion it asks about is still running. `mayHaveBroadcast == true` means the wallet -failed _after_ rgb-lib may already have broadcast — retry `complete` to finish the -bookkeeping rather than sending again. It stays true on an expired operation, because -expiry does not un-broadcast a transaction. - -## The verify-before-sign contract - -Signing is never blind. `verify_and_sign_psbt` is the only signing entry point; it runs -`verify_psbt` internally and refuses on any failure, before a single input is signed. -`verify_psbt` can also be called on its own to show the user what they are about to -approve. It never errors and never panics on hostile input: a malformed PSBT is a -verdict with all five checks failed, not a crash. The five checks, in evaluation order -and with the same wire names as the TypeScript SDK: - -1. **`inputs-own` — every input is ours.** The input's key-origin entry is selected **by - our master fingerprint, never by path shape**, and its path must re-derive from _our_ - xpubs to the exact script being spent. Inputs are accepted on either account and at - any index: this is what a hostile PSBT can least fake. -2. **`recipients-match` — recipient outputs match the user-stated intent.** Script and - amount, matched as a multiset. The expected script is **re-derived from the - human-readable address** the user reviews; the server's `script_hex` is only - cross-checked against it, otherwise a hostile gateway could pair the intended address - with an attacker script and both would "match". Witness asset sends additionally - require exactly one foreign output carrying exactly the approved witness amount. -3. **`change-own` — change pays only us, at a recoverable path.** Every output that is - not an intended recipient, the approved witness output or an OP_RETURN must re-derive - from our vanilla or colored account at **keychain 0 and index ≤ `max_own_output_index`** - (default 10 000). Any index under the account xpub is technically the user's, but only - indexes a descriptor wallet scans are recoverable, so a hostile gateway must not be - able to park "change" at index 10⁸. Outputs with no key-origin metadata are accepted - only if they match a script within the first `change_scan_window` indexes (default 30; - 0 requires metadata on every output). The input/output asymmetry (inputs: any path; - outputs: keychain 0, bounded) is deliberate. -4. **`fee-budget` — inputs − outputs is positive and at most `max_fee_sat`**, the fee - budget the user approved. -5. **`opret-zero` — OP_RETURN outputs carry 0 sats.** The RGB opret commitment may exist - but may not carry value. - -**Intent binding is what makes check 2 non-vacuous.** The intent is server-produced; on -its own it proves nothing against a hostile gateway, which can put an attacker output in -the PSBT and the same attacker output in the intent. `prepare_send_btc`, -`prepare_send_asset` and `prepare_create_utxos` therefore compare the returned intent -field by field against the request you made (kind, recipient address and amount, asset -id, amount and recipient id, witness amount, recipient count, no smuggled asset or UTXO -shape, and any optional you pinned such as the fee rate) and return `IntentMismatch` -listing every divergence before the intent can reach `verify_psbt`. Build an intent some -other way and that binding is yours to make. - -**Decoys.** A PSBT may carry several key-origin entries per input. Verify selects the -entry by our fingerprint, and sign signs with the very entry verify proved, so a decoy -entry with a foreign fingerprint ordered first cannot divert signing to a key no check -covered. `match_origin_path` is the single path-acceptance rule shared by verify and by -`private_key_for_path`, so the two cannot drift apart. - -**Stated limitation.** The checks verify all _bitcoin-value_ movement. RGB allocations -on the spent inputs are **not verifiable client-side**: the opret commitment is opaque -here, so every server-prepared **colored** spend trusts the gateway for the RGB state -transition at send time (design doc, "send-time trust"; `verify.ts:11-13`). The -mitigations are the gateway's, not this crate's. - -## The `HttpTransport` extension point - -Rust performs no network IO. `GatewayClient` builds every request (method, absolute -percent-encoded URL, lowercase headers including `authorization`, `idempotency-key` and -`content-type`, optional UTF-8 JSON body, timeout) and parses every response; the host -app moves the bytes through one foreign-implemented method: - -```rust -#[uniffi::export(with_foreign)] -pub trait HttpTransport: Send + Sync { - fn send(&self, request: HttpRequest) -> Result; -} -``` - -- Return `HttpResponse { status, body }` for **any** HTTP status, 4xx and 5xx included; - Rust turns those into `SdkError::Gateway { status, code, detail }`. -- Return `SdkError::Transport { reason }` only when no HTTP status exists (DNS, TLS, - timeout). Never put request headers in the reason: they carry the bearer token. -- Anything else the transport throws (a Kotlin `RuntimeException`, a Swift `Error` that is - not `SdkError`) is mapped by Rust to `SdkError::Transport` with uniffi's diagnostic as - the reason. It never surfaces as an internal error or a crash; the smoke tests on both - bindings throw a foreign exception through Rust to prove it. -- The call is synchronous from Rust's side, so there is no async-over-FFI; the transport - may block. Kotlin: OkHttp or `HttpURLConnection`. Swift: the shipped - `URLSessionHttpTransport` (ephemeral session, blocks on a semaphore bounded by - `timeout_ms`). -- Rust never renders the bearer token into an error, and its `Debug` for `HttpRequest` - and `CreatedUser` redacts it (asserted in `tests/parity.rs`; the binding smoke tests - assert every error is token-free). That redaction stops at the FFI: over uniffi - `HttpRequest` is a plain Kotlin `data class` / Swift `struct` whose default `toString()` - prints the headers verbatim, so a transport must never log the request it receives. - -## Mobile runtime constraints (design doc Block 3) and this SDK's answer - -The design doc lists four constraints for a node on the phone. Being explicit about -which apply here is what makes the minimal variant's case. - -- **Process death is normal.** _Applies, cheaply._ This is a **stateless** client: it - holds no channel state, no database, no background task. Process death costs nothing - beyond an in-flight request. Hold the mnemonic in the platform keystore and re-create - `ClientKeys` with `fromMnemonic` on restore; `GatewayClient` is re-created for free. - A prepared-but-unsigned operation simply expires on the gateway (`PreparedOp.expires_at`). -- **Every binding call is blocking.** _Applies, and is stated plainly:_ every uniffi call - runs on the calling thread. `verify_and_sign_psbt` is milliseconds of secp256k1 work; - every `GatewayClient` call blocks for the whole HTTP round trip inside your transport. - **Never call the SDK on the Android main thread** (an ANR, exactly as the design doc - flags for the node SDK — it applies to any uniffi surface) **or on the iOS main queue.** - Use a coroutine on `Dispatchers.IO`, a `Task.detached`, or a background queue. -- **The fail-fast watchdog.** _Does not apply._ There is no node, no event loop and no - `process::exit`. The release profile keeps `panic = "unwind"` so uniffi converts any - Rust panic into a foreign exception; every exported function returns a typed error and - `verify_psbt` returns a failed verdict on hostile input. -- **Battery, data and chain backends.** _Does not apply._ No chain sync, no indexer - polling, no peer connections. The only network traffic is the HTTPS calls the app makes - through its own transport, when the user acts. - -## Measured sizes - -Release profile `opt-level = "z"`, thin LTO, `codegen-units = 1`, `strip = "symbols"`, -measured 2026-09-15; host cdylib re-measured 2026-09-18. - -| Artifact | Raw | gzip -9 | Note | -| ------------------------------------------- | ------------------------: | ----------: | --------------------------------------------------------------------------------------- | -| Host `x86_64-unknown-linux-gnu` cdylib | 2 502 000 B | 1 685 630 B | size gate in `tests/parity.rs`: < 2 600 000 B | -| Android `arm64-v8a` `.so` (NDK r29) | 2 366 720 B | 1 689 588 B | gate in `../scripts/build_android.sh`: < 3 000 000 B; `.text` 640 KB, `.rodata` 1.30 MB | -| Android `armeabi-v7a` / `x86_64` `.so` | 1 992 820 B / 2 508 984 B | — | | -| Android AAR (3 ABIs + 514 KB `classes.jar`) | 5 449 477 B | — | a device installs one slice | -| JNA 5.14.0 AAR (consumer-side dependency) | 517 894 B | — | uniffi's Kotlin bindings need it | -| iOS | not an artifact number | | see below | - -About 1.06 MB of every slice is libsecp256k1's precomputed tables, which every secp256k1 -binding (Rust or Kotlin) carries. The plan's scratch measurement (derive/PSBT/sign only) -was 1.94 MB; the finished crate adds the gateway client, the JSON codec and both invoice -decoders for the difference. - -- **Raw vs compressed.** Android install size tracks the **raw** number: APKs and App - Bundles store native libraries uncompressed (`extractNativeLibs=false`, default since - API 23) so they can be page-mapped. The gzip column is what a download costs, not - what the device keeps. What an arm64 device installs is one slice plus JNA, - **≈ 2.88 MB**, against ≈ 1.9 MB for a pure-Kotlin SDK that avoids JNA. -- **iOS: measure the app-size delta, not the archive.** iOS links statically and the app - linker dead-strips the archive, so the `.a` size is not what the user downloads. The - number that matters is the delta in the app's installed size with and without the SDK - linked, on a device build with App Thinning; expect the Android arm64 slice as an - upper bound, less after dead-stripping. `build_apple.sh` prints archive sizes and has - no gate by design. -- **16 KB page alignment.** Every `PT_LOAD` segment of every Android `.so` is aligned to - 16 384 bytes (measured `0x4000` on all three ABIs). NDK r28+ aligns the 64-bit ABIs by - default but leaves `armeabi-v7a` at 4 096, so `build_android.sh` passes - `-Wl,-z,max-page-size=16384` to every target and fails the build on any misaligned - segment. Required for native code targeting Android 15+. - -## Building and testing - -```sh -# Rust: the real gate — 99 tests (31 unit + 68 in tests/parity.rs), the size gate and -# the dependency-creep guard. -cargo test --manifest-path minimal-sdk/rust-sdk/Cargo.toml - -# Android: bindings, host cdylib, Kotlin smoke, cargo ndk for 3 ABIs, gates, AAR. -# The Gradle wrapper lives in minimal-sdk/android; there is none at the repo root. -JAVA_HOME=~/.jdks/temurin-17 ./minimal-sdk/android/gradlew -p minimal-sdk/android test assembleRelease - -# Apple (macOS + Xcode): 5 targets → XCFramework → Swift smoke on the macOS slice. -minimal-sdk/scripts/build_apple.sh && swift test --package-path minimal-sdk/apple -``` - -`minimal-sdk/scripts/generate_bindings.sh [output_dir] [library_path]` -regenerates bindings from an unstripped host debug build (library mode reads the -`UNIFFI_META_*` symbols, which the release profile strips; the metadata is -target-independent); pass `library_path` to reuse an existing build. The other two -scripts' options are listed in `../android/README.md` (`build_android.sh`) and -`../apple/README.md` (`build_apple.sh`). CI is -`.github/workflows/minimal-sdk-mobile.yaml`: the Rust job is the gate, the Android job -runs the Gradle command above plus the alignment and size checks, the macOS job is the -only place the XCFramework is assembled. - -### Parity: what is verified, and where - -The tests read `../packages/client-sdk/test/fixtures/rgblib-parity.json` by relative -path. It was generated from the real `@utexo/rgb-lib` binding and is never copied or -regenerated here: one rgb-lib-authored ground truth, checked by an implementation that -shares no code with it. Regenerating the fixture re-runs the whole suite in CI. - -| Acceptance item | Test (`tests/parity.rs` unless noted) | Result 2026-09-15 | -| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| Account xpubs + master fingerprint `73c5da0a` for all four networks | `keys_all_four_networks_match_rgb_lib_fixture_exactly` | pass | -| All ten regtest addresses (5 vanilla + 5 colored, indexes 0..4) | `derive_reproduces_rgb_lib_regtest_addresses_at_keychain_0_indexes_0_to_4` | pass | -| Fixture PSBT signs to `signing.txid` with a verifying BIP-340 signature | `sign_fixture_psbt_matches_rgb_lib_txid_with_a_valid_key_path_signature` | pass; txid parity + signature validity, never witness bytes (BIP-340 is randomized) | -| Each of the five checks fails alone on its own tampering | `check1_…inputs_own`, `check2_…recipients_match` (×3), `check3_…change_own` (×2), `check4_…fee_budget`, `check5_…opret_zero` | pass | -| Decoy key-origin entry cannot divert selection or signing | `decoy_key_origin_entry_does_not_divert_selection`, `sign_through_a_decoy_key_origin_entry_ordered_before_ours` | pass | -| Intent binding fires on every tamper shape | `intent_binding_send_btc_…`, `intent_binding_send_asset_…`, `intent_binding_create_utxos_…` | pass | -| Hostile or truncated PSBTs never panic | `garbage_and_truncated_psbts_fail_all_five_without_panicking`, `gateway_hostile_bodies_never_panic` | pass | -| No secret ever renders | `keys_debug_and_xpubs_render_no_secret`, `sign_errors_never_render_key_material_and_never_panic`, `gateway_requests_never_carry_secrets_and_token_never_renders` | pass | -| No `rgb-lib` / `reqwest` / `tokio` / TLS crate in the graph | `dependency_creep_guard` (+ `cargo tree -i` for each: no match) | pass; 59 graph entries | -| Size gate | `size_gate_release_cdylib_under_budget` | pass, 2 491 688 B | -| Kotlin smoke (15 cases) through JNA, both variants | `../android/src/test/kotlin/…/SmokeTest.kt` | pass | -| Swift smoke (17 cases, 3 of them on the shipped `URLSessionHttpTransport`) | `../apple/Tests/MinimalSdkTests/SmokeTests.swift` | pass on Linux (Swift 6.0.3, host cdylib); on macOS the `apple` job of `minimal-sdk-mobile.yaml` runs the same suite against the XCFramework slice | - -The Kotlin and Swift suites are deliberately smoke-only: they prove the binding surface -(every lowering shape the API uses: objects, records with optionals, lists, maps and -enums, the typed errors), the native load and the foreign transport end to end, in both -directions (a transport that throws the declared error and one that throws a foreign -exception). Every deep assertion lives once, in Rust. diff --git a/minimal-sdk/rust-sdk/src/derive.rs b/minimal-sdk/rust-sdk/src/derive.rs deleted file mode 100644 index 27d6e8ed..00000000 --- a/minimal-sdk/rust-sdk/src/derive.rs +++ /dev/null @@ -1,363 +0,0 @@ -//! Taproot re-derivation and origin-path matching. -//! -//! Re-derivation of taproot `tr(key)` scripts/addresses from account xpubs at -//! arbitrary keychain/index is the client-side half of change verification -//! (verify-before-sign check 3) and input-ownership proofs (check 1). It is -//! also how the app produces receive addresses without asking the gateway. -//! -//! Behavioural reference: `minimal-sdk/packages/client-sdk/src/derive.ts`. -//! Parity against rgb-lib's addresses is checked in `tests/parity.rs`. - -use bitcoin::bip32::{ChildNumber, Xpub}; -use bitcoin::hex::DisplayHex; -use bitcoin::key::XOnlyPublicKey; -use bitcoin::{Address, ScriptBuf}; - -use crate::keys::AccountXpubs; -use crate::network::{account_path_u32, BitcoinNetwork, HARDENED}; -use crate::{secp, SdkError, SdkResult}; - -/// A taproot `tr(key)` output re-derived at `keychain/index` under one of the -/// two account xpubs. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct DerivedScript { - pub keychain: u32, - pub index: u32, - /// BIP-341 tweaked output script (`OP_1 <32-byte key>`), lowercase hex. - pub script_hex: String, - /// Bech32m address for the network the xpub was derived on. - pub address: String, - /// Untweaked x-only internal key, 32 bytes lowercase hex. - pub internal_key_hex: String, -} - -/// Parse an account xpub string and check its version bytes belong to -/// `network` (xpub on mainnet, tpub everywhere else) — mirroring -/// `HDKey.fromExtendedKey(xpub, hdVersions(network))`, which rejects a key -/// serialized for a different network. Hardened derivation below an xpub is -/// impossible, so the account must already be `m/86'/coin'/0'`. -/// BIP-32 depth of an account key: `m / 86' / coin' / account'`. -const ACCOUNT_DEPTH: u8 = 3; - -pub fn parse_account_xpub(xpub: &str, network: BitcoinNetwork) -> SdkResult { - let parsed: Xpub = xpub.parse().map_err(|_| SdkError::InvalidInput { - reason: "invalid account xpub: not a BIP-32 extended public key".into(), - })?; - if parsed.network != network.network_kind() { - return Err(SdkError::InvalidInput { - reason: format!("invalid account xpub: version bytes are not for {network}"), - }); - } - // Only an account-level key (`m/86'/coin'/account'`, depth 3, hardened - // last step) makes `keychain/index` land where rgb-lib's addresses are. - // A master or deeper key would derive silently wrong addresses. - if parsed.depth != ACCOUNT_DEPTH || !parsed.child_number.is_hardened() { - return Err(SdkError::InvalidInput { - reason: format!( - "invalid account xpub: expected an account-level key (depth {ACCOUNT_DEPTH}, hardened), got depth {}", - parsed.depth - ), - }); - } - Ok(parsed) -} - -/// The two account xpubs as parsed keys, the form verify and -/// [`derive_for_origin_path`] consume. Public material only. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParsedAccounts { - pub network: BitcoinNetwork, - pub vanilla: Xpub, - pub colored: Xpub, -} - -impl ParsedAccounts { - /// Parse the registration material returned by - /// [`crate::keys::ClientKeys::xpubs`] (or received back from the gateway). - pub fn parse(xpubs: &AccountXpubs) -> SdkResult { - Ok(ParsedAccounts { - network: xpubs.network, - vanilla: parse_account_xpub(&xpubs.vanilla, xpubs.network)?, - colored: parse_account_xpub(&xpubs.colored, xpubs.network)?, - }) - } - - /// The account xpub for the colored (RGB) or vanilla (BTC) side. - pub fn account(&self, colored: bool) -> &Xpub { - if colored { - &self.colored - } else { - &self.vanilla - } - } -} - -/// Derive the taproot script/address at `keychain/index` under an account -/// xpub: derive the two unhardened children, take the x-only internal key, -/// build the BIP-341 `tr(key)` script (key-path only, no script tree) and its -/// bech32m address for `network`. -/// -/// Errors only on a hardened `keychain`/`index` (impossible from an xpub). -/// It never panics: the values may come from attacker-controlled PSBT -/// metadata. -pub fn derive_taproot( - account_xpub: &Xpub, - keychain: u32, - index: u32, - network: BitcoinNetwork, -) -> SdkResult { - let child = |what: &str, value: u32| { - ChildNumber::from_normal_idx(value).map_err(|_| SdkError::DerivationFailed { - reason: format!("{what} {value} is not an unhardened child index"), - }) - }; - let children = [child("keychain", keychain)?, child("index", index)?]; - let key = - account_xpub - .derive_pub(secp(), &children) - .map_err(|_| SdkError::DerivationFailed { - reason: format!("public derivation at {keychain}/{index} failed"), - })?; - let internal_key: XOnlyPublicKey = key.public_key.into(); - let script = ScriptBuf::new_p2tr(secp(), internal_key, None); - let address = Address::p2tr(secp(), internal_key, None, network.to_bitcoin()); - Ok(DerivedScript { - keychain, - index, - script_hex: script.as_bytes().to_lower_hex_string(), - address: address.to_string(), - internal_key_hex: internal_key.serialize().to_lower_hex_string(), - }) -} - -/// Which of the wallet's two accounts a key-origin path belongs to, and the -/// unhardened `keychain/index` under it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct OriginPathMatch { - pub colored: bool, - pub keychain: u32, - pub index: u32, -} - -/// Match a full master-based key-origin path (`[86', coin', 0', keychain, -/// index]`) against the user's two accounts. -/// -/// **Single source of truth for path acceptance.** Verify proves scripts with -/// it (through [`derive_for_origin_path`]) and sign (via -/// [`crate::keys::ClientKeys::private_key_for_path`]) picks signing keys with -/// it. The two must never drift, or verify could approve a path that sign -/// will not use. Accepts exactly five children: the three hardened account -/// children of one of the two accounts for `network`, then an unhardened -/// keychain and an unhardened index. Returns `None` — never an error, never a -/// panic — on foreign paths: paths are attacker-controlled PSBT input. -pub fn match_origin_path(path: &[u32], network: BitcoinNetwork) -> Option { - for colored in [false, true] { - let prefix = account_path_u32(network, colored); - if path.len() != prefix.len() + 2 { - continue; - } - if path[..prefix.len()] != prefix { - continue; - } - let keychain = path[prefix.len()]; - let index = path[prefix.len() + 1]; - if keychain >= HARDENED || index >= HARDENED { - continue; - } - return Some(OriginPathMatch { - colored, - keychain, - index, - }); - } - None -} - -/// Re-derive the taproot script for a full master-based key-origin path -/// against the matching account xpub; `None` when the path is not ours or -/// the derivation fails. Never errors, never panics — the path is PSBT input. -pub fn derive_for_origin_path( - path: &[u32], - accounts: &ParsedAccounts, - network: BitcoinNetwork, -) -> Option { - let m = match_origin_path(path, network)?; - derive_taproot(accounts.account(m.colored), m.keychain, m.index, network).ok() -} - -#[cfg(test)] -mod tests { - use super::*; - - const H: u32 = HARDENED; - - #[test] - fn matches_both_accounts_only() { - let net = BitcoinNetwork::Regtest; - assert_eq!( - match_origin_path(&[H + 86, H + 1, H, 0, 7], net), - Some(OriginPathMatch { - colored: false, - keychain: 0, - index: 7 - }) - ); - assert_eq!( - match_origin_path(&[H + 86, H + 827_167, H, 1, 3], net), - Some(OriginPathMatch { - colored: true, - keychain: 1, - index: 3 - }) - ); - // Mainnet coin types are foreign on regtest and vice versa. - assert_eq!(match_origin_path(&[H + 86, H, H, 0, 0], net), None); - assert_eq!( - match_origin_path(&[H + 86, H + 827_166, H, 0, 0], net), - None - ); - assert_eq!( - match_origin_path(&[H + 86, H, H, 0, 0], BitcoinNetwork::Mainnet), - Some(OriginPathMatch { - colored: false, - keychain: 0, - index: 0 - }) - ); - } - - #[test] - fn rejects_foreign_shapes_without_erroring() { - let net = BitcoinNetwork::Regtest; - let cases: &[&[u32]] = &[ - &[], - &[H + 86], - &[H + 86, H + 1, H], - &[H + 86, H + 1, H, 0], - &[H + 86, H + 1, H, 0, 0, 0], - &[H + 84, H + 1, H, 0, 0], - &[86, H + 1, H, 0, 0], - &[H + 86, 1, H, 0, 0], - &[H + 86, H + 1, 0, 0, 0], - &[H + 86, H + 1, H + 1, 0, 0], - &[H + 86, H + 1, H, H, 0], - &[H + 86, H + 1, H, 0, H], - &[H + 86, H + 1, H, u32::MAX, 0], - ]; - for case in cases { - assert_eq!(match_origin_path(case, net), None, "{case:?}"); - } - } - - /// A deterministic account xpub for `kind`, from a fixed 32-byte seed. - fn test_xpub(kind: bitcoin::NetworkKind) -> Xpub { - let master = bitcoin::bip32::Xpriv::new_master(kind, &[7u8; 32]).unwrap(); - let account = master - .derive_priv( - secp(), - &account_path_u32(BitcoinNetwork::Mainnet, false).map(ChildNumber::from), - ) - .unwrap(); - Xpub::from_priv(secp(), &account) - } - - #[test] - fn derive_taproot_rejects_hardened_children_without_panicking() { - let xpub = test_xpub(bitcoin::NetworkKind::Main); - for (keychain, index) in [(HARDENED, 0), (0, HARDENED), (u32::MAX, u32::MAX)] { - assert!(matches!( - derive_taproot(&xpub, keychain, index, BitcoinNetwork::Mainnet), - Err(SdkError::DerivationFailed { .. }) - )); - } - let ok = derive_taproot(&xpub, 0, 0, BitcoinNetwork::Mainnet).unwrap(); - assert!(ok.address.starts_with("bc1p")); - assert_eq!(ok.script_hex.len(), 68); - assert!(ok.script_hex.starts_with("5120")); - assert_eq!(ok.script_hex, ok.script_hex.to_lowercase()); - assert_eq!(ok.internal_key_hex.len(), 64); - // Deterministic. - assert_eq!( - derive_taproot(&xpub, 0, 0, BitcoinNetwork::Mainnet).unwrap(), - ok - ); - assert_ne!( - derive_taproot(&xpub, 0, 1, BitcoinNetwork::Mainnet).unwrap(), - ok - ); - } - - #[test] - fn parse_account_xpub_checks_version_bytes() { - let mainnet = test_xpub(bitcoin::NetworkKind::Main).to_string(); - let testnet = test_xpub(bitcoin::NetworkKind::Test).to_string(); - assert!(mainnet.starts_with("xpub")); - assert!(testnet.starts_with("tpub")); - assert!(parse_account_xpub(&mainnet, BitcoinNetwork::Mainnet).is_ok()); - assert!(matches!( - parse_account_xpub(&testnet, BitcoinNetwork::Mainnet), - Err(SdkError::InvalidInput { .. }) - )); - for net in [ - BitcoinNetwork::Testnet, - BitcoinNetwork::Signet, - BitcoinNetwork::Regtest, - ] { - assert!(parse_account_xpub(&testnet, net).is_ok(), "{net}"); - assert!(matches!( - parse_account_xpub(&mainnet, net), - Err(SdkError::InvalidInput { .. }) - )); - } - for bad in ["not-an-xpub", "", "xpub", &mainnet[..mainnet.len() - 1]] { - let err = parse_account_xpub(bad, BitcoinNetwork::Mainnet).unwrap_err(); - assert!(matches!(err, SdkError::InvalidInput { .. }), "{bad:?}"); - // The error never echoes the caller's input. - assert!(!err.to_string().contains(&mainnet[..20])); - } - } - - #[test] - fn parse_account_xpub_rejects_keys_that_are_not_account_level() { - let master = - bitcoin::bip32::Xpriv::new_master(bitcoin::NetworkKind::Main, &[7u8; 32]).unwrap(); - let account = master - .derive_priv( - secp(), - &account_path_u32(BitcoinNetwork::Mainnet, false).map(ChildNumber::from), - ) - .unwrap(); - let master_xpub = Xpub::from_priv(secp(), &master).to_string(); - let account_xpub = Xpub::from_priv(secp(), &account); - let keychain_xpub = account_xpub - .derive_pub(secp(), &[ChildNumber::from_normal_idx(0).unwrap()]) - .unwrap() - .to_string(); - let unhardened_depth3 = Xpub::from_priv( - secp(), - &master - .derive_priv( - secp(), - &[ - ChildNumber::from_hardened_idx(86).unwrap(), - ChildNumber::from_hardened_idx(0).unwrap(), - ChildNumber::from_normal_idx(0).unwrap(), - ], - ) - .unwrap(), - ) - .to_string(); - assert!(parse_account_xpub(&account_xpub.to_string(), BitcoinNetwork::Mainnet).is_ok()); - for (what, bad) in [ - ("master (depth 0)", master_xpub), - ("keychain (depth 4)", keychain_xpub), - ("unhardened depth 3", unhardened_depth3), - ] { - let err = parse_account_xpub(&bad, BitcoinNetwork::Mainnet).unwrap_err(); - assert!( - matches!(&err, SdkError::InvalidInput { reason } if reason.contains("account-level")), - "{what}: {err:?}" - ); - } - } -} diff --git a/minimal-sdk/rust-sdk/src/ffi.rs b/minimal-sdk/rust-sdk/src/ffi.rs deleted file mode 100644 index 87bb93cb..00000000 --- a/minimal-sdk/rust-sdk/src/ffi.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! The exported free-function surface. Exported *objects* (`ClientKeys`) keep -//! their `#[uniffi::export] impl` block next to the type, in their own module. -//! -//! Every exported function must return a plain value or [`crate::SdkResult`] -//! and must never panic on caller-controlled input: a panic here becomes a -//! host-app crash (uniffi turns it into a foreign exception at best). -//! -//! Bindings are generated in **library mode** (`uniffi-bindgen generate -//! --library`); there is no UDL file and none may be added. -//! `uniffi::setup_scaffolding!()` lives in `lib.rs` because uniffi requires -//! it at the crate root. -//! -//! Generate bindings from an **unstripped host build** (`cargo build --lib`, -//! i.e. `target/debug/libutexo_minimal_sdk.so`): library mode reads the -//! `UNIFFI_META_*` entries from the ELF `.symtab`, which the release profile's -//! `strip = "symbols"` removes. The metadata is profile- and target-independent, -//! so bindings generated from the debug host build match the stripped release -//! and cross-compiled libraries exactly. - -use crate::derive::{derive_taproot, parse_account_xpub, DerivedScript}; -use crate::invoice::{Bolt11Invoice, RgbInvoice}; -use crate::keys::{ClientKeys, GeneratedKeys}; -use crate::network::BitcoinNetwork; -use crate::sign::SignResult; -use crate::verify::{VerifyParams, VerifyVerdict}; -use crate::SdkResult; -use std::sync::Arc; - -/// Version of this SDK core, from `Cargo.toml`. Trivial export used to prove -/// the binding pipeline end to end (Kotlin/Swift smoke tests call it first). -#[uniffi::export] -pub fn sdk_version() -> String { - env!("CARGO_PKG_VERSION").to_string() -} - -/// Generate a fresh 12-word mnemonic and the keys derived from it. The -/// returned mnemonic is the user's only backup: show it once, never send it. -#[uniffi::export] -pub fn generate_keys(network: BitcoinNetwork) -> SdkResult { - ClientKeys::generate(network) -} - -/// Re-derive the taproot `tr(key)` script and address at `keychain/index` -/// under an **account xpub** (public material only). This is how the app -/// produces receive addresses locally and how it can cross-check an address -/// the gateway hands back. `InvalidInput` when the xpub is malformed or -/// serialized for another network; `DerivationFailed` on a hardened -/// `keychain`/`index`. -#[uniffi::export] -pub fn derive_taproot_address( - account_xpub: String, - keychain: u32, - index: u32, - network: BitcoinNetwork, -) -> SdkResult { - let xpub = parse_account_xpub(&account_xpub, network)?; - derive_taproot(&xpub, keychain, index, network) -} - -/// Run the five verify-before-sign checks on a base64 PSBT against the -/// gateway's intent summary and the user's own xpubs. Never fails and never -/// panics: hostile or unparseable input is a verdict with every check failed -/// and the reason in each `detail`. Signing (`verify_and_sign_psbt`) runs -/// this internally and refuses on any failure; call it directly to show the -/// user what they are about to approve. -#[uniffi::export(name = "verify_psbt")] -pub fn ffi_verify_psbt(psbt: String, params: VerifyParams) -> VerifyVerdict { - crate::verify::verify_psbt(&psbt, ¶ms) -} - -/// Verify the PSBT against the intent, then BIP-341 key-path sign every -/// input and finalize. The **only** signing entry point: verification runs -/// inside and `VerificationFailed` (naming the failed checks) is returned -/// before anything is signed. `params.xpubs` must be `keys.xpubs()`. -/// Returns the signed+finalized base64 PSBT, its txid and the passing verdict. -#[uniffi::export(name = "verify_and_sign_psbt")] -pub fn ffi_verify_and_sign_psbt( - keys: Arc, - psbt: String, - params: VerifyParams, -) -> SdkResult { - crate::sign::verify_and_sign_psbt(&keys, &psbt, ¶ms) -} - -/// Decode a BOLT-11 lightning invoice (network, amount, payment hash and -/// secret, description, recovered payee, timestamp, expiry). Fed user-pasted -/// strings: any malformed input is `InvoiceDecode`, never a panic. -#[uniffi::export(name = "decode_bolt11")] -pub fn ffi_decode_bolt11(invoice: String) -> SdkResult { - crate::invoice::decode_bolt11(&invoice) -} - -/// Decode an RGB invoice (rgb-invoicing v0.11 grammar, `~` = omitted -/// segment): asset id, amount, chain-qualified recipient id, beneficiary -/// kind, transport endpoints, expiry. Malformed input is `InvoiceDecode`. -#[uniffi::export(name = "decode_rgb_invoice")] -pub fn ffi_decode_rgb_invoice(invoice: String) -> SdkResult { - crate::invoice::decode_rgb_invoice(&invoice) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sdk_version_matches_manifest() { - assert_eq!(sdk_version(), env!("CARGO_PKG_VERSION")); - assert!(!sdk_version().is_empty()); - } -} diff --git a/minimal-sdk/rust-sdk/src/gateway.rs b/minimal-sdk/rust-sdk/src/gateway.rs deleted file mode 100644 index 95361626..00000000 --- a/minimal-sdk/rust-sdk/src/gateway.rs +++ /dev/null @@ -1,1884 +0,0 @@ -//! Thin typed client for the minimal gateway API, and the intent binding -//! that makes verify-before-sign's check 2 non-vacuous. -//! -//! **Rust performs no network IO.** The host app supplies an -//! [`HttpTransport`] (OkHttp/`HttpURLConnection` on Android, `URLSession` -//! on iOS) through uniffi's foreign-trait mechanism; this crate links no HTTP -//! or TLS stack. The call is synchronous from Rust's side, so there is no -//! async-over-FFI: the host is expected to call the client off its UI thread. -//! -//! Money-moving calls carry an `Idempotency-Key`; one is generated per -//! logical operation unless the caller pins its own (retry the SAME operation -//! with the SAME key to get the cached response instead of a duplicate spend). -//! -//! Invariants (design doc I1–I4): only account xpubs and the master -//! fingerprint ever appear in a request; no mnemonic, seed or xprv is a -//! request field or reachable from this module; the bearer token is never -//! rendered into an error or `Debug` output. -//! -//! Behavioural reference: `minimal-sdk/packages/client-sdk/src/gateway.ts`. - -use std::collections::HashMap; -use std::fmt; -use std::sync::Arc; - -use bitcoin::hex::DisplayHex; -use bitcoin::secp256k1::rand::{rngs::OsRng, RngCore}; - -use crate::json::{self, Json, ObjectBuilder}; -use crate::verify::{IntentAsset, IntentKind, IntentRecipient, IntentUtxos, OnchainIntent}; -use crate::{SdkError, SdkResult}; - -/// Default request timeout handed to the transport, as in the TS client. -const DEFAULT_TIMEOUT_MS: u64 = 30_000; - -// --------------------------------------------------------------------------- -// Transport seam -// --------------------------------------------------------------------------- - -/// HTTP method. The gateway API uses only these two. -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] -pub enum HttpMethod { - Get, - Post, -} - -impl HttpMethod { - pub fn name(self) -> &'static str { - match self { - HttpMethod::Get => "GET", - HttpMethod::Post => "POST", - } - } -} - -/// One request for the host transport to perform, exactly as built: the -/// transport must send it verbatim (method, absolute URL, every header, the -/// body if any) and must not add, drop or rewrite headers. -/// -/// The redacting `Debug` below exists only on the Rust side. Over uniffi this -/// is a Kotlin `data class` / Swift `struct` whose default `toString()` / -/// `String(describing:)` prints `headers` verbatim, bearer token included: -/// a host transport must never log the request it receives. -#[derive(Clone, PartialEq, Eq, uniffi::Record)] -pub struct HttpRequest { - pub method: HttpMethod, - /// Absolute URL, already percent-encoded. - pub url: String, - /// Lowercase header names. Includes `authorization` on authenticated - /// calls, `idempotency-key` on money-moving calls and `content-type` - /// when a body is present. - pub headers: HashMap, - /// UTF-8 JSON body, `None` for GET and body-less POSTs. - pub body: Option, - /// Total time the transport may spend before giving up with - /// `SdkError::Transport`. - pub timeout_ms: u64, -} - -/// Header names whose values are credentials: redacted in `Debug`. -const SECRET_HEADERS: &[&str] = &["authorization", "x-operator-token"]; - -/// Renders every header **name** but redacts credential values, so a host -/// app that logs requests never logs the bearer or operator token. -impl fmt::Debug for HttpRequest { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut names: Vec<(&str, &str)> = self - .headers - .iter() - .map(|(k, v)| { - if SECRET_HEADERS.contains(&k.as_str()) { - (k.as_str(), "[redacted]") - } else { - (k.as_str(), v.as_str()) - } - }) - .collect(); - names.sort_unstable(); - f.debug_struct("HttpRequest") - .field("method", &self.method) - .field("url", &self.url) - .field("headers", &names) - .field("body", &self.body) - .field("timeout_ms", &self.timeout_ms) - .finish() - } -} - -/// What the transport got back. Any status is acceptable here: status -/// mapping (2xx vs error body) is done in Rust. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct HttpResponse { - pub status: u16, - /// Response body as text (may be empty or non-JSON; Rust copes). - pub body: String, -} - -/// The network seam, implemented by the host app (modelled on the node's -/// `ExternalSignerHost`). Return `Ok` for **any** HTTP status the server -/// answered with; return `Err(SdkError::Transport { .. })` only when no -/// response was obtained (DNS, connect, TLS, timeout). Never include -/// request headers in the error reason. -#[uniffi::export(with_foreign)] -pub trait HttpTransport: Send + Sync { - fn send(&self, request: HttpRequest) -> Result; -} - -// --------------------------------------------------------------------------- -// Wire types (kept in sync with the gateway's OpenAPI schema) -// --------------------------------------------------------------------------- - -#[derive(Clone, PartialEq, Eq, uniffi::Record)] -pub struct CreatedUser { - pub user_id: String, - /// The per-user bearer token. Store it in the platform keystore. - pub token: String, - pub created_at: u64, -} - -/// Renders the token as `[redacted]`: this is the one record that carries a -/// credential, and a host app may log the value it just received. -impl fmt::Debug for CreatedUser { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CreatedUser") - .field("user_id", &self.user_id) - .field("token", &"[redacted]") - .field("created_at", &self.created_at) - .finish() - } -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct MeInfo { - pub user_id: String, - pub created_at: u64, -} - -/// Public registration material: account xpubs and the master fingerprint -/// (`ClientKeys::xpubs()`). The only key material that ever leaves the device. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct RegisterXpubsParams { - pub vanilla: String, - pub colored: String, - pub fingerprint: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct RegisteredXpubs { - pub fingerprint: String, - pub address: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct WalletAddress { - pub address: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct Balance { - pub settled: u64, - pub future: u64, - pub spendable: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct BtcBalances { - pub vanilla: Balance, - pub colored: Balance, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct AssetBalance { - pub asset_id: String, - pub schema: String, - pub ticker: Option, - pub name: String, - pub precision: u8, - pub balance: Balance, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct WalletBalances { - pub btc: BtcBalances, - pub assets: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct UnspentAllocation { - pub asset_id: Option, - pub amount: Option, - pub settled: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct WalletUnspent { - pub txid: String, - pub vout: u32, - pub amount_sat: u64, - pub colorable: bool, - pub allocations: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct WalletUnspents { - pub unspents: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct WalletTransfer { - pub idx: u64, - pub asset_id: Option, - pub amount: Option, - pub kind: String, - pub status: String, - pub txid: Option, - pub recipient_id: Option, - pub expiration: Option, - pub created_at: u64, - pub updated_at: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct WalletTransfers { - pub transfers: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] -pub enum ReceiveMode { - Blind, - Witness, -} - -impl ReceiveMode { - fn wire(self) -> &'static str { - match self { - ReceiveMode::Blind => "blind", - ReceiveMode::Witness => "witness", - } - } - - fn from_wire(s: &str) -> Option { - match s { - "blind" => Some(ReceiveMode::Blind), - "witness" => Some(ReceiveMode::Witness), - _ => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct ReceiveParams { - pub mode: ReceiveMode, - pub asset_id: Option, - pub amount: Option, - pub duration_seconds: Option, - pub min_confirmations: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct ReceiveResult { - pub invoice: String, - pub recipient_id: String, - pub expiration_timestamp: Option, - pub mode: ReceiveMode, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct SyncResult { - pub status: String, -} - -/// A prepared on-chain operation whose `intent` has already been bound to -/// the caller's request (see [`GatewayClient::prepare_send_btc`]). Hand -/// `intent` to `verify_and_sign_psbt` as-is. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct PreparedOp { - pub op_id: String, - pub psbt: String, - pub intent: OnchainIntent, - pub expires_at: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct PrepareSendBtcParams { - pub address: String, - pub amount_sat: u64, - pub fee_rate_sat_per_vb: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct PrepareSendAssetParams { - pub asset_id: String, - pub amount: u64, - pub recipient_id: String, - /// Required for witness (`wvout`) recipients; must be unset for blind. - pub witness_amount_sat: Option, - pub transport_endpoints: Option>, - pub donation: Option, - pub min_confirmations: Option, - pub fee_rate_sat_per_vb: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct PrepareCreateUtxosParams { - pub num: Option, - pub size: Option, - pub up_to: Option, - pub fee_rate_sat_per_vb: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct CompleteParams { - pub op_id: String, - /// The signed+finalized PSBT from `verify_and_sign_psbt`. - pub signed_psbt: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct CompleteResult { - pub txid: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct CreateUtxosCompleteResult { - pub txid: Option, - pub utxos_created: u32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] -pub enum OperationState { - Pending, - Completed, - Expired, -} - -impl OperationState { - fn from_wire(s: &str) -> Option { - match s { - "pending" => Some(OperationState::Pending), - "completed" => Some(OperationState::Completed), - "expired" => Some(OperationState::Expired), - _ => None, - } - } -} - -/// Durable state of a prepared on-chain operation, for recovery after the -/// outcome of a `complete` call was lost — a timeout, a crash, or the 502 -/// `COMPLETE_AMBIGUOUS` the gateway returns when its wallet failed *after* -/// rgb-lib may already have broadcast. -/// -/// `may_have_broadcast` is the field that matters in that case: rgb-lib -/// broadcasts before it writes its bookkeeping, so a txid can be recorded on an -/// operation that never completed. When it is true the transaction may already -/// be on the network — retry `complete` (safe, and it finishes the bookkeeping) -/// rather than preparing a second operation. It stays true on an `Expired` -/// operation, because expiry does not un-broadcast a transaction. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct OperationStatus { - pub op_id: String, - pub kind: IntentKind, - pub state: OperationState, - /// Final txid once completed; the possibly-broadcast txid while ambiguous. - pub txid: Option, - pub may_have_broadcast: bool, - /// The same intent summary `prepare` returned. - pub intent: OnchainIntent, - pub created_at: u64, - pub expires_at: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] -pub enum LnAssetKind { - Btc, - Rgb, -} - -impl LnAssetKind { - fn wire(self) -> &'static str { - match self { - LnAssetKind::Btc => "btc", - LnAssetKind::Rgb => "rgb", - } - } - - fn from_wire(s: &str) -> Option { - match s { - "btc" => Some(LnAssetKind::Btc), - "rgb" => Some(LnAssetKind::Rgb), - _ => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct LnDepositPrepareParams { - pub kind: LnAssetKind, - /// Declared BTC deposit amount (cap check), required for kind `Btc`. - pub amount_msat: Option, - pub asset_id: Option, - pub amount: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct LnDepositPrepareResult { - pub deposit_id: String, - pub kind: LnAssetKind, - /// Node-owned BTC target address (kind `Btc`). - pub address: Option, - /// RGB invoice to pay into the node (kind `Rgb`). - pub invoice: Option, - pub recipient_id: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct LnPayParams { - pub invoice: String, - /// Required when the invoice carries no amount. - pub amt_msat: Option, - pub asset_amount: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] -pub enum LnPaymentStatus { - Pending, - Succeeded, - Failed, -} - -impl LnPaymentStatus { - fn from_wire(s: &str) -> Option { - match s { - "pending" => Some(LnPaymentStatus::Pending), - "succeeded" => Some(LnPaymentStatus::Succeeded), - "failed" => Some(LnPaymentStatus::Failed), - _ => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct LnPayResult { - pub payment_hash: String, - pub status: LnPaymentStatus, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct LnInvoiceCreateParams { - pub amt_msat: Option, - pub expiry_sec: Option, - pub asset_id: Option, - pub asset_amount: Option, - pub description: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct LnInvoiceCreated { - pub invoice: String, - pub payment_hash: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] -pub enum LnInvoiceState { - Pending, - Settled, - Expired, -} - -impl LnInvoiceState { - fn from_wire(s: &str) -> Option { - match s { - "pending" => Some(LnInvoiceState::Pending), - "settled" => Some(LnInvoiceState::Settled), - "expired" => Some(LnInvoiceState::Expired), - _ => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct LnInvoiceInfo { - pub payment_hash: String, - pub invoice: String, - pub state: LnInvoiceState, - pub amt_msat: Option, - pub asset_id: Option, - pub asset_amount: Option, - pub created_at: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] -pub enum LnPaymentDirection { - Inbound, - Outbound, -} - -impl LnPaymentDirection { - fn from_wire(s: &str) -> Option { - match s { - "inbound" => Some(LnPaymentDirection::Inbound), - "outbound" => Some(LnPaymentDirection::Outbound), - _ => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct LnPaymentEntry { - pub payment_hash: String, - pub direction: LnPaymentDirection, - pub status: LnPaymentStatus, - pub amt_msat: Option, - pub asset_id: Option, - pub asset_amount: Option, - pub created_at: u64, - pub updated_at: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct LnPayments { - pub payments: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct LnBalance { - pub btc_msat: u64, - /// Asset id → amount. - pub assets: HashMap, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct LnWithdrawParams { - pub kind: LnAssetKind, - pub address: Option, - pub amount_sat: Option, - pub asset_id: Option, - pub amount: Option, - pub recipient_id: Option, - /// Required when `recipient_id` is a witness (`:wvout:…`) beneficiary. - pub witness_amount_sat: Option, - pub transport_endpoints: Option>, - pub fee_rate_sat_per_vb: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct LnWithdrawResult { - pub withdrawal_id: String, - pub txid: String, -} - -// --------------------------------------------------------------------------- -// Idempotency keys and URL encoding -// --------------------------------------------------------------------------- - -/// RFC-4122 version-4 id from the OS cryptographic RNG (the `rand` that -/// `bitcoin`'s `rand-std` feature already links; no new dependency). Used -/// for idempotency keys: unguessable, so a third party cannot collide a -/// retry and read back another user's cached response. -pub fn generate_idempotency_key() -> SdkResult { - let mut bytes = [0u8; 16]; - OsRng - .try_fill_bytes(&mut bytes) - .map_err(|_| SdkError::Internal { - reason: "operating system RNG unavailable".into(), - })?; - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - let hex = bytes.to_lower_hex_string(); - Ok(format!( - "{}-{}-{}-{}-{}", - &hex[0..8], - &hex[8..12], - &hex[12..16], - &hex[16..20], - &hex[20..] - )) -} - -/// `encodeURIComponent`: everything but `A-Z a-z 0-9 - _ . ! ~ * ' ( )` is -/// percent-encoded as UTF-8 bytes. RGB asset ids carry `:` and payment -/// hashes come straight from callers: an unencoded value could retarget the -/// request to a different route. -pub fn encode_uri_component(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for byte in s.bytes() { - match byte { - b'A'..=b'Z' - | b'a'..=b'z' - | b'0'..=b'9' - | b'-' - | b'_' - | b'.' - | b'!' - | b'~' - | b'*' - | b'\'' - | b'(' - | b')' => out.push(byte as char), - _ => { - // `write!` into a String cannot fail. - use std::fmt::Write as _; - let _ = write!(out, "%{byte:02X}"); - } - } - } - out -} - -// --------------------------------------------------------------------------- -// Client -// --------------------------------------------------------------------------- - -/// Typed gateway client. Construct once per user session with the bearer -/// token; `create_user` is the only call that works without one. -#[derive(uniffi::Object)] -pub struct GatewayClient { - base_url: String, - token: Option, - timeout_ms: u64, - transport: Arc, -} - -/// Renders the base URL and whether a token is configured — never the token. -impl fmt::Debug for GatewayClient { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("GatewayClient") - .field("base_url", &self.base_url) - .field("token", &self.token.as_ref().map(|_| "[redacted]")) - .field("timeout_ms", &self.timeout_ms) - .finish() - } -} - -struct RequestSpec<'a> { - method: HttpMethod, - path: String, - body: Option, - extra_headers: Vec<(&'static str, &'a str)>, - auth: bool, -} - -impl<'a> RequestSpec<'a> { - fn get(path: impl Into) -> Self { - RequestSpec { - method: HttpMethod::Get, - path: path.into(), - body: None, - extra_headers: Vec::new(), - auth: true, - } - } - - fn post(path: impl Into, body: Option) -> Self { - RequestSpec { - method: HttpMethod::Post, - path: path.into(), - body, - extra_headers: Vec::new(), - auth: true, - } - } -} - -impl GatewayClient { - /// `base_url` with trailing slashes removed; `token` may be omitted only - /// for the operator bootstrap (`create_user`). - pub fn new( - base_url: &str, - token: Option, - timeout_ms: Option, - transport: Arc, - ) -> GatewayClient { - GatewayClient { - base_url: base_url.trim_end_matches('/').to_owned(), - token, - timeout_ms: timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS), - transport, - } - } - - /// Build the request, hand it to the transport and map the response: - /// 2xx → parsed JSON body (`Null` if empty/non-JSON, as the TS client's - /// `payload = null`); anything else → `Gateway { status, code, detail }` - /// from the `{ error: { code, message } }` body when present. - fn request(&self, spec: RequestSpec<'_>) -> SdkResult { - let mut headers: HashMap = HashMap::new(); - for (name, value) in spec.extra_headers { - headers.insert(name.to_owned(), value.to_owned()); - } - if spec.auth { - let token = self.token.as_ref().ok_or_else(|| SdkError::Gateway { - status: 0, - code: "NO_TOKEN".into(), - detail: "gateway client has no bearer token configured".into(), - })?; - headers.insert("authorization".into(), format!("Bearer {token}")); - } - let body = spec.body.map(|json| json.to_json_string()); - if body.is_some() { - headers.insert("content-type".into(), "application/json".into()); - } - let request = HttpRequest { - method: spec.method, - url: format!("{}{}", self.base_url, spec.path), - headers, - body, - timeout_ms: self.timeout_ms, - }; - let response = self.transport.send(request)?; - let payload = json::parse(&response.body).unwrap_or(Json::Null); - if !(200..300).contains(&response.status) { - let error = payload.get("error"); - let code = error - .and_then(|e| e.get("code")) - .and_then(Json::as_str) - .unwrap_or("UNKNOWN") - .to_owned(); - let detail = error - .and_then(|e| e.get("message")) - .and_then(Json::as_str) - .map(str::to_owned) - .unwrap_or_else(|| format!("gateway returned {}", response.status)); - return Err(SdkError::Gateway { - status: response.status, - code, - detail, - }); - } - Ok(payload) - } - - /// Money-moving POST: carries an `Idempotency-Key`, generated unless the - /// caller pinned one. - fn post_idempotent( - &self, - path: &str, - body: Json, - idempotency_key: Option, - ) -> SdkResult { - let key = match idempotency_key { - Some(key) => key, - None => generate_idempotency_key()?, - }; - let mut spec = RequestSpec::post(path, Some(body)); - spec.extra_headers.push(("idempotency-key", key.as_str())); - self.request(spec) - } - - /// Bootstrap-only; requires the operator token, not a user token. - pub fn create_user(&self, operator_token: &str) -> SdkResult { - let mut spec = RequestSpec::post("/v1/users", None); - spec.auth = false; - spec.extra_headers - .push(("x-operator-token", operator_token)); - let v = self.request(spec)?; - Ok(CreatedUser { - user_id: field_str(&v, "userId")?, - token: field_str(&v, "token")?, - created_at: field_u64(&v, "createdAt")?, - }) - } - - pub fn me(&self) -> SdkResult { - let v = self.request(RequestSpec::get("/v1/me"))?; - Ok(MeInfo { - user_id: field_str(&v, "userId")?, - created_at: field_u64(&v, "createdAt")?, - }) - } - - /// Register the account xpubs and master fingerprint. This is the only - /// key-related request the client ever makes, and it carries public - /// material only. - pub fn register_xpubs(&self, params: &RegisterXpubsParams) -> SdkResult { - let body = ObjectBuilder::new() - .field("vanilla", params.vanilla.as_str()) - .field("colored", params.colored.as_str()) - .field("fingerprint", params.fingerprint.as_str()) - .build(); - let v = self.request(RequestSpec::post("/v1/wallet/xpubs", Some(body)))?; - Ok(RegisteredXpubs { - fingerprint: field_str(&v, "fingerprint")?, - address: field_str(&v, "address")?, - }) - } - - pub fn get_address(&self) -> SdkResult { - let v = self.request(RequestSpec::get("/v1/wallet/address"))?; - Ok(WalletAddress { - address: field_str(&v, "address")?, - }) - } - - pub fn get_balances(&self) -> SdkResult { - let v = self.request(RequestSpec::get("/v1/wallet/balances"))?; - let btc = field(&v, "btc")?; - let assets = field_array(&v, "assets")? - .iter() - .map(|a| { - Ok(AssetBalance { - asset_id: field_str(a, "assetId")?, - schema: field_str(a, "schema")?, - ticker: field_opt_str(a, "ticker")?, - name: field_str(a, "name")?, - precision: u8::try_from(field_u64(a, "precision")?) - .map_err(|_| malformed("assets[].precision out of range"))?, - balance: parse_balance(field(a, "balance")?)?, - }) - }) - .collect::>>()?; - Ok(WalletBalances { - btc: BtcBalances { - vanilla: parse_balance(field(btc, "vanilla")?)?, - colored: parse_balance(field(btc, "colored")?)?, - }, - assets, - }) - } - - pub fn get_unspents(&self) -> SdkResult { - let v = self.request(RequestSpec::get("/v1/wallet/unspents"))?; - let unspents = field_array(&v, "unspents")? - .iter() - .map(|u| { - let allocations = field_array(u, "allocations")? - .iter() - .map(|a| { - Ok(UnspentAllocation { - asset_id: field_opt_str(a, "assetId")?, - amount: field_opt_u64(a, "amount")?, - settled: field_bool(a, "settled")?, - }) - }) - .collect::>>()?; - Ok(WalletUnspent { - txid: field_str(u, "txid")?, - vout: u32::try_from(field_u64(u, "vout")?) - .map_err(|_| malformed("unspents[].vout out of range"))?, - amount_sat: field_u64(u, "amountSat")?, - colorable: field_bool(u, "colorable")?, - allocations, - }) - }) - .collect::>>()?; - Ok(WalletUnspents { unspents }) - } - - pub fn get_transfers(&self, asset_id: Option<&str>) -> SdkResult { - let query = match asset_id { - Some(id) => format!("?assetId={}", encode_uri_component(id)), - None => String::new(), - }; - let v = self.request(RequestSpec::get(format!("/v1/wallet/transfers{query}")))?; - let transfers = field_array(&v, "transfers")? - .iter() - .map(|t| { - Ok(WalletTransfer { - idx: field_u64(t, "idx")?, - asset_id: field_opt_str(t, "assetId")?, - amount: field_opt_u64(t, "amount")?, - kind: field_str(t, "kind")?, - status: field_str(t, "status")?, - txid: field_opt_str(t, "txid")?, - recipient_id: field_opt_str(t, "recipientId")?, - expiration: field_opt_u64(t, "expiration")?, - created_at: field_u64(t, "createdAt")?, - updated_at: field_u64(t, "updatedAt")?, - }) - }) - .collect::>>()?; - Ok(WalletTransfers { transfers }) - } - - pub fn receive(&self, params: &ReceiveParams) -> SdkResult { - let body = ObjectBuilder::new() - .field("mode", params.mode.wire()) - .optional("assetId", params.asset_id.clone()) - .optional("amount", params.amount) - .optional("durationSeconds", params.duration_seconds) - .optional("minConfirmations", params.min_confirmations) - .build(); - let v = self.request(RequestSpec::post("/v1/wallet/receive", Some(body)))?; - let mode = field_str(&v, "mode")?; - Ok(ReceiveResult { - invoice: field_str(&v, "invoice")?, - recipient_id: field_str(&v, "recipientId")?, - expiration_timestamp: field_opt_u64(&v, "expirationTimestamp")?, - mode: ReceiveMode::from_wire(&mode) - .ok_or_else(|| malformed(&format!("unknown receive mode {mode:?}")))?, - }) - } - - pub fn sync(&self) -> SdkResult { - let v = self.request(RequestSpec::post("/v1/wallet/sync", None))?; - Ok(SyncResult { - status: field_str(&v, "status")?, - }) - } - - /// Prepare a BTC send. `IntentMismatch` if the returned intent - /// contradicts `params` (see `checked_intent`). - pub fn prepare_send_btc( - &self, - params: &PrepareSendBtcParams, - idempotency_key: Option, - ) -> SdkResult { - let body = ObjectBuilder::new() - .field("address", params.address.as_str()) - .field("amountSat", params.amount_sat) - .optional("feeRateSatPerVb", params.fee_rate_sat_per_vb) - .build(); - let v = self.post_idempotent("/v1/onchain/send-btc/prepare", body, idempotency_key)?; - checked_intent(&v, IntentKind::SendBtc, |intent, mismatch| { - let recipients = intent.recipients; - if recipients.len() == 1 { - let only = &recipients[0]; - let address = only.get("address").and_then(Json::as_str); - if address != Some(params.address.as_str()) { - mismatch(format!( - "recipient address {} != {}", - display(only.get("address")), - params.address - )); - } - let amount = only.get("amountSat").and_then(Json::as_u64); - if amount != Some(params.amount_sat) { - mismatch(format!( - "recipient amountSat {} != {}", - display(only.get("amountSat")), - params.amount_sat - )); - } - } else { - mismatch(format!( - "expected exactly 1 recipient, got {}", - recipients.len() - )); - } - if !intent.asset.is_null() { - mismatch("send-btc intent carries an asset".into()); - } - if !intent.utxos.is_null() { - mismatch("send-btc intent carries a utxo shape".into()); - } - check_pinned_u64( - "feeRateSatPerVb", - params.fee_rate_sat_per_vb, - intent.fee_rate, - mismatch, - ); - }) - } - - pub fn complete_send_btc( - &self, - params: &CompleteParams, - idempotency_key: Option, - ) -> SdkResult { - let v = self.post_idempotent( - "/v1/onchain/send-btc/complete", - complete_body(params), - idempotency_key, - )?; - Ok(CompleteResult { - txid: field_str(&v, "txid")?, - }) - } - - /// Prepare an RGB asset send. `IntentMismatch` if the returned intent - /// contradicts `params` (see `checked_intent`). - pub fn prepare_send_asset( - &self, - params: &PrepareSendAssetParams, - idempotency_key: Option, - ) -> SdkResult { - let body = ObjectBuilder::new() - .field("assetId", params.asset_id.as_str()) - .field("amount", params.amount) - .field("recipientId", params.recipient_id.as_str()) - .optional("witnessAmountSat", params.witness_amount_sat) - .optional("transportEndpoints", params.transport_endpoints.clone()) - .optional("donation", params.donation) - .optional("minConfirmations", params.min_confirmations) - .optional("feeRateSatPerVb", params.fee_rate_sat_per_vb) - .build(); - let v = self.post_idempotent("/v1/onchain/send-asset/prepare", body, idempotency_key)?; - checked_intent(&v, IntentKind::SendAsset, |intent, mismatch| { - if !intent.recipients.is_empty() { - mismatch(format!( - "send-asset intent carries {} bitcoin recipient(s)", - intent.recipients.len() - )); - } - if !intent.utxos.is_null() { - mismatch("send-asset intent carries a utxo shape".into()); - } - let asset = intent.asset; - if asset.as_object().is_none() { - mismatch("send-asset intent carries no asset".into()); - return; - } - if asset.get("assetId").and_then(Json::as_str) != Some(params.asset_id.as_str()) { - mismatch(format!( - "assetId {} != {}", - display(asset.get("assetId")), - params.asset_id - )); - } - if asset.get("amount").and_then(Json::as_u64) != Some(params.amount) { - mismatch(format!( - "amount {} != {}", - display(asset.get("amount")), - params.amount - )); - } - if asset.get("recipientId").and_then(Json::as_str) != Some(params.recipient_id.as_str()) - { - mismatch(format!( - "recipientId {} != {}", - display(asset.get("recipientId")), - params.recipient_id - )); - } - // Always asserted, including the blind case: a witness amount the - // caller never asked for lets check 2 account for one foreign - // output of that size, which check 3 would otherwise have refused. - // A missing field is a mismatch too (`undefined !== null`). - let witness_ok = match (asset.get("witnessAmountSat"), params.witness_amount_sat) { - (Some(v), None) => v.is_null(), - (Some(v), Some(expected)) => v.as_u64() == Some(expected), - (None, _) => false, - }; - if !witness_ok { - mismatch(format!( - "witnessAmountSat {} != {}", - display(asset.get("witnessAmountSat")), - params - .witness_amount_sat - .map_or_else(|| "null".to_owned(), |v| v.to_string()) - )); - } - if let Some(endpoints) = ¶ms.transport_endpoints { - let returned: Option> = asset - .get("transportEndpoints") - .and_then(Json::as_array) - .map(|items| items.iter().filter_map(Json::as_str).collect()); - let same = returned.as_ref().is_some_and(|r| { - r.len() == endpoints.len() && r.iter().zip(endpoints).all(|(a, b)| a == b) - }); - if !same { - mismatch(format!( - "transportEndpoints {} != {}", - returned.map_or_else(|| "null".to_owned(), |r| r.join(",")), - endpoints.join(",") - )); - } - } - check_pinned_u64( - "feeRateSatPerVb", - params.fee_rate_sat_per_vb, - intent.fee_rate, - mismatch, - ); - }) - } - - pub fn complete_send_asset( - &self, - params: &CompleteParams, - idempotency_key: Option, - ) -> SdkResult { - let v = self.post_idempotent( - "/v1/onchain/send-asset/complete", - complete_body(params), - idempotency_key, - )?; - Ok(CompleteResult { - txid: field_str(&v, "txid")?, - }) - } - - /// Prepare a UTXO-creation transaction. `IntentMismatch` if the returned - /// intent contradicts `params` (see `checked_intent`). - pub fn prepare_create_utxos( - &self, - params: &PrepareCreateUtxosParams, - idempotency_key: Option, - ) -> SdkResult { - let body = ObjectBuilder::new() - .optional("num", params.num) - .optional("size", params.size) - .optional("upTo", params.up_to) - .optional("feeRateSatPerVb", params.fee_rate_sat_per_vb) - .build(); - let v = self.post_idempotent("/v1/onchain/create-utxos/prepare", body, idempotency_key)?; - checked_intent(&v, IntentKind::CreateUtxos, |intent, mismatch| { - if !intent.recipients.is_empty() { - mismatch(format!( - "create-utxos intent carries {} recipient(s)", - intent.recipients.len() - )); - } - if !intent.asset.is_null() { - mismatch("create-utxos intent carries an asset".into()); - } - let utxos = intent.utxos; - if utxos.as_object().is_none() { - mismatch("create-utxos intent carries no utxo shape".into()); - return; - } - check_pinned_u64("num", params.num.map(u64::from), utxos.get("num"), mismatch); - check_pinned_u64("size", params.size, utxos.get("size"), mismatch); - if let Some(up_to) = params.up_to { - if utxos.get("upTo").and_then(Json::as_bool) != Some(up_to) { - mismatch(format!("upTo {} != {up_to}", display(utxos.get("upTo")))); - } - } - check_pinned_u64( - "feeRateSatPerVb", - params.fee_rate_sat_per_vb, - intent.fee_rate, - mismatch, - ); - }) - } - - pub fn complete_create_utxos( - &self, - params: &CompleteParams, - idempotency_key: Option, - ) -> SdkResult { - let v = self.post_idempotent( - "/v1/onchain/create-utxos/complete", - complete_body(params), - idempotency_key, - )?; - Ok(CreateUtxosCompleteResult { - txid: field_opt_str(&v, "txid")?, - utxos_created: u32::try_from(field_u64(&v, "utxosCreated")?) - .map_err(|_| malformed("utxosCreated out of range"))?, - }) - } - - /// Read back a prepared operation's durable state. - /// - /// Safe to poll: a read, no idempotency key, and the gateway does not queue - /// it behind that user's wallet work — so it answers even while the - /// `complete` being asked about is still running. It never returns the - /// PSBT; the caller already holds it from `prepare`. - pub fn get_onchain_operation(&self, op_id: &str) -> SdkResult { - let v = self.request(RequestSpec::get(format!( - "/v1/onchain/operations/{}", - encode_uri_component(op_id) - )))?; - let state_wire = field_str(&v, "state")?; - let state = OperationState::from_wire(&state_wire) - .ok_or_else(|| malformed(&format!("unknown operation state {state_wire:?}")))?; - let kind_wire = field_str(&v, "kind")?; - let kind = IntentKind::from_wire(&kind_wire) - .ok_or_else(|| malformed(&format!("unknown operation kind {kind_wire:?}")))?; - let intent = v - .get("intent") - .ok_or_else(|| malformed("operation carries no intent summary"))?; - // Reuse the same typed decoder `prepare` uses, so a field this SDK - // cannot represent fails here rather than surfacing as a silent default. - let intent = typed_intent(intent, kind).map_err(|e| malformed(&e))?; - Ok(OperationStatus { - op_id: field_str(&v, "opId")?, - kind, - state, - txid: field_opt_str(&v, "txid")?, - may_have_broadcast: v - .get("mayHaveBroadcast") - .and_then(Json::as_bool) - .ok_or_else(|| malformed("mayHaveBroadcast is not a boolean"))?, - intent, - created_at: field_u64(&v, "createdAt")?, - expires_at: field_u64(&v, "expiresAt")?, - }) - } - - /// Pin the idempotency key to retry a prepare without minting a second - /// deposit target. - pub fn prepare_ln_deposit( - &self, - params: &LnDepositPrepareParams, - idempotency_key: Option, - ) -> SdkResult { - let body = ObjectBuilder::new() - .field("kind", params.kind.wire()) - .optional("amountMsat", params.amount_msat) - .optional("assetId", params.asset_id.clone()) - .optional("amount", params.amount) - .build(); - let v = self.post_idempotent("/v1/ln/deposit/prepare", body, idempotency_key)?; - let kind = field_str(&v, "kind")?; - Ok(LnDepositPrepareResult { - deposit_id: field_str(&v, "depositId")?, - kind: LnAssetKind::from_wire(&kind) - .ok_or_else(|| malformed(&format!("unknown deposit kind {kind:?}")))?, - address: field_opt_str(&v, "address")?, - invoice: field_opt_str(&v, "invoice")?, - recipient_id: field_opt_str(&v, "recipientId")?, - }) - } - - /// Money-moving: pin the idempotency key to retry the SAME payment safely. - pub fn pay_ln_invoice( - &self, - params: &LnPayParams, - idempotency_key: Option, - ) -> SdkResult { - let body = ObjectBuilder::new() - .field("invoice", params.invoice.as_str()) - .optional("amtMsat", params.amt_msat) - .optional("assetAmount", params.asset_amount) - .build(); - let v = self.post_idempotent("/v1/ln/pay", body, idempotency_key)?; - let status = field_str(&v, "status")?; - Ok(LnPayResult { - payment_hash: field_str(&v, "paymentHash")?, - status: LnPaymentStatus::from_wire(&status) - .ok_or_else(|| malformed(&format!("unknown payment status {status:?}")))?, - }) - } - - pub fn create_ln_invoice(&self, params: &LnInvoiceCreateParams) -> SdkResult { - let body = ObjectBuilder::new() - .optional("amtMsat", params.amt_msat) - .optional("expirySec", params.expiry_sec) - .optional("assetId", params.asset_id.clone()) - .optional("assetAmount", params.asset_amount) - .optional("description", params.description.clone()) - .build(); - let v = self.request(RequestSpec::post("/v1/ln/invoice", Some(body)))?; - Ok(LnInvoiceCreated { - invoice: field_str(&v, "invoice")?, - payment_hash: field_str(&v, "paymentHash")?, - }) - } - - pub fn get_ln_invoice(&self, payment_hash: &str) -> SdkResult { - let v = self.request(RequestSpec::get(format!( - "/v1/ln/invoice/{}", - encode_uri_component(payment_hash) - )))?; - let state = field_str(&v, "state")?; - Ok(LnInvoiceInfo { - payment_hash: field_str(&v, "paymentHash")?, - invoice: field_str(&v, "invoice")?, - state: LnInvoiceState::from_wire(&state) - .ok_or_else(|| malformed(&format!("unknown invoice state {state:?}")))?, - amt_msat: field_opt_u64(&v, "amtMsat")?, - asset_id: field_opt_str(&v, "assetId")?, - asset_amount: field_opt_u64(&v, "assetAmount")?, - created_at: field_u64(&v, "createdAt")?, - }) - } - - pub fn list_ln_payments(&self) -> SdkResult { - let v = self.request(RequestSpec::get("/v1/ln/payments"))?; - let payments = field_array(&v, "payments")? - .iter() - .map(|p| { - let direction = field_str(p, "direction")?; - let status = field_str(p, "status")?; - Ok(LnPaymentEntry { - payment_hash: field_str(p, "paymentHash")?, - direction: LnPaymentDirection::from_wire(&direction).ok_or_else(|| { - malformed(&format!("unknown payment direction {direction:?}")) - })?, - status: LnPaymentStatus::from_wire(&status) - .ok_or_else(|| malformed(&format!("unknown payment status {status:?}")))?, - amt_msat: field_opt_u64(p, "amtMsat")?, - asset_id: field_opt_str(p, "assetId")?, - asset_amount: field_opt_u64(p, "assetAmount")?, - created_at: field_u64(p, "createdAt")?, - updated_at: field_u64(p, "updatedAt")?, - }) - }) - .collect::>>()?; - Ok(LnPayments { payments }) - } - - pub fn get_ln_balance(&self) -> SdkResult { - let v = self.request(RequestSpec::get("/v1/ln/balance"))?; - let assets = field(&v, "assets")? - .as_object() - .ok_or_else(|| malformed("assets is not an object"))? - .iter() - .map(|(id, amount)| { - let amount = amount - .as_u64() - .ok_or_else(|| malformed(&format!("assets[{id:?}] is not an integer")))?; - Ok((id.clone(), amount)) - }) - .collect::>>()?; - Ok(LnBalance { - btc_msat: field_u64(&v, "btcMsat")?, - assets, - }) - } - - /// Money-moving: pin the idempotency key to retry the SAME withdrawal. - pub fn withdraw_ln( - &self, - params: &LnWithdrawParams, - idempotency_key: Option, - ) -> SdkResult { - let body = ObjectBuilder::new() - .field("kind", params.kind.wire()) - .optional("address", params.address.clone()) - .optional("amountSat", params.amount_sat) - .optional("assetId", params.asset_id.clone()) - .optional("amount", params.amount) - .optional("recipientId", params.recipient_id.clone()) - .optional("witnessAmountSat", params.witness_amount_sat) - .optional("transportEndpoints", params.transport_endpoints.clone()) - .optional("feeRateSatPerVb", params.fee_rate_sat_per_vb) - .build(); - let v = self.post_idempotent("/v1/ln/withdraw", body, idempotency_key)?; - Ok(LnWithdrawResult { - withdrawal_id: field_str(&v, "withdrawalId")?, - txid: field_str(&v, "txid")?, - }) - } -} - -fn complete_body(params: &CompleteParams) -> Json { - ObjectBuilder::new() - .field("opId", params.op_id.as_str()) - .field("signedPsbt", params.signed_psbt.as_str()) - .build() -} - -// --------------------------------------------------------------------------- -// Intent binding -// --------------------------------------------------------------------------- - -/// The server's intent, still untyped, as handed to the per-kind comparison. -struct RawIntent<'a> { - recipients: &'a [Json], - asset: &'a Json, - utxos: &'a Json, - fee_rate: Option<&'a Json>, -} - -/// Bind the gateway's intent summary to the caller's OWN request. -/// -/// `verify` check 2 matches the PSBT's outputs against the intent. If that -/// intent is simply whatever the server chose to return, the check is -/// vacuous against the threat verify-before-sign exists for: a hostile -/// gateway pairs an attacker script in the PSBT with the same attacker -/// address in the intent and both "match", while checks 1/3/4/5 stay clean -/// (the output is accounted, so change-own skips it). The design doc calls -/// for the *user's* stated intent (`minimal-sdk-and-lightweight-rln.md`, -/// check 2), so every field the caller actually stated is asserted here -/// before the intent is handed on. -/// -/// Fields the caller left to the server (fee rate, transport endpoints, utxo -/// shape) are asserted only when the caller pinned them: they carry no -/// bitcoin value for check 2, and the absolute fee stays bounded by -/// `max_fee_sat`. The shape assertions (no recipients on an asset/utxo -/// intent, no asset on a send-btc intent) are not cosmetic — a foreign -/// `asset.witnessAmountSat` or an extra recipient is exactly how a server -/// would smuggle an unaccounted output past check 3. -/// -/// The comparison runs on the **untyped** JSON so that a malformed field is -/// reported as a mismatch (fail closed) rather than silently coerced; the -/// typed [`OnchainIntent`] is built only after every assertion passed, and -/// any field it cannot represent is a mismatch too. All mismatches are -/// collected and returned together as one `IntentMismatch`. -fn checked_intent( - prepared: &Json, - kind: IntentKind, - compare: impl FnOnce(RawIntent<'_>, &mut dyn FnMut(String)), -) -> SdkResult { - let intent = match prepared.get("intent") { - Some(intent) if intent.as_object().is_some() => intent, - _ => { - return Err(SdkError::IntentMismatch { - reason: "prepare response carries no intent summary".into(), - }) - } - }; - let mut mismatches: Vec = Vec::new(); - let returned_kind = intent.get("kind").and_then(Json::as_str); - if returned_kind != Some(kind.name()) { - mismatches.push(format!( - "kind {} != {kind}", - returned_kind.unwrap_or("undefined") - )); - } - match intent.get("recipients").and_then(Json::as_array) { - None => mismatches.push("intent.recipients is not an array".into()), - Some(recipients) => { - let null = Json::Null; - let raw = RawIntent { - recipients, - asset: intent.get("asset").unwrap_or(&null), - utxos: intent.get("utxos").unwrap_or(&null), - fee_rate: intent.get("feeRateSatPerVb"), - }; - compare(raw, &mut |detail| mismatches.push(detail)); - } - } - let mismatch_error = |mismatches: &[String]| SdkError::IntentMismatch { - reason: format!( - "gateway intent does not match the request: {}", - mismatches.join("; ") - ), - }; - if !mismatches.is_empty() { - return Err(mismatch_error(&mismatches)); - } - let intent = typed_intent(intent, kind).map_err(|detail| mismatch_error(&[detail]))?; - Ok(PreparedOp { - op_id: field_str(prepared, "opId")?, - psbt: field_str(prepared, "psbt")?, - intent, - expires_at: field_u64(prepared, "expiresAt")?, - }) -} - -/// Convert the (already compared) server intent into the typed record -/// `verify` consumes. Any field that does not fit is reported by name. -fn typed_intent(intent: &Json, kind: IntentKind) -> Result { - let fee_rate_sat_per_vb = intent - .get("feeRateSatPerVb") - .and_then(Json::as_u64) - .ok_or("intent.feeRateSatPerVb is not an integer")?; - let recipients = intent - .get("recipients") - .and_then(Json::as_array) - .ok_or("intent.recipients is not an array")? - .iter() - .map(|r| { - Ok(IntentRecipient { - address: r - .get("address") - .and_then(Json::as_str) - .ok_or("recipient.address is not a string")? - .to_owned(), - script_hex: r - .get("scriptHex") - .and_then(Json::as_str) - .ok_or("recipient.scriptHex is not a string")? - .to_owned(), - amount_sat: r - .get("amountSat") - .and_then(Json::as_u64) - .ok_or("recipient.amountSat is not an integer")?, - }) - }) - .collect::, String>>()?; - let asset = match intent.get("asset") { - None | Some(Json::Null) => None, - Some(a) => { - let witness = match a.get("witnessAmountSat") { - None | Some(Json::Null) => None, - Some(v) => Some( - v.as_u64() - .ok_or("asset.witnessAmountSat is not an integer")?, - ), - }; - let transport_endpoints = a - .get("transportEndpoints") - .and_then(Json::as_array) - .ok_or("asset.transportEndpoints is not an array")? - .iter() - .map(|e| { - e.as_str() - .map(str::to_owned) - .ok_or_else(|| "asset.transportEndpoints[] is not a string".to_owned()) - }) - .collect::, String>>()?; - Some(IntentAsset { - asset_id: a - .get("assetId") - .and_then(Json::as_str) - .ok_or("asset.assetId is not a string")? - .to_owned(), - amount: a - .get("amount") - .and_then(Json::as_u64) - .ok_or("asset.amount is not an integer")?, - recipient_id: a - .get("recipientId") - .and_then(Json::as_str) - .ok_or("asset.recipientId is not a string")? - .to_owned(), - witness_amount_sat: witness, - transport_endpoints, - }) - } - }; - let utxos = match intent.get("utxos") { - None | Some(Json::Null) => None, - Some(u) => Some(IntentUtxos { - up_to: u - .get("upTo") - .and_then(Json::as_bool) - .ok_or("utxos.upTo is not a boolean")?, - num: u - .get("num") - .and_then(Json::as_u64) - .and_then(|n| u32::try_from(n).ok()) - .ok_or("utxos.num is not a u32")?, - size: u - .get("size") - .and_then(Json::as_u64) - .ok_or("utxos.size is not an integer")?, - }), - }; - Ok(OnchainIntent { - kind, - fee_rate_sat_per_vb, - recipients, - asset, - utxos, - }) -} - -/// `checkPinned`: a caller-pinned integer must come back exactly. -fn check_pinned_u64( - name: &str, - requested: Option, - returned: Option<&Json>, - mismatch: &mut dyn FnMut(String), -) { - if let Some(expected) = requested { - if returned.and_then(Json::as_u64) != Some(expected) { - mismatch(format!("{name} {} != {expected}", display(returned))); - } - } -} - -/// Render a JSON scalar for a mismatch message (`String(x)` in TS). -fn display(value: Option<&Json>) -> String { - match value { - None => "undefined".into(), - Some(Json::Null) => "null".into(), - Some(Json::Bool(b)) => b.to_string(), - Some(Json::Number(n)) => n.clone(), - Some(Json::String(s)) => s.clone(), - Some(other) => other.to_json_string(), - } -} - -// --------------------------------------------------------------------------- -// Response field access -// --------------------------------------------------------------------------- - -/// A 2xx body that does not have the documented shape. -fn malformed(detail: &str) -> SdkError { - SdkError::Gateway { - status: 0, - code: "MALFORMED_RESPONSE".into(), - detail: format!("gateway response malformed: {detail}"), - } -} - -fn field<'a>(v: &'a Json, key: &str) -> SdkResult<&'a Json> { - v.get(key) - .ok_or_else(|| malformed(&format!("missing field {key}"))) -} - -fn field_str(v: &Json, key: &str) -> SdkResult { - field(v, key)? - .as_str() - .map(str::to_owned) - .ok_or_else(|| malformed(&format!("{key} is not a string"))) -} - -fn field_opt_str(v: &Json, key: &str) -> SdkResult> { - match v.get(key) { - None | Some(Json::Null) => Ok(None), - Some(Json::String(s)) => Ok(Some(s.clone())), - Some(_) => Err(malformed(&format!("{key} is not a string or null"))), - } -} - -fn field_u64(v: &Json, key: &str) -> SdkResult { - field(v, key)? - .as_u64() - .ok_or_else(|| malformed(&format!("{key} is not a non-negative integer"))) -} - -fn field_opt_u64(v: &Json, key: &str) -> SdkResult> { - match v.get(key) { - None | Some(Json::Null) => Ok(None), - Some(n) => n - .as_u64() - .map(Some) - .ok_or_else(|| malformed(&format!("{key} is not an integer or null"))), - } -} - -fn field_bool(v: &Json, key: &str) -> SdkResult { - field(v, key)? - .as_bool() - .ok_or_else(|| malformed(&format!("{key} is not a boolean"))) -} - -fn field_array<'a>(v: &'a Json, key: &str) -> SdkResult<&'a [Json]> { - field(v, key)? - .as_array() - .ok_or_else(|| malformed(&format!("{key} is not an array"))) -} - -fn parse_balance(v: &Json) -> SdkResult { - Ok(Balance { - settled: field_u64(v, "settled")?, - future: field_u64(v, "future")?, - spendable: field_u64(v, "spendable")?, - }) -} - -// --------------------------------------------------------------------------- -// uniffi surface -// --------------------------------------------------------------------------- - -/// The uniffi surface of [`GatewayClient`]: one method per gateway route. -/// `idempotency_key` may be `null` (a fresh v4 UUID is generated) or pinned -/// by the caller to retry the same operation safely. -#[uniffi::export] -impl GatewayClient { - /// `base_url` like `https://gw.example.com` (trailing slashes ignored), - /// the user's bearer token (`null` only for `create_user`), an optional - /// timeout in milliseconds (default 30 000) and the host transport. - #[uniffi::constructor(name = "new")] - pub fn ffi_new( - base_url: String, - token: Option, - timeout_ms: Option, - transport: Arc, - ) -> Arc { - Arc::new(Self::new(&base_url, token, timeout_ms, transport)) - } - - #[uniffi::method(name = "create_user")] - pub fn ffi_create_user(&self, operator_token: String) -> SdkResult { - self.create_user(&operator_token) - } - - #[uniffi::method(name = "me")] - pub fn ffi_me(&self) -> SdkResult { - self.me() - } - - #[uniffi::method(name = "register_xpubs")] - pub fn ffi_register_xpubs(&self, params: RegisterXpubsParams) -> SdkResult { - self.register_xpubs(¶ms) - } - - #[uniffi::method(name = "get_address")] - pub fn ffi_get_address(&self) -> SdkResult { - self.get_address() - } - - #[uniffi::method(name = "get_balances")] - pub fn ffi_get_balances(&self) -> SdkResult { - self.get_balances() - } - - #[uniffi::method(name = "get_unspents")] - pub fn ffi_get_unspents(&self) -> SdkResult { - self.get_unspents() - } - - #[uniffi::method(name = "get_transfers")] - pub fn ffi_get_transfers(&self, asset_id: Option) -> SdkResult { - self.get_transfers(asset_id.as_deref()) - } - - #[uniffi::method(name = "receive")] - pub fn ffi_receive(&self, params: ReceiveParams) -> SdkResult { - self.receive(¶ms) - } - - #[uniffi::method(name = "sync")] - pub fn ffi_sync(&self) -> SdkResult { - self.sync() - } - - #[uniffi::method(name = "prepare_send_btc")] - pub fn ffi_prepare_send_btc( - &self, - params: PrepareSendBtcParams, - idempotency_key: Option, - ) -> SdkResult { - self.prepare_send_btc(¶ms, idempotency_key) - } - - #[uniffi::method(name = "complete_send_btc")] - pub fn ffi_complete_send_btc( - &self, - params: CompleteParams, - idempotency_key: Option, - ) -> SdkResult { - self.complete_send_btc(¶ms, idempotency_key) - } - - #[uniffi::method(name = "prepare_send_asset")] - pub fn ffi_prepare_send_asset( - &self, - params: PrepareSendAssetParams, - idempotency_key: Option, - ) -> SdkResult { - self.prepare_send_asset(¶ms, idempotency_key) - } - - #[uniffi::method(name = "complete_send_asset")] - pub fn ffi_complete_send_asset( - &self, - params: CompleteParams, - idempotency_key: Option, - ) -> SdkResult { - self.complete_send_asset(¶ms, idempotency_key) - } - - #[uniffi::method(name = "prepare_create_utxos")] - pub fn ffi_prepare_create_utxos( - &self, - params: PrepareCreateUtxosParams, - idempotency_key: Option, - ) -> SdkResult { - self.prepare_create_utxos(¶ms, idempotency_key) - } - - #[uniffi::method(name = "complete_create_utxos")] - pub fn ffi_complete_create_utxos( - &self, - params: CompleteParams, - idempotency_key: Option, - ) -> SdkResult { - self.complete_create_utxos(¶ms, idempotency_key) - } - - #[uniffi::method(name = "get_onchain_operation")] - pub fn ffi_get_onchain_operation(&self, op_id: String) -> SdkResult { - self.get_onchain_operation(&op_id) - } - - #[uniffi::method(name = "prepare_ln_deposit")] - pub fn ffi_prepare_ln_deposit( - &self, - params: LnDepositPrepareParams, - idempotency_key: Option, - ) -> SdkResult { - self.prepare_ln_deposit(¶ms, idempotency_key) - } - - #[uniffi::method(name = "pay_ln_invoice")] - pub fn ffi_pay_ln_invoice( - &self, - params: LnPayParams, - idempotency_key: Option, - ) -> SdkResult { - self.pay_ln_invoice(¶ms, idempotency_key) - } - - #[uniffi::method(name = "create_ln_invoice")] - pub fn ffi_create_ln_invoice( - &self, - params: LnInvoiceCreateParams, - ) -> SdkResult { - self.create_ln_invoice(¶ms) - } - - #[uniffi::method(name = "get_ln_invoice")] - pub fn ffi_get_ln_invoice(&self, payment_hash: String) -> SdkResult { - self.get_ln_invoice(&payment_hash) - } - - #[uniffi::method(name = "list_ln_payments")] - pub fn ffi_list_ln_payments(&self) -> SdkResult { - self.list_ln_payments() - } - - #[uniffi::method(name = "get_ln_balance")] - pub fn ffi_get_ln_balance(&self) -> SdkResult { - self.get_ln_balance() - } - - #[uniffi::method(name = "withdraw_ln")] - pub fn ffi_withdraw_ln( - &self, - params: LnWithdrawParams, - idempotency_key: Option, - ) -> SdkResult { - self.withdraw_ln(¶ms, idempotency_key) - } -} - -/// Fresh RFC-4122 v4 idempotency key, for callers that want to pin one -/// across a retry loop. -#[uniffi::export(name = "generate_idempotency_key")] -pub fn ffi_generate_idempotency_key() -> SdkResult { - generate_idempotency_key() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn idempotency_keys_are_v4_uuids() { - let key = generate_idempotency_key().unwrap(); - assert_eq!(key.len(), 36); - let parts: Vec<&str> = key.split('-').collect(); - assert_eq!( - parts.iter().map(|p| p.len()).collect::>(), - [8, 4, 4, 4, 12] - ); - assert!(parts[2].starts_with('4')); - assert!(matches!(parts[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b')); - assert!(key.bytes().all(|b| b == b'-' || b.is_ascii_hexdigit())); - } - - #[test] - fn encode_uri_component_matches_javascript() { - assert_eq!(encode_uri_component("rgb:abc-123"), "rgb%3Aabc-123"); - assert_eq!( - encode_uri_component("../v1/ln/balance"), - "..%2Fv1%2Fln%2Fbalance" - ); - assert_eq!(encode_uri_component("a b&c=d?e#f"), "a%20b%26c%3Dd%3Fe%23f"); - assert_eq!(encode_uri_component("-_.!~*'()"), "-_.!~*'()"); - assert_eq!(encode_uri_component("é"), "%C3%A9"); - } - - #[test] - fn request_debug_redacts_credentials() { - let mut headers = HashMap::new(); - headers.insert("authorization".to_owned(), "Bearer sekrit".to_owned()); - headers.insert("x-operator-token".to_owned(), "op-sekrit".to_owned()); - headers.insert("idempotency-key".to_owned(), "k".to_owned()); - let request = HttpRequest { - method: HttpMethod::Post, - url: "http://gw/v1/x".into(), - headers, - body: None, - timeout_ms: 1, - }; - let rendered = format!("{request:?}"); - assert!(!rendered.contains("sekrit"), "{rendered}"); - assert!(rendered.contains("authorization")); - assert!(rendered.contains("[redacted]")); - assert!(rendered.contains("\"k\"")); - } - - #[test] - fn created_user_debug_redacts_the_token() { - let created = CreatedUser { - user_id: "u".into(), - token: "tok-sekrit".into(), - created_at: 1, - }; - let rendered = format!("{created:?}"); - assert!(!rendered.contains("sekrit"), "{rendered}"); - assert!(rendered.contains("[redacted]")); - assert!(rendered.contains("\"u\"")); - } -} diff --git a/minimal-sdk/rust-sdk/src/invoice.rs b/minimal-sdk/rust-sdk/src/invoice.rs deleted file mode 100644 index 6e214f02..00000000 --- a/minimal-sdk/rust-sdk/src/invoice.rs +++ /dev/null @@ -1,608 +0,0 @@ -//! Invoice decoding for intent construction: -//! -//! - **BOLT-11** (payee, amount, payment hash, payment secret, description, -//! expiry): a minimal decoder over the `bech32` crate `bitcoin` already -//! re-exports, plus secp256k1 signature recovery for the payee. -//! - **RGB invoices** in the rgb-invoicing v0.11 grammar -//! `rgb:///?expiry=&endpoints=` -//! (asset id, amount, beneficiary kind, transports). A witness beneficiary is -//! a `wvout:` recipient id, not a plain address. Full RGB validation is -//! explicitly out of scope (design doc): this parses the grammar, it does not -//! validate the contract. -//! -//! Both decoders are fed **user-pasted strings**. Every malformed input is -//! [`SdkError::InvoiceDecode`] and nothing else; no path can panic (asserted -//! in `tests/parity.rs` by truncating and corrupting real invoices at every -//! position). -//! -//! Behavioural reference: `minimal-sdk/packages/client-sdk/src/invoice.ts`. -//! -//! **Why the bech32 checksum is run by hand.** The `bech32` crate's checked -//! decoder enforces the BIP-173 code length (1023 characters), but BOLT-11 -//! explicitly lifts that limit — invoices carrying route hints routinely -//! exceed it. The TypeScript reference decodes with the length limit disabled; -//! this port uses the crate's character/HRP validation and its checksum engine -//! directly, which validates the same polynomial without the length cap. - -use bitcoin::bech32::primitives::checksum::{Checksum, Engine}; -use bitcoin::bech32::primitives::decode::UncheckedHrpstring; -use bitcoin::bech32::{Bech32, Fe32}; -use bitcoin::hashes::{sha256, Hash}; -use bitcoin::hex::DisplayHex; -use bitcoin::secp256k1::ecdsa::{RecoverableSignature, RecoveryId, Signature}; -use bitcoin::secp256k1::{Message, PublicKey}; - -use crate::network::BitcoinNetwork; -use crate::{secp, SdkError, SdkResult}; - -fn invalid(reason: impl Into) -> SdkError { - SdkError::InvoiceDecode { - reason: reason.into(), - } -} - -// --------------------------------------------------------------------------- -// BOLT-11 -// --------------------------------------------------------------------------- - -/// A decoded BOLT-11 invoice: the fields intent construction needs. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct Bolt11Invoice { - pub network: BitcoinNetwork, - /// Invoice amount in millisatoshi; `None` for any-amount invoices. - pub amount_msat: Option, - /// 32-byte payment hash, lowercase hex. - pub payment_hash: String, - /// 32-byte payment secret, lowercase hex (`None` on legacy invoices). - pub payment_secret: Option, - /// Short description, or `None` when only a description hash is present. - pub description: Option, - pub description_hash: Option, - /// Payee node id (33-byte compressed pubkey, lowercase hex), recovered - /// from the signature or taken from the `n` tag after verifying the - /// signature against it. - pub payee_node_id: String, - /// Invoice creation time, unix seconds. - pub timestamp: u64, - /// Seconds until expiry (BOLT-11 default 3600). - pub expiry_seconds: u64, -} - -/// HRP network prefixes, longest-prefix-first where one is a prefix of another -/// (`bcrt` before `bc`, `tbs` before `tb`). -const HRP_NETWORKS: [(&str, BitcoinNetwork); 4] = [ - ("bcrt", BitcoinNetwork::Regtest), - ("tbs", BitcoinNetwork::Signet), - ("bc", BitcoinNetwork::Mainnet), - ("tb", BitcoinNetwork::Testnet), -]; - -const MSAT_PER_BTC: u64 = 100_000_000_000; - -/// BOLT-11 tagged-field types this decoder reads. -const TAG_PAYMENT_HASH: u8 = 1; // p -const TAG_EXPIRY: u8 = 6; // x -const TAG_DESCRIPTION: u8 = 13; // d -const TAG_PAYMENT_SECRET: u8 = 16; // s -const TAG_PAYEE: u8 = 19; // n -const TAG_DESCRIPTION_HASH: u8 = 23; // h - -/// Words in a 65-byte recoverable signature (65 * 8 / 5). -const SIGNATURE_WORDS: usize = 104; -/// Words in the 35-bit timestamp. -const TIMESTAMP_WORDS: usize = 7; - -fn parse_hrp(hrp: &str) -> SdkResult<(BitcoinNetwork, Option)> { - let rest = hrp - .strip_prefix("ln") - .ok_or_else(|| invalid(format!("not a BOLT11 HRP: {hrp}")))?; - let (prefix, network) = HRP_NETWORKS - .iter() - .find(|(prefix, _)| rest.starts_with(prefix)) - .copied() - .ok_or_else(|| invalid(format!("unknown network prefix: {rest}")))?; - let amount_part = &rest[prefix.len()..]; - if amount_part.is_empty() { - return Ok((network, None)); - } - // `^(\d+)([munp])?$` - let (digits, unit) = match amount_part.as_bytes().last() { - Some(b'm' | b'u' | b'n' | b'p') => { - let (d, u) = amount_part.split_at(amount_part.len() - 1); - (d, Some(u)) - } - _ => (amount_part, None), - }; - if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { - return Err(invalid(format!("malformed amount: {amount_part}"))); - } - let value: u64 = digits - .parse() - .map_err(|_| invalid(format!("amount out of range: {amount_part}")))?; - let msat = match unit { - None => value.checked_mul(MSAT_PER_BTC), - Some("m") => value.checked_mul(MSAT_PER_BTC / 1_000), - Some("u") => value.checked_mul(MSAT_PER_BTC / 1_000_000), - Some("n") => value.checked_mul(MSAT_PER_BTC / 1_000_000_000), - Some("p") => { - if !value.is_multiple_of(10) { - return Err(invalid("sub-millisatoshi pico amount")); - } - Some(value / 10) - } - Some(_) => return Err(invalid(format!("malformed amount: {amount_part}"))), - }; - let msat = msat.ok_or_else(|| invalid(format!("amount out of range: {amount_part}")))?; - Ok((network, Some(msat))) -} - -/// Pack 5-bit words into bytes MSB-first, zero-padding the tail (BIP-173). -fn words_to_bytes_padded(words: &[u8]) -> Vec { - let mut bytes = Vec::with_capacity(words.len() * 5 / 8 + 1); - let mut acc: u32 = 0; - let mut bits: u32 = 0; - for &word in words { - acc = (acc << 5) | u32::from(word & 0x1f); - bits += 5; - while bits >= 8 { - bits -= 8; - bytes.push(((acc >> bits) & 0xff) as u8); - } - } - if bits > 0 { - bytes.push(((acc << (8 - bits)) & 0xff) as u8); - } - bytes -} - -/// Pack 5-bit words into bytes, DROPPING trailing pad bits (tagged fields). -fn words_to_bytes_trimmed(words: &[u8]) -> Vec { - let mut bytes = words_to_bytes_padded(words); - bytes.truncate(words.len() * 5 / 8); - bytes -} - -/// Big-endian base-32 integer; `None` on overflow (a hostile tag length). -fn words_to_u64(words: &[u8]) -> Option { - words.iter().try_fold(0u64, |acc, &w| { - acc.checked_mul(32)?.checked_add(u64::from(w & 0x1f)) - }) -} - -/// Bech32 decode with the code-length limit disabled: character, case, HRP -/// and separator validation from the `bech32` crate, checksum from its -/// engine. Returns the lowercase HRP and the 5-bit data words without the -/// checksum. -fn bech32_decode_unlimited(invoice: &str) -> SdkResult<(String, Vec)> { - let lower = invoice.to_lowercase(); - let unchecked = UncheckedHrpstring::new(&lower) - .map_err(|e| invalid(format!("bech32 decode failed: {e}")))?; - let hrp = unchecked.hrp(); - let ascii = unchecked.data_part_ascii(); - if ascii.len() < Bech32::CHECKSUM_LENGTH { - return Err(invalid( - "bech32 decode failed: data part shorter than checksum", - )); - } - let mut engine = Engine::::new(); - engine.input_hrp(hrp); - // Characters were validated by `UncheckedHrpstring::new`. - let words: Vec = ascii - .iter() - .map(|&b| Fe32::from_char_unchecked(b)) - .inspect(|fe| engine.input_fe(*fe)) - .map(Fe32::to_u8) - .collect(); - if *engine.residue() != Bech32::TARGET_RESIDUE { - return Err(invalid("bech32 decode failed: invalid checksum")); - } - let data_len = words.len() - Bech32::CHECKSUM_LENGTH; - let mut words = words; - words.truncate(data_len); - Ok((hrp.to_lowercase(), words)) -} - -/// Decode a BOLT-11 invoice. Malformed input — wrong checksum, truncated -/// data, unknown HRP, bad tag lengths, an unrecoverable signature — is -/// `InvoiceDecode`; nothing here panics on hostile input. -pub fn decode_bolt11(invoice: &str) -> SdkResult { - let (hrp, words) = bech32_decode_unlimited(invoice)?; - let (network, amount_msat) = parse_hrp(&hrp)?; - if words.len() < SIGNATURE_WORDS + TIMESTAMP_WORDS { - return Err(invalid("data part too short")); - } - let (data_words, signature_words) = words.split_at(words.len() - SIGNATURE_WORDS); - let timestamp = words_to_u64(&data_words[..TIMESTAMP_WORDS]) - .ok_or_else(|| invalid("timestamp out of range"))?; - - let mut payment_hash: Option = None; - let mut payment_secret: Option = None; - let mut description: Option = None; - let mut description_hash: Option = None; - let mut payee_from_tag: Option> = None; - let mut expiry_seconds: u64 = 3600; - - let mut cursor = TIMESTAMP_WORDS; - while cursor < data_words.len() { - let tag_type = data_words[cursor]; - let (high, low) = match (data_words.get(cursor + 1), data_words.get(cursor + 2)) { - (Some(h), Some(l)) => (*h, *l), - _ => return Err(invalid("truncated tag")), - }; - let length = usize::from(high) * 32 + usize::from(low); - let start = cursor + 3; - let data = data_words - .get(start..start + length) - .ok_or_else(|| invalid("truncated tag data"))?; - cursor = start + length; - match tag_type { - TAG_PAYMENT_HASH => { - if length == 52 && payment_hash.is_none() { - payment_hash = Some(words_to_bytes_trimmed(data).to_lower_hex_string()); - } - } - TAG_PAYMENT_SECRET => { - if length == 52 && payment_secret.is_none() { - payment_secret = Some(words_to_bytes_trimmed(data).to_lower_hex_string()); - } - } - TAG_DESCRIPTION => { - description = - Some(String::from_utf8_lossy(&words_to_bytes_trimmed(data)).into_owned()); - } - TAG_DESCRIPTION_HASH => { - if length == 52 { - description_hash = Some(words_to_bytes_trimmed(data).to_lower_hex_string()); - } - } - TAG_PAYEE => { - if length == 53 { - payee_from_tag = Some(words_to_bytes_trimmed(data)); - } - } - TAG_EXPIRY => { - expiry_seconds = - words_to_u64(data).ok_or_else(|| invalid("expiry out of range"))?; - } - // Features, route hints, fallback address, min-final-cltv, metadata: - // not needed for intent construction. - _ => {} - } - } - let payment_hash = payment_hash.ok_or_else(|| invalid("missing payment hash"))?; - - let signature = words_to_bytes_trimmed(signature_words); - if signature.len() != 65 { - return Err(invalid("malformed signature")); - } - let recovery_id = signature[64]; - if recovery_id > 3 { - return Err(invalid(format!("invalid recovery id {recovery_id}"))); - } - // BOLT-11: the signature commits to sha256(hrp || data-part bytes), the - // data part packed MSB-first with zero padding. - let mut message = hrp.clone().into_bytes(); - message.extend_from_slice(&words_to_bytes_padded(data_words)); - let digest = Message::from_digest(sha256::Hash::hash(&message).to_byte_array()); - - let payee_node_id = match payee_from_tag { - Some(payee_bytes) => { - let payee = PublicKey::from_slice(&payee_bytes) - .map_err(|_| invalid("malformed payee node id"))?; - let sig = Signature::from_compact(&signature[..64]) - .map_err(|_| invalid("signature recovery failed: malformed signature"))?; - secp() - .verify_ecdsa(&digest, &sig, &payee) - .map_err(|_| invalid("signature does not match payee node id"))?; - payee.serialize().to_lower_hex_string() - } - None => { - let recid = RecoveryId::from_i32(i32::from(recovery_id)) - .map_err(|_| invalid(format!("invalid recovery id {recovery_id}")))?; - let sig = RecoverableSignature::from_compact(&signature[..64], recid) - .map_err(|_| invalid("signature recovery failed: malformed signature"))?; - let recovered = secp() - .recover_ecdsa(&digest, &sig) - .map_err(|_| invalid("signature recovery failed"))?; - recovered.serialize().to_lower_hex_string() - } - }; - - Ok(Bolt11Invoice { - network, - amount_msat, - payment_hash, - payment_secret, - description, - description_hash, - payee_node_id, - timestamp, - expiry_seconds, - }) -} - -// --------------------------------------------------------------------------- -// RGB invoices -// --------------------------------------------------------------------------- - -/// How the RGB beneficiary is paid. -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] -pub enum BeneficiaryKind { - /// `utxob:` — a blinded seal on an existing UTXO of the receiver. - Blind, - /// `wvout:` — paid by a new output of the sender's witness transaction. - Witness, - /// Neither grammar matched; the recipient id is passed through untouched. - Unknown, -} - -/// A decoded RGB invoice (rgb-invoicing v0.11 grammar). -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct RgbInvoice { - /// Full asset id (`rgb:…`), or `None` for asset-agnostic invoices. - pub asset_id: Option, - /// Schema id segment, or `None` when omitted. - pub schema: Option, - /// Fungible amount, or `None` (omitted / non-fungible state). - pub amount: Option, - /// Raw assignment-state segment when present and non-numeric. - pub assignment_raw: Option, - /// Chain-qualified beneficiary — exactly the `recipient_id` the gateway's - /// send-asset prepare expects (e.g. `bcrt:utxob:…` or `bcrt:wvout:…`). - pub recipient_id: String, - pub beneficiary_kind: BeneficiaryKind, - /// Chain prefix of the beneficiary (`bc`, `tb`, `bcrt`, …). - pub chain: Option, - /// Consignment transport endpoints from the `endpoints` query param. - pub transport_endpoints: Vec, - /// Unix-seconds expiry from the `expiry` query param. - pub expiry_timestamp: Option, - pub assignment_name: Option, -} - -/// The rgb-invoicing placeholder for an omitted path segment. -const OMITTED: &str = "~"; - -/// `decodeURIComponent` semantics: `%XX` sequences become bytes and the -/// result must be valid UTF-8; otherwise the value is returned unchanged. -/// `+` is **not** a space. -fn percent_decode(value: &str) -> String { - let bytes = value.as_bytes(); - let mut out = Vec::with_capacity(bytes.len()); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' { - let hex = match bytes.get(i + 1..i + 3) { - Some(h) => h, - None => return value.to_owned(), - }; - // `from_str_radix` would accept a sign (`%+4`); decodeURIComponent - // does not, so require two hex digits explicitly. - let decoded = match std::str::from_utf8(hex) - .ok() - .filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())) - .and_then(|h| u8::from_str_radix(h, 16).ok()) - { - Some(b) => b, - None => return value.to_owned(), - }; - out.push(decoded); - i += 3; - } else { - out.push(bytes[i]); - i += 1; - } - } - String::from_utf8(out).unwrap_or_else(|_| value.to_owned()) -} - -fn is_all_digits(s: &str) -> bool { - !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) -} - -/// `^([a-z0-9]+):(utxob|wvout):(.+)$` -fn parse_beneficiary(seg: &str) -> Option<(String, BeneficiaryKind)> { - let (chain, rest) = seg.split_once(':')?; - if chain.is_empty() - || !chain - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) - { - return None; - } - let (kind, id) = rest.split_once(':')?; - let kind = match kind { - "utxob" => BeneficiaryKind::Blind, - "wvout" => BeneficiaryKind::Witness, - _ => return None, - }; - if id.is_empty() { - return None; - } - Some((chain.to_owned(), kind)) -} - -/// Decode an RGB invoice. The `~` placeholder marks an omitted contract, -/// schema or state segment. Malformed input is `InvoiceDecode`. -pub fn decode_rgb_invoice(invoice: &str) -> SdkResult { - let body = invoice - .strip_prefix("rgb:") - .ok_or_else(|| invalid("missing rgb: scheme"))?; - let (path, query) = match body.split_once('?') { - Some((p, q)) => (p, q), - None => (body, ""), - }; - - let segments: Vec<&str> = path.split('/').collect(); - if segments.len() != 4 { - return Err(invalid(format!( - "expected contract/schema/state/beneficiary, got {} segment(s)", - segments.len() - ))); - } - let (contract_seg, schema_seg, state_seg, beneficiary_seg) = - (segments[0], segments[1], segments[2], segments[3]); - if beneficiary_seg.is_empty() { - return Err(invalid("empty beneficiary")); - } - - let asset_id = (contract_seg != OMITTED).then(|| format!("rgb:{contract_seg}")); - let schema = (schema_seg != OMITTED).then(|| schema_seg.to_owned()); - let mut amount: Option = None; - let mut assignment_raw: Option = None; - if state_seg != OMITTED { - if is_all_digits(state_seg) { - amount = Some( - state_seg - .parse() - .map_err(|_| invalid(format!("amount out of range: {state_seg}")))?, - ); - } else { - assignment_raw = Some(state_seg.to_owned()); - } - } - - let (chain, beneficiary_kind) = match parse_beneficiary(beneficiary_seg) { - Some((chain, kind)) => (Some(chain), kind), - None => (None, BeneficiaryKind::Unknown), - }; - - let mut transport_endpoints: Vec = Vec::new(); - let mut expiry_timestamp: Option = None; - let mut assignment_name: Option = None; - if !query.is_empty() { - for pair in query.split('&') { - let (key, value) = pair - .split_once('=') - .ok_or_else(|| invalid(format!("malformed query parameter: {pair}")))?; - let key = percent_decode(key); - let value = percent_decode(value); - match key.as_str() { - "endpoints" => { - transport_endpoints = value - .split(',') - .filter(|endpoint| !endpoint.is_empty()) - .map(str::to_owned) - .collect(); - } - "expiry" => { - if !is_all_digits(&value) { - return Err(invalid(format!("invalid expiry: {value}"))); - } - expiry_timestamp = Some( - value - .parse() - .map_err(|_| invalid(format!("invalid expiry: {value}")))?, - ); - } - "assignment_name" => assignment_name = Some(value), - _ => {} - } - } - } - - Ok(RgbInvoice { - asset_id, - schema, - amount, - assignment_raw, - recipient_id: beneficiary_seg.to_owned(), - beneficiary_kind, - chain, - transport_endpoints, - expiry_timestamp, - assignment_name, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn hrp_amounts_follow_bolt11_multipliers() { - assert_eq!(parse_hrp("lnbc").unwrap(), (BitcoinNetwork::Mainnet, None)); - assert_eq!( - parse_hrp("lnbc1").unwrap(), - (BitcoinNetwork::Mainnet, Some(MSAT_PER_BTC)) - ); - assert_eq!( - parse_hrp("lnbc2500u").unwrap(), - (BitcoinNetwork::Mainnet, Some(250_000_000)) - ); - assert_eq!( - parse_hrp("lntb20m").unwrap(), - (BitcoinNetwork::Testnet, Some(2_000_000_000)) - ); - assert_eq!( - parse_hrp("lnbcrt10n").unwrap(), - (BitcoinNetwork::Regtest, Some(1_000)) - ); - assert_eq!( - parse_hrp("lntbs250p").unwrap(), - (BitcoinNetwork::Signet, Some(25)) - ); - for bad in [ - "bc", - "lnxx1", - "lnbc25a", - "lnbcm", - "lnbc251p", - "lnbc999999999999999999999", - ] { - assert!( - matches!(parse_hrp(bad), Err(SdkError::InvoiceDecode { .. })), - "{bad}" - ); - } - } - - #[test] - fn word_packing_matches_bip173() { - // 8 words = 40 bits = 5 bytes exactly. - assert_eq!( - words_to_bytes_padded(&[31, 31, 31, 31, 31, 31, 31, 31]), - vec![0xff; 5] - ); - // 7 words = 35 bits: padded gives 5 bytes, trimmed gives 4. - assert_eq!(words_to_bytes_padded(&[0; 7]).len(), 5); - assert_eq!(words_to_bytes_trimmed(&[0; 7]).len(), 4); - assert_eq!(words_to_u64(&[1, 0]), Some(32)); - assert_eq!(words_to_u64(&[31; 13]), None); - } - - #[test] - fn percent_decode_matches_decode_uri_component() { - assert_eq!(percent_decode("a%20b"), "a b"); - assert_eq!(percent_decode("rpc%3A%2F%2Fx"), "rpc://x"); - assert_eq!(percent_decode("a+b"), "a+b"); - // Malformed escapes are returned unchanged, never an error. - assert_eq!(percent_decode("bad%zz"), "bad%zz"); - assert_eq!(percent_decode("trail%2"), "trail%2"); - assert_eq!(percent_decode("%ff"), "%ff"); - // A sign is not a hex digit (u8::from_str_radix would accept it). - assert_eq!(percent_decode("%+4"), "%+4"); - assert_eq!(percent_decode("a%+41"), "a%+41"); - } - - #[test] - fn beneficiary_grammar() { - assert_eq!( - parse_beneficiary("bcrt:wvout:abc"), - Some(("bcrt".into(), BeneficiaryKind::Witness)) - ); - assert_eq!( - parse_beneficiary("bc:utxob:abc"), - Some(("bc".into(), BeneficiaryKind::Blind)) - ); - for bad in [ - "bcrt:other:abc", - "BC:utxob:abc", - ":utxob:abc", - "bc:utxob:", - "bc1q...", - ] { - assert_eq!(parse_beneficiary(bad), None, "{bad}"); - } - } -} diff --git a/minimal-sdk/rust-sdk/src/json.rs b/minimal-sdk/rust-sdk/src/json.rs deleted file mode 100644 index 490637f4..00000000 --- a/minimal-sdk/rust-sdk/src/json.rs +++ /dev/null @@ -1,541 +0,0 @@ -//! Minimal JSON value, parser and serializer for the gateway wire format. -//! -//! Hand-written so the crate keeps its dependency set to `bitcoin`, `bip39`, -//! `uniffi` and `thiserror`: `serde_json` stays a dev-dependency (the plan's -//! dependency-creep guard asserts it never reaches the library graph). -//! -//! Everything parsed here comes from the network and is treated as hostile: -//! the parser never panics, bounds nesting depth, keeps numbers as their raw -//! token (so `as_u64` is an exact integer parse with no float round trip), -//! and reports every malformation as a plain error string. Serialization -//! matches `JSON.stringify` for the value shapes the TypeScript client emits -//! (integers, booleans, strings, arrays, objects; `undefined` fields are -//! simply not emitted). - -use std::fmt::Write as _; - -/// Maximum nesting depth accepted by the parser. The gateway's deepest -/// response (`wallet/balances`) nests four levels; anything past this bound -/// is a hostile or broken body, not a real document. -const MAX_DEPTH: usize = 32; - -/// A JSON document. Objects preserve key order; lookups return the **last** -/// occurrence of a duplicated key, matching `JSON.parse`. -#[derive(Debug, Clone, PartialEq)] -pub enum Json { - Null, - Bool(bool), - /// Raw number token exactly as it appeared (`-12`, `1.5`, `1e3`). - Number(String), - String(String), - Array(Vec), - Object(Vec<(String, Json)>), -} - -impl Json { - /// Object field lookup (`None` for non-objects and missing keys). - pub fn get(&self, key: &str) -> Option<&Json> { - match self { - Json::Object(fields) => fields.iter().rev().find(|(k, _)| k == key).map(|(_, v)| v), - _ => None, - } - } - - pub fn is_null(&self) -> bool { - matches!(self, Json::Null) - } - - pub fn as_str(&self) -> Option<&str> { - match self { - Json::String(s) => Some(s), - _ => None, - } - } - - pub fn as_bool(&self) -> Option { - match self { - Json::Bool(b) => Some(*b), - _ => None, - } - } - - /// Exact non-negative integer. Fractions, exponents and negatives are - /// `None`: every amount on the wire is an integer count of units. - pub fn as_u64(&self) -> Option { - match self { - Json::Number(raw) => raw.parse::().ok(), - _ => None, - } - } - - pub fn as_array(&self) -> Option<&[Json]> { - match self { - Json::Array(items) => Some(items), - _ => None, - } - } - - pub fn as_object(&self) -> Option<&[(String, Json)]> { - match self { - Json::Object(fields) => Some(fields), - _ => None, - } - } - - /// Unsigned integer literal. - pub fn u64(value: u64) -> Json { - Json::Number(value.to_string()) - } - - /// Serialize compactly, exactly as `JSON.stringify` would. - pub fn to_json_string(&self) -> String { - let mut out = String::new(); - self.write_to(&mut out); - out - } - - fn write_to(&self, out: &mut String) { - match self { - Json::Null => out.push_str("null"), - Json::Bool(true) => out.push_str("true"), - Json::Bool(false) => out.push_str("false"), - Json::Number(raw) => out.push_str(raw), - Json::String(s) => write_string(s, out), - Json::Array(items) => { - out.push('['); - for (i, item) in items.iter().enumerate() { - if i > 0 { - out.push(','); - } - item.write_to(out); - } - out.push(']'); - } - Json::Object(fields) => { - out.push('{'); - for (i, (key, value)) in fields.iter().enumerate() { - if i > 0 { - out.push(','); - } - write_string(key, out); - out.push(':'); - value.write_to(out); - } - out.push('}'); - } - } - } -} - -impl From<&str> for Json { - fn from(s: &str) -> Json { - Json::String(s.to_owned()) - } -} - -impl From for Json { - fn from(s: String) -> Json { - Json::String(s) - } -} - -impl From for Json { - fn from(b: bool) -> Json { - Json::Bool(b) - } -} - -impl From for Json { - fn from(n: u64) -> Json { - Json::u64(n) - } -} - -impl From for Json { - fn from(n: u32) -> Json { - Json::u64(u64::from(n)) - } -} - -impl From> for Json { - fn from(items: Vec) -> Json { - Json::Array(items.into_iter().map(Json::String).collect()) - } -} - -/// Builder for request bodies: `undefined` (unset optional) fields are left -/// out, exactly as `JSON.stringify` drops them. -#[derive(Debug, Default)] -pub struct ObjectBuilder { - fields: Vec<(String, Json)>, -} - -impl ObjectBuilder { - pub fn new() -> Self { - Self::default() - } - - pub fn field(mut self, key: &str, value: impl Into) -> Self { - self.fields.push((key.to_owned(), value.into())); - self - } - - /// Emit the field only when the caller set it. - pub fn optional>(self, key: &str, value: Option) -> Self { - match value { - Some(v) => self.field(key, v), - None => self, - } - } - - pub fn build(self) -> Json { - Json::Object(self.fields) - } -} - -fn write_string(s: &str, out: &mut String) { - out.push('"'); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - '\u{8}' => out.push_str("\\b"), - '\u{c}' => out.push_str("\\f"), - c if (c as u32) < 0x20 => { - // `write!` into a String cannot fail. - let _ = write!(out, "\\u{:04x}", c as u32); - } - c => out.push(c), - } - } - out.push('"'); -} - -/// Parse a complete JSON document. Trailing non-whitespace is an error. -pub fn parse(text: &str) -> Result { - let mut parser = Parser { - bytes: text.as_bytes(), - pos: 0, - }; - parser.skip_ws(); - let value = parser.value(0)?; - parser.skip_ws(); - if parser.pos != parser.bytes.len() { - return Err(format!("trailing characters at offset {}", parser.pos)); - } - Ok(value) -} - -struct Parser<'a> { - bytes: &'a [u8], - pos: usize, -} - -impl<'a> Parser<'a> { - fn peek(&self) -> Option { - self.bytes.get(self.pos).copied() - } - - fn skip_ws(&mut self) { - while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) { - self.pos += 1; - } - } - - fn expect_literal(&mut self, literal: &str) -> Result<(), String> { - let end = self.pos + literal.len(); - if self.bytes.get(self.pos..end) == Some(literal.as_bytes()) { - self.pos = end; - Ok(()) - } else { - Err(format!("expected {literal} at offset {}", self.pos)) - } - } - - fn value(&mut self, depth: usize) -> Result { - if depth > MAX_DEPTH { - return Err(format!("nesting deeper than {MAX_DEPTH}")); - } - match self.peek() { - None => Err("unexpected end of input".into()), - Some(b'n') => self.expect_literal("null").map(|_| Json::Null), - Some(b't') => self.expect_literal("true").map(|_| Json::Bool(true)), - Some(b'f') => self.expect_literal("false").map(|_| Json::Bool(false)), - Some(b'"') => self.string().map(Json::String), - Some(b'[') => self.array(depth), - Some(b'{') => self.object(depth), - Some(b'-' | b'0'..=b'9') => self.number(), - Some(other) => Err(format!( - "unexpected byte 0x{other:02x} at offset {}", - self.pos - )), - } - } - - fn number(&mut self) -> Result { - let start = self.pos; - if self.peek() == Some(b'-') { - self.pos += 1; - } - match self.peek() { - Some(b'0') => self.pos += 1, - Some(b'1'..=b'9') => self.digits(), - _ => return Err(format!("malformed number at offset {start}")), - } - if self.peek() == Some(b'.') { - self.pos += 1; - if !matches!(self.peek(), Some(b'0'..=b'9')) { - return Err(format!("malformed number at offset {start}")); - } - self.digits(); - } - if matches!(self.peek(), Some(b'e' | b'E')) { - self.pos += 1; - if matches!(self.peek(), Some(b'+' | b'-')) { - self.pos += 1; - } - if !matches!(self.peek(), Some(b'0'..=b'9')) { - return Err(format!("malformed number at offset {start}")); - } - self.digits(); - } - let raw = std::str::from_utf8(&self.bytes[start..self.pos]) - .map_err(|_| format!("malformed number at offset {start}"))?; - Ok(Json::Number(raw.to_owned())) - } - - fn digits(&mut self) { - while matches!(self.peek(), Some(b'0'..=b'9')) { - self.pos += 1; - } - } - - fn hex4(&mut self) -> Result { - let end = self.pos + 4; - let slice = self - .bytes - .get(self.pos..end) - .ok_or_else(|| "truncated \\u escape".to_string())?; - // `from_str_radix` accepts a leading sign; JSON.parse does not. - if !slice.iter().all(u8::is_ascii_hexdigit) { - return Err("malformed \\u escape".to_string()); - } - let text = std::str::from_utf8(slice).map_err(|_| "malformed \\u escape".to_string())?; - let value = - u32::from_str_radix(text, 16).map_err(|_| "malformed \\u escape".to_string())?; - self.pos = end; - Ok(value) - } - - fn string(&mut self) -> Result { - // Opening quote. - self.pos += 1; - let mut out = String::new(); - loop { - let start = self.pos; - while let Some(b) = self.peek() { - if b == b'"' || b == b'\\' || b < 0x20 { - break; - } - self.pos += 1; - } - let chunk = std::str::from_utf8(&self.bytes[start..self.pos]) - .map_err(|_| "invalid UTF-8 in string".to_string())?; - out.push_str(chunk); - match self.peek() { - None => return Err("unterminated string".into()), - Some(b'"') => { - self.pos += 1; - return Ok(out); - } - Some(b'\\') => { - self.pos += 1; - let escaped = self - .peek() - .ok_or_else(|| "unterminated escape".to_string())?; - self.pos += 1; - match escaped { - b'"' => out.push('"'), - b'\\' => out.push('\\'), - b'/' => out.push('/'), - b'b' => out.push('\u{8}'), - b'f' => out.push('\u{c}'), - b'n' => out.push('\n'), - b'r' => out.push('\r'), - b't' => out.push('\t'), - b'u' => { - let mut code = self.hex4()?; - if (0xD800..0xDC00).contains(&code) { - // High surrogate: a low surrogate must follow. - self.expect_literal("\\u") - .map_err(|_| "unpaired surrogate".to_string())?; - let low = self.hex4()?; - if !(0xDC00..0xE000).contains(&low) { - return Err("unpaired surrogate".into()); - } - code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00); - } - let c = char::from_u32(code) - .ok_or_else(|| "invalid unicode escape".to_string())?; - out.push(c); - } - other => return Err(format!("invalid escape \\{}", other as char)), - } - } - Some(_) => return Err("control character in string".into()), - } - } - } - - fn array(&mut self, depth: usize) -> Result { - self.pos += 1; - let mut items = Vec::new(); - self.skip_ws(); - if self.peek() == Some(b']') { - self.pos += 1; - return Ok(Json::Array(items)); - } - loop { - self.skip_ws(); - items.push(self.value(depth + 1)?); - self.skip_ws(); - match self.peek() { - Some(b',') => self.pos += 1, - Some(b']') => { - self.pos += 1; - return Ok(Json::Array(items)); - } - _ => return Err(format!("expected , or ] at offset {}", self.pos)), - } - } - } - - fn object(&mut self, depth: usize) -> Result { - self.pos += 1; - let mut fields = Vec::new(); - self.skip_ws(); - if self.peek() == Some(b'}') { - self.pos += 1; - return Ok(Json::Object(fields)); - } - loop { - self.skip_ws(); - if self.peek() != Some(b'"') { - return Err(format!("expected object key at offset {}", self.pos)); - } - let key = self.string()?; - self.skip_ws(); - if self.peek() != Some(b':') { - return Err(format!("expected : at offset {}", self.pos)); - } - self.pos += 1; - self.skip_ws(); - let value = self.value(depth + 1)?; - fields.push((key, value)); - self.skip_ws(); - match self.peek() { - Some(b',') => self.pos += 1, - Some(b'}') => { - self.pos += 1; - return Ok(Json::Object(fields)); - } - _ => return Err(format!("expected , or }} at offset {}", self.pos)), - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn round_trips_the_shapes_the_gateway_uses() { - let text = r#"{"a":[1,2,{"b":null}],"c":"x\"y\\z\n\u00e9\ud83d\ude00","d":true,"e":false,"f":-3,"g":1.5e2}"#; - let parsed = parse(text).unwrap(); - assert_eq!(parsed.get("c").and_then(Json::as_str), Some("x\"y\\z\né😀")); - assert_eq!(parsed.get("d").and_then(Json::as_bool), Some(true)); - assert_eq!(parsed.get("f").and_then(Json::as_u64), None); - assert_eq!(parsed.get("g").and_then(Json::as_u64), None); - let a = parsed.get("a").and_then(Json::as_array).unwrap(); - assert_eq!(a[0].as_u64(), Some(1)); - assert!(a[2].get("b").is_some_and(Json::is_null)); - // Serialization is compact and escapes like JSON.stringify. - let again = parse(&parsed.to_json_string()).unwrap(); - assert_eq!(again, parsed); - assert_eq!( - Json::from("tab\t\u{1}").to_json_string(), - "\"tab\\t\\u0001\"" - ); - } - - #[test] - fn duplicate_keys_resolve_to_the_last_like_json_parse() { - let parsed = parse(r#"{"k":1,"k":2}"#).unwrap(); - assert_eq!(parsed.get("k").and_then(Json::as_u64), Some(2)); - } - - #[test] - fn malformed_documents_are_errors_not_panics() { - for bad in [ - "", - " ", - "{", - "}", - "[1,]", - "{\"a\":}", - "{a:1}", - "\"unterminated", - "\"bad\\q\"", - "\"\\ud83d\"", - "\"\\ud83dx\"", - "\"\\u12\"", - "\"\\u+041\"", - "\"\\u-041\"", - "tru", - "nul", - "01", - "-", - "1.", - "1e", - "1 2", - "\u{1}", - "\"\u{1}\"", - &"[".repeat(MAX_DEPTH + 2), - &format!( - "{}1{}", - "[".repeat(MAX_DEPTH + 1), - "]".repeat(MAX_DEPTH + 1) - ), - ] { - assert!(parse(bad).is_err(), "{bad:?} should not parse"); - } - // Depth exactly at the bound still parses. - let ok = format!("{}1{}", "[".repeat(MAX_DEPTH), "]".repeat(MAX_DEPTH)); - assert!(parse(&ok).is_ok()); - } - - #[test] - fn builder_omits_unset_optionals() { - let body = ObjectBuilder::new() - .field("mode", "blind") - .optional("assetId", None::) - .optional("amount", Some(5u64)) - .build(); - assert_eq!(body.to_json_string(), r#"{"mode":"blind","amount":5}"#); - } - - #[test] - fn as_u64_is_an_exact_integer_parse() { - assert_eq!( - Json::Number("18446744073709551615".into()).as_u64(), - Some(u64::MAX) - ); - assert_eq!(Json::Number("18446744073709551616".into()).as_u64(), None); - assert_eq!(Json::Number("1e3".into()).as_u64(), None); - assert_eq!(Json::String("5".into()).as_u64(), None); - } -} diff --git a/minimal-sdk/rust-sdk/src/keys.rs b/minimal-sdk/rust-sdk/src/keys.rs deleted file mode 100644 index cbdcf90d..00000000 --- a/minimal-sdk/rust-sdk/src/keys.rs +++ /dev/null @@ -1,246 +0,0 @@ -//! Key generation and restore, mirroring rgb-lib's derivation exactly -//! (cross-checked against the rgb-lib-generated fixture in -//! `minimal-sdk/packages/client-sdk/test/fixtures/rgblib-parity.json`): -//! BIP-39 seed (empty passphrase) → master → account xprv/xpub at -//! `m/86'/coin'/0'` for the vanilla and colored keychains. -//! -//! The mnemonic is the user's ONLY backup and never leaves the client; only -//! xpubs and the master fingerprint are shared with the gateway (design doc -//! I1/I2). Behavioural reference: `minimal-sdk/packages/client-sdk/src/keys.ts`. -//! -//! Secrets hygiene, enforced here and asserted in `tests/parity.rs`: -//! - [`ClientKeys`] holds the two account xprvs and nothing else: the mnemonic, -//! the seed and the master xprv are wiped and dropped at the end of -//! construction and are not retrievable through any method; the account -//! xprvs are wiped again when the `ClientKeys` is dropped, so key material -//! does not linger in freed memory of a long-lived mobile process; -//! - [`ClientKeys`] has a **manual** `Debug` impl that renders only public -//! material; the same goes for [`GeneratedKeys`]; -//! - no error path formats the words supplied: a bad mnemonic is -//! [`SdkError::InvalidMnemonic`] with no payload. - -use std::fmt; -use std::sync::Arc; - -use bip39::Mnemonic; -use bitcoin::bip32::{ChildNumber, Fingerprint, Xpriv, Xpub}; -use bitcoin::hex::DisplayHex; -use bitcoin::secp256k1::SecretKey; - -use crate::derive::{match_origin_path, ParsedAccounts}; -use crate::network::{account_path, BitcoinNetwork}; -use crate::{secp, SdkError, SdkResult}; - -/// Word count of a freshly generated mnemonic (128 bits of entropy). -const GENERATED_WORD_COUNT: usize = 12; - -/// Public account material — safe to share, used to register with the gateway. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct AccountXpubs { - pub network: BitcoinNetwork, - /// Master key fingerprint, 8 lowercase hex chars. - pub fingerprint: String, - /// Account xpub for the vanilla (BTC) keychain, `m/86'/coin'/0'`. - pub vanilla: String, - /// Account xpub for the colored (RGB) keychain, `m/86'/rgb-coin'/0'`. - pub colored: String, -} - -/// Result of [`ClientKeys::generate`]: the mnemonic the user must back up, -/// and the keys derived from it. This is the **only** place the mnemonic is -/// ever handed back, and only because the caller has to show it once. -#[derive(uniffi::Record)] -pub struct GeneratedKeys { - pub mnemonic: String, - pub keys: Arc, -} - -impl fmt::Debug for GeneratedKeys { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("GeneratedKeys") - .field("mnemonic", &"[redacted]") - .field("keys", &self.keys) - .finish() - } -} - -/// Client-side key material. Holds the account xprvs needed for signing. -#[derive(uniffi::Object)] -pub struct ClientKeys { - network: BitcoinNetwork, - fingerprint: Fingerprint, - vanilla_account: Xpriv, - colored_account: Xpriv, -} - -/// Renders no secret: network, fingerprint and the two account **xpubs**. -impl fmt::Debug for ClientKeys { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ClientKeys") - .field("network", &self.network) - .field("fingerprint", &self.fingerprint_hex()) - .field("vanilla_xpub", &self.vanilla_xpub().to_string()) - .field("colored_xpub", &self.colored_xpub().to_string()) - .finish() - } -} - -impl ClientKeys { - /// Generate a fresh 12-word mnemonic and derive keys from it. - pub fn generate(network: BitcoinNetwork) -> SdkResult { - let mnemonic = - Mnemonic::generate(GENERATED_WORD_COUNT).map_err(|_| SdkError::Internal { - reason: "mnemonic generation failed".into(), - })?; - let keys = Self::from_parsed(&mnemonic, network)?; - Ok(GeneratedKeys { - mnemonic: mnemonic.to_string(), - keys: Arc::new(keys), - }) - } - - /// Restore keys from an existing mnemonic; `InvalidMnemonic` on a bad - /// word, word count or checksum. The error deliberately carries nothing - /// about the words supplied. - pub fn from_mnemonic(mnemonic: &str, network: BitcoinNetwork) -> SdkResult { - let parsed = Mnemonic::parse(mnemonic).map_err(|_| SdkError::InvalidMnemonic)?; - Self::from_parsed(&parsed, network) - } - - fn from_parsed(mnemonic: &Mnemonic, network: BitcoinNetwork) -> SdkResult { - // Empty passphrase, exactly as rgb-lib derives. - let mut seed = mnemonic.to_seed(""); - let result = Self::from_seed(&seed, network); - // Wipe the seed whether or not derivation succeeded; `master` is - // wiped inside `from_seed`. Only the two account xprvs survive. - wipe(&mut seed); - result - } - - fn from_seed(seed: &[u8; 64], network: BitcoinNetwork) -> SdkResult { - let mut master = Xpriv::new_master(network.network_kind(), seed).map_err(|_| { - SdkError::DerivationFailed { - reason: "master key".into(), - } - })?; - let derive = |colored: bool| { - master - .derive_priv(secp(), &account_path(network, colored)) - .map_err(|_| SdkError::DerivationFailed { - reason: format!("account (colored={colored})"), - }) - }; - let keys = ClientKeys { - network, - fingerprint: master.fingerprint(secp()), - vanilla_account: derive(false)?, - colored_account: derive(true)?, - }; - master.private_key.non_secure_erase(); - Ok(keys) - } - - /// Master key fingerprint as raw bytes (PSBT key-origin comparison). - pub fn fingerprint(&self) -> Fingerprint { - self.fingerprint - } - - /// Account xpub for the vanilla (BTC) keychain. - pub fn vanilla_xpub(&self) -> Xpub { - Xpub::from_priv(secp(), &self.vanilla_account) - } - - /// Account xpub for the colored (RGB) keychain. - pub fn colored_xpub(&self) -> Xpub { - Xpub::from_priv(secp(), &self.colored_account) - } - - /// The two account xpubs as parsed keys, ready for - /// [`crate::derive::derive_for_origin_path`]. Public material only. - pub fn accounts(&self) -> ParsedAccounts { - ParsedAccounts { - network: self.network, - vanilla: self.vanilla_xpub(), - colored: self.colored_xpub(), - } - } - - /// Private key for a full master-based path `[86', coin', 0', keychain, - /// index]` as found in PSBT key-origin metadata; `None` when the path is - /// not one of this wallet's two accounts (never errors, never panics — - /// used on adversarial input). - pub fn private_key_for_path(&self, path: &[u32]) -> Option { - // Path acceptance is delegated to `match_origin_path`, the single - // source of truth shared with verify (`derive_for_origin_path`). - // There is deliberately no second path check here: what verify - // proved is exactly what sign derives, and the private key returned - // for an accepted path is the key whose x-only public key verify - // re-derived as the input's internal key (asserted in tests/parity.rs). - let m = match_origin_path(path, self.network)?; - let account = if m.colored { - &self.colored_account - } else { - &self.vanilla_account - }; - let children = [ - ChildNumber::from_normal_idx(m.keychain).ok()?, - ChildNumber::from_normal_idx(m.index).ok()?, - ]; - Some(account.derive_priv(secp(), &children).ok()?.private_key) - } -} - -/// The uniffi surface of [`ClientKeys`]. Only public material crosses the -/// boundary: no method returns a mnemonic, seed, xprv or private key. -#[uniffi::export] -impl ClientKeys { - /// Restore keys from an existing mnemonic (`InvalidMnemonic` on a bad - /// checksum, word or word count). - #[uniffi::constructor(name = "from_mnemonic")] - pub fn ffi_from_mnemonic(mnemonic: String, network: BitcoinNetwork) -> SdkResult> { - Ok(Arc::new(Self::from_mnemonic(&mnemonic, network)?)) - } - - /// Network these keys were derived for. - pub fn network(&self) -> BitcoinNetwork { - self.network - } - - /// Master key fingerprint, 8 lowercase hex chars. - #[uniffi::method(name = "fingerprint")] - pub fn fingerprint_hex(&self) -> String { - self.fingerprint.as_bytes().to_lower_hex_string() - } - - /// Public registration material; never contains private keys. - pub fn xpubs(&self) -> AccountXpubs { - AccountXpubs { - network: self.network, - fingerprint: self.fingerprint_hex(), - vanilla: self.vanilla_xpub().to_string(), - colored: self.colored_xpub().to_string(), - } - } -} - -/// Wipe the account private keys when the keys go away. `non_secure_erase` -/// is secp256k1's best-effort overwrite (volatile writes the optimiser cannot -/// elide); it is not a guarantee against every copy the allocator made. The -/// chain codes are left: with the private key gone they only reproduce the -/// public xpub, which is shared with the gateway anyway. -impl Drop for ClientKeys { - fn drop(&mut self) { - self.vanilla_account.private_key.non_secure_erase(); - self.colored_account.private_key.non_secure_erase(); - } -} - -/// Overwrite a secret buffer with zeros through volatile writes so the -/// store is not optimised away as dead. -fn wipe(bytes: &mut [u8]) { - for b in bytes.iter_mut() { - // SAFETY: `b` is a valid, aligned, exclusive reference for the write. - unsafe { std::ptr::write_volatile(b, 0) }; - } - std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst); -} diff --git a/minimal-sdk/rust-sdk/src/lib.rs b/minimal-sdk/rust-sdk/src/lib.rs deleted file mode 100644 index 88bf4772..00000000 --- a/minimal-sdk/rust-sdk/src/lib.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Minimal UTEXO client SDK core. -//! -//! One Rust implementation of key management, taproot derivation, -//! verify-before-sign PSBT checks, signing, invoice decoding and the gateway -//! client, exposed to Kotlin and Swift through uniffi. This crate is **not** -//! the node: no LDK, no rgb-lib, no HTTP or TLS stack. -//! -//! Behavioural reference: `minimal-sdk/packages/client-sdk/src/` (TypeScript). -//! Parity against rgb-lib is checked in `tests/parity.rs` from the fixture -//! `minimal-sdk/packages/client-sdk/test/fixtures/rgblib-parity.json`. -//! -//! Invariants that hold everywhere in this crate (design doc I1–I4): -//! - the mnemonic and any xprv never leave the device and are never a gateway -//! request field; -//! - only account xpubs and the master fingerprint are ever sent to the gateway; -//! - nothing logs, `Debug`-prints or renders a mnemonic, seed, xprv or private -//! key, including error variants; -//! - no panic crosses the FFI boundary: every exported function returns -//! [`SdkResult`]. - -use std::sync::OnceLock; - -use bitcoin::secp256k1::{All, Secp256k1}; - -uniffi::setup_scaffolding!(); - -pub mod derive; -pub mod ffi; -pub mod gateway; -pub mod invoice; -pub(crate) mod json; -pub mod keys; -pub mod network; -pub mod sign; -pub mod verify; - -pub use derive::{ - derive_for_origin_path, derive_taproot, match_origin_path, parse_account_xpub, DerivedScript, - OriginPathMatch, ParsedAccounts, -}; -pub use ffi::{derive_taproot_address, generate_keys, sdk_version}; -pub use gateway::{ - encode_uri_component, generate_idempotency_key, GatewayClient, HttpMethod, HttpRequest, - HttpResponse, HttpTransport, OperationState, OperationStatus, PreparedOp, -}; -pub use invoice::{decode_bolt11, decode_rgb_invoice, BeneficiaryKind, Bolt11Invoice, RgbInvoice}; -pub use keys::{AccountXpubs, ClientKeys, GeneratedKeys}; -pub use network::BitcoinNetwork; -pub use sign::{verify_and_sign_psbt, SignResult}; -pub use verify::{ - own_derivation, verify_psbt, CheckName, CheckResult, IntentAsset, IntentKind, IntentRecipient, - IntentUtxos, OnchainIntent, TapKeyOrigins, VerifyParams, VerifyVerdict, -}; - -/// Process-wide secp256k1 context (signing + verification). Built once; the -/// precomputed tables are the bulk of the crate's size and are shared. -pub(crate) fn secp() -> &'static Secp256k1 { - static SECP: OnceLock> = OnceLock::new(); - SECP.get_or_init(Secp256k1::new) -} - -/// The single error type every exported function returns. -/// -/// Variants carry only diagnostic strings and status codes. **Never** format a -/// mnemonic, seed, xprv, private key, or raw PSBT/transaction bytes derived from -/// them into a variant: errors are the place secrets leak in practice, and the -/// host app may log them verbatim. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] -pub enum SdkError { - /// A caller-supplied argument was malformed (bad network name, bad hex, ...). - #[error("invalid input: {reason}")] - InvalidInput { reason: String }, - /// The mnemonic did not pass BIP-39 validation. Deliberately carries no - /// detail about the words supplied. - #[error("invalid mnemonic")] - InvalidMnemonic, - /// A BIP-32 / taproot derivation failed (hardened path requested from an - /// xpub, index out of range, ...). - #[error("derivation failed: {reason}")] - DerivationFailed { reason: String }, - /// The PSBT could not be parsed at all. Hostile-but-parseable PSBTs are a - /// failed verification verdict, not an error. - #[error("invalid psbt: {reason}")] - InvalidPsbt { reason: String }, - /// One of the five verify-before-sign checks failed; signing was refused. - #[error("verification failed at check '{check}': {reason}")] - VerificationFailed { check: String, reason: String }, - /// Signing or finalization failed after verification passed. - #[error("signing failed: {reason}")] - SigningFailed { reason: String }, - /// A BOLT-11 or RGB invoice could not be decoded. The only error the - /// invoice decoders return: they are fed user-pasted strings. - #[error("invalid invoice: {reason}")] - InvoiceDecode { reason: String }, - /// The foreign HTTP transport reported a failure (no HTTP status available). - #[error("transport error: {reason}")] - Transport { reason: String }, - /// The gateway answered with a non-success status. `detail` is the - /// server's human-readable message (`{ error: { message } }` in the - /// body). It is deliberately not named `message`: uniffi renders error - /// variants as `kotlin.Exception` subclasses, where a `message` field - /// collides with `Throwable.message` and the generated Kotlin does not - /// compile (found by the Task 8 Android build). - #[error("gateway error {status}: {code}: {detail}")] - Gateway { - status: u16, - code: String, - detail: String, - }, - /// The gateway's returned intent did not match what the client asked for. - #[error("intent mismatch: {reason}")] - IntentMismatch { reason: String }, - /// An internal invariant was violated. Reported instead of panicking so the - /// host app gets an error rather than a crash. - #[error("internal error: {reason}")] - Internal { reason: String }, -} - -/// Result alias every exported function returns. -pub type SdkResult = Result; - -/// A foreign `HttpTransport` that throws anything other than the declared -/// `SdkError` (a Kotlin `RuntimeException`, a Swift `Error` that is not -/// `SdkError`, a failure to lift the error buffer) reaches Rust as -/// `UnexpectedUniFFICallbackError`. Without this conversion uniffi's generic -/// path panics inside `GatewayClient`, which the export boundary re-raises as -/// an untyped internal exception: the one place a host bug would break the -/// "no panic crosses the FFI" invariant. Mapping it to `Transport` keeps the -/// error typed; `reason` is uniffi's own diagnostic and carries no headers. -impl From for SdkError { - fn from(e: uniffi::UnexpectedUniFFICallbackError) -> Self { - SdkError::Transport { - reason: format!("transport raised an unexpected error: {}", e.reason), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn error_display_carries_only_diagnostics() { - let err = SdkError::VerificationFailed { - check: "fee-budget".into(), - reason: "fee 1200 > max 1000".into(), - }; - assert_eq!( - err.to_string(), - "verification failed at check 'fee-budget': fee 1200 > max 1000" - ); - assert_eq!(SdkError::InvalidMnemonic.to_string(), "invalid mnemonic"); - } - - #[test] - fn unexpected_foreign_callback_errors_become_transport_errors() { - let err: SdkError = uniffi::UnexpectedUniFFICallbackError::new("boom").into(); - assert_eq!( - err, - SdkError::Transport { - reason: "transport raised an unexpected error: boom".into() - } - ); - } -} diff --git a/minimal-sdk/rust-sdk/src/network.rs b/minimal-sdk/rust-sdk/src/network.rs deleted file mode 100644 index 6cf30483..00000000 --- a/minimal-sdk/rust-sdk/src/network.rs +++ /dev/null @@ -1,223 +0,0 @@ -//! Network constants mirroring rgb-lib exactly (rgb-lib `src/utils.rs`, -//! `get_coin_type`): BIP-86 purpose, account 0, keychain 0 on both the vanilla -//! (standard coin type) and colored (RGB coin type 827166/827167) sides. -//! -//! Behavioural reference: `minimal-sdk/packages/client-sdk/src/network.ts`. - -use std::fmt; - -use bitcoin::bip32::ChildNumber; -use bitcoin::{Network, NetworkKind}; - -/// BIP-86 purpose (`86'`). -pub const PURPOSE: u32 = 86; -/// Account index (`0'`) for both the vanilla and the colored account. -pub const ACCOUNT: u32 = 0; -/// External keychain (`0`) — the only keychain own *outputs* may use. -pub const KEYCHAIN: u32 = 0; -/// BIP-32 hardened-index threshold. -pub const HARDENED: u32 = 0x8000_0000; - -/// RGB coin type on mainnet (rgb-lib `get_coin_type`). -pub const COIN_RGB_MAINNET: u32 = 827_166; -/// RGB coin type on every non-mainnet network. -pub const COIN_RGB_TESTNET: u32 = 827_167; - -/// BIP-44 coin type for vanilla (BTC) keys on mainnet. -pub const COIN_BTC_MAINNET: u32 = 0; -/// BIP-44 coin type for vanilla (BTC) keys on every non-mainnet network. -pub const COIN_BTC_TESTNET: u32 = 1; - -/// The four networks the gateway and rgb-lib know about. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, uniffi::Enum)] -pub enum BitcoinNetwork { - Mainnet, - Testnet, - Signet, - Regtest, -} - -/// Version bytes for extended key serialization (xprv/xpub vs tprv/tpub). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct HdVersions { - pub private: u32, - pub public: u32, -} - -const MAINNET_VERSIONS: HdVersions = HdVersions { - private: 0x0488_ade4, - public: 0x0488_b21e, -}; -const TESTNET_VERSIONS: HdVersions = HdVersions { - private: 0x0435_8394, - public: 0x0435_87cf, -}; - -impl BitcoinNetwork { - /// All networks, in the order the parity fixture lists them. - pub const ALL: [BitcoinNetwork; 4] = [ - BitcoinNetwork::Mainnet, - BitcoinNetwork::Testnet, - BitcoinNetwork::Signet, - BitcoinNetwork::Regtest, - ]; - - /// Canonical name as used by the gateway API and the parity fixture. - pub fn name(self) -> &'static str { - match self { - BitcoinNetwork::Mainnet => "Mainnet", - BitcoinNetwork::Testnet => "Testnet", - BitcoinNetwork::Signet => "Signet", - BitcoinNetwork::Regtest => "Regtest", - } - } - - /// The `bitcoin` crate network (address encoding, script rules). - pub fn to_bitcoin(self) -> Network { - match self { - BitcoinNetwork::Mainnet => Network::Bitcoin, - BitcoinNetwork::Testnet => Network::Testnet, - BitcoinNetwork::Signet => Network::Signet, - BitcoinNetwork::Regtest => Network::Regtest, - } - } - - /// Main vs Test: selects the xprv/xpub or tprv/tpub serialization. - pub fn network_kind(self) -> NetworkKind { - match self { - BitcoinNetwork::Mainnet => NetworkKind::Main, - _ => NetworkKind::Test, - } - } - - /// Extended-key version bytes for this network. - pub fn hd_versions(self) -> HdVersions { - match self { - BitcoinNetwork::Mainnet => MAINNET_VERSIONS, - _ => TESTNET_VERSIONS, - } - } - - /// Bech32 human-readable part for addresses: `bc` / `tb` / `tb` / `bcrt`. - pub fn bech32_hrp(self) -> &'static str { - match self { - BitcoinNetwork::Mainnet => "bc", - BitcoinNetwork::Testnet | BitcoinNetwork::Signet => "tb", - BitcoinNetwork::Regtest => "bcrt", - } - } - - /// Whether this is mainnet (real funds). - pub fn is_mainnet(self) -> bool { - self == BitcoinNetwork::Mainnet - } -} - -impl fmt::Display for BitcoinNetwork { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.name()) - } -} - -/// BIP-44 coin type: the standard BTC coin type for vanilla keys, the RGB -/// coin type for colored keys. -pub fn coin_type(network: BitcoinNetwork, colored: bool) -> u32 { - match (colored, network.is_mainnet()) { - (true, true) => COIN_RGB_MAINNET, - (true, false) => COIN_RGB_TESTNET, - (false, true) => COIN_BTC_MAINNET, - (false, false) => COIN_BTC_TESTNET, - } -} - -/// Hardened account derivation path children: `[86', coin', 0']`. -pub fn account_path(network: BitcoinNetwork, colored: bool) -> [ChildNumber; 3] { - [ - ChildNumber::Hardened { index: PURPOSE }, - ChildNumber::Hardened { - index: coin_type(network, colored), - }, - ChildNumber::Hardened { index: ACCOUNT }, - ] -} - -/// The same path as raw `u32` children (`HARDENED + x`), the form PSBT -/// key-origin metadata and the TypeScript SDK use. -pub fn account_path_u32(network: BitcoinNetwork, colored: bool) -> [u32; 3] { - account_path(network, colored).map(u32::from) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn coin_types_match_rgb_lib() { - assert_eq!(coin_type(BitcoinNetwork::Mainnet, false), 0); - assert_eq!(coin_type(BitcoinNetwork::Mainnet, true), 827_166); - for net in [ - BitcoinNetwork::Testnet, - BitcoinNetwork::Signet, - BitcoinNetwork::Regtest, - ] { - assert_eq!(coin_type(net, false), 1, "{net}"); - assert_eq!(coin_type(net, true), 827_167, "{net}"); - } - } - - #[test] - fn account_path_is_hardened_86_coin_0() { - assert_eq!( - account_path_u32(BitcoinNetwork::Regtest, false), - [HARDENED + 86, HARDENED + 1, HARDENED] - ); - assert_eq!( - account_path_u32(BitcoinNetwork::Regtest, true), - [HARDENED + 86, HARDENED + 827_167, HARDENED] - ); - assert_eq!( - account_path_u32(BitcoinNetwork::Mainnet, true), - [HARDENED + 86, HARDENED + 827_166, HARDENED] - ); - for child in account_path(BitcoinNetwork::Mainnet, false) { - assert!(child.is_hardened()); - } - assert_eq!( - account_path(BitcoinNetwork::Mainnet, false) - .iter() - .map(ToString::to_string) - .collect::>(), - ["86'", "0'", "0'"] - ); - } - - #[test] - fn versions_hrp_and_kind() { - assert_eq!(BitcoinNetwork::Mainnet.hd_versions().private, 0x0488_ade4); - assert_eq!(BitcoinNetwork::Mainnet.hd_versions().public, 0x0488_b21e); - for net in [ - BitcoinNetwork::Testnet, - BitcoinNetwork::Signet, - BitcoinNetwork::Regtest, - ] { - assert_eq!(net.hd_versions().private, 0x0435_8394); - assert_eq!(net.hd_versions().public, 0x0435_87cf); - assert_eq!(net.network_kind(), NetworkKind::Test); - } - assert_eq!(BitcoinNetwork::Mainnet.network_kind(), NetworkKind::Main); - assert_eq!(BitcoinNetwork::Mainnet.bech32_hrp(), "bc"); - assert_eq!(BitcoinNetwork::Testnet.bech32_hrp(), "tb"); - assert_eq!(BitcoinNetwork::Signet.bech32_hrp(), "tb"); - assert_eq!(BitcoinNetwork::Regtest.bech32_hrp(), "bcrt"); - assert_eq!(BitcoinNetwork::Regtest.to_bitcoin(), Network::Regtest); - } - - #[test] - fn names_are_distinct_and_display_as_themselves() { - let names: Vec<&str> = BitcoinNetwork::ALL.iter().map(|n| n.name()).collect(); - assert_eq!(names, ["Mainnet", "Testnet", "Signet", "Regtest"]); - for net in BitcoinNetwork::ALL { - assert_eq!(net.to_string(), net.name()); - } - } -} diff --git a/minimal-sdk/rust-sdk/src/sign.rs b/minimal-sdk/rust-sdk/src/sign.rs deleted file mode 100644 index 06d95e11..00000000 --- a/minimal-sdk/rust-sdk/src/sign.rs +++ /dev/null @@ -1,298 +0,0 @@ -//! BIP-341 key-path sign + finalize. Refuses to sign unless verify-before-sign -//! passed: verification runs **inside** [`verify_and_sign_psbt`] and a failed -//! verdict is [`SdkError::VerificationFailed`] — there is no bypass. This -//! module exports exactly one function and no flag, second entry point or -//! `pub` helper that signs without verifying (asserted in `tests/parity.rs`). -//! -//! Keys are derived per input from the PSBT's own key-origin paths, but only -//! after [`crate::verify`] check 1 proved each path re-derives to the exact -//! script being spent, and only from the entry [`own_derivation`] selects by -//! our master fingerprint — the same entry check 1 proved. -//! -//! Behavioural reference: `minimal-sdk/packages/client-sdk/src/sign.ts`. -//! Parity with rgb-lib's own signer is **txid + signature validity**, never -//! witness byte-equality: BIP-340 signatures are randomized. - -use bitcoin::hashes::Hash; -use bitcoin::key::{Keypair, TapTweak, XOnlyPublicKey}; -use bitcoin::psbt::Psbt; -use bitcoin::sighash::{Prevouts, SighashCache}; -use bitcoin::taproot; -use bitcoin::{TapSighashType, TxOut, Witness}; - -use crate::keys::ClientKeys; -use crate::verify::{own_derivation, verify_psbt, VerifyParams, VerifyVerdict}; -use crate::{secp, SdkError, SdkResult}; - -/// What [`verify_and_sign_psbt`] returns on success. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct SignResult { - /// Signed AND finalized PSBT, base64 — what the gateway's `complete` - /// expects. - pub signed_psbt: String, - pub txid: String, - /// The passing verdict signing was conditioned on. - pub verdict: VerifyVerdict, -} - -/// Verify the PSBT against the intent, then key-path-sign every input and -/// finalize. -/// -/// Errors, in order of evaluation: -/// - `InvalidInput` when `params.xpubs` are not the xpubs of `keys`: the -/// verdict would then be proven for a different wallet than the one -/// signing, breaking "sign with the entry verify proved". Checked first -/// because it is a caller bug, not a property of the PSBT. -/// - `VerificationFailed` when any of the five checks fails: `check` names -/// the first failed check, `reason` lists every failed check with its -/// detail. Nothing is signed. -/// - `SigningFailed` naming the input index when an input has no signable -/// key-origin entry of ours (never a skip), carries a sighash type other -/// than `DEFAULT`/`ALL` (a `NONE`/`SINGLE`/`ANYONECANPAY` signature would -/// not commit to the outputs the verdict approved), or the produced -/// signature does not verify against the script being spent. -/// -/// No error carries key material; every reason is an input index, a check -/// name or a fixed diagnostic. -pub fn verify_and_sign_psbt( - keys: &ClientKeys, - psbt_base64: &str, - params: &VerifyParams, -) -> SdkResult { - // The verdict is proven against `params.xpubs`; the signature comes - // from `keys`. They must be the same wallet, or check 1's "this path - // re-derives to the spent script" says nothing about the key used below. - if params.xpubs != keys.xpubs() { - return Err(SdkError::InvalidInput { - reason: "params.xpubs are not the xpubs of the signing keys".into(), - }); - } - let verdict = verify_psbt(psbt_base64, params); - if !verdict.ok { - return Err(refusal(&verdict)); - } - - let mut psbt: Psbt = psbt_base64.parse().map_err(|_| SdkError::InvalidPsbt { - reason: "psbt did not re-parse after verification".into(), - })?; - let txid = psbt.unsigned_tx.compute_txid().to_string(); - if verdict.txid.as_deref() != Some(txid.as_str()) { - return Err(SdkError::Internal { - reason: "verdict txid does not match the transaction being signed".into(), - }); - } - - // Check 1 guarantees every input carries a witness_utxo; collect them - // once for the BIP-341 sighash (which commits to all prevouts). - let prevouts: Vec = psbt - .inputs - .iter() - .enumerate() - .map(|(i, input)| { - input.witness_utxo.clone().ok_or(SdkError::SigningFailed { - reason: format!("input {i}: no witnessUtxo"), - }) - }) - .collect::>()?; - let prevouts = Prevouts::All(&prevouts); - let mut cache = SighashCache::new(&psbt.unsigned_tx); - - let mut signatures: Vec = Vec::with_capacity(psbt.inputs.len()); - for (index, input) in psbt.inputs.iter().enumerate() { - // Sign with the SAME entry verify proved re-derives to the script - // being spent — selected by our master fingerprint, not by path - // shape. Matching on shape alone would pick a decoy entry carrying a - // foreign fingerprint when the PSBT lists one first, and signing - // would then fail against a key no check ever covered. - let Some((_, source)) = own_derivation(&input.tap_key_origins, keys.fingerprint()) else { - return Err(SdkError::SigningFailed { - reason: format!("input {index}: no signable key-origin path"), - }); - }; - let Some(secret_key) = keys.private_key_for_path(&source.1.to_u32_vec()) else { - return Err(SdkError::SigningFailed { - reason: format!("input {index}: key-origin path is not under our accounts"), - }); - }; - - // Only DEFAULT (absent) or ALL: anything else would let the - // transaction be altered after the verdict approved it. - let sighash_type = match input.sighash_type { - None => TapSighashType::Default, - Some(ty) => match ty.taproot_hash_ty() { - Ok(TapSighashType::Default) => TapSighashType::Default, - Ok(TapSighashType::All) => TapSighashType::All, - _ => { - return Err(SdkError::SigningFailed { - reason: format!("input {index}: unsupported sighash type {ty}"), - }) - } - }, - }; - let sighash = cache - .taproot_key_spend_signature_hash(index, &prevouts, sighash_type) - .map_err(|_| SdkError::SigningFailed { - reason: format!("input {index}: sighash computation failed"), - })?; - let message = bitcoin::secp256k1::Message::from_digest(sighash.to_byte_array()); - - // BIP-341 key-path spend of a `tr(key)` output with no script tree: - // tweak the derived internal key with an empty merkle root — the - // construction check 1 re-derived the spent script from. - let keypair = Keypair::from_secret_key(secp(), &secret_key) - .tap_tweak(secp(), None) - .to_keypair(); - let signature = secp().sign_schnorr(&message, &keypair); - - // End-to-end proof the signature spends THIS output: verify it - // against the output key inside the spent script, not against - // anything the PSBT metadata claims. - let output_key = spent_output_key(&input.witness_utxo).ok_or(SdkError::SigningFailed { - reason: format!("input {index}: spent script is not a taproot output"), - })?; - if secp() - .verify_schnorr(&signature, &message, &output_key) - .is_err() - { - return Err(SdkError::SigningFailed { - reason: format!( - "input {index}: signature does not verify against the spent script" - ), - }); - } - signatures.push(taproot::Signature { - signature, - sighash_type, - }); - } - - // Signer role: record every key-path signature on its input. - for (input, signature) in psbt.inputs.iter_mut().zip(signatures) { - input.tap_key_sig = Some(signature); - } - // Finalizer role: build the one-element key-path witness from - // tap_key_sig and, as BIP-174 requires, drop the signing metadata the - // final witness supersedes. Only the UTXO fields and the final witness - // stay — exactly what rgb-lib's own finalized fixture PSBT retains. - for (index, input) in psbt.inputs.iter_mut().enumerate() { - let signature = input.tap_key_sig.take().ok_or(SdkError::Internal { - reason: format!("input {index}: signed but no tap_key_sig to finalize"), - })?; - input.final_script_witness = Some(Witness::p2tr_key_spend(&signature)); - input.partial_sigs.clear(); - input.sighash_type = None; - input.redeem_script = None; - input.witness_script = None; - input.bip32_derivation.clear(); - input.tap_script_sigs.clear(); - input.tap_scripts.clear(); - input.tap_key_origins.clear(); - input.tap_internal_key = None; - input.tap_merkle_root = None; - } - - Ok(SignResult { - signed_psbt: psbt.to_string(), - txid, - verdict, - }) -} - -/// The 32-byte output key of a `OP_1 <32 bytes>` script, if that is what -/// the spent output is. -fn spent_output_key(witness_utxo: &Option) -> Option { - let script = witness_utxo.as_ref()?.script_pubkey.as_bytes(); - if !(script.len() == 34 && script[0] == 0x51 && script[1] == 0x20) { - return None; - } - XOnlyPublicKey::from_slice(&script[2..]).ok() -} - -/// The refusal error for a failed verdict: `check` is the first failed -/// check, `reason` lists every failed check with its detail (which carry -/// indexes, amounts, addresses and check names only — never key material). -fn refusal(verdict: &VerifyVerdict) -> SdkError { - let failed: Vec = verdict - .checks - .iter() - .filter(|c| !c.ok) - .map(|c| match &c.detail { - Some(detail) => format!("{} ({detail})", c.check), - None => c.check.to_string(), - }) - .collect(); - SdkError::VerificationFailed { - check: verdict - .first_failure() - .map(|c| c.check.to_string()) - .unwrap_or_default(), - reason: format!("refusing to sign: {}", failed.join("; ")), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::verify::{CheckName, CheckResult}; - - #[test] - fn refusal_names_the_first_failure_and_lists_them_all() { - let verdict = VerifyVerdict { - ok: false, - checks: CheckName::ALL - .iter() - .map(|&check| CheckResult { - check, - ok: !matches!(check, CheckName::RecipientsMatch | CheckName::FeeBudget), - detail: match check { - CheckName::RecipientsMatch => Some("no output pays 39000 sat".into()), - CheckName::FeeBudget => Some("fee 5000 sat outside budget".into()), - _ => None, - }, - }) - .collect(), - fee_sat: Some(5000), - txid: None, - }; - let err = refusal(&verdict); - assert_eq!( - err, - SdkError::VerificationFailed { - check: "recipients-match".into(), - reason: "refusing to sign: recipients-match (no output pays 39000 sat); \ - fee-budget (fee 5000 sat outside budget)" - .into(), - } - ); - } - - #[test] - fn spent_output_key_accepts_only_p2tr_scripts() { - let key = XOnlyPublicKey::from_slice(&[2u8; 32]).unwrap(); - let p2tr = bitcoin::ScriptBuf::new_p2tr_tweaked( - bitcoin::key::TweakedPublicKey::dangerous_assume_tweaked(key), - ); - let utxo = |script: bitcoin::ScriptBuf| { - Some(TxOut { - value: bitcoin::Amount::from_sat(1), - script_pubkey: script, - }) - }; - assert_eq!(spent_output_key(&utxo(p2tr)), Some(key)); - assert_eq!(spent_output_key(&None), None); - assert_eq!(spent_output_key(&utxo(bitcoin::ScriptBuf::new())), None); - // P2WPKH (OP_0 <20 bytes>) and a 33-byte OP_1 push are not taproot. - assert_eq!( - spent_output_key(&utxo(bitcoin::ScriptBuf::from_bytes( - [vec![0x00, 0x14], vec![7u8; 20]].concat() - ))), - None - ); - assert_eq!( - spent_output_key(&utxo(bitcoin::ScriptBuf::from_bytes( - [vec![0x51, 0x21], vec![2u8; 33]].concat() - ))), - None - ); - } -} diff --git a/minimal-sdk/rust-sdk/src/verify.rs b/minimal-sdk/rust-sdk/src/verify.rs deleted file mode 100644 index 1f9c1da3..00000000 --- a/minimal-sdk/rust-sdk/src/verify.rs +++ /dev/null @@ -1,674 +0,0 @@ -//! Verify-before-sign: the design doc's five checks, evaluated against a -//! parsed PSBT and the gateway's machine-readable intent summary. Signing is -//! never blind — `sign` refuses unless the verdict here is ok. -//! -//! All PSBT content (scripts, amounts, key-origin metadata) is treated as -//! adversarial. Ownership is proven by **re-deriving** scripts from the -//! user's own account xpubs: a hostile server may supply any path it likes, -//! but only a path under the user's accounts can re-derive to a script the -//! user controls. [`verify_psbt`] never returns an error and never panics on -//! malformed input — it returns a failed verdict. -//! -//! Known limitation (design doc, "send-time trust"): RGB allocations on the -//! spent inputs are NOT verifiable here; only bitcoin-value movement is. -//! -//! Behavioural reference: `minimal-sdk/packages/client-sdk/src/verify.ts`. -//! Two properties from that source must survive any refactor: -//! -//! 1. [`own_derivation`] selects the key-origin entry **by our master -//! fingerprint, never by path shape**, and sign signs with the very entry -//! verify proved. -//! 2. Own **outputs** are restricted to keychain 0 at a bounded index; own -//! **inputs** are not. The asymmetry is deliberate (see check 3). - -use std::cell::OnceCell; -use std::collections::{BTreeMap, HashSet}; -use std::fmt; -use std::str::FromStr; - -use bitcoin::bip32::{Fingerprint, KeySource}; -use bitcoin::hex::DisplayHex; -use bitcoin::key::XOnlyPublicKey; -use bitcoin::psbt::Psbt; -use bitcoin::taproot::TapLeafHash; -use bitcoin::Address; - -use crate::derive::{derive_for_origin_path, derive_taproot, ParsedAccounts}; -use crate::keys::AccountXpubs; -use crate::network::{BitcoinNetwork, KEYCHAIN}; - -/// Which gateway operation an intent summarises. -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] -pub enum IntentKind { - SendBtc, - SendAsset, - CreateUtxos, -} - -impl IntentKind { - /// The gateway's wire name (`send_btc`, `send_asset`, `create_utxos`). - pub fn name(self) -> &'static str { - match self { - IntentKind::SendBtc => "send_btc", - IntentKind::SendAsset => "send_asset", - IntentKind::CreateUtxos => "create_utxos", - } - } - - /// Inverse of [`IntentKind::name`], for decoding a gateway response. - pub fn from_wire(s: &str) -> Option { - match s { - "send_btc" => Some(IntentKind::SendBtc), - "send_asset" => Some(IntentKind::SendAsset), - "create_utxos" => Some(IntentKind::CreateUtxos), - _ => None, - } - } -} - -impl fmt::Display for IntentKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.name()) - } -} - -/// A bitcoin recipient in the gateway's intent summary. Mirrors the -/// gateway's prepare-response schema (kept in sync). -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct IntentRecipient { - /// Human-readable address — the field the user actually reviews and the - /// one check 2 re-derives the expected script from. - pub address: String, - /// Server-supplied output script, lowercase hex. Only cross-checked - /// against the address; never trusted on its own. - pub script_hex: String, - pub amount_sat: u64, -} - -/// The RGB side of a `send_asset` intent. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct IntentAsset { - pub asset_id: String, - pub amount: u64, - pub recipient_id: String, - /// `Some` for witness (`wvout`) beneficiaries: the exact sat amount the - /// one foreign witness output must carry. `None` for blind sends, where - /// no foreign output is allowed at all. - pub witness_amount_sat: Option, - pub transport_endpoints: Vec, -} - -/// The shape of a `create_utxos` intent. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct IntentUtxos { - pub up_to: bool, - pub num: u32, - pub size: u64, -} - -/// The gateway's machine-readable summary of what a prepared PSBT does. -/// -/// It is SERVER-produced. The caller must bind it to the user's own request -/// first (the gateway client's `prepare*` methods do); check 2 is only as -/// trustworthy as that binding. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct OnchainIntent { - pub kind: IntentKind, - pub fee_rate_sat_per_vb: u64, - pub recipients: Vec, - pub asset: Option, - pub utxos: Option, -} - -/// The five verify-before-sign checks, in evaluation order. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, uniffi::Enum)] -pub enum CheckName { - InputsOwn, - RecipientsMatch, - ChangeOwn, - FeeBudget, - OpretZero, -} - -impl CheckName { - /// All five checks, in the order every verdict lists them. - pub const ALL: [CheckName; 5] = [ - CheckName::InputsOwn, - CheckName::RecipientsMatch, - CheckName::ChangeOwn, - CheckName::FeeBudget, - CheckName::OpretZero, - ]; - - /// The check's wire name, identical to the TypeScript SDK's `CheckName`. - pub fn name(self) -> &'static str { - match self { - CheckName::InputsOwn => "inputs-own", - CheckName::RecipientsMatch => "recipients-match", - CheckName::ChangeOwn => "change-own", - CheckName::FeeBudget => "fee-budget", - CheckName::OpretZero => "opret-zero", - } - } -} - -impl fmt::Display for CheckName { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.name()) - } -} - -/// Outcome of one check. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct CheckResult { - pub check: CheckName, - pub ok: bool, - /// Human-readable reason when `ok` is false. Carries indexes, amounts, - /// addresses and check names only — never key material. - pub detail: Option, -} - -/// The verdict: `ok` only when all five checks pass. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct VerifyVerdict { - pub ok: bool, - /// Exactly five entries, in [`CheckName::ALL`] order. - pub checks: Vec, - /// Absolute fee in sats (`inputs − outputs`), when computable. Negative - /// when the outputs exceed the inputs. - pub fee_sat: Option, - /// Txid of the unsigned transaction, when parseable. - pub txid: Option, -} - -impl VerifyVerdict { - /// The result of one named check. - pub fn check(&self, name: CheckName) -> Option<&CheckResult> { - self.checks.iter().find(|c| c.check == name) - } - - /// Names of the checks that failed, in evaluation order. - pub fn failed(&self) -> Vec { - self.checks - .iter() - .filter(|c| !c.ok) - .map(|c| c.check) - .collect() - } - - /// The first failed check, if any — what a refusal error reports. - pub fn first_failure(&self) -> Option<&CheckResult> { - self.checks.iter().find(|c| !c.ok) - } -} - -/// Everything [`verify_psbt`] needs besides the PSBT itself. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct VerifyParams { - /// Intent summary from the prepare response, already bound to the - /// user's own request. - pub intent: OnchainIntent, - /// The user's own account xpubs + master fingerprint - /// ([`crate::keys::ClientKeys::xpubs`]). - pub xpubs: AccountXpubs, - /// User-approved absolute fee budget in sats (check 4). - pub max_fee_sat: u64, - /// Fallback for own outputs lacking key-origin metadata: accept scripts - /// re-derived at keychain 0, indexes `0..window`, on both accounts. - /// `Some(0)` disables the fallback (metadata-only). `None` = 30. - #[uniffi(default = None)] - pub change_scan_window: Option, - /// Highest derivation index accepted for a metadata-proven own OUTPUT - /// (check 3). Any index under the account xpub re-derives, but only - /// indexes a descriptor wallet will actually scan are recoverable — - /// without this bound a hostile gateway could steer "change" to e.g. - /// index 9e8, where the funds are technically the user's yet invisible - /// to every wallet scan. Kept far above realistic per-user usage while - /// cheap to sweep in a recovery scan. `None` = 10 000. - #[uniffi(default = None)] - pub max_own_output_index: Option, -} - -/// Default for [`VerifyParams::change_scan_window`]. -pub const DEFAULT_CHANGE_SCAN_WINDOW: u32 = 30; -/// Default for [`VerifyParams::max_own_output_index`]. -pub const DEFAULT_MAX_OWN_OUTPUT_INDEX: u32 = 10_000; - -/// The taproot key-origin map a PSBT input or output carries, keyed by -/// x-only public key (so distinct decoy keys are distinct entries). -pub type TapKeyOrigins = BTreeMap, KeySource)>; - -/// First key-origin entry carrying the user's master fingerprint, if any: -/// the x-only key it is filed under and its `(fingerprint, path)` source. -/// -/// Public because sign MUST pick the signing key from the very entry this -/// proved: selecting by path *shape* alone would let a decoy entry with a -/// foreign fingerprint, ordered first, divert signing to a key the verdict -/// never covered. The path inside the returned entry is still -/// attacker-controlled and is only ever accepted through -/// [`crate::derive::match_origin_path`]. -pub fn own_derivation( - origins: &TapKeyOrigins, - fingerprint: Fingerprint, -) -> Option<(XOnlyPublicKey, &KeySource)> { - origins - .iter() - .find(|(_, (_, source))| source.0 == fingerprint) - .map(|(key, (_, source))| (*key, source)) -} - -/// One transaction output as the checks see it. -struct ParsedOutput { - index: usize, - script_hex: String, - amount_sat: u64, - is_opret: bool, - /// Proven ours by re-derivation (metadata path or scan window). - is_own: bool, -} - -/// Output script (lowercase hex) for an address on `network`, or `None` -/// when it does not decode there. -fn script_hex_from_address(address: &str, network: BitcoinNetwork) -> Option { - let parsed = Address::from_str(address).ok()?; - let checked = parsed.require_network(network.to_bitcoin()).ok()?; - Some(checked.script_pubkey().to_hex_string()) -} - -fn fail_all(detail: &str) -> VerifyVerdict { - VerifyVerdict { - ok: false, - checks: CheckName::ALL - .iter() - .map(|&check| CheckResult { - check, - ok: false, - detail: Some(detail.to_owned()), - }) - .collect(), - fee_sat: None, - txid: None, - } -} - -fn result(check: CheckName, failures: Vec) -> CheckResult { - CheckResult { - check, - ok: failures.is_empty(), - detail: if failures.is_empty() { - None - } else { - Some(failures.join("; ")) - }, - } -} - -/// Run the five checks. **Never** returns an error and never panics: a PSBT -/// that cannot be parsed, an xpub or fingerprint that cannot be read, or an -/// internal failure of any kind yields a verdict with all five checks failed -/// and the reason in every `detail`. -pub fn verify_psbt(psbt: &str, params: &VerifyParams) -> VerifyVerdict { - // Belt and braces: every line below is written not to panic on hostile - // input, but a panic here would be a host-app crash, so the whole - // evaluation is fenced as well. - let outcome = - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| verify_parsed(psbt, params))); - match outcome { - Ok(Ok(verdict)) => verdict, - Ok(Err(reason)) => fail_all(&format!("psbt could not be safely parsed: {reason}")), - Err(_) => fail_all("psbt could not be safely parsed: internal failure"), - } -} - -fn verify_parsed(psbt: &str, params: &VerifyParams) -> Result { - let intent = ¶ms.intent; - let network = params.xpubs.network; - let our_fingerprint = Fingerprint::from_str(¶ms.xpubs.fingerprint) - .map_err(|_| "master fingerprint is not 8 hex chars".to_owned())?; - let accounts = ParsedAccounts::parse(¶ms.xpubs).map_err(|e| e.to_string())?; - let psbt = Psbt::from_str(psbt).map_err(|e| e.to_string())?; - let mut checks: Vec = Vec::with_capacity(CheckName::ALL.len()); - - // Check 1 — every input is ours: key-origin fingerprint matches AND the - // path re-derives (from OUR xpubs) to the exact script being spent. - let mut input_failures: Vec = Vec::new(); - let mut input_total_sat: u128 = 0; - for (i, input) in psbt.inputs.iter().enumerate() { - let Some(utxo) = &input.witness_utxo else { - input_failures.push(format!( - "input {i}: no witnessUtxo, cannot verify what is being spent" - )); - continue; - }; - input_total_sat += u128::from(utxo.value.to_sat()); - let Some((_, source)) = own_derivation(&input.tap_key_origins, our_fingerprint) else { - input_failures.push(format!( - "input {i}: no key origin with our fingerprint (foreign input)" - )); - continue; - }; - let Some(derived) = derive_for_origin_path(&source.1.to_u32_vec(), &accounts, network) - else { - input_failures.push(format!( - "input {i}: key-origin path is not under our accounts" - )); - continue; - }; - if derived.script_hex != utxo.script_pubkey.to_hex_string() { - input_failures.push(format!( - "input {i}: spent script does not re-derive from our keys" - )); - continue; - } - if let Some(internal_key) = input.tap_internal_key { - if internal_key.serialize().to_lower_hex_string() != derived.internal_key_hex { - input_failures.push(format!( - "input {i}: tapInternalKey mismatch with re-derived key" - )); - } - } - } - if psbt.inputs.is_empty() { - input_failures.push("transaction has no inputs".to_owned()); - } - checks.push(result(CheckName::InputsOwn, input_failures)); - - // Own-script set for the metadata-less change fallback (keychain 0 only — - // the single keychain rgb-lib uses on both accounts). Built lazily: real - // rgb-lib PSBTs carry key-origin metadata on their change outputs, so the - // common path must not pay for 2 × scan_window key derivations. - let scan_window = params - .change_scan_window - .unwrap_or(DEFAULT_CHANGE_SCAN_WINDOW); - let scanned_own_scripts: OnceCell> = OnceCell::new(); - let is_scanned_own_script = |script_hex: &str| { - scanned_own_scripts - .get_or_init(|| { - let mut set = HashSet::new(); - for account in [&accounts.vanilla, &accounts.colored] { - for index in 0..scan_window { - if let Ok(derived) = derive_taproot(account, KEYCHAIN, index, network) { - set.insert(derived.script_hex); - } - } - } - set - }) - .contains(script_hex) - }; - let max_own_output_index = params - .max_own_output_index - .unwrap_or(DEFAULT_MAX_OWN_OUTPUT_INDEX); - - let mut outputs: Vec = Vec::with_capacity(psbt.outputs.len()); - let mut output_total_sat: u128 = 0; - for (index, (txout, output)) in psbt - .unsigned_tx - .output - .iter() - .zip(psbt.outputs.iter()) - .enumerate() - { - let script_hex = txout.script_pubkey.to_hex_string(); - let amount_sat = txout.value.to_sat(); - output_total_sat += u128::from(amount_sat); - let is_opret = txout.script_pubkey.as_bytes().first() == Some(&0x6a); - let mut is_own = false; - if let Some((_, source)) = own_derivation(&output.tap_key_origins, our_fingerprint) { - // Own OUTPUTS must live in the wallet's real descriptor space: - // keychain 0 (the single keychain rgb-lib scans, `KEYCHAIN`) at a - // bounded index. Inputs (check 1) stay unrestricted — spending - // from an odd path is proven safe by the script re-derivation - // alone. Do not "tidy" this asymmetry. - is_own = derive_for_origin_path(&source.1.to_u32_vec(), &accounts, network) - .is_some_and(|derived| { - derived.script_hex == script_hex - && derived.keychain == KEYCHAIN - && derived.index <= max_own_output_index - }); - } - if !is_own && !is_opret { - is_own = is_scanned_own_script(&script_hex); - } - outputs.push(ParsedOutput { - index, - script_hex, - amount_sat, - is_opret, - is_own, - }); - } - - // Check 2 — recipient outputs match the user-stated intent - // (script + amount), matched as a multiset; plus (witness asset sends) - // exactly one foreign output carrying exactly the approved witness amount. - // - // The expected script is RE-DERIVED from the human-readable address — the - // field the user actually reviews. The server-supplied script_hex is only - // cross-checked: a hostile gateway could otherwise pair the intended - // address with an attacker script and both would "match". - let mut accounted: HashSet = HashSet::new(); - let mut recipient_failures: Vec = Vec::new(); - for recipient in &intent.recipients { - let Some(expected_script_hex) = script_hex_from_address(&recipient.address, network) else { - recipient_failures.push(format!( - "recipient address {} does not decode on {network}", - recipient.address - )); - continue; - }; - if recipient.script_hex.to_lowercase() != expected_script_hex { - recipient_failures.push(format!( - "intent scriptHex does not match recipient address {}", - recipient.address - )); - continue; - } - let matched = outputs.iter().find(|o| { - !accounted.contains(&o.index) - && o.script_hex == expected_script_hex - && o.amount_sat == recipient.amount_sat - }); - match matched { - Some(output) => { - accounted.insert(output.index); - } - None => recipient_failures.push(format!( - "no output pays {} sat to intended recipient {}", - recipient.amount_sat, recipient.address - )), - } - } - if let Some(witness_amount) = intent.asset.as_ref().and_then(|a| a.witness_amount_sat) { - let candidates: Vec = outputs - .iter() - .filter(|o| { - !accounted.contains(&o.index) - && !o.is_own - && !o.is_opret - && o.amount_sat == witness_amount - }) - .map(|o| o.index) - .collect(); - match candidates.as_slice() { - [only] => { - accounted.insert(*only); - } - _ => recipient_failures.push(format!( - "expected exactly one witness output of {witness_amount} sat, found {}", - candidates.len() - )), - } - } - checks.push(result(CheckName::RecipientsMatch, recipient_failures)); - - // Check 3 — change pays only re-derivable own scripts: everything that is - // not an intended recipient, the approved witness output, or an - // OP_RETURN must prove ownership. - let change_failures: Vec = outputs - .iter() - .filter(|o| !accounted.contains(&o.index) && !o.is_opret && !o.is_own) - .map(|o| { - format!( - "output {} ({} sat) does not re-derive from our keys", - o.index, o.amount_sat - ) - }) - .collect(); - checks.push(result(CheckName::ChangeOwn, change_failures)); - - // Check 4 — fee = inputs − outputs within the user-approved budget. - let fee: i128 = input_total_sat as i128 - output_total_sat as i128; - let fee_ok = fee > 0 && fee <= i128::from(params.max_fee_sat); - checks.push(result( - CheckName::FeeBudget, - if fee_ok { - Vec::new() - } else { - vec![format!( - "fee {fee} sat outside budget (0, {}]", - params.max_fee_sat - )] - }, - )); - - // Check 5 — OP_RETURN outputs carry 0 sats. - let opret_failures: Vec = outputs - .iter() - .filter(|o| o.is_opret && o.amount_sat != 0) - .map(|o| format!("OP_RETURN output {} carries {} sat", o.index, o.amount_sat)) - .collect(); - checks.push(result(CheckName::OpretZero, opret_failures)); - - Ok(VerifyVerdict { - ok: checks.iter().all(|c| c.ok), - checks, - fee_sat: i64::try_from(fee).ok(), - txid: Some(psbt.unsigned_tx.compute_txid().to_string()), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use bitcoin::bip32::{ChildNumber, DerivationPath}; - - fn origins(entries: &[(XOnlyPublicKey, Fingerprint, &[u32])]) -> TapKeyOrigins { - entries - .iter() - .map(|(key, fp, path)| { - let path: DerivationPath = path - .iter() - .map(|&c| ChildNumber::from(c)) - .collect::>() - .into(); - (*key, (Vec::new(), (*fp, path))) - }) - .collect() - } - - fn xonly(byte: u8) -> XOnlyPublicKey { - let sk = bitcoin::secp256k1::SecretKey::from_slice(&[byte; 32]).unwrap(); - let (key, _) = sk.x_only_public_key(crate::secp()); - key - } - - #[test] - fn own_derivation_selects_by_fingerprint_not_position_or_shape() { - let ours = Fingerprint::from([0x73, 0xc5, 0xda, 0x0a]); - let theirs = Fingerprint::from([1, 2, 3, 4]); - let our_key = xonly(9); - let decoy_key = xonly(2); - let our_path: &[u32] = &[0x8000_0056, 0x8000_0001, 0x8000_0000, 0, 0]; - // The decoy carries a *well-formed* own-looking path and a key that - // sorts first; only the fingerprint tells them apart. - let map = origins(&[(decoy_key, theirs, our_path), (our_key, ours, our_path)]); - assert!(map.keys().next() == Some(&decoy_key) || map.keys().next() == Some(&our_key)); - let (key, source) = own_derivation(&map, ours).expect("our entry"); - assert_eq!(key, our_key); - assert_eq!(source.0, ours); - assert_eq!(source.1.to_u32_vec(), our_path); - - // Only a foreign entry: nothing, even on a perfect path shape. - let only_decoy = origins(&[(decoy_key, theirs, our_path)]); - assert!(own_derivation(&only_decoy, ours).is_none()); - assert!(own_derivation(&TapKeyOrigins::new(), ours).is_none()); - } - - #[test] - fn check_names_match_the_typescript_sdk() { - let names: Vec<&str> = CheckName::ALL.iter().map(|c| c.name()).collect(); - assert_eq!( - names, - [ - "inputs-own", - "recipients-match", - "change-own", - "fee-budget", - "opret-zero" - ] - ); - let kinds: Vec<&str> = [ - IntentKind::SendBtc, - IntentKind::SendAsset, - IntentKind::CreateUtxos, - ] - .iter() - .map(|k| k.name()) - .collect(); - assert_eq!(kinds, ["send_btc", "send_asset", "create_utxos"]); - } - - #[test] - fn fail_all_lists_every_check_with_the_reason() { - let verdict = fail_all("boom"); - assert!(!verdict.ok); - assert_eq!(verdict.checks.len(), 5); - assert_eq!(verdict.failed(), CheckName::ALL); - assert!(verdict - .checks - .iter() - .all(|c| !c.ok && c.detail.as_deref() == Some("boom"))); - assert_eq!(verdict.fee_sat, None); - assert_eq!(verdict.txid, None); - assert_eq!( - verdict.first_failure().map(|c| c.check), - Some(CheckName::InputsOwn) - ); - } - - #[test] - fn script_hex_from_address_requires_the_network() { - let key = xonly(9); - let script = bitcoin::ScriptBuf::new_p2tr(crate::secp(), key, None).to_hex_string(); - for network in BitcoinNetwork::ALL { - let address = Address::p2tr(crate::secp(), key, None, network.to_bitcoin()).to_string(); - assert_eq!( - script_hex_from_address(&address, network).as_deref(), - Some(script.as_str()), - "{network}" - ); - // Mainnet and regtest addresses are foreign everywhere else; - // testnet and signet share the `tb` prefix, as in bitcoin itself. - let foreign = match network { - BitcoinNetwork::Mainnet => BitcoinNetwork::Testnet, - _ => BitcoinNetwork::Mainnet, - }; - assert!( - script_hex_from_address(&address, foreign).is_none(), - "{network}" - ); - } - for bad in [ - "not-an-address", - "", - "bcrt1p", - "bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", - ] { - assert!( - script_hex_from_address(bad, BitcoinNetwork::Regtest).is_none(), - "{bad:?}" - ); - } - } -} diff --git a/minimal-sdk/rust-sdk/tests/parity.rs b/minimal-sdk/rust-sdk/tests/parity.rs deleted file mode 100644 index b80ea9a2..00000000 --- a/minimal-sdk/rust-sdk/tests/parity.rs +++ /dev/null @@ -1,4986 +0,0 @@ -//! Parity harness, size gate and dependency-creep guard. -//! -//! Parity rule (non-negotiable): the fixture is read **by relative path** from -//! the TypeScript client SDK. It is never copied, regenerated or replaced by a -//! Rust generator. One rgb-lib-authored ground truth, checked by an -//! implementation that shares no code with it. - -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use bitcoin::bip32::{ChildNumber, Xpub}; -use bitcoin::key::XOnlyPublicKey; -use bitcoin::secp256k1::{PublicKey, Secp256k1}; -use bitcoin::{Address, ScriptBuf}; -use utexo_minimal_sdk::network::HARDENED; -use utexo_minimal_sdk::{ - derive_for_origin_path, derive_taproot, derive_taproot_address, match_origin_path, - parse_account_xpub, BitcoinNetwork, ClientKeys, ParsedAccounts, SdkError, -}; - -/// Stripped release cdylib budget. Baseline measured 2026-09-15 on x86_64 for -/// `bitcoin` + `bip39` + uniffi with derive/PSBT/sign reachable: 1.94 MB, of -/// which 1.06 MB is secp256k1 precomputed tables and irreducible. -/// -/// Raised 2026-09-18 from 2_500_000 when `get_onchain_operation` was added: -/// the crate then measured 2 493 352 B, so a single new uniffi route — one -/// `Record`, one `Enum` and the lift/lower scaffolding uniffi generates per -/// field — cost 8 648 B and breached a budget with 0.27% headroom. The move to -/// 2.6 MB buys room for roughly ten more routes; it is NOT licence to add a -/// dependency, which the banned-crate guards below still forbid outright. -const SIZE_BUDGET_BYTES: u64 = 2_600_000; - -/// Crates that must never appear anywhere in the resolved graph, dev or not. -const BANNED_CRATES: &[&str] = &["rgb-lib", "reqwest", "tokio", "rustls", "openssl"]; - -/// Crates allowed as dev-dependencies only; they must not reach the library. -const DEV_ONLY_CRATES: &[&str] = &["serde_json"]; - -fn manifest_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) -} - -fn fixture_path() -> PathBuf { - manifest_dir().join("../packages/client-sdk/test/fixtures/rgblib-parity.json") -} - -fn load_fixture() -> serde_json::Value { - let path = fixture_path(); - let text = fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("cannot read parity fixture {}: {e}", path.display())); - serde_json::from_str(&text) - .unwrap_or_else(|e| panic!("parity fixture {} is not JSON: {e}", path.display())) -} - -fn cargo() -> Command { - Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) -} - -fn target_dir() -> PathBuf { - std::env::var_os("CARGO_TARGET_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| manifest_dir().join("target")) -} - -fn release_cdylib_path() -> PathBuf { - let name = if cfg!(target_os = "macos") { - "libutexo_minimal_sdk.dylib" - } else if cfg!(target_os = "windows") { - "utexo_minimal_sdk.dll" - } else { - "libutexo_minimal_sdk.so" - }; - target_dir().join("release").join(name) -} - -#[test] -fn fixture_is_read_by_relative_path_and_pins_master_fingerprint() { - let path = fixture_path(); - assert!( - path.starts_with(manifest_dir().join("..")), - "fixture must live in the TypeScript client SDK, not in this crate" - ); - assert!( - !manifest_dir() - .join("tests") - .join("rgblib-parity.json") - .exists(), - "do not copy the parity fixture into the Rust crate" - ); - - let fixture = load_fixture(); - let generated_by = fixture["generatedBy"].as_str().unwrap_or_default(); - assert!( - generated_by.contains("@utexo/rgb-lib"), - "fixture must be rgb-lib generated, got generatedBy={generated_by:?}" - ); - let regtest = &fixture["networks"]["Regtest"]; - assert_eq!(regtest["masterFingerprint"].as_str(), Some("73c5da0a")); - for network in ["Regtest", "Testnet", "Signet", "Mainnet"] { - let entry = &fixture["networks"][network]; - assert_eq!( - entry["masterFingerprint"].as_str(), - Some("73c5da0a"), - "master fingerprint is network independent ({network})" - ); - for key in ["accountXpubVanilla", "accountXpubColored"] { - assert!( - entry[key].is_string(), - "{network}.{key} missing from fixture" - ); - } - } -} - -#[test] -fn size_gate_release_cdylib_under_budget() { - let status = cargo() - .args(["build", "--release", "--lib", "--manifest-path"]) - .arg(manifest_dir().join("Cargo.toml")) - .status() - .expect("failed to spawn cargo build --release"); - assert!(status.success(), "release build failed"); - - let path = release_cdylib_path(); - let bytes = fs::metadata(&path) - .unwrap_or_else(|e| panic!("release cdylib missing at {}: {e}", path.display())) - .len(); - println!( - "size gate: {} = {} bytes ({:.2} MB) of {:.2} MB budget", - path.display(), - bytes, - bytes as f64 / 1_000_000.0, - SIZE_BUDGET_BYTES as f64 / 1_000_000.0 - ); - assert!(bytes > 0, "release cdylib is empty"); - assert!( - bytes < SIZE_BUDGET_BYTES, - "release cdylib {bytes} bytes exceeds budget {SIZE_BUDGET_BYTES}; \ - justify the dependency in the progress notes or drop it" - ); -} - -fn lockfile_package_names(lock: &str) -> Vec { - lock.lines() - .filter_map(|line| line.strip_prefix("name = \"")) - .filter_map(|rest| rest.strip_suffix('"')) - .map(str::to_owned) - .collect() -} - -#[test] -fn dependency_creep_guard() { - let lock_path = manifest_dir().join("Cargo.lock"); - let lock = fs::read_to_string(&lock_path).expect("Cargo.lock must be committed"); - let names = lockfile_package_names(&lock); - assert!( - names.iter().any(|n| n == "utexo-minimal-sdk"), - "Cargo.lock does not describe this crate" - ); - for banned in BANNED_CRATES { - assert!( - !names.iter().any(|n| n == banned), - "banned crate `{banned}` is in Cargo.lock (resolved graph)" - ); - } - - // Library graph only (no dev-dependencies): dev-only helpers must not ship. - let output = cargo() - .args([ - "tree", - "-e", - "normal", - "--prefix", - "none", - "--locked", - "--manifest-path", - ]) - .arg(manifest_dir().join("Cargo.toml")) - .output() - .expect("failed to run cargo tree"); - assert!( - output.status.success(), - "cargo tree failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let tree = String::from_utf8_lossy(&output.stdout); - let library_crates: Vec<&str> = tree - .lines() - .filter_map(|line| line.split_whitespace().next()) - .collect(); - assert!( - library_crates.contains(&"utexo-minimal-sdk"), - "cargo tree output did not include the crate:\n{tree}" - ); - for banned in BANNED_CRATES.iter().chain(DEV_ONLY_CRATES) { - assert!( - !library_crates.contains(banned), - "`{banned}` reached the library dependency graph:\n{tree}" - ); - } - println!( - "dependency guard: {} crates in the library graph", - library_crates - .iter() - .collect::>() - .len() - ); -} - -#[test] -fn no_udl_file_exists() { - // Bindings are generated in library mode from proc-macro metadata only. - let src = manifest_dir().join("src"); - let udl: Vec = fs::read_dir(&src) - .expect("src dir") - .filter_map(Result::ok) - .map(|e| e.path()) - .filter(|p| p.extension().is_some_and(|ext| ext == "udl")) - .collect(); - assert!(udl.is_empty(), "UDL files are not allowed: {udl:?}"); - assert!(!Path::new(&manifest_dir().join("src/utexo_minimal_sdk.udl")).exists()); -} - -// --------------------------------------------------------------------------- -// Task 2: keys — BIP-39 → BIP-32 accounts, checked against rgb-lib's output. -// --------------------------------------------------------------------------- - -fn fixture_str<'a>(v: &'a serde_json::Value, what: &str) -> &'a str { - v.as_str() - .unwrap_or_else(|| panic!("fixture field {what} is not a string")) -} - -fn fixture_mnemonic(fixture: &serde_json::Value) -> String { - fixture_str(&fixture["mnemonic"], "mnemonic").to_owned() -} - -/// Secret-looking substrings that must never appear in any rendered form. -fn secret_markers(mnemonic: &str) -> Vec { - let mut markers = vec![mnemonic.to_owned()]; - markers.extend(["xprv", "tprv", "SecretKey", "private_key"].map(str::to_owned)); - markers -} - -/// No secret marker appears as a substring, and no run of two consecutive -/// mnemonic words appears as consecutive tokens. A single word is not -/// evidence: a random BIP-39 word (`network`, `address`, `fee`, ...) is -/// legitimately a Debug field name or diagnostic word, and generated -/// mnemonics made the single-token form of this check flaky. Any real leak -/// renders the phrase, or at least a run of it, in order. -fn assert_no_secret(rendered: &str, mnemonic: &str, what: &str) { - for marker in secret_markers(mnemonic) { - assert!( - !rendered.contains(&marker), - "{what} leaks {marker:?}: {rendered}" - ); - } - let tokens: Vec<&str> = rendered - .split(|c: char| !c.is_ascii_alphanumeric()) - .filter(|t| !t.is_empty()) - .collect(); - let words: Vec<&str> = mnemonic.split_whitespace().collect(); - for pair in words.windows(2) { - assert!( - !tokens.windows(2).any(|t| t == pair), - "{what} leaks mnemonic words {pair:?}: {rendered}" - ); - } -} - -#[test] -fn keys_all_four_networks_match_rgb_lib_fixture_exactly() { - let fixture = load_fixture(); - let mnemonic = fixture_mnemonic(&fixture); - for network in BitcoinNetwork::ALL { - let entry = &fixture["networks"][network.name()]; - assert!(entry.is_object(), "fixture has no entry for {network}"); - let keys = ClientKeys::from_mnemonic(&mnemonic, network) - .unwrap_or_else(|e| panic!("from_mnemonic failed on {network}: {e}")); - let xpubs = keys.xpubs(); - assert_eq!(xpubs.network, network); - assert_eq!( - xpubs.vanilla, - fixture_str(&entry["accountXpubVanilla"], "accountXpubVanilla"), - "{network} vanilla account xpub" - ); - assert_eq!( - xpubs.colored, - fixture_str(&entry["accountXpubColored"], "accountXpubColored"), - "{network} colored account xpub" - ); - assert_eq!( - xpubs.fingerprint, - fixture_str(&entry["masterFingerprint"], "masterFingerprint"), - "{network} master fingerprint" - ); - assert_eq!(keys.fingerprint_hex(), xpubs.fingerprint); - assert_eq!(keys.network(), network); - - // Serialization version bytes are the network's xpub/tpub bytes. - let expected_prefix = if network.is_mainnet() { "xpub" } else { "tpub" }; - for xpub in [&xpubs.vanilla, &xpubs.colored] { - assert!(xpub.starts_with(expected_prefix), "{network}: {xpub}"); - let decoded: Xpub = xpub.parse().expect("our xpub must round-trip"); - let version = u32::from_be_bytes(decoded.encode()[..4].try_into().unwrap()); - assert_eq!(version, network.hd_versions().public, "{network}"); - assert_eq!(decoded.depth, 3, "account xpub is m/86'/coin'/0'"); - assert_eq!( - decoded.child_number, - ChildNumber::Hardened { index: 0 }, - "account child is 0'" - ); - } - } -} - -#[test] -fn keys_fingerprint_is_eight_lowercase_hex_chars() { - let fixture = load_fixture(); - let keys = - ClientKeys::from_mnemonic(&fixture_mnemonic(&fixture), BitcoinNetwork::Regtest).unwrap(); - let fp = keys.fingerprint_hex(); - assert_eq!(fp.len(), 8); - assert!(fp - .chars() - .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); - assert_eq!(fp, "73c5da0a"); -} - -#[test] -fn keys_bad_mnemonics_are_rejected_without_detail() { - let fixture = load_fixture(); - let good = fixture_mnemonic(&fixture); - let words: Vec<&str> = good.split_whitespace().collect(); - // Bad checksum: swap the last word for a valid word that breaks the checksum. - let bad_checksum = [&words[..11], &["abandon"]].concat().join(" "); - // Unknown word, wrong word count, and a 12-word all-valid but wrong-checksum - // phrase with the last word swapped from a different valid phrase. - let unknown_word = [&words[..11], &["notaword"]].concat().join(" "); - let short = words[..11].join(" "); - let long = [&words[..], &["about"]].concat().join(" "); - for (label, bad) in [ - ("bad checksum", bad_checksum), - ("unknown word", unknown_word), - ("11 words", short), - ("13 words", long), - ("empty", String::new()), - ] { - for network in BitcoinNetwork::ALL { - let err = ClientKeys::from_mnemonic(&bad, network) - .err() - .unwrap_or_else(|| panic!("{label} accepted on {network}")); - assert_eq!(err, SdkError::InvalidMnemonic, "{label} on {network}"); - // The error renders nothing about the words supplied. - let rendered = format!("{err} / {err:?}"); - for word in bad.split_whitespace() { - assert!(!rendered.contains(word), "{label}: error leaks {word:?}"); - } - } - } - // Whitespace variations of a valid mnemonic are still valid (BIP-39 - // normalization), as in the TypeScript SDK's validateMnemonic. - let spaced = format!(" {} ", words.join(" ")); - let a = ClientKeys::from_mnemonic(&spaced, BitcoinNetwork::Regtest).unwrap(); - let b = ClientKeys::from_mnemonic(&good, BitcoinNetwork::Regtest).unwrap(); - assert_eq!(a.xpubs(), b.xpubs()); -} - -#[test] -fn keys_other_mnemonic_yields_different_material() { - let fixture = load_fixture(); - let other = fixture_str(&fixture["otherMnemonic"], "otherMnemonic"); - let reference = - ClientKeys::from_mnemonic(&fixture_mnemonic(&fixture), BitcoinNetwork::Regtest).unwrap(); - let foreign = ClientKeys::from_mnemonic(other, BitcoinNetwork::Regtest) - .expect("otherMnemonic is a valid BIP-39 phrase"); - assert_ne!(reference.fingerprint_hex(), foreign.fingerprint_hex()); - assert_ne!(reference.xpubs().vanilla, foreign.xpubs().vanilla); - assert_ne!(reference.xpubs().colored, foreign.xpubs().colored); - // The colored and vanilla accounts are distinct keys, not aliases. - assert_ne!(reference.xpubs().vanilla, reference.xpubs().colored); - // The same mnemonic on mainnet vs regtest: same fingerprint (network - // independent), different xpubs (different coin types + version bytes). - let mainnet = - ClientKeys::from_mnemonic(&fixture_mnemonic(&fixture), BitcoinNetwork::Mainnet).unwrap(); - assert_eq!(mainnet.fingerprint_hex(), reference.fingerprint_hex()); - assert_ne!(mainnet.xpubs().vanilla, reference.xpubs().vanilla); -} - -#[test] -fn keys_generate_round_trips_through_from_mnemonic() { - for network in BitcoinNetwork::ALL { - let generated = ClientKeys::generate(network).expect("generate"); - assert_eq!( - generated.mnemonic.split_whitespace().count(), - 12, - "fresh mnemonics are 12 words" - ); - let restored = ClientKeys::from_mnemonic(&generated.mnemonic, network).unwrap(); - assert_eq!(restored.xpubs(), generated.keys.xpubs()); - assert_eq!(restored.fingerprint_hex(), generated.keys.fingerprint_hex()); - assert_eq!(generated.keys.network(), network); - // Two generations never collide. - let again = ClientKeys::generate(network).unwrap(); - assert_ne!(again.mnemonic, generated.mnemonic); - assert_ne!(again.keys.xpubs(), generated.keys.xpubs()); - } - // The exported free function is the same thing. - let via_ffi = utexo_minimal_sdk::generate_keys(BitcoinNetwork::Regtest).unwrap(); - assert!(ClientKeys::from_mnemonic(&via_ffi.mnemonic, BitcoinNetwork::Regtest).is_ok()); -} - -#[test] -fn keys_debug_and_xpubs_render_no_secret() { - let fixture = load_fixture(); - let mnemonic = fixture_mnemonic(&fixture); - for network in BitcoinNetwork::ALL { - let keys = ClientKeys::from_mnemonic(&mnemonic, network).unwrap(); - let debug = format!("{keys:?}"); - assert_no_secret(&debug, &mnemonic, "ClientKeys Debug"); - assert!(debug.contains("ClientKeys"), "{debug}"); - assert!( - debug.contains("73c5da0a"), - "Debug should still identify the wallet: {debug}" - ); - let pretty = format!("{keys:#?}"); - assert_no_secret(&pretty, &mnemonic, "ClientKeys pretty Debug"); - - let xpubs = keys.xpubs(); - let rendered = format!( - "{xpubs:?} {} {} {}", - xpubs.fingerprint, xpubs.vanilla, xpubs.colored - ); - assert_no_secret(&rendered, &mnemonic, "AccountXpubs"); - } - // GeneratedKeys deliberately holds the mnemonic; its Debug must not print it. - let generated = ClientKeys::generate(BitcoinNetwork::Regtest).unwrap(); - let debug = format!("{generated:?}"); - assert_no_secret(&debug, &generated.mnemonic, "GeneratedKeys Debug"); - assert!(debug.contains("[redacted]"), "{debug}"); -} - -#[test] -fn keys_private_key_for_path_follows_match_origin_path() { - let fixture = load_fixture(); - let network = BitcoinNetwork::Regtest; - let keys = ClientKeys::from_mnemonic(&fixture_mnemonic(&fixture), network).unwrap(); - let secp = Secp256k1::new(); - let h = HARDENED; - - // Own paths under both accounts: the private key's public key equals the - // public derivation from the shared account xpub — proving the xpubs the - // gateway sees and the keys we sign with are the same accounts. - for (colored, coin) in [(false, 1u32), (true, 827_167u32)] { - let xpub: Xpub = if colored { - keys.xpubs().colored.parse().unwrap() - } else { - keys.xpubs().vanilla.parse().unwrap() - }; - for (keychain, index) in [(0u32, 0u32), (0, 4), (1, 9), (0, 123_456)] { - let path = [h + 86, h + coin, h, keychain, index]; - let sk = keys - .private_key_for_path(&path) - .unwrap_or_else(|| panic!("own path {path:?} rejected")); - let expected = xpub - .derive_pub( - &secp, - &[ - ChildNumber::from_normal_idx(keychain).unwrap(), - ChildNumber::from_normal_idx(index).unwrap(), - ], - ) - .unwrap() - .public_key; - assert_eq!(PublicKey::from_secret_key(&secp, &sk), expected, "{path:?}"); - } - } - - // Foreign paths: None, never an error, never a panic. - let foreign: &[&[u32]] = &[ - &[], - &[h + 86, h + 1, h], - &[h + 86, h + 1, h, 0], - &[h + 86, h + 1, h, 0, 0, 0], - &[h + 84, h + 1, h, 0, 0], - &[h + 86, h, h, 0, 0], // mainnet coin on regtest keys - &[h + 86, h + 827_166, h, 0, 0], // mainnet RGB coin on regtest keys - &[h + 86, h + 1, h + 1, 0, 0], // account 1' - &[h + 86, h + 1, h, h, 0], // hardened keychain - &[h + 86, h + 1, h, 0, h], // hardened index - &[u32::MAX; 5], - ]; - for path in foreign { - assert!(keys.private_key_for_path(path).is_none(), "{path:?}"); - } -} - -// --------------------------------------------------------------------------- -// Task 3: taproot derivation and origin-path matching, checked against the -// rgb-lib-generated regtest addresses. -// --------------------------------------------------------------------------- - -fn fixture_addresses(fixture: &serde_json::Value, key: &str) -> Vec { - let list = fixture["regtest"][key] - .as_array() - .unwrap_or_else(|| panic!("fixture regtest.{key} is not an array")); - assert_eq!(list.len(), 5, "fixture regtest.{key} has 5 addresses"); - list.iter() - .enumerate() - .map(|(i, v)| fixture_str(v, &format!("regtest.{key}[{i}]")).to_owned()) - .collect() -} - -fn regtest_accounts(fixture: &serde_json::Value) -> ParsedAccounts { - let entry = &fixture["networks"]["Regtest"]; - ParsedAccounts { - network: BitcoinNetwork::Regtest, - vanilla: parse_account_xpub( - fixture_str(&entry["accountXpubVanilla"], "accountXpubVanilla"), - BitcoinNetwork::Regtest, - ) - .unwrap(), - colored: parse_account_xpub( - fixture_str(&entry["accountXpubColored"], "accountXpubColored"), - BitcoinNetwork::Regtest, - ) - .unwrap(), - } -} - -#[test] -fn derive_reproduces_rgb_lib_regtest_addresses_at_keychain_0_indexes_0_to_4() { - let fixture = load_fixture(); - let network = BitcoinNetwork::Regtest; - let accounts = regtest_accounts(&fixture); - let entry = &fixture["networks"]["Regtest"]; - - for (colored, key) in [(false, "vanillaAddresses"), (true, "coloredAddresses")] { - let expected = fixture_addresses(&fixture, key); - let xpub_str = fixture_str( - &entry[if colored { - "accountXpubColored" - } else { - "accountXpubVanilla" - }], - key, - ); - for (index, address) in expected.iter().enumerate() { - let index = index as u32; - let derived = derive_taproot(accounts.account(colored), 0, index, network) - .unwrap_or_else(|e| panic!("{key}[{index}]: {e}")); - assert_eq!(&derived.address, address, "{key}[{index}] address"); - assert_eq!(derived.keychain, 0); - assert_eq!(derived.index, index); - - // The script is the BIP-341 tr(key) output for that address, the - // internal key is the untweaked x-only key, and both are lowercase hex. - let parsed: Address = address - .parse::>() - .unwrap() - .require_network(network.to_bitcoin()) - .unwrap(); - let script_hex = parsed.script_pubkey().to_hex_string(); - assert_eq!(derived.script_hex, script_hex, "{key}[{index}] script"); - assert!(derived.script_hex.starts_with("5120"), "OP_1 PUSH32"); - assert_eq!(derived.script_hex.len(), 68); - assert_eq!(derived.internal_key_hex.len(), 64); - for hex in [&derived.script_hex, &derived.internal_key_hex] { - assert!(hex - .chars() - .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); - } - let internal: XOnlyPublicKey = derived.internal_key_hex.parse().unwrap(); - let secp = Secp256k1::new(); - assert_eq!( - ScriptBuf::new_p2tr(&secp, internal, None).to_hex_string(), - derived.script_hex, - "internal key tweaks to the output script" - ); - assert_ne!( - &derived.script_hex[4..], - derived.internal_key_hex, - "output key must be the tweaked key, not the raw internal key" - ); - - // The exported FFI function gives the identical answer from the string xpub. - assert_eq!( - derive_taproot_address(xpub_str.to_owned(), 0, index, network).unwrap(), - derived - ); - } - } - - // The vanilla and colored accounts never produce the same address. - let vanilla = fixture_addresses(&fixture, "vanillaAddresses"); - let colored = fixture_addresses(&fixture, "coloredAddresses"); - assert!(vanilla.iter().all(|a| !colored.contains(a))); - // Same accounts, other keychain/index: different, still regtest bech32m. - let other = derive_taproot(&accounts.vanilla, 1, 0, network).unwrap(); - assert!(!vanilla.contains(&other.address)); - assert!(other.address.starts_with("bcrt1p")); -} - -#[test] -fn derive_address_hrp_follows_network() { - let fixture = load_fixture(); - let mnemonic = fixture_mnemonic(&fixture); - for network in BitcoinNetwork::ALL { - let keys = ClientKeys::from_mnemonic(&mnemonic, network).unwrap(); - let derived = derive_taproot(&keys.accounts().vanilla, 0, 0, network).unwrap(); - let prefix = format!("{}1p", network.bech32_hrp()); - assert!( - derived.address.starts_with(&prefix), - "{network}: {}", - derived.address - ); - // Same string xpub through the FFI path; wrong-network xpub is refused. - let xpubs = keys.xpubs(); - assert_eq!( - derive_taproot_address(xpubs.vanilla.clone(), 0, 0, network).unwrap(), - derived - ); - let other = if network.is_mainnet() { - BitcoinNetwork::Regtest - } else { - BitcoinNetwork::Mainnet - }; - assert!(matches!( - derive_taproot_address(xpubs.vanilla.clone(), 0, 0, other), - Err(SdkError::InvalidInput { .. }) - )); - } - // Hardened keychain/index through the FFI surface: an error, never a panic. - let keys = ClientKeys::from_mnemonic(&mnemonic, BitcoinNetwork::Regtest).unwrap(); - let xpub = keys.xpubs().vanilla; - assert!(matches!( - derive_taproot_address(xpub.clone(), HARDENED, 0, BitcoinNetwork::Regtest), - Err(SdkError::DerivationFailed { .. }) - )); - assert!(matches!( - derive_taproot_address(xpub, 0, HARDENED, BitcoinNetwork::Regtest), - Err(SdkError::DerivationFailed { .. }) - )); - assert!(matches!( - derive_taproot_address("garbage".into(), 0, 0, BitcoinNetwork::Regtest), - Err(SdkError::InvalidInput { .. }) - )); -} - -#[test] -fn derive_for_origin_path_re_derives_rgb_lib_addresses_and_rejects_foreign_paths() { - let fixture = load_fixture(); - let network = BitcoinNetwork::Regtest; - let accounts = regtest_accounts(&fixture); - let h = HARDENED; - let vanilla_path = [h + 86, h + 1, h]; - let colored_path = [h + 86, h + 827_167, h]; - let vanilla = fixture_addresses(&fixture, "vanillaAddresses"); - let colored = fixture_addresses(&fixture, "coloredAddresses"); - - for index in 0..5u32 { - let v = derive_for_origin_path( - &[vanilla_path[0], vanilla_path[1], vanilla_path[2], 0, index], - &accounts, - network, - ) - .unwrap(); - assert_eq!(v.address, vanilla[index as usize]); - let c = derive_for_origin_path( - &[colored_path[0], colored_path[1], colored_path[2], 0, index], - &accounts, - network, - ) - .unwrap(); - assert_eq!(c.address, colored[index as usize]); - } - // ClientKeys::accounts() is the same public material. - let keys = ClientKeys::from_mnemonic(&fixture_mnemonic(&fixture), network).unwrap(); - assert_eq!(keys.accounts(), accounts); - - let foreign: &[&[u32]] = &[ - &[], - &[h + 84, h + 1, h, 0, 0], - &[h + 86, h + 1, h, 0], - &[h + 86, h + 1, h, h, 0], - &[h + 86, h + 1, h, 0, h], - &[h + 86, h + 1, h, 0, 0, 0], - &[h + 86, h, h, 0, 0], - &[h + 86, h + 827_166, h, 0, 0], - &[h + 86, h + 1, h + 1, 0, 0], - ]; - for path in foreign { - assert!( - derive_for_origin_path(path, &accounts, network).is_none(), - "{path:?}" - ); - } -} - -#[test] -fn match_origin_path_accepts_both_accounts_and_rejects_everything_else() { - let h = HARDENED; - for network in BitcoinNetwork::ALL { - let (btc, rgb) = if network.is_mainnet() { - (0u32, 827_166u32) - } else { - (1u32, 827_167u32) - }; - let v = match_origin_path(&[h + 86, h + btc, h, 0, 5], network).unwrap(); - assert!(!v.colored); - assert_eq!((v.keychain, v.index), (0, 5)); - let c = match_origin_path(&[h + 86, h + rgb, h, 1, h - 1], network).unwrap(); - assert!(c.colored); - assert_eq!((c.keychain, c.index), (1, h - 1)); - - let rejected: Vec> = vec![ - vec![], // empty - vec![h + 86, h + btc, h], // account only - vec![h + 86, h + btc, h, 0], // short - vec![h + 86, h + btc, h, 0, 0, 0], // over-long - vec![h + 84, h + btc, h, 0, 0], // wrong purpose - vec![86, h + btc, h, 0, 0], // unhardened purpose - vec![h + 86, h + btc + 2, h, 0, 0], // wrong coin - vec![h + 86, btc, h, 0, 0], // unhardened coin - vec![h + 86, h + btc, h + 1, 0, 0], // wrong account - vec![h + 86, h + btc, 0, 0, 0], // unhardened account - vec![h + 86, h + btc, h, h, 0], // hardened keychain - vec![h + 86, h + btc, h, 0, h], // hardened index - vec![h + 86, h + rgb, h, u32::MAX, 0], // hardened keychain (colored) - vec![h + 86, h + rgb, h, 0, u32::MAX], // hardened index (colored) - vec![u32::MAX; 5], - ]; - for path in &rejected { - assert_eq!(match_origin_path(path, network), None, "{network} {path:?}"); - } - // The other network's coin types are foreign here. - let (obtc, orgb) = if network.is_mainnet() { - (1, 827_167) - } else { - (0, 827_166) - }; - assert_eq!( - match_origin_path(&[h + 86, h + obtc, h, 0, 0], network), - None - ); - assert_eq!( - match_origin_path(&[h + 86, h + orgb, h, 0, 0], network), - None - ); - } -} - -#[test] -fn private_key_for_path_signs_exactly_what_verify_re_derives() { - // The property the single-source-of-truth design exists for: for every - // path, `private_key_for_path` returns a key iff `match_origin_path` - // accepts it, and that key's x-only public key is precisely the internal - // key `derive_for_origin_path` (verify's view) re-derives for the path. - let fixture = load_fixture(); - let mnemonic = fixture_mnemonic(&fixture); - let secp = Secp256k1::new(); - let h = HARDENED; - for network in BitcoinNetwork::ALL { - let keys = ClientKeys::from_mnemonic(&mnemonic, network).unwrap(); - let accounts = keys.accounts(); - let (btc, rgb) = if network.is_mainnet() { - (0u32, 827_166u32) - } else { - (1u32, 827_167u32) - }; - let mut paths: Vec> = vec![ - vec![], - vec![h + 86, h + btc, h], - vec![h + 86, h + btc, h, 0], - vec![h + 86, h + btc, h, 0, 0, 0], - vec![h + 84, h + btc, h, 0, 0], - vec![h + 86, h + btc, h + 1, 0, 0], - vec![h + 86, h + btc, h, h, 0], - vec![h + 86, h + btc, h, 0, h], - vec![h + 86, h + btc + 2, h, 0, 0], - ]; - for coin in [btc, rgb] { - for (keychain, index) in [(0, 0), (0, 4), (1, 0), (1, 7), (9, 123_456), (0, h - 1)] { - paths.push(vec![h + 86, h + coin, h, keychain, index]); - } - } - let mut accepted = 0; - for path in &paths { - let matched = match_origin_path(path, network); - let sk = keys.private_key_for_path(path); - let derived = derive_for_origin_path(path, &accounts, network); - assert_eq!(matched.is_some(), sk.is_some(), "{network} {path:?}"); - assert_eq!(matched.is_some(), derived.is_some(), "{network} {path:?}"); - if let (Some(sk), Some(derived)) = (sk, derived) { - accepted += 1; - let (xonly, _) = PublicKey::from_secret_key(&secp, &sk).x_only_public_key(); - assert_eq!( - xonly.serialize().to_vec(), - hex_decode(&derived.internal_key_hex), - "{network} {path:?}: signing key != verified internal key" - ); - let m = matched.unwrap(); - assert_eq!((m.keychain, m.index), (derived.keychain, derived.index)); - } - } - assert_eq!(accepted, 12, "{network}: 6 own paths per account"); - } -} - -fn hex_decode(s: &str) -> Vec { - (0..s.len()) - .step_by(2) - .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) - .collect() -} - -// --------------------------------------------------------------------------- -// Task 4: verify-before-sign — the five checks, against the rgb-lib fixture -// PSBT and adversarial PSBTs built here. Each attack must trip exactly the -// check that owns it; hostile bytes must never panic or error. -// --------------------------------------------------------------------------- - -mod psbt_builder { - //! A PSBT builder with full control over scripts and key-origin metadata, - //! mirroring `minimal-sdk/packages/client-sdk/test/helpers.ts`. Metadata - //! is attacker-controlled in the threat model, so this deliberately lets - //! a test attach any fingerprint/path/key to any input or output. - - use bitcoin::absolute::LockTime; - use bitcoin::bip32::{ChildNumber, DerivationPath, Fingerprint}; - use bitcoin::hashes::Hash; - use bitcoin::key::XOnlyPublicKey; - use bitcoin::psbt::Psbt; - use bitcoin::transaction::Version; - use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; - use utexo_minimal_sdk::network::HARDENED; - use utexo_minimal_sdk::{derive_taproot, BitcoinNetwork, ClientKeys}; - - pub const NETWORK: BitcoinNetwork = BitcoinNetwork::Regtest; - /// Vanilla-account path children on regtest: m/86'/1'/0'. - pub const VANILLA_PATH: [u32; 3] = [HARDENED + 86, HARDENED + 1, HARDENED]; - /// Colored-account path children on regtest: m/86'/827167'/0'. - pub const COLORED_PATH: [u32; 3] = [HARDENED + 86, HARDENED + 827_167, HARDENED]; - - /// Standard zero-value OP_RETURN output used across cases. - pub fn opret_script() -> ScriptBuf { - ScriptBuf::from_bytes(vec![0x6a, 0x05, 1, 2, 3, 4, 5]) - } - - /// A test wallet: the script and internal key at any keychain/index of - /// either account, plus the master fingerprint. - pub struct TestWallet { - pub keys: ClientKeys, - } - - impl TestWallet { - pub fn from_mnemonic(mnemonic: &str) -> Self { - TestWallet { - keys: ClientKeys::from_mnemonic(mnemonic, NETWORK).expect("fixture mnemonic"), - } - } - - pub fn fingerprint(&self) -> Fingerprint { - self.keys.fingerprint() - } - - pub fn script_at(&self, colored: bool, keychain: u32, index: u32) -> ScriptBuf { - let derived = derive_taproot( - self.keys.accounts().account(colored), - keychain, - index, - NETWORK, - ) - .expect("unhardened derivation"); - ScriptBuf::from_hex(&derived.script_hex).unwrap() - } - - pub fn address_at(&self, colored: bool, keychain: u32, index: u32) -> String { - derive_taproot( - self.keys.accounts().account(colored), - keychain, - index, - NETWORK, - ) - .expect("unhardened derivation") - .address - } - - pub fn xonly_at(&self, colored: bool, keychain: u32, index: u32) -> XOnlyPublicKey { - let derived = derive_taproot( - self.keys.accounts().account(colored), - keychain, - index, - NETWORK, - ) - .expect("unhardened derivation"); - derived.internal_key_hex.parse().unwrap() - } - - /// Key-origin metadata claiming this wallet owns `keychain/index`. - pub fn origin(&self, colored: bool, keychain: u32, index: u32) -> KeyOrigin { - let account = if colored { COLORED_PATH } else { VANILLA_PATH }; - KeyOrigin { - fingerprint: self.fingerprint(), - path: [account[0], account[1], account[2], keychain, index].to_vec(), - xonly: self.xonly_at(colored, keychain, index), - } - } - } - - #[derive(Clone)] - pub struct KeyOrigin { - pub fingerprint: Fingerprint, - pub path: Vec, - pub xonly: XOnlyPublicKey, - } - - impl KeyOrigin { - pub fn derivation_path(&self) -> DerivationPath { - self.path - .iter() - .map(|&c| ChildNumber::from(c)) - .collect::>() - .into() - } - } - - pub struct InputSpec { - /// Script actually being spent. - pub script: ScriptBuf, - pub amount: u64, - /// Key-origin entries to attach, decoys included (the map is keyed - /// by x-only key, so a decoy must carry a distinct key). - pub origins: Vec, - pub tap_internal_key: Option, - } - - impl InputSpec { - pub fn new(script: ScriptBuf, amount: u64, origin: Option) -> Self { - InputSpec { - script, - amount, - origins: origin.into_iter().collect(), - tap_internal_key: None, - } - } - } - - pub struct OutputSpec { - pub script: ScriptBuf, - pub amount: u64, - pub origin: Option, - } - - impl OutputSpec { - pub fn new(script: ScriptBuf, amount: u64, origin: Option) -> Self { - OutputSpec { - script, - amount, - origin, - } - } - } - - /// Build a base64 PSBT: v2 transaction, synthetic outpoints, and exactly - /// the metadata each spec asks for. No sanity checks — hostile-server - /// emulation. - pub fn build_psbt(inputs: &[InputSpec], outputs: &[OutputSpec]) -> String { - build_psbt_raw(inputs, outputs).to_string() - } - - pub fn build_psbt_raw(inputs: &[InputSpec], outputs: &[OutputSpec]) -> Psbt { - let tx = Transaction { - version: Version::TWO, - lock_time: LockTime::ZERO, - input: inputs - .iter() - .enumerate() - .map(|(i, _)| TxIn { - previous_output: OutPoint { - txid: Txid::from_byte_array([(i + 1) as u8; 32]), - vout: i as u32, - }, - script_sig: ScriptBuf::new(), - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - witness: Witness::new(), - }) - .collect(), - output: outputs - .iter() - .map(|o| TxOut { - value: Amount::from_sat(o.amount), - script_pubkey: o.script.clone(), - }) - .collect(), - }; - let mut psbt = Psbt::from_unsigned_tx(tx).expect("unsigned tx"); - for (spec, input) in inputs.iter().zip(psbt.inputs.iter_mut()) { - input.witness_utxo = Some(TxOut { - value: Amount::from_sat(spec.amount), - script_pubkey: spec.script.clone(), - }); - input.tap_internal_key = spec.tap_internal_key; - for origin in &spec.origins { - input.tap_key_origins.insert( - origin.xonly, - (Vec::new(), (origin.fingerprint, origin.derivation_path())), - ); - } - } - for (spec, output) in outputs.iter().zip(psbt.outputs.iter_mut()) { - if let Some(origin) = &spec.origin { - output.tap_internal_key = Some(origin.xonly); - output.tap_key_origins.insert( - origin.xonly, - (Vec::new(), (origin.fingerprint, origin.derivation_path())), - ); - } - } - psbt - } -} - -use bitcoin::psbt::Psbt; -use psbt_builder::{ - build_psbt, build_psbt_raw, opret_script, InputSpec, KeyOrigin, OutputSpec, TestWallet, -}; -use utexo_minimal_sdk::{ - own_derivation, verify_psbt, CheckName, IntentAsset, IntentKind, IntentRecipient, IntentUtxos, - OnchainIntent, VerifyParams, VerifyVerdict, -}; - -struct Wallets { - ours: TestWallet, - foreign: TestWallet, - fixture: serde_json::Value, -} - -fn wallets() -> Wallets { - let fixture = load_fixture(); - let ours = TestWallet::from_mnemonic(&fixture_mnemonic(&fixture)); - let foreign = - TestWallet::from_mnemonic(fixture_str(&fixture["otherMnemonic"], "otherMnemonic")); - assert_ne!(ours.fingerprint(), foreign.fingerprint()); - Wallets { - ours, - foreign, - fixture, - } -} - -/// The intent the rgb-lib fixture PSBT was prepared for: 40 000 sat to the -/// foreign wallet's vanilla address 0 (`test/sign.test.ts`). -fn send_btc_intent(w: &Wallets) -> OnchainIntent { - OnchainIntent { - kind: IntentKind::SendBtc, - fee_rate_sat_per_vb: 2, - recipients: vec![IntentRecipient { - address: w.foreign.address_at(false, 0, 0), - script_hex: w.foreign.script_at(false, 0, 0).to_hex_string(), - amount_sat: 40_000, - }], - asset: None, - utxos: None, - } -} - -fn params(w: &Wallets, intent: OnchainIntent, max_fee_sat: u64) -> VerifyParams { - VerifyParams { - intent, - xpubs: w.ours.keys.xpubs(), - max_fee_sat, - change_scan_window: None, - max_own_output_index: None, - } -} - -fn check(verdict: &VerifyVerdict, name: CheckName) -> &utexo_minimal_sdk::CheckResult { - verdict - .check(name) - .unwrap_or_else(|| panic!("verdict has no {name} check: {verdict:?}")) -} - -fn detail(verdict: &VerifyVerdict, name: CheckName) -> String { - check(verdict, name).detail.clone().unwrap_or_default() -} - -/// Assert exactly `expected` failed, every other check passed, and the -/// verdict is not ok (unless `expected` is empty). -fn assert_only_fails(verdict: &VerifyVerdict, expected: &[CheckName]) { - assert_eq!(verdict.checks.len(), 5, "{verdict:?}"); - assert_eq!( - verdict.checks.iter().map(|c| c.check).collect::>(), - CheckName::ALL, - "checks are always listed in evaluation order" - ); - assert_eq!(verdict.failed(), expected, "{verdict:#?}"); - assert_eq!(verdict.ok, expected.is_empty(), "{verdict:#?}"); - for c in &verdict.checks { - assert_eq!(c.detail.is_some(), !c.ok, "detail iff failed: {c:?}"); - } -} - -fn assert_all_pass(verdict: &VerifyVerdict) { - assert_only_fails(verdict, &[]); -} - -struct Happy { - change_script: Option, - change_origin: Option>, - change_amount: u64, - opret_amount: u64, - extra_outputs: Vec, - input_origins: Option>, - input_internal_key: Option, -} - -impl Happy { - fn default() -> Self { - Happy { - change_script: None, - change_origin: None, - change_amount: 59_000, - opret_amount: 0, - extra_outputs: Vec::new(), - input_origins: None, - input_internal_key: None, - } - } -} - -/// Happy-path shape, same as `test/verify.test.ts` `happyPsbt`: own input -/// 100 000 sat at vanilla 0/0, intended recipient 40 000, own change 59 000 -/// at vanilla 0/1, 0-sat OP_RETURN. Fee 1 000. -fn happy_psbt(w: &Wallets, h: Happy) -> String { - let mut input = InputSpec::new( - w.ours.script_at(false, 0, 0), - 100_000, - Some(w.ours.origin(false, 0, 0)), - ); - if let Some(origins) = h.input_origins { - input.origins = origins; - } - input.tap_internal_key = h.input_internal_key; - let mut outputs = vec![ - OutputSpec::new(w.foreign.script_at(false, 0, 0), 40_000, None), - OutputSpec::new( - h.change_script - .unwrap_or_else(|| w.ours.script_at(false, 0, 1)), - h.change_amount, - h.change_origin - .unwrap_or_else(|| Some(w.ours.origin(false, 0, 1))), - ), - OutputSpec::new(opret_script(), h.opret_amount, None), - ]; - outputs.extend(h.extra_outputs); - build_psbt(&[input], &outputs) -} - -#[test] -fn psbt_deserialize_preserves_every_tap_key_origin_entry_including_decoys() { - // The decoy defence depends on `tap_key_origins` surviving a round trip - // with a foreign-fingerprint entry alongside ours on the same input, and - // on outputs. Proved rather than assumed. - let w = wallets(); - let ours = w.ours.origin(false, 0, 0); - let decoy = w.foreign.origin(false, 0, 1); - let decoy2 = w.foreign.origin(true, 0, 0); - let mut input = InputSpec::new(w.ours.script_at(false, 0, 0), 100_000, None); - input.origins = vec![decoy.clone(), ours.clone(), decoy2.clone()]; - input.tap_internal_key = Some(ours.xonly); - let psbt_b64 = build_psbt( - &[input], - &[OutputSpec::new( - w.ours.script_at(false, 0, 1), - 99_000, - Some(w.ours.origin(false, 0, 1)), - )], - ); - - let parsed: Psbt = psbt_b64.parse().expect("round trip"); - let origins = &parsed.inputs[0].tap_key_origins; - assert_eq!(origins.len(), 3, "all three entries survive: {origins:?}"); - for entry in [&decoy, &ours, &decoy2] { - let (_, (fp, path)) = origins - .get(&entry.xonly) - .unwrap_or_else(|| panic!("entry {} missing", entry.xonly)); - assert_eq!(*fp, entry.fingerprint); - assert_eq!(path.to_u32_vec(), entry.path); - } - assert_eq!(parsed.inputs[0].tap_internal_key, Some(ours.xonly)); - assert_eq!( - parsed.inputs[0] - .witness_utxo - .as_ref() - .map(|u| u.value.to_sat()), - Some(100_000) - ); - assert_eq!(parsed.outputs[0].tap_key_origins.len(), 1); - // Byte-exact re-serialization: nothing is dropped or reordered. - assert_eq!(parsed.to_string(), psbt_b64); - assert_eq!(Psbt::deserialize(&parsed.serialize()).unwrap(), parsed); - - // `own_derivation` on the parsed map picks ours regardless of key order. - let (key, source) = own_derivation(origins, w.ours.fingerprint()).expect("ours"); - assert_eq!(key, ours.xonly); - assert_eq!(source.1.to_u32_vec(), ours.path); - let (dkey, _) = own_derivation(origins, w.foreign.fingerprint()).expect("theirs"); - assert!(dkey == decoy.xonly || dkey == decoy2.xonly); -} - -#[test] -fn verify_fixture_psbt_passes_all_five_checks_with_rgb_lib_txid() { - let w = wallets(); - let unsigned = fixture_str( - &w.fixture["signing"]["unsignedPsbt"], - "signing.unsignedPsbt", - ); - let verdict = verify_psbt(unsigned, ¶ms(&w, send_btc_intent(&w), 2_000)); - assert_all_pass(&verdict); - assert_eq!(verdict.fee_sat, Some(1_000)); - assert_eq!( - verdict.txid.as_deref(), - Some(fixture_str(&w.fixture["signing"]["txid"], "signing.txid")) - ); - // Same answer through the exported function with owned values. - let via_ffi = utexo_minimal_sdk::ffi::ffi_verify_psbt( - unsigned.to_owned(), - params(&w, send_btc_intent(&w), 2_000), - ); - assert_eq!(via_ffi, verdict); - // The fixture PSBT has the rgb-lib shape: metadata on input 0 and on the - // change output, both under our fingerprint. - let parsed: Psbt = unsigned.parse().unwrap(); - assert!(own_derivation(&parsed.inputs[0].tap_key_origins, w.ours.fingerprint()).is_some()); - assert!(own_derivation(&parsed.outputs[1].tap_key_origins, w.ours.fingerprint()).is_some()); - // Nothing rendered from a verdict ever carries key material. - let mnemonic = fixture_mnemonic(&w.fixture); - assert_no_secret(&format!("{verdict:?}"), &mnemonic, "VerifyVerdict Debug"); - assert_no_secret( - &format!("{:?}", params(&w, send_btc_intent(&w), 2_000)), - &mnemonic, - "VerifyParams Debug", - ); -} - -#[test] -fn verify_happy_psbt_built_here_matches_the_fixture_shape() { - let w = wallets(); - let verdict = verify_psbt( - &happy_psbt(&w, Happy::default()), - ¶ms(&w, send_btc_intent(&w), 2_000), - ); - assert_all_pass(&verdict); - assert_eq!(verdict.fee_sat, Some(1_000)); - assert_eq!(verdict.txid.as_ref().map(String::len), Some(64)); - - // Colored-keychain input and change (blind asset send): all pass. - let blind = OnchainIntent { - kind: IntentKind::SendAsset, - fee_rate_sat_per_vb: 2, - recipients: vec![], - asset: Some(IntentAsset { - asset_id: "rgb:fixture".into(), - amount: 5, - recipient_id: "bcrt:utxob:fixture".into(), - witness_amount_sat: None, - transport_endpoints: vec!["rpc://localhost:3000/json-rpc".into()], - }), - utxos: None, - }; - let colored = build_psbt( - &[InputSpec::new( - w.ours.script_at(true, 0, 0), - 30_000, - Some(w.ours.origin(true, 0, 0)), - )], - &[ - OutputSpec::new( - w.ours.script_at(true, 0, 1), - 29_000, - Some(w.ours.origin(true, 0, 1)), - ), - OutputSpec::new(opret_script(), 0, None), - ], - ); - assert_all_pass(&verify_psbt(&colored, ¶ms(&w, blind, 2_000))); -} - -#[test] -fn check1_foreign_input_fails_only_inputs_own() { - let w = wallets(); - // (a) Our script, but the only key origin carries the OTHER mnemonic's - // fingerprint: not provably ours. - let mut foreign_fp = w.foreign.origin(false, 0, 0); - foreign_fp.path = w.ours.origin(false, 0, 0).path; - let verdict = verify_psbt( - &happy_psbt( - &w, - Happy { - input_origins: Some(vec![foreign_fp]), - ..Happy::default() - }, - ), - ¶ms(&w, send_btc_intent(&w), 2_000), - ); - assert_only_fails(&verdict, &[CheckName::InputsOwn]); - assert!(detail(&verdict, CheckName::InputsOwn).contains("foreign input")); - - // (b) Attacker spends THEIR utxo but labels it with our fingerprint/path. - let psbt = build_psbt( - &[InputSpec::new( - w.foreign.script_at(false, 0, 0), - 100_000, - Some(w.ours.origin(false, 0, 0)), - )], - &[ - OutputSpec::new(w.foreign.script_at(false, 0, 0), 40_000, None), - OutputSpec::new( - w.ours.script_at(false, 0, 1), - 59_000, - Some(w.ours.origin(false, 0, 1)), - ), - ], - ); - let verdict = verify_psbt(&psbt, ¶ms(&w, send_btc_intent(&w), 2_000)); - assert_only_fails(&verdict, &[CheckName::InputsOwn]); - assert!(detail(&verdict, CheckName::InputsOwn).contains("does not re-derive")); - - // (c) Our fingerprint on a path outside our accounts (account 1'). - let mut odd = w.ours.origin(false, 0, 0); - odd.path[2] = HARDENED + 1; - let verdict = verify_psbt( - &happy_psbt( - &w, - Happy { - input_origins: Some(vec![odd]), - ..Happy::default() - }, - ), - ¶ms(&w, send_btc_intent(&w), 2_000), - ); - assert_only_fails(&verdict, &[CheckName::InputsOwn]); - assert!(detail(&verdict, CheckName::InputsOwn).contains("not under our accounts")); - - // (d) No metadata at all. - let verdict = verify_psbt( - &happy_psbt( - &w, - Happy { - input_origins: Some(vec![]), - ..Happy::default() - }, - ), - ¶ms(&w, send_btc_intent(&w), 2_000), - ); - assert_only_fails(&verdict, &[CheckName::InputsOwn]); - - // (e) Forged tapInternalKey that does not match the re-derived key. - let verdict = verify_psbt( - &happy_psbt( - &w, - Happy { - input_internal_key: Some(w.ours.xonly_at(false, 0, 4)), - ..Happy::default() - }, - ), - ¶ms(&w, send_btc_intent(&w), 2_000), - ); - assert_only_fails(&verdict, &[CheckName::InputsOwn]); - assert!(detail(&verdict, CheckName::InputsOwn).contains("tapInternalKey mismatch")); - // ...while the genuine internal key passes. - assert_all_pass(&verify_psbt( - &happy_psbt( - &w, - Happy { - input_internal_key: Some(w.ours.xonly_at(false, 0, 0)), - ..Happy::default() - }, - ), - ¶ms(&w, send_btc_intent(&w), 2_000), - )); - - // (f) No witness_utxo: cannot know what is being spent. - let mut psbt = build_psbt_raw( - &[InputSpec::new( - w.ours.script_at(false, 0, 0), - 100_000, - Some(w.ours.origin(false, 0, 0)), - )], - &[OutputSpec::new( - w.ours.script_at(false, 0, 1), - 99_000, - Some(w.ours.origin(false, 0, 1)), - )], - ); - psbt.inputs[0].witness_utxo = None; - let intent = OnchainIntent { - recipients: vec![], - ..send_btc_intent(&w) - }; - let verdict = verify_psbt(&psbt.to_string(), ¶ms(&w, intent, 2_000)); - assert!(detail(&verdict, CheckName::InputsOwn).contains("no witnessUtxo")); - assert!(verdict.failed().contains(&CheckName::InputsOwn)); - - // (g) Own inputs are NOT restricted to keychain 0 or a bounded index: - // spending from an odd path is proven safe by re-derivation alone. - let odd_input = InputSpec::new( - w.ours.script_at(false, 7, 900_000_000), - 100_000, - Some(w.ours.origin(false, 7, 900_000_000)), - ); - let psbt = build_psbt( - &[odd_input], - &[ - OutputSpec::new(w.foreign.script_at(false, 0, 0), 40_000, None), - OutputSpec::new( - w.ours.script_at(false, 0, 1), - 59_000, - Some(w.ours.origin(false, 0, 1)), - ), - ], - ); - assert_all_pass(&verify_psbt(&psbt, ¶ms(&w, send_btc_intent(&w), 2_000))); -} - -#[test] -fn check1_inputless_transaction_fails_closed() { - let w = wallets(); - let psbt = build_psbt( - &[], - &[OutputSpec::new( - w.foreign.script_at(false, 0, 0), - 40_000, - None, - )], - ); - let verdict = verify_psbt(&psbt, ¶ms(&w, send_btc_intent(&w), 2_000)); - assert!(!check(&verdict, CheckName::InputsOwn).ok); - assert!(detail(&verdict, CheckName::InputsOwn).contains("no inputs")); - assert!(!verdict.ok); - assert_eq!(verdict.fee_sat, Some(-40_000)); -} - -/// A self-send: the intended recipient is one of OUR keychain-0 addresses, -/// carrying metadata. Tampering the intent then trips check 2 alone, because -/// the mismatched output still proves ownership under check 3. -fn self_send(w: &Wallets) -> (String, OnchainIntent) { - let psbt = build_psbt( - &[InputSpec::new( - w.ours.script_at(false, 0, 0), - 100_000, - Some(w.ours.origin(false, 0, 0)), - )], - &[ - OutputSpec::new( - w.ours.script_at(false, 0, 2), - 40_000, - Some(w.ours.origin(false, 0, 2)), - ), - OutputSpec::new( - w.ours.script_at(false, 0, 1), - 59_000, - Some(w.ours.origin(false, 0, 1)), - ), - OutputSpec::new(opret_script(), 0, None), - ], - ); - let intent = OnchainIntent { - kind: IntentKind::SendBtc, - fee_rate_sat_per_vb: 2, - recipients: vec![IntentRecipient { - address: w.ours.address_at(false, 0, 2), - script_hex: w.ours.script_at(false, 0, 2).to_hex_string(), - amount_sat: 40_000, - }], - asset: None, - utxos: None, - }; - (psbt, intent) -} - -#[test] -fn check2_tampered_recipient_amount_fails_only_recipients_match() { - let w = wallets(); - let (psbt, mut intent) = self_send(&w); - assert_all_pass(&verify_psbt(&psbt, ¶ms(&w, intent.clone(), 2_000))); - intent.recipients[0].amount_sat = 39_000; - let verdict = verify_psbt(&psbt, ¶ms(&w, intent, 2_000)); - assert_only_fails(&verdict, &[CheckName::RecipientsMatch]); - assert!(detail(&verdict, CheckName::RecipientsMatch).contains("no output pays 39000 sat")); - - // The realistic case on the rgb-lib fixture PSBT: the intent's amount is - // tampered, so recipients-match fails, and the now-unaccounted foreign - // output is flagged by change-own too (defence in depth). - let unsigned = fixture_str( - &w.fixture["signing"]["unsignedPsbt"], - "signing.unsignedPsbt", - ); - let mut tampered = send_btc_intent(&w); - tampered.recipients[0].amount_sat = 39_000; - let verdict = verify_psbt(unsigned, ¶ms(&w, tampered, 2_000)); - assert_eq!( - verdict.failed(), - [CheckName::RecipientsMatch, CheckName::ChangeOwn] - ); - // PSBT-side amount tampering (recipient paid 90 000, change 9 000). - let psbt = build_psbt( - &[InputSpec::new( - w.ours.script_at(false, 0, 0), - 100_000, - Some(w.ours.origin(false, 0, 0)), - )], - &[ - OutputSpec::new(w.foreign.script_at(false, 0, 0), 90_000, None), - OutputSpec::new( - w.ours.script_at(false, 0, 1), - 9_000, - Some(w.ours.origin(false, 0, 1)), - ), - ], - ); - let verdict = verify_psbt(&psbt, ¶ms(&w, send_btc_intent(&w), 2_000)); - assert!(!check(&verdict, CheckName::RecipientsMatch).ok); - assert!(!verdict.ok); -} - -#[test] -fn check2_tampered_recipient_address_fails_only_recipients_match() { - let w = wallets(); - let (psbt, mut intent) = self_send(&w); - // Intent names a different (own) address, consistent with its script. - intent.recipients[0].address = w.ours.address_at(false, 0, 3); - intent.recipients[0].script_hex = w.ours.script_at(false, 0, 3).to_hex_string(); - let verdict = verify_psbt(&psbt, ¶ms(&w, intent, 2_000)); - assert_only_fails(&verdict, &[CheckName::RecipientsMatch]); - assert!(detail(&verdict, CheckName::RecipientsMatch).contains("no output pays")); - - // Swapped recipient script in the PSBT (payment redirected). - let swapped = build_psbt( - &[InputSpec::new( - w.ours.script_at(false, 0, 0), - 100_000, - Some(w.ours.origin(false, 0, 0)), - )], - &[ - OutputSpec::new(w.foreign.script_at(false, 0, 4), 40_000, None), - OutputSpec::new( - w.ours.script_at(false, 0, 1), - 59_000, - Some(w.ours.origin(false, 0, 1)), - ), - OutputSpec::new(opret_script(), 0, None), - ], - ); - let verdict = verify_psbt(&swapped, ¶ms(&w, send_btc_intent(&w), 2_000)); - assert!(!check(&verdict, CheckName::RecipientsMatch).ok); - assert!(!verdict.ok); - - // An undecodable intent address fails instead of being trusted. - let mut bad = send_btc_intent(&w); - bad.recipients[0].address = "not-an-address".into(); - let verdict = verify_psbt(&happy_psbt(&w, Happy::default()), ¶ms(&w, bad, 2_000)); - assert!(!check(&verdict, CheckName::RecipientsMatch).ok); - assert!(detail(&verdict, CheckName::RecipientsMatch).contains("does not decode")); - - // A mainnet address for the same key does not decode on regtest. - let mut wrong_net = send_btc_intent(&w); - let key = w.foreign.xonly_at(false, 0, 0); - wrong_net.recipients[0].address = - bitcoin::Address::p2tr(&Secp256k1::new(), key, None, bitcoin::Network::Bitcoin).to_string(); - let verdict = verify_psbt( - &happy_psbt(&w, Happy::default()), - ¶ms(&w, wrong_net, 2_000), - ); - assert!(detail(&verdict, CheckName::RecipientsMatch).contains("does not decode on Regtest")); -} - -#[test] -fn check2_intent_script_hex_contradicting_its_address_fails_recipients_match() { - let w = wallets(); - // A compromised gateway shows the user the REAL recipient address but - // pairs it with an attacker script and a PSBT paying that script. The - // expected script must be re-derived from the address, never trusted. - let attacker_script = w.foreign.script_at(false, 0, 4); - let mut hostile = send_btc_intent(&w); - hostile.recipients[0].script_hex = attacker_script.to_hex_string(); - let psbt = build_psbt( - &[InputSpec::new( - w.ours.script_at(false, 0, 0), - 100_000, - Some(w.ours.origin(false, 0, 0)), - )], - &[ - OutputSpec::new(attacker_script, 40_000, None), - OutputSpec::new( - w.ours.script_at(false, 0, 1), - 59_000, - Some(w.ours.origin(false, 0, 1)), - ), - OutputSpec::new(opret_script(), 0, None), - ], - ); - let verdict = verify_psbt(&psbt, ¶ms(&w, hostile.clone(), 2_000)); - assert!(!check(&verdict, CheckName::RecipientsMatch).ok); - assert!(detail(&verdict, CheckName::RecipientsMatch).contains("does not match")); - assert!(!verdict.ok); - - // Isolated: same contradiction on a self-send trips only check 2. - let (psbt, mut intent) = self_send(&w); - intent.recipients[0].script_hex = w.ours.script_at(false, 0, 1).to_hex_string(); - let verdict = verify_psbt(&psbt, ¶ms(&w, intent.clone(), 2_000)); - assert_only_fails(&verdict, &[CheckName::RecipientsMatch]); - // Case-insensitive script_hex comparison, as in the TypeScript SDK. - intent.recipients[0].script_hex = w.ours.script_at(false, 0, 2).to_hex_string().to_uppercase(); - assert_all_pass(&verify_psbt(&psbt, ¶ms(&w, intent, 2_000))); -} - -#[test] -fn check2_recipients_are_matched_as_a_multiset() { - let w = wallets(); - // Two identical recipients need two outputs; one output cannot satisfy both. - let recipient = IntentRecipient { - address: w.foreign.address_at(false, 0, 0), - script_hex: w.foreign.script_at(false, 0, 0).to_hex_string(), - amount_sat: 20_000, - }; - let intent = OnchainIntent { - recipients: vec![recipient.clone(), recipient], - ..send_btc_intent(&w) - }; - let two = build_psbt( - &[InputSpec::new( - w.ours.script_at(false, 0, 0), - 100_000, - Some(w.ours.origin(false, 0, 0)), - )], - &[ - OutputSpec::new(w.foreign.script_at(false, 0, 0), 20_000, None), - OutputSpec::new(w.foreign.script_at(false, 0, 0), 20_000, None), - OutputSpec::new( - w.ours.script_at(false, 0, 1), - 59_000, - Some(w.ours.origin(false, 0, 1)), - ), - ], - ); - assert_all_pass(&verify_psbt(&two, ¶ms(&w, intent.clone(), 2_000))); - let one = build_psbt( - &[InputSpec::new( - w.ours.script_at(false, 0, 0), - 100_000, - Some(w.ours.origin(false, 0, 0)), - )], - &[ - OutputSpec::new(w.foreign.script_at(false, 0, 0), 20_000, None), - OutputSpec::new( - w.ours.script_at(false, 0, 1), - 79_000, - Some(w.ours.origin(false, 0, 1)), - ), - ], - ); - let verdict = verify_psbt(&one, ¶ms(&w, intent, 2_000)); - assert_only_fails(&verdict, &[CheckName::RecipientsMatch]); -} - -fn witness_intent(amount: Option) -> OnchainIntent { - OnchainIntent { - kind: IntentKind::SendAsset, - fee_rate_sat_per_vb: 2, - recipients: vec![], - asset: Some(IntentAsset { - asset_id: "rgb:fixture".into(), - amount: 5, - recipient_id: "bcrt:wvout:fixture".into(), - witness_amount_sat: amount, - transport_endpoints: vec![], - }), - utxos: None, - } -} - -#[test] -fn check2_witness_send_requires_exactly_one_foreign_output_of_the_approved_amount() { - let w = wallets(); - let input = || { - InputSpec::new( - w.ours.script_at(true, 0, 0), - 30_000, - Some(w.ours.origin(true, 0, 0)), - ) - }; - let change = |amount: u64| { - OutputSpec::new( - w.ours.script_at(true, 0, 1), - amount, - Some(w.ours.origin(true, 0, 1)), - ) - }; - let psbt = build_psbt( - &[input()], - &[ - OutputSpec::new(w.foreign.script_at(false, 0, 3), 3_000, None), - change(26_000), - OutputSpec::new(opret_script(), 0, None), - ], - ); - assert_all_pass(&verify_psbt( - &psbt, - ¶ms(&w, witness_intent(Some(3_000)), 2_000), - )); - // The same PSBT under a BLIND intent: no foreign output allowed. - let blind = verify_psbt(&psbt, ¶ms(&w, witness_intent(None), 2_000)); - assert_only_fails(&blind, &[CheckName::ChangeOwn]); - - // Zero candidates: the only foreign output pays the WRONG amount. - let wrong = build_psbt( - &[input()], - &[ - OutputSpec::new(w.foreign.script_at(false, 0, 3), 2_999, None), - change(23_000), - ], - ); - let verdict = verify_psbt(&wrong, ¶ms(&w, witness_intent(Some(3_000)), 5_000)); - assert!(detail(&verdict, CheckName::RecipientsMatch).contains("found 0")); - assert_eq!( - verdict.failed(), - [CheckName::RecipientsMatch, CheckName::ChangeOwn] - ); - - // Two candidates at the approved amount: ambiguous, must fail. - let two = build_psbt( - &[input()], - &[ - OutputSpec::new(w.foreign.script_at(false, 0, 3), 3_000, None), - OutputSpec::new(w.foreign.script_at(false, 0, 4), 3_000, None), - change(22_000), - ], - ); - let verdict = verify_psbt(&two, ¶ms(&w, witness_intent(Some(3_000)), 5_000)); - assert!(detail(&verdict, CheckName::RecipientsMatch).contains("found 2")); - assert!(!verdict.ok); -} - -#[test] -fn check3_unaccounted_foreign_output_fails_only_change_own() { - let w = wallets(); - // Extra undeclared output skimming 5 000 sat (change reduced to keep the fee). - let verdict = verify_psbt( - &happy_psbt( - &w, - Happy { - change_amount: 54_000, - extra_outputs: vec![OutputSpec::new( - w.foreign.script_at(false, 0, 2), - 5_000, - None, - )], - ..Happy::default() - }, - ), - ¶ms(&w, send_btc_intent(&w), 2_000), - ); - assert_only_fails(&verdict, &[CheckName::ChangeOwn]); - assert!(detail(&verdict, CheckName::ChangeOwn).contains("output 3 (5000 sat)")); - - // Tampered change: foreign script under our metadata. - let verdict = verify_psbt( - &happy_psbt( - &w, - Happy { - change_script: Some(w.foreign.script_at(false, 0, 1)), - ..Happy::default() - }, - ), - ¶ms(&w, send_btc_intent(&w), 2_000), - ); - assert_only_fails(&verdict, &[CheckName::ChangeOwn]); - - // create_utxos: all-own outputs pass; one swapped output is caught. - let utxos_intent = OnchainIntent { - kind: IntentKind::CreateUtxos, - fee_rate_sat_per_vb: 2, - recipients: vec![], - asset: None, - utxos: Some(IntentUtxos { - up_to: false, - num: 2, - size: 1_000, - }), - }; - let input = || { - InputSpec::new( - w.ours.script_at(false, 0, 0), - 100_000, - Some(w.ours.origin(false, 0, 0)), - ) - }; - let own = |index: u32, amount: u64| { - OutputSpec::new( - w.ours.script_at(false, 0, index), - amount, - Some(w.ours.origin(false, 0, index)), - ) - }; - let good = build_psbt(&[input()], &[own(1, 1_000), own(2, 1_000), own(3, 97_000)]); - assert_all_pass(&verify_psbt( - &good, - ¶ms(&w, utxos_intent.clone(), 2_000), - )); - let tampered = build_psbt( - &[input()], - &[ - own(1, 1_000), - own(2, 1_000), - OutputSpec::new(w.foreign.script_at(false, 0, 3), 97_000, None), - ], - ); - assert_only_fails( - &verify_psbt(&tampered, ¶ms(&w, utxos_intent, 2_000)), - &[CheckName::ChangeOwn], - ); -} - -#[test] -fn check3_own_output_outside_keychain_0_or_index_bound_fails_only_change_own() { - let w = wallets(); - // The script genuinely re-derives from our xpub at keychain 1, but no - // descriptor wallet scans keychain 1 — accepting it would let a hostile - // gateway strand the change where the user can never find it. - let verdict = verify_psbt( - &happy_psbt( - &w, - Happy { - change_script: Some(w.ours.script_at(false, 1, 0)), - change_origin: Some(Some(w.ours.origin(false, 1, 0))), - ..Happy::default() - }, - ), - ¶ms(&w, send_btc_intent(&w), 2_000), - ); - assert_only_fails(&verdict, &[CheckName::ChangeOwn]); - - // Beyond the index bound: index 9e8 is ours yet unrecoverable. - let verdict = verify_psbt( - &happy_psbt( - &w, - Happy { - change_script: Some(w.ours.script_at(false, 0, 900_000_000)), - change_origin: Some(Some(w.ours.origin(false, 0, 900_000_000))), - ..Happy::default() - }, - ), - ¶ms(&w, send_btc_intent(&w), 2_000), - ); - assert_only_fails(&verdict, &[CheckName::ChangeOwn]); - - // The bound is inclusive at the default 10 000 and honours an override. - let at = |index: u32, max: Option| { - let mut p = params(&w, send_btc_intent(&w), 2_000); - p.max_own_output_index = max; - verify_psbt( - &happy_psbt( - &w, - Happy { - change_script: Some(w.ours.script_at(false, 0, index)), - change_origin: Some(Some(w.ours.origin(false, 0, index))), - ..Happy::default() - }, - ), - &p, - ) - }; - assert_all_pass(&at(10_000, None)); - assert_only_fails(&at(10_001, None), &[CheckName::ChangeOwn]); - assert_all_pass(&at(10_001, Some(10_001))); - assert_only_fails(&at(50, Some(49)), &[CheckName::ChangeOwn]); - // Metadata-proven change beyond the scan window at a sane index passes. - assert_all_pass(&at(40, None)); -} - -#[test] -fn check3_metadata_less_change_uses_the_scan_window_on_demand() { - let w = wallets(); - let psbt = happy_psbt( - &w, - Happy { - change_origin: Some(None), - ..Happy::default() - }, - ); - assert_all_pass(&verify_psbt(&psbt, ¶ms(&w, send_btc_intent(&w), 2_000))); - let mut no_scan = params(&w, send_btc_intent(&w), 2_000); - no_scan.change_scan_window = Some(0); - assert_only_fails(&verify_psbt(&psbt, &no_scan), &[CheckName::ChangeOwn]); - // Index 1 is inside a window of 2 but outside a window of 1. - let mut narrow = params(&w, send_btc_intent(&w), 2_000); - narrow.change_scan_window = Some(2); - assert_all_pass(&verify_psbt(&psbt, &narrow)); - narrow.change_scan_window = Some(1); - assert_only_fails(&verify_psbt(&psbt, &narrow), &[CheckName::ChangeOwn]); - // The scan window covers the colored account too, keychain 0 only. - let colored_change = build_psbt( - &[InputSpec::new( - w.ours.script_at(false, 0, 0), - 100_000, - Some(w.ours.origin(false, 0, 0)), - )], - &[ - OutputSpec::new(w.foreign.script_at(false, 0, 0), 40_000, None), - OutputSpec::new(w.ours.script_at(true, 0, 29), 59_000, None), - ], - ); - assert_all_pass(&verify_psbt( - &colored_change, - ¶ms(&w, send_btc_intent(&w), 2_000), - )); - let keychain_1 = build_psbt( - &[InputSpec::new( - w.ours.script_at(false, 0, 0), - 100_000, - Some(w.ours.origin(false, 0, 0)), - )], - &[ - OutputSpec::new(w.foreign.script_at(false, 0, 0), 40_000, None), - OutputSpec::new(w.ours.script_at(false, 1, 0), 59_000, None), - ], - ); - assert_only_fails( - &verify_psbt(&keychain_1, ¶ms(&w, send_btc_intent(&w), 2_000)), - &[CheckName::ChangeOwn], - ); -} - -#[test] -fn check4_fee_outside_budget_fails_only_fee_budget() { - let w = wallets(); - // Inflated: change 20 000 → fee 40 000. - let verdict = verify_psbt( - &happy_psbt( - &w, - Happy { - change_amount: 20_000, - ..Happy::default() - }, - ), - ¶ms(&w, send_btc_intent(&w), 2_000), - ); - assert_only_fails(&verdict, &[CheckName::FeeBudget]); - assert_eq!(verdict.fee_sat, Some(40_000)); - assert!( - detail(&verdict, CheckName::FeeBudget).contains("fee 40000 sat outside budget (0, 2000]") - ); - - // Zero fee: outputs == inputs. - let verdict = verify_psbt( - &happy_psbt( - &w, - Happy { - change_amount: 60_000, - ..Happy::default() - }, - ), - ¶ms(&w, send_btc_intent(&w), 2_000), - ); - assert_only_fails(&verdict, &[CheckName::FeeBudget]); - assert_eq!(verdict.fee_sat, Some(0)); - - // Negative fee: outputs exceed inputs. - let verdict = verify_psbt( - &happy_psbt( - &w, - Happy { - change_amount: 70_000, - ..Happy::default() - }, - ), - ¶ms(&w, send_btc_intent(&w), 2_000), - ); - assert_only_fails(&verdict, &[CheckName::FeeBudget]); - assert_eq!(verdict.fee_sat, Some(-10_000)); - - // Budget is inclusive; the fixture PSBT's 1 000 sat fee at budget 1 000 - // passes and at 999 fails. - let unsigned = fixture_str( - &w.fixture["signing"]["unsignedPsbt"], - "signing.unsignedPsbt", - ); - assert_all_pass(&verify_psbt( - unsigned, - ¶ms(&w, send_btc_intent(&w), 1_000), - )); - assert_only_fails( - &verify_psbt(unsigned, ¶ms(&w, send_btc_intent(&w), 999)), - &[CheckName::FeeBudget], - ); - assert_only_fails( - &verify_psbt(unsigned, ¶ms(&w, send_btc_intent(&w), 0)), - &[CheckName::FeeBudget], - ); -} - -#[test] -fn check5_funded_op_return_fails_only_opret_zero() { - let w = wallets(); - let verdict = verify_psbt( - &happy_psbt( - &w, - Happy { - opret_amount: 500, - change_amount: 58_500, - ..Happy::default() - }, - ), - ¶ms(&w, send_btc_intent(&w), 2_000), - ); - assert_only_fails(&verdict, &[CheckName::OpretZero]); - assert!(detail(&verdict, CheckName::OpretZero).contains("OP_RETURN output 2 carries 500 sat")); -} - -#[test] -fn decoy_key_origin_entry_does_not_divert_selection() { - let w = wallets(); - // A foreign-fingerprint entry on a well-formed own-looking path sits in - // the same map as ours. Selecting by fingerprint ignores it; selecting by - // shape would pick whichever sorts first. Both key orders are exercised - // by using decoys at two different indexes. - for decoy_index in [1u32, 2, 3, 4] { - let mut decoy = w.foreign.origin(false, 0, decoy_index); - decoy.path = w.ours.origin(false, 0, decoy_index).path; - let psbt = happy_psbt( - &w, - Happy { - input_origins: Some(vec![decoy.clone(), w.ours.origin(false, 0, 0)]), - input_internal_key: Some(w.ours.xonly_at(false, 0, 0)), - ..Happy::default() - }, - ); - let verdict = verify_psbt(&psbt, ¶ms(&w, send_btc_intent(&w), 2_000)); - assert_all_pass(&verdict); - - let parsed: Psbt = psbt.parse().unwrap(); - let origins = &parsed.inputs[0].tap_key_origins; - assert_eq!(origins.len(), 2); - let (key, source) = own_derivation(origins, w.ours.fingerprint()).unwrap(); - assert_eq!(key, w.ours.xonly_at(false, 0, 0), "decoy at {decoy_index}"); - assert_eq!(source.1.to_u32_vec(), w.ours.origin(false, 0, 0).path); - // What verify proved is what sign will derive: the private key for - // that path is the key behind the selected entry. - let sk = w - .ours - .keys - .private_key_for_path(&source.1.to_u32_vec()) - .unwrap(); - assert_eq!(sk.x_only_public_key(&Secp256k1::new()).0, key); - } - - // A decoy on the OUTPUT side must not make foreign change look own either: - // the entry carries our fingerprint but the script is theirs. - let mut lying = w.ours.origin(false, 0, 1); - lying.xonly = w.foreign.xonly_at(false, 0, 1); - let verdict = verify_psbt( - &happy_psbt( - &w, - Happy { - change_script: Some(w.foreign.script_at(false, 0, 1)), - change_origin: Some(Some(lying)), - ..Happy::default() - }, - ), - ¶ms(&w, send_btc_intent(&w), 2_000), - ); - assert_only_fails(&verdict, &[CheckName::ChangeOwn]); -} - -#[test] -fn garbage_and_truncated_psbts_fail_all_five_without_panicking() { - let w = wallets(); - let p = params(&w, send_btc_intent(&w), 2_000); - let unsigned = fixture_str( - &w.fixture["signing"]["unsignedPsbt"], - "signing.unsignedPsbt", - ); - let mut cases: Vec<(String, String)> = vec![ - ("not base64".into(), "!!!!not-base64!!!!".into()), - ( - "random bytes".into(), - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==".into(), - ), - ("empty string".into(), String::new()), - ("truncated psbt magic".into(), "cHNidP8=".into()), - ("whitespace".into(), " ".into()), - ("magic only".into(), "cHNidP8AAA==".into()), - ]; - // Truncations of the real fixture PSBT at every 4-char base64 boundary. - for len in (4..unsigned.len()).step_by(4) { - cases.push((format!("truncated at {len}"), unsigned[..len].to_owned())); - } - // Bit flips through the fixture PSBT's bytes. - let bytes: Psbt = unsigned.parse().unwrap(); - let raw = bytes.serialize(); - for i in (0..raw.len()).step_by(3) { - let mut flipped = raw.clone(); - flipped[i] ^= 0x55; - cases.push((format!("bit flip at {i}"), { - use bitcoin::base64::Engine; - bitcoin::base64::prelude::BASE64_STANDARD.encode(&flipped) - })); - } - let mnemonic = fixture_mnemonic(&w.fixture); - let mut unparseable = 0usize; - for (label, psbt) in cases { - let verdict = verify_psbt(&psbt, &p); - assert_eq!(verdict.checks.len(), 5, "{label}"); - assert_no_secret(&format!("{verdict:?}"), &mnemonic, &label); - if verdict.txid.is_none() { - // Unparseable: every check fails with the same parse reason. - unparseable += 1; - assert!(!verdict.ok, "{label}"); - assert_eq!(verdict.failed(), CheckName::ALL, "{label}: {verdict:?}"); - assert_eq!(verdict.fee_sat, None, "{label}"); - for c in &verdict.checks { - assert!( - c.detail - .as_deref() - .is_some_and(|d| d.starts_with("psbt could not be safely parsed")), - "{label}: {c:?}" - ); - } - } else if !label.starts_with("bit flip") { - panic!("{label} parsed as a PSBT: {verdict:?}"); - } - // A bit flip that still parses is judged on its merits: a flip in a - // field no check covers (e.g. the tx version) legitimately passes; - // a flip in an amount, script or key-origin field fails the check - // that owns it. What matters here is: no panic, no `Err`, five - // checks, no secret rendered. - } - assert!( - unparseable > 10, - "expected most corruptions to be unparseable" - ); - - // Bad xpub material fails all five too, rather than erroring. - let mut bad_xpubs = params(&w, send_btc_intent(&w), 2_000); - bad_xpubs.xpubs.fingerprint = "zz".into(); - let verdict = verify_psbt(unsigned, &bad_xpubs); - assert_eq!(verdict.failed(), CheckName::ALL); - assert!(detail(&verdict, CheckName::InputsOwn).contains("fingerprint")); - let mut bad_xpubs = params(&w, send_btc_intent(&w), 2_000); - bad_xpubs.xpubs.vanilla = "tpubgarbage".into(); - let verdict = verify_psbt(unsigned, &bad_xpubs); - assert_eq!(verdict.failed(), CheckName::ALL); - // Wrong network for the xpubs: the fixture's regtest tpubs are not - // mainnet material, and an intent address of the wrong network fails. - let mut wrong_net = params(&w, send_btc_intent(&w), 2_000); - wrong_net.xpubs.network = BitcoinNetwork::Mainnet; - assert_eq!(verify_psbt(unsigned, &wrong_net).failed(), CheckName::ALL); - - // The foreign wallet's own xpubs see the fixture PSBT as entirely foreign. - let theirs = VerifyParams { - intent: send_btc_intent(&w), - xpubs: w.foreign.keys.xpubs(), - max_fee_sat: 2_000, - change_scan_window: None, - max_own_output_index: None, - }; - let verdict = verify_psbt(unsigned, &theirs); - assert_eq!( - verdict.failed(), - [CheckName::InputsOwn, CheckName::ChangeOwn] - ); -} - -// --------------------------------------------------------------------------- -// Task 5: sign and finalize — txid parity with rgb-lib's own signer, -// signature validity, and refusal on anything verification rejects. -// --------------------------------------------------------------------------- - -use bitcoin::hashes::Hash; -use bitcoin::key::TapTweak; -use bitcoin::secp256k1::Message; -use bitcoin::sighash::{Prevouts, SighashCache}; -use bitcoin::TapSighashType; -use utexo_minimal_sdk::{verify_and_sign_psbt, SignResult}; - -fn fixture_unsigned(w: &Wallets) -> &str { - fixture_str( - &w.fixture["signing"]["unsignedPsbt"], - "signing.unsignedPsbt", - ) -} - -/// Every check a signed+finalized PSBT must satisfy, independent of which -/// wallet signed it: one 64-byte (SIGHASH_DEFAULT) key-path witness element -/// per input that verifies as BIP-340 over the BIP-341 key-spend sighash -/// against the output key in the spent script, signing metadata dropped, -/// and the transaction extracts to `expected_txid`. -fn assert_signed_and_finalized(signed_psbt: &str, expected_txid: &str) -> Psbt { - let psbt: Psbt = signed_psbt.parse().expect("signed psbt parses"); - let secp = Secp256k1::new(); - let prevouts: Vec = psbt - .inputs - .iter() - .map(|i| i.witness_utxo.clone().expect("witness_utxo retained")) - .collect(); - let mut cache = SighashCache::new(&psbt.unsigned_tx); - for (index, input) in psbt.inputs.iter().enumerate() { - let witness = input - .final_script_witness - .as_ref() - .unwrap_or_else(|| panic!("input {index} not finalized")); - assert_eq!(witness.len(), 1, "input {index}: key-path witness"); - let sig_bytes = witness.nth(0).unwrap(); - assert_eq!( - sig_bytes.len(), - 64, - "input {index}: 64-byte SIGHASH_DEFAULT sig" - ); - let signature = bitcoin::secp256k1::schnorr::Signature::from_slice(sig_bytes).unwrap(); - let sighash = cache - .taproot_key_spend_signature_hash( - index, - &Prevouts::All(&prevouts), - TapSighashType::Default, - ) - .unwrap(); - let script = prevouts[index].script_pubkey.as_bytes(); - assert_eq!(&script[..2], &[0x51, 0x20]); - let output_key = XOnlyPublicKey::from_slice(&script[2..]).unwrap(); - secp.verify_schnorr( - &signature, - &Message::from_digest(sighash.to_byte_array()), - &output_key, - ) - .unwrap_or_else(|e| panic!("input {index}: signature invalid: {e}")); - // Finalizer role: signing metadata dropped, UTXO retained. - assert!(input.tap_key_sig.is_none(), "input {index}"); - assert!(input.tap_key_origins.is_empty(), "input {index}"); - assert!(input.tap_internal_key.is_none(), "input {index}"); - assert!(input.sighash_type.is_none(), "input {index}"); - assert!(input.partial_sigs.is_empty() && input.bip32_derivation.is_empty()); - } - let tx = psbt.clone().extract_tx().expect("finalized psbt extracts"); - assert_eq!(tx.compute_txid().to_string(), expected_txid); - psbt -} - -#[test] -fn sign_fixture_psbt_matches_rgb_lib_txid_with_a_valid_key_path_signature() { - let w = wallets(); - let expected_txid = fixture_str(&w.fixture["signing"]["txid"], "signing.txid"); - let result = verify_and_sign_psbt( - &w.ours.keys, - fixture_unsigned(&w), - ¶ms(&w, send_btc_intent(&w), 2_000), - ) - .expect("fixture PSBT signs"); - - // (a) the verdict is ok and is the one signing was conditioned on. - assert_all_pass(&result.verdict); - assert_eq!(result.verdict.fee_sat, Some(1_000)); - // (b) txid parity with rgb-lib. - assert_eq!(result.txid, expected_txid); - assert_eq!(result.verdict.txid.as_deref(), Some(expected_txid)); - // (c)+(d) exactly one 64-byte witness element that verifies as BIP-340 - // over the BIP-341 sighash against the spent output key ... - let signed = assert_signed_and_finalized(&result.signed_psbt, expected_txid); - // ... which is the re-derived x-only key at the fixture input's path - // (vanilla 0/0), tweaked with no script tree. - let unsigned: Psbt = fixture_unsigned(&w).parse().unwrap(); - let (entry_key, source) = - own_derivation(&unsigned.inputs[0].tap_key_origins, w.ours.fingerprint()).unwrap(); - let internal = w.ours.xonly_at(false, 0, 0); - assert_eq!(entry_key, internal); - assert_eq!(source.1.to_u32_vec(), w.ours.origin(false, 0, 0).path); - let (output_key, _) = internal.tap_tweak(&Secp256k1::new(), None); - let script = &signed.inputs[0] - .witness_utxo - .as_ref() - .unwrap() - .script_pubkey; - assert_eq!(&script.as_bytes()[2..], &output_key.serialize()); - - // (e) rgb-lib's own signed fixture parses to the same txid and has the - // same witness shape; its bytes are the fixture's recorded signature. - // Ours are NOT compared byte-for-byte: BIP-340 signatures are randomized. - let rgb_lib_signed = fixture_str(&w.fixture["signing"]["signedPsbt"], "signing.signedPsbt"); - let theirs = assert_signed_and_finalized(rgb_lib_signed, expected_txid); - let their_sig = theirs.inputs[0] - .final_script_witness - .as_ref() - .unwrap() - .nth(0) - .unwrap(); - assert_eq!( - their_sig.to_vec(), - hex_decode(fixture_str( - &w.fixture["signing"]["witnessSignature"], - "signing.witnessSignature" - )) - ); - let our_sig = signed.inputs[0] - .final_script_witness - .as_ref() - .unwrap() - .nth(0) - .unwrap(); - assert_ne!( - our_sig, their_sig, - "randomized nonces: byte-equality is not the parity claim" - ); - - // Signing twice gives two different valid signatures for one txid. - let again = verify_and_sign_psbt( - &w.ours.keys, - fixture_unsigned(&w), - ¶ms(&w, send_btc_intent(&w), 2_000), - ) - .unwrap(); - assert_eq!(again.txid, result.txid); - assert_ne!(again.signed_psbt, result.signed_psbt); - assert_signed_and_finalized(&again.signed_psbt, expected_txid); -} - -#[test] -fn sign_handles_multiple_inputs_across_both_accounts() { - let w = wallets(); - // Own inputs on vanilla 0/3, colored 0/1 and vanilla 1/5 (an odd - // keychain is fine for inputs), recipient 40 000, own change, OP_RETURN. - let psbt = build_psbt( - &[ - InputSpec::new( - w.ours.script_at(false, 0, 3), - 50_000, - Some(w.ours.origin(false, 0, 3)), - ), - InputSpec::new( - w.ours.script_at(true, 0, 1), - 30_000, - Some(w.ours.origin(true, 0, 1)), - ), - InputSpec::new( - w.ours.script_at(false, 1, 5), - 20_000, - Some(w.ours.origin(false, 1, 5)), - ), - ], - &[ - OutputSpec::new(w.foreign.script_at(false, 0, 0), 40_000, None), - OutputSpec::new( - w.ours.script_at(true, 0, 2), - 59_000, - Some(w.ours.origin(true, 0, 2)), - ), - OutputSpec::new(opret_script(), 0, None), - ], - ); - let result = verify_and_sign_psbt(&w.ours.keys, &psbt, ¶ms(&w, send_btc_intent(&w), 2_000)) - .expect("three own inputs sign"); - assert_all_pass(&result.verdict); - let unsigned: Psbt = psbt.parse().unwrap(); - assert_eq!(result.txid, unsigned.unsigned_tx.compute_txid().to_string()); - let signed = assert_signed_and_finalized(&result.signed_psbt, &result.txid); - assert_eq!(signed.inputs.len(), 3); - // Outputs are the finalizer's business only on inputs: their metadata - // survives for the gateway to read. - assert_eq!(signed.outputs[1].tap_key_origins.len(), 1); -} - -#[test] -fn sign_refuses_tampered_recipient_amount_and_produces_no_signature() { - let w = wallets(); - let mut tampered = send_btc_intent(&w); - tampered.recipients[0].amount_sat = 39_000; - let err = verify_and_sign_psbt( - &w.ours.keys, - fixture_unsigned(&w), - ¶ms(&w, tampered, 2_000), - ) - .expect_err("tampered amount must be refused"); - let SdkError::VerificationFailed { check, reason } = &err else { - panic!("expected VerificationFailed, got {err:?}"); - }; - // The failed check names are carried: the first in `check`, all in - // `reason` (recipients-match, plus change-own on the now-unaccounted - // foreign output). - assert_eq!(check, "recipients-match"); - assert!( - reason.starts_with("refusing to sign: recipients-match ("), - "{reason}" - ); - assert!(reason.contains("no output pays 39000 sat"), "{reason}"); - assert!(reason.contains("; change-own ("), "{reason}"); - assert!(!reason.contains("fee-budget"), "{reason}"); - // Nothing was signed: the refusal is an error, not a result, and the - // only signing entry point is the one that just refused (see - // `sign_has_exactly_one_entry_point_and_it_verifies_first`). - assert!(matches!(err, SdkError::VerificationFailed { .. })); - - // Over budget: fee-budget is the (only) failed check. - let err = verify_and_sign_psbt( - &w.ours.keys, - fixture_unsigned(&w), - ¶ms(&w, send_btc_intent(&w), 500), - ) - .unwrap_err(); - assert_eq!( - err, - SdkError::VerificationFailed { - check: "fee-budget".into(), - reason: "refusing to sign: fee-budget (fee 1000 sat outside budget (0, 500])".into(), - } - ); -} - -#[test] -fn sign_refuses_inputs_whose_key_origin_path_is_not_ours() { - let w = wallets(); - let p = params(&w, send_btc_intent(&w), 2_000); - - // (a) A foreign input (their script, their fingerprint) — the intent - // matches, but check 1 fails before any signing. - let mut spec = InputSpec::new( - w.foreign.script_at(false, 0, 0), - 100_000, - Some(w.foreign.origin(false, 0, 0)), - ); - spec.tap_internal_key = Some(w.foreign.xonly_at(false, 0, 0)); - let psbt = build_psbt( - &[spec], - &[ - OutputSpec::new(w.foreign.script_at(false, 0, 0), 40_000, None), - OutputSpec::new( - w.ours.script_at(false, 0, 1), - 59_000, - Some(w.ours.origin(false, 0, 1)), - ), - OutputSpec::new(opret_script(), 0, None), - ], - ); - let err = verify_and_sign_psbt(&w.ours.keys, &psbt, &p).unwrap_err(); - assert!( - matches!(&err, SdkError::VerificationFailed { check, .. } if check == "inputs-own"), - "{err:?}" - ); - - // (b) Our fingerprint on a path outside our accounts (account 1'). - let mut odd = w.ours.origin(false, 0, 0); - odd.path[2] = HARDENED + 1; - let psbt = happy_psbt( - &w, - Happy { - input_origins: Some(vec![odd]), - ..Happy::default() - }, - ); - let err = verify_and_sign_psbt(&w.ours.keys, &psbt, &p).unwrap_err(); - assert!( - matches!(&err, SdkError::VerificationFailed { check, reason } - if check == "inputs-own" && reason.contains("not under our accounts")), - "{err:?}" - ); - - // (c) An input carrying no key-origin entry at all. - let psbt = happy_psbt( - &w, - Happy { - input_origins: Some(vec![]), - ..Happy::default() - }, - ); - let err = verify_and_sign_psbt(&w.ours.keys, &psbt, &p).unwrap_err(); - assert!( - matches!(&err, SdkError::VerificationFailed { check, .. } if check == "inputs-own"), - "{err:?}" - ); - - // (d) The verdict belongs to another wallet: the foreign wallet's own - // PSBT verifies fine against the foreign xpubs, but our keys must not - // sign on the strength of someone else's verdict. - let foreign_psbt = build_psbt( - &[InputSpec::new( - w.foreign.script_at(false, 0, 0), - 100_000, - Some(w.foreign.origin(false, 0, 0)), - )], - &[ - OutputSpec::new(w.ours.script_at(false, 0, 0), 40_000, None), - OutputSpec::new( - w.foreign.script_at(false, 0, 1), - 59_000, - Some(w.foreign.origin(false, 0, 1)), - ), - OutputSpec::new(opret_script(), 0, None), - ], - ); - let foreign_intent = OnchainIntent { - kind: IntentKind::SendBtc, - fee_rate_sat_per_vb: 2, - recipients: vec![IntentRecipient { - address: w.ours.address_at(false, 0, 0), - script_hex: w.ours.script_at(false, 0, 0).to_hex_string(), - amount_sat: 40_000, - }], - asset: None, - utxos: None, - }; - let foreign_params = VerifyParams { - intent: foreign_intent, - xpubs: w.foreign.keys.xpubs(), - max_fee_sat: 2_000, - change_scan_window: None, - max_own_output_index: None, - }; - assert_all_pass(&verify_psbt(&foreign_psbt, &foreign_params)); - let err = verify_and_sign_psbt(&w.ours.keys, &foreign_psbt, &foreign_params).unwrap_err(); - assert_eq!( - err, - SdkError::InvalidInput { - reason: "params.xpubs are not the xpubs of the signing keys".into() - } - ); - // The foreign wallet itself signs it. - let result = verify_and_sign_psbt(&w.foreign.keys, &foreign_psbt, &foreign_params).unwrap(); - assert_signed_and_finalized(&result.signed_psbt, &result.txid); - // Foreign keys with OUR params: refused up front as a caller bug, before - // any verdict is even computed. - let err = verify_and_sign_psbt(&w.foreign.keys, fixture_unsigned(&w), &p).unwrap_err(); - assert!(matches!(&err, SdkError::InvalidInput { .. }), "{err:?}"); - // Foreign keys with THEIR params see our fixture PSBT as foreign input. - let mut theirs = p.clone(); - theirs.xpubs = w.foreign.keys.xpubs(); - let err = verify_and_sign_psbt(&w.foreign.keys, fixture_unsigned(&w), &theirs).unwrap_err(); - assert!( - matches!(&err, SdkError::VerificationFailed { check, .. } if check == "inputs-own"), - "{err:?}" - ); -} - -#[test] -fn sign_through_a_decoy_key_origin_entry_ordered_before_ours() { - let w = wallets(); - // verify selects the entry carrying OUR master fingerprint; sign must - // use that same entry. Selecting by path shape would pick the decoy (a - // foreign fingerprint on a well-formed own path) whenever it sorts - // first, and signing would fail against a key no check covered. - for decoy_index in [1u32, 2, 3, 4] { - let mut decoy = w.foreign.origin(false, 0, decoy_index); - decoy.path = w.ours.origin(false, 0, decoy_index).path; - let psbt = happy_psbt( - &w, - Happy { - input_origins: Some(vec![decoy, w.ours.origin(false, 0, 0)]), - input_internal_key: Some(w.ours.xonly_at(false, 0, 0)), - ..Happy::default() - }, - ); - let result = - verify_and_sign_psbt(&w.ours.keys, &psbt, ¶ms(&w, send_btc_intent(&w), 2_000)) - .unwrap_or_else(|e| panic!("decoy at {decoy_index}: {e}")); - assert_all_pass(&result.verdict); - assert_signed_and_finalized(&result.signed_psbt, &result.txid); - } -} - -#[test] -fn sign_refuses_sighash_types_that_do_not_commit_to_the_verdict() { - let w = wallets(); - let p = params(&w, send_btc_intent(&w), 2_000); - let base: Psbt = fixture_unsigned(&w).parse().unwrap(); - // DEFAULT and ALL commit to every output the verdict approved; both sign. - for ty in [TapSighashType::Default, TapSighashType::All] { - let mut psbt = base.clone(); - psbt.inputs[0].sighash_type = Some(ty.into()); - let result = verify_and_sign_psbt(&w.ours.keys, &psbt.to_string(), &p) - .unwrap_or_else(|e| panic!("{ty}: {e}")); - let signed: Psbt = result.signed_psbt.parse().unwrap(); - let sig = signed.inputs[0] - .final_script_witness - .as_ref() - .unwrap() - .nth(0) - .unwrap(); - // ALL is encoded with a trailing sighash byte (65 bytes), DEFAULT as - // 64 bytes — both are valid key-path witnesses. - assert_eq!( - sig.len(), - if ty == TapSighashType::Default { - 64 - } else { - 65 - }, - "{ty}" - ); - assert_eq!(result.txid, base.unsigned_tx.compute_txid().to_string()); - } - // NONE / SINGLE / ANYONECANPAY variants would let outputs change after - // approval: refused after verification, with the input index named. - for ty in [ - TapSighashType::None, - TapSighashType::Single, - TapSighashType::AllPlusAnyoneCanPay, - TapSighashType::NonePlusAnyoneCanPay, - TapSighashType::SinglePlusAnyoneCanPay, - ] { - let mut psbt = base.clone(); - psbt.inputs[0].sighash_type = Some(ty.into()); - let err = verify_and_sign_psbt(&w.ours.keys, &psbt.to_string(), &p).unwrap_err(); - assert!( - matches!(&err, SdkError::SigningFailed { reason } - if reason.starts_with("input 0: unsupported sighash type")), - "{ty}: {err:?}" - ); - } - // A raw byte that is no taproot sighash type at all takes the same - // refusal path (the catch-all arm), not a panic. - for raw in [4u32, 0x7f, 0x100] { - let mut psbt = base.clone(); - psbt.inputs[0].sighash_type = Some(bitcoin::psbt::PsbtSighashType::from_u32(raw)); - let err = verify_and_sign_psbt(&w.ours.keys, &psbt.to_string(), &p).unwrap_err(); - assert!( - matches!(&err, SdkError::SigningFailed { reason } - if reason.starts_with("input 0: unsupported sighash type")), - "raw {raw:#x}: {err:?}" - ); - } -} - -#[test] -fn sign_errors_never_render_key_material_and_never_panic() { - let w = wallets(); - let mnemonic = fixture_mnemonic(&w.fixture); - let p = params(&w, send_btc_intent(&w), 2_000); - let mut tampered = send_btc_intent(&w); - tampered.recipients[0].amount_sat = 39_000; - let unsigned = fixture_unsigned(&w); - let mut errors: Vec = vec![ - verify_and_sign_psbt(&w.ours.keys, unsigned, ¶ms(&w, tampered, 2_000)).unwrap_err(), - verify_and_sign_psbt(&w.ours.keys, unsigned, ¶ms(&w, send_btc_intent(&w), 1)) - .unwrap_err(), - verify_and_sign_psbt(&w.ours.keys, "!!!not a psbt!!!", &p).unwrap_err(), - verify_and_sign_psbt(&w.ours.keys, "", &p).unwrap_err(), - verify_and_sign_psbt(&w.ours.keys, "cHNidP8=", &p).unwrap_err(), - ]; - for len in (4..unsigned.len()).step_by(20) { - errors.push(verify_and_sign_psbt(&w.ours.keys, &unsigned[..len], &p).unwrap_err()); - } - for err in &errors { - // Hostile PSBTs are refused as a failed verdict, never a parse error - // or panic, so verification stays the single gate. - assert!( - matches!(err, SdkError::VerificationFailed { .. }), - "{err:?}" - ); - } - // Mismatched keys/xpubs and unsupported sighash types are the other two - // error shapes; their messages are fixed diagnostics plus an index. - errors.push(verify_and_sign_psbt(&w.foreign.keys, unsigned, &p).unwrap_err()); - let mut bad_xpubs = p.clone(); - bad_xpubs.xpubs.fingerprint = "zz".into(); - errors.push(verify_and_sign_psbt(&w.ours.keys, unsigned, &bad_xpubs).unwrap_err()); - let mut none: Psbt = unsigned.parse().unwrap(); - none.inputs[0].sighash_type = Some(TapSighashType::None.into()); - errors.push(verify_and_sign_psbt(&w.ours.keys, &none.to_string(), &p).unwrap_err()); - let other_mnemonic = fixture_str(&w.fixture["otherMnemonic"], "otherMnemonic"); - for err in &errors { - for m in [mnemonic.as_str(), other_mnemonic] { - assert_no_secret(&err.to_string(), m, "error display"); - assert_no_secret(&format!("{err:?}"), m, "error debug"); - } - } - // A successful result renders no secret either. - let ok: SignResult = verify_and_sign_psbt(&w.ours.keys, unsigned, &p).unwrap(); - assert_no_secret(&format!("{ok:?}"), &mnemonic, "sign result"); -} - -#[test] -fn sign_has_exactly_one_entry_point_and_it_verifies_first() { - // Structural guard for "no exported entry point signs without - // verifying": the schnorr signing call exists in exactly one place in - // the library, `src/sign.rs`, whose only `pub fn` is - // `verify_and_sign_psbt`, and the first thing that function does is run - // `verify_psbt`. Add a second signing path and this test names it. - let src = manifest_dir().join("src"); - let mut signing_sites: Vec<(String, usize)> = Vec::new(); - let mut pub_fns_in_sign: Vec = Vec::new(); - for entry in fs::read_dir(&src).unwrap().filter_map(Result::ok) { - let path = entry.path(); - if path.extension().is_none_or(|e| e != "rs") { - continue; - } - let name = path.file_name().unwrap().to_string_lossy().into_owned(); - let text = fs::read_to_string(&path).unwrap(); - for (n, line) in text.lines().enumerate() { - let code = line.split("//").next().unwrap_or(""); - if code.contains("sign_schnorr") { - signing_sites.push((name.clone(), n + 1)); - } - if name == "sign.rs" && code.trim_start().starts_with("pub fn ") { - pub_fns_in_sign.push(code.trim().to_owned()); - } - } - } - assert_eq!( - signing_sites.len(), - 1, - "exactly one schnorr signing site: {signing_sites:?}" - ); - assert_eq!(signing_sites[0].0, "sign.rs"); - assert_eq!(pub_fns_in_sign, ["pub fn verify_and_sign_psbt("]); - let sign_rs = fs::read_to_string(src.join("sign.rs")).unwrap(); - let body_start = sign_rs.find("pub fn verify_and_sign_psbt(").unwrap(); - let first_verify = sign_rs[body_start..] - .find("verify_psbt(psbt_base64, params)") - .unwrap(); - let first_sign = sign_rs[body_start..].find("sign_schnorr(").unwrap(); - assert!(first_verify < first_sign, "verification precedes signing"); - // No opt-out anywhere in the module's code (comments may name what they - // rule out). - let code_only = |text: &str| -> String { - text.lines() - .filter(|l| !l.trim_start().starts_with("//")) - .collect::>() - .join("\n") - }; - let sign_code = code_only(&sign_rs); - for forbidden in ["skip_verify", "unchecked", "force", "unsafe "] { - assert!( - !sign_code.contains(forbidden), - "sign.rs contains {forbidden:?}" - ); - } - // The uniffi surface exposes the same single function under the same name. - let ffi_rs = fs::read_to_string(src.join("ffi.rs")).unwrap(); - let sign_exports: Vec<&str> = ffi_rs - .lines() - .filter(|l| l.contains("#[uniffi::export") && l.contains("sign")) - .collect(); - assert_eq!( - sign_exports, - ["#[uniffi::export(name = \"verify_and_sign_psbt\")]"], - "exactly one signing export, and it verifies" - ); - // The only things ffi.rs reaches in the sign module are its result type - // and the verifying entry point: no other signer is exported. - let ffi_code = code_only(&ffi_rs); - for (at, _) in ffi_code.match_indices("sign::") { - let rest = &ffi_code[at + "sign::".len()..]; - assert!( - rest.starts_with("SignResult") || rest.starts_with("verify_and_sign_psbt("), - "ffi.rs reaches into sign:: at an unexpected item: {}", - &rest[..rest.len().min(40)] - ); - } -} - -// --------------------------------------------------------------------------- -// Task 6: invoice decoding — BOLT-11 spec vectors (the reference is -// packages/client-sdk/test/invoice.test.ts) and the real rgb-lib RGB invoice -// from the parity fixture. Every malformed input is `InvoiceDecode`, only. -// --------------------------------------------------------------------------- - -use utexo_minimal_sdk::{decode_bolt11, decode_rgb_invoice, BeneficiaryKind}; - -const SPEC_PAYEE: &str = "03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad"; -const SPEC_HASH: &str = "0001020304050607080900010203040506070809000102030405060708090102"; -const SPEC_SECRET: &str = "1111111111111111111111111111111111111111111111111111111111111111"; - -const VECTOR_ANY_AMOUNT: &str = "lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql"; -const VECTOR_COFFEE: &str = "lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh"; -const VECTOR_UTF8: &str = "lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpu9qrsgqhtjpauu9ur7fw2thcl4y9vfvh4m9wlfyz2gem29g5ghe2aak2pm3ps8fdhtceqsaagty2vph7utlgj48u0ged6a337aewvraedendscp573dxr"; -const VECTOR_HASHED: &str = "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp7ynn44"; -const VECTOR_TESTNET: &str = "lntb20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfpp3x9et2e20v6pu37c5d9vax37wxq72un989qrsgqdj545axuxtnfemtpwkc45hx9d2ft7x04mt8q7y6t0k2dge9e7h8kpy9p34ytyslj3yu569aalz2xdk8xkd7ltxqld94u8h2esmsmacgpghe9k8"; - -fn assert_invoice_decode_error(result: Result, what: &str) { - match result { - Err(SdkError::InvoiceDecode { reason }) => { - assert!(!reason.is_empty(), "{what}: empty reason") - } - other => panic!("{what}: expected InvoiceDecode, got {other:?}"), - } -} - -#[test] -fn bolt11_any_amount_donation_vector() { - let inv = decode_bolt11(VECTOR_ANY_AMOUNT).unwrap(); - assert_eq!(inv.network, BitcoinNetwork::Mainnet); - assert_eq!(inv.amount_msat, None); - assert_eq!(inv.payment_hash, SPEC_HASH); - assert_eq!(inv.payment_secret.as_deref(), Some(SPEC_SECRET)); - assert_eq!( - inv.description.as_deref(), - Some("Please consider supporting this project") - ); - assert_eq!(inv.description_hash, None); - assert_eq!(inv.payee_node_id, SPEC_PAYEE); - assert_eq!(inv.timestamp, 1_496_314_658); - assert_eq!(inv.expiry_seconds, 3600); -} - -#[test] -fn bolt11_coffee_vector_amount_and_expiry() { - let inv = decode_bolt11(VECTOR_COFFEE).unwrap(); - assert_eq!(inv.network, BitcoinNetwork::Mainnet); - assert_eq!(inv.amount_msat, Some(250_000_000)); - assert_eq!(inv.description.as_deref(), Some("1 cup coffee")); - assert_eq!(inv.expiry_seconds, 60); - assert_eq!(inv.payee_node_id, SPEC_PAYEE); - assert_eq!(inv.payment_hash, SPEC_HASH); -} - -#[test] -fn bolt11_utf8_description_vector() { - let inv = decode_bolt11(VECTOR_UTF8).unwrap(); - assert_eq!(inv.description.as_deref(), Some("ナンセンス 1杯")); - assert_eq!(inv.amount_msat, Some(250_000_000)); - assert_eq!(inv.payee_node_id, SPEC_PAYEE); -} - -#[test] -fn bolt11_hashed_description_vector() { - let inv = decode_bolt11(VECTOR_HASHED).unwrap(); - assert_eq!(inv.amount_msat, Some(2_000_000_000)); - assert_eq!(inv.description, None); - assert_eq!( - inv.description_hash.as_deref(), - Some("3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1") - ); - assert_eq!(inv.payee_node_id, SPEC_PAYEE); -} - -#[test] -fn bolt11_testnet_vector_ignores_fallback_tag() { - let inv = decode_bolt11(VECTOR_TESTNET).unwrap(); - assert_eq!(inv.network, BitcoinNetwork::Testnet); - assert_eq!(inv.amount_msat, Some(2_000_000_000)); - assert_eq!(inv.payee_node_id, SPEC_PAYEE); - assert_eq!(inv.payment_hash, SPEC_HASH); -} - -#[test] -fn bolt11_uppercase_input_decodes_identically() { - let lower = decode_bolt11(VECTOR_COFFEE).unwrap(); - let upper = decode_bolt11(&VECTOR_COFFEE.to_uppercase()).unwrap(); - assert_eq!(lower, upper); - // Mixed case is lowercased before decoding, as the TS reference does - // (`invoice.toLowerCase()`): pasted invoices with a capitalised prefix - // still decode, and the signature message uses the lowercase HRP. - let mut mixed = VECTOR_COFFEE.to_owned(); - mixed.replace_range(0..2, "LN"); - assert_eq!(decode_bolt11(&mixed).unwrap(), lower); -} - -#[test] -fn bolt11_malformed_inputs_are_invoice_decode_only() { - // Wrong checksum: flip the last character. - let last = VECTOR_COFFEE.chars().last().unwrap(); - let corrupted = format!( - "{}{}", - &VECTOR_COFFEE[..VECTOR_COFFEE.len() - 1], - if last == 'h' { 'k' } else { 'h' } - ); - assert_invoice_decode_error(decode_bolt11(&corrupted), "checksum"); - // Non-lightning bech32 (a segwit address). - assert_invoice_decode_error( - decode_bolt11("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"), - "segwit address", - ); - // Truncated data. - assert_invoice_decode_error(decode_bolt11("lnbc1pvjluez"), "truncated"); - // Not bech32 at all. - for junk in [ - "", - "1", - "lnbc", - "lnbc1", - "rgb:~/~/~/x", - "lnbc1\u{00e9}", - " lnbc1pvjluez", - ] { - assert_invoice_decode_error(decode_bolt11(junk), junk); - } -} - -#[test] -fn bolt11_every_truncation_and_corruption_is_ok_or_invoice_decode() { - // User-pasted strings: no position may panic or surface any other - // error variant. Truncations at every length, and one-character - // corruption at every position, of a real vector. - let charset: Vec = "qpzry9x8gf2tvdw0s3jn54khce6mua7l".chars().collect(); - let mut decoded_ok = 0usize; - for vector in [VECTOR_COFFEE, VECTOR_TESTNET] { - for len in 0..=vector.len() { - match decode_bolt11(&vector[..len]) { - Ok(_) => decoded_ok += 1, - Err(SdkError::InvoiceDecode { .. }) => {} - Err(other) => panic!("truncation {len}: {other:?}"), - } - } - let chars: Vec = vector.chars().collect(); - for pos in 0..chars.len() { - let mut mutated = chars.clone(); - let replacement = charset.iter().copied().find(|c| *c != chars[pos]).unwrap(); - mutated[pos] = replacement; - let s: String = mutated.into_iter().collect(); - match decode_bolt11(&s) { - Ok(_) => decoded_ok += 1, - Err(SdkError::InvoiceDecode { .. }) => {} - Err(other) => panic!("corruption at {pos}: {other:?}"), - } - } - } - // Only the untruncated originals can decode (2), never a corruption: - // the checksum catches every single-character change. - assert_eq!(decoded_ok, 2, "corrupted vectors must not decode"); -} - -// --- Test-side BOLT-11 encoder: needed to prove the length limit is off and -// --- to exercise the `n` (payee) tag, which none of the spec vectors carries. - -fn bytes_to_words(bytes: &[u8]) -> Vec { - let mut words = Vec::new(); - let mut acc: u64 = 0; - let mut bits = 0u32; - for &b in bytes { - acc = ((acc << 8) | u64::from(b)) & 0xffff_ffff; - bits += 8; - while bits >= 5 { - bits -= 5; - words.push(((acc >> bits) & 0x1f) as u8); - } - } - if bits > 0 { - words.push(((acc << (5 - bits)) & 0x1f) as u8); - } - words -} - -fn words_to_bytes_padded(words: &[u8]) -> Vec { - let mut bytes = Vec::new(); - let mut acc: u64 = 0; - let mut bits = 0u32; - for &w in words { - acc = ((acc << 5) | u64::from(w)) & 0xffff_ffff; - bits += 5; - while bits >= 8 { - bits -= 8; - bytes.push(((acc >> bits) & 0xff) as u8); - } - } - if bits > 0 { - bytes.push(((acc << (8 - bits)) & 0xff) as u8); - } - bytes -} - -fn tag(tag_type: u8, data: Vec) -> Vec { - // A tag length is 10 bits: at most 1023 words per tagged field. - assert!( - data.len() < 1024, - "tag {tag_type} data too long: {} words", - data.len() - ); - let mut out = vec![tag_type, (data.len() / 32) as u8, (data.len() % 32) as u8]; - out.extend(data); - out -} - -/// Encode and sign a BOLT-11 invoice over `hrp` and pre-signature data words. -/// Uses the same checksum engine the library uses, deliberately without the -/// crate's length-limited `encode`. -fn encode_bolt11(hrp: &str, data_words: &[u8], secret: &bitcoin::secp256k1::SecretKey) -> String { - use bitcoin::bech32::primitives::checksum::{Checksum, Engine, PackedFe32}; - use bitcoin::bech32::{Bech32, Fe32, Hrp}; - use bitcoin::hashes::{sha256, Hash}; - use bitcoin::secp256k1::Message; - - let secp = Secp256k1::new(); - let mut message = hrp.as_bytes().to_vec(); - message.extend_from_slice(&words_to_bytes_padded(data_words)); - let digest = Message::from_digest(sha256::Hash::hash(&message).to_byte_array()); - let (recid, compact) = secp - .sign_ecdsa_recoverable(&digest, secret) - .serialize_compact(); - let mut sig = compact.to_vec(); - sig.push(recid.to_i32() as u8); - let mut words = data_words.to_vec(); - words.extend(bytes_to_words(&sig)); - assert_eq!(words.len(), data_words.len() + 104); - - let charset: Vec = "qpzry9x8gf2tvdw0s3jn54khce6mua7l".chars().collect(); - let parsed_hrp = Hrp::parse(hrp).unwrap(); - let mut engine = Engine::::new(); - engine.input_hrp(parsed_hrp); - for &w in &words { - engine.input_fe(Fe32::from_char(charset[usize::from(w)]).unwrap()); - } - engine.input_target_residue(); - let residue = *engine.residue(); - let mut out = format!("{hrp}1"); - for &w in &words { - out.push(charset[usize::from(w)]); - } - for i in 0..Bech32::CHECKSUM_LENGTH { - out.push(charset[usize::from(residue.unpack(Bech32::CHECKSUM_LENGTH - i - 1))]); - } - out -} - -fn spec_like_data_words(description: &str, payee_tag: Option<&PublicKey>) -> Vec { - let mut data = bytes_to_words(&1_496_314_658u64.to_be_bytes()[3..]); // 5 bytes = 8 words - // Timestamp is 7 words (35 bits): drop the leading zero word. - data.remove(0); - assert_eq!(data.len(), 7); - let hash: Vec = (0..32).map(|i| i as u8).collect(); - data.extend(tag(1, bytes_to_words(&hash))); - data.extend(tag(16, bytes_to_words(&[0x11; 32]))); - data.extend(tag(13, bytes_to_words(description.as_bytes()))); - if let Some(pk) = payee_tag { - data.extend(tag(19, bytes_to_words(&pk.serialize()))); - } - data.extend(tag(6, vec![1, 28])); // expiry 60 - data -} - -#[test] -fn bolt11_length_limit_is_disabled_and_payee_recovers() { - // A 580-byte description plus a 600-word route-hint tag (type 3, which - // the decoder skips) make the invoice far longer than the 1023-char - // bech32 code length that the crate's checked decoder enforces; BOLT-11 - // lifts that limit and so must we (route-hinted invoices exceed it). - let secp = Secp256k1::new(); - let secret = bitcoin::secp256k1::SecretKey::from_slice(&[7u8; 32]).unwrap(); - let payee = PublicKey::from_secret_key(&secp, &secret); - let description: String = "route-hint sized description ".repeat(20); - assert!(description.len() > 570); - let mut data = spec_like_data_words(&description, None); - data.extend(tag(3, (0..600u32).map(|i| (i % 32) as u8).collect())); - let invoice = encode_bolt11("lnbcrt2500u", &data, &secret); - assert!( - invoice.len() > 1023, - "invoice must exceed the bech32 code length ({} chars)", - invoice.len() - ); - let inv = decode_bolt11(&invoice).unwrap(); - assert_eq!(inv.network, BitcoinNetwork::Regtest); - assert_eq!(inv.amount_msat, Some(250_000_000)); - assert_eq!(inv.payee_node_id, payee.to_string()); - assert_eq!(inv.description.as_deref(), Some(description.as_str())); - assert_eq!(inv.payment_secret.as_deref(), Some(SPEC_SECRET)); - assert_eq!(inv.timestamp, 1_496_314_658); - assert_eq!(inv.expiry_seconds, 60); - // A flipped payload character on the long invoice still fails checksum. - let mut chars: Vec = invoice.chars().collect(); - let mid = chars.len() / 2; - chars[mid] = if chars[mid] == 'q' { 'p' } else { 'q' }; - let corrupted: String = chars.into_iter().collect(); - assert_invoice_decode_error(decode_bolt11(&corrupted), "long invoice checksum"); -} - -#[test] -fn bolt11_payee_tag_is_verified_against_the_signature() { - let secp = Secp256k1::new(); - let secret = bitcoin::secp256k1::SecretKey::from_slice(&[9u8; 32]).unwrap(); - let payee = PublicKey::from_secret_key(&secp, &secret); - let other = PublicKey::from_secret_key( - &secp, - &bitcoin::secp256k1::SecretKey::from_slice(&[10u8; 32]).unwrap(), - ); - // `n` tag matching the signer: accepted, payee taken from the tag. - let data = spec_like_data_words("with n tag", Some(&payee)); - let inv = decode_bolt11(&encode_bolt11("lntbs10n", &data, &secret)).unwrap(); - assert_eq!(inv.network, BitcoinNetwork::Signet); - assert_eq!(inv.amount_msat, Some(1_000)); - assert_eq!(inv.payee_node_id, payee.to_string()); - // `n` tag naming a different key than the signer: refused. - let data = spec_like_data_words("with wrong n tag", Some(&other)); - assert_invoice_decode_error( - decode_bolt11(&encode_bolt11("lntbs10n", &data, &secret)), - "payee tag mismatch", - ); - // No payment hash at all: refused even though the signature is valid. - let mut data = bytes_to_words(&1_496_314_658u64.to_be_bytes()[3..]); - data.remove(0); - data.extend(tag(13, bytes_to_words(b"no hash"))); - assert_invoice_decode_error( - decode_bolt11(&encode_bolt11("lnbc", &data, &secret)), - "missing payment hash", - ); -} - -#[test] -fn bolt11_wrong_length_and_duplicate_hash_tags_follow_the_spec() { - // BOLT-11: a reader MUST skip `p`/`s`/`h` fields of the wrong length and - // MUST use the first of duplicated known fields. - let secret = bitcoin::secp256k1::SecretKey::from_slice(&[11u8; 32]).unwrap(); - let timestamp = || { - let mut data = bytes_to_words(&1_496_314_658u64.to_be_bytes()[3..]); - data.remove(0); - data - }; - // A 51-word `p` tag is skipped, which leaves the invoice without a hash. - let mut data = timestamp(); - data.extend(tag(1, vec![1; 51])); - data.extend(tag(16, bytes_to_words(&[0x11; 32]))); - data.extend(tag(13, bytes_to_words(b"short hash"))); - assert_invoice_decode_error( - decode_bolt11(&encode_bolt11("lnbc", &data, &secret)), - "wrong-length payment hash", - ); - // A 53-word `p` tag is skipped too; a well-formed one after it is used. - let hash: Vec = (0..32).map(|i| i as u8).collect(); - let mut data = timestamp(); - data.extend(tag(1, vec![1; 53])); - data.extend(tag(1, bytes_to_words(&hash))); - data.extend(tag(16, bytes_to_words(&[0x11; 32]))); - data.extend(tag(13, bytes_to_words(b"long then good"))); - let hash_hex: String = hash.iter().map(|b| format!("{b:02x}")).collect(); - let inv = decode_bolt11(&encode_bolt11("lnbc", &data, &secret)).unwrap(); - assert_eq!(inv.payment_hash, hash_hex); - // Two well-formed `p` tags: the first wins, the second is ignored. - let other: Vec = (0..32).map(|i| 0xff - i as u8).collect(); - let mut data = timestamp(); - data.extend(tag(1, bytes_to_words(&hash))); - data.extend(tag(1, bytes_to_words(&other))); - data.extend(tag(16, bytes_to_words(&[0x11; 32]))); - data.extend(tag(16, bytes_to_words(&[0x22; 32]))); - data.extend(tag(13, bytes_to_words(b"duplicates"))); - let inv = decode_bolt11(&encode_bolt11("lnbc", &data, &secret)).unwrap(); - assert_eq!(inv.payment_hash, hash_hex); - assert_eq!(inv.payment_secret.as_deref(), Some(SPEC_SECRET)); - // A wrong-length `s` tag leaves the secret absent rather than garbled. - let mut data = timestamp(); - data.extend(tag(1, bytes_to_words(&hash))); - data.extend(tag(16, vec![2; 50])); - data.extend(tag(13, bytes_to_words(b"short secret"))); - let inv = decode_bolt11(&encode_bolt11("lnbc", &data, &secret)).unwrap(); - assert_eq!(inv.payment_secret, None); -} - -#[test] -fn rgb_invoice_fixture_witness_receive_matches_rgb_lib() { - let fixture = load_fixture(); - let wr = &fixture["witnessReceive"]; - let invoice = fixture_str(&wr["invoice"], "witnessReceive.invoice"); - let inv = decode_rgb_invoice(invoice).unwrap(); - assert_eq!(inv.asset_id, None); - assert_eq!(inv.schema, None); - assert_eq!(inv.amount, None); - assert_eq!(inv.assignment_raw, None); - assert_eq!( - inv.recipient_id, - fixture_str(&wr["recipientId"], "witnessReceive.recipientId") - ); - assert_eq!(inv.beneficiary_kind, BeneficiaryKind::Witness); - assert_eq!(inv.chain.as_deref(), Some("bcrt")); - assert_eq!( - inv.expiry_timestamp, - Some( - wr["expirationTimestamp"] - .as_u64() - .expect("expirationTimestamp") - ) - ); - assert_eq!(inv.transport_endpoints, ["rpc://localhost:3000/json-rpc"]); - assert_eq!(inv.assignment_name, None); -} - -#[test] -fn rgb_invoice_blind_with_asset_amount_and_endpoints() { - let inv = decode_rgb_invoice( - "rgb:erRCLIhl-nBu1DdF-M8YCRNH-Y3rB0W3-hLZH1DP-1AY9zpQ/RGB20Fixed/100/bcrt:utxob:4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?expiry=1700000000&endpoints=rpc://proxy-a/json-rpc,rpcs://proxy-b/json-rpc", - ) - .unwrap(); - assert_eq!( - inv.asset_id.as_deref(), - Some("rgb:erRCLIhl-nBu1DdF-M8YCRNH-Y3rB0W3-hLZH1DP-1AY9zpQ") - ); - assert_eq!(inv.schema.as_deref(), Some("RGB20Fixed")); - assert_eq!(inv.amount, Some(100)); - assert_eq!(inv.beneficiary_kind, BeneficiaryKind::Blind); - assert_eq!( - inv.recipient_id, - "bcrt:utxob:4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa" - ); - assert_eq!(inv.chain.as_deref(), Some("bcrt")); - assert_eq!(inv.expiry_timestamp, Some(1_700_000_000)); - assert_eq!( - inv.transport_endpoints, - ["rpc://proxy-a/json-rpc", "rpcs://proxy-b/json-rpc"] - ); -} - -#[test] -fn rgb_invoice_non_numeric_state_and_assignment_name() { - let inv = decode_rgb_invoice( - "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/~/BF/bc:utxob:4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?assignment_name=assetOwner", - ) - .unwrap(); - assert_eq!(inv.amount, None); - assert_eq!(inv.assignment_raw.as_deref(), Some("BF")); - assert_eq!(inv.assignment_name.as_deref(), Some("assetOwner")); - assert_eq!(inv.chain.as_deref(), Some("bc")); - assert_eq!(inv.schema, None); - assert_eq!(inv.transport_endpoints, Vec::::new()); - assert_eq!(inv.expiry_timestamp, None); - // Unknown beneficiary grammar passes the id through, kind Unknown. - let inv = decode_rgb_invoice("rgb:~/~/~/bcrt1qsomething").unwrap(); - assert_eq!(inv.beneficiary_kind, BeneficiaryKind::Unknown); - assert_eq!(inv.chain, None); - assert_eq!(inv.recipient_id, "bcrt1qsomething"); - // Percent-encoded endpoints are decoded; empty endpoints dropped. - let inv = - decode_rgb_invoice("rgb:~/~/~/bcrt:utxob:x?endpoints=rpc%3A%2F%2Fa%2Fjson-rpc,,").unwrap(); - assert_eq!(inv.transport_endpoints, ["rpc://a/json-rpc"]); -} - -#[test] -fn rgb_invoice_malformed_inputs_are_invoice_decode_only() { - for bad in [ - "rgbx:~/~/~/bcrt:utxob:x", - "rgb:~/~/bcrt:utxob:x", - "rgb:~/~/~/", - "rgb:~/~/~/bcrt:utxob:x?expiry=soon", - "rgb:~/~/~/bcrt:utxob:x?flag", - "rgb:~/~/~/~/bcrt:utxob:x", - "rgb:~/~/99999999999999999999999/bcrt:utxob:x", - "rgb:~/~/~/bcrt:utxob:x?expiry=99999999999999999999999", - "", - "rgb:", - "lnbc1pvjluez", - ] { - assert_invoice_decode_error(decode_rgb_invoice(bad), bad); - } - // Every truncation of the fixture invoice is Ok or InvoiceDecode. - let fixture = load_fixture(); - let invoice = fixture_str(&fixture["witnessReceive"]["invoice"], "invoice"); - for len in 0..=invoice.len() { - if !invoice.is_char_boundary(len) { - continue; - } - match decode_rgb_invoice(&invoice[..len]) { - Ok(_) | Err(SdkError::InvoiceDecode { .. }) => {} - Err(other) => panic!("truncation {len}: {other:?}"), - } - } -} - -#[test] -fn invoice_decoders_are_exported_and_error_variant_is_named_invoice_decode() { - let ffi_rs = fs::read_to_string(manifest_dir().join("src/ffi.rs")).unwrap(); - assert!(ffi_rs.contains("#[uniffi::export(name = \"decode_bolt11\")]")); - assert!(ffi_rs.contains("#[uniffi::export(name = \"decode_rgb_invoice\")]")); - let lib_rs = fs::read_to_string(manifest_dir().join("src/lib.rs")).unwrap(); - assert!(lib_rs.contains("InvoiceDecode { reason: String }")); - assert!(!lib_rs.contains("InvalidInvoice")); - // The invoice module returns no other variant. - let invoice_rs = fs::read_to_string(manifest_dir().join("src/invoice.rs")).unwrap(); - for variant in [ - "InvalidInput", - "InvalidPsbt", - "SigningFailed", - "Internal {", - "Transport", - "Gateway", - "unwrap()", - "expect(", - ] { - let body = invoice_rs.split("#[cfg(test)]").next().unwrap(); - assert!(!body.contains(variant), "invoice.rs uses {variant}"); - } -} - -// --------------------------------------------------------------------------- -// Task 7: gateway client — every route through a fake in-Rust transport (no -// network), intent binding for the three prepare* methods, idempotency keys, -// error mapping and secrets hygiene. The reference is -// packages/client-sdk/test/gateway.test.ts. -// --------------------------------------------------------------------------- - -use std::collections::VecDeque; -use std::sync::{Arc, Mutex}; - -use utexo_minimal_sdk::gateway::{ - CompleteParams, LnAssetKind, LnDepositPrepareParams, LnInvoiceCreateParams, LnInvoiceState, - LnPayParams, LnPaymentDirection, LnPaymentStatus, LnWithdrawParams, PrepareCreateUtxosParams, - PrepareSendAssetParams, PrepareSendBtcParams, ReceiveMode, ReceiveParams, RegisterXpubsParams, -}; -use utexo_minimal_sdk::{ - encode_uri_component, generate_idempotency_key, GatewayClient, HttpMethod, HttpRequest, - HttpResponse, HttpTransport, OperationState, -}; - -/// Records every request and answers from a queue. Implements the same -/// foreign trait Kotlin/Swift will, so the client is exercised exactly as it -/// is over uniffi, minus the FFI hop. -#[derive(Default)] -struct FakeTransport { - seen: Mutex>, - responses: Mutex>>, -} - -impl FakeTransport { - fn with(responses: Vec>) -> Arc { - Arc::new(FakeTransport { - seen: Mutex::new(Vec::new()), - responses: Mutex::new(responses.into()), - }) - } - - fn seen(&self) -> Vec { - self.seen.lock().unwrap().clone() - } - - fn push(&self, response: Result) { - self.responses.lock().unwrap().push_back(response); - } -} - -impl HttpTransport for FakeTransport { - fn send(&self, request: HttpRequest) -> Result { - self.seen.lock().unwrap().push(request); - self.responses - .lock() - .unwrap() - .pop_front() - .expect("FakeTransport: no response queued") - } -} - -fn ok(status: u16, body: serde_json::Value) -> Result { - Ok(HttpResponse { - status, - body: body.to_string(), - }) -} - -const UUID_V4_LEN: usize = 36; - -fn assert_uuid_v4(key: &str) { - assert_eq!(key.len(), UUID_V4_LEN, "{key}"); - let parts: Vec<&str> = key.split('-').collect(); - assert_eq!( - parts.iter().map(|p| p.len()).collect::>(), - [8, 4, 4, 4, 12], - "{key}" - ); - assert!( - key.bytes() - .all(|b| b == b'-' || (b.is_ascii_hexdigit() && !b.is_ascii_uppercase())), - "{key}" - ); - assert!(parts[2].starts_with('4'), "version nibble: {key}"); - assert!( - matches!(parts[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b'), - "variant bits: {key}" - ); -} - -fn client(transport: &Arc, token: Option<&str>) -> GatewayClient { - GatewayClient::new( - "http://gw.local", - token.map(str::to_owned), - None, - transport.clone(), - ) -} - -fn body_json(request: &HttpRequest) -> serde_json::Value { - serde_json::from_str(request.body.as_deref().expect("request has a body")).unwrap() -} - -fn assert_no_idempotency_key(request: &HttpRequest) { - assert!( - !request.headers.contains_key("idempotency-key"), - "{} carries an idempotency key", - request.url - ); -} - -/// The intent the fake gateway returns for the fixture PSBT: the same -/// 40 000 sat send to the foreign wallet that `send_btc_intent` describes. -fn fixture_send_btc_intent_json(w: &Wallets) -> serde_json::Value { - serde_json::json!({ - "kind": "send_btc", - "feeRateSatPerVb": 2, - "recipients": [{ - "address": w.foreign.address_at(false, 0, 0), - "scriptHex": w.foreign.script_at(false, 0, 0).to_hex_string(), - "amountSat": 40_000 - }], - "asset": null, - "utxos": null - }) -} - -fn prepared(intent: serde_json::Value) -> serde_json::Value { - serde_json::json!({ "opId": "op-1", "psbt": "cHNidP8=", "expiresAt": 1, "intent": intent }) -} - -const ADDRESS: &str = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080"; -const ATTACKER: &str = "bcrt1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3"; - -fn send_btc_intent_json() -> serde_json::Value { - serde_json::json!({ - "kind": "send_btc", - "feeRateSatPerVb": 2, - "recipients": [{ "address": ADDRESS, "scriptHex": "0014deadbeef", "amountSat": 40_000 }], - "asset": null, - "utxos": null - }) -} - -fn send_asset_intent_json() -> serde_json::Value { - serde_json::json!({ - "kind": "send_asset", - "feeRateSatPerVb": 2, - "recipients": [], - "asset": { - "assetId": "rgb:good", - "amount": 10, - "recipientId": "utxob:me", - "witnessAmountSat": null, - "transportEndpoints": ["http://proxy"] - }, - "utxos": null - }) -} - -fn create_utxos_intent_json() -> serde_json::Value { - serde_json::json!({ - "kind": "create_utxos", - "feeRateSatPerVb": 2, - "recipients": [], - "asset": null, - "utxos": { "upTo": false, "num": 4, "size": 1_000 } - }) -} - -fn send_btc_params() -> PrepareSendBtcParams { - PrepareSendBtcParams { - address: ADDRESS.into(), - amount_sat: 40_000, - fee_rate_sat_per_vb: None, - } -} - -fn send_asset_params() -> PrepareSendAssetParams { - PrepareSendAssetParams { - asset_id: "rgb:good".into(), - amount: 10, - recipient_id: "utxob:me".into(), - witness_amount_sat: None, - transport_endpoints: None, - donation: None, - min_confirmations: None, - fee_rate_sat_per_vb: None, - } -} - -fn assert_intent_mismatch( - result: Result, - expected_detail: &str, - what: &str, -) { - match result { - Err(SdkError::IntentMismatch { reason }) => assert!( - reason.contains(expected_detail), - "{what}: mismatch reason {reason:?} does not mention {expected_detail:?}" - ), - other => panic!("{what}: expected IntentMismatch, got {other:?}"), - } -} - -#[test] -fn gateway_every_route_round_trips_with_method_path_auth_and_body_shape() { - let hash = "c".repeat(64); - let transport = FakeTransport::with(vec![ - ok(200, serde_json::json!({ "userId": "u1", "createdAt": 7 })), - ok( - 200, - serde_json::json!({ "fingerprint": "73c5da0a", "address": "bcrt1p...a" }), - ), - ok(200, serde_json::json!({ "address": "bcrt1p...b" })), - ok( - 200, - serde_json::json!({ - "btc": { - "vanilla": { "settled": 1, "future": 2, "spendable": 3 }, - "colored": { "settled": 4, "future": 5, "spendable": 6 } - }, - "assets": [{ - "assetId": "rgb:x", "schema": "NIA", "ticker": null, "name": "X", - "precision": 8, "balance": { "settled": 7, "future": 8, "spendable": 9 } - }] - }), - ), - ok( - 200, - serde_json::json!({ "unspents": [{ - "txid": "aa", "vout": 1, "amountSat": 1000, "colorable": true, - "allocations": [{ "assetId": "rgb:x", "amount": 5, "settled": true }, - { "assetId": null, "amount": null, "settled": false }] - }] }), - ), - ok( - 200, - serde_json::json!({ "transfers": [{ - "idx": 3, "assetId": "rgb:x", "amount": 5, "kind": "issuance", "status": "settled", - "txid": null, "recipientId": null, "expiration": null, "createdAt": 1, "updatedAt": 2 - }] }), - ), - ok(200, serde_json::json!({ "transfers": [] })), - ok( - 201, - serde_json::json!({ - "invoice": "rgb:~/~/~/bcrt:utxob:x", "recipientId": "bcrt:utxob:x", - "expirationTimestamp": 99, "mode": "blind" - }), - ), - ok(200, serde_json::json!({ "status": "ok" })), - ok(200, prepared(send_btc_intent_json())), - ok(200, serde_json::json!({ "txid": "bb" })), - ok(200, prepared(send_asset_intent_json())), - ok(200, serde_json::json!({ "txid": "cc" })), - ok(200, prepared(create_utxos_intent_json())), - ok(200, serde_json::json!({ "txid": null, "utxosCreated": 4 })), - ok( - 201, - serde_json::json!({ - "depositId": "d", "kind": "btc", "address": "bcrt1q...", "invoice": null, - "recipientId": null - }), - ), - ok( - 200, - serde_json::json!({ "paymentHash": hash, "status": "pending" }), - ), - ok( - 201, - serde_json::json!({ "invoice": "lnbcrt1...", "paymentHash": hash }), - ), - ok( - 200, - serde_json::json!({ - "paymentHash": hash, "invoice": "lnbcrt1...", "state": "pending", - "amtMsat": 1000, "assetId": null, "assetAmount": null, "createdAt": 1 - }), - ), - ok( - 200, - serde_json::json!({ "payments": [{ - "paymentHash": hash, "direction": "outbound", "status": "succeeded", - "amtMsat": 1000, "assetId": null, "assetAmount": null, "createdAt": 1, - "updatedAt": 2 - }] }), - ), - ok( - 200, - serde_json::json!({ "btcMsat": 5000, "assets": { "rgb:x": 12 } }), - ), - ok( - 201, - serde_json::json!({ "withdrawalId": "w", "txid": "dd" }), - ), - ]); - let c = client(&transport, Some("tok-1")); - - assert_eq!(c.me().unwrap().user_id, "u1"); - let registered = c - .register_xpubs(&RegisterXpubsParams { - vanilla: "tpubV".into(), - colored: "tpubC".into(), - fingerprint: "73c5da0a".into(), - }) - .unwrap(); - assert_eq!(registered.address, "bcrt1p...a"); - assert_eq!(c.get_address().unwrap().address, "bcrt1p...b"); - let balances = c.get_balances().unwrap(); - assert_eq!(balances.btc.colored.spendable, 6); - assert_eq!(balances.assets[0].ticker, None); - assert_eq!(balances.assets[0].balance.settled, 7); - let unspents = c.get_unspents().unwrap(); - assert_eq!(unspents.unspents[0].allocations[0].amount, Some(5)); - assert_eq!(unspents.unspents[0].allocations[1].asset_id, None); - let transfers = c.get_transfers(Some("rgb:abc-123")).unwrap(); - assert_eq!(transfers.transfers[0].idx, 3); - assert!(c.get_transfers(None).unwrap().transfers.is_empty()); - let received = c - .receive(&ReceiveParams { - mode: ReceiveMode::Blind, - asset_id: Some("rgb:x".into()), - amount: Some(5), - duration_seconds: None, - min_confirmations: Some(1), - }) - .unwrap(); - assert_eq!(received.mode, ReceiveMode::Blind); - assert_eq!(received.expiration_timestamp, Some(99)); - assert_eq!(c.sync().unwrap().status, "ok"); - let op = c.prepare_send_btc(&send_btc_params(), None).unwrap(); - assert_eq!(op.op_id, "op-1"); - assert_eq!(op.intent.kind, IntentKind::SendBtc); - let complete = CompleteParams { - op_id: "op-1".into(), - signed_psbt: "cHNidP8=".into(), - }; - assert_eq!(c.complete_send_btc(&complete, None).unwrap().txid, "bb"); - let op = c.prepare_send_asset(&send_asset_params(), None).unwrap(); - assert_eq!(op.intent.asset.as_ref().unwrap().asset_id, "rgb:good"); - assert_eq!(c.complete_send_asset(&complete, None).unwrap().txid, "cc"); - let op = c - .prepare_create_utxos( - &PrepareCreateUtxosParams { - num: Some(4), - size: None, - up_to: None, - fee_rate_sat_per_vb: None, - }, - None, - ) - .unwrap(); - assert_eq!(op.intent.utxos.as_ref().unwrap().num, 4); - let created = c.complete_create_utxos(&complete, None).unwrap(); - assert_eq!((created.txid, created.utxos_created), (None, 4)); - let deposit = c - .prepare_ln_deposit( - &LnDepositPrepareParams { - kind: LnAssetKind::Btc, - amount_msat: Some(1_000_000), - asset_id: None, - amount: None, - }, - None, - ) - .unwrap(); - assert_eq!(deposit.address.as_deref(), Some("bcrt1q...")); - assert_eq!(deposit.kind, LnAssetKind::Btc); - let paid = c - .pay_ln_invoice( - &LnPayParams { - invoice: "lnbcrt1...".into(), - amt_msat: None, - asset_amount: None, - }, - None, - ) - .unwrap(); - assert_eq!(paid.status, LnPaymentStatus::Pending); - let invoice = c - .create_ln_invoice(&LnInvoiceCreateParams { - amt_msat: Some(1000), - expiry_sec: None, - asset_id: None, - asset_amount: None, - description: Some("coffee".into()), - }) - .unwrap(); - assert_eq!(invoice.payment_hash, hash); - let info = c.get_ln_invoice("../v1/ln/balance").unwrap(); - assert_eq!(info.state, LnInvoiceState::Pending); - let payments = c.list_ln_payments().unwrap(); - assert_eq!(payments.payments[0].direction, LnPaymentDirection::Outbound); - assert_eq!(payments.payments[0].status, LnPaymentStatus::Succeeded); - let ln_balance = c.get_ln_balance().unwrap(); - assert_eq!(ln_balance.btc_msat, 5000); - assert_eq!(ln_balance.assets.get("rgb:x"), Some(&12)); - let withdrawn = c - .withdraw_ln( - &LnWithdrawParams { - kind: LnAssetKind::Btc, - address: Some("bcrt1q...".into()), - amount_sat: Some(1_000), - asset_id: None, - amount: None, - recipient_id: None, - witness_amount_sat: None, - transport_endpoints: None, - fee_rate_sat_per_vb: None, - }, - None, - ) - .unwrap(); - assert_eq!(withdrawn.txid, "dd"); - - // Method, path, auth header and body shape, in call order. - let seen = transport.seen(); - let expected: Vec<(HttpMethod, &str, Option, bool)> = vec![ - (HttpMethod::Get, "/v1/me", None, false), - ( - HttpMethod::Post, - "/v1/wallet/xpubs", - Some( - serde_json::json!({ "vanilla": "tpubV", "colored": "tpubC", "fingerprint": "73c5da0a" }), - ), - false, - ), - (HttpMethod::Get, "/v1/wallet/address", None, false), - (HttpMethod::Get, "/v1/wallet/balances", None, false), - (HttpMethod::Get, "/v1/wallet/unspents", None, false), - ( - HttpMethod::Get, - "/v1/wallet/transfers?assetId=rgb%3Aabc-123", - None, - false, - ), - (HttpMethod::Get, "/v1/wallet/transfers", None, false), - ( - HttpMethod::Post, - "/v1/wallet/receive", - Some( - serde_json::json!({ "mode": "blind", "assetId": "rgb:x", "amount": 5, "minConfirmations": 1 }), - ), - false, - ), - (HttpMethod::Post, "/v1/wallet/sync", None, false), - ( - HttpMethod::Post, - "/v1/onchain/send-btc/prepare", - Some(serde_json::json!({ "address": ADDRESS, "amountSat": 40_000 })), - true, - ), - ( - HttpMethod::Post, - "/v1/onchain/send-btc/complete", - Some(serde_json::json!({ "opId": "op-1", "signedPsbt": "cHNidP8=" })), - true, - ), - ( - HttpMethod::Post, - "/v1/onchain/send-asset/prepare", - Some( - serde_json::json!({ "assetId": "rgb:good", "amount": 10, "recipientId": "utxob:me" }), - ), - true, - ), - ( - HttpMethod::Post, - "/v1/onchain/send-asset/complete", - Some(serde_json::json!({ "opId": "op-1", "signedPsbt": "cHNidP8=" })), - true, - ), - ( - HttpMethod::Post, - "/v1/onchain/create-utxos/prepare", - Some(serde_json::json!({ "num": 4 })), - true, - ), - ( - HttpMethod::Post, - "/v1/onchain/create-utxos/complete", - Some(serde_json::json!({ "opId": "op-1", "signedPsbt": "cHNidP8=" })), - true, - ), - ( - HttpMethod::Post, - "/v1/ln/deposit/prepare", - Some(serde_json::json!({ "kind": "btc", "amountMsat": 1_000_000 })), - true, - ), - ( - HttpMethod::Post, - "/v1/ln/pay", - Some(serde_json::json!({ "invoice": "lnbcrt1..." })), - true, - ), - ( - HttpMethod::Post, - "/v1/ln/invoice", - Some(serde_json::json!({ "amtMsat": 1000, "description": "coffee" })), - false, - ), - ( - HttpMethod::Get, - "/v1/ln/invoice/..%2Fv1%2Fln%2Fbalance", - None, - false, - ), - (HttpMethod::Get, "/v1/ln/payments", None, false), - (HttpMethod::Get, "/v1/ln/balance", None, false), - ( - HttpMethod::Post, - "/v1/ln/withdraw", - Some(serde_json::json!({ "kind": "btc", "address": "bcrt1q...", "amountSat": 1_000 })), - true, - ), - ]; - assert_eq!(seen.len(), expected.len(), "one request per route"); - for (request, (method, path, body, money_moving)) in seen.iter().zip(expected) { - assert_eq!(request.method, method, "{path}"); - assert_eq!(request.url, format!("http://gw.local{path}")); - assert_eq!( - request.headers.get("authorization").map(String::as_str), - Some("Bearer tok-1"), - "{path}" - ); - assert_eq!(request.timeout_ms, 30_000); - match body { - Some(expected_body) => { - assert_eq!(body_json(request), expected_body, "{path} body"); - assert_eq!( - request.headers.get("content-type").map(String::as_str), - Some("application/json"), - "{path}" - ); - } - None => { - assert_eq!(request.body, None, "{path} must carry no body"); - assert!(!request.headers.contains_key("content-type"), "{path}"); - } - } - if money_moving { - assert_uuid_v4(request.headers.get("idempotency-key").expect(path)); - } else { - assert_no_idempotency_key(request); - } - } - // 22 distinct routes were covered (transfers and invoice/{hash} twice). - let routes: std::collections::BTreeSet<&str> = seen - .iter() - .map(|r| { - r.url - .trim_start_matches("http://gw.local") - .split('?') - .next() - .unwrap() - }) - .map(|p| { - if p.starts_with("/v1/ln/invoice/") { - "/v1/ln/invoice/{hash}" - } else { - p - } - }) - .collect(); - assert_eq!(routes.len(), 21); - // create_user is the 22nd: operator token, no bearer, no body. - let transport = FakeTransport::with(vec![ok( - 201, - serde_json::json!({ "userId": "u1", "token": "t-new", "createdAt": 1 }), - )]); - let created = client(&transport, None).create_user("op-secret").unwrap(); - assert_eq!(created.token, "t-new"); - let request = &transport.seen()[0]; - assert_eq!(request.method, HttpMethod::Post); - assert_eq!(request.url, "http://gw.local/v1/users"); - assert_eq!( - request.headers.get("x-operator-token").map(String::as_str), - Some("op-secret") - ); - assert!(!request.headers.contains_key("authorization")); - assert_eq!(request.body, None); - assert_no_idempotency_key(request); -} - -#[test] -fn gateway_get_onchain_operation_reads_durable_state_after_a_lost_complete() { - let op_id = "11111111-2222-4333-8444-555555555555"; - let status_json = |state: &str, txid: serde_json::Value, may: bool| { - serde_json::json!({ - "opId": op_id, - "kind": "send_btc", - "state": state, - "txid": txid, - "mayHaveBroadcast": may, - "intent": send_btc_intent_json(), - "createdAt": 1_700_000_000_000u64, - "expiresAt": 1_700_000_900_000u64, - }) - }; - let transport = FakeTransport::with(vec![ - ok(200, status_json("pending", serde_json::Value::Null, false)), - // The case this route exists for: the wallet failed after rgb-lib may - // already have broadcast, so a txid is recorded on an op that never - // completed. - ok( - 200, - status_json("pending", serde_json::json!("d".repeat(64)), true), - ), - // Expiry must not clear the signal: an expired op whose txid was - // recorded may still have its transaction confirmed. - ok( - 200, - status_json("expired", serde_json::json!("d".repeat(64)), true), - ), - ok( - 200, - status_json("completed", serde_json::json!("e".repeat(64)), false), - ), - ]); - let c = client(&transport, Some("tok-1")); - - let fresh = c.get_onchain_operation(op_id).unwrap(); - assert_eq!(fresh.op_id, op_id); - assert_eq!(fresh.kind, IntentKind::SendBtc); - assert_eq!(fresh.state, OperationState::Pending); - assert_eq!(fresh.txid, None); - assert!(!fresh.may_have_broadcast); - // The intent comes back through the same typed decoder `prepare` uses. - assert_eq!(fresh.intent.kind, IntentKind::SendBtc); - assert_eq!(fresh.intent.recipients[0].amount_sat, 40_000); - assert_eq!(fresh.created_at, 1_700_000_000_000); - assert_eq!(fresh.expires_at, 1_700_000_900_000); - - let ambiguous = c.get_onchain_operation(op_id).unwrap(); - assert_eq!(ambiguous.state, OperationState::Pending); - assert!(ambiguous.may_have_broadcast); - assert_eq!(ambiguous.txid, Some("d".repeat(64))); - - let expired = c.get_onchain_operation(op_id).unwrap(); - assert_eq!(expired.state, OperationState::Expired); - assert!(expired.may_have_broadcast, "expiry does not un-broadcast"); - - let done = c.get_onchain_operation(op_id).unwrap(); - assert_eq!(done.state, OperationState::Completed); - assert!(!done.may_have_broadcast); - - // A read: GET, percent-encoded path, bearer auth, no body, and no - // idempotency key — so polling it can never consume one. - for request in transport.seen() { - assert_eq!(request.method, HttpMethod::Get); - assert_eq!( - request.url, - format!("http://gw.local/v1/onchain/operations/{op_id}") - ); - assert_eq!(request.body, None); - assert_no_idempotency_key(&request); - assert_eq!( - request.headers.get("authorization").map(String::as_str), - Some("Bearer tok-1") - ); - } -} - -#[test] -fn gateway_get_onchain_operation_fails_closed_on_unknown_state_or_kind() { - let base = |state: &str, kind: &str| { - serde_json::json!({ - "opId": "11111111-2222-4333-8444-555555555555", - "kind": kind, - "state": state, - "txid": null, - "mayHaveBroadcast": false, - "intent": send_btc_intent_json(), - "createdAt": 1u64, - "expiresAt": 2u64, - }) - }; - // A state or kind this SDK does not know must be an error, never a silent - // default — the caller decides recovery on these fields. - let mut missing_flag = base("pending", "send_btc"); - missing_flag["mayHaveBroadcast"] = serde_json::Value::Null; - let transport = FakeTransport::with(vec![ - ok(200, base("teleported", "send_btc")), - ok(200, base("pending", "send_dogecoin")), - ok(200, missing_flag), - ]); - let c = client(&transport, Some("tok-1")); - for _ in 0..3 { - assert!(matches!( - c.get_onchain_operation("11111111-2222-4333-8444-555555555555"), - Err(SdkError::Gateway { .. }) - )); - } -} - -#[test] -fn gateway_base_url_trailing_slash_percent_encoding_and_no_token_guard() { - let transport = FakeTransport::with(vec![ok(200, serde_json::json!({ "address": "x" }))]); - let c = GatewayClient::new( - "http://gw.local///", - Some("tok-1".into()), - Some(5), - transport.clone(), - ); - c.get_address().unwrap(); - let request = &transport.seen()[0]; - assert_eq!(request.url, "http://gw.local/v1/wallet/address"); - assert_eq!(request.timeout_ms, 5); - assert_eq!(encode_uri_component("rgb:abc-123"), "rgb%3Aabc-123"); - - // Authenticated calls without a token fail before any network IO. - let transport = FakeTransport::with(vec![]); - let c = client(&transport, None); - match c.get_balances() { - Err(SdkError::Gateway { status, code, .. }) => { - assert_eq!((status, code.as_str()), (0, "NO_TOKEN")); - } - other => panic!("expected NO_TOKEN, got {other:?}"), - } - assert!(transport.seen().is_empty(), "no request may be sent"); -} - -#[test] -fn gateway_status_and_body_surface_on_4xx_and_5xx() { - let transport = FakeTransport::with(vec![ - ok( - 409, - serde_json::json!({ "error": { "code": "IDEMPOTENCY_CONFLICT", "message": "key reused" } }), - ), - Ok(HttpResponse { - status: 502, - body: "bad gateway".into(), - }), - Ok(HttpResponse { - status: 500, - body: String::new(), - }), - ok(400, serde_json::json!({ "error": { "code": 5 } })), - Err(SdkError::Transport { - reason: "connection refused".into(), - }), - // A 2xx that is not the documented shape is an explicit error, not a - // default value. - ok(200, serde_json::json!({ "address": 5 })), - Ok(HttpResponse { - status: 200, - body: "not json".into(), - }), - ]); - let c = client(&transport, Some("tok-1")); - assert_eq!( - c.sync().unwrap_err(), - SdkError::Gateway { - status: 409, - code: "IDEMPOTENCY_CONFLICT".into(), - detail: "key reused".into() - } - ); - assert_eq!( - c.me().unwrap_err(), - SdkError::Gateway { - status: 502, - code: "UNKNOWN".into(), - detail: "gateway returned 502".into() - } - ); - assert_eq!( - c.me().unwrap_err(), - SdkError::Gateway { - status: 500, - code: "UNKNOWN".into(), - detail: "gateway returned 500".into() - } - ); - assert_eq!( - c.me().unwrap_err(), - SdkError::Gateway { - status: 400, - code: "UNKNOWN".into(), - detail: "gateway returned 400".into() - } - ); - assert_eq!( - c.me().unwrap_err(), - SdkError::Transport { - reason: "connection refused".into() - } - ); - // Any declared error variant the transport returns is passed through - // untouched, not re-wrapped as Transport. - let foreign = SdkError::Gateway { - status: 0, - code: "HOST_CANCELLED".into(), - detail: "the app cancelled the request".into(), - }; - let cancelled = FakeTransport::with(vec![Err(foreign.clone())]); - assert_eq!(client(&cancelled, Some("tok-1")).me().unwrap_err(), foreign); - for what in ["wrong type", "non-json"] { - match c.get_address() { - Err(SdkError::Gateway { - status, - code, - detail, - }) => { - assert_eq!((status, code.as_str()), (0, "MALFORMED_RESPONSE"), "{what}"); - assert!(detail.contains("address"), "{what}: {detail}"); - } - other => panic!("{what}: expected MALFORMED_RESPONSE, got {other:?}"), - } - } - // Unknown enum values on the wire are refused rather than mapped. - transport.push(ok( - 200, - serde_json::json!({ "paymentHash": "a", "status": "maybe" }), - )); - let err = c - .pay_ln_invoice( - &LnPayParams { - invoice: "x".into(), - amt_msat: None, - asset_amount: None, - }, - None, - ) - .unwrap_err(); - assert!( - matches!(err, SdkError::Gateway { ref code, .. } if code == "MALFORMED_RESPONSE"), - "{err:?}" - ); -} - -#[test] -fn gateway_idempotency_keys_are_unique_per_call_and_stable_when_pinned() { - let mut keys = std::collections::HashSet::new(); - for _ in 0..64 { - let key = generate_idempotency_key().unwrap(); - assert_uuid_v4(&key); - assert!(keys.insert(key), "duplicate idempotency key"); - } - - let transport = FakeTransport::with(vec![ - ok(200, prepared(send_btc_intent_json())), - ok(200, prepared(create_utxos_intent_json())), - ok(200, serde_json::json!({ "txid": "aa" })), - ok(200, serde_json::json!({ "txid": "aa" })), - ok( - 200, - serde_json::json!({ "paymentHash": "a", "status": "pending" }), - ), - ok(200, serde_json::json!({ "withdrawalId": "w", "txid": "b" })), - ok(200, serde_json::json!({ "btcMsat": 0, "assets": {} })), - ok(200, serde_json::json!({ "payments": [] })), - ]); - let c = client(&transport, Some("t")); - c.prepare_send_btc(&send_btc_params(), None).unwrap(); - c.prepare_create_utxos( - &PrepareCreateUtxosParams { - num: None, - size: None, - up_to: None, - fee_rate_sat_per_vb: None, - }, - None, - ) - .unwrap(); - let pinned = generate_idempotency_key().unwrap(); - let complete = CompleteParams { - op_id: "op".into(), - signed_psbt: "cHNidP8=".into(), - }; - c.complete_send_btc(&complete, Some(pinned.clone())) - .unwrap(); - c.complete_send_btc(&complete, Some(pinned.clone())) - .unwrap(); - c.pay_ln_invoice( - &LnPayParams { - invoice: "lnbcrt1...".into(), - amt_msat: None, - asset_amount: None, - }, - Some(pinned.clone()), - ) - .unwrap(); - c.withdraw_ln( - &LnWithdrawParams { - kind: LnAssetKind::Btc, - address: Some("bcrt1q...".into()), - amount_sat: Some(1_000), - asset_id: None, - amount: None, - recipient_id: None, - witness_amount_sat: None, - transport_endpoints: None, - fee_rate_sat_per_vb: None, - }, - None, - ) - .unwrap(); - c.get_ln_balance().unwrap(); - c.list_ln_payments().unwrap(); - - let seen = transport.seen(); - let key = |i: usize| seen[i].headers.get("idempotency-key").cloned(); - assert_uuid_v4(&key(0).unwrap()); - assert_uuid_v4(&key(1).unwrap()); - assert_ne!(key(0), key(1), "fresh key per money-moving call"); - assert_eq!(key(2).as_deref(), Some(pinned.as_str())); - assert_eq!(key(3).as_deref(), Some(pinned.as_str())); - assert_eq!(key(4).as_deref(), Some(pinned.as_str())); - assert_uuid_v4(&key(5).unwrap()); - assert_eq!(key(6), None, "reads carry no idempotency key"); - assert_eq!(key(7), None); -} - -#[test] -fn intent_binding_send_btc_rejects_every_tampered_field_and_accepts_a_match() { - // verify check 2 matches the PSBT against the intent. A server-echoed - // intent makes that check vacuous against a hostile gateway: it can put - // an attacker output in the PSBT and the SAME attacker output in the - // intent, and every one of the 5 checks passes. These bind the intent to - // what the caller actually asked for, before it can reach verify_psbt. - let run = |intent: serde_json::Value, params: &PrepareSendBtcParams| { - let transport = FakeTransport::with(vec![ok(200, prepared(intent))]); - client(&transport, Some("t")).prepare_send_btc(params, None) - }; - let with = |patch: serde_json::Value| { - let mut intent = send_btc_intent_json(); - for (k, v) in patch.as_object().unwrap() { - intent[k] = v.clone(); - } - intent - }; - - let op = run(send_btc_intent_json(), &send_btc_params()).unwrap(); - assert_eq!(op.op_id, "op-1"); - assert_eq!(op.psbt, "cHNidP8="); - assert_eq!(op.expires_at, 1); - // Server-chosen defaults are left alone: feeRateSatPerVb was not pinned. - assert_eq!(op.intent.fee_rate_sat_per_vb, 2); - assert_eq!(op.intent.recipients[0].script_hex, "0014deadbeef"); - - let swapped = with(serde_json::json!({ - "recipients": [{ "address": ATTACKER, "scriptHex": "0014deadbeef", "amountSat": 40_000 }] - })); - assert_intent_mismatch( - run(swapped, &send_btc_params()), - &format!("recipient address {ATTACKER} != {ADDRESS}"), - "swapped recipient address", - ); - - let inflated = with(serde_json::json!({ - "recipients": [{ "address": ADDRESS, "scriptHex": "0014deadbeef", "amountSat": 4_000_000 }] - })); - assert_intent_mismatch( - run(inflated, &send_btc_params()), - "recipient amountSat 4000000 != 40000", - "changed amount", - ); - - let extra = with(serde_json::json!({ - "recipients": [ - { "address": ADDRESS, "scriptHex": "0014deadbeef", "amountSat": 40_000 }, - { "address": ATTACKER, "scriptHex": "0014cafe", "amountSat": 100_000 } - ] - })); - assert_intent_mismatch( - run(extra, &send_btc_params()), - "expected exactly 1 recipient, got 2", - "extra recipient", - ); - - // asset.witnessAmountSat is how check 2 accounts for a FOREIGN output; - // on a plain BTC send there is no such approval to grant. - let smuggled = with(serde_json::json!({ - "asset": { - "assetId": "rgb:x", "amount": 1, "recipientId": "wvout:y", - "witnessAmountSat": 5_000_000, "transportEndpoints": [] - } - })); - assert_intent_mismatch( - run(smuggled, &send_btc_params()), - "send-btc intent carries an asset", - "asset smuggled onto a send-btc intent", - ); - let utxo_shape = with(serde_json::json!({ "utxos": { "upTo": true, "num": 1, "size": 1 } })); - assert_intent_mismatch( - run(utxo_shape, &send_btc_params()), - "send-btc intent carries a utxo shape", - "utxo shape on a send-btc intent", - ); - - // A pinned fee rate the server did not honour. - let pinned_fee = PrepareSendBtcParams { - fee_rate_sat_per_vb: Some(5), - ..send_btc_params() - }; - assert_intent_mismatch( - run(send_btc_intent_json(), &pinned_fee), - "feeRateSatPerVb 2 != 5", - "pinned fee rate", - ); - let honoured = with(serde_json::json!({ "feeRateSatPerVb": 5 })); - assert_eq!( - run(honoured, &pinned_fee) - .unwrap() - .intent - .fee_rate_sat_per_vb, - 5 - ); - - // No intent at all, an intent that is not an object, a different flow. - for (body, detail) in [ - ( - serde_json::json!({ "opId": "op-4", "psbt": "cHNidP8=", "expiresAt": 1 }), - "prepare response carries no intent summary", - ), - ( - serde_json::json!({ "opId": "op-4", "psbt": "cHNidP8=", "expiresAt": 1, "intent": null }), - "prepare response carries no intent summary", - ), - ( - serde_json::json!({ "opId": "op-4", "psbt": "cHNidP8=", "expiresAt": 1, "intent": "send_btc" }), - "prepare response carries no intent summary", - ), - ( - prepared(with(serde_json::json!({ "kind": "create_utxos" }))), - "kind create_utxos != send_btc", - ), - ( - prepared(with(serde_json::json!({ "recipients": "none" }))), - "intent.recipients is not an array", - ), - ] { - let transport = FakeTransport::with(vec![ok(200, body)]); - assert_intent_mismatch( - client(&transport, Some("t")).prepare_send_btc(&send_btc_params(), None), - detail, - detail, - ); - } - - // Every mismatch is listed, not just the first. - let everything = with(serde_json::json!({ - "kind": "send_asset", - "recipients": [{ "address": ATTACKER, "scriptHex": "0014deadbeef", "amountSat": 1 }], - "utxos": { "upTo": true, "num": 1, "size": 1 } - })); - let reason = match run(everything, &pinned_fee) { - Err(SdkError::IntentMismatch { reason }) => reason, - other => panic!("expected IntentMismatch, got {other:?}"), - }; - for detail in [ - "kind send_asset != send_btc", - &format!("recipient address {ATTACKER} != {ADDRESS}"), - "recipient amountSat 1 != 40000", - "send-btc intent carries a utxo shape", - "feeRateSatPerVb 2 != 5", - ] { - assert!(reason.contains(detail), "{reason:?} lacks {detail:?}"); - } - assert_eq!(reason.matches("; ").count(), 4, "{reason}"); - - // A field verify cannot represent is a mismatch too (fail closed). - let stringly = with(serde_json::json!({ - "recipients": [{ "address": ADDRESS, "scriptHex": "0014deadbeef", "amountSat": "40000" }] - })); - assert_intent_mismatch( - run(stringly, &send_btc_params()), - "recipient amountSat 40000 != 40000", - "string amount is not a number", - ); - let no_script = with(serde_json::json!({ - "recipients": [{ "address": ADDRESS, "amountSat": 40_000 }] - })); - assert_intent_mismatch( - run(no_script, &send_btc_params()), - "recipient.scriptHex is not a string", - "missing scriptHex", - ); -} - -#[test] -fn intent_binding_send_asset_rejects_changed_asset_amount_recipient_witness_and_recipients() { - let run = |intent: serde_json::Value, params: &PrepareSendAssetParams| { - let transport = FakeTransport::with(vec![ok(200, prepared(intent))]); - client(&transport, Some("t")).prepare_send_asset(params, None) - }; - let with_asset = |patch: serde_json::Value| { - let mut intent = send_asset_intent_json(); - for (k, v) in patch.as_object().unwrap() { - intent["asset"][k] = v.clone(); - } - intent - }; - - let op = run(send_asset_intent_json(), &send_asset_params()).unwrap(); - let asset = op.intent.asset.unwrap(); - assert_eq!(asset.witness_amount_sat, None); - assert_eq!(asset.transport_endpoints, ["http://proxy"]); - assert_eq!(op.intent.recipients, Vec::::new()); - - for (patch, detail) in [ - ( - serde_json::json!({ "assetId": "rgb:evil" }), - "assetId rgb:evil != rgb:good", - ), - (serde_json::json!({ "amount": 1_000 }), "amount 1000 != 10"), - ( - serde_json::json!({ "recipientId": "utxob:attacker" }), - "recipientId utxob:attacker != utxob:me", - ), - // A foreign witness amount on a blind send: check 2 would account - // for one foreign output of that size. - ( - serde_json::json!({ "witnessAmountSat": 5_000_000 }), - "witnessAmountSat 5000000 != null", - ), - ] { - assert_intent_mismatch(run(with_asset(patch), &send_asset_params()), detail, detail); - } - // A missing witnessAmountSat is not "null": `undefined !== null`. - let mut missing = send_asset_intent_json(); - missing["asset"] - .as_object_mut() - .unwrap() - .remove("witnessAmountSat"); - assert_intent_mismatch( - run(missing, &send_asset_params()), - "witnessAmountSat undefined != null", - "missing witnessAmountSat", - ); - // The witness case: the caller's amount must come back exactly. - let witness_params = PrepareSendAssetParams { - witness_amount_sat: Some(1_000), - ..send_asset_params() - }; - assert_intent_mismatch( - run(send_asset_intent_json(), &witness_params), - "witnessAmountSat null != 1000", - "witness amount dropped", - ); - let witness_ok = run( - with_asset(serde_json::json!({ "witnessAmountSat": 1_000 })), - &witness_params, - ) - .unwrap(); - assert_eq!( - witness_ok.intent.asset.unwrap().witness_amount_sat, - Some(1_000) - ); - - // Bitcoin recipients on an asset send, a utxo shape, no asset at all. - let mut with_recipient = send_asset_intent_json(); - with_recipient["recipients"] = - serde_json::json!([{ "address": ATTACKER, "scriptHex": "0014cafe", "amountSat": 100_000 }]); - assert_intent_mismatch( - run(with_recipient, &send_asset_params()), - "send-asset intent carries 1 bitcoin recipient(s)", - "recipient on an asset send", - ); - let mut with_utxos = send_asset_intent_json(); - with_utxos["utxos"] = serde_json::json!({ "upTo": false, "num": 1, "size": 1 }); - assert_intent_mismatch( - run(with_utxos, &send_asset_params()), - "send-asset intent carries a utxo shape", - "utxo shape on an asset send", - ); - let mut no_asset = send_asset_intent_json(); - no_asset["asset"] = serde_json::Value::Null; - assert_intent_mismatch( - run(no_asset, &send_asset_params()), - "send-asset intent carries no asset", - "asset dropped", - ); - - // Transport endpoints are asserted only when pinned. - let pinned = PrepareSendAssetParams { - transport_endpoints: Some(vec!["http://mine".into()]), - ..send_asset_params() - }; - assert_intent_mismatch( - run(send_asset_intent_json(), &pinned), - "transportEndpoints http://proxy != http://mine", - "pinned endpoints", - ); - assert!(run( - with_asset(serde_json::json!({ "transportEndpoints": ["http://mine"] })), - &pinned - ) - .is_ok()); - let pinned_fee = PrepareSendAssetParams { - fee_rate_sat_per_vb: Some(9), - ..send_asset_params() - }; - assert_intent_mismatch( - run(send_asset_intent_json(), &pinned_fee), - "feeRateSatPerVb 2 != 9", - "pinned fee", - ); - // The request body carries every pinned field and omits unset ones. - let transport = FakeTransport::with(vec![ok(200, prepared(send_asset_intent_json()))]); - let _ = client(&transport, Some("t")).prepare_send_asset( - &PrepareSendAssetParams { - donation: Some(true), - min_confirmations: Some(3), - ..send_asset_params() - }, - None, - ); - assert_eq!( - body_json(&transport.seen()[0]), - serde_json::json!({ - "assetId": "rgb:good", "amount": 10, "recipientId": "utxob:me", - "donation": true, "minConfirmations": 3 - }) - ); -} - -#[test] -fn intent_binding_create_utxos_asserts_only_pinned_shape_fields() { - let run = |intent: serde_json::Value, params: &PrepareCreateUtxosParams| { - let transport = FakeTransport::with(vec![ok(200, prepared(intent))]); - client(&transport, Some("t")).prepare_create_utxos(params, None) - }; - let unpinned = PrepareCreateUtxosParams { - num: None, - size: None, - up_to: None, - fee_rate_sat_per_vb: None, - }; - let op = run(create_utxos_intent_json(), &unpinned).unwrap(); - let utxos = op.intent.utxos.unwrap(); - assert_eq!((utxos.up_to, utxos.num, utxos.size), (false, 4, 1_000)); - assert!(run( - create_utxos_intent_json(), - &PrepareCreateUtxosParams { - num: Some(4), - ..unpinned.clone() - } - ) - .is_ok()); - for (params, detail) in [ - ( - PrepareCreateUtxosParams { - num: Some(8), - ..unpinned.clone() - }, - "num 4 != 8", - ), - ( - PrepareCreateUtxosParams { - size: Some(32_000), - ..unpinned.clone() - }, - "size 1000 != 32000", - ), - ( - PrepareCreateUtxosParams { - up_to: Some(true), - ..unpinned.clone() - }, - "upTo false != true", - ), - ( - PrepareCreateUtxosParams { - fee_rate_sat_per_vb: Some(1), - ..unpinned.clone() - }, - "feeRateSatPerVb 2 != 1", - ), - ] { - assert_intent_mismatch(run(create_utxos_intent_json(), ¶ms), detail, detail); - } - let mut with_recipient = create_utxos_intent_json(); - with_recipient["recipients"] = - serde_json::json!([{ "address": ATTACKER, "scriptHex": "0014cafe", "amountSat": 1 }]); - assert_intent_mismatch( - run(with_recipient, &unpinned), - "create-utxos intent carries 1 recipient(s)", - "recipient on create-utxos", - ); - let mut with_asset = create_utxos_intent_json(); - with_asset["asset"] = send_asset_intent_json()["asset"].clone(); - assert_intent_mismatch( - run(with_asset, &unpinned), - "create-utxos intent carries an asset", - "asset on create-utxos", - ); - let mut no_shape = create_utxos_intent_json(); - no_shape["utxos"] = serde_json::Value::Null; - assert_intent_mismatch( - run(no_shape, &unpinned), - "create-utxos intent carries no utxo shape", - "shape dropped", - ); -} - -#[test] -fn gateway_prepared_intent_feeds_verify_and_sign_to_the_rgb_lib_txid() { - // End to end through the client: the bound intent the fake gateway - // returned for the fixture PSBT is the very value verify consumes, and - // signing it reproduces rgb-lib's txid; the signed PSBT then goes back - // through complete_send_btc. - let w = wallets(); - let expected_txid = fixture_str(&w.fixture["signing"]["txid"], "signing.txid"); - let transport = FakeTransport::with(vec![ - ok( - 200, - serde_json::json!({ - "opId": "op-fixture", "psbt": fixture_unsigned(&w), "expiresAt": 1, - "intent": fixture_send_btc_intent_json(&w) - }), - ), - ok(200, serde_json::json!({ "txid": expected_txid })), - ]); - let c = client(&transport, Some("t")); - let op = c - .prepare_send_btc( - &PrepareSendBtcParams { - address: w.foreign.address_at(false, 0, 0), - amount_sat: 40_000, - fee_rate_sat_per_vb: Some(2), - }, - None, - ) - .unwrap(); - assert_eq!(op.intent, send_btc_intent(&w)); - let signed = verify_and_sign_psbt(&w.ours.keys, &op.psbt, ¶ms(&w, op.intent, 2_000)) - .expect("bound intent verifies and signs"); - assert_eq!(signed.txid, expected_txid); - let completed = c - .complete_send_btc( - &CompleteParams { - op_id: op.op_id, - signed_psbt: signed.signed_psbt.clone(), - }, - None, - ) - .unwrap(); - assert_eq!(completed.txid, expected_txid); - let body = body_json(&transport.seen()[1]); - assert_eq!(body["opId"], "op-fixture"); - assert_eq!(body["signedPsbt"], signed.signed_psbt); -} - -#[test] -fn gateway_requests_never_carry_secrets_and_token_never_renders() { - let fixture = load_fixture(); - let mnemonic = fixture_mnemonic(&fixture); - let keys = ClientKeys::from_mnemonic(&mnemonic, BitcoinNetwork::Regtest).unwrap(); - let xpubs = keys.xpubs(); - let token = "tok-SECRET-bearer-8f2a"; - let operator = "op-SECRET-9c1d"; - - // One response per call, in the order below. - let transport = FakeTransport::with(vec![ - ok( - 201, - serde_json::json!({ "userId": "u", "token": token, "createdAt": 1 }), - ), - ok(200, serde_json::json!({ "userId": "u", "createdAt": 1 })), - ok( - 200, - serde_json::json!({ "fingerprint": xpubs.fingerprint, "address": "a" }), - ), - ok(200, serde_json::json!({ "address": "a" })), - ok( - 200, - serde_json::json!({ "btc": { "vanilla": { "settled": 0, "future": 0, "spendable": 0 }, "colored": { "settled": 0, "future": 0, "spendable": 0 } }, "assets": [] }), - ), - ok(200, serde_json::json!({ "unspents": [] })), - ok(200, serde_json::json!({ "transfers": [] })), - ok( - 200, - serde_json::json!({ "invoice": "i", "recipientId": "r", "expirationTimestamp": null, "mode": "witness" }), - ), - ok(200, serde_json::json!({ "status": "ok" })), - ok(200, prepared(send_btc_intent_json())), - ok(200, serde_json::json!({ "txid": "t" })), - ok(200, prepared(send_asset_intent_json())), - ok(200, serde_json::json!({ "txid": "t" })), - ok(200, prepared(create_utxos_intent_json())), - ok(200, serde_json::json!({ "txid": null, "utxosCreated": 0 })), - ok( - 200, - serde_json::json!({ "depositId": "d", "kind": "rgb", "address": null, "invoice": "i", "recipientId": "r" }), - ), - ok( - 200, - serde_json::json!({ "paymentHash": "h", "status": "failed" }), - ), - ok( - 200, - serde_json::json!({ "invoice": "i", "paymentHash": "h" }), - ), - ok( - 200, - serde_json::json!({ "paymentHash": "h", "invoice": "i", "state": "expired", "amtMsat": null, "assetId": null, "assetAmount": null, "createdAt": 1 }), - ), - ok(200, serde_json::json!({ "payments": [] })), - ok(200, serde_json::json!({ "btcMsat": 0, "assets": {} })), - ok(200, serde_json::json!({ "withdrawalId": "w", "txid": "t" })), - ]); - let c = client(&transport, Some(token)); - let created = c.create_user(operator).unwrap(); - assert_eq!(created.token, token); - // The one record carrying a credential redacts it in Debug. - let rendered = format!("{created:?}"); - assert!(!rendered.contains(token), "{rendered}"); - assert!(rendered.contains("[redacted]")); - c.me().unwrap(); - c.register_xpubs(&RegisterXpubsParams { - vanilla: xpubs.vanilla.clone(), - colored: xpubs.colored.clone(), - fingerprint: xpubs.fingerprint.clone(), - }) - .unwrap(); - c.get_address().unwrap(); - c.get_balances().unwrap(); - c.get_unspents().unwrap(); - c.get_transfers(Some("rgb:x")).unwrap(); - c.receive(&ReceiveParams { - mode: ReceiveMode::Witness, - asset_id: None, - amount: None, - duration_seconds: Some(60), - min_confirmations: None, - }) - .unwrap(); - c.sync().unwrap(); - c.prepare_send_btc(&send_btc_params(), None).unwrap(); - let complete = CompleteParams { - op_id: "op-1".into(), - signed_psbt: "cHNidP8=".into(), - }; - c.complete_send_btc(&complete, None).unwrap(); - c.prepare_send_asset(&send_asset_params(), None).unwrap(); - c.complete_send_asset(&complete, None).unwrap(); - c.prepare_create_utxos( - &PrepareCreateUtxosParams { - num: None, - size: None, - up_to: None, - fee_rate_sat_per_vb: None, - }, - None, - ) - .unwrap(); - c.complete_create_utxos(&complete, None).unwrap(); - c.prepare_ln_deposit( - &LnDepositPrepareParams { - kind: LnAssetKind::Rgb, - amount_msat: None, - asset_id: Some("rgb:x".into()), - amount: Some(1), - }, - None, - ) - .unwrap(); - c.pay_ln_invoice( - &LnPayParams { - invoice: "lnbcrt1...".into(), - amt_msat: Some(1), - asset_amount: None, - }, - None, - ) - .unwrap(); - c.create_ln_invoice(&LnInvoiceCreateParams { - amt_msat: None, - expiry_sec: Some(60), - asset_id: Some("rgb:x".into()), - asset_amount: Some(1), - description: None, - }) - .unwrap(); - c.get_ln_invoice("h").unwrap(); - c.list_ln_payments().unwrap(); - c.get_ln_balance().unwrap(); - c.withdraw_ln( - &LnWithdrawParams { - kind: LnAssetKind::Rgb, - address: None, - amount_sat: None, - asset_id: Some("rgb:x".into()), - amount: Some(1), - recipient_id: Some("bcrt:wvout:x".into()), - witness_amount_sat: Some(1_000), - transport_endpoints: Some(vec!["rpc://p".into()]), - fee_rate_sat_per_vb: Some(2), - }, - None, - ) - .unwrap(); - - let seen = transport.seen(); - assert_eq!(seen.len(), 22, "all 22 routes exercised"); - let mut registered_xpubs = 0; - for request in &seen { - // No body or header carries the mnemonic, a seed or an xprv/tprv. - let body = request.body.clone().unwrap_or_default(); - assert_no_secret(&body, &mnemonic, &format!("{} body", request.url)); - for (name, value) in &request.headers { - assert_no_secret(value, &mnemonic, &format!("{} header {name}", request.url)); - } - assert_no_secret(&request.url, &mnemonic, "url"); - // The bearer token travels in exactly one header of authenticated - // calls and nowhere else (never in a body, url or other header). - let bearer = format!("Bearer {token}"); - for (name, value) in &request.headers { - if name == "authorization" { - assert_eq!(value, &bearer); - } else { - assert!(!value.contains(token), "{name} carries the token"); - } - } - assert!(!body.contains(token) && !request.url.contains(token)); - if request.url.ends_with("/v1/wallet/xpubs") { - registered_xpubs += 1; - let sent = body_json(request); - assert_eq!(sent["vanilla"], xpubs.vanilla); - assert_eq!(sent["colored"], xpubs.colored); - assert_eq!(sent["fingerprint"], xpubs.fingerprint); - assert!(xpubs.vanilla.starts_with("tpub") && xpubs.colored.starts_with("tpub")); - } - // Debug output of a request redacts credential values. - let rendered = format!("{request:?}"); - assert!(!rendered.contains(token), "{rendered}"); - assert!(!rendered.contains(operator), "{rendered}"); - assert_no_secret(&rendered, &mnemonic, "request Debug"); - } - assert_eq!(registered_xpubs, 1); - - // The bearer token never renders in Debug or any error. - let rendered = format!("{c:?}"); - assert!(!rendered.contains(token), "{rendered}"); - assert!(rendered.contains("[redacted]"), "{rendered}"); - let transport = FakeTransport::with(vec![ - ok( - 401, - serde_json::json!({ "error": { "code": "UNAUTHORIZED", "message": "bad token" } }), - ), - Err(SdkError::Transport { - reason: "tls handshake failed".into(), - }), - ok(200, serde_json::json!({ "opId": 1 })), - ok(200, prepared(send_btc_intent_json())), - ok(200, serde_json::json!({})), - ]); - let c = client(&transport, Some(token)); - let errors = vec![ - c.me().unwrap_err(), - c.me().unwrap_err(), - c.prepare_send_btc(&send_btc_params(), None).unwrap_err(), - c.prepare_send_btc( - &PrepareSendBtcParams { - address: ATTACKER.into(), - ..send_btc_params() - }, - None, - ) - .unwrap_err(), - c.get_address().unwrap_err(), - client(&FakeTransport::with(vec![]), None).me().unwrap_err(), - ]; - assert!(errors - .iter() - .any(|e| matches!(e, SdkError::Gateway { status: 401, .. }))); - assert!(errors - .iter() - .any(|e| matches!(e, SdkError::Transport { .. }))); - assert!(errors - .iter() - .any(|e| matches!(e, SdkError::IntentMismatch { .. }))); - for error in errors { - for rendered in [format!("{error}"), format!("{error:?}")] { - assert!(!rendered.contains(token), "{rendered}"); - assert!(!rendered.contains(operator), "{rendered}"); - assert_no_secret(&rendered, &mnemonic, "gateway error"); - } - } -} - -#[test] -fn gateway_source_links_no_network_stack_and_reaches_no_key_material() { - let src = manifest_dir().join("src"); - let gateway_rs = fs::read_to_string(src.join("gateway.rs")).unwrap(); - // Code only: doc comments legitimately name the invariants they uphold. - let strip_comments = |text: &str| -> String { - text.split("#[cfg(test)]") - .next() - .unwrap() - .lines() - .filter(|l| !l.trim_start().starts_with("//")) - .collect::>() - .join("\n") - }; - let body = strip_comments(&gateway_rs); - // The transport is a foreign trait: Rust performs no IO. - assert!(body.contains("#[uniffi::export(with_foreign)]\npub trait HttpTransport: Send + Sync")); - for forbidden in [ - "std::net", - "TcpStream", - "reqwest", - "ureq", - "hyper", - "rustls", - "openssl", - "tokio", - "async fn", - ".await", - "unwrap()", - "expect(", - "panic!", - "unreachable!", - // Key material is unreachable from this module by construction: - // it never names the mnemonic, seed or private-key types. - "ClientKeys", - "Xpriv", - "SecretKey", - "Mnemonic", - "mnemonic", - "seed", - ] { - assert!( - !body.contains(forbidden), - "gateway.rs contains {forbidden:?}" - ); - } - // json.rs is the only other module the client depends on and is just as - // strict: no panics on hostile input. - let json_rs = fs::read_to_string(src.join("json.rs")).unwrap(); - let json_body = strip_comments(&json_rs); - for forbidden in ["unwrap()", "expect(", "panic!", "unreachable!"] { - assert!( - !json_body.contains(forbidden), - "json.rs contains {forbidden:?}" - ); - } - // Idempotency keys come from the OS CSPRNG that bitcoin already links. - assert!(body.contains("OsRng")); - let manifest = fs::read_to_string(manifest_dir().join("Cargo.toml")).unwrap(); - for crate_name in ["uuid", "rand =", "serde_json =", "reqwest", "ureq"] { - let deps = manifest.split("[dev-dependencies]").next().unwrap(); - assert!( - !deps.contains(crate_name), - "Cargo.toml gained {crate_name:?}" - ); - } - // The uniffi surface exposes the client, the 23 routes and the key generator. - let route_exports = body.matches("#[uniffi::method(name = \"").count(); - assert_eq!(route_exports, 23, "one uniffi method per route"); - assert!(body.contains("#[uniffi::export(name = \"generate_idempotency_key\")]")); -} - -#[test] -fn gateway_hostile_bodies_never_panic() { - // Every response body here is either accepted or an SdkError, never a - // panic; the parser also survives deep nesting and junk bytes. - let bodies = [ - "", - "null", - "[]", - "{", - "{\"address\":", - "\u{0}", - "{\"address\":\"\\ud800\"}", - "{\"unspents\":[{\"txid\":1}]}", - "{\"transfers\":[{}]}", - "{\"btc\":null,\"assets\":[]}", - "{\"assets\":{\"rgb:x\":-1},\"btcMsat\":0}", - "{\"assets\":{\"rgb:x\":1.5},\"btcMsat\":0}", - "{\"intent\":{\"kind\":\"send_btc\",\"recipients\":[null]}}", - "{\"intent\":{\"kind\":\"send_btc\",\"recipients\":[{}],\"asset\":[],\"utxos\":{}}}", - "{\"opId\":\"o\",\"psbt\":\"p\",\"expiresAt\":-1,\"intent\":{\"kind\":\"send_btc\",\"feeRateSatPerVb\":2,\"recipients\":[{\"address\":\"bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080\",\"scriptHex\":\"00\",\"amountSat\":40000}],\"asset\":null,\"utxos\":null}}", - &"[".repeat(10_000), - &format!("{{\"assets\":{}}}", "[".repeat(100)), - ]; - for body in bodies { - let responses: Vec> = (0..7) - .map(|_| { - Ok(HttpResponse { - status: 200, - body: body.to_owned(), - }) - }) - .collect(); - let transport = FakeTransport::with(responses); - let c = client(&transport, Some("t")); - // None of these bodies is a valid response for the route it is fed - // to, so every call must be an explicit error (a malformed-response - // `Gateway` or an `IntentMismatch`), never a defaulted value. - let outcomes: Vec> = vec![ - c.get_address().map(|v| format!("{v:?}")), - c.get_balances().map(|v| format!("{v:?}")), - c.get_unspents().map(|v| format!("{v:?}")), - c.get_transfers(None).map(|v| format!("{v:?}")), - c.get_ln_balance().map(|v| format!("{v:?}")), - c.prepare_send_btc(&send_btc_params(), None) - .map(|v| format!("{v:?}")), - c.list_ln_payments().map(|v| format!("{v:?}")), - ]; - for outcome in outcomes { - assert!( - matches!( - &outcome, - Err(SdkError::Gateway { code, .. }) if code == "MALFORMED_RESPONSE" - ) || matches!(&outcome, Err(SdkError::IntentMismatch { .. })), - "body {body:?}: expected an explicit error, got {outcome:?}" - ); - } - } -} diff --git a/minimal-sdk/rust-sdk/uniffi.toml b/minimal-sdk/rust-sdk/uniffi.toml deleted file mode 100644 index bde40565..00000000 --- a/minimal-sdk/rust-sdk/uniffi.toml +++ /dev/null @@ -1,11 +0,0 @@ -# Binding configuration for library-mode generation (no UDL file exists or -# may be added; every export is a proc macro in src/). -[bindings.kotlin] -package_name = "com.utexo.minimalsdk" -cdylib_name = "utexo_minimal_sdk" - -[bindings.swift] -module_name = "MinimalSdk" -ffi_module_name = "MinimalSdkFFI" -ffi_module_filename = "MinimalSdkFFI" -cdylib_name = "utexo_minimal_sdk" diff --git a/minimal-sdk/scripts/build_android.sh b/minimal-sdk/scripts/build_android.sh deleted file mode 100755 index 0c7e5a06..00000000 --- a/minimal-sdk/scripts/build_android.sh +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env bash -# Cross-compile the minimal SDK core for Android and check the result. -# -# Usage: build_android.sh [--check-only] [jni_libs_dir] -# -# Modelled on scripts/ci/build_android_jni.sh (`cargo ndk` per ABI). The aws-lc -# cleanup that script performs is node-specific and deliberately NOT carried -# over: this crate has no aws-lc. -# -# After building (or with --check-only, on an existing jniLibs tree) this -# script enforces three release gates on every produced .so: -# -# 1. 16 KB page alignment: every PT_LOAD segment must be aligned to >= 16384. -# Android 15+ requires it for native code (Play deadline 2026-05-31, passed). -# A misaligned library is a release blocker; the fix is NDK r28+ (aligns by -# default) or `-Wl,-z,max-page-size=16384` on older NDKs. -# 2. Exports: every .so must export the uniffi entry points (a wrong -# crate-type or LTO dropping them links fine and fails at first call). -# 3. Size: the single arm64-v8a slice must stay under ANDROID_ARM64_BUDGET -# bytes (3.0 MB). Install size tracks this RAW number, not a compressed one. -# -# Environment: -# ANDROID_NDK_ROOT / ANDROID_NDK_HOME NDK to use (default: newest under -# $ANDROID_HOME/ndk or ~/Android/Sdk/ndk) -# ANDROID_API minSdk the .so targets (default 24) -# ANDROID_ARM64_BUDGET arm64-v8a raw-size budget in bytes -# (default 3000000) -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -RUST_SDK_DIR="$REPO_ROOT/minimal-sdk/rust-sdk" -ANDROID_DIR="$REPO_ROOT/minimal-sdk/android" -LIB_NAME="libutexo_minimal_sdk.so" -ABIS=(arm64-v8a armeabi-v7a x86_64) -ANDROID_API="${ANDROID_API:-24}" -# Raw bytes for the arm64-v8a slice. The Rust size gate (tests/parity.rs) is -# 2.5 MB on the x86_64 host build; arm64 code is denser but the budget leaves -# room for the NDK's unwind tables and alignment padding. -ANDROID_ARM64_BUDGET="${ANDROID_ARM64_BUDGET:-3000000}" -REQUIRED_PAGE_ALIGN=16384 - -CHECK_ONLY=0 -if [[ "${1:-}" == "--check-only" ]]; then - CHECK_ONLY=1 - shift -fi -OUT_DIR="${1:-$ANDROID_DIR/generated/jniLibs}" - -die() { - echo "ERROR: $*" >&2 - exit 1 -} - -need_cmd() { - command -v "$1" >/dev/null 2>&1 || die "required command '$1' not found" -} - -resolve_ndk() { - local candidate="${ANDROID_NDK_ROOT:-${ANDROID_NDK_HOME:-}}" - if [[ -z "$candidate" ]]; then - local sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}" - if [[ -d "$sdk/ndk" ]]; then - candidate="$(find "$sdk/ndk" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)" - fi - fi - [[ -n "$candidate" && -f "$candidate/source.properties" ]] || - die "no Android NDK found; install one with 'sdkmanager \"ndk;\"' or set ANDROID_NDK_ROOT" - echo "$candidate" -} - -NDK_ROOT="$(resolve_ndk)" -NDK_VERSION="$(sed -n 's/^Pkg.Revision = //p' "$NDK_ROOT/source.properties")" -NDK_BIN="$NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin" -if [[ ! -d "$NDK_BIN" ]]; then - NDK_BIN="$(find "$NDK_ROOT/toolchains/llvm/prebuilt" -mindepth 1 -maxdepth 1 -type d | head -n 1)/bin" -fi -READELF="$NDK_BIN/llvm-readelf" -[[ -x "$READELF" ]] || READELF="$(command -v llvm-readelf || command -v readelf || true)" -[[ -n "$READELF" ]] || die "no readelf available (expected $NDK_BIN/llvm-readelf)" -NM="$NDK_BIN/llvm-nm" -[[ -x "$NM" ]] || NM="$(command -v llvm-nm || command -v nm || true)" -[[ -n "$NM" ]] || die "no nm available (expected $NDK_BIN/llvm-nm)" - -# Portable file size in bytes (GNU stat -c and BSD stat -f differ; wc does not). -file_size() { - wc -c <"$1" | tr -d '[:space:]' -} - -if [[ "$CHECK_ONLY" == "0" ]]; then - need_cmd cargo - cargo ndk --version >/dev/null 2>&1 || die "cargo-ndk not installed: cargo install cargo-ndk" - export ANDROID_NDK_ROOT="$NDK_ROOT" - export ANDROID_NDK_HOME="$NDK_ROOT" - echo "Using NDK $NDK_VERSION at $NDK_ROOT (API $ANDROID_API)" - rm -rf "$OUT_DIR" - mkdir -p "$OUT_DIR" - # NDK r28+ links the 64-bit ABIs with 16 KB max-page-size by default but - # leaves 32-bit arm at 4 KB (measured on r29: armeabi-v7a PT_LOAD align - # 0x1000). Android's 16 KB page mode only exists on 64-bit devices, so that - # is not a runtime bug, but the gate below applies to EVERY shipped .so so - # the AAR is uniformly aligned. Ask the linker explicitly for all targets; - # for arm64/x86_64 this restates the default. - PAGE_ALIGN_FLAG="-C link-arg=-Wl,-z,max-page-size=$REQUIRED_PAGE_ALIGN" - export CARGO_TARGET_AARCH64_LINUX_ANDROID_RUSTFLAGS="${CARGO_TARGET_AARCH64_LINUX_ANDROID_RUSTFLAGS:-} $PAGE_ALIGN_FLAG" - export CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_RUSTFLAGS="${CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_RUSTFLAGS:-} $PAGE_ALIGN_FLAG" - export CARGO_TARGET_X86_64_LINUX_ANDROID_RUSTFLAGS="${CARGO_TARGET_X86_64_LINUX_ANDROID_RUSTFLAGS:-} $PAGE_ALIGN_FLAG" - # One invocation for all three ABIs; cargo-ndk lays the output out as - # //$LIB_NAME, the jniLibs layout Gradle expects. - ( - cd "$RUST_SDK_DIR" - cargo ndk \ - --platform "$ANDROID_API" \ - -t arm64-v8a -t armeabi-v7a -t x86_64 \ - -o "$OUT_DIR" \ - build --release --lib - ) -fi - -# ---- Gate 1: 16 KB page alignment on every PT_LOAD segment of every .so ---- -check_alignment() { - local so="$1" - local ok=1 - local found=0 - local align - # llvm-readelf -lW prints one " LOAD off vaddr paddr filesz memsz flags align" line per segment. - while read -r align; do - found=1 - align=$(( align )) # readelf prints hex (0x4000); bash arithmetic parses it - if (( align < REQUIRED_PAGE_ALIGN )); then - echo " FAIL $so: PT_LOAD align $align < $REQUIRED_PAGE_ALIGN" >&2 - ok=0 - fi - done < <("$READELF" -lW "$so" | awk '$1 == "LOAD" { print $NF }') - (( found == 1 )) || die "$so has no PT_LOAD segments (not an ELF shared object?)" - (( ok == 1 )) -} - -echo "Checking 16 KB page alignment (PT_LOAD align >= $REQUIRED_PAGE_ALIGN):" -ALIGN_OK=1 -for abi in "${ABIS[@]}"; do - so="$OUT_DIR/$abi/$LIB_NAME" - [[ -f "$so" ]] || die "missing $so" - if check_alignment "$so"; then - echo " ok $abi" - else - ALIGN_OK=0 - fi -done -(( ALIGN_OK == 1 )) || - die "16 KB alignment check failed (release blocker). Use NDK r28+ or add -Wl,-z,max-page-size=16384; NDK in use: $NDK_VERSION" - -# ---- Gate 2: the uniffi entry points are exported from every .so ----------- -# JNA resolves these by name at first call; the same check build_apple.sh -# runs on the iOS device slice. -ENTRY_POINT="uniffi_utexo_minimal_sdk_fn_func_sdk_version" -echo "Checking exported uniffi entry points ($ENTRY_POINT):" -for abi in "${ABIS[@]}"; do - so="$OUT_DIR/$abi/$LIB_NAME" - "$NM" -D --defined-only "$so" 2>/dev/null | awk '{ print $NF }' | grep -qx "$ENTRY_POINT" || - die "$so does not export $ENTRY_POINT (wrong crate-type or stripped exports?)" - echo " ok $abi" -done - -# ---- Gate 3: arm64 raw size budget, plus the numbers worth recording ---- -echo "Android library sizes (raw bytes; install size tracks raw, not compressed):" -for abi in "${ABIS[@]}"; do - so="$OUT_DIR/$abi/$LIB_NAME" - printf ' %-12s %10d bytes\n' "$abi" "$(file_size "$so")" -done -for aar in "$ANDROID_DIR"/build/outputs/aar/*.aar; do - [[ -f "$aar" ]] || continue - printf ' %-12s %10d bytes (%s)\n' "aar" "$(file_size "$aar")" "$(basename "$aar")" -done -jna_aar="$(find "${GRADLE_USER_HOME:-$HOME/.gradle}/caches/modules-2/files-2.1/net.java.dev.jna/jna/5.14.0" -name 'jna-5.14.0.aar' 2>/dev/null | head -n 1 || true)" -if [[ -n "$jna_aar" ]]; then - printf ' %-12s %10d bytes (net.java.dev.jna:jna:5.14.0@aar, consumer-side dependency)\n' "jna" "$(file_size "$jna_aar")" -fi - -ARM64_BYTES="$(file_size "$OUT_DIR/arm64-v8a/$LIB_NAME")" -if (( ARM64_BYTES >= ANDROID_ARM64_BUDGET )); then - die "arm64-v8a $LIB_NAME is $ARM64_BYTES bytes, over the $ANDROID_ARM64_BUDGET byte budget" -fi -echo "Android size gate: arm64-v8a $ARM64_BYTES bytes < $ANDROID_ARM64_BUDGET budget (NDK $NDK_VERSION)" -echo "Android JNI libs OK in $OUT_DIR" diff --git a/minimal-sdk/scripts/build_apple.sh b/minimal-sdk/scripts/build_apple.sh deleted file mode 100755 index 8e609951..00000000 --- a/minimal-sdk/scripts/build_apple.sh +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env bash -# Build the minimal SDK core for Apple platforms and package it as an -# XCFramework for the Swift package in minimal-sdk/apple. -# -# Usage: build_apple.sh -# -# Modelled on scripts/ci/package_swift_xcframework.sh. Steps: -# 1. cargo build --release --lib (staticlib) for the iOS device target, -# the two iOS simulator targets and the two macOS targets -# 2. generate_bindings.sh swift (library mode, from the unstripped host -# build); the header + modulemap land in -# minimal-sdk/apple/Sources/MinimalSdkFFI which doubles as the -# XCFramework headers directory -# 3. lipo the simulator slices into one universal .a, same for macOS -# 4. xcodebuild -create-xcframework with device + simulator + macOS -# libraries, each with the headers directory -# 5. sanity: the device slice exports the uniffi symbols; print sizes -# -# The macOS slice exists so `swift test` can run the smoke test natively on -# a macOS host; the iOS app links the device slice only. -# -# Sizes printed here are archive sizes and are NOT the number that matters: -# iOS links statically and dead-strips at app link, so the meaningful figure -# is the app-size delta. There is deliberately no size gate in this script; -# the Rust size gate (tests/parity.rs) and the Android arm64 gate -# (build_android.sh) bound the code that ends up in the app. -# -# Requires macOS with Xcode (xcodebuild, lipo, nm) and the five Rust targets -# (rustup target add ...). Not runnable on Linux; the Swift package's Linux -# branch links the host cdylib directly instead. -# -# Environment: -# IOS_DEPLOYMENT_TARGET minimum iOS (default 15.0, must match Package.swift) -# MACOS_DEPLOYMENT_TARGET minimum macOS (default 12.0, must match Package.swift) -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -RUST_SDK_DIR="$REPO_ROOT/minimal-sdk/rust-sdk" -APPLE_DIR="$REPO_ROOT/minimal-sdk/apple" -LIB_BASENAME="libutexo_minimal_sdk.a" -FFI_MODULE="MinimalSdkFFI" -HEADERS_DIR="$APPLE_DIR/Sources/$FFI_MODULE" -BUILD_DIR="$APPLE_DIR/build" -XCFRAMEWORK="$APPLE_DIR/$FFI_MODULE.xcframework" -IOS_DEPLOYMENT_TARGET="${IOS_DEPLOYMENT_TARGET:-15.0}" -MACOS_DEPLOYMENT_TARGET="${MACOS_DEPLOYMENT_TARGET:-12.0}" - -IOS_DEVICE_TARGET=aarch64-apple-ios -IOS_SIM_TARGETS=(aarch64-apple-ios-sim x86_64-apple-ios) -MACOS_TARGETS=(aarch64-apple-darwin x86_64-apple-darwin) -ALL_TARGETS=("$IOS_DEVICE_TARGET" "${IOS_SIM_TARGETS[@]}" "${MACOS_TARGETS[@]}") - -[[ $# -eq 0 ]] || { - echo "usage: $0 (no arguments; see the header for environment variables)" >&2 - exit 1 -} - -die() { - echo "ERROR: $*" >&2 - exit 1 -} - -need_cmd() { - command -v "$1" >/dev/null 2>&1 || die "required command '$1' not found" -} - -[[ "$(uname -s)" == "Darwin" ]] || - die "XCFramework packaging needs macOS + Xcode; on Linux use 'swift test' in minimal-sdk/apple against the host cdylib" -need_cmd cargo -need_cmd rustup -need_cmd xcodebuild -need_cmd lipo -need_cmd nm - -INSTALLED_TARGETS="$(rustup target list --installed)" -for t in "${ALL_TARGETS[@]}"; do - grep -qx "$t" <<<"$INSTALLED_TARGETS" || die "Rust target $t not installed: rustup target add $t" -done - -TARGET_DIR="${CARGO_TARGET_DIR:-$RUST_SDK_DIR/target}" - -# ---- 1. Static libraries ------------------------------------------------- -# The deployment targets are pinned so every object in the archive records -# the same minimum OS as Package.swift declares; an archive built for a -# newer minimum than the app fails at link with an unhelpful ld warning. -export IPHONEOS_DEPLOYMENT_TARGET="$IOS_DEPLOYMENT_TARGET" -export MACOSX_DEPLOYMENT_TARGET="$MACOS_DEPLOYMENT_TARGET" -echo "Building release static libraries (iOS >= $IOS_DEPLOYMENT_TARGET, macOS >= $MACOS_DEPLOYMENT_TARGET)" -for t in "${ALL_TARGETS[@]}"; do - echo " cargo build --release --lib --target $t" - cargo build --release --lib --manifest-path "$RUST_SDK_DIR/Cargo.toml" --target "$t" - [[ -f "$TARGET_DIR/$t/release/$LIB_BASENAME" ]] || die "missing $TARGET_DIR/$t/release/$LIB_BASENAME" -done - -# ---- 2. Swift bindings, header and modulemap --------------------------- -"$SCRIPT_DIR/generate_bindings.sh" swift -[[ -f "$HEADERS_DIR/$FFI_MODULE.h" && -f "$HEADERS_DIR/module.modulemap" ]] || - die "generate_bindings.sh swift did not produce $HEADERS_DIR/{$FFI_MODULE.h,module.modulemap}" - -# ---- 3. Universal simulator and macOS archives --------------------------- -rm -rf "$BUILD_DIR" -mkdir -p "$BUILD_DIR/ios-simulator" "$BUILD_DIR/macos" -SIM_INPUTS=() -for t in "${IOS_SIM_TARGETS[@]}"; do - SIM_INPUTS+=("$TARGET_DIR/$t/release/$LIB_BASENAME") -done -lipo -create "${SIM_INPUTS[@]}" -output "$BUILD_DIR/ios-simulator/$LIB_BASENAME" -MAC_INPUTS=() -for t in "${MACOS_TARGETS[@]}"; do - MAC_INPUTS+=("$TARGET_DIR/$t/release/$LIB_BASENAME") -done -lipo -create "${MAC_INPUTS[@]}" -output "$BUILD_DIR/macos/$LIB_BASENAME" - -# ---- 4. XCFramework ------------------------------------------------------ -DEVICE_LIB="$TARGET_DIR/$IOS_DEVICE_TARGET/release/$LIB_BASENAME" -rm -rf "$XCFRAMEWORK" -xcodebuild -create-xcframework \ - -library "$DEVICE_LIB" -headers "$HEADERS_DIR" \ - -library "$BUILD_DIR/ios-simulator/$LIB_BASENAME" -headers "$HEADERS_DIR" \ - -library "$BUILD_DIR/macos/$LIB_BASENAME" -headers "$HEADERS_DIR" \ - -output "$XCFRAMEWORK" - -# ---- 5. Sanity and numbers ---------------------------------------------- -# An archive that lost its exported uniffi entry points (wrong crate-type, -# LTO dropping them) links fine and fails at first call; catch it here. -nm -g "$DEVICE_LIB" 2>/dev/null | grep -q "_uniffi_utexo_minimal_sdk_fn_func_sdk_version" || - die "$DEVICE_LIB does not export the uniffi entry points" - -echo "Apple static library sizes (archive bytes; NOT install size, see README):" -for t in "${ALL_TARGETS[@]}"; do - printf ' %-24s %10d bytes\n' "$t" "$(stat -f %z "$TARGET_DIR/$t/release/$LIB_BASENAME")" -done -printf ' %-24s %10d bytes (lipo)\n' "ios-simulator universal" "$(stat -f %z "$BUILD_DIR/ios-simulator/$LIB_BASENAME")" -printf ' %-24s %10d bytes (lipo)\n' "macos universal" "$(stat -f %z "$BUILD_DIR/macos/$LIB_BASENAME")" -echo "Packaged $XCFRAMEWORK" diff --git a/minimal-sdk/scripts/generate_bindings.sh b/minimal-sdk/scripts/generate_bindings.sh deleted file mode 100755 index 88c576f0..00000000 --- a/minimal-sdk/scripts/generate_bindings.sh +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env bash -# Generate Kotlin or Swift bindings for the minimal SDK core in uniffi -# LIBRARY MODE: the crate is proc-macro only, there is no UDL file and none -# may be added (docs/plans/20260915-minimal-mobile-rust-sdk.md, Task 1). -# -# Usage: generate_bindings.sh [output_dir] [library_path] -# -# Library mode reads the UNIFFI_META_* symbols from the built library's -# symbol table. The release profile strips symbols, so bindings are generated -# from an UNSTRIPPED host DEBUG build (`cargo build --lib`). The metadata is -# profile- and target-independent, so the output matches the stripped release -# and the cross-compiled Android/iOS libraries exactly. -# -# Output layout: -# kotlin /com/utexo/minimalsdk/utexo_minimal_sdk.kt -# (default output_dir: minimal-sdk/android/generated/kotlin) -# swift /MinimalSdk.swift the Swift sources -# /MinimalSdkFFI.h the C header -# /module.modulemap the modulemap -# (default output_dir: minimal-sdk/apple/Sources/MinimalSdk/Generated; -# ffi_dir is always minimal-sdk/apple/Sources/MinimalSdkFFI) -# -# library_path defaults to an unstripped host debug build made here; pass a -# path to reuse an existing build instead. -# -# The Swift header and modulemap are split out of the Swift source directory -# because they serve two masters that must see the same bytes: on Apple -# platforms build_apple.sh hands to `xcodebuild -create-xcframework` -# as the XCFramework's headers; on Linux Package.swift points a systemLibrary -# target at it so `swift test` can run the smoke test against the host cdylib. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -RUST_SDK_DIR="$REPO_ROOT/minimal-sdk/rust-sdk" -APPLE_DIR="$REPO_ROOT/minimal-sdk/apple" -BINDGEN_MANIFEST="$REPO_ROOT/bindings/uniffi-bindgen/Cargo.toml" -CONFIG_PATH="$RUST_SDK_DIR/uniffi.toml" -CRATE_NAME="utexo_minimal_sdk" -SWIFT_MODULE="MinimalSdk" -SWIFT_FFI_MODULE="MinimalSdkFFI" - -LANGUAGE="${1:-}" -if [[ "$LANGUAGE" != "kotlin" && "$LANGUAGE" != "swift" ]]; then - echo "usage: $0 [output_dir] [library_path]" >&2 - exit 1 -fi - -OUT_DIR="${2:-}" -if [[ -z "$OUT_DIR" ]]; then - case "$LANGUAGE" in - kotlin) OUT_DIR="$REPO_ROOT/minimal-sdk/android/generated/kotlin" ;; - swift) OUT_DIR="$APPLE_DIR/Sources/$SWIFT_MODULE/Generated" ;; - esac -fi -SWIFT_FFI_DIR="$APPLE_DIR/Sources/$SWIFT_FFI_MODULE" - -need_cmd() { - command -v "$1" >/dev/null 2>&1 || { - echo "ERROR: required command '$1' not found" >&2 - exit 1 - } -} -need_cmd cargo - -# Guard the library-mode contract: a UDL file would silently take precedence -# in the node's scripts and would drift from the proc-macro surface here. -if compgen -G "$RUST_SDK_DIR/src/*.udl" >/dev/null; then - echo "ERROR: $RUST_SDK_DIR/src contains a .udl file; the minimal SDK is proc-macro only" >&2 - exit 1 -fi - -LIB_PATH="${3:-}" -if [[ -z "$LIB_PATH" ]]; then - echo "Building unstripped host library for uniffi metadata..." - cargo build --lib --manifest-path "$RUST_SDK_DIR/Cargo.toml" - TARGET_DIR="${CARGO_TARGET_DIR:-$RUST_SDK_DIR/target}" - for candidate in \ - "$TARGET_DIR/debug/lib${CRATE_NAME}.so" \ - "$TARGET_DIR/debug/lib${CRATE_NAME}.dylib"; do - if [[ -f "$candidate" ]]; then - LIB_PATH="$candidate" - break - fi - done -fi -if [[ -z "$LIB_PATH" || ! -f "$LIB_PATH" ]]; then - echo "ERROR: built library not found (pass it as the 3rd argument)" >&2 - exit 1 -fi - -rm -rf "$OUT_DIR" -mkdir -p "$OUT_DIR" - -cargo run --quiet --manifest-path "$BINDGEN_MANIFEST" -- \ - generate "$LIB_PATH" \ - --library \ - --crate "$CRATE_NAME" \ - --language "$LANGUAGE" \ - --config "$CONFIG_PATH" \ - -o "$OUT_DIR" - -case "$LANGUAGE" in - kotlin) - GENERATED="$OUT_DIR/com/utexo/minimalsdk/${CRATE_NAME}.kt" - ;; - swift) - GENERATED="$OUT_DIR/$SWIFT_MODULE.swift" - ;; -esac -if [[ ! -f "$GENERATED" ]]; then - echo "ERROR: expected generated file missing: $GENERATED" >&2 - exit 1 -fi - -if [[ "$LANGUAGE" == "swift" ]]; then - HEADER="$OUT_DIR/$SWIFT_FFI_MODULE.h" - MODULEMAP="$OUT_DIR/$SWIFT_FFI_MODULE.modulemap" - for f in "$HEADER" "$MODULEMAP"; do - [[ -f "$f" ]] || { - echo "ERROR: expected generated file missing: $f" >&2 - exit 1 - } - done - # A systemLibrary target and an XCFramework headers directory both want - # the modulemap under its canonical name, next to the header it declares. - rm -rf "$SWIFT_FFI_DIR" - mkdir -p "$SWIFT_FFI_DIR" - mv "$HEADER" "$SWIFT_FFI_DIR/$SWIFT_FFI_MODULE.h" - mv "$MODULEMAP" "$SWIFT_FFI_DIR/module.modulemap" - echo "Swift FFI header and modulemap in $SWIFT_FFI_DIR" -fi - -echo "Generated $LANGUAGE bindings from $LIB_PATH into $OUT_DIR" From cd0f42d577669d1e8453658fab340ba46f4229f4 Mon Sep 17 00:00:00 2001 From: rmn-boiko Date: Tue, 29 Sep 2026 16:43:01 +0200 Subject: [PATCH 6/9] Add wallet logs --- .../packages/gateway/src/wallets/rgblib.ts | 2 +- .../packages/gateway/src/wallets/service.ts | 13 +++- .../gateway/test/rgblib-mapping.test.ts | 7 ++ .../gateway/test/wallet-routes.test.ts | 66 ++++++++++++++++++- 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/minimal-sdk/packages/gateway/src/wallets/rgblib.ts b/minimal-sdk/packages/gateway/src/wallets/rgblib.ts index 98422d94..b70a3b54 100644 --- a/minimal-sdk/packages/gateway/src/wallets/rgblib.ts +++ b/minimal-sdk/packages/gateway/src/wallets/rgblib.ts @@ -160,7 +160,7 @@ function toDetail(error: unknown): string { * variant degrades to 500, never to a wrong 400. */ const CLIENT_ERROR_VARIANTS = - /\b(AssetNotFound|BitcoinNetworkMismatch|Invalid(Address|AmountZero|Assignment|BitcoinNetwork|Expiration|FeeRate|ProxyProtocol|RecipientData|RecipientID|RecipientMap|RecipientNetwork|TransportEndpoints?|Txid|WitnessVersion)|MaxFeeExceeded|MinFeeNotMet|NoValidTransportEndpoint|OutputBelowDustLimit|RecipientIDAlreadyUsed|RecipientIDDuplicated|UnknownRgbSchema|UnsupportedSchema|UnsupportedTransportType)\b/; + /\b(AssetNotFound|BitcoinNetworkMismatch|Invalid(Address|AmountZero|Assignment|BitcoinKeys|BitcoinNetwork|Expiration|FeeRate|Fingerprint|ProxyProtocol|Pubkey|RecipientData|RecipientID|RecipientMap|RecipientNetwork|TransportEndpoints?|Txid|VanillaKeychain|WitnessVersion)|MaxFeeExceeded|MinFeeNotMet|NoValidTransportEndpoint|OutputBelowDustLimit|RecipientIDAlreadyUsed|RecipientIDDuplicated|UnknownRgbSchema|UnsupportedSchema|UnsupportedTransportType)\b/; /** Wrap a native failure; rgb-lib errors are Rust debug strings. Exported for unit tests. */ export function wrapNativeError(operation: string, error: unknown): WalletBackendError { diff --git a/minimal-sdk/packages/gateway/src/wallets/service.ts b/minimal-sdk/packages/gateway/src/wallets/service.ts index 1eec0eb8..faa2985c 100644 --- a/minimal-sdk/packages/gateway/src/wallets/service.ts +++ b/minimal-sdk/packages/gateway/src/wallets/service.ts @@ -125,8 +125,17 @@ export class WalletService { try { address = await this.pool.withWallet({ userId, xpubs }, (wallet) => wallet.getAddress()); } catch (error) { - if (error instanceof WalletBackendError) { - throw new HttpError(400, 'INVALID_XPUBS', 'wallet construction from these xpubs failed'); + // Only a rejection rgb-lib pinned on the REQUEST may be answered 400. + // Everything else here — a missing rgb-lib native module, an unreachable + // indexer — is the deployment's fault, not the caller's, and blaming the + // xpubs sends an operator hunting for a client bug. Either way the cause + // must travel with the error: the central handler logs a 400 ONLY when it + // carries one, so dropping it left the single most common bring-up + // failure with no server-side trace at all. + if (error instanceof WalletBackendError && error.clientError) { + throw new HttpError(400, 'INVALID_XPUBS', 'wallet construction from these xpubs failed', { + cause: error, + }); } throw error; } diff --git a/minimal-sdk/packages/gateway/test/rgblib-mapping.test.ts b/minimal-sdk/packages/gateway/test/rgblib-mapping.test.ts index 1de4f19d..cc480852 100644 --- a/minimal-sdk/packages/gateway/test/rgblib-mapping.test.ts +++ b/minimal-sdk/packages/gateway/test/rgblib-mapping.test.ts @@ -41,6 +41,13 @@ describe('wrapNativeError', () => { 'RgbLib(InvalidTransportEndpoint { details: "bad scheme" })', 'RgbLib(RecipientIDDuplicated)', 'RgbLib(InvalidFeeRate { details: "below minimum" })', + // Key material the caller registered: their request, their 400. Without + // these, a genuinely bad xpub was indistinguishable from a missing rgb-lib + // module or an unreachable indexer at the registration route. + 'RgbLib(InvalidBitcoinKeys)', + 'RgbLib(InvalidFingerprint)', + 'RgbLib(InvalidPubkey { details: "bad point" })', + 'RgbLib(InvalidVanillaKeychain)', ])('classifies %s as a client error, not insufficient funds', (detail) => { const wrapped = wrapNativeError('sendAssetBegin', new Error(detail)); expect(wrapped.insufficientFunds).toBe(false); diff --git a/minimal-sdk/packages/gateway/test/wallet-routes.test.ts b/minimal-sdk/packages/gateway/test/wallet-routes.test.ts index 34d1c237..e23f33f1 100644 --- a/minimal-sdk/packages/gateway/test/wallet-routes.test.ts +++ b/minimal-sdk/packages/gateway/test/wallet-routes.test.ts @@ -122,8 +122,13 @@ describe('wallet routes', () => { expect(short.statusCode).toBe(400); }); - it('maps wallet-construction failure to 400 INVALID_XPUBS and persists nothing', async () => { - backend.openError = new WalletBackendError('wallet construction failed', 'bad xpub'); + it('maps a REJECTED xpub to 400 INVALID_XPUBS and persists nothing', async () => { + backend.openError = new WalletBackendError( + 'wallet construction failed', + 'RgbLib(InvalidBitcoinKeys)', + false, + true, + ); const response = await register(); expect(response.statusCode).toBe(400); expect(response.json().error.code).toBe('INVALID_XPUBS'); @@ -133,6 +138,63 @@ describe('wallet routes', () => { expect(retry.statusCode).toBe(201); }); + it('does NOT blame the xpubs for a deployment failure, and logs the cause', async () => { + // A missing rgb-lib native module or an unreachable indexer also fails the + // smoke-open. Answering 400 INVALID_XPUBS sent an operator hunting for a + // client bug — and, because the central handler logs a 400 only when it + // carries a cause, left the most common bring-up failure with no trace at + // all. Both halves are asserted here. + const lines: string[] = []; + const logged = await testServer({ + walletBackend: backend, + loggerStream: { write: (msg) => lines.push(msg) }, + }); + const loggedUser = await createTestUser(logged); + backend.openError = new WalletBackendError( + 'wallet construction failed', + 'rgb-lib native module unavailable: no platform package for linux-x64', + ); + const response = await logged.inject({ + method: 'POST', + url: '/v1/wallet/xpubs', + headers: { authorization: `Bearer ${loggedUser.token}` }, + payload: XPUBS, + }); + expect(response.statusCode).toBe(500); + expect(response.json().error.code).toBe('INTERNAL'); + // Opaque to the client (I4) but present in the log for the operator. + expect(response.body).not.toContain('rgb-lib'); + expect(lines.join('\n')).toContain('no platform package for linux-x64'); + backend.openError = undefined; + await logged.close(); + }); + + it('records the cause of a rejected xpub so a 400 is still diagnosable', async () => { + const lines: string[] = []; + const logged = await testServer({ + walletBackend: backend, + loggerStream: { write: (msg) => lines.push(msg) }, + }); + const loggedUser = await createTestUser(logged); + backend.openError = new WalletBackendError( + 'wallet construction failed', + 'RgbLib(InvalidFingerprint)', + false, + true, + ); + const response = await logged.inject({ + method: 'POST', + url: '/v1/wallet/xpubs', + headers: { authorization: `Bearer ${loggedUser.token}` }, + payload: XPUBS, + }); + expect(response.statusCode).toBe(400); + expect(response.body).not.toContain('RgbLib'); + expect(lines.join('\n')).toContain('InvalidFingerprint'); + backend.openError = undefined; + await logged.close(); + }); + it('evicts the smoke-opened wallet when persisting the xpubs fails', async () => { // The pool keys handles by userId alone, so a handle cached for xpubs // that were never persisted would serve addresses derived from the WRONG From f0276519fbba813b2d30d70609cc270435bdda28 Mon Sep 17 00:00:00 2001 From: rmn-boiko Date: Wed, 30 Sep 2026 13:51:09 +0200 Subject: [PATCH 7/9] Fix indexer network compatibility --- minimal-sdk/packages/gateway/README.md | 55 ++++++++++++++++++- minimal-sdk/packages/gateway/src/config.ts | 32 +++++++++-- .../gateway/src/wallets/derivation.ts | 2 + .../packages/gateway/src/wallets/prepare.ts | 2 + .../packages/gateway/test/config.test.ts | 17 ++++++ .../packages/gateway/test/derivation.test.ts | 14 +++++ 6 files changed, 117 insertions(+), 5 deletions(-) diff --git a/minimal-sdk/packages/gateway/README.md b/minimal-sdk/packages/gateway/README.md index 9c5b176f..6f6c4e99 100644 --- a/minimal-sdk/packages/gateway/README.md +++ b/minimal-sdk/packages/gateway/README.md @@ -249,7 +249,7 @@ Optional (default in parentheses): | `GATEWAY_HOST` (`127.0.0.1`) | Interface the gateway listens on. | | `GATEWAY_PORT` (`8480`) | Gateway HTTP port. | | `RLN_ADMIN_TOKEN` (empty) | Biscuit admin token; empty when RLN runs with `--disable-authentication` on localhost. Never logged, never echoed (I4). | -| `GATEWAY_BITCOIN_NETWORK` (`Regtest`) | rgb-lib network for user wallets: `Mainnet`, `Testnet`, `Signet`, `Regtest`. | +| `GATEWAY_BITCOIN_NETWORK` (`Regtest`) | rgb-lib network for user wallets: `Mainnet`, `Testnet`, `Testnet4`, `Signet`, `SignetCustom`, `Regtest`. **`Signet` means Bitcoin's _default_ signet** — see the indexer note below. | | `GATEWAY_WALLET_MAX_OPEN` (`32`) | LRU capacity of the open-wallet pool. | | `GATEWAY_ONCHAIN_OP_TTL_SECONDS` (`600`) | Seconds an unsigned prepare-step PSBT stays completable before expiring. | | `GATEWAY_FLOAT_CAP_PER_USER_MSAT` (`1000000000`) | Per-user ceiling on the custodial LN float. | @@ -284,6 +284,59 @@ one payment credit several users' ledgers. The gateway refuses such an intent wi intents are held to the same rule on the `recipient_id` the node blinds for them, for the same reason (the watcher attributes RGB by recipient id). +## Indexer requirements + +`GATEWAY_WALLET_INDEXER_URL` accepts **either** an Electrum server (`host:port`, +`ssl://host:port`) **or** an esplora REST base URL (`https://…`). The published rgb-lib +native module carries both the `electrum` and `esplora` features, and rgb-lib picks the +client by probing Electrum first and falling back to esplora. + +The two are not equivalent, and esplora is the more forgiving: + +| | Electrum resolver | esplora resolver | +| ------------------------------------------------------ | ----------------- | ---------------- | +| genesis hash matches the configured network | required | required | +| a known probe transaction exists (default signet only) | required | — | +| verbose transactions supported | required | — | + +So Blockstream/mempool `electrs` — the binary behind the esplora REST API — is **rejected +over its Electrum port** (`verbose transactions are currently unsupported`) while working +fine over its **esplora REST port**. Pointing this variable at the same esplora URL as +`ESPLORA_URL` is a valid and usually simpler deployment; a full Electrum server +(romanz/electrs, Fulcrum) is the alternative. + +### `Signet` vs `SignetCustom` + +`GATEWAY_BITCOIN_NETWORK=Signet` means Bitcoin's **default, public** signet. A signet +running its own challenge is `SignetCustom`. Every signet shares the same genesis block +hash, so the indexer check alone will NOT catch the difference when using esplora — but +the two are distinct chains to RGB and they tag invoices differently: `sb` for `Signet`, +`sbc` for `SignetCustom`. Getting this wrong yields a wallet that opens cleanly and then +produces RGB invoices no counterparty on the real chain will accept. Match whatever RLN +is running (`--network signetcustom`). + +### Diagnosing a failed `POST /v1/wallet/xpubs` + +Both mistakes surface as a wallet-open failure that names neither the variable nor the +fix: + +``` +RgbLib(InvalidIndexer { details: "resolver is for another chain-network pair" }) +RgbLib(InvalidIndexer { details: "... verbose transactions are unsupported ..." }) +``` + +If that route fails on a fresh deployment while every other route works, check these two +first. Two commands settle it: + +```sh +# 1. Electrum URL only: does it support verbose transactions? +# (result = usable, error = use the esplora URL instead, or a full Electrum server) +printf '{"jsonrpc":"2.0","id":1,"method":"blockchain.transaction.get","params":["",true]}\n' | nc + +# 2. Is this the DEFAULT signet? (404 = custom signet, so use SignetCustom) +curl -s -o /dev/null -w '%{http_code}\n' "$ESPLORA_URL/tx/8153034f45e695453250a8fb7225a5e545144071d8ed7b0d3211efa1f3c92ad8" +``` + ## Float-cap tuning The LN working balance is custodial (see the custody boundary below), so the caps are diff --git a/minimal-sdk/packages/gateway/src/config.ts b/minimal-sdk/packages/gateway/src/config.ts index 27859cd4..4af39a61 100644 --- a/minimal-sdk/packages/gateway/src/config.ts +++ b/minimal-sdk/packages/gateway/src/config.ts @@ -20,12 +20,21 @@ export interface GatewayConfig { /** Gateway-owned directory holding one watch-only wallet data-dir per user. */ walletsDir: string; /** - * Indexer URL passed to rgb-lib goOnline (electrum host:port — the variant - * verified in Preflight A; the shipped rgb-lib build has no esplora feature). + * Indexer URL passed to rgb-lib `goOnline`. Either an Electrum server + * (`host:port`, `ssl://host:port`) or an esplora REST base URL + * (`https://…`) — the published rgb-lib native module is built with BOTH the + * `electrum` and `esplora` features, and rgb-lib picks the client by probing + * Electrum first and falling back to esplora. + * + * They are NOT equivalent. rgb-lib's Electrum resolver additionally requires + * verbose-transaction support and, on the default signet, the presence of a + * specific probe transaction; its esplora resolver checks only the genesis + * hash. So a Blockstream/mempool `electrs` is rejected over its Electrum port + * but works over its esplora REST port. See the README's indexer section. */ walletIndexerUrl: string; /** rgb-lib BitcoinNetwork for user wallets. */ - bitcoinNetwork: 'Mainnet' | 'Testnet' | 'Signet' | 'Regtest'; + bitcoinNetwork: 'Mainnet' | 'Testnet' | 'Testnet4' | 'Signet' | 'SignetCustom' | 'Regtest'; /** LRU capacity of the open-wallet pool. */ walletMaxOpen: number; /** Seconds an unsigned prepare-step PSBT stays completable before expiring. */ @@ -113,7 +122,22 @@ function intWithDefault( return value; } -const BITCOIN_NETWORKS = ['Mainnet', 'Testnet', 'Signet', 'Regtest'] as const; +/** + * Every rgb-lib `BitcoinNetwork` variant (src/utils.rs). `Signet` means Bitcoin's + * DEFAULT signet; a signet with its own challenge is `SignetCustom` and is a + * different chain to RGB, even though every signet shares the same genesis block + * hash. Telling the gateway `Signet` for a custom signet fails at first wallet + * use with an `InvalidIndexer`/"resolver is for another chain-network pair" that + * names neither the network nor the fix, so the option has to exist here. + */ +const BITCOIN_NETWORKS = [ + 'Mainnet', + 'Testnet', + 'Testnet4', + 'Signet', + 'SignetCustom', + 'Regtest', +] as const; function bitcoinNetwork( env: Record, diff --git a/minimal-sdk/packages/gateway/src/wallets/derivation.ts b/minimal-sdk/packages/gateway/src/wallets/derivation.ts index 9f2f28bf..d417ca61 100644 --- a/minimal-sdk/packages/gateway/src/wallets/derivation.ts +++ b/minimal-sdk/packages/gateway/src/wallets/derivation.ts @@ -47,7 +47,9 @@ const ADDRESS_NETWORKS: Record< > = { Mainnet: { bech32: 'bc', pubKeyHash: 0x00, scriptHash: 0x05, wif: 0x80 }, Testnet: { bech32: 'tb', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, + Testnet4: { bech32: 'tb', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, Signet: { bech32: 'tb', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, + SignetCustom: { bech32: 'tb', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, Regtest: { bech32: 'bcrt', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, }; diff --git a/minimal-sdk/packages/gateway/src/wallets/prepare.ts b/minimal-sdk/packages/gateway/src/wallets/prepare.ts index a522ac3f..6d2cb051 100644 --- a/minimal-sdk/packages/gateway/src/wallets/prepare.ts +++ b/minimal-sdk/packages/gateway/src/wallets/prepare.ts @@ -131,7 +131,9 @@ const ADDRESS_NETWORKS: Record< > = { Mainnet: { bech32: 'bc', pubKeyHash: 0x00, scriptHash: 0x05, wif: 0x80 }, Testnet: { bech32: 'tb', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, + Testnet4: { bech32: 'tb', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, Signet: { bech32: 'tb', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, + SignetCustom: { bech32: 'tb', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, Regtest: { bech32: 'bcrt', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, }; diff --git a/minimal-sdk/packages/gateway/test/config.test.ts b/minimal-sdk/packages/gateway/test/config.test.ts index 91b8d7ac..a3c8ceb7 100644 --- a/minimal-sdk/packages/gateway/test/config.test.ts +++ b/minimal-sdk/packages/gateway/test/config.test.ts @@ -95,4 +95,21 @@ describe('loadConfig', () => { delete (env as Record)['RLN_ADMIN_TOKEN']; expect(loadConfig(env).rlnAdminToken).toBe(''); }); + + it('accepts every rgb-lib network, including the two signet variants', () => { + // `Signet` is Bitcoin's DEFAULT signet. A signet with its own challenge is a + // different chain to RGB even though every signet shares one genesis block + // hash, so a deployment on a custom signet MUST be able to say so — telling + // it `Signet` fails at first wallet use with an opaque "resolver is for + // another chain-network pair". + for (const network of ['Mainnet', 'Testnet', 'Testnet4', 'Signet', 'SignetCustom', 'Regtest']) { + const config = loadConfig(testEnv({ GATEWAY_BITCOIN_NETWORK: network })); + expect(config.bitcoinNetwork).toBe(network); + } + // Case-insensitive, as before. + expect(loadConfig(testEnv({ GATEWAY_BITCOIN_NETWORK: 'signetcustom' })).bitcoinNetwork).toBe( + 'SignetCustom', + ); + expect(() => loadConfig(testEnv({ GATEWAY_BITCOIN_NETWORK: 'Liquid' }))).toThrow(ConfigError); + }); }); diff --git a/minimal-sdk/packages/gateway/test/derivation.test.ts b/minimal-sdk/packages/gateway/test/derivation.test.ts index 416e55ff..0d79c979 100644 --- a/minimal-sdk/packages/gateway/test/derivation.test.ts +++ b/minimal-sdk/packages/gateway/test/derivation.test.ts @@ -129,6 +129,20 @@ describe('attributeVanillaAddress', () => { }); }); + it('treats both signet variants like every other non-mainnet chain', () => { + // SignetCustom differs from Signet only in which chain RGB thinks it is; the + // address encoding and the vanilla coin type (1) are identical, so + // attribution must work the same for both. + const expected = derive(VANILLA_TPUB, VANILLA_KEYCHAIN, 0, TESTNET_ADDRESS); + for (const network of ['Signet', 'SignetCustom', 'Testnet4'] as const) { + expect(attributeVanillaAddress(VANILLA_TPUB, network, expected.address, 5)).toMatchObject({ + index: 0, + derivationPath: "m/86'/1'/0'/0/0", + scriptHex: expected.scriptHex, + }); + } + }); + it('exposes a scan window wide enough to be useful', () => { expect(ATTRIBUTION_WINDOW).toBeGreaterThanOrEqual(100); }); From 18b60680b596f0d3abb6cdead7ba3582357f8025 Mon Sep 17 00:00:00 2001 From: rmn-boiko Date: Mon, 5 Oct 2026 18:55:08 +0200 Subject: [PATCH 8/9] Add integration docs --- minimal-sdk/docs/mobile-integration.md | 240 +++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 minimal-sdk/docs/mobile-integration.md diff --git a/minimal-sdk/docs/mobile-integration.md b/minimal-sdk/docs/mobile-integration.md new file mode 100644 index 00000000..c584ed2d --- /dev/null +++ b/minimal-sdk/docs/mobile-integration.md @@ -0,0 +1,240 @@ +# Integrating the UTEXO mobile SDK + +How to build an Android app on `rgb-sdk-kotlin-light` talking to a `minimal-gateway` +deployment. Written for the app developer; the operator-facing half of the gateway is in +[`packages/gateway/README.md`](../packages/gateway/README.md). + +Read [What works today](#2-what-works-today) first. The SDK's surface is deliberately +narrower than the gateway's, and one capability you will probably want — sending BTC — +works but is reachable only through a lower-level entry point, on purpose. + +--- + +## 1. The shape of it + +``` +Android app + └── rgb-sdk-kotlin-light keys, verify-before-sign, local signing, journal + │ the seed NEVER leaves the device + │ HTTPS + minimal-gateway per-user auth, idempotency, scoping + ├── watch-only rgb-lib wallet (one per user, built from your xpubs) + └── RLN shared Lightning node + └── esplora + RGB proxy +``` + +The division of labour is the point: + +- **The device holds the seed.** It derives the account xpubs and ships only those. +- **The gateway holds no key material.** It builds a watch-only rgb-lib wallet from your + xpubs and can therefore _prepare_ a transaction but never sign one. +- **The device verifies, then signs.** The gateway returns an unsigned PSBT plus a + machine-readable statement of intent; the SDK checks the PSBT against the original + request before a signature exists. +- **The gateway broadcasts.** + +So on-chain BTC is self-custodial: those coins cannot move without your user's device. +See [Custody](#6-custody-read-this-before-you-promise-anything) for where that stops +being true. + +## 2. Setup + +### Gradle + +```kotlin +// settings.gradle.kts — until the SDK is on a public repository +dependencyResolutionManagement { + repositories { mavenLocal(); google(); mavenCentral() } +} + +// app/build.gradle.kts +dependencies { + implementation("com.utexo:rgb-sdk-kotlin-light:0.1.0-SNAPSHOT") +} + +android { defaultConfig { minSdk = 24 } } // SDK minSdk is 24 +``` + +The AAR bundles JNI libraries for `arm64-v8a`, `armeabi-v7a` and `x86_64`. There is no +32-bit-only or RISC-V build; an emulator must be `x86_64` or `arm64-v8a`. + +### Gateway credentials + +The gateway has no self-service signup. An operator mints each user once: + +```sh +curl -X POST https://your-gateway.example/v1/users \ + -H "x-operator-token: $GATEWAY_OPERATOR_TOKEN" +# 201 {"userId":"u_…","token":"utxg_…","createdAt":…} +``` + +That `token` is returned **exactly once** — only its hash is stored, and there is no +rotation or recovery endpoint. Your backend should mint it during onboarding and deliver +it to the device over a channel you already trust. Treat it as a bearer credential: it +authorizes every call for that user. + +Store it with `AndroidKeystoreAPIKeyStore`, which implements `WalletAPIKeyProvider` and +reloads the credential per request, so rotation does not require rebuilding the wallet. + +--- + +## 3. Lifecycle + +### Provision the seed first + +The seed must exist in the Keystore _before_ the wallet is created. + +```kotlin +val walletId = WalletId.of("primary") +val network = Network.SIGNET + +// First run only. Back the mnemonic up — it is the user's ONLY recovery path. +SecretMnemonic.fromWords(userEnteredWords).use { mnemonic -> + AndroidKeystoreSeedStore(context, walletId, network) + .provisionFromMnemonic(mnemonic) +} +``` + +`SecretMnemonic`, `SecretSeed` and `WalletAPIKey` are `AutoCloseable` and zeroize on +`close()`. Always use them inside `use { }`. They redact themselves in `toString()`, so +they cannot leak through a log statement. + +### Create, init, unlock + +```kotlin +val wallet = UTEXOWallet.create( + context = context, + configuration = WalletConfiguration(walletId = walletId, network = network), + service = WalletServiceConfiguration( + baseUrl = "https://your-gateway.example", // HTTPS required + apiKeyProvider = AndroidKeystoreAPIKeyStore(context, walletId, network), + ), +) + +wallet.init() // registers the xpubs with the gateway (idempotent) +wallet.unlock() // loads the seed; required before any balance or signing call +``` + +- `baseUrl` must be **HTTPS** with no path, query or user-info. Plain HTTP is accepted only + for `127.0.0.1`, `::1`, `localhost` and the emulator host `10.0.2.2`, and only when you + opt in with `allowInsecureLocalDevelopment = true`. +- The backend permanently binds its credential to the first `WalletId` it sees, and pins + the gateway `userId` across restarts. A different user behind the same token fails as + `BackendProtocolMismatch` rather than silently switching wallets. +- Call `shutdown()` when backgrounding and `destroy()` before creating a replacement + instance — shutdown deliberately retains storage ownership. + +### Addresses + +`getAddress()` derives locally from the seed and does **not** ask the gateway. That is +deliberate: the gateway holds your xpubs and could otherwise hand back any address it +liked. If you do call `GET /v1/wallet/address` yourself, the response carries a +`derivation` object (`account`, `keychain`, `index`, `derivationPath`, `scriptHex`) so you +can re-derive it and compare before showing it to a user. A `null` derivation means the +gateway could not attribute the address — treat that as unproven, not as safe. + +--- + +## 4. Sending: the verify-before-sign contract + +### The gate + +**Sending works.** It has been run end to end against a live gateway — prepared, verified, +signed on-device and broadcast. What is gated is _which entry point_ can reach it. + +There are two, and they differ by exactly one boolean: + +| Entry point | `allowUnverifiedPsbtProfile` | `sendBtc` | +| ------------------------------------------------------------- | ------------------------------------------------- | --------------------------------- | +| `UTEXOWallet` (application API) | hardcoded `false` in `WalletServiceConfiguration` | throws `SdkException.Unsupported` | +| `RgbLightningNodeGatewayBackend` (`@WalletInfrastructureApi`) | you choose | works | + +Everything downstream of that flag is identical: the same preflight, the same intent +checks, the same native signer, the same gateway endpoints. The flag is a deliberate hold +on the _convenience_ API until a deployment's exact PSBT profile is qualified — not a sign +that the path is unfinished. + +So `wallet.sendBtc(...)` throwing `Unsupported` is expected today. To actually send, build +the backend yourself: + +```kotlin +@OptIn(WalletInfrastructureApi::class) +val backend = RgbLightningNodeGatewayBackend( + RgbLightningNodeGatewayConfiguration( + baseUrl = "https://your-gateway.example", + expectedNetwork = Network.SIGNET, + bearerToken = token, + allowUnverifiedPsbtProfile = true, // the gate + ), +) +``` + +Do not ship this without qualifying your deployment first. When you do, the flow is: + +``` +prepare → gateway returns { opId, psbt, intent, expiresAt } +verify → the adapter checks kind, recipient, amount, fee rate, canonical Base64, expiry +sign → the native signer re-validates inputs, outputs, fee and change + against the ORIGINAL request, then signs +complete → gateway finalizes and broadcasts +``` + +Two properties worth relying on. The signer validates against the request your code made, +not against anything the server said — so a server that returns an honest-looking intent +alongside a different PSBT is caught. And signatures never change a txid, so the txid +observed at verification is the txid that broadcasts; the server cannot swap the +transaction between the two steps. + +### Idempotency and retries + +Every money-moving call carries an `IdempotencyKey`. **Persist it and reuse it on retry** — +that key is the operation's identity: + +```kotlin +val request = SendBtcRequest( + address = BitcoinAddress.of(destination), + amount = Satoshis.of(100_000), + feeRate = FeeRate.satPerVbyte("2"), + idempotencyKey = persistedKey, // NOT IdempotencyKey.random() on a retry +) +``` + +A retry with the same key resumes the recorded operation instead of sending twice. A +_different_ request under the same key is rejected with `OperationConflict`. + +### Outcomes you must handle + +| Exception | Meaning | Do | +| ------------------------- | ----------------------------------------------------- | ---------------------------------- | +| `BackendRejected` | the gateway refused (bad address, insufficient funds) | surface it; the money did not move | +| `OperationConflict` | same key, different request | fix the request or use a fresh key | +| `OperationExpired` | the prepared PSBT passed its TTL | prepare again | +| **`OperationAmbiguous`** | **the outcome is unknown** | **see below** | +| `BackendUnavailable` | transient (timeout, 429, 5xx) | retry with the _same_ key | +| `PolicyRejected` | the PSBT failed local policy — possible tampering | do not retry blindly; investigate | +| `BackendProtocolMismatch` | the gateway returned something unmappable | treat as a bug, not a user error | + +**`OperationAmbiguous` does not mean failure.** rgb-lib broadcasts before it writes its +bookkeeping, so a failure after broadcast is indistinguishable from one before it. Never +present it as "payment failed" and never auto-retry it as a fresh send. The SDK preserves +the operation and resolves it on the next call with the same idempotency key; the gateway +also runs a background worker that finishes such operations server-side. Show the user +"confirming" and re-check. + +## 5. Verifying your integration + +A reference end-to-end run lives in the SDK repo at +`sdk/src/androidTest/kotlin/com/utexo/rgb/sdk/DeployedGatewayE2eTest.kt`. It no-ops unless +a gateway URL is supplied, and shows the full path — identity derivation, registration, +prepare, local verify-and-sign, completion — against a live gateway on an emulator. + +```sh +./gradlew :sdk:connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=com.utexo.rgb.sdk.DeployedGatewayE2eTest \ + -Pandroid.testInstrumentationRunnerArguments.gatewayBaseUrl=https://your-gateway.example \ + -Pandroid.testInstrumentationRunnerArguments.bearerToken=utxg_… \ + -Pandroid.testInstrumentationRunnerArguments.mnemonic=word_word_… # underscore-separated +``` + +Fund the registered address between step 1 and step 2. Use a throwaway mnemonic and a test +network. From ac8f219ca5f6d06184aa33fb8d488b0f62021f22 Mon Sep 17 00:00:00 2001 From: rmn-boiko Date: Fri, 9 Oct 2026 13:37:28 +0200 Subject: [PATCH 9/9] Rgb send fix --- minimal-sdk/docs/mobile-integration.md | 240 ------------------ .../packages/gateway/src/routes/onchain.ts | 10 +- .../packages/gateway/src/schemas/onchain.ts | 12 +- .../packages/gateway/src/schemas/wallet.ts | 12 +- .../packages/gateway/src/wallets/backend.ts | 22 +- .../packages/gateway/src/wallets/prepare.ts | 17 +- .../packages/gateway/src/wallets/rgblib.ts | 13 +- .../gateway/test/onchain-routes.test.ts | 5 +- .../gateway/test/rgblib-mapping.test.ts | 4 + .../packages/gateway/test/wallet-mocks.ts | 9 +- .../gateway/test/wallet-routes.test.ts | 2 + 11 files changed, 89 insertions(+), 257 deletions(-) delete mode 100644 minimal-sdk/docs/mobile-integration.md diff --git a/minimal-sdk/docs/mobile-integration.md b/minimal-sdk/docs/mobile-integration.md deleted file mode 100644 index c584ed2d..00000000 --- a/minimal-sdk/docs/mobile-integration.md +++ /dev/null @@ -1,240 +0,0 @@ -# Integrating the UTEXO mobile SDK - -How to build an Android app on `rgb-sdk-kotlin-light` talking to a `minimal-gateway` -deployment. Written for the app developer; the operator-facing half of the gateway is in -[`packages/gateway/README.md`](../packages/gateway/README.md). - -Read [What works today](#2-what-works-today) first. The SDK's surface is deliberately -narrower than the gateway's, and one capability you will probably want — sending BTC — -works but is reachable only through a lower-level entry point, on purpose. - ---- - -## 1. The shape of it - -``` -Android app - └── rgb-sdk-kotlin-light keys, verify-before-sign, local signing, journal - │ the seed NEVER leaves the device - │ HTTPS - minimal-gateway per-user auth, idempotency, scoping - ├── watch-only rgb-lib wallet (one per user, built from your xpubs) - └── RLN shared Lightning node - └── esplora + RGB proxy -``` - -The division of labour is the point: - -- **The device holds the seed.** It derives the account xpubs and ships only those. -- **The gateway holds no key material.** It builds a watch-only rgb-lib wallet from your - xpubs and can therefore _prepare_ a transaction but never sign one. -- **The device verifies, then signs.** The gateway returns an unsigned PSBT plus a - machine-readable statement of intent; the SDK checks the PSBT against the original - request before a signature exists. -- **The gateway broadcasts.** - -So on-chain BTC is self-custodial: those coins cannot move without your user's device. -See [Custody](#6-custody-read-this-before-you-promise-anything) for where that stops -being true. - -## 2. Setup - -### Gradle - -```kotlin -// settings.gradle.kts — until the SDK is on a public repository -dependencyResolutionManagement { - repositories { mavenLocal(); google(); mavenCentral() } -} - -// app/build.gradle.kts -dependencies { - implementation("com.utexo:rgb-sdk-kotlin-light:0.1.0-SNAPSHOT") -} - -android { defaultConfig { minSdk = 24 } } // SDK minSdk is 24 -``` - -The AAR bundles JNI libraries for `arm64-v8a`, `armeabi-v7a` and `x86_64`. There is no -32-bit-only or RISC-V build; an emulator must be `x86_64` or `arm64-v8a`. - -### Gateway credentials - -The gateway has no self-service signup. An operator mints each user once: - -```sh -curl -X POST https://your-gateway.example/v1/users \ - -H "x-operator-token: $GATEWAY_OPERATOR_TOKEN" -# 201 {"userId":"u_…","token":"utxg_…","createdAt":…} -``` - -That `token` is returned **exactly once** — only its hash is stored, and there is no -rotation or recovery endpoint. Your backend should mint it during onboarding and deliver -it to the device over a channel you already trust. Treat it as a bearer credential: it -authorizes every call for that user. - -Store it with `AndroidKeystoreAPIKeyStore`, which implements `WalletAPIKeyProvider` and -reloads the credential per request, so rotation does not require rebuilding the wallet. - ---- - -## 3. Lifecycle - -### Provision the seed first - -The seed must exist in the Keystore _before_ the wallet is created. - -```kotlin -val walletId = WalletId.of("primary") -val network = Network.SIGNET - -// First run only. Back the mnemonic up — it is the user's ONLY recovery path. -SecretMnemonic.fromWords(userEnteredWords).use { mnemonic -> - AndroidKeystoreSeedStore(context, walletId, network) - .provisionFromMnemonic(mnemonic) -} -``` - -`SecretMnemonic`, `SecretSeed` and `WalletAPIKey` are `AutoCloseable` and zeroize on -`close()`. Always use them inside `use { }`. They redact themselves in `toString()`, so -they cannot leak through a log statement. - -### Create, init, unlock - -```kotlin -val wallet = UTEXOWallet.create( - context = context, - configuration = WalletConfiguration(walletId = walletId, network = network), - service = WalletServiceConfiguration( - baseUrl = "https://your-gateway.example", // HTTPS required - apiKeyProvider = AndroidKeystoreAPIKeyStore(context, walletId, network), - ), -) - -wallet.init() // registers the xpubs with the gateway (idempotent) -wallet.unlock() // loads the seed; required before any balance or signing call -``` - -- `baseUrl` must be **HTTPS** with no path, query or user-info. Plain HTTP is accepted only - for `127.0.0.1`, `::1`, `localhost` and the emulator host `10.0.2.2`, and only when you - opt in with `allowInsecureLocalDevelopment = true`. -- The backend permanently binds its credential to the first `WalletId` it sees, and pins - the gateway `userId` across restarts. A different user behind the same token fails as - `BackendProtocolMismatch` rather than silently switching wallets. -- Call `shutdown()` when backgrounding and `destroy()` before creating a replacement - instance — shutdown deliberately retains storage ownership. - -### Addresses - -`getAddress()` derives locally from the seed and does **not** ask the gateway. That is -deliberate: the gateway holds your xpubs and could otherwise hand back any address it -liked. If you do call `GET /v1/wallet/address` yourself, the response carries a -`derivation` object (`account`, `keychain`, `index`, `derivationPath`, `scriptHex`) so you -can re-derive it and compare before showing it to a user. A `null` derivation means the -gateway could not attribute the address — treat that as unproven, not as safe. - ---- - -## 4. Sending: the verify-before-sign contract - -### The gate - -**Sending works.** It has been run end to end against a live gateway — prepared, verified, -signed on-device and broadcast. What is gated is _which entry point_ can reach it. - -There are two, and they differ by exactly one boolean: - -| Entry point | `allowUnverifiedPsbtProfile` | `sendBtc` | -| ------------------------------------------------------------- | ------------------------------------------------- | --------------------------------- | -| `UTEXOWallet` (application API) | hardcoded `false` in `WalletServiceConfiguration` | throws `SdkException.Unsupported` | -| `RgbLightningNodeGatewayBackend` (`@WalletInfrastructureApi`) | you choose | works | - -Everything downstream of that flag is identical: the same preflight, the same intent -checks, the same native signer, the same gateway endpoints. The flag is a deliberate hold -on the _convenience_ API until a deployment's exact PSBT profile is qualified — not a sign -that the path is unfinished. - -So `wallet.sendBtc(...)` throwing `Unsupported` is expected today. To actually send, build -the backend yourself: - -```kotlin -@OptIn(WalletInfrastructureApi::class) -val backend = RgbLightningNodeGatewayBackend( - RgbLightningNodeGatewayConfiguration( - baseUrl = "https://your-gateway.example", - expectedNetwork = Network.SIGNET, - bearerToken = token, - allowUnverifiedPsbtProfile = true, // the gate - ), -) -``` - -Do not ship this without qualifying your deployment first. When you do, the flow is: - -``` -prepare → gateway returns { opId, psbt, intent, expiresAt } -verify → the adapter checks kind, recipient, amount, fee rate, canonical Base64, expiry -sign → the native signer re-validates inputs, outputs, fee and change - against the ORIGINAL request, then signs -complete → gateway finalizes and broadcasts -``` - -Two properties worth relying on. The signer validates against the request your code made, -not against anything the server said — so a server that returns an honest-looking intent -alongside a different PSBT is caught. And signatures never change a txid, so the txid -observed at verification is the txid that broadcasts; the server cannot swap the -transaction between the two steps. - -### Idempotency and retries - -Every money-moving call carries an `IdempotencyKey`. **Persist it and reuse it on retry** — -that key is the operation's identity: - -```kotlin -val request = SendBtcRequest( - address = BitcoinAddress.of(destination), - amount = Satoshis.of(100_000), - feeRate = FeeRate.satPerVbyte("2"), - idempotencyKey = persistedKey, // NOT IdempotencyKey.random() on a retry -) -``` - -A retry with the same key resumes the recorded operation instead of sending twice. A -_different_ request under the same key is rejected with `OperationConflict`. - -### Outcomes you must handle - -| Exception | Meaning | Do | -| ------------------------- | ----------------------------------------------------- | ---------------------------------- | -| `BackendRejected` | the gateway refused (bad address, insufficient funds) | surface it; the money did not move | -| `OperationConflict` | same key, different request | fix the request or use a fresh key | -| `OperationExpired` | the prepared PSBT passed its TTL | prepare again | -| **`OperationAmbiguous`** | **the outcome is unknown** | **see below** | -| `BackendUnavailable` | transient (timeout, 429, 5xx) | retry with the _same_ key | -| `PolicyRejected` | the PSBT failed local policy — possible tampering | do not retry blindly; investigate | -| `BackendProtocolMismatch` | the gateway returned something unmappable | treat as a bug, not a user error | - -**`OperationAmbiguous` does not mean failure.** rgb-lib broadcasts before it writes its -bookkeeping, so a failure after broadcast is indistinguishable from one before it. Never -present it as "payment failed" and never auto-retry it as a fresh send. The SDK preserves -the operation and resolves it on the next call with the same idempotency key; the gateway -also runs a background worker that finishes such operations server-side. Show the user -"confirming" and re-check. - -## 5. Verifying your integration - -A reference end-to-end run lives in the SDK repo at -`sdk/src/androidTest/kotlin/com/utexo/rgb/sdk/DeployedGatewayE2eTest.kt`. It no-ops unless -a gateway URL is supplied, and shows the full path — identity derivation, registration, -prepare, local verify-and-sign, completion — against a live gateway on an emulator. - -```sh -./gradlew :sdk:connectedDebugAndroidTest \ - -Pandroid.testInstrumentationRunnerArguments.class=com.utexo.rgb.sdk.DeployedGatewayE2eTest \ - -Pandroid.testInstrumentationRunnerArguments.gatewayBaseUrl=https://your-gateway.example \ - -Pandroid.testInstrumentationRunnerArguments.bearerToken=utxg_… \ - -Pandroid.testInstrumentationRunnerArguments.mnemonic=word_word_… # underscore-separated -``` - -Fund the registered address between step 1 and step 2. Use a throwaway mnemonic and a test -network. diff --git a/minimal-sdk/packages/gateway/src/routes/onchain.ts b/minimal-sdk/packages/gateway/src/routes/onchain.ts index 77bb8abe..ce56ef74 100644 --- a/minimal-sdk/packages/gateway/src/routes/onchain.ts +++ b/minimal-sdk/packages/gateway/src/routes/onchain.ts @@ -43,8 +43,14 @@ export function registerOnchainRoutes(app: FastifyInstance): void { const userId = request.userId as string; const { opId, signedPsbt } = request.body as CompleteBody; return app.queues.enqueue(userId, async () => { - const { txid, utxosCreated } = await app.onchain.complete(userId, kind, opId, signedPsbt); - return kind === 'create_utxos' ? { txid, utxosCreated } : { txid }; + const completed = await app.onchain.complete(userId, kind, opId, signedPsbt); + if (kind === 'create_utxos') { + return { txid: completed.txid, utxosCreated: completed.utxosCreated }; + } + if (kind === 'send_asset') { + return { txid: completed.txid, batchTransferIdx: completed.batchTransferIdx }; + } + return { txid: completed.txid }; }); }); }; diff --git a/minimal-sdk/packages/gateway/src/schemas/onchain.ts b/minimal-sdk/packages/gateway/src/schemas/onchain.ts index a3f2d609..46acc0d9 100644 --- a/minimal-sdk/packages/gateway/src/schemas/onchain.ts +++ b/minimal-sdk/packages/gateway/src/schemas/onchain.ts @@ -226,8 +226,16 @@ export const sendAssetCompleteRouteSchema = { response: { 200: { type: 'object', - properties: { txid: { type: 'string' } }, - required: ['txid'], + properties: { + txid: { type: 'string' }, + /** + * rgb-lib's batch transfer index for the transfer this created. refresh, + * fail and delete address a transfer by this, not by its txid, so a + * client cannot manage what it just sent without it. + */ + batchTransferIdx: { type: ['integer', 'null'] }, + }, + required: ['txid', 'batchTransferIdx'], additionalProperties: false, }, ...completeErrorResponses, diff --git a/minimal-sdk/packages/gateway/src/schemas/wallet.ts b/minimal-sdk/packages/gateway/src/schemas/wallet.ts index 48f9853e..d4735325 100644 --- a/minimal-sdk/packages/gateway/src/schemas/wallet.ts +++ b/minimal-sdk/packages/gateway/src/schemas/wallet.ts @@ -165,6 +165,8 @@ export const walletUnspentsRouteSchema = { vout: { type: 'integer' }, amountSat: { type: 'number' }, colorable: { type: 'boolean' }, + /** True once the transaction creating this UTXO has been broadcast. */ + exists: { type: 'boolean' }, /** * Blind receives already promised against this UTXO. A client * counting free allocation slots cannot derive this from @@ -187,7 +189,15 @@ export const walletUnspentsRouteSchema = { }, }, }, - required: ['txid', 'vout', 'amountSat', 'colorable', 'pendingBlinded', 'allocations'], + required: [ + 'txid', + 'vout', + 'amountSat', + 'colorable', + 'exists', + 'pendingBlinded', + 'allocations', + ], additionalProperties: false, }, }, diff --git a/minimal-sdk/packages/gateway/src/wallets/backend.ts b/minimal-sdk/packages/gateway/src/wallets/backend.ts index d1374028..036b955a 100644 --- a/minimal-sdk/packages/gateway/src/wallets/backend.ts +++ b/minimal-sdk/packages/gateway/src/wallets/backend.ts @@ -67,6 +67,12 @@ export interface WalletUnspent { vout: number; amountSat: number; colorable: boolean; + /** + * Whether the transaction creating this UTXO has been broadcast. A client + * selecting inputs or counting spendable value cannot infer this from the + * other fields, and must not guess it. + */ + exists: boolean; /** Blind receives already promised against this UTXO (reserved slots). */ pendingBlinded: number; allocations: UnspentAllocation[]; @@ -122,6 +128,12 @@ export interface SendAssetBeginRequest { expirationTimestamp: number; } +export interface AssetSendResult { + txid: string; + /** rgb-lib's batch transfer index for the created transfer. */ + batchTransferIdx: number; +} + export interface CreateUtxosParams { upTo: boolean; num: number; @@ -146,8 +158,14 @@ export interface WalletHandle { sendBtcEnd(signedPsbt: string): Promise; /** Prepare an unsigned asset-send PSBT (opret commitment already embedded). */ sendAssetBegin(request: SendAssetBeginRequest): Promise; - /** Post consignment + broadcast a signed asset-send PSBT; returns the txid. */ - sendAssetEnd(signedPsbt: string): Promise; + /** + * Post consignment + broadcast a signed asset-send PSBT. + * + * The batch transfer index comes back with the txid because rgb-lib keys + * refresh, fail and delete by it — a client that only receives a txid cannot + * address the transfer it just created. + */ + sendAssetEnd(signedPsbt: string): Promise; /** Prepare an unsigned PSBT creating colorable UTXOs. */ createUtxosBegin(params: CreateUtxosParams): Promise; /** Broadcast a signed create-utxos PSBT; returns how many UTXOs were created. */ diff --git a/minimal-sdk/packages/gateway/src/wallets/prepare.ts b/minimal-sdk/packages/gateway/src/wallets/prepare.ts index 6d2cb051..7119fcb5 100644 --- a/minimal-sdk/packages/gateway/src/wallets/prepare.ts +++ b/minimal-sdk/packages/gateway/src/wallets/prepare.ts @@ -68,6 +68,11 @@ export interface CompletedOp { txid: string | null; /** Only set for create-utxos completions. */ utxosCreated: number | null; + /** + * Only set for send-asset completions: rgb-lib's batch transfer index, which + * is how refresh, fail and delete address the transfer this created. + */ + batchTransferIdx: number | null; } /** @@ -437,8 +442,9 @@ export class OnchainService { let txid: string | null = null; let utxosCreated: number | null = null; + let batchTransferIdx: number | null = null; try { - ({ txid, utxosCreated } = await this.runEnd(identity, kind, signedPsbt)); + ({ txid, utxosCreated, batchTransferIdx } = await this.runEnd(identity, kind, signedPsbt)); } catch (error) { if (error instanceof WalletBackendError) { if (error.insufficientFunds) { @@ -483,7 +489,7 @@ export class OnchainService { } this.settleCompleted(opId, kind, userId, txid, now); - return { txid, utxosCreated }; + return { txid, utxosCreated, batchTransferIdx }; } /** @@ -499,17 +505,20 @@ export class OnchainService { ): Promise { let txid: string | null = null; let utxosCreated: number | null = null; + let batchTransferIdx: number | null = null; await this.pool.withWallet(identity, async (wallet) => { if (kind === 'send_btc') { txid = await wallet.sendBtcEnd(signedPsbt); } else if (kind === 'send_asset') { - txid = await wallet.sendAssetEnd(signedPsbt); + const sent = await wallet.sendAssetEnd(signedPsbt); + txid = sent.txid; + batchTransferIdx = sent.batchTransferIdx; } else { utxosCreated = await wallet.createUtxosEnd(signedPsbt); txid = txidFromPsbt(signedPsbt); } }); - return { txid, utxosCreated }; + return { txid, utxosCreated, batchTransferIdx }; } /** Terminal bookkeeping for a finished op; shared with the worker. */ diff --git a/minimal-sdk/packages/gateway/src/wallets/rgblib.ts b/minimal-sdk/packages/gateway/src/wallets/rgblib.ts index b70a3b54..b2e30df8 100644 --- a/minimal-sdk/packages/gateway/src/wallets/rgblib.ts +++ b/minimal-sdk/packages/gateway/src/wallets/rgblib.ts @@ -23,6 +23,7 @@ import { type SendAssetBeginRequest, type UnspentAllocation, type WalletAsset, + type AssetSendResult, type WalletBackend, type WalletBtcBalance, type WalletHandle, @@ -334,6 +335,7 @@ export function mapUnspents(raw: unknown): WalletUnspent[] { vout: safeIntegerOrZero(outpoint['vout'], 'unspent vout'), amountSat: safeIntegerOrZero(utxo['btcAmount'], 'unspent btcAmount'), colorable: utxo['colorable'] === true, + exists: utxo['exists'] === true, // Blind receives already promised against this UTXO. A client choosing // inputs or counting free allocation slots cannot do it from // `allocations` alone: a pending blind receive reserves a slot without @@ -591,18 +593,23 @@ class NativeWalletHandle implements WalletHandle { return psbt; } - async sendAssetEnd(signedPsbt: string): Promise { + async sendAssetEnd(signedPsbt: string): Promise { const raw = this.call('sendAssetEnd', () => JSON.parse(this.lib.rgblib_send_end(this.wallet, this.online, signedPsbt, false)), ) as Record; const txid = stringOrNull(raw['txid']); - if (txid === null) { + // rgb-lib's OperationResult always carries both; a response missing either + // is shape drift, and a transfer a client cannot address is not a success. + if (txid === null || typeof raw['batchTransferIdx'] !== 'number') { throw new WalletBackendError( 'wallet sendAssetEnd failed', `unexpected send result shape: ${Object.keys(raw).join(',')}`, ); } - return txid; + return { + txid, + batchTransferIdx: safeInteger(raw['batchTransferIdx'], 'send batchTransferIdx'), + }; } async createUtxosBegin(params: CreateUtxosParams): Promise { diff --git a/minimal-sdk/packages/gateway/test/onchain-routes.test.ts b/minimal-sdk/packages/gateway/test/onchain-routes.test.ts index 015fd123..6a35275d 100644 --- a/minimal-sdk/packages/gateway/test/onchain-routes.test.ts +++ b/minimal-sdk/packages/gateway/test/onchain-routes.test.ts @@ -481,7 +481,10 @@ describe('on-chain prepare/complete routes', () => { const { opId } = (await prepareSendAsset()).json(); const response = await complete('send-asset', opId); expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ txid: 'mock-asset-txid' }); + // The batch transfer index travels with the txid: rgb-lib keys refresh, + // fail and delete by it, so a client cannot manage what it just sent + // without it. + expect(response.json()).toEqual({ txid: 'mock-asset-txid', batchTransferIdx: 7 }); const owned = app.db .prepare('SELECT user_id, state FROM resource_map WHERE kind = ? AND resource_id = ?') .get('asset_transfer', 'mock-asset-txid') as { user_id: string; state: string }; diff --git a/minimal-sdk/packages/gateway/test/rgblib-mapping.test.ts b/minimal-sdk/packages/gateway/test/rgblib-mapping.test.ts index cc480852..a5af8eef 100644 --- a/minimal-sdk/packages/gateway/test/rgblib-mapping.test.ts +++ b/minimal-sdk/packages/gateway/test/rgblib-mapping.test.ts @@ -215,6 +215,7 @@ describe('mapUnspents', () => { outpoint: { txid: 'txid-1', vout: 2 }, btcAmount: 998, colorable: true, + exists: true, }, pendingBlinded: 1, rgbAllocations: [ @@ -229,6 +230,7 @@ describe('mapUnspents', () => { vout: 2, amountSat: 998, colorable: true, + exists: true, pendingBlinded: 1, allocations: [ { @@ -253,6 +255,8 @@ describe('mapUnspents', () => { { utxo: { outpoint: { txid: 't', vout: 0 }, btcAmount: 1, colorable: false } }, ]); expect(unspent?.pendingBlinded).toBe(0); + // Absent `exists` must read as false, never as an optimistic true. + expect(unspent?.exists).toBe(false); }); it('returns an empty list for a non-array payload', () => { diff --git a/minimal-sdk/packages/gateway/test/wallet-mocks.ts b/minimal-sdk/packages/gateway/test/wallet-mocks.ts index 0c77fe17..60c136f3 100644 --- a/minimal-sdk/packages/gateway/test/wallet-mocks.ts +++ b/minimal-sdk/packages/gateway/test/wallet-mocks.ts @@ -1,5 +1,6 @@ /** Configurable in-memory wallet backend for unit tests. */ import type { + AssetSendResult, CreateUtxosParams, ReceiveData, ReceiveRequest, @@ -26,6 +27,7 @@ export interface MockWalletData { preparedPsbt?: string; sendBtcTxid?: string; sendAssetTxid?: string; + sendAssetBatchTransferIdx?: number; utxosCreated?: number; } @@ -120,10 +122,13 @@ export class MockWalletHandle implements WalletHandle { }); } - sendAssetEnd(signedPsbt: string): Promise { + sendAssetEnd(signedPsbt: string): Promise { return this.op('sendAssetEnd', () => { this.lastSignedPsbt = signedPsbt; - return this.data.sendAssetTxid ?? 'mock-asset-txid'; + return { + txid: this.data.sendAssetTxid ?? 'mock-asset-txid', + batchTransferIdx: this.data.sendAssetBatchTransferIdx ?? 7, + }; }); } diff --git a/minimal-sdk/packages/gateway/test/wallet-routes.test.ts b/minimal-sdk/packages/gateway/test/wallet-routes.test.ts index e23f33f1..a8f74234 100644 --- a/minimal-sdk/packages/gateway/test/wallet-routes.test.ts +++ b/minimal-sdk/packages/gateway/test/wallet-routes.test.ts @@ -374,6 +374,7 @@ describe('wallet routes', () => { vout: 1, amountSat: 9999, colorable: true, + exists: true, pendingBlinded: 2, allocations: [ { @@ -400,6 +401,7 @@ describe('wallet routes', () => { vout: 1, amountSat: 9999, colorable: true, + exists: true, pendingBlinded: 2, allocations: [ {