From 0881a4ff421f198ad8ef9596f24459799de546a6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 21:17:14 +0100 Subject: [PATCH 01/23] feat: route paykit payments through pubky swaps --- Cargo.lock | 1761 ++++++++++++++++++++++++-- Cargo.toml | 5 + src/lib.rs | 82 ++ src/modules/mod.rs | 1 + src/modules/pubky_swaps/client.rs | 569 +++++++++ src/modules/pubky_swaps/errors.rs | 78 ++ src/modules/pubky_swaps/keys.rs | 98 ++ src/modules/pubky_swaps/mod.rs | 16 + src/modules/pubky_swaps/router.rs | 182 +++ src/modules/pubky_swaps/store.rs | 83 ++ src/modules/pubky_swaps/transport.rs | 90 ++ src/modules/pubky_swaps/types.rs | 120 ++ 12 files changed, 2971 insertions(+), 114 deletions(-) create mode 100644 src/modules/pubky_swaps/client.rs create mode 100644 src/modules/pubky_swaps/errors.rs create mode 100644 src/modules/pubky_swaps/keys.rs create mode 100644 src/modules/pubky_swaps/mod.rs create mode 100644 src/modules/pubky_swaps/router.rs create mode 100644 src/modules/pubky_swaps/store.rs create mode 100644 src/modules/pubky_swaps/transport.rs create mode 100644 src/modules/pubky_swaps/types.rs diff --git a/Cargo.lock b/Cargo.lock index 1e2e36b..3293ce2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,7 +101,7 @@ dependencies = [ "fixed-cache", "foldhash 0.2.0", "hashbrown 0.17.1", - "indexmap 2.13.0", + "indexmap 2.14.2", "itoa", "k256", "keccak-asm", @@ -149,7 +149,7 @@ dependencies = [ "alloy-sol-macro-input", "const-hex", "heck", - "indexmap 2.13.0", + "indexmap 2.14.2", "proc-macro-error3", "proc-macro2", "quote", @@ -278,6 +278,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" +dependencies = [ + "rustversion", +] + [[package]] name = "argon2" version = "0.5.3" @@ -631,6 +640,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version 0.4.1", +] + [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -646,6 +666,18 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http", + "log", + "url", +] + [[package]] name = "auto_impl" version = "1.3.0" @@ -686,12 +718,29 @@ dependencies = [ "pkg-config", ] +[[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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base32" version = "0.5.1" @@ -726,6 +775,12 @@ 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" @@ -809,7 +864,7 @@ dependencies = [ "rand_core 0.6.4", "ripemd", "secp256k1 0.27.0", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -888,7 +943,7 @@ dependencies = [ "secp256k1 0.30.0", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "zeroize", ] @@ -1001,9 +1056,11 @@ dependencies = [ "boltz-client", "btleplug", "chrono", + "electrum-client 0.25.0", "hex", + "iroh", "jade-client-rs", - "jni", + "jni 0.19.0", "lazy-regex", "lightning-invoice 0.32.0", "lnurl-rs", @@ -1019,13 +1076,14 @@ dependencies = [ "r2d2_sqlite", "rand 0.8.5", "regex", - "reqwest", + "reqwest 0.12.28", "rusqlite", "rust-blocktank-client", "rust_decimal", "serde", "serde_json", "serial_test", + "swap-common", "tempfile", "test-case", "thiserror 2.0.18", @@ -1106,7 +1164,16 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "objc2", + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", ] [[package]] @@ -1160,7 +1227,7 @@ dependencies = [ "lightning", "lightning-invoice 0.34.1", "log", - "reqwest", + "reqwest 0.12.28", "secp256k1 0.32.0-beta.2", "serde", "serde_json", @@ -1211,7 +1278,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ - "sha2", + "sha2 0.10.9", "tinyvec", ] @@ -1227,20 +1294,20 @@ dependencies = [ "dashmap 6.1.0", "dbus", "futures", - "jni", + "jni 0.19.0", "jni-utils", "log", - "objc2", + "objc2 0.5.2", "objc2-core-bluetooth", - "objc2-foundation", + "objc2-foundation 0.2.2", "once_cell", "static_assertions", "thiserror 2.0.18", "tokio", "tokio-stream", "uuid", - "windows", - "windows-future", + "windows 0.61.3", + "windows-future 0.2.1", ] [[package]] @@ -1359,9 +1426,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -1474,6 +1541,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cobs" version = "0.3.0" @@ -1517,6 +1590,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const_format" version = "0.2.36" @@ -1730,6 +1809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", + "rand_core 0.10.0", ] [[package]] @@ -1756,6 +1836,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1766,8 +1855,26 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto", + "fiat-crypto 0.2.9", + "rustc_version 0.4.1", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rand_core 0.10.0", "rustc_version 0.4.1", + "serde", "subtle", "zeroize", ] @@ -1812,9 +1919,29 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-encoding-macro" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" +dependencies = [ + "data-encoding", + "syn 3.0.6", +] [[package]] name = "dbus" @@ -1878,7 +2005,18 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468", "zeroize", ] @@ -1948,7 +2086,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -1963,6 +2101,18 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "block2 0.6.2", + "libc", + "objc2 0.6.5", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1974,6 +2124,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + [[package]] name = "dnssec-prover" version = "0.6.8" @@ -2016,12 +2177,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", "elliptic-curve", "rfc6979", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", ] [[package]] @@ -2030,9 +2191,20 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", + "pkcs8 0.10.2", "serde", - "signature", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8 0.11.0", + "serdect", + "signature 3.0.0", ] [[package]] @@ -2041,10 +2213,26 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", + "rand_core 0.10.0", "serde", - "sha2", + "sha2 0.11.1", + "signature 3.0.0", "subtle", "zeroize", ] @@ -2122,13 +2310,13 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", + "base16ct 0.2.0", "crypto-bigint", "digest 0.10.7", "ff", "generic-array", "group", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", "sec1", "subtle", @@ -2156,6 +2344,17 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "enum-assoc" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0590c4a94da3372e83493b956755a6e2266830b6e4e3b101afe66e3f39477b91" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "enum-ordinalize" version = "4.4.2" @@ -2281,6 +2480,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2336,6 +2541,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" @@ -2659,6 +2870,25 @@ dependencies = [ "subtle", ] +[[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 = "half" version = "2.7.1" @@ -2712,12 +2942,6 @@ dependencies = [ "foldhash 0.1.5", ] -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - [[package]] name = "hashbrown" version = "0.17.1" @@ -2895,9 +3119,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -2921,7 +3147,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.6", + "webpki-roots 1.0.11", ] [[package]] @@ -3074,6 +3300,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + [[package]] name = "idna" version = "1.1.0" @@ -3095,6 +3327,26 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "igd-next" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.10.3", + "tokio", + "url", + "xmltree", +] + [[package]] name = "impl-codec" version = "0.6.0" @@ -3128,12 +3380,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -3167,6 +3419,19 @@ dependencies = [ "mach2", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3184,27 +3449,191 @@ dependencies = [ ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "iroh" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "b2f8d1cfffc83efe39a1031aab423ce09cb8048071baba550508931e9a81ce46" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek 3.0.0", + "futures-util", + "getrandom 0.4.2", + "http", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "portmapper", + "rand 0.10.3", + "reqwest 0.13.5", + "rustc-hash", + "rustls 0.23.46", + "rustls-pki-types", + "serde", + "smallvec", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] [[package]] -name = "itertools" -version = "0.10.5" +name = "iroh-base" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "43b72366b16001af3a51574233df88ca6d62da0215807549a769e7de887da803" dependencies = [ - "either", + "curve25519-dalek 5.0.0", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek 3.0.0", + "getrandom 0.4.2", + "n0-error", + "rand 0.10.3", + "serde", + "url", + "zeroize", ] [[package]] -name = "itertools" -version = "0.13.0" +name = "iroh-dns" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "7481d0792dde1035a2b06ec06ed3cf6d01fcb1a494e6931b489e32073382048d" dependencies = [ - "either", + "arc-swap", + "cfg_aliases", + "derive_more", + "iroh-base", + "n0-dns-resolver", + "n0-error", + "n0-future", + "portable-atomic", + "rand 0.10.3", + "rustls 0.23.46", + "simple-dns 0.12.0", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ede55536349842337f7cdc63012ec33dc637945f3259c98a0763aac364cdf09" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "iroh-relay" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e5589e7c1475700686603a970275687f760df853bc6525c7eb1347aa31d7230" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.2", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru 0.18.5", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "percent-encoding", + "pin-project", + "postcard", + "rand 0.10.3", + "reqwest 0.13.5", + "rustls 0.23.46", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "webpki-roots 1.0.11", + "ws_stream_wasm", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", ] [[package]] @@ -3234,7 +3663,7 @@ dependencies = [ "log", "minicbor", "rand 0.8.5", - "reqwest", + "reqwest 0.12.28", "serde", "serde_bytes", "serde_json", @@ -3295,18 +3724,83 @@ checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" dependencies = [ "cesu8", "combine", - "jni-sys", + "jni-sys 0.3.0", "log", "thiserror 1.0.69", "walkdir", ] +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.0", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jni-utils" version = "0.1.1" @@ -3315,7 +3809,7 @@ checksum = "259e9f2c3ead61de911f147000660511f07ab00adeed1d84f5ac4d0386e7a6c4" dependencies = [ "dashmap 5.5.3", "futures", - "jni", + "jni 0.19.0", "log", "once_cell", "static_assertions", @@ -3352,7 +3846,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "once_cell", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3590,7 +4084,7 @@ dependencies = [ "bitcoin 0.32.8", "cbc", "email_address", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "ureq", @@ -3631,12 +4125,27 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465" +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + [[package]] name = "mach2" version = "0.4.3" @@ -3666,11 +4175,11 @@ dependencies = [ "crc", "document-features", "dyn-clone", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "flume", "futures-lite", "getrandom 0.2.17", - "lru", + "lru 0.13.0", "serde", "serde_bencode", "serde_bytes", @@ -3760,6 +4269,99 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "musig2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183312a1f782d0e27c2e9aa4d68c151301caa39b38f48ea125c1d55ab1ff29f0" +dependencies = [ + "base16ct 0.2.0", + "hmac", + "secp", + "secp256k1 0.31.1", + "sha2 0.10.9", + "subtle", +] + +[[package]] +name = "n0-dns-resolver" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535c99c9a786bee714155d2ee4fc2477405c2c43536e1bc6a51de09a0d1448f7" +dependencies = [ + "derive_more", + "ipconfig", + "jni 0.22.4", + "lru 0.18.5", + "n0-error", + "n0-future", + "ndk-context", + "rand 0.10.3", + "reqwest 0.13.5", + "rustc-hash", + "rustls 0.23.46", + "simple-dns 0.12.0", + "system-configuration", + "tokio", + "tokio-rustls", + "tracing", + "webpki-roots 1.0.11", +] + +[[package]] +name = "n0-error" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c59ea174675ce6bc196878851e5049e11b8b1f9af3ba87e4d6df8d828bb001" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4412346410d6616f3668c40e53c298e5320c7b856f7a960e5914e442601be79f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bf1ecb076a87e0e3f5edcf726e7d29e788c273bb0161ba30666b53f085906c" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", + "slotmap", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -3777,6 +4379,180 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "netdev" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "096c44b66a9b09b99b4dd36b1c295ff3a492039ccecaf55466bd6ddcdba59968" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core 0.8.2", + "netlink-packet-route 0.31.0", + "netlink-sys 0.8.8", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation 0.3.2", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netdev" +version = "0.46.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c52c2584961c68f5a6e3356797344061f818c93b8acc9cd6d7e284795e563e" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core 0.9.0", + "netlink-packet-route 0.33.0", + "netlink-sys 0.9.0", + "objc2-core-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b897d7bd4f0af82e68d40d0344cf37e97f9c97ddf74a098de3e4da05e96ca395" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6e2daf9a8c2e21302714706860a0e6be02e03d17294ecc66b354ea7d4059dd" + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags 2.11.0", + "libc", + "log", + "netlink-packet-core 0.8.2", +] + +[[package]] +name = "netlink-packet-route" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59d48ffc8b73a506e1ff0878528c72668f2bfbc3d875b6be890a96be5d0d5576" +dependencies = [ + "bitflags 2.11.0", + "libc", + "log", + "netlink-packet-core 0.9.0", + "zerocopy", +] + +[[package]] +name = "netlink-proto" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93af8261786086024cd5e96e0a991dd65ced07bbf7c233a487bbc96b971d5539" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "log", + "netlink-packet-core 0.8.2", + "netlink-sys 0.8.8", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netlink-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99d38e00b420df49e940fe0826e667c3dad82ac68eb4c2bbf754c1c14a83a10" +dependencies = [ + "bytes", + "libc", + "log", +] + +[[package]] +name = "netwatch" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39da9cad5f23aa43401f09497d3b280e51b5feba115d9ffbf38ca35d6ff95e96" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev 0.45.1", + "netdev 0.46.3", + "netlink-packet-core 0.8.2", + "netlink-packet-route 0.31.0", + "netlink-proto", + "netlink-sys 0.8.8", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows 0.62.2", + "windows-result 0.4.1", + "wmi", +] + [[package]] name = "nix" version = "0.26.4" @@ -3798,6 +4574,68 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "noq" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6249a065adc1edbc1704a95436f38dd60476064dbc3b7c47cf279e02d1590eb5" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash", + "rustls 0.23.46", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c01a35be3ed37558c8db69554419f24eff8a63599b54f711bbe3863ec727c47d" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.2", + "identity-hash", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls 0.23.46", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d0160422832eea1cc11c34be04ee6d5a046452b0b46cfb14695d55a1f24c954" +dependencies = [ + "cfg_aliases", + "libc", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "ntimestamp" version = "1.0.0" @@ -3857,6 +4695,28 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -3873,6 +4733,15 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + [[package]] name = "objc2-core-bluetooth" version = "0.2.2" @@ -3880,8 +4749,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a644b62ffb826a5277f536cf0f701493de420b13d40e700c452c36567771111" dependencies = [ "bitflags 2.11.0", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "block2 0.6.2", + "dispatch2", + "libc", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-security", + "objc2-security-foundation", ] [[package]] @@ -3897,9 +4793,57 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "block2 0.6.2", + "libc", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", "libc", - "objc2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-security", ] [[package]] @@ -3984,6 +4928,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "papaya" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da2442474a9404698c42509b8967f437249dbc7b50493e83020333d3943ec0ae" +dependencies = [ + "equivalent", + "seize", +] + [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -4093,6 +5047,15 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4116,7 +5079,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap 2.13.0", + "indexmap 2.14.2", +] + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version 0.4.1", ] [[package]] @@ -4161,6 +5134,26 @@ dependencies = [ "siphasher 1.0.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.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -4186,24 +5179,24 @@ dependencies = [ "cfg_aliases", "document-features", "dyn-clone", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "futures-buffered", "futures-lite", "genawaiter", "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", "serde", "sha1_smol", - "simple-dns", + "simple-dns 0.9.3", "thiserror 2.0.18", "tokio", "tracing", @@ -4217,8 +5210,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.3", + "spki 0.8.1", ] [[package]] @@ -4233,6 +5236,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -4261,6 +5277,9 @@ name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] [[package]] name = "portable-atomic-util" @@ -4271,6 +5290,35 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "portmapper" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca97242f016e090a25330613bc2382aab65eb22f9149dbe84d8f8e711d49a530" +dependencies = [ + "base64 0.22.1", + "bytes", + "derive_more", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "n0-future", + "netwatch", + "num_enum", + "rand 0.10.3", + "serde", + "smallvec", + "socket2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + [[package]] name = "possiblyrandom" version = "0.2.1" @@ -4290,9 +5338,21 @@ dependencies = [ "embedded-io 0.4.0", "embedded-io 0.6.1", "heapless", + "postcard-derive", "serde", ] +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -4510,7 +5570,7 @@ dependencies = [ "log", "pkarr", "pubky-common 0.5.4", - "reqwest", + "reqwest 0.12.28", "thiserror 2.0.18", "tokio", "tracing", @@ -4533,7 +5593,7 @@ dependencies = [ "log", "pkarr", "pubky-common 0.6.0", - "reqwest", + "reqwest 0.12.28", "thiserror 2.0.18", "tokio", "tracing", @@ -4571,7 +5631,7 @@ dependencies = [ "base32", "blake3", "crypto_secretbox", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "js-sys", "once_cell", "pkarr", @@ -4592,7 +5652,7 @@ dependencies = [ "base32", "blake3", "crypto_secretbox", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "js-sys", "once_cell", "pkarr", @@ -4629,6 +5689,15 @@ dependencies = [ "psl-types", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4965,7 +6034,44 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.6", + "webpki-roots 1.0.11", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls 0.23.46", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", ] [[package]] @@ -5106,7 +6212,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", @@ -5132,9 +6238,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc-hex" @@ -5223,6 +6329,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls 0.23.46", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.15", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -5397,14 +6530,25 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", + "base16ct 0.2.0", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "subtle", "zeroize", ] +[[package]] +name = "secp" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ef0ffc3b1b2720b00b919f6c697b1e64aca7bba54b93e0ef4b8560fac6f946" +dependencies = [ + "base16ct 0.2.0", + "secp256k1 0.31.1", + "subtle", +] + [[package]] name = "secp256k1" version = "0.27.0" @@ -5543,6 +6687,12 @@ dependencies = [ "libc", ] +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" + [[package]] name = "self_cell" version = "1.2.2" @@ -5577,6 +6727,12 @@ dependencies = [ "pest", ] +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -5686,7 +6842,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.14.2", "jiff", "schemars 0.9.0", "schemars 1.2.2", @@ -5695,6 +6851,16 @@ dependencies = [ "time", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", + "serde", +] + [[package]] name = "serial_test" version = "3.4.0" @@ -5767,6 +6933,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha3" version = "0.10.9" @@ -5832,12 +7009,31 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "rand_core 0.10.0", +] + [[package]] name = "simd-adler32" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version 0.4.1", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -5853,6 +7049,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "simple-dns" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6f884fa9a8d48101774bfbd3aeb81e968dd22cffd19a372da69f183db22c1a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -5887,6 +7092,15 @@ dependencies = [ "parking_lot 0.11.2", ] +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -5920,6 +7134,23 @@ dependencies = [ "winapi", ] +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "spin" version = "0.9.8" @@ -5942,7 +7173,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der 0.8.3", ] [[package]] @@ -5963,12 +7204,51 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swap-common" +version = "0.1.0" +source = "git+https://github.com/coreyphillips/pubky-swap?rev=8686702#86867029e6da06e7ba787c6ec61667a38789fe7a" +dependencies = [ + "anyhow", + "bitcoin 0.32.8", + "hex", + "musig2", + "rand 0.8.5", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "syn" version = "1.0.109" @@ -6043,6 +7323,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501336eb7ba9e417300a6a0fa985721065467aa83a6dcf0422a8e43e4c0328fa" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "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 = "tap" version = "1.0.1" @@ -6161,6 +7462,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -6314,10 +7616,34 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.2", + "http", + "httparse", + "rand 0.10.3", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + [[package]] name = "toml" version = "0.5.11" @@ -6342,7 +7668,7 @@ version = "0.25.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.2", "toml_datetime", "toml_parser", "winnow 0.7.15", @@ -6408,6 +7734,7 @@ 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", @@ -6488,7 +7815,7 @@ dependencies = [ "rusb", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "tokio", "tracing", @@ -6619,7 +7946,7 @@ dependencies = [ "glob", "goblin", "heck", - "indexmap 2.13.0", + "indexmap 2.14.2", "once_cell", "serde", "tempfile", @@ -6651,7 +7978,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09acd2ce09c777dd65ee97c251d33c8a972afc04873f1e3b21eb3492ade16933" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.14.2", "proc-macro2", "quote", "syn 2.0.117", @@ -6694,7 +8021,7 @@ checksum = "dd76b3ac8a2d964ca9fce7df21c755afb4c77b054a85ad7a029ad179cc5abb8a" dependencies = [ "anyhow", "heck", - "indexmap 2.13.0", + "indexmap 2.14.2", "tempfile", "uniffi_internal_macros", ] @@ -6798,6 +8125,7 @@ dependencies = [ "getrandom 0.4.2", "js-sys", "rand 0.10.3", + "serde_core", "wasm-bindgen", ] @@ -6938,11 +8266,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.14.2", "wasm-encoder", "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -6951,7 +8292,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.2", "semver 1.0.27", ] @@ -6985,6 +8326,15 @@ dependencies = [ "untrusted", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.22.6" @@ -7006,14 +8356,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", ] @@ -7027,6 +8377,12 @@ dependencies = [ "nom", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -7064,11 +8420,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -7080,6 +8448,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -7114,7 +8491,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -7161,6 +8549,27 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[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 +8606,22 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[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 +8630,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 +8642,35 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[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 +8682,45 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -7273,24 +8733,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -7343,7 +8827,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap 2.13.0", + "indexmap 2.14.2", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -7374,7 +8858,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.0", - "indexmap 2.13.0", + "indexmap 2.14.2", "log", "serde", "serde_derive", @@ -7393,7 +8877,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.14.2", "log", "semver 1.0.27", "serde", @@ -7403,12 +8887,46 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows 0.61.3", + "windows-core 0.62.2", +] + [[package]] name = "writeable" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version 0.4.1", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wyz" version = "0.5.1" @@ -7424,7 +8942,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "rand_core 0.6.4", "serde", "zeroize", @@ -7436,6 +8954,21 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8aa498d22c9bbaf482329839bc5620c46be275a19a812e9a22a2b07529a642a" +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + [[package]] name = "yoke" version = "0.8.1" @@ -7502,18 +9035,18 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index d3aba16..c43cc8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,11 @@ ur = "0.5.2" log = "0.4" pubky = "0.6.0" pubky-app-specs = "0.4" +# Pubky Swaps client: wire types, contract checks and HTLC spends from pubky-swap, +# requests over its iroh `pubky-swap/direct/1` protocol, lockups read over Electrum. +swap-common = { git = "https://github.com/coreyphillips/pubky-swap", rev = "8686702" } +iroh = "=1.2.0" +electrum-client = { version = "0.25", default-features = false, features = ["proxy", "use-rustls-ring"] } # Bluetooth (for Android JNI initialization) # btleplug is also pulled in transitively by trezor-connect-rs diff --git a/src/lib.rs b/src/lib.rs index bcb474f..b0c99b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,6 +54,10 @@ pub use crate::modules::jade::{ JadeTransportReadResult, JadeTransportResult, JadeVersionInfo, JadeXpubResponse, }; use crate::modules::pubky::{PubkyAuthDetails, PubkyAuthKind, PubkyError, PubkyProfile}; +use crate::modules::pubky_swaps::{ + self as pubky_swaps, PaykitRoute, PaykitRouteRequest, PubkySwapError, PubkySwapRoutePayment, + PubkySwapRouteStatus, SwapRouteEdge, +}; use crate::modules::trezor::account_type_to_script_type; pub use crate::modules::trezor::{ get_transport_callback, trezor_is_ble_available, trezor_set_transport_callback, @@ -509,6 +513,9 @@ pub fn init_db(base_path: String) -> Result { let blocktank_db = rt .block_on(async { BlocktankDB::new(&format!("{}/blocktank.db", base_path), None).await })?; let boltz_db = rt.block_on(async { BoltzDB::new(&format!("{}/boltz.db", base_path)).await })?; + modules::pubky_swaps::init(&base_path).map_err(|e| DbError::InitializationError { + error_details: e.to_string(), + })?; // Initialize sync database { @@ -3415,6 +3422,81 @@ pub async fn boltz_stop_swap_updates() { .await; } +// ============================================================================ +// Pubky Swaps as Paykit routes +// ============================================================================ + +fn pubky_swaps_runtime_err(e: tokio::task::JoinError) -> PubkySwapError { + PubkySwapError::Transport { + error_details: format!("Runtime error: {}", e), + } +} + +/// The `swap/1` edges a Pubky Swaps provider advertises right now, read from its live offer +/// over iroh. The wallet's swap identity is derived from `mnemonic`. +#[uniffi::export] +pub async fn pubky_swaps_provider_edges( + provider_pubky: String, + mnemonic: String, + bip39_passphrase: Option, +) -> Result, PubkySwapError> { + let rt = ensure_runtime(); + rt.spawn(async move { + pubky_swaps::provider_edges(provider_pubky, mnemonic, bip39_passphrase).await + }) + .await + .unwrap_or_else(|e| Err(pubky_swaps_runtime_err(e))) +} + +/// Pick how to pay a Paykit contact. Returns `Direct` when a balance the wallet holds reaches +/// one of the contact's endpoints, otherwise the cheapest firm quote from the trusted Pubky +/// Swaps providers, or `None` with a reason. The user never picks the swap. +#[uniffi::export] +pub async fn pubky_swaps_route_paykit_payment( + request: PaykitRouteRequest, + network: Network, + mnemonic: String, + bip39_passphrase: Option, +) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { + pubky_swaps::route_paykit_payment(request, network.into(), mnemonic, bip39_passphrase).await + }) + .await + .unwrap_or_else(|e| Err(pubky_swaps_runtime_err(e))) +} + +/// Commit to a routed quote and get the hold invoice to pay. The provider's contract and invoice +/// are checked against the quote and the wallet's own keys before they are returned. +#[uniffi::export] +pub async fn pubky_swaps_commit_route( + quote_id: String, + electrum_url: String, + mnemonic: String, + bip39_passphrase: Option, +) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { + pubky_swaps::commit_route(quote_id, electrum_url, mnemonic, bip39_passphrase).await + }) + .await + .unwrap_or_else(|e| Err(pubky_swaps_runtime_err(e))) +} + +/// Advance a committed route: once the provider's lockup has its confirmations, claim it to the +/// contact's address. Call after paying the hold invoice, until it reports `Claimed`. +#[uniffi::export] +pub async fn pubky_swaps_advance( + swap_id: String, + mnemonic: String, + bip39_passphrase: Option, +) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { pubky_swaps::advance_swap(swap_id, mnemonic, bip39_passphrase).await }) + .await + .unwrap_or_else(|e| Err(pubky_swaps_runtime_err(e))) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/modules/mod.rs b/src/modules/mod.rs index d628203..812b215 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -6,6 +6,7 @@ pub mod jade; pub mod lnurl; pub mod onchain; pub mod pubky; +pub mod pubky_swaps; pub mod scanner; pub mod seedqr; pub mod trezor; diff --git a/src/modules/pubky_swaps/client.rs b/src/modules/pubky_swaps/client.rs new file mode 100644 index 0000000..914c5e3 --- /dev/null +++ b/src/modules/pubky_swaps/client.rs @@ -0,0 +1,569 @@ +//! The `swap/1` flow for a Paykit payment: route, commit, claim. +//! +//! Message mapping (Paykit Capabilities §13.1): `rfq` is pubky-swap's `QuoteRequest`, `quote` +//! is `Quote`, `commit` is `SwapRequest`, `contract` is `SwapAccept`, `refusal` is `Reject`. +//! Here they travel over iroh `direct/1`, which §13.1 allows for the same payloads. + +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use bitcoin::{Address, OutPoint, ScriptBuf}; +use electrum_client::{Client as ElectrumClient, ElectrumApi}; +use once_cell::sync::Lazy; +use swap_common::htlc::build_htlc_script; +use swap_common::messages::{ + OfferRequest, Quote, QuoteRequest, Reject, SwapMessage, SwapOffer, SwapRequest, SwapScript, + SwapStatusRequest, PROTOCOL_VERSION, +}; +use swap_common::onchain::{build_claim_tx, estimate_spend_fee, spend_vsize}; +use swap_common::timelock::TimelockParams; +use swap_common::validate::{ + validate_accept, validate_hold_invoice, validate_quote, verify_contract, ClientPolicy, + DecodedHoldInvoice, +}; +use swap_common::SwapDirection; +use uuid::Uuid; + +use super::errors::PubkySwapError; +use super::keys; +use super::router::{self, is_lightning}; +use super::store::{self, SwapRecord}; +use super::transport::{bare_pubky, DirectClient}; +use super::types::*; + +/// Quotes the router fetched, kept until the wallet commits to one of them. +static QUOTES: Lazy>> = Lazy::new(Default::default); + +#[derive(Clone)] +struct CachedQuote { + route: PubkySwapRouteQuote, + quote: Quote, + network: bitcoin::Network, +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn policy(network: bitcoin::Network) -> ClientPolicy { + ClientPolicy::for_network(network, TimelockParams::default()) +} + +fn refused(reject: Reject) -> PubkySwapError { + PubkySwapError::Refused { + code: reject.code.unwrap_or_else(|| "unknown".into()), + reason: reject.reason, + } +} + +fn unexpected(what: &str, got: &SwapMessage) -> PubkySwapError { + PubkySwapError::transport(format!("expected {what}, got {got:?}")) +} + +fn identity_pubky(secret: &[u8; 32]) -> String { + pubky::Keypair::from_secret(secret).public_key().to_z32() +} + +fn parse_address(address: &str, network: bitcoin::Network) -> Result { + Address::from_str(address) + .map_err(|e| PubkySwapError::invalid(format!("address: {e}")))? + .require_network(network) + .map_err(|e| PubkySwapError::invalid(format!("address: {e}"))) +} + +/// The wallet's fee for claiming the provider's lockup to `destination`. +fn claim_fee(destination: &ScriptBuf, fee_rate_sat_vb: u64) -> Result { + // Only the shape matters for the size: a real contract has the same lengths. + let key = keys::swap_keys( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + None, + 0, + )?; + let script = build_htlc_script(&[0; 32], &key.claim_pubkey, &key.claim_pubkey, 1_000_000); + Ok(estimate_spend_fee( + fee_rate_sat_vb.max(1), + spend_vsize(&script, destination, true), + )) +} + +async fn fetch_offer(client: &DirectClient, provider: &str) -> Result { + let request = SwapMessage::OfferRequest(OfferRequest { + request_id: Some(Uuid::new_v4()), + }); + match client.request(provider, &request).await? { + SwapMessage::Offer(offer) => Ok(offer), + SwapMessage::Reject(reject) => Err(refused(reject)), + other => Err(unexpected("Offer", &other)), + } +} + +/// The `swap/1` edges a provider advertises right now. +pub async fn provider_edges( + provider: String, + mnemonic: String, + bip39_passphrase: Option, +) -> Result, PubkySwapError> { + let secret = keys::identity_secret(&mnemonic, bip39_passphrase.as_deref())?; + let client = DirectClient::bind(secret).await?; + let offer = fetch_offer(&client, bare_pubky(&provider)).await; + client.close().await; + Ok(router::edges_from_offer(&offer?)) +} + +async fn quote_edge( + client: &DirectClient, + identity: &str, + offer: &SwapOffer, + lock_sat: u64, + network: bitcoin::Network, +) -> Result { + let request = QuoteRequest { + request_id: Some(Uuid::new_v4()), + offer_id: offer.offer_id, + client_pkarr: identity.to_string(), + direction: SwapDirection::Reverse, + amount_sat: lock_sat, + protocol_version: PROTOCOL_VERSION, + features: vec![], + }; + let reply = client + .request( + &offer.provider_pkarr, + &SwapMessage::QuoteRequest(request.clone()), + ) + .await?; + let quote = match reply { + SwapMessage::Quote(quote) => quote, + SwapMessage::Reject(reject) => return Err(refused(reject)), + other => return Err(unexpected("Quote", &other)), + }; + validate_quote("e, &request, now_unix(), &policy(network)) + .map_err(PubkySwapError::validation)?; + Ok(quote) +} + +/// Pick the route for a Paykit payment. The user never picks a swap: one is used only when no +/// rail the wallet holds reaches an endpoint the contact published. +pub async fn route_paykit_payment( + request: PaykitRouteRequest, + network: bitcoin::Network, + mnemonic: String, + bip39_passphrase: Option, +) -> Result { + let amount = request.amount_sat; + if amount == 0 { + return Err(PubkySwapError::invalid("amount must be positive")); + } + let endpoints = &request.contact_endpoints; + if let Some(lightning) = endpoints.iter().find(|e| is_lightning(&e.method_id)) { + if request.lightning_spendable_sat >= amount { + return Ok(PaykitRoute::Direct { + endpoint: lightning.clone(), + }); + } + } + let onchain = router::onchain_endpoint(endpoints, network); + if let Some(onchain) = &onchain { + if request.onchain_spendable_sat >= amount { + return Ok(PaykitRoute::Direct { + endpoint: onchain.clone(), + }); + } + } + let Some(onchain) = onchain else { + return Ok(PaykitRoute::None { + reason: "No balance can reach an endpoint this contact accepts.".into(), + }); + }; + if request.providers.is_empty() { + return Ok(PaykitRoute::None { + reason: "No swap provider is set up.".into(), + }); + } + + let destination = parse_address(&onchain.value, network)?.script_pubkey(); + let claim_fee_sat = claim_fee(&destination, request.fee_rate_sat_vb)?; + let lock_sat = amount + claim_fee_sat; + + let secret = keys::identity_secret(&mnemonic, bip39_passphrase.as_deref())?; + let identity = identity_pubky(&secret); + let client = DirectClient::bind(secret).await?; + + let mut offers = HashMap::new(); + let mut edges = vec![]; + for provider in &request.providers { + match fetch_offer(&client, bare_pubky(provider)).await { + Ok(offer) if offer.network.to_bitcoin_network() == network => { + edges.extend(router::edges_from_offer(&offer)); + offers.insert(offer.provider_pkarr.clone(), offer); + } + Ok(_) => log::warn!("pubky swaps: provider {provider} is on another network"), + Err(e) => log::warn!("pubky swaps: no offer from {provider}: {e}"), + } + } + let ranked = router::rank_reverse_edges(edges, lock_sat, request.lightning_spendable_sat); + let asked = ranked.len() as u32; + + let mut best: Option<(Quote, String)> = None; + for edge in &ranked { + let Some(offer) = offers.get(&edge.provider_pubky) else { + continue; + }; + match quote_edge(&client, &identity, offer, lock_sat, network).await { + Ok(quote) if quote.total_sat <= request.lightning_spendable_sat => { + if best + .as_ref() + .is_none_or(|(b, _)| quote.total_sat < b.total_sat) + { + best = Some((quote, edge.provider_pubky.clone())); + } + } + Ok(_) => {} + Err(e) => log::warn!("pubky swaps: no quote from {}: {e}", edge.provider_pubky), + } + } + client.close().await; + + let Some((quote, provider)) = best else { + return Ok(PaykitRoute::None { + reason: "No swap provider can route this payment right now.".into(), + }); + }; + let route = PubkySwapRouteQuote { + provider_pubky: provider, + quote_id: quote.quote_id.to_string(), + endpoint: onchain, + delivered_sat: amount, + lock_sat, + debit_sat: quote.total_sat, + service_fee_sat: quote.service_fee_sat, + provider_onchain_fee_sat: quote.onchain_fee_sat, + claim_fee_sat, + total_fee_sat: quote.total_sat.saturating_sub(amount), + atomicity: "hash".into(), + valid_until_unix: quote.valid_until_unix, + providers_quoted: asked, + }; + QUOTES.lock().unwrap().insert( + route.quote_id.clone(), + CachedQuote { + route: route.clone(), + quote, + network, + }, + ); + Ok(PaykitRoute::Swap { quote: route }) +} + +fn decode_hold_invoice(invoice: &str) -> Result { + let invoice = lightning_invoice::Bolt11Invoice::from_str(invoice) + .map_err(|e| PubkySwapError::validation(format!("hold invoice: {e}")))?; + let amount_msat = invoice.amount_milli_satoshis(); + let expires_at = invoice + .expires_at() + .map(|d| d.as_secs()) + .unwrap_or_default(); + Ok(DecodedHoldInvoice { + payment_hash: *invoice.payment_hash().as_ref(), + amount_msat: amount_msat.unwrap_or_default(), + amount_is_explicit: amount_msat.is_some(), + expires_at_unix: expires_at, + }) +} + +fn chain_tip(electrum_url: &str) -> Result { + let client = ElectrumClient::new(electrum_url)?; + Ok(client.block_headers_subscribe()?.height as u32) +} + +/// Commit to a routed quote. Returns the hold invoice the wallet pays over Lightning; nothing +/// is funded before that payment. +pub async fn commit_route( + quote_id: String, + electrum_url: String, + mnemonic: String, + bip39_passphrase: Option, +) -> Result { + let cached = QUOTES + .lock() + .unwrap() + .get("e_id) + .cloned() + .ok_or_else(|| PubkySwapError::NotFound { + error_details: format!("quote {quote_id}"), + })?; + let CachedQuote { + route, + quote, + network, + } = cached; + if quote.is_expired(now_unix()) { + return Err(PubkySwapError::Refused { + code: "quote_expired".into(), + reason: "The quote expired. Route the payment again.".into(), + }); + } + let passphrase = bip39_passphrase.as_deref(); + let secret = keys::identity_secret(&mnemonic, passphrase)?; + let index = store::reserve_index()?; + let swap_keys = keys::swap_keys(&mnemonic, passphrase, index)?; + let request = SwapRequest { + script_type: SwapScript::P2wsh, + quote_id: quote.quote_id, + client_pkarr: identity_pubky(&secret), + direction: SwapDirection::Reverse, + payment_hash_hex: hex::encode(swap_keys.payment_hash), + client_claim_pubkey_hex: Some(swap_keys.claim_pubkey.to_string()), + client_refund_pubkey_hex: None, + invoice: None, + }; + + let client = DirectClient::bind(secret).await?; + let reply = client + .request(&route.provider_pubky, &SwapMessage::SwapRequest(request)) + .await; + let reply = match reply { + // `pending` means the provider is still admitting it: ask again, never a decline. + Ok(SwapMessage::Reject(reject)) if reject.code.as_deref() == Some("pending") => { + let status = SwapMessage::SwapStatusRequest(SwapStatusRequest { + request_id: Some(Uuid::new_v4()), + swap_id: None, + quote_id: Some(quote.quote_id), + }); + client.request(&route.provider_pubky, &status).await + } + other => other, + }; + client.close().await; + let accept = match reply? { + SwapMessage::SwapAccept(accept) => accept, + SwapMessage::SwapStatusSnapshot(snapshot) => snapshot.accept, + SwapMessage::Reject(reject) => return Err(refused(reject)), + other => return Err(unexpected("SwapAccept", &other)), + }; + + let tip = { + let url = electrum_url.clone(); + tokio::task::spawn_blocking(move || chain_tip(&url)) + .await + .map_err(PubkySwapError::chain)?? + }; + let policy = policy(network); + validate_accept(&accept, "e, Some(tip), &policy).map_err(PubkySwapError::validation)?; + let htlc_spk = verify_contract( + &accept, + SwapDirection::Reverse, + &swap_keys.payment_hash, + &swap_keys.claim_pubkey, + network, + ) + .map_err(PubkySwapError::validation)?; + let invoice = accept + .invoice + .clone() + .ok_or_else(|| PubkySwapError::validation("the contract has no hold invoice"))?; + validate_hold_invoice( + &decode_hold_invoice(&invoice)?, + "e, + &swap_keys.payment_hash, + now_unix(), + &policy, + ) + .map_err(PubkySwapError::validation)?; + let htlc_address = Address::from_script(&htlc_spk, network) + .map_err(PubkySwapError::validation)? + .to_string(); + + let record = SwapRecord { + swap_id: accept.swap_id.to_string(), + key_index: index, + provider_pubky: route.provider_pubky.clone(), + network, + electrum_url, + quote, + accept: accept.clone(), + destination_address: route.endpoint.value.clone(), + delivered_sat: route.delivered_sat, + claim_fee_sat: route.claim_fee_sat, + claim_txid: None, + created_at_unix: now_unix(), + }; + store::save(&record)?; + QUOTES.lock().unwrap().remove("e_id); + + Ok(PubkySwapRoutePayment { + swap_id: record.swap_id, + invoice, + debit_sat: route.debit_sat, + delivered_sat: route.delivered_sat, + destination_address: route.endpoint.value, + htlc_address, + timeout_block_height: accept.timeout_block_height, + }) +} + +fn status_of(record: &SwapRecord, state: PubkySwapRouteState) -> PubkySwapRouteStatus { + PubkySwapRouteStatus { + swap_id: record.swap_id.clone(), + state, + provider_pubky: record.provider_pubky.clone(), + delivered_sat: record.delivered_sat, + destination_address: record.destination_address.clone(), + } +} + +/// Find the provider's lockup and, once it has its confirmations, claim it straight to the +/// contact's address. Call again until it reports `Claimed`. +fn advance_blocking( + mut record: SwapRecord, + mnemonic: &str, + passphrase: Option<&str>, +) -> Result { + if let Some(txid) = &record.claim_txid { + let state = PubkySwapRouteState::Claimed { txid: txid.clone() }; + return Ok(status_of(&record, state)); + } + let keys = keys::swap_keys(mnemonic, passphrase, record.key_index)?; + let provider = record + .accept + .provider_pubkey_hex + .parse() + .map_err(PubkySwapError::validation)?; + let redeem_script = build_htlc_script( + &keys.payment_hash, + &keys.claim_pubkey, + &provider, + record.accept.timeout_block_height, + ); + let htlc_spk = ScriptBuf::new_p2wsh(&redeem_script.wscript_hash()); + let required = + policy(record.network).effective_confirmations(record.quote.required_confirmations); + + let electrum = ElectrumClient::new(&record.electrum_url)?; + let tip = electrum.block_headers_subscribe()?.height as u32; + let mut lockup: Option<(OutPoint, u32)> = None; + for item in electrum.script_get_history(&htlc_spk)? { + let tx = electrum.transaction_get(&item.tx_hash)?; + let vout = tx.output.iter().position(|out| { + out.script_pubkey == htlc_spk && out.value.to_sat() == record.accept.onchain_amount_sat + }); + if let Some(vout) = vout { + let confirmations = if item.height > 0 { + tip.saturating_sub(item.height as u32) + 1 + } else { + 0 + }; + lockup = Some((OutPoint::new(item.tx_hash, vout as u32), confirmations)); + break; + } + } + let Some((outpoint, confirmations)) = lockup else { + let state = PubkySwapRouteState::AwaitingLockup { + confirmations: 0, + required, + }; + return Ok(status_of(&record, state)); + }; + if confirmations < required { + let state = PubkySwapRouteState::AwaitingLockup { + confirmations, + required, + }; + return Ok(status_of(&record, state)); + } + + let destination = parse_address(&record.destination_address, record.network)?; + let claim = build_claim_tx( + outpoint, + record.accept.onchain_amount_sat, + &redeem_script, + destination.script_pubkey(), + record.claim_fee_sat, + keys.preimage, + &keys.claim_key, + ) + .map_err(PubkySwapError::chain)?; + let txid = electrum.transaction_broadcast(&claim)?.to_string(); + record.claim_txid = Some(txid.clone()); + store::save(&record)?; + Ok(status_of(&record, PubkySwapRouteState::Claimed { txid })) +} + +pub async fn advance_swap( + swap_id: String, + mnemonic: String, + bip39_passphrase: Option, +) -> Result { + let record = store::load(&swap_id)?; + tokio::task::spawn_blocking(move || { + advance_blocking(record, &mnemonic, bip39_passphrase.as_deref()) + }) + .await + .map_err(PubkySwapError::chain)? +} + +#[cfg(test)] +mod live_tests { + //! Against a running regtest provider: + //! `PUBKY_SWAPS_PROVIDER= PUBKY_SWAPS_ELECTRUM=tcp://127.0.0.1:60001 + //! PUBKY_SWAPS_DESTINATION= cargo test --lib pubky_swaps::client::live_tests -- --ignored` + use super::*; + + const MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + #[tokio::test(flavor = "multi_thread")] + #[ignore = "needs a running regtest Pubky Swaps provider"] + async fn routes_an_onchain_contact_through_the_provider_and_commits() { + let provider = std::env::var("PUBKY_SWAPS_PROVIDER").unwrap(); + let electrum = std::env::var("PUBKY_SWAPS_ELECTRUM").unwrap(); + let destination = std::env::var("PUBKY_SWAPS_DESTINATION").unwrap(); + let dir = std::env::temp_dir().join(format!("pubky-swaps-{}", Uuid::new_v4())); + store::init(dir.to_str().unwrap()).unwrap(); + + let edges = provider_edges(provider.clone(), MNEMONIC.into(), None) + .await + .unwrap(); + assert!(edges.iter().any(|e| e.direction == "reverse"), "{edges:?}"); + + let request = PaykitRouteRequest { + amount_sat: 20_000, + contact_endpoints: vec![PaykitEndpoint { + method_id: "btc-regtest-p2wpkh".into(), + value: destination.clone(), + }], + lightning_spendable_sat: 500_000, + onchain_spendable_sat: 0, + providers: vec![provider], + fee_rate_sat_vb: 2, + }; + let route = route_paykit_payment(request, bitcoin::Network::Regtest, MNEMONIC.into(), None) + .await + .unwrap(); + let PaykitRoute::Swap { quote } = route else { + panic!("expected a swap route, got {route:?}"); + }; + assert_eq!(quote.delivered_sat, 20_000); + assert_eq!(quote.lock_sat, 20_000 + quote.claim_fee_sat); + assert!(quote.debit_sat > quote.lock_sat); + + let payment = commit_route(quote.quote_id.clone(), electrum, MNEMONIC.into(), None) + .await + .unwrap(); + assert_eq!(payment.destination_address, destination); + assert!(payment.invoice.starts_with("lnbcrt"), "{}", payment.invoice); + let status = advance_swap(payment.swap_id.clone(), MNEMONIC.into(), None) + .await + .unwrap(); + assert!(matches!( + status.state, + PubkySwapRouteState::AwaitingLockup { .. } + )); + log::info!("{quote:?} {payment:?}"); + } +} diff --git a/src/modules/pubky_swaps/errors.rs b/src/modules/pubky_swaps/errors.rs new file mode 100644 index 0000000..3657d5f --- /dev/null +++ b/src/modules/pubky_swaps/errors.rs @@ -0,0 +1,78 @@ +use thiserror::Error; + +/// Errors surfaced by the Pubky Swaps route module. +#[derive(uniffi::Error, Debug, Error)] +pub enum PubkySwapError { + #[error("Invalid input: {error_details}")] + InvalidInput { error_details: String }, + + #[error("Provider unreachable: {error_details}")] + Transport { error_details: String }, + + /// The provider refused the request with one of its closed refusal codes. + #[error("Provider refused ({code}): {reason}")] + Refused { code: String, reason: String }, + + /// The provider's quote, contract or invoice did not match what was asked for. + #[error("Validation failed: {error_details}")] + Validation { error_details: String }, + + #[error("Chain error: {error_details}")] + Chain { error_details: String }, + + #[error("Storage error: {error_details}")] + Storage { error_details: String }, + + #[error("Swap not found: {error_details}")] + NotFound { error_details: String }, +} + +impl PubkySwapError { + pub(crate) fn invalid(details: impl ToString) -> Self { + Self::InvalidInput { + error_details: details.to_string(), + } + } + + pub(crate) fn transport(details: impl ToString) -> Self { + Self::Transport { + error_details: details.to_string(), + } + } + + pub(crate) fn validation(details: impl ToString) -> Self { + Self::Validation { + error_details: details.to_string(), + } + } + + pub(crate) fn chain(details: impl ToString) -> Self { + Self::Chain { + error_details: details.to_string(), + } + } + + pub(crate) fn storage(details: impl ToString) -> Self { + Self::Storage { + error_details: details.to_string(), + } + } +} + +impl From for PubkySwapError { + fn from(err: serde_json::Error) -> Self { + Self::storage(err) + } +} + +impl From for PubkySwapError { + fn from(err: std::io::Error) -> Self { + Self::storage(err) + } +} + +impl From for PubkySwapError { + fn from(err: electrum_client::Error) -> Self { + Self::chain(err) + } +} diff --git a/src/modules/pubky_swaps/keys.rs b/src/modules/pubky_swaps/keys.rs new file mode 100644 index 0000000..def82d7 --- /dev/null +++ b/src/modules/pubky_swaps/keys.rs @@ -0,0 +1,98 @@ +//! Swap keys derived from the wallet seed, so a swap can be rebuilt from the seed and its index. +//! +//! The provider sees only public keys and the payment hash. Each key is +//! HMAC-SHA512(seed, domain || index), truncated to 32 bytes, under its own domain string, so +//! nothing here can collide with the Boltz module's BIP85 keys or with each other. + +use bitcoin::hashes::{hmac, sha256, sha512, Hash, HashEngine}; +use bitcoin::secp256k1::{Secp256k1, SecretKey}; + +use super::errors::PubkySwapError; + +const IDENTITY_DOMAIN: &[u8] = b"bitkit/pubky-swaps/identity/v1"; +const CLAIM_DOMAIN: &[u8] = b"bitkit/pubky-swaps/claim/v1/"; +const PREIMAGE_DOMAIN: &[u8] = b"bitkit/pubky-swaps/preimage/v1/"; + +pub(crate) struct SwapKeys { + pub claim_key: SecretKey, + pub claim_pubkey: bitcoin::PublicKey, + pub preimage: [u8; 32], + pub payment_hash: [u8; 32], +} + +fn seed(mnemonic: &str, passphrase: Option<&str>) -> Result<[u8; 64], PubkySwapError> { + let mnemonic = bip39::Mnemonic::parse(mnemonic) + .map_err(|e| PubkySwapError::invalid(format!("mnemonic: {e}")))?; + Ok(mnemonic.to_seed(passphrase.unwrap_or(""))) +} + +fn derive(seed: &[u8; 64], domain: &[u8], index: Option) -> [u8; 32] { + let mut engine = hmac::HmacEngine::::new(seed); + engine.input(domain); + if let Some(index) = index { + engine.input(&index.to_be_bytes()); + } + let out = hmac::Hmac::::from_engine(engine).to_byte_array(); + let mut key = [0u8; 32]; + key.copy_from_slice(&out[..32]); + key +} + +/// The Ed25519 secret the wallet uses as its identity towards swap providers. It is not the +/// user's Pubky identity, so providers cannot link swaps to the user's profile. +pub(crate) fn identity_secret( + mnemonic: &str, + passphrase: Option<&str>, +) -> Result<[u8; 32], PubkySwapError> { + Ok(derive(&seed(mnemonic, passphrase)?, IDENTITY_DOMAIN, None)) +} + +pub(crate) fn swap_keys( + mnemonic: &str, + passphrase: Option<&str>, + index: u64, +) -> Result { + let seed = seed(mnemonic, passphrase)?; + let claim_key = SecretKey::from_slice(&derive(&seed, CLAIM_DOMAIN, Some(index))) + .map_err(|e| PubkySwapError::invalid(format!("claim key: {e}")))?; + let claim_pubkey = bitcoin::PublicKey::new(claim_key.public_key(&Secp256k1::new())); + let preimage = derive(&seed, PREIMAGE_DOMAIN, Some(index)); + let payment_hash = sha256::Hash::hash(&preimage).to_byte_array(); + Ok(SwapKeys { + claim_key, + claim_pubkey, + preimage, + payment_hash, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + #[test] + fn keys_are_deterministic_and_distinct_per_index() { + let a = swap_keys(MNEMONIC, None, 0).unwrap(); + let again = swap_keys(MNEMONIC, None, 0).unwrap(); + let b = swap_keys(MNEMONIC, None, 1).unwrap(); + assert_eq!(a.claim_key, again.claim_key); + assert_eq!(a.preimage, again.preimage); + assert_ne!(a.claim_key, b.claim_key); + assert_ne!(a.preimage, b.preimage); + assert_ne!(a.preimage[..], a.claim_key.secret_bytes()[..]); + } + + #[test] + fn passphrase_changes_every_key() { + let plain = swap_keys(MNEMONIC, None, 0).unwrap(); + let with = swap_keys(MNEMONIC, Some("x"), 0).unwrap(); + assert_ne!(plain.claim_key, with.claim_key); + assert_ne!( + identity_secret(MNEMONIC, None).unwrap(), + identity_secret(MNEMONIC, Some("x")).unwrap() + ); + } +} diff --git a/src/modules/pubky_swaps/mod.rs b/src/modules/pubky_swaps/mod.rs new file mode 100644 index 0000000..d95646f --- /dev/null +++ b/src/modules/pubky_swaps/mod.rs @@ -0,0 +1,16 @@ +//! Pubky Swaps as Paykit routes: when no rail the wallet holds reaches an endpoint a Paykit +//! contact published, route the payment through a Pubky Swaps provider (`swap/1`). +//! See README.md. + +mod client; +mod errors; +mod keys; +mod router; +mod store; +mod transport; +mod types; + +pub use client::{advance_swap, commit_route, provider_edges, route_paykit_payment}; +pub use errors::PubkySwapError; +pub(crate) use store::init; +pub use types::*; diff --git a/src/modules/pubky_swaps/router.rs b/src/modules/pubky_swaps/router.rs new file mode 100644 index 0000000..5af58eb --- /dev/null +++ b/src/modules/pubky_swaps/router.rs @@ -0,0 +1,182 @@ +//! Wallet-local route selection for a Paykit payment (Paykit Capabilities §9.1, one `swap/1` +//! leg at most). +//! +//! A contact the wallet can pay from its own balance is paid directly. Otherwise the router +//! turns each trusted provider's offer into `swap/1` edges, keeps the edges that connect a rail +//! the wallet holds to an endpoint the contact published, pre-ranks them by indicative fee, +//! asks the best few for a firm quote, and picks the one that debits the wallet least for the +//! exact amount the contact must receive. + +use swap_common::messages::SwapOffer; +use swap_common::SwapDirection; + +use super::types::{PaykitEndpoint, SwapRouteEdge}; + +pub(crate) const LIGHTNING_BOLT11: &str = "btc-lightning-bolt11"; +pub(crate) const LIGHTNING_LNURL: &str = "btc-lightning-lnurl"; +/// Providers asked for a firm quote per payment (§9.1 `fanout_budget`). +pub(crate) const FANOUT_BUDGET: usize = 3; + +/// On-chain address types in the order Bitkit prefers to pay them. +const ONCHAIN_PREFERENCE: [&str; 4] = ["p2tr", "p2wpkh", "p2sh", "p2pkh"]; + +pub(crate) fn network_label(network: bitcoin::Network) -> &'static str { + match network { + bitcoin::Network::Bitcoin => "bitcoin", + bitcoin::Network::Regtest => "regtest", + bitcoin::Network::Signet => "signet", + _ => "testnet", + } +} + +pub(crate) fn is_lightning(method_id: &str) -> bool { + method_id == LIGHTNING_BOLT11 || method_id == LIGHTNING_LNURL +} + +/// The contact's on-chain endpoint for `network`, preferring the cheapest address type. +pub(crate) fn onchain_endpoint( + endpoints: &[PaykitEndpoint], + network: bitcoin::Network, +) -> Option { + let prefix = format!("btc-{}-", network_label(network)); + ONCHAIN_PREFERENCE.iter().find_map(|kind| { + endpoints + .iter() + .find(|e| e.method_id == format!("{prefix}{kind}")) + .cloned() + }) +} + +/// The `swap/1` edges an offer advertises (Paykit Capabilities §13.1). The fee, minimum and +/// `fund_within_secs` are offer-wide, so both directions carry the same numbers. +pub(crate) fn edges_from_offer(offer: &SwapOffer) -> Vec { + let onchain = format!( + "btc-{}-*", + network_label(offer.network.to_bitcoin_network()) + ); + offer + .directions + .iter() + .map(|direction| { + let (dir, from, to) = match direction { + SwapDirection::Reverse => { + ("reverse", LIGHTNING_BOLT11.to_string(), onchain.clone()) + } + SwapDirection::Submarine => { + ("submarine", onchain.clone(), LIGHTNING_BOLT11.to_string()) + } + }; + SwapRouteEdge { + provider_pubky: offer.provider_pkarr.clone(), + offer_id: offer.offer_id.to_string(), + direction: dir.to_string(), + from_endpoint: from, + to_endpoint: to, + min_sat: offer.effective_min_amount_sat(), + max_sat: offer.max_amount_sat, + base_fee_sat: offer.base_fee_sat, + fee_ppm: offer.fee_ppm, + onchain_fee_sat: offer.onchain_fee_sat, + atomicity: "hash".to_string(), + fund_within_secs: offer.fund_within_secs, + } + }) + .collect() +} + +/// Indicative fee of an edge for `amount_sat`: base + ppm + on-chain (§6.2). +pub(crate) fn indicative_fee(edge: &SwapRouteEdge, amount_sat: u64) -> u64 { + let proportional = (u128::from(amount_sat) * u128::from(edge.fee_ppm) / 1_000_000) as u64; + edge.base_fee_sat + .saturating_add(proportional) + .saturating_add(edge.onchain_fee_sat) +} + +/// Reverse edges that can lock `lock_sat` and that the wallet can afford over Lightning, +/// cheapest first, at most [`FANOUT_BUDGET`]. +pub(crate) fn rank_reverse_edges( + edges: Vec, + lock_sat: u64, + lightning_spendable_sat: u64, +) -> Vec { + let mut usable: Vec<_> = edges + .into_iter() + .filter(|e| e.direction == "reverse") + .filter(|e| lock_sat >= e.min_sat && lock_sat <= e.max_sat) + .filter(|e| lock_sat.saturating_add(indicative_fee(e, lock_sat)) <= lightning_spendable_sat) + .collect(); + usable.sort_by_key(|e| indicative_fee(e, lock_sat)); + usable.truncate(FANOUT_BUDGET); + usable +} + +#[cfg(test)] +mod tests { + use super::*; + + fn edge(provider: &str, direction: &str, base: u64, min: u64, max: u64) -> SwapRouteEdge { + SwapRouteEdge { + provider_pubky: provider.into(), + offer_id: "o".into(), + direction: direction.into(), + from_endpoint: LIGHTNING_BOLT11.into(), + to_endpoint: "btc-regtest-*".into(), + min_sat: min, + max_sat: max, + base_fee_sat: base, + fee_ppm: 2500, + onchain_fee_sat: 500, + atomicity: "hash".into(), + fund_within_secs: 300, + } + } + + fn endpoint(method_id: &str) -> PaykitEndpoint { + PaykitEndpoint { + method_id: method_id.into(), + value: "v".into(), + } + } + + #[test] + fn picks_preferred_onchain_endpoint_for_the_network() { + let endpoints = vec![ + endpoint("btc-regtest-p2pkh"), + endpoint("btc-bitcoin-p2tr"), + endpoint("btc-regtest-p2wpkh"), + ]; + let picked = onchain_endpoint(&endpoints, bitcoin::Network::Regtest).unwrap(); + assert_eq!(picked.method_id, "btc-regtest-p2wpkh"); + assert!( + onchain_endpoint(&[endpoint(LIGHTNING_BOLT11)], bitcoin::Network::Regtest).is_none() + ); + } + + #[test] + fn ranks_reverse_edges_by_fee_within_bounds_and_budget() { + let edges = vec![ + edge("expensive", "reverse", 900, 10_000, 1_000_000), + edge("cheap", "reverse", 100, 10_000, 1_000_000), + edge("submarine", "submarine", 0, 10_000, 1_000_000), + edge("too-small", "reverse", 0, 10_000, 15_000), + edge("min-too-high", "reverse", 0, 50_000, 1_000_000), + ]; + let ranked = rank_reverse_edges(edges, 20_000, 1_000_000); + let names: Vec<_> = ranked.iter().map(|e| e.provider_pubky.as_str()).collect(); + assert_eq!(names, ["cheap", "expensive"]); + } + + #[test] + fn drops_edges_the_wallet_cannot_afford() { + let edges = vec![edge("p", "reverse", 100, 10_000, 1_000_000)]; + // 20,000 locked + 100 + 50 + 500 fee = 20,650. + assert!(rank_reverse_edges(edges.clone(), 20_000, 20_649).is_empty()); + assert_eq!(rank_reverse_edges(edges, 20_000, 20_650).len(), 1); + } + + #[test] + fn indicative_fee_matches_the_offer_formula() { + let e = edge("p", "reverse", 100, 0, u64::MAX); + assert_eq!(indicative_fee(&e, 100_000), 100 + 250 + 500); + } +} diff --git a/src/modules/pubky_swaps/store.rs b/src/modules/pubky_swaps/store.rs new file mode 100644 index 0000000..6efaec4 --- /dev/null +++ b/src/modules/pubky_swaps/store.rs @@ -0,0 +1,83 @@ +//! One JSON file per swap under `/pubky_swaps/`, plus the next key index. +//! +//! A record holds only public data and the key index; the keys come from the seed. Every +//! record is written before the step it protects: the index before the commit is sent, the +//! contract before the invoice is handed to the app. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use once_cell::sync::OnceCell; +use serde::{Deserialize, Serialize}; +use swap_common::messages::{Quote, SwapAccept}; + +use super::errors::PubkySwapError; + +static BASE: OnceCell = OnceCell::new(); +static LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct SwapRecord { + pub swap_id: String, + pub key_index: u64, + pub provider_pubky: String, + pub network: bitcoin::Network, + pub electrum_url: String, + pub quote: Quote, + pub accept: SwapAccept, + pub destination_address: String, + pub delivered_sat: u64, + pub claim_fee_sat: u64, + pub claim_txid: Option, + pub created_at_unix: u64, +} + +pub(crate) fn init(base_path: &str) -> Result<(), PubkySwapError> { + let dir = Path::new(base_path).join("pubky_swaps"); + fs::create_dir_all(&dir)?; + let _ = BASE.set(dir); + Ok(()) +} + +fn dir() -> Result<&'static PathBuf, PubkySwapError> { + BASE.get() + .ok_or_else(|| PubkySwapError::storage("Database not initialized. Call init_db first.")) +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), PubkySwapError> { + let tmp = path.with_extension("tmp"); + fs::write(&tmp, bytes)?; + fs::rename(tmp, path)?; + Ok(()) +} + +/// Reserve the next key index, persisted before it is used. +pub(crate) fn reserve_index() -> Result { + let _guard = LOCK.lock().unwrap(); + let path = dir()?.join("next_index"); + let index = match fs::read_to_string(&path) { + Ok(s) => s.trim().parse().map_err(PubkySwapError::storage)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0, + Err(e) => return Err(e.into()), + }; + write_atomic(&path, (index + 1).to_string().as_bytes())?; + Ok(index) +} + +pub(crate) fn save(record: &SwapRecord) -> Result<(), PubkySwapError> { + let _guard = LOCK.lock().unwrap(); + let path = dir()?.join(format!("{}.json", record.swap_id)); + write_atomic(&path, &serde_json::to_vec_pretty(record)?) +} + +pub(crate) fn load(swap_id: &str) -> Result { + let path = dir()?.join(format!("{swap_id}.json")); + match fs::read(&path) { + Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(PubkySwapError::NotFound { + error_details: swap_id.to_string(), + }), + Err(e) => Err(e.into()), + } +} diff --git a/src/modules/pubky_swaps/transport.rs b/src/modules/pubky_swaps/transport.rs new file mode 100644 index 0000000..c8e6d40 --- /dev/null +++ b/src/modules/pubky_swaps/transport.rs @@ -0,0 +1,90 @@ +//! Requests to a Pubky Swaps provider over iroh `pubky-swap/direct/1`. +//! +//! The provider authenticates the request by the connection's key, so this needs no +//! homeserver on either side. Each request opens a stream, writes `{"message": …}`, and reads +//! the provider's reply, which is one `SwapMessage`. + +use std::time::Duration; + +use iroh::endpoint::presets; +use iroh::{Endpoint, PublicKey as EndpointId, SecretKey}; +use serde_json::json; +use swap_common::messages::SwapMessage; + +use super::errors::PubkySwapError; + +const DIRECT_ALPN: &[u8] = b"pubky-swap/direct/1"; +const MAX_REPLY_BYTES: usize = 64 * 1024; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + +pub(crate) struct DirectClient { + endpoint: Endpoint, +} + +impl DirectClient { + /// Bind an endpoint whose id is the wallet's swap identity. + pub(crate) async fn bind(identity_secret: [u8; 32]) -> Result { + let endpoint = Endpoint::builder(presets::N0) + .secret_key(SecretKey::from_bytes(&identity_secret)) + .bind() + .await + .map_err(PubkySwapError::transport)?; + Ok(Self { endpoint }) + } + + /// Send one request to `provider_pubky` and return its reply. + pub(crate) async fn request( + &self, + provider_pubky: &str, + message: &SwapMessage, + ) -> Result { + let provider = provider_endpoint_id(provider_pubky)?; + let body = serde_json::to_vec(&json!({ "message": message }))?; + let conn = tokio::time::timeout( + CONNECT_TIMEOUT, + self.endpoint.connect(provider, DIRECT_ALPN), + ) + .await + .map_err(|_| PubkySwapError::transport("timed out connecting to the provider"))? + .map_err(PubkySwapError::transport)?; + let reply = tokio::time::timeout(REQUEST_TIMEOUT, async { + let (mut send, mut recv) = conn.open_bi().await.map_err(PubkySwapError::transport)?; + send.write_all(&body) + .await + .map_err(PubkySwapError::transport)?; + send.finish().map_err(PubkySwapError::transport)?; + recv.read_to_end(MAX_REPLY_BYTES) + .await + .map_err(PubkySwapError::transport) + }) + .await + .map_err(|_| PubkySwapError::transport("timed out waiting for the provider"))??; + conn.close(0u32.into(), b"done"); + serde_json::from_slice(&reply) + .map_err(|e| PubkySwapError::transport(format!("unreadable reply: {e}"))) + } + + pub(crate) async fn close(self) { + self.endpoint.close().await; + } +} + +/// A pubky and an iroh endpoint id are the same Ed25519 key. +fn provider_endpoint_id(provider_pubky: &str) -> Result { + let bare = bare_pubky(provider_pubky); + let key = pubky::PublicKey::try_from_z32(bare) + .map_err(|e| PubkySwapError::invalid(format!("provider pubky: {e}")))?; + EndpointId::from_bytes(&key.to_bytes()) + .map_err(|e| PubkySwapError::invalid(format!("provider pubky: {e}"))) +} + +/// The 52-character z-base-32 key, with any `pubky://` or `pubky` display prefix removed. +pub(crate) fn bare_pubky(pubky: &str) -> &str { + let pubky = pubky.trim(); + let pubky = pubky.strip_prefix("pubky://").unwrap_or(pubky); + match pubky.strip_prefix("pubky") { + Some(rest) if rest.len() == 52 => rest, + _ => pubky, + } +} diff --git a/src/modules/pubky_swaps/types.rs b/src/modules/pubky_swaps/types.rs new file mode 100644 index 0000000..650785e --- /dev/null +++ b/src/modules/pubky_swaps/types.rs @@ -0,0 +1,120 @@ +use serde::{Deserialize, Serialize}; + +/// A `swap/1` edge from a provider's offer, in Paykit Capabilities terms: the payment endpoint +/// the owner pays from, the endpoint the route delivers to, its bounds and its indicative fee. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct SwapRouteEdge { + pub provider_pubky: String, + pub offer_id: String, + /// `reverse` (Lightning to on-chain) or `submarine` (on-chain to Lightning). + pub direction: String, + /// Payment Endpoint Identifier the owner pays with, such as `btc-lightning-bolt11`. + pub from_endpoint: String, + /// Payment Endpoint Identifier family the route delivers to, such as `btc-regtest-*`. + pub to_endpoint: String, + /// The minimum the provider enforces, not its configured floor. + pub min_sat: u64, + pub max_sat: u64, + pub base_fee_sat: u64, + pub fee_ppm: u64, + pub onchain_fee_sat: u64, + /// Atomicity class of the edge. Pubky Swaps legs are always `hash`. + pub atomicity: String, + pub fund_within_secs: u64, +} + +/// What the wallet knows when it pays a Paykit contact: the amount, the endpoints the contact +/// published and what the wallet can spend on each rail. +#[derive(Debug, Clone, uniffi::Record)] +pub struct PaykitRouteRequest { + pub amount_sat: u64, + /// Payment Endpoint Identifiers paired with their values, as Paykit resolved them. + pub contact_endpoints: Vec, + pub lightning_spendable_sat: u64, + pub onchain_spendable_sat: u64, + /// Swap providers the wallet trusts, as bare z-base-32 pubkys. + pub providers: Vec, + /// Fee rate for the wallet's own claim transaction. + pub fee_rate_sat_vb: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct PaykitEndpoint { + pub method_id: String, + pub value: String, +} + +/// The route the router picked. A direct route means no swap is needed. +#[derive(Debug, Clone, uniffi::Enum)] +pub enum PaykitRoute { + /// The wallet can pay this endpoint from its own balance. + Direct { endpoint: PaykitEndpoint }, + /// The wallet pays over Lightning and a Pubky Swaps provider delivers on-chain. + Swap { quote: PubkySwapRouteQuote }, + /// Nothing can pay this contact. `reason` says why, for the user. + None { reason: String }, +} + +/// A firm `swap/1` quote for one route, with everything the review screen shows. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct PubkySwapRouteQuote { + pub provider_pubky: String, + pub quote_id: String, + /// The contact endpoint the swap delivers to. + pub endpoint: PaykitEndpoint, + /// What the contact receives. + pub delivered_sat: u64, + /// What the provider locks on chain: the delivered amount plus the wallet's claim fee. + pub lock_sat: u64, + /// What the wallet pays over Lightning. + pub debit_sat: u64, + pub service_fee_sat: u64, + pub provider_onchain_fee_sat: u64, + pub claim_fee_sat: u64, + /// `debit_sat - delivered_sat`. + pub total_fee_sat: u64, + pub atomicity: String, + pub valid_until_unix: u64, + /// How many providers were asked for a quote. + pub providers_quoted: u32, +} + +/// A committed swap: the hold invoice the wallet pays to start it. +#[derive(Debug, Clone, uniffi::Record)] +pub struct PubkySwapRoutePayment { + pub swap_id: String, + pub invoice: String, + pub debit_sat: u64, + pub delivered_sat: u64, + pub destination_address: String, + pub htlc_address: String, + pub timeout_block_height: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum PubkySwapRouteState { + /// Waiting for the hold invoice to be paid. + AwaitingPayment, + /// Paid; waiting for the provider's on-chain lockup, or for its confirmations. + AwaitingLockup { + confirmations: u32, + required: u32, + }, + /// The claim to the contact's address is broadcast. The provider settles the invoice + /// once it sees the preimage. + Claimed { + txid: String, + }, + Failed { + reason: String, + }, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct PubkySwapRouteStatus { + pub swap_id: String, + pub state: PubkySwapRouteState, + pub provider_pubky: String, + pub delivered_sat: u64, + pub destination_address: String, +} From f1afbafad1e140e5c3bcf7b8c40a18d3070e04dc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 22:05:38 +0100 Subject: [PATCH 02/23] docs: describe the pubky swaps route module --- AGENTS.md | 4 +-- src/modules/pubky_swaps/README.md | 55 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 src/modules/pubky_swaps/README.md diff --git a/AGENTS.md b/AGENTS.md index 743a64e..b5d1e09 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 (scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky, pubky_swaps, 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: scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky, pubky_swaps, 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/src/modules/pubky_swaps/README.md b/src/modules/pubky_swaps/README.md new file mode 100644 index 0000000..aa47b55 --- /dev/null +++ b/src/modules/pubky_swaps/README.md @@ -0,0 +1,55 @@ +# Pubky Swaps Module + +Routes a Paykit payment through a [Pubky Swaps](https://github.com/coreyphillips/pubky-swap) +provider when no balance the wallet holds reaches an endpoint the contact published. The user +never picks the swap: the router does, and the review screen shows the route. + +The first route is a reverse swap: the wallet pays over Lightning, the provider locks bitcoin on +chain, and this module claims it straight to the contact's on-chain address. The contact receives +exactly the amount asked for (`exact_out`): the lock covers the wallet's own claim fee. + +## Flow + +| Step | Paykit Capabilities | pubky-swap message | Owner | +|------|---------------------|--------------------|-------| +| Pick a route | router (§9.1) | `OfferRequest` → `Offer` | `pubky_swaps_route_paykit_payment` | +| Ask for a price | `rfq` → `quote` | `QuoteRequest` → `Quote` | same call, up to 3 providers | +| Commit | `commit` → `contract` | `SwapRequest` → `SwapAccept` | `pubky_swaps_commit_route` | +| Fund | funding is the acceptance | hold invoice | **App** pays the invoice from spending | +| Claim | evidence from the rail | claim with the preimage | `pubky_swaps_advance` | + +`pubky_swaps_route_paykit_payment` returns `Direct` when a balance reaches one of the contact's +endpoints, so the app only swaps when it must. + +Before the invoice reaches the app, the module checks the quote against the request +(`validate_quote`), the contract against the quote and the chain tip (`validate_accept`), rebuilds +the HTLC from its own keys (`verify_contract`), and binds the hold invoice to the quote and the +payment hash (`validate_hold_invoice`). These checks are pubky-swap's own `swap-common`. + +## Transport + +Requests travel over iroh `pubky-swap/direct/1`. The provider authenticates the request by the +connection's key, so neither side needs a homeserver. Paykit Capabilities §13.1 allows the same +payloads over this carrier. + +## Keys and storage + +- The wallet's identity towards providers, its claim keys and its preimages derive from the seed + with HMAC-SHA512 under their own domain strings. They never collide with the Boltz module's keys, + and the identity is not the user's Pubky identity. +- `/pubky_swaps/.json` holds the contract and the key index, never a secret. The + index is reserved on disk before the commit is sent. + +## What it does not do yet + +- **Discovery:** providers come from the app's configuration, not from published Capability + Descriptors (§6, §7). +- **Paykit transport:** no `paykit-cap.*` messages over Encrypted Links. paykit-rs has no API to + send extension kinds yet (§17). +- **Signed terms:** pubky-swap signs nothing at `main`, so quotes and contracts are not + attributable to the provider yet. +- **Submarine routes** (savings to a Lightning-only contact) and USDT. Pubky Swaps is Bitcoin and + Lightning only. +- **Refunds and recovery:** a reverse swap needs no wallet refund (the provider cancels its hold + invoice), but there is no scan to rebuild swaps from the seed alone, and swap records are not in + the wallet backup. From 32bdb79bfdad40a1284675148794f10e7cd55c58 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 22:14:53 +0100 Subject: [PATCH 03/23] feat: find swap providers among contacts --- src/modules/pubky_swaps/README.md | 4 ++-- src/modules/pubky_swaps/client.rs | 34 ++++++++++++++++++++++++---- src/modules/pubky_swaps/transport.rs | 1 + src/modules/pubky_swaps/types.rs | 3 ++- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/modules/pubky_swaps/README.md b/src/modules/pubky_swaps/README.md index aa47b55..17433ab 100644 --- a/src/modules/pubky_swaps/README.md +++ b/src/modules/pubky_swaps/README.md @@ -42,8 +42,8 @@ payloads over this carrier. ## What it does not do yet -- **Discovery:** providers come from the app's configuration, not from published Capability - Descriptors (§6, §7). +- **Discovery:** candidates come from the caller, usually the user's contacts. Each is asked for + its live offer over iroh; nothing reads published Capability Descriptors yet (§6, §7). - **Paykit transport:** no `paykit-cap.*` messages over Encrypted Links. paykit-rs has no API to send extension kinds yet (§17). - **Signed terms:** pubky-swap signs nothing at `main`, so quotes and contracts are not diff --git a/src/modules/pubky_swaps/client.rs b/src/modules/pubky_swaps/client.rs index 914c5e3..106113a 100644 --- a/src/modules/pubky_swaps/client.rs +++ b/src/modules/pubky_swaps/client.rs @@ -33,6 +33,9 @@ use super::store::{self, SwapRecord}; use super::transport::{bare_pubky, DirectClient}; use super::types::*; +/// How long a candidate provider has to answer an offer request. +const OFFER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8); + /// Quotes the router fetched, kept until the wallet commits to one of them. static QUOTES: Lazy>> = Lazy::new(Default::default); @@ -196,14 +199,35 @@ pub async fn route_paykit_payment( let mut offers = HashMap::new(); let mut edges = vec![]; - for provider in &request.providers { - match fetch_offer(&client, bare_pubky(provider)).await { - Ok(offer) if offer.network.to_bitcoin_network() == network => { + // Candidates are usually the user's contacts, and most of them run no provider: ask them all at + // once and give each a short window to answer. + let mut probes = tokio::task::JoinSet::new(); + let mut candidates: Vec = request + .providers + .iter() + .map(|p| bare_pubky(p).to_string()) + .collect(); + candidates.sort(); + candidates.dedup(); + for provider in candidates { + let client = client.clone(); + probes.spawn(async move { + let offer = tokio::time::timeout(OFFER_TIMEOUT, fetch_offer(&client, &provider)).await; + (provider, offer) + }); + } + while let Some(probe) = probes.join_next().await { + let Ok((provider, offer)) = probe else { + continue; + }; + match offer { + Ok(Ok(offer)) if offer.network.to_bitcoin_network() == network => { edges.extend(router::edges_from_offer(&offer)); offers.insert(offer.provider_pkarr.clone(), offer); } - Ok(_) => log::warn!("pubky swaps: provider {provider} is on another network"), - Err(e) => log::warn!("pubky swaps: no offer from {provider}: {e}"), + Ok(Ok(_)) => log::warn!("pubky swaps: provider {provider} is on another network"), + Ok(Err(e)) => log::debug!("pubky swaps: no offer from {provider}: {e}"), + Err(_) => log::debug!("pubky swaps: {provider} did not answer in time"), } } let ranked = router::rank_reverse_edges(edges, lock_sat, request.lightning_spendable_sat); diff --git a/src/modules/pubky_swaps/transport.rs b/src/modules/pubky_swaps/transport.rs index c8e6d40..7440d82 100644 --- a/src/modules/pubky_swaps/transport.rs +++ b/src/modules/pubky_swaps/transport.rs @@ -18,6 +18,7 @@ const MAX_REPLY_BYTES: usize = 64 * 1024; const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +#[derive(Clone)] pub(crate) struct DirectClient { endpoint: Endpoint, } diff --git a/src/modules/pubky_swaps/types.rs b/src/modules/pubky_swaps/types.rs index 650785e..b3d2c05 100644 --- a/src/modules/pubky_swaps/types.rs +++ b/src/modules/pubky_swaps/types.rs @@ -32,7 +32,8 @@ pub struct PaykitRouteRequest { pub contact_endpoints: Vec, pub lightning_spendable_sat: u64, pub onchain_spendable_sat: u64, - /// Swap providers the wallet trusts, as bare z-base-32 pubkys. + /// Candidate swap providers as pubkys, usually the user's contacts. Each is asked for its + /// offer; those that run no provider are skipped. pub providers: Vec, /// Fee rate for the wallet's own claim transaction. pub fee_rate_sat_vb: u64, From 3fd9e9dfa41b9348614e08469cf10ae82e234569 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 22:35:14 +0100 Subject: [PATCH 04/23] fix: wait for a home relay before asking providers --- src/modules/pubky_swaps/client.rs | 8 +++++--- src/modules/pubky_swaps/transport.rs | 8 ++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/modules/pubky_swaps/client.rs b/src/modules/pubky_swaps/client.rs index 106113a..e31adad 100644 --- a/src/modules/pubky_swaps/client.rs +++ b/src/modules/pubky_swaps/client.rs @@ -34,7 +34,7 @@ use super::transport::{bare_pubky, DirectClient}; use super::types::*; /// How long a candidate provider has to answer an offer request. -const OFFER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8); +const OFFER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); /// Quotes the router fetched, kept until the wallet commits to one of them. static QUOTES: Lazy>> = Lazy::new(Default::default); @@ -534,7 +534,7 @@ pub async fn advance_swap( #[cfg(test)] mod live_tests { //! Against a running regtest provider: - //! `PUBKY_SWAPS_PROVIDER= PUBKY_SWAPS_ELECTRUM=tcp://127.0.0.1:60001 + //! `PUBKY_SWAPS_PROVIDER=[,…] PUBKY_SWAPS_ELECTRUM=tcp://127.0.0.1:60001 //! PUBKY_SWAPS_DESTINATION= cargo test --lib pubky_swaps::client::live_tests -- --ignored` use super::*; @@ -550,6 +550,8 @@ mod live_tests { let dir = std::env::temp_dir().join(format!("pubky-swaps-{}", Uuid::new_v4())); store::init(dir.to_str().unwrap()).unwrap(); + let mut providers: Vec = provider.split(',').map(str::to_string).collect(); + let provider = providers[0].clone(); let edges = provider_edges(provider.clone(), MNEMONIC.into(), None) .await .unwrap(); @@ -563,7 +565,7 @@ mod live_tests { }], lightning_spendable_sat: 500_000, onchain_spendable_sat: 0, - providers: vec![provider], + providers: std::mem::take(&mut providers), fee_rate_sat_vb: 2, }; let route = route_paykit_payment(request, bitcoin::Network::Regtest, MNEMONIC.into(), None) diff --git a/src/modules/pubky_swaps/transport.rs b/src/modules/pubky_swaps/transport.rs index 7440d82..40063ec 100644 --- a/src/modules/pubky_swaps/transport.rs +++ b/src/modules/pubky_swaps/transport.rs @@ -17,6 +17,7 @@ const DIRECT_ALPN: &[u8] = b"pubky-swap/direct/1"; const MAX_REPLY_BYTES: usize = 64 * 1024; const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +const ONLINE_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Clone)] pub(crate) struct DirectClient { @@ -31,6 +32,13 @@ impl DirectClient { .bind() .await .map_err(PubkySwapError::transport)?; + // Reaching a provider needs a home relay; without one the first connects all time out. + if tokio::time::timeout(ONLINE_TIMEOUT, endpoint.online()) + .await + .is_err() + { + log::warn!("pubky swaps: no home relay after {ONLINE_TIMEOUT:?}"); + } Ok(Self { endpoint }) } From 5e262b92422b1ea801a260d813b7a5d9a26ef8bd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 23:19:16 +0100 Subject: [PATCH 05/23] chore: regenerate ios bindings for pubky swaps routes --- bindings/ios/bitkitcore.swift | 1462 +++++++++++++++++++++++++++++++-- bindings/ios/bitkitcoreFFI.h | 44 + 2 files changed, 1418 insertions(+), 88 deletions(-) diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index aae3011..0c02c11 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -11559,6 +11559,206 @@ public func FfiConverterTypePassportAccountExport_lower(_ value: PassportAccount } +public struct PaykitEndpoint { + public var methodId: String + public var value: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(methodId: String, value: String) { + self.methodId = methodId + self.value = value + } +} + +#if compiler(>=6) +extension PaykitEndpoint: Sendable {} +#endif + + +extension PaykitEndpoint: Equatable, Hashable { + public static func ==(lhs: PaykitEndpoint, rhs: PaykitEndpoint) -> Bool { + if lhs.methodId != rhs.methodId { + return false + } + if lhs.value != rhs.value { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(methodId) + hasher.combine(value) + } +} + +extension PaykitEndpoint: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePaykitEndpoint: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PaykitEndpoint { + return + try PaykitEndpoint( + methodId: FfiConverterString.read(from: &buf), + value: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: PaykitEndpoint, into buf: inout [UInt8]) { + FfiConverterString.write(value.methodId, into: &buf) + FfiConverterString.write(value.value, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePaykitEndpoint_lift(_ buf: RustBuffer) throws -> PaykitEndpoint { + return try FfiConverterTypePaykitEndpoint.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePaykitEndpoint_lower(_ value: PaykitEndpoint) -> RustBuffer { + return FfiConverterTypePaykitEndpoint.lower(value) +} + + +/** + * What the wallet knows when it pays a Paykit contact: the amount, the endpoints the contact + * published and what the wallet can spend on each rail. + */ +public struct PaykitRouteRequest { + public var amountSat: UInt64 + /** + * Payment Endpoint Identifiers paired with their values, as Paykit resolved them. + */ + public var contactEndpoints: [PaykitEndpoint] + public var lightningSpendableSat: UInt64 + public var onchainSpendableSat: UInt64 + /** + * Candidate swap providers as pubkys, usually the user's contacts. Each is asked for its + * offer; those that run no provider are skipped. + */ + public var providers: [String] + /** + * Fee rate for the wallet's own claim transaction. + */ + public var feeRateSatVb: UInt64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(amountSat: UInt64, + /** + * Payment Endpoint Identifiers paired with their values, as Paykit resolved them. + */contactEndpoints: [PaykitEndpoint], lightningSpendableSat: UInt64, onchainSpendableSat: UInt64, + /** + * Candidate swap providers as pubkys, usually the user's contacts. Each is asked for its + * offer; those that run no provider are skipped. + */providers: [String], + /** + * Fee rate for the wallet's own claim transaction. + */feeRateSatVb: UInt64) { + self.amountSat = amountSat + self.contactEndpoints = contactEndpoints + self.lightningSpendableSat = lightningSpendableSat + self.onchainSpendableSat = onchainSpendableSat + self.providers = providers + self.feeRateSatVb = feeRateSatVb + } +} + +#if compiler(>=6) +extension PaykitRouteRequest: Sendable {} +#endif + + +extension PaykitRouteRequest: Equatable, Hashable { + public static func ==(lhs: PaykitRouteRequest, rhs: PaykitRouteRequest) -> Bool { + if lhs.amountSat != rhs.amountSat { + return false + } + if lhs.contactEndpoints != rhs.contactEndpoints { + return false + } + if lhs.lightningSpendableSat != rhs.lightningSpendableSat { + return false + } + if lhs.onchainSpendableSat != rhs.onchainSpendableSat { + return false + } + if lhs.providers != rhs.providers { + return false + } + if lhs.feeRateSatVb != rhs.feeRateSatVb { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(amountSat) + hasher.combine(contactEndpoints) + hasher.combine(lightningSpendableSat) + hasher.combine(onchainSpendableSat) + hasher.combine(providers) + hasher.combine(feeRateSatVb) + } +} + +extension PaykitRouteRequest: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePaykitRouteRequest: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PaykitRouteRequest { + return + try PaykitRouteRequest( + amountSat: FfiConverterUInt64.read(from: &buf), + contactEndpoints: FfiConverterSequenceTypePaykitEndpoint.read(from: &buf), + lightningSpendableSat: FfiConverterUInt64.read(from: &buf), + onchainSpendableSat: FfiConverterUInt64.read(from: &buf), + providers: FfiConverterSequenceString.read(from: &buf), + feeRateSatVb: FfiConverterUInt64.read(from: &buf) + ) + } + + public static func write(_ value: PaykitRouteRequest, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.amountSat, into: &buf) + FfiConverterSequenceTypePaykitEndpoint.write(value.contactEndpoints, into: &buf) + FfiConverterUInt64.write(value.lightningSpendableSat, into: &buf) + FfiConverterUInt64.write(value.onchainSpendableSat, into: &buf) + FfiConverterSequenceString.write(value.providers, into: &buf) + FfiConverterUInt64.write(value.feeRateSatVb, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePaykitRouteRequest_lift(_ buf: RustBuffer) throws -> PaykitRouteRequest { + return try FfiConverterTypePaykitRouteRequest.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePaykitRouteRequest_lower(_ value: PaykitRouteRequest) -> RustBuffer { + return FfiConverterTypePaykitRouteRequest.lower(value) +} + + public struct PreActivityMetadata { public var walletId: String public var paymentId: String @@ -12064,6 +12264,416 @@ public func FfiConverterTypePubkyProfileLink_lower(_ value: PubkyProfileLink) -> } +/** + * A committed swap: the hold invoice the wallet pays to start it. + */ +public struct PubkySwapRoutePayment { + public var swapId: String + public var invoice: String + public var debitSat: UInt64 + public var deliveredSat: UInt64 + public var destinationAddress: String + public var htlcAddress: String + public var timeoutBlockHeight: UInt32 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(swapId: String, invoice: String, debitSat: UInt64, deliveredSat: UInt64, destinationAddress: String, htlcAddress: String, timeoutBlockHeight: UInt32) { + self.swapId = swapId + self.invoice = invoice + self.debitSat = debitSat + self.deliveredSat = deliveredSat + self.destinationAddress = destinationAddress + self.htlcAddress = htlcAddress + self.timeoutBlockHeight = timeoutBlockHeight + } +} + +#if compiler(>=6) +extension PubkySwapRoutePayment: Sendable {} +#endif + + +extension PubkySwapRoutePayment: Equatable, Hashable { + public static func ==(lhs: PubkySwapRoutePayment, rhs: PubkySwapRoutePayment) -> Bool { + if lhs.swapId != rhs.swapId { + return false + } + if lhs.invoice != rhs.invoice { + return false + } + if lhs.debitSat != rhs.debitSat { + return false + } + if lhs.deliveredSat != rhs.deliveredSat { + return false + } + if lhs.destinationAddress != rhs.destinationAddress { + return false + } + if lhs.htlcAddress != rhs.htlcAddress { + return false + } + if lhs.timeoutBlockHeight != rhs.timeoutBlockHeight { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(swapId) + hasher.combine(invoice) + hasher.combine(debitSat) + hasher.combine(deliveredSat) + hasher.combine(destinationAddress) + hasher.combine(htlcAddress) + hasher.combine(timeoutBlockHeight) + } +} + +extension PubkySwapRoutePayment: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePubkySwapRoutePayment: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PubkySwapRoutePayment { + return + try PubkySwapRoutePayment( + swapId: FfiConverterString.read(from: &buf), + invoice: FfiConverterString.read(from: &buf), + debitSat: FfiConverterUInt64.read(from: &buf), + deliveredSat: FfiConverterUInt64.read(from: &buf), + destinationAddress: FfiConverterString.read(from: &buf), + htlcAddress: FfiConverterString.read(from: &buf), + timeoutBlockHeight: FfiConverterUInt32.read(from: &buf) + ) + } + + public static func write(_ value: PubkySwapRoutePayment, into buf: inout [UInt8]) { + FfiConverterString.write(value.swapId, into: &buf) + FfiConverterString.write(value.invoice, into: &buf) + FfiConverterUInt64.write(value.debitSat, into: &buf) + FfiConverterUInt64.write(value.deliveredSat, into: &buf) + FfiConverterString.write(value.destinationAddress, into: &buf) + FfiConverterString.write(value.htlcAddress, into: &buf) + FfiConverterUInt32.write(value.timeoutBlockHeight, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePubkySwapRoutePayment_lift(_ buf: RustBuffer) throws -> PubkySwapRoutePayment { + return try FfiConverterTypePubkySwapRoutePayment.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePubkySwapRoutePayment_lower(_ value: PubkySwapRoutePayment) -> RustBuffer { + return FfiConverterTypePubkySwapRoutePayment.lower(value) +} + + +/** + * A firm `swap/1` quote for one route, with everything the review screen shows. + */ +public struct PubkySwapRouteQuote { + public var providerPubky: String + public var quoteId: String + /** + * The contact endpoint the swap delivers to. + */ + public var endpoint: PaykitEndpoint + /** + * What the contact receives. + */ + public var deliveredSat: UInt64 + /** + * What the provider locks on chain: the delivered amount plus the wallet's claim fee. + */ + public var lockSat: UInt64 + /** + * What the wallet pays over Lightning. + */ + public var debitSat: UInt64 + public var serviceFeeSat: UInt64 + public var providerOnchainFeeSat: UInt64 + public var claimFeeSat: UInt64 + /** + * `debit_sat - delivered_sat`. + */ + public var totalFeeSat: UInt64 + public var atomicity: String + public var validUntilUnix: UInt64 + /** + * How many providers were asked for a quote. + */ + public var providersQuoted: UInt32 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(providerPubky: String, quoteId: String, + /** + * The contact endpoint the swap delivers to. + */endpoint: PaykitEndpoint, + /** + * What the contact receives. + */deliveredSat: UInt64, + /** + * What the provider locks on chain: the delivered amount plus the wallet's claim fee. + */lockSat: UInt64, + /** + * What the wallet pays over Lightning. + */debitSat: UInt64, serviceFeeSat: UInt64, providerOnchainFeeSat: UInt64, claimFeeSat: UInt64, + /** + * `debit_sat - delivered_sat`. + */totalFeeSat: UInt64, atomicity: String, validUntilUnix: UInt64, + /** + * How many providers were asked for a quote. + */providersQuoted: UInt32) { + self.providerPubky = providerPubky + self.quoteId = quoteId + self.endpoint = endpoint + self.deliveredSat = deliveredSat + self.lockSat = lockSat + self.debitSat = debitSat + self.serviceFeeSat = serviceFeeSat + self.providerOnchainFeeSat = providerOnchainFeeSat + self.claimFeeSat = claimFeeSat + self.totalFeeSat = totalFeeSat + self.atomicity = atomicity + self.validUntilUnix = validUntilUnix + self.providersQuoted = providersQuoted + } +} + +#if compiler(>=6) +extension PubkySwapRouteQuote: Sendable {} +#endif + + +extension PubkySwapRouteQuote: Equatable, Hashable { + public static func ==(lhs: PubkySwapRouteQuote, rhs: PubkySwapRouteQuote) -> Bool { + if lhs.providerPubky != rhs.providerPubky { + return false + } + if lhs.quoteId != rhs.quoteId { + return false + } + if lhs.endpoint != rhs.endpoint { + return false + } + if lhs.deliveredSat != rhs.deliveredSat { + return false + } + if lhs.lockSat != rhs.lockSat { + return false + } + if lhs.debitSat != rhs.debitSat { + return false + } + if lhs.serviceFeeSat != rhs.serviceFeeSat { + return false + } + if lhs.providerOnchainFeeSat != rhs.providerOnchainFeeSat { + return false + } + if lhs.claimFeeSat != rhs.claimFeeSat { + return false + } + if lhs.totalFeeSat != rhs.totalFeeSat { + return false + } + if lhs.atomicity != rhs.atomicity { + return false + } + if lhs.validUntilUnix != rhs.validUntilUnix { + return false + } + if lhs.providersQuoted != rhs.providersQuoted { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(providerPubky) + hasher.combine(quoteId) + hasher.combine(endpoint) + hasher.combine(deliveredSat) + hasher.combine(lockSat) + hasher.combine(debitSat) + hasher.combine(serviceFeeSat) + hasher.combine(providerOnchainFeeSat) + hasher.combine(claimFeeSat) + hasher.combine(totalFeeSat) + hasher.combine(atomicity) + hasher.combine(validUntilUnix) + hasher.combine(providersQuoted) + } +} + +extension PubkySwapRouteQuote: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePubkySwapRouteQuote: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PubkySwapRouteQuote { + return + try PubkySwapRouteQuote( + providerPubky: FfiConverterString.read(from: &buf), + quoteId: FfiConverterString.read(from: &buf), + endpoint: FfiConverterTypePaykitEndpoint.read(from: &buf), + deliveredSat: FfiConverterUInt64.read(from: &buf), + lockSat: FfiConverterUInt64.read(from: &buf), + debitSat: FfiConverterUInt64.read(from: &buf), + serviceFeeSat: FfiConverterUInt64.read(from: &buf), + providerOnchainFeeSat: FfiConverterUInt64.read(from: &buf), + claimFeeSat: FfiConverterUInt64.read(from: &buf), + totalFeeSat: FfiConverterUInt64.read(from: &buf), + atomicity: FfiConverterString.read(from: &buf), + validUntilUnix: FfiConverterUInt64.read(from: &buf), + providersQuoted: FfiConverterUInt32.read(from: &buf) + ) + } + + public static func write(_ value: PubkySwapRouteQuote, into buf: inout [UInt8]) { + FfiConverterString.write(value.providerPubky, into: &buf) + FfiConverterString.write(value.quoteId, into: &buf) + FfiConverterTypePaykitEndpoint.write(value.endpoint, into: &buf) + FfiConverterUInt64.write(value.deliveredSat, into: &buf) + FfiConverterUInt64.write(value.lockSat, into: &buf) + FfiConverterUInt64.write(value.debitSat, into: &buf) + FfiConverterUInt64.write(value.serviceFeeSat, into: &buf) + FfiConverterUInt64.write(value.providerOnchainFeeSat, into: &buf) + FfiConverterUInt64.write(value.claimFeeSat, into: &buf) + FfiConverterUInt64.write(value.totalFeeSat, into: &buf) + FfiConverterString.write(value.atomicity, into: &buf) + FfiConverterUInt64.write(value.validUntilUnix, into: &buf) + FfiConverterUInt32.write(value.providersQuoted, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePubkySwapRouteQuote_lift(_ buf: RustBuffer) throws -> PubkySwapRouteQuote { + return try FfiConverterTypePubkySwapRouteQuote.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePubkySwapRouteQuote_lower(_ value: PubkySwapRouteQuote) -> RustBuffer { + return FfiConverterTypePubkySwapRouteQuote.lower(value) +} + + +public struct PubkySwapRouteStatus { + public var swapId: String + public var state: PubkySwapRouteState + public var providerPubky: String + public var deliveredSat: UInt64 + public var destinationAddress: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(swapId: String, state: PubkySwapRouteState, providerPubky: String, deliveredSat: UInt64, destinationAddress: String) { + self.swapId = swapId + self.state = state + self.providerPubky = providerPubky + self.deliveredSat = deliveredSat + self.destinationAddress = destinationAddress + } +} + +#if compiler(>=6) +extension PubkySwapRouteStatus: Sendable {} +#endif + + +extension PubkySwapRouteStatus: Equatable, Hashable { + public static func ==(lhs: PubkySwapRouteStatus, rhs: PubkySwapRouteStatus) -> Bool { + if lhs.swapId != rhs.swapId { + return false + } + if lhs.state != rhs.state { + return false + } + if lhs.providerPubky != rhs.providerPubky { + return false + } + if lhs.deliveredSat != rhs.deliveredSat { + return false + } + if lhs.destinationAddress != rhs.destinationAddress { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(swapId) + hasher.combine(state) + hasher.combine(providerPubky) + hasher.combine(deliveredSat) + hasher.combine(destinationAddress) + } +} + +extension PubkySwapRouteStatus: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePubkySwapRouteStatus: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PubkySwapRouteStatus { + return + try PubkySwapRouteStatus( + swapId: FfiConverterString.read(from: &buf), + state: FfiConverterTypePubkySwapRouteState.read(from: &buf), + providerPubky: FfiConverterString.read(from: &buf), + deliveredSat: FfiConverterUInt64.read(from: &buf), + destinationAddress: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: PubkySwapRouteStatus, into buf: inout [UInt8]) { + FfiConverterString.write(value.swapId, into: &buf) + FfiConverterTypePubkySwapRouteState.write(value.state, into: &buf) + FfiConverterString.write(value.providerPubky, into: &buf) + FfiConverterUInt64.write(value.deliveredSat, into: &buf) + FfiConverterString.write(value.destinationAddress, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePubkySwapRouteStatus_lift(_ buf: RustBuffer) throws -> PubkySwapRouteStatus { + return try FfiConverterTypePubkySwapRouteStatus.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePubkySwapRouteStatus_lower(_ value: PubkySwapRouteStatus) -> RustBuffer { + return FfiConverterTypePubkySwapRouteStatus.lower(value) +} + + /** * Result of creating a reverse swap (Lightning -> onchain). * @@ -12514,55 +13124,241 @@ extension SupportedHardwareWallet: Equatable, Hashable { if lhs.vendor != rhs.vendor { return false } - if lhs.vendorName != rhs.vendorName { + if lhs.vendorName != rhs.vendorName { + return false + } + if lhs.model != rhs.model { + return false + } + if lhs.displayName != rhs.displayName { + return false + } + if lhs.transports != rhs.transports { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(vendor) + hasher.combine(vendorName) + hasher.combine(model) + hasher.combine(displayName) + hasher.combine(transports) + } +} + +extension SupportedHardwareWallet: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSupportedHardwareWallet: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SupportedHardwareWallet { + return + try SupportedHardwareWallet( + vendor: FfiConverterTypeHardwareWalletVendor.read(from: &buf), + vendorName: FfiConverterString.read(from: &buf), + model: FfiConverterString.read(from: &buf), + displayName: FfiConverterString.read(from: &buf), + transports: FfiConverterSequenceTypeHardwareWalletTransport.read(from: &buf) + ) + } + + public static func write(_ value: SupportedHardwareWallet, into buf: inout [UInt8]) { + FfiConverterTypeHardwareWalletVendor.write(value.vendor, into: &buf) + FfiConverterString.write(value.vendorName, into: &buf) + FfiConverterString.write(value.model, into: &buf) + FfiConverterString.write(value.displayName, into: &buf) + FfiConverterSequenceTypeHardwareWalletTransport.write(value.transports, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSupportedHardwareWallet_lift(_ buf: RustBuffer) throws -> SupportedHardwareWallet { + return try FfiConverterTypeSupportedHardwareWallet.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSupportedHardwareWallet_lower(_ value: SupportedHardwareWallet) -> RustBuffer { + return FfiConverterTypeSupportedHardwareWallet.lower(value) +} + + +/** + * A `swap/1` edge from a provider's offer, in Paykit Capabilities terms: the payment endpoint + * the owner pays from, the endpoint the route delivers to, its bounds and its indicative fee. + */ +public struct SwapRouteEdge { + public var providerPubky: String + public var offerId: String + /** + * `reverse` (Lightning to on-chain) or `submarine` (on-chain to Lightning). + */ + public var direction: String + /** + * Payment Endpoint Identifier the owner pays with, such as `btc-lightning-bolt11`. + */ + public var fromEndpoint: String + /** + * Payment Endpoint Identifier family the route delivers to, such as `btc-regtest-*`. + */ + public var toEndpoint: String + /** + * The minimum the provider enforces, not its configured floor. + */ + public var minSat: UInt64 + public var maxSat: UInt64 + public var baseFeeSat: UInt64 + public var feePpm: UInt64 + public var onchainFeeSat: UInt64 + /** + * Atomicity class of the edge. Pubky Swaps legs are always `hash`. + */ + public var atomicity: String + public var fundWithinSecs: UInt64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(providerPubky: String, offerId: String, + /** + * `reverse` (Lightning to on-chain) or `submarine` (on-chain to Lightning). + */direction: String, + /** + * Payment Endpoint Identifier the owner pays with, such as `btc-lightning-bolt11`. + */fromEndpoint: String, + /** + * Payment Endpoint Identifier family the route delivers to, such as `btc-regtest-*`. + */toEndpoint: String, + /** + * The minimum the provider enforces, not its configured floor. + */minSat: UInt64, maxSat: UInt64, baseFeeSat: UInt64, feePpm: UInt64, onchainFeeSat: UInt64, + /** + * Atomicity class of the edge. Pubky Swaps legs are always `hash`. + */atomicity: String, fundWithinSecs: UInt64) { + self.providerPubky = providerPubky + self.offerId = offerId + self.direction = direction + self.fromEndpoint = fromEndpoint + self.toEndpoint = toEndpoint + self.minSat = minSat + self.maxSat = maxSat + self.baseFeeSat = baseFeeSat + self.feePpm = feePpm + self.onchainFeeSat = onchainFeeSat + self.atomicity = atomicity + self.fundWithinSecs = fundWithinSecs + } +} + +#if compiler(>=6) +extension SwapRouteEdge: Sendable {} +#endif + + +extension SwapRouteEdge: Equatable, Hashable { + public static func ==(lhs: SwapRouteEdge, rhs: SwapRouteEdge) -> Bool { + if lhs.providerPubky != rhs.providerPubky { + return false + } + if lhs.offerId != rhs.offerId { + return false + } + if lhs.direction != rhs.direction { + return false + } + if lhs.fromEndpoint != rhs.fromEndpoint { + return false + } + if lhs.toEndpoint != rhs.toEndpoint { + return false + } + if lhs.minSat != rhs.minSat { return false } - if lhs.model != rhs.model { + if lhs.maxSat != rhs.maxSat { return false } - if lhs.displayName != rhs.displayName { + if lhs.baseFeeSat != rhs.baseFeeSat { return false } - if lhs.transports != rhs.transports { + if lhs.feePpm != rhs.feePpm { + return false + } + if lhs.onchainFeeSat != rhs.onchainFeeSat { + return false + } + if lhs.atomicity != rhs.atomicity { + return false + } + if lhs.fundWithinSecs != rhs.fundWithinSecs { return false } return true } public func hash(into hasher: inout Hasher) { - hasher.combine(vendor) - hasher.combine(vendorName) - hasher.combine(model) - hasher.combine(displayName) - hasher.combine(transports) + hasher.combine(providerPubky) + hasher.combine(offerId) + hasher.combine(direction) + hasher.combine(fromEndpoint) + hasher.combine(toEndpoint) + hasher.combine(minSat) + hasher.combine(maxSat) + hasher.combine(baseFeeSat) + hasher.combine(feePpm) + hasher.combine(onchainFeeSat) + hasher.combine(atomicity) + hasher.combine(fundWithinSecs) } } -extension SupportedHardwareWallet: Codable {} +extension SwapRouteEdge: Codable {} #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSupportedHardwareWallet: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SupportedHardwareWallet { +public struct FfiConverterTypeSwapRouteEdge: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwapRouteEdge { return - try SupportedHardwareWallet( - vendor: FfiConverterTypeHardwareWalletVendor.read(from: &buf), - vendorName: FfiConverterString.read(from: &buf), - model: FfiConverterString.read(from: &buf), - displayName: FfiConverterString.read(from: &buf), - transports: FfiConverterSequenceTypeHardwareWalletTransport.read(from: &buf) + try SwapRouteEdge( + providerPubky: FfiConverterString.read(from: &buf), + offerId: FfiConverterString.read(from: &buf), + direction: FfiConverterString.read(from: &buf), + fromEndpoint: FfiConverterString.read(from: &buf), + toEndpoint: FfiConverterString.read(from: &buf), + minSat: FfiConverterUInt64.read(from: &buf), + maxSat: FfiConverterUInt64.read(from: &buf), + baseFeeSat: FfiConverterUInt64.read(from: &buf), + feePpm: FfiConverterUInt64.read(from: &buf), + onchainFeeSat: FfiConverterUInt64.read(from: &buf), + atomicity: FfiConverterString.read(from: &buf), + fundWithinSecs: FfiConverterUInt64.read(from: &buf) ) } - public static func write(_ value: SupportedHardwareWallet, into buf: inout [UInt8]) { - FfiConverterTypeHardwareWalletVendor.write(value.vendor, into: &buf) - FfiConverterString.write(value.vendorName, into: &buf) - FfiConverterString.write(value.model, into: &buf) - FfiConverterString.write(value.displayName, into: &buf) - FfiConverterSequenceTypeHardwareWalletTransport.write(value.transports, into: &buf) + public static func write(_ value: SwapRouteEdge, into buf: inout [UInt8]) { + FfiConverterString.write(value.providerPubky, into: &buf) + FfiConverterString.write(value.offerId, into: &buf) + FfiConverterString.write(value.direction, into: &buf) + FfiConverterString.write(value.fromEndpoint, into: &buf) + FfiConverterString.write(value.toEndpoint, into: &buf) + FfiConverterUInt64.write(value.minSat, into: &buf) + FfiConverterUInt64.write(value.maxSat, into: &buf) + FfiConverterUInt64.write(value.baseFeeSat, into: &buf) + FfiConverterUInt64.write(value.feePpm, into: &buf) + FfiConverterUInt64.write(value.onchainFeeSat, into: &buf) + FfiConverterString.write(value.atomicity, into: &buf) + FfiConverterUInt64.write(value.fundWithinSecs, into: &buf) } } @@ -12570,15 +13366,15 @@ public struct FfiConverterTypeSupportedHardwareWallet: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSupportedHardwareWallet_lift(_ buf: RustBuffer) throws -> SupportedHardwareWallet { - return try FfiConverterTypeSupportedHardwareWallet.lift(buf) +public func FfiConverterTypeSwapRouteEdge_lift(_ buf: RustBuffer) throws -> SwapRouteEdge { + return try FfiConverterTypeSwapRouteEdge.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSupportedHardwareWallet_lower(_ value: SupportedHardwareWallet) -> RustBuffer { - return FfiConverterTypeSupportedHardwareWallet.lower(value) +public func FfiConverterTypeSwapRouteEdge_lower(_ value: SwapRouteEdge) -> RustBuffer { + return FfiConverterTypeSwapRouteEdge.lower(value) } @@ -23255,6 +24051,106 @@ extension PassphraseResponse: Codable {} +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. +/** + * The route the router picked. A direct route means no swap is needed. + */ + +public enum PaykitRoute { + + /** + * The wallet can pay this endpoint from its own balance. + */ + case direct(endpoint: PaykitEndpoint + ) + /** + * The wallet pays over Lightning and a Pubky Swaps provider delivers on-chain. + */ + case swap(quote: PubkySwapRouteQuote + ) + /** + * Nothing can pay this contact. `reason` says why, for the user. + */ + case none(reason: String + ) +} + + +#if compiler(>=6) +extension PaykitRoute: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePaykitRoute: FfiConverterRustBuffer { + typealias SwiftType = PaykitRoute + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PaykitRoute { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .direct(endpoint: try FfiConverterTypePaykitEndpoint.read(from: &buf) + ) + + case 2: return .swap(quote: try FfiConverterTypePubkySwapRouteQuote.read(from: &buf) + ) + + case 3: return .none(reason: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: PaykitRoute, into buf: inout [UInt8]) { + switch value { + + + case let .direct(endpoint): + writeInt(&buf, Int32(1)) + FfiConverterTypePaykitEndpoint.write(endpoint, into: &buf) + + + case let .swap(quote): + writeInt(&buf, Int32(2)) + FfiConverterTypePubkySwapRouteQuote.write(quote, into: &buf) + + + case let .none(reason): + writeInt(&buf, Int32(3)) + FfiConverterString.write(reason, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePaykitRoute_lift(_ buf: RustBuffer) throws -> PaykitRoute { + return try FfiConverterTypePaykitRoute.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePaykitRoute_lower(_ value: PaykitRoute) -> RustBuffer { + return FfiConverterTypePaykitRoute.lower(value) +} + + +extension PaykitRoute: Equatable, Hashable {} + +extension PaykitRoute: Codable {} + + + + + + // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. @@ -23600,25 +24496,182 @@ extension PubkyAuthKind: Codable {} -public enum PubkyError: Swift.Error { +public enum PubkyError: Swift.Error { + + + + case InvalidCapabilities(reason: String + ) + case AuthFailed(reason: String + ) + case NoActiveFlow + case ResolutionFailed(reason: String + ) + case FetchFailed(reason: String + ) + case ProfileNotFound + case ProfileParseFailed(reason: String + ) + case KeyError(reason: String + ) + case WriteFailed(reason: String + ) +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePubkyError: FfiConverterRustBuffer { + typealias SwiftType = PubkyError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PubkyError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .InvalidCapabilities( + reason: try FfiConverterString.read(from: &buf) + ) + case 2: return .AuthFailed( + reason: try FfiConverterString.read(from: &buf) + ) + case 3: return .NoActiveFlow + case 4: return .ResolutionFailed( + reason: try FfiConverterString.read(from: &buf) + ) + case 5: return .FetchFailed( + reason: try FfiConverterString.read(from: &buf) + ) + case 6: return .ProfileNotFound + case 7: return .ProfileParseFailed( + reason: try FfiConverterString.read(from: &buf) + ) + case 8: return .KeyError( + reason: try FfiConverterString.read(from: &buf) + ) + case 9: return .WriteFailed( + reason: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: PubkyError, into buf: inout [UInt8]) { + switch value { + + + + + + case let .InvalidCapabilities(reason): + writeInt(&buf, Int32(1)) + FfiConverterString.write(reason, into: &buf) + + + case let .AuthFailed(reason): + writeInt(&buf, Int32(2)) + FfiConverterString.write(reason, into: &buf) + + + case .NoActiveFlow: + writeInt(&buf, Int32(3)) + + + case let .ResolutionFailed(reason): + writeInt(&buf, Int32(4)) + FfiConverterString.write(reason, into: &buf) + + + case let .FetchFailed(reason): + writeInt(&buf, Int32(5)) + FfiConverterString.write(reason, into: &buf) + + + case .ProfileNotFound: + writeInt(&buf, Int32(6)) + + + case let .ProfileParseFailed(reason): + writeInt(&buf, Int32(7)) + FfiConverterString.write(reason, into: &buf) + + + case let .KeyError(reason): + writeInt(&buf, Int32(8)) + FfiConverterString.write(reason, into: &buf) + + + case let .WriteFailed(reason): + writeInt(&buf, Int32(9)) + FfiConverterString.write(reason, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePubkyError_lift(_ buf: RustBuffer) throws -> PubkyError { + return try FfiConverterTypePubkyError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePubkyError_lower(_ value: PubkyError) -> RustBuffer { + return FfiConverterTypePubkyError.lower(value) +} + + +extension PubkyError: Equatable, Hashable {} + +extension PubkyError: Codable {} + + + + +extension PubkyError: Foundation.LocalizedError { + public var errorDescription: String? { + String(reflecting: self) + } +} + + + + + +/** + * Errors surfaced by the Pubky Swaps route module. + */ +public enum PubkySwapError: Swift.Error { - case InvalidCapabilities(reason: String + case InvalidInput(errorDetails: String ) - case AuthFailed(reason: String + case Transport(errorDetails: String ) - case NoActiveFlow - case ResolutionFailed(reason: String + /** + * The provider refused the request with one of its closed refusal codes. + */ + case Refused(code: String, reason: String ) - case FetchFailed(reason: String + /** + * The provider's quote, contract or invoice did not match what was asked for. + */ + case Validation(errorDetails: String ) - case ProfileNotFound - case ProfileParseFailed(reason: String + case Chain(errorDetails: String ) - case KeyError(reason: String + case Storage(errorDetails: String ) - case WriteFailed(reason: String + case NotFound(errorDetails: String ) } @@ -23626,92 +24679,84 @@ public enum PubkyError: Swift.Error { #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypePubkyError: FfiConverterRustBuffer { - typealias SwiftType = PubkyError +public struct FfiConverterTypePubkySwapError: FfiConverterRustBuffer { + typealias SwiftType = PubkySwapError - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PubkyError { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PubkySwapError { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .InvalidCapabilities( - reason: try FfiConverterString.read(from: &buf) + case 1: return .InvalidInput( + errorDetails: try FfiConverterString.read(from: &buf) ) - case 2: return .AuthFailed( - reason: try FfiConverterString.read(from: &buf) + case 2: return .Transport( + errorDetails: try FfiConverterString.read(from: &buf) ) - case 3: return .NoActiveFlow - case 4: return .ResolutionFailed( + case 3: return .Refused( + code: try FfiConverterString.read(from: &buf), reason: try FfiConverterString.read(from: &buf) ) - case 5: return .FetchFailed( - reason: try FfiConverterString.read(from: &buf) + case 4: return .Validation( + errorDetails: try FfiConverterString.read(from: &buf) ) - case 6: return .ProfileNotFound - case 7: return .ProfileParseFailed( - reason: try FfiConverterString.read(from: &buf) + case 5: return .Chain( + errorDetails: try FfiConverterString.read(from: &buf) ) - case 8: return .KeyError( - reason: try FfiConverterString.read(from: &buf) + case 6: return .Storage( + errorDetails: try FfiConverterString.read(from: &buf) ) - case 9: return .WriteFailed( - reason: try FfiConverterString.read(from: &buf) + case 7: return .NotFound( + errorDetails: try FfiConverterString.read(from: &buf) ) default: throw UniffiInternalError.unexpectedEnumCase } } - public static func write(_ value: PubkyError, into buf: inout [UInt8]) { + public static func write(_ value: PubkySwapError, into buf: inout [UInt8]) { switch value { - case let .InvalidCapabilities(reason): + case let .InvalidInput(errorDetails): writeInt(&buf, Int32(1)) - FfiConverterString.write(reason, into: &buf) + FfiConverterString.write(errorDetails, into: &buf) - case let .AuthFailed(reason): + case let .Transport(errorDetails): writeInt(&buf, Int32(2)) - FfiConverterString.write(reason, into: &buf) + FfiConverterString.write(errorDetails, into: &buf) - case .NoActiveFlow: + case let .Refused(code,reason): writeInt(&buf, Int32(3)) + FfiConverterString.write(code, into: &buf) + FfiConverterString.write(reason, into: &buf) + - - case let .ResolutionFailed(reason): + case let .Validation(errorDetails): writeInt(&buf, Int32(4)) - FfiConverterString.write(reason, into: &buf) + FfiConverterString.write(errorDetails, into: &buf) - case let .FetchFailed(reason): + case let .Chain(errorDetails): writeInt(&buf, Int32(5)) - FfiConverterString.write(reason, into: &buf) + FfiConverterString.write(errorDetails, into: &buf) - case .ProfileNotFound: + case let .Storage(errorDetails): writeInt(&buf, Int32(6)) - - - case let .ProfileParseFailed(reason): - writeInt(&buf, Int32(7)) - FfiConverterString.write(reason, into: &buf) - - - case let .KeyError(reason): - writeInt(&buf, Int32(8)) - FfiConverterString.write(reason, into: &buf) + FfiConverterString.write(errorDetails, into: &buf) - case let .WriteFailed(reason): - writeInt(&buf, Int32(9)) - FfiConverterString.write(reason, into: &buf) + case let .NotFound(errorDetails): + writeInt(&buf, Int32(7)) + FfiConverterString.write(errorDetails, into: &buf) } } @@ -23721,26 +24766,26 @@ public struct FfiConverterTypePubkyError: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypePubkyError_lift(_ buf: RustBuffer) throws -> PubkyError { - return try FfiConverterTypePubkyError.lift(buf) +public func FfiConverterTypePubkySwapError_lift(_ buf: RustBuffer) throws -> PubkySwapError { + return try FfiConverterTypePubkySwapError.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypePubkyError_lower(_ value: PubkyError) -> RustBuffer { - return FfiConverterTypePubkyError.lower(value) +public func FfiConverterTypePubkySwapError_lower(_ value: PubkySwapError) -> RustBuffer { + return FfiConverterTypePubkySwapError.lower(value) } -extension PubkyError: Equatable, Hashable {} +extension PubkySwapError: Equatable, Hashable {} -extension PubkyError: Codable {} +extension PubkySwapError: Codable {} -extension PubkyError: Foundation.LocalizedError { +extension PubkySwapError: Foundation.LocalizedError { public var errorDescription: String? { String(reflecting: self) } @@ -23749,6 +24794,112 @@ extension PubkyError: 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 PubkySwapRouteState { + + /** + * Waiting for the hold invoice to be paid. + */ + case awaitingPayment + /** + * Paid; waiting for the provider's on-chain lockup, or for its confirmations. + */ + case awaitingLockup(confirmations: UInt32, required: UInt32 + ) + /** + * The claim to the contact's address is broadcast. The provider settles the invoice + * once it sees the preimage. + */ + case claimed(txid: String + ) + case failed(reason: String + ) +} + + +#if compiler(>=6) +extension PubkySwapRouteState: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePubkySwapRouteState: FfiConverterRustBuffer { + typealias SwiftType = PubkySwapRouteState + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PubkySwapRouteState { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .awaitingPayment + + case 2: return .awaitingLockup(confirmations: try FfiConverterUInt32.read(from: &buf), required: try FfiConverterUInt32.read(from: &buf) + ) + + case 3: return .claimed(txid: try FfiConverterString.read(from: &buf) + ) + + case 4: return .failed(reason: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: PubkySwapRouteState, into buf: inout [UInt8]) { + switch value { + + + case .awaitingPayment: + writeInt(&buf, Int32(1)) + + + case let .awaitingLockup(confirmations,required): + writeInt(&buf, Int32(2)) + FfiConverterUInt32.write(confirmations, into: &buf) + FfiConverterUInt32.write(required, into: &buf) + + + case let .claimed(txid): + writeInt(&buf, Int32(3)) + FfiConverterString.write(txid, into: &buf) + + + case let .failed(reason): + writeInt(&buf, Int32(4)) + FfiConverterString.write(reason, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePubkySwapRouteState_lift(_ buf: RustBuffer) throws -> PubkySwapRouteState { + return try FfiConverterTypePubkySwapRouteState.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePubkySwapRouteState_lower(_ value: PubkySwapRouteState) -> RustBuffer { + return FfiConverterTypePubkySwapRouteState.lower(value) +} + + +extension PubkySwapRouteState: Equatable, Hashable {} + +extension PubkySwapRouteState: Codable {} + + + + + + // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. @@ -28107,6 +29258,31 @@ fileprivate struct FfiConverterSequenceTypePassportAccount: FfiConverterRustBuff } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypePaykitEndpoint: FfiConverterRustBuffer { + typealias SwiftType = [PaykitEndpoint] + + public static func write(_ value: [PaykitEndpoint], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypePaykitEndpoint.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [PaykitEndpoint] { + let len: Int32 = try readInt(&buf) + var seq = [PaykitEndpoint]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypePaykitEndpoint.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -28182,6 +29358,31 @@ fileprivate struct FfiConverterSequenceTypeSupportedHardwareWallet: FfiConverter } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeSwapRouteEdge: FfiConverterRustBuffer { + typealias SwiftType = [SwapRouteEdge] + + public static func write(_ value: [SwapRouteEdge], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeSwapRouteEdge.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [SwapRouteEdge] { + let len: Int32 = try readInt(&buf) + var seq = [SwapRouteEdge]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeSwapRouteEdge.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -30672,6 +31873,79 @@ public func pubkySignUp(secretKeyHex: String, homeserverPublicKeyZ32: String, si errorHandler: FfiConverterTypePubkyError_lift ) } +/** + * Advance a committed route: once the provider's lockup has its confirmations, claim it to the + * contact's address. Call after paying the hold invoice, until it reports `Claimed`. + */ +public func pubkySwapsAdvance(swapId: String, mnemonic: String, bip39Passphrase: String?)async throws -> PubkySwapRouteStatus { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_pubky_swaps_advance(FfiConverterString.lower(swapId),FfiConverterString.lower(mnemonic),FfiConverterOptionString.lower(bip39Passphrase) + ) + }, + 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: FfiConverterTypePubkySwapRouteStatus_lift, + errorHandler: FfiConverterTypePubkySwapError_lift + ) +} +/** + * Commit to a routed quote and get the hold invoice to pay. The provider's contract and invoice + * are checked against the quote and the wallet's own keys before they are returned. + */ +public func pubkySwapsCommitRoute(quoteId: String, electrumUrl: String, mnemonic: String, bip39Passphrase: String?)async throws -> PubkySwapRoutePayment { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_pubky_swaps_commit_route(FfiConverterString.lower(quoteId),FfiConverterString.lower(electrumUrl),FfiConverterString.lower(mnemonic),FfiConverterOptionString.lower(bip39Passphrase) + ) + }, + 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: FfiConverterTypePubkySwapRoutePayment_lift, + errorHandler: FfiConverterTypePubkySwapError_lift + ) +} +/** + * The `swap/1` edges a Pubky Swaps provider advertises right now, read from its live offer + * over iroh. The wallet's swap identity is derived from `mnemonic`. + */ +public func pubkySwapsProviderEdges(providerPubky: String, mnemonic: String, bip39Passphrase: String?)async throws -> [SwapRouteEdge] { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_pubky_swaps_provider_edges(FfiConverterString.lower(providerPubky),FfiConverterString.lower(mnemonic),FfiConverterOptionString.lower(bip39Passphrase) + ) + }, + 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: FfiConverterSequenceTypeSwapRouteEdge.lift, + errorHandler: FfiConverterTypePubkySwapError_lift + ) +} +/** + * Pick how to pay a Paykit contact. Returns `Direct` when a balance the wallet holds reaches + * one of the contact's endpoints, otherwise the cheapest firm quote from the trusted Pubky + * Swaps providers, or `None` with a reason. The user never picks the swap. + */ +public func pubkySwapsRoutePaykitPayment(request: PaykitRouteRequest, network: Network, mnemonic: String, bip39Passphrase: String?)async throws -> PaykitRoute { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_pubky_swaps_route_paykit_payment(FfiConverterTypePaykitRouteRequest_lower(request),FfiConverterTypeNetwork_lower(network),FfiConverterString.lower(mnemonic),FfiConverterOptionString.lower(bip39Passphrase) + ) + }, + 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: FfiConverterTypePaykitRoute_lift, + errorHandler: FfiConverterTypePubkySwapError_lift + ) +} /** * Persist a successful outgoing RBF result, including its replacement fee rate, in one transaction. * Call after the node has returned the replacement txid; this function does not broadcast anything. @@ -31971,6 +33245,18 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_func_pubky_sign_up() != 61692) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_func_pubky_swaps_advance() != 27871) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_pubky_swaps_commit_route() != 4882) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_pubky_swaps_provider_edges() != 4359) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_pubky_swaps_route_paykit_payment() != 7835) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_func_record_rbf_boost() != 15433) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index 388dc3f..4f4cb61 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -1571,6 +1571,26 @@ uint64_t uniffi_bitkitcore_fn_func_pubky_sign_in(RustBuffer secret_key_hex uint64_t uniffi_bitkitcore_fn_func_pubky_sign_up(RustBuffer secret_key_hex, RustBuffer homeserver_public_key_z32, RustBuffer signup_code ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_PUBKY_SWAPS_ADVANCE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_PUBKY_SWAPS_ADVANCE +uint64_t uniffi_bitkitcore_fn_func_pubky_swaps_advance(RustBuffer swap_id, RustBuffer mnemonic, RustBuffer bip39_passphrase +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_PUBKY_SWAPS_COMMIT_ROUTE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_PUBKY_SWAPS_COMMIT_ROUTE +uint64_t uniffi_bitkitcore_fn_func_pubky_swaps_commit_route(RustBuffer quote_id, RustBuffer electrum_url, RustBuffer mnemonic, RustBuffer bip39_passphrase +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_PUBKY_SWAPS_PROVIDER_EDGES +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_PUBKY_SWAPS_PROVIDER_EDGES +uint64_t uniffi_bitkitcore_fn_func_pubky_swaps_provider_edges(RustBuffer provider_pubky, RustBuffer mnemonic, RustBuffer bip39_passphrase +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_PUBKY_SWAPS_ROUTE_PAYKIT_PAYMENT +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_PUBKY_SWAPS_ROUTE_PAYKIT_PAYMENT +uint64_t uniffi_bitkitcore_fn_func_pubky_swaps_route_paykit_payment(RustBuffer request, RustBuffer network, RustBuffer mnemonic, RustBuffer bip39_passphrase +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_RECORD_RBF_BOOST #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_RECORD_RBF_BOOST void uniffi_bitkitcore_fn_func_record_rbf_boost(RustBuffer wallet_id, RustBuffer original_activity_id, RustBuffer replacement_tx_id, uint64_t fee_rate, RustCallStatus *_Nonnull out_status @@ -3062,6 +3082,30 @@ uint16_t uniffi_bitkitcore_checksum_func_pubky_sign_in(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PUBKY_SIGN_UP uint16_t uniffi_bitkitcore_checksum_func_pubky_sign_up(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PUBKY_SWAPS_ADVANCE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PUBKY_SWAPS_ADVANCE +uint16_t uniffi_bitkitcore_checksum_func_pubky_swaps_advance(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PUBKY_SWAPS_COMMIT_ROUTE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PUBKY_SWAPS_COMMIT_ROUTE +uint16_t uniffi_bitkitcore_checksum_func_pubky_swaps_commit_route(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PUBKY_SWAPS_PROVIDER_EDGES +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PUBKY_SWAPS_PROVIDER_EDGES +uint16_t uniffi_bitkitcore_checksum_func_pubky_swaps_provider_edges(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PUBKY_SWAPS_ROUTE_PAYKIT_PAYMENT +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PUBKY_SWAPS_ROUTE_PAYKIT_PAYMENT +uint16_t uniffi_bitkitcore_checksum_func_pubky_swaps_route_paykit_payment(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_RECORD_RBF_BOOST From 3581ab31911d4acf76868a78f18017d3257df88a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 30 Oct 2026 01:48:19 +0100 Subject: [PATCH 06/23] ci: give android bindgen room for the iroh build --- .github/workflows/android-bindgen.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 835fe27d4bf26d17f645270ef45dc97addfffbc7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 30 Oct 2026 02:54:19 +0100 Subject: [PATCH 07/23] chore: prepare 0.8.0-swaps.1 prerelease --- Cargo.lock | 2 +- Cargo.toml | 2 +- Package.swift | 4 ++-- bindings/android/gradle.properties | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3293ce2..4a6a225 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1041,7 +1041,7 @@ dependencies = [ [[package]] name = "bitkitcore" -version = "0.8.0-rc11" +version = "0.8.0-swaps.1" dependencies = [ "alloy-primitives", "alloy-rlp", diff --git a/Cargo.toml b/Cargo.toml index c43cc8d..1bbecb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitkitcore" -version = "0.8.0-rc11" +version = "0.8.0-swaps.1" edition = "2021" rust-version = "1.90" diff --git a/Package.swift b/Package.swift index 6b2ce47..1a0410e 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-swaps.1" +let checksum = "69c085c480bab97cff77dbc4a654762a469d72598107937dcc0c59c73e6c4680" 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..5179394 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-swaps.1 From 60e98cf94b2d6d69c9018e6dccdba118ec2a4ed0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 30 Oct 2026 04:36:21 +0100 Subject: [PATCH 08/23] fix: link network framework on ios iroh reads network interfaces through Network.framework, so apps that link BitkitCore without it fail with undefined nw_* symbols. --- Package.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 1a0410e..d56a144 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription import Foundation -let tag = "v0.8.0-swaps.1" +let tag = "v0.8.0-swaps.2" let checksum = "69c085c480bab97cff77dbc4a654762a469d72598107937dcc0c59c73e6c4680" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" @@ -27,7 +27,9 @@ let package = Package( name: "BitkitCore", dependencies: ["BitkitCoreFFI"], path: "./bindings/ios", - sources: ["bitkitcore.swift"] + sources: ["bitkitcore.swift"], + // iroh reads network interfaces through Network.framework. + linkerSettings: [.linkedFramework("Network")] ), localBinary ? .binaryTarget( From 1abc3ff8bc40848951d19ae74259f004b78ce5be Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 30 Oct 2026 04:36:21 +0100 Subject: [PATCH 09/23] chore: prepare 0.8.0-swaps.2 prerelease --- Cargo.lock | 2 +- Cargo.toml | 2 +- bindings/android/gradle.properties | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a6a225..5d274c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1041,7 +1041,7 @@ dependencies = [ [[package]] name = "bitkitcore" -version = "0.8.0-swaps.1" +version = "0.8.0-swaps.2" dependencies = [ "alloy-primitives", "alloy-rlp", diff --git a/Cargo.toml b/Cargo.toml index 1bbecb1..a6b8876 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitkitcore" -version = "0.8.0-swaps.1" +version = "0.8.0-swaps.2" edition = "2021" rust-version = "1.90" diff --git a/bindings/android/gradle.properties b/bindings/android/gradle.properties index 5179394..6572396 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-swaps.1 +version=0.8.0-swaps.2 From 160d4436c5d4d1e85e2e0d8e2a4ece2c7f7cb30a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 14:59:17 +0100 Subject: [PATCH 10/23] fix: claim swaps only inside the safe window --- src/modules/pubky_swaps/client.rs | 48 ++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/src/modules/pubky_swaps/client.rs b/src/modules/pubky_swaps/client.rs index e31adad..0e1ccd2 100644 --- a/src/modules/pubky_swaps/client.rs +++ b/src/modules/pubky_swaps/client.rs @@ -18,7 +18,7 @@ use swap_common::messages::{ SwapStatusRequest, PROTOCOL_VERSION, }; use swap_common::onchain::{build_claim_tx, estimate_spend_fee, spend_vsize}; -use swap_common::timelock::TimelockParams; +use swap_common::timelock::{claim_start_is_safe, TimelockParams}; use swap_common::validate::{ validate_accept, validate_hold_invoice, validate_quote, verify_contract, ClientPolicy, DecodedHoldInvoice, @@ -35,6 +35,9 @@ use super::types::*; /// How long a candidate provider has to answer an offer request. const OFFER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); +/// How long the wallet keeps asking about a swap request the provider is still admitting. +const PENDING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); +const PENDING_POLL: std::time::Duration = std::time::Duration::from_secs(2); /// Quotes the router fetched, kept until the wallet commits to one of them. static QUOTES: Lazy>> = Lazy::new(Default::default); @@ -284,6 +287,10 @@ pub async fn route_paykit_payment( Ok(PaykitRoute::Swap { quote: route }) } +fn is_pending(reply: &Result) -> bool { + matches!(reply, Ok(SwapMessage::Reject(reject)) if reject.code.as_deref() == Some("pending")) +} + fn decode_hold_invoice(invoice: &str) -> Result { let invoice = lightning_invoice::Bolt11Invoice::from_str(invoice) .map_err(|e| PubkySwapError::validation(format!("hold invoice: {e}")))?; @@ -348,21 +355,21 @@ pub async fn commit_route( }; let client = DirectClient::bind(secret).await?; - let reply = client + let mut reply = client .request(&route.provider_pubky, &SwapMessage::SwapRequest(request)) .await; - let reply = match reply { - // `pending` means the provider is still admitting it: ask again, never a decline. - Ok(SwapMessage::Reject(reject)) if reject.code.as_deref() == Some("pending") => { - let status = SwapMessage::SwapStatusRequest(SwapStatusRequest { - request_id: Some(Uuid::new_v4()), - swap_id: None, - quote_id: Some(quote.quote_id), - }); - client.request(&route.provider_pubky, &status).await - } - other => other, - }; + // `pending` means the provider is still admitting it, never a decline: keep asking until it + // accepts or refuses. Past the deadline the wallet gives up without paying anything. + let deadline = tokio::time::Instant::now() + PENDING_TIMEOUT; + while is_pending(&reply) && tokio::time::Instant::now() < deadline { + tokio::time::sleep(PENDING_POLL).await; + let status = SwapMessage::SwapStatusRequest(SwapStatusRequest { + request_id: Some(Uuid::new_v4()), + swap_id: None, + quote_id: Some(quote.quote_id), + }); + reply = client.request(&route.provider_pubky, &status).await; + } client.close().await; let accept = match reply? { SwapMessage::SwapAccept(accept) => accept, @@ -501,6 +508,19 @@ fn advance_blocking( return Ok(status_of(&record, state)); } + // Claiming reveals the preimage. Too close to the timeout, the provider could settle the hold + // invoice and still refund its lock, so the wallet leaves it to the refund instead. + if !claim_start_is_safe(tip, record.accept.timeout_block_height) { + let state = PubkySwapRouteState::Failed { + reason: format!( + "The lockup times out at block {}, too close to claim safely at block {tip}. \ + The provider's refund returns the payment.", + record.accept.timeout_block_height + ), + }; + return Ok(status_of(&record, state)); + } + let destination = parse_address(&record.destination_address, record.network)?; let claim = build_claim_tx( outpoint, From 7621dfccd3870e8bdcbb14dc6c4d895f6c2ab188 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 16:26:34 +0100 Subject: [PATCH 11/23] fix: never reuse a swap preimage and watch claims until they confirm --- Cargo.toml | 2 +- src/modules/pubky_swaps/README.md | 21 +- src/modules/pubky_swaps/claim.rs | 311 ++++++++++++++++++++++++++++++ src/modules/pubky_swaps/client.rs | 192 ++++++++++-------- src/modules/pubky_swaps/keys.rs | 52 +++-- src/modules/pubky_swaps/mod.rs | 1 + src/modules/pubky_swaps/store.rs | 48 +++-- src/modules/pubky_swaps/types.rs | 10 +- 8 files changed, 506 insertions(+), 131 deletions(-) create mode 100644 src/modules/pubky_swaps/claim.rs diff --git a/Cargo.toml b/Cargo.toml index a6b8876..5441a0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "bitkitcore" version = "0.8.0-swaps.2" edition = "2021" -rust-version = "1.90" +rust-version = "1.91" [lib] crate-type = ["cdylib", "staticlib", "rlib"] diff --git a/src/modules/pubky_swaps/README.md b/src/modules/pubky_swaps/README.md index 17433ab..52f3dd0 100644 --- a/src/modules/pubky_swaps/README.md +++ b/src/modules/pubky_swaps/README.md @@ -34,11 +34,22 @@ payloads over this carrier. ## Keys and storage -- The wallet's identity towards providers, its claim keys and its preimages derive from the seed - with HMAC-SHA512 under their own domain strings. They never collide with the Boltz module's keys, - and the identity is not the user's Pubky identity. -- `/pubky_swaps/.json` holds the contract and the key index, never a secret. The - index is reserved on disk before the commit is sent. +- The wallet's identity towards providers derives from the seed. Each swap's claim key and preimage + derive from the seed and a random nonce, with HMAC-SHA512 under their own domain strings: a + restored wallet or a second device on the same seed never repeats a preimage an earlier claim + published. They never collide with the Boltz module's keys, and the identity is not the user's + Pubky identity. +- `/pubky_swaps/.json` holds the contract, the nonce and the signed claim, never a + secret. It is synced to disk before the invoice reaches the app, and the claim before it is + broadcast. + +## Claim + +`pubky_swaps_advance` claims the lockup once it has its confirmations and while the timeout is more +than 6 blocks away, then reports `ClaimBroadcast` until the claim confirms and `Claimed` after. Each +call checks the HTLC's history: a claim that dropped out of the mempool is broadcast again, a refund +by the provider or a lockup that never came before the timeout ends in `Failed`. The claim's fee is +fixed when the route is quoted and is never bumped, so swap routes are refused on mainnet. ## What it does not do yet diff --git a/src/modules/pubky_swaps/claim.rs b/src/modules/pubky_swaps/claim.rs new file mode 100644 index 0000000..f743d59 --- /dev/null +++ b/src/modules/pubky_swaps/claim.rs @@ -0,0 +1,311 @@ +//! What `pubky_swaps_advance` does next for a reverse swap, decided from the HTLC's script history +//! (mempool included) and the chain tip, so the claim path can be checked without a chain. + +use bitcoin::{OutPoint, ScriptBuf, Transaction}; +use swap_common::timelock::claim_start_is_safe; + +use super::types::PubkySwapRouteState; + +/// The contract as the wallet rebuilt it from its own keys. +pub(crate) struct Contract<'a> { + pub htlc_spk: &'a ScriptBuf, + pub lock_sat: u64, + pub destination: &'a ScriptBuf, + pub timeout: u32, + pub required_confirmations: u32, +} + +#[derive(Debug, PartialEq)] +pub(crate) enum Step { + /// Report this state; nothing to do. + Report(PubkySwapRouteState), + /// Our claim spends the lockup; record `txid` if the record names another, and report. + Claimed { + txid: String, + state: PubkySwapRouteState, + }, + /// The saved claim is not in the history: broadcast it again. Its preimage is already public, + /// so it goes out whatever the window. + Rebroadcast, + /// Build, save and broadcast the claim of this lockup. + Claim(OutPoint), +} + +pub(crate) fn next_step( + contract: &Contract, + saved_claim: Option<&str>, + tip: u32, + history: &[(i32, Transaction)], +) -> Step { + let confirmations_at = |height: i32| { + if height > 0 { + tip.saturating_sub(height as u32) + 1 + } else { + 0 + } + }; + let lockup = history.iter().find_map(|(height, tx)| { + let vout = tx.output.iter().position(|out| { + out.script_pubkey == *contract.htlc_spk && out.value.to_sat() == contract.lock_sat + })?; + Some(( + OutPoint::new(tx.compute_txid(), vout as u32), + confirmations_at(*height), + )) + }); + let Some((outpoint, confirmations)) = lockup else { + return Step::Report(if tip >= contract.timeout { + PubkySwapRouteState::Failed { + reason: "The provider never locked funds before the timeout. Its hold invoice is \ + cancelled and the payment returns." + .into(), + } + } else { + PubkySwapRouteState::AwaitingLockup { + confirmations: 0, + required: contract.required_confirmations, + } + }); + }; + + let spend = history.iter().find(|(_, tx)| { + tx.input + .iter() + .any(|input| input.previous_output == outpoint) + }); + if let Some((height, tx)) = spend { + if !tx + .output + .iter() + .any(|out| out.script_pubkey == *contract.destination) + { + return Step::Report(PubkySwapRouteState::Failed { + reason: "The provider refunded its lockup instead of the claim.".into(), + }); + } + let txid = tx.compute_txid().to_string(); + let state = if confirmations_at(*height) > 0 { + PubkySwapRouteState::Claimed { txid: txid.clone() } + } else { + PubkySwapRouteState::ClaimBroadcast { txid: txid.clone() } + }; + return Step::Claimed { txid, state }; + } + + if saved_claim.is_some() { + return Step::Rebroadcast; + } + if confirmations < contract.required_confirmations { + return Step::Report(PubkySwapRouteState::AwaitingLockup { + confirmations, + required: contract.required_confirmations, + }); + } + // Claiming reveals the preimage. Too close to the timeout, the provider could settle the hold + // invoice and still refund its lock, so the wallet leaves it to the refund instead. + if !claim_start_is_safe(tip, contract.timeout) { + return Step::Report(PubkySwapRouteState::Failed { + reason: format!( + "The lockup times out at block {}, too close to claim safely at block {tip}. \ + The provider's refund returns the payment.", + contract.timeout + ), + }); + } + Step::Claim(outpoint) +} + +#[cfg(test)] +mod tests { + use bitcoin::absolute::LockTime; + use bitcoin::transaction::Version; + use bitcoin::{Amount, Sequence, TxIn, TxOut, Witness}; + + use super::*; + + const LOCK: u64 = 20_200; + const TIMEOUT: u32 = 1_144; + + fn spk(byte: u8) -> ScriptBuf { + ScriptBuf::from_bytes(vec![0x00, 0x14].into_iter().chain([byte; 20]).collect()) + } + + fn tx(inputs: Vec, outputs: Vec<(ScriptBuf, u64)>) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: inputs + .into_iter() + .map(|previous_output| TxIn { + previous_output, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }) + .collect(), + output: outputs + .into_iter() + .map(|(script_pubkey, sats)| TxOut { + value: Amount::from_sat(sats), + script_pubkey, + }) + .collect(), + } + } + + struct Fixture { + htlc: ScriptBuf, + destination: ScriptBuf, + lockup: Transaction, + } + + impl Fixture { + fn new() -> Self { + let htlc = spk(1); + let lockup = tx( + vec![OutPoint::null()], + vec![(spk(9), 5_000), (htlc.clone(), LOCK)], + ); + Self { + htlc, + destination: spk(2), + lockup, + } + } + + fn contract(&self) -> Contract<'_> { + Contract { + htlc_spk: &self.htlc, + lock_sat: LOCK, + destination: &self.destination, + timeout: TIMEOUT, + required_confirmations: 1, + } + } + + fn outpoint(&self) -> OutPoint { + OutPoint::new(self.lockup.compute_txid(), 1) + } + + fn claim(&self) -> Transaction { + tx( + vec![self.outpoint()], + vec![(self.destination.clone(), 20_000)], + ) + } + + fn refund(&self) -> Transaction { + tx(vec![self.outpoint()], vec![(spk(3), 20_000)]) + } + + fn step(&self, saved: Option<&str>, tip: u32, history: &[(i32, Transaction)]) -> Step { + next_step(&self.contract(), saved, tip, history) + } + } + + #[test] + fn waits_for_a_lockup_until_the_timeout_then_fails() { + let f = Fixture::new(); + assert_eq!( + f.step(None, TIMEOUT - 1, &[]), + Step::Report(PubkySwapRouteState::AwaitingLockup { + confirmations: 0, + required: 1 + }) + ); + assert!(matches!( + f.step(None, TIMEOUT, &[]), + Step::Report(PubkySwapRouteState::Failed { .. }) + )); + } + + #[test] + fn waits_for_the_lockup_confirmations() { + let f = Fixture::new(); + assert_eq!( + f.step(None, 1_000, &[(0, f.lockup.clone())]), + Step::Report(PubkySwapRouteState::AwaitingLockup { + confirmations: 0, + required: 1 + }) + ); + assert_eq!( + f.step(None, 1_000, &[(1_000, f.lockup.clone())]), + Step::Claim(f.outpoint()) + ); + } + + #[test] + fn leaves_a_lockup_too_close_to_the_timeout_to_the_refund() { + let f = Fixture::new(); + let history = [(1_000, f.lockup.clone())]; + assert_eq!( + f.step(None, TIMEOUT - 7, &history), + Step::Claim(f.outpoint()) + ); + assert!(matches!( + f.step(None, TIMEOUT - 6, &history), + Step::Report(PubkySwapRouteState::Failed { .. }) + )); + } + + #[test] + fn reports_the_claim_as_broadcast_until_it_confirms() { + let f = Fixture::new(); + let claim = f.claim(); + let txid = claim.compute_txid().to_string(); + let lockup = (1_000, f.lockup.clone()); + assert_eq!( + f.step(Some(&txid), 1_001, &[lockup.clone(), (0, claim.clone())]), + Step::Claimed { + txid: txid.clone(), + state: PubkySwapRouteState::ClaimBroadcast { txid: txid.clone() } + } + ); + assert_eq!( + f.step(Some(&txid), 1_002, &[lockup, (1_002, claim)]), + Step::Claimed { + txid: txid.clone(), + state: PubkySwapRouteState::Claimed { txid } + } + ); + } + + #[test] + fn fails_when_the_provider_refunds_even_after_the_claim_went_out() { + let f = Fixture::new(); + let history = [(1_000, f.lockup.clone()), (1_200, f.refund())]; + let saved = f.claim().compute_txid().to_string(); + for saved in [None, Some(saved.as_str())] { + assert!(matches!( + f.step(saved, 1_200, &history), + Step::Report(PubkySwapRouteState::Failed { .. }) + )); + } + } + + #[test] + fn broadcasts_a_saved_claim_again_once_it_left_the_mempool() { + let f = Fixture::new(); + let saved = f.claim().compute_txid().to_string(); + // Also past the claim window: the preimage is public, so the claim must go out. + assert_eq!( + f.step(Some(&saved), TIMEOUT - 1, &[(1_000, f.lockup.clone())]), + Step::Rebroadcast + ); + } + + #[test] + fn records_a_claim_found_on_chain_that_the_record_missed() { + let f = Fixture::new(); + let claim = f.claim(); + let txid = claim.compute_txid().to_string(); + assert_eq!( + f.step(None, 1_001, &[(1_000, f.lockup.clone()), (0, claim)]), + Step::Claimed { + txid: txid.clone(), + state: PubkySwapRouteState::ClaimBroadcast { txid } + } + ); + } +} diff --git a/src/modules/pubky_swaps/client.rs b/src/modules/pubky_swaps/client.rs index 0e1ccd2..36dff7d 100644 --- a/src/modules/pubky_swaps/client.rs +++ b/src/modules/pubky_swaps/client.rs @@ -9,7 +9,7 @@ use std::str::FromStr; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; -use bitcoin::{Address, OutPoint, ScriptBuf}; +use bitcoin::{Address, ScriptBuf}; use electrum_client::{Client as ElectrumClient, ElectrumApi}; use once_cell::sync::Lazy; use swap_common::htlc::build_htlc_script; @@ -18,7 +18,7 @@ use swap_common::messages::{ SwapStatusRequest, PROTOCOL_VERSION, }; use swap_common::onchain::{build_claim_tx, estimate_spend_fee, spend_vsize}; -use swap_common::timelock::{claim_start_is_safe, TimelockParams}; +use swap_common::timelock::TimelockParams; use swap_common::validate::{ validate_accept, validate_hold_invoice, validate_quote, verify_contract, ClientPolicy, DecodedHoldInvoice, @@ -26,6 +26,7 @@ use swap_common::validate::{ use swap_common::SwapDirection; use uuid::Uuid; +use super::claim; use super::errors::PubkySwapError; use super::keys; use super::router::{self, is_lightning}; @@ -35,6 +36,9 @@ use super::types::*; /// How long a candidate provider has to answer an offer request. const OFFER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); +/// Most candidates asked for an offer per payment: each is a connection and an address lookup +/// through iroh's n0 discovery, which sees which keys the wallet looks up. +const MAX_CANDIDATES: usize = 30; /// How long the wallet keeps asking about a swap request the provider is still admitting. const PENDING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); const PENDING_POLL: std::time::Duration = std::time::Duration::from_secs(2); @@ -88,7 +92,7 @@ fn claim_fee(destination: &ScriptBuf, fee_rate_sat_vb: u64) -> Result = request + // In the caller's order, so the cap keeps configured providers listed first. + let mut seen = std::collections::HashSet::new(); + let candidates: Vec = request .providers .iter() .map(|p| bare_pubky(p).to_string()) + .filter(|p| seen.insert(p.clone())) + .take(MAX_CANDIDATES) .collect(); - candidates.sort(); - candidates.dedup(); for provider in candidates { let client = client.clone(); probes.spawn(async move { @@ -224,6 +237,9 @@ pub async fn route_paykit_payment( continue; }; match offer { + Ok(Ok(offer)) if bare_pubky(&offer.provider_pkarr) != provider => { + log::warn!("pubky swaps: {provider} answered with another provider's offer") + } Ok(Ok(offer)) if offer.network.to_bitcoin_network() == network => { edges.extend(router::edges_from_offer(&offer)); offers.insert(offer.provider_pkarr.clone(), offer); @@ -263,7 +279,8 @@ pub async fn route_paykit_payment( }; let route = PubkySwapRouteQuote { provider_pubky: provider, - quote_id: quote.quote_id.to_string(), + // The wallet's own id for this route: two providers may pick the same quote id. + quote_id: Uuid::new_v4().to_string(), endpoint: onchain, delivered_sat: amount, lock_sat, @@ -276,7 +293,10 @@ pub async fn route_paykit_payment( valid_until_unix: quote.valid_until_unix, providers_quoted: asked, }; - QUOTES.lock().unwrap().insert( + let mut quotes = QUOTES.lock().unwrap(); + let now = now_unix(); + quotes.retain(|_, cached| !cached.quote.is_expired(now)); + quotes.insert( route.quote_id.clone(), CachedQuote { route: route.clone(), @@ -333,6 +353,11 @@ pub async fn commit_route( quote, network, } = cached; + if network == bitcoin::Network::Bitcoin { + return Err(PubkySwapError::invalid( + "Swap routes are not available on mainnet yet.", + )); + } if quote.is_expired(now_unix()) { return Err(PubkySwapError::Refused { code: "quote_expired".into(), @@ -341,8 +366,8 @@ pub async fn commit_route( } let passphrase = bip39_passphrase.as_deref(); let secret = keys::identity_secret(&mnemonic, passphrase)?; - let index = store::reserve_index()?; - let swap_keys = keys::swap_keys(&mnemonic, passphrase, index)?; + let nonce = keys::new_nonce(); + let swap_keys = keys::swap_keys(&mnemonic, passphrase, &nonce)?; let request = SwapRequest { script_type: SwapScript::P2wsh, quote_id: quote.quote_id, @@ -377,6 +402,13 @@ pub async fn commit_route( SwapMessage::Reject(reject) => return Err(refused(reject)), other => return Err(unexpected("SwapAccept", &other)), }; + // `advance` watches and spends only the P2WSH contract the request asked for. + if accept.script_type != SwapScript::P2wsh { + return Err(PubkySwapError::validation(format!( + "the provider answered with a {:?} contract, not P2WSH", + accept.script_type + ))); + } let tip = { let url = electrum_url.clone(); @@ -412,7 +444,7 @@ pub async fn commit_route( let record = SwapRecord { swap_id: accept.swap_id.to_string(), - key_index: index, + key_nonce: hex::encode(nonce), provider_pubky: route.provider_pubky.clone(), network, electrum_url, @@ -422,6 +454,7 @@ pub async fn commit_route( delivered_sat: route.delivered_sat, claim_fee_sat: route.claim_fee_sat, claim_txid: None, + claim_tx_hex: None, created_at_unix: now_unix(), }; store::save(&record)?; @@ -449,93 +482,94 @@ fn status_of(record: &SwapRecord, state: PubkySwapRouteState) -> PubkySwapRouteS } /// Find the provider's lockup and, once it has its confirmations, claim it straight to the -/// contact's address. Call again until it reports `Claimed`. +/// contact's address. Call again until it reports `Claimed`: until the claim confirms, each call +/// checks it is still in the mempool and broadcasts it again if it dropped out. fn advance_blocking( mut record: SwapRecord, mnemonic: &str, passphrase: Option<&str>, ) -> Result { - if let Some(txid) = &record.claim_txid { - let state = PubkySwapRouteState::Claimed { txid: txid.clone() }; - return Ok(status_of(&record, state)); - } - let keys = keys::swap_keys(mnemonic, passphrase, record.key_index)?; + let nonce: [u8; 32] = hex::decode(&record.key_nonce) + .ok() + .and_then(|n| n.try_into().ok()) + .ok_or_else(|| PubkySwapError::storage("invalid key nonce"))?; + let keys = keys::swap_keys(mnemonic, passphrase, &nonce)?; let provider = record .accept .provider_pubkey_hex .parse() .map_err(PubkySwapError::validation)?; - let redeem_script = build_htlc_script( - &keys.payment_hash, - &keys.claim_pubkey, - &provider, - record.accept.timeout_block_height, - ); + let timeout = record.accept.timeout_block_height; + let redeem_script = + build_htlc_script(&keys.payment_hash, &keys.claim_pubkey, &provider, timeout); let htlc_spk = ScriptBuf::new_p2wsh(&redeem_script.wscript_hash()); - let required = - policy(record.network).effective_confirmations(record.quote.required_confirmations); + let destination = parse_address(&record.destination_address, record.network)?.script_pubkey(); + let contract = claim::Contract { + htlc_spk: &htlc_spk, + lock_sat: record.accept.onchain_amount_sat, + destination: &destination, + timeout, + required_confirmations: policy(record.network) + .effective_confirmations(record.quote.required_confirmations), + }; let electrum = ElectrumClient::new(&record.electrum_url)?; let tip = electrum.block_headers_subscribe()?.height as u32; - let mut lockup: Option<(OutPoint, u32)> = None; + let mut history = vec![]; for item in electrum.script_get_history(&htlc_spk)? { - let tx = electrum.transaction_get(&item.tx_hash)?; - let vout = tx.output.iter().position(|out| { - out.script_pubkey == htlc_spk && out.value.to_sat() == record.accept.onchain_amount_sat - }); - if let Some(vout) = vout { - let confirmations = if item.height > 0 { - tip.saturating_sub(item.height as u32) + 1 - } else { - 0 - }; - lockup = Some((OutPoint::new(item.tx_hash, vout as u32), confirmations)); - break; - } + history.push((item.height, electrum.transaction_get(&item.tx_hash)?)); } - let Some((outpoint, confirmations)) = lockup else { - let state = PubkySwapRouteState::AwaitingLockup { - confirmations: 0, - required, - }; - return Ok(status_of(&record, state)); + + let state = match claim::next_step(&contract, record.claim_txid.as_deref(), tip, &history) { + claim::Step::Report(state) => state, + claim::Step::Claimed { txid, state } => { + if record.claim_txid.as_deref() != Some(txid.as_str()) { + record.claim_txid = Some(txid); + store::save(&record)?; + } + state + } + claim::Step::Rebroadcast => { + let txid = record.claim_txid.clone().unwrap_or_default(); + match &record.claim_tx_hex { + Some(claim) => broadcast_claim(&electrum, claim), + None => log::warn!("pubky swaps: no saved claim to broadcast again for {txid}"), + } + PubkySwapRouteState::ClaimBroadcast { txid } + } + claim::Step::Claim(outpoint) => { + let claim = build_claim_tx( + outpoint, + record.accept.onchain_amount_sat, + &redeem_script, + destination.clone(), + record.claim_fee_sat, + keys.preimage, + &keys.claim_key, + ) + .map_err(PubkySwapError::chain)?; + let txid = claim.compute_txid().to_string(); + let claim = bitcoin::consensus::encode::serialize_hex(&claim); + // Saved before the broadcast, so a restart in between finds and sends the same claim. + record.claim_txid = Some(txid.clone()); + record.claim_tx_hex = Some(claim.clone()); + store::save(&record)?; + broadcast_claim(&electrum, &claim); + PubkySwapRouteState::ClaimBroadcast { txid } + } }; - if confirmations < required { - let state = PubkySwapRouteState::AwaitingLockup { - confirmations, - required, - }; - return Ok(status_of(&record, state)); - } + Ok(status_of(&record, state)) +} - // Claiming reveals the preimage. Too close to the timeout, the provider could settle the hold - // invoice and still refund its lock, so the wallet leaves it to the refund instead. - if !claim_start_is_safe(tip, record.accept.timeout_block_height) { - let state = PubkySwapRouteState::Failed { - reason: format!( - "The lockup times out at block {}, too close to claim safely at block {tip}. \ - The provider's refund returns the payment.", - record.accept.timeout_block_height - ), - }; - return Ok(status_of(&record, state)); +/// Send a saved claim. A failure is only logged: the claim is saved, and the next call finds it +/// missing from the history and sends it again, so the app keeps polling instead of stopping. +fn broadcast_claim(electrum: &ElectrumClient, claim: &str) { + let sent = bitcoin::consensus::encode::deserialize_hex::(claim) + .map_err(PubkySwapError::storage) + .and_then(|claim| Ok(electrum.transaction_broadcast(&claim)?)); + if let Err(e) = sent { + log::warn!("pubky swaps: claim broadcast failed, retrying on the next advance: {e}"); } - - let destination = parse_address(&record.destination_address, record.network)?; - let claim = build_claim_tx( - outpoint, - record.accept.onchain_amount_sat, - &redeem_script, - destination.script_pubkey(), - record.claim_fee_sat, - keys.preimage, - &keys.claim_key, - ) - .map_err(PubkySwapError::chain)?; - let txid = electrum.transaction_broadcast(&claim)?.to_string(); - record.claim_txid = Some(txid.clone()); - store::save(&record)?; - Ok(status_of(&record, PubkySwapRouteState::Claimed { txid })) } pub async fn advance_swap( diff --git a/src/modules/pubky_swaps/keys.rs b/src/modules/pubky_swaps/keys.rs index def82d7..0f05818 100644 --- a/src/modules/pubky_swaps/keys.rs +++ b/src/modules/pubky_swaps/keys.rs @@ -1,8 +1,11 @@ -//! Swap keys derived from the wallet seed, so a swap can be rebuilt from the seed and its index. +//! Swap keys derived from the wallet seed and a random per-swap nonce, so a swap can be rebuilt +//! from the seed and the nonce kept in its record. //! //! The provider sees only public keys and the payment hash. Each key is -//! HMAC-SHA512(seed, domain || index), truncated to 32 bytes, under its own domain string, so -//! nothing here can collide with the Boltz module's BIP85 keys or with each other. +//! HMAC-SHA512(seed, domain || nonce), truncated to 32 bytes, under its own domain string, so +//! nothing here can collide with the Boltz module's BIP85 keys or with each other. The nonce is +//! random rather than a counter: a restored or second wallet on the same seed must never derive a +//! preimage an earlier claim has already published. use bitcoin::hashes::{hmac, sha256, sha512, Hash, HashEngine}; use bitcoin::secp256k1::{Secp256k1, SecretKey}; @@ -10,8 +13,8 @@ use bitcoin::secp256k1::{Secp256k1, SecretKey}; use super::errors::PubkySwapError; const IDENTITY_DOMAIN: &[u8] = b"bitkit/pubky-swaps/identity/v1"; -const CLAIM_DOMAIN: &[u8] = b"bitkit/pubky-swaps/claim/v1/"; -const PREIMAGE_DOMAIN: &[u8] = b"bitkit/pubky-swaps/preimage/v1/"; +const CLAIM_DOMAIN: &[u8] = b"bitkit/pubky-swaps/claim/v2/"; +const PREIMAGE_DOMAIN: &[u8] = b"bitkit/pubky-swaps/preimage/v2/"; pub(crate) struct SwapKeys { pub claim_key: SecretKey, @@ -26,12 +29,10 @@ fn seed(mnemonic: &str, passphrase: Option<&str>) -> Result<[u8; 64], PubkySwapE Ok(mnemonic.to_seed(passphrase.unwrap_or(""))) } -fn derive(seed: &[u8; 64], domain: &[u8], index: Option) -> [u8; 32] { +fn derive(seed: &[u8; 64], domain: &[u8], nonce: &[u8]) -> [u8; 32] { let mut engine = hmac::HmacEngine::::new(seed); engine.input(domain); - if let Some(index) = index { - engine.input(&index.to_be_bytes()); - } + engine.input(nonce); let out = hmac::Hmac::::from_engine(engine).to_byte_array(); let mut key = [0u8; 32]; key.copy_from_slice(&out[..32]); @@ -44,19 +45,24 @@ pub(crate) fn identity_secret( mnemonic: &str, passphrase: Option<&str>, ) -> Result<[u8; 32], PubkySwapError> { - Ok(derive(&seed(mnemonic, passphrase)?, IDENTITY_DOMAIN, None)) + Ok(derive(&seed(mnemonic, passphrase)?, IDENTITY_DOMAIN, &[])) +} + +/// A fresh random nonce for a new swap's keys. +pub(crate) fn new_nonce() -> [u8; 32] { + rand::random() } pub(crate) fn swap_keys( mnemonic: &str, passphrase: Option<&str>, - index: u64, + nonce: &[u8; 32], ) -> Result { let seed = seed(mnemonic, passphrase)?; - let claim_key = SecretKey::from_slice(&derive(&seed, CLAIM_DOMAIN, Some(index))) + let claim_key = SecretKey::from_slice(&derive(&seed, CLAIM_DOMAIN, nonce)) .map_err(|e| PubkySwapError::invalid(format!("claim key: {e}")))?; let claim_pubkey = bitcoin::PublicKey::new(claim_key.public_key(&Secp256k1::new())); - let preimage = derive(&seed, PREIMAGE_DOMAIN, Some(index)); + let preimage = derive(&seed, PREIMAGE_DOMAIN, nonce); let payment_hash = sha256::Hash::hash(&preimage).to_byte_array(); Ok(SwapKeys { claim_key, @@ -74,10 +80,10 @@ mod tests { "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; #[test] - fn keys_are_deterministic_and_distinct_per_index() { - let a = swap_keys(MNEMONIC, None, 0).unwrap(); - let again = swap_keys(MNEMONIC, None, 0).unwrap(); - let b = swap_keys(MNEMONIC, None, 1).unwrap(); + fn keys_are_deterministic_and_distinct_per_nonce() { + let a = swap_keys(MNEMONIC, None, &[0; 32]).unwrap(); + let again = swap_keys(MNEMONIC, None, &[0; 32]).unwrap(); + let b = swap_keys(MNEMONIC, None, &[1; 32]).unwrap(); assert_eq!(a.claim_key, again.claim_key); assert_eq!(a.preimage, again.preimage); assert_ne!(a.claim_key, b.claim_key); @@ -87,12 +93,20 @@ mod tests { #[test] fn passphrase_changes_every_key() { - let plain = swap_keys(MNEMONIC, None, 0).unwrap(); - let with = swap_keys(MNEMONIC, Some("x"), 0).unwrap(); + let plain = swap_keys(MNEMONIC, None, &[0; 32]).unwrap(); + let with = swap_keys(MNEMONIC, Some("x"), &[0; 32]).unwrap(); assert_ne!(plain.claim_key, with.claim_key); assert_ne!( identity_secret(MNEMONIC, None).unwrap(), identity_secret(MNEMONIC, Some("x")).unwrap() ); } + + #[test] + fn fresh_nonces_never_repeat_a_preimage() { + let a = swap_keys(MNEMONIC, None, &new_nonce()).unwrap(); + let b = swap_keys(MNEMONIC, None, &new_nonce()).unwrap(); + assert_ne!(a.preimage, b.preimage); + assert_ne!(a.payment_hash, b.payment_hash); + } } diff --git a/src/modules/pubky_swaps/mod.rs b/src/modules/pubky_swaps/mod.rs index d95646f..4950f49 100644 --- a/src/modules/pubky_swaps/mod.rs +++ b/src/modules/pubky_swaps/mod.rs @@ -2,6 +2,7 @@ //! contact published, route the payment through a Pubky Swaps provider (`swap/1`). //! See README.md. +mod claim; mod client; mod errors; mod keys; diff --git a/src/modules/pubky_swaps/store.rs b/src/modules/pubky_swaps/store.rs index 6efaec4..fbda257 100644 --- a/src/modules/pubky_swaps/store.rs +++ b/src/modules/pubky_swaps/store.rs @@ -1,26 +1,27 @@ -//! One JSON file per swap under `/pubky_swaps/`, plus the next key index. +//! One JSON file per swap under `/pubky_swaps/`. //! -//! A record holds only public data and the key index; the keys come from the seed. Every -//! record is written before the step it protects: the index before the commit is sent, the -//! contract before the invoice is handed to the app. +//! A record holds only public data and the swap's key nonce; the keys come from the seed and the +//! nonce. The contract is written, and synced to disk, before the invoice is handed to the app. use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::sync::{Mutex, RwLock}; -use once_cell::sync::OnceCell; use serde::{Deserialize, Serialize}; use swap_common::messages::{Quote, SwapAccept}; use super::errors::PubkySwapError; -static BASE: OnceCell = OnceCell::new(); +/// The current base path; `init_db` may move it, like every other store it opens. +static BASE: RwLock> = RwLock::new(None); static LOCK: Mutex<()> = Mutex::new(()); #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct SwapRecord { pub swap_id: String, - pub key_index: u64, + /// Hex of the random nonce the swap's claim key and preimage derive from. + pub key_nonce: String, pub provider_pubky: String, pub network: bitcoin::Network, pub electrum_url: String, @@ -30,41 +31,38 @@ pub(crate) struct SwapRecord { pub delivered_sat: u64, pub claim_fee_sat: u64, pub claim_txid: Option, + /// The signed claim, kept so it can be broadcast again while it is unconfirmed. + #[serde(default)] + pub claim_tx_hex: Option, pub created_at_unix: u64, } pub(crate) fn init(base_path: &str) -> Result<(), PubkySwapError> { let dir = Path::new(base_path).join("pubky_swaps"); fs::create_dir_all(&dir)?; - let _ = BASE.set(dir); + *BASE.write().unwrap() = Some(dir); Ok(()) } -fn dir() -> Result<&'static PathBuf, PubkySwapError> { - BASE.get() +fn dir() -> Result { + BASE.read() + .unwrap() + .clone() .ok_or_else(|| PubkySwapError::storage("Database not initialized. Call init_db first.")) } fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), PubkySwapError> { let tmp = path.with_extension("tmp"); - fs::write(&tmp, bytes)?; + let mut file = fs::File::create(&tmp)?; + file.write_all(bytes)?; + file.sync_all()?; fs::rename(tmp, path)?; + if let Some(parent) = path.parent() { + fs::File::open(parent)?.sync_all()?; + } Ok(()) } -/// Reserve the next key index, persisted before it is used. -pub(crate) fn reserve_index() -> Result { - let _guard = LOCK.lock().unwrap(); - let path = dir()?.join("next_index"); - let index = match fs::read_to_string(&path) { - Ok(s) => s.trim().parse().map_err(PubkySwapError::storage)?, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0, - Err(e) => return Err(e.into()), - }; - write_atomic(&path, (index + 1).to_string().as_bytes())?; - Ok(index) -} - pub(crate) fn save(record: &SwapRecord) -> Result<(), PubkySwapError> { let _guard = LOCK.lock().unwrap(); let path = dir()?.join(format!("{}.json", record.swap_id)); diff --git a/src/modules/pubky_swaps/types.rs b/src/modules/pubky_swaps/types.rs index b3d2c05..bd5ac0f 100644 --- a/src/modules/pubky_swaps/types.rs +++ b/src/modules/pubky_swaps/types.rs @@ -60,6 +60,8 @@ pub enum PaykitRoute { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] pub struct PubkySwapRouteQuote { pub provider_pubky: String, + /// The wallet's id for this route, which `pubky_swaps_commit_route` takes. The provider's own + /// quote id stays inside bitkit-core. pub quote_id: String, /// The contact endpoint the swap delivers to. pub endpoint: PaykitEndpoint, @@ -101,8 +103,12 @@ pub enum PubkySwapRouteState { confirmations: u32, required: u32, }, - /// The claim to the contact's address is broadcast. The provider settles the invoice - /// once it sees the preimage. + /// The claim to the contact's address is broadcast but not confirmed yet. The provider settles + /// the invoice once it sees the preimage; keep calling until the claim confirms. + ClaimBroadcast { + txid: String, + }, + /// The claim to the contact's address is confirmed. Claimed { txid: String, }, From 64cdedeb57de9f67e30c4cedb10fa7971017ffb5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 16:26:35 +0100 Subject: [PATCH 12/23] chore: regenerate ios bindings for the claim states --- bindings/ios/bitkitcore.swift | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 0c02c11..702803f 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -12384,6 +12384,10 @@ public func FfiConverterTypePubkySwapRoutePayment_lower(_ value: PubkySwapRouteP */ public struct PubkySwapRouteQuote { public var providerPubky: String + /** + * The wallet's id for this route, which `pubky_swaps_commit_route` takes. The provider's own + * quote id stays inside bitkit-core. + */ public var quoteId: String /** * The contact endpoint the swap delivers to. @@ -12417,7 +12421,11 @@ public struct PubkySwapRouteQuote { // Default memberwise initializers are never public by default, so we // declare one manually. - public init(providerPubky: String, quoteId: String, + public init(providerPubky: String, + /** + * The wallet's id for this route, which `pubky_swaps_commit_route` takes. The provider's own + * quote id stays inside bitkit-core. + */quoteId: String, /** * The contact endpoint the swap delivers to. */endpoint: PaykitEndpoint, @@ -24809,8 +24817,13 @@ public enum PubkySwapRouteState { case awaitingLockup(confirmations: UInt32, required: UInt32 ) /** - * The claim to the contact's address is broadcast. The provider settles the invoice - * once it sees the preimage. + * The claim to the contact's address is broadcast but not confirmed yet. The provider settles + * the invoice once it sees the preimage; keep calling until the claim confirms. + */ + case claimBroadcast(txid: String + ) + /** + * The claim to the contact's address is confirmed. */ case claimed(txid: String ) @@ -24838,10 +24851,13 @@ public struct FfiConverterTypePubkySwapRouteState: FfiConverterRustBuffer { case 2: return .awaitingLockup(confirmations: try FfiConverterUInt32.read(from: &buf), required: try FfiConverterUInt32.read(from: &buf) ) - case 3: return .claimed(txid: try FfiConverterString.read(from: &buf) + case 3: return .claimBroadcast(txid: try FfiConverterString.read(from: &buf) + ) + + case 4: return .claimed(txid: try FfiConverterString.read(from: &buf) ) - case 4: return .failed(reason: try FfiConverterString.read(from: &buf) + case 5: return .failed(reason: try FfiConverterString.read(from: &buf) ) default: throw UniffiInternalError.unexpectedEnumCase @@ -24862,13 +24878,18 @@ public struct FfiConverterTypePubkySwapRouteState: FfiConverterRustBuffer { FfiConverterUInt32.write(required, into: &buf) - case let .claimed(txid): + case let .claimBroadcast(txid): writeInt(&buf, Int32(3)) FfiConverterString.write(txid, into: &buf) - case let .failed(reason): + case let .claimed(txid): writeInt(&buf, Int32(4)) + FfiConverterString.write(txid, into: &buf) + + + case let .failed(reason): + writeInt(&buf, Int32(5)) FfiConverterString.write(reason, into: &buf) } From cdfdcaed79425ec75f85665789f8dd3ceedef569 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 16:31:24 +0100 Subject: [PATCH 13/23] fix: name each on-chain endpoint and respect the fee cap when ranking swaps --- bindings/ios/bitkitcore.swift | 4 +- src/modules/pubky_swaps/client.rs | 8 +++- src/modules/pubky_swaps/router.rs | 70 +++++++++++++++++++------------ src/modules/pubky_swaps/types.rs | 2 +- 4 files changed, 54 insertions(+), 30 deletions(-) diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 702803f..b04ab02 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -13216,7 +13216,7 @@ public struct SwapRouteEdge { */ public var fromEndpoint: String /** - * Payment Endpoint Identifier family the route delivers to, such as `btc-regtest-*`. + * Payment Endpoint Identifier the route delivers to, such as `btc-regtest-p2wpkh`. */ public var toEndpoint: String /** @@ -13243,7 +13243,7 @@ public struct SwapRouteEdge { * Payment Endpoint Identifier the owner pays with, such as `btc-lightning-bolt11`. */fromEndpoint: String, /** - * Payment Endpoint Identifier family the route delivers to, such as `btc-regtest-*`. + * Payment Endpoint Identifier the route delivers to, such as `btc-regtest-p2wpkh`. */toEndpoint: String, /** * The minimum the provider enforces, not its configured floor. diff --git a/src/modules/pubky_swaps/client.rs b/src/modules/pubky_swaps/client.rs index 36dff7d..cedeae4 100644 --- a/src/modules/pubky_swaps/client.rs +++ b/src/modules/pubky_swaps/client.rs @@ -249,7 +249,13 @@ pub async fn route_paykit_payment( Err(_) => log::debug!("pubky swaps: {provider} did not answer in time"), } } - let ranked = router::rank_reverse_edges(edges, lock_sat, request.lightning_spendable_sat); + let ranked = router::rank_reverse_edges( + edges, + &onchain.method_id, + lock_sat, + request.lightning_spendable_sat, + policy(network).max_fee_bps, + ); let asked = ranked.len() as u32; let mut best: Option<(Quote, String)> = None; diff --git a/src/modules/pubky_swaps/router.rs b/src/modules/pubky_swaps/router.rs index 5af58eb..ae575ce 100644 --- a/src/modules/pubky_swaps/router.rs +++ b/src/modules/pubky_swaps/router.rs @@ -47,26 +47,20 @@ pub(crate) fn onchain_endpoint( }) } -/// The `swap/1` edges an offer advertises (Paykit Capabilities §13.1). The fee, minimum and -/// `fund_within_secs` are offer-wide, so both directions carry the same numbers. +/// The `swap/1` edges an offer advertises (Paykit Capabilities §13.1), one per on-chain Payment +/// Endpoint Identifier the provider pays or accepts. The fee, minimum and `fund_within_secs` are +/// offer-wide, so every edge carries the same numbers. pub(crate) fn edges_from_offer(offer: &SwapOffer) -> Vec { - let onchain = format!( - "btc-{}-*", - network_label(offer.network.to_bitcoin_network()) - ); - offer - .directions - .iter() - .map(|direction| { + let network = network_label(offer.network.to_bitcoin_network()); + let mut edges = vec![]; + for direction in &offer.directions { + for kind in ONCHAIN_PREFERENCE { + let onchain = format!("btc-{network}-{kind}"); let (dir, from, to) = match direction { - SwapDirection::Reverse => { - ("reverse", LIGHTNING_BOLT11.to_string(), onchain.clone()) - } - SwapDirection::Submarine => { - ("submarine", onchain.clone(), LIGHTNING_BOLT11.to_string()) - } + SwapDirection::Reverse => ("reverse", LIGHTNING_BOLT11.to_string(), onchain), + SwapDirection::Submarine => ("submarine", onchain, LIGHTNING_BOLT11.to_string()), }; - SwapRouteEdge { + edges.push(SwapRouteEdge { provider_pubky: offer.provider_pkarr.clone(), offer_id: offer.offer_id.to_string(), direction: dir.to_string(), @@ -79,9 +73,10 @@ pub(crate) fn edges_from_offer(offer: &SwapOffer) -> Vec { onchain_fee_sat: offer.onchain_fee_sat, atomicity: "hash".to_string(), fund_within_secs: offer.fund_within_secs, - } - }) - .collect() + }); + } + } + edges } /// Indicative fee of an edge for `amount_sat`: base + ppm + on-chain (§6.2). @@ -92,17 +87,22 @@ pub(crate) fn indicative_fee(edge: &SwapRouteEdge, amount_sat: u64) -> u64 { .saturating_add(edge.onchain_fee_sat) } -/// Reverse edges that can lock `lock_sat` and that the wallet can afford over Lightning, +/// Reverse edges that deliver to `endpoint`, can lock `lock_sat`, stay within the client's fee +/// cap (swap-common refuses a quote above it) and that the wallet can afford over Lightning, /// cheapest first, at most [`FANOUT_BUDGET`]. pub(crate) fn rank_reverse_edges( edges: Vec, + endpoint: &str, lock_sat: u64, lightning_spendable_sat: u64, + max_fee_bps: u16, ) -> Vec { + let max_fee = (u128::from(lock_sat) * u128::from(max_fee_bps) / 10_000) as u64; let mut usable: Vec<_> = edges .into_iter() - .filter(|e| e.direction == "reverse") + .filter(|e| e.direction == "reverse" && e.to_endpoint == endpoint) .filter(|e| lock_sat >= e.min_sat && lock_sat <= e.max_sat) + .filter(|e| indicative_fee(e, lock_sat) <= max_fee) .filter(|e| lock_sat.saturating_add(indicative_fee(e, lock_sat)) <= lightning_spendable_sat) .collect(); usable.sort_by_key(|e| indicative_fee(e, lock_sat)); @@ -120,7 +120,7 @@ mod tests { offer_id: "o".into(), direction: direction.into(), from_endpoint: LIGHTNING_BOLT11.into(), - to_endpoint: "btc-regtest-*".into(), + to_endpoint: "btc-regtest-p2wpkh".into(), min_sat: min, max_sat: max, base_fee_sat: base, @@ -161,7 +161,7 @@ mod tests { edge("too-small", "reverse", 0, 10_000, 15_000), edge("min-too-high", "reverse", 0, 50_000, 1_000_000), ]; - let ranked = rank_reverse_edges(edges, 20_000, 1_000_000); + let ranked = rank_reverse_edges(edges, "btc-regtest-p2wpkh", 20_000, 1_000_000, 10_000); let names: Vec<_> = ranked.iter().map(|e| e.provider_pubky.as_str()).collect(); assert_eq!(names, ["cheap", "expensive"]); } @@ -170,8 +170,11 @@ mod tests { fn drops_edges_the_wallet_cannot_afford() { let edges = vec![edge("p", "reverse", 100, 10_000, 1_000_000)]; // 20,000 locked + 100 + 50 + 500 fee = 20,650. - assert!(rank_reverse_edges(edges.clone(), 20_000, 20_649).is_empty()); - assert_eq!(rank_reverse_edges(edges, 20_000, 20_650).len(), 1); + let rank = |spendable| { + rank_reverse_edges(edges.clone(), "btc-regtest-p2wpkh", 20_000, spendable, 500) + }; + assert!(rank(20_649).is_empty()); + assert_eq!(rank(20_650).len(), 1); } #[test] @@ -179,4 +182,19 @@ mod tests { let e = edge("p", "reverse", 100, 0, u64::MAX); assert_eq!(indicative_fee(&e, 100_000), 100 + 250 + 500); } + + #[test] + fn drops_edges_above_the_client_fee_cap_and_other_endpoints() { + // 100 + 50 + 500 = 650 sats on 20,000 is 3.25%: under the 5% cap, over a 3% one. + let edges = vec![edge("p", "reverse", 100, 10_000, 1_000_000)]; + assert_eq!( + rank_reverse_edges(edges.clone(), "btc-regtest-p2wpkh", 20_000, 1_000_000, 500).len(), + 1 + ); + assert!( + rank_reverse_edges(edges.clone(), "btc-regtest-p2wpkh", 20_000, 1_000_000, 300) + .is_empty() + ); + assert!(rank_reverse_edges(edges, "btc-regtest-p2tr", 20_000, 1_000_000, 500).is_empty()); + } } diff --git a/src/modules/pubky_swaps/types.rs b/src/modules/pubky_swaps/types.rs index bd5ac0f..fa75fea 100644 --- a/src/modules/pubky_swaps/types.rs +++ b/src/modules/pubky_swaps/types.rs @@ -10,7 +10,7 @@ pub struct SwapRouteEdge { pub direction: String, /// Payment Endpoint Identifier the owner pays with, such as `btc-lightning-bolt11`. pub from_endpoint: String, - /// Payment Endpoint Identifier family the route delivers to, such as `btc-regtest-*`. + /// Payment Endpoint Identifier the route delivers to, such as `btc-regtest-p2wpkh`. pub to_endpoint: String, /// The minimum the provider enforces, not its configured floor. pub min_sat: u64, From 8202a62e8497954cccf620b86c9d323c3970fcbb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 16:35:15 +0100 Subject: [PATCH 14/23] fix: derive swap keys from a clock index the seed can rebuild --- src/modules/pubky_swaps/README.md | 15 ++++---- src/modules/pubky_swaps/client.rs | 14 +++----- src/modules/pubky_swaps/keys.rs | 58 +++++++++++++++++-------------- src/modules/pubky_swaps/store.rs | 43 ++++++++++++++++++++--- 4 files changed, 83 insertions(+), 47 deletions(-) diff --git a/src/modules/pubky_swaps/README.md b/src/modules/pubky_swaps/README.md index 52f3dd0..dd4bed4 100644 --- a/src/modules/pubky_swaps/README.md +++ b/src/modules/pubky_swaps/README.md @@ -35,13 +35,14 @@ payloads over this carrier. ## Keys and storage - The wallet's identity towards providers derives from the seed. Each swap's claim key and preimage - derive from the seed and a random nonce, with HMAC-SHA512 under their own domain strings: a - restored wallet or a second device on the same seed never repeats a preimage an earlier claim - published. They never collide with the Boltz module's keys, and the identity is not the user's - Pubky identity. -- `/pubky_swaps/.json` holds the contract, the nonce and the signed claim, never a - secret. It is synced to disk before the invoice reaches the app, and the claim before it is - broadcast. + derive from the seed and a clock index, with HMAC-SHA512 under their own domain strings. The + index is the Unix time in seconds when the swap starts, or one past the previous index when that + is later, so a restored or reinstalled wallet never repeats a preimage an earlier claim published, + and a restore can find a listed swap's index by scanning a window around its creation time. They + never collide with the Boltz module's keys, and the identity is not the user's Pubky identity. +- `/pubky_swaps/last_index` is synced to disk before the swap request is sent. + `/pubky_swaps/.json` holds the contract, the index and the signed claim, never a + secret; it is synced before the invoice reaches the app, and the claim before it is broadcast. ## Claim diff --git a/src/modules/pubky_swaps/client.rs b/src/modules/pubky_swaps/client.rs index cedeae4..3d8d4b8 100644 --- a/src/modules/pubky_swaps/client.rs +++ b/src/modules/pubky_swaps/client.rs @@ -92,7 +92,7 @@ fn claim_fee(destination: &ScriptBuf, fee_rate_sat_vb: u64) -> Result, ) -> Result { - let nonce: [u8; 32] = hex::decode(&record.key_nonce) - .ok() - .and_then(|n| n.try_into().ok()) - .ok_or_else(|| PubkySwapError::storage("invalid key nonce"))?; - let keys = keys::swap_keys(mnemonic, passphrase, &nonce)?; + let keys = keys::swap_keys(mnemonic, passphrase, record.key_index)?; let provider = record .accept .provider_pubkey_hex diff --git a/src/modules/pubky_swaps/keys.rs b/src/modules/pubky_swaps/keys.rs index 0f05818..34960e0 100644 --- a/src/modules/pubky_swaps/keys.rs +++ b/src/modules/pubky_swaps/keys.rs @@ -1,11 +1,14 @@ -//! Swap keys derived from the wallet seed and a random per-swap nonce, so a swap can be rebuilt -//! from the seed and the nonce kept in its record. +//! Swap keys derived from the wallet seed and a clock index, so a swap can be rebuilt from the +//! seed alone. //! //! The provider sees only public keys and the payment hash. Each key is -//! HMAC-SHA512(seed, domain || nonce), truncated to 32 bytes, under its own domain string, so -//! nothing here can collide with the Boltz module's BIP85 keys or with each other. The nonce is -//! random rather than a counter: a restored or second wallet on the same seed must never derive a -//! preimage an earlier claim has already published. +//! HMAC-SHA512(seed, domain || index), truncated to 32 bytes, under its own domain string, so +//! nothing here can collide with the Boltz module's BIP85 keys or with each other. +//! +//! The index is the Unix time in seconds when the swap starts, or one past the previous index when +//! that is later. A restored or reinstalled wallet starts from its clock, past every index it +//! used, so it never derives a preimage an earlier claim published. A restore finds each listed +//! swap's index by scanning a window around its creation time for the swap's payment hash. use bitcoin::hashes::{hmac, sha256, sha512, Hash, HashEngine}; use bitcoin::secp256k1::{Secp256k1, SecretKey}; @@ -13,8 +16,8 @@ use bitcoin::secp256k1::{Secp256k1, SecretKey}; use super::errors::PubkySwapError; const IDENTITY_DOMAIN: &[u8] = b"bitkit/pubky-swaps/identity/v1"; -const CLAIM_DOMAIN: &[u8] = b"bitkit/pubky-swaps/claim/v2/"; -const PREIMAGE_DOMAIN: &[u8] = b"bitkit/pubky-swaps/preimage/v2/"; +const CLAIM_DOMAIN: &[u8] = b"bitkit/pubky-swaps/claim/v3/"; +const PREIMAGE_DOMAIN: &[u8] = b"bitkit/pubky-swaps/preimage/v3/"; pub(crate) struct SwapKeys { pub claim_key: SecretKey, @@ -29,10 +32,10 @@ fn seed(mnemonic: &str, passphrase: Option<&str>) -> Result<[u8; 64], PubkySwapE Ok(mnemonic.to_seed(passphrase.unwrap_or(""))) } -fn derive(seed: &[u8; 64], domain: &[u8], nonce: &[u8]) -> [u8; 32] { +fn derive(seed: &[u8; 64], domain: &[u8], suffix: &[u8]) -> [u8; 32] { let mut engine = hmac::HmacEngine::::new(seed); engine.input(domain); - engine.input(nonce); + engine.input(suffix); let out = hmac::Hmac::::from_engine(engine).to_byte_array(); let mut key = [0u8; 32]; key.copy_from_slice(&out[..32]); @@ -48,21 +51,21 @@ pub(crate) fn identity_secret( Ok(derive(&seed(mnemonic, passphrase)?, IDENTITY_DOMAIN, &[])) } -/// A fresh random nonce for a new swap's keys. -pub(crate) fn new_nonce() -> [u8; 32] { - rand::random() +/// The index a new swap takes: the clock, or one past the last index when that is later. +pub(crate) fn next_index(last: Option, now_unix: u64) -> u64 { + last.map_or(now_unix, |last| now_unix.max(last + 1)) } pub(crate) fn swap_keys( mnemonic: &str, passphrase: Option<&str>, - nonce: &[u8; 32], + index: u64, ) -> Result { let seed = seed(mnemonic, passphrase)?; - let claim_key = SecretKey::from_slice(&derive(&seed, CLAIM_DOMAIN, nonce)) + let claim_key = SecretKey::from_slice(&derive(&seed, CLAIM_DOMAIN, &index.to_be_bytes())) .map_err(|e| PubkySwapError::invalid(format!("claim key: {e}")))?; let claim_pubkey = bitcoin::PublicKey::new(claim_key.public_key(&Secp256k1::new())); - let preimage = derive(&seed, PREIMAGE_DOMAIN, nonce); + let preimage = derive(&seed, PREIMAGE_DOMAIN, &index.to_be_bytes()); let payment_hash = sha256::Hash::hash(&preimage).to_byte_array(); Ok(SwapKeys { claim_key, @@ -80,10 +83,10 @@ mod tests { "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; #[test] - fn keys_are_deterministic_and_distinct_per_nonce() { - let a = swap_keys(MNEMONIC, None, &[0; 32]).unwrap(); - let again = swap_keys(MNEMONIC, None, &[0; 32]).unwrap(); - let b = swap_keys(MNEMONIC, None, &[1; 32]).unwrap(); + fn keys_are_deterministic_and_distinct_per_index() { + let a = swap_keys(MNEMONIC, None, 1_793_600_000).unwrap(); + let again = swap_keys(MNEMONIC, None, 1_793_600_000).unwrap(); + let b = swap_keys(MNEMONIC, None, 1_793_600_001).unwrap(); assert_eq!(a.claim_key, again.claim_key); assert_eq!(a.preimage, again.preimage); assert_ne!(a.claim_key, b.claim_key); @@ -93,8 +96,8 @@ mod tests { #[test] fn passphrase_changes_every_key() { - let plain = swap_keys(MNEMONIC, None, &[0; 32]).unwrap(); - let with = swap_keys(MNEMONIC, Some("x"), &[0; 32]).unwrap(); + let plain = swap_keys(MNEMONIC, None, 0).unwrap(); + let with = swap_keys(MNEMONIC, Some("x"), 0).unwrap(); assert_ne!(plain.claim_key, with.claim_key); assert_ne!( identity_secret(MNEMONIC, None).unwrap(), @@ -103,10 +106,11 @@ mod tests { } #[test] - fn fresh_nonces_never_repeat_a_preimage() { - let a = swap_keys(MNEMONIC, None, &new_nonce()).unwrap(); - let b = swap_keys(MNEMONIC, None, &new_nonce()).unwrap(); - assert_ne!(a.preimage, b.preimage); - assert_ne!(a.payment_hash, b.payment_hash); + fn indexes_follow_the_clock_and_never_repeat() { + assert_eq!(next_index(None, 1_000), 1_000); + assert_eq!(next_index(Some(900), 1_000), 1_000); + // Several swaps in one second step past the clock. + assert_eq!(next_index(Some(1_000), 1_000), 1_001); + assert_eq!(next_index(Some(1_001), 1_000), 1_002); } } diff --git a/src/modules/pubky_swaps/store.rs b/src/modules/pubky_swaps/store.rs index fbda257..1d0fe18 100644 --- a/src/modules/pubky_swaps/store.rs +++ b/src/modules/pubky_swaps/store.rs @@ -1,7 +1,8 @@ //! One JSON file per swap under `/pubky_swaps/`. //! -//! A record holds only public data and the swap's key nonce; the keys come from the seed and the -//! nonce. The contract is written, and synced to disk, before the invoice is handed to the app. +//! A record holds only public data and the swap's key index; the keys come from the seed and the +//! index. The index is synced to disk before the swap request is sent, and the contract before the +//! invoice is handed to the app. use std::fs; use std::io::Write; @@ -20,8 +21,8 @@ static LOCK: Mutex<()> = Mutex::new(()); #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct SwapRecord { pub swap_id: String, - /// Hex of the random nonce the swap's claim key and preimage derive from. - pub key_nonce: String, + /// The clock index the swap's claim key and preimage derive from (see `keys`). + pub key_index: u64, pub provider_pubky: String, pub network: bitcoin::Network, pub electrum_url: String, @@ -63,6 +64,21 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), PubkySwapError> { Ok(()) } +/// Take the index for a new swap at `now_unix` and sync it to disk before it is used. Without a +/// stored index, as after a restore, the clock alone is past every index used before. +pub(crate) fn reserve_index(now_unix: u64) -> Result { + let _guard = LOCK.lock().unwrap(); + let path = dir()?.join("last_index"); + let last = match fs::read_to_string(&path) { + Ok(s) => Some(s.trim().parse().map_err(PubkySwapError::storage)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(e.into()), + }; + let index = super::keys::next_index(last, now_unix); + write_atomic(&path, index.to_string().as_bytes())?; + Ok(index) +} + pub(crate) fn save(record: &SwapRecord) -> Result<(), PubkySwapError> { let _guard = LOCK.lock().unwrap(); let path = dir()?.join(format!("{}.json", record.swap_id)); @@ -79,3 +95,22 @@ pub(crate) fn load(swap_id: &str) -> Result { Err(e) => Err(e.into()), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reserves_increasing_indexes_and_never_repeats_after_a_wipe() { + let base = std::env::temp_dir().join(format!("pubky-swaps-index-{}", uuid::Uuid::new_v4())); + init(base.to_str().unwrap()).unwrap(); + let first = reserve_index(1_000).unwrap(); + let second = reserve_index(1_000).unwrap(); + assert_eq!((first, second), (1_000, 1_001)); + // A restore starts without the stored index, at a later time. + fs::remove_dir_all(&base).unwrap(); + init(base.to_str().unwrap()).unwrap(); + assert!(reserve_index(2_000).unwrap() > second); + fs::remove_dir_all(&base).ok(); + } +} From b9259eda7f7369aea3e2f69712434912af2b154e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 17:09:49 +0100 Subject: [PATCH 15/23] fix: keep swap indexes behind the clock and claims private past the window --- src/lib.rs | 5 ++ src/modules/pubky_swaps/README.md | 9 ++- src/modules/pubky_swaps/claim.rs | 107 ++++++++++++++++++++++++------ src/modules/pubky_swaps/client.rs | 54 +++++++++++---- src/modules/pubky_swaps/keys.rs | 28 ++++---- src/modules/pubky_swaps/store.rs | 26 +++++--- 6 files changed, 172 insertions(+), 57 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b0c99b1..4773c44 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3451,6 +3451,11 @@ pub async fn pubky_swaps_provider_edges( /// Pick how to pay a Paykit contact. Returns `Direct` when a balance the wallet holds reaches /// one of the contact's endpoints, otherwise the cheapest firm quote from the trusted Pubky /// Swaps providers, or `None` with a reason. The user never picks the swap. +/// +/// Each candidate in `request.providers`, at most 30, is asked for its offer over iroh. iroh finds +/// a candidate's address through n0's discovery servers, so n0 sees the keys looked up from the +/// wallet's swap identity and IP: pass candidates the user expects to route through, not every +/// contact, where that matters. #[uniffi::export] pub async fn pubky_swaps_route_paykit_payment( request: PaykitRouteRequest, diff --git a/src/modules/pubky_swaps/README.md b/src/modules/pubky_swaps/README.md index dd4bed4..2880144 100644 --- a/src/modules/pubky_swaps/README.md +++ b/src/modules/pubky_swaps/README.md @@ -29,7 +29,11 @@ payment hash (`validate_hold_invoice`). These checks are pubky-swap's own `swap- ## Transport Requests travel over iroh `pubky-swap/direct/1`. The provider authenticates the request by the -connection's key, so neither side needs a homeserver. Paykit Capabilities §13.1 allows the same +connection's key, so neither side needs a homeserver. iroh does need n0's infrastructure: it finds +each candidate's address through n0's discovery servers and publishes the wallet's own there, so +for each payment that needs a swap, n0 sees up to 30 candidate keys looked up from one swap identity +and IP. The caller's candidate list sets that exposure. Published Capability Descriptors +(coreyphillips/pubky-swap#104) would let the router ask only providers, not every contact. Paykit Capabilities §13.1 allows the same payloads over this carrier. ## Keys and storage @@ -40,7 +44,8 @@ payloads over this carrier. is later, so a restored or reinstalled wallet never repeats a preimage an earlier claim published, and a restore can find a listed swap's index by scanning a window around its creation time. They never collide with the Boltz module's keys, and the identity is not the user's Pubky identity. -- `/pubky_swaps/last_index` is synced to disk before the swap request is sent. +- A wallet starts at most one swap per second, so an index is never ahead of the clock. + `/pubky_swaps/last_index` is synced to disk before the swap request is sent. `/pubky_swaps/.json` holds the contract, the index and the signed claim, never a secret; it is synced before the invoice reaches the app, and the claim before it is broadcast. diff --git a/src/modules/pubky_swaps/claim.rs b/src/modules/pubky_swaps/claim.rs index f743d59..44765e4 100644 --- a/src/modules/pubky_swaps/claim.rs +++ b/src/modules/pubky_swaps/claim.rs @@ -11,6 +11,8 @@ pub(crate) struct Contract<'a> { pub htlc_spk: &'a ScriptBuf, pub lock_sat: u64, pub destination: &'a ScriptBuf, + /// What the claim pays the contact: the lock less the claim fee. + pub delivered_sat: u64, pub timeout: u32, pub required_confirmations: u32, } @@ -24,16 +26,22 @@ pub(crate) enum Step { txid: String, state: PubkySwapRouteState, }, - /// The saved claim is not in the history: broadcast it again. Its preimage is already public, - /// so it goes out whatever the window. + /// The saved claim is not in the history: broadcast it again. Once it was published its + /// preimage is public, so it goes out whatever the window; before that, only inside it. Rebroadcast, /// Build, save and broadcast the claim of this lockup. Claim(OutPoint), } +/// The claim a record holds, and whether its preimage ever left the wallet. +pub(crate) struct SavedClaim<'a> { + pub txid: &'a str, + pub published: bool, +} + pub(crate) fn next_step( contract: &Contract, - saved_claim: Option<&str>, + saved_claim: Option, tip: u32, history: &[(i32, Transaction)], ) -> Step { @@ -74,16 +82,19 @@ pub(crate) fn next_step( .any(|input| input.previous_output == outpoint) }); if let Some((height, tx)) = spend { - if !tx - .output - .iter() - .any(|out| out.script_pubkey == *contract.destination) - { + // Ours only if it is the saved claim, or pays the contact exactly what the claim would: a + // refund with a dust output to the contact is not a payment. + let txid = tx.compute_txid().to_string(); + let is_saved = saved_claim.as_ref().is_some_and(|claim| claim.txid == txid); + let pays_contact = tx.output.iter().any(|out| { + out.script_pubkey == *contract.destination + && out.value.to_sat() == contract.delivered_sat + }); + if !is_saved && !pays_contact { return Step::Report(PubkySwapRouteState::Failed { reason: "The provider refunded its lockup instead of the claim.".into(), }); } - let txid = tx.compute_txid().to_string(); let state = if confirmations_at(*height) > 0 { PubkySwapRouteState::Claimed { txid: txid.clone() } } else { @@ -92,18 +103,13 @@ pub(crate) fn next_step( return Step::Claimed { txid, state }; } - if saved_claim.is_some() { - return Step::Rebroadcast; - } - if confirmations < contract.required_confirmations { - return Step::Report(PubkySwapRouteState::AwaitingLockup { - confirmations, - required: contract.required_confirmations, - }); + let safe_to_reveal = claim_start_is_safe(tip, contract.timeout); + if let Some(claim) = saved_claim { + if claim.published || safe_to_reveal { + return Step::Rebroadcast; + } } - // Claiming reveals the preimage. Too close to the timeout, the provider could settle the hold - // invoice and still refund its lock, so the wallet leaves it to the refund instead. - if !claim_start_is_safe(tip, contract.timeout) { + if !safe_to_reveal { return Step::Report(PubkySwapRouteState::Failed { reason: format!( "The lockup times out at block {}, too close to claim safely at block {tip}. \ @@ -112,6 +118,12 @@ pub(crate) fn next_step( ), }); } + if confirmations < contract.required_confirmations { + return Step::Report(PubkySwapRouteState::AwaitingLockup { + confirmations, + required: contract.required_confirmations, + }); + } Step::Claim(outpoint) } @@ -178,6 +190,7 @@ mod tests { htlc_spk: &self.htlc, lock_sat: LOCK, destination: &self.destination, + delivered_sat: 20_000, timeout: TIMEOUT, required_confirmations: 1, } @@ -198,9 +211,22 @@ mod tests { tx(vec![self.outpoint()], vec![(spk(3), 20_000)]) } + /// A saved claim that already reached the server. fn step(&self, saved: Option<&str>, tip: u32, history: &[(i32, Transaction)]) -> Step { + let saved = saved.map(|txid| SavedClaim { + txid, + published: true, + }); next_step(&self.contract(), saved, tip, history) } + + fn step_unpublished(&self, saved: &str, tip: u32, history: &[(i32, Transaction)]) -> Step { + let saved = SavedClaim { + txid: saved, + published: false, + }; + next_step(&self.contract(), Some(saved), tip, history) + } } #[test] @@ -308,4 +334,45 @@ mod tests { } ); } + + #[test] + fn keeps_an_unpublished_claim_private_once_the_window_closed() { + let f = Fixture::new(); + let saved = f.claim().compute_txid().to_string(); + let history = [(1_000, f.lockup.clone())]; + assert_eq!( + f.step_unpublished(&saved, TIMEOUT - 7, &history), + Step::Rebroadcast + ); + assert!(matches!( + f.step_unpublished(&saved, TIMEOUT + 5, &history), + Step::Report(PubkySwapRouteState::Failed { .. }) + )); + } + + #[test] + fn reports_a_refund_with_dust_to_the_contact_as_failed() { + let f = Fixture::new(); + let refund = tx( + vec![f.outpoint()], + vec![(spk(3), 19_000), (f.destination.clone(), 546)], + ); + assert!(matches!( + f.step( + None, + TIMEOUT + 5, + &[(1_000, f.lockup.clone()), (TIMEOUT as i32 + 1, refund)] + ), + Step::Report(PubkySwapRouteState::Failed { .. }) + )); + } + + #[test] + fn fails_an_unconfirmed_lockup_past_the_timeout() { + let f = Fixture::new(); + assert!(matches!( + f.step(None, 2_000, &[(0, f.lockup.clone())]), + Step::Report(PubkySwapRouteState::Failed { .. }) + )); + } } diff --git a/src/modules/pubky_swaps/client.rs b/src/modules/pubky_swaps/client.rs index 3d8d4b8..ef4dd54 100644 --- a/src/modules/pubky_swaps/client.rs +++ b/src/modules/pubky_swaps/client.rs @@ -372,7 +372,13 @@ pub async fn commit_route( } let passphrase = bip39_passphrase.as_deref(); let secret = keys::identity_secret(&mnemonic, passphrase)?; - let index = store::reserve_index(now_unix())?; + // One swap per clock second keeps every index behind the clock (see `keys`). + let index = loop { + if let Some(index) = store::reserve_index(now_unix())? { + break index; + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + }; let swap_keys = keys::swap_keys(&mnemonic, passphrase, index)?; let request = SwapRequest { script_type: SwapScript::P2wsh, @@ -461,6 +467,7 @@ pub async fn commit_route( claim_fee_sat: route.claim_fee_sat, claim_txid: None, claim_tx_hex: None, + claim_published: false, created_at_unix: now_unix(), }; store::save(&record)?; @@ -510,6 +517,7 @@ fn advance_blocking( htlc_spk: &htlc_spk, lock_sat: record.accept.onchain_amount_sat, destination: &destination, + delivered_sat: record.delivered_sat, timeout, required_confirmations: policy(record.network) .effective_confirmations(record.quote.required_confirmations), @@ -522,19 +530,24 @@ fn advance_blocking( history.push((item.height, electrum.transaction_get(&item.tx_hash)?)); } - let state = match claim::next_step(&contract, record.claim_txid.as_deref(), tip, &history) { + let saved = record.claim_txid.as_deref().map(|txid| claim::SavedClaim { + txid, + published: record.claim_published, + }); + let state = match claim::next_step(&contract, saved, tip, &history) { claim::Step::Report(state) => state, claim::Step::Claimed { txid, state } => { - if record.claim_txid.as_deref() != Some(txid.as_str()) { + if record.claim_txid.as_deref() != Some(txid.as_str()) || !record.claim_published { record.claim_txid = Some(txid); + record.claim_published = true; store::save(&record)?; } state } claim::Step::Rebroadcast => { let txid = record.claim_txid.clone().unwrap_or_default(); - match &record.claim_tx_hex { - Some(claim) => broadcast_claim(&electrum, claim), + match record.claim_tx_hex.clone() { + Some(claim) => publish_claim(&electrum, &mut record, &claim)?, None => log::warn!("pubky swaps: no saved claim to broadcast again for {txid}"), } PubkySwapRouteState::ClaimBroadcast { txid } @@ -556,31 +569,48 @@ fn advance_blocking( record.claim_txid = Some(txid.clone()); record.claim_tx_hex = Some(claim.clone()); store::save(&record)?; - broadcast_claim(&electrum, &claim); + publish_claim(&electrum, &mut record, &claim)?; PubkySwapRouteState::ClaimBroadcast { txid } } }; Ok(status_of(&record, state)) } -/// Send a saved claim. A failure is only logged: the claim is saved, and the next call finds it -/// missing from the history and sends it again, so the app keeps polling instead of stopping. -fn broadcast_claim(electrum: &ElectrumClient, claim: &str) { +/// Send a saved claim and record once the server took it, since its preimage is then public. A +/// failure is only logged: the next call finds the claim missing from the history and sends it +/// again, inside the safe window unless it was already published, so the app keeps polling. +fn publish_claim( + electrum: &ElectrumClient, + record: &mut SwapRecord, + claim: &str, +) -> Result<(), PubkySwapError> { let sent = bitcoin::consensus::encode::deserialize_hex::(claim) .map_err(PubkySwapError::storage) .and_then(|claim| Ok(electrum.transaction_broadcast(&claim)?)); - if let Err(e) = sent { - log::warn!("pubky swaps: claim broadcast failed, retrying on the next advance: {e}"); + match sent { + Ok(_) if !record.claim_published => { + record.claim_published = true; + store::save(record) + } + Ok(_) => Ok(()), + Err(e) => { + log::warn!("pubky swaps: claim broadcast failed, retrying on the next advance: {e}"); + Ok(()) + } } } +/// One advance at a time, so two overlapping calls cannot save a stale record over the claim. +static ADVANCING: Mutex<()> = Mutex::new(()); + pub async fn advance_swap( swap_id: String, mnemonic: String, bip39_passphrase: Option, ) -> Result { - let record = store::load(&swap_id)?; tokio::task::spawn_blocking(move || { + let _advancing = ADVANCING.lock().unwrap(); + let record = store::load(&swap_id)?; advance_blocking(record, &mnemonic, bip39_passphrase.as_deref()) }) .await diff --git a/src/modules/pubky_swaps/keys.rs b/src/modules/pubky_swaps/keys.rs index 34960e0..c5550a2 100644 --- a/src/modules/pubky_swaps/keys.rs +++ b/src/modules/pubky_swaps/keys.rs @@ -5,10 +5,12 @@ //! HMAC-SHA512(seed, domain || index), truncated to 32 bytes, under its own domain string, so //! nothing here can collide with the Boltz module's BIP85 keys or with each other. //! -//! The index is the Unix time in seconds when the swap starts, or one past the previous index when -//! that is later. A restored or reinstalled wallet starts from its clock, past every index it -//! used, so it never derives a preimage an earlier claim published. A restore finds each listed -//! swap's index by scanning a window around its creation time for the swap's payment hash. +//! The index is the Unix time in seconds when the swap starts, and a wallet starts at most one swap +//! per second, so an index is never ahead of the clock. A restored or reinstalled wallet starts +//! after every index it used, so it never derives a preimage an earlier claim published. A restore +//! finds each listed swap's index by scanning a window around its creation time for the swap's +//! payment hash. Two devices on one seed still derive the same keys if both start a swap in the +//! same second. use bitcoin::hashes::{hmac, sha256, sha512, Hash, HashEngine}; use bitcoin::secp256k1::{Secp256k1, SecretKey}; @@ -51,9 +53,9 @@ pub(crate) fn identity_secret( Ok(derive(&seed(mnemonic, passphrase)?, IDENTITY_DOMAIN, &[])) } -/// The index a new swap takes: the clock, or one past the last index when that is later. -pub(crate) fn next_index(last: Option, now_unix: u64) -> u64 { - last.map_or(now_unix, |last| now_unix.max(last + 1)) +/// The index a new swap takes at `now_unix`, or `None` while this second already has a swap. +pub(crate) fn next_index(last: Option, now_unix: u64) -> Option { + last.is_none_or(|last| now_unix > last).then_some(now_unix) } pub(crate) fn swap_keys( @@ -106,11 +108,11 @@ mod tests { } #[test] - fn indexes_follow_the_clock_and_never_repeat() { - assert_eq!(next_index(None, 1_000), 1_000); - assert_eq!(next_index(Some(900), 1_000), 1_000); - // Several swaps in one second step past the clock. - assert_eq!(next_index(Some(1_000), 1_000), 1_001); - assert_eq!(next_index(Some(1_001), 1_000), 1_002); + fn indexes_follow_the_clock_and_never_run_ahead_of_it() { + assert_eq!(next_index(None, 1_000), Some(1_000)); + assert_eq!(next_index(Some(900), 1_000), Some(1_000)); + // A second swap in the same second waits for the next one. + assert_eq!(next_index(Some(1_000), 1_000), None); + assert_eq!(next_index(Some(1_000), 1_001), Some(1_001)); } } diff --git a/src/modules/pubky_swaps/store.rs b/src/modules/pubky_swaps/store.rs index 1d0fe18..9f017fe 100644 --- a/src/modules/pubky_swaps/store.rs +++ b/src/modules/pubky_swaps/store.rs @@ -35,6 +35,9 @@ pub(crate) struct SwapRecord { /// The signed claim, kept so it can be broadcast again while it is unconfirmed. #[serde(default)] pub claim_tx_hex: Option, + /// Whether the claim, and with it the preimage, ever reached the server or the chain. + #[serde(default)] + pub claim_published: bool, pub created_at_unix: u64, } @@ -64,9 +67,10 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), PubkySwapError> { Ok(()) } -/// Take the index for a new swap at `now_unix` and sync it to disk before it is used. Without a -/// stored index, as after a restore, the clock alone is past every index used before. -pub(crate) fn reserve_index(now_unix: u64) -> Result { +/// Take the index for a new swap at `now_unix` and sync it to disk before it is used, or `None` +/// while this second already has a swap. Without a stored index, as after a restore, the clock +/// alone is past every index used before. +pub(crate) fn reserve_index(now_unix: u64) -> Result, PubkySwapError> { let _guard = LOCK.lock().unwrap(); let path = dir()?.join("last_index"); let last = match fs::read_to_string(&path) { @@ -74,9 +78,11 @@ pub(crate) fn reserve_index(now_unix: u64) -> Result { Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, Err(e) => return Err(e.into()), }; - let index = super::keys::next_index(last, now_unix); + let Some(index) = super::keys::next_index(last, now_unix) else { + return Ok(None); + }; write_atomic(&path, index.to_string().as_bytes())?; - Ok(index) + Ok(Some(index)) } pub(crate) fn save(record: &SwapRecord) -> Result<(), PubkySwapError> { @@ -104,13 +110,13 @@ mod tests { fn reserves_increasing_indexes_and_never_repeats_after_a_wipe() { let base = std::env::temp_dir().join(format!("pubky-swaps-index-{}", uuid::Uuid::new_v4())); init(base.to_str().unwrap()).unwrap(); - let first = reserve_index(1_000).unwrap(); - let second = reserve_index(1_000).unwrap(); - assert_eq!((first, second), (1_000, 1_001)); - // A restore starts without the stored index, at a later time. + assert_eq!(reserve_index(1_000).unwrap(), Some(1_000)); + assert_eq!(reserve_index(1_000).unwrap(), None); + assert_eq!(reserve_index(1_001).unwrap(), Some(1_001)); + // A restore starts without the stored index, in a later second. fs::remove_dir_all(&base).unwrap(); init(base.to_str().unwrap()).unwrap(); - assert!(reserve_index(2_000).unwrap() > second); + assert_eq!(reserve_index(1_002).unwrap(), Some(1_002)); fs::remove_dir_all(&base).ok(); } } From e33a6097729eeaf9c17cd571ce702926c14fba46 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 17:10:08 +0100 Subject: [PATCH 16/23] chore: regenerate ios bindings --- bindings/ios/bitkitcore.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index b04ab02..fb32777 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -31952,6 +31952,11 @@ public func pubkySwapsProviderEdges(providerPubky: String, mnemonic: String, bip * Pick how to pay a Paykit contact. Returns `Direct` when a balance the wallet holds reaches * one of the contact's endpoints, otherwise the cheapest firm quote from the trusted Pubky * Swaps providers, or `None` with a reason. The user never picks the swap. + * + * Each candidate in `request.providers`, at most 30, is asked for its offer over iroh. iroh finds + * a candidate's address through n0's discovery servers, so n0 sees the keys looked up from the + * wallet's swap identity and IP: pass candidates the user expects to route through, not every + * contact, where that matters. */ public func pubkySwapsRoutePaykitPayment(request: PaykitRouteRequest, network: Network, mnemonic: String, bip39Passphrase: String?)async throws -> PaykitRoute { return @@ -33275,7 +33280,7 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_func_pubky_swaps_provider_edges() != 4359) { return InitializationResult.apiChecksumMismatch } - if (uniffi_bitkitcore_checksum_func_pubky_swaps_route_paykit_payment() != 7835) { + if (uniffi_bitkitcore_checksum_func_pubky_swaps_route_paykit_payment() != 51456) { return InitializationResult.apiChecksumMismatch } if (uniffi_bitkitcore_checksum_func_record_rbf_boost() != 15433) { From 671014bc1e989369f494c76361b0bb4cc1fff374 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 17:30:19 +0100 Subject: [PATCH 17/23] fix: bound swap advances and keep possibly exposed claims retrying --- src/modules/pubky_swaps/client.rs | 97 ++++++++++++++++++++++++++----- src/modules/pubky_swaps/store.rs | 5 +- 2 files changed, 85 insertions(+), 17 deletions(-) diff --git a/src/modules/pubky_swaps/client.rs b/src/modules/pubky_swaps/client.rs index ef4dd54..afb8632 100644 --- a/src/modules/pubky_swaps/client.rs +++ b/src/modules/pubky_swaps/client.rs @@ -10,7 +10,7 @@ use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use bitcoin::{Address, ScriptBuf}; -use electrum_client::{Client as ElectrumClient, ElectrumApi}; +use electrum_client::{Client as ElectrumClient, ConfigBuilder, ElectrumApi}; use once_cell::sync::Lazy; use swap_common::htlc::build_htlc_script; use swap_common::messages::{ @@ -39,6 +39,10 @@ const OFFER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); /// Most candidates asked for an offer per payment: each is a connection and an address lookup /// through iroh's n0 discovery, which sees which keys the wallet looks up. const MAX_CANDIDATES: usize = 30; +const ELECTRUM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +/// Longest wait for a free clock second before a swap request; past it the clock is behind the +/// last swap's index, as after a backward clock correction. +const INDEX_WAIT: std::time::Duration = std::time::Duration::from_secs(3); /// How long the wallet keeps asking about a swap request the provider is still admitting. const PENDING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); const PENDING_POLL: std::time::Duration = std::time::Duration::from_secs(2); @@ -333,8 +337,14 @@ fn decode_hold_invoice(invoice: &str) -> Result Result { + let config = ConfigBuilder::new().timeout(Some(ELECTRUM_TIMEOUT)).build(); + Ok(ElectrumClient::from_config(url, config)?) +} + fn chain_tip(electrum_url: &str) -> Result { - let client = ElectrumClient::new(electrum_url)?; + let client = electrum(electrum_url)?; Ok(client.block_headers_subscribe()?.height as u32) } @@ -373,10 +383,16 @@ pub async fn commit_route( let passphrase = bip39_passphrase.as_deref(); let secret = keys::identity_secret(&mnemonic, passphrase)?; // One swap per clock second keeps every index behind the clock (see `keys`). + let index_deadline = tokio::time::Instant::now() + INDEX_WAIT; let index = loop { if let Some(index) = store::reserve_index(now_unix())? { break index; } + if tokio::time::Instant::now() >= index_deadline { + return Err(PubkySwapError::invalid( + "The device clock is behind the last swap. Check the date and time, then try again.", + )); + } tokio::time::sleep(std::time::Duration::from_millis(250)).await; }; let swap_keys = keys::swap_keys(&mnemonic, passphrase, index)?; @@ -468,6 +484,7 @@ pub async fn commit_route( claim_txid: None, claim_tx_hex: None, claim_published: false, + claim_seen: false, created_at_unix: now_unix(), }; store::save(&record)?; @@ -523,7 +540,7 @@ fn advance_blocking( .effective_confirmations(record.quote.required_confirmations), }; - let electrum = ElectrumClient::new(&record.electrum_url)?; + let electrum = electrum(&record.electrum_url)?; let tip = electrum.block_headers_subscribe()?.height as u32; let mut history = vec![]; for item in electrum.script_get_history(&htlc_spk)? { @@ -537,9 +554,10 @@ fn advance_blocking( let state = match claim::next_step(&contract, saved, tip, &history) { claim::Step::Report(state) => state, claim::Step::Claimed { txid, state } => { - if record.claim_txid.as_deref() != Some(txid.as_str()) || !record.claim_published { + if record.claim_txid.as_deref() != Some(txid.as_str()) || !record.claim_seen { record.claim_txid = Some(txid); record.claim_published = true; + record.claim_seen = true; store::save(&record)?; } state @@ -576,32 +594,51 @@ fn advance_blocking( Ok(status_of(&record, state)) } -/// Send a saved claim and record once the server took it, since its preimage is then public. A -/// failure is only logged: the next call finds the claim missing from the history and sends it -/// again, inside the safe window unless it was already published, so the app keeps polling. +/// Send a saved claim. It is recorded as published before it goes out: once a broadcast was +/// attempted its preimage may have left the wallet, even if the reply is lost. Only an explicit +/// refusal from the server marks it private again, and a failure is only logged, so the app keeps +/// polling and the next call sends it again. fn publish_claim( electrum: &ElectrumClient, record: &mut SwapRecord, claim: &str, ) -> Result<(), PubkySwapError> { - let sent = bitcoin::consensus::encode::deserialize_hex::(claim) - .map_err(PubkySwapError::storage) - .and_then(|claim| Ok(electrum.transaction_broadcast(&claim)?)); - match sent { - Ok(_) if !record.claim_published => { - record.claim_published = true; + let claim = bitcoin::consensus::encode::deserialize_hex::(claim) + .map_err(PubkySwapError::storage)?; + if !record.claim_published { + record.claim_published = true; + store::save(record)?; + } + match electrum.transaction_broadcast(&claim) { + Ok(_) if !record.claim_seen => { + record.claim_seen = true; store::save(record) } Ok(_) => Ok(()), Err(e) => { log::warn!("pubky swaps: claim broadcast failed, retrying on the next advance: {e}"); + if is_refusal(&e) && !record.claim_seen { + record.claim_published = false; + store::save(record)?; + } Ok(()) } } } -/// One advance at a time, so two overlapping calls cannot save a stale record over the claim. -static ADVANCING: Mutex<()> = Mutex::new(()); +/// Whether the server answered and refused, rather than the reply being lost. +fn is_refusal(error: &electrum_client::Error) -> bool { + match error { + electrum_client::Error::Protocol(_) => true, + electrum_client::Error::AllAttemptsErrored(errors) => errors.iter().all(is_refusal), + _ => false, + } +} + +/// One advance at a time per swap, so two overlapping calls cannot save a stale record over the +/// claim, while a slow call for one swap does not hold up the others. +static ADVANCING: Lazy>>>> = + Lazy::new(Default::default); pub async fn advance_swap( swap_id: String, @@ -609,7 +646,13 @@ pub async fn advance_swap( bip39_passphrase: Option, ) -> Result { tokio::task::spawn_blocking(move || { - let _advancing = ADVANCING.lock().unwrap(); + let lock = ADVANCING + .lock() + .unwrap() + .entry(swap_id.clone()) + .or_default() + .clone(); + let _advancing = lock.lock().unwrap(); let record = store::load(&swap_id)?; advance_blocking(record, &mnemonic, bip39_passphrase.as_deref()) }) @@ -617,6 +660,28 @@ pub async fn advance_swap( .map_err(PubkySwapError::chain)? } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_an_answer_from_the_server_counts_as_a_refusal() { + use electrum_client::Error; + let refused = || Error::Protocol(serde_json::json!({"message": "min relay fee not met"})); + let lost = || Error::IOError(std::io::Error::from(std::io::ErrorKind::TimedOut)); + assert!(is_refusal(&refused())); + assert!(!is_refusal(&lost())); + assert!(is_refusal(&Error::AllAttemptsErrored(vec![ + refused(), + refused() + ]))); + assert!(!is_refusal(&Error::AllAttemptsErrored(vec![ + refused(), + lost() + ]))); + } +} + #[cfg(test)] mod live_tests { //! Against a running regtest provider: diff --git a/src/modules/pubky_swaps/store.rs b/src/modules/pubky_swaps/store.rs index 9f017fe..2fef71f 100644 --- a/src/modules/pubky_swaps/store.rs +++ b/src/modules/pubky_swaps/store.rs @@ -35,9 +35,12 @@ pub(crate) struct SwapRecord { /// The signed claim, kept so it can be broadcast again while it is unconfirmed. #[serde(default)] pub claim_tx_hex: Option, - /// Whether the claim, and with it the preimage, ever reached the server or the chain. + /// Whether a broadcast of the claim was ever attempted, so its preimage may be public. #[serde(default)] pub claim_published: bool, + /// Whether the server accepted the claim or it showed up in the chain's history. + #[serde(default)] + pub claim_seen: bool, pub created_at_unix: u64, } From 4ed4b4e7ea356eeb41e85f44deb75a8acdad489c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 17:46:47 +0100 Subject: [PATCH 18/23] fix: keep retrying a claim an earlier attempt may have published --- src/modules/pubky_swaps/README.md | 13 +++++++------ src/modules/pubky_swaps/client.rs | 11 +++++++---- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/modules/pubky_swaps/README.md b/src/modules/pubky_swaps/README.md index 2880144..e74884b 100644 --- a/src/modules/pubky_swaps/README.md +++ b/src/modules/pubky_swaps/README.md @@ -40,12 +40,13 @@ payloads over this carrier. - The wallet's identity towards providers derives from the seed. Each swap's claim key and preimage derive from the seed and a clock index, with HMAC-SHA512 under their own domain strings. The - index is the Unix time in seconds when the swap starts, or one past the previous index when that - is later, so a restored or reinstalled wallet never repeats a preimage an earlier claim published, - and a restore can find a listed swap's index by scanning a window around its creation time. They - never collide with the Boltz module's keys, and the identity is not the user's Pubky identity. -- A wallet starts at most one swap per second, so an index is never ahead of the clock. - `/pubky_swaps/last_index` is synced to disk before the swap request is sent. + index is the Unix time in seconds when the swap starts, and a wallet starts at most one swap per + second, so an index is never ahead of the clock. A restored or reinstalled wallet therefore never + repeats a preimage an earlier claim published, and a restore can find a listed swap's index by + scanning a window around its creation time. When the clock is behind the last index, as after a + backward clock correction, the commit fails after 3 s instead of waiting. The keys never collide + with the Boltz module's keys, and the identity is not the user's Pubky identity. +- `/pubky_swaps/last_index` is synced to disk before the swap request is sent. `/pubky_swaps/.json` holds the contract, the index and the signed claim, never a secret; it is synced before the invoice reaches the app, and the claim before it is broadcast. diff --git a/src/modules/pubky_swaps/client.rs b/src/modules/pubky_swaps/client.rs index afb8632..882e0ff 100644 --- a/src/modules/pubky_swaps/client.rs +++ b/src/modules/pubky_swaps/client.rs @@ -596,8 +596,8 @@ fn advance_blocking( /// Send a saved claim. It is recorded as published before it goes out: once a broadcast was /// attempted its preimage may have left the wallet, even if the reply is lost. Only an explicit -/// refusal from the server marks it private again, and a failure is only logged, so the app keeps -/// polling and the next call sends it again. +/// refusal of the first attempt marks it private again, and a failure is only logged, so the app +/// keeps polling and the next call sends it again. fn publish_claim( electrum: &ElectrumClient, record: &mut SwapRecord, @@ -605,7 +605,10 @@ fn publish_claim( ) -> Result<(), PubkySwapError> { let claim = bitcoin::consensus::encode::deserialize_hex::(claim) .map_err(PubkySwapError::storage)?; - if !record.claim_published { + // Only a refusal of the very first attempt proves the preimage never left: an earlier attempt + // may have reached the server even if its reply was lost. + let first_attempt = !record.claim_published; + if first_attempt { record.claim_published = true; store::save(record)?; } @@ -617,7 +620,7 @@ fn publish_claim( Ok(_) => Ok(()), Err(e) => { log::warn!("pubky swaps: claim broadcast failed, retrying on the next advance: {e}"); - if is_refusal(&e) && !record.claim_seen { + if first_attempt && is_refusal(&e) { record.claim_published = false; store::save(record)?; } From 39120a2746e9a155d699747f69b146ff3bd95c00 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 18:00:17 +0100 Subject: [PATCH 19/23] fix: send each claim once so a refusal cannot hide an earlier attempt --- src/modules/pubky_swaps/client.rs | 49 ++++++++++++++++++------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/src/modules/pubky_swaps/client.rs b/src/modules/pubky_swaps/client.rs index 882e0ff..3265a3c 100644 --- a/src/modules/pubky_swaps/client.rs +++ b/src/modules/pubky_swaps/client.rs @@ -339,7 +339,14 @@ fn decode_hold_invoice(invoice: &str) -> Result Result { - let config = ConfigBuilder::new().timeout(Some(ELECTRUM_TIMEOUT)).build(); + electrum_with_retries(url, 1) +} + +fn electrum_with_retries(url: &str, retries: u8) -> Result { + let config = ConfigBuilder::new() + .timeout(Some(ELECTRUM_TIMEOUT)) + .retry(retries) + .build(); Ok(ElectrumClient::from_config(url, config)?) } @@ -565,7 +572,7 @@ fn advance_blocking( claim::Step::Rebroadcast => { let txid = record.claim_txid.clone().unwrap_or_default(); match record.claim_tx_hex.clone() { - Some(claim) => publish_claim(&electrum, &mut record, &claim)?, + Some(claim) => publish_claim(&mut record, &claim)?, None => log::warn!("pubky swaps: no saved claim to broadcast again for {txid}"), } PubkySwapRouteState::ClaimBroadcast { txid } @@ -587,7 +594,7 @@ fn advance_blocking( record.claim_txid = Some(txid.clone()); record.claim_tx_hex = Some(claim.clone()); store::save(&record)?; - publish_claim(&electrum, &mut record, &claim)?; + publish_claim(&mut record, &claim)?; PubkySwapRouteState::ClaimBroadcast { txid } } }; @@ -598,13 +605,18 @@ fn advance_blocking( /// attempted its preimage may have left the wallet, even if the reply is lost. Only an explicit /// refusal of the first attempt marks it private again, and a failure is only logged, so the app /// keeps polling and the next call sends it again. -fn publish_claim( - electrum: &ElectrumClient, - record: &mut SwapRecord, - claim: &str, -) -> Result<(), PubkySwapError> { +fn publish_claim(record: &mut SwapRecord, claim: &str) -> Result<(), PubkySwapError> { let claim = bitcoin::consensus::encode::deserialize_hex::(claim) .map_err(PubkySwapError::storage)?; + // No automatic resend: a refusal must answer the one attempt this call made, not a retry that + // hides an earlier attempt whose reply was lost. + let sender = match electrum_with_retries(&record.electrum_url, 0) { + Ok(sender) => sender, + Err(e) => { + log::warn!("pubky swaps: no Electrum connection for the claim, retrying: {e}"); + return Ok(()); + } + }; // Only a refusal of the very first attempt proves the preimage never left: an earlier attempt // may have reached the server even if its reply was lost. let first_attempt = !record.claim_published; @@ -612,7 +624,7 @@ fn publish_claim( record.claim_published = true; store::save(record)?; } - match electrum.transaction_broadcast(&claim) { + match sender.transaction_broadcast(&claim) { Ok(_) if !record.claim_seen => { record.claim_seen = true; store::save(record) @@ -629,13 +641,10 @@ fn publish_claim( } } -/// Whether the server answered and refused, rather than the reply being lost. +/// Whether the server answered and refused, rather than the reply being lost. Only a bare server +/// error counts, from a client that sends once. fn is_refusal(error: &electrum_client::Error) -> bool { - match error { - electrum_client::Error::Protocol(_) => true, - electrum_client::Error::AllAttemptsErrored(errors) => errors.iter().all(is_refusal), - _ => false, - } + matches!(error, electrum_client::Error::Protocol(_)) } /// One advance at a time per swap, so two overlapping calls cannot save a stale record over the @@ -674,13 +683,11 @@ mod tests { let lost = || Error::IOError(std::io::Error::from(std::io::ErrorKind::TimedOut)); assert!(is_refusal(&refused())); assert!(!is_refusal(&lost())); - assert!(is_refusal(&Error::AllAttemptsErrored(vec![ - refused(), - refused() - ]))); + // A transport error after the single attempt, or retries that hid an earlier one. + assert!(!is_refusal(&Error::AllAttemptsErrored(vec![]))); assert!(!is_refusal(&Error::AllAttemptsErrored(vec![ - refused(), - lost() + lost(), + refused() ]))); } } From cab1972f29816e041546f2e61b783804df5b86f8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 19:56:17 +0100 Subject: [PATCH 20/23] feat: restore swaps from the seed through the provider's owner_ref --- Cargo.lock | 20 +-- Cargo.toml | 2 +- src/lib.rs | 32 ++++ src/modules/pubky_swaps/README.md | 30 ++-- src/modules/pubky_swaps/client.rs | 267 +++++++++++++++++++++++++++--- src/modules/pubky_swaps/keys.rs | 75 ++++++--- src/modules/pubky_swaps/mod.rs | 2 +- src/modules/pubky_swaps/store.rs | 50 +----- 8 files changed, 362 insertions(+), 116 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5d274c6..40fff4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -875,7 +875,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes 0.14.1", + "bitcoin_hashes 0.12.0", "rand 0.8.5", "rand_core 0.6.4", "serde", @@ -2421,7 +2421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2764,7 +2764,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", + "windows-link 0.1.3", "windows-result 0.4.1", ] @@ -4657,7 +4657,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6276,7 +6276,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6347,7 +6347,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6567,7 +6567,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "bitcoin_hashes 0.14.1", + "bitcoin_hashes 0.12.0", "rand 0.8.5", "secp256k1-sys 0.10.1", "serde", @@ -7234,7 +7234,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "swap-common" version = "0.1.0" -source = "git+https://github.com/coreyphillips/pubky-swap?rev=8686702#86867029e6da06e7ba787c6ec61667a38789fe7a" +source = "git+https://github.com/coreyphillips/pubky-swap?rev=8c828c7004de22410d628a83c73c8e9c2a417ca4#8c828c7004de22410d628a83c73c8e9c2a417ca4" dependencies = [ "anyhow", "bitcoin 0.32.8", @@ -7360,7 +7360,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8405,7 +8405,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5441a0d..2104623 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ pubky = "0.6.0" pubky-app-specs = "0.4" # Pubky Swaps client: wire types, contract checks and HTLC spends from pubky-swap, # requests over its iroh `pubky-swap/direct/1` protocol, lockups read over Electrum. -swap-common = { git = "https://github.com/coreyphillips/pubky-swap", rev = "8686702" } +swap-common = { git = "https://github.com/coreyphillips/pubky-swap", rev = "8c828c7004de22410d628a83c73c8e9c2a417ca4" } iroh = "=1.2.0" electrum-client = { version = "0.25", default-features = false, features = ["proxy", "use-rustls-ring"] } diff --git a/src/lib.rs b/src/lib.rs index 4773c44..5774dbf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3502,6 +3502,38 @@ pub async fn pubky_swaps_advance( .unwrap_or_else(|e| Err(pubky_swaps_runtime_err(e))) } +/// Rebuild the swaps this wallet started, for a wallet restored from its seed. Each of +/// `providers` lists the swaps it holds for the wallet's swap identity, and the wallet derives +/// their keys from the seed and checks them against each contract. The contact's address is not in +/// the listing, so a restored swap claims to `payout_address`, the wallet's own. Returns the +/// restored swap ids; drive each with `pubky_swaps_advance`. +#[uniffi::export] +pub async fn pubky_swaps_restore( + providers: Vec, + electrum_url: String, + payout_address: String, + fee_rate_sat_vb: u64, + network: Network, + mnemonic: String, + bip39_passphrase: Option, +) -> Result, PubkySwapError> { + let rt = ensure_runtime(); + rt.spawn(async move { + pubky_swaps::restore_swaps( + providers, + electrum_url, + payout_address, + fee_rate_sat_vb, + network.into(), + mnemonic, + bip39_passphrase, + ) + .await + }) + .await + .unwrap_or_else(|e| Err(pubky_swaps_runtime_err(e))) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/modules/pubky_swaps/README.md b/src/modules/pubky_swaps/README.md index e74884b..ec788d8 100644 --- a/src/modules/pubky_swaps/README.md +++ b/src/modules/pubky_swaps/README.md @@ -39,17 +39,25 @@ payloads over this carrier. ## Keys and storage - The wallet's identity towards providers derives from the seed. Each swap's claim key and preimage - derive from the seed and a clock index, with HMAC-SHA512 under their own domain strings. The - index is the Unix time in seconds when the swap starts, and a wallet starts at most one swap per - second, so an index is never ahead of the clock. A restored or reinstalled wallet therefore never - repeats a preimage an earlier claim published, and a restore can find a listed swap's index by - scanning a window around its creation time. When the clock is behind the last index, as after a - backward clock correction, the commit fails after 3 s instead of waiting. The keys never collide - with the Boltz module's keys, and the identity is not the user's Pubky identity. -- `/pubky_swaps/last_index` is synced to disk before the swap request is sent. - `/pubky_swaps/.json` holds the contract, the index and the signed claim, never a + derive from the seed and a random 32-byte index, with HMAC-SHA512 under their own domain strings, + so a restored wallet or a second device on the same seed never repeats a preimage an earlier + claim published. The keys never collide with the Boltz module's keys, and the identity is not the + user's Pubky identity. +- The index is not secret: the swap request carries it as `owner_ref`, which the provider keeps and + lists back (`swap-list-v1`). +- `/pubky_swaps/.json` holds the contract, the index and the signed claim, never a secret; it is synced before the invoice reaches the app, and the claim before it is broadcast. +## Restore + +`pubky_swaps_restore` rebuilds the swaps a wallet started when it has only its seed. Each provider +lists the swaps it holds for the wallet's swap identity; the wallet derives each swap's keys from +the index in its `owner_ref` and checks the payment hash and the contract against them before it +writes a record. The listing does not carry the contact's address, so a restored swap claims to a +payout address the app passes, the wallet's own; a claim the provider already saw is kept as it +is. Other wallets' swaps, swaps without this wallet's `owner_ref` and records the wallet already +has are skipped. `pubky_swaps_advance` then drives each restored swap. + ## Claim `pubky_swaps_advance` claims the lockup once it has its confirmations and while the timeout is more @@ -69,5 +77,5 @@ fixed when the route is quoted and is never bumped, so swap routes are refused o - **Submarine routes** (savings to a Lightning-only contact) and USDT. Pubky Swaps is Bitcoin and Lightning only. - **Refunds and recovery:** a reverse swap needs no wallet refund (the provider cancels its hold - invoice), but there is no scan to rebuild swaps from the seed alone, and swap records are not in - the wallet backup. + invoice). A restore rebuilds swaps from providers' listings, within their retention window, and + claims them to the wallet's own address; swap records are not in the wallet backup. diff --git a/src/modules/pubky_swaps/client.rs b/src/modules/pubky_swaps/client.rs index 3265a3c..99cc802 100644 --- a/src/modules/pubky_swaps/client.rs +++ b/src/modules/pubky_swaps/client.rs @@ -14,8 +14,8 @@ use electrum_client::{Client as ElectrumClient, ConfigBuilder, ElectrumApi}; use once_cell::sync::Lazy; use swap_common::htlc::build_htlc_script; use swap_common::messages::{ - OfferRequest, Quote, QuoteRequest, Reject, SwapMessage, SwapOffer, SwapRequest, SwapScript, - SwapStatusRequest, PROTOCOL_VERSION, + OfferRequest, Quote, QuoteRequest, Reject, SwapListRequest, SwapMessage, SwapOffer, + SwapRequest, SwapScript, SwapStatusRequest, SwapStatusSnapshot, PROTOCOL_VERSION, }; use swap_common::onchain::{build_claim_tx, estimate_spend_fee, spend_vsize}; use swap_common::timelock::TimelockParams; @@ -40,9 +40,6 @@ const OFFER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); /// through iroh's n0 discovery, which sees which keys the wallet looks up. const MAX_CANDIDATES: usize = 30; const ELECTRUM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); -/// Longest wait for a free clock second before a swap request; past it the clock is behind the -/// last swap's index, as after a backward clock correction. -const INDEX_WAIT: std::time::Duration = std::time::Duration::from_secs(3); /// How long the wallet keeps asking about a swap request the provider is still admitting. const PENDING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); const PENDING_POLL: std::time::Duration = std::time::Duration::from_secs(2); @@ -96,7 +93,7 @@ fn claim_fee(destination: &ScriptBuf, fee_rate_sat_vb: u64) -> Result= index_deadline { - return Err(PubkySwapError::invalid( - "The device clock is behind the last swap. Check the date and time, then try again.", - )); - } - tokio::time::sleep(std::time::Duration::from_millis(250)).await; - }; - let swap_keys = keys::swap_keys(&mnemonic, passphrase, index)?; + let index = keys::new_index(); + let swap_keys = keys::swap_keys(&mnemonic, passphrase, &index)?; let request = SwapRequest { script_type: SwapScript::P2wsh, quote_id: quote.quote_id, @@ -412,6 +397,7 @@ pub async fn commit_route( client_claim_pubkey_hex: Some(swap_keys.claim_pubkey.to_string()), client_refund_pubkey_hex: None, invoice: None, + owner_ref: Some(keys::owner_ref(&index)), }; let client = DirectClient::bind(secret).await?; @@ -479,11 +465,12 @@ pub async fn commit_route( let record = SwapRecord { swap_id: accept.swap_id.to_string(), - key_index: index, + key_index: hex::encode(index), provider_pubky: route.provider_pubky.clone(), network, electrum_url, - quote, + required_confirmations: quote.required_confirmations, + quote: Some(quote), accept: accept.clone(), destination_address: route.endpoint.value.clone(), delivered_sat: route.delivered_sat, @@ -526,7 +513,11 @@ fn advance_blocking( mnemonic: &str, passphrase: Option<&str>, ) -> Result { - let keys = keys::swap_keys(mnemonic, passphrase, record.key_index)?; + let index: [u8; 32] = hex::decode(&record.key_index) + .ok() + .and_then(|index| index.try_into().ok()) + .ok_or_else(|| PubkySwapError::storage("invalid key index"))?; + let keys = keys::swap_keys(mnemonic, passphrase, &index)?; let provider = record .accept .provider_pubkey_hex @@ -544,7 +535,7 @@ fn advance_blocking( delivered_sat: record.delivered_sat, timeout, required_confirmations: policy(record.network) - .effective_confirmations(record.quote.required_confirmations), + .effective_confirmations(record.required_confirmations), }; let electrum = electrum(&record.electrum_url)?; @@ -652,6 +643,146 @@ fn is_refusal(error: &electrum_client::Error) -> bool { static ADVANCING: Lazy>>>> = Lazy::new(Default::default); +/// Rebuild the records of reverse swaps this wallet started, for a wallet restored from its seed: +/// each provider lists the swaps it holds for the wallet's swap identity (`swap-list-v1`), the +/// wallet derives each swap's keys from the index in its `owner_ref`, and checks them against the +/// listed contract before writing a record. The contact's address is not in the listing, so a +/// restored swap claims to `payout_address`, the wallet's own. Swaps already on record, other +/// wallets' swaps and contracts that do not match are skipped. Returns the restored swap ids; +/// `advance_swap` then drives each like any other. +pub async fn restore_swaps( + providers: Vec, + electrum_url: String, + payout_address: String, + fee_rate_sat_vb: u64, + network: bitcoin::Network, + mnemonic: String, + bip39_passphrase: Option, +) -> Result, PubkySwapError> { + let payout = parse_address(&payout_address, network)?.script_pubkey(); + let claim_fee_sat = claim_fee(&payout, fee_rate_sat_vb)?; + let secret = keys::identity_secret(&mnemonic, bip39_passphrase.as_deref())?; + let client = DirectClient::bind(secret).await?; + let mut seen = std::collections::HashSet::new(); + let mut restored = vec![]; + for provider in providers.iter().map(|p| bare_pubky(p).to_string()) { + if !seen.insert(provider.clone()) { + continue; + } + let snapshots = match list_swaps(&client, &provider).await { + Ok(snapshots) => snapshots, + Err(e) => { + log::warn!("pubky swaps: no swap list from {provider}: {e}"); + continue; + } + }; + for snapshot in snapshots { + let swap_id = snapshot.accept.swap_id.to_string(); + if store::load(&swap_id).is_ok() { + continue; + } + let record = match restored_record( + &snapshot, + &provider, + &electrum_url, + &payout_address, + claim_fee_sat, + network, + &mnemonic, + bip39_passphrase.as_deref(), + ) { + Some(record) => record, + None => continue, + }; + store::save(&record)?; + restored.push(swap_id); + } + } + client.close().await; + Ok(restored) +} + +/// Every swap a provider lists for this wallet's identity, page by page. +async fn list_swaps( + client: &DirectClient, + provider: &str, +) -> Result, PubkySwapError> { + let mut snapshots = vec![]; + let mut cursor = None; + loop { + let request = SwapMessage::SwapListRequest(SwapListRequest { + request_id: Some(Uuid::new_v4()), + cursor: cursor.take(), + limit: None, + active_only: false, + }); + let page = match client.request(provider, &request).await? { + SwapMessage::SwapList(page) => page, + SwapMessage::Reject(reject) => return Err(refused(reject)), + other => return Err(unexpected("SwapList", &other)), + }; + snapshots.extend(page.snapshots); + match page.next_cursor { + Some(next) => cursor = Some(next), + None => return Ok(snapshots), + } + } +} + +/// The record for a listed reverse swap whose keys this wallet derives from its `owner_ref`, or +/// `None` when the swap is not this wallet's or its contract does not match those keys. +#[allow(clippy::too_many_arguments)] +fn restored_record( + snapshot: &SwapStatusSnapshot, + provider: &str, + electrum_url: &str, + payout_address: &str, + claim_fee_sat: u64, + network: bitcoin::Network, + mnemonic: &str, + passphrase: Option<&str>, +) -> Option { + let accept = &snapshot.accept; + if accept.direction != SwapDirection::Reverse || accept.script_type != SwapScript::P2wsh { + return None; + } + let index = keys::index_from_owner_ref(snapshot.owner_ref.as_deref()?)?; + let keys = keys::swap_keys(mnemonic, passphrase, &index).ok()?; + if hex::encode(keys.payment_hash) != snapshot.payment_hash_hex { + return None; + } + verify_contract( + accept, + SwapDirection::Reverse, + &keys.payment_hash, + &keys.claim_pubkey, + network, + ) + .ok()?; + let delivered_sat = accept.onchain_amount_sat.checked_sub(claim_fee_sat)?; + // A claim the provider already saw went to the contact: keep it, rather than read it as a + // refund for not paying `payout_address`. + let claim_txid = snapshot.claim_observed_txid_hex.clone(); + Some(SwapRecord { + swap_id: accept.swap_id.to_string(), + key_index: hex::encode(index), + provider_pubky: provider.to_string(), + network, + electrum_url: electrum_url.to_string(), + quote: None, + required_confirmations: snapshot.required_confirmations, + accept: accept.clone(), + destination_address: payout_address.to_string(), + delivered_sat, + claim_fee_sat, + claim_published: claim_txid.is_some(), + claim_seen: claim_txid.is_some(), + claim_txid, + claim_tx_hex: None, + created_at_unix: snapshot.created_at_unix, + }) +} + pub async fn advance_swap( swap_id: String, mnemonic: String, @@ -676,6 +807,94 @@ pub async fn advance_swap( mod tests { use super::*; + const SEED: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const OTHER_SEED: &str = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; + const PAYOUT: &str = "bcrt1qdp8zwu9xk08p3dvkz4qyavmfaadkr4lnvwfah2"; + + fn listed_swap(mnemonic: &str, owner_ref: Option) -> SwapStatusSnapshot { + let index = owner_ref + .as_deref() + .and_then(keys::index_from_owner_ref) + .unwrap_or([7; 32]); + let keys = keys::swap_keys(mnemonic, None, &index).unwrap(); + let provider_secret = bitcoin::secp256k1::SecretKey::from_slice(&[9; 32]).unwrap(); + let provider = bitcoin::PublicKey::new( + provider_secret.public_key(&bitcoin::secp256k1::Secp256k1::new()), + ); + SwapStatusSnapshot { + request_id: None, + accept: swap_common::messages::SwapAccept { + script_type: SwapScript::P2wsh, + swap_tree: None, + quote_id: Uuid::new_v4(), + swap_id: Uuid::new_v4(), + direction: SwapDirection::Reverse, + htlc_script_hex: String::new(), + htlc_address: String::new(), + onchain_amount_sat: 20_300, + timeout_block_height: 1_144, + provider_pubkey_hex: provider.to_string(), + invoice: None, + }, + network: swap_common::NetworkSpec::Regtest, + state: swap_common::SwapState::LockupConfirmed, + funding_txid_hex: None, + funding_vout: None, + spend_txid_hex: None, + claim_observed_txid_hex: None, + required_confirmations: 1, + updated_at_unix: 0, + observed_at_unix: 0, + created_at_unix: 1_000, + payment_hash_hex: hex::encode(keys.payment_hash), + owner_ref, + } + } + + fn restore(snapshot: &SwapStatusSnapshot) -> Option { + restored_record( + snapshot, + "provider", + "tcp://127.0.0.1:60001", + PAYOUT, + 300, + bitcoin::Network::Regtest, + SEED, + None, + ) + } + + #[test] + fn restores_a_listed_swap_from_the_index_in_its_owner_ref() { + let index = keys::new_index(); + let snapshot = listed_swap(SEED, Some(keys::owner_ref(&index))); + let record = restore(&snapshot).expect("restored"); + assert_eq!(record.key_index, hex::encode(index)); + assert_eq!(record.destination_address, PAYOUT); + assert_eq!(record.delivered_sat, 20_000); + assert_eq!(record.required_confirmations, 1); + assert!(record.quote.is_none() && record.claim_txid.is_none()); + } + + #[test] + fn skips_swaps_this_wallet_cannot_rebuild() { + let other_wallets = listed_swap(OTHER_SEED, Some(keys::owner_ref(&keys::new_index()))); + assert!(restore(&other_wallets).is_none()); + assert!(restore(&listed_swap(SEED, None)).is_none()); + assert!(restore(&listed_swap(SEED, Some("someone-else:1".into()))).is_none()); + } + + #[test] + fn keeps_a_claim_the_provider_already_saw() { + let mut snapshot = listed_swap(SEED, Some(keys::owner_ref(&keys::new_index()))); + snapshot.claim_observed_txid_hex = Some("ab".repeat(32)); + let record = restore(&snapshot).expect("restored"); + assert_eq!(record.claim_txid, snapshot.claim_observed_txid_hex); + assert!(record.claim_published && record.claim_seen); + } + #[test] fn only_an_answer_from_the_server_counts_as_a_refusal() { use electrum_client::Error; diff --git a/src/modules/pubky_swaps/keys.rs b/src/modules/pubky_swaps/keys.rs index c5550a2..0329e6d 100644 --- a/src/modules/pubky_swaps/keys.rs +++ b/src/modules/pubky_swaps/keys.rs @@ -1,16 +1,14 @@ -//! Swap keys derived from the wallet seed and a clock index, so a swap can be rebuilt from the -//! seed alone. +//! Swap keys derived from the wallet seed and a random per-swap index, so a swap can be rebuilt +//! from the seed and the index. //! //! The provider sees only public keys and the payment hash. Each key is //! HMAC-SHA512(seed, domain || index), truncated to 32 bytes, under its own domain string, so //! nothing here can collide with the Boltz module's BIP85 keys or with each other. //! -//! The index is the Unix time in seconds when the swap starts, and a wallet starts at most one swap -//! per second, so an index is never ahead of the clock. A restored or reinstalled wallet starts -//! after every index it used, so it never derives a preimage an earlier claim published. A restore -//! finds each listed swap's index by scanning a window around its creation time for the swap's -//! payment hash. Two devices on one seed still derive the same keys if both start a swap in the -//! same second. +//! The index is 32 random bytes, so a restored wallet or a second device on the same seed never +//! repeats a preimage an earlier claim published. It is not secret: the wallet sends it to the +//! provider as the swap's `owner_ref`, which the provider keeps and lists back (`swap-list-v1`), so +//! a wallet restored from its seed reads the index and derives the keys again. use bitcoin::hashes::{hmac, sha256, sha512, Hash, HashEngine}; use bitcoin::secp256k1::{Secp256k1, SecretKey}; @@ -18,8 +16,10 @@ use bitcoin::secp256k1::{Secp256k1, SecretKey}; use super::errors::PubkySwapError; const IDENTITY_DOMAIN: &[u8] = b"bitkit/pubky-swaps/identity/v1"; -const CLAIM_DOMAIN: &[u8] = b"bitkit/pubky-swaps/claim/v3/"; -const PREIMAGE_DOMAIN: &[u8] = b"bitkit/pubky-swaps/preimage/v3/"; +const CLAIM_DOMAIN: &[u8] = b"bitkit/pubky-swaps/claim/v4/"; +const PREIMAGE_DOMAIN: &[u8] = b"bitkit/pubky-swaps/preimage/v4/"; +/// Marks an `owner_ref` this wallet wrote: the version of the key scheme, then the index in hex. +const OWNER_REF_PREFIX: &str = "bitkit-swap-v4:"; pub(crate) struct SwapKeys { pub claim_key: SecretKey, @@ -53,21 +53,34 @@ pub(crate) fn identity_secret( Ok(derive(&seed(mnemonic, passphrase)?, IDENTITY_DOMAIN, &[])) } -/// The index a new swap takes at `now_unix`, or `None` while this second already has a swap. -pub(crate) fn next_index(last: Option, now_unix: u64) -> Option { - last.is_none_or(|last| now_unix > last).then_some(now_unix) +/// A fresh random index for a new swap's keys. +pub(crate) fn new_index() -> [u8; 32] { + rand::random() +} + +/// The `owner_ref` that carries `index` to the provider. +pub(crate) fn owner_ref(index: &[u8; 32]) -> String { + format!("{OWNER_REF_PREFIX}{}", hex::encode(index)) +} + +/// The index in an `owner_ref` this wallet wrote, or `None` for anything else. +pub(crate) fn index_from_owner_ref(owner_ref: &str) -> Option<[u8; 32]> { + hex::decode(owner_ref.strip_prefix(OWNER_REF_PREFIX)?) + .ok()? + .try_into() + .ok() } pub(crate) fn swap_keys( mnemonic: &str, passphrase: Option<&str>, - index: u64, + index: &[u8; 32], ) -> Result { let seed = seed(mnemonic, passphrase)?; - let claim_key = SecretKey::from_slice(&derive(&seed, CLAIM_DOMAIN, &index.to_be_bytes())) + let claim_key = SecretKey::from_slice(&derive(&seed, CLAIM_DOMAIN, index)) .map_err(|e| PubkySwapError::invalid(format!("claim key: {e}")))?; let claim_pubkey = bitcoin::PublicKey::new(claim_key.public_key(&Secp256k1::new())); - let preimage = derive(&seed, PREIMAGE_DOMAIN, &index.to_be_bytes()); + let preimage = derive(&seed, PREIMAGE_DOMAIN, index); let payment_hash = sha256::Hash::hash(&preimage).to_byte_array(); Ok(SwapKeys { claim_key, @@ -86,9 +99,9 @@ mod tests { #[test] fn keys_are_deterministic_and_distinct_per_index() { - let a = swap_keys(MNEMONIC, None, 1_793_600_000).unwrap(); - let again = swap_keys(MNEMONIC, None, 1_793_600_000).unwrap(); - let b = swap_keys(MNEMONIC, None, 1_793_600_001).unwrap(); + let a = swap_keys(MNEMONIC, None, &[0; 32]).unwrap(); + let again = swap_keys(MNEMONIC, None, &[0; 32]).unwrap(); + let b = swap_keys(MNEMONIC, None, &[1; 32]).unwrap(); assert_eq!(a.claim_key, again.claim_key); assert_eq!(a.preimage, again.preimage); assert_ne!(a.claim_key, b.claim_key); @@ -98,8 +111,8 @@ mod tests { #[test] fn passphrase_changes_every_key() { - let plain = swap_keys(MNEMONIC, None, 0).unwrap(); - let with = swap_keys(MNEMONIC, Some("x"), 0).unwrap(); + let plain = swap_keys(MNEMONIC, None, &[0; 32]).unwrap(); + let with = swap_keys(MNEMONIC, Some("x"), &[0; 32]).unwrap(); assert_ne!(plain.claim_key, with.claim_key); assert_ne!( identity_secret(MNEMONIC, None).unwrap(), @@ -108,11 +121,19 @@ mod tests { } #[test] - fn indexes_follow_the_clock_and_never_run_ahead_of_it() { - assert_eq!(next_index(None, 1_000), Some(1_000)); - assert_eq!(next_index(Some(900), 1_000), Some(1_000)); - // A second swap in the same second waits for the next one. - assert_eq!(next_index(Some(1_000), 1_000), None); - assert_eq!(next_index(Some(1_000), 1_001), Some(1_001)); + fn fresh_indexes_never_repeat_a_preimage() { + let a = swap_keys(MNEMONIC, None, &new_index()).unwrap(); + let b = swap_keys(MNEMONIC, None, &new_index()).unwrap(); + assert_ne!(a.payment_hash, b.payment_hash); + } + + #[test] + fn the_index_round_trips_through_an_owner_ref_the_provider_keeps() { + let index = new_index(); + let owner_ref = owner_ref(&index); + assert!(swap_common::messages::owner_ref_is_valid(&owner_ref)); + assert_eq!(index_from_owner_ref(&owner_ref), Some(index)); + assert_eq!(index_from_owner_ref("someone-else:00"), None); + assert_eq!(index_from_owner_ref("bitkit-swap-v4:zz"), None); } } diff --git a/src/modules/pubky_swaps/mod.rs b/src/modules/pubky_swaps/mod.rs index 4950f49..d2f47b5 100644 --- a/src/modules/pubky_swaps/mod.rs +++ b/src/modules/pubky_swaps/mod.rs @@ -11,7 +11,7 @@ mod store; mod transport; mod types; -pub use client::{advance_swap, commit_route, provider_edges, route_paykit_payment}; +pub use client::{advance_swap, commit_route, provider_edges, restore_swaps, route_paykit_payment}; pub use errors::PubkySwapError; pub(crate) use store::init; pub use types::*; diff --git a/src/modules/pubky_swaps/store.rs b/src/modules/pubky_swaps/store.rs index 2fef71f..8e68cc3 100644 --- a/src/modules/pubky_swaps/store.rs +++ b/src/modules/pubky_swaps/store.rs @@ -1,8 +1,7 @@ //! One JSON file per swap under `/pubky_swaps/`. //! //! A record holds only public data and the swap's key index; the keys come from the seed and the -//! index. The index is synced to disk before the swap request is sent, and the contract before the -//! invoice is handed to the app. +//! index. The contract is synced to disk before the invoice is handed to the app. use std::fs; use std::io::Write; @@ -21,12 +20,16 @@ static LOCK: Mutex<()> = Mutex::new(()); #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct SwapRecord { pub swap_id: String, - /// The clock index the swap's claim key and preimage derive from (see `keys`). - pub key_index: u64, + /// Hex of the random index the swap's claim key and preimage derive from (see `keys`). + pub key_index: String, pub provider_pubky: String, pub network: bitcoin::Network, pub electrum_url: String, - pub quote: Quote, + /// The quote this wallet committed to; absent for a swap rebuilt from a provider's listing. + #[serde(default)] + pub quote: Option, + /// Lockup confirmations the provider requires before the claim. + pub required_confirmations: u32, pub accept: SwapAccept, pub destination_address: String, pub delivered_sat: u64, @@ -70,24 +73,6 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), PubkySwapError> { Ok(()) } -/// Take the index for a new swap at `now_unix` and sync it to disk before it is used, or `None` -/// while this second already has a swap. Without a stored index, as after a restore, the clock -/// alone is past every index used before. -pub(crate) fn reserve_index(now_unix: u64) -> Result, PubkySwapError> { - let _guard = LOCK.lock().unwrap(); - let path = dir()?.join("last_index"); - let last = match fs::read_to_string(&path) { - Ok(s) => Some(s.trim().parse().map_err(PubkySwapError::storage)?), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, - Err(e) => return Err(e.into()), - }; - let Some(index) = super::keys::next_index(last, now_unix) else { - return Ok(None); - }; - write_atomic(&path, index.to_string().as_bytes())?; - Ok(Some(index)) -} - pub(crate) fn save(record: &SwapRecord) -> Result<(), PubkySwapError> { let _guard = LOCK.lock().unwrap(); let path = dir()?.join(format!("{}.json", record.swap_id)); @@ -104,22 +89,3 @@ pub(crate) fn load(swap_id: &str) -> Result { Err(e) => Err(e.into()), } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reserves_increasing_indexes_and_never_repeats_after_a_wipe() { - let base = std::env::temp_dir().join(format!("pubky-swaps-index-{}", uuid::Uuid::new_v4())); - init(base.to_str().unwrap()).unwrap(); - assert_eq!(reserve_index(1_000).unwrap(), Some(1_000)); - assert_eq!(reserve_index(1_000).unwrap(), None); - assert_eq!(reserve_index(1_001).unwrap(), Some(1_001)); - // A restore starts without the stored index, in a later second. - fs::remove_dir_all(&base).unwrap(); - init(base.to_str().unwrap()).unwrap(); - assert_eq!(reserve_index(1_002).unwrap(), Some(1_002)); - fs::remove_dir_all(&base).ok(); - } -} From 0f93ce06fc12adce03121641c6bbe35d0bbfbff8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 19:56:18 +0100 Subject: [PATCH 21/23] chore: regenerate ios bindings for swap restore --- bindings/ios/bitkitcore.swift | 24 ++++++++++++++++++++++++ bindings/ios/bitkitcoreFFI.h | 11 +++++++++++ 2 files changed, 35 insertions(+) diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index fb32777..c0f5b65 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -31948,6 +31948,27 @@ public func pubkySwapsProviderEdges(providerPubky: String, mnemonic: String, bip errorHandler: FfiConverterTypePubkySwapError_lift ) } +/** + * Rebuild the swaps this wallet started, for a wallet restored from its seed. Each of + * `providers` lists the swaps it holds for the wallet's swap identity, and the wallet derives + * their keys from the seed and checks them against each contract. The contact's address is not in + * the listing, so a restored swap claims to `payout_address`, the wallet's own. Returns the + * restored swap ids; drive each with `pubky_swaps_advance`. + */ +public func pubkySwapsRestore(providers: [String], electrumUrl: String, payoutAddress: String, feeRateSatVb: UInt64, network: Network, mnemonic: String, bip39Passphrase: String?)async throws -> [String] { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_pubky_swaps_restore(FfiConverterSequenceString.lower(providers),FfiConverterString.lower(electrumUrl),FfiConverterString.lower(payoutAddress),FfiConverterUInt64.lower(feeRateSatVb),FfiConverterTypeNetwork_lower(network),FfiConverterString.lower(mnemonic),FfiConverterOptionString.lower(bip39Passphrase) + ) + }, + 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: FfiConverterSequenceString.lift, + errorHandler: FfiConverterTypePubkySwapError_lift + ) +} /** * Pick how to pay a Paykit contact. Returns `Direct` when a balance the wallet holds reaches * one of the contact's endpoints, otherwise the cheapest firm quote from the trusted Pubky @@ -33280,6 +33301,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_func_pubky_swaps_provider_edges() != 4359) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_func_pubky_swaps_restore() != 21980) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_func_pubky_swaps_route_paykit_payment() != 51456) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index 4f4cb61..e5b5c98 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -1586,6 +1586,11 @@ uint64_t uniffi_bitkitcore_fn_func_pubky_swaps_commit_route(RustBuffer quote_id, uint64_t uniffi_bitkitcore_fn_func_pubky_swaps_provider_edges(RustBuffer provider_pubky, RustBuffer mnemonic, RustBuffer bip39_passphrase ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_PUBKY_SWAPS_RESTORE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_PUBKY_SWAPS_RESTORE +uint64_t uniffi_bitkitcore_fn_func_pubky_swaps_restore(RustBuffer providers, RustBuffer electrum_url, RustBuffer payout_address, uint64_t fee_rate_sat_vb, RustBuffer network, RustBuffer mnemonic, RustBuffer bip39_passphrase +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_PUBKY_SWAPS_ROUTE_PAYKIT_PAYMENT #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_PUBKY_SWAPS_ROUTE_PAYKIT_PAYMENT uint64_t uniffi_bitkitcore_fn_func_pubky_swaps_route_paykit_payment(RustBuffer request, RustBuffer network, RustBuffer mnemonic, RustBuffer bip39_passphrase @@ -3100,6 +3105,12 @@ uint16_t uniffi_bitkitcore_checksum_func_pubky_swaps_commit_route(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PUBKY_SWAPS_PROVIDER_EDGES uint16_t uniffi_bitkitcore_checksum_func_pubky_swaps_provider_edges(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PUBKY_SWAPS_RESTORE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PUBKY_SWAPS_RESTORE +uint16_t uniffi_bitkitcore_checksum_func_pubky_swaps_restore(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PUBKY_SWAPS_ROUTE_PAYKIT_PAYMENT From f07bccb3b9d0dbdb99decc8563f78e21a3df1305 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 19:57:52 +0100 Subject: [PATCH 22/23] chore: pin pubky-swap at the merged swap listing --- Cargo.lock | 16 ++++++++-------- Cargo.toml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40fff4a..f2d9384 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -875,7 +875,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes 0.12.0", + "bitcoin_hashes 0.14.1", "rand 0.8.5", "rand_core 0.6.4", "serde", @@ -2421,7 +2421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6276,7 +6276,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6347,7 +6347,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6567,7 +6567,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "bitcoin_hashes 0.12.0", + "bitcoin_hashes 0.14.1", "rand 0.8.5", "secp256k1-sys 0.10.1", "serde", @@ -7234,7 +7234,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "swap-common" version = "0.1.0" -source = "git+https://github.com/coreyphillips/pubky-swap?rev=8c828c7004de22410d628a83c73c8e9c2a417ca4#8c828c7004de22410d628a83c73c8e9c2a417ca4" +source = "git+https://github.com/coreyphillips/pubky-swap?rev=1801c8080d44591877f5c5ebe70ab87fe57b8ee0#1801c8080d44591877f5c5ebe70ab87fe57b8ee0" dependencies = [ "anyhow", "bitcoin 0.32.8", @@ -7360,7 +7360,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8405,7 +8405,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2104623..0effee8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ pubky = "0.6.0" pubky-app-specs = "0.4" # Pubky Swaps client: wire types, contract checks and HTLC spends from pubky-swap, # requests over its iroh `pubky-swap/direct/1` protocol, lockups read over Electrum. -swap-common = { git = "https://github.com/coreyphillips/pubky-swap", rev = "8c828c7004de22410d628a83c73c8e9c2a417ca4" } +swap-common = { git = "https://github.com/coreyphillips/pubky-swap", rev = "1801c8080d44591877f5c5ebe70ab87fe57b8ee0" } iroh = "=1.2.0" electrum-client = { version = "0.25", default-features = false, features = ["proxy", "use-rustls-ring"] } From 3d36a274c8f1ea2bd9e551db7b602700f958f880 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 3 Nov 2026 04:50:12 +0100 Subject: [PATCH 23/23] fix: claim restored swaps and bound swap lists --- src/modules/pubky_swaps/claim.rs | 29 +++++++--- src/modules/pubky_swaps/client.rs | 89 ++++++++++++++++++++----------- 2 files changed, 82 insertions(+), 36 deletions(-) diff --git a/src/modules/pubky_swaps/claim.rs b/src/modules/pubky_swaps/claim.rs index 44765e4..d15d54a 100644 --- a/src/modules/pubky_swaps/claim.rs +++ b/src/modules/pubky_swaps/claim.rs @@ -26,9 +26,11 @@ pub(crate) enum Step { txid: String, state: PubkySwapRouteState, }, - /// The saved claim is not in the history: broadcast it again. Once it was published its - /// preimage is public, so it goes out whatever the window; before that, only inside it. - Rebroadcast, + /// The saved claim is not in the history: broadcast it again, or a new claim of this lockup + /// when the record holds none (a restored swap whose claim the provider saw). Once a claim was + /// published its preimage is public, so it goes out whatever the window; before that, only + /// inside it. + Rebroadcast(OutPoint), /// Build, save and broadcast the claim of this lockup. Claim(OutPoint), } @@ -106,7 +108,7 @@ pub(crate) fn next_step( let safe_to_reveal = claim_start_is_safe(tip, contract.timeout); if let Some(claim) = saved_claim { if claim.published || safe_to_reveal { - return Step::Rebroadcast; + return Step::Rebroadcast(outpoint); } } if !safe_to_reveal { @@ -317,7 +319,22 @@ mod tests { // Also past the claim window: the preimage is public, so the claim must go out. assert_eq!( f.step(Some(&saved), TIMEOUT - 1, &[(1_000, f.lockup.clone())]), - Step::Rebroadcast + Step::Rebroadcast(f.outpoint()) + ); + } + + #[test] + fn claims_again_when_a_claim_the_provider_saw_left_the_mempool() { + let f = Fixture::new(); + // A restored swap holds only the txid the provider observed, published, and no claim to + // resend: the client builds a new one from this outpoint, past the window too. + assert_eq!( + f.step( + Some("observed-by-the-provider"), + TIMEOUT + 5, + &[(1_000, f.lockup.clone())] + ), + Step::Rebroadcast(f.outpoint()) ); } @@ -342,7 +359,7 @@ mod tests { let history = [(1_000, f.lockup.clone())]; assert_eq!( f.step_unpublished(&saved, TIMEOUT - 7, &history), - Step::Rebroadcast + Step::Rebroadcast(f.outpoint()) ); assert!(matches!( f.step_unpublished(&saved, TIMEOUT + 5, &history), diff --git a/src/modules/pubky_swaps/client.rs b/src/modules/pubky_swaps/client.rs index 99cc802..647e5f9 100644 --- a/src/modules/pubky_swaps/client.rs +++ b/src/modules/pubky_swaps/client.rs @@ -9,7 +9,7 @@ use std::str::FromStr; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; -use bitcoin::{Address, ScriptBuf}; +use bitcoin::{Address, OutPoint, ScriptBuf}; use electrum_client::{Client as ElectrumClient, ConfigBuilder, ElectrumApi}; use once_cell::sync::Lazy; use swap_common::htlc::build_htlc_script; @@ -43,6 +43,8 @@ const ELECTRUM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30) /// How long the wallet keeps asking about a swap request the provider is still admitting. const PENDING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); const PENDING_POLL: std::time::Duration = std::time::Duration::from_secs(2); +/// Most pages read from one provider's swap list on restore. +const MAX_LIST_PAGES: usize = 100; /// Quotes the router fetched, kept until the wallet commits to one of them. static QUOTES: Lazy>> = Lazy::new(Default::default); @@ -560,38 +562,52 @@ fn advance_blocking( } state } - claim::Step::Rebroadcast => { - let txid = record.claim_txid.clone().unwrap_or_default(); - match record.claim_tx_hex.clone() { - Some(claim) => publish_claim(&mut record, &claim)?, - None => log::warn!("pubky swaps: no saved claim to broadcast again for {txid}"), + claim::Step::Rebroadcast(outpoint) => match record.claim_tx_hex.clone() { + Some(claim) => { + publish_claim(&mut record, &claim)?; + PubkySwapRouteState::ClaimBroadcast { + txid: record.claim_txid.clone().unwrap_or_default(), + } } - PubkySwapRouteState::ClaimBroadcast { txid } - } + // A restored swap knows only the txid of the claim the provider saw. Its preimage is + // public, so a claim of our own goes out whatever the window. + None => claim_lockup(&mut record, outpoint, &redeem_script, &destination, &keys)?, + }, claim::Step::Claim(outpoint) => { - let claim = build_claim_tx( - outpoint, - record.accept.onchain_amount_sat, - &redeem_script, - destination.clone(), - record.claim_fee_sat, - keys.preimage, - &keys.claim_key, - ) - .map_err(PubkySwapError::chain)?; - let txid = claim.compute_txid().to_string(); - let claim = bitcoin::consensus::encode::serialize_hex(&claim); - // Saved before the broadcast, so a restart in between finds and sends the same claim. - record.claim_txid = Some(txid.clone()); - record.claim_tx_hex = Some(claim.clone()); - store::save(&record)?; - publish_claim(&mut record, &claim)?; - PubkySwapRouteState::ClaimBroadcast { txid } + claim_lockup(&mut record, outpoint, &redeem_script, &destination, &keys)? } }; Ok(status_of(&record, state)) } +/// Build the claim of `outpoint` to the contact, save it, then broadcast it. +fn claim_lockup( + record: &mut SwapRecord, + outpoint: OutPoint, + redeem_script: &ScriptBuf, + destination: &ScriptBuf, + keys: &keys::SwapKeys, +) -> Result { + let claim = build_claim_tx( + outpoint, + record.accept.onchain_amount_sat, + redeem_script, + destination.clone(), + record.claim_fee_sat, + keys.preimage, + &keys.claim_key, + ) + .map_err(PubkySwapError::chain)?; + let txid = claim.compute_txid().to_string(); + let claim = bitcoin::consensus::encode::serialize_hex(&claim); + // Saved before the broadcast, so a restart in between finds and sends the same claim. + record.claim_txid = Some(txid.clone()); + record.claim_tx_hex = Some(claim.clone()); + store::save(record)?; + publish_claim(record, &claim)?; + Ok(PubkySwapRouteState::ClaimBroadcast { txid }) +} + /// Send a saved claim. It is recorded as published before it goes out: once a broadcast was /// attempted its preimage may have left the wallet, even if the reply is lost. Only an explicit /// refusal of the first attempt marks it private again, and a failure is only logged, so the app @@ -694,7 +710,11 @@ pub async fn restore_swaps( Some(record) => record, None => continue, }; - store::save(&record)?; + // One swap that cannot be saved must not cost the user the others. + if let Err(e) = store::save(&record) { + log::warn!("pubky swaps: could not save restored swap {swap_id}: {e}"); + continue; + } restored.push(swap_id); } } @@ -702,14 +722,17 @@ pub async fn restore_swaps( Ok(restored) } -/// Every swap a provider lists for this wallet's identity, page by page. +/// Every swap a provider lists for this wallet's identity, page by page. A provider that keeps +/// sending cursors stops at [`MAX_LIST_PAGES`] or at a cursor it sent before, so it cannot hold up +/// the restore of the providers after it. async fn list_swaps( client: &DirectClient, provider: &str, ) -> Result, PubkySwapError> { let mut snapshots = vec![]; let mut cursor = None; - loop { + let mut cursors_seen = std::collections::HashSet::new(); + for _ in 0..MAX_LIST_PAGES { let request = SwapMessage::SwapListRequest(SwapListRequest { request_id: Some(Uuid::new_v4()), cursor: cursor.take(), @@ -723,10 +746,16 @@ async fn list_swaps( }; snapshots.extend(page.snapshots); match page.next_cursor { - Some(next) => cursor = Some(next), + Some(next) if cursors_seen.insert(next.clone()) => cursor = Some(next), + Some(next) => { + log::warn!("pubky swaps: {provider} sent cursor {next} again; ending its list"); + return Ok(snapshots); + } None => return Ok(snapshots), } } + log::warn!("pubky swaps: {provider} listed more than {MAX_LIST_PAGES} pages; ending its list"); + Ok(snapshots) } /// The record for a listed reverse swap whose keys this wallet derives from its `owner_ref`, or