diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbef3bb..b5f66ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: target: aarch64-unknown-linux-gnu - os: macos-latest target: aarch64-apple-darwin - - os: macos-13 + - os: macos-15-intel target: x86_64-apple-darwin steps: diff --git a/.gitignore b/.gitignore index 75602a8..51025f0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ target .zone checkpoint-*.tar.gz latest.json + +data/ +setup-spaced-prod-env.sh +setup-spaced-prod-vpn.sh +setup-spaced-test-env.sh +NOTES.md diff --git a/CREATENUM.md b/CREATENUM.md new file mode 100644 index 0000000..afddda8 --- /dev/null +++ b/CREATENUM.md @@ -0,0 +1,150 @@ +# Create a num with `--bind-spk` (CLI, JSON-RPC, Node.js) + +This document describes how to create a new **num** with a **bound script public key** (`--bind-spk`), matching `space-cli createnum --bind-spk [--fee-rate ]` and the equivalent JSON-RPC calls. + +## CLI + +```bash +space-cli createnum --bind-spk [--fee-rate ] +``` + +- **`--bind-spk`** — script pubkey as hex (even-length hex string). Same bytes you would pass over JSON-RPC as `bind_spk`. +- **`--fee-rate`** — optional; fee in **sat/vB**. If omitted, the wallet **estimates** a fee (via Bitcoin Core `estimatesmartfee`, same as other wallet operations without an explicit rate). + +Omitting `--bind-spk` is a different code path (auto-generated address). This document only covers the explicit `--bind-spk` case. + +## JSON-RPC (same node as `space-cli`) + +Transport, default ports, and `Authorization: Basic` are the same as in [ESTIMATE_FEE.md](ESTIMATE_FEE.md) (POST JSON-RPC 2.0 to the base HTTP URL, Basic auth from cookie or user/password). + +### Option 1: `walletsendrequest` (full CLI parity) + +The CLI uses this method. Parameters are a **positional array**: `["", ]`. + +The builder should contain a **single** request of type `createnum` with `bind_spk` set to the same hex string as the CLI. Optional fee behavior matches the CLI: + +- **`fee_rate: null`** — no explicit fee in the request; the node estimates (like omitting `--fee-rate`). +- **`fee_rate: `** — wire format for [rust-bitcoin `FeeRate`](https://docs.rs/bitcoin/): the value is the internal **sat/kwu** integer, not sat/vB. Conversion: **sat/kwu = sat/vB × 250** (because 1 vB = 4 WU, and the newtype stores sat per 1000 WU). Example: 2 sat/vB → `500`. + +Minimal example (no explicit fee, wallet name `default`): + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "walletsendrequest", + "params": [ + "default", + { + "requests": [ + { + "request": "createnum", + "bind_spk": "5120aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "fee_rate": null, + "force": false, + "confirmed_only": false, + "skip_tx_check": false + } + ] +} +``` + +To set an explicit fee in sat/vB (e.g. 2), set `"fee_rate": 500` in the object above (2 × 250). The [Node.js helper](#nodejs-helper-spaced-createnum-apimjs) below accepts **sat/vB** and applies this conversion for you. + +**Result** — same shape as other wallet batch operations (`WalletResponse` JSON from the node). + +### Option 2: `walletcreatenum` (convenience) + +Method **`walletcreatenum`** takes three positional parameters: + +1. **Wallet** (string) +2. **`fee_rate_sat_vb`** (integer) — fee in **sat/vB** (the server maps this to an internal `FeeRate`; you do not send sat/kwu yourself). +3. **`spk_hex`** (string) — hex-encoded script pubkey. + +This path **always** requires an explicit `fee_rate_sat_vb`. It does **not** support the “no `--fee-rate` / estimate” behavior. For that, use **`walletsendrequest`** with `fee_rate: null` (Option 1) or the Node helper with `feeRateSatPerVB: null`. + +Example (from the built-in schema examples): + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "walletcreatenum", + "params": [ + "default", + 2, + "5120aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ] +} +``` + +## Node.js helper: `spaced-createnum-api.mjs` + +The repo includes **`spaced-createnum-api.mjs`**, which calls **`walletsendrequest`** with a `createnum` request so behavior matches the CLI (optional estimated fee or explicit **sat/vB**). + +- **`createNumWithBindSpk({ ... })`** — main entry point. +- **`createSpacedAuthHeader(...)`** — builds `Authorization: Basic` from `SPACED_RPC_USER` / `SPACED_RPC_PASSWORD`, **`SPACED_COOKIE`**, or an options object. +- **`feeRateSatPerVbToWire(satPerVb)`** — **sat/vB →** JSON `fee_rate` value (× 250) when you build requests by hand. +- **`spacedJsonRpc({ rpcUrl, authorization, method, params })`** — low-level POST wrapper. + +**Environment** (optional): `SPACED_RPC_URL` (default `http://127.0.0.1:7225` if unset in code), and the same auth env vars as above. + +**Import (ESM):** + +```javascript +import { createNumWithBindSpk } from "./spaced-createnum-api.mjs"; + +const result = await createNumWithBindSpk({ + bindSpkHex: "5120…", + feeRateSatPerVB: null, // estimate fee (like omitting --fee-rate) + auth: { cookiePath: process.env.HOME + "/.local/share/spaced/mainnet/.cookie" }, +}); +``` + +**Explicit fee (e.g. 2 sat/vB, like `--fee-rate 2`):** + +```javascript +await createNumWithBindSpk({ + bindSpkHex: "5120…", + feeRateSatPerVB: 2, + auth: { cookiePath: "…" }, +}); +``` + +**One-shot CLI** (same module, no `import`): + +```bash +node spaced-createnum-api.mjs [fee-sat/vB] +``` + +If the fee argument is omitted, the helper uses **`null`** (estimate). + +## Example script: `test-createnum.js` + +**`test-createnum.js`** loads the ESM module and demonstrates **with** and **without** an explicit `feeRateSatPerVB`. Requires **Node 18+** for `fetch`. + +**Usage:** + +```text +node test-createnum.js [with-fee|no-fee|both] +``` + +| Mode | Effect | +|------------|--------| +| `no-fee` | (default) `feeRateSatPerVB: null` — estimated fee. | +| `with-fee` | `feeRateSatPerVB: 2` — same idea as `--fee-rate 2`. | +| `both` | Runs **no-fee** first, then **with-fee** — **two separate transactions**; use on regtest/signet or when you intend to broadcast twice. | + +Set `SPACED_RPC_URL` and cookie or credentials the same way as for `space-cli`. + +## More examples in the tree + +- **`client/src/rpc_schema.rs`** — under method `walletsendrequest`, look for the **“Create a num”** example; under **`walletcreatenum`**, a full JSON-RPC example with binding script. +- With the `schema` feature, **`cargo run -p spaces_client --bin rpc-docs`** prints markdown or `rpc-docs --json` for the full spec. + +## See also + +- Rust types: `client/src/rpc.rs` — `RpcWalletTxBuilder`, `RpcWalletRequest` (`createnum` / `CreateNumParams` with `bind_spk`), `FeeRate`. +- [ESTIMATE_FEE.md](ESTIMATE_FEE.md) — HTTP, ports, and Basic authentication for JSON-RPC. diff --git a/Cargo.lock b/Cargo.lock index 61d8860..52adea7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -358,9 +358,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.1" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -469,9 +469,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -503,9 +503,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -525,9 +525,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -744,9 +744,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "filetime" @@ -983,9 +983,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "hashlink" @@ -1146,9 +1146,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", @@ -1159,6 +1159,7 @@ dependencies = [ "httparse", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -1182,18 +1183,19 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http 1.4.0", - "hyper 1.9.0", + "hyper 1.8.1", "hyper-util", - "rustls 0.23.38", + "rustls 0.23.37", + "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", "tower-service", - "webpki-roots 1.0.7", + "webpki-roots 1.0.6", ] [[package]] @@ -1208,7 +1210,7 @@ dependencies = [ "futures-util", "http 1.4.0", "http-body 1.0.1", - "hyper 1.9.0", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", @@ -1221,13 +1223,12 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", - "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1235,9 +1236,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -1248,9 +1249,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1262,15 +1263,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1282,15 +1283,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", @@ -1330,12 +1331,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -1357,9 +1358,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.12" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" dependencies = [ "memchr", "serde", @@ -1413,12 +1414,10 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ - "cfg-if", - "futures-util", "once_cell", "wasm-bindgen", ] @@ -1550,20 +1549,20 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.185" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.7.4", + "redox_syscall 0.7.3", ] [[package]] @@ -1591,9 +1590,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "lock_api" @@ -1658,9 +1657,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", @@ -1675,9 +1674,9 @@ checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-traits" @@ -1817,11 +1816,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "plain" @@ -1837,18 +1842,18 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.7" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" dependencies = [ "portable-atomic", ] [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] @@ -1959,8 +1964,8 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.2", - "rustls 0.23.38", + "rustc-hash 2.1.1", + "rustls 0.23.37", "socket2 0.6.3", "thiserror 2.0.18", "tokio", @@ -1977,10 +1982,10 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.4", + "rand 0.9.2", "ring", - "rustc-hash 2.1.2", - "rustls 0.23.38", + "rustc-hash 2.1.1", + "rustls 0.23.37", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -2037,9 +2042,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -2094,9 +2099,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.4" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ "bitflags", ] @@ -2155,15 +2160,15 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", - "hyper-rustls 0.27.9", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.38", + "rustls 0.23.37", "rustls-pki-types", "serde", "serde_json", @@ -2178,7 +2183,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.7", + "webpki-roots 1.0.6", ] [[package]] @@ -2223,9 +2228,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustix" @@ -2267,15 +2272,15 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.38" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.12", + "rustls-webpki 0.103.10", "subtle", "zeroize", ] @@ -2323,9 +2328,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", @@ -2439,9 +2444,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.28" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" @@ -2562,9 +2567,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "sip7" @@ -2937,9 +2942,9 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -2962,9 +2967,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", @@ -2979,9 +2984,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", @@ -3004,7 +3009,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.38", + "rustls 0.23.37", "tokio", ] @@ -3036,18 +3041,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap", "toml_datetime", @@ -3057,9 +3062,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ "winnow", ] @@ -3217,11 +3222,11 @@ dependencies = [ "flate2", "log", "percent-encoding", - "rustls 0.23.38", + "rustls 0.23.37", "rustls-pki-types", "ureq-proto", "utf8-zero", - "webpki-roots 1.0.7", + "webpki-roots 1.0.6", ] [[package]] @@ -3328,9 +3333,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -3341,19 +3346,23 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ + "cfg-if", + "futures-util", "js-sys", + "once_cell", "wasm-bindgen", + "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3361,9 +3370,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -3374,9 +3383,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] @@ -3417,9 +3426,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", @@ -3443,9 +3452,9 @@ checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] @@ -3701,9 +3710,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" dependencies = [ "memchr", ] @@ -3798,9 +3807,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "xattr" @@ -3814,9 +3823,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3825,9 +3834,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", @@ -3837,18 +3846,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", @@ -3857,18 +3866,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", @@ -3884,9 +3893,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", @@ -3895,9 +3904,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -3906,9 +3915,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", diff --git a/ESTIMATE_FEE.md b/ESTIMATE_FEE.md new file mode 100644 index 0000000..1a75ecc --- /dev/null +++ b/ESTIMATE_FEE.md @@ -0,0 +1,158 @@ +# `estimatefee` — JSON-RPC from Node.js + +`estimatefee` is a **spaced** JSON-RPC method. It does not talk to Bitcoin Core directly; the node forwards the request to `estimatesmartfee` and returns a fee rate in **sat/vB** plus an estimated **blocks** value. + +## Method + +| Field | Value | +|--------|--------| +| **JSON-RPC method** | `estimatefee` | +| **Parameters** | Positional array (see below) | +| **Result** | `{ "feerate_sat_vb": number, "blocks": number }` | + +**Parameters (array, in order):** + +1. **`conf_target`** (number) — desired confirmation target in blocks (1–1008, same range as Bitcoin Core `estimatesmartfee`). +2. **`estimate_mode`** (string, optional) — second element of the **same** `params` array. If you need Core’s default behavior, pass `"unset"`. Other values: `"conservative"`, `"economical"` (Bitcoin Core 0.16+). + +Omitting the second value is not covered here; the CLI always sends a mode (default `"unset"`). To mirror the CLI’s default target of **6** blocks: `[6, "unset"]`. + +**Example** (equivalent to `space-cli estimatefee 1 -m conservative`): + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "estimatefee", + "params": [1, "conservative"] +} +``` + +**Example success `result`:** + +```json +{ + "feerate_sat_vb": 12, + "blocks": 2 +} +``` + +On failure, the response is a normal JSON-RPC error object (`error.code`, `error.message`). + +## Transport and URL + +- **Protocol:** JSON-RPC 2.0 over **HTTP POST**. +- **Path:** The node serves JSON-RPC on the **root** of the listen URL (e.g. `http://127.0.0.1:7225/`), with `Content-Type: application/json`. +- **Default listen ports** (if you did not override `--rpc-bind` / `SPACED_RPC_PORT`): + +| Network | Port | +|----------|------| +| Mainnet | 7225 | +| Testnet4 | 7224 | +| Testnet | 7223 | +| Signet | 7221 | +| Regtest | 7218 | + +## Authentication + +The RPC HTTP server requires a **`Authorization: Basic ...`** header. The value after `Basic` is a **base64 string** (not double-encoded) built in one of two ways, matching the spaced node and `space-cli`. + +### Option A — RPC user and password (when spaced was started with them) + +`Authorization` = ``Basic `` + `Buffer.from(\`${user}:${password}\`, "utf8").toString("base64")` (Node.js). + +### Option B — Cookie file (default when no RPC user is configured) + +1. The node writes a one-line file `__cookie__:<64 random chars>` under its data directory for the chain, e.g. on Linux: `~/.local/share/spaced//.cookie` (see your platform’s “data directory” for the `spaced` project). +2. Read that file as UTF-8, trim. +3. `Authorization` = ``Basic `` + `Buffer.from(trimmed, "utf8").toString("base64")`. + +## Node.js example (Node 18+ `fetch`) + +```javascript +import { readFile } from "node:fs/promises"; + +const RPC_URL = process.env.SPACED_RPC_URL ?? "http://127.0.0.1:7225"; +const COOKIE_PATH = process.env.SPACED_COOKIE; // e.g. path to .cookie +const SPACED_RPC_USER = process.env.SPACED_RPC_USER; +const SPACED_RPC_PASSWORD = process.env.SPACED_RPC_PASSWORD; + +/** + * @param {string} [cookiePath] - Path to spaced .cookie (if not using user/password) + */ +async function basicAuthHeader(cookiePath) { + if (SPACED_RPC_USER != null && SPACED_RPC_PASSWORD != null) { + const t = Buffer.from( + `${SPACED_RPC_USER}:${SPACED_RPC_PASSWORD}`, + "utf8" + ).toString("base64"); + return `Basic ${t}`; + } + if (!cookiePath) { + throw new Error( + "Set SPACED_RPC_USER/SPACED_RPC_PASSWORD or SPACED_COOKIE path (spaced .cookie file)" + ); + } + const raw = (await readFile(cookiePath, "utf8")).trim(); + return `Basic ${Buffer.from(raw, "utf8").toString("base64")}`; +} + +/** + * @param {number} confTarget - confirmation target in blocks + * @param {string} [mode='unset'] - 'unset' | 'conservative' | 'economical' (as in space-cli) + */ +export async function estimateFee( + confTarget, + mode = "unset", + { cookiePath = COOKIE_PATH } = {} +) { + const res = await fetch(RPC_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: await basicAuthHeader(cookiePath), + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "estimatefee", + params: [confTarget, mode], + }), + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status} ${res.statusText}`); + } + + const j = await res.json(); + if (j.error) { + const msg = + j.error.data != null + ? `${j.error.message}: ${JSON.stringify(j.error.data)}` + : j.error.message; + throw new Error(msg); + } + + return j.result; // { feerate_sat_vb, blocks } +} + +// Example: same as: space-cli estimatefee 1 -m conservative +const r = await estimateFee(1, "conservative", { + cookiePath: "/path/to/.cookie", +}); +console.log(r); +``` + +**Environment alignment with `space-cli`:** + +- `SPACED_RPC_URL` (or construct default URL from your chain’s port, as in the table above). +- Either `SPACED_RPC_USER` + `SPACED_RPC_PASSWORD`, or the path to the same cookie file the CLI would use (often in the spaced data directory for that network). + +## Discovery + +The node also exposes **`rpc.discover`**, which returns a list of method names (for tooling). That does not replace this document for parameter shapes, but is useful to confirm the method name `estimatefee` on your build. + +## See also + +- Implementation: `client/src/rpc.rs` — method `estimatefee`, struct `FeeEstimateResponse`. +- CLI: `space-cli estimatefee` — same RPC call under the hood. diff --git a/OPERATOR.md b/OPERATOR.md new file mode 100644 index 0000000..a5903b9 --- /dev/null +++ b/OPERATOR.md @@ -0,0 +1,49 @@ + operate Initialize a space or numeric for operation of off-chain subspaces + commit Commit a new root + rollback Rollback the last pending commitment + delegate Delegate operation of a space or numeric to someone else + getdelegator Get the current space a num id is responsible for + getdelegation Get the current num id responsible for a space or numeric + getcommitment Get a commitment for a space or numeric + +spaces operate --help +Initialize a space or numeric for operation of off-chain subspaces + +Usage: space-cli operate [OPTIONS] + +spaces commit --help +Commit a new root + +Usage: space-cli commit [OPTIONS] + +Arguments: + Space name, numeric, or num id + The new state root + + +spaces delegate --help +Delegate operation of a space or numeric to someone else + +Usage: space-cli delegate [OPTIONS] --to + +Arguments: + Space name, numeric, or num id + + --to Recipient space name or address (must be a space address) + +spaces getdelegator --help +Get the current space a num id is responsible for + +Usage: space-cli getdelegator [OPTIONS] + +Arguments: + A num id (e.g., num1...) or numeric (e.g., #800000-3) + +getdelegation --help +Get the current num id responsible for a space or numeric + +Usage: space-cli getdelegation [OPTIONS] + +Arguments: + Space name, numeric, or num id + diff --git a/README.md b/README.md index 5981d2b..4ee4520 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,11 @@ Checkout [releases](https://github.com/spacesprotocol/spaces/releases) for an immediately usable binary version of this software. +## Work on Subspaces + +Spaces is live on mainnet. Subspaces is live on testnet4, and development work is happening on the [subspaces branch](https://github.com/spacesprotocol/spaces/tree/subspaces). + + ## What does it do? Spaces are sovereign Bitcoin identities. They leverage the existing infrastructure and security of Bitcoin without requiring a new blockchain or any modifications to Bitcoin itself [learn more](https://spacesprotocol.org). diff --git a/TX_CALLBACK_API.md b/TX_CALLBACK_API.md new file mode 100644 index 0000000..cab06a6 --- /dev/null +++ b/TX_CALLBACK_API.md @@ -0,0 +1,440 @@ +# Transaction Callback API Documentation + +## Overview + +The Transaction Callback API provides a mechanism for clients to register callback endpoints that will be notified when specific Bitcoin transactions are included in blocks. This enables real-time monitoring of transaction confirmations without requiring continuous polling. + +## Architecture + +The callback system consists of: +- **Callback Registry**: Manages registered clients and their watched transaction IDs +- **Block Processing Integration**: Automatically checks each new block for watched transactions +- **HTTP Notifications**: Sends POST requests to registered callback URLs when transactions are found + +## RPC Endpoints + +### 1. `registertxcallback` + +Register a new client with a callback URL. + +**Parameters:** +- `client_id` (string): Unique identifier for this client +- `callback_url` (string): HTTP/HTTPS URL endpoint to receive notifications + +**Returns:** +- Success: `null` (no error) +- Error: Error object with code and message + +**Example:** +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "registertxcallback", + "params": { + "client_id": "my-service-001", + "callback_url": "https://example.com/api/tx-notifications" + } +} +``` + +**Response:** +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": null +} +``` + +### 2. `unregistertxcallback` + +Unregister a client and remove all its watched transactions. + +**Parameters:** +- `client_id` (string): The client ID to unregister + +**Returns:** +- `true` if client was found and unregistered +- `false` if client was not found + +**Example:** +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "unregistertxcallback", + "params": { + "client_id": "my-service-001" + } +} +``` + +**Response:** +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": true +} +``` + +### 3. `updatetxwatches` + +Update the list of transaction IDs that a client is watching for. + +**Parameters:** +- `client_id` (string): The client ID +- `txids` (array of strings): Array of transaction IDs (hex-encoded) to watch for + +**Returns:** +- `true` if client was found and updated +- `false` if client was not found + +**Example:** +```json +{ + "jsonrpc": "2.0", + "id": 3, + "method": "updatetxwatches", + "params": { + "client_id": "my-service-001", + "txids": [ + "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "b2086ec66e527e4db2aa0g66c7195f3226c0456f27d6dg413gd91f0e6gcg6e59e" + ] + } +} +``` + +**Response:** +```json +{ + "jsonrpc": "2.0", + "id": 3, + "result": true +} +``` + +### 4. `gettxcallback` + +Get information about a registered callback client. + +**Parameters:** +- `client_id` (string): The client ID to query + +**Returns:** +- `null` if client not found +- Client object with: + - `client_id` (string) + - `callback_url` (string) + - `watched_txids` (array of strings) + - `registered_at` (number): Unix timestamp + +**Example:** +```json +{ + "jsonrpc": "2.0", + "id": 4, + "method": "gettxcallback", + "params": { + "client_id": "my-service-001" + } +} +``` + +**Response:** +```json +{ + "jsonrpc": "2.0", + "id": 4, + "result": { + "client_id": "my-service-001", + "callback_url": "https://example.com/api/tx-notifications", + "watched_txids": [ + "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d" + ], + "registered_at": 1704067200 + } +} +``` + +### 5. `listtxcallbacks` + +List all registered callback clients. + +**Parameters:** None + +**Returns:** +- Array of client objects (same structure as `gettxcallback`) + +**Example:** +```json +{ + "jsonrpc": "2.0", + "id": 5, + "method": "listtxcallbacks", + "params": {} +} +``` + +**Response:** +```json +{ + "jsonrpc": "2.0", + "id": 5, + "result": [ + { + "client_id": "my-service-001", + "callback_url": "https://example.com/api/tx-notifications", + "watched_txids": ["a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"], + "registered_at": 1704067200 + }, + { + "client_id": "another-service", + "callback_url": "https://another.example.com/webhook", + "watched_txids": [], + "registered_at": 1704067300 + } + ] +} +``` + +## Callback Notification Format + +When a watched transaction is found in a block, a POST request is sent to the registered callback URL with the following JSON payload: + +```json +{ + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "block_height": 850000, + "block_hash": "0000000000000000000123456789abcdef0123456789abcdef0123456789abcdef", + "confirmations": 1, + "chain_tip_height": 850000, + "notified_at": 1704067500 +} +``` + +**Fields:** +- `txid`: The transaction ID that was found (hex string) +- `block_height`: Block height where the transaction was included +- `block_hash`: Block hash (hex string) +- `confirmations`: Number of confirmations (1 for the block it was included in) +- `chain_tip_height`: Current chain tip height at notification time +- `notified_at`: Unix timestamp when the notification was sent + +## Complete Usage Example + +This example demonstrates the full lifecycle of watching for a transaction: + +### Step 1: Register a Client + +```bash +curl -X POST http://localhost:8332 \ + -H "Content-Type: application/json" \ + -H "Authorization: Basic " \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "registertxcallback", + "params": { + "client_id": "payment-processor", + "callback_url": "https://myapp.com/api/payment-confirmed" + } + }' +``` + +### Step 2: Set Up Watched Transactions + +```bash +curl -X POST http://localhost:8332 \ + -H "Content-Type: application/json" \ + -H "Authorization: Basic " \ + -d '{ + "jsonrpc": "2.0", + "id": 2, + "method": "updatetxwatches", + "params": { + "client_id": "payment-processor", + "txids": [ + "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d" + ] + } + }' +``` + +### Step 3: Update Watched Transactions (Adding More) + +```bash +curl -X POST http://localhost:8332 \ + -H "Content-Type: application/json" \ + -H "Authorization: Basic " \ + -d '{ + "jsonrpc": "2.0", + "id": 3, + "method": "updatetxwatches", + "params": { + "client_id": "payment-processor", + "txids": [ + "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "b2086ec66e527e4db2aa0g66c7195f3226c0456f27d6dg413gd91f0e6gcg6e59e", + "c3097fd77f638f5ec3bb1h77d82a0g4337d1568g38eh524he02g1f7hdhd7f6af" + ] + } + }' +``` + +### Step 4: Check Client Status + +```bash +curl -X POST http://localhost:8332 \ + -H "Content-Type: application/json" \ + -H "Authorization: Basic " \ + -d '{ + "jsonrpc": "2.0", + "id": 4, + "method": "gettxcallback", + "params": { + "client_id": "payment-processor" + } + }' +``` + +### Step 5: Receive Notification + +When one of the watched transactions is included in a block, your callback endpoint will receive: + +```http +POST https://myapp.com/api/payment-confirmed HTTP/1.1 +Content-Type: application/json + +{ + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "block_height": 850000, + "block_hash": "0000000000000000000123456789abcdef0123456789abcdef0123456789abcdef", + "confirmations": 1, + "chain_tip_height": 850000, + "notified_at": 1704067500 +} +``` + +### Step 6: Update Watched Transactions (Remove Completed) + +After processing a notification, you may want to remove that transaction from the watch list: + +```bash +curl -X POST http://localhost:8332 \ + -H "Content-Type: application/json" \ + -H "Authorization: Basic " \ + -d '{ + "jsonrpc": "2.0", + "id": 5, + "method": "updatetxwatches", + "params": { + "client_id": "payment-processor", + "txids": [ + "b2086ec66e527e4db2aa0g66c7195f3226c0456f27d6dg413gd91f0e6gcg6e59e", + "c3097fd77f638f5ec3bb1h77d82a0g4337d1568g38eh524he02g1f7hdhd7f6af" + ] + } + }' +``` + +### Step 7: Unregister When Done + +```bash +curl -X POST http://localhost:8332 \ + -H "Content-Type: application/json" \ + -H "Authorization: Basic " \ + -d '{ + "jsonrpc": "2.0", + "id": 6, + "method": "unregistertxcallback", + "params": { + "client_id": "payment-processor" + } + }' +``` + +## Implementation Notes + +### Callback Endpoint Requirements + +Your callback endpoint should: +1. Accept POST requests with JSON content +2. Return a 2xx HTTP status code on success +3. Respond quickly (notifications are sent asynchronously, but slow responses may cause issues) +4. Be idempotent (same notification may be sent multiple times in case of retries) + +### Error Handling + +- If a callback URL returns a non-2xx status code, an error is logged but the system continues +- Network errors are logged but don't affect block processing +- Failed callbacks do not prevent other callbacks from being sent + +### Performance Considerations + +- Notifications are sent asynchronously and don't block block processing +- Multiple transactions in the same block trigger separate notifications +- The same transaction ID can be watched by multiple clients +- Callback registry operations are thread-safe and use async locks + +### Security Considerations + +- Use HTTPS for callback URLs in production +- Implement authentication/authorization on your callback endpoints +- Validate the notification payload on your callback endpoint +- Consider rate limiting on your callback endpoint + +## Example Callback Endpoint Implementation + +Here's a simple example of a callback endpoint handler: + +```python +from flask import Flask, request, jsonify +import logging + +app = Flask(__name__) +logging.basicConfig(level=logging.INFO) + +@app.route('/api/payment-confirmed', methods=['POST']) +def payment_confirmed(): + notification = request.json + + txid = notification['txid'] + block_height = notification['block_height'] + confirmations = notification['confirmations'] + + logging.info(f"Transaction {txid} confirmed in block {block_height} with {confirmations} confirmations") + + # Process the payment confirmation + # ... your business logic here ... + + return jsonify({'status': 'received'}), 200 +``` + +## Troubleshooting + +### Notifications Not Received + +1. Verify the client is registered: `gettxcallback` +2. Check that transaction IDs are correctly set: `gettxcallback` shows `watched_txids` +3. Verify the callback URL is accessible from the spaced server +4. Check server logs for callback errors + +### Transaction Already Confirmed + +If a transaction was already confirmed before you registered the callback, you won't receive a notification. You should: +1. Check the transaction status using `gettxmeta` +2. Register the callback before the transaction is broadcast +3. Monitor the mempool separately if needed + +### Multiple Notifications + +You may receive multiple notifications for the same transaction if: +- The callback endpoint was slow to respond +- Network issues caused retries +- The transaction appears in multiple blocks (shouldn't happen, but handle gracefully) + +Always make your callback handler idempotent. + diff --git a/client/src/app.rs b/client/src/app.rs index 38689cb..c562c7f 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -1,4 +1,5 @@ use crate::config::Args; +use crate::callbacks::CallbackRegistry; use crate::rpc::{AsyncChainState, RpcServerImpl, WalletLoadRequest, WalletManager}; use crate::source::{BitcoinBlockSource, BitcoinRpc}; use crate::spaces::Spaced; @@ -46,7 +47,7 @@ impl App { }); } - async fn setup_rpc_services(&mut self, spaced: &Spaced) { + async fn setup_rpc_services(&mut self, spaced: &Spaced, callback_registry: CallbackRegistry) { let (wallet_loader_tx, wallet_loader_rx) = mpsc::channel(1); let wallet_manager = WalletManager { @@ -71,7 +72,7 @@ impl App { .await .map_err(|e| anyhow!("Chain state error: {}", e)) }); - let rpc_server = RpcServerImpl::new(async_chain_state.clone(), wallet_manager); + let rpc_server = RpcServerImpl::new_with_callbacks(async_chain_state.clone(), wallet_manager, callback_registry); let bind = spaced.bind.clone(); let auth_token = spaced.auth_token.clone(); @@ -88,15 +89,21 @@ impl App { .await; } - async fn setup_sync_service(&mut self, mut spaced: Spaced) { + async fn setup_sync_service(&mut self, mut spaced: Spaced, callback_registry: CallbackRegistry) { let (spaced_sender, spaced_receiver) = tokio::sync::oneshot::channel(); let shutdown = self.shutdown.clone(); let rpc = spaced.rpc.clone(); + let tokio_runtime = tokio::runtime::Handle::current(); std::thread::spawn(move || { let source = BitcoinBlockSource::new(rpc); - _ = spaced_sender.send(spaced.protocol_sync(source, shutdown)); + _ = spaced_sender.send(spaced.protocol_sync( + source, + shutdown, + callback_registry, + tokio_runtime, + )); }); self.services.spawn(async move { @@ -108,8 +115,10 @@ impl App { pub async fn run(&mut self, args: Vec) -> anyhow::Result<()> { let spaced = Args::configure(args).await?; - self.setup_rpc_services(&spaced).await; - self.setup_sync_service(spaced).await; + let callback_registry = + CallbackRegistry::load_or_new(spaced.data_dir.join("txcallbacks.json")).await?; + self.setup_rpc_services(&spaced, callback_registry.clone()).await; + self.setup_sync_service(spaced, callback_registry).await; while let Some(res) = self.services.join_next().await { res?? diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index 4899b67..81aeb27 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -1,39 +1,41 @@ extern crate core; +use std::{ + fs, io, + io::{BufRead, IsTerminal, Write}, + path::PathBuf, +}; +use std::str::FromStr; use anyhow::anyhow; use clap::{Parser, Subcommand}; use colored::{Color, Colorize}; use jsonrpsee::{ - core::{ClientError, client::Error}, + core::{client::Error, ClientError}, http_client::HttpClient, }; -use spaces_client::rpc::{ - CommitParams, CreateNumParams, DelegateParams, OperateParams, SetFallbackParams, -}; -use spaces_client::store::Sha256; use spaces_client::{ auth::{auth_token_from_cookie, auth_token_from_creds, http_client_with_auth}, - config::{ExtendedNetwork, default_cookie_path, default_spaces_rpc_port}, + config::{default_cookie_path, default_spaces_rpc_port, ExtendedNetwork}, format::{ - Format, print_error_rpc_response, print_list_bidouts, print_list_nums_response, + print_error_rpc_response, print_list_bidouts, print_list_nums_response, print_list_spaces_response, print_list_transactions, print_list_unspent, - print_list_wallets, print_server_info, print_wallet_balance_response, print_wallet_info, - print_wallet_response, + print_list_wallets, print_server_info, print_wallet_balance_response, + print_wallet_info, print_wallet_response, Format, }, rpc::{ - BidParams, OpenParams, RegisterParams, RpcClient, RpcWalletRequest, RpcWalletTxBuilder, - SendCoinsParams, Subject, TransferSpacesParams, + BidParams, OpenParams, RegisterParams, RpcClient, RpcWalletRequest, + RpcWalletTxBuilder, SendCoinsParams, Subject, TransferSpacesParams, }, - wallets::{AddressKind, WalletResponse}, + wallets::{AddressKind, ListNumsResponse, WalletResponse}, }; -use spaces_nums::num_id::NumId; +use spaces_client::rpc::{CommitParams, CreateNumParams, DelegateParams, OperateParams, SetFallbackParams}; +use spaces_client::store::Sha256; use spaces_protocol::bitcoin::{Amount, FeeRate, OutPoint, Txid}; use spaces_protocol::slabel::SLabel; -use spaces_wallet::bitcoin::ScriptBuf; +use spaces_nums::num_id::NumId; +use spaces_wallet::{bitcoin::secp256k1::schnorr::Signature, export::WalletExport, nostr::NostrEvent, Listing}; use spaces_wallet::bitcoin::hashes::sha256; -use spaces_wallet::{Listing, bitcoin::secp256k1::schnorr::Signature, export::WalletExport}; -use std::str::FromStr; -use std::{fs, io, io::Write, path::PathBuf}; +use spaces_wallet::bitcoin::ScriptBuf; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] @@ -145,6 +147,13 @@ enum Commands { /// Generate a random p2tr keypair and print the secret key, script pubkey, and num id #[command(name = "generatekey")] GenerateKey, + /// Get all defined spaces + #[command(name = "getallspaces")] + GetAllSpaces { + /// Only return spaces that have reached expiration + #[arg(long)] + expired: bool, + }, /// Create a new num #[command(name = "createnum")] CreateNum { @@ -260,6 +269,16 @@ enum Commands { #[arg(default_value = "0")] target: usize, }, + /// Estimate fee rate for a given confirmation target + #[command(name = "estimatefee")] + EstimateFee { + /// Target number of blocks for confirmation (1-1008) + #[arg(default_value = "6")] + conf_target: u32, + /// Fee estimation mode: unset, conservative, or economical + #[arg(long, short)] + mode: Option, + }, /// Send the specified amount of BTC to the given name or address #[command( name = "send", @@ -365,6 +384,7 @@ enum Commands { /// space-cli setfallback @alice --txt btc=bc1q... --txt nostr=npub1... /// space-cli setfallback @alice --raw SGVsbG8= /// echo '[{"type":"txt","key":"btc","value":["bc1q..."]}]' | space-cli setfallback @alice --stdin + /// space-cli setfallback @alice --txt btc=bc1q... --dry-run #[command(name = "setfallback")] SetFallback { /// Space name, numeric, or num id @@ -384,6 +404,10 @@ enum Commands { /// Fee rate to use in sat/vB #[arg(long, short)] fee_rate: Option, + /// Print hex of the OP_RETURN data payload (SIP-7 bytes) and exit (no RPC / no transaction). + /// SUBJECT is still required by the parser but ignored. + #[arg(long)] + dry_run: bool, }, /// Get on-chain fallback record data for a space or num. #[command(name = "getfallback")] @@ -391,6 +415,24 @@ enum Commands { /// Space name, numeric, or num id subject: Subject, }, + /// Sign a Nostr event using the space's private key + #[command(name = "signevent")] + SignEvent { + /// Space name (e.g., @example) + space: String, + /// Path to a Nostr event JSON file (omit for stdin) + #[arg(short, long)] + input: Option, + }, + /// Verify a signed Nostr event against the space's or numeric's public key + #[command(name = "verifyevent")] + VerifyEvent { + /// Space or numeric subject (e.g., @example) + space: String, + /// Path to a signed Nostr event JSON file (omit for stdin) + #[arg(short, long)] + input: Option, + }, /// List last transactions #[command(name = "listtransactions")] ListTransactions { @@ -404,10 +446,14 @@ enum Commands { #[command(name = "listspaces")] ListSpaces, /// List nums. Defaults to owned, use --kind external for nums created but not owned. + /// With --spk, lists matching nums from both owned and external (kind is ignored). #[command(name = "listnums")] ListNums { #[arg(long, default_value = "owned")] kind: String, + /// Hex-encoded script_pubkey; only nums whose output script matches (owned and external) + #[arg(long, value_name = "HEX")] + spk: Option, }, /// List unspent auction outputs i.e. outputs that can be /// auctioned off in the bidding process @@ -452,8 +498,11 @@ impl SpaceCli { args.rpc_url = Some(default_rpc_url(&args.chain)); } - let auth_token = if let Some(user) = args.rpc_user.as_ref() { - auth_token_from_creds(user, args.rpc_password.as_ref().unwrap()) + let auth_token = if args.rpc_user.is_some() { + auth_token_from_creds( + args.rpc_user.as_ref().unwrap(), + args.rpc_password.as_ref().unwrap(), + ) } else { let cookie_path = match &args.rpc_cookie { Some(path) => path, @@ -474,7 +523,7 @@ impl SpaceCli { Self { wallet: args.wallet.clone(), format: args.output_format, - dust: args.dust.map(Amount::from_sat), + dust: args.dust.map(|d| Amount::from_sat(d)), force: args.force, skip_tx_check: args.skip_tx_check, network: args.chain, @@ -533,7 +582,7 @@ async fn main() -> anyhow::Result<()> { match result { Ok(_) => {} - Err(error) => match error { + Err(error) => match ClientError::from(error) { Error::Call(rpc) => { print_error_rpc_response(rpc.code(), rpc.message().to_string(), cli.format); } @@ -590,11 +639,35 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client let response = cli.client.estimate_bid(target).await?; println!("{} sat", Amount::from_sat(response).to_sat()); } + Commands::EstimateFee { conf_target, mode } => { + let response = cli.client.estimate_fee(conf_target, mode).await?; + match cli.format { + Format::Text => { + println!("Fee rate: {} sat/vB", response.feerate_sat_vb); + println!("Blocks: {}", response.blocks); + } + Format::Json => { + println!("{}", serde_json::to_string_pretty(&response)?); + } + } + } Commands::GetSpace { space } => { let space = normalize_space(&space); let response = cli.client.get_space(&space).await?; println!("{}", serde_json::to_string_pretty(&response)?); } + Commands::GetAllSpaces { expired } => { + let mut spaces = cli.client.get_all_spaces().await?; + if expired { + let info = cli.client.get_server_info().await?; + let current_height = info.tip.height; + spaces.retain(|fso| { + fso.spaceout.space.as_ref() + .map_or(false, |s| s.is_expired(current_height)) + }); + } + println!("{}", serde_json::to_string_pretty(&spaces)?); + } Commands::GetSpaceOut { outpoint } => { let response = cli.client.get_spaceout(outpoint).await?; println!("{}", serde_json::to_string_pretty(&response)?); @@ -628,8 +701,9 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client Commands::ExportWallet { path } => { let result = cli.client.wallet_export(&cli.wallet).await?; let content = serde_json::to_string_pretty(&result).expect("result"); - fs::write(path, content) - .map_err(|e| ClientError::Custom(format!("Could not save to path: {}", e)))?; + fs::write(path, content).map_err(|e| { + ClientError::Custom(format!("Could not save to path: {}", e.to_string())) + })?; } Commands::GetWalletInfo => { let result = cli.client.wallet_get_info(&cli.wallet).await?; @@ -692,16 +766,12 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client .await? } Commands::Renew { spaces, fee_rate } => { - let spaces: Vec<_> = spaces - .into_iter() - .map(|s| { - let normalized = normalize_space(&s); - Subject::Label(SLabel::from_str(&normalized).expect("valid space")) - }) - .collect(); + let spaces: Vec<_> = spaces.into_iter().map(|s| { + let normalized = normalize_space(&s); + Subject::Label(SLabel::from_str(&normalized).expect("valid space")) + }).collect(); cli.send_request( - Some(RpcWalletRequest::Transfer(TransferSpacesParams { - secret: None, + Some(RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces, to: None, data: None, @@ -720,9 +790,8 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client } => { let secret = if secret_stdin { let mut input = String::new(); - io::stdin().read_line(&mut input).map_err(|e| { - ClientError::Custom(format!("failed to read secret from stdin: {}", e)) - })?; + io::stdin().read_line(&mut input) + .map_err(|e| ClientError::Custom(format!("failed to read secret from stdin: {}", e)))?; Some(input.trim().to_string()) } else { None @@ -763,21 +832,18 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client raw, stdin, fee_rate, + dry_run, } => { use base64::Engine; let data = if let Some(raw_b64) = raw { // Raw base64-encoded wire-format bytes - base64::engine::general_purpose::STANDARD - .decode(&raw_b64) - .map_err(|e| { - ClientError::Custom(format!("Could not base64 decode data: {}", e)) - })? + base64::engine::general_purpose::STANDARD.decode(&raw_b64) + .map_err(|e| ClientError::Custom(format!("Could not base64 decode data: {}", e)))? } else if stdin { // Read JSON records from stdin let mut input = String::new(); - io::stdin() - .read_line(&mut input) - .map_err(|e| ClientError::Custom(format!("Failed to read stdin: {}", e)))?; + io::stdin().read_line(&mut input).map_err(|e| + ClientError::Custom(format!("Failed to read stdin: {}", e)))?; let record_set: sip7::RecordSet = serde_json::from_str(input.trim()) .map_err(|e| ClientError::Custom(format!("Invalid SIP-7 JSON: {}", e)))?; record_set.to_bytes() @@ -785,29 +851,15 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client // Build from --txt and --blob flags let mut records = Vec::new(); for txt in &txt_records { - let (key, value) = txt.split_once('=').ok_or_else(|| { - ClientError::Custom(format!( - "Invalid --txt format '{}': expected key=value", - txt - )) - })?; + let (key, value) = txt.split_once('=').ok_or_else(|| + ClientError::Custom(format!("Invalid --txt format '{}': expected key=value", txt)))?; records.push(sip7::Record::txt(key, &[value])); } for blob in &blob_records { - let (key, b64_value) = blob.split_once('=').ok_or_else(|| { - ClientError::Custom(format!( - "Invalid --blob format '{}': expected key=base64", - blob - )) - })?; - let value = base64::engine::general_purpose::STANDARD - .decode(b64_value) - .map_err(|e| { - ClientError::Custom(format!( - "Invalid base64 in --blob '{}': {}", - key, e - )) - })?; + let (key, b64_value) = blob.split_once('=').ok_or_else(|| + ClientError::Custom(format!("Invalid --blob format '{}': expected key=base64", blob)))?; + let value = base64::engine::general_purpose::STANDARD.decode(b64_value) + .map_err(|e| ClientError::Custom(format!("Invalid base64 in --blob '{}': {}", key, e)))?; records.push(sip7::Record::blob(key, value)); } sip7::RecordSet::pack(records) @@ -815,25 +867,53 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client .to_bytes() } else { return Err(ClientError::Custom( - "No data specified. Use --txt, --blob, --raw, or --stdin".to_string(), + "No data specified. Use --txt, --blob, --raw, or --stdin".to_string() )); }; - cli.send_request( - Some(RpcWalletRequest::SetFallback(SetFallbackParams { - subject, - data, - })), - None, - fee_rate, - false, - ) - .await?; + if dry_run { + println!("{}", hex::encode(&data)); + } else { + cli.send_request( + Some(RpcWalletRequest::SetFallback(SetFallbackParams { subject, data })), + None, + fee_rate, + false, + ) + .await?; + } } - Commands::GetFallback { subject } => { + Commands::GetFallback { + subject, + } => { let response = cli.client.get_fallback(subject).await?; println!("{}", serde_json::to_string_pretty(&response)?); } + Commands::SignEvent { mut space, input } => { + let event = read_event(input) + .map_err(|e| ClientError::Custom(format!("input error: {}", e)))?; + space = normalize_space(&space); + let subject = Subject::from_str(&space) + .map_err(|e| ClientError::Custom(e.to_string()))?; + let result = cli + .client + .wallet_sign_event(&cli.wallet, subject, event) + .await?; + println!("{}", serde_json::to_string(&result).expect("result")); + } + Commands::VerifyEvent { mut space, input } => { + let event = read_event(input) + .map_err(|e| ClientError::Custom(format!("input error: {}", e)))?; + space = normalize_space(&space); + let subject = Subject::from_str(&space) + .map_err(|e| ClientError::Custom(e.to_string()))?; + let event = cli + .client + .verify_event(subject, event) + .await + .map_err(|e| ClientError::Custom(e.to_string()))?; + println!("{}", serde_json::to_string(&event).expect("result")); + } Commands::ListUnspent => { let utxos = cli.client.wallet_list_unspent(&cli.wallet).await?; print_list_unspent(utxos, cli.format); @@ -854,10 +934,32 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client let spaces = cli.client.wallet_list_spaces(&cli.wallet).await?; print_list_spaces_response(tip.tip.height, spaces, cli.format); } - Commands::ListNums { kind } => { - let kind = if kind == "owned" { None } else { Some(kind) }; - let nums = cli.client.wallet_list_nums(&cli.wallet, kind).await?; - print_list_nums_response(nums, cli.format); + Commands::ListNums { kind, spk } => { + if let Some(hex) = spk { + let spk_script = ScriptBuf::from( + hex::decode(hex.trim()).map_err(|_| { + ClientError::Custom("Invalid --spk hex (expected script_pubkey bytes)".to_string()) + })?, + ); + let owned = cli.client.wallet_list_nums(&cli.wallet, None).await?; + let external = cli + .client + .wallet_list_nums(&cli.wallet, Some("external".to_string())) + .await?; + let mut nums: Vec<_> = owned + .nums + .into_iter() + .chain(external.nums) + .filter(|e| e.numout.script_pubkey == spk_script) + .collect(); + nums.sort_by_key(|e| (e.txid, e.numout.n)); + nums.dedup_by_key(|e| (e.txid, e.numout.n)); + print_list_nums_response(ListNumsResponse { nums }, cli.format); + } else { + let kind = if kind == "owned" { None } else { Some(kind) }; + let nums = cli.client.wallet_list_nums(&cli.wallet, kind).await?; + print_list_nums_response(nums, cli.format); + } } Commands::Balance => { let balance = cli.client.wallet_get_balance(&cli.wallet).await?; @@ -962,10 +1064,10 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client println!("{} Listing verified", "✓".color(Color::Green)); } Commands::GenerateKey => { + use spaces_wallet::bitcoin::secp256k1::{Secp256k1, Keypair}; use spaces_wallet::bitcoin::key::TapTweak; - use spaces_wallet::bitcoin::opcodes::all::OP_PUSHNUM_1; use spaces_wallet::bitcoin::script::Builder; - use spaces_wallet::bitcoin::secp256k1::{Keypair, Secp256k1}; + use spaces_wallet::bitcoin::opcodes::all::OP_PUSHNUM_1; let secp = Secp256k1::new(); let (secret_key, _) = secp.generate_keypair(&mut rand::thread_rng()); @@ -988,10 +1090,8 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client Commands::CreateNum { bind_spk, fee_rate } => { let spk = match bind_spk { Some(hex) => { - let spk = ScriptBuf::from( - hex::decode(hex) - .map_err(|_| ClientError::Custom("Invalid spk hex".to_string()))?, - ); + let spk = ScriptBuf::from(hex::decode(hex) + .map_err(|_| ClientError::Custom("Invalid spk hex".to_string()))?); let num_id = NumId::from_spk::(spk.clone()); println!("Creating num id: {}", num_id); Some(spk) @@ -1009,7 +1109,7 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client fee_rate, false, ) - .await? + .await? } Commands::GetNum { subject } => { let num = cli @@ -1030,19 +1130,17 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client } Commands::Operate { subject, fee_rate } => { cli.send_request( - Some(RpcWalletRequest::Operate(OperateParams { subject })), + Some(RpcWalletRequest::Operate(OperateParams { + subject, + })), None, fee_rate, false, ) - .await?; + .await?; println!("Operate setup should be complete once tx is confirmed"); } - Commands::Commit { - subject, - root, - fee_rate, - } => { + Commands::Commit { subject, root, fee_rate } => { cli.send_request( Some(RpcWalletRequest::Commit(CommitParams { subject, @@ -1052,7 +1150,7 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client fee_rate, false, ) - .await?; + .await?; } Commands::Rollback { subject, fee_rate } => { cli.send_request( @@ -1064,21 +1162,20 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client fee_rate, false, ) - .await?; + .await?; println!("Rollback transaction sent"); } - Commands::Delegate { - subject, - to, - fee_rate, - } => { + Commands::Delegate { subject, to, fee_rate } => { cli.send_request( - Some(RpcWalletRequest::Delegate(DelegateParams { subject, to })), + Some(RpcWalletRequest::Delegate(DelegateParams { + subject, + to, + })), None, fee_rate, false, ) - .await?; + .await?; } Commands::GetDelegator { subject } => { let delegator = cli @@ -1112,3 +1209,22 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client fn default_rpc_url(chain: &ExtendedNetwork) -> String { format!("http://127.0.0.1:{}", default_spaces_rpc_port(chain)) } + +fn read_event(file: Option) -> anyhow::Result { + let content = get_input(file)?; + let event: NostrEvent = serde_json::from_str(&content)?; + Ok(event) +} + +fn get_input(input: Option) -> anyhow::Result { + Ok(match input { + Some(file) => fs::read_to_string(file)?, + None => { + let stdin = io::stdin(); + if stdin.is_terminal() { + return Err(anyhow!("no input provided: specify a file path or pipe via stdin")); + } + stdin.lock().lines().collect::>()? + } + }) +} diff --git a/client/src/callbacks.rs b/client/src/callbacks.rs new file mode 100644 index 0000000..ddf9e38 --- /dev/null +++ b/client/src/callbacks.rs @@ -0,0 +1,429 @@ +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use anyhow::Result; +use spaces_protocol::bitcoin::Txid; +use log::{error, info, warn}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use reqwest::Client as HttpClient; + +const CALLBACK_PERSISTENCE_VERSION: u32 = 1; + +/// On-disk format for [`CallbackRegistry`] (reverse index is rebuilt on load). +#[derive(Debug, Serialize, Deserialize)] +struct CallbackPersistence { + version: u32, + clients: Vec, +} + +/// Represents a registered callback client +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CallbackClient { + /// Unique identifier for this client + pub client_id: String, + /// URL endpoint to call when a watched transaction is found + pub callback_url: String, + /// Set of transaction IDs this client is watching for + pub watched_txids: HashSet, + /// Timestamp when this client was registered + pub registered_at: u64, +} + +/// Notification payload sent to callback URLs +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionNotification { + /// The transaction ID that was found + pub txid: Txid, + /// Block height where the transaction was included + pub block_height: u32, + /// Block hash where the transaction was included + pub block_hash: String, + /// Number of confirmations (1 for the block it was included in) + pub confirmations: u32, + /// Current chain tip height + pub chain_tip_height: u32, + /// Timestamp when the notification was sent + pub notified_at: u64, +} + +/// Registry for managing transaction callbacks +#[derive(Clone)] +pub struct CallbackRegistry { + /// Map from client_id to CallbackClient + clients: Arc>>, + /// Reverse map from txid to set of client_ids watching it + txid_to_clients: Arc>>>, + /// HTTP client for making callback requests + http_client: Arc, + /// When set, registry state is saved here after each mutation (atomic replace). + persist_path: Option, +} + +impl CallbackRegistry { + pub fn new() -> Self { + Self { + clients: Arc::new(RwLock::new(HashMap::new())), + txid_to_clients: Arc::new(RwLock::new(HashMap::new())), + http_client: Arc::new(HttpClient::new()), + persist_path: None, + } + } + + /// Load saved registrations from `path` if it exists, otherwise start empty. State is re-saved after each change. + pub async fn load_or_new(path: PathBuf) -> Result { + let registry = Self { + clients: Arc::new(RwLock::new(HashMap::new())), + txid_to_clients: Arc::new(RwLock::new(HashMap::new())), + http_client: Arc::new(HttpClient::new()), + persist_path: Some(path.clone()), + }; + + if path.exists() { + let bytes = tokio::fs::read(&path).await?; + let file: CallbackPersistence = serde_json::from_slice(&bytes)?; + if file.version != CALLBACK_PERSISTENCE_VERSION { + anyhow::bail!( + "unsupported tx callback persistence version {} (expected {})", + file.version, + CALLBACK_PERSISTENCE_VERSION + ); + } + let mut clients: HashMap = HashMap::new(); + for client in file.clients { + let cid = client.client_id.clone(); + clients.insert(cid, client); + } + let n = clients.len(); + let txid_map = Self::rebuild_txid_index(&clients); + *registry.clients.write().await = clients; + *registry.txid_to_clients.write().await = txid_map; + info!( + "txcallback: restored {} registered client(s) from {}", + n, + path.display() + ); + } + + Ok(registry) + } + + fn rebuild_txid_index( + clients: &HashMap, + ) -> HashMap> { + let mut txid_map: HashMap> = HashMap::new(); + for (cid, client) in clients { + for txid in &client.watched_txids { + txid_map + .entry(*txid) + .or_insert_with(HashSet::new) + .insert(cid.clone()); + } + } + txid_map + } + + async fn persist_to_disk(&self) { + let Some(path) = self.persist_path.as_ref() else { + return; + }; + + let clients_vec: Vec = { + let guard = self.clients.read().await; + guard.values().cloned().collect() + }; + + let payload = CallbackPersistence { + version: CALLBACK_PERSISTENCE_VERSION, + clients: clients_vec, + }; + + let data = match serde_json::to_vec_pretty(&payload) { + Ok(d) => d, + Err(e) => { + error!("txcallback: failed to serialize persistence: {}", e); + return; + } + }; + + if let Some(parent) = path.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + error!( + "txcallback: create_dir_all {}: {}", + parent.display(), + e + ); + return; + } + } + + let tmp_path = path.with_extension("tmp"); + if let Err(e) = tokio::fs::write(&tmp_path, &data).await { + error!( + "txcallback: write {}: {}", + tmp_path.display(), + e + ); + return; + } + + if let Err(e) = tokio::fs::rename(&tmp_path, path).await { + error!("txcallback: rename {} -> {}: {}", tmp_path.display(), path.display(), e); + let _ = tokio::fs::remove_file(&tmp_path).await; + } + } + + /// Register a new callback client + pub async fn register_client( + &self, + client_id: String, + callback_url: String, + ) -> Result<()> { + let mut clients = self.clients.write().await; + let mut txid_map = self.txid_to_clients.write().await; + + // If client already exists, remove old watched txids from reverse map + if let Some(old_client) = clients.get(&client_id) { + info!( + "txcallback: re-registering client '{}' (url: {} -> {}), clearing {} watched txid(s)", + client_id, + old_client.callback_url, + callback_url, + old_client.watched_txids.len() + ); + for txid in &old_client.watched_txids { + if let Some(client_set) = txid_map.get_mut(txid) { + client_set.remove(&client_id); + if client_set.is_empty() { + txid_map.remove(txid); + } + } + } + } else { + info!( + "txcallback: registering new client '{}' with callback url '{}'", + client_id, callback_url + ); + } + + let client = CallbackClient { + client_id: client_id.clone(), + callback_url, + watched_txids: HashSet::new(), + registered_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + }; + + clients.insert(client_id, client); + drop(clients); + drop(txid_map); + self.persist_to_disk().await; + Ok(()) + } + + /// Unregister a callback client + pub async fn unregister_client(&self, client_id: &str) -> Result { + let mut clients = self.clients.write().await; + let mut txid_map = self.txid_to_clients.write().await; + + if let Some(client) = clients.remove(client_id) { + info!( + "txcallback: unregistered client '{}' (url: '{}', had {} watched txid(s))", + client_id, + client.callback_url, + client.watched_txids.len() + ); + // Remove all watched txids from reverse map + for txid in &client.watched_txids { + if let Some(client_set) = txid_map.get_mut(txid) { + client_set.remove(client_id); + if client_set.is_empty() { + txid_map.remove(txid); + } + } + } + drop(clients); + drop(txid_map); + self.persist_to_disk().await; + Ok(true) + } else { + info!("txcallback: unregister requested for unknown client '{}'", client_id); + Ok(false) + } + } + + /// Update the list of watched transaction IDs for a client + pub async fn update_watched_txids( + &self, + client_id: &str, + txids: Vec, + ) -> Result { + let mut clients = self.clients.write().await; + let mut txid_map = self.txid_to_clients.write().await; + + if let Some(client) = clients.get_mut(client_id) { + let prev_count = client.watched_txids.len(); + + // Remove old txids from reverse map + for txid in &client.watched_txids { + if let Some(client_set) = txid_map.get_mut(txid) { + client_set.remove(client_id); + if client_set.is_empty() { + txid_map.remove(txid); + } + } + } + + // Update client's watched txids + let new_txids: HashSet = txids.into_iter().collect(); + let new_count = new_txids.len(); + client.watched_txids = new_txids.clone(); + + // Add new txids to reverse map + for txid in &new_txids { + txid_map + .entry(*txid) + .or_insert_with(HashSet::new) + .insert(client_id.to_string()); + } + + let tx_list = new_txids + .iter() + .map(|t| t.to_string()) + .collect::>() + .join(", "); + if tx_list.is_empty() { + info!( + "txcallback: updated watches for client '{}': {} -> {} txid(s) (none)", + client_id, prev_count, new_count + ); + } else { + info!( + "txcallback: updated watches for client '{}': {} -> {} txid(s): {}", + client_id, prev_count, new_count, tx_list + ); + } + drop(clients); + drop(txid_map); + self.persist_to_disk().await; + Ok(true) + } else { + info!( + "txcallback: updatetxwatches for unknown client '{}'", + client_id + ); + Ok(false) + } + } + + /// Get information about a registered client + pub async fn get_client(&self, client_id: &str) -> Option { + let clients = self.clients.read().await; + clients.get(client_id).cloned() + } + + /// List all registered clients + pub async fn list_clients(&self) -> Vec { + let clients = self.clients.read().await; + clients.values().cloned().collect() + } + + /// Check if any watched transactions are in a block and notify clients + pub async fn check_and_notify( + &self, + block_height: u32, + block_hash: &str, + block_txids: &[Txid], + chain_tip_height: u32, + ) { + let txid_map = self.txid_to_clients.read().await; + let clients = self.clients.read().await; + + if !txid_map.is_empty() { + info!( + "txcallback: scanning block {} ({} txs) against {} watched txid(s) across {} client(s)", + block_height, + block_txids.len(), + txid_map.len(), + clients.len() + ); + } + + let mut notifications = Vec::new(); + + // Find which clients need to be notified + for txid in block_txids { + if let Some(client_ids) = txid_map.get(txid) { + for client_id in client_ids { + if let Some(client) = clients.get(client_id) { + info!( + "txcallback: watched txid {} found in block {} — queuing notification for client '{}'", + txid, block_height, client_id + ); + let confirmations = chain_tip_height.saturating_sub(block_height) + 1; + notifications.push(( + client.clone(), + TransactionNotification { + txid: *txid, + block_height, + block_hash: block_hash.to_string(), + confirmations, + chain_tip_height, + notified_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + }, + )); + } + } + } + } + + // Send notifications asynchronously + for (client, notification) in notifications { + let http_client = self.http_client.clone(); + let callback_url = client.callback_url.clone(); + let client_id = client.client_id.clone(); + + tokio::spawn(async move { + match http_client + .post(&callback_url) + .json(¬ification) + .send() + .await + { + Ok(response) => { + if response.status().is_success() { + info!( + "Successfully notified client {} about txid {} at block {}", + client_id, notification.txid, block_height + ); + } else { + warn!( + "Callback to {} returned status {} for txid {}", + callback_url, + response.status(), + notification.txid + ); + } + } + Err(e) => { + error!( + "Failed to send callback to {} for txid {}: {}", + callback_url, notification.txid, e + ); + } + } + }); + } + } +} + +impl Default for CallbackRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/client/src/config.rs b/client/src/config.rs index ed18e8d..8ef6d43 100644 --- a/client/src/config.rs +++ b/client/src/config.rs @@ -15,7 +15,7 @@ use rand::{ {Rng, thread_rng}, }; use serde::Deserialize; -use spaces_protocol::bitcoin::Network; +use spaces_protocol::{bitcoin::Network, constants::ChainAnchor}; use crate::store::chain::{Chain, ROOT_ANCHORS_COUNT}; use crate::{ @@ -137,6 +137,16 @@ impl ExtendedNetwork { _ => Err(()), } } + + pub fn genesis(&self) -> ChainAnchor { + match self { + ExtendedNetwork::Testnet => ChainAnchor::TESTNET(), + ExtendedNetwork::Testnet4 => ChainAnchor::TESTNET4(), + ExtendedNetwork::Regtest => ChainAnchor::REGTEST(), + ExtendedNetwork::Mainnet => ChainAnchor::MAINNET(), + _ => panic!("unsupported network"), + } + } } impl Args { diff --git a/client/src/fallback_handle.rs b/client/src/fallback_handle.rs new file mode 100644 index 0000000..d2c552c --- /dev/null +++ b/client/src/fallback_handle.rs @@ -0,0 +1,546 @@ +//! Resolve SIP-7 fallback payloads by scanning indexed block metadata for TXT `handle=…` +//! or by wildcard pattern matching across spaces and handles. + +use std::collections::BTreeMap; +use spaces_nums::TxChangeSet as NumTxChangeSet; +use spaces_protocol::bitcoin::BlockHash; +use spaces_protocol::{validate::TxChangeSet as SpaceTxChangeSet, Covenant}; +use sip7::ParsedRecord; + +use crate::client::{BlockMeta, NumBlockMeta}; +use crate::store::chain::Chain; + +/// Simple glob match supporting `*` (zero or more chars) and `?` (exactly one char). +pub fn glob_match(pattern: &str, text: &str) -> bool { + let p: Vec = pattern.chars().collect(); + let t: Vec = text.chars().collect(); + glob_impl(&p, &t) +} + +fn glob_impl(p: &[char], t: &[char]) -> bool { + match (p.first(), t.first()) { + (None, None) => true, + (Some(&'*'), _) => { + glob_impl(&p[1..], t) || (!t.is_empty() && glob_impl(p, &t[1..])) + } + (Some(&'?'), Some(_)) => glob_impl(&p[1..], &t[1..]), + (Some(&a), Some(&b)) if a == b => glob_impl(&p[1..], &t[1..]), + _ => false, + } +} + +/// True if the SIP-7 payload parses and contains a TXT record with key `handle` whose value +/// equals `needle` (exact UTF-8; any chunk in a multi-value TXT matches). +pub fn sip7_handle_matches(data: &[u8], needle: &str) -> bool { + let rs = sip7::RecordSet::new(data.to_vec()); + let Ok(records) = rs.unpack() else { + return false; + }; + for r in records { + if let ParsedRecord::Txt { key, value } = r { + if key == "handle" && value.iter().any(|v| v == needle) { + return true; + } + } + } + false +} + +/// Extract all values of TXT records with the given `txt_key` from a SIP-7 payload. +fn sip7_txt_values(data: &[u8], txt_key: &str) -> Vec { + let rs = sip7::RecordSet::new(data.to_vec()); + let Ok(records) = rs.unpack() else { + return Vec::new(); + }; + let mut out = Vec::new(); + for r in records { + if let ParsedRecord::Txt { key, value } = r { + if key == txt_key { + out.extend(value.into_iter().map(|v| v.to_owned())); + } + } + } + out +} + +fn transfer_data_from_space_changeset(changeset: &SpaceTxChangeSet) -> Vec> { + let mut out = Vec::new(); + for c in &changeset.creates { + if let Some(space) = &c.space { + if let Covenant::Transfer { data: Some(b), .. } = &space.covenant { + out.push(b.clone().to_vec()); + } + } + } + for u in &changeset.updates { + if let Some(space) = &u.output.spaceout.space { + if let Covenant::Transfer { data: Some(b), .. } = &space.covenant { + out.push(b.clone().to_vec()); + } + } + } + out +} + +fn data_from_num_changeset(changeset: &NumTxChangeSet) -> Vec> { + changeset + .creates + .iter() + .filter_map(|n| n.num.data.as_ref().map(|b| b.clone().to_vec())) + .collect() +} + +fn space_events_for_block(meta: &BlockMeta) -> Vec<(u32, Vec)> { + let mut out = Vec::new(); + for tx in &meta.tx_meta { + let pos = tx.tx.as_ref().map(|t| t.position).unwrap_or(u32::MAX); + for pl in transfer_data_from_space_changeset(&tx.changeset) { + out.push((pos, pl)); + } + } + out +} + +fn num_events_for_block(meta: &NumBlockMeta) -> Vec<(u32, Vec)> { + let mut out = Vec::new(); + for tx in &meta.tx_meta { + let pos = tx.tx.as_ref().map(|t| t.position).unwrap_or(u32::MAX); + for pl in data_from_num_changeset(&tx.changeset) { + out.push((pos, pl)); + } + } + out +} + +/// Latest matching raw SIP-7 bytes in chain order (height, then block tx index when known). +pub(crate) fn merge_scan_fallback( + spaces_rows: &[(u32, BlockHash, BlockMeta)], + nums_rows: &[(u32, BlockHash, NumBlockMeta)], + needle: &str, +) -> Option> { + let mut i = 0usize; + let mut j = 0usize; + let mut best: Option<(u32, u32, Vec)> = None; + + loop { + let h_sp = spaces_rows.get(i).map(|r| r.0); + let h_nm = nums_rows.get(j).map(|r| r.0); + let h = match (h_sp, h_nm) { + (Some(a), Some(b)) => a.min(b), + (Some(a), None) => a, + (None, Some(b)) => b, + (None, None) => break, + }; + + let mut events = Vec::new(); + if h_sp == Some(h) { + events.extend(space_events_for_block(&spaces_rows[i].2)); + i += 1; + } + if h_nm == Some(h) { + events.extend(num_events_for_block(&nums_rows[j].2)); + j += 1; + } + + events.sort_by_key(|(pos, _)| *pos); + for (pos, pl) in events { + if sip7_handle_matches(&pl, needle) { + best = Some((h, pos, pl)); + } + } + } + + best.map(|(_, _, v)| v) +} + +/// True if `pattern` is a space-name pattern (starts with `@` or contains no `@`). +/// Patterns with content before `@` (e.g. `*@mad`) are handle patterns. +fn is_space_pattern(pattern: &str) -> bool { + match pattern.find('@') { + Some(0) => true, // @* or @m?d + Some(_) => false, // *@mad, dict*@* + None => true, // * or m?d (no @, treated as space) + } +} + +/// Scan all indexed payloads for SIP-7 TXT records with the given `txt_key`, returning +/// `BTreeMap` for values matching `pattern` (latest per value wins). +pub(crate) fn merge_scan_txt_wildcard( + spaces_rows: &[(u32, BlockHash, BlockMeta)], + nums_rows: &[(u32, BlockHash, NumBlockMeta)], + txt_key: &str, + pattern: &str, +) -> BTreeMap> { + let mut latest: BTreeMap)> = BTreeMap::new(); + + let mut i = 0usize; + let mut j = 0usize; + + loop { + let h_sp = spaces_rows.get(i).map(|r| r.0); + let h_nm = nums_rows.get(j).map(|r| r.0); + let h = match (h_sp, h_nm) { + (Some(a), Some(b)) => a.min(b), + (Some(a), None) => a, + (None, Some(b)) => b, + (None, None) => break, + }; + + let mut events: Vec<(u32, Vec)> = Vec::new(); + if h_sp == Some(h) { + events.extend(space_events_for_block(&spaces_rows[i].2)); + i += 1; + } + if h_nm == Some(h) { + events.extend(num_events_for_block(&nums_rows[j].2)); + j += 1; + } + + events.sort_by_key(|(pos, _)| *pos); + for (pos, pl) in events { + for val in sip7_txt_values(&pl, txt_key) { + if glob_match(pattern, &val) { + let entry = latest.entry(val).or_insert((0, 0, Vec::new())); + if (h, pos) >= (entry.0, entry.1) { + *entry = (h, pos, pl.clone()); + } + } + } + } + } + + latest + .into_iter() + .map(|(k, (_, _, payload))| (k, payload)) + .collect() +} + +/// Search for fallback payloads matching a wildcard pattern. +/// Space patterns (`@*`, `@m?d`) match TXT records with key `"space"`. +/// Handle patterns (`*@mad`, `*@*`) match TXT records with key `"handle"`. +pub fn search_fallback_by_pattern( + chain: &Chain, + pattern: &str, +) -> anyhow::Result>> { + if !chain.has_spaces_index() { + anyhow::bail!( + "wildcard lookup requires the spaces block index; run spaced with --block-index" + ); + } + + let spaces_rows = chain.list_spaces_blocks_merged()?; + let nums_rows = if chain.has_nums_index() { + chain.list_nums_blocks_merged()? + } else { + Vec::new() + }; + + let txt_key = if is_space_pattern(pattern) { "space" } else { "handle" }; + Ok(merge_scan_txt_wildcard(&spaces_rows, &nums_rows, txt_key, pattern)) +} + +/// Latest matching raw SIP-7 bytes in chain order (height, then block tx index when known). +pub fn find_fallback_payload_by_handle(chain: &Chain, needle: &str) -> anyhow::Result>> { + if !chain.has_spaces_index() { + anyhow::bail!( + "handle lookup requires the spaces block index; run spaced with --block-index" + ); + } + + let spaces_rows = chain.list_spaces_blocks_merged()?; + let nums_rows = if chain.has_nums_index() { + chain.list_nums_blocks_merged()? + } else { + Vec::new() + }; + + Ok(merge_scan_fallback(&spaces_rows, &nums_rows, needle)) +} + +#[cfg(test)] +mod tests { + use super::*; + use spaces_protocol::bitcoin::hashes::Hash as _; + use spaces_protocol::bitcoin::{Amount, BlockHash, ScriptBuf, Txid}; + use spaces_protocol::slabel::SLabel; + use spaces_protocol::validate::TxChangeSet; + use spaces_protocol::{Bytes, Space, SpaceOut}; + use std::str::FromStr as _; + + use crate::client::{BlockMeta, TxData, TxEntry}; + + fn sample_handle_payload(handle: &str) -> Vec { + sip7::RecordSet::pack(vec![ + sip7::Record::seq(1), + sip7::Record::txt("handle", &[handle]), + ]) + .expect("pack") + .to_bytes() + } + + fn sample_space_payload(space: &str) -> Vec { + sip7::RecordSet::pack(vec![ + sip7::Record::seq(1), + sip7::Record::txt("space", &[space]), + ]) + .expect("pack") + .to_bytes() + } + + fn tx_entry_with_payload(height: u32, tx_pos: u32, payload: Vec) -> TxEntry { + let space_out = SpaceOut { + n: 0, + space: Some(Space { + name: SLabel::from_str("@test").expect("label"), + covenant: Covenant::Transfer { + expire_height: height + 1000, + data: Some(Bytes::new(payload)), + }, + }), + value: Amount::ZERO, + script_pubkey: ScriptBuf::new(), + }; + let cs = TxChangeSet { + txid: Txid::all_zeros(), + spends: vec![], + creates: vec![space_out], + updates: vec![], + }; + TxEntry { + changeset: cs, + tx: Some(TxData { + position: tx_pos, + raw: Bytes::new(Vec::new()), + }), + } + } + + #[test] + fn sip7_handle_matches_respects_key_and_value() { + let pl = sample_handle_payload("dictionary@mad"); + assert!(sip7_handle_matches(&pl, "dictionary@mad")); + assert!(!sip7_handle_matches(&pl, "other@mad")); + } + + #[test] + fn merge_scan_picks_latest_height() { + let h = BlockHash::all_zeros(); + let want = sample_handle_payload("dictionary@mad"); + let older = BlockMeta { + height: 10, + tx_meta: vec![tx_entry_with_payload(10, 0, want.clone())], + }; + let newer = BlockMeta { + height: 20, + tx_meta: vec![tx_entry_with_payload(20, 0, want.clone())], + }; + let rows = vec![(10, h, older), (20, h, newer)]; + let got = merge_scan_fallback(&rows, &[], "dictionary@mad").expect("match"); + assert_eq!(got, want); + } + + #[test] + fn merge_scan_picks_later_tx_position_same_block() { + let h = BlockHash::all_zeros(); + let want = sample_handle_payload("dictionary@mad"); + let meta = BlockMeta { + height: 10, + tx_meta: vec![ + tx_entry_with_payload(10, 1, sample_handle_payload("wrong@x")), + tx_entry_with_payload(10, 3, want.clone()), + ], + }; + let rows = vec![(10, h, meta)]; + let got = merge_scan_fallback(&rows, &[], "dictionary@mad").expect("match"); + assert_eq!(got, want); + } + + // --- glob matcher tests --- + + #[test] + fn glob_match_exact() { + assert!(glob_match("@mad", "@mad")); + assert!(!glob_match("@mad", "@bad")); + } + + #[test] + fn glob_match_star() { + assert!(glob_match("*@mad", "dictionary@mad")); + assert!(glob_match("*@mad", "x@mad")); + assert!(glob_match("*@mad", "@mad")); + assert!(!glob_match("*@mad", "dictionary@bad")); + } + + #[test] + fn glob_match_star_at_star() { + assert!(glob_match("*@*", "dictionary@mad")); + assert!(glob_match("*@*", "a@b")); + assert!(!glob_match("*@*", "noatsign")); + } + + #[test] + fn glob_match_question() { + assert!(glob_match("d?ct@mad", "dict@mad")); + assert!(glob_match("d?ct@mad", "duct@mad")); + assert!(!glob_match("d?ct@mad", "dct@mad")); + assert!(!glob_match("d?ct@mad", "dabct@mad")); + } + + #[test] + fn glob_match_at_star_spaces() { + assert!(glob_match("@*", "@mad")); + assert!(glob_match("@*", "@bitcoin")); + assert!(!glob_match("@*", "mad")); + } + + // --- space-pattern wildcard scan tests (txt key="space") --- + + #[test] + fn space_wildcard_returns_matching_space_txt() { + let h = BlockHash::all_zeros(); + let mad_pl = sample_space_payload("@mad"); + let btc_pl = sample_space_payload("@bitcoin"); + let meta = BlockMeta { + height: 10, + tx_meta: vec![ + tx_entry_with_payload(10, 0, mad_pl.clone()), + tx_entry_with_payload(10, 1, btc_pl.clone()), + ], + }; + let rows = vec![(10, h, meta)]; + let result = merge_scan_txt_wildcard(&rows, &[], "space", "@*"); + assert_eq!(result.len(), 2); + assert_eq!(result["@mad"], mad_pl); + assert_eq!(result["@bitcoin"], btc_pl); + } + + #[test] + fn space_wildcard_filters_by_pattern() { + let h = BlockHash::all_zeros(); + let mad_pl = sample_space_payload("@mad"); + let btc_pl = sample_space_payload("@bitcoin"); + let meta = BlockMeta { + height: 10, + tx_meta: vec![ + tx_entry_with_payload(10, 0, mad_pl.clone()), + tx_entry_with_payload(10, 1, btc_pl), + ], + }; + let rows = vec![(10, h, meta)]; + let result = merge_scan_txt_wildcard(&rows, &[], "space", "@m*"); + assert_eq!(result.len(), 1); + assert!(result.contains_key("@mad")); + } + + #[test] + fn space_wildcard_latest_overwrites() { + let h = BlockHash::all_zeros(); + let old_pl = sample_space_payload("@mad"); + let new_pl = sample_space_payload("@mad"); + let block1 = BlockMeta { + height: 10, + tx_meta: vec![tx_entry_with_payload(10, 0, old_pl)], + }; + let block2 = BlockMeta { + height: 20, + tx_meta: vec![tx_entry_with_payload(20, 0, new_pl.clone())], + }; + let rows = vec![(10, h, block1), (20, h, block2)]; + let result = merge_scan_txt_wildcard(&rows, &[], "space", "@*"); + assert_eq!(result["@mad"], new_pl); + } + + #[test] + fn space_wildcard_ignores_handle_key() { + let h = BlockHash::all_zeros(); + let handle_pl = sample_handle_payload("@mad"); + let meta = BlockMeta { + height: 10, + tx_meta: vec![tx_entry_with_payload(10, 0, handle_pl)], + }; + let rows = vec![(10, h, meta)]; + let result = merge_scan_txt_wildcard(&rows, &[], "space", "@*"); + assert!(result.is_empty(), "handle-keyed records should not match a space pattern"); + } + + // --- handle-pattern wildcard scan tests (txt key="handle") --- + + #[test] + fn handle_wildcard_returns_matching_handles() { + let h = BlockHash::all_zeros(); + let meta = BlockMeta { + height: 10, + tx_meta: vec![ + tx_entry_with_payload(10, 0, sample_handle_payload("dictionary@mad")), + tx_entry_with_payload(10, 1, sample_handle_payload("other@mad")), + tx_entry_with_payload(10, 2, sample_handle_payload("foo@bar")), + ], + }; + let rows = vec![(10, h, meta)]; + let result = merge_scan_txt_wildcard(&rows, &[], "handle", "*@mad"); + assert_eq!(result.len(), 2); + assert!(result.contains_key("dictionary@mad")); + assert!(result.contains_key("other@mad")); + assert!(!result.contains_key("foo@bar")); + } + + #[test] + fn handle_wildcard_star_at_star_returns_all() { + let h = BlockHash::all_zeros(); + let meta = BlockMeta { + height: 10, + tx_meta: vec![ + tx_entry_with_payload(10, 0, sample_handle_payload("dictionary@mad")), + tx_entry_with_payload(10, 1, sample_handle_payload("foo@bar")), + ], + }; + let rows = vec![(10, h, meta)]; + let result = merge_scan_txt_wildcard(&rows, &[], "handle", "*@*"); + assert_eq!(result.len(), 2); + } + + #[test] + fn handle_wildcard_latest_per_handle() { + let h = BlockHash::all_zeros(); + let old_pl = sample_handle_payload("dictionary@mad"); + let new_pl = sample_handle_payload("dictionary@mad"); + let block1 = BlockMeta { + height: 10, + tx_meta: vec![tx_entry_with_payload(10, 0, old_pl)], + }; + let block2 = BlockMeta { + height: 20, + tx_meta: vec![tx_entry_with_payload(20, 0, new_pl.clone())], + }; + let rows = vec![(10, h, block1), (20, h, block2)]; + let result = merge_scan_txt_wildcard(&rows, &[], "handle", "*@*"); + assert_eq!(result.len(), 1); + assert_eq!(result["dictionary@mad"], new_pl); + } + + #[test] + fn handle_wildcard_ignores_space_key() { + let h = BlockHash::all_zeros(); + let space_pl = sample_space_payload("dictionary@mad"); + let meta = BlockMeta { + height: 10, + tx_meta: vec![tx_entry_with_payload(10, 0, space_pl)], + }; + let rows = vec![(10, h, meta)]; + let result = merge_scan_txt_wildcard(&rows, &[], "handle", "*@*"); + assert!(result.is_empty(), "space-keyed records should not match a handle pattern"); + } + + // --- is_space_pattern tests --- + + #[test] + fn pattern_classification() { + assert!(is_space_pattern("@*")); + assert!(is_space_pattern("@m?d")); + assert!(is_space_pattern("*")); + assert!(is_space_pattern("m?d")); + assert!(!is_space_pattern("*@mad")); + assert!(!is_space_pattern("*@*")); + assert!(!is_space_pattern("dict*@mad")); + } +} diff --git a/client/src/lib.rs b/client/src/lib.rs index 64fb07a..4dcd1f5 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -9,20 +9,22 @@ use std::time::{Duration, Instant}; use base64::Engine; use serde::{Deserialize, Deserializer, Serializer}; -pub mod app; pub mod auth; -mod cbf; +pub mod callbacks; mod checker; pub mod client; pub mod config; pub mod format; +mod fallback_handle; pub mod rpc; #[cfg(feature = "schema")] pub mod rpc_schema; pub mod source; -mod spaces; pub mod store; pub mod wallets; +mod cbf; +pub mod app; +mod spaces; fn std_wait(mut predicate: F, wait: Duration) where diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 33447a5..23606d9 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -1,78 +1,77 @@ -use crate::auth::BasicAuthLayer; -use crate::store::Sha256; -use crate::store::chain::{CACHED_SNAPSHOT_LOOKBACK, COMMIT_BLOCK_INTERVAL, Chain}; -use crate::store::spaces::RolloutEntry; -use crate::wallets::WalletInfoWithProgress; -use crate::{ - calc_progress, - checker::TxChecker, - client::{BlockMeta, BlockchainInfo, NumBlockMeta, TxEntry}, - config::ExtendedNetwork, - deserialize_base64, serialize_base64, - source::BitcoinRpc, - wallets::{ - AddressKind, ListNumsResponse, ListSpacesResponse, RpcWallet, TxInfo, TxResponse, - WalletCommand, WalletResponse, - }, +use std::{ + collections::BTreeMap, fs, fs::File, io::Write, net::SocketAddr, path::PathBuf, str::FromStr, + sync::Arc, }; -use anyhow::{Context, anyhow}; +use std::collections::HashSet; +use anyhow::{anyhow, Context}; use bdk::{ - KeychainKind, bitcoin::{Amount, BlockHash, FeeRate, Network, Txid}, chain::BlockId, keys::{ - DerivableKey, ExtendedKey, GeneratableKey, GeneratedKey, bip39::{Language, Mnemonic, WordCount}, + DerivableKey, ExtendedKey, GeneratableKey, GeneratedKey, }, miniscript::Tap, + KeychainKind, }; use jsonrpsee::{ core::async_trait, proc_macros::rpc, - server::{Server, middleware::http::ProxyGetRequestLayer}, + server::{middleware::http::ProxyGetRequestLayer, Server}, types::ErrorObjectOwned, }; -use log::info; +use log::{info, warn}; use serde::{Deserialize, Serialize}; use spacedb::tx::ProofType; -use spaces_nums::num_id::NumId; -use spaces_nums::snumeric::SNumeric; -use spaces_nums::{ - ChainProofRequest, Commitment, CommitmentKey, CommitmentTipKey, DelegatorKey, FullNumOut, - NumKeyKind, NumOut, NumOutpointKey, NumSource, RootAnchor, -}; -use spaces_protocol::bitcoin::ScriptBuf; -use spaces_protocol::hasher::Hash; use spaces_protocol::{ - Bytes, Covenant, FullSpaceOut, SpaceOut, bitcoin, + bitcoin, bitcoin::{ + bip32::Xpriv, Network::{Regtest, Testnet}, OutPoint, - bip32::Xpriv, }, constants::ChainAnchor, hasher::{KeyHasher, OutpointKey, SpaceKey}, prepare::SpacesSource, slabel::SLabel, validate::TxChangeSet, + Bytes, Covenant, FullSpaceOut, SpaceOut, }; -pub use spaces_wallet::Subject; -use spaces_wallet::bitcoin::hashes::sha256; use spaces_wallet::{ - Balance, DoubleUtxo, Listing, SpacesWallet, WalletConfig, WalletDescriptors, WalletOutput, bdk_wallet as bdk, bdk_wallet::template::Bip86, bitcoin::hashes::Hash as BitcoinHash, - bitcoin::secp256k1::schnorr, export::WalletExport, -}; -use std::collections::HashSet; -use std::{ - collections::BTreeMap, fs, fs::File, io::Write, net::SocketAddr, path::PathBuf, str::FromStr, - sync::Arc, + bitcoin::secp256k1::schnorr, + export::WalletExport, nostr::NostrEvent, Balance, DoubleUtxo, Listing, SpacesWallet, + WalletConfig, WalletDescriptors, WalletOutput, }; +pub use spaces_wallet::Subject; use tokio::{ select, - sync::{RwLock, broadcast, mpsc, oneshot}, + sync::{broadcast, mpsc, oneshot, RwLock}, task::JoinSet, }; +use spaces_protocol::bitcoin::ScriptBuf; +use spaces_protocol::hasher::Hash; +use spaces_nums::{NumSource, FullNumOut, NumOut, Commitment, CommitmentTipKey, CommitmentKey, DelegatorKey, NumOutpointKey, RootAnchor, ChainProofRequest, NumKeyKind}; +use spaces_nums::snumeric::SNumeric; +use spaces_nums::num_id::NumId; +use spaces_wallet::bitcoin::hashes::sha256; +use crate::auth::BasicAuthLayer; +use crate::wallets::WalletInfoWithProgress; +use crate::{ + calc_progress, + checker::TxChecker, + client::{BlockMeta, NumBlockMeta, TxEntry, BlockchainInfo}, + config::ExtendedNetwork, + deserialize_base64, serialize_base64, + source::BitcoinRpc, + wallets::{ + sip7_records_for_num_data, AddressKind, ListNumsResponse, ListSpacesResponse, NumEntry, + RpcWallet, TxInfo, TxResponse, WalletCommand, WalletResponse, + }, +}; +use crate::store::chain::{Chain, COMMIT_BLOCK_INTERVAL, CACHED_SNAPSHOT_LOOKBACK}; +use crate::store::Sha256; +use crate::store::spaces::RolloutEntry; pub(crate) type Responder = oneshot::Sender; @@ -88,6 +87,7 @@ pub struct ServerInfo { pub progress: f32, } + #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct ChainInfo { @@ -136,6 +136,9 @@ pub enum ChainStateCommand { hash: SpaceKey, resp: Responder>>, }, + GetAllSpaces { + resp: Responder>>, + }, GetSpaceout { outpoint: OutPoint, resp: Responder>>, @@ -169,10 +172,23 @@ pub enum ChainStateCommand { outpoint: OutPoint, resp: Responder>>, }, + /// Chain-wide live nums whose output `script_pubkey` matches (hex decoded by RPC layer). + ListNumsBySpk { + script_pubkey: ScriptBuf, + resp: Responder>, + }, GetTxMeta { txid: Txid, resp: Responder>>, }, + FindFallbackByHandle { + needle: String, + resp: Responder>>>, + }, + SearchFallbackByPattern { + pattern: String, + resp: Responder>>>, + }, GetBlockMeta { height_or_hash: HeightOrHash, resp: Responder>, @@ -199,6 +215,11 @@ pub enum ChainStateCommand { signature: Vec, resp: Responder>, }, + VerifyEvent { + subject: Subject, + event: NostrEvent, + resp: Responder>, + }, BuildChainProof { request: ChainProofRequest, prefer_recent: bool, @@ -219,6 +240,7 @@ pub struct AsyncChainState { sender: mpsc::Sender, } + #[rpc(server, client)] pub trait Rpc { #[method(name = "getserverinfo")] @@ -230,6 +252,9 @@ pub trait Rpc { space_or_hash: &str, ) -> Result, ErrorObjectOwned>; + #[method(name = "getallspaces")] + async fn get_all_spaces(&self) -> Result, ErrorObjectOwned>; + #[method(name = "getspaceowner")] async fn get_space_owner( &self, @@ -240,24 +265,32 @@ pub trait Rpc { async fn get_spaceout(&self, outpoint: OutPoint) -> Result, ErrorObjectOwned>; #[method(name = "getnum")] - async fn get_num(&self, subject: Subject) -> Result, ErrorObjectOwned>; + async fn get_num( + &self, + subject: Subject, + ) -> Result, ErrorObjectOwned>; #[method(name = "getnumowner")] - async fn get_num_owner(&self, subject: Subject) -> Result, ErrorObjectOwned>; + async fn get_num_owner( + &self, + subject: Subject, + ) -> Result, ErrorObjectOwned>; #[method(name = "getnumout")] async fn get_numout(&self, outpoint: OutPoint) -> Result, ErrorObjectOwned>; + /// List live num outputs on the indexed chain whose `script_pubkey` equals the given hex script. + /// Same JSON shape as `walletlistnums`; does not load a wallet or use `kind`. + #[method(name = "listnumsbyspk")] + async fn list_nums_by_spk(&self, spk_hex: String) -> Result; + #[method(name = "getcommitment")] - async fn get_commitment( - &self, - subject: Subject, - root: Option, - ) -> Result, ErrorObjectOwned>; + async fn get_commitment(&self, subject: Subject, root: Option) -> Result, ErrorObjectOwned>; #[method(name = "getdelegation")] async fn get_delegation(&self, subject: Subject) -> Result, ErrorObjectOwned>; + #[method(name = "getdelegator")] async fn get_delegator(&self, subject: Subject) -> Result, ErrorObjectOwned>; @@ -288,6 +321,21 @@ pub trait Rpc { #[method(name = "gettxmeta")] async fn get_tx_meta(&self, txid: Txid) -> Result, ErrorObjectOwned>; + #[method(name = "registertxcallback")] + async fn register_tx_callback(&self, client_id: String, callback_url: String) -> Result<(), ErrorObjectOwned>; + + #[method(name = "unregistertxcallback")] + async fn unregister_tx_callback(&self, client_id: String) -> Result; + + #[method(name = "updatetxwatches")] + async fn update_tx_watches(&self, client_id: String, txids: Vec) -> Result; + + #[method(name = "gettxcallback")] + async fn get_tx_callback(&self, client_id: String) -> Result, ErrorObjectOwned>; + + #[method(name = "listtxcallbacks")] + async fn list_tx_callbacks(&self) -> Result, ErrorObjectOwned>; + #[method(name = "listwallets")] async fn list_wallets(&self) -> Result, ErrorObjectOwned>; @@ -297,6 +345,7 @@ pub trait Rpc { #[method(name = "walletimport")] async fn wallet_import(&self, wallet: WalletExport) -> Result<(), ErrorObjectOwned>; + #[method(name = "walletcanoperate")] async fn wallet_can_operate( &self, @@ -312,6 +361,21 @@ pub trait Rpc { message: Bytes, ) -> Result; + #[method(name = "walletsignevent")] + async fn wallet_sign_event( + &self, + wallet: &str, + subject: Subject, + event: NostrEvent, + ) -> Result; + + #[method(name = "verifyevent")] + async fn verify_event( + &self, + subject: Subject, + event: NostrEvent, + ) -> Result; + #[method(name = "verifyschnorr")] async fn verify_schnorr( &self, @@ -322,7 +386,7 @@ pub trait Rpc { #[method(name = "walletgetinfo")] async fn wallet_get_info(&self, name: &str) - -> Result; + -> Result; #[method(name = "walletexport")] async fn wallet_export(&self, name: &str) -> Result; @@ -340,6 +404,15 @@ pub trait Rpc { request: RpcWalletTxBuilder, ) -> Result; + /// Create a num with a required binding script_pubkey (hex) and fee rate (sat/vB). Same result JSON as `walletsendrequest` for createnum. + #[method(name = "walletcreatenum")] + async fn wallet_create_num( + &self, + wallet: &str, + fee_rate_sat_vb: u64, + spk_hex: String, + ) -> Result; + #[method(name = "walletgetnewaddress")] async fn wallet_get_new_address( &self, @@ -438,15 +511,25 @@ pub trait Rpc { async fn get_fallback( &self, subject: Subject, - ) -> Result, ErrorObjectOwned>; + ) -> Result; + + #[method(name = "estimatefee")] + async fn estimate_fee( + &self, + conf_target: u32, + estimate_mode: Option, + ) -> Result; /// Debug method to set a space's expire height (regtest only) #[method(name = "debugsetexpireheight")] - async fn debug_set_expire_height( - &self, - space: &str, - expire_height: u32, - ) -> Result<(), ErrorObjectOwned>; + async fn debug_set_expire_height(&self, space: &str, expire_height: u32) -> Result<(), ErrorObjectOwned>; +} + +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct FeeEstimateResponse { + pub feerate_sat_vb: u64, + pub blocks: u64, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -601,8 +684,10 @@ pub struct RpcServerImpl { wallet_manager: WalletManager, store: AsyncChainState, client: reqwest::Client, + callback_registry: crate::callbacks::CallbackRegistry, } + /// Combined proof result for a chain proof request containing subtrees from both /// spaces and ptrs trees at the same snapshot height. #[derive(Clone, Serialize, Deserialize)] @@ -677,29 +762,19 @@ impl WalletManager { Ok(export) } - pub async fn create_wallet( - &self, - client: &reqwest::Client, - name: &str, - ) -> anyhow::Result { + pub async fn create_wallet(&self, client: &reqwest::Client, name: &str) -> anyhow::Result { let mnemonic: GeneratedKey<_, Tap> = Mnemonic::generate((WordCount::Words12, Language::English)) .map_err(|_| anyhow!("Mnemonic generation error"))?; let start_block = self.get_wallet_start_block(client).await?; - self.setup_new_wallet(name.to_string(), mnemonic.to_string(), start_block)?; + self.setup_new_wallet(name.to_string(), mnemonic.to_string(), Some(start_block.height))?; self.load_wallet(name).await?; Ok(mnemonic.to_string()) } - pub async fn recover_wallet( - &self, - client: &reqwest::Client, - name: &str, - mnemonic: &str, - ) -> anyhow::Result<()> { - let start_block = self.get_wallet_start_block(client).await?; - self.setup_new_wallet(name.to_string(), mnemonic.to_string(), start_block)?; + pub async fn recover_wallet(&self, name: &str, mnemonic: &str) -> anyhow::Result<()> { + self.setup_new_wallet(name.to_string(), mnemonic.to_string(), None)?; self.load_wallet(name).await?; Ok(()) } @@ -708,14 +783,14 @@ impl WalletManager { &self, name: String, mnemonic: String, - start_block: BlockId, + start_block_height: Option, ) -> anyhow::Result<()> { let wallet_path = self.data_dir.join(&name); if wallet_path.exists() { return Err(anyhow!(format!("Wallet `{}` already exists", name))); } - let export = self.wallet_from_mnemonic(name.clone(), mnemonic, start_block)?; + let export = self.wallet_from_mnemonic(name.clone(), mnemonic, start_block_height)?; fs::create_dir_all(&wallet_path)?; let wallet_export_path = wallet_path.join("wallet.json"); let mut file = fs::File::create(wallet_export_path)?; @@ -727,7 +802,7 @@ impl WalletManager { &self, name: String, mnemonic: String, - start_block: BlockId, + start_block_height: Option, ) -> anyhow::Result { let (network, _) = self.fallback_network(); let xpriv = Self::descriptor_from_mnemonic(network, &mnemonic)?; @@ -736,8 +811,14 @@ impl WalletManager { let tmp = bdk::Wallet::create(external, internal) .network(network) .create_wallet_no_persist()?; + + let start_block_height = match start_block_height { + Some(height) => height, + None => self.network.genesis().height, + }; + let export = - WalletExport::export_wallet(&tmp, &name, start_block.height).map_err(|e| anyhow!(e))?; + WalletExport::export_wallet(&tmp, &name, start_block_height).map_err(|e| anyhow!(e))?; Ok(export) } @@ -843,13 +924,13 @@ impl WalletManager { async fn get_wallet_start_block(&self, client: &reqwest::Client) -> anyhow::Result { let count: i32 = self .rpc - .send_json(client, &self.rpc.get_block_count()) + .send_json(&client, &self.rpc.get_block_count()) .await?; let height = std::cmp::max(count - 1, 0) as u32; let hash = self .rpc - .send_json(client, &self.rpc.get_block_hash(height)) + .send_json(&client, &self.rpc.get_block_hash(height)) .await?; Ok(BlockId { height, hash }) @@ -871,13 +952,26 @@ impl WalletManager { impl RpcServerImpl { pub fn new(store: AsyncChainState, wallet_manager: WalletManager) -> Self { + Self::new_with_callbacks(store, wallet_manager, crate::callbacks::CallbackRegistry::new()) + } + + pub fn new_with_callbacks( + store: AsyncChainState, + wallet_manager: WalletManager, + callback_registry: crate::callbacks::CallbackRegistry, + ) -> Self { RpcServerImpl { wallet_manager, store, client: reqwest::Client::new(), + callback_registry, } } + pub fn callback_registry(&self) -> &crate::callbacks::CallbackRegistry { + &self.callback_registry + } + async fn wallet(&self, wallet: &str) -> Result { let wallets = self.wallet_manager.wallets.read().await; wallets.get(wallet).cloned().ok_or_else(|| { @@ -920,19 +1014,16 @@ impl RpcServerImpl { let mut module = self.clone().into_rpc(); let methods: Vec = module.method_names().map(|s| s.to_string()).collect(); - module - .register_method( - "rpc.discover", - move |_, _| serde_json::json!({ "methods": methods }), - ) - .expect("register rpc.discover"); + module.register_method("rpc.discover", move |_, _| { + serde_json::json!({ "methods": methods }) + }).expect("register rpc.discover"); #[cfg(feature = "schema")] { let spec = crate::rpc_schema::full_spec(); - module - .register_method("rpc.schema", move |_, _| spec.clone()) - .expect("register rpc.schema"); + module.register_method("rpc.schema", move |_, _| { + spec.clone() + }).expect("register rpc.schema"); } let handle = listener.start(module); @@ -988,6 +1079,13 @@ impl RpcServer for RpcServerImpl { Ok(info) } + async fn get_all_spaces(&self) -> Result, ErrorObjectOwned> { + self.store + .get_all_spaces() + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + async fn get_space_owner( &self, space_or_hash: &str, @@ -1038,11 +1136,21 @@ impl RpcServer for RpcServerImpl { Ok(spaceout) } - async fn get_commitment( - &self, - subject: Subject, - root: Option, - ) -> Result, ErrorObjectOwned> { + async fn list_nums_by_spk(&self, spk_hex: String) -> Result { + let bytes = hex::decode(spk_hex.trim()).map_err(|e| { + ErrorObjectOwned::owned( + -1, + format!("invalid spk hex: {}", e), + None::, + ) + })?; + self.store + .list_nums_by_spk(bytes) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + + async fn get_commitment(&self, subject: Subject, root: Option) -> Result, ErrorObjectOwned> { let c = self .store .get_commitment(subject, root.map(|r| *r.as_ref())) @@ -1069,6 +1177,7 @@ impl RpcServer for RpcServerImpl { Ok(delegator) } + async fn check_package( &self, txs: Vec, @@ -1165,7 +1274,7 @@ impl RpcServer for RpcServerImpl { subject: Subject, message: Bytes, ) -> Result { - self.wallet(wallet) + self.wallet(&wallet) .await? .send_sign_schnorr(subject, message.to_vec()) .await @@ -1178,13 +1287,26 @@ impl RpcServer for RpcServerImpl { wallet: &str, subject: Subject, ) -> Result { - self.wallet(wallet) + self.wallet(&wallet) .await? .send_can_operate(subject) .await .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } + async fn wallet_sign_event( + &self, + wallet: &str, + subject: Subject, + event: NostrEvent, + ) -> Result { + self.wallet(&wallet) + .await? + .send_sign_event(subject, event) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + async fn verify_schnorr( &self, subject: Subject, @@ -1198,11 +1320,64 @@ impl RpcServer for RpcServerImpl { .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } + async fn verify_event( + &self, + subject: Subject, + event: NostrEvent, + ) -> Result { + self.store + .verify_event(subject, event) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + + async fn register_tx_callback( + &self, + client_id: String, + callback_url: String, + ) -> Result<(), ErrorObjectOwned> { + self.callback_registry + .register_client(client_id, callback_url) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + + async fn unregister_tx_callback(&self, client_id: String) -> Result { + self.callback_registry + .unregister_client(&client_id) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + + async fn update_tx_watches( + &self, + client_id: String, + txids: Vec, + ) -> Result { + self.callback_registry + .update_watched_txids(&client_id, txids) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + + async fn get_tx_callback( + &self, + client_id: String, + ) -> Result, ErrorObjectOwned> { + Ok(self.callback_registry.get_client(&client_id).await) + } + + async fn list_tx_callbacks( + &self, + ) -> Result, ErrorObjectOwned> { + Ok(self.callback_registry.list_clients().await) + } + async fn wallet_get_info( &self, wallet: &str, ) -> Result { - self.wallet(wallet) + self.wallet(&wallet) .await? .send_get_info() .await @@ -1228,7 +1403,7 @@ impl RpcServer for RpcServerImpl { async fn wallet_recover(&self, name: &str, mnemonic: String) -> Result<(), ErrorObjectOwned> { self.wallet_manager - .recover_wallet(&self.client, name, &mnemonic) + .recover_wallet(name, &mnemonic) .await .map_err(|error| { ErrorObjectOwned::owned(RPC_WALLET_NOT_LOADED, error.to_string(), None::) @@ -1241,7 +1416,7 @@ impl RpcServer for RpcServerImpl { request: RpcWalletTxBuilder, ) -> Result { let result = self - .wallet(wallet) + .wallet(&wallet) .await? .send_batch_tx(request) .await @@ -1249,12 +1424,54 @@ impl RpcServer for RpcServerImpl { Ok(result) } + async fn wallet_create_num( + &self, + wallet: &str, + fee_rate_sat_vb: u64, + spk_hex: String, + ) -> Result { + let fee_rate = FeeRate::from_sat_per_vb(fee_rate_sat_vb).ok_or_else(|| { + ErrorObjectOwned::owned( + -1, + "invalid fee_rate_sat_vb (fee rate out of range)".to_string(), + None::, + ) + })?; + let spk_bytes = hex::decode(spk_hex.trim()).map_err(|e| { + ErrorObjectOwned::owned( + -1, + format!("invalid spk hex: {}", e), + None::, + ) + })?; + if spk_bytes.is_empty() { + return Err(ErrorObjectOwned::owned( + -1, + "spk hex decodes to an empty script".to_string(), + None::, + )); + } + let bind_spk = ScriptBuf::from(spk_bytes); + let request = RpcWalletTxBuilder { + bidouts: None, + requests: vec![RpcWalletRequest::CreateNum(CreateNumParams { + bind_spk: Some(bind_spk), + })], + fee_rate: Some(fee_rate), + dust: None, + force: false, + confirmed_only: false, + skip_tx_check: false, + }; + self.wallet_send_request(wallet, request).await + } + async fn wallet_get_new_address( &self, wallet: &str, kind: AddressKind, ) -> Result { - self.wallet(wallet) + self.wallet(&wallet) .await? .send_get_new_address(kind) .await @@ -1266,7 +1483,7 @@ impl RpcServer for RpcServerImpl { wallet: &str, kind: AddressKind, ) -> Result { - self.wallet(wallet) + self.wallet(&wallet) .await? .send_increment_address(kind) .await @@ -1280,7 +1497,7 @@ impl RpcServer for RpcServerImpl { fee_rate: FeeRate, skip_tx_check: bool, ) -> Result, ErrorObjectOwned> { - self.wallet(wallet) + self.wallet(&wallet) .await? .send_fee_bump(txid, fee_rate, skip_tx_check) .await @@ -1294,7 +1511,7 @@ impl RpcServer for RpcServerImpl { fee_rate: Option, skip_tx_check: bool, ) -> Result { - self.wallet(wallet) + self.wallet(&wallet) .await? .send_buy(listing, fee_rate, skip_tx_check) .await @@ -1307,7 +1524,7 @@ impl RpcServer for RpcServerImpl { space: String, amount: u64, ) -> Result { - self.wallet(wallet) + self.wallet(&wallet) .await? .send_sell(space, amount) .await @@ -1345,7 +1562,7 @@ impl RpcServer for RpcServerImpl { count: usize, skip: usize, ) -> Result, ErrorObjectOwned> { - self.wallet(wallet) + self.wallet(&wallet) .await? .send_list_transactions(count, skip) .await @@ -1358,7 +1575,7 @@ impl RpcServer for RpcServerImpl { outpoint: OutPoint, fee_rate: FeeRate, ) -> Result { - self.wallet(wallet) + self.wallet(&wallet) .await? .send_force_spend(outpoint, fee_rate) .await @@ -1369,7 +1586,7 @@ impl RpcServer for RpcServerImpl { &self, wallet: &str, ) -> Result { - self.wallet(wallet) + self.wallet(&wallet) .await? .send_list_spaces() .await @@ -1382,7 +1599,7 @@ impl RpcServer for RpcServerImpl { kind: Option, ) -> Result { let external = kind.as_deref() == Some("external"); - self.wallet(wallet) + self.wallet(&wallet) .await? .send_list_nums(external) .await @@ -1393,7 +1610,7 @@ impl RpcServer for RpcServerImpl { &self, wallet: &str, ) -> Result, ErrorObjectOwned> { - self.wallet(wallet) + self.wallet(&wallet) .await? .send_list_unspent() .await @@ -1401,7 +1618,7 @@ impl RpcServer for RpcServerImpl { } async fn wallet_list_bidouts(&self, wallet: &str) -> Result, ErrorObjectOwned> { - self.wallet(wallet) + self.wallet(&wallet) .await? .send_list_bidouts() .await @@ -1409,80 +1626,149 @@ impl RpcServer for RpcServerImpl { } async fn wallet_get_balance(&self, wallet: &str) -> Result { - self.wallet(wallet) + self.wallet(&wallet) .await? .send_get_balance() .await .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } - async fn get_fallback( - &self, - subject: Subject, - ) -> Result, ErrorObjectOwned> { - let data = - match &subject { - Subject::Label(label) if !label.is_numeric() => { - let space_hash = SpaceKey::from(Sha256::hash(label.as_ref())); - let fso = - self.store.get_space(space_hash).await.map_err(|e| { - ErrorObjectOwned::owned(-1, e.to_string(), None::) - })?; - fso.and_then(|fso| { - if let Some(space) = &fso.spaceout.space - && let Covenant::Transfer { data, .. } = &space.covenant - { + async fn get_fallback(&self, subject: Subject) -> Result { + if let Subject::HandlePattern(ref pattern) = subject { + let results = self.store + .search_fallback_by_pattern(pattern) + .await + .map_err(|e| ErrorObjectOwned::owned(-1, e.to_string(), None::))?; + + let map: BTreeMap = results + .into_iter() + .map(|(name, raw)| { + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(&raw); + let rs = sip7::RecordSet::new(raw); + let records = if rs.unpack().is_ok() { Some(rs) } else { None }; + (name, FallbackResponse { data: encoded, records }) + }) + .collect(); + + return serde_json::to_value(&map) + .map_err(|e| ErrorObjectOwned::owned(-1, e.to_string(), None::)); + } + + let data = match &subject { + Subject::Handle(name) => { + let needle = name.to_string(); + self.store + .find_fallback_by_handle(&needle) + .await + .map_err(|e| ErrorObjectOwned::owned(-1, e.to_string(), None::))? + } + Subject::Label(label) if !label.is_numeric() => { + let space_hash = SpaceKey::from(Sha256::hash(label.as_ref())); + let fso = self.store.get_space(space_hash).await + .map_err(|e| ErrorObjectOwned::owned(-1, e.to_string(), None::))?; + fso.and_then(|fso| { + if let Some(space) = &fso.spaceout.space { + if let Covenant::Transfer { data, .. } = &space.covenant { return data.as_ref().map(|b| b.clone().to_vec()); } - None - }) - } - _ => { - let fpt = - self.store.get_ptr(subject).await.map_err(|e| { - ErrorObjectOwned::owned(-1, e.to_string(), None::) - })?; - fpt.and_then(|fpt| fpt.numout.num.data.map(|b| b.to_vec())) - } - }; + } + None + }) + } + _ => { + let fpt = self.store.get_ptr(subject.clone()).await + .map_err(|e| ErrorObjectOwned::owned(-1, e.to_string(), None::))?; + fpt.and_then(|fpt| fpt.numout.num.data.map(|b| b.to_vec())) + } + }; - match data { - None => Ok(None), + let response = match data { + None => None, Some(raw) => { use base64::Engine; let encoded = base64::engine::general_purpose::STANDARD.encode(&raw); let rs = sip7::RecordSet::new(raw); let records = if rs.unpack().is_ok() { Some(rs) } else { None }; - Ok(Some(FallbackResponse { + Some(FallbackResponse { data: encoded, records, - })) + }) } - } + }; + + serde_json::to_value(&response) + .map_err(|e| ErrorObjectOwned::owned(-1, e.to_string(), None::)) } - async fn debug_set_expire_height( + async fn estimate_fee( &self, - space: &str, - expire_height: u32, - ) -> Result<(), ErrorObjectOwned> { + conf_target: u32, + estimate_mode: Option, + ) -> Result { + let mode = estimate_mode.unwrap_or_else(|| "unset".to_string()); + info!("estimatefee: request conf_target={} mode={}", conf_target, mode); + let params = serde_json::json!([conf_target, mode]); + let rpc = self.wallet_manager.rpc.clone(); + + let estimate_req = rpc.make_request("estimatesmartfee", params); + + let result = tokio::task::spawn_blocking(move || { + let blocking_client = reqwest::blocking::Client::new(); + rpc.send_json_blocking::(&blocking_client, &estimate_req) + }) + .await + .map_err(|e| ErrorObjectOwned::owned(-1, format!("Task join error: {}", e), None::))?; + + match result { + Ok(res) => { + if let Some(fee_rate) = res["feerate"].as_f64() { + let fee_rate_sat_vb = (fee_rate * 100_000.0).ceil() as u64; + let blocks = res["blocks"].as_u64().unwrap_or(conf_target as u64); + info!( + "estimatefee: ok feerate_sat_vb={} blocks={}", + fee_rate_sat_vb, blocks + ); + Ok(FeeEstimateResponse { + feerate_sat_vb: fee_rate_sat_vb, + blocks, + }) + } else { + warn!( + "estimatefee: no feerate in bitcoind response (conf_target={} mode={})", + conf_target, mode + ); + Err(ErrorObjectOwned::owned( + -1, + "Fee estimation unavailable: no feerate in response".to_string(), + None::, + )) + } + } + Err(e) => { + warn!( + "estimatefee: estimatesmartfee failed conf_target={} mode={} error={}", + conf_target, mode, e + ); + Err(ErrorObjectOwned::owned( + -1, + format!("RPC error: {}", e), + None::, + )) + } + } + } + + async fn debug_set_expire_height(&self, space: &str, expire_height: u32) -> Result<(), ErrorObjectOwned> { // Only allow on regtest - let info = self - .store - .get_server_info() - .await + let info = self.store.get_server_info().await .map_err(|e| ErrorObjectOwned::owned(-1, e.to_string(), None::))?; if info.network != ExtendedNetwork::Regtest { - return Err(ErrorObjectOwned::owned( - -1, - "debug_set_expire_height is only available on regtest", - None::, - )); + return Err(ErrorObjectOwned::owned(-1, "debug_set_expire_height is only available on regtest", None::)); } - let space_label = SLabel::from_str(space).map_err(|e| { - ErrorObjectOwned::owned(-1, format!("Invalid space name: {}", e), None::) - })?; + let space_label = SLabel::from_str(space) + .map_err(|e| ErrorObjectOwned::owned(-1, format!("Invalid space name: {}", e), None::))?; self.store .debug_set_expire_height(space_label, expire_height) @@ -1503,7 +1789,7 @@ impl AsyncChainState { rpc: &BitcoinRpc, ) -> Result, anyhow::Error> { let info: serde_json::Value = rpc - .send_json(client, &rpc.get_raw_transaction(txid, true)) + .send_json(client, &rpc.get_raw_transaction(&txid, true)) .await .map_err(|e| anyhow!("Could not retrieve tx ({})", e))?; @@ -1511,8 +1797,13 @@ impl AsyncChainState { BlockHash::from_str(info.get("blockhash").and_then(|t| t.as_str()).ok_or_else( || anyhow!("Could not retrieve block hash for tx (is it in the mempool?)"), )?)?; - let block = - Self::get_indexed_block(state, HeightOrHash::Hash(block_hash), client, rpc).await?; + let block = Self::get_indexed_block( + state, + HeightOrHash::Hash(block_hash), + client, + rpc, + ) + .await?; Ok(block .block_meta @@ -1648,6 +1939,10 @@ impl AsyncChainState { let result = state.get_space_info(&hash); let _ = resp.send(result); } + ChainStateCommand::GetAllSpaces { resp } => { + let result = state.get_all_spaces(); + let _ = resp.send(result); + } ChainStateCommand::GetSpaceout { outpoint, resp } => { let result = state .get_spaceout(&outpoint) @@ -1661,22 +1956,16 @@ impl AsyncChainState { let _ = resp.send(result); } ChainStateCommand::GetNum { subject, resp } => { - let result = resolve_num_id(state, &subject).and_then(|id| state.get_num_info(&id)); + let result = resolve_num_id(state, &subject) + .and_then(|id| state.get_num_info(&id)); let _ = resp.send(result); } ChainStateCommand::GetNumOutpoint { subject, resp } => { - let result = resolve_num_id(state, &subject).and_then(|id| { - state - .get_num_outpoint_by_id(&id) - .context("could not fetch numout") - }); + let result = resolve_num_id(state, &subject) + .and_then(|id| state.get_num_outpoint_by_id(&id).context("could not fetch numout")); let _ = resp.send(result); } - ChainStateCommand::GetCommitment { - subject, - root, - resp, - } => { + ChainStateCommand::GetCommitment { subject, root, resp } => { let result = get_commitment(state, &subject, root); let _ = resp.send(result); } @@ -1685,11 +1974,9 @@ impl AsyncChainState { let _ = resp.send(result); } ChainStateCommand::GetDelegator { subject, resp } => { - let result = resolve_num_id(state, &subject).and_then(|id| { - state - .get_delegator(&DelegatorKey::from_id::(id)) - .map_err(|e| anyhow!("could not get delegator: {}", e)) - }); + let result = resolve_num_id(state, &subject) + .and_then(|id| state.get_delegator(&DelegatorKey::from_id::(id)) + .map_err(|e| anyhow!("could not get delegator: {}", e))); let _ = resp.send(result); } ChainStateCommand::GetNumOut { outpoint, resp } => { @@ -1698,24 +1985,59 @@ impl AsyncChainState { .context("could not fetch numouts"); let _ = resp.send(result); } + ChainStateCommand::ListNumsBySpk { + script_pubkey, + resp, + } => { + let result = (|| { + let rows = + state.list_live_nums_with_script_pubkey(script_pubkey.as_bytes())?; + let nums = rows + .into_iter() + .map(|(txid, numout, delegating_for)| { + let records = sip7_records_for_num_data(&numout.num.data); + NumEntry { + txid, + numout, + delegating_for, + records, + } + }) + .collect(); + Ok(ListNumsResponse { nums }) + })(); + let _ = resp.send(result); + } ChainStateCommand::GetBlockMeta { height_or_hash, resp, } => { - let res = Self::get_indexed_block(state, height_or_hash, client, rpc).await; + let res = + Self::get_indexed_block(state, height_or_hash, client, rpc) + .await; let _ = resp.send(res); } ChainStateCommand::GetNumBlockMeta { height_or_hash, resp, } => { - let res = Self::get_indexed_ptr_block(state, height_or_hash, client, rpc).await; + let res = + Self::get_indexed_ptr_block(state, height_or_hash, client, rpc) + .await; let _ = resp.send(res); } ChainStateCommand::GetTxMeta { txid, resp } => { let res = Self::get_indexed_tx(state, &txid, client, rpc).await; let _ = resp.send(res); } + ChainStateCommand::FindFallbackByHandle { needle, resp } => { + let res = state.find_fallback_payload_by_handle(&needle); + let _ = resp.send(res); + } + ChainStateCommand::SearchFallbackByPattern { pattern, resp } => { + let res = state.search_fallback_by_pattern(&pattern); + let _ = resp.send(res); + } ChainStateCommand::EstimateBid { target, resp } => { let estimate = state.estimate_bid(target); _ = resp.send(estimate); @@ -1725,14 +2047,11 @@ impl AsyncChainState { _ = resp.send(rollouts); } ChainStateCommand::VerifyListing { listing, resp } => { - _ = resp.send(SpacesWallet::verify_listing::(state, &listing).map(|_| ())); + _ = resp.send( + SpacesWallet::verify_listing::(state, &listing).map(|_| ()), + ); } - ChainStateCommand::VerifySchnorr { - subject, - message, - signature, - resp, - } => { + ChainStateCommand::VerifySchnorr { subject, message, signature, resp } => { let result = (|| { let sig = schnorr::Signature::from_slice(&signature) .map_err(|_| anyhow!("Invalid signature format"))?; @@ -1740,6 +2059,9 @@ impl AsyncChainState { })(); _ = resp.send(result); } + ChainStateCommand::VerifyEvent { subject, event, resp } => { + _ = resp.send(SpacesWallet::verify_event::(state, subject, event)); + } ChainStateCommand::BuildChainProof { request, prefer_recent, @@ -1754,16 +2076,8 @@ impl AsyncChainState { ChainStateCommand::GetRootAnchors { resp } => { _ = resp.send(Self::handle_get_anchor(anchors_path, state)); } - ChainStateCommand::DebugSetExpireHeight { - space, - expire_height, - resp, - } => { - _ = resp.send(Self::handle_debug_set_expire_height( - state, - space, - expire_height, - )); + ChainStateCommand::DebugSetExpireHeight { space, expire_height, resp } => { + _ = resp.send(Self::handle_debug_set_expire_height(state, space, expire_height)); } } } @@ -1774,26 +2088,18 @@ impl AsyncChainState { expire_height: u32, ) -> anyhow::Result<()> { let space_key = SpaceKey::from(Sha256::hash(space.as_ref())); - let outpoint = state - .get_space_outpoint(&space_key)? + let outpoint = state.get_space_outpoint(&space_key)? .ok_or_else(|| anyhow::anyhow!("Space not found: {}", space))?; - let mut spaceout = state - .get_spaceout(&outpoint)? + let mut spaceout = state.get_spaceout(&outpoint)? .ok_or_else(|| anyhow::anyhow!("Spaceout not found for outpoint"))?; // Update expire_height in the covenant if let Some(ref mut space_data) = spaceout.space { match &mut space_data.covenant { - Covenant::Transfer { - expire_height: eh, .. - } => { + Covenant::Transfer { expire_height: eh, .. } => { *eh = expire_height; } - _ => { - return Err(anyhow::anyhow!( - "Space is not in Transfer covenant (not owned)" - )); - } + _ => return Err(anyhow::anyhow!("Space is not in Transfer covenant (not owned)")), } } else { return Err(anyhow::anyhow!("SpaceOut has no space data")); @@ -1812,9 +2118,9 @@ impl AsyncChainState { if let Some(anchors_path) = anchors_path { let anchors: Vec = serde_json::from_reader( File::open(anchors_path) - .map_err(|e| anyhow!("Could not open anchors file: {}", e))?, + .or_else(|e| Err(anyhow!("Could not open anchors file: {}", e)))?, ) - .map_err(|e| anyhow!("Could not read anchors file: {}", e))?; + .or_else(|e| Err(anyhow!("Could not read anchors file: {}", e)))?; return Ok(anchors); } @@ -1824,10 +2130,7 @@ impl AsyncChainState { // Try to compute PTR root if we're past PTR genesis let ptrs_root = if state.can_scan_nums(meta.height) { - state - .nums_mut() - .state - .inner() + state.nums_mut().state.inner() .ok() .and_then(|s| s.compute_root().ok()) } else { @@ -1875,12 +2178,12 @@ impl AsyncChainState { let outpoint_key = OutpointKey::from_outpoint::(fso.outpoint()); space_tree_keys.insert(outpoint_key.into()); - if let Some(space) = &fso.spaceout.space - && let Covenant::Transfer { expire_height, .. } = &space.covenant - { - let last_update = - expire_height.saturating_sub(spaces_protocol::constants::RENEWAL_INTERVAL); - most_recent_update = std::cmp::max(most_recent_update, last_update); + if let Some(space) = &fso.spaceout.space { + if let Covenant::Transfer { expire_height, .. } = &space.covenant { + let last_update = expire_height + .saturating_sub(spaces_protocol::constants::RENEWAL_INTERVAL); + most_recent_update = std::cmp::max(most_recent_update, last_update); + } } let id = NumId::from_spk::(fso.spaceout.script_pubkey); @@ -1892,45 +2195,44 @@ impl AsyncChainState { NumKeyKind::Num(numeric) => { let id = state.get_num_id(&numeric)?; if let Some(id) = id { - let fpt = state - .get_num_info(&id)? + let fpt = state.get_num_info(&id)? .expect("num id must exist if numeric exists"); - num_tree_keys - .insert(NumOutpointKey::from_outpoint::(fpt.outpoint()).into()); - most_recent_update = - std::cmp::max(most_recent_update, fpt.numout.num.last_update); + num_tree_keys.insert( + NumOutpointKey::from_outpoint::(fpt.outpoint()).into() + ); + most_recent_update = std::cmp::max(most_recent_update, fpt.numout.num.last_update); // insert delegate information let operator_id = NumId::from_spk::(fpt.numout.script_pubkey); let operator = state.get_num_info(&operator_id)?; if let Some(operator) = operator { num_tree_keys.insert( - NumOutpointKey::from_outpoint::(operator.outpoint()).into(), + NumOutpointKey::from_outpoint::(operator.outpoint()).into() ); - most_recent_update = - std::cmp::max(most_recent_update, operator.numout.num.last_update); + most_recent_update = std::cmp::max(most_recent_update, operator.numout.num.last_update); + } else { num_tree_keys.insert(operator_id.into()); } + } } NumKeyKind::Id(id) => { if let Some(fpt) = state.get_num_info(&id)? { - num_tree_keys - .insert(NumOutpointKey::from_outpoint::(fpt.outpoint()).into()); - most_recent_update = - std::cmp::max(most_recent_update, fpt.numout.num.last_update); + num_tree_keys.insert( + NumOutpointKey::from_outpoint::(fpt.outpoint()).into() + ); + most_recent_update = std::cmp::max(most_recent_update, fpt.numout.num.last_update); // insert delegate information let operator_id = NumId::from_spk::(fpt.numout.script_pubkey); let operator = state.get_num_info(&operator_id)?; if let Some(operator) = operator { num_tree_keys.insert( - NumOutpointKey::from_outpoint::(operator.outpoint()).into(), + NumOutpointKey::from_outpoint::(operator.outpoint()).into() ); - most_recent_update = - std::cmp::max(most_recent_update, operator.numout.num.last_update); + most_recent_update = std::cmp::max(most_recent_update, operator.numout.num.last_update); } else { num_tree_keys.insert(operator_id.into()); } @@ -1941,10 +2243,10 @@ impl AsyncChainState { } NumKeyKind::Commitment(k) => { num_tree_keys.insert(k.into()); - } + }, NumKeyKind::CommitmentTip(k) => { num_tree_keys.insert(k.into()); - } + }, } } @@ -1955,25 +2257,23 @@ impl AsyncChainState { let blocks_remaining = next_commit - tip.height; return Err(anyhow!( "Cannot prove: data updated at block {} is not yet committed. Try again in {} block(s)", - most_recent_update, - blocks_remaining + most_recent_update, blocks_remaining )); } - let num_tree_keys: Vec<_> = num_tree_keys.into_iter().collect(); - let space_tree_keys: Vec<_> = space_tree_keys.into_iter().collect(); + let num_tree_keys : Vec<_> = num_tree_keys.into_iter().collect(); + let space_tree_keys : Vec<_> = space_tree_keys.into_iter().collect(); let cached_height = Self::cached_snapshot_height(tip.height); - let use_cached = !prefer_recent && cached_height.is_some_and(|h| most_recent_update <= h); + let use_cached = !prefer_recent + && cached_height.is_some_and(|h| most_recent_update <= h); let (spaces_proof, spaces_root, block_anchor, ptrs_proof, ptrs_root) = if use_cached { let height = cached_height.unwrap(); let snapshot = state.snapshot_at(height)?; let spaces_anchor: ChainAnchor = snapshot.spaces.metadata().try_into()?; - let spaces_proof = snapshot - .spaces - .prove(&space_tree_keys, ProofType::Standard)?; + let spaces_proof = snapshot.spaces.prove(&space_tree_keys, ProofType::Standard)?; let spaces_root = spaces_proof.compute_root()?; let ptrs_anchor: ChainAnchor = snapshot.nums.metadata().try_into()?; @@ -1986,13 +2286,7 @@ impl AsyncChainState { let ptrs_proof = snapshot.nums.prove(&num_tree_keys, ProofType::Standard)?; let ptrs_root = ptrs_proof.compute_root()?; - ( - spaces_proof, - spaces_root, - spaces_anchor, - ptrs_proof, - ptrs_root, - ) + (spaces_proof, spaces_root, spaces_anchor, ptrs_proof, ptrs_root) } else { let spaces_snapshot = state.spaces_inner()?; let spaces_root = spaces_snapshot.compute_root()?; @@ -2011,13 +2305,7 @@ impl AsyncChainState { let ptrs_proof = ptrs_snapshot.prove(&num_tree_keys, ProofType::Standard)?; let ptrs_root = ptrs_proof.compute_root()?; - ( - spaces_proof, - spaces_root, - spaces_anchor, - ptrs_proof, - ptrs_root, - ) + (spaces_proof, spaces_root, spaces_anchor, ptrs_proof, ptrs_root) }; let spaces_buf = spaces_proof.to_vec()?; @@ -2089,6 +2377,14 @@ impl AsyncChainState { resp_rx.await? } + pub async fn verify_event(&self, subject: Subject, event: NostrEvent) -> anyhow::Result { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::VerifyEvent { subject, event, resp }) + .await?; + resp_rx.await? + } + pub async fn build_chain_proof( &self, request: ChainProofRequest, @@ -2113,18 +2409,10 @@ impl AsyncChainState { resp_rx.await? } - pub async fn debug_set_expire_height( - &self, - space: SLabel, - expire_height: u32, - ) -> anyhow::Result<()> { + pub async fn debug_set_expire_height(&self, space: SLabel, expire_height: u32) -> anyhow::Result<()> { let (resp, resp_rx) = oneshot::channel(); self.sender - .send(ChainStateCommand::DebugSetExpireHeight { - space, - expire_height, - resp, - }) + .send(ChainStateCommand::DebugSetExpireHeight { space, expire_height, resp }) .await?; resp_rx.await? } @@ -2145,6 +2433,14 @@ impl AsyncChainState { resp_rx.await? } + pub async fn get_all_spaces(&self) -> anyhow::Result> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::GetAllSpaces { resp }) + .await?; + resp_rx.await? + } + pub async fn get_ptr(&self, subject: Subject) -> anyhow::Result> { let (resp, resp_rx) = oneshot::channel(); self.sender @@ -2204,22 +2500,25 @@ impl AsyncChainState { resp_rx.await? } - pub async fn get_commitment( - &self, - subject: Subject, - root: Option, - ) -> anyhow::Result> { + pub async fn list_nums_by_spk(&self, script_pubkey: Vec) -> anyhow::Result { let (resp, resp_rx) = oneshot::channel(); self.sender - .send(ChainStateCommand::GetCommitment { - subject, - root, + .send(ChainStateCommand::ListNumsBySpk { + script_pubkey: ScriptBuf::from(script_pubkey), resp, }) .await?; resp_rx.await? } + pub async fn get_commitment(&self, subject: Subject, root: Option) -> anyhow::Result> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::GetCommitment { subject, root, resp }) + .await?; + resp_rx.await? + } + pub async fn get_delegation(&self, subject: Subject) -> anyhow::Result> { let (resp, resp_rx) = oneshot::channel(); self.sender @@ -2271,6 +2570,31 @@ impl AsyncChainState { .await?; resp_rx.await? } + + pub async fn find_fallback_by_handle(&self, needle: &str) -> anyhow::Result>> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::FindFallbackByHandle { + needle: needle.to_string(), + resp, + }) + .await?; + resp_rx.await? + } + + pub async fn search_fallback_by_pattern( + &self, + pattern: &str, + ) -> anyhow::Result>> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::SearchFallbackByPattern { + pattern: pattern.to_string(), + resp, + }) + .await?; + resp_rx.await? + } } fn resolve_num_id(state: &mut Chain, subject: &Subject) -> anyhow::Result { @@ -2278,11 +2602,12 @@ fn resolve_num_id(state: &mut Chain, subject: &Subject) -> anyhow::Result Subject::NumId(id) => Ok(*id), Subject::Label(label) if label.is_numeric() => { let numeric: SNumeric = label.clone().try_into().unwrap(); - state - .get_num_id(&numeric)? + state.get_num_id(&numeric)? .ok_or_else(|| anyhow!("numeric '{}' not found", numeric)) } Subject::Label(_) => Err(anyhow!("expected a num id or numeric, not a space")), + Subject::Handle(h) => Err(anyhow!("expected a num id or numeric, not a handle: {}", h)), + Subject::HandlePattern(p) => Err(anyhow!("expected a num id or numeric, not a pattern: {}", p)), } } @@ -2345,15 +2670,17 @@ async fn get_server_info( }) } + fn resolve_label(state: &mut Chain, subject: &Subject) -> anyhow::Result { match subject { Subject::Label(label) => Ok(label.clone()), Subject::NumId(id) => { - let info = state - .get_num_info(id)? + let info = state.get_num_info(id)? .ok_or_else(|| anyhow!("num id '{}' not found", id))?; Ok(info.numout.num.name.to_slabel()) } + Subject::Handle(h) => Err(anyhow!("expected a space or num, not a handle: {}", h)), + Subject::HandlePattern(p) => Err(anyhow!("expected a space or num, not a pattern: {}", p)), } } @@ -2367,22 +2694,24 @@ fn get_delegation(state: &mut Chain, subject: &Subject) -> anyhow::Result