diff --git a/.github/workflows/android-bindgen.yml b/.github/workflows/android-bindgen.yml index 6282c05..98afa30 100644 --- a/.github/workflows/android-bindgen.yml +++ b/.github/workflows/android-bindgen.yml @@ -60,7 +60,7 @@ jobs: android-bindgen: name: Android Bindgen runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 90 steps: - name: Checkout diff --git a/.gitignore b/.gitignore index 94644d9..df1c978 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ bindings/ios/BitkitCore.xcframework.zip !*.local.template* /tests/usdt-fork/node_modules/ +src/modules/arkade/intents/oracle/node_modules diff --git a/AGENTS.md b/AGENTS.md index 743a64e..b59d26b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Android bindings are built and published by `.github/workflows/gradle-publish.ym ```bash cargo test # All tests -cargo test modules:: # Single module (scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky, usdt) +cargo test modules:: # Single module (arkade, scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky, usdt) ``` ## Lint & Format @@ -35,7 +35,7 @@ Android bindings use ktlint via Gradle plugin (`org.jlleitschuh.gradle.ktlint`), ## Architecture - `src/lib.rs` — UniFFI exports and module re-exports -- `src/modules/`: core modules: scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky, usdt +- `src/modules/`: core modules: arkade, scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky, usdt - `bindings/` — Platform-specific binding outputs (ios/, android/, python/) - `build.sh`, `build_ios.sh`, `build_android.sh`, `build_python.sh` — Build scripts diff --git a/Cargo.lock b/Cargo.lock index 1e2e36b..4770279 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -272,6 +272,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "antlr4rust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093d520274bfff7278d776f7ea12981a0a0a6f96db90964658e0f38fc6e9a6a6" +dependencies = [ + "better_any", + "bit-set", + "byteorder", + "lazy_static", + "murmur3", + "once_cell", + "parking_lot 0.12.5", + "typed-arena", + "uuid", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -290,6 +307,106 @@ dependencies = [ "password-hash", ] +[[package]] +name = "ark-bdk-wallet" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc40e5a6c27d842e477de7510ab327c667383622aa414af3115e9b0377f20142" +dependencies = [ + "anyhow", + "ark-client", + "ark-core", + "async-stream", + "bdk_esplora", + "bdk_wallet", + "bitcoin 0.32.8", + "esplora-client", + "getrandom 0.2.17", + "gloo-timers", + "jiff", + "tracing", +] + +[[package]] +name = "ark-client" +version = "0.12.0" +dependencies = [ + "ark-core", + "ark-delegator", + "ark-fees", + "ark-grpc", + "async-stream", + "async-trait", + "backon", + "base64 0.22.1", + "bech32 0.11.1", + "bitcoin 0.32.8", + "futures", + "futures-util", + "getrandom 0.2.17", + "gloo-timers", + "jiff", + "lightning-invoice 0.33.3", + "prost 0.13.5", + "prost-types 0.13.5", + "rand 0.8.5", + "reqwest 0.12.28", + "secp256k1 0.33.1", + "serde", + "serde_json", + "serde_with", + "tokio", + "tonic 0.14.9", + "tonic-build", + "tonic-prost", + "tonic-web-wasm-client", + "tracing", + "wasm-bindgen-futures", +] + +[[package]] +name = "ark-core" +version = "0.12.0" +dependencies = [ + "bech32 0.11.1", + "bitcoin 0.32.8", + "bs58", + "getrandom 0.2.17", + "getrandom 0.3.4", + "hex", + "js-sys", + "rand 0.8.5", + "secp256k1 0.33.1", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "web-sys", +] + +[[package]] +name = "ark-delegator" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08574857df0ca54c13558255f2e311463232bc2092aefc1d72b47575ffdf8b17" +dependencies = [ + "ark-core", + "bitcoin 0.32.8", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "ark-fees" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b0f340600e1837ad3a5f57dd160775d4dcebb603bc922cfa33c68fd7e2fc64c" +dependencies = [ + "cel", +] + [[package]] name = "ark-ff" version = "0.3.0" @@ -456,6 +573,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ark-grpc" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c09b5cec33fbcff8b7a71092ad21a8940d333c093750972ed34692800db0712" +dependencies = [ + "ark-core", + "async-stream", + "base64 0.22.1", + "bitcoin 0.32.8", + "futures", + "log", + "prost 0.14.4", + "serde_json", + "tonic 0.14.9", + "tonic-prost", + "tonic-prost-build", +] + +[[package]] +name = "ark-script" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614469269c5a043ac909ed1576def0ad88a49b7753bb4e40ac8568e1c3210b26" +dependencies = [ + "bitcoin 0.32.8", + "hex", + "thiserror 1.0.69", +] + [[package]] name = "ark-serialize" version = "0.3.0" @@ -620,6 +767,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -631,6 +800,43 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async-utility" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "188f83b9a198af8c336e505611edb00d6d2ac5c694241c5a4f9a12316938cfe9" +dependencies = [ + "futures-util", + "gloo-timers", + "tokio", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-wsocket" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +dependencies = [ + "async-utility", + "futures", + "futures-util", + "js-sys", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-tungstenite", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "atomic-destructor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" + [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -686,6 +892,60 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper 1.0.2", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -783,6 +1043,56 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bdk_chain" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4955734f97b2baed3f36d16ae7c203fdde31ae85391ac44ee3cbcaf0886db5ce" +dependencies = [ + "bdk_core", + "bitcoin 0.32.8", + "miniscript 12.3.7", + "serde", +] + +[[package]] +name = "bdk_core" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b545aea1efc090e4f71f1dd5468090d9f54c3de48002064c04895ef811fbe0b2" +dependencies = [ + "bitcoin 0.32.8", + "hashbrown 0.14.5", + "serde", +] + +[[package]] +name = "bdk_esplora" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7fdf5efbebabc0c0bb46c0348ef0d4db505856c7d6c5d50cebba1e5eda5fe4" +dependencies = [ + "async-trait", + "bdk_core", + "esplora-client", + "futures", + "miniscript 12.3.7", +] + +[[package]] +name = "bdk_wallet" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "461b92c4e47b688a92740b204f4580e0a51775df16b67dde1d2db6ede1f0ba09" +dependencies = [ + "bdk_chain", + "bitcoin 0.32.8", + "miniscript 12.3.7", + "rand_core 0.6.4", + "serde", + "serde_json", +] + [[package]] name = "bech32" version = "0.9.1" @@ -795,6 +1105,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "better_any" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4372b9543397a4b86050cc5e7ee36953edf4bac9518e8a774c2da694977fb6e4" + [[package]] name = "bip32" version = "0.5.3" @@ -841,6 +1157,21 @@ dependencies = [ "sha3 0.10.9", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitcoin" version = "0.30.2" @@ -986,21 +1317,29 @@ dependencies = [ [[package]] name = "bitkitcore" -version = "0.8.0-rc11" +version = "0.8.0-arkade.6" dependencies = [ "alloy-primitives", "alloy-rlp", "alloy-sol-types", "android_logger", + "ark-bdk-wallet", + "ark-client", + "ark-core", + "ark-delegator", + "ark-fees", + "ark-script", "async-trait", "base64 0.22.1", "bdk", + "bech32 0.11.1", "bip39", "bitcoin 0.32.8", "bitcoin-address-generator", "boltz-client", "btleplug", "chrono", + "esplora-client", "hex", "jade-client-rs", "jni", @@ -1010,19 +1349,21 @@ dependencies = [ "log", "minicbor", "miniscript 12.3.7", + "nostr-sdk", "once_cell", "openssl", - "prost", + "prost 0.13.5", "pubky 0.6.0", "pubky-app-specs", "r2d2", "r2d2_sqlite", "rand 0.8.5", "regex", - "reqwest", + "reqwest 0.12.28", "rusqlite", "rust-blocktank-client", "rust_decimal", + "rustls 0.23.46", "serde", "serde_json", "serial_test", @@ -1160,7 +1501,7 @@ dependencies = [ "lightning", "lightning-invoice 0.34.1", "log", - "reqwest", + "reqwest 0.12.28", "secp256k1 0.32.0-beta.2", "serde", "serde_json", @@ -1345,6 +1686,22 @@ dependencies = [ "shlex", ] +[[package]] +name = "cel" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca1e5eda1b0f8476181bed1bfc9232a91d62ff0b9f1bc0e48afff3cbcb5b0b5c" +dependencies = [ + "antlr4rust", + "chrono", + "lazy_static", + "nom", + "paste", + "regex", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -1363,6 +1720,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.0" @@ -1374,6 +1742,19 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.44" @@ -1606,6 +1987,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1622,6 +2013,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1783,6 +2180,40 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -2156,6 +2587,20 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "enum-ordinalize" version = "4.4.2" @@ -2225,6 +2670,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esplora-client" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0da3c186d286e046253ccdc4bb71aa87ef872e4eff2045947c0c4fe3d2b2efc" +dependencies = [ + "bitcoin 0.32.8", + "hex-conservative", + "log", + "reqwest 0.11.27", + "serde", + "tokio", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -2336,6 +2795,12 @@ dependencies = [ "spin 0.9.8", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -2588,9 +3053,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -2654,9 +3121,47 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] @@ -2701,6 +3206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash 0.8.12", + "serde", ] [[package]] @@ -2831,6 +3337,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -2841,6 +3358,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -2848,7 +3376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -2859,8 +3387,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -2887,20 +3415,45 @@ dependencies = [ [[package]] name = "hyper" -version = "1.8.1" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "http", - "http-body", + "h2 0.4.20", + "http 1.4.0", + "http-body 1.0.1", "httparse", + "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -2912,8 +3465,8 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", - "hyper", + "http 1.4.0", + "hyper 1.12.0", "hyper-util", "rustls 0.23.46", "rustls-native-certs", @@ -2921,7 +3474,33 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.6", + "webpki-roots 1.0.11", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper 1.12.0", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", ] [[package]] @@ -2932,7 +3511,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.12.0", "hyper-util", "native-tls", "tokio", @@ -2950,17 +3529,19 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.12.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", + "system-configuration 0.7.0", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -3074,6 +3655,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -3155,6 +3742,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", ] [[package]] @@ -3234,7 +3824,7 @@ dependencies = [ "log", "minicbor", "rand 0.8.5", - "reqwest", + "reqwest 0.12.28", "serde", "serde_bytes", "serde_json", @@ -3506,6 +4096,18 @@ dependencies = [ "lightning-types 0.1.0", ] +[[package]] +name = "lightning-invoice" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39b494954ed8f1d774d86473224fadf9f0dfcca38c2d16a7dbdf2b5800c8943" +dependencies = [ + "bech32 0.11.1", + "bitcoin 0.32.8", + "lightning-types 0.2.12", + "serde", +] + [[package]] name = "lightning-invoice" version = "0.34.1" @@ -3539,6 +4141,15 @@ dependencies = [ "hex-conservative", ] +[[package]] +name = "lightning-types" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da19491efc72d10a13d5b8fd9c0cc6de7814742675adcb9996fa13f40a3b5ca" +dependencies = [ + "bitcoin 0.32.8", +] + [[package]] name = "lightning-types" version = "0.3.2" @@ -3590,7 +4201,7 @@ dependencies = [ "bitcoin 0.32.8", "cbc", "email_address", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "ureq", @@ -3631,6 +4242,12 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + [[package]] name = "lru-slab" version = "0.1.2" @@ -3670,7 +4287,7 @@ dependencies = [ "flume", "futures-lite", "getrandom 0.2.17", - "lru", + "lru 0.13.0", "serde", "serde_bencode", "serde_bytes", @@ -3688,6 +4305,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -3731,6 +4354,7 @@ checksum = "b8343cc1ef1408bd9bdbf69f7aef47017dfab7e6349ec26fddf62e0e9fb5a4cf" dependencies = [ "bech32 0.11.1", "bitcoin 0.32.8", + "serde", ] [[package]] @@ -3760,6 +4384,21 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "murmur3" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a198f9589efc03f544388dfc4a19fe8af4323662b62f598b8dcfdac62c14771c" +dependencies = [ + "byteorder", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -3777,6 +4416,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "negentropy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d66662cb5ca384d4991509755ed5a42dd8e31c4282bf881938e506fc3146ec" + [[package]] name = "nix" version = "0.26.4" @@ -3798,6 +4443,70 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nostr" +version = "0.43.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62a97d745f1bd8d5e05a978632bbb87b0614567d5142906fe7c86fb2440faac6" +dependencies = [ + "base64 0.22.1", + "bech32 0.11.1", + "bip39", + "bitcoin_hashes 0.14.1", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "getrandom 0.2.17", + "instant", + "scrypt", + "secp256k1 0.29.1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + +[[package]] +name = "nostr-database" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c75a8c2175d2785ba73cfddef21d1e30da5fbbdf158569b6808ba44973a15b" +dependencies = [ + "lru 0.16.4", + "nostr", + "tokio", +] + +[[package]] +name = "nostr-relay-pool" +version = "0.43.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2f43b70d13dfc50508a13cd902e11f4625312b2ce0e4b7c4c2283fd04001bd" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "lru 0.16.4", + "negentropy", + "nostr", + "nostr-database", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-sdk" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599f8963d6a1522a13b1a2b0ea6e168acfc367706606f1d33fa595e91fa22db0" +dependencies = [ + "async-utility", + "nostr", + "nostr-database", + "nostr-relay-pool", + "tokio", +] + [[package]] name = "ntimestamp" version = "1.0.0" @@ -4119,6 +4828,17 @@ dependencies = [ "indexmap 2.13.0", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.13.0", +] + [[package]] name = "phf" version = "0.11.3" @@ -4162,16 +4882,30 @@ dependencies = [ ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "pin-project" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] [[package]] -name = "pin-utils" -version = "0.1.0" +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkarr" @@ -4193,11 +4927,11 @@ dependencies = [ "getrandom 0.2.17", "heed", "log", - "lru", + "lru 0.13.0", "mainline", "ntimestamp", "page_size", - "reqwest", + "reqwest 0.12.28", "rustls 0.23.46", "rustls-webpki 0.102.8", "self_cell", @@ -4422,7 +5156,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.13.5", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive 0.14.4", ] [[package]] @@ -4436,10 +5180,31 @@ dependencies = [ "log", "multimap", "once_cell", - "petgraph", + "petgraph 0.7.1", + "prettyplease", + "prost 0.13.5", + "prost-types 0.13.5", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools 0.14.0", + "log", + "multimap", + "petgraph 0.8.3", "prettyplease", - "prost", - "prost-types", + "prost 0.14.4", + "prost-types 0.14.4", + "pulldown-cmark", + "pulldown-cmark-to-cmark", "regex", "syn 2.0.117", "tempfile", @@ -4458,13 +5223,35 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "prost-types" version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" dependencies = [ - "prost", + "prost 0.13.5", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost 0.14.4", ] [[package]] @@ -4510,7 +5297,7 @@ dependencies = [ "log", "pkarr", "pubky-common 0.5.4", - "reqwest", + "reqwest 0.12.28", "thiserror 2.0.18", "tokio", "tracing", @@ -4533,7 +5320,7 @@ dependencies = [ "log", "pkarr", "pubky-common 0.6.0", - "reqwest", + "reqwest 0.12.28", "thiserror 2.0.18", "tokio", "tracing", @@ -4629,6 +5416,26 @@ dependencies = [ "psl-types", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb" +dependencies = [ + "bitflags 2.11.0", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bbb29c624230c4bd1047bbdb2aa47e41c860e9665ce62ba9504eebe91bf867" +dependencies = [ + "pulldown-cmark", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4642,7 +5449,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.46", - "socket2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -4680,7 +5487,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.59.0", ] @@ -4762,7 +5569,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "chacha20", + "chacha20 0.10.0", "getrandom 0.4.2", "rand_core 0.10.0", ] @@ -4924,6 +5731,47 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls 0.5.0", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-native-tls", + "tokio-socks", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -4934,16 +5782,19 @@ dependencies = [ "bytes", "cookie", "cookie_store 0.22.1", + "encoding_rs", "futures-core", - "http", - "http-body", + "h2 0.4.20", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.12.0", "hyper-rustls", - "hyper-tls", + "hyper-tls 0.6.0", "hyper-util", "js-sys", "log", + "mime", "native-tls", "percent-encoding", "pin-project-lite", @@ -4954,7 +5805,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", "tokio-rustls", @@ -4965,7 +5816,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.6", + "webpki-roots 1.0.11", ] [[package]] @@ -5106,7 +5957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "011a0e743459d488c261488a5900f9efce5adb022812aad9c7a09117fea7bef5" dependencies = [ "chrono", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "thiserror 2.0.18", @@ -5213,6 +6064,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -5369,6 +6229,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + [[package]] name = "sct" version = "0.7.1" @@ -5461,6 +6333,17 @@ dependencies = [ "secp256k1-sys 0.12.0", ] +[[package]] +name = "secp256k1" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7f404a8dab7a7a5a631e741d8699aa9c8e1d689fc26ccf897c7187565490b69" +dependencies = [ + "rand 0.9.2", + "secp256k1-sys 0.14.1", + "serde", +] + [[package]] name = "secp256k1-sys" version = "0.8.2" @@ -5497,6 +6380,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-sys" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b2992d4a3cd244539a7d5d0966aadbe5ca7fb868a5d7e38c29499b9e709bbd" +dependencies = [ + "cc", +] + [[package]] name = "secp256k1-zkp" version = "0.11.0" @@ -5527,7 +6419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -5692,9 +6584,22 @@ dependencies = [ "schemars 1.2.2", "serde_core", "serde_json", + "serde_with_macros", "time", ] +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serial_test" version = "3.4.0" @@ -5729,7 +6634,7 @@ checksum = "6a2f4ac56b5d3af3c40fbbee17be96d532cba02fa5853926aacdb77d926272ab" dependencies = [ "bitflags 2.11.0", "cfg-if", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "io-kit-sys", "mach2", @@ -5899,6 +6804,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -6014,6 +6929,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -6043,6 +6964,48 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tap" version = "1.0.1" @@ -6221,7 +7184,7 @@ dependencies = [ "parking_lot 0.12.5", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -6257,6 +7220,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -6294,7 +7269,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", + "http 1.4.0", "httparse", "js-sys", "rustls 0.23.46", @@ -6357,6 +7332,121 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "base64 0.22.1", + "bytes", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project", + "tokio-stream", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2cdfc673aabd30fe8fee92e65837b644c37c5d47f0f97ae6d5e0f1237d15e1b" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2 0.4.20", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.12.0", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2 0.6.3", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "webpki-roots 1.0.11", +] + +[[package]] +name = "tonic-build" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6d916d206ed7bdda1778ddff9bda43303789c258877e7bc22fb44ee5c984b4" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tonic-prost" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba464054e2226f182a34489b8b079bf4c6e3e7ab5fd7fc30e024f24aa92a484" +dependencies = [ + "bytes", + "prost 0.14.4", + "tonic 0.14.9", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea216ddc448e08e7839ab09e20e60b06b6a1c32745ccb212c0ebd161c199f1b" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build 0.14.4", + "prost-types 0.14.4", + "quote", + "syn 2.0.117", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tonic-web-wasm-client" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8957be1a1c7aa12d4c9d67882060dd57aed816bbc553fa60949312e839f4a8ea" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "httparse", + "js-sys", + "pin-project", + "thiserror 1.0.69", + "tonic 0.12.3", + "tower-service", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + [[package]] name = "tower" version = "0.5.3" @@ -6365,11 +7455,15 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.13.0", "pin-project-lite", - "sync_wrapper", + "slab", + "sync_wrapper 1.0.2", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -6381,8 +7475,8 @@ dependencies = [ "bitflags 2.11.0", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -6482,8 +7576,8 @@ dependencies = [ "log", "num-bigint", "num-traits", - "prost", - "prost-build", + "prost 0.13.5", + "prost-build 0.13.5", "rand 0.8.5", "rusb", "serde", @@ -6512,7 +7606,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.2", @@ -6523,6 +7617,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typenum" version = "1.19.0" @@ -6562,6 +7662,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -6831,9 +7937,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -6943,6 +8049,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -7006,14 +8125,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.6", + "webpki-roots 1.0.11", ] [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -7161,6 +8280,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -7197,13 +8327,22 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -7212,7 +8351,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -7224,20 +8363,35 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -7249,18 +8403,36 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -7273,24 +8445,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -7315,6 +8511,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index d3aba16..1db38a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitkitcore" -version = "0.8.0-rc11" +version = "0.8.0-arkade.6" edition = "2021" rust-version = "1.90" @@ -52,6 +52,18 @@ btleplug = "0.11" alloy-primitives = { version = "1", default-features = false, features = ["std", "serde"] } alloy-sol-types = "1" alloy-rlp = "0.3" +# Arkade (src/modules/arkade): Ark Labs' rust-sdk 0.12. ark-client's `sqlite` feature stays off, so sqlite is +# linked once, through rusqlite above. +ark-bdk-wallet = "=0.12.0" +ark-client = { version = "=0.12.0", default-features = false, features = ["tls-webpki-roots"] } +ark-core = "=0.12.0" +ark-delegator = "=0.12.0" +ark-fees = "=0.12.0" +ark-script = "=0.12.0" +bech32 = "0.11" +nostr-sdk = { version = "0.43", default-features = false, features = ["nip44"] } +esplora-client = { version = "0.11.0", default-features = false, features = ["async", "async-https", "tokio"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } # Trezor connect library - non-iOS platforms get USB + Bluetooth [target.'cfg(not(target_os = "ios"))'.dependencies] @@ -86,6 +98,17 @@ tempfile = "3.2" serial_test = "3.2.0" rust_decimal = "1.30" +# ark-core 0.12.0 builds the owner/operator 2-of-2 leaf as ` CHECKSIGVERIFY CHECKSIG`; the +# TypeScript and Go SDKs build ` CHECKSIGVERIFY CHECKSIG`, so the same key gets different +# Arkade and boarding addresses (arkade-os/rust-sdk#132). The vendored copy flips the five call sites to the +# TypeScript order and changes nothing else; drop it once upstream matches. +# ark-client 0.12.0 makes a batch intent valid from the device's current second, and the operator refuses one +# from a clock even a second ahead ("proof of ownership not yet valid"): the vendored copy makes it valid from +# a minute earlier and changes nothing else. +[patch.crates-io] +ark-core = { path = "vendor/ark-core" } +ark-client = { path = "vendor/ark-client" } + [[bin]] name = "example" path = "example/main.rs" diff --git a/Package.swift b/Package.swift index 6b2ce47..771fc34 100644 --- a/Package.swift +++ b/Package.swift @@ -4,8 +4,8 @@ import PackageDescription import Foundation -let tag = "v0.8.0-rc11" -let checksum = "f7ef2ffb625498c651111cb0f5a177893c5abd91c89b077e11abf0ee289cd544" +let tag = "v0.8.0-arkade.6" +let checksum = "a7fe4afda17bf33927f9ba321368b2246491789d14aa5c905def5ca479a4293f" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/bindings/android/gradle.properties b/bindings/android/gradle.properties index 82cf125..5d74956 100644 --- a/bindings/android/gradle.properties +++ b/bindings/android/gradle.properties @@ -3,4 +3,4 @@ android.useAndroidX=true android.enableJetifier=true kotlin.code.style=official group=com.synonym -version=0.8.0-rc11 +version=0.8.0-arkade.6 diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index aae3011..c64b1a9 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -585,6 +585,231 @@ fileprivate struct FfiConverterData: FfiConverterRustBuffer { +/** + * Callback interface for Arkade wallet events. + * + * Implement this in Swift/Kotlin/Python and register it via `arkade_set_listener` to hear of history entries + * that are new, or newly settled, since the last pass: offline Lightning receives the LNURL server claimed, + * payments to the Arkade address, boarded deposits and settled offboards; and of each Lightning swap whose + * outcome changed. + */ +public protocol ArkadeEventListener: AnyObject, Sendable { + + func onHistory(entries: [ArkadeHistoryEntry], balance: ArkadeBalance) + + func onSwap(swap: ArkadeSwap) + +} +/** + * Callback interface for Arkade wallet events. + * + * Implement this in Swift/Kotlin/Python and register it via `arkade_set_listener` to hear of history entries + * that are new, or newly settled, since the last pass: offline Lightning receives the LNURL server claimed, + * payments to the Arkade address, boarded deposits and settled offboards; and of each Lightning swap whose + * outcome changed. + */ +open class ArkadeEventListenerImpl: ArkadeEventListener, @unchecked Sendable { + fileprivate let pointer: UnsafeMutableRawPointer! + + /// Used to instantiate a [FFIObject] without an actual pointer, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoPointer { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + self.pointer = pointer + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noPointer: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing [Pointer] the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noPointer: NoPointer) { + self.pointer = nil + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiClonePointer() -> UnsafeMutableRawPointer { + return try! rustCall { uniffi_bitkitcore_fn_clone_arkadeeventlistener(self.pointer, $0) } + } + // No primary constructor declared for this class. + + deinit { + guard let pointer = pointer else { + return + } + + try! rustCall { uniffi_bitkitcore_fn_free_arkadeeventlistener(pointer, $0) } + } + + + + +open func onHistory(entries: [ArkadeHistoryEntry], balance: ArkadeBalance) {try! rustCall() { + uniffi_bitkitcore_fn_method_arkadeeventlistener_on_history(self.uniffiClonePointer(), + FfiConverterSequenceTypeArkadeHistoryEntry.lower(entries), + FfiConverterTypeArkadeBalance_lower(balance),$0 + ) +} +} + +open func onSwap(swap: ArkadeSwap) {try! rustCall() { + uniffi_bitkitcore_fn_method_arkadeeventlistener_on_swap(self.uniffiClonePointer(), + FfiConverterTypeArkadeSwap_lower(swap),$0 + ) +} +} + + +} + + +// Put the implementation in a struct so we don't pollute the top-level namespace +fileprivate struct UniffiCallbackInterfaceArkadeEventListener { + + // Create the VTable using a series of closures. + // Swift automatically converts these into C callback functions. + // + // This creates 1-element array, since this seems to be the only way to construct a const + // pointer that we can pass to the Rust code. + static let vtable: [UniffiVTableCallbackInterfaceArkadeEventListener] = [UniffiVTableCallbackInterfaceArkadeEventListener( + onHistory: { ( + uniffiHandle: UInt64, + entries: RustBuffer, + balance: RustBuffer, + uniffiOutReturn: UnsafeMutableRawPointer, + uniffiCallStatus: UnsafeMutablePointer + ) in + let makeCall = { + () throws -> () in + guard let uniffiObj = try? FfiConverterTypeArkadeEventListener.handleMap.get(handle: uniffiHandle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return uniffiObj.onHistory( + entries: try FfiConverterSequenceTypeArkadeHistoryEntry.lift(entries), + balance: try FfiConverterTypeArkadeBalance_lift(balance) + ) + } + + + let writeReturn = { () } + uniffiTraitInterfaceCall( + callStatus: uniffiCallStatus, + makeCall: makeCall, + writeReturn: writeReturn + ) + }, + onSwap: { ( + uniffiHandle: UInt64, + swap: RustBuffer, + uniffiOutReturn: UnsafeMutableRawPointer, + uniffiCallStatus: UnsafeMutablePointer + ) in + let makeCall = { + () throws -> () in + guard let uniffiObj = try? FfiConverterTypeArkadeEventListener.handleMap.get(handle: uniffiHandle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return uniffiObj.onSwap( + swap: try FfiConverterTypeArkadeSwap_lift(swap) + ) + } + + + let writeReturn = { () } + uniffiTraitInterfaceCall( + callStatus: uniffiCallStatus, + makeCall: makeCall, + writeReturn: writeReturn + ) + }, + uniffiFree: { (uniffiHandle: UInt64) -> () in + let result = try? FfiConverterTypeArkadeEventListener.handleMap.remove(handle: uniffiHandle) + if result == nil { + print("Uniffi callback interface ArkadeEventListener: handle missing in uniffiFree") + } + } + )] +} + +private func uniffiCallbackInitArkadeEventListener() { + uniffi_bitkitcore_fn_init_callback_vtable_arkadeeventlistener(UniffiCallbackInterfaceArkadeEventListener.vtable) +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeArkadeEventListener: FfiConverter { + fileprivate static let handleMap = UniffiHandleMap() + + typealias FfiType = UnsafeMutableRawPointer + typealias SwiftType = ArkadeEventListener + + public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> ArkadeEventListener { + return ArkadeEventListenerImpl(unsafeFromRawPointer: pointer) + } + + public static func lower(_ value: ArkadeEventListener) -> UnsafeMutableRawPointer { + guard let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: handleMap.insert(obj: value))) else { + fatalError("Cast to UnsafeMutableRawPointer failed") + } + return ptr + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ArkadeEventListener { + let v: UInt64 = try readInt(&buf) + // The Rust code won't compile if a pointer won't fit in a UInt64. + // We have to go via `UInt` because that's the thing that's the size of a pointer. + let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: v)) + if (ptr == nil) { + throw UniffiInternalError.unexpectedNullPointer + } + return try lift(ptr!) + } + + public static func write(_ value: ArkadeEventListener, into buf: inout [UInt8]) { + // This fiddling is because `Int` is the thing that's the same size as a pointer. + // The Rust code won't compile if a pointer won't fit in a `UInt64`. + writeInt(&buf, UInt64(bitPattern: Int64(Int(bitPattern: lower(value))))) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeEventListener_lift(_ pointer: UnsafeMutableRawPointer) throws -> ArkadeEventListener { + return try FfiConverterTypeArkadeEventListener.lift(pointer) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeEventListener_lower(_ value: ArkadeEventListener) -> UnsafeMutableRawPointer { + return FfiConverterTypeArkadeEventListener.lower(value) +} + + + + + + /** * Callback interface for receiving Boltz swap lifecycle events. * @@ -4093,126 +4318,1389 @@ public func FfiConverterTypeAddressInfo_lower(_ value: AddressInfo) -> RustBuffe } -/** - * Fees and limits for a swap pair, used to size a swap and present costs. - */ -public struct BoltzPairInfo { - /** - * Pair hash identifying the current terms (passed back to Boltz if needed). - */ - public var hash: String - /** - * Exchange rate of the pair. - */ - public var rate: Double - /** - * Minimum swap amount in satoshis. - */ - public var minimalSat: UInt64 - /** - * Maximum swap amount in satoshis. - */ - public var maximalSat: UInt64 +public struct ArkadeBalance { /** - * Boltz service fee as a percentage of the swap amount. - */ - public var feePercentage: Double - /** - * Estimated absolute miner fees in satoshis. + * Spendable now: settled plus preconfirmed. */ - public var minerFeesSat: UInt64 + public var available: UInt64 + public var settled: UInt64 + public var preconfirmed: UInt64 + public var recoverable: UInt64 + public var boardingConfirmed: UInt64 + public var boardingUnconfirmed: UInt64 + public var total: UInt64 // Default memberwise initializers are never public by default, so we // declare one manually. public init( /** - * Pair hash identifying the current terms (passed back to Boltz if needed). - */hash: String, - /** - * Exchange rate of the pair. - */rate: Double, - /** - * Minimum swap amount in satoshis. - */minimalSat: UInt64, - /** - * Maximum swap amount in satoshis. - */maximalSat: UInt64, - /** - * Boltz service fee as a percentage of the swap amount. - */feePercentage: Double, - /** - * Estimated absolute miner fees in satoshis. - */minerFeesSat: UInt64) { - self.hash = hash - self.rate = rate - self.minimalSat = minimalSat - self.maximalSat = maximalSat - self.feePercentage = feePercentage - self.minerFeesSat = minerFeesSat + * Spendable now: settled plus preconfirmed. + */available: UInt64, settled: UInt64, preconfirmed: UInt64, recoverable: UInt64, boardingConfirmed: UInt64, boardingUnconfirmed: UInt64, total: UInt64) { + self.available = available + self.settled = settled + self.preconfirmed = preconfirmed + self.recoverable = recoverable + self.boardingConfirmed = boardingConfirmed + self.boardingUnconfirmed = boardingUnconfirmed + self.total = total } } #if compiler(>=6) -extension BoltzPairInfo: Sendable {} +extension ArkadeBalance: Sendable {} #endif -extension BoltzPairInfo: Equatable, Hashable { - public static func ==(lhs: BoltzPairInfo, rhs: BoltzPairInfo) -> Bool { - if lhs.hash != rhs.hash { +extension ArkadeBalance: Equatable, Hashable { + public static func ==(lhs: ArkadeBalance, rhs: ArkadeBalance) -> Bool { + if lhs.available != rhs.available { return false } - if lhs.rate != rhs.rate { + if lhs.settled != rhs.settled { return false } - if lhs.minimalSat != rhs.minimalSat { + if lhs.preconfirmed != rhs.preconfirmed { return false } - if lhs.maximalSat != rhs.maximalSat { + if lhs.recoverable != rhs.recoverable { return false } - if lhs.feePercentage != rhs.feePercentage { + if lhs.boardingConfirmed != rhs.boardingConfirmed { return false } - if lhs.minerFeesSat != rhs.minerFeesSat { + if lhs.boardingUnconfirmed != rhs.boardingUnconfirmed { + return false + } + if lhs.total != rhs.total { return false } return true } public func hash(into hasher: inout Hasher) { - hasher.combine(hash) - hasher.combine(rate) - hasher.combine(minimalSat) - hasher.combine(maximalSat) - hasher.combine(feePercentage) - hasher.combine(minerFeesSat) + hasher.combine(available) + hasher.combine(settled) + hasher.combine(preconfirmed) + hasher.combine(recoverable) + hasher.combine(boardingConfirmed) + hasher.combine(boardingUnconfirmed) + hasher.combine(total) } } -extension BoltzPairInfo: Codable {} +extension ArkadeBalance: Codable {} #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeBoltzPairInfo: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> BoltzPairInfo { +public struct FfiConverterTypeArkadeBalance: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ArkadeBalance { return - try BoltzPairInfo( - hash: FfiConverterString.read(from: &buf), - rate: FfiConverterDouble.read(from: &buf), - minimalSat: FfiConverterUInt64.read(from: &buf), - maximalSat: FfiConverterUInt64.read(from: &buf), - feePercentage: FfiConverterDouble.read(from: &buf), - minerFeesSat: FfiConverterUInt64.read(from: &buf) + try ArkadeBalance( + available: FfiConverterUInt64.read(from: &buf), + settled: FfiConverterUInt64.read(from: &buf), + preconfirmed: FfiConverterUInt64.read(from: &buf), + recoverable: FfiConverterUInt64.read(from: &buf), + boardingConfirmed: FfiConverterUInt64.read(from: &buf), + boardingUnconfirmed: FfiConverterUInt64.read(from: &buf), + total: FfiConverterUInt64.read(from: &buf) ) } - public static func write(_ value: BoltzPairInfo, into buf: inout [UInt8]) { - FfiConverterString.write(value.hash, into: &buf) - FfiConverterDouble.write(value.rate, into: &buf) + public static func write(_ value: ArkadeBalance, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.available, into: &buf) + FfiConverterUInt64.write(value.settled, into: &buf) + FfiConverterUInt64.write(value.preconfirmed, into: &buf) + FfiConverterUInt64.write(value.recoverable, into: &buf) + FfiConverterUInt64.write(value.boardingConfirmed, into: &buf) + FfiConverterUInt64.write(value.boardingUnconfirmed, into: &buf) + FfiConverterUInt64.write(value.total, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeBalance_lift(_ buf: RustBuffer) throws -> ArkadeBalance { + return try FfiConverterTypeArkadeBalance.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeBalance_lower(_ value: ArkadeBalance) -> RustBuffer { + return FfiConverterTypeArkadeBalance.lower(value) +} + + +public struct ArkadeFees { + /** + * The operator's fee programs (CEL), as `/v1/info` gives them. + */ + public var intentOffchainInput: String + public var intentOffchainOutput: String + public var intentOnchainInput: String + public var intentOnchainOutput: String + public var txFeeRate: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * The operator's fee programs (CEL), as `/v1/info` gives them. + */intentOffchainInput: String, intentOffchainOutput: String, intentOnchainInput: String, intentOnchainOutput: String, txFeeRate: String) { + self.intentOffchainInput = intentOffchainInput + self.intentOffchainOutput = intentOffchainOutput + self.intentOnchainInput = intentOnchainInput + self.intentOnchainOutput = intentOnchainOutput + self.txFeeRate = txFeeRate + } +} + +#if compiler(>=6) +extension ArkadeFees: Sendable {} +#endif + + +extension ArkadeFees: Equatable, Hashable { + public static func ==(lhs: ArkadeFees, rhs: ArkadeFees) -> Bool { + if lhs.intentOffchainInput != rhs.intentOffchainInput { + return false + } + if lhs.intentOffchainOutput != rhs.intentOffchainOutput { + return false + } + if lhs.intentOnchainInput != rhs.intentOnchainInput { + return false + } + if lhs.intentOnchainOutput != rhs.intentOnchainOutput { + return false + } + if lhs.txFeeRate != rhs.txFeeRate { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(intentOffchainInput) + hasher.combine(intentOffchainOutput) + hasher.combine(intentOnchainInput) + hasher.combine(intentOnchainOutput) + hasher.combine(txFeeRate) + } +} + +extension ArkadeFees: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeArkadeFees: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ArkadeFees { + return + try ArkadeFees( + intentOffchainInput: FfiConverterString.read(from: &buf), + intentOffchainOutput: FfiConverterString.read(from: &buf), + intentOnchainInput: FfiConverterString.read(from: &buf), + intentOnchainOutput: FfiConverterString.read(from: &buf), + txFeeRate: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: ArkadeFees, into buf: inout [UInt8]) { + FfiConverterString.write(value.intentOffchainInput, into: &buf) + FfiConverterString.write(value.intentOffchainOutput, into: &buf) + FfiConverterString.write(value.intentOnchainInput, into: &buf) + FfiConverterString.write(value.intentOnchainOutput, into: &buf) + FfiConverterString.write(value.txFeeRate, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeFees_lift(_ buf: RustBuffer) throws -> ArkadeFees { + return try FfiConverterTypeArkadeFees.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeFees_lower(_ value: ArkadeFees) -> RustBuffer { + return FfiConverterTypeArkadeFees.lower(value) +} + + +public struct ArkadeHistoryEntry { + /** + * The non-empty ids of `boarding_txid`, `commitment_txid` and `ark_txid`, joined by `:`. + */ + public var key: String + public var boardingTxid: String? + public var commitmentTxid: String? + public var arkTxid: String? + /** + * `SENT` or `RECEIVED` (the bridge's `type`). + */ + public var txType: String + /** + * `boarding`, `batch`, `offchain` or `exit`. + */ + public var tag: String + public var amount: UInt64 + public var settled: Bool + /** + * Unix milliseconds. + */ + public var createdAt: UInt64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * The non-empty ids of `boarding_txid`, `commitment_txid` and `ark_txid`, joined by `:`. + */key: String, boardingTxid: String?, commitmentTxid: String?, arkTxid: String?, + /** + * `SENT` or `RECEIVED` (the bridge's `type`). + */txType: String, + /** + * `boarding`, `batch`, `offchain` or `exit`. + */tag: String, amount: UInt64, settled: Bool, + /** + * Unix milliseconds. + */createdAt: UInt64) { + self.key = key + self.boardingTxid = boardingTxid + self.commitmentTxid = commitmentTxid + self.arkTxid = arkTxid + self.txType = txType + self.tag = tag + self.amount = amount + self.settled = settled + self.createdAt = createdAt + } +} + +#if compiler(>=6) +extension ArkadeHistoryEntry: Sendable {} +#endif + + +extension ArkadeHistoryEntry: Equatable, Hashable { + public static func ==(lhs: ArkadeHistoryEntry, rhs: ArkadeHistoryEntry) -> Bool { + if lhs.key != rhs.key { + return false + } + if lhs.boardingTxid != rhs.boardingTxid { + return false + } + if lhs.commitmentTxid != rhs.commitmentTxid { + return false + } + if lhs.arkTxid != rhs.arkTxid { + return false + } + if lhs.txType != rhs.txType { + return false + } + if lhs.tag != rhs.tag { + return false + } + if lhs.amount != rhs.amount { + return false + } + if lhs.settled != rhs.settled { + return false + } + if lhs.createdAt != rhs.createdAt { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(key) + hasher.combine(boardingTxid) + hasher.combine(commitmentTxid) + hasher.combine(arkTxid) + hasher.combine(txType) + hasher.combine(tag) + hasher.combine(amount) + hasher.combine(settled) + hasher.combine(createdAt) + } +} + +extension ArkadeHistoryEntry: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeArkadeHistoryEntry: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ArkadeHistoryEntry { + return + try ArkadeHistoryEntry( + key: FfiConverterString.read(from: &buf), + boardingTxid: FfiConverterOptionString.read(from: &buf), + commitmentTxid: FfiConverterOptionString.read(from: &buf), + arkTxid: FfiConverterOptionString.read(from: &buf), + txType: FfiConverterString.read(from: &buf), + tag: FfiConverterString.read(from: &buf), + amount: FfiConverterUInt64.read(from: &buf), + settled: FfiConverterBool.read(from: &buf), + createdAt: FfiConverterUInt64.read(from: &buf) + ) + } + + public static func write(_ value: ArkadeHistoryEntry, into buf: inout [UInt8]) { + FfiConverterString.write(value.key, into: &buf) + FfiConverterOptionString.write(value.boardingTxid, into: &buf) + FfiConverterOptionString.write(value.commitmentTxid, into: &buf) + FfiConverterOptionString.write(value.arkTxid, into: &buf) + FfiConverterString.write(value.txType, into: &buf) + FfiConverterString.write(value.tag, into: &buf) + FfiConverterUInt64.write(value.amount, into: &buf) + FfiConverterBool.write(value.settled, into: &buf) + FfiConverterUInt64.write(value.createdAt, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeHistoryEntry_lift(_ buf: RustBuffer) throws -> ArkadeHistoryEntry { + return try FfiConverterTypeArkadeHistoryEntry.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeHistoryEntry_lower(_ value: ArkadeHistoryEntry) -> RustBuffer { + return FfiConverterTypeArkadeHistoryEntry.lower(value) +} + + +public struct ArkadeInfo { + public var arkAddress: String + public var boardingAddress: String + /** + * `bitcoin`, `mutinynet` or `regtest`. + */ + public var network: String + public var dust: UInt64 + /** + * Seconds (or blocks, below 512), as the operator advertises them. + */ + public var unilateralExitDelay: UInt64 + public var boardingExitDelay: UInt64 + public var fees: ArkadeFees + /** + * Whether a delegate renews this wallet's outputs while it is offline. + */ + public var delegate: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(arkAddress: String, boardingAddress: String, + /** + * `bitcoin`, `mutinynet` or `regtest`. + */network: String, dust: UInt64, + /** + * Seconds (or blocks, below 512), as the operator advertises them. + */unilateralExitDelay: UInt64, boardingExitDelay: UInt64, fees: ArkadeFees, + /** + * Whether a delegate renews this wallet's outputs while it is offline. + */delegate: Bool) { + self.arkAddress = arkAddress + self.boardingAddress = boardingAddress + self.network = network + self.dust = dust + self.unilateralExitDelay = unilateralExitDelay + self.boardingExitDelay = boardingExitDelay + self.fees = fees + self.delegate = delegate + } +} + +#if compiler(>=6) +extension ArkadeInfo: Sendable {} +#endif + + +extension ArkadeInfo: Equatable, Hashable { + public static func ==(lhs: ArkadeInfo, rhs: ArkadeInfo) -> Bool { + if lhs.arkAddress != rhs.arkAddress { + return false + } + if lhs.boardingAddress != rhs.boardingAddress { + return false + } + if lhs.network != rhs.network { + return false + } + if lhs.dust != rhs.dust { + return false + } + if lhs.unilateralExitDelay != rhs.unilateralExitDelay { + return false + } + if lhs.boardingExitDelay != rhs.boardingExitDelay { + return false + } + if lhs.fees != rhs.fees { + return false + } + if lhs.delegate != rhs.delegate { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(arkAddress) + hasher.combine(boardingAddress) + hasher.combine(network) + hasher.combine(dust) + hasher.combine(unilateralExitDelay) + hasher.combine(boardingExitDelay) + hasher.combine(fees) + hasher.combine(delegate) + } +} + +extension ArkadeInfo: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeArkadeInfo: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ArkadeInfo { + return + try ArkadeInfo( + arkAddress: FfiConverterString.read(from: &buf), + boardingAddress: FfiConverterString.read(from: &buf), + network: FfiConverterString.read(from: &buf), + dust: FfiConverterUInt64.read(from: &buf), + unilateralExitDelay: FfiConverterUInt64.read(from: &buf), + boardingExitDelay: FfiConverterUInt64.read(from: &buf), + fees: FfiConverterTypeArkadeFees.read(from: &buf), + delegate: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: ArkadeInfo, into buf: inout [UInt8]) { + FfiConverterString.write(value.arkAddress, into: &buf) + FfiConverterString.write(value.boardingAddress, into: &buf) + FfiConverterString.write(value.network, into: &buf) + FfiConverterUInt64.write(value.dust, into: &buf) + FfiConverterUInt64.write(value.unilateralExitDelay, into: &buf) + FfiConverterUInt64.write(value.boardingExitDelay, into: &buf) + FfiConverterTypeArkadeFees.write(value.fees, into: &buf) + FfiConverterBool.write(value.delegate, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeInfo_lift(_ buf: RustBuffer) throws -> ArkadeInfo { + return try FfiConverterTypeArkadeInfo.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeInfo_lower(_ value: ArkadeInfo) -> RustBuffer { + return FfiConverterTypeArkadeInfo.lower(value) +} + + +public struct ArkadeLightningAddress { + public var handle: String + public var lightningAddress: String? + public var lnurl: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(handle: String, lightningAddress: String?, lnurl: String) { + self.handle = handle + self.lightningAddress = lightningAddress + self.lnurl = lnurl + } +} + +#if compiler(>=6) +extension ArkadeLightningAddress: Sendable {} +#endif + + +extension ArkadeLightningAddress: Equatable, Hashable { + public static func ==(lhs: ArkadeLightningAddress, rhs: ArkadeLightningAddress) -> Bool { + if lhs.handle != rhs.handle { + return false + } + if lhs.lightningAddress != rhs.lightningAddress { + return false + } + if lhs.lnurl != rhs.lnurl { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(handle) + hasher.combine(lightningAddress) + hasher.combine(lnurl) + } +} + +extension ArkadeLightningAddress: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeArkadeLightningAddress: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ArkadeLightningAddress { + return + try ArkadeLightningAddress( + handle: FfiConverterString.read(from: &buf), + lightningAddress: FfiConverterOptionString.read(from: &buf), + lnurl: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: ArkadeLightningAddress, into buf: inout [UInt8]) { + FfiConverterString.write(value.handle, into: &buf) + FfiConverterOptionString.write(value.lightningAddress, into: &buf) + FfiConverterString.write(value.lnurl, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeLightningAddress_lift(_ buf: RustBuffer) throws -> ArkadeLightningAddress { + return try FfiConverterTypeArkadeLightningAddress.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeLightningAddress_lower(_ value: ArkadeLightningAddress) -> RustBuffer { + return FfiConverterTypeArkadeLightningAddress.lower(value) +} + + +/** + * What paying an invoice would cost. + */ +public struct ArkadePayQuote { + /** + * `give_sat - take_sat`. + */ + public var feeSat: UInt64 + /** + * What the wallet funds. + */ + public var giveSat: UInt64 + /** + * The invoice amount. + */ + public var takeSat: UInt64 + public var expiresAt: UInt64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * `give_sat - take_sat`. + */feeSat: UInt64, + /** + * What the wallet funds. + */giveSat: UInt64, + /** + * The invoice amount. + */takeSat: UInt64, expiresAt: UInt64) { + self.feeSat = feeSat + self.giveSat = giveSat + self.takeSat = takeSat + self.expiresAt = expiresAt + } +} + +#if compiler(>=6) +extension ArkadePayQuote: Sendable {} +#endif + + +extension ArkadePayQuote: Equatable, Hashable { + public static func ==(lhs: ArkadePayQuote, rhs: ArkadePayQuote) -> Bool { + if lhs.feeSat != rhs.feeSat { + return false + } + if lhs.giveSat != rhs.giveSat { + return false + } + if lhs.takeSat != rhs.takeSat { + return false + } + if lhs.expiresAt != rhs.expiresAt { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(feeSat) + hasher.combine(giveSat) + hasher.combine(takeSat) + hasher.combine(expiresAt) + } +} + +extension ArkadePayQuote: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeArkadePayQuote: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ArkadePayQuote { + return + try ArkadePayQuote( + feeSat: FfiConverterUInt64.read(from: &buf), + giveSat: FfiConverterUInt64.read(from: &buf), + takeSat: FfiConverterUInt64.read(from: &buf), + expiresAt: FfiConverterUInt64.read(from: &buf) + ) + } + + public static func write(_ value: ArkadePayQuote, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.feeSat, into: &buf) + FfiConverterUInt64.write(value.giveSat, into: &buf) + FfiConverterUInt64.write(value.takeSat, into: &buf) + FfiConverterUInt64.write(value.expiresAt, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadePayQuote_lift(_ buf: RustBuffer) throws -> ArkadePayQuote { + return try FfiConverterTypeArkadePayQuote.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadePayQuote_lower(_ value: ArkadePayQuote) -> RustBuffer { + return FfiConverterTypeArkadePayQuote.lower(value) +} + + +public struct ArkadeRegtestEndpoints { + public var arkServerUrl: String + public var esploraUrl: String + /** + * The LNURL server for the Lightning address; `None` turns the address off. + */ + public var lnurlUrl: String? + /** + * The delegate; `None` leaves renewal to the wallet. + */ + public var delegateUrl: String? + /** + * The solver's x-only discovery key; `None` turns Lightning swaps off. + */ + public var solverPubkey: String? + public var solverRelays: [String] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(arkServerUrl: String, esploraUrl: String, + /** + * The LNURL server for the Lightning address; `None` turns the address off. + */lnurlUrl: String?, + /** + * The delegate; `None` leaves renewal to the wallet. + */delegateUrl: String?, + /** + * The solver's x-only discovery key; `None` turns Lightning swaps off. + */solverPubkey: String?, solverRelays: [String]) { + self.arkServerUrl = arkServerUrl + self.esploraUrl = esploraUrl + self.lnurlUrl = lnurlUrl + self.delegateUrl = delegateUrl + self.solverPubkey = solverPubkey + self.solverRelays = solverRelays + } +} + +#if compiler(>=6) +extension ArkadeRegtestEndpoints: Sendable {} +#endif + + +extension ArkadeRegtestEndpoints: Equatable, Hashable { + public static func ==(lhs: ArkadeRegtestEndpoints, rhs: ArkadeRegtestEndpoints) -> Bool { + if lhs.arkServerUrl != rhs.arkServerUrl { + return false + } + if lhs.esploraUrl != rhs.esploraUrl { + return false + } + if lhs.lnurlUrl != rhs.lnurlUrl { + return false + } + if lhs.delegateUrl != rhs.delegateUrl { + return false + } + if lhs.solverPubkey != rhs.solverPubkey { + return false + } + if lhs.solverRelays != rhs.solverRelays { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(arkServerUrl) + hasher.combine(esploraUrl) + hasher.combine(lnurlUrl) + hasher.combine(delegateUrl) + hasher.combine(solverPubkey) + hasher.combine(solverRelays) + } +} + +extension ArkadeRegtestEndpoints: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeArkadeRegtestEndpoints: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ArkadeRegtestEndpoints { + return + try ArkadeRegtestEndpoints( + arkServerUrl: FfiConverterString.read(from: &buf), + esploraUrl: FfiConverterString.read(from: &buf), + lnurlUrl: FfiConverterOptionString.read(from: &buf), + delegateUrl: FfiConverterOptionString.read(from: &buf), + solverPubkey: FfiConverterOptionString.read(from: &buf), + solverRelays: FfiConverterSequenceString.read(from: &buf) + ) + } + + public static func write(_ value: ArkadeRegtestEndpoints, into buf: inout [UInt8]) { + FfiConverterString.write(value.arkServerUrl, into: &buf) + FfiConverterString.write(value.esploraUrl, into: &buf) + FfiConverterOptionString.write(value.lnurlUrl, into: &buf) + FfiConverterOptionString.write(value.delegateUrl, into: &buf) + FfiConverterOptionString.write(value.solverPubkey, into: &buf) + FfiConverterSequenceString.write(value.solverRelays, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeRegtestEndpoints_lift(_ buf: RustBuffer) throws -> ArkadeRegtestEndpoints { + return try FfiConverterTypeArkadeRegtestEndpoints.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeRegtestEndpoints_lower(_ value: ArkadeRegtestEndpoints) -> RustBuffer { + return FfiConverterTypeArkadeRegtestEndpoints.lower(value) +} + + +public struct ArkadeStartParams { + /** + * Where the wallet keeps its database; the app gives each wallet its own. + */ + public var dataDir: String + public var mnemonic: String + public var passphrase: String? + public var network: ArkadeNetwork + /** + * Scan the operator and the chain for the wallet's past outputs before returning, as a wallet restored from + * its phrase needs (TypeScript `wallet.restore()`). + */ + public var restore: Bool + /** + * An `export_state` dump to import before the wallet opens its storage. + */ + public var backup: String? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * Where the wallet keeps its database; the app gives each wallet its own. + */dataDir: String, mnemonic: String, passphrase: String?, network: ArkadeNetwork, + /** + * Scan the operator and the chain for the wallet's past outputs before returning, as a wallet restored from + * its phrase needs (TypeScript `wallet.restore()`). + */restore: Bool, + /** + * An `export_state` dump to import before the wallet opens its storage. + */backup: String?) { + self.dataDir = dataDir + self.mnemonic = mnemonic + self.passphrase = passphrase + self.network = network + self.restore = restore + self.backup = backup + } +} + +#if compiler(>=6) +extension ArkadeStartParams: Sendable {} +#endif + + +extension ArkadeStartParams: Equatable, Hashable { + public static func ==(lhs: ArkadeStartParams, rhs: ArkadeStartParams) -> Bool { + if lhs.dataDir != rhs.dataDir { + return false + } + if lhs.mnemonic != rhs.mnemonic { + return false + } + if lhs.passphrase != rhs.passphrase { + return false + } + if lhs.network != rhs.network { + return false + } + if lhs.restore != rhs.restore { + return false + } + if lhs.backup != rhs.backup { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(dataDir) + hasher.combine(mnemonic) + hasher.combine(passphrase) + hasher.combine(network) + hasher.combine(restore) + hasher.combine(backup) + } +} + +extension ArkadeStartParams: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeArkadeStartParams: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ArkadeStartParams { + return + try ArkadeStartParams( + dataDir: FfiConverterString.read(from: &buf), + mnemonic: FfiConverterString.read(from: &buf), + passphrase: FfiConverterOptionString.read(from: &buf), + network: FfiConverterTypeArkadeNetwork.read(from: &buf), + restore: FfiConverterBool.read(from: &buf), + backup: FfiConverterOptionString.read(from: &buf) + ) + } + + public static func write(_ value: ArkadeStartParams, into buf: inout [UInt8]) { + FfiConverterString.write(value.dataDir, into: &buf) + FfiConverterString.write(value.mnemonic, into: &buf) + FfiConverterOptionString.write(value.passphrase, into: &buf) + FfiConverterTypeArkadeNetwork.write(value.network, into: &buf) + FfiConverterBool.write(value.restore, into: &buf) + FfiConverterOptionString.write(value.backup, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeStartParams_lift(_ buf: RustBuffer) throws -> ArkadeStartParams { + return try FfiConverterTypeArkadeStartParams.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeStartParams_lower(_ value: ArkadeStartParams) -> RustBuffer { + return FfiConverterTypeArkadeStartParams.lower(value) +} + + +/** + * A Lightning swap through Arkade Intents (the app's `ArkadeSwap`): `receive` pays into the wallet, `send` pays + * an invoice out of it. + */ +public struct ArkadeSwap { + public var id: String + /** + * `receive` or `send`. + */ + public var direction: String + /** + * `accepted`, `open`, `funded`, `claimed`, `paid`, `refunded`, `lapsed`, `needs_recovery` or `failed`. + */ + public var outcome: String + public var giveSat: UInt64 + public var takeSat: UInt64 + public var feeSat: UInt64 + public var bolt11: String + public var paymentHash: String + /** + * Unix seconds. + */ + public var createdAt: UInt64 + public var updatedAt: UInt64 + public var blockedReason: String? + /** + * Receive: the last moment the invoice can be paid. Send: when the quote lapses. + */ + public var expiresAt: UInt64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: String, + /** + * `receive` or `send`. + */direction: String, + /** + * `accepted`, `open`, `funded`, `claimed`, `paid`, `refunded`, `lapsed`, `needs_recovery` or `failed`. + */outcome: String, giveSat: UInt64, takeSat: UInt64, feeSat: UInt64, bolt11: String, paymentHash: String, + /** + * Unix seconds. + */createdAt: UInt64, updatedAt: UInt64, blockedReason: String?, + /** + * Receive: the last moment the invoice can be paid. Send: when the quote lapses. + */expiresAt: UInt64) { + self.id = id + self.direction = direction + self.outcome = outcome + self.giveSat = giveSat + self.takeSat = takeSat + self.feeSat = feeSat + self.bolt11 = bolt11 + self.paymentHash = paymentHash + self.createdAt = createdAt + self.updatedAt = updatedAt + self.blockedReason = blockedReason + self.expiresAt = expiresAt + } +} + +#if compiler(>=6) +extension ArkadeSwap: Sendable {} +#endif + + +extension ArkadeSwap: Equatable, Hashable { + public static func ==(lhs: ArkadeSwap, rhs: ArkadeSwap) -> Bool { + if lhs.id != rhs.id { + return false + } + if lhs.direction != rhs.direction { + return false + } + if lhs.outcome != rhs.outcome { + return false + } + if lhs.giveSat != rhs.giveSat { + return false + } + if lhs.takeSat != rhs.takeSat { + return false + } + if lhs.feeSat != rhs.feeSat { + return false + } + if lhs.bolt11 != rhs.bolt11 { + return false + } + if lhs.paymentHash != rhs.paymentHash { + return false + } + if lhs.createdAt != rhs.createdAt { + return false + } + if lhs.updatedAt != rhs.updatedAt { + return false + } + if lhs.blockedReason != rhs.blockedReason { + return false + } + if lhs.expiresAt != rhs.expiresAt { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(direction) + hasher.combine(outcome) + hasher.combine(giveSat) + hasher.combine(takeSat) + hasher.combine(feeSat) + hasher.combine(bolt11) + hasher.combine(paymentHash) + hasher.combine(createdAt) + hasher.combine(updatedAt) + hasher.combine(blockedReason) + hasher.combine(expiresAt) + } +} + +extension ArkadeSwap: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeArkadeSwap: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ArkadeSwap { + return + try ArkadeSwap( + id: FfiConverterString.read(from: &buf), + direction: FfiConverterString.read(from: &buf), + outcome: FfiConverterString.read(from: &buf), + giveSat: FfiConverterUInt64.read(from: &buf), + takeSat: FfiConverterUInt64.read(from: &buf), + feeSat: FfiConverterUInt64.read(from: &buf), + bolt11: FfiConverterString.read(from: &buf), + paymentHash: FfiConverterString.read(from: &buf), + createdAt: FfiConverterUInt64.read(from: &buf), + updatedAt: FfiConverterUInt64.read(from: &buf), + blockedReason: FfiConverterOptionString.read(from: &buf), + expiresAt: FfiConverterUInt64.read(from: &buf) + ) + } + + public static func write(_ value: ArkadeSwap, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.direction, into: &buf) + FfiConverterString.write(value.outcome, into: &buf) + FfiConverterUInt64.write(value.giveSat, into: &buf) + FfiConverterUInt64.write(value.takeSat, into: &buf) + FfiConverterUInt64.write(value.feeSat, into: &buf) + FfiConverterString.write(value.bolt11, into: &buf) + FfiConverterString.write(value.paymentHash, into: &buf) + FfiConverterUInt64.write(value.createdAt, into: &buf) + FfiConverterUInt64.write(value.updatedAt, into: &buf) + FfiConverterOptionString.write(value.blockedReason, into: &buf) + FfiConverterUInt64.write(value.expiresAt, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeSwap_lift(_ buf: RustBuffer) throws -> ArkadeSwap { + return try FfiConverterTypeArkadeSwap.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeSwap_lower(_ value: ArkadeSwap) -> RustBuffer { + return FfiConverterTypeArkadeSwap.lower(value) +} + + +public struct ArkadeVtxo { + public var txid: String + public var vout: UInt32 + public var value: UInt64 + /** + * `preconfirmed`, `settled`, `swept` or `spent`. + */ + public var state: String + /** + * Unix seconds after which the operator may sweep this output unless it is renewed. + */ + public var batchExpiry: UInt64? + /** + * Unix milliseconds, as the bridge reports it. + */ + public var createdAt: UInt64? + public var isPreconfirmed: Bool + public var isSwept: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(txid: String, vout: UInt32, value: UInt64, + /** + * `preconfirmed`, `settled`, `swept` or `spent`. + */state: String, + /** + * Unix seconds after which the operator may sweep this output unless it is renewed. + */batchExpiry: UInt64?, + /** + * Unix milliseconds, as the bridge reports it. + */createdAt: UInt64?, isPreconfirmed: Bool, isSwept: Bool) { + self.txid = txid + self.vout = vout + self.value = value + self.state = state + self.batchExpiry = batchExpiry + self.createdAt = createdAt + self.isPreconfirmed = isPreconfirmed + self.isSwept = isSwept + } +} + +#if compiler(>=6) +extension ArkadeVtxo: Sendable {} +#endif + + +extension ArkadeVtxo: Equatable, Hashable { + public static func ==(lhs: ArkadeVtxo, rhs: ArkadeVtxo) -> Bool { + if lhs.txid != rhs.txid { + return false + } + if lhs.vout != rhs.vout { + return false + } + if lhs.value != rhs.value { + return false + } + if lhs.state != rhs.state { + return false + } + if lhs.batchExpiry != rhs.batchExpiry { + return false + } + if lhs.createdAt != rhs.createdAt { + return false + } + if lhs.isPreconfirmed != rhs.isPreconfirmed { + return false + } + if lhs.isSwept != rhs.isSwept { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(txid) + hasher.combine(vout) + hasher.combine(value) + hasher.combine(state) + hasher.combine(batchExpiry) + hasher.combine(createdAt) + hasher.combine(isPreconfirmed) + hasher.combine(isSwept) + } +} + +extension ArkadeVtxo: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeArkadeVtxo: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ArkadeVtxo { + return + try ArkadeVtxo( + txid: FfiConverterString.read(from: &buf), + vout: FfiConverterUInt32.read(from: &buf), + value: FfiConverterUInt64.read(from: &buf), + state: FfiConverterString.read(from: &buf), + batchExpiry: FfiConverterOptionUInt64.read(from: &buf), + createdAt: FfiConverterOptionUInt64.read(from: &buf), + isPreconfirmed: FfiConverterBool.read(from: &buf), + isSwept: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: ArkadeVtxo, into buf: inout [UInt8]) { + FfiConverterString.write(value.txid, into: &buf) + FfiConverterUInt32.write(value.vout, into: &buf) + FfiConverterUInt64.write(value.value, into: &buf) + FfiConverterString.write(value.state, into: &buf) + FfiConverterOptionUInt64.write(value.batchExpiry, into: &buf) + FfiConverterOptionUInt64.write(value.createdAt, into: &buf) + FfiConverterBool.write(value.isPreconfirmed, into: &buf) + FfiConverterBool.write(value.isSwept, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeVtxo_lift(_ buf: RustBuffer) throws -> ArkadeVtxo { + return try FfiConverterTypeArkadeVtxo.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeVtxo_lower(_ value: ArkadeVtxo) -> RustBuffer { + return FfiConverterTypeArkadeVtxo.lower(value) +} + + +/** + * Fees and limits for a swap pair, used to size a swap and present costs. + */ +public struct BoltzPairInfo { + /** + * Pair hash identifying the current terms (passed back to Boltz if needed). + */ + public var hash: String + /** + * Exchange rate of the pair. + */ + public var rate: Double + /** + * Minimum swap amount in satoshis. + */ + public var minimalSat: UInt64 + /** + * Maximum swap amount in satoshis. + */ + public var maximalSat: UInt64 + /** + * Boltz service fee as a percentage of the swap amount. + */ + public var feePercentage: Double + /** + * Estimated absolute miner fees in satoshis. + */ + public var minerFeesSat: UInt64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * Pair hash identifying the current terms (passed back to Boltz if needed). + */hash: String, + /** + * Exchange rate of the pair. + */rate: Double, + /** + * Minimum swap amount in satoshis. + */minimalSat: UInt64, + /** + * Maximum swap amount in satoshis. + */maximalSat: UInt64, + /** + * Boltz service fee as a percentage of the swap amount. + */feePercentage: Double, + /** + * Estimated absolute miner fees in satoshis. + */minerFeesSat: UInt64) { + self.hash = hash + self.rate = rate + self.minimalSat = minimalSat + self.maximalSat = maximalSat + self.feePercentage = feePercentage + self.minerFeesSat = minerFeesSat + } +} + +#if compiler(>=6) +extension BoltzPairInfo: Sendable {} +#endif + + +extension BoltzPairInfo: Equatable, Hashable { + public static func ==(lhs: BoltzPairInfo, rhs: BoltzPairInfo) -> Bool { + if lhs.hash != rhs.hash { + return false + } + if lhs.rate != rhs.rate { + return false + } + if lhs.minimalSat != rhs.minimalSat { + return false + } + if lhs.maximalSat != rhs.maximalSat { + return false + } + if lhs.feePercentage != rhs.feePercentage { + return false + } + if lhs.minerFeesSat != rhs.minerFeesSat { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(hash) + hasher.combine(rate) + hasher.combine(minimalSat) + hasher.combine(maximalSat) + hasher.combine(feePercentage) + hasher.combine(minerFeesSat) + } +} + +extension BoltzPairInfo: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeBoltzPairInfo: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> BoltzPairInfo { + return + try BoltzPairInfo( + hash: FfiConverterString.read(from: &buf), + rate: FfiConverterDouble.read(from: &buf), + minimalSat: FfiConverterUInt64.read(from: &buf), + maximalSat: FfiConverterUInt64.read(from: &buf), + feePercentage: FfiConverterDouble.read(from: &buf), + minerFeesSat: FfiConverterUInt64.read(from: &buf) + ) + } + + public static func write(_ value: BoltzPairInfo, into buf: inout [UInt8]) { + FfiConverterString.write(value.hash, into: &buf) + FfiConverterDouble.write(value.rate, into: &buf) FfiConverterUInt64.write(value.minimalSat, into: &buf) FfiConverterUInt64.write(value.maximalSat, into: &buf) FfiConverterDouble.write(value.feePercentage, into: &buf) @@ -19268,26 +20756,273 @@ public struct FfiConverterTypeAddressError: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeAddressError_lift(_ buf: RustBuffer) throws -> AddressError { - return try FfiConverterTypeAddressError.lift(buf) +public func FfiConverterTypeAddressError_lift(_ buf: RustBuffer) throws -> AddressError { + return try FfiConverterTypeAddressError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeAddressError_lower(_ value: AddressError) -> RustBuffer { + return FfiConverterTypeAddressError.lower(value) +} + + +extension AddressError: Equatable, Hashable {} + +extension AddressError: Codable {} + + + + +extension AddressError: Foundation.LocalizedError { + public var errorDescription: String? { + String(reflecting: self) + } +} + + + + +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. + +public enum AddressType { + + case p2pkh + case p2sh + case p2wpkh + case p2wsh + case p2tr + case unknown +} + + +#if compiler(>=6) +extension AddressType: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeAddressType: FfiConverterRustBuffer { + typealias SwiftType = AddressType + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AddressType { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .p2pkh + + case 2: return .p2sh + + case 3: return .p2wpkh + + case 4: return .p2wsh + + case 5: return .p2tr + + case 6: return .unknown + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: AddressType, into buf: inout [UInt8]) { + switch value { + + + case .p2pkh: + writeInt(&buf, Int32(1)) + + + case .p2sh: + writeInt(&buf, Int32(2)) + + + case .p2wpkh: + writeInt(&buf, Int32(3)) + + + case .p2wsh: + writeInt(&buf, Int32(4)) + + + case .p2tr: + writeInt(&buf, Int32(5)) + + + case .unknown: + writeInt(&buf, Int32(6)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeAddressType_lift(_ buf: RustBuffer) throws -> AddressType { + return try FfiConverterTypeAddressType.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeAddressType_lower(_ value: AddressType) -> RustBuffer { + return FfiConverterTypeAddressType.lower(value) +} + + +extension AddressType: Equatable, Hashable {} + +extension AddressType: Codable {} + + + + + + + +/** + * Errors surfaced by the Arkade module. + */ +public enum ArkadeError: Swift.Error { + + + + case NotStarted + case InvalidInput(errorDetails: String + ) + case ArkError(errorDetails: String + ) + case LnurlError(errorDetails: String + ) + case SwapError(errorDetails: String + ) + case NetworkError(errorDetails: String + ) + case DatabaseError(errorDetails: String + ) + case InternalError(errorDetails: String + ) +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeArkadeError: FfiConverterRustBuffer { + typealias SwiftType = ArkadeError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ArkadeError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .NotStarted + case 2: return .InvalidInput( + errorDetails: try FfiConverterString.read(from: &buf) + ) + case 3: return .ArkError( + errorDetails: try FfiConverterString.read(from: &buf) + ) + case 4: return .LnurlError( + errorDetails: try FfiConverterString.read(from: &buf) + ) + case 5: return .SwapError( + errorDetails: try FfiConverterString.read(from: &buf) + ) + case 6: return .NetworkError( + errorDetails: try FfiConverterString.read(from: &buf) + ) + case 7: return .DatabaseError( + errorDetails: try FfiConverterString.read(from: &buf) + ) + case 8: return .InternalError( + errorDetails: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: ArkadeError, into buf: inout [UInt8]) { + switch value { + + + + + + case .NotStarted: + writeInt(&buf, Int32(1)) + + + case let .InvalidInput(errorDetails): + writeInt(&buf, Int32(2)) + FfiConverterString.write(errorDetails, into: &buf) + + + case let .ArkError(errorDetails): + writeInt(&buf, Int32(3)) + FfiConverterString.write(errorDetails, into: &buf) + + + case let .LnurlError(errorDetails): + writeInt(&buf, Int32(4)) + FfiConverterString.write(errorDetails, into: &buf) + + + case let .SwapError(errorDetails): + writeInt(&buf, Int32(5)) + FfiConverterString.write(errorDetails, into: &buf) + + + case let .NetworkError(errorDetails): + writeInt(&buf, Int32(6)) + FfiConverterString.write(errorDetails, into: &buf) + + + case let .DatabaseError(errorDetails): + writeInt(&buf, Int32(7)) + FfiConverterString.write(errorDetails, into: &buf) + + + case let .InternalError(errorDetails): + writeInt(&buf, Int32(8)) + FfiConverterString.write(errorDetails, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeArkadeError_lift(_ buf: RustBuffer) throws -> ArkadeError { + return try FfiConverterTypeArkadeError.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeAddressError_lower(_ value: AddressError) -> RustBuffer { - return FfiConverterTypeAddressError.lower(value) +public func FfiConverterTypeArkadeError_lower(_ value: ArkadeError) -> RustBuffer { + return FfiConverterTypeArkadeError.lower(value) } -extension AddressError: Equatable, Hashable {} +extension ArkadeError: Equatable, Hashable {} -extension AddressError: Codable {} +extension ArkadeError: Codable {} -extension AddressError: Foundation.LocalizedError { +extension ArkadeError: Foundation.LocalizedError { public var errorDescription: String? { String(reflecting: self) } @@ -19299,74 +21034,65 @@ extension AddressError: Foundation.LocalizedError { // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. -public enum AddressType { +public enum ArkadeNetwork { - case p2pkh - case p2sh - case p2wpkh - case p2wsh - case p2tr - case unknown + /** + * Mainnet, against Ark Labs' operator. + */ + case bitcoin + /** + * Arkade's signet, for dev builds. + */ + case mutinynet + /** + * A local regtest stack (operator, esplora, solver, LNURL server), for dev builds and tests. + */ + case regtest(endpoints: ArkadeRegtestEndpoints + ) } #if compiler(>=6) -extension AddressType: Sendable {} +extension ArkadeNetwork: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeAddressType: FfiConverterRustBuffer { - typealias SwiftType = AddressType +public struct FfiConverterTypeArkadeNetwork: FfiConverterRustBuffer { + typealias SwiftType = ArkadeNetwork - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AddressType { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ArkadeNetwork { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .p2pkh - - case 2: return .p2sh - - case 3: return .p2wpkh - - case 4: return .p2wsh + case 1: return .bitcoin - case 5: return .p2tr + case 2: return .mutinynet - case 6: return .unknown + case 3: return .regtest(endpoints: try FfiConverterTypeArkadeRegtestEndpoints.read(from: &buf) + ) default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: AddressType, into buf: inout [UInt8]) { + public static func write(_ value: ArkadeNetwork, into buf: inout [UInt8]) { switch value { - case .p2pkh: + case .bitcoin: writeInt(&buf, Int32(1)) - case .p2sh: + case .mutinynet: writeInt(&buf, Int32(2)) - case .p2wpkh: + case let .regtest(endpoints): writeInt(&buf, Int32(3)) - - - case .p2wsh: - writeInt(&buf, Int32(4)) - - - case .p2tr: - writeInt(&buf, Int32(5)) - - - case .unknown: - writeInt(&buf, Int32(6)) - + FfiConverterTypeArkadeRegtestEndpoints.write(endpoints, into: &buf) + } } } @@ -19375,21 +21101,21 @@ public struct FfiConverterTypeAddressType: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeAddressType_lift(_ buf: RustBuffer) throws -> AddressType { - return try FfiConverterTypeAddressType.lift(buf) +public func FfiConverterTypeArkadeNetwork_lift(_ buf: RustBuffer) throws -> ArkadeNetwork { + return try FfiConverterTypeArkadeNetwork.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeAddressType_lower(_ value: AddressType) -> RustBuffer { - return FfiConverterTypeAddressType.lower(value) +public func FfiConverterTypeArkadeNetwork_lower(_ value: ArkadeNetwork) -> RustBuffer { + return FfiConverterTypeArkadeNetwork.lower(value) } -extension AddressType: Equatable, Hashable {} +extension ArkadeNetwork: Equatable, Hashable {} -extension AddressType: Codable {} +extension ArkadeNetwork: Codable {} @@ -26334,6 +28060,30 @@ fileprivate struct FfiConverterOptionData: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeArkadeEventListener: FfiConverterRustBuffer { + typealias SwiftType = ArkadeEventListener? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeArkadeEventListener.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeArkadeEventListener.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -27707,6 +29457,81 @@ fileprivate struct FfiConverterSequenceTypeAddressInfo: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeArkadeHistoryEntry: FfiConverterRustBuffer { + typealias SwiftType = [ArkadeHistoryEntry] + + public static func write(_ value: [ArkadeHistoryEntry], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeArkadeHistoryEntry.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [ArkadeHistoryEntry] { + let len: Int32 = try readInt(&buf) + var seq = [ArkadeHistoryEntry]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeArkadeHistoryEntry.read(from: &buf)) + } + return seq + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeArkadeSwap: FfiConverterRustBuffer { + typealias SwiftType = [ArkadeSwap] + + public static func write(_ value: [ArkadeSwap], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeArkadeSwap.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [ArkadeSwap] { + let len: Int32 = try readInt(&buf) + var seq = [ArkadeSwap]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeArkadeSwap.read(from: &buf)) + } + return seq + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeArkadeVtxo: FfiConverterRustBuffer { + typealias SwiftType = [ArkadeVtxo] + + public static func write(_ value: [ArkadeVtxo], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeArkadeVtxo.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [ArkadeVtxo] { + let len: Int32 = try readInt(&buf) + var seq = [ArkadeVtxo]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeArkadeVtxo.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -28837,86 +30662,418 @@ public func uniffiForeignFutureHandleCountBitkitcore() -> Int { UNIFFI_FOREIGN_FUTURE_HANDLE_MAP.count } /** - * Decode activities from Core's canonical backup JSON, defaulting a - * missing/empty wallet id to [`DEFAULT_WALLET_ID`]. + * Decode activities from Core's canonical backup JSON, defaulting a + * missing/empty wallet id to [`DEFAULT_WALLET_ID`]. + */ +public func activitiesFromJson(json: String)throws -> [Activity] { + return try FfiConverterSequenceTypeActivity.lift(try rustCallWithError(FfiConverterTypeActivityError_lift) { + uniffi_bitkitcore_fn_func_activities_from_json( + FfiConverterString.lower(json),$0 + ) +}) +} +/** + * Serialize activities to Core's canonical backup JSON. + */ +public func activitiesToJson(activities: [Activity])throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeActivityError_lift) { + uniffi_bitkitcore_fn_func_activities_to_json( + FfiConverterSequenceTypeActivity.lower(activities),$0 + ) +}) +} +/** + * Decode activity tags from Core's canonical backup JSON, defaulting a + * missing/empty wallet id to [`DEFAULT_WALLET_ID`]. + */ +public func activityTagsFromJson(json: String)throws -> [ActivityTags] { + return try FfiConverterSequenceTypeActivityTags.lift(try rustCallWithError(FfiConverterTypeActivityError_lift) { + uniffi_bitkitcore_fn_func_activity_tags_from_json( + FfiConverterString.lower(json),$0 + ) +}) +} +/** + * Serialize activity tags to Core's canonical backup JSON. + */ +public func activityTagsToJson(tags: [ActivityTags])throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeActivityError_lift) { + uniffi_bitkitcore_fn_func_activity_tags_to_json( + FfiConverterSequenceTypeActivityTags.lower(tags),$0 + ) +}) +} +public func activityWipeAll()throws {try rustCallWithError(FfiConverterTypeActivityError_lift) { + uniffi_bitkitcore_fn_func_activity_wipe_all($0 + ) +} +} +public func addPreActivityMetadata(preActivityMetadata: PreActivityMetadata)throws {try rustCallWithError(FfiConverterTypeActivityError_lift) { + uniffi_bitkitcore_fn_func_add_pre_activity_metadata( + FfiConverterTypePreActivityMetadata_lower(preActivityMetadata),$0 + ) +} +} +public func addPreActivityMetadataTags(walletId: String, paymentId: String, tags: [String])throws {try rustCallWithError(FfiConverterTypeActivityError_lift) { + uniffi_bitkitcore_fn_func_add_pre_activity_metadata_tags( + FfiConverterString.lower(walletId), + FfiConverterString.lower(paymentId), + FfiConverterSequenceString.lower(tags),$0 + ) +} +} +public func addTags(walletId: String, activityId: String, tags: [String])throws {try rustCallWithError(FfiConverterTypeActivityError_lift) { + uniffi_bitkitcore_fn_func_add_tags( + FfiConverterString.lower(walletId), + FfiConverterString.lower(activityId), + FfiConverterSequenceString.lower(tags),$0 + ) +} +} +public func approvePubkyAuth(authUrl: String, secretKeyHex: String)async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_approve_pubky_auth(FfiConverterString.lower(authUrl),FfiConverterString.lower(secretKeyHex) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_void, + completeFunc: ffi_bitkitcore_rust_future_complete_void, + freeFunc: ffi_bitkitcore_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypePubkyError_lift + ) +} +public func arkadeBalance()async throws -> ArkadeBalance { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_balance( + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeArkadeBalance_lift, + errorHandler: FfiConverterTypeArkadeError_lift + ) +} +/** + * Whether a delegate renews the wallet's outputs while it is offline. + */ +public func arkadeDelegateInfo()async throws -> Bool { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_delegate_info( + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_i8, + completeFunc: ffi_bitkitcore_rust_future_complete_i8, + freeFunc: ffi_bitkitcore_rust_future_free_i8, + liftFunc: FfiConverterBool.lift, + errorHandler: FfiConverterTypeArkadeError_lift + ) +} +/** + * Everything the Arkade wallet keeps in `data_dir`, as one JSON document for the VSS backup. + */ +public func arkadeExportState(dataDir: String)throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeArkadeError_lift) { + uniffi_bitkitcore_fn_func_arkade_export_state( + FfiConverterString.lower(dataDir),$0 + ) +}) +} +public func arkadeHistory()async throws -> [ArkadeHistoryEntry] { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_history( + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterSequenceTypeArkadeHistoryEntry.lift, + errorHandler: FfiConverterTypeArkadeError_lift + ) +} +/** + * Import an `arkade_export_state` dump into `data_dir`; the number of rows added. Import before + * `arkade_start`, or pass the dump as `ArkadeStartParams.backup`. */ -public func activitiesFromJson(json: String)throws -> [Activity] { - return try FfiConverterSequenceTypeActivity.lift(try rustCallWithError(FfiConverterTypeActivityError_lift) { - uniffi_bitkitcore_fn_func_activities_from_json( +public func arkadeImportState(dataDir: String, json: String)throws -> UInt64 { + return try FfiConverterUInt64.lift(try rustCallWithError(FfiConverterTypeArkadeError_lift) { + uniffi_bitkitcore_fn_func_arkade_import_state( + FfiConverterString.lower(dataDir), FfiConverterString.lower(json),$0 ) }) } +public func arkadeInfo()async throws -> ArkadeInfo { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_info( + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeArkadeInfo_lift, + errorHandler: FfiConverterTypeArkadeError_lift + ) +} /** - * Serialize activities to Core's canonical backup JSON. + * The wallet's Lightning address on Ark Labs' LNURL server, found again from the seed or claimed on first use. */ -public func activitiesToJson(activities: [Activity])throws -> String { - return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeActivityError_lift) { - uniffi_bitkitcore_fn_func_activities_to_json( - FfiConverterSequenceTypeActivity.lower(activities),$0 - ) -}) +public func arkadeLightningAddress()async throws -> ArkadeLightningAddress { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_lightning_address( + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeArkadeLightningAddress_lift, + errorHandler: FfiConverterTypeArkadeError_lift + ) } /** - * Decode activity tags from Core's canonical backup JSON, defaulting a - * missing/empty wallet id to [`DEFAULT_WALLET_ID`]. + * Move `amount_sat`, or everything when `None`, to the on-chain `address` in the next batch, less the + * operator's exit fee; the commitment txid. */ -public func activityTagsFromJson(json: String)throws -> [ActivityTags] { - return try FfiConverterSequenceTypeActivityTags.lift(try rustCallWithError(FfiConverterTypeActivityError_lift) { - uniffi_bitkitcore_fn_func_activity_tags_from_json( - FfiConverterString.lower(json),$0 - ) -}) +public func arkadeOffboard(address: String, amountSat: UInt64?)async throws -> String { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_offboard(FfiConverterString.lower(address),FfiConverterOptionUInt64.lower(amountSat) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterString.lift, + errorHandler: FfiConverterTypeArkadeError_lift + ) } /** - * Serialize activity tags to Core's canonical backup JSON. + * The operator's fee programs, which `arkade_offboard` pays. */ -public func activityTagsToJson(tags: [ActivityTags])throws -> String { - return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeActivityError_lift) { - uniffi_bitkitcore_fn_func_activity_tags_to_json( - FfiConverterSequenceTypeActivityTags.lower(tags),$0 - ) -}) +public func arkadeOffboardFee()async throws -> ArkadeFees { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_offboard_fee( + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeArkadeFees_lift, + errorHandler: FfiConverterTypeArkadeError_lift + ) } -public func activityWipeAll()throws {try rustCallWithError(FfiConverterTypeActivityError_lift) { - uniffi_bitkitcore_fn_func_activity_wipe_all($0 - ) +/** + * Pay `bolt11` through Arkade Intents, refusing a fee above `max_fee_sat`. Returns once the lockup is funded; + * the outcome arrives through the listener's `on_swap`. + */ +public func arkadePay(bolt11: String, maxFeeSat: UInt64?)async throws -> ArkadeSwap { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_pay(FfiConverterString.lower(bolt11),FfiConverterOptionUInt64.lower(maxFeeSat) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeArkadeSwap_lift, + errorHandler: FfiConverterTypeArkadeError_lift + ) } +/** + * What paying `bolt11` through Arkade Intents would cost. + */ +public func arkadeQuotePay(bolt11: String)async throws -> ArkadePayQuote { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_quote_pay(FfiConverterString.lower(bolt11) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeArkadePayQuote_lift, + errorHandler: FfiConverterTypeArkadeError_lift + ) } -public func addPreActivityMetadata(preActivityMetadata: PreActivityMetadata)throws {try rustCallWithError(FfiConverterTypeActivityError_lift) { - uniffi_bitkitcore_fn_func_add_pre_activity_metadata( - FfiConverterTypePreActivityMetadata_lower(preActivityMetadata),$0 - ) +/** + * An invoice for exactly `amount_sat` through Arkade Intents: the payer pays it, the wallet gets it less the + * solver's fee. The swap is stored first; the wallet claims it once the solver funds the lockup. + */ +public func arkadeReceive(amountSat: UInt64)async throws -> ArkadeSwap { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_receive(FfiConverterUInt64.lower(amountSat) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeArkadeSwap_lift, + errorHandler: FfiConverterTypeArkadeError_lift + ) } +/** + * Renew outputs within 3 days of their batch expiry, and recoverable ones; the commitment txid, if any. + */ +public func arkadeRenew()async throws -> String? { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_renew( + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterOptionString.lift, + errorHandler: FfiConverterTypeArkadeError_lift + ) } -public func addPreActivityMetadataTags(walletId: String, paymentId: String, tags: [String])throws {try rustCallWithError(FfiConverterTypeActivityError_lift) { - uniffi_bitkitcore_fn_func_add_pre_activity_metadata_tags( - FfiConverterString.lower(walletId), - FfiConverterString.lower(paymentId), - FfiConverterSequenceString.lower(tags),$0 +/** + * Pay `amount_sat` to another Arkade address; the Arkade txid. + */ +public func arkadeSendArk(address: String, amountSat: UInt64)async throws -> String { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_send_ark(FfiConverterString.lower(address),FfiConverterUInt64.lower(amountSat) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterString.lift, + errorHandler: FfiConverterTypeArkadeError_lift + ) +} +/** + * Register the receiver of Arkade history events, or remove it with `None`. + */ +public func arkadeSetListener(listener: ArkadeEventListener?) {try! rustCall() { + uniffi_bitkitcore_fn_func_arkade_set_listener( + FfiConverterOptionTypeArkadeEventListener.lower(listener),$0 ) } } -public func addTags(walletId: String, activityId: String, tags: [String])throws {try rustCallWithError(FfiConverterTypeActivityError_lift) { - uniffi_bitkitcore_fn_func_add_tags( - FfiConverterString.lower(walletId), - FfiConverterString.lower(activityId), - FfiConverterSequenceString.lower(tags),$0 - ) +/** + * Board confirmed on-chain deposits now instead of on the next one-minute pass; the commitment txid, or `None` + * when there was nothing to board. + */ +public func arkadeSettle()async throws -> String? { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_settle( + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterOptionString.lift, + errorHandler: FfiConverterTypeArkadeError_lift + ) +} +/** + * Start the Arkade wallet from the Bitkit seed (BIP86 account 1), or return its info when it already runs. + * `params.restore` scans the operator and the chain for a restored wallet's past outputs; `params.backup` is an + * `arkade_export_state` dump imported before the wallet opens its database. + */ +public func arkadeStart(params: ArkadeStartParams)async throws -> ArkadeInfo { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_start(FfiConverterTypeArkadeStartParams_lower(params) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeArkadeInfo_lift, + errorHandler: FfiConverterTypeArkadeError_lift + ) } +/** + * Stop the Arkade wallet and its background tasks; its database stays. + */ +public func arkadeStop()async { + return + try! await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_stop( + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_void, + completeFunc: ffi_bitkitcore_rust_future_complete_void, + freeFunc: ffi_bitkitcore_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: nil + + ) } -public func approvePubkyAuth(authUrl: String, secretKeyHex: String)async throws { +/** + * Every Arkade Intents Lightning swap, newest first. + */ +public func arkadeSwaps()async throws -> [ArkadeSwap] { return try await uniffiRustCallAsync( rustFutureFunc: { - uniffi_bitkitcore_fn_func_approve_pubky_auth(FfiConverterString.lower(authUrl),FfiConverterString.lower(secretKeyHex) + uniffi_bitkitcore_fn_func_arkade_swaps( + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterSequenceTypeArkadeSwap.lift, + errorHandler: FfiConverterTypeArkadeError_lift + ) +} +public func arkadeVtxos()async throws -> [ArkadeVtxo] { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_vtxos( + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterSequenceTypeArkadeVtxo.lift, + errorHandler: FfiConverterTypeArkadeError_lift + ) +} +/** + * Stop the Arkade wallet and delete its database in `data_dir`. + */ +public func arkadeWipe(dataDir: String)async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_arkade_wipe(FfiConverterString.lower(dataDir) ) }, pollFunc: ffi_bitkitcore_rust_future_poll_void, completeFunc: ffi_bitkitcore_rust_future_complete_void, freeFunc: ffi_bitkitcore_rust_future_free_void, liftFunc: { $0 }, - errorHandler: FfiConverterTypePubkyError_lift + errorHandler: FfiConverterTypeArkadeError_lift ) } public func blocktankRemoveAllCjitEntries()async throws { @@ -31569,6 +33726,69 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_func_approve_pubky_auth() != 22222) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_func_arkade_balance() != 23068) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_delegate_info() != 39830) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_export_state() != 43252) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_history() != 15370) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_import_state() != 6508) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_info() != 60547) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_lightning_address() != 5082) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_offboard() != 3856) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_offboard_fee() != 41350) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_pay() != 8253) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_quote_pay() != 10416) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_receive() != 20657) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_renew() != 12733) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_send_ark() != 16983) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_set_listener() != 27978) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_settle() != 39476) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_start() != 47973) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_stop() != 16403) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_swaps() != 3452) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_vtxos() != 6583) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_arkade_wipe() != 38999) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_func_blocktank_remove_all_cjit_entries() != 40127) { return InitializationResult.apiChecksumMismatch } @@ -32175,6 +34395,12 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_func_wipe_all_transaction_details() != 65339) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_method_arkadeeventlistener_on_history() != 64003) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_method_arkadeeventlistener_on_swap() != 44042) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_method_boltzeventlistener_on_event() != 11294) { return InitializationResult.apiChecksumMismatch } @@ -32320,6 +34546,7 @@ private let initializationResult: InitializationResult = { return InitializationResult.apiChecksumMismatch } + uniffiCallbackInitArkadeEventListener() uniffiCallbackInitBoltzEventListener() uniffiCallbackInitEventListener() uniffiCallbackInitJadeTransportCallback() diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index 388dc3f..8a4b82c 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -250,6 +250,20 @@ typedef struct UniffiForeignFutureStructVoid { typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureStructVoid ); +#endif +#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_ARKADE_EVENT_LISTENER_METHOD0 +#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_ARKADE_EVENT_LISTENER_METHOD0 +typedef void (*UniffiCallbackInterfaceArkadeEventListenerMethod0)(uint64_t, RustBuffer, RustBuffer, void* _Nonnull, + RustCallStatus *_Nonnull uniffiCallStatus + ); + +#endif +#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_ARKADE_EVENT_LISTENER_METHOD1 +#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_ARKADE_EVENT_LISTENER_METHOD1 +typedef void (*UniffiCallbackInterfaceArkadeEventListenerMethod1)(uint64_t, RustBuffer, void* _Nonnull, + RustCallStatus *_Nonnull uniffiCallStatus + ); + #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_BOLTZ_EVENT_LISTENER_METHOD0 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_BOLTZ_EVENT_LISTENER_METHOD0 @@ -403,6 +417,15 @@ typedef void (*UniffiCallbackInterfaceTrezorUiCallbackMethod1)(uint64_t, int8_t, typedef void (*UniffiCallbackInterfaceUsdtBackupMethod0)(uint64_t, RustBuffer, UniffiForeignFutureCompleteVoid _Nonnull, uint64_t, UniffiForeignFuture* _Nonnull ); +#endif +#ifndef UNIFFI_FFIDEF_V_TABLE_CALLBACK_INTERFACE_ARKADE_EVENT_LISTENER +#define UNIFFI_FFIDEF_V_TABLE_CALLBACK_INTERFACE_ARKADE_EVENT_LISTENER +typedef struct UniffiVTableCallbackInterfaceArkadeEventListener { + UniffiCallbackInterfaceArkadeEventListenerMethod0 _Nonnull onHistory; + UniffiCallbackInterfaceArkadeEventListenerMethod1 _Nonnull onSwap; + UniffiCallbackInterfaceFree _Nonnull uniffiFree; +} UniffiVTableCallbackInterfaceArkadeEventListener; + #endif #ifndef UNIFFI_FFIDEF_V_TABLE_CALLBACK_INTERFACE_BOLTZ_EVENT_LISTENER #define UNIFFI_FFIDEF_V_TABLE_CALLBACK_INTERFACE_BOLTZ_EVENT_LISTENER @@ -467,6 +490,31 @@ typedef struct UniffiVTableCallbackInterfaceUsdtBackup { UniffiCallbackInterfaceFree _Nonnull uniffiFree; } UniffiVTableCallbackInterfaceUsdtBackup; +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CLONE_ARKADEEVENTLISTENER +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CLONE_ARKADEEVENTLISTENER +void*_Nonnull uniffi_bitkitcore_fn_clone_arkadeeventlistener(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FREE_ARKADEEVENTLISTENER +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FREE_ARKADEEVENTLISTENER +void uniffi_bitkitcore_fn_free_arkadeeventlistener(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_INIT_CALLBACK_VTABLE_ARKADEEVENTLISTENER +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_INIT_CALLBACK_VTABLE_ARKADEEVENTLISTENER +void uniffi_bitkitcore_fn_init_callback_vtable_arkadeeventlistener(const UniffiVTableCallbackInterfaceArkadeEventListener* _Nonnull vtable +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_ARKADEEVENTLISTENER_ON_HISTORY +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_ARKADEEVENTLISTENER_ON_HISTORY +void uniffi_bitkitcore_fn_method_arkadeeventlistener_on_history(void*_Nonnull ptr, RustBuffer entries, RustBuffer balance, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_ARKADEEVENTLISTENER_ON_SWAP +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_ARKADEEVENTLISTENER_ON_SWAP +void uniffi_bitkitcore_fn_method_arkadeeventlistener_on_swap(void*_Nonnull ptr, RustBuffer swap, RustCallStatus *_Nonnull out_status +); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CLONE_BOLTZEVENTLISTENER #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CLONE_BOLTZEVENTLISTENER @@ -875,6 +923,122 @@ void uniffi_bitkitcore_fn_func_add_tags(RustBuffer wallet_id, RustBuffer activit uint64_t uniffi_bitkitcore_fn_func_approve_pubky_auth(RustBuffer auth_url, RustBuffer secret_key_hex ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_BALANCE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_BALANCE +uint64_t uniffi_bitkitcore_fn_func_arkade_balance(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_DELEGATE_INFO +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_DELEGATE_INFO +uint64_t uniffi_bitkitcore_fn_func_arkade_delegate_info(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_EXPORT_STATE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_EXPORT_STATE +RustBuffer uniffi_bitkitcore_fn_func_arkade_export_state(RustBuffer data_dir, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_HISTORY +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_HISTORY +uint64_t uniffi_bitkitcore_fn_func_arkade_history(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_IMPORT_STATE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_IMPORT_STATE +uint64_t uniffi_bitkitcore_fn_func_arkade_import_state(RustBuffer data_dir, RustBuffer json, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_INFO +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_INFO +uint64_t uniffi_bitkitcore_fn_func_arkade_info(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_LIGHTNING_ADDRESS +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_LIGHTNING_ADDRESS +uint64_t uniffi_bitkitcore_fn_func_arkade_lightning_address(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_OFFBOARD +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_OFFBOARD +uint64_t uniffi_bitkitcore_fn_func_arkade_offboard(RustBuffer address, RustBuffer amount_sat +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_OFFBOARD_FEE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_OFFBOARD_FEE +uint64_t uniffi_bitkitcore_fn_func_arkade_offboard_fee(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_PAY +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_PAY +uint64_t uniffi_bitkitcore_fn_func_arkade_pay(RustBuffer bolt11, RustBuffer max_fee_sat +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_QUOTE_PAY +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_QUOTE_PAY +uint64_t uniffi_bitkitcore_fn_func_arkade_quote_pay(RustBuffer bolt11 +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_RECEIVE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_RECEIVE +uint64_t uniffi_bitkitcore_fn_func_arkade_receive(uint64_t amount_sat +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_RENEW +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_RENEW +uint64_t uniffi_bitkitcore_fn_func_arkade_renew(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_SEND_ARK +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_SEND_ARK +uint64_t uniffi_bitkitcore_fn_func_arkade_send_ark(RustBuffer address, uint64_t amount_sat +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_SET_LISTENER +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_SET_LISTENER +void uniffi_bitkitcore_fn_func_arkade_set_listener(RustBuffer listener, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_SETTLE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_SETTLE +uint64_t uniffi_bitkitcore_fn_func_arkade_settle(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_START +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_START +uint64_t uniffi_bitkitcore_fn_func_arkade_start(RustBuffer params +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_STOP +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_STOP +uint64_t uniffi_bitkitcore_fn_func_arkade_stop(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_SWAPS +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_SWAPS +uint64_t uniffi_bitkitcore_fn_func_arkade_swaps(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_VTXOS +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_VTXOS +uint64_t uniffi_bitkitcore_fn_func_arkade_vtxos(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_WIPE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ARKADE_WIPE +uint64_t uniffi_bitkitcore_fn_func_arkade_wipe(RustBuffer data_dir +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_BLOCKTANK_REMOVE_ALL_CJIT_ENTRIES #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_BLOCKTANK_REMOVE_ALL_CJIT_ENTRIES uint64_t uniffi_bitkitcore_fn_func_blocktank_remove_all_cjit_entries(void @@ -2258,6 +2422,132 @@ uint16_t uniffi_bitkitcore_checksum_func_add_tags(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_APPROVE_PUBKY_AUTH uint16_t uniffi_bitkitcore_checksum_func_approve_pubky_auth(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_BALANCE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_BALANCE +uint16_t uniffi_bitkitcore_checksum_func_arkade_balance(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_DELEGATE_INFO +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_DELEGATE_INFO +uint16_t uniffi_bitkitcore_checksum_func_arkade_delegate_info(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_EXPORT_STATE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_EXPORT_STATE +uint16_t uniffi_bitkitcore_checksum_func_arkade_export_state(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_HISTORY +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_HISTORY +uint16_t uniffi_bitkitcore_checksum_func_arkade_history(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_IMPORT_STATE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_IMPORT_STATE +uint16_t uniffi_bitkitcore_checksum_func_arkade_import_state(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_INFO +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_INFO +uint16_t uniffi_bitkitcore_checksum_func_arkade_info(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_LIGHTNING_ADDRESS +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_LIGHTNING_ADDRESS +uint16_t uniffi_bitkitcore_checksum_func_arkade_lightning_address(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_OFFBOARD +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_OFFBOARD +uint16_t uniffi_bitkitcore_checksum_func_arkade_offboard(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_OFFBOARD_FEE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_OFFBOARD_FEE +uint16_t uniffi_bitkitcore_checksum_func_arkade_offboard_fee(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_PAY +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_PAY +uint16_t uniffi_bitkitcore_checksum_func_arkade_pay(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_QUOTE_PAY +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_QUOTE_PAY +uint16_t uniffi_bitkitcore_checksum_func_arkade_quote_pay(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_RECEIVE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_RECEIVE +uint16_t uniffi_bitkitcore_checksum_func_arkade_receive(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_RENEW +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_RENEW +uint16_t uniffi_bitkitcore_checksum_func_arkade_renew(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_SEND_ARK +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_SEND_ARK +uint16_t uniffi_bitkitcore_checksum_func_arkade_send_ark(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_SET_LISTENER +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_SET_LISTENER +uint16_t uniffi_bitkitcore_checksum_func_arkade_set_listener(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_SETTLE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_SETTLE +uint16_t uniffi_bitkitcore_checksum_func_arkade_settle(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_START +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_START +uint16_t uniffi_bitkitcore_checksum_func_arkade_start(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_STOP +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_STOP +uint16_t uniffi_bitkitcore_checksum_func_arkade_stop(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_SWAPS +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_SWAPS +uint16_t uniffi_bitkitcore_checksum_func_arkade_swaps(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_VTXOS +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_VTXOS +uint16_t uniffi_bitkitcore_checksum_func_arkade_vtxos(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_WIPE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_ARKADE_WIPE +uint16_t uniffi_bitkitcore_checksum_func_arkade_wipe(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_BLOCKTANK_REMOVE_ALL_CJIT_ENTRIES @@ -3470,6 +3760,18 @@ uint16_t uniffi_bitkitcore_checksum_func_wipe_all_databases(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_WIPE_ALL_TRANSACTION_DETAILS uint16_t uniffi_bitkitcore_checksum_func_wipe_all_transaction_details(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_ARKADEEVENTLISTENER_ON_HISTORY +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_ARKADEEVENTLISTENER_ON_HISTORY +uint16_t uniffi_bitkitcore_checksum_method_arkadeeventlistener_on_history(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_ARKADEEVENTLISTENER_ON_SWAP +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_ARKADEEVENTLISTENER_ON_SWAP +uint16_t uniffi_bitkitcore_checksum_method_arkadeeventlistener_on_swap(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_BOLTZEVENTLISTENER_ON_EVENT diff --git a/src/lib.rs b/src/lib.rs index bcb474f..647eecf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -85,6 +85,11 @@ use crate::onchain::{ start_watcher, stop_all_watchers, stop_watcher, EventListener, WatcherParams, }; pub use modules::activity; +pub use modules::arkade::{ + self as arkade, ArkadeBalance, ArkadeError, ArkadeEventListener, ArkadeFees, + ArkadeHistoryEntry, ArkadeInfo, ArkadeLightningAddress, ArkadeNetwork, ArkadePayQuote, + ArkadeStartParams, ArkadeSwap, ArkadeVtxo, +}; pub use modules::boltz as boltz_swaps; pub use modules::lnurl; pub use modules::onchain; @@ -3415,6 +3420,165 @@ pub async fn boltz_stop_swap_updates() { .await; } +// ── Arkade ─────────────────────────────────────────────────────────────────── + +fn arkade_runtime_err(e: tokio::task::JoinError) -> ArkadeError { + ArkadeError::InternalError { + error_details: format!("Arkade task failed: {e}"), + } +} + +/// Runs an Arkade call on the shared runtime, which ark-client's gRPC and the wallet's background tasks need. +async fn on_arkade_runtime(f: F) -> Result +where + T: Send + 'static, + F: std::future::Future> + Send + 'static, +{ + ensure_runtime() + .spawn(f) + .await + .unwrap_or_else(|e| Err(arkade_runtime_err(e))) +} + +/// Start the Arkade wallet from the Bitkit seed (BIP86 account 1), or return its info when it already runs. +/// `params.restore` scans the operator and the chain for a restored wallet's past outputs; `params.backup` is an +/// `arkade_export_state` dump imported before the wallet opens its database. +#[uniffi::export] +pub async fn arkade_start(params: ArkadeStartParams) -> Result { + on_arkade_runtime(arkade::start(params)).await +} + +/// Stop the Arkade wallet and its background tasks; its database stays. +#[uniffi::export] +pub async fn arkade_stop() { + let _ = on_arkade_runtime(async { + arkade::stop().await; + Ok(()) + }) + .await; +} + +/// Register the receiver of Arkade history events, or remove it with `None`. +#[uniffi::export] +pub fn arkade_set_listener(listener: Option>) { + arkade::set_listener(listener) +} + +#[uniffi::export] +pub async fn arkade_info() -> Result { + on_arkade_runtime(arkade::info()).await +} + +#[uniffi::export] +pub async fn arkade_balance() -> Result { + on_arkade_runtime(arkade::balance()).await +} + +#[uniffi::export] +pub async fn arkade_vtxos() -> Result, ArkadeError> { + on_arkade_runtime(arkade::vtxos()).await +} + +#[uniffi::export] +pub async fn arkade_history() -> Result, ArkadeError> { + on_arkade_runtime(arkade::history()).await +} + +/// Board confirmed on-chain deposits now instead of on the next one-minute pass; the commitment txid, or `None` +/// when there was nothing to board. +#[uniffi::export] +pub async fn arkade_settle() -> Result, ArkadeError> { + on_arkade_runtime(arkade::settle()).await +} + +/// Renew outputs within 3 days of their batch expiry, and recoverable ones; the commitment txid, if any. +#[uniffi::export] +pub async fn arkade_renew() -> Result, ArkadeError> { + on_arkade_runtime(arkade::renew()).await +} + +/// The operator's fee programs, which `arkade_offboard` pays. +#[uniffi::export] +pub async fn arkade_offboard_fee() -> Result { + on_arkade_runtime(arkade::offboard_fee()).await +} + +/// Move `amount_sat`, or everything when `None`, to the on-chain `address` in the next batch, less the +/// operator's exit fee; the commitment txid. +#[uniffi::export] +pub async fn arkade_offboard( + address: String, + amount_sat: Option, +) -> Result { + on_arkade_runtime(arkade::offboard(address, amount_sat)).await +} + +/// Pay `amount_sat` to another Arkade address; the Arkade txid. +#[uniffi::export] +pub async fn arkade_send_ark(address: String, amount_sat: u64) -> Result { + on_arkade_runtime(arkade::send_ark(address, amount_sat)).await +} + +/// Whether a delegate renews the wallet's outputs while it is offline. +#[uniffi::export] +pub async fn arkade_delegate_info() -> Result { + on_arkade_runtime(arkade::delegate_info()).await +} + +/// The wallet's Lightning address on Ark Labs' LNURL server, found again from the seed or claimed on first use. +#[uniffi::export] +pub async fn arkade_lightning_address() -> Result { + on_arkade_runtime(arkade::lightning_address()).await +} + +/// An invoice for exactly `amount_sat` through Arkade Intents: the payer pays it, the wallet gets it less the +/// solver's fee. The swap is stored first; the wallet claims it once the solver funds the lockup. +#[uniffi::export] +pub async fn arkade_receive(amount_sat: u64) -> Result { + on_arkade_runtime(arkade::receive(amount_sat)).await +} + +/// What paying `bolt11` through Arkade Intents would cost. +#[uniffi::export] +pub async fn arkade_quote_pay(bolt11: String) -> Result { + on_arkade_runtime(arkade::quote_pay(bolt11)).await +} + +/// Pay `bolt11` through Arkade Intents, refusing a fee above `max_fee_sat`. Returns once the lockup is funded; +/// the outcome arrives through the listener's `on_swap`. +#[uniffi::export] +pub async fn arkade_pay( + bolt11: String, + max_fee_sat: Option, +) -> Result { + on_arkade_runtime(arkade::pay(bolt11, max_fee_sat)).await +} + +/// Every Arkade Intents Lightning swap, newest first. +#[uniffi::export] +pub async fn arkade_swaps() -> Result, ArkadeError> { + on_arkade_runtime(arkade::swaps()).await +} + +/// Everything the Arkade wallet keeps in `data_dir`, as one JSON document for the VSS backup. +#[uniffi::export] +pub fn arkade_export_state(data_dir: String) -> Result { + arkade::export_state(&data_dir) +} + +/// Import an `arkade_export_state` dump into `data_dir`; the number of rows added. Import before +/// `arkade_start`, or pass the dump as `ArkadeStartParams.backup`. +#[uniffi::export] +pub fn arkade_import_state(data_dir: String, json: String) -> Result { + arkade::import_state(&data_dir, &json) +} + +/// Stop the Arkade wallet and delete its database in `data_dir`. +#[uniffi::export] +pub async fn arkade_wipe(data_dir: String) -> Result<(), ArkadeError> { + on_arkade_runtime(async move { arkade::wipe(&data_dir).await }).await +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/modules/arkade/errors.rs b/src/modules/arkade/errors.rs new file mode 100644 index 0000000..0301605 --- /dev/null +++ b/src/modules/arkade/errors.rs @@ -0,0 +1,88 @@ +use std::fmt::Display; +use thiserror::Error; + +/// Errors surfaced by the Arkade module. +#[derive(uniffi::Error, Debug, Error)] +pub enum ArkadeError { + #[error("Arkade wallet not started")] + NotStarted, + + #[error("Invalid input: {error_details}")] + InvalidInput { error_details: String }, + + #[error("Arkade operator error: {error_details}")] + ArkError { error_details: String }, + + #[error("Lightning address service error: {error_details}")] + LnurlError { error_details: String }, + + #[error("Lightning swap error: {error_details}")] + SwapError { error_details: String }, + + #[error("Network error: {error_details}")] + NetworkError { error_details: String }, + + #[error("Database error: {error_details}")] + DatabaseError { error_details: String }, + + #[error("Internal error: {error_details}")] + InternalError { error_details: String }, +} + +impl ArkadeError { + pub(crate) fn invalid(e: impl Display) -> Self { + Self::InvalidInput { + error_details: e.to_string(), + } + } + + pub(crate) fn internal(e: impl Display) -> Self { + Self::InternalError { + error_details: e.to_string(), + } + } + + pub(crate) fn ark(e: impl Display) -> Self { + Self::ArkError { + error_details: format!("{e:#}"), + } + } + + pub(crate) fn lnurl(e: impl Display) -> Self { + Self::LnurlError { + error_details: e.to_string(), + } + } + + pub(crate) fn network(e: impl Display) -> Self { + Self::NetworkError { + error_details: e.to_string(), + } + } + + pub(crate) fn storage(e: impl Display) -> Self { + Self::DatabaseError { + error_details: e.to_string(), + } + } +} + +impl From for ArkadeError { + fn from(e: ark_client::Error) -> Self { + Self::ark(e) + } +} + +impl From for ArkadeError { + fn from(e: super::intents::Error) -> Self { + Self::SwapError { + error_details: e.to_string(), + } + } +} + +impl From for ArkadeError { + fn from(e: rusqlite::Error) -> Self { + Self::storage(e) + } +} diff --git a/src/modules/arkade/esplora.rs b/src/modules/arkade/esplora.rs new file mode 100644 index 0000000..b527e99 --- /dev/null +++ b/src/modules/arkade/esplora.rs @@ -0,0 +1,334 @@ +//! The chain source ark-client reads boarding outputs and transaction status from, after `ark-client-sample`'s. + +use ark_client::{Blockchain, Error, SpendStatus, TxStatus}; +use ark_core::ExplorerUtxo; +use bitcoin::{Address, Amount, OutPoint, ScriptBuf, Transaction, Txid}; +use esplora_client::Tx as EsploraTx; +use std::collections::{HashMap, HashSet}; +use std::future::Future; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +/// Public esplora servers drop kept-alive connections now and then (`IncompleteMessage`); reads retry. A +/// server that times out, refuses the connection or rate-limits isn't retried: the read moves to the next +/// server instead. +async fn retry(f: F) -> Result +where + F: Fn() -> Fut, + Fut: Future>, +{ + let mut attempt = 0; + loop { + match f().await { + Err(esplora_client::Error::Reqwest(e)) if e.is_timeout() || e.is_connect() => { + return Err(esplora_client::Error::Reqwest(e)) + } + Err(e @ esplora_client::Error::Reqwest(_)) if attempt < 3 => { + attempt += 1; + log::debug!("esplora read failed, retrying: {e}"); + tokio::time::sleep(Duration::from_millis(250 * attempt)).await; + } + other => return other, + } + } +} + +/// Whether the server failed rather than the request: another server may answer it. +fn server_failed(e: &esplora_client::Error) -> bool { + matches!( + e, + esplora_client::Error::Reqwest(_) + | esplora_client::Error::HttpResponse { + status: 429 | 500..=599, + .. + } + ) +} + +/// A request that gets no answer fails after this long instead of hanging the wallet. +const TIMEOUT_SECS: u64 = 10; +/// How long a read address history stays good. Public explorers allow a few hundred reads an hour per IP +/// (blockstream.info: 700), and the history watch and chores read the same boarding addresses each pass. +const HISTORY_TTL: Duration = Duration::from_secs(60); +/// How long a read chain tip stays good: a scan reads it once per address otherwise. +const TIP_TTL: Duration = Duration::from_secs(60); + +pub struct Esplora { + /// The servers in order of preference, with their URLs for the log. + clients: Vec<(String, esplora_client::AsyncClient)>, + /// The server reads go to first: the last one that answered, so a server that is down costs one timeout, + /// not one per read. + current: AtomicUsize, + tip: std::sync::Mutex>, + /// Recent address histories: balance, history and the chores read the same few boarding addresses within + /// seconds of each other, and mempool.space throttles an address's bursts by refusing connections. + histories: std::sync::Mutex, Instant)>>, + /// Outputs found spent, and transactions found confirmed: final, so never read again. + spent: std::sync::Mutex>, + confirmed: std::sync::Mutex>, +} + +impl Esplora { + pub fn new(urls: &[String]) -> Result { + let clients = urls + .iter() + .map(|url| { + esplora_client::Builder::new(url) + .timeout(TIMEOUT_SECS) + .build_async() + .map(|client| (url.clone(), client)) + .map_err(super::errors::ArkadeError::internal) + }) + .collect::, _>>()?; + if clients.is_empty() { + return Err(super::errors::ArkadeError::internal("no esplora server")); + } + Ok(Self { + clients, + current: AtomicUsize::new(0), + tip: std::sync::Mutex::new(None), + histories: std::sync::Mutex::new(HashMap::new()), + spent: std::sync::Mutex::new(HashMap::new()), + confirmed: std::sync::Mutex::new(HashMap::new()), + }) + } + + /// Runs a read on the current server, then on each other server while they fail; the one that answers + /// becomes current. + async fn read<'a, T, F, Fut>(&'a self, f: F) -> Result + where + F: Fn(&'a esplora_client::AsyncClient) -> Fut, + Fut: Future>, + { + let start = self.current.load(Ordering::Relaxed); + let mut failed = None; + for step in 0..self.clients.len() { + let i = (start + step) % self.clients.len(); + let (url, client) = &self.clients[i]; + match retry(|| f(client)).await { + Err(e) if server_failed(&e) => { + log::warn!("esplora '{url}' failed: {e}"); + failed = Some(e); + } + other => { + if i != start && self.current.swap(i, Ordering::Relaxed) != i { + log::info!("esplora reads now go to '{url}'"); + } + return other; + } + } + } + Err(failed.expect("at least one server")) + } + + async fn tip(&self) -> Result { + if let Some((height, at)) = self.tip.lock().ok().and_then(|t| *t) { + if at.elapsed() < TIP_TTL { + return Ok(height); + } + } + let height = self.read(|c| c.get_height()).await?; + if let Ok(mut tip) = self.tip.lock() { + *tip = Some((height, Instant::now())); + } + Ok(height) + } +} + +impl Esplora { + async fn history(&self, script: &ScriptBuf) -> Result, esplora_client::Error> { + if let Some((txs, at)) = self + .histories + .lock() + .ok() + .and_then(|h| h.get(script).cloned()) + { + if at.elapsed() < HISTORY_TTL { + return Ok(txs); + } + } + let txs = self.read(|c| c.scripthash_txs(script, None)).await?; + if let Ok(mut histories) = self.histories.lock() { + histories.insert(script.clone(), (txs.clone(), Instant::now())); + } + Ok(txs) + } +} + +impl Blockchain for Esplora { + async fn find_outpoints(&self, address: &Address) -> Result, Error> { + let tip = self.tip().await.map_err(Error::consumer)?; + let script_pubkey = address.script_pubkey(); + let txs = self + .history(&script_pubkey) + .await + .map_err(Error::consumer)?; + + let spent: HashSet = txs + .iter() + .flat_map(|tx| { + tx.vin + .iter() + .filter(|input| { + input + .prevout + .as_ref() + .is_some_and(|p| p.scriptpubkey == script_pubkey) + }) + .map(|input| OutPoint { + txid: input.txid, + vout: input.vout, + }) + }) + .collect(); + + Ok(txs + .into_iter() + .flat_map(|tx| { + let txid = tx.txid; + let confirmations = tx + .status + .block_height + .and_then(|h| tip.checked_sub(h)) + .map_or(0, |d| d + 1); + let blocktime = tx.status.block_time; + tx.vout + .iter() + .enumerate() + .filter(|(_, v)| v.scriptpubkey == script_pubkey) + .map(|(i, v)| { + let outpoint = OutPoint { + txid, + vout: i as u32, + }; + ExplorerUtxo { + outpoint, + amount: Amount::from_sat(v.value), + confirmation_blocktime: blocktime, + confirmations: confirmations as u64, + is_spent: spent.contains(&outpoint), + } + }) + .collect::>() + }) + .collect()) + } + + async fn find_tx(&self, txid: &Txid) -> Result, Error> { + self.read(|c| c.get_tx(txid)).await.map_err(Error::consumer) + } + + async fn get_tx_status(&self, txid: &Txid) -> Result { + if let Some(at) = self + .confirmed + .lock() + .ok() + .and_then(|c| c.get(txid).copied()) + { + return Ok(TxStatus { + confirmed_at: Some(at), + }); + } + let info = self + .read(|c| c.get_tx_info(txid)) + .await + .map_err(Error::consumer)?; + let confirmed_at = info.and_then(|s| s.status.block_time.map(|t| t as i64)); + if let (Some(at), Ok(mut confirmed)) = (confirmed_at, self.confirmed.lock()) { + confirmed.insert(*txid, at); + } + Ok(TxStatus { confirmed_at }) + } + + async fn get_output_status(&self, txid: &Txid, vout: u32) -> Result { + let outpoint = OutPoint { txid: *txid, vout }; + if let Some(spend) = self + .spent + .lock() + .ok() + .and_then(|s| s.get(&outpoint).copied()) + { + return Ok(SpendStatus { + spend_txid: Some(spend), + }); + } + let status = self + .read(|c| c.get_output_status(txid, vout as u64)) + .await + .map_err(Error::consumer)?; + let spend_txid = status.and_then(|s| s.txid); + if let (Some(spend), Ok(mut spent)) = (spend_txid, self.spent.lock()) { + spent.insert(outpoint, spend); + } + Ok(SpendStatus { spend_txid }) + } + + async fn broadcast(&self, tx: &Transaction) -> Result<(), Error> { + self.read(|c| c.broadcast(tx)) + .await + .map_err(Error::consumer) + } + + async fn get_fee_rate(&self) -> Result { + let estimates = self + .read(|c| c.get_fee_estimates()) + .await + .map_err(Error::consumer)?; + Ok(estimates.get(&6).copied().unwrap_or(1.0)) + } + + async fn broadcast_package(&self, txs: &[&Transaction]) -> Result<(), Error> { + for tx in txs { + self.broadcast(tx).await?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + /// An esplora that answers every request with `height`. + async fn serve_height(height: u32) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 1024]; + let _ = socket.read(&mut request).await; + let body = height.to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + url + } + + /// A port nothing listens on: connections are refused. + async fn dead_url() -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + format!("http://{}", listener.local_addr().unwrap()) + } + + #[tokio::test] + async fn reads_move_to_the_next_server_when_one_is_down() { + let esplora = Esplora::new(&[dead_url().await, serve_height(974_269).await]).unwrap(); + + assert_eq!(esplora.tip().await.unwrap(), 974_269); + assert_eq!(esplora.current.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn a_read_fails_when_every_server_is_down() { + let esplora = Esplora::new(&[dead_url().await, dead_url().await]).unwrap(); + + assert!(esplora.tip().await.is_err()); + assert_eq!(esplora.current.load(Ordering::Relaxed), 0); + } +} diff --git a/src/modules/arkade/fixtures/oracle.json b/src/modules/arkade/fixtures/oracle.json new file mode 100644 index 0000000..6fc47db --- /dev/null +++ b/src/modules/arkade/fixtures/oracle.json @@ -0,0 +1,41 @@ +{ + "mnemonic": "lottery chimney height exit laptop spend legend parent radio sign discover legal", + "network": "bitcoin", + "descriptor": "tr([67a24100/86'/0'/1']xpub6CvSLhajsoYFRHhiG8b2ipgusF37CjhNvUUgrqNV2vQ7BPYxGgpbrDDY1ZpQvGK49fW4ZNxZRvHzkrzWH6De5afguiJPYnRGD9MY4vtZ8zn/0/*)", + "xOnlyPublicKey": "5d3a1b1b05a3b9fcd56306bc0f690fb2d2c2330400f0ec25d6ac5c6d294548da", + "claimPublicKey": "035d3a1b1b05a3b9fcd56306bc0f690fb2d2c2330400f0ec25d6ac5c6d294548da", + "lnurlDomain": "lnurl.arkade.sh", + "token": "0c1d42570d56713327b318f8bde11df0f5e6e800d8422132caedb57110071046", + "lnurlOfHandle": "LNURL1DP68GURN8GHJ7MRWW4EXCTNPWF4KZER99EEKSTEWWAJKCMPDDDHX7AMW9AKXUATJD3CZ7AMFWDJJ6MREDEUQ7FP289", + "server": { + "signerPubkey": "038202bebddeb1f7442803897a85eaf3ce9254d07df0172fc3725ab5f0d097779c", + "network": "bitcoin", + "unilateralExitDelay": 605184, + "boardingExitDelay": 7776256, + "dust": 330 + }, + "delegatePubkey": "026d7d45360014bce9a8ad30a10c28dd1571a22a2e90c9682268404d37b5b114a6", + "info": { + "arkAddress": "ark1qzpq904am6clw3pgqwyh4p02708fy4xs0hcpwt7rwfdttuxsjamecfjcrgw33etd4mp508vypy9zg9ael8z92vqn0sx6d6vvuy679hts0h0e5c", + "boardingAddress": "bc1pgjnfz74jd8rs2hw0wnnq83hn960lya503q0ykzuy8tmvnz7f7ucsd7y9dg", + "network": "bitcoin", + "dust": 330, + "unilateralExitDelay": 605184, + "boardingExitDelay": 7776256, + "fees": { + "intentFee": { + "offchainInput": "inputType == 'vtxo' && now() - birth < 86400.0 ? amount * 0.002 : 0.0", + "offchainOutput": "", + "onchainInput": "", + "onchainOutput": "200.0" + }, + "txFeeRate": "0" + }, + "delegate": true + }, + "lightningAddress": { + "handle": "wise-lynx", + "lightningAddress": "wise-lynx@lnurl.arkade.sh", + "lnurl": "LNURL1DP68GURN8GHJ7MRWW4EXCTNPWF4KZER99EEKSTEWWAJKCMPDDDHX7AMW9AKXUATJD3CZ7AMFWDJJ6MREDEUQ7FP289" + } +} diff --git a/src/modules/arkade/intents/bolt11.rs b/src/modules/arkade/intents/bolt11.rs new file mode 100644 index 0000000..017390d --- /dev/null +++ b/src/modules/arkade/intents/bolt11.rs @@ -0,0 +1,44 @@ +//! The BOLT11 facts the corridor needs, read like `decodeBolt11` in `client/corridors/bolt11.ts`: +//! signature-blind, amount floored to whole sats, amountless as 0. + +use super::error::Error; +use super::error::Result; +use lightning_invoice::Bolt11Invoice; +use std::str::FromStr; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InvoiceFacts { + pub raw: String, + /// `sha256(P)`, lowercase hex. + pub payment_hash: String, + /// 0 for an amountless invoice. + pub amount_sats: u64, + /// Absolute expiry, unix seconds. + pub expires_at: u64, +} + +pub fn decode(invoice: &str) -> Result { + let parsed = Bolt11Invoice::from_str(invoice.trim()) + .map_err(|e| Error::Invoice(format!("undecodable BOLT11: {e}")))?; + let timestamp = parsed.duration_since_epoch().as_secs(); + Ok(InvoiceFacts { + raw: invoice.trim().to_string(), + payment_hash: hex::encode(parsed.payment_hash().as_ref() as &[u8]), + amount_sats: parsed.amount_milli_satoshis().unwrap_or(0) / 1000, + expires_at: timestamp + parsed.expiry_time().as_secs(), + }) +} + +#[cfg(test)] +mod tests { + #[test] + fn matches_the_ts_decoder() { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("fixtures/oracle.json")).unwrap(); + let ts = &fixture["receive"]["invoice"]; + let facts = super::decode(ts["raw"].as_str().unwrap()).unwrap(); + assert_eq!(facts.payment_hash, ts["paymentHash"].as_str().unwrap()); + assert_eq!(facts.amount_sats, ts["amountSats"].as_u64().unwrap()); + assert_eq!(facts.expires_at, ts["expiresAt"].as_u64().unwrap()); + } +} diff --git a/src/modules/arkade/intents/client.rs b/src/modules/arkade/intents/client.rs new file mode 100644 index 0000000..0d58531 --- /dev/null +++ b/src/modules/arkade/intents/client.rs @@ -0,0 +1,589 @@ +//! `IntentsClient`: receive and pay over Lightning through an Arkade Intents solver, persist every +//! swap before it is handed out or funded, and drive claims and refunds in the background. + +use super::bolt11; +use super::contract::derive_receive; +use super::contract::derive_send; +use super::contract::hex_32; +use super::contract::verify_receive; +use super::contract::verify_send; +use super::contract::Terms; +use super::contract::REFUND_MTP_LAG_SECONDS; +use super::error::Error; +use super::error::Result; +use super::preimage::payment_hash; +use super::preimage::salted_preimage; +use super::spend::live; +use super::spend::preimage_in; +use super::spend::spend_lockup; +use super::spend::Leaf; +use super::spend::MAX_LOCKUP_SPEND_INPUTS; +use super::store::Direction; +use super::store::Outcome; +use super::store::SwapRecord; +use super::store::SwapStore; +use super::transport::NostrTransport; +use super::transport::SolverCard; +use super::wallet::ArkWallet; +use super::wire; +use ark_core::server::Info; +use ark_core::server::VirtualTxOutPoint; +use ark_core::ArkAddress; +use bitcoin::Amount; +use std::sync::Arc; +use std::sync::RwLock; +use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +pub type UpdateCallback = Arc; + +/// What paying an invoice would cost. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PayQuote { + /// The lockup amount minus the invoice amount. + pub fee_sat: u64, + /// What the wallet funds. + pub give_sat: u64, + /// The invoice amount. + pub take_sat: u64, + pub expires_at: u64, +} + +pub struct IntentsClient { + wallet: Arc, + store: Arc, + transport: NostrTransport, + listeners: RwLock>, + driving: tokio::sync::Mutex<()>, +} + +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or_default() +} + +enum Spend { + Claim([u8; 32]), + Refund, + Unexplained, +} + +struct SendPrep { + quote: wire::RfqQuote, + invoice: bolt11::InvoiceFacts, + lockup: super::contract::SendLockup, +} + +impl IntentsClient { + pub fn new(wallet: Arc, store: Arc, solver: SolverCard) -> Self { + Self { + wallet, + store, + transport: NostrTransport::new(solver), + listeners: RwLock::new(Vec::new()), + driving: tokio::sync::Mutex::new(()), + } + } + + /// Called with the record each time a swap's outcome (or blocked reason) changes. + pub fn on_update(&self, callback: UpdateCallback) { + self.listeners.write().expect("listeners").push(callback); + } + + pub fn swaps(&self) -> Result> { + self.store.all() + } + + fn emit(&self, record: &SwapRecord) { + for listener in self.listeners.read().expect("listeners").iter() { + listener(record); + } + } + + fn save( + &self, + record: &mut SwapRecord, + outcome: Outcome, + blocked: Option, + ) -> Result<()> { + if record.outcome == outcome && record.blocked_reason == blocked { + return Ok(()); + } + record.outcome = outcome; + record.blocked_reason = blocked; + record.updated_at = now(); + self.store.put(record)?; + self.emit(record); + Ok(()) + } + + async fn terms(&self) -> Result<(Info, Terms)> { + let info = self.wallet.server_info().await?; + let terms = Terms::from_server_info(&info)?; + Ok((info, terms)) + } + + /// An invoice for exactly `amount_sat`: the payer pays it, the wallet gets it less the + /// solver's fee. The record is persisted before the invoice is returned. + pub async fn receive(&self, amount_sat: u64) -> Result { + let (_, terms) = self.terms().await?; + let keypair = self.wallet.identity_keypair()?; + let payout_pubkey = keypair.x_only_public_key().0; + let payout = self.wallet.address().await?; + + // `quote_conflict` means the solver already holds this hash; one retry with a fresh salt. + let mut attempt = 0; + let (salt, hash, rfq_id, quote) = loop { + let salt: [u8; 32] = rand::random(); + let hash = hex::encode(payment_hash(&salted_preimage(&keypair, &salt))); + let rfq_id = wire::new_rfq_id(); + let request = wire::receive_request( + &rfq_id, + amount_sat, + wire::ReceiveProfile { + payment_hash: hash.clone(), + payout_address: payout.encode(), + payout_pubkey: payout_pubkey.to_string(), + }, + ); + match self.transport.request_quote(&request).await { + Ok(quote) => break (salt, hash, rfq_id, quote), + Err(Error::Refused { ref reason, .. }) + if reason == "quote_conflict" && attempt == 0 => + { + attempt += 1; + } + Err(e) => return Err(e), + } + }; + + let lockup = derive_receive("e, &hash, payout_pubkey, &payout, &terms)?; + let invoice = bolt11::decode(&lockup.invoice)?; + let time = now(); + let pay_deadline = verify_receive("e, amount_sat, &hash, &invoice, time)?; + let (give, take) = (quote.give()?, quote.take()?); + let record = SwapRecord { + id: rfq_id.clone(), + direction: Direction::Receive, + // As TS reports a just-accepted receive; the first drive pass makes it `open`. + outcome: Outcome::Accepted, + give_sat: give, + take_sat: take, + fee_sat: give - take, + bolt11: invoice.raw.clone(), + payment_hash: hash, + created_at: time, + updated_at: time, + blocked_reason: None, + expires_at: pay_deadline, + solver: self.transport.card.name.clone(), + rfq_id, + quote: serde_json::to_value("e).map_err(|e| Error::Other(e.to_string()))?, + lockup: lockup.params, + lockup_address: lockup.address.encode(), + preimage_salt: Some(hex::encode(salt)), + preimage: None, + payout_address: payout.encode(), + funding_txid: None, + claim_txid: None, + refund_txid: None, + }; + self.store.put(&record)?; + self.emit(&record); + Ok(record) + } + + async fn prepare_send(&self, bolt11: &str) -> Result { + let invoice = bolt11::decode(bolt11)?; + if invoice.amount_sats == 0 { + return Err(Error::Invoice( + "amountless invoices cannot be paid by swap".into(), + )); + } + let time = now(); + if time >= invoice.expires_at { + return Err(Error::Invoice("invoice expired".into())); + } + let (_, terms) = self.terms().await?; + let keypair = self.wallet.identity_keypair()?; + let refund = self.wallet.address().await?; + let rfq_id = wire::new_rfq_id(); + let request = wire::send_request( + &rfq_id, + wire::SendProfile { + invoice: invoice.raw.clone(), + refund_address: refund.encode(), + client_refund_pubkey: keypair.x_only_public_key().0.to_string(), + }, + ); + let quote = self.transport.request_quote(&request).await?; + let time = now(); + verify_send("e, &invoice, time)?; + let lockup = derive_send( + "e, + &invoice.payment_hash, + keypair.x_only_public_key().0, + &refund, + &terms, + time, + )?; + Ok(SendPrep { + quote, + invoice, + lockup, + }) + } + + /// What paying `bolt11` would cost. Discloses the invoice to the solver; funds nothing. + pub async fn quote_pay(&self, bolt11: &str) -> Result { + let prep = self.prepare_send(bolt11).await?; + let (give, take) = (prep.quote.give()?, prep.quote.take()?); + Ok(PayQuote { + fee_sat: give - take, + give_sat: give, + take_sat: take, + expires_at: prep.quote.valid_until, + }) + } + + /// Pay `bolt11` by funding a lockup the solver claims once it has paid the invoice. Refuses a + /// fee above `max_fee_sat`. The record is persisted before funding. + pub async fn pay(&self, bolt11: &str, max_fee_sat: Option) -> Result { + let prep = self.prepare_send(bolt11).await?; + let (give, take) = (prep.quote.give()?, prep.quote.take()?); + let fee = give - take; + if let Some(max) = max_fee_sat { + if fee > max { + return Err(Error::FeeTooHigh { fee, max }); + } + } + let time = now(); + let mut record = SwapRecord { + id: prep.quote.rfq_id.clone(), + direction: Direction::Send, + outcome: Outcome::Accepted, + give_sat: give, + take_sat: take, + fee_sat: fee, + bolt11: prep.invoice.raw.clone(), + payment_hash: prep.invoice.payment_hash.clone(), + created_at: time, + updated_at: time, + blocked_reason: None, + expires_at: prep.quote.valid_until, + solver: self.transport.card.name.clone(), + rfq_id: prep.quote.rfq_id.clone(), + quote: serde_json::to_value(&prep.quote).map_err(|e| Error::Other(e.to_string()))?, + lockup: prep.lockup.params.clone(), + lockup_address: prep.lockup.address.encode(), + preimage_salt: None, + preimage: None, + payout_address: String::new(), + funding_txid: None, + claim_txid: None, + refund_txid: None, + }; + self.store.put(&record)?; + self.emit(&record); + if now() >= prep.quote.valid_until { + self.save( + &mut record, + Outcome::Failed, + Some("quote expired before funding".into()), + )?; + return Err(Error::QuoteExpired); + } + let txid = self + .wallet + .send(&prep.lockup.address, Amount::from_sat(give)) + .await + .map_err(Error::from)?; + record.funding_txid = Some(txid.to_string()); + self.save(&mut record, Outcome::Funded, None)?; + Ok(record) + } + + /// Run [`Self::drive_once`] every `every` until the handle is aborted. + pub fn drive(self: &Arc, every: Duration) -> tokio::task::JoinHandle<()> { + let client = Arc::clone(self); + tokio::spawn(async move { + loop { + if let Err(e) = client.drive_once().await { + log::warn!("arkade intents drive pass failed: {e}"); + } + tokio::time::sleep(every).await; + } + }) + } + + /// One pass over every open swap: claim funded receives, refund expired sends, and record what + /// the lockups show. + pub async fn drive_once(&self) -> Result<()> { + let _guard = self.driving.lock().await; + let open: Vec<_> = self + .store + .all()? + .into_iter() + .filter(|r| !r.outcome.is_terminal()) + .collect(); + if open.is_empty() { + return Ok(()); + } + let info = self.wallet.server_info().await?; + for mut record in open { + let result = match record.direction { + Direction::Receive => self.drive_receive(&info, &mut record).await, + Direction::Send => self.drive_send(&info, &mut record).await, + }; + if let Err(e) = result { + log::warn!("arkade intents swap {}: {e}", record.id); + } + } + Ok(()) + } + + async fn lockup_vtxos( + &self, + record: &SwapRecord, + ) -> Result<(ArkAddress, Vec)> { + let address = ArkAddress::decode(&record.lockup_address) + .map_err(|e| Error::Store(format!("lockup address: {e}")))?; + let vtxos = self.wallet.vtxos(&address).await?; + Ok((address, vtxos)) + } + + /// Read the transactions that spent a lockup: the preimage if any carries it, else whether + /// one pays the lockup's `sender` destination (a refund). `None` when nothing could be read. + async fn spend_evidence( + &self, + spent: &[VirtualTxOutPoint], + hash: &[u8; 32], + refund_script: &bitcoin::ScriptBuf, + ) -> Result> { + let mut seen_any = false; + let mut refunded = false; + for vtxo in spent { + for txid in [vtxo.spent_by, vtxo.ark_txid].into_iter().flatten() { + if let Some(tx) = self.wallet.virtual_tx(txid).await? { + seen_any = true; + if let Some(p) = preimage_in(&tx, hash) { + return Ok(Some(Spend::Claim(p))); + } + refunded |= tx + .unsigned_tx + .output + .iter() + .any(|o| o.script_pubkey == *refund_script); + } + } + } + Ok(seen_any.then_some(if refunded { + Spend::Refund + } else { + Spend::Unexplained + })) + } + + async fn drive_receive(&self, info: &Info, record: &mut SwapRecord) -> Result<()> { + let (_, vtxos) = self.lockup_vtxos(record).await?; + let time = now(); + let refund_locktime = u64::from(record.lockup.refund_locktime); + let live = live(&vtxos, info.dust); + let hash = hex_32(&record.payment_hash, "payment_hash")?; + + if live.is_empty() && !vtxos.is_empty() { + if vtxos + .iter() + .any(|v| v.is_unrolled || (!v.is_spent && v.is_swept)) + { + return self.save(record, Outcome::NeedsRecovery, Some( + "the lockup left the offchain path (swept or unrolled); only an onchain spend can move it".into(), + )); + } + // Fully spent: ours (or any claim revealing our P) is a claim; anything else is the + // solver taking it back. + let ours = record.claim_txid.as_deref().is_some_and(|c| { + vtxos + .iter() + .any(|v| v.ark_txid.map(|t| t.to_string()).as_deref() == Some(c)) + }); + if ours { + return self.save(record, Outcome::Claimed, None); + } + let solver_refund = record.lockup.sender_pk_script.clone(); + return match self.spend_evidence(&vtxos, &hash, &solver_refund).await? { + Some(Spend::Claim(_)) => self.save(record, Outcome::Claimed, None), + Some(Spend::Refund) => self.save(record, Outcome::Lapsed, None), + Some(Spend::Unexplained) => self.save( + record, + Outcome::NeedsRecovery, + Some( + "the lockup was spent without our preimage or a refund to the solver" + .into(), + ), + ), + None => Ok(()), + }; + } + + if time >= refund_locktime { + // The window closed. Publishing P into the solver's live refund window risks a race. + if record.claim_txid.is_some() { + return Ok(()); + } + if time < refund_locktime + REFUND_MTP_LAG_SECONDS { + return self.save(record, Outcome::NeedsRecovery, Some( + "the claim window closed with the lockup unclaimed; only the solver can act now".into(), + )); + } + return self.save(record, Outcome::Lapsed, None); + } + + if live.is_empty() { + return self.save(record, Outcome::Open, None); + } + let funded: u64 = live.iter().map(|v| v.amount.to_sat()).sum(); + // The anti-dust gate: claiming publishes P, which settles the payer's HTLC in full. + if record.claim_txid.is_none() && funded < record.take_sat { + return self.save( + record, + Outcome::NeedsRecovery, + Some(format!( + "the lockup holds {funded} sat, below the agreed {}; not revealing the preimage", + record.take_sat + )), + ); + } + let salt = hex_32( + record + .preimage_salt + .as_deref() + .ok_or_else(|| Error::Store("receive record has no salt".into()))?, + "preimage_salt", + )?; + let keypair = self.wallet.identity_keypair()?; + let preimage = salted_preimage(&keypair, &salt); + if payment_hash(&preimage) != hash { + return self.save( + record, + Outcome::NeedsRecovery, + Some("this wallet's key does not re-derive the swap's preimage".into()), + ); + } + let mut batch = live.clone(); + batch.sort_by_key(|v| std::cmp::Reverse(v.amount)); + batch.truncate(MAX_LOCKUP_SPEND_INPUTS); + let lockup = record.lockup.build()?; + let payout = ArkAddress::decode(&record.payout_address) + .map_err(|e| Error::Store(format!("payout address: {e}")))?; + let txid = spend_lockup( + self.wallet.as_ref(), + info, + &lockup, + &batch, + &keypair, + Leaf::Claim { preimage }, + &payout, + ) + .await?; + record.claim_txid = Some(txid.to_string()); + let outcome = if batch.len() == live.len() { + Outcome::Claimed + } else { + Outcome::Funded + }; + record.updated_at = now(); + self.store.put(record)?; + self.save(record, outcome, None) + } + + async fn drive_send(&self, info: &Info, record: &mut SwapRecord) -> Result<()> { + let (_, vtxos) = self.lockup_vtxos(record).await?; + let time = now(); + let refund_locktime = u64::from(record.lockup.refund_locktime); + + if record.outcome == Outcome::Accepted { + if vtxos.is_empty() { + if time > record.expires_at + 600 { + return self.save( + record, + Outcome::Failed, + Some("the lockup was never funded before the quote expired".into()), + ); + } + return Ok(()); + } + self.save(record, Outcome::Funded, None)?; + } + + let live = live(&vtxos, info.dust); + if live.is_empty() && !vtxos.is_empty() { + if vtxos + .iter() + .any(|v| v.is_unrolled || (!v.is_spent && v.is_swept)) + { + return self.save(record, Outcome::NeedsRecovery, Some( + "the lockup left the offchain path (swept or unrolled); only an onchain spend can move it".into(), + )); + } + let ours = record.refund_txid.as_deref().is_some_and(|r| { + vtxos + .iter() + .any(|v| v.ark_txid.map(|t| t.to_string()).as_deref() == Some(r)) + }); + if ours { + return self.save(record, Outcome::Refunded, None); + } + let hash = hex_32(&record.payment_hash, "payment_hash")?; + let our_refund = record.lockup.sender_pk_script.clone(); + return match self.spend_evidence(&vtxos, &hash, &our_refund).await? { + Some(Spend::Claim(p)) => { + record.preimage = Some(hex::encode(p)); + self.save(record, Outcome::Paid, None) + } + // The solver's refund paths, which all pay our refund address. + Some(Spend::Refund) => self.save(record, Outcome::Refunded, None), + // Never guess `refunded`: a paid invoice read as refunded invites paying twice. + Some(Spend::Unexplained) => self.save( + record, + Outcome::NeedsRecovery, + Some( + "the lockup was spent without a readable preimage or a refund to us".into(), + ), + ), + None => Ok(()), + }; + } + + if !live.is_empty() && time >= refund_locktime { + let keypair = self.wallet.identity_keypair()?; + let lockup = record.lockup.build()?; + let refund = self.wallet.address().await?; + let mut batch = live.clone(); + batch.truncate(MAX_LOCKUP_SPEND_INPUTS); + let txid = spend_lockup( + self.wallet.as_ref(), + info, + &lockup, + &batch, + &keypair, + Leaf::RefundWithoutReceiver, + &refund, + ) + .await?; + record.refund_txid = Some(txid.to_string()); + let outcome = if batch.len() == live.len() { + Outcome::Refunded + } else { + Outcome::Funded + }; + record.updated_at = now(); + self.store.put(record)?; + return self.save(record, outcome, None); + } + Ok(()) + } +} diff --git a/src/modules/arkade/intents/client_tests.rs b/src/modules/arkade/intents/client_tests.rs new file mode 100644 index 0000000..972585f --- /dev/null +++ b/src/modules/arkade/intents/client_tests.rs @@ -0,0 +1,574 @@ +//! Drive and store tests against a mock wallet, using the oracle fixture's real quotes so the +//! lockups are the ones the solver quoted. No network. + +use super::client::IntentsClient; +use super::contract::*; +use super::error::WalletError; +use super::preimage::*; +use super::spend::preimage_in; +use super::store::*; +use super::transport::SolverCard; +use super::wallet::ArkWallet; +use super::wire::RfqQuote; +use ark_core::server::Info; +use ark_core::server::VirtualTxOutPoint; +use ark_core::ArkAddress; +use async_trait::async_trait; +use bitcoin::bip32::DerivationPath; +use bitcoin::bip32::Xpriv; +use bitcoin::key::Secp256k1; +use bitcoin::secp256k1::Keypair; +use bitcoin::Amount; +use bitcoin::Network; +use bitcoin::OutPoint; +use bitcoin::Psbt; +use bitcoin::ScriptBuf; +use bitcoin::Txid; +use serde_json::Value; +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; +use std::sync::Mutex; + +fn fixture() -> Value { + serde_json::from_str(include_str!("fixtures/oracle.json")).unwrap() +} + +#[derive(Default)] +struct Mock { + keypair: Option, + address: Option, + vtxos: Mutex>, + virtual_txs: Mutex>, + submitted: Mutex>, + finalized: Mutex>, + sent: Mutex>, +} + +fn info() -> Info { + let f = fixture(); + let signer = + bitcoin::secp256k1::PublicKey::from_str(f["server"]["signerPubkey"].as_str().unwrap()) + .unwrap(); + let checkpoint_tapscript = ScriptBuf::builder() + .push_int(i64::from( + bitcoin::Sequence::from_512_second_intervals(1).to_consensus_u32(), + )) + .push_opcode(bitcoin::opcodes::all::OP_CSV) + .push_opcode(bitcoin::opcodes::all::OP_DROP) + .push_x_only_key(&signer.x_only_public_key().0) + .push_opcode(bitcoin::opcodes::all::OP_CHECKSIG) + .into_script(); + Info { + version: "test".into(), + signer_pk: signer, + forfeit_pk: signer, + forfeit_address: bitcoin::Address::from_str("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq") + .unwrap() + .assume_checked(), + checkpoint_tapscript, + network: Network::Bitcoin, + session_duration: 60, + unilateral_exit_delay: ark_core::server::parse_sequence_number(605184).unwrap(), + boarding_exit_delay: ark_core::server::parse_sequence_number(605184).unwrap(), + utxo_min_amount: None, + utxo_max_amount: None, + vtxo_min_amount: None, + vtxo_max_amount: None, + dust: Amount::from_sat(330), + fees: None, + scheduled_session: None, + deprecated_signers: vec![], + service_status: Default::default(), + digest: String::new(), + max_tx_weight: 0, + max_op_return_outputs: 0, + } +} + +#[async_trait] +impl ArkWallet for Mock { + async fn server_info(&self) -> Result { + Ok(info()) + } + async fn address(&self) -> Result { + Ok(self.address.unwrap()) + } + fn identity_keypair(&self) -> Result { + Ok(self.keypair.unwrap()) + } + async fn vtxos(&self, _: &ArkAddress) -> Result, WalletError> { + Ok(self.vtxos.lock().unwrap().clone()) + } + async fn virtual_tx(&self, txid: Txid) -> Result, WalletError> { + Ok(self.virtual_txs.lock().unwrap().get(&txid).cloned()) + } + async fn submit_offchain( + &self, + ark_tx: Psbt, + checkpoints: Vec, + ) -> Result, WalletError> { + self.submitted.lock().unwrap().push(ark_tx); + Ok(checkpoints) + } + async fn finalize_offchain(&self, txid: Txid, _: Vec) -> Result<(), WalletError> { + self.finalized.lock().unwrap().push(txid); + Ok(()) + } + async fn send(&self, address: &ArkAddress, amount: Amount) -> Result { + self.sent.lock().unwrap().push((*address, amount)); + Ok(Txid::from_str(&"11".repeat(32)).unwrap()) + } +} + +fn identity(mnemonic: &str) -> Keypair { + let seed = bip39::Mnemonic::parse(mnemonic).unwrap().to_seed(""); + let secp = Secp256k1::new(); + Xpriv::new_master(Network::Bitcoin, &seed) + .unwrap() + .derive_priv(&secp, &DerivationPath::from_str("m/86'/0'/1'/0/0").unwrap()) + .unwrap() + .to_keypair(&secp) +} + +fn vtxo(script: ScriptBuf, sats: u64, vout: u32) -> VirtualTxOutPoint { + VirtualTxOutPoint { + outpoint: OutPoint { + txid: Txid::from_str(&"22".repeat(32)).unwrap(), + vout, + }, + created_at: 0, + expires_at: i64::MAX, + amount: Amount::from_sat(sats), + script, + is_preconfirmed: true, + is_swept: false, + is_unrolled: false, + is_spent: false, + spent_by: None, + commitment_txids: vec![], + settled_by: None, + ark_txid: None, + assets: vec![], + depth: 1, + } +} + +/// A receive record and a client over a mock wallet, as `receive()` would have persisted it. +fn receive_setup( + refund_locktime_shift: i64, +) -> ( + Arc, + Arc, + IntentsClient, + SwapRecord, +) { + let f = fixture(); + let r = &f["receive"]; + let keypair = identity(f["mnemonic"].as_str().unwrap()); + let payout = ArkAddress::decode(r["payoutAddress"].as_str().unwrap()).unwrap(); + let mut quote: RfqQuote = serde_json::from_value(r["quote"].clone()).unwrap(); + let terms = Terms::from_server_info(&info()).unwrap(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut lockup = derive_receive( + "e, + r["paymentHash"].as_str().unwrap(), + keypair.x_only_public_key().0, + &payout, + &terms, + ) + .unwrap(); + // Move the clock: rebuild the lockup around a locktime relative to now (the address changes, + // which is fine for the drive, which reads the persisted params). + lockup.params.refund_locktime = (now as i64 + refund_locktime_shift) as u32; + quote.refund_locktime = Some(u64::from(lockup.params.refund_locktime)); + let address = lockup.params.build().unwrap().address(Network::Bitcoin); + let record = SwapRecord { + id: quote.rfq_id.clone(), + direction: Direction::Receive, + outcome: Outcome::Open, + give_sat: 1000, + take_sat: 995, + fee_sat: 5, + bolt11: lockup.invoice.clone(), + payment_hash: r["paymentHash"].as_str().unwrap().into(), + created_at: now, + updated_at: now, + blocked_reason: None, + expires_at: now + 600, + solver: "beta-solver".into(), + rfq_id: quote.rfq_id.clone(), + quote: serde_json::to_value("e).unwrap(), + lockup: lockup.params.clone(), + lockup_address: address.encode(), + preimage_salt: Some(r["preimageSalt"].as_str().unwrap().into()), + preimage: None, + payout_address: payout.encode(), + funding_txid: None, + claim_txid: None, + refund_txid: None, + }; + let wallet = Arc::new(Mock { + keypair: Some(keypair), + address: Some(payout), + ..Default::default() + }); + let store = Arc::new(JsonStore::memory()); + store.put(&record).unwrap(); + let client = IntentsClient::new(wallet.clone(), store.clone(), SolverCard::beta_solver()); + (wallet, store, client, record) +} + +#[tokio::test] +async fn receive_waits_then_claims_with_the_preimage() { + let (wallet, store, client, record) = receive_setup(3600); + let updates = Arc::new(Mutex::new(Vec::new())); + let seen = updates.clone(); + client.on_update(Arc::new(move |r: &SwapRecord| { + seen.lock().unwrap().push(r.outcome) + })); + + client.drive_once().await.unwrap(); + assert_eq!( + store.get(&record.id).unwrap().unwrap().outcome, + Outcome::Open + ); + + // Underfunded: the preimage must not go out. + let script = record.lockup.build().unwrap().script_pubkey(); + *wallet.vtxos.lock().unwrap() = vec![vtxo(script.clone(), 994, 0)]; + client.drive_once().await.unwrap(); + let held = store.get(&record.id).unwrap().unwrap(); + assert_eq!(held.outcome, Outcome::NeedsRecovery); + assert!(held + .blocked_reason + .unwrap() + .contains("below the agreed 995")); + assert!(wallet.submitted.lock().unwrap().is_empty()); + + // Funded in full (split over two outputs): claimed through the claim leaf. + *wallet.vtxos.lock().unwrap() = vec![vtxo(script.clone(), 500, 0), vtxo(script, 495, 1)]; + client.drive_once().await.unwrap(); + let claimed = store.get(&record.id).unwrap().unwrap(); + assert_eq!(claimed.outcome, Outcome::Claimed); + let submitted = wallet.submitted.lock().unwrap(); + assert_eq!(submitted.len(), 1); + let ark_tx = &submitted[0]; + assert_eq!(ark_tx.inputs.len(), 2); + let hash = hex_32(&record.payment_hash, "hash").unwrap(); + assert!(preimage_in(ark_tx, &hash).is_some(), "the claim carries P"); + let me = wallet.keypair.unwrap().x_only_public_key().0; + let claim_leaf = record.lockup.build().unwrap().claim_script().clone(); + for input in &ark_tx.inputs { + assert!(input.tap_script_sigs.keys().any(|(pk, _)| *pk == me)); + assert!(input.tap_scripts.values().any(|(s, _)| *s == claim_leaf)); + } + let payout = ArkAddress::decode(&record.payout_address).unwrap(); + assert_eq!( + ark_tx.unsigned_tx.output[0].script_pubkey, + payout.to_p2tr_script_pubkey() + ); + assert_eq!(ark_tx.unsigned_tx.output[0].value, Amount::from_sat(995)); + assert_eq!( + claimed.claim_txid, + Some(ark_tx.unsigned_tx.compute_txid().to_string()) + ); + assert_eq!( + *wallet.finalized.lock().unwrap(), + vec![ark_tx.unsigned_tx.compute_txid()] + ); + assert_eq!( + *updates.lock().unwrap(), + vec![Outcome::NeedsRecovery, Outcome::Claimed] + ); +} + +#[tokio::test] +async fn unpaid_receive_lapses_after_the_window() { + let (_, store, client, record) = receive_setup(-60); + client.drive_once().await.unwrap(); + assert_eq!( + store.get(&record.id).unwrap().unwrap().outcome, + Outcome::NeedsRecovery + ); + + let (_, store, client, record) = receive_setup(-(REFUND_MTP_LAG_SECONDS as i64) - 60); + client.drive_once().await.unwrap(); + assert_eq!( + store.get(&record.id).unwrap().unwrap().outcome, + Outcome::Lapsed + ); +} + +#[tokio::test] +async fn receive_reclaimed_by_the_solver_is_lapsed() { + let (wallet, store, client, record) = receive_setup(3600); + let script = record.lockup.build().unwrap().script_pubkey(); + let mut spent = vtxo(script, 995, 0); + spent.is_spent = true; + let spender = Txid::from_str(&"33".repeat(32)).unwrap(); + spent.spent_by = Some(spender); + *wallet.vtxos.lock().unwrap() = vec![spent]; + // Unknown spender: nothing learned. + client.drive_once().await.unwrap(); + assert_eq!( + store.get(&record.id).unwrap().unwrap().outcome, + Outcome::Open + ); + wallet + .virtual_txs + .lock() + .unwrap() + .insert(spender, empty_psbt()); + // A spend with neither our preimage nor a payout to the solver's refund script: surfaced. + client.drive_once().await.unwrap(); + assert_eq!( + store.get(&record.id).unwrap().unwrap().outcome, + Outcome::NeedsRecovery + ); + // The solver's reclaim pays its refund script: lapsed. + let mut reclaim = empty_psbt(); + reclaim.unsigned_tx.output.push(bitcoin::TxOut { + value: Amount::from_sat(995), + script_pubkey: record.lockup.sender_pk_script.clone(), + }); + reclaim.outputs.push(Default::default()); + wallet.virtual_txs.lock().unwrap().insert(spender, reclaim); + client.drive_once().await.unwrap(); + assert_eq!( + store.get(&record.id).unwrap().unwrap().outcome, + Outcome::Lapsed + ); +} + +fn empty_psbt() -> Psbt { + Psbt::from_unsigned_tx(bitcoin::Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![bitcoin::TxIn::default()], + output: vec![], + }) + .unwrap() +} + +/// A funded send record over the fixture's send quote. +fn send_setup( + refund_locktime_shift: i64, +) -> ( + Arc, + Arc, + IntentsClient, + SwapRecord, +) { + let f = fixture(); + let s = &f["send"]; + let keypair = identity(f["mnemonic"].as_str().unwrap()); + let refund = ArkAddress::decode(s["refund"]["address"].as_str().unwrap()).unwrap(); + let quote: RfqQuote = serde_json::from_value(s["quote"].clone()).unwrap(); + let bolt11 = s["request"]["profile"]["invoice"].as_str().unwrap(); + let invoice = super::bolt11::decode(bolt11).unwrap(); + let terms = Terms::from_server_info(&info()).unwrap(); + let lockup = derive_send( + "e, + &invoice.payment_hash, + keypair.x_only_public_key().0, + &refund, + &terms, + f["capturedAt"].as_u64().unwrap(), + ) + .unwrap(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut params = lockup.params.clone(); + params.refund_locktime = (now as i64 + refund_locktime_shift) as u32; + let record = SwapRecord { + id: quote.rfq_id.clone(), + direction: Direction::Send, + outcome: Outcome::Funded, + give_sat: 1004, + take_sat: 1000, + fee_sat: 4, + bolt11: bolt11.into(), + payment_hash: invoice.payment_hash.clone(), + created_at: now, + updated_at: now, + blocked_reason: None, + expires_at: now + 600, + solver: "beta-solver".into(), + rfq_id: quote.rfq_id.clone(), + quote: serde_json::to_value("e).unwrap(), + lockup: params.clone(), + lockup_address: params.build().unwrap().address(Network::Bitcoin).encode(), + preimage_salt: None, + preimage: None, + payout_address: String::new(), + funding_txid: Some("11".repeat(32)), + claim_txid: None, + refund_txid: None, + }; + let wallet = Arc::new(Mock { + keypair: Some(keypair), + address: Some(refund), + ..Default::default() + }); + let store = Arc::new(JsonStore::memory()); + store.put(&record).unwrap(); + let client = IntentsClient::new(wallet.clone(), store.clone(), SolverCard::beta_solver()); + (wallet, store, client, record) +} + +#[tokio::test] +async fn send_is_paid_when_the_solver_claim_reveals_the_preimage() { + let (wallet, store, client, record) = send_setup(3600); + let script = record.lockup.build().unwrap().script_pubkey(); + *wallet.vtxos.lock().unwrap() = vec![vtxo(script.clone(), 1004, 0)]; + client.drive_once().await.unwrap(); + assert_eq!( + store.get(&record.id).unwrap().unwrap().outcome, + Outcome::Funded + ); + + // The solver's claim checkpoint carries the preimage in its condition witness. + let preimage = [42u8; 32]; + let mut record = record; + record.payment_hash = hex::encode(payment_hash(&preimage)); + store.put(&record).unwrap(); + let mut checkpoint = empty_psbt(); + checkpoint.inputs[0].unknown.insert( + bitcoin::psbt::raw::Key { + type_value: 222, + key: ark_core::VTXO_CONDITION_KEY.to_vec(), + }, + [vec![1u8, 32], preimage.to_vec()].concat(), + ); + let spender = Txid::from_str(&"44".repeat(32)).unwrap(); + wallet + .virtual_txs + .lock() + .unwrap() + .insert(spender, checkpoint); + let mut spent = vtxo(script, 1004, 0); + spent.is_spent = true; + spent.spent_by = Some(spender); + *wallet.vtxos.lock().unwrap() = vec![spent]; + client.drive_once().await.unwrap(); + let paid = store.get(&record.id).unwrap().unwrap(); + assert_eq!(paid.outcome, Outcome::Paid); + assert_eq!(paid.preimage, Some(hex::encode(preimage))); +} + +#[tokio::test] +async fn send_refunds_after_the_locktime() { + let (wallet, store, client, record) = send_setup(-60); + let script = record.lockup.build().unwrap().script_pubkey(); + *wallet.vtxos.lock().unwrap() = vec![vtxo(script, 1004, 0)]; + client.drive_once().await.unwrap(); + let refunded = store.get(&record.id).unwrap().unwrap(); + assert_eq!(refunded.outcome, Outcome::Refunded); + let submitted = wallet.submitted.lock().unwrap(); + let ark_tx = &submitted[0]; + let leaf = record + .lockup + .build() + .unwrap() + .refund_without_receiver_script() + .clone(); + assert!(ark_tx.inputs[0] + .tap_scripts + .values() + .any(|(s, _)| *s == leaf)); + assert!(preimage_in(ark_tx, &[0; 32]).is_none()); + assert_eq!( + ark_tx.unsigned_tx.lock_time.to_consensus_u32(), + record.lockup.refund_locktime + ); + assert_eq!(ark_tx.unsigned_tx.output[0].value, Amount::from_sat(1004)); + assert_eq!( + ark_tx.unsigned_tx.output[0].script_pubkey, + wallet.address.unwrap().to_p2tr_script_pubkey() + ); +} + +#[test] +fn store_persists_and_round_trips_backups() { + let (_, store, _, record) = receive_setup(3600); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("swaps.json"); + let file = JsonStore::open(&path).unwrap(); + assert_eq!(file.import(&store.export().unwrap()).unwrap(), 1); + drop(file); + let reopened = JsonStore::open(&path).unwrap(); + assert_eq!(reopened.get(&record.id).unwrap().unwrap(), record); + // Re-importing the same backup writes nothing; a newer copy wins. + assert_eq!(reopened.import(&store.export().unwrap()).unwrap(), 0); + let mut newer = record.clone(); + newer.outcome = Outcome::Claimed; + newer.updated_at += 1; + store.put(&newer).unwrap(); + assert_eq!(reopened.import(&store.export().unwrap()).unwrap(), 1); + assert_eq!( + reopened.get(&record.id).unwrap().unwrap().outcome, + Outcome::Claimed + ); + // The record carries no secret: only the public salt. + let json = reopened.export().unwrap(); + let preimage = salted_preimage( + &identity(fixture()["mnemonic"].as_str().unwrap()), + &hex_32(record.preimage_salt.as_deref().unwrap(), "salt").unwrap(), + ); + assert!(!json.contains(&hex::encode(preimage))); +} + +/// Each swap goes through the Arkade backup (`arkade_export_state` into a fresh `arkade_import_state`) whole, and +/// the restored record still drives: a funded receive claims, an expired send refunds. +#[tokio::test] +async fn swaps_survive_the_arkade_backup_and_still_drive() { + use super::super::store::{export, import, SqliteSwaps}; + + let restore = |record: &SwapRecord| { + let from = tempfile::tempdir().unwrap(); + SqliteSwaps::open(from.path()).unwrap().put(record).unwrap(); + let dump = export(from.path()).unwrap(); + let to = tempfile::tempdir().unwrap(); + assert_eq!(import(to.path(), &dump).unwrap(), 1); + let store = Arc::new(SqliteSwaps::open(to.path()).unwrap()); + assert_eq!(store.get(&record.id).unwrap().as_ref(), Some(record)); + (to, store) + }; + + let (wallet, _, _, record) = receive_setup(3600); + assert!(record.preimage_salt.is_some()); + let (_dir, store) = restore(&record); + let client = IntentsClient::new(wallet.clone(), store.clone(), SolverCard::beta_solver()); + let script = record.lockup.build().unwrap().script_pubkey(); + *wallet.vtxos.lock().unwrap() = vec![vtxo(script, 995, 0)]; + client.drive_once().await.unwrap(); + assert_eq!( + store.get(&record.id).unwrap().unwrap().outcome, + Outcome::Claimed + ); + let claim = &wallet.submitted.lock().unwrap()[0]; + let hash = hex_32(&record.payment_hash, "hash").unwrap(); + assert!(preimage_in(claim, &hash).is_some(), "the salt re-derives P"); + + let (wallet, _, _, record) = send_setup(-60); + let (_dir, store) = restore(&record); + let client = IntentsClient::new(wallet.clone(), store.clone(), SolverCard::beta_solver()); + let script = record.lockup.build().unwrap().script_pubkey(); + *wallet.vtxos.lock().unwrap() = vec![vtxo(script, 1004, 0)]; + client.drive_once().await.unwrap(); + assert_eq!( + store.get(&record.id).unwrap().unwrap().outcome, + Outcome::Refunded + ); + let refund = &wallet.submitted.lock().unwrap()[0]; + assert_eq!( + refund.unsigned_tx.lock_time.to_consensus_u32(), + record.lockup.refund_locktime + ); +} diff --git a/src/modules/arkade/intents/contract.rs b/src/modules/arkade/intents/contract.rs new file mode 100644 index 0000000..0deaadd --- /dev/null +++ b/src/modules/arkade/intents/contract.rs @@ -0,0 +1,413 @@ +//! The lockup each corridor compiles, derived from the quote's binding fields plus the wallet's +//! own data, and the checks a quote must pass before an invoice is published or a lockup funded. +//! Mirrors `rfq.ts` (`deriveLightningReceive`, `deriveLightningSend`, `assertReceivable`, +//! `assertFundable`) and `client/verify.ts`. + +use super::bolt11::InvoiceFacts; +use super::error::Error; +use super::error::Result; +use super::vhtlc::seconds_sequence; +use super::vhtlc::NonInteractiveParameters; +use super::vhtlc::VhtlcOptions; +use super::vhtlc::VhtlcV2; +use super::wire::RfqQuote; +use ark_core::ArkAddress; +use bitcoin::hashes::ripemd160; +use bitcoin::hashes::Hash; +use bitcoin::Network; +use bitcoin::ScriptBuf; +use bitcoin::XOnlyPublicKey; +use serde::Deserialize; +use serde::Serialize; + +const SEQUENCE_GRANULARITY_SECONDS: u32 = 512; +/// How long the sender's solo refund opens after the receiver's unilateral claim. +pub const SOLO_REFUND_HEADROOM_SECONDS: u32 = 8 * SEQUENCE_GRANULARITY_SECONDS; +/// Receive: the floor between the last moment the hold invoice can be paid and the solver's +/// refund opening. +pub const MIN_CLAIM_WINDOW_SECONDS: u64 = 30 * 60; +/// Send: refuse to fund unless this long remains before the refund path opens (MTP lag). +pub const MIN_HEADROOM_SECONDS: u64 = 90 * 60; +/// Send: the longest refund window the client accepts. +pub const MAX_REFUND_WINDOW_SECONDS: u64 = 30 * 24 * 3600; +/// The solver's CLTV matures on median-time-past, which trails wall clock. +pub const REFUND_MTP_LAG_SECONDS: u64 = 2 * 60 * 60; + +/// Covenant co-signer ("emulator") keys, pinned per network as in `@arkade-os/sdk`. Never the +/// one a server reports: it lands in a leaf that decides who can move the funds. +pub fn emulator_pubkey(network: Network) -> Result { + let hex = match network { + Network::Bitcoin => "39c196415da47b26456a101daaa12ba9e445bfe153197f1e2b750bf40e52092e", + Network::Regtest => "999413c46fa10ada5cbc4bcc79a1d09160c2ba3cfc812705d7a13e5e545fb2a9", + Network::Signet => "f823b9b2febc81f4af967e77aed2f541cbd3397c6d8f5a72e32eb7b471af889a", + other => return Err(Error::Other(format!("no emulator is pinned for {other}"))), + }; + Ok(hex.parse().expect("valid pinned key")) +} + +/// What both corridors read off the wallet's own Arkade connection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Terms { + pub operator: XOnlyPublicKey, + pub emulator: XOnlyPublicKey, + /// `unilateralClaimDelay`, seconds: the operator's exit delay rounded up to 512. + pub claim_delay: u32, + pub network: Network, +} + +impl Terms { + pub fn from_server_info(info: &ark_core::server::Info) -> Result { + let delay = info.unilateral_exit_delay; + if !delay.is_time_locked() { + return Err(Error::Other(format!( + "operator exit delay {delay} is not in seconds" + ))); + } + let exit_delay_seconds = (delay.to_consensus_u32() & 0xffff) * SEQUENCE_GRANULARITY_SECONDS; + Ok(Self { + operator: info.signer_pk.x_only_public_key().0, + emulator: emulator_pubkey(info.network)?, + claim_delay: unilateral_claim_delay(exit_delay_seconds)?, + network: info.network, + }) + } +} + +pub fn unilateral_claim_delay(operator_exit_delay_seconds: u32) -> Result { + let max = 0xffff * SEQUENCE_GRANULARITY_SECONDS - SOLO_REFUND_HEADROOM_SECONDS; + if !(SEQUENCE_GRANULARITY_SECONDS..=max).contains(&operator_exit_delay_seconds) { + return Err(Error::Other(format!( + "operator exit delay {operator_exit_delay_seconds}s is out of range" + ))); + } + Ok( + operator_exit_delay_seconds.div_ceil(SEQUENCE_GRANULARITY_SECONDS) + * SEQUENCE_GRANULARITY_SECONDS, + ) +} + +/// Everything a lockup is rebuilt from; persisted with the swap record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LockupParams { + pub sender: XOnlyPublicKey, + pub receiver: XOnlyPublicKey, + pub server: XOnlyPublicKey, + pub emulator: XOnlyPublicKey, + /// `sha256(P)`, hex. + pub payment_hash: String, + pub refund_locktime: u32, + pub claim_delay: u32, + pub refund_without_receiver_delay: u32, + pub sender_pk_script: ScriptBuf, + pub receiver_pk_script: ScriptBuf, + pub legacy_pre_timelocked_refund: bool, +} + +impl LockupParams { + pub fn build(&self) -> Result { + let hash = hex_32(&self.payment_hash, "payment_hash")?; + Ok(VhtlcV2::new( + VhtlcOptions { + sender: self.sender, + receiver: self.receiver, + server: self.server, + preimage_hash: ripemd160::Hash::hash(&hash), + refund_locktime: self.refund_locktime, + unilateral_claim_delay: seconds_sequence(self.claim_delay)?, + // Level with the claim: a two-signature leaf neither party spends alone. + unilateral_refund_delay: seconds_sequence(self.claim_delay)?, + unilateral_refund_without_receiver_delay: seconds_sequence( + self.refund_without_receiver_delay, + )?, + }, + NonInteractiveParameters { + emulator_pubkey: self.emulator, + receiver_pk_script: self.receiver_pk_script.clone(), + sender_pk_script: self.sender_pk_script.clone(), + legacy_pre_timelocked_refund: self.legacy_pre_timelocked_refund, + }, + )?) + } + + /// Build both shapes the quote may use (full suite first) and keep the one whose address + /// the solver quoted. The quoted address is compare-only: a mismatch refuses, never adopts. + fn matching( + mut self, + quote: &RfqQuote, + network: Network, + ) -> Result<(Self, VhtlcV2, ArkAddress)> { + let quoted = quote.profile_str("lockup_address")?; + let mut derived = Vec::new(); + for legacy in [false, true] { + self.legacy_pre_timelocked_refund = legacy; + let script = self.build()?; + let address = script.address(network); + if address.encode() == quoted { + return Ok((self, script, address)); + } + derived.push(address.encode()); + } + Err(Error::verification( + "lockup_address", + derived.join(" or "), + quoted, + )) + } +} + +pub fn hex_32(value: &str, field: &'static str) -> Result<[u8; 32]> { + hex::decode(value) + .ok() + .and_then(|b| b.try_into().ok()) + .ok_or_else(|| Error::verification(field, "32 bytes of hex", value)) +} + +/// `toXOnly`: a 32-byte x-only key, or a 33-byte compressed one. +fn solver_key(quote: &RfqQuote) -> Result { + let bytes = hex::decode("e.solver_pubkey) + .map_err(|_| Error::verification("solver_pubkey", "hex", "e.solver_pubkey))?; + let key = match bytes.len() { + 32 => XOnlyPublicKey::from_slice(&bytes).ok(), + 33 => bitcoin::secp256k1::PublicKey::from_slice(&bytes) + .ok() + .map(|k| k.x_only_public_key().0), + _ => None, + }; + key.ok_or_else(|| Error::verification("solver_pubkey", "a public key", "e.solver_pubkey)) +} + +fn p2tr_script(value: &str, field: &'static str) -> Result { + let bytes = hex::decode(value).map_err(|_| Error::verification(field, "hex", value))?; + if bytes.len() != 34 || bytes[0] != 0x51 || bytes[1] != 0x20 { + return Err(Error::verification(field, "a P2TR pkScript", value)); + } + Ok(ScriptBuf::from_bytes(bytes)) +} + +/// The receive lockup the solver must fund, derived and matched against the quote. +#[derive(Debug, Clone)] +pub struct ReceiveLockup { + pub params: LockupParams, + pub script: VhtlcV2, + pub address: ArkAddress, + /// The solver's hold invoice on our hash. + pub invoice: String, +} + +/// `deriveLightningReceive`: the trader is `receiver` (it holds `P`), the solver `sender`. +pub fn derive_receive( + quote: &RfqQuote, + payment_hash: &str, + payout_pubkey: XOnlyPublicKey, + payout_address: &ArkAddress, + terms: &Terms, +) -> Result { + let refund_locktime = quote + .refund_locktime + .ok_or_else(|| Error::verification("refund_window", "refund_locktime", "missing"))?; + let invoice = quote.profile_str("invoice")?.to_string(); + let params = LockupParams { + sender: solver_key(quote)?, + receiver: payout_pubkey, + server: terms.operator, + emulator: terms.emulator, + payment_hash: payment_hash.to_string(), + refund_locktime: u32::try_from(refund_locktime) + .map_err(|_| Error::verification("refund_window", "a u32 locktime", refund_locktime))?, + claim_delay: terms.claim_delay, + refund_without_receiver_delay: terms.claim_delay + SOLO_REFUND_HEADROOM_SECONDS, + sender_pk_script: p2tr_script( + quote.profile_str("solver_refund_pk_script")?, + "solver_refund_pk_script", + )?, + receiver_pk_script: payout_address.to_p2tr_script_pubkey(), + legacy_pre_timelocked_refund: false, + }; + let (params, script, address) = params.matching(quote, terms.network)?; + Ok(ReceiveLockup { + params, + script, + address, + invoice, + }) +} + +/// The receive gates (`quoteLightningReceive`): the pinned give side, the hold invoice bound to +/// our hash and the quoted amount, and a claim window. Returns the pay deadline. +pub fn verify_receive( + quote: &RfqQuote, + amount_sat: u64, + payment_hash: &str, + invoice: &InvoiceFacts, + now: u64, +) -> Result { + let (give, take) = (quote.give()?, quote.take()?); + if give != amount_sat { + return Err(Error::verification("amount", amount_sat, give)); + } + if take > give { + return Err(Error::verification( + "amount", + "take <= give", + format!("{give} -> {take}"), + )); + } + if take == 0 { + return Err(Error::verification("amount", "a positive take", take)); + } + if invoice.payment_hash != payment_hash { + return Err(Error::verification( + "invoice", + payment_hash, + &invoice.payment_hash, + )); + } + if invoice.amount_sats == 0 { + return Err(Error::verification( + "invoice", + give, + "the invoice names no amount", + )); + } + if invoice.amount_sats != give { + return Err(Error::verification("invoice", give, invoice.amount_sats)); + } + let pay_deadline = invoice.expires_at.min(quote.valid_until); + if now >= pay_deadline { + return Err(Error::QuoteExpired); + } + let refund_locktime = quote + .refund_locktime + .ok_or_else(|| Error::verification("refund_window", "refund_locktime", "missing"))?; + if refund_locktime < pay_deadline + MIN_CLAIM_WINDOW_SECONDS { + return Err(Error::verification( + "refund_window", + format!("at least {MIN_CLAIM_WINDOW_SECONDS}s to claim after the pay deadline"), + refund_locktime.saturating_sub(pay_deadline), + )); + } + Ok(pay_deadline) +} + +/// The send lockup we fund, derived and matched against the quote. +#[derive(Debug, Clone)] +pub struct SendLockup { + pub params: LockupParams, + pub script: VhtlcV2, + pub address: ArkAddress, +} + +/// `deriveLightningSend`: the trader is `sender` (it holds the refund), the solver `receiver`. +pub fn derive_send( + quote: &RfqQuote, + payment_hash: &str, + sender_pubkey: XOnlyPublicKey, + refund_address: &ArkAddress, + terms: &Terms, + now: u64, +) -> Result { + let refund_locktime = quote + .refund_locktime + .ok_or_else(|| Error::verification("refund_window", "refund_locktime", "missing"))?; + let solo = quote.profile_u64("refund_without_receiver_delay")?; + let valid_solo = solo >= u64::from(terms.claim_delay) + && solo % u64::from(SEQUENCE_GRANULARITY_SECONDS) == 0 + && solo <= 0xffff * u64::from(SEQUENCE_GRANULARITY_SECONDS); + if !valid_solo { + return Err(Error::verification( + "refund_window", + "a valid refund_without_receiver_delay", + solo, + )); + } + if solo < refund_locktime.saturating_sub(now) { + return Err(Error::verification( + "refund_window", + "the solo refund opening after refund_locktime", + solo, + )); + } + let params = LockupParams { + sender: sender_pubkey, + receiver: solver_key(quote)?, + server: terms.operator, + emulator: terms.emulator, + payment_hash: payment_hash.to_string(), + refund_locktime: u32::try_from(refund_locktime) + .map_err(|_| Error::verification("refund_window", "a u32 locktime", refund_locktime))?, + claim_delay: terms.claim_delay, + refund_without_receiver_delay: solo as u32, + sender_pk_script: refund_address.to_p2tr_script_pubkey(), + receiver_pk_script: p2tr_script( + quote.profile_str("receiver_pk_script")?, + "receiver_pk_script", + )?, + legacy_pre_timelocked_refund: false, + }; + let (params, script, address) = params.matching(quote, terms.network)?; + Ok(SendLockup { + params, + script, + address, + }) +} + +/// The send gates (`verifySendInvoice`, `assertFundable`, `verifyRefundWindow`): the take side is +/// the invoice verbatim, the spread is not negative, and the clocks leave room to refund. +pub fn verify_send(quote: &RfqQuote, invoice: &InvoiceFacts, now: u64) -> Result<()> { + let (give, take) = (quote.give()?, quote.take()?); + if take != invoice.amount_sats { + return Err(Error::verification("invoice", invoice.amount_sats, take)); + } + if give < take { + return Err(Error::verification( + "invoice", + format!("at least {take}"), + give, + )); + } + if quote + .profile + .get("payment_hash") + .and_then(|v| v.as_str()) + .is_some_and(|h| h != invoice.payment_hash) + { + return Err(Error::verification( + "invoice", + &invoice.payment_hash, + quote.profile_str("payment_hash")?, + )); + } + if now >= invoice.expires_at { + return Err(Error::Invoice("invoice expired".into())); + } + if now >= quote.valid_until { + return Err(Error::QuoteExpired); + } + let refund_locktime = quote + .refund_locktime + .ok_or_else(|| Error::verification("refund_window", "refund_locktime", "missing"))?; + if refund_locktime < now + MIN_HEADROOM_SECONDS { + return Err(Error::verification( + "refund_window", + format!("at least {MIN_HEADROOM_SECONDS}s before the refund opens"), + refund_locktime.saturating_sub(now), + )); + } + if refund_locktime - now > MAX_REFUND_WINDOW_SECONDS { + return Err(Error::verification( + "refund_window", + format!("at most {MAX_REFUND_WINDOW_SECONDS}s"), + refund_locktime - now, + )); + } + let solo = quote.profile_u64("refund_without_receiver_delay")?; + if solo >= MAX_REFUND_WINDOW_SECONDS + u64::from(SEQUENCE_GRANULARITY_SECONDS) { + return Err(Error::verification( + "refund_window", + format!("a solo-refund delay of at most {MAX_REFUND_WINDOW_SECONDS}s"), + solo, + )); + } + Ok(()) +} diff --git a/src/modules/arkade/intents/error.rs b/src/modules/arkade/intents/error.rs new file mode 100644 index 0000000..c666aac --- /dev/null +++ b/src/modules/arkade/intents/error.rs @@ -0,0 +1,59 @@ +use super::vhtlc::VhtlcError; + +/// A wallet-side failure, raised by the binding's [`super::ArkWallet`] implementation. +#[derive(Debug, Clone, thiserror::Error)] +#[error("{0}")] +pub struct WalletError(pub String); + +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The solver answered `rfq_refusal`. + #[error("solver refused: {reason}{}", error_code.as_deref().map(|c| format!(" ({c})")).unwrap_or_default())] + Refused { + reason: String, + error_code: Option, + /// The rest of the refusal (`field`, `actual`, `expected`, `limit`, `unit`). + detail: serde_json::Value, + }, + /// A quote failed one of the client's checks; never fund or publish it. + #[error("quote failed the {check} check: expected {expected}, got {actual}")] + Verification { + check: &'static str, + expected: String, + actual: String, + }, + #[error("quote expired")] + QuoteExpired, + #[error("fee {fee} sat exceeds the {max} sat maximum")] + FeeTooHigh { fee: u64, max: u64 }, + #[error("invalid invoice: {0}")] + Invoice(String), + #[error("transport: {0}")] + Transport(String), + #[error("no solver reply within {0} seconds")] + Timeout(u64), + #[error("wallet: {0}")] + Wallet(#[from] WalletError), + #[error("store: {0}")] + Store(String), + #[error("lockup script: {0}")] + Vhtlc(#[from] VhtlcError), + #[error("{0}")] + Other(String), +} + +impl Error { + pub(crate) fn verification( + check: &'static str, + expected: impl ToString, + actual: impl ToString, + ) -> Self { + Self::Verification { + check, + expected: expected.to_string(), + actual: actual.to_string(), + } + } +} + +pub type Result = std::result::Result; diff --git a/src/modules/arkade/intents/fixtures/oracle.json b/src/modules/arkade/intents/fixtures/oracle.json new file mode 100644 index 0000000..149e8a6 --- /dev/null +++ b/src/modules/arkade/intents/fixtures/oracle.json @@ -0,0 +1,105 @@ +{ + "comment": "Captured by oracle/capture.mjs from @arkade-os/swap 0.1.0-rc.34 against beta-solver on mainnet. Throwaway wallet, no funds.", + "capturedAt": 1793308284, + "mnemonic": "teach found fiscal pause dignity stairs shop skull learn grace about one", + "walletDescriptor": "tr([337f7e1e/86'/0'/1']xpub6CoB8N5R7boNHVZDQmTpZHBqpg5ZynviQ4Nc2x2oCk3s4g1v91Yyhwm7TeZ4vGk71VprUZBF93MHCyhEA6PGMU9qjtSp19yXaBZqcoxim5y/0/*)", + "server": { + "signerPubkey": "038202bebddeb1f7442803897a85eaf3ce9254d07df0172fc3725ab5f0d097779c", + "unilateralExitDelay": "605184", + "network": "bitcoin" + }, + "terms": { + "operatorPubkey": "8202bebddeb1f7442803897a85eaf3ce9254d07df0172fc3725ab5f0d097779c", + "emulatorPubkey": "39c196415da47b26456a101daaa12ba9e445bfe153197f1e2b750bf40e52092e", + "claimDelay": 605184, + "hrp": "ark" + }, + "receive": { + "claimDescriptor": "tr(0cbc7d57f97372ddeaea715e0eed62c4949ea207050812cf42c4d0c4cb2ced14)", + "claimPubkey": "0cbc7d57f97372ddeaea715e0eed62c4949ea207050812cf42c4d0c4cb2ced14", + "perArtifactDescriptor": false, + "preimageSalt": "754c9e344084d83f673223f72f6030e5eb4210e9f1e0061445879f26506e7562", + "mustPersistPreimage": false, + "preimageMessage": "41726b6164652d436f6e74726163742d507265696d6167652d53616c7465642d76310cbc7d57f97372ddeaea715e0eed62c4949ea207050812cf42c4d0c4cb2ced14754c9e344084d83f673223f72f6030e5eb4210e9f1e0061445879f26506e7562", + "preimage": "15c92b24e610293f811d174ec4a0396d9e5f744a264a117598465dc9cb697cc3", + "paymentHash": "08cc1bb5fe3d61dddebffea0275fc1abf3a38e8119efaed31ee141717f297005", + "payoutAddress": "ark1qzpq904am6clw3pgqwyh4p02708fy4xs0hcpwt7rwfdttuxsjamecalxzwfmpmvycmsrlqgpdu5t9k9f9p5thptjr7jsk4trupgflerftmvcrs", + "amount": 1000, + "request": { + "v": 1, + "type": "rfq_request", + "rfq_id": "54a7353b1fdae62b7231b5297be1262c6d00eef89f7b0e8e3e72a4222af15e39", + "pair": "lightning:BTC->arkade:BTC", + "amount_side": "from", + "amount": "1000", + "profile": { + "payment_hash": "08cc1bb5fe3d61dddebffea0275fc1abf3a38e8119efaed31ee141717f297005", + "payout_address": "ark1qzpq904am6clw3pgqwyh4p02708fy4xs0hcpwt7rwfdttuxsjamecalxzwfmpmvycmsrlqgpdu5t9k9f9p5thptjr7jsk4trupgflerftmvcrs", + "payout_pubkey": "0cbc7d57f97372ddeaea715e0eed62c4949ea207050812cf42c4d0c4cb2ced14" + } + }, + "quote": { + "v": 1, + "type": "rfq_quote", + "rfq_id": "54a7353b1fdae62b7231b5297be1262c6d00eef89f7b0e8e3e72a4222af15e39", + "pair": "lightning:BTC->arkade:BTC", + "from_amount": 1000, + "to_amount": 995, + "solver_pubkey": "66422c952f8dcb96e4d0c3f049cd1e265b8461b916d9913c65c2494b64b4e3ce", + "valid_until": 1793310083, + "refund_locktime": 1793315483, + "profile": { + "payment_hash": "08cc1bb5fe3d61dddebffea0275fc1abf3a38e8119efaed31ee141717f297005", + "invoice": "lnbc10u1p4w8dnmpp5prxphd0784samh4ll6szwh7p40e68r5pr8h6a5c7u9qhzlefwqzsdqz9ccqzdyxqrpwusp5ghpkflpkxyql3ncnn2qtumfjff5ftlgpr0zhv9706l7hgpn5nweq9qxpqysgqgalqdntn7ykj7y2xfezwqmak99d2akwvdeuqv8rk6zygcrsrvx6zgu8pyy75ttqc5mhax6rtq30cc7pq2p4pmnrlme30h52h5j028ggq3jakpx", + "lockup_address": "ark1qzpq904am6clw3pgqwyh4p02708fy4xs0hcpwt7rwfdttuxsjamechtuxwlqpe2y88xcsfmcw56gxy8h8dfjvfta4pp34c8cl9ah72xc6v7tzr", + "solver_refund_pk_script": "5120891ca8c55ddcbef6d7416148071c9690b59870c42507d52c76adfef0a9df3fa4" + } + }, + "lockupAddress": "ark1qzpq904am6clw3pgqwyh4p02708fy4xs0hcpwt7rwfdttuxsjamechtuxwlqpe2y88xcsfmcw56gxy8h8dfjvfta4pp34c8cl9ah72xc6v7tzr", + "legacy": null, + "invoice": { + "raw": "lnbc10u1p4w8dnmpp5prxphd0784samh4ll6szwh7p40e68r5pr8h6a5c7u9qhzlefwqzsdqz9ccqzdyxqrpwusp5ghpkflpkxyql3ncnn2qtumfjff5ftlgpr0zhv9706l7hgpn5nweq9qxpqysgqgalqdntn7ykj7y2xfezwqmak99d2akwvdeuqv8rk6zygcrsrvx6zgu8pyy75ttqc5mhax6rtq30cc7pq2p4pmnrlme30h52h5j028ggq3jakpx", + "paymentHash": "08cc1bb5fe3d61dddebffea0275fc1abf3a38e8119efaed31ee141717f297005", + "amountSats": 1000, + "expiresAt": 1793309783 + } + }, + "send": { + "request": { + "v": 1, + "type": "rfq_request", + "rfq_id": "ee11190da64a2b9f8dfde1786a4b0ddeb4a8f0ffc647ad6308d07ba9da62a3dd", + "pair": "arkade:BTC->lightning:BTC", + "amount_side": "to", + "profile": { + "invoice": "lnbc10u1p4w8dnmpp5prxphd0784samh4ll6szwh7p40e68r5pr8h6a5c7u9qhzlefwqzsdqz9ccqzdyxqrpwusp5ghpkflpkxyql3ncnn2qtumfjff5ftlgpr0zhv9706l7hgpn5nweq9qxpqysgqgalqdntn7ykj7y2xfezwqmak99d2akwvdeuqv8rk6zygcrsrvx6zgu8pyy75ttqc5mhax6rtq30cc7pq2p4pmnrlme30h52h5j028ggq3jakpx", + "refund_address": "ark1qzpq904am6clw3pgqwyh4p02708fy4xs0hcpwt7rwfdttuxsjamecalxzwfmpmvycmsrlqgpdu5t9k9f9p5thptjr7jsk4trupgflerftmvcrs", + "client_refund_pubkey": "0cbc7d57f97372ddeaea715e0eed62c4949ea207050812cf42c4d0c4cb2ced14" + } + }, + "quote": { + "v": 1, + "type": "rfq_quote", + "rfq_id": "ee11190da64a2b9f8dfde1786a4b0ddeb4a8f0ffc647ad6308d07ba9da62a3dd", + "pair": "arkade:BTC->lightning:BTC", + "from_amount": 1004, + "to_amount": 1000, + "solver_pubkey": "66422c952f8dcb96e4d0c3f049cd1e265b8461b916d9913c65c2494b64b4e3ce", + "valid_until": 1793309184, + "refund_locktime": 1793920668, + "profile": { + "payment_hash": "08cc1bb5fe3d61dddebffea0275fc1abf3a38e8119efaed31ee141717f297005", + "lockup_address": "ark1qzpq904am6clw3pgqwyh4p02708fy4xs0hcpwt7rwfdttuxsjameew3n8ds24nmmf88cz4c89rh3vtmsyr32et7jxk0dzpw9k5q59ngyl39tk4", + "refund_without_receiver_delay": 612864, + "receiver_pk_script": "5120891ca8c55ddcbef6d7416148071c9690b59870c42507d52c76adfef0a9df3fa4" + } + }, + "refund": { + "descriptor": "tr(0cbc7d57f97372ddeaea715e0eed62c4949ea207050812cf42c4d0c4cb2ced14)", + "pubkey": "0cbc7d57f97372ddeaea715e0eed62c4949ea207050812cf42c4d0c4cb2ced14", + "address": "ark1qzpq904am6clw3pgqwyh4p02708fy4xs0hcpwt7rwfdttuxsjamecalxzwfmpmvycmsrlqgpdu5t9k9f9p5thptjr7jsk4trupgflerftmvcrs" + }, + "lockupAddress": "ark1qzpq904am6clw3pgqwyh4p02708fy4xs0hcpwt7rwfdttuxsjameew3n8ds24nmmf88cz4c89rh3vtmsyr32et7jxk0dzpw9k5q59ngyl39tk4", + "lockupAddressLegacy": "ark1qzpq904am6clw3pgqwyh4p02708fy4xs0hcpwt7rwfdttuxsjamecmfs6lwyrg77dk5mzws5wrpj9y6ky5kyx84kjddjv7vwhavnylxpfhsjjt" + } +} diff --git a/src/modules/arkade/intents/fixtures/vhtlc-v2-nine-leaf.json b/src/modules/arkade/intents/fixtures/vhtlc-v2-nine-leaf.json new file mode 100644 index 0000000..6eee4a2 --- /dev/null +++ b/src/modules/arkade/intents/fixtures/vhtlc-v2-nine-leaf.json @@ -0,0 +1,48 @@ +{ + "comment": "Generated from VHTLC.ScriptV2 with fixed, non-random test keys so this file is byte-reproducible. Regenerate by constructing VHTLC.ScriptV2 with: sender = schnorr.getPublicKey(Uint8Array(32).fill(1)), receiver = fill(2), server = fill(3), preimageHash = Uint8Array(20).fill(9), refundLocktime = 1000n, unilateralClaimDelay = {blocks, 100n}, unilateralRefundDelay = {blocks, 102n}, unilateralRefundWithoutReceiverDelay = {blocks, 103n}, nonInteractiveClaim = {receiverPkScript: 0x5120 || fill(4), emulatorPubkey: fill(5)}, nonInteractiveRefund = {senderPkScript: 0x5120 || fill(6), emulatorPubkey: fill(5), withoutReceiver: true} (all fill(n) via schnorr.getPublicKey(Uint8Array(32).fill(n)), @noble/curves/secp256k1.js) — then print pkScript, every script.*Script leaf, script.encode(), and both *ArkadeScript fields as hex. See test/script/vhtlc-vectors.test.ts for the exact reconstruction this pins against.", + "options": { + "sender": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + "receiver": "024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "server": "02531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337", + "preimageHash": "0909090909090909090909090909090909090909", + "refundLocktime": "1000", + "unilateralClaimDelay": { + "type": "blocks", + "value": "100" + }, + "unilateralRefundDelay": { + "type": "blocks", + "value": "102" + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "blocks", + "value": "103" + }, + "nonInteractiveClaim": { + "receiverPkScript": "5120462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b", + "emulatorPubkey": "62c0a046dacce86ddd0343c6d3c7c79c2208ba0d9c9cf24a6d046d21d21f90f7" + }, + "nonInteractiveRefund": { + "senderPkScript": "5120f006a18d5653c4edf5391ff23a61f03ff83d237e880ee61187fa9f379a028e0a", + "emulatorPubkey": "62c0a046dacce86ddd0343c6d3c7c79c2208ba0d9c9cf24a6d046d21d21f90f7", + "withoutReceiver": true + } + }, + "pkScript": "51208c814f0c398c065d46d975283d24818e97d9c6e917be2d30c347f4fc11c48b5b", + "tapTree": "01c06082012088a91409090909090909090909090909090909090909098769204d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766ad20531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337ac01c066201b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078fad204d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766ad20531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337ac01c04902e803b175201b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078fad20531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337ac01c04282012088a914090909090909090909090909090909090909090987690164b275204d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766ac01c0480166b275201b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078fad204d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766ac01c0260167b275201b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078fac01c06082012088a9140909090909090909090909090909090909090909876920531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337ad20b39fac289b95579dfbb032f44d076375d3f3820b36021169bf63d8ec40ed99e1ac01c06620531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337ad204d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766ad2065ed13213f33571c6666008870605652066d8879f7a972d2e34b6070e63ed9efac01c04902e803b17520531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337ad2065ed13213f33571c6666008870605652066d8879f7a972d2e34b6070e63ed9efac", + "leaves": { + "claim": "82012088a91409090909090909090909090909090909090909098769204d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766ad20531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337ac", + "refund": "201b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078fad204d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766ad20531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337ac", + "refundWithoutReceiver": "02e803b175201b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078fad20531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337ac", + "unilateralClaim": "82012088a914090909090909090909090909090909090909090987690164b275204d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766ac", + "unilateralRefund": "0166b275201b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078fad204d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766ac", + "unilateralRefundWithoutReceiver": "0167b275201b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078fac", + "nonInteractiveClaim": "82012088a9140909090909090909090909090909090909090909876920531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337ad20b39fac289b95579dfbb032f44d076375d3f3820b36021169bf63d8ec40ed99e1ac", + "nonInteractiveRefund": "20531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337ad204d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766ad2065ed13213f33571c6666008870605652066d8879f7a972d2e34b6070e63ed9efac", + "nonInteractiveRefundWithoutReceiver": "02e803b17520531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337ad2065ed13213f33571c6666008870605652066d8879f7a972d2e34b6070e63ed9efac" + }, + "arkadeScripts": { + "nonInteractiveClaim": "cd76d1518820462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b88cfcdc9a2", + "nonInteractiveRefund": "cd76d1518820f006a18d5653c4edf5391ff23a61f03ff83d237e880ee61187fa9f379a028e0a88cfcdc9a2" + } +} diff --git a/src/modules/arkade/intents/mainnet_tests.rs b/src/modules/arkade/intents/mainnet_tests.rs new file mode 100644 index 0000000..77e34f9 --- /dev/null +++ b/src/modules/arkade/intents/mainnet_tests.rs @@ -0,0 +1,192 @@ +//! Real RFQs against `beta-solver` on mainnet, with the oracle fixture's throwaway wallet. Moves +//! no money: it asks for a receive quote and a send quote (for the receive's own hold invoice) +//! and funds, pays and claims nothing. +//! +//! cargo test --lib modules::arkade::intents::mainnet_tests -- --ignored --nocapture + +use super::bolt11; +use super::contract::LockupParams; +use super::preimage::payment_hash; +use super::preimage::salted_preimage; +use super::*; +use ark_core::server::Info; +use ark_core::server::VirtualTxOutPoint; +use ark_core::ArkAddress; +use async_trait::async_trait; +use bitcoin::bip32::DerivationPath; +use bitcoin::bip32::Xpriv; +use bitcoin::key::Secp256k1; +use bitcoin::secp256k1::Keypair; +use bitcoin::Amount; +use bitcoin::Network; +use bitcoin::Psbt; +use bitcoin::ScriptBuf; +use bitcoin::Txid; +use serde_json::Value; +use std::str::FromStr; +use std::sync::Arc; + +const ARK_SERVER: &str = "https://arkade.computer"; + +/// Quotes only: server info and keys, no spending. +struct QuoteOnlyWallet { + keypair: Keypair, + address: ArkAddress, +} + +fn refuse() -> Result { + Err(WalletError("quote-only test wallet moves no money".into())) +} + +#[async_trait] +impl ArkWallet for QuoteOnlyWallet { + async fn server_info(&self) -> Result { + let v: Value = reqwest::get(format!("{ARK_SERVER}/v1/info")) + .await + .and_then(|r| r.error_for_status()) + .map_err(|e| WalletError(e.to_string()))? + .json() + .await + .map_err(|e| WalletError(e.to_string()))?; + let s = |k: &str| v[k].as_str().unwrap_or_default().to_string(); + let n = |k: &str| s(k).parse::().unwrap_or_default(); + assert_eq!(s("network"), "bitcoin"); + Ok(Info { + version: s("version"), + signer_pk: s("signerPubkey") + .parse() + .map_err(|e| WalletError(format!("{e}")))?, + forfeit_pk: s("forfeitPubkey") + .parse() + .map_err(|e| WalletError(format!("{e}")))?, + forfeit_address: bitcoin::Address::from_str(&s("forfeitAddress")) + .map_err(|e| WalletError(e.to_string()))? + .assume_checked(), + checkpoint_tapscript: ScriptBuf::from_hex(&s("checkpointTapscript")) + .unwrap_or_default(), + network: Network::Bitcoin, + session_duration: n("sessionDuration") as u64, + unilateral_exit_delay: ark_core::server::parse_sequence_number(n( + "unilateralExitDelay", + )) + .map_err(|e| WalletError(e.to_string()))?, + boarding_exit_delay: ark_core::server::parse_sequence_number(n("boardingExitDelay")) + .map_err(|e| WalletError(e.to_string()))?, + utxo_min_amount: None, + utxo_max_amount: None, + vtxo_min_amount: None, + vtxo_max_amount: None, + dust: Amount::from_sat(n("dust") as u64), + fees: None, + scheduled_session: None, + deprecated_signers: vec![], + service_status: Default::default(), + digest: s("digest"), + max_tx_weight: 0, + max_op_return_outputs: 0, + }) + } + async fn address(&self) -> Result { + Ok(self.address) + } + fn identity_keypair(&self) -> Result { + Ok(self.keypair) + } + async fn vtxos(&self, _: &ArkAddress) -> Result, WalletError> { + refuse() + } + async fn virtual_tx(&self, _: Txid) -> Result, WalletError> { + refuse() + } + async fn submit_offchain(&self, _: Psbt, _: Vec) -> Result, WalletError> { + refuse() + } + async fn finalize_offchain(&self, _: Txid, _: Vec) -> Result<(), WalletError> { + refuse() + } + async fn send(&self, _: &ArkAddress, _: Amount) -> Result { + refuse() + } +} + +#[tokio::test] +#[ignore = "talks to beta-solver on mainnet"] +async fn mainnet_receive_and_send_quotes() { + super::super::install_crypto_provider(); + let fixture: Value = serde_json::from_str(include_str!("fixtures/oracle.json")).unwrap(); + let seed = bip39::Mnemonic::parse(fixture["mnemonic"].as_str().unwrap()) + .unwrap() + .to_seed(""); + let secp = Secp256k1::new(); + let keypair = Xpriv::new_master(Network::Bitcoin, &seed) + .unwrap() + .derive_priv(&secp, &DerivationPath::from_str("m/86'/0'/1'/0/0").unwrap()) + .unwrap() + .to_keypair(&secp); + // The TS wallet's own address for this key (ark-core 0.12.0 orders the default leaf + // differently; the binding patches that). + let address = + ArkAddress::decode(fixture["receive"]["payoutAddress"].as_str().unwrap()).unwrap(); + let wallet = Arc::new(QuoteOnlyWallet { keypair, address }); + let client = IntentsClient::new( + wallet, + Arc::new(JsonStore::memory()), + SolverCard::beta_solver(), + ); + + let amount = 1000; + let record = client.receive(amount).await.expect("receive quote"); + let invoice = bolt11::decode(&record.bolt11).unwrap(); + let salt: [u8; 32] = hex::decode(record.preimage_salt.as_deref().unwrap()) + .unwrap() + .try_into() + .unwrap(); + let ours = hex::encode(payment_hash(&salted_preimage(&keypair, &salt))); + let quote = &record.quote; + println!( + "receive: give {} take {} fee {} sat", + record.give_sat, record.take_sat, record.fee_sat + ); + println!( + " invoice amount {} sat, expires in {}s", + invoice.amount_sats, + invoice.expires_at as i64 - record.created_at as i64 + ); + println!( + " invoice hash {}\n our hash {}", + invoice.payment_hash, ours + ); + println!( + " quote valid {}s, refund_locktime in {}s", + quote["valid_until"].as_u64().unwrap() - record.created_at, + quote["refund_locktime"].as_u64().unwrap() - record.created_at + ); + println!( + " lockup {} (solver quoted {})", + record.lockup_address, quote["profile"]["lockup_address"] + ); + assert_eq!(invoice.amount_sats, amount); + assert_eq!(invoice.payment_hash, ours); + assert_eq!(record.payment_hash, ours); + assert_eq!( + record.lockup_address, + quote["profile"]["lockup_address"].as_str().unwrap() + ); + assert_eq!(record.outcome, Outcome::Accepted); + let rebuilt: &LockupParams = &record.lockup; + assert_eq!( + rebuilt.build().unwrap().address(Network::Bitcoin).encode(), + record.lockup_address + ); + + let pay = client.quote_pay(&record.bolt11).await.expect("send quote"); + println!( + "send quote for that invoice: give {} take {} fee {} sat, valid {}s", + pay.give_sat, + pay.take_sat, + pay.fee_sat, + pay.expires_at as i64 - record.created_at as i64 + ); + assert_eq!(pay.take_sat, amount); + assert_eq!(pay.fee_sat, pay.give_sat - pay.take_sat); +} diff --git a/src/modules/arkade/intents/mod.rs b/src/modules/arkade/intents/mod.rs new file mode 100644 index 0000000..7ad48b8 --- /dev/null +++ b/src/modules/arkade/intents/mod.rs @@ -0,0 +1,40 @@ +//! Arkade Intents Lightning client: quotes Lightning swaps from an Arkade solver over Nostr RFQ, +//! verifies them against locally derived VHTLC v2 lockups, and drives claims and refunds. +//! +//! Every module refers to its siblings through `super::`. `ArkWallet` is implemented on the Arkade wallet in +//! the Arkade wallet (`src/modules/arkade/wallet.rs`); swap records live in the Arkade database +//! (`src/modules/arkade/store.rs`), so `arkade_export_state` carries them. + +pub mod bolt11; +pub mod client; +pub mod contract; +pub mod error; + +pub mod preimage; +pub mod spend; +pub mod store; +pub mod transport; +pub mod vhtlc; +pub mod wallet; +pub mod wire; + +#[cfg(test)] +mod client_tests; +#[cfg(test)] +#[allow(clippy::print_stdout)] // prints the live quote it checks, run with --nocapture +mod mainnet_tests; +#[cfg(test)] +mod oracle_tests; + +pub use client::IntentsClient; +pub use client::PayQuote; +pub use error::Error; +pub use error::Result; +pub use error::WalletError; +pub use store::Direction; +pub use store::JsonStore; +pub use store::Outcome; +pub use store::SwapRecord; +pub use store::SwapStore; +pub use transport::SolverCard; +pub use wallet::ArkWallet; diff --git a/src/modules/arkade/intents/oracle/capture.mjs b/src/modules/arkade/intents/oracle/capture.mjs new file mode 100644 index 0000000..b5de3df --- /dev/null +++ b/src/modules/arkade/intents/oracle/capture.mjs @@ -0,0 +1,195 @@ +// Oracle: what `@arkade-os/swap` 0.1.0-rc.34 on `@arkade-os/sdk` 0.5.0-rc.31 computes for a +// throwaway wallet and real mainnet quotes from `beta-solver`. Moves no money: it asks for one +// receive quote and one send quote (for the receive quote's own hold invoice) and pays nothing. +// +// node src/modules/arkade/intents/oracle/capture.mjs > src/modules/arkade/intents/fixtures/oracle.json +// +// `oracle/node_modules` is a local, gitignored symlink to the Bitkit bridge's node_modules. +// +// Uses ORACLE_MNEMONIC when set (a throwaway), else generates one. The mnemonic lands in the +// output: it is test data for a wallet that never holds funds. +import { + BITCOIN_EMULATOR_PUBKEY, + InMemoryContractRepository, + InMemoryWalletRepository, + MnemonicIdentity, + Wallet, + configureEventSource, + networkFromArkadeInfo, + provisionClaimSecret, + provisionRefundKey, + toXOnly, + buildPreimageMessage, + buildSaltedPreimageMessage, + isPerArtifactDescriptor, +} from "@arkade-os/sdk"; +import { + deriveLightningReceive, + lightningReceiveRequest, + lightningSendContract, + lightningSendRequest, + + unilateralClaimDelay, +} from "@arkade-os/swap/protocol"; +import { decodeBolt11 } from "@arkade-os/swap/advanced"; +import { nostrRfqTransport, closeSharedNostrPool } from "@arkade-os/swap/nostr"; +import { EventSource } from "eventsource"; +import { generateMnemonic, mnemonicToSeedSync } from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english.js"; +import { HDKey } from "@scure/bip32"; +import { hex } from "@scure/base"; +import { readFileSync } from "node:fs"; +const newRfqId = () => hex.encode(crypto.getRandomValues(new Uint8Array(32))); + +configureEventSource((url) => new EventSource(url)); +const log = (...a) => console.error(...a); + +const mnemonic = process.env.ORACLE_MNEMONIC ?? generateMnemonic(wordlist); +const root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)); +const path = "86'/0'/1'"; +const fp = root.fingerprint.toString(16).padStart(8, "0"); +const descriptor = `tr([${fp}/${path}]${root.derive(`m/${path}`).publicExtendedKey}/0/*)`; +const wallet = await Wallet.create({ + identity: MnemonicIdentity.fromMnemonic(mnemonic, { descriptor }), + arkServerUrl: "https://arkade.computer", + storage: { + walletRepository: new InMemoryWalletRepository(), + contractRepository: new InMemoryContractRepository(), + }, +}); +const info = await wallet.getArkadeInfo({ requireLive: true }); +const card = JSON.parse( + readFileSync(new URL("../../bridge/src/beta-solver.card.json", import.meta.url)), +); +const terms = { + operatorPubkey: toXOnly(hex.decode(info.signerPubkey), "ark signer key"), + emulatorPubkey: toXOnly(hex.decode(BITCOIN_EMULATOR_PUBKEY), "emulator"), + claimDelay: unilateralClaimDelay(Number(info.unilateralExitDelay)), + hrp: networkFromArkadeInfo(info).hrp, +}; +log("server", info.signerPubkey, "exit delay", String(info.unilateralExitDelay), "claimDelay", terms.claimDelay); + +const transport = nostrRfqTransport({ + relays: card.transports.nostr.relays, + solverPubkey: card.discovery_pubkey, +}); + +// ── receive ────────────────────────────────────────────────────────────────── +const secrets = await provisionClaimSecret(wallet); +const paymentHash = hex.encode(secrets.paymentHash); +const payoutAddress = await wallet.getAddress(); +const amount = Number(process.env.ORACLE_AMOUNT ?? 1000); +const receiveRequest = { + ...lightningReceiveRequest({ + rfqId: newRfqId(), + paymentHash, + payoutAddress, + payoutPubkey: secrets.pubkey, + amount: 0, + amountSide: "from", + }), + amount: String(amount), +}; +const receiveQuote = await transport.requestQuote(receiveRequest); +log("receive quote", JSON.stringify(receiveQuote).slice(0, 300)); +const derived = deriveLightningReceive({ + quote: receiveQuote, + paymentHash, + payoutPubkey: secrets.pubkey, + payoutAddress, + ...terms, +}); +const invoiceFacts = decodeBolt11(derived.invoice); + +// ── send, quoting the receive quote's own hold invoice; nothing is funded ──── +const refund = await provisionRefundKey(wallet); +const sendRequest = lightningSendRequest({ + rfqId: newRfqId(), + invoice: derived.invoice, + refundAddress: refund.address, + senderPubkey: refund.pubkey, +}); +let send; +try { + const sendQuote = await transport.requestQuote(sendRequest); + log("send quote", JSON.stringify(sendQuote).slice(0, 300)); + const base = { + solverPubkey: toXOnly(hex.decode(sendQuote.solver_pubkey), "solver key"), + refundLocktime: sendQuote.refund_locktime, + operatorPubkey: terms.operatorPubkey, + paymentHash: invoiceFacts.paymentHash, + claimDelay: terms.claimDelay, + refundWithoutReceiverDelay: sendQuote.profile.refund_without_receiver_delay, + emulatorPubkey: terms.emulatorPubkey, + refundPkScript: refund.pkScript, + senderPubkey: refund.pubkey, + receiverPkScript: hex.decode(sendQuote.profile.receiver_pk_script), + }; + const addressOf = (legacy) => + lightningSendContract({ ...base, ...(legacy ? { legacy } : {}) }) + .address(terms.hrp, terms.operatorPubkey) + .encode(); + send = { + request: sendRequest, + quote: sendQuote, + refund: { descriptor: refund.descriptor, pubkey: hex.encode(refund.pubkey), address: refund.address }, + lockupAddress: addressOf(undefined), + lockupAddressLegacy: addressOf("preTimelockedRefund"), + }; +} catch (error) { + log("send quote failed", error?.name, error?.message); + send = { request: sendRequest, error: `${error?.name}: ${error?.message}` }; +} + +await transport.close(); +closeSharedNostrPool?.(); + +console.log( + JSON.stringify( + { + comment: + "Captured by oracle/capture.mjs from @arkade-os/swap 0.1.0-rc.34 against beta-solver on mainnet. Throwaway wallet, no funds.", + capturedAt: Math.floor(Date.now() / 1000), + mnemonic, + walletDescriptor: descriptor, + server: { + signerPubkey: info.signerPubkey, + unilateralExitDelay: String(info.unilateralExitDelay), + network: info.network, + }, + terms: { + operatorPubkey: hex.encode(terms.operatorPubkey), + emulatorPubkey: hex.encode(terms.emulatorPubkey), + claimDelay: terms.claimDelay, + hrp: terms.hrp, + }, + receive: { + claimDescriptor: secrets.descriptor, + claimPubkey: hex.encode(secrets.pubkey), + // The bridge's wallet runs in the default `walletMode: "auto"` (static), so the + // claim key is the identity key and P takes the salted arm. + perArtifactDescriptor: isPerArtifactDescriptor(secrets.descriptor), + preimageSalt: secrets.preimageSalt ? hex.encode(secrets.preimageSalt) : null, + mustPersistPreimage: secrets.mustPersistPreimage, + preimageMessage: hex.encode( + secrets.preimageSalt + ? buildSaltedPreimageMessage(secrets.pubkey, secrets.preimageSalt) + : buildPreimageMessage(secrets.pubkey, 0), + ), + preimage: hex.encode(secrets.preimage), + paymentHash, + payoutAddress, + amount, + request: receiveRequest, + quote: receiveQuote, + lockupAddress: derived.address, + legacy: derived.contractParams.legacy ?? null, + invoice: invoiceFacts, + }, + send, + }, + null, + 2, + ), +); +process.exit(0); diff --git a/src/modules/arkade/intents/oracle_tests.rs b/src/modules/arkade/intents/oracle_tests.rs new file mode 100644 index 0000000..cfeba76 --- /dev/null +++ b/src/modules/arkade/intents/oracle_tests.rs @@ -0,0 +1,205 @@ +//! Oracle tests: for the throwaway wallet and the real `beta-solver` quotes in +//! `fixtures/oracle.json` (captured by `oracle/capture.mjs` from `@arkade-os/swap` +//! 0.1.0-rc.34), the Rust claim key, preimage, payment hash and lockup addresses equal what the TS +//! package computed, and equal the solver's own `lockup_address`. + +use super::bolt11; +use super::contract::*; +use super::preimage::*; +use super::wire::*; +use ark_core::ArkAddress; +use bitcoin::bip32::DerivationPath; +use bitcoin::bip32::Xpriv; +use bitcoin::key::Secp256k1; +use bitcoin::Network; +use serde_json::Value; +use std::str::FromStr; + +fn fixture() -> Value { + serde_json::from_str(include_str!("fixtures/oracle.json")).unwrap() +} + +/// `m/86'/0'/1'/0/0`: the TS identity key under Bitkit's Arkade account. +fn identity(mnemonic: &str) -> bitcoin::secp256k1::Keypair { + let seed = bip39::Mnemonic::parse(mnemonic).unwrap().to_seed(""); + let secp = Secp256k1::new(); + let xpriv = Xpriv::new_master(Network::Bitcoin, &seed) + .unwrap() + .derive_priv(&secp, &DerivationPath::from_str("m/86'/0'/1'/0/0").unwrap()) + .unwrap(); + xpriv.to_keypair(&secp) +} + +fn terms(f: &Value) -> Terms { + Terms { + operator: f["terms"]["operatorPubkey"] + .as_str() + .unwrap() + .parse() + .unwrap(), + emulator: emulator_pubkey(Network::Bitcoin).unwrap(), + claim_delay: unilateral_claim_delay( + f["server"]["unilateralExitDelay"] + .as_str() + .unwrap() + .parse() + .unwrap(), + ) + .unwrap(), + network: Network::Bitcoin, + } +} + +#[test] +fn terms_match_ts() { + let f = fixture(); + let t = terms(&f); + assert_eq!( + t.claim_delay as u64, + f["terms"]["claimDelay"].as_u64().unwrap() + ); + assert_eq!( + t.emulator.to_string(), + f["terms"]["emulatorPubkey"].as_str().unwrap() + ); + // ark-rs parses the operator's 605184 s exit delay into a seconds Sequence; Terms reads it back. + let seq = ark_core::server::parse_sequence_number(605184).unwrap(); + assert_eq!((seq.to_consensus_u32() & 0xffff) * 512, 605184); +} + +#[test] +fn receive_secrets_match_ts() { + let f = fixture(); + let r = &f["receive"]; + let kp = identity(f["mnemonic"].as_str().unwrap()); + assert_eq!( + kp.x_only_public_key().0.to_string(), + r["claimPubkey"].as_str().unwrap(), + "claim key" + ); + assert_eq!(r["perArtifactDescriptor"], Value::Bool(false)); + let salt = hex_32(r["preimageSalt"].as_str().unwrap(), "salt").unwrap(); + let xonly = kp.x_only_public_key().0.serialize(); + assert_eq!( + hex::encode(salted_preimage_message(&xonly, &salt)), + r["preimageMessage"].as_str().unwrap() + ); + let preimage = salted_preimage(&kp, &salt); + assert_eq!( + hex::encode(preimage), + r["preimage"].as_str().unwrap(), + "preimage" + ); + assert_eq!( + hex::encode(payment_hash(&preimage)), + r["paymentHash"].as_str().unwrap(), + "payment hash" + ); +} + +#[test] +fn receive_lockup_matches_ts_and_solver() { + let f = fixture(); + let r = &f["receive"]; + let quote: RfqQuote = serde_json::from_value(r["quote"].clone()).unwrap(); + let kp = identity(f["mnemonic"].as_str().unwrap()); + let payout = ArkAddress::decode(r["payoutAddress"].as_str().unwrap()).unwrap(); + let lockup = derive_receive( + "e, + r["paymentHash"].as_str().unwrap(), + kp.x_only_public_key().0, + &payout, + &terms(&f), + ) + .unwrap(); + assert_eq!( + lockup.address.encode(), + r["lockupAddress"].as_str().unwrap(), + "TS address" + ); + assert_eq!( + lockup.address.encode(), + quote.profile["lockup_address"].as_str().unwrap(), + "solver address" + ); + assert!(!lockup.params.legacy_pre_timelocked_refund); + assert_eq!(lockup.script.tapscripts().len(), 9); + // Rebuilding from the persisted params gives the same lockup. + assert_eq!( + lockup.params.build().unwrap().address(Network::Bitcoin), + lockup.address + ); + + let invoice = bolt11::decode(&lockup.invoice).unwrap(); + let now = f["capturedAt"].as_u64().unwrap(); + let deadline = verify_receive( + "e, + 1000, + r["paymentHash"].as_str().unwrap(), + &invoice, + now, + ) + .unwrap(); + assert_eq!(deadline, invoice.expires_at.min(quote.valid_until)); + // A different hash, or a different amount, is refused. + assert!(verify_receive("e, 1000, &"00".repeat(32), &invoice, now).is_err()); + assert!(verify_receive( + "e, + 999, + r["paymentHash"].as_str().unwrap(), + &invoice, + now + ) + .is_err()); + assert!(verify_receive( + "e, + 1000, + r["paymentHash"].as_str().unwrap(), + &invoice, + deadline + ) + .is_err()); +} + +#[test] +fn send_lockup_matches_ts_and_solver() { + let f = fixture(); + let s = &f["send"]; + let quote: RfqQuote = serde_json::from_value(s["quote"].clone()).unwrap(); + let invoice = bolt11::decode(s["request"]["profile"]["invoice"].as_str().unwrap()).unwrap(); + let kp = identity(f["mnemonic"].as_str().unwrap()); + assert_eq!( + kp.x_only_public_key().0.to_string(), + s["refund"]["pubkey"].as_str().unwrap() + ); + let refund = ArkAddress::decode(s["refund"]["address"].as_str().unwrap()).unwrap(); + let now = f["capturedAt"].as_u64().unwrap(); + let lockup = derive_send( + "e, + &invoice.payment_hash, + kp.x_only_public_key().0, + &refund, + &terms(&f), + now, + ) + .unwrap(); + assert_eq!( + lockup.address.encode(), + s["lockupAddress"].as_str().unwrap(), + "TS address" + ); + assert_eq!( + lockup.address.encode(), + quote.profile["lockup_address"].as_str().unwrap(), + "solver address" + ); + // The legacy eight-leaf shape is a different address, which TS computes the same. + let mut legacy = lockup.params.clone(); + legacy.legacy_pre_timelocked_refund = true; + assert_eq!( + legacy.build().unwrap().address(Network::Bitcoin).encode(), + s["lockupAddressLegacy"].as_str().unwrap() + ); + verify_send("e, &invoice, now).unwrap(); + assert_eq!(quote.give().unwrap() - quote.take().unwrap(), 4); +} diff --git a/src/modules/arkade/intents/preimage.rs b/src/modules/arkade/intents/preimage.rs new file mode 100644 index 0000000..b7bafd0 --- /dev/null +++ b/src/modules/arkade/intents/preimage.rs @@ -0,0 +1,51 @@ +//! The receive preimage, derived from the wallet key exactly as `@arkade-os/sdk` 0.5.0-rc.31's +//! `provisionClaimSecret` does, so a restored wallet re-derives every swap's `P`. +//! +//! Bitkit's TS wallet runs in the default `walletMode: "auto"` (static), whose descriptor is the +//! bare identity key `tr()`. That takes the salted arm: a fresh public 32-byte salt per +//! swap, persisted in the clear with the record, and +//! +//! ```text +//! P = sha256(bip340_sign(sha256(TAG || xonly || salt), key, aux_rand = 0^32)) +//! ``` +//! +//! with `TAG = "Arkade-Contract-Preimage-Salted-v1"`. The HD arm (`"Arkade-RFQ-Preimage-v1"`, +//! `xonly || u32le(0)` over a fresh HD child) applies only to `walletMode: "hd"` and is not used. + +use bitcoin::hashes::sha256; +use bitcoin::hashes::Hash; +use bitcoin::key::Secp256k1; +use bitcoin::secp256k1::Keypair; +use bitcoin::secp256k1::Message; + +pub const ARKADE_SWAP_PREIMAGE_TAG: &str = "Arkade-RFQ-Preimage-v1"; +pub const ARKADE_SALTED_PREIMAGE_TAG: &str = "Arkade-Contract-Preimage-Salted-v1"; + +/// `TAG || xonly(32) || salt(32)`, the message whose hash gets signed. +pub fn salted_preimage_message(xonly: &[u8; 32], salt: &[u8; 32]) -> Vec { + let mut message = ARKADE_SALTED_PREIMAGE_TAG.as_bytes().to_vec(); + message.extend_from_slice(xonly); + message.extend_from_slice(salt); + message +} + +/// `sha256(sign_det(sha256(message)))`: BIP-340 with all-zero aux randomness, so reproducible. +pub fn preimage_from_message(keypair: &Keypair, message: &[u8]) -> [u8; 32] { + let digest = sha256::Hash::hash(message).to_byte_array(); + let signature = Secp256k1::new().sign_schnorr_with_aux_rand( + &Message::from_digest(digest), + keypair, + &[0u8; 32], + ); + sha256::Hash::hash(signature.as_ref()).to_byte_array() +} + +/// The swap's `P` from the claim key and its salt. +pub fn salted_preimage(keypair: &Keypair, salt: &[u8; 32]) -> [u8; 32] { + let xonly = keypair.x_only_public_key().0.serialize(); + preimage_from_message(keypair, &salted_preimage_message(&xonly, salt)) +} + +pub fn payment_hash(preimage: &[u8; 32]) -> [u8; 32] { + sha256::Hash::hash(preimage).to_byte_array() +} diff --git a/src/modules/arkade/intents/spend.rs b/src/modules/arkade/intents/spend.rs new file mode 100644 index 0000000..96c564c --- /dev/null +++ b/src/modules/arkade/intents/spend.rs @@ -0,0 +1,207 @@ +//! Spending a lockup offchain: the receive claim (collaborative `claim` leaf: preimage, receiver +//! and server) and the send refund (`refundWithoutReceiver`: sender and server after the CLTV). +//! Built like `ark-client`'s Boltz `claim_vhtlc` / `refund_expired_vhtlc`, on `ark-core`. + +use super::error::Error; +use super::error::Result; +use super::vhtlc::VhtlcV2; +use super::wallet::ArkWallet; +use ark_core::send::build_offchain_transactions; +use ark_core::send::sign_ark_transaction; +use ark_core::send::sign_checkpoint_transaction; +use ark_core::send::OffchainTransactions; +use ark_core::send::SendReceiver; +use ark_core::send::VtxoInput; +use ark_core::server::Info; +use ark_core::server::VirtualTxOutPoint; +use ark_core::ArkAddress; +use ark_core::VTXO_CONDITION_KEY; +use bitcoin::absolute::LockTime; +use bitcoin::consensus::Decodable; +use bitcoin::consensus::Encodable; +use bitcoin::hashes::sha256; +use bitcoin::hashes::Hash; +use bitcoin::key::Secp256k1; +use bitcoin::psbt; +use bitcoin::secp256k1::schnorr; +use bitcoin::secp256k1::Keypair; +use bitcoin::secp256k1::Message; +use bitcoin::Amount; +use bitcoin::Psbt; +use bitcoin::Txid; +use bitcoin::VarInt; +use bitcoin::XOnlyPublicKey; + +/// PSBT proprietary-ish key type arkd reads the condition witness from. +const CONDITION_KEY_TYPE: u8 = 222; +/// At most this many lockup outputs per spend; the rest go in the next pass. +pub const MAX_LOCKUP_SPEND_INPUTS: usize = 16; + +/// Which leaf a spend takes. +pub enum Leaf { + /// Receive: the trader's claim, revealing `P`. + Claim { preimage: [u8; 32] }, + /// Send: the trader's refund after `refund_locktime`. + RefundWithoutReceiver, +} + +/// Spend `vtxos` of `lockup` to `destination` in one offchain transaction; returns the Ark txid. +pub async fn spend_lockup( + wallet: &W, + info: &Info, + lockup: &VhtlcV2, + vtxos: &[VirtualTxOutPoint], + keypair: &Keypair, + leaf: Leaf, + destination: &ArkAddress, +) -> Result { + if vtxos.is_empty() { + return Err(Error::Other("nothing to spend".into())); + } + let (script, locktime, preimage) = match &leaf { + Leaf::Claim { preimage } => (lockup.claim_script().clone(), None, Some(*preimage)), + Leaf::RefundWithoutReceiver => ( + lockup.refund_without_receiver_script().clone(), + Some(LockTime::from_consensus(lockup.options.refund_locktime)), + None, + ), + }; + let control_block = lockup + .control_block(&script) + .ok_or_else(|| Error::Other("leaf missing from the lockup tree".into()))?; + let inputs: Vec = vtxos + .iter() + .map(|v| { + VtxoInput::new( + script.clone(), + locktime, + control_block.clone(), + lockup.tapscripts(), + lockup.script_pubkey(), + v.amount, + v.outpoint, + v.assets.clone(), + ) + }) + .collect(); + let total: Amount = vtxos.iter().map(|v| v.amount).sum(); + let OffchainTransactions { + mut ark_tx, + checkpoint_txs, + } = build_offchain_transactions( + &[SendReceiver::bitcoin(*destination, total)], + destination, + &inputs, + info, + ) + .map_err(|e| Error::Other(format!("build offchain tx: {e}")))?; + + let sign = |input: &mut psbt::Input, msg: Message| { + if let Some(preimage) = preimage { + attach_condition_witness(input, &preimage); + } + let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, keypair); + Ok::, ark_core::Error>(vec![( + sig, + keypair.x_only_public_key().0, + )]) + }; + for index in 0..ark_tx.inputs.len() { + sign_ark_transaction(sign, &mut ark_tx, index) + .map_err(|e| Error::Other(format!("sign ark tx: {e}")))?; + } + let ark_txid = ark_tx.unsigned_tx.compute_txid(); + let mut checkpoints = wallet.submit_offchain(ark_tx, checkpoint_txs).await?; + for checkpoint in &mut checkpoints { + sign_checkpoint_transaction(sign, checkpoint) + .map_err(|e| Error::Other(format!("sign checkpoint: {e}")))?; + } + wallet.finalize_offchain(ark_txid, checkpoints).await?; + Ok(ark_txid) +} + +/// The preimage as the condition witness: one element, `P`. +fn attach_condition_witness(input: &mut psbt::Input, preimage: &[u8; 32]) { + let mut bytes = vec![1u8]; + VarInt(preimage.len() as u64) + .consensus_encode(&mut bytes) + .expect("vec write"); + bytes.extend_from_slice(preimage); + input.unknown.insert( + psbt::raw::Key { + type_value: CONDITION_KEY_TYPE, + key: VTXO_CONDITION_KEY.to_vec(), + }, + bytes, + ); +} + +/// Find a 32-byte witness element hashing to `payment_hash` in a spending transaction: in a +/// condition witness or a final witness. This is how a send learns the solver's claim settled. +pub fn preimage_in(tx: &Psbt, payment_hash: &[u8; 32]) -> Option<[u8; 32]> { + let matches = |item: &[u8]| -> Option<[u8; 32]> { + let p: [u8; 32] = item.try_into().ok()?; + (sha256::Hash::hash(&p).to_byte_array() == *payment_hash).then_some(p) + }; + for input in &tx.inputs { + for (key, value) in &input.unknown { + if key.type_value != CONDITION_KEY_TYPE || key.key != VTXO_CONDITION_KEY { + continue; + } + let mut cursor = value.as_slice(); + let Ok(count) = VarInt::consensus_decode(&mut cursor) else { + continue; + }; + for _ in 0..count.0 { + let Ok(len) = VarInt::consensus_decode(&mut cursor) else { + break; + }; + let len = len.0 as usize; + if cursor.len() < len { + break; + } + if let Some(p) = matches(&cursor[..len]) { + return Some(p); + } + cursor = &cursor[len..]; + } + } + if let Some(witness) = &input.final_script_witness { + if let Some(p) = witness.iter().find_map(matches) { + return Some(p); + } + } + } + None +} + +/// The live outputs of a lockup: unspent and still spendable offchain. +pub fn live(vtxos: &[VirtualTxOutPoint], dust: Amount) -> Vec { + vtxos + .iter() + .filter(|v| !v.is_spent && !v.is_unrolled && !v.is_recoverable(dust)) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn condition_witness_round_trips() { + let preimage = [7u8; 32]; + let hash = sha256::Hash::hash(&preimage).to_byte_array(); + let tx = bitcoin::Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn::default()], + output: vec![], + }; + let mut psbt = Psbt::from_unsigned_tx(tx).unwrap(); + assert_eq!(preimage_in(&psbt, &hash), None); + attach_condition_witness(&mut psbt.inputs[0], &preimage); + assert_eq!(preimage_in(&psbt, &hash), Some(preimage)); + assert_eq!(preimage_in(&psbt, &[0; 32]), None); + } +} diff --git a/src/modules/arkade/intents/store.rs b/src/modules/arkade/intents/store.rs new file mode 100644 index 0000000..968cb6f --- /dev/null +++ b/src/modules/arkade/intents/store.rs @@ -0,0 +1,231 @@ +//! Swap records and where they live. A record holds everything needed to finish a swap after a +//! restart or a restore (the lockup's parameters and, on a receive, the public preimage salt); +//! never a private key or a receive preimage. `export`/`import` carry the whole set as one JSON +//! document, for the app's backup. + +use super::contract::LockupParams; +use super::error::Error; +use super::error::Result; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Mutex; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Direction { + Receive, + Send, +} + +/// The TS client's `Outcome` vocabulary, trader-centric. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Outcome { + /// Persisted, and its funding has not been broadcast (send). + Accepted, + /// Waiting on the counterparty: the hold invoice is out and unpaid (receive). + Open, + /// The trader's lockup is funded (send), or the solver's is and a claim is pending (receive). + Funded, + /// Receive: the trader's claim landed. + Claimed, + /// Send: the solver claimed with the preimage, so the invoice was paid. + Paid, + /// Send: the lockup came back to the trader. + Refunded, + /// Receive: never claimed; the solver took the lockup back and the payment never arrived. + Lapsed, + /// Surfaced, never terminal; `blocked_reason` says why. + NeedsRecovery, + /// An action failed and its window closed. + Failed, +} + +impl Outcome { + pub fn is_terminal(self) -> bool { + matches!( + self, + Outcome::Claimed + | Outcome::Paid + | Outcome::Refunded + | Outcome::Lapsed + | Outcome::Failed + ) + } + + pub fn as_str(self) -> &'static str { + match self { + Outcome::Accepted => "accepted", + Outcome::Open => "open", + Outcome::Funded => "funded", + Outcome::Claimed => "claimed", + Outcome::Paid => "paid", + Outcome::Refunded => "refunded", + Outcome::Lapsed => "lapsed", + Outcome::NeedsRecovery => "needs_recovery", + Outcome::Failed => "failed", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SwapRecord { + // The app's `ArkadeSwap` shape. + pub id: String, + pub direction: Direction, + pub outcome: Outcome, + pub give_sat: u64, + pub take_sat: u64, + pub fee_sat: u64, + pub bolt11: String, + pub payment_hash: String, + pub created_at: u64, + pub updated_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blocked_reason: Option, + /// Receive: the last moment the invoice can be paid. Send: the quote's `valid_until`. + pub expires_at: u64, + + // What finishing the swap needs. + pub solver: String, + pub rfq_id: String, + pub quote: serde_json::Value, + pub lockup: LockupParams, + pub lockup_address: String, + /// Receive: the public salt `P` re-derives from with the identity key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preimage_salt: Option, + /// Send: the preimage read off the solver's claim, once observed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preimage: Option, + /// Receive: where the claim pays. + pub payout_address: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub funding_txid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claim_txid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refund_txid: Option, +} + +pub trait SwapStore: Send + Sync { + fn put(&self, record: &SwapRecord) -> Result<()>; + fn get(&self, id: &str) -> Result>; + fn all(&self) -> Result>; + + /// Every record as one JSON document. + fn export(&self) -> Result { + let all = self.all()?; + serde_json::to_string(&Backup { + version: 1, + swaps: all, + }) + .map_err(|e| Error::Store(e.to_string())) + } + + /// Merge a backup in: unknown records are added, known ones kept unless the backup's copy is + /// newer. Returns how many were written. + fn import(&self, json: &str) -> Result { + let backup: Backup = serde_json::from_str(json).map_err(|e| Error::Store(e.to_string()))?; + let mut written = 0; + for record in backup.swaps { + let newer = match self.get(&record.id)? { + Some(held) => record.updated_at > held.updated_at, + None => true, + }; + if newer { + self.put(&record)?; + written += 1; + } + } + Ok(written) + } +} + +#[derive(Serialize, Deserialize)] +struct Backup { + version: u32, + swaps: Vec, +} + +/// In-memory, optionally mirrored to a JSON file written atomically on every change. +pub struct JsonStore { + records: Mutex>, + path: Option, +} + +impl JsonStore { + pub fn memory() -> Self { + Self { + records: Mutex::new(BTreeMap::new()), + path: None, + } + } + + /// Open (or create) the file at `path`. + pub fn open(path: impl Into) -> Result { + let path = path.into(); + let records = match std::fs::read_to_string(&path) { + Ok(json) => serde_json::from_str::(&json) + .map_err(|e| Error::Store(format!("{}: {e}", path.display())))? + .swaps + .into_iter() + .map(|r| (r.id.clone(), r)) + .collect(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(), + Err(e) => return Err(Error::Store(e.to_string())), + }; + Ok(Self { + records: Mutex::new(records), + path: Some(path), + }) + } + + fn flush(&self, records: &BTreeMap) -> Result<()> { + let Some(path) = &self.path else { + return Ok(()); + }; + let json = serde_json::to_vec(&Backup { + version: 1, + swaps: records.values().cloned().collect(), + }) + .map_err(|e| Error::Store(e.to_string()))?; + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, json).map_err(|e| Error::Store(e.to_string()))?; + std::fs::rename(&tmp, path).map_err(|e| Error::Store(e.to_string())) + } +} + +impl SwapStore for JsonStore { + fn put(&self, record: &SwapRecord) -> Result<()> { + let mut records = self.records.lock().expect("store lock"); + let previous = records.insert(record.id.clone(), record.clone()); + if let Err(e) = self.flush(&records) { + // Keep memory and disk in step: a record that is not durable was not stored. + match previous { + Some(p) => records.insert(p.id.clone(), p), + None => records.remove(&record.id), + }; + return Err(e); + } + Ok(()) + } + + fn get(&self, id: &str) -> Result> { + Ok(self.records.lock().expect("store lock").get(id).cloned()) + } + + fn all(&self) -> Result> { + let mut all: Vec<_> = self + .records + .lock() + .expect("store lock") + .values() + .cloned() + .collect(); + all.sort_by_key(|r| std::cmp::Reverse(r.created_at)); + Ok(all) + } +} diff --git a/src/modules/arkade/intents/transport.rs b/src/modules/arkade/intents/transport.rs new file mode 100644 index 0000000..38a76b1 --- /dev/null +++ b/src/modules/arkade/intents/transport.rs @@ -0,0 +1,151 @@ +//! The Nostr RFQ transport (`nostr.ts`): kind-24859 ephemeral events, NIP-44 v2 encrypted to the +//! solver card's `discovery_pubkey`, from a fresh transport key per negotiation so it is not +//! linkable to the wallet. The reply subscription opens before the request is published. + +use super::error::Error; +use super::error::Result; +use super::wire::expect_quote; +use super::wire::RfqQuote; +use nostr_sdk::nips::nip44; +use nostr_sdk::prelude::*; +use serde_json::Value; +use std::time::Duration; + +/// Directed RFQ traffic; must match the solver's `NOSTR_KIND_DIRECTED`. +pub const RFQ_DIRECTED_KIND: u16 = 24859; +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +/// The solver card's transport facts. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SolverCard { + pub name: String, + /// x-only hex: who requests are addressed and encrypted to, and who must sign replies. + pub discovery_pubkey: String, + pub relays: Vec, +} + +impl SolverCard { + /// `beta-solver`, the mainnet solver Ark Labs pinned (the registry lists none). + pub fn beta_solver() -> Self { + Self { + name: "beta-solver".into(), + discovery_pubkey: "66422c952f8dcb96e4d0c3f049cd1e265b8461b916d9913c65c2494b64b4e3ce" + .into(), + relays: vec!["wss://nostr.arkade.sh".into()], + } + } +} + +#[derive(Debug, Clone)] +pub struct NostrTransport { + pub card: SolverCard, + pub timeout: Duration, +} + +impl NostrTransport { + pub fn new(card: SolverCard) -> Self { + Self { + card, + timeout: DEFAULT_TIMEOUT, + } + } + + /// Send one `rfq_request` and wait for its `rfq_quote` (or `rfq_refusal`). + pub async fn request_quote(&self, request: &Value) -> Result { + let rfq_id = request["rfq_id"].as_str().unwrap_or_default().to_string(); + let pair = request["pair"].as_str().unwrap_or_default().to_string(); + let solver = PublicKey::from_hex(&self.card.discovery_pubkey) + .map_err(|e| Error::Transport(format!("bad solver key: {e}")))?; + let keys = Keys::generate(); + let client = Client::new(keys.clone()); + let result = self + .negotiate(&client, &keys, solver, request, &rfq_id) + .await; + client.shutdown().await; + expect_quote(result?, &rfq_id, &pair) + } + + async fn negotiate( + &self, + client: &Client, + keys: &Keys, + solver: PublicKey, + request: &Value, + rfq_id: &str, + ) -> Result { + for relay in &self.card.relays { + client + .add_relay(relay.as_str()) + .await + .map_err(|e| Error::Transport(format!("relay {relay}: {e}")))?; + } + client.connect().await; + client.wait_for_connection(Duration::from_secs(10)).await; + + let mut notifications = client.notifications(); + let filter = Filter::new() + .kind(Kind::Custom(RFQ_DIRECTED_KIND)) + .pubkey(keys.public_key()) + .author(solver); + client + .subscribe(filter, None) + .await + .map_err(|e| Error::Transport(format!("subscribe: {e}")))?; + + let content = nip44::encrypt( + keys.secret_key(), + &solver, + request.to_string(), + nip44::Version::V2, + ) + .map_err(|e| Error::Transport(format!("encrypt: {e}")))?; + let event = EventBuilder::new(Kind::Custom(RFQ_DIRECTED_KIND), content) + .tag(Tag::public_key(solver)) + .sign_with_keys(keys) + .map_err(|e| Error::Transport(format!("sign: {e}")))?; + let sent = client + .send_event(&event) + .await + .map_err(|e| Error::Transport(format!("publish: {e}")))?; + if sent.success.is_empty() { + return Err(Error::Transport(format!( + "no relay accepted the RFQ message: {:?}", + sent.failed + ))); + } + + let wait = async { + loop { + match notifications.recv().await { + Ok(RelayPoolNotification::Event { event, .. }) => { + if event.pubkey != solver + || event.kind != Kind::Custom(RFQ_DIRECTED_KIND) + || event.verify().is_err() + { + continue; + } + let Ok(plain) = nip44::decrypt(keys.secret_key(), &solver, &event.content) + else { + continue; + }; + let Ok(payload) = serde_json::from_str::(&plain) else { + continue; + }; + if payload.get("rfq_id").and_then(Value::as_str) == Some(rfq_id) { + return Ok(payload); + } + } + Ok(RelayPoolNotification::Shutdown) => { + return Err(Error::Transport("relay pool shut down".into())) + } + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} + Err(e) => return Err(Error::Transport(e.to_string())), + } + } + }; + tokio::time::timeout(self.timeout, wait) + .await + .map_err(|_| Error::Timeout(self.timeout.as_secs()))? + } +} diff --git a/src/modules/arkade/intents/vhtlc.rs b/src/modules/arkade/intents/vhtlc.rs new file mode 100644 index 0000000..683f92a --- /dev/null +++ b/src/modules/arkade/intents/vhtlc.rs @@ -0,0 +1,483 @@ +//! VHTLC v2 with the emulator covenant suite: the lockup both Lightning corridors compile. +//! +//! Ported from arkade-os/rust-sdk#287 (`feat/vhtlc-covenant-leaves`, commit `eb8548d`), which +//! mirrors `VHTLC.ScriptV2` with `nonInteractiveParameters` in `@arkade-os/sdk` 0.5.0-rc.31. Kept +//! here rather than taken as a git dependency so the crate links the same `ark-core =0.12.0` the +//! wallet binding does. Drop it for `ark_core::vhtlc::VhtlcScript::new_v2` once that ships. +//! +//! Leaf order fixes the merkle root: the six signature leaves, then the covenant suite +//! (`nonInteractiveClaim`, `nonInteractiveRefund`, and, unless legacy, the timelocked +//! `nonInteractiveRefundWithoutReceiver`). The tree is assembled with btcd's algorithm, as arkd +//! and the TS SDK do. + +use ark_core::ArkAddress; +use bitcoin::hashes::ripemd160; +use bitcoin::hashes::Hash; +use bitcoin::key::Secp256k1; +use bitcoin::opcodes::all::*; +use bitcoin::opcodes::Opcode; +use bitcoin::taproot::ControlBlock; +use bitcoin::taproot::LeafVersion; +use bitcoin::taproot::NodeInfo; +use bitcoin::taproot::TaprootSpendInfo; +use bitcoin::Network; +use bitcoin::PublicKey; +use bitcoin::ScriptBuf; +use bitcoin::Sequence; +use bitcoin::XOnlyPublicKey; +use std::collections::VecDeque; +use std::str::FromStr; + +#[derive(Debug, thiserror::Error)] +pub enum VhtlcError { + #[error("invalid P2TR script for {0}: expected `OP_1 <32 bytes>`")] + InvalidP2trScript(&'static str), + #[error("invalid timelock: {0}")] + InvalidTimelock(String), + #[error("covenant key derivation failed: {0}")] + Covenant(String), + #[error("taproot construction failed: {0}")] + Taproot(String), +} + +/// The emulator covenant leaves, all or nothing (`VHTLC.Options.nonInteractiveParameters`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NonInteractiveParameters { + pub emulator_pubkey: XOnlyPublicKey, + /// Where the claim covenant pays: the receiver's P2TR pkScript. + pub receiver_pk_script: ScriptBuf, + /// Where both refund covenants pay: the sender's P2TR pkScript. + pub sender_pk_script: ScriptBuf, + /// Rebuild a lockup funded before the timelocked refund leaf shipped (eight leaves). + pub legacy_pre_timelocked_refund: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VhtlcOptions { + pub sender: XOnlyPublicKey, + pub receiver: XOnlyPublicKey, + pub server: XOnlyPublicKey, + pub preimage_hash: ripemd160::Hash, + pub refund_locktime: u32, + pub unilateral_claim_delay: Sequence, + pub unilateral_refund_delay: Sequence, + pub unilateral_refund_without_receiver_delay: Sequence, +} + +/// A built nine-leaf (or eight-leaf legacy) VHTLC v2. +#[derive(Debug, Clone)] +pub struct VhtlcV2 { + pub options: VhtlcOptions, + pub covenant: NonInteractiveParameters, + leaves: Leaves, + spend_info: TaprootSpendInfo, +} + +#[derive(Debug, Clone)] +struct Leaves { + claim: ScriptBuf, + refund: ScriptBuf, + refund_without_receiver: ScriptBuf, + unilateral_claim: ScriptBuf, + unilateral_refund: ScriptBuf, + unilateral_refund_without_receiver: ScriptBuf, + non_interactive_claim: ScriptBuf, + non_interactive_claim_arkade_script: ScriptBuf, + non_interactive_refund: ScriptBuf, + non_interactive_refund_arkade_script: ScriptBuf, + non_interactive_refund_without_receiver: Option, +} + +fn is_p2tr(script: &ScriptBuf) -> bool { + let b = script.as_bytes(); + b.len() == 34 && b[0] == 0x51 && b[1] == 0x20 +} + +fn check_delay(name: &str, delay: Sequence) -> Result<(), VhtlcError> { + if !delay.is_relative_lock_time() || delay.to_consensus_u32() & 0xffff == 0 { + return Err(VhtlcError::InvalidTimelock(format!( + "{name} must be a non-zero CSV relative lock time" + ))); + } + Ok(()) +} + +/// `enforcePayTo`: the output at this input's index is P2TR, pays the destination program, and +/// carries at least the input's value. Opcodes 0xc9..0xd1 are Arkade introspection opcodes, +/// executed by the emulator. +fn enforce_pay_to(destination: &ScriptBuf) -> ScriptBuf { + let program: &[u8; 32] = destination.as_bytes()[2..] + .try_into() + .expect("validated 34-byte P2TR script"); + ScriptBuf::builder() + .push_opcode(Opcode::from(0xcd)) // PUSHCURRENTINPUTINDEX + .push_opcode(OP_DUP) + .push_opcode(Opcode::from(0xd1)) // INSPECTOUTPUTSCRIPTPUBKEY + .push_int(1) + .push_opcode(OP_EQUALVERIFY) + .push_slice(program) + .push_opcode(OP_EQUALVERIFY) + .push_opcode(Opcode::from(0xcf)) // INSPECTOUTPUTVALUE + .push_opcode(Opcode::from(0xcd)) // PUSHCURRENTINPUTINDEX + .push_opcode(Opcode::from(0xc9)) // INSPECTINPUTVALUE + .push_opcode(OP_GREATERTHANOREQUAL) + .into_script() +} + +impl VhtlcOptions { + /// `OP_SIZE 32 OP_EQUALVERIFY OP_HASH160 OP_EQUAL` (the v2 preimage condition). + fn condition(&self) -> bitcoin::script::Builder { + ScriptBuf::builder() + .push_opcode(OP_SIZE) + .push_int(32) + .push_opcode(OP_EQUALVERIFY) + .push_opcode(OP_HASH160) + .push_slice(self.preimage_hash.as_byte_array()) + .push_opcode(OP_EQUAL) + } + + fn csv(builder: bitcoin::script::Builder, delay: Sequence) -> bitcoin::script::Builder { + builder + .push_int(delay.to_consensus_u32() as i64) + .push_opcode(OP_CSV) + .push_opcode(OP_DROP) + } +} + +impl VhtlcV2 { + pub fn new( + options: VhtlcOptions, + covenant: NonInteractiveParameters, + ) -> Result { + if options.refund_locktime == 0 { + return Err(VhtlcError::InvalidTimelock( + "refund locktime must be > 0".into(), + )); + } + check_delay("unilateral claim delay", options.unilateral_claim_delay)?; + check_delay("unilateral refund delay", options.unilateral_refund_delay)?; + check_delay( + "unilateral refund without receiver delay", + options.unilateral_refund_without_receiver_delay, + )?; + if !is_p2tr(&covenant.receiver_pk_script) { + return Err(VhtlcError::InvalidP2trScript("receiver_pk_script")); + } + if !is_p2tr(&covenant.sender_pk_script) { + return Err(VhtlcError::InvalidP2trScript("sender_pk_script")); + } + let o = &options; + + let claim = o + .condition() + .push_opcode(OP_VERIFY) + .push_x_only_key(&o.receiver) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&o.server) + .push_opcode(OP_CHECKSIG) + .into_script(); + let refund = ScriptBuf::builder() + .push_x_only_key(&o.sender) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&o.receiver) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&o.server) + .push_opcode(OP_CHECKSIG) + .into_script(); + let refund_without_receiver = ScriptBuf::builder() + .push_int(o.refund_locktime as i64) + .push_opcode(OP_CLTV) + .push_opcode(OP_DROP) + .push_x_only_key(&o.sender) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&o.server) + .push_opcode(OP_CHECKSIG) + .into_script(); + let unilateral_claim = VhtlcOptions::csv( + o.condition().push_opcode(OP_VERIFY), + o.unilateral_claim_delay, + ) + .push_x_only_key(&o.receiver) + .push_opcode(OP_CHECKSIG) + .into_script(); + let unilateral_refund = VhtlcOptions::csv(ScriptBuf::builder(), o.unilateral_refund_delay) + .push_x_only_key(&o.sender) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&o.receiver) + .push_opcode(OP_CHECKSIG) + .into_script(); + let unilateral_refund_without_receiver = VhtlcOptions::csv( + ScriptBuf::builder(), + o.unilateral_refund_without_receiver_delay, + ) + .push_x_only_key(&o.sender) + .push_opcode(OP_CHECKSIG) + .into_script(); + + let tweak = |script: &ScriptBuf| { + ark_script::tweak::compute_arkade_script_public_key(&covenant.emulator_pubkey, script) + .map_err(|e| VhtlcError::Covenant(format!("{e:?}"))) + }; + let nic_script = enforce_pay_to(&covenant.receiver_pk_script); + let nic_cosigner = tweak(&nic_script)?; + let non_interactive_claim = o + .condition() + .push_opcode(OP_VERIFY) + .push_x_only_key(&o.server) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&nic_cosigner) + .push_opcode(OP_CHECKSIG) + .into_script(); + let nir_script = enforce_pay_to(&covenant.sender_pk_script); + let nir_cosigner = tweak(&nir_script)?; + let non_interactive_refund = ScriptBuf::builder() + .push_x_only_key(&o.server) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&o.receiver) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&nir_cosigner) + .push_opcode(OP_CHECKSIG) + .into_script(); + let non_interactive_refund_without_receiver = (!covenant.legacy_pre_timelocked_refund) + .then(|| { + ScriptBuf::builder() + .push_int(o.refund_locktime as i64) + .push_opcode(OP_CLTV) + .push_opcode(OP_DROP) + .push_x_only_key(&o.server) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&nir_cosigner) + .push_opcode(OP_CHECKSIG) + .into_script() + }); + + let leaves = Leaves { + claim, + refund, + refund_without_receiver, + unilateral_claim, + unilateral_refund, + unilateral_refund_without_receiver, + non_interactive_claim, + non_interactive_claim_arkade_script: nic_script, + non_interactive_refund, + non_interactive_refund_arkade_script: nir_script, + non_interactive_refund_without_receiver, + }; + let spend_info = build_taproot(&leaves.ordered())?; + Ok(Self { + options, + covenant, + leaves, + spend_info, + }) + } + + /// Every leaf, in the order the merkle root depends on. + pub fn tapscripts(&self) -> Vec { + self.leaves.ordered() + } + + pub fn spend_info(&self) -> &TaprootSpendInfo { + &self.spend_info + } + + pub fn script_pubkey(&self) -> ScriptBuf { + ScriptBuf::builder() + .push_opcode(OP_PUSHNUM_1) + .push_slice(self.spend_info.output_key().serialize()) + .into_script() + } + + pub fn address(&self, network: Network) -> ArkAddress { + ArkAddress::new(network, self.options.server, self.spend_info.output_key()) + } + + pub fn claim_script(&self) -> &ScriptBuf { + &self.leaves.claim + } + + pub fn refund_without_receiver_script(&self) -> &ScriptBuf { + &self.leaves.refund_without_receiver + } + + pub fn control_block(&self, leaf: &ScriptBuf) -> Option { + self.spend_info + .control_block(&(leaf.clone(), LeafVersion::TapScript)) + } + + /// Named leaves, for fixtures and debugging. + pub fn named_leaves(&self) -> Vec<(&'static str, ScriptBuf)> { + let l = &self.leaves; + let mut out = vec![ + ("claim", l.claim.clone()), + ("refund", l.refund.clone()), + ("refundWithoutReceiver", l.refund_without_receiver.clone()), + ("unilateralClaim", l.unilateral_claim.clone()), + ("unilateralRefund", l.unilateral_refund.clone()), + ( + "unilateralRefundWithoutReceiver", + l.unilateral_refund_without_receiver.clone(), + ), + ("nonInteractiveClaim", l.non_interactive_claim.clone()), + ("nonInteractiveRefund", l.non_interactive_refund.clone()), + ]; + if let Some(s) = &l.non_interactive_refund_without_receiver { + out.push(("nonInteractiveRefundWithoutReceiver", s.clone())); + } + out + } + + pub fn arkade_scripts(&self) -> (ScriptBuf, ScriptBuf) { + ( + self.leaves.non_interactive_claim_arkade_script.clone(), + self.leaves.non_interactive_refund_arkade_script.clone(), + ) + } +} + +impl Leaves { + fn ordered(&self) -> Vec { + let mut v = vec![ + self.claim.clone(), + self.refund.clone(), + self.refund_without_receiver.clone(), + self.unilateral_claim.clone(), + self.unilateral_refund.clone(), + self.unilateral_refund_without_receiver.clone(), + self.non_interactive_claim.clone(), + self.non_interactive_refund.clone(), + ]; + v.extend(self.non_interactive_refund_without_receiver.clone()); + v + } +} + +fn build_taproot(scripts: &[ScriptBuf]) -> Result { + let internal = XOnlyPublicKey::from( + PublicKey::from_str(ark_core::UNSPENDABLE_KEY) + .map_err(|e| VhtlcError::Taproot(e.to_string()))?, + ); + let tree = assemble_taproot_tree(scripts)?; + Ok(TaprootSpendInfo::from_node_info( + &Secp256k1::new(), + internal, + tree, + )) +} + +/// btcd's `AssembleTaprootScriptTree`: pair leaves left to right (an odd trailing leaf joins the +/// last pair), then FIFO-merge branches until one root remains. Private in `ark-core` 0.12.0. +fn assemble_taproot_tree(scripts: &[ScriptBuf]) -> Result { + let leaf = |s: &ScriptBuf| NodeInfo::new_leaf_with_ver(s.clone(), LeafVersion::TapScript); + let combine = |a: NodeInfo, b: NodeInfo| { + NodeInfo::combine(a, b).map_err(|e| VhtlcError::Taproot(e.to_string())) + }; + match scripts.len() { + 0 => return Err(VhtlcError::Taproot("no scripts".into())), + 1 => return Ok(leaf(&scripts[0])), + _ => {} + } + let mut branches: Vec = Vec::new(); + let mut i = 0; + while i < scripts.len() { + if i == scripts.len() - 1 { + let last = branches.pop().expect("a branch precedes a trailing leaf"); + branches.push(combine(last, leaf(&scripts[i]))?); + } else { + branches.push(combine(leaf(&scripts[i]), leaf(&scripts[i + 1]))?); + } + i += 2; + } + let mut queue: VecDeque = branches.into(); + while queue.len() >= 2 { + let left = queue.pop_front().expect("two branches"); + let right = queue.pop_front().expect("two branches"); + queue.push_back(combine(left, right)?); + } + Ok(queue.pop_front().expect("one root")) +} + +/// BIP68 seconds-typed relative lock time; `seconds` must be a multiple of 512. +pub fn seconds_sequence(seconds: u32) -> Result { + if seconds == 0 || !seconds.is_multiple_of(512) || seconds / 512 > 0xffff { + return Err(VhtlcError::InvalidTimelock(format!( + "{seconds}s is not a positive BIP68 multiple of 512" + ))); + } + Sequence::from_seconds_floor(seconds).map_err(|e| VhtlcError::InvalidTimelock(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::hex::DisplayHex; + use bitcoin::secp256k1::Keypair; + use bitcoin::secp256k1::SecretKey; + + fn key(fill: u8) -> XOnlyPublicKey { + let sk = SecretKey::from_slice(&[fill; 32]).unwrap(); + Keypair::from_secret_key(&Secp256k1::new(), &sk) + .x_only_public_key() + .0 + } + + fn p2tr(fill: u8) -> ScriptBuf { + ScriptBuf::new_p2tr_tweaked(bitcoin::key::TweakedPublicKey::dangerous_assume_tweaked( + key(fill), + )) + } + + /// The cross-SDK nine-leaf fixture from rust-sdk#287, generated by `VHTLC.ScriptV2` (blocks + /// delays; the corridor uses seconds, which the oracle test covers). + #[test] + fn nine_leaf_fixture() { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("fixtures/vhtlc-v2-nine-leaf.json")).unwrap(); + let options = VhtlcOptions { + sender: key(1), + receiver: key(2), + server: key(3), + preimage_hash: ripemd160::Hash::from_byte_array([9; 20]), + refund_locktime: 1000, + unilateral_claim_delay: Sequence::from_height(100), + unilateral_refund_delay: Sequence::from_height(102), + unilateral_refund_without_receiver_delay: Sequence::from_height(103), + }; + let v = VhtlcV2::new( + options, + NonInteractiveParameters { + emulator_pubkey: key(5), + receiver_pk_script: p2tr(4), + sender_pk_script: p2tr(6), + legacy_pre_timelocked_refund: false, + }, + ) + .unwrap(); + for (name, script) in v.named_leaves() { + assert_eq!( + script.as_bytes().to_lower_hex_string(), + fixture["leaves"][name].as_str().unwrap(), + "leaf {name}" + ); + } + let (nic, nir) = v.arkade_scripts(); + assert_eq!( + nic.as_bytes().to_lower_hex_string(), + fixture["arkadeScripts"]["nonInteractiveClaim"] + .as_str() + .unwrap() + ); + assert_eq!( + nir.as_bytes().to_lower_hex_string(), + fixture["arkadeScripts"]["nonInteractiveRefund"] + .as_str() + .unwrap() + ); + assert_eq!( + v.script_pubkey().as_bytes().to_lower_hex_string(), + fixture["pkScript"].as_str().unwrap() + ); + assert_eq!(v.tapscripts().len(), 9); + } +} diff --git a/src/modules/arkade/intents/wallet.rs b/src/modules/arkade/intents/wallet.rs new file mode 100644 index 0000000..5408f56 --- /dev/null +++ b/src/modules/arkade/intents/wallet.rs @@ -0,0 +1,50 @@ +//! The wallet behind the client: Arkade network I/O and the identity key. The binding implements +//! it on its ark-client `Client`; every spend is built and signed here, so the trait carries no +//! swap logic. + +use super::error::WalletError; +use ark_core::server::Info; +use ark_core::server::VirtualTxOutPoint; +use ark_core::ArkAddress; +use async_trait::async_trait; +use bitcoin::secp256k1::Keypair; +use bitcoin::Amount; +use bitcoin::Psbt; +use bitcoin::Txid; + +#[async_trait] +pub trait ArkWallet: Send + Sync { + /// The operator's terms (`ark_client::Client::server_info`). + async fn server_info(&self) -> Result; + + /// The wallet's offchain receive address: receive payouts and send refunds land here. + async fn address(&self) -> Result; + + /// The identity key, `m/86'/c'/1'/0/0`: the TS wallet's static key. It is the receive claim + /// key (its preimages derive from it) and the send refund key. + fn identity_keypair(&self) -> Result; + + /// Every VTXO, spent or not, at an address (`get_virtual_tx_outpoints`). + async fn vtxos(&self, address: &ArkAddress) -> Result, WalletError>; + + /// A virtual transaction by id (the indexer's `GetVirtualTxs`), `None` when unknown. Used to + /// read the preimage out of the solver's claim of a send lockup. + async fn virtual_tx(&self, txid: Txid) -> Result, WalletError>; + + /// `submit_offchain_transaction_request`; returns the server-signed checkpoints. + async fn submit_offchain( + &self, + ark_tx: Psbt, + checkpoints: Vec, + ) -> Result, WalletError>; + + /// `finalize_offchain_transaction`. + async fn finalize_offchain( + &self, + ark_txid: Txid, + checkpoints: Vec, + ) -> Result<(), WalletError>; + + /// Pay `amount` from the wallet's own VTXOs to `address` (funds a send lockup). + async fn send(&self, address: &ArkAddress, amount: Amount) -> Result; +} diff --git a/src/modules/arkade/intents/wire.rs b/src/modules/arkade/intents/wire.rs new file mode 100644 index 0000000..73f6c87 --- /dev/null +++ b/src/modules/arkade/intents/wire.rs @@ -0,0 +1,263 @@ +//! RFQ v1 messages, matching `@arkade-os/swap` 0.1.0-rc.34 byte for byte on the way out. +//! +//! Requests are exact: the solver's schema is `.strict()` and refuses any undeclared key (or a +//! `null`) as `unsupported_payload`. Replies are read leniently, as the TS client does: unknown +//! fields are kept, and amounts read as a JSON number or a canonical decimal string. + +use super::error::Error; +use super::error::Result; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Map; +use serde_json::Value; + +pub const LIGHTNING_RECEIVE_PAIR: &str = "lightning:BTC->arkade:BTC"; +pub const LIGHTNING_SEND_PAIR: &str = "arkade:BTC->lightning:BTC"; + +#[derive(Debug, Clone, Serialize)] +pub struct RfqRequest

{ + pub v: u8, + #[serde(rename = "type")] + pub kind: &'static str, + pub rfq_id: String, + pub pair: &'static str, + pub amount_side: &'static str, + /// Canonical decimal string of sats; absent on a Lightning send (the invoice fixes it). + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option, + pub profile: P, +} + +/// `lightning:BTC->arkade:BTC`. No `claim_packet`: no covclaimd is deployed for this corridor +/// and the solver refuses an empty one. +#[derive(Debug, Clone, Serialize)] +pub struct ReceiveProfile { + pub payment_hash: String, + pub payout_address: String, + pub payout_pubkey: String, +} + +/// `arkade:BTC->lightning:BTC`. +#[derive(Debug, Clone, Serialize)] +pub struct SendProfile { + pub invoice: String, + pub refund_address: String, + pub client_refund_pubkey: String, +} + +pub fn receive_request(rfq_id: &str, amount_sat: u64, profile: ReceiveProfile) -> Value { + serde_json::to_value(RfqRequest { + v: 1, + kind: "rfq_request", + rfq_id: rfq_id.to_string(), + pair: LIGHTNING_RECEIVE_PAIR, + // `amountOn: "give"`: the payer pays exactly `amount`. + amount_side: "from", + amount: Some(amount_sat.to_string()), + profile, + }) + .expect("serializable") +} + +pub fn send_request(rfq_id: &str, profile: SendProfile) -> Value { + serde_json::to_value(RfqRequest { + v: 1, + kind: "rfq_request", + rfq_id: rfq_id.to_string(), + pair: LIGHTNING_SEND_PAIR, + amount_side: "to", + amount: None, + profile, + }) + .expect("serializable") +} + +/// A sats amount on the wire: a JSON number or a canonical decimal string. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum WireAmount { + Number(u64), + String(String), +} + +impl WireAmount { + pub fn sats(&self, field: &'static str) -> Result { + match self { + WireAmount::Number(n) => Ok(*n), + WireAmount::String(s) => { + let canonical = !s.is_empty() + && s.len() <= 30 + && s.bytes().all(|b| b.is_ascii_digit()) + && (s == "0" || !s.starts_with('0')); + if !canonical { + return Err(Error::verification(field, "a canonical decimal amount", s)); + } + s.parse() + .map_err(|_| Error::verification(field, "an amount in sats", s)) + } + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RfqQuote { + pub v: u8, + #[serde(rename = "type")] + pub kind: String, + pub rfq_id: String, + pub pair: String, + pub from_amount: WireAmount, + pub to_amount: WireAmount, + pub solver_pubkey: String, + pub valid_until: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refund_locktime: Option, + #[serde(default)] + pub profile: Map, + #[serde(flatten)] + pub extra: Map, +} + +impl RfqQuote { + pub fn give(&self) -> Result { + self.from_amount.sats("from_amount") + } + + pub fn take(&self) -> Result { + self.to_amount.sats("to_amount") + } + + pub fn profile_str(&self, field: &'static str) -> Result<&str> { + self.profile + .get(field) + .and_then(Value::as_str) + .ok_or_else(|| Error::verification("profile", format!("profile.{field}"), "missing")) + } + + pub fn profile_u64(&self, field: &'static str) -> Result { + self.profile + .get(field) + .and_then(Value::as_u64) + .ok_or_else(|| Error::verification("profile", format!("profile.{field}"), "missing")) + } +} + +/// `expectQuote`: a refusal becomes [`Error::Refused`]; anything not a quote for THIS rfq_id and +/// pair is an error. +pub fn expect_quote(payload: Value, rfq_id: &str, pair: &str) -> Result { + let kind = payload + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + if kind == "rfq_refusal" { + let reason = payload + .get("reason") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + let error_code = payload + .get("error_code") + .and_then(Value::as_str) + .map(str::to_string); + let mut detail = Map::new(); + for field in ["field", "actual", "expected", "limit", "unit"] { + if let Some(v) = payload.get(field) { + detail.insert(field.into(), v.clone()); + } + } + return Err(Error::Refused { + reason, + error_code, + detail: Value::Object(detail), + }); + } + if kind != "rfq_quote" || payload.get("rfq_id").and_then(Value::as_str) != Some(rfq_id) { + return Err(Error::Transport(format!("unexpected reply: {kind}"))); + } + if payload.get("pair").and_then(Value::as_str) != Some(pair) { + return Err(Error::verification( + "pair", + pair, + payload.get("pair").cloned().unwrap_or(Value::Null), + )); + } + serde_json::from_value(payload).map_err(|e| Error::Transport(format!("malformed quote: {e}"))) +} + +pub fn new_rfq_id() -> String { + let bytes: [u8; 32] = rand::random(); + hex::encode(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn requests_match_the_ts_wire() { + let fixture: Value = serde_json::from_str(include_str!("fixtures/oracle.json")).unwrap(); + let ts = &fixture["receive"]["request"]; + let ours = receive_request( + ts["rfq_id"].as_str().unwrap(), + fixture["receive"]["amount"].as_u64().unwrap(), + ReceiveProfile { + payment_hash: ts["profile"]["payment_hash"].as_str().unwrap().into(), + payout_address: ts["profile"]["payout_address"].as_str().unwrap().into(), + payout_pubkey: ts["profile"]["payout_pubkey"].as_str().unwrap().into(), + }, + ); + assert_eq!( + serde_json::to_string(&ours).unwrap(), + serde_json::to_string(ts).unwrap() + ); + + let ts = &fixture["send"]["request"]; + let ours = send_request( + ts["rfq_id"].as_str().unwrap(), + SendProfile { + invoice: ts["profile"]["invoice"].as_str().unwrap().into(), + refund_address: ts["profile"]["refund_address"].as_str().unwrap().into(), + client_refund_pubkey: ts["profile"]["client_refund_pubkey"] + .as_str() + .unwrap() + .into(), + }, + ); + assert_eq!( + serde_json::to_string(&ours).unwrap(), + serde_json::to_string(ts).unwrap() + ); + } + + #[test] + fn reads_quotes_and_refusals() { + let fixture: Value = serde_json::from_str(include_str!("fixtures/oracle.json")).unwrap(); + let wire = fixture["receive"]["quote"].clone(); + let id = wire["rfq_id"].as_str().unwrap().to_string(); + let quote = expect_quote(wire.clone(), &id, LIGHTNING_RECEIVE_PAIR).unwrap(); + assert_eq!(quote.give().unwrap(), 1000); + assert!(expect_quote(wire.clone(), "other", LIGHTNING_RECEIVE_PAIR).is_err()); + assert!(expect_quote(wire, &id, LIGHTNING_SEND_PAIR).is_err()); + + let mut string_amounts = fixture["receive"]["quote"].clone(); + string_amounts["from_amount"] = "1000".into(); + assert_eq!( + expect_quote(string_amounts, &id, LIGHTNING_RECEIVE_PAIR) + .unwrap() + .give() + .unwrap(), + 1000 + ); + + let refusal = serde_json::json!({"v":1,"type":"rfq_refusal","rfq_id":id,"reason":"amount_out_of_range","error_code":"amount_below_min","limit":500,"unit":"sats"}); + match expect_quote(refusal, &id, LIGHTNING_RECEIVE_PAIR) { + Err(Error::Refused { + reason, error_code, .. + }) => { + assert_eq!(reason, "amount_out_of_range"); + assert_eq!(error_code.as_deref(), Some("amount_below_min")); + } + other => panic!("{other:?}"), + } + } +} diff --git a/src/modules/arkade/keys.rs b/src/modules/arkade/keys.rs new file mode 100644 index 0000000..8b9e9e9 --- /dev/null +++ b/src/modules/arkade/keys.rs @@ -0,0 +1,67 @@ +//! Keys from the Bitkit seed: BIP86 account 1 (`m/86'/0'/1'` on mainnet, `m/86'/1'/1'` elsewhere), receive keys +//! under `/0/*`, as the bridge's `tr([fp/86'/c'/1']xpub/0/*)` descriptor gives the TypeScript `SeedIdentity`. +//! Savings' Taproot keys use account 0, so the two wallets never share a key. + +use super::errors::ArkadeError; +use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv}; +use bitcoin::key::{Keypair, Secp256k1}; +use bitcoin::Network; + +/// The BIP86 account Arkade uses; Savings uses account 0. +pub const ARK_ACCOUNT: u32 = 1; + +pub struct Keys { + pub master: Xpriv, + pub network: Network, + /// `m/86'/c'/1'`. + pub account_path: DerivationPath, + /// `m/86'/c'/1'/0`: the base the HD key provider appends receive indices to. + pub receive_path: DerivationPath, + /// `m/86'/c'/1'/0/0`, the TypeScript identity's own key: it signs the LNURL session token and is the + /// registered claim key, whatever receive index the wallet has rotated to. + pub identity: Keypair, +} + +impl Keys { + pub fn from_mnemonic( + mnemonic: &str, + passphrase: Option<&str>, + network: Network, + ) -> Result { + let mnemonic = bip39::Mnemonic::parse_normalized(mnemonic.trim()) + .map_err(|e| ArkadeError::invalid(format!("invalid mnemonic: {e}")))?; + let seed = mnemonic.to_seed(passphrase.unwrap_or("")); + let master = Xpriv::new_master(network, &seed).map_err(ArkadeError::internal)?; + let coin = if network == Network::Bitcoin { 0 } else { 1 }; + let account_path: DerivationPath = vec![ + ChildNumber::from_hardened_idx(86).map_err(ArkadeError::internal)?, + ChildNumber::from_hardened_idx(coin).map_err(ArkadeError::internal)?, + ChildNumber::from_hardened_idx(ARK_ACCOUNT).map_err(ArkadeError::internal)?, + ] + .into(); + let receive_path = + account_path.child(ChildNumber::from_normal_idx(0).map_err(ArkadeError::internal)?); + let identity_path = + receive_path.child(ChildNumber::from_normal_idx(0).map_err(ArkadeError::internal)?); + let secp = Secp256k1::new(); + let identity = master + .derive_priv(&secp, &identity_path) + .map_err(ArkadeError::internal)? + .to_keypair(&secp); + Ok(Self { + master, + network, + account_path, + receive_path, + identity, + }) + } + + /// The account-level extended key. The on-chain wallet ark-client requires is built from it, so its BDK + /// BIP86 template lands under Arkade's account instead of on Savings' `m/86'/c'/0'`. + pub fn account_xpriv(&self) -> Result { + self.master + .derive_priv(&Secp256k1::new(), &self.account_path) + .map_err(ArkadeError::internal) + } +} diff --git a/src/modules/arkade/listener.rs b/src/modules/arkade/listener.rs new file mode 100644 index 0000000..7cb790a --- /dev/null +++ b/src/modules/arkade/listener.rs @@ -0,0 +1,71 @@ +use super::types::{ArkadeBalance, ArkadeHistoryEntry, ArkadeSwap}; +use super::wallet::Wallet; +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// How often history is read for the listener, as the TypeScript bridge polls it. +const HISTORY_EVERY: Duration = Duration::from_secs(30); + +/// Callback interface for Arkade wallet events. +/// +/// Implement this in Swift/Kotlin/Python and register it via `arkade_set_listener` to hear of history entries +/// that are new, or newly settled, since the last pass: offline Lightning receives the LNURL server claimed, +/// payments to the Arkade address, boarded deposits and settled offboards; and of each Lightning swap whose +/// outcome changed. +#[uniffi::export(with_foreign)] +pub trait ArkadeEventListener: Send + Sync { + fn on_history(&self, entries: Vec, balance: ArkadeBalance); + + fn on_swap(&self, swap: ArkadeSwap); +} + +pub(crate) static LISTENER: Mutex>> = Mutex::new(None); + +pub fn set_listener(listener: Option>) { + if let Ok(mut l) = LISTENER.lock() { + *l = listener; + } +} + +pub(crate) fn listener() -> Option> { + LISTENER.lock().ok().and_then(|l| l.clone()) +} + +/// The bridge's `pollHistory`: entries new or newly settled since the last pass go to the listener. +pub(crate) async fn watch_history(wallet: Arc) { + let mut seen: Option> = None; + let mut every = tokio::time::interval(HISTORY_EVERY); + every.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + every.tick().await; + let entries = match wallet.history().await { + Ok(entries) => entries, + Err(e) => { + log::warn!("Arkade history poll failed: {e}"); + continue; + } + }; + let keys: HashSet = entries + .iter() + .map(|e| format!("{}:{}", e.key, e.settled)) + .collect(); + if let Some(seen) = seen.as_ref() { + let fresh: Vec<_> = entries + .into_iter() + .filter(|e| !seen.contains(&format!("{}:{}", e.key, e.settled))) + .collect(); + if let (false, Some(listener)) = (fresh.is_empty(), listener()) { + match wallet.balance().await { + Ok(balance) => listener.on_history(fresh, balance), + Err(e) => { + // Keep the entries unseen, so the next pass reports them. + log::warn!("Arkade balance for history event failed: {e}"); + continue; + } + } + } + } + seen = Some(keys); + } +} diff --git a/src/modules/arkade/lnurl.rs b/src/modules/arkade/lnurl.rs new file mode 100644 index 0000000..17bceef --- /dev/null +++ b/src/modules/arkade/lnurl.rs @@ -0,0 +1,296 @@ +//! The wallet's Lightning address on Ark Labs' hosted lnurl-server, ported from `@arkade-os/lnurl-client` +//! (`arkadeLnurl(...).owned` and `.claim`, `deriveSessionTokenForIdentity`, `claimPublicKeyOf`, `encodeLnurl`). +//! +//! The server knows a wallet only by its session token, an ECDSA signature by the identity key, so a wallet +//! restored from the same seed derives the same token and finds its address again. + +use super::errors::ArkadeError; +use bech32::{Bech32, Hrp}; +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::key::{Keypair, Secp256k1}; +use bitcoin::secp256k1::Message; +use reqwest::{Response, Url}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +const MESSAGE_PREFIX: &str = "lnurl-session:"; + +/// `deriveSessionTokenForIdentity(identity, domain)`: hex SHA-256 of the compact RFC 6979 ECDSA signature over +/// SHA-256(`lnurl-session:` + domain). The TypeScript client signs twice to prove the signer deterministic; +/// RFC 6979 is, so one signature here is the same bytes. +pub fn session_token(identity: &Keypair, domain: &str) -> Result { + let domain = domain.trim().to_lowercase(); + if domain.is_empty() { + return Err(ArkadeError::invalid( + "a session token must be bound to a domain", + )); + } + let digest = sha256::Hash::hash(format!("{MESSAGE_PREFIX}{domain}").as_bytes()); + let signature = Secp256k1::new().sign_ecdsa( + &Message::from_digest(digest.to_byte_array()), + &identity.secret_key(), + ); + Ok(hex::encode( + sha256::Hash::hash(&signature.serialize_compact()).to_byte_array(), + )) +} + +/// `claimPublicKeyOf(identity)`: the identity key, compressed, as hex. +pub fn claim_public_key(identity: &Keypair) -> String { + hex::encode(identity.public_key().serialize()) +} + +/// `deriveSessionId(token)`: the first 32 hex chars of SHA-256 of the token bytes. +pub fn session_id(token: &str) -> Result { + let bytes = hex::decode(token).map_err(ArkadeError::internal)?; + Ok(hex::encode(sha256::Hash::hash(&bytes).to_byte_array())[..32].to_string()) +} + +/// `encodeLnurl(url)`: bech32 with the `lnurl` prefix, uppercased. +pub fn encode_lnurl(url: &str) -> Result { + let hrp = Hrp::parse("lnurl").map_err(ArkadeError::internal)?; + let encoded = bech32::encode::(hrp, url.as_bytes()).map_err(ArkadeError::internal)?; + Ok(encoded.to_uppercase()) +} + +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct ArkadeLightningAddress { + pub handle: String, + pub lightning_address: Option, + pub lnurl: String, +} + +/// Where the server pays this wallet: what `arkadeLnurl` reads from the wallet as its destinations. +pub struct Destinations { + pub arkade_address: String, + pub boarding_address: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListedAddress { + username: Option, + handle: Option, + domain: String, + status: String, + lightning_address: Option, + session_lnurl: Option, + arkade_address: Option, + claim_public_key: Option, + boarding_address: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RegisteredAddress { + username: Option, + handle: Option, + lightning_address: Option, + session_lnurl: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct IdentityBinding<'a> { + arkade_address: &'a str, + claim_public_key: &'a str, + boarding_address: &'a str, + domain: &'a str, +} + +pub struct LnurlService { + base_url: String, + domain: String, + token: String, + claim_public_key: String, + http: reqwest::Client, + /// The receiver once found or claimed; also the lock that serialises claims, since the server allows one + /// address per wallet. + receiver: tokio::sync::Mutex>, +} + +impl LnurlService { + pub fn new(base_url: &str, identity: &Keypair) -> Result { + let base_url = base_url.trim_end_matches('/').to_string(); + let domain = Url::parse(&base_url) + .map_err(|e| ArkadeError::invalid(format!("invalid LNURL server URL: {e}")))? + .host_str() + .ok_or_else(|| ArkadeError::invalid("LNURL server URL has no host"))? + .to_lowercase(); + Ok(Self { + token: session_token(identity, &domain)?, + claim_public_key: claim_public_key(identity), + base_url, + domain, + http: reqwest::Client::new(), + receiver: tokio::sync::Mutex::new(None), + }) + } + + /// The bridge's `lightningAddress()`: the address this identity owns, bound again to the current + /// destinations, or else a newly claimed random one. A claim that loses a race falls back to the address the + /// other claim registered. + pub async fn lightning_address( + &self, + to: &Destinations, + ) -> Result { + let mut receiver = self.receiver.lock().await; + if let Some(r) = receiver.as_ref() { + return Ok(r.clone()); + } + let found = match self.owned(to).await? { + Some(r) => r, + None => match self.claim(to).await { + Ok(r) => r, + Err(e) => self.owned(to).await?.ok_or(e)?, + }, + }; + *receiver = Some(found.clone()); + Ok(found) + } + + /// `owned({ refresh: true })`. + async fn owned( + &self, + to: &Destinations, + ) -> Result, ArkadeError> { + let listed: Vec = read( + self.http + .get(format!("{}/lnurl/address", self.base_url)) + .bearer_auth(&self.token) + .send() + .await, + ) + .await?; + let active: Vec = listed + .into_iter() + .filter(|a| a.domain.eq_ignore_ascii_case(&self.domain) && a.status == "active") + .collect(); + let mut refused = None; + for a in &active { + let bound = a.arkade_address.as_deref() == Some(to.arkade_address.as_str()) + && a.claim_public_key.as_deref() == Some(self.claim_public_key.as_str()) + && a.boarding_address.as_deref() == Some(to.boarding_address.as_str()); + if bound { + continue; + } + if let Err(e) = self.bind(handle_of(&a.handle, &a.username)?, to).await { + refused.get_or_insert(e); + } + } + if let Some(e) = refused { + return Err(e); + } + let Some(chosen) = active + .iter() + .find(|a| a.session_lnurl.is_some()) + .or(active.first()) + else { + return Ok(None); + }; + let handle = handle_of(&chosen.handle, &chosen.username)?; + Ok(Some(self.receiver_from( + handle, + chosen.lightning_address.clone(), + chosen.session_lnurl.is_some(), + )?)) + } + + /// `claim({})`: register a random address, then bind this identity to it. + async fn claim(&self, to: &Destinations) -> Result { + let body = serde_json::json!({ "token": self.token, "domain": self.domain }); + let registered: RegisteredAddress = read( + self.http + .post(format!("{}/lnurl/address", self.base_url)) + .json(&body) + .send() + .await, + ) + .await?; + let handle = handle_of(®istered.handle, ®istered.username)?; + self.bind(handle, to).await?; + self.receiver_from( + handle, + registered.lightning_address, + registered.session_lnurl.is_some(), + ) + } + + /// `registerArkadeIdentity`: where the server pays offline receives, and the key that may claim them. + async fn bind(&self, handle: &str, to: &Destinations) -> Result<(), ArkadeError> { + let mut url = Url::parse(&self.base_url).map_err(ArkadeError::internal)?; + url.path_segments_mut() + .map_err(|_| ArkadeError::internal("LNURL server URL cannot take a path"))? + .extend(["lnurl", "address", handle, "arkade"]); + let body = IdentityBinding { + arkade_address: &to.arkade_address, + claim_public_key: &self.claim_public_key, + boarding_address: &to.boarding_address, + domain: &self.domain, + }; + let _: Value = read( + self.http + .post(url) + .bearer_auth(&self.token) + .json(&body) + .send() + .await, + ) + .await?; + Ok(()) + } + + fn receiver_from( + &self, + handle: &str, + lightning_address: Option, + nameless: bool, + ) -> Result { + let url = if nameless { + format!("{}/lnurl/{}", self.base_url, session_id(&self.token)?) + } else { + format!("{}/.well-known/lnurlp/{handle}", self.base_url) + }; + Ok(ArkadeLightningAddress { + handle: handle.to_string(), + lightning_address, + lnurl: encode_lnurl(&url)?, + }) + } +} + +fn handle_of<'a>( + handle: &'a Option, + username: &'a Option, +) -> Result<&'a str, ArkadeError> { + handle + .as_deref() + .or(username.as_deref()) + .ok_or_else(|| ArkadeError::lnurl("address has no handle")) +} + +/// `apiFetch`: the JSON body, or the server's `error` (or LUD `reason`) for a refused request. +async fn read( + response: reqwest::Result, +) -> Result { + let response = response.map_err(ArkadeError::network)?; + let status = response.status(); + let body: Value = response.json().await.unwrap_or(Value::Null); + let reason = match ( + body.get("status").and_then(Value::as_str), + body.get("reason").and_then(Value::as_str), + ) { + (Some("ERROR"), Some(reason)) => Some(reason.to_string()), + _ => body + .get("error") + .and_then(Value::as_str) + .map(str::to_string), + }; + if let Some(reason) = reason { + return Err(ArkadeError::lnurl(reason)); + } + if !status.is_success() { + return Err(ArkadeError::lnurl(status)); + } + serde_json::from_value(body).map_err(ArkadeError::lnurl) +} diff --git a/src/modules/arkade/mod.rs b/src/modules/arkade/mod.rs new file mode 100644 index 0000000..516f6af --- /dev/null +++ b/src/modules/arkade/mod.rs @@ -0,0 +1,221 @@ +//! Arkade: the Spending wallet's Arkade balance (Ark Labs' `rust-sdk`, ark-client 0.12) and its Lightning address +//! (Ark Labs' lnurl-server). It answers with the method surface of the apps' former TypeScript bridge +//! (`bridge.js`), minus the Lightning swaps, which come with the Arkade Intents client. +//! +//! One wallet runs at a time. Keys come from the Bitkit seed on BIP86 account 1 (`m/86'/0'/1'`), so there is no +//! new phrase and no key shared with Savings (account 0). + +mod errors; +mod esplora; +pub mod intents; +pub mod keys; +mod listener; +pub mod lnurl; +#[cfg(test)] +#[allow(clippy::print_stdout)] +mod regtest_tests; +mod store; +#[cfg(test)] +mod tests; +mod types; +mod wallet; + +pub use errors::ArkadeError; +pub use listener::{set_listener, ArkadeEventListener}; +pub use lnurl::ArkadeLightningAddress; +pub use types::*; + +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex as TokioMutex; +use tokio::task::JoinHandle; +use wallet::Wallet; + +/// How often the chores board confirmed deposits and renew outputs near expiry. +const CHORES_EVERY: Duration = Duration::from_secs(60); +/// How often open Lightning swaps are driven: funded receives claimed, expired sends refunded. +const DRIVE_EVERY: Duration = Duration::from_secs(10); + +type Swaps = intents::IntentsClient; + +struct Running { + wallet: Arc, + /// `None` where no solver serves Lightning swaps (mutinynet, or a regtest stack without one). + swaps: Option>, + tasks: Vec>, +} + +impl Running { + fn stop(self) { + self.tasks.iter().for_each(JoinHandle::abort); + self.wallet.stop(); + } +} + +static RUNNING: TokioMutex> = TokioMutex::const_new(None); + +pub(crate) fn install_crypto_provider() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +/// Starts the wallet, or returns its info when it already runs. Must run on a multi-threaded tokio runtime: +/// it spawns the chores and the history watch. +pub async fn start(params: ArkadeStartParams) -> Result { + install_crypto_provider(); + let mut running = RUNNING.lock().await; + if let Some(r) = running.as_ref() { + return r.wallet.info().await; + } + let wallet = Wallet::start(¶ms).await?; + let info = wallet.info().await?; + let mut tasks = vec![ + tokio::spawn(chores(wallet.clone())), + tokio::spawn(listener::watch_history(wallet.clone())), + ]; + let swaps = match params.network.config().solver { + Some(solver) => { + let store = Arc::new(store::SqliteSwaps::open(Path::new(¶ms.data_dir))?); + let client = Arc::new(Swaps::new(wallet.clone(), store, solver)); + client.on_update(Arc::new(|record: &intents::SwapRecord| { + if let Some(listener) = listener::listener() { + listener.on_swap(ArkadeSwap::from(record)); + } + })); + tasks.push(client.drive(DRIVE_EVERY)); + Some(client) + } + None => None, + }; + *running = Some(Running { + wallet, + swaps, + tasks, + }); + Ok(info) +} + +/// Stops the wallet and its background tasks; its database stays. +pub async fn stop() { + if let Some(r) = RUNNING.lock().await.take() { + r.stop(); + } +} + +async fn wallet() -> Result, ArkadeError> { + RUNNING + .lock() + .await + .as_ref() + .map(|r| r.wallet.clone()) + .ok_or(ArkadeError::NotStarted) +} + +async fn swaps_client() -> Result, ArkadeError> { + let running = RUNNING.lock().await; + let running = running.as_ref().ok_or(ArkadeError::NotStarted)?; + running + .swaps + .clone() + .ok_or_else(|| ArkadeError::invalid("Lightning swaps are not available on this network")) +} + +/// An invoice for exactly `amount_sat`: the payer pays it, the wallet gets it less the solver's fee. The swap is +/// stored before the invoice is returned; its claim happens on the drive loop once the solver funds it. +pub async fn receive(amount_sat: u64) -> Result { + Ok(ArkadeSwap::from( + &swaps_client().await?.receive(amount_sat).await?, + )) +} + +/// What paying `bolt11` would cost. +pub async fn quote_pay(bolt11: String) -> Result { + Ok(swaps_client().await?.quote_pay(&bolt11).await?.into()) +} + +/// Funds the lockup for `bolt11`, refusing a fee above `max_fee_sat`; the outcome arrives as a swap event. +pub async fn pay(bolt11: String, max_fee_sat: Option) -> Result { + Ok(ArkadeSwap::from( + &swaps_client().await?.pay(&bolt11, max_fee_sat).await?, + )) +} + +/// Every Lightning swap, newest first. +pub async fn swaps() -> Result, ArkadeError> { + Ok(swaps_client() + .await? + .swaps()? + .iter() + .map(ArkadeSwap::from) + .collect()) +} + +pub async fn info() -> Result { + wallet().await?.info().await +} + +pub async fn balance() -> Result { + wallet().await?.balance().await +} + +pub async fn vtxos() -> Result, ArkadeError> { + wallet().await?.vtxos().await +} + +pub async fn history() -> Result, ArkadeError> { + wallet().await?.history().await +} + +pub async fn settle() -> Result, ArkadeError> { + wallet().await?.settle().await +} + +pub async fn renew() -> Result, ArkadeError> { + wallet().await?.renew().await +} + +pub async fn offboard_fee() -> Result { + wallet().await?.fees().await +} + +pub async fn offboard(address: String, amount_sat: Option) -> Result { + wallet().await?.offboard(&address, amount_sat).await +} + +pub async fn send_ark(address: String, amount_sat: u64) -> Result { + wallet().await?.send_ark(&address, amount_sat).await +} + +pub async fn delegate_info() -> Result { + Ok(wallet().await?.delegate) +} + +pub async fn lightning_address() -> Result { + wallet().await?.lightning_address().await +} + +pub fn export_state(data_dir: &str) -> Result { + store::export(Path::new(data_dir)) +} + +pub fn import_state(data_dir: &str, json: &str) -> Result { + store::import(Path::new(data_dir), json) +} + +/// Stops the wallet and deletes its database. +pub async fn wipe(data_dir: &str) -> Result<(), ArkadeError> { + stop().await; + store::wipe(Path::new(data_dir)) +} + +async fn chores(wallet: Arc) { + let mut every = tokio::time::interval(CHORES_EVERY); + every.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + every.tick().await; + wallet.chores().await; + } +} diff --git a/src/modules/arkade/oracle/oracle.mjs b/src/modules/arkade/oracle/oracle.mjs new file mode 100644 index 0000000..1d760ab --- /dev/null +++ b/src/modules/arkade/oracle/oracle.mjs @@ -0,0 +1,110 @@ +// Oracle for the crate's unit tests: runs Ark Labs' TypeScript SDK (the same packages the Bitkit bridge +// bundles) for one throwaway mnemonic on mainnet and prints the values the Rust crate must reproduce. +// +// ARKADE_WORKSPACE=

ORACLE_MNEMONIC="..." node src/modules/arkade/oracle/oracle.mjs \ +// > src/modules/arkade/fixtures/oracle.json +// +// ARKADE_WORKSPACE holds `bridge/` (the built Bitkit bridge with its node_modules) and `pwtest/` (playwright-core); +// the script drives the bridge bundle in headless Chromium. Moves no money: it starts the wallet, reads `info` and +// claims or finds the identity's Lightning address. +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +if (!process.env.ARKADE_WORKSPACE) throw new Error("set ARKADE_WORKSPACE"); +const root = new URL(`file://${process.env.ARKADE_WORKSPACE.replace(/\/?$/, "/")}`); +const mod = (p) => import(fileURLToPath(new URL(p, root))); +const { MnemonicIdentity } = await mod("bridge/node_modules/@arkade-os/sdk/dist/index.js"); +const { deriveSessionTokenForIdentity, claimPublicKeyOf, encodeLnurl } = await mod( + "bridge/node_modules/@arkade-os/lnurl-client/dist/arkade.js", +); +const { HDKey } = await mod("bridge/node_modules/@scure/bip32/index.js"); +const { mnemonicToSeedSync, generateMnemonic } = await mod("bridge/node_modules/@scure/bip39/index.js"); +const { wordlist } = await mod("bridge/node_modules/@scure/bip39/wordlists/english.js"); +const { chromium } = await mod("pwtest/node_modules/playwright-core/index.mjs"); + +const ARK = "https://arkade.computer"; +const DELEGATE = "https://delegate.arkade.money"; +const LNURL = "https://lnurl.arkade.sh"; +const hex = (b) => Buffer.from(b).toString("hex"); + +const mnemonic = process.env.ORACLE_MNEMONIC ?? generateMnemonic(wordlist); + +// The bridge's descriptorFor(): BIP86 account 1, receive keys /0/*. +const root32 = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)); +const path = "86'/0'/1'"; +const fingerprint = root32.fingerprint.toString(16).padStart(8, "0"); +const descriptor = `tr([${fingerprint}/${path}]${root32.derive(`m/${path}`).publicExtendedKey}/0/*)`; +const identity = MnemonicIdentity.fromMnemonic(mnemonic, { descriptor }); + +const token = await deriveSessionTokenForIdentity(identity, new URL(LNURL).hostname); +const claimPublicKey = await claimPublicKeyOf(identity); +const xOnlyPublicKey = hex(await identity.xOnlyPublicKey()); + +const serverInfo = await (await fetch(`${ARK}/v1/info`)).json(); +const delegateInfo = await (await fetch(`${DELEGATE}/v1/delegator/info`)).json(); + +// The bridge itself, as the apps run it. +const dist = new URL("bridge/dist/", root); +const origin = "https://appassets.androidplatform.net"; +const browser = await chromium.launch({ + executablePath: + process.env.HOME + + "/Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell", +}); +const page = await (await browser.newContext()).newPage(); +await page.route(`${origin}/**`, (route) => { + const name = new URL(route.request().url()).pathname.split("/").pop() || "index.html"; + route.fulfill({ body: readFileSync(new URL(name, dist)), contentType: name.endsWith(".js") ? "text/javascript" : "text/html" }); +}); +const pending = new Map(); +let seq = 0; +await page.exposeFunction("__native", (json) => { + const msg = JSON.parse(json); + if (msg.id && pending.has(msg.id)) { + pending.get(msg.id)(msg); + pending.delete(msg.id); + } +}); +await page.addInitScript(() => { + window.BitkitArkNative = { postMessage: (s) => window.__native(s) }; +}); +const call = async (method, params = {}) => { + const msg = await new Promise((resolve) => { + const id = `o${++seq}`; + pending.set(id, resolve); + page.evaluate(([id, m, p]) => window.bitkitArk.call(id, m, p), [id, method, JSON.stringify(params)]); + }); + if (!msg.ok) throw new Error(`${method}: ${msg.error}`); + return msg.result; +}; +await page.goto(`${origin}/arkade/index.html`); +const info = await call("start", { mnemonic, network: "bitcoin", arkServerUrl: ARK, delegateUrl: DELEGATE, lnurlUrl: LNURL }); +const address = await call("lightningAddress"); +await browser.close(); + +console.log( + JSON.stringify( + { + mnemonic, + network: "bitcoin", + descriptor, + xOnlyPublicKey, + claimPublicKey, + lnurlDomain: new URL(LNURL).hostname, + token, + lnurlOfHandle: encodeLnurl(`${LNURL}/.well-known/lnurlp/${address.handle}`), + server: { + signerPubkey: serverInfo.signerPubkey, + network: serverInfo.network, + unilateralExitDelay: Number(serverInfo.unilateralExitDelay), + boardingExitDelay: Number(serverInfo.boardingExitDelay), + dust: Number(serverInfo.dust), + }, + delegatePubkey: delegateInfo.pubkey, + info, + lightningAddress: address, + }, + null, + 2, + ), +); diff --git a/src/modules/arkade/regtest_tests.rs b/src/modules/arkade/regtest_tests.rs new file mode 100644 index 0000000..ec3bee8 --- /dev/null +++ b/src/modules/arkade/regtest_tests.rs @@ -0,0 +1,211 @@ +//! The whole module against a local regtest stack (ArkLabsHQ/arkade-regtest, solver in `relay` mode), with test +//! money only: receive into an empty wallet, send, an offline receive to the Lightning address, and a restore. +//! +//! The stack's ports are reached on localhost (7070 arkd, 3000 esplora, 7777 relay, 9090 lnurl-server, 7012 +//! delegate). Two shell commands stand in for the other Lightning node; `{}` is replaced: +//! +//! ARKADE_REGTEST_SOLVER= \ +//! ARKADE_REGTEST_PAY='docker exec ark-lnd-peer lncli --network=regtest payinvoice --force {}' \ +//! ARKADE_REGTEST_INVOICE='docker exec ark-lnd-peer lncli --network=regtest addinvoice --amt {}' \ +//! cargo test --lib modules::arkade::regtest_tests -- --ignored --nocapture --test-threads 1 + +use super::*; +use rand::RngCore; +use serde_json::Value; +use serial_test::serial; +use std::process::{Command, Stdio}; +use std::time::Duration; + +const RECEIVE_SAT: u64 = 20_000; +const SEND_SAT: u64 = 5_000; +const OFFLINE_SAT: u64 = 3_000; +const WAIT: Duration = Duration::from_secs(180); + +fn env(key: &str) -> String { + std::env::var(key).unwrap_or_else(|_| panic!("set {key}")) +} + +fn network() -> ArkadeNetwork { + ArkadeNetwork::Regtest { + endpoints: ArkadeRegtestEndpoints { + ark_server_url: "http://localhost:7070".into(), + esplora_url: "http://localhost:3000/api".into(), + lnurl_url: Some("http://localhost:9090".into()), + delegate_url: Some("http://localhost:7012".into()), + solver_pubkey: Some(env("ARKADE_REGTEST_SOLVER")), + solver_relays: vec!["ws://localhost:7777".into()], + }, + } +} + +fn params( + dir: &tempfile::TempDir, + mnemonic: &str, + restore: bool, + backup: Option, +) -> ArkadeStartParams { + ArkadeStartParams { + data_dir: dir.path().to_string_lossy().into_owned(), + mnemonic: mnemonic.into(), + passphrase: None, + network: network(), + restore, + backup, + } +} + +/// Runs a template command with `{}` replaced; `wait` false leaves it running (a payment blocks until it settles). +fn shell(template_key: &str, arg: &str, wait: bool) -> String { + let cmd = env(template_key).replace("{}", arg); + let mut command = Command::new("sh"); + command.arg("-c").arg(&cmd); + if !wait { + command.stdout(Stdio::null()).stderr(Stdio::null()); + let mut child = command.spawn().unwrap(); + std::thread::spawn(move || child.wait()); + return String::new(); + } + let out = command.output().unwrap(); + assert!( + out.status.success(), + "{cmd}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).unwrap() +} + +async fn wait_for(what: &str, mut check: F) +where + F: FnMut() -> Fut, + Fut: std::future::Future, +{ + let started = std::time::Instant::now(); + while started.elapsed() < WAIT { + if check().await { + println!("{what} after {} s", started.elapsed().as_secs()); + return; + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + panic!("{what}: not within {} s", WAIT.as_secs()); +} + +async fn swap(id: &str) -> ArkadeSwap { + swaps() + .await + .unwrap() + .into_iter() + .find(|s| s.id == id) + .unwrap() +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +#[ignore] +async fn receive_send_offline_receive_and_restore() { + let mut entropy = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut entropy); + let mnemonic = bip39::Mnemonic::from_entropy(&entropy).unwrap().to_string(); + + // Receive into an empty wallet: the payer pays the solver's hold invoice and the wallet claims the lockup. + let first = tempfile::tempdir().unwrap(); + let info = start(params(&first, &mnemonic, false, None)).await.unwrap(); + assert_eq!(info.network, "regtest"); + assert!(info.ark_address.starts_with("tark1")); + assert_eq!(balance().await.unwrap().total, 0); + let received = receive(RECEIVE_SAT).await.unwrap(); + println!( + "receive: invoice for {} sats, fee {}", + received.give_sat, received.fee_sat + ); + shell("ARKADE_REGTEST_PAY", &received.bolt11, false); + wait_for("receive claimed", || async { + swap(&received.id).await.outcome == "claimed" + }) + .await; + wait_for("balance credited", || async { + balance().await.unwrap().available > 0 + }) + .await; + let after_receive = balance().await.unwrap().available; + assert_eq!(after_receive, received.take_sat); + + // Send: the wallet locks funds for the solver, which pays the invoice and claims with its preimage. + let invoice: Value = serde_json::from_str(&shell( + "ARKADE_REGTEST_INVOICE", + &SEND_SAT.to_string(), + true, + )) + .unwrap(); + let bolt11 = invoice["payment_request"].as_str().unwrap().to_string(); + let quote = quote_pay(bolt11.clone()).await.unwrap(); + println!( + "send: {} sats for a {} sat invoice", + quote.give_sat, quote.take_sat + ); + let sent = pay(bolt11, Some(quote.fee_sat)).await.unwrap(); + wait_for("send paid", || async { + swap(&sent.id).await.outcome == "paid" + }) + .await; + let after_send = balance().await.unwrap().available; + assert_eq!(after_send, after_receive - quote.give_sat); + + // Offline receive: with the wallet stopped, a payer pays the Lightning address; lnurl-server claims it for the + // wallet, which finds the output when it starts again. + let address = lightning_address().await.unwrap(); + println!("lightning address: {:?}", address.lightning_address); + let before_stop = super::info().await.unwrap(); + let backup = export_state(first.path().to_str().unwrap()).unwrap(); + stop().await; + let pay_request: Value = reqwest::get(format!( + "http://localhost:9090/.well-known/lnurlp/{}", + address.handle + )) + .await + .unwrap() + .json() + .await + .unwrap(); + // The stack's lnurl-server names its callback by its bare domain, `localhost`, without the published port. + let callback = pay_request["callback"].as_str().unwrap().replacen( + "://localhost/", + "://localhost:9090/", + 1, + ); + let separator = if callback.contains('?') { '&' } else { '?' }; + let invoice: Value = reqwest::get(format!( + "{callback}{separator}amount={}", + OFFLINE_SAT * 1000 + )) + .await + .unwrap() + .json() + .await + .unwrap(); + let offline_bolt11 = invoice["pr"].as_str().unwrap().to_string(); + shell("ARKADE_REGTEST_PAY", &offline_bolt11, false); + + // Restore from the phrase and the backup in a fresh directory: same addresses, the funds, and the offline + // receive. + let second = tempfile::tempdir().unwrap(); + let restored = start(params(&second, &mnemonic, true, Some(backup))) + .await + .unwrap(); + // The wallet hands out a fresh key per receive; a restore finds the used ones and resumes after them. + assert_eq!(restored.ark_address, before_stop.ark_address); + assert_eq!(restored.boarding_address, before_stop.boarding_address); + assert_eq!(lightning_address().await.unwrap(), address); + wait_for("offline receive found", || async { + balance().await.unwrap().available > after_send + }) + .await; + let after_offline = balance().await.unwrap().available; + println!("offline receive: {} sats", after_offline - after_send); + assert!(swaps() + .await + .unwrap() + .iter() + .any(|s| s.id == sent.id && s.outcome == "paid")); + wipe(second.path().to_str().unwrap()).await.unwrap(); +} diff --git a/src/modules/arkade/store.rs b/src/modules/arkade/store.rs new file mode 100644 index 0000000..e5ad82f --- /dev/null +++ b/src/modules/arkade/store.rs @@ -0,0 +1,253 @@ +//! The wallet's lasting state, on bitkit-core's own rusqlite (ark-client's `sqlite` feature would link a second +//! sqlite through sqlx): +//! - every contract (address) the wallet handed out, with its HD key index, which the key provider re-derives at +//! connect; +//! - every Lightning swap, with what finishing it needs (a receive's preimage salt, the lockup's parameters). The +//! seed alone cannot bring an in-flight swap back. +//! +//! VTXOs, boarding outputs and history are read from the operator and the chain. The dump `export` writes is the +//! portable form the app keeps in its VSS backup. + +use super::errors::ArkadeError; +use super::intents::{Error as SwapStoreError, Result as SwapResult, SwapRecord, SwapStore}; +use ark_client::{ContractStore, Error}; +use ark_core::contract::{ContractState, StoredContract}; +use bitcoin::Script; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::Mutex; + +pub const DB_FILE: &str = "arkade.db"; +const DUMP_VERSION: u32 = 1; + +fn open_db(data_dir: &Path) -> Result { + std::fs::create_dir_all(data_dir).map_err(ArkadeError::storage)?; + let connection = Connection::open(data_dir.join(DB_FILE))?; + connection.busy_timeout(std::time::Duration::from_secs(5))?; + connection.execute_batch( + "PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS contracts ( + script_pubkey BLOB PRIMARY KEY NOT NULL, + contract TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS swaps ( + id TEXT PRIMARY KEY NOT NULL, + record TEXT NOT NULL + );", + )?; + Ok(connection) +} + +pub struct SqliteContracts { + connection: Mutex, +} + +impl SqliteContracts { + pub fn open(data_dir: &Path) -> Result { + let connection = open_db(data_dir)?; + Ok(Self { + connection: Mutex::new(connection), + }) + } + + fn with(&self, f: impl FnOnce(&Connection) -> rusqlite::Result) -> Result { + let connection = self + .connection + .lock() + .map_err(|_| Error::consumer("contract store lock poisoned"))?; + f(&connection).map_err(|e| Error::consumer(format!("contract store: {e}"))) + } + + fn put(&self, contract: &StoredContract) -> Result<(), Error> { + let json = serde_json::to_string(contract).map_err(|e| Error::consumer(e.to_string()))?; + self.with(|c| { + c.execute( + "INSERT OR REPLACE INTO contracts (script_pubkey, contract) VALUES (?1, ?2)", + params![contract.script_pubkey.as_bytes(), json], + ) + .map(|_| ()) + }) + } +} + +fn decode(json: String) -> Result { + serde_json::from_str(&json).map_err(|e| Error::consumer(format!("stored contract: {e}"))) +} + +impl ContractStore for SqliteContracts { + fn insert(&mut self, contract: StoredContract) -> Result<(), Error> { + if self.get_by_script(&contract.script_pubkey)?.is_some() { + return Err(Error::consumer("contract script already exists")); + } + self.put(&contract) + } + + fn get_by_script(&self, script_pubkey: &Script) -> Result, Error> { + self.with(|c| { + c.query_row( + "SELECT contract FROM contracts WHERE script_pubkey = ?1", + params![script_pubkey.as_bytes()], + |row| row.get::<_, String>(0), + ) + .optional() + })? + .map(decode) + .transpose() + } + + fn list(&self) -> Result, Error> { + self.with(|c| { + let mut statement = c.prepare("SELECT contract FROM contracts")?; + let rows = statement.query_map([], |row| row.get::<_, String>(0))?; + rows.collect::>>() + })? + .into_iter() + .map(decode) + .collect() + } + + fn update_state(&mut self, script_pubkey: &Script, state: ContractState) -> Result<(), Error> { + let mut contract = self + .get_by_script(script_pubkey)? + .ok_or_else(|| Error::consumer("unknown contract script"))?; + contract.state = state; + self.put(&contract) + } +} + +/// The Lightning swaps' records, in the same database. +pub struct SqliteSwaps { + connection: Mutex, +} + +impl SqliteSwaps { + pub fn open(data_dir: &Path) -> Result { + Ok(Self { + connection: Mutex::new(open_db(data_dir)?), + }) + } + + fn with(&self, f: impl FnOnce(&Connection) -> rusqlite::Result) -> SwapResult { + let connection = self + .connection + .lock() + .map_err(|_| SwapStoreError::Store("swap store lock poisoned".into()))?; + f(&connection).map_err(|e| SwapStoreError::Store(e.to_string())) + } +} + +fn decode_swap(json: String) -> SwapResult { + serde_json::from_str(&json).map_err(|e| SwapStoreError::Store(format!("stored swap: {e}"))) +} + +impl SwapStore for SqliteSwaps { + fn put(&self, record: &SwapRecord) -> SwapResult<()> { + let json = + serde_json::to_string(record).map_err(|e| SwapStoreError::Store(e.to_string()))?; + self.with(|c| { + c.execute( + "INSERT OR REPLACE INTO swaps (id, record) VALUES (?1, ?2)", + params![record.id, json], + ) + .map(|_| ()) + }) + } + + fn get(&self, id: &str) -> SwapResult> { + self.with(|c| { + c.query_row( + "SELECT record FROM swaps WHERE id = ?1", + params![id], + |row| row.get::<_, String>(0), + ) + .optional() + })? + .map(decode_swap) + .transpose() + } + + fn all(&self) -> SwapResult> { + let mut all = self + .with(|c| { + let mut statement = c.prepare("SELECT record FROM swaps")?; + let rows = statement.query_map([], |row| row.get::<_, String>(0))?; + rows.collect::>>() + })? + .into_iter() + .map(decode_swap) + .collect::>>()?; + all.sort_by_key(|r| std::cmp::Reverse(r.created_at)); + Ok(all) + } +} + +#[derive(Serialize, Deserialize)] +struct Dump { + version: u32, + contracts: Vec, + swaps: Vec, +} + +pub fn export(data_dir: &Path) -> Result { + let contracts = SqliteContracts::open(data_dir)? + .list() + .map_err(ArkadeError::storage)?; + let swaps = SqliteSwaps::open(data_dir)? + .all() + .map_err(ArkadeError::storage)?; + serde_json::to_string(&Dump { + version: DUMP_VERSION, + contracts, + swaps, + }) + .map_err(ArkadeError::internal) +} + +/// Adds the dump's contracts that the store lacks and its swaps that are new or newer; returns how many rows it +/// wrote. +pub fn import(data_dir: &Path, json: &str) -> Result { + let dump: Dump = serde_json::from_str(json) + .map_err(|e| ArkadeError::invalid(format!("Arkade backup: {e}")))?; + if dump.version > DUMP_VERSION { + return Err(ArkadeError::invalid(format!( + "unknown Arkade backup version {}", + dump.version + ))); + } + let mut store = SqliteContracts::open(data_dir)?; + let mut added = 0; + for contract in dump.contracts { + if store + .get_by_script(&contract.script_pubkey) + .map_err(ArkadeError::storage)? + .is_none() + { + store.insert(contract).map_err(ArkadeError::storage)?; + added += 1; + } + } + // A swap the store already holds is replaced only by a newer copy. + let swaps = SqliteSwaps::open(data_dir)?; + for record in dump.swaps { + let newer = match swaps.get(&record.id).map_err(ArkadeError::storage)? { + Some(held) => record.updated_at > held.updated_at, + None => true, + }; + if newer { + swaps.put(&record).map_err(ArkadeError::storage)?; + added += 1; + } + } + Ok(added) +} + +pub fn wipe(data_dir: &Path) -> Result<(), ArkadeError> { + for suffix in ["", "-wal", "-shm", "-journal"] { + let path = data_dir.join(format!("{DB_FILE}{suffix}")); + if path.exists() { + std::fs::remove_file(&path).map_err(ArkadeError::storage)?; + } + } + Ok(()) +} diff --git a/src/modules/arkade/tests.rs b/src/modules/arkade/tests.rs new file mode 100644 index 0000000..93d82e8 --- /dev/null +++ b/src/modules/arkade/tests.rs @@ -0,0 +1,198 @@ +//! Key derivation, the LNURL session token and the LNURL encoding, checked against values Ark Labs' TypeScript SDK +//! produced for one throwaway mnemonic (`fixtures/oracle.json`, written by `oracle/oracle.mjs`). +//! +//! The `#[ignore]`d tests run against Ark Labs' mainnet operator, delegate and lnurl-server and move no money: +//! `cargo test modules::arkade -- --ignored --test-threads 1`. + +use super::keys::Keys; +use super::lnurl::{claim_public_key, encode_lnurl, session_token}; +use super::*; +use ark_core::server::parse_sequence_number; +use ark_core::{BoardingOutput, Vtxo}; +use bitcoin::key::Secp256k1; +use bitcoin::{Network, PublicKey, XOnlyPublicKey}; +use rand::RngCore; +use serde_json::Value; +use serial_test::serial; +use std::str::FromStr; + +fn oracle() -> Value { + serde_json::from_str(include_str!("fixtures/oracle.json")).unwrap() +} + +fn keys(o: &Value) -> Keys { + Keys::from_mnemonic(o["mnemonic"].as_str().unwrap(), None, Network::Bitcoin).unwrap() +} + +fn xonly(hex: &str) -> XOnlyPublicKey { + PublicKey::from_str(hex).unwrap().into() +} + +#[test] +fn identity_key_matches_seed_identity() { + let o = oracle(); + let k = keys(&o); + assert_eq!(k.account_path.to_string(), "86'/0'/1'"); + assert_eq!( + k.identity.x_only_public_key().0.to_string(), + o["xOnlyPublicKey"].as_str().unwrap() + ); +} + +#[test] +fn claim_public_key_matches() { + let o = oracle(); + assert_eq!( + claim_public_key(&keys(&o).identity), + o["claimPublicKey"].as_str().unwrap() + ); +} + +#[test] +fn session_token_is_byte_identical() { + let o = oracle(); + let token = session_token(&keys(&o).identity, o["lnurlDomain"].as_str().unwrap()).unwrap(); + assert_eq!(token, o["token"].as_str().unwrap()); + // Case and whitespace in the domain don't change the token, as in the TypeScript client. + assert_eq!( + session_token(&keys(&o).identity, " LNURL.arkade.sh ").unwrap(), + token + ); +} + +#[test] +fn lnurl_encoding_matches() { + let o = oracle(); + let handle = o["lightningAddress"]["handle"].as_str().unwrap(); + let lnurl = encode_lnurl(&format!( + "https://lnurl.arkade.sh/.well-known/lnurlp/{handle}" + )) + .unwrap(); + assert_eq!(lnurl, o["lnurlOfHandle"].as_str().unwrap()); + assert_eq!(lnurl, o["lightningAddress"]["lnurl"].as_str().unwrap()); +} + +/// Rebuilds the bridge's Arkade and boarding addresses offline from the operator and delegate keys it saw. This +/// holds only with the vendored ark-core's owner-first 2-of-2 leaf (see `vendor/ark-core`). +#[test] +fn addresses_match_the_bridge() { + let o = oracle(); + let owner = keys(&o).identity.x_only_public_key().0; + let server = xonly(o["server"]["signerPubkey"].as_str().unwrap()); + let delegate = xonly(o["delegatePubkey"].as_str().unwrap()); + let exit = parse_sequence_number(o["server"]["unilateralExitDelay"].as_i64().unwrap()).unwrap(); + let boarding_exit = + parse_sequence_number(o["server"]["boardingExitDelay"].as_i64().unwrap()).unwrap(); + let secp = Secp256k1::new(); + + let vtxo = + Vtxo::new_with_delegator(&secp, server, owner, delegate, exit, Network::Bitcoin).unwrap(); + assert_eq!( + vtxo.to_ark_address().encode(), + o["info"]["arkAddress"].as_str().unwrap() + ); + + let boarding = + BoardingOutput::new(&secp, server, owner, boarding_exit, Network::Bitcoin).unwrap(); + assert_eq!( + boarding.address().to_string(), + o["info"]["boardingAddress"].as_str().unwrap() + ); +} + +#[test] +fn state_dump_round_trips() { + let from = tempfile::tempdir().unwrap(); + let to = tempfile::tempdir().unwrap(); + let dump = export_state(from.path().to_str().unwrap()).unwrap(); + assert_eq!(import_state(to.path().to_str().unwrap(), &dump).unwrap(), 0); +} + +fn params( + dir: &tempfile::TempDir, + mnemonic: &str, + restore: bool, + backup: Option, +) -> ArkadeStartParams { + ArkadeStartParams { + data_dir: dir.path().to_string_lossy().into_owned(), + mnemonic: mnemonic.into(), + passphrase: None, + network: ArkadeNetwork::Bitcoin, + restore, + backup, + } +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +#[ignore] +async fn fresh_wallet_finds_its_address_after_restore() { + let mut entropy = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut entropy); + let mnemonic = bip39::Mnemonic::from_entropy(&entropy).unwrap().to_string(); + + let first = tempfile::tempdir().unwrap(); + let info = start(params(&first, &mnemonic, false, None)).await.unwrap(); + assert!(info.ark_address.starts_with("ark1")); + assert!(info.boarding_address.starts_with("bc1p")); + assert!(info.delegate); + assert_eq!(super::info().await.unwrap().ark_address, info.ark_address); + assert_eq!(balance().await.unwrap().total, 0); + assert!(vtxos().await.unwrap().is_empty()); + assert!(history().await.unwrap().is_empty()); + let address = lightning_address().await.unwrap(); + assert!(address.lnurl.starts_with("LNURL1")); + let backup = export_state(first.path().to_str().unwrap()).unwrap(); + stop().await; + + let second = tempfile::tempdir().unwrap(); + let restored = start(params(&second, &mnemonic, true, Some(backup))) + .await + .unwrap(); + assert_eq!(restored.ark_address, info.ark_address); + assert_eq!(restored.boarding_address, info.boarding_address); + assert_eq!(lightning_address().await.unwrap(), address); + wipe(second.path().to_str().unwrap()).await.unwrap(); +} + +/// The fixture wallet the TypeScript bridge started: the module gets the same addresses live and finds the +/// Lightning address the bridge claimed. +#[tokio::test(flavor = "multi_thread")] +#[serial] +#[ignore] +async fn fixture_wallet_matches_the_bridge() { + let o = oracle(); + let dir = tempfile::tempdir().unwrap(); + let info = start(params(&dir, o["mnemonic"].as_str().unwrap(), true, None)) + .await + .unwrap(); + assert_eq!(info.ark_address, o["info"]["arkAddress"].as_str().unwrap()); + assert_eq!( + info.boarding_address, + o["info"]["boardingAddress"].as_str().unwrap() + ); + assert_eq!( + info.unilateral_exit_delay, + o["info"]["unilateralExitDelay"].as_u64().unwrap() + ); + assert_eq!( + info.boarding_exit_delay, + o["info"]["boardingExitDelay"].as_u64().unwrap() + ); + assert_eq!(info.dust, o["info"]["dust"].as_u64().unwrap()); + let address = lightning_address().await.unwrap(); + assert_eq!( + address.handle, + o["lightningAddress"]["handle"].as_str().unwrap() + ); + assert_eq!( + address.lightning_address.as_deref(), + o["lightningAddress"]["lightningAddress"].as_str() + ); + assert_eq!( + address.lnurl, + o["lightningAddress"]["lnurl"].as_str().unwrap() + ); + wipe(dir.path().to_str().unwrap()).await.unwrap(); +} diff --git a/src/modules/arkade/types.rs b/src/modules/arkade/types.rs new file mode 100644 index 0000000..4ae4ed5 --- /dev/null +++ b/src/modules/arkade/types.rs @@ -0,0 +1,240 @@ +//! The records the apps get back: the shapes of the bridge's results (`bridge.js` and `ArkadeService.kt`). + +use super::intents::SolverCard; + +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum ArkadeNetwork { + /// Mainnet, against Ark Labs' operator. + Bitcoin, + /// Arkade's signet, for dev builds. + Mutinynet, + /// A local regtest stack (operator, esplora, solver, LNURL server), for dev builds and tests. + Regtest { endpoints: ArkadeRegtestEndpoints }, +} + +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct ArkadeRegtestEndpoints { + pub ark_server_url: String, + pub esplora_url: String, + /// The LNURL server for the Lightning address; `None` turns the address off. + pub lnurl_url: Option, + /// The delegate; `None` leaves renewal to the wallet. + pub delegate_url: Option, + /// The solver's x-only discovery key; `None` turns Lightning swaps off. + pub solver_pubkey: Option, + pub solver_relays: Vec, +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct ArkadeStartParams { + /// Where the wallet keeps its database; the app gives each wallet its own. + pub data_dir: String, + pub mnemonic: String, + pub passphrase: Option, + pub network: ArkadeNetwork, + /// Scan the operator and the chain for the wallet's past outputs before returning, as a wallet restored from + /// its phrase needs (TypeScript `wallet.restore()`). + pub restore: bool, + /// An `export_state` dump to import before the wallet opens its storage. + pub backup: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct ArkadeFees { + /// The operator's fee programs (CEL), as `/v1/info` gives them. + pub intent_offchain_input: String, + pub intent_offchain_output: String, + pub intent_onchain_input: String, + pub intent_onchain_output: String, + pub tx_fee_rate: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct ArkadeInfo { + pub ark_address: String, + pub boarding_address: String, + /// `bitcoin`, `mutinynet` or `regtest`. + pub network: String, + pub dust: u64, + /// Seconds (or blocks, below 512), as the operator advertises them. + pub unilateral_exit_delay: u64, + pub boarding_exit_delay: u64, + pub fees: ArkadeFees, + /// Whether a delegate renews this wallet's outputs while it is offline. + pub delegate: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, uniffi::Record)] +pub struct ArkadeBalance { + /// Spendable now: settled plus preconfirmed. + pub available: u64, + pub settled: u64, + pub preconfirmed: u64, + pub recoverable: u64, + pub boarding_confirmed: u64, + pub boarding_unconfirmed: u64, + pub total: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct ArkadeVtxo { + pub txid: String, + pub vout: u32, + pub value: u64, + /// `preconfirmed`, `settled`, `swept` or `spent`. + pub state: String, + /// Unix seconds after which the operator may sweep this output unless it is renewed. + pub batch_expiry: Option, + /// Unix milliseconds, as the bridge reports it. + pub created_at: Option, + pub is_preconfirmed: bool, + pub is_swept: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct ArkadeHistoryEntry { + /// The non-empty ids of `boarding_txid`, `commitment_txid` and `ark_txid`, joined by `:`. + pub key: String, + pub boarding_txid: Option, + pub commitment_txid: Option, + pub ark_txid: Option, + /// `SENT` or `RECEIVED` (the bridge's `type`). + pub tx_type: String, + /// `boarding`, `batch`, `offchain` or `exit`. + pub tag: String, + pub amount: u64, + pub settled: bool, + /// Unix milliseconds. + pub created_at: u64, +} + +/// A Lightning swap through Arkade Intents (the app's `ArkadeSwap`): `receive` pays into the wallet, `send` pays +/// an invoice out of it. +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct ArkadeSwap { + pub id: String, + /// `receive` or `send`. + pub direction: String, + /// `accepted`, `open`, `funded`, `claimed`, `paid`, `refunded`, `lapsed`, `needs_recovery` or `failed`. + pub outcome: String, + pub give_sat: u64, + pub take_sat: u64, + pub fee_sat: u64, + pub bolt11: String, + pub payment_hash: String, + /// Unix seconds. + pub created_at: u64, + pub updated_at: u64, + pub blocked_reason: Option, + /// Receive: the last moment the invoice can be paid. Send: when the quote lapses. + pub expires_at: u64, +} + +impl From<&super::intents::SwapRecord> for ArkadeSwap { + fn from(r: &super::intents::SwapRecord) -> Self { + Self { + id: r.id.clone(), + direction: match r.direction { + super::intents::Direction::Receive => "receive", + super::intents::Direction::Send => "send", + } + .to_string(), + outcome: r.outcome.as_str().to_string(), + give_sat: r.give_sat, + take_sat: r.take_sat, + fee_sat: r.fee_sat, + bolt11: r.bolt11.clone(), + payment_hash: r.payment_hash.clone(), + created_at: r.created_at, + updated_at: r.updated_at, + blocked_reason: r.blocked_reason.clone(), + expires_at: r.expires_at, + } + } +} + +/// What paying an invoice would cost. +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Record)] +pub struct ArkadePayQuote { + /// `give_sat - take_sat`. + pub fee_sat: u64, + /// What the wallet funds. + pub give_sat: u64, + /// The invoice amount. + pub take_sat: u64, + pub expires_at: u64, +} + +impl From for ArkadePayQuote { + fn from(q: super::intents::PayQuote) -> Self { + Self { + fee_sat: q.fee_sat, + give_sat: q.give_sat, + take_sat: q.take_sat, + expires_at: q.expires_at, + } + } +} + +pub const SENT: &str = "SENT"; +pub const RECEIVED: &str = "RECEIVED"; + +pub struct NetworkConfig { + pub name: &'static str, + pub network: bitcoin::Network, + pub ark_server_url: String, + pub delegate_url: Option, + pub esplora_url: String, + /// Read when `esplora_url` doesn't answer. + pub esplora_fallback_urls: Vec, + pub lnurl_url: Option, + /// Who serves Lightning swaps; `None` where no solver does. + pub solver: Option, +} + +impl ArkadeNetwork { + pub fn config(&self) -> NetworkConfig { + match self { + Self::Bitcoin => NetworkConfig { + name: "bitcoin", + network: bitcoin::Network::Bitcoin, + ark_server_url: "https://arkade.computer".into(), + delegate_url: Some("https://delegate.arkade.money".into()), + esplora_url: "https://mempool.space/api".into(), + esplora_fallback_urls: vec![ + "https://blockstream.info/api".into(), + "https://mempool.emzy.de/api".into(), + ], + lnurl_url: Some("https://lnurl.arkade.sh".into()), + solver: Some(SolverCard::beta_solver()), + }, + Self::Mutinynet => NetworkConfig { + name: "mutinynet", + network: bitcoin::Network::Signet, + ark_server_url: "https://mutinynet.arkade.sh".into(), + delegate_url: Some("https://delegator.mutinynet.arkade.sh".into()), + esplora_url: "https://mutinynet.com/api".into(), + esplora_fallback_urls: vec![], + lnurl_url: None, + solver: None, + }, + Self::Regtest { endpoints } => NetworkConfig { + name: "regtest", + network: bitcoin::Network::Regtest, + ark_server_url: endpoints.ark_server_url.clone(), + delegate_url: endpoints.delegate_url.clone(), + esplora_url: endpoints.esplora_url.clone(), + esplora_fallback_urls: vec![], + lnurl_url: endpoints.lnurl_url.clone(), + solver: endpoints + .solver_pubkey + .clone() + .map(|discovery_pubkey| SolverCard { + name: "regtest-solver".into(), + discovery_pubkey, + relays: endpoints.solver_relays.clone(), + }), + }, + } + } +} diff --git a/src/modules/arkade/wallet.rs b/src/modules/arkade/wallet.rs new file mode 100644 index 0000000..e767368 --- /dev/null +++ b/src/modules/arkade/wallet.rs @@ -0,0 +1,726 @@ +//! The started wallet: an ark-client `Client` on the HD key provider, esplora, BDK and sqlite, with the +//! bridge's methods on top. + +use super::errors::ArkadeError; +use super::esplora::Esplora; +use super::intents::{ArkWallet, WalletError}; +use super::keys::Keys; +use super::lnurl::{ArkadeLightningAddress, Destinations, LnurlService}; +use super::store::{self, SqliteContracts}; +use super::types::*; +use ark_client::vtxo_watcher::{VtxoWatcherConfig, VtxoWatcherHandle}; +use ark_client::{ + Blockchain, GetVtxosRequestFilter, InMemorySwapStorage, OfflineClient, OfflineClientConfig, +}; +use ark_core::history::Transaction; +use ark_core::send::SendReceiver; +use ark_core::server::{Info, VirtualTxOutPoint}; +use ark_core::ArkAddress; +use ark_delegator::DelegatorClient; +use bitcoin::{Address, Amount, OutPoint, Psbt, Sequence, Txid, XOnlyPublicKey}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Swaps arrive with the Arkade Intents client (stage B); until then the swap store ark-client requires is the +/// in-memory one. +pub type Ark = ark_client::Client; + +/// Outputs this close to their batch expiry are renewed (the TypeScript `VtxoManager`'s threshold). +pub const RENEW_WITHIN: Duration = Duration::from_secs(3 * 24 * 60 * 60); +/// How many unused keys the restore scan walks past before it stops. +pub const RESTORE_GAP_LIMIT: u32 = ark_client::DEFAULT_GAP_LIMIT; + +pub struct Wallet { + pub client: Arc, + pub esplora: Arc, + pub network: &'static str, + pub delegate: bool, + pub lnurl: Option, + /// One batch at a time: the chores and the app's own settle, renew and offboard calls would otherwise + /// register competing intents for the same outputs. + pub batch: tokio::sync::Mutex<()>, + pub watcher: std::sync::Mutex>, + /// `m/86'/c'/1'/0/0`, the TypeScript wallet's static key: the Lightning swaps' claim and refund key. + identity: bitcoin::secp256k1::Keypair, +} + +pub struct BoardingUtxo { + pub outpoint: OutPoint, + pub amount: Amount, + pub confirmed: bool, + pub spent: bool, +} + +impl Wallet { + pub async fn start(params: &ArkadeStartParams) -> Result, ArkadeError> { + let data_dir = Path::new(¶ms.data_dir); + let config = params.network.config(); + let keys = Keys::from_mnemonic( + ¶ms.mnemonic, + params.passphrase.as_deref(), + config.network, + )?; + std::fs::create_dir_all(data_dir).map_err(ArkadeError::storage)?; + if let Some(backup) = params.backup.as_deref() { + match store::import(data_dir, backup) { + Ok(rows) => log::info!("Imported '{rows}' Arkade contracts from backup"), + Err(e) => log::warn!("importing Arkade backup failed: {e}"), + } + } + + // The TypeScript wallet builds the delegate (3-leaf) address only when the delegate answered, and the + // plain one otherwise; so does this. + let delegator = config.delegate_url.clone().map(DelegatorClient::new); + let delegator_pk = match &delegator { + Some(delegator) => match delegator.info().await { + Ok(info) => Some(parse_xonly(&info.pubkey)?), + Err(e) => { + log::warn!("delegate info failed, starting without a delegate: {e}"); + None + } + }, + None => None, + }; + + let esplora_urls: Vec = std::iter::once(config.esplora_url.clone()) + .chain(config.esplora_fallback_urls.iter().cloned()) + .collect(); + let esplora = Arc::new(Esplora::new(&esplora_urls)?); + let onchain = ark_bdk_wallet::Wallet::new_from_xpriv( + keys.account_xpriv()?, + config.network, + &config.esplora_url, + ) + .map_err(ArkadeError::internal)?; + let swaps = InMemorySwapStorage::new(); + let contracts = SqliteContracts::open(data_dir)?; + let client_config = OfflineClientConfig { + ark_server_url: config.ark_server_url.clone(), + delegator_pk, + ..Default::default() + }; + let client = OfflineClient::with_bip32( + client_config, + keys.master, + Some(keys.receive_path.clone()), + esplora.clone(), + Arc::new(onchain), + Arc::new(swaps), + ) + .with_contract_store(Box::new(contracts)) + .connect() + .await?; + let info = client.server_info().await?; + if info.network != config.network { + return Err(ArkadeError::invalid(format!( + "operator runs on {}, expected {}", + info.network, config.name + ))); + } + + if params.restore { + let started = std::time::Instant::now(); + match client.restore_contracts(RESTORE_GAP_LIMIT).await { + Ok(report) => log::info!( + "Restored '{}' Arkade keys and '{}' contracts in '{}' ms", + report.discovered_keys(), + report.discovered_contracts(), + started.elapsed().as_millis() + ), + Err(e) => log::warn!("restore scan failed: {e}"), + } + } + + let client = Arc::new(client); + let watcher = delegator + .filter(|_| delegator_pk.is_some()) + .map(|d| client.start_vtxo_watcher(Arc::new(d), VtxoWatcherConfig::default())); + let lnurl = config + .lnurl_url + .as_deref() + .map(|url| LnurlService::new(url, &keys.identity)) + .transpose()?; + Ok(Arc::new(Self { + client, + esplora, + network: config.name, + delegate: delegator_pk.is_some(), + lnurl, + batch: tokio::sync::Mutex::new(()), + watcher: std::sync::Mutex::new(watcher), + identity: keys.identity, + })) + } + + pub fn stop(&self) { + if let Some(watcher) = self.watcher.lock().ok().and_then(|mut w| w.take()) { + watcher.stop(); + } + } + + pub async fn info(&self) -> Result { + let info = self.client.server_info().await?; + let (ark_address, _) = self.client.get_offchain_address().await?; + let boarding_address = self.client.get_boarding_address().await?; + Ok(ArkadeInfo { + ark_address: ark_address.encode(), + boarding_address: boarding_address.to_string(), + network: self.network.to_string(), + dust: info.dust.to_sat(), + unilateral_exit_delay: sequence_value(info.unilateral_exit_delay), + boarding_exit_delay: sequence_value(info.boarding_exit_delay), + fees: fees_of(&info), + delegate: self.delegate, + }) + } + + pub async fn fees(&self) -> Result { + Ok(fees_of(&self.client.server_info().await?)) + } + + /// Every output on every boarding address this wallet watches, with whether a batch has taken it. + pub async fn boarding_utxos(&self) -> Result, ArkadeError> { + let mut seen = HashSet::new(); + let mut utxos = Vec::new(); + for address in self.client.get_boarding_addresses().await? { + for utxo in self.esplora.find_outpoints(&address).await? { + if seen.insert(utxo.outpoint) { + utxos.push(BoardingUtxo { + outpoint: utxo.outpoint, + amount: utxo.amount, + confirmed: utxo.confirmation_blocktime.is_some(), + spent: utxo.is_spent, + }); + } + } + } + Ok(utxos) + } + + pub async fn balance(&self) -> Result { + let offchain = self.client.offchain_balance().await?; + // Boarding is read from the public explorers; when they fail, the operator's balance still stands. + let boarding = self.boarding_utxos().await.unwrap_or_else(|e| { + log::warn!("Arkade boarding read failed, balance shows no deposits: {e}"); + Vec::new() + }); + let sum = |confirmed: bool| { + boarding + .iter() + .filter(|u| !u.spent && u.confirmed == confirmed) + .map(|u| u.amount.to_sat()) + .sum::() + }; + let settled = offchain.confirmed().to_sat(); + let preconfirmed = offchain.pre_confirmed().to_sat(); + let recoverable = (offchain.recoverable() + offchain.pending_recovery()).to_sat(); + let (boarding_confirmed, boarding_unconfirmed) = (sum(true), sum(false)); + Ok(ArkadeBalance { + available: settled + preconfirmed, + settled, + preconfirmed, + recoverable, + boarding_confirmed, + boarding_unconfirmed, + total: settled + preconfirmed + recoverable + boarding_confirmed + boarding_unconfirmed, + }) + } + + pub async fn vtxos(&self) -> Result, ArkadeError> { + let list = self + .client + .list_vtxos_filtered(GetVtxosRequestFilter::Renewable) + .await?; + Ok(list + .all_unspent() + .map(|entry| { + let v = entry.vtxo(); + let state = if v.is_swept { + "swept" + } else if v.is_spent { + "spent" + } else if v.is_preconfirmed { + "preconfirmed" + } else { + "settled" + }; + ArkadeVtxo { + txid: v.outpoint.txid.to_string(), + vout: v.outpoint.vout, + value: v.amount.to_sat(), + state: state.to_string(), + batch_expiry: u64::try_from(v.expires_at).ok().filter(|e| *e > 0), + created_at: u64::try_from(v.created_at).ok().map(|s| s * 1000), + is_preconfirmed: v.is_preconfirmed, + is_swept: v.is_swept, + } + }) + .collect()) + } + + pub async fn history(&self) -> Result, ArkadeError> { + let boarded: HashMap = self + .boarding_utxos() + .await? + .into_iter() + .map(|u| (u.outpoint.txid, u.spent)) + .collect(); + let now_ms = now_secs() * 1000; + let at = |secs: Option| { + secs.and_then(|s| u64::try_from(s).ok()) + .map_or(now_ms, |s| s * 1000) + }; + let txs = self.client.transaction_history().await?; + Ok(txs + .into_iter() + .map(|tx| match tx { + Transaction::Boarding { + txid, + amount, + confirmed_at, + } => entry( + (Some(txid), None, None), + RECEIVED, + "boarding", + amount.to_sat(), + boarded.get(&txid).copied().unwrap_or(false), + at(confirmed_at), + ), + Transaction::Commitment { + txid, + amount, + created_at, + } => entry( + (None, Some(txid), None), + if amount.is_negative() { SENT } else { RECEIVED }, + "batch", + amount.unsigned_abs().to_sat(), + true, + at(Some(created_at)), + ), + Transaction::Ark { + txid, + amount, + is_settled, + created_at, + } => entry( + (None, None, Some(txid)), + if amount.is_negative() { SENT } else { RECEIVED }, + "offchain", + amount.unsigned_abs().to_sat(), + is_settled, + at(Some(created_at)), + ), + Transaction::Offboard { + commitment_txid, + amount, + confirmed_at, + } => entry( + (None, Some(commitment_txid), None), + SENT, + "batch", + amount.to_sat(), + true, + at(confirmed_at), + ), + }) + .collect()) + } + + /// Boards confirmed boarding outputs now (the bridge's `settle`, `Ramps.onboard`). + pub async fn settle(&self) -> Result, ArkadeError> { + let boarding: Vec = self + .boarding_utxos() + .await? + .into_iter() + .filter(|u| u.confirmed && !u.spent) + .map(|u| u.outpoint) + .collect(); + if boarding.is_empty() { + return Ok(None); + } + let _batch = self.batch.lock().await; + let txid = self.client.settle_vtxos(&mut rng(), &[], &boarding).await?; + Ok(txid.map(|t| t.to_string())) + } + + /// Renews outputs within [`RENEW_WITHIN`] of their batch expiry, and recoverable ones. + pub async fn renew(&self) -> Result, ArkadeError> { + let info = self.client.server_info().await?; + let cutoff = now_secs() as i64 + RENEW_WITHIN.as_secs() as i64; + let list = self + .client + .list_vtxos_filtered(GetVtxosRequestFilter::Renewable) + .await?; + let due: Vec = list + .all_unspent() + .filter(|e| { + e.vtxo().is_recoverable(info.dust) + || (e.vtxo().expires_at > 0 && e.vtxo().expires_at < cutoff) + }) + .map(|e| e.vtxo().outpoint) + .collect(); + if due.is_empty() { + return Ok(None); + } + let _batch = self.batch.lock().await; + let txid = self.client.settle_vtxos(&mut rng(), &due, &[]).await?; + Ok(txid.map(|t| t.to_string())) + } + + /// Collaborative exit to `address`, paying the operator's on-chain output fee out of the amount, as + /// `Ramps.offboard` does: the address gets `amount` (or everything) less that fee. + pub async fn offboard( + &self, + address: &str, + amount: Option, + ) -> Result { + let info = self.client.server_info().await?; + let address = Address::from_str(address) + .and_then(|a| a.require_network(info.network)) + .map_err(|e| ArkadeError::invalid(format!("invalid address: {e}")))?; + let estimator = fee_estimator(&info)?; + let output_fee = |sats: u64| -> Result { + let output = ark_fees::Output { + amount: sats, + script: address.script_pubkey().to_string(), + }; + Ok(estimator + .eval_onchain_output(output) + .map_err(ArkadeError::ark)? + .to_satoshis()) + }; + let mut rng = rng(); + let _batch = self.batch.lock().await; + let txid = match amount { + Some(amount) => { + let send = amount + .checked_sub(output_fee(amount)?) + .filter(|s| *s > 0) + .ok_or_else(|| { + ArkadeError::invalid(format!("{amount} sats does not cover the exit fee")) + })?; + self.client + .collaborative_redeem(&mut rng, address, Amount::from_sat(send)) + .await? + } + None => { + let now = now_secs() as i64; + let list = self + .client + .list_vtxos_filtered(GetVtxosRequestFilter::Renewable) + .await?; + let mut input_fees = 0; + let mut inputs = Vec::new(); + for e in list.batch_settleable_at(&info, now) { + input_fees += input_fee(&estimator, e.vtxo(), info.dust)?; + inputs.push((e.vtxo().outpoint, e.vtxo().amount.to_sat())); + } + // The operator charges each VTXO spent as well as the output. + let total = inputs + .iter() + .map(|(_, sats)| sats) + .sum::() + .saturating_sub(input_fees); + // A fee program may price the output on its own amount: settle on the largest output whose + // amount plus fee fits the inputs. + let mut send = total.saturating_sub(output_fee(total)?); + while send > 0 && send + output_fee(send)? > total { + send -= 1; + } + if send == 0 { + return Err(ArkadeError::invalid("nothing to offboard after fees")); + } + let outpoints = inputs.into_iter().map(|(o, _)| o).collect::>(); + self.client + .collaborative_redeem_vtxo_selection( + &mut rng, + outpoints.into_iter(), + address, + Amount::from_sat(send), + ) + .await? + } + }; + Ok(txid.to_string()) + } + + pub async fn send_ark(&self, address: &str, amount: u64) -> Result { + let address = ArkAddress::decode(address) + .map_err(|e| ArkadeError::invalid(format!("invalid Arkade address: {e}")))?; + let txid = self + .client + .send(vec![SendReceiver::bitcoin( + address, + Amount::from_sat(amount), + )]) + .await?; + Ok(txid.to_string()) + } + + pub async fn lightning_address(&self) -> Result { + let lnurl = self + .lnurl + .as_ref() + .ok_or_else(|| ArkadeError::lnurl("not configured on this network"))?; + let info = self.info().await?; + lnurl + .lightning_address(&Destinations { + arkade_address: info.ark_address, + boarding_address: info.boarding_address, + }) + .await + } + + /// One pass of the background chores: board confirmed deposits, renew outputs near expiry. + pub async fn chores(&self) { + if let Err(e) = self.settle().await { + log::warn!("boarding failed: {e}"); + } + if let Err(e) = self.renew().await { + log::warn!("renewal failed: {e}"); + } + } +} + +fn entry( + (boarding, commitment, ark): (Option, Option, Option), + tx_type: &str, + tag: &str, + amount: u64, + settled: bool, + created_at: u64, +) -> ArkadeHistoryEntry { + let [boarding_txid, commitment_txid, ark_txid] = + [boarding, commitment, ark].map(|t| t.map(|t| t.to_string())); + let key = [&boarding_txid, &commitment_txid, &ark_txid] + .into_iter() + .flatten() + .cloned() + .collect::>() + .join(":"); + ArkadeHistoryEntry { + key, + boarding_txid, + commitment_txid, + ark_txid, + tx_type: tx_type.to_string(), + tag: tag.to_string(), + amount, + settled, + created_at, + } +} + +fn parse_xonly(pk: &str) -> Result { + XOnlyPublicKey::from_str(pk) + .or_else(|_| bitcoin::PublicKey::from_str(pk).map(XOnlyPublicKey::from)) + .map_err(|e| ArkadeError::ark(format!("invalid delegate key '{pk}': {e}"))) +} + +/// The operator's delay as `/v1/info` gives it: seconds for time-based delays, blocks otherwise. +pub fn sequence_value(sequence: Sequence) -> u64 { + match sequence.to_relative_lock_time() { + Some(bitcoin::relative::LockTime::Time(t)) => u64::from(t.value()) * 512, + Some(bitcoin::relative::LockTime::Blocks(b)) => u64::from(b.value()), + None => 0, + } +} + +fn fees_of(info: &Info) -> ArkadeFees { + let fees = info + .fees + .clone() + .unwrap_or_else(|| ark_core::server::FeeInfo { + intent_fee: Default::default(), + tx_fee_rate: String::new(), + }); + ArkadeFees { + intent_offchain_input: fees.intent_fee.offchain_input.unwrap_or_default(), + intent_offchain_output: fees.intent_fee.offchain_output.unwrap_or_default(), + intent_onchain_input: fees.intent_fee.onchain_input.unwrap_or_default(), + intent_onchain_output: fees.intent_fee.onchain_output.unwrap_or_default(), + tx_fee_rate: fees.tx_fee_rate, + } +} + +/// The operator's `offchainInput` fee for spending `vtxo`, rounded up as the operator does. +fn input_fee( + estimator: &ark_fees::Estimator, + vtxo: &VirtualTxOutPoint, + dust: Amount, +) -> Result { + let input_type = if vtxo.is_recoverable(dust) { + ark_fees::VtxoType::Recoverable + } else { + ark_fees::VtxoType::Vtxo + }; + Ok(estimator + .eval_offchain_input(ark_fees::OffchainInput { + amount: vtxo.amount.to_sat(), + expiry: Some(vtxo.expires_at), + birth: Some(vtxo.created_at), + input_type, + ..Default::default() + }) + .map_err(ArkadeError::ark)? + .to_satoshis()) +} + +/// ark-client builds the same estimator internally but keeps it private. +fn fee_estimator(info: &Info) -> Result { + let fees = fees_of(info); + ark_fees::Estimator::new(ark_fees::Config { + intent_offchain_input_program: fees.intent_offchain_input, + intent_onchain_input_program: fees.intent_onchain_input, + intent_offchain_output_program: fees.intent_offchain_output, + intent_onchain_output_program: fees.intent_onchain_output, + }) + .map_err(ArkadeError::ark) +} + +pub fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// A `Send` RNG: the batch futures hold it across awaits on a multi-threaded runtime. +fn rng() -> rand::rngs::StdRng { + rand::SeedableRng::from_entropy() +} + +/// The Arkade Intents client's wallet: network I/O on the ark-client `Client` and the identity key. +#[async_trait::async_trait] +impl ArkWallet for Wallet { + async fn server_info(&self) -> Result { + self.client.server_info().await.map_err(wallet_error) + } + + async fn address(&self) -> Result { + Ok(self + .client + .get_offchain_address() + .await + .map_err(wallet_error)? + .0) + } + + fn identity_keypair(&self) -> Result { + Ok(self.identity) + } + + async fn vtxos(&self, address: &ArkAddress) -> Result, WalletError> { + self.client + .get_virtual_tx_outpoints(std::iter::once(*address)) + .await + .map_err(wallet_error) + } + + async fn virtual_tx(&self, txid: Txid) -> Result, WalletError> { + let response = self + .client + .network_client() + .get_virtual_txs(vec![txid.to_string()], None, None) + .await + .map_err(wallet_error)?; + Ok(response + .txs + .into_iter() + .find(|tx| tx.unsigned_tx.compute_txid() == txid)) + } + + async fn submit_offchain( + &self, + ark_tx: Psbt, + checkpoints: Vec, + ) -> Result, WalletError> { + let response = self + .client + .network_client() + .submit_offchain_transaction_request(ark_tx, checkpoints) + .await + .map_err(wallet_error)?; + Ok(response.signed_checkpoint_txs) + } + + async fn finalize_offchain( + &self, + ark_txid: Txid, + checkpoints: Vec, + ) -> Result<(), WalletError> { + self.client + .network_client() + .finalize_offchain_transaction(ark_txid, checkpoints) + .await + .map(|_| ()) + .map_err(wallet_error) + } + + async fn send(&self, address: &ArkAddress, amount: Amount) -> Result { + self.client + .send(vec![SendReceiver::bitcoin(*address, amount)]) + .await + .map_err(wallet_error) + } +} + +fn wallet_error(e: impl std::fmt::Display) -> WalletError { + WalletError(format!("{e:#}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vtxo(amount: u64, expires_in: i64) -> VirtualTxOutPoint { + let now = now_secs() as i64; + VirtualTxOutPoint { + outpoint: OutPoint::null(), + created_at: now, + expires_at: now + expires_in, + amount: Amount::from_sat(amount), + script: Default::default(), + is_preconfirmed: false, + is_swept: false, + is_unrolled: false, + is_spent: false, + spent_by: None, + commitment_txids: Vec::new(), + settled_by: None, + ark_txid: None, + assets: Vec::new(), + depth: 0, + } + } + + #[test] + fn input_fee_follows_the_operator_program() { + // arkade.computer's programs on 2 Nov 2026: a VTXO with over 29 days left pays 0.2%. + let estimator = ark_fees::Estimator::new(ark_fees::Config { + intent_offchain_input_program: + "inputType == 'vtxo' && expiry - now() > 2505216.0 ? amount * 0.002 : 0.0".into(), + intent_onchain_output_program: "200.0".into(), + ..Default::default() + }) + .unwrap(); + let dust = Amount::from_sat(330); + let day = 24 * 60 * 60; + assert_eq!( + input_fee(&estimator, &vtxo(11_500, 30 * day), dust).unwrap(), + 23 + ); + assert_eq!( + input_fee(&estimator, &vtxo(11_501, 30 * day), dust).unwrap(), + 24 + ); + assert_eq!( + input_fee(&estimator, &vtxo(11_500, 10 * day), dust).unwrap(), + 0 + ); + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index d628203..3688c9e 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -1,4 +1,5 @@ pub mod activity; +pub mod arkade; pub mod blocktank; pub mod boltz; pub mod hardware_wallet; diff --git a/vendor/ark-client/.cargo-ok b/vendor/ark-client/.cargo-ok new file mode 100644 index 0000000..5f8b795 --- /dev/null +++ b/vendor/ark-client/.cargo-ok @@ -0,0 +1 @@ +{"v":1} \ No newline at end of file diff --git a/vendor/ark-client/Cargo.lock b/vendor/ark-client/Cargo.lock new file mode 100644 index 0000000..2f12602 --- /dev/null +++ b/vendor/ark-client/Cargo.lock @@ -0,0 +1,4093 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "antlr4rust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093d520274bfff7278d776f7ea12981a0a0a6f96db90964658e0f38fc6e9a6a6" +dependencies = [ + "better_any", + "bit-set", + "byteorder", + "lazy_static", + "murmur3", + "once_cell", + "parking_lot", + "typed-arena", + "uuid", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ark-client" +version = "0.12.0" +dependencies = [ + "ark-core", + "ark-delegator", + "ark-fees", + "ark-grpc", + "async-stream", + "async-trait", + "backon", + "base64 0.22.1", + "bech32", + "bitcoin", + "futures", + "futures-util", + "getrandom 0.2.17", + "gloo-timers", + "jiff", + "lightning-invoice", + "prost 0.13.5", + "prost-types 0.13.5", + "rand 0.8.8", + "reqwest", + "rusqlite", + "rustls", + "secp256k1 0.33.1", + "serde", + "serde_json", + "serde_with", + "sqlx", + "tempfile", + "tokio", + "tokio-stream", + "tonic 0.14.6", + "tonic-build", + "tonic-prost", + "tonic-web-wasm-client", + "tracing", + "wasm-bindgen-futures", +] + +[[package]] +name = "ark-core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace6dccaca2f279025696377f7fa57d3c3f45696d054c7c3822c9211647598ef" +dependencies = [ + "bech32", + "bitcoin", + "bs58", + "getrandom 0.2.17", + "getrandom 0.3.4", + "hex", + "js-sys", + "rand 0.8.8", + "secp256k1 0.33.1", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "web-sys", +] + +[[package]] +name = "ark-delegator" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08574857df0ca54c13558255f2e311463232bc2092aefc1d72b47575ffdf8b17" +dependencies = [ + "ark-core", + "bitcoin", + "reqwest", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "ark-fees" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b0f340600e1837ad3a5f57dd160775d4dcebb603bc922cfa33c68fd7e2fc64c" +dependencies = [ + "cel", +] + +[[package]] +name = "ark-grpc" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c09b5cec33fbcff8b7a71092ad21a8940d333c093750972ed34692800db0712" +dependencies = [ + "ark-core", + "async-stream", + "base64 0.22.1", + "bitcoin", + "futures", + "log", + "prost 0.14.4", + "serde_json", + "tonic 0.14.6", + "tonic-prost", + "tonic-prost-build", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base58ck" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "365c0acd5b2e8dd0111a46c4faea83fb3cfb6e39a49a7c73a06e090db7b2eff0" +dependencies = [ + "bitcoin_hashes", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "better_any" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4372b9543397a4b86050cc5e7ee36953edf4bac9518e8a774c2da694977fb6e4" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitcoin" +version = "0.32.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0ce8bd5baaa0d303a19915a6d93afed161f528654e42da2a7a97d05c59499a" +dependencies = [ + "base58ck", + "base64 0.21.7", + "bech32", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative 0.2.3", + "hex_lit", + "secp256k1 0.29.1", + "serde", +] + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9daa31138eb443d5751b207f3f64154e2bb09cd59960562ccc7a7112be38147f" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.3.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8bea3a9f0cfece4564e37cb49a38cc245ca5184719e50d7d0dda3268722c4e2" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin-units" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb95693f371d089a4b5b6fc41c6f3ea6e01ee8c15388335dfac8ea685173b51" +dependencies = [ + "bitcoin-consensus-encoding", + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.3", + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cel" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca1e5eda1b0f8476181bed1bfc9232a91d62ff0b9f1bc0e48afff3cbcb5b0b5c" +dependencies = [ + "antlr4rust", + "chrono", + "lazy_static", + "nom", + "paste", + "regex", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "defmt" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "defmt-parser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.10", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "lightning-invoice" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39b494954ed8f1d774d86473224fadf9f0dfcca38c2d16a7dbdf2b5800c8943" +dependencies = [ + "bech32", + "bitcoin", + "lightning-types", + "serde", +] + +[[package]] +name = "lightning-types" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da19491efc72d10a13d5b8fd9c0cc6de7814742675adcb9996fa13f40a3b5ca" +dependencies = [ + "bitcoin", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "murmur3" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a198f9589efc03f544388dfc4a19fe8af4323662b62f598b8dcfdac62c14771c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.2", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive 0.14.4", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost 0.14.4", + "prost-types 0.14.4", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost 0.14.4", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.2", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bbb29c624230c4bd1047bbdb2aa47e41c860e9665ce62ba9504eebe91bf867" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.10", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.9.1", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.8", + "secp256k1-sys 0.10.1", + "serde", +] + +[[package]] +name = "secp256k1" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7f404a8dab7a7a5a631e741d8699aa9c8e1d689fc26ccf897c7187565490b69" +dependencies = [ + "rand 0.9.5", + "secp256k1-sys 0.14.1", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "secp256k1-sys" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b2992d4a3cd244539a7d5d0966aadbe5ca7fb868a5d7e38c29499b9e709bbd" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.3", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink 0.10.0", + "indexmap 2.14.2", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.2", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.8", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.21", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.2", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.8", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.21", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.21", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "base64 0.22.1", + "bytes", + "http", + "http-body", + "http-body-util", + "percent-encoding", + "pin-project", + "tokio-stream", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", + "webpki-roots 1.0.10", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost 0.14.4", + "tonic 0.14.6", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types 0.14.4", + "quote", + "syn 2.0.119", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tonic-web-wasm-client" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8957be1a1c7aa12d4c9d67882060dd57aed816bbc553fa60949312e839f4a8ea" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "httparse", + "js-sys", + "pin-project", + "thiserror 1.0.69", + "tonic 0.12.3", + "tower-service", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 2.14.2", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.10", +] + +[[package]] +name = "webpki-roots" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/vendor/ark-client/Cargo.toml b/vendor/ark-client/Cargo.toml new file mode 100644 index 0000000..273c6e5 --- /dev/null +++ b/vendor/ark-client/Cargo.toml @@ -0,0 +1,247 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.86" +name = "ark-client" +version = "0.12.0" +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "High-level client library for building Arkade wallets in Rust" +readme = "README.md" +keywords = [ + "ark", + "arkade", + "bitcoin", + "wallet", +] +categories = ["cryptography::cryptocurrencies"] +license = "MIT" +repository = "https://github.com/arkade-os/rust-sdk" + +[features] +default = ["tls-native-roots"] +sqlite = [ + "sqlx", + "rusqlite", +] +test-utils = [] +tls-native-roots = [ + "tonic/tls-native-roots", + "ark-grpc/tls-native-roots", +] +tls-webpki-roots = [ + "tonic/tls-webpki-roots", + "ark-grpc/tls-webpki-roots", +] + +[lib] +name = "ark_client" +path = "src/lib.rs" + +[dependencies.ark-core] +version = "0.12.0" + +[dependencies.ark-delegator] +version = "0.12.0" + +[dependencies.ark-fees] +version = "0.12.0" + +[dependencies.async-stream] +version = "0.3" + +[dependencies.async-trait] +version = "0.1" + +[dependencies.base64] +version = "0.22.1" + +[dependencies.bech32] +version = "0.11" + +[dependencies.bitcoin] +version = "0.32.7" +features = [ + "rand", + "serde", +] + +[dependencies.futures] +version = "0.3.31" + +[dependencies.jiff] +version = "0.2.1" + +[dependencies.lightning-invoice] +version = "0.33.2" +features = ["serde"] + +[dependencies.musig] +version = "0.33" +features = ["serde"] +package = "secp256k1" + +[dependencies.prost] +version = "0.13.3" + +[dependencies.prost-types] +version = "0.13.3" + +[dependencies.rand] +version = "0.8" + +[dependencies.reqwest] +version = "0.12" +features = ["json"] + +[dependencies.rusqlite] +version = "0.32" +features = ["bundled"] +optional = true + +[dependencies.serde] +version = "1.0" +features = ["derive"] + +[dependencies.serde_json] +version = "1.0" + +[dependencies.serde_with] +version = "3.14" + +[dependencies.sqlx] +version = "0.8" +features = [ + "runtime-tokio-rustls", + "sqlite", + "migrate", + "chrono", +] +optional = true + +[dependencies.tokio] +version = "1.41.0" +features = [ + "sync", + "rt-multi-thread", +] + +[dependencies.tracing] +version = "0.1.37" + +[dev-dependencies.ark-grpc] +version = "0.12.0" +features = ["test-utils"] +default-features = false + +[dev-dependencies.rustls] +version = "0.23" +features = ["ring"] + +[dev-dependencies.tempfile] +version = "3.8" + +[dev-dependencies.tokio] +version = "1.41.0" +features = [ + "macros", + "net", + "rt-multi-thread", + "sync", +] + +[dev-dependencies.tokio-stream] +version = "0.1" +features = ["net"] + +[dev-dependencies.tonic-prost] +version = "0.14" + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies.backon] +version = "1" +features = ["gloo-timers-sleep"] + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies.futures-util] +version = "0.3.31" +features = ["alloc"] +default-features = false + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies.getrandom] +version = "0.2" +features = [ + "wasm-bindgen", + "js", +] + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies.gloo-timers] +version = "0.3.0" +features = ["futures"] + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies.tonic] +version = "0.14" +features = ["codegen"] +default-features = false + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies.tonic-prost] +version = "0.14" + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies.tonic-web-wasm-client] +version = "0.6" +default-features = false + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies.wasm-bindgen-futures] +version = "0.4" + +[target."cfg(genproto)".build-dependencies.tonic-build] +version = "0.14" + +[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies.ark-grpc] +version = "0.12.0" +default-features = false + +[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies.backon] +version = "1" +features = ["tokio-sleep"] + +[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies.tonic] +version = "0.14" + +[lints.clippy] +dbg_macro = "warn" +large_enum_variant = "allow" +print_stderr = "warn" +print_stdout = "warn" +redundant_clone = "warn" +redundant_else = "warn" +too_many_arguments = "allow" +unnecessary_wraps = "warn" +unused_async = "warn" +unwrap_in_result = "warn" +unwrap_used = "warn" +wildcard_enum_match_arm = "warn" + +[lints.rust] +unused-import-braces = "warn" +unused-qualifications = "warn" + +[lints.rust.unexpected_cfgs] +level = "warn" +priority = 0 +check-cfg = [ + "cfg(genproto)", + "cfg(docsrs)", +] diff --git a/vendor/ark-client/Cargo.toml.orig b/vendor/ark-client/Cargo.toml.orig new file mode 100644 index 0000000..61d4d63 --- /dev/null +++ b/vendor/ark-client/Cargo.toml.orig @@ -0,0 +1,74 @@ +[package] +name = "ark-client" +version = "0.12.0" +categories = { workspace = true } +edition = { workspace = true } +keywords = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +rust-version = "1.86" +description = "High-level client library for building Arkade wallets in Rust" + +[lints] +workspace = true + +[dependencies] +ark-core = { path = "../ark-core", version = "0.12.0" } +ark-delegator = { path = "../ark-delegator", version = "0.12.0" } +ark-fees = { path = "../ark-fees", version = "0.12.0" } +async-stream = "0.3" +async-trait = "0.1" +base64 = "0.22.1" +bech32 = "0.11" +bitcoin = { version = "0.32.7", features = ["rand", "serde"] } +futures = "0.3.31" +jiff = "0.2.1" +lightning-invoice = { version = "0.33.2", features = ["serde"] } +musig = { package = "secp256k1", version = "0.33", features = ["serde"] } +prost = "0.13.3" +prost-types = "0.13.3" +rand = "0.8" +reqwest = { version = "0.12", features = ["json"] } +rusqlite = { version = "0.32", features = ["bundled"], optional = true } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_with = "3.14" +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "migrate", "chrono"], optional = true } +tokio = { version = "1.41.0", features = ["sync", "rt-multi-thread"] } +tracing = "0.1.37" + +[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] +ark-grpc = { path = "../ark-grpc", version = "0.12.0", default-features = false } +backon = { version = "1", features = ["tokio-sleep"] } +tonic = "0.14" + +# TODO: We do not yet support WASM in `ark-client`. To support WASM in this high level crate, we +# will need to make it possible to use either `ark-grpc` or `ark-rest`. The current default is +# `ark-grpc`, but only `ark-rest` can support WASM. +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +backon = { version = "1", features = ["gloo-timers-sleep"] } +futures-util = { version = "0.3.31", default-features = false, features = ["alloc"] } +getrandom = { version = "0.2", features = ["wasm-bindgen", "js"] } +gloo-timers = { version = "0.3.0", features = ["futures"] } +tonic = { version = "0.14", default-features = false, features = ["codegen"] } +tonic-prost = "0.14" +tonic-web-wasm-client = { version = "0.6", default-features = false } +wasm-bindgen-futures = { version = "0.4" } + +[target.'cfg(genproto)'.build-dependencies] +tonic-build = { version = "0.14" } + +[dev-dependencies] +ark-grpc = { path = "../ark-grpc", version = "0.12.0", default-features = false, features = ["test-utils"] } +rustls = { version = "0.23", features = ["ring"] } +tempfile = "3.8" +tokio = { version = "1.41.0", features = ["macros", "net", "rt-multi-thread", "sync"] } +tokio-stream = { version = "0.1", features = ["net"] } +tonic-prost = "0.14" + +[features] +default = ["tls-native-roots"] +tls-native-roots = ["tonic/tls-native-roots", "ark-grpc/tls-native-roots"] +tls-webpki-roots = ["tonic/tls-webpki-roots", "ark-grpc/tls-webpki-roots"] +sqlite = ["sqlx", "rusqlite"] +test-utils = [] diff --git a/vendor/ark-client/README.md b/vendor/ark-client/README.md new file mode 100644 index 0000000..99546e4 --- /dev/null +++ b/vendor/ark-client/README.md @@ -0,0 +1,22 @@ +# ark-client + +High-level client library for building Arkade wallets in Rust. + +`ark-client` provides the wallet abstractions of the SDK. Use it to receive, select, and send virtual outputs, board funds from onchain, estimate fees, track the state of a virtual output, and join batch swaps through the operator. It talks to the operator through either supported transport client, `ark-grpc` or `ark-rest`. + +## Install + +```toml +[dependencies] +ark-client = "0.12.0" +``` + +Enable optional SQLite storage support with: + +```toml +ark-client = { version = "0.12.0", features = ["sqlite"] } +``` + +## Documentation + +API documentation is available on [docs.rs/ark-client](https://docs.rs/ark-client). diff --git a/vendor/ark-client/migrations/001_initial.sql b/vendor/ark-client/migrations/001_initial.sql new file mode 100644 index 0000000..389c497 --- /dev/null +++ b/vendor/ark-client/migrations/001_initial.sql @@ -0,0 +1,23 @@ +-- Initial schema for swap storage with separate tables for submarine and reverse swaps + +-- Create submarine swaps table +CREATE TABLE submarine_swaps ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL, -- JSON serialized SubmarineSwapData + created_at INTEGER NOT NULL, -- Unix timestamp + updated_at INTEGER NOT NULL -- Unix timestamp +); + +-- Create reverse swaps table +CREATE TABLE reverse_swaps ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL, -- JSON serialized ReverseSwapData + created_at INTEGER NOT NULL, -- Unix timestamp + updated_at INTEGER NOT NULL -- Unix timestamp +); + +-- Create indexes for time-based queries +CREATE INDEX idx_submarine_swaps_created_at ON submarine_swaps(created_at); +CREATE INDEX idx_submarine_swaps_updated_at ON submarine_swaps(updated_at); +CREATE INDEX idx_reverse_swaps_created_at ON reverse_swaps(created_at); +CREATE INDEX idx_reverse_swaps_updated_at ON reverse_swaps(updated_at); diff --git a/vendor/ark-client/migrations/002_chain_swaps.sql b/vendor/ark-client/migrations/002_chain_swaps.sql new file mode 100644 index 0000000..b30ff23 --- /dev/null +++ b/vendor/ark-client/migrations/002_chain_swaps.sql @@ -0,0 +1,11 @@ +-- Add chain swaps table for ARK <-> BTC chain swaps via Boltz + +CREATE TABLE chain_swaps ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL, -- JSON serialized ChainSwapData + created_at INTEGER NOT NULL, -- Unix timestamp + updated_at INTEGER NOT NULL -- Unix timestamp +); + +CREATE INDEX idx_chain_swaps_created_at ON chain_swaps(created_at); +CREATE INDEX idx_chain_swaps_updated_at ON chain_swaps(updated_at); diff --git a/vendor/ark-client/src/asset.rs b/vendor/ark-client/src/asset.rs new file mode 100644 index 0000000..b9b86a1 --- /dev/null +++ b/vendor/ark-client/src/asset.rs @@ -0,0 +1,294 @@ +use crate::error::ErrorContext; +use crate::send_vtxo::coin_select_vtxo; +use crate::send_vtxo::select_contract_vtxos; +use crate::swap_storage::SwapStorage; +use crate::wallet::OnchainWallet; +use crate::AnnotatedVtxo; +use crate::Blockchain; +use crate::Client; +use crate::Error; +use crate::GetVtxosRequestFilter; +use ark_core::asset::AssetId; +use ark_core::asset::ControlAssetConfig; +use ark_core::coin_select::select_vtxos; +use ark_core::coin_select::select_vtxos_for_asset; +use ark_core::send::build_asset_burn_transactions; +use ark_core::send::build_asset_reissuance_transactions; +use ark_core::send::build_self_asset_issuance_transactions; +use ark_core::send::AssetReissuanceTransactions; +use ark_core::send::SelfAssetIssuanceTransactions; +use bitcoin::Amount; +use bitcoin::Txid; +use std::collections::HashSet; + +/// Result of an asset issuance. +#[derive(Debug, Clone)] +pub struct IssueAssetResult { + /// The Ark transaction ID. + pub ark_txid: Txid, + /// The issued asset IDs. If a new control asset was created, it is first. + pub asset_ids: Vec, +} + +impl Client +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + /// Issue a new asset. + /// + /// Creates a fresh asset with the given `amount`. The asset is sent to the caller's own + /// address. If `control_asset` is provided, the asset can be reissued in the future. + pub async fn issue_asset( + &self, + amount: u64, + control_asset_config: Option, + metadata: Option>, + ) -> Result { + if amount == 0 { + return Err(Error::ad_hoc("asset amount must be > 0")); + } + + let server_info = self.server_info().await?; + let (own_address, _) = self.get_offchain_address().await?; + let spendable_contracts = self.spendable_virtual_vtxos().await?; + let spendable = spendable_contracts + .iter() + .map(coin_select_vtxo) + .collect::>(); + + let selected_coins = select_vtxos(spendable, server_info.dust, server_info.dust, true) + .map_err(Error::from) + .context("failed to select coins for asset issuance")?; + + let issuance_inputs = + self.build_vtxo_inputs(select_contract_vtxos(&spendable_contracts, &selected_coins))?; + let (change_address, change_address_vtxo) = self.get_offchain_address().await?; + + let SelfAssetIssuanceTransactions { + ark_tx, + checkpoint_txs, + asset_ids, + } = build_self_asset_issuance_transactions( + &own_address, + &change_address, + &issuance_inputs, + &server_info, + amount, + control_asset_config, + metadata, + ) + .map_err(Error::from) + .context("failed to build asset issuance transactions")?; + + let pending_tx = self + .submit_built_offchain_send(ark_tx, checkpoint_txs, change_address_vtxo.owner_pk()) + .await + .context("failed to submit asset issuance transaction")?; + + let ark_txid = pending_tx.ark_txid; + self.sign_and_finalize_pending_tx(pending_tx) + .await + .context("failed to finalize asset issuance transaction")?; + + Ok(IssueAssetResult { + ark_txid, + asset_ids, + }) + } + + /// Reissue additional units of an existing asset. + /// + /// The asset must have been created with a control asset. The control asset is spent as input + /// and sent back to the caller, while the new asset units are minted. + pub async fn reissue_asset(&self, asset_id: AssetId, amount: u64) -> Result { + if amount == 0 { + return Err(Error::ad_hoc("reissue amount must be > 0")); + } + + let server_info = self.server_info().await?; + let asset_info = self + .get_asset(asset_id) + .await + .context("failed to get asset info")?; + + let control_asset_id = asset_info.control_asset_id.ok_or_else(|| { + Error::ad_hoc(format!( + "Asset {} can't be reissued, no control asset", + asset_id + )) + })?; + + let spendable_contracts = self.spendable_virtual_vtxos().await?; + let spendable = spendable_contracts + .iter() + .map(coin_select_vtxo) + .collect::>(); + + let (control_coins, _control_change) = + select_vtxos_for_asset(&spendable, 1, control_asset_id) + .map_err(Error::from) + .context("failed to select control asset for reissuance")?; + + let mut selected_outpoints: HashSet<_> = + control_coins.iter().map(|coin| coin.outpoint).collect(); + let mut selected = control_coins; + let btc_provided: Amount = selected.iter().map(|coin| coin.amount).sum(); + let btc_shortfall = server_info + .dust + .checked_sub(btc_provided) + .unwrap_or(Amount::ZERO); + + if btc_shortfall > Amount::ZERO { + let available: Vec<_> = spendable + .iter() + .filter(|coin| !selected_outpoints.contains(&coin.outpoint)) + .cloned() + .collect(); + + let btc_coins = select_vtxos(available, btc_shortfall, server_info.dust, true) + .map_err(Error::from) + .context("failed to select BTC coins for reissuance")?; + + for coin in btc_coins { + if selected_outpoints.insert(coin.outpoint) { + selected.push(coin); + } + } + } + + let reissuance_inputs = + self.build_vtxo_inputs(select_contract_vtxos(&spendable_contracts, &selected))?; + let (self_address, _) = self.get_offchain_address().await?; + let (change_address, change_address_vtxo) = self.get_offchain_address().await?; + + let AssetReissuanceTransactions { + ark_tx, + checkpoint_txs, + } = build_asset_reissuance_transactions( + &self_address, + &change_address, + &reissuance_inputs, + &server_info, + asset_id, + control_asset_id, + amount, + ) + .map_err(Error::from) + .context("failed to build asset reissuance transactions")?; + + let pending_tx = self + .submit_built_offchain_send(ark_tx, checkpoint_txs, change_address_vtxo.owner_pk()) + .await + .context("failed to submit reissuance transaction")?; + + let ark_txid = pending_tx.ark_txid; + self.sign_and_finalize_pending_tx(pending_tx) + .await + .context("failed to finalize reissuance transaction")?; + + Ok(ark_txid) + } + + /// Burn a specific amount of an asset. + pub async fn burn_asset(&self, asset_id: AssetId, amount: u64) -> Result { + if amount == 0 { + return Err(Error::ad_hoc("burn amount must be > 0")); + } + + let server_info = self.server_info().await?; + let spendable_contracts = self.spendable_virtual_vtxos().await?; + let spendable = spendable_contracts + .iter() + .map(coin_select_vtxo) + .collect::>(); + + let (asset_coins, asset_change) = select_vtxos_for_asset(&spendable, amount, asset_id) + .map_err(Error::from) + .context("failed to select coins for asset burn")?; + + let mut selected_outpoints: HashSet<_> = + asset_coins.iter().map(|coin| coin.outpoint).collect(); + let mut selected = asset_coins; + + let mut carries_asset_change = asset_change > 0; + for coin in &selected { + if coin.assets.iter().any(|asset| asset.asset_id != asset_id) { + carries_asset_change = true; + break; + } + } + + let btc_provided: Amount = selected.iter().map(|coin| coin.amount).sum(); + let mut btc_needed = server_info.dust; + if carries_asset_change { + btc_needed += server_info.dust; + } + + let btc_shortfall = btc_needed.checked_sub(btc_provided).unwrap_or(Amount::ZERO); + if btc_shortfall > Amount::ZERO { + let available: Vec<_> = spendable + .iter() + .filter(|coin| !selected_outpoints.contains(&coin.outpoint)) + .cloned() + .collect(); + + let btc_coins = select_vtxos(available, btc_shortfall, server_info.dust, true) + .map_err(Error::from) + .context("failed to select BTC coins for asset burn")?; + + for coin in btc_coins { + if selected_outpoints.insert(coin.outpoint) { + selected.push(coin); + } + } + } + + let burn_inputs = + self.build_vtxo_inputs(select_contract_vtxos(&spendable_contracts, &selected))?; + let (own_address, _) = self.get_offchain_address().await?; + let (change_address, change_address_vtxo) = self.get_offchain_address().await?; + + let offchain = build_asset_burn_transactions( + &own_address, + &change_address, + &burn_inputs, + &server_info, + asset_id, + amount, + ) + .map_err(Error::from) + .context("failed to build asset burn transactions")?; + + let pending_tx = self + .submit_built_offchain_send( + offchain.ark_tx, + offchain.checkpoint_txs, + change_address_vtxo.owner_pk(), + ) + .await + .context("failed to submit asset burn transaction")?; + + let ark_txid = pending_tx.ark_txid; + self.sign_and_finalize_pending_tx(pending_tx) + .await + .context("failed to finalize asset burn transaction")?; + + Ok(ark_txid) + } + + async fn spendable_virtual_vtxos(&self) -> Result, Error> { + let vtxo_list = self + .list_vtxos_filtered(GetVtxosRequestFilter::Spendable) + .await + .context("failed to list VTXOs")?; + + let now = crate::utils::unix_now()?; + let server_info = self.server_info().await?; + Ok(vtxo_list + .spendable_offchain_at(&server_info, now) + .cloned() + .collect()) + } +} diff --git a/vendor/ark-client/src/batch.rs b/vendor/ark-client/src/batch.rs new file mode 100644 index 0000000..0158a2b --- /dev/null +++ b/vendor/ark-client/src/batch.rs @@ -0,0 +1,2218 @@ +use crate::error::ErrorContext as _; +use crate::swap_storage::SwapStorage; +use crate::utils::sleep; +use crate::utils::timeout_op; +use crate::wallet::OnchainWallet; +use crate::Blockchain; +use crate::Client; +use crate::Error; +use crate::GetVtxosRequestFilter; +use ark_core::batch; +use ark_core::batch::aggregate_nonces; +use ark_core::batch::complete_delegate_forfeit_txs; +use ark_core::batch::create_and_sign_forfeit_txs; +use ark_core::batch::create_asset_preservation_packet; +use ark_core::batch::generate_nonce_tree; +use ark_core::batch::sign_batch_tree_tx; +use ark_core::batch::sign_commitment_psbt; +use ark_core::batch::Delegate; +use ark_core::batch::NonceKps; +use ark_core::contract::SpendPathKind; +use ark_core::intent; +use ark_core::script::extract_checksig_pubkeys; +use ark_core::server; +use ark_core::server::BatchTreeEventType; +use ark_core::server::PartialSigTree; +use ark_core::server::StreamEvent; +use ark_core::ArkAddress; +use ark_core::ArkNote; +use ark_core::ExplorerUtxo; +use ark_core::TxGraph; +use backon::ExponentialBuilder; +use backon::Retryable; +use bitcoin::hashes::sha256; +use bitcoin::hashes::Hash; +use bitcoin::hex::DisplayHex; +use bitcoin::key::Keypair; +use bitcoin::key::Secp256k1; +use bitcoin::psbt; +use bitcoin::secp256k1; +use bitcoin::secp256k1::schnorr; +use bitcoin::secp256k1::PublicKey; +use bitcoin::Address; +use bitcoin::Amount; +use bitcoin::OutPoint; +use bitcoin::Psbt; +use bitcoin::TxOut; +use bitcoin::Txid; +use bitcoin::XOnlyPublicKey; +use futures::StreamExt; +use rand::CryptoRng; +use rand::Rng; +use std::collections::HashMap; +use std::collections::HashSet; + +impl Client +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + /// Settle _all_ prior VTXOs and boarding outputs into the next batch, generating new confirmed + /// VTXOs. + /// + /// Most callers should prefer [`Self::settle`], which only renews VTXOs that have actually + /// expired. Settling unexpired VTXOs is rarely necessary. + pub async fn settle_all(&self, rng: &mut R) -> Result, Error> + where + R: Rng + CryptoRng + Clone, + { + self.settle_at(crate::utils::unix_now()?, rng).await + } + + pub(crate) async fn settle_at(&self, now: i64, rng: &mut R) -> Result, Error> + where + R: Rng + CryptoRng + Clone, + { + let server_info = self.server_info().await?; + + // Get off-chain address and send all funds to this address, no change output 🦄 + let (to_address, _) = self.get_offchain_address_with_server_info(&server_info)?; + + let (boarding_inputs, vtxo_inputs, total_amount) = self + .fetch_commitment_transaction_inputs(&server_info, now) + .await?; + + tracing::debug!( + offchain_adress = %to_address.encode(), + ?boarding_inputs, + ?vtxo_inputs, + "Attempting to settle outputs" + ); + + if boarding_inputs.is_empty() && vtxo_inputs.is_empty() { + tracing::debug!("No inputs to board with"); + return Ok(None); + } + + let join_next_batch = || async { + self.join_next_batch( + &mut rng.clone(), + &server_info, + boarding_inputs.clone(), + vtxo_inputs.clone(), + BatchOutputType::Board { + to_address, + to_amount: total_amount, + }, + ) + .await + }; + + // Joining a batch can fail depending on the timing, so we try a few times. + let commitment_txid = join_next_batch + .retry(ExponentialBuilder::default().with_max_times(0)) + .sleep(sleep) + .when(|err| !err.is_server_info_changed()) + .notify(|err: &Error, dur: std::time::Duration| { + tracing::warn!("Retrying joining next batch after {dur:?}. Error: {err}",); + }) + .await + .context("Failed to join batch")?; + + tracing::info!(%commitment_txid, "Settlement success"); + + Ok(Some(commitment_txid)) + } + + /// Settle prior VTXOs that have expired or are recoverable, together with all available + /// boarding outputs, into the next batch, generating new confirmed VTXOs. + /// + /// Healthy (unexpired) VTXOs are left untouched. This is the path callers typically want when + /// periodically renewing their wallet: healthy VTXOs do not need to be touched, and including + /// them would only inflate batch fees. Boarding outputs are always included because callers + /// generally want freshly funded coins to enter the Ark. + /// + /// NOTE: sub-dust recoverable VTXOs can only be rescued when their combined value exceeds the + /// server's dust threshold; otherwise the batch protocol rejects the settlement with a + /// `cannot settle into sub-dust VTXO` error. When the wallet holds isolated sub-dust amounts, + /// fall back to [`Self::settle_all`], which can roll them in alongside healthy VTXOs that + /// act as carrier value. + pub async fn settle(&self, rng: &mut R) -> Result, Error> + where + R: Rng + CryptoRng + Clone, + { + let server_info = self.server_info().await?; + + let vtxo_list = self + .list_vtxos_with_server_info(&server_info, Some(GetVtxosRequestFilter::Renewable)) + .await?; + let vtxo_outpoints: Vec = vtxo_list + .recoverable() + .map(|entry| entry.vtxo().outpoint) + .collect(); + + let (boarding_inputs, _, _) = self + .fetch_commitment_transaction_inputs(&server_info, crate::utils::unix_now()?) + .await?; + let boarding_outpoints: Vec = + boarding_inputs.iter().map(|i| i.outpoint()).collect(); + + if vtxo_outpoints.is_empty() && boarding_outpoints.is_empty() { + tracing::debug!("No expired/recoverable VTXOs or boarding outputs to settle"); + return Ok(None); + } + + tracing::debug!( + num_vtxos = vtxo_outpoints.len(), + num_boarding = boarding_outpoints.len(), + "Attempting to settle expired/recoverable VTXOs and boarding outputs" + ); + + self.settle_vtxos_with_server_info(rng, &server_info, &vtxo_outpoints, &boarding_outpoints) + .await + } + + /// Settle _all_ prior VTXOs, boarding outputs, and the provided ArkNotes into the next batch. + /// + /// ArkNotes are bearer tokens that can be redeemed by revealing their preimage. + /// This method combines them with regular VTXOs and boarding outputs into a single + /// settlement transaction. + pub async fn settle_with_notes( + &self, + rng: &mut R, + notes: Vec, + ) -> Result, Error> + where + R: Rng + CryptoRng + Clone, + { + let server_info = self.server_info().await?; + + let (to_address, _) = self.get_offchain_address_with_server_info(&server_info)?; + + let (boarding_inputs, vtxo_inputs, mut total_amount) = self + .fetch_commitment_transaction_inputs(&server_info, crate::utils::unix_now()?) + .await?; + + // Convert arknotes to intent inputs and add their value to total + let note_inputs: Vec = notes + .iter() + .map(|note| { + total_amount += note.value(); + note.to_intent_input() + }) + .collect::, _>>()?; + + // Combine VTXO inputs with note inputs + let all_vtxo_inputs: Vec = + vtxo_inputs.into_iter().chain(note_inputs).collect(); + + tracing::debug!( + offchain_address = %to_address.encode(), + ?boarding_inputs, + num_vtxo_inputs = all_vtxo_inputs.len(), + num_notes = notes.len(), + %total_amount, + "Attempting to settle outputs with notes" + ); + + if boarding_inputs.is_empty() && all_vtxo_inputs.is_empty() { + tracing::debug!("No inputs to settle"); + return Ok(None); + } + + let join_next_batch = || async { + self.join_next_batch( + &mut rng.clone(), + &server_info, + boarding_inputs.clone(), + all_vtxo_inputs.clone(), + BatchOutputType::Board { + to_address, + to_amount: total_amount, + }, + ) + .await + }; + + let commitment_txid = join_next_batch + .retry(ExponentialBuilder::default().with_max_times(0)) + .sleep(sleep) + .when(|err| !err.is_server_info_changed()) + .notify(|err: &Error, dur: std::time::Duration| { + tracing::warn!("Retrying joining next batch after {dur:?}. Error: {err}"); + }) + .await + .context("Failed to join batch")?; + + tracing::info!(%commitment_txid, num_notes = notes.len(), "Settlement with notes success"); + + Ok(Some(commitment_txid)) + } + + /// Settle specific VTXOs and boarding outputs by outpoint into the next batch, generating new + /// confirmed VTXOs. + /// + /// Unlike [`Self::settle`], this method allows the caller to specify exactly which VTXOs and + /// boarding outputs to settle by providing their outpoints. + pub async fn settle_vtxos( + &self, + rng: &mut R, + vtxo_outpoints: &[OutPoint], + boarding_outpoints: &[OutPoint], + ) -> Result, Error> + where + R: Rng + CryptoRng + Clone, + { + let server_info = self.server_info().await?; + self.settle_vtxos_with_server_info(rng, &server_info, vtxo_outpoints, boarding_outpoints) + .await + } + + pub(crate) async fn settle_vtxos_with_server_info( + &self, + rng: &mut R, + server_info: &server::Info, + vtxo_outpoints: &[OutPoint], + boarding_outpoints: &[OutPoint], + ) -> Result, Error> + where + R: Rng + CryptoRng + Clone, + { + // Get off-chain address and send all funds to this address, no change output. + let (to_address, _) = self.get_offchain_address_with_server_info(server_info)?; + + let (all_boarding_inputs, all_vtxo_inputs, _) = self + .fetch_commitment_transaction_inputs(server_info, crate::utils::unix_now()?) + .await?; + + // Filter boarding inputs to only those specified. + let boarding_inputs: Vec<_> = all_boarding_inputs + .into_iter() + .filter(|input| boarding_outpoints.contains(&input.outpoint())) + .collect(); + + // Filter VTXO inputs to only those specified. + let vtxo_inputs: Vec<_> = all_vtxo_inputs + .into_iter() + .filter(|input| vtxo_outpoints.contains(&input.outpoint())) + .collect(); + + // Recalculate total amount from filtered inputs. + let total_amount = boarding_inputs + .iter() + .map(|i| i.amount()) + .chain(vtxo_inputs.iter().map(|i| i.amount())) + .fold(Amount::ZERO, |acc, a| acc + a); + + tracing::debug!( + offchain_address = %to_address.encode(), + ?boarding_inputs, + ?vtxo_inputs, + %total_amount, + "Attempting to settle specific outputs" + ); + + if boarding_inputs.is_empty() && vtxo_inputs.is_empty() { + tracing::debug!("No matching inputs to settle"); + return Ok(None); + } + + let join_next_batch = || async { + self.join_next_batch( + &mut rng.clone(), + server_info, + boarding_inputs.clone(), + vtxo_inputs.clone(), + BatchOutputType::Board { + to_address, + to_amount: total_amount, + }, + ) + .await + }; + + // Joining a batch can fail depending on the timing, so we try a few times. + let commitment_txid = join_next_batch + .retry(ExponentialBuilder::default().with_max_times(0)) + .sleep(sleep) + .when(|err| !err.is_server_info_changed()) + .notify(|err: &Error, dur: std::time::Duration| { + tracing::warn!("Retrying joining next batch after {dur:?}. Error: {err}",); + }) + .await + .context("Failed to join batch")?; + + tracing::info!(%commitment_txid, "Settlement of specific VTXOs success"); + + Ok(Some(commitment_txid)) + } + + /// The operator's input fee for the VTXOs among `inputs` (boarding inputs are not VTXOs). + async fn vtxo_input_fees( + &self, + server_info: &server::Info, + inputs: &[intent::Input], + ) -> Result { + let spent: HashSet = inputs.iter().map(|input| input.outpoint()).collect(); + let list = self + .list_vtxos_with_server_info(server_info, Some(GetVtxosRequestFilter::Renewable)) + .await + .context("failed to get VTXO list")?; + self.eval_offchain_input_fees( + list.all_unspent() + .map(|entry| entry.vtxo()) + .filter(|vtxo| spent.contains(&vtxo.outpoint)), + server_info.dust, + ) + } + + /// Settle _some_ prior VTXOs and boarding outputs into the next batch, generating UTXOs as + /// outputs to a new commitment transaction. + pub async fn collaborative_redeem( + &self, + rng: &mut R, + to_address: Address, + to_amount: Amount, + ) -> Result + where + R: Rng + CryptoRng + Clone, + { + let server_info = self.server_info().await?; + + let (change_address, _) = self.get_offchain_address_with_server_info(&server_info)?; + + let (boarding_inputs, vtxo_inputs, total_amount) = self + .fetch_commitment_transaction_inputs(&server_info, crate::utils::unix_now()?) + .await?; + + // The operator charges the on-chain output and each VTXO spent. + let onchain_fee = self.eval_onchain_output_fee(ark_fees::Output { + amount: to_amount.to_sat(), + script: to_address.script_pubkey().to_string(), + })? + self.vtxo_input_fees(&server_info, &vtxo_inputs).await?; + + // Fee comes out of change, not the send amount. + let change_amount = total_amount + .checked_sub(to_amount) + .and_then(|a| a.checked_sub(onchain_fee)) + .ok_or_else(|| { + Error::coin_select(format!( + "insufficient balance: {total_amount} < {to_amount} (send) + {onchain_fee} (fee)" + )) + })?; + + tracing::info!( + %to_address, + send_amount = %to_amount, + fee = %onchain_fee, + change_address = %change_address.encode(), + %change_amount, + ?boarding_inputs, + "Attempting to collaboratively redeem outputs" + ); + + let join_next_batch = || async { + self.join_next_batch( + &mut rng.clone(), + &server_info, + boarding_inputs.clone(), + vtxo_inputs.clone(), + BatchOutputType::OffBoard { + to_address: to_address.clone(), + to_amount, + change_address, + change_amount, + }, + ) + .await + }; + + // Joining a batch can fail depending on the timing, so we try a few times. + let commitment_txid = join_next_batch + .retry(ExponentialBuilder::default().with_max_times(3)) + .sleep(sleep) + .when(|err| !err.is_server_info_changed()) + .notify(|err: &Error, dur: std::time::Duration| { + tracing::warn!("Retrying joining next batch after {dur:?}. Error: {err}"); + }) + .await + .context("Failed to join batch")?; + + tracing::info!(%commitment_txid, "Collaborative redeem success"); + + Ok(commitment_txid) + } + + /// Settle a selection of VTXOs into the next batch, generating UTXOs as + /// outputs to a new commitment transaction. + pub async fn collaborative_redeem_vtxo_selection( + &self, + rng: &mut R, + input_vtxos: impl Iterator + Clone, + to_address: Address, + to_amount: Amount, + ) -> Result + where + R: Rng + CryptoRng + Clone, + { + let server_info = self.server_info().await?; + + let (change_address, _) = self.get_offchain_address_with_server_info(&server_info)?; + + let vtxo_inputs = self + .selected_batch_settleable_vtxo_inputs(&server_info, input_vtxos) + .await?; + + if vtxo_inputs.is_empty() { + return Err(Error::ad_hoc("no matching VTXO outpoints found")); + } + + // Check that total amount is sufficient + let total_input_amount = vtxo_inputs + .iter() + .fold(Amount::ZERO, |acc, vtxo| acc + vtxo.amount()); + + // The operator charges the on-chain output and each VTXO spent. + let onchain_fee = self.eval_onchain_output_fee(ark_fees::Output { + amount: to_amount.to_sat(), + script: to_address.script_pubkey().to_string(), + })? + self.vtxo_input_fees(&server_info, &vtxo_inputs).await?; + + // Fee comes out of change, not the send amount. + let change_amount = total_input_amount + .checked_sub(to_amount) + .and_then(|a| a.checked_sub(onchain_fee)) + .ok_or_else(|| { + Error::coin_select(format!( + "insufficient VTXO amount: {total_input_amount} < {to_amount} (send) + {onchain_fee} (fee)" + )) + })?; + + tracing::info!( + %to_address, + send_amount = %to_amount, + fee = %onchain_fee, + change_address = %change_address.encode(), + %change_amount, + "Attempting to collaboratively redeem outputs" + ); + + let join_next_batch = || async { + self.join_next_batch( + &mut rng.clone(), + &server_info, + Vec::new(), + vtxo_inputs.clone(), + BatchOutputType::OffBoard { + to_address: to_address.clone(), + to_amount, + change_address, + change_amount, + }, + ) + .await + }; + + // Joining a batch can fail depending on the timing, so we try a few times. + let commitment_txid = join_next_batch + .retry(ExponentialBuilder::default().with_max_times(3)) + .sleep(sleep) + .when(|err| !err.is_server_info_changed()) + .notify(|err: &Error, dur: std::time::Duration| { + tracing::warn!("Retrying joining next batch after {dur:?}. Error: {err}"); + }) + .await + .context("Failed to join batch")?; + + tracing::info!(%commitment_txid, "Collaborative redeem success"); + + Ok(commitment_txid) + } + + pub(crate) async fn selected_batch_settleable_vtxo_inputs( + &self, + server_info: &server::Info, + input_vtxos: impl IntoIterator, + ) -> Result, Error> { + let requested: HashSet = input_vtxos.into_iter().collect(); + + let vtxo_list = self + .list_vtxos_with_server_info(server_info, Some(GetVtxosRequestFilter::Renewable)) + .await + .context("failed to get VTXO list")?; + let now = crate::utils::unix_now()?; + + let matching_unspent = vtxo_list + .all_unspent() + .filter(|entry| requested.contains(&entry.vtxo().outpoint)) + .collect::>(); + + let settleable = vtxo_list + .batch_settleable_at(server_info, now) + .filter(|entry| requested.contains(&entry.vtxo().outpoint)) + .collect::>(); + let settleable_outpoints = settleable + .iter() + .map(|entry| entry.vtxo().outpoint) + .collect::>(); + + let blocked = matching_unspent + .iter() + .filter(|entry| !settleable_outpoints.contains(&entry.vtxo().outpoint)) + .map(|entry| entry.vtxo().outpoint.to_string()) + .collect::>(); + if !blocked.is_empty() { + return Err(Error::ad_hoc(format!( + "selected VTXO outpoints are not batch-settleable because their signer cutoff has passed: {}", + blocked.join(", ") + ))); + } + + settleable + .into_iter() + .map(|entry| { + let spend_selection = entry.spend_selection(SpendPathKind::Forfeit)?; + + Ok(intent::Input::new_with_spend_selection( + entry.vtxo().outpoint, + entry.exit_delay()?, + TxOut { + value: entry.vtxo().amount, + script_pubkey: entry.script_pubkey(), + }, + entry.tapscripts(), + spend_selection, + false, + entry.vtxo().is_swept, + entry.vtxo().assets.clone(), + )) + }) + .collect::, Error>>() + } + + /// Generate a delegate for settling VTXOs on behalf of the owner. + /// + /// The owner pre-signs the intent and forfeit transactions, allowing another party to complete + /// the settlement at a later time using the provided `delegate_cosigner_pk`. + /// + /// # Arguments + /// + /// * `delegate_cosigner_pk` - The cosigner public key that the delegate will use + /// * `select_recoverable_vtxos` - Whether to include recoverable VTXOs + /// + /// # Returns + /// + /// A [`Delegate`] struct containing all the pre-signed data needed for settlement. + pub async fn generate_delegate( + &self, + delegate_cosigner_pk: PublicKey, + ) -> Result { + let server_info = self.server_info().await?; + + // Get off-chain address and send all funds to this address. + let (to_address, _) = self.get_offchain_address_with_server_info(&server_info)?; + + // Simply collect all VTXOs that can be settled. + let (_, vtxo_inputs, _) = self + .fetch_commitment_transaction_inputs(&server_info, crate::utils::unix_now()?) + .await?; + + let total_amount = vtxo_inputs + .iter() + .fold(Amount::ZERO, |acc, v| acc + v.amount()); + + if vtxo_inputs.is_empty() { + return Err(Error::ad_hoc("no inputs to settle via delegate")); + } + + let mut outputs = vec![intent::Output::Offchain(TxOut { + value: total_amount, + script_pubkey: to_address.to_p2tr_script_pubkey(), + })]; + + if let Some(packet) = create_asset_preservation_packet(&vtxo_inputs, &outputs)? { + outputs.push(intent::Output::AssetPacket(packet.to_txout())); + } + + let delegate = batch::prepare_delegate_psbts( + vtxo_inputs, + outputs, + delegate_cosigner_pk, + &server_info.forfeit_address, + server_info.dust, + )?; + + Ok(delegate) + } + + /// Sign a set of delegate PSBTs, including the intent PSBT and the forfeit PSBTs. + pub fn sign_delegate_psbts( + &self, + intent_psbt: &mut Psbt, + forfeit_psbts: &mut [Psbt], + ) -> Result<(), Error> { + let sign_fn = + |input: &mut psbt::Input, + msg: secp256k1::Message| + -> Result, ark_core::Error> { + match &input.witness_script { + None => Err(ark_core::Error::ad_hoc( + "Missing witness script for psbt::Input", + )), + Some(script) => { + let mut res = vec![]; + let pks = extract_checksig_pubkeys(script); + for pk in pks { + if let Ok(keypair) = self.keypair_by_pk(&pk) { + let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &keypair); + let pk = keypair.x_only_public_key().0; + res.push((sig, pk)); + } + } + Ok(res) + } + } + }; + + batch::sign_delegate_psbts(sign_fn, intent_psbt, forfeit_psbts)?; + + Ok(()) + } + + /// Settle a delegate by completing the batch protocol using pre-signed data. + /// + /// This method allows Bob to settle Alice's VTXOs using the pre-signed intent and forfeit + /// transactions from the [`Delegate`] struct. + /// + /// # Arguments + /// + /// * `rng` - Random number generator for nonce generation + /// * `delegate` - The delegate struct containing pre-signed data + /// * `own_cosigner_kp` - Bob's cosigner keypair (must match the delegate_cosigner_pk) + /// + /// # Returns + /// + /// The commitment transaction ID if successful. + pub async fn settle_delegate( + &self, + rng: &mut R, + delegate: Delegate, + own_cosigner_kp: Keypair, + ) -> Result + where + R: Rng + CryptoRng, + { + // Verify the cosigner key matches + if own_cosigner_kp.public_key() != delegate.delegate_cosigner_pk { + return Err(Error::ad_hoc( + "provided cosigner keypair does not match delegate_cosigner_pk", + )); + } + + let server_info = self.server_info().await?; + + // Register the pre-signed intent + let intent_id = timeout_op( + self.inner.timeout, + self.network_client() + .register_intent(delegate.intent.clone()), + ) + .await + .context("failed to register delegated intent")??; + + tracing::debug!(intent_id, "Registered delegated intent"); + + let network_client = self.network_client(); + + #[derive(Debug, PartialEq, Eq)] + enum Step { + Start, + BatchStarted, + BatchSigningStarted, + Finalized, + } + + impl Step { + fn next(&self) -> Step { + match self { + Step::Start => Step::BatchStarted, + Step::BatchStarted => Step::BatchSigningStarted, + Step::BatchSigningStarted => Step::Finalized, + Step::Finalized => Step::Finalized, + } + } + } + + let mut step = Step::Start; + + let own_cosigner_kps = [own_cosigner_kp]; + let own_cosigner_pks = own_cosigner_kps + .iter() + .map(|k| k.public_key()) + .collect::>(); + + let mut batch_id: Option = None; + + let vtxo_input_outpoints = delegate + .forfeit_psbts + .iter() + .map(|psbt| psbt.unsigned_tx.input[0].previous_output) + .collect::>(); + + let topics = vtxo_input_outpoints + .iter() + .map(ToString::to_string) + .chain( + own_cosigner_pks + .iter() + .map(|pk| pk.serialize().to_lower_hex_string()), + ) + .collect(); + + let mut stream = network_client.get_event_stream(topics).await?; + + let (ark_forfeit_pk, _) = server_info.forfeit_pk.x_only_public_key(); + + let mut unsigned_commitment_tx = None; + + let mut vtxo_batch_tree_graph_chunks = Some(Vec::new()); + let mut vtxo_batch_tree_graph: Option = None; + + let mut connectors_graph_chunks = Some(Vec::new()); + let mut batch_expiry = None; + + let mut agg_nonce_pks = HashMap::new(); + + let mut our_nonce_trees: Option> = None; + + loop { + match timeout_op(self.inner.timeout, stream.next()) + .await + .context("timed out waiting for batch event")? + { + Some(Ok(event)) => match event { + StreamEvent::BatchStarted(e) => { + if step != Step::Start { + continue; + } + + let hash = sha256::Hash::hash(intent_id.as_bytes()); + let hash = hash.as_byte_array().to_vec().to_lower_hex_string(); + + if e.intent_id_hashes.iter().any(|h| h == &hash) { + timeout_op( + self.inner.timeout, + self.network_client() + .confirm_registration(intent_id.clone()), + ) + .await + .context("failed to confirm intent registration")??; + + tracing::info!(batch_id = e.id, intent_id, "Intent ID found for batch"); + + batch_id = Some(e.id); + + step = Step::BatchStarted; + + batch_expiry = Some(e.batch_expiry); + } else { + tracing::debug!( + batch_id = e.id, + intent_id, + "Intent ID not found for batch" + ); + } + } + StreamEvent::TreeTx(e) => { + if step != Step::BatchStarted && step != Step::BatchSigningStarted { + continue; + } + + match e.batch_tree_event_type { + BatchTreeEventType::Vtxo => { + match &mut vtxo_batch_tree_graph_chunks { + Some(vtxo_batch_tree_graph_chunks) => { + tracing::debug!("Got new VTXO batch-tree graph chunk"); + + vtxo_batch_tree_graph_chunks.push(e.tx_graph_chunk) + } + None => { + return Err(Error::ark_server( + "received unexpected VTXO batch-tree graph chunk", + )); + } + }; + } + BatchTreeEventType::Connector => { + match connectors_graph_chunks { + Some(ref mut connectors_graph_chunks) => { + tracing::debug!("Got new connectors graph chunk"); + + connectors_graph_chunks.push(e.tx_graph_chunk) + } + None => { + return Err(Error::ark_server( + "received unexpected connectors graph chunk", + )); + } + }; + } + } + } + StreamEvent::TreeSignature(e) => { + if step != Step::BatchSigningStarted { + continue; + } + + match e.batch_tree_event_type { + BatchTreeEventType::Vtxo => { + match vtxo_batch_tree_graph { + Some(ref mut vtxo_batch_tree_graph) => { + vtxo_batch_tree_graph.apply(|graph| { + if graph.root().unsigned_tx.compute_txid() != e.txid { + Ok(true) + } else { + graph.set_signature(e.signature); + + Ok(false) + } + })?; + } + None => { + return Err(Error::ark_server( + "received batch-tree signature without transaction graph", + )); + } + }; + } + BatchTreeEventType::Connector => { + return Err(Error::ark_server( + "received batch-tree signature for connector tree", + )); + } + } + } + StreamEvent::TreeSigningStarted(e) => { + if step != Step::BatchStarted { + continue; + } + + let chunks = vtxo_batch_tree_graph_chunks.take().ok_or(Error::ark_server( + "received batch-tree signing started event without VTXO batch-tree graph chunks", + ))?; + vtxo_batch_tree_graph = + Some(TxGraph::new(chunks).map_err(Error::from).context( + "failed to build VTXO batch-tree graph before generating nonces", + )?); + + tracing::info!(batch_id = e.id, "Batch signing started"); + + for own_cosigner_pk in own_cosigner_pks.iter() { + if !&e.cosigners_pubkeys.iter().any(|p| p == own_cosigner_pk) { + return Err(Error::ark_server(format!( + "own cosigner PK is not present in cosigner PKs: {own_cosigner_pk}" + ))); + } + } + + let mut our_nonce_tree_map = HashMap::new(); + for own_cosigner_kp in own_cosigner_kps { + let own_cosigner_pk = own_cosigner_kp.public_key(); + let nonce_tree = generate_nonce_tree( + rng, + vtxo_batch_tree_graph + .as_ref() + .expect("VTXO batch-tree graph"), + own_cosigner_pk, + &e.unsigned_commitment_tx, + ) + .map_err(Error::from) + .context("failed to generate VTXO nonce tree")?; + + tracing::info!( + cosigner_pk = %own_cosigner_pk, + "Submitting nonce tree for cosigner PK" + ); + + network_client + .submit_tree_nonces( + &e.id, + own_cosigner_pk, + nonce_tree.to_nonce_pks(), + ) + .await + .map_err(Error::ark_server) + .context("failed to submit VTXO nonce tree")?; + + our_nonce_tree_map.insert(own_cosigner_kp, nonce_tree); + } + + unsigned_commitment_tx = Some(e.unsigned_commitment_tx); + our_nonce_trees = Some(our_nonce_tree_map); + + step = step.next(); + } + StreamEvent::TreeNonces(e) => { + if step != Step::BatchSigningStarted { + continue; + } + + let tree_tx_nonce_pks = e.nonces; + + let cosigner_pk = match tree_tx_nonce_pks.0.iter().find(|(pk, _)| { + own_cosigner_pks + .iter() + .any(|p| &&p.x_only_public_key().0 == pk) + }) { + Some((pk, _)) => *pk, + None => { + tracing::debug!( + batch_id = e.id, + txid = %e.txid, + "Received irrelevant TreeNonces event" + ); + + continue; + } + }; + + tracing::debug!( + batch_id = e.id, + txid = %e.txid, + %cosigner_pk, + "Received TreeNonces event" + ); + + let agg_nonce_pk = aggregate_nonces(tree_tx_nonce_pks); + + agg_nonce_pks.insert(e.txid, agg_nonce_pk); + + if vtxo_batch_tree_graph.is_none() { + let chunks = vtxo_batch_tree_graph_chunks.take().ok_or(Error::ark_server( + "received batch-tree nonces event without VTXO batch-tree graph chunks", + ))?; + vtxo_batch_tree_graph = Some( + TxGraph::new(chunks) + .map_err(Error::from) + .context("failed to build VTXO batch-tree graph before batch-tree signing")?, + ); + } + let vtxo_batch_tree_graph_ref = + vtxo_batch_tree_graph.as_ref().expect("just populated"); + + if agg_nonce_pks.len() == vtxo_batch_tree_graph_ref.nb_of_nodes() { + let cosigner_kp = own_cosigner_kps + .iter() + .find(|kp| kp.public_key().x_only_public_key().0 == cosigner_pk) + .ok_or_else(|| { + Error::ad_hoc("no cosigner keypair to sign for own PK") + })?; + + let our_nonce_trees = our_nonce_trees.as_mut().ok_or( + Error::ark_server("missing nonce trees during batch protocol"), + )?; + + let our_nonce_tree = + our_nonce_trees + .get_mut(cosigner_kp) + .ok_or(Error::ark_server( + "missing nonce tree during batch protocol", + ))?; + + let unsigned_commitment_tx = unsigned_commitment_tx + .as_ref() + .ok_or_else(|| Error::ad_hoc("missing commitment TX"))?; + + let batch_expiry = batch_expiry + .ok_or_else(|| Error::ad_hoc("missing batch expiry"))?; + + let mut partial_sig_tree = PartialSigTree::default(); + for (txid, _) in vtxo_batch_tree_graph_ref.as_map() { + let agg_nonce_pk = agg_nonce_pks.get(&txid).ok_or_else(|| { + Error::ad_hoc(format!( + "missing aggregated nonce PK for TX {txid}" + )) + })?; + + let sigs = sign_batch_tree_tx( + txid, + batch_expiry, + ark_forfeit_pk, + cosigner_kp, + *agg_nonce_pk, + vtxo_batch_tree_graph_ref, + unsigned_commitment_tx, + our_nonce_tree, + ) + .map_err(Error::from) + .context("failed to sign VTXO batch-tree transactions")?; + + partial_sig_tree.0.extend(sigs.0); + } + + network_client + .submit_tree_signatures( + &e.id, + cosigner_kp.public_key(), + partial_sig_tree, + ) + .await + .map_err(Error::ark_server) + .context("failed to submit VTXO batch-tree signatures")?; + } + } + StreamEvent::TreeNoncesAggregated(e) => { + tracing::debug!(batch_id = e.id, "Batch combined nonces generated"); + } + StreamEvent::BatchFinalization(e) => { + if step != Step::BatchSigningStarted { + continue; + } + + tracing::debug!( + commitment_txid = %e.commitment_tx.unsigned_tx.compute_txid(), + "Batch finalization started (delegate)" + ); + + let chunks = connectors_graph_chunks.take().ok_or(Error::ark_server( + "received batch finalization event without connectors", + ))?; + + if chunks.is_empty() { + tracing::debug!(batch_id = e.id, "No delegated forfeit transactions"); + } else { + let connectors_graph = TxGraph::new(chunks) + .map_err(Error::from) + .context( + "failed to build connectors graph before completing forfeit TXs", + )?; + + tracing::debug!( + batch_id = e.id, + "Completing delegated forfeit transactions" + ); + + let signed_forfeit_psbts = complete_delegate_forfeit_txs( + &delegate.forfeit_psbts, + &connectors_graph.leaves(), + )?; + + network_client + .submit_signed_forfeit_txs(signed_forfeit_psbts, None) + .await?; + } + + step = step.next(); + } + StreamEvent::BatchFinalized(e) => { + if step != Step::Finalized { + continue; + } + + let commitment_txid = e.commitment_txid; + + tracing::info!(batch_id = e.id, %commitment_txid, "Delegated batch finalized"); + + return Ok(commitment_txid); + } + StreamEvent::BatchFailed(ref e) => { + if Some(&e.id) == batch_id.as_ref() { + return Err(Error::ark_server(format!( + "batch failed {}: {}", + e.id, e.reason + ))); + } + + tracing::debug!("Unrelated batch failed: {e:?}"); + } + StreamEvent::Heartbeat => {} + StreamEvent::StreamStarted(_) => {} + }, + Some(Err(e)) => { + tracing::error!("Got error from event stream"); + + return Err(Error::ark_server(e)); + } + None => { + return Err(Error::ark_server("dropped batch event stream")); + } + } + } + } + + /// Get all the [`batch::OnChainInput`]s and [`batch::VtxoInput`]s that can be used to join an + /// upcoming batch. + pub(crate) async fn fetch_commitment_transaction_inputs( + &self, + server_info: &server::Info, + now: i64, + ) -> Result<(Vec, Vec, Amount), Error> { + let now = u64::try_from(now).map_err(|_| Error::ad_hoc("negative timestamp"))?; + + // Get all known boarding outputs. + let boarding_outputs = self.boarding_outputs()?; + + let mut boarding_inputs: Vec = Vec::new(); + let mut total_amount = Amount::ZERO; + + // To track unique outpoints and prevent duplicates + let mut seen_outpoints = HashSet::new(); + + // Find outpoints for each boarding output. + for boarding_output in boarding_outputs { + let outpoints = timeout_op( + self.inner.timeout, + self.blockchain().find_outpoints(boarding_output.address()), + ) + .await + .context("failed to find outpoints")??; + + for o in outpoints.iter() { + if let ExplorerUtxo { + outpoint, + amount, + confirmation_blocktime: Some(confirmation_blocktime), + confirmations, + is_spent: false, + } = o + { + // Check for duplicate outpoints + if seen_outpoints.contains(outpoint) { + continue; + } + + // Skip boarding outputs whose server key is past its cooperative-sign + // cutoff — the operator won't co-sign the old key's forfeit path. + // These must be recovered via unilateral exit (send_on_chain). + if server_info + .signer_requires_recovery_at(boarding_output.server_pk(), now as i64) + { + continue; + } + + // Only include confirmed boarding outputs with an _inactive_ exit path. + if !boarding_output.can_be_claimed_unilaterally_by_owner( + std::time::Duration::from_secs(now), + std::time::Duration::from_secs(*confirmation_blocktime), + *confirmations, + ) { + // Mark this outpoint as seen + seen_outpoints.insert(*outpoint); + + let script_pubkey = boarding_output.script_pubkey(); + let tapscripts = boarding_output.tapscripts(); + let spend_selection = + boarding_output.spend_selection(SpendPathKind::Forfeit)?; + + boarding_inputs.push(batch::OnChainInput::new_with_spend_selection( + boarding_output.exit_delay(), + script_pubkey, + tapscripts, + spend_selection, + boarding_output.owner_pk(), + *amount, + *outpoint, + )); + total_amount += *amount; + } + } + } + } + + let vtxo_list = self + .list_vtxos_with_server_info(server_info, Some(GetVtxosRequestFilter::Renewable)) + .await?; + // Reuse the caller-supplied timestamp (not a fresh wall-clock) so the VTXO cutoff filter + // below is evaluated against the same instant as the boarding filter above, and so a + // test-injected `now` deterministically controls both. + let settleable_vtxos: Vec<_> = vtxo_list + .batch_settleable_at(server_info, now as i64) + .collect(); + + total_amount += settleable_vtxos + .iter() + .fold(Amount::ZERO, |acc, entry| acc + entry.vtxo().amount); + + let vtxo_inputs = settleable_vtxos + .into_iter() + .map(|entry| { + let spend_selection = entry.spend_selection(SpendPathKind::Forfeit)?; + + Ok(intent::Input::new_with_spend_selection( + entry.vtxo().outpoint, + entry.exit_delay()?, + TxOut { + value: entry.vtxo().amount, + script_pubkey: entry.script_pubkey(), + }, + entry.tapscripts(), + spend_selection, + false, + entry.vtxo().is_swept, + entry.vtxo().assets.clone(), + )) + }) + .collect::, Error>>()?; + + Ok((boarding_inputs, vtxo_inputs, total_amount)) + } + + /// Prepare an intent for batch registration or fee estimation. + /// + /// This creates a signed intent PSBT along with all the data needed to participate + /// in the batch protocol. + pub(crate) fn prepare_intent( + &self, + rng: &mut R, + onchain_inputs: Vec, + vtxo_inputs: Vec, + output_type: BatchOutputType, + intent_kind: PrepareIntentKind, + dust: Amount, + ) -> Result + where + R: Rng + CryptoRng, + { + if onchain_inputs.is_empty() && vtxo_inputs.is_empty() { + return Err(Error::ad_hoc("cannot prepare intent without inputs")); + } + + // Generate an (ephemeral) cosigner keypair. + let cosigner_keypair = Keypair::new(self.secp(), rng); + + let vtxo_input_outpoints = vtxo_inputs.iter().map(|i| i.outpoint()).collect::>(); + + let inputs = { + let boarding_inputs = onchain_inputs.clone().into_iter().map(|o| { + intent::Input::new( + o.outpoint(), + o.sequence(), + None, + TxOut { + value: o.amount(), + script_pubkey: o.script_pubkey().clone(), + }, + o.tapscripts().to_vec(), + o.spend_info().clone(), + true, + false, + Vec::new(), + ) + }); + + boarding_inputs + .chain(vtxo_inputs.clone()) + .collect::>() + }; + + let mut outputs = vec![]; + + match output_type { + BatchOutputType::Board { + to_address, + to_amount, + } => { + if to_amount < dust { + return Err(Error::ad_hoc(format!( + "cannot settle into sub-dust VTXO: {to_amount} < {dust}" + ))); + } + + outputs.push(intent::Output::Offchain(TxOut { + value: to_amount, + script_pubkey: to_address.to_p2tr_script_pubkey(), + })); + } + BatchOutputType::OffBoard { + to_address, + to_amount, + change_amount, + .. + } if change_amount == Amount::ZERO => { + outputs.push(intent::Output::Onchain(TxOut { + value: to_amount, + script_pubkey: to_address.script_pubkey(), + })); + } + BatchOutputType::OffBoard { + to_address, + to_amount, + change_address, + change_amount, + } => { + if change_amount < dust { + return Err(Error::ad_hoc(format!( + "cannot settle with sub-dust change VTXO: {change_amount} < {dust}" + ))); + } + + outputs.push(intent::Output::Onchain(TxOut { + value: to_amount, + script_pubkey: to_address.script_pubkey(), + })); + + outputs.push(intent::Output::Offchain(TxOut { + value: change_amount, + script_pubkey: change_address.to_p2tr_script_pubkey(), + })); + } + } + + let cosigner_pk = cosigner_keypair.public_key(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to compute now timestamp")?; + let now = now.as_secs(); + let expire_at = now + (2 * 60); + // Bitkit: valid from a minute ago, so a device clock a few seconds ahead of the operator's doesn't get + // the intent refused as "not yet valid" (INVALID_INTENT_TIMERANGE). + let valid_at = now.saturating_sub(60); + + if let Some(packet) = create_asset_preservation_packet(&inputs, &outputs)? { + outputs.push(intent::Output::AssetPacket(packet.to_txout())); + } + + let mut onchain_output_indexes = Vec::new(); + for (i, output) in outputs.iter().enumerate() { + if matches!(output, intent::Output::Onchain(_)) { + onchain_output_indexes.push(i); + } + } + + let message = match intent_kind { + PrepareIntentKind::EstimateFee => intent::IntentMessage::EstimateIntentFee { + onchain_output_indexes, + valid_at, + expire_at, + own_cosigner_pks: vec![cosigner_pk], + }, + PrepareIntentKind::Register => intent::IntentMessage::Register { + onchain_output_indexes, + valid_at, + expire_at, + own_cosigner_pks: vec![cosigner_pk], + }, + }; + + let intent = self.sign_intent(&onchain_inputs, inputs, outputs.clone(), message)?; + + Ok(PreparedIntent { + intent, + cosigner_keypair, + vtxo_input_outpoints, + outputs, + onchain_inputs, + vtxo_inputs, + }) + } + + /// Build and sign the proof of an intent over `inputs`: every VTXO input is signed by the + /// keys this client holds for it, every boarding input (`onchain_inputs`) by its owner key. + fn sign_intent( + &self, + onchain_inputs: &[batch::OnChainInput], + inputs: Vec, + outputs: Vec, + message: intent::IntentMessage, + ) -> Result { + let secp = Secp256k1::new(); + + let sign_for_vtxo_fn = + |input: &mut psbt::Input, + msg: secp256k1::Message| + -> Result, ark_core::Error> { + match &input.witness_script { + None => Err(ark_core::Error::ad_hoc( + "Missing witness script in psbt::Input when signing intent", + )), + Some(script) => { + let pks = extract_checksig_pubkeys(script); + let mut res = vec![]; + for pk in pks { + if let Ok(keypair) = self.keypair_by_pk(&pk) { + let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair); + res.push((sig, keypair.public_key().into())) + } + } + Ok(res) + } + } + }; + + let sign_for_onchain_fn = + |input: &mut psbt::Input, + msg: secp256k1::Message| + -> Result<(schnorr::Signature, XOnlyPublicKey), ark_core::Error> { + let onchain_input = onchain_inputs + .iter() + .find(|o| { + Some(o.script_pubkey().clone()) + == input.witness_utxo.clone().map(|w| w.script_pubkey) + }) + .ok_or_else(|| { + ark_core::Error::ad_hoc( + "could not find signing key for onchain input: {input:?}", + ) + })?; + + let owner_pk = onchain_input.owner_pk(); + let sig = self + .sign_for_pk(&owner_pk, &msg) + .map_err(|e| ark_core::Error::ad_hoc(e.to_string()))?; + + Ok((sig, owner_pk)) + }; + + let intent = intent::make_intent( + sign_for_vtxo_fn, + sign_for_onchain_fn, + inputs, + outputs, + message, + )?; + + Ok(intent) + } + + /// Delete the intents queued at the Ark server that spend any of `outpoints`, VTXOs of this + /// wallet. + /// + /// An intent that is registered but never joins a batch (the client gave up waiting, or + /// crashed) stays queued at the server and keeps its inputs locked: every later intent + /// spending one of them is rejected as a duplicate. Only the owner of an input can register + /// an intent for it, so any intent holding these inputs is this wallet's own. + /// + /// The server matches queued intents by their VTXO inputs only, so an intent holding just + /// boarding outputs is not found. An abandoned intent is not dropped either: every batch takes + /// it, gets no confirmation and queues it again. While a batch holds it, it is not queued and + /// cannot be deleted, so this retries for about one batch period before giving up. + pub async fn delete_intents(&self, outpoints: &[OutPoint]) -> Result<(), Error> { + let server_info = self.server_info().await?; + let (boarding_inputs, vtxo_inputs, _) = self + .fetch_commitment_transaction_inputs(&server_info, crate::utils::unix_now()?) + .await?; + + let onchain_inputs: Vec<_> = boarding_inputs + .into_iter() + .filter(|input| outpoints.contains(&input.outpoint())) + .collect(); + let vtxo_inputs: Vec<_> = vtxo_inputs + .into_iter() + .filter(|input| outpoints.contains(&input.outpoint())) + .collect(); + + self.delete_intents_for(&onchain_inputs, vtxo_inputs, DELETE_INTENT_ATTEMPTS) + .await + } + + /// Delete the intents queued at the Ark server that spend any of these inputs, trying up to + /// `max_attempts` times while no queued intent matches. + async fn delete_intents_for( + &self, + onchain_inputs: &[batch::OnChainInput], + vtxo_inputs: Vec, + max_attempts: u32, + ) -> Result<(), Error> { + // The server matches queued intents by their VTXO inputs only. + if vtxo_inputs.is_empty() { + return Err(Error::ad_hoc( + "no VTXO inputs of this wallet to delete intents for", + )); + } + + let inputs = onchain_inputs + .iter() + .map(|o| { + intent::Input::new( + o.outpoint(), + o.sequence(), + None, + TxOut { + value: o.amount(), + script_pubkey: o.script_pubkey().clone(), + }, + o.tapscripts().to_vec(), + o.spend_info().clone(), + true, + false, + Vec::new(), + ) + }) + .chain(vtxo_inputs) + .collect::>(); + + let now = crate::utils::unix_now()?; + let now = u64::try_from(now).map_err(|_| Error::ad_hoc("negative timestamp"))?; + let message = intent::IntentMessage::Delete { + expire_at: now + (2 * 60), + }; + + let proof = self.sign_intent(onchain_inputs, inputs, Vec::new(), message)?; + + let mut attempt = 1; + loop { + let result = timeout_op( + self.inner.timeout, + self.network_client().delete_intent(proof.clone()), + ) + .await + .context("failed to delete intent")?; + match result { + Ok(()) => break, + // Taken into a running batch; it is queued again once that batch fails. + Err(e) + if e.to_string().contains(NO_MATCHING_INTENT_ERROR) + && attempt < max_attempts => + { + tracing::debug!(attempt, "No queued intent to delete yet; retrying"); + attempt += 1; + sleep(DELETE_INTENT_RETRY_DELAY).await; + } + Err(e) => return Err(e.into()), + } + } + + Ok(()) + } + + pub(crate) async fn join_next_batch( + &self, + rng: &mut R, + server_info: &server::Info, + onchain_inputs: Vec, + vtxo_inputs: Vec, + output_type: BatchOutputType, + ) -> Result + where + R: Rng + CryptoRng, + { + let prepared = self.prepare_intent( + rng, + onchain_inputs, + vtxo_inputs, + output_type, + PrepareIntentKind::Register, + server_info.dust, + )?; + + let PreparedIntent { + intent, + cosigner_keypair, + vtxo_input_outpoints, + outputs, + onchain_inputs, + vtxo_inputs, + } = prepared; + + let onchain_input_outpoints = onchain_inputs + .iter() + .map(|i| i.outpoint()) + .collect::>(); + + let own_cosigner_kps = [cosigner_keypair]; + let own_cosigner_pks = own_cosigner_kps + .iter() + .map(|k| k.public_key()) + .collect::>(); + + let secp = Secp256k1::new(); + + let mut step = Step::Start; + + // Kept to delete this intent again if the batch does not complete. + let cleanup_onchain_inputs = onchain_inputs.clone(); + let cleanup_vtxo_inputs = vtxo_inputs.clone(); + + let intent_id = match timeout_op( + self.inner.timeout, + self.network_client().register_intent(intent.clone()), + ) + .await + .context("failed to register intent")? + { + Ok(intent_id) => intent_id, + // An input is held by an intent still queued from an earlier attempt that never + // joined a batch; only this wallet can have registered it. Delete it and retry once. + Err(e) if e.to_string().contains(DUPLICATED_INPUT_ERROR) => { + tracing::warn!( + error = %e, + "An input of this intent is held by a stale intent; deleting it and retrying" + ); + self.delete_intents_for( + &cleanup_onchain_inputs, + cleanup_vtxo_inputs.clone(), + DELETE_INTENT_ATTEMPTS, + ) + .await + .context("failed to delete the stale intent holding our inputs")?; + timeout_op( + self.inner.timeout, + self.network_client().register_intent(intent), + ) + .await + .context("failed to register intent")?? + } + Err(e) => return Err(e.into()), + }; + + let outcome: Result = async { + tracing::debug!( + intent_id, + ?onchain_input_outpoints, + ?vtxo_input_outpoints, + ?outputs, + "Registered intent for batch" + ); + + let network_client = self.network_client(); + + let mut batch_id: Option = None; + + let topics = vtxo_input_outpoints + .iter() + .map(ToString::to_string) + .chain( + own_cosigner_pks + .iter() + .map(|pk| pk.serialize().to_lower_hex_string()), + ) + .collect(); + + let mut stream = network_client.get_event_stream(topics).await?; + + let (ark_forfeit_pk, _) = server_info.forfeit_pk.x_only_public_key(); + + let mut unsigned_commitment_tx = None; + + let mut vtxo_batch_tree_graph_chunks = Some(Vec::new()); + let mut vtxo_batch_tree_graph: Option = None; + + let mut connectors_graph_chunks = Some(Vec::new()); + let mut batch_expiry = None; + + let mut agg_nonce_pks = HashMap::new(); + + let mut our_nonce_trees: Option> = None; + loop { + match timeout_op(self.inner.timeout, stream.next()) + .await + .context("timed out waiting for batch event")? + { + Some(Ok(event)) => match event { + StreamEvent::BatchStarted(e) => { + if step != Step::Start { + continue; + } + + let hash = sha256::Hash::hash(intent_id.as_bytes()); + let hash = hash.as_byte_array().to_vec().to_lower_hex_string(); + + if e.intent_id_hashes.iter().any(|h| h == &hash) { + timeout_op( + self.inner.timeout, + self.network_client() + .confirm_registration(intent_id.clone()), + ) + .await + .context("failed to confirm intent registration")??; + + tracing::info!(batch_id = e.id, intent_id, "Intent ID found for batch"); + + batch_id = Some(e.id); + + // Depending on whether we are generating new VTXOs or not, we continue + // with a different step in the state machine. + step = match outputs + .iter() + .any(|o| matches!(o, intent::Output::Offchain(_))) + { + true => Step::BatchStarted, + false => Step::BatchSigningStarted, + }; + + batch_expiry = Some(e.batch_expiry); + } else { + tracing::debug!( + batch_id = e.id, + intent_id, + "Intent ID not found for batch" + ); + } + } + StreamEvent::TreeTx(e) => { + if step != Step::BatchStarted && step != Step::BatchSigningStarted { + continue; + } + + match e.batch_tree_event_type { + BatchTreeEventType::Vtxo => { + match &mut vtxo_batch_tree_graph_chunks { + Some(vtxo_batch_tree_graph_chunks) => { + tracing::debug!("Got new VTXO batch-tree graph chunk"); + + vtxo_batch_tree_graph_chunks.push(e.tx_graph_chunk) + } + None => { + return Err(Error::ark_server( + "received unexpected VTXO batch-tree graph chunk", + )); + } + }; + } + BatchTreeEventType::Connector => { + match connectors_graph_chunks { + Some(ref mut connectors_graph_chunks) => { + tracing::debug!("Got new connectors graph chunk"); + + connectors_graph_chunks.push(e.tx_graph_chunk) + } + None => { + return Err(Error::ark_server( + "received unexpected connectors graph chunk", + )); + } + }; + } + } + } + StreamEvent::TreeSignature(e) => { + if step != Step::BatchSigningStarted { + continue; + } + + match e.batch_tree_event_type { + BatchTreeEventType::Vtxo => { + match vtxo_batch_tree_graph { + Some(ref mut vtxo_batch_tree_graph) => { + vtxo_batch_tree_graph.apply(|graph| { + if graph.root().unsigned_tx.compute_txid() != e.txid { + Ok(true) + } else { + graph.set_signature(e.signature); + + Ok(false) + } + })?; + } + None => { + return Err(Error::ark_server( + "received batch-tree signature without transaction graph", + )); + } + }; + } + BatchTreeEventType::Connector => { + return Err(Error::ark_server( + "received batch-tree signature for connector tree", + )); + } + } + } + StreamEvent::TreeSigningStarted(e) => { + if step != Step::BatchStarted { + continue; + } + + let chunks = vtxo_batch_tree_graph_chunks.take().ok_or(Error::ark_server( + "received batch-tree signing started event without VTXO batch-tree graph chunks", + ))?; + vtxo_batch_tree_graph = + Some(TxGraph::new(chunks).map_err(Error::from).context( + "failed to build VTXO batch-tree graph before generating nonces", + )?); + + tracing::info!(batch_id = e.id, "Batch signing started"); + + for own_cosigner_pk in own_cosigner_pks.iter() { + if !&e.cosigners_pubkeys.iter().any(|p| p == own_cosigner_pk) { + return Err(Error::ark_server(format!( + "own cosigner PK is not present in cosigner PKs: {own_cosigner_pk}" + ))); + } + } + + // We generate and submit a nonce tree for every cosigner key we provide. + let mut our_nonce_tree_map = HashMap::new(); + for own_cosigner_kp in own_cosigner_kps { + let own_cosigner_pk = own_cosigner_kp.public_key(); + let nonce_tree = generate_nonce_tree( + rng, + vtxo_batch_tree_graph + .as_ref() + .expect("VTXO batch-tree graph"), + own_cosigner_pk, + &e.unsigned_commitment_tx, + ) + .map_err(Error::from) + .context("failed to generate VTXO nonce tree")?; + + tracing::info!( + cosigner_pk = %own_cosigner_pk, + "Submitting nonce tree for cosigner PK" + ); + + network_client + .submit_tree_nonces( + &e.id, + own_cosigner_pk, + nonce_tree.to_nonce_pks(), + ) + .await + .map_err(Error::ark_server) + .context("failed to submit VTXO nonce tree")?; + + our_nonce_tree_map.insert(own_cosigner_kp, nonce_tree); + } + + unsigned_commitment_tx = Some(e.unsigned_commitment_tx); + our_nonce_trees = Some(our_nonce_tree_map); + + step = step.next(); + } + StreamEvent::TreeNonces(e) => { + if step != Step::BatchSigningStarted { + continue; + } + + let tree_tx_nonce_pks = e.nonces; + + let cosigner_pk = match tree_tx_nonce_pks.0.iter().find(|(pk, _)| { + own_cosigner_pks + .iter() + .any(|p| &&p.x_only_public_key().0 == pk) + }) { + Some((pk, _)) => *pk, + None => { + tracing::debug!( + batch_id = e.id, + txid = %e.txid, + "Received irrelevant TreeNonces event" + ); + + continue; + } + }; + + tracing::debug!( + batch_id = e.id, + txid = %e.txid, + %cosigner_pk, + "Received TreeNonces event" + ); + + let agg_nonce_pk = aggregate_nonces(tree_tx_nonce_pks); + + agg_nonce_pks.insert(e.txid, agg_nonce_pk); + + if vtxo_batch_tree_graph.is_none() { + let chunks = vtxo_batch_tree_graph_chunks.take().ok_or(Error::ark_server( + "received batch-tree nonces event without VTXO batch-tree graph chunks", + ))?; + vtxo_batch_tree_graph = Some( + TxGraph::new(chunks) + .map_err(Error::from) + .context("failed to build VTXO batch-tree graph before batch-tree signing")?, + ); + } + let vtxo_batch_tree_graph_ref = + vtxo_batch_tree_graph.as_ref().expect("just populated"); + + // Once we collect an aggregated nonce per transaction in our VTXO + // batch-tree graph, we can sign and submit our partial signatures. + if agg_nonce_pks.len() == vtxo_batch_tree_graph_ref.nb_of_nodes() { + let cosigner_kp = own_cosigner_kps + .iter() + .find(|kp| kp.public_key().x_only_public_key().0 == cosigner_pk) + .ok_or_else(|| { + Error::ad_hoc("no cosigner keypair to sign for own PK") + })?; + + let our_nonce_trees = our_nonce_trees.as_mut().ok_or( + Error::ark_server("missing nonce trees during batch protocol"), + )?; + + let our_nonce_tree = + our_nonce_trees + .get_mut(cosigner_kp) + .ok_or(Error::ark_server( + "missing nonce tree during batch protocol", + ))?; + + let unsigned_commitment_tx = unsigned_commitment_tx + .as_ref() + .ok_or_else(|| Error::ad_hoc("missing commitment TX"))?; + + let batch_expiry = batch_expiry + .ok_or_else(|| Error::ad_hoc("missing batch expiry"))?; + + let mut partial_sig_tree = PartialSigTree::default(); + for (txid, _) in vtxo_batch_tree_graph_ref.as_map() { + let agg_nonce_pk = agg_nonce_pks.get(&txid).ok_or_else(|| { + Error::ad_hoc(format!( + "missing aggregated nonce PK for TX {txid}" + )) + })?; + + let sigs = sign_batch_tree_tx( + txid, + batch_expiry, + ark_forfeit_pk, + cosigner_kp, + *agg_nonce_pk, + vtxo_batch_tree_graph_ref, + unsigned_commitment_tx, + our_nonce_tree, + ) + .map_err(Error::from) + .context("failed to sign VTXO batch-tree transactions")?; + + partial_sig_tree.0.extend(sigs.0); + } + + network_client + .submit_tree_signatures( + &e.id, + cosigner_kp.public_key(), + partial_sig_tree, + ) + .await + .map_err(Error::ark_server) + .context("failed to submit VTXO batch-tree signatures")?; + } + } + StreamEvent::TreeNoncesAggregated(e) => { + tracing::debug!(batch_id = e.id, "Batch combined nonces generated"); + } + StreamEvent::BatchFinalization(e) => { + if step != Step::BatchSigningStarted { + continue; + } + + tracing::debug!( + commitment_txid = %e.commitment_tx.unsigned_tx.compute_txid(), + "Batch finalization started" + ); + + let signed_forfeit_psbts = if !vtxo_inputs.is_empty() { + let chunks = + connectors_graph_chunks.take().ok_or(Error::ark_server( + "received batch finalization event without connectors", + ))?; + + if chunks.is_empty() { + tracing::debug!(batch_id = e.id, "No forfeit transactions"); + + Vec::new() + } else { + let connectors_graph = TxGraph::new(chunks) + .map_err(Error::from) + .context( + "failed to build connectors graph before signing forfeit TXs", + )?; + + tracing::debug!(batch_id = e.id, "Batch finalization started"); + + create_and_sign_forfeit_txs( + |input: &mut psbt::Input, msg: secp256k1::Message| match &input + .witness_script + { + None => Err(ark_core::Error::ad_hoc( + "Missing witness script in psbt::Input when signing forfeit", + )), + Some(script) => { + let pks = extract_checksig_pubkeys(script); + let mut res = vec![]; + for pk in pks { + if let Ok(keypair) = + self.keypair_by_pk(&pk) { + let sig = + secp.sign_schnorr_no_aux_rand(&msg, &keypair); + res.push((sig, keypair.public_key().into())) + } + } + Ok(res) + } + }, + vtxo_inputs.as_slice(), + &connectors_graph.leaves(), + &server_info.forfeit_address, + server_info.dust, + ) + .map_err(Error::from)? + } + } else { + Vec::new() + }; + + let commitment_psbt = if onchain_inputs.is_empty() { + None + } else { + let mut commitment_psbt = e.commitment_tx; + + let sign_for_pk_fn = |pk: &XOnlyPublicKey, + msg: &secp256k1::Message| + -> Result< + schnorr::Signature, + ark_core::Error, + > { + self.sign_for_pk(pk, msg) + .map_err(|e| ark_core::Error::ad_hoc(e.to_string())) + }; + + sign_commitment_psbt( + sign_for_pk_fn, + &mut commitment_psbt, + &onchain_inputs, + ) + .map_err(Error::from)?; + + Some(commitment_psbt) + }; + + if !signed_forfeit_psbts.is_empty() || commitment_psbt.is_some() { + network_client + .submit_signed_forfeit_txs(signed_forfeit_psbts, commitment_psbt) + .await?; + } + + step = step.next(); + } + StreamEvent::BatchFinalized(e) => { + if step != Step::Finalized { + continue; + } + + let commitment_txid = e.commitment_txid; + + tracing::info!(batch_id = e.id, %commitment_txid, "Batch finalized"); + + return Ok(commitment_txid); + } + StreamEvent::BatchFailed(ref e) => { + if Some(&e.id) == batch_id.as_ref() { + return Err(Error::ark_server(format!( + "batch failed {}: {}", + e.id, e.reason + ))); + } + + tracing::debug!("Unrelated batch failed: {e:?}"); + } + StreamEvent::Heartbeat => {} + StreamEvent::StreamStarted(_) => {} + }, + Some(Err(e)) => { + tracing::error!("Got error from event stream"); + + return Err(Error::ark_server(e)); + } + None => { + return Err(Error::ark_server("dropped batch event stream")); + } + } + } + + } + .await; + + // A registered intent that did not complete would stay queued at the server and lock + // its inputs for every later batch. Best effort, in a single attempt so the batch error + // is not delayed: an intent still held by a running batch is not queued and cannot be + // deleted now; the next registration spending its inputs deletes it then. The server + // cannot find an intent by boarding inputs, so there is nothing to try without VTXOs. + if let Err(e) = &outcome { + if !cleanup_vtxo_inputs.is_empty() { + if let Err(delete_error) = self + .delete_intents_for(&cleanup_onchain_inputs, cleanup_vtxo_inputs, 1) + .await + { + tracing::warn!( + intent_id, + ?onchain_input_outpoints, + ?vtxo_input_outpoints, + error = %e, + %delete_error, + "Did not delete the intent of a failed batch" + ); + } + } + } + + return outcome; + + #[derive(Debug, PartialEq, Eq)] + enum Step { + Start, + BatchStarted, + BatchSigningStarted, + Finalized, + } + + impl Step { + fn next(&self) -> Step { + match self { + Step::Start => Step::BatchStarted, + Step::BatchStarted => Step::BatchSigningStarted, + Step::BatchSigningStarted => Step::Finalized, + Step::Finalized => Step::Finalized, // we can't go further + } + } + } + } +} + +#[derive(Debug, Clone)] +pub(crate) enum PrepareIntentKind { + Register, + EstimateFee, +} + +/// The Ark server's rejection of an intent whose input another queued intent already holds. +/// +/// arkd returns no error code for it, so this matches the text of the error raised by its intent +/// live store (`internal/infrastructure/live-store/{inmemory,redis}/intents.go`). +const DUPLICATED_INPUT_ERROR: &str = "already registered by another intent"; + +/// The Ark server's answer to a deletion when no queued intent spends the given inputs. +/// +/// Matched against the error text of arkd's `DeleteIntentsByProof` +/// (`internal/core/application/service.go`). +const NO_MATCHING_INTENT_ERROR: &str = "no matching intents found"; + +/// How often to retry a deletion that found no queued intent, and how long to wait between +/// attempts: together a little over one batch period, the longest an intent is out of the queue. +const DELETE_INTENT_ATTEMPTS: u32 = 15; +const DELETE_INTENT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5); + +#[derive(Debug, Clone)] +pub(crate) enum BatchOutputType { + Board { + to_address: ArkAddress, + to_amount: Amount, + }, + OffBoard { + to_address: Address, + to_amount: Amount, + change_address: ArkAddress, + change_amount: Amount, + }, +} + +/// Prepared intent data ready for batch registration. +pub(crate) struct PreparedIntent { + /// The signed intent. + pub intent: intent::Intent, + /// The ephemeral cosigner keypair. + pub cosigner_keypair: Keypair, + /// VTXO input outpoints (used for event stream topics). + pub vtxo_input_outpoints: Vec, + /// Intent outputs (used to determine batch protocol steps). + pub outputs: Vec, + /// The original onchain inputs (needed for commitment signing). + pub onchain_inputs: Vec, + /// The original VTXO inputs (needed for forfeit signing). + pub vtxo_inputs: Vec, +} diff --git a/vendor/ark-client/src/boltz.rs b/vendor/ark-client/src/boltz.rs new file mode 100644 index 0000000..650557d --- /dev/null +++ b/vendor/ark-client/src/boltz.rs @@ -0,0 +1,7512 @@ +// Active VHTLC contracts are not swept by deprecated-signer migration. Their claim/refund +// recovery paths reconstruct scripts against both current and deprecated server keys so swaps +// created before a signer rotation remain recoverable. + +use crate::batch::BatchOutputType; +use crate::error::ErrorContext as _; +use crate::swap_storage::SwapStorage; +use crate::timeout_op; +use crate::wallet::OnchainWallet; +use crate::Blockchain; +use crate::Client; +use crate::Error; +use ark_core::contract::ContractState; +use ark_core::contract::VhtlcContract; +use ark_core::intent; +use ark_core::script::extract_checksig_pubkeys; +use ark_core::send::build_offchain_transactions; +use ark_core::send::sign_ark_transaction; +use ark_core::send::sign_checkpoint_transaction; +use ark_core::send::OffchainTransactions; +use ark_core::send::SendReceiver; +use ark_core::send::VtxoInput; +use ark_core::server::parse_sequence_number; +use ark_core::server::Info; +use ark_core::server::PendingTx; +use ark_core::server::SubscriptionEvent; +use ark_core::server::SubscriptionResponse; +use ark_core::vhtlc::VhtlcOptions; +use ark_core::vhtlc::VhtlcScript; +use ark_core::ArkAddress; +use ark_core::VtxoList; +use ark_core::VTXO_CONDITION_KEY; +use bitcoin::absolute; +use bitcoin::consensus::Encodable; +use bitcoin::hashes::ripemd160; +use bitcoin::hashes::sha256; +use bitcoin::hashes::Hash; +use bitcoin::io::Write; +use bitcoin::key::Keypair; +use bitcoin::key::Secp256k1; +use bitcoin::psbt; +use bitcoin::secp256k1; +use bitcoin::secp256k1::schnorr; +use bitcoin::taproot::LeafVersion; +use bitcoin::taproot::TapLeafHash; +use bitcoin::Amount; +use bitcoin::Psbt; +use bitcoin::PublicKey; +use bitcoin::ScriptBuf; +use bitcoin::TxOut; +use bitcoin::Txid; +use bitcoin::VarInt; +use bitcoin::XOnlyPublicKey; +use lightning_invoice::Bolt11Invoice; +use rand::CryptoRng; +use rand::Rng; +use serde::Deserialize; +use serde::Serialize; +use serde_with::serde_as; +use serde_with::DisplayFromStr; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::collections::HashSet; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +/// Maximum byte length of a BOLT11 invoice description (`d` field). +/// +/// BOLT11 tagged fields use a 10-bit length in 5-bit groups, capping the payload at +/// `floor(1023 * 5 / 8) = 639` UTF-8 bytes. +const MAX_BOLT11_DESCRIPTION_BYTES: usize = 639; +const VHTLC_WATCHER_INITIAL_BACKOFF: Duration = Duration::from_secs(1); +const VHTLC_WATCHER_MAX_BACKOFF: Duration = Duration::from_secs(30); +const VHTLC_WATCHER_REFRESH_INTERVAL: Duration = Duration::from_secs(60); +const VHTLC_REFUND_RETRY_INITIAL_BACKOFF: Duration = Duration::from_secs(30); +const VHTLC_REFUND_RETRY_MAX_BACKOFF: Duration = Duration::from_secs(30 * 60); + +fn validate_invoice_description(description: Option<&str>) -> Result<(), Error> { + if let Some(d) = description { + if d.len() > MAX_BOLT11_DESCRIPTION_BYTES { + return Err(Error::consumer(format!( + "invoice description is {} bytes (> {} bytes).", + d.len(), + MAX_BOLT11_DESCRIPTION_BYTES, + ))); + } + } + Ok(()) +} + +/// The type of a Boltz swap. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] +pub enum SwapType { + Submarine, + Reverse, + Chain, + /// Swap ID not found in local storage. + Unknown, +} + +impl std::fmt::Display for SwapType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Submarine => write!(f, "submarine"), + Self::Reverse => write!(f, "reverse"), + Self::Chain => write!(f, "chain"), + Self::Unknown => write!(f, "unknown"), + } + } +} + +/// Status information for a Boltz swap. +#[derive(Clone, Debug)] +pub struct SwapStatusInfo { + pub swap_id: String, + pub swap_type: SwapType, + pub status: SwapStatus, +} + +#[derive(Clone, Debug)] +pub struct SubmarineSwapResult { + pub swap_id: String, + pub txid: Txid, + pub amount: Amount, +} + +#[derive(Clone, Debug)] +pub struct ReverseSwapResult { + pub swap_id: String, + pub amount: Amount, + pub invoice: Bolt11Invoice, +} + +#[derive(Clone, Debug)] +pub struct ClaimVhtlcResult { + pub swap_id: String, + pub claim_txid: Txid, + pub claim_amount: Amount, + pub preimage: [u8; 32], +} + +/// The type of VHTLC spend that was submitted but not yet finalized. +/// +/// Determined by matching the spend script in the pending transaction's PSBT against the known +/// VHTLC spend paths. +#[derive(Clone, Debug)] +pub enum PendingVhtlcSpendType { + /// Claim via `claim_script`: preimage + receiver + server. + /// + /// Used in reverse submarine swaps (receiving Lightning → Arkade). + Claim { swap_id: String, preimage: [u8; 32] }, + /// Collaborative refund via `refund_script`: sender + receiver (Boltz) + server. + /// + /// Used in submarine swaps when Boltz cooperates. + CollaborativeRefund { swap_id: String }, + /// Expired refund via `refund_without_receiver_script`: CLTV timeout + sender + server. + /// + /// Used in submarine swaps when the timelock has expired and Boltz is unavailable. + ExpiredRefund { swap_id: String }, +} + +impl PendingVhtlcSpendType { + pub fn swap_id(&self) -> &str { + match self { + Self::Claim { swap_id, .. } + | Self::CollaborativeRefund { swap_id } + | Self::ExpiredRefund { swap_id } => swap_id, + } + } + + pub fn name(&self) -> &'static str { + match self { + Self::Claim { .. } => "Claim", + Self::CollaborativeRefund { .. } => "CollaborativeRefund", + Self::ExpiredRefund { .. } => "ExpiredRefund", + } + } +} + +/// A pending (submitted but not finalized) VHTLC spend transaction. +#[derive(Clone, Debug)] +pub struct PendingVhtlcSpendTx { + pub spend_type: PendingVhtlcSpendType, + pub pending_tx: PendingTx, +} + +/// Result of attempting to finalize all pending VHTLC spend transactions. +#[derive(Clone, Debug)] +pub struct ContinuePendingVhtlcSpendTxsResult { + pub finalized: Vec, + pub failed: Vec, +} + +/// A pending VHTLC spend transaction that could not be finalized. +#[derive(Clone, Debug)] +pub struct PendingVhtlcSpendFailure { + pub ark_txid: Txid, + pub swap_id: String, + pub spend_type: &'static str, + pub error: String, +} + +/// Configuration for the background Boltz VHTLC watcher. +#[derive(Clone, Copy, Debug)] +pub struct BoltzVhtlcWatcherConfig { + /// How often to refresh subscriptions and retry claim/refund lifecycle actions. + pub refresh_interval: Duration, +} + +impl Default for BoltzVhtlcWatcherConfig { + fn default() -> Self { + Self { + refresh_interval: VHTLC_WATCHER_REFRESH_INTERVAL, + } + } +} + +/// Handle to stop the background Boltz VHTLC watcher. +/// +/// Keep this handle alive for as long as the watcher should run. Dropping it stops the watcher. +#[must_use = "dropping the handle stops the Boltz VHTLC watcher"] +pub struct BoltzVhtlcWatcherHandle { + stop_tx: tokio::sync::watch::Sender, +} + +impl BoltzVhtlcWatcherHandle { + /// Stop the background watcher. + pub fn stop(self) { + let _ = self.stop_tx.send(true); + } +} + +impl Drop for BoltzVhtlcWatcherHandle { + fn drop(&mut self) { + let _ = self.stop_tx.send(true); + } +} + +#[derive(Default)] +struct BoltzVhtlcActionLog { + claimed: HashSet, + refunded: HashSet, + claims_in_flight: HashSet, + refunds_in_flight: HashSet, + refund_retries: HashMap, +} + +struct RefundRetryState { + failures: u32, + retry_after: Instant, +} + +impl BoltzVhtlcActionLog { + fn begin_claim(&mut self, swap_id: &str) -> bool { + if self.claimed.contains(swap_id) + || self.refunded.contains(swap_id) + || self.claims_in_flight.contains(swap_id) + || self.refunds_in_flight.contains(swap_id) + { + return false; + } + self.claims_in_flight.insert(swap_id.to_string()) + } + + fn finish_claim(&mut self, swap_id: &str, succeeded: bool) { + self.claims_in_flight.remove(swap_id); + if succeeded { + self.claimed.insert(swap_id.to_string()); + } + } + + fn begin_refund(&mut self, swap_id: &str) -> bool { + if self.claimed.contains(swap_id) + || self.refunded.contains(swap_id) + || self.claims_in_flight.contains(swap_id) + || self.refunds_in_flight.contains(swap_id) + { + return false; + } + + if let Some(retry) = self.refund_retries.get(swap_id) { + if Instant::now() < retry.retry_after { + return false; + } + } + + self.refunds_in_flight.insert(swap_id.to_string()) + } + + fn finish_refund(&mut self, swap_id: &str, succeeded: bool) { + self.refunds_in_flight.remove(swap_id); + if succeeded { + self.refund_retries.remove(swap_id); + self.refunded.insert(swap_id.to_string()); + return; + } + + let retry = self + .refund_retries + .entry(swap_id.to_string()) + .or_insert(RefundRetryState { + failures: 0, + retry_after: Instant::now(), + }); + retry.failures = retry.failures.saturating_add(1); + let multiplier = 1_u32 + .checked_shl(retry.failures.saturating_sub(1).min(10)) + .unwrap_or(1); + let backoff = VHTLC_REFUND_RETRY_INITIAL_BACKOFF + .saturating_mul(multiplier) + .min(VHTLC_REFUND_RETRY_MAX_BACKOFF); + retry.retry_after = Instant::now() + backoff; + } +} + +#[derive(Clone, Debug)] +struct VhtlcLifecycleInfo { + swap_id: String, + swap_type: SwapType, + address: ArkAddress, + script_pubkey: ScriptBuf, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SpentVhtlcAction { + Reconcile, + KeepActive, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum VhtlcContractLiveness { + /// No VTXO exists yet. Keep watching: the VHTLC may still be funded later. + Unfunded, + /// A spend was submitted but not finalized. Keep active for pending-tx recovery. + PendingSpend, + /// VTXO exists and can still be spent cooperatively/offchain. + Funded, + /// VTXO exists and is recoverable/settleable. + Recoverable, + /// VTXO existed and no longer has any actionable offchain/recovery state. + Spent, +} + +impl VhtlcContractLiveness { + fn should_deactivate_contract(self) -> bool { + matches!(self, Self::Spent) + } +} + +fn classify_vhtlc_contract_liveness( + dust: Amount, + has_pending_spend: bool, + vtxos: Vec, +) -> VhtlcContractLiveness { + if has_pending_spend { + return VhtlcContractLiveness::PendingSpend; + } + + if vtxos.is_empty() { + return VhtlcContractLiveness::Unfunded; + } + + let vtxos = VtxoList::new(dust, vtxos); + if vtxos.spendable_offchain().next().is_some() { + return VhtlcContractLiveness::Funded; + } + if vtxos.recoverable().next().is_some() { + return VhtlcContractLiveness::Recoverable; + } + + VhtlcContractLiveness::Spent +} + +fn validated_boltz_tap_script_sigs( + input: &psbt::Input, + expected_pk: XOnlyPublicKey, + expected_leaf_hash: TapLeafHash, +) -> Result, Error> { + let mut sigs = BTreeMap::new(); + + for ((pk, leaf_hash), sig) in &input.tap_script_sigs { + if *pk != expected_pk || *leaf_hash != expected_leaf_hash { + return Err(Error::ad_hoc(format!( + "unexpected Boltz tap script signature for pubkey {pk} and leaf hash {leaf_hash}" + ))); + } + + sigs.insert((*pk, *leaf_hash), *sig); + } + + if sigs.is_empty() { + return Err(Error::ad_hoc(format!( + "missing Boltz tap script signature for pubkey {expected_pk} and leaf hash {expected_leaf_hash}" + ))); + } + + Ok(sigs) +} + +impl Client +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + // Submarine swap. + + /// Prepare the payment of a BOLT11 invoice by setting up a submarine swap via Boltz. + /// + /// This function does not execute the payment itself. Once you are ready for payment you + /// will have to send the required `amount` to the `vhtlc_address`. + /// + /// If you are looking for a function which pays the invoice immediately, consider using + /// [`Client::pay_ln_invoice`] instead. + /// + /// # Arguments + /// + /// - `invoice`: a [`Bolt11Invoice`] to be paid. + /// + /// # Returns + /// + /// - A [`SubmarineSwapData`] object, including an identifier for the swap. + pub async fn prepare_ln_invoice_payment( + &self, + invoice: Bolt11Invoice, + ) -> Result { + let refund_keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?; + let refund_public_key = refund_keypair.public_key(); + let key_derivation_index = + self.derivation_index_for_pk(&refund_keypair.x_only_public_key().0); + + let preimage_hash = invoice.payment_hash(); + let preimage_hash = ripemd160::Hash::hash(preimage_hash.as_byte_array()); + + let request = CreateSubmarineSwapRequest { + from: Asset::Ark, + to: Asset::Btc, + invoice, + refund_public_key: refund_public_key.into(), + referral_id: self.inner.boltz_referral_id.clone(), + }; + let url = format!("{}/v2/swap/submarine", self.inner.boltz_url); + + let client = reqwest::Client::new(); + let response = client + .post(&url) + .json(&request) + .send() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to send submarine swap request")?; + + if !response.status().is_success() { + let error_text = response + .text() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to read error text")?; + + return Err(Error::ad_hoc(format!( + "failed to create submarine swap: {error_text}" + ))); + } + + let swap_response: CreateSubmarineSwapResponse = response + .json() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to deserialize submarine swap response")?; + + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(Error::ad_hoc) + .context("failed to compute created_at")?; + + let server_info = self.server_info().await?; + let vhtlc = self.build_vhtlc_script( + &server_info, + swap_response.claim_public_key, + refund_public_key.into(), + preimage_hash, + &swap_response.timeout_block_heights, + &swap_response.address, + )?; + + let script_pubkey = self + .insert_vhtlc_contract(vhtlc.options().clone(), key_derivation_index) + .context("failed to persist VHTLC contract for submarine swap")?; + + let data = SubmarineSwapData { + id: swap_response.id.clone(), + status: SwapStatus::Created, + preimage: None, + preimage_hash, + refund_public_key: refund_public_key.into(), + claim_public_key: swap_response.claim_public_key, + vhtlc_address: swap_response.address, + timeout_block_heights: swap_response.timeout_block_heights, + amount: swap_response.expected_amount, + invoice: request.invoice.clone(), + created_at: created_at.as_secs(), + key_derivation_index, + contract_script_pubkey: Some(script_pubkey.clone()), + }; + + if let Err(error) = self + .swap_storage() + .insert_submarine(swap_response.id.clone(), data.clone()) + .await + { + return Err(self.deactivate_orphaned_vhtlc_contract( + &script_pubkey, + &swap_response.id, + error, + )); + } + + tracing::info!( + swap_id = swap_response.id, + vhtlc_address = %data.vhtlc_address, + expected_amount = %data.amount, + "Prepared Lightning invoice payment" + ); + + Ok(data) + } + + /// Pay a BOLT11 invoice by performing a submarine swap via Boltz. This allows to make Lightning + /// payments with an Arkade wallet. + /// + /// # Arguments + /// + /// - `invoice`: a [`Bolt11Invoice`] to be paid. + /// + /// # Returns + /// + /// - A [`SubmarineSwapResult`], including an identifier for the swap and the TXID of the Arkade + /// transaction that funds the VHTLC. + pub async fn pay_ln_invoice( + &self, + invoice: Bolt11Invoice, + ) -> Result { + let refund_keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?; + let refund_public_key = refund_keypair.public_key(); + let key_derivation_index = + self.derivation_index_for_pk(&refund_keypair.x_only_public_key().0); + + let preimage_hash = invoice.payment_hash(); + let preimage_hash = ripemd160::Hash::hash(preimage_hash.as_byte_array()); + + let request = CreateSubmarineSwapRequest { + from: Asset::Ark, + to: Asset::Btc, + invoice, + refund_public_key: refund_public_key.into(), + referral_id: self.inner.boltz_referral_id.clone(), + }; + let url = format!("{}/v2/swap/submarine", self.inner.boltz_url); + + let client = reqwest::Client::new(); + let response = client + .post(&url) + .json(&request) + .send() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to send submarine swap request")?; + + if !response.status().is_success() { + let error_text = response + .text() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to read error text")?; + + return Err(Error::ad_hoc(format!( + "failed to create submarine swap: {error_text}" + ))); + } + + let swap_response: CreateSubmarineSwapResponse = response + .json() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to deserialize submarine swap response")?; + + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(Error::ad_hoc) + .context("failed to compute created_at")?; + + let server_info = self.server_info().await?; + let vhtlc = self.build_vhtlc_script( + &server_info, + swap_response.claim_public_key, + refund_public_key.into(), + preimage_hash, + &swap_response.timeout_block_heights, + &swap_response.address, + )?; + + let script_pubkey = self + .insert_vhtlc_contract(vhtlc.options().clone(), key_derivation_index) + .context("failed to persist VHTLC contract for submarine swap")?; + + if let Err(error) = self + .swap_storage() + .insert_submarine( + swap_response.id.clone(), + SubmarineSwapData { + id: swap_response.id.clone(), + status: SwapStatus::Created, + preimage: None, + preimage_hash, + refund_public_key: refund_public_key.into(), + claim_public_key: swap_response.claim_public_key, + vhtlc_address: swap_response.address, + timeout_block_heights: swap_response.timeout_block_heights, + amount: swap_response.expected_amount, + invoice: request.invoice.clone(), + created_at: created_at.as_secs(), + key_derivation_index, + contract_script_pubkey: Some(script_pubkey.clone()), + }, + ) + .await + { + return Err(self.deactivate_orphaned_vhtlc_contract( + &script_pubkey, + &swap_response.id, + error, + )); + } + + let vhtlc_address = swap_response.address; + let amount = swap_response.expected_amount; + + let txid = self + .send(vec![SendReceiver::bitcoin(vhtlc_address, amount)]) + .await?; + + tracing::info!(swap_id = swap_response.id, %amount, "Funded VHTLC"); + + Ok(SubmarineSwapResult { + swap_id: swap_response.id, + txid, + amount, + }) + } + + /// Wait for the Lightning invoice associated with a submarine swap to be paid by Boltz. + /// + /// Boltz will first need to claim our VHTLC before paying the invoice. When Boltz claims + /// the VHTLC, the preimage is revealed in the claim transaction's witness. This method + /// extracts and persists the preimage to swap storage. + /// + /// # Returns + /// + /// The 32-byte preimage that was revealed when Boltz claimed the VHTLC. + pub async fn wait_for_invoice_paid(&self, swap_id: &str) -> Result<[u8; 32], Error> { + use futures::StreamExt; + + let stream = + self.subscribe_to_swap_updates_for_type(swap_id.to_string(), SwapType::Submarine); + tokio::pin!(stream); + + while let Some(status_result) = stream.next().await { + match status_result { + Ok(status) => { + tracing::debug!(swap_id, current = ?status, "Swap status"); + match &status { + SwapStatus::InvoicePaid => { + let deadline = tokio::time::Instant::now() + self.inner.timeout; + + loop { + match self.extract_submarine_swap_preimage(swap_id).await { + Ok(preimage) => return Ok(preimage), + Err(e) => { + if tokio::time::Instant::now() >= deadline { + return Err(e.context( + "invoice paid but failed to extract preimage from claim tx", + )); + } + + tracing::debug!( + swap_id, + "Preimage not available yet, retrying: {e}" + ); + } + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + SwapStatus::Error { error } => { + tracing::error!(swap_id, error, "Boltz reported swap error"); + return Err(swap_wait_failure_error( + &status, + swap_id, + "invoice payment", + )); + } + SwapStatus::TransactionRefunded + | SwapStatus::TransactionFailed + | SwapStatus::TransactionLockupFailed + | SwapStatus::InvoiceFailedToPay + | SwapStatus::InvoiceExpired + | SwapStatus::SwapExpired => { + return Err(swap_wait_failure_error( + &status, + swap_id, + "invoice payment", + )); + } + SwapStatus::InvoiceSet + | SwapStatus::InvoicePending + | SwapStatus::Created + | SwapStatus::TransactionMempool + | SwapStatus::TransactionConfirmed + | SwapStatus::TransactionServerMempool + | SwapStatus::TransactionServerConfirmed + | SwapStatus::TransactionClaimed + | SwapStatus::InvoiceSettled => {} + SwapStatus::Other(status) => { + tracing::debug!(swap_id, status, "Ignoring unknown swap status"); + } + } + } + Err(e) => return Err(e), + } + } + + Err(Error::ad_hoc("Status stream ended unexpectedly")) + } + + /// Extract the preimage from a claimed submarine swap VHTLC. + /// + /// After Boltz claims the VHTLC, the preimage is embedded in the claim transaction's PSBT + /// via the `VTXO_CONDITION_KEY` unknown field. This method fetches that transaction and + /// extracts the preimage. + /// + /// The extracted preimage is validated against the stored preimage hash and persisted to + /// swap storage. + pub async fn extract_submarine_swap_preimage(&self, swap_id: &str) -> Result<[u8; 32], Error> { + let mut swap_data = self + .swap_storage() + .get_submarine(swap_id) + .await? + .ok_or(Error::ad_hoc("submarine swap not found"))?; + + // If the preimage was already extracted, return it. + if let Some(preimage) = swap_data.preimage { + return Ok(preimage); + } + + let vhtlc_address = swap_data.vhtlc_address; + + // Find the VHTLC outpoint — it should be spent by now. + let virtual_tx_outpoints = self + .get_virtual_tx_outpoints(std::iter::once(vhtlc_address)) + .await + .context("failed to get virtual tx outpoints for VHTLC address")?; + + let vhtlc_outpoint = virtual_tx_outpoints + .iter() + .find(|o| o.is_spent) + .ok_or_else(|| Error::ad_hoc("VHTLC outpoint not found or not yet spent (claimed)"))?; + + let claim_txid = vhtlc_outpoint.ark_txid.ok_or_else(|| { + Error::ad_hoc("VHTLC is spent but has no ark_txid (claim transaction)") + })?; + + // Fetch the claim transaction PSBT. + let claim_txs = timeout_op( + self.inner.timeout, + self.network_client() + .get_virtual_txs(vec![claim_txid.to_string()], None, None), + ) + .await? + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to fetch claim transaction")?; + + let claim_psbt = claim_txs + .txs + .first() + .ok_or_else(|| Error::ad_hoc("claim transaction not found"))?; + + // Extract the preimage from the PSBT's unknown fields. + let preimage = extract_preimage_from_psbt(claim_psbt)?; + + // Validate against the stored hash. + let computed_hash = ripemd160::Hash::hash(sha256::Hash::hash(&preimage).as_byte_array()); + if computed_hash != swap_data.preimage_hash { + return Err(Error::ad_hoc(format!( + "extracted preimage does not match stored hash: expected {}, got {}", + swap_data.preimage_hash, computed_hash + ))); + } + + // Persist the preimage. + swap_data.preimage = Some(preimage); + self.swap_storage() + .update_submarine(swap_id, swap_data) + .await + .context("failed to persist preimage to swap storage")?; + + tracing::info!( + swap_id, + "Extracted and persisted preimage from claim transaction" + ); + + Ok(preimage) + } + + /// Refund a VHTLC after the timelock has expired. + /// + /// This path does not require a signature from Boltz. + pub async fn refund_expired_vhtlc(&self, swap_id: &str) -> Result { + let mut swap_data = self + .swap_storage() + .get_submarine(swap_id) + .await? + .ok_or(Error::ad_hoc("Submarine swap not found"))?; + + let server_info = self.server_info().await?; + + let vhtlc = self + .submarine_vhtlc_script(&mut swap_data, &server_info) + .await?; + let vhtlc_address = vhtlc.address(); + + let vhtlc_outpoint = { + let virtual_tx_outpoints = self + .get_virtual_tx_outpoints(std::iter::once(vhtlc_address)) + .await?; + + let vtxo_list = VtxoList::new(server_info.dust, virtual_tx_outpoints); + + // We expect a single outpoint. + let mut unspent = vtxo_list.all_unspent(); + let vhtlc_outpoint = unspent.next().ok_or_else(|| { + Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}")) + })?; + + vhtlc_outpoint.clone() + }; + + let (refund_address, _) = self.get_offchain_address().await?; + let refund_amount = swap_data.amount; + + let outputs = vec![SendReceiver { + address: refund_address, + amount: refund_amount, + assets: Vec::new(), + }]; + + let refund_script = vhtlc.refund_without_receiver_script(); + + let spend_info = vhtlc.taproot_spend_info(); + let script_ver = (refund_script, LeafVersion::TapScript); + let control_block = spend_info + .control_block(&script_ver) + .ok_or(Error::ad_hoc("control block not found for refund script"))?; + + let script_pubkey = vhtlc.script_pubkey(); + + let refunder_pk = swap_data.refund_public_key.inner.x_only_public_key().0; + let vhtlc_input = VtxoInput::new( + script_ver.0, + Some(absolute::LockTime::from_consensus( + swap_data.timeout_block_heights.refund, + )), + control_block, + vhtlc.tapscripts(), + script_pubkey, + refund_amount, + vhtlc_outpoint.outpoint, + vhtlc_outpoint.assets, + ); + + // The change address is superfluous because we are _draining_ the VHTLC. + let change_address = &refund_address; + + let OffchainTransactions { + mut ark_tx, + checkpoint_txs, + } = build_offchain_transactions( + &outputs, + change_address, + std::slice::from_ref(&vhtlc_input), + &server_info, + )?; + + let kp = self.swap_keypair_by_pk(&refunder_pk, swap_data.key_derivation_index, swap_id)?; + let sign_fn = + |_: &mut psbt::Input, + msg: secp256k1::Message| + -> Result, ark_core::Error> { + let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp); + let pk = kp.x_only_public_key().0; + + Ok(vec![(sig, pk)]) + }; + + sign_ark_transaction(sign_fn, &mut ark_tx, 0)?; + + let ark_txid = ark_tx.unsigned_tx.compute_txid(); + + let res = self + .network_client() + .submit_offchain_transaction_request(ark_tx, checkpoint_txs) + .await?; + + let mut checkpoint_psbt = res + .signed_checkpoint_txs + .first() + .ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))? + .clone(); + + let kp = self.swap_keypair_by_pk(&refunder_pk, swap_data.key_derivation_index, swap_id)?; + let sign_fn = + |_: &mut psbt::Input, + msg: secp256k1::Message| + -> Result, ark_core::Error> { + let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp); + let pk = kp.x_only_public_key().0; + + Ok(vec![(sig, pk)]) + }; + + sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt)?; + + timeout_op( + self.inner.timeout, + self.network_client() + .finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]), + ) + .await? + .map_err(Error::ark_server) + .context("failed to finalize offchain transaction")?; + + self.best_effort_mark_vhtlc_contract_inactive( + swap_id, + swap_data.contract_script_pubkey.as_ref(), + "refund", + ); + + tracing::info!(txid = %ark_txid, "Refunded VHTLC"); + + Ok(ark_txid) + } + + /// Refund a VHTLC after the timelock has expired via settlement. + /// + /// This path does not require a signature from Boltz. + pub async fn refund_expired_vhtlc_via_settlement( + &self, + rng: &mut R, + swap_id: &str, + ) -> Result + where + R: Rng + CryptoRng, + { + let mut swap_data = self + .swap_storage() + .get_submarine(swap_id) + .await? + .ok_or(Error::ad_hoc("Submarine swap not found"))?; + + let timeout_block_heights = swap_data.timeout_block_heights; + let server_info = self.server_info().await?; + + let vhtlc = self + .submarine_vhtlc_script(&mut swap_data, &server_info) + .await?; + let vhtlc_address = vhtlc.address(); + + let vhtlc_outpoint = { + let virtual_tx_outpoints = self + .get_virtual_tx_outpoints(std::iter::once(vhtlc_address)) + .await?; + + let vtxo_list = VtxoList::new(server_info.dust, virtual_tx_outpoints); + + // We expect a single outpoint. + let mut recoverable = vtxo_list.recoverable(); + + recoverable + .next() + .ok_or_else(|| { + Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}")) + })? + .clone() + }; + + let refund_script = vhtlc.refund_without_receiver_script(); + + let spend_info = vhtlc.taproot_spend_info(); + let script_ver = (refund_script, LeafVersion::TapScript); + let control_block = spend_info + .control_block(&script_ver) + .ok_or(Error::ad_hoc("control block not found for refund script"))?; + + let script_pubkey = vhtlc.script_pubkey(); + + let (refund_address, _) = self.get_offchain_address_with_server_info(&server_info)?; + let refund_amount = swap_data.amount; + + let vhtlc_input = intent::Input::new( + vhtlc_outpoint.outpoint, + parse_sequence_number(timeout_block_heights.unilateral_refund as i64) + .map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?, + Some(absolute::LockTime::from_consensus( + timeout_block_heights.refund, + )), + TxOut { + value: refund_amount, + script_pubkey, + }, + vhtlc.tapscripts(), + (script_ver.0, control_block), + false, + true, + vhtlc_outpoint.assets, + ); + + let commitment_txid = self + .join_next_batch( + rng, + &server_info, + Vec::new(), + vec![vhtlc_input], + BatchOutputType::Board { + to_address: refund_address, + to_amount: refund_amount, + }, + ) + .await + .context("failed to join batch")?; + + self.best_effort_mark_vhtlc_contract_inactive( + swap_id, + swap_data.contract_script_pubkey.as_ref(), + "settlement refund", + ); + + tracing::info!(txid = %commitment_txid, "Refunded VHTLC via settlement"); + + Ok(commitment_txid) + } + + /// Refund a VHTLC with collaboration from Boltz. + /// + /// This path requires Boltz's cooperation to sign the refund transaction. It allows refunding + /// a submarine swap before the timelock expires. For refunds after timelock expiry without + /// Boltz cooperation, use [`Client::refund_expired_vhtlc`] instead. + pub async fn refund_vhtlc(&self, swap_id: &str) -> Result { + let mut swap_data = self + .swap_storage() + .get_submarine(swap_id) + .await? + .ok_or(Error::ad_hoc("submarine swap not found"))?; + + let server_info = self.server_info().await?; + + let vhtlc = self + .submarine_vhtlc_script(&mut swap_data, &server_info) + .await?; + let vhtlc_address = vhtlc.address(); + + let vhtlc_outpoint = { + let virtual_tx_outpoints = self + .get_virtual_tx_outpoints(std::iter::once(vhtlc_address)) + .await?; + + let vtxo_list = VtxoList::new(server_info.dust, virtual_tx_outpoints); + + // We expect a single outpoint. + let mut unspent = vtxo_list.all_unspent(); + let vhtlc_outpoint = unspent.next().ok_or_else(|| { + Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}")) + })?; + + vhtlc_outpoint.clone() + }; + + let (refund_address, _) = self.get_offchain_address().await?; + let refund_amount = swap_data.amount; + + let outputs = vec![SendReceiver { + address: refund_address, + amount: refund_amount, + assets: Vec::new(), + }]; + + // Use the collaborative refund script which requires sender + receiver + server signatures. + let refund_script = vhtlc.refund_script(); + let boltz_refund_pk = swap_data.claim_public_key.inner.x_only_public_key().0; + let refund_leaf_hash = TapLeafHash::from_script(&refund_script, LeafVersion::TapScript); + + let spend_info = vhtlc.taproot_spend_info(); + let script_ver = (refund_script, LeafVersion::TapScript); + let control_block = spend_info + .control_block(&script_ver) + .ok_or(Error::ad_hoc("control block not found for refund script"))?; + + let script_pubkey = vhtlc.script_pubkey(); + + let refunder_pk = swap_data.refund_public_key.inner.x_only_public_key().0; + let vhtlc_input = VtxoInput::new( + script_ver.0, + None, // No locktime required for collaborative refund + control_block, + vhtlc.tapscripts(), + script_pubkey, + refund_amount, + vhtlc_outpoint.outpoint, + vhtlc_outpoint.assets, + ); + + // The change address is superfluous because we are _draining_ the VHTLC. + let change_address = &refund_address; + + let OffchainTransactions { + mut ark_tx, + checkpoint_txs, + } = build_offchain_transactions( + &outputs, + change_address, + std::slice::from_ref(&vhtlc_input), + &server_info, + )?; + + // Sign the ark transaction with the sender's (user's) key. + let kp = self.swap_keypair_by_pk(&refunder_pk, swap_data.key_derivation_index, swap_id)?; + let sign_fn = + |_: &mut psbt::Input, + msg: secp256k1::Message| + -> Result, ark_core::Error> { + let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp); + let pk = kp.x_only_public_key().0; + + Ok(vec![(sig, pk)]) + }; + + sign_ark_transaction(sign_fn, &mut ark_tx, 0)?; + + // Get the unsigned checkpoint - we'll sign it after arkd adds its signature. + let checkpoint_psbt = checkpoint_txs + .first() + .ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))? + .clone(); + + // Send ark transaction (with user signature) and unsigned checkpoint to Boltz. + // Boltz will add their signature (receiver) to the ark transaction. + let url = format!( + "{}/v2/swap/submarine/{swap_id}/refund/ark", + self.inner.boltz_url + ); + let client = reqwest::Client::new(); + let response = client + .post(&url) + .json(&RefundSwapRequest { + transaction: ark_tx.to_string(), + checkpoint: checkpoint_psbt.to_string(), + }) + .send() + .await + .map_err(Error::ad_hoc) + .context("failed to send refund request to Boltz")?; + + if !response.status().is_success() { + let error_text = response + .text() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to read error text")?; + + return Err(Error::ad_hoc(format!( + "Boltz refund request failed: {error_text}" + ))); + } + + let refund_response: RefundSwapResponse = response + .json() + .await + .map_err(Error::ad_hoc) + .context("failed to deserialize refund response")?; + + if let Some(err) = refund_response.error.as_deref() { + return Err(Error::ad_hoc(format!("Boltz refund request failed: {err}"))); + } + + // Parse the Boltz-signed transactions. + let boltz_signed_ark_tx = Psbt::from_str(&refund_response.transaction) + .map_err(Error::ad_hoc) + .context("could not parse refund transaction PSBT")?; + + let boltz_signed_checkpoint = Psbt::from_str(&refund_response.checkpoint) + .map_err(Error::ad_hoc) + .context("could not parse refund checkpoint PSBT")?; + + let ark_txid = boltz_signed_ark_tx.unsigned_tx.compute_txid(); + + // Extract Boltz's signatures before sending to arkd (server strips incoming sigs). + let boltz_tap_script_sigs = validated_boltz_tap_script_sigs( + boltz_signed_checkpoint + .inputs + .first() + .ok_or_else(|| Error::ad_hoc("boltz checkpoint has no inputs"))?, + boltz_refund_pk, + refund_leaf_hash, + )?; + + // Submit to arkd for server signature. + // We send the Boltz-signed transactions so arkd can add its signature. + let res = self + .network_client() + .submit_offchain_transaction_request(boltz_signed_ark_tx, vec![boltz_signed_checkpoint]) + .await?; + + // The server returns the checkpoint with its signature added. + // Now we need to add our (sender) signature to the checkpoint. + let mut server_signed_checkpoint = res + .signed_checkpoint_txs + .first() + .ok_or_else(|| Error::ad_hoc("no signed checkpoint PSBTs returned"))? + .clone(); + + let kp = self.swap_keypair_by_pk(&refunder_pk, swap_data.key_derivation_index, swap_id)?; + let sign_fn = + |_: &mut psbt::Input, + msg: secp256k1::Message| + -> Result, ark_core::Error> { + let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp); + let pk = kp.x_only_public_key().0; + + Ok(vec![(sig, pk)]) + }; + + server_signed_checkpoint + .inputs + .first_mut() + .ok_or_else(|| Error::ad_hoc("server checkpoint has no inputs"))? + .tap_script_sigs + .extend(boltz_tap_script_sigs); + + sign_checkpoint_transaction(sign_fn, &mut server_signed_checkpoint)?; + + // Finalize the transaction with the fully-signed checkpoint. + timeout_op( + self.inner.timeout, + self.network_client() + .finalize_offchain_transaction(ark_txid, vec![server_signed_checkpoint]), + ) + .await? + .map_err(Error::ark_server) + .context("failed to finalize offchain transaction")?; + + self.best_effort_mark_vhtlc_contract_inactive( + swap_id, + swap_data.contract_script_pubkey.as_ref(), + "collaborative refund", + ); + + tracing::info!(swap_id, txid = %ark_txid, "Refunded VHTLC via collaborative refund"); + + Ok(ark_txid) + } + + // Reverse submarine swap. + + async fn validate_reverse_recipient_address( + &self, + recipient_address: Option<&ArkAddress>, + ) -> Result<(), Error> { + let Some(recipient_address) = recipient_address else { + return Ok(()); + }; + + let server_info = self.server_info().await?; + let server_signer: XOnlyPublicKey = server_info.signer_pk.into(); + if recipient_address.server() != server_signer { + return Err(Error::consumer(format!( + "recipient Arkade address belongs to a different server: expected {server_signer}, got {}", + recipient_address.server() + ))); + } + + Ok(()) + } + + async fn reverse_claim_address(&self, swap: &ReverseSwapData) -> Result { + if let Some(address) = swap.claim_address { + self.validate_reverse_recipient_address(Some(&address)) + .await?; + return Ok(address); + } + + let (address, _) = self + .get_offchain_address() + .await + .context("failed to get offchain address")?; + + Ok(address) + } + + /// Generate a BOLT11 invoice to perform a reverse submarine swap via Boltz. This allows to + /// receive Lightning payments into an Arkade wallet. + /// + /// # Arguments + /// + /// - `amount`: the expected [`Amount`] to be received. + /// - `expiry_secs`: optional invoice expiry, in seconds from now. If `None`, Boltz's default is + /// used. + /// - `description`: optional memo embedded in the BOLT11 invoice's `d` field (visible to the + /// payer). + /// + /// # Returns + /// + /// - A `ReverseSwapResult`, including an identifier for the reverse swap and the + /// [`Bolt11Invoice`] to be paid. + pub async fn get_ln_invoice( + &self, + amount: SwapAmount, + expiry_secs: Option, + description: Option, + ) -> Result { + self.create_reverse_swap_invoice_with_new_preimage(amount, expiry_secs, None, description) + .await + } + + /// Generate a BOLT11 invoice to receive Lightning into another user's Arkade address. + /// + /// The local client still creates and claims the Boltz reverse-swap VHTLC, but the resulting + /// Arkade output is sent to `recipient_address` instead of a fresh local address. + /// + /// # Arguments + /// + /// - `amount`: the expected [`Amount`] to be received. + /// - `recipient_address`: Arkade address that receives the claimed VHTLC output. + /// - `expiry_secs`: optional invoice expiry, in seconds from now. If `None`, Boltz's default is + /// used. + /// - `description`: optional memo embedded in the BOLT11 invoice's `d` field (visible to the + /// payer). + /// + /// # Returns + /// + /// - A `ReverseSwapResult`, including an identifier for the reverse swap and the + /// [`Bolt11Invoice`] to be paid. + pub async fn get_ln_invoice_for_address( + &self, + amount: SwapAmount, + recipient_address: ArkAddress, + expiry_secs: Option, + description: Option, + ) -> Result { + self.create_reverse_swap_invoice_with_new_preimage( + amount, + expiry_secs, + Some(recipient_address), + description, + ) + .await + } + + async fn create_reverse_swap_invoice_with_new_preimage( + &self, + amount: SwapAmount, + expiry_secs: Option, + recipient_address: Option, + description: Option, + ) -> Result { + let preimage: [u8; 32] = rand::random(); + let preimage_hash_sha256 = sha256::Hash::hash(&preimage); + + self.create_reverse_swap_invoice( + amount, + expiry_secs, + preimage_hash_sha256, + Some(preimage), + recipient_address, + description, + ) + .await + } + + /// Generate a BOLT11 invoice using a provided SHA256 preimage hash for a reverse submarine + /// swap via Boltz. This allows receiving Lightning payments when the preimage is managed + /// externally. + /// + /// # Arguments + /// + /// - `amount`: the expected [`Amount`] to be received. + /// - `expiry_secs`: optional invoice expiry, in seconds from now. If `None`, Boltz's default is + /// used. + /// - `preimage_hash_sha256`: the SHA256 hash of the preimage. The preimage itself is not stored + /// and must be provided later when claiming via [`Self::claim_vhtlc`]. + /// - `description`: optional memo embedded in the BOLT11 invoice's `d` field (visible to the + /// payer). + /// + /// # Returns + /// + /// - A [`ReverseSwapResult`], including an identifier for the reverse swap and the + /// [`Bolt11Invoice`] to be paid. + /// + /// # Note + /// + /// After calling this method, use [`Self::wait_for_vhtlc_funding`] to wait for the VHTLC to + /// be funded, then [`Self::claim_vhtlc`] with the preimage to claim the funds. + pub async fn get_ln_invoice_from_hash( + &self, + amount: SwapAmount, + expiry_secs: Option, + preimage_hash_sha256: sha256::Hash, + description: Option, + ) -> Result { + self.create_reverse_swap_invoice( + amount, + expiry_secs, + preimage_hash_sha256, + None, + None, + description, + ) + .await + } + + /// Generate a BOLT11 invoice from an externally managed preimage hash and receive the claimed + /// VHTLC output into another user's Arkade address. + /// + /// After calling this method, use [`Self::wait_for_vhtlc_funding`] to wait for the VHTLC to + /// be funded, then [`Self::claim_vhtlc`] with the preimage to claim the funds. + pub async fn get_ln_invoice_from_hash_for_address( + &self, + amount: SwapAmount, + recipient_address: ArkAddress, + expiry_secs: Option, + preimage_hash_sha256: sha256::Hash, + description: Option, + ) -> Result { + self.create_reverse_swap_invoice( + amount, + expiry_secs, + preimage_hash_sha256, + None, + Some(recipient_address), + description, + ) + .await + } + + async fn create_reverse_swap_invoice( + &self, + amount: SwapAmount, + expiry_secs: Option, + preimage_hash_sha256: sha256::Hash, + preimage: Option<[u8; 32]>, + recipient_address: Option, + description: Option, + ) -> Result { + validate_invoice_description(description.as_deref())?; + self.validate_reverse_recipient_address(recipient_address.as_ref()) + .await?; + + let preimage_hash = ripemd160::Hash::hash(preimage_hash_sha256.as_byte_array()); + + let claim_keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?; + let claim_public_key = claim_keypair.public_key(); + let key_derivation_index = + self.derivation_index_for_pk(&claim_keypair.x_only_public_key().0); + + let (invoice_amount, onchain_amount) = match amount { + SwapAmount::Invoice(amount) => (Some(amount), None), + SwapAmount::Vhtlc(amount) => (None, Some(amount)), + }; + + let request = CreateReverseSwapRequest { + from: Asset::Btc, + to: Asset::Ark, + invoice_amount, + onchain_amount, + claim_public_key: claim_public_key.into(), + preimage_hash: preimage_hash_sha256, + invoice_expiry: expiry_secs, + referral_id: self.inner.boltz_referral_id.clone(), + description, + }; + + let url = format!("{}/v2/swap/reverse", self.inner.boltz_url); + + let client = reqwest::Client::new(); + let response = client + .post(&url) + .json(&request) + .send() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to send reverse swap request")?; + + if !response.status().is_success() { + let error_text = response + .text() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to read error text")?; + + return Err(Error::ad_hoc(format!( + "failed to create reverse swap: {error_text}" + ))); + } + + let response: CreateReverseSwapResponse = response + .json() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to deserialize reverse swap response")?; + + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(Error::ad_hoc) + .context("failed to compute created_at")?; + + let swap_amount = response.onchain_amount.or(onchain_amount).ok_or_else(|| { + Error::ad_hoc("onchain_amount not provided by Boltz and not specified in request") + })?; + + let server_info = self.server_info().await?; + let vhtlc = self.build_vhtlc_script( + &server_info, + claim_public_key.into(), + response.refund_public_key, + preimage_hash, + &response.timeout_block_heights, + &response.lockup_address, + )?; + + let script_pubkey = self + .insert_vhtlc_contract(vhtlc.options().clone(), key_derivation_index) + .context("failed to persist VHTLC contract for reverse submarine swap")?; + + let swap = ReverseSwapData { + id: response.id.clone(), + status: SwapStatus::Created, + preimage, + vhtlc_address: response.lockup_address, + preimage_hash, + refund_public_key: response.refund_public_key, + amount: swap_amount, + claim_public_key: claim_public_key.into(), + timeout_block_heights: response.timeout_block_heights, + created_at: created_at.as_secs(), + key_derivation_index, + bolt11: response.invoice.to_string(), + invoice_expiry: response.invoice.expiry_time().as_secs(), + claim_address: recipient_address, + contract_script_pubkey: Some(script_pubkey.clone()), + }; + + if let Err(error) = self + .swap_storage() + .insert_reverse(response.id.clone(), swap.clone()) + .await + .context("failed to persist swap data") + { + return Err(self.deactivate_orphaned_vhtlc_contract( + &script_pubkey, + &response.id, + error, + )); + } + + Ok(ReverseSwapResult { + swap_id: swap.id, + invoice: response.invoice, + amount: swap_amount, + }) + } + + /// Wait for the VHTLC associated with a reverse submarine swap to be funded. + /// + /// This method only waits for the funding transaction to be detected (in mempool or confirmed). + /// It does not claim the VHTLC. Use [`Self::claim_vhtlc`] to claim after the preimage is known. + /// + /// # Arguments + /// + /// - `swap_id`: The unique identifier for the reverse swap. + /// + /// # Returns + /// + /// Returns `Ok(())` when the VHTLC funding transaction is detected. + pub async fn wait_for_vhtlc_funding(&self, swap_id: &str) -> Result<(), Error> { + use futures::StreamExt; + + let stream = + self.subscribe_to_swap_updates_for_type(swap_id.to_string(), SwapType::Reverse); + tokio::pin!(stream); + + while let Some(status_result) = stream.next().await { + match status_result { + Ok(status) => { + tracing::debug!(swap_id, current = ?status, "Swap status"); + + match &status { + SwapStatus::TransactionMempool | SwapStatus::TransactionConfirmed => { + tracing::debug!(swap_id, "VHTLC funding detected"); + return Ok(()); + } + SwapStatus::Error { error } => { + tracing::error!(swap_id, error, "Boltz reported swap error"); + return Err(swap_wait_failure_error(&status, swap_id, "VHTLC funding")); + } + SwapStatus::TransactionRefunded + | SwapStatus::TransactionFailed + | SwapStatus::TransactionLockupFailed + | SwapStatus::InvoiceFailedToPay + | SwapStatus::InvoiceExpired + | SwapStatus::SwapExpired => { + return Err(swap_wait_failure_error(&status, swap_id, "VHTLC funding")); + } + SwapStatus::Created + | SwapStatus::TransactionClaimed + | SwapStatus::TransactionServerMempool + | SwapStatus::TransactionServerConfirmed + | SwapStatus::InvoiceSet + | SwapStatus::InvoicePending + | SwapStatus::InvoicePaid + | SwapStatus::InvoiceSettled => {} + SwapStatus::Other(status) => { + tracing::debug!(swap_id, status, "Ignoring unknown swap status"); + } + } + } + Err(e) => return Err(e), + } + } + + Err(Error::ad_hoc("Status stream ended unexpectedly")) + } + + /// Claim a funded VHTLC for a reverse submarine swap using the preimage. + /// + /// This method should be called after the VHTLC has been funded (after + /// [`Self::wait_for_vhtlc_funding`] returns) and the preimage is known. + /// + /// # Arguments + /// + /// - `swap_id`: The unique identifier for the reverse swap. + /// - `preimage`: The 32-byte preimage that unlocks the VHTLC. + /// + /// # Returns + /// + /// Returns a [`ClaimVhtlcResult`] with details about the claim transaction. + pub async fn claim_vhtlc( + &self, + swap_id: &str, + preimage: [u8; 32], + ) -> Result { + let mut swap = self + .swap_storage() + .get_reverse(swap_id) + .await + .context("failed to get reverse swap data")? + .ok_or_else(|| Error::ad_hoc(format!("reverse swap data not found: {swap_id}")))?; + + // Verify the preimage matches the stored hash + let preimage_hash_sha256 = sha256::Hash::hash(&preimage); + let preimage_hash = ripemd160::Hash::hash(preimage_hash_sha256.as_byte_array()); + + if preimage_hash != swap.preimage_hash { + return Err(Error::ad_hoc(format!( + "preimage does not match stored hash for swap {swap_id}" + ))); + } + + tracing::debug!(swap_id, "Claiming VHTLC with verified preimage"); + + let server_info = self.server_info().await?; + + let vhtlc = self.reverse_vhtlc_script(&mut swap, &server_info).await?; + let vhtlc_address = vhtlc.address(); + + // TODO: Ideally we can skip this if the vout is always the same (probably 0). + let vhtlc_outpoint = { + let virtual_tx_outpoints = self + .get_virtual_tx_outpoints(std::iter::once(vhtlc_address)) + .await?; + + let vtxo_list = VtxoList::new(server_info.dust, virtual_tx_outpoints); + + // We expect a single outpoint. + let mut unspent = vtxo_list.all_unspent(); + let vhtlc_outpoint = unspent.next().ok_or_else(|| { + Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}")) + })?; + + vhtlc_outpoint.clone() + }; + + let claim_address = self.reverse_claim_address(&swap).await?; + let claim_amount = swap.amount; + + let outputs = vec![SendReceiver { + address: claim_address, + amount: claim_amount, + assets: Vec::new(), + }]; + + let spend_info = vhtlc.taproot_spend_info(); + let script_ver = (vhtlc.claim_script(), LeafVersion::TapScript); + let control_block = spend_info + .control_block(&script_ver) + .ok_or(Error::ad_hoc("control block not found for claim script"))?; + + let script_pubkey = vhtlc.script_pubkey(); + + let claimer_pk = swap.claim_public_key.inner.x_only_public_key().0; + let vhtlc_input = VtxoInput::new( + script_ver.0, + None, + control_block, + vhtlc.tapscripts(), + script_pubkey, + claim_amount, + vhtlc_outpoint.outpoint, + vhtlc_outpoint.assets, + ); + + // The change address is superfluous because we are _draining_ the VHTLC. + let change_address = &claim_address; + + let OffchainTransactions { + mut ark_tx, + checkpoint_txs, + } = build_offchain_transactions( + &outputs, + change_address, + std::slice::from_ref(&vhtlc_input), + &server_info, + ) + .map_err(Error::from) + .context("failed to build offchain TXs")?; + + let kp = self.swap_keypair_by_pk(&claimer_pk, swap.key_derivation_index, swap_id)?; + let sign_fn = + |input: &mut psbt::Input, + msg: secp256k1::Message| + -> Result, ark_core::Error> { + // Add preimage to PSBT input. + { + // Initialized with a 1, because we only have one witness element: the preimage. + let mut bytes = vec![1]; + + let length = VarInt::from(preimage.len() as u64); + + length + .consensus_encode(&mut bytes) + .expect("valid length encoding"); + + bytes.write_all(&preimage).expect("valid preimage encoding"); + + input.unknown.insert( + psbt::raw::Key { + type_value: 222, + key: VTXO_CONDITION_KEY.to_vec(), + }, + bytes, + ); + } + + let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp); + let pk = kp.x_only_public_key().0; + + Ok(vec![(sig, pk)]) + }; + + sign_ark_transaction(sign_fn, &mut ark_tx, 0) + .map_err(Error::from) + .context("failed to sign Arkade TX")?; + + let ark_txid = ark_tx.unsigned_tx.compute_txid(); + + let res = self + .network_client() + .submit_offchain_transaction_request(ark_tx, checkpoint_txs) + .await + .map_err(Error::from) + .context("failed to submit offchain TXs")?; + + let mut checkpoint_psbt = res + .signed_checkpoint_txs + .first() + .ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))? + .clone(); + + sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt) + .map_err(Error::from) + .context("failed to sign checkpoint TX")?; + + timeout_op( + self.inner.timeout, + self.network_client() + .finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]), + ) + .await + .context("failed to finalize offchain transaction")? + .map_err(Error::ark_server) + .context("failed to finalize offchain transaction")?; + + // Update storage to persist the preimage + let mut updated_swap = swap.clone(); + updated_swap.preimage = Some(preimage); + self.swap_storage() + .update_reverse(swap_id, updated_swap) + .await + .context("failed to update swap data with preimage")?; + + self.best_effort_mark_vhtlc_contract_inactive( + swap_id, + swap.contract_script_pubkey.as_ref(), + "claim", + ); + + tracing::info!(swap_id, txid = %ark_txid, "Claimed VHTLC"); + + Ok(ClaimVhtlcResult { + swap_id: swap_id.to_string(), + claim_txid: ark_txid, + claim_amount, + preimage, + }) + } + + /// Wait for the VHTLC associated with a reverse submarine swap to be funded, then claim it. + /// + /// # Note + /// + /// This method requires that the preimage was stored when creating the reverse swap (i.e., via + /// [`Self::get_ln_invoice`]). If the swap was created with [`Self::get_ln_invoice_from_hash`], + /// use [`Self::wait_for_vhtlc_funding`] followed by [`Self::claim_vhtlc`] instead. + pub async fn wait_for_vhtlc(&self, swap_id: &str) -> Result { + let swap = self + .swap_storage() + .get_reverse(swap_id) + .await + .context("failed to get reverse swap data")? + .ok_or_else(|| Error::ad_hoc(format!("reverse swap data not found: {swap_id}")))?; + + // Ensure the preimage is available in storage + let preimage = swap.preimage.ok_or_else(|| { + Error::ad_hoc(format!( + "preimage not found in storage for swap {swap_id}. \ + Use wait_for_vhtlc_funding and claim_vhtlc instead." + )) + })?; + + self.wait_for_vhtlc_funding(swap_id).await?; + self.claim_vhtlc(swap_id, preimage).await + } + + // Chain swap. + + /// Create a chain swap via Boltz for swapping between Arkade and on-chain BTC. + /// + /// Returns a [`ChainSwapResult`] containing the swap ID and the address the user must + /// fund to initiate the swap. For [`ChainSwapDirection::ArkToBtc`], the user should send + /// Arkade VTXOs to the `user_lockup_address` using [`Client::send_vtxo`]. For + /// [`ChainSwapDirection::BtcToArk`], the user should send BTC to the `user_lockup_address`. + /// + /// After funding, use [`Self::wait_for_chain_swap_server_lockup`] to wait for Boltz to + /// lock their side. Then claim with the direction-specific method: + /// + /// - [`ChainSwapDirection::BtcToArk`] uses [`Self::claim_chain_swap`]. + /// - [`ChainSwapDirection::ArkToBtc`] uses [`Self::claim_chain_swap_btc`]. + pub async fn create_chain_swap( + &self, + direction: ChainSwapDirection, + amount: ChainSwapAmount, + ) -> Result { + let preimage: [u8; 32] = rand::random(); + let preimage_hash = sha256::Hash::hash(&preimage); + + let claim_keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?; + let claim_public_key = PublicKey::new(claim_keypair.public_key()); + let claim_key_derivation_index = + self.derivation_index_for_pk(&claim_keypair.x_only_public_key().0); + + let refund_keypair = self.next_keypair(crate::key_provider::KeypairIndex::New)?; + let refund_public_key = PublicKey::new(refund_keypair.public_key()); + let refund_key_derivation_index = + self.derivation_index_for_pk(&refund_keypair.x_only_public_key().0); + + let (from, to) = match &direction { + ChainSwapDirection::ArkToBtc => (Asset::Ark, Asset::Btc), + ChainSwapDirection::BtcToArk => (Asset::Btc, Asset::Ark), + }; + + let (user_lock_amount, server_lock_amount) = match &amount { + ChainSwapAmount::UserLock(a) => (Some(*a), None), + ChainSwapAmount::ServerLock(a) => (None, Some(*a)), + }; + + let request = CreateChainSwapRequest { + from, + to, + user_lock_amount, + server_lock_amount, + claim_public_key, + refund_public_key, + preimage_hash, + referral_id: self.inner.boltz_referral_id.clone(), + }; + + let url = format!("{}/v2/swap/chain", self.inner.boltz_url); + + let client = reqwest::Client::new(); + let response = client + .post(&url) + .json(&request) + .send() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to send chain swap request")?; + + if !response.status().is_success() { + let error_text = response + .text() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to read error text")?; + + return Err(Error::ad_hoc(format!( + "failed to create chain swap: {error_text}" + ))); + } + + let swap_response: CreateChainSwapResponse = response + .json() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to deserialize chain swap response")?; + + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(Error::ad_hoc) + .context("failed to compute created_at")?; + + // lockup_details = user's side (where user locks funds) + // claim_details = server's side (where user claims funds) + // The Arkade side carries `timeouts` (full VHTLC timelocks). + // The BTC side carries `swap_tree` and optionally `bip21`. + let bip21 = swap_response + .lockup_details + .bip21 + .or(swap_response.claim_details.bip21.clone()); + + let swap_tree = swap_response + .lockup_details + .swap_tree + .or(swap_response.claim_details.swap_tree.clone()); + + let chain_vhtlc_fields = chain_vhtlc_fields( + &direction, + claim_public_key, + refund_public_key, + swap_response.lockup_details.server_public_key, + swap_response.claim_details.server_public_key, + &swap_response.lockup_details.lockup_address, + &swap_response.claim_details.lockup_address, + swap_response.lockup_details.timeouts, + swap_response.claim_details.timeouts, + claim_key_derivation_index, + refund_key_derivation_index, + )?; + + let server_info = self.server_info().await?; + let vhtlc = self.build_vhtlc_script( + &server_info, + chain_vhtlc_fields.claim_public_key, + chain_vhtlc_fields.refund_public_key, + ripemd160::Hash::hash(preimage_hash.as_byte_array()), + &chain_vhtlc_fields.timeouts, + &chain_vhtlc_fields.address, + )?; + let contract_script_pubkey = vhtlc.script_pubkey(); + + let stored_script_pubkey = self + .insert_vhtlc_contract( + vhtlc.options().clone(), + chain_vhtlc_fields.key_derivation_index, + ) + .context("failed to persist chain VHTLC contract")?; + debug_assert_eq!(stored_script_pubkey, contract_script_pubkey); + + let data = ChainSwapData { + id: swap_response.id.clone(), + status: SwapStatus::Created, + direction, + preimage: Some(preimage), + preimage_hash, + claim_public_key, + refund_public_key, + server_claim_public_key: swap_response.lockup_details.server_public_key, + server_refund_public_key: swap_response.claim_details.server_public_key, + user_lockup_address: swap_response.lockup_details.lockup_address, + server_lockup_address: swap_response.claim_details.lockup_address, + user_lockup_amount: swap_response.lockup_details.amount, + server_lockup_amount: swap_response.claim_details.amount, + user_timeout_block_height: swap_response.lockup_details.timeout_block_height, + server_timeout_block_height: swap_response.claim_details.timeout_block_height, + user_timeout_block_heights: swap_response.lockup_details.timeouts, + server_timeout_block_heights: swap_response.claim_details.timeouts, + bip21, + swap_tree, + created_at: created_at.as_secs(), + claim_key_derivation_index, + refund_key_derivation_index, + contract_script_pubkey: Some(contract_script_pubkey.clone()), + }; + + if let Err(error) = self + .swap_storage() + .insert_chain(swap_response.id.clone(), data.clone()) + .await + { + return Err(self.deactivate_orphaned_vhtlc_contract( + &contract_script_pubkey, + &swap_response.id, + error, + )); + } + + tracing::info!( + swap_id = swap_response.id, + direction = ?data.direction, + user_lockup_address = %data.user_lockup_address, + user_lockup_amount = %data.user_lockup_amount, + server_lockup_amount = %data.server_lockup_amount, + "Created chain swap" + ); + + Ok(ChainSwapResult { + swap_id: swap_response.id, + user_lockup_address: data.user_lockup_address, + user_lockup_amount: data.user_lockup_amount, + server_lockup_amount: data.server_lockup_amount, + bip21: data.bip21, + }) + } + + /// Wait for Boltz to lock funds on their side of the chain swap. + /// + /// Returns when the server's lockup transaction is detected in the mempool or confirmed. + /// After this returns, claim with the direction-specific method: + /// + /// - [`ChainSwapDirection::BtcToArk`] uses [`Self::claim_chain_swap`]. + /// - [`ChainSwapDirection::ArkToBtc`] uses [`Self::claim_chain_swap_btc`]. + /// + /// Returns the server's lockup transaction ID if available. + pub async fn wait_for_chain_swap_server_lockup( + &self, + swap_id: &str, + ) -> Result, Error> { + use futures::StreamExt; + + let stream = self.subscribe_to_swap_updates_for_type(swap_id.to_string(), SwapType::Chain); + tokio::pin!(stream); + + while let Some(status_result) = stream.next().await { + match status_result { + Ok(status) => { + tracing::debug!(swap_id, current = ?status, "Chain swap status"); + match &status { + SwapStatus::TransactionServerMempool + | SwapStatus::TransactionServerConfirmed => { + // Fetch the full status to get the server's lockup txid. + let url = format!("{}/v2/swap/{swap_id}", self.inner.boltz_url); + let response = reqwest::Client::new() + .get(&url) + .send() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to fetch chain swap status")?; + + let status = response.status(); + if !status.is_success() { + let error_text = response + .text() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to read chain swap status error response")?; + return Err(Error::ad_hoc(format!( + "failed to fetch chain swap status ({status}): {error_text}" + ))); + } + + let txid = response + .json::() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to decode chain swap status response")? + .transaction + .map(|t| t.id); + + tracing::info!( + swap_id, + server_lockup_txid = txid.as_deref().unwrap_or("unknown"), + "Server lockup detected" + ); + return Ok(txid); + } + SwapStatus::Error { error } => { + tracing::error!(swap_id, error, "Boltz reported chain swap error"); + return Err(swap_wait_failure_error( + &status, + swap_id, + "chain swap server lockup", + )); + } + SwapStatus::TransactionRefunded + | SwapStatus::TransactionFailed + | SwapStatus::TransactionLockupFailed + | SwapStatus::InvoiceFailedToPay + | SwapStatus::InvoiceExpired + | SwapStatus::SwapExpired => { + return Err(swap_wait_failure_error( + &status, + swap_id, + "chain swap server lockup", + )); + } + // User lockup detected — still waiting for server side. + SwapStatus::Created + | SwapStatus::TransactionMempool + | SwapStatus::TransactionConfirmed + | SwapStatus::TransactionClaimed + | SwapStatus::InvoiceSet + | SwapStatus::InvoicePending + | SwapStatus::InvoicePaid + | SwapStatus::InvoiceSettled => {} + SwapStatus::Other(status) => { + tracing::debug!(swap_id, status, "Ignoring unknown chain swap status"); + } + } + } + Err(e) => return Err(e), + } + } + + Err(Error::ad_hoc("Chain swap status stream ended unexpectedly")) + } + + /// Claim the Arkade VHTLC from a chain swap after Boltz has locked funds. + /// + /// This claims the server's Arkade VHTLC lockup using the stored preimage. It is intended + /// for [`ChainSwapDirection::BtcToArk`] swaps where the server locks an Arkade VHTLC. + /// + /// Call this after [`Self::wait_for_chain_swap_server_lockup`] returns. + pub async fn claim_chain_swap(&self, swap_id: &str) -> Result { + let mut swap = self + .swap_storage() + .get_chain(swap_id) + .await + .context("failed to get chain swap data")? + .ok_or_else(|| Error::ad_hoc(format!("chain swap data not found: {swap_id}")))?; + + if swap.direction != ChainSwapDirection::BtcToArk { + return Err(Error::ad_hoc( + "claim_chain_swap only applies to BtcToArk swaps; use claim_chain_swap_btc", + )); + } + + let preimage = swap + .preimage + .ok_or_else(|| Error::ad_hoc(format!("preimage not found for chain swap {swap_id}")))?; + + let server_info = self.server_info().await?; + + let vhtlc = self.chain_vhtlc_script(&mut swap, &server_info).await?; + let vhtlc_address = vhtlc.address(); + + let vhtlc_outpoint = { + let virtual_tx_outpoints = self + .get_virtual_tx_outpoints(std::iter::once(vhtlc_address)) + .await?; + + let vtxo_list = VtxoList::new(server_info.dust, virtual_tx_outpoints); + + let mut unspent = vtxo_list.all_unspent(); + let vhtlc_outpoint = unspent.next().ok_or_else(|| { + Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}")) + })?; + + vhtlc_outpoint.clone() + }; + + let (claim_address, _) = self + .get_offchain_address() + .await + .context("failed to get offchain address")?; + let claim_amount = swap.server_lockup_amount; + + let outputs = vec![SendReceiver::bitcoin(claim_address, claim_amount)]; + + let spend_info = vhtlc.taproot_spend_info(); + let script_ver = (vhtlc.claim_script(), LeafVersion::TapScript); + let control_block = spend_info + .control_block(&script_ver) + .ok_or(Error::ad_hoc("control block not found for claim script"))?; + + let script_pubkey = vhtlc.script_pubkey(); + + let claimer_pk = swap.claim_public_key.inner.x_only_public_key().0; + let vhtlc_input = VtxoInput::new( + script_ver.0, + None, + control_block, + vhtlc.tapscripts(), + script_pubkey, + claim_amount, + vhtlc_outpoint.outpoint, + vhtlc_outpoint.assets, + ); + + // The change address is superfluous because we are _draining_ the VHTLC. + let change_address = &claim_address; + + let OffchainTransactions { + mut ark_tx, + checkpoint_txs, + } = build_offchain_transactions( + &outputs, + change_address, + std::slice::from_ref(&vhtlc_input), + &server_info, + ) + .map_err(Error::from) + .context("failed to build offchain TXs")?; + + let kp = self.swap_keypair_by_pk(&claimer_pk, swap.claim_key_derivation_index, swap_id)?; + let sign_fn = + |input: &mut psbt::Input, + msg: secp256k1::Message| + -> Result, ark_core::Error> { + // Add preimage to PSBT input. + { + let mut bytes = vec![1]; + + let length = VarInt::from(preimage.len() as u64); + + length + .consensus_encode(&mut bytes) + .expect("valid length encoding"); + + bytes.write_all(&preimage).expect("valid preimage encoding"); + + input.unknown.insert( + psbt::raw::Key { + type_value: 222, + key: VTXO_CONDITION_KEY.to_vec(), + }, + bytes, + ); + } + + let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp); + let pk = kp.x_only_public_key().0; + + Ok(vec![(sig, pk)]) + }; + + sign_ark_transaction(sign_fn, &mut ark_tx, 0) + .map_err(Error::from) + .context("failed to sign Arkade TX")?; + + let ark_txid = ark_tx.unsigned_tx.compute_txid(); + + let res = self + .network_client() + .submit_offchain_transaction_request(ark_tx, checkpoint_txs) + .await + .map_err(Error::from) + .context("failed to submit offchain TXs")?; + + let mut checkpoint_psbt = res + .signed_checkpoint_txs + .first() + .ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))? + .clone(); + + sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt) + .map_err(Error::from) + .context("failed to sign checkpoint TX")?; + + timeout_op( + self.inner.timeout, + self.network_client() + .finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]), + ) + .await + .context("failed to finalize offchain transaction")? + .map_err(Error::ark_server) + .context("failed to finalize offchain transaction")?; + + tracing::info!(swap_id, txid = %ark_txid, "Claimed chain swap VHTLC"); + + let mut updated_swap = swap.clone(); + updated_swap.status = SwapStatus::TransactionClaimed; + self.swap_storage() + .update_chain(swap_id, updated_swap.clone()) + .await + .context("failed to update chain swap data")?; + self.best_effort_mark_vhtlc_contract_inactive( + swap_id, + updated_swap.contract_script_pubkey.as_ref(), + "chain claim", + ); + + Ok(ark_txid) + } + + /// Claim on-chain BTC from a chain swap after Boltz has locked funds. + /// + /// This claims the server's on-chain BTC HTLC using the stored preimage. It is intended + /// for [`ChainSwapDirection::ArkToBtc`] swaps where the server locks on-chain BTC. + /// + /// Call this after [`Self::wait_for_chain_swap_server_lockup`] returns. + pub async fn claim_chain_swap_btc( + &self, + swap_id: &str, + destination_address: bitcoin::Address, + fee_rate_sat_vb: f64, + ) -> Result { + let swap = self + .swap_storage() + .get_chain(swap_id) + .await + .context("failed to get chain swap data")? + .ok_or_else(|| Error::ad_hoc(format!("chain swap data not found: {swap_id}")))?; + + let preimage = swap + .preimage + .ok_or_else(|| Error::ad_hoc(format!("preimage not found for chain swap {swap_id}")))?; + + let swap_tree = swap.swap_tree.clone().ok_or_else(|| { + Error::ad_hoc("no swap tree found (this swap has no on-chain BTC HTLC)") + })?; + + // The BTC lockup is server-side for ArkToBtc + let btc_address_str = &swap.server_lockup_address; + + // Reconstruct the taproot tree. For ArkToBtc, the server's key on the BTC + // side is server_refund_public_key and the user's key is claim_public_key. + let taproot_spend_info = reconstruct_btc_htlc( + swap.server_refund_public_key, + swap.claim_public_key, + &swap_tree, + )?; + + let secp = Secp256k1::new(); + + // Verify the reconstructed address matches the lockup address. + let expected_spk = ScriptBuf::new_p2tr( + &secp, + taproot_spend_info.internal_key(), + taproot_spend_info.merkle_root(), + ); + + let parsed_address: bitcoin::Address = btc_address_str + .parse() + .map_err(|e| Error::ad_hoc(format!("invalid BTC lockup address: {e}")))?; + let parsed_address = parsed_address.assume_checked(); + let target_spk = parsed_address.script_pubkey(); + + if expected_spk != target_spk { + return Err(Error::ad_hoc(format!( + "taproot address mismatch for BTC lockup {btc_address_str}" + ))); + } + + let claim_script_bytes: Vec = + bitcoin::hex::FromHex::from_hex(&swap_tree.claim_leaf.output) + .map_err(|e| Error::ad_hoc(format!("invalid claim leaf hex: {e}")))?; + let claim_script = ScriptBuf::from_bytes(claim_script_bytes); + let claim_ver = (claim_script.clone(), LeafVersion::TapScript); + + // Find the unspent UTXO at the BTC lockup address + let utxos = self + .inner + .blockchain + .find_outpoints(&parsed_address) + .await + .context("failed to find UTXOs at BTC lockup address")?; + + let utxo = utxos.iter().find(|u| !u.is_spent).ok_or_else(|| { + Error::ad_hoc(format!( + "no unspent UTXO found at BTC lockup address {btc_address_str}" + )) + })?; + + // Get the control block for the claim leaf + let control_block = taproot_spend_info + .control_block(&claim_ver) + .ok_or(Error::ad_hoc("control block not found for claim leaf"))?; + + let cb_bytes = control_block.serialize(); + // Weight: 4 * (overhead 10.5 + input ~41 + output ~43) + witness items + let witness_weight = 1 + 1 + 64 + 1 + 32 + 1 + claim_script.len() + 1 + cb_bytes.len() + 1; + let weight = 4 * (11 + 41 + 43) + witness_weight; + let vsize = weight.div_ceil(4); + let fee = Amount::from_sat((vsize as f64 * fee_rate_sat_vb).ceil() as u64); + + let claim_amount = utxo.amount.checked_sub(fee).ok_or_else(|| { + Error::ad_hoc(format!( + "UTXO amount {} is less than estimated fee {}", + utxo.amount, fee + )) + })?; + + // Build the unsigned transaction + let mut tx = bitcoin::Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: utxo.outpoint, + script_sig: ScriptBuf::new(), + sequence: bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: bitcoin::Witness::new(), + }], + output: vec![TxOut { + value: claim_amount, + script_pubkey: destination_address.script_pubkey(), + }], + }; + + // Compute the taproot script-path sighash + let leaf_hash = TapLeafHash::from_script(&claim_script, LeafVersion::TapScript); + + let prevouts = [TxOut { + value: utxo.amount, + script_pubkey: target_spk.clone(), + }]; + + let sighash = bitcoin::sighash::SighashCache::new(&tx) + .taproot_script_spend_signature_hash( + 0, + &bitcoin::sighash::Prevouts::All(&prevouts), + leaf_hash, + bitcoin::TapSighashType::Default, + ) + .map_err(|e| Error::ad_hoc(format!("failed to compute sighash: {e}")))?; + + let msg = secp256k1::Message::from_digest(sighash.to_byte_array()); + let claim_pk = swap.claim_public_key.inner.x_only_public_key().0; + let claim_kp = + self.swap_keypair_by_pk(&claim_pk, swap.claim_key_derivation_index, swap_id)?; + let signature = secp.sign_schnorr_no_aux_rand(&msg, &claim_kp); + + // Build witness: + let mut witness = bitcoin::Witness::new(); + witness.push(signature.serialize()); + witness.push(preimage); + witness.push(claim_script.as_bytes()); + witness.push(cb_bytes); + + tx.input[0].witness = witness; + + // Broadcast + self.inner + .blockchain + .broadcast(&tx) + .await + .context("failed to broadcast BTC claim transaction")?; + + let txid = tx.compute_txid(); + + tracing::info!(swap_id, %txid, %claim_amount, "Claimed on-chain BTC from chain swap"); + + let mut updated_swap = swap.clone(); + updated_swap.status = SwapStatus::TransactionClaimed; + self.swap_storage() + .update_chain(swap_id, updated_swap) + .await + .context("failed to update chain swap data")?; + + Ok(txid) + } + + /// Refund the Arkade VHTLC from a chain swap after the timelock has expired. + /// + /// This is for [`ChainSwapDirection::ArkToBtc`] swaps where the user locked an Arkade VHTLC + /// and needs to reclaim it (e.g. if Boltz never locked BTC or the swap expired). + /// + /// This path does not require a signature from Boltz. + pub async fn refund_chain_swap(&self, swap_id: &str) -> Result { + let mut swap = self + .swap_storage() + .get_chain(swap_id) + .await + .context("failed to get chain swap data")? + .ok_or_else(|| Error::ad_hoc(format!("chain swap data not found: {swap_id}")))?; + + if swap.direction != ChainSwapDirection::ArkToBtc { + return Err(Error::ad_hoc( + "refund_chain_swap only applies to ArkToBtc swaps; use refund_chain_swap_btc", + )); + } + + let timeout_block_heights = swap.user_timeout_block_heights.ok_or_else(|| { + Error::ad_hoc("chain swap is missing Arkade-side VHTLC timeouts for user lockup") + })?; + + let server_info = self.server_info().await?; + + let vhtlc = self.chain_vhtlc_script(&mut swap, &server_info).await?; + let vhtlc_address = vhtlc.address(); + + let vhtlc_outpoint = { + let virtual_tx_outpoints = self + .get_virtual_tx_outpoints(std::iter::once(vhtlc_address)) + .await?; + + let vtxo_list = VtxoList::new(server_info.dust, virtual_tx_outpoints); + + let mut unspent = vtxo_list.all_unspent(); + unspent + .next() + .ok_or_else(|| { + Error::ad_hoc(format!("no outpoint found for address {vhtlc_address}")) + })? + .clone() + }; + + let (refund_address, _) = self.get_offchain_address().await?; + let refund_amount = swap.user_lockup_amount; + + let outputs = vec![SendReceiver::bitcoin(refund_address, refund_amount)]; + + let refund_script = vhtlc.refund_without_receiver_script(); + let spend_info = vhtlc.taproot_spend_info(); + let script_ver = (refund_script, LeafVersion::TapScript); + let control_block = spend_info + .control_block(&script_ver) + .ok_or(Error::ad_hoc("control block not found for refund script"))?; + + let script_pubkey = vhtlc.script_pubkey(); + let refunder_pk = swap.refund_public_key.inner.x_only_public_key().0; + + // The change address is superfluous because we are _draining_ the VHTLC. + let change_address = &refund_address; + + let vhtlc_input = VtxoInput::new( + script_ver.0, + Some(absolute::LockTime::from_consensus( + timeout_block_heights.refund, + )), + control_block, + vhtlc.tapscripts(), + script_pubkey, + refund_amount, + vhtlc_outpoint.outpoint, + vhtlc_outpoint.assets, + ); + + let OffchainTransactions { + mut ark_tx, + checkpoint_txs, + } = build_offchain_transactions( + &outputs, + change_address, + std::slice::from_ref(&vhtlc_input), + &server_info, + )?; + + let kp = + self.swap_keypair_by_pk(&refunder_pk, swap.refund_key_derivation_index, swap_id)?; + let sign_fn = + |_: &mut psbt::Input, + msg: secp256k1::Message| + -> Result, ark_core::Error> { + let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp); + let pk = kp.x_only_public_key().0; + Ok(vec![(sig, pk)]) + }; + + sign_ark_transaction(sign_fn, &mut ark_tx, 0)?; + + let ark_txid = ark_tx.unsigned_tx.compute_txid(); + + let res = self + .network_client() + .submit_offchain_transaction_request(ark_tx, checkpoint_txs) + .await?; + + let mut checkpoint_psbt = res + .signed_checkpoint_txs + .first() + .ok_or_else(|| Error::ad_hoc("no checkpoint PSBTs found"))? + .clone(); + + let kp = + self.swap_keypair_by_pk(&refunder_pk, swap.refund_key_derivation_index, swap_id)?; + let sign_fn = + |_: &mut psbt::Input, + msg: secp256k1::Message| + -> Result, ark_core::Error> { + let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &kp); + let pk = kp.x_only_public_key().0; + Ok(vec![(sig, pk)]) + }; + + sign_checkpoint_transaction(sign_fn, &mut checkpoint_psbt)?; + + timeout_op( + self.inner.timeout, + self.network_client() + .finalize_offchain_transaction(ark_txid, vec![checkpoint_psbt]), + ) + .await? + .map_err(Error::ark_server) + .context("failed to finalize offchain transaction")?; + + tracing::info!(swap_id, txid = %ark_txid, "Refunded chain swap Arkade VHTLC"); + + let mut updated_swap = swap.clone(); + updated_swap.status = SwapStatus::TransactionRefunded; + self.swap_storage() + .update_chain(swap_id, updated_swap.clone()) + .await + .context("failed to update chain swap data")?; + self.best_effort_mark_vhtlc_contract_inactive( + swap_id, + updated_swap.contract_script_pubkey.as_ref(), + "chain refund", + ); + + Ok(ark_txid) + } + + /// Refund on-chain BTC from a chain swap after the timelock has expired. + /// + /// This is for [`ChainSwapDirection::BtcToArk`] swaps where the user locked on-chain BTC + /// and needs to reclaim it (e.g. if Boltz never locked the Arkade VHTLC or the swap expired). + pub async fn refund_chain_swap_btc( + &self, + swap_id: &str, + destination_address: bitcoin::Address, + fee_rate_sat_vb: f64, + ) -> Result { + let swap = self + .swap_storage() + .get_chain(swap_id) + .await + .context("failed to get chain swap data")? + .ok_or_else(|| Error::ad_hoc(format!("chain swap data not found: {swap_id}")))?; + + let swap_tree = swap.swap_tree.clone().ok_or_else(|| { + Error::ad_hoc("no swap tree found (this swap has no on-chain BTC lockup)") + })?; + + // The user's BTC lockup address + let btc_address_str = &swap.user_lockup_address; + + // Reconstruct the taproot tree. For BtcToArk, the server's key on the BTC + // side is server_claim_public_key and the user's key is refund_public_key. + let taproot_spend_info = reconstruct_btc_htlc( + swap.server_claim_public_key, + swap.refund_public_key, + &swap_tree, + )?; + + let secp = Secp256k1::new(); + + let refund_script_bytes: Vec = + bitcoin::hex::FromHex::from_hex(&swap_tree.refund_leaf.output) + .map_err(|e| Error::ad_hoc(format!("invalid refund leaf hex: {e}")))?; + let refund_script = ScriptBuf::from_bytes(refund_script_bytes); + let refund_ver = (refund_script.clone(), LeafVersion::TapScript); + + // Verify address + let expected_spk = ScriptBuf::new_p2tr( + &secp, + taproot_spend_info.internal_key(), + taproot_spend_info.merkle_root(), + ); + + let parsed_address: bitcoin::Address = btc_address_str + .parse() + .map_err(|e| Error::ad_hoc(format!("invalid BTC lockup address: {e}")))?; + let parsed_address = parsed_address.assume_checked(); + let target_spk = parsed_address.script_pubkey(); + + if expected_spk != target_spk { + return Err(Error::ad_hoc(format!( + "taproot address mismatch for BTC lockup {btc_address_str}" + ))); + } + + // Find the unspent UTXO + let utxos = self + .inner + .blockchain + .find_outpoints(&parsed_address) + .await + .context("failed to find UTXOs at BTC lockup address")?; + + let utxo = utxos.iter().find(|u| !u.is_spent).ok_or_else(|| { + Error::ad_hoc(format!( + "no unspent UTXO found at BTC lockup address {btc_address_str}" + )) + })?; + + let control_block = taproot_spend_info + .control_block(&refund_ver) + .ok_or(Error::ad_hoc("control block not found for refund leaf"))?; + + let cb_bytes = control_block.serialize(); + let witness_weight = 1 + 1 + 64 + 1 + refund_script.len() + 1 + cb_bytes.len() + 1; + let weight = 4 * (11 + 41 + 43) + witness_weight; + let vsize = weight.div_ceil(4); + let fee = Amount::from_sat((vsize as f64 * fee_rate_sat_vb).ceil() as u64); + + let refund_amount = utxo.amount.checked_sub(fee).ok_or_else(|| { + Error::ad_hoc(format!( + "UTXO amount {} is less than estimated fee {}", + utxo.amount, fee + )) + })?; + + // Use the user's timeout block height as nLockTime + let lock_time = absolute::LockTime::from_consensus(swap.user_timeout_block_height); + + let mut tx = bitcoin::Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time, + input: vec![bitcoin::TxIn { + previous_output: utxo.outpoint, + script_sig: ScriptBuf::new(), + sequence: bitcoin::Sequence::ENABLE_LOCKTIME_NO_RBF, + witness: bitcoin::Witness::new(), + }], + output: vec![TxOut { + value: refund_amount, + script_pubkey: destination_address.script_pubkey(), + }], + }; + + // Sign with the refund key + let leaf_hash = TapLeafHash::from_script(&refund_script, LeafVersion::TapScript); + + let prevouts = [TxOut { + value: utxo.amount, + script_pubkey: target_spk, + }]; + + let sighash = bitcoin::sighash::SighashCache::new(&tx) + .taproot_script_spend_signature_hash( + 0, + &bitcoin::sighash::Prevouts::All(&prevouts), + leaf_hash, + bitcoin::TapSighashType::Default, + ) + .map_err(|e| Error::ad_hoc(format!("failed to compute sighash: {e}")))?; + + let msg = secp256k1::Message::from_digest(sighash.to_byte_array()); + let refund_pk = swap.refund_public_key.inner.x_only_public_key().0; + let refund_kp = + self.swap_keypair_by_pk(&refund_pk, swap.refund_key_derivation_index, swap_id)?; + let signature = secp.sign_schnorr_no_aux_rand(&msg, &refund_kp); + + // Witness for refund: + let mut witness = bitcoin::Witness::new(); + witness.push(signature.serialize()); + witness.push(refund_script.as_bytes()); + witness.push(cb_bytes); + + tx.input[0].witness = witness; + + self.inner + .blockchain + .broadcast(&tx) + .await + .context("failed to broadcast BTC refund transaction")?; + + let txid = tx.compute_txid(); + + tracing::info!(swap_id, %txid, %refund_amount, "Refunded on-chain BTC from chain swap"); + + let mut updated_swap = swap.clone(); + updated_swap.status = SwapStatus::TransactionRefunded; + self.swap_storage() + .update_chain(swap_id, updated_swap) + .await + .context("failed to update chain swap data")?; + + Ok(txid) + } + + /// Query the current status of any Boltz swap by ID. + /// + /// Checks local swap storage to determine the swap type, then queries the Boltz API + /// for the live status. + pub async fn get_swap_status(&self, swap_id: &str) -> Result { + // Determine swap type from local storage. + let swap_type = self.swap_type_for_id(swap_id).await?; + + // Query the Boltz API for live status. + let url = format!("{}/v2/swap/{swap_id}", self.inner.boltz_url); + let client = reqwest::Client::new(); + let response = client + .get(&url) + .send() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to query swap status")?; + + if !response.status().is_success() { + let error_text = response + .text() + .await + .map_err(|e| Error::ad_hoc(e.to_string()))?; + return Err(Error::ad_hoc(format!( + "failed to get swap status: {error_text}" + ))); + } + + let status_response: GetSwapStatusResponse = response + .json() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to deserialize swap status response")?; + + self.persist_swap_status_for_type(swap_type, swap_id, status_response.status.clone()) + .await?; + + Ok(SwapStatusInfo { + swap_id: swap_id.to_string(), + swap_type, + status: status_response.status, + }) + } + + /// Fetch fee information from Boltz for both submarine and reverse swaps. + /// + /// # Returns + /// + /// - A [`BoltzFees`] struct containing fee information for both swap types. + pub async fn get_fees(&self) -> Result { + let client = reqwest::Client::builder() + .timeout(self.inner.timeout) + .build() + .map_err(|e| Error::ad_hoc(e.to_string()))?; + + // Fetch submarine swap fees (Arkade -> BTC) + let submarine_url = format!("{}/v2/swap/submarine", self.inner.boltz_url); + let submarine_response = client + .get(&submarine_url) + .send() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to fetch submarine swap fees")?; + + if !submarine_response.status().is_success() { + let error_text = submarine_response + .text() + .await + .map_err(|e| Error::ad_hoc(e.to_string()))?; + return Err(Error::ad_hoc(format!( + "failed to fetch submarine swap fees: {error_text}" + ))); + } + + let submarine_pairs: SubmarinePairsResponse = submarine_response + .json() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to deserialize submarine swap fees response")?; + + let submarine_pair_fees = &submarine_pairs.ark.btc.fees; + let submarine_fees = SubmarineSwapFees { + percentage: submarine_pair_fees.percentage, + miner_fees: submarine_pair_fees.miner_fees, + }; + + // Fetch reverse swap fees (BTC -> Arkade) + let reverse_url = format!("{}/v2/swap/reverse", self.inner.boltz_url); + let reverse_response = client + .get(&reverse_url) + .send() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to fetch reverse swap fees")?; + + if !reverse_response.status().is_success() { + let error_text = reverse_response + .text() + .await + .map_err(|e| Error::ad_hoc(e.to_string()))?; + return Err(Error::ad_hoc(format!( + "failed to fetch reverse swap fees: {error_text}" + ))); + } + + let reverse_pairs: ReversePairsResponse = reverse_response + .json() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to deserialize reverse swap fees response")?; + + let reverse_pair_fees = &reverse_pairs.btc.ark.fees; + let reverse_fees = ReverseSwapFees { + percentage: reverse_pair_fees.percentage, + miner_fees: ReverseMinerFees { + lockup: reverse_pair_fees.miner_fees.lockup, + claim: reverse_pair_fees.miner_fees.claim, + }, + }; + + Ok(BoltzFees { + submarine: submarine_fees, + reverse: reverse_fees, + }) + } + + /// Fetch swap amount limits from Boltz for submarine swaps. + /// + /// # Returns + /// + /// - A [`SwapLimits`] struct containing minimum and maximum swap amounts in satoshis. + pub async fn get_limits(&self) -> Result { + let client = reqwest::Client::builder() + .timeout(self.inner.timeout) + .build() + .map_err(|e| Error::ad_hoc(e.to_string()))?; + + let url = format!("{}/v2/swap/submarine", self.inner.boltz_url); + let response = client + .get(&url) + .send() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to fetch swap limits")?; + + if !response.status().is_success() { + let error_text = response + .text() + .await + .map_err(|e| Error::ad_hoc(e.to_string()))?; + return Err(Error::ad_hoc(format!( + "failed to fetch swap limits: {error_text}" + ))); + } + + let pairs: SubmarinePairsResponse = response + .json() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to deserialize swap limits response")?; + + Ok(SwapLimits { + min: pairs.ark.btc.limits.minimal, + max: pairs.ark.btc.limits.maximal, + }) + } + + /// Use Boltz's API to learn about updates for a particular swap. + // TODO: Make sure this is WASM-compatible. + pub fn subscribe_to_swap_updates( + &self, + swap_id: String, + ) -> impl futures::Stream> + '_ { + async_stream::stream! { + use futures::StreamExt; + + let swap_type = match self.swap_type_for_id(&swap_id).await { + Ok(swap_type) => swap_type, + Err(error) => { + yield Err(error); + return; + } + }; + let stream = self.subscribe_to_swap_updates_for_type(swap_id, swap_type); + tokio::pin!(stream); + + while let Some(status) = stream.next().await { + yield status; + } + } + } + + /// Start a background watcher for active Boltz VHTLC contracts. + /// + /// The watcher subscribes directly to active VHTLC scripts and drives lifecycle actions from + /// VTXO events: it claims locally claimable VHTLCs, extracts submarine preimages after Boltz + /// spends a VHTLC, reconciles contract state, and retries refunds for locally stored refundable + /// statuses. This keeps VHTLC lifecycle handling event-driven instead of tying contract + /// deactivation to Boltz status polling. + /// + /// Keep the returned handle alive for as long as the watcher should run. Dropping the handle + /// stops the watcher. + #[must_use = "dropping the handle stops the Boltz VHTLC watcher"] + pub fn start_boltz_vhtlc_watcher(self: &Arc) -> BoltzVhtlcWatcherHandle + where + B: Send + Sync + 'static, + W: Send + Sync + 'static, + { + self.start_boltz_vhtlc_watcher_with_config(BoltzVhtlcWatcherConfig::default()) + } + + /// Start a background watcher for active Boltz VHTLC contracts with custom configuration. + /// + /// Keep the returned handle alive for as long as the watcher should run. Dropping the handle + /// stops the watcher. + #[must_use = "dropping the handle stops the Boltz VHTLC watcher"] + pub fn start_boltz_vhtlc_watcher_with_config( + self: &Arc, + config: BoltzVhtlcWatcherConfig, + ) -> BoltzVhtlcWatcherHandle + where + B: Send + Sync + 'static, + W: Send + Sync + 'static, + { + let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); + let client = Arc::clone(self); + tokio::spawn(async move { + run_boltz_vhtlc_watcher_loop(client, stop_rx, config).await; + tracing::debug!("Boltz VHTLC watcher stopped"); + }); + BoltzVhtlcWatcherHandle { stop_tx } + } + + fn subscribe_to_swap_updates_for_type( + &self, + swap_id: String, + swap_type: SwapType, + ) -> impl futures::Stream> + '_ { + async_stream::stream! { + let mut last_status: Option = None; + let url = format!("{}/v2/swap/{swap_id}", self.inner.boltz_url); + + loop { + let client = reqwest::Client::new(); + let response = client + .get(&url) + .send() + .await; + + match response { + Ok(resp) if resp.status().is_success() => { + let status_response = resp + .json::() + .await + .map_err(|e| Error::ad_hoc(e.to_string())); + + match status_response { + Ok(current_status) => { + let current_status = current_status.status; + + let status_changed = last_status.as_ref() != Some(¤t_status); + let status_terminal = current_status.is_terminal(); + if status_changed || status_terminal { + if let Err(e) = self.persist_swap_status_for_type(swap_type, &swap_id, current_status.clone()).await { + yield Err(e); + break; + } + } + + // Only yield if status has changed. + if status_changed { + last_status = Some(current_status.clone()); + yield Ok(current_status); + } + + if status_terminal { + break; + } + } + Err(e) => { + yield Err(Error::ad_hoc(format!( + "failed to deserialize swap status response: {e}" + ))); + break; + } + } + } + Ok(resp) => { + let error_text = resp + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + yield Err(Error::ad_hoc(format!( + "failed to check swap status: {error_text}" + ))); + break; + } + Err(e) => { + yield Err(Error::ad_hoc(e.to_string()) + .context("failed to send swap status request")); + break; + } + } + + // Poll every second + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } + + // Pending VHTLC spend recovery. + + /// List pending (submitted but not finalized) VHTLC spend transactions. + /// + /// This checks swaps whose VHTLC contract is still active, queries the server for pending + /// VTXOs on their VHTLC addresses, and determines the spend type from the PSBT data. + pub async fn list_pending_vhtlc_spend_txs(&self) -> Result, Error> { + let vhtlc_infos = self.collect_active_vhtlc_infos().await?; + + if vhtlc_infos.is_empty() { + return Ok(vec![]); + } + + let addresses = vhtlc_infos.iter().map(|info| info.address); + let request = ark_core::server::GetVtxosRequest::new_for_addresses(addresses) + .pending_only() + .map_err(Error::from)?; + + let vtxos = self + .fetch_all_vtxos(request) + .await + .context("failed to fetch pending VHTLC VTXOs")?; + + tracing::debug!( + num_pending_vtxos = vtxos.len(), + "Fetched pending VHTLC VTXOs" + ); + + if vtxos.is_empty() { + return Ok(vec![]); + } + + // Map script_pubkey → VhtlcInfo for lookup. + let info_by_script: HashMap<_, _> = vhtlc_infos + .iter() + .map(|info| (info.script_pubkey.clone(), info)) + .collect(); + + let secp = Secp256k1::new(); + let mut results = Vec::new(); + let mut seen_ark_txids = HashSet::new(); + + for vtxo in &vtxos { + let info = match info_by_script.get(&vtxo.script) { + Some(info) => info, + None => { + tracing::warn!( + outpoint = %vtxo.outpoint, + "Skipping pending VHTLC VTXO with unknown script" + ); + continue; + } + }; + + // Build an intent to fetch the pending tx from the server. + // We prove ownership using the forfeit-like spend path that we can sign. + // If we have a preimage (reverse swap claim path), include it as extra + // witness so the server can verify the intent proof for the claim script. + let intent_input = match info.preimage { + Some(preimage) => intent::Input::new_with_extra_witness( + vtxo.outpoint, + bitcoin::Sequence::ZERO, + None, + TxOut { + value: vtxo.amount, + script_pubkey: info.script_pubkey.clone(), + }, + vhtlc_tapscripts(&info.vhtlc), + info.intent_spend_info.clone(), + false, + vtxo.is_swept, + vtxo.assets.clone(), + vec![preimage.to_vec()], + ), + None => intent::Input::new( + vtxo.outpoint, + bitcoin::Sequence::ZERO, + None, + TxOut { + value: vtxo.amount, + script_pubkey: info.script_pubkey.clone(), + }, + vhtlc_tapscripts(&info.vhtlc), + info.intent_spend_info.clone(), + false, + vtxo.is_swept, + vtxo.assets.clone(), + ), + }; + + let sign_for_vtxo_fn = |input: &mut psbt::Input, + msg: secp256k1::Message| + -> Result< + Vec<(schnorr::Signature, XOnlyPublicKey)>, + ark_core::Error, + > { + match &input.witness_script { + None => Err(ark_core::Error::ad_hoc( + "Missing witness script when signing get-pending-tx intent for VHTLC", + )), + Some(script) => { + let pks = extract_checksig_pubkeys(script); + let mut res = vec![]; + for pk in &pks { + if let Ok(keypair) = self.keypair_by_pk(pk) { + let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair); + res.push((sig, keypair.x_only_public_key().0)); + } + } + Ok(res) + } + } + }; + + let sign_for_onchain_fn = + |_: &mut psbt::Input, + _: secp256k1::Message| + -> Result<(schnorr::Signature, XOnlyPublicKey), ark_core::Error> { + Err(ark_core::Error::ad_hoc( + "unexpected onchain input in get-pending-tx intent", + )) + }; + + let message = intent::IntentMessage::GetPendingTx { expire_at: 0 }; + let get_pending_intent = intent::make_intent( + sign_for_vtxo_fn, + sign_for_onchain_fn, + vec![intent_input], + vec![], + message, + )?; + + let pending_txs = self + .network_client() + .get_pending_tx(get_pending_intent) + .await + .map_err(Error::ark_server) + .context("failed to get pending VHTLC transactions")?; + + for pending_tx in pending_txs { + if !seen_ark_txids.insert(pending_tx.ark_txid) { + continue; + } + + let spend_type = Self::identify_vhtlc_spend_type(info, &pending_tx)?; + + tracing::info!( + ark_txid = %pending_tx.ark_txid, + swap_id = spend_type.swap_id(), + spend_type = spend_type.name(), + "Found pending VHTLC spend transaction" + ); + + results.push(PendingVhtlcSpendTx { + spend_type, + pending_tx, + }); + } + } + + Ok(results) + } + + /// Continue (finalize) a pending VHTLC spend transaction. + /// + /// Handles the different spend types appropriately: + /// - **Claim**: signs the checkpoint with the claim key and injects the preimage. + /// - **CollaborativeRefund**: re-requests Boltz's signature, then signs with the refund key. + /// - **ExpiredRefund**: signs the checkpoint with the refund key (no Boltz needed). + pub async fn continue_pending_vhtlc_spend_tx( + &self, + pending: &PendingVhtlcSpendTx, + ) -> Result { + let ark_txid = pending.pending_tx.ark_txid; + + match &pending.spend_type { + PendingVhtlcSpendType::Claim { preimage, .. } => { + self.continue_pending_claim(ark_txid, &pending.pending_tx, *preimage) + .await + } + PendingVhtlcSpendType::CollaborativeRefund { swap_id } => { + self.continue_pending_collaborative_refund(ark_txid, &pending.pending_tx, swap_id) + .await + } + PendingVhtlcSpendType::ExpiredRefund { .. } => { + self.continue_pending_expired_refund(ark_txid, &pending.pending_tx) + .await + } + } + } + + /// Sign and finalize all pending VHTLC spend transactions. + /// + /// Discovery failures are returned as errors. Per-transaction finalization failures are + /// collected in the result so callers can observe partial recovery without preventing other + /// pending transactions from being finalized. + pub async fn continue_pending_vhtlc_spend_txs( + &self, + ) -> Result { + let pending = self.list_pending_vhtlc_spend_txs().await?; + + let mut finalized = Vec::new(); + let mut failed = Vec::new(); + for tx in &pending { + match self.continue_pending_vhtlc_spend_tx(tx).await { + Ok(txid) => finalized.push(txid), + Err(e) => { + tracing::warn!( + ark_txid = %tx.pending_tx.ark_txid, + swap_id = tx.spend_type.swap_id(), + ?e, + "Failed to finalize pending VHTLC spend tx" + ); + failed.push(PendingVhtlcSpendFailure { + ark_txid: tx.pending_tx.ark_txid, + swap_id: tx.spend_type.swap_id().to_string(), + spend_type: tx.spend_type.name(), + error: e.to_string(), + }); + } + } + } + + Ok(ContinuePendingVhtlcSpendTxsResult { finalized, failed }) + } + + /// Sign and finalize a pending claim VHTLC checkpoint. + async fn continue_pending_claim( + &self, + ark_txid: Txid, + pending_tx: &PendingTx, + preimage: [u8; 32], + ) -> Result { + let mut signed_checkpoint_txs = pending_tx.signed_checkpoint_txs.clone(); + + for checkpoint_psbt in signed_checkpoint_txs.iter_mut() { + Self::restore_witness_script_if_needed(checkpoint_psbt, &pending_tx.signed_ark_tx)?; + + // Inject preimage into checkpoint inputs before signing. + Self::inject_preimage_into_psbt(checkpoint_psbt, preimage); + + self.sign_checkpoint_with_own_keys(checkpoint_psbt)?; + } + + timeout_op( + self.inner.timeout, + self.network_client() + .finalize_offchain_transaction(ark_txid, signed_checkpoint_txs), + ) + .await? + .map_err(Error::ark_server) + .context("failed to finalize pending claim transaction")?; + + tracing::info!(txid = %ark_txid, "Finalized pending VHTLC claim"); + Ok(ark_txid) + } + + /// Re-request Boltz's signature and finalize a pending collaborative refund. + async fn continue_pending_collaborative_refund( + &self, + ark_txid: Txid, + pending_tx: &PendingTx, + swap_id: &str, + ) -> Result { + // For collaborative refunds, the server stripped Boltz's signatures when we + // submitted. We need to re-request them from Boltz. + // + // Re-send the ark tx and each checkpoint to Boltz's refund endpoint to get fresh + // signatures from them. + let mut swap_data = self + .swap_storage() + .get_submarine(swap_id) + .await? + .ok_or_else(|| Error::ad_hoc(format!("submarine swap not found: {swap_id}")))?; + let server_info = self.server_info().await?; + let vhtlc = self + .submarine_vhtlc_script(&mut swap_data, &server_info) + .await?; + let refund_script = vhtlc.refund_script(); + let boltz_refund_pk = swap_data.claim_public_key.inner.x_only_public_key().0; + let refund_leaf_hash = TapLeafHash::from_script(&refund_script, LeafVersion::TapScript); + + let url = format!( + "{}/v2/swap/submarine/{swap_id}/refund/ark", + self.inner.boltz_url + ); + let client = reqwest::Client::new(); + + let mut signed_checkpoint_txs = Vec::new(); + + for checkpoint_psbt in &pending_tx.signed_checkpoint_txs { + let response = client + .post(&url) + .json(&RefundSwapRequest { + transaction: pending_tx.signed_ark_tx.to_string(), + checkpoint: checkpoint_psbt.to_string(), + }) + .send() + .await + .map_err(Error::ad_hoc) + .context("failed to re-request Boltz refund signature")?; + + if !response.status().is_success() { + let error_text = response + .text() + .await + .map_err(|e| Error::ad_hoc(e.to_string())) + .context("failed to read Boltz error text")?; + + return Err(Error::ad_hoc(format!( + "Boltz refund re-sign request failed: {error_text}" + ))); + } + + let refund_response: RefundSwapResponse = response + .json() + .await + .map_err(Error::ad_hoc) + .context("failed to deserialize Boltz refund response")?; + + if let Some(err) = refund_response.error.as_deref() { + return Err(Error::ad_hoc(format!("Boltz refund re-sign failed: {err}"))); + } + + let boltz_signed_checkpoint = Psbt::from_str(&refund_response.checkpoint) + .map_err(Error::ad_hoc) + .context("could not parse Boltz-signed checkpoint PSBT")?; + + // Extract Boltz's tap_script_sigs. + let boltz_tap_script_sigs = validated_boltz_tap_script_sigs( + boltz_signed_checkpoint + .inputs + .first() + .ok_or_else(|| Error::ad_hoc("Boltz checkpoint has no inputs"))?, + boltz_refund_pk, + refund_leaf_hash, + )?; + + // Start from the server's checkpoint (which has the server's signature). + let mut final_checkpoint = checkpoint_psbt.clone(); + Self::restore_witness_script_if_needed( + &mut final_checkpoint, + &pending_tx.signed_ark_tx, + )?; + + // Merge Boltz's signatures. + final_checkpoint + .inputs + .first_mut() + .ok_or_else(|| Error::ad_hoc("checkpoint has no inputs"))? + .tap_script_sigs + .extend(boltz_tap_script_sigs); + + // Add our (sender) signature. + self.sign_checkpoint_with_own_keys(&mut final_checkpoint)?; + + signed_checkpoint_txs.push(final_checkpoint); + } + + timeout_op( + self.inner.timeout, + self.network_client() + .finalize_offchain_transaction(ark_txid, signed_checkpoint_txs), + ) + .await? + .map_err(Error::ark_server) + .context("failed to finalize pending collaborative refund")?; + + tracing::info!(txid = %ark_txid, swap_id, "Finalized pending collaborative refund"); + Ok(ark_txid) + } + + /// Sign and finalize a pending expired refund checkpoint. + async fn continue_pending_expired_refund( + &self, + ark_txid: Txid, + pending_tx: &PendingTx, + ) -> Result { + let mut signed_checkpoint_txs = pending_tx.signed_checkpoint_txs.clone(); + + for checkpoint_psbt in signed_checkpoint_txs.iter_mut() { + Self::restore_witness_script_if_needed(checkpoint_psbt, &pending_tx.signed_ark_tx)?; + self.sign_checkpoint_with_own_keys(checkpoint_psbt)?; + } + + timeout_op( + self.inner.timeout, + self.network_client() + .finalize_offchain_transaction(ark_txid, signed_checkpoint_txs), + ) + .await? + .map_err(Error::ark_server) + .context("failed to finalize pending expired refund")?; + + tracing::info!(txid = %ark_txid, "Finalized pending expired VHTLC refund"); + Ok(ark_txid) + } + + // Private helpers for pending VHTLC recovery. + + /// Try to reconstruct a [`VhtlcScript`] that matches `expected_address` by trying the current + /// server signer and all deprecated signers in order. Returns the first match. + /// + /// This handles the case where the server rotated its signing key after a swap was created: + /// the VHTLC was built with the old key, so we must try deprecated keys to find the right one. + fn reconstruct_vhtlc_for_address( + &self, + server_info: &Info, + mk_opts: impl Fn(XOnlyPublicKey) -> Result, + expected_address: &ArkAddress, + ) -> Result { + reconstruct_vhtlc_from_keys( + server_info.all_server_keys(), + server_info.network, + mk_opts, + expected_address, + ) + } + + /// Reconstruct a [`VhtlcScript`] from swap data fields, trying current + deprecated signers. + fn build_vhtlc_script( + &self, + server_info: &Info, + claim_public_key: PublicKey, + refund_public_key: PublicKey, + preimage_hash: ripemd160::Hash, + timeout_block_heights: &TimeoutBlockHeights, + expected_address: &ArkAddress, + ) -> Result { + let unilateral_claim_delay = + parse_sequence_number(timeout_block_heights.unilateral_claim as i64) + .map_err(|e| Error::ad_hoc(format!("invalid unilateral claim timeout: {e}")))?; + let unilateral_refund_delay = + parse_sequence_number(timeout_block_heights.unilateral_refund as i64) + .map_err(|e| Error::ad_hoc(format!("invalid unilateral refund timeout: {e}")))?; + let unilateral_refund_without_receiver_delay = + parse_sequence_number(timeout_block_heights.unilateral_refund_without_receiver as i64) + .map_err(|e| { + Error::ad_hoc(format!("invalid refund without receiver timeout: {e}")) + })?; + + self.reconstruct_vhtlc_for_address( + server_info, + |server| { + Ok(VhtlcOptions { + sender: refund_public_key.inner.x_only_public_key().0, + receiver: claim_public_key.inner.x_only_public_key().0, + server, + preimage_hash, + refund_locktime: timeout_block_heights.refund, + unilateral_claim_delay, + unilateral_refund_delay, + unilateral_refund_without_receiver_delay, + }) + }, + expected_address, + ) + } + + pub(crate) async fn migrate_boltz_vhtlc_contracts( + &self, + server_info: &Info, + ) -> Result { + let mut migrated = 0; + + for mut swap in self.swap_storage().list_all_submarine().await? { + if swap.contract_script_pubkey.is_some() { + continue; + } + match self.submarine_vhtlc_script(&mut swap, server_info).await { + Ok(_) => { + self.best_effort_reconcile_vhtlc_contract_state_from_vtxos( + &swap.id, + swap.vhtlc_address, + swap.contract_script_pubkey.clone(), + ) + .await; + migrated += 1; + } + Err(error) => { + tracing::warn!(swap_id = %swap.id, ?error, "Failed to migrate submarine VHTLC contract"); + } + } + } + + for mut swap in self.swap_storage().list_all_reverse().await? { + if swap.contract_script_pubkey.is_some() { + continue; + } + match self.reverse_vhtlc_script(&mut swap, server_info).await { + Ok(_) => { + self.best_effort_reconcile_vhtlc_contract_state_from_vtxos( + &swap.id, + swap.vhtlc_address, + swap.contract_script_pubkey.clone(), + ) + .await; + migrated += 1; + } + Err(error) => { + tracing::warn!(swap_id = %swap.id, ?error, "Failed to migrate reverse VHTLC contract"); + } + } + } + + for mut swap in self.swap_storage().list_all_chain().await? { + if swap.contract_script_pubkey.is_some() { + self.best_effort_cache_chain_swap_key(&swap); + continue; + } + match self.chain_vhtlc_script(&mut swap, server_info).await { + Ok(_) => { + self.best_effort_cache_chain_swap_key(&swap); + match swap.chain_vhtlc_address() { + Ok(vhtlc_address) => { + self.best_effort_reconcile_vhtlc_contract_state_from_vtxos( + &swap.id, + vhtlc_address, + swap.contract_script_pubkey.clone(), + ) + .await; + } + Err(error) => { + tracing::warn!( + swap_id = %swap.id, + ?error, + "Failed to resolve chain VHTLC address during contract-state reconciliation" + ); + } + } + migrated += 1; + } + Err(error) => { + tracing::warn!(swap_id = %swap.id, ?error, "Failed to migrate chain VHTLC contract"); + } + } + } + + Ok(migrated) + } + + fn best_effort_cache_chain_swap_key(&self, swap: &ChainSwapData) { + let (pk, key_derivation_index, key_role) = match swap.direction { + ChainSwapDirection::ArkToBtc => ( + swap.refund_public_key.inner.x_only_public_key().0, + swap.refund_key_derivation_index, + "refund", + ), + ChainSwapDirection::BtcToArk => ( + swap.claim_public_key.inner.x_only_public_key().0, + swap.claim_key_derivation_index, + "claim", + ), + }; + + if let Err(error) = self.swap_keypair_by_pk(&pk, key_derivation_index, &swap.id) { + tracing::warn!( + swap_id = %swap.id, + key_role, + ?error, + "Failed to cache chain swap VHTLC key" + ); + } + } + + async fn swap_type_for_id(&self, swap_id: &str) -> Result { + if self.swap_storage().get_submarine(swap_id).await?.is_some() { + Ok(SwapType::Submarine) + } else if self.swap_storage().get_reverse(swap_id).await?.is_some() { + Ok(SwapType::Reverse) + } else if self.swap_storage().get_chain(swap_id).await?.is_some() { + Ok(SwapType::Chain) + } else { + Ok(SwapType::Unknown) + } + } + + async fn persist_swap_status_for_type( + &self, + swap_type: SwapType, + swap_id: &str, + status: SwapStatus, + ) -> Result<(), Error> { + match swap_type { + SwapType::Submarine => { + if self.swap_storage().get_submarine(swap_id).await?.is_some() { + let should_reconcile = status.is_terminal(); + self.swap_storage() + .update_status_submarine(swap_id, status) + .await?; + if should_reconcile { + if let Some(swap) = self.swap_storage().get_submarine(swap_id).await? { + self.best_effort_reconcile_vhtlc_contract_state_from_vtxos( + swap_id, + swap.vhtlc_address, + swap.contract_script_pubkey, + ) + .await; + } + } + } + } + SwapType::Reverse => { + if self.swap_storage().get_reverse(swap_id).await?.is_some() { + let should_reconcile = status.is_terminal(); + self.swap_storage() + .update_status_reverse(swap_id, status) + .await?; + if should_reconcile { + if let Some(swap) = self.swap_storage().get_reverse(swap_id).await? { + self.best_effort_reconcile_vhtlc_contract_state_from_vtxos( + swap_id, + swap.vhtlc_address, + swap.contract_script_pubkey, + ) + .await; + } + } + } + } + SwapType::Chain => { + if self.swap_storage().get_chain(swap_id).await?.is_some() { + let should_reconcile = status.is_terminal(); + self.swap_storage() + .update_status_chain(swap_id, status) + .await?; + if should_reconcile { + if let Some(swap) = self.swap_storage().get_chain(swap_id).await? { + match swap.chain_vhtlc_address() { + Ok(vhtlc_address) => { + self.best_effort_reconcile_vhtlc_contract_state_from_vtxos( + swap_id, + vhtlc_address, + swap.contract_script_pubkey, + ) + .await; + } + Err(error) => { + tracing::warn!( + swap_id, + ?error, + "Failed to resolve chain VHTLC address during contract-state reconciliation" + ); + } + } + } + } + } + } + SwapType::Unknown => {} + } + + Ok(()) + } + + fn insert_vhtlc_contract( + &self, + options: VhtlcOptions, + key_derivation_index: Option, + ) -> Result { + let contract = VhtlcContract { options }; + let state = self + .state + .read() + .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?; + let mut manager = state + .contract_manager + .lock() + .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))?; + let stored = + manager.insert_or_get(contract, ContractState::Active, key_derivation_index)?; + Ok(stored.script_pubkey) + } + + async fn observe_vhtlc_contract_liveness( + &self, + server_info: &Info, + address: ArkAddress, + ) -> Result { + let pending_request = + ark_core::server::GetVtxosRequest::new_for_addresses(std::iter::once(address)) + .pending_only() + .map_err(Error::from)?; + let pending_vtxos = self + .fetch_all_vtxos(pending_request) + .await + .context("failed to fetch pending VHTLC VTXOs")?; + + let vtxos = self + .get_virtual_tx_outpoints(std::iter::once(address)) + .await + .context("failed to fetch VHTLC VTXOs")?; + + Ok(classify_vhtlc_contract_liveness( + server_info.dust, + !pending_vtxos.is_empty(), + vtxos, + )) + } + + async fn reconcile_vhtlc_contract_state_from_vtxos( + &self, + swap_id: &str, + address: ArkAddress, + script_pubkey: Option, + ) -> Result<(), Error> { + let Some(script_pubkey) = script_pubkey else { + return Ok(()); + }; + + let server_info = self.server_info().await?; + let liveness = self + .observe_vhtlc_contract_liveness(&server_info, address) + .await?; + + tracing::debug!( + swap_id, + %address, + ?liveness, + "Observed VHTLC contract liveness" + ); + + if liveness.should_deactivate_contract() { + self.mark_vhtlc_contract_inactive(Some(&script_pubkey))?; + tracing::info!( + swap_id, + %address, + "Marked spent VHTLC contract inactive" + ); + } + + Ok(()) + } + + async fn best_effort_reconcile_vhtlc_contract_state_from_vtxos( + &self, + swap_id: &str, + address: ArkAddress, + script_pubkey: Option, + ) { + match self.vhtlc_contract_is_inactive(script_pubkey.as_ref()) { + Ok(true) => return, + Ok(false) => {} + Err(error) => { + tracing::warn!( + swap_id, + ?error, + "Failed to check VHTLC contract state before reconciliation" + ); + } + } + + if let Err(error) = self + .reconcile_vhtlc_contract_state_from_vtxos(swap_id, address, script_pubkey) + .await + { + tracing::warn!( + swap_id, + ?error, + "Failed to reconcile VHTLC contract state from VTXO status" + ); + } + } + + fn deactivate_orphaned_vhtlc_contract( + &self, + script_pubkey: &ScriptBuf, + swap_id: &str, + error: Error, + ) -> Error { + if let Err(rollback_error) = self.mark_vhtlc_contract_inactive(Some(script_pubkey)) { + tracing::warn!( + swap_id, + ?rollback_error, + "Failed to deactivate orphaned VHTLC contract after swap persistence failure" + ); + } + error + } + + fn mark_vhtlc_contract_inactive(&self, script_pubkey: Option<&ScriptBuf>) -> Result<(), Error> { + let Some(script_pubkey) = script_pubkey else { + return Ok(()); + }; + let state = self + .state + .read() + .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?; + let result = state + .contract_manager + .lock() + .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))? + .update_state(script_pubkey, ContractState::Inactive); + result + } + + fn vhtlc_contract_is_inactive(&self, script_pubkey: Option<&ScriptBuf>) -> Result { + let Some(script_pubkey) = script_pubkey else { + return Ok(false); + }; + let state = self + .state + .read() + .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?; + let inactive = state + .contract_manager + .lock() + .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))? + .get(script_pubkey)? + .is_some_and(|contract| contract.state == ContractState::Inactive); + Ok(inactive) + } + + fn best_effort_mark_vhtlc_contract_inactive( + &self, + swap_id: &str, + script_pubkey: Option<&ScriptBuf>, + action: &'static str, + ) { + if let Err(error) = self.mark_vhtlc_contract_inactive(script_pubkey) { + tracing::warn!( + swap_id, + action, + ?error, + "Failed to deactivate finalized VHTLC contract" + ); + } + } + + fn get_vhtlc_contract( + &self, + script_pubkey: &ScriptBuf, + ) -> Result, Error> { + let state = self + .state + .read() + .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?; + let contract = state + .contract_manager + .lock() + .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))? + .get_typed(script_pubkey)?; + Ok(contract) + } + + async fn submarine_vhtlc_script( + &self, + swap: &mut SubmarineSwapData, + server_info: &Info, + ) -> Result { + if let Some(script_pubkey) = &swap.contract_script_pubkey { + if let Some(contract) = self.get_vhtlc_contract(script_pubkey)? { + let vhtlc = vhtlc_script_from_contract(contract, &swap.vhtlc_address, server_info)?; + + return Ok(vhtlc); + } + + tracing::warn!( + swap_id = %swap.id, + "VHTLC contract reference missing; recreating from legacy swap data" + ); + } + + let vhtlc = self.build_vhtlc_script( + server_info, + swap.claim_public_key, + swap.refund_public_key, + swap.preimage_hash, + &swap.timeout_block_heights, + &swap.vhtlc_address, + )?; + + let script_pubkey = self + .insert_vhtlc_contract(vhtlc.options().clone(), swap.key_derivation_index) + .context("failed to persist VHTLC contract")?; + + swap.contract_script_pubkey = Some(script_pubkey); + self.swap_storage() + .update_submarine(&swap.id, swap.clone()) + .await + .context("failed to persist VHTLC contract reference")?; + + Ok(vhtlc) + } + + fn build_chain_vhtlc_script( + &self, + swap: &ChainSwapData, + server_info: &Info, + ) -> Result { + let fields = swap.chain_vhtlc_fields()?; + self.build_vhtlc_script( + server_info, + fields.claim_public_key, + fields.refund_public_key, + ripemd160::Hash::hash(swap.preimage_hash.as_byte_array()), + &fields.timeouts, + &fields.address, + ) + } + + async fn chain_vhtlc_script( + &self, + swap: &mut ChainSwapData, + server_info: &Info, + ) -> Result { + if let Some(script_pubkey) = &swap.contract_script_pubkey { + if let Some(contract) = self.get_vhtlc_contract(script_pubkey)? { + let expected_address = swap.chain_vhtlc_address()?; + let vhtlc = vhtlc_script_from_contract(contract, &expected_address, server_info)?; + + return Ok(vhtlc); + } + + tracing::warn!( + swap_id = %swap.id, + "Chain VHTLC contract reference missing; recreating from swap data" + ); + } + + let vhtlc = self.build_chain_vhtlc_script(swap, server_info)?; + + let script_pubkey = self + .insert_vhtlc_contract(vhtlc.options().clone(), swap.chain_vhtlc_key_index()) + .context("failed to persist chain VHTLC contract")?; + + swap.contract_script_pubkey = Some(script_pubkey); + self.swap_storage() + .update_chain(&swap.id, swap.clone()) + .await + .context("failed to persist chain VHTLC contract reference")?; + + Ok(vhtlc) + } + + async fn reverse_vhtlc_script( + &self, + swap: &mut ReverseSwapData, + server_info: &Info, + ) -> Result { + if let Some(script_pubkey) = &swap.contract_script_pubkey { + if let Some(contract) = self.get_vhtlc_contract(script_pubkey)? { + return vhtlc_script_from_contract(contract, &swap.vhtlc_address, server_info); + } + tracing::warn!( + swap_id = %swap.id, + "VHTLC contract reference missing; recreating from legacy swap data" + ); + } + + let vhtlc = self.build_vhtlc_script( + server_info, + swap.claim_public_key, + swap.refund_public_key, + swap.preimage_hash, + &swap.timeout_block_heights, + &swap.vhtlc_address, + )?; + + let script_pubkey = self + .insert_vhtlc_contract(vhtlc.options().clone(), swap.key_derivation_index) + .context("failed to persist VHTLC contract")?; + + swap.contract_script_pubkey = Some(script_pubkey); + self.swap_storage() + .update_reverse(&swap.id, swap.clone()) + .await + .context("failed to persist VHTLC contract reference")?; + Ok(vhtlc) + } + + /// Return a swap keypair, deriving it from the persisted swap index if needed. + fn swap_keypair_by_pk( + &self, + pk: &XOnlyPublicKey, + key_derivation_index: Option, + swap_id: &str, + ) -> Result { + if let Ok(kp) = self.keypair_by_pk(pk) { + return Ok(kp); + } + + let index = key_derivation_index.ok_or_else(|| { + Error::ad_hoc(format!( + "legacy swap {swap_id} is missing key derivation index for pubkey {pk}" + )) + })?; + + let key_provider = self.inner.discoverable_key_provider.as_ref().ok_or_else(|| { + Error::ad_hoc(format!( + "swap key {pk} for swap {swap_id} is not cached and no discoverable key provider is configured" + )) + })?; + + let kp = key_provider + .derive_at_discovery_index(index) + .with_context(|| format!("failed to derive swap key at index {index}"))? + .ok_or_else(|| Error::ad_hoc(format!("no swap key at derivation index {index}")))?; + + if kp.x_only_public_key().0 != *pk { + return Err(Error::ad_hoc(format!( + "key at derivation index {index} does not match swap pubkey {pk}" + ))); + } + + key_provider + .cache_discovered_keypair(index, kp) + .with_context(|| format!("failed to cache swap key at index {index}"))?; + + Ok(kp) + } + + async fn collect_active_vhtlc_infos(&self) -> Result, Error> { + let submarine_swaps = self + .swap_storage() + .list_all_submarine() + .await + .context("failed to list submarine swaps")?; + + let reverse_swaps = self + .swap_storage() + .list_all_reverse() + .await + .context("failed to list reverse swaps")?; + + let server_info = self.server_info().await?; + let mut infos = Vec::new(); + + for mut swap in submarine_swaps { + if self.vhtlc_contract_is_inactive(swap.contract_script_pubkey.as_ref())? { + continue; + } + + // Ensure the refund key (sender) is available for pending intent signing. + if let Err(error) = self.swap_keypair_by_pk( + &swap.refund_public_key.inner.x_only_public_key().0, + swap.key_derivation_index, + &swap.id, + ) { + tracing::warn!( + swap_id = %swap.id, + ?error, + "Skipping submarine swap with unavailable refund key" + ); + continue; + } + + let vhtlc = self.submarine_vhtlc_script(&mut swap, &server_info).await?; + + // For submarine swaps, the user is the sender (refund key). + // Use refund_without_receiver_script as the intent proof — it only requires + // sender + server, and we can always sign for sender. + let refund_script = vhtlc.refund_without_receiver_script(); + let spend_info = vhtlc.taproot_spend_info(); + let control_block = spend_info + .control_block(&(refund_script.clone(), LeafVersion::TapScript)) + .ok_or_else(|| { + Error::ad_hoc("control block not found for refund_without_receiver script") + })?; + + infos.push(VhtlcInfo { + swap_id: swap.id.clone(), + address: swap.vhtlc_address, + script_pubkey: vhtlc.script_pubkey(), + vhtlc, + intent_spend_info: (refund_script, control_block), + preimage: swap.preimage, + }); + } + + for mut swap in reverse_swaps { + if self.vhtlc_contract_is_inactive(swap.contract_script_pubkey.as_ref())? { + continue; + } + + // Ensure the claim key (receiver) is available for pending intent signing. + if let Err(error) = self.swap_keypair_by_pk( + &swap.claim_public_key.inner.x_only_public_key().0, + swap.key_derivation_index, + &swap.id, + ) { + tracing::warn!( + swap_id = %swap.id, + ?error, + "Skipping reverse swap with unavailable claim key" + ); + continue; + } + + let vhtlc = self.reverse_vhtlc_script(&mut swap, &server_info).await?; + + // For reverse swaps, the user is the receiver (claim key). + // Use claim_script as the intent proof — we need to sign with the receiver key. + let claim_script = vhtlc.claim_script(); + let spend_info = vhtlc.taproot_spend_info(); + let control_block = spend_info + .control_block(&(claim_script.clone(), LeafVersion::TapScript)) + .ok_or_else(|| Error::ad_hoc("control block not found for claim script"))?; + + infos.push(VhtlcInfo { + swap_id: swap.id.clone(), + address: swap.vhtlc_address, + script_pubkey: vhtlc.script_pubkey(), + vhtlc, + intent_spend_info: (claim_script, control_block), + preimage: swap.preimage, + }); + } + + Ok(infos) + } + + /// Determine the spend type by comparing the PSBT's spend script against known VHTLC scripts. + fn identify_vhtlc_spend_type( + info: &VhtlcInfo, + pending_tx: &PendingTx, + ) -> Result { + // Extract the spend script from the ark tx's PSBT input tap_scripts. + let spend_script = pending_tx + .signed_ark_tx + .inputs + .iter() + .find_map(|input| { + input.tap_scripts.values().find_map(|(script, _)| { + // Match against this VHTLC's known scripts. + let claim = info.vhtlc.claim_script(); + let refund = info.vhtlc.refund_script(); + let refund_no_recv = info.vhtlc.refund_without_receiver_script(); + + if *script == claim || *script == refund || *script == refund_no_recv { + Some(script.clone()) + } else { + None + } + }) + }) + .ok_or_else(|| { + Error::ad_hoc(format!( + "could not identify spend script in pending tx {} for swap {}", + pending_tx.ark_txid, info.swap_id + )) + })?; + + let claim_script = info.vhtlc.claim_script(); + let refund_script = info.vhtlc.refund_script(); + + if spend_script == claim_script { + // Claim — we need the preimage. Try to extract it from the ark tx PSBT + // (it was injected as extra witness data when the tx was originally signed), + // falling back to what's stored in swap data. + let preimage = extract_preimage_from_psbt(&pending_tx.signed_ark_tx) + .ok() + .or(info.preimage) + .ok_or_else(|| { + Error::ad_hoc(format!( + "cannot recover preimage for pending claim of swap {}", + info.swap_id + )) + })?; + + Ok(PendingVhtlcSpendType::Claim { + swap_id: info.swap_id.clone(), + preimage, + }) + } else if spend_script == refund_script { + Ok(PendingVhtlcSpendType::CollaborativeRefund { + swap_id: info.swap_id.clone(), + }) + } else { + Ok(PendingVhtlcSpendType::ExpiredRefund { + swap_id: info.swap_id.clone(), + }) + } + } + + /// Inject a preimage into all inputs of a PSBT via the `VTXO_CONDITION_KEY` unknown field. + fn inject_preimage_into_psbt(psbt: &mut Psbt, preimage: [u8; 32]) { + let mut bytes = vec![1]; + let length = VarInt::from(preimage.len() as u64); + length + .consensus_encode(&mut bytes) + .expect("valid length encoding"); + bytes.write_all(&preimage).expect("valid preimage encoding"); + + let key = psbt::raw::Key { + type_value: 222, + key: VTXO_CONDITION_KEY.to_vec(), + }; + + for input in &mut psbt.inputs { + input.unknown.insert(key.clone(), bytes.clone()); + } + } + + /// Sign a checkpoint PSBT by matching pubkeys in the witness script against our keys. + fn sign_checkpoint_with_own_keys(&self, checkpoint_psbt: &mut Psbt) -> Result<(), Error> { + let sign_fn = + |input: &mut psbt::Input, + msg: secp256k1::Message| + -> Result, ark_core::Error> { + let script = input.witness_script.as_ref().ok_or_else(|| { + ark_core::Error::ad_hoc("missing witness script for checkpoint signing") + })?; + let pks = extract_checksig_pubkeys(script); + let mut res = vec![]; + for pk in pks { + if let Ok(keypair) = self.keypair_by_pk(&pk) { + let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &keypair); + res.push((sig, keypair.x_only_public_key().0)); + } + } + Ok(res) + }; + + sign_checkpoint_transaction(sign_fn, checkpoint_psbt)?; + Ok(()) + } + + /// Restore the witness_script on a checkpoint PSBT if the server stripped it. + /// + /// This is the same logic used by [`Client::continue_pending_offchain_txs`]. + fn restore_witness_script_if_needed( + checkpoint_psbt: &mut Psbt, + signed_ark_tx: &Psbt, + ) -> Result<(), Error> { + if checkpoint_psbt + .inputs + .first() + .ok_or_else(|| Error::ad_hoc("checkpoint PSBT has no inputs"))? + .witness_script + .is_some() + { + return Ok(()); + } + + let checkpoint_txid = checkpoint_psbt.unsigned_tx.compute_txid(); + + let ark_input_idx = signed_ark_tx + .unsigned_tx + .input + .iter() + .position(|inp| inp.previous_output.txid == checkpoint_txid) + .ok_or_else(|| { + Error::ad_hoc(format!( + "checkpoint txid {checkpoint_txid} not found in ark tx inputs" + )) + })?; + + let witness_script = signed_ark_tx + .inputs + .get(ark_input_idx) + .and_then(|input| input.witness_script.clone()) + .ok_or_else(|| { + Error::ad_hoc(format!( + "missing witness script on ark tx input {ark_input_idx}" + )) + })?; + + checkpoint_psbt + .inputs + .first_mut() + .ok_or_else(|| Error::ad_hoc("checkpoint PSBT has no inputs"))? + .witness_script = Some(witness_script); + Ok(()) + } +} + +async fn run_boltz_vhtlc_watcher_loop( + client: Arc>, + mut stop_rx: tokio::sync::watch::Receiver, + config: BoltzVhtlcWatcherConfig, +) where + B: Blockchain + Send + Sync + 'static, + W: OnchainWallet + Send + Sync + 'static, + S: SwapStorage + 'static, +{ + let refresh_interval = if config.refresh_interval.is_zero() { + VHTLC_WATCHER_REFRESH_INTERVAL + } else { + config.refresh_interval + }; + let mut backoff = VHTLC_WATCHER_INITIAL_BACKOFF; + let mut action_log = BoltzVhtlcActionLog::default(); + + loop { + if *stop_rx.borrow() { + return; + } + + drive_boltz_vhtlc_swaps(client.as_ref(), &mut action_log).await; + + let addresses = match active_vhtlc_addresses(client.as_ref()).await { + Ok(addresses) => addresses, + Err(error) => { + tracing::warn!(?error, "Failed to collect active VHTLC addresses"); + if wait_for_vhtlc_watcher_retry(&mut stop_rx, backoff).await { + return; + } + backoff = (backoff * 2).min(VHTLC_WATCHER_MAX_BACKOFF); + continue; + } + }; + + if addresses.is_empty() { + if wait_for_vhtlc_watcher_retry(&mut stop_rx, refresh_interval).await { + return; + } + backoff = VHTLC_WATCHER_INITIAL_BACKOFF; + continue; + } + + let subscription_id = match client.subscribe_to_scripts(addresses.clone(), None).await { + Ok(subscription_id) => subscription_id, + Err(error) => { + tracing::warn!(?error, "Failed to subscribe to VHTLC scripts"); + if wait_for_vhtlc_watcher_retry(&mut stop_rx, backoff).await { + return; + } + backoff = (backoff * 2).min(VHTLC_WATCHER_MAX_BACKOFF); + continue; + } + }; + + let mut stream = match client.get_subscription(subscription_id.clone(), None).await { + Ok(stream) => stream, + Err(error) => { + tracing::warn!(?error, "Failed to open VHTLC subscription stream"); + if wait_for_vhtlc_watcher_retry(&mut stop_rx, backoff).await { + return; + } + backoff = (backoff * 2).min(VHTLC_WATCHER_MAX_BACKOFF); + continue; + } + }; + + tracing::info!( + watched_scripts = addresses.len(), + "Boltz VHTLC watcher connected" + ); + backoff = VHTLC_WATCHER_INITIAL_BACKOFF; + let mut subscribed_addrs: HashSet = addresses.into_iter().collect(); + let mut refresh_interval = tokio::time::interval(refresh_interval); + + loop { + use futures::StreamExt; + + tokio::select! { + _ = stop_rx.changed() => return, + _ = refresh_interval.tick() => { + if let Err(error) = refresh_boltz_vhtlc_subscription( + client.as_ref(), + &subscription_id, + &mut subscribed_addrs, + ).await { + tracing::warn!(?error, "Failed to refresh VHTLC watcher subscription"); + } + drive_boltz_vhtlc_swaps(client.as_ref(), &mut action_log).await; + } + event = stream.next() => { + match event { + Some(Ok(SubscriptionResponse::Heartbeat)) => {} + Some(Ok(SubscriptionResponse::SubscriptionStarted { subscription_id })) => { + tracing::debug!(subscription_id, "VHTLC subscription stream started"); + } + Some(Ok(SubscriptionResponse::Event(event))) => { + if let Err(error) = handle_boltz_vhtlc_subscription_event( + client.as_ref(), + &event, + &mut action_log, + ).await { + tracing::warn!(?error, "Failed to handle VHTLC subscription event"); + } + } + Some(Err(error)) => { + tracing::warn!(?error, "VHTLC subscription stream error"); + break; + } + None => { + tracing::debug!("VHTLC subscription stream ended"); + break; + } + } + } + } + } + + if wait_for_vhtlc_watcher_retry(&mut stop_rx, backoff).await { + return; + } + backoff = (backoff * 2).min(VHTLC_WATCHER_MAX_BACKOFF); + } +} + +async fn drive_boltz_vhtlc_swaps( + client: &Client, + action_log: &mut BoltzVhtlcActionLog, +) where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + if let Err(error) = drive_claimable_vhtlc_swaps(client, action_log).await { + tracing::warn!(?error, "Failed to drive claimable VHTLC swaps"); + } + if let Err(error) = drive_refundable_vhtlc_swaps(client, action_log).await { + tracing::warn!(?error, "Failed to drive refundable VHTLC swaps"); + } +} + +async fn active_vhtlc_addresses(client: &Client) -> Result, Error> +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + let infos = collect_active_vhtlc_lifecycle_infos(client).await?; + Ok(infos.into_iter().map(|info| info.address).collect()) +} + +async fn refresh_boltz_vhtlc_subscription( + client: &Client, + subscription_id: &str, + subscribed_addrs: &mut HashSet, +) -> Result<(), Error> +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + let new_addrs: Vec<_> = active_vhtlc_addresses(client) + .await? + .into_iter() + .filter(|address| !subscribed_addrs.contains(address)) + .collect(); + + if new_addrs.is_empty() { + return Ok(()); + } + + client + .subscribe_to_scripts(new_addrs.clone(), Some(subscription_id.to_string())) + .await?; + + let added = new_addrs.len(); + subscribed_addrs.extend(new_addrs); + tracing::info!(added, "Updated VHTLC watcher subscription"); + Ok(()) +} + +async fn handle_boltz_vhtlc_subscription_event( + client: &Client, + event: &SubscriptionEvent, + action_log: &mut BoltzVhtlcActionLog, +) -> Result<(), Error> +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + if event.new_vtxos.is_empty() && event.spent_vtxos.is_empty() { + return Ok(()); + } + + tracing::debug!( + txid = %event.txid, + new_vtxos = event.new_vtxos.len(), + spent_vtxos = event.spent_vtxos.len(), + "Received VHTLC subscription event" + ); + + let infos = collect_active_vhtlc_lifecycle_infos(client).await?; + let info_by_script: HashMap<_, _> = infos + .into_iter() + .map(|info| (info.script_pubkey.clone(), info)) + .collect(); + + for new_vtxo in &event.new_vtxos { + let Some(info) = info_by_script.get(&new_vtxo.script) else { + continue; + }; + drive_funded_vhtlc_swap(client, info, action_log).await; + } + + for spent_vtxo in &event.spent_vtxos { + let Some(info) = info_by_script.get(&spent_vtxo.script) else { + continue; + }; + if drive_spent_vhtlc_swap(client, info).await == SpentVhtlcAction::Reconcile { + client + .reconcile_vhtlc_contract_state_from_vtxos( + &info.swap_id, + info.address, + Some(info.script_pubkey.clone()), + ) + .await?; + } + } + + Ok(()) +} + +async fn collect_active_vhtlc_lifecycle_infos( + client: &Client, +) -> Result, Error> +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + let submarine_swaps = client + .swap_storage() + .list_all_submarine() + .await + .context("failed to list submarine swaps")?; + let reverse_swaps = client + .swap_storage() + .list_all_reverse() + .await + .context("failed to list reverse swaps")?; + let chain_swaps = client + .swap_storage() + .list_all_chain() + .await + .context("failed to list chain swaps")?; + let server_info = client.server_info().await?; + let mut infos = Vec::new(); + + for mut swap in submarine_swaps { + match client.vhtlc_contract_is_inactive(swap.contract_script_pubkey.as_ref()) { + Ok(true) => continue, + Ok(false) => {} + Err(error) => { + tracing::warn!( + swap_id = %swap.id, + ?error, + "Skipping submarine swap after VHTLC contract state check failed" + ); + continue; + } + } + + let vhtlc = match client.submarine_vhtlc_script(&mut swap, &server_info).await { + Ok(vhtlc) => vhtlc, + Err(error) => { + tracing::warn!( + swap_id = %swap.id, + ?error, + "Skipping submarine swap after VHTLC reconstruction failed" + ); + continue; + } + }; + + infos.push(VhtlcLifecycleInfo { + swap_id: swap.id.clone(), + swap_type: SwapType::Submarine, + address: swap.vhtlc_address, + script_pubkey: vhtlc.script_pubkey(), + }); + } + + for mut swap in reverse_swaps { + match client.vhtlc_contract_is_inactive(swap.contract_script_pubkey.as_ref()) { + Ok(true) => continue, + Ok(false) => {} + Err(error) => { + tracing::warn!( + swap_id = %swap.id, + ?error, + "Skipping reverse swap after VHTLC contract state check failed" + ); + continue; + } + } + + let vhtlc = match client.reverse_vhtlc_script(&mut swap, &server_info).await { + Ok(vhtlc) => vhtlc, + Err(error) => { + tracing::warn!( + swap_id = %swap.id, + ?error, + "Skipping reverse swap after VHTLC reconstruction failed" + ); + continue; + } + }; + + infos.push(VhtlcLifecycleInfo { + swap_id: swap.id.clone(), + swap_type: SwapType::Reverse, + address: swap.vhtlc_address, + script_pubkey: vhtlc.script_pubkey(), + }); + } + + for mut swap in chain_swaps { + match client.vhtlc_contract_is_inactive(swap.contract_script_pubkey.as_ref()) { + Ok(true) => continue, + Ok(false) => {} + Err(error) => { + tracing::warn!( + swap_id = %swap.id, + ?error, + "Skipping chain swap after VHTLC contract state check failed" + ); + continue; + } + } + + let address = match swap.chain_vhtlc_address() { + Ok(address) => address, + Err(error) => { + tracing::warn!( + swap_id = %swap.id, + ?error, + "Skipping chain swap with unresolved VHTLC address" + ); + continue; + } + }; + + let vhtlc = match client.chain_vhtlc_script(&mut swap, &server_info).await { + Ok(vhtlc) => vhtlc, + Err(error) => { + tracing::warn!( + swap_id = %swap.id, + ?error, + "Skipping chain swap after VHTLC reconstruction failed" + ); + continue; + } + }; + + infos.push(VhtlcLifecycleInfo { + swap_id: swap.id.clone(), + swap_type: SwapType::Chain, + address, + script_pubkey: vhtlc.script_pubkey(), + }); + } + + Ok(infos) +} + +async fn drive_claimable_vhtlc_swaps( + client: &Client, + action_log: &mut BoltzVhtlcActionLog, +) -> Result<(), Error> +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + let infos = collect_active_vhtlc_lifecycle_infos(client).await?; + let server_info = client.server_info().await?; + + for info in infos { + if !matches!(info.swap_type, SwapType::Reverse | SwapType::Chain) { + continue; + } + + let liveness = match client + .observe_vhtlc_contract_liveness(&server_info, info.address) + .await + { + Ok(liveness) => liveness, + Err(error) => { + tracing::warn!( + swap_id = %info.swap_id, + ?error, + "Skipping claim retry after VHTLC liveness check failed" + ); + continue; + } + }; + + if matches!( + liveness, + VhtlcContractLiveness::Funded | VhtlcContractLiveness::Recoverable + ) { + drive_funded_vhtlc_swap(client, &info, action_log).await; + } + } + + Ok(()) +} + +async fn drive_funded_vhtlc_swap( + client: &Client, + info: &VhtlcLifecycleInfo, + action_log: &mut BoltzVhtlcActionLog, +) where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + match info.swap_type { + SwapType::Reverse => drive_reverse_vhtlc_claim(client, &info.swap_id, action_log).await, + SwapType::Chain => drive_chain_vhtlc_claim(client, &info.swap_id, action_log).await, + SwapType::Submarine | SwapType::Unknown => {} + } +} + +async fn drive_reverse_vhtlc_claim( + client: &Client, + swap_id: &str, + action_log: &mut BoltzVhtlcActionLog, +) where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + let swap = match client.swap_storage().get_reverse(swap_id).await { + Ok(Some(swap)) => swap, + Ok(None) => return, + Err(error) => { + tracing::warn!( + swap_id, + ?error, + "Failed to load reverse swap for VHTLC claim" + ); + return; + } + }; + + let Some(preimage) = swap.preimage else { + tracing::debug!( + swap_id, + "Reverse VHTLC funded, but preimage is externally managed; skipping auto-claim" + ); + return; + }; + + if !action_log.begin_claim(swap_id) { + return; + } + + match client.claim_vhtlc(swap_id, preimage).await { + Ok(result) => { + action_log.finish_claim(swap_id, true); + if let Err(error) = + update_reverse_swap_status(client, swap_id, SwapStatus::TransactionClaimed).await + { + tracing::warn!(swap_id, ?error, "Failed to persist reverse claim status"); + } + tracing::info!( + swap_id, + txid = %result.claim_txid, + "Auto-claimed reverse VHTLC from funding event" + ); + } + Err(error) => { + action_log.finish_claim(swap_id, false); + tracing::warn!(swap_id, ?error, "Failed to auto-claim reverse VHTLC"); + } + } +} + +async fn drive_chain_vhtlc_claim( + client: &Client, + swap_id: &str, + action_log: &mut BoltzVhtlcActionLog, +) where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + let swap = match client.swap_storage().get_chain(swap_id).await { + Ok(Some(swap)) => swap, + Ok(None) => return, + Err(error) => { + tracing::warn!(swap_id, ?error, "Failed to load chain swap for VHTLC claim"); + return; + } + }; + + if swap.direction != ChainSwapDirection::BtcToArk { + return; + } + if swap.preimage.is_none() { + tracing::debug!( + swap_id, + "Chain VHTLC funded, but preimage is missing; skipping auto-claim" + ); + return; + } + if !action_log.begin_claim(swap_id) { + return; + } + + match client.claim_chain_swap(swap_id).await { + Ok(txid) => { + action_log.finish_claim(swap_id, true); + tracing::info!(swap_id, %txid, "Auto-claimed chain VHTLC from funding event"); + } + Err(error) => { + action_log.finish_claim(swap_id, false); + tracing::warn!(swap_id, ?error, "Failed to auto-claim chain VHTLC"); + } + } +} + +async fn drive_spent_vhtlc_swap( + client: &Client, + info: &VhtlcLifecycleInfo, +) -> SpentVhtlcAction +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + if info.swap_type != SwapType::Submarine { + return SpentVhtlcAction::Reconcile; + } + + match client.swap_storage().get_submarine(&info.swap_id).await { + Ok(Some(swap)) if swap.preimage.is_some() => return SpentVhtlcAction::Reconcile, + Ok(Some(_)) => {} + Ok(None) => return SpentVhtlcAction::Reconcile, + Err(error) => { + tracing::warn!(swap_id = %info.swap_id, ?error, "Failed to load submarine swap"); + return SpentVhtlcAction::KeepActive; + } + } + + match client.extract_submarine_swap_preimage(&info.swap_id).await { + Ok(_) => { + tracing::info!( + swap_id = %info.swap_id, + "Extracted submarine preimage from spent VHTLC event" + ); + SpentVhtlcAction::Reconcile + } + Err(error) => { + tracing::warn!( + swap_id = %info.swap_id, + ?error, + "Could not extract submarine preimage from spent VHTLC; keeping contract active" + ); + SpentVhtlcAction::KeepActive + } + } +} + +async fn drive_refundable_vhtlc_swaps( + client: &Client, + action_log: &mut BoltzVhtlcActionLog, +) -> Result<(), Error> +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + let server_info = client.server_info().await?; + + for swap in client.swap_storage().list_all_submarine().await? { + if !is_submarine_refundable_status(&swap.status) { + continue; + } + + match client.vhtlc_contract_is_inactive(swap.contract_script_pubkey.as_ref()) { + Ok(true) => continue, + Ok(false) => {} + Err(error) => { + tracing::warn!( + swap_id = %swap.id, + ?error, + "Skipping submarine refund after contract-state check failed" + ); + continue; + } + } + + let liveness = match client + .observe_vhtlc_contract_liveness(&server_info, swap.vhtlc_address) + .await + { + Ok(liveness) => liveness, + Err(error) => { + tracing::warn!( + swap_id = %swap.id, + ?error, + "Skipping submarine refund after VHTLC liveness check failed" + ); + continue; + } + }; + if !is_refundable_vhtlc_liveness(liveness) { + tracing::debug!( + swap_id = %swap.id, + ?liveness, + "Skipping auto-refund for non-refundable VHTLC liveness" + ); + continue; + } + + if !action_log.begin_refund(&swap.id) { + continue; + } + + let result = if matches!(swap.status, SwapStatus::SwapExpired) { + client.refund_expired_vhtlc(&swap.id).await + } else { + client.refund_vhtlc(&swap.id).await + }; + + match result { + Ok(txid) => { + action_log.finish_refund(&swap.id, true); + if let Err(error) = + update_submarine_swap_status(client, &swap.id, SwapStatus::TransactionRefunded) + .await + { + tracing::warn!( + swap_id = %swap.id, + ?error, + "Failed to persist submarine refund status" + ); + } + tracing::info!(swap_id = %swap.id, %txid, "Auto-refunded submarine VHTLC"); + } + Err(error) => { + action_log.finish_refund(&swap.id, false); + tracing::warn!(swap_id = %swap.id, ?error, "Failed to auto-refund submarine VHTLC"); + } + } + } + + for swap in client.swap_storage().list_all_chain().await? { + if swap.direction != ChainSwapDirection::ArkToBtc + || !is_chain_refundable_status(&swap.status) + { + continue; + } + + match client.vhtlc_contract_is_inactive(swap.contract_script_pubkey.as_ref()) { + Ok(true) => continue, + Ok(false) => {} + Err(error) => { + tracing::warn!( + swap_id = %swap.id, + ?error, + "Skipping chain refund after contract-state check failed" + ); + continue; + } + } + + let address = match swap.chain_vhtlc_address() { + Ok(address) => address, + Err(error) => { + tracing::warn!( + swap_id = %swap.id, + ?error, + "Skipping chain refund with unresolved VHTLC address" + ); + continue; + } + }; + let liveness = match client + .observe_vhtlc_contract_liveness(&server_info, address) + .await + { + Ok(liveness) => liveness, + Err(error) => { + tracing::warn!( + swap_id = %swap.id, + ?error, + "Skipping chain refund after VHTLC liveness check failed" + ); + continue; + } + }; + if !is_refundable_vhtlc_liveness(liveness) { + tracing::debug!( + swap_id = %swap.id, + ?liveness, + "Skipping auto-refund for non-refundable chain VHTLC liveness" + ); + continue; + } + + if !action_log.begin_refund(&swap.id) { + continue; + } + + match client.refund_chain_swap(&swap.id).await { + Ok(txid) => { + action_log.finish_refund(&swap.id, true); + tracing::info!(swap_id = %swap.id, %txid, "Auto-refunded chain VHTLC"); + } + Err(error) => { + action_log.finish_refund(&swap.id, false); + tracing::warn!(swap_id = %swap.id, ?error, "Failed to auto-refund chain VHTLC"); + } + } + } + + Ok(()) +} + +fn is_refundable_vhtlc_liveness(liveness: VhtlcContractLiveness) -> bool { + matches!( + liveness, + VhtlcContractLiveness::Funded | VhtlcContractLiveness::Recoverable + ) +} + +fn is_submarine_refundable_status(status: &SwapStatus) -> bool { + matches!( + status, + SwapStatus::InvoiceFailedToPay + | SwapStatus::TransactionLockupFailed + | SwapStatus::SwapExpired + ) +} + +fn is_chain_refundable_status(status: &SwapStatus) -> bool { + matches!(status, SwapStatus::SwapExpired) +} + +async fn update_submarine_swap_status( + client: &Client, + swap_id: &str, + status: SwapStatus, +) -> Result<(), Error> +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + if client + .swap_storage() + .get_submarine(swap_id) + .await? + .is_some() + { + client + .swap_storage() + .update_status_submarine(swap_id, status) + .await?; + } + Ok(()) +} + +async fn update_reverse_swap_status( + client: &Client, + swap_id: &str, + status: SwapStatus, +) -> Result<(), Error> +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + if client.swap_storage().get_reverse(swap_id).await?.is_some() { + client + .swap_storage() + .update_status_reverse(swap_id, status) + .await?; + } + Ok(()) +} + +async fn wait_for_vhtlc_watcher_retry( + stop_rx: &mut tokio::sync::watch::Receiver, + duration: Duration, +) -> bool { + tokio::select! { + _ = stop_rx.changed() => true, + _ = tokio::time::sleep(duration) => false, + } +} + +/// Internal info about an active VHTLC, used during pending tx recovery. +struct VhtlcInfo { + swap_id: String, + address: ArkAddress, + script_pubkey: ScriptBuf, + vhtlc: VhtlcScript, + /// The spend path and control block used to prove ownership in the GetPendingTx intent. + intent_spend_info: (ScriptBuf, bitcoin::taproot::ControlBlock), + preimage: Option<[u8; 32]>, +} + +/// Reconstruct the taproot spend info for a Boltz on-chain BTC HTLC. +/// +/// Boltz uses `MuSig2(serverKey, userKey)` as the internal key. +/// The tree has two leaves: claim and refund, from the [`SwapTree`]. +fn reconstruct_btc_htlc( + server_pk: PublicKey, + user_pk: PublicKey, + swap_tree: &SwapTree, +) -> Result { + let claim_script_bytes: Vec = bitcoin::hex::FromHex::from_hex(&swap_tree.claim_leaf.output) + .map_err(|e| Error::ad_hoc(format!("invalid claim leaf hex: {e}")))?; + let claim_script = ScriptBuf::from_bytes(claim_script_bytes); + + let refund_script_bytes: Vec = + bitcoin::hex::FromHex::from_hex(&swap_tree.refund_leaf.output) + .map_err(|e| Error::ad_hoc(format!("invalid refund leaf hex: {e}")))?; + let refund_script = ScriptBuf::from_bytes(refund_script_bytes); + + let musig_server_pk = musig::PublicKey::from_slice(&server_pk.to_bytes()) + .map_err(|e| Error::ad_hoc(format!("invalid server key for musig: {e}")))?; + let musig_user_pk = musig::PublicKey::from_slice(&user_pk.to_bytes()) + .map_err(|e| Error::ad_hoc(format!("invalid user key for musig: {e}")))?; + + let key_agg = musig::musig::KeyAggCache::new(&[&musig_server_pk, &musig_user_pk]); + let internal_key = XOnlyPublicKey::from_slice(&key_agg.agg_pk().to_byte_array()) + .map_err(|e| Error::ad_hoc(format!("invalid aggregated key: {e}")))?; + + let secp = Secp256k1::new(); + bitcoin::taproot::TaprootBuilder::new() + .add_leaf(1, claim_script) + .map_err(|e| Error::ad_hoc(format!("failed to add claim leaf: {e}")))? + .add_leaf(1, refund_script) + .map_err(|e| Error::ad_hoc(format!("failed to add refund leaf: {e}")))? + .finalize(&secp, internal_key) + .map_err(|_| Error::ad_hoc("failed to finalize taproot tree")) +} + +/// Collect all tapscripts from a [`VhtlcScript`]. +fn vhtlc_tapscripts(vhtlc: &VhtlcScript) -> Vec { + vec![ + vhtlc.claim_script(), + vhtlc.refund_script(), + vhtlc.refund_without_receiver_script(), + vhtlc.unilateral_claim_script(), + vhtlc.unilateral_refund_script(), + vhtlc.unilateral_refund_without_receiver_script(), + ] +} + +/// Extract the preimage from a PSBT's `VTXO_CONDITION_KEY` unknown field. +/// +/// The condition data is encoded as: `[num_elements] [varint_length] [preimage_bytes]`. +/// For VHTLC claims, there is exactly one element: the 32-byte preimage. +fn extract_preimage_from_psbt(psbt: &Psbt) -> Result<[u8; 32], Error> { + let condition_key = psbt::raw::Key { + type_value: 222, + key: VTXO_CONDITION_KEY.to_vec(), + }; + + for input in &psbt.inputs { + if let Some(condition_data) = input.unknown.get(&condition_key) { + if condition_data.is_empty() { + continue; + } + + // First byte is the number of witness elements. + let num_elements = condition_data[0] as usize; + if num_elements == 0 { + continue; + } + + // Parse the first element: varint length followed by the preimage bytes. + let mut cursor = std::io::Cursor::new(&condition_data[1..]); + let length = bitcoin::consensus::Decodable::consensus_decode(&mut cursor) + .map_err(|e| Error::ad_hoc(format!("failed to decode varint length: {e}")))?; + let length: VarInt = length; + let offset = cursor.position() as usize; + let remaining = &condition_data[1 + offset..]; + + if remaining.len() < length.0 as usize { + return Err(Error::ad_hoc(format!( + "condition data too short: expected {} bytes, got {}", + length.0, + remaining.len() + ))); + } + + let preimage_bytes = &remaining[..length.0 as usize]; + + let preimage: [u8; 32] = preimage_bytes.try_into().map_err(|_| { + Error::ad_hoc(format!( + "preimage has unexpected length: {} (expected 32)", + preimage_bytes.len() + )) + })?; + + return Ok(preimage); + } + } + + Err(Error::ad_hoc( + "no VTXO_CONDITION_KEY found in any PSBT input", + )) +} + +fn vhtlc_script_from_contract( + contract: VhtlcContract, + expected_address: &ArkAddress, + server_info: &Info, +) -> Result { + let vhtlc = VhtlcScript::new(contract.options, server_info.network) + .map_err(|e| Error::ad_hoc(format!("failed to build VHTLC: {e}")))?; + + if vhtlc.address() != *expected_address { + return Err(Error::ad_hoc("stored VHTLC contract address mismatch")); + } + + Ok(vhtlc) +} + +/// The amount to be shared with Boltz when creating a reverse submarine swap. +pub enum SwapAmount { + /// Use this value if you need to set the value to be sent by the payer on Lightning. + Invoice(Amount), + /// Use this value if you need to set the value to be received by the payee on Arkade. + Vhtlc(Amount), +} + +impl SwapAmount { + pub fn invoice(amount: Amount) -> Self { + Self::Invoice(amount) + } + + pub fn vhtlc(amount: Amount) -> Self { + Self::Vhtlc(amount) + } +} + +/// The amount specification for a chain swap. +pub enum ChainSwapAmount { + /// The amount the user will lock up. + UserLock(Amount), + /// The amount the user wants to receive (server lock amount). + ServerLock(Amount), +} + +/// Data related to a submarine swap. +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubmarineSwapData { + /// Unique swap identifier. + pub id: String, + /// Preimage for the swap (learned when Boltz claims the VHTLC). + pub preimage: Option<[u8; 32]>, + /// The preimage hash of the BOLT11 invoice. + pub preimage_hash: ripemd160::Hash, + /// Public key of the receiving party. + pub claim_public_key: PublicKey, + /// Public key of the sending party. + pub refund_public_key: PublicKey, + /// Amount locked up in the VHTLC. + pub amount: Amount, + /// All the timelocks for this swap. + pub timeout_block_heights: TimeoutBlockHeights, + /// Address where funds are locked. + #[serde_as(as = "DisplayFromStr")] + pub vhtlc_address: ArkAddress, + /// BOLT11 invoice associated with the swap. + pub invoice: Bolt11Invoice, + /// Current swap status. + pub status: SwapStatus, + /// UNIX timestamp when swap was created. + pub created_at: u64, + /// BIP32 derivation index of the refund key (sender). + /// + /// `None` for legacy swap data created before this field was added. + #[serde(default)] + pub key_derivation_index: Option, + /// Script pubkey of the contract-store VHTLC row for this swap. + #[serde(default)] + pub contract_script_pubkey: Option, +} + +/// Data related to a reverse submarine swap. +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReverseSwapData { + /// Unique swap identifier. + pub id: String, + /// Preimage for the swap (optional, may not be known at creation time). + pub preimage: Option<[u8; 32]>, + /// The preimage hash of the BOLT11 invoice. + pub preimage_hash: ripemd160::Hash, + /// Public key of the receiving party. + pub claim_public_key: PublicKey, + /// Public key of the sending party. + pub refund_public_key: PublicKey, + /// Amount locked up in the VHTLC. + pub amount: Amount, + /// All the timelocks for this swap. + pub timeout_block_heights: TimeoutBlockHeights, + /// Address where funds are locked. + #[serde_as(as = "DisplayFromStr")] + pub vhtlc_address: ArkAddress, + /// Current swap status. + pub status: SwapStatus, + /// UNIX timestamp when swap was created. + pub created_at: u64, + /// BIP32 derivation index of the claim key (receiver). + /// + /// `None` for legacy swap data created before this field was added. + #[serde(default)] + pub key_derivation_index: Option, + /// BOLT11 invoice string for this swap. + pub bolt11: String, + /// Invoice expiry in seconds, derived from the BOLT11 invoice itself. + pub invoice_expiry: u64, + /// Arkade address that receives the claimed VHTLC output. + /// + /// `None` for normal receives and legacy swap data, where the client claims into a fresh local + /// offchain address. + #[serde_as(as = "Option")] + #[serde(default)] + pub claim_address: Option, + /// Script pubkey of the contract-store VHTLC row for this swap. + #[serde(default)] + pub contract_script_pubkey: Option, +} + +/// All possible states of a Boltz swap. +/// +/// Swaps progress through these states during their lifecycle. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum SwapStatus { + /// Initial state when swap is created. + #[serde(rename = "swap.created")] + Created, + /// Lockup transaction detected in mempool. + #[serde(rename = "transaction.mempool")] + TransactionMempool, + /// Lockup transaction confirmed on-chain. + #[serde(rename = "transaction.confirmed")] + TransactionConfirmed, + /// Transaction refunded. + #[serde(rename = "transaction.refunded")] + TransactionRefunded, + /// Transaction failed. + #[serde(rename = "transaction.failed")] + TransactionFailed, + /// Transaction claimed. + #[serde(rename = "transaction.claimed")] + TransactionClaimed, + /// Server lockup transaction detected in mempool (chain swaps). + #[serde(rename = "transaction.server.mempool")] + TransactionServerMempool, + /// Server lockup transaction confirmed (chain swaps). + #[serde(rename = "transaction.server.confirmed")] + TransactionServerConfirmed, + /// Lightning invoice has been set. + #[serde(rename = "invoice.set")] + InvoiceSet, + /// Waiting for Lightning invoice payment. + #[serde(rename = "invoice.pending")] + InvoicePending, + /// Lightning invoice successfully paid. + #[serde(rename = "invoice.paid")] + InvoicePaid, + /// Lightning invoice settled (reverse swaps). + #[serde(rename = "invoice.settled")] + InvoiceSettled, + /// Lightning invoice payment failed. + #[serde(rename = "invoice.failedToPay")] + InvoiceFailedToPay, + /// Invoice expired. + #[serde(rename = "invoice.expired")] + InvoiceExpired, + /// Lockup amount was insufficient (chain swaps). + #[serde(rename = "transaction.lockupFailed")] + TransactionLockupFailed, + /// Swap expired - can be refunded. + #[serde(rename = "swap.expired")] + SwapExpired, + /// Swap failed with error. + #[serde(rename = "error")] + Error { error: String }, + /// An unrecognized status from the Boltz API. + #[serde(untagged)] + Other(String), +} + +impl SwapStatus { + /// Whether this status represents a Boltz-terminal lifecycle state. + /// + /// Do not use this to decide whether an Arkade-side VHTLC contract is inactive: a terminal + /// Boltz status can still leave a user-claimable/refundable VHTLC. Use observed VTXO liveness + /// for contract deactivation instead. + pub fn is_terminal(&self) -> bool { + match self { + Self::TransactionRefunded + | Self::TransactionFailed + | Self::TransactionClaimed + | Self::TransactionLockupFailed + | Self::InvoicePaid + | Self::InvoiceSettled + | Self::InvoiceFailedToPay + | Self::InvoiceExpired + | Self::SwapExpired + | Self::Error { .. } => true, + Self::Created + | Self::TransactionMempool + | Self::TransactionConfirmed + | Self::TransactionServerMempool + | Self::TransactionServerConfirmed + | Self::InvoiceSet + | Self::InvoicePending + | Self::Other(_) => false, + } + } +} + +fn swap_wait_failure_error(status: &SwapStatus, swap_id: &str, awaited: &str) -> Error { + let reason = match status { + SwapStatus::Error { error } => format!("Boltz returned error: {error}"), + SwapStatus::TransactionRefunded => "transaction was refunded".to_string(), + SwapStatus::TransactionFailed => "transaction failed".to_string(), + SwapStatus::TransactionLockupFailed => "lockup transaction failed".to_string(), + SwapStatus::InvoiceFailedToPay => "invoice failed to pay".to_string(), + SwapStatus::InvoiceExpired => "invoice expired".to_string(), + SwapStatus::SwapExpired => "swap expired".to_string(), + SwapStatus::TransactionClaimed => "transaction was claimed".to_string(), + SwapStatus::InvoicePaid => "invoice was paid".to_string(), + SwapStatus::InvoiceSettled => "invoice was settled".to_string(), + SwapStatus::Created => "swap is still created".to_string(), + SwapStatus::TransactionMempool => "transaction is in mempool".to_string(), + SwapStatus::TransactionConfirmed => "transaction is confirmed".to_string(), + SwapStatus::TransactionServerMempool => "server transaction is in mempool".to_string(), + SwapStatus::TransactionServerConfirmed => "server transaction is confirmed".to_string(), + SwapStatus::InvoiceSet => "invoice is set".to_string(), + SwapStatus::InvoicePending => "invoice is pending".to_string(), + SwapStatus::Other(status) => format!("unknown status: {status}"), + }; + + Error::ad_hoc(format!( + "swap {swap_id} cannot complete {awaited}: {reason}" + )) +} + +#[derive(Debug, Clone, Serialize, Deserialize, Copy)] +#[serde(rename_all = "camelCase")] +pub struct TimeoutBlockHeights { + pub refund: u32, + pub unilateral_claim: u32, + pub unilateral_refund: u32, + pub unilateral_refund_without_receiver: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +enum Asset { + Btc, + Ark, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateReverseSwapRequest { + from: Asset, + to: Asset, + #[serde(skip_serializing_if = "Option::is_none")] + invoice_amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + onchain_amount: Option, + claim_public_key: PublicKey, + preimage_hash: sha256::Hash, + /// The expiry will be this number of seconds in the future. + /// + /// If not provided, the generated invoice will have the default expiry set by Boltz. + #[serde(skip_serializing_if = "Option::is_none")] + invoice_expiry: Option, + #[serde(skip_serializing_if = "Option::is_none")] + referral_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, +} + +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateReverseSwapResponse { + id: String, + #[serde_as(as = "DisplayFromStr")] + lockup_address: ArkAddress, + refund_public_key: PublicKey, + timeout_block_heights: TimeoutBlockHeights, + invoice: Bolt11Invoice, + onchain_amount: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CreateSubmarineSwapRequest { + from: Asset, + to: Asset, + invoice: Bolt11Invoice, + #[serde(rename = "refundPublicKey")] + refund_public_key: PublicKey, + #[serde(rename = "referralId", skip_serializing_if = "Option::is_none")] + referral_id: Option, +} + +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateSubmarineSwapResponse { + id: String, + #[serde_as(as = "DisplayFromStr")] + address: ArkAddress, + expected_amount: Amount, + claim_public_key: PublicKey, + timeout_block_heights: TimeoutBlockHeights, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct GetSwapStatusResponse { + status: SwapStatus, + #[serde(default)] + transaction: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SwapStatusTransaction { + id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RefundSwapRequest { + transaction: String, + checkpoint: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RefundSwapResponse { + transaction: String, + checkpoint: String, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +/// Fee information for submarine swaps (Arkade -> Lightning). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubmarineSwapFees { + /// Percentage fee charged by Boltz (e.g., 0.25 = 0.25%). + pub percentage: f64, + /// Fixed miner fee in satoshis. + pub miner_fees: u64, +} + +/// Miner fees for reverse swaps, broken down by operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReverseMinerFees { + /// Miner fee for lockup transaction in satoshis. + pub lockup: u64, + /// Miner fee for claim transaction in satoshis. + pub claim: u64, +} + +/// Fee information for reverse swaps (Lightning -> Arkade). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReverseSwapFees { + /// Percentage fee charged by Boltz (e.g., 0.25 = 0.25%). + pub percentage: f64, + /// Miner fees broken down by operation. + pub miner_fees: ReverseMinerFees, +} + +/// Combined fee information for both swap types. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BoltzFees { + /// Fees for submarine swaps (Arkade -> Lightning). + pub submarine: SubmarineSwapFees, + /// Fees for reverse swaps (Lightning -> Arkade). + pub reverse: ReverseSwapFees, +} + +/// Limits for swap amounts. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SwapLimits { + /// Minimum amount in satoshis. + pub min: u64, + /// Maximum amount in satoshis. + pub max: u64, +} + +// Internal structs for deserializing the Boltz API response. + +#[derive(Debug, Clone, Deserialize)] +struct PairLimits { + minimal: u64, + maximal: u64, +} + +// Submarine swap: { "ARK": { "BTC": { ... } } } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SubmarinePairFees { + percentage: f64, + miner_fees: u64, +} + +#[derive(Debug, Clone, Deserialize)] +struct SubmarinePairInfo { + fees: SubmarinePairFees, + limits: PairLimits, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +struct SubmarineArkPairs { + btc: SubmarinePairInfo, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +struct SubmarinePairsResponse { + ark: SubmarineArkPairs, +} + +// Reverse swap: { "BTC": { "ARK": { ... } } } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReverseMinerFeesResponse { + claim: u64, + lockup: u64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReversePairFees { + percentage: f64, + miner_fees: ReverseMinerFeesResponse, +} + +#[derive(Debug, Clone, Deserialize)] +struct ReversePairInfo { + fees: ReversePairFees, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +struct ReverseBtcPairs { + ark: ReversePairInfo, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +struct ReversePairsResponse { + btc: ReverseBtcPairs, +} + +// ── Chain swap types ────────────────────────────────────────────────── + +/// Direction of a chain swap. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ChainSwapDirection { + /// User locks Arkade VHTLC, claims on-chain BTC. + ArkToBtc, + /// User sends on-chain BTC, claims Arkade VHTLC. + BtcToArk, +} + +/// Data for a pending chain swap (Arkade ↔ BTC). +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChainSwapData { + /// Unique swap identifier. + pub id: String, + /// Current swap status. + pub status: SwapStatus, + /// Direction of the swap. + pub direction: ChainSwapDirection, + /// Preimage for the swap. + pub preimage: Option<[u8; 32]>, + /// The preimage hash. + pub preimage_hash: sha256::Hash, + /// User's claim public key (for claiming Boltz's VHTLC). + pub claim_public_key: PublicKey, + /// User's refund public key (for refunding user's VHTLC). + pub refund_public_key: PublicKey, + /// Boltz's claim public key (on user's VHTLC). + pub server_claim_public_key: PublicKey, + /// Boltz's refund public key (on Boltz's VHTLC). + pub server_refund_public_key: PublicKey, + /// Address where user locks funds. + pub user_lockup_address: String, + /// Address where Boltz locks funds. + pub server_lockup_address: String, + /// Amount user locks up. + pub user_lockup_amount: Amount, + /// Amount Boltz locks up (what user receives). + pub server_lockup_amount: Amount, + /// Timeout block height for user's lockup. + pub user_timeout_block_height: u32, + /// Timeout block height for Boltz's lockup. + pub server_timeout_block_height: u32, + /// Full VHTLC timelocks for user's lockup (present when user locks on Arkade side). + #[serde(default)] + pub user_timeout_block_heights: Option, + /// Full VHTLC timelocks for Boltz's lockup (present when server locks on Arkade side). + #[serde(default)] + pub server_timeout_block_heights: Option, + /// BIP21 payment URI for funding (present for on-chain BTC lockup). + #[serde(default)] + pub bip21: Option, + /// Swap tree for the on-chain BTC HTLC (present for the BTC side of chain swaps). + #[serde(default)] + pub swap_tree: Option, + /// UNIX timestamp when swap was created. + pub created_at: u64, + /// BIP32 derivation index for the claim key. + #[serde(default)] + pub claim_key_derivation_index: Option, + /// BIP32 derivation index for the refund key. + #[serde(default)] + pub refund_key_derivation_index: Option, + /// Script pubkey of the contract-store Arkade-side VHTLC row for this swap. + #[serde(default)] + pub contract_script_pubkey: Option, +} + +/// Direction-specific Arkade-side VHTLC inputs for a chain swap. +/// +/// A Boltz chain swap always has two lockup legs: +/// +/// - `lockup_details`: the user's lockup leg (`user_lockup_*` in [`ChainSwapData`]) +/// - `claim_details`: Boltz's/server's lockup leg (`server_lockup_*` in [`ChainSwapData`]) +/// +/// Exactly one of those legs is an Arkade VHTLC; the other is an on-chain BTC HTLC. The Arkade leg +/// is the only part we persist in the contract manager as a [`VhtlcContract`]. This helper result +/// keeps the direction table in one place so creation, lazy migration, and spend paths all agree on +/// which keys, address, timeouts, and wallet derivation index define the Arkade-side VHTLC. +/// +/// Direction table: +/// +/// | Direction | Arkade-side leg | VHTLC receiver/claim key | VHTLC sender/refund key | Wallet key index | +/// |-----------|--------------|--------------------------|-------------------------|------------------| +/// | Arkade → BTC | user's lockup (`lockup_details`) | Boltz server claim key | wallet refund key | refund key index | +/// | BTC → Arkade | server lockup (`claim_details`) | wallet claim key | Boltz server refund key | claim key index | +/// +/// The `address` is parsed and retained here because it is part of VHTLC reconstruction: we build +/// candidate scripts against the current and deprecated Arkade server signers, then keep the +/// candidate whose Arkade address matches the one Boltz returned for the Arkade-side leg. +struct ChainVhtlcFields { + claim_public_key: PublicKey, + refund_public_key: PublicKey, + timeouts: TimeoutBlockHeights, + address: ArkAddress, + key_derivation_index: Option, +} + +/// Select the Arkade-side VHTLC fields from chain-swap data. +/// +/// This deliberately accepts the raw fields rather than a [`ChainSwapData`] value so callers can +/// use it before the swap row exists. Creation uses it to compute `contract_script_pubkey` first +/// and then builds a fully-populated [`ChainSwapData`] in one struct literal. Lazy migration and +/// spend paths call the same logic through [`ChainSwapData::chain_vhtlc_fields`]. +/// +/// The argument names use wallet/server and user/server lockup terminology to mirror Boltz's +/// response and [`ChainSwapData`]: +/// +/// - `wallet_claim_public_key` / `wallet_refund_public_key` are the user's generated swap keys. +/// - `server_claim_public_key` comes from Boltz `lockup_details.server_public_key` and is used when +/// Boltz claims the user's Arkade lockup in an Arkade→BTC swap. +/// - `server_refund_public_key` comes from Boltz `claim_details.server_public_key` and is used when +/// Boltz refunds its Arkade lockup in a BTC→Arkade swap. +/// - `user_*` fields describe `lockup_details`; `server_*` fields describe `claim_details`. +#[allow(clippy::too_many_arguments)] +fn chain_vhtlc_fields( + direction: &ChainSwapDirection, + wallet_claim_public_key: PublicKey, + wallet_refund_public_key: PublicKey, + server_claim_public_key: PublicKey, + server_refund_public_key: PublicKey, + user_lockup_address: &str, + server_lockup_address: &str, + user_timeout_block_heights: Option, + server_timeout_block_heights: Option, + claim_key_derivation_index: Option, + refund_key_derivation_index: Option, +) -> Result { + let (claim_public_key, refund_public_key, timeouts, address, key_derivation_index) = + match direction { + ChainSwapDirection::ArkToBtc => ( + server_claim_public_key, + wallet_refund_public_key, + user_timeout_block_heights.ok_or_else(|| { + Error::ad_hoc( + "chain swap is missing Arkade-side VHTLC timeouts for user lockup", + ) + })?, + user_lockup_address, + refund_key_derivation_index, + ), + ChainSwapDirection::BtcToArk => ( + wallet_claim_public_key, + server_refund_public_key, + server_timeout_block_heights.ok_or_else(|| { + Error::ad_hoc( + "chain swap is missing Arkade-side VHTLC timeouts for server lockup", + ) + })?, + server_lockup_address, + claim_key_derivation_index, + ), + }; + + let address = ArkAddress::decode(address) + .map_err(|e| Error::ad_hoc(format!("invalid chain VHTLC address: {e}")))?; + + Ok(ChainVhtlcFields { + claim_public_key, + refund_public_key, + timeouts, + address, + key_derivation_index, + }) +} + +impl ChainSwapData { + fn chain_vhtlc_fields(&self) -> Result { + chain_vhtlc_fields( + &self.direction, + self.claim_public_key, + self.refund_public_key, + self.server_claim_public_key, + self.server_refund_public_key, + &self.user_lockup_address, + &self.server_lockup_address, + self.user_timeout_block_heights, + self.server_timeout_block_heights, + self.claim_key_derivation_index, + self.refund_key_derivation_index, + ) + } + + fn chain_vhtlc_key_index(&self) -> Option { + match self.direction { + ChainSwapDirection::ArkToBtc => self.refund_key_derivation_index, + ChainSwapDirection::BtcToArk => self.claim_key_derivation_index, + } + } + + fn chain_vhtlc_address(&self) -> Result { + let address = match self.direction { + ChainSwapDirection::ArkToBtc => &self.user_lockup_address, + ChainSwapDirection::BtcToArk => &self.server_lockup_address, + }; + ArkAddress::decode(address) + .map_err(|e| Error::ad_hoc(format!("invalid chain VHTLC address: {e}"))) + } +} + +/// Result of creating a chain swap. +#[derive(Clone, Debug)] +pub struct ChainSwapResult { + /// Unique swap identifier. + pub swap_id: String, + /// Address the user must fund to initiate the swap. + pub user_lockup_address: String, + /// Amount the user must send. + pub user_lockup_amount: Amount, + /// Amount the user will receive after fees. + pub server_lockup_amount: Amount, + /// BIP21 payment URI for on-chain BTC funding (when the user lockup is BTC). + pub bip21: Option, +} + +// ── Chain swap Boltz API types ─────────────────────────────────────── + +/// Tapscript tree for an on-chain BTC HTLC used in chain swaps. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SwapTree { + /// Leaf used to claim (requires preimage + claim key signature). + pub claim_leaf: SwapTreeLeaf, + /// Leaf used to refund (requires timelock + refund key signature). + pub refund_leaf: SwapTreeLeaf, +} + +/// A single leaf in a [`SwapTree`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SwapTreeLeaf { + /// Tapscript leaf version (192 = TapScript). + pub version: u8, + /// Hex-encoded Bitcoin script. + pub output: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateChainSwapRequest { + from: Asset, + to: Asset, + #[serde(skip_serializing_if = "Option::is_none")] + user_lock_amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + server_lock_amount: Option, + claim_public_key: PublicKey, + refund_public_key: PublicKey, + preimage_hash: sha256::Hash, + #[serde(skip_serializing_if = "Option::is_none")] + referral_id: Option, +} + +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateChainSwapResponse { + id: String, + claim_details: ChainSwapSideDetails, + lockup_details: ChainSwapSideDetails, +} + +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ChainSwapSideDetails { + lockup_address: String, + server_public_key: PublicKey, + timeout_block_height: u32, + #[serde(default)] + timeouts: Option, + amount: Amount, + #[serde(default)] + swap_tree: Option, + #[serde(default)] + bip21: Option, +} + +// VHTLC timeouts come from the stored swap data/Boltz response, not from the server's current +// unilateral-exit delay. The legacy exit-delay probe is therefore only needed for regular +// VTXO/boarding script discovery. + +/// Iterate `server_keys` in order, building a [`VhtlcScript`] for each one, and return the +/// first whose address matches `expected_address`. +/// +/// Extracted from [`Client::reconstruct_vhtlc_for_address`] so the key-iteration logic can be +/// tested without a full [`Client`] instance. +pub(crate) fn reconstruct_vhtlc_from_keys( + server_keys: impl Iterator, + network: bitcoin::Network, + mk_opts: impl Fn(XOnlyPublicKey) -> Result, + expected_address: &ArkAddress, +) -> Result { + for server_key in server_keys { + let opts = mk_opts(server_key)?; + let vhtlc = VhtlcScript::new(opts, network).map_err(Error::ad_hoc)?; + if &vhtlc.address() == expected_address { + return Ok(vhtlc); + } + } + Err(Error::ad_hoc(format!( + "VHTLC script could not be reconstructed for address {expected_address}: \ + does not match current or any deprecated server key" + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contract::ContractManager; + use crate::swap_storage::InMemorySwapStorage; + use crate::ExplorerUtxo; + use crate::OfflineClient; + use crate::OfflineClientConfig; + use crate::ServerState; + use crate::SpendStatus; + use crate::TxStatus; + use ark_core::UtxoCoinSelection; + use bitcoin::secp256k1::Keypair; + use bitcoin::secp256k1::SecretKey; + use bitcoin::Transaction; + use bitcoin::Txid; + use std::sync::Arc; + use std::sync::Mutex; + use std::sync::RwLock; + use std::time::Duration; + use std::time::Instant; + + #[derive(Clone)] + struct DummyBlockchain; + + impl Blockchain for DummyBlockchain { + async fn find_outpoints( + &self, + _address: &bitcoin::Address, + ) -> Result, Error> { + Ok(Vec::new()) + } + + async fn find_tx(&self, _txid: &Txid) -> Result, Error> { + Ok(None) + } + + async fn get_tx_status(&self, _txid: &Txid) -> Result { + Ok(TxStatus { confirmed_at: None }) + } + + async fn get_output_status(&self, _txid: &Txid, _vout: u32) -> Result { + Ok(SpendStatus { spend_txid: None }) + } + + async fn broadcast(&self, _tx: &Transaction) -> Result<(), Error> { + Ok(()) + } + + async fn get_fee_rate(&self) -> Result { + Ok(1.0) + } + + async fn broadcast_package(&self, _txs: &[&Transaction]) -> Result<(), Error> { + Ok(()) + } + } + + struct DummyWallet { + keypair: Keypair, + secp: Secp256k1, + } + + impl DummyWallet { + fn new() -> Self { + let secp = Secp256k1::new(); + let keypair = + Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[2; 32]).unwrap()); + Self { keypair, secp } + } + } + + impl OnchainWallet for DummyWallet { + fn get_onchain_address(&self) -> Result { + Ok(bitcoin::Address::p2tr( + &self.secp, + self.keypair.x_only_public_key().0, + None, + bitcoin::Network::Regtest, + )) + } + + async fn sync(&self) -> Result<(), Error> { + Ok(()) + } + + fn balance(&self) -> Result { + Ok(crate::wallet::Balance { + immature: Amount::ZERO, + trusted_pending: Amount::ZERO, + untrusted_pending: Amount::ZERO, + confirmed: Amount::ZERO, + }) + } + + fn prepare_send_to_address( + &self, + _address: bitcoin::Address, + _amount: Amount, + _fee_rate: bitcoin::FeeRate, + ) -> Result { + Err(Error::wallet("not implemented")) + } + + fn sign(&self, _psbt: &mut Psbt) -> Result { + Ok(true) + } + + fn select_coins(&self, _target_amount: Amount) -> Result { + Err(Error::wallet("not implemented")) + } + } + + type TestClient = Client; + + fn test_server_info() -> Info { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[1; 32]).unwrap(); + let public_key = secp256k1::PublicKey::from_secret_key(&secp, &secret_key); + let keypair = Keypair::from_secret_key(&secp, &secret_key); + let address = bitcoin::Address::p2tr( + &secp, + keypair.x_only_public_key().0, + None, + bitcoin::Network::Testnet, + ); + Info { + version: "test".to_string(), + signer_pk: public_key, + forfeit_pk: public_key, + forfeit_address: address, + checkpoint_tapscript: ScriptBuf::new(), + network: bitcoin::Network::Testnet, + session_duration: 60, + unilateral_exit_delay: bitcoin::Sequence::from_height(144), + boarding_exit_delay: bitcoin::Sequence::from_height(144), + utxo_min_amount: None, + utxo_max_amount: None, + vtxo_min_amount: None, + vtxo_max_amount: None, + dust: Amount::from_sat(1000), + fees: None, + scheduled_session: None, + deprecated_signers: vec![ark_core::server::DeprecatedSigner { + pk: secp256k1::PublicKey::from_x_only_public_key( + fixture_server_xonly(), + secp256k1::Parity::Even, + ), + cutoff_date: 0, + }], + service_status: Default::default(), + digest: "digest".to_string(), + max_tx_weight: 0, + max_op_return_outputs: 0, + } + } + + fn test_client(server_info: Info) -> TestClient { + let secp = Secp256k1::new(); + let keypair = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[3; 32]).unwrap()); + let inner = + OfflineClient::::with_keypair( + OfflineClientConfig { + ark_server_url: "http://127.0.0.1:1".to_string(), + boltz_url: "http://127.0.0.1:1".to_string(), + timeout: Duration::from_millis(50), + ..Default::default() + }, + keypair, + Arc::new(DummyBlockchain), + Arc::new(DummyWallet::new()), + Arc::new(InMemorySwapStorage::default()), + ); + let mut contract_manager = ContractManager::in_memory(server_info.network); + contract_manager.register_builtins().unwrap(); + Client { + inner, + state: Arc::new(RwLock::new(ServerState { + fee_estimator: ark_fees::Estimator::new(Default::default()).unwrap(), + server_info, + server_info_refreshed_at: Instant::now(), + contract_manager: Mutex::new(contract_manager), + })), + server_info_refresh_lock: Arc::new(tokio::sync::Mutex::new(())), + } + } + + fn fixture_vtxo(amount: Amount, is_spent: bool) -> ark_core::server::VirtualTxOutPoint { + ark_core::server::VirtualTxOutPoint { + outpoint: bitcoin::OutPoint { + txid: Txid::from_byte_array([1; 32]), + vout: 0, + }, + created_at: 0, + expires_at: i64::MAX, + amount, + script: ScriptBuf::new(), + is_preconfirmed: false, + is_swept: false, + is_unrolled: false, + is_spent, + spent_by: None, + commitment_txids: Vec::new(), + settled_by: None, + ark_txid: None, + assets: Vec::new(), + depth: 0, + } + } + + #[test] + fn classify_vhtlc_liveness_deactivates_only_spent_vtxos() { + let dust = Amount::from_sat(1000); + + assert_eq!( + classify_vhtlc_contract_liveness(dust, true, Vec::new()), + VhtlcContractLiveness::PendingSpend + ); + assert_eq!( + classify_vhtlc_contract_liveness(dust, false, Vec::new()), + VhtlcContractLiveness::Unfunded + ); + assert_eq!( + classify_vhtlc_contract_liveness( + dust, + false, + vec![fixture_vtxo(Amount::from_sat(1000), false)] + ), + VhtlcContractLiveness::Funded + ); + assert_eq!( + classify_vhtlc_contract_liveness( + dust, + false, + vec![fixture_vtxo(Amount::from_sat(1), false)] + ), + VhtlcContractLiveness::Recoverable + ); + assert_eq!( + classify_vhtlc_contract_liveness( + dust, + false, + vec![fixture_vtxo(Amount::from_sat(1000), true)] + ), + VhtlcContractLiveness::Spent + ); + + assert!(VhtlcContractLiveness::Spent.should_deactivate_contract()); + assert!(!VhtlcContractLiveness::Recoverable.should_deactivate_contract()); + } + + fn test_taproot_signature(byte: u8) -> bitcoin::taproot::Signature { + let secp = Secp256k1::new(); + let keypair = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[byte; 32]).unwrap()); + let msg = secp256k1::Message::from_digest([byte; 32]); + bitcoin::taproot::Signature { + signature: secp.sign_schnorr_no_aux_rand(&msg, &keypair), + sighash_type: bitcoin::TapSighashType::Default, + } + } + + #[test] + fn validates_expected_boltz_tap_script_signature() { + let secp = Secp256k1::new(); + let keypair = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[4; 32]).unwrap()); + let pk = keypair.x_only_public_key().0; + let script = ScriptBuf::new_p2tr(&secp, keypair.x_only_public_key().0, None); + let leaf_hash = TapLeafHash::from_script(&script, LeafVersion::TapScript); + let sig = test_taproot_signature(5); + let mut input = psbt::Input::default(); + input.tap_script_sigs.insert((pk, leaf_hash), sig); + + let validated = validated_boltz_tap_script_sigs(&input, pk, leaf_hash).unwrap(); + + assert_eq!(validated.len(), 1); + assert_eq!(validated.get(&(pk, leaf_hash)), Some(&sig)); + } + + #[test] + fn rejects_unexpected_boltz_tap_script_signature() { + let secp = Secp256k1::new(); + let expected_keypair = + Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[4; 32]).unwrap()); + let unexpected_keypair = + Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[5; 32]).unwrap()); + let expected_pk = expected_keypair.x_only_public_key().0; + let unexpected_pk = unexpected_keypair.x_only_public_key().0; + let script = ScriptBuf::new_p2tr(&secp, expected_pk, None); + let leaf_hash = TapLeafHash::from_script(&script, LeafVersion::TapScript); + let mut input = psbt::Input::default(); + input + .tap_script_sigs + .insert((unexpected_pk, leaf_hash), test_taproot_signature(6)); + + assert!(validated_boltz_tap_script_sigs(&input, expected_pk, leaf_hash).is_err()); + } + + #[test] + fn refund_action_log_backs_off_after_failure() { + let mut log = BoltzVhtlcActionLog::default(); + + assert!(log.begin_refund("swap-1")); + log.finish_refund("swap-1", false); + + assert!(!log.begin_refund("swap-1")); + } + + #[test] + fn refund_action_log_clears_backoff_after_success() { + let mut log = BoltzVhtlcActionLog::default(); + + assert!(log.begin_refund("swap-1")); + log.finish_refund("swap-1", false); + assert!(log.refund_retries.contains_key("swap-1")); + + log.refund_retries.get_mut("swap-1").unwrap().retry_after = Instant::now(); + assert!(log.begin_refund("swap-1")); + log.finish_refund("swap-1", true); + + assert!(!log.refund_retries.contains_key("swap-1")); + assert!(!log.begin_refund("swap-1")); + } + + #[test] + fn only_funded_or_recoverable_vhtlcs_are_refundable() { + assert!(!is_refundable_vhtlc_liveness( + VhtlcContractLiveness::Unfunded + )); + assert!(!is_refundable_vhtlc_liveness( + VhtlcContractLiveness::PendingSpend + )); + assert!(is_refundable_vhtlc_liveness(VhtlcContractLiveness::Funded)); + assert!(is_refundable_vhtlc_liveness( + VhtlcContractLiveness::Recoverable + )); + assert!(!is_refundable_vhtlc_liveness(VhtlcContractLiveness::Spent)); + } + + #[test] + fn test_deserialize_create_reverse_swap_response() { + let json = r#"{ + "id": "vqhG2fJtNY4H", + "lockupAddress": "tark1qra883hysahlkt0ujcwhv0x2n278849c3m7t3a08l7fdc40f4f2nmw3f7kn37vvq0hqazxtqgtvhwp3z83zfgr7qc82t9mty8vk95ynpx3l43d", + "refundPublicKey": "0206988651c7fbe41747bb21b54ced0a183f4d658e007ee8fdb23fbbfccb8e0c55", + "timeoutBlockHeights": { + "refund": 1760508054, + "unilateralClaim": 9728, + "unilateralRefund": 86528, + "unilateralRefundWithoutReceiver": 86528 + }, + "invoice": "lntbs10u1p5wmeeepp56ms94rkev7tdrwqyus5a63lny2mqzq9vh2rq3u4ym3v4lxv6xl4qdql2djkuepqw3hjqs2jfvsxzerywfjhxuccqz95xqztfsp5ckaskagag554na8d56tlrfdxasstqrmmpkvswqqqx6y386jcfq9s9qxpqysgqt7z0vkdwkqamydae7ctgkh7l8q75w7q9394ce3lda2mkfxrpfdtj5gmltuctav7jdgatkflhztrjjzutdla5e4xp0uhxxy7sluzll4qpkkh6wv", + "onchainAmount": 996 +}"#; + + let response: CreateReverseSwapResponse = + serde_json::from_str(json).expect("Failed to deserialize CreateReverseSwapResponse"); + + // Verify the deserialized fields + assert_eq!(response.id, "vqhG2fJtNY4H"); + assert_eq!(response.onchain_amount, Some(Amount::from_sat(996))); + assert_eq!( + response.refund_public_key, + PublicKey::from_str( + "0206988651c7fbe41747bb21b54ced0a183f4d658e007ee8fdb23fbbfccb8e0c55" + ) + .expect("valid public key") + ); + assert_eq!( + response.lockup_address.to_string(), + "tark1qra883hysahlkt0ujcwhv0x2n278849c3m7t3a08l7fdc40f4f2nmw3f7kn37vvq0hqazxtqgtvhwp3z83zfgr7qc82t9mty8vk95ynpx3l43d" + ); + assert_eq!(response.timeout_block_heights.refund, 1760508054); + assert_eq!(response.timeout_block_heights.unilateral_claim, 9728); + assert_eq!(response.timeout_block_heights.unilateral_refund, 86528); + assert_eq!( + response + .timeout_block_heights + .unilateral_refund_without_receiver, + 86528 + ); + } + + #[test] + fn test_btc_htlc_address_reconstruction_btc_to_ark() { + // Real BtcToArk chain swap response from Boltz mutinynet. + // lockupDetails = BTC side (user locks): serverPublicKey = server's claim key. + // User's key is refundPublicKey from the request. + let server_pk = PublicKey::from_str( + "03ce9f5a57218103d5fe07b9d7ecf4b28ad60a960f0fbfd86dd090013020617389", + ) + .unwrap(); + let user_pk = PublicKey::from_str( + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + ) + .unwrap(); + let swap_tree = SwapTree { + claim_leaf: SwapTreeLeaf { + version: 192, + output: "82012088a914b472a266d0bd89c13706a4132ccfb16f7c3b9fcb8820ce9f5a57218103d5fe07b9d7ecf4b28ad60a960f0fbfd86dd090013020617389ac".into(), + }, + refund_leaf: SwapTreeLeaf { + version: 192, + output: "20c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5ad03f9832db1".into(), + }, + }; + + let spend_info = reconstruct_btc_htlc(server_pk, user_pk, &swap_tree).unwrap(); + + let secp = Secp256k1::new(); + let spk = ScriptBuf::new_p2tr(&secp, spend_info.internal_key(), spend_info.merkle_root()); + let addr = bitcoin::Address::from_script(&spk, bitcoin::Network::Testnet).unwrap(); + + assert_eq!( + addr.to_string(), + "tb1ptf632fkczflsjn4356ra4x2s6qp6vvk8e7pplprpwnkvcsd8tpwqkw92c7" + ); + } + + #[test] + fn submarine_swap_request_serializes_referral_id_when_set() { + let request = CreateSubmarineSwapRequest { + from: Asset::Ark, + to: Asset::Btc, + invoice: Bolt11Invoice::from_str( + "lntbs10u1p5wmeeepp56ms94rkev7tdrwqyus5a63lny2mqzq9vh2rq3u4ym3v4lxv6xl4qdql2djkuepqw3hjqs2jfvsxzerywfjhxuccqz95xqztfsp5ckaskagag554na8d56tlrfdxasstqrmmpkvswqqqx6y386jcfq9s9qxpqysgqt7z0vkdwkqamydae7ctgkh7l8q75w7q9394ce3lda2mkfxrpfdtj5gmltuctav7jdgatkflhztrjjzutdla5e4xp0uhxxy7sluzll4qpkkh6wv", + ) + .unwrap(), + refund_public_key: PublicKey::from_str( + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + ) + .unwrap(), + referral_id: Some("partner-xyz".to_string()), + }; + + let json: serde_json::Value = serde_json::to_value(&request).unwrap(); + assert_eq!(json["referralId"], "partner-xyz"); + } + + #[test] + fn submarine_swap_request_omits_referral_id_when_none() { + let request = CreateSubmarineSwapRequest { + from: Asset::Ark, + to: Asset::Btc, + invoice: Bolt11Invoice::from_str( + "lntbs10u1p5wmeeepp56ms94rkev7tdrwqyus5a63lny2mqzq9vh2rq3u4ym3v4lxv6xl4qdql2djkuepqw3hjqs2jfvsxzerywfjhxuccqz95xqztfsp5ckaskagag554na8d56tlrfdxasstqrmmpkvswqqqx6y386jcfq9s9qxpqysgqt7z0vkdwkqamydae7ctgkh7l8q75w7q9394ce3lda2mkfxrpfdtj5gmltuctav7jdgatkflhztrjjzutdla5e4xp0uhxxy7sluzll4qpkkh6wv", + ) + .unwrap(), + refund_public_key: PublicKey::from_str( + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + ) + .unwrap(), + referral_id: None, + }; + + let json: serde_json::Value = serde_json::to_value(&request).unwrap(); + assert!(json.get("referralId").is_none()); + assert!(json.get("referral_id").is_none()); + } + + #[test] + fn reverse_swap_request_serializes_referral_id_when_set() { + let request = CreateReverseSwapRequest { + from: Asset::Btc, + to: Asset::Ark, + invoice_amount: Some(Amount::from_sat(1000)), + onchain_amount: None, + claim_public_key: PublicKey::from_str( + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + ) + .unwrap(), + preimage_hash: sha256::Hash::from_byte_array([1u8; 32]), + invoice_expiry: Some(3600), + referral_id: Some("partner-xyz".to_string()), + description: None, + }; + + let json: serde_json::Value = serde_json::to_value(&request).unwrap(); + assert_eq!(json["referralId"], "partner-xyz"); + } + + #[test] + fn reverse_swap_request_omits_referral_id_when_none() { + let request = CreateReverseSwapRequest { + from: Asset::Btc, + to: Asset::Ark, + invoice_amount: Some(Amount::from_sat(1000)), + onchain_amount: None, + claim_public_key: PublicKey::from_str( + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + ) + .unwrap(), + preimage_hash: sha256::Hash::from_byte_array([1u8; 32]), + invoice_expiry: Some(3600), + referral_id: None, + description: None, + }; + + let json: serde_json::Value = serde_json::to_value(&request).unwrap(); + assert!(json.get("referralId").is_none()); + assert!(json.get("referral_id").is_none()); + } + + #[test] + fn chain_swap_request_serializes_referral_id_when_set() { + let request = CreateChainSwapRequest { + from: Asset::Ark, + to: Asset::Btc, + user_lock_amount: Some(Amount::from_sat(1000)), + server_lock_amount: None, + claim_public_key: PublicKey::from_str( + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + ) + .unwrap(), + refund_public_key: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + preimage_hash: sha256::Hash::from_byte_array([1u8; 32]), + referral_id: Some("partner-xyz".to_string()), + }; + + let json: serde_json::Value = serde_json::to_value(&request).unwrap(); + assert_eq!(json["referralId"], "partner-xyz"); + } + + #[test] + fn chain_swap_request_omits_referral_id_when_none() { + let request = CreateChainSwapRequest { + from: Asset::Ark, + to: Asset::Btc, + user_lock_amount: Some(Amount::from_sat(1000)), + server_lock_amount: None, + claim_public_key: PublicKey::from_str( + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + ) + .unwrap(), + refund_public_key: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + preimage_hash: sha256::Hash::from_byte_array([1u8; 32]), + referral_id: None, + }; + + let json: serde_json::Value = serde_json::to_value(&request).unwrap(); + assert!(json.get("referralId").is_none()); + assert!(json.get("referral_id").is_none()); + } + + #[test] + fn test_btc_htlc_address_reconstruction_ark_to_btc() { + // Real ArkToBtc chain swap response from Boltz mutinynet. + // claimDetails = BTC side (user claims): serverPublicKey = server's refund key. + // User's key is claimPublicKey from the request. + let server_pk = PublicKey::from_str( + "0207364dc5853e630be83439fde62b531e3c11db34ce8c4f454a56782555c58ed6", + ) + .unwrap(); + let user_pk = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let swap_tree = SwapTree { + claim_leaf: SwapTreeLeaf { + version: 192, + output: "82012088a914cf7ff51392e9a37bc72c7284841db669c82e2c14882079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798ac".into(), + }, + refund_leaf: SwapTreeLeaf { + version: 192, + output: "2007364dc5853e630be83439fde62b531e3c11db34ce8c4f454a56782555c58ed6ad036b832db1".into(), + }, + }; + + let spend_info = reconstruct_btc_htlc(server_pk, user_pk, &swap_tree).unwrap(); + + let secp = Secp256k1::new(); + let spk = ScriptBuf::new_p2tr(&secp, spend_info.internal_key(), spend_info.merkle_root()); + let addr = bitcoin::Address::from_script(&spk, bitcoin::Network::Testnet).unwrap(); + + assert_eq!( + addr.to_string(), + "tb1pxa78pf55g0aaurrd8c76fyax4df9e8y38fzps8sw2vkrecf9k3ss36a78m" + ); + } + + #[test] + fn validate_invoice_description_accepts_none_empty_and_max_length() { + assert!(validate_invoice_description(None).is_ok()); + assert!(validate_invoice_description(Some("")).is_ok()); + let at_limit = "a".repeat(MAX_BOLT11_DESCRIPTION_BYTES); + assert!(validate_invoice_description(Some(&at_limit)).is_ok()); + } + + #[test] + fn validate_invoice_description_rejects_over_limit() { + let too_long = "a".repeat(MAX_BOLT11_DESCRIPTION_BYTES + 1); + let err = validate_invoice_description(Some(&too_long)).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("640"), "unexpected error message: {msg}"); + assert!(msg.contains("639"), "unexpected error message: {msg}"); + } + + // ── reconstruct_vhtlc_from_keys ───────────────────────────────────────── + + /// Build a [`VhtlcOptions`] from the first fixture in vhtlc.json (CSV > 16). + /// Keys and expected address are taken verbatim from the JSON fixture so the test + /// is independent of any client-side logic. + fn fixture_opts(server: XOnlyPublicKey) -> VhtlcOptions { + let sender = XOnlyPublicKey::from( + PublicKey::from_str( + "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + ) + .unwrap() + .inner, + ); + let receiver = XOnlyPublicKey::from( + PublicKey::from_str( + "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + ) + .unwrap() + .inner, + ); + VhtlcOptions { + sender, + receiver, + server, + preimage_hash: ripemd160::Hash::from_str("4d487dd3753a89bc9fe98401d1196523058251fc") + .unwrap(), + refund_locktime: 265, + unilateral_claim_delay: bitcoin::Sequence::from_height(17), + unilateral_refund_delay: bitcoin::Sequence::from_height(144), + unilateral_refund_without_receiver_delay: bitcoin::Sequence::from_height(144), + } + } + + fn fixture_server_xonly() -> XOnlyPublicKey { + XOnlyPublicKey::from( + PublicKey::from_str( + "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + ) + .unwrap() + .inner, + ) + } + + // Expected Arkade address from the fixture (vhtlc.json CSV > 16 case, testnet). + const FIXTURE_ADDRESS: &str = "tark1qz4d2t2czchfaml2l3ad3gwde2qxpd0srhc7wkpnvtg99cnxyz8c3pnvvhnhumhwhqthmlxmdryakwx99s6508y8dunj9sty2p5mr7unh5re63"; + + const BOLT11_FIXTURE: &str = "lnbcrt10u1p5d55pjpp56ms94rkev7tdrwqyus5a63lny2mqzq9vh2rq3u4ym3v4lxv6xl4qdql2djkuepqw3hjqs2jfvsxzerywfjhxuccqz95xqztfsp57x0nwf7nzsndjdrvsre570ehg0szw34l284hswdz6zpqvktq9mrs9qxpqysgqllgxhxeny0tvtnxuqgn4s0t2qamc6yqc4t3pe6p2x5lgs8v8r3vxzxp3a3ax9j7d2ta5cduddln8n9se7q0jgg7s0h8t2vhljlu3wkcps9k8xs"; + + // A second server key that produces a different address for the same other params. + fn wrong_server_xonly() -> XOnlyPublicKey { + XOnlyPublicKey::from( + PublicKey::from_str( + "0206988651c7fbe41747bb21b54ced0a183f4d658e007ee8fdb23fbbfccb8e0c55", + ) + .unwrap() + .inner, + ) + } + + fn public_key_from_xonly(pk: XOnlyPublicKey) -> PublicKey { + PublicKey::new(secp256k1::PublicKey::from_x_only_public_key( + pk, + secp256k1::Parity::Even, + )) + } + + fn fixture_submarine_swap(contract_script_pubkey: Option) -> SubmarineSwapData { + let opts = fixture_opts(fixture_server_xonly()); + let vhtlc = VhtlcScript::new(opts.clone(), bitcoin::Network::Testnet).unwrap(); + SubmarineSwapData { + id: "swap-1".to_string(), + preimage: None, + preimage_hash: opts.preimage_hash, + claim_public_key: public_key_from_xonly(opts.receiver), + refund_public_key: public_key_from_xonly(opts.sender), + amount: Amount::from_sat(1000), + timeout_block_heights: TimeoutBlockHeights { + refund: opts.refund_locktime, + unilateral_claim: opts.unilateral_claim_delay.to_consensus_u32(), + unilateral_refund: opts.unilateral_refund_delay.to_consensus_u32(), + unilateral_refund_without_receiver: opts + .unilateral_refund_without_receiver_delay + .to_consensus_u32(), + }, + vhtlc_address: vhtlc.address(), + invoice: Bolt11Invoice::from_str(BOLT11_FIXTURE).unwrap(), + status: SwapStatus::Created, + created_at: 123, + key_derivation_index: Some(7), + contract_script_pubkey, + } + } + + fn fixture_reverse_swap(contract_script_pubkey: Option) -> ReverseSwapData { + let opts = fixture_opts(fixture_server_xonly()); + let vhtlc = VhtlcScript::new(opts.clone(), bitcoin::Network::Testnet).unwrap(); + ReverseSwapData { + id: "reverse-1".to_string(), + preimage: Some([1; 32]), + preimage_hash: opts.preimage_hash, + claim_public_key: public_key_from_xonly(opts.receiver), + refund_public_key: public_key_from_xonly(opts.sender), + amount: Amount::from_sat(1000), + timeout_block_heights: TimeoutBlockHeights { + refund: opts.refund_locktime, + unilateral_claim: opts.unilateral_claim_delay.to_consensus_u32(), + unilateral_refund: opts.unilateral_refund_delay.to_consensus_u32(), + unilateral_refund_without_receiver: opts + .unilateral_refund_without_receiver_delay + .to_consensus_u32(), + }, + vhtlc_address: vhtlc.address(), + status: SwapStatus::Created, + created_at: 123, + key_derivation_index: Some(8), + bolt11: BOLT11_FIXTURE.to_string(), + invoice_expiry: 3600, + claim_address: None, + contract_script_pubkey, + } + } + + fn fixture_chain_swap( + direction: ChainSwapDirection, + contract_script_pubkey: Option, + ) -> ChainSwapData { + let mut opts = fixture_opts(fixture_server_xonly()); + let preimage_hash = sha256::Hash::from_byte_array([2; 32]); + opts.preimage_hash = ripemd160::Hash::hash(preimage_hash.as_byte_array()); + let vhtlc = VhtlcScript::new(opts.clone(), bitcoin::Network::Testnet).unwrap(); + let timeouts = TimeoutBlockHeights { + refund: opts.refund_locktime, + unilateral_claim: opts.unilateral_claim_delay.to_consensus_u32(), + unilateral_refund: opts.unilateral_refund_delay.to_consensus_u32(), + unilateral_refund_without_receiver: opts + .unilateral_refund_without_receiver_delay + .to_consensus_u32(), + }; + let ark_address = vhtlc.address().to_string(); + let btc_address = + "tb1pxa78pf55g0aaurrd8c76fyax4df9e8y38fzps8sw2vkrecf9k3ss36a78m".to_string(); + ChainSwapData { + id: "chain-1".to_string(), + status: SwapStatus::Created, + direction: direction.clone(), + preimage: Some([1; 32]), + preimage_hash, + claim_public_key: public_key_from_xonly(opts.receiver), + refund_public_key: public_key_from_xonly(opts.sender), + server_claim_public_key: public_key_from_xonly(opts.receiver), + server_refund_public_key: public_key_from_xonly(opts.sender), + user_lockup_address: match direction { + ChainSwapDirection::ArkToBtc => ark_address.clone(), + ChainSwapDirection::BtcToArk => btc_address.clone(), + }, + server_lockup_address: match direction { + ChainSwapDirection::ArkToBtc => btc_address, + ChainSwapDirection::BtcToArk => ark_address, + }, + user_lockup_amount: Amount::from_sat(1000), + server_lockup_amount: Amount::from_sat(900), + user_timeout_block_height: 265, + server_timeout_block_height: 265, + user_timeout_block_heights: matches!(direction, ChainSwapDirection::ArkToBtc) + .then_some(timeouts), + server_timeout_block_heights: matches!(direction, ChainSwapDirection::BtcToArk) + .then_some(timeouts), + bip21: None, + swap_tree: None, + created_at: 123, + claim_key_derivation_index: Some(7), + refund_key_derivation_index: Some(8), + contract_script_pubkey, + } + } + + #[test] + fn submarine_swap_data_deserializes_legacy_missing_contract_reference() { + let script_pubkey = VhtlcScript::new( + fixture_opts(fixture_server_xonly()), + bitcoin::Network::Testnet, + ) + .unwrap() + .script_pubkey(); + let swap = fixture_submarine_swap(Some(script_pubkey)); + let mut json = serde_json::to_value(&swap).unwrap(); + json.as_object_mut() + .unwrap() + .remove("contract_script_pubkey"); + + let decoded: SubmarineSwapData = serde_json::from_value(json).unwrap(); + assert_eq!(decoded.id, swap.id); + assert_eq!(decoded.contract_script_pubkey, None); + } + + #[test] + fn submarine_swap_data_roundtrips_contract_reference() { + let script_pubkey = VhtlcScript::new( + fixture_opts(fixture_server_xonly()), + bitcoin::Network::Testnet, + ) + .unwrap() + .script_pubkey(); + let swap = fixture_submarine_swap(Some(script_pubkey.clone())); + + let decoded: SubmarineSwapData = + serde_json::from_value(serde_json::to_value(&swap).unwrap()).unwrap(); + assert_eq!(decoded.contract_script_pubkey, Some(script_pubkey)); + } + + #[test] + fn reverse_swap_data_deserializes_legacy_missing_contract_reference() { + let script_pubkey = VhtlcScript::new( + fixture_opts(fixture_server_xonly()), + bitcoin::Network::Testnet, + ) + .unwrap() + .script_pubkey(); + let swap = fixture_reverse_swap(Some(script_pubkey)); + let mut json = serde_json::to_value(&swap).unwrap(); + json.as_object_mut() + .unwrap() + .remove("contract_script_pubkey"); + + let decoded: ReverseSwapData = serde_json::from_value(json).unwrap(); + assert_eq!(decoded.id, swap.id); + assert_eq!(decoded.contract_script_pubkey, None); + } + + #[test] + fn reverse_swap_data_roundtrips_contract_reference() { + let script_pubkey = VhtlcScript::new( + fixture_opts(fixture_server_xonly()), + bitcoin::Network::Testnet, + ) + .unwrap() + .script_pubkey(); + let swap = fixture_reverse_swap(Some(script_pubkey.clone())); + + let decoded: ReverseSwapData = + serde_json::from_value(serde_json::to_value(&swap).unwrap()).unwrap(); + assert_eq!(decoded.contract_script_pubkey, Some(script_pubkey)); + } + + #[test] + fn chain_swap_data_deserializes_legacy_missing_contract_reference() { + let script_pubkey = VhtlcScript::new( + fixture_opts(fixture_server_xonly()), + bitcoin::Network::Testnet, + ) + .unwrap() + .script_pubkey(); + let swap = fixture_chain_swap(ChainSwapDirection::ArkToBtc, Some(script_pubkey)); + let mut json = serde_json::to_value(&swap).unwrap(); + json.as_object_mut() + .unwrap() + .remove("contract_script_pubkey"); + + let decoded: ChainSwapData = serde_json::from_value(json).unwrap(); + assert_eq!(decoded.id, swap.id); + assert_eq!(decoded.contract_script_pubkey, None); + } + + #[test] + fn chain_swap_data_roundtrips_contract_reference() { + let script_pubkey = VhtlcScript::new( + fixture_opts(fixture_server_xonly()), + bitcoin::Network::Testnet, + ) + .unwrap() + .script_pubkey(); + let swap = fixture_chain_swap(ChainSwapDirection::BtcToArk, Some(script_pubkey.clone())); + + let decoded: ChainSwapData = + serde_json::from_value(serde_json::to_value(&swap).unwrap()).unwrap(); + assert_eq!(decoded.contract_script_pubkey, Some(script_pubkey)); + } + + #[tokio::test] + async fn terminal_submarine_status_keeps_vhtlc_contract_active_without_spent_vtxo() { + let server_info = test_server_info(); + let client = test_client(server_info); + let script_pubkey = client + .insert_vhtlc_contract(fixture_opts(fixture_server_xonly()), Some(7)) + .unwrap(); + let mut swap = fixture_submarine_swap(Some(script_pubkey.clone())); + client + .swap_storage() + .insert_submarine(swap.id.clone(), swap.clone()) + .await + .unwrap(); + + client + .persist_swap_status_for_type( + SwapType::Submarine, + &swap.id, + SwapStatus::TransactionClaimed, + ) + .await + .unwrap(); + + swap = client + .swap_storage() + .get_submarine(&swap.id) + .await + .unwrap() + .unwrap(); + assert_eq!(swap.status, SwapStatus::TransactionClaimed); + let state = client.state.read().unwrap(); + let stored = state + .contract_manager + .lock() + .unwrap() + .get(&script_pubkey) + .unwrap() + .unwrap(); + assert_eq!(stored.state, ContractState::Active); + } + + #[tokio::test] + async fn terminal_reverse_status_keeps_vhtlc_contract_active_without_spent_vtxo() { + let server_info = test_server_info(); + let client = test_client(server_info); + let script_pubkey = client + .insert_vhtlc_contract(fixture_opts(fixture_server_xonly()), Some(8)) + .unwrap(); + let mut swap = fixture_reverse_swap(Some(script_pubkey.clone())); + client + .swap_storage() + .insert_reverse(swap.id.clone(), swap.clone()) + .await + .unwrap(); + + client + .persist_swap_status_for_type(SwapType::Reverse, &swap.id, SwapStatus::InvoiceExpired) + .await + .unwrap(); + + swap = client + .swap_storage() + .get_reverse(&swap.id) + .await + .unwrap() + .unwrap(); + assert_eq!(swap.status, SwapStatus::InvoiceExpired); + let state = client.state.read().unwrap(); + let stored = state + .contract_manager + .lock() + .unwrap() + .get(&script_pubkey) + .unwrap() + .unwrap(); + assert_eq!(stored.state, ContractState::Active); + } + + #[tokio::test] + async fn terminal_chain_status_keeps_vhtlc_contract_active_without_spent_vtxo() { + let server_info = test_server_info(); + let client = test_client(server_info); + let script_pubkey = client + .insert_vhtlc_contract(fixture_opts(fixture_server_xonly()), Some(8)) + .unwrap(); + let mut swap = + fixture_chain_swap(ChainSwapDirection::ArkToBtc, Some(script_pubkey.clone())); + client + .swap_storage() + .insert_chain(swap.id.clone(), swap.clone()) + .await + .unwrap(); + + client + .persist_swap_status_for_type( + SwapType::Chain, + &swap.id, + SwapStatus::TransactionRefunded, + ) + .await + .unwrap(); + + swap = client + .swap_storage() + .get_chain(&swap.id) + .await + .unwrap() + .unwrap(); + assert_eq!(swap.status, SwapStatus::TransactionRefunded); + let state = client.state.read().unwrap(); + let stored = state + .contract_manager + .lock() + .unwrap() + .get(&script_pubkey) + .unwrap() + .unwrap(); + assert_eq!(stored.state, ContractState::Active); + } + + #[tokio::test] + async fn non_terminal_swap_status_keeps_vhtlc_contract_active() { + let server_info = test_server_info(); + let client = test_client(server_info); + let script_pubkey = client + .insert_vhtlc_contract(fixture_opts(fixture_server_xonly()), Some(7)) + .unwrap(); + let swap = fixture_submarine_swap(Some(script_pubkey.clone())); + client + .swap_storage() + .insert_submarine(swap.id.clone(), swap.clone()) + .await + .unwrap(); + + client + .persist_swap_status_for_type( + SwapType::Submarine, + &swap.id, + SwapStatus::TransactionMempool, + ) + .await + .unwrap(); + + let state = client.state.read().unwrap(); + let stored = state + .contract_manager + .lock() + .unwrap() + .get(&script_pubkey) + .unwrap() + .unwrap(); + assert_eq!(stored.state, ContractState::Active); + } + + #[tokio::test] + async fn eager_migration_persists_missing_vhtlc_contract_refs() { + let server_info = test_server_info(); + let client = test_client(server_info.clone()); + let submarine = fixture_submarine_swap(None); + let reverse = fixture_reverse_swap(None); + client + .swap_storage() + .insert_submarine(submarine.id.clone(), submarine.clone()) + .await + .unwrap(); + let chain = fixture_chain_swap(ChainSwapDirection::ArkToBtc, None); + client + .swap_storage() + .insert_reverse(reverse.id.clone(), reverse.clone()) + .await + .unwrap(); + client + .swap_storage() + .insert_chain(chain.id.clone(), chain.clone()) + .await + .unwrap(); + + let migrated = client + .migrate_boltz_vhtlc_contracts(&server_info) + .await + .unwrap(); + + assert_eq!(migrated, 3); + assert!(client + .swap_storage() + .get_submarine(&submarine.id) + .await + .unwrap() + .unwrap() + .contract_script_pubkey + .is_some()); + assert!(client + .swap_storage() + .get_reverse(&reverse.id) + .await + .unwrap() + .unwrap() + .contract_script_pubkey + .is_some()); + assert!(client + .swap_storage() + .get_chain(&chain.id) + .await + .unwrap() + .unwrap() + .contract_script_pubkey + .is_some()); + } + + #[tokio::test] + async fn eager_migration_keeps_contract_active_without_spent_vtxo() { + let server_info = test_server_info(); + let client = test_client(server_info.clone()); + let mut swap = fixture_submarine_swap(None); + swap.status = SwapStatus::TransactionClaimed; + client + .swap_storage() + .insert_submarine(swap.id.clone(), swap.clone()) + .await + .unwrap(); + + let migrated = client + .migrate_boltz_vhtlc_contracts(&server_info) + .await + .unwrap(); + + assert_eq!(migrated, 1); + let stored_swap = client + .swap_storage() + .get_submarine(&swap.id) + .await + .unwrap() + .unwrap(); + let script_pubkey = stored_swap.contract_script_pubkey.unwrap(); + let state = client.state.read().unwrap(); + let stored = state + .contract_manager + .lock() + .unwrap() + .get(&script_pubkey) + .unwrap() + .unwrap(); + assert_eq!(stored.state, ContractState::Active); + } + + #[test] + fn mark_vhtlc_contract_inactive_updates_contract_state() { + let server_info = test_server_info(); + let client = test_client(server_info); + let script_pubkey = client + .insert_vhtlc_contract(fixture_opts(fixture_server_xonly()), Some(9)) + .unwrap(); + + client + .mark_vhtlc_contract_inactive(Some(&script_pubkey)) + .unwrap(); + + let state = client.state.read().unwrap(); + let stored = state + .contract_manager + .lock() + .unwrap() + .get(&script_pubkey) + .unwrap() + .unwrap(); + assert_eq!(stored.state, ContractState::Inactive); + assert_eq!(stored.key_index, Some(9)); + } + + #[test] + fn deactivates_orphaned_contract_after_swap_persistence_failure() { + let client = test_client(test_server_info()); + let script_pubkey = client + .insert_vhtlc_contract(fixture_opts(fixture_server_xonly()), Some(9)) + .unwrap(); + + let error = client.deactivate_orphaned_vhtlc_contract( + &script_pubkey, + "swap-1", + Error::ad_hoc("swap storage failed"), + ); + + assert_eq!(error.to_string(), "swap storage failed"); + let state = client.state.read().unwrap(); + let stored = state + .contract_manager + .lock() + .unwrap() + .get(&script_pubkey) + .unwrap() + .unwrap(); + assert_eq!(stored.state, ContractState::Inactive); + } + + #[test] + fn mark_vhtlc_contract_inactive_ignores_missing_reference() { + let client = test_client(test_server_info()); + + client.mark_vhtlc_contract_inactive(None).unwrap(); + + let state = client.state.read().unwrap(); + assert!(state + .contract_manager + .lock() + .unwrap() + .list() + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn submarine_vhtlc_script_lazily_migrates_legacy_swap() { + let server_info = test_server_info(); + let client = test_client(server_info.clone()); + let mut swap = fixture_submarine_swap(None); + client + .swap_storage() + .insert_submarine(swap.id.clone(), swap.clone()) + .await + .unwrap(); + + let vhtlc = client + .submarine_vhtlc_script(&mut swap, &server_info) + .await + .unwrap(); + + let script_pubkey = swap.contract_script_pubkey.clone().unwrap(); + assert_eq!(script_pubkey, vhtlc.script_pubkey()); + let stored_swap = client + .swap_storage() + .get_submarine(&swap.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + stored_swap.contract_script_pubkey, + Some(script_pubkey.clone()) + ); + let state = client.state.read().unwrap(); + let stored_contract = state + .contract_manager + .lock() + .unwrap() + .get(&script_pubkey) + .unwrap() + .unwrap(); + assert_eq!(stored_contract.state, ContractState::Active); + assert_eq!(stored_contract.key_index, swap.key_derivation_index); + } + + #[tokio::test] + async fn build_chain_vhtlc_script_does_not_require_stored_swap() { + let server_info = test_server_info(); + let client = test_client(server_info.clone()); + let swap = fixture_chain_swap(ChainSwapDirection::ArkToBtc, None); + + let vhtlc = client + .build_chain_vhtlc_script(&swap, &server_info) + .unwrap(); + + assert_eq!(vhtlc.address(), swap.chain_vhtlc_address().unwrap()); + assert_eq!(swap.chain_vhtlc_key_index(), Some(8)); + assert!(client + .swap_storage() + .get_chain(&swap.id) + .await + .unwrap() + .is_none()); + let state = client.state.read().unwrap(); + assert!(state + .contract_manager + .lock() + .unwrap() + .list() + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn chain_vhtlc_script_lazily_migrates_legacy_swap() { + let server_info = test_server_info(); + let client = test_client(server_info.clone()); + let mut swap = fixture_chain_swap(ChainSwapDirection::BtcToArk, None); + client + .swap_storage() + .insert_chain(swap.id.clone(), swap.clone()) + .await + .unwrap(); + + let vhtlc = client + .chain_vhtlc_script(&mut swap, &server_info) + .await + .unwrap(); + + let script_pubkey = swap.contract_script_pubkey.clone().unwrap(); + assert_eq!(script_pubkey, vhtlc.script_pubkey()); + let stored_swap = client + .swap_storage() + .get_chain(&swap.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + stored_swap.contract_script_pubkey, + Some(script_pubkey.clone()) + ); + let state = client.state.read().unwrap(); + let stored_contract = state + .contract_manager + .lock() + .unwrap() + .get(&script_pubkey) + .unwrap() + .unwrap(); + assert_eq!(stored_contract.state, ContractState::Active); + assert_eq!(stored_contract.key_index, swap.chain_vhtlc_key_index()); + } + + #[tokio::test] + async fn reverse_vhtlc_script_lazily_migrates_legacy_swap() { + let server_info = test_server_info(); + let client = test_client(server_info.clone()); + let mut swap = fixture_reverse_swap(None); + client + .swap_storage() + .insert_reverse(swap.id.clone(), swap.clone()) + .await + .unwrap(); + + let vhtlc = client + .reverse_vhtlc_script(&mut swap, &server_info) + .await + .unwrap(); + + let script_pubkey = swap.contract_script_pubkey.clone().unwrap(); + assert_eq!(script_pubkey, vhtlc.script_pubkey()); + let stored_swap = client + .swap_storage() + .get_reverse(&swap.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + stored_swap.contract_script_pubkey, + Some(script_pubkey.clone()) + ); + let state = client.state.read().unwrap(); + let stored_contract = state + .contract_manager + .lock() + .unwrap() + .get(&script_pubkey) + .unwrap() + .unwrap(); + assert_eq!(stored_contract.state, ContractState::Active); + assert_eq!(stored_contract.key_index, swap.key_derivation_index); + } + + #[test] + fn reconstruct_matches_with_single_current_key() { + let server = fixture_server_xonly(); + let expected = ArkAddress::decode(FIXTURE_ADDRESS).unwrap(); + + let vhtlc = reconstruct_vhtlc_from_keys( + std::iter::once(server), + bitcoin::Network::Testnet, + |sk| Ok(fixture_opts(sk)), + &expected, + ) + .unwrap(); + + assert_eq!(vhtlc.address(), expected); + } + + #[test] + fn reconstruct_skips_wrong_key_and_finds_deprecated() { + let wrong = wrong_server_xonly(); + let correct = fixture_server_xonly(); + let expected = ArkAddress::decode(FIXTURE_ADDRESS).unwrap(); + + // Iterator: wrong key first, correct key second (simulates signer rotation). + let keys = [wrong, correct].into_iter(); + let vhtlc = reconstruct_vhtlc_from_keys( + keys, + bitcoin::Network::Testnet, + |sk| Ok(fixture_opts(sk)), + &expected, + ) + .unwrap(); + + assert_eq!(vhtlc.address(), expected); + } + + #[test] + fn reconstruct_errors_when_no_key_matches() { + let wrong = wrong_server_xonly(); + let expected = ArkAddress::decode(FIXTURE_ADDRESS).unwrap(); + + let err = reconstruct_vhtlc_from_keys( + std::iter::once(wrong), + bitcoin::Network::Testnet, + |sk| Ok(fixture_opts(sk)), + &expected, + ) + .err() + .expect("should have failed"); + + assert!( + err.to_string() + .contains("does not match current or any deprecated server key"), + "unexpected error: {err}" + ); + } + + #[test] + fn reconstruct_propagates_mk_opts_error() { + let server = fixture_server_xonly(); + let expected = ArkAddress::decode(FIXTURE_ADDRESS).unwrap(); + + let err = reconstruct_vhtlc_from_keys( + std::iter::once(server), + bitcoin::Network::Testnet, + |_| Err(Error::ad_hoc("options error")), + &expected, + ) + .err() + .expect("should have failed"); + + assert!( + err.to_string().contains("options error"), + "unexpected: {err}" + ); + } + + #[test] + fn build_vhtlc_script_sender_is_refund_receiver_is_claim() { + // Verify the key-role mapping: build_vhtlc_script(claim, refund, ...) must produce the + // same address as a manually-constructed VhtlcOptions{sender=refund, receiver=claim}. + let claim_pk = PublicKey::from_str( + "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + ) + .unwrap(); + let refund_pk = PublicKey::from_str( + "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + ) + .unwrap(); + let server = fixture_server_xonly(); + let expected = ArkAddress::decode(FIXTURE_ADDRESS).unwrap(); + + let opts = VhtlcOptions { + sender: refund_pk.inner.x_only_public_key().0, + receiver: claim_pk.inner.x_only_public_key().0, + server, + preimage_hash: ripemd160::Hash::from_str("4d487dd3753a89bc9fe98401d1196523058251fc") + .unwrap(), + refund_locktime: 265, + unilateral_claim_delay: bitcoin::Sequence::from_height(17), + unilateral_refund_delay: bitcoin::Sequence::from_height(144), + unilateral_refund_without_receiver_delay: bitcoin::Sequence::from_height(144), + }; + let manual_vhtlc = + VhtlcScript::new(opts, bitcoin::Network::Testnet).expect("valid options"); + + // The manual construction produces the expected fixture address. + assert_eq!(manual_vhtlc.address(), expected); + } +} diff --git a/vendor/ark-client/src/coin_select.rs b/vendor/ark-client/src/coin_select.rs new file mode 100644 index 0000000..67dedc0 --- /dev/null +++ b/vendor/ark-client/src/coin_select.rs @@ -0,0 +1,157 @@ +use crate::error::ErrorContext; +use crate::swap_storage::SwapStorage; +use crate::wallet::OnchainWallet; +use crate::Blockchain; +use crate::Client; +use crate::Error; +use ark_core::contract::SpendPathKind; +use ark_core::unilateral_exit; +use ark_core::ExplorerUtxo; +use bitcoin::Amount; +use bitcoin::TxOut; +use jiff::Timestamp; +use std::collections::HashSet; +use std::time::Duration; + +/// Select boarding outputs and VTXOs to be used as inputs in on-chain transactions, exiting the Ark +/// ecosystem. +/// +/// This function prioritizes boarding outputs over VTXOs. That is, we may not select any VTXOs if +/// the `target_amount` is covered using only boarding outputs. +/// +/// TODO: We should use a coin selection algorithm that takes into account fees e.g. +/// https://github.com/bitcoindevkit/coin-select. +/// +/// TODO: Part of this logic needs to be extracted into `ark-core`. +pub async fn coin_select_for_onchain( + client: &Client, + target_amount: Amount, +) -> Result< + ( + Vec, + Vec, + ), + Error, +> +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + let boarding_outputs = client.boarding_outputs()?; + + let now = Timestamp::now(); + + let mut selected_boarding_outputs = HashSet::new(); + let mut selected_amount = Amount::ZERO; + + for boarding_output in boarding_outputs.iter() { + if target_amount <= selected_amount { + return Ok((selected_boarding_outputs.into_iter().collect(), Vec::new())); + } + + let outpoints = client + .blockchain() + .find_outpoints(boarding_output.address()) + .await?; + + for o in outpoints.iter() { + // Find outpoints for each boarding output. + if let ExplorerUtxo { + outpoint, + amount, + confirmation_blocktime: Some(confirmation_blocktime), + confirmations, + is_spent: false, + } = o + { + // For each confirmed outpoint, check if they can already be spent unilaterally + // using the exit path. + if boarding_output.can_be_claimed_unilaterally_by_owner( + now.as_duration().try_into().context("invalid now")?, + Duration::from_secs(*confirmation_blocktime), + *confirmations, + ) { + tracing::debug!(?outpoint, %amount, ?boarding_output, "Selected boarding output"); + + let script_pubkey = boarding_output.script_pubkey(); + let spend_selection = boarding_output.spend_selection(SpendPathKind::Exit)?; + + if selected_boarding_outputs.insert( + unilateral_exit::OnChainInput::new_with_spend_selection( + boarding_output.exit_delay(), + script_pubkey, + spend_selection, + *amount, + *outpoint, + ), + ) { + selected_amount += *amount; + } + } + } + } + } + + let mut selected_vtxo_outputs = HashSet::new(); + + for contract in client.active_offchain_contracts()? { + let vtxo = &contract.vtxo; + if target_amount <= selected_amount { + return Ok(( + selected_boarding_outputs.into_iter().collect(), + selected_vtxo_outputs.into_iter().collect(), + )); + } + + let outpoints = client.blockchain().find_outpoints(vtxo.address()).await?; + for o in outpoints.iter() { + // Find outpoints for each VTXO. + if let ExplorerUtxo { + outpoint, + amount, + confirmation_blocktime: Some(confirmation_blocktime), + confirmations, + is_spent: false, + } = o + { + // For each confirmed outpoint, check if they can already be spent unilaterally + // using the exit path. + if vtxo.can_be_claimed_unilaterally_by_owner( + now.as_duration().try_into().map_err(Error::ad_hoc)?, + Duration::from_secs(*confirmation_blocktime), + *confirmations, + ) { + tracing::debug!(?outpoint, %amount, ?vtxo, "Selected VTXO"); + + let script_pubkey = vtxo.script_pubkey(); + let spend_selection = contract.spend_selection(SpendPathKind::Exit)?; + + selected_vtxo_outputs.insert( + unilateral_exit::VtxoInput::new_with_spend_selection( + *outpoint, + vtxo.exit_delay(), + TxOut { + value: *amount, + script_pubkey, + }, + spend_selection, + ), + ); + selected_amount += *amount; + } + } + } + } + + if selected_amount < target_amount { + return Err(Error::coin_select(format!( + "insufficient funds: selected = {selected_amount}, needed = {target_amount}" + ))); + } + + Ok(( + selected_boarding_outputs.into_iter().collect(), + selected_vtxo_outputs.into_iter().collect(), + )) +} diff --git a/vendor/ark-client/src/contract.rs b/vendor/ark-client/src/contract.rs new file mode 100644 index 0000000..f2582fb --- /dev/null +++ b/vendor/ark-client/src/contract.rs @@ -0,0 +1,1455 @@ +use crate::Error; +use ark_core::contract::BoardingContract; +use ark_core::contract::ContractContext; +use ark_core::contract::ContractSpec; +use ark_core::contract::ContractState; +use ark_core::contract::ContractType; +use ark_core::contract::DefaultVtxoContract; +use ark_core::contract::DelegateVtxoContract; +use ark_core::contract::SpendSelection; +use ark_core::contract::StoredContract; +use ark_core::contract::VhtlcContract; +use ark_core::server; +use ark_core::server::VirtualTxOutPoint; +use ark_core::ArkAddress; +use ark_core::BoardingOutput; +use ark_core::Vtxo; +use bitcoin::Address; +use bitcoin::Amount; +use bitcoin::Network; +use bitcoin::Script; +use bitcoin::ScriptBuf; +use bitcoin::Sequence; +use bitcoin::XOnlyPublicKey; +use std::collections::HashMap; +use std::marker::PhantomData; +#[cfg(feature = "sqlite")] +use std::path::Path; +#[cfg(feature = "sqlite")] +use std::sync::Mutex; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +trait DynContractHandler: Send + Sync { + fn contract_type(&self) -> ContractType; + fn validate(&self, stored: &StoredContract, ctx: &ContractContext) -> Result<(), Error>; + fn spendable_selections( + &self, + stored: &StoredContract, + ctx: &ContractContext, + ) -> Result, Error>; +} + +struct ContractHandler { + _marker: PhantomData, +} + +impl Default for ContractHandler { + fn default() -> Self { + Self { + _marker: PhantomData, + } + } +} + +impl DynContractHandler for ContractHandler { + fn contract_type(&self) -> ContractType { + T::contract_type() + } + + fn validate(&self, stored: &StoredContract, ctx: &ContractContext) -> Result<(), Error> { + if stored.contract_type != T::contract_type() { + return Err(Error::ad_hoc("unexpected contract type")); + } + if stored.contract_version != T::VERSION { + return Err(Error::ad_hoc(format!( + "unsupported contract version: {}", + stored.contract_version + ))); + } + + let data: T = serde_json::from_value(stored.data.clone()) + .map_err(|e| Error::ad_hoc(format!("failed to decode contract data: {e}")))?; + let derived_script = data.script_pubkey(ctx)?; + if derived_script != stored.script_pubkey { + return Err(Error::ad_hoc("contract script mismatch")); + } + + Ok(()) + } + + fn spendable_selections( + &self, + stored: &StoredContract, + ctx: &ContractContext, + ) -> Result, Error> { + self.validate(stored, ctx)?; + let data: T = serde_json::from_value(stored.data.clone()) + .map_err(|e| Error::ad_hoc(format!("failed to decode contract data: {e}")))?; + data.spendable_selections(ctx).map_err(Into::into) + } +} + +#[derive(Default)] +pub struct ContractRegistry { + handlers: HashMap>, +} + +impl ContractRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn register(&mut self) -> Result<(), Error> { + let contract_type = T::contract_type(); + if self.handlers.contains_key(&contract_type) { + return Err(Error::ad_hoc(format!( + "contract handler already registered: {contract_type}" + ))); + } + let handler = Box::new(ContractHandler::::default()); + debug_assert_eq!(handler.contract_type(), contract_type); + self.handlers.insert(contract_type, handler); + Ok(()) + } + + fn handler_for(&self, contract_type: &ContractType) -> Result<&dyn DynContractHandler, Error> { + self.handlers + .get(contract_type) + .map(|handler| handler.as_ref()) + .ok_or_else(|| Error::ad_hoc(format!("unknown contract type: {contract_type}"))) + } +} + +/// Persistence backend for validated [`StoredContract`] rows. +/// +/// Stores are keyed by script pubkey. Higher-level compatibility for built-in same-script templates +/// is handled by [`ContractManager`], not by individual store implementations. +pub trait ContractStore: Send + Sync { + fn insert(&mut self, contract: StoredContract) -> Result<(), Error>; + fn get_by_script(&self, script_pubkey: &Script) -> Result, Error>; + fn list(&self) -> Result, Error>; + fn update_state(&mut self, script_pubkey: &Script, state: ContractState) -> Result<(), Error>; +} + +#[derive(Default)] +pub struct MemoryContractStore { + contracts: HashMap, +} + +impl MemoryContractStore { + pub fn new() -> Self { + Self::default() + } +} + +impl ContractStore for MemoryContractStore { + fn insert(&mut self, contract: StoredContract) -> Result<(), Error> { + if self.contracts.contains_key(&contract.script_pubkey) { + return Err(Error::ad_hoc("contract script already exists")); + } + self.contracts + .insert(contract.script_pubkey.clone(), contract); + Ok(()) + } + + fn get_by_script(&self, script_pubkey: &Script) -> Result, Error> { + Ok(self.contracts.get(script_pubkey).cloned()) + } + + fn list(&self) -> Result, Error> { + Ok(self.contracts.values().cloned().collect()) + } + + fn update_state(&mut self, script_pubkey: &Script, state: ContractState) -> Result<(), Error> { + let contract = self + .contracts + .get_mut(script_pubkey) + .ok_or_else(|| Error::ad_hoc("unknown contract script"))?; + contract.state = state; + Ok(()) + } +} + +#[cfg(feature = "sqlite")] +pub struct SqliteContractStore { + connection: Mutex, +} + +#[cfg(feature = "sqlite")] +impl SqliteContractStore { + pub fn new>(db_path: P) -> Result { + let db_path = db_path.as_ref(); + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + Error::consumer(format!("failed to create contract store directory: {e}")) + })?; + } + + let connection = rusqlite::Connection::open(db_path) + .map_err(|e| Error::consumer(format!("failed to open contract store: {e}")))?; + let store = Self { + connection: Mutex::new(connection), + }; + store.initialize()?; + Ok(store) + } + + pub fn new_default() -> Result { + Self::new("contracts.db") + } + + fn initialize(&self) -> Result<(), Error> { + let connection = self.connection()?; + connection + .execute_batch( + "CREATE TABLE IF NOT EXISTS contracts ( + script_pubkey BLOB PRIMARY KEY NOT NULL, + contract_type TEXT NOT NULL, + contract_version INTEGER NOT NULL, + state TEXT NOT NULL, + created_at INTEGER NOT NULL, + key_index INTEGER, + data TEXT NOT NULL + );", + ) + .map_err(|e| Error::consumer(format!("failed to initialize contract store: {e}")))?; + Ok(()) + } + + fn connection(&self) -> Result, Error> { + self.connection + .lock() + .map_err(|_| Error::ad_hoc("contract store connection lock poisoned")) + } + + fn state_to_str(state: ContractState) -> &'static str { + match state { + ContractState::Active => "active", + ContractState::Inactive => "inactive", + } + } + + fn state_from_str(value: &str) -> Result { + match value { + "active" => Ok(ContractState::Active), + "inactive" => Ok(ContractState::Inactive), + _ => Err(Error::ad_hoc(format!("unknown contract state: {value}"))), + } + } + + fn row_to_contract(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let script_pubkey: Vec = row.get("script_pubkey")?; + let contract_type: String = row.get("contract_type")?; + let contract_version: i64 = row.get("contract_version")?; + let state: String = row.get("state")?; + let created_at: i64 = row.get("created_at")?; + let key_index: Option = row.get("key_index")?; + let data: String = row.get("data")?; + + let contract_type = ContractType::new(contract_type).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e)) + })?; + let state = Self::state_from_str(&state).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Text, Box::new(e)) + })?; + let data = serde_json::from_str(&data).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e)) + })?; + + Ok(StoredContract { + contract_type, + contract_version: u32::try_from(contract_version).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Integer, + Box::new(e), + ) + })?, + script_pubkey: ScriptBuf::from_bytes(script_pubkey), + state, + created_at: u64::try_from(created_at).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 4, + rusqlite::types::Type::Integer, + Box::new(e), + ) + })?, + key_index: key_index + .map(|value| { + u32::try_from(value).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 5, + rusqlite::types::Type::Integer, + Box::new(e), + ) + }) + }) + .transpose()?, + data, + }) + } +} + +#[cfg(feature = "sqlite")] +impl ContractStore for SqliteContractStore { + fn insert(&mut self, contract: StoredContract) -> Result<(), Error> { + let data = serde_json::to_string(&contract.data) + .map_err(|e| Error::ad_hoc(format!("failed to encode contract data: {e}")))?; + let connection = self.connection()?; + connection + .execute( + "INSERT INTO contracts ( + script_pubkey, + contract_type, + contract_version, + state, + created_at, + key_index, + data + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + contract.script_pubkey.as_bytes(), + contract.contract_type.as_str(), + i64::from(contract.contract_version), + Self::state_to_str(contract.state), + i64::try_from(contract.created_at).map_err(|e| Error::ad_hoc(format!( + "contract created_at does not fit sqlite integer: {e}" + )))?, + contract.key_index.map(i64::from), + data, + ], + ) + .map_err(|e| { + if matches!(e, rusqlite::Error::SqliteFailure(ref err, _) if err.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY) + { + Error::ad_hoc("contract script already exists") + } else { + Error::consumer(format!("failed to insert contract: {e}")) + } + })?; + Ok(()) + } + + fn get_by_script(&self, script_pubkey: &Script) -> Result, Error> { + let connection = self.connection()?; + let mut statement = connection + .prepare( + "SELECT script_pubkey, contract_type, contract_version, state, created_at, key_index, data + FROM contracts + WHERE script_pubkey = ?1", + ) + .map_err(|e| Error::consumer(format!("failed to prepare contract lookup: {e}")))?; + let mut rows = statement + .query(rusqlite::params![script_pubkey.as_bytes()]) + .map_err(|e| Error::consumer(format!("failed to lookup contract: {e}")))?; + let Some(row) = rows + .next() + .map_err(|e| Error::consumer(format!("failed to read contract: {e}")))? + else { + return Ok(None); + }; + Self::row_to_contract(row) + .map(Some) + .map_err(|e| Error::consumer(format!("failed to decode contract: {e}"))) + } + + fn list(&self) -> Result, Error> { + let connection = self.connection()?; + let mut statement = connection + .prepare( + "SELECT script_pubkey, contract_type, contract_version, state, created_at, key_index, data + FROM contracts + ORDER BY created_at, rowid", + ) + .map_err(|e| Error::consumer(format!("failed to prepare contract list: {e}")))?; + let rows = statement + .query_map([], Self::row_to_contract) + .map_err(|e| Error::consumer(format!("failed to list contracts: {e}")))?; + rows.collect::>>() + .map_err(|e| Error::consumer(format!("failed to decode contracts: {e}"))) + } + + fn update_state(&mut self, script_pubkey: &Script, state: ContractState) -> Result<(), Error> { + let connection = self.connection()?; + let updated = connection + .execute( + "UPDATE contracts SET state = ?1 WHERE script_pubkey = ?2", + rusqlite::params![Self::state_to_str(state), script_pubkey.as_bytes()], + ) + .map_err(|e| Error::consumer(format!("failed to update contract state: {e}")))?; + if updated == 0 { + return Err(Error::ad_hoc("unknown contract script")); + } + Ok(()) + } +} + +/// A VTXO returned by wallet surfaces, enriched with its stored contract metadata. +/// +/// Use [`Self::spend_selection`] to build transaction inputs. The underlying VTXO and contract are +/// exposed read-only for wallet UX and advanced integrations. +#[derive(Clone, Debug, PartialEq)] +pub struct AnnotatedVtxo { + contract: StoredContract, + vtxo: VirtualTxOutPoint, + spend_selections: Vec, +} + +impl AnnotatedVtxo { + pub(crate) fn new( + contract: StoredContract, + vtxo: VirtualTxOutPoint, + spend_selections: Vec, + ) -> Self { + Self { + contract, + vtxo, + spend_selections, + } + } + + pub fn contract(&self) -> &StoredContract { + &self.contract + } + + pub fn vtxo(&self) -> &VirtualTxOutPoint { + &self.vtxo + } + + pub fn spend_selections(&self) -> &[SpendSelection] { + &self.spend_selections + } + + pub fn spend_selection( + &self, + kind: ark_core::contract::SpendPathKind, + ) -> Result { + self.spend_selections + .iter() + .find(|selection| selection.path.kind == kind) + .cloned() + .ok_or_else(|| Error::ad_hoc(format!("missing {kind:?} spend path"))) + } + + pub fn tapscripts(&self) -> Vec { + self.spend_selections + .iter() + .map(|selection| selection.path.script.clone()) + .collect() + } + + pub fn script_pubkey(&self) -> ScriptBuf { + self.contract.script_pubkey.clone() + } + + pub fn server_pk(&self) -> Result { + Ok(self.vtxo_contract_data()?.server) + } + + pub fn owner_pk(&self) -> Result { + Ok(self.vtxo_contract_data()?.owner) + } + + pub fn exit_delay(&self) -> Result { + Ok(self.vtxo_contract_data()?.exit_delay) + } + + fn vtxo_contract_data(&self) -> Result { + offchain_vtxo_data(&self.contract) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct VtxoContractData { + server: XOnlyPublicKey, + owner: XOnlyPublicKey, + exit_delay: Sequence, +} + +/// A boarding output returned by wallet surfaces, enriched with its stored contract metadata. +/// +/// Use [`Self::spend_selection`] to build transaction inputs. The underlying output and contract +/// are exposed read-only for wallet UX and advanced integrations. +#[derive(Clone, Debug, PartialEq)] +pub struct AnnotatedBoardingOutput { + contract: StoredContract, + output: BoardingOutput, + spend_selections: Vec, +} + +impl AnnotatedBoardingOutput { + pub(crate) fn new( + contract: StoredContract, + output: BoardingOutput, + spend_selections: Vec, + ) -> Self { + Self { + contract, + output, + spend_selections, + } + } + + pub fn contract(&self) -> &StoredContract { + &self.contract + } + + pub fn output(&self) -> &BoardingOutput { + &self.output + } + + pub fn spend_selections(&self) -> &[SpendSelection] { + &self.spend_selections + } + + pub fn spend_selection( + &self, + kind: ark_core::contract::SpendPathKind, + ) -> Result { + self.spend_selections + .iter() + .find(|selection| selection.path.kind == kind) + .cloned() + .ok_or_else(|| Error::ad_hoc(format!("missing {kind:?} spend path"))) + } + + pub fn tapscripts(&self) -> Vec { + self.spend_selections + .iter() + .map(|selection| selection.path.script.clone()) + .collect() + } + + pub fn address(&self) -> &Address { + self.output.address() + } + + pub fn script_pubkey(&self) -> ScriptBuf { + self.contract.script_pubkey.clone() + } + + pub fn server_pk(&self) -> XOnlyPublicKey { + self.output.server_pk() + } + + pub fn owner_pk(&self) -> XOnlyPublicKey { + self.output.owner_pk() + } + + pub fn exit_delay(&self) -> Sequence { + self.output.exit_delay() + } + + pub fn can_be_claimed_unilaterally_by_owner( + &self, + now: std::time::Duration, + confirmation_blocktime: std::time::Duration, + confirmations: u64, + ) -> bool { + self.output + .can_be_claimed_unilaterally_by_owner(now, confirmation_blocktime, confirmations) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ActiveOffchainContract { + pub address: ArkAddress, + pub vtxo: Vtxo, + pub spend_selections: Vec, +} + +impl ActiveOffchainContract { + pub fn spend_selection( + &self, + kind: ark_core::contract::SpendPathKind, + ) -> Result { + self.spend_selections + .iter() + .find(|selection| selection.path.kind == kind) + .cloned() + .ok_or_else(|| Error::ad_hoc(format!("missing {kind:?} spend path"))) + } +} + +#[derive(Clone, Debug)] +pub struct AnnotatedVtxoList { + dust: Amount, + vtxos: Vec, +} + +impl AnnotatedVtxoList { + pub fn new(dust: Amount, vtxos: Vec) -> Self { + Self { dust, vtxos } + } + + pub fn into_inner(self) -> Vec { + self.vtxos + } + + pub fn all(&self) -> impl Iterator { + self.vtxos.iter() + } + + pub fn all_unspent(&self) -> impl Iterator { + let dust = self.dust; + self.vtxos + .iter() + .filter(move |entry| entry.vtxo.is_unspent(dust)) + } + + pub fn spendable_offchain(&self) -> impl Iterator { + let dust = self.dust; + self.vtxos + .iter() + .filter(move |entry| entry.vtxo.is_spendable_offchain(dust)) + } + + pub fn spendable_offchain_at<'a>( + &'a self, + server_info: &'a server::Info, + now_unix_secs: i64, + ) -> impl Iterator + 'a { + self.spendable_offchain().filter(move |entry| { + !entry + .server_pk() + .map(|server_pk| server_info.signer_requires_recovery_at(server_pk, now_unix_secs)) + .unwrap_or(false) + }) + } + + pub fn pending_recovery_due_to_signer_at<'a>( + &'a self, + server_info: &'a server::Info, + now_unix_secs: i64, + ) -> impl Iterator + 'a { + self.spendable_offchain().filter(move |entry| { + entry + .server_pk() + .map(|server_pk| server_info.signer_requires_recovery_at(server_pk, now_unix_secs)) + .unwrap_or(false) + }) + } + + pub fn batch_settleable_at<'a>( + &'a self, + server_info: &'a server::Info, + now_unix_secs: i64, + ) -> impl Iterator + 'a { + self.all_unspent().filter(move |entry| { + entry.vtxo.is_recoverable(server_info.dust) + || !entry + .server_pk() + .map(|server_pk| { + server_info.signer_requires_recovery_at(server_pk, now_unix_secs) + }) + .unwrap_or(false) + }) + } + + pub fn pre_confirmed(&self) -> impl Iterator { + let dust = self.dust; + self.vtxos + .iter() + .filter(move |entry| entry.vtxo.is_pre_confirmed_spendable(dust)) + } + + pub fn confirmed(&self) -> impl Iterator { + let dust = self.dust; + self.vtxos + .iter() + .filter(move |entry| entry.vtxo.is_confirmed_spendable(dust)) + } + + pub fn recoverable(&self) -> impl Iterator { + self.vtxos + .iter() + .filter(move |entry| entry.vtxo.is_recoverable(self.dust)) + } + + pub fn could_exit_unilaterally(&self) -> impl Iterator { + self.pre_confirmed().chain(self.confirmed()) + } + + pub fn spent(&self) -> impl Iterator { + let dust = self.dust; + self.vtxos + .iter() + .filter(move |entry| entry.vtxo.is_spent_status(dust)) + } +} + +/// Registry and persistence layer for built-in and custom Ark contracts. +/// +/// `ContractManager` is a public extension point: consumers can register custom +/// [`ContractSpec`] implementations while reusing the SDK's validation, persistence, and spend +/// selection plumbing. The low-level row APIs expose stored contracts keyed by script; +/// wallet-facing code should prefer semantic annotated outputs such as [`AnnotatedVtxo`] and +/// [`AnnotatedBoardingOutput`]. +pub struct ContractManager { + network: Network, + registry: ContractRegistry, + store: Box, +} + +impl ContractManager { + pub fn new(network: Network, store: Box) -> Self { + Self { + network, + registry: ContractRegistry::new(), + store, + } + } + + pub fn new_with_builtins( + network: Network, + store: Box, + ) -> Result { + let mut manager = Self::new(network, store); + manager.register_builtins()?; + Ok(manager) + } + + pub fn in_memory(network: Network) -> Self { + Self::new(network, Box::new(MemoryContractStore::new())) + } + + pub fn in_memory_with_builtins(network: Network) -> Result { + Self::new_with_builtins(network, Box::new(MemoryContractStore::new())) + } + + pub fn network(&self) -> Network { + self.network + } + + pub fn register(&mut self) -> Result<(), Error> { + self.registry.register::() + } + + pub fn register_builtins(&mut self) -> Result<(), Error> { + self.register::()?; + self.register::()?; + self.register::()?; + self.register::() + } + + pub fn insert( + &mut self, + contract: T, + state: ContractState, + key_index: Option, + ) -> Result { + let stored = self.stored_contract(contract, state, key_index)?; + self.store.insert(stored.clone())?; + Ok(stored) + } + + pub fn insert_or_get( + &mut self, + contract: T, + state: ContractState, + key_index: Option, + ) -> Result { + let stored = self.stored_contract(contract, state, key_index)?; + + match self.store.get_by_script(&stored.script_pubkey)? { + None => { + self.store.insert(stored.clone())?; + Ok(stored) + } + Some(existing) if same_stored_contract(&existing, &stored) => Ok(existing), + Some(existing) if can_share_script_row(&existing, &stored)? => Ok(existing), + Some(_) => Err(Error::ad_hoc( + "contract script already exists with different data", + )), + } + } + + fn stored_contract( + &self, + contract: T, + state: ContractState, + key_index: Option, + ) -> Result { + let ctx = ContractContext::new(self.network); + let stored = StoredContract { + contract_type: T::contract_type(), + contract_version: T::VERSION, + script_pubkey: contract.script_pubkey(&ctx)?, + state, + created_at: now_secs()?, + key_index, + data: serde_json::to_value(contract) + .map_err(|e| Error::ad_hoc(format!("failed to encode contract data: {e}")))?, + }; + + let handler = self.registry.handler_for(&stored.contract_type)?; + handler.validate(&stored, &ctx)?; + Ok(stored) + } + + pub fn insert_stored(&mut self, stored: StoredContract) -> Result<(), Error> { + let ctx = ContractContext::new(self.network); + let handler = self.registry.handler_for(&stored.contract_type)?; + handler.validate(&stored, &ctx)?; + self.store.insert(stored) + } + + pub fn get(&self, script_pubkey: &Script) -> Result, Error> { + self.store.get_by_script(script_pubkey) + } + + pub fn get_typed(&self, script_pubkey: &Script) -> Result, Error> { + let Some(stored) = self.store.get_by_script(script_pubkey)? else { + return Ok(None); + }; + + if stored.contract_type != T::contract_type() { + return Err(Error::ad_hoc("unexpected contract type")); + } + if stored.contract_version != T::VERSION { + return Err(Error::ad_hoc(format!( + "unsupported contract version: {}", + stored.contract_version + ))); + } + + serde_json::from_value(stored.data) + .map(Some) + .map_err(|e| Error::ad_hoc(format!("failed to decode contract data: {e}"))) + } + + pub fn list(&self) -> Result, Error> { + self.store.list() + } + + pub fn list_by_type(&self, contract_type: ContractType) -> Result, Error> { + Ok(self + .store + .list()? + .into_iter() + .filter(|contract| contract.contract_type == contract_type) + .collect()) + } + + pub fn list_active_by_type( + &self, + contract_type: ContractType, + ) -> Result, Error> { + Ok(self + .list_by_type(contract_type)? + .into_iter() + .filter(|contract| contract.state == ContractState::Active) + .collect()) + } + + pub fn update_state( + &mut self, + script_pubkey: &Script, + state: ContractState, + ) -> Result<(), Error> { + self.store.update_state(script_pubkey, state) + } + + pub fn spendable_selections( + &self, + stored: &StoredContract, + ) -> Result, Error> { + let ctx = ContractContext::new(self.network); + let handler = self.registry.handler_for(&stored.contract_type)?; + handler.spendable_selections(stored, &ctx) + } + + pub(crate) fn active_offchain_contracts( + &self, + unilateral_exit_delay_candidates: &[Sequence], + ) -> Result, Error> { + let ctx = ContractContext::new(self.network); + self.store + .list()? + .into_iter() + .filter(|stored| stored.state == ContractState::Active) + .filter_map(|stored| { + match active_offchain_contract_from_stored( + self, + &ctx, + stored, + unilateral_exit_delay_candidates, + ) { + Ok(Some(contract)) => Some(Ok(contract)), + Ok(None) => None, + Err(e) => Some(Err(e)), + } + }) + .collect() + } + + pub fn annotate_vtxos( + &self, + vtxos: Vec, + ) -> Result, Error> { + vtxos + .into_iter() + .map(|vtxo| { + let contract = self + .store + .get_by_script(&vtxo.script)? + .ok_or_else(|| Error::ad_hoc("unknown contract script"))?; + let spend_selections = self.spendable_selections(&contract)?; + Ok(AnnotatedVtxo::new(contract, vtxo, spend_selections)) + }) + .collect() + } + + /// Return active boarding outputs, including compatible default VTXO rows. + /// + /// The store keeps one row per script. If a default VTXO row was stored before an equivalent + /// boarding row, on-chain boarding discovery must still see it as a boarding output. Default + /// VTXO rows are included only when their CSV delay is one of the caller's boarding delay + /// candidates. Passing an empty slice means "strict boarding rows only". + pub(crate) fn annotated_boarding_outputs_for_exit_delays( + &self, + compatible_default_exit_delays: &[Sequence], + ) -> Result, Error> { + let ctx = ContractContext::new(self.network); + self.store + .list()? + .into_iter() + .filter(|stored| stored.state == ContractState::Active) + .filter_map(|stored| { + boarding_contract_from_stored(&stored, compatible_default_exit_delays) + .map(|contract| (stored, contract)) + }) + .map(|(stored, contract)| { + let output = contract.boarding_output(&ctx)?; + let spend_selections = self.spendable_selections(&stored)?; + Ok(AnnotatedBoardingOutput::new( + stored, + output, + spend_selections, + )) + }) + .collect() + } + + pub fn annotated_boarding_outputs(&self) -> Result, Error> { + self.annotated_boarding_outputs_for_exit_delays(&[]) + } +} + +fn same_stored_contract(a: &StoredContract, b: &StoredContract) -> bool { + a.contract_type == b.contract_type + && a.contract_version == b.contract_version + && a.data == b.data +} + +/// Whether two same-script rows may use the row that was stored first. +/// +/// This is intentionally limited to default VTXO/boarding rows that decode to the same two-leaf +/// server+owner/CSV template. Delegate and VHTLC scripts carry different leaves/semantics and a +/// same-script collision with them should remain a hard error. +fn can_share_script_row(a: &StoredContract, b: &StoredContract) -> Result { + let default_vtxo_boarding = a.contract_type == ContractType::default_vtxo() + && b.contract_type == ContractType::boarding(); + let boarding_default_vtxo = a.contract_type == ContractType::boarding() + && b.contract_type == ContractType::default_vtxo(); + if !default_vtxo_boarding && !boarding_default_vtxo { + return Ok(false); + } + + // Store only one row for a script. Allow default VTXO and boarding to share that row only + // when the decoded script template is identical. + Ok(two_leaf_vtxo_data(a)? == two_leaf_vtxo_data(b)?) +} + +fn active_offchain_contract_from_stored( + manager: &ContractManager, + ctx: &ContractContext, + stored: StoredContract, + unilateral_exit_delay_candidates: &[Sequence], +) -> Result, Error> { + if stored.contract_type == ContractType::delegate_vtxo() { + let contract: DelegateVtxoContract = serde_json::from_value(stored.data.clone()) + .map_err(|e| Error::ad_hoc(format!("failed to decode delegate vtxo contract: {e}")))?; + return Ok(Some(active_offchain_contract( + manager, + &stored, + contract.vtxo(ctx)?, + )?)); + } + + if stored.contract_type != ContractType::default_vtxo() + && stored.contract_type != ContractType::boarding() + { + return Ok(None); + } + + let data = two_leaf_vtxo_data(&stored)?; + + // A boarding row can also represent an offchain default VTXO row for the same script, but only + // when its CSV delay is one of the delays used for unilateral-exit VTXOs. Other boarding rows + // must not be queried as Arkade receive addresses. + if stored.contract_type == ContractType::boarding() + && !unilateral_exit_delay_candidates.contains(&data.exit_delay) + { + return Ok(None); + } + + let contract = DefaultVtxoContract { + server: data.server, + owner: data.owner, + exit_delay: data.exit_delay, + }; + Ok(Some(active_offchain_contract( + manager, + &stored, + contract.vtxo(ctx)?, + )?)) +} + +fn active_offchain_contract( + manager: &ContractManager, + stored: &StoredContract, + vtxo: Vtxo, +) -> Result { + Ok(ActiveOffchainContract { + address: vtxo.to_ark_address(), + vtxo, + spend_selections: manager.spendable_selections(stored)?, + }) +} + +fn offchain_vtxo_data(stored: &StoredContract) -> Result { + if stored.contract_type == ContractType::delegate_vtxo() { + return delegate_vtxo_data(stored); + } + two_leaf_vtxo_data(stored) +} + +fn delegate_vtxo_data(stored: &StoredContract) -> Result { + if stored.contract_type != ContractType::delegate_vtxo() { + return Err(Error::ad_hoc(format!( + "contract type {} is not a delegate vtxo contract", + stored.contract_type + ))); + } + let contract: DelegateVtxoContract = serde_json::from_value(stored.data.clone()) + .map_err(|e| Error::ad_hoc(format!("failed to decode delegate vtxo contract: {e}")))?; + Ok(VtxoContractData { + server: contract.server, + owner: contract.owner, + exit_delay: contract.exit_delay, + }) +} + +/// Decode rows that use the shared two-leaf default VTXO/boarding template. +/// +/// Both contract types produce the same spend paths when server, owner and CSV delay match. This +/// helper is the single place that treats them as the same template; callers decide whether that +/// template is being used as an offchain VTXO or as an on-chain boarding output. +fn two_leaf_vtxo_data(stored: &StoredContract) -> Result { + if stored.contract_type == ContractType::default_vtxo() { + let contract: DefaultVtxoContract = serde_json::from_value(stored.data.clone()) + .map_err(|e| Error::ad_hoc(format!("failed to decode default vtxo contract: {e}")))?; + return Ok(VtxoContractData { + server: contract.server, + owner: contract.owner, + exit_delay: contract.exit_delay, + }); + } + if stored.contract_type == ContractType::boarding() { + let contract: BoardingContract = serde_json::from_value(stored.data.clone()) + .map_err(|e| Error::ad_hoc(format!("failed to decode boarding contract: {e}")))?; + return Ok(VtxoContractData { + server: contract.server, + owner: contract.owner, + exit_delay: contract.exit_delay, + }); + } + Err(Error::ad_hoc(format!( + "contract type {} is not a two-leaf vtxo contract", + stored.contract_type + ))) +} + +/// Resolve a stored row into boarding semantics when safe. +/// +/// Real boarding rows always qualify. Default VTXO rows qualify only as a script-sharing fallback +/// and only for the boarding exit-delay candidates supplied by the caller; otherwise every default +/// VTXO row would incorrectly appear as an on-chain boarding address. +fn boarding_contract_from_stored( + stored: &StoredContract, + compatible_default_vtxo_exit_delays: &[Sequence], +) -> Option { + let data = two_leaf_vtxo_data(stored).ok()?; + + // A default VTXO row can also represent a boarding row for the same script, but only when the + // caller is explicitly watching that CSV delay as a boarding delay. + if stored.contract_type == ContractType::boarding() + || compatible_default_vtxo_exit_delays.contains(&data.exit_delay) + { + return Some(BoardingContract { + server: data.server, + owner: data.owner, + exit_delay: data.exit_delay, + }); + } + + None +} + +fn now_secs() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|e| Error::ad_hoc(format!("system clock before unix epoch: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_core::contract::SpendPathKind; + use bitcoin::Amount; + use bitcoin::OutPoint; + use bitcoin::Sequence; + use bitcoin::XOnlyPublicKey; + use std::str::FromStr; + + fn test_keys() -> (XOnlyPublicKey, XOnlyPublicKey, XOnlyPublicKey) { + let server = XOnlyPublicKey::from_str( + "18845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", + ) + .unwrap(); + let owner = XOnlyPublicKey::from_str( + "28845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", + ) + .unwrap(); + let delegator = XOnlyPublicKey::from_str( + "38845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", + ) + .unwrap(); + (server, owner, delegator) + } + + #[test] + fn stores_and_dispatches_default_contract() { + let (server, owner, _) = test_keys(); + let mut manager = ContractManager::in_memory(Network::Regtest); + manager.register_builtins().unwrap(); + + let contract = DefaultVtxoContract { + server, + owner, + exit_delay: Sequence::from_seconds_ceil(86400).unwrap(), + }; + let stored = manager + .insert(contract.clone(), ContractState::Active, Some(7)) + .unwrap(); + + assert_eq!(stored.contract_type, ContractType::default_vtxo()); + assert_eq!(stored.key_index, Some(7)); + assert_eq!( + manager.get(&stored.script_pubkey).unwrap(), + Some(stored.clone()) + ); + assert_eq!( + manager + .get_typed::(&stored.script_pubkey) + .unwrap(), + Some(contract) + ); + + let selections = manager.spendable_selections(&stored).unwrap(); + assert_eq!(selections.len(), 2); + assert!(selections + .iter() + .all(|selection| !selection.path.script.is_empty())); + } + + #[test] + fn annotates_vtxos_with_contract_spend_paths() { + let (server, owner, _) = test_keys(); + let mut manager = ContractManager::in_memory(Network::Regtest); + manager.register_builtins().unwrap(); + + let contract = DefaultVtxoContract { + server, + owner, + exit_delay: Sequence::from_seconds_ceil(86400).unwrap(), + }; + let stored = manager + .insert(contract, ContractState::Active, Some(7)) + .unwrap(); + let vtxo = VirtualTxOutPoint { + outpoint: OutPoint::null(), + created_at: 0, + expires_at: 0, + amount: Amount::from_sat(42_000), + script: stored.script_pubkey.clone(), + is_preconfirmed: false, + is_swept: false, + is_unrolled: false, + is_spent: false, + spent_by: None, + commitment_txids: Vec::new(), + settled_by: None, + ark_txid: None, + assets: Vec::new(), + depth: 0, + }; + + let annotated = manager.annotate_vtxos(vec![vtxo.clone()]).unwrap(); + + assert_eq!(annotated.len(), 1); + assert_eq!(annotated[0].contract, stored); + assert_eq!(annotated[0].vtxo, vtxo); + assert_eq!(annotated[0].spend_selections.len(), 2); + } + + #[test] + fn annotates_boarding_outputs_with_contract_spend_paths() { + let (server, owner, _) = test_keys(); + let mut manager = ContractManager::in_memory(Network::Regtest); + manager.register_builtins().unwrap(); + + let contract = BoardingContract { + server, + owner, + exit_delay: Sequence::from_seconds_ceil(86400).unwrap(), + }; + let stored = manager + .insert(contract, ContractState::Active, Some(7)) + .unwrap(); + + let annotated = manager.annotated_boarding_outputs().unwrap(); + + assert_eq!(annotated.len(), 1); + assert_eq!(annotated[0].contract, stored); + assert_eq!(annotated[0].script_pubkey(), stored.script_pubkey); + assert_eq!(annotated[0].server_pk(), server); + assert_eq!(annotated[0].owner_pk(), owner); + assert_eq!(annotated[0].spend_selections.len(), 2); + assert!(annotated[0].spend_selection(SpendPathKind::Forfeit).is_ok()); + assert!(annotated[0].spend_selection(SpendPathKind::Exit).is_ok()); + } + + #[test] + fn default_vtxo_and_boarding_can_share_script_row() { + let (server, owner, _) = test_keys(); + let mut manager = ContractManager::in_memory(Network::Regtest); + manager.register_builtins().unwrap(); + let exit_delay = Sequence::from_seconds_ceil(86400).unwrap(); + + let default = DefaultVtxoContract { + server, + owner, + exit_delay, + }; + let boarding = BoardingContract { + server, + owner, + exit_delay, + }; + + let stored_default = manager + .insert_or_get(default, ContractState::Active, Some(7)) + .unwrap(); + let stored_boarding = manager + .insert_or_get(boarding, ContractState::Active, Some(7)) + .unwrap(); + + assert_eq!(stored_boarding, stored_default); + assert_eq!(stored_default.contract_type, ContractType::default_vtxo()); + assert_eq!(manager.list().unwrap().len(), 1); + + let boarding_outputs = manager + .annotated_boarding_outputs_for_exit_delays(&[exit_delay]) + .unwrap(); + assert_eq!(boarding_outputs.len(), 1); + assert_eq!(boarding_outputs[0].contract, stored_default); + assert_eq!(boarding_outputs[0].server_pk(), server); + assert_eq!(boarding_outputs[0].owner_pk(), owner); + } + + #[test] + fn malformed_builtin_contract_fails_vtxo_annotation() { + let (server, owner, _) = test_keys(); + let ctx = ContractContext::new(Network::Regtest); + let script_pubkey = DefaultVtxoContract { + server, + owner, + exit_delay: Sequence::from_height(10), + } + .script_pubkey(&ctx) + .unwrap(); + let mut store = MemoryContractStore::default(); + let stored = StoredContract { + contract_type: ContractType::default_vtxo(), + contract_version: DefaultVtxoContract::VERSION, + script_pubkey, + state: ContractState::Active, + created_at: 0, + key_index: None, + data: serde_json::json!({"bad": "shape"}), + }; + store.insert(stored.clone()).unwrap(); + let mut manager = ContractManager::new(Network::Regtest, Box::new(store)); + manager.register_builtins().unwrap(); + let vtxo = VirtualTxOutPoint { + outpoint: OutPoint::null(), + created_at: 0, + expires_at: 0, + amount: Amount::from_sat(42_000), + script: stored.script_pubkey, + is_preconfirmed: false, + is_swept: false, + is_unrolled: false, + is_spent: false, + spent_by: None, + commitment_txids: Vec::new(), + settled_by: None, + ark_txid: None, + assets: Vec::new(), + depth: 0, + }; + + let err = manager.annotate_vtxos(vec![vtxo]).unwrap_err(); + + assert!( + format!("{err:?}").contains("failed to decode contract data"), + "{err:?}" + ); + } + + #[test] + fn boarding_contract_can_annotate_offchain_vtxo() { + let (server, owner, _) = test_keys(); + let mut manager = ContractManager::in_memory(Network::Regtest); + manager.register_builtins().unwrap(); + let exit_delay = Sequence::from_seconds_ceil(86400).unwrap(); + + let stored = manager + .insert_or_get( + BoardingContract { + server, + owner, + exit_delay, + }, + ContractState::Active, + Some(7), + ) + .unwrap(); + let vtxo = VirtualTxOutPoint { + outpoint: OutPoint::null(), + created_at: 0, + expires_at: 0, + amount: Amount::from_sat(42_000), + script: stored.script_pubkey.clone(), + is_preconfirmed: false, + is_swept: false, + is_unrolled: false, + is_spent: false, + spent_by: None, + commitment_txids: Vec::new(), + settled_by: None, + ark_txid: None, + assets: Vec::new(), + depth: 0, + }; + + let annotated = manager.annotate_vtxos(vec![vtxo]).unwrap(); + + assert_eq!(annotated.len(), 1); + assert_eq!(annotated[0].contract, stored); + assert_eq!(annotated[0].server_pk().unwrap(), server); + assert_eq!(annotated[0].owner_pk().unwrap(), owner); + assert_eq!(annotated[0].exit_delay().unwrap(), exit_delay); + } + + #[cfg(feature = "sqlite")] + #[test] + fn sqlite_store_persists_contracts() { + let (server, owner, _) = test_keys(); + let tempdir = tempfile::tempdir().unwrap(); + let db_path = tempdir.path().join("contracts.db"); + let mut manager = ContractManager::new( + Network::Regtest, + Box::new(SqliteContractStore::new(&db_path).unwrap()), + ); + manager.register_builtins().unwrap(); + + let contract = DefaultVtxoContract { + server, + owner, + exit_delay: Sequence::from_seconds_ceil(86400).unwrap(), + }; + let stored = manager + .insert(contract, ContractState::Active, Some(7)) + .unwrap(); + manager + .update_state(&stored.script_pubkey, ContractState::Inactive) + .unwrap(); + + let mut reopened = ContractManager::new( + Network::Regtest, + Box::new(SqliteContractStore::new(&db_path).unwrap()), + ); + reopened.register_builtins().unwrap(); + + let persisted = reopened.get(&stored.script_pubkey).unwrap().unwrap(); + assert_eq!(persisted.state, ContractState::Inactive); + assert_eq!(persisted.contract_type, ContractType::default_vtxo()); + assert_eq!(persisted.key_index, Some(7)); + assert_eq!(persisted.data, stored.data); + assert_eq!(reopened.list().unwrap().len(), 1); + } + + #[test] + fn store_enforces_script_uniqueness() { + let (server, owner, _) = test_keys(); + let mut manager = ContractManager::in_memory(Network::Regtest); + manager.register_builtins().unwrap(); + let contract = DefaultVtxoContract { + server, + owner, + exit_delay: Sequence::from_seconds_ceil(86400).unwrap(), + }; + + manager + .insert(contract.clone(), ContractState::Active, None) + .unwrap(); + assert!(manager + .insert(contract, ContractState::Active, None) + .is_err()); + } + + #[test] + fn validates_script_mismatch() { + let (server, owner, delegator) = test_keys(); + let mut manager = ContractManager::in_memory(Network::Regtest); + manager.register_builtins().unwrap(); + let default = DefaultVtxoContract { + server, + owner, + exit_delay: Sequence::from_seconds_ceil(86400).unwrap(), + }; + let delegate = DelegateVtxoContract { + server, + owner, + delegator, + exit_delay: Sequence::from_seconds_ceil(86400).unwrap(), + }; + let ctx = ContractContext::new(Network::Regtest); + let stored = StoredContract { + contract_type: ContractType::default_vtxo(), + contract_version: DefaultVtxoContract::VERSION, + script_pubkey: delegate.script_pubkey(&ctx).unwrap(), + state: ContractState::Active, + created_at: 0, + key_index: None, + data: serde_json::to_value(default).unwrap(), + }; + + assert!(manager.insert_stored(stored).is_err()); + } +} diff --git a/vendor/ark-client/src/error.rs b/vendor/ark-client/src/error.rs new file mode 100644 index 0000000..24e38e2 --- /dev/null +++ b/vendor/ark-client/src/error.rs @@ -0,0 +1,357 @@ +use std::error::Error as StdError; +use std::fmt; + +type Source = Box; + +#[derive(Debug)] +pub struct Error { + inner: Box, +} + +#[derive(Debug)] +struct ErrorImpl { + kind: Kind, + cause: Option, +} + +#[derive(Debug)] +enum Kind { + /// Ad-hoc error, + AdHoc(AdHocError), + /// An error related to interactions with the Ark server. + ArkServer(ArkServerError), + /// An error from [`ark_core`]. + Core(CoreError), + /// An error related to coin selection of VTXOs and boarding outputs. + CoinSelect(CoinSelectError), + /// An error related to actions within the wallet. + Wallet(WalletError), + /// Ark server info changed while processing a request. + ServerInfoChanged(ServerInfoChangedError), + /// An error thrown by a user of this library + Consumer(ConsumerError), +} + +#[derive(Debug)] +struct AdHocError { + source: Source, +} + +#[derive(Debug)] +struct ArkServerError { + source: Source, +} + +#[derive(Debug)] +struct CoreError { + source: ark_core::Error, +} + +#[derive(Debug)] +struct CoinSelectError { + source: Source, +} + +#[derive(Debug)] +struct WalletError { + source: Source, +} + +#[derive(Debug)] +struct ServerInfoChangedError; + +#[derive(Debug)] +struct ConsumerError { + source: Source, +} + +impl Error { + fn new(kind: Kind) -> Self { + Self { + inner: Box::new(ErrorImpl { kind, cause: None }), + } + } + + pub(crate) fn ad_hoc(source: impl Into) -> Self { + Error::new(Kind::AdHoc(AdHocError { + source: source.into(), + })) + } + + pub(crate) fn ark_server(source: impl Into) -> Self { + Error::new(Kind::ArkServer(ArkServerError { + source: source.into(), + })) + } + + pub(crate) fn coin_select(source: impl Into) -> Self { + Error::new(Kind::CoinSelect(CoinSelectError { + source: source.into(), + })) + } + + pub fn wallet(source: impl Into) -> Self { + Error::new(Kind::Wallet(WalletError { + source: source.into(), + })) + } + + pub fn consumer(source: impl Into) -> Self { + Error::new(Kind::Consumer(ConsumerError { + source: source.into(), + })) + } + + pub(crate) fn server_info_changed(source: impl Into) -> Self { + source + .into() + .context(Error::new(Kind::ServerInfoChanged(ServerInfoChangedError))) + } + + pub fn is_server_info_changed(&self) -> bool { + let mut err = self; + loop { + if matches!(err.inner.kind, Kind::ServerInfoChanged(_)) { + return true; + } + err = match err.inner.cause.as_ref() { + Some(err) => err, + None => return false, + }; + } + } + + /// Returns `true` if this error chain contains an arkd build-version mismatch. + pub fn is_version_mismatch(&self) -> bool { + let mut err = self; + loop { + if err.inner.kind.is_version_mismatch() { + return true; + } + err = match err.inner.cause.as_ref() { + Some(err) => err, + None => return false, + }; + } + } +} + +impl Kind { + fn is_version_mismatch(&self) -> bool { + match self { + Kind::ArkServer(err) => { + err.source.is::() + && err + .source + .downcast_ref::() + .is_some_and(ark_grpc::Error::is_version_mismatch) + } + Kind::AdHoc(_) + | Kind::Core(_) + | Kind::CoinSelect(_) + | Kind::Wallet(_) + | Kind::ServerInfoChanged(_) + | Kind::Consumer(_) => false, + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut err = self; + loop { + write!(f, "{}", err.inner.kind)?; + err = match err.inner.cause.as_ref() { + None => break, + Some(err) => err, + }; + write!(f, ": ")?; + } + Ok(()) + } +} + +impl fmt::Display for Kind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Kind::AdHoc(ref err) => err.fmt(f), + Kind::ArkServer(ref err) => err.fmt(f), + Kind::Core(ref err) => err.fmt(f), + Kind::CoinSelect(ref err) => err.fmt(f), + Kind::Wallet(ref err) => err.fmt(f), + Kind::ServerInfoChanged(ref err) => err.fmt(f), + Kind::Consumer(ref err) => err.fmt(f), + } + } +} + +impl fmt::Display for AdHocError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.source.fmt(f) + } +} + +impl fmt::Display for ArkServerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.source.fmt(f) + } +} + +impl fmt::Display for CoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.source.fmt(f) + } +} + +impl fmt::Display for CoinSelectError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.source.fmt(f) + } +} + +impl fmt::Display for WalletError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.source.fmt(f) + } +} + +impl fmt::Display for ServerInfoChangedError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Ark server info changed while processing the request. Server info was refreshed, but the failed operation was not retried. Rebuild the request and retry if safe") + } +} + +impl fmt::Display for ConsumerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.source.fmt(f) + } +} + +impl From for Error { + fn from(value: ark_core::Error) -> Self { + Self::new(Kind::Core(CoreError { source: value })) + } +} + +pub trait IntoError { + fn into_error(self) -> Error; +} + +impl IntoError for Error { + fn into_error(self) -> Error { + self + } +} + +impl IntoError for &'static str { + fn into_error(self) -> Error { + Error::ad_hoc(self) + } +} + +impl IntoError for String { + fn into_error(self) -> Error { + Error::ad_hoc(self) + } +} + +/// A trait for contextualizing error values. +/// +/// This makes it easy to contextualize either `Error` or `Result`. +/// Specifically, in the latter case, it absolves one of the need to call +/// `map_err` everywhere one wants to add context to an error. +/// +/// This trick was borrowed from `jiff`, which borrowed it from `anyhow`. +pub trait ErrorContext { + type Output; + + /// Contextualize the given consequent error with this (`self`) error as + /// the cause. + /// + /// This is equivalent to saying that "consequent is caused by self." + /// + /// Note that if an `Error` is given for `kind`, then this panics if it has + /// a cause. (Because the cause would otherwise be dropped. An error causal + /// chain is just a linked list, not a tree.) + fn context(self, consequent: impl IntoError) -> Self::Output; + + /// Like `context`, but hides error construction within a closure. + /// + /// This is useful if the creation of the consequent error is not otherwise + /// guarded and when error construction is potentially "costly" (i.e., it + /// allocates). The closure avoids paying the cost of contextual error + /// creation in the happy path. + fn with_context(self, consequent: impl FnOnce() -> E) -> Self::Output; +} + +impl ErrorContext for Error { + type Output = Error; + + fn context(self, consequent: impl IntoError) -> Error { + let mut err = consequent.into_error(); + assert!( + err.inner.cause.is_none(), + "cause of consequence must be `None`" + ); + + err.inner.cause = Some(self); + err + } + + fn with_context(self, consequent: impl FnOnce() -> E) -> Error { + let mut err = consequent().into_error(); + assert!( + err.inner.cause.is_none(), + "cause of consequence must be `None`" + ); + + err.inner.cause = Some(self); + err + } +} + +impl ErrorContext for Result +where + E: StdError + Send + Sync + 'static, +{ + type Output = Result; + + fn context(self, consequent: impl IntoError) -> Result { + self.map_err(|err| { + let err: Box = Box::new(err); + match err.downcast::() { + Ok(err) => (*err).context(consequent), + Err(err) => Error::ad_hoc(err).context(consequent), + } + }) + } + + fn with_context(self, consequent: impl FnOnce() -> C) -> Result { + self.map_err(|err| { + let err: Box = Box::new(err); + match err.downcast::() { + Ok(err) => (*err).with_context(consequent), + Err(err) => Error::ad_hoc(err).with_context(consequent), + } + }) + } +} + +impl From for Error { + fn from(value: ark_grpc::Error) -> Self { + if value.is_server_info_changed() { + Self::server_info_changed(Self::ark_server(value)) + } else { + Self::ark_server(value) + } + } +} + +impl StdError for Error { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.inner + .cause + .as_ref() + .map(|e| e as &(dyn StdError + 'static)) + } +} diff --git a/vendor/ark-client/src/fee_estimation.rs b/vendor/ark-client/src/fee_estimation.rs new file mode 100644 index 0000000..8f43a63 --- /dev/null +++ b/vendor/ark-client/src/fee_estimation.rs @@ -0,0 +1,313 @@ +use crate::batch; +use crate::batch::BatchOutputType; +use crate::wallet::OnchainWallet; +use crate::Client; +use crate::Error; +use crate::SwapStorage; +use ark_core::ArkAddress; +use bitcoin::Address; +use bitcoin::Amount; +use bitcoin::OutPoint; +use bitcoin::SignedAmount; +use rand::CryptoRng; +use rand::Rng; + +impl Client +where + B: crate::Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + /// Estimates the fee to collaboratively redeem VTXOs to an on-chain Bitcoin address. + /// + /// This function calculates the expected fee for moving funds from the Ark protocol + /// back to a standard on-chain Bitcoin address through a collaborative redemption process. + /// The fee is estimated by creating a simulated intent and querying the Ark server. + /// + /// # Arguments + /// + /// * `rng` - A random number generator for creating the intent + /// * `to_address` - The on-chain Bitcoin address to send funds to + /// * `to_amount` - The amount to send to the destination address + /// + /// # Returns + /// + /// Returns the estimated fee as a [`SignedAmount`]. The fee will be deducted from + /// the total available balance when performing the actual redemption. + /// + /// # Errors + /// + /// Returns an error if: + /// - The available balance is insufficient for the requested amount + /// - Failed to fetch VTXOs or boarding inputs + /// - Failed to communicate with the Ark server + pub async fn estimate_onchain_fees( + &self, + rng: &mut R, + to_address: Address, + to_amount: Amount, + ) -> Result + where + R: Rng + CryptoRng + Clone, + { + let server_info = self.server_info().await?; + + let (change_address, _) = self.get_offchain_address_with_server_info(&server_info)?; + + let (boarding_inputs, vtxo_inputs, total_amount) = self + .fetch_commitment_transaction_inputs(&server_info, crate::utils::unix_now()?) + .await?; + + let change_amount = total_amount.checked_sub(to_amount).ok_or_else(|| { + Error::coin_select(format!( + "cannot afford to send {to_amount}, only have {total_amount}" + )) + })?; + + tracing::info!( + %to_address, + gross_amount = %to_amount, + change_address = %change_address.encode(), + %change_amount, + ?boarding_inputs, + "Estimating fee to collaboratively redeem outputs" + ); + + let intent = self.prepare_intent( + &mut rng.clone(), + boarding_inputs, + vtxo_inputs, + BatchOutputType::OffBoard { + to_address, + to_amount, + change_address, + change_amount, + }, + batch::PrepareIntentKind::EstimateFee, + server_info.dust, + )?; + + let amount = self.network_client().estimate_fees(intent.intent).await?; + + Ok(amount) + } + + /// Estimates the fee to join the next batch and settle funds to an Ark address. + /// + /// This function calculates the expected fee for consolidating all available VTXOs + /// and boarding outputs into fresh VTXOs through the Ark batch process. The full + /// available balance will be used, with fees deducted from the resulting VTXO. + /// + /// Use this to estimate fees before calling [`settle`](crate::Client::settle) or + /// similar batch operations. + /// + /// # Arguments + /// + /// * `rng` - A random number generator for creating the intent + /// * `to_address` - The Ark address to receive the settled funds + /// + /// # Returns + /// + /// Returns the estimated fee as a [`SignedAmount`]. This fee will be deducted from + /// the total available balance when joining the actual batch. + /// + /// # Errors + /// + /// Returns an error if: + /// - Failed to fetch VTXOs or boarding inputs + /// - Failed to communicate with the Ark server + pub async fn estimate_batch_fees( + &self, + rng: &mut R, + to_address: ArkAddress, + ) -> Result + where + R: Rng + CryptoRng + Clone, + { + let server_info = self.server_info().await?; + + let (boarding_inputs, vtxo_inputs, total_amount) = self + .fetch_commitment_transaction_inputs(&server_info, crate::utils::unix_now()?) + .await?; + + tracing::info!( + %to_address, + gross_amount = %total_amount, + ?boarding_inputs, + "Estimating fee to board outputs" + ); + + let intent = self.prepare_intent( + &mut rng.clone(), + boarding_inputs, + vtxo_inputs, + BatchOutputType::Board { + to_address, + to_amount: total_amount, + }, + batch::PrepareIntentKind::EstimateFee, + server_info.dust, + )?; + + let amount = self.network_client().estimate_fees(intent.intent).await?; + + Ok(amount) + } + + /// Estimates the fee to collaboratively redeem specific VTXOs to an on-chain Bitcoin address. + /// + /// This function is similar to [`estimate_onchain_fees`](Self::estimate_onchain_fees), but + /// allows you to specify exactly which VTXOs to use as inputs instead of using automatic + /// coin selection. This is useful when you want to estimate fees for redeeming specific + /// UTXOs. + /// + /// # Arguments + /// + /// * `rng` - A random number generator for creating the intent + /// * `input_vtxos` - An iterator of [`OutPoint`]s specifying which VTXOs to use as inputs + /// * `to_address` - The on-chain Bitcoin address to send funds to + /// * `to_amount` - The amount to send to the destination address + /// + /// # Returns + /// + /// Returns the estimated fee as a [`SignedAmount`]. The fee will be deducted from + /// the total input amount, with any remainder going to change. + /// + /// # Errors + /// + /// Returns an error if: + /// - No matching VTXO outpoints are found + /// - The total input amount is insufficient for the requested amount plus fees + /// - Failed to fetch VTXOs + /// - Failed to communicate with the Ark server + pub async fn estimate_onchain_fees_vtxo_selection( + &self, + rng: &mut R, + input_vtxos: impl Iterator + Clone, + to_address: Address, + to_amount: Amount, + ) -> Result + where + R: Rng + CryptoRng + Clone, + { + let server_info = self.server_info().await?; + + let (change_address, _) = self.get_offchain_address_with_server_info(&server_info)?; + + let vtxo_inputs = self + .selected_batch_settleable_vtxo_inputs(&server_info, input_vtxos) + .await?; + + if vtxo_inputs.is_empty() { + return Err(Error::ad_hoc("no matching VTXO outpoints found")); + } + + let total_input_amount = vtxo_inputs + .iter() + .fold(Amount::ZERO, |acc, vtxo| acc + vtxo.amount()); + + let change_amount = total_input_amount.checked_sub(to_amount).ok_or_else(|| { + Error::coin_select(format!( + "cannot afford to send {to_amount}, only have {total_input_amount}" + )) + })?; + + tracing::info!( + %to_address, + %to_amount, + %total_input_amount, + change_address = %change_address.encode(), + %change_amount, + num_vtxos = vtxo_inputs.len(), + "Estimating fee to collaboratively redeem selected VTXOs" + ); + + let intent = self.prepare_intent( + &mut rng.clone(), + vec![], // No boarding inputs when using specific VTXOs + vtxo_inputs, + BatchOutputType::OffBoard { + to_address, + to_amount, + change_address, + change_amount, + }, + batch::PrepareIntentKind::EstimateFee, + server_info.dust, + )?; + + let amount = self.network_client().estimate_fees(intent.intent).await?; + + Ok(amount) + } + + /// Estimates the fee to join the next batch with specific VTXOs and settle to an Ark address. + /// + /// This function is similar to [`estimate_batch_fees`](Self::estimate_batch_fees), but allows + /// you to specify exactly which VTXOs to use as inputs instead of using all available VTXOs. + /// This is useful when you want to estimate fees for settling specific UTXOs into fresh VTXOs. + /// + /// # Arguments + /// + /// * `rng` - A random number generator for creating the intent + /// * `input_vtxos` - An iterator of [`OutPoint`]s specifying which VTXOs to use as inputs + /// * `to_address` - The Ark address to receive the settled funds + /// + /// # Returns + /// + /// Returns the estimated fee as a [`SignedAmount`]. The fee will be deducted from + /// the total input amount when joining the actual batch. + /// + /// # Errors + /// + /// Returns an error if: + /// - No matching VTXO outpoints are found + /// - Failed to fetch VTXOs + /// - Failed to communicate with the Ark server + pub async fn estimate_batch_fees_vtxo_selection( + &self, + rng: &mut R, + input_vtxos: impl Iterator + Clone, + to_address: ArkAddress, + ) -> Result + where + R: Rng + CryptoRng + Clone, + { + let server_info = self.server_info().await?; + + let vtxo_inputs = self + .selected_batch_settleable_vtxo_inputs(&server_info, input_vtxos) + .await?; + + if vtxo_inputs.is_empty() { + return Err(Error::ad_hoc("no matching VTXO outpoints found")); + } + + let total_input_amount = vtxo_inputs + .iter() + .fold(Amount::ZERO, |acc, vtxo| acc + vtxo.amount()); + + tracing::info!( + %to_address, + %total_input_amount, + num_vtxos = vtxo_inputs.len(), + "Estimating fee to settle selected VTXOs" + ); + + let intent = self.prepare_intent( + &mut rng.clone(), + vec![], // No boarding inputs when using specific VTXOs + vtxo_inputs, + BatchOutputType::Board { + to_address, + to_amount: total_input_amount, + }, + batch::PrepareIntentKind::EstimateFee, + server_info.dust, + )?; + + let amount = self.network_client().estimate_fees(intent.intent).await?; + + Ok(amount) + } +} diff --git a/vendor/ark-client/src/key_provider.rs b/vendor/ark-client/src/key_provider.rs new file mode 100644 index 0000000..06638aa --- /dev/null +++ b/vendor/ark-client/src/key_provider.rs @@ -0,0 +1,622 @@ +use crate::error::Error; +use bitcoin::bip32::DerivationPath; +use bitcoin::bip32::Xpriv; +use bitcoin::key::Keypair; +use bitcoin::secp256k1::Secp256k1; +use std::sync::Arc; + +pub enum KeypairIndex { + /// Increments the index and returns a new keypair + New, + /// Returns the last unused address + LastUnused, +} + +/// Provides keypairs for signing operations +/// +/// This trait allows different key management strategies: +/// - Static keypair (single key) +/// - BIP32 HD wallet (hierarchical deterministic) +/// - Hardware wallets (future) +/// - Custom key derivation schemes +pub trait KeyProvider: Send + Sync { + /// Get a keypair for receiving funds + /// + /// For static key providers, this always returns the same keypair regardless of the index. + /// For HD wallets, behavior depends on the `keypair_index` parameter. + /// + /// # Arguments + /// + /// * `keypair_index` - Controls which keypair to return: + /// - `KeypairIndex::New`: Increments the internal index and returns a new keypair + /// - `KeypairIndex::LastUnused`: Returns the last unused keypair without incrementing + /// + /// # Returns + /// + /// A keypair to use for receiving funds + fn get_next_keypair(&self, keypair_index: KeypairIndex) -> Result; + + /// Get a keypair for a specific BIP32 derivation path + /// + /// # Arguments + /// + /// * `path` - BIP32 derivation path as an array of child indexes + /// + /// # Returns + /// + /// A keypair derived at the specified path, or an error if derivation is not supported + fn get_keypair_for_path(&self, path: &[u32]) -> Result; + + /// Get a keypair for a specific public key + /// + /// This is essential for HD wallets where you need to find the correct keypair + /// for signing with a previously generated public key. + /// + /// # Arguments + /// + /// * `pk` - The X-only public key to find the keypair for + /// + /// # Returns + /// + /// The keypair corresponding to the public key, or an error if not found + fn get_keypair_for_pk(&self, pk: &bitcoin::XOnlyPublicKey) -> Result; + + /// Get all public keys that this provider currently knows about + /// + /// For static key providers, this returns the single keypair's public key. + /// For HD wallets, this returns all public keys that have been derived and cached + /// (i.e., keys generated via `get_next_keypair`). + /// + /// This is useful for determining which keys are available for signing operations + /// without having to search or derive new keys. + /// + /// # Returns + /// + /// A vector of X-only public keys known to this provider + fn get_cached_pks(&self) -> Result, Error>; +} + +/// Key provider extension for index-based discovery and restore. +pub trait DiscoverableKeyProvider: KeyProvider { + /// Get the derivation index for a cached public key. + fn get_derivation_index_for_pk(&self, pk: &bitcoin::XOnlyPublicKey) -> Option; + + /// Derive a keypair at a specific index without caching. + fn derive_at_discovery_index(&self, index: u32) -> Result, Error>; + + /// Cache a discovered keypair at the given index. + /// + /// This is called after discovery determines a key is "used" (has VTXOs). + /// Implementations should also advance receive-key state to avoid reuse. + fn cache_discovered_keypair(&self, index: u32, kp: Keypair) -> Result<(), Error>; + + /// Derive and cache the keypair at a persisted derivation index. + /// + /// This is used after loading persisted contracts so HD wallets can sign for stored + /// contracts immediately after restart, without running a full restore scan. + /// Implementations must not advance receive-key discovery state here. + fn cache_keypair_at_index(&self, index: u32) -> Result<(), Error>; + + fn mark_as_used(&self, pk: &bitcoin::XOnlyPublicKey) -> Result<(), Error>; +} + +/// A simple key provider that uses a static keypair +/// +/// This is the simplest implementation and is backward compatible with +/// the original single-keypair design. +#[derive(Clone)] +pub struct StaticKeyProvider { + kp: Keypair, +} + +impl StaticKeyProvider { + /// Create a new static key provider + pub fn new(kp: Keypair) -> Self { + Self { kp } + } +} + +impl KeyProvider for StaticKeyProvider { + fn get_next_keypair(&self, _: KeypairIndex) -> Result { + // Static provider always returns the same keypair + Ok(self.kp) + } + + fn get_keypair_for_path(&self, _path: &[u32]) -> Result { + // Static provider always returns the same keypair + Ok(self.kp) + } + + fn get_keypair_for_pk(&self, pk: &bitcoin::XOnlyPublicKey) -> Result { + // Verify that the requested public key matches our keypair + let our_pk = self.kp.x_only_public_key().0; + if &our_pk == pk { + Ok(self.kp) + } else { + Err(Error::ad_hoc(format!( + "Public key mismatch: requested {pk}, but only have {our_pk}" + ))) + } + } + + fn get_cached_pks(&self) -> Result, Error> { + Ok(vec![self.kp.public_key().into()]) + } +} + +/// A BIP32 hierarchical deterministic key provider +/// +/// This provider derives keypairs from a master extended private key +/// using BIP32 derivation paths. It maintains an index counter for +/// generating new receiving addresses. +/// +/// ## Example +/// +/// ```rust +/// # use std::str::FromStr; +/// # use bitcoin::bip32::{Xpriv, DerivationPath}; +/// # use bitcoin::Network; +/// # use crate::ark_client::KeyProvider; +/// # use ark_client::Bip32KeyProvider; +/// # use ark_client::key_provider::KeypairIndex; +/// +/// fn example() -> Result<(), Box> { +/// // Create from a master key with a base path (e.g., m/84'/0'/0'/0) +/// let master_key = Xpriv::from_str("xprv...")?; +/// let base_path = DerivationPath::from_str("m/84'/0'/0'/0")?; +/// +/// // This will derive keys at m/84'/0'/0'/0/0, m/84'/0'/0'/0/1, etc. +/// let provider = Bip32KeyProvider::new(master_key, base_path); +/// +/// // Get the next receiving keypair (increments index) +/// let kp1 = provider.get_next_keypair(KeypairIndex::New)?; // m/84'/0'/0'/0/0 +/// let kp2 = provider.get_next_keypair(KeypairIndex::New)?; // m/84'/0'/0'/0/1 +/// +/// // Or derive a specific keypair by path +/// let custom_path = vec![84 + 0x8000_0000, 0x8000_0000, 0x8000_0000, 0, 5]; +/// let kp = provider.get_keypair_for_path(&custom_path)?; +/// # Ok(()) +/// # } +/// ``` +pub struct Bip32KeyProvider { + master_key: Xpriv, + base_path: DerivationPath, + // Using std::sync::Mutex for interior mutability across Send + Sync + next_index: Arc>, + // Cache of derived keys: pk -> (path_index, keypair, used) + // The `used` flag indicates whether this keypair has been used (has VTXOs) + key_cache: + Arc>>, +} + +#[derive(Clone, Copy)] +pub struct KeyCacheValue { + path_index: u32, + kp: Keypair, + /// Indicates whether this keypair has been used (has VTXOs). + used: bool, +} + +impl Bip32KeyProvider { + /// Create a new BIP32 key provider + /// + /// # Arguments + /// + /// * `master_key` - The master extended private key (xpriv) + /// * `base_path` - The base derivation path (e.g., m/84'/0'/0'/0). The provider will append + /// index numbers to this path. + pub fn new(master_key: Xpriv, base_path: DerivationPath) -> Self { + Self { + master_key, + base_path, + next_index: Arc::new(std::sync::Mutex::new(0)), + key_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())), + } + } + + /// Create a new BIP32 key provider starting from a specific index + /// + /// # Arguments + /// + /// * `master_key` - The master extended private key (xpriv) + /// * `base_path` - The base derivation path + /// * `start_index` - The starting index for key derivation + pub fn new_with_index(master_key: Xpriv, base_path: DerivationPath, start_index: u32) -> Self { + Self { + master_key, + base_path, + next_index: Arc::new(std::sync::Mutex::new(start_index)), + key_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())), + } + } + + /// Derive a keypair at the specified path + fn derive_keypair(&self, path: &DerivationPath) -> Result { + let secp = Secp256k1::new(); + let derived_key = self + .master_key + .derive_priv(&secp, path) + .map_err(|e| Error::ad_hoc(format!("BIP32 derivation failed: {e}")))?; + + Ok(derived_key.to_keypair(&secp)) + } + + /// Derive a keypair at base_path/index + fn derive_at_index(&self, index: u32) -> Result { + use bitcoin::bip32::ChildNumber; + + let path = self.base_path.clone(); + let path = path.extend([ChildNumber::Normal { index }]); + + self.derive_keypair(&path) + } +} + +impl KeyProvider for Bip32KeyProvider { + fn get_next_keypair(&self, keypair_index: KeypairIndex) -> Result { + match keypair_index { + KeypairIndex::New => { + // Get and increment the next index + let index = { + let mut next_index = self + .next_index + .lock() + .map_err(|e| Error::ad_hoc(format!("Failed to lock next_index: {e}")))?; + let current = *next_index; + *next_index = next_index + .checked_add(1) + .ok_or_else(|| Error::ad_hoc("Key derivation index overflow"))?; + current + }; + + // Derive the keypair at this index + let kp = self.derive_at_index(index)?; + + // Cache it for later lookup (marked as unused) + let pk = kp.x_only_public_key().0; + { + let mut cache = self + .key_cache + .write() + .map_err(|e| Error::ad_hoc(format!("Failed to lock key_cache: {e}")))?; + cache.insert( + pk, + KeyCacheValue { + path_index: index, + kp, + used: false, + }, + ); + } + + Ok(kp) + } + KeypairIndex::LastUnused => { + // First, try to find an unused keypair in the cache + { + let cache = self + .key_cache + .read() + .map_err(|e| Error::ad_hoc(format!("Failed to lock key_cache: {e}")))?; + + // Find the unused keypair with the lowest index + let unused = cache + .values() + .filter(|KeyCacheValue { used, .. }| !used) + .min_by_key(|KeyCacheValue { path_index, .. }| *path_index); + + if let Some(KeyCacheValue { kp, .. }) = unused { + return Ok(*kp); + } + } + + // No unused keypair found, derive a new one + self.get_next_keypair(KeypairIndex::New) + } + } + } + + fn get_keypair_for_path(&self, path: &[u32]) -> Result { + use bitcoin::bip32::ChildNumber; + let child_numbers: Vec = path + .iter() + .map(|&n| { + if n & 0x8000_0000 != 0 { + ChildNumber::Hardened { + index: n & 0x7FFF_FFFF, + } + } else { + ChildNumber::Normal { index: n } + } + }) + .collect(); + let derivation_path = DerivationPath::from(child_numbers); + self.derive_keypair(&derivation_path) + } + + fn get_keypair_for_pk(&self, pk: &bitcoin::XOnlyPublicKey) -> Result { + // First check the cache + { + let cache = self + .key_cache + .read() + .map_err(|e| Error::ad_hoc(format!("Failed to lock key_cache: {e}")))?; + if let Some(KeyCacheValue { kp, .. }) = cache.get(pk) { + return Ok(*kp); + } + } + + // If not in cache, we need to search. For now, we'll search up to the current index + let current_index = { + let next_index = self + .next_index + .lock() + .map_err(|e| Error::ad_hoc(format!("Failed to lock next_index: {e}")))?; + *next_index + }; + + // Search through derived keys up to current index + for i in 0..current_index { + let kp = self.derive_at_index(i)?; + let derived_pk = kp.x_only_public_key().0; + + if &derived_pk == pk { + // Cache it for next time (assume used since we're looking it up for signing) + let mut cache = self + .key_cache + .write() + .map_err(|e| Error::ad_hoc(format!("Failed to lock key_cache: {e}")))?; + cache.insert( + derived_pk, + KeyCacheValue { + path_index: i, + kp, + used: true, + }, + ); + return Ok(kp); + } + } + + Err(Error::ad_hoc(format!( + "Public key {pk} not found in HD wallet. \ + Searched indices 0..{current_index}. \ + The key may have been generated outside this provider." + ))) + } + + fn get_cached_pks(&self) -> Result, Error> { + let cache = self + .key_cache + .read() + .map_err(|e| Error::ad_hoc(format!("Failed to lock key_cache: {e}")))?; + + Ok(cache.keys().copied().collect()) + } +} + +impl DiscoverableKeyProvider for Bip32KeyProvider { + fn get_derivation_index_for_pk(&self, pk: &bitcoin::XOnlyPublicKey) -> Option { + let cache = self.key_cache.read().ok()?; + cache.get(pk).map(|v| v.path_index) + } + + fn derive_at_discovery_index(&self, index: u32) -> Result, Error> { + self.derive_at_index(index).map(Some) + } + + fn cache_discovered_keypair(&self, index: u32, kp: Keypair) -> Result<(), Error> { + let pk = kp.x_only_public_key().0; + + // Add to cache (marked as used since it was discovered with VTXOs) + { + let mut cache = self + .key_cache + .write() + .map_err(|e| Error::ad_hoc(format!("Failed to lock key_cache: {e}")))?; + cache.insert( + pk, + KeyCacheValue { + path_index: index, + kp, + used: true, + }, + ); + } + + // Update next_index if needed (set to index + 1 if >= current) + { + let mut next = self + .next_index + .lock() + .map_err(|e| Error::ad_hoc(format!("Failed to lock next_index: {e}")))?; + if index >= *next { + *next = index + .checked_add(1) + .ok_or_else(|| Error::ad_hoc("Key derivation index overflow"))?; + } + } + + Ok(()) + } + + fn cache_keypair_at_index(&self, index: u32) -> Result<(), Error> { + let kp = self.derive_at_index(index)?; + let pk = kp.x_only_public_key().0; + let mut cache = self + .key_cache + .write() + .map_err(|e| Error::ad_hoc(format!("Failed to lock key_cache: {e}")))?; + cache.insert( + pk, + KeyCacheValue { + path_index: index, + kp, + used: true, + }, + ); + Ok(()) + } + + fn mark_as_used(&self, pk: &bitcoin::XOnlyPublicKey) -> Result<(), Error> { + // First check the cache + { + let maybe_kp = { + let cache = self + .key_cache + .read() + .map_err(|e| Error::ad_hoc(format!("Failed to lock key_cache: {e}")))?; + cache.get(pk).copied() + }; + + match maybe_kp { + Some(KeyCacheValue { + path_index, + kp, + used: false, + }) => { + let mut cache = self + .key_cache + .write() + .map_err(|e| Error::ad_hoc(format!("Failed to lock key_cache: {e}")))?; + cache.insert( + *pk, + KeyCacheValue { + path_index, + kp, + used: true, + }, + ); + return Ok(()); + } + Some(KeyCacheValue { used: true, .. }) => { + // already marked as used + return Ok(()); + } + _ => { + // no found + } + } + } + + // If not in cache, we need to search. For now, we'll search up to the current index + let current_index = { + let next_index = self + .next_index + .lock() + .map_err(|e| Error::ad_hoc(format!("Failed to lock next_index: {e}")))?; + *next_index + }; + + // Search through derived keys up to current index + for i in 0..current_index { + let kp = self.derive_at_index(i)?; + let derived_pk = kp.x_only_public_key().0; + + if &derived_pk == pk { + // Cache it for next time (assume used since we're looking it up for signing) + let mut cache = self + .key_cache + .write() + .map_err(|e| Error::ad_hoc(format!("Failed to lock key_cache: {e}")))?; + cache.insert( + derived_pk, + KeyCacheValue { + path_index: i, + kp, + used: true, + }, + ); + return Ok(()); + } + } + + Err(Error::ad_hoc(format!( + "Public key {pk} not found in HD wallet. \ + Searched indices 0..{current_index}. \ + The key may have been generated outside this provider." + ))) + } +} + +// Implement KeyProvider for Arc where T: KeyProvider +impl KeyProvider for Arc { + fn get_next_keypair(&self, keypair_index: KeypairIndex) -> Result { + (**self).get_next_keypair(keypair_index) + } + + fn get_keypair_for_path(&self, path: &[u32]) -> Result { + (**self).get_keypair_for_path(path) + } + + fn get_keypair_for_pk(&self, pk: &bitcoin::XOnlyPublicKey) -> Result { + (**self).get_keypair_for_pk(pk) + } + + fn get_cached_pks(&self) -> Result, Error> { + (**self).get_cached_pks() + } +} + +impl DiscoverableKeyProvider for Arc { + fn get_derivation_index_for_pk(&self, pk: &bitcoin::XOnlyPublicKey) -> Option { + (**self).get_derivation_index_for_pk(pk) + } + + fn derive_at_discovery_index(&self, index: u32) -> Result, Error> { + (**self).derive_at_discovery_index(index) + } + + fn cache_discovered_keypair(&self, index: u32, kp: Keypair) -> Result<(), Error> { + (**self).cache_discovered_keypair(index, kp) + } + + fn cache_keypair_at_index(&self, index: u32) -> Result<(), Error> { + (**self).cache_keypair_at_index(index) + } + + fn mark_as_used(&self, pk: &bitcoin::XOnlyPublicKey) -> Result<(), Error> { + (**self).mark_as_used(pk) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::Network; + use std::str::FromStr; + + #[test] + fn cache_keypair_at_index_hydrates_hd_lookup_after_restart() { + let seed = [7_u8; 32]; + let master = Xpriv::new_master(Network::Regtest, &seed).unwrap(); + let base_path = DerivationPath::from_str("m/86'/1'/0'/0").unwrap(); + + let original = Bip32KeyProvider::new(master, base_path.clone()); + let expected = original.derive_at_discovery_index(7).unwrap().unwrap(); + let expected_pk = expected.x_only_public_key().0; + let first_receive_pk = original + .derive_at_discovery_index(0) + .unwrap() + .unwrap() + .x_only_public_key() + .0; + + let restarted = Bip32KeyProvider::new(master, base_path); + assert!(restarted.get_keypair_for_pk(&expected_pk).is_err()); + + restarted.cache_keypair_at_index(7).unwrap(); + let actual = restarted.get_keypair_for_pk(&expected_pk).unwrap(); + + assert_eq!(actual.x_only_public_key().0, expected_pk); + assert_eq!(restarted.get_derivation_index_for_pk(&expected_pk), Some(7)); + assert_eq!( + restarted + .get_next_keypair(KeypairIndex::LastUnused) + .unwrap() + .x_only_public_key() + .0, + first_receive_pk + ); + } +} diff --git a/vendor/ark-client/src/lib.rs b/vendor/ark-client/src/lib.rs new file mode 100644 index 0000000..6cdc6ca --- /dev/null +++ b/vendor/ark-client/src/lib.rs @@ -0,0 +1,2817 @@ +use crate::error::ErrorContext; +use crate::key_provider::KeypairIndex; +use crate::utils::sleep; +use crate::utils::timeout_op; +use crate::utils::unix_now; +use crate::wallet::OnchainWallet; +use ark_core::asset::AssetId; +use ark_core::build_anchor_tx; +use ark_core::contract::BoardingContract; +use ark_core::contract::ContractContext; +use ark_core::contract::ContractState; +use ark_core::contract::ContractType; +use ark_core::contract::DefaultVtxoContract; +use ark_core::contract::DelegateVtxoContract; +use ark_core::contract::StoredContract; +use ark_core::contract::VhtlcContract; +use ark_core::history; +use ark_core::history::generate_incoming_vtxo_transaction_history; +use ark_core::history::generate_outgoing_vtxo_transaction_history; +use ark_core::history::sort_transactions_by_created_at; +use ark_core::history::OutgoingTransaction; +use ark_core::server; +use ark_core::server::GetVtxosRequest; +use ark_core::server::SubscriptionFilter; +use ark_core::server::SubscriptionResponse; +use ark_core::server::VirtualTxOutPoint; +use ark_core::ArkAddress; +use ark_core::ExplorerUtxo; +use ark_core::UtxoCoinSelection; +use ark_core::Vtxo; +use ark_core::VtxoList; +use ark_core::DEFAULT_DERIVATION_PATH; +use ark_grpc::VtxoChainResponse; +use bitcoin::bip32::DerivationPath; +use bitcoin::bip32::Xpriv; +use bitcoin::key::Keypair; +use bitcoin::key::Secp256k1; +use bitcoin::secp256k1::schnorr::Signature; +use bitcoin::secp256k1::All; +use bitcoin::secp256k1::Message; +use bitcoin::Address; +use bitcoin::Amount; +use bitcoin::Network; +use bitcoin::OutPoint; +use bitcoin::ScriptBuf; +use bitcoin::Transaction; +use bitcoin::Txid; +use bitcoin::XOnlyPublicKey; +use futures::Future; +use futures::Stream; +use std::collections::HashMap; +use std::collections::HashSet; +use std::str::FromStr; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::RwLock; +use std::time::Duration; +use std::time::Instant; + +pub mod contract; +pub mod error; +pub mod key_provider; +pub mod swap_storage; +pub mod vtxo_watcher; +pub mod wallet; + +mod asset; +mod batch; +mod boltz; +mod coin_select; +mod fee_estimation; +mod migration; +mod send_vtxo; +mod unilateral_exit; +mod utils; + +pub use ark_core::server::DeprecatedSignerStatus; +pub use ark_core::server::GetVtxosRequestFilter; +pub use ark_core::server::ServerSignerStatus; +pub use asset::IssueAssetResult; +pub use boltz::BoltzVhtlcWatcherConfig; +pub use boltz::BoltzVhtlcWatcherHandle; +pub use boltz::ChainSwapAmount; +pub use boltz::ChainSwapData; +pub use boltz::ChainSwapDirection; +pub use boltz::ChainSwapResult; +pub use boltz::PendingVhtlcSpendTx; +pub use boltz::PendingVhtlcSpendType; +pub use boltz::ReverseSwapData; +pub use boltz::SubmarineSwapData; +pub use boltz::SwapAmount; +pub use boltz::SwapStatus; +pub use boltz::SwapStatusInfo; +pub use boltz::SwapType; +pub use boltz::TimeoutBlockHeights; +pub use contract::AnnotatedBoardingOutput; +pub use contract::AnnotatedVtxo; +pub use contract::AnnotatedVtxoList; +pub use contract::ContractManager; +pub use contract::ContractRegistry; +pub use contract::ContractStore; +pub use contract::MemoryContractStore; +#[cfg(feature = "sqlite")] +pub use contract::SqliteContractStore; +pub use error::Error; +pub use key_provider::Bip32KeyProvider; +pub use key_provider::DiscoverableKeyProvider; +pub use key_provider::KeyProvider; +pub use key_provider::StaticKeyProvider; +pub use lightning_invoice; +pub use migration::DeprecatedSignerMigrationReport; +pub use migration::DeprecatedSignerReport; +pub use migration::MigrationLegReport; +pub use migration::MigrationSkipReason; +pub use migration::MigrationVtxoRef; +pub use migration::MAX_VTXOS_PER_SETTLEMENT; +pub use swap_storage::InMemorySwapStorage; +#[cfg(feature = "sqlite")] +pub use swap_storage::SqliteSwapStorage; +pub use swap_storage::SwapStorage; + +/// Default gap limit for BIP44-style key discovery +/// +/// This is the number of consecutive unused addresses to scan before +/// assuming all used addresses have been found. +pub const DEFAULT_GAP_LIMIT: u32 = 20; + +/// Default Boltz `referralId` sent with swap creation requests when the caller does not +/// provide one. Identifies traffic originating from this SDK. +pub const DEFAULT_BOLTZ_REFERRAL_ID: &str = "arkade-rs-SDK"; + +/// Summary returned by [`Client::restore_contracts`]. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ContractRestoreReport { + /// Gap limit used for this scan. + pub gap_limit: u32, + /// First derived key index that was probed, if any. + pub scanned_from: Option, + /// One past the last derived key index that was probed, if any. + pub scanned_to_exclusive: Option, + /// Derived key indexes that were probed. + pub scanned_keys: u32, + /// Key indexes where at least one contract had activity. + pub discovered_key_indexes: Vec, + /// Highest discovered key index, if any. + pub last_used_key_index: Option, + /// Suggested next receive key index, if any. + pub next_key_index: Option, + /// Offchain default/delegate contracts with VTXO activity. + pub offchain_contracts: u32, + /// Boarding contracts with on-chain UTXO activity. + pub boarding_contracts: u32, + /// Contracts that were not already in the store. + pub inserted_contracts: u32, + /// Discovered contracts that were already present in the store. + pub known_contracts: u32, + /// Per-contract discovery details for caller UX. + pub entries: Vec, +} + +impl ContractRestoreReport { + pub fn discovered_keys(&self) -> u32 { + self.discovered_key_indexes.len() as u32 + } + + pub fn discovered_contracts(&self) -> u32 { + self.entries.len() as u32 + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContractRestoreEntry { + pub key_index: u32, + pub contract_type: ContractType, + pub script_pubkey: ScriptBuf, + pub status: ContractRestoreEntryStatus, + pub discovery: ContractRestoreDiscovery, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ContractRestoreEntryStatus { + Inserted, + Known, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ContractRestoreDiscovery { + Offchain { + vtxos: Vec, + }, + Boarding { + outpoints: Vec, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContractRestoreVtxo { + pub outpoint: OutPoint, + pub amount: Amount, + pub is_spent: bool, + pub is_swept: bool, + pub is_unrolled: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContractRestoreOutpoint { + pub outpoint: OutPoint, + pub amount: Amount, + pub confirmation_blocktime: Option, + pub confirmations: u64, +} + +/// Wallet-facing view of a stored contract. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContractInfo { + /// The validated stored contract row. + pub contract: StoredContract, + /// Address derived from the contract, when the SDK knows how to derive one. + pub address: Option, + /// Kind of address in [`Self::address`]. + pub address_kind: Option, + /// Server signer encoded in this contract, when the SDK knows how to decode it. + pub server_pk: Option, + /// Rotation status of [`Self::server_pk`] against the current server info. + pub signer_status: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ContractAddressKind { + Ark, + Bitcoin, +} + +/// Default mainnet Arkade server URL. +pub const ARKADE_MAINNET_URL: &str = "https://arkade.computer"; + +/// Default mutinynet Arkade server URL. +pub const ARKADE_MUTINYNET_URL: &str = "https://mutinynet.arkade.sh"; + +/// Default mainnet Boltz API URL. +pub const BOLTZ_MAINNET_URL: &str = "https://api.boltz.exchange"; + +/// Default mutinynet Boltz API URL. +pub const BOLTZ_MUTINYNET_URL: &str = "https://api.boltz.mutinynet.arkade.sh"; + +/// Default timeout for network operations. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +/// Default maximum age for cached Ark server info. +pub const DEFAULT_SERVER_INFO_TTL: Duration = Duration::from_secs(15 * 60); + +/// Boltz referral ID behavior for swap creation requests. +#[derive(Clone, Debug, Default)] +pub enum BoltzReferralId { + /// Use [`DEFAULT_BOLTZ_REFERRAL_ID`]. + #[default] + Default, + /// Send no `referralId` field with Boltz swap creation requests. + Disabled, + /// Send a custom `referralId` field with Boltz swap creation requests. + Custom(String), +} + +/// Configuration for constructing an [`OfflineClient`]. +/// +/// The default configuration targets mainnet. Set [`Self::server_info_ttl`] to +/// [`Duration::ZERO`] to refresh server info on every access. +#[derive(Clone, Debug)] +pub struct OfflineClientConfig { + pub ark_server_url: String, + pub boltz_url: String, + pub timeout: Duration, + pub server_info_ttl: Duration, + pub boltz_referral_id: BoltzReferralId, + pub delegator_pk: Option, + pub historical_delegator_pks: Vec, +} + +impl Default for OfflineClientConfig { + fn default() -> Self { + Self { + ark_server_url: ARKADE_MAINNET_URL.to_string(), + boltz_url: BOLTZ_MAINNET_URL.to_string(), + timeout: DEFAULT_TIMEOUT, + server_info_ttl: DEFAULT_SERVER_INFO_TTL, + boltz_referral_id: BoltzReferralId::default(), + delegator_pk: None, + historical_delegator_pks: Vec::new(), + } + } +} + +/// A client to interact with Ark Server +/// +/// ## Example +/// +/// ```rust +/// # use std::future::Future; +/// # use std::str::FromStr; +/// # use ark_client::{Blockchain, Client, Error, SpendStatus, TxStatus}; +/// # use ark_client::OfflineClient; +/// # use ark_client::OfflineClientConfig; +/// # use bitcoin::key::Keypair; +/// # use bitcoin::secp256k1::SecretKey; +/// # use std::sync::Arc; +/// # use bitcoin::{Address, Amount, FeeRate, Psbt, Transaction, Txid}; +/// # use ark_client::wallet::{Balance, OnchainWallet}; +/// # use ark_client::InMemorySwapStorage; +/// # use ark_core::{UtxoCoinSelection, ExplorerUtxo}; +/// # use ark_client::StaticKeyProvider; +/// +/// struct MyBlockchain {} +/// # +/// # impl MyBlockchain { +/// # pub fn new(_url: &str) -> Self { Self {}} +/// # } +/// # +/// # impl Blockchain for MyBlockchain { +/// # +/// # async fn find_outpoints(&self, address: &Address) -> Result, Error> { +/// # unimplemented!("You can implement this function using your preferred client library such as esplora_client") +/// # } +/// # +/// # async fn find_tx(&self, txid: &Txid) -> Result, Error> { +/// # unimplemented!() +/// # } +/// # +/// # async fn get_tx_status(&self, txid: &Txid) -> Result { +/// # unimplemented!() +/// # } +/// # +/// # async fn get_output_status(&self, txid: &Txid, vout: u32) -> Result { +/// # unimplemented!() +/// # } +/// # +/// # async fn broadcast(&self, tx: &Transaction) -> Result<(), Error> { +/// # unimplemented!() +/// # } +/// # +/// # async fn get_fee_rate(&self) -> Result { +/// # unimplemented!() +/// # } +/// # +/// # async fn broadcast_package( +/// # &self, +/// # txs: &[&Transaction], +/// # ) -> Result<(), Error> { +/// # unimplemented!() +/// # } +/// # } +/// +/// struct MyWallet {} +/// # impl OnchainWallet for MyWallet where { +/// # +/// # fn get_onchain_address(&self) -> Result { +/// # unimplemented!("You can implement this function using your preferred client library such as bdk") +/// # } +/// # +/// # async fn sync(&self) -> Result<(), Error> { +/// # unimplemented!() +/// # } +/// # +/// # fn balance(&self) -> Result { +/// # unimplemented!() +/// # } +/// # +/// # fn prepare_send_to_address(&self, address: Address, amount: Amount, fee_rate: FeeRate) -> Result { +/// # unimplemented!() +/// # } +/// # +/// # fn sign(&self, psbt: &mut Psbt) -> Result { +/// # unimplemented!() +/// # } +/// # +/// # fn select_coins(&self, target_amount: Amount) -> Result { +/// # unimplemented!() +/// # } +/// # } +/// # +/// +/// // Initialize the client with a static keypair +/// async fn init_client_with_keypair() -> Result, ark_client::Error> { +/// // Create a keypair for signing transactions +/// let secp = bitcoin::key::Secp256k1::new(); +/// let secret_key = SecretKey::from_str("your_private_key_here").unwrap(); +/// let keypair = Keypair::from_secret_key(&secp, &secret_key); +/// +/// // Initialize blockchain and wallet implementations +/// let blockchain = Arc::new(MyBlockchain::new("https://esplora.example.com")); +/// let wallet = Arc::new(MyWallet {}); +/// +/// let config = OfflineClientConfig { +/// ark_server_url: "https://ark-server.example.com".to_string(), +/// boltz_url: "http://boltz.example.com".to_string(), +/// ..Default::default() +/// }; +/// +/// let offline_client = OfflineClient::with_keypair( +/// config, +/// keypair, +/// blockchain, +/// wallet, +/// Arc::new(InMemorySwapStorage::default()), +/// ); +/// +/// // Connect to the Ark server and get server info +/// let client = offline_client.connect().await?; +/// +/// Ok(client) +/// } +/// +/// // Initialize the client with a BIP32 HD wallet +/// # use bitcoin::bip32::{Xpriv, DerivationPath}; +/// async fn init_client_with_bip32() -> Result, ark_client::Error> { +/// // Create a BIP32 master key and derivation path +/// let master_key = Xpriv::from_str("xprv...").unwrap(); +/// let derivation_path = DerivationPath::from_str("m/84'/0'/0'/0/0").unwrap(); +/// +/// // Initialize blockchain and wallet implementations +/// let blockchain = Arc::new(MyBlockchain::new("https://esplora.example.com")); +/// let wallet = Arc::new(MyWallet {}); +/// +/// let config = OfflineClientConfig { +/// ark_server_url: "https://ark-server.example.com".to_string(), +/// boltz_url: "http://boltz.example.com".to_string(), +/// ..Default::default() +/// }; +/// +/// let offline_client = OfflineClient::with_bip32( +/// config, +/// master_key, +/// Some(derivation_path), +/// blockchain, +/// wallet, +/// Arc::new(InMemorySwapStorage::default()), +/// ); +/// +/// // Connect to the Ark server and get server info +/// let client = offline_client.connect().await?; +/// +/// Ok(client) +/// } +/// ``` +#[derive(Clone)] +pub struct OfflineClient { + // TODO: We could introduce a generic interface so that consumers can use either GRPC or REST. + network_client: ark_grpc::Client, + key_provider: Arc, + discoverable_key_provider: Option>, + blockchain: Arc, + secp: Secp256k1, + wallet: Arc, + swap_storage: Arc, + boltz_url: String, + boltz_referral_id: Option, + timeout: Duration, + server_info_ttl: Duration, + contract_store: Arc>>>, + delegator_pk: Option, + historical_delegator_pks: Vec, +} + +/// A client to interact with Ark server +/// +/// See [`OfflineClient`] docs for details. +pub struct Client { + inner: OfflineClient, + state: Arc>, + server_info_refresh_lock: Arc>, +} + +struct ServerState { + server_info: server::Info, + fee_estimator: ark_fees::Estimator, + server_info_refreshed_at: Instant, + contract_manager: Mutex, +} + +#[derive(Clone, Debug)] +enum RestoreCandidate { + DefaultVtxo(DefaultVtxoContract), + DelegateVtxo(DelegateVtxoContract), + Boarding(BoardingContract), +} + +#[derive(Clone, Debug)] +enum RestoreDiscoveryTarget { + Offchain(ArkAddress), + Boarding(Address), +} + +impl RestoreCandidate { + fn discovery_target( + &self, + secp: &Secp256k1, + ctx: &ContractContext, + ) -> Result { + match self { + Self::DefaultVtxo(contract) => Ok(RestoreDiscoveryTarget::Offchain( + Vtxo::new_default( + secp, + contract.server, + contract.owner, + contract.exit_delay, + ctx.network(), + )? + .to_ark_address(), + )), + Self::DelegateVtxo(contract) => Ok(RestoreDiscoveryTarget::Offchain( + Vtxo::new_with_delegator( + secp, + contract.server, + contract.owner, + contract.delegator, + contract.exit_delay, + ctx.network(), + )? + .to_ark_address(), + )), + Self::Boarding(contract) => Ok(RestoreDiscoveryTarget::Boarding( + contract.boarding_output(ctx)?.address().clone(), + )), + } + } + + fn script_pubkey( + &self, + secp: &Secp256k1, + ctx: &ContractContext, + ) -> Result { + match self { + Self::DefaultVtxo(contract) => Ok(Vtxo::new_default( + secp, + contract.server, + contract.owner, + contract.exit_delay, + ctx.network(), + )? + .script_pubkey()), + Self::DelegateVtxo(contract) => Ok(Vtxo::new_with_delegator( + secp, + contract.server, + contract.owner, + contract.delegator, + contract.exit_delay, + ctx.network(), + )? + .script_pubkey()), + Self::Boarding(contract) => Ok(contract.boarding_output(ctx)?.script_pubkey()), + } + } + + fn contract_type(&self) -> ContractType { + match self { + Self::DefaultVtxo(_) => ContractType::default_vtxo(), + Self::DelegateVtxo(_) => ContractType::delegate_vtxo(), + Self::Boarding(_) => ContractType::boarding(), + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct TxStatus { + pub confirmed_at: Option, +} + +#[derive(Clone, Copy, Debug)] +pub struct SpendStatus { + pub spend_txid: Option, +} + +pub struct AddressVtxos { + pub unspent: Vec, + pub spent: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct OffChainBalance { + pre_confirmed: Amount, + confirmed: Amount, + recoverable: Amount, + /// Funds under a deprecated server signer whose cooperative-sign cutoff has passed. + /// These VTXOs cannot be spent offchain (operator won't co-sign the old key) and are not yet + /// recoverable (not expired). They will become recoverable once their VTXO expiry passes. + pending_recovery: Amount, + asset_balances: HashMap, +} + +impl OffChainBalance { + pub fn pre_confirmed(&self) -> Amount { + self.pre_confirmed + } + + pub fn confirmed(&self) -> Amount { + self.confirmed + } + + /// Balance which can only be settled, and does not require a forfeit transaction per VTXO. + pub fn recoverable(&self) -> Amount { + self.recoverable + } + + /// Funds locked under a deprecated signer past its cutoff — cannot be spent offchain, + /// waiting for VTXO expiry to become recoverable. Still counted in `total()`. + pub fn pending_recovery(&self) -> Amount { + self.pending_recovery + } + + pub fn total(&self) -> Amount { + self.pre_confirmed + self.confirmed + self.recoverable + self.pending_recovery + } + + /// Asset balances keyed by asset ID. + pub fn asset_balances(&self) -> &HashMap { + &self.asset_balances + } +} + +pub trait Blockchain { + fn find_outpoints( + &self, + address: &Address, + ) -> impl Future, Error>> + Send; + + fn find_tx( + &self, + txid: &Txid, + ) -> impl Future, Error>> + Send; + + fn get_tx_status(&self, txid: &Txid) -> impl Future> + Send; + + fn get_output_status( + &self, + txid: &Txid, + vout: u32, + ) -> impl Future> + Send; + + fn broadcast(&self, tx: &Transaction) -> impl Future> + Send; + + fn get_fee_rate(&self) -> impl Future> + Send; + + fn broadcast_package( + &self, + txs: &[&Transaction], + ) -> impl Future> + Send; +} + +impl OfflineClient +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + /// Create a new offline client with a generic key provider. + pub fn with_key_provider( + config: OfflineClientConfig, + key_provider: Arc, + blockchain: Arc, + wallet: Arc, + swap_storage: Arc, + ) -> Self { + Self::with_key_provider_parts(config, key_provider, None, blockchain, wallet, swap_storage) + } + + /// Create a new offline client with a discoverable key provider. + pub fn with_discoverable_key_provider

( + config: OfflineClientConfig, + key_provider: Arc

, + blockchain: Arc, + wallet: Arc, + swap_storage: Arc, + ) -> Self + where + P: DiscoverableKeyProvider + 'static, + { + let core_key_provider: Arc = key_provider.clone(); + let discoverable_key_provider: Arc = key_provider; + Self::with_key_provider_parts( + config, + core_key_provider, + Some(discoverable_key_provider), + blockchain, + wallet, + swap_storage, + ) + } + + fn with_key_provider_parts( + config: OfflineClientConfig, + key_provider: Arc, + discoverable_key_provider: Option>, + blockchain: Arc, + wallet: Arc, + swap_storage: Arc, + ) -> Self { + let secp = Secp256k1::new(); + let network_client = ark_grpc::Client::new(config.ark_server_url); + + // Normalize historical delegator keys once (preserve order, remove duplicates), then + // ensure the current delegator key is present at the front. + let mut seen = HashSet::new(); + let mut historical_delegator_pks: Vec<_> = config + .historical_delegator_pks + .into_iter() + .filter(|pk| seen.insert(*pk)) + .collect(); + + if let Some(pk) = config.delegator_pk { + historical_delegator_pks.retain(|k| *k != pk); + historical_delegator_pks.insert(0, pk); + } + + let boltz_referral_id = match config.boltz_referral_id { + BoltzReferralId::Default => Some(DEFAULT_BOLTZ_REFERRAL_ID.to_string()), + BoltzReferralId::Disabled => None, + BoltzReferralId::Custom(referral_id) => Some(referral_id), + }; + Self { + network_client, + key_provider, + discoverable_key_provider, + blockchain, + secp, + wallet, + swap_storage, + boltz_url: config.boltz_url.trim_end_matches('/').to_string(), + boltz_referral_id, + timeout: config.timeout, + server_info_ttl: config.server_info_ttl, + contract_store: Arc::new(Mutex::new(None)), + delegator_pk: config.delegator_pk, + historical_delegator_pks, + } + } + + /// Create a new offline client with a static keypair. + pub fn with_keypair( + config: OfflineClientConfig, + kp: Keypair, + blockchain: Arc, + wallet: Arc, + swap_storage: Arc, + ) -> Self { + let key_provider = Arc::new(StaticKeyProvider::new(kp)); + Self::with_key_provider(config, key_provider, blockchain, wallet, swap_storage) + } + + /// Create a new offline client with an [`Xpriv`]. + pub fn with_bip32( + config: OfflineClientConfig, + xpriv: Xpriv, + path: Option, + blockchain: Arc, + wallet: Arc, + swap_storage: Arc, + ) -> Self { + let path = path.unwrap_or( + DerivationPath::from_str(DEFAULT_DERIVATION_PATH).expect("valid derivation path"), + ); + let key_provider = Arc::new(Bip32KeyProvider::new(xpriv, path)); + Self::with_discoverable_key_provider(config, key_provider, blockchain, wallet, swap_storage) + } + + /// Use a custom contract store for the connected client. + /// + /// If unset, the client uses an in-memory contract store. + pub fn with_contract_store(self, store: Box) -> Self { + let mut contract_store = self + .contract_store + .lock() + .expect("contract store lock should not be poisoned"); + *contract_store = Some(store); + drop(contract_store); + self + } + + /// Returns the currently configured delegator pubkey, if any. + pub fn delegator_pk(&self) -> Option { + self.delegator_pk + } + + /// Returns the Boltz referral ID sent with all swap creation requests, if any. + pub fn boltz_referral_id(&self) -> Option<&str> { + self.boltz_referral_id.as_deref() + } + + fn contract_manager(&self, network: Network) -> Result { + let store = self + .contract_store + .lock() + .map_err(|_| Error::ad_hoc("contract store lock poisoned"))? + .take() + .unwrap_or_else(|| Box::new(MemoryContractStore::new())); + Ok(ContractManager::new(network, store)) + } + + /// Connects to the Ark server and retrieves server information. + /// + /// # Errors + /// + /// Returns an error if the connection fails or times out. + pub async fn connect(mut self) -> Result, Error> { + timeout_op(self.timeout, self.network_client.connect()) + .await + .context("Failed to connect to Ark server")??; + + self.finish_connect().await + } + + /// Connects to the Ark server and retrieves server information. + /// + /// If it encounters errors, it will retry `max_retries`. + /// + /// # Errors + /// + /// Returns an error if the connection fails or times out. + pub async fn connect_with_retries( + mut self, + max_retries: usize, + ) -> Result, Error> { + let mut n_retries = 0; + while n_retries < max_retries { + let res = timeout_op(self.timeout, self.network_client.connect()) + .await + .context("Failed to connect to Ark server")?; + + match res { + Ok(()) => break, + Err(error) => { + tracing::warn!(?error, "Failed to connect to Ark server, retrying"); + + sleep(Duration::from_secs(2)).await; + + n_retries += 1; + + continue; + } + }; + } + + self.finish_connect().await + } + + async fn finish_connect(mut self) -> Result, Error> { + let server_info = timeout_op(self.timeout, self.network_client.get_info()) + .await + .context("Failed to get Ark server info")??; + + tracing::debug!(ark_server_url = ?self.network_client, "Connected to Ark server"); + + let fee_estimator = build_fee_estimator(&server_info)?; + let mut contract_manager = self.contract_manager(server_info.network)?; + contract_manager.register_builtins()?; + let state = Arc::new(RwLock::new(ServerState { + server_info: server_info.clone(), + fee_estimator, + server_info_refreshed_at: Instant::now(), + contract_manager: Mutex::new(contract_manager), + })); + let hook_state = state.clone(); + self.network_client + .set_info_refresh_hook(move |server_info| { + update_server_state(&hook_state, server_info) + .map_err(|err| Box::new(err) as Box) + }); + + let client = Client { + inner: self, + state, + server_info_refresh_lock: Arc::new(tokio::sync::Mutex::new(())), + }; + + match client.migrate_boltz_vhtlc_contracts(&server_info).await { + Ok(migrated) if migrated > 0 => { + tracing::info!(migrated, "Migrated Boltz VHTLC contracts at connect"); + } + Ok(_) => {} + Err(error) => { + tracing::warn!(?error, "Failed to migrate Boltz VHTLC contracts at connect"); + } + } + + client.hydrate_persisted_contract_keys()?; + + // Eagerly persist the bounded baseline contract set. This mirrors the TS SDK split: + // connect() registers the always-watched index-0/current-key surface, while full + // gap-limit wallet regeneration is explicit via restore_contracts(). + if let Err(error) = client.persist_baseline_contracts(&server_info) { + tracing::warn!(?error, "Failed to persist baseline contracts at connect"); + } + + Ok(client) + } +} + +fn contract_info_from_stored( + contract: StoredContract, + server_info: &server::Info, + now_unix_secs: i64, +) -> Result { + let ctx = ContractContext::new(server_info.network); + let (address, address_kind, server_pk) = match &contract.contract_type { + contract_type if *contract_type == ContractType::default_vtxo() => { + let data: DefaultVtxoContract = + serde_json::from_value(contract.data.clone()).map_err(|e| { + Error::ad_hoc(format!("failed to decode default vtxo contract: {e}")) + })?; + ( + Some(data.vtxo(&ctx)?.to_ark_address().to_string()), + Some(ContractAddressKind::Ark), + Some(data.server), + ) + } + contract_type if *contract_type == ContractType::delegate_vtxo() => { + let data: DelegateVtxoContract = serde_json::from_value(contract.data.clone()) + .map_err(|e| { + Error::ad_hoc(format!("failed to decode delegate vtxo contract: {e}")) + })?; + ( + Some(data.vtxo(&ctx)?.to_ark_address().to_string()), + Some(ContractAddressKind::Ark), + Some(data.server), + ) + } + contract_type if *contract_type == ContractType::boarding() => { + let data: BoardingContract = serde_json::from_value(contract.data.clone()) + .map_err(|e| Error::ad_hoc(format!("failed to decode boarding contract: {e}")))?; + ( + Some(data.boarding_output(&ctx)?.address().to_string()), + Some(ContractAddressKind::Bitcoin), + Some(data.server), + ) + } + contract_type if *contract_type == ContractType::vhtlc() => { + let data: VhtlcContract = serde_json::from_value(contract.data.clone()) + .map_err(|e| Error::ad_hoc(format!("failed to decode vhtlc contract: {e}")))?; + let address = + ark_core::vhtlc::VhtlcScript::new(data.options.clone(), server_info.network) + .map_err(|e| Error::ad_hoc(format!("failed to build vhtlc address: {e}")))? + .address() + .to_string(); + ( + Some(address), + Some(ContractAddressKind::Ark), + Some(data.options.server), + ) + } + _ => { + let address = Address::from_script(&contract.script_pubkey, server_info.network) + .ok() + .map(|address| address.to_string()); + let address_kind = address.as_ref().map(|_| ContractAddressKind::Bitcoin); + (address, address_kind, None) + } + }; + let signer_status = + server_pk.map(|server_pk| server_info.signer_status_at(server_pk, now_unix_secs)); + + Ok(ContractInfo { + contract, + address, + address_kind, + server_pk, + signer_status, + }) +} + +fn build_fee_estimator(server_info: &server::Info) -> Result { + let fee_estimator_config = server_info + .fees + .clone() + .map(|fees| ark_fees::Config { + intent_offchain_input_program: fees.intent_fee.offchain_input.unwrap_or_default(), + intent_onchain_input_program: fees.intent_fee.onchain_input.unwrap_or_default(), + intent_offchain_output_program: fees.intent_fee.offchain_output.unwrap_or_default(), + intent_onchain_output_program: fees.intent_fee.onchain_output.unwrap_or_default(), + }) + .unwrap_or_default(); + + ark_fees::Estimator::new(fee_estimator_config).map_err(Error::ark_server) +} + +fn update_server_state( + state: &Arc>, + server_info: server::Info, +) -> Result<(), Error> { + let fee_estimator = build_fee_estimator(&server_info)?; + let mut state = state + .write() + .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?; + state.server_info = server_info; + state.fee_estimator = fee_estimator; + state.server_info_refreshed_at = Instant::now(); + Ok(()) +} + +impl Client +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + /// Returns Ark server info, refreshing the cached snapshot when its TTL has expired. + pub async fn server_info(&self) -> Result { + // Fast path avoids taking the async mutex while the cache is fresh. + if let Some(server_info) = self.cached_server_info_if_fresh()? { + return Ok(server_info); + } + + let _guard = self.server_info_refresh_lock.lock().await; + // Re-check after acquiring the mutex: another task may have refreshed while we waited. + if let Some(server_info) = self.cached_server_info_if_fresh()? { + return Ok(server_info); + } + + self.refresh_server_info_unlocked().await + } + + fn cached_server_info_if_fresh(&self) -> Result, Error> { + self.state + .read() + .map(|state| { + (state.server_info_refreshed_at.elapsed() < self.inner.server_info_ttl) + .then(|| state.server_info.clone()) + }) + .map_err(|_| Error::ad_hoc("client server state lock poisoned")) + } + + fn with_server_state(&self, f: impl FnOnce(&ServerState) -> T) -> Result { + self.state + .read() + .map(|state| f(&state)) + .map_err(|_| Error::ad_hoc("client server state lock poisoned")) + } + + fn eval_onchain_output_fee(&self, output: ark_fees::Output) -> Result { + self.with_server_state(|state| state.fee_estimator.eval_onchain_output(output))? + .map(|fee| Amount::from_sat(fee.to_satoshis())) + .map_err(Error::ad_hoc) + } + + /// The operator's `offchainInput` fee for spending these VTXOs, rounded up per input as the operator does. + pub(crate) fn eval_offchain_input_fees<'a>( + &self, + vtxos: impl IntoIterator, + dust: Amount, + ) -> Result { + self.with_server_state(|state| { + vtxos.into_iter().try_fold(0, |sum, vtxo| { + let input_type = if vtxo.is_recoverable(dust) { + ark_fees::VtxoType::Recoverable + } else { + ark_fees::VtxoType::Vtxo + }; + state + .fee_estimator + .eval_offchain_input(ark_fees::OffchainInput { + amount: vtxo.amount.to_sat(), + expiry: Some(vtxo.expires_at), + birth: Some(vtxo.created_at), + input_type, + ..Default::default() + }) + .map(|fee| sum + fee.to_satoshis()) + }) + })? + .map(Amount::from_sat) + .map_err(Error::ad_hoc) + } + + /// Force-fetch the latest Ark server `/info` and replace the cached server state. + /// + /// This bypasses the TTL used by [`Self::server_info`]. Concurrent refreshes are serialized + /// with the same refresh gate used by TTL-based refreshes. + /// + /// Returns the freshly fetched server info. The refreshed snapshot includes the server's + /// current [`server::Info::deprecated_signers`], allowing subsequent wallet operations to + /// observe signer rotations. + pub async fn refresh_server_info(&self) -> Result { + let _guard = self.server_info_refresh_lock.lock().await; + self.refresh_server_info_unlocked().await + } + + /// Fetch `/info` and update cached server state without acquiring the refresh gate. + /// + /// Callers must already hold `server_info_refresh_lock`, or otherwise guarantee that + /// concurrent refreshes are serialized. + async fn refresh_server_info_unlocked(&self) -> Result { + let server_info = timeout_op(self.inner.timeout, self.network_client().get_info()) + .await + .context("Failed to refresh Ark server info")??; + + update_server_state(&self.state, server_info.clone())?; + + Ok(server_info) + } + + /// Returns the currently configured delegator pubkey, if any. + pub fn delegator_pk(&self) -> Option { + self.inner.delegator_pk() + } + + /// Returns the Boltz referral ID sent with all swap creation requests, if any. + pub fn boltz_referral_id(&self) -> Option<&str> { + self.inner.boltz_referral_id() + } + + /// List all contracts currently known to this wallet. + /// + /// This is a wallet-facing view over the contract store: each row includes the stored contract, + /// a derived address when the SDK knows the contract type, and signer-rotation status for + /// contracts that encode a server signer. + pub async fn list_contracts(&self) -> Result, Error> { + let server_info = self.server_info().await?; + let now = unix_now()?; + let contracts = { + let state = self + .state + .read() + .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?; + let contracts = state + .contract_manager + .lock() + .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))? + .list()?; + contracts + }; + + contracts + .into_iter() + .map(|contract| contract_info_from_stored(contract, &server_info, now)) + .collect() + } + + /// Get a new offchain receiving address. + /// + /// When a delegator is configured (via [`OfflineClientConfig::delegator_pk`]), + /// returns a 3-leaf delegate address. Otherwise returns a standard 2-leaf address. + /// + /// For HD wallets, this will derive a new address each time it's called. + /// For static key providers, this will always return the same address. + pub async fn get_offchain_address(&self) -> Result<(ArkAddress, Vtxo), Error> { + let server_info = self.server_info().await?; + self.get_offchain_address_with_server_info(&server_info) + } + + pub(crate) fn get_offchain_address_with_server_info( + &self, + server_info: &server::Info, + ) -> Result<(ArkAddress, Vtxo), Error> { + let server_signer = server_info.signer_pk.into(); + let owner = self + .next_keypair(KeypairIndex::LastUnused)? + .public_key() + .into(); + + self.persist_offchain_vtxo_contract(server_info, server_signer, owner) + } + + /// Get all known offchain addresses for this wallet. + /// + /// When a delegator is configured, this returns **both** the default (2-leaf) and delegate + /// (3-leaf) addresses for each key, so that VTXOs at either address are visible. If + /// historical delegator keys are set via `historical_delegator_pks` passed to + /// [`OfflineClientConfig::historical_delegator_pks`], addresses for those are included too. + pub async fn get_offchain_addresses(&self) -> Result, Error> { + let server_info = self.server_info().await?; + self.get_offchain_addresses_with_server_info(&server_info) + } + + fn persist_baseline_contracts(&self, server_info: &server::Info) -> Result<(), Error> { + self.persist_baseline_offchain_contracts(server_info)?; + self.persist_watch_boarding_outputs(server_info)?; + Ok(()) + } + + fn hydrate_persisted_contract_keys(&self) -> Result<(), Error> { + if let Some(key_provider) = self.inner.discoverable_key_provider.as_ref() { + let state = self + .state + .read() + .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?; + let contracts = state + .contract_manager + .lock() + .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))? + .list()?; + + let mut indices: Vec = contracts + .into_iter() + .filter_map(|contract| contract.key_index) + .collect(); + indices.sort_unstable(); + indices.dedup(); + + for index in indices { + key_provider.cache_keypair_at_index(index)?; + } + }; + + Ok(()) + } + + fn persist_baseline_offchain_contracts( + &self, + server_info: &server::Info, + ) -> Result, Error> { + let owner = self + .next_keypair(KeypairIndex::LastUnused)? + .x_only_public_key() + .0; + let candidate_delays = ark_core::candidate_exit_delays( + server_info.unilateral_exit_delay, + server_info.network, + )?; + + let mut results = Vec::new(); + for server_signer in server_info.all_server_keys() { + for exit_delay in &candidate_delays { + results.push(self.persist_default_vtxo_contract( + server_info.network, + server_signer, + owner, + *exit_delay, + )?); + + let mut seen = HashSet::new(); + for dpk in &self.inner.historical_delegator_pks { + if !seen.insert(dpk) { + continue; + } + results.push(self.persist_delegate_vtxo_contract( + server_info.network, + server_signer, + owner, + *dpk, + *exit_delay, + )?); + } + } + } + Ok(results) + } + + pub(crate) fn get_offchain_addresses_with_server_info( + &self, + server_info: &server::Info, + ) -> Result, Error> { + let pks = self.inner.key_provider.get_cached_pks()?; + + // Build addresses for current signer + all deprecated signers so VTXOs under any + // known server key are discovered and visible in the balance. + let all_server_keys: Vec = server_info.all_server_keys().collect(); + // Enumerate under every candidate exit delay (the advertised delay plus, on mainnet, the + // legacy delay), mirroring `restore_contracts`: a VTXO minted before the operator shortened + // the delay lives at a legacy-delay address, so building only the current delay + // would hide it from `list_vtxos`/balance/migration even though its key was + // discovered. Off mainnet this is just the advertised delay (no behaviour change). + let candidate_delays = ark_core::candidate_exit_delays( + server_info.unilateral_exit_delay, + server_info.network, + )?; + + let mut results = Vec::new(); + + for owner_pk in &pks { + for server_signer in &all_server_keys { + for exit_delay in &candidate_delays { + results.push(self.persist_default_vtxo_contract( + server_info.network, + *server_signer, + *owner_pk, + *exit_delay, + )?); + + // Delegate addresses for all known delegator keys. + let mut seen = HashSet::new(); + for dpk in &self.inner.historical_delegator_pks { + if !seen.insert(dpk) { + continue; + } + results.push(self.persist_delegate_vtxo_contract( + server_info.network, + *server_signer, + *owner_pk, + *dpk, + *exit_delay, + )?); + } + } + } + } + + Ok(results) + } + + fn persist_offchain_vtxo_contract( + &self, + server_info: &server::Info, + server_signer: XOnlyPublicKey, + owner: XOnlyPublicKey, + ) -> Result<(ArkAddress, Vtxo), Error> { + match self.inner.delegator_pk { + Some(delegator) => self.persist_delegate_vtxo_contract( + server_info.network, + server_signer, + owner, + delegator, + server_info.unilateral_exit_delay, + ), + None => self.persist_default_vtxo_contract( + server_info.network, + server_signer, + owner, + server_info.unilateral_exit_delay, + ), + } + } + + fn persist_default_vtxo_contract( + &self, + network: Network, + server: XOnlyPublicKey, + owner: XOnlyPublicKey, + exit_delay: bitcoin::Sequence, + ) -> Result<(ArkAddress, Vtxo), Error> { + let key_index = self.derivation_index_for_pk(&owner); + let contract = DefaultVtxoContract { + server, + owner, + exit_delay, + }; + let state = self + .state + .read() + .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?; + let mut manager = state + .contract_manager + .lock() + .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))?; + manager.insert_or_get(contract.clone(), ContractState::Active, key_index)?; + let ctx = ContractContext::new(network); + // Derive from the requested default VTXO contract, not from the stored row: the store may + // already contain an equivalent boarding row for this script, but the caller still needs + // the offchain Arkade address for this default VTXO script. + let vtxo = contract.vtxo(&ctx)?; + Ok((vtxo.to_ark_address(), vtxo)) + } + + fn persist_delegate_vtxo_contract( + &self, + network: Network, + server: XOnlyPublicKey, + owner: XOnlyPublicKey, + delegator: XOnlyPublicKey, + exit_delay: bitcoin::Sequence, + ) -> Result<(ArkAddress, Vtxo), Error> { + let key_index = self.derivation_index_for_pk(&owner); + let contract = DelegateVtxoContract { + server, + owner, + delegator, + exit_delay, + }; + let state = self + .state + .read() + .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?; + let mut manager = state + .contract_manager + .lock() + .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))?; + let stored = manager.insert_or_get(contract, ContractState::Active, key_index)?; + let contract = manager + .get_typed::(&stored.script_pubkey)? + .ok_or_else(|| Error::ad_hoc("missing delegate vtxo contract"))?; + let ctx = ContractContext::new(network); + let vtxo = contract.vtxo(&ctx)?; + Ok((vtxo.to_ark_address(), vtxo)) + } + + /// Restore persisted contracts using explicit contract discovery. + /// + /// This method derives candidate default VTXO, delegate VTXO, and boarding contracts for each + /// key index. It queries the Arkade VTXO index for offchain candidates and the configured + /// blockchain backend for boarding candidates, persists contracts that have activity, and stops + /// when a full batch has no discovered contracts. + /// + /// No-op for StaticKeyProvider. + /// + /// # Arguments + /// + /// * `gap_limit` - Number of consecutive unused key indexes before stopping + pub async fn restore_contracts(&self, gap_limit: u32) -> Result { + if gap_limit == 0 { + return Err(Error::ad_hoc("restore gap limit must be greater than zero")); + } + + let Some(key_provider) = self.inner.discoverable_key_provider.as_ref() else { + tracing::debug!("Key provider does not support discovery, skipping"); + return Ok(ContractRestoreReport { + gap_limit, + ..Default::default() + }); + }; + + let server_info = &self.server_info().await?; + let ctx = ContractContext::new(server_info.network); + let all_server_keys: Vec = server_info.all_server_keys().collect(); + let offchain_exit_delays = ark_core::candidate_exit_delays( + server_info.unilateral_exit_delay, + server_info.network, + )?; + let boarding_exit_delays = + ark_core::candidate_exit_delays(server_info.boarding_exit_delay, server_info.network)?; + + let mut start_index = 0u32; + let mut report = ContractRestoreReport { + gap_limit, + ..Default::default() + }; + + tracing::info!(gap_limit, "Starting contract restore"); + + loop { + let batch = self.restore_candidate_batch( + start_index, + gap_limit, + &all_server_keys, + &offchain_exit_delays, + &boarding_exit_delays, + )?; + + if batch.is_empty() { + break; + } + + report.scanned_from.get_or_insert(start_index); + let scanned_to_exclusive = start_index + .checked_add(batch.len() as u32) + .ok_or_else(|| Error::ad_hoc("Key discovery index overflow"))?; + report.scanned_to_exclusive = Some(scanned_to_exclusive); + report.scanned_keys += batch.len() as u32; + + let mut offchain_addresses = Vec::new(); + for (_, _, candidates) in &batch { + for candidate in candidates { + if let RestoreDiscoveryTarget::Offchain(address) = + candidate.discovery_target(self.secp(), &ctx)? + { + offchain_addresses.push(address); + } + } + } + let vtxo_list = self + .list_vtxos_for_addresses(offchain_addresses.into_iter()) + .await?; + let mut offchain_vtxos_by_script = + HashMap::>::new(); + for vtxo in vtxo_list.all() { + offchain_vtxos_by_script + .entry(vtxo.script.clone()) + .or_default() + .push(ContractRestoreVtxo { + outpoint: vtxo.outpoint, + amount: vtxo.amount, + is_spent: vtxo.is_spent, + is_swept: vtxo.is_swept, + is_unrolled: vtxo.is_unrolled, + }); + } + + let mut found_any = false; + for (index, kp, candidates) in batch { + let mut found_for_key = false; + + for candidate in candidates { + let script = candidate.script_pubkey(self.secp(), &ctx)?; + let contract_type = candidate.contract_type(); + let target = candidate.discovery_target(self.secp(), &ctx)?; + let discovery = match target { + RestoreDiscoveryTarget::Offchain(_) => offchain_vtxos_by_script + .get(&script) + .filter(|vtxos| !vtxos.is_empty()) + .cloned() + .map(|vtxos| ContractRestoreDiscovery::Offchain { vtxos }), + RestoreDiscoveryTarget::Boarding(address) => { + let outpoints = self + .blockchain() + .find_outpoints(&address) + .await? + .into_iter() + .filter(|utxo| !utxo.is_spent) + .map(|utxo| ContractRestoreOutpoint { + outpoint: utxo.outpoint, + amount: utxo.amount, + confirmation_blocktime: utxo.confirmation_blocktime, + confirmations: utxo.confirmations, + }) + .collect::>(); + (!outpoints.is_empty()) + .then_some(ContractRestoreDiscovery::Boarding { outpoints }) + } + }; + + let Some(discovery) = discovery else { + continue; + }; + + let inserted = self.persist_restore_candidate(candidate, index)?; + let status = if inserted { + report.inserted_contracts += 1; + ContractRestoreEntryStatus::Inserted + } else { + report.known_contracts += 1; + ContractRestoreEntryStatus::Known + }; + match &discovery { + ContractRestoreDiscovery::Offchain { .. } => report.offchain_contracts += 1, + ContractRestoreDiscovery::Boarding { .. } => report.boarding_contracts += 1, + } + report.entries.push(ContractRestoreEntry { + key_index: index, + contract_type, + script_pubkey: script, + status, + discovery, + }); + found_for_key = true; + } + + if found_for_key { + key_provider.cache_discovered_keypair(index, kp)?; + report.discovered_key_indexes.push(index); + report.last_used_key_index = Some( + report + .last_used_key_index + .map_or(index, |last| last.max(index)), + ); + report.next_key_index = report + .last_used_key_index + .and_then(|last| last.checked_add(1)); + found_any = true; + } + } + + if !found_any { + break; + } + + start_index = start_index + .checked_add(gap_limit) + .ok_or_else(|| Error::ad_hoc("Key discovery index overflow"))?; + } + + tracing::info!(?report, "Contract restore completed"); + + Ok(report) + } + + fn restore_candidate_batch( + &self, + start_index: u32, + gap_limit: u32, + server_keys: &[XOnlyPublicKey], + offchain_exit_delays: &[bitcoin::Sequence], + boarding_exit_delays: &[bitcoin::Sequence], + ) -> Result)>, Error> { + let mut batch = Vec::with_capacity(gap_limit as usize); + + for i in 0..gap_limit { + let index = start_index + .checked_add(i) + .ok_or_else(|| Error::ad_hoc("Key discovery index overflow"))?; + let Some(key_provider) = self.inner.discoverable_key_provider.as_ref() else { + break; + }; + let Some(kp) = key_provider.derive_at_discovery_index(index)? else { + break; + }; + let owner = kp.x_only_public_key().0; + let mut candidates = Vec::new(); + + for server in server_keys { + for exit_delay in offchain_exit_delays { + candidates.push(RestoreCandidate::DefaultVtxo(DefaultVtxoContract { + server: *server, + owner, + exit_delay: *exit_delay, + })); + + let mut seen_delegators = HashSet::new(); + for delegator in &self.inner.historical_delegator_pks { + if !seen_delegators.insert(delegator) { + continue; + } + candidates.push(RestoreCandidate::DelegateVtxo(DelegateVtxoContract { + server: *server, + owner, + delegator: *delegator, + exit_delay: *exit_delay, + })); + } + } + + for exit_delay in boarding_exit_delays { + candidates.push(RestoreCandidate::Boarding(BoardingContract { + server: *server, + owner, + exit_delay: *exit_delay, + })); + } + } + + batch.push((index, kp, candidates)); + } + + Ok(batch) + } + + fn persist_restore_candidate( + &self, + candidate: RestoreCandidate, + key_index: u32, + ) -> Result { + let state = self + .state + .read() + .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?; + let mut manager = state + .contract_manager + .lock() + .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))?; + let ctx = ContractContext::new(state.server_info.network); + let script = candidate.script_pubkey(self.secp(), &ctx)?; + let existed = manager.get(&script)?.is_some(); + + match candidate { + RestoreCandidate::DefaultVtxo(contract) => { + manager.insert_or_get(contract, ContractState::Active, Some(key_index))?; + } + RestoreCandidate::DelegateVtxo(contract) => { + manager.insert_or_get(contract, ContractState::Active, Some(key_index))?; + } + RestoreCandidate::Boarding(contract) => { + manager.insert_or_get(contract, ContractState::Active, Some(key_index))?; + } + } + + Ok(!existed) + } + + // At the moment we are always generating the same address. + pub async fn get_boarding_address(&self) -> Result { + let server_info = &self.server_info().await?; + let owner = self + .next_keypair(KeypairIndex::LastUnused)? + .x_only_public_key() + .0; + + let contract = BoardingContract { + server: server_info.signer_pk.into(), + owner, + exit_delay: server_info.boarding_exit_delay, + }; + let key_index = self.derivation_index_for_pk(&owner); + let state = self + .state + .read() + .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?; + let stored = state + .contract_manager + .lock() + .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))? + .insert_or_get(contract, ContractState::Active, key_index)?; + + Address::from_script(&stored.script_pubkey, server_info.network) + .map_err(|e| Error::ad_hoc(format!("invalid boarding contract script: {e}"))) + } + + pub fn get_onchain_address(&self) -> Result { + self.inner.wallet.get_onchain_address() + } + + pub async fn get_boarding_addresses(&self) -> Result, Error> { + let server_info = &self.server_info().await?; + + // Persist a boarding output for every (signer, exit-delay) candidate and return the + // de-duplicated addresses. This is the watch/history surface: it covers the current signer + // plus all deprecated signers (server-key rotation), each crossed with the candidate + // exit-delay set (the advertised delay plus, on mainnet, the legacy delay) so deposits + // minted under an older delay or an older key are still visible. + // + // The spend path (settle) deliberately stays current-signer-only; + // deprecated-signer boarding recovery is handled via migrate_deprecated_signer_vtxos(). + let outputs = self.persist_watch_boarding_outputs(server_info)?; + + let mut seen = HashSet::new(); + let mut addresses = Vec::with_capacity(outputs.len()); + for output in &outputs { + let address = output.address().clone(); + if seen.insert(address.clone()) { + addresses.push(address); + } + } + + Ok(addresses) + } + + /// Persist (idempotently) a boarding output for each signer the wallet should watch crossed + /// with each candidate exit delay, returning annotated boarding outputs. + /// + /// Covers the current signer plus every deprecated signer, each paired with + /// [`ark_core::candidate_exit_delays`] (the advertised boarding-exit delay plus, on mainnet, + /// the legacy delay). Re-persisting the same boarding contract is idempotent, so this is safe + /// to call repeatedly — at connect time and again from [`Client::get_boarding_addresses`]. + fn persist_watch_boarding_outputs( + &self, + server_info: &server::Info, + ) -> Result, Error> { + let candidate_delays = + ark_core::candidate_exit_delays(server_info.boarding_exit_delay, server_info.network)?; + let owner = self + .next_keypair(KeypairIndex::LastUnused)? + .x_only_public_key() + .0; + let key_index = self.derivation_index_for_pk(&owner); + let state = self + .state + .read() + .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?; + let mut manager = state + .contract_manager + .lock() + .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))?; + + for server_pk in server_info.all_server_keys() { + for exit_delay in &candidate_delays { + let contract = BoardingContract { + server: server_pk, + owner, + exit_delay: *exit_delay, + }; + manager.insert_or_get(contract, ContractState::Active, key_index)?; + } + } + + manager.annotated_boarding_outputs_for_exit_delays(&candidate_delays) + } + + pub async fn get_virtual_tx_outpoints( + &self, + addresses: impl Iterator, + ) -> Result, Error> { + let request = GetVtxosRequest::new_for_addresses(addresses); + self.fetch_all_vtxos(request).await + } + + /// List every VTXO the wallet ever held, spent ones included. + /// + /// The list grows with the wallet's history. Callers that only need unspent VTXOs should + /// use [`Self::list_vtxos_filtered`]. + pub async fn list_vtxos(&self) -> Result { + let server_info = self.server_info().await?; + self.list_vtxos_with_server_info(&server_info, None).await + } + + /// Like [`Self::list_vtxos`], restricted by the Ark server to the VTXOs matching `filter`. + /// + /// The unfiltered list includes every VTXO the wallet ever held and is fetched 100 at a + /// time, so a long-lived wallet pays for its whole history on every call. Use + /// [`GetVtxosRequestFilter::Renewable`] (spendable plus recoverable) for balances and + /// settlement, and [`GetVtxosRequestFilter::Spendable`] for offchain coin selection. + pub async fn list_vtxos_filtered( + &self, + filter: GetVtxosRequestFilter, + ) -> Result { + let server_info = self.server_info().await?; + self.list_vtxos_with_server_info(&server_info, Some(filter)) + .await + } + + pub(crate) async fn list_vtxos_with_server_info( + &self, + server_info: &server::Info, + filter: Option, + ) -> Result { + let addresses = self.active_offchain_contract_addresses()?; + let mut request = GetVtxosRequest::new_for_addresses(addresses.into_iter()); + if let Some(filter) = filter { + request = request.with_filter(filter)?; + } + let virtual_tx_outpoints = self.fetch_all_vtxos(request).await?; + let contract_vtxos = self.annotate_vtxos(virtual_tx_outpoints)?; + + Ok(AnnotatedVtxoList::new(server_info.dust, contract_vtxos)) + } + + pub async fn list_vtxos_for_addresses( + &self, + addresses: impl Iterator, + ) -> Result { + let server_info = self.server_info().await?; + self.list_vtxos_for_addresses_with_server_info(&server_info, addresses) + .await + } + + pub(crate) async fn list_vtxos_for_addresses_with_server_info( + &self, + server_info: &server::Info, + addresses: impl Iterator, + ) -> Result { + let virtual_tx_outpoints = self + .get_virtual_tx_outpoints(addresses) + .await + .context("failed to get VTXOs for addresses")?; + + let vtxo_list = VtxoList::new(server_info.dust, virtual_tx_outpoints); + + Ok(vtxo_list) + } + + pub async fn list_vtxos_for_outpoints( + &self, + outpoints: Vec, + ) -> Result { + let request = GetVtxosRequest::new_for_outpoints(&outpoints); + let virtual_tx_outpoints = self.fetch_all_vtxos(request).await?; + let contract_vtxos = self.annotate_vtxos(virtual_tx_outpoints)?; + Ok(AnnotatedVtxoList::new( + self.server_info().await?.dust, + contract_vtxos, + )) + } + + pub async fn get_vtxo_chain( + &self, + out_point: OutPoint, + size: i32, + index: i32, + ) -> Result, Error> { + let vtxo_chain = timeout_op( + self.inner.timeout, + self.network_client() + .get_vtxo_chain(Some(out_point), Some((size, index)), None, None), + ) + .await + .context("Failed to fetch VTXO chain")??; + + Ok(Some(vtxo_chain)) + } + + pub async fn offchain_balance(&self) -> Result { + // Every amount below is over unspent VTXOs. + let vtxo_list = self + .list_vtxos_filtered(GetVtxosRequestFilter::Renewable) + .await + .context("failed to list VTXOs")?; + let now = unix_now()?; + let server_info = self.server_info().await?; + + let spendable_outpoints: HashSet = vtxo_list + .spendable_offchain_at(&server_info, now) + .map(|entry| entry.vtxo().outpoint) + .collect(); + + let pre_confirmed = vtxo_list + .pre_confirmed() + .filter(|entry| spendable_outpoints.contains(&entry.vtxo().outpoint)) + .fold(Amount::ZERO, |acc, entry| acc + entry.vtxo().amount); + + let confirmed = vtxo_list + .confirmed() + .filter(|entry| spendable_outpoints.contains(&entry.vtxo().outpoint)) + .fold(Amount::ZERO, |acc, entry| acc + entry.vtxo().amount); + + let recoverable = vtxo_list + .recoverable() + .fold(Amount::ZERO, |acc, entry| acc + entry.vtxo().amount); + + let pending_recovery = vtxo_list + .pending_recovery_due_to_signer_at(&server_info, now) + .fold(Amount::ZERO, |acc, entry| acc + entry.vtxo().amount); + + // Aggregate asset balances from currently offchain-spendable VTXOs only. + let mut asset_balances: HashMap = HashMap::new(); + for entry in vtxo_list.spendable_offchain_at(&server_info, now) { + for asset in &entry.vtxo().assets { + let total = asset_balances + .get(&asset.asset_id) + .copied() + .unwrap_or(0) + .checked_add(asset.amount) + .ok_or_else(|| Error::ad_hoc("asset balance overflow"))?; + asset_balances.insert(asset.asset_id, total); + } + } + + Ok(OffChainBalance { + pre_confirmed, + confirmed, + recoverable, + pending_recovery, + asset_balances, + }) + } + + /// Get information about an asset by its ID. + pub async fn get_asset(&self, asset_id: AssetId) -> Result { + timeout_op( + self.inner.timeout, + self.network_client().get_asset(asset_id), + ) + .await + .context("Failed to get asset info")? + .map_err(Error::ark_server) + } + + pub async fn transaction_history(&self) -> Result, Error> { + let mut boarding_transactions = Vec::new(); + let mut boarding_commitment_transactions = Vec::new(); + + let boarding_addresses = self.get_boarding_addresses().await?; + for boarding_address in boarding_addresses.iter() { + let outpoints = timeout_op( + self.inner.timeout, + self.blockchain().find_outpoints(boarding_address), + ) + .await + .context("Failed to find outpoints")??; + + for ExplorerUtxo { + outpoint, + amount, + confirmation_blocktime, + .. + } in outpoints.iter() + { + let confirmed_at = confirmation_blocktime.map(|t| t as i64); + + boarding_transactions.push(history::Transaction::Boarding { + txid: outpoint.txid, + amount: *amount, + confirmed_at, + }); + + let status = timeout_op( + self.inner.timeout, + self.blockchain() + .get_output_status(&outpoint.txid, outpoint.vout), + ) + .await + .context("Failed to get Tx output status")??; + + if let Some(spend_txid) = status.spend_txid { + boarding_commitment_transactions.push(spend_txid); + } + } + } + + let vtxo_list = self.list_vtxos().await?; + + let spent_outpoints = vtxo_list + .spent() + .map(|entry| entry.vtxo().clone()) + .collect::>(); + let unspent_outpoints = vtxo_list + .all_unspent() + .map(|entry| entry.vtxo().clone()) + .collect::>(); + + let incoming_transactions = generate_incoming_vtxo_transaction_history( + &spent_outpoints, + &unspent_outpoints, + &boarding_commitment_transactions, + )?; + + let outgoing_txs = + generate_outgoing_vtxo_transaction_history(&spent_outpoints, &unspent_outpoints)?; + + let mut outgoing_transactions = vec![]; + for tx in outgoing_txs { + let tx = match tx { + OutgoingTransaction::Complete(tx) => tx, + OutgoingTransaction::Incomplete(incomplete_tx) => { + let first_outpoint = incomplete_tx.first_outpoint(); + + let request = GetVtxosRequest::new_for_outpoints(&[first_outpoint]); + let vtxos = self.fetch_all_vtxos(request).await?; + + match vtxos.first() { + Some(virtual_tx_outpoint) => { + match incomplete_tx.finish(virtual_tx_outpoint) { + Ok(tx) => tx, + Err(e) => { + tracing::warn!( + %first_outpoint, + "Could not finish outgoing TX, skipping: {e}" + ); + continue; + } + } + } + None => { + tracing::warn!( + %first_outpoint, + "Could not find virtual TX outpoint for outgoing TX, skipping" + ); + continue; + } + } + } + OutgoingTransaction::IncompleteOffboard(incomplete_offboard) => { + let status = timeout_op( + self.inner.timeout, + self.blockchain() + .get_tx_status(&incomplete_offboard.commitment_txid()), + ) + .await + .context("failed to get commitment TX status")??; + + incomplete_offboard.finish(status.confirmed_at) + } + }; + + outgoing_transactions.push(tx); + } + + let mut txs = [ + boarding_transactions, + incoming_transactions, + outgoing_transactions, + ] + .concat(); + + sort_transactions_by_created_at(&mut txs); + + Ok(txs) + } + + /// The server's dust threshold amount. + pub async fn dust(&self) -> Result { + Ok(self.server_info().await?.dust) + } + + pub fn network_client(&self) -> ark_grpc::Client { + self.inner.network_client.clone() + } + + /// Fetch all VTXOs for a request, handling pagination internally. + async fn fetch_all_vtxos( + &self, + request: GetVtxosRequest, + ) -> Result, Error> { + if request.reference().is_empty() { + return Ok(Vec::new()); + } + + tracing::debug!(filter = ?request.filter(), "Fetching VTXOs"); + + let mut all_vtxos = Vec::new(); + let mut cursor = 0; + const PAGE_SIZE: i32 = 100; + + loop { + let paged_request = request.clone().with_page(PAGE_SIZE, cursor); + let response = timeout_op( + self.inner.timeout, + self.network_client().list_vtxos(paged_request), + ) + .await + .context("failed to fetch list of VTXOs")??; + + all_vtxos.extend(response.vtxos); + + match next_vtxo_page(response.page.as_ref())? { + Some(next) => cursor = next, + None => break, + } + } + + Ok(all_vtxos) + } + + fn next_keypair(&self, keypair_index: KeypairIndex) -> Result { + self.inner.key_provider.get_next_keypair(keypair_index) + } + fn keypair_by_pk(&self, pk: &XOnlyPublicKey) -> Result { + self.inner.key_provider.get_keypair_for_pk(pk) + } + + fn sign_for_pk(&self, pk: &XOnlyPublicKey, msg: &Message) -> Result { + let keypair = self.keypair_by_pk(pk)?; + Ok(self.secp().sign_schnorr_no_aux_rand(msg, &keypair)) + } + + fn boarding_outputs(&self) -> Result, Error> { + let state = self + .state + .read() + .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?; + // Include default VTXO rows only when their CSV delay matches a boarding delay candidate. + // This covers the equal-delay case where a default VTXO row is the stored row for a script + // that can also be used for boarding, without turning every default VTXO receive script + // into a boarding watch. + let candidate_delays = ark_core::candidate_exit_delays( + state.server_info.boarding_exit_delay, + state.server_info.network, + )?; + let outputs = state + .contract_manager + .lock() + .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))? + .annotated_boarding_outputs_for_exit_delays(&candidate_delays)?; + Ok(outputs) + } + + fn active_offchain_contract_addresses(&self) -> Result, Error> { + self.active_offchain_contracts().map(|contracts| { + contracts + .into_iter() + .map(|contract| contract.address) + .collect() + }) + } + + fn active_offchain_contracts(&self) -> Result, Error> { + let state = self + .state + .read() + .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?; + let candidate_delays = ark_core::candidate_exit_delays( + state.server_info.unilateral_exit_delay, + state.server_info.network, + )?; + let contracts = state + .contract_manager + .lock() + .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))? + .active_offchain_contracts(&candidate_delays)?; + Ok(contracts) + } + + fn annotate_vtxos(&self, vtxos: Vec) -> Result, Error> { + let state = self + .state + .read() + .map_err(|_| Error::ad_hoc("client server state lock poisoned"))?; + let annotated = state + .contract_manager + .lock() + .map_err(|_| Error::ad_hoc("contract manager lock poisoned"))? + .annotate_vtxos(vtxos)?; + Ok(annotated) + } + + fn derivation_index_for_pk(&self, pk: &XOnlyPublicKey) -> Option { + self.inner + .discoverable_key_provider + .as_ref() + .and_then(|provider| provider.get_derivation_index_for_pk(pk)) + } + + fn secp(&self) -> &Secp256k1 { + &self.inner.secp + } + + fn blockchain(&self) -> &B { + &self.inner.blockchain + } + + fn swap_storage(&self) -> &S { + &self.inner.swap_storage + } + + /// Use the P2A output of a transaction to bump its transaction fee with a child transaction. + pub async fn bump_tx(&self, parent: &Transaction) -> Result { + let fee_rate = timeout_op(self.inner.timeout, self.blockchain().get_fee_rate()) + .await + .context("Failed to retrieve fee rate")??; + + let change_address = self.inner.wallet.get_onchain_address()?; + + // Create a closure that converts CoinSelectionResult to UtxoCoinSelection + let select_coins_fn = + |target_amount: Amount| -> Result { + self.inner.wallet.select_coins(target_amount).map_err(|e| { + ark_core::Error::ad_hoc(format!("failed to select coins for anchor TX: {e}")) + }) + }; + + // Build the PSBT using ark-core (includes witness UTXO setup) + let mut psbt = build_anchor_tx(parent, change_address, fee_rate, select_coins_fn) + .map_err(|e| Error::ad_hoc(e.to_string()))?; + + // Sign the transaction + self.inner + .wallet + .sign(&mut psbt) + .context("failed to sign bump TX")?; + + // Extract the final transaction + let tx = psbt.extract_tx().map_err(Error::ad_hoc)?; + + Ok(tx) + } + + /// Subscribe to receive transaction notifications for specific VTXO scripts + /// + /// This method allows you to subscribe to get notified about transactions + /// affecting the provided VTXO addresses. It can also be used to update an + /// existing subscription by adding new scripts to it. + /// + /// # Arguments + /// + /// * `scripts` - Vector of ArkAddress to subscribe to + /// * `subscription_id` - Unique identifier for the subscription. Use the same ID to update an + /// existing subscription. Use None for new subscriptions + /// + /// # Returns + /// + /// Returns the subscription ID if successful + pub async fn subscribe_to_scripts( + &self, + scripts: Vec, + subscription_id: Option, + ) -> Result { + self.network_client() + .subscribe_to_scripts(scripts, subscription_id) + .await + .map_err(Into::into) + } + + /// Remove scripts from an existing subscription + /// + /// This method allows you to unsubscribe from receiving notifications for + /// specific VTXO scripts while keeping the subscription active for other scripts. + /// + /// # Arguments + /// + /// * `scripts` - Vector of ArkAddress to unsubscribe from + /// * `subscription_id` - The subscription ID to update + pub async fn unsubscribe_from_scripts( + &self, + scripts: Vec, + subscription_id: String, + ) -> Result<(), Error> { + self.network_client() + .unsubscribe_from_scripts(scripts, subscription_id) + .await + .map_err(Into::into) + } + + /// Get a subscription stream that returns subscription responses + /// + /// This method returns a stream that yields SubscriptionResponse messages + /// containing information about new and spent VTXOs for the subscribed scripts. + /// + /// # Arguments + /// + /// * `subscription_id` - The subscription ID to get the stream for + /// * `filter` - Optional [`SubscriptionFilter`] applied when the stream is opened + /// + /// # Returns + /// + /// Returns a Stream of SubscriptionResponse messages + pub async fn get_subscription( + &self, + subscription_id: String, + filter: Option, + ) -> Result> + Unpin, Error> + { + self.network_client() + .get_subscription(subscription_id, filter) + .await + .map_err(Into::into) + } + + /// Update the filter of an existing subscription + /// + /// The filter's expressions are always overwritten as a whole (an empty list clears them), + /// whereas the scripts are added to and removed from the subscription's existing script set. + pub async fn update_subscription( + &self, + subscription_id: String, + filter: SubscriptionFilter, + ) -> Result<(), Error> { + self.network_client() + .update_subscription(subscription_id, filter) + .await + .map_err(Into::into) + } +} + +/// The cursor for the page after `page`, or `None` once the last page has been read. +/// +/// The server numbers pages from 1 and clamps `next` to `total`, so `next` equals `total` both +/// on the second-to-last page and on the last one. Only `current` tells them apart: stopping on +/// `next == total` would drop the last page whenever there is more than one. +/// +/// A `next` that does not move past `current` is an error rather than a stop: following it would +/// loop forever, and stopping early would silently return a partial list. +fn next_vtxo_page(page: Option<&server::IndexerPage>) -> Result, Error> { + let Some(page) = page else { + return Ok(None); + }; + + if page.current >= page.total { + return Ok(None); + } + + if page.next <= page.current { + return Err(Error::ad_hoc(format!( + "server returned a VTXO page that does not advance: current {}, next {}, total {}", + page.current, page.next, page.total + ))); + } + + Ok(Some(page.next)) +} + +#[cfg(test)] +mod next_vtxo_page_tests { + use super::*; + + fn page(current: i32, next: i32, total: i32) -> server::IndexerPage { + server::IndexerPage { + current, + next, + total, + } + } + + fn next(page: Option<&server::IndexerPage>) -> Option { + next_vtxo_page(page).unwrap() + } + + #[test] + fn no_page_info_stops() { + assert_eq!(next(None), None); + } + + #[test] + fn empty_result_stops() { + assert_eq!(next(Some(&page(1, 0, 0))), None); + } + + #[test] + fn single_page_stops() { + assert_eq!(next(Some(&page(1, 1, 1))), None); + } + + #[test] + fn second_to_last_page_continues_to_the_last_one() { + assert_eq!(next(Some(&page(1, 2, 2))), Some(2)); + assert_eq!(next(Some(&page(2, 3, 3))), Some(3)); + } + + #[test] + fn last_page_stops() { + assert_eq!(next(Some(&page(2, 2, 2))), None); + assert_eq!(next(Some(&page(3, 3, 3))), None); + } + + #[test] + fn page_that_does_not_advance_is_an_error() { + assert!(next_vtxo_page(Some(&page(2, 2, 3))).is_err()); + assert!(next_vtxo_page(Some(&page(2, 1, 3))).is_err()); + } +} + +#[cfg(test)] +mod digest_guard_tests { + use super::*; + use ark_grpc::test_utils; + use bitcoin::key::Secp256k1; + use bitcoin::secp256k1::SecretKey; + use bitcoin::Address; + use bitcoin::FeeRate; + use bitcoin::Network; + use bitcoin::Psbt; + use std::convert::Infallible; + use std::future::Future; + use std::pin::Pin; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use std::task::Context; + use std::task::Poll; + use tokio::net::TcpListener; + use tonic::body::Body; + use tonic::codegen::http; + use tonic::codegen::Service; + use tonic::server::NamedService; + use tonic::server::UnaryService; + + #[derive(Clone, Default)] + struct MockArkServer { + state: Arc, + } + + #[derive(Default)] + struct MockState { + get_info_calls: AtomicUsize, + list_vtxos_calls: AtomicUsize, + } + + #[derive(Clone)] + struct DummyBlockchain; + + impl Blockchain for DummyBlockchain { + async fn find_outpoints(&self, _address: &Address) -> Result, Error> { + Ok(Vec::new()) + } + + async fn find_tx(&self, _txid: &Txid) -> Result, Error> { + Ok(None) + } + + async fn get_tx_status(&self, _txid: &Txid) -> Result { + Ok(TxStatus { confirmed_at: None }) + } + + async fn get_output_status(&self, _txid: &Txid, _vout: u32) -> Result { + Ok(SpendStatus { spend_txid: None }) + } + + async fn broadcast(&self, _tx: &Transaction) -> Result<(), Error> { + Ok(()) + } + + async fn get_fee_rate(&self) -> Result { + Ok(1.0) + } + + async fn broadcast_package(&self, _txs: &[&Transaction]) -> Result<(), Error> { + Ok(()) + } + } + + struct DummyWallet { + keypair: Keypair, + secp: Secp256k1, + } + + impl DummyWallet { + fn new() -> Self { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[2; 32]).unwrap(); + let keypair = Keypair::from_secret_key(&secp, &secret_key); + Self { keypair, secp } + } + } + + impl OnchainWallet for DummyWallet { + fn get_onchain_address(&self) -> Result { + Ok(Address::p2tr( + &self.secp, + self.keypair.x_only_public_key().0, + None, + Network::Regtest, + )) + } + + async fn sync(&self) -> Result<(), Error> { + Ok(()) + } + + fn balance(&self) -> Result { + Ok(wallet::Balance { + immature: Amount::ZERO, + trusted_pending: Amount::ZERO, + untrusted_pending: Amount::ZERO, + confirmed: Amount::ZERO, + }) + } + + fn prepare_send_to_address( + &self, + _address: Address, + _amount: Amount, + _fee_rate: FeeRate, + ) -> Result { + Err(Error::wallet("not implemented")) + } + + fn sign(&self, _psbt: &mut Psbt) -> Result { + Ok(true) + } + + fn select_coins(&self, _target_amount: Amount) -> Result { + Err(Error::wallet("not implemented")) + } + } + + impl Service> for MockArkServer { + type Response = http::Response; + type Error = Infallible; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/ark.v1.ArkService/GetInfo" => { + let method = GetInfoSvc { + state: self.state.clone(), + }; + Box::pin(async move { + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec); + Ok(grpc.unary(method, req).await) + }) + } + "/ark.v1.IndexerService/GetVtxos" => { + let method = ListVtxosSvc { + state: self.state.clone(), + }; + Box::pin(async move { + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec); + Ok(grpc.unary(method, req).await) + }) + } + _ => Box::pin(async move { + Ok(http::Response::builder() + .status(200) + .header("grpc-status", "12") + .header("content-type", "application/grpc") + .body(Body::empty()) + .unwrap()) + }), + } + } + } + + impl NamedService for MockArkServer { + const NAME: &'static str = "ark.v1.ArkService"; + } + + #[derive(Clone)] + struct MockIndexerServer(MockArkServer); + + impl Service> for MockIndexerServer { + type Response = http::Response; + type Error = Infallible; + type Future = >>::Future; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.0.poll_ready(cx) + } + + fn call(&mut self, req: http::Request) -> Self::Future { + self.0.call(req) + } + } + + impl NamedService for MockIndexerServer { + const NAME: &'static str = "ark.v1.IndexerService"; + } + + #[derive(Clone)] + struct GetInfoSvc { + state: Arc, + } + + impl UnaryService for GetInfoSvc { + type Response = test_utils::GetInfoResponse; + type Future = Pin< + Box, tonic::Status>> + Send>, + >; + + fn call(&mut self, _request: tonic::Request) -> Self::Future { + self.state.get_info_calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(tonic::Response::new(info_response("fresh-digest"))) }) + } + } + + #[derive(Clone)] + struct ListVtxosSvc { + state: Arc, + } + + impl UnaryService for ListVtxosSvc { + type Response = test_utils::GetVtxosResponse; + type Future = Pin< + Box, tonic::Status>> + Send>, + >; + + fn call(&mut self, _request: tonic::Request) -> Self::Future { + self.state.list_vtxos_calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { + Err(tonic::Status::failed_precondition( + "DIGEST_MISMATCH: invalid digest header", + )) + }) + } + } + + async fn connect_test_client( + mock: MockArkServer, + ) -> Client { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener); + let indexer_mock = MockIndexerServer(mock.clone()); + tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(mock) + .add_service(indexer_mock) + .serve_with_incoming(incoming) + .await + .unwrap(); + }); + + let secp = Secp256k1::new(); + let keypair = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[3; 32]).unwrap()); + OfflineClient::::with_keypair( + OfflineClientConfig { + ark_server_url: format!("http://{addr}"), + boltz_url: "http://127.0.0.1:1".to_string(), + ..Default::default() + }, + keypair, + Arc::new(DummyBlockchain), + Arc::new(DummyWallet::new()), + Arc::new(InMemorySwapStorage::default()), + ) + .connect() + .await + .unwrap() + } + + fn info_response(digest: &str) -> test_utils::GetInfoResponse { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[1; 32]).unwrap(); + let keypair = Keypair::from_secret_key(&secp, &secret_key); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret_key); + let (xonly, _) = keypair.x_only_public_key(); + let address = Address::p2tr(&secp, xonly, None, Network::Regtest); + + test_utils::GetInfoResponse { + version: "0.9.9".to_string(), + signer_pubkey: public_key.to_string(), + forfeit_pubkey: public_key.to_string(), + forfeit_address: address.to_string(), + checkpoint_tapscript: String::new(), + network: "regtest".to_string(), + session_duration: 60, + unilateral_exit_delay: 144, + boarding_exit_delay: 144, + utxo_min_amount: 0, + utxo_max_amount: 0, + vtxo_min_amount: 0, + vtxo_max_amount: 0, + dust: 1000, + fees: None, + scheduled_session: None, + deprecated_signers: Vec::new(), + service_status: Default::default(), + digest: digest.to_string(), + max_tx_weight: 0, + max_op_return_outputs: 0, + } + } + + #[tokio::test] + async fn server_info_uses_fresh_cache_without_refetching() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let mock = MockArkServer::default(); + let state = mock.state.clone(); + let client = connect_test_client(mock).await; + assert_eq!(state.get_info_calls.load(Ordering::SeqCst), 1); + + let info = client.server_info().await.unwrap(); + assert_eq!(info.digest, "fresh-digest"); + assert_eq!(state.get_info_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn list_contracts_returns_wallet_contract_views() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let client = connect_test_client(MockArkServer::default()).await; + let contracts = client.list_contracts().await.unwrap(); + + assert!(!contracts.is_empty()); + assert!(contracts.iter().all(|entry| entry.address.is_some())); + assert!(contracts.iter().all(|entry| entry.signer_status.is_some())); + } + + #[tokio::test] + async fn boarding_addresses_include_default_row_when_scripts_overlap() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let client = connect_test_client(MockArkServer::default()).await; + let addresses = client.get_boarding_addresses().await.unwrap(); + + assert_eq!(addresses.len(), 1); + } + + #[tokio::test] + async fn restore_contracts_rejects_zero_gap_limit() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let client = connect_test_client(MockArkServer::default()).await; + let err = client.restore_contracts(0).await.unwrap_err(); + + assert!(err.to_string().contains("gap limit")); + } + + #[tokio::test] + async fn restore_contracts_reports_gap_limit_for_static_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let client = connect_test_client(MockArkServer::default()).await; + let report = client.restore_contracts(20).await.unwrap(); + + assert_eq!(report.gap_limit, 20); + assert_eq!(report.scanned_keys, 0); + assert!(report.entries.is_empty()); + } + + #[tokio::test] + async fn server_info_zero_ttl_always_refreshes() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let mock = MockArkServer::default(); + let state = mock.state.clone(); + let mut client = connect_test_client(mock).await; + client.inner.server_info_ttl = Duration::ZERO; + assert_eq!(state.get_info_calls.load(Ordering::SeqCst), 1); + + client.server_info().await.unwrap(); + client.server_info().await.unwrap(); + + assert_eq!(state.get_info_calls.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn server_info_refreshes_expired_cache_once_for_concurrent_callers() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let mock = MockArkServer::default(); + let state = mock.state.clone(); + let client = Arc::new(connect_test_client(mock).await); + client.state.write().unwrap().server_info_refreshed_at = + Instant::now() - DEFAULT_SERVER_INFO_TTL - Duration::from_secs(1); + + let a = Arc::clone(&client); + let b = Arc::clone(&client); + let (info_a, info_b) = tokio::join!(a.server_info(), b.server_info()); + assert_eq!(info_a.unwrap().digest, "fresh-digest"); + assert_eq!(info_b.unwrap().digest, "fresh-digest"); + assert_eq!(state.get_info_calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn guarded_client_refreshes_info_and_does_not_retry_on_digest_mismatch() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let mock = MockArkServer::default(); + let state = mock.state.clone(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener); + + let indexer_mock = MockIndexerServer(mock.clone()); + tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(mock) + .add_service(indexer_mock) + .serve_with_incoming(incoming) + .await + .unwrap(); + }); + + let mut inner = ark_grpc::Client::new(format!("http://{addr}")); + inner.connect().await.unwrap(); + + let initial_info: server::Info = info_response("stale-digest").try_into().unwrap(); + let mut contract_manager = ContractManager::in_memory(initial_info.network); + contract_manager.register_builtins().unwrap(); + let cached_state = Arc::new(RwLock::new(ServerState { + server_info: initial_info, + fee_estimator: build_fee_estimator(&info_response("stale-digest").try_into().unwrap()) + .unwrap(), + server_info_refreshed_at: Instant::now() + - DEFAULT_SERVER_INFO_TTL + - Duration::from_secs(1), + contract_manager: Mutex::new(contract_manager), + })); + let hook_state = cached_state.clone(); + inner.set_info_refresh_hook(move |server_info| { + update_server_state(&hook_state, server_info) + .map_err(|err| Box::new(err) as Box) + }); + + let err = match inner + .list_vtxos(GetVtxosRequest::new_for_outpoints(&[OutPoint::null()])) + .await + { + Ok(_) => panic!("list_vtxos unexpectedly succeeded"), + Err(err) => err, + }; + + assert!(err.is_server_info_changed()); + assert!(Error::from(err).is_server_info_changed()); + assert_eq!(state.list_vtxos_calls.load(Ordering::SeqCst), 1); + assert_eq!(state.get_info_calls.load(Ordering::SeqCst), 1); + let refreshed_state = cached_state.read().unwrap(); + assert_eq!(refreshed_state.server_info.digest, "fresh-digest"); + assert!(refreshed_state.server_info_refreshed_at.elapsed() < DEFAULT_SERVER_INFO_TTL); + } +} diff --git a/vendor/ark-client/src/migration.rs b/vendor/ark-client/src/migration.rs new file mode 100644 index 0000000..1f5bd71 --- /dev/null +++ b/vendor/ark-client/src/migration.rs @@ -0,0 +1,965 @@ +use crate::error::ErrorContext; +use crate::swap_storage::SwapStorage; +use crate::utils::timeout_op; +use crate::utils::unix_now; +use crate::wallet::OnchainWallet; +use crate::Blockchain; +use crate::Client; +use crate::Error; +use crate::GetVtxosRequestFilter; +use ark_core::server::DeprecatedSignerStatus; +use ark_core::ExplorerUtxo; +use bitcoin::Amount; +use bitcoin::OutPoint; +use bitcoin::Txid; +use bitcoin::XOnlyPublicKey; +use std::collections::HashMap; +use std::collections::HashSet; + +/// Maximum number of inputs a single deprecated-signer migration leg will settle in one batch. +/// +/// A client-side safeguard: it bounds the input count of one +/// [`Client::migrate_deprecated_signer_vtxos`] leg so a wallet holding many small VTXOs does not +/// build a batch intent that exceeds the server's transaction-weight limit. Any overflow is +/// deferred to a later migration cycle (see [`MigrationLegReport::deferred`]). +pub const MAX_VTXOS_PER_SETTLEMENT: usize = 50; + +/// A single VTXO or boarding output referenced in a [`DeprecatedSignerMigrationReport`]. +#[derive(Debug, Clone)] +pub struct MigrationVtxoRef { + /// The input's outpoint. + pub outpoint: OutPoint, + /// The input's amount. + pub amount: Amount, + /// The deprecated signer the input was minted under. + pub signer_pk: XOnlyPublicKey, + /// The signer's advertised cooperative-sign cutoff (Unix seconds); `0` means "rotate now". + pub cutoff_date: i64, +} + +/// Why a single migration leg ([`DeprecatedSignerMigrationReport::vtxo`] or +/// [`DeprecatedSignerMigrationReport::boarding`]) settled nothing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MigrationSkipReason { + /// The selected aggregate fell below the server's dust floor. + BelowDust, + /// Every migratable input in the leg individually exceeds the per-output ceiling + /// (`vtxo_max_amount`); none can migrate cooperatively, so the leg has only `oversized` + /// inputs and submitted nothing. + OversizedOnly, + /// The leg had no migratable inputs at all. + NothingMigratable, +} + +/// Outcome of one [`Client::migrate_deprecated_signer_vtxos`] leg. +/// +/// Each leg owns its full sizing pipeline and reports independently — a failure or skip in one leg +/// never suppresses the other. The pipeline is: +/// +/// 1. inputs whose individual amount exceeds the server's per-output ceiling (`vtxo_max_amount`) +/// are split out as [`Self::oversized`] — they can never form a `<= ceiling` output and must +/// exit unilaterally; +/// 2. the remainder is selected highest-value-first, bounded by both [`MAX_VTXOS_PER_SETTLEMENT`] +/// and a running aggregate within the ceiling — the overflow lands in [`Self::deferred`] for a +/// later cycle; +/// 3. if the selected aggregate is below the dust floor, the leg is [`Self::skipped`] and nothing +/// is submitted. +#[derive(Debug, Clone)] +pub struct MigrationLegReport { + /// The settlement TXID, when this leg submitted a batch. `None` on skip. + pub settle_txid: Option, + /// Inputs submitted in this leg's settlement; empty on skip. + pub migrated: Vec, + /// Migratable inputs deferred to a later cycle by this leg's count or amount caps. + pub deferred: Vec, + /// Inputs whose value alone exceeds the per-output ceiling; they require a unilateral exit and + /// never migrate cooperatively. + pub oversized: Vec, + /// Why this leg submitted nothing; `None` when a settlement was attempted. + pub skipped: Option, + /// The settlement error, if this leg's `settle_vtxos` call failed. Set independently of the + /// other leg — a failure here does not prevent the other leg from running. + pub error: Option, +} + +impl MigrationLegReport { + /// A leg that submitted nothing for the given reason. + fn skipped(reason: MigrationSkipReason) -> Self { + Self { + settle_txid: None, + migrated: Vec::new(), + deferred: Vec::new(), + oversized: Vec::new(), + skipped: Some(reason), + error: None, + } + } + + /// Whether this leg attempted settlement and failed. + pub fn failed(&self) -> bool { + self.error.is_some() + } +} + +/// Result of a [`Client::migrate_deprecated_signer_vtxos`] pass, split into two symmetric legs: +/// a VTXO leg and a boarding leg. They are never combined into a single intent. +#[derive(Debug, Clone)] +pub struct DeprecatedSignerMigrationReport { + /// The VTXO migration leg. + pub vtxo: MigrationLegReport, + /// The boarding-output migration leg. + pub boarding: MigrationLegReport, +} + +impl DeprecatedSignerMigrationReport { + /// A report where both legs found nothing to migrate (e.g. the server advertises no + /// deprecated signers, or the wallet holds no pre-cutoff deprecated-signer outputs). + fn nothing_migratable() -> Self { + Self { + vtxo: MigrationLegReport::skipped(MigrationSkipReason::NothingMigratable), + boarding: MigrationLegReport::skipped(MigrationSkipReason::NothingMigratable), + } + } + + /// Whether any migration leg attempted settlement and failed. + pub fn failed(&self) -> bool { + self.vtxo.failed() || self.boarding.failed() + } + + /// Whether the wallet was rotated off a deprecated signer this pass — i.e. at least one leg + /// submitted a settlement. + pub fn rotated(&self) -> bool { + self.vtxo.settle_txid.is_some() || self.boarding.settle_txid.is_some() + } + + /// The settlement TXIDs produced this pass (at most one per leg). + pub fn settle_txids(&self) -> Vec { + [self.vtxo.settle_txid, self.boarding.settle_txid] + .into_iter() + .flatten() + .collect() + } +} + +/// Outcome of sizing one migration leg's candidate inputs against the server limits, before any +/// settlement I/O. Produced by [`size_migration_leg`]. +#[derive(Debug, Clone)] +struct MigrationLegSizing { + /// Inputs chosen to be settled this pass (highest-value-first within the caps). + selected: Vec, + /// Migratable inputs deferred to a later cycle by the count or aggregate caps. + deferred: Vec, + /// Inputs whose individual value exceeds the per-output ceiling; they can never form a + /// `<= ceiling` output and must exit unilaterally. + oversized: Vec, + /// Why nothing was selected, if so. `None` when [`Self::selected`] is non-empty and the leg + /// should proceed to settle. + skip_reason: Option, +} + +/// Size one migration leg's candidates against the per-output ceiling (`vtxo_max_amount`) and the +/// dust floor, without performing any settlement. +/// +/// This is the pure core of [`Client::run_migration_leg`], factored out so its branching (oversized +/// split, count cap, running-aggregate ceiling, dust floor, and the skip-reason classification) is +/// unit-testable without a `Client`/network. The pipeline is: +/// +/// 1. inputs whose individual amount exceeds `vtxo_max_amount` are split out as `oversized` (a +/// `None` ceiling means no limit, so nothing is oversized); +/// 2. the remainder is selected highest-value-first, bounded by both [`MAX_VTXOS_PER_SETTLEMENT`] +/// (a hard stop) and a running aggregate kept within the ceiling (a skip, so a smaller input +/// behind a larger one can still get in); the overflow lands in `deferred`; +/// 3. if nothing was selected, or the selected aggregate is below `dust`, `skip_reason` is set +/// ([`MigrationSkipReason::OversizedOnly`] when the only candidates were oversized, else +/// [`MigrationSkipReason::BelowDust`]); an empty candidate list yields +/// [`MigrationSkipReason::NothingMigratable`]. +fn size_migration_leg( + candidates: Vec, + vtxo_max_amount: Option, + dust: Amount, +) -> MigrationLegSizing { + if candidates.is_empty() { + return MigrationLegSizing { + selected: Vec::new(), + deferred: Vec::new(), + oversized: Vec::new(), + skip_reason: Some(MigrationSkipReason::NothingMigratable), + }; + } + + // (1) Split out inputs whose INDIVIDUAL amount exceeds the per-output ceiling. They can + // never form a `<= ceiling` output, so they cannot migrate cooperatively and must exit + // unilaterally. Report them rather than dropping them. `None` ceiling => no limit. + let (oversized, mut sized): (Vec<_>, Vec<_>) = candidates + .into_iter() + .partition(|c| vtxo_max_amount.is_some_and(|max| c.amount > max)); + + if !oversized.is_empty() { + tracing::warn!( + count = oversized.len(), + ?vtxo_max_amount, + "Deprecated-signer migration: inputs exceed the per-output limit and cannot be \ + migrated cooperatively; they require a unilateral exit" + ); + } + + // (2) Select highest-value-first, bounded by both the count cap and a running aggregate + // within the ceiling. Skipped (not stopped) on an aggregate breach so a smaller input + // behind an oversized-but-sized one still gets in; the count cap is a hard stop. The rest + // is deferred to a later cycle. + sized.sort_by_key(|c| std::cmp::Reverse(c.amount)); + + let mut selected: Vec = Vec::new(); + let mut deferred: Vec = Vec::new(); + let mut aggregate = Amount::ZERO; + for candidate in sized { + if selected.len() >= MAX_VTXOS_PER_SETTLEMENT { + deferred.push(candidate); + continue; + } + let next = aggregate + candidate.amount; + if vtxo_max_amount.is_some_and(|max| next > max) { + deferred.push(candidate); + continue; + } + aggregate = next; + selected.push(candidate); + } + + // (3) A migration output equals the gross sum of its inputs (migration is fee-exempt), so a + // selected aggregate below dust would be rejected — skip the leg. + let skip_reason = if selected.is_empty() || aggregate < dust { + // Nothing got selected and the only candidates were oversized => OversizedOnly; + // otherwise the (sized) selection summed below dust. + if selected.is_empty() && !oversized.is_empty() { + Some(MigrationSkipReason::OversizedOnly) + } else { + Some(MigrationSkipReason::BelowDust) + } + } else { + None + }; + + MigrationLegSizing { + selected, + deferred, + oversized, + skip_reason, + } +} + +/// Whether the wallet holds any funds under a (deprecated) signer — spendable VTXOs, recoverable +/// VTXOs, or boarding outputs — deciding whether the signer is surfaced by +/// [`Client::deprecated_signer_status`]. +/// +/// Recoverable VTXOs must count: an expired signer whose VTXOs have all become recoverable +/// (`spendable_count == 0`) still holds funds the user needs surfaced. Counting only spendable +/// VTXOs would drop such a signer from the report and hide those funds. +fn signer_holds_funds( + spendable_count: usize, + recoverable_count: usize, + boarding_count: usize, +) -> bool { + spendable_count + recoverable_count + boarding_count > 0 +} + +/// Classify a deprecated signer from its advertised cutoff and the current time, returning the +/// [`DeprecatedSignerStatus`] and `seconds_until_cutoff` hint. +/// +/// Pure core of [`Client::deprecated_signer_status`], factored out so the classification is +/// unit-testable without a `Client`/network. Consistent with +/// [`ark_core::server::Info::signer_status_at`] and the `is_pre_cutoff_deprecated` check in +/// [`Client::migrate_deprecated_signer_vtxos`]: a `cutoff_date` of `0` is "rotate now" +/// ([`DeprecatedSignerStatus::DueNow`], still co-signable); a future cutoff is +/// [`DeprecatedSignerStatus::Migratable`] (with a positive `seconds_until_cutoff`); a passed cutoff +/// is [`DeprecatedSignerStatus::Expired`]. +fn classify_deprecated_signer(cutoff_date: i64, now: i64) -> (DeprecatedSignerStatus, Option) { + let status = DeprecatedSignerStatus::from_cutoff(cutoff_date, now); + (status, status.seconds_until_cutoff(cutoff_date, now)) +} + +/// Read-only, per-signer status of the deprecated server signers the wallet currently holds funds +/// under. Produced by [`Client::deprecated_signer_status`]. +/// +/// This is observability only — building it never moves funds and never settles or migrates. The +/// `recoverable_*` vs `awaiting_sweep_*` split and `next_sweep_eta` are only populated for +/// [`DeprecatedSignerStatus::Expired`] signers (the post-cutoff recover-on-sweep lifecycle applies +/// to VTXOs only). +#[derive(Debug, Clone)] +pub struct DeprecatedSignerReport { + /// The deprecated signer's x-only key. + pub signer_pk: XOnlyPublicKey, + /// The signer's status, derived from its cutoff and the current time. + pub status: DeprecatedSignerStatus, + /// The advertised cooperative-sign cutoff (Unix seconds); `0` means "rotate immediately". + pub cutoff_date: i64, + /// Seconds until the cutoff (`cutoff_date - now`); `None` when no future cutoff is advertised + /// (i.e. `cutoff_date == 0` or already passed). + pub seconds_until_cutoff: Option, + /// Number of spendable (non-recoverable) VTXOs the wallet holds under this signer. + pub vtxo_count: usize, + /// Total value of those spendable VTXOs. + pub vtxo_value: Amount, + /// Number of confirmed boarding UTXOs the wallet holds under this signer (includes those whose + /// own CSV exit window has elapsed — they leave via the unilateral sweep). + pub boarding_count: usize, + /// Total value of those boarding UTXOs. + pub boarding_value: Amount, + /// Expired-signer VTXOs already swept/expired and queued for recovery to the active signer. + /// Non-zero only on [`DeprecatedSignerStatus::Expired`] rows. + pub recoverable_count: usize, + /// Total value of the recoverable VTXOs. + pub recoverable_value: Amount, + /// Expired-signer VTXOs not yet swept; awaiting the server batch sweep before they become + /// recoverable. Non-zero only on [`DeprecatedSignerStatus::Expired`] rows. + pub awaiting_sweep_count: usize, + /// Total value of the awaiting-sweep VTXOs. + pub awaiting_sweep_value: Amount, + /// Soonest VTXO expiry (Unix seconds) among the awaiting-sweep set, as a recovery ETA hint. + /// `None` when there are no awaiting-sweep VTXOs under this signer. + pub next_sweep_eta: Option, +} + +impl Client +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + /// Sweep VTXOs and boarding outputs minted under a *pre-cutoff* deprecated server signer to + /// the current signer, then report what moved. + /// + /// Only deprecated-signer, pre-cutoff inputs are touched — current-signer outputs are left + /// untouched (no consolidation, no incidental settlement fee), and past-cutoff outputs are + /// skipped automatically by [`Self::fetch_commitment_transaction_inputs`] (the operator won't + /// co-sign the old key, so they become recoverable after expiry and exit via the recovery + /// path). + /// + /// Migration runs as two **independent** legs — a VTXO leg and a boarding leg — each routed + /// through [`Self::settle_vtxos`] with its own scoped outpoint set. A failure in one leg does + /// not suppress the other. Before settling, each leg is sized against the server's per-output + /// ceiling (`vtxo_max_amount`) and dust floor (see [`MigrationLegReport`] for the exact + /// pipeline): inputs that individually exceed the ceiling are reported as `oversized` (they can + /// never form a `<= ceiling` output and must exit unilaterally — they are NOT silently + /// dropped); the remainder is selected highest-value-first up to [`MAX_VTXOS_PER_SETTLEMENT`] + /// and a running aggregate within the ceiling, deferring the rest to a later cycle; a leg whose + /// selected aggregate is below dust is skipped. + /// + /// When the server advertises no deprecated signers, returns an empty + /// [`MigrationSkipReason::NothingMigratable`] report without touching the wallet. + pub async fn migrate_deprecated_signer_vtxos( + &self, + rng: &mut R, + ) -> Result + where + R: rand::Rng + rand::CryptoRng + Clone, + { + // Snapshot the server info once (TOCTOU): the empty-check, the per-input + // classification closure, and the leg sizing must all see the same + // `deprecated_signers`/`vtxo_max_amount`/`dust` even if a concurrent digest-driven + // `refresh_server_info` swaps the snapshot mid-call. + let server_info = self.server_info().await?; + if server_info.deprecated_signers.is_empty() { + return Ok(DeprecatedSignerMigrationReport::nothing_migratable()); + } + + let now = unix_now()?; + + let is_pre_cutoff_deprecated = |server_pk: XOnlyPublicKey| -> Option { + if !server_info + .signer_status_at(server_pk, now) + .is_pre_cutoff_deprecated() + { + return None; + } + + server_info + .deprecated_signers + .iter() + .find(|ds| ds.pk.x_only_public_key().0 == server_pk) + .map(|ds| ds.cutoff_date) + }; + + // `fetch_commitment_transaction_inputs` already drops PAST-cutoff deprecated inputs (the + // operator won't co-sign the old key). We narrow further to the PRE-cutoff deprecated + // inputs, which is exactly the cooperatively-migratable set. + let (boarding_inputs, vtxo_inputs, _) = self + .fetch_commitment_transaction_inputs(&server_info, now) + .await?; + + let contract_vtxos = self + .list_vtxos_with_server_info(&server_info, Some(GetVtxosRequestFilter::Renewable)) + .await?; + + // Build the candidate (outpoint, amount, signer, cutoff) list for the VTXO leg. + let mut vtxo_candidates: Vec = Vec::new(); + for input in &vtxo_inputs { + let Some(contract_vtxo) = contract_vtxos + .all() + .find(|entry| entry.vtxo().outpoint == input.outpoint()) + else { + tracing::debug!( + outpoint = %input.outpoint(), + "Skipping VTXO with no contract during migration" + ); + continue; + }; + let signer_pk = contract_vtxo.server_pk()?; + if let Some(cutoff_date) = is_pre_cutoff_deprecated(signer_pk) { + vtxo_candidates.push(MigrationVtxoRef { + outpoint: input.outpoint(), + amount: input.amount(), + signer_pk, + cutoff_date, + }); + } + } + + // Build the candidate list for the boarding leg. + let boarding_outputs_by_script = self + .boarding_outputs()? + .into_iter() + .map(|boarding_output| (boarding_output.script_pubkey(), boarding_output)) + .collect::>(); + let mut boarding_candidates: Vec = Vec::new(); + for input in &boarding_inputs { + let Some(boarding_output) = boarding_outputs_by_script.get(input.script_pubkey()) + else { + tracing::debug!( + outpoint = %input.outpoint(), + "Skipping boarding input with no contract during migration" + ); + continue; + }; + let signer_pk = boarding_output.server_pk(); + if let Some(cutoff_date) = is_pre_cutoff_deprecated(signer_pk) { + boarding_candidates.push(MigrationVtxoRef { + outpoint: input.outpoint(), + amount: input.amount(), + signer_pk, + cutoff_date, + }); + } + } + + if vtxo_candidates.is_empty() && boarding_candidates.is_empty() { + tracing::debug!("No migratable deprecated-signer VTXOs or boarding outputs found"); + return Ok(DeprecatedSignerMigrationReport::nothing_migratable()); + } + + tracing::info!( + num_vtxos = vtxo_candidates.len(), + num_boarding = boarding_candidates.len(), + "Found pre-cutoff deprecated-signer outputs; migrating to current signer" + ); + + let vtxo_max_amount = server_info.vtxo_max_amount; + let dust = server_info.dust; + + // Run each leg independently so a failure in one does not suppress the other. + let vtxo_leg = self + .run_migration_leg( + rng, + &server_info, + vtxo_candidates, + vtxo_max_amount, + dust, + true, + ) + .await?; + let boarding_leg = self + .run_migration_leg( + rng, + &server_info, + boarding_candidates, + vtxo_max_amount, + dust, + false, + ) + .await?; + + Ok(DeprecatedSignerMigrationReport { + vtxo: vtxo_leg, + boarding: boarding_leg, + }) + } + + /// Report the per-signer status of every deprecated server signer the wallet currently holds + /// funds under, without migrating anything. + /// + /// This is observability only — it never moves funds and never calls settle or migrate. It is + /// the read-only sibling of [`Self::migrate_deprecated_signer_vtxos`]. For each deprecated + /// signer it merges the wallet's contract-annotated VTXO holdings and its on-chain boarding + /// holdings (grouped by their stored contract signer) into one [`DeprecatedSignerReport`]. + /// + /// Signers under which the wallet holds neither VTXOs nor boarding outputs are omitted. When + /// the server advertises no deprecated signers, returns an empty vector without touching the + /// chain. + /// + /// For [`DeprecatedSignerStatus::Expired`] signers the VTXOs are additionally split into the + /// already-swept/expired `recoverable_*` set and the not-yet-swept `awaiting_sweep_*` set, and + /// `next_sweep_eta` is the soonest VTXO expiry (`expires_at`) among the awaiting set. + pub async fn deprecated_signer_status(&self) -> Result, Error> { + // Snapshot once (TOCTOU): the empty-check and every per-signer classification must see the + // same `deprecated_signers`/`dust` even if a concurrent refresh swaps the snapshot. + let server_info = self.server_info().await?; + if server_info.deprecated_signers.is_empty() { + return Ok(Vec::new()); + } + + let now = unix_now()?; + let dust = server_info.dust; + + // Aggregate VTXO holdings per signer in a single pass over all unspent VTXOs. + #[derive(Default)] + struct VtxoAgg { + // Spendable (non-recoverable) VTXOs. + spendable_count: usize, + spendable_value: Amount, + // Already-swept/expired VTXOs (only surfaced for past-cutoff signers). + recoverable_count: usize, + recoverable_value: Amount, + // Soonest expiry among the spendable (awaiting-sweep) VTXOs. + next_sweep_eta: Option, + } + + let vtxo_list = self + .list_vtxos_with_server_info(&server_info, Some(GetVtxosRequestFilter::Renewable)) + .await + .context("failed to list VTXOs")?; + let mut vtxo_aggs: HashMap = HashMap::new(); + for entry in vtxo_list.all_unspent() { + let agg = vtxo_aggs.entry(entry.server_pk()?).or_default(); + if entry.vtxo().is_recoverable(dust) { + agg.recoverable_count += 1; + agg.recoverable_value += entry.vtxo().amount; + } else { + agg.spendable_count += 1; + agg.spendable_value += entry.vtxo().amount; + agg.next_sweep_eta = Some(match agg.next_sweep_eta { + Some(eta) => eta.min(entry.vtxo().expires_at), + None => entry.vtxo().expires_at, + }); + } + } + + // Aggregate confirmed boarding holdings per signer. Mirrors the discovery in + // `fetch_commitment_transaction_inputs` (boarding outputs -> `find_outpoints`) but WITHOUT + // the cutoff/CSV-claimability filters: the report counts every confirmed, unspent boarding + // coin under a signer, including past-cutoff and CSV-expired ones (they still leave via the + // unilateral sweep). + let mut boarding_aggs: HashMap = HashMap::new(); + let mut seen_outpoints = HashSet::new(); + for boarding_output in self.boarding_outputs()? { + let outpoints = timeout_op( + self.inner.timeout, + self.blockchain().find_outpoints(boarding_output.address()), + ) + .await + .context("failed to find boarding outpoints")??; + + for o in outpoints.iter() { + if let ExplorerUtxo { + outpoint, + amount, + confirmation_blocktime: Some(_), + is_spent: false, + .. + } = o + { + if !seen_outpoints.insert(*outpoint) { + continue; + } + let entry = boarding_aggs + .entry(boarding_output.server_pk()) + .or_insert((0, Amount::ZERO)); + entry.0 += 1; + entry.1 += *amount; + } + } + } + + let mut reports = Vec::new(); + for ds in &server_info.deprecated_signers { + let signer_pk = ds.pk.x_only_public_key().0; + let cutoff_date = ds.cutoff_date; + + // Status + `seconds_until_cutoff`, consistent with `is_signer_past_cutoff_at` / + // `is_pre_cutoff_deprecated`: cutoff `0` = rotate-now (still co-signable); a future + // cutoff = migratable; a passed cutoff = expired. + let (status, seconds_until_cutoff) = classify_deprecated_signer(cutoff_date, now); + + let vtxo_agg = vtxo_aggs.get(&signer_pk); + let (boarding_count, boarding_value) = boarding_aggs + .get(&signer_pk) + .copied() + .unwrap_or((0, Amount::ZERO)); + + let vtxo_count = vtxo_agg.map(|a| a.spendable_count).unwrap_or(0); + let vtxo_value = vtxo_agg.map(|a| a.spendable_value).unwrap_or(Amount::ZERO); + + // Skip signers under which the wallet holds no funds at all. + let recoverable_vtxo_count = vtxo_agg.map(|a| a.recoverable_count).unwrap_or(0); + if !signer_holds_funds(vtxo_count, recoverable_vtxo_count, boarding_count) { + continue; + } + + // The recover-on-sweep split applies to past-cutoff (expired) signers only; for still + // co-signable signers these stay zero / `None`. + let is_expired = status == DeprecatedSignerStatus::Expired; + let recoverable_count = vtxo_agg + .filter(|_| is_expired) + .map(|a| a.recoverable_count) + .unwrap_or(0); + let recoverable_value = vtxo_agg + .filter(|_| is_expired) + .map(|a| a.recoverable_value) + .unwrap_or(Amount::ZERO); + let (awaiting_sweep_count, awaiting_sweep_value, next_sweep_eta) = if is_expired { + ( + vtxo_count, + vtxo_value, + vtxo_agg.and_then(|a| a.next_sweep_eta), + ) + } else { + (0, Amount::ZERO, None) + }; + + reports.push(DeprecatedSignerReport { + signer_pk, + status, + cutoff_date, + seconds_until_cutoff, + vtxo_count, + vtxo_value, + boarding_count, + boarding_value, + recoverable_count, + recoverable_value, + awaiting_sweep_count, + awaiting_sweep_value, + next_sweep_eta, + }); + } + + Ok(reports) + } + + /// Size a single migration leg against the server limits and settle the selected inputs. + /// + /// `is_vtxo_leg` selects which argument of [`Self::settle_vtxos`] the chosen outpoints are + /// passed in (VTXO vs boarding); + /// the other argument is empty so each leg is a distinct intent. + async fn run_migration_leg( + &self, + rng: &mut R, + server_info: &ark_core::server::Info, + candidates: Vec, + vtxo_max_amount: Option, + dust: Amount, + is_vtxo_leg: bool, + ) -> Result + where + R: rand::Rng + rand::CryptoRng + Clone, + { + // Pure sizing (split oversized, cap count + aggregate, dust floor) is factored into + // `size_migration_leg` so it can be unit-tested without a `Client`/network. This leg only + // adds the I/O: settling the selected inputs and mapping the outcome onto a report. + let MigrationLegSizing { + selected, + deferred, + oversized, + skip_reason, + } = size_migration_leg(candidates, vtxo_max_amount, dust); + + if let Some(reason) = skip_reason { + return Ok(MigrationLegReport { + settle_txid: None, + migrated: Vec::new(), + // Surface any sized-but-skipped inputs (e.g. a below-dust selection) as deferred + // so a later cycle re-attempts them, matching the settle-error path below. For + // OversizedOnly/NothingMigratable `selected` is empty, so this is a no-op there. + deferred: selected.into_iter().chain(deferred).collect(), + oversized, + skipped: Some(reason), + error: None, + }); + } + + let selected_outpoints: Vec = selected.iter().map(|c| c.outpoint).collect(); + let settle_result = if is_vtxo_leg { + self.settle_vtxos_with_server_info(rng, server_info, &selected_outpoints, &[]) + .await + } else { + self.settle_vtxos_with_server_info(rng, server_info, &[], &selected_outpoints) + .await + }; + + // Capture (rather than propagate) the settle error so the caller can still run the other + // leg — a failure in one leg must not suppress the other. + Ok(match settle_result { + Ok(settle_txid) => MigrationLegReport { + settle_txid, + migrated: selected, + deferred, + oversized, + skipped: None, + error: None, + }, + Err(e) => { + tracing::warn!(error = %e, "Deprecated-signer migration leg failed to settle"); + MigrationLegReport { + settle_txid: None, + migrated: Vec::new(), + // The selected inputs did not move; surface them as deferred so a retry + // re-attempts them. + deferred: selected.into_iter().chain(deferred).collect(), + oversized, + skipped: None, + error: Some(e.to_string()), + } + } + }) + } +} + +/// Unit coverage for the pure deprecated-signer-migration logic: the per-leg sizing pipeline +/// ([`size_migration_leg`]), the signer classification ([`classify_deprecated_signer`]), and the +/// empty-`deprecated_signers` short-circuit report ([`DeprecatedSignerMigrationReport`]). These +/// run without a `Client`/network — they exercise the same branching the regtest e2e tests cover +/// end-to-end. +#[cfg(test)] +mod migration_tests { + use super::*; + use bitcoin::hashes::Hash; + use bitcoin::key::Keypair; + use bitcoin::key::Secp256k1; + + /// A migratable candidate of the given amount. Each gets a distinct outpoint (via `vout`) so + /// selection order and counts are observable; the signer/cutoff are fixed placeholders the + /// sizing logic does not inspect. + fn candidate(vout: u32, amount: Amount) -> MigrationVtxoRef { + let secp = Secp256k1::new(); + let sk = bitcoin::secp256k1::SecretKey::from_slice(&[7u8; 32]).unwrap(); + let signer_pk = Keypair::from_secret_key(&secp, &sk).x_only_public_key().0; + MigrationVtxoRef { + outpoint: OutPoint::new(Txid::from_byte_array([0u8; 32]), vout), + amount, + signer_pk, + cutoff_date: 0, + } + } + + fn sat(n: u64) -> Amount { + Amount::from_sat(n) + } + + // ── size_migration_leg ─────────────────────────────────────────────────── + + #[test] + fn sizing_empty_candidates_is_nothing_migratable() { + let sizing = size_migration_leg(Vec::new(), Some(sat(1000)), sat(330)); + assert!(sizing.selected.is_empty()); + assert!(sizing.deferred.is_empty()); + assert!(sizing.oversized.is_empty()); + assert_eq!( + sizing.skip_reason, + Some(MigrationSkipReason::NothingMigratable) + ); + } + + #[test] + fn sizing_selects_all_when_within_limits() { + let candidates = vec![candidate(0, sat(500)), candidate(1, sat(400))]; + let sizing = size_migration_leg(candidates, Some(sat(1000)), sat(330)); + assert_eq!(sizing.selected.len(), 2); + assert!(sizing.deferred.is_empty()); + assert!(sizing.oversized.is_empty()); + assert_eq!(sizing.skip_reason, None); + // Highest-value-first ordering. + assert_eq!(sizing.selected[0].amount, sat(500)); + assert_eq!(sizing.selected[1].amount, sat(400)); + } + + #[test] + fn sizing_caps_to_vtxo_max_deferring_the_rest() { + // Ceiling 1000: the 700 fits, the next 700 would push the aggregate to 1400 (> ceiling) + // so it is deferred, not stopped — a later 300 still fits under the running aggregate. + let candidates = vec![ + candidate(0, sat(700)), + candidate(1, sat(700)), + candidate(2, sat(300)), + ]; + let sizing = size_migration_leg(candidates, Some(sat(1000)), sat(330)); + assert_eq!(sizing.selected.len(), 2); + let selected: Vec<_> = sizing.selected.iter().map(|c| c.amount).collect(); + assert_eq!(selected, vec![sat(700), sat(300)]); + assert_eq!(sizing.deferred.len(), 1); + assert_eq!(sizing.deferred[0].amount, sat(700)); + assert!(sizing.oversized.is_empty()); + assert_eq!(sizing.skip_reason, None); + } + + #[test] + fn sizing_splits_oversized_inputs() { + // 1500 alone exceeds the 1000 ceiling: it can never form a `<= ceiling` output, so it is + // reported as oversized (not dropped, not deferred). The 600 still migrates. + let candidates = vec![candidate(0, sat(1500)), candidate(1, sat(600))]; + let sizing = size_migration_leg(candidates, Some(sat(1000)), sat(330)); + assert_eq!(sizing.oversized.len(), 1); + assert_eq!(sizing.oversized[0].amount, sat(1500)); + assert_eq!(sizing.selected.len(), 1); + assert_eq!(sizing.selected[0].amount, sat(600)); + assert!(sizing.deferred.is_empty()); + assert_eq!(sizing.skip_reason, None); + } + + #[test] + fn sizing_oversized_only_when_all_exceed_ceiling() { + let candidates = vec![candidate(0, sat(1500)), candidate(1, sat(2000))]; + let sizing = size_migration_leg(candidates, Some(sat(1000)), sat(330)); + assert_eq!(sizing.oversized.len(), 2); + assert!(sizing.selected.is_empty()); + assert!(sizing.deferred.is_empty()); + assert_eq!(sizing.skip_reason, Some(MigrationSkipReason::OversizedOnly)); + } + + #[test] + fn sizing_skips_below_dust() { + // Selected aggregate (200) is below the dust floor (330): the leg is skipped as BelowDust + // (no oversized inputs involved). The candidate still satisfied the per-input and aggregate + // ceilings, so it remains in `selected`; `run_migration_leg` reads `selected` only when + // `skip_reason` is `None`, so a BelowDust leg settles nothing. + let candidates = vec![candidate(0, sat(200))]; + let sizing = size_migration_leg(candidates, Some(sat(1000)), sat(330)); + assert_eq!(sizing.skip_reason, Some(MigrationSkipReason::BelowDust)); + assert!(sizing.oversized.is_empty()); + } + + #[test] + fn sizing_defers_beyond_count_cap() { + // One more candidate than the per-settlement count cap, each tiny so the aggregate ceiling + // never binds: exactly MAX_VTXOS_PER_SETTLEMENT are selected and the remainder is deferred. + let candidates: Vec<_> = (0..=MAX_VTXOS_PER_SETTLEMENT as u32) + .map(|i| candidate(i, sat(1))) + .collect(); + // `None` ceiling => the aggregate cap does not apply; dust floor of 1 sat is met by the + // selected aggregate (MAX_VTXOS_PER_SETTLEMENT sats). + let sizing = size_migration_leg(candidates, None, sat(1)); + assert_eq!(sizing.selected.len(), MAX_VTXOS_PER_SETTLEMENT); + assert_eq!(sizing.deferred.len(), 1); + assert!(sizing.oversized.is_empty()); + assert_eq!(sizing.skip_reason, None); + } + + #[test] + fn sizing_none_ceiling_means_no_oversized() { + // With no advertised ceiling, no input is ever oversized regardless of size. + let candidates = vec![candidate(0, sat(10_000_000)), candidate(1, sat(20_000_000))]; + let sizing = size_migration_leg(candidates, None, sat(330)); + assert!(sizing.oversized.is_empty()); + assert_eq!(sizing.selected.len(), 2); + assert_eq!(sizing.skip_reason, None); + } + + // ── classify_deprecated_signer ─────────────────────────────────────────── + + #[test] + fn classify_cutoff_zero_is_due_now() { + let (status, secs) = classify_deprecated_signer(0, 1_000_000); + assert_eq!(status, DeprecatedSignerStatus::DueNow); + assert_eq!(secs, None); + } + + #[test] + fn classify_future_cutoff_is_migratable() { + let now = 1_000_000i64; + let (status, secs) = classify_deprecated_signer(now + 86_400, now); + assert_eq!(status, DeprecatedSignerStatus::Migratable); + assert_eq!(secs, Some(86_400)); + } + + #[test] + fn classify_exact_cutoff_boundary_is_expired() { + // cutoff_date <= now (and != 0) => expired. The boundary (cutoff == now) requires + // recovery instead of cooperative migration. + let now = 1_000_000i64; + let (status, secs) = classify_deprecated_signer(now, now); + assert_eq!(status, DeprecatedSignerStatus::Expired); + assert_eq!(secs, None); + } + + #[test] + fn classify_past_cutoff_is_expired() { + let now = 1_000_000i64; + let (status, secs) = classify_deprecated_signer(now - 1, now); + assert_eq!(status, DeprecatedSignerStatus::Expired); + assert_eq!(secs, None); + } + + // ── deprecated_signer_status emptiness skip ────────────────────────────── + + #[test] + fn signer_with_only_recoverable_vtxos_is_kept() { + // Regression for the report skip dropping an expired signer whose VTXOs are all + // recoverable (spendable_count == 0): those funds must still be surfaced. + assert!(signer_holds_funds(0, 3, 0)); + } + + #[test] + fn signer_with_only_spendable_vtxos_is_kept() { + assert!(signer_holds_funds(5, 0, 0)); + } + + #[test] + fn signer_with_only_boarding_is_kept() { + assert!(signer_holds_funds(0, 0, 2)); + } + + #[test] + fn signer_with_no_funds_is_dropped() { + assert!(!signer_holds_funds(0, 0, 0)); + } + + // ── empty-deprecated-signers short-circuit report ──────────────────────── + + #[test] + fn nothing_migratable_report_is_not_rotated() { + // The report `migrate_deprecated_signer_vtxos` returns when the server advertises no + // deprecated signers: not rotated, no settle txids, both legs NothingMigratable. + let report = DeprecatedSignerMigrationReport::nothing_migratable(); + assert!(!report.failed()); + assert!(!report.rotated()); + assert!(report.settle_txids().is_empty()); + assert_eq!( + report.vtxo.skipped, + Some(MigrationSkipReason::NothingMigratable) + ); + assert_eq!( + report.boarding.skipped, + Some(MigrationSkipReason::NothingMigratable) + ); + assert!(report.vtxo.migrated.is_empty()); + assert!(report.boarding.migrated.is_empty()); + } + + #[test] + fn migration_report_failed_tracks_leg_errors() { + let report = DeprecatedSignerMigrationReport { + vtxo: MigrationLegReport { + settle_txid: None, + migrated: Vec::new(), + deferred: Vec::new(), + oversized: Vec::new(), + skipped: None, + error: Some("settle failed".to_owned()), + }, + boarding: MigrationLegReport::skipped(MigrationSkipReason::NothingMigratable), + }; + + assert!(report.failed()); + assert!(report.vtxo.failed()); + assert!(!report.boarding.failed()); + assert!(!report.rotated()); + } +} diff --git a/vendor/ark-client/src/send_vtxo.rs b/vendor/ark-client/src/send_vtxo.rs new file mode 100644 index 0000000..65009bb --- /dev/null +++ b/vendor/ark-client/src/send_vtxo.rs @@ -0,0 +1,831 @@ +use crate::error::ErrorContext; +use crate::swap_storage::SwapStorage; +use crate::utils::timeout_op; +use crate::wallet::OnchainWallet; +use crate::AnnotatedVtxo; +use crate::Blockchain; +use crate::Client; +use crate::Error; +use crate::GetVtxosRequestFilter; +use ark_core::asset::AssetId; +use ark_core::coin_select::select_vtxos; +use ark_core::coin_select::select_vtxos_for_asset; +use ark_core::coin_select::VirtualTxOutPoint; +use ark_core::contract::SpendPathKind; +use ark_core::intent; +use ark_core::script::extract_checksig_pubkeys; +use ark_core::send::build_asset_send_transactions; +use ark_core::send::sign_ark_transaction; +use ark_core::send::sign_checkpoint_transaction; +use ark_core::send::OffchainTransactions; +use ark_core::send::SendReceiver; +use ark_core::send::VtxoInput; +use ark_core::server; +use ark_core::server::PendingTx; +use bitcoin::key::Secp256k1; +use bitcoin::psbt; +use bitcoin::secp256k1; +use bitcoin::secp256k1::schnorr; +use bitcoin::Amount; +use bitcoin::OutPoint; +use bitcoin::TxOut; +use bitcoin::Txid; +use bitcoin::XOnlyPublicKey; +use std::collections::HashMap; +use std::collections::HashSet; +use std::time::Duration; + +pub(crate) fn coin_select_vtxo(entry: &AnnotatedVtxo) -> VirtualTxOutPoint { + VirtualTxOutPoint { + outpoint: entry.vtxo().outpoint, + script_pubkey: entry.vtxo().script.clone(), + expire_at: entry.vtxo().expires_at, + amount: entry.vtxo().amount, + assets: entry.vtxo().assets.clone(), + } +} + +pub(crate) fn select_contract_vtxos( + available: &[AnnotatedVtxo], + selected: &[VirtualTxOutPoint], +) -> Vec { + selected + .iter() + .filter_map(|coin| { + available + .iter() + .find(|entry| entry.vtxo().outpoint == coin.outpoint) + .cloned() + }) + .collect() +} + +impl Client +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + // Send public APIs + + /// Send bitcoin and/or Arkade assets offchain to one or more receivers. + /// + /// Coin selection handles both bitcoin-only and asset-bearing VTXOs. An asset packet is + /// attached only when the transfer actually involves carried or requested assets. + /// + /// # Arguments + /// + /// * `receivers` - a list of [`SendReceiver`]s, specifying a target address, a bitcoin amount + /// and an optional list of assets. + /// + /// # Returns + /// + /// The [`Txid`] of the resulting Ark transaction. + pub async fn send(&self, receivers: Vec) -> Result { + // Apply coin selection to satisfy the given `receivers`. + let selected = self + .auto_select_send_inputs(&receivers) + .await + .context("failed to auto-select send inputs")?; + + let txid = self + .send_with_selected_inputs(selected, receivers) + .await + .context("failed to send with selected inputs")?; + + Ok(txid) + } + + /// Spend specific VTXOs in an Ark transaction sending bitcoin and/or Arkade assets to one or + /// more receivers. + /// + /// Unlike [`Self::send`], this method allows the caller to specify exactly which VTXOs to + /// spend by providing their outpoints. This is useful for applications that want to have full + /// control over VTXO selection. + /// + /// # Arguments + /// + /// * `vtxo_outpoints` - a list of all the outpoints to be used as inputs to the transaction. + /// * `receivers` - a list of [`SendReceiver`]s, specifying a target address, a bitcoin amount + /// and an optional list of assets. + /// + /// # Returns + /// + /// The [`Txid`] of the generated Ark transaction. + /// + /// # Errors + /// + /// Returns an error if the selected VTXOs don't have enough BTC value or assets to cover the + /// requested receiver amounts. + pub async fn send_selection( + &self, + vtxo_outpoints: &[OutPoint], + receivers: Vec, + ) -> Result { + // Resolve contract-annotated inputs for the `vtxo_outpoints` chosen by the caller. + let selected = self + .resolve_selected_send_inputs(vtxo_outpoints) + .await + .context("failed to resolve selected send inputs")?; + + let txid = self + .send_with_selected_inputs(selected, receivers) + .await + .context("failed to send with selected inputs")?; + + Ok(txid) + } + + // Pending transactions + + /// Finalize a specific pending offchain transaction. + /// + /// Fetches the pending transaction identified by `ark_txid` from the server, signs the + /// checkpoint transactions, and finalizes it. + /// + /// This is useful when you need fine-grained control over which pending transaction to + /// finalize (e.g. when a database tracks individual pending funding attempts). + /// + /// # Errors + /// + /// Returns an error if no pending transaction with the given `ark_txid` is found, or if + /// signing / finalization fails. + pub async fn finalize_pending_offchain_tx(&self, ark_txid: Txid) -> Result<(), Error> { + let pending_txs = self.fetch_pending_offchain_txs().await?; + + let pending_tx = pending_txs + .into_iter() + .find(|tx| tx.ark_txid == ark_txid) + .ok_or_else(|| { + Error::ad_hoc(format!( + "no pending transaction found for ark txid {ark_txid}" + )) + })?; + + self.sign_and_finalize_pending_tx(pending_tx).await + } + + /// Resume and finalize any pending (submitted but not finalized) offchain transactions. + /// + /// This handles the case where `send_vtxo` successfully submitted the transaction to the + /// server but failed before finalizing (e.g. due to a crash or network error). The server + /// holds the submitted-but-not-finalized transaction in a pending state. This method + /// retrieves it, signs the checkpoint transactions, and finalizes. + /// + /// # Returns + /// + /// The [`Txid`]s of the finalized Ark transactions, or an empty vec if there were no + /// pending transactions. + pub async fn continue_pending_offchain_txs(&self) -> Result, Error> { + let pending_txs = self.fetch_pending_offchain_txs().await?; + + if pending_txs.is_empty() { + return Ok(vec![]); + } + + let mut finalized_txids = Vec::new(); + + for pending_tx in pending_txs { + let ark_txid = pending_tx.ark_txid; + self.sign_and_finalize_pending_tx(pending_tx).await?; + finalized_txids.push(ark_txid); + } + + Ok(finalized_txids) + } + + /// List pending (submitted but not finalized) offchain transactions. + /// + /// This retrieves any transactions that were submitted to the server but not yet finalized + /// (e.g. due to a crash or network error between submit and finalize). + /// + /// # Returns + /// + /// The pending transactions, or an empty vec if there are none. + pub async fn list_pending_offchain_txs(&self) -> Result, Error> { + self.fetch_pending_offchain_txs().await + } + + /// Build, sign and submit an offchain transaction to the server without finalizing. + /// + /// This is primarily useful for testing pending transaction recovery flows. + /// + /// Returns the Ark TXID. The transaction will remain in a pending state on the server until + /// [`Self::finalize_pending_offchain_tx`] or [`Self::continue_pending_offchain_txs`] completes + /// it. + pub async fn submit_offchain_tx( + &self, + vtxo_inputs: Vec, + address: ark_core::ArkAddress, + amount: Amount, + ) -> Result { + let server_info = self.server_info().await?; + let receivers = vec![SendReceiver { + address, + amount, + assets: Vec::new(), + }]; + let pending_tx = self + .build_and_submit(vtxo_inputs, receivers, &server_info) + .await?; + Ok(pending_tx.ark_txid) + } + + // Private helpers + + /// Create a signing closure that signs with any known keypair. + fn make_sign_fn( + &self, + ) -> impl FnMut( + &mut psbt::Input, + secp256k1::Message, + ) -> Result, ark_core::Error> + + '_ { + |input, msg| { + let script = input + .witness_script + .as_ref() + .ok_or_else(|| ark_core::Error::ad_hoc("Missing witness script for psbt::Input"))?; + let pks = extract_checksig_pubkeys(script); + let secp = Secp256k1::new(); + let mut sigs = vec![]; + for pk in pks { + if let Ok(keypair) = self.keypair_by_pk(&pk) { + let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair); + sigs.push((sig, keypair.x_only_public_key().0)); + } + } + Ok(sigs) + } + } + + async fn auto_select_send_inputs( + &self, + receivers: &[SendReceiver], + ) -> Result, Error> { + let vtxo_list = self + .list_vtxos_filtered(GetVtxosRequestFilter::Spendable) + .await + .context("failed to get spendable VTXOs")?; + + let now = crate::utils::unix_now()?; + let server_info = self.server_info().await?; + let spendable_contracts = vtxo_list + .spendable_offchain_at(&server_info, now) + .cloned() + .collect::>(); + let spendable = spendable_contracts + .iter() + .map(coin_select_vtxo) + .collect::>(); + + let mut selected_outpoints = HashSet::new(); + let mut selected = Vec::new(); + let mut asset_changes: HashMap = HashMap::new(); + let mut btc_needed = Amount::ZERO; + let mut btc_provided = Amount::ZERO; + + for receiver in receivers { + btc_needed += receiver.amount; + + for asset in &receiver.assets { + let mut amount_to_select = asset.amount; + + if let Some(existing_change) = asset_changes.get_mut(&asset.asset_id) { + if amount_to_select <= *existing_change { + *existing_change -= amount_to_select; + if *existing_change == 0 { + asset_changes.remove(&asset.asset_id); + } + continue; + } + amount_to_select -= *existing_change; + asset_changes.remove(&asset.asset_id); + } + + let available: Vec<_> = spendable + .iter() + .filter(|v| !selected_outpoints.contains(&v.outpoint)) + .cloned() + .collect(); + + let (asset_coins, asset_change) = + select_vtxos_for_asset(&available, amount_to_select, asset.asset_id) + .map_err(Error::from) + .context("failed to select coins for asset transfer")?; + + for coin in &asset_coins { + if selected_outpoints.insert(coin.outpoint) { + btc_provided += coin.amount; + + for carried_asset in &coin.assets { + if carried_asset.asset_id != asset.asset_id { + *asset_changes.entry(carried_asset.asset_id).or_insert(0) += + carried_asset.amount; + } + } + + selected.push(coin.clone()); + } + } + + if asset_change > 0 { + *asset_changes.entry(asset.asset_id).or_insert(0) += asset_change; + } + } + } + + if !asset_changes.is_empty() { + btc_needed += server_info.dust; + } + + let btc_shortfall = btc_needed.checked_sub(btc_provided).unwrap_or(Amount::ZERO); + + if btc_shortfall > Amount::ZERO { + let available: Vec<_> = spendable + .iter() + .filter(|v| !selected_outpoints.contains(&v.outpoint)) + .cloned() + .collect(); + + let btc_coins = select_vtxos(available, btc_shortfall, server_info.dust, true) + .map_err(Error::from) + .context("failed to select BTC coins for asset transfer")?; + + for coin in &btc_coins { + if selected_outpoints.insert(coin.outpoint) { + for carried_asset in &coin.assets { + *asset_changes.entry(carried_asset.asset_id).or_insert(0) += + carried_asset.amount; + } + selected.push(coin.clone()); + } + } + } + + let inputs = + self.build_vtxo_inputs(select_contract_vtxos(&spendable_contracts, &selected))?; + + Ok(inputs) + } + + async fn resolve_selected_send_inputs( + &self, + vtxo_outpoints: &[OutPoint], + ) -> Result, Error> { + let requested_outpoints: HashSet<_> = vtxo_outpoints.iter().copied().collect(); + + let vtxo_list = self + .list_vtxos_for_outpoints(vtxo_outpoints.to_vec()) + .await + .context("failed to get VTXO list")?; + + let now = crate::utils::unix_now()?; + let server_info = self.server_info().await?; + let selected_contracts: Vec<_> = vtxo_list + .spendable_offchain_at(&server_info, now) + .filter(|entry| requested_outpoints.contains(&entry.vtxo().outpoint)) + .cloned() + .collect(); + let selected: Vec<_> = selected_contracts.iter().map(coin_select_vtxo).collect(); + + if selected.is_empty() { + return Err(Error::ad_hoc("no matching VTXO outpoints found")); + } + + if selected.len() != requested_outpoints.len() { + let found_outpoints: HashSet<_> = selected.iter().map(|v| v.outpoint).collect(); + let missing_outpoints = requested_outpoints + .difference(&found_outpoints) + .map(ToString::to_string) + .collect::>(); + + return Err(Error::ad_hoc(format!( + "some selected VTXO outpoints were not found or not spendable: {}", + missing_outpoints.join(", ") + ))); + } + + let inputs = self.build_vtxo_inputs(selected_contracts)?; + + Ok(inputs) + } + + /// Convert selected [`VirtualTxOutPoint`]s into [`send::VtxoInput`]s. + pub(crate) fn build_vtxo_inputs( + &self, + selected: Vec, + ) -> Result, Error> { + selected + .into_iter() + .map(|entry| { + let spend_selection = entry.spend_selection(SpendPathKind::Forfeit)?; + + Ok(VtxoInput::new_with_spend_selection( + spend_selection, + entry.tapscripts(), + entry.script_pubkey(), + entry.vtxo().amount, + entry.vtxo().outpoint, + entry.vtxo().assets.clone(), + )) + }) + .collect() + } + + fn validate_selected_inputs_cover_receivers( + vtxo_inputs: &[VtxoInput], + receivers: &[SendReceiver], + dust: Amount, + ) -> Result<(), Error> { + let selected_amount = vtxo_inputs + .iter() + .fold(Amount::ZERO, |acc, v| acc + v.amount()); + let requested_amount = receivers.iter().fold(Amount::ZERO, |acc, r| acc + r.amount); + + let mut selected_assets = HashMap::::new(); + for vtxo_input in vtxo_inputs { + for asset in vtxo_input.assets() { + *selected_assets.entry(asset.asset_id).or_insert(0) = selected_assets + .get(&asset.asset_id) + .copied() + .unwrap_or(0) + .checked_add(asset.amount) + .ok_or_else(|| Error::ad_hoc("selected asset amount overflow"))?; + } + } + + let mut requested_assets = HashMap::::new(); + for receiver in receivers { + for asset in &receiver.assets { + *requested_assets.entry(asset.asset_id).or_insert(0) = requested_assets + .get(&asset.asset_id) + .copied() + .unwrap_or(0) + .checked_add(asset.amount) + .ok_or_else(|| Error::ad_hoc("requested asset amount overflow"))?; + } + } + + for (asset_id, requested_asset_amount) in &requested_assets { + let selected_asset_amount = selected_assets.get(asset_id).copied().unwrap_or(0); + if selected_asset_amount < *requested_asset_amount { + return Err(Error::coin_select(format!( + "insufficient asset amount for {}: {} < {}", + asset_id, selected_asset_amount, requested_asset_amount + ))); + } + } + + let mut has_asset_change = false; + for (asset_id, selected_asset_amount) in &selected_assets { + let requested_asset_amount = requested_assets.get(asset_id).copied().unwrap_or(0); + + if *selected_asset_amount < requested_asset_amount { + return Err(Error::coin_select(format!( + "insufficient asset amount for {}: {} < {}", + asset_id, selected_asset_amount, requested_asset_amount + ))); + } + + if *selected_asset_amount > requested_asset_amount { + has_asset_change = true; + } + } + + let required_amount = match has_asset_change { + true => requested_amount + .checked_add(dust) + .ok_or_else(|| Error::ad_hoc("required BTC amount overflow"))?, + false => requested_amount, + }; + + if selected_amount < required_amount { + return Err(Error::coin_select(format!( + "insufficient VTXO amount: {} < {}", + selected_amount, required_amount + ))); + } + + Ok(()) + } + + async fn send_with_selected_inputs( + &self, + vtxo_inputs: Vec, + receivers: Vec, + ) -> Result { + let server_info = self.server_info().await?; + Self::validate_selected_inputs_cover_receivers(&vtxo_inputs, &receivers, server_info.dust)?; + + let pending_tx = self + .build_and_submit(vtxo_inputs, receivers, &server_info) + .await?; + let ark_txid = pending_tx.ark_txid; + + self.sign_and_finalize_pending_tx(pending_tx).await?; + + Ok(ark_txid) + } + + /// Sign and submit a prebuilt offchain transaction to the server without finalizing. + /// + /// Returns the pending transaction payload from the server. The change-address key is marked + /// as used. + pub(crate) async fn submit_built_offchain_send( + &self, + mut ark_tx: bitcoin::Psbt, + checkpoint_txs: Vec, + used_pk: XOnlyPublicKey, + ) -> Result { + for i in 0..checkpoint_txs.len() { + sign_ark_transaction(self.make_sign_fn(), &mut ark_tx, i)?; + } + + let res = self + .network_client() + .submit_offchain_transaction_request(ark_tx, checkpoint_txs) + .await + .map_err(Error::ark_server) + .context("failed to submit offchain transaction request")?; + + let pending_tx = PendingTx { + ark_txid: res.signed_ark_tx.unsigned_tx.compute_txid(), + signed_ark_tx: res.signed_ark_tx, + signed_checkpoint_txs: res.signed_checkpoint_txs, + }; + + if let Some(key_provider) = self.inner.discoverable_key_provider.as_ref() { + if let Err(err) = key_provider.mark_as_used(&used_pk) { + tracing::warn!( + "Failed updating keypair cache for used change address: {:?}", + err + ); + } + } + + Ok(pending_tx) + } + + /// Build, sign the Ark transaction, and submit to the server *without* finalizing. + async fn build_and_submit( + &self, + inputs: Vec, + receivers: Vec, + server_info: &server::Info, + ) -> Result { + let (change_address, change_address_vtxo) = self.get_offchain_address().await?; + + let OffchainTransactions { + ark_tx, + checkpoint_txs, + } = build_asset_send_transactions(&receivers, &change_address, &inputs, server_info) + .map_err(Error::from) + .context("failed to build offchain asset-send transactions")?; + + self.submit_built_offchain_send(ark_tx, checkpoint_txs, change_address_vtxo.owner_pk()) + .await + } + + /// Sign checkpoint transactions from a [`PendingTx`] and finalize. + pub(crate) async fn sign_and_finalize_pending_tx( + &self, + pending_tx: PendingTx, + ) -> Result<(), Error> { + let ark_txid = pending_tx.ark_txid; + let mut signed_checkpoint_txs = pending_tx.signed_checkpoint_txs; + + // Build a map from checkpoint txid -> ark tx input index so we can + // restore witness scripts that the server may have stripped. + let ark_input_idx_by_cp_txid: HashMap<_, _> = pending_tx + .signed_ark_tx + .unsigned_tx + .input + .iter() + .enumerate() + .map(|(i, inp)| (inp.previous_output.txid, i)) + .collect(); + + for checkpoint_psbt in signed_checkpoint_txs.iter_mut() { + if checkpoint_psbt.inputs[0].witness_script.is_none() { + let checkpoint_txid = checkpoint_psbt.unsigned_tx.compute_txid(); + let idx = ark_input_idx_by_cp_txid + .get(&checkpoint_txid) + .ok_or_else(|| { + Error::ad_hoc(format!( + "checkpoint txid {checkpoint_txid} not found in ark tx inputs \ + for pending tx {ark_txid}" + )) + })?; + + let ws = pending_tx + .signed_ark_tx + .inputs + .get(*idx) + .and_then(|input| input.witness_script.clone()) + .ok_or_else(|| { + Error::ad_hoc(format!( + "missing witness script on ark tx input {idx} \ + for pending tx {ark_txid}" + )) + })?; + + checkpoint_psbt.inputs[0].witness_script = Some(ws); + } + + sign_checkpoint_transaction(self.make_sign_fn(), checkpoint_psbt)?; + } + + self.finalize_offchain_tx(ark_txid, signed_checkpoint_txs) + .await + } + + /// Submit offchain transaction data for finalization. + /// + /// We retry a few times to overcome transient failures. + /// + /// After submit succeeds but before finalize completes, a transient error would leave the + /// transaction in a pending state. Retrying here attempts to resolve that, without needing full + /// recovery via [`Self::continue_pending_offchain_txs`]. + pub async fn finalize_offchain_tx( + &self, + ark_txid: Txid, + signed_checkpoint_txs: Vec, + ) -> Result<(), Error> { + const MAX_RETRIES: usize = 3; + + let mut last_err = None; + + for attempt in 0..=MAX_RETRIES { + if attempt > 0 { + let delay = Duration::from_millis(500 * (1 << (attempt - 1))); + tracing::warn!( + %ark_txid, + attempt, + ?delay, + "Retrying finalize after transient failure" + ); + crate::utils::sleep(delay).await; + } + + match timeout_op( + self.inner.timeout, + self.network_client() + .finalize_offchain_transaction(ark_txid, signed_checkpoint_txs.clone()), + ) + .await + .context("finalize offchain transaction timed out")? + { + Ok(_) => return Ok(()), + Err(e) => { + last_err = Some(Error::ark_server(e)); + } + } + } + + Err(last_err + .expect("at least one attempt was made") + .with_context(|| { + format!("failed to finalize offchain transaction after {MAX_RETRIES} retries") + })) + } + + /// Fetch pending offchain transactions from the server. + async fn fetch_pending_offchain_txs(&self) -> Result, Error> { + const MAX_INPUTS_PER_INTENT: usize = 20; + + let ark_addresses = self.get_offchain_addresses().await?; + + // Use pending_only filter to only fetch VTXOs that are spent but not + // finalized. This is much cheaper than fetching all VTXOs when there + // are no pending transactions (common case). + let addresses = ark_addresses.iter().map(|(a, _)| *a); + let request = server::GetVtxosRequest::new_for_addresses(addresses) + .pending_only() + .map_err(Error::from)?; + + let vtxos = self + .fetch_all_vtxos(request) + .await + .context("failed to fetch pending VTXOs")?; + let vtxos = self.annotate_vtxos(vtxos)?; + + tracing::debug!(num_pending_vtxos = vtxos.len(), "Fetched pending VTXOs"); + + if vtxos.is_empty() { + return Ok(vec![]); + } + + let secp = Secp256k1::new(); + let mut all_pending_txs = Vec::new(); + let mut seen_ark_txids = HashSet::new(); + + // Batch inputs to avoid oversized intents. + for (batch_idx, batch) in vtxos.chunks(MAX_INPUTS_PER_INTENT).enumerate() { + let mut vtxo_inputs = Vec::new(); + for entry in batch { + let spend_selection = entry + .spend_selection(SpendPathKind::Forfeit) + .context("failed to get forfeit spend selection")?; + + vtxo_inputs.push(intent::Input::new_with_spend_selection( + entry.vtxo().outpoint, + entry.exit_delay()?, + TxOut { + value: entry.vtxo().amount, + script_pubkey: entry.script_pubkey(), + }, + entry.tapscripts(), + spend_selection, + false, + entry.vtxo().is_swept, + entry.vtxo().assets.clone(), + )); + } + + if vtxo_inputs.is_empty() { + continue; + } + + tracing::debug!( + batch = batch_idx, + num_inputs = vtxo_inputs.len(), + "Querying server for pending txs" + ); + + // expire_at = 0: server does not enforce expiry for get-pending-tx intents. + let message = intent::IntentMessage::GetPendingTx { expire_at: 0 }; + + let sign_for_vtxo_fn = |input: &mut psbt::Input, + msg: secp256k1::Message| + -> Result< + Vec<(schnorr::Signature, XOnlyPublicKey)>, + ark_core::Error, + > { + match &input.witness_script { + None => Err(ark_core::Error::ad_hoc( + "Missing witness script in psbt::Input when signing get-pending-tx intent", + )), + Some(script) => { + let pks = extract_checksig_pubkeys(script); + let mut res = vec![]; + for pk in &pks { + if let Ok(keypair) = self.keypair_by_pk(pk) { + let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair); + res.push((sig, keypair.x_only_public_key().0)); + } + } + Ok(res) + } + } + }; + + let sign_for_onchain_fn = + |_: &mut psbt::Input, + _: secp256k1::Message| + -> Result<(schnorr::Signature, XOnlyPublicKey), ark_core::Error> { + Err(ark_core::Error::ad_hoc( + "unexpected onchain input in get-pending-tx intent", + )) + }; + + let get_pending_intent = intent::make_intent( + sign_for_vtxo_fn, + sign_for_onchain_fn, + vtxo_inputs, + vec![], + message, + )?; + + let pending_txs = self + .network_client() + .get_pending_tx(get_pending_intent) + .await + .map_err(Error::ark_server) + .context("failed to get pending transactions")?; + + tracing::debug!( + batch = batch_idx, + num_pending_txs = pending_txs.len(), + "Server response for batch" + ); + + for tx in pending_txs { + if seen_ark_txids.insert(tx.ark_txid) { + tracing::info!( + ark_txid = %tx.ark_txid, + "Found pending transaction" + ); + all_pending_txs.push(tx); + } + } + } + + tracing::info!( + num_pending_txs = all_pending_txs.len(), + "Total pending transactions found" + ); + + Ok(all_pending_txs) + } +} diff --git a/vendor/ark-client/src/swap_storage/memory.rs b/vendor/ark-client/src/swap_storage/memory.rs new file mode 100644 index 0000000..753d2f0 --- /dev/null +++ b/vendor/ark-client/src/swap_storage/memory.rs @@ -0,0 +1,176 @@ +use super::SwapStorage; +use crate::boltz::ChainSwapData; +use crate::boltz::ReverseSwapData; +use crate::boltz::SubmarineSwapData; +use crate::boltz::SwapStatus; +use crate::Error; +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex; + +/// In-memory implementation of [`SwapStorage`]. +/// +/// This implementation stores swap data in memory using a [`HashMap`] protected by a [`Mutex`]. +/// Data is lost when the application restarts, making this suitable for development, testing, +/// and scenarios where persistence is not required. +pub struct InMemorySwapStorage { + submarine_swaps: Arc>>, + reverse_swaps: Arc>>, + chain_swaps: Arc>>, +} + +impl InMemorySwapStorage { + /// Create a new in-memory swap storage. + /// + /// # Examples + /// + /// ```rust + /// use ark_client::InMemorySwapStorage; + /// + /// let storage = InMemorySwapStorage::new(); + /// ``` + pub fn new() -> Self { + Self { + submarine_swaps: Arc::new(Mutex::new(HashMap::new())), + reverse_swaps: Arc::new(Mutex::new(HashMap::new())), + chain_swaps: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +impl Default for InMemorySwapStorage { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl SwapStorage for InMemorySwapStorage { + async fn insert_submarine(&self, id: String, data: SubmarineSwapData) -> Result<(), Error> { + let mut swaps = self.submarine_swaps.lock().expect("lock"); + swaps.insert(id, data); + Ok(()) + } + + async fn insert_reverse(&self, id: String, data: ReverseSwapData) -> Result<(), Error> { + let mut swaps = self.reverse_swaps.lock().expect("lock"); + swaps.insert(id, data); + Ok(()) + } + + async fn get_submarine(&self, id: &str) -> Result, Error> { + let swaps = self.submarine_swaps.lock().expect("lock"); + Ok(swaps.get(id).cloned()) + } + + async fn get_reverse(&self, id: &str) -> Result, Error> { + let swaps = self.reverse_swaps.lock().expect("lock"); + Ok(swaps.get(id).cloned()) + } + + async fn update_status_submarine(&self, id: &str, status: SwapStatus) -> Result<(), Error> { + let mut swaps = self.submarine_swaps.lock().expect("lock"); + if let Some(swap) = swaps.get_mut(id) { + swap.status = status; + Ok(()) + } else { + Err(Error::consumer(format!("swap not found: {id}"))) + } + } + + async fn update_status_reverse(&self, id: &str, status: SwapStatus) -> Result<(), Error> { + let mut swaps = self.reverse_swaps.lock().expect("lock"); + if let Some(swap) = swaps.get_mut(id) { + swap.status = status; + Ok(()) + } else { + Err(Error::consumer(format!("swap not found: {id}"))) + } + } + + async fn update_submarine(&self, id: &str, data: SubmarineSwapData) -> Result<(), Error> { + let mut swaps = self.submarine_swaps.lock().expect("lock"); + match swaps.get_mut(id) { + Some(entry) => { + *entry = data; + Ok(()) + } + None => Err(Error::consumer(format!("submarine swap not found: {id}"))), + } + } + + async fn update_reverse(&self, id: &str, data: ReverseSwapData) -> Result<(), Error> { + let mut swaps = self.reverse_swaps.lock().expect("lock"); + match swaps.get_mut(id) { + Some(entry) => { + *entry = data; + Ok(()) + } + None => Err(Error::consumer(format!("reverse swap not found: {id}"))), + } + } + + async fn list_all_submarine(&self) -> Result, Error> { + let swaps = self.submarine_swaps.lock().expect("lock"); + Ok(swaps.values().cloned().collect()) + } + + async fn list_all_reverse(&self) -> Result, Error> { + let swaps = self.reverse_swaps.lock().expect("lock"); + + Ok(swaps.values().cloned().collect()) + } + + async fn remove_submarine(&self, id: &str) -> Result, Error> { + let mut swaps = self.submarine_swaps.lock().expect("lock"); + Ok(swaps.remove(id)) + } + + async fn remove_reverse(&self, id: &str) -> Result, Error> { + let mut swaps = self.reverse_swaps.lock().expect("lock"); + Ok(swaps.remove(id)) + } + + async fn insert_chain(&self, id: String, data: ChainSwapData) -> Result<(), Error> { + let mut swaps = self.chain_swaps.lock().expect("lock"); + swaps.insert(id, data); + Ok(()) + } + + async fn get_chain(&self, id: &str) -> Result, Error> { + let swaps = self.chain_swaps.lock().expect("lock"); + Ok(swaps.get(id).cloned()) + } + + async fn update_status_chain(&self, id: &str, status: SwapStatus) -> Result<(), Error> { + let mut swaps = self.chain_swaps.lock().expect("lock"); + if let Some(swap) = swaps.get_mut(id) { + swap.status = status; + Ok(()) + } else { + Err(Error::consumer(format!("chain swap not found: {id}"))) + } + } + + async fn update_chain(&self, id: &str, data: ChainSwapData) -> Result<(), Error> { + let mut swaps = self.chain_swaps.lock().expect("lock"); + match swaps.get_mut(id) { + Some(entry) => { + *entry = data; + Ok(()) + } + None => Err(Error::consumer(format!("chain swap not found: {id}"))), + } + } + + async fn list_all_chain(&self) -> Result, Error> { + let swaps = self.chain_swaps.lock().expect("lock"); + Ok(swaps.values().cloned().collect()) + } + + async fn remove_chain(&self, id: &str) -> Result, Error> { + let mut swaps = self.chain_swaps.lock().expect("lock"); + Ok(swaps.remove(id)) + } +} diff --git a/vendor/ark-client/src/swap_storage/mod.rs b/vendor/ark-client/src/swap_storage/mod.rs new file mode 100644 index 0000000..3c7da26 --- /dev/null +++ b/vendor/ark-client/src/swap_storage/mod.rs @@ -0,0 +1,177 @@ +//! # Swap Storage +//! +//! The Ark client supports pluggable swap storage implementations, allowing you to persist +//! swap data using your preferred storage backend while providing an in-memory fallback by default. +//! +//! ## Available Implementations +//! +//! - [`InMemorySwapStorage`] - Default in-memory implementation for development and testing +//! - [`SqliteSwapStorage`] - SQLite-based persistent implementation for production use (requires +//! the `sqlite` feature) +use crate::boltz::ChainSwapData; +use crate::boltz::ReverseSwapData; +use crate::boltz::SubmarineSwapData; +use crate::boltz::SwapStatus; +use crate::Error; +use async_trait::async_trait; + +mod memory; +#[cfg(feature = "sqlite")] +mod sqlite; + +pub use memory::InMemorySwapStorage; +#[cfg(feature = "sqlite")] +pub use sqlite::SqliteSwapStorage; + +/// Trait for storing and retrieving swap data. +/// +/// This trait provides a pluggable interface for swap persistence, allowing different +/// storage backends to be used with the Ark client. +#[async_trait] +pub trait SwapStorage: Send + Sync { + /// Store submarine swap data. + /// + /// # Arguments + /// * `id` - Unique identifier for the swap + /// * `data` - The swap data to store + /// + /// # Errors + /// Returns an error if the swap cannot be stored (e.g., database error, duplicate ID). + async fn insert_submarine(&self, id: String, data: SubmarineSwapData) -> Result<(), Error>; + + /// Store reverse submarine swap data. + /// + /// # Arguments + /// * `id` - Unique identifier for the swap + /// * `data` - The swap data to store + /// + /// # Errors + /// Returns an error if the swap cannot be stored (e.g., database error, duplicate ID). + async fn insert_reverse(&self, id: String, data: ReverseSwapData) -> Result<(), Error>; + + /// Retrieve submarine swap data by ID. + /// + /// # Arguments + /// * `id` - The unique identifier of the swap to retrieve + /// + /// # Returns + /// * `Ok(Some(data))` if the swap exists + /// * `Ok(None)` if the swap does not exist + /// * `Err(error)` if there was an error accessing storage + async fn get_submarine(&self, id: &str) -> Result, Error>; + + /// Retrieve reverse submarine swap data by ID. + /// + /// # Arguments + /// * `id` - The unique identifier of the swap to retrieve + /// + /// # Returns + /// * `Ok(Some(data))` if the swap exists + /// * `Ok(None)` if the swap does not exist + /// * `Err(error)` if there was an error accessing storage + async fn get_reverse(&self, id: &str) -> Result, Error>; + + /// Update the status of an existing submarine swap. + /// + /// This is a convenience method that retrieves the swap, updates its status, + /// and saves it back to storage. + /// + /// # Arguments + /// * `id` - The unique identifier of the swap to update + /// * `status` - The new status to set + /// + /// # Errors + /// Returns an error if the swap doesn't exist or cannot be updated. + async fn update_status_submarine(&self, id: &str, status: SwapStatus) -> Result<(), Error>; + + /// Update the status of an existing reverse submarine swap. + /// + /// This is a convenience method that retrieves the swap, updates its status, + /// and saves it back to storage. + /// + /// # Arguments + /// * `id` - The unique identifier of the swap to update + /// * `status` - The new status to set + /// + /// # Errors + /// Returns an error if the swap doesn't exist or cannot be updated. + async fn update_status_reverse(&self, id: &str, status: SwapStatus) -> Result<(), Error>; + + /// Update an existing submarine swap. + /// + /// # Arguments + /// * `id` - The unique identifier of the swap to update + /// * `data` - The new swap data to set + /// + /// # Errors + /// Returns an error if the swap doesn't exist or cannot be updated. + async fn update_submarine(&self, id: &str, data: SubmarineSwapData) -> Result<(), Error>; + + /// Update an existing reverse submarine swap. + /// + /// # Arguments + /// * `id` - The unique identifier of the swap to update + /// * `data` - The new swap data to set + /// + /// # Errors + /// Returns an error if the swap doesn't exist or cannot be updated. + async fn update_reverse(&self, id: &str, data: ReverseSwapData) -> Result<(), Error>; + + /// List all stored submarine swaps. + /// + /// # Returns + /// A vector containing all swap data. The order may vary by implementation. + /// + /// # Errors + /// Returns an error if the swaps cannot be retrieved from storage. + async fn list_all_submarine(&self) -> Result, Error>; + + /// List all stored reverse submarine swaps. + /// + /// # Returns + /// A vector containing all swap data. The order may vary by implementation. + /// + /// # Errors + /// Returns an error if the swaps cannot be retrieved from storage. + async fn list_all_reverse(&self) -> Result, Error>; + + /// Remove submarine swap data by ID. + /// + /// # Arguments + /// * `id` - The unique identifier of the swap to remove + /// + /// # Returns + /// * `Ok(Some(data))` if the swap existed and was removed + /// * `Ok(None)` if the swap did not exist + /// * `Err(error)` if there was an error accessing storage + async fn remove_submarine(&self, id: &str) -> Result, Error>; + + /// Remove reverse submarine swap data by ID. + /// + /// # Arguments + /// * `id` - The unique identifier of the swap to remove + /// + /// # Returns + /// * `Ok(Some(data))` if the swap existed and was removed + /// * `Ok(None)` if the swap did not exist + /// * `Err(error)` if there was an error accessing storage + async fn remove_reverse(&self, id: &str) -> Result, Error>; + + /// Store chain swap data. + async fn insert_chain(&self, id: String, data: ChainSwapData) -> Result<(), Error>; + + /// Retrieve chain swap data by ID. + async fn get_chain(&self, id: &str) -> Result, Error>; + + /// Update the status of an existing chain swap. + async fn update_status_chain(&self, id: &str, status: SwapStatus) -> Result<(), Error>; + + /// Update an existing chain swap. + async fn update_chain(&self, id: &str, data: ChainSwapData) -> Result<(), Error>; + + /// List all stored chain swaps. + async fn list_all_chain(&self) -> Result, Error>; + + /// Remove chain swap data by ID. + async fn remove_chain(&self, id: &str) -> Result, Error>; +} diff --git a/vendor/ark-client/src/swap_storage/sqlite.rs b/vendor/ark-client/src/swap_storage/sqlite.rs new file mode 100644 index 0000000..9a0a3ba --- /dev/null +++ b/vendor/ark-client/src/swap_storage/sqlite.rs @@ -0,0 +1,674 @@ +use super::SwapStorage; +use crate::boltz::ChainSwapData; +use crate::boltz::ReverseSwapData; +use crate::boltz::SubmarineSwapData; +use crate::boltz::SwapStatus; +use crate::Error; +use async_trait::async_trait; +use sqlx::sqlite::SqliteConnectOptions; +use sqlx::sqlite::SqlitePoolOptions; +use sqlx::sqlite::SqliteRow; +use sqlx::Pool; +use sqlx::Row; +use sqlx::Sqlite; +use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +// TODO: move this into its own crate? +/// SQLite-based persistent implementation of [`SwapStorage`]. +#[derive(Clone)] +pub struct SqliteSwapStorage { + pool: Pool, +} + +impl SqliteSwapStorage { + /// Create a new SQLite swap storage with the specified database file path. + /// + /// The database file and parent directories will be created if they don't exist. + /// Database migrations will be automatically applied. + /// + /// # Arguments + /// * `db_path` - Path to the SQLite database file + /// + /// # Errors + /// + /// Returns an error if: + /// - The database file cannot be created or opened + /// - Database migrations fail + /// - Parent directory cannot be created + pub async fn new>(db_path: P) -> Result { + let db_path = db_path.as_ref(); + + // Create parent directory if it doesn't exist + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + Error::consumer(format!("Failed to create database directory: {e}")) + })?; + } + + let options = SqliteConnectOptions::new() + .filename(db_path) + .create_if_missing(true); + + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect_with(options) + .await + .map_err(|e| Error::consumer(format!("Failed to connect to database: {e}")))?; + + // Run migrations + sqlx::migrate!("./migrations") + .run(&pool) + .await + .map_err(|e| Error::consumer(format!("Failed to run migrations: {e}")))?; + + Ok(Self { pool }) + } + + /// Create a new SQLite swap storage with the default database path. + /// + /// The default path is `./swaps.db` in the current working directory. + /// This is convenient for development and single-instance deployments. + /// + /// # Errors + /// + /// Returns an error if the database cannot be created or opened in the current directory. + pub async fn new_default() -> Result { + let db_path = Path::new("swaps.db"); + Self::new(db_path).await + } + + fn current_timestamp() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("valid duration") + .as_secs() as i64 + } +} + +#[async_trait] +impl SwapStorage for SqliteSwapStorage { + async fn insert_submarine(&self, id: String, data: SubmarineSwapData) -> Result<(), Error> { + let data_json = serde_json::to_string(&data).map_err(|e| { + Error::consumer(format!("Failed to serialize submarine swap data: {e}")) + })?; + + let now = Self::current_timestamp(); + + sqlx::query( + "INSERT INTO submarine_swaps (id, data, created_at, updated_at) VALUES (?, ?, ?, ?)", + ) + .bind(&id) + .bind(&data_json) + .bind(now) + .bind(now) + .execute(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to insert submarine swap: {e}")))?; + + Ok(()) + } + + async fn insert_reverse(&self, id: String, data: ReverseSwapData) -> Result<(), Error> { + let data_json = serde_json::to_string(&data) + .map_err(|e| Error::consumer(format!("Failed to serialize reverse swap data: {e}")))?; + + let now = Self::current_timestamp(); + + sqlx::query( + "INSERT INTO reverse_swaps (id, data, created_at, updated_at) VALUES (?, ?, ?, ?)", + ) + .bind(&id) + .bind(&data_json) + .bind(now) + .bind(now) + .execute(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to insert reverse swap: {e}")))?; + + Ok(()) + } + + async fn get_submarine(&self, id: &str) -> Result, Error> { + let row: Option = sqlx::query("SELECT data FROM submarine_swaps WHERE id = ?") + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to query submarine swap: {e}")))?; + + match row { + Some(row) => { + let data: String = row.get("data"); + let swap_data: SubmarineSwapData = serde_json::from_str(&data).map_err(|e| { + Error::consumer(format!("Failed to deserialize submarine swap data: {e}")) + })?; + Ok(Some(swap_data)) + } + None => Ok(None), + } + } + + async fn get_reverse(&self, id: &str) -> Result, Error> { + let row: Option = sqlx::query("SELECT data FROM reverse_swaps WHERE id = ?") + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to query reverse swap: {e}")))?; + + match row { + Some(row) => { + let data: String = row.get("data"); + let swap_data: ReverseSwapData = serde_json::from_str(&data).map_err(|e| { + Error::consumer(format!("Failed to deserialize reverse swap data: {e}")) + })?; + Ok(Some(swap_data)) + } + None => Ok(None), + } + } + + async fn update_status_submarine(&self, id: &str, status: SwapStatus) -> Result<(), Error> { + // First, get the existing swap + let mut swap_data = self + .get_submarine(id) + .await? + .ok_or_else(|| Error::consumer(format!("Submarine swap not found: {id}")))?; + + // Update the status + swap_data.status = status; + + // Serialize and save back + let data_json = serde_json::to_string(&swap_data).map_err(|e| { + Error::consumer(format!("Failed to serialize submarine swap data: {e}")) + })?; + + let now = Self::current_timestamp(); + + let result = + sqlx::query("UPDATE submarine_swaps SET data = ?, updated_at = ? WHERE id = ?") + .bind(&data_json) + .bind(now) + .bind(id) + .execute(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to update submarine swap: {e}")))?; + + if result.rows_affected() == 0 { + return Err(Error::consumer(format!("Submarine swap not found: {id}"))); + } + + Ok(()) + } + + async fn update_status_reverse(&self, id: &str, status: SwapStatus) -> Result<(), Error> { + // First, get the existing swap + let mut swap_data = self + .get_reverse(id) + .await? + .ok_or_else(|| Error::consumer(format!("Reverse swap not found: {id}")))?; + + // Update the status + swap_data.status = status; + + // Serialize and save back + let data_json = serde_json::to_string(&swap_data) + .map_err(|e| Error::consumer(format!("Failed to serialize reverse swap data: {e}")))?; + + let now = Self::current_timestamp(); + + let result = sqlx::query("UPDATE reverse_swaps SET data = ?, updated_at = ? WHERE id = ?") + .bind(&data_json) + .bind(now) + .bind(id) + .execute(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to update reverse swap: {e}")))?; + + if result.rows_affected() == 0 { + return Err(Error::consumer(format!("Reverse swap not found: {id}"))); + } + + Ok(()) + } + + async fn update_submarine(&self, id: &str, data: SubmarineSwapData) -> Result<(), Error> { + let data_json = serde_json::to_string(&data).map_err(|e| { + Error::consumer(format!("Failed to serialize submarine swap data: {e}")) + })?; + + let now = Self::current_timestamp(); + + let result = + sqlx::query("UPDATE submarine_swaps SET data = ?, updated_at = ? WHERE id = ?") + .bind(&data_json) + .bind(now) + .bind(id) + .execute(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to update submarine swap: {e}")))?; + + if result.rows_affected() == 0 { + return Err(Error::consumer(format!("Submarine swap not found: {id}"))); + } + + Ok(()) + } + + async fn update_reverse(&self, id: &str, data: ReverseSwapData) -> Result<(), Error> { + let data_json = serde_json::to_string(&data) + .map_err(|e| Error::consumer(format!("Failed to serialize reverse swap data: {e}")))?; + + let now = Self::current_timestamp(); + + let result = sqlx::query("UPDATE reverse_swaps SET data = ?, updated_at = ? WHERE id = ?") + .bind(&data_json) + .bind(now) + .bind(id) + .execute(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to update reverse swap: {e}")))?; + + if result.rows_affected() == 0 { + return Err(Error::consumer(format!("Reverse swap not found: {id}"))); + } + + Ok(()) + } + + async fn list_all_submarine(&self) -> Result, Error> { + let rows: Vec = + sqlx::query("SELECT data FROM submarine_swaps ORDER BY created_at ASC") + .fetch_all(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to list submarine swaps: {e}")))?; + + let mut swaps = Vec::new(); + for row in rows { + let data: String = row.get("data"); + let swap_data: SubmarineSwapData = serde_json::from_str(&data).map_err(|e| { + Error::consumer(format!("Failed to deserialize submarine swap data: {e}")) + })?; + swaps.push(swap_data); + } + + Ok(swaps) + } + + async fn list_all_reverse(&self) -> Result, Error> { + let rows: Vec = + sqlx::query("SELECT data FROM reverse_swaps ORDER BY created_at ASC") + .fetch_all(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to list reverse swaps: {e}")))?; + + let mut swaps = Vec::new(); + for row in rows { + let data: String = row.get("data"); + let swap_data: ReverseSwapData = serde_json::from_str(&data).map_err(|e| { + Error::consumer(format!("Failed to deserialize reverse swap data: {e}")) + })?; + swaps.push(swap_data); + } + + Ok(swaps) + } + + async fn remove_submarine(&self, id: &str) -> Result, Error> { + // First get the swap data to return it + let swap_data = self.get_submarine(id).await?; + + if swap_data.is_some() { + let result = sqlx::query("DELETE FROM submarine_swaps WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to delete submarine swap: {e}")))?; + + if result.rows_affected() == 0 { + return Ok(None); + } + } + + Ok(swap_data) + } + + async fn remove_reverse(&self, id: &str) -> Result, Error> { + // First get the swap data to return it + let swap_data = self.get_reverse(id).await?; + + if swap_data.is_some() { + let result = sqlx::query("DELETE FROM reverse_swaps WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to delete reverse swap: {e}")))?; + + if result.rows_affected() == 0 { + return Ok(None); + } + } + + Ok(swap_data) + } + + async fn insert_chain(&self, id: String, data: ChainSwapData) -> Result<(), Error> { + let data_json = serde_json::to_string(&data) + .map_err(|e| Error::consumer(format!("Failed to serialize chain swap data: {e}")))?; + + let now = Self::current_timestamp(); + + sqlx::query( + "INSERT INTO chain_swaps (id, data, created_at, updated_at) VALUES (?, ?, ?, ?)", + ) + .bind(&id) + .bind(&data_json) + .bind(now) + .bind(now) + .execute(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to insert chain swap: {e}")))?; + + Ok(()) + } + + async fn get_chain(&self, id: &str) -> Result, Error> { + let row: Option = sqlx::query("SELECT data FROM chain_swaps WHERE id = ?") + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to query chain swap: {e}")))?; + + match row { + Some(row) => { + let data: String = row.get("data"); + let swap_data: ChainSwapData = serde_json::from_str(&data).map_err(|e| { + Error::consumer(format!("Failed to deserialize chain swap data: {e}")) + })?; + Ok(Some(swap_data)) + } + None => Ok(None), + } + } + + async fn update_status_chain(&self, id: &str, status: SwapStatus) -> Result<(), Error> { + let mut swap_data = self + .get_chain(id) + .await? + .ok_or_else(|| Error::consumer(format!("chain swap not found: {id}")))?; + + swap_data.status = status; + + let data_json = serde_json::to_string(&swap_data) + .map_err(|e| Error::consumer(format!("Failed to serialize chain swap data: {e}")))?; + + let now = Self::current_timestamp(); + + sqlx::query("UPDATE chain_swaps SET data = ?, updated_at = ? WHERE id = ?") + .bind(&data_json) + .bind(now) + .bind(id) + .execute(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to update chain swap status: {e}")))?; + + Ok(()) + } + + async fn update_chain(&self, id: &str, data: ChainSwapData) -> Result<(), Error> { + let data_json = serde_json::to_string(&data) + .map_err(|e| Error::consumer(format!("Failed to serialize chain swap data: {e}")))?; + + let now = Self::current_timestamp(); + + let result = sqlx::query("UPDATE chain_swaps SET data = ?, updated_at = ? WHERE id = ?") + .bind(&data_json) + .bind(now) + .bind(id) + .execute(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to update chain swap: {e}")))?; + + if result.rows_affected() == 0 { + return Err(Error::consumer(format!("chain swap not found: {id}"))); + } + + Ok(()) + } + + async fn list_all_chain(&self) -> Result, Error> { + let rows: Vec = + sqlx::query("SELECT data FROM chain_swaps ORDER BY created_at ASC") + .fetch_all(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to list chain swaps: {e}")))?; + + let mut swaps = Vec::new(); + for row in rows { + let data: String = row.get("data"); + let swap_data: ChainSwapData = serde_json::from_str(&data).map_err(|e| { + Error::consumer(format!("Failed to deserialize chain swap data: {e}")) + })?; + swaps.push(swap_data); + } + + Ok(swaps) + } + + async fn remove_chain(&self, id: &str) -> Result, Error> { + let swap_data = self.get_chain(id).await?; + + if swap_data.is_some() { + let result = sqlx::query("DELETE FROM chain_swaps WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await + .map_err(|e| Error::consumer(format!("Failed to delete chain swap: {e}")))?; + + if result.rows_affected() == 0 { + return Ok(None); + } + } + + Ok(swap_data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_core::ArkAddress; + use bitcoin::hashes::ripemd160; + use bitcoin::hashes::Hash; + use bitcoin::Amount; + use bitcoin::PublicKey; + use lightning_invoice::Bolt11Invoice; + use std::str::FromStr; + use tempfile::TempDir; + + fn create_test_submarine_swap_data(id: &str) -> SubmarineSwapData { + SubmarineSwapData { + id: id.to_string(), + status: SwapStatus::Created, + preimage: None, + preimage_hash: ripemd160::Hash::from_slice(&[2u8; 20]).unwrap(), + refund_public_key: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + claim_public_key: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + vhtlc_address: ArkAddress::decode("tark1qqellv77udfmr20tun8dvju5vgudpf9vxe8jwhthrkn26fz96pawqfdy8nk05rsmrf8h94j26905e7n6sng8y059z8ykn2j5xcuw4xt846qj6x").unwrap(), + timeout_block_heights: crate::boltz::TimeoutBlockHeights { + refund: 144, + unilateral_claim: 24, + unilateral_refund: 144, + unilateral_refund_without_receiver: 288, + }, + amount: Amount::from_sat(100_000), + invoice: Bolt11Invoice::from_str("lnbcrt10u1p5d55pjpp56ms94rkev7tdrwqyus5a63lny2mqzq9vh2rq3u4ym3v4lxv6xl4qdql2djkuepqw3hjqs2jfvsxzerywfjhxuccqz95xqztfsp57x0nwf7nzsndjdrvsre570ehg0szw34l284hswdz6zpqvktq9mrs9qxpqysgqllgxhxeny0tvtnxuqgn4s0t2qamc6yqc4t3pe6p2x5lgs8v8r3vxzxp3a3ax9j7d2ta5cduddln8n9se7q0jgg7s0h8t2vhljlu3wkcps9k8xs").unwrap(), + created_at: 1234567890, + key_derivation_index: None, + contract_script_pubkey: None, + } + } + + fn create_test_reverse_swap_data(id: &str) -> ReverseSwapData { + ReverseSwapData { + id: id.to_string(), + status: SwapStatus::Created, + preimage: Some([1u8; 32]), + vhtlc_address: ArkAddress::decode("tark1qqellv77udfmr20tun8dvju5vgudpf9vxe8jwhthrkn26fz96pawqfdy8nk05rsmrf8h94j26905e7n6sng8y059z8ykn2j5xcuw4xt846qj6x").unwrap(), + preimage_hash: ripemd160::Hash::from_slice(&[2u8; 20]).unwrap(), + refund_public_key: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + amount: Amount::from_sat(100_000), + claim_public_key: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + timeout_block_heights: crate::boltz::TimeoutBlockHeights { + refund: 144, + unilateral_claim: 24, + unilateral_refund: 144, + unilateral_refund_without_receiver: 288, + }, + created_at: 1234567890, + key_derivation_index: None, + bolt11: "lnbc100u1ptest".to_string(), + invoice_expiry: 3600, + claim_address: None, + contract_script_pubkey: None, + } + } + + #[tokio::test] + async fn test_sqlite_storage_submarine_operations() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + + let storage = SqliteSwapStorage::new(&db_path).await.unwrap(); + + // Test insert and get + let swap1 = create_test_submarine_swap_data("swap1"); + storage + .insert_submarine("swap1".to_string(), swap1.clone()) + .await + .unwrap(); + + let retrieved = storage.get_submarine("swap1").await.unwrap(); + assert!(retrieved.is_some()); + let retrieved = retrieved.unwrap(); + assert_eq!(retrieved.id, swap1.id); + assert_eq!(retrieved.status, swap1.status); + + // Test get non-existent + let non_existent = storage.get_submarine("nonexistent").await.unwrap(); + assert!(non_existent.is_none()); + + // Test list_all + let swap2 = create_test_submarine_swap_data("swap2"); + storage + .insert_submarine("swap2".to_string(), swap2.clone()) + .await + .unwrap(); + + let all_swaps = storage.list_all_submarine().await.unwrap(); + assert_eq!(all_swaps.len(), 2); + + // Test update_status + storage + .update_status_submarine("swap1", SwapStatus::InvoicePaid) + .await + .unwrap(); + let updated = storage.get_submarine("swap1").await.unwrap().unwrap(); + assert_eq!(updated.status, SwapStatus::InvoicePaid); + + // Test remove + let removed = storage.remove_submarine("swap1").await.unwrap(); + assert!(removed.is_some()); + assert_eq!(removed.unwrap().id, "swap1"); + + let after_remove = storage.get_submarine("swap1").await.unwrap(); + assert!(after_remove.is_none()); + + let remaining = storage.list_all_submarine().await.unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].id, "swap2"); + + // Test update_submarine with preimage persistence + let mut updated_swap2 = swap2.clone(); + updated_swap2.preimage = Some([7u8; 32]); + updated_swap2.status = SwapStatus::InvoicePaid; + storage + .update_submarine("swap2", updated_swap2.clone()) + .await + .unwrap(); + let from_db = storage.get_submarine("swap2").await.unwrap().unwrap(); + assert_eq!(from_db.preimage, Some([7u8; 32])); + assert_eq!(from_db.status, SwapStatus::InvoicePaid); + + // Test update_submarine on non-existent swap returns error + let result = storage.update_submarine("nonexistent", updated_swap2).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_sqlite_storage_reverse_operations() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + + let storage = SqliteSwapStorage::new(&db_path).await.unwrap(); + + // Test insert and get + let swap1 = create_test_reverse_swap_data("swap1"); + storage + .insert_reverse("swap1".to_string(), swap1.clone()) + .await + .unwrap(); + + let retrieved = storage.get_reverse("swap1").await.unwrap(); + assert!(retrieved.is_some()); + let retrieved = retrieved.unwrap(); + assert_eq!(retrieved.id, swap1.id); + assert_eq!(retrieved.status, swap1.status); + + // Test get non-existent + let non_existent = storage.get_reverse("nonexistent").await.unwrap(); + assert!(non_existent.is_none()); + + // Test list_all + let swap2 = create_test_reverse_swap_data("swap2"); + storage + .insert_reverse("swap2".to_string(), swap2.clone()) + .await + .unwrap(); + + let all_swaps = storage.list_all_reverse().await.unwrap(); + assert_eq!(all_swaps.len(), 2); + + // Test update_status + storage + .update_status_reverse("swap1", SwapStatus::InvoicePaid) + .await + .unwrap(); + let updated = storage.get_reverse("swap1").await.unwrap().unwrap(); + assert_eq!(updated.status, SwapStatus::InvoicePaid); + + // Test remove + let removed = storage.remove_reverse("swap1").await.unwrap(); + assert!(removed.is_some()); + assert_eq!(removed.unwrap().id, "swap1"); + + let after_remove = storage.get_reverse("swap1").await.unwrap(); + assert!(after_remove.is_none()); + + let remaining = storage.list_all_reverse().await.unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].id, "swap2"); + } +} diff --git a/vendor/ark-client/src/unilateral_exit.rs b/vendor/ark-client/src/unilateral_exit.rs new file mode 100644 index 0000000..4887cf1 --- /dev/null +++ b/vendor/ark-client/src/unilateral_exit.rs @@ -0,0 +1,317 @@ +use crate::coin_select::coin_select_for_onchain; +use crate::error::Error; +use crate::error::ErrorContext; +use crate::swap_storage::SwapStorage; +use crate::utils::sleep; +use crate::utils::timeout_op; +use crate::wallet::OnchainWallet; +use crate::Blockchain; +use crate::Client; +use crate::GetVtxosRequestFilter; +use ark_core::build_unilateral_exit_tree_txids; +use ark_core::script::extract_checksig_pubkeys; +use ark_core::unilateral_exit; +use ark_core::unilateral_exit::create_unilateral_exit_transaction; +use ark_core::unilateral_exit::finalize_unilateral_exit_tree; +use ark_core::unilateral_exit::UnilateralExitTree; +use backon::ExponentialBuilder; +use backon::Retryable; +use bitcoin::key::Secp256k1; +use bitcoin::psbt; +use bitcoin::Address; +use bitcoin::Amount; +use bitcoin::Transaction; +use bitcoin::TxOut; +use bitcoin::Txid; +use std::collections::HashSet; + +// TODO: We should not _need_ to connect to the Ark server to perform unilateral exit. Currently we +// do talk to the Ark server for simplicity. +impl Client +where + B: Blockchain, + W: OnchainWallet, + S: SwapStorage + 'static, +{ + /// Build the unilateral exit transaction tree for all spendable VTXOs. + /// + /// ### Returns + /// + /// The tree as a `Vec>`, where each branch represents a path from a + /// commitment transaction output to a spendable VTXO. Every transaction is finalized, but + /// requires fee bumping through a P2A output. + pub async fn build_unilateral_exit_trees(&self) -> Result>, Error> { + // The server's spendable VTXOs are those neither spent, swept nor unrolled. The server + // knows nothing of signer cutoffs, so VTXOs under a deprecated signer past its cutoff are + // included and can still be exited. + let vtxo_list = self + .list_vtxos_filtered(GetVtxosRequestFilter::Spendable) + .await + .context("failed to get spendable VTXOs")?; + + let mut unilateral_exit_trees = Vec::new(); + + // For each spendable VTXO, generate its unilateral exit tree. + for contract_vtxo in vtxo_list.could_exit_unilaterally() { + let virtual_tx_outpoint = contract_vtxo.vtxo(); + let vtxo_chain_response = timeout_op( + self.inner.timeout, + self.network_client().get_vtxo_chain( + Some(virtual_tx_outpoint.outpoint), + None, + None, + None, + ), + ) + .await + .context(format!( + "failed to get VTXO chain for outpoint {}", + virtual_tx_outpoint.outpoint + ))??; + + let paths = build_unilateral_exit_tree_txids( + &vtxo_chain_response.chains, + virtual_tx_outpoint.outpoint.txid, + )?; + + // We don't want to fetch transactions more than once. + let txs = HashSet::::from_iter(paths.concat()); + + let virtual_txs_response = timeout_op( + self.inner.timeout, + self.network_client().get_virtual_txs( + txs.iter().map(|tx| tx.to_string()).collect(), + None, + None, + ), + ) + .await + .context("failed to get virtual TXs")??; + + let paths = paths + .into_iter() + .map(|path| { + path.into_iter() + .map(|txid| { + virtual_txs_response + .txs + .iter() + .find(|t| t.unsigned_tx.compute_txid() == txid) + .cloned() + .ok_or_else(|| { + Error::ad_hoc(format!("no PSBT found for virtual TX {txid}")) + }) + }) + .collect::, _>>() + }) + .collect::, _>>()?; + + let unilateral_exit_tree = + UnilateralExitTree::new(virtual_tx_outpoint.commitment_txids.clone(), paths); + + unilateral_exit_trees.push(unilateral_exit_tree); + } + + let mut branches: Vec> = Vec::new(); + for unilateral_exit_tree in unilateral_exit_trees { + let commitment_txids = unilateral_exit_tree.commitment_txids(); + + let mut commitment_txs = Vec::new(); + for commitment_txid in commitment_txids.iter() { + let commitment_tx = timeout_op( + self.inner.timeout, + self.blockchain().find_tx(commitment_txid), + ) + .await?? + .ok_or_else(|| { + Error::ad_hoc(format!("could not find commitment TX {commitment_txid}")) + })?; + + commitment_txs.push(commitment_tx); + } + + let finalized_unilateral_exit_tree = + finalize_unilateral_exit_tree(&unilateral_exit_tree, commitment_txs.as_slice())?; + branches.extend(finalized_unilateral_exit_tree); + } + + Ok(branches) + } + + /// Broadcast the next unconfirmed transaction in a branch, skipping transactions that are + /// already on the blockchain. + /// + /// ### Returns + /// + /// `Ok(Some(txid))` if a transaction was broadcast, `Ok(None)` if all are confirmed. + pub async fn broadcast_next_unilateral_exit_node( + &self, + branch: &[Transaction], + ) -> Result, Error> { + let blockchain = &self.blockchain(); + + for parent_tx in branch { + let parent_txid = parent_tx.compute_txid(); + + let broadcast = || async { + let is_not_published = blockchain.find_tx(&parent_txid).await?.is_none(); + + if is_not_published { + let child_tx = self.bump_tx(parent_tx).await?; + let bump_txid = child_tx.compute_txid(); + + tracing::info!( + txid = %parent_txid, + %bump_txid, + "Broadcasting unilateral exit TX" + ); + + blockchain + .broadcast_package(&[parent_tx, &child_tx]) + .await?; + + Ok(Some(parent_txid)) + } else { + tracing::debug!( + %parent_txid, + "Unilateral exit TX already found on the blockchain" + ); + + Ok(None) + } + }; + + let res = broadcast + .retry(ExponentialBuilder::default().with_max_times(5)) + .sleep(sleep) + .notify(|err: &Error, dur: std::time::Duration| { + tracing::warn!( + "Retrying broadcasting VTXO transaction {parent_txid} after {dur:?}. Error: {err}", + ); + }) + .await + .with_context(|| format!("Failed to broadcast VTXO transaction {parent_txid}"))?; + + if let Some(bump_txid) = res { + tracing::info!( + txid = %parent_txid, + %bump_txid, + "Broadcast VTXO transaction" + ); + + return Ok(Some(parent_txid)); + } + } + + // All transactions in the branch are already on-chain + Ok(None) + } + + /// Spend boarding outputs and VTXOs to an _on-chain_ address. + /// + /// All these outputs are spent unilaterally. + /// + /// To be able to spend a boarding output, we must wait for the exit delay to pass. + /// + /// To be able to spend a VTXO, the VTXO itself must be published on-chain (via something like + /// `unilateral_off_board`), and then we must wait for the exit delay to pass. + pub async fn send_on_chain( + &self, + to_address: Address, + to_amount: Amount, + ) -> Result { + let (tx, _) = self + .create_send_on_chain_transaction_inner(to_address, to_amount) + .await?; + + let txid = tx.compute_txid(); + tracing::info!( + %txid, + "Broadcasting transaction sending Ark outputs onchain" + ); + + timeout_op(self.inner.timeout, self.blockchain().broadcast(&tx)) + .await + .with_context(|| format!("failed to broadcast transaction {txid}"))??; + + Ok(txid) + } + + /// Build the on-chain send transaction without broadcasting. + /// + /// Primarily useful for testing. Exposed publicly behind the `test-utils` feature. + #[cfg(feature = "test-utils")] + pub async fn create_send_on_chain_transaction( + &self, + to_address: Address, + to_amount: Amount, + ) -> Result<(Transaction, Vec), Error> { + self.create_send_on_chain_transaction_inner(to_address, to_amount) + .await + } + + pub(crate) async fn create_send_on_chain_transaction_inner( + &self, + to_address: Address, + to_amount: Amount, + ) -> Result<(Transaction, Vec), Error> { + let dust = self.server_info().await?.dust; + if to_amount < dust { + return Err(Error::ad_hoc(format!( + "invalid amount {to_amount}, must be greater than dust: {}", + dust, + ))); + } + + // TODO: Do not use an arbitrary fee. + let fee = Amount::from_sat(1_000); + + let (onchain_inputs, vtxo_inputs) = coin_select_for_onchain(self, to_amount + fee).await?; + + let change_address = self.inner.wallet.get_onchain_address()?; + + let sign = move |input: &mut psbt::Input, msg: bitcoin::secp256k1::Message| match &input + .witness_script + { + None => Err(ark_core::Error::ad_hoc( + "Missing witness script for psbt::Input when signing unilateral exit transaction", + )), + Some(script) => { + let mut res = vec![]; + let pks = extract_checksig_pubkeys(script); + + for pk in pks { + if let Ok(keypair) = self.keypair_by_pk(&pk) { + let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &keypair); + let pk = keypair.x_only_public_key().0; + res.push((sig, pk)) + } + } + + Ok(res) + } + }; + + let tx = create_unilateral_exit_transaction( + to_address, + to_amount, + change_address, + &onchain_inputs, + &vtxo_inputs, + sign, + ) + .map_err(Error::from)?; + + let prevouts = onchain_inputs + .iter() + .map(unilateral_exit::OnChainInput::previous_output) + .chain( + vtxo_inputs + .iter() + .map(unilateral_exit::VtxoInput::previous_output), + ) + .collect(); + + Ok((tx, prevouts)) + } +} diff --git a/vendor/ark-client/src/utils.rs b/vendor/ark-client/src/utils.rs new file mode 100644 index 0000000..e87b761 --- /dev/null +++ b/vendor/ark-client/src/utils.rs @@ -0,0 +1,62 @@ +use crate::Error; +use std::time::Duration; + +/// Current time as Unix seconds. Uses `js_sys::Date` on wasm32, `std::time` elsewhere. +pub(crate) fn unix_now() -> Result { + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| Error::ad_hoc(format!("system clock before UNIX_EPOCH: {e}")))? + .as_secs(); + + i64::try_from(secs).map_err(|_| Error::ad_hoc("unix timestamp overflow")) + } + + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + Ok((js_sys::Date::now() / 1000.0) as i64) + } +} + +pub(crate) async fn sleep(duration: Duration) { + #[cfg(target_arch = "wasm32")] + { + gloo_timers::future::sleep(duration).await + } + #[cfg(not(target_arch = "wasm32"))] + { + tokio::time::sleep(duration).await; + } +} + +/// A utility function for running async operations with timeout and error handling +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +pub(crate) async fn timeout_op(timeout: Duration, operation: F) -> Result +where + F: futures_util::future::Future, +{ + use futures_util::future::select; + use futures_util::future::Either; + use gloo_timers::future::TimeoutFuture; + + let ms = timeout.as_millis().min(u128::from(u32::MAX)) as u32; + let timeout_future = TimeoutFuture::new(ms); + + match select(Box::pin(operation), timeout_future).await { + Either::Left((result, _)) => Ok(result), + Either::Right((_, _)) => Err(Error::ad_hoc(format!( + "operation timed out after {timeout:?}" + ))), + } +} + +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +pub(crate) async fn timeout_op(timeout: Duration, operation: F) -> Result +where + F: std::future::Future + Send, +{ + tokio::time::timeout(timeout, operation) + .await + .map_err(|_| Error::ad_hoc(format!("operation timed out after {timeout:?}"))) +} diff --git a/vendor/ark-client/src/vtxo_watcher.rs b/vendor/ark-client/src/vtxo_watcher.rs new file mode 100644 index 0000000..1390c76 --- /dev/null +++ b/vendor/ark-client/src/vtxo_watcher.rs @@ -0,0 +1,1213 @@ +//! Background VTXO watcher that auto-delegates and auto-renews VTXOs. +//! +//! Full behavior: +//! - On new VTXOs received: submit them to the delegator service for future renewal +//! - On new VTXOs received: self-renew VTXOs that are close to expiry (safety net) +//! - On stream error: reconnect with exponential backoff + +use crate::error::ErrorContext; +use crate::swap_storage::SwapStorage; +use crate::wallet::OnchainWallet; +use crate::AnnotatedVtxo; +use crate::Blockchain; +use crate::Client; +use crate::Error; +use crate::GetVtxosRequestFilter; +use ark_core::intent; +use ark_core::server::SubscriptionResponse; +use ark_core::server::VirtualTxOutPoint; +use ark_core::ArkAddress; +#[cfg(test)] +use ark_core::Vtxo; +use ark_delegator::DelegatorClient; +use bitcoin::secp256k1::PublicKey; +use bitcoin::Amount; +use bitcoin::OutPoint; +use bitcoin::ScriptBuf; +use bitcoin::TxOut; +use futures::StreamExt; +use rand::rngs::OsRng; +use std::collections::BTreeMap; +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::sync::watch; + +/// Handle to stop the background VTXO watcher. +/// +/// Dropping the handle will also stop the watcher. +pub struct VtxoWatcherHandle { + stop_tx: watch::Sender, +} + +impl VtxoWatcherHandle { + /// Stop the background watcher. + pub fn stop(self) { + let _ = self.stop_tx.send(true); + } +} + +impl Drop for VtxoWatcherHandle { + fn drop(&mut self) { + let _ = self.stop_tx.send(true); + } +} + +/// Backoff parameters for reconnection. +const INITIAL_BACKOFF: Duration = Duration::from_secs(1); +const MAX_BACKOFF: Duration = Duration::from_secs(30); + +/// Periodic key discovery settings for keeping script subscriptions fresh. +const KEY_DISCOVERY_INTERVAL: Duration = Duration::from_secs(10); + +/// How often the background migration arm fires when healthy. The frequent cadence is safe +/// because [`Client::migrate_deprecated_signer_vtxos`] short-circuits to a no-op +/// `NothingMigratable` report when the server advertises no deprecated signers or the wallet holds +/// no pre-cutoff deprecated-signer outputs. +const MIGRATION_INTERVAL: Duration = Duration::from_secs(60); + +/// Exponential-backoff bounds for the migration arm after a failing pass. The cooldown doubles per +/// consecutive failure, caps at five minutes, and resets to the base on a successful or no-op pass. +const MIGRATION_BASE_COOLDOWN: Duration = Duration::from_secs(30); +const MIGRATION_MAX_COOLDOWN: Duration = Duration::from_secs(300); + +/// Configuration for [`Client::start_vtxo_watcher`]. +#[derive(Debug, Clone, Copy)] +pub struct VtxoWatcherConfig { + /// When `true` (the default), the watcher runs a periodic + /// [`Client::migrate_deprecated_signer_vtxos`] pass that rotates funds off any deprecated + /// server signer the wallet still holds pre-cutoff outputs under. Errors are logged and + /// swallowed (never killing the loop), and a persistently failing pass backs off + /// exponentially. Set to `false` to disable the migration arm entirely; renewal and delegation + /// behavior are unaffected either way. + pub migrate_deprecated_signers: bool, +} + +impl Default for VtxoWatcherConfig { + fn default() -> Self { + Self { + migrate_deprecated_signers: true, + } + } +} + +enum WatcherWork { + NewVtxos { vtxos: Vec }, + RenewTick, +} + +impl Client +where + B: Blockchain + Send + Sync + 'static, + W: OnchainWallet + Send + Sync + 'static, + S: SwapStorage + 'static, +{ + /// Start a background task that watches for new VTXOs and: + /// + /// 1. **Delegates** them to the configured delegator service for future auto-renewal + /// 2. **Self-renews** VTXOs that are close to expiry (safety net) + /// 3. **Migrates** funds off deprecated server signers on a periodic, backed-off pass (unless + /// disabled via [`VtxoWatcherConfig::migrate_deprecated_signers`]) + /// + /// Reconnects automatically with exponential backoff (1s → 2s → … → 30s) on stream errors. + /// + /// Requires the client to be wrapped in an `Arc` for shared ownership with the background + /// task. + /// + /// Returns a [`VtxoWatcherHandle`] that stops the watcher when dropped. + pub fn start_vtxo_watcher( + self: &Arc, + delegator: Arc, + config: VtxoWatcherConfig, + ) -> VtxoWatcherHandle { + let (stop_tx, stop_rx) = watch::channel(false); + + let client = Arc::clone(self); + tokio::spawn(async move { + run_watcher_loop(client, delegator, config, stop_rx).await; + tracing::debug!("VTXO watcher stopped"); + }); + + VtxoWatcherHandle { stop_tx } + } +} + +/// Outer loop that reconnects on stream errors with exponential backoff. +async fn run_watcher_loop( + client: Arc>, + delegator: Arc, + config: VtxoWatcherConfig, + mut stop_rx: watch::Receiver, +) where + B: Blockchain + Send + Sync + 'static, + W: OnchainWallet + Send + Sync + 'static, + S: SwapStorage + 'static, +{ + let mut backoff = INITIAL_BACKOFF; + + loop { + if *stop_rx.borrow() { + return; + } + + let addresses = match client.active_offchain_contract_addresses() { + Ok(a) => a, + Err(e) => { + tracing::error!("Failed to get active offchain contracts: {e}"); + return; + } + }; + + let subscription_id = match client.subscribe_to_scripts(addresses.clone(), None).await { + Ok(id) => id, + Err(e) => { + tracing::warn!("Failed to subscribe: {e}, retrying in {backoff:?}"); + if wait_or_stop(&mut stop_rx, backoff).await { + return; + } + backoff = (backoff * 2).min(MAX_BACKOFF); + continue; + } + }; + + let mut stream = match client.get_subscription(subscription_id.clone(), None).await { + Ok(s) => s, + Err(e) => { + tracing::warn!("Failed to get subscription stream: {e}, retrying in {backoff:?}"); + if wait_or_stop(&mut stop_rx, backoff).await { + return; + } + backoff = (backoff * 2).min(MAX_BACKOFF); + continue; + } + }; + + tracing::info!("VTXO watcher connected"); + backoff = INITIAL_BACKOFF; + let mut subscribed_addrs: HashSet = addresses.into_iter().collect(); + let mut renew_interval = tokio::time::interval(Duration::from_secs(60)); + let mut discovery_interval = tokio::time::interval(KEY_DISCOVERY_INTERVAL); + let (work_tx, mut work_rx) = mpsc::channel::(128); + + let worker_handle = tokio::spawn({ + let client = client.clone(); + let delegator = delegator.clone(); + async move { + let mut seen_unspent_outpoints = HashSet::::new(); + + while let Some(first) = work_rx.recv().await { + // Handle one guaranteed message to start the batch. + let (mut pending_vtxos, mut should_renew, mut should_sync) = match first { + WatcherWork::NewVtxos { vtxos } => (vtxos, true, false), + WatcherWork::RenewTick => (Vec::new(), true, true), + }; + + // Drain whatever else is already queued without waiting. + while let Ok(work) = work_rx.try_recv() { + match work { + WatcherWork::NewVtxos { vtxos } => { + pending_vtxos.extend(vtxos); + should_renew = true; + } + WatcherWork::RenewTick => { + should_renew = true; + should_sync = true; + } + } + } + + if should_sync { + match collect_new_delegation_candidates( + &client, + &mut seen_unspent_outpoints, + ) + .await + { + Ok(new_candidates) => { + if !new_candidates.is_empty() { + tracing::debug!( + count = new_candidates.len(), + "Found new delegatable VTXOs from failsafe polling" + ); + pending_vtxos.extend(new_candidates); + } + } + Err(e) => { + tracing::warn!("Failsafe delegation poll failed: {e}"); + } + } + } + + if !pending_vtxos.is_empty() { + let mut deduped = Vec::new(); + let mut seen = HashSet::new(); + for vtxo in pending_vtxos { + if seen.insert(vtxo.outpoint) { + deduped.push(vtxo); + } + } + + tracing::debug!(count = deduped.len(), "Processing VTXOs for delegation"); + delegate_vtxos(&client, &delegator, &deduped).await; + } + + if should_renew { + renew_expiring_vtxos(&client).await; + } + } + } + }); + + // Independent migration arm: rotates funds off deprecated server signers on its own + // self-paced cooldown loop, separate from the renewal/delegation worker and the + // subscription stream (it polls wallet state, like renewal). Spawned per connection and + // aborted on reconnect/stop so passes never overlap. Disabled entirely when the config + // flag is off. + let migration_handle = config.migrate_deprecated_signers.then(|| { + let client = client.clone(); + let mut stop_rx = stop_rx.clone(); + tokio::spawn(async move { + run_migration_arm(&client, &mut stop_rx).await; + }) + }); + + loop { + tokio::select! { + _ = stop_rx.changed() => { + drop(work_tx); + let _ = worker_handle.await; + if let Some(handle) = migration_handle { + handle.abort(); + } + return; + } + _ = renew_interval.tick() => { + if work_tx.send(WatcherWork::RenewTick).await.is_err() { + tracing::warn!("VTXO worker channel closed, reconnecting in {backoff:?}"); + break; + } + } + _ = discovery_interval.tick() => { + match refresh_subscription_scripts( + client.as_ref(), + &subscription_id, + &mut subscribed_addrs, + ) + .await + { + Ok(()) => {} + Err(e) => { + tracing::warn!("Failed to refresh script subscription: {e}"); + } + } + } + event = stream.next() => { + match event { + Some(Ok(SubscriptionResponse::Heartbeat)) => {} + Some(Ok(SubscriptionResponse::SubscriptionStarted { subscription_id })) => { + tracing::debug!(subscription_id, "Subscription started"); + } + Some(Ok(SubscriptionResponse::Event(event))) => { + if !event.new_vtxos.is_empty() { + tracing::debug!( + txid = %event.txid, + new_vtxos = event.new_vtxos.len(), + "Received subscription event with new VTXOs" + ); + + if work_tx.send(WatcherWork::NewVtxos { + vtxos: event.new_vtxos, + }) + .await.is_err() + { + tracing::warn!("VTXO worker channel closed. Reconnecting in {backoff:?}"); + break; + } + } + } + Some(Err(e)) => { + tracing::warn!("VTXO subscription error: {e}, reconnecting in {backoff:?}"); + break; + } + None => { + tracing::debug!("VTXO subscription stream ended, reconnecting in {backoff:?}"); + break; + } + } + } + } + } + + drop(work_tx); + let _ = worker_handle.await; + // Abort the per-connection migration arm; the next iteration spawns a fresh one. This + // prevents two migration loops racing across a reconnect. + if let Some(handle) = migration_handle { + handle.abort(); + } + + if wait_or_stop(&mut stop_rx, backoff).await { + return; + } + backoff = (backoff * 2).min(MAX_BACKOFF); + } +} + +/// Background migration arm: periodically rotate funds off deprecated server signers. +/// +/// On each fire it runs one [`Client::migrate_deprecated_signer_vtxos`] pass and logs the outcome. +/// Errors are swallowed (never propagated — this must never kill the watcher). Cadence is +/// [`MIGRATION_INTERVAL`] while healthy; a failing pass backs off exponentially between +/// [`MIGRATION_BASE_COOLDOWN`] and [`MIGRATION_MAX_COOLDOWN`], resetting to the base interval on a +/// fully successful or no-op pass. The frequent base cadence is cheap because the migration call is +/// a no-op (`NothingMigratable`) whenever there is nothing to rotate. +/// +/// When [`Client::refresh_server_info`] updates the cached `deprecated_signers`, this arm picks up +/// the freshly advertised deprecated signers on its next pass and migrates. +async fn run_migration_arm(client: &Client, stop_rx: &mut watch::Receiver) +where + B: Blockchain + Send + Sync + 'static, + W: OnchainWallet + Send + Sync + 'static, + S: SwapStorage + 'static, +{ + // Consecutive-failure count drives the exponential cooldown; `0` means healthy (use the base + // interval). Reset to `0` on any fully successful or no-op pass. + let mut consecutive_failures: u32 = 0; + loop { + let delay = migration_delay(consecutive_failures); + if wait_or_stop(stop_rx, delay).await { + return; + } + + let mut rng = OsRng; + match client.migrate_deprecated_signer_vtxos(&mut rng).await { + Ok(report) => { + if report.failed() { + consecutive_failures = consecutive_failures.saturating_add(1); + let next = migration_delay(consecutive_failures); + tracing::warn!( + txids = ?report.settle_txids(), + vtxo_error = ?report.vtxo.error.as_deref(), + boarding_error = ?report.boarding.error.as_deref(), + "Background migration pass had leg failure; backing off {next:?}" + ); + } else { + if report.rotated() { + tracing::info!( + txids = ?report.settle_txids(), + "Background migration rotated funds off deprecated signer(s)" + ); + } else { + tracing::debug!("Background migration pass: nothing to migrate"); + } + // Success or no-op: back to the healthy cadence. + consecutive_failures = 0; + } + } + Err(e) => { + // Back off so a persistently failing migration does not retry every interval. + consecutive_failures = consecutive_failures.saturating_add(1); + let next = migration_delay(consecutive_failures); + tracing::warn!("Background migration pass failed: {e}; backing off {next:?}"); + } + } + } +} + +/// Cooldown before the next migration pass given the consecutive-failure count. +/// +/// `0` failures → the healthy [`MIGRATION_INTERVAL`]. Otherwise an exponential backoff of +/// `MIGRATION_BASE_COOLDOWN * 2^(failures - 1)`, saturating at [`MIGRATION_MAX_COOLDOWN`]. +fn migration_delay(consecutive_failures: u32) -> Duration { + if consecutive_failures == 0 { + return MIGRATION_INTERVAL; + } + let shift = consecutive_failures - 1; + let scaled = MIGRATION_BASE_COOLDOWN + .checked_mul(1u32.checked_shl(shift).unwrap_or(u32::MAX)) + .unwrap_or(MIGRATION_MAX_COOLDOWN); + scaled.min(MIGRATION_MAX_COOLDOWN) +} + +/// Wait for the given duration or until stop is signalled. Returns `true` if stopped. +async fn wait_or_stop(stop_rx: &mut watch::Receiver, duration: Duration) -> bool { + tokio::select! { + _ = stop_rx.changed() => true, + _ = tokio::time::sleep(duration) => false, + } +} + +/// Add newly persisted active contract scripts to an existing subscription. +async fn refresh_subscription_scripts( + client: &Client, + subscription_id: &str, + subscribed_addrs: &mut HashSet, +) -> Result<(), Error> +where + B: Blockchain + Send + Sync + 'static, + W: OnchainWallet + Send + Sync + 'static, + S: SwapStorage + 'static, +{ + let addrs = client.active_offchain_contract_addresses()?; + let new_addrs: Vec<_> = addrs + .into_iter() + .filter(|addr| !subscribed_addrs.contains(addr)) + .collect(); + + if new_addrs.is_empty() { + return Ok(()); + } + + client + .subscribe_to_scripts(new_addrs.clone(), Some(subscription_id.to_string())) + .await?; + + let added = new_addrs.len(); + subscribed_addrs.extend(new_addrs); + tracing::info!( + added, + "Updated watcher subscription with newly active contract addresses" + ); + + Ok(()) +} + +/// Enumerate newly seen unspent delegate-eligible VTXOs from wallet state. +/// +/// This is a failsafe path to catch outputs that may have been missed by subscription timing. +async fn collect_new_delegation_candidates( + client: &Client, + seen_unspent_outpoints: &mut HashSet, +) -> Result, Error> +where + B: Blockchain + Send + Sync + 'static, + W: OnchainWallet + Send + Sync + 'static, + S: SwapStorage + 'static, +{ + let vtxo_list = client + .list_vtxos_filtered(GetVtxosRequestFilter::Renewable) + .await?; + + let mut current_outpoints = HashSet::new(); + let mut newly_seen = Vec::new(); + + for entry in vtxo_list.all_unspent() { + if entry.contract().contract_type != ark_core::contract::ContractType::delegate_vtxo() { + continue; + } + + current_outpoints.insert(entry.vtxo().outpoint); + + if !seen_unspent_outpoints.contains(&entry.vtxo().outpoint) { + newly_seen.push(entry.vtxo().clone()); + } + } + + *seen_unspent_outpoints = current_outpoints; + + Ok(newly_seen) +} + +/// Delegator info cached per delegation batch. +struct DelegatorState { + cosigner_pk: PublicKey, + fee: Amount, + fee_address_script: ScriptBuf, +} + +/// Fetch and parse delegator info into a usable form. +async fn fetch_delegator_state(delegator: &DelegatorClient) -> Result { + let info = delegator + .info() + .await + .context(Error::ad_hoc("failed to get delegator info"))?; + + let cosigner_pk: PublicKey = info + .pubkey + .parse::() + .context("failed to parse delegator PK")?; + + let fee = info + .fee + .parse::() + .map(Amount::from_sat) + .context("failed to parse delegator fee")?; + + let fee_address_script = info + .delegator_address + .parse::() + .context("failed to parse delegator fee address")? + .to_p2tr_script_pubkey(); + + Ok(DelegatorState { + cosigner_pk, + fee, + fee_address_script, + }) +} + +/// Number of seconds in a UTC day. +const SECONDS_PER_DAY: i64 = 86_400; + +/// Normalize a unix timestamp (seconds) to UTC midnight of that day. +fn day_timestamp(ts: i64) -> i64 { + ts - ts.rem_euclid(SECONDS_PER_DAY) +} + +/// Group VTXOs by their expiry day (UTC midnight), returning groups sorted by expiry. +/// +/// Recoverable VTXOs (expired or sub-dust) are collected separately and merged into the earliest +/// non-recoverable group. +fn group_by_expiry_day(vtxos: &[AnnotatedVtxo], dust: Amount) -> Vec<(i64, Vec<&AnnotatedVtxo>)> { + let mut groups: BTreeMap> = BTreeMap::new(); + let mut recoverable: Vec<&AnnotatedVtxo> = Vec::new(); + + for entry in vtxos { + if entry.vtxo().is_spent { + continue; + } + + if entry.contract().contract_type != ark_core::contract::ContractType::delegate_vtxo() { + continue; + } + + if entry.vtxo().is_recoverable(dust) { + recoverable.push(entry); + } else if entry.vtxo().expires_at > 0 { + let day = day_timestamp(entry.vtxo().expires_at); + groups.entry(day).or_default().push(entry); + } + } + + if !recoverable.is_empty() { + if let Some((&earliest_day, _)) = groups.iter().next() { + groups.entry(earliest_day).or_default().extend(recoverable); + } else { + groups.insert(0, recoverable); + } + } + + groups.into_iter().collect() +} + +/// Calculate the `valid_at` timestamp for a delegation group. +/// +/// For each non-recoverable VTXO, compute activation at 90% of its full lifetime: +/// `created_at + (expires_at - created_at) * 0.9`. The earliest of those activations is used. +/// +/// If the group only contains recoverable/expired VTXOs (or activation is already in the past), +/// schedule soon (`now + 60s`). +fn calculate_valid_at(group_vtxos: &[&AnnotatedVtxo], dust: Amount) -> u64 { + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let earliest_activation = group_vtxos + .iter() + .filter(|entry| { + !entry.vtxo().is_recoverable(dust) + && entry.vtxo().created_at > 0 + && entry.vtxo().expires_at > 0 + && entry.vtxo().expires_at > entry.vtxo().created_at + }) + .map(|entry| { + let created_at = entry.vtxo().created_at as u64; + let lifetime = (entry.vtxo().expires_at - entry.vtxo().created_at) as u64; + created_at + (lifetime * 9 / 10) + }) + .min(); + + match earliest_activation { + Some(valid_at) if valid_at > now_secs => valid_at, + _ => now_secs + 60, + } +} + +/// Submit newly received VTXOs to the delegator service for future auto-renewal. +/// +/// Only the affected outpoints are delegated; spend metadata is resolved through contracts. +async fn delegate_vtxos( + client: &Arc>, + delegator: &DelegatorClient, + new_vtxos: &[VirtualTxOutPoint], +) where + B: Blockchain + Send + Sync + 'static, + W: OnchainWallet + Send + Sync + 'static, + S: SwapStorage + 'static, +{ + let vtxo_list = match client + .list_vtxos_filtered(GetVtxosRequestFilter::Renewable) + .await + { + Ok(v) => v, + Err(e) => { + tracing::error!("Failed to list VTXOs for delegation: {e}"); + return; + } + }; + + // The subscription event tells us which outpoints are new, but we need the full + // VirtualTxOutPoint (with expires_at, created_at) from the server for grouping. + let new_outpoints: HashSet<_> = new_vtxos.iter().map(|v| v.outpoint).collect(); + let enriched: Vec<_> = vtxo_list + .all_unspent() + .filter(|entry| new_outpoints.contains(&entry.vtxo().outpoint)) + .cloned() + .collect(); + + let server_info = match client.server_info().await { + Ok(server_info) => server_info, + Err(e) => { + tracing::error!("Failed to read server info for delegation: {e}"); + return; + } + }; + + let groups = group_by_expiry_day(&enriched, server_info.dust); + if groups.is_empty() { + tracing::debug!("No delegate-eligible VTXOs after enrichment/grouping; skipping"); + return; + } + + let delegator_state = match fetch_delegator_state(delegator).await { + Ok(s) => Arc::new(s), + Err(e) => { + tracing::error!("{e}"); + return; + } + }; + + let (to_address, _) = match client.get_offchain_address().await { + Ok(v) => v, + Err(e) => { + tracing::error!("Failed to get offchain address for delegation: {e}"); + return; + } + }; + let dest_script = to_address.to_p2tr_script_pubkey(); + + let mut handles = Vec::new(); + + for (_day, group_vtxos) in groups { + let valid_at = calculate_valid_at(&group_vtxos, server_info.dust); + + let mut vtxo_inputs = Vec::new(); + let mut total_amount = Amount::ZERO; + + for entry in &group_vtxos { + let spend_selection = match entry + .spend_selection(ark_core::contract::SpendPathKind::Delegate) + { + Ok(selection) => selection, + Err(e) => { + tracing::warn!(outpoint = %entry.vtxo().outpoint, "Cannot get delegate spend selection: {e}"); + continue; + } + }; + + let exit_delay = match entry.exit_delay() { + Ok(exit_delay) => exit_delay, + Err(e) => { + tracing::warn!(outpoint = %entry.vtxo().outpoint, "Cannot get delegate exit delay: {e}"); + continue; + } + }; + + vtxo_inputs.push(intent::Input::new_with_spend_selection( + entry.vtxo().outpoint, + exit_delay, + TxOut { + value: entry.vtxo().amount, + script_pubkey: entry.script_pubkey(), + }, + entry.tapscripts(), + spend_selection, + entry.vtxo().is_spent, + entry.vtxo().is_swept, + entry.vtxo().assets.clone(), + )); + + total_amount += entry.vtxo().amount; + } + + if vtxo_inputs.is_empty() { + continue; + } + + let fee = delegator_state.fee; + if fee >= total_amount { + tracing::warn!( + %total_amount, %fee, + "Delegator fee exceeds VTXO group value, skipping" + ); + continue; + } + let net_amount = total_amount - fee; + + if net_amount < server_info.dust { + tracing::warn!(%net_amount, "Net amount after fee is below dust, skipping"); + continue; + } + + let mut outputs = Vec::new(); + if fee > Amount::ZERO { + outputs.push(intent::Output::Offchain(TxOut { + value: fee, + script_pubkey: delegator_state.fee_address_script.clone(), + })); + } + outputs.push(intent::Output::Offchain(TxOut { + value: net_amount, + script_pubkey: dest_script.clone(), + })); + + let server_info_forfeit_addr = server_info.forfeit_address.clone(); + let dust = server_info.dust; + let ds = Arc::clone(&delegator_state); + + let delegator = delegator.clone(); + let client = Arc::clone(client); + handles.push(tokio::spawn(async move { + delegate_group( + &client, + &delegator, + vtxo_inputs, + outputs, + ds.cosigner_pk, + &server_info_forfeit_addr, + dust, + valid_at, + ) + .await; + })); + } + + for handle in handles { + let _ = handle.await; + } +} + +/// Prepare, sign, and submit a single delegation group. +async fn delegate_group( + client: &Client, + delegator: &DelegatorClient, + vtxo_inputs: Vec, + outputs: Vec, + cosigner_pk: PublicKey, + forfeit_address: &bitcoin::Address, + dust: Amount, + valid_at: u64, +) where + B: Blockchain + Send + Sync + 'static, + W: OnchainWallet + Send + Sync + 'static, + S: SwapStorage + 'static, +{ + let input_count = vtxo_inputs.len(); + + let mut delegate = match ark_core::batch::prepare_delegate_psbts_at( + vtxo_inputs, + outputs, + cosigner_pk, + forfeit_address, + dust, + Some(valid_at), + ) { + Ok(d) => d, + Err(e) => { + tracing::error!("Failed to prepare delegate PSBTs: {e}"); + return; + } + }; + + if let Err(e) = + client.sign_delegate_psbts(&mut delegate.intent.proof, &mut delegate.forfeit_psbts) + { + tracing::error!("Failed to sign delegate PSBTs: {e}"); + return; + } + + if let Err(e) = delegator + .delegate(&delegate.intent, &delegate.forfeit_psbts, None) + .await + { + tracing::error!("Failed to submit delegation: {e}"); + return; + } + + tracing::info!( + vtxo_count = input_count, + valid_at, + "Delegated VTXO group to delegator service" + ); +} + +/// Fraction of VTXO lifetime remaining at which we self-renew as a safety net. +const SELF_RENEW_REMAINING_FRACTION: f64 = 0.10; + +/// Select VTXOs that should be self-renewed. +/// +/// Includes all recoverable VTXOs (expired, swept, or sub-dust) plus VTXOs whose remaining lifetime +/// is less than [`SELF_RENEW_REMAINING_FRACTION`] of their total lifetime. +fn select_vtxos_for_self_renewal(vtxos: &[AnnotatedVtxo], dust: Amount, now: i64) -> Vec { + let selected: Vec<_> = vtxos + .iter() + .filter(|entry| { + if entry.vtxo().is_recoverable(dust) { + return true; + } + + if entry.vtxo().expires_at <= 0 || entry.vtxo().created_at <= 0 { + return false; + } + let total_lifetime = entry.vtxo().expires_at - entry.vtxo().created_at; + let remaining = entry.vtxo().expires_at - now; + remaining > 0 + && (remaining as f64) < (total_lifetime as f64 * SELF_RENEW_REMAINING_FRACTION) + }) + .collect(); + + let total_amount = selected + .iter() + .fold(Amount::ZERO, |total, entry| total + entry.vtxo().amount); + if total_amount < dust { + return Vec::new(); + } + + selected.iter().map(|entry| entry.vtxo().outpoint).collect() +} + +/// Self-renew VTXOs that are close to expiry or already recoverable. +async fn renew_expiring_vtxos(client: &Client) +where + B: Blockchain + Send + Sync + 'static, + W: OnchainWallet + Send + Sync + 'static, + S: SwapStorage + 'static, +{ + let vtxo_list = match client + .list_vtxos_filtered(GetVtxosRequestFilter::Renewable) + .await + { + Ok(v) => v, + Err(e) => { + tracing::warn!("Failed to list VTXOs for renewal check: {e}"); + return; + } + }; + + let server_info = match client.server_info().await { + Ok(server_info) => server_info, + Err(e) => { + tracing::warn!("Failed to read server info for renewal check: {e}"); + return; + } + }; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let unspent: Vec<_> = vtxo_list.all_unspent().cloned().collect(); + let expiring_outpoints = select_vtxos_for_self_renewal(&unspent, server_info.dust, now); + + if expiring_outpoints.is_empty() { + return; + } + + tracing::info!( + count = expiring_outpoints.len(), + "Self-renewing expiring/recoverable VTXOs" + ); + + let mut rng = OsRng; + match client + .settle_vtxos(&mut rng, &expiring_outpoints, &[]) + .await + { + Ok(Some(txid)) => { + tracing::info!(%txid, "Self-renewed expiring VTXOs"); + } + Ok(None) => {} + Err(e) => { + tracing::warn!("Failed to self-renew VTXOs: {e}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::hashes::Hash; + use bitcoin::key::Secp256k1; + use bitcoin::Network; + use bitcoin::Sequence; + use bitcoin::Txid; + use bitcoin::XOnlyPublicKey; + use std::str::FromStr; + + fn test_keys() -> (XOnlyPublicKey, XOnlyPublicKey, XOnlyPublicKey) { + let server = XOnlyPublicKey::from_str( + "18845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", + ) + .unwrap(); + let owner = XOnlyPublicKey::from_str( + "28845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", + ) + .unwrap(); + let delegator = XOnlyPublicKey::from_str( + "38845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", + ) + .unwrap(); + (server, owner, delegator) + } + + fn delegated_vtxo() -> (ArkAddress, Vtxo) { + let secp = Secp256k1::new(); + let (server, owner, delegator) = test_keys(); + let vtxo = Vtxo::new_with_delegator( + &secp, + server, + owner, + delegator, + Sequence::from_seconds_ceil(86400).unwrap(), + Network::Regtest, + ) + .unwrap(); + (vtxo.to_ark_address(), vtxo) + } + + fn mk_contract_vtxo( + script: ScriptBuf, + amount_sat: u64, + expires_at: i64, + vout: u32, + ) -> AnnotatedVtxo { + use ark_core::contract::ContractState; + use ark_core::contract::ContractType; + use ark_core::contract::DelegateVtxoContract; + use ark_core::contract::SpendPath; + use ark_core::contract::SpendPathKind; + use ark_core::contract::StoredContract; + + let (server, owner, delegator) = test_keys(); + let contract = DelegateVtxoContract { + server, + owner, + delegator, + exit_delay: Sequence::from_seconds_ceil(86400).unwrap(), + }; + let vtxo = VirtualTxOutPoint { + outpoint: OutPoint::new(Txid::all_zeros(), vout), + created_at: expires_at - 1000, + expires_at, + amount: Amount::from_sat(amount_sat), + script: script.clone(), + is_preconfirmed: false, + is_swept: false, + is_unrolled: false, + is_spent: false, + spent_by: None, + commitment_txids: vec![], + settled_by: None, + ark_txid: None, + assets: vec![], + depth: 0, + }; + AnnotatedVtxo::new( + StoredContract { + contract_type: ContractType::delegate_vtxo(), + contract_version: 1, + script_pubkey: script, + state: ContractState::Active, + created_at: 0, + key_index: None, + data: serde_json::to_value(contract).unwrap(), + }, + vtxo, + vec![SpendPath::new( + SpendPathKind::Delegate, + ScriptBuf::new(), + dummy_control_block(), + ) + .select()], + ) + } + + fn dummy_control_block() -> bitcoin::taproot::ControlBlock { + let secp = Secp256k1::new(); + let internal_key = test_keys().0; + let spend_info = bitcoin::taproot::TaprootBuilder::new() + .add_leaf(0, ScriptBuf::new()) + .unwrap() + .finalize(&secp, internal_key) + .unwrap(); + spend_info + .control_block(&(ScriptBuf::new(), bitcoin::taproot::LeafVersion::TapScript)) + .unwrap() + } + + #[test] + fn migration_delay_uses_base_interval_when_healthy() { + assert_eq!(migration_delay(0), MIGRATION_INTERVAL); + } + + #[test] + fn migration_delay_backs_off_exponentially_and_caps() { + // 30s * 2^(failures-1): 30s, 60s, 120s, 240s, then saturates at the 5min cap. + assert_eq!(migration_delay(1), MIGRATION_BASE_COOLDOWN); + assert_eq!(migration_delay(2), MIGRATION_BASE_COOLDOWN * 2); + assert_eq!(migration_delay(3), MIGRATION_BASE_COOLDOWN * 4); + assert_eq!(migration_delay(4), MIGRATION_BASE_COOLDOWN * 8); + assert_eq!(migration_delay(5), MIGRATION_MAX_COOLDOWN); + // Large failure counts must not panic (no shift overflow) and stay at the cap. + assert_eq!(migration_delay(100), MIGRATION_MAX_COOLDOWN); + assert_eq!(migration_delay(u32::MAX), MIGRATION_MAX_COOLDOWN); + } + + #[test] + fn day_timestamp_normalizes_to_midnight() { + let ts = 1705322700; // 2024-01-15 13:45:00 UTC + let day = day_timestamp(ts); + assert_eq!(day % SECONDS_PER_DAY, 0); + assert!(day <= ts); + assert!(ts - day < SECONDS_PER_DAY); + } + + #[test] + fn day_timestamp_already_midnight() { + let ts = SECONDS_PER_DAY * 19738; + assert_eq!(day_timestamp(ts), ts); + } + + #[test] + fn group_by_expiry_day_merges_recoverable_into_earliest_group() { + let (addr, _) = delegated_vtxo(); + let script = addr.to_p2tr_script_pubkey(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let day1_midnight = day_timestamp(now) + SECONDS_PER_DAY; + let day2_midnight = day1_midnight + SECONDS_PER_DAY; + + let recoverable = mk_contract_vtxo(script.clone(), 100, day1_midnight + 500, 0); // sub-dust + let non_recoverable_day1 = mk_contract_vtxo(script.clone(), 10_000, day1_midnight + 800, 1); + let non_recoverable_day2 = mk_contract_vtxo(script, 10_000, day2_midnight + 800, 2); + + let vtxos = [non_recoverable_day2, recoverable, non_recoverable_day1]; + let groups = group_by_expiry_day(&vtxos, Amount::from_sat(500)); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].0, day_timestamp(day1_midnight + 800)); + assert_eq!(groups[1].0, day_timestamp(day2_midnight + 800)); + assert_eq!(groups[0].1.len(), 2); + assert_eq!(groups[1].1.len(), 1); + } + + #[test] + fn calculate_valid_at_for_non_recoverable_group_is_before_expiry() { + let script = ScriptBuf::new(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let later = mk_contract_vtxo(script, 10_000, now + 10_000, 1); + let group = vec![&later]; + + let valid_at = calculate_valid_at(&group, Amount::from_sat(500)); + + assert!(valid_at > now as u64); + assert!(valid_at < later.vtxo().expires_at as u64); + } + + #[test] + fn select_vtxos_for_self_renewal_includes_expired_and_subdust() { + let script = ScriptBuf::new(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let expired = mk_contract_vtxo(script.clone(), 10_000, now - 1, 0); + let subdust = mk_contract_vtxo(script.clone(), 100, now + 10_000, 1); + let fresh = mk_contract_vtxo(script, 10_000, now + 10_000, 2); + + let selected = select_vtxos_for_self_renewal( + &[expired.clone(), subdust.clone(), fresh], + Amount::from_sat(500), + now, + ); + + assert_eq!( + selected, + vec![expired.vtxo().outpoint, subdust.vtxo().outpoint] + ); + } + + #[test] + fn select_vtxos_for_self_renewal_includes_near_expiry() { + let script = ScriptBuf::new(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let near_expiry = mk_contract_vtxo(script, 10_000, now + 50, 0); + + let selected = select_vtxos_for_self_renewal( + std::slice::from_ref(&near_expiry), + Amount::from_sat(500), + now, + ); + + assert_eq!(selected, vec![near_expiry.vtxo().outpoint]); + } + + #[test] + fn select_vtxos_for_self_renewal_skips_when_total_is_below_dust() { + let script = ScriptBuf::new(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let subdust1 = mk_contract_vtxo(script.clone(), 100, now + 10_000, 0); + let subdust2 = mk_contract_vtxo(script, 200, now + 10_000, 1); + + let selected = + select_vtxos_for_self_renewal(&[subdust1, subdust2], Amount::from_sat(500), now); + + assert!(selected.is_empty()); + } + + #[test] + fn calculate_valid_at_for_recoverable_only_group_is_soon() { + let script = ScriptBuf::new(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let recoverable = mk_contract_vtxo(script, 100, now + 5_000, 0); // sub-dust at dust=500 + let group = vec![&recoverable]; + + let start = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let valid_at = calculate_valid_at(&group, Amount::from_sat(500)); + let end = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + assert!(valid_at >= start + 60); + assert!(valid_at <= end + 61); + } +} diff --git a/vendor/ark-client/src/wallet.rs b/vendor/ark-client/src/wallet.rs new file mode 100644 index 0000000..80b1386 --- /dev/null +++ b/vendor/ark-client/src/wallet.rs @@ -0,0 +1,41 @@ +use crate::error::Error; +use ark_core::UtxoCoinSelection; +use bitcoin::Address; +use bitcoin::Amount; +use bitcoin::FeeRate; +use bitcoin::Psbt; +use std::future::Future; + +// TODO: I think we should get rid of `OnchainWallet` and just use `KeyProvider` plus explicit +// on-chain coin selection/signing dependencies. + +pub trait OnchainWallet { + fn get_onchain_address(&self) -> Result; + + fn sync(&self) -> impl Future>; + + fn balance(&self) -> Result; + + fn prepare_send_to_address( + &self, + address: Address, + amount: Amount, + fee_rate: FeeRate, + ) -> Result; + + fn sign(&self, psbt: &mut Psbt) -> Result; + + fn select_coins(&self, target_amount: Amount) -> Result; +} + +#[derive(Debug, Clone, Copy)] +pub struct Balance { + /// All coinbase outputs not yet matured + pub immature: Amount, + /// Unconfirmed UTXOs generated by a wallet tx + pub trusted_pending: Amount, + /// Unconfirmed UTXOs received from an external wallet + pub untrusted_pending: Amount, + /// Confirmed and immediately spendable balance + pub confirmed: Amount, +} diff --git a/vendor/ark-core/Cargo.lock b/vendor/ark-core/Cargo.lock new file mode 100644 index 0000000..1c68558 --- /dev/null +++ b/vendor/ark-core/Cargo.lock @@ -0,0 +1,655 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ark-core" +version = "0.12.0" +dependencies = [ + "bech32", + "bitcoin", + "bs58", + "getrandom 0.2.17", + "getrandom 0.3.4", + "hex", + "js-sys", + "rand 0.8.8", + "secp256k1 0.33.1", + "serde", + "serde_json", + "thiserror", + "tracing", + "web-sys", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "base58ck" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "365c0acd5b2e8dd0111a46c4faea83fb3cfb6e39a49a7c73a06e090db7b2eff0" +dependencies = [ + "bitcoin_hashes", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bitcoin" +version = "0.32.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0ce8bd5baaa0d303a19915a6d93afed161f528654e42da2a7a97d05c59499a" +dependencies = [ + "base58ck", + "base64", + "bech32", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative 0.2.3", + "hex_lit", + "secp256k1 0.29.1", + "serde", +] + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9daa31138eb443d5751b207f3f64154e2bb09cd59960562ccc7a7112be38147f" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.3.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8bea3a9f0cfece4564e37cb49a38cc245ca5184719e50d7d0dda3268722c4e2" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin-units" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb95693f371d089a4b5b6fc41c6f3ea6e01ee8c15388335dfac8ea685173b51" +dependencies = [ + "bitcoin-consensus-encoding", + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.3", + "serde", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.8", + "secp256k1-sys 0.10.1", + "serde", +] + +[[package]] +name = "secp256k1" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7f404a8dab7a7a5a631e741d8699aa9c8e1d689fc26ccf897c7187565490b69" +dependencies = [ + "rand 0.9.5", + "secp256k1-sys 0.14.1", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "secp256k1-sys" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b2992d4a3cd244539a7d5d0966aadbe5ca7fb868a5d7e38c29499b9e709bbd" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/vendor/ark-core/Cargo.toml b/vendor/ark-core/Cargo.toml new file mode 100644 index 0000000..97ad337 --- /dev/null +++ b/vendor/ark-core/Cargo.toml @@ -0,0 +1,123 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.86" +name = "ark-core" +version = "0.12.0" +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Core Arkade types and transaction utilities" +readme = "README.md" +keywords = [ + "ark", + "arkade", + "bitcoin", + "wallet", +] +categories = ["cryptography::cryptocurrencies"] +license = "MIT" +repository = "https://github.com/arkade-os/rust-sdk" + +[lib] +name = "ark_core" +path = "src/lib.rs" + +[dependencies.bech32] +version = "0.11" + +[dependencies.bitcoin] +version = "0.32.7" +features = [ + "base64", + "rand", + "serde", +] + +[dependencies.bs58] +version = "0.5" + +[dependencies.hex] +version = "0.4" + +[dependencies.musig] +version = "0.33" +features = ["serde"] +package = "secp256k1" + +[dependencies.rand] +version = "0.8" + +[dependencies.serde] +version = "1.0" +features = ["derive"] + +[dependencies.serde_json] +version = "1.0" + +[dependencies.thiserror] +version = "1.0" + +[dependencies.tracing] +version = "0.1.37" + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies.getrandom] +version = "0.2" +features = [ + "wasm-bindgen", + "js", +] + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies.getrandom2] +version = "0.3.1" +features = ["wasm_js"] +package = "getrandom" + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies.js-sys] +version = "0.3" + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies.web-sys] +version = "0.3.77" +features = [ + "Window", + "Performance", +] + +[lints.clippy] +dbg_macro = "warn" +large_enum_variant = "allow" +print_stderr = "warn" +print_stdout = "warn" +redundant_clone = "warn" +redundant_else = "warn" +too_many_arguments = "allow" +unnecessary_wraps = "warn" +unused_async = "warn" +unwrap_in_result = "warn" +unwrap_used = "warn" +wildcard_enum_match_arm = "warn" + +[lints.rust] +unused-import-braces = "warn" +unused-qualifications = "warn" + +[lints.rust.unexpected_cfgs] +level = "warn" +priority = 0 +check-cfg = [ + "cfg(genproto)", + "cfg(docsrs)", +] diff --git a/vendor/ark-core/Cargo.toml.orig b/vendor/ark-core/Cargo.toml.orig new file mode 100644 index 0000000..72892ed --- /dev/null +++ b/vendor/ark-core/Cargo.toml.orig @@ -0,0 +1,31 @@ +[package] +name = "ark-core" +version = "0.12.0" +categories = { workspace = true } +edition = { workspace = true } +keywords = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +rust-version = "1.86" +description = "Core Arkade types and transaction utilities" + +[lints] +workspace = true + +[dependencies] +bech32 = "0.11" +bitcoin = { version = "0.32.7", features = ["base64", "rand", "serde"] } +bs58 = "0.5" +hex = "0.4" +musig = { package = "secp256k1", version = "0.33", features = ["serde"] } +rand = "0.8" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "1.0" +tracing = "0.1.37" + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +getrandom = { version = "0.2", features = ["wasm-bindgen", "js"] } +getrandom2 = { package = "getrandom", version = "0.3.1", features = ["wasm_js"] } +js-sys = "0.3" +web-sys = { version = "0.3.77", features = ["Window", "Performance"] } diff --git a/vendor/ark-core/README.md b/vendor/ark-core/README.md new file mode 100644 index 0000000..867a087 --- /dev/null +++ b/vendor/ark-core/README.md @@ -0,0 +1,16 @@ +# ark-core + +Core types and utilities for the Arkade protocol. + +This crate contains the shared data structures and protocol primitives used by the Arkade Rust SDK, including Arkade addresses, VTXOs, batches, intents, boarding outputs, transaction graph helpers, and related serialization/conversion utilities. + +## Install + +```toml +[dependencies] +ark-core = "0.12.0" +``` + +## Documentation + +API documentation is available on [docs.rs/ark-core](https://docs.rs/ark-core). diff --git a/vendor/ark-core/src/ark_address.rs b/vendor/ark-core/src/ark_address.rs new file mode 100644 index 0000000..45cc670 --- /dev/null +++ b/vendor/ark-core/src/ark_address.rs @@ -0,0 +1,167 @@ +use crate::Error; +use bech32::Bech32m; +use bech32::Hrp; +use bitcoin::key::TweakedPublicKey; +use bitcoin::Network; +use bitcoin::ScriptBuf; +use bitcoin::XOnlyPublicKey; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ArkAddress { + version: u8, + hrp: Hrp, + server: XOnlyPublicKey, + vtxo_tap_key: TweakedPublicKey, +} + +impl ArkAddress { + pub fn to_p2tr_script_pubkey(&self) -> ScriptBuf { + ScriptBuf::new_p2tr_tweaked(self.vtxo_tap_key) + } + + pub fn to_sub_dust_script_pubkey(&self) -> ScriptBuf { + ScriptBuf::new_op_return(self.vtxo_tap_key.serialize()) + } + + /// Return the Arkade server signer key encoded in this address. + pub fn server(&self) -> XOnlyPublicKey { + self.server + } +} + +impl ArkAddress { + pub fn new(network: Network, server: XOnlyPublicKey, vtxo_tap_key: TweakedPublicKey) -> Self { + let hrp = match network { + Network::Bitcoin => "ark", + Network::Testnet | Network::Testnet4 | Network::Signet | Network::Regtest => "tark", + }; + + let hrp = Hrp::parse_unchecked(hrp); + + Self { + version: 0, + hrp, + server, + vtxo_tap_key, + } + } + + pub fn encode(&self) -> String { + let mut bytes = [0u8; 65]; + + bytes[0] = self.version; + + bytes[1..33].copy_from_slice(&self.server.serialize()); + bytes[33..].copy_from_slice(&self.vtxo_tap_key.serialize()); + + bech32::encode::(self.hrp, bytes.as_slice()).expect("data can be encoded") + } + + pub fn decode(value: &str) -> Result { + let (hrp, bytes) = bech32::decode(value).map_err(Error::address_format)?; + + // ArkAddress requires exactly 65 bytes: 1 version + 32 server pubkey + 32 vtxo pubkey + if bytes.len() != 65 { + return Err(Error::address_format(format!( + "invalid ark address length: expected 65 bytes, got {}", + bytes.len() + ))); + } + + let version = bytes[0]; + + let server = XOnlyPublicKey::from_slice(&bytes[1..33]).map_err(Error::address_format)?; + let vtxo_tap_key = + XOnlyPublicKey::from_slice(&bytes[33..]).map_err(Error::address_format)?; + + // It is safe to call `dangerous_assume_tweaked` because we are treating the VTXO tap key as + // finished product i.e. we are only going to use it as an address to send coins to. + let vtxo_tap_key = TweakedPublicKey::dangerous_assume_tweaked(vtxo_tap_key); + + Ok(Self { + version, + hrp, + server, + vtxo_tap_key, + }) + } +} + +impl std::fmt::Display for ArkAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.encode()) + } +} + +impl FromStr for ArkAddress { + type Err = Error; + + fn from_str(s: &str) -> Result { + Self::decode(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::hex::DisplayHex; + + // Taken from https://github.com/arkade-os/arkd/blob/b2c4a6ea6ab1a5a4078c578bcfca650ed19dc4ec/common/fixtures/encoding.json. + #[test] + fn roundtrip() { + let address = "tark1qqellv77udfmr20tun8dvju5vgudpf9vxe8jwhthrkn26fz96pawqfdy8nk05rsmrf8h94j26905e7n6sng8y059z8ykn2j5xcuw4xt846qj6x"; + + let decoded = ArkAddress::decode(address).unwrap(); + + let hrp = decoded.hrp.to_string(); + assert_eq!(hrp, "tark"); + + let version = decoded.version; + assert_eq!(version, 0); + + let server = decoded.server.serialize().as_hex().to_string(); + assert_eq!( + server, + "33ffb3dee353b1a9ebe4ced64b946238d0a4ac364f275d771da6ad2445d07ae0" + ); + + let vtxo_tap_key = decoded.vtxo_tap_key.serialize().as_hex().to_string(); + assert_eq!( + vtxo_tap_key, + "25a43cecfa0e1b1a4f72d64ad15f4cfa7a84d0723e8511c969aa543638ea9967" + ); + + let encoded = decoded.encode(); + + assert_eq!(encoded, address); + } + + #[test] + fn hash_and_eq() { + use std::collections::HashSet; + + let addr_str = "tark1qqellv77udfmr20tun8dvju5vgudpf9vxe8jwhthrkn26fz96pawqfdy8nk05rsmrf8h94j26905e7n6sng8y059z8ykn2j5xcuw4xt846qj6x"; + let a = ArkAddress::decode(addr_str).unwrap(); + let b = ArkAddress::decode(addr_str).unwrap(); + + // Eq: identical addresses are equal + assert_eq!(a, b); + + // Hash: identical addresses hash to the same bucket + let mut set = HashSet::new(); + set.insert(a); + assert!(!set.insert(b), "duplicate address should not be inserted"); + assert_eq!(set.len(), 1); + + // Different address is distinct + let other = ArkAddress::new( + Network::Regtest, + a.server, + TweakedPublicKey::dangerous_assume_tweaked(a.server), + ); + assert_ne!(a, other); + assert!(set.insert(other)); + assert_eq!(set.len(), 2); + } +} diff --git a/vendor/ark-core/src/arknote.rs b/vendor/ark-core/src/arknote.rs new file mode 100644 index 0000000..18adaee --- /dev/null +++ b/vendor/ark-core/src/arknote.rs @@ -0,0 +1,327 @@ +//! ArkNote: a transferable off-chain value token. +//! +//! An ArkNote encodes a preimage and a value. Anyone who knows the preimage can +//! spend the note by revealing it. This enables simple bearer-token-style transfers. + +use crate::intent; +use crate::script::arknote_script; +use crate::script::tr_script_pubkey; +use crate::Error; +use crate::UNSPENDABLE_KEY; +use bitcoin::hashes::sha256; +use bitcoin::hashes::Hash; +use bitcoin::key::Secp256k1; +use bitcoin::taproot::LeafVersion; +use bitcoin::taproot::TaprootBuilder; +use bitcoin::Amount; +use bitcoin::OutPoint; +use bitcoin::PublicKey; +use bitcoin::ScriptBuf; +use bitcoin::Sequence; +use bitcoin::TxOut; +use bitcoin::Txid; +use std::fmt; + +/// Default human-readable prefix for ArkNote string encoding. +pub const DEFAULT_HRP: &str = "arknote"; + +/// Length of the preimage in bytes. +pub const PREIMAGE_LENGTH: usize = 32; + +/// Length of the value field in bytes (u32 big-endian). +const VALUE_LENGTH: usize = 4; + +/// Total length of an encoded ArkNote payload. +const ARKNOTE_LENGTH: usize = PREIMAGE_LENGTH + VALUE_LENGTH; + +/// Fake outpoint vout used for ArkNotes (they don't correspond to real UTXOs). +pub const FAKE_VOUT: u32 = 0; + +/// ArkNote is a bearer token that can be redeemed by revealing its preimage. +/// +/// The note encodes: +/// - A 32-byte preimage (the secret) +/// - A value in satoshis (up to u32::MAX) +/// +/// The on-chain representation is a hash-locked taproot script that checks +/// `SHA256(witness) == hash(preimage)`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArkNote { + preimage: [u8; PREIMAGE_LENGTH], + value: Amount, + hrp: String, +} + +impl ArkNote { + /// Create a new ArkNote with the default HRP. + pub fn new(preimage: [u8; PREIMAGE_LENGTH], value: Amount) -> Result { + Self::new_with_hrp(preimage, value, DEFAULT_HRP.to_string()) + } + + /// Create a new ArkNote with a custom HRP. + pub fn new_with_hrp( + preimage: [u8; PREIMAGE_LENGTH], + value: Amount, + hrp: String, + ) -> Result { + // Validate that value fits in u32 + if value.to_sat() > u32::MAX as u64 { + return Err(Error::ad_hoc(format!( + "value {} exceeds maximum of {} sats", + value.to_sat(), + u32::MAX + ))); + } + + Ok(Self { + preimage, + value, + hrp, + }) + } + + /// Parse an ArkNote from its string representation. + pub fn from_string(s: &str) -> Result { + Self::from_string_with_hrp(s, DEFAULT_HRP) + } + + /// Parse an ArkNote from its string representation with a custom HRP. + pub fn from_string_with_hrp(s: &str, hrp: &str) -> Result { + let s = s.trim(); + + if !s.starts_with(hrp) { + return Err(Error::ad_hoc(format!( + "invalid prefix: expected '{}', got '{}'", + hrp, + &s[..hrp.len().min(s.len())] + ))); + } + + let encoded = &s[hrp.len()..]; + let decoded = bs58::decode(encoded) + .into_vec() + .map_err(|e| Error::ad_hoc(format!("invalid base58: {e}")))?; + + if decoded.len() != ARKNOTE_LENGTH { + return Err(Error::ad_hoc(format!( + "invalid payload length: expected {}, got {}", + ARKNOTE_LENGTH, + decoded.len() + ))); + } + + let mut preimage = [0u8; PREIMAGE_LENGTH]; + preimage.copy_from_slice(&decoded[..PREIMAGE_LENGTH]); + + let value_bytes: [u8; 4] = decoded[PREIMAGE_LENGTH..] + .try_into() + .map_err(|_| Error::ad_hoc("invalid value bytes"))?; + let value = Amount::from_sat(u32::from_be_bytes(value_bytes) as u64); + + Self::new_with_hrp(preimage, value, hrp.to_string()) + } + + /// Encode the ArkNote to its string representation. + pub fn to_encoded_string(&self) -> String { + self.to_string() + } + + /// Get the preimage. + pub fn preimage(&self) -> &[u8; PREIMAGE_LENGTH] { + &self.preimage + } + + /// Get the preimage hash. + pub fn preimage_hash(&self) -> sha256::Hash { + sha256::Hash::hash(&self.preimage) + } + + /// Get the value in satoshis. + pub fn value(&self) -> Amount { + self.value + } + + /// Get the HRP. + pub fn hrp(&self) -> &str { + &self.hrp + } + + /// Get the script that locks this note (spendable by revealing the preimage). + pub fn script(&self) -> ScriptBuf { + arknote_script(&self.preimage_hash()) + } + + /// Get a synthetic txid derived from the preimage hash. + /// + /// This is used to create a unique identifier for the note in the VTXO system. + pub fn txid(&self) -> Txid { + Txid::from_byte_array(*self.preimage_hash().as_byte_array()) + } + + /// Get a synthetic outpoint for this note. + pub fn outpoint(&self) -> OutPoint { + OutPoint::new(self.txid(), FAKE_VOUT) + } + + /// Convert this ArkNote to an intent input for settlement. + /// + /// The note creates a fake VTXO with a hash-lock script. When settling, + /// the preimage is revealed as the witness instead of a signature. + pub fn to_intent_input(&self) -> Result { + let secp = Secp256k1::new(); + + let unspendable_key: PublicKey = UNSPENDABLE_KEY + .parse() + .map_err(|e| Error::ad_hoc(format!("invalid unspendable key: {e}")))?; + let (unspendable_xonly, _) = unspendable_key.inner.x_only_public_key(); + + let note_script = self.script(); + + // Build taproot tree with single leaf (the note script) + let spend_info = TaprootBuilder::new() + .add_leaf(0, note_script.clone()) + .map_err(|e| Error::ad_hoc(format!("failed to add leaf: {e:?}")))? + .finalize(&secp, unspendable_xonly) + .map_err(|e| Error::ad_hoc(format!("failed to finalize taproot: {e:?}")))?; + + let control_block = spend_info + .control_block(&(note_script.clone(), LeafVersion::TapScript)) + .ok_or_else(|| Error::ad_hoc("failed to get control block for note script"))?; + + let script_pubkey = tr_script_pubkey(&spend_info); + + Ok(intent::Input::new_with_extra_witness( + self.outpoint(), + Sequence::MAX, + None, + TxOut { + value: self.value, + script_pubkey, + }, + vec![note_script.clone()], + (note_script, control_block), + false, // not onchain + false, // not swept + Vec::new(), + vec![self.preimage.to_vec()], + )) + } +} + +impl fmt::Display for ArkNote { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut payload = Vec::with_capacity(ARKNOTE_LENGTH); + payload.extend_from_slice(&self.preimage); + payload.extend_from_slice(&(self.value.to_sat() as u32).to_be_bytes()); + + write!(f, "{}{}", self.hrp, bs58::encode(payload).into_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex_to_array32(hex: &str) -> [u8; 32] { + let bytes = hex::decode(hex).expect("valid hex"); + bytes.try_into().expect("32 bytes") + } + + #[test] + fn roundtrip_encoding() { + let preimage = + hex_to_array32("11d2a03264d0efd311d2a03264d0efd311d2a03264d0efd311d2a03264d0efd3"); + let value = Amount::from_sat(900_000); + + let note = ArkNote::new(preimage, value).unwrap(); + let encoded = note.to_string(); + let decoded = ArkNote::from_string(&encoded).unwrap(); + + assert_eq!(decoded.preimage(), &preimage); + assert_eq!(decoded.value(), value); + } + + #[test] + fn test_vectors() { + // Test vectors matching TypeScript SDK + let cases = [ + ( + "arknote", + "arknote8rFzGqZsG9RCLripA6ez8d2hQEzFKsqCeiSnXhQj56Ysw7ZQT", + "11d2a03264d0efd311d2a03264d0efd311d2a03264d0efd311d2a03264d0efd3", + 900_000u64, + ), + ( + "arknote", + "arknoteSkB92YpWm4Q2ijQHH34cqbKkCZWszsiQgHVjtNeFF2Cwp59D", + "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + 1_828_932u64, + ), + ( + "noteark", + "noteark8rFzGqZsG9RCLripA6ez8d2hQEzFKsqCeiSnXhQj56Ysw7ZQT", + "11d2a03264d0efd311d2a03264d0efd311d2a03264d0efd311d2a03264d0efd3", + 900_000u64, + ), + ]; + + for (hrp, note_str, preimage_hex, expected_sats) in cases { + let note = ArkNote::from_string_with_hrp(note_str, hrp).unwrap(); + + assert_eq!(note.preimage(), &hex_to_array32(preimage_hex)); + assert_eq!(note.value(), Amount::from_sat(expected_sats)); + assert_eq!(note.hrp(), hrp); + + // Roundtrip + let reconstructed = ArkNote::new_with_hrp( + hex_to_array32(preimage_hex), + Amount::from_sat(expected_sats), + hrp.to_string(), + ) + .unwrap(); + assert_eq!(reconstructed.to_string(), note_str); + } + } + + #[test] + fn invalid_prefix() { + let result = ArkNote::from_string("wrongprefix123456789"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("invalid prefix")); + } + + #[test] + fn invalid_base58() { + let result = ArkNote::from_string("arknote!!!invalid!!!"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("base58")); + } + + #[test] + fn value_overflow() { + let preimage = [0u8; 32]; + let result = ArkNote::new(preimage, Amount::from_sat(u64::MAX)); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("exceeds maximum")); + } + + #[test] + fn script_is_hash_lock() { + let preimage = [0x42u8; 32]; + let note = ArkNote::new(preimage, Amount::from_sat(1000)).unwrap(); + let script = note.script(); + + // Should be: OP_SHA256 <32-byte hash> OP_EQUAL + let bytes = script.as_bytes(); + assert_eq!(bytes[0], bitcoin::opcodes::all::OP_SHA256.to_u8()); + assert_eq!(bytes[1], 0x20); // push 32 bytes + assert_eq!(bytes[34], bitcoin::opcodes::all::OP_EQUAL.to_u8()); + } + + #[test] + fn whitespace_handling() { + let note_str = " arknote8rFzGqZsG9RCLripA6ez8d2hQEzFKsqCeiSnXhQj56Ysw7ZQT "; + let note = ArkNote::from_string(note_str).unwrap(); + assert_eq!(note.value(), Amount::from_sat(900_000)); + } +} diff --git a/vendor/ark-core/src/asset.rs b/vendor/ark-core/src/asset.rs new file mode 100644 index 0000000..ce92200 --- /dev/null +++ b/vendor/ark-core/src/asset.rs @@ -0,0 +1,108 @@ +use crate::Error; +use bitcoin::hashes::Hash; +use bitcoin::hex::DisplayHex; +use bitcoin::hex::FromHex; +use bitcoin::Txid; +use serde::Serialize; +use serde::Serializer; +use std::num::NonZeroU64; + +pub mod packet; + +/// An asset identifier: (genesis_txid, group_index). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct AssetId { + pub txid: Txid, + pub group_index: u16, +} + +impl AssetId { + fn encode(&self, buf: &mut Vec) { + // txid in its canonical display byte order, followed by group_index as LE bytes. + let mut txid_bytes = self.txid.to_byte_array(); + txid_bytes.reverse(); + buf.extend_from_slice(&txid_bytes); + buf.extend_from_slice(&self.group_index.to_le_bytes()); + } +} + +impl std::fmt::Display for AssetId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}{}", + self.txid, + self.group_index.to_le_bytes().to_lower_hex_string() + ) + } +} + +impl Serialize for AssetId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl std::str::FromStr for AssetId { + type Err = Error; + + fn from_str(s: &str) -> Result { + if s.len() != 68 { + return Err(Error::ad_hoc(format!( + "invalid asset ID format '{}', expected 68 hex chars (txid + 2-byte LE group index)", + s + ))); + } + + let txid: Txid = s[..64] + .parse() + .map_err(|e| Error::ad_hoc(format!("invalid txid in asset ID: {}", e)))?; + + let group_index_bytes = <[u8; 2]>::from_hex(&s[64..]) + .map_err(|e| Error::ad_hoc(format!("invalid group index in asset ID: {}", e)))?; + let group_index = u16::from_le_bytes(group_index_bytes); + + Ok(Self { txid, group_index }) + } +} + +/// Control asset configuration to issue new assets. +#[derive(Clone, Debug)] +pub enum ControlAssetConfig { + /// Issue an asset with a new control asset. + New { + /// Number of control asset units to create. + amount: NonZeroU64, + }, + /// Issue an asset with an existing control asset. + Existing { + /// Control asset ID. + id: AssetId, + }, +} + +impl ControlAssetConfig { + /// Instantiate control asset config to issue assets with a _new_ control asset. + /// + /// # Arguments + /// + /// * `amount` - The number of _control_ asset units to issue. + pub fn new(amount: u64) -> Result { + let amount = + NonZeroU64::new(amount).ok_or(Error::ad_hoc("control asset amount cannot be zero"))?; + + Ok(Self::New { amount }) + } + + /// Instantiate control asset config to issue assets with an _existing_ control asset. + /// + /// # Arguments + /// + /// * `id` - The existing control asset ID. + pub fn existing(id: AssetId) -> Self { + Self::Existing { id } + } +} diff --git a/vendor/ark-core/src/asset/packet.rs b/vendor/ark-core/src/asset/packet.rs new file mode 100644 index 0000000..bf15db6 --- /dev/null +++ b/vendor/ark-core/src/asset/packet.rs @@ -0,0 +1,373 @@ +//! Arkade Asset V1 packet encoding. +//! +//! Implements the binary encoding format as specified in the Arkade Asset V1 specification. +//! The packet is embedded in a Bitcoin transaction via an OP_RETURN output. + +use crate::asset::AssetId; +use crate::Error; +use bitcoin::TxOut; + +/// TLV type byte for the asset packet. +const ASSET_PACKET_TYPE: u8 = 0x00; + +/// Presence byte bits for Group optional fields. +const PRESENCE_ASSET_ID: u8 = 0x01; +const PRESENCE_CONTROL_ASSET: u8 = 0x02; +const PRESENCE_METADATA: u8 = 0x04; + +/// A complete asset packet containing one or more asset groups. +/// +/// This is a transaction output with an `OP_RETURN` script. +#[derive(Clone, Debug)] +pub struct Packet { + pub groups: Vec, +} + +impl Packet { + /// Encode this packet into its binary representation. + pub fn encode(&self) -> Vec { + let mut buf = Vec::new(); + encode_uvarint(&mut buf, self.groups.len() as u64); + for group in &self.groups { + group.encode(&mut buf); + } + buf + } + + /// Wrap this packet into an OP_RETURN TxOut with the ARK magic bytes and TLV envelope. + pub fn to_txout(&self) -> TxOut { + crate::extension::packet_txout(ASSET_PACKET_TYPE, &self.encode()) + } +} + +/// Reference to a control asset. +#[derive(Clone, Debug)] +pub enum AssetRef { + /// Reference an existing asset by its full ID. + ById(AssetId), + /// Reference a group in the same transaction by index. + ByGroup(u16), +} + +impl AssetRef { + fn encode(&self, buf: &mut Vec) { + match self { + AssetRef::ById(asset_id) => { + buf.push(0x01); // BY_ID + asset_id.encode(buf); + } + AssetRef::ByGroup(gidx) => { + buf.push(0x02); // BY_GROUP + buf.extend_from_slice(&gidx.to_le_bytes()); + } + } + } +} + +/// A single asset group within a packet. +#[derive(Clone, Debug)] +pub struct AssetGroup { + /// If `None`, this is a fresh asset issuance. The asset ID will be derived from + /// `(this_txid, group_index)`. + pub asset_id: Option, + /// Control asset reference. Only valid for issuances (when `asset_id` is `None`). + pub control_asset: Option, + /// Metadata key-value pairs attached to the asset group. + pub metadata: Option, + /// Asset inputs consumed by this group. + pub inputs: Vec, + /// Asset outputs produced by this group. + pub outputs: Vec, +} + +impl AssetGroup { + fn encode(&self, buf: &mut Vec) { + // Compute presence byte + let mut presence: u8 = 0; + if self.asset_id.is_some() { + presence |= PRESENCE_ASSET_ID; + } + if self.control_asset.is_some() { + presence |= PRESENCE_CONTROL_ASSET; + } + if self.metadata.is_some() { + presence |= PRESENCE_METADATA; + } + buf.push(presence); + + // Encode optional fields in order + if let Some(asset_id) = &self.asset_id { + asset_id.encode(buf); + } + if let Some(control_asset) = &self.control_asset { + control_asset.encode(buf); + } + if let Some(metadata) = &self.metadata { + encode_metadata(buf, metadata); + } + + // Encode inputs + encode_uvarint(buf, self.inputs.len() as u64); + for input in &self.inputs { + input.encode(buf); + } + + // Encode outputs + encode_uvarint(buf, self.outputs.len() as u64); + for output in &self.outputs { + output.encode(buf); + } + } +} + +/// A local asset input referencing a transaction input by index. +#[derive(Clone, Debug)] +pub struct AssetInput { + /// Index into the transaction's inputs. + pub input_index: u16, + /// Amount of asset from this input. + pub amount: u64, +} + +impl AssetInput { + fn encode(&self, buf: &mut Vec) { + buf.push(0x01); // LOCAL + buf.extend_from_slice(&self.input_index.to_le_bytes()); + encode_uvarint(buf, self.amount); + } +} + +/// A local asset output referencing a transaction output by index. +#[derive(Clone, Debug)] +pub struct AssetOutput { + /// Index into the transaction's outputs. + pub output_index: u16, + /// Amount of asset to this output. + pub amount: u64, +} + +impl AssetOutput { + fn encode(&self, buf: &mut Vec) { + buf.push(0x01); // LOCAL + buf.extend_from_slice(&self.output_index.to_le_bytes()); + encode_uvarint(buf, self.amount); + } +} + +/// Key-value metadata map. +pub type Metadata = Vec<(String, String)>; + +/// Encode a metadata map: count, then for each entry: key_len + key + value_len + value. +fn encode_metadata(buf: &mut Vec, metadata: &[(String, String)]) { + encode_uvarint(buf, metadata.len() as u64); + for (key, value) in metadata { + encode_uvarint(buf, key.len() as u64); + buf.extend_from_slice(key.as_bytes()); + encode_uvarint(buf, value.len() as u64); + buf.extend_from_slice(value.as_bytes()); + } +} + +/// Helper to add an asset packet as an OP_RETURN output to an existing PSBT. +/// +/// The P2A (anchor) output must remain the last output. This function inserts +/// the asset packet output before it. +pub fn add_asset_packet_to_psbt(psbt: &mut bitcoin::Psbt, packet: &Packet) -> Result<(), Error> { + if packet.groups.is_empty() { + return Err(Error::ad_hoc( + "asset packet must contain at least one group", + )); + } + + crate::extension::add_packet_to_psbt(psbt, ASSET_PACKET_TYPE, &packet.encode()) + .map_err(Error::ad_hoc)?; + + Ok(()) +} + +/// Encode a uvarint (LEB128 unsigned variable-length integer). +/// +/// This matches Go's `binary.PutUvarint` / protobuf unsigned varint encoding. +fn encode_uvarint(buf: &mut Vec, mut value: u64) { + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + buf.push(byte); + if value == 0 { + break; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::hex::DisplayHex; + + #[test] + fn test_encode_uvarint() { + let mut buf = Vec::new(); + encode_uvarint(&mut buf, 0); + assert_eq!(buf, vec![0x00]); + + buf.clear(); + encode_uvarint(&mut buf, 127); + assert_eq!(buf, vec![0x7f]); + + buf.clear(); + encode_uvarint(&mut buf, 128); + assert_eq!(buf, vec![0x80, 0x01]); + + buf.clear(); + encode_uvarint(&mut buf, 300); + assert_eq!(buf, vec![0xac, 0x02]); + } + + #[test] + fn test_fresh_issuance_no_control() { + // Fresh asset, no control, 1000 units to output 0 + let packet = Packet { + groups: vec![AssetGroup { + asset_id: None, + control_asset: None, + metadata: None, + inputs: vec![], + outputs: vec![AssetOutput { + output_index: 0, + amount: 1000, + }], + }], + }; + + let encoded = packet.encode(); + // Should start with group count = 1 + assert_eq!(encoded[0], 0x01); + // Presence byte = 0 (no asset_id, no control_asset, no metadata) + assert_eq!(encoded[1], 0x00); + // Input count = 0 + assert_eq!(encoded[2], 0x00); + // Output count = 1 + assert_eq!(encoded[3], 0x01); + } + + #[test] + fn test_fresh_issuance_with_control_by_group() { + // Control asset group + issued asset group referencing it + let packet = Packet { + groups: vec![ + // Group 0: control asset (fresh, no control ref) + AssetGroup { + asset_id: None, + control_asset: None, + metadata: None, + inputs: vec![], + outputs: vec![AssetOutput { + output_index: 0, + amount: 1, + }], + }, + // Group 1: issued asset referencing group 0 as control + AssetGroup { + asset_id: None, + control_asset: Some(AssetRef::ByGroup(0)), + metadata: None, + inputs: vec![], + outputs: vec![AssetOutput { + output_index: 0, + amount: 1000, + }], + }, + ], + }; + + let encoded = packet.encode(); + // Group count = 2 + assert_eq!(encoded[0], 0x02); + } + + #[test] + fn test_to_txout() { + let packet = Packet { + groups: vec![AssetGroup { + asset_id: None, + control_asset: None, + metadata: None, + inputs: vec![], + outputs: vec![AssetOutput { + output_index: 0, + amount: 100, + }], + }], + }; + + let txout = packet.to_txout(); + assert_eq!(txout.value, bitcoin::Amount::ZERO); + + // Script should start with OP_RETURN (0x6a) + let script_bytes = txout.script_pubkey.as_bytes(); + assert_eq!(script_bytes[0], 0x6a); + + // After push byte, should have ARK magic + // push_len byte, then 0x41 0x52 0x4b + let data_start = 2; // 0x6a + push_len + assert_eq!( + &script_bytes[data_start..data_start + 3], + &crate::extension::MAGIC_BYTES + ); + } + + #[test] + fn test_asset_id_display_matches_from_str_format() { + let asset_id = AssetId { + txid: "58534acb681218c0fda8f6b6ae3b4cb5d8897e7c5fcba5792621c368b3db479c" + .parse() + .unwrap(), + group_index: 0, + }; + + let encoded = asset_id.to_string(); + assert_eq!( + encoded, + "58534acb681218c0fda8f6b6ae3b4cb5d8897e7c5fcba5792621c368b3db479c0000" + ); + assert_eq!(encoded.parse::().unwrap(), asset_id); + } + + #[test] + fn test_asset_id_display_matches_from_str_format_for_non_zero_group() { + let asset_id = AssetId { + txid: "58534acb681218c0fda8f6b6ae3b4cb5d8897e7c5fcba5792621c368b3db479c" + .parse() + .unwrap(), + group_index: 1, + }; + + let encoded = asset_id.to_string(); + assert_eq!( + encoded, + "58534acb681218c0fda8f6b6ae3b4cb5d8897e7c5fcba5792621c368b3db479c0100" + ); + assert_eq!(encoded.parse::().unwrap(), asset_id); + } + + #[test] + fn test_asset_id_binary_encoding_uses_txid_display_byte_order() { + let asset_id = AssetId { + txid: "58534acb681218c0fda8f6b6ae3b4cb5d8897e7c5fcba5792621c368b3db479c" + .parse() + .unwrap(), + group_index: 1, + }; + + let mut buf = Vec::new(); + asset_id.encode(&mut buf); + + assert_eq!( + buf.to_lower_hex_string(), + "58534acb681218c0fda8f6b6ae3b4cb5d8897e7c5fcba5792621c368b3db479c0100" + ); + } +} diff --git a/vendor/ark-core/src/batch.rs b/vendor/ark-core/src/batch.rs new file mode 100644 index 0000000..65a90c0 --- /dev/null +++ b/vendor/ark-core/src/batch.rs @@ -0,0 +1,1237 @@ +use crate::anchor_output; +use crate::asset::packet; +use crate::contract::SpendSelection; +use crate::conversions::from_musig_xonly; +use crate::conversions::to_musig_pk; +use crate::intent; +use crate::intent::Intent; +use crate::server::NoncePks; +use crate::server::PartialSigTree; +use crate::server::TreeTxNoncePks; +use crate::tree_tx_output_script::TreeTxOutputScript; +use crate::Error; +use crate::ErrorContext; +use crate::TxGraph; +use crate::VTXO_COSIGNER_PSBT_KEY; +use crate::VTXO_INPUT_INDEX; +use bitcoin::absolute::LockTime; +use bitcoin::hashes::Hash; +use bitcoin::key::Keypair; +use bitcoin::key::Secp256k1; +use bitcoin::psbt; +use bitcoin::secp256k1; +use bitcoin::secp256k1::schnorr; +use bitcoin::secp256k1::PublicKey; +use bitcoin::sighash::Prevouts; +use bitcoin::sighash::SighashCache; +use bitcoin::taproot; +use bitcoin::transaction; +use bitcoin::Address; +use bitcoin::Amount; +use bitcoin::OutPoint; +use bitcoin::Psbt; +use bitcoin::TapLeafHash; +use bitcoin::TapSighashType; +use bitcoin::Transaction; +use bitcoin::TxIn; +use bitcoin::TxOut; +use bitcoin::Txid; +use bitcoin::XOnlyPublicKey; +use musig::musig; +use rand::CryptoRng; +use rand::Rng; +use std::collections::BTreeMap; +use std::collections::HashMap; + +/// A UTXO that is primed to become a VTXO. Alternatively, the owner of this UTXO may decide to +/// spend it into a vanilla UTXO. +/// +/// Only UTXOs with a particular script (involving an Ark server) can become VTXOs. +#[derive(Debug, Clone)] +pub struct OnChainInput { + /// The sequence needed by the selected spend path. + sequence: bitcoin::Sequence, + /// The script pubkey of the UTXO. + script_pubkey: bitcoin::ScriptBuf, + /// The full tapscript set for the contract. + tapscripts: Vec, + /// The selected spend path. + spend_info: (bitcoin::ScriptBuf, taproot::ControlBlock), + /// The owner key that signs the selected spend path. + owner_pk: XOnlyPublicKey, + /// The amount of coins locked in the UTXO. + amount: Amount, + /// The location of this UTXO in the blockchain. + outpoint: OutPoint, +} + +impl OnChainInput { + pub fn new( + sequence: bitcoin::Sequence, + script_pubkey: bitcoin::ScriptBuf, + tapscripts: Vec, + spend_info: (bitcoin::ScriptBuf, taproot::ControlBlock), + owner_pk: XOnlyPublicKey, + amount: Amount, + outpoint: OutPoint, + ) -> Self { + Self { + sequence, + script_pubkey, + tapscripts, + spend_info, + owner_pk, + amount, + outpoint, + } + } + + pub fn new_with_spend_selection( + default_sequence: bitcoin::Sequence, + script_pubkey: bitcoin::ScriptBuf, + tapscripts: Vec, + spend_selection: SpendSelection, + owner_pk: XOnlyPublicKey, + amount: Amount, + outpoint: OutPoint, + ) -> Self { + let (sequence, spend_info) = spend_selection.resolved_spend_info(default_sequence); + Self::new( + sequence, + script_pubkey, + tapscripts, + spend_info, + owner_pk, + amount, + outpoint, + ) + } + + pub fn sequence(&self) -> bitcoin::Sequence { + self.sequence + } + + pub fn script_pubkey(&self) -> &bitcoin::ScriptBuf { + &self.script_pubkey + } + + pub fn tapscripts(&self) -> &[bitcoin::ScriptBuf] { + &self.tapscripts + } + + pub fn spend_info(&self) -> &(bitcoin::ScriptBuf, taproot::ControlBlock) { + &self.spend_info + } + + pub fn owner_pk(&self) -> XOnlyPublicKey { + self.owner_pk + } + + pub fn amount(&self) -> Amount { + self.amount + } + + pub fn outpoint(&self) -> OutPoint { + self.outpoint + } +} + +/// A nonce key pair per batch-tree transaction output that we are a part of in the batch. +/// +/// The [`musig::SecretNonce`] element of the tuple is an [`Option`] because it cannot be cloned or +/// copied. When we are ready to sign a batch-tree transaction, we call the method `take_sk` to move +/// out of the [`Option`]. +#[allow(clippy::type_complexity)] +pub struct NonceKps(HashMap, musig::PublicNonce)>); + +impl NonceKps { + /// Take ownership of the [`musig::SecretNonce`] for the transaction identified by `txid`. + /// + /// The caller must take ownership because the [`musig::SecretNonce`] ensures that it can only + /// be used once, to avoid nonce reuse. + pub fn take_sk(&mut self, txid: &Txid) -> Option { + self.0.get_mut(txid).and_then(|(sec, _)| sec.take()) + } + + /// Convert into [`NoncePks`]. + pub fn to_nonce_pks(&self) -> NoncePks { + let nonce_pks = self + .0 + .iter() + .map(|(txid, (_, pub_nonce))| (*txid, *pub_nonce)) + .collect::>(); + + NoncePks::new(nonce_pks) + } +} + +/// Generate a nonce key pair for each batch-tree transaction output that we are a part of in the +/// batch. +pub fn generate_nonce_tree( + rng: &mut R, + batch_tree_tx_graph: &TxGraph, + own_cosigner_pk: PublicKey, + commitment_tx: &Psbt, +) -> Result +where + R: Rng + CryptoRng, +{ + let batch_tree_tx_map = batch_tree_tx_graph.as_map(); + + let nonce_tree = batch_tree_tx_map + .iter() + .map(|(txid, tx)| { + let cosigner_pks = extract_cosigner_pks_from_vtxo_psbt(tx)?; + + if !cosigner_pks.contains(&own_cosigner_pk) { + return Err(Error::crypto(format!( + "cosigner PKs does not contain {own_cosigner_pk} for tree TX {txid}" + ))); + } + + // We derive nonces from uniform randomness rather than from the counter-based + // `nonce_gen`, because we have no non-repeating counter that survives a restart, and a + // repeated MuSig2 nonce leaks the secret key. + let session_id = musig::SessionSecretRand::assume_uniformly_random(rng.r#gen()); + let extra_rand = rng.r#gen(); + + let msg = tree_tx_sighash(tx, &batch_tree_tx_map, commitment_tx)?; + + let key_agg_cache = { + let cosigner_pks = cosigner_pks + .iter() + .map(|pk| to_musig_pk(*pk)) + .collect::>(); + musig::KeyAggCache::new(&cosigner_pks.iter().collect::>()) + }; + + let (nonce, pub_nonce) = key_agg_cache.nonce_gen_with_uniform_randomness( + session_id, + to_musig_pk(own_cosigner_pk), + &msg, + extra_rand, + ); + + Ok((*txid, (Some(nonce), pub_nonce))) + }) + .collect::, _>>()?; + + Ok(NonceKps(nonce_tree)) +} + +fn tree_tx_sighash( + // The tree PSBT to be signed. + psbt: &Psbt, + // The entire tree TX set for this batch, to look for the previous output. + tx_map: &HashMap, + // The commitment transaction, in case it contains the previous output. + commitment_tx: &Psbt, +) -> Result<[u8; 32], Error> { + let tx = &psbt.unsigned_tx; + + // We expect a single input to a VTXO. + let previous_output = tx.input[VTXO_INPUT_INDEX].previous_output; + + let parent_tx = tx_map + .get(&previous_output.txid) + .or_else(|| { + (previous_output.txid == commitment_tx.unsigned_tx.compute_txid()) + .then_some(&commitment_tx) + }) + .ok_or_else(|| { + Error::crypto(format!( + "parent transaction {} not found for tree TX {}", + previous_output.txid, + tx.compute_txid() + )) + })?; + let previous_output = parent_tx + .unsigned_tx + .output + .get(previous_output.vout as usize) + .ok_or_else(|| { + Error::crypto(format!( + "previous output {} not found for tree TX {}", + previous_output, + tx.compute_txid() + )) + })?; + + let prevouts = [previous_output]; + let prevouts = Prevouts::All(&prevouts); + + // Here we are generating a key spend sighash, because batch-tree outputs are signed by parties + // with VTXOs in this new batch. We use a musig key spend to efficiently coordinate with all the + // parties. + let tap_sighash = SighashCache::new(tx) + .taproot_key_spend_signature_hash(VTXO_INPUT_INDEX, &prevouts, TapSighashType::Default) + .map_err(Error::crypto)?; + + Ok(tap_sighash.to_raw_hash().to_byte_array()) +} + +/// Compute the aggregated nonce public key for a transaction in the batch-tree. +/// +/// The [`TreeTxNoncePks`] holds the public nonces of all the cosigners of this batch-tree +/// transaction. +pub fn aggregate_nonces(tree_tx_nonce_pks: TreeTxNoncePks) -> musig::AggregatedNonce { + let pks = tree_tx_nonce_pks.to_pks(); + let ref_pks = pks.iter().collect::>(); + musig::AggregatedNonce::new(&ref_pks) +} + +/// Use `own_cosigner_kp` to sign each batch-tree transaction output that we are a part of, using +/// `our_nonce_kps` to provide our share of each aggregate nonce. +pub fn sign_batch_tree_tx( + tree_txid: Txid, + vtxo_tree_expiry: bitcoin::Sequence, + server_pk: XOnlyPublicKey, + own_cosigner_kp: &Keypair, + agg_nonce_pk: musig::AggregatedNonce, + batch_tree_tx_graph: &TxGraph, + commitment_psbt: &Psbt, + // This holds all the nonce KPs we generated earlier. We need to mutate it to be able to _move_ + // the secret nonce out of it before signing. + our_nonce_kps: &mut NonceKps, +) -> Result { + let own_cosigner_pk = own_cosigner_kp.public_key(); + + let internal_node_script = TreeTxOutputScript::new(vtxo_tree_expiry, server_pk); + + let secp = Secp256k1::new(); + + let own_cosigner_kp = ::musig::Keypair::from_secret_bytes(own_cosigner_kp.secret_bytes()) + .map_err(|e| Error::ad_hoc(format!("invalid keypair: {e}")))?; + + let batch_tree_tx_map = batch_tree_tx_graph.as_map(); + + let psbt = batch_tree_tx_map + .get(&tree_txid) + .ok_or_else(|| Error::ad_hoc(format!("TXID {tree_txid} not found in batch-tree map")))?; + + let mut cosigner_pks = extract_cosigner_pks_from_vtxo_psbt(psbt)?; + cosigner_pks.sort_by_key(|k| k.serialize()); + + if !cosigner_pks.contains(&own_cosigner_pk) { + return Err(Error::ad_hoc( + "own cosigner PK not found among batch-tree transaction cosigner PKs", + )); + } + + tracing::debug!(%tree_txid, "Generating partial signature"); + + let mut key_agg_cache = { + let cosigner_pks = cosigner_pks + .iter() + .map(|pk| to_musig_pk(*pk)) + .collect::>(); + musig::KeyAggCache::new(&cosigner_pks.iter().collect::>()) + }; + + let sweep_tap_tree = + internal_node_script.sweep_spend_leaf(&secp, from_musig_xonly(key_agg_cache.agg_pk())); + + let tweak = ::musig::Scalar::from( + ::musig::SecretKey::from_secret_bytes(*sweep_tap_tree.tap_tweak().as_byte_array()) + .map_err(|e| Error::ad_hoc(format!("invalid tweak: {e}")))?, + ); + + key_agg_cache + .pubkey_xonly_tweak_add(&tweak) + .map_err(Error::crypto)?; + + let msg = tree_tx_sighash(psbt, &batch_tree_tx_map, commitment_psbt)?; + + let nonce_sk = our_nonce_kps + .take_sk(&tree_txid) + .ok_or_else(|| Error::crypto(format!("missing nonce for tree TX {tree_txid}")))?; + + let sig = musig::Session::new(&key_agg_cache, agg_nonce_pk, &msg).partial_sign( + nonce_sk, + &own_cosigner_kp, + &key_agg_cache, + ); + + let partial_sig_tree = HashMap::from_iter([(tree_txid, sig)]); + + Ok(PartialSigTree(partial_sig_tree)) +} + +/// Build and sign a forfeit transaction per [`VtxoInput`] to be used in an upcoming commitment +/// transaction. +pub fn create_and_sign_forfeit_txs( + mut sign_fn: S, + vtxo_inputs: &[intent::Input], + connectors_leaves: &[&Psbt], + server_forfeit_address: &Address, + // As defined by the server. + dust: Amount, +) -> Result, Error> +where + S: FnMut( + &mut psbt::Input, + secp256k1::Message, + ) -> Result, Error>, +{ + const FORFEIT_TX_CONNECTOR_INDEX: usize = 0; + const FORFEIT_TX_VTXO_INDEX: usize = 1; + + let secp = Secp256k1::new(); + + let connector_amount = dust; + + let connector_index = derive_vtxo_connector_map(vtxo_inputs, connectors_leaves, dust)?; + + let mut signed_forfeit_psbts = Vec::new(); + for vtxo_input in vtxo_inputs.iter() { + if vtxo_input.amount() < dust || vtxo_input.is_swept() { + // Sub-dust VTXOs don't need to be forfeited. + continue; + } + + let outpoint = vtxo_input.outpoint(); + + let connector_outpoint = connector_index.get(&outpoint).ok_or_else(|| { + Error::ad_hoc(format!( + "connector outpoint missing for virtual TX outpoint {outpoint}" + )) + })?; + + let connector_psbt = connectors_leaves + .iter() + .find(|l| l.unsigned_tx.compute_txid() == connector_outpoint.txid) + .ok_or_else(|| { + Error::ad_hoc(format!( + "connector PSBT missing for virtual TX outpoint {outpoint}" + )) + })?; + + let connector_output = connector_psbt + .unsigned_tx + .output + .get(connector_outpoint.vout as usize) + .ok_or_else(|| { + Error::ad_hoc(format!( + "connector output missing for virtual TX outpoint {outpoint}" + )) + })?; + + let forfeit_output = TxOut { + value: vtxo_input.amount() + connector_amount, + script_pubkey: server_forfeit_address.script_pubkey(), + }; + + let mut forfeit_psbt = Psbt::from_unsigned_tx(Transaction { + version: transaction::Version::non_standard(3), + lock_time: LockTime::ZERO, + input: vec![ + TxIn { + previous_output: *connector_outpoint, + ..Default::default() + }, + TxIn { + previous_output: outpoint, + ..Default::default() + }, + ], + output: vec![forfeit_output.clone(), anchor_output()], + }) + .map_err(Error::transaction)?; + + forfeit_psbt.inputs[FORFEIT_TX_CONNECTOR_INDEX].witness_utxo = + Some(connector_output.clone()); + + forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX].witness_utxo = Some(TxOut { + value: vtxo_input.amount(), + script_pubkey: vtxo_input.script_pubkey().clone(), + }); + + forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX].sighash_type = + Some(TapSighashType::Default.into()); + + let (forfeit_script, forfeit_control_block) = vtxo_input.spend_info(); + + let leaf_version = forfeit_control_block.leaf_version; + forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX] + .tap_scripts + .insert( + forfeit_control_block.clone(), + (forfeit_script.clone(), leaf_version), + ); + forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX].witness_script = Some(forfeit_script.clone()); + + let prevouts = forfeit_psbt + .inputs + .iter() + .filter_map(|i| i.witness_utxo.clone()) + .collect::>(); + let prevouts = Prevouts::All(&prevouts); + + let leaf_hash = TapLeafHash::from_script(forfeit_script, leaf_version); + + let tap_sighash = SighashCache::new(&forfeit_psbt.unsigned_tx) + .taproot_script_spend_signature_hash( + FORFEIT_TX_VTXO_INDEX, + &prevouts, + leaf_hash, + TapSighashType::Default, + ) + .map_err(Error::crypto)?; + + let msg = secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array()); + + let sigs = sign_fn(&mut forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX], msg)?; + + for (sig, pk) in sigs { + secp.verify_schnorr(&sig, &msg, &pk) + .map_err(Error::crypto) + .context("failed to verify own forfeit signature")?; + + let sig = taproot::Signature { + signature: sig, + sighash_type: TapSighashType::Default, + }; + + forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX] + .tap_script_sigs + .insert((pk, leaf_hash), sig); + } + + signed_forfeit_psbts.push(forfeit_psbt.clone()); + } + + Ok(signed_forfeit_psbts) +} + +/// Sign every input of the `commitment_psbt` which is in the provided `onchain_inputs` list. +pub fn sign_commitment_psbt( + sign_for_pk_fn: F, + commitment_psbt: &mut Psbt, + onchain_inputs: &[OnChainInput], +) -> Result<(), Error> +where + F: Fn(&XOnlyPublicKey, &secp256k1::Message) -> Result, +{ + let secp = Secp256k1::new(); + + let prevouts = commitment_psbt + .inputs + .iter() + .filter_map(|i| i.witness_utxo.clone()) + .collect::>(); + + // Sign commitment transaction inputs that belong to us. For every output we are settling, we + // look through the commitment transaction inputs to find a matching input. + for onchain_input in onchain_inputs.iter() { + let (forfeit_script, forfeit_control_block) = onchain_input.spend_info.clone(); + + for (i, input) in commitment_psbt.inputs.iter_mut().enumerate() { + let previous_outpoint = commitment_psbt.unsigned_tx.input[i].previous_output; + + if previous_outpoint == onchain_input.outpoint { + // In the case of a boarding output, we are actually using a + // script spend path. + + let leaf_version = forfeit_control_block.leaf_version; + input.tap_scripts = BTreeMap::from_iter([( + forfeit_control_block.clone(), + (forfeit_script.clone(), leaf_version), + )]); + + let prevouts = Prevouts::All(&prevouts); + + let leaf_hash = TapLeafHash::from_script(&forfeit_script, leaf_version); + + let tap_sighash = SighashCache::new(&commitment_psbt.unsigned_tx) + .taproot_script_spend_signature_hash( + i, + &prevouts, + leaf_hash, + TapSighashType::Default, + ) + .map_err(Error::crypto)?; + + let msg = + secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array()); + let pk = onchain_input.owner_pk; + + let sig = sign_for_pk_fn(&pk, &msg)?; + + secp.verify_schnorr(&sig, &msg, &pk) + .map_err(Error::crypto) + .context("failed to verify own commitment TX signature")?; + + let sig = taproot::Signature { + signature: sig, + sighash_type: TapSighashType::Default, + }; + + input.tap_script_sigs.insert((pk, leaf_hash), sig); + } + } + } + + Ok(()) +} + +/// Build a map between VTXOs and their corresponding connector outputs. +fn derive_vtxo_connector_map( + vtxo_inputs: &[intent::Input], + connectors_leaves: &[&Psbt], + dust: Amount, +) -> Result, Error> { + // Collect all connector outpoints (non-anchor outputs). + let mut connector_outpoints = Vec::new(); + for psbt in connectors_leaves.iter() { + for (vout, output) in psbt.unsigned_tx.output.iter().enumerate() { + // Skip anchor outputs. + if output.value == Amount::ZERO { + continue; + } + connector_outpoints.push(OutPoint { + txid: psbt.unsigned_tx.compute_txid(), + vout: vout as u32, + }); + } + } + + // Sort connector outpoints for deterministic ordering + connector_outpoints.sort_by(|a, b| a.txid.cmp(&b.txid).then(a.vout.cmp(&b.vout))); + + // Get virtual TX outpoints that need forfeiting (excluding sub-dust and swept). + let mut virtual_tx_outpoints = vtxo_inputs + .iter() + .filter_map(|vtxo_input| { + ((vtxo_input.amount() >= dust) && !vtxo_input.is_swept()) + .then_some(vtxo_input.outpoint()) + }) + .collect::>(); + + // Sort virtual TX outpoints for deterministic ordering. + virtual_tx_outpoints.sort_by(|a, b| a.txid.cmp(&b.txid).then(a.vout.cmp(&b.vout))); + + if connector_outpoints.len() < virtual_tx_outpoints.len() { + return Err(Error::ad_hoc(format!( + "mismatch between VTXO count ({}) and connector count ({})", + virtual_tx_outpoints.len(), + connector_outpoints.len() + ))); + } + + // Create mapping by position. + let mut map = HashMap::new(); + for (virtual_tx_outpoint, connector_outpoint) in + virtual_tx_outpoints.iter().zip(connector_outpoints.iter()) + { + map.insert(*virtual_tx_outpoint, *connector_outpoint); + } + + Ok(map) +} + +fn extract_cosigner_pks_from_vtxo_psbt(psbt: &Psbt) -> Result, Error> { + let vtxo_input = &psbt.inputs[VTXO_INPUT_INDEX]; + + let mut cosigner_pks = Vec::new(); + for (key, pk) in vtxo_input.unknown.iter() { + if key.key.starts_with(&VTXO_COSIGNER_PSBT_KEY) { + cosigner_pks.push( + bitcoin::PublicKey::from_slice(pk) + .map_err(Error::crypto) + .context("invalid PK")? + .inner, + ); + } + } + Ok(cosigner_pks) +} + +/// A delegate contains all the information necessary for another party to settle VTXOs on behalf of +/// the owner. +/// +/// The owner pre-signs the intent and forfeit transactions, allowing another party to complete the +/// settlement at a later time. +#[derive(Debug, Clone)] +pub struct Delegate { + pub intent: Intent, + /// Partial forfeit transactions signed with SIGHASH_ALL | ANYONECANPAY. + pub forfeit_psbts: Vec, + /// The cosigner public key of the party who will execute the settlement as the delegate. + pub delegate_cosigner_pk: PublicKey, +} + +/// Prepare unsigned intent and forfeit PSBTs for delegate. +/// +/// This is step 1 of the delegate flow. Bob can prepare these PSBTs and send them to Alice for +/// signing. +/// +/// # Arguments +/// +/// * `intent_inputs` - VTXO inputs to be settled +/// * `outputs` - Desired outputs (typically back to the owner's address) +/// * `delegate_cosigner_pk` - Public keys of cosigner who will participate in the settlement +/// * `server_forfeit_address` - Address where forfeits are sent +/// * `dust` - Dust amount for connectors +/// +/// # Returns +/// +/// A [`Delegate`] struct containing unsigned PSBTs ready for signing. +pub fn prepare_delegate_psbts( + intent_inputs: Vec, + outputs: Vec, + delegate_cosigner_pk: PublicKey, + server_forfeit_address: &Address, + dust: Amount, +) -> Result { + prepare_delegate_psbts_at( + intent_inputs, + outputs, + delegate_cosigner_pk, + server_forfeit_address, + dust, + None, + ) +} + +/// Like [`prepare_delegate_psbts`], but with an explicit `valid_at` timestamp. +/// +/// When delegating to a third-party service, `valid_at` is set to the time at which the delegator +/// should execute the renewal (e.g. 90% through the VTXO's lifetime). In this case `expire_at` is +/// set to `0` (no expiry), since the delegator holds the intent until `valid_at` arrives. +/// +/// If `valid_at` is `None`, the current time is used and the intent expires in 2 minutes (same as +/// [`prepare_delegate_psbts`]). +pub fn prepare_delegate_psbts_at( + intent_inputs: Vec, + outputs: Vec, + delegate_cosigner_pk: PublicKey, + server_forfeit_address: &Address, + dust: Amount, + valid_at: Option, +) -> Result { + // Create intent message + let now = std::time::SystemTime::now(); + let now = now + .duration_since(std::time::UNIX_EPOCH) + .map_err(Error::ad_hoc) + .context("failed to compute now timestamp")?; + let now = now.as_secs(); + + // When valid_at is explicit (delegator flow), the intent is held for future use — no expiry. + // When valid_at is None (P2P flow), expire after 2 minutes. + let (valid_at, expire_at) = match valid_at { + Some(vat) => (vat, 0), + None => (now, now + (2 * 60)), + }; + + let onchain_output_indexes = outputs + .iter() + .enumerate() + .filter_map(|(idx, output)| match output { + intent::Output::Onchain(_) => Some(idx), + intent::Output::Offchain(_) | intent::Output::AssetPacket(_) => None, + }) + .collect(); + + let intent_message = intent::IntentMessage::Register { + onchain_output_indexes, + valid_at, + expire_at, + own_cosigner_pks: vec![delegate_cosigner_pk], + }; + + // Build the intent PSBT (unsigned) + let (mut intent_psbt, _fake_input) = + intent::build_proof_psbt(&intent_message, &intent_inputs, &outputs)?; + + // Sign the intent PSBT + for (i, proof_input) in intent_psbt.inputs.iter_mut().enumerate() { + if i == 0 { + let (script, control_block) = intent_inputs[0].spend_info().clone(); + + proof_input.tap_scripts = + BTreeMap::from_iter([(control_block, (script, taproot::LeafVersion::TapScript))]); + } else { + let (script, control_block) = intent_inputs[i - 1].spend_info().clone(); + + let tap_tree = intent::taptree::TapTree(intent_inputs[i - 1].tapscripts().to_vec()); + let bytes = tap_tree + .encode() + .map_err(Error::ad_hoc) + .with_context(|| format!("failed to encode taptree for input {i}"))?; + + proof_input.unknown.insert( + psbt::raw::Key { + type_value: 222, + key: crate::VTXO_TAPROOT_KEY.to_vec(), + }, + bytes, + ); + proof_input.tap_scripts = + BTreeMap::from_iter([(control_block, (script, taproot::LeafVersion::TapScript))]); + }; + } + + // Build unsigned forfeit PSBTs + let mut forfeit_psbts = Vec::new(); + const FORFEIT_TX_VTXO_INDEX: usize = 0; + + for intent_input in intent_inputs.iter() { + // Skip swept or sub-dust VTXOs - they cannot be forfeited. + if intent_input.is_swept() || intent_input.amount() < dust { + continue; + } + + let vtxo_amount = intent_input.amount(); + let virtual_tx_outpoint = intent_input.outpoint(); + let connector_amount = dust; + + // Create partial forfeit transaction with only the VTXO input + let forfeit_output = TxOut { + value: vtxo_amount + connector_amount, + script_pubkey: server_forfeit_address.script_pubkey(), + }; + + let mut forfeit_psbt = Psbt::from_unsigned_tx(Transaction { + version: transaction::Version::non_standard(3), + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: virtual_tx_outpoint, + ..Default::default() + }], + output: vec![forfeit_output, anchor_output()], + }) + .map_err(|e| Error::ad_hoc(format!("failed to create forfeit PSBT: {e}")))?; + + forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX].witness_utxo = Some(TxOut { + value: vtxo_amount, + script_pubkey: intent_input.script_pubkey().clone(), + }); + + // Set sighash type to SIGHASH_ALL | ANYONECANPAY + forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX].sighash_type = Some( + psbt::PsbtSighashType::from(TapSighashType::AllPlusAnyoneCanPay), + ); + + let (forfeit_script, forfeit_control_block) = intent_input.spend_info(); + let leaf_version = forfeit_control_block.leaf_version; + forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX] + .tap_scripts + .insert( + forfeit_control_block.clone(), + (forfeit_script.clone(), leaf_version), + ); + + forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX].witness_script = Some(forfeit_script.clone()); + + forfeit_psbts.push(forfeit_psbt); + } + + let intent = Intent::new(intent_psbt, intent_message); + + Ok(Delegate { + intent, + forfeit_psbts, + delegate_cosigner_pk, + }) +} + +/// Complete the delegated forfeit transactions by adding connector inputs and finalizing them. +pub fn create_asset_preservation_packet( + inputs: &[intent::Input], + outputs: &[intent::Output], +) -> Result, Error> { + const INTENT_PROOF_FAKE_INPUT_INDEX_OFFSET: u16 = 1; + + let mut groups: Vec = Vec::new(); + + let preserved_output_index = + outputs + .iter() + .enumerate() + .find_map(|(index, output)| match output { + intent::Output::Offchain(_) => Some(index as u16), + intent::Output::Onchain(_) | intent::Output::AssetPacket(_) => None, + }); + + for (input_index, input) in inputs.iter().enumerate() { + for asset in input.assets() { + if let Some(group) = groups + .iter_mut() + .find(|group| group.asset_id == Some(asset.asset_id)) + { + group.inputs.push(packet::AssetInput { + input_index: input_index as u16 + INTENT_PROOF_FAKE_INPUT_INDEX_OFFSET, + amount: asset.amount, + }); + + if let Some(output) = group.outputs.first_mut() { + output.amount = output.amount.checked_add(asset.amount).ok_or_else(|| { + Error::ad_hoc("asset amount overflow while preserving assets in settlement") + })?; + } + } else { + let mut asset_outputs = Vec::new(); + match preserved_output_index { + Some(output_index) => asset_outputs.push(packet::AssetOutput { + output_index, + amount: asset.amount, + }), + None => { + return Err(Error::ad_hoc( + "cannot preserve assets in settlement without an offchain output", + )) + } + } + + groups.push(packet::AssetGroup { + asset_id: Some(asset.asset_id), + control_asset: None, + metadata: None, + inputs: vec![packet::AssetInput { + input_index: input_index as u16 + INTENT_PROOF_FAKE_INPUT_INDEX_OFFSET, + amount: asset.amount, + }], + outputs: asset_outputs, + }); + } + } + } + + if groups.is_empty() { + return Ok(None); + } + + groups.sort_by_key(|group| { + let asset_id = group + .asset_id + .expect("asset-preservation groups always have asset ids"); + (*asset_id.txid.as_byte_array(), asset_id.group_index) + }); + + Ok(Some(packet::Packet { groups })) +} + +pub fn complete_delegate_forfeit_txs( + forfeit_psbts: &[Psbt], + connectors_leaves: &[&Psbt], +) -> Result, Error> { + const FORFEIT_TX_CONNECTOR_INDEX: usize = 0; + const FORFEIT_TX_VTXO_INDEX: usize = 1; + + let connector_index = derive_vtxo_connector_map_delegate( + forfeit_psbts + .iter() + .map(|psbt| psbt.unsigned_tx.input[0].previous_output) + .collect(), + connectors_leaves, + )?; + + let mut completed_forfeit_psbts = Vec::new(); + + for forfeit_psbt in forfeit_psbts.iter() { + let virtual_tx_outpoint = forfeit_psbt.unsigned_tx.input[0].previous_output; + + let connector_outpoint = connector_index.get(&virtual_tx_outpoint).ok_or_else(|| { + Error::ad_hoc(format!( + "connector outpoint missing for virtual TX outpoint {virtual_tx_outpoint}", + )) + })?; + + let connector_psbt = connectors_leaves + .iter() + .find(|l| l.unsigned_tx.compute_txid() == connector_outpoint.txid) + .ok_or_else(|| { + Error::ad_hoc(format!( + "connector PSBT missing for virtual TX outpoint {virtual_tx_outpoint}", + )) + })?; + + let connector_output = connector_psbt + .unsigned_tx + .output + .get(connector_outpoint.vout as usize) + .ok_or_else(|| { + Error::ad_hoc(format!( + "connector output missing for virtual TX outpoint {virtual_tx_outpoint}", + )) + })?; + + // Add the connector input to the partial forfeit transaction + let mut completed_tx = forfeit_psbt.unsigned_tx.clone(); + completed_tx.input.insert( + FORFEIT_TX_CONNECTOR_INDEX, + TxIn { + previous_output: *connector_outpoint, + ..Default::default() + }, + ); + + let mut completed_psbt = Psbt::from_unsigned_tx(completed_tx) + .map_err(|e| Error::ad_hoc(format!("failed to create PSBT from unsigned tx: {e}")))?; + + // Copy the VTXO input data from the partial PSBT + completed_psbt.inputs[FORFEIT_TX_VTXO_INDEX] = forfeit_psbt.inputs[0].clone(); + + // Add connector input data + completed_psbt.inputs[FORFEIT_TX_CONNECTOR_INDEX].witness_utxo = + Some(connector_output.clone()); + + // Copy outputs from partial PSBT + completed_psbt.outputs = forfeit_psbt.outputs.clone(); + + completed_forfeit_psbts.push(completed_psbt); + } + + Ok(completed_forfeit_psbts) +} + +/// Build a map between virtual TX outpoints and their corresponding connector outputs. +fn derive_vtxo_connector_map_delegate( + mut virtual_tx_outpoints: Vec, + connectors_leaves: &[&Psbt], +) -> Result, Error> { + // Collect all connector outpoints (non-anchor outputs). + let mut connector_outpoints = Vec::new(); + for psbt in connectors_leaves.iter() { + for (vout, output) in psbt.unsigned_tx.output.iter().enumerate() { + // Skip anchor outputs. + if output.value == Amount::ZERO { + continue; + } + connector_outpoints.push(OutPoint { + txid: psbt.unsigned_tx.compute_txid(), + vout: vout as u32, + }); + } + } + + // Sort connector outpoints for deterministic ordering + connector_outpoints.sort_by(|a, b| a.txid.cmp(&b.txid).then(a.vout.cmp(&b.vout))); + + // Sort virtual TX outpoints for deterministic ordering. + virtual_tx_outpoints.sort_by(|a, b| a.txid.cmp(&b.txid).then(a.vout.cmp(&b.vout))); + + // Ensure we have matching counts. + if connector_outpoints.len() < virtual_tx_outpoints.len() { + return Err(Error::ad_hoc(format!( + "mismatch between VTXO count ({}) and connector count ({})", + virtual_tx_outpoints.len(), + connector_outpoints.len() + ))); + } + + // Create mapping by position. + let mut map = HashMap::new(); + for (virtual_tx_outpoint, connector_outpoint) in + virtual_tx_outpoints.iter().zip(connector_outpoints.iter()) + { + map.insert(*virtual_tx_outpoint, *connector_outpoint); + } + + Ok(map) +} + +/// Sign delegate PSBTs. +/// +/// # Errors +/// +/// Returns an error if signing fails. +pub fn sign_delegate_psbts( + mut sign_fn: S, + intent_psbt: &mut Psbt, + forfeit_psbts: &mut [Psbt], +) -> Result<(), Error> +where + S: FnMut( + &mut psbt::Input, + secp256k1::Message, + ) -> Result, Error>, +{ + let prevouts = intent_psbt + .inputs + .iter() + .filter_map(|i| i.witness_utxo.clone()) + .collect::>(); + + for (i, psbt_input) in intent_psbt.inputs.iter_mut().enumerate() { + let prevouts = Prevouts::All(&prevouts); + + let (_, (script, leaf_version)) = + psbt_input.tap_scripts.first_key_value().expect("a value"); + + let leaf_hash = TapLeafHash::from_script(script, *leaf_version); + + let tap_sighash = SighashCache::new(&intent_psbt.unsigned_tx) + .taproot_script_spend_signature_hash(i, &prevouts, leaf_hash, TapSighashType::Default) + .map_err(Error::crypto) + .with_context(|| format!("failed to compute sighash for intent input {i}"))?; + + let msg = secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array()); + + let sigs = + sign_fn(psbt_input, msg).with_context(|| format!("failed to sign intent input {i}"))?; + for (sig, pk) in sigs { + let sig = taproot::Signature { + signature: sig, + sighash_type: TapSighashType::Default, + }; + + psbt_input.tap_script_sigs.insert((pk, leaf_hash), sig); + } + } + + // Sign the forfeit PSBTs + const FORFEIT_TX_VTXO_INDEX: usize = 0; + + for forfeit_psbt in forfeit_psbts { + let prevouts = forfeit_psbt + .inputs + .iter() + .filter_map(|i| i.witness_utxo.clone()) + .collect::>(); + let prevouts = Prevouts::All(&prevouts); + + let psbt_input = forfeit_psbt + .inputs + .get_mut(FORFEIT_TX_VTXO_INDEX) + .expect("input at index"); + + let (_, (forfeit_script, leaf_version)) = + psbt_input.tap_scripts.first_key_value().expect("one entry"); + + let leaf_hash = TapLeafHash::from_script(forfeit_script, *leaf_version); + + let tap_sighash = SighashCache::new(&forfeit_psbt.unsigned_tx) + .taproot_script_spend_signature_hash( + FORFEIT_TX_VTXO_INDEX, + &prevouts, + leaf_hash, + TapSighashType::AllPlusAnyoneCanPay, + ) + .map_err(|e| Error::ad_hoc(format!("failed to compute forfeit sighash: {e}")))?; + + let msg = secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array()); + + let sigs = + sign_fn(&mut forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX], msg).with_context(|| { + format!( + "failed to sign forfeit PSBT {}", + forfeit_psbt.unsigned_tx.compute_txid() + ) + })?; + + for (sig, pk) in sigs { + let sig = taproot::Signature { + signature: sig, + sighash_type: TapSighashType::AllPlusAnyoneCanPay, + }; + + forfeit_psbt.inputs[FORFEIT_TX_VTXO_INDEX] + .tap_script_sigs + .insert((pk, leaf_hash), sig); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::TxGraphChunk; + use bitcoin::ScriptBuf; + use rand::rngs::StdRng; + use rand::SeedableRng; + + fn cosigner_kp(n: u8) -> Keypair { + let secp = Secp256k1::new(); + let sk = secp256k1::SecretKey::from_slice(&[n; 32]).unwrap(); + + Keypair::from_secret_key(&secp, &sk) + } + + fn commitment_tx() -> Psbt { + let tx = Transaction { + version: transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new(), + }], + }; + + Psbt::from_unsigned_tx(tx).unwrap() + } + + /// A single-node batch tree spending the first output of `commitment_tx`, listing + /// `cosigner_pk` as its only cosigner. + fn batch_tree(commitment_tx: &Psbt, cosigner_pk: PublicKey) -> TxGraph { + let tx = Transaction { + version: transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: commitment_tx.unsigned_tx.compute_txid(), + vout: 0, + }, + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(9_000), + script_pubkey: ScriptBuf::new(), + }], + }; + + let mut psbt = Psbt::from_unsigned_tx(tx).unwrap(); + + let mut key = VTXO_COSIGNER_PSBT_KEY.to_vec(); + key.push(0); + + psbt.inputs[VTXO_INPUT_INDEX].unknown.insert( + psbt::raw::Key { type_value: 0, key }, + bitcoin::PublicKey::new(cosigner_pk).to_bytes(), + ); + + TxGraph::new(vec![TxGraphChunk { + txid: None, + tx: psbt, + children: HashMap::new(), + }]) + .unwrap() + } + + fn pub_nonces(seed: [u8; 32]) -> Vec<[u8; 66]> { + let own_cosigner_pk = cosigner_kp(1).public_key(); + let commitment_tx = commitment_tx(); + let batch_tree = batch_tree(&commitment_tx, own_cosigner_pk); + + let mut rng = StdRng::from_seed(seed); + let nonce_kps = + generate_nonce_tree(&mut rng, &batch_tree, own_cosigner_pk, &commitment_tx).unwrap(); + + let mut nonces = nonce_kps + .0 + .values() + .map(|(_, pub_nonce)| pub_nonce.serialize()) + .collect::>(); + nonces.sort(); + + nonces + } + + /// The public nonces must be a pure function of the RNG stream. A nonce derived from anything + /// else (a counter, the clock) would vary here even though the RNG does not. + #[test] + fn nonce_tree_is_determined_by_the_rng() { + assert_eq!(pub_nonces([7; 32]), pub_nonces([7; 32])); + } + + /// The public nonces must actually vary with the RNG stream. A nonce keyed off a fixed counter + /// would repeat here, and repeating a MuSig2 nonce leaks the secret key. + #[test] + fn nonce_tree_varies_with_the_rng() { + assert_ne!(pub_nonces([7; 32]), pub_nonces([8; 32])); + } +} diff --git a/vendor/ark-core/src/boarding_output.rs b/vendor/ark-core/src/boarding_output.rs new file mode 100644 index 0000000..c66a8e4 --- /dev/null +++ b/vendor/ark-core/src/boarding_output.rs @@ -0,0 +1,264 @@ +use crate::script::csv_sig_script; +use crate::script::multisig_script; +use crate::script::tr_script_pubkey; +use crate::Error; +use crate::ExitDelayKind; +use crate::ExplorerUtxo; +use crate::UNSPENDABLE_KEY; +use bitcoin::key::PublicKey; +use bitcoin::key::Secp256k1; +use bitcoin::key::Verification; +use bitcoin::taproot; +use bitcoin::taproot::LeafVersion; +use bitcoin::taproot::TaprootBuilder; +use bitcoin::taproot::TaprootSpendInfo; +use bitcoin::Address; +use bitcoin::Amount; +use bitcoin::Network; +use bitcoin::OutPoint; +use bitcoin::ScriptBuf; +use bitcoin::XOnlyPublicKey; +use std::time::Duration; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct BoardingOutput { + server: XOnlyPublicKey, + owner: XOnlyPublicKey, + spend_info: TaprootSpendInfo, + address: Address, + exit_delay: bitcoin::Sequence, + exit_delay_kind: ExitDelayKind, +} + +impl BoardingOutput { + pub fn new( + secp: &Secp256k1, + server: XOnlyPublicKey, + owner: XOnlyPublicKey, + exit_delay: bitcoin::Sequence, + network: Network, + ) -> Result + where + C: Verification, + { + let unspendable_key: PublicKey = UNSPENDABLE_KEY + .parse() + .map_err(|e| Error::ad_hoc(format!("invalid unspendable key: {e}")))?; + let (unspendable_key, _) = unspendable_key.inner.x_only_public_key(); + + let multisig_script = multisig_script(owner, server); + let exit_script = csv_sig_script(exit_delay, owner); + + let spend_info = TaprootBuilder::new() + .add_leaf(1, multisig_script) + .map_err(|e| Error::ad_hoc(format!("invalid multisig leaf: {e}")))? + .add_leaf(1, exit_script) + .map_err(|e| Error::ad_hoc(format!("invalid exit leaf: {e}")))? + .finalize(secp, unspendable_key) + .map_err(|_| Error::ad_hoc("failed to finalize taproot builder"))?; + + let exit_delay_kind = ExitDelayKind::from_sequence(exit_delay)?; + + let script_pubkey = tr_script_pubkey(&spend_info); + let address = Address::from_script(&script_pubkey, network) + .map_err(|e| Error::ad_hoc(format!("invalid script: {e}")))?; + + Ok(Self { + server, + owner, + spend_info, + address, + exit_delay, + exit_delay_kind, + }) + } + + pub fn address(&self) -> &Address { + &self.address + } + + pub fn owner_pk(&self) -> XOnlyPublicKey { + self.owner + } + + pub fn server_pk(&self) -> XOnlyPublicKey { + self.server + } + + pub fn script_pubkey(&self) -> ScriptBuf { + self.address.script_pubkey() + } + + pub fn forfeit_spend_info(&self) -> (ScriptBuf, taproot::ControlBlock) { + // It's kind of rubbish that we need to reconstruct the script every time we want a + // `ControlBlock`. It would be nicer to just get the `ControlBlock` for the left leaf and + // the right leaf, knowing which one is which. + + let forfeit_script = self.forfeit_script(); + + let control_block = self + .spend_info + .control_block(&(forfeit_script.clone(), LeafVersion::TapScript)) + .expect("forfeit script"); + + (forfeit_script, control_block) + } + + pub fn exit_spend_info(&self) -> (ScriptBuf, taproot::ControlBlock) { + let exit_script = self.exit_script(); + + let control_block = self + .spend_info + .control_block(&(exit_script.clone(), LeafVersion::TapScript)) + .expect("exit script"); + + (exit_script, control_block) + } + + pub fn exit_delay(&self) -> bitcoin::Sequence { + self.exit_delay + } + + pub fn output_key(&self) -> bitcoin::key::TweakedPublicKey { + self.spend_info.output_key() + } + + pub fn to_ark_address(&self, network: Network, server: XOnlyPublicKey) -> crate::ArkAddress { + crate::ArkAddress::new(network, server, self.output_key()) + } + + pub fn tapscripts(&self) -> Vec { + let (exit_script, _) = self.exit_spend_info(); + let (forfeit_script, _) = self.forfeit_spend_info(); + + vec![exit_script, forfeit_script] + } + + /// Whether the boarding output can be claimed unilaterally by the owner or not, given the + /// `confirmation_blocktime` of the transaction that included this boarding output as an output. + pub fn can_be_claimed_unilaterally_by_owner( + &self, + now: Duration, + confirmation_blocktime: Duration, + confirmations: u64, + ) -> bool { + match self.exit_delay_kind { + ExitDelayKind::Time(seconds) => { + let exit_path_time = confirmation_blocktime + seconds; + + now > exit_path_time + } + ExitDelayKind::Blocks(confirmations_required) => { + confirmations >= confirmations_required + } + } + } + + fn forfeit_script(&self) -> ScriptBuf { + multisig_script(self.owner, self.server) + } + + fn exit_script(&self) -> ScriptBuf { + csv_sig_script(self.exit_delay, self.owner) + } +} + +/// The on-chain status of a collection of boarding outputs. +#[derive(Debug, Clone, Default)] +pub struct BoardingOutpoints { + /// Boarding outputs that can be converted into VTXOs in collaboration with the Ark server. + pub spendable: Vec<(OutPoint, Amount, BoardingOutput)>, + /// Boarding outputs that should only be spent unilaterally. + pub expired: Vec<(OutPoint, Amount, BoardingOutput)>, + /// Boarding outputs that are not yet confirmed on-chain. + pub pending: Vec<(OutPoint, Amount, BoardingOutput)>, + /// Boarding outputs that were already spent. + pub spent: Vec<(OutPoint, Amount)>, +} + +impl BoardingOutpoints { + pub fn spendable_balance(&self) -> Amount { + self.spendable.iter().fold(Amount::ZERO, |acc, x| acc + x.1) + } + + pub fn expired_balance(&self) -> Amount { + self.expired.iter().fold(Amount::ZERO, |acc, x| acc + x.1) + } + + pub fn pending_balance(&self) -> Amount { + self.pending.iter().fold(Amount::ZERO, |acc, x| acc + x.1) + } +} + +/// Given a list of [`BoardingOutput`]s, determine their on-chain status. +pub fn list_boarding_outpoints( + find_outpoints_fn: F, + boarding_outputs: &[BoardingOutput], +) -> Result +where + F: Fn(&Address) -> Result, Error>, +{ + let mut spendable = Vec::new(); + let mut expired = Vec::new(); + let mut pending = Vec::new(); + let mut spent = Vec::new(); + for boarding_output in boarding_outputs.iter() { + let boarding_address = boarding_output.address(); + + // The boarding outputs corresponding to this address that we can find on-chain. + let boarding_utxos = find_outpoints_fn(boarding_address)?; + + for boarding_utxo in boarding_utxos.iter() { + match *boarding_utxo { + // The boarding output can be found on-chain. + ExplorerUtxo { + confirmation_blocktime: Some(confirmation_blocktime), + confirmations, + outpoint, + amount, + is_spent: false, + } => { + let now = std::time::UNIX_EPOCH.elapsed().map_err(Error::ad_hoc)?; + + // If the boarding output is on-chain can be spent unilaterally, it has expired. + if boarding_output.can_be_claimed_unilaterally_by_owner( + now, + Duration::from_secs(confirmation_blocktime), + confirmations, + ) { + expired.push((outpoint, amount, boarding_output.clone())); + } + // If the boarding output is on-chain and cannot be spent unilaterally, it is + // spendable. + else { + spendable.push((outpoint, amount, boarding_output.clone())); + } + } + // The boarding output is still pending confirmation. + ExplorerUtxo { + confirmation_blocktime: None, + outpoint, + amount, + is_spent: false, + .. + } => { + pending.push((outpoint, amount, boarding_output.clone())); + } + // The boarding output was spent. + ExplorerUtxo { + outpoint, + amount, + is_spent: true, + .. + } => spent.push((outpoint, amount)), + } + } + } + + Ok(BoardingOutpoints { + spendable, + expired, + pending, + spent, + }) +} diff --git a/vendor/ark-core/src/coin_select.rs b/vendor/ark-core/src/coin_select.rs new file mode 100644 index 0000000..6857a16 --- /dev/null +++ b/vendor/ark-core/src/coin_select.rs @@ -0,0 +1,141 @@ +use crate::asset::AssetId; +use crate::server::Asset; +use crate::Error; +use bitcoin::Amount; +use bitcoin::OutPoint; +use bitcoin::ScriptBuf; + +#[derive(Clone, Debug)] +pub struct VirtualTxOutPoint { + pub outpoint: OutPoint, + pub script_pubkey: ScriptBuf, + pub expire_at: i64, + pub amount: Amount, + pub assets: Vec, +} + +/// Select VTXOs to be used as inputs in Ark transactions. +pub fn select_vtxos( + mut virtual_tx_outpoints: Vec, + amount: Amount, + dust: Amount, + sort_by_expiration_time: bool, +) -> Result, Error> { + let mut selected = Vec::new(); + let mut not_selected = Vec::new(); + let mut selected_amount = Amount::ZERO; + + if sort_by_expiration_time { + // Sort vtxos by expiration (older first) + virtual_tx_outpoints.sort_by_key(|a| a.expire_at); + } + + // Process VTXOs + for virtual_tx_outpoint in virtual_tx_outpoints { + if selected_amount >= amount { + not_selected.push(virtual_tx_outpoint); + } else { + selected.push(virtual_tx_outpoint.clone()); + selected_amount += virtual_tx_outpoint.amount; + } + } + + if selected_amount < amount { + return Err(Error::coin_select(format!( + "insufficient funds: selected = {selected_amount}, needed = {amount}" + ))); + } + + // Try to avoid generating dust. + let change_amount = selected_amount - amount; + if let Some(vtxo) = not_selected.first() { + if change_amount < dust { + selected.push(vtxo.clone()); + } + } + + Ok(selected) +} + +/// Select VTXOs that hold a specific asset, accumulating until `amount` is reached. +/// +/// Returns the selected VTXOs and the asset change amount. +pub fn select_vtxos_for_asset( + virtual_tx_outpoints: &[VirtualTxOutPoint], + amount: u64, + asset_id: AssetId, +) -> Result<(Vec, u64), Error> { + // Filter to only VTXOs containing this asset. + let mut candidates: Vec = virtual_tx_outpoints + .iter() + .filter(|v| v.assets.iter().any(|a| a.asset_id == asset_id)) + .cloned() + .collect(); + + // Sort by expiration (older first). + candidates.sort_by_key(|a| a.expire_at); + + let mut selected = Vec::new(); + let mut selected_amount: u64 = 0; + + for vtxo in candidates { + if selected_amount >= amount { + break; + } + + if let Some(asset) = vtxo + .assets + .iter() + .find(|a| a.asset_id == asset_id && a.amount != 0) + { + selected_amount += asset.amount; + selected.push(vtxo); + } + } + + let change = match selected_amount.checked_sub(amount) { + Some(change) => change, + None => { + return Err(Error::coin_select(format!( + "insufficient asset funds for {asset_id}: selected = {selected_amount}, needed = {amount}" + ))); + } + }; + + Ok((selected, change)) +} + +// Tests for the coin selection function +#[cfg(test)] +mod tests { + use super::*; + + fn vtxo(expire_at: i64, amount: Amount) -> VirtualTxOutPoint { + VirtualTxOutPoint { + outpoint: OutPoint::default(), + script_pubkey: ScriptBuf::new(), + expire_at, + amount, + assets: Vec::new(), + } + } + + #[test] + fn test_basic_coin_selection() { + let vtxos = vec![vtxo(123456789, Amount::from_sat(3000))]; + + let result = select_vtxos(vtxos, Amount::from_sat(2500), Amount::from_sat(100), true); + assert!(result.is_ok()); + + let selected = result.unwrap(); + assert_eq!(selected.len(), 1); + } + + #[test] + fn test_insufficient_funds() { + let vtxos = vec![vtxo(123456789, Amount::from_sat(100))]; + + let result = select_vtxos(vtxos, Amount::from_sat(1000), Amount::from_sat(50), true); + assert!(result.is_err()); + } +} diff --git a/vendor/ark-core/src/contract.rs b/vendor/ark-core/src/contract.rs new file mode 100644 index 0000000..f0a69c1 --- /dev/null +++ b/vendor/ark-core/src/contract.rs @@ -0,0 +1,543 @@ +use crate::boarding_output::BoardingOutput; +use crate::vhtlc::VhtlcOptions; +use crate::vtxo::Vtxo; +use crate::Error; +use bitcoin::absolute; +use bitcoin::key::Secp256k1; +use bitcoin::secp256k1::All; +use bitcoin::taproot::ControlBlock; +use bitcoin::Network; +use bitcoin::ScriptBuf; +use bitcoin::Sequence; +use bitcoin::XOnlyPublicKey; +use serde::Deserialize; +use serde::Serialize; +use std::fmt; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ContractType(String); + +impl ContractType { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(Error::ad_hoc("contract type cannot be empty")); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn default_vtxo() -> Self { + Self("default".to_string()) + } + + pub fn delegate_vtxo() -> Self { + Self("delegate".to_string()) + } + + pub fn boarding() -> Self { + Self("boarding".to_string()) + } + + pub fn vhtlc() -> Self { + Self("vhtlc".to_string()) + } +} + +impl fmt::Display for ContractType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl From<&'static str> for ContractType { + fn from(value: &'static str) -> Self { + Self(value.to_string()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContractState { + Active, + Inactive, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct StoredContract { + pub contract_type: ContractType, + pub contract_version: u32, + pub script_pubkey: ScriptBuf, + pub state: ContractState, + pub created_at: u64, + /// HD derivation index for contracts generated by an index-based key provider. + /// + /// Static/custom providers may not expose an index, and imported contracts may not have one. + pub key_index: Option, + pub data: serde_json::Value, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SpendPathKind { + Forfeit, + Exit, + Delegate, + VhtlcClaim, + VhtlcRefund, + VhtlcRefundWithoutReceiver, + VhtlcUnilateralClaim, + VhtlcUnilateralRefund, + VhtlcUnilateralRefundWithoutReceiver, + Custom(String), +} + +impl SpendPathKind { + pub fn from_vhtlc_name(name: String) -> Self { + match name.as_str() { + "claim" => Self::VhtlcClaim, + "refund" => Self::VhtlcRefund, + "refund_without_receiver" => Self::VhtlcRefundWithoutReceiver, + "unilateral_claim" => Self::VhtlcUnilateralClaim, + "unilateral_refund" => Self::VhtlcUnilateralRefund, + "unilateral_refund_without_receiver" => Self::VhtlcUnilateralRefundWithoutReceiver, + _ => Self::Custom(name), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SpendPath { + pub kind: SpendPathKind, + pub script: ScriptBuf, + pub control_block: ControlBlock, +} + +impl SpendPath { + pub fn new(kind: SpendPathKind, script: ScriptBuf, control_block: ControlBlock) -> Self { + Self { + kind, + script, + control_block, + } + } + + pub fn select(self) -> SpendSelection { + SpendSelection::new(self) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SpendSelection { + pub path: SpendPath, + pub sequence: Option, + pub locktime: Option, + pub extra_witness: Vec>, +} + +impl SpendSelection { + pub fn new(path: SpendPath) -> Self { + Self { + path, + sequence: None, + locktime: None, + extra_witness: Vec::new(), + } + } + + pub fn with_sequence(mut self, sequence: Sequence) -> Self { + self.sequence = Some(sequence); + self + } + + pub fn with_locktime(mut self, locktime: absolute::LockTime) -> Self { + self.locktime = Some(locktime); + self + } + + pub fn with_extra_witness(mut self, extra_witness: Vec>) -> Self { + self.extra_witness = extra_witness; + self + } + + pub fn resolved_sequence(&self, default_sequence: Sequence) -> Sequence { + self.sequence.unwrap_or(default_sequence) + } + + pub fn resolved_spend_info( + &self, + default_sequence: Sequence, + ) -> (Sequence, (ScriptBuf, ControlBlock)) { + (self.resolved_sequence(default_sequence), self.spend_info()) + } + + pub fn spend_info(&self) -> (ScriptBuf, ControlBlock) { + (self.path.script.clone(), self.path.control_block.clone()) + } +} + +#[derive(Clone)] +pub struct ContractContext { + network: Network, + secp: Secp256k1, +} + +impl ContractContext { + pub fn new(network: Network) -> Self { + Self { + network, + secp: Secp256k1::new(), + } + } + + pub fn network(&self) -> Network { + self.network + } + + pub fn secp(&self) -> &Secp256k1 { + &self.secp + } +} + +pub trait ContractSpec: + Clone + Serialize + for<'de> Deserialize<'de> + Send + Sync + 'static +{ + const VERSION: u32; + + fn contract_type() -> ContractType; + fn script_pubkey(&self, ctx: &ContractContext) -> Result; + fn spendable_paths(&self, ctx: &ContractContext) -> Result, Error>; + + fn spendable_selections(&self, ctx: &ContractContext) -> Result, Error> { + Ok(self + .spendable_paths(ctx)? + .into_iter() + .map(SpendPath::select) + .collect()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DefaultVtxoContract { + pub server: XOnlyPublicKey, + pub owner: XOnlyPublicKey, + pub exit_delay: Sequence, +} + +impl ContractSpec for DefaultVtxoContract { + const VERSION: u32 = 1; + + fn contract_type() -> ContractType { + ContractType::default_vtxo() + } + + fn script_pubkey(&self, ctx: &ContractContext) -> Result { + Ok(self.vtxo(ctx)?.script_pubkey()) + } + + fn spendable_paths(&self, ctx: &ContractContext) -> Result, Error> { + let vtxo = self.vtxo(ctx)?; + let (forfeit_script, forfeit_control_block) = vtxo.forfeit_spend_info()?; + let (exit_script, exit_control_block) = vtxo.exit_spend_info()?; + Ok(vec![ + SpendPath::new( + SpendPathKind::Forfeit, + forfeit_script, + forfeit_control_block, + ), + SpendPath::new(SpendPathKind::Exit, exit_script, exit_control_block), + ]) + } + + fn spendable_selections(&self, ctx: &ContractContext) -> Result, Error> { + Ok(self + .spendable_paths(ctx)? + .into_iter() + .map(|path| { + if path.kind == SpendPathKind::Exit { + path.select().with_sequence(self.exit_delay) + } else { + path.select() + } + }) + .collect()) + } +} + +impl DefaultVtxoContract { + pub fn vtxo(&self, ctx: &ContractContext) -> Result { + Vtxo::new_default( + ctx.secp(), + self.server, + self.owner, + self.exit_delay, + ctx.network(), + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DelegateVtxoContract { + pub server: XOnlyPublicKey, + pub owner: XOnlyPublicKey, + pub delegator: XOnlyPublicKey, + pub exit_delay: Sequence, +} + +impl ContractSpec for DelegateVtxoContract { + const VERSION: u32 = 1; + + fn contract_type() -> ContractType { + ContractType::delegate_vtxo() + } + + fn script_pubkey(&self, ctx: &ContractContext) -> Result { + Ok(self.vtxo(ctx)?.script_pubkey()) + } + + fn spendable_paths(&self, ctx: &ContractContext) -> Result, Error> { + let vtxo = self.vtxo(ctx)?; + let (forfeit_script, forfeit_control_block) = vtxo.forfeit_spend_info()?; + let (exit_script, exit_control_block) = vtxo.exit_spend_info()?; + let (delegate_script, delegate_control_block) = vtxo.delegate_spend_info()?; + Ok(vec![ + SpendPath::new( + SpendPathKind::Forfeit, + forfeit_script, + forfeit_control_block, + ), + SpendPath::new(SpendPathKind::Exit, exit_script, exit_control_block), + SpendPath::new( + SpendPathKind::Delegate, + delegate_script, + delegate_control_block, + ), + ]) + } + + fn spendable_selections(&self, ctx: &ContractContext) -> Result, Error> { + Ok(self + .spendable_paths(ctx)? + .into_iter() + .map(|path| { + if path.kind == SpendPathKind::Exit { + path.select().with_sequence(self.exit_delay) + } else { + path.select() + } + }) + .collect()) + } +} + +impl DelegateVtxoContract { + pub fn vtxo(&self, ctx: &ContractContext) -> Result { + Vtxo::new_with_delegator( + ctx.secp(), + self.server, + self.owner, + self.delegator, + self.exit_delay, + ctx.network(), + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoardingContract { + pub server: XOnlyPublicKey, + pub owner: XOnlyPublicKey, + pub exit_delay: Sequence, +} + +impl ContractSpec for BoardingContract { + const VERSION: u32 = 1; + + fn contract_type() -> ContractType { + ContractType::boarding() + } + + fn script_pubkey(&self, ctx: &ContractContext) -> Result { + Ok(self.boarding_output(ctx)?.script_pubkey()) + } + + fn spendable_paths(&self, ctx: &ContractContext) -> Result, Error> { + let boarding_output = self.boarding_output(ctx)?; + let (forfeit_script, forfeit_control_block) = boarding_output.forfeit_spend_info(); + let (exit_script, exit_control_block) = boarding_output.exit_spend_info(); + Ok(vec![ + SpendPath::new( + SpendPathKind::Forfeit, + forfeit_script, + forfeit_control_block, + ), + SpendPath::new(SpendPathKind::Exit, exit_script, exit_control_block), + ]) + } + + fn spendable_selections(&self, ctx: &ContractContext) -> Result, Error> { + Ok(self + .spendable_paths(ctx)? + .into_iter() + .map(|path| { + if path.kind == SpendPathKind::Exit { + path.select().with_sequence(self.exit_delay) + } else { + path.select() + } + }) + .collect()) + } +} + +impl BoardingContract { + pub fn boarding_output(&self, ctx: &ContractContext) -> Result { + BoardingOutput::new( + ctx.secp(), + self.server, + self.owner, + self.exit_delay, + ctx.network(), + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VhtlcContract { + pub options: VhtlcOptions, +} + +impl ContractSpec for VhtlcContract { + const VERSION: u32 = 1; + + fn contract_type() -> ContractType { + ContractType::vhtlc() + } + + fn script_pubkey(&self, ctx: &ContractContext) -> Result { + let script = crate::vhtlc::VhtlcScript::new(self.options.clone(), ctx.network()) + .map_err(|e| Error::ad_hoc(format!("failed to build vhtlc: {e}")))?; + Ok(script.script_pubkey()) + } + + fn spendable_paths(&self, ctx: &ContractContext) -> Result, Error> { + let script = crate::vhtlc::VhtlcScript::new(self.options.clone(), ctx.network()) + .map_err(|e| Error::ad_hoc(format!("failed to build vhtlc: {e}")))?; + script + .get_script_map() + .into_iter() + .map(|(name, tapscript)| { + let control_block = script + .taproot_spend_info() + .control_block(&(tapscript.clone(), bitcoin::taproot::LeafVersion::TapScript)) + .ok_or_else(|| Error::ad_hoc("missing vhtlc control block"))?; + Ok(SpendPath { + kind: SpendPathKind::from_vhtlc_name(name), + script: tapscript, + control_block, + }) + }) + .collect() + } + + fn spendable_selections(&self, ctx: &ContractContext) -> Result, Error> { + Ok(self + .spendable_paths(ctx)? + .into_iter() + .filter_map(|path| match path.kind { + SpendPathKind::VhtlcClaim | SpendPathKind::VhtlcUnilateralClaim => None, + SpendPathKind::VhtlcRefundWithoutReceiver => Some(path.select().with_locktime( + absolute::LockTime::from_consensus(self.options.refund_locktime), + )), + SpendPathKind::VhtlcUnilateralRefund => Some( + path.select() + .with_sequence(self.options.unilateral_refund_delay), + ), + SpendPathKind::VhtlcUnilateralRefundWithoutReceiver => Some( + path.select() + .with_sequence(self.options.unilateral_refund_without_receiver_delay), + ), + SpendPathKind::Forfeit + | SpendPathKind::Exit + | SpendPathKind::Delegate + | SpendPathKind::VhtlcRefund + | SpendPathKind::Custom(_) => Some(path.select()), + }) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::hashes::ripemd160; + use bitcoin::hashes::Hash; + use std::str::FromStr; + + fn test_key(hex: &str) -> XOnlyPublicKey { + XOnlyPublicKey::from_str(hex).unwrap() + } + + fn vhtlc_contract() -> VhtlcContract { + VhtlcContract { + options: VhtlcOptions { + sender: test_key( + "f874c4fd782a63a2b078b424f9f4e5edae622880a29fe572bd71bee45ab55ea0", + ), + receiver: test_key( + "b02b1a956cd0ed91d1a06b65ae976470ffd4b8c9215f446f266bb74fedd153b6", + ), + server: test_key( + "e35799157be4b37565bb5afe4d04e6a0fa0a4b6a4f4e48b0d904685d253cdbdb", + ), + preimage_hash: ripemd160::Hash::hash(b"preimage"), + refund_locktime: 500, + unilateral_claim_delay: Sequence::from_height(10), + unilateral_refund_delay: Sequence::from_height(20), + unilateral_refund_without_receiver_delay: Sequence::from_height(30), + }, + } + } + + #[test] + fn vhtlc_spendable_selections_include_required_constraints() { + let ctx = ContractContext::new(Network::Regtest); + let selections = vhtlc_contract().spendable_selections(&ctx).unwrap(); + + assert!(!selections + .iter() + .any(|selection| selection.path.kind == SpendPathKind::VhtlcClaim)); + assert!(!selections + .iter() + .any(|selection| selection.path.kind == SpendPathKind::VhtlcUnilateralClaim)); + + let refund_without_receiver = selections + .iter() + .find(|selection| selection.path.kind == SpendPathKind::VhtlcRefundWithoutReceiver) + .unwrap(); + assert_eq!( + refund_without_receiver.locktime, + Some(absolute::LockTime::from_consensus(500)) + ); + + let unilateral_refund = selections + .iter() + .find(|selection| selection.path.kind == SpendPathKind::VhtlcUnilateralRefund) + .unwrap(); + assert_eq!(unilateral_refund.sequence, Some(Sequence::from_height(20))); + + let unilateral_refund_without_receiver = selections + .iter() + .find(|selection| { + selection.path.kind == SpendPathKind::VhtlcUnilateralRefundWithoutReceiver + }) + .unwrap(); + assert_eq!( + unilateral_refund_without_receiver.sequence, + Some(Sequence::from_height(30)) + ); + } +} diff --git a/vendor/ark-core/src/conversions.rs b/vendor/ark-core/src/conversions.rs new file mode 100644 index 0000000..62e752e --- /dev/null +++ b/vendor/ark-core/src/conversions.rs @@ -0,0 +1,10 @@ +use bitcoin::secp256k1::PublicKey; +use bitcoin::XOnlyPublicKey; + +pub fn to_musig_pk(pk: PublicKey) -> musig::PublicKey { + musig::PublicKey::from_slice(&pk.serialize()).expect("valid conversion") +} + +pub fn from_musig_xonly(pk: musig::XOnlyPublicKey) -> XOnlyPublicKey { + XOnlyPublicKey::from_slice(&pk.to_byte_array()).expect("valid conversion") +} diff --git a/vendor/ark-core/src/error.rs b/vendor/ark-core/src/error.rs new file mode 100644 index 0000000..2b1eac3 --- /dev/null +++ b/vendor/ark-core/src/error.rs @@ -0,0 +1,237 @@ +use std::error::Error as StdError; +use std::fmt; + +type Source = Box; + +#[derive(Debug)] +pub struct Error { + inner: Box, +} + +#[derive(Debug)] +struct ErrorImpl { + kind: Kind, + cause: Option, +} + +#[derive(Debug)] +enum Kind { + /// Ad-hoc error, + AdHoc(AdHocError), + /// An error related to cryptography. + Crypto(CryptoError), + /// An error related to constructing Bitcoin transactions. + Transaction(TransactionError), + /// An error related to coin selection of VTXOs and boarding outputs. + CoinSelect(CoinSelectError), + /// An error related to encoding or decoding an Ark address. + ArkAddress(ArkAddressError), +} + +#[derive(Debug)] +struct AdHocError { + source: Source, +} + +#[derive(Debug)] +struct CryptoError { + source: Source, +} + +#[derive(Debug)] +struct TransactionError { + source: Source, +} + +#[derive(Debug)] +struct CoinSelectError { + source: Source, +} + +#[derive(Debug)] +struct ArkAddressError { + source: Source, +} + +impl Error { + fn new(kind: Kind) -> Self { + Self { + inner: Box::new(ErrorImpl { kind, cause: None }), + } + } + + pub fn ad_hoc(source: impl Into) -> Self { + Error::new(Kind::AdHoc(AdHocError { + source: source.into(), + })) + } + + pub(crate) fn crypto(source: impl Into) -> Self { + Error::new(Kind::Crypto(CryptoError { + source: source.into(), + })) + } + + pub(crate) fn transaction(source: impl Into) -> Self { + Error::new(Kind::Transaction(TransactionError { + source: source.into(), + })) + } + + pub(crate) fn coin_select(source: impl Into) -> Self { + Error::new(Kind::CoinSelect(CoinSelectError { + source: source.into(), + })) + } + + pub(crate) fn address_format(source: impl Into) -> Self { + Error::new(Kind::ArkAddress(ArkAddressError { + source: source.into(), + })) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut err = self; + loop { + write!(f, "{}", err.inner.kind)?; + err = match err.inner.cause.as_ref() { + None => break, + Some(err) => err, + }; + write!(f, ": ")?; + } + Ok(()) + } +} + +impl fmt::Display for Kind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Kind::AdHoc(ref err) => err.fmt(f), + Kind::Crypto(ref err) => err.fmt(f), + Kind::Transaction(ref err) => err.fmt(f), + Kind::CoinSelect(ref err) => err.fmt(f), + Kind::ArkAddress(ref err) => err.fmt(f), + } + } +} + +impl fmt::Display for AdHocError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.source.fmt(f) + } +} + +impl fmt::Display for CryptoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.source.fmt(f) + } +} + +impl fmt::Display for TransactionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.source.fmt(f) + } +} + +impl fmt::Display for CoinSelectError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.source.fmt(f) + } +} + +impl fmt::Display for ArkAddressError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.source.fmt(f) + } +} + +pub trait IntoError { + fn into_error(self) -> Error; +} + +impl IntoError for Error { + fn into_error(self) -> Error { + self + } +} + +impl IntoError for &'static str { + fn into_error(self) -> Error { + Error::ad_hoc(self) + } +} + +impl IntoError for String { + fn into_error(self) -> Error { + Error::ad_hoc(self) + } +} + +/// A trait for contextualizing error values. +/// +/// This makes it easy to contextualize either `Error` or `Result`. +/// Specifically, in the latter case, it absolves one of the need to call +/// `map_err` everywhere one wants to add context to an error. +/// +/// This trick was borrowed from `jiff`, which borrowed it from `anyhow`. +pub trait ErrorContext { + /// Contextualize the given consequent error with this (`self`) error as + /// the cause. + /// + /// This is equivalent to saying that "consequent is caused by self." + /// + /// Note that if an `Error` is given for `kind`, then this panics if it has + /// a cause. (Because the cause would otherwise be dropped. An error causal + /// chain is just a linked list, not a tree.) + fn context(self, consequent: impl IntoError) -> Self; + + /// Like `context`, but hides error construction within a closure. + /// + /// This is useful if the creation of the consequent error is not otherwise + /// guarded and when error construction is potentially "costly" (i.e., it + /// allocates). The closure avoids paying the cost of contextual error + /// creation in the happy path. + /// + /// Usually this only makes sense to use on a `Result`, otherwise + /// the closure is just executed immediately anyway. + fn with_context(self, consequent: impl FnOnce() -> E) -> Self; +} + +impl ErrorContext for Error { + fn context(self, consequent: impl IntoError) -> Error { + let mut err = consequent.into_error(); + assert!( + err.inner.cause.is_none(), + "cause of consequence must be `None`" + ); + + err.inner.cause = Some(self); + err + } + + fn with_context(self, consequent: impl FnOnce() -> E) -> Error { + let mut err = consequent().into_error(); + assert!( + err.inner.cause.is_none(), + "cause of consequence must be `None`" + ); + + err.inner.cause = Some(self); + err + } +} + +impl ErrorContext for Result { + fn context(self, consequent: impl IntoError) -> Result { + self.map_err(|err| err.context(consequent)) + } + + fn with_context(self, consequent: impl FnOnce() -> E) -> Result { + self.map_err(|err| err.with_context(consequent)) + } +} + +impl StdError for Error {} diff --git a/vendor/ark-core/src/extension.rs b/vendor/ark-core/src/extension.rs new file mode 100644 index 0000000..5acfdb3 --- /dev/null +++ b/vendor/ark-core/src/extension.rs @@ -0,0 +1,242 @@ +use bitcoin::opcodes::all::OP_RETURN; +use bitcoin::script::Instruction; +use bitcoin::Amount; +use bitcoin::Script; +use bitcoin::ScriptBuf; +use bitcoin::TxOut; + +pub const MAGIC_BYTES: [u8; 3] = [0x41, 0x52, 0x4b]; + +#[derive(Debug, thiserror::Error)] +pub enum ExtensionError { + #[error("extension payload length overflows")] + PayloadLengthOverflow, + #[error("truncated extension packet type")] + TruncatedPacketType, + #[error("truncated extension packet length")] + TruncatedPacketLength, + #[error("truncated extension packet payload, expected {expected} bytes got {got}")] + TruncatedPacketPayload { expected: usize, got: usize }, + #[error("duplicate extension packet type {0}")] + DuplicatePacketType(u8), +} + +pub fn encode_uvarint(buf: &mut Vec, mut value: u64) { + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + buf.push(byte); + if value == 0 { + break; + } + } +} + +fn decode_uvarint(data: &[u8], offset: &mut usize) -> Result { + let mut value = 0_u64; + for shift in (0..64).step_by(7) { + let Some(byte) = data.get(*offset) else { + return Err(ExtensionError::TruncatedPacketLength); + }; + *offset += 1; + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Ok(value); + } + } + Err(ExtensionError::PayloadLengthOverflow) +} + +pub fn is_extension(script: &Script) -> bool { + extension_payload(script).is_some() +} + +pub fn extension_payload(script: &Script) -> Option<&[u8]> { + let mut instructions = script.instructions(); + if !matches!(instructions.next(), Some(Ok(Instruction::Op(OP_RETURN)))) { + return None; + } + let Some(Ok(Instruction::PushBytes(bytes))) = instructions.next() else { + return None; + }; + let bytes = bytes.as_bytes(); + (bytes.len() >= MAGIC_BYTES.len() && bytes[..MAGIC_BYTES.len()] == MAGIC_BYTES).then_some(bytes) +} + +pub fn iter_packets(payload: &[u8]) -> Result, ExtensionError> { + let mut packets = Vec::new(); + let mut offset = MAGIC_BYTES.len(); + + while offset < payload.len() { + let Some(packet_type) = payload.get(offset).copied() else { + return Err(ExtensionError::TruncatedPacketType); + }; + offset += 1; + + let packet_len = decode_uvarint(payload, &mut offset)? as usize; + let end = offset + .checked_add(packet_len) + .ok_or(ExtensionError::PayloadLengthOverflow)?; + if end > payload.len() { + return Err(ExtensionError::TruncatedPacketPayload { + expected: end, + got: payload.len(), + }); + } + + packets.push((packet_type, &payload[offset..end])); + offset = end; + } + + Ok(packets) +} + +pub fn find_packet_payload( + tx: &bitcoin::Transaction, + packet_type: u8, +) -> Result, ExtensionError> { + for output in &tx.output { + let Some(payload) = extension_payload(&output.script_pubkey) else { + continue; + }; + for (current_type, current_payload) in iter_packets(payload)? { + if current_type == packet_type { + return Ok(Some(current_payload)); + } + } + return Ok(None); + } + + Ok(None) +} + +pub fn packet_txout(packet_type: u8, packet_payload: &[u8]) -> TxOut { + let mut payload = Vec::new(); + payload.extend_from_slice(&MAGIC_BYTES); + push_packet(&mut payload, packet_type, packet_payload); + + TxOut { + value: Amount::ZERO, + script_pubkey: op_return_script(&payload), + } +} + +pub fn add_packet_to_psbt( + psbt: &mut bitcoin::Psbt, + packet_type: u8, + packet_payload: &[u8], +) -> Result<(), ExtensionError> { + let mut encoded_packet = Vec::new(); + push_packet(&mut encoded_packet, packet_type, packet_payload); + + for output in &mut psbt.unsigned_tx.output { + let Some(existing_payload) = extension_payload(&output.script_pubkey) else { + continue; + }; + + for (existing_type, _) in iter_packets(existing_payload)? { + if existing_type == packet_type { + return Err(ExtensionError::DuplicatePacketType(packet_type)); + } + } + + let mut payload = existing_payload.to_vec(); + payload.extend_from_slice(&encoded_packet); + output.script_pubkey = op_return_script(&payload); + return Ok(()); + } + + let txout = packet_txout(packet_type, packet_payload); + let len = psbt.unsigned_tx.output.len(); + + if len == 0 { + psbt.unsigned_tx.output.push(txout); + psbt.outputs.push(bitcoin::psbt::Output::default()); + return Ok(()); + } + + let anchor_index = len - 1; + psbt.unsigned_tx.output.insert(anchor_index, txout); + psbt.outputs + .insert(anchor_index, bitcoin::psbt::Output::default()); + Ok(()) +} + +fn push_packet(payload: &mut Vec, packet_type: u8, packet_payload: &[u8]) { + payload.push(packet_type); + encode_uvarint(payload, packet_payload.len() as u64); + payload.extend_from_slice(packet_payload); +} + +fn op_return_script(data: &[u8]) -> ScriptBuf { + let mut script = Vec::new(); + script.push(OP_RETURN.to_u8()); + push_data(&mut script, data); + ScriptBuf::from_bytes(script) +} + +fn push_data(script: &mut Vec, data: &[u8]) { + let len = data.len(); + if len <= 75 { + script.push(len as u8); + } else if len <= 0xff { + script.push(0x4c); + script.push(len as u8); + } else if len <= 0xffff { + script.push(0x4d); + script.extend_from_slice(&(len as u16).to_le_bytes()); + } else { + script.push(0x4e); + script.extend_from_slice(&(len as u32).to_le_bytes()); + } + script.extend_from_slice(data); +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::absolute; + use bitcoin::transaction; + use bitcoin::TxIn; + + #[test] + fn encodes_go_uvarint_packet_lengths() { + let txout = packet_txout(0x01, &[0; 136]); + let payload = extension_payload(&txout.script_pubkey).unwrap(); + assert_eq!(&payload[..5], &[0x41, 0x52, 0x4b, 0x01, 0x88]); + assert_eq!(payload[5], 0x01); + } + + #[test] + fn appends_to_existing_extension_output() { + let mut psbt = bitcoin::Psbt::from_unsigned_tx(bitcoin::Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![ + TxOut { + value: Amount::ZERO, + script_pubkey: packet_txout(0x00, &[0xaa]).script_pubkey, + }, + TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new(), + }, + ], + }) + .unwrap(); + + add_packet_to_psbt(&mut psbt, 0x01, &[0xbb, 0xcc]).unwrap(); + + assert_eq!(psbt.unsigned_tx.output.len(), 2); + let payload = extension_payload(&psbt.unsigned_tx.output[0].script_pubkey).unwrap(); + let packets = iter_packets(payload).unwrap(); + assert_eq!( + packets, + vec![(0x00, &[0xaa][..]), (0x01, &[0xbb, 0xcc][..])] + ); + } +} diff --git a/vendor/ark-core/src/history.rs b/vendor/ark-core/src/history.rs new file mode 100644 index 0000000..eda65ce --- /dev/null +++ b/vendor/ark-core/src/history.rs @@ -0,0 +1,1136 @@ +use crate::server::VirtualTxOutPoint; +use crate::Error; +use bitcoin::Amount; +use bitcoin::SignedAmount; +use bitcoin::Txid; +use std::collections::hash_map::Entry; +use std::collections::HashMap; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Transaction { + /// A transaction that transforms a UTXO into a boarding output. + Boarding { + txid: Txid, + /// We use [`Amount`] because boarding transactions are always incoming i.e. we receive a + /// boarding output. + amount: Amount, + confirmed_at: Option, + }, + /// A transaction that confirms VTXOs. + Commitment { + txid: Txid, + /// We use [`SignedAmount`] because commitment transactions may be incoming or outgoing + /// i.e. we can send or receive VTXOs. + amount: SignedAmount, + created_at: i64, + }, + /// A transaction that has VTXOs as outputs. + Ark { + txid: Txid, + /// We use [`SignedAmount`] because Ark transactions may be incoming or outgoing i.e. + /// we can send or receive VTXOs. + amount: SignedAmount, + /// An Ark transaction is settled if our outputs in it have been spent. Thus, if we have no + /// _outputs_ in it, it is considered settled too. + is_settled: bool, + created_at: i64, + }, + /// A transaction that offboards VTXOs to an onchain output. + Offboard { + /// The commitment TXID that settles the VTXOs. + commitment_txid: Txid, + /// We use [`Amount`] because offboarding transactions are always outgoing. + amount: Amount, + /// Confirmation time of the commitment transaction. This information must be provided by + /// an external source (e.g., esplora). + confirmed_at: Option, + }, +} + +impl Transaction { + /// The creation time of the [`Transaction`]. This value can be used for sorting. + /// + /// - The creation time of a boarding transaction is based on its confirmation time. If it is + /// pending, we return [`None`]. + /// + /// - The creation time of a commitment transaction is based on the `created_at` of our VTXO + /// produced by it. + /// + /// - The creation time of an Ark transaction is based on the `created_at` of our VTXO produced + /// by it. + /// + /// - The creation time of an offboard transaction is based on its confirmation time. If it is + /// pending, we return [`None`]. + pub fn created_at(&self) -> Option { + match self { + Transaction::Boarding { confirmed_at, .. } + | Transaction::Offboard { confirmed_at, .. } => *confirmed_at, + Transaction::Commitment { created_at, .. } | Transaction::Ark { created_at, .. } => { + Some(*created_at) + } + } + } + + pub fn txid(&self) -> Txid { + match self { + Transaction::Boarding { txid, .. } + | Transaction::Commitment { txid, .. } + | Transaction::Ark { txid, .. } => *txid, + Transaction::Offboard { + commitment_txid, .. + } => *commitment_txid, + } + } +} + +/// Sorts a slice of [`Transaction`] in descending order by creation time. +/// +/// Transactions with no creation time (None) are placed first, followed by transactions +/// sorted by creation time in descending order (newest first). +pub fn sort_transactions_by_created_at(txs: &mut [Transaction]) { + txs.sort_by(|a, b| match (a.created_at(), b.created_at()) { + (None, None) => std::cmp::Ordering::Equal, + (None, Some(_)) => std::cmp::Ordering::Less, + (Some(_), None) => std::cmp::Ordering::Greater, + (Some(a_time), Some(b_time)) => b_time.cmp(&a_time), + }); +} + +/// Generate a list of transactions where we receive VTXOs. +/// +/// This list excludes settlements or transactions where we receive a change VTXO. +pub fn generate_incoming_vtxo_transaction_history( + spent_vtxos: &[VirtualTxOutPoint], + spendable_vtxos: &[VirtualTxOutPoint], + // Commitment transactions which take a boarding output of ours as an input. + boarding_commitment_txs: &[Txid], +) -> Result, Error> { + let mut txs = Vec::new(); + + let all_vtxos = spent_vtxos.iter().chain(spendable_vtxos.iter()); + + let mut spent_vtxos_left_to_check = spent_vtxos.to_vec(); + + // We iterate through every VTXO because all VTXOs were incoming at some point. + for vtxo in all_vtxos { + // Confirmed settlement of boarding output into VTXO => IGNORED. + if !vtxo.is_preconfirmed + && boarding_commitment_txs.contains( + // There should only be one commitment TXID for confirmed VTXOs. + &vtxo.commitment_txids[0], + ) + { + continue; + } + + // An incoming VTXO that deserves an entry in the transaction history is the result of an + // incoming payment. We may receive a VTXO as part of a commitment transaction or through an + // Ark transaction. + + if vtxo.is_preconfirmed { + // We compute how much we spent in that Ark transaction. + let spent_amount = { + let mut spent_amount = Amount::ZERO; + let mut remaining_spent_vtxos = Vec::new(); + for spent_vtxo in spent_vtxos_left_to_check.iter() { + if spent_vtxo.ark_txid == Some(vtxo.outpoint.txid) { + spent_amount += spent_vtxo.amount; + } else { + remaining_spent_vtxos.push(spent_vtxo.clone()); + } + } + + spent_vtxos_left_to_check = remaining_spent_vtxos; + + spent_amount + }; + + let receive_amount = vtxo.amount.to_signed().map_err(Error::ad_hoc)?; + let spent_amount = spent_amount.to_signed().map_err(Error::ad_hoc)?; + + let net_amount = receive_amount - spent_amount; + + // If net amount is zero, it's a self-payment => IGNORED. + // + // If net amount is negative, it's a change VTXO => IGNORED. + if net_amount.is_positive() { + txs.push(Transaction::Ark { + txid: vtxo.outpoint.txid, + amount: net_amount, + is_settled: vtxo.spent_by.is_some() || + // To include settled dust outputs too! + vtxo.settled_by.is_some(), + created_at: vtxo.created_at, + }) + } + } else { + // We compute how much we spent in that batch. + let spent_amount = { + let mut spent_amount = Amount::ZERO; + let mut remaining_spent_vtxos = Vec::new(); + for spent_vtxo in spent_vtxos_left_to_check.iter() { + // There should only be one commitment TXID for confirmed VTXOs. + let commitment_txid = vtxo.commitment_txids[0]; + + if spent_vtxo.settled_by == Some(commitment_txid) { + spent_amount += spent_vtxo.amount; + } else { + remaining_spent_vtxos.push(spent_vtxo.clone()); + } + } + + spent_vtxos_left_to_check = remaining_spent_vtxos; + + spent_amount + }; + + let receive_amount = vtxo.amount.to_signed().map_err(Error::ad_hoc)?; + let spent_amount = spent_amount.to_signed().map_err(Error::ad_hoc)?; + + let net_amount = receive_amount - spent_amount; + + // If net amount received is zero, it's a VTXO being settled => IGNORED. + // + // If net amount received is negative, it's a change VTXO => IGNORED. + if net_amount.is_positive() { + txs.push(Transaction::Commitment { + txid: vtxo.outpoint.txid, + amount: receive_amount, + created_at: vtxo.created_at, + }) + } + } + } + + Ok(txs) +} + +/// Generate a list of outgoing transactions. +/// +/// This includes: +/// - Outgoing Ark transactions (offchain payments) +/// - Offboarding transactions (collaborative redeem to onchain) +/// +/// Pure settlements (VTXO refreshes with no net outflow) are excluded. +/// +/// # Returns +/// +/// An iterator of [`OutgoingTransaction`]s. +/// +/// We do not return a list of [`Transaction`]s directly because some outgoing transactions may need +/// additional data to be constructed: +/// - [`OutgoingTransaction::Incomplete`]: needs a [`VirtualTxOutPoint`] to complete. +/// - [`OutgoingTransaction::IncompleteOffboard`]: needs confirmation data from an external source. +/// +/// # Example +/// +/// ```rust +/// # use ark_core::history::OutgoingTransaction; +/// # use ark_core::history::generate_outgoing_vtxo_transaction_history; +/// # use ark_core::server::VirtualTxOutPoint; +/// # use ark_core::Error; +/// # use bitcoin::OutPoint; +/// # use bitcoin::Txid; +/// # fn fetch_virtual_tx_outpoint(_outpoint: OutPoint) -> Result, Error> { +/// # Ok(None) +/// # } +/// # fn fetch_tx_confirmation_time(_txid: Txid) -> Result, Error> { +/// # Ok(None) +/// # } +/// # +/// # let spent_vtxos = vec![]; +/// # let spendable_vtxos = vec![]; +/// let outgoing_txs = generate_outgoing_vtxo_transaction_history(&spent_vtxos, &spendable_vtxos).unwrap(); +/// +/// let mut complete_outgoing_txs = vec![]; +/// for outgoing_tx in outgoing_txs { +/// match outgoing_tx { +/// OutgoingTransaction::Complete(complete_tx) => { +/// complete_outgoing_txs.push(complete_tx); +/// } +/// OutgoingTransaction::Incomplete(incomplete_tx) => { +/// // Need to fetch additional VTXO data to complete. +/// let virtual_tx_outpoint = fetch_virtual_tx_outpoint(incomplete_tx.first_outpoint()).unwrap(); +/// if let Some(virtual_tx_outpoint) = virtual_tx_outpoint { +/// let complete_tx = incomplete_tx.finish(&virtual_tx_outpoint).unwrap(); +/// complete_outgoing_txs.push(complete_tx); +/// } +/// } +/// OutgoingTransaction::IncompleteOffboard(incomplete_offboard) => { +/// // Need to fetch confirmation time from an external source (e.g., esplora). +/// let confirmed_at = fetch_tx_confirmation_time(incomplete_offboard.commitment_txid()).unwrap(); +/// let complete_tx = incomplete_offboard.finish(confirmed_at); +/// complete_outgoing_txs.push(complete_tx); +/// } +/// } +/// } +/// ``` +pub fn generate_outgoing_vtxo_transaction_history( + spent_vtxos: &[VirtualTxOutPoint], + spendable_vtxos: &[VirtualTxOutPoint], +) -> Result, Error> { + let all_vtxos = [spent_vtxos, spendable_vtxos].concat(); + + // We collect all the transactions where one or more VTXOs of ours are spent. + let mut vtxos_by_spent_by = HashMap::>::new(); + // We collect all the VTXOs that are settled (forfeited) by a commitment transaction. + let mut vtxos_by_settled_by = HashMap::>::new(); + + for spent_vtxo in spent_vtxos.iter() { + if let Some(settled_by) = spent_vtxo.settled_by { + // Track settlements to detect offboarding. + match vtxos_by_settled_by.entry(settled_by) { + Entry::Occupied(mut occupied_entry) => { + occupied_entry.get_mut().push(spent_vtxo.clone()); + } + Entry::Vacant(e) => { + e.insert(vec![spent_vtxo.clone()]); + } + } + } else if let Some(ark_txid) = spent_vtxo.ark_txid { + if spent_vtxo.spent_by.is_some() { + match vtxos_by_spent_by.entry(ark_txid) { + Entry::Occupied(mut occupied_entry) => { + occupied_entry.get_mut().push(spent_vtxo.clone()); + } + Entry::Vacant(e) => { + e.insert(vec![spent_vtxo.clone()]); + } + } + } + } + } + + // An outgoing VTXO that warrants an entry in the transaction history is the input to an + // outgoing payment. We may send a VTXO as part of a commitment transaction or through an Ark + // transaction. + let mut outgoing_txs = Vec::new(); + + // Process regular outgoing transactions (Ark transactions). + for (spend_txid, spent_vtxos) in vtxos_by_spent_by.iter() { + let spent_amount = spent_vtxos + .iter() + .fold(Amount::ZERO, |acc, x| acc + x.amount) + .to_signed() + .map_err(Error::ad_hoc)?; + + let produced_virtual_tx_outpoints = all_vtxos + .iter() + .filter(|v| v.outpoint.txid == *spend_txid) + .collect::>(); + + let produced_amount = produced_virtual_tx_outpoints + .iter() + .fold(Amount::ZERO, |acc, x| acc + x.amount) + .to_signed() + .map_err(Error::ad_hoc)?; + + let net_amount = produced_amount - spent_amount; + + if !net_amount.is_negative() { + // Ignore settlements and self-payments. + continue; + } + + let tx = match produced_virtual_tx_outpoints.first() { + Some(virtual_tx_change_outpoint) => { + OutgoingTransaction::with_change(virtual_tx_change_outpoint, net_amount) + } + None => OutgoingTransaction::without_change(*spend_txid, net_amount), + }; + + outgoing_txs.push(tx); + } + + // Process settlements to detect offboarding transactions. + // + // When VTXOs are settled by a commitment transaction, the inputs may be: + // 1. Refreshed into new VTXOs of equal value (pure settlement) - ignore. + // 2. Partially offboarded with some change VTXO remaining - track the offboarded amount. + // 3. Fully offboarded with no change VTXO - track the entire amount. + // + // NOTE: I believe this may not tell the whole story, but it's good enough for now. + for (commitment_txid, settled_vtxos) in vtxos_by_settled_by.iter() { + let input_amount = settled_vtxos + .iter() + .fold(Amount::ZERO, |acc, x| acc + x.amount) + .to_signed() + .map_err(Error::ad_hoc)?; + + // Find VTXOs that were produced by this settlement (have commitment_txid in their + // commitment_txids). + let produced_vtxos = all_vtxos + .iter() + .filter(|v| v.commitment_txids.contains(commitment_txid)) + .collect::>(); + + let output_amount = produced_vtxos + .iter() + .fold(Amount::ZERO, |acc, x| acc + x.amount) + .to_signed() + .map_err(Error::ad_hoc)?; + + let offboarded_amount = input_amount - output_amount; + + if offboarded_amount.is_positive() { + // Some or all of the input was offboarded onchain. + outgoing_txs.push(OutgoingTransaction::IncompleteOffboard( + IncompleteOffboardTransaction { + commitment_txid: *commitment_txid, + amount: offboarded_amount.to_unsigned().map_err(Error::ad_hoc)?, + }, + )); + } + // If offboarded_amount <= 0, it's a pure settlement (refresh) - ignore. + } + + Ok(OutgoingTransactionIter::new(outgoing_txs)) +} + +/// An outgoing transaction. +/// +/// If the transaction is [`OutgoingTransaction::Complete`], it can be used as is. If the +/// transaction is [`OutgoingTransaction::Incomplete`], you will need to complete it with a +/// [`VirtualTxOutPoint`]. If the transaction is [`OutgoingTransaction::IncompleteOffboard`], you +/// will need to complete it with confirmation data. +/// +/// Refer to [`generate_outgoing_vtxo_transaction_history`] for more info on how to use this type. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum OutgoingTransaction { + Complete(Transaction), + Incomplete(IncompleteOutgoingTransaction), + IncompleteOffboard(IncompleteOffboardTransaction), +} + +impl OutgoingTransaction { + /// Build an outgoing transaction with a change output of ours. + /// + /// With the change [`VirtualTxOutPoint`], we can go ahead and build the corresponding + /// [`Transaction`]. + fn with_change( + virtual_tx_change_outpoint: &VirtualTxOutPoint, + net_amount: SignedAmount, + ) -> Self { + Self::Complete(build_outgoing_transaction( + virtual_tx_change_outpoint, + net_amount, + )) + } + + /// Build outgoing transaction data, without a change output of ours. + /// + /// Without a change output, we need to look for a foreign [`VirtualTxOutPoint`] to be able to + /// build the corresponding [`Transaction`]. + fn without_change(txid: Txid, net_amount: SignedAmount) -> Self { + Self::Incomplete(IncompleteOutgoingTransaction { + first_outpoint: bitcoin::OutPoint { txid, vout: 0 }, + net_amount, + }) + } +} + +/// An outgoing transaction that is missing data about one of its [`VirtualTxOutPoint`]s so that it +/// can be completed. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct IncompleteOutgoingTransaction { + // We take the first one because: + // + // - Any outpoint will work. + // - Every transaction has at least one outpoint. + first_outpoint: bitcoin::OutPoint, + net_amount: SignedAmount, +} + +/// An offboard transaction that is missing confirmation data so that it can be completed. +/// +/// Use [`IncompleteOffboardTransaction::finish`] to complete the transaction with confirmation +/// data from an external source (e.g., esplora). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct IncompleteOffboardTransaction { + commitment_txid: Txid, + amount: Amount, +} + +impl IncompleteOffboardTransaction { + /// The commitment TXID of this offboard transaction. + /// + /// Use this value to query an external source (e.g., esplora) for confirmation data. + pub fn commitment_txid(&self) -> Txid { + self.commitment_txid + } + + /// Transform this incomplete offboard transaction into a [`Transaction`]. + /// + /// # Arguments + /// + /// * `confirmed_at`: The confirmation time of the commitment transaction, or [`None`] if + /// unconfirmed. + pub fn finish(self, confirmed_at: Option) -> Transaction { + Transaction::Offboard { + commitment_txid: self.commitment_txid, + amount: self.amount, + confirmed_at, + } + } +} + +impl IncompleteOutgoingTransaction { + /// The first [`bitcoin::OutPoint`] of this transaction. + /// + /// Use this value to find the corresponding [`VirtualTxOutPoint`], to be able to call + /// [`IncompleteOutgoingTransaction::finish`] and build a [`Transaction`]. + pub fn first_outpoint(&self) -> bitcoin::OutPoint { + self.first_outpoint + } + + /// Transform this incomplete outgoing transaction into a [`Transaction`]. + /// + /// # Arguments + /// + /// * `virtual_tx_outpoint`: a [`VirtualTxOutPoint`]. + /// + /// # Returns + /// + /// A complete [`Transaction`]. + /// + /// # Errors + /// + /// If the TXID of the provided `virtual_tx_outpoint` does not match that of the + /// `first_outpoint` field, we return an error. + pub fn finish(self, virtual_tx_outpoint: &VirtualTxOutPoint) -> Result { + if self.first_outpoint.txid != virtual_tx_outpoint.outpoint.txid { + return Err(Error::ad_hoc(format!( + "cannot finish outgoing transaction with unrelated \ + virtual TX outpoint: expected {}, got {}", + self.first_outpoint.txid, virtual_tx_outpoint.outpoint.txid + ))); + } + + Ok(build_outgoing_transaction( + virtual_tx_outpoint, + self.net_amount, + )) + } +} + +/// An iterator of [`OutgoingTransaction`]s. +struct OutgoingTransactionIter { + inner: std::vec::IntoIter, +} + +impl OutgoingTransactionIter { + /// Build a new iterator of [`OutgoingTransaction`]s. + fn new(txs: Vec) -> Self { + Self { + inner: txs.into_iter(), + } + } +} + +impl Iterator for OutgoingTransactionIter { + type Item = OutgoingTransaction; + + fn next(&mut self) -> Option { + self.inner.next() + } +} + +/// Build an outgoing [`Transaction`]. +fn build_outgoing_transaction( + // A virtual TX outpoint of the outgoing transaction. + vtxo_outpoint: &VirtualTxOutPoint, + // A negative amount representing coins received minus coins sent in the transaction. + net_amount: SignedAmount, +) -> Transaction { + let created_at = vtxo_outpoint.created_at; + match vtxo_outpoint.is_preconfirmed { + true => Transaction::Ark { + txid: vtxo_outpoint.outpoint.txid, + amount: net_amount, + // For a pre-confirmed outgoing Ark transaction, the sender always considers the + // transaction settled. + is_settled: true, + created_at, + }, + false => Transaction::Commitment { + txid: vtxo_outpoint.commitment_txids[0], + amount: net_amount, + created_at, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::OutPoint; + use bitcoin::ScriptBuf; + + // These tests are taken straight from the Go client. + // NOTE: The go tests disappeared when the client was moved to a different repository. + + #[test] + fn alice_before_sending() { + let boarding_commitment_txs = [ + "c16ae0d917ac400790da18456015975521bec6e1d1962ad728c0070808c564e8" + .parse() + .unwrap(), + ]; + + let spendable_vtxos = [VirtualTxOutPoint { + outpoint: OutPoint { + txid: "2646aea682389e1739a33a617d1f3ee28ccc7e4e16210936cece7a823e37527e" + .parse() + .unwrap(), + vout: 0, + }, + created_at: 1730330127, + expires_at: 1730934927, + amount: Amount::from_sat(20_000), + script: ScriptBuf::new(), + is_preconfirmed: false, + is_swept: false, + is_unrolled: false, + is_spent: false, + spent_by: None, + commitment_txids: vec![ + "c16ae0d917ac400790da18456015975521bec6e1d1962ad728c0070808c564e8" + .parse() + .unwrap(), + ], + settled_by: None, + ark_txid: None, + assets: Vec::new(), + depth: 0, + }]; + + let inc_txs = generate_incoming_vtxo_transaction_history( + &[], + &spendable_vtxos, + &boarding_commitment_txs, + ) + .unwrap(); + + let out_txs = generate_outgoing_vtxo_transaction_history(&[], &spendable_vtxos) + .unwrap() + .collect::>(); + + assert!(inc_txs.is_empty()); + assert!(out_txs.is_empty()); + } + + #[test] + fn alice_after_sending() { + let boarding_commitment_txs = [ + "c16ae0d917ac400790da18456015975521bec6e1d1962ad728c0070808c564e8" + .parse() + .unwrap(), + ]; + + let spendable_vtxos = [VirtualTxOutPoint { + outpoint: OutPoint { + txid: "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034" + .parse() + .unwrap(), + vout: 1, + }, + created_at: 1730330256, + expires_at: 1730934927, + amount: Amount::from_sat(18_784), + script: ScriptBuf::new(), + is_preconfirmed: true, + is_swept: false, + is_unrolled: false, + is_spent: false, + spent_by: None, + commitment_txids: vec![ + "c16ae0d917ac400790da18456015975521bec6e1d1962ad728c0070808c564e8" + .parse() + .unwrap(), + ], + settled_by: None, + ark_txid: None, + assets: Vec::new(), + depth: 0, + }]; + + let spent_vtxos = [VirtualTxOutPoint { + outpoint: OutPoint { + txid: "2646aea682389e1739a33a617d1f3ee28ccc7e4e16210936cece7a823e37527e" + .parse() + .unwrap(), + vout: 0, + }, + created_at: 1730330127, + expires_at: 1730934927, + amount: Amount::from_sat(20_000), + script: ScriptBuf::new(), + is_preconfirmed: false, + is_swept: false, + is_unrolled: false, + is_spent: true, + spent_by: Some( + "e3c4f18d0418935db8000c5b8c8fc8d776b5741cd625369eceea9aebb8bcee03" + .parse() + .unwrap(), + ), + commitment_txids: vec![ + "c16ae0d917ac400790da18456015975521bec6e1d1962ad728c0070808c564e8" + .parse() + .unwrap(), + ], + settled_by: None, + ark_txid: Some( + "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034" + .parse() + .unwrap(), + ), + assets: Vec::new(), + depth: 0, + }]; + + let inc_txs = generate_incoming_vtxo_transaction_history( + &spent_vtxos, + &spendable_vtxos, + &boarding_commitment_txs, + ) + .unwrap(); + + let out_txs = generate_outgoing_vtxo_transaction_history(&spent_vtxos, &spendable_vtxos) + .unwrap() + .filter_map(|tx| { + if let OutgoingTransaction::Complete(tx) = tx { + Some(tx) + } else { + None + } + }) + .collect::>(); + + assert!(inc_txs.is_empty()); + + assert_eq!( + out_txs, + [Transaction::Ark { + txid: "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034" + .parse() + .unwrap(), + amount: SignedAmount::from_sat(-1_216), + is_settled: true, + created_at: 1730330256, + }] + ); + } + + #[test] + fn bob_before_settling() { + let spendable_vtxos = [ + VirtualTxOutPoint { + outpoint: OutPoint { + txid: "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034" + .parse() + .unwrap(), + vout: 0, + }, + created_at: 1730330256, + expires_at: 1730934927, + amount: Amount::from_sat(1_000), + script: ScriptBuf::new(), + is_preconfirmed: true, + is_swept: false, + is_unrolled: false, + is_spent: false, + spent_by: None, + commitment_txids: vec![ + "c16ae0d917ac400790da18456015975521bec6e1d1962ad728c0070808c564e8" + .parse() + .unwrap(), + ], + settled_by: None, + ark_txid: None, + assets: Vec::new(), + depth: 0, + }, + VirtualTxOutPoint { + outpoint: OutPoint { + txid: "884d85c0db6b52139c39337d54c1f20cd8c5c0d2e83109d69246a345ccc9d169" + .parse() + .unwrap(), + vout: 0, + }, + created_at: 1730330748, + expires_at: 1730935548, + amount: Amount::from_sat(2_000), + script: ScriptBuf::new(), + is_preconfirmed: true, + is_swept: false, + is_unrolled: false, + is_spent: false, + spent_by: None, + commitment_txids: vec![ + "a4e91c211398e0be0edad322fb74a739b1c77bb82b9e4ea94b0115b8e4dfe645" + .parse() + .unwrap(), + ], + settled_by: None, + ark_txid: None, + assets: Vec::new(), + depth: 0, + }, + ]; + + let spent_vtxos = []; + + let mut inc_txs = + generate_incoming_vtxo_transaction_history(&spent_vtxos, &spendable_vtxos, &[]) + .unwrap(); + + sort_transactions_by_created_at(&mut inc_txs); + + let out_txs = generate_outgoing_vtxo_transaction_history(&spent_vtxos, &spendable_vtxos) + .unwrap() + .collect::>(); + + assert_eq!( + inc_txs, + [ + Transaction::Ark { + txid: "884d85c0db6b52139c39337d54c1f20cd8c5c0d2e83109d69246a345ccc9d169" + .parse() + .unwrap(), + amount: SignedAmount::from_sat(2_000), + is_settled: false, + created_at: 1730330748, + }, + Transaction::Ark { + txid: "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034" + .parse() + .unwrap(), + amount: SignedAmount::from_sat(1_000), + is_settled: false, + created_at: 1730330256, + } + ] + ); + + assert!(out_txs.is_empty()); + } + + #[test] + fn bob_after_settling() { + let spendable_vtxos = [VirtualTxOutPoint { + outpoint: OutPoint { + txid: "d9c95372c0c419fd007005edd54e21dabac0375a37fc5f17c313bc1e5f483af9" + .parse() + .unwrap(), + vout: 0, + }, + created_at: 1730331035, + expires_at: 1730935835, + amount: Amount::from_sat(3_000), + script: ScriptBuf::new(), + is_preconfirmed: false, + is_swept: false, + is_unrolled: false, + is_spent: false, + spent_by: None, + commitment_txids: vec![ + "7fd65ce87e0f9a7af583593d5b0124aabd65c97e05159525d0a98201d6ae95a4" + .parse() + .unwrap(), + ], + settled_by: None, + ark_txid: None, + assets: Vec::new(), + depth: 0, + }]; + + let spent_vtxos = [ + VirtualTxOutPoint { + outpoint: OutPoint { + txid: "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034" + .parse() + .unwrap(), + vout: 0, + }, + created_at: 1730330256, + expires_at: 1730934927, + amount: Amount::from_sat(1_000), + script: ScriptBuf::new(), + is_preconfirmed: true, + is_swept: false, + is_unrolled: false, + is_spent: true, + spent_by: Some( + "c9bdde5595c5479394e805a8c468657cd94ae75a504172e514030b3c549f3646" + .parse() + .unwrap(), + ), + commitment_txids: vec![ + "c16ae0d917ac400790da18456015975521bec6e1d1962ad728c0070808c564e8" + .parse() + .unwrap(), + ], + settled_by: Some( + "7fd65ce87e0f9a7af583593d5b0124aabd65c97e05159525d0a98201d6ae95a4" + .parse() + .unwrap(), + ), + ark_txid: None, + assets: Vec::new(), + depth: 0, + }, + VirtualTxOutPoint { + outpoint: OutPoint { + txid: "884d85c0db6b52139c39337d54c1f20cd8c5c0d2e83109d69246a345ccc9d169" + .parse() + .unwrap(), + vout: 0, + }, + created_at: 1730330748, + expires_at: 1730935548, + amount: Amount::from_sat(2_000), + script: ScriptBuf::new(), + is_preconfirmed: true, + is_swept: false, + is_unrolled: false, + is_spent: true, + spent_by: Some( + "a7c06a495dd145fd95693a5190b26ffa391aa4440c1af26f9ff293166d97d807" + .parse() + .unwrap(), + ), + commitment_txids: vec![ + "a4e91c211398e0be0edad322fb74a739b1c77bb82b9e4ea94b0115b8e4dfe645" + .parse() + .unwrap(), + ], + settled_by: Some( + "7fd65ce87e0f9a7af583593d5b0124aabd65c97e05159525d0a98201d6ae95a4" + .parse() + .unwrap(), + ), + ark_txid: None, + assets: Vec::new(), + depth: 0, + }, + ]; + + let mut inc_txs = + generate_incoming_vtxo_transaction_history(&spent_vtxos, &spendable_vtxos, &[]) + .unwrap(); + + sort_transactions_by_created_at(&mut inc_txs); + + let out_txs = generate_outgoing_vtxo_transaction_history(&spent_vtxos, &spendable_vtxos) + .unwrap() + .collect::>(); + + assert_eq!( + inc_txs, + [ + Transaction::Ark { + txid: "884d85c0db6b52139c39337d54c1f20cd8c5c0d2e83109d69246a345ccc9d169" + .parse() + .unwrap(), + amount: SignedAmount::from_sat(2_000), + is_settled: true, + created_at: 1730330748, + }, + Transaction::Ark { + txid: "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034" + .parse() + .unwrap(), + amount: SignedAmount::from_sat(1_000), + is_settled: true, + created_at: 1730330256, + } + ] + ); + + assert!(out_txs.is_empty()); + } + + #[test] + fn bob_after_sending() { + let spendable_vtxos = [VirtualTxOutPoint { + outpoint: OutPoint { + txid: "c59004f8c468a922216f513ec7d63d9b6a13571af0bacd51910709351d27fe55" + .parse() + .unwrap(), + vout: 1, + }, + created_at: 1730331198, + expires_at: 1730935835, + amount: Amount::from_sat(684), + script: ScriptBuf::new(), + is_preconfirmed: true, + is_swept: false, + is_unrolled: false, + is_spent: false, + spent_by: None, + commitment_txids: vec![ + "7fd65ce87e0f9a7af583593d5b0124aabd65c97e05159525d0a98201d6ae95a4" + .parse() + .unwrap(), + ], + settled_by: None, + ark_txid: None, + assets: Vec::new(), + depth: 0, + }]; + + let spent_vtxos = [ + VirtualTxOutPoint { + outpoint: OutPoint { + txid: "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034" + .parse() + .unwrap(), + vout: 0, + }, + created_at: 1730330256, + expires_at: 1730934927, + amount: Amount::from_sat(1_000), + script: ScriptBuf::new(), + is_preconfirmed: true, + is_swept: false, + is_unrolled: false, + is_spent: true, + spent_by: Some( + "c9bdde5595c5479394e805a8c468657cd94ae75a504172e514030b3c549f3646" + .parse() + .unwrap(), + ), + commitment_txids: vec![ + "c16ae0d917ac400790da18456015975521bec6e1d1962ad728c0070808c564e8" + .parse() + .unwrap(), + ], + settled_by: Some( + "7fd65ce87e0f9a7af583593d5b0124aabd65c97e05159525d0a98201d6ae95a4" + .parse() + .unwrap(), + ), + ark_txid: None, + assets: Vec::new(), + depth: 0, + }, + VirtualTxOutPoint { + outpoint: OutPoint { + txid: "884d85c0db6b52139c39337d54c1f20cd8c5c0d2e83109d69246a345ccc9d169" + .parse() + .unwrap(), + vout: 0, + }, + created_at: 1730330748, + expires_at: 1730935548, + amount: Amount::from_sat(2_000), + script: ScriptBuf::new(), + is_preconfirmed: true, + is_swept: false, + is_unrolled: false, + is_spent: true, + spent_by: Some( + "a7c06a495dd145fd95693a5190b26ffa391aa4440c1af26f9ff293166d97d807" + .parse() + .unwrap(), + ), + commitment_txids: vec![ + "a4e91c211398e0be0edad322fb74a739b1c77bb82b9e4ea94b0115b8e4dfe645" + .parse() + .unwrap(), + ], + settled_by: Some( + "7fd65ce87e0f9a7af583593d5b0124aabd65c97e05159525d0a98201d6ae95a4" + .parse() + .unwrap(), + ), + ark_txid: None, + assets: Vec::new(), + depth: 0, + }, + VirtualTxOutPoint { + outpoint: OutPoint { + txid: "d9c95372c0c419fd007005edd54e21dabac0375a37fc5f17c313bc1e5f483af9" + .parse() + .unwrap(), + vout: 0, + }, + created_at: 1730331035, + expires_at: 1730935835, + amount: Amount::from_sat(3_000), + script: ScriptBuf::new(), + is_preconfirmed: false, + is_swept: false, + is_unrolled: false, + is_spent: true, + spent_by: Some( + "cfcfec99c9767162fc2432fac7cac6240eae2ce344d2d0e1600284399f5dd493" + .parse() + .unwrap(), + ), + commitment_txids: vec![ + "7fd65ce87e0f9a7af583593d5b0124aabd65c97e05159525d0a98201d6ae95a4" + .parse() + .unwrap(), + ], + settled_by: None, + ark_txid: Some( + "c59004f8c468a922216f513ec7d63d9b6a13571af0bacd51910709351d27fe55" + .parse() + .unwrap(), + ), + assets: Vec::new(), + depth: 0, + }, + ]; + + let inc_txs = + generate_incoming_vtxo_transaction_history(&spent_vtxos, &spendable_vtxos, &[]) + .unwrap(); + + let out_txs = generate_outgoing_vtxo_transaction_history(&spent_vtxos, &spendable_vtxos) + .unwrap() + .filter_map(|tx| { + if let OutgoingTransaction::Complete(tx) = tx { + Some(tx) + } else { + None + } + }) + .collect::>(); + + let mut txs = [inc_txs, out_txs].concat(); + sort_transactions_by_created_at(&mut txs); + + assert_eq!( + txs, + [ + Transaction::Ark { + txid: "c59004f8c468a922216f513ec7d63d9b6a13571af0bacd51910709351d27fe55" + .parse() + .unwrap(), + amount: SignedAmount::from_sat(-2_316), + is_settled: true, + created_at: 1730331198, + }, + Transaction::Ark { + txid: "884d85c0db6b52139c39337d54c1f20cd8c5c0d2e83109d69246a345ccc9d169" + .parse() + .unwrap(), + amount: SignedAmount::from_sat(2_000), + is_settled: true, + created_at: 1730330748, + }, + Transaction::Ark { + txid: "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034" + .parse() + .unwrap(), + amount: SignedAmount::from_sat(1_000), + is_settled: true, + created_at: 1730330256, + } + ] + ); + } +} diff --git a/vendor/ark-core/src/intent.rs b/vendor/ark-core/src/intent.rs new file mode 100644 index 0000000..6657dc1 --- /dev/null +++ b/vendor/ark-core/src/intent.rs @@ -0,0 +1,777 @@ +use crate::contract::SpendSelection; +use crate::Asset; +use crate::Error; +use crate::ErrorContext; +use crate::VTXO_CONDITION_KEY; +use crate::VTXO_TAPROOT_KEY; +use bitcoin::absolute; +use bitcoin::base64; +use bitcoin::base64::Engine; +use bitcoin::hashes::sha256; +use bitcoin::hashes::Hash; +use bitcoin::opcodes::all::*; +use bitcoin::psbt; +use bitcoin::psbt::PsbtSighashType; +use bitcoin::secp256k1; +use bitcoin::secp256k1::schnorr; +use bitcoin::secp256k1::PublicKey; +use bitcoin::sighash::Prevouts; +use bitcoin::sighash::SighashCache; +use bitcoin::taproot; +use bitcoin::transaction::Version; +use bitcoin::Amount; +use bitcoin::OutPoint; +use bitcoin::Psbt; +use bitcoin::ScriptBuf; +use bitcoin::Sequence; +use bitcoin::TapLeafHash; +use bitcoin::TapSighashType; +use bitcoin::Transaction; +use bitcoin::TxIn; +use bitcoin::TxOut; +use bitcoin::Txid; +use bitcoin::Witness; +use bitcoin::XOnlyPublicKey; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Debug)] +pub struct Input { + // The TXID of this outpoint is a hash of the TXID of the actual outpoint. + outpoint: OutPoint, + // Related to OP_CSV (such as unilateral exit for all VTXOs). + sequence: Sequence, + // Related to OP_CLTV (such as the timelock in a HTLC). + locktime: absolute::LockTime, + witness_utxo: TxOut, + // We do not serialize this. + tapscripts: Vec, + spend_info: (ScriptBuf, taproot::ControlBlock), + is_onchain: bool, + is_swept: bool, + assets: Vec, + /// Extra witness elements for spending (e.g., preimage for ArkNotes). + /// When set, these are used instead of generating a signature. + extra_witness: Option>>, +} + +impl Input { + pub fn new( + outpoint: OutPoint, + sequence: Sequence, + locktime: Option, + witness_utxo: TxOut, + tapscripts: Vec, + spend_info: (ScriptBuf, taproot::ControlBlock), + is_onchain: bool, + is_swept: bool, + assets: Vec, + ) -> Self { + Self { + outpoint, + sequence, + locktime: locktime.unwrap_or(absolute::LockTime::ZERO), + witness_utxo, + tapscripts, + spend_info, + is_onchain, + is_swept, + assets, + extra_witness: None, + } + } + + pub fn new_with_spend_selection( + outpoint: OutPoint, + default_sequence: Sequence, + witness_utxo: TxOut, + tapscripts: Vec, + spend_selection: SpendSelection, + is_onchain: bool, + is_swept: bool, + assets: Vec, + ) -> Self { + let (sequence, spend_info) = spend_selection.resolved_spend_info(default_sequence); + Self { + outpoint, + sequence, + locktime: spend_selection.locktime.unwrap_or(absolute::LockTime::ZERO), + witness_utxo, + tapscripts, + spend_info, + is_onchain, + is_swept, + assets, + extra_witness: (!spend_selection.extra_witness.is_empty()) + .then_some(spend_selection.extra_witness), + } + } + + /// Create an input with extra witness elements (e.g., for ArkNotes). + pub fn new_with_extra_witness( + outpoint: OutPoint, + sequence: Sequence, + locktime: Option, + witness_utxo: TxOut, + tapscripts: Vec, + spend_info: (ScriptBuf, taproot::ControlBlock), + is_onchain: bool, + is_swept: bool, + assets: Vec, + extra_witness: Vec>, + ) -> Self { + Self { + outpoint, + sequence, + locktime: locktime.unwrap_or(absolute::LockTime::ZERO), + witness_utxo, + tapscripts, + spend_info, + is_onchain, + is_swept, + assets, + extra_witness: Some(extra_witness), + } + } + + pub fn script_pubkey(&self) -> &ScriptBuf { + &self.witness_utxo.script_pubkey + } + + pub fn amount(&self) -> Amount { + self.witness_utxo.value + } + + pub fn spend_info(&self) -> &(ScriptBuf, taproot::ControlBlock) { + &self.spend_info + } + + pub fn outpoint(&self) -> OutPoint { + self.outpoint + } + + pub fn tapscripts(&self) -> &[ScriptBuf] { + &self.tapscripts + } + + pub fn is_swept(&self) -> bool { + self.is_swept + } + + pub fn assets(&self) -> &[Asset] { + &self.assets + } + + pub fn extra_witness(&self) -> Option<&[Vec]> { + self.extra_witness.as_deref() + } +} + +#[derive(Debug, Clone)] +pub enum Output { + /// An output created when boarding. + Offchain(TxOut), + /// An output created when offboarding. + Onchain(TxOut), + /// An auxiliary output that should be copied into the target transaction but is neither an + /// offchain VTXO nor an onchain payout. + AssetPacket(TxOut), +} + +#[derive(Debug, Clone)] +pub struct Intent { + pub proof: Psbt, + message: IntentMessage, +} + +impl Intent { + pub fn new(proof: Psbt, message: IntentMessage) -> Self { + Self { proof, message } + } + + pub fn serialize_proof(&self) -> String { + let base64 = base64::engine::GeneralPurpose::new( + &base64::alphabet::STANDARD, + base64::engine::GeneralPurposeConfig::new(), + ); + + let bytes = self.proof.serialize(); + + base64.encode(&bytes) + } + + pub fn serialize_message(&self) -> Result { + self.message.encode() + } +} + +pub fn make_intent( + sign_for_vtxo_fn: SV, + sign_for_onchain_fn: SO, + inputs: Vec, + outputs: Vec, + message: IntentMessage, +) -> Result +where + SV: Fn( + &mut psbt::Input, + secp256k1::Message, + ) -> Result, Error>, + SO: Fn( + &mut psbt::Input, + secp256k1::Message, + ) -> Result<(schnorr::Signature, XOnlyPublicKey), Error>, +{ + let (mut proof_psbt, fake_input) = build_proof_psbt(&message, &inputs, &outputs)?; + + for (i, proof_input) in proof_psbt.inputs.iter_mut().enumerate() { + if i == 0 { + let (script, control_block) = inputs[0].spend_info.clone(); + + proof_input + .tap_scripts + .insert(control_block, (script, taproot::LeafVersion::TapScript)); + } else { + let (script, control_block) = inputs[i - 1].spend_info.clone(); + + let tap_tree = taptree::TapTree(inputs[i - 1].tapscripts.clone()); + let bytes = tap_tree + .encode() + .map_err(Error::ad_hoc) + .with_context(|| format!("failed to encode taptree for input {i}"))?; + + proof_input.unknown.insert( + psbt::raw::Key { + type_value: 222, + key: VTXO_TAPROOT_KEY.to_vec(), + }, + bytes, + ); + proof_input + .tap_scripts + .insert(control_block, (script, taproot::LeafVersion::TapScript)); + }; + } + + let prevouts = proof_psbt + .inputs + .iter() + .filter_map(|i| i.witness_utxo.clone()) + .collect::>(); + + let inputs = [inputs, vec![fake_input]].concat(); + + for (i, proof_input) in proof_psbt.inputs.iter_mut().enumerate() { + let input = inputs + .iter() + .find(|input| input.outpoint == proof_psbt.unsigned_tx.input[i].previous_output) + .expect("witness utxo"); + + let prevouts = Prevouts::All(&prevouts); + + let (_, (script, leaf_version)) = + proof_input.tap_scripts.first_key_value().expect("a value"); + + let leaf_hash = TapLeafHash::from_script(script, *leaf_version); + + let tap_sighash = SighashCache::new(&proof_psbt.unsigned_tx) + .taproot_script_spend_signature_hash(i, &prevouts, leaf_hash, TapSighashType::Default) + .map_err(Error::crypto) + .with_context(|| format!("failed to compute sighash for proof of funds input {i}"))?; + + let msg = secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array()); + + // Add extra witness data (e.g., preimage for ArkNotes / VHTLCs) if present. + if let Some(extra_witness) = input.extra_witness() { + // Encode the witness elements and add to PSBT unknown field. + // Format: [num_elements] [len1] [elem1] [len2] [elem2] ... + let encoded = encode_witness(extra_witness); + proof_input.unknown.insert( + psbt::raw::Key { + type_value: 222, + key: VTXO_CONDITION_KEY.to_vec(), + }, + encoded, + ); + } + + // Sign for any keys we own in the spend script. + // For scripts that only need extra witness (e.g., ArkNotes with just a preimage hash + // check), sign_for_vtxo_fn will return an empty vec since there are no checksig pubkeys. + let sigs = match input.is_onchain { + true => vec![sign_for_onchain_fn(proof_input, msg)?], + false => sign_for_vtxo_fn(proof_input, msg)?, + }; + + for (sig, pk) in sigs { + let sig = taproot::Signature { + signature: sig, + sighash_type: TapSighashType::Default, + }; + proof_input.tap_script_sigs.insert((pk, leaf_hash), sig); + } + } + + Ok(Intent { + proof: proof_psbt, + message, + }) +} + +pub(crate) fn build_proof_psbt( + message: &IntentMessage, + inputs: &[Input], + outputs: &[Output], +) -> Result<(Psbt, Input), Error> { + if inputs.is_empty() { + return Err(Error::ad_hoc("missing inputs")); + } + + let message = message + .encode() + .map_err(Error::ad_hoc) + .context("failed to encode intent message")?; + + let first_input = inputs[0].clone(); + let script_pubkey = first_input.witness_utxo.script_pubkey.clone(); + + let to_spend_tx = { + let hash = message_hash(message.as_bytes()); + + let script_sig = ScriptBuf::builder() + .push_opcode(OP_PUSHBYTES_0) + .push_slice(hash.as_byte_array()) + .into_script(); + + let output = TxOut { + value: Amount::ZERO, + script_pubkey, + }; + + Transaction { + version: Version::non_standard(0), + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: Txid::all_zeros(), + vout: 0xFFFFFFFF, + }, + script_sig, + sequence: Sequence::ZERO, + witness: Witness::default(), + }], + output: vec![output], + } + }; + + let fake_outpoint = OutPoint { + txid: to_spend_tx.compute_txid(), + vout: 0, + }; + + let to_sign_psbt = { + let mut to_sign_inputs = Vec::with_capacity(inputs.len() + 1); + + to_sign_inputs.push(TxIn { + previous_output: fake_outpoint, + script_sig: ScriptBuf::new(), + sequence: first_input.sequence, + witness: Witness::default(), + }); + + for input in inputs.iter() { + to_sign_inputs.push(TxIn { + previous_output: input.outpoint, + script_sig: ScriptBuf::new(), + sequence: input.sequence, + witness: Witness::default(), + }); + } + + let outputs = match outputs.len() { + 0 => vec![TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new_op_return([]), + }], + _ => outputs + .iter() + .map(|o| match o { + Output::Offchain(txout) + | Output::Onchain(txout) + | Output::AssetPacket(txout) => txout.clone(), + }) + .collect::>(), + }; + + let tx = Transaction { + version: Version::TWO, + lock_time: inputs + .iter() + .map(|i| i.locktime) + .max_by(|a, b| a.to_consensus_u32().cmp(&b.to_consensus_u32())) + .unwrap_or(absolute::LockTime::ZERO), + input: to_sign_inputs, + output: outputs, + }; + + let mut psbt = Psbt::from_unsigned_tx(tx) + .map_err(Error::ad_hoc) + .context("failed to build proof of funds PSBT")?; + + psbt.inputs[0].witness_utxo = Some(to_spend_tx.output[0].clone()); + psbt.inputs[0].sighash_type = Some(PsbtSighashType::from_u32(1)); + psbt.inputs[0].witness_script = Some(inputs[0].spend_info.0.clone()); + + for (i, input) in inputs.iter().enumerate() { + psbt.inputs[i + 1].witness_utxo = Some(input.witness_utxo.clone()); + psbt.inputs[i + 1].sighash_type = Some(PsbtSighashType::from_u32(1)); + psbt.inputs[i + 1].witness_script = Some(input.spend_info.0.clone()); + } + + // BIP-322: PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE lets a co-signer recompute the `to_spend` + // commitment from PSBT-internal data alone. + psbt.unknown.insert( + psbt::raw::Key { + type_value: PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE, + key: Vec::new(), + }, + message.into_bytes(), + ); + + psbt + }; + + let mut first_input_modified = first_input; + first_input_modified.outpoint = fake_outpoint; + + Ok((to_sign_psbt, first_input_modified)) +} + +/// BIP-322 global PSBT field carrying the encoded message being signed. +const PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE: u8 = 0x09; + +fn message_hash(message: &[u8]) -> sha256::Hash { + const TAG: &[u8] = b"ark-intent-proof-message"; + + let hashed_tag = sha256::Hash::hash(TAG); + + let mut v = Vec::new(); + v.extend_from_slice(hashed_tag.as_byte_array()); + v.extend_from_slice(hashed_tag.as_byte_array()); + v.extend_from_slice(message); + + sha256::Hash::hash(&v) +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(tag = "type")] +pub enum IntentMessage { + #[serde(rename = "register")] + Register { + onchain_output_indexes: Vec, + valid_at: u64, + expire_at: u64, + #[serde(rename = "cosigners_public_keys")] + own_cosigner_pks: Vec, + }, + #[serde(rename = "delete")] + Delete { expire_at: u64 }, + #[serde(rename = "estimate-intent-fee")] + EstimateIntentFee { + onchain_output_indexes: Vec, + valid_at: u64, + expire_at: u64, + #[serde(rename = "cosigners_public_keys")] + own_cosigner_pks: Vec, + }, + #[serde(rename = "get-pending-tx")] + GetPendingTx { expire_at: u64 }, +} + +impl IntentMessage { + pub fn encode(&self) -> Result { + serde_json::to_string(self) + .map_err(Error::ad_hoc) + .context("failed to serialize intent message to JSON") + } +} + +/// Encode witness elements in the format used by PSBT condition witness field. +/// +/// Format: [num_elements as varint] [len1 as varint] [elem1] [len2 as varint] [elem2] ... +pub(crate) fn encode_witness(elements: &[Vec]) -> Vec { + let mut result = Vec::new(); + + // Write number of elements as compact size + write_compact_size(&mut result, elements.len() as u64); + + // Write each element with its length prefix + for elem in elements { + write_compact_size(&mut result, elem.len() as u64); + result.extend_from_slice(elem); + } + + result +} + +/// Write a compact size uint (Bitcoin's variable-length integer encoding). +fn write_compact_size(w: &mut Vec, val: u64) { + if val < 253 { + w.push(val as u8); + } else if val < 0x10000 { + w.push(253); + w.extend_from_slice(&(val as u16).to_le_bytes()); + } else if val < 0x100000000 { + w.push(254); + w.extend_from_slice(&(val as u32).to_le_bytes()); + } else { + w.push(255); + w.extend_from_slice(&val.to_le_bytes()); + } +} + +pub(crate) mod taptree { + use bitcoin::ScriptBuf; + use std::io::Write; + use std::io::{self}; + + pub struct TapTree(pub Vec); + + impl TapTree { + pub fn encode(&self) -> io::Result> { + let mut tapscripts_bytes = Vec::new(); + for tapscript in &self.0 { + // write depth (always 1) + tapscripts_bytes.push(1); + + // write leaf version (base leaf version: 0xc0) + tapscripts_bytes.push(0xc0); + + // write script + write_compact_size_uint(&mut tapscripts_bytes, tapscript.len() as u64)?; + tapscripts_bytes.extend(tapscript.as_bytes()); + } + + Ok(tapscripts_bytes) + } + + #[cfg(test)] + pub fn decode(data: &[u8]) -> io::Result { + use std::io::Cursor; + use std::io::Read; + + let mut buf = Cursor::new(data); + let mut leaves = Vec::new(); + + // Read leaves until we run out of data + while buf.position() < data.len() as u64 { + // depth : ignore + let mut depth = [0u8; 1]; + buf.read_exact(&mut depth)?; + + // leaf version : ignore, we assume base tapscript + let mut lv = [0u8; 1]; + buf.read_exact(&mut lv)?; + + // script length + let script_len = read_compact_size_uint(&mut buf)? as usize; + + // script bytes + let mut script_bytes = vec![0u8; script_len]; + buf.read_exact(&mut script_bytes)?; + + leaves.push(ScriptBuf::from_bytes(script_bytes)); + } + + Ok(TapTree(leaves)) + } + } + + // Write compact size uint to writer + fn write_compact_size_uint(w: &mut W, val: u64) -> io::Result<()> { + if val < 253 { + w.write_all(&[val as u8]) + } else if val < 0x10000 { + w.write_all(&[253])?; + w.write_all(&(val as u16).to_le_bytes()) + } else if val < 0x100000000 { + w.write_all(&[254])?; + w.write_all(&(val as u32).to_le_bytes()) + } else { + w.write_all(&[255])?; + w.write_all(&val.to_le_bytes()) + } + } + + #[cfg(test)] + // Read compact size uint from reader + fn read_compact_size_uint(r: &mut R) -> io::Result { + let mut first = [0u8; 1]; + r.read_exact(&mut first)?; + match first[0] { + 253 => { + let mut buf = [0u8; 2]; + r.read_exact(&mut buf)?; + Ok(u16::from_le_bytes(buf) as u64) + } + 254 => { + let mut buf = [0u8; 4]; + r.read_exact(&mut buf)?; + Ok(u32::from_le_bytes(buf) as u64) + } + 255 => { + let mut buf = [0u8; 8]; + r.read_exact(&mut buf)?; + Ok(u64::from_le_bytes(buf)) + } + v => Ok(v as u64), + } + } + + #[cfg(test)] + mod tests { + use super::*; + use bitcoin::opcodes::OP_FALSE; + use bitcoin::opcodes::OP_TRUE; + + #[test] + fn tap_tree_encode_decode_roundtrip() { + let scripts = vec![ScriptBuf::builder().push_opcode(OP_TRUE).into_script()]; + + let tree = TapTree(scripts.clone()); + let encoded = tree.encode().unwrap(); + let decoded = TapTree::decode(&encoded).unwrap(); + assert_eq!(decoded.0, scripts); + } + + #[test] + fn tap_tree_multiple_leaves() { + let scripts = vec![ + ScriptBuf::builder().push_opcode(OP_TRUE).into_script(), + ScriptBuf::builder().push_opcode(OP_FALSE).into_script(), + ]; + let tree = TapTree(scripts.clone()); + let encoded = tree.encode().unwrap(); + let decoded = TapTree::decode(&encoded).unwrap(); + assert_eq!(decoded.0, scripts); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn intent_message_register_serialization() { + let pk = PublicKey::from_str( + "027b763fdd0d6d96d1ce6fb95e09e381fdae2bcbe3ed7d1a2bd95702524d5dcd8a", + ) + .unwrap(); + let msg = IntentMessage::Register { + onchain_output_indexes: vec![], + valid_at: 1762861934, + expire_at: 1762862054, + own_cosigner_pks: vec![pk], + }; + let encoded = msg.encode().unwrap(); + assert_eq!( + encoded, + r#"{"type":"register","onchain_output_indexes":[],"valid_at":1762861934,"expire_at":1762862054,"cosigners_public_keys":["027b763fdd0d6d96d1ce6fb95e09e381fdae2bcbe3ed7d1a2bd95702524d5dcd8a"]}"# + ); + } + + #[test] + fn intent_message_estimate_fee_serialization() { + let pk = PublicKey::from_str( + "027b763fdd0d6d96d1ce6fb95e09e381fdae2bcbe3ed7d1a2bd95702524d5dcd8a", + ) + .unwrap(); + let msg = IntentMessage::EstimateIntentFee { + onchain_output_indexes: vec![], + valid_at: 1762861934, + expire_at: 1762862054, + own_cosigner_pks: vec![pk], + }; + let encoded = msg.encode().unwrap(); + assert_eq!( + encoded, + r#"{"type":"estimate-intent-fee","onchain_output_indexes":[],"valid_at":1762861934,"expire_at":1762862054,"cosigners_public_keys":["027b763fdd0d6d96d1ce6fb95e09e381fdae2bcbe3ed7d1a2bd95702524d5dcd8a"]}"# + ); + } + + #[test] + fn intent_message_delete_serialization() { + let msg = IntentMessage::Delete { + expire_at: 1762862054, + }; + let encoded = msg.encode().unwrap(); + assert_eq!(encoded, r#"{"type":"delete","expire_at":1762862054}"#); + } + + #[test] + fn proof_psbt_carries_the_signed_message() { + use bitcoin::opcodes::OP_TRUE; + use bitcoin::taproot::LeafVersion; + use bitcoin::taproot::TaprootBuilder; + + let secp = secp256k1::Secp256k1::new(); + let internal_key = XOnlyPublicKey::from_str( + "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0", + ) + .unwrap(); + let leaf = ScriptBuf::builder().push_opcode(OP_TRUE).into_script(); + let spend_info = TaprootBuilder::new() + .add_leaf(0, leaf.clone()) + .unwrap() + .finalize(&secp, internal_key) + .unwrap(); + let control_block = spend_info + .control_block(&(leaf.clone(), LeafVersion::TapScript)) + .unwrap(); + + let input = Input::new( + OutPoint::null(), + Sequence::MAX, + None, + TxOut { + value: Amount::from_sat(1_000), + script_pubkey: ScriptBuf::new_p2tr_tweaked(spend_info.output_key()), + }, + vec![leaf.clone()], + (leaf, control_block), + false, + false, + Vec::new(), + ); + let message = IntentMessage::Delete { + expire_at: 1762862054, + }; + + let (psbt, _) = build_proof_psbt(&message, &[input], &[]).unwrap(); + + // The field survives serialization, which is what the server and co-signers receive. + let psbt = Psbt::deserialize(&psbt.serialize()).unwrap(); + let key = psbt::raw::Key { + type_value: 0x09, + key: Vec::new(), + }; + assert_eq!( + psbt.unknown.get(&key), + Some(&message.encode().unwrap().into_bytes()) + ); + } + + #[test] + fn intent_message_get_pending_tx_serialization() { + let msg = IntentMessage::GetPendingTx { + expire_at: 1762862054, + }; + let encoded = msg.encode().unwrap(); + assert_eq!( + encoded, + r#"{"type":"get-pending-tx","expire_at":1762862054}"# + ); + } +} diff --git a/vendor/ark-core/src/introspector.rs b/vendor/ark-core/src/introspector.rs new file mode 100644 index 0000000..edb43db --- /dev/null +++ b/vendor/ark-core/src/introspector.rs @@ -0,0 +1 @@ +pub mod packet; diff --git a/vendor/ark-core/src/introspector/packet.rs b/vendor/ark-core/src/introspector/packet.rs new file mode 100644 index 0000000..ae39eaa --- /dev/null +++ b/vendor/ark-core/src/introspector/packet.rs @@ -0,0 +1,412 @@ +use crate::extension; +use bitcoin::consensus::encode::Decodable; +use bitcoin::consensus::encode::Encodable; +use bitcoin::consensus::encode::{self}; +use bitcoin::io; +use bitcoin::ScriptBuf; +use bitcoin::Transaction; +use bitcoin::TxOut; +use bitcoin::VarInt; +use bitcoin::Witness; +use std::collections::BTreeSet; +use std::io::Cursor; +use std::io::Read; + +const PACKET_TYPE: u8 = 0x01; +const MAX_ENTRY_COUNT: usize = 1_000; +const MAX_SCRIPT_LENGTH: usize = 10_000; +const MAX_WITNESS_LENGTH: usize = 1_000_000; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IntrospectorEntry { + pub vin: u16, + pub script: ScriptBuf, + pub witness: Witness, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Packet { + pub entries: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum PacketError { + #[error("empty packet")] + EmptyPacket, + #[error("max introspector entry count exceeded, max={max} got={got}")] + EntryCountExceeded { max: usize, got: usize }, + #[error("empty script at entry {0}")] + EmptyScript(usize), + #[error("duplicate vin {vin} at entry {entry}")] + DuplicateVin { vin: u16, entry: usize }, + #[error("max introspector script length exceeded, max={max} got={got}")] + ScriptLengthExceeded { max: usize, got: usize }, + #[error("max introspector witness length exceeded, max={max} got={got}")] + WitnessLengthExceeded { max: usize, got: usize }, + #[error("failed to encode packet: {0}")] + Encode(io::Error), + #[error("failed to decode witness: {0}")] + WitnessDecode(encode::Error), + #[error("failed to decode packet: {0}")] + Decode(encode::Error), + #[error("failed to read packet: {0}")] + Read(std::io::Error), + #[error("introspector payload length overflows")] + PayloadLengthOverflow, + #[error("truncated introspector payload, expected {expected} bytes got {got}")] + TruncatedPayload { expected: usize, got: usize }, + #[error("unexpected {0} trailing bytes")] + TrailingBytes(usize), + #[error("failed to process extension packet: {0}")] + Extension(#[from] extension::ExtensionError), +} + +impl Packet { + pub fn new(entries: Vec) -> Result { + let packet = Self { entries }; + packet.validate()?; + Ok(packet) + } + + pub fn validate(&self) -> Result<(), PacketError> { + if self.entries.is_empty() { + return Err(PacketError::EmptyPacket); + } + + if self.entries.len() > MAX_ENTRY_COUNT { + return Err(PacketError::EntryCountExceeded { + max: MAX_ENTRY_COUNT, + got: self.entries.len(), + }); + } + + let mut seen = BTreeSet::new(); + for (index, entry) in self.entries.iter().enumerate() { + if entry.script.is_empty() { + return Err(PacketError::EmptyScript(index)); + } + + let script_len = entry.script.as_bytes().len(); + if script_len > MAX_SCRIPT_LENGTH { + return Err(PacketError::ScriptLengthExceeded { + max: MAX_SCRIPT_LENGTH, + got: script_len, + }); + } + + if !seen.insert(entry.vin) { + return Err(PacketError::DuplicateVin { + vin: entry.vin, + entry: index, + }); + } + } + + Ok(()) + } + + pub fn encode(&self) -> Result, PacketError> { + self.validate()?; + + let mut bytes = Vec::new(); + VarInt(self.entries.len() as u64) + .consensus_encode(&mut bytes) + .map_err(PacketError::Encode)?; + + for entry in &self.entries { + bytes.extend_from_slice(&entry.vin.to_le_bytes()); + + let script = entry.script.as_bytes(); + VarInt(script.len() as u64) + .consensus_encode(&mut bytes) + .map_err(PacketError::Encode)?; + bytes.extend_from_slice(script); + + let witness = encode::serialize(&entry.witness); + if witness.len() > MAX_WITNESS_LENGTH { + return Err(PacketError::WitnessLengthExceeded { + max: MAX_WITNESS_LENGTH, + got: witness.len(), + }); + } + VarInt(witness.len() as u64) + .consensus_encode(&mut bytes) + .map_err(PacketError::Encode)?; + bytes.extend_from_slice(&witness); + } + + Ok(bytes) + } + + pub fn decode(data: &[u8]) -> Result { + let mut reader = Cursor::new(data); + let entry_count = VarInt::consensus_decode(&mut reader) + .map_err(PacketError::Decode)? + .0 as usize; + + if entry_count > MAX_ENTRY_COUNT { + return Err(PacketError::EntryCountExceeded { + max: MAX_ENTRY_COUNT, + got: entry_count, + }); + } + + let mut entries = Vec::with_capacity(entry_count); + for _ in 0..entry_count { + let mut vin = [0_u8; 2]; + reader.read_exact(&mut vin).map_err(PacketError::Read)?; + let vin = u16::from_le_bytes(vin); + + let script_len = VarInt::consensus_decode(&mut reader) + .map_err(PacketError::Decode)? + .0 as usize; + if script_len > MAX_SCRIPT_LENGTH { + return Err(PacketError::ScriptLengthExceeded { + max: MAX_SCRIPT_LENGTH, + got: script_len, + }); + } + let mut script = vec![0_u8; script_len]; + reader.read_exact(&mut script).map_err(PacketError::Read)?; + + let witness_len = VarInt::consensus_decode(&mut reader) + .map_err(PacketError::Decode)? + .0 as usize; + if witness_len > MAX_WITNESS_LENGTH { + return Err(PacketError::WitnessLengthExceeded { + max: MAX_WITNESS_LENGTH, + got: witness_len, + }); + } + let mut witness_bytes = vec![0_u8; witness_len]; + reader + .read_exact(&mut witness_bytes) + .map_err(PacketError::Read)?; + let mut witness_reader = witness_bytes.as_slice(); + let witness = Witness::consensus_decode(&mut witness_reader) + .map_err(PacketError::WitnessDecode)?; + if !witness_reader.is_empty() { + return Err(PacketError::TrailingBytes(witness_reader.len())); + } + + entries.push(IntrospectorEntry { + vin, + script: ScriptBuf::from_bytes(script), + witness, + }); + } + + let remaining = data.len() - reader.position() as usize; + if remaining != 0 { + return Err(PacketError::TrailingBytes(remaining)); + } + + Self::new(entries) + } + + pub fn to_txout(&self) -> Result { + let packet = self.encode()?; + + Ok(extension::packet_txout(PACKET_TYPE, &packet)) + } +} + +/// Add an introspector packet OP_RETURN output to a PSBT. +/// +/// If the PSBT already has outputs, this inserts the packet before the last +/// output. Arkade transactions are expected to keep the anchor output last, so +/// callers must only use this helper with PSBTs that either have no outputs or +/// whose final output is the anchor. +pub fn add_packet_to_psbt(psbt: &mut bitcoin::Psbt, packet: &Packet) -> Result<(), PacketError> { + let packet = packet.encode()?; + + extension::add_packet_to_psbt(psbt, PACKET_TYPE, &packet)?; + + Ok(()) +} + +pub fn find_packet(tx: &Transaction) -> Result, PacketError> { + let Some(payload) = extension::find_packet_payload(tx, PACKET_TYPE)? else { + return Ok(None); + }; + + Packet::decode(payload).map(Some) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::absolute; + use bitcoin::hex::DisplayHex; + use bitcoin::hex::FromHex; + use bitcoin::script::PushBytesBuf; + use bitcoin::transaction; + use bitcoin::Amount; + use bitcoin::TxIn; + + fn witness(items: &[&str]) -> Witness { + Witness::from_slice( + &items + .iter() + .map(|item| Vec::from_hex(item).unwrap()) + .collect::>(), + ) + } + + fn tx_with_op_return_payload(payload: Vec) -> Transaction { + let push_bytes = PushBytesBuf::try_from(payload).unwrap(); + Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![], + output: vec![TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::builder() + .push_opcode(bitcoin::opcodes::all::OP_RETURN) + .push_slice(push_bytes) + .into_script(), + }], + } + } + + #[test] + fn matches_go_vectors() { + let packet = Packet::new(vec![IntrospectorEntry { + vin: 0, + script: ScriptBuf::from_bytes(Vec::from_hex("010203").unwrap()), + witness: witness(&["0405"]), + }]) + .unwrap(); + + assert_eq!( + packet.encode().unwrap().to_lower_hex_string(), + "010000030102030401020405" + ); + + let packet = Packet::new(vec![ + IntrospectorEntry { + vin: 0, + script: ScriptBuf::from_bytes(Vec::from_hex("01").unwrap()), + witness: witness(&["02"]), + }, + IntrospectorEntry { + vin: 1, + script: ScriptBuf::from_bytes(Vec::from_hex("0304").unwrap()), + witness: witness(&["05", "06"]), + }, + IntrospectorEntry { + vin: 5, + script: ScriptBuf::from_bytes(Vec::from_hex("07").unwrap()), + witness: Witness::default(), + }, + ]) + .unwrap(); + + assert_eq!( + packet.encode().unwrap().to_lower_hex_string(), + "0300000101030101020100020304050201050106050001070100" + ); + } + + #[test] + fn decode_rejects_invalid_packets() { + assert!(matches!(Packet::new(vec![]), Err(PacketError::EmptyPacket))); + assert!(matches!( + Packet::decode(&Vec::from_hex("0000000101ff").unwrap()), + Err(PacketError::TrailingBytes(5)) + )); + assert!(matches!( + Packet::decode(&Vec::from_hex("010000fd1127").unwrap()), + Err(PacketError::ScriptLengthExceeded { .. }) + )); + assert!(matches!( + Packet::decode(&Vec::from_hex("01000001510200ff").unwrap()), + Err(PacketError::TrailingBytes(1)) + )); + } + + #[test] + fn find_packet_rejects_invalid_extension_payload_lengths() { + let tx = tx_with_op_return_payload(Vec::from_hex("41524b010200").unwrap()); + assert!(matches!( + find_packet(&tx), + Err(PacketError::Extension( + extension::ExtensionError::TruncatedPacketPayload { + expected: 7, + got: 6, + } + )) + )); + + let tx = tx_with_op_return_payload(Vec::from_hex("41524b010100ff").unwrap()); + assert!(matches!( + find_packet(&tx), + Err(PacketError::Extension( + extension::ExtensionError::TruncatedPacketLength + )) + )); + + let tx = tx_with_op_return_payload(Vec::from_hex("41524b01ffffffffffffffffff").unwrap()); + assert!(matches!( + find_packet(&tx), + Err(PacketError::Extension( + extension::ExtensionError::TruncatedPacketLength + )) + )); + } + + #[test] + fn add_and_find_packet() { + let packet = Packet::new(vec![IntrospectorEntry { + vin: 0, + script: ScriptBuf::from_bytes(Vec::from_hex("51").unwrap()), + witness: Witness::default(), + }]) + .unwrap(); + + let mut psbt = bitcoin::Psbt::from_unsigned_tx(Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![ + TxOut { + value: Amount::from_sat(1_000), + script_pubkey: ScriptBuf::new(), + }, + TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::new(), + }, + ], + }) + .unwrap(); + + add_packet_to_psbt(&mut psbt, &packet).unwrap(); + assert_eq!(psbt.unsigned_tx.output.len(), 3); + + let found = find_packet(&psbt.unsigned_tx).unwrap().unwrap(); + assert_eq!(found, packet); + } + + #[test] + fn duplicate_vins_are_rejected() { + let err = Packet::new(vec![ + IntrospectorEntry { + vin: 1, + script: ScriptBuf::from_bytes(vec![0x51]), + witness: Witness::default(), + }, + IntrospectorEntry { + vin: 1, + script: ScriptBuf::from_bytes(vec![0x52]), + witness: Witness::default(), + }, + ]) + .unwrap_err(); + + assert!(matches!( + err, + PacketError::DuplicateVin { vin: 1, entry: 1 } + )); + } +} diff --git a/vendor/ark-core/src/lib.rs b/vendor/ark-core/src/lib.rs new file mode 100644 index 0000000..bb937cd --- /dev/null +++ b/vendor/ark-core/src/lib.rs @@ -0,0 +1,141 @@ +use bitcoin::relative; +use bitcoin::Amount; +use bitcoin::OutPoint; +use bitcoin::ScriptBuf; +use bitcoin::TxOut; +use std::time::Duration; + +pub mod arknote; +pub mod asset; +pub mod batch; +pub mod boarding_output; +pub mod coin_select; +pub mod contract; +pub mod conversions; +pub mod extension; +pub mod history; +pub mod intent; +pub mod introspector; +pub mod script; +pub mod send; +pub mod server; +pub mod unilateral_exit; +pub mod vhtlc; +pub mod vtxo; + +mod ark_address; +mod error; +mod tree_tx_output_script; +mod tx_graph; +mod vtxo_list; + +pub use ark_address::ArkAddress; +pub use arknote::ArkNote; +pub use boarding_output::BoardingOutput; +pub use error::Error; +pub use error::ErrorContext; +pub use script::extract_sequence_from_csv_sig_script; +pub use server::Asset; +pub use server::AssetInfo; +pub use tx_graph::TxGraph; +pub use tx_graph::TxGraphChunk; +pub use unilateral_exit::build_anchor_tx; +pub use unilateral_exit::build_unilateral_exit_tree_txids; +pub use unilateral_exit::SelectedUtxo; +pub use unilateral_exit::UtxoCoinSelection; +pub use vtxo::Vtxo; +pub use vtxo_list::VtxoList; + +pub const UNSPENDABLE_KEY: &str = + "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"; + +pub const VTXO_INPUT_INDEX: usize = 0; + +/// The byte value corresponds to the string "taptree". +pub const VTXO_TAPROOT_KEY: [u8; 7] = [116, 97, 112, 116, 114, 101, 101]; + +/// The byte value corresponds to the string "condition". +pub const VTXO_CONDITION_KEY: [u8; 9] = [99, 111, 110, 100, 105, 116, 105, 111, 110]; + +/// The byte value corresponds to the string "expiry". +pub const VTXO_TREE_EXPIRY_PSBT_KEY: [u8; 6] = [101, 120, 112, 105, 114, 121]; + +/// The cosigner PKs that sign a VTXO TX input are included in the `unknown` key-value map field of +/// that input in the VTXO PSBT. Since the `unknown` field can be used for any purpose, we know that +/// a value is a cosigner PK if the corresponding key starts with this prefix. +/// +/// The byte value corresponds to the string "cosigner". +pub const VTXO_COSIGNER_PSBT_KEY: [u8; 8] = [99, 111, 115, 105, 103, 110, 101, 114]; + +pub const DEFAULT_DERIVATION_PATH: &str = "m/83696968'/11811'/0"; + +/// Mainnet's original unilateral-exit delay (~7 days, in seconds). +/// +/// arkd currently advertises only the active exit delay in `/info`. Clients that need to discover +/// historical scripts can probe this legacy mainnet delay alongside the advertised one. +pub const MAINNET_LEGACY_UNILATERAL_EXIT_DELAY_SECS: u32 = 605_184; + +/// Candidate exit-delay set for discovery/watch. +/// +/// Returns `current` plus, on mainnet only, the hardcoded legacy delay used by older outputs. The +/// result is deduplicated, so if `current` already equals the legacy value it appears only once. +pub fn candidate_exit_delays( + current: bitcoin::Sequence, + network: bitcoin::Network, +) -> Result, Error> { + let mut delays = vec![current]; + + if network == bitcoin::Network::Bitcoin { + let legacy = + bitcoin::Sequence::from_seconds_ceil(MAINNET_LEGACY_UNILATERAL_EXIT_DELAY_SECS) + .map_err(Error::ad_hoc)?; + if !delays.contains(&legacy) { + delays.push(legacy); + } + } + + Ok(delays) +} + +const ANCHOR_SCRIPT_PUBKEY: [u8; 4] = [0x51, 0x02, 0x4e, 0x73]; + +/// Information a UTXO that may be extracted from an on-chain explorer. +#[derive(Clone, Copy, Debug)] +pub struct ExplorerUtxo { + pub outpoint: OutPoint, + pub amount: Amount, + pub confirmation_blocktime: Option, + pub confirmations: u64, + pub is_spent: bool, +} + +pub fn anchor_output() -> TxOut { + let script_pubkey = ScriptBuf::from_bytes(ANCHOR_SCRIPT_PUBKEY.to_vec()); + + TxOut { + value: Amount::ZERO, + script_pubkey, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum ExitDelayKind { + Time(Duration), + Blocks(u64), +} + +impl ExitDelayKind { + pub(crate) fn from_sequence(sequence: bitcoin::Sequence) -> Result { + let kind = match sequence + .to_relative_lock_time() + .ok_or_else(|| Error::ad_hoc("exit delay is not a relative locktime"))? + { + relative::LockTime::Time(time) => { + Self::Time(Duration::from_secs(time.value() as u64 * 512)) + } + relative::LockTime::Blocks(height) => Self::Blocks(height.value() as u64), + }; + + Ok(kind) + } +} diff --git a/vendor/ark-core/src/script.rs b/vendor/ark-core/src/script.rs new file mode 100644 index 0000000..4ab33df --- /dev/null +++ b/vendor/ark-core/src/script.rs @@ -0,0 +1,263 @@ +use bitcoin::hashes::sha256; +use bitcoin::hashes::Hash; +use bitcoin::opcodes::all::*; +use bitcoin::script::Instruction; +use bitcoin::taproot::TaprootSpendInfo; +use bitcoin::ScriptBuf; +use bitcoin::XOnlyPublicKey; +use std::fmt; + +/// A hash-lock script for ArkNotes. +/// +/// The script checks that `SHA256(witness) == preimage_hash`. +/// Anyone who knows the preimage can spend by providing it as the witness. +pub fn arknote_script(preimage_hash: &sha256::Hash) -> ScriptBuf { + ScriptBuf::builder() + .push_opcode(OP_SHA256) + .push_slice(preimage_hash.as_byte_array()) + .push_opcode(OP_EQUAL) + .into_script() +} + +/// A conventional 2-of-2 multisignature [`ScriptBuf`]. +pub fn multisig_script(pk_0: XOnlyPublicKey, pk_1: XOnlyPublicKey) -> ScriptBuf { + ScriptBuf::builder() + .push_x_only_key(&pk_0) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&pk_1) + .push_opcode(OP_CHECKSIG) + .into_script() +} + +/// A 3-of-3 multisignature [`ScriptBuf`]. +/// +/// All three parties must sign to spend: ` CHECKSIGVERIFY CHECKSIGVERIFY +/// CHECKSIG`. +pub fn multisig_3_of_3_script( + pk_0: XOnlyPublicKey, + pk_1: XOnlyPublicKey, + pk_2: XOnlyPublicKey, +) -> ScriptBuf { + ScriptBuf::builder() + .push_x_only_key(&pk_0) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&pk_1) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&pk_2) + .push_opcode(OP_CHECKSIG) + .into_script() +} + +/// A [`ScriptBuf`] allowing the owner of `pk` to spend after `locktime_seconds` have passed from +/// the time the corresponding output was included in a block. +// TODO: Should support multisig. +pub fn csv_sig_script(locktime: bitcoin::Sequence, pk: XOnlyPublicKey) -> ScriptBuf { + ScriptBuf::builder() + .push_int(locktime.to_consensus_u32() as i64) + .push_opcode(OP_CSV) + .push_opcode(OP_DROP) + .push_x_only_key(&pk) + .push_opcode(OP_CHECKSIG) + .into_script() +} + +/// The script pubkey for the Taproot output corresponding to the given [`TaprootSpendInfo`]. +pub fn tr_script_pubkey(spend_info: &TaprootSpendInfo) -> ScriptBuf { + let output_key = spend_info.output_key(); + let builder = bitcoin::blockdata::script::Builder::new(); + builder + .push_opcode(OP_PUSHNUM_1) + .push_slice(output_key.serialize()) + .into_script() +} + +/// Extracts all [`XOnlyPublicKey`]s from checksig patterns in the script. +/// +/// Finds all 32-byte data pushes that are immediately followed by +/// [`OP_CHECKSIG`] or [`OP_CHECKSIGVERIFY`] opcodes. +/// +/// Returns an empty vector if no matching keys are found. +pub fn extract_checksig_pubkeys(script: &ScriptBuf) -> Vec { + let instructions: Vec<_> = script.instructions().filter_map(|inst| inst.ok()).collect(); + + let mut pubkeys = Vec::new(); + + for window in instructions.windows(2) { + let (push, checksig) = (&window[0], &window[1]); + + // Check if we have a 32-byte push followed by CHECKSIG or CHECKSIGVERIFY + if let Instruction::PushBytes(bytes) = push { + if bytes.len() != 32 { + continue; + } + + let is_checksig = matches!( + checksig, + Instruction::Op(op) if *op == OP_CHECKSIG || *op == OP_CHECKSIGVERIFY + ); + + if let Ok(pk) = XOnlyPublicKey::from_slice(bytes.as_bytes()) { + if is_checksig { + pubkeys.push(pk); + } + } + } + } + + pubkeys +} + +pub fn extract_sequence_from_csv_sig_script( + script: &ScriptBuf, +) -> Result { + let csv_index = script + .to_bytes() + .windows(2) + .position(|window| *window == [OP_CSV.to_u8(), OP_DROP.to_u8()]) + .ok_or(InvalidCsvSigScriptError)?; + + let before_csv = &script.to_bytes()[..csv_index]; + + // It is either `OP_PUSHNUM_X` (a single byte) or `OP_PUSH_BYTES_X BYTES` (more than one + // byte). + let sequence = if before_csv.len() > 1 { + &before_csv[1..] + } else { + before_csv + }; + + let mut sequence = sequence.to_vec(); + sequence.reverse(); + + let mut buffer = [0u8; 4]; + let input_len = sequence.len(); + let start_index = 4 - input_len; // calculate how many spaces to leave at the front + + buffer[start_index..].copy_from_slice(&sequence); + + let sequence = u32::from_be_bytes(buffer); + + let sequence = bitcoin::Sequence::from_consensus(sequence); + + Ok(sequence) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InvalidCsvSigScriptError; + +impl fmt::Display for InvalidCsvSigScriptError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("invalid CSV-Sig script") + } +} + +impl std::error::Error for InvalidCsvSigScriptError {} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::locktime; + use bitcoin::XOnlyPublicKey; + use std::str::FromStr; + + #[test] + fn test_extract_sequence_from_csv_sig_script() { + // Equivalent to two 512-second intervals. + let locktime_seconds = 1024; + let sequence = bitcoin::Sequence::from_seconds_ceil(locktime_seconds).unwrap(); + + let pk = XOnlyPublicKey::from_str( + "18845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", + ) + .unwrap(); + + let script = csv_sig_script(sequence, pk); + + let parsed = extract_sequence_from_csv_sig_script(&script).unwrap(); + let parsed = parsed.to_relative_lock_time(); + + assert_eq!( + parsed, + locktime::relative::LockTime::from_512_second_intervals(2).into() + ); + } + + #[test] + fn test_multisig_3_of_3_script() { + let pk_0 = XOnlyPublicKey::from_str( + "18845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", + ) + .unwrap(); + let pk_1 = XOnlyPublicKey::from_str( + "28845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", + ) + .unwrap(); + let pk_2 = XOnlyPublicKey::from_str( + "38845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", + ) + .unwrap(); + + let script = multisig_3_of_3_script(pk_0, pk_1, pk_2); + let pubkeys = extract_checksig_pubkeys(&script); + + assert_eq!(pubkeys.len(), 3); + assert_eq!(pubkeys[0], pk_0); + assert_eq!(pubkeys[1], pk_1); + assert_eq!(pubkeys[2], pk_2); + } + + #[test] + fn test_extract_checksig_pubkeys_from_multisig() { + let pk_0 = XOnlyPublicKey::from_str( + "18845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", + ) + .unwrap(); + let pk_1 = XOnlyPublicKey::from_str( + "28845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", + ) + .unwrap(); + + let script = multisig_script(pk_0, pk_1); + let pubkeys = extract_checksig_pubkeys(&script); + + assert_eq!(pubkeys.len(), 2); + assert_eq!(pubkeys[0], pk_0); + assert_eq!(pubkeys[1], pk_1); + } + + #[test] + fn test_extract_checksig_pubkeys_from_csv_sig() { + let pk = XOnlyPublicKey::from_str( + "18845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", + ) + .unwrap(); + let sequence = bitcoin::Sequence::from_seconds_ceil(1024).unwrap(); + + let script = csv_sig_script(sequence, pk); + let pubkeys = extract_checksig_pubkeys(&script); + + assert_eq!(pubkeys.len(), 1); + assert_eq!(pubkeys[0], pk); + } + + #[test] + fn test_extract_checksig_pubkeys_empty_script() { + let script = ScriptBuf::new(); + let pubkeys = extract_checksig_pubkeys(&script); + + assert!(pubkeys.is_empty()); + } + + #[test] + fn test_extract_checksig_pubkeys_no_checksig() { + // Script with only OP_DROP and OP_RETURN, no checksig + let script = ScriptBuf::builder() + .push_opcode(OP_DROP) + .push_opcode(OP_RETURN) + .into_script(); + + let pubkeys = extract_checksig_pubkeys(&script); + + assert!(pubkeys.is_empty()); + } +} diff --git a/vendor/ark-core/src/send.rs b/vendor/ark-core/src/send.rs new file mode 100644 index 0000000..479464c --- /dev/null +++ b/vendor/ark-core/src/send.rs @@ -0,0 +1,1360 @@ +use crate::anchor_output; +use crate::asset; +use crate::asset::packet::add_asset_packet_to_psbt; +use crate::asset::AssetId; +use crate::contract::SpendSelection; +use crate::script::tr_script_pubkey; +use crate::server; +use crate::ArkAddress; +use crate::Asset; +use crate::Error; +use crate::ErrorContext; +use crate::UNSPENDABLE_KEY; +use crate::VTXO_TAPROOT_KEY; +use bitcoin::absolute::LockTime; +use bitcoin::hashes::Hash; +use bitcoin::key::PublicKey; +use bitcoin::key::Secp256k1; +use bitcoin::psbt; +use bitcoin::secp256k1; +use bitcoin::secp256k1::schnorr; +use bitcoin::sighash::Prevouts; +use bitcoin::sighash::SighashCache; +use bitcoin::taproot; +use bitcoin::taproot::ControlBlock; +use bitcoin::taproot::LeafVersion; +use bitcoin::taproot::TaprootBuilder; +use bitcoin::taproot::TaprootSpendInfo; +use bitcoin::transaction; +use bitcoin::Amount; +use bitcoin::OutPoint; +use bitcoin::Psbt; +use bitcoin::ScriptBuf; +use bitcoin::TapLeafHash; +use bitcoin::TapSighashType; +use bitcoin::Transaction; +use bitcoin::TxIn; +use bitcoin::TxOut; +use bitcoin::XOnlyPublicKey; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::io; +use std::io::Write; + +pub mod issue_asset; +pub mod reissue_asset; + +pub use issue_asset::build_self_asset_issuance_transactions; +pub use issue_asset::SelfAssetIssuanceTransactions; +pub use reissue_asset::build_asset_reissuance_transactions; +pub use reissue_asset::AssetReissuanceTransactions; + +/// A VTXO to be spent into a pre-confirmed VTXO. +#[derive(Debug, Clone)] +pub struct VtxoInput { + /// The script path that will be used to spend the [`Vtxo`]. + /// + /// The very same spend path is also used when building the corresponding checkpoint output. + spend_script: ScriptBuf, + /// An optional locktime, only set if the `spend_script` uses `OP_CLTV`. + // TODO: Parse this information from the script instead. + locktime: Option, + control_block: ControlBlock, + /// All the scripts in the Taproot tree. + tapscripts: Vec, + script_pubkey: ScriptBuf, + /// The amount of coins locked in the VTXO. + amount: Amount, + /// Where the VTXO would end up on the blockchain if it were to become a UTXO. + outpoint: OutPoint, + /// All the assets carried by this VTXO. + assets: Vec, +} + +impl VtxoInput { + pub fn new( + vtxo_spend_script: ScriptBuf, + locktime: Option, + control_block: ControlBlock, + tapscripts: Vec, + script_pubkey: ScriptBuf, + amount: Amount, + outpoint: OutPoint, + assets: Vec, + ) -> Self { + Self { + spend_script: vtxo_spend_script, + locktime, + control_block, + tapscripts, + script_pubkey, + amount, + outpoint, + assets, + } + } + + pub fn new_with_spend_selection( + spend_selection: SpendSelection, + tapscripts: Vec, + script_pubkey: ScriptBuf, + amount: Amount, + outpoint: OutPoint, + assets: Vec, + ) -> Self { + let (spend_script, control_block) = spend_selection.spend_info(); + Self::new( + spend_script, + spend_selection.locktime, + control_block, + tapscripts, + script_pubkey, + amount, + outpoint, + assets, + ) + } + + pub fn outpoint(&self) -> OutPoint { + self.outpoint + } + + pub fn spend_info(&self) -> (&ScriptBuf, &ControlBlock) { + (&self.spend_script, &self.control_block) + } + + pub fn script_pubkey(&self) -> ScriptBuf { + self.script_pubkey.clone() + } + + pub fn amount(&self) -> Amount { + self.amount + } + + pub fn assets(&self) -> &[Asset] { + &self.assets + } +} + +/// A receiver for a generic offchain send with optional assets. +#[derive(Debug, Clone)] +pub struct SendReceiver { + pub address: ArkAddress, + pub amount: Amount, + pub assets: Vec, +} + +impl SendReceiver { + pub fn bitcoin(address: ArkAddress, amount: Amount) -> Self { + Self { + address, + amount, + assets: Vec::new(), + } + } +} + +#[derive(Debug, Clone)] +pub struct OffchainTransactions { + pub ark_tx: Psbt, + pub checkpoint_txs: Vec, +} + +/// Build a transaction to send VTXOs to another [`ArkAddress`]. +pub(crate) fn btc_change_output_index(ark_tx: &Psbt, num_receiver_outputs: usize) -> Option { + (ark_tx.unsigned_tx.output.len() > num_receiver_outputs + 1) + .then_some((ark_tx.unsigned_tx.output.len() - 2) as u16) +} + +/// Build unsigned offchain transactions for sending BTC to one or more receivers. +/// +/// Receiver outputs are assigned in the same order as `receivers`, followed by an optional BTC +/// change output and the final anchor output. +/// +/// # Arguments +/// +/// * `receivers` - Offchain recipients and the BTC amounts assigned to each transaction output. Any +/// assets carried on [`SendReceiver`] values are ignored by this builder. +/// * `change_address` - The sender's offchain change address, used if the transaction has BTC +/// change +/// * `vtxo_inputs` - The selected VTXO inputs to spend, together with any assets they already carry +/// * `server_info` - Server configuration used to build the offchain transaction shape and dust +/// output +/// +/// # Returns +/// +/// [`OffchainTransactions`] containing the unsigned Ark transaction and unsigned checkpoint +/// transactions. +/// +/// This function is intentionally packet-agnostic: it builds the BTC transaction skeleton only and +/// does not attach an asset packet. Callers that need asset semantics should either add exactly +/// one packet themselves or use [`build_asset_send_transactions`] for the generic asset-send flow. +/// +/// # Errors +/// +/// Returns an error if unsigned offchain transaction construction fails. +pub fn build_offchain_transactions( + receivers: &[SendReceiver], + change_address: &ArkAddress, + vtxo_inputs: &[VtxoInput], + server_info: &server::Info, +) -> Result { + if vtxo_inputs.is_empty() { + return Err(Error::transaction( + "cannot build Ark transaction without inputs", + )); + } + + let vtxo_min_amount = server_info.vtxo_min_amount.unwrap_or(Amount::ONE_SAT); + if receivers + .iter() + .any(|SendReceiver { amount, .. }| *amount < vtxo_min_amount) + { + return Err(Error::transaction(format!( + "output amount smaller than minimum of {vtxo_min_amount}" + ))); + } + + let checkpoint_script = &server_info.checkpoint_tapscript; + + let mut checkpoint_data = Vec::new(); + for vtxo_input in vtxo_inputs.iter() { + let (psbt, spend_info) = build_checkpoint_psbt(vtxo_input, checkpoint_script.clone()) + .with_context(|| { + format!( + "failed to build checkpoint psbt for input {:?}", + vtxo_input.outpoint + ) + })?; + + checkpoint_data.push((psbt, spend_info)); + } + + let mut outputs = receivers + .iter() + .map( + |SendReceiver { + address, amount, .. + }| { + if *amount >= server_info.dust { + TxOut { + value: *amount, + script_pubkey: address.to_p2tr_script_pubkey(), + } + } else { + TxOut { + value: *amount, + script_pubkey: address.to_sub_dust_script_pubkey(), + } + } + }, + ) + .collect::>(); + + let total_input_amount: Amount = vtxo_inputs.iter().map(|v| v.amount).sum(); + let total_output_amount: Amount = outputs.iter().map(|v| v.value).sum(); + + let change_amount = total_input_amount.checked_sub(total_output_amount).ok_or_else(|| { + Error::transaction(format!( + "cannot cover total output amount ({total_output_amount}) with total input amount ({total_input_amount})" + )) + })?; + + if change_amount > Amount::ZERO { + if change_amount >= server_info.dust { + outputs.push(TxOut { + value: change_amount, + script_pubkey: change_address.to_p2tr_script_pubkey(), + }) + } else { + outputs.push(TxOut { + value: change_amount, + script_pubkey: change_address.to_sub_dust_script_pubkey(), + }) + } + } + + outputs.push(anchor_output()); + + let timelocked_inputs = vtxo_inputs + .iter() + .filter_map(|x| x.locktime) + .collect::>(); + + let highest_timelock = timelocked_inputs + .iter() + .try_fold(None, |acc, a| match (acc, a) { + (None, locktime) => Ok(Some(*locktime)), + (Some(a @ LockTime::Blocks(h1)), LockTime::Blocks(h2)) if h1 > *h2 => Ok(Some(a)), + (Some(LockTime::Blocks(_)), b @ LockTime::Blocks(_)) => Ok(Some(*b)), + (Some(a @ LockTime::Seconds(t1)), LockTime::Seconds(t2)) if t1 > *t2 => Ok(Some(a)), + (Some(LockTime::Seconds(_)), b @ LockTime::Seconds(_)) => Ok(Some(*b)), + _ => Err(Error::transaction("incompatible locktimes")), + })?; + + let (lock_time, sequence) = match highest_timelock { + Some(timelock) => (timelock, bitcoin::Sequence::ENABLE_LOCKTIME_NO_RBF), + None => (LockTime::ZERO, bitcoin::Sequence::MAX), + }; + + let unsigned_ark_tx = Transaction { + version: transaction::Version::non_standard(3), + lock_time, + input: checkpoint_data + .iter() + .map(|(psbt, _)| TxIn { + previous_output: OutPoint { + txid: psbt.unsigned_tx.compute_txid(), + vout: 0, + }, + script_sig: Default::default(), + sequence, + witness: Default::default(), + }) + .collect(), + output: outputs, + }; + + let mut unsigned_ark_psbt = + Psbt::from_unsigned_tx(unsigned_ark_tx).map_err(Error::transaction)?; + + for (i, (checkpoint_psbt, checkpoint_spend_info)) in checkpoint_data.iter().enumerate() { + // Set checkpoint output as `witness_utxo` field. + + unsigned_ark_psbt.inputs[i].witness_utxo = + Some(checkpoint_psbt.unsigned_tx.output[0].clone()); + + // Set script to be used in `tap_scripts` field for spending the checkpoint output. + + let vtxo_spend_script = &vtxo_inputs[i].spend_script; + let leaf_version = LeafVersion::TapScript; + let control_block = checkpoint_spend_info + .spend_info + .control_block(&(vtxo_spend_script.clone(), leaf_version)) + .expect("control block for vtxo spend script"); + + unsigned_ark_psbt.inputs[i].tap_scripts = + BTreeMap::from_iter([(control_block, (vtxo_spend_script.clone(), leaf_version))]); + + // Add _all_ the scripts in the Taproot tree to custom unknown field. + + let mut bytes = Vec::new(); + + let spend_script = &vtxo_inputs[i].spend_script; + let scripts = [spend_script.clone(), checkpoint_script.clone()]; + + for script in scripts { + // Write the depth (always 1). TODO: Support more depth. + bytes.push(1); + + // TODO: Support future leaf versions. + bytes.push(LeafVersion::TapScript.to_consensus()); + + let mut script_bytes = script.to_bytes(); + + write_compact_size_uint(&mut bytes, script_bytes.len() as u64) + .map_err(Error::transaction)?; + + bytes.append(&mut script_bytes); + } + + unsigned_ark_psbt.inputs[i].unknown.insert( + psbt::raw::Key { + type_value: 222, + key: VTXO_TAPROOT_KEY.to_vec(), + }, + bytes, + ); + unsigned_ark_psbt.inputs[i].witness_script = Some(spend_script.clone()); + } + + Ok(OffchainTransactions { + ark_tx: unsigned_ark_psbt, + checkpoint_txs: checkpoint_data.into_iter().map(|(psbt, _)| psbt).collect(), + }) +} + +#[derive(Debug, Clone)] +struct CheckpointSpendInfo { + spend_info: TaprootSpendInfo, +} + +impl CheckpointSpendInfo { + fn new(vtxo_input: &VtxoInput, checkpoint_exit_script: ScriptBuf) -> Self { + let secp = Secp256k1::new(); + + let unspendable_key: PublicKey = UNSPENDABLE_KEY.parse().expect("valid key"); + let (unspendable_key, _) = unspendable_key.inner.x_only_public_key(); + + let vtxo_spend_script = &vtxo_input.spend_script; + + let spend_info = TaprootBuilder::new() + .add_leaf(1, vtxo_spend_script.clone()) + .expect("valid spend leaf") + .add_leaf(1, checkpoint_exit_script) + .expect("valid exit leaf") + .finalize(&secp, unspendable_key) + .expect("can be finalized"); + + Self { spend_info } + } + + fn script_pubkey(&self) -> ScriptBuf { + tr_script_pubkey(&self.spend_info) + } +} + +fn build_checkpoint_psbt( + vtxo_input: &VtxoInput, + // An alternative way for the _server_ to unilaterally spend the checkpoint output, in case the + // owner does not spend it. + // + // This is defined by the Ark server. + checkpoint_exit_script: ScriptBuf, +) -> Result<(Psbt, CheckpointSpendInfo), Error> { + let (lock_time, sequence) = match vtxo_input.locktime { + Some(timelock) => (timelock, bitcoin::Sequence::ENABLE_LOCKTIME_NO_RBF), + None => (LockTime::ZERO, bitcoin::Sequence::MAX), + }; + + let inputs = vec![TxIn { + previous_output: vtxo_input.outpoint, + script_sig: Default::default(), + sequence, + witness: Default::default(), + }]; + + let checkpoint_spend_info = CheckpointSpendInfo::new(vtxo_input, checkpoint_exit_script); + + let outputs = vec![ + TxOut { + value: vtxo_input.amount, + script_pubkey: checkpoint_spend_info.script_pubkey(), + }, + anchor_output(), + ]; + + let unsigned_tx = Transaction { + version: transaction::Version::non_standard(3), + lock_time, + input: inputs, + output: outputs, + }; + + let mut unsigned_checkpoint_psbt = + Psbt::from_unsigned_tx(unsigned_tx).map_err(Error::transaction)?; + + // Set VTXO being spent as `witness_utxo` field. + + unsigned_checkpoint_psbt.inputs[0].witness_utxo = Some(TxOut { + value: vtxo_input.amount, + script_pubkey: vtxo_input.script_pubkey.clone(), + }); + + // Set script to be used in `tap_scripts` field for spending the VTXO. + + let (vtxo_spend_script, vtxo_spend_control_block) = vtxo_input.spend_info(); + + let leaf_version = vtxo_spend_control_block.leaf_version; + unsigned_checkpoint_psbt.inputs[0].tap_scripts = BTreeMap::from_iter([( + vtxo_spend_control_block.clone(), + (vtxo_spend_script.clone(), leaf_version), + )]); + + // Add _all_ the scripts in the Taproot tree to custom unknown field. + + let mut bytes = Vec::new(); + + for script in vtxo_input.tapscripts.iter() { + // Write the depth (always 1). TODO: Support more depth. + bytes.push(1); + + // TODO: Support future leaf versions. + bytes.push(LeafVersion::TapScript.to_consensus()); + + let mut script_bytes = script.to_bytes(); + + write_compact_size_uint(&mut bytes, script_bytes.len() as u64) + .map_err(Error::transaction)?; + + bytes.append(&mut script_bytes); + } + + unsigned_checkpoint_psbt.inputs[0].unknown.insert( + psbt::raw::Key { + type_value: 222, + key: VTXO_TAPROOT_KEY.to_vec(), + }, + bytes, + ); + unsigned_checkpoint_psbt.inputs[0].witness_script = Some(vtxo_spend_script.clone()); + + Ok((unsigned_checkpoint_psbt, checkpoint_spend_info)) +} + +fn write_compact_size_uint(w: &mut W, val: u64) -> io::Result<()> { + if val < 253 { + w.write_all(&[val as u8])?; + } else if val < 0x10000 { + w.write_all(&[253])?; + w.write_all(&(val as u16).to_le_bytes())?; + } else if val < 0x100000000 { + w.write_all(&[254])?; + w.write_all(&(val as u32).to_le_bytes())?; + } else { + w.write_all(&[255])?; + w.write_all(&val.to_le_bytes())?; + } + Ok(()) +} + +// TODO: Sign checkpoint and sign Ark are basically the same. We can combine them, probably. +pub fn sign_checkpoint_transaction(sign_fn: S, psbt: &mut Psbt) -> Result<(), Error> +where + S: FnOnce( + &mut psbt::Input, + secp256k1::Message, + ) -> Result, Error>, +{ + let witness_utxo = [psbt.inputs[0].witness_utxo.clone().expect("witness UTXO")]; + let prevouts = Prevouts::All(&witness_utxo); + + let psbt_input = psbt.inputs.get_mut(0).expect("input at index"); + + let (_, (vtxo_spend_script, leaf_version)) = + psbt_input.tap_scripts.first_key_value().expect("one entry"); + + let leaf_hash = TapLeafHash::from_script(vtxo_spend_script, *leaf_version); + + let tap_sighash = SighashCache::new(&psbt.unsigned_tx) + .taproot_script_spend_signature_hash(0, &prevouts, leaf_hash, TapSighashType::Default) + .map_err(Error::crypto) + .context("failed to generate sighash")?; + + let msg = secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array()); + + let sigs = sign_fn(psbt_input, msg)?; + for (sig, pk) in sigs { + let sig = taproot::Signature { + signature: sig, + sighash_type: TapSighashType::Default, + }; + + psbt_input.tap_script_sigs.insert((pk, leaf_hash), sig); + } + + Ok(()) +} + +pub fn sign_ark_transaction(sign_fn: S, psbt: &mut Psbt, input_index: usize) -> Result<(), Error> +where + S: FnOnce( + &mut psbt::Input, + secp256k1::Message, + ) -> Result, Error>, +{ + tracing::debug!(index = input_index, "Signing Ark transaction input"); + + let witness_utxos = psbt + .inputs + .iter() + .map(|i| i.witness_utxo.clone().expect("witness UTXO")) + .collect::>(); + + let psbt_input = psbt.inputs.get_mut(input_index).expect("input at index"); + + // To spend a checkpoint output we are using a script spend path. + + let prevouts = Prevouts::All(&witness_utxos); + + let (_, (vtxo_spend_script, leaf_version)) = + psbt_input.tap_scripts.first_key_value().expect("one entry"); + + let leaf_hash = TapLeafHash::from_script(vtxo_spend_script, *leaf_version); + + let tap_sighash = SighashCache::new(&psbt.unsigned_tx) + .taproot_script_spend_signature_hash( + input_index, + &prevouts, + leaf_hash, + TapSighashType::Default, + ) + .map_err(Error::crypto) + .context("failed to generate sighash")?; + + let msg = secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array()); + + let sigs = sign_fn(psbt_input, msg)?; + for (sig, pk) in sigs { + let sig = taproot::Signature { + signature: sig, + sighash_type: TapSighashType::Default, + }; + + psbt_input.tap_script_sigs.insert((pk, leaf_hash), sig); + } + + Ok(()) +} + +/// Build unsigned offchain transactions for sending BTC and optional assets to one or more +/// receivers. +/// +/// We first build the BTC transaction skeleton via [`build_offchain_transactions`] and then, if the +/// transfer actually involves assets, add exactly one asset packet that: +/// +/// - routes each requested asset amount to the corresponding receiver output index +/// - preserves leftover carried assets on the BTC change output +/// +/// Specialized flows such as issuance, reissuance, and burn should call +/// [`build_offchain_transactions`] directly and attach their own packet semantics explicitly. +/// +/// # Errors +/// +/// Returns an error if BTC transaction construction fails, if a receiver references an asset that +/// is not present in the selected inputs, if the requested amount for any asset exceeds the +/// selected input amount for that asset, or if leftover assets would need to be preserved but the +/// transaction has no BTC change output. +pub fn build_asset_send_transactions( + receivers: &[SendReceiver], + change_address: &ArkAddress, + vtxo_inputs: &[VtxoInput], + server_info: &server::Info, +) -> Result { + let mut offchain = + build_offchain_transactions(receivers, change_address, vtxo_inputs, server_info)?; + + if let Some(packet) = create_send_packet(vtxo_inputs, receivers, &offchain.ark_tx)? { + add_asset_packet_to_psbt(&mut offchain.ark_tx, &packet)?; + } + + Ok(offchain) +} + +/// Build unsigned offchain transactions for burning a specific amount of an asset. +/// +/// The burn is represented by consuming the selected asset amount from the chosen inputs without +/// creating a corresponding asset output. Any remaining carried assets are preserved on the BTC +/// change output. +/// +/// # Errors +/// +/// Returns an error if BTC transaction construction fails, if the selected inputs do not contain +/// the asset to burn, if the selected amount for the burned asset is insufficient, or if leftover +/// carried assets would need to be preserved but the transaction has no BTC change output. +pub fn build_asset_burn_transactions( + own_address: &ArkAddress, + change_address: &ArkAddress, + vtxo_inputs: &[VtxoInput], + server_info: &server::Info, + burn_asset_id: AssetId, + burn_amount: u64, +) -> Result { + let mut offchain = build_offchain_transactions( + &[SendReceiver { + address: *own_address, + amount: server_info.dust, + assets: Vec::new(), + }], + change_address, + vtxo_inputs, + server_info, + )?; + + if let Some(packet) = + create_burn_packet(vtxo_inputs, burn_asset_id, burn_amount, &offchain.ark_tx)? + { + add_asset_packet_to_psbt(&mut offchain.ark_tx, &packet)?; + } + + Ok(offchain) +} + +/// Create the asset packet for a generic asset send. +/// +/// Receiver asset allocations are assigned to their corresponding receiver output indexes. Any +/// leftover carried assets are preserved on the BTC change output when one exists. +fn create_send_packet( + inputs: &[VtxoInput], + receivers: &[SendReceiver], + ark_tx: &Psbt, +) -> Result, Error> { + struct AssetTransfer { + inputs: Vec, + outputs: Vec, + input_amount: u64, + requested_amount: u64, + } + + let mut transfers: HashMap = HashMap::new(); + + for (input_index, input) in inputs.iter().enumerate() { + for asset in &input.assets { + let transfer = transfers + .entry(asset.asset_id) + .or_insert_with(|| AssetTransfer { + inputs: Vec::new(), + outputs: Vec::new(), + input_amount: 0, + requested_amount: 0, + }); + + transfer.inputs.push(asset::packet::AssetInput { + input_index: input_index as u16, + amount: asset.amount, + }); + + transfer.input_amount = transfer + .input_amount + .checked_add(asset.amount) + .ok_or_else(|| Error::ad_hoc("asset input amount overflow"))?; + } + } + + let any_receiver_assets = receivers.iter().any(|receiver| !receiver.assets.is_empty()); + if transfers.is_empty() && !any_receiver_assets { + return Ok(None); + } + + for (receiver_index, receiver) in receivers.iter().enumerate() { + for asset in &receiver.assets { + let transfer = transfers.get_mut(&asset.asset_id).ok_or_else(|| { + Error::ad_hoc(format!( + "receiver references asset {} that is not present in selected inputs", + asset.asset_id + )) + })?; + + transfer.outputs.push(asset::packet::AssetOutput { + output_index: receiver_index as u16, + amount: asset.amount, + }); + transfer.requested_amount = transfer + .requested_amount + .checked_add(asset.amount) + .ok_or_else(|| Error::ad_hoc("asset transfer amount overflow"))?; + } + } + + let change_output_index = btc_change_output_index(ark_tx, receivers.len()); + let mut groups = Vec::new(); + + for (asset_id, mut transfer) in transfers.into_iter() { + let leftover_amount = transfer + .input_amount + .checked_sub(transfer.requested_amount) + .ok_or_else(|| { + Error::ad_hoc(format!( + "requested amount for asset {} exceeds selected input amount", + asset_id + )) + })?; + + match (change_output_index, leftover_amount) { + (Some(change_output_index), leftover_amount) if leftover_amount > 0 => { + transfer.outputs.push(asset::packet::AssetOutput { + output_index: change_output_index, + amount: leftover_amount, + }); + } + (None, leftover_amount) if leftover_amount > 0 => { + return Err(Error::ad_hoc( + "asset transfer has preserved asset changes but no BTC change output", + )); + } + _ => {} + } + + groups.push(asset::packet::AssetGroup { + asset_id: Some(asset_id), + control_asset: None, + metadata: None, + inputs: transfer.inputs, + outputs: transfer.outputs, + }); + } + + groups.sort_by_key(|group| { + let asset_id = group + .asset_id + .expect("generic asset-send groups always have asset ids"); + (*asset_id.txid.as_byte_array(), asset_id.group_index) + }); + + Ok(Some(asset::packet::Packet { groups })) +} + +fn create_burn_packet( + inputs: &[VtxoInput], + burn_asset_id: AssetId, + burn_amount: u64, + ark_tx: &Psbt, +) -> Result, Error> { + struct AssetTransfer { + inputs: Vec, + input_amount: u64, + } + + let mut transfers: HashMap = HashMap::new(); + + for (input_index, input) in inputs.iter().enumerate() { + for asset in input.assets() { + let transfer = transfers + .entry(asset.asset_id) + .or_insert_with(|| AssetTransfer { + inputs: Vec::new(), + input_amount: 0, + }); + + transfer.inputs.push(asset::packet::AssetInput { + input_index: input_index as u16, + amount: asset.amount, + }); + transfer.input_amount += asset.amount; + } + } + + if transfers.is_empty() { + return Err(Error::ad_hoc(format!( + "selected inputs do not contain asset {}", + burn_asset_id + ))); + } + + let burn_input_amount = transfers + .get(&burn_asset_id) + .ok_or_else(|| { + Error::ad_hoc(format!( + "selected inputs do not contain asset {}", + burn_asset_id + )) + })? + .input_amount; + + let burn_leftover_amount = burn_input_amount.checked_sub(burn_amount).ok_or_else(|| { + Error::ad_hoc(format!( + "requested burn amount for asset {} exceeds selected input amount", + burn_asset_id + )) + })?; + + let preserved_output_index = btc_change_output_index(ark_tx, 1).unwrap_or(0); + let mut groups = Vec::new(); + + for (asset_id, transfer) in transfers.into_iter() { + let leftover_amount = if asset_id == burn_asset_id { + burn_leftover_amount + } else { + transfer.input_amount + }; + + let mut outputs = Vec::new(); + if leftover_amount > 0 { + outputs.push(asset::packet::AssetOutput { + output_index: preserved_output_index, + amount: leftover_amount, + }); + } + + groups.push(asset::packet::AssetGroup { + asset_id: Some(asset_id), + control_asset: None, + metadata: None, + inputs: transfer.inputs, + outputs, + }); + } + + groups.sort_by_key(|group| { + let asset_id = group + .asset_id + .expect("asset-burn groups always have asset ids"); + (*asset_id.txid.as_byte_array(), asset_id.group_index) + }); + + Ok(Some(asset::packet::Packet { groups })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::asset::packet::AssetGroup; + use crate::asset::packet::AssetInput; + use crate::asset::packet::AssetOutput; + use crate::asset::packet::Packet; + use crate::script::multisig_script; + use crate::send::VtxoInput; + use crate::server::Info; + use bitcoin::key::Secp256k1; + use bitcoin::opcodes::OP_TRUE; + use bitcoin::script::Builder; + use bitcoin::taproot::LeafVersion; + use bitcoin::taproot::TaprootBuilder; + use bitcoin::Amount; + use bitcoin::Network; + use bitcoin::OutPoint; + use bitcoin::Sequence; + use bitcoin::Txid; + + #[test] + fn build_offchain_transactions_has_no_packet_even_when_assets_are_present() { + let server_info = test_server_info(); + let asset_id = AssetId { + txid: Txid::from_byte_array([10; 32]), + group_index: 0, + }; + let (input, own_address) = asset_send_input( + 1, + 660, + vec![Asset { + asset_id, + amount: 10, + }], + ); + let receiver = SendReceiver { + address: own_address, + amount: Amount::from_sat(330), + assets: vec![Asset { + asset_id, + amount: 6, + }], + }; + + let res = + build_offchain_transactions(&[receiver], &own_address, &[input], &server_info).unwrap(); + + assert_eq!(res.ark_tx.unsigned_tx.output.len(), 3); + } + + #[test] + fn build_asset_send_transactions_routes_requested_assets_to_receiver_outputs_and_change() { + let server_info = test_server_info(); + let asset_id = AssetId { + txid: Txid::from_byte_array([11; 32]), + group_index: 4, + }; + let (input, own_address) = asset_send_input( + 2, + 660, + vec![Asset { + asset_id, + amount: 10, + }], + ); + let receiver = SendReceiver { + address: own_address, + amount: Amount::from_sat(330), + assets: vec![Asset { + asset_id, + amount: 6, + }], + }; + + let res = build_asset_send_transactions(&[receiver], &own_address, &[input], &server_info) + .unwrap(); + + let expected_packet = Packet { + groups: vec![AssetGroup { + asset_id: Some(asset_id), + control_asset: None, + metadata: None, + inputs: vec![AssetInput { + input_index: 0, + amount: 10, + }], + outputs: vec![ + AssetOutput { + output_index: 0, + amount: 6, + }, + AssetOutput { + output_index: 1, + amount: 4, + }, + ], + }], + }; + + assert_eq!( + res.ark_tx.unsigned_tx.output[asset_packet_index(&res.ark_tx)], + expected_packet.to_txout() + ); + } + + #[test] + fn build_asset_send_transactions_errors_when_receiver_references_missing_asset() { + let server_info = test_server_info(); + let missing_asset_id = AssetId { + txid: Txid::from_byte_array([12; 32]), + group_index: 1, + }; + let (input, own_address) = asset_send_input(3, 330, vec![]); + let receiver = SendReceiver { + address: own_address, + amount: Amount::from_sat(330), + assets: vec![Asset { + asset_id: missing_asset_id, + amount: 1, + }], + }; + + let err = build_asset_send_transactions(&[receiver], &own_address, &[input], &server_info) + .unwrap_err(); + + assert!(err.to_string().contains("receiver references asset")); + } + + #[test] + fn build_asset_send_transactions_errors_when_leftover_assets_exist_but_no_btc_change_output() { + let server_info = test_server_info(); + let asset_id = AssetId { + txid: Txid::from_byte_array([13; 32]), + group_index: 2, + }; + let (input, own_address) = asset_send_input( + 4, + 330, + vec![Asset { + asset_id, + amount: 10, + }], + ); + let receiver = SendReceiver { + address: own_address, + amount: Amount::from_sat(330), + assets: vec![Asset { + asset_id, + amount: 6, + }], + }; + + let err = build_asset_send_transactions(&[receiver], &own_address, &[input], &server_info) + .unwrap_err(); + + assert!(err + .to_string() + .contains("asset transfer has preserved asset changes but no BTC change output")); + } + + #[test] + fn build_asset_send_transactions_sorts_packet_groups_stably() { + let server_info = test_server_info(); + let asset_id_a = AssetId { + txid: Txid::from_byte_array([14; 32]), + group_index: 1, + }; + let asset_id_b = AssetId { + txid: Txid::from_byte_array([15; 32]), + group_index: 0, + }; + let (input, own_address) = asset_send_input( + 5, + 660, + vec![ + Asset { + asset_id: asset_id_b, + amount: 8, + }, + Asset { + asset_id: asset_id_a, + amount: 10, + }, + ], + ); + let receiver = SendReceiver { + address: own_address, + amount: Amount::from_sat(330), + assets: vec![ + Asset { + asset_id: asset_id_b, + amount: 3, + }, + Asset { + asset_id: asset_id_a, + amount: 4, + }, + ], + }; + + let res = build_asset_send_transactions(&[receiver], &own_address, &[input], &server_info) + .unwrap(); + + let expected_packet = Packet { + groups: vec![ + AssetGroup { + asset_id: Some(asset_id_a), + control_asset: None, + metadata: None, + inputs: vec![AssetInput { + input_index: 0, + amount: 10, + }], + outputs: vec![ + AssetOutput { + output_index: 0, + amount: 4, + }, + AssetOutput { + output_index: 1, + amount: 6, + }, + ], + }, + AssetGroup { + asset_id: Some(asset_id_b), + control_asset: None, + metadata: None, + inputs: vec![AssetInput { + input_index: 0, + amount: 8, + }], + outputs: vec![ + AssetOutput { + output_index: 0, + amount: 3, + }, + AssetOutput { + output_index: 1, + amount: 5, + }, + ], + }, + ], + }; + + assert_eq!( + res.ark_tx.unsigned_tx.output[asset_packet_index(&res.ark_tx)], + expected_packet.to_txout() + ); + } + + #[test] + fn build_asset_burn_transactions_routes_leftover_assets_to_change() { + let server_info = test_server_info(); + let burn_asset_id = AssetId { + txid: Txid::from_byte_array([16; 32]), + group_index: 0, + }; + let carried_asset_id = AssetId { + txid: Txid::from_byte_array([17; 32]), + group_index: 1, + }; + let (input, own_address) = asset_send_input( + 6, + 660, + vec![ + Asset { + asset_id: burn_asset_id, + amount: 10, + }, + Asset { + asset_id: carried_asset_id, + amount: 4, + }, + ], + ); + + let res = build_asset_burn_transactions( + &own_address, + &own_address, + &[input], + &server_info, + burn_asset_id, + 6, + ) + .unwrap(); + + let expected_packet = Packet { + groups: vec![ + AssetGroup { + asset_id: Some(burn_asset_id), + control_asset: None, + metadata: None, + inputs: vec![AssetInput { + input_index: 0, + amount: 10, + }], + outputs: vec![AssetOutput { + output_index: 1, + amount: 4, + }], + }, + AssetGroup { + asset_id: Some(carried_asset_id), + control_asset: None, + metadata: None, + inputs: vec![AssetInput { + input_index: 0, + amount: 4, + }], + outputs: vec![AssetOutput { + output_index: 1, + amount: 4, + }], + }, + ], + }; + + assert_eq!( + res.ark_tx.unsigned_tx.output[asset_packet_index(&res.ark_tx)], + expected_packet.to_txout() + ); + } + + #[test] + fn build_asset_burn_transactions_errors_when_asset_is_missing() { + let server_info = test_server_info(); + let missing_asset_id = AssetId { + txid: Txid::from_byte_array([18; 32]), + group_index: 0, + }; + let (input, own_address) = asset_send_input(7, 330, vec![]); + + let err = build_asset_burn_transactions( + &own_address, + &own_address, + &[input], + &server_info, + missing_asset_id, + 1, + ) + .unwrap_err(); + + assert!(err + .to_string() + .contains("selected inputs do not contain asset")); + } + + #[test] + fn build_asset_burn_transactions_routes_leftover_assets_to_self_output_without_btc_change() { + let server_info = test_server_info(); + let burn_asset_id = AssetId { + txid: Txid::from_byte_array([19; 32]), + group_index: 0, + }; + let (input, own_address) = asset_send_input( + 8, + 330, + vec![Asset { + asset_id: burn_asset_id, + amount: 10, + }], + ); + + let res = build_asset_burn_transactions( + &own_address, + &own_address, + &[input], + &server_info, + burn_asset_id, + 6, + ) + .unwrap(); + + let expected_packet = Packet { + groups: vec![AssetGroup { + asset_id: Some(burn_asset_id), + control_asset: None, + metadata: None, + inputs: vec![AssetInput { + input_index: 0, + amount: 10, + }], + outputs: vec![AssetOutput { + output_index: 0, + amount: 4, + }], + }], + }; + + assert_eq!( + res.ark_tx.unsigned_tx.output[asset_packet_index(&res.ark_tx)], + expected_packet.to_txout() + ); + } + + fn test_server_info() -> Info { + let signer_pk = "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + .parse() + .unwrap(); + let forfeit_pk = "03dff1d77f2a671c5f36183726db2341be58f8be17d2a3d1d2cd47b7b0f5f2d624" + .parse() + .unwrap(); + + Info { + version: "test".into(), + signer_pk, + forfeit_pk, + forfeit_address: "bcrt1q8frde3yn78tl9ecgq4anlz909jh0clefhucdur" + .parse::>() + .unwrap() + .require_network(Network::Regtest) + .unwrap(), + checkpoint_tapscript: Builder::new().push_opcode(OP_TRUE).into_script(), + network: Network::Regtest, + session_duration: 0, + unilateral_exit_delay: Sequence::MAX, + boarding_exit_delay: Sequence::MAX, + utxo_min_amount: None, + utxo_max_amount: None, + vtxo_min_amount: Some(Amount::from_sat(1)), + vtxo_max_amount: None, + dust: Amount::from_sat(330), + fees: None, + scheduled_session: None, + deprecated_signers: vec![], + service_status: Default::default(), + digest: "test".into(), + max_tx_weight: 40_000, + max_op_return_outputs: 3, + } + } + + fn asset_send_input( + outpoint_tag: u8, + amount_sat: u64, + assets: Vec, + ) -> (VtxoInput, ArkAddress) { + let secp = Secp256k1::new(); + + let server_pk: PublicKey = + "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + .parse() + .unwrap(); + let owner_pk: PublicKey = + "03dff1d77f2a671c5f36183726db2341be58f8be17d2a3d1d2cd47b7b0f5f2d624" + .parse() + .unwrap(); + + let server_xonly = server_pk.inner.x_only_public_key().0; + let owner_xonly = owner_pk.inner.x_only_public_key().0; + let spend_script = multisig_script(server_xonly, owner_xonly); + let spend_info = TaprootBuilder::new() + .add_leaf(0, spend_script.clone()) + .unwrap() + .finalize(&secp, server_xonly) + .unwrap(); + let control_block = spend_info + .control_block(&(spend_script.clone(), LeafVersion::TapScript)) + .unwrap(); + let own_address = ArkAddress::new(Network::Regtest, server_xonly, spend_info.output_key()); + + ( + VtxoInput::new( + spend_script.clone(), + None, + control_block, + vec![spend_script], + own_address.to_p2tr_script_pubkey(), + Amount::from_sat(amount_sat), + OutPoint::new(Txid::from_byte_array([outpoint_tag; 32]), 0), + assets, + ), + own_address, + ) + } + + fn asset_packet_index(ark_tx: &Psbt) -> usize { + ark_tx.unsigned_tx.output.len() - 2 + } +} diff --git a/vendor/ark-core/src/send/issue_asset.rs b/vendor/ark-core/src/send/issue_asset.rs new file mode 100644 index 0000000..2283583 --- /dev/null +++ b/vendor/ark-core/src/send/issue_asset.rs @@ -0,0 +1,676 @@ +use crate::asset; +use crate::asset::packet::add_asset_packet_to_psbt; +use crate::asset::AssetId; +use crate::asset::ControlAssetConfig; +use crate::send::build_offchain_transactions; +use crate::send::OffchainTransactions; +use crate::send::SendReceiver; +use crate::send::VtxoInput; +use crate::server; +use crate::ArkAddress; +use crate::Error; +use bitcoin::hashes::Hash as _; +use bitcoin::Psbt; +use bitcoin::Txid; +use std::collections::HashMap; + +/// Unsigned transactions for self asset issuance plus the derived asset IDs. +#[derive(Debug, Clone)] +pub struct SelfAssetIssuanceTransactions { + pub ark_tx: Psbt, + pub checkpoint_txs: Vec, + pub asset_ids: Vec, +} + +/// Self-controlled output used by self-issuance packet assignments. +const SELF_ISSUANCE_OUTPUT_INDEX: u16 = 0; + +/// Build unsigned offchain transactions for issuing a fresh asset to self. +/// +/// Output [`SELF_ISSUANCE_OUTPUT_INDEX`] remains self-controlled and carries the newly issued +/// asset amount together with any assets already carried by the selected inputs. +/// +/// This builder is therefore intended for self-issuance flows, where output +/// [`SELF_ISSUANCE_OUTPUT_INDEX`] remains under the issuer's control. +/// +/// # Arguments +/// +/// * `own_address` - The issuer's offchain address that receives the newly issued asset +/// * `change_address` - The issuer's offchain change address, used if the transaction has BTC +/// change +/// * `inputs` - The selected VTXO inputs to spend, together with any assets they already carry +/// * `server_info` - Server configuration used to build the offchain transaction shape and dust +/// output +/// * `amount` - The amount of the new asset to issue +/// * `control_asset_config` - Optional control asset configuration for making the issued asset +/// reissuable +/// * `metadata` - Optional metadata to attach to the newly issued asset group +/// +/// # Returns +/// +/// [`SelfAssetIssuanceTransactions`] containing the unsigned Ark transaction, unsigned checkpoint +/// transactions, and the derived asset IDs for the issued asset groups. +/// +/// # Errors +/// +/// Returns an error if `amount` is zero, if an existing control asset is requested but the +/// selected inputs do not include a non-zero balance of that asset, or if unsigned offchain +/// transaction construction fails. +pub fn build_self_asset_issuance_transactions( + own_address: &ArkAddress, + change_address: &ArkAddress, + inputs: &[VtxoInput], + server_info: &server::Info, + amount: u64, + control_asset_config: Option, + metadata: Option>, +) -> Result { + if amount == 0 { + return Err(Error::ad_hoc("asset amount must be > 0")); + } + + let OffchainTransactions { + mut ark_tx, + checkpoint_txs, + } = build_offchain_transactions( + &[SendReceiver { + address: *own_address, + amount: server_info.dust, + assets: Vec::new(), + }], + change_address, + inputs, + server_info, + )?; + + let packet = create_self_issuance_packet( + inputs, + amount, + control_asset_config.as_ref(), + metadata.as_ref(), + )?; + add_asset_packet_to_psbt(&mut ark_tx, &packet)?; + + let asset_ids = derive_issued_asset_ids( + ark_tx.unsigned_tx.compute_txid(), + control_asset_config.as_ref(), + ); + + Ok(SelfAssetIssuanceTransactions { + ark_tx, + checkpoint_txs, + asset_ids, + }) +} + +/// Create the asset packet for a self-issuance transaction. +/// +/// Output [`SELF_ISSUANCE_OUTPUT_INDEX`] is treated as the self-controlled destination for the +/// newly issued asset and any assets already carried by the selected inputs. +/// +/// If a new control asset is created, it is emitted as group `0` so the issued asset can refer to +/// it via [`asset::packet::AssetRef::ByGroup`]. Carried assets are preserved on output +/// [`SELF_ISSUANCE_OUTPUT_INDEX`] as well. +/// +/// # Arguments +/// +/// * `inputs` - The selected VTXO inputs to spend, together with any assets they already carry +/// * `amount` - The amount of the new asset to issue +/// * `control_asset_config` - Optional control asset configuration for making the issued asset +/// reissuable +/// * `metadata` - Optional metadata to attach to the newly issued asset group +/// +/// # Returns +/// +/// An [`asset::packet::Packet`] describing how newly issued and carried assets are assigned to +/// output [`SELF_ISSUANCE_OUTPUT_INDEX`]. +/// +/// # Errors +/// +/// Returns an error if `control_asset_config` references an existing control asset id that is not +/// present with a non-zero balance in the selected inputs. +fn create_self_issuance_packet( + inputs: &[VtxoInput], + amount: u64, + control_asset_config: Option<&ControlAssetConfig>, + metadata: Option<&Vec<(String, String)>>, +) -> Result { + let mut groups = Vec::new(); + + // If we are generating a new control asset as part of issuing, it belongs in group `0`. + if let Some(ControlAssetConfig::New { + amount: control_amount, + }) = control_asset_config + { + groups.push(asset::packet::AssetGroup { + asset_id: None, + control_asset: None, + metadata: metadata.cloned(), + inputs: vec![], + outputs: vec![asset::packet::AssetOutput { + output_index: SELF_ISSUANCE_OUTPUT_INDEX, + amount: (*control_amount).into(), + }], + }); + } + + let control_asset_ref = match control_asset_config { + Some(ControlAssetConfig::New { .. }) => Some(asset::packet::AssetRef::ByGroup(0)), + Some(ControlAssetConfig::Existing { id }) => Some(asset::packet::AssetRef::ById(*id)), + None => None, + }; + + // The issued asset can be in either: + // + // - group `0`, if no new control asset was generated; or + // - group `1`, if a new control asset was generated. + groups.push(asset::packet::AssetGroup { + asset_id: None, + control_asset: control_asset_ref, + metadata: metadata.cloned(), + inputs: vec![], + outputs: vec![asset::packet::AssetOutput { + output_index: SELF_ISSUANCE_OUTPUT_INDEX, + amount, + }], + }); + + // Ensure asset preservation. + let mut existing_asset_groups: HashMap = HashMap::new(); + for (input_index, input) in inputs.iter().enumerate() { + for asset in &input.assets { + let group = existing_asset_groups + .entry(asset.asset_id) + .or_insert_with(|| asset::packet::AssetGroup { + asset_id: Some(asset.asset_id), + control_asset: None, + metadata: None, + inputs: Vec::new(), + outputs: vec![asset::packet::AssetOutput { + output_index: SELF_ISSUANCE_OUTPUT_INDEX, + amount: 0, + }], + }); + + group.inputs.push(asset::packet::AssetInput { + input_index: input_index as u16, + amount: asset.amount, + }); + + group.outputs[0].amount = group.outputs[0] + .amount + .checked_add(asset.amount) + .ok_or_else(|| { + Error::ad_hoc("asset amount overflow while preserving carried assets") + })?; + } + } + + // If the control asset is referenced by ID (it existed before this transaction), ensure that it + // is an input to the transaction. Otherwise, the Arkade server will not authorise issuance. + if let Some(ControlAssetConfig::Existing { id }) = control_asset_config { + let control_group = existing_asset_groups + .get(id) + .map(|t| t.inputs.as_slice()) + .unwrap_or_default(); + let control_input_amount: u64 = control_group.iter().map(|i| i.amount).sum(); + + if control_input_amount == 0 { + return Err(Error::ad_hoc( + "control asset missing from issuance transaction inputs", + )); + } + } + + // Sort the remaining groups to make it easier to test the behaviour. This is _not_ required. + let mut existing_asset_groups = existing_asset_groups.into_values().collect::>(); + existing_asset_groups.sort_by_key(|group| { + let asset_id = group + .asset_id + .expect("issuance carried-asset groups always have asset ids"); + (asset_id.txid.to_byte_array(), asset_id.group_index) + }); + groups.extend(existing_asset_groups); + + Ok(asset::packet::Packet { groups }) +} + +/// Derive the asset IDs created by a self-issuance transaction from the final transaction ID. +/// +/// When a new control asset is created, it is emitted as group `0` and the issued asset becomes +/// group `1`. Otherwise, the issued asset is group `0`. +fn derive_issued_asset_ids( + txid: Txid, + control_asset_config: Option<&ControlAssetConfig>, +) -> Vec { + let mut asset_ids = Vec::new(); + let mut group_index = 0; + + if matches!(control_asset_config, Some(ControlAssetConfig::New { .. })) { + asset_ids.push(AssetId { txid, group_index }); + group_index += 1; + } + + asset_ids.push(AssetId { txid, group_index }); + asset_ids +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::asset::packet::AssetGroup; + use crate::asset::packet::AssetInput; + use crate::asset::packet::AssetOutput; + use crate::asset::packet::Packet; + use crate::asset::ControlAssetConfig; + use crate::script::multisig_script; + use crate::send::VtxoInput; + use crate::server; + use crate::server::Asset; + use crate::ArkAddress; + use bitcoin::key::Secp256k1; + use bitcoin::opcodes::OP_TRUE; + use bitcoin::script::Builder; + use bitcoin::taproot::LeafVersion; + use bitcoin::taproot::TaprootBuilder; + use bitcoin::Amount; + use bitcoin::Network; + use bitcoin::OutPoint; + use bitcoin::Sequence; + use bitcoin::Txid; + + #[test] + fn derive_issued_asset_ids_without_control_asset() { + let txid = Txid::from_byte_array([1; 32]); + + assert_eq!( + derive_issued_asset_ids(txid, None), + vec![AssetId { + txid, + group_index: 0, + }] + ); + } + + #[test] + fn derive_issued_asset_ids_with_existing_control_asset() { + let txid = Txid::from_byte_array([2; 32]); + let control_asset_id = AssetId { + txid: Txid::from_byte_array([3; 32]), + group_index: 7, + }; + + assert_eq!( + derive_issued_asset_ids(txid, Some(&ControlAssetConfig::existing(control_asset_id))), + vec![AssetId { + txid, + group_index: 0, + }] + ); + } + + #[test] + fn derive_issued_asset_ids_with_new_control_asset() { + let txid = Txid::from_byte_array([4; 32]); + let control_asset = ControlAssetConfig::new(1).expect("non-zero control asset amount"); + + assert_eq!( + derive_issued_asset_ids(txid, Some(&control_asset)), + vec![ + AssetId { + txid, + group_index: 0, + }, + AssetId { + txid, + group_index: 1, + }, + ] + ); + } + + #[test] + fn self_issuance_without_carried_assets_has_only_issued_group() { + let server_info = test_server_info(); + + let (input, own_address) = self_issuance_input(Vec::new()); + + let res = build_self_asset_issuance_transactions( + &own_address, + &own_address, + &[input], + &server_info, + 123, + None, + None, + ) + .unwrap(); + + // Newly minted asset. + let issued_group = AssetGroup { + asset_id: None, + // Cannot be reissued. + control_asset: None, + metadata: None, + inputs: vec![], + outputs: vec![AssetOutput { + output_index: SELF_ISSUANCE_OUTPUT_INDEX, + amount: 123, + }], + }; + + let expected_packet = Packet { + groups: vec![issued_group], + }; + + let asset_packet_index = asset_packet_index(&res.ark_tx); + + assert_eq!( + res.ark_tx.unsigned_tx.output[asset_packet_index], + expected_packet.to_txout() + ); + } + + #[test] + fn self_issuance_preserves_carried_assets_on_output_zero() { + let server_info = test_server_info(); + let unrelated_asset_id = AssetId { + txid: Txid::from_byte_array([11; 32]), + group_index: 4, + }; + + let (input, own_address) = self_issuance_input(vec![Asset { + asset_id: unrelated_asset_id, + amount: 7, + }]); + + let res = build_self_asset_issuance_transactions( + &own_address, + &own_address, + &[input], + &server_info, + 123, + None, + None, + ) + .unwrap(); + + // Newly minted asset. + let issued_group = AssetGroup { + asset_id: None, + // Cannot be reissued. + control_asset: None, + metadata: None, + inputs: vec![], + outputs: vec![AssetOutput { + output_index: SELF_ISSUANCE_OUTPUT_INDEX, + amount: 123, + }], + }; + + // Get back unrelated asset in full. + let unrelated_group = AssetGroup { + asset_id: Some(unrelated_asset_id), + control_asset: None, + metadata: None, + inputs: vec![AssetInput { + input_index: 0, + amount: 7, + }], + outputs: vec![AssetOutput { + output_index: SELF_ISSUANCE_OUTPUT_INDEX, + amount: 7, + }], + }; + + let expected_packet = Packet { + groups: vec![issued_group, unrelated_group], + }; + + let asset_packet_index = asset_packet_index(&res.ark_tx); + + assert_eq!( + res.ark_tx.unsigned_tx.output[asset_packet_index], + expected_packet.to_txout() + ); + } + + #[test] + fn self_issuance_with_new_control_asset_emits_control_group_before_issued_group() { + let server_info = test_server_info(); + let (input, own_address) = self_issuance_input(Vec::new()); + + let res = build_self_asset_issuance_transactions( + &own_address, + &own_address, + &[input], + &server_info, + 123, + // Configured to mint a new control asset. + Some(ControlAssetConfig::new(5).unwrap()), + None, + ) + .unwrap(); + + // Acquire control asset in full. + let control_group = AssetGroup { + asset_id: None, + control_asset: None, + metadata: None, + inputs: vec![], + outputs: vec![AssetOutput { + output_index: SELF_ISSUANCE_OUTPUT_INDEX, + amount: 5, + }], + }; + + // Newly minted asset. + let issued_group = AssetGroup { + asset_id: None, + // Can be reissued. Referenced control asset by group because it's in the same + // transaction. + control_asset: Some(asset::packet::AssetRef::ByGroup(0)), + metadata: None, + inputs: vec![], + outputs: vec![AssetOutput { + output_index: SELF_ISSUANCE_OUTPUT_INDEX, + amount: 123, + }], + }; + + let expected_packet = Packet { + groups: vec![control_group, issued_group], + }; + + let asset_packet_index = asset_packet_index(&res.ark_tx); + + assert_eq!( + res.ark_tx.unsigned_tx.output[asset_packet_index], + expected_packet.to_txout() + ); + } + + #[test] + fn self_issuance_with_existing_control_asset_preserves_it_and_references_it_by_id() { + let server_info = test_server_info(); + let control_asset_id = AssetId { + txid: Txid::from_byte_array([14; 32]), + group_index: 2, + }; + + let (input, own_address) = self_issuance_input(vec![ + // Control asset. + Asset { + asset_id: control_asset_id, + amount: 3, + }, + ]); + + let res = build_self_asset_issuance_transactions( + &own_address, + &own_address, + &[input], + &server_info, + 123, + // Configured to reuse an existing control asset. + Some(ControlAssetConfig::existing(control_asset_id)), + None, + ) + .unwrap(); + + // Newly minted asset. + let issued_group = AssetGroup { + asset_id: None, + // Can be reissued. Referenced control asset by ID because it existed before this + // transaction. + control_asset: Some(asset::packet::AssetRef::ById(control_asset_id)), + metadata: None, + inputs: vec![], + outputs: vec![AssetOutput { + output_index: SELF_ISSUANCE_OUTPUT_INDEX, + amount: 123, + }], + }; + + // Get back control asset in full. + let control_group = AssetGroup { + asset_id: Some(control_asset_id), + control_asset: None, + metadata: None, + inputs: vec![AssetInput { + input_index: 0, + amount: 3, + }], + outputs: vec![AssetOutput { + output_index: SELF_ISSUANCE_OUTPUT_INDEX, + amount: 3, + }], + }; + + let expected_packet = Packet { + groups: vec![issued_group, control_group], + }; + + let asset_packet_index = asset_packet_index(&res.ark_tx); + + assert_eq!( + res.ark_tx.unsigned_tx.output[asset_packet_index], + expected_packet.to_txout() + ); + } + + #[test] + fn self_issuance_without_control_asset_input_errors() { + let server_info = test_server_info(); + let control_asset_id = AssetId { + txid: Txid::from_byte_array([16; 32]), + group_index: 3, + }; + let unrelated_asset_id = AssetId { + txid: Txid::from_byte_array([17; 32]), + group_index: 1, + }; + let (input, own_address) = self_issuance_input(vec![Asset { + asset_id: unrelated_asset_id, + amount: 9, + }]); + + let err = build_self_asset_issuance_transactions( + &own_address, + &own_address, + &[input], + &server_info, + 123, + Some(ControlAssetConfig::existing(control_asset_id)), + None, + ) + .unwrap_err(); + + assert!(err + .to_string() + .contains("control asset missing from issuance transaction inputs")); + } + + fn test_server_info() -> server::Info { + let signer_pk = "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + .parse() + .unwrap(); + let forfeit_pk = "03dff1d77f2a671c5f36183726db2341be58f8be17d2a3d1d2cd47b7b0f5f2d624" + .parse() + .unwrap(); + + server::Info { + version: "test".into(), + signer_pk, + forfeit_pk, + forfeit_address: "bcrt1q8frde3yn78tl9ecgq4anlz909jh0clefhucdur" + .parse::>() + .unwrap() + .require_network(Network::Regtest) + .unwrap(), + checkpoint_tapscript: Builder::new().push_opcode(OP_TRUE).into_script(), + network: Network::Regtest, + session_duration: 0, + unilateral_exit_delay: Sequence::MAX, + boarding_exit_delay: Sequence::MAX, + utxo_min_amount: None, + utxo_max_amount: None, + vtxo_min_amount: Some(Amount::from_sat(1)), + vtxo_max_amount: None, + dust: Amount::from_sat(330), + fees: None, + scheduled_session: None, + deprecated_signers: vec![], + service_status: Default::default(), + digest: "test".into(), + max_tx_weight: 40_000, + max_op_return_outputs: 3, + } + } + + fn self_issuance_input(assets: Vec) -> (VtxoInput, ArkAddress) { + let secp = Secp256k1::new(); + + let server_pk: bitcoin::key::PublicKey = + "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + .parse() + .unwrap(); + let owner_pk: bitcoin::key::PublicKey = + "03dff1d77f2a671c5f36183726db2341be58f8be17d2a3d1d2cd47b7b0f5f2d624" + .parse() + .unwrap(); + + let server_xonly = server_pk.inner.x_only_public_key().0; + let owner_xonly = owner_pk.inner.x_only_public_key().0; + let spend_script = multisig_script(server_xonly, owner_xonly); + let spend_info = TaprootBuilder::new() + .add_leaf(0, spend_script.clone()) + .unwrap() + .finalize(&secp, server_xonly) + .unwrap(); + let control_block = spend_info + .control_block(&(spend_script.clone(), LeafVersion::TapScript)) + .unwrap(); + let own_address = ArkAddress::new(Network::Regtest, server_xonly, spend_info.output_key()); + + ( + VtxoInput::new( + spend_script.clone(), + None, + control_block, + vec![spend_script], + own_address.to_p2tr_script_pubkey(), + Amount::from_sat(330), + OutPoint::new(Txid::from_byte_array([0; 32]), 0), + assets, + ), + own_address, + ) + } + + // The location of the asset packet in the transaction. It's always the second-to-last output, + // just before the anchor output. + fn asset_packet_index(ark_tx: &Psbt) -> usize { + ark_tx.unsigned_tx.output.len() - 2 + } +} diff --git a/vendor/ark-core/src/send/reissue_asset.rs b/vendor/ark-core/src/send/reissue_asset.rs new file mode 100644 index 0000000..0757534 --- /dev/null +++ b/vendor/ark-core/src/send/reissue_asset.rs @@ -0,0 +1,688 @@ +use crate::asset; +use crate::asset::packet::add_asset_packet_to_psbt; +use crate::asset::AssetId; +use crate::send::build_offchain_transactions; +use crate::send::OffchainTransactions; +use crate::send::SendReceiver; +use crate::send::VtxoInput; +use crate::server; +use crate::ArkAddress; +use crate::Error; +use bitcoin::hashes::Hash as _; +use bitcoin::Psbt; +use std::collections::HashMap; + +/// Self-controlled output used by reissuance packet assignments. +const SELF_REISSUANCE_OUTPUT_INDEX: u16 = 0; + +/// Unsigned transactions for asset reissuance. +#[derive(Debug, Clone)] +pub struct AssetReissuanceTransactions { + pub ark_tx: Psbt, + pub checkpoint_txs: Vec, +} + +/// Build unsigned offchain transactions for reissuing an existing asset to self. +/// +/// Output [`SELF_REISSUANCE_OUTPUT_INDEX`] remains self-controlled and carries the preserved +/// control-asset balance, the newly reissued asset amount, and any other assets already carried by +/// the selected inputs. +/// +/// If the selected inputs already carry units of `reissue_asset_id`, those existing units are +/// merged into the same asset group and output [`SELF_REISSUANCE_OUTPUT_INDEX`] allocation as the +/// newly reissued amount. +/// +/// # Arguments +/// +/// * `own_address` - The issuer's offchain address that receives the returned control asset and the +/// newly reissued asset amount +/// * `change_address` - The issuer's offchain change address, used if the transaction has BTC +/// change +/// * `inputs` - The selected VTXO inputs to spend, together with any assets they already carry +/// * `server_info` - Server configuration used to build the offchain transaction shape and dust +/// output +/// * `reissue_asset_id` - The ID of the existing asset being reissued +/// * `control_asset_id` - The ID of the control asset authorizing the reissuance +/// * `reissue_amount` - The additional amount of the asset to mint +/// +/// # Returns +/// +/// [`AssetReissuanceTransactions`] containing the unsigned Ark transaction and unsigned checkpoint +/// transactions. +/// +/// # Errors +/// +/// Returns an error if `reissue_amount` is zero, if unsigned offchain transaction construction +/// fails, or if the selected inputs do not include a non-zero balance of `control_asset_id` to +/// authorize the reissuance. +pub fn build_asset_reissuance_transactions( + own_address: &ArkAddress, + change_address: &ArkAddress, + inputs: &[VtxoInput], + server_info: &server::Info, + reissue_asset_id: AssetId, + control_asset_id: AssetId, + reissue_amount: u64, +) -> Result { + if reissue_amount == 0 { + return Err(Error::ad_hoc("reissue amount must be > 0")); + } + + let packet = + create_reissuance_packet(inputs, reissue_asset_id, control_asset_id, reissue_amount)?; + + let OffchainTransactions { + mut ark_tx, + checkpoint_txs, + } = build_offchain_transactions( + &[SendReceiver { + address: *own_address, + amount: server_info.dust, + assets: Vec::new(), + }], + change_address, + inputs, + server_info, + )?; + + add_asset_packet_to_psbt(&mut ark_tx, &packet)?; + + Ok(AssetReissuanceTransactions { + ark_tx, + checkpoint_txs, + }) +} + +/// Create the asset packet for a self-reissuance transaction. +/// +/// Output [`SELF_REISSUANCE_OUTPUT_INDEX`] is treated as the self-controlled destination for all +/// assets involved in the reissuance flow. The returned control asset, the newly reissued amount, +/// any existing carried balance of `reissue_asset_id`, and any other preserved carried assets are +/// all assigned to output [`SELF_REISSUANCE_OUTPUT_INDEX`]. +/// +/// # Arguments +/// +/// * `inputs` - The selected VTXO inputs to spend, together with any assets they already carry +/// * `reissue_asset_id` - The ID of the existing asset being reissued +/// * `control_asset_id` - The ID of the control asset authorizing the reissuance +/// * `reissue_amount` - The additional amount of the asset to mint +/// +/// # Returns +/// +/// An [`asset::packet::Packet`] describing how carried and newly reissued assets are assigned to +/// transaction output [`SELF_REISSUANCE_OUTPUT_INDEX`]. +/// +/// # Errors +/// +/// Returns an error if the selected inputs do not include a non-zero balance of +/// `control_asset_id` to authorize the reissuance. +fn create_reissuance_packet( + inputs: &[VtxoInput], + reissue_asset_id: AssetId, + control_asset_id: AssetId, + reissue_amount: u64, +) -> Result { + struct AssetTransfer { + inputs: Vec, + output_amount: u64, + } + + let mut transfers: HashMap = HashMap::new(); + + // For every distinct asset (reissue, control and unrelated!) in the input set, generate asset + // inputs and collect the total output amount (to ensure asset preservation). + for (input_index, input) in inputs.iter().enumerate() { + for asset in &input.assets { + let transfer = transfers + .entry(asset.asset_id) + .or_insert_with(|| AssetTransfer { + inputs: Vec::new(), + output_amount: 0, + }); + + transfer.inputs.push(asset::packet::AssetInput { + input_index: input_index as u16, + amount: asset.amount, + }); + transfer.output_amount = transfer + .output_amount + .checked_add(asset.amount) + .ok_or_else(|| Error::ad_hoc("reissuance preserved asset amount overflow"))?; + } + } + + // Ensure that control asset is an input to the transaction. Otherwise, the Arkade server will + // not authorise reissuance. + if !transfers.contains_key(&control_asset_id) { + return Err(Error::ad_hoc( + "control asset missing from reissuance transaction inputs", + )); + } + + // Actually include the newly minted assets in the asset output for the reissued asset. + { + let reissue_transfer = transfers + .entry(reissue_asset_id) + .or_insert_with(|| AssetTransfer { + inputs: Vec::new(), + output_amount: 0, + }); + reissue_transfer.output_amount = reissue_transfer + .output_amount + .checked_add(reissue_amount) + .ok_or_else(|| Error::ad_hoc("reissuance minted asset amount overflow"))?; + } + + let mut groups = transfers + .into_iter() + .map(|(asset_id, transfer)| asset::packet::AssetGroup { + asset_id: Some(asset_id), + control_asset: None, + metadata: None, + inputs: transfer.inputs, + outputs: vec![asset::packet::AssetOutput { + output_index: SELF_REISSUANCE_OUTPUT_INDEX, + amount: transfer.output_amount, + }], + }) + .collect::>(); + + // Sort the groups to make it easier to test the behaviour. This is _not_ required. + groups.sort_by_key(|group| { + let asset_id = group + .asset_id + .expect("reissuance packet groups always have asset ids"); + (*asset_id.txid.as_byte_array(), asset_id.group_index) + }); + + Ok(asset::packet::Packet { groups }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::asset::packet::AssetGroup; + use crate::asset::packet::AssetInput; + use crate::asset::packet::AssetOutput; + use crate::asset::packet::Packet; + use crate::script::multisig_script; + use crate::send::VtxoInput; + use crate::server::Asset; + use crate::server::Info; + use bitcoin::key::Secp256k1; + use bitcoin::opcodes::OP_TRUE; + use bitcoin::script::Builder; + use bitcoin::taproot::LeafVersion; + use bitcoin::taproot::TaprootBuilder; + use bitcoin::Amount; + use bitcoin::Network; + use bitcoin::OutPoint; + use bitcoin::Sequence; + use bitcoin::Txid; + + #[test] + fn self_reissuance_returns_control_asset_and_mints_reissued_asset() { + let server_info = test_server_info(); + let asset_id = AssetId { + txid: Txid::from_byte_array([1; 32]), + group_index: 0, + }; + let control_asset_id = AssetId { + txid: Txid::from_byte_array([2; 32]), + group_index: 1, + }; + + let (input, own_address) = self_reissuance_input( + Txid::from_byte_array([3; 32]), + // Only provide control asset as input. + vec![Asset { + asset_id: control_asset_id, + amount: 5, + }], + ); + + let res = build_asset_reissuance_transactions( + &own_address, + &own_address, + &[input], + &server_info, + asset_id, + control_asset_id, + 123, + ) + .unwrap(); + + // Newly minted asset. + let reissued_group = AssetGroup { + asset_id: Some(asset_id), + control_asset: None, + metadata: None, + inputs: vec![], + outputs: vec![AssetOutput { + output_index: 0, + amount: 123, + }], + }; + + // Get back control asset in full. + let control_group = AssetGroup { + asset_id: Some(control_asset_id), + control_asset: None, + metadata: None, + inputs: vec![AssetInput { + input_index: 0, + amount: 5, + }], + outputs: vec![AssetOutput { + output_index: 0, + amount: 5, + }], + }; + + let expected_packet = Packet { + groups: vec![reissued_group, control_group], + }; + + let asset_packet_index = asset_packet_index(&res.ark_tx); + + assert_eq!( + res.ark_tx.unsigned_tx.output[asset_packet_index], + expected_packet.to_txout() + ); + } + + #[test] + fn self_reissuance_preserves_unrelated_carried_assets_on_output_zero() { + let server_info = test_server_info(); + let asset_id = AssetId { + txid: Txid::from_byte_array([1; 32]), + group_index: 0, + }; + let control_asset_id = AssetId { + txid: Txid::from_byte_array([2; 32]), + group_index: 1, + }; + let unrelated_asset_id = AssetId { + txid: Txid::from_byte_array([3; 32]), + group_index: 2, + }; + + let (input, own_address) = self_reissuance_input( + Txid::from_byte_array([4; 32]), + vec![ + // Control asset. + Asset { + asset_id: control_asset_id, + amount: 1, + }, + // Unrelated asset. + Asset { + asset_id: unrelated_asset_id, + amount: 9, + }, + ], + ); + + let res = build_asset_reissuance_transactions( + &own_address, + &own_address, + &[input], + &server_info, + asset_id, + control_asset_id, + 123, + ) + .unwrap(); + + // Newly minted asset. + let reissued_group = AssetGroup { + asset_id: Some(asset_id), + control_asset: None, + metadata: None, + inputs: vec![], + outputs: vec![AssetOutput { + output_index: 0, + amount: 123, + }], + }; + + // Get back control asset in full. + let control_group = AssetGroup { + asset_id: Some(control_asset_id), + control_asset: None, + metadata: None, + inputs: vec![AssetInput { + input_index: 0, + amount: 1, + }], + outputs: vec![AssetOutput { + output_index: 0, + amount: 1, + }], + }; + + // Get back unrelated asset in full. + let unrelated_group = AssetGroup { + asset_id: Some(unrelated_asset_id), + control_asset: None, + metadata: None, + inputs: vec![AssetInput { + input_index: 0, + amount: 9, + }], + outputs: vec![AssetOutput { + output_index: 0, + amount: 9, + }], + }; + + let expected_packet = Packet { + groups: vec![reissued_group, control_group, unrelated_group], + }; + + let asset_packet_index = asset_packet_index(&res.ark_tx); + + assert_eq!( + res.ark_tx.unsigned_tx.output[asset_packet_index], + expected_packet.to_txout() + ); + } + + #[test] + fn self_reissuance_with_existing_asset_balance_merges_it_into_output_zero() { + let server_info = test_server_info(); + let asset_id = AssetId { + txid: Txid::from_byte_array([1; 32]), + group_index: 0, + }; + let control_asset_id = AssetId { + txid: Txid::from_byte_array([2; 32]), + group_index: 1, + }; + + let (input, own_address) = self_reissuance_input( + Txid::from_byte_array([3; 32]), + vec![ + // Control asset. + Asset { + asset_id: control_asset_id, + amount: 1, + }, + // The same asset we are about to reissue. + Asset { + asset_id, + amount: 7, + }, + ], + ); + + let res = build_asset_reissuance_transactions( + &own_address, + &own_address, + &[input], + &server_info, + asset_id, + control_asset_id, + 123, + ) + .unwrap(); + + // Reissued asset includes preserved balance plus newly minted amount. + let reissued_group = AssetGroup { + asset_id: Some(asset_id), + control_asset: None, + metadata: None, + inputs: vec![AssetInput { + input_index: 0, + amount: 7, + }], + outputs: vec![AssetOutput { + output_index: 0, + amount: 130, + }], + }; + + // Get back control asset in full. + let control_group = AssetGroup { + asset_id: Some(control_asset_id), + control_asset: None, + metadata: None, + inputs: vec![AssetInput { + input_index: 0, + amount: 1, + }], + outputs: vec![AssetOutput { + output_index: 0, + amount: 1, + }], + }; + + let expected_packet = Packet { + groups: vec![reissued_group, control_group], + }; + + let asset_packet_index = asset_packet_index(&res.ark_tx); + + assert_eq!( + res.ark_tx.unsigned_tx.output[asset_packet_index], + expected_packet.to_txout() + ); + } + + #[test] + fn self_reissuance_without_control_asset_input_errors() { + let server_info = test_server_info(); + let asset_id = AssetId { + txid: Txid::from_byte_array([1; 32]), + group_index: 0, + }; + let control_asset_id = AssetId { + txid: Txid::from_byte_array([2; 32]), + group_index: 1, + }; + let unrelated_asset_id = AssetId { + txid: Txid::from_byte_array([3; 32]), + group_index: 2, + }; + + let (input, own_address) = self_reissuance_input( + Txid::from_byte_array([4; 32]), + vec![Asset { + asset_id: unrelated_asset_id, + amount: 9, + }], + ); + + let err = build_asset_reissuance_transactions( + &own_address, + &own_address, + &[input], + &server_info, + asset_id, + control_asset_id, + 123, + ) + .unwrap_err(); + + assert!(err + .to_string() + .contains("control asset missing from reissuance transaction inputs")); + } + + #[test] + fn self_reissuance_errors_when_preserved_asset_amount_overflows() { + let server_info = test_server_info(); + let asset_id = AssetId { + txid: Txid::from_byte_array([5; 32]), + group_index: 0, + }; + let control_asset_id = AssetId { + txid: Txid::from_byte_array([6; 32]), + group_index: 1, + }; + + let (input_a, own_address) = self_reissuance_input( + Txid::from_byte_array([7; 32]), + vec![ + Asset { + asset_id: control_asset_id, + amount: 1, + }, + Asset { + asset_id, + amount: u64::MAX, + }, + ], + ); + let (input_b, _) = self_reissuance_input( + Txid::from_byte_array([8; 32]), + vec![Asset { + asset_id, + amount: 1, + }], + ); + + let err = build_asset_reissuance_transactions( + &own_address, + &own_address, + &[input_a, input_b], + &server_info, + asset_id, + control_asset_id, + 1, + ) + .unwrap_err(); + + assert!(err + .to_string() + .contains("reissuance preserved asset amount overflow")); + } + + #[test] + fn self_reissuance_errors_when_minted_asset_amount_overflows() { + let server_info = test_server_info(); + let asset_id = AssetId { + txid: Txid::from_byte_array([9; 32]), + group_index: 0, + }; + let control_asset_id = AssetId { + txid: Txid::from_byte_array([10; 32]), + group_index: 1, + }; + + let (input, own_address) = self_reissuance_input( + Txid::from_byte_array([11; 32]), + vec![ + Asset { + asset_id: control_asset_id, + amount: 1, + }, + Asset { + asset_id, + amount: u64::MAX, + }, + ], + ); + + let err = build_asset_reissuance_transactions( + &own_address, + &own_address, + &[input], + &server_info, + asset_id, + control_asset_id, + 1, + ) + .unwrap_err(); + + assert!(err + .to_string() + .contains("reissuance minted asset amount overflow")); + } + + fn test_server_info() -> Info { + let signer_pk = "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + .parse() + .unwrap(); + let forfeit_pk = "03dff1d77f2a671c5f36183726db2341be58f8be17d2a3d1d2cd47b7b0f5f2d624" + .parse() + .unwrap(); + + Info { + version: "test".into(), + signer_pk, + forfeit_pk, + forfeit_address: "bcrt1q8frde3yn78tl9ecgq4anlz909jh0clefhucdur" + .parse::>() + .unwrap() + .require_network(Network::Regtest) + .unwrap(), + checkpoint_tapscript: Builder::new().push_opcode(OP_TRUE).into_script(), + network: Network::Regtest, + session_duration: 0, + unilateral_exit_delay: Sequence::MAX, + boarding_exit_delay: Sequence::MAX, + utxo_min_amount: None, + utxo_max_amount: None, + vtxo_min_amount: Some(Amount::from_sat(1)), + vtxo_max_amount: None, + dust: Amount::from_sat(330), + fees: None, + scheduled_session: None, + deprecated_signers: vec![], + service_status: Default::default(), + digest: "test".into(), + max_tx_weight: 40_000, + max_op_return_outputs: 3, + } + } + + fn self_reissuance_input( + asset_bearing_input_txid: Txid, + assets: Vec, + ) -> (VtxoInput, ArkAddress) { + let secp = Secp256k1::new(); + + let server_pk: bitcoin::key::PublicKey = + "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + .parse() + .unwrap(); + let owner_pk: bitcoin::key::PublicKey = + "03dff1d77f2a671c5f36183726db2341be58f8be17d2a3d1d2cd47b7b0f5f2d624" + .parse() + .unwrap(); + + let server_xonly = server_pk.inner.x_only_public_key().0; + let owner_xonly = owner_pk.inner.x_only_public_key().0; + let spend_script = multisig_script(server_xonly, owner_xonly); + let spend_info = TaprootBuilder::new() + .add_leaf(0, spend_script.clone()) + .unwrap() + .finalize(&secp, server_xonly) + .unwrap(); + let control_block = spend_info + .control_block(&(spend_script.clone(), LeafVersion::TapScript)) + .unwrap(); + let own_address = ArkAddress::new(Network::Regtest, server_xonly, spend_info.output_key()); + + ( + VtxoInput::new( + spend_script.clone(), + None, + control_block, + vec![spend_script], + own_address.to_p2tr_script_pubkey(), + Amount::from_sat(330), + OutPoint::new(asset_bearing_input_txid, 0), + assets, + ), + own_address, + ) + } + + // The location of the asset packet in the transaction. It's always the second-to-last output, + // just before the anchor output. + fn asset_packet_index(ark_tx: &Psbt) -> usize { + ark_tx.unsigned_tx.output.len() - 2 + } +} diff --git a/vendor/ark-core/src/server.rs b/vendor/ark-core/src/server.rs new file mode 100644 index 0000000..d0b2a7f --- /dev/null +++ b/vendor/ark-core/src/server.rs @@ -0,0 +1,1223 @@ +//! Messages exchanged between the client and the Ark server. + +use crate::asset::AssetId; +use crate::tx_graph::TxGraphChunk; +use crate::ArkAddress; +use crate::Error; +use crate::ErrorContext; +use bitcoin::hex::DisplayHex; +use bitcoin::secp256k1::PublicKey; +use bitcoin::taproot::Signature; +use bitcoin::Amount; +use bitcoin::OutPoint; +use bitcoin::Psbt; +use bitcoin::ScriptBuf; +use bitcoin::Transaction; +use bitcoin::Txid; +use bitcoin::XOnlyPublicKey; +use musig::musig; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::str::FromStr; + +/// arkd build version targeted by this SDK. +/// +/// Sent in the `X-Build-Version`/`x-build-version` request header so arkd can +/// reject clients that target an older incompatible server version. This is the +/// arkd protocol target, not the Rust crate version. +/// +/// Update this when the SDK intentionally targets a newer arkd compatibility +/// baseline. +pub const TARGET_ARKD_VERSION: &str = "0.9.15"; + +/// Version of this SDK, as `rust-sdk/`. +/// +/// Sent in the `X-SDK-Version`/`x-sdk-version` request header so arkd can attribute +/// traffic to this SDK and release. The version is resolved from the crate version at +/// compile time, unlike [`TARGET_ARKD_VERSION`], which is the hand-maintained arkd +/// compatibility target. +pub const SDK_VERSION: &str = concat!("rust-sdk/", env!("CARGO_PKG_VERSION")); + +/// An aggregate public nonce per shared internal (non-leaf) node in the batch-tree. +#[derive(Debug, Clone)] +pub struct NoncePks(HashMap); + +impl NoncePks { + pub fn new(nonce_pks: HashMap) -> Self { + Self(nonce_pks) + } + + /// Get the [`MusigPubNonce`] for the transaction identified by `txid`. + pub fn get(&self, txid: &Txid) -> Option { + self.0.get(txid).copied() + } + + pub fn encode(&self) -> HashMap { + self.0 + .iter() + .map(|(k, v)| (k.to_string(), v.serialize().to_lower_hex_string())) + .collect() + } + + pub fn decode(map: HashMap) -> Result { + let map = map + .into_iter() + .map(|(k, v)| { + let key = k + .parse() + .map_err(Error::ad_hoc) + .context("failed to parse TXID")?; + + let value = { + let nonce_bytes = bitcoin::hex::FromHex::from_hex(&v) + .map_err(Error::ad_hoc) + .context("failed to decode public nonce from hex")?; + musig::PublicNonce::from_byte_array(&nonce_bytes) + .map_err(Error::ad_hoc) + .context("failed to decode public nonce from bytes")? + }; + + Ok((key, value)) + }) + .collect::, Error>>()?; + + Ok(Self(map)) + } +} + +/// A public nonce per public key, where each public key corresponds to a party signing a +/// transaction in the batch-tree. +#[derive(Debug, Clone)] +pub struct TreeTxNoncePks(pub HashMap); + +impl TreeTxNoncePks { + pub fn new(tree_nonce_pks: HashMap) -> Self { + Self(tree_nonce_pks) + } + + pub fn to_pks(&self) -> Vec { + self.0.values().copied().collect() + } + + pub fn encode(&self) -> HashMap { + self.0 + .iter() + .map(|(k, v)| (k.to_string(), v.serialize().to_lower_hex_string())) + .collect() + } + + pub fn decode(map: HashMap) -> Result { + let map = map + .into_iter() + .map(|(k, v)| { + let key = k + .parse() + .map_err(Error::ad_hoc) + .context("failed to parse PK")?; + + let value = { + let nonce_bytes = bitcoin::hex::FromHex::from_hex(&v) + .map_err(Error::ad_hoc) + .context("failed to decode public nonce from hex")?; + musig::PublicNonce::from_byte_array(&nonce_bytes) + .map_err(Error::ad_hoc) + .context("failed to decode public nonce from bytes")? + }; + + Ok((key, value)) + }) + .collect::, Error>>()?; + + Ok(Self(map)) + } +} + +/// A Musig partial signature per shared internal (non-leaf) node in the batch-tree. +#[derive(Debug, Clone, Default)] +pub struct PartialSigTree(pub HashMap); + +impl PartialSigTree { + pub fn encode(&self) -> HashMap { + self.0 + .iter() + .map(|(k, v)| (k.to_string(), v.serialize().to_lower_hex_string())) + .collect() + } + + pub fn decode(map: HashMap) -> Result { + let map = map + .into_iter() + .map(|(k, v)| { + let key = k + .parse() + .map_err(Error::ad_hoc) + .context("failed to parse TXID")?; + + let value = { + let sig_bytes = bitcoin::hex::FromHex::from_hex(&v) + .map_err(Error::ad_hoc) + .context("failed to decode partial signature from hex")?; + musig::PartialSignature::from_byte_array(&sig_bytes) + .map_err(Error::ad_hoc) + .context("failed to decode partial signature from bytes")? + }; + + Ok((key, value)) + }) + .collect::, Error>>()?; + + Ok(Self(map)) + } +} + +#[derive(Debug, Clone, Default)] +pub struct TxTree { + pub nodes: BTreeMap<(usize, usize), TxTreeNode>, +} + +impl TxTree { + pub fn new() -> Self { + Self { + nodes: BTreeMap::new(), + } + } + + pub fn get_mut(&mut self, level: usize, index: usize) -> Result<&mut TxTreeNode, Error> { + self.nodes + .get_mut(&(level, index)) + .ok_or_else(|| Error::ad_hoc("TxTreeNode not found at ({level}, {index})")) + } + + pub fn insert(&mut self, node: TxTreeNode, level: usize, index: usize) { + self.nodes.insert((level, index), node); + } + + pub fn txs(&self) -> impl Iterator { + self.nodes.values().map(|node| &node.tx.unsigned_tx) + } + + /// Get all nodes at a specific level. + pub fn get_level(&self, level: usize) -> Vec<&TxTreeNode> { + self.nodes + .range((level, 0)..(level + 1, 0)) + .map(|(_, node)| node) + .collect() + } + + /// Iterate over levels in order. + pub fn iter_levels(&self) -> impl Iterator)> { + let max_level = self + .nodes + .keys() + .map(|(level, _)| *level) + .max() + .unwrap_or(0); + + (0..=max_level).map(move |level| { + let nodes = self.get_level(level); + (level, nodes) + }) + } +} + +#[derive(Debug, Clone)] +pub struct TxTreeNode { + pub txid: Txid, + pub tx: Psbt, + pub parent_txid: Txid, + pub level: i32, + pub level_index: i32, + pub leaf: bool, +} + +#[derive(Clone)] +pub struct GetVtxosRequest { + reference: GetVtxosRequestReference, + filter: Option, + page: Option, + before: Option, + after: Option, +} + +/// Page request for paginated queries. +#[derive(Debug, Clone, Copy)] +pub struct PageRequest { + /// Number of items per page. + pub size: i32, + /// Page index (0-based). + pub index: i32, +} + +impl GetVtxosRequest { + pub fn new_for_addresses(addresses: impl Iterator) -> Self { + let scripts = addresses + .flat_map(|a| [a.to_p2tr_script_pubkey()]) + .collect(); + + Self { + reference: GetVtxosRequestReference::Scripts(scripts), + filter: None, + page: None, + before: None, + after: None, + } + } + + pub fn new_for_outpoints(outpoints: &[OutPoint]) -> Self { + Self { + reference: GetVtxosRequestReference::OutPoints(outpoints.to_vec()), + filter: None, + page: None, + before: None, + after: None, + } + } + + /// Restrict the request to the VTXOs matching `filter`. Only one filter can be set. + pub fn with_filter(self, filter: GetVtxosRequestFilter) -> Result { + if self.filter.is_some() { + return Err(Error::ad_hoc("GetVtxosRequest filter already set")); + } + + Ok(Self { + filter: Some(filter), + ..self + }) + } + + pub fn spendable_only(self) -> Result { + self.with_filter(GetVtxosRequestFilter::Spendable) + } + + pub fn spent_only(self) -> Result { + self.with_filter(GetVtxosRequestFilter::Spent) + } + + pub fn recoverable_only(self) -> Result { + self.with_filter(GetVtxosRequestFilter::Recoverable) + } + + pub fn pending_only(self) -> Result { + self.with_filter(GetVtxosRequestFilter::PendingOnly) + } + + /// Only VTXOs that are neither spent nor unrolled: the spendable and the recoverable ones. + pub fn renewable_only(self) -> Result { + self.with_filter(GetVtxosRequestFilter::Renewable) + } + + pub fn reference(&self) -> &GetVtxosRequestReference { + &self.reference + } + + pub fn filter(&self) -> Option<&GetVtxosRequestFilter> { + self.filter.as_ref() + } + + pub fn with_page(self, size: i32, index: i32) -> Self { + Self { + page: Some(PageRequest { size, index }), + ..self + } + } + + pub fn page(&self) -> Option { + self.page + } + + pub fn with_before(self, before: u64) -> Self { + Self { + before: Some(before), + ..self + } + } + + pub fn with_after(self, after: u64) -> Self { + Self { + after: Some(after), + ..self + } + } + + pub fn before(&self) -> Option { + self.before + } + pub fn after(&self) -> Option { + self.after + } +} + +#[derive(Clone)] +pub enum GetVtxosRequestReference { + Scripts(Vec), + OutPoints(Vec), +} + +impl GetVtxosRequestReference { + pub fn is_empty(&self) -> bool { + match self { + GetVtxosRequestReference::Scripts(script_bufs) => script_bufs.is_empty(), + GetVtxosRequestReference::OutPoints(outpoints) => outpoints.is_empty(), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GetVtxosRequestFilter { + Spendable, + Spent, + Recoverable, + PendingOnly, + Renewable, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VirtualTxOutPoint { + pub outpoint: OutPoint, + pub created_at: i64, + pub expires_at: i64, + pub amount: Amount, + pub script: ScriptBuf, + /// A pre-confirmed VTXO spends from another VTXO and is not a leaf of a batch-tree. + pub is_preconfirmed: bool, + pub is_swept: bool, + pub is_unrolled: bool, + pub is_spent: bool, + /// If the VTXO is spent, this field references the _checkpoint transaction_ that actually + /// spends it. The corresponding Ark transaction is in the `ark_txid` field. + /// + /// If the VTXO is renewed, this field references the corresponding _forfeit transaction_. + pub spent_by: Option, + /// The list of commitment transactions that are ancestors to this VTXO. + pub commitment_txids: Vec, + /// The commitment TXID onto which this VTXO was forfeited. + pub settled_by: Option, + /// The Ark transaction that _spends_ this VTXO (if we omit the checkpoint transaction). + pub ark_txid: Option, + /// Assets carried by this VTXO. + pub assets: Vec, + /// Number of pre-confirmed transactions between this VTXO and the closest batch-tree leaf. + /// + /// Zero for VTXOs that are batch-tree leaves themselves. + pub depth: u32, +} + +impl VirtualTxOutPoint { + /// Check if a VTXO is recoverable. + /// + /// Recoverable VTXOs can be settled, but they cannot be sent in an offchain transaction. To + /// settle them, the original VTXO does not need to be forfeited, as the Arkade server already + /// controls it. + /// + /// An unrolled VTXO is never recoverable: it is onchain, and the Arkade server does not take it + /// into a batch. + pub fn is_recoverable(&self, dust: Amount) -> bool { + if self.is_spent || self.is_unrolled { + return false; + } + + self.amount < dust || self.is_swept || self.is_expired() + } + + /// Check if a VTXO should be treated as unspent wallet state. + pub fn is_unspent(&self, dust: Amount) -> bool { + self.is_recoverable(dust) || (!self.is_unrolled && !self.is_spent && !self.is_swept) + } + + /// Check if a VTXO can be spent in an offchain transaction. + pub fn is_spendable_offchain(&self, dust: Amount) -> bool { + !self.is_recoverable(dust) && !self.is_unrolled && !self.is_spent && !self.is_swept + } + + pub fn is_pre_confirmed_spendable(&self, dust: Amount) -> bool { + self.is_spendable_offchain(dust) && self.is_preconfirmed + } + + pub fn is_confirmed_spendable(&self, dust: Amount) -> bool { + self.is_spendable_offchain(dust) && !self.is_preconfirmed + } + + /// Check if a VTXO should be treated as spent wallet state. + pub fn is_spent_status(&self, dust: Amount) -> bool { + !self.is_recoverable(dust) && (self.is_unrolled || self.is_spent || self.is_swept) + } + + /// Check if a VTXO has expired. + /// + /// Expired VTXOs can be settled, but they cannot be sent in an offchain transaction. To settle + /// them, the original VTXO must be forfeited. + /// + /// NOTE: The server's concept of now may differ from the client's, so client and server may + /// sometimes disagree on whether a VTXO has expired or not. + pub fn is_expired(&self) -> bool { + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + let current_timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("valid duration") + .as_secs() as i64; + + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + let current_timestamp = (js_sys::Date::now() / 1000.0) as i64; + + current_timestamp > self.expires_at && !self.is_swept && !self.is_spent + } +} + +#[derive(Clone, Debug)] +pub struct Info { + pub version: String, + pub signer_pk: PublicKey, + pub forfeit_pk: PublicKey, + pub forfeit_address: bitcoin::Address, + pub checkpoint_tapscript: ScriptBuf, + pub network: bitcoin::Network, + pub session_duration: u64, + pub unilateral_exit_delay: bitcoin::Sequence, + pub boarding_exit_delay: bitcoin::Sequence, + pub utxo_min_amount: Option, + pub utxo_max_amount: Option, + pub vtxo_min_amount: Option, + pub vtxo_max_amount: Option, + pub dust: Amount, + pub fees: Option, + pub scheduled_session: Option, + pub deprecated_signers: Vec, + pub service_status: HashMap, + pub digest: String, + pub max_tx_weight: i64, + pub max_op_return_outputs: i64, +} + +/// Fee information from the server. +#[derive(Clone, Debug)] +pub struct FeeInfo { + pub intent_fee: IntentFeeInfo, + pub tx_fee_rate: String, +} + +/// Intent fee information. +/// +/// These are CEL like programs which need to be evaluated during runtime. See [`ark-fees`] module +/// for details. +#[derive(Clone, Debug, Default)] +pub struct IntentFeeInfo { + pub offchain_input: Option, + pub offchain_output: Option, + pub onchain_input: Option, + pub onchain_output: Option, +} + +#[derive(Clone, Debug)] +pub struct ScheduledSession { + pub next_start_time: i64, + pub next_end_time: i64, + pub period: i64, + pub duration: i64, + pub fees: Option, +} + +#[derive(Clone, Debug)] +pub struct DeprecatedSigner { + pub pk: PublicKey, + pub cutoff_date: i64, +} + +/// Status of a deprecated server signer at a specific point in time. +/// +/// arkd uses `cutoff_date == 0` to mean "rotate immediately" rather than "cutoff already +/// passed". The operator still co-signs for that key, so the status is distinct from +/// [`Self::Expired`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeprecatedSignerStatus { + /// The signer has a future cooperative-sign cutoff (`cutoff_date > now`). + Migratable, + /// The signer should be migrated immediately (`cutoff_date == 0`), but still co-signs. + DueNow, + /// The cooperative-sign cutoff has passed (`cutoff_date != 0 && cutoff_date <= now`). + Expired, +} + +impl DeprecatedSignerStatus { + /// Classify an advertised deprecated-signer cutoff against `now_unix_secs`. + pub fn from_cutoff(cutoff_date: i64, now_unix_secs: i64) -> Self { + if cutoff_date == 0 { + Self::DueNow + } else if cutoff_date > now_unix_secs { + Self::Migratable + } else { + Self::Expired + } + } + + /// Seconds until the cooperative-sign cutoff, when it is in the future. + pub fn seconds_until_cutoff(self, cutoff_date: i64, now_unix_secs: i64) -> Option { + match self { + Self::Migratable => Some(cutoff_date - now_unix_secs), + Self::DueNow | Self::Expired => None, + } + } + + /// Whether outputs under this deprecated signer are still cooperatively migratable. + pub fn is_cooperatively_migratable(self) -> bool { + matches!(self, Self::Migratable | Self::DueNow) + } +} + +/// Rotation status for any server signer key known to a client. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServerSignerStatus { + /// The server's current signing key. + Current, + /// A deprecated signing key advertised by the server. + Deprecated(DeprecatedSignerStatus), + /// A key that is neither current nor advertised as deprecated. + Unknown, +} + +impl ServerSignerStatus { + /// Whether this key is a deprecated signer whose cooperative-sign window has closed. + pub fn requires_recovery(self) -> bool { + matches!(self, Self::Deprecated(DeprecatedSignerStatus::Expired)) + } + + /// Whether this key belongs to a deprecated signer that can still be cooperatively migrated. + pub fn is_pre_cutoff_deprecated(self) -> bool { + matches!( + self, + Self::Deprecated(DeprecatedSignerStatus::Migratable | DeprecatedSignerStatus::DueNow) + ) + } +} + +impl Info { + /// Returns all known server signing keys: the current signer followed by all deprecated ones. + pub fn all_server_keys(&self) -> impl Iterator + '_ { + std::iter::once(self.signer_pk.x_only_public_key().0).chain( + self.deprecated_signers + .iter() + .map(|ds| ds.pk.x_only_public_key().0), + ) + } + + /// Classify `server_pk` relative to the current and deprecated signers advertised by `/info`. + pub fn signer_status_at( + &self, + server_pk: XOnlyPublicKey, + now_unix_secs: i64, + ) -> ServerSignerStatus { + if self.signer_pk.x_only_public_key().0 == server_pk { + return ServerSignerStatus::Current; + } + + self.deprecated_signers + .iter() + .find(|ds| ds.pk.x_only_public_key().0 == server_pk) + .map(|ds| { + ServerSignerStatus::Deprecated(DeprecatedSignerStatus::from_cutoff( + ds.cutoff_date, + now_unix_secs, + )) + }) + .unwrap_or(ServerSignerStatus::Unknown) + } + + /// Return the deprecated-signer status for `server_pk`, if the key is deprecated. + pub fn deprecated_signer_status_at( + &self, + server_pk: XOnlyPublicKey, + now_unix_secs: i64, + ) -> Option { + match self.signer_status_at(server_pk, now_unix_secs) { + ServerSignerStatus::Deprecated(status) => Some(status), + ServerSignerStatus::Current | ServerSignerStatus::Unknown => None, + } + } + + /// Returns `true` when `server_pk` belongs to a deprecated signer whose cooperative-sign + /// window has closed and whose outputs must wait for recovery instead of joining cooperative + /// spends. A `cutoff_date` of `0` means "rotate immediately" but remains co-signable. + pub fn signer_requires_recovery_at( + &self, + server_pk: XOnlyPublicKey, + now_unix_secs: i64, + ) -> bool { + self.signer_status_at(server_pk, now_unix_secs) + .requires_recovery() + } + + /// Backwards-compatible name for [`Self::signer_requires_recovery_at`]. + pub fn is_signer_past_cutoff_at(&self, server_pk: XOnlyPublicKey, now_unix_secs: i64) -> bool { + self.signer_requires_recovery_at(server_pk, now_unix_secs) + } +} + +#[derive(Debug, Clone)] +pub struct StreamStartedEvent { + pub id: String, +} + +#[derive(Debug, Clone)] +pub struct BatchStartedEvent { + pub id: String, + pub intent_id_hashes: Vec, + pub batch_expiry: bitcoin::Sequence, +} + +#[derive(Debug, Clone)] +pub struct BatchFinalizationEvent { + pub id: String, + pub commitment_tx: Psbt, +} + +#[derive(Debug, Clone)] +pub struct BatchFinalizedEvent { + pub id: String, + pub commitment_txid: Txid, +} + +#[derive(Debug, Clone)] +pub struct BatchFailed { + pub id: String, + pub reason: String, +} + +#[derive(Debug, Clone)] +pub struct TreeSigningStartedEvent { + pub id: String, + pub cosigners_pubkeys: Vec, + pub unsigned_commitment_tx: Psbt, +} + +#[derive(Debug, Clone)] +pub struct TreeNoncesAggregatedEvent { + pub id: String, + pub tree_nonces: NoncePks, +} + +#[derive(Debug, Clone)] +pub struct TreeTxEvent { + pub id: String, + pub topic: Vec, + pub batch_tree_event_type: BatchTreeEventType, + pub tx_graph_chunk: TxGraphChunk, +} + +#[derive(Debug, Clone)] +pub struct TreeSignatureEvent { + pub id: String, + pub topic: Vec, + pub batch_tree_event_type: BatchTreeEventType, + pub txid: Txid, + pub signature: Signature, +} + +#[derive(Debug, Clone)] +pub struct TreeNoncesEvent { + pub id: String, + pub topic: Vec, + pub txid: Txid, + pub nonces: TreeTxNoncePks, +} + +#[derive(Debug, Clone)] +pub enum BatchTreeEventType { + Vtxo, + Connector, +} + +#[derive(Debug, Clone)] +pub enum StreamEvent { + StreamStarted(StreamStartedEvent), + BatchStarted(BatchStartedEvent), + BatchFinalization(BatchFinalizationEvent), + BatchFinalized(BatchFinalizedEvent), + BatchFailed(BatchFailed), + TreeSigningStarted(TreeSigningStartedEvent), + TreeNoncesAggregated(TreeNoncesAggregatedEvent), + TreeTx(TreeTxEvent), + TreeSignature(TreeSignatureEvent), + TreeNonces(TreeNoncesEvent), + Heartbeat, +} + +impl StreamEvent { + pub fn name(&self) -> String { + let s = match self { + StreamEvent::StreamStarted(_) => "StreamStarted", + StreamEvent::BatchStarted(_) => "BatchStarted", + StreamEvent::BatchFinalization(_) => "BatchFinalization", + StreamEvent::BatchFinalized(_) => "BatchFinalized", + StreamEvent::BatchFailed(_) => "BatchFailed", + StreamEvent::TreeSigningStarted(_) => "TreeSigningStarted", + StreamEvent::TreeNoncesAggregated(_) => "TreeNoncesAggregated", + StreamEvent::TreeTx(_) => "TreeTx", + StreamEvent::TreeSignature(_) => "TreeSignature", + StreamEvent::TreeNonces(_) => "TreeNoncesEvent", + StreamEvent::Heartbeat => "Heartbeat", + }; + + s.to_string() + } +} + +pub enum StreamTransactionData { + Commitment(CommitmentTransaction), + Ark(ArkTransaction), + Heartbeat, +} + +pub struct ArkTransaction { + pub txid: Txid, + pub tx: Option, + pub spent_vtxos: Vec, + pub unspent_vtxos: Vec, + /// key: outpoint, value: checkpoint txid. Only set for offchain txs. + pub checkpoint_txs: HashMap, + pub swept_vtxos: Vec, +} + +pub struct CommitmentTransaction { + pub txid: Txid, + pub spent_vtxos: Vec, + pub unspent_vtxos: Vec, +} + +#[derive(Clone, Debug)] +pub enum SubscriptionResponse { + Event(Box), + Heartbeat, + SubscriptionStarted { subscription_id: String }, +} + +/// Filter for indexer subscriptions. +/// +/// A tx event is dispatched to a subscription when any of its expressions evaluates to true on +/// the event's tx, OR when the event carries a VTXO whose script is in the subscription's script +/// set. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SubscriptionFilter { + /// CEL expressions evaluated against each indexed tx envelope. The indexer combines them + /// with OR. On update, the field's contents replace any previously set expressions, even + /// when the list is empty (which clears them entirely). + pub expressions: Vec, + /// Scripts to add to the subscription's script set. + pub add_scripts: Vec, + /// Scripts to remove from the subscription's script set. + pub remove_scripts: Vec, +} + +/// Proof of ownership for indexer queries that return transaction data. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum IndexerAuth { + /// Intent that directly proves ownership of the queried vtxo or transaction inputs. If + /// passed, the outpoint/txids request fields are ignored by the server. + Intent { proof: String, message: String }, + /// A token returned by a previous `GetVtxoChain` or `GetVirtualTxs` call, which can be + /// recycled once ownership has already been proved. + Token(String), +} + +#[derive(Clone, Debug)] +pub struct SubscriptionEvent { + pub txid: Txid, + pub scripts: Vec, + pub new_vtxos: Vec, + pub spent_vtxos: Vec, + pub tx: Option, + pub checkpoint_txs: HashMap, +} + +pub struct VtxoChains { + pub inner: Vec, +} + +pub struct VtxoChain { + pub txid: Txid, + pub tx_type: ChainedTxType, + pub spends: Vec, + pub expires_at: i64, +} + +#[derive(Debug)] +pub enum ChainedTxType { + Commitment, + Tree, + Checkpoint, + Ark, + Unspecified, +} + +pub struct SubmitOffchainTxResponse { + pub signed_ark_tx: Psbt, + pub signed_checkpoint_txs: Vec, +} + +#[derive(Debug, Clone)] +pub struct PendingTx { + pub ark_txid: Txid, + pub signed_ark_tx: Psbt, + pub signed_checkpoint_txs: Vec, +} + +#[derive(Debug, Clone)] +pub struct FinalizeOffchainTxResponse {} + +#[derive(Debug)] +pub struct VirtualTxsResponse { + pub txs: Vec, + pub page: Option, + /// Token that can be recycled as [`IndexerAuth::Token`] for other indexer queries related to + /// the same vtxos/txs. + pub auth_token: Option, +} + +#[derive(Debug)] +pub struct IndexerPage { + pub current: i32, + pub next: i32, + pub total: i32, +} + +#[derive(Clone, Debug)] +pub enum Network { + Bitcoin, + Testnet, + Testnet4, + Signet, + Regtest, + Mutinynet, +} + +/// An asset carried by a VTXO. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Asset { + pub asset_id: AssetId, + pub amount: u64, +} + +/// Metadata about an issued asset, including its control asset reference. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetInfo { + pub asset_id: AssetId, + pub control_asset_id: Option, + pub supply: u64, + pub metadata: String, +} + +impl AssetInfo { + pub fn can_be_reissued(&self) -> bool { + self.control_asset_id.is_some() + } +} + +impl From for bitcoin::Network { + fn from(value: Network) -> Self { + match value { + Network::Bitcoin => bitcoin::Network::Bitcoin, + Network::Testnet => bitcoin::Network::Testnet, + Network::Testnet4 => bitcoin::Network::Testnet4, + Network::Signet => bitcoin::Network::Signet, + Network::Regtest => bitcoin::Network::Regtest, + Network::Mutinynet => bitcoin::Network::Signet, + } + } +} + +impl FromStr for Network { + type Err = String; + + #[inline] + fn from_str(s: &str) -> Result { + match s { + "bitcoin" => Ok(Network::Bitcoin), + "testnet" => Ok(Network::Testnet), + "testnet4" => Ok(Network::Testnet4), + "signet" => Ok(Network::Signet), + "regtest" => Ok(Network::Regtest), + "mutinynet" => Ok(Network::Mutinynet), + _ => Err(format!("Unsupported network {}", s.to_owned())), + } + } +} + +pub fn parse_sequence_number(value: i64) -> Result { + /// The threshold that determines whether an expiry or exit delay should be parsed as a + /// number of blocks or a number of seconds. + /// + /// - A value below 512 is considered a number of blocks. + /// - A value of 512 or more is considered a number of seconds. + const ARBITRARY_SEQUENCE_THRESHOLD: i64 = 512; + + let sequence = if value.is_negative() { + return Err(Error::ad_hoc(format!("invalid sequence number: {value}"))); + } else if value < ARBITRARY_SEQUENCE_THRESHOLD { + bitcoin::Sequence::from_height(value as u16) + } else { + let secs = u32::try_from(value) + .map_err(|_| Error::ad_hoc(format!("sequence seconds overflow: {value}")))?; + + bitcoin::Sequence::from_seconds_ceil(secs).map_err(Error::ad_hoc)? + }; + + Ok(sequence) +} + +/// Parse a fee amount string as satoshis. Returns Amount::ZERO for empty or missing strings. +pub fn parse_fee_amount(amount_str: Option) -> Amount { + amount_str + .and_then(|s| { + if s.is_empty() { + None + } else { + s.parse::().ok() + } + }) + .map(Amount::from_sat) + .unwrap_or(Amount::ZERO) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::address::NetworkUnchecked; + use bitcoin::secp256k1::PublicKey; + use std::collections::HashMap; + use std::str::FromStr; + + // Well-known compressed secp256k1 public keys used as test fixtures. + const PK_A: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const PK_B: &str = "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + const PK_C: &str = "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9"; + const PK_UNRELATED: &str = "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4"; + + fn pk(hex: &str) -> PublicKey { + PublicKey::from_str(hex).unwrap() + } + + fn xonly(hex: &str) -> XOnlyPublicKey { + pk(hex).x_only_public_key().0 + } + + fn make_info(current_hex: &str, deprecated: Vec<(&str, i64)>) -> Info { + let dummy_address: bitcoin::Address = + "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx" + .parse() + .unwrap(); + Info { + version: "1".into(), + signer_pk: pk(current_hex), + forfeit_pk: pk(current_hex), + forfeit_address: dummy_address.assume_checked(), + checkpoint_tapscript: ScriptBuf::new(), + network: bitcoin::Network::Testnet, + session_duration: 0, + unilateral_exit_delay: bitcoin::Sequence::ZERO, + boarding_exit_delay: bitcoin::Sequence::ZERO, + utxo_min_amount: None, + utxo_max_amount: None, + vtxo_min_amount: None, + vtxo_max_amount: None, + dust: Amount::ZERO, + fees: None, + scheduled_session: None, + deprecated_signers: deprecated + .into_iter() + .map(|(key, cutoff)| DeprecatedSigner { + pk: pk(key), + cutoff_date: cutoff, + }) + .collect(), + service_status: HashMap::new(), + digest: String::new(), + max_tx_weight: 0, + max_op_return_outputs: 0, + } + } + + // ── all_server_keys ────────────────────────────────────────────────────── + + #[test] + fn all_server_keys_no_deprecated() { + let info = make_info(PK_A, vec![]); + let keys: Vec<_> = info.all_server_keys().collect(); + assert_eq!(keys, vec![xonly(PK_A)]); + } + + #[test] + fn all_server_keys_includes_deprecated_in_order() { + let info = make_info(PK_A, vec![(PK_B, 1000), (PK_C, 2000)]); + let keys: Vec<_> = info.all_server_keys().collect(); + assert_eq!(keys, vec![xonly(PK_A), xonly(PK_B), xonly(PK_C)]); + } + + #[test] + fn all_server_keys_current_is_always_first() { + let info = make_info(PK_C, vec![(PK_A, 500), (PK_B, 600)]); + let keys: Vec<_> = info.all_server_keys().collect(); + assert_eq!(keys[0], xonly(PK_C)); + } + + // ── signer_status_at ──────────────────────────────────────────────────── + + #[test] + fn signer_status_classifies_current_deprecated_and_unknown() { + let now = 1_000_000i64; + let info = make_info(PK_A, vec![(PK_B, 0), (PK_C, now + 10)]); + + assert_eq!( + info.signer_status_at(xonly(PK_A), now), + ServerSignerStatus::Current + ); + assert_eq!( + info.signer_status_at(xonly(PK_B), now), + ServerSignerStatus::Deprecated(DeprecatedSignerStatus::DueNow) + ); + assert_eq!( + info.signer_status_at(xonly(PK_C), now), + ServerSignerStatus::Deprecated(DeprecatedSignerStatus::Migratable) + ); + assert_eq!( + info.signer_status_at(xonly(PK_UNRELATED), now), + ServerSignerStatus::Unknown + ); + } + + #[test] + fn signer_status_expired_requires_recovery() { + let now = 1_000_000i64; + let info = make_info(PK_A, vec![(PK_B, now)]); + + let status = info.signer_status_at(xonly(PK_B), now); + assert_eq!( + status, + ServerSignerStatus::Deprecated(DeprecatedSignerStatus::Expired) + ); + assert!(status.requires_recovery()); + } + + // ── is_signer_past_cutoff_at ───────────────────────────────────────────── + + #[test] + fn current_signer_key_is_never_past_cutoff() { + let info = make_info(PK_A, vec![]); + assert!(!info.is_signer_past_cutoff_at(xonly(PK_A), i64::MAX)); + } + + #[test] + fn unknown_key_is_not_past_cutoff() { + let info = make_info(PK_A, vec![(PK_B, 100)]); + assert!(!info.is_signer_past_cutoff_at(xonly(PK_UNRELATED), 200)); + } + + #[test] + fn cutoff_zero_means_rotate_immediately_not_past_cutoff() { + // cutoff_date == 0 means "rotate now" but the operator still co-signs. + // is_signer_past_cutoff_at must return false so the key is not excluded from batches. + let info = make_info(PK_A, vec![(PK_B, 0)]); + assert!(!info.is_signer_past_cutoff_at(xonly(PK_B), 9_999_999)); + } + + #[test] + fn future_cutoff_is_not_past() { + let now = 1_000_000i64; + let info = make_info(PK_A, vec![(PK_B, now + 1)]); + assert!(!info.is_signer_past_cutoff_at(xonly(PK_B), now)); + } + + #[test] + fn exact_cutoff_boundary_is_past() { + let now = 1_000_000i64; + let info = make_info(PK_A, vec![(PK_B, now)]); + assert!(info.is_signer_past_cutoff_at(xonly(PK_B), now)); + } + + #[test] + fn past_cutoff_is_past() { + let now = 1_000_000i64; + let info = make_info(PK_A, vec![(PK_B, now - 1)]); + assert!(info.is_signer_past_cutoff_at(xonly(PK_B), now)); + } + + #[test] + fn multiple_deprecated_only_past_key_is_flagged() { + let now = 1_000_000i64; + // PK_B: future (not past), PK_C: past + let info = make_info(PK_A, vec![(PK_B, now + 100), (PK_C, now - 100)]); + assert!(!info.is_signer_past_cutoff_at(xonly(PK_B), now)); + assert!(info.is_signer_past_cutoff_at(xonly(PK_C), now)); + } + + // ── GetVtxosRequest filters ────────────────────────────────────────────── + + #[test] + fn renewable_only_sets_the_renewable_filter() { + let request = GetVtxosRequest::new_for_outpoints(&[]) + .renewable_only() + .unwrap(); + + assert_eq!(request.filter(), Some(&GetVtxosRequestFilter::Renewable)); + } + + #[test] + fn a_second_filter_is_rejected() { + let request = GetVtxosRequest::new_for_outpoints(&[]) + .renewable_only() + .unwrap(); + + assert!(request.clone().spendable_only().is_err()); + assert!(request + .with_filter(GetVtxosRequestFilter::Renewable) + .is_err()); + } + + // ── VirtualTxOutPoint status ───────────────────────────────────────────── + + fn vtxo(amount: u64, is_swept: bool, is_unrolled: bool, expires_at: i64) -> VirtualTxOutPoint { + VirtualTxOutPoint { + outpoint: OutPoint::null(), + created_at: 0, + expires_at, + amount: Amount::from_sat(amount), + script: ScriptBuf::new(), + is_preconfirmed: false, + is_swept, + is_unrolled, + is_spent: false, + spent_by: None, + commitment_txids: Vec::new(), + settled_by: None, + ark_txid: None, + assets: Vec::new(), + depth: 0, + } + } + + const DUST: Amount = Amount::from_sat(330); + const FAR_FUTURE: i64 = i64::MAX; + + #[test] + fn unrolled_vtxo_is_never_recoverable() { + let sub_dust = vtxo(100, false, true, FAR_FUTURE); + let swept = vtxo(10_000, true, true, FAR_FUTURE); + let expired = vtxo(10_000, false, true, 0); + + for vtxo in [sub_dust, swept, expired] { + assert!(!vtxo.is_recoverable(DUST)); + assert!(!vtxo.is_unspent(DUST)); + assert!(vtxo.is_spent_status(DUST)); + } + } + + #[test] + fn recoverable_vtxos_that_are_not_unrolled_stay_recoverable() { + let sub_dust = vtxo(100, false, false, FAR_FUTURE); + let swept = vtxo(10_000, true, false, FAR_FUTURE); + let expired = vtxo(10_000, false, false, 0); + + for vtxo in [sub_dust, swept, expired] { + assert!(vtxo.is_recoverable(DUST)); + assert!(vtxo.is_unspent(DUST)); + assert!(!vtxo.is_spent_status(DUST)); + } + } +} diff --git a/vendor/ark-core/src/tree_tx_output_script.rs b/vendor/ark-core/src/tree_tx_output_script.rs new file mode 100644 index 0000000..ab2d152 --- /dev/null +++ b/vendor/ark-core/src/tree_tx_output_script.rs @@ -0,0 +1,36 @@ +use crate::script::csv_sig_script; +use bitcoin::key::Secp256k1; +use bitcoin::secp256k1::All; +use bitcoin::taproot::LeafVersion; +use bitcoin::taproot::TaprootBuilder; +use bitcoin::taproot::TaprootSpendInfo; +use bitcoin::ScriptBuf; +use bitcoin::XOnlyPublicKey; + +/// The script of an _internal_ node of the batch-tree. By internal node we mean a non-leaf node. +/// +/// This script allows the Ark server to sweep the entire output after the `vtxo_tree_expiry` +/// seconds have passed from the time the output was included in a block. +pub struct TreeTxOutputScript { + script: ScriptBuf, +} + +impl TreeTxOutputScript { + pub fn new(vtxo_tree_expiry: bitcoin::Sequence, server: XOnlyPublicKey) -> Self { + let script = csv_sig_script(vtxo_tree_expiry, server); + + Self { script } + } + + pub fn sweep_spend_leaf( + &self, + secp: &Secp256k1, + aggregate_pk: XOnlyPublicKey, + ) -> TaprootSpendInfo { + TaprootBuilder::new() + .add_leaf_with_ver(0, self.script.clone(), LeafVersion::TapScript) + .expect("valid sweep leaf") + .finalize(secp, aggregate_pk) + .expect("can be finalized") + } +} diff --git a/vendor/ark-core/src/tx_graph.rs b/vendor/ark-core/src/tx_graph.rs new file mode 100644 index 0000000..774cbfa --- /dev/null +++ b/vendor/ark-core/src/tx_graph.rs @@ -0,0 +1,300 @@ +use crate::Error; +use crate::VTXO_INPUT_INDEX; +use bitcoin::taproot::Signature; +use bitcoin::Psbt; +use bitcoin::Sequence; +use bitcoin::Txid; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct TxGraph { + root: Psbt, + children: HashMap, +} + +#[derive(Debug, Clone)] +pub struct TxGraphChunk { + pub txid: Option, + pub tx: Psbt, + pub children: HashMap, +} + +impl TxGraph { + pub fn new(chunks: Vec) -> Result { + if chunks.is_empty() { + return Err(Error::ad_hoc("empty chunks")); + } + + // Create a map to store all chunks by their txid for easy lookup. + let mut chunks_by_txid = HashMap::new(); + + for chunk in chunks { + let txid = chunk.tx.unsigned_tx.compute_txid(); + Self::validate_finality(txid, &chunk.tx)?; + + chunks_by_txid.insert(txid, chunk); + } + + // Find the root chunks (the ones that aren't referenced as a child). + let mut root_txids = Vec::new(); + + for txid in chunks_by_txid.keys() { + let mut is_child = false; + + for (other_txid, other_chunk) in &chunks_by_txid { + if other_txid == txid { + // Skip self. + continue; + } + + // Check if the current chunk is a child of the other chunk. + if other_chunk + .children + .values() + .any(|child_txid| child_txid == txid) + { + is_child = true; + break; + } + } + + // If the chunk is not a child of any other chunk, it is a root. + if !is_child { + root_txids.push(*txid); + } + } + + if root_txids.is_empty() { + return Err(Error::ad_hoc("no root chunk found")); + } + + if root_txids.len() > 1 { + return Err(Error::ad_hoc(format!( + "multiple root chunks found: {root_txids:?}", + ))); + } + + let graph = Self::build_graph(root_txids[0], &chunks_by_txid).ok_or_else(|| { + Error::ad_hoc(format!("chunk not found for root txid: {}", root_txids[0])) + })?; + + // Verify that the number of chunks is equal to the number of nodes in the graph + let chunk_count = chunks_by_txid.len(); + let node_count = graph.nb_of_nodes(); + if node_count != chunk_count { + return Err(Error::ad_hoc(format!( + "number of chunks ({chunk_count}) is not equal to \ + the number of nodes in the graph ({node_count})", + ))); + } + + Ok(graph) + } + + fn build_graph(root_txid: Txid, chunks_by_txid: &HashMap) -> Option { + let root_chunk = chunks_by_txid.get(&root_txid)?; + + let mut children = HashMap::new(); + + for (output_index, child_txid) in &root_chunk.children { + if let Some(child_graph) = Self::build_graph(*child_txid, chunks_by_txid) { + children.insert(*output_index, child_graph); + } + } + + Some(TxGraph { + root: root_chunk.tx.clone(), + children, + }) + } + + /// A non-final transaction cannot be broadcast on demand, so a non-final graph would give us an + /// unroll path that is unusable exactly when we need it. + fn validate_finality(txid: Txid, tx: &Psbt) -> Result<(), Error> { + let lock_time = tx.unsigned_tx.lock_time.to_consensus_u32(); + if lock_time != 0 { + return Err(Error::ad_hoc(format!( + "non-zero lock time ({lock_time}) in tx graph chunk {txid}", + ))); + } + + for input in &tx.unsigned_tx.input { + let sequence = input.sequence; + if sequence != Sequence::MAX { + return Err(Error::ad_hoc(format!( + "non-final sequence ({sequence:#x}) in tx graph chunk {txid}", + ))); + } + } + + Ok(()) + } + + pub fn nb_of_nodes(&self) -> usize { + let mut nb = 1; + for child in self.children.values() { + nb += child.nb_of_nodes(); + } + nb + } + + pub fn apply(&mut self, f: F) -> Result<(), Error> + where + F: Fn(&mut TxGraph) -> Result + Copy, + { + let should_continue = f(self)?; + + if !should_continue { + return Ok(()); + } + + for child in self.children.values_mut() { + child.apply(f)?; + } + + Ok(()) + } + + pub fn find(&self, txid: &Txid) -> Option<&Self> { + if self.root.unsigned_tx.compute_txid() == *txid { + return Some(self); + } + + for child in self.children.values() { + if let Some(node) = child.find(txid) { + return Some(node); + } + } + + None + } + + pub fn as_map(&self) -> HashMap { + fn _as_map<'a>(graph: &'a TxGraph, map: &mut HashMap) { + map.insert(graph.root.unsigned_tx.compute_txid(), &graph.root); + + for child in graph.children.values() { + _as_map(child, map); + } + } + + let mut map = HashMap::new(); + _as_map(self, &mut map); + + map + } + + pub fn set_signature(&mut self, sig: Signature) { + self.root.inputs[VTXO_INPUT_INDEX].tap_key_sig = Some(sig); + } + + pub fn root(&self) -> &Psbt { + &self.root + } + + /// Return all leaf nodes (transactions without children) in the graph. + pub fn leaves(&self) -> Vec<&Psbt> { + if self.children.is_empty() { + return vec![&self.root]; + } + + let mut leaves = Vec::new(); + for child in self.children.values() { + leaves.extend(child.leaves()); + } + + leaves + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::absolute::LockTime; + use bitcoin::transaction::Version; + use bitcoin::Amount; + use bitcoin::ScriptBuf; + use bitcoin::Transaction; + use bitcoin::TxIn; + use bitcoin::TxOut; + + fn chunk(value: u64, lock_time: LockTime, sequence: Sequence) -> TxGraphChunk { + let tx = Transaction { + version: Version(3), + lock_time, + input: vec![TxIn { + sequence, + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(value), + script_pubkey: ScriptBuf::new(), + }], + }; + + TxGraphChunk { + txid: None, + tx: Psbt::from_unsigned_tx(tx).unwrap(), + children: HashMap::new(), + } + } + + fn final_chunk(value: u64) -> TxGraphChunk { + chunk(value, LockTime::ZERO, Sequence::MAX) + } + + fn txid(chunk: &TxGraphChunk) -> Txid { + chunk.tx.unsigned_tx.compute_txid() + } + + #[test] + fn accepts_final_graph() { + let child = final_chunk(1_000); + + let mut root = final_chunk(2_000); + root.children.insert(0, txid(&child)); + + let graph = TxGraph::new(vec![root, child]).unwrap(); + + assert_eq!(graph.nb_of_nodes(), 2); + } + + #[test] + fn rejects_non_zero_lock_time() { + let non_final = chunk(1_000, LockTime::from_consensus(800_000), Sequence::MAX); + let non_final_txid = txid(&non_final); + + let error = TxGraph::new(vec![non_final]).unwrap_err().to_string(); + + assert!(error.contains("lock time"), "{error}"); + assert!(error.contains("800000"), "{error}"); + assert!(error.contains(&non_final_txid.to_string()), "{error}"); + } + + #[test] + fn rejects_non_final_sequence() { + let non_final = chunk(1_000, LockTime::ZERO, Sequence::from_height(144)); + let non_final_txid = txid(&non_final); + + let error = TxGraph::new(vec![non_final]).unwrap_err().to_string(); + + assert!(error.contains("sequence"), "{error}"); + assert!(error.contains("0x90"), "{error}"); + assert!(error.contains(&non_final_txid.to_string()), "{error}"); + } + + #[test] + fn rejects_non_final_sequence_in_non_root_chunk() { + let child = chunk(1_000, LockTime::ZERO, Sequence::ENABLE_RBF_NO_LOCKTIME); + let child_txid = txid(&child); + + let mut root = final_chunk(2_000); + root.children.insert(0, child_txid); + + let error = TxGraph::new(vec![root, child]).unwrap_err().to_string(); + + assert!(error.contains("sequence"), "{error}"); + assert!(error.contains("0xfffffffd"), "{error}"); + assert!(error.contains(&child_txid.to_string()), "{error}"); + } +} diff --git a/vendor/ark-core/src/unilateral_exit.rs b/vendor/ark-core/src/unilateral_exit.rs new file mode 100644 index 0000000..bfcca79 --- /dev/null +++ b/vendor/ark-core/src/unilateral_exit.rs @@ -0,0 +1,895 @@ +use crate::anchor_output; +use crate::contract::SpendSelection; +use crate::script::extract_checksig_pubkeys; +use crate::server; +use crate::Error; +use crate::ErrorContext; +use crate::VTXO_CONDITION_KEY; +use crate::VTXO_INPUT_INDEX; +use bitcoin::absolute::LockTime; +use bitcoin::consensus::Decodable; +use bitcoin::hashes::Hash; +use bitcoin::hex::DisplayHex; +use bitcoin::key::Secp256k1; +use bitcoin::psbt; +use bitcoin::secp256k1; +use bitcoin::secp256k1::schnorr; +use bitcoin::sighash::Prevouts; +use bitcoin::sighash::SighashCache; +use bitcoin::taproot; +use bitcoin::transaction; +use bitcoin::Address; +use bitcoin::Amount; +use bitcoin::OutPoint; +use bitcoin::Psbt; +use bitcoin::ScriptBuf; +use bitcoin::Sequence; +use bitcoin::TapLeafHash; +use bitcoin::TapSighashType; +use bitcoin::Transaction; +use bitcoin::TxIn; +use bitcoin::TxOut; +use bitcoin::Txid; +use bitcoin::VarInt; +use bitcoin::Weight; +use bitcoin::Witness; +use bitcoin::XOnlyPublicKey; +use std::collections::HashMap; +use std::collections::HashSet; + +/// A UTXO that could have become a VTXO with the help of the Ark server, but is now unilaterally +/// spendable by the original owner. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct OnChainInput { + /// The sequence needed by the selected spend path. + sequence: Sequence, + /// The script pubkey of the UTXO. + script_pubkey: ScriptBuf, + /// The selected spend path. + spend_info: (ScriptBuf, taproot::ControlBlock), + /// The amount of coins locked in the UTXO. + amount: Amount, + /// The location of this UTXO in the blockchain. + outpoint: OutPoint, +} + +impl OnChainInput { + pub fn new( + sequence: Sequence, + script_pubkey: ScriptBuf, + spend_info: (ScriptBuf, taproot::ControlBlock), + amount: Amount, + outpoint: OutPoint, + ) -> Self { + Self { + sequence, + script_pubkey, + spend_info, + amount, + outpoint, + } + } + + pub fn new_with_spend_selection( + default_sequence: Sequence, + script_pubkey: ScriptBuf, + spend_selection: SpendSelection, + amount: Amount, + outpoint: OutPoint, + ) -> Self { + Self::new( + spend_selection.sequence.unwrap_or(default_sequence), + script_pubkey, + spend_selection.spend_info(), + amount, + outpoint, + ) + } + + pub fn previous_output(&self) -> TxOut { + TxOut { + value: self.amount, + script_pubkey: self.script_pubkey.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct VtxoInput { + outpoint: OutPoint, + sequence: Sequence, + witness_utxo: TxOut, + /// Where the VTXO would end up on the blockchain if it were to become a UTXO. + spend_info: (ScriptBuf, taproot::ControlBlock), +} + +impl VtxoInput { + pub fn new( + outpoint: OutPoint, + sequence: Sequence, + witness_utxo: TxOut, + spend_info: (ScriptBuf, taproot::ControlBlock), + ) -> Self { + Self { + outpoint, + sequence, + witness_utxo, + spend_info, + } + } + + pub fn new_with_spend_selection( + outpoint: OutPoint, + default_sequence: Sequence, + witness_utxo: TxOut, + spend_selection: SpendSelection, + ) -> Self { + Self::new( + outpoint, + spend_selection.sequence.unwrap_or(default_sequence), + witness_utxo, + spend_selection.spend_info(), + ) + } + + pub fn previous_output(&self) -> TxOut { + self.witness_utxo.clone() + } +} + +/// Build a transaction that spends boarding outputs and VTXOs to an _on-chain_ `to_address`. Any +/// coins left over after covering the `to_amount` are sent to an on-chain change address. +/// +/// All these outputs are spent unilaterally i.e. without the collaboration of the Ark server. +/// +/// To be able to spend a boarding output, we must wait for the exit delay to pass. +/// +/// To be able to spend a VTXO, the VTXO itself must be published on-chain, and then we must wait +/// for the exit delay to pass. +pub fn create_unilateral_exit_transaction( + to_address: Address, + to_amount: Amount, + change_address: Address, + onchain_inputs: &[OnChainInput], + vtxo_inputs: &[VtxoInput], + sign_fn: S, +) -> Result +where + S: Fn( + &mut psbt::Input, + secp256k1::Message, + ) -> Result, Error>, +{ + if onchain_inputs.is_empty() && vtxo_inputs.is_empty() { + return Err(Error::transaction( + "cannot create transaction without inputs", + )); + } + + let secp = Secp256k1::new(); + + let mut output = vec![TxOut { + value: to_amount, + script_pubkey: to_address.script_pubkey(), + }]; + + let total_amount: Amount = onchain_inputs + .iter() + .map(|o| o.amount) + .chain(vtxo_inputs.iter().map(|v| v.witness_utxo.value)) + .sum(); + + let change_amount = total_amount.checked_sub(to_amount).ok_or_else(|| { + Error::transaction(format!( + "cannot cover to_amount ({to_amount}) with total input amount ({total_amount})" + )) + })?; + + if change_amount > Amount::ZERO { + output.push(TxOut { + value: change_amount, + script_pubkey: change_address.script_pubkey(), + }); + } + + let input = { + let onchain_inputs = onchain_inputs.iter().map(|o| TxIn { + previous_output: o.outpoint, + sequence: o.sequence, + ..Default::default() + }); + + let vtxo_inputs = vtxo_inputs.iter().map(|v| TxIn { + previous_output: v.outpoint, + sequence: v.sequence, + ..Default::default() + }); + + onchain_inputs.chain(vtxo_inputs).collect::>() + }; + + let mut psbt = Psbt::from_unsigned_tx(Transaction { + version: transaction::Version::TWO, + lock_time: LockTime::ZERO, + input, + output, + }) + .map_err(Error::transaction)?; + + // Add a `witness_utxo` for every transaction input. + for (i, input) in psbt.inputs.iter_mut().enumerate() { + let outpoint = psbt.unsigned_tx.input[i].previous_output; + + for onchain_input in onchain_inputs { + if onchain_input.outpoint == outpoint { + input.witness_utxo = Some(TxOut { + value: onchain_input.amount, + script_pubkey: onchain_input.script_pubkey.clone(), + }); + + let (script, cb) = onchain_input.spend_info.clone(); + let leaf_version = cb.leaf_version; + input.tap_scripts.insert(cb, (script, leaf_version)); + } + } + + for vtxo_input in vtxo_inputs.iter() { + if vtxo_input.outpoint == outpoint { + input.witness_utxo = Some(TxOut { + value: vtxo_input.witness_utxo.value, + script_pubkey: vtxo_input.witness_utxo.script_pubkey.clone(), + }); + + let (script, cb) = vtxo_input.spend_info.clone(); + let leaf_version = cb.leaf_version; + input.tap_scripts.insert(cb, (script, leaf_version)); + } + } + } + + // Collect all `witness_utxo` entries. + let prevouts = psbt + .inputs + .iter() + .filter_map(|i| i.witness_utxo.clone()) + .collect::>(); + + // Sign each input. + for (i, input) in psbt.inputs.iter_mut().enumerate() { + let (exit_control_block, (exit_script, leaf_version)) = input + .tap_scripts + .pop_first() + .ok_or_else(|| Error::ad_hoc(format!("no exit script found for input {i}")))?; + + input.witness_script = Some(exit_script.clone()); + + let leaf_hash = TapLeafHash::from_script(&exit_script, leaf_version); + + let tap_sighash = SighashCache::new(&psbt.unsigned_tx) + .taproot_script_spend_signature_hash( + i, + &Prevouts::All(&prevouts), + leaf_hash, + TapSighashType::Default, + ) + .map_err(Error::crypto)?; + + let msg = secp256k1::Message::from_digest(tap_sighash.to_raw_hash().to_byte_array()); + + let sigs = sign_fn(input, msg)?; + + let mut witness = Vec::new(); + for (sig, pk) in sigs.iter() { + secp.verify_schnorr(sig, &msg, pk) + .map_err(Error::crypto) + .with_context(|| format!("failed to verify own signature for input {i}"))?; + + witness.push(&sig[..]); + } + + witness.push(exit_script.as_bytes()); + + let control_block = exit_control_block.serialize(); + witness.push(control_block.as_slice()); + + let witness = Witness::from_slice(&witness); + + input.final_script_witness = Some(witness); + } + + let tx = psbt.clone().extract_tx().map_err(Error::transaction)?; + + tracing::debug!( + ?onchain_inputs, + ?vtxo_inputs, + raw_tx = %bitcoin::consensus::serialize(&tx).as_hex(), + "Built transaction sending inputs to on-chain address" + ); + + Ok(tx) +} + +/// Build a topologically sorted unilateral exit branch of TXIDs for a VTXO from a +/// [`server::VtxoChains`]. +/// +/// The returned branch contains every virtual transaction in the ancestor sub-DAG exactly once, +/// ordered so every transaction appears after its virtual parents. This avoids enumerating every +/// distinct root-to-leaf path, which can be exponential when a VTXO has many merged ancestors. +pub fn build_unilateral_exit_tree_txids( + vtxo_chains: &server::VtxoChains, + // The TXID of the VTXO we want to commit on-chain. + ark_txid: Txid, +) -> Result>, Error> { + let chain_map = vtxo_chains + .inner + .iter() + .map(|vtxo_chain| (vtxo_chain.txid, vtxo_chain)) + .collect::>(); + + fn visit_virtual_ancestors( + current_txid: Txid, + chain_map: &HashMap, + visiting: &mut HashSet, + visited: &mut HashSet, + sorted: &mut Vec, + ) -> Result { + if visited.contains(¤t_txid) { + return Ok(true); + } + + if !visiting.insert(current_txid) { + return Err(Error::ad_hoc("chain traversal led to cycle")); + } + + let chain = chain_map.get(¤t_txid).ok_or_else(|| { + Error::ad_hoc(format!("could not find VtxoChain for TXID: {current_txid}")) + })?; + + if chain.spends.is_empty() { + return Err(Error::ad_hoc(format!( + "dead end reached at TXID {current_txid} with no commitment transaction" + ))); + } + + let mut reached_commitment = false; + for &parent_txid in &chain.spends { + let parent_chain = chain_map.get(&parent_txid).ok_or_else(|| { + Error::ad_hoc(format!( + "could not find VtxoChain for parent TXID: {parent_txid}", + )) + })?; + + match parent_chain.tx_type { + server::ChainedTxType::Commitment => { + reached_commitment = true; + } + server::ChainedTxType::Ark + | server::ChainedTxType::Checkpoint + | server::ChainedTxType::Tree => { + reached_commitment |= + visit_virtual_ancestors(parent_txid, chain_map, visiting, visited, sorted)?; + } + server::ChainedTxType::Unspecified => { + tracing::warn!( + txid = %parent_txid, + "Found unspecified TX type when walking up virtual TX tree. \ + Treating it like a virtual TX" + ); + + reached_commitment |= + visit_virtual_ancestors(parent_txid, chain_map, visiting, visited, sorted)?; + } + } + } + + visiting.remove(¤t_txid); + visited.insert(current_txid); + sorted.push(current_txid); + + Ok(reached_commitment) + } + + let mut visiting = HashSet::new(); + let mut visited = HashSet::new(); + let mut sorted = Vec::new(); + + if !visit_virtual_ancestors( + ark_txid, + &chain_map, + &mut visiting, + &mut visited, + &mut sorted, + )? { + return Err(Error::ad_hoc(format!( + "no path found from Ark TX {ark_txid} to commitment transaction", + ))); + } + + Ok(vec![sorted]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn txid(n: u8) -> Txid { + Txid::from_byte_array([n; 32]) + } + + fn chain( + txid: Txid, + tx_type: server::ChainedTxType, + spends: impl Into>, + ) -> server::VtxoChain { + server::VtxoChain { + txid, + tx_type, + spends: spends.into(), + expires_at: 0, + } + } + + fn exit_branch(chains: Vec, ark_txid: Txid) -> Vec { + build_unilateral_exit_tree_txids(&server::VtxoChains { inner: chains }, ark_txid) + .expect("valid unilateral exit branch") + .pop() + .expect("one topological branch") + } + + #[test] + fn condition_witness_elements_decode_encoded_witness() { + let elements = vec![ + b"preimage".to_vec(), + Vec::new(), + vec![0; 253], + vec![1, 2, 3, 4], + ]; + let mut input = psbt::Input::default(); + + input.unknown.insert( + psbt::raw::Key { + type_value: 222, + key: VTXO_CONDITION_KEY.to_vec(), + }, + crate::intent::encode_witness(&elements), + ); + + assert_eq!(condition_witness_elements(&input).unwrap(), elements); + } + + #[test] + fn unilateral_exit_txids_for_linear_chain_are_parent_first() { + let commitment = txid(1); + let tree = txid(2); + let ark = txid(3); + + let branch = exit_branch( + vec![ + chain(commitment, server::ChainedTxType::Commitment, []), + chain(tree, server::ChainedTxType::Tree, [commitment]), + chain(ark, server::ChainedTxType::Ark, [tree]), + ], + ark, + ); + + assert_eq!(branch, vec![tree, ark]); + } + + #[test] + fn unilateral_exit_txids_deduplicate_merged_ancestor_dag() { + let commitment = txid(1); + let left = txid(2); + let right = txid(3); + let merge = txid(4); + let ark = txid(5); + + let branch = exit_branch( + vec![ + chain(commitment, server::ChainedTxType::Commitment, []), + chain(left, server::ChainedTxType::Tree, [commitment]), + chain(right, server::ChainedTxType::Tree, [commitment]), + chain(merge, server::ChainedTxType::Checkpoint, [left, right]), + chain(ark, server::ChainedTxType::Ark, [merge]), + ], + ark, + ); + + assert_eq!(branch, vec![left, right, merge, ark]); + } + + #[test] + fn unilateral_exit_txids_avoid_exponential_path_enumeration() { + let commitment = txid(1); + let a1 = txid(2); + let b1 = txid(3); + let m1 = txid(4); + let a2 = txid(5); + let b2 = txid(6); + let m2 = txid(7); + let ark = txid(8); + + let branch = exit_branch( + vec![ + chain(commitment, server::ChainedTxType::Commitment, []), + chain(a1, server::ChainedTxType::Tree, [commitment]), + chain(b1, server::ChainedTxType::Tree, [commitment]), + chain(m1, server::ChainedTxType::Checkpoint, [a1, b1]), + chain(a2, server::ChainedTxType::Tree, [m1]), + chain(b2, server::ChainedTxType::Tree, [m1]), + chain(m2, server::ChainedTxType::Checkpoint, [a2, b2]), + chain(ark, server::ChainedTxType::Ark, [m2]), + ], + ark, + ); + + assert_eq!(branch, vec![a1, b1, m1, a2, b2, m2, ark]); + } + + #[test] + fn unilateral_exit_txids_reject_cycles() { + let a = txid(1); + let b = txid(2); + + let err = build_unilateral_exit_tree_txids( + &server::VtxoChains { + inner: vec![ + chain(a, server::ChainedTxType::Ark, [b]), + chain(b, server::ChainedTxType::Checkpoint, [a]), + ], + }, + a, + ) + .expect_err("cycle should be rejected"); + + assert!(err.to_string().contains("cycle")); + } +} + +/// The full path from a commitment transaction to a VTXO. The entire path must be published +/// on-chain to execute a unilateral exit with this VTXO. +/// +/// A branch may contain both batch-tree internal node transactions, which spend their parent via +/// key path, and VTXO spend transactions, which spend a confirmed or pre-confirmed VTXO via script +/// path. We use the word "tree" because a VTXO may come from more than one path, e.g. if its +/// corresponding Ark transaction has more than one input. +pub struct UnilateralExitTree { + /// The commitment transactions from which this VTXO comes from. + /// + /// A pre-confirmed VTXO can have ancestors from more than one batch, hence the list. + commitment_txids: Vec, + /// The chains of virtual transactions that lead to a VTXO. + /// + /// Virtual TXs in a branch are ordered by distance to the root commitment transaction, with + /// virtual TXs closest to it appearing first. + inner: Vec>, +} + +impl UnilateralExitTree { + pub fn new(commitment_txids: Vec, virtual_tx_tree: Vec>) -> Self { + Self { + commitment_txids, + inner: virtual_tx_tree, + } + } + + pub fn inner(&self) -> &Vec> { + &self.inner + } + + pub fn commitment_txids(&self) -> &[Txid] { + &self.commitment_txids + } +} + +/// Finalize a virtual transaction input using only the authorization data already present in the +/// PSBT input. +/// +/// This is intended for historical virtual transactions in a unilateral-exit branch. The caller +/// provides the `witness_utxo` for the input being finalized, and this function materializes either +/// the taproot key-spend witness used by batch-tree internal nodes or a satisfiable taproot +/// script-spend witness used when spending VTXOs. +pub fn finalize_virtual_tx_input( + mut psbt: Psbt, + input_index: usize, + witness_utxo: TxOut, +) -> Result { + let input = psbt + .inputs + .get_mut(input_index) + .ok_or_else(|| Error::transaction(format!("missing PSBT input {input_index}")))?; + + input.witness_utxo = Some(witness_utxo); + + let txid = psbt.unsigned_tx.compute_txid(); + + if let Some(tap_key_sig) = input.tap_key_sig { + tracing::debug!(%txid, "Finalizing batch-tree internal node key spend"); + + input.final_script_witness = Some(Witness::p2tr_key_spend(&tap_key_sig)); + } else { + tracing::debug!(%txid, "Finalizing VTXO script spend"); + + input.final_script_witness = Some(finalize_taproot_script_spend_witness(input)?); + } + + psbt.extract_tx().map_err(Error::transaction) +} + +/// Build the final witness for a taproot script-spend input from its PSBT data. +/// +/// The selected tapleaf is the first tap script for which signatures are available for every +/// `CHECKSIG`/`CHECKSIGVERIFY` pubkey in the script. Signatures are pushed in reverse script order. +/// Extra condition witness elements, such as VHTLC preimages, are read from the +/// `VTXO_CONDITION_KEY` unknown input field and pushed after signatures. +/// +/// Condition witness elements are therefore placed at the top of the initial script stack. This +/// requires condition-checking opcodes, such as `OP_HASH160` or `OP_SIZE`, to appear before any +/// `CHECKSIG`/`CHECKSIGVERIFY` opcode in the tapleaf script. +pub fn finalize_taproot_script_spend_witness(input: &psbt::Input) -> Result { + for (control_block, (script, leaf_version)) in input.tap_scripts.iter() { + let leaf_hash = TapLeafHash::from_script(script, *leaf_version); + let pubkeys = extract_checksig_pubkeys(script); + + if pubkeys.is_empty() { + continue; + } + + let signatures = pubkeys + .iter() + .map(|pk| { + input + .tap_script_sigs + .get(&(*pk, leaf_hash)) + .map(|sig| sig.to_vec()) + }) + .collect::>>(); + + let Some(signatures) = signatures else { + continue; + }; + + let mut witness = Witness::new(); + + for signature in signatures.into_iter().rev() { + witness.push(signature); + } + + for element in condition_witness_elements(input)? { + witness.push(element); + } + + witness.push(script.as_bytes()); + witness.push(control_block.serialize()); + + return Ok(witness); + } + + Err(Error::transaction( + "no satisfiable taproot script-spend leaf found in PSBT input", + )) +} + +fn condition_witness_elements(input: &psbt::Input) -> Result>, Error> { + let condition_key = psbt::raw::Key { + type_value: 222, + key: VTXO_CONDITION_KEY.to_vec(), + }; + + let Some(condition_data) = input.unknown.get(&condition_key) else { + return Ok(Vec::new()); + }; + + let mut cursor = std::io::Cursor::new(condition_data); + let element_count = VarInt::consensus_decode(&mut cursor) + .map_err(|e| Error::transaction(format!("failed to decode condition count: {e}")))? + .0; + + let count_end = usize::try_from(cursor.position()) + .map_err(|_| Error::transaction("condition cursor position overflow"))?; + let remaining_after_count = condition_data.len().saturating_sub(count_end); + let element_count = usize::try_from(element_count) + .map_err(|_| Error::transaction("condition witness element count overflow"))?; + + // Each element needs at least a compact-size length byte, even when the element itself is + // empty. + if element_count > remaining_after_count { + return Err(Error::transaction(format!( + "condition witness element count {element_count} exceeds remaining buffer size {remaining_after_count}" + ))); + } + + let mut elements = Vec::with_capacity(element_count); + for _ in 0..element_count { + let element_len = VarInt::consensus_decode(&mut cursor) + .map_err(|e| Error::transaction(format!("failed to decode condition length: {e}")))? + .0; + let element_len = usize::try_from(element_len) + .map_err(|_| Error::transaction("condition witness element length overflow"))?; + let start = usize::try_from(cursor.position()) + .map_err(|_| Error::transaction("condition cursor position overflow"))?; + let end = start + .checked_add(element_len) + .ok_or_else(|| Error::transaction("condition witness element end overflow"))?; + + if condition_data.len() < end { + return Err(Error::transaction(format!( + "condition witness element too short: expected {element_len} bytes, got {}", + condition_data.len().saturating_sub(start) + ))); + } + + elements.push(condition_data[start..end].to_vec()); + cursor.set_position(end as u64); + } + + Ok(elements) +} + +/// Finalize all virtual transactions needed to commit a VTXO on-chain. +pub fn finalize_unilateral_exit_tree( + unilateral_exit_tree: &UnilateralExitTree, + commitment_txs: &[Transaction], +) -> Result>, Error> { + let mut finalized_virtual_tx_branches = Vec::new(); + for unilateral_exit_branch in unilateral_exit_tree.inner.iter() { + let mut finalized_unilateral_exit_branch = Vec::new(); + for virtual_tx in unilateral_exit_branch.iter() { + let psbt = virtual_tx.clone(); + + let virtual_tx_previous_output = + psbt.unsigned_tx.input[VTXO_INPUT_INDEX].previous_output; + + let witness_utxo = { + unilateral_exit_branch + .iter() + .map(|p| &p.unsigned_tx) + .chain(commitment_txs.iter()) + .find_map(|other_psbt| { + (other_psbt.compute_txid() == virtual_tx_previous_output.txid).then_some( + other_psbt.output[virtual_tx_previous_output.vout as usize].clone(), + ) + }) + } + .ok_or_else(|| { + Error::ad_hoc(format!( + "no witness UTXO found for virtual TX outpoint {virtual_tx_previous_output}" + )) + })?; + + let tx = finalize_virtual_tx_input(psbt, VTXO_INPUT_INDEX, witness_utxo)?; + + finalized_unilateral_exit_branch.push(tx); + } + finalized_virtual_tx_branches.push(finalized_unilateral_exit_branch); + } + + Ok(finalized_virtual_tx_branches) +} + +#[deprecated(note = "use finalize_unilateral_exit_tree")] +pub fn sign_unilateral_exit_tree( + unilateral_exit_tree: &UnilateralExitTree, + commitment_txs: &[Transaction], +) -> Result>, Error> { + finalize_unilateral_exit_tree(unilateral_exit_tree, commitment_txs) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectedUtxo { + pub outpoint: OutPoint, + pub amount: Amount, + pub address: Address, +} + +#[derive(Debug, Clone)] +pub struct UtxoCoinSelection { + pub selected_utxos: Vec, + pub total_selected: Amount, + pub change_amount: Amount, +} + +/// Build an anchor transaction by spending a 0-value P2A output and adding another output to cover +/// the transaction fees. +pub fn build_anchor_tx( + bumpable_tx: &Transaction, + change_address: Address, + fee_rate: f64, + select_coins_fn: F, +) -> Result +where + F: FnOnce(Amount) -> Result, +{ + let anchor = find_anchor_outpoint(bumpable_tx)?; + + // Estimate for the size of the bump transaction. + const P2TR_KEYSPEND_INPUT_WEIGHT: u64 = 57 * 4 + 64; // 292 weight units + const NESTED_P2WSH_INPUT_WEIGHT: u64 = 91 * 4 + 3 * 4; // 376 weight units + const P2TR_OUTPUT_WEIGHT: u64 = 43 * 4; // 172 weight units + + // We assume only one UTXO will be selected to have a correct estimate. + let estimated_weight = Weight::from_wu( + NESTED_P2WSH_INPUT_WEIGHT + P2TR_KEYSPEND_INPUT_WEIGHT + P2TR_OUTPUT_WEIGHT, + ); + + let child_vsize = estimated_weight.to_vbytes_ceil(); + let package_size = child_vsize + bumpable_tx.weight().to_vbytes_ceil(); + + let fee = Amount::from_sat((package_size as f64 * fee_rate).ceil() as u64); + + // Use dependency to select coins to cover the fee. + let UtxoCoinSelection { + selected_utxos, + total_selected, + change_amount, + } = select_coins_fn(fee)?; + + if total_selected < fee { + return Err(Error::coin_select(format!( + "insufficient coins selected to cover {fee} fee" + ))); + } + + // Build inputs and outputs. + let mut inputs = vec![anchor]; + let mut sequences = vec![Sequence::MAX]; + + for utxo in selected_utxos.iter() { + inputs.push(utxo.outpoint); + sequences.push(Sequence::MAX); + } + + let outputs = vec![TxOut { + value: change_amount, + script_pubkey: change_address.script_pubkey(), + }]; + + // Create PSBT. + let mut psbt = Psbt::from_unsigned_tx(Transaction { + version: transaction::Version::non_standard(3), + lock_time: LockTime::ZERO, + input: inputs + .iter() + .zip(sequences.iter()) + .map(|(outpoint, sequence)| TxIn { + previous_output: *outpoint, + script_sig: ScriptBuf::new(), + sequence: *sequence, + witness: Witness::new(), + }) + .collect(), + output: outputs, + }) + .map_err(|e| Error::transaction(format!("Failed to create PSBT: {e}")))?; + + // Set witness UTXO for anchor input (first input). The anchor input does not need signing, + // hence the empty witness. + psbt.inputs[0].witness_utxo = Some(anchor_output()); + psbt.inputs[0].final_script_witness = Some(Witness::new()); + + // Set witness UTXO for the additional inputs (probably just one). + for i in 1..psbt.inputs.len() { + if let Some(utxo) = selected_utxos.get(i - 1) { + psbt.inputs[i].witness_utxo = Some(TxOut { + value: utxo.amount, + script_pubkey: utxo.address.script_pubkey(), + }); + } + } + + Ok(psbt) +} + +fn find_anchor_outpoint(tx: &Transaction) -> Result { + let anchor_output_template = anchor_output(); + + for (index, output) in tx.output.iter().enumerate() { + if output == &anchor_output_template { + return Ok(OutPoint { + txid: tx.compute_txid(), + vout: index as u32, + }); + } + } + + Err(Error::transaction("anchor output not found in transaction")) +} diff --git a/vendor/ark-core/src/vhtlc.rs b/vendor/ark-core/src/vhtlc.rs new file mode 100644 index 0000000..aab771e --- /dev/null +++ b/vendor/ark-core/src/vhtlc.rs @@ -0,0 +1,873 @@ +//! Virtual Hash Time Lock Contract (VHTLC) implementation for Ark Lightning Swaps. +//! +//! This module implements VHTLC scripts that enable atomic swaps and conditional +//! payments in the Ark protocol. The VHTLC provides multiple spending paths with +//! different conditions and participants. + +use crate::ArkAddress; +use crate::UNSPENDABLE_KEY; +use bitcoin::hashes::ripemd160; +use bitcoin::hashes::Hash; +use bitcoin::opcodes::all::*; +use bitcoin::taproot::TaprootBuilder; +use bitcoin::taproot::TaprootSpendInfo; +use bitcoin::Network; +use bitcoin::PublicKey; +use bitcoin::ScriptBuf; +use bitcoin::Sequence; +use bitcoin::XOnlyPublicKey; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::str::FromStr; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum VhtlcError { + #[error("Invalid preimage hash length: expected 20 bytes, got {0}")] + InvalidPreimageHashLength(usize), + #[error("Invalid public key length: expected 32 bytes, got {0}")] + InvalidPublicKeyLength(usize), + #[error("Invalid locktime: {0}")] + InvalidLocktime(String), + #[error("Invalid delay: {0}")] + InvalidDelay(String), + #[error("Taproot construction failed: {0}")] + TaprootError(String), +} + +/// Represents a script with its weight for taproot tree construction +#[derive(Debug, Clone)] +struct TaprootScriptItem { + script: ScriptBuf, + weight: u32, +} + +/// Internal tree node for building the taproot tree structure +#[derive(Debug, Clone)] +enum TaprootTreeNode { + Leaf { + script: ScriptBuf, + weight: u32, + }, + Branch { + left: Box, + right: Box, + weight: u32, + }, +} + +/// Options for creating a VHTLC (Virtual Hash Time Lock Contract) +/// +/// This structure contains all the necessary parameters to construct a VHTLC, +/// including the public keys of participants and various timeout values. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VhtlcOptions { + pub sender: XOnlyPublicKey, + pub receiver: XOnlyPublicKey, + pub server: XOnlyPublicKey, + pub preimage_hash: ripemd160::Hash, + pub refund_locktime: u32, + pub unilateral_claim_delay: Sequence, + pub unilateral_refund_delay: Sequence, + pub unilateral_refund_without_receiver_delay: Sequence, +} + +impl VhtlcOptions { + pub fn validate(&self) -> Result<(), VhtlcError> { + if self.refund_locktime == 0 { + return Err(VhtlcError::InvalidLocktime( + "Refund locktime must be greater than 0".to_string(), + )); + } + + if !self.unilateral_claim_delay.is_relative_lock_time() + || self.unilateral_claim_delay.to_consensus_u32() == 0 + { + return Err(VhtlcError::InvalidDelay( + "Unilateral claim delay must be a valid non-zero CSV relative lock time" + .to_string(), + )); + } + + if !self.unilateral_refund_delay.is_relative_lock_time() + || self.unilateral_refund_delay.to_consensus_u32() == 0 + { + return Err(VhtlcError::InvalidDelay( + "Unilateral refund delay must be a valid non-zero CSV relative lock time" + .to_string(), + )); + } + + if !self + .unilateral_refund_without_receiver_delay + .is_relative_lock_time() + || self + .unilateral_refund_without_receiver_delay + .to_consensus_u32() + == 0 + { + return Err(VhtlcError::InvalidDelay( + "Unilateral refund without receiver delay must be a valid non-zero CSV relative lock time" + .to_string(), + )); + } + + Ok(()) + } + + fn build_taproot(&self) -> Result { + let internal_pubkey = PublicKey::from_str(UNSPENDABLE_KEY) + .map_err(|e| VhtlcError::TaprootError(format!("Failed to parse internal key: {e}")))?; + let internal_key = XOnlyPublicKey::from(internal_pubkey); + + // Create script list with weights + // Lower weight = more likely to be used = shallower in tree + let scripts = vec![ + TaprootScriptItem { + script: self.claim_script(), + weight: 1, // Most likely - collaborative claim + }, + TaprootScriptItem { + script: self.refund_script(), + weight: 1, // Most likely - collaborative refund + }, + TaprootScriptItem { + script: self.refund_without_receiver_script(), + weight: 1, // Less common + }, + TaprootScriptItem { + script: self.unilateral_claim_script(), + weight: 1, // Less common + }, + TaprootScriptItem { + script: self.unilateral_refund_script(), + weight: 1, // Least common + }, + TaprootScriptItem { + script: self.unilateral_refund_without_receiver_script(), + weight: 1, // Least common + }, + ]; + + // Build the tree using the weight-based algorithm + let tree = Self::taproot_list_to_tree(scripts)?; + + // Create TaprootBuilder and add the tree + let builder = TaprootBuilder::new(); + let builder = Self::add_tree_to_builder(builder, &tree, 0)?; + + let secp = bitcoin::secp256k1::Secp256k1::new(); + let taproot_spend_info = builder + .finalize(&secp, internal_key) + .map_err(|e| VhtlcError::TaprootError(format!("Failed to finalize taproot: {e:?}")))?; + + Ok(taproot_spend_info) + } + + /// Creates the claim script where receiver reveals the preimage + /// + /// Requires: preimage hash verification + receiver signature + server signature + pub fn claim_script(&self) -> ScriptBuf { + let preimage_hash = self.preimage_hash; + + ScriptBuf::builder() + .push_opcode(OP_HASH160) + .push_slice(preimage_hash.as_byte_array()) + .push_opcode(OP_EQUAL) + .push_opcode(OP_VERIFY) + .push_x_only_key(&self.receiver) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&self.server) + .push_opcode(OP_CHECKSIG) + .into_script() + } + + /// Creates the collaborative refund script + /// + /// Requires: sender + receiver + server signatures + pub fn refund_script(&self) -> ScriptBuf { + ScriptBuf::builder() + .push_x_only_key(&self.sender) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&self.receiver) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&self.server) + .push_opcode(OP_CHECKSIG) + .into_script() + } + + /// Creates the refund script when receiver is unavailable + /// + /// Requires: CLTV timeout + sender + server signatures + pub fn refund_without_receiver_script(&self) -> ScriptBuf { + ScriptBuf::builder() + .push_int(self.refund_locktime as i64) + .push_opcode(OP_CLTV) + .push_opcode(OP_DROP) + .push_x_only_key(&self.sender) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&self.server) + .push_opcode(OP_CHECKSIG) + .into_script() + } + + /// Creates the unilateral claim script (no server cooperation needed) + /// + /// Requires: preimage hash verification + CSV delay + receiver signature + pub fn unilateral_claim_script(&self) -> ScriptBuf { + let preimage_hash = self.preimage_hash; + let sequence = self.unilateral_claim_delay; + + ScriptBuf::builder() + .push_opcode(OP_HASH160) + .push_slice(preimage_hash.as_byte_array()) + .push_opcode(OP_EQUAL) + .push_opcode(OP_VERIFY) + .push_int(sequence.to_consensus_u32() as i64) + .push_opcode(OP_CSV) + .push_opcode(OP_DROP) + .push_x_only_key(&self.receiver) + .push_opcode(OP_CHECKSIG) + .into_script() + } + + /// Creates the unilateral refund script + /// + /// Requires: CSV delay + sender + receiver signatures + pub fn unilateral_refund_script(&self) -> ScriptBuf { + let sequence = self.unilateral_refund_delay; + ScriptBuf::builder() + .push_int(sequence.to_consensus_u32() as i64) + .push_opcode(OP_CSV) + .push_opcode(OP_DROP) + .push_x_only_key(&self.sender) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&self.receiver) + .push_opcode(OP_CHECKSIG) + .into_script() + } + + /// Creates the unilateral refund script when receiver is unavailable + /// + /// Requires: CSV delay + sender signature + pub fn unilateral_refund_without_receiver_script(&self) -> ScriptBuf { + let sequence = self.unilateral_refund_without_receiver_delay; + ScriptBuf::builder() + .push_int(sequence.to_consensus_u32() as i64) + .push_opcode(OP_CSV) + .push_opcode(OP_DROP) + .push_x_only_key(&self.sender) + .push_opcode(OP_CHECKSIG) + .into_script() + } + + /// Build a balanced taproot tree from a list of scripts with weights + /// Following the TypeScript algorithm from scure-btc-signer + fn taproot_list_to_tree( + scripts: Vec, + ) -> Result { + if scripts.is_empty() { + return Err(VhtlcError::TaprootError("Empty script list".to_string())); + } + + // Clone input and convert to nodes + let mut lst: Vec = scripts + .into_iter() + .map(|item| TaprootTreeNode::Leaf { + script: item.script, + weight: item.weight, + }) + .collect(); + + // Build tree by combining nodes with smallest weights + while lst.len() >= 2 { + // Sort: elements with smallest weight are at the end of queue + lst.sort_by(|a, b| { + let weight_a = match a { + TaprootTreeNode::Leaf { weight, .. } => *weight, + TaprootTreeNode::Branch { weight, .. } => *weight, + }; + let weight_b = match b { + TaprootTreeNode::Leaf { weight, .. } => *weight, + TaprootTreeNode::Branch { weight, .. } => *weight, + }; + // Reverse comparison to put smallest at end + weight_b.cmp(&weight_a) + }); + + // Pop the two smallest weight nodes + let b = lst.pop().expect("an element"); + let a = lst.pop().expect("an element"); + + // Calculate combined weight + let weight_a = match &a { + TaprootTreeNode::Leaf { weight, .. } => *weight, + TaprootTreeNode::Branch { weight, .. } => *weight, + }; + let weight_b = match &b { + TaprootTreeNode::Leaf { weight, .. } => *weight, + TaprootTreeNode::Branch { weight, .. } => *weight, + }; + + // Create branch with combined weight + lst.push(TaprootTreeNode::Branch { + weight: weight_a + weight_b, + left: Box::new(a), + right: Box::new(b), + }); + } + + // Return the root node + Ok(lst.into_iter().next().expect("root node")) + } + + /// Recursively add tree nodes to TaprootBuilder + fn add_tree_to_builder( + builder: TaprootBuilder, + node: &TaprootTreeNode, + depth: u8, + ) -> Result { + match node { + TaprootTreeNode::Leaf { script, .. } => builder + .add_leaf(depth, script.clone()) + .map_err(|e| VhtlcError::TaprootError(format!("Failed to add leaf: {e}"))), + TaprootTreeNode::Branch { left, right, .. } => { + let builder = Self::add_tree_to_builder(builder, left, depth + 1)?; + Self::add_tree_to_builder(builder, right, depth + 1) + } + } + } +} + +/// VHTLC Script builder and manager +/// +/// This struct creates and manages VHTLC scripts with six different spending paths: +/// 1. **Claim**: Receiver reveals preimage (collaborative with server) +/// 2. **Refund**: Collaborative refund (all three parties) +/// 3. **Refund without Receiver**: Sender refunds after locktime (with server) +/// 4. **Unilateral Claim**: Receiver claims after delay (no server needed) +/// 5. **Unilateral Refund**: Collaborative unilateral refund after delay +/// 6. **Unilateral Refund without Receiver**: Sender unilateral refund after both timeouts +pub struct VhtlcScript { + options: VhtlcOptions, + taproot_spend_info: TaprootSpendInfo, + network: Network, +} + +impl VhtlcScript { + /// Creates a new VHTLC script with the given options + /// + /// This will validate the options and build the complete taproot tree + /// with all spending paths. + pub fn new(options: VhtlcOptions, network: Network) -> Result { + options.validate()?; + + let taproot_spend_info = options.build_taproot()?; + + Ok(Self { + options, + taproot_spend_info, + network, + }) + } + + pub fn taproot_spend_info(&self) -> &TaprootSpendInfo { + &self.taproot_spend_info + } + + pub fn options(&self) -> &VhtlcOptions { + &self.options + } + + pub fn script_pubkey(&self) -> ScriptBuf { + ScriptBuf::builder() + .push_opcode(OP_PUSHNUM_1) + .push_slice(self.taproot_spend_info.output_key().serialize()) + .into_script() + } + + pub fn address(&self) -> ArkAddress { + ArkAddress::new( + self.network, + self.options.server, + self.taproot_spend_info().output_key(), + ) + } + + /// Creates the claim script where receiver reveals the preimage + /// + /// Requires: preimage hash verification + receiver signature + server signature + pub fn claim_script(&self) -> ScriptBuf { + self.options.claim_script() + } + + /// Creates the collaborative refund script + /// + /// Requires: sender + receiver + server signatures + pub fn refund_script(&self) -> ScriptBuf { + self.options.refund_script() + } + + /// Creates the refund script when receiver is unavailable + /// + /// Requires: CLTV timeout + sender + server signatures + pub fn refund_without_receiver_script(&self) -> ScriptBuf { + self.options.refund_without_receiver_script() + } + + /// Creates the unilateral claim script (no server cooperation needed) + /// + /// Requires: preimage hash verification + CSV delay + receiver signature + pub fn unilateral_claim_script(&self) -> ScriptBuf { + self.options.unilateral_claim_script() + } + + /// Creates the unilateral refund script + /// + /// Requires: CSV delay + sender + receiver signatures + pub fn unilateral_refund_script(&self) -> ScriptBuf { + self.options.unilateral_refund_script() + } + + /// Creates the unilateral refund script when receiver is unavailable + /// + /// Requires: CSV delay + sender signature + pub fn unilateral_refund_without_receiver_script(&self) -> ScriptBuf { + self.options.unilateral_refund_without_receiver_script() + } + + pub fn get_script_map(&self) -> BTreeMap { + let mut map = BTreeMap::new(); + map.insert("claim".to_string(), self.claim_script()); + map.insert("refund".to_string(), self.refund_script()); + map.insert( + "refund_without_receiver".to_string(), + self.refund_without_receiver_script(), + ); + map.insert( + "unilateral_claim".to_string(), + self.unilateral_claim_script(), + ); + map.insert( + "unilateral_refund".to_string(), + self.unilateral_refund_script(), + ); + map.insert( + "unilateral_refund_without_receiver".to_string(), + self.unilateral_refund_without_receiver_script(), + ); + map + } + + pub fn tapscripts(self) -> Vec { + vec![ + self.claim_script(), + self.refund_script(), + self.refund_without_receiver_script(), + self.unilateral_claim_script(), + self.unilateral_refund_script(), + self.unilateral_refund_without_receiver_script(), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::hex::DisplayHex; + use bitcoin::hex::FromHex; + use bitcoin::Network; + use bitcoin::PublicKey; + use bitcoin::Sequence; + use bitcoin::XOnlyPublicKey; + use serde::Deserialize; + use serde::Serialize; + use std::collections::HashMap; + use std::fs; + use std::str::FromStr; + + #[derive(Debug, Deserialize, Serialize)] + struct Fixtures { + valid: Vec, + invalid: Vec, + } + + #[derive(Debug, Deserialize, Serialize)] + struct ValidTestCase { + description: String, + #[serde(rename = "preimageHash")] + preimage_hash: String, + receiver: String, + sender: String, + server: String, + #[serde(rename = "refundLocktime")] + refund_locktime: u32, + #[serde(rename = "unilateralClaimDelay")] + unilateral_claim_delay: Delay, + #[serde(rename = "unilateralRefundDelay")] + unilateral_refund_delay: Delay, + #[serde(rename = "unilateralRefundWithoutReceiverDelay")] + unilateral_refund_without_receiver_delay: Delay, + expected: String, + scripts: ScriptHexes, + taproot: TaprootInfo, + #[serde(rename = "decodedScripts")] + decoded_scripts: HashMap, + } + + #[derive(Debug, Deserialize, Serialize)] + struct InvalidTestCase { + description: String, + #[serde(rename = "preimageHash")] + preimage_hash: String, + receiver: String, + sender: String, + server: String, + #[serde(rename = "refundLocktime")] + refund_locktime: u32, + #[serde(rename = "unilateralClaimDelay")] + unilateral_claim_delay: Delay, + #[serde(rename = "unilateralRefundDelay")] + unilateral_refund_delay: Delay, + #[serde(rename = "unilateralRefundWithoutReceiverDelay")] + unilateral_refund_without_receiver_delay: Delay, + error: String, + } + + #[derive(Debug, Deserialize, Serialize)] + struct ScriptHexes { + #[serde(rename = "claimScript")] + claim_script: String, + #[serde(rename = "refundScript")] + refund_script: String, + #[serde(rename = "refundWithoutReceiverScript")] + refund_without_receiver_script: String, + #[serde(rename = "unilateralClaimScript")] + unilateral_claim_script: String, + #[serde(rename = "unilateralRefundScript")] + unilateral_refund_script: String, + #[serde(rename = "unilateralRefundWithoutReceiverScript")] + unilateral_refund_without_receiver_script: String, + } + + #[derive(Debug, Deserialize, Serialize)] + struct TaprootInfo { + #[serde(rename = "tweakedPublicKey")] + tweaked_public_key: String, + #[serde(rename = "tapTree")] + tap_tree: String, + #[serde(rename = "internalKey")] + internal_key: String, + } + + #[derive(Debug, Deserialize, Serialize)] + struct Delay { + #[serde(rename = "type")] + delay_type: String, + value: u32, + } + + impl Delay { + fn to_sequence(&self) -> Result { + match self.delay_type.as_str() { + "blocks" => { + if self.value == 0 { + return Err("unilateral claim delay must greater than 0".to_string()); + } + Ok(Sequence::from_height(self.value as u16)) + } + "seconds" => { + if self.value < 512 { + return Err("seconds timelock must be greater or equal to 512".to_string()); + } + if self.value % 512 != 0 { + return Err("seconds timelock must be multiple of 512".to_string()); + } + Sequence::from_seconds_ceil(self.value) + .map_err(|e| format!("Invalid seconds value: {e}")) + } + _ => Err(format!("Unknown delay type: {}", self.delay_type)), + } + } + } + + fn hex_to_bytes20(hex: &str) -> Result<[u8; 20], String> { + let bytes = Vec::from_hex(hex).map_err(|e| format!("Invalid hex: {e}"))?; + if bytes.len() != 20 { + return Err("preimage hash must be 20 bytes".to_string()); + } + let mut arr = [0u8; 20]; + arr.copy_from_slice(&bytes); + Ok(arr) + } + + fn pubkey_to_xonly(pubkey_hex: &str) -> XOnlyPublicKey { + let pubkey = PublicKey::from_str(pubkey_hex).expect("valid public key"); + XOnlyPublicKey::from(pubkey.inner) + } + + #[test] + fn test_vhtlc_with_valid_fixtures() { + let fixtures_path = concat!(env!("CARGO_MANIFEST_DIR"), "/src/vhtlc_fixtures/vhtlc.json"); + let fixtures_json = fs::read_to_string(fixtures_path).expect("to read fixtures file"); + let fixtures: Fixtures = serde_json::from_str(&fixtures_json).expect("to parse fixtures"); + + for test_case in fixtures.valid { + let preimage_hash = + ripemd160::Hash::from_str(&test_case.preimage_hash).expect("valid hash"); + + let sender = pubkey_to_xonly(&test_case.sender); + let receiver = pubkey_to_xonly(&test_case.receiver); + let server = pubkey_to_xonly(&test_case.server); + + let options = VhtlcOptions { + sender, + receiver, + server, + preimage_hash, + refund_locktime: test_case.refund_locktime, + unilateral_claim_delay: test_case + .unilateral_claim_delay + .to_sequence() + .expect("valid delay"), + unilateral_refund_delay: test_case + .unilateral_refund_delay + .to_sequence() + .expect("valid delay"), + unilateral_refund_without_receiver_delay: test_case + .unilateral_refund_without_receiver_delay + .to_sequence() + .expect("valid delay"), + }; + + let vhtlc = VhtlcScript::new(options, Network::Testnet).expect("to create VHTLC"); + + // Test 1: Verify all script hex encodings + let claim_hex = vhtlc.claim_script().as_bytes().to_lower_hex_string(); + assert_eq!( + claim_hex, test_case.scripts.claim_script, + "Claim script hex mismatch for test case: {}", + test_case.description + ); + + let refund_hex = vhtlc.refund_script().as_bytes().to_lower_hex_string(); + assert_eq!( + refund_hex, test_case.scripts.refund_script, + "Refund script hex mismatch for test case: {}", + test_case.description + ); + + let refund_without_receiver_hex = vhtlc + .refund_without_receiver_script() + .as_bytes() + .to_lower_hex_string(); + assert_eq!( + refund_without_receiver_hex, test_case.scripts.refund_without_receiver_script, + "Refund without receiver script hex mismatch for test case: {}", + test_case.description + ); + + let unilateral_claim_hex = vhtlc + .unilateral_claim_script() + .as_bytes() + .to_lower_hex_string(); + assert_eq!( + unilateral_claim_hex, test_case.scripts.unilateral_claim_script, + "Unilateral claim script hex mismatch for test case: {}", + test_case.description + ); + + let unilateral_refund_hex = vhtlc + .unilateral_refund_script() + .as_bytes() + .to_lower_hex_string(); + assert_eq!( + unilateral_refund_hex, test_case.scripts.unilateral_refund_script, + "Unilateral refund script hex mismatch for test case: {}", + test_case.description + ); + + let unilateral_refund_without_receiver_hex = vhtlc + .unilateral_refund_without_receiver_script() + .as_bytes() + .to_lower_hex_string(); + + assert_eq!( + unilateral_refund_without_receiver_hex, + test_case.scripts.unilateral_refund_without_receiver_script, + "Unilateral refund without receiver script hex mismatch for test case: {}. Our impl includes CLTV locktime, fixture expects only CSV", + test_case.description + ); + + // Test 2: Verify taproot information + let taproot_info = vhtlc.taproot_spend_info(); + + let internal_key = taproot_info.internal_key(); + let internal_key_hex = internal_key.serialize().to_lower_hex_string(); + + // The internal key in fixtures is prefixed with version byte + let pubkey = PublicKey::from_str(&test_case.taproot.internal_key) + .expect("valid internal key in fixture"); + let expected_internal = XOnlyPublicKey::from(pubkey.inner) + .serialize() + .to_lower_hex_string(); + + assert_eq!( + internal_key_hex, expected_internal, + "Internal key mismatch for test case: {}", + test_case.description + ); + + let output_key = taproot_info.output_key(); + let output_key_hex = output_key.serialize().to_lower_hex_string(); + + assert_eq!( + output_key_hex, test_case.taproot.tweaked_public_key, + "Tweaked public key mismatch for test case: {}", + test_case.description + ); + + // Test 3: Verify address generation + let addr = vhtlc.address(); + let address_str = addr.encode(); + + assert_eq!( + address_str, test_case.expected, + "Address mismatch for test case: {}", + test_case.description + ); + } + } + + #[test] + fn test_vhtlc_with_invalid_fixtures() { + let fixtures_path = concat!(env!("CARGO_MANIFEST_DIR"), "/src/vhtlc_fixtures/vhtlc.json"); + let fixtures_json = fs::read_to_string(fixtures_path).expect("to read fixtures file"); + let fixtures: Fixtures = serde_json::from_str(&fixtures_json).expect("to parse fixtures"); + + for test_case in fixtures.invalid { + // Try to parse preimage hash + let preimage_hash_result = hex_to_bytes20(&test_case.preimage_hash); + + if let Err(e) = preimage_hash_result { + assert!( + e.contains(&test_case.error), + "Expected error containing '{}', got '{}' for test case: {}", + test_case.error, + e, + test_case.description + ); + continue; + } + + // Check refund locktime + if test_case.refund_locktime == 0 { + assert!( + test_case + .error + .contains("refund locktime must be greater than 0"), + "Expected refund locktime error for test case: {}", + test_case.description + ); + continue; + } + + // Try to convert delays + let claim_delay_result = test_case.unilateral_claim_delay.to_sequence(); + if let Err(e) = claim_delay_result { + assert!( + e.contains(&test_case.error), + "Expected error containing '{}', got '{}' for claim delay in test case: {}", + test_case.error, + e, + test_case.description + ); + continue; + } + + let refund_delay_result = test_case.unilateral_refund_delay.to_sequence(); + if let Err(e) = refund_delay_result { + assert!( + e.contains(&test_case.error), + "Expected error containing '{}', got '{}' for refund delay in test case: {}", + test_case.error, + e, + test_case.description + ); + continue; + } + + let refund_without_receiver_delay_result = test_case + .unilateral_refund_without_receiver_delay + .to_sequence(); + if let Err(e) = refund_without_receiver_delay_result { + assert!( + e.contains(&test_case.error), + "Expected error containing '{}', got '{}' for refund without receiver delay in test case: {}", + test_case.error, + e, + test_case.description + ); + continue; + } + + // If we got here, all validations passed but they shouldn't have + panic!( + "Invalid test case '{}' didn't fail as expected", + test_case.description + ); + } + } + + #[test] + fn test_specific_script_encodings() { + // Test specific script encoding for the first valid case + let sender = + pubkey_to_xonly("030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4"); + let receiver = + pubkey_to_xonly("021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b"); + let server = + pubkey_to_xonly("03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88"); + let preimage_hash = + ripemd160::Hash::from_str("4d487dd3753a89bc9fe98401d1196523058251fc").unwrap(); + + let options = VhtlcOptions { + sender, + receiver, + server, + preimage_hash, + refund_locktime: 265, + unilateral_claim_delay: Sequence::from_height(17), + unilateral_refund_delay: Sequence::from_height(144), + unilateral_refund_without_receiver_delay: Sequence::from_height(144), + }; + + let vhtlc = VhtlcScript::new(options, Network::Testnet).expect("to create VHTLC"); + + // Verify claim script + let claim_script = vhtlc.claim_script(); + let claim_hex = claim_script.as_bytes().to_lower_hex_string(); + let expected_claim = "a9144d487dd3753a89bc9fe98401d1196523058251fc8769201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac"; + assert_eq!( + claim_hex, expected_claim, + "Claim script should match fixture" + ); + + // Verify unilateral claim script (with CSV=17) + let unilateral_claim = vhtlc.unilateral_claim_script(); + let unilateral_claim_hex = unilateral_claim.as_bytes().to_lower_hex_string(); + + // Check the CSV encoding for value 17 + assert!( + unilateral_claim_hex.contains("0111"), + "Should contain CSV value 17 as 0x0111" + ); + + let expected_unilateral_claim = "a9144d487dd3753a89bc9fe98401d1196523058251fc87690111b275201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac"; + assert_eq!( + unilateral_claim_hex, expected_unilateral_claim, + "Unilateral claim script should match fixture" + ); + } +} diff --git a/vendor/ark-core/src/vhtlc_fixtures/vhtlc.json b/vendor/ark-core/src/vhtlc_fixtures/vhtlc.json new file mode 100644 index 0000000..fbe4aa0 --- /dev/null +++ b/vendor/ark-core/src/vhtlc_fixtures/vhtlc.json @@ -0,0 +1,258 @@ +{ + "valid": [ + { + "description": "CSV locktime > 16", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251fc", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 265, + "unilateralClaimDelay": { + "type": "blocks", + "value": 17 + }, + "unilateralRefundDelay": { + "type": "blocks", + "value": 144 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "blocks", + "value": 144 + }, + "expected": "tark1qz4d2t2czchfaml2l3ad3gwde2qxpd0srhc7wkpnvtg99cnxyz8c3pnvvhnhumhwhqthmlxmdryakwx99s6508y8dunj9sty2p5mr7unh5re63", + "scripts": { + "claimScript": "a9144d487dd3753a89bc9fe98401d1196523058251fc8769201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "refundScript": "200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "refundWithoutReceiverScript": "020901b175200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "unilateralClaimScript": "a9144d487dd3753a89bc9fe98401d1196523058251fc87690111b275201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac", + "unilateralRefundScript": "029000b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac", + "unilateralRefundWithoutReceiverScript": "029000b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ac" + }, + "taproot": { + "tweakedPublicKey": "866c65e77e6eeeb8177dfcdb68c9db38c52c35479c876f2722c1645069b1fb93", + "tapTree": "0601c05ca9144d487dd3753a89bc9fe98401d1196523058251fc8769201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c066200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c049020901b175200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c03ea9144d487dd3753a89bc9fe98401d1196523058251fc87690111b275201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac01c049029000b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac01c027029000b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ac", + "internalKey": "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + }, + "decodedScripts": { + "claimScript": "HASH160 0x4d487dd3753a89bc9fe98401d1196523058251fc EQUAL VERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "refundScript": "0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "refundWithoutReceiverScript": "0x0901 CHECKLOCKTIMEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "unilateralClaimScript": "HASH160 0x4d487dd3753a89bc9fe98401d1196523058251fc EQUAL VERIFY 0x11 CHECKSEQUENCEVERIFY DROP 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIG", + "unilateralRefundScript": "0x9000 CHECKSEQUENCEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIG", + "unilateralRefundWithoutReceiverScript": "0x9000 CHECKSEQUENCEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIG" + } + }, + { + "description": "CSV locktime <= 16", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251fc", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 265, + "unilateralClaimDelay": { + "type": "blocks", + "value": 16 + }, + "unilateralRefundDelay": { + "type": "blocks", + "value": 144 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "blocks", + "value": 144 + }, + "expected": "tark1qz4d2t2czchfaml2l3ad3gwde2qxpd0srhc7wkpnvtg99cnxyz8c3vyn9exe9gjwcjp5ez0wfhhawvvg0xfenzztjmgp3ddrvkwhw04eztqjn6", + "scripts": { + "claimScript": "a9144d487dd3753a89bc9fe98401d1196523058251fc8769201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "refundScript": "200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "refundWithoutReceiverScript": "020901b175200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "unilateralClaimScript": "a9144d487dd3753a89bc9fe98401d1196523058251fc876960b275201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac", + "unilateralRefundScript": "029000b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac", + "unilateralRefundWithoutReceiverScript": "029000b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ac" + }, + "taproot": { + "tweakedPublicKey": "b0932e4d92a24ec4834c89ee4defd73188799399884b96d018b5a3659d773eb9", + "tapTree": "0601c05ca9144d487dd3753a89bc9fe98401d1196523058251fc8769201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c066200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c049020901b175200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c03da9144d487dd3753a89bc9fe98401d1196523058251fc876960b275201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac01c049029000b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac01c027029000b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ac", + "internalKey": "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + }, + "decodedScripts": { + "claimScript": "HASH160 0x4d487dd3753a89bc9fe98401d1196523058251fc EQUAL VERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "refundScript": "0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "refundWithoutReceiverScript": "0x0901 CHECKLOCKTIMEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "unilateralClaimScript": "HASH160 0x4d487dd3753a89bc9fe98401d1196523058251fc EQUAL VERIFY 16 CHECKSEQUENCEVERIFY DROP 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIG", + "unilateralRefundScript": "0x9000 CHECKSEQUENCEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIG", + "unilateralRefundWithoutReceiverScript": "0x9000 CHECKSEQUENCEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIG" + } + }, + { + "description": "with seconds CSV timelock", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251fc", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 265, + "unilateralClaimDelay": { + "type": "seconds", + "value": 512 + }, + "unilateralRefundDelay": { + "type": "seconds", + "value": 1024 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "seconds", + "value": 1536 + }, + "expected": "tark1qz4d2t2czchfaml2l3ad3gwde2qxpd0srhc7wkpnvtg99cnxyz8c3f354ncawvx3enha2ydyrmactc6fyuvqppsqpl5k63hzupmrl7ndmz8pnu", + "scripts": { + "claimScript": "a9144d487dd3753a89bc9fe98401d1196523058251fc8769201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "refundScript": "200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "refundWithoutReceiverScript": "020901b175200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac", + "unilateralClaimScript": "a9144d487dd3753a89bc9fe98401d1196523058251fc876903010040b275201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac", + "unilateralRefundScript": "03020040b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac", + "unilateralRefundWithoutReceiverScript": "03030040b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ac" + }, + "taproot": { + "tweakedPublicKey": "a634acf1d730d1ccefd511a41efb85e34927180086000fe96d46e2e0763ffa6d", + "tapTree": "0601c05ca9144d487dd3753a89bc9fe98401d1196523058251fc8769201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c066200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c049020901b175200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad20aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88ac01c040a9144d487dd3753a89bc9fe98401d1196523058251fc876903010040b275201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac01c04a03020040b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ad201e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53bac01c02803030040b275200192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4ac", + "internalKey": "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + }, + "decodedScripts": { + "claimScript": "HASH160 0x4d487dd3753a89bc9fe98401d1196523058251fc EQUAL VERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "refundScript": "0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "refundWithoutReceiverScript": "0x0901 CHECKLOCKTIMEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0xaad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88 CHECKSIG", + "unilateralClaimScript": "HASH160 0x4d487dd3753a89bc9fe98401d1196523058251fc EQUAL VERIFY 0x010040 CHECKSEQUENCEVERIFY DROP 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIG", + "unilateralRefundScript": "0x020040 CHECKSEQUENCEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIGVERIFY 0x1e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b CHECKSIG", + "unilateralRefundWithoutReceiverScript": "0x030040 CHECKSEQUENCEVERIFY DROP 0x0192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4 CHECKSIG" + } + } + ], + "invalid": [ + { + "description": "Invalid preimageHash length (too short)", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 265, + "unilateralClaimDelay": { + "type": "blocks", + "value": 17 + }, + "unilateralRefundDelay": { + "type": "blocks", + "value": 144 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "blocks", + "value": 144 + }, + "error": "preimage hash must be 20 bytes" + }, + { + "description": "Invalid preimageHash length (too long)", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251fc1234567890abcdef", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 265, + "unilateralClaimDelay": { + "type": "blocks", + "value": 17 + }, + "unilateralRefundDelay": { + "type": "blocks", + "value": 144 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "blocks", + "value": 144 + }, + "error": "preimage hash must be 20 bytes" + }, + { + "description": "Zero timelock value", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251fc", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 265, + "unilateralClaimDelay": { + "type": "blocks", + "value": 0 + }, + "unilateralRefundDelay": { + "type": "blocks", + "value": 144 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "blocks", + "value": 144 + }, + "error": "unilateral claim delay must greater than 0" + }, + { + "description": "Invalid refund locktime (zero)", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251fc", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 0, + "unilateralClaimDelay": { + "type": "blocks", + "value": 17 + }, + "unilateralRefundDelay": { + "type": "blocks", + "value": 144 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "blocks", + "value": 144 + }, + "error": "refund locktime must be greater than 0" + }, + { + "description": "Invalid seconds timelock (not multiple of 512)", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251fc", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 265, + "unilateralClaimDelay": { + "type": "seconds", + "value": 1000 + }, + "unilateralRefundDelay": { + "type": "seconds", + "value": 1024 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "seconds", + "value": 1536 + }, + "error": "seconds timelock must be multiple of 512" + }, + { + "description": "Invalid seconds timelock (less than 512)", + "preimageHash": "4d487dd3753a89bc9fe98401d1196523058251fc", + "receiver": "021e1bb85455fe3f5aed60d101aa4dbdb9e7714f6226769a97a17a5331dadcd53b", + "sender": "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4", + "server": "03aad52d58162e9eefeafc7ad8a1cdca8060b5f01df1e7583362d052e266208f88", + "refundLocktime": 265, + "unilateralClaimDelay": { + "type": "seconds", + "value": 512 + }, + "unilateralRefundDelay": { + "type": "seconds", + "value": 511 + }, + "unilateralRefundWithoutReceiverDelay": { + "type": "seconds", + "value": 1536 + }, + "error": "seconds timelock must be greater or equal to 512" + } + ] +} diff --git a/vendor/ark-core/src/vtxo.rs b/vendor/ark-core/src/vtxo.rs new file mode 100644 index 0000000..1640f3f --- /dev/null +++ b/vendor/ark-core/src/vtxo.rs @@ -0,0 +1,627 @@ +use crate::ark_address::ArkAddress; +use crate::script::csv_sig_script; +use crate::script::multisig_3_of_3_script; +use crate::script::multisig_script; +use crate::script::tr_script_pubkey; +use crate::Error; +use crate::ErrorContext; +use crate::ExitDelayKind; +use crate::UNSPENDABLE_KEY; +use bitcoin::key::PublicKey; +use bitcoin::key::Secp256k1; +use bitcoin::key::Verification; +use bitcoin::taproot; +use bitcoin::taproot::LeafVersion; +use bitcoin::taproot::NodeInfo; +use bitcoin::taproot::TaprootSpendInfo; +use bitcoin::Address; +use bitcoin::Network; +use bitcoin::ScriptBuf; +use bitcoin::XOnlyPublicKey; +use std::collections::VecDeque; +use std::time::Duration; + +/// All the information needed to _spend_ a VTXO. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Vtxo { + server_forfeit: XOnlyPublicKey, + owner: XOnlyPublicKey, + owner_unilateral_exit: XOnlyPublicKey, + /// The delegator's public key, if this VTXO has a delegate spending path. + delegator: Option, + spend_info: TaprootSpendInfo, + /// All the scripts in this VTXO's Taproot tree. + tapscripts: Vec, + address: Address, + exit_delay: bitcoin::Sequence, + exit_delay_kind: ExitDelayKind, + network: Network, +} + +impl Vtxo { + /// Build a VTXO, by providing all the scripts to be included in the Taproot tree. + /// + /// The provided `scripts` must follow the following rules: + /// + /// - All unilateral spend paths MUST be timelocked. + /// - All other spend paths MUST involve the Ark server's signature. + pub fn new_with_custom_scripts( + secp: &Secp256k1, + server_forfeit: XOnlyPublicKey, + owner: XOnlyPublicKey, + // TODO: Verify the validity of these scripts before constructing the `Vtxo`. + scripts: Vec, + exit_delay: bitcoin::Sequence, + network: Network, + ) -> Result + where + C: Verification, + { + let vtxo = Self::new_with_custom_scripts_and_split_owner_keys( + secp, + server_forfeit, + owner, + owner, + scripts, + exit_delay, + network, + )?; + + Ok(vtxo) + } + + pub fn new_with_custom_scripts_and_split_owner_keys( + secp: &Secp256k1, + server_forfeit: XOnlyPublicKey, + owner: XOnlyPublicKey, + owner_unilateral_exit: XOnlyPublicKey, + scripts: Vec, + exit_delay: bitcoin::Sequence, + network: Network, + ) -> Result + where + C: Verification, + { + let unspendable_key: PublicKey = UNSPENDABLE_KEY + .parse() + .map_err(|e| Error::ad_hoc(format!("invalid unspendable key: {e}")))?; + let (unspendable_key, _) = unspendable_key.inner.x_only_public_key(); + + let node = assemble_taproot_tree(&scripts)?; + let spend_info = TaprootSpendInfo::from_node_info(secp, unspendable_key, node); + + let exit_delay_kind = ExitDelayKind::from_sequence(exit_delay)?; + + let script_pubkey = tr_script_pubkey(&spend_info); + let address = Address::from_script(&script_pubkey, network) + .map_err(|e| Error::ad_hoc(format!("invalid script: {e}")))?; + + Ok(Self { + server_forfeit, + owner, + owner_unilateral_exit, + delegator: None, + spend_info, + tapscripts: scripts, + address, + exit_delay, + exit_delay_kind, + network, + }) + } + + /// Build a default VTXO. + pub fn new_default( + secp: &Secp256k1, + server_signer: XOnlyPublicKey, + owner: XOnlyPublicKey, + exit_delay: bitcoin::Sequence, + network: Network, + ) -> Result + where + C: Verification, + { + let forfeit_script = multisig_script(owner, server_signer); + let redeem_script = csv_sig_script(exit_delay, owner); + + Self::new_with_custom_scripts( + secp, + server_signer, + owner, + vec![forfeit_script, redeem_script], + exit_delay, + network, + ) + } + + /// Build a VTXO with a delegate spending path. + /// + /// This creates a 3-leaf Taproot tree: + /// 1. **Forfeit**: 2-of-2 multisig (server + owner) + /// 2. **Exit**: CSV-timelocked owner signature + /// 3. **Delegate**: 3-of-3 multisig (owner + delegator + server) + /// + /// The delegate path allows a third-party delegator service to cooperate with the owner and + /// the server to renew VTXOs before they expire. + pub fn new_with_delegator( + secp: &Secp256k1, + server_signer: XOnlyPublicKey, + owner: XOnlyPublicKey, + delegator: XOnlyPublicKey, + exit_delay: bitcoin::Sequence, + network: Network, + ) -> Result + where + C: Verification, + { + let forfeit_script = multisig_script(owner, server_signer); + let redeem_script = csv_sig_script(exit_delay, owner); + let delegate_script = multisig_3_of_3_script(owner, delegator, server_signer); + + let mut vtxo = Self::new_with_custom_scripts( + secp, + server_signer, + owner, + vec![forfeit_script, redeem_script, delegate_script], + exit_delay, + network, + )?; + + vtxo.delegator = Some(delegator); + + Ok(vtxo) + } + + pub fn script_pubkey(&self) -> ScriptBuf { + self.address.script_pubkey() + } + + pub fn address(&self) -> &Address { + &self.address + } + + pub fn owner_pk(&self) -> XOnlyPublicKey { + self.owner + } + + pub fn server_pk(&self) -> XOnlyPublicKey { + self.server_forfeit + } + + pub fn delegator_pk(&self) -> Option { + self.delegator + } + + pub fn exit_delay(&self) -> bitcoin::Sequence { + self.exit_delay + } + + pub fn to_ark_address(&self) -> ArkAddress { + let vtxo_tap_key = self.spend_info.output_key(); + ArkAddress::new(self.network, self.server_forfeit, vtxo_tap_key) + } + + /// The spend info of an arbitrary branch of a VTXO. + pub fn get_spend_info(&self, script: ScriptBuf) -> Result { + let control_block = self + .spend_info + .control_block(&(script, LeafVersion::TapScript)) + .ok_or(Error::ad_hoc("could not build control block for script"))?; + + Ok(control_block) + } + + /// The spend info for the forfeit branch of a _default_ VTXO. + /// + /// This method can fail because [`Vtxo`]s constructed with the method + /// [`Vtxo::new_with_custom_scripts`] may not contain this script exactly. + pub fn forfeit_spend_info(&self) -> Result<(ScriptBuf, taproot::ControlBlock), Error> { + let forfeit_script = multisig_script(self.owner, self.server_forfeit); + + let control_block = self + .get_spend_info(forfeit_script.clone()) + .context("missing default forfeit script")?; + + Ok((forfeit_script, control_block)) + } + + /// The spend info for the unilateral exit branch of a _default_ VTXO. + /// + /// This method can fail because [`Vtxo`]s constructed with the method + /// [`Vtxo::new_with_custom_scripts`] may not contain this script exactly. + pub fn exit_spend_info(&self) -> Result<(ScriptBuf, taproot::ControlBlock), Error> { + let exit_script = csv_sig_script(self.exit_delay, self.owner_unilateral_exit); + + let control_block = self + .get_spend_info(exit_script.clone()) + .context("missing default exit script")?; + + Ok((exit_script, control_block)) + } + + /// The spend info for the delegate branch of a VTXO constructed with + /// [`Vtxo::new_with_delegator`]. + /// + /// Returns an error if the VTXO was not built with a delegator. + pub fn delegate_spend_info(&self) -> Result<(ScriptBuf, taproot::ControlBlock), Error> { + let delegator = self + .delegator + .ok_or(Error::ad_hoc("VTXO has no delegate path"))?; + + let delegate_script = multisig_3_of_3_script(self.owner, delegator, self.server_forfeit); + + let control_block = self + .get_spend_info(delegate_script.clone()) + .context("missing delegate script")?; + + Ok((delegate_script, control_block)) + } + + pub fn tapscripts(&self) -> Vec { + self.tapscripts.clone() + } + + /// Whether the VTXO can be claimed unilaterally by the owner or not, given the + /// `confirmation_blocktime` of the transaction that included this VTXO as an output. + pub fn can_be_claimed_unilaterally_by_owner( + &self, + now: Duration, + confirmation_blocktime: Duration, + confirmations: u64, + ) -> bool { + match self.exit_delay_kind { + ExitDelayKind::Time(seconds) => { + let exit_path_time = confirmation_blocktime + seconds; + + now > exit_path_time + } + ExitDelayKind::Blocks(confirmations_required) => { + confirmations >= confirmations_required + } + } + } +} + +/// Assemble a Taproot tree from `scripts`, in input order, matching btcd's +/// `txscript.AssembleTaprootScriptTree`. +/// +/// This is the same construction the Arkade TypeScript SDK's `VtxoScript` uses +/// (`assembleBtcdTaprootTree`), so VTXOs built here derive byte-identical +/// addresses and control blocks across SDKs and the Ark server, for any number +/// of leaves. A previous depth-table approach only matched for one, two, or +/// three leaves and silently diverged (or failed) for larger trees. +/// +/// The algorithm has two phases: +/// +/// 1. Pair leaves left-to-right. A lone trailing leaf (odd leaf count) is merged into the branch +/// built immediately before it. +/// 2. Repeatedly merge the two front-most branches (FIFO) until a single root remains. +/// +/// Branch hashing is BIP341 (lexicographically sorted children), handled by +/// [`NodeInfo::combine`], so the resulting Merkle root is independent of the +/// left/right order within each branch. +fn assemble_taproot_tree(scripts: &[ScriptBuf]) -> Result { + if scripts.is_empty() { + return Err(Error::ad_hoc( + "cannot build a Taproot tree from zero scripts", + )); + } + + let leaf = + |script: &ScriptBuf| NodeInfo::new_leaf_with_ver(script.clone(), LeafVersion::TapScript); + let combine = |a: NodeInfo, b: NodeInfo| { + NodeInfo::combine(a, b) + .map_err(|e| Error::ad_hoc(format!("failed to combine Taproot nodes: {e}"))) + }; + + if scripts.len() == 1 { + return Ok(leaf(&scripts[0])); + } + + // Phase 1: pair leaves left-to-right. + let mut branches: Vec = Vec::new(); + let mut i = 0; + while i < scripts.len() { + if i == scripts.len() - 1 { + // Odd trailing leaf: merge it into the last branch built so far. + let last = branches + .pop() + .expect("a branch is always built before a trailing odd leaf"); + branches.push(combine(last, leaf(&scripts[i]))?); + } else { + branches.push(combine(leaf(&scripts[i]), leaf(&scripts[i + 1]))?); + } + i += 2; + } + + // Phase 2: FIFO-merge branches until a single root remains. + let mut queue: VecDeque = branches.into(); + while queue.len() >= 2 { + let left = queue + .pop_front() + .expect("queue holds at least two branches"); + let right = queue + .pop_front() + .expect("queue holds at least two branches"); + queue.push_back(combine(left, right)?); + } + + Ok(queue + .pop_front() + .expect("exactly one root branch remains after merging")) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::secp256k1::Secp256k1; + use std::str::FromStr; + + fn test_keys() -> (XOnlyPublicKey, XOnlyPublicKey, XOnlyPublicKey) { + let server = XOnlyPublicKey::from_str( + "18845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", + ) + .unwrap(); + let owner = XOnlyPublicKey::from_str( + "28845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", + ) + .unwrap(); + let delegator = XOnlyPublicKey::from_str( + "38845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166", + ) + .unwrap(); + (server, owner, delegator) + } + + #[test] + fn new_with_delegator_has_three_tapscripts() { + let secp = Secp256k1::new(); + let (server, owner, delegator) = test_keys(); + let exit_delay = bitcoin::Sequence::from_seconds_ceil(86400).unwrap(); + + let vtxo = Vtxo::new_with_delegator( + &secp, + server, + owner, + delegator, + exit_delay, + Network::Regtest, + ) + .unwrap(); + + assert_eq!(vtxo.tapscripts().len(), 3); + assert_eq!(vtxo.delegator_pk(), Some(delegator)); + } + + #[test] + fn delegator_vtxo_all_spend_paths_resolve() { + let secp = Secp256k1::new(); + let (server, owner, delegator) = test_keys(); + let exit_delay = bitcoin::Sequence::from_seconds_ceil(86400).unwrap(); + + let vtxo = Vtxo::new_with_delegator( + &secp, + server, + owner, + delegator, + exit_delay, + Network::Regtest, + ) + .unwrap(); + + // All three spend paths should produce valid spend info. + let (forfeit_script, _cb) = vtxo.forfeit_spend_info().unwrap(); + let (exit_script, _cb) = vtxo.exit_spend_info().unwrap(); + let (delegate_script, _cb) = vtxo.delegate_spend_info().unwrap(); + + // Scripts should be distinct. + assert_ne!(forfeit_script, exit_script); + assert_ne!(forfeit_script, delegate_script); + assert_ne!(exit_script, delegate_script); + } + + #[test] + fn default_vtxo_has_no_delegate_path() { + let secp = Secp256k1::new(); + let (server, owner, _) = test_keys(); + let exit_delay = bitcoin::Sequence::from_seconds_ceil(86400).unwrap(); + + let vtxo = Vtxo::new_default(&secp, server, owner, exit_delay, Network::Regtest).unwrap(); + + assert!(vtxo.delegator_pk().is_none()); + assert!(vtxo.delegate_spend_info().is_err()); + } + + #[test] + fn delegator_vtxo_address_differs_from_default() { + let secp = Secp256k1::new(); + let (server, owner, delegator) = test_keys(); + let exit_delay = bitcoin::Sequence::from_seconds_ceil(86400).unwrap(); + + let default = + Vtxo::new_default(&secp, server, owner, exit_delay, Network::Regtest).unwrap(); + let with_delegator = Vtxo::new_with_delegator( + &secp, + server, + owner, + delegator, + exit_delay, + Network::Regtest, + ) + .unwrap(); + + // Different taproot trees should produce different addresses. + assert_ne!(default.address(), with_delegator.address()); + } + + fn xonly(secp: &Secp256k1, byte: u8) -> XOnlyPublicKey { + bitcoin::key::Keypair::from_seckey_slice(secp, &[byte; 32]) + .expect("non-zero repeated byte is a valid secret key") + .x_only_public_key() + .0 + } + + /// `n` distinct leaf scripts (single-key CSV) for tree-shape tests. + fn leaf_scripts(secp: &Secp256k1, n: usize) -> Vec { + let seq = bitcoin::Sequence::from_seconds_ceil(512) + .expect("512 seconds is a valid relative timelock"); + (0..n) + .map(|i| csv_sig_script(seq, xonly(secp, (i as u8) + 1))) + .collect() + } + + /// `new_with_custom_scripts` must reproduce btcd's `AssembleTaprootScriptTree` + /// (and therefore the TS SDK's `VtxoScript`). We cross-check against an + /// independent `TaprootBuilder` reconstruction using the known btcd leaf + /// depths, in input order, for the counts the Ark flow actually uses. + #[test] + fn custom_scripts_tree_matches_btcd_assembly() { + use bitcoin::taproot::TaprootBuilder; + + let secp = Secp256k1::new(); + let (server, owner, _) = test_keys(); + let exit = bitcoin::Sequence::from_seconds_ceil(512) + .expect("512 seconds is a valid relative timelock"); + + // btcd depth tables (input order) for 2..=5 leaves. + let cases: &[(usize, &[u8])] = &[ + (2, &[1, 1]), + (3, &[2, 2, 1]), + (4, &[2, 2, 2, 2]), + (5, &[2, 2, 3, 3, 2]), + ]; + + let unspendable: PublicKey = UNSPENDABLE_KEY + .parse() + .expect("hardcoded unspendable key is valid"); + let (unspendable, _) = unspendable.inner.x_only_public_key(); + + for (n, depths) in cases { + let scripts = leaf_scripts(&secp, *n); + + let vtxo = Vtxo::new_with_custom_scripts( + &secp, + server, + owner, + scripts.clone(), + exit, + Network::Bitcoin, + ) + .expect("custom scripts build a valid VTXO"); + + let mut builder = TaprootBuilder::new(); + for (script, depth) in scripts.iter().zip(depths.iter()) { + builder = builder + .add_leaf(*depth, script.clone()) + .expect("valid leaf depth for reference tree"); + } + let reference = builder + .finalize(&secp, unspendable) + .expect("reference taproot tree finalizes"); + let reference = ArkAddress::new(Network::Bitcoin, server, reference.output_key()); + + assert_eq!( + vtxo.to_ark_address().encode(), + reference.encode(), + "tree mismatch for {n} leaves" + ); + } + } + + /// Every leaf must remain spendable (resolve a control block) for any leaf + /// count, including the non-power-of-two counts the old depth table either + /// mis-built (5) or failed on entirely (4, 6). + #[test] + fn custom_scripts_tree_preserves_all_leaves() { + let secp = Secp256k1::new(); + let (server, owner, _) = test_keys(); + let exit = bitcoin::Sequence::from_seconds_ceil(512) + .expect("512 seconds is a valid relative timelock"); + + for n in 1..=16usize { + let scripts = leaf_scripts(&secp, n); + let vtxo = Vtxo::new_with_custom_scripts( + &secp, + server, + owner, + scripts.clone(), + exit, + Network::Bitcoin, + ) + .expect("custom scripts build a valid VTXO"); + + assert_eq!(vtxo.tapscripts().len(), n, "leaf count changed for n={n}"); + for script in &scripts { + assert!( + vtxo.get_spend_info(script.clone()).is_ok(), + "no control block for a leaf with n={n}" + ); + } + } + } + + /// Known-answer test pinning the 5-leaf escrow address to the value produced + /// by the Arkade TS SDK / `arkade.computer` operator. Guards cross-SDK + /// address compatibility against future tree-construction regressions. + #[test] + fn escrow_five_leaf_address_matches_ts_sdk() { + use bitcoin::absolute::LockTime; + use bitcoin::opcodes::all::OP_CHECKSIG; + use bitcoin::opcodes::all::OP_CHECKSIGVERIFY; + use bitcoin::opcodes::all::OP_CLTV; + use bitcoin::opcodes::all::OP_CSV; + use bitcoin::opcodes::all::OP_DROP; + use bitcoin::script::Builder; + + let secp = Secp256k1::new(); + let (server, arbiter, alice, bob) = ( + xonly(&secp, 1), + xonly(&secp, 2), + xonly(&secp, 3), + xonly(&secp, 4), + ); + let expiry = LockTime::from_consensus(1_750_000_000); + let exit = bitcoin::Sequence::from_seconds_ceil(512) + .expect("512 seconds is a valid relative timelock"); + + let cltv = |a: XOnlyPublicKey, b: XOnlyPublicKey| { + Builder::new() + .push_int(expiry.to_consensus_u32() as i64) + .push_opcode(OP_CLTV) + .push_opcode(OP_DROP) + .push_x_only_key(&a) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&b) + .push_opcode(OP_CHECKSIG) + .into_script() + }; + let csv = |a: XOnlyPublicKey, b: XOnlyPublicKey| { + Builder::new() + .push_int(exit.to_consensus_u32() as i64) + .push_opcode(OP_CSV) + .push_opcode(OP_DROP) + .push_x_only_key(&a) + .push_opcode(OP_CHECKSIGVERIFY) + .push_x_only_key(&b) + .push_opcode(OP_CHECKSIG) + .into_script() + }; + + let scripts = vec![ + multisig_3_of_3_script(server, arbiter, alice), + multisig_3_of_3_script(server, arbiter, bob), + cltv(server, arbiter), + multisig_3_of_3_script(server, alice, bob), + csv(alice, bob), + ]; + + let vtxo = + Vtxo::new_with_custom_scripts(&secp, server, alice, scripts, exit, Network::Bitcoin) + .expect("escrow scripts build a valid VTXO"); + + assert_eq!( + vtxo.to_ark_address().encode(), + "ark1qqdcf32k0vfxgsyet5ldt246q4jaw8scx3sysx0lnstlt6w4m5rclv2zhfqtr8zgxsw3erprwh0z7vg6uyg75n229y8kn0d4yjew75z35ddvjs", + ); + } +} diff --git a/vendor/ark-core/src/vtxo_list.rs b/vendor/ark-core/src/vtxo_list.rs new file mode 100644 index 0000000..9c0f26a --- /dev/null +++ b/vendor/ark-core/src/vtxo_list.rs @@ -0,0 +1,197 @@ +use crate::server::Info; +use crate::server::VirtualTxOutPoint; +use crate::ExplorerUtxo; +use crate::Vtxo; +use bitcoin::Amount; +use bitcoin::ScriptBuf; +use bitcoin::XOnlyPublicKey; +use std::collections::HashMap; +use std::time::Duration; + +#[derive(Clone, Debug)] +pub struct VtxoList { + // Unspent + pre_confirmed: Vec, + confirmed: Vec, + recoverable: Vec, + + // Spent + spent: Vec, +} + +impl VtxoList { + pub fn new( + // The dust amount according to the Arkade server. Dust outputs are considered recoverable. + dust: Amount, + virtual_tx_outpoints: Vec, + ) -> Self { + let mut recoverable = Vec::new(); + let mut spent = Vec::new(); + let mut pre_confirmed = Vec::new(); + let mut confirmed = Vec::new(); + for virtual_tx_outpoint in virtual_tx_outpoints { + if virtual_tx_outpoint.is_recoverable(dust) { + recoverable.push(virtual_tx_outpoint); + } else if virtual_tx_outpoint.is_spent_status(dust) { + spent.push(virtual_tx_outpoint); + } else if virtual_tx_outpoint.is_pre_confirmed_spendable(dust) { + pre_confirmed.push(virtual_tx_outpoint); + } else if virtual_tx_outpoint.is_confirmed_spendable(dust) { + confirmed.push(virtual_tx_outpoint); + } + } + + VtxoList { + pre_confirmed, + confirmed, + recoverable, + spent, + } + } + + pub fn all(&self) -> impl Iterator { + self.all_unspent().chain(self.spent()) + } + + pub fn all_unspent(&self) -> impl Iterator { + self.pre_confirmed + .iter() + .chain(self.confirmed.iter()) + .chain(self.recoverable.iter()) + } + + /// VTXOs that are in a state that allows for unilateral exit. + /// + /// This does _not_ mean that the VTXOs are readily spendable on-chain, just that their ancestor + /// chain can still be published. + pub fn could_exit_unilaterally(&self) -> impl Iterator { + self.pre_confirmed.iter().chain(self.confirmed.iter()) + } + + /// VTXOs that can be spent in an offchain transaction. + pub fn spendable_offchain(&self) -> impl Iterator { + self.pre_confirmed.iter().chain(self.confirmed.iter()) + } + + /// VTXOs that can be spent in an offchain transaction at `now_unix_secs`. + /// + /// This excludes otherwise-spendable VTXOs minted under a deprecated signer whose + /// cooperative-sign window has closed. Those VTXOs cannot be forfeited by the server anymore; + /// they become usable again only after they expire and move into the recovery path. + pub fn spendable_offchain_at<'a, F>( + &'a self, + server_info: &'a Info, + now_unix_secs: i64, + server_pk_for_script: F, + ) -> impl Iterator + 'a + where + F: Fn(&ScriptBuf) -> Option + 'a, + { + self.spendable_offchain().filter(move |vtxo| { + !server_pk_for_script(&vtxo.script) + .map(|server_pk| server_info.signer_requires_recovery_at(server_pk, now_unix_secs)) + .unwrap_or(false) + }) + } + + /// Otherwise-spendable VTXOs blocked only by a deprecated signer's closed cooperative-sign + /// window. These remain wallet funds, but they are pending recovery until expiry. + pub fn pending_recovery_due_to_signer_at<'a, F>( + &'a self, + server_info: &'a Info, + now_unix_secs: i64, + server_pk_for_script: F, + ) -> impl Iterator + 'a + where + F: Fn(&ScriptBuf) -> Option + 'a, + { + self.spendable_offchain().filter(move |vtxo| { + server_pk_for_script(&vtxo.script) + .map(|server_pk| server_info.signer_requires_recovery_at(server_pk, now_unix_secs)) + .unwrap_or(false) + }) + } + + /// Unspent VTXOs that may be included in a cooperative batch settlement at `now_unix_secs`. + /// + /// Recoverable VTXOs are always safe: they no longer need a server forfeit signature. Healthy + /// VTXOs still need that signature, so VTXOs under an expired deprecated signer are excluded. + pub fn batch_settleable_at<'a, F>( + &'a self, + server_info: &'a Info, + now_unix_secs: i64, + server_pk_for_script: F, + ) -> impl Iterator + 'a + where + F: Fn(&ScriptBuf) -> Option + 'a, + { + let dust = server_info.dust; + self.all_unspent().filter(move |vtxo| { + vtxo.is_recoverable(dust) + || !server_pk_for_script(&vtxo.script) + .map(|server_pk| { + server_info.signer_requires_recovery_at(server_pk, now_unix_secs) + }) + .unwrap_or(false) + }) + } + + pub fn pre_confirmed(&self) -> impl Iterator { + self.pre_confirmed.iter() + } + + pub fn confirmed(&self) -> impl Iterator { + self.confirmed.iter() + } + + /// Returns the list of recoverable VTXOs + /// + /// A VTXO is recoverable if it: + /// + /// - has expired; + /// - was swept already; or + /// - is sub-dust. + pub fn recoverable(&self) -> impl Iterator { + self.recoverable.iter() + } + + /// VTXOs that are already on-chain and can be spent unilaterally (the exit path is active). + pub fn exit_ready( + &self, + now: Duration, + // Corresponds to every VTXO in `vtxos` which has been found on the blockchain. + explorer_utxos: Vec, + // TODO: We probably shouldn't involve the opinionated `Vtxo` type here. + vtxos: HashMap, + ) -> impl Iterator { + self.all_unspent().filter(move |v| { + match explorer_utxos + .iter() + .find(|explorer_utxo| explorer_utxo.outpoint == v.outpoint) + { + // VTXOs that have been confirmed on the blockchain. + Some(ExplorerUtxo { + confirmation_blocktime: Some(confirmation_blocktime), + confirmations, + .. + }) => { + // VTXOs with an _active_ exit path. These should be claimed unilaterally. + if let Some(vtxo) = vtxos.get(&v.script) { + vtxo.can_be_claimed_unilaterally_by_owner( + now, + Duration::from_secs(*confirmation_blocktime), + *confirmations, + ) + } else { + false + } + } + _ => false, + } + }) + } + + pub fn spent(&self) -> impl Iterator { + self.spent.iter() + } +}