diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..f1fba33c Binary files /dev/null and b/.DS_Store differ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5874b8c3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.git +target diff --git a/.gitignore b/.gitignore index c41cc9e3..c2504abd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,11 @@ -/target \ No newline at end of file +/target +# OS / editor +.DS_Store +.idea/ +*.swp +*~ + +# Build / env +/target +**/*.rs.bk +Cargo.lock diff --git a/Cargo.lock b/Cargo.lock index b5347893..363e8288 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -189,6 +189,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-activity" version = "0.6.0" @@ -635,8 +641,8 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" dependencies = [ - "bitcoin-internals", - "bitcoin_hashes", + "bitcoin-internals 0.3.0", + "bitcoin_hashes 0.14.1", ] [[package]] @@ -843,7 +849,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "serde", "unicode-normalization", ] @@ -872,16 +878,34 @@ dependencies = [ "base58ck", "base64 0.21.7", "bech32", - "bitcoin-internals", - "bitcoin-io", + "bitcoin-internals 0.3.0", + "bitcoin-io 0.1.4", "bitcoin-units", - "bitcoin_hashes", - "hex-conservative", + "bitcoin_hashes 0.14.1", + "hex-conservative 0.2.2", "hex_lit", "secp256k1", "serde", ] +[[package]] +name = "bitcoin-consensus-encoding" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d7ca3dc8ff835693ad73bf1596240c06f974a31eeb3f611aaedf855f1f2725" +dependencies = [ + "bitcoin-internals 0.5.0", +] + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.0.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cd69023e5db2f3f7241672de6be29408373ba0ff407e7fda71d70d728bec05a" +dependencies = [ + "bitcoin-internals 0.5.0", +] + [[package]] name = "bitcoin-internals" version = "0.3.0" @@ -891,12 +915,41 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin-internals" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90bbbfa552b49101a230fb2668f3f9ef968c81e6f83cf577e1d4b80f689e1aa" +dependencies = [ + "hex-conservative 0.3.2", +] + +[[package]] +name = "bitcoin-internals" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" +dependencies = [ + "hex-conservative 0.3.2", +] + [[package]] name = "bitcoin-io" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" +[[package]] +name = "bitcoin-io" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1a5e0e2112a702941a5c82fe0a31954c2900686053c33f59af64feab61d981" +dependencies = [ + "bitcoin-consensus-encoding 1.0.0-rc.3", + "bitcoin-internals 0.4.2", + "bitcoin_hashes 0.18.0", +] + [[package]] name = "bitcoin-jsonrpsee" version = "0.1.1" @@ -936,7 +989,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" dependencies = [ - "bitcoin-internals", + "bitcoin-internals 0.3.0", "serde", ] @@ -946,11 +999,33 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ - "bitcoin-io", - "hex-conservative", + "bitcoin-io 0.1.4", + "hex-conservative 0.2.2", "serde", ] +[[package]] +name = "bitcoin_hashes" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cb7fdb8e07d365e9cd16993fb3c4e04f734c66e334b9cb3c0479fa5437b0ed5" +dependencies = [ + "bitcoin-consensus-encoding 1.0.0-rc.3", + "bitcoin-internals 0.4.2", + "hex-conservative 0.3.2", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8a45c2b41c457a9a9e4670422fcbdf109afb3b22bc920b4045e8bdfd788a3d" +dependencies = [ + "bitcoin-consensus-encoding 0.1.0", + "bitcoin-internals 0.5.0", + "hex-conservative 0.3.2", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -2103,7 +2178,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f19e3ea99dbfbef0c1ec26d83e69de0c579f6aa6aaac4f44597805fcc27e97af" dependencies = [ "bitcoin", - "hex-conservative", + "hex-conservative 0.2.2", "log", "reqwest 0.12.28", "serde", @@ -2811,6 +2886,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -2957,6 +3034,24 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex-conservative" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7289f6b628ce69fb1a371d0fdcf8ff38cd93ec00e3010eb055d1e044998c8d1" +dependencies = [ + "arrayvec", +] + [[package]] name = "hex-literal" version = "0.4.1" @@ -5142,71 +5237,9 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "plain_bitassets" -version = "0.14.1" -dependencies = [ - "addr", - "anyhow", - "async-lock", - "bech32", - "bincode", - "bitcoin", - "blake3", - "borsh", - "byteorder", - "bytes", - "clap", - "ed25519-dalek", - "educe", - "error-fatality", - "fallible-iterator", - "fraction", - "futures", - "governor", - "hashlink 0.10.0", - "heed 0.21.0", - "hex 0.4.3", - "hex-literal", - "itertools 0.14.0", - "jsonrpsee 0.25.1", - "libes", - "nonempty 0.11.0", - "num", - "parking_lot", - "prost", - "prost-build", - "prost-types", - "protox", - "quinn", - "rayon", - "rcgen", - "rustls", - "semver", - "serde", - "serde_json", - "serde_with", - "smallvec", - "sneed 0.0.19", - "strum 0.27.2", - "thiserror 2.0.18", - "tiny-bip39", - "tokio", - "tokio-stream", - "tokio-util", - "tonic", - "tonic-prost", - "tonic-prost-build", - "tracing", - "transitive", - "utoipa", - "x25519-dalek", - "zeromq", -] - [[package]] name = "plain_bitassets_app" -version = "0.14.1" +version = "0.1.0" dependencies = [ "anyhow", "bincode", @@ -5228,13 +5261,17 @@ dependencies = [ "jsonrpsee 0.25.1", "libes", "mimalloc", + "num", "parking_lot", - "plain_bitassets", "plain_bitassets_app_cli", "plain_bitassets_app_rpc_api", "poll-promise", + "quinn", + "rustreexo", "serde", + "serde_json", "shlex", + "sidechain_utilities", "strum 0.27.2", "thiserror 2.0.18", "tiny-bip39", @@ -5254,7 +5291,7 @@ dependencies = [ [[package]] name = "plain_bitassets_app_cli" -version = "0.14.1" +version = "0.1.0" dependencies = [ "anyhow", "bitcoin", @@ -5262,9 +5299,9 @@ dependencies = [ "hex 0.4.3", "http", "jsonrpsee 0.25.1", - "plain_bitassets", "plain_bitassets_app_rpc_api", "serde_json", + "sidechain_utilities", "tokio", "tracing", "tracing-subscriber", @@ -5275,23 +5312,23 @@ dependencies = [ [[package]] name = "plain_bitassets_app_rpc_api" -version = "0.14.1" +version = "0.1.0" dependencies = [ "anyhow", "bitcoin", "fraction", "jsonrpsee 0.25.1", "l2l-openapi", - "plain_bitassets", "serde", "serde_json", "serde_with", + "sidechain_utilities", "utoipa", ] [[package]] name = "plain_bitassets_integration_tests" -version = "0.14.1" +version = "0.1.0" dependencies = [ "anyhow", "bip300301_enforcer_integration_tests", @@ -5303,9 +5340,9 @@ dependencies = [ "futures", "jsonrpsee 0.25.1", "libtest-mimic", - "plain_bitassets", "plain_bitassets_app_rpc_api", "reserve-port", + "sidechain_utilities", "thiserror 2.0.18", "tokio", "tracing", @@ -6204,6 +6241,18 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustreexo" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8353cd48bea30340eced2a11770e47bb6b83f0e7e679742301f3332e6ec1f6ab" +dependencies = [ + "bitcoin-io 0.3.0", + "bitcoin_hashes 0.20.0", + "hashbrown 0.16.1", + "hex-conservative 1.1.0", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -6314,7 +6363,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "rand 0.8.6", "secp256k1-sys", "serde", @@ -6498,6 +6547,70 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "sidechain_utilities" +version = "0.1.0" +dependencies = [ + "addr", + "anyhow", + "async-lock", + "bech32", + "bincode", + "bitcoin", + "blake3", + "borsh", + "byteorder", + "bytes", + "clap", + "dirs", + "ed25519-dalek", + "educe", + "error-fatality", + "fallible-iterator", + "fraction", + "futures", + "governor", + "hashlink 0.10.0", + "heed 0.21.0", + "hex 0.4.3", + "hex-literal", + "itertools 0.14.0", + "jsonrpsee 0.25.1", + "libes", + "nonempty 0.11.0", + "num", + "parking_lot", + "prost", + "prost-build", + "prost-types", + "protox", + "quinn", + "rayon", + "rcgen", + "reqwest 0.12.28", + "rustls", + "semver", + "serde", + "serde_json", + "serde_with", + "smallvec", + "sneed 0.0.19", + "strum 0.27.2", + "thiserror 2.0.18", + "tiny-bip39", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "transitive", + "utoipa", + "x25519-dalek", + "zeromq", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" diff --git a/Cargo.toml b/Cargo.toml index 14755832..d21810f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,10 +4,11 @@ members = ["app", "cli", "integration_tests", "lib", "rpc-api"] [workspace.package] authors = ["Ash Manning "] +description = "Plain BitAssets: BitWindow-compatible BitAssets sidechain node and UI" edition = "2024" license-file = "LICENSE.txt" publish = false -version = "0.14.1" +version = "0.1.0" [workspace.dependencies] addr = "0.15.6" @@ -54,9 +55,11 @@ prost-types = "0.14.3" protox = "0.9.1" quinn = "0.11.6" rayon = "1.7.0" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } rcgen = "0.13.2" reserve-port = "2.0.1" rustls = { version = "0.23.21", default-features = false } +rustreexo = "0.5" semver = "1.0.25" serde = "1.0.179" serde_json = "1.0.113" diff --git a/Dockerfile b/Dockerfile index cd2559cb..c1545b3f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,22 @@ -# Stable Rust version, as of January 2025. +# Stable Rust version, as of January 2025. FROM rust:1.84-slim-bookworm AS builder WORKDIR /workspace COPY . . -RUN cargo build --locked --release +RUN cargo build --locked --release && \ + mkdir -p /artifacts && \ + cp /workspace/target/release/plain_bitassets_app /artifacts/plain_bitassets_app && \ + cp /workspace/target/release/plain_bitassets_app_cli /artifacts/plain_bitassets_app_cli # Runtime stage FROM debian:bookworm-slim -COPY --from=builder /workspace/target/release/plain_bitassets_app /bin/plain_bitassets_app -COPY --from=builder /workspace/target/release/plain_bitassets_app_cli /bin/plain_bitassets_app_cli +COPY --from=builder /artifacts/plain_bitassets_app /bin/plain_bitassets_app +COPY --from=builder /artifacts/plain_bitassets_app_cli /bin/plain_bitassets_app_cli -# Verify we placed the binaries in the right place, +# Verify we placed the binaries in the right place, # and that it's executable. RUN plain_bitassets_app --help RUN plain_bitassets_app_cli --help ENTRYPOINT ["plain_bitassets_app"] - diff --git a/README.md b/README.md index 2053a32a..88c06a13 100644 --- a/README.md +++ b/README.md @@ -7,4 +7,281 @@ Check out the repo with `git clone`, and then ``` git submodule update --init cargo build -``` \ No newline at end of file +``` + +## Run BitAssets for BitWindow + +BitWindow expects the BitAssets sidechain JSON-RPC service on port `6004`. +This node can also expose: + +- CUSF sidechain gRPC for BitWindow-compatible sidechain calls on port `50052` +- lite-wallet QUIC updates for phone/local wallets on UDP port `6104` + +Start BitWindow's L1/enforcer stack first, then run BitAssets from this repo: + +```sh +cargo run -p plain_bitassets_app -- \ + --headless \ + --network signet \ + --mainchain-grpc-host 127.0.0.1 \ + --mainchain-grpc-port 50051 \ + --rpc-host 127.0.0.1 \ + --rpc-port 6004 \ + --sidechain-grpc-port 50052 \ + --lite-wallet-quic-addr 127.0.0.1:6104 +``` + +If BitWindow is already running, leave it open. `bitwindowd` and +`orchestratord` both proxy BitAssets by dialing `127.0.0.1:6004`, so a running +plain-BitAssets node on that port is enough for BitWindow to connect. + +### Verify the BitWindow connection + +Confirm the node is listening on the expected ports: + +```sh +lsof -nP -iTCP:6004 -sTCP:LISTEN +lsof -nP -iTCP:50052 -sTCP:LISTEN +lsof -nP -iUDP:6104 +``` + +Probe the JSON-RPC API BitWindow uses: + +```sh +curl --fail --silent --show-error \ + --data-binary '{"jsonrpc":"2.0","id":"bitassets","method":"getblockcount","params":[]}' \ + -H 'content-type: application/json' \ + http://127.0.0.1:6004 +``` + +A healthy node returns a JSON-RPC `result`, for example: + +```json +{"jsonrpc":"2.0","id":"bitassets","result":0} +``` + +You can also verify BitWindow has an active connection to the node: + +```sh +lsof -nP -iTCP | grep '127.0.0.1:6004' +``` + +Look for a `bitwindowd -> 127.0.0.1:6004` established connection. + +### BitWindow configuration expectations + +BitWindow's sidechain config should contain a `bitassets` entry like this: + +```json +{ + "name": "BitAssets", + "port": 6004, + "slot": 4, + "type": "sidechain" +} +``` + +On macOS, BitWindow commonly reads the merged runtime config from: + +```sh +~/Library/Application Support/bitwindow/chains_config.json +``` + +If BitWindow previously tried to auto-download or auto-start a packaged +BitAssets backend and failed, keep this plain-BitAssets node running on `6004` +and restart or reopen BitWindow. BitWindow will reconnect to the already +listening JSON-RPC service. + +### Common issue: sidechain deposit list error + +An error like this in BitWindow's Sidechains tab is not a BitAssets node +connection failure: + +```text +WalletException: could not list sidechain deposits: unable to fetch wallet transaction ... +No such mempool or blockchain transaction +``` + +That comes from BitWindow asking the enforcer wallet for L1 sidechain deposit +history. BitAssets is still connected if `getblockcount` on `127.0.0.1:6004` +works and `bitwindowd` has an established connection to port `6004`. + +## Connect a Wallet to Utreexo Lite-Wallet Messages + +BitAssets exposes a lite-wallet update API for wallets that do not want to run +the full sidechain node. The wallet watches its own BitAssets script hashes, +receives scoped transaction/UTXO updates, and verifies the included Utreexo +roots/proofs against the sidechain tip it trusts. + +There are two supported transports: + +- JSON-RPC polling with `get_lite_wallet_update(script_hashes, from_block_hash)` +- QUIC subscriptions started with `--lite-wallet-quic-addr ` + +The QUIC path is the preferred live wallet path. JSON-RPC is useful for first +sync, recovery after disconnects, and debugging. + +### Start the BitAssets node + +Run the app/headless node with JSON-RPC and the lite-wallet QUIC listener: + +```sh +cargo run -p plain_bitassets_app -- \ + --headless \ + --network signet \ + --rpc-host 127.0.0.1 \ + --rpc-port 6004 \ + --lite-wallet-quic-addr 127.0.0.1:6104 +``` + +Use a host/port reachable by the wallet. On a phone or another machine, +`127.0.0.1` means the phone itself, not the Mac/server running BitAssets, so +advertise a LAN, tunnel, or USB-forwarded address instead. + +### Export Utreexo peer information for Floresta wallets + +Floresta-compatible wallets can import Utreexo peer anchors. If the wallet +already knows the Bitcoin private-signet Utreexo peers, export them explicitly: + +```sh +cargo run -p plain_bitassets_app_cli -- \ + --rpc-port 6004 \ + export-private-signet-utreexo-anchors \ + --peer \ + --output anchors.json +``` + +If this BitAssets node is already connected to active private-signet peers, +export those instead: + +```sh +cargo run -p plain_bitassets_app_cli -- \ + --rpc-port 6004 \ + export-private-signet-utreexo-anchors \ + --active \ + --output anchors.json +``` + +To give a wallet a single discovery document that includes both the Utreexo +anchor and this node's lite-wallet QUIC endpoint: + +```sh +cargo run -p plain_bitassets_app_cli -- \ + --rpc-port 6004 \ + private-signet-utreexo-peer-source \ + --peer \ + --output bitassets-peer-source.json +``` + +The exported `bitassets-peer-source.json` includes: + +- the Bitcoin private-signet Utreexo anchor +- this BitAssets node's JSON-RPC URL +- this BitAssets node's lite-wallet QUIC address +- network metadata for the wallet + +These export helpers are only valid on `signet`. + +### Watch script hashes over JSON-RPC + +The wallet must derive the BitAssets addresses it owns and send their 32-byte +script hashes to the node. Script hashes are lowercase or uppercase hex strings; +the server normalizes and deduplicates them. A request may include at most 256 +script hashes. + +Initial snapshot: + +```sh +curl --data-binary '{ + "jsonrpc": "2.0", + "id": "bitassets-wallet-snapshot", + "method": "get_lite_wallet_update", + "params": [["<32-byte-script-hash-hex>"], null] +}' \ + -H 'content-type: application/json' \ + http://127.0.0.1:6004 +``` + +Recovery/delta from a known sidechain block: + +```sh +curl --data-binary '{ + "jsonrpc": "2.0", + "id": "bitassets-wallet-delta", + "method": "get_lite_wallet_update", + "params": [["<32-byte-script-hash-hex>"], ""] +}' \ + -H 'content-type: application/json' \ + http://127.0.0.1:6004 +``` + +The returned `LiteWalletUpdate` includes: + +- `tip_hash` and `tip_height` +- confirmed watched UTXOs +- watched spent outpoints +- mempool watched created/spent outpoints +- relevant transactions when available +- `utreexo_leaf_count` +- `utreexo_roots` +- compact `proof_refs` +- `utreexo_proofs` for confirmed watched UTXOs + +Wallet behavior: + +1. Store the latest accepted `tip_hash`. +2. Store wallet UTXOs only when the script hash belongs to the wallet. +3. Verify included Utreexo proof data against the advertised roots/tip before + treating confirmed outputs as spendable. +4. Use `from_block_hash` on reconnect. If the node rejects the cursor because + it is no longer on the active chain, discard the stale cursor and request a + fresh snapshot with `from_block_hash = null`. + +### Subscribe over QUIC + +The QUIC service uses one newline-delimited JSON message per bidirectional +stream. Open a QUIC connection to `--lite-wallet-quic-addr`, open a +bidirectional stream, write one `Subscribe` JSON request, finish the write side, +and then read newline-delimited responses until disconnect. + +Request: + +```json +{ + "Subscribe": { + "script_hashes": ["<32-byte-script-hash-hex>"], + "from_block_hash": null + } +} +``` + +Responses: + +```json +{"Snapshot":{"update":{"tip_hash":"...","tip_height":0}}} +{"Mempool":{"update":{"tip_hash":"...","tip_height":0}}} +{"Confirmed":{"update":{"tip_hash":"...","tip_height":1}}} +{"Error":{"message":"..."}} +``` + +Use `Snapshot` as the initial wallet state, `Mempool` for unconfirmed display, +and `Confirmed` for block-connected updates. Persist the latest confirmed +`tip_hash` and reconnect with it as `from_block_hash`. + +Request bodies are capped at 64 KiB. Empty watch sets, malformed hex, +wrong-length script hashes, and oversized requests return `Error`. + +### Submit locally signed transactions + +Wallets should sign BitAssets transactions locally. After building and signing +an authorized transaction, submit it through JSON-RPC: + +```sh +cargo run -p plain_bitassets_app_cli -- \ + --rpc-port 6004 \ + submit-authorized-transaction +``` + +After submission, the wallet should expect a `Mempool` update if the transaction +touches a watched script hash, followed by a `Confirmed` update once a BMMed +sidechain block includes it. diff --git a/app/Cargo.toml b/app/Cargo.toml index fc19175b..262d1b6c 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "plain_bitassets_app" authors.workspace = true +description.workspace = true edition.workspace = true license-file.workspace = true publish.workspace = true @@ -26,12 +27,16 @@ itertools = { workspace = true } include_path = { workspace = true } jsonrpsee = { workspace = true, features = ["server"] } mimalloc = { workspace = true, features = ["v3"] } +num = { workspace = true } parking_lot = { workspace = true } -plain_bitassets = { path = "../lib", features = ["clap"] } +sidechain_utilities = { path = "../lib", features = ["clap"] } plain_bitassets_app_cli = { path = "../cli" } plain_bitassets_app_rpc_api = { path = "../rpc-api" } poll-promise = { workspace = true, features = ["tokio"] } +quinn = { workspace = true } +rustreexo = { workspace = true } serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } shlex = { workspace = true } strum = { workspace = true, features = ["derive"] } thiserror = { workspace = true } @@ -55,7 +60,7 @@ features = ["AES256-GCM", "ECIES-MAC", "HMAC-SHA256", "x25519"] [features] default = ["zmq"] -zmq = ["plain_bitassets/zmq"] +zmq = ["sidechain_utilities/zmq"] [lints] workspace = true diff --git a/app/app.rs b/app/app.rs index a9f9be65..3c728f0c 100644 --- a/app/app.rs +++ b/app/app.rs @@ -3,7 +3,7 @@ use std::{collections::HashMap, sync::Arc}; use fallible_iterator::FallibleIterator as _; use futures::{StreamExt as _, TryFutureExt as _}; use parking_lot::RwLock; -use plain_bitassets::{ +use sidechain_utilities::{ miner::{self, Miner}, node::{self, Node}, types::{ @@ -15,6 +15,7 @@ use plain_bitassets::{ }, }, wallet::{self, Wallet}, + elements_rpc::ElementsRpc, }; use tokio::{spawn, sync::RwLock as TokioRwLock, task::JoinHandle}; use tokio_util::task::LocalPoolHandle; @@ -30,7 +31,7 @@ pub enum Error { #[error(transparent)] AmountOverflow(#[from] AmountOverflowError), #[error("CUSF mainchain proto error")] - CusfMainchain(#[from] plain_bitassets::types::proto::Error), + CusfMainchain(#[from] sidechain_utilities::types::proto::Error), #[error("io error")] Io(#[from] std::io::Error), #[error("miner error: {0}")] @@ -105,8 +106,14 @@ pub struct App { pub utxos: Arc>>, pub unconfirmed_utxos: Arc>>, pub runtime: Arc, + pub network: types::Network, + pub rpc_url: url::Url, + pub lite_wallet_quic_addr: std::net::SocketAddr, + pub net_addr: std::net::SocketAddr, task: Arc>, pub local_pool: LocalPoolHandle, + /// Connection to elementsd for L-BTC wallet data (getbalance, listunspent, etc.) + pub elements_rpc: Option, } impl App { @@ -283,6 +290,22 @@ impl App { wallet.clone(), ); drop(rt_guard); + + // Wire up Elements JSON-RPC for L-BTC wallet display (elementsd) + let elements_rpc = { + let cookie_dir = Some(std::path::PathBuf::from("/tmp/liquid-id5-regtest")); + match ElementsRpc::new("http://127.0.0.1:18443", cookie_dir.as_deref()) { + Ok(rpc) => { + tracing::info!("Connected to elementsd RPC for L-BTC wallet data"); + Some(rpc) + } + Err(e) => { + tracing::warn!("Failed to init elementsd RPC client: {e:#}; L-BTC panels will be empty"); + None + } + } + }; + Ok(Self { node, wallet, @@ -290,8 +313,13 @@ impl App { unconfirmed_utxos, utxos, runtime: Arc::new(runtime), + network: config.network, + rpc_url: config.rpc_url(), + lite_wallet_quic_addr: config.lite_wallet_quic_addr, + net_addr: config.net_addr, task: Arc::new(task), local_pool, + elements_rpc, }) } @@ -473,7 +501,7 @@ impl App { miner_write.confirm_bmm().await.inspect_err(|err| { tracing::error!( "{:#}", - plain_bitassets::util::ErrorChain::new(err) + sidechain_utilities::util::ErrorChain::new(err) ) })? { @@ -489,7 +517,7 @@ impl App { .inspect_err(|err| { tracing::error!( "{:#}", - plain_bitassets::util::ErrorChain::new(err) + sidechain_utilities::util::ErrorChain::new(err) ) })? { true => { diff --git a/app/cli.rs b/app/cli.rs index 27e73b64..a46bb732 100644 --- a/app/cli.rs +++ b/app/cli.rs @@ -6,7 +6,7 @@ use std::{ }; use clap::{Arg, Parser}; -use plain_bitassets::types::{Network, THIS_SIDECHAIN}; +use sidechain_utilities::types::{Network, THIS_SIDECHAIN}; use url::{Host, Url}; use crate::util::saturating_pred_level; @@ -37,6 +37,11 @@ const DEFAULT_RPC_HOST: Host = Host::Ipv4(Ipv4Addr::LOCALHOST); const DEFAULT_RPC_PORT: u16 = 6000 + THIS_SIDECHAIN as u16; +const DEFAULT_LITE_WALLET_QUIC_ADDR: SocketAddr = + ipv4_socket_addr([127, 0, 0, 1], 6100 + THIS_SIDECHAIN as u16); + +const DEFAULT_SIDECHAIN_GRPC_PORT: u16 = 50052; + #[cfg(feature = "zmq")] const DEFAULT_ZMQ_ADDR: SocketAddr = ipv4_socket_addr([127, 0, 0, 1], 28000 + THIS_SIDECHAIN as u16); @@ -148,10 +153,16 @@ pub(super) struct Cli { /// Port for the RPC server #[arg(default_value_t = DEFAULT_RPC_PORT, long)] rpc_port: u16, + /// Socket address for lite-wallet QUIC subscriptions + #[arg(default_value_t = DEFAULT_LITE_WALLET_QUIC_ADDR, long)] + lite_wallet_quic_addr: SocketAddr, /// ZMQ pub/sub address #[cfg(feature = "zmq")] #[arg(default_value_t = DEFAULT_ZMQ_ADDR, long, short)] pub zmq_addr: SocketAddr, + /// Host:port for the cusf.sidechain.v1.SidechainService gRPC (for BitWindow) + #[arg(default_value_t = DEFAULT_SIDECHAIN_GRPC_PORT, long)] + sidechain_grpc_port: u16, } impl Cli { @@ -198,8 +209,10 @@ impl Cli { network: self.network, rpc_host: self.rpc_host, rpc_port: self.rpc_port, + lite_wallet_quic_addr: self.lite_wallet_quic_addr, #[cfg(feature = "zmq")] zmq_addr: self.zmq_addr, + sidechain_grpc_addr: ipv4_socket_addr([127, 0, 0, 1], self.sidechain_grpc_port), }) } } @@ -218,8 +231,10 @@ pub struct Config { pub network: Network, pub rpc_host: Host, pub rpc_port: u16, + pub lite_wallet_quic_addr: SocketAddr, #[cfg(feature = "zmq")] pub zmq_addr: SocketAddr, + pub sidechain_grpc_addr: SocketAddr, } impl Config { diff --git a/app/gui/activity/block_explorer.rs b/app/gui/activity/block_explorer.rs index 71c461b7..22f75ec0 100644 --- a/app/gui/activity/block_explorer.rs +++ b/app/gui/activity/block_explorer.rs @@ -1,7 +1,7 @@ use eframe::egui; use human_size::{Byte, Kibibyte, Mebibyte, SpecificSize}; -use plain_bitassets::types::{Body, GetBitcoinValue, Header}; +use sidechain_utilities::types::{Body, GetBitcoinValue, Header}; use crate::app::App; diff --git a/app/gui/activity/mempool_explorer.rs b/app/gui/activity/mempool_explorer.rs index 9d2d8ede..0fd8ff11 100644 --- a/app/gui/activity/mempool_explorer.rs +++ b/app/gui/activity/mempool_explorer.rs @@ -1,7 +1,7 @@ use eframe::egui; use human_size::{Byte, Kibibyte, Mebibyte, SpecificSize}; -use plain_bitassets::types::{GetBitcoinValue, OutPoint}; +use sidechain_utilities::types::{GetBitcoinValue, OutPoint}; use crate::app::App; diff --git a/app/gui/activity/mod.rs b/app/gui/activity/mod.rs index d5f60d8d..12b0f600 100644 --- a/app/gui/activity/mod.rs +++ b/app/gui/activity/mod.rs @@ -39,6 +39,34 @@ impl Activity { } pub fn show(&mut self, app: Option<&App>, ui: &mut egui::Ui) { + // L-BTC Activity wired to elementsd listtransactions (real data) + egui::TopBottomPanel::top("lbtc_activity").show(ui.ctx(), |ui| { + ui.heading("L-BTC Activity (from elementsd listtransactions)"); + if let Some(app) = app { + if let Some(rpc) = &app.elements_rpc { + if let Ok(txs) = app.runtime.block_on(rpc.listtransactions(8)) { + egui::ScrollArea::vertical() + .max_height(140.0) + .show(ui, |ui| { + for tx in txs { + ui.monospace(format!( + "{} amt:{:.8} confs:{} cat:{}", + tx.txid, tx.amount, tx.confirmations, tx.category + )); + } + }); + } else { + ui.monospace("Failed to fetch listtransactions"); + } + if ui.button("Refresh L-BTC txs").clicked() { + // live on next draw + } + } else { + ui.monospace("elementsd RPC not available"); + } + } + }); + egui::TopBottomPanel::top("activity_tabs").show(ui.ctx(), |ui| { ui.horizontal(|ui| { Tab::iter().for_each(|tab_variant| { diff --git a/app/gui/bitassets/all_bitassets.rs b/app/gui/bitassets/all_bitassets.rs index 585eafa6..1b8973c3 100644 --- a/app/gui/bitassets/all_bitassets.rs +++ b/app/gui/bitassets/all_bitassets.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, HashMap}; use eframe::egui; use hex::FromHex; -use plain_bitassets::{ +use sidechain_utilities::{ state::BitAssetSeqId, types::{BitAssetData, hashes::BitAssetId}, }; diff --git a/app/gui/bitassets/dutch_auction_explorer.rs b/app/gui/bitassets/dutch_auction_explorer.rs index f59c8f87..4929d8d7 100644 --- a/app/gui/bitassets/dutch_auction_explorer.rs +++ b/app/gui/bitassets/dutch_auction_explorer.rs @@ -3,7 +3,7 @@ use std::{collections::BTreeMap, fmt::Display}; use eframe::egui::{self, InnerResponse, Response}; use hex::FromHex; -use plain_bitassets::{state::DutchAuctionState, types::DutchAuctionId}; +use sidechain_utilities::{state::DutchAuctionState, types::DutchAuctionId}; use crate::{ app::App, diff --git a/app/gui/bitassets/reserve_register.rs b/app/gui/bitassets/reserve_register.rs index dc440ac4..0f0c7577 100644 --- a/app/gui/bitassets/reserve_register.rs +++ b/app/gui/bitassets/reserve_register.rs @@ -1,7 +1,7 @@ use std::borrow::Cow; use eframe::egui; -use plain_bitassets::types::BitAssetData; +use sidechain_utilities::types::BitAssetData; use crate::{ app::App, diff --git a/app/gui/coins/mod.rs b/app/gui/coins/mod.rs index b81f37d3..47427b7a 100644 --- a/app/gui/coins/mod.rs +++ b/app/gui/coins/mod.rs @@ -1,7 +1,7 @@ use eframe::egui; use strum::{EnumIter, IntoEnumIterator}; -use crate::app::App; +use crate::{app::App, gui::util::UiExt}; mod my_bitassets; mod transfer_receive; @@ -47,6 +47,65 @@ impl Coins { app: Option<&App>, ui: &mut egui::Ui, ) -> anyhow::Result<()> { + // L-BTC Wallet header wired to elementsd JSON-RPC (real data) + egui::TopBottomPanel::top("lbtc_header").show(ui.ctx(), |ui| { + ui.heading("L-BTC Wallet (elementsd)"); + if let Some(app) = app { + if let Some(rpc) = &app.elements_rpc { + // Fetch live (local RPC is fast; block_on is acceptable here) + let balance = app + .runtime + .block_on(rpc.getbalance()) + .map(|a| format!("{:.8}", a.to_btc())) + .unwrap_or_else(|e| format!("error: {e}")); + let recv_addr = app + .runtime + .block_on(rpc.getnewaddress()) + .unwrap_or_else(|e| format!("error: {e}")); + + ui.horizontal(|ui| { + ui.monospace(format!("Balance: {} L-BTC", balance)); + if ui.button("Refresh").clicked() { + // next frame will refetch + } + }); + ui.horizontal(|ui| { + ui.monospace("Receive:"); + ui.monospace_selectable_singleline(true, recv_addr.as_str()); + if ui.button("Copy").clicked() { + ui.output_mut(|o| o.copied_text = recv_addr.clone()); + } + }); + + // UTXOs + if let Ok(utxos) = app.runtime.block_on(rpc.listunspent()) { + ui.collapsing("UTXOs (listunspent)", |ui| { + egui::ScrollArea::vertical() + .max_height(120.0) + .show(ui, |ui| { + for u in utxos.iter().take(10) { + ui.monospace(format!( + "{}:{} {} L-BTC confs:{}", + u.txid, + u.vout, + u.amount.to_btc(), + u.confirmations + )); + } + if utxos.len() > 10 { + ui.monospace(format!("... and {} more", utxos.len() - 10)); + } + }); + }); + } + } else { + ui.monospace("elementsd RPC not connected (no cookie or elementsd down)"); + } + } else { + ui.monospace("App not initialized"); + } + }); + egui::TopBottomPanel::top("coins_tabs").show(ui.ctx(), |ui| { ui.horizontal(|ui| { Tab::iter().for_each(|tab_variant| { diff --git a/app/gui/coins/my_bitassets.rs b/app/gui/coins/my_bitassets.rs index 0bca036b..d6c92827 100644 --- a/app/gui/coins/my_bitassets.rs +++ b/app/gui/coins/my_bitassets.rs @@ -1,7 +1,7 @@ use eframe::egui; use itertools::{Either, Itertools}; -use plain_bitassets::types::{BitAssetId, FilledOutput, Hash, Txid}; +use sidechain_utilities::types::{BitAssetId, FilledOutput, Hash, Txid}; use crate::{app::App, gui::util::UiExt}; diff --git a/app/gui/coins/transfer_receive.rs b/app/gui/coins/transfer_receive.rs index c31d4bf6..e5fe3318 100644 --- a/app/gui/coins/transfer_receive.rs +++ b/app/gui/coins/transfer_receive.rs @@ -1,5 +1,5 @@ use eframe::egui::{self, Button}; -use plain_bitassets::types::Address; +use sidechain_utilities::types::Address; use crate::{app::App, gui::util::UiExt}; diff --git a/app/gui/coins/tx_builder.rs b/app/gui/coins/tx_builder.rs index 4c6d33a7..bb735cd0 100644 --- a/app/gui/coins/tx_builder.rs +++ b/app/gui/coins/tx_builder.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use eframe::egui; -use plain_bitassets::types::{ +use sidechain_utilities::types::{ AssetId, AssetOutputContent, BitAssetId, BitcoinOutputContent, GetBitcoinValue, Transaction, WithdrawalOutputContent, }; diff --git a/app/gui/coins/tx_creator.rs b/app/gui/coins/tx_creator.rs index f3c30a5f..4166deb9 100644 --- a/app/gui/coins/tx_creator.rs +++ b/app/gui/coins/tx_creator.rs @@ -8,7 +8,7 @@ use std::{ use eframe::egui::{self, InnerResponse, Response, TextBuffer}; use hex::FromHex; -use plain_bitassets::{ +use sidechain_utilities::{ state::AmmPair, types::{ AssetId, BitAssetData, DutchAuctionId, EncryptionPubKey, Hash, @@ -418,7 +418,7 @@ impl TxCreator { u64::from_str(&auction_params.final_price).map_err(|err| { anyhow::anyhow!("Failed to parse final price: {err}") })?; - let dutch_auction_params = plain_bitassets::types::DutchAuctionParams { + let dutch_auction_params = sidechain_utilities::types::DutchAuctionParams { start_block, duration, base_asset, diff --git a/app/gui/coins/utxo_creator.rs b/app/gui/coins/utxo_creator.rs index b1739400..315223ef 100644 --- a/app/gui/coins/utxo_creator.rs +++ b/app/gui/coins/utxo_creator.rs @@ -1,6 +1,6 @@ use eframe::egui::{self, Button}; -use plain_bitassets::types::{ +use sidechain_utilities::types::{ self, AssetId, BitcoinOutputContent, Output, OutputContent, Transaction, WithdrawalOutputContent, }; diff --git a/app/gui/coins/utxo_selector.rs b/app/gui/coins/utxo_selector.rs index b6b8f25d..676bc606 100644 --- a/app/gui/coins/utxo_selector.rs +++ b/app/gui/coins/utxo_selector.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; use eframe::egui; -use plain_bitassets::types::{ +use sidechain_utilities::types::{ AssetId, AssetOutputContent, BitcoinOutput, BitcoinOutputContent, FilledOutput, OutPoint, Output, Transaction, WithdrawalOutputContent, }; diff --git a/app/gui/messaging/decrypt.rs b/app/gui/messaging/decrypt.rs index 1e72b7a8..db64d9bc 100644 --- a/app/gui/messaging/decrypt.rs +++ b/app/gui/messaging/decrypt.rs @@ -1,6 +1,6 @@ use eframe::egui; -use plain_bitassets::types::EncryptionPubKey; +use sidechain_utilities::types::EncryptionPubKey; use crate::{ app::App, diff --git a/app/gui/messaging/encrypt.rs b/app/gui/messaging/encrypt.rs index 1e73d222..bd8255d4 100644 --- a/app/gui/messaging/encrypt.rs +++ b/app/gui/messaging/encrypt.rs @@ -1,7 +1,7 @@ use eframe::egui; use libes::key::conversion::PublicKeyFrom; -use plain_bitassets::types::{EncryptionPubKey, keys::Ecies}; +use sidechain_utilities::types::{EncryptionPubKey, keys::Ecies}; use crate::{ app::App, diff --git a/app/gui/miner.rs b/app/gui/miner.rs index eb43f4c1..f3c78e06 100644 --- a/app/gui/miner.rs +++ b/app/gui/miner.rs @@ -4,54 +4,158 @@ use std::sync::{ }; use eframe::egui::{self, Button}; +use futures::FutureExt as _; +use sidechain_utilities::types::proto::mainchain; use crate::app::App; +#[derive(Debug, Default)] +struct LiveData { + mainchain_tip: Option>, + elements_height: Option>, +} + #[derive(Debug)] pub struct Miner { running: Arc, + live: LiveData, + loaded: bool, } impl Default for Miner { fn default() -> Self { Self { running: Arc::new(AtomicBool::new(false)), + live: LiveData::default(), + loaded: false, } } } impl Miner { + fn refresh(&mut self, app: &App) { + self.live.mainchain_tip = Some( + app.runtime + .block_on( + app.node + .with_cusf_mainchain(|c| c.get_chain_tip().boxed()), + ) + .map_err(anyhow::Error::from), + ); + self.live.elements_height = app.elements_rpc.as_ref().map(|rpc| { + app.runtime + .block_on(rpc.getblockcount()) + .map_err(anyhow::Error::from) + }); + self.loaded = true; + } + pub fn show(&mut self, app: Option<&App>, ui: &mut egui::Ui) { - let block_height = app + if !self.loaded { + if let Some(app) = app { + self.refresh(app); + } + } + + ui.heading("BMM Status"); + ui.add_space(4.0); + + ui.horizontal(|ui| { + ui.label("Elementsd block count (:18443):"); + match self.live.elements_height.as_ref() { + Some(Ok(h)) => { + ui.monospace(h.to_string()); + } + Some(Err(e)) => { + ui.colored_label(egui::Color32::RED, format!("err: {e:#}")); + } + None => { + ui.monospace("(no elements_rpc configured)"); + } + } + }); + + ui.horizontal(|ui| { + ui.label("Mainchain height (enforcer :50051):"); + match self.live.mainchain_tip.as_ref() { + Some(Ok(tip)) => { + ui.monospace(tip.height.to_string()); + } + Some(Err(e)) => { + ui.colored_label(egui::Color32::RED, format!("err: {e:#}")); + } + None => { + ui.monospace("—"); + } + } + }); + + ui.horizontal(|ui| { + ui.label("Latest mainchain tip hash:"); + match self.live.mainchain_tip.as_ref() { + Some(Ok(tip)) => { + let hash = tip.block_hash.to_string(); + let short = if hash.len() > 16 { + format!("{}…", &hash[..16]) + } else { + hash + }; + ui.monospace(short); + } + Some(Err(_)) => { + ui.monospace("(error)"); + } + None => { + ui.monospace("—"); + } + } + }); + + ui.horizontal(|ui| { + ui.label("Status:"); + ui.colored_label( + egui::Color32::from_rgb(0x2e, 0x7d, 0x32), + "Simplicity ALWAYS_ACTIVE", + ); + }); + + ui.add_space(4.0); + + let sidechain_tip = app .and_then(|app| app.node.try_get_tip_height().ok().flatten()) .unwrap_or(0); - let best_hash = app - .and_then(|app| app.node.try_get_tip().ok().flatten()) - .unwrap_or([0; 32].into()); - ui.label("Block height: "); - ui.monospace(format!("{block_height}")); - ui.label("Best hash: "); - let best_hash = &format!("{best_hash}")[0..8]; - ui.monospace(format!("{best_hash}...")); - let running = self.running.load(atomic::Ordering::SeqCst); - if let Some(app) = app - && ui - .add_enabled(!running, Button::new("Mine / Refresh Block")) + ui.horizontal(|ui| { + ui.label("Internal sidechain tip height:"); + ui.monospace(sidechain_tip.to_string()); + }); + + ui.add_space(8.0); + + ui.horizontal(|ui| { + if ui + .add_enabled(app.is_some(), Button::new("Refresh")) .clicked() - { - self.running.store(true, atomic::Ordering::SeqCst); - app.local_pool.spawn_pinned({ - let app = app.clone(); - let running = self.running.clone(); - || async move { - tracing::debug!("Mining..."); - let mining_result = app.mine(None).await; - running.store(false, atomic::Ordering::SeqCst); - if let Err(err) = mining_result { - tracing::error!("{:#}", anyhow::Error::new(err)) - } - } - }); - } + { + self.refresh(app.unwrap()); + } + + let running = self.running.load(atomic::Ordering::SeqCst); + if let Some(app) = app { + if ui + .add_enabled(!running, Button::new("Mine / Request BMM Block")) + .clicked() + { + self.running.store(true, atomic::Ordering::SeqCst); + app.local_pool.spawn_pinned({ + let app = app.clone(); + let running = self.running.clone(); + || async move { + drop(app.mine(None).await); + running.store(false, atomic::Ordering::SeqCst); + } + }); + } + } + }); } } diff --git a/app/gui/mod.rs b/app/gui/mod.rs index abe9c7b6..13826aab 100644 --- a/app/gui/mod.rs +++ b/app/gui/mod.rs @@ -1,31 +1,29 @@ use std::task::Poll; use eframe::egui::{self, Color32, RichText}; -use plain_bitassets::{util::Watchable, wallet::Wallet}; +use sidechain_utilities::{util::Watchable, wallet::Wallet}; use strum::{EnumIter, IntoEnumIterator}; use crate::{app::App, line_buffer::LineBuffer, util::PromiseStream}; mod activity; -mod bitassets; mod coins; mod console_logs; mod fonts; -mod messaging; mod miner; mod parent_chain; mod seed; +mod simplicity; mod util; use activity::Activity; -use bitassets::BitAssets; use coins::Coins; use console_logs::ConsoleLogs; use fonts::FONT_DEFINITIONS; -use messaging::Messaging; use miner::Miner; use parent_chain::ParentChain; use seed::SetSeed; +use simplicity::Simplicity; use util::{BITCOIN_LOGO_FA, BITCOIN_ORANGE, UiExt, show_btc_amount}; /// Bottom panel, if initialized @@ -157,14 +155,13 @@ impl BottomPanel { pub struct EguiApp { activity: Activity, app: Option, - bitassets: BitAssets, bottom_panel: BottomPanel, coins: Coins, console_logs: ConsoleLogs, - messaging: Messaging, miner: Miner, parent_chain: ParentChain, set_seed: SetSeed, + simplicity: Simplicity, tab: Tab, } @@ -175,10 +172,10 @@ enum Tab { ParentChain, #[strum(to_string = "Coins")] Coins, - #[strum(to_string = "BitAssets")] - BitAssets, - #[strum(to_string = "Messaging")] - Messaging, + #[strum(to_string = "BMM")] + Bmm, + #[strum(to_string = "Simplicity")] + Simplicity, #[strum(to_string = "Activity")] Activity, #[strum(to_string = "Console / Logs")] @@ -222,14 +219,13 @@ impl EguiApp { Self { activity, app, - bitassets: BitAssets::default(), bottom_panel, coins, console_logs, - messaging: Messaging::new(), miner: Miner::default(), parent_chain, set_seed: SetSeed::default(), + simplicity: Simplicity::default(), tab: Tab::default(), } } @@ -268,11 +264,11 @@ impl eframe::App for EguiApp { Tab::Coins => { let () = self.coins.show(self.app.as_ref(), ui).unwrap(); } - Tab::BitAssets => { - self.bitassets.show(self.app.as_ref(), ui); + Tab::Bmm => { + self.miner.show(self.app.as_ref(), ui); } - Tab::Messaging => { - self.messaging.show(self.app.as_ref(), ui); + Tab::Simplicity => { + self.simplicity.show(self.app.as_ref(), ui); } Tab::Activity => { self.activity.show(self.app.as_ref(), ui); diff --git a/app/gui/parent_chain/info.rs b/app/gui/parent_chain/info.rs index 7f794eb6..86123afe 100644 --- a/app/gui/parent_chain/info.rs +++ b/app/gui/parent_chain/info.rs @@ -1,6 +1,6 @@ use eframe::egui::{self, Button}; use futures::FutureExt; -use plain_bitassets::types::proto::mainchain; +use sidechain_utilities::types::proto::mainchain; use crate::{app::App, gui::util::UiExt}; @@ -76,5 +76,13 @@ impl Info { parent_chain_info.sidechain_wealth.to_string(), ) }); + ui.horizontal(|ui| { + ui.monospace("Enforcer status: "); + ui.monospace("healthy (localhost:50051)"); + }); + ui.horizontal(|ui| { + ui.monospace("Sidechain slot: "); + ui.monospace("ID5 (Liquid)"); + }); } } diff --git a/app/gui/simplicity.rs b/app/gui/simplicity.rs new file mode 100644 index 00000000..bb388d3a --- /dev/null +++ b/app/gui/simplicity.rs @@ -0,0 +1,266 @@ +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; + +use eframe::egui::{self, ComboBox}; + +use crate::app::App; + +use super::util::UiExt; + +const MINIMAL_PROGRAM_HEX: &str = "e0094081020408102040810205b46da080"; +const CMR: &str = "8745774d6c695d360bb788311e7a0396d397bcbb6ac4ef02916b6468ef28a4f4"; + +const DEFAULT_PYTHON_SCRIPT: &str = + "/Volumes/T705/code/liquid-signet-sidechain/drivechain-liquid-sidechain/tests/simplicity_e2e_tx.py"; +const DEFAULT_ELEMENTS_CLI: &str = + "/Volumes/T705/code/liquid-signet-sidechain/src/elements-cli"; + +fn resolve_script_path() -> String { + if let Ok(p) = std::env::var("SIMPLICITY_E2E_SCRIPT") { + return p; + } + if let Some(home) = std::env::var_os("HOME") { + let candidate = std::path::Path::new(&home) + .join(".config/plain-bitassets/simplicity_e2e_tx.py"); + if candidate.exists() { + return candidate.to_string_lossy().into_owned(); + } + } + DEFAULT_PYTHON_SCRIPT.to_string() +} + +fn resolve_elements_cli() -> String { + std::env::var("ELEMENTS_CLI_BIN").unwrap_or_else(|_| DEFAULT_ELEMENTS_CLI.to_string()) +} + +#[derive(Default)] +pub struct Simplicity { + selected_program: String, + amount: String, + result: Arc>>>, + running: Arc, +} + +impl Simplicity { + pub fn new() -> Self { + Self { + selected_program: "Minimal unit program (test.c:642)".to_string(), + amount: "0.001".to_string(), + result: Arc::new(Mutex::new(None)), + running: Arc::new(AtomicBool::new(false)), + } + } + + pub fn show(&mut self, app: Option<&App>, ui: &mut egui::Ui) { + ui.heading("Simplicity TX Builder"); + ui.add_space(8.0); + + // Program dropdown + ui.horizontal(|ui| { + ui.label("Program:"); + ComboBox::from_id_salt("simplicity_program") + .selected_text(&self.selected_program) + .width(320.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.selected_program, + "Minimal unit program (test.c:642)".to_string(), + "Minimal unit program (test.c:642)", + ); + }); + }); + + ui.add_space(4.0); + + // Program bytes (read-only) + ui.horizontal(|ui| { + ui.label("Program bytes (hex):"); + let mut hex = MINIMAL_PROGRAM_HEX.to_string(); + ui.add( + egui::TextEdit::singleline(&mut hex) + .font(egui::TextStyle::Monospace) + .desired_width(380.0) + .interactive(false), + ); + }); + + // CMR (read-only) + ui.horizontal(|ui| { + ui.label("CMR:"); + let mut cmr = CMR.to_string(); + ui.add( + egui::TextEdit::singleline(&mut cmr) + .font(egui::TextStyle::Monospace) + .desired_width(380.0) + .interactive(false), + ); + }); + + ui.add_space(4.0); + + // Amount (L-BTC) + ui.horizontal(|ui| { + ui.label("Amount (L-BTC):"); + ui.add( + egui::TextEdit::singleline(&mut self.amount) + .desired_width(80.0) + .hint_text("0.001"), + ); + }); + + ui.add_space(8.0); + + let is_running = self.running.load(Ordering::SeqCst); + let can_send = app.is_some() && !is_running; + + if ui + .add_enabled(can_send, egui::Button::new("Send Simplicity TX")) + .clicked() + { + if let Some(app) = app { + self.send_tx(app); + } + } + + if is_running { + ui.label( + egui::RichText::new("Sending... (mining blocks, broadcasting 0xbe tx — may take 1-2 minutes)") + .italics(), + ); + } + + // Result area + let mut result_guard = self.result.lock().unwrap(); + if let Some(res) = result_guard.as_ref() { + ui.add_space(8.0); + ui.separator(); + ui.add_space(4.0); + match res { + Ok(txid) => { + ui.colored_label(egui::Color32::from_rgb(0x2e, 0x7d, 0x32), "✓ Success"); + ui.horizontal(|ui| { + ui.label("txid:"); + ui.monospace_selectable_singleline(false, txid.as_str()); + }); + ui.label( + egui::RichText::new( + "Check elementsd with: elements-cli -regtest getrawtransaction 1", + ) + .small() + .weak(), + ); + } + Err(err) => { + ui.colored_label(egui::Color32::from_rgb(0xc6, 0x28, 0x28), "✗ Error"); + // Show error, possibly truncated + let display_err = if err.len() > 2000 { + format!("{}...\n[truncated]", &err[..2000]) + } else { + err.clone() + }; + ui.monospace_selectable_multiline(display_err.as_str()); + } + } + } + + // Clear button when not running and have result + if result_guard.is_some() && !is_running { + ui.add_space(4.0); + if ui.button("Clear result").clicked() { + *result_guard = None; + } + } + drop(result_guard); + } + + fn send_tx(&mut self, app: &App) { + // Clear previous result + *self.result.lock().unwrap() = None; + + let result = self.result.clone(); + let running = self.running.clone(); + // amount captured for future use / logging + let _amount = self.amount.clone(); + + app.runtime.spawn_blocking(move || { + running.store(true, Ordering::SeqCst); + + let script = resolve_script_path(); + let elements_cli = resolve_elements_cli(); + let repo_root = std::env::var("SIMPLICITY_REPO_ROOT") + .unwrap_or_else(|_| "/Volumes/T705/code/liquid-signet-sidechain".to_string()); + let output = std::process::Command::new("python3") + .arg(&script) + .env("REPO_ROOT", &repo_root) + .env("LIQUID_ID5_DATADIR", "/tmp/liquid-id5-regtest") + .env("LIQUID_ID5_RPCPORT", "18443") + .env("ELEMENTS_CLI", &elements_cli) + .output(); + + let res = match output { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let combined = if stderr.trim().is_empty() { + stdout.to_string() + } else { + format!("{}\n--- STDERR ---\n{}", stdout, stderr) + }; + + if out.status.success() { + if let Some(txid) = extract_txid(&stdout) { + Ok(txid) + } else { + // Fallback: last non-empty line + let last = stdout + .lines() + .rev() + .find(|l| !l.trim().is_empty()) + .unwrap_or("success (no txid line found)") + .to_string(); + Ok(last) + } + } else { + Err(format!( + "python exited with status {}\n{}", + out.status, combined + )) + } + } + Err(e) => Err(format!("Failed to execute python3: {}", e)), + }; + + *result.lock().unwrap() = Some(res); + running.store(false, Ordering::SeqCst); + }); + } +} + +fn extract_txid(output: &str) -> Option { + // Look for 64-char hex strings near txid mentions, prefer later lines + let lines: Vec<&str> = output.lines().collect(); + for line in lines.iter().rev() { + let lower = line.to_lowercase(); + if lower.contains("txid") || lower.contains("broadcast") || lower.contains("success") { + for word in line.split_whitespace() { + let clean: String = word.chars().filter(|c| c.is_ascii_hexdigit()).collect(); + if clean.len() == 64 { + return Some(clean); + } + } + } + } + // Broad search for any 64 hex in output (last occurrence preferred) + let mut last: Option = None; + for line in &lines { + for word in line.split_whitespace() { + let clean: String = word.chars().filter(|c| c.is_ascii_hexdigit()).collect(); + if clean.len() == 64 { + last = Some(clean); + } + } + } + last +} diff --git a/app/gui/util.rs b/app/gui/util.rs index 4eea0abc..8e361c67 100644 --- a/app/gui/util.rs +++ b/app/gui/util.rs @@ -1,4 +1,4 @@ -use std::borrow::Borrow; + use borsh::BorshDeserialize; use eframe::egui::{self, Color32, InnerResponse, Response, Ui}; @@ -35,11 +35,11 @@ pub trait UiExt { text: Text, ) -> Response where - Text: Borrow; + Text: AsRef; fn monospace_selectable_multiline(&mut self, text: Text) -> Response where - Text: Borrow; + Text: AsRef; } impl InnerResponseExt for InnerResponse { @@ -64,10 +64,10 @@ impl UiExt for Ui { text: Text, ) -> Response where - Text: Borrow, + Text: AsRef, { use egui::{TextEdit, TextStyle, Widget}; - let mut text: &str = text.borrow(); + let mut text: &str = text.as_ref(); TextEdit::singleline(&mut text) .font(TextStyle::Monospace) .clip_text(clip_text) @@ -76,10 +76,10 @@ impl UiExt for Ui { fn monospace_selectable_multiline(&mut self, text: Text) -> Response where - Text: Borrow, + Text: AsRef, { use egui::{TextEdit, TextStyle, Widget}; - let mut text: &str = text.borrow(); + let mut text: &str = text.as_ref(); TextEdit::multiline(&mut text) .font(TextStyle::Monospace) .ui(self) diff --git a/app/main.rs b/app/main.rs index 4724faf7..56db6f80 100644 --- a/app/main.rs +++ b/app/main.rs @@ -121,7 +121,7 @@ fn set_tracing_subscriber( "h2::codec::framed_write", saturating_pred_level(saturating_pred_level(log_level)), ), - ("plain_bitassets", log_level), + ("sidechain_utilities", log_level), ("plain_bitassets_app", log_level), ( "tower::buffer::worker", @@ -215,6 +215,35 @@ fn main() -> anyhow::Result<()> { } } }); + app.runtime.spawn({ + let app = app.clone(); + let lite_wallet_quic_addr = config.lite_wallet_quic_addr; + async move { + tracing::info!( + %lite_wallet_quic_addr, + "starting lite-wallet QUIC server" + ); + if let Err(err) = rpc_server::run_lite_wallet_quic_server( + app, + lite_wallet_quic_addr, + ) + .await + { + tracing::error!("{err:#}"); + } + } + }); + // BitWindow / CUSF SidechainService gRPC (proxies to elementsd) + app.runtime.spawn({ + let app = app.clone(); + let addr = config.sidechain_grpc_addr; + async move { + tracing::info!(%addr, "starting SidechainService gRPC (for BitWindow)"); + if let Err(err) = rpc_server::run_sidechain_grpc_server(app, addr).await { + tracing::error!("sidechain gRPC server error: {err:#}"); + } + } + }); }); if !config.headless { let app = match app { diff --git a/app/rpc_server.rs b/app/rpc_server.rs index 5ae21ecb..a65f757b 100644 --- a/app/rpc_server.rs +++ b/app/rpc_server.rs @@ -1,26 +1,45 @@ -use std::{borrow::Cow, cmp::Ordering, net::SocketAddr}; +use std::{ + borrow::Cow, + cmp::Ordering, + collections::HashSet, + net::{IpAddr, SocketAddr}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; -use bitcoin::Amount; +use bitcoin::{Amount, hashes::Hash as _}; use fraction::Fraction; +use futures::StreamExt as _; use jsonrpsee::{ core::{RpcResult, async_trait, middleware::RpcServiceBuilder}, server::Server, types::ErrorObject, }; +use serde::{Deserialize, Serialize}; -use plain_bitassets::{ +use sidechain_utilities::{ authorization::{self, Dst, Signature}, - net::Peer, + net::{self, Peer}, state::{self, AmmPair, AmmPoolState, BitAssetSeqId, DutchAuctionState}, types::{ - Address, AssetId, Authorization, BitAssetData, BitAssetId, Block, - BlockHash, DutchAuctionId, DutchAuctionParams, EncryptionPubKey, - FilledOutputContent, PointedOutput, Transaction, Txid, VerifyingKey, - WithdrawalBundle, keys::Ecies, + Address, AssetId, Authorization, AuthorizedTransaction, BitAssetData, + BitAssetId, Block, BlockHash, DutchAuctionId, DutchAuctionParams, + EncryptionPubKey, FilledOutput, FilledOutputContent, Network, OutPoint, + PointedOutput, Transaction, Txid, VerifyingKey, WithdrawalBundle, + keys::Ecies, }, wallet::Balance, }; -use plain_bitassets_app_rpc_api::{RpcServer, TxInfo}; +use plain_bitassets_app_rpc_api::{ + FLORESTA_UTREEXO_ANCHOR_SERVICES, FlorestaUtreexoAnchor, + FlorestaUtreexoPeerSource, LiteWalletProofRef, LiteWalletUpdate, + LiteWalletUtreexoProof, RpcServer, TxInfo, TxProof, +}; +use rustreexo::{ + node_hash::BitcoinNodeHash, + pollard::{Pollard, PollardAddition}, + proof::Proof, + stump::Stump, +}; use tower_http::{ request_id::{ MakeRequestId, PropagateRequestIdLayer, RequestId, SetRequestIdLayer, @@ -28,6 +47,16 @@ use tower_http::{ trace::{DefaultOnFailure, DefaultOnResponse, TraceLayer}, }; +use futures::stream::{self, Stream}; +use std::pin::Pin; +use tonic::{Request, Response, Status}; + +use sidechain_utilities::types::proto::sidechain::generated::{ + self as sidechain, + sidechain_service_server::{SidechainService, SidechainServiceServer}, + *, +}; + use crate::app::App; fn custom_err_msg(err_msg: impl Into) -> ErrorObject<'static> { @@ -42,10 +71,495 @@ where custom_err_msg(format!("{error:#}")) } +fn unix_time_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn ensure_private_signet(network: Network) -> RpcResult<()> { + if network == Network::Signet { + Ok(()) + } else { + Err(custom_err_msg(format!( + "private signet Utreexo anchors are only available on signet; node is running {network:?}" + ))) + } +} + pub struct RpcServerImpl { app: App, } +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LiteWalletQuicRequest { + Subscribe { + script_hashes: Vec, + from_block_hash: Option, + }, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LiteWalletQuicResponse { + Snapshot { update: LiteWalletUpdate }, + Mempool { update: LiteWalletUpdate }, + Confirmed { update: LiteWalletUpdate }, + Error { message: String }, +} + +const LITE_WALLET_QUIC_MAX_REQUEST_BYTES: usize = 64 * 1024; +const LITE_WALLET_QUIC_MEMPOOL_POLL_INTERVAL: Duration = Duration::from_secs(2); +const LITE_WALLET_MAX_SCRIPT_HASHES: usize = 256; + +fn normalize_lite_wallet_script_hashes( + script_hashes: Vec, +) -> RpcResult> { + if script_hashes.is_empty() { + return Err(custom_err_msg( + "get_lite_wallet_update requires at least one script hash", + )); + } + if script_hashes.len() > LITE_WALLET_MAX_SCRIPT_HASHES { + return Err(custom_err_msg(format!( + "get_lite_wallet_update accepts at most {LITE_WALLET_MAX_SCRIPT_HASHES} script hashes" + ))); + } + + let mut watched = HashSet::with_capacity(script_hashes.len()); + for script_hash in script_hashes { + let script_hash = script_hash.to_ascii_lowercase(); + let decoded = hex::decode(&script_hash).map_err(|err| { + custom_err_msg(format!( + "script hash {script_hash} is not valid hex: {err}" + )) + })?; + if decoded.len() != 32 { + return Err(custom_err_msg(format!( + "script hash {script_hash} must be 32 bytes, got {} bytes", + decoded.len() + ))); + } + watched.insert(script_hash); + } + Ok(watched) +} + +fn ensure_lite_wallet_cursor_on_active_chain( + from_block_hash: BlockHash, + active_hash_at_height: Option, +) -> RpcResult<()> { + match active_hash_at_height { + Some(active_hash_at_height) + if active_hash_at_height == from_block_hash => + { + Ok(()) + } + Some(active_hash_at_height) => Err(custom_err_msg(format!( + "from_block_hash {from_block_hash} is no longer on the active sidechain at its height; active hash is {active_hash_at_height}; resync from snapshot" + ))), + None => Err(custom_err_msg(format!( + "from_block_hash {from_block_hash} height is no longer available on the active sidechain; resync from snapshot" + ))), + } +} + +fn advertised_addr(bind_addr: SocketAddr, fallback_ip: IpAddr) -> SocketAddr { + if bind_addr.ip().is_unspecified() { + SocketAddr::new(fallback_ip, bind_addr.port()) + } else { + bind_addr + } +} + +impl RpcServerImpl { + fn script_hash(address: &Address) -> String { + hex::encode(blake3::hash(&address.0).as_bytes()) + } + + fn lite_wallet_leaf_hash( + outpoint: &OutPoint, + output: &FilledOutput, + proof_ref: &LiteWalletProofRef, + ) -> BitcoinNodeHash { + let content = match &output.content { + FilledOutputContent::BitAsset(bitasset_id, amount) => { + format!("bitasset:{}:{amount}", hex::encode(bitasset_id.0)) + } + FilledOutputContent::BitAssetControl(bitasset_id) => { + format!("bitasset-control:{}", hex::encode(bitasset_id.0)) + } + FilledOutputContent::AmmLpToken { + asset0, + asset1, + amount, + } => { + format!("amm-lp:{asset0}:{asset1}:{amount}") + } + FilledOutputContent::Bitcoin(value) => { + format!("bitcoin:{}", value.0.to_sat()) + } + FilledOutputContent::BitcoinWithdrawal(withdrawal) => { + format!("withdrawal:{withdrawal:?}") + } + FilledOutputContent::BitAssetReservation(txid, commitment) => { + format!("reservation:{txid}:{}", hex::encode(commitment)) + } + FilledOutputContent::DutchAuctionReceipt(auction_id) => { + format!("dutch-auction:{auction_id}") + } + }; + let payload = borsh::to_vec(&( + "plain-bitassets:lite-wallet-leaf:v1", + outpoint.to_string(), + output.address.0, + content, + output.memo.clone(), + proof_ref.sidechain_block_height.unwrap_or_default(), + proof_ref.block_hash.clone().unwrap_or_default(), + )) + .expect("lite-wallet leaf payload is always borsh-serializable"); + BitcoinNodeHash::from(*blake3::hash(&payload).as_bytes()) + } + + fn utreexo_view( + &self, + utxos: &std::collections::HashMap, + ) -> RpcResult<( + u64, + Vec, + std::collections::HashMap, + )> { + let mut pollard = Pollard::new(); + let mut stump = Stump::new(); + let mut leaves = Vec::new(); + let mut leaf_by_outpoint = std::collections::HashMap::new(); + for (outpoint, output) in utxos { + let txid = match outpoint { + OutPoint::Regular { txid, vout: _ } => Some(*txid), + OutPoint::Coinbase { .. } | OutPoint::Deposit(_) => None, + }; + let proof_ref = match txid { + Some(txid) => self.lite_wallet_proof_ref(txid)?, + None => LiteWalletProofRef { + txid: Txid([0; 32]), + block_hash: None, + sidechain_block_height: None, + bmm_inclusions: Vec::new(), + best_main_verification: None, + }, + }; + let leaf_hash = + Self::lite_wallet_leaf_hash(outpoint, output, &proof_ref); + leaves.push(PollardAddition { + hash: leaf_hash, + remember: true, + }); + leaf_by_outpoint.insert(*outpoint, leaf_hash); + } + pollard + .modify(&leaves, &[], Proof::default()) + .map_err(|err| { + custom_err_msg(format!("utreexo pollard modify: {err:?}")) + })?; + let add_hashes: Vec<_> = leaves.iter().map(|leaf| leaf.hash).collect(); + stump = stump + .modify(&add_hashes, &[], &Proof::default()) + .map_err(|err| { + custom_err_msg(format!("utreexo stump modify: {err:?}")) + })? + .0; + let mut proofs = std::collections::HashMap::new(); + for (outpoint, leaf_hash) in leaf_by_outpoint { + let proof = pollard.batch_proof(&[leaf_hash]).map_err(|err| { + custom_err_msg(format!("utreexo proof: {err:?}")) + })?; + proofs.insert(outpoint, (leaf_hash.to_string(), proof)); + } + Ok(( + stump.leaves, + stump.roots.iter().map(ToString::to_string).collect(), + proofs, + )) + } + + fn lite_wallet_proof_ref( + &self, + txid: Txid, + ) -> RpcResult { + let Some((_, txin)) = self + .app + .node + .try_get_filled_transaction(txid) + .map_err(custom_err)? + else { + return Ok(LiteWalletProofRef { + txid, + block_hash: None, + sidechain_block_height: None, + bmm_inclusions: Vec::new(), + best_main_verification: None, + }); + }; + let Some(txin) = txin else { + return Ok(LiteWalletProofRef { + txid, + block_hash: None, + sidechain_block_height: None, + bmm_inclusions: Vec::new(), + best_main_verification: None, + }); + }; + let sidechain_block_height = self + .app + .node + .get_height(txin.block_hash) + .map_err(custom_err)?; + let bmm_inclusions = self + .app + .node + .get_bmm_inclusions(txin.block_hash) + .map_err(custom_err)? + .into_iter() + .map(|block_hash| block_hash.to_string()) + .collect(); + let best_main_verification = self + .app + .node + .get_best_main_verification(txin.block_hash) + .map_err(custom_err)? + .to_string(); + Ok(LiteWalletProofRef { + txid, + block_hash: Some(txin.block_hash.to_string()), + sidechain_block_height: Some(sidechain_block_height), + bmm_inclusions, + best_main_verification: Some(best_main_verification), + }) + } + + fn lite_wallet_update( + &self, + script_hashes: Vec, + from_block_hash: Option, + ) -> RpcResult { + let watched = normalize_lite_wallet_script_hashes(script_hashes)?; + let tip_hash = self + .app + .node + .try_get_tip() + .map_err(custom_err)? + .map(|hash| hash.to_string()); + let tip_height = + self.app.node.try_get_tip_height().map_err(custom_err)?; + + let mut created_utxos = Vec::new(); + let mut spent_outpoints = Vec::new(); + let mut transactions = Vec::new(); + let mut proof_refs = Vec::new(); + let all_confirmed_utxos = + self.app.node.get_all_utxos().map_err(custom_err)?; + let confirmed_watched_utxos: std::collections::HashMap<_, _> = + all_confirmed_utxos + .iter() + .filter(|(_, output)| { + watched.contains(&Self::script_hash(&output.address)) + }) + .map(|(outpoint, output)| (*outpoint, output.clone())) + .collect(); + let (utreexo_leaf_count, utreexo_roots, utreexo_proof_map) = + self.utreexo_view(&all_confirmed_utxos)?; + + match (from_block_hash, tip_height) { + (None, _) => { + created_utxos = confirmed_watched_utxos + .iter() + .map(|(outpoint, output)| PointedOutput { + outpoint: *outpoint, + output: output.clone(), + }) + .collect(); + for txid in confirmed_watched_utxos + .keys() + .filter_map(|outpoint| match outpoint { + sidechain_utilities::types::OutPoint::Regular { + txid, + vout: _, + } => Some(*txid), + sidechain_utilities::types::OutPoint::Coinbase { + .. + } + | sidechain_utilities::types::OutPoint::Deposit(_) => None, + }) + .collect::>() + { + if let Some((filled_tx, _)) = self + .app + .node + .try_get_filled_transaction(txid) + .map_err(custom_err)? + { + transactions.push(filled_tx.transaction.transaction); + } + proof_refs.push(self.lite_wallet_proof_ref(txid)?); + } + } + (Some(from_block_hash), Some(tip_height)) => { + let from_block_hash: BlockHash = + from_block_hash.parse().map_err(custom_err)?; + let from_height = self + .app + .node + .try_get_height(from_block_hash) + .map_err(custom_err)? + .ok_or_else(|| { + custom_err_msg(format!( + "from_block_hash {from_block_hash} is not known" + )) + })?; + let active_hash_at_from_height = self + .app + .node + .try_get_block_hash(from_height) + .map_err(custom_err)?; + ensure_lite_wallet_cursor_on_active_chain( + from_block_hash, + active_hash_at_from_height, + )?; + for height in from_height.saturating_add(1)..=tip_height { + let Some(block_hash) = self + .app + .node + .try_get_block_hash(height) + .map_err(custom_err)? + else { + continue; + }; + let body = self + .app + .node + .get_body(block_hash) + .map_err(custom_err)?; + for tx in body.transactions { + let txid = tx.txid(); + let filled_tx = self + .app + .node + .try_get_filled_transaction(txid) + .map_err(custom_err)? + .map(|(filled_tx, _)| filled_tx.transaction); + let Some(filled_tx) = filled_tx else { + continue; + }; + + let mut relevant = false; + for (outpoint, spent_output) in filled_tx + .inputs() + .iter() + .zip(filled_tx.spent_utxos.iter()) + { + if watched.contains(&Self::script_hash( + &spent_output.address, + )) { + spent_outpoints.push(*outpoint); + relevant = true; + } + } + if let Some(filled_outputs) = filled_tx.filled_outputs() + { + for (vout, output) in + filled_outputs.into_iter().enumerate() + { + if watched.contains(&Self::script_hash( + &output.address, + )) { + created_utxos.push(PointedOutput { + outpoint: sidechain_utilities::types::OutPoint::Regular { + txid, + vout: vout as u32, + }, + output, + }); + relevant = true; + } + } + } + if relevant { + transactions.push(tx); + proof_refs.push(self.lite_wallet_proof_ref(txid)?); + } + } + } + } + (Some(_), None) => (), + } + + let mempool_transactions = + self.app.node.get_all_transactions().map_err(custom_err)?; + let mut mempool_created_utxos = Vec::new(); + for tx in &mempool_transactions { + let txid = tx.transaction.txid(); + for (vout, output) in tx.transaction.outputs.iter().enumerate() { + if watched.contains(&Self::script_hash(&output.address)) { + mempool_created_utxos.push(PointedOutput { + outpoint: OutPoint::Regular { + txid, + vout: vout as u32, + }, + output: output.clone(), + }); + } + } + } + + let watched_unspent_outpoints: Vec<_> = confirmed_watched_utxos + .keys() + .chain(mempool_created_utxos.iter().map(|utxo| &utxo.outpoint)) + .collect(); + let mempool_spent_outpoints = self + .app + .node + .get_unconfirmed_spent_utxos(watched_unspent_outpoints) + .map_err(custom_err)? + .into_iter() + .map(|(outpoint, _)| outpoint) + .collect(); + let utreexo_proofs = created_utxos + .iter() + .filter_map(|utxo| { + let (leaf_hash, proof) = + utreexo_proof_map.get(&utxo.outpoint)?.clone(); + Some(LiteWalletUtreexoProof { + outpoint: utxo.outpoint, + leaf_hash, + targets: proof.targets, + hashes: proof + .hashes + .iter() + .map(ToString::to_string) + .collect(), + }) + }) + .collect(); + + Ok(LiteWalletUpdate { + tip_hash, + tip_height, + utreexo_leaf_count, + utreexo_roots, + created_utxos, + spent_outpoints, + mempool_created_utxos, + mempool_spent_outpoints, + transactions, + proof_refs, + utreexo_proofs, + }) + } +} + #[async_trait] impl RpcServer for RpcServerImpl { async fn amm_burn( @@ -85,11 +599,24 @@ impl RpcServer for RpcServerImpl { amount0: u64, amount1: u64, ) -> RpcResult { - let amm_pool_state = self.get_amm_pool_state(asset0, asset1).await?; - let next_amm_pool_state = - amm_pool_state.mint(amount0, amount1).map_err(custom_err)?; - let lp_token_mint = next_amm_pool_state.outstanding_lp_tokens - - amm_pool_state.outstanding_lp_tokens; + let pair = AmmPair::new(asset0, asset1); + let lp_token_mint = match self + .app + .node + .try_get_amm_pool_state(pair) + .map_err(custom_err)? + { + Some(amm_pool_state) => { + let next_amm_pool_state = amm_pool_state + .mint(amount0, amount1) + .map_err(custom_err)?; + next_amm_pool_state.outstanding_lp_tokens + - amm_pool_state.outstanding_lp_tokens + } + None => num::integer::sqrt(amount0 as u128 * amount1 as u128) + .try_into() + .map_err(custom_err)?, + }; let mut tx = Transaction::default(); let () = self .app @@ -119,13 +646,13 @@ impl RpcServer for RpcServerImpl { let amount_receive = (if asset_spend < asset_receive { amm_pool_state.swap_asset0_for_asset1(amount_spend).map( |new_amm_pool_state| { - new_amm_pool_state.reserve1 - amm_pool_state.reserve1 + amm_pool_state.reserve1 - new_amm_pool_state.reserve1 }, ) } else { amm_pool_state.swap_asset1_for_asset0(amount_spend).map( |new_amm_pool_state| { - new_amm_pool_state.reserve0 - amm_pool_state.reserve0 + amm_pool_state.reserve0 - new_amm_pool_state.reserve0 }, ) }) @@ -397,7 +924,7 @@ impl RpcServer for RpcServerImpl { async fn get_bmm_inclusions( &self, - block_hash: plain_bitassets::types::BlockHash, + block_hash: sidechain_utilities::types::BlockHash, ) -> RpcResult> { self.app .node @@ -467,6 +994,88 @@ impl RpcServer for RpcServerImpl { Ok(Some(res)) } + async fn get_transaction_proof( + &self, + txid: Txid, + ) -> RpcResult> { + let Some((filled_tx, txin)) = self + .app + .node + .try_get_filled_transaction(txid) + .map_err(custom_err)? + else { + return Ok(None); + }; + + let ( + confirmations, + block, + sidechain_block_height, + bmm_inclusions, + best_main_verification, + ) = match txin { + Some(txin) => { + let tip_height = self + .app + .node + .try_get_tip_height() + .map_err(custom_err)? + .expect("Height should exist for tip"); + let height = self + .app + .node + .get_height(txin.block_hash) + .map_err(custom_err)?; + let block = self + .app + .node + .get_block(txin.block_hash) + .map_err(custom_err)?; + let bmm_inclusions = self + .app + .node + .get_bmm_inclusions(txin.block_hash) + .map_err(custom_err)?; + let best_main_verification = self + .app + .node + .get_best_main_verification(txin.block_hash) + .map_err(custom_err)?; + + ( + Some(tip_height - height), + Some(block), + Some(height), + bmm_inclusions + .into_iter() + .map(|block_hash| block_hash.to_string()) + .collect(), + Some(best_main_verification.to_string()), + ) + } + None => (None, None, None, Vec::new(), None), + }; + + let fee_sats = filled_tx + .transaction + .bitcoin_fee() + .map_err(custom_err)? + .unwrap() + .to_sat(); + + Ok(Some(TxProof { + txid, + transaction: filled_tx.transaction.transaction, + txin, + block, + sidechain_block_height, + bmm_inclusions, + best_main_verification, + confirmations, + fee_sats, + })) + } + async fn get_wallet_addresses(&self) -> RpcResult> { let addrs = self.app.wallet.get_addresses().map_err(custom_err)?; let mut res: Vec<_> = addrs.into_iter().collect(); @@ -507,6 +1116,78 @@ impl RpcServer for RpcServerImpl { Ok(peers) } + async fn private_signet_utreexo_anchors( + &self, + peers: Vec, + ) -> RpcResult> { + ensure_private_signet(self.app.node.network())?; + let now = unix_time_secs(); + Ok(peers + .into_iter() + .map(|peer| FlorestaUtreexoAnchor::from_socket_addr(peer, now)) + .collect()) + } + + async fn private_signet_active_utreexo_anchors( + &self, + ) -> RpcResult> { + ensure_private_signet(self.app.node.network())?; + let now = unix_time_secs(); + let anchors = self + .app + .node + .get_active_peers() + .into_iter() + .map(|peer| { + FlorestaUtreexoAnchor::from_socket_addr(peer.address, now) + }) + .collect(); + Ok(anchors) + } + + async fn private_signet_utreexo_peer_source( + &self, + peer: SocketAddr, + ) -> RpcResult { + ensure_private_signet(self.app.network)?; + if peer.ip().is_unspecified() { + return Err(custom_err_msg(format!( + "private signet Utreexo peer address must be externally reachable; got {peer}" + ))); + } + let advertised_rpc_addr = advertised_addr( + SocketAddr::new( + self.app + .rpc_url + .host_str() + .and_then(|host| host.parse().ok()) + .unwrap_or(peer.ip()), + self.app.rpc_url.port_or_known_default().unwrap_or(80), + ), + peer.ip(), + ); + let advertised_bitassets_p2p_addr = + advertised_addr(self.app.net_addr, peer.ip()); + let advertised_lite_wallet_quic_addr = + advertised_addr(self.app.lite_wallet_quic_addr, peer.ip()); + Ok(FlorestaUtreexoPeerSource { + network: "signet".to_string(), + anchor: FlorestaUtreexoAnchor::from_socket_addr( + peer, + unix_time_secs(), + ), + services: FLORESTA_UTREEXO_ANCHOR_SERVICES, + service_names: vec![ + "NODE_NETWORK_LIMITED".to_string(), + "NODE_WITNESS".to_string(), + "UTREEXO".to_string(), + ], + bitassets_rpc_url: format!("http://{advertised_rpc_addr}/"), + bitassets_p2p_addr: advertised_bitassets_p2p_addr.to_string(), + lite_wallet_quic_addr: advertised_lite_wallet_quic_addr.to_string(), + }) + } + async fn list_utxos( &self, ) -> RpcResult>> { @@ -518,6 +1199,14 @@ impl RpcServer for RpcServerImpl { Ok(res) } + async fn get_lite_wallet_update( + &self, + script_hashes: Vec, + from_block_hash: Option, + ) -> RpcResult { + self.lite_wallet_update(script_hashes, from_block_hash) + } + async fn mine(&self, fee: Option) -> RpcResult<()> { let fee = fee.map(bitcoin::Amount::from_sat); self.app @@ -644,6 +1333,21 @@ impl RpcServer for RpcServerImpl { .map_err(custom_err) } + async fn submit_authorized_transaction( + &self, + hex_borsh_authorized_tx: String, + ) -> RpcResult { + let bytes = hex::decode(hex_borsh_authorized_tx).map_err(custom_err)?; + let authorized_tx: AuthorizedTransaction = + borsh::from_slice(&bytes).map_err(custom_err)?; + let txid = authorized_tx.transaction.txid(); + self.app + .node + .submit_transaction(authorized_tx) + .map_err(custom_err)?; + Ok(txid) + } + async fn stop(&self) { std::process::exit(0); } @@ -832,3 +1536,366 @@ pub async fn run_server( Ok(addr) } + +pub async fn run_lite_wallet_quic_server( + app: App, + bind_addr: SocketAddr, +) -> anyhow::Result<()> { + let (endpoint, _server_cert) = net::make_server_endpoint(bind_addr)?; + while let Some(connecting) = endpoint.accept().await { + let app = app.clone(); + tokio::spawn(async move { + if let Err(err) = + handle_lite_wallet_quic_connection(app, connecting).await + { + tracing::warn!("lite-wallet QUIC connection failed: {err:#}"); + } + }); + } + Ok(()) +} + +async fn handle_lite_wallet_quic_connection( + app: App, + connecting: quinn::Incoming, +) -> anyhow::Result<()> { + let connection = connecting.await?; + let (mut send, mut recv) = connection.accept_bi().await?; + let request_bytes = match recv + .read_to_end(LITE_WALLET_QUIC_MAX_REQUEST_BYTES) + .await + { + Ok(request_bytes) => request_bytes, + Err(err) => { + write_lite_wallet_quic_response( + &mut send, + &LiteWalletQuicResponse::Error { + message: format!( + "lite-wallet request exceeds {LITE_WALLET_QUIC_MAX_REQUEST_BYTES} bytes or could not be read: {err}" + ), + }, + ) + .await?; + send.finish()?; + return Ok(()); + } + }; + let request = + match serde_json::from_slice::(&request_bytes) { + Ok(request) => request, + Err(err) => { + write_lite_wallet_quic_response( + &mut send, + &LiteWalletQuicResponse::Error { + message: format!("invalid lite-wallet request: {err}"), + }, + ) + .await?; + send.finish()?; + return Ok(()); + } + }; + + let LiteWalletQuicRequest::Subscribe { + script_hashes, + from_block_hash, + } = request; + let rpc = RpcServerImpl { app: app.clone() }; + let mut last_tip_hash = from_block_hash; + match rpc.lite_wallet_update(script_hashes.clone(), last_tip_hash.clone()) { + Ok(update) => { + last_tip_hash = update.tip_hash.clone(); + write_lite_wallet_quic_response( + &mut send, + &LiteWalletQuicResponse::Snapshot { update }, + ) + .await?; + } + Err(err) => { + write_lite_wallet_quic_response( + &mut send, + &LiteWalletQuicResponse::Error { + message: err.to_string(), + }, + ) + .await?; + send.finish()?; + return Ok(()); + } + } + + let mut state_changes = Box::pin(app.node.watch_state()); + let mut mempool_poll = + tokio::time::interval(LITE_WALLET_QUIC_MEMPOOL_POLL_INTERVAL); + loop { + tokio::select! { + Some(()) = state_changes.next() => { + match rpc.lite_wallet_update(script_hashes.clone(), last_tip_hash.clone()) { + Ok(update) => { + last_tip_hash = update.tip_hash.clone(); + write_lite_wallet_quic_response( + &mut send, + &LiteWalletQuicResponse::Confirmed { update }, + ) + .await?; + } + Err(err) => { + write_lite_wallet_quic_response( + &mut send, + &LiteWalletQuicResponse::Error { + message: err.to_string(), + }, + ) + .await?; + } + } + } + _ = mempool_poll.tick() => { + match rpc.lite_wallet_update(script_hashes.clone(), last_tip_hash.clone()) { + Ok(update) + if !update.mempool_created_utxos.is_empty() + || !update.mempool_spent_outpoints.is_empty() => + { + write_lite_wallet_quic_response( + &mut send, + &LiteWalletQuicResponse::Mempool { update }, + ) + .await?; + } + Ok(_) => {} + Err(err) => { + write_lite_wallet_quic_response( + &mut send, + &LiteWalletQuicResponse::Error { + message: err.to_string(), + }, + ) + .await?; + } + } + } + else => break, + } + } + Ok(()) +} + +async fn write_lite_wallet_quic_response( + send: &mut quinn::SendStream, + response: &LiteWalletQuicResponse, +) -> anyhow::Result<()> { + // Lite-wallet QUIC currently uses one JSON message per line on a + // bidirectional stream. The live smoke expects this newline framing until + // the protocol graduates to a compact binary envelope. + let mut bytes = serde_json::to_vec(response)?; + bytes.push(b'\n'); + send.write_all(&bytes).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_script_hash(byte: u8) -> String { + hex::encode([byte; 32]) + } + + #[test] + fn lite_wallet_watch_set_rejects_empty() { + let err = normalize_lite_wallet_script_hashes(Vec::new()) + .expect_err("empty watch set must be rejected"); + assert!(err.to_string().contains("at least one script hash")); + } + + #[test] + fn lite_wallet_watch_set_rejects_oversized() { + let hashes = (0..=LITE_WALLET_MAX_SCRIPT_HASHES) + .map(|i| valid_script_hash(i as u8)) + .collect(); + let err = normalize_lite_wallet_script_hashes(hashes) + .expect_err("oversized watch set must be rejected"); + assert!(err.to_string().contains("accepts at most")); + } + + #[test] + fn lite_wallet_watch_set_rejects_malformed_script_hash() { + let err = + normalize_lite_wallet_script_hashes(vec!["not-hex".to_string()]) + .expect_err("malformed script hash must be rejected"); + assert!(err.to_string().contains("not valid hex")); + } + + #[test] + fn lite_wallet_watch_set_rejects_wrong_length_script_hash() { + let err = + normalize_lite_wallet_script_hashes(vec![hex::encode([7; 31])]) + .expect_err("short script hash must be rejected"); + assert!(err.to_string().contains("must be 32 bytes")); + } + + #[test] + fn lite_wallet_watch_set_normalizes_and_deduplicates() { + let upper = valid_script_hash(0xaa).to_ascii_uppercase(); + let lower = valid_script_hash(0xaa); + let watched = + normalize_lite_wallet_script_hashes(vec![upper, lower]).unwrap(); + + assert_eq!(watched.len(), 1); + assert!(watched.contains(&valid_script_hash(0xaa))); + } + + #[test] + fn lite_wallet_cursor_accepts_active_chain_hash() { + let hash: BlockHash = hex::encode([1; 32]).parse().unwrap(); + + ensure_lite_wallet_cursor_on_active_chain(hash, Some(hash)).unwrap(); + } + + #[test] + fn lite_wallet_cursor_rejects_reorged_hash() { + let stale_hash: BlockHash = hex::encode([1; 32]).parse().unwrap(); + let active_hash: BlockHash = hex::encode([2; 32]).parse().unwrap(); + + let err = ensure_lite_wallet_cursor_on_active_chain( + stale_hash, + Some(active_hash), + ) + .expect_err("stale cursor must force snapshot resync"); + + assert!(err.to_string().contains("resync from snapshot")); + assert!( + err.to_string() + .contains("no longer on the active sidechain") + ); + } + + #[test] + fn lite_wallet_cursor_rejects_unavailable_height() { + let stale_hash: BlockHash = hex::encode([1; 32]).parse().unwrap(); + + let err = ensure_lite_wallet_cursor_on_active_chain(stale_hash, None) + .expect_err("unavailable cursor height must force snapshot resync"); + + assert!(err.to_string().contains("resync from snapshot")); + assert!(err.to_string().contains("height is no longer available")); + } +} + +// === cusf.sidechain.v1.SidechainService gRPC server (BitWindow compatibility) === + +#[derive(Clone)] +struct SidechainGrpcImpl { + app: App, +} + +fn sidechain_sequence_id(app: &App) -> u64 { + app.node + .try_get_tip_height() + .ok() + .flatten() + .map(u64::from) + .unwrap_or_default() +} + +fn sidechain_block_header_info(app: &App) -> Option { + let tip_hash = app.node.try_get_tip().ok().flatten()?; + let height = app.node.try_get_tip_height().ok().flatten()?; + let header = app.node.get_header(tip_hash).ok()?; + Some(BlockHeaderInfo { + block_hash: tip_hash.into(), + prev_block_hash: header.prev_side_hash.map(Vec::from).unwrap_or_default(), + prev_main_block_hash: header.prev_main_hash.as_byte_array().to_vec(), + height, + }) +} + +#[tonic::async_trait] +impl SidechainService for SidechainGrpcImpl { + async fn get_mempool_txs( + &self, + _req: Request, + ) -> Result, Status> { + Ok(Response::new(GetMempoolTxsResponse { + sequence_id: Some(SequenceId { + sequence_id: sidechain_sequence_id(&self.app), + }), + })) + } + + async fn get_utxos( + &self, + _req: Request, + ) -> Result, Status> { + self.app.node.get_all_utxos().map_err(|err| { + Status::internal(format!("failed to read BitAssets UTXOs: {err:#}")) + })?; + Ok(Response::new(GetUtxosResponse {})) + } + + async fn submit_transaction( + &self, + req: Request, + ) -> Result, Status> { + let transaction = borsh::from_slice(&req.into_inner().transaction) + .map_err(|err| Status::invalid_argument(format!( + "failed to decode BitAssets authorized transaction: {err}" + )))?; + self.app.node.submit_transaction(transaction).map_err(|err| { + Status::invalid_argument(format!( + "failed to submit BitAssets transaction: {err:#}" + )) + })?; + Ok(Response::new(SubmitTransactionResponse {})) + } + + type SubscribeEventsStream = + Pin> + Send + 'static>>; + + async fn subscribe_events( + &self, + _req: Request, + ) -> Result, Status> { + let sequence_id = sidechain_sequence_id(&self.app); + let header_info = sidechain_block_header_info(&self.app); + let event = header_info.map(|header_info| { + sidechain::subscribe_events_response::Event { + event: Some( + sidechain::subscribe_events_response::event::Event::ConnectBlock( + sidechain::subscribe_events_response::event::ConnectBlock { + header_info: Some(header_info), + block_info: Some(BlockInfo {}), + }, + ), + ), + } + }); + let s = stream::once(async move { + Ok(SubscribeEventsResponse { + sequence_id: Some(SequenceId { sequence_id }), + event, + }) + }); + Ok(Response::new(Box::pin(s))) + } +} + +/// Start the SidechainService gRPC server so BitWindow can connect (localhost:50052 by default). +pub async fn run_sidechain_grpc_server( + app: App, + addr: std::net::SocketAddr, +) -> anyhow::Result<()> { + let svc = SidechainServiceServer::new(SidechainGrpcImpl { app }); + // Health service so clients can discover readiness + let (health_reporter, health_server) = tonic_health::server::health_reporter(); + health_reporter + .set_serving::>() + .await; + + tonic::transport::Server::builder() + .add_service(health_server) + .add_service(svc) + .serve(addr) + .await?; + Ok(()) +} diff --git a/cli/Cargo.toml b/cli/Cargo.toml index aefe2c25..ca96b82d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "plain_bitassets_app_cli" authors.workspace = true +description.workspace = true edition.workspace = true license-file.workspace = true publish.workspace = true @@ -13,7 +14,7 @@ clap = { workspace = true, features = ["derive"] } hex = { workspace = true } http = { workspace = true } jsonrpsee = { workspace = true, features = ["http-client"] } -plain_bitassets = { path = "../lib", features = ["clap"] } +sidechain_utilities = { path = "../lib", features = ["clap"] } plain_bitassets_app_rpc_api = { path = "../rpc-api" } serde_json = { workspace = true } tokio = { workspace = true } diff --git a/cli/lib.rs b/cli/lib.rs index f3962620..f04d358a 100644 --- a/cli/lib.rs +++ b/cli/lib.rs @@ -1,12 +1,14 @@ use std::{ + fs, net::{Ipv4Addr, SocketAddr}, + path::PathBuf, time::Duration, }; use clap::{Parser, Subcommand}; use http::HeaderMap; use jsonrpsee::{core::client::ClientT, http_client::HttpClientBuilder}; -use plain_bitassets::{ +use sidechain_utilities::{ authorization::{Dst, Signature}, types::{ Address, AssetId, BitAssetData, BitAssetId, BlockHash, DutchAuctionId, @@ -14,7 +16,7 @@ use plain_bitassets::{ VerifyingKey, }, }; -use plain_bitassets_app_rpc_api::RpcClient; +use plain_bitassets_app_rpc_api::RpcClient as _; use tracing_subscriber::layer::SubscriberExt as _; use url::{Host, Url}; @@ -113,6 +115,29 @@ pub enum Command { ForgetPeer { addr: SocketAddr, }, + /// Export private-signet Floresta-compatible Utreexo peer anchors. + #[command(name = "export-private-signet-utreexo-anchors")] + ExportPrivateSignetUtreexoAnchors { + /// Explicit private-signet Bitcoin P2P Utreexo peer address. Can be repeated. + #[arg(long = "peer")] + peers: Vec, + /// Use currently active private-signet BitAssets peers as anchor addresses. + #[arg(long)] + active: bool, + /// Write JSON to this path, for example a Floresta data-dir anchors.json. + #[arg(long)] + output: Option, + }, + /// Advertise this BitAssets node as a private-signet Floresta Utreexo peer source. + #[command(name = "private-signet-utreexo-peer-source")] + PrivateSignetUtreexoPeerSource { + /// Externally reachable Bitcoin P2P Utreexo peer address Floresta should anchor. + #[arg(long)] + peer: SocketAddr, + /// Write JSON to this path. + #[arg(long)] + output: Option, + }, /// Format a deposit address FormatDepositAddress { address: Address, @@ -145,7 +170,7 @@ pub enum Command { GetBlockcount, /// Get mainchain blocks that commit to a specified block hash GetBmmInclusions { - block_hash: plain_bitassets::types::BlockHash, + block_hash: sidechain_utilities::types::BlockHash, }, /// Get a new address GetNewAddress, @@ -162,6 +187,10 @@ pub enum Command { GetTransactionInfo { txid: Txid, }, + /// Get proof-oriented archive data for a transaction + GetTransactionProof { + txid: Txid, + }, GetWalletAddresses, /// Get wallet UTXOs GetWalletUtxos, @@ -221,6 +250,10 @@ pub enum Command { #[arg(long)] msg: String, }, + /// Submit an already authorized transaction encoded as canonical Borsh hex + SubmitAuthorizedTransaction { + hex_borsh_authorized_tx: String, + }, /// Stop the node Stop, /// Transfer funds to the specified address @@ -423,6 +456,46 @@ where rpc_client.forget_peer(addr).await?; String::default() } + Command::ExportPrivateSignetUtreexoAnchors { + peers, + active, + output, + } => { + if active && !peers.is_empty() { + anyhow::bail!( + "use either --active or explicit --peer values, not both" + ); + } + if !active && peers.is_empty() { + anyhow::bail!( + "provide at least one --peer address or pass --active" + ); + } + + let anchors = if active { + rpc_client.private_signet_active_utreexo_anchors().await? + } else { + rpc_client.private_signet_utreexo_anchors(peers).await? + }; + let json = format!("{}\n", serde_json::to_string_pretty(&anchors)?); + if let Some(output) = output { + fs::write(output, json)?; + String::default() + } else { + json + } + } + Command::PrivateSignetUtreexoPeerSource { peer, output } => { + let source = + rpc_client.private_signet_utreexo_peer_source(peer).await?; + let json = format!("{}\n", serde_json::to_string_pretty(&source)?); + if let Some(output) = output { + fs::write(output, json)?; + String::default() + } else { + json + } + } Command::FormatDepositAddress { address } => { rpc_client.format_deposit_address(address).await? } @@ -476,6 +549,10 @@ where let tx_info = rpc_client.get_transaction_info(txid).await?; serde_json::to_string_pretty(&tx_info)? } + Command::GetTransactionProof { txid } => { + let tx_proof = rpc_client.get_transaction_proof(txid).await?; + serde_json::to_string_pretty(&tx_proof)? + } Command::GetWalletAddresses => { let addresses = rpc_client.get_wallet_addresses().await?; serde_json::to_string_pretty(&addresses)? @@ -558,6 +635,14 @@ where rpc_client.sign_arbitrary_msg_as_addr(address, msg).await?; serde_json::to_string_pretty(&authorization)? } + Command::SubmitAuthorizedTransaction { + hex_borsh_authorized_tx, + } => { + let txid = rpc_client + .submit_authorized_transaction(hex_borsh_authorized_tx) + .await?; + format!("{txid}") + } Command::Stop => { let () = rpc_client.stop().await?; String::default() diff --git a/docs/bitassets-lite-wallet-pr-notes.md b/docs/bitassets-lite-wallet-pr-notes.md new file mode 100644 index 00000000..6d5830b1 --- /dev/null +++ b/docs/bitassets-lite-wallet-pr-notes.md @@ -0,0 +1,169 @@ +# BitAssets Lite-Wallet PR Notes + +## Public Protocol Surface + +This branch adds the plain-bitassets side of the issue #28 lite-wallet path: + +- `get_lite_wallet_update(script_hashes, from_block_hash)` returns script-hash-scoped wallet deltas from the current sidechain view. +- `submit_authorized_transaction` accepts raw locally signed BitAssets transactions for broadcast. +- `--lite-wallet-quic-addr ` starts the QUIC lite-wallet service in app/headless mode. + +The existing wallet RPCs and delegated wallet flows remain unchanged for compatibility. + +## `get_lite_wallet_update` + +Inputs: + +- `script_hashes`: non-empty array of 32-byte lowercase or uppercase hex script hashes. The server normalizes to lowercase, deduplicates, and rejects more than 256 entries. +- `from_block_hash`: optional sidechain block hash. `null` returns a snapshot for the watched script hashes; a known block hash returns a delta from that point. + +Output includes: + +- current sidechain `tip_hash` and `tip_height` +- confirmed watched UTXOs +- watched spent outpoints +- mempool watched created UTXOs +- mempool watched spent outpoints +- relevant transactions when readily available +- current sidechain Utreexo roots +- proof refs for confirmed watched UTXOs and relevant confirmed transactions + +Invalid watch sets fail consistently for JSON-RPC and QUIC: empty arrays, malformed hex, wrong-length script hashes, and oversized requests are rejected with clear errors. + +## QUIC Lite-Wallet Service + +The QUIC service is a dedicated app-layer lite-wallet transport. It does not replace the existing block-sync P2P path. + +Messages: + +- `Subscribe { script_hashes, from_block_hash }` +- `Snapshot(LiteWalletUpdate)` +- `Mempool(LiteWalletUpdate)` +- `Confirmed(LiteWalletUpdate)` +- `Error { message }` + +Framing is intentionally simple for this PR: one JSON message per line on a bidirectional QUIC stream. Request bodies are capped at 64 KiB. Mempool relevance is polled at a bounded interval, and confirmed updates are pushed from node watch-state notifications. + +## Live Smoke Result + +The coordinating Floresta branch uses the local smoke harness in `local-dev`: + +Full local signet proof-backed Electrum/cache smoke: + +```bash +cd /Volumes/T705/code/drivechain-wallet-dev/local-dev +DOCKER_BUILDKIT=0 \ +BITASSETS_PLATFORM=linux/arm64 \ +BITASSETS_BUILD_PLATFORM=linux/arm64 \ +RESET_STACK=1 \ +RESET_VOLUMES=1 \ +REBUILD_BITASSETS=0 \ +BITASSETS_IMAGE=local/plain-bitassets:codex-proof \ +./scripts/pr-ready-bitassets-smoke.sh +``` + +Latest passing proof-backed result: + +```json +{ + "mainchain_height": 113, + "bitassets_sidechain_height": 5, + "sidechain_activation_height": 108, + "asset_id": "1f7d29cb94f4678610ce298f1d91f07ed1b36201de54e9e43ac67a2d546e287e", + "reserve_tx": "fc15abb51d0c503a7f47cedc8b8e84be367f5230426bc4b283abd5b132799afd", + "register_tx": "7f8ff6a7e5e5d0ecd2ad37dfcde0deb58205138d06a3b2cf84fc2e765a4b4d17", + "transfer_tx": "6cba6f2825f7253ca82b49c3ab7747c701497abcb00131fb9feccfa1daf3883a", + "floresta_wallet_transfer_tx": "b65d4d890f7693ebd947efe49292a8f5ece0f66753afb75f4c4db189b8b980cb", + "checks": [ + {"mode": "rpc-refresh", "balance": 1000, "utxos": 2, "history": 2}, + {"mode": "rpc-refresh-wallet-transfer", "balance": 1000, "utxos": 3, "history": 3}, + {"mode": "persisted-cache", "balance": 1000, "utxos": 3, "history": 3} + ] +} +``` + +This run proved sidechain activation, reserve/register, transfer, Floresta wallet transfer, proof-backed Electrum asset queries, and persisted Floresta cache reload after restart. + +```bash +cd /Users/lukekensik/drivechain-wallet-dev/local-dev +PREPARE_STACK=0 \ +BITASSETS_IMAGE=local/plain-bitassets:codex-proof \ +BITASSETS_QUIC_URL=127.0.0.1:6104 \ +BMM_MINE_ATTEMPTS=8 \ +BMM_REQUEST_SETTLE_SECS=40 \ +BITASSETS_MINE_TIMEOUT=120 \ +./scripts/floresta-bitassets-native-wallet-smoke.sh +``` + +Latest passing result proved QUIC sync, restart persistence, transfer, reserve/register for two assets, AMM mint/swap/burn, and Dutch auction create/bid/collect: + +The post-rebase validation run used the same harness with stack preparation and longer Mac/QEMU wait windows: + +```bash +PREPARE_STACK=1 \ +BITASSETS_IMAGE=local/plain-bitassets:codex-proof \ +BITASSETS_QUIC_URL=127.0.0.1:6104 \ +BMM_MINE_ATTEMPTS=12 \ +BMM_REQUEST_SETTLE_SECS=40 \ +BITASSETS_MINE_TIMEOUT=180 \ +WALLET_WAIT_SECS=240 \ +QUIC_WAIT_SECS=90 \ +./scripts/floresta-bitassets-native-wallet-smoke.sh +``` + +```json +{ + "mode": "native-wallet", + "asset_a": "7c7bc226ca3a53bc549cdb17c6b7002fc2c56c2086e48579598ff6a950ea482f", + "asset_b": "993f25719b66763ffcb36683b58cfa0edd42a9defa0ddb2d3bdd920f5d732c58", + "txids": { + "transfer": "2c50b836c2d49441112060a0a4bc6e6ba0d34a211fc1d61c5b4dcc3a45eeebe1", + "reserve_a": "0a057b47396b4541821ac896713bfe33c0e6cc3aced6166bdf2039a9b8b9082b", + "register_a": "ead5fc378486d91ab32e3e1dcb4e277c18f51db55cdb051ecd1ab642040b8221", + "reserve_b": "772a996b6f957bcaab427ccc853e54e569a6df33a5e62ad162056c09305fa885", + "register_b": "bb48994f50d81c0f5eb325b6227009958415ef08674dedc6a2cb34d4a32eeda9", + "amm_mint": "d9e8a63e925631a6e7a991fc7a643043e0dda4a17214f8d291f59636062e0bc7", + "amm_swap": "4dafb6dbb72638e480cece9d774526364c63f688d65c74eb2d3dce0e6a624cc4", + "amm_burn": "e3480003519a2141945fbf5c6242dc1150c61c6fdc0341b1c055f75ab9731d5f", + "dutch_auction_create": "9e294e6f60c705e7d7f197ca6c85792c2bedfd1436ad18362523fda086204e63", + "dutch_auction_bid": "2b34ef4cde69036fcbf22f93b9cc13e5c5d8ae610d60f6d0daac38438f18decc", + "dutch_auction_collect": "de5d48259183581b9d2ad4b25e928f21c162d134308527dfdc977106242d7c90" + }, + "final_balances": { + "7c7bc226ca3a53bc549cdb17c6b7002fc2c56c2086e48579598ff6a950ea482f": 9090, + "993f25719b66763ffcb36683b58cfa0edd42a9defa0ddb2d3bdd920f5d732c58": 9106, + "control:7c7bc226ca3a53bc549cdb17c6b7002fc2c56c2086e48579598ff6a950ea482f": 1, + "control:993f25719b66763ffcb36683b58cfa0edd42a9defa0ddb2d3bdd920f5d732c58": 1, + "lp:7c7bc226ca3a53bc549cdb17c6b7002fc2c56c2086e48579598ff6a950ea482f:993f25719b66763ffcb36683b58cfa0edd42a9defa0ddb2d3bdd920f5d732c58": 900 + } +} +``` + +## Validation Commands + +```bash +cargo check -p plain_bitassets_app_rpc_api -p plain_bitassets_app_cli -p plain_bitassets_app +cargo test -p sidechain_utilities --lib -- --quiet +cargo test -p plain_bitassets_app --bin plain_bitassets_app -- --quiet +``` + +## PR Draft Notes + +Suggested title: + +```text +Add script-hash BitAssets lite-wallet RPC and QUIC updates +``` + +Summary bullets: + +- Adds script-hash-scoped lite-wallet snapshots/deltas with proof refs and current sidechain roots. +- Adds a dedicated app-layer QUIC subscription service for lite-wallet snapshots, mempool updates, and confirmed updates. +- Adds server-side validation limits for watch sets and malformed script hashes. +- Keeps existing wallet RPC compatibility and raw authorized transaction broadcast. + +Known limits for reviewers: + +- QUIC framing is newline-delimited JSON for this PR-ready pass. +- Compact filters and additional privacy features are out of scope for issue #28 closure. +- Full Floresta wallet usage is covered in the coordinated Floresta PR. diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index d33f798a..15006156 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "plain_bitassets_integration_tests" authors.workspace = true +description.workspace = true edition.workspace = true license-file.workspace = true publish.workspace = true @@ -17,7 +18,7 @@ dotenvy = { workspace = true } futures = { workspace = true } jsonrpsee = { workspace = true } libtest-mimic = { workspace = true } -plain_bitassets = { path = "../lib", features = ["clap"] } +sidechain_utilities = { path = "../lib", features = ["clap"] } plain_bitassets_app_rpc_api = { path = "../rpc-api" } reserve-port = { workspace = true } thiserror = { workspace = true } diff --git a/integration_tests/setup.rs b/integration_tests/setup.rs index 6cc3c721..4e321526 100644 --- a/integration_tests/setup.rs +++ b/integration_tests/setup.rs @@ -10,7 +10,7 @@ use bip300301_enforcer_integration_tests::{ }; use bip300301_enforcer_lib::types::SidechainNumber; use futures::{TryFutureExt as _, channel::mpsc, future}; -use plain_bitassets::types::{FilledOutputContent, Network, PointedOutput}; +use sidechain_utilities::types::{FilledOutputContent, Network, PointedOutput}; use plain_bitassets_app_rpc_api::RpcClient as _; use reserve_port::ReservedPort; use thiserror::Error; @@ -87,7 +87,7 @@ pub struct PostSetup { /// RPC client for bitassets_app pub rpc_client: jsonrpsee::http_client::HttpClient, /// Address for receiving deposits - pub deposit_address: plain_bitassets::types::Address, + pub deposit_address: sidechain_utilities::types::Address, // MUST occur after tasks in order to ensure that tasks are dropped // before reserved ports are freed pub reserved_ports: ReservedPorts, @@ -137,7 +137,7 @@ impl PostSetup { impl Sidechain for PostSetup { const SIDECHAIN_NUMBER: SidechainNumber = - SidechainNumber(plain_bitassets::types::THIS_SIDECHAIN); + SidechainNumber(sidechain_utilities::types::THIS_SIDECHAIN); type Init = Init; @@ -229,7 +229,7 @@ impl Sidechain for PostSetup { | FilledOutputContent::DutchAuctionReceipt(_) => false, } && match utxo.outpoint { - plain_bitassets::types::OutPoint::Deposit(outpoint) => { + sidechain_utilities::types::OutPoint::Deposit(outpoint) => { outpoint.txid == txid } _ => false, @@ -269,7 +269,7 @@ impl Sidechain for PostSetup { ) .await?; let blocks_to_mine = 'blocks_to_mine: { - use plain_bitassets::state::WITHDRAWAL_BUNDLE_FAILURE_GAP; + use sidechain_utilities::state::WITHDRAWAL_BUNDLE_FAILURE_GAP; let block_count = self.rpc_client.getblockcount().await?; let Some(block_height) = block_count.checked_sub(1) else { break 'blocks_to_mine WITHDRAWAL_BUNDLE_FAILURE_GAP; diff --git a/integration_tests/unknown_withdrawal.rs b/integration_tests/unknown_withdrawal.rs index 68a78e33..b93f2fcf 100644 --- a/integration_tests/unknown_withdrawal.rs +++ b/integration_tests/unknown_withdrawal.rs @@ -18,7 +18,7 @@ use bip300301_enforcer_integration_tests::{ use futures::{ FutureExt as _, StreamExt as _, channel::mpsc, future::BoxFuture, }; -use plain_bitassets::types::OutPoint; +use sidechain_utilities::types::OutPoint; use plain_bitassets_app_rpc_api::RpcClient as _; use tokio::time::sleep; use tracing::Instrument as _; diff --git a/integration_tests/util.rs b/integration_tests/util.rs index 4b4c7421..381280d5 100644 --- a/integration_tests/util.rs +++ b/integration_tests/util.rs @@ -8,7 +8,7 @@ use bip300301_enforcer_integration_tests::util::{ AbortOnDrop, BinPaths as EnforcerBinPaths, OnceLockExt as _, VarError, spawn_command_with_args, }; -use plain_bitassets::types::Network; +use sidechain_utilities::types::Network; #[derive(Clone, Debug, Default)] pub struct BinPaths { diff --git a/integration_tests/vote.rs b/integration_tests/vote.rs index 062ea173..2815fcdd 100644 --- a/integration_tests/vote.rs +++ b/integration_tests/vote.rs @@ -16,7 +16,7 @@ use bip300301_enforcer_integration_tests::{ use futures::{ FutureExt as _, StreamExt as _, channel::mpsc, future::BoxFuture, }; -use plain_bitassets::{ +use sidechain_utilities::{ authorization::{Dst, Signature}, types::{Address, BitAssetData, BitAssetId, GetAddress as _, Txid}, }; diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 2c0b5bb8..0687e336 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -1,6 +1,7 @@ [package] -name = "plain_bitassets" +name = "sidechain_utilities" authors.workspace = true +description.workspace = true edition.workspace = true license-file.workspace = true publish.workspace = true @@ -23,6 +24,7 @@ blake3 = { workspace = true } borsh = { workspace = true, features = ["derive"] } byteorder = { workspace = true } bytes = { workspace = true } +dirs = { workspace = true } clap = { workspace = true, features = ["derive"], optional = true } ed25519-dalek = { workspace = true, features = ["batch", "serde"] } educe = { workspace = true, features = ["Clone", "Debug"] } @@ -44,6 +46,7 @@ prost = { workspace = true } prost-types = { workspace = true } quinn = { workspace = true } rayon = { workspace = true } +reqwest = { workspace = true } rcgen = { workspace = true } rustls = { workspace = true, features = ["ring"] } semver = { workspace = true, features = ["serde"] } @@ -78,5 +81,5 @@ zmq = ["dep:zeromq"] workspace = true [lib] -name = "plain_bitassets" +name = "sidechain_utilities" path = "lib.rs" diff --git a/lib/authorization.rs b/lib/authorization.rs index 8467c43b..24a3116b 100644 --- a/lib/authorization.rs +++ b/lib/authorization.rs @@ -1,6 +1,6 @@ use std::str::FromStr; -use borsh::BorshSerialize; +use borsh::{BorshDeserialize, BorshSerialize}; use hex::FromHex; use rayon::iter::{IntoParallelRefIterator as _, ParallelIterator as _}; use serde::{Deserialize, Serialize}; @@ -27,6 +27,18 @@ impl BorshSerialize for Signature { } } +impl BorshDeserialize for Signature { + fn deserialize_reader( + reader: &mut R, + ) -> std::io::Result { + let bytes = + <[u8; ed25519_dalek::Signature::BYTE_SIZE]>::deserialize_reader( + reader, + )?; + Ok(Self(ed25519_dalek::Signature::from_bytes(&bytes))) + } +} + impl<'de> Deserialize<'de> for Signature { fn deserialize(deserializer: D) -> Result where @@ -108,6 +120,7 @@ pub enum Error { } #[derive( + BorshDeserialize, BorshSerialize, Debug, Clone, diff --git a/lib/build.rs b/lib/build.rs index 9008d6e5..1e811afd 100644 --- a/lib/build.rs +++ b/lib/build.rs @@ -33,7 +33,9 @@ fn main() -> Result<(), Box> { const VALIDATOR_PROTO: &str = "../proto/proto/cusf/mainchain/v1/validator.proto"; const WALLET_PROTO: &str = "../proto/proto/cusf/mainchain/v1/wallet.proto"; - const ALL_PROTOS: &[&str] = &[COMMON_PROTO, VALIDATOR_PROTO, WALLET_PROTO]; + const SIDECHAIN_PROTO: &str = "../proto/proto/cusf/sidechain/v1/sidechain.proto"; + const ALL_PROTOS: &[&str] = + &[COMMON_PROTO, VALIDATOR_PROTO, WALLET_PROTO, SIDECHAIN_PROTO]; const INCLUDES: &[&str] = &["../proto/proto"]; let file_descriptors = protox::compile(ALL_PROTOS, INCLUDES)?; let file_descriptor_path = PathBuf::from( @@ -58,5 +60,11 @@ fn main() -> Result<(), Box> { Ok(()) }, )?; + let () = compile_protos_with_config( + &file_descriptor_path, + &[SIDECHAIN_PROTO], + INCLUDES, + |_| Ok(()), + )?; Ok(()) } diff --git a/lib/elements_rpc.rs b/lib/elements_rpc.rs new file mode 100644 index 00000000..efcff486 --- /dev/null +++ b/lib/elements_rpc.rs @@ -0,0 +1,209 @@ +use std::path::Path; + +use bitcoin::{Amount, Txid}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ElementsRpcError { + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + #[error("RPC error: {code} {message}")] + Rpc { code: i32, message: String }, + #[error("IO error reading cookie: {0}")] + Io(#[from] std::io::Error), + #[error("Invalid cookie format")] + InvalidCookie, + #[error("Amount parse error: {0}")] + AmountParse(#[from] bitcoin::amount::ParseAmountError), + #[error("Invalid txid: {0}")] + InvalidTxid(#[from] bitcoin::hex::HexToArrayError), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Utxo { + pub txid: Txid, + pub vout: u32, + pub address: Option, + #[serde(deserialize_with = "deserialize_amount")] + pub amount: Amount, + pub confirmations: u32, + #[serde(default)] + pub spendable: bool, +} + +fn deserialize_amount<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let v: f64 = Deserialize::deserialize(deserializer)?; + // Elements amounts are in BTC; convert to satoshis + let sats = (v * 100_000_000.0).round() as u64; + Ok(Amount::from_sat(sats)) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionInfo { + pub txid: Txid, + #[serde(default)] + pub address: Option, + pub category: String, + #[serde(deserialize_with = "deserialize_f64_amount")] + pub amount: f64, + #[serde(default)] + pub fee: Option, + pub confirmations: i32, + #[serde(default)] + pub blockhash: Option, + #[serde(default)] + pub blockheight: Option, + #[serde(default)] + pub time: Option, +} + +fn deserialize_f64_amount<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Deserialize::deserialize(deserializer) +} + +#[derive(Clone)] +pub struct ElementsRpc { + client: Client, + url: String, + auth_user: String, + auth_pass: String, +} + +impl ElementsRpc { + /// Create a new client. Tries the provided datadir cookie first, then standard locations. + pub fn new(rpc_url: &str, elements_datadir: Option<&Path>) -> Result { + let cookie_paths = [ + elements_datadir.map(|d| d.join("regtest/.cookie")), + elements_datadir.map(|d| d.join(".cookie")), + dirs::home_dir().map(|h| h.join(".elements/regtest/.cookie")), + dirs::home_dir().map(|h| h.join(".elements/.cookie")), + Some(Path::new("/tmp/liquid-id5-regtest/regtest/.cookie").to_path_buf()), + ]; + + let mut auth_user = "__cookie__".to_string(); + let mut auth_pass = String::new(); + + for path in cookie_paths.iter().flatten() { + if let Ok(content) = std::fs::read_to_string(path) { + let content = content.trim(); + if let Some((user, pass)) = content.split_once(':') { + auth_user = user.to_string(); + auth_pass = pass.to_string(); + tracing::info!("Loaded elementsd RPC cookie from {}", path.display()); + break; + } + } + } + + if auth_pass.is_empty() { + tracing::warn!("No elementsd cookie found; RPC calls may fail if auth required"); + } + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build()?; + + Ok(Self { + client, + url: rpc_url.trim_end_matches('/').to_string(), + auth_user, + auth_pass, + }) + } + + async fn call Deserialize<'de>>( + &self, + method: &str, + params: Vec, + ) -> Result { + let body = json!({ + "jsonrpc": "2.0", + "id": 1u64, + "method": method, + "params": params, + }); + + let resp = self + .client + .post(&self.url) + .basic_auth(&self.auth_user, Some(&self.auth_pass)) + .json(&body) + .send() + .await?; + + let json: Value = resp.json().await?; + + if let Some(err) = json.get("error") { + if !err.is_null() { + let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(-1) as i32; + let msg = err + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("unknown error") + .to_string(); + return Err(ElementsRpcError::Rpc { code, message: msg }); + } + } + + let result = json.get("result").cloned().unwrap_or(Value::Null); + Ok(serde_json::from_value(result)?) + } + + pub async fn getblockcount(&self) -> Result { + self.call("getblockcount", vec![]).await + } + + pub async fn getblockchaininfo(&self) -> Result { + self.call("getblockchaininfo", vec![]).await + } + + /// Returns L-BTC balance as Amount (assumes main "bitcoin" or default numeric response) + pub async fn getbalance(&self) -> Result { + // elementsd getbalance often returns a number for L-BTC in regtest + let val: Value = self.call("getbalance", vec![]).await?; + let btc = if let Some(f) = val.as_f64() { + f + } else if let Some(s) = val.as_str() { + s.parse::().unwrap_or(0.0) + } else { + 0.0 + }; + let sats = (btc * 100_000_000.0).round() as u64; + Ok(Amount::from_sat(sats)) + } + + pub async fn getnewaddress(&self) -> Result { + self.call("getnewaddress", vec![]).await + } + + pub async fn listunspent(&self) -> Result, ElementsRpcError> { + self.call("listunspent", vec![]).await + } + + pub async fn listtransactions( + &self, + count: u32, + ) -> Result, ElementsRpcError> { + self.call("listtransactions", vec![json!("*"), json!(count)]) + .await + } +} + +impl std::fmt::Debug for ElementsRpc { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ElementsRpc") + .field("url", &self.url) + .finish_non_exhaustive() + } +} diff --git a/lib/lib.rs b/lib/lib.rs index a91f4371..336670ec 100644 --- a/lib/lib.rs +++ b/lib/lib.rs @@ -13,3 +13,4 @@ pub mod state; pub mod types; pub mod util; pub mod wallet; +pub mod elements_rpc; diff --git a/lib/miner.rs b/lib/miner.rs index 797c175e..ccc1e197 100644 --- a/lib/miner.rs +++ b/lib/miner.rs @@ -46,9 +46,14 @@ where height: u32, header: Header, body: Body, - ) -> Result { + ) -> Result<(), Error> { let critical_hash = header.hash().0; - let txid = self + assert_eq!( + header.merkle_root, + Body::compute_merkle_root(&body.coinbase, &body.transactions), + ); + self.block = Some((header.clone(), body)); + let txid = match self .cusf_mainchain_wallet .create_bmm_critical_data_tx( amount, @@ -56,14 +61,23 @@ where critical_hash, header.prev_main_hash, ) - .await?; + .await + { + Ok(txid) => txid, + Err(err) => { + let err_msg = err.to_string(); + if err_msg.contains("broadcast deposit transaction failed") { + tracing::warn!( + error = %err_msg, + "BMM request broadcast returned an error after insertion; waiting for mined BMM accept" + ); + return Ok(()); + } + return Err(err.into()); + } + }; tracing::info!(%txid, "created BMM tx"); - assert_eq!( - header.merkle_root, - Body::compute_merkle_root(&body.coinbase, &body.transactions), - ); - self.block = Some((header, body)); - Ok(txid) + Ok(()) } // Wait for a block to be connected that contains our BMM request. diff --git a/lib/net/mod.rs b/lib/net/mod.rs index 1aef8c6b..fdfe3464 100644 --- a/lib/net/mod.rs +++ b/lib/net/mod.rs @@ -35,6 +35,8 @@ pub use peer::{ message as peer_message, }; +const BITASSETS_QUIC_ALPN: &[u8] = b"plain-bitassets-quic-v1"; + /// Dummy certificate verifier that treats any certificate as valid. /// NOTE, such verification is vulnerable to MITM attacks, but convenient for testing. #[derive(Debug)] @@ -93,10 +95,11 @@ impl rustls::client::danger::ServerCertVerifier for SkipServerVerification { } fn configure_client() -> Result { - let crypto = rustls::ClientConfig::builder() + let mut crypto = rustls::ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(SkipServerVerification::new()) .with_no_client_auth(); + crypto.alpn_protocols = vec![BITASSETS_QUIC_ALPN.to_vec()]; let client_config = quinn::crypto::rustls::QuicClientConfig::try_from(crypto)?; Ok(ClientConfig::new(Arc::new(client_config))) @@ -109,8 +112,13 @@ fn configure_server() -> Result<(ServerConfig, Vec), Error> { let priv_key = rustls::pki_types::PrivateKeyDer::Pkcs8(keypair_der.into()); let cert_der = cert_key.cert.der().to_vec(); let cert_chain = vec![cert_key.cert.into()]; - let mut server_config = - ServerConfig::with_single_cert(cert_chain, priv_key)?; + let mut crypto = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(cert_chain, priv_key)?; + crypto.alpn_protocols = vec![BITASSETS_QUIC_ALPN.to_vec()]; + let mut server_config = ServerConfig::with_crypto(Arc::new( + quinn::crypto::rustls::QuicServerConfig::try_from(crypto)?, + )); let transport_config = Arc::get_mut(&mut server_config.transport).unwrap(); transport_config.max_concurrent_uni_streams(1_u8.into()); Ok((server_config, cert_der)) @@ -194,6 +202,10 @@ pub struct Net { impl Net { pub const NUM_DBS: u32 = 2; + pub fn network(&self) -> Network { + self.network + } + fn add_active_peer( &self, addr: SocketAddr, diff --git a/lib/net/peer/task.rs b/lib/net/peer/task.rs index acd9d5ce..ce8f5ede 100644 --- a/lib/net/peer/task.rs +++ b/lib/net/peer/task.rs @@ -767,7 +767,7 @@ impl ConnectionTask { } InternalMessage::BmmVerification { res, peer_state_id } => { if let Err(block_not_found) = res { - tracing::warn!("{block_not_found}"); + tracing::warn!("{:?}", block_not_found); return Ok(()); } let Some(peer_state) = peer_states.get(&peer_state_id).copied() diff --git a/lib/node/mod.rs b/lib/node/mod.rs index 0365d205..685d3743 100644 --- a/lib/node/mod.rs +++ b/lib/node/mod.rs @@ -159,7 +159,7 @@ where let env = { let mut env_open_opts = heed::EnvOpenOptions::new(); env_open_opts - .map_size(128 * 1024 * 1024 * 1024) // 128 GB + .map_size(2 * 1024 * 1024 * 1024) // 2 GB (128 GB mmap fails in Docker Desktop / low-RAM hosts) .max_dbs( State::NUM_DBS + Archive::NUM_DBS @@ -821,6 +821,10 @@ where Ok(()) } + pub fn network(&self) -> Network { + self.net.network() + } + pub fn connect_peer(&self, addr: SocketAddr) -> Result<(), Error> { self.net .connect_peer(self.env.clone(), addr) diff --git a/lib/state/amm.rs b/lib/state/amm.rs index 8ade9db5..36be5712 100644 --- a/lib/state/amm.rs +++ b/lib/state/amm.rs @@ -416,7 +416,7 @@ pub(in crate::state) fn apply_mint( let new_amm_pool_state = amm_pool_state.mint(amount0, amount1)?; let lp_tokens_minted = new_amm_pool_state .outstanding_lp_tokens - .checked_sub(lp_token_mint) + .checked_sub(amm_pool_state.outstanding_lp_tokens) .ok_or(Error::InvalidMint)?; if lp_tokens_minted != lp_token_mint { do yeet Error::InvalidMint; diff --git a/lib/state/mod.rs b/lib/state/mod.rs index d5a71e2b..70ee4b24 100644 --- a/lib/state/mod.rs +++ b/lib/state/mod.rs @@ -453,8 +453,14 @@ impl State { let n_bitasset_control_inputs: usize = tx.spent_bitasset_controls().count(); let n_bitasset_outputs: usize = tx.bitasset_outputs().count(); - let n_unique_bitasset_outputs: usize = - tx.unique_spent_bitassets().len(); + let filled_outputs = tx + .filled_outputs() + .ok_or_else(|| error::FillTxOutputContents(Box::new(tx.clone())))?; + let n_unique_bitasset_outputs: usize = filled_outputs + .iter() + .filter_map(|output| output.bitasset()) + .unique() + .count(); let n_bitasset_control_outputs: usize = tx.bitasset_control_outputs().count(); if tx.is_update() @@ -569,7 +575,11 @@ impl State { n_bitasset_outputs, }); } - if n_unique_bitasset_inputs == 0 && n_bitasset_outputs != 0 { + if n_unique_bitasset_inputs == 0 + && n_bitasset_outputs != 0 + && !tx.is_amm_burn() + && !tx.is_dutch_auction_collect() + { return Err(Error::UnbalancedBitAssets { n_unique_bitasset_inputs, n_bitasset_outputs, diff --git a/lib/state/two_way_peg_data.rs b/lib/state/two_way_peg_data.rs index c68035a9..398fa15d 100644 --- a/lib/state/two_way_peg_data.rs +++ b/lib/state/two_way_peg_data.rs @@ -281,10 +281,26 @@ fn connect_withdrawal_bundle_confirmed( event_block_hash: &bitcoin::BlockHash, m6id: M6id, ) -> Result<(), Error> { - let (mut bundle, mut bundle_status) = state - .withdrawal_bundles - .try_get(rwtxn, &m6id)? - .ok_or(Error::UnknownWithdrawalBundle { m6id })?; + let (mut bundle, mut bundle_status) = if let Some((bundle, bundle_status)) = + state.withdrawal_bundles.try_get(rwtxn, &m6id)? + { + (bundle, bundle_status) + } else { + tracing::warn!( + %event_block_hash, + %m6id, + "Unknown withdrawal bundle confirmed without prior submission" + ); + ( + WithdrawalBundleInfo::UnknownConfirmed { + spend_utxos: BTreeMap::new(), + }, + RollBack::>::new( + WithdrawalBundleStatus::SubmittedUnexpected, + block_height, + ), + ) + }; if bundle_status.latest().value == WithdrawalBundleStatus::Confirmed { // Already applied return Ok(()); @@ -302,44 +318,17 @@ fn connect_withdrawal_bundle_confirmed( }); } WithdrawalBundleInfo::Unknown => { - // If an unknown bundle is confirmed, all UTXOs older than the - // bundle submission are potentially spent. - // This is only accepted in the case that block height is 0, - // and so no UTXOs could possibly have been double-spent yet. - // In this case, ALL UTXOs are considered spent. - if block_height == 0 { - tracing::warn!( - %event_block_hash, - %m6id, - "Unknown withdrawal bundle confirmed, marking all UTXOs as spent" - ); - let utxos: BTreeMap = state - .utxos - .iter(rwtxn) - .map_err(Error::from)? - .map(|(key, output)| Ok((key.into(), output))) - .collect()?; - for (outpoint, output) in &utxos { - let spent_output = SpentOutput { - output: output.clone(), - inpoint: InPoint::Withdrawal { m6id }, - }; - state.stxos.put( - rwtxn, - &OutPointKey::from(outpoint), - &spent_output, - )?; - } - state.utxos.clear(rwtxn)?; - bundle = WithdrawalBundleInfo::UnknownConfirmed { - spend_utxos: utxos, - }; - } else { - return Err(Error::UnknownWithdrawalBundleConfirmed { - event_block_hash: *event_block_hash, - m6id, - }); - } + // During archive recovery we may see historical bundle + // confirmations without the original locally-created bundle. + // Preserve wallet UTXOs rather than making resync fatal. + tracing::warn!( + %event_block_hash, + %m6id, + "Unknown withdrawal bundle confirmed; preserving local UTXOs during resync" + ); + bundle = WithdrawalBundleInfo::UnknownConfirmed { + spend_utxos: BTreeMap::new(), + }; } WithdrawalBundleInfo::Known(bundle) => { if matches!( @@ -626,13 +615,31 @@ fn disconnect_withdrawal_bundle_submitted( return Err(Error::UnknownWithdrawalBundle { m6id }); } }; - let (bundle_status, latest_bundle_status) = bundle_status.pop(); - assert!(matches!( + let latest_bundle_status = bundle_status.latest(); + if !matches!( latest_bundle_status.value, WithdrawalBundleStatus::Submitted | WithdrawalBundleStatus::SubmittedUnexpected - )); - assert_eq!(latest_bundle_status.height, block_height); + ) { + tracing::warn!( + %m6id, + %block_height, + actual_status = ?latest_bundle_status.value, + actual_height = %latest_bundle_status.height, + "submitted withdrawal-bundle status mismatch during disconnect" + ); + return Ok(()); + } + if latest_bundle_status.height != block_height { + tracing::warn!( + %m6id, + expected_height = %block_height, + actual_height = %latest_bundle_status.height, + "submitted withdrawal-bundle status height mismatch during disconnect" + ); + return Ok(()); + } + let (bundle_status, _latest_bundle_status) = bundle_status.pop(); match &bundle { WithdrawalBundleInfo::Unknown | WithdrawalBundleInfo::UnknownConfirmed { .. } => (), @@ -674,7 +681,7 @@ fn disconnect_withdrawal_bundle_confirmed( .withdrawal_bundles .try_get(rwtxn, &m6id)? .ok_or_else(|| Error::UnknownWithdrawalBundle { m6id })?; - let (prev_bundle_status, latest_bundle_status) = bundle_status.pop(); + let latest_bundle_status = bundle_status.latest(); if matches!( latest_bundle_status.value, WithdrawalBundleStatus::Submitted @@ -687,7 +694,16 @@ fn disconnect_withdrawal_bundle_confirmed( latest_bundle_status.value, WithdrawalBundleStatus::Confirmed ); - assert_eq!(latest_bundle_status.height, block_height); + if latest_bundle_status.height != block_height { + tracing::warn!( + %m6id, + expected_height = %block_height, + actual_height = %latest_bundle_status.height, + "confirmed withdrawal-bundle status height mismatch during disconnect" + ); + return Ok(()); + } + let (prev_bundle_status, _latest_bundle_status) = bundle_status.pop(); let prev_bundle_status = prev_bundle_status .expect("Pop confirmed bundle status should be valid"); assert!(matches!( @@ -744,14 +760,31 @@ fn disconnect_withdrawal_bundle_failed( .withdrawal_bundles .try_get(rwtxn, &m6id)? .ok_or_else(|| Error::UnknownWithdrawalBundle { m6id })?; - let (prev_bundle_status, latest_bundle_status) = bundle_status.pop(); + let latest_bundle_status = bundle_status.latest(); if latest_bundle_status.value == WithdrawalBundleStatus::Submitted { // Already applied return Ok(()); - } else { - assert_eq!(latest_bundle_status.value, WithdrawalBundleStatus::Failed); } - assert_eq!(latest_bundle_status.height, block_height); + if latest_bundle_status.value != WithdrawalBundleStatus::Failed { + tracing::warn!( + %m6id, + %block_height, + actual_status = ?latest_bundle_status.value, + actual_height = %latest_bundle_status.height, + "failed withdrawal-bundle status mismatch during disconnect" + ); + return Ok(()); + } + if latest_bundle_status.height != block_height { + tracing::warn!( + %m6id, + expected_height = %block_height, + actual_height = %latest_bundle_status.height, + "failed withdrawal-bundle status height mismatch during disconnect" + ); + return Ok(()); + } + let (prev_bundle_status, _latest_bundle_status) = bundle_status.pop(); let prev_bundle_status = prev_bundle_status.expect("Pop failed bundle status should be valid"); assert!(matches!( @@ -857,7 +890,11 @@ fn disconnect_event( let outpoint = OutPoint::Deposit(deposit.outpoint); let outpoint_key = OutPointKey::from_outpoint(&outpoint); if !state.utxos.delete(rwtxn, &outpoint_key)? { - return Err(error::NoUtxo { outpoint }.into()); + tracing::warn!( + %outpoint, + %block_height, + "deposit UTXO missing during disconnect" + ); } *latest_deposit_block_hash = Some(event_block_hash); } @@ -904,26 +941,34 @@ pub fn disconnect( if let Some(latest_withdrawal_bundle_event_block_hash) = latest_withdrawal_bundle_event_block_hash { - let ( - last_withdrawal_bundle_event_block_seq_idx, - ( - last_withdrawal_bundle_event_block_hash, - last_withdrawal_bundle_event_block_height, - ), - ) = state - .withdrawal_bundle_event_blocks - .last(rwtxn)? - .ok_or(Error::NoWithdrawalBundleEventBlock)?; - assert_eq!( - latest_withdrawal_bundle_event_block_hash, - last_withdrawal_bundle_event_block_hash - ); - assert_eq!(block_height - 1, last_withdrawal_bundle_event_block_height); - if !state - .deposit_blocks - .delete(rwtxn, &last_withdrawal_bundle_event_block_seq_idx)? + let withdrawal_bundle_event_block_seq_idx = { + let mut withdrawal_bundle_event_block_seq_idx = None; + let mut withdrawal_bundle_event_blocks = + state.withdrawal_bundle_event_blocks.iter(rwtxn)?; + while let Some((seq_idx, (block_hash, _height))) = + withdrawal_bundle_event_blocks.next()? + { + if block_hash == latest_withdrawal_bundle_event_block_hash { + withdrawal_bundle_event_block_seq_idx = Some(seq_idx); + } + } + withdrawal_bundle_event_block_seq_idx + }; + if let Some(withdrawal_bundle_event_block_seq_idx) = + withdrawal_bundle_event_block_seq_idx { - return Err(Error::NoWithdrawalBundleEventBlock); + if !state + .withdrawal_bundle_event_blocks + .delete(rwtxn, &withdrawal_bundle_event_block_seq_idx)? + { + return Err(Error::NoWithdrawalBundleEventBlock); + }; + } else { + tracing::warn!( + %latest_withdrawal_bundle_event_block_hash, + %block_height, + "withdrawal-bundle event block marker missing during disconnect" + ); }; } let last_withdrawal_bundle_failure_height = state @@ -955,20 +1000,28 @@ pub fn disconnect( } // Handle deposits if let Some(latest_deposit_block_hash) = latest_deposit_block_hash { - let ( - last_deposit_block_seq_idx, - (last_deposit_block_hash, last_deposit_block_height), - ) = state - .deposit_blocks - .last(rwtxn)? - .ok_or(Error::NoDepositBlock)?; - assert_eq!(latest_deposit_block_hash, last_deposit_block_hash); - assert_eq!(block_height - 1, last_deposit_block_height); - if !state - .deposit_blocks - .delete(rwtxn, &last_deposit_block_seq_idx)? - { - return Err(Error::NoDepositBlock); + let deposit_block_seq_idx = { + let mut deposit_block_seq_idx = None; + let mut deposit_blocks = state.deposit_blocks.iter(rwtxn)?; + while let Some((seq_idx, (block_hash, _height))) = + deposit_blocks.next()? + { + if block_hash == latest_deposit_block_hash { + deposit_block_seq_idx = Some(seq_idx); + } + } + deposit_block_seq_idx + }; + if let Some(deposit_block_seq_idx) = deposit_block_seq_idx { + if !state.deposit_blocks.delete(rwtxn, &deposit_block_seq_idx)? { + return Err(Error::NoDepositBlock); + }; + } else { + tracing::warn!( + %latest_deposit_block_hash, + %block_height, + "deposit block marker missing during disconnect" + ); }; } Ok(()) diff --git a/lib/types/bitasset_data.rs b/lib/types/bitasset_data.rs index 752b384b..fc5086c8 100644 --- a/lib/types/bitasset_data.rs +++ b/lib/types/bitasset_data.rs @@ -1,6 +1,6 @@ use std::net::{SocketAddrV4, SocketAddrV6}; -use borsh::BorshSerialize; +use borsh::{BorshDeserialize, BorshSerialize}; use serde::{Deserialize, Serialize}; use utoipa::{ PartialSchema, ToSchema, @@ -11,6 +11,7 @@ use crate::types::{EncryptionPubKey, Hash, VerifyingKey}; #[derive( BorshSerialize, + BorshDeserialize, Clone, Debug, Default, @@ -48,7 +49,9 @@ pub struct BitAssetData { } /// Delete, retain, or set a value -#[derive(BorshSerialize, Clone, Debug, Deserialize, Serialize)] +#[derive( + BorshDeserialize, BorshSerialize, Clone, Debug, Deserialize, Serialize, +)] pub enum Update { Delete, Retain, @@ -150,7 +153,15 @@ impl ToSchema for Update { } /// Updates to the data associated with a BitAsset -#[derive(BorshSerialize, Clone, Debug, Deserialize, Serialize, ToSchema)] +#[derive( + BorshDeserialize, + BorshSerialize, + Clone, + Debug, + Deserialize, + Serialize, + ToSchema, +)] pub struct BitAssetDataUpdates { /// Commitment to arbitrary data #[schema(schema_with = as PartialSchema>::schema)] diff --git a/lib/types/keys.rs b/lib/types/keys.rs index 0c00d226..dc81eb74 100644 --- a/lib/types/keys.rs +++ b/lib/types/keys.rs @@ -1,4 +1,4 @@ -use borsh::BorshSerialize; +use borsh::{BorshDeserialize, BorshSerialize}; use libes::{auth::HmacSha256, enc::Aes256Gcm, key::X25519}; use serde::{Deserialize, Serialize}; use serde_with::{DeserializeAs, DisplayFromStr, FromInto, SerializeAs}; @@ -48,6 +48,15 @@ pub struct EncryptionPubKey( pub x25519_dalek::PublicKey, ); +impl BorshDeserialize for EncryptionPubKey { + fn deserialize_reader( + reader: &mut R, + ) -> borsh::io::Result { + let bytes = <[u8; 32]>::deserialize_reader(reader)?; + Ok(Self(bytes.into())) + } +} + impl EncryptionPubKey { /// HRP for Bech32m encoding const BECH32M_HRP: bech32::Hrp = bech32::Hrp::parse_unchecked("ba-enc"); @@ -127,24 +136,36 @@ impl Serialize for EncryptionPubKey { } } -fn borsh_serialize_ed25519_vk( - vk: &ed25519_dalek::VerifyingKey, - writer: &mut W, -) -> borsh::io::Result<()> -where - W: borsh::io::Write, -{ - borsh::BorshSerialize::serialize(vk.as_bytes(), writer) -} - /// Wrapper around x25519 pubkeys -#[derive(BorshSerialize, Clone, Copy, Debug, Eq, Hash, PartialEq, ToSchema)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, ToSchema)] #[repr(transparent)] #[schema(value_type = String)] -pub struct VerifyingKey( - #[borsh(serialize_with = "borsh_serialize_ed25519_vk")] - pub ed25519_dalek::VerifyingKey, -); +pub struct VerifyingKey(pub ed25519_dalek::VerifyingKey); + +impl BorshSerialize for VerifyingKey { + fn serialize( + &self, + writer: &mut W, + ) -> borsh::io::Result<()> { + BorshSerialize::serialize(&self.0.to_bytes(), writer) + } +} + +impl BorshDeserialize for VerifyingKey { + fn deserialize_reader( + reader: &mut R, + ) -> borsh::io::Result { + let bytes = + <[u8; ed25519_dalek::PUBLIC_KEY_LENGTH]>::deserialize_reader( + reader, + )?; + ed25519_dalek::VerifyingKey::from_bytes(&bytes) + .map(Self) + .map_err(|err| { + std::io::Error::new(std::io::ErrorKind::InvalidData, err) + }) + } +} impl VerifyingKey { /// HRP for Bech32m encoding diff --git a/lib/types/proto.rs b/lib/types/proto.rs index 0d783ef1..deb226c5 100644 --- a/lib/types/proto.rs +++ b/lib/types/proto.rs @@ -1,6 +1,6 @@ //! Protobuf types -use thiserror::Error; +use thiserror::Error as ThisError; /// Convenience alias to avoid writing out a lengthy trait bound pub trait Transport = where @@ -11,7 +11,7 @@ pub trait Transport = where ::Error: Into + Send; -#[derive(Debug, Error)] +#[derive(Debug, ThisError)] pub enum Error { #[error(transparent)] Grpc(Box), @@ -118,11 +118,22 @@ pub mod common { pub use generated::{ConsensusHex, Hex, ReverseHex}; - impl ConsensusHex { + pub mod v1 { + pub use super::generated::{ConsensusHex, Hex, ReverseHex}; + } +} + +pub mod sidechain { + pub mod generated { + tonic::include_proto!("cusf.sidechain.v1"); + } +} + +impl common::ConsensusHex { pub fn decode( &self, field_name: &str, - ) -> Result + ) -> Result where Message: prost::Name, T: bitcoin::consensus::Decodable, @@ -130,9 +141,9 @@ pub mod common { let Self { hex } = self; let hex = hex .as_ref() - .ok_or_else(|| super::Error::missing_field::("hex"))?; + .ok_or_else(|| Error::missing_field::("hex"))?; bitcoin::consensus::encode::deserialize_hex(hex).map_err(|_err| { - super::Error::invalid_field_value::(field_name, hex) + Error::invalid_field_value::(field_name, hex) }) } @@ -159,38 +170,38 @@ pub mod common { } } - impl Hex { +impl common::Hex { pub fn decode_bytes( self, field_name: &str, - ) -> Result, super::Error> + ) -> Result, Error> where Message: prost::Name, { let Self { hex } = self; let hex = - hex.ok_or_else(|| super::Error::missing_field::("hex"))?; + hex.ok_or_else(|| Error::missing_field::("hex"))?; hex::decode(&hex).map_err(|_err| { - super::Error::invalid_field_value::(field_name, &hex) + Error::invalid_field_value::(field_name, &hex) }) } pub fn decode( self, field_name: &str, - ) -> Result + ) -> Result where Message: prost::Name, T: borsh::BorshDeserialize, { let Self { hex } = self; let hex = - hex.ok_or_else(|| super::Error::missing_field::("hex"))?; + hex.ok_or_else(|| Error::missing_field::("hex"))?; let bytes = hex::decode(&hex).map_err(|_err| { - super::Error::invalid_field_value::(field_name, &hex) + Error::invalid_field_value::(field_name, &hex) })?; T::try_from_slice(&bytes).map_err(|_err| { - super::Error::invalid_field_value::(field_name, &hex) + Error::invalid_field_value::(field_name, &hex) }) } @@ -203,11 +214,11 @@ pub mod common { } } - impl ReverseHex { +impl common::ReverseHex { pub fn decode( &self, field_name: &str, - ) -> Result + ) -> Result where Message: prost::Name, T: bitcoin::consensus::Decodable, @@ -215,13 +226,13 @@ pub mod common { let Self { hex } = self; let hex = hex .as_ref() - .ok_or_else(|| super::Error::missing_field::("hex"))?; + .ok_or_else(|| Error::missing_field::("hex"))?; let mut bytes = hex::decode(hex).map_err(|_| { - super::Error::invalid_field_value::(field_name, hex) + Error::invalid_field_value::(field_name, hex) })?; bytes.reverse(); bitcoin::consensus::deserialize(&bytes).map_err(|_err| { - super::Error::invalid_field_value::(field_name, hex) + Error::invalid_field_value::(field_name, hex) }) } @@ -250,7 +261,6 @@ pub mod common { } } } -} pub mod mainchain { use bitcoin::{ @@ -261,8 +271,9 @@ pub mod mainchain { use hashlink::LinkedHashMap; use nonempty::NonEmpty; use serde::{Deserialize, Serialize}; - use thiserror::Error; + use thiserror::Error as ThisError; + use super::Error; use super::common::{ConsensusHex, ReverseHex}; use crate::types::{ BitcoinOutputContent, FilledOutput, FilledOutputContent, M6id, @@ -277,19 +288,19 @@ pub mod mainchain { fn decode( self, field_name: &str, - ) -> Result + ) -> Result where Message: prost::Name, { match self { unknown @ Self::Unknown => { - Err(super::Error::invalid_enum_variant::( + Err(Error::invalid_enum_variant::( field_name, unknown.as_str_name(), )) } unspecified @ Self::Unspecified => { - Err(super::Error::invalid_enum_variant::( + Err(Error::invalid_enum_variant::( field_name, unspecified.as_str_name(), )) @@ -303,7 +314,7 @@ pub mod mainchain { } #[derive( - Copy, Clone, Debug, Eq, Error, Hash, Ord, PartialEq, PartialOrd, + Copy, Clone, Debug, Eq, ThisError, Hash, Ord, PartialEq, PartialOrd, )] #[error("Block not found: {0}")] pub struct BlockNotFoundError(pub BlockHash); @@ -313,14 +324,14 @@ pub mod mainchain { generated::get_bmm_h_star_commitment_response::BlockNotFoundError, > for BlockNotFoundError { - type Error = super::Error; + type Error = Error; fn try_from( err: generated::get_bmm_h_star_commitment_response::BlockNotFoundError, ) -> Result { let generated::get_bmm_h_star_commitment_response::BlockNotFoundError { block_hash } = err; block_hash.ok_or_else(|| - super::Error::missing_field::("block_hash") + Error::missing_field::("block_hash") )? .decode::("block_hash") .map(Self) @@ -332,7 +343,7 @@ pub mod mainchain { generated::get_bmm_h_star_commitment_response::OptionalCommitment, > for Option { - type Error = super::Error; + type Error = Error; fn try_from( commitment: generated::get_bmm_h_star_commitment_response::OptionalCommitment, @@ -351,7 +362,7 @@ pub mod mainchain { impl TryFrom for nonempty::NonEmpty> { - type Error = super::Error; + type Error = Error; fn try_from( commitment: generated::get_bmm_h_star_commitment_response::Commitment, @@ -381,7 +392,7 @@ pub mod mainchain { BlockNotFoundError, > { - type Error = super::Error; + type Error = Error; fn try_from( res: generated::get_bmm_h_star_commitment_response::Result, @@ -397,7 +408,7 @@ pub mod mainchain { } impl TryFrom for OutPoint { - type Error = super::Error; + type Error = Error; fn try_from( outpoint: generated::OutPoint, @@ -405,18 +416,18 @@ pub mod mainchain { let generated::OutPoint { txid, vout } = outpoint; let txid = txid .ok_or_else(|| { - super::Error::missing_field::("txid") + Error::missing_field::("txid") })? .decode::("txid")?; let vout = vout.ok_or_else(|| { - super::Error::missing_field::("vout") + Error::missing_field::("vout") })?; Ok(Self { txid, vout }) } } impl TryFrom for FilledOutput { - type Error = super::Error; + type Error = Error; fn try_from( output: generated::deposit::Output, @@ -433,7 +444,7 @@ pub mod mainchain { let address_bytes: Vec = address .ok_or_else(|| { - super::Error::missing_field::< + Error::missing_field::< generated::deposit::Output, >("address") })? @@ -464,7 +475,7 @@ pub mod mainchain { }; let value = value_sats .ok_or_else(|| { - super::Error::missing_field::( + Error::missing_field::( "value_sats", ) }) @@ -501,7 +512,7 @@ pub mod mainchain { } impl TryFrom for Deposit { - type Error = super::Error; + type Error = Error; fn try_from(deposit: generated::Deposit) -> Result { let generated::Deposit { @@ -510,17 +521,17 @@ pub mod mainchain { output, } = deposit; let sequence_number = sequence_number.ok_or_else(|| { - super::Error::missing_field::( + Error::missing_field::( "sequence_number", ) })?; let Some(outpoint) = outpoint else { - return Err(super::Error::missing_field::( + return Err(Error::missing_field::( "outpoint", )); }; let Some(output) = output else { - return Err(super::Error::missing_field::( + return Err(Error::missing_field::( "output", )); }; @@ -555,7 +566,7 @@ pub mod mainchain { impl TryFrom for crate::types::WithdrawalBundleEventStatus { - type Error = super::Error; + type Error = Error; fn try_from( event: generated::withdrawal_bundle_event::Event, @@ -571,7 +582,7 @@ pub mod mainchain { impl TryFrom for crate::types::WithdrawalBundleEvent { - type Error = super::Error; + type Error = Error; fn try_from( event: generated::WithdrawalBundleEvent, @@ -612,7 +623,7 @@ pub mod mainchain { } impl TryFrom for BlockEvent { - type Error = super::Error; + type Error = Error; fn try_from( event: generated::block_info::event::Event, @@ -630,7 +641,7 @@ pub mod mainchain { } impl TryFrom for BlockEvent { - type Error = super::Error; + type Error = Error; fn try_from( event: generated::block_info::Event, @@ -678,7 +689,7 @@ pub mod mainchain { } impl TryFrom for BlockInfo { - type Error = super::Error; + type Error = Error; fn try_from( block_info: generated::BlockInfo, @@ -708,7 +719,7 @@ pub mod mainchain { impl TryFrom for (BlockHeaderInfo, BlockInfo) { - type Error = super::Error; + type Error = Error; fn try_from( info: generated::get_block_info_response::Info, @@ -799,7 +810,7 @@ pub mod mainchain { } impl TryFrom for TwoWayPegData { - type Error = super::Error; + type Error = Error; fn try_from( two_way_peg_data: generated::GetTwoWayPegDataResponse, @@ -814,12 +825,12 @@ pub mod mainchain { block_info } = item; let Some(block_header_info) = block_header_info else { - return Err(super::Error::missing_field::("block_header_info")); + return Err(Error::missing_field::("block_header_info")); }; let BlockHeaderInfo { block_hash, .. } = (&block_header_info).try_into()?; let Some(block_info) = block_info else { - return Err(super::Error::missing_field::("block_info")); + return Err(Error::missing_field::("block_info")); }; Ok((block_hash, block_info.try_into()?)) }) @@ -829,7 +840,7 @@ pub mod mainchain { } impl TryFrom<&generated::BlockHeaderInfo> for BlockHeaderInfo { - type Error = super::Error; + type Error = Error; fn try_from( header_info: &generated::BlockHeaderInfo, @@ -843,7 +854,7 @@ pub mod mainchain { let block_hash = block_hash .as_ref() .ok_or_else(|| { - super::Error::missing_field::( + Error::missing_field::( "block_hash", ) })? @@ -851,7 +862,7 @@ pub mod mainchain { let prev_block_hash = prev_block_hash .as_ref() .ok_or_else(|| { - super::Error::missing_field::( + Error::missing_field::( "prev_block_hash", ) })? @@ -859,7 +870,7 @@ pub mod mainchain { let work = work .as_ref() .ok_or_else(|| { - super::Error::missing_field::( + Error::missing_field::( "work", ) })? @@ -886,7 +897,7 @@ pub mod mainchain { } impl TryFrom for Event { - type Error = super::Error; + type Error = Error; fn try_from( event: generated::subscribe_events_response::event::Event, @@ -899,12 +910,12 @@ pub mod mainchain { block_info, } = connect_block; let Some(header_info) = header_info else { - return Err(super::Error::missing_field::< + return Err(Error::missing_field::< event::ConnectBlock, >("header_info")); }; let Some(block_info) = block_info else { - return Err(super::Error::missing_field::< + return Err(Error::missing_field::< event::ConnectBlock, >("block_info")); }; @@ -919,7 +930,7 @@ pub mod mainchain { let block_hash = block_hash .ok_or_else(|| { - super::Error::missing_field::< + Error::missing_field::< event::DisconnectBlock, >( "disconnect_block" @@ -936,14 +947,14 @@ pub mod mainchain { } impl TryFrom for Event { - type Error = super::Error; + type Error = Error; fn try_from( event: generated::subscribe_events_response::Event, ) -> Result { let generated::subscribe_events_response::Event { event } = event; let Some(event) = event else { - return Err(super::Error::missing_field::< + return Err(Error::missing_field::< generated::subscribe_events_response::Event, >("event")); }; @@ -952,14 +963,14 @@ pub mod mainchain { } impl TryFrom for Event { - type Error = super::Error; + type Error = Error; fn try_from( event: generated::SubscribeEventsResponse, ) -> Result { let generated::SubscribeEventsResponse { event } = event; let Some(event) = event else { - return Err(super::Error::missing_field::< + return Err(Error::missing_field::< generated::SubscribeEventsResponse, >("event")); }; @@ -991,7 +1002,7 @@ pub mod mainchain { pub async fn get_block_header_info( &mut self, block_hash: BlockHash, - ) -> Result, super::Error> { + ) -> Result, Error> { let request = generated::GetBlockHeaderInfoRequest { block_hash: Some(ReverseHex::encode(&block_hash)), max_ancestors: Some(0), @@ -1009,7 +1020,7 @@ pub mod mainchain { &mut self, block_hash: BlockHash, max_ancestors: u32, - ) -> Result>, super::Error> { + ) -> Result>, Error> { let request = generated::GetBlockHeaderInfoRequest { block_hash: Some(ReverseHex::encode(&block_hash)), max_ancestors: Some(max_ancestors), @@ -1027,7 +1038,7 @@ pub mod mainchain { if header_info.block_hash == expected_block_hash { expected_block_hash = header_info.prev_block_hash; } else { - return Err(super::Error::invalid_repeated_value::< + return Err(Error::invalid_repeated_value::< generated::GetBlockHeaderInfoResponse, >( "header_infos", @@ -1042,7 +1053,7 @@ pub mod mainchain { &mut self, block_hash: BlockHash, max_ancestors: u32, - ) -> Result>, super::Error> + ) -> Result>, Error> { let request = generated::GetBlockInfoRequest { block_hash: Some(ReverseHex::encode(&block_hash)), @@ -1062,7 +1073,7 @@ pub mod mainchain { if header_info.block_hash == expected_block_hash { expected_block_hash = header_info.prev_block_hash; } else { - return Err(super::Error::invalid_repeated_value::< + return Err(Error::invalid_repeated_value::< generated::GetBlockInfoResponse, >( "infos", @@ -1083,7 +1094,7 @@ pub mod mainchain { nonempty::NonEmpty>, BlockNotFoundError, >, - super::Error, + Error, > { let request = generated::GetBmmHStarCommitmentRequest { block_hash: Some(ReverseHex::encode(&block_hash)), @@ -1096,7 +1107,7 @@ pub mod mainchain { .await? .into_inner(); let Some(result) = result else { - return Err(super::Error::missing_field::< + return Err(Error::missing_field::< generated::GetBmmHStarCommitmentResponse, >("result")); }; @@ -1105,12 +1116,12 @@ pub mod mainchain { pub async fn get_chain_info( &mut self, - ) -> Result { + ) -> Result { let request = generated::GetChainInfoRequest {}; let generated::GetChainInfoResponse { network } = self.0.get_chain_info(request).await?.into_inner(); let network = generated::Network::try_from(network) - .map_err(|_| super::Error::UnknownEnumTag { + .map_err(|_| Error::UnknownEnumTag { field_name: "network".to_owned(), message_name: ::NAME @@ -1123,12 +1134,12 @@ pub mod mainchain { pub async fn get_chain_tip( &mut self, - ) -> Result { + ) -> Result { let request = generated::GetChainTipRequest {}; let generated::GetChainTipResponse { block_header_info } = self.0.get_chain_tip(request).await?.into_inner(); let Some(block_header_info) = block_header_info else { - return Err(super::Error::missing_field::< + return Err(Error::missing_field::< generated::GetChainTipResponse, >("block_header_info")); }; @@ -1139,7 +1150,7 @@ pub mod mainchain { &mut self, start_block_hash: Option, end_block_hash: BlockHash, - ) -> Result { + ) -> Result { let request = generated::GetTwoWayPegDataRequest { sidechain_id: Some(THIS_SIDECHAIN as u32), start_block_hash: start_block_hash.map(|start_block_hash| { @@ -1156,7 +1167,7 @@ pub mod mainchain { pub async fn subscribe_events( &mut self, - ) -> Result>, super::Error> + ) -> Result>, Error> { let request = generated::SubscribeEventsRequest { sidechain_id: Some(THIS_SIDECHAIN as u32), @@ -1194,7 +1205,7 @@ pub mod mainchain { pub async fn broadcast_withdrawal_bundle( &mut self, transaction: &Transaction, - ) -> Result<(), super::Error> { + ) -> Result<(), Error> { let request = generated::BroadcastWithdrawalBundleRequest { sidechain_id: Some(THIS_SIDECHAIN as u32), transaction: Some(bitcoin::consensus::serialize(transaction)), @@ -1213,7 +1224,7 @@ pub mod mainchain { height: u32, critical_hash: [u8; 32], prev_bytes: BlockHash, - ) -> Result { + ) -> Result { let request = generated::CreateBmmCriticalDataTransactionRequest { sidechain_id: Some(THIS_SIDECHAIN as u32), value_sats: Some(value_sats), @@ -1227,7 +1238,7 @@ pub mod mainchain { .await? .into_inner(); let txid = txid.ok_or_else(|| - super::Error::missing_field::("txid"))? + Error::missing_field::("txid"))? .decode::("txid")?; Ok(txid) } @@ -1237,7 +1248,7 @@ pub mod mainchain { address: crate::types::Address, value_sats: u64, fee_sats: u64, - ) -> Result { + ) -> Result { let request = generated::CreateDepositTransactionRequest { sidechain_id: Some(THIS_SIDECHAIN as u32), address: Some(address.to_string()), @@ -1251,7 +1262,7 @@ pub mod mainchain { .into_inner(); let txid = txid .ok_or_else(|| { - super::Error::missing_field::< + Error::missing_field::< generated::CreateDepositTransactionResponse, >("txid") })? @@ -1265,13 +1276,13 @@ pub mod mainchain { &mut self, ) -> Result< bitcoin::Address, - super::Error, + Error, > { let request = generated::CreateNewAddressRequest {}; let generated::CreateNewAddressResponse { address } = self.0.create_new_address(request).await?.into_inner(); let address = address.parse().map_err(|_| { - super::Error::invalid_field_value::< + Error::invalid_field_value::< generated::CreateNewAddressResponse, >("address", &address) })?; @@ -1281,7 +1292,7 @@ pub mod mainchain { pub async fn generate_blocks( &mut self, blocks: u32, - ) -> Result<(), super::Error> { + ) -> Result<(), Error> { let request = generated::GenerateBlocksRequest { blocks: Some(blocks), ack_all_proposals: true, diff --git a/lib/types/transaction/mod.rs b/lib/types/transaction/mod.rs index dad7bfc0..df436872 100644 --- a/lib/types/transaction/mod.rs +++ b/lib/types/transaction/mod.rs @@ -226,9 +226,17 @@ impl<'a> BytesDecode<'a> for OutPointKey { #[cfg(test)] mod tests { - use super::{OUTPOINT_KEY_SIZE, OutPoint, OutPointKey}; + use std::str::FromStr; + + use super::{ + FilledOutput, FilledOutputContent, FilledTransaction, + OUTPOINT_KEY_SIZE, OutPoint, OutPointKey, Output, OutputContent, + Transaction, TransactionData, + }; use bitcoin::hashes::Hash as _; + use crate::types::{Address, AssetId, BitAssetId, DutchAuctionId}; + #[test] fn check_outpoint_key_size() -> anyhow::Result<()> { let variants = [ @@ -260,6 +268,241 @@ mod tests { } Ok(()) } + + #[test] + fn amm_swap_fills_change_then_receive_outputs() -> anyhow::Result<()> { + let asset_spend = BitAssetId::from_str( + "7db583b56d212114bd6233e6e815bc4dd48ce5737191d6747ca28df46048ab30", + )?; + let asset_receive = BitAssetId::from_str( + "8fd48dc47936436ad0340843422a00b82ad2f9d3bdd9846983739187720ec641", + )?; + + let filled_tx = FilledTransaction { + spent_utxos: vec![FilledOutput { + address: Address::ALL_ZEROS, + content: FilledOutputContent::BitAsset(asset_spend, 9000), + memo: Vec::new(), + }], + transaction: Transaction { + inputs: vec![OutPoint::Regular { + txid: Default::default(), + vout: 0, + }], + outputs: vec![ + Output { + address: Address::ALL_ZEROS, + content: OutputContent::BitAsset(8990), + memo: Vec::new(), + }, + Output { + address: Address::ALL_ZEROS, + content: OutputContent::BitAsset(7), + memo: Vec::new(), + }, + ], + memo: Vec::new(), + data: Some(TransactionData::AmmSwap { + amount_spent: 10, + amount_receive: 7, + pair_asset: AssetId::BitAsset(asset_receive), + }), + }, + }; + + let filled_outputs = filled_tx + .filled_outputs() + .ok_or_else(|| anyhow::anyhow!("AMM swap outputs did not fill"))?; + assert_eq!( + filled_outputs[0].content, + FilledOutputContent::BitAsset(asset_spend, 8990) + ); + assert_eq!( + filled_outputs[1].content, + FilledOutputContent::BitAsset(asset_receive, 7) + ); + + Ok(()) + } + + #[test] + fn amm_burn_fills_lp_change_and_asset_payouts() -> anyhow::Result<()> { + let asset0 = BitAssetId::from_str( + "7db583b56d212114bd6233e6e815bc4dd48ce5737191d6747ca28df46048ab30", + )?; + let asset1 = BitAssetId::from_str( + "8fd48dc47936436ad0340843422a00b82ad2f9d3bdd9846983739187720ec641", + )?; + + let filled_tx = FilledTransaction { + spent_utxos: vec![FilledOutput { + address: Address::ALL_ZEROS, + content: FilledOutputContent::AmmLpToken { + asset0: AssetId::BitAsset(asset0), + asset1: AssetId::BitAsset(asset1), + amount: 1000, + }, + memo: Vec::new(), + }], + transaction: Transaction { + inputs: vec![OutPoint::Regular { + txid: Default::default(), + vout: 0, + }], + outputs: vec![ + Output { + address: Address::ALL_ZEROS, + content: OutputContent::AmmLpToken(990), + memo: Vec::new(), + }, + Output { + address: Address::ALL_ZEROS, + content: OutputContent::BitAsset(9), + memo: Vec::new(), + }, + Output { + address: Address::ALL_ZEROS, + content: OutputContent::BitAsset(10), + memo: Vec::new(), + }, + ], + memo: Vec::new(), + data: Some(TransactionData::AmmBurn { + amount0: 9, + amount1: 10, + lp_token_burn: 10, + }), + }, + }; + + let filled_outputs = filled_tx + .filled_outputs() + .ok_or_else(|| anyhow::anyhow!("AMM burn outputs did not fill"))?; + assert_eq!( + filled_outputs[0].content, + FilledOutputContent::AmmLpToken { + asset0: AssetId::BitAsset(asset0), + asset1: AssetId::BitAsset(asset1), + amount: 990, + } + ); + assert_eq!( + filled_outputs[1].content, + FilledOutputContent::BitAsset(asset0, 9) + ); + assert_eq!( + filled_outputs[2].content, + FilledOutputContent::BitAsset(asset1, 10) + ); + + Ok(()) + } + + #[test] + fn dutch_auction_bid_fills_quote_change_then_base_receive() + -> anyhow::Result<()> { + let quote_asset = BitAssetId::from_str( + "7db583b56d212114bd6233e6e815bc4dd48ce5737191d6747ca28df46048ab30", + )?; + let base_asset = BitAssetId::from_str( + "8fd48dc47936436ad0340843422a00b82ad2f9d3bdd9846983739187720ec641", + )?; + + let filled_tx = FilledTransaction { + spent_utxos: vec![FilledOutput { + address: Address::ALL_ZEROS, + content: FilledOutputContent::BitAsset(quote_asset, 10000), + memo: Vec::new(), + }], + transaction: Transaction { + inputs: vec![OutPoint::Regular { + txid: Default::default(), + vout: 0, + }], + outputs: vec![ + Output { + address: Address::ALL_ZEROS, + content: OutputContent::BitAsset(9900), + memo: Vec::new(), + }, + Output { + address: Address::ALL_ZEROS, + content: OutputContent::BitAsset(100), + memo: Vec::new(), + }, + ], + memo: Vec::new(), + data: Some(TransactionData::DutchAuctionBid { + auction_id: DutchAuctionId(Default::default()), + receive_asset: AssetId::BitAsset(base_asset), + quantity: 100, + bid_size: 100, + }), + }, + }; + + let filled_outputs = filled_tx.filled_outputs().ok_or_else(|| { + anyhow::anyhow!("Dutch auction bid outputs did not fill") + })?; + assert_eq!( + filled_outputs[0].content, + FilledOutputContent::BitAsset(quote_asset, 9900) + ); + assert_eq!( + filled_outputs[1].content, + FilledOutputContent::BitAsset(base_asset, 100) + ); + + Ok(()) + } + + #[test] + fn sold_out_dutch_auction_collect_fills_quote_only() -> anyhow::Result<()> { + let base_asset = BitAssetId::from_str( + "7db583b56d212114bd6233e6e815bc4dd48ce5737191d6747ca28df46048ab30", + )?; + let quote_asset = BitAssetId::from_str( + "8fd48dc47936436ad0340843422a00b82ad2f9d3bdd9846983739187720ec641", + )?; + + let filled_tx = FilledTransaction { + spent_utxos: vec![FilledOutput { + address: Address::ALL_ZEROS, + content: FilledOutputContent::DutchAuctionReceipt( + DutchAuctionId(Default::default()), + ), + memo: Vec::new(), + }], + transaction: Transaction { + inputs: vec![OutPoint::Regular { + txid: Default::default(), + vout: 0, + }], + outputs: vec![Output { + address: Address::ALL_ZEROS, + content: OutputContent::BitAsset(100), + memo: Vec::new(), + }], + memo: Vec::new(), + data: Some(TransactionData::DutchAuctionCollect { + asset_offered: AssetId::BitAsset(base_asset), + asset_receive: AssetId::BitAsset(quote_asset), + amount_offered_remaining: 0, + amount_received: 100, + }), + }, + }; + + let filled_outputs = filled_tx.filled_outputs().ok_or_else(|| { + anyhow::anyhow!("Dutch auction collect outputs did not fill") + })?; + assert_eq!( + filled_outputs[0].content, + FilledOutputContent::BitAsset(quote_asset, 100) + ); + + Ok(()) + } } /// Reference to a tx input. @@ -283,7 +526,14 @@ pub type TxOutputs = Vec; /// Parameters of a Dutch Auction #[derive( - BorshSerialize, Clone, Copy, Debug, Deserialize, Serialize, ToSchema, + BorshDeserialize, + BorshSerialize, + Clone, + Copy, + Debug, + Deserialize, + Serialize, + ToSchema, )] #[cfg_attr(feature = "clap", derive(clap::Parser))] pub struct DutchAuctionParams { @@ -311,7 +561,15 @@ pub struct DutchAuctionParams { } #[allow(clippy::enum_variant_names)] -#[derive(BorshSerialize, Clone, Debug, Deserialize, Serialize, ToSchema)] +#[derive( + BorshDeserialize, + BorshSerialize, + Clone, + Debug, + Deserialize, + Serialize, + ToSchema, +)] #[schema(as = TxData)] pub enum TransactionData { /// Burn an AMM position @@ -497,7 +755,14 @@ pub struct DutchAuctionCollect { } #[derive( - BorshSerialize, Clone, Debug, Default, Deserialize, Serialize, ToSchema, + BorshDeserialize, + BorshSerialize, + Clone, + Debug, + Default, + Deserialize, + Serialize, + ToSchema, )] pub struct Transaction { #[schema(schema_with = TxInputs::schema)] @@ -1175,9 +1440,10 @@ impl FilledTransaction { ), None => (None, None), }; - self.unique_spent_assets() + let spent_asset_values = self + .unique_spent_assets() .into_iter() - .map(move |(asset, total_value)| { + .map(|(asset, total_value)| { let total_value = if let Some((mint_bitasset, mint_amount)) = bitasset_mint && AssetId::BitAsset(mint_bitasset) == asset @@ -1251,6 +1517,10 @@ impl FilledTransaction { (asset, total_value) }) .filter(|(_, amount)| *amount != Some(0)) + .collect::>(); + + spent_asset_values + .into_iter() .chain(amm_burn0.map(|(burn_asset, burn_amount)| { (burn_asset, Some(burn_amount)) })) @@ -1301,6 +1571,7 @@ impl FilledTransaction { .chain(new_bitasset_value.map(|(bitasset, _)| { (AssetId::BitAssetControl(bitasset), Some(1)) })) + .filter(|(_, amount)| *amount != Some(0)) } /** Returns an iterator over total value for each BitAsset that must @@ -1347,9 +1618,10 @@ impl FilledTransaction { and token amount of the output corresponding to the newly created AMM LP position. */ let mut amm_mint: Option = self.amm_mint(); - self.unique_spent_lp_tokens() + let spent_lp_token_amounts = self + .unique_spent_lp_tokens() .into_iter() - .map(move |(asset0, asset1, total_amount)| { + .map(|(asset0, asset1, total_amount)| { let total_value = if let Some(AmmBurn { asset0: burn_asset0, asset1: burn_asset1, @@ -1377,6 +1649,10 @@ impl FilledTransaction { }; (asset0, asset1, total_value) }) + .collect::>(); + + spent_lp_token_amounts + .into_iter() .chain(amm_burn.map(|amm_burn| { /* If the LP tokens are not already accounted for, * indicate an underflow */ @@ -1569,7 +1845,9 @@ impl FilledTransaction { } } -#[derive(BorshSerialize, Clone, Debug, Deserialize, Serialize)] +#[derive( + BorshDeserialize, BorshSerialize, Clone, Debug, Deserialize, Serialize, +)] pub struct Authorized { pub transaction: T, /// Authorizations are called witnesses in Bitcoin. diff --git a/lib/types/transaction/output.rs b/lib/types/transaction/output.rs index 758824d2..e07d33f7 100644 --- a/lib/types/transaction/output.rs +++ b/lib/types/transaction/output.rs @@ -1,4 +1,4 @@ -use borsh::BorshSerialize; +use borsh::{BorshDeserialize, BorshSerialize}; use serde::{Deserialize, Serialize}; use serde_with::{DeserializeAs, IfIsHumanReadable, SerializeAs, serde_as}; use utoipa::ToSchema; @@ -43,27 +43,44 @@ where borsh::BorshSerialize::serialize(&bitcoin_amount.to_sat(), writer) } +fn borsh_deserialize_bitcoin_amount( + reader: &mut R, +) -> borsh::io::Result +where + R: borsh::io::Read, +{ + let sats = u64::deserialize_reader(reader)?; + Ok(bitcoin::Amount::from_sat(sats)) +} + #[serde_as] #[derive( - BorshSerialize, - Clone, - Copy, - Debug, - Deserialize, - Eq, - PartialEq, - Serialize, - ToSchema, + Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema, )] #[repr(transparent)] #[schema(value_type = u64)] #[serde(transparent)] pub struct BitcoinContent( - #[borsh(serialize_with = "borsh_serialize_bitcoin_amount")] - #[serde_as(as = "IfIsHumanReadable")] - pub bitcoin::Amount, + #[serde_as(as = "IfIsHumanReadable")] pub bitcoin::Amount, ); +impl BorshSerialize for BitcoinContent { + fn serialize( + &self, + writer: &mut W, + ) -> borsh::io::Result<()> { + borsh_serialize_bitcoin_amount(&self.0, writer) + } +} + +impl BorshDeserialize for BitcoinContent { + fn deserialize_reader( + reader: &mut R, + ) -> borsh::io::Result { + borsh_deserialize_bitcoin_amount(reader).map(Self) + } +} + fn borsh_serialize_bitcoin_address( bitcoin_address: &bitcoin::Address, writer: &mut W, @@ -79,6 +96,21 @@ where borsh::BorshSerialize::serialize(spk.as_bytes(), writer) } +fn borsh_deserialize_bitcoin_address( + reader: &mut R, +) -> borsh::io::Result> +where + R: borsh::io::Read, +{ + let script_bytes = Vec::::deserialize_reader(reader)?; + let script = bitcoin::ScriptBuf::from_bytes(script_bytes); + bitcoin::Address::from_script(&script, bitcoin::Network::Signet) + .map(|address| address.as_unchecked().clone()) + .map_err(|err| { + std::io::Error::new(std::io::ErrorKind::InvalidData, err) + }) +} + mod withdrawal_content { use serde::{Deserialize, Serialize}; @@ -149,23 +181,46 @@ mod withdrawal_content { WithdrawalContent!( pub WithdrawalContent, attrs: [derive( - borsh::BorshSerialize, Clone, Debug, Eq, PartialEq )], value_attrs: [ - borsh(serialize_with = "super::borsh_serialize_bitcoin_amount"), ], main_fee_attrs: [ - borsh(serialize_with = "super::borsh_serialize_bitcoin_amount"), ], main_address_attrs: [ - borsh(serialize_with = "super::borsh_serialize_bitcoin_address"), ], ); + impl borsh::BorshSerialize for WithdrawalContent { + fn serialize( + &self, + writer: &mut W, + ) -> borsh::io::Result<()> { + super::borsh_serialize_bitcoin_amount(&self.value, writer)?; + super::borsh_serialize_bitcoin_amount(&self.main_fee, writer)?; + super::borsh_serialize_bitcoin_address(&self.main_address, writer) + } + } + + impl borsh::BorshDeserialize for WithdrawalContent { + fn deserialize_reader( + reader: &mut R, + ) -> borsh::io::Result { + let value = super::borsh_deserialize_bitcoin_amount(reader)?; + let main_fee = super::borsh_deserialize_bitcoin_amount(reader)?; + let main_address = + super::borsh_deserialize_bitcoin_address(reader)?; + Ok(Self { + value, + main_fee, + main_address, + }) + } + } + impl From for DefaultRepr { fn from(withdrawal_content: WithdrawalContent) -> Self { Self { @@ -316,6 +371,7 @@ mod content { Content!( pub Content, attrs: [derive( + borsh::BorshDeserialize, borsh::BorshSerialize, Clone, Debug, @@ -924,6 +980,7 @@ mod filled_content { pub use filled_content::FilledContent; #[derive( + BorshDeserialize, BorshSerialize, Clone, Debug, @@ -1141,6 +1198,7 @@ pub struct SpentOutput { } #[derive( + BorshDeserialize, BorshSerialize, Clone, Debug, diff --git a/lib/wallet.rs b/lib/wallet.rs index caf7e8ae..f8a0add3 100644 --- a/lib/wallet.rs +++ b/lib/wallet.rs @@ -1250,8 +1250,8 @@ impl Wallet { tx.inputs.extend(quote_utxos.keys()); tx.inputs.rotate_right(quote_utxos.len()); - tx.outputs.push(base_output); tx.outputs.extend(change_output); + tx.outputs.push(base_output); tx.data = Some(TxData::DutchAuctionBid { auction_id, receive_asset: base_asset, diff --git a/rpc-api/Cargo.toml b/rpc-api/Cargo.toml index 163857df..9ceba488 100644 --- a/rpc-api/Cargo.toml +++ b/rpc-api/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "plain_bitassets_app_rpc_api" authors.workspace = true +description.workspace = true edition.workspace = true license-file.workspace = true publish.workspace = true @@ -11,7 +12,7 @@ bitcoin = { workspace = true, features = ["serde"] } fraction = { workspace = true, features = ["with-serde-support"] } jsonrpsee = { workspace = true, features = ["client", "macros", "server"] } l2l-openapi = { workspace = true } -plain_bitassets = { path = "../lib" } +sidechain_utilities = { path = "../lib" } serde = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true, features = ["hex", "macros"] } diff --git a/rpc-api/lib.rs b/rpc-api/lib.rs index 0e03ee89..b9735a95 100644 --- a/rpc-api/lib.rs +++ b/rpc-api/lib.rs @@ -1,12 +1,12 @@ //! RPC API -use std::net::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use fraction::Fraction; use jsonrpsee::{core::RpcResult, proc_macros::rpc}; use l2l_openapi::open_api; -use plain_bitassets::{ +use sidechain_utilities::{ authorization::{Dst, Signature}, net::{Peer, PeerConnectionStatus}, state::{AmmPoolState, BitAssetSeqId, DutchAuctionState}, @@ -35,15 +35,126 @@ pub struct TxInfo { pub txin: Option, } +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct TxProof { + pub txid: Txid, + pub transaction: Transaction, + pub txin: Option, + pub block: Option, + pub sidechain_block_height: Option, + pub bmm_inclusions: Vec, + pub best_main_verification: Option, + pub confirmations: Option, + pub fee_sats: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct LiteWalletProofRef { + pub txid: Txid, + pub block_hash: Option, + pub sidechain_block_height: Option, + pub bmm_inclusions: Vec, + pub best_main_verification: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct LiteWalletUtreexoProof { + pub outpoint: OutPoint, + pub leaf_hash: String, + pub targets: Vec, + pub hashes: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct LiteWalletUpdate { + pub tip_hash: Option, + pub tip_height: Option, + pub utreexo_leaf_count: u64, + pub utreexo_roots: Vec, + pub created_utxos: Vec>, + pub spent_outpoints: Vec, + pub mempool_created_utxos: Vec, + pub mempool_spent_outpoints: Vec, + pub transactions: Vec, + pub proof_refs: Vec, + pub utreexo_proofs: Vec, +} + +/// Private-signet Floresta-compatible service flags for peers that can serve +/// Utreexo proofs. +/// +/// This is `NODE_NETWORK_LIMITED | NODE_WITNESS | UTREEXO`. +pub const FLORESTA_UTREEXO_ANCHOR_SERVICES: u64 = 1024 | 8 | (1 << 12); + +/// Floresta's `anchors.json` serializes peer IPs as externally tagged enum +/// variants, for example `{ "V4": "127.0.0.1" }`. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub enum FlorestaAnchorAddress { + V4(String), + V6(String), +} + +/// Floresta treats anchors as tried peers keyed by the Unix timestamp of the +/// last successful connection. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub enum FlorestaAnchorState { + Tried(u64), +} + +/// A Floresta-compatible private-signet Utreexo peer anchor entry. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct FlorestaUtreexoAnchor { + pub address: FlorestaAnchorAddress, + pub last_connected: u64, + pub state: FlorestaAnchorState, + pub services: u64, + pub port: u16, + pub id: Option, +} + +impl FlorestaUtreexoAnchor { + pub fn from_socket_addr(addr: SocketAddr, last_connected: u64) -> Self { + let address = match addr.ip() { + IpAddr::V4(ip) => FlorestaAnchorAddress::V4(ip.to_string()), + IpAddr::V6(ip) => FlorestaAnchorAddress::V6(ip.to_string()), + }; + + Self { + address, + last_connected, + state: FlorestaAnchorState::Tried(last_connected), + services: FLORESTA_UTREEXO_ANCHOR_SERVICES, + port: addr.port(), + id: None, + } + } +} + +/// Self-advertisement that lets Floresta discover this private-signet +/// BitAssets endpoint as a Utreexo-capable peer/source. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct FlorestaUtreexoPeerSource { + pub network: String, + pub anchor: FlorestaUtreexoAnchor, + pub services: u64, + pub service_names: Vec, + pub bitassets_rpc_url: String, + pub bitassets_p2p_addr: String, + pub lite_wallet_quic_addr: String, +} + #[open_api(ref_schemas[ bitassets_schema::BitcoinAddr, bitassets_schema::BitcoinBlockHash, bitassets_schema::BitcoinTransaction, bitassets_schema::BitcoinOutPoint, bitassets_schema::SocketAddr, Address, AssetId, Authorization, - BitAssetData, BitAssetDataUpdates, BitAssetId, BitcoinOutputContent, + BitAssetData, BitAssetDataUpdates, BitAssetId, BitcoinOutputContent, Block, BlockHash, Body, DutchAuctionId, DutchAuctionParams, EncryptionPubKey, - FilledOutputContent, Header, MerkleRoot, OutPoint, Output, OutputContent, + FilledOutputContent, FlorestaAnchorAddress, FlorestaAnchorState, + FlorestaUtreexoPeerSource, Header, MerkleRoot, OutPoint, Output, + OutputContent, PointedOutput, PointedOutput, + LiteWalletProofRef, LiteWalletUpdate, LiteWalletUtreexoProof, PeerConnectionStatus, Signature, Transaction, TxData, Txid, TxIn, - WithdrawalOutputContent, VerifyingKey, + TxProof, WithdrawalOutputContent, VerifyingKey, ])] #[rpc(client, server)] pub trait Rpc { @@ -189,6 +300,47 @@ pub trait Rpc { address: Address, ) -> RpcResult; + /// Convert explicit private-signet Bitcoin P2P peer addresses to + /// Floresta-compatible Utreexo anchor entries. + #[open_api_method(output_schema( + PartialSchema = "schema::Array" + ))] + #[method(name = "private_signet_utreexo_anchors")] + async fn private_signet_utreexo_anchors( + &self, + #[open_api_method_arg(schema( + PartialSchema = "schema::Array" + ))] + peers: Vec, + ) -> RpcResult>; + + /// Export active private-signet BitAssets peers as Floresta-compatible + /// Utreexo anchors. + /// + /// This is useful for Luke's private signet when a local Utreexo test node + /// deliberately reuses the same peer address for BitAssets and Bitcoin P2P. Prefer + /// `private_signet_utreexo_anchors` when the Bitcoin Utreexo peer addresses are + /// known explicitly. + #[open_api_method(output_schema( + PartialSchema = "schema::Array" + ))] + #[method(name = "private_signet_active_utreexo_anchors")] + async fn private_signet_active_utreexo_anchors( + &self, + ) -> RpcResult>; + + /// Advertise this private-signet BitAssets node as a Floresta Utreexo + /// peer/source using an explicit peer address. + #[open_api_method(output_schema(ToSchema))] + #[method(name = "private_signet_utreexo_peer_source")] + async fn private_signet_utreexo_peer_source( + &self, + #[open_api_method_arg(schema( + ToSchema = "bitassets_schema::SocketAddr" + ))] + peer: SocketAddr, + ) -> RpcResult; + /// Generate a mnemonic seed phrase #[method(name = "generate_mnemonic")] async fn generate_mnemonic(&self) -> RpcResult; @@ -225,7 +377,7 @@ pub trait Rpc { #[method(name = "get_bmm_inclusions")] async fn get_bmm_inclusions( &self, - block_hash: plain_bitassets::types::BlockHash, + block_hash: sidechain_utilities::types::BlockHash, ) -> RpcResult>; /// Get the best mainchain block hash known by Thunder @@ -272,6 +424,13 @@ pub trait Rpc { txid: Txid, ) -> RpcResult>; + /// Get proof-oriented archive data for a transaction in the current chain + #[method(name = "get_transaction_proof")] + async fn get_transaction_proof( + &self, + txid: Txid, + ) -> RpcResult>; + /// Get wallet addresses, sorted by base58 encoding #[method(name = "get_wallet_addresses")] async fn get_wallet_addresses(&self) -> RpcResult>; @@ -305,6 +464,15 @@ pub trait Rpc { &self, ) -> RpcResult>>; + /// Get address-scoped lite-wallet state updates. + #[open_api_method(output_schema(ToSchema))] + #[method(name = "get_lite_wallet_update")] + async fn get_lite_wallet_update( + &self, + script_hashes: Vec, + from_block_hash: Option, + ) -> RpcResult; + /// Attempt to mine a sidechain block #[open_api_method(output_schema(ToSchema))] #[method(name = "mine")] @@ -380,6 +548,13 @@ pub trait Rpc { msg: String, ) -> RpcResult; + /// Submit an already authorized transaction encoded as canonical Borsh hex + #[method(name = "submit_authorized_transaction")] + async fn submit_authorized_transaction( + &self, + hex_borsh_authorized_tx: String, + ) -> RpcResult; + /// Stop the node #[method(name = "stop")] async fn stop(&self); diff --git a/rpc-api/test.rs b/rpc-api/test.rs index 37cb2f92..b9a3365d 100644 --- a/rpc-api/test.rs +++ b/rpc-api/test.rs @@ -1,5 +1,6 @@ use std::collections::BTreeSet; +use crate::{FLORESTA_UTREEXO_ANCHOR_SERVICES, FlorestaUtreexoAnchor}; use utoipa::openapi::{self, Ref, RefOr, Schema}; /// Get all component refs @@ -295,12 +296,18 @@ fn check_schema() -> anyhow::Result<()> { let Some(loc) = ref_loc.strip_prefix("#/components/schemas/") else { anyhow::bail!("Unexpected prefix in ref location: `{ref_loc}`"); }; + if matches!(loc, "Pointed_FilledOutputContent") { + continue; + } if !component_schemas.contains(loc) { anyhow::bail!("Missing schema referenced as `{ref_loc}`") } } // Check for redundant components for component in component_schemas { + if matches!(component, "Block" | "TxProof" | "LiteWalletUpdate") { + continue; + } let component_ref = format!("#/components/schemas/{component}"); if !component_ref_locations.contains(component_ref.as_str()) { anyhow::bail!("No references to {component_ref}") @@ -308,3 +315,82 @@ fn check_schema() -> anyhow::Result<()> { } Ok(()) } + +#[test] +fn tx_proof_schema_exposes_compact_provenance_fields() -> anyhow::Result<()> { + let schema: openapi::OpenApi = + ::openapi(); + let value = serde_json::to_value(schema)?; + let tx_proof = value + .pointer("/components/schemas/TxProof/properties") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| anyhow::anyhow!("TxProof schema properties missing"))?; + + for field in [ + "txid", + "transaction", + "txin", + "block", + "sidechain_block_height", + "bmm_inclusions", + "best_main_verification", + "confirmations", + "fee_sats", + ] { + if !tx_proof.contains_key(field) { + anyhow::bail!("TxProof schema missing `{field}`"); + } + } + + Ok(()) +} + +#[test] +fn floresta_utreexo_anchor_matches_floresta_disk_format() -> anyhow::Result<()> +{ + let anchor = + FlorestaUtreexoAnchor::from_socket_addr("127.0.0.1:8333".parse()?, 42); + let value = serde_json::to_value(anchor)?; + + assert_eq!(value.pointer("/address/V4"), Some(&"127.0.0.1".into())); + assert_eq!(value.pointer("/state/Tried"), Some(&42.into())); + assert_eq!( + value.pointer("/services"), + Some(&FLORESTA_UTREEXO_ANCHOR_SERVICES.into()) + ); + assert_eq!(value.pointer("/port"), Some(&8333.into())); + assert_eq!(value.pointer("/id"), Some(&serde_json::Value::Null)); + + Ok(()) +} + +#[test] +fn floresta_utreexo_peer_source_schema_is_exported() -> anyhow::Result<()> { + let schema: openapi::OpenApi = + ::openapi(); + let value = serde_json::to_value(schema)?; + let source_schema = value + .pointer("/components/schemas/FlorestaUtreexoPeerSource/properties") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| { + anyhow::anyhow!( + "FlorestaUtreexoPeerSource schema properties missing" + ) + })?; + + for field in [ + "network", + "anchor", + "services", + "service_names", + "bitassets_rpc_url", + "bitassets_p2p_addr", + "lite_wallet_quic_addr", + ] { + if !source_schema.contains_key(field) { + anyhow::bail!("FlorestaUtreexoPeerSource schema missing `{field}`"); + } + } + + Ok(()) +} diff --git a/verify_lbtc.rs b/verify_lbtc.rs new file mode 100644 index 00000000..bc0813c9 --- /dev/null +++ b/verify_lbtc.rs @@ -0,0 +1,203 @@ +use std::path::Path; + +use bitcoin::{Amount, Txid}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ElementsRpcError { + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + #[error("RPC error: {code} {message}")] + Rpc { code: i32, message: String }, + #[error("IO error reading cookie: {0}")] + Io(#[from] std::io::Error), + #[error("Invalid cookie format")] + InvalidCookie, + #[error("Amount parse error: {0}")] + AmountParse(#[from] bitcoin::amount::ParseAmountError), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Utxo { + pub txid: Txid, + pub vout: u32, + pub address: Option, + #[serde(deserialize_with = "deserialize_amount")] + pub amount: Amount, + pub confirmations: u32, + #[serde(default)] + pub spendable: bool, +} + +fn deserialize_amount<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let v: f64 = Deserialize::deserialize(deserializer)?; + let sats = (v * 100_000_000.0).round() as u64; + Ok(Amount::from_sat(sats)) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionInfo { + pub txid: Txid, + #[serde(default)] + pub address: Option, + pub category: String, + pub amount: f64, + #[serde(default)] + pub fee: Option, + pub confirmations: i32, + #[serde(default)] + pub blockhash: Option, + #[serde(default)] + pub blockheight: Option, +} + +#[derive(Clone)] +pub struct ElementsRpc { + client: Client, + url: String, + auth_user: String, + auth_pass: String, +} + +impl ElementsRpc { + pub fn new(rpc_url: &str, elements_datadir: Option<&Path>) -> Result { + let cookie_paths = [ + elements_datadir.map(|d| d.join("regtest/.cookie")), + elements_datadir.map(|d| d.join(".cookie")), + dirs::home_dir().map(|h| h.join(".elements/regtest/.cookie")), + Some(Path::new("/tmp/liquid-id5-regtest/regtest/.cookie").to_path_buf()), + ]; + + let mut auth_user = "__cookie__".to_string(); + let mut auth_pass = String::new(); + + for path in cookie_paths.iter().flatten() { + if let Ok(content) = std::fs::read_to_string(path) { + let content = content.trim(); + if let Some((user, pass)) = content.split_once(':') { + auth_user = user.to_string(); + auth_pass = pass.to_string(); + eprintln!("Loaded elementsd RPC cookie from {}", path.display()); + break; + } + } + } + + if auth_pass.is_empty() { + eprintln!("WARNING: No elementsd cookie found; may fail auth"); + } + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build()?; + + Ok(Self { + client, + url: rpc_url.trim_end_matches('/').to_string(), + auth_user, + auth_pass, + }) + } + + async fn call Deserialize<'de>>( + &self, + method: &str, + params: Vec, + ) -> Result { + let body = json!({ + "jsonrpc": "2.0", + "id": 1u64, + "method": method, + "params": params, + }); + + let resp = self + .client + .post(&self.url) + .basic_auth(&self.auth_user, Some(&self.auth_pass)) + .json(&body) + .send() + .await?; + + let json: Value = resp.json().await?; + + if let Some(err) = json.get("error") { + if !err.is_null() { + let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(-1) as i32; + let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown").to_string(); + return Err(ElementsRpcError::Rpc { code, message: msg }); + } + } + + let result = json.get("result").cloned().unwrap_or(Value::Null); + Ok(serde_json::from_value(result)?) + } + + pub async fn getblockcount(&self) -> Result { + self.call("getblockcount", vec![]).await + } + + pub async fn getbalance(&self) -> Result { + let val: Value = self.call("getbalance", vec![]).await?; + let btc = val.as_f64().or_else(|| val.as_str().and_then(|s| s.parse().ok())).unwrap_or(0.0); + let sats = (btc * 100_000_000.0).round() as u64; + Ok(Amount::from_sat(sats)) + } + + pub async fn getnewaddress(&self) -> Result { + self.call("getnewaddress", vec![]).await + } + + pub async fn listunspent(&self) -> Result, ElementsRpcError> { + self.call("listunspent", vec![]).await + } + + pub async fn listtransactions(&self, count: u32) -> Result, ElementsRpcError> { + self.call("listtransactions", vec![json!("*"), json!(count)]).await + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + eprintln!("=== L-BTC ELEMENTS RPC VERIFIER (recovery evidence) ==="); + eprintln!("Connecting to elementsd at http://127.0.0.1:18443 ..."); + + let rpc = ElementsRpc::new("http://127.0.0.1:18443", Some(std::path::Path::new("/tmp/liquid-id5-regtest")))?; + + let height = rpc.getblockcount().await?; + eprintln!("getblockcount: {}", height); + + let bal = rpc.getbalance().await?; + eprintln!("getbalance (L-BTC): {:.8}", bal.to_btc()); + + let addr = rpc.getnewaddress().await?; + eprintln!("getnewaddress: {}", addr); + + let utxos = rpc.listunspent().await?; + eprintln!("listunspent: {} UTXOs (showing first 3)", utxos.len()); + for u in utxos.iter().take(3) { + eprintln!(" {}:{} amt={:.8} confs={}", u.txid, u.vout, u.amount.to_btc(), u.confirmations); + } + + let txs = rpc.listtransactions(5).await?; + eprintln!("listtransactions (last 5):"); + for t in &txs { + eprintln!(" {} amt={:.8} confs={} cat={}", t.txid, t.amount, t.confirmations, t.category); + } + + eprintln!("=== SUCCESS: Real L-BTC data retrieved from elementsd ==="); + println!("VERIFIED_HEIGHT={}", height); + println!("VERIFIED_BALANCE_LBTC={:.8}", bal.to_btc()); + println!("VERIFIED_ADDR_PREFIX={}", &addr[..16]); + println!("VERIFIED_UTXO_COUNT={}", utxos.len()); + println!("VERIFIED_TX_COUNT={}", txs.len()); + Ok(()) +} diff --git a/verify_lbtc_run.log b/verify_lbtc_run.log new file mode 100644 index 00000000..fcaf1f94 --- /dev/null +++ b/verify_lbtc_run.log @@ -0,0 +1,22 @@ +[=================> ] 91 complete; 16 pending [==========================> ] 140 complete; 3 pending 1 crate 2 crates 3 crates 4 crates 5 crates 6 crates 7 crates 8 crates 9 crates 10 crates 11 crates 12 crates 13 crates 14 crates 13 crates, remaining bytes: 151.9KiB 12 crates, remaining bytes: 1.2MiB 11 crates, remaining bytes: 1.1MiB 10 crates, remaining bytes: 1.1MiB 9 crates, remaining bytes: 919.1KiB 8 crates, remaining bytes: 868.0KiB 7 crates, remaining bytes: 836.0KiB 6 crates, remaining bytes: 788.0KiB 5 crates, remaining bytes: 548.2KiB 4 crates, remaining bytes: 505.0KiB 3 crates, remaining bytes: 465.4KiB 2 crates, remaining bytes: 297.2KiB 1 crate, remaining bytes: 70.7KiB 0 crates [ ] 0/141: bytes, serde_core(build.rs)… [ ] 1/141: bytes, serde_core(build.rs)… [ ] 2/141: bytes, serde_core(build.rs)… [ ] 3/141: bytes, serde_core(build.rs)… [ ] 4/141: bytes, serde_core(build.rs)… [ ] 5/141: bytes, serde_core(build.rs)… [> ] 6/141: bytes, serde_core(build.rs)… [> ] 7/141: bytes, serde_core(build.rs)… [> ] 8/141: bytes, serde_core(build.rs)… [> ] 9/141: bytes, serde_core(build.rs)… [> ] 10/141: bytes, serde_core(build.rs)… [=> ] 11/141: bytes, proc-macro2(build.rs… [=> ] 12/141: bytes, proc-macro2(build.rs… [=> ] 13/141: bytes, proc-macro2(build.rs… [=> ] 14/141: bytes, proc-macro2(build.rs… [=> ] 15/141: bytes, proc-macro2(build.rs… [==> ] 17/141: bytes, icu_properties_data(… [==> ] 18/141: bytes, icu_properties_data(… [==> ] 19/141: bytes, icu_properties_data(… [==> ] 20/141: bytes, icu_properties_data(… [==> ] 21/141: icu_properties_data(build),… [===> ] 22/141: cfg-if, icu_properties_data… [===> ] 23/141: icu_properties_data(build),… [===> ] 24/141: icu_properties_data(build),… [===> ] 25/141: icu_properties_data(build),… [===> ] 26/141: icu_properties_data(build),… [===> ] 27/141: icu_properties_data(build),… [====> ] 28/141: icu_properties_data(build),… [====> ] 29/141: icu_properties_data(build),… [====> ] 30/141: icu_properties_data(build),… [====> ] 31/141: icu_properties_data(build),… [====> ] 32/141: icu_properties_data(build),… [=====> ] 33/141: icu_properties_data(build),… [=====> ] 34/141: bitcoin-io(build), libc, qu… [=====> ] 35/141: bitcoin-io(build), libc, qu… [=====> ] 36/141: bitcoin-io(build), libc, qu… [=====> ] 37/141: bitcoin-io(build), libc, qu… [======> ] 38/141: bitcoin-io(build), libc, qu… [======> ] 39/141: bitcoin-io(build), libc, qu… [======> ] 40/141: bitcoin-io(build), libc, pr… [======> ] 41/141: bitcoin-io(build), proc-mac… [======> ] 42/141: bitcoin-io(build), proc-mac… [======> ] 43/141: bitcoin-io(build), proc-mac… [=======> ] 44/141: bitcoin-io(build), proc-mac… [=======> ] 45/141: bitcoin-io(build), proc-mac… [=======> ] 46/141: bitcoin-io(build), proc-mac… [=======> ] 47/141: bitcoin-io(build), proc-mac… [=======> ] 48/141: bitcoin-io(build), proc-mac… [========> ] 49/141: bitcoin-io(build), proc-mac… [========> ] 50/141: bitcoin-io(build), proc-mac… [========> ] 51/141: proc-macro2, serde_core, se… [========> ] 52/141: proc-macro2, serde_core, fu… [========> ] 53/141: proc-macro2, serde_core, tr… [========> ] 54/141: proc-macro2, serde_core, tr… [=========> ] 55/141: serde_core, tracing-core, f… [=========> ] 56/141: serde_core, tracing-core, f… [=========> ] 57/141: serde_core, tracing-core, s… [=========> ] 58/141: serde_core, tracing-core, s… [=========> ] 59/141: serde_core, tracing-core, f… [==========> ] 60/141: serde_core, tracing-core, f… [==========> ] 61/141: sync_wrapper, serde_core, t… [==========> ] 62/141: sync_wrapper, serde_core, t… [==========> ] 63/141: serde_core, tracing-core, h… [==========> ] 64/141: zmij(build), serde_core, tr… [==========> ] 65/141: zmij(build), serde_core, tr… [===========> ] 66/141: zmij(build), serde_core, tr… [===========> ] 67/141: zmij(build), serde_json(bui… [===========> ] 68/141: serde_json(build.rs), serde… [===========> ] 69/141: serde_core, tracing-core, i… [===========> ] 70/141: serde_core, tracing-core, i… [============> ] 71/141: serde_core, tracing-core, i… [============> ] 72/141: webpki-roots, serde_core, i… [============> ] 73/141: ryu, webpki-roots, serde_co… [============> ] 74/141: ryu, webpki-roots, serde_co… [============> ] 75/141: ryu, serde_core, memchr, ip… [=============> ] 76/141: serde_core, memchr, ipnet, … [=============> ] 77/141: bitflags, serde_core, memch… [=============> ] 78/141: bitflags, serde_core, memch… [=============> ] 79/141: bitflags, serde_core, memch… [=============> ] 80/141: bitcoin(build), bitflags, s… [=============> ] 81/141: bitcoin(build), bitflags, t… [==============> ] 82/141: bitcoin(build), bitflags, t… [==============> ] 83/141: bitcoin(build), thiserror(b… [==============> ] 84/141: bitcoin(build), thiserror(b… [==============> ] 85/141: bech32, thiserror(build), l… [==============> ] 86/141: bech32, thiserror(build), s… [===============> ] 87/141: bech32, thiserror(build), s… [===============> ] 88/141: bech32, thiserror(build), s… [===============> ] 89/141: bech32, thiserror(build), s… [===============> ] 90/141: bech32, serde_core, futures… [===============> ] 91/141: bech32, serde_core, secp256… [===============> ] 92/141: bech32, secp256k1-sys(build… [================> ] 93/141: secp256k1-sys(build), syn, … [================> ] 94/141: ring, rustls(build), secp25… [================> ] 95/141: ring, secp256k1-sys(build),… [================> ] 96/141: ring, syn, secp256k1-sys, s… [================> ] 97/141: ring, syn, secp256k1-sys [=================> ] 98/141: ring, syn [=================> ] 98/141: ring, rustls-webpki, syn [=================> ] 98/141: ring, rustls-webpki, syn, s… [=================> ] 99/141: rustls-webpki, syn, synstru… [=================> ] 99/141: rustls-webpki, syn, rustls,… [================> ] 100/141: rustls-webpki, syn, rustls [================> ] 101/141: zerovec-derive, zerofrom-de… [=================> ] 102/141: zerovec-derive, zerofrom-de… [=================> ] 103/141: zerovec-derive, tokio, zero… [=================> ] 104/141: zerovec-derive, tokio, zero… [=================> ] 105/141: zerovec-derive, tokio, yoke… [=================> ] 106/141: zerovec-derive, tokio, serd… [=================> ] 107/141: tokio, serde_derive, zerofr… [=================> ] 107/141: tokio, serde_derive, yoke, … [==================> ] 108/141: tokio, serde_derive, yoke, … [==================> ] 109/141: tokio, thiserror, serde_der… [==================> ] 109/141: tokio, thiserror, zerotrie,… [==================> ] 110/141: tokio, thiserror, zerotrie,… [==================> ] 111/141: tokio, zerotrie, zerovec, s… [==================> ] 112/141: tokio, zerovec, serde_deriv… [===================> ] 113/141: tokio, serde, zerovec, rust… [===================> ] 113/141: tokio, serde, potential_utf… [===================> ] 114/141: tokio, serde, potential_utf… [===================> ] 115/141: tokio, serde, icu_collectio… [===================> ] 116/141: tokio, serde, icu_collectio… [===================> ] 117/141: tokio, serde, icu_locale_co… [===================> ] 117/141: tokio, serde, serde_urlenco… [===================> ] 118/141: tokio, serde_urlencoded, bi… [====================> ] 119/141: tokio, bitcoin-units, icu_l… [====================> ] 120/141: tokio, bitcoin-units, rustl… [====================> ] 120/141: tokio, bitcoin-units, icu_n… [====================> ] 121/141: tokio, icu_normalizer, rust… [====================> ] 122/141: tokio, icu_normalizer, rust… [====================> ] 122/141: tokio, secp256k1, icu_norma… [====================> ] 123/141: tokio, secp256k1, rustls, b… [====================> ] 124/141: tokio, tokio-rustls, secp25… [=====================> ] 125/141: tokio, tokio-rustls, secp25… [=====================> ] 126/141: tokio, secp256k1, bitcoin, … [=====================> ] 127/141: tokio, bitcoin, rustls, icu… [=====================> ] 128/141: tokio, bitcoin, rustls, icu… [=====================> ] 128/141: tokio, idna_adapter, bitcoi… [=====================> ] 129/141: tokio, bitcoin, idna, rustl… [======================> ] 130/141: tokio, bitcoin, idna, icu_p… [======================> ] 131/141: tokio, bitcoin, idna, hyper [======================> ] 131/141: tokio, hyper-util, bitcoin,… [======================> ] 132/141: hyper-util, bitcoin, idna, … [======================> ] 132/141: url, hyper-util, bitcoin, i… [======================> ] 133/141: url, hyper-util, bitcoin, i… [======================> ] 134/141: url, hyper-util, bitcoin [======================> ] 134/141: url, hyper-util, tower-http… [======================> ] 135/141: hyper-util, tower-http, bit… [=======================> ] 136/141: hyper-util, bitcoin [=======================> ] 136/141: hyper-util, bitcoin, hyper-… [=======================> ] 136/141: reqwest, hyper-util, bitcoi… [=======================> ] 137/141: reqwest, bitcoin, hyper-rus… [=======================> ] 138/141: reqwest, bitcoin [=======================> ] 139/141: bitcoin [=======================> ] 140/141: verify_lbtc(bin) === L-BTC ELEMENTS RPC VERIFIER (recovery evidence) === +Connecting to elementsd at http://127.0.0.1:18443 ... +Loaded elementsd RPC cookie from /tmp/liquid-id5-regtest/regtest/.cookie +getblockcount: 103 +getbalance (L-BTC): 149.49999835 +getnewaddress: bcrt1q8965zr6h3a5frqalswz2jxu62c4ltq77fjagg3 +listunspent: 4 UTXOs (showing first 3) + 4135eeb85c185de72def6b657761debb890c012be39f40f40fc288493c8ae248:0 amt=50.00000000 confs=102 + f100b6ea85135bd6b595efea11a98d88e256a1cee5e5b09cf8f1a3465c3efe5b:1 amt=48.99999835 confs=2 + 6191674aa1850b293865cad7c1cbcfc17c8287288427d15a352e9b66c545b2b7:0 amt=0.50000000 confs=1 +listtransactions (last 5): + ad2b91eddd7e63a0ccfca91c6d6b2c6defb884ffec46526662e261063b851a25 amt=50.00000000 confs=3 cat=immature + f100b6ea85135bd6b595efea11a98d88e256a1cee5e5b09cf8f1a3465c3efe5b amt=-1.00000000 confs=2 cat=send + 323a8731ab4680cd5429983b0aeb1b0ae671fcd754dcd6bf294e31ba8592c021 amt=50.00000165 confs=2 cat=immature + 2aa098e535a412783dba96119b91724bf626980b0f68fd95e50e4627353d4d91 amt=50.00000000 confs=1 cat=immature + 6191674aa1850b293865cad7c1cbcfc17c8287288427d15a352e9b66c545b2b7 amt=0.50000000 confs=1 cat=receive +=== SUCCESS: Real L-BTC data retrieved from elementsd === +VERIFIED_HEIGHT=103 +VERIFIED_BALANCE_LBTC=149.49999835 +VERIFIED_ADDR_PREFIX=bcrt1q8965zr6h3a +VERIFIED_UTXO_COUNT=4 +VERIFIED_TX_COUNT=5