Compare commits

..

9 Commits

Author SHA1 Message Date
nym21 17478a4ac4 website: redesign part 34 2026-06-22 16:42:14 +02:00
nym21 b3031b3375 website: redesign part 33 2026-06-21 17:12:25 +02:00
nym21 2e401379a0 deps: bumped 2026-06-21 17:11:43 +02:00
nym21 45ab6ebf71 website: redesign part 32 2026-06-19 23:16:27 +02:00
nym21 00f7d69ea6 website: redesign part 31 2026-06-18 22:39:28 +02:00
nym21 408d83c350 global: private xpub support part 3 2026-06-17 21:23:26 +02:00
nym21 43df9e098c global: private xpub support part 2 2026-06-17 11:25:42 +02:00
nym21 0c7861071d global: private xpub support part 1 2026-06-16 23:37:03 +02:00
nym21 6f430bdb8c clients: bump versions 2026-06-15 16:24:56 +02:00
113 changed files with 7624 additions and 12755 deletions
Generated
+95 -213
View File
@@ -55,9 +55,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.2"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib",
]
@@ -79,9 +79,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayvec"
version = "0.7.6"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
[[package]]
name = "async-compression"
@@ -176,9 +176,9 @@ dependencies = [
[[package]]
name = "base58ck"
version = "0.1.100"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec5dc7e09f7bb15f0062da7c03086d6b71a2c84e0af4fccbbc7d8c6559847816"
checksum = "8f1cba749a07c1efb1f4d87518f39cea4aec25d0991fb97e80459c057238f0d2"
dependencies = [
"bitcoin_hashes",
]
@@ -227,9 +227,9 @@ dependencies = [
[[package]]
name = "bitcoin"
version = "0.32.100"
version = "0.32.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39581299241111285f3268ba75ddf372746fd041620918b145c1af9d75e91b6c"
checksum = "a1a121ccf1177a09084bd6ce97c52119919aac08ea3649967958eae41beceebf"
dependencies = [
"base58ck",
"base64 0.21.7",
@@ -245,24 +245,24 @@ dependencies = [
[[package]]
name = "bitcoin-io"
version = "0.1.100"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175"
checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953"
[[package]]
name = "bitcoin-units"
version = "0.1.100"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57bad157b78d0d1b22c4cbb6a35a566211fc4d14866a37f2c780652b50f3b845"
checksum = "c202276cab20ab02cf6cc9d4df0d4284f7c784031619d3842236de8bc2bd5c6c"
dependencies = [
"serde",
]
[[package]]
name = "bitcoin_hashes"
version = "0.14.100"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f"
checksum = "4ed83caece3afc59919481b33b472e1432d1abc4641ed9100be142ef5110b406"
dependencies = [
"bitcoin-io",
"hex-conservative",
@@ -700,9 +700,9 @@ dependencies = [
[[package]]
name = "brotli"
version = "8.0.3"
version = "8.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -711,9 +711,9 @@ dependencies = [
[[package]]
name = "brotli-decompressor"
version = "5.0.1"
version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -745,9 +745,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
[[package]]
name = "byteview"
@@ -757,9 +757,9 @@ checksum = "1c53ba0f290bfc610084c05582d9c5d421662128fc69f4bf236707af6fd321b9"
[[package]]
name = "cc"
version = "1.2.64"
version = "1.2.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -957,9 +957,9 @@ dependencies = [
[[package]]
name = "corepc-types"
version = "0.14.0"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f095534efdb8f2f43d48b9c3e9f35aefdf29ec6a5f1895064f575a67bc2a8dfe"
checksum = "86c274042a89391a324ab313b5210680969d02a9fa977733c3354aea4d7a4883"
dependencies = [
"bitcoin",
"serde",
@@ -1067,14 +1067,43 @@ dependencies = [
"parking_lot_core",
]
[[package]]
name = "defmt"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b"
dependencies = [
"defmt-parser",
"proc-macro-error2",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]]
name = "derive_more"
@@ -1280,12 +1309,6 @@ dependencies = [
"spin",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "font-kit"
version = "0.14.3"
@@ -1422,15 +1445,13 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.4.2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
[[package]]
@@ -1472,15 +1493,6 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1706,12 +1718,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "idna"
version = "1.1.0"
@@ -1838,10 +1844,11 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.28"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102"
checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46"
dependencies = [
"defmt",
"jiff-static",
"log",
"portable-atomic",
@@ -1852,9 +1859,9 @@ dependencies = [
[[package]]
name = "jiff-static"
version = "0.2.28"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47"
checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f"
dependencies = [
"proc-macro2",
"quote",
@@ -1905,12 +1912,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lexopt"
version = "0.3.2"
@@ -1992,9 +1993,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.32"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lsm-tree"
@@ -2339,12 +2340,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "prettyplease"
version = "0.2.37"
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
"syn",
]
@@ -2935,9 +2948,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.117"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
@@ -2968,7 +2981,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -3005,12 +3018,11 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.47"
version = "0.3.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
@@ -3020,15 +3032,15 @@ dependencies = [
[[package]]
name = "time-core"
version = "0.1.8"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.27"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d"
dependencies = [
"num-conv",
"time-core",
@@ -3138,9 +3150,9 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.11"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
dependencies = [
"async-compression",
"bitflags 2.13.0",
@@ -3150,6 +3162,7 @@ dependencies = [
"http",
"http-body",
"http-body-util",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
@@ -3400,16 +3413,7 @@ version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
"wit-bindgen",
]
[[package]]
@@ -3457,40 +3461,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.13.0",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.102"
@@ -3503,9 +3473,9 @@ dependencies = [
[[package]]
name = "webpki-roots"
version = "1.0.7"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
@@ -3703,100 +3673,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.13.0",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "writeable"
version = "0.6.3"
+4 -4
View File
@@ -35,7 +35,7 @@ debug = true
[workspace.dependencies]
aide = { version = "0.16.0-alpha.4", features = ["axum-json", "axum-query"] }
axum = { version = "0.8.9", default-features = false, features = ["http1", "json", "query", "tokio", "tracing"] }
bitcoin = { version = "0.32.100", features = ["serde"] }
bitcoin = { version = "0.32.10", features = ["serde"] }
brk_alloc = { version = "0.3.4", path = "crates/brk_alloc" }
brk_bencher = { version = "0.3.4", path = "crates/brk_bencher" }
brk_bindgen = { version = "0.3.4", path = "crates/brk_bindgen" }
@@ -62,11 +62,11 @@ brk_website = { version = "0.3.4", path = "crates/brk_website" }
byteview = "0.10.1"
color-eyre = "0.6.5"
corepc-jsonrpc = { package = "jsonrpc", version = "0.19.0", features = ["simple_http"], default-features = false }
corepc-types = { version = "0.14.0", features = ["std"], default-features = false }
corepc-types = { version = "0.15.0", features = ["std"], default-features = false }
derive_more = { version = "2.1.1", features = ["deref", "deref_mut"] }
fjall = "3.1.5"
indexmap = { version = "2.14.0", features = ["serde"] }
jiff = { version = "0.2.28", features = ["perf-inline", "tz-system"], default-features = false }
jiff = { version = "0.2.29", features = ["perf-inline", "tz-system"], default-features = false }
owo-colors = "4.3.0"
parking_lot = "0.12.5"
pco = "1.0.2"
@@ -79,7 +79,7 @@ serde_derive = "1.0.228"
serde_json = { version = "1.0.150", features = ["float_roundtrip", "preserve_order"] }
smallvec = "1.15.2"
tokio = { version = "1.52.3", features = ["rt-multi-thread"] }
tower-http = { version = "0.6.11", features = ["catch-panic", "compression-br", "compression-gzip", "compression-zstd", "cors", "normalize-path", "timeout", "trace"] }
tower-http = { version = "0.7.0", features = ["catch-panic", "compression-br", "compression-gzip", "compression-zstd", "cors", "normalize-path", "timeout", "trace"] }
tower-layer = "0.3"
tracing = { version = "0.1", default-features = false, features = ["std"] }
ureq = { version = "3.3.0", features = ["json"] }
@@ -51,6 +51,12 @@ const _openBrowserCache = (option) => {{
return caches.open(name).catch(() => null);
}};
/**
* @param {{string}} url
* @returns {{URL}}
*/
const _parseBaseUrl = (url) => new URL(url, typeof location === 'undefined' ? undefined : location.href);
/**
* Custom error class for BRK client errors
*/
@@ -403,6 +409,9 @@ class BrkClientBase {{
const isString = typeof options === 'string';
const rawUrl = isString ? options : options.baseUrl;
this.baseUrl = rawUrl.endsWith('/') ? rawUrl.slice(0, -1) : rawUrl;
const url = _parseBaseUrl(this.baseUrl);
this.url = url.href.endsWith('/') ? url.href.slice(0, -1) : url.href;
this.domain = url.hostname;
this.timeout = isString ? 5000 : (options.timeout ?? 5000);
/** @type {{Promise<Cache | null>}} */
this._browserCachePromise = _openBrowserCache(isString ? undefined : options.browserCache);
+10 -1
View File
@@ -9538,7 +9538,7 @@ pub struct BrkClient {
impl BrkClient {
/// Client version.
pub const VERSION: &'static str = "v0.3.3";
pub const VERSION: &'static str = "v0.3.4";
/// Create a new client with the given base URL.
pub fn new(base_url: impl Into<String>) -> Self {
@@ -9866,6 +9866,15 @@ impl BrkClient {
self.base.get_json(&path)
}
/// Address hash-prefix matches
///
/// Find addresses by address type and address-payload hash prefix. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`.
///
/// Endpoint: `GET /api/address/hash-prefix/{addr_type}/{prefix}`
pub fn get_address_hash_prefix_matches(&self, addr_type: OutputType, prefix: &str) -> Result<AddrHashPrefixMatches> {
self.base.get_json(&format!("/api/address/hash-prefix/{addr_type}/{prefix}"))
}
/// Address information
///
/// Retrieve address information including balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR).
-279
View File
@@ -1,279 +0,0 @@
use std::str::FromStr;
use bitcoin::{Network, PublicKey, ScriptBuf};
use brk_error::{Error, OptionData, Result};
use brk_types::{
Addr, AddrBytes, AddrChainStats, AddrHash, AddrIndexOutPoint, AddrIndexTxIndex, AddrStats,
AnyAddrDataIndexEnum, Dollars, Height, OutputType, Transaction, TxIndex, TxStatus, Txid,
TypeIndex, Unit, Utxo, Vout,
};
use vecdb::VecIndex;
use crate::Query;
impl Query {
pub fn addr(&self, addr: Addr) -> Result<AddrStats> {
let computer = self.computer();
let script = if let Ok(addr) = bitcoin::Address::from_str(&addr) {
if !addr.is_valid_for_network(Network::Bitcoin) {
return Err(Error::InvalidNetwork);
}
let addr = addr.assume_checked();
addr.script_pubkey()
} else if let Ok(pubkey) = PublicKey::from_str(&addr) {
ScriptBuf::new_p2pk(&pubkey)
} else {
return Err(Error::InvalidAddr);
};
let output_type = OutputType::from(&script);
let Ok(bytes) = AddrBytes::try_from((&script, output_type)) else {
return Err(Error::InvalidAddr);
};
let hash = AddrHash::from(&bytes);
let type_index = self.type_index_for(output_type, &hash)?;
if type_index >= self.safe_lengths().to_type_index(output_type) {
return Err(Error::UnknownAddr);
}
let any_addr_index = computer
.distribution
.any_addr_indexes
.get_once(output_type, type_index)?;
let (addr_data, realized_price) = match any_addr_index.to_enum() {
AnyAddrDataIndexEnum::Funded(index) => {
let data = computer
.distribution
.addrs_data
.funded
.reader()
.get(usize::from(index));
let price = data.realized_price().to_dollars();
(data, price)
}
AnyAddrDataIndexEnum::Empty(index) => {
let data = computer
.distribution
.addrs_data
.empty
.reader()
.get(usize::from(index))
.into();
(data, Dollars::default())
}
};
Ok(AddrStats {
addr,
addr_type: output_type,
chain_stats: AddrChainStats {
type_index,
funded_txo_count: addr_data.funded_txo_count,
funded_txo_sum: addr_data.received,
spent_txo_count: addr_data.spent_txo_count,
spent_txo_sum: addr_data.sent,
tx_count: addr_data.tx_count,
realized_price,
},
mempool_stats: self
.mempool()
.and_then(|m| m.addr_stats(&bytes))
.unwrap_or_default(),
})
}
pub fn addr_txs_chain(
&self,
addr: &Addr,
after_txid: Option<Txid>,
limit: usize,
) -> Result<Vec<Transaction>> {
let txindices = self.addr_txindices(addr, after_txid, limit)?;
self.transactions_by_indices(&txindices)
}
pub fn addr_txids(
&self,
addr: Addr,
after_txid: Option<Txid>,
limit: usize,
) -> Result<Vec<Txid>> {
let txindices = self.addr_txindices(&addr, after_txid, limit)?;
let txid_reader = self.indexer().vecs.transactions.txid.reader();
Ok(txindices
.into_iter()
.map(|tx_index| txid_reader.get(tx_index.to_usize()))
.collect())
}
fn addr_txindices(
&self,
addr: &Addr,
after_txid: Option<Txid>,
limit: usize,
) -> Result<Vec<TxIndex>> {
let stores = &self.indexer().stores;
let (output_type, type_index) = self.resolve_addr(addr)?;
let store = stores
.addr_type_to_addr_index_and_tx_index
.get(output_type)
.data()?;
let tx_index_len = self.safe_lengths().tx_index;
if let Some(after_txid) = after_txid {
let after_tx_index = self.resolve_tx_index(&after_txid)?;
let min = AddrIndexTxIndex::min_for_addr(type_index);
let cursor = AddrIndexTxIndex::from((type_index, after_tx_index));
Ok(store
.range(min..cursor)
.rev()
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
.filter(|tx_index| *tx_index < tx_index_len)
.take(limit)
.collect())
} else {
Ok(store
.prefix(type_index)
.rev()
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
.filter(|tx_index| *tx_index < tx_index_len)
.take(limit)
.collect())
}
}
pub fn addr_utxos(&self, addr: Addr, max_utxos: usize) -> Result<Vec<Utxo>> {
let indexer = self.indexer();
let stores = &indexer.stores;
let vecs = &indexer.vecs;
let (output_type, type_index) = self.resolve_addr(&addr)?;
let store = stores
.addr_type_to_addr_index_and_unspent_outpoint
.get(output_type)
.data()?;
let tx_index_len = self.safe_lengths().tx_index;
let outpoints: Vec<(TxIndex, Vout)> = store
.prefix(type_index)
.map(|(key, _): (AddrIndexOutPoint, Unit)| (key.tx_index(), key.vout()))
.filter(|(tx_index, _)| *tx_index < tx_index_len)
.take(max_utxos + 1)
.collect();
if outpoints.len() > max_utxos {
return Err(Error::TooManyUtxos);
}
let txid_reader = vecs.transactions.txid.reader();
let first_txout_index_reader = vecs.transactions.first_txout_index.reader();
let value_reader = vecs.outputs.value.reader();
let mut cached_status: Option<(Height, TxStatus)> = None;
let mut utxos = Vec::with_capacity(outpoints.len());
for (tx_index, vout) in outpoints {
let txid = txid_reader.get(tx_index.to_usize());
let first_txout_index = first_txout_index_reader.get(tx_index.to_usize());
let value = value_reader.get(usize::from(first_txout_index + vout));
let height = self.confirmed_status_height(tx_index)?;
let status = if let Some((h, ref s)) = cached_status
&& h == height
{
s.clone()
} else {
let s = self.confirmed_status_at(height)?;
cached_status = Some((height, s.clone()));
s
};
utxos.push(Utxo {
txid,
vout,
status,
value,
});
}
Ok(utxos)
}
pub fn addr_mempool_hash(&self, addr: &Addr) -> Option<u64> {
let mempool = self.mempool()?;
let bytes = AddrBytes::from_str(addr).ok()?;
mempool.addr_state_hash(&bytes)
}
pub fn addr_mempool_txs(&self, addr: &Addr, limit: usize) -> Result<Vec<Transaction>> {
let bytes = AddrBytes::from_str(addr)?;
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?;
Ok(mempool.addr_txs(&bytes, limit))
}
/// Height of the last on-chain activity for an address (last tx_index to height).
/// With `before_txid`, returns the newest activity strictly older than that
/// cursor. Used by paginated chain etags so a new tx above the cursor
/// doesn't invalidate deeper pages.
pub fn addr_last_activity_height(
&self,
addr: &Addr,
before_txid: Option<&Txid>,
) -> Result<Height> {
let (output_type, type_index) = self.resolve_addr(addr)?;
let store = self
.indexer()
.stores
.addr_type_to_addr_index_and_tx_index
.get(output_type)
.data()?;
let tx_index_len = self.safe_lengths().tx_index;
let last_tx_index = match before_txid {
Some(txid) => {
let before_tx_index = self.resolve_tx_index(txid)?;
let min = AddrIndexTxIndex::min_for_addr(type_index);
let cursor = AddrIndexTxIndex::from((type_index, before_tx_index));
store
.range(min..cursor)
.rev()
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
.find(|tx_index| *tx_index < tx_index_len)
.ok_or(Error::UnknownAddr)?
}
None => store
.prefix(type_index)
.rev()
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
.find(|tx_index| *tx_index < tx_index_len)
.ok_or(Error::UnknownAddr)?,
};
self.confirmed_status_height(last_tx_index)
}
fn resolve_addr(&self, addr: &Addr) -> Result<(OutputType, TypeIndex)> {
let bytes = AddrBytes::from_str(addr)?;
let output_type = OutputType::from(&bytes);
let hash = AddrHash::from(&bytes);
let type_index = self.type_index_for(output_type, &hash)?;
Ok((output_type, type_index))
}
/// Lookup the per-type index of an address by `(output_type, hash)`.
/// Returns `UnknownAddr` if the hash is absent from the type's index.
fn type_index_for(&self, output_type: OutputType, hash: &AddrHash) -> Result<TypeIndex> {
self.indexer()
.stores
.addr_type_to_addr_hash_to_addr_index
.get(output_type)
.data()?
.get(hash)?
.map(|cow| cow.into_owned())
.ok_or(Error::UnknownAddr)
}
}
@@ -0,0 +1,45 @@
use brk_error::{Error, OptionData, Result};
use brk_types::{Addr, AddrIndexTxIndex, Height, Txid, Unit};
use crate::Query;
impl Query {
/// Height of the last on-chain activity for an address (last tx_index to height).
/// With `before_txid`, returns the newest activity strictly older than that
/// cursor. Used by paginated chain etags so a new tx above the cursor
/// doesn't invalidate deeper pages.
pub fn addr_last_activity_height(
&self,
addr: &Addr,
before_txid: Option<&Txid>,
) -> Result<Height> {
let (output_type, type_index) = self.resolve_addr(addr)?;
let store = self
.indexer()
.stores
.addr_type_to_addr_index_and_tx_index
.get(output_type)
.data()?;
let tx_index_len = self.safe_lengths().tx_index;
let last_tx_index = match before_txid {
Some(txid) => {
let before_tx_index = self.resolve_tx_index(txid)?;
let min = AddrIndexTxIndex::min_for_addr(type_index);
let cursor = AddrIndexTxIndex::from((type_index, before_tx_index));
store
.range(min..cursor)
.rev()
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
.find(|tx_index| *tx_index < tx_index_len)
.ok_or(Error::UnknownAddr)?
}
None => store
.prefix(type_index)
.rev()
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
.find(|tx_index| *tx_index < tx_index_len)
.ok_or(Error::UnknownAddr)?,
};
self.confirmed_status_height(last_tx_index)
}
}
@@ -0,0 +1,115 @@
use brk_error::{Error, OptionData, Result};
use brk_types::{Addr, AddrHash, AddrHashPrefixMatches, OutputType};
use crate::Query;
const ADDR_HASH_PREFIX_MATCH_LIMIT: usize = 100;
impl Query {
pub fn addr_hash_prefix_matches(
&self,
addr_type: OutputType,
prefix: &str,
) -> Result<AddrHashPrefixMatches> {
if !addr_type.is_addr() {
return Err(Error::UnsupportedType(addr_type.to_string()));
}
let prefix = AddrHashPrefix::parse(prefix)?;
let store = self
.indexer()
.stores
.addr_type_to_addr_hash_to_addr_index
.get(addr_type)
.data()?;
let safe_type_index = self.safe_lengths().to_type_index(addr_type);
let addr_readers = self.indexer().vecs.addrs.addr_readers();
let mut addresses = Vec::new();
let max_hash = AddrHash::new(u64::MAX);
if let Some(upper) = prefix.upper {
for (_, type_index) in store.range(prefix.lower..upper) {
if type_index >= safe_type_index {
continue;
}
let script = addr_readers.script_pubkey(addr_type, type_index);
addresses.push(Addr::try_from((&script, addr_type))?);
if addresses.len() > ADDR_HASH_PREFIX_MATCH_LIMIT {
break;
}
}
} else {
for (_, type_index) in store.range(prefix.lower..max_hash) {
if type_index >= safe_type_index {
continue;
}
let script = addr_readers.script_pubkey(addr_type, type_index);
addresses.push(Addr::try_from((&script, addr_type))?);
if addresses.len() > ADDR_HASH_PREFIX_MATCH_LIMIT {
break;
}
}
if addresses.len() <= ADDR_HASH_PREFIX_MATCH_LIMIT
&& let Some(type_index) = store.get(&max_hash)?.map(|cow| cow.into_owned())
&& type_index < safe_type_index
{
let script = addr_readers.script_pubkey(addr_type, type_index);
addresses.push(Addr::try_from((&script, addr_type))?);
}
}
let truncated = addresses.len() > ADDR_HASH_PREFIX_MATCH_LIMIT;
addresses.truncate(ADDR_HASH_PREFIX_MATCH_LIMIT);
Ok(AddrHashPrefixMatches {
addr_type,
prefix: prefix.text,
truncated,
addresses,
})
}
}
struct AddrHashPrefix {
text: String,
lower: AddrHash,
upper: Option<AddrHash>,
}
impl AddrHashPrefix {
const MAX_NIBBLES: usize = u64::BITS as usize / 4;
fn parse(prefix: &str) -> Result<Self> {
let nibbles = prefix.len();
if !(1..=Self::MAX_NIBBLES).contains(&nibbles) {
return Err(Self::parse_error());
}
let value = u64::from_str_radix(prefix, 16).map_err(|_| Self::parse_error())?;
let shift = (Self::MAX_NIBBLES - nibbles) * 4;
let factor = 1_u64 << shift;
let lower = value * factor;
let upper = value
.checked_add(1)
.and_then(|value| value.checked_mul(factor))
.map(AddrHash::new);
Ok(Self {
text: prefix.to_ascii_lowercase(),
lower: AddrHash::new(lower),
upper,
})
}
fn parse_error() -> Error {
Error::Parse(format!(
"hash prefix must be 1 to {} hexadecimal characters",
Self::MAX_NIBBLES
))
}
}
+20
View File
@@ -0,0 +1,20 @@
use std::str::FromStr;
use brk_error::{Error, Result};
use brk_types::{Addr, AddrBytes, Transaction};
use crate::Query;
impl Query {
pub fn addr_mempool_hash(&self, addr: &Addr) -> Option<u64> {
let mempool = self.mempool()?;
let bytes = AddrBytes::from_str(addr).ok()?;
mempool.addr_state_hash(&bytes)
}
pub fn addr_mempool_txs(&self, addr: &Addr, limit: usize) -> Result<Vec<Transaction>> {
let bytes = AddrBytes::from_str(addr)?;
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?;
Ok(mempool.addr_txs(&bytes, limit))
}
}
+7
View File
@@ -0,0 +1,7 @@
mod activity;
mod hash_prefix;
mod mempool;
mod resolve;
mod stats;
mod txs;
mod utxos;
+33
View File
@@ -0,0 +1,33 @@
use std::str::FromStr;
use brk_error::{Error, OptionData, Result};
use brk_types::{Addr, AddrBytes, AddrHash, OutputType, TypeIndex};
use crate::Query;
impl Query {
pub(super) fn resolve_addr(&self, addr: &Addr) -> Result<(OutputType, TypeIndex)> {
let bytes = AddrBytes::from_str(addr)?;
let output_type = OutputType::from(&bytes);
let hash = AddrHash::from(&bytes);
let type_index = self.type_index_for(output_type, &hash)?;
Ok((output_type, type_index))
}
/// Lookup the per-type index of an address by `(output_type, hash)`.
/// Returns `UnknownAddr` if the hash is absent from the type's index.
pub(super) fn type_index_for(
&self,
output_type: OutputType,
hash: &AddrHash,
) -> Result<TypeIndex> {
self.indexer()
.stores
.addr_type_to_addr_hash_to_addr_index
.get(output_type)
.data()?
.get(hash)?
.map(|cow| cow.into_owned())
.ok_or(Error::UnknownAddr)
}
}
+78
View File
@@ -0,0 +1,78 @@
use std::str::FromStr;
use brk_error::{Error, Result};
use brk_types::{
Addr, AddrBytes, AddrChainStats, AddrHash, AddrStats, AnyAddrDataIndexEnum, Dollars,
OutputType, TypeIndex,
};
use crate::Query;
impl Query {
pub fn addr(&self, addr: Addr) -> Result<AddrStats> {
let bytes = AddrBytes::from_str(&addr)?;
let output_type = OutputType::from(&bytes);
let hash = AddrHash::from(&bytes);
let type_index = self.type_index_for(output_type, &hash)?;
self.addr_stats(addr, bytes, output_type, type_index)
}
fn addr_stats(
&self,
addr: Addr,
bytes: AddrBytes,
output_type: OutputType,
type_index: TypeIndex,
) -> Result<AddrStats> {
if type_index >= self.safe_lengths().to_type_index(output_type) {
return Err(Error::UnknownAddr);
}
let computer = self.computer();
let any_addr_index = computer
.distribution
.any_addr_indexes
.get_once(output_type, type_index)?;
let (addr_data, realized_price) = match any_addr_index.to_enum() {
AnyAddrDataIndexEnum::Funded(index) => {
let data = computer
.distribution
.addrs_data
.funded
.reader()
.get(usize::from(index));
let price = data.realized_price().to_dollars();
(data, price)
}
AnyAddrDataIndexEnum::Empty(index) => {
let data = computer
.distribution
.addrs_data
.empty
.reader()
.get(usize::from(index))
.into();
(data, Dollars::default())
}
};
Ok(AddrStats {
addr,
addr_type: output_type,
chain_stats: AddrChainStats {
type_index,
funded_txo_count: addr_data.funded_txo_count,
funded_txo_sum: addr_data.received,
spent_txo_count: addr_data.spent_txo_count,
spent_txo_sum: addr_data.sent,
tx_count: addr_data.tx_count,
realized_price,
},
mempool_stats: self
.mempool()
.and_then(|m| m.addr_stats(&bytes))
.unwrap_or_default(),
})
}
}
+70
View File
@@ -0,0 +1,70 @@
use brk_error::{OptionData, Result};
use brk_types::{Addr, AddrIndexTxIndex, Transaction, TxIndex, Txid, Unit};
use vecdb::VecIndex;
use crate::Query;
impl Query {
pub fn addr_txs_chain(
&self,
addr: &Addr,
after_txid: Option<Txid>,
limit: usize,
) -> Result<Vec<Transaction>> {
let txindices = self.addr_txindices(addr, after_txid, limit)?;
self.transactions_by_indices(&txindices)
}
pub fn addr_txids(
&self,
addr: Addr,
after_txid: Option<Txid>,
limit: usize,
) -> Result<Vec<Txid>> {
let txindices = self.addr_txindices(&addr, after_txid, limit)?;
let txid_reader = self.indexer().vecs.transactions.txid.reader();
Ok(txindices
.into_iter()
.map(|tx_index| txid_reader.get(tx_index.to_usize()))
.collect())
}
fn addr_txindices(
&self,
addr: &Addr,
after_txid: Option<Txid>,
limit: usize,
) -> Result<Vec<TxIndex>> {
let stores = &self.indexer().stores;
let (output_type, type_index) = self.resolve_addr(addr)?;
let store = stores
.addr_type_to_addr_index_and_tx_index
.get(output_type)
.data()?;
let tx_index_len = self.safe_lengths().tx_index;
if let Some(after_txid) = after_txid {
let after_tx_index = self.resolve_tx_index(&after_txid)?;
let min = AddrIndexTxIndex::min_for_addr(type_index);
let cursor = AddrIndexTxIndex::from((type_index, after_tx_index));
Ok(store
.range(min..cursor)
.rev()
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
.filter(|tx_index| *tx_index < tx_index_len)
.take(limit)
.collect())
} else {
Ok(store
.prefix(type_index)
.rev()
.map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index())
.filter(|tx_index| *tx_index < tx_index_len)
.take(limit)
.collect())
}
}
}
+64
View File
@@ -0,0 +1,64 @@
use brk_error::{Error, OptionData, Result};
use brk_types::{Addr, AddrIndexOutPoint, Height, TxIndex, TxStatus, Unit, Utxo, Vout};
use vecdb::VecIndex;
use crate::Query;
impl Query {
pub fn addr_utxos(&self, addr: Addr, max_utxos: usize) -> Result<Vec<Utxo>> {
let indexer = self.indexer();
let stores = &indexer.stores;
let vecs = &indexer.vecs;
let (output_type, type_index) = self.resolve_addr(&addr)?;
let store = stores
.addr_type_to_addr_index_and_unspent_outpoint
.get(output_type)
.data()?;
let tx_index_len = self.safe_lengths().tx_index;
let outpoints: Vec<(TxIndex, Vout)> = store
.prefix(type_index)
.map(|(key, _): (AddrIndexOutPoint, Unit)| (key.tx_index(), key.vout()))
.filter(|(tx_index, _)| *tx_index < tx_index_len)
.take(max_utxos + 1)
.collect();
if outpoints.len() > max_utxos {
return Err(Error::TooManyUtxos);
}
let txid_reader = vecs.transactions.txid.reader();
let first_txout_index_reader = vecs.transactions.first_txout_index.reader();
let value_reader = vecs.outputs.value.reader();
let mut cached_status: Option<(Height, TxStatus)> = None;
let mut utxos = Vec::with_capacity(outpoints.len());
for (tx_index, vout) in outpoints {
let txid = txid_reader.get(tx_index.to_usize());
let first_txout_index = first_txout_index_reader.get(tx_index.to_usize());
let value = value_reader.get(usize::from(first_txout_index + vout));
let height = self.confirmed_status_height(tx_index)?;
let status = if let Some((h, ref s)) = cached_status
&& h == height
{
s.clone()
} else {
let s = self.confirmed_status_at(height)?;
cached_status = Some((height, s.clone()));
s
};
utxos.push(Utxo {
txid,
vout,
status,
value,
});
}
Ok(utxos)
}
}
+27 -2
View File
@@ -3,12 +3,14 @@ use axum::{
extract::{Path, State},
http::{HeaderMap, Uri},
};
use brk_types::{AddrStats, AddrValidation, Transaction, Utxo, Version};
use brk_types::{
AddrHashPrefixMatches, AddrStats, AddrValidation, Transaction, Utxo, Version,
};
use crate::{
AppState, CacheStrategy,
extended::TransformResponseExtended,
params::{AddrAfterTxidParam, AddrParam, Empty, ValidateAddrParam},
params::{AddrAfterTxidParam, AddrHashPrefixParam, AddrParam, Empty, ValidateAddrParam},
};
/// Esplora `/txs` and `/txs/chain` page sizes. Wire-protocol constants from
@@ -26,6 +28,29 @@ pub trait AddrRoutes {
impl AddrRoutes for ApiRouter<AppState> {
fn add_addr_routes(self) -> Self {
self.api_route(
"/api/address/hash-prefix/{addr_type}/{prefix}",
get_with(async |
uri: Uri,
headers: HeaderMap,
Path(path): Path<AddrHashPrefixParam>,
_: Empty,
State(state): State<AppState>
| {
state.respond_json(&headers, CacheStrategy::Tip, &uri, move |q| {
q.addr_hash_prefix_matches(path.addr_type, &path.prefix)
}).await
}, |op| op
.id("get_address_hash_prefix_matches")
.addrs_tag()
.summary("Address hash-prefix matches")
.description("Find addresses by address type and address-payload hash prefix. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`.")
.json_response::<AddrHashPrefixMatches>()
.not_modified()
.bad_request()
.server_error()
),
)
.api_route(
"/api/address/{address}",
get_with(async |
uri: Uri,
@@ -0,0 +1,9 @@
use brk_types::OutputType;
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Deserialize, JsonSchema)]
pub struct AddrHashPrefixParam {
pub addr_type: OutputType,
pub prefix: String,
}
+2
View File
@@ -1,4 +1,5 @@
mod addr_after_txid_param;
mod addr_hash_prefix_param;
mod addr_param;
mod block_count_param;
mod blockhash_param;
@@ -20,6 +21,7 @@ mod urpd_params;
mod validate_addr_param;
pub use addr_after_txid_param::*;
pub use addr_hash_prefix_param::*;
pub use addr_param::*;
pub use block_count_param::*;
pub use blockhash_param::*;
+6
View File
@@ -7,6 +7,12 @@ use super::AddrBytes;
#[derive(Debug, Deref, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Bytes, Hash)]
pub struct AddrHash(u64);
impl AddrHash {
pub const fn new(value: u64) -> Self {
Self(value)
}
}
impl From<&AddrBytes> for AddrHash {
#[inline]
fn from(addr_bytes: &AddrBytes) -> Self {
@@ -0,0 +1,11 @@
use crate::{Addr, OutputType};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct AddrHashPrefixMatches {
pub addr_type: OutputType,
pub prefix: String,
pub truncated: bool,
pub addresses: Vec<Addr>,
}
+2
View File
@@ -6,6 +6,7 @@ mod addr;
mod addr_bytes;
mod addr_chain_stats;
mod addr_hash;
mod addr_hash_prefix_matches;
mod addr_index_any;
mod addr_index_outpoint;
mod addr_index_tx_index;
@@ -203,6 +204,7 @@ pub use addr::*;
pub use addr_bytes::*;
pub use addr_chain_stats::*;
pub use addr_hash::*;
pub use addr_hash_prefix_matches::*;
pub use addr_index_any::*;
pub use addr_index_outpoint::*;
pub use addr_index_tx_index::*;
+39 -1
View File
@@ -29,6 +29,18 @@
* @property {TypeIndex} typeIndex - Index of this address within its type on the blockchain
* @property {Dollars} realizedPrice - Realized price (average cost basis) in USD
*/
/**
* @typedef {Object} AddrHashPrefixMatches
* @property {OutputType} addrType
* @property {string} prefix
* @property {boolean} truncated
* @property {Addr[]} addresses
*/
/**
* @typedef {Object} AddrHashPrefixParam
* @property {OutputType} addrType
* @property {string} prefix
*/
/**
* Address statistics in the mempool (unconfirmed transactions only)
*
@@ -1485,6 +1497,12 @@ const _openBrowserCache = (option) => {
return caches.open(name).catch(() => null);
};
/**
* @param {string} url
* @returns {URL}
*/
const _parseBaseUrl = (url) => new URL(url, typeof location === 'undefined' ? undefined : location.href);
/**
* Custom error class for BRK client errors
*/
@@ -1837,6 +1855,9 @@ class BrkClientBase {
const isString = typeof options === 'string';
const rawUrl = isString ? options : options.baseUrl;
this.baseUrl = rawUrl.endsWith('/') ? rawUrl.slice(0, -1) : rawUrl;
const url = _parseBaseUrl(this.baseUrl);
this.url = url.href.endsWith('/') ? url.href.slice(0, -1) : url.href;
this.domain = url.hostname;
this.timeout = isString ? 5000 : (options.timeout ?? 5000);
/** @type {Promise<Cache | null>} */
this._browserCachePromise = _openBrowserCache(isString ? undefined : options.browserCache);
@@ -7872,7 +7893,7 @@ function createTransferPattern(client, acc) {
* @extends BrkClientBase
*/
class BrkClient extends BrkClientBase {
VERSION = "v0.3.3";
VERSION = "v0.3.4";
INDEXES = /** @type {const} */ ([
"minute10",
@@ -11476,6 +11497,23 @@ class BrkClient extends BrkClientBase {
return this.getJson(path, { signal, onValue, cache });
}
/**
* Address hash-prefix matches
*
* Find addresses by address type and address-payload hash prefix. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`.
*
* Endpoint: `GET /api/address/hash-prefix/{addr_type}/{prefix}`
*
* @param {OutputType} addr_type
* @param {string} prefix
* @param {{ signal?: AbortSignal, onValue?: (value: AddrHashPrefixMatches) => void, cache?: boolean }} [options]
* @returns {Promise<AddrHashPrefixMatches>}
*/
async getAddressHashPrefixMatches(addr_type, prefix, { signal, onValue, cache } = {}) {
const path = `/api/address/hash-prefix/${addr_type}/${prefix}`;
return this.getJson(path, { signal, onValue, cache });
}
/**
* Address information
*
+1 -1
View File
@@ -40,5 +40,5 @@
"url": "git+https://github.com/bitcoinresearchkit/brk.git"
},
"type": "module",
"version": "0.3.3"
"version": "0.3.4"
}
+19 -1
View File
@@ -298,6 +298,16 @@ class AddrChainStats(TypedDict):
type_index: TypeIndex
realized_price: Dollars
class AddrHashPrefixMatches(TypedDict):
addr_type: OutputType
prefix: str
truncated: bool
addresses: List[Addr]
class AddrHashPrefixParam(TypedDict):
addr_type: OutputType
prefix: str
class AddrMempoolStats(TypedDict):
"""
Address statistics in the mempool (unconfirmed transactions only)
@@ -7003,7 +7013,7 @@ class SeriesTree:
class BrkClient(BrkClientBase):
"""Main BRK client with series tree and API methods."""
VERSION = "v0.3.3"
VERSION = "v0.3.4"
INDEXES = [
"minute10",
@@ -8436,6 +8446,14 @@ class BrkClient(BrkClientBase):
path = f'/api/v1/historical-price{"?" + query if query else ""}'
return self.get_json(path)
def get_address_hash_prefix_matches(self, addr_type: OutputType, prefix: str) -> AddrHashPrefixMatches:
"""Address hash-prefix matches.
Find addresses by address type and address-payload hash prefix. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`.
Endpoint: `GET /api/address/hash-prefix/{addr_type}/{prefix}`"""
return self.get_json(f'/api/address/hash-prefix/{addr_type}/{prefix}')
def get_address(self, address: Addr) -> AddrStats:
"""Address information.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "brk-client"
version = "0.3.3"
version = "0.3.4"
description = "Bitcoin on-chain analytics client — thousands of metrics, block explorer, and address index"
readme = "README.md"
requires-python = ">=3.9"
+15
View File
@@ -0,0 +1,15 @@
dialog {
--dialog-space: 1.5rem;
color-scheme: light;
width: min(100% - 2rem, 30rem);
border: 0;
border-radius: var(--dialog-space);
padding: var(--dialog-space);
color: var(--black);
background: var(--white);
&::backdrop {
background: color-mix(in oklch, var(--black) 72%, transparent);
}
}
+2
View File
@@ -2,6 +2,7 @@ const links = [
{ href: "/explore", label: "Explore" },
{ href: "/learn", label: "Learn" },
{ href: "/build", label: "Build" },
{ href: "/wallets", label: "Wallets" },
];
export function createHomePage() {
@@ -17,6 +18,7 @@ export function createHomePage() {
for (const { href, label } of links) {
const link = document.createElement("a");
link.href = href;
link.dataset.button = "";
link.append(label);
nav.append(link);
}
-20
View File
@@ -16,25 +16,5 @@ main.home {
gap: 0.5rem;
font-size: var(--font-size-xs);
line-height: 1;
text-transform: uppercase;
a {
display: block;
padding: 0.75rem 1rem;
border-radius: 0.3125rem;
color: var(--white);
background: var(--gray);
text-decoration: none;
&:hover {
color: var(--black);
background: var(--white);
}
&:active {
color: var(--black);
background: var(--orange);
}
}
}
}
+18
View File
@@ -115,6 +115,24 @@
<link rel="stylesheet" href="/learn/charts/stacked/style.css" />
<link rel="stylesheet" href="/learn/contents/style.css" />
<link rel="stylesheet" href="/build/style.css" />
<link rel="stylesheet" href="/wallets/style.css" />
<link rel="stylesheet" href="/dialog/style.css" />
<link rel="stylesheet" href="/wallets/amount/style.css" />
<link rel="stylesheet" href="/wallets/hold/style.css" />
<link rel="stylesheet" href="/wallets/layout/style.css" />
<link rel="stylesheet" href="/wallets/form/style.css" />
<link rel="stylesheet" href="/wallets/add/style.css" />
<link rel="stylesheet" href="/wallets/empty/style.css" />
<link rel="stylesheet" href="/wallets/start/style.css" />
<link rel="stylesheet" href="/wallets/start/reset/style.css" />
<link rel="stylesheet" href="/wallets/selector/style.css" />
<link rel="stylesheet" href="/wallets/wallet/summary/style.css" />
<link rel="stylesheet" href="/wallets/wallet/receive/style.css" />
<link rel="stylesheet" href="/wallets/wallet/tabs/style.css" />
<link rel="stylesheet" href="/wallets/wallet/address/style.css" />
<link rel="stylesheet" href="/wallets/wallet/history/style.css" />
<link rel="stylesheet" href="/wallets/wallet/holdings/style.css" />
<link rel="stylesheet" href="/wallets/wallet/addresses/style.css" />
<!-- /IMPORTMAP -->
<script>
+1
View File
@@ -0,0 +1 @@
../modules
File diff suppressed because it is too large Load Diff
+2
View File
@@ -2,6 +2,7 @@ import { createBuildPage } from "./build/index.js";
import { createExplorePage } from "./explore/index.js";
import { createHomePage } from "./home/index.js";
import { createLearnPage } from "./learn/index.js";
import { createWalletsPage } from "./wallets/index.js";
/** @type {Record<string, () => HTMLElement>} */
const routes = {
@@ -9,6 +10,7 @@ const routes = {
"/explore": createExplorePage,
"/learn": createLearnPage,
"/build": createBuildPage,
"/wallets": createWalletsPage,
};
/** @param {string} pathname */
+86 -4
View File
@@ -7,13 +7,95 @@ body {
background: var(--black);
}
body {
> main {
min-height: 100dvh;
color: var(--white);
body > main {
min-height: 100dvh;
color: var(--white);
}
button,
input,
select,
textarea,
:where(a[data-button]) {
appearance: none;
min-width: 0;
border-radius: var(--control-radius);
padding: var(--control-padding);
font: inherit;
font-size: var(--font-size-sm);
line-height: 1;
&:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
}
button,
:where(a[data-button]) {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
color: var(--button-color);
background: var(--button-background);
text-decoration: none;
text-transform: uppercase;
cursor: pointer;
&:hover {
background: var(--button-hover-background);
}
&:active {
background: var(--button-active-background);
}
&:disabled {
opacity: 0.5;
cursor: progress;
}
}
input,
select,
textarea {
border: 1px solid var(--field-border-color);
color: var(--field-color);
background: transparent;
&:hover {
border-color: var(--field-color);
color: var(--field-color);
}
&:active {
border-color: var(--field-active-color);
color: var(--field-active-color);
}
&[aria-invalid="true"] {
border-color: var(--red);
color: var(--red);
&::placeholder {
color: var(--red);
}
}
&::placeholder {
color: color-mix(in oklch, var(--field-color) 70%, transparent);
}
}
input {
height: 2.375rem;
}
textarea {
line-height: var(--line-height-sm);
}
::selection {
color: var(--black);
background-color: var(--orange);
+14
View File
@@ -29,6 +29,20 @@
--line-height-sm: calc(1.25 / 0.875);
--font-size-base: 1rem;
--line-height-base: calc(1.5 / 1);
--font-size-lg: 1.25rem;
--line-height-lg: calc(1.5 / 1.25);
--control-radius: 0.3125rem;
--control-padding: 0.75rem 1rem;
--button-color: light-dark(var(--white), var(--black));
--button-background: var(--gray);
--button-hover-background: light-dark(var(--black), var(--white));
--button-active-background: var(--orange);
--field-color: light-dark(var(--black), var(--white));
--field-border-color: var(--gray);
--field-active-color: var(--orange);
--page-x: 2rem;
--layer-header: 10;
+1 -1
View File
@@ -11,5 +11,5 @@
"lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"],
"skipLibCheck": true,
},
"exclude": ["assets", "./scripts/modules"],
"exclude": ["assets", "modules", "./scripts/modules"],
}
+1 -1
View File
@@ -1,3 +1,3 @@
import { BrkClient } from "../modules/brk-client/index.js";
export const brk = new BrkClient("https://bitview.space");
export const brk = new BrkClient("http://localhost:3110");
+84
View File
@@ -0,0 +1,84 @@
import { createField } from "../form/index.js";
import { createElement } from "../dom.js";
import { redaction } from "../redaction/index.js";
/**
* @typedef {Object} AddWalletFormSubmit
* @property {HTMLInputElement} name
* @property {HTMLTextAreaElement} source
* @property {HTMLButtonElement} submit
* @property {HTMLFormElement} form
*/
/**
* @typedef {Object} AddWalletFormOptions
* @property {() => void} onCancel
* @property {(submit: AddWalletFormSubmit) => void | Promise<void>} onSubmit
*/
function createSourceInput() {
const input = document.createElement("textarea");
input.name = "source";
redaction.setInput(input);
input.autocomplete = "off";
input.placeholder = "xpub... or wsh(sortedmulti(...))";
input.required = true;
input.spellcheck = false;
input.rows = 4;
return input;
}
/**
* @param {AddWalletFormOptions} options
*/
export function createAddForm(options) {
const form = createElement("form", "add");
const title = document.createElement("h2");
const description = document.createElement("p");
const name = document.createElement("input");
const source = createSourceInput();
const actions = document.createElement("footer");
const cancel = document.createElement("button");
const submit = document.createElement("button");
const fields = [
createField("name", name),
createField("xpub or descriptor", source),
];
title.append("Add wallet");
description.append(
"Import an xpub or watch-only descriptor. Spending keys are never needed.",
);
name.name = "name";
name.autocomplete = "off";
name.placeholder = "Wallet 1";
name.required = true;
cancel.type = "button";
cancel.append("Cancel");
submit.type = "submit";
submit.append("Add");
actions.append(cancel, submit);
form.append(
title,
description,
...fields,
actions,
);
cancel.addEventListener("click", options.onCancel);
source.addEventListener("input", () => {
source.removeAttribute("aria-invalid");
});
form.addEventListener("submit", (event) => {
event.preventDefault();
void options.onSubmit({
name,
source,
submit,
form,
});
});
return form;
}
+23
View File
@@ -0,0 +1,23 @@
import { isOutputDescriptor } from "../derive/index.js";
const EXTENDED_PUBLIC_KEY_PATTERN =
/\b(?:xpub|ypub|zpub|tpub|upub|vpub)[1-9A-HJ-NP-Za-km-z]{20,}\b/;
/**
* @param {string} text
*/
export function readWalletSourceText(text) {
const value = text.trim();
if (isOutputDescriptor(value)) {
return value;
}
const match = value.match(EXTENDED_PUBLIC_KEY_PATTERN);
if (match) {
return match[0];
}
throw new Error("Expected an xpub or descriptor");
}
+25
View File
@@ -0,0 +1,25 @@
main.wallets {
.add {
display: grid;
gap: 1.25rem;
> h2 {
color: var(--black);
font-size: 2.5rem;
line-height: 1;
}
> p {
margin: 0;
color: var(--gray);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
}
> footer {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
}
}
+190
View File
@@ -0,0 +1,190 @@
import { redaction } from "../redaction/index.js";
const SATS_PER_BTC = 100_000_000;
const FRACTION_DIGITS = 8;
const FIXED_PRIVATE_TEXT = "*****";
const amounts = /** @type {BtcAmountRecord[]} */ ([]);
/**
* @typedef {Object} BtcAmountOptions
* @property {boolean} [signed]
*
* @typedef {Object} BtcAmount
* @property {number} sats
* @property {boolean} signed
*
* @typedef {Object} BtcAmountRecord
* @property {HTMLElement} element
* @property {BtcAmount} amount
*/
/**
* @typedef {Object} BtcPart
* @property {string} text
* @property {boolean} muted
*/
/**
* @param {BtcPart[]} parts
* @param {string} text
* @param {boolean} muted
*/
function pushPart(parts, text, muted) {
const last = parts[parts.length - 1];
if (last && last.muted === muted) {
last.text += text;
return;
}
parts.push({ text, muted });
}
/**
* @param {number} value
*/
function formatInteger(value) {
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
}
/**
* @param {number} sats
*/
function splitBtc(sats) {
const absolute = Math.abs(sats);
return {
whole: Math.floor(absolute / SATS_PER_BTC),
fraction: String(absolute % SATS_PER_BTC).padStart(FRACTION_DIGITS, "0"),
};
}
/**
* @param {string} fraction
* @param {(index: number) => boolean} isMuted
* @param {(index: number) => boolean} isSpaceMuted
*/
function getFractionParts(fraction, isMuted, isSpaceMuted) {
const parts = /** @type {BtcPart[]} */ ([]);
for (let index = 0; index < fraction.length; index += 1) {
pushPart(parts, fraction[index], isMuted(index));
if (index === 1 || index === 4) {
pushPart(parts, " ", isSpaceMuted(index));
}
}
return parts;
}
/**
* @param {number} sats
* @param {BtcAmountOptions} [options]
*/
function getBtcParts(sats, options = {}) {
const parts = /** @type {BtcPart[]} */ ([]);
const { whole, fraction } = splitBtc(sats);
const firstFractionDigit = fraction.search(/[1-9]/);
const lastFractionDigit = Math.max(...[...fraction].map((digit, index) => {
return digit === "0" ? -1 : index;
}));
if (options.signed && sats > 0) pushPart(parts, "+", false);
if (sats < 0) pushPart(parts, "-", false);
pushPart(parts, "₿", true);
if (whole === 0) {
const mutedUntil = firstFractionDigit === -1
? FRACTION_DIGITS
: firstFractionDigit;
pushPart(parts, "0.", true);
for (const part of getFractionParts(
fraction,
(index) => index < mutedUntil,
(index) => index < mutedUntil,
)) {
pushPart(parts, part.text, part.muted);
}
return parts;
}
pushPart(parts, formatInteger(whole), false);
if (lastFractionDigit === -1) {
pushPart(parts, ".", true);
for (const part of getFractionParts(fraction, () => true, () => true)) {
pushPart(parts, part.text, part.muted);
}
return parts;
}
pushPart(parts, ".", false);
for (const part of getFractionParts(
fraction,
(index) => index > lastFractionDigit,
(index) => index >= lastFractionDigit,
)) {
pushPart(parts, part.text, part.muted);
}
return parts;
}
/**
* @param {HTMLElement} element
* @param {BtcAmount} amount
*/
function renderBtcAmount(element, amount) {
if (redaction.isHidden()) {
element.textContent = FIXED_PRIVATE_TEXT;
return;
}
element.replaceChildren(...getBtcParts(amount.sats, amount).map((part) => {
const span = document.createElement("span");
if (part.muted) {
span.classList.add("muted");
}
span.append(part.text);
return span;
}));
}
/**
* @template {keyof HTMLElementTagNameMap} Tag
* @param {Tag} tag
* @param {number} sats
* @param {BtcAmountOptions} [options]
*/
export function createBtcAmount(tag, sats, options = {}) {
const element = document.createElement(tag);
const amount = {
sats,
signed: options.signed === true,
};
element.classList.add("amount");
amounts.push({ element, amount });
renderBtcAmount(element, amount);
return element;
}
export function syncBtcAmounts() {
for (let index = amounts.length - 1; index >= 0; index -= 1) {
const { element, amount } = amounts[index];
if (!element.isConnected) {
amounts.splice(index, 1);
} else {
renderBtcAmount(element, amount);
}
}
}
+7
View File
@@ -0,0 +1,7 @@
main.wallets {
.amount {
.muted {
color: color-mix(in oklch, currentColor 45%, transparent);
}
}
}
+24
View File
@@ -0,0 +1,24 @@
/**
* @template Item, Result
* @param {readonly Item[]} items
* @param {number} limit
* @param {(item: Item) => Promise<Result>} fn
* @returns {Promise<Result[]>}
*/
export async function mapConcurrent(items, limit, fn) {
const results = /** @type {Result[]} */ ([]);
let next = 0;
const workerCount = Math.min(limit, items.length);
const workers = Array.from({ length: workerCount }, async () => {
while (next < items.length) {
const index = next;
next += 1;
results[index] = await fn(items[index]);
}
});
await Promise.all(workers);
return results;
}
+270
View File
@@ -0,0 +1,270 @@
import { encodeBase58Check } from "./base58.js";
import { concatBytes } from "./bytes.js";
import { hash160, sha256 } from "./hash.js";
import {
addXOnlyPublicKeyTweak,
getXOnlyPublicKey,
} from "./secp256k1.js";
const bech32Alphabet = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const bech32Generator = /** @type {const} */ ([
0x3b6a57b2,
0x26508e6d,
0x1ea119fa,
0x3d4233dd,
0x2a1462b3,
]);
const BECH32_CHECKSUM = 1;
const BECH32M_CHECKSUM = 0x2bc830a3;
const p2pkhVersions = /** @type {const} */ ({
mainnet: 0x00,
testnet: 0x6f,
});
const p2shVersions = /** @type {const} */ ({
mainnet: 0x05,
testnet: 0xc4,
});
const bech32Prefixes = /** @type {const} */ ({
mainnet: "bc",
testnet: "tb",
});
/**
* @typedef {"mainnet" | "testnet"} BitcoinNetwork
* @typedef {"p2pkh" | "p2sh_p2wpkh" | "v0_p2wpkh" | "v1_p2tr" | "v0_p2wsh_sortedmulti"} AddressScript
* @typedef {Object} EncodedAddress
* @property {string} address
* @property {Uint8Array} payload
*/
/**
* @param {number} version
* @param {Uint8Array} payload
*/
function encodeVersionedBase58(version, payload) {
return encodeBase58Check(concatBytes([Uint8Array.of(version), payload]));
}
/**
* @param {string} prefix
*/
function expandBech32Prefix(prefix) {
const values = /** @type {number[]} */ ([]);
for (const character of prefix) {
values.push(character.charCodeAt(0) >> 5);
}
values.push(0);
for (const character of prefix) {
values.push(character.charCodeAt(0) & 31);
}
return values;
}
/**
* @param {number[]} values
*/
function bech32Polymod(values) {
let checksum = 1;
for (const value of values) {
const top = checksum >>> 25;
checksum = ((checksum & 0x1ffffff) << 5) ^ value;
for (let i = 0; i < bech32Generator.length; i += 1) {
if ((top >>> i) & 1) {
checksum ^= bech32Generator[i];
}
}
}
return checksum;
}
/**
* @param {string} prefix
* @param {number[]} values
* @param {number} checksumConstant
*/
function createBech32Checksum(prefix, values, checksumConstant) {
const polymod = bech32Polymod([
...expandBech32Prefix(prefix),
...values,
0,
0,
0,
0,
0,
0,
]);
const checksum = /** @type {number[]} */ ([]);
const combined = polymod ^ checksumConstant;
for (let i = 0; i < 6; i += 1) {
checksum.push((combined >>> (5 * (5 - i))) & 31);
}
return checksum;
}
/**
* @param {Uint8Array} bytes
* @param {number} fromBits
* @param {number} toBits
*/
function convertBits(bytes, fromBits, toBits) {
const maxValue = (1 << toBits) - 1;
const values = /** @type {number[]} */ ([]);
let accumulator = 0;
let bits = 0;
for (const byte of bytes) {
accumulator = (accumulator << fromBits) | byte;
bits += fromBits;
while (bits >= toBits) {
bits -= toBits;
values.push((accumulator >>> bits) & maxValue);
}
}
if (bits > 0) {
values.push((accumulator << (toBits - bits)) & maxValue);
}
return values;
}
/**
* @param {string} prefix
* @param {number[]} values
* @param {number} checksumConstant
*/
function encodeBech32(prefix, values, checksumConstant) {
const checksum = createBech32Checksum(prefix, values, checksumConstant);
const characters = [...values, ...checksum].map((value) => {
return bech32Alphabet[value];
});
return `${prefix}1${characters.join("")}`;
}
/**
* @param {string} tag
* @param {Uint8Array} bytes
*/
async function taggedHash(tag, bytes) {
const tagHash = await sha256(new TextEncoder().encode(tag));
return sha256(concatBytes([tagHash, tagHash, bytes]));
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
async function encodeP2pkhAddressData(publicKey, network) {
const payload = await hash160(publicKey);
return {
address: await encodeVersionedBase58(p2pkhVersions[network], payload),
payload,
};
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
async function encodeP2shP2wpkhAddressData(publicKey, network) {
const publicKeyHash = await hash160(publicKey);
const redeemScript = concatBytes([Uint8Array.of(0x00, 0x14), publicKeyHash]);
const payload = await hash160(redeemScript);
return {
address: await encodeVersionedBase58(p2shVersions[network], payload),
payload,
};
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
async function encodeP2wpkhAddressData(publicKey, network) {
const payload = await hash160(publicKey);
const values = [0, ...convertBits(payload, 8, 5)];
return {
address: encodeBech32(bech32Prefixes[network], values, BECH32_CHECKSUM),
payload,
};
}
/**
* @param {Uint8Array} witnessScript
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
export async function encodeP2wshAddressData(witnessScript, network) {
const payload = await sha256(witnessScript);
const values = [0, ...convertBits(payload, 8, 5)];
return {
address: encodeBech32(bech32Prefixes[network], values, BECH32_CHECKSUM),
payload,
};
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
async function encodeP2trAddressData(publicKey, network) {
const internalKey = getXOnlyPublicKey(publicKey);
const tweak = await taggedHash("TapTweak", internalKey);
const payload = addXOnlyPublicKeyTweak(publicKey, tweak);
const values = [1, ...convertBits(payload, 8, 5)];
return {
address: encodeBech32(bech32Prefixes[network], values, BECH32M_CHECKSUM),
payload,
};
}
/**
* @param {Uint8Array} publicKey
* @param {AddressScript} script
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
export async function encodePublicKeyAddressData(publicKey, script, network) {
if (script === "p2pkh") {
return encodeP2pkhAddressData(publicKey, network);
}
if (script === "p2sh_p2wpkh") {
return encodeP2shP2wpkhAddressData(publicKey, network);
}
if (script === "v0_p2wpkh") {
return encodeP2wpkhAddressData(publicKey, network);
}
if (script === "v1_p2tr") {
return encodeP2trAddressData(publicKey, network);
}
throw new Error("Expected a single-key address script");
}
+114
View File
@@ -0,0 +1,114 @@
import { bytesEqual, bytesToBigInt, concatBytes, createBytes } from "./bytes.js";
import { checksum as createChecksum } from "./hash.js";
const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const base = 58n;
/**
* @param {string} character
*/
function readBase58Character(character) {
const index = alphabet.indexOf(character);
if (index === -1) {
throw new Error(`Invalid Base58 character: ${character}`);
}
return index;
}
/**
* @param {string} text
*/
function countLeadingZeros(text) {
let count = 0;
while (text[count] === "1") {
count += 1;
}
return count;
}
/**
* @param {Uint8Array} bytes
*/
function countLeadingZeroBytes(bytes) {
let count = 0;
while (bytes[count] === 0) {
count += 1;
}
return count;
}
/**
* @param {string} text
*/
function decodeBase58(text) {
let value = 0n;
for (const character of text) {
value = value * base + BigInt(readBase58Character(character));
}
const leadingZeros = countLeadingZeros(text);
const decoded = /** @type {number[]} */ ([]);
while (value > 0n) {
decoded.push(Number(value & 0xffn));
value >>= 8n;
}
decoded.reverse();
const bytes = createBytes(leadingZeros + decoded.length);
bytes.set(decoded, leadingZeros);
return bytes;
}
/**
* @param {Uint8Array} bytes
*/
function encodeBase58(bytes) {
let value = bytesToBigInt(bytes);
let text = "";
while (value > 0n) {
const index = Number(value % base);
text = alphabet[index] + text;
value /= base;
}
return "1".repeat(countLeadingZeroBytes(bytes)) + text;
}
/**
* @param {string} text
*/
export async function decodeBase58Check(text) {
const bytes = decodeBase58(text);
if (bytes.length < 4) {
throw new Error("Invalid Base58Check payload");
}
const payload = bytes.slice(0, -4);
const expected = await createChecksum(payload);
const actual = bytes.slice(-4);
if (!bytesEqual(actual, expected)) {
throw new Error("Invalid Base58Check checksum");
}
return payload;
}
/**
* @param {Uint8Array} payload
*/
export async function encodeBase58Check(payload) {
return encodeBase58(concatBytes([payload, await createChecksum(payload)]));
}
+84
View File
@@ -0,0 +1,84 @@
import { concatBytes, writeUint32 } from "./bytes.js";
import { hmacSha512 } from "./hash.js";
import { parseExtendedPublicKey } from "./key.js";
import { addPublicKeyTweak } from "./secp256k1.js";
const HARDENED_INDEX = 0x80000000;
/**
* @typedef {import("./key.js").ExtendedPublicKey} ExtendedPublicKey
*/
/**
* @typedef {Object} DerivedPublicKey
* @property {number} index
* @property {Uint8Array} publicKey
*/
/**
* @param {ExtendedPublicKey} key
* @param {number} index
* @returns {Promise<ExtendedPublicKey>}
*/
async function derivePublicChild(key, index) {
if (!Number.isSafeInteger(index) || index < 0 || index >= HARDENED_INDEX) {
throw new Error("Expected a non-hardened child index");
}
const data = concatBytes([key.publicKey, writeUint32(index)]);
const digest = await hmacSha512(key.chainCode, data);
const tweak = digest.slice(0, 32);
const chainCode = digest.slice(32);
return {
text: key.text,
depth: key.depth + 1,
childNumber: index,
parentFingerprint: key.parentFingerprint,
chainCode,
publicKey: addPublicKeyTweak(key.publicKey, tweak),
version: key.version,
};
}
/**
* @param {ExtendedPublicKey} key
* @param {readonly number[]} path
*/
async function derivePublicPath(key, path) {
let child = key;
for (const index of path) {
child = await derivePublicChild(child, index);
}
return child;
}
/**
* @param {ExtendedPublicKey} key
* @param {number} start
* @param {number} count
* @param {readonly number[]} [path]
* @returns {Promise<DerivedPublicKey[]>}
*/
export async function derivePublicKeys(key, start, count, path = []) {
const parent = await derivePublicPath(key, path);
const children = /** @type {DerivedPublicKey[]} */ ([]);
for (let offset = 0; offset < count; offset += 1) {
const index = start + offset;
const child = await derivePublicChild(parent, index);
children.push({ index, publicKey: child.publicKey });
}
return children;
}
/**
* @param {string} text
*/
export async function parseXpub(text) {
return parseExtendedPublicKey(text);
}
+92
View File
@@ -0,0 +1,92 @@
/**
* @param {number} length
*/
export function createBytes(length) {
return new Uint8Array(length);
}
/**
* @param {Uint8Array[]} parts
*/
export function concatBytes(parts) {
const length = parts.reduce((total, part) => total + part.length, 0);
const bytes = createBytes(length);
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.length;
}
return bytes;
}
/**
* @param {Uint8Array} bytes
* @param {number} start
*/
export function readUint32(bytes, start) {
return (
bytes[start] * 0x1000000 +
bytes[start + 1] * 0x10000 +
bytes[start + 2] * 0x100 +
bytes[start + 3]
);
}
/**
* @param {number} value
*/
export function writeUint32(value) {
const bytes = createBytes(4);
bytes[0] = value >>> 24;
bytes[1] = value >>> 16;
bytes[2] = value >>> 8;
bytes[3] = value;
return bytes;
}
/**
* @param {Uint8Array} bytes
*/
export function bytesToBigInt(bytes) {
let value = 0n;
for (const byte of bytes) {
value = (value << 8n) + BigInt(byte);
}
return value;
}
/**
* @param {bigint} value
* @param {number} length
*/
export function bigIntToBytes(value, length) {
const bytes = createBytes(length);
let remaining = value;
for (let i = length - 1; i >= 0; i -= 1) {
bytes[i] = Number(remaining & 0xffn);
remaining >>= 8n;
}
return bytes;
}
/**
* @param {Uint8Array} left
* @param {Uint8Array} right
*/
export function bytesEqual(left, right) {
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
if (left[i] !== right[i]) return false;
}
return true;
}
+401
View File
@@ -0,0 +1,401 @@
import { concatBytes } from "./bytes.js";
import { derivePublicKeys, parseXpub } from "./bip32.js";
import { encodeP2wshAddressData } from "./address.js";
const CHECKSUM_SEPARATOR = "#";
const WSH_SORTEDMULTI_PREFIX = "wsh(sortedmulti(";
const WSH_SORTEDMULTI_SUFFIX = "))";
const OP_CHECKMULTISIG = 0xae;
const COMPRESSED_PUBLIC_KEY_BYTES = 33;
const MAX_WSH_MULTISIG_KEYS = 20;
/**
* @typedef {import("./address.js").BitcoinNetwork} BitcoinNetwork
* @typedef {import("./index.js").GeneratedAddress} GeneratedAddress
*/
/**
* @typedef {Object} DescriptorKey
* @property {string} xpub
* @property {number[]} path
*/
/**
* @typedef {Object} SortedMultisigDescriptor
* @property {"v0_p2wsh_sortedmulti"} script
* @property {number} threshold
* @property {DescriptorKey[]} keys
*/
/**
* @param {string} text
*/
function compactText(text) {
return text.trim().replace(/\s+/g, "");
}
/**
* @param {string} text
*/
function stripDescriptorChecksum(text) {
const value = compactText(text);
const checksumIndex = value.indexOf(CHECKSUM_SEPARATOR);
return checksumIndex === -1 ? value : value.slice(0, checksumIndex);
}
/**
* @param {string} text
*/
function isSupportedDescriptor(text) {
return (
text.startsWith(WSH_SORTEDMULTI_PREFIX) &&
text.endsWith(WSH_SORTEDMULTI_SUFFIX)
);
}
/**
* @param {string} text
*/
function extractOutputDescriptors(text) {
const value = compactText(text);
const descriptors = /** @type {string[]} */ ([]);
let offset = 0;
while (offset < value.length) {
const start = value.indexOf(WSH_SORTEDMULTI_PREFIX, offset);
if (start === -1) break;
let depth = 0;
let end = -1;
let seenOpen = false;
for (let index = start; index < value.length; index += 1) {
const character = value[index];
if (character === "(") {
depth += 1;
seenOpen = true;
}
if (character === ")") {
depth -= 1;
}
if (seenOpen && depth === 0) {
end = index + 1;
break;
}
}
if (end === -1) break;
const descriptor = stripDescriptorChecksum(value.slice(start, end));
if (isSupportedDescriptor(descriptor)) {
descriptors.push(descriptor);
}
offset = end;
}
return descriptors;
}
/**
* @param {string} text
*/
export function isOutputDescriptor(text) {
return extractOutputDescriptors(text).length > 0;
}
/**
* @param {string} text
*/
function readFirstOutputDescriptor(text) {
const descriptor = extractOutputDescriptors(text)[0];
if (!descriptor) {
throw new Error("Unsupported output descriptor");
}
return descriptor;
}
/**
* @param {string} text
*/
function splitDescriptorArguments(text) {
const values = /** @type {string[]} */ ([]);
let bracketDepth = 0;
let groupDepth = 0;
let start = 0;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (character === "[") bracketDepth += 1;
if (character === "]") bracketDepth -= 1;
if (character === "(") groupDepth += 1;
if (character === ")") groupDepth -= 1;
if (character === "," && bracketDepth === 0 && groupDepth === 0) {
values.push(text.slice(start, index));
start = index + 1;
}
}
values.push(text.slice(start));
return values;
}
/**
* @param {string} value
*/
function readThreshold(value) {
const threshold = Number(value);
if (!Number.isSafeInteger(threshold) || threshold < 1) {
throw new Error("Invalid multisig threshold");
}
return threshold;
}
/**
* @param {string} value
*/
function readNonHardenedIndex(value) {
if (value.endsWith("'") || value.endsWith("h")) {
throw new Error("Descriptor xpub derivation cannot be hardened");
}
const index = Number(value);
if (!Number.isSafeInteger(index) || index < 0) {
throw new Error("Invalid descriptor derivation path");
}
return index;
}
/**
* @param {string} text
*/
function readDescriptorKeyPath(text) {
if (!text.startsWith("/")) {
throw new Error("Expected a ranged descriptor key path");
}
const segments = text.slice(1).split("/");
if (segments[segments.length - 1] !== "*") {
throw new Error("Expected a descriptor wildcard path");
}
return segments.slice(0, -1).map(readNonHardenedIndex);
}
/**
* @param {string} text
* @returns {DescriptorKey}
*/
function readDescriptorKey(text) {
let value = text;
if (value.startsWith("[")) {
const end = value.indexOf("]");
if (end === -1) {
throw new Error("Invalid descriptor key origin");
}
value = value.slice(end + 1);
}
const pathIndex = value.indexOf("/");
if (pathIndex === -1) {
throw new Error("Expected descriptor key derivation");
}
return {
xpub: value.slice(0, pathIndex),
path: readDescriptorKeyPath(value.slice(pathIndex)),
};
}
/**
* @param {string} text
* @returns {SortedMultisigDescriptor}
*/
export function parseOutputDescriptor(text) {
const value = readFirstOutputDescriptor(text);
const body = value.slice(
WSH_SORTEDMULTI_PREFIX.length,
-WSH_SORTEDMULTI_SUFFIX.length,
);
const [thresholdText, ...keyTexts] = splitDescriptorArguments(body);
const threshold = readThreshold(thresholdText);
const keys = keyTexts.map(readDescriptorKey);
if (
threshold > keys.length ||
keys.length < 1 ||
keys.length > MAX_WSH_MULTISIG_KEYS
) {
throw new Error("Invalid multisig key count");
}
return {
script: "v0_p2wsh_sortedmulti",
threshold,
keys,
};
}
/**
* @param {string} descriptorText
*/
function inferDescriptorBranchId(descriptorText) {
const descriptor = parseOutputDescriptor(descriptorText);
const branchIds = descriptor.keys.map((key) => {
return key.path[key.path.length - 1];
});
const sameBranch = branchIds.every((branchId) => {
return branchId === branchIds[0];
});
if (!sameBranch) return undefined;
if (branchIds[0] === 0) return "receive";
if (branchIds[0] === 1) return "change";
}
/**
* @param {string} text
*/
export function getOutputDescriptorBranchIds(text) {
const branchIds = /** @type {string[]} */ ([]);
for (const descriptor of extractOutputDescriptors(text)) {
const branchId = inferDescriptorBranchId(descriptor);
if (branchId && !branchIds.includes(branchId)) {
branchIds.push(branchId);
}
}
return branchIds.length ? branchIds : ["receive"];
}
/**
* @param {string} source
* @param {string} [branchId]
*/
export function selectOutputDescriptor(source, branchId = "receive") {
const descriptors = extractOutputDescriptors(source);
if (descriptors.length === 0) {
throw new Error("Unsupported output descriptor");
}
return descriptors.find((descriptor) => {
return inferDescriptorBranchId(descriptor) === branchId;
}) ?? descriptors[0];
}
/**
* @param {Uint8Array} left
* @param {Uint8Array} right
*/
function compareBytes(left, right) {
for (let index = 0; index < Math.min(left.length, right.length); index += 1) {
if (left[index] !== right[index]) return left[index] - right[index];
}
return left.length - right.length;
}
/**
* @param {number} value
*/
function encodeScriptNumber(value) {
if (value <= 16) return Uint8Array.of(0x50 + value);
return Uint8Array.of(0x01, value);
}
/**
* @param {readonly Uint8Array[]} publicKeys
* @param {number} threshold
*/
function encodeSortedMultisigScript(publicKeys, threshold) {
const sortedKeys = [...publicKeys].sort(compareBytes);
const pushes = sortedKeys.map((publicKey) => {
if (publicKey.length !== COMPRESSED_PUBLIC_KEY_BYTES) {
throw new Error("Expected compressed multisig public keys");
}
return concatBytes([Uint8Array.of(COMPRESSED_PUBLIC_KEY_BYTES), publicKey]);
});
return concatBytes([
encodeScriptNumber(threshold),
...pushes,
encodeScriptNumber(sortedKeys.length),
Uint8Array.of(OP_CHECKMULTISIG),
]);
}
/**
* @param {string} descriptorText
* @param {Object} options
* @param {number} options.start
* @param {number} options.count
* @returns {Promise<GeneratedAddress[]>}
*/
export async function generateAddressesFromDescriptor(descriptorText, options) {
const descriptor = parseOutputDescriptor(descriptorText);
const parsedKeys = await Promise.all(
descriptor.keys.map((key) => parseXpub(key.xpub)),
);
const network = parsedKeys[0].version.network;
const childSets = await Promise.all(
parsedKeys.map((key, index) => {
if (key.version.network !== network) {
throw new Error("Descriptor xpub networks must match");
}
return derivePublicKeys(
key,
options.start,
options.count,
descriptor.keys[index].path,
);
}),
);
const addresses = /** @type {GeneratedAddress[]} */ ([]);
for (let offset = 0; offset < options.count; offset += 1) {
const publicKeys = childSets.map((children) => children[offset].publicKey);
const witnessScript = encodeSortedMultisigScript(
publicKeys,
descriptor.threshold,
);
const addressData = await encodeP2wshAddressData(witnessScript, network);
addresses.push({
index: options.start + offset,
address: addressData.address,
payload: addressData.payload,
script: descriptor.script,
network,
addrType: "v0_p2wsh",
});
}
return addresses;
}
+265
View File
@@ -0,0 +1,265 @@
import { createBytes } from "./bytes.js";
const ripemdLeftIndexes = /** @type {const} */ ([
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
7, 4, 13, 1, 10, 6, 15, 3, 12, 0, 9, 5, 2, 14, 11, 8,
3, 10, 14, 4, 9, 15, 8, 1, 2, 7, 0, 6, 13, 11, 5, 12,
1, 9, 11, 10, 0, 8, 12, 4, 13, 3, 7, 15, 14, 5, 6, 2,
4, 0, 5, 9, 7, 12, 2, 10, 14, 1, 3, 8, 11, 6, 15, 13,
]);
const ripemdRightIndexes = /** @type {const} */ ([
5, 14, 7, 0, 9, 2, 11, 4, 13, 6, 15, 8, 1, 10, 3, 12,
6, 11, 3, 7, 0, 13, 5, 10, 14, 15, 8, 12, 4, 9, 1, 2,
15, 5, 1, 3, 7, 14, 6, 9, 11, 8, 12, 2, 10, 0, 4, 13,
8, 6, 4, 1, 3, 11, 15, 0, 5, 12, 2, 13, 9, 7, 10, 14,
12, 15, 10, 4, 1, 5, 8, 7, 6, 2, 13, 14, 0, 3, 9, 11,
]);
const ripemdLeftShifts = /** @type {const} */ ([
11, 14, 15, 12, 5, 8, 7, 9, 11, 13, 14, 15, 6, 7, 9, 8,
7, 6, 8, 13, 11, 9, 7, 15, 7, 12, 15, 9, 11, 7, 13, 12,
11, 13, 6, 7, 14, 9, 13, 15, 14, 8, 13, 6, 5, 12, 7, 5,
11, 12, 14, 15, 14, 15, 9, 8, 9, 14, 5, 6, 8, 6, 5, 12,
9, 15, 5, 11, 6, 8, 13, 12, 5, 12, 13, 14, 11, 8, 5, 6,
]);
const ripemdRightShifts = /** @type {const} */ ([
8, 9, 9, 11, 13, 15, 15, 5, 7, 7, 8, 11, 14, 14, 12, 6,
9, 13, 15, 7, 12, 8, 9, 11, 7, 7, 12, 7, 6, 15, 13, 11,
9, 7, 15, 11, 8, 6, 6, 14, 12, 13, 5, 14, 13, 13, 7, 5,
15, 5, 8, 11, 14, 14, 6, 14, 6, 9, 12, 9, 12, 5, 15, 8,
8, 5, 12, 9, 12, 5, 14, 6, 8, 13, 6, 5, 15, 13, 11, 11,
]);
const ripemdLeftConstants = /** @type {const} */ ([
0x00000000,
0x5a827999,
0x6ed9eba1,
0x8f1bbcdc,
0xa953fd4e,
]);
const ripemdRightConstants = /** @type {const} */ ([
0x50a28be6,
0x5c4dd124,
0x6d703ef3,
0x7a6d76e9,
0x00000000,
]);
/**
* @param {Uint8Array} bytes
*/
function toArrayBuffer(bytes) {
const buffer = new ArrayBuffer(bytes.length);
new Uint8Array(buffer).set(bytes);
return buffer;
}
/**
* @param {Uint8Array} bytes
*/
export async function sha256(bytes) {
return new Uint8Array(
await crypto.subtle.digest("SHA-256", toArrayBuffer(bytes)),
);
}
/**
* @param {Uint8Array} bytes
*/
async function doubleSha256(bytes) {
return sha256(await sha256(bytes));
}
/**
* @param {Uint8Array} key
* @param {Uint8Array} bytes
*/
export async function hmacSha512(key, bytes) {
const cryptoKey = await crypto.subtle.importKey(
"raw",
toArrayBuffer(key),
{ name: "HMAC", hash: "SHA-512" },
false,
["sign"],
);
return new Uint8Array(
await crypto.subtle.sign("HMAC", cryptoKey, toArrayBuffer(bytes)),
);
}
/**
* @param {number} value
* @param {number} bits
*/
function rotateLeft(value, bits) {
return (value << bits) | (value >>> (32 - bits));
}
/**
* @param {number} round
* @param {number} x
* @param {number} y
* @param {number} z
*/
function ripemdFunction(round, x, y, z) {
if (round < 16) return x ^ y ^ z;
if (round < 32) return (x & y) | (~x & z);
if (round < 48) return (x | ~y) ^ z;
if (round < 64) return (x & z) | (y & ~z);
return x ^ (y | ~z);
}
/**
* @param {Uint8Array} bytes
*/
function createRipemdBlocks(bytes) {
const bitLength = BigInt(bytes.length) * 8n;
const length = bytes.length + 1 + 8;
const paddedLength = Math.ceil(length / 64) * 64;
const padded = createBytes(paddedLength);
padded.set(bytes);
padded[bytes.length] = 0x80;
for (let i = 0; i < 8; i += 1) {
padded[paddedLength - 8 + i] = Number(
(bitLength >> (BigInt(i) * 8n)) & 0xffn,
);
}
return padded;
}
/**
* @param {Uint8Array} block
* @param {number} offset
*/
function readRipemdWords(block, offset) {
const words = /** @type {number[]} */ ([]);
for (let i = 0; i < 16; i += 1) {
const start = offset + i * 4;
words.push(
block[start] |
(block[start + 1] << 8) |
(block[start + 2] << 16) |
(block[start + 3] << 24),
);
}
return words;
}
/**
* @param {Uint8Array} target
* @param {number} offset
* @param {number} value
*/
function writeRipemdWord(target, offset, value) {
target[offset] = value;
target[offset + 1] = value >>> 8;
target[offset + 2] = value >>> 16;
target[offset + 3] = value >>> 24;
}
/**
* @param {Uint8Array} bytes
*/
function ripemd160(bytes) {
const blocks = createRipemdBlocks(bytes);
const digest = createBytes(20);
let h0 = 0x67452301;
let h1 = 0xefcdab89;
let h2 = 0x98badcfe;
let h3 = 0x10325476;
let h4 = 0xc3d2e1f0;
for (let offset = 0; offset < blocks.length; offset += 64) {
const words = readRipemdWords(blocks, offset);
let al = h0;
let bl = h1;
let cl = h2;
let dl = h3;
let el = h4;
let ar = h0;
let br = h1;
let cr = h2;
let dr = h3;
let er = h4;
for (let round = 0; round < 80; round += 1) {
const leftGroup = Math.floor(round / 16);
const rightGroup = Math.floor(round / 16);
const nextLeft =
(rotateLeft(
(al +
ripemdFunction(round, bl, cl, dl) +
words[ripemdLeftIndexes[round]] +
ripemdLeftConstants[leftGroup]) |
0,
ripemdLeftShifts[round],
) +
el) |
0;
const nextRight =
(rotateLeft(
(ar +
ripemdFunction(79 - round, br, cr, dr) +
words[ripemdRightIndexes[round]] +
ripemdRightConstants[rightGroup]) |
0,
ripemdRightShifts[round],
) +
er) |
0;
al = el;
el = dl;
dl = rotateLeft(cl, 10);
cl = bl;
bl = nextLeft;
ar = er;
er = dr;
dr = rotateLeft(cr, 10);
cr = br;
br = nextRight;
}
const nextH0 = (h1 + cl + dr) | 0;
h1 = (h2 + dl + er) | 0;
h2 = (h3 + el + ar) | 0;
h3 = (h4 + al + br) | 0;
h4 = (h0 + bl + cr) | 0;
h0 = nextH0;
}
writeRipemdWord(digest, 0, h0);
writeRipemdWord(digest, 4, h1);
writeRipemdWord(digest, 8, h2);
writeRipemdWord(digest, 12, h3);
writeRipemdWord(digest, 16, h4);
return digest;
}
/**
* @param {Uint8Array} bytes
*/
export async function hash160(bytes) {
return ripemd160(await sha256(bytes));
}
/**
* @param {Uint8Array} bytes
*/
export async function checksum(bytes) {
return (await doubleSha256(bytes)).slice(0, 4);
}
+134
View File
@@ -0,0 +1,134 @@
import { encodePublicKeyAddressData } from "./address.js";
import { derivePublicKeys, parseXpub } from "./bip32.js";
import {
generateAddressesFromDescriptor,
getOutputDescriptorBranchIds,
isOutputDescriptor,
selectOutputDescriptor,
} from "./descriptor.js";
const DEFAULT_START_INDEX = 0;
const DEFAULT_ADDRESS_COUNT = 20;
const MAX_ADDRESS_COUNT = 100;
const addrTypeByScript = /** @type {const} */ ({
p2pkh: "p2pkh",
p2sh_p2wpkh: "p2sh",
v0_p2wpkh: "v0_p2wpkh",
v1_p2tr: "v1_p2tr",
v0_p2wsh_sortedmulti: "v0_p2wsh",
});
/**
* @typedef {import("./address.js").AddressScript} AddressScript
* @typedef {import("./address.js").BitcoinNetwork} BitcoinNetwork
* @typedef {(typeof addrTypeByScript)[keyof typeof addrTypeByScript]} AddressType
*/
/**
* @typedef {Object} GeneratedAddress
* @property {number} index
* @property {string} address
* @property {Uint8Array} payload
* @property {Uint8Array} [publicKey]
* @property {AddressScript} script
* @property {BitcoinNetwork} network
* @property {AddressType} addrType
*/
/**
* @typedef {Object} GenerateAddressesOptions
* @property {number} [start]
* @property {number} [count]
* @property {AddressScript} [script]
* @property {readonly number[]} [path]
* @property {string} [branchId]
*/
/**
* @param {number | undefined} value
*/
function readStart(value) {
if (value === undefined) return DEFAULT_START_INDEX;
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error("Expected a non-negative start index");
}
return value;
}
/**
* @param {number | undefined} value
*/
function readCount(value) {
const count = value ?? DEFAULT_ADDRESS_COUNT;
if (!Number.isSafeInteger(count) || count < 1 || count > MAX_ADDRESS_COUNT) {
throw new Error(`Expected an address count from 1 to ${MAX_ADDRESS_COUNT}`);
}
return count;
}
/**
* @param {string} source
* @param {GenerateAddressesOptions} [options]
* @returns {Promise<GeneratedAddress[]>}
*/
export async function generateAddressesFromKey(source, options = {}) {
const key = await parseXpub(source);
const start = readStart(options.start);
const count = readCount(options.count);
const script = options.script ?? key.version.script;
const addrType = addrTypeByScript[script];
const children = await derivePublicKeys(key, start, count, options.path);
const addresses = /** @type {GeneratedAddress[]} */ ([]);
for (const child of children) {
const addressData = await encodePublicKeyAddressData(
child.publicKey,
script,
key.version.network,
);
addresses.push({
index: child.index,
address: addressData.address,
payload: addressData.payload,
publicKey: child.publicKey,
script,
network: key.version.network,
addrType,
});
}
return addresses;
}
/**
* @param {string} source
* @param {GenerateAddressesOptions} [options]
* @returns {Promise<GeneratedAddress[]>}
*/
export async function generateAddressesFromWalletSource(source, options = {}) {
const start = readStart(options.start);
const count = readCount(options.count);
if (isOutputDescriptor(source)) {
return generateAddressesFromDescriptor(
selectOutputDescriptor(source, options.branchId),
{ start, count },
);
}
return generateAddressesFromKey(source, {
...options,
start,
count,
});
}
export {
getOutputDescriptorBranchIds,
isOutputDescriptor,
};
+124
View File
@@ -0,0 +1,124 @@
import { decodeBase58Check } from "./base58.js";
import { readUint32 } from "./bytes.js";
const EXTENDED_PUBLIC_KEY_LENGTH = 78;
const PUBLIC_KEY_LENGTH = 33;
const CHAIN_CODE_LENGTH = 32;
const extendedPublicKeyVersions = /** @type {const} */ ([
{
version: 0x0488b21e,
prefix: "xpub",
network: "mainnet",
script: "p2pkh",
addrType: "p2pkh",
},
{
version: 0x049d7cb2,
prefix: "ypub",
network: "mainnet",
script: "p2sh_p2wpkh",
addrType: "p2sh",
},
{
version: 0x04b24746,
prefix: "zpub",
network: "mainnet",
script: "v0_p2wpkh",
addrType: "v0_p2wpkh",
},
{
version: 0x043587cf,
prefix: "tpub",
network: "testnet",
script: "p2pkh",
addrType: "p2pkh",
},
{
version: 0x044a5262,
prefix: "upub",
network: "testnet",
script: "p2sh_p2wpkh",
addrType: "p2sh",
},
{
version: 0x045f1cf6,
prefix: "vpub",
network: "testnet",
script: "v0_p2wpkh",
addrType: "v0_p2wpkh",
},
]);
/**
* @typedef {typeof extendedPublicKeyVersions[number]} ExtendedPublicKeyVersion
*/
/**
* @typedef {Object} ExtendedPublicKey
* @property {string} text
* @property {number} depth
* @property {number} childNumber
* @property {Uint8Array} parentFingerprint
* @property {Uint8Array} chainCode
* @property {Uint8Array} publicKey
* @property {ExtendedPublicKeyVersion} version
*/
/**
* @param {number} version
* @returns {ExtendedPublicKeyVersion}
*/
function findExtendedPublicKeyVersion(version) {
const metadata = extendedPublicKeyVersions.find((item) => {
return item.version === version;
});
if (!metadata) {
throw new Error(`Unsupported extended public key version: ${version}`);
}
return metadata;
}
/**
* @param {Uint8Array} publicKey
*/
function validateCompressedPublicKey(publicKey) {
if (
publicKey.length !== PUBLIC_KEY_LENGTH ||
(publicKey[0] !== 0x02 && publicKey[0] !== 0x03)
) {
throw new Error("Expected a compressed public key");
}
}
/**
* @param {string} text
* @returns {Promise<ExtendedPublicKey>}
*/
export async function parseExtendedPublicKey(text) {
const value = text.trim();
const bytes = await decodeBase58Check(value);
if (bytes.length !== EXTENDED_PUBLIC_KEY_LENGTH) {
throw new Error("Invalid extended public key length");
}
const version = findExtendedPublicKeyVersion(readUint32(bytes, 0));
const parentFingerprint = bytes.slice(5, 9);
const chainCode = bytes.slice(13, 13 + CHAIN_CODE_LENGTH);
const publicKey = bytes.slice(45);
validateCompressedPublicKey(publicKey);
return {
text: value,
depth: bytes[4],
childNumber: readUint32(bytes, 9),
parentFingerprint,
chainCode,
publicKey,
version,
};
}
+6
View File
@@ -0,0 +1,6 @@
export const addressScripts = /** @type {const} */ ([
{ id: "v0_p2wpkh", label: "P2WPKH" },
{ id: "v1_p2tr", label: "P2TR" },
{ id: "p2sh_p2wpkh", label: "Nested P2WPKH" },
{ id: "p2pkh", label: "P2PKH" },
]);
+255
View File
@@ -0,0 +1,255 @@
import { bigIntToBytes, bytesToBigInt, createBytes } from "./bytes.js";
const FIELD_PRIME =
0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn;
const GROUP_ORDER =
0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
const GENERATOR = /** @type {const} */ ({
x: 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n,
y: 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n,
});
/**
* @typedef {Object} Secp256k1Point
* @property {bigint} x
* @property {bigint} y
*/
/**
* @param {bigint} value
* @param {bigint} modulo
*/
function mod(value, modulo) {
const result = value % modulo;
return result >= 0n ? result : result + modulo;
}
/**
* @param {bigint} value
* @param {bigint} exponent
* @param {bigint} modulo
*/
function modPow(value, exponent, modulo) {
let result = 1n;
let base = mod(value, modulo);
let power = exponent;
while (power > 0n) {
if (power & 1n) result = mod(result * base, modulo);
base = mod(base * base, modulo);
power >>= 1n;
}
return result;
}
/**
* @param {bigint} value
*/
function invertField(value) {
let low = mod(value, FIELD_PRIME);
let high = FIELD_PRIME;
let lowCoefficient = 1n;
let highCoefficient = 0n;
while (low > 1n) {
const ratio = high / low;
[low, high] = [high - low * ratio, low];
[lowCoefficient, highCoefficient] = [
highCoefficient - lowCoefficient * ratio,
lowCoefficient,
];
}
return mod(lowCoefficient, FIELD_PRIME);
}
/**
* @param {Secp256k1Point} point
*/
function isOnCurve(point) {
const left = mod(point.y * point.y, FIELD_PRIME);
const right = mod(point.x * point.x * point.x + 7n, FIELD_PRIME);
return left === right;
}
/**
* @param {Secp256k1Point} point
*/
function doublePoint(point) {
if (point.y === 0n) return null;
const slope = mod(
3n * point.x * point.x * invertField(2n * point.y),
FIELD_PRIME,
);
const x = mod(slope * slope - 2n * point.x, FIELD_PRIME);
const y = mod(slope * (point.x - x) - point.y, FIELD_PRIME);
return { x, y };
}
/**
* @param {Secp256k1Point} point
*/
function negatePoint(point) {
return { x: point.x, y: mod(-point.y, FIELD_PRIME) };
}
/**
* @param {Secp256k1Point} point
*/
function forceEvenY(point) {
return point.y & 1n ? negatePoint(point) : point;
}
/**
* @param {Secp256k1Point | null} left
* @param {Secp256k1Point | null} right
* @returns {Secp256k1Point | null}
*/
function addPoints(left, right) {
if (!left) return right;
if (!right) return left;
if (left.x === right.x) {
if (mod(left.y + right.y, FIELD_PRIME) === 0n) return null;
return doublePoint(left);
}
const slope = mod(
(right.y - left.y) * invertField(right.x - left.x),
FIELD_PRIME,
);
const x = mod(slope * slope - left.x - right.x, FIELD_PRIME);
const y = mod(slope * (left.x - x) - left.y, FIELD_PRIME);
return { x, y };
}
/**
* @param {bigint} scalar
* @param {Secp256k1Point} point
* @returns {Secp256k1Point | null}
*/
function multiplyPoint(scalar, point) {
let result = /** @type {Secp256k1Point | null} */ (null);
let addend = /** @type {Secp256k1Point | null} */ (point);
let remaining = scalar;
while (remaining > 0n) {
if (remaining & 1n) result = addPoints(result, addend);
addend = addPoints(addend, addend);
remaining >>= 1n;
}
return result;
}
/**
* @param {bigint} x
* @param {boolean} odd
*/
function liftX(x, odd) {
if (x >= FIELD_PRIME) {
throw new Error("Invalid secp256k1 x coordinate");
}
let y = modPow(x * x * x + 7n, (FIELD_PRIME + 1n) / 4n, FIELD_PRIME);
if (Boolean(y & 1n) !== odd) {
y = FIELD_PRIME - y;
}
const point = { x, y };
if (!isOnCurve(point)) {
throw new Error("Invalid secp256k1 point");
}
return point;
}
/**
* @param {Uint8Array} publicKey
*/
function parseCompressedPublicKey(publicKey) {
if (
publicKey.length !== 33 ||
(publicKey[0] !== 0x02 && publicKey[0] !== 0x03)
) {
throw new Error("Expected a compressed public key");
}
return liftX(bytesToBigInt(publicKey.slice(1)), publicKey[0] === 0x03);
}
/**
* @param {Secp256k1Point} point
*/
function compressPublicKey(point) {
const publicKey = createBytes(33);
publicKey[0] = point.y & 1n ? 0x03 : 0x02;
publicKey.set(bigIntToBytes(point.x, 32), 1);
return publicKey;
}
/**
* @param {Uint8Array} publicKey
*/
export function getXOnlyPublicKey(publicKey) {
const point = forceEvenY(parseCompressedPublicKey(publicKey));
return bigIntToBytes(point.x, 32);
}
/**
* @param {Uint8Array} publicKey
* @param {Uint8Array} tweak
*/
export function addPublicKeyTweak(publicKey, tweak) {
const scalar = bytesToBigInt(tweak);
if (scalar === 0n || scalar >= GROUP_ORDER) {
throw new Error("Invalid secp256k1 public key tweak");
}
const tweakPoint = multiplyPoint(scalar, GENERATOR);
const childPoint = addPoints(parseCompressedPublicKey(publicKey), tweakPoint);
if (!childPoint) {
throw new Error("Invalid secp256k1 child public key");
}
return compressPublicKey(childPoint);
}
/**
* @param {Uint8Array} publicKey
* @param {Uint8Array} tweak
*/
export function addXOnlyPublicKeyTweak(publicKey, tweak) {
const scalar = bytesToBigInt(tweak);
if (scalar >= GROUP_ORDER) {
throw new Error("Invalid secp256k1 x-only public key tweak");
}
const internalPoint = forceEvenY(parseCompressedPublicKey(publicKey));
const tweakPoint = scalar === 0n ? null : multiplyPoint(scalar, GENERATOR);
const outputPoint = addPoints(internalPoint, tweakPoint);
if (!outputPoint) {
throw new Error("Invalid secp256k1 x-only child public key");
}
return bigIntToBytes(outputPoint.x, 32);
}
+48
View File
@@ -0,0 +1,48 @@
/**
* @template {keyof HTMLElementTagNameMap} Tag
* @param {Tag} tag
* @param {string} className
*/
export function createElement(tag, className) {
const element = document.createElement(tag);
element.className = className;
return element;
}
/**
* @param {HTMLButtonElement} button
* @param {boolean} busy
* @param {string} idleLabel
* @param {string} busyLabel
*/
export function setBusy(button, busy, idleLabel, busyLabel) {
button.disabled = busy;
button.ariaBusy = busy ? "true" : "false";
button.textContent = busy ? busyLabel : idleLabel;
}
/**
* @param {HTMLButtonElement} button
* @param {string} idleLabel
* @param {string} busyLabel
* @param {() => Promise<void>} task
*/
export async function withBusy(button, idleLabel, busyLabel, task) {
setBusy(button, true, idleLabel, busyLabel);
try {
await task();
} finally {
setBusy(button, false, idleLabel, busyLabel);
}
}
/**
* @param {HTMLElement} status
* @param {string} text
*/
export function setStatus(status, text) {
status.textContent = text;
}
+38
View File
@@ -0,0 +1,38 @@
import { createElement } from "../dom.js";
/**
* @typedef {Object} EmptyOptions
* @property {() => void} onAdd
* @property {() => void} [onClear]
*/
/**
* @param {EmptyOptions} options
*/
export function createEmpty(options) {
const empty = createElement("section", "empty");
const title = document.createElement("h1");
const text = document.createElement("p");
const actions = document.createElement("menu");
const button = document.createElement("button");
title.append("No wallet yet");
text.append("Import an xpub or watch-only descriptor to start watching activity.");
button.type = "button";
button.append("Add wallet");
button.addEventListener("click", options.onAdd);
actions.append(button);
if (options.onClear) {
const clear = document.createElement("button");
clear.type = "button";
clear.append("Clear");
clear.addEventListener("click", options.onClear);
actions.append(clear);
}
empty.append(title, text, actions);
return empty;
}
+34
View File
@@ -0,0 +1,34 @@
main.wallets {
.empty {
display: grid;
gap: 1rem;
place-content: center;
min-height: calc(100dvh - 2 * var(--offset));
text-align: center;
h1 {
margin: 0;
font-size: 4rem;
font-weight: 400;
line-height: 1;
}
p {
max-width: 31rem;
margin: 0;
color: var(--gray);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
}
> menu {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
margin: 0;
padding: 0;
list-style: none;
}
}
}
+6
View File
@@ -0,0 +1,6 @@
/**
* @param {unknown} error
*/
export function getErrorMessage(error) {
return error instanceof Error ? error.message : "Request failed";
}
+13
View File
@@ -0,0 +1,13 @@
/**
* @param {string} label
* @param {HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement} control
*/
export function createField(label, control) {
const element = document.createElement("label");
const text = document.createElement("span");
text.append(label);
element.append(text, control);
return element;
}
+14
View File
@@ -0,0 +1,14 @@
main.wallets {
label {
display: grid;
gap: 0.375rem;
min-width: 0;
> span {
color: var(--gray);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
text-transform: uppercase;
}
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* @param {number} value
*/
export function formatNumber(value) {
return new Intl.NumberFormat("en-US").format(value);
}
/**
* @param {number} dollars
*/
export function formatUsd(dollars) {
return new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 0,
style: "currency",
}).format(dollars);
}
+136
View File
@@ -0,0 +1,136 @@
import { createElement } from "../dom.js";
const FILL_MS = 2_000;
const DRAIN_MS = 600;
/**
* @param {number} value
*/
function clampProgress(value) {
return Math.max(0, Math.min(1, value));
}
/**
* @param {HTMLButtonElement} button
* @param {() => void} onHold
*/
function bindHold(button, onHold) {
/** @type {number | undefined} */
let frame;
let holding = false;
let progress = 0;
let previous = 0;
function render() {
button.style.setProperty("--hold-progress", String(progress));
button.style.setProperty("--hold-progress-width", `${progress * 100}%`);
button.classList.toggle("active", progress > 0);
}
function stop() {
if (frame === undefined) return;
cancelAnimationFrame(frame);
frame = undefined;
}
/**
* @param {number} now
*/
function tick(now) {
const elapsed = now - previous;
const rate = elapsed / (holding ? FILL_MS : DRAIN_MS);
previous = now;
progress = clampProgress(progress + (holding ? rate : -rate));
render();
if (holding && progress === 1) {
stop();
holding = false;
progress = 0;
button.classList.remove("holding");
render();
onHold();
return;
}
if (!holding && progress === 0) {
stop();
return;
}
frame = requestAnimationFrame(tick);
}
function run() {
if (frame !== undefined) return;
previous = performance.now();
frame = requestAnimationFrame(tick);
}
function release() {
if (!holding) return;
holding = false;
button.classList.remove("holding");
run();
}
function hold() {
stop();
holding = true;
button.classList.add("holding");
run();
}
render();
button.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
button.setPointerCapture(event.pointerId);
hold();
});
button.addEventListener("pointerup", release);
button.addEventListener("pointercancel", release);
button.addEventListener("lostpointercapture", release);
button.addEventListener("keydown", (event) => {
if (event.repeat || (event.key !== " " && event.key !== "Enter")) return;
event.preventDefault();
hold();
});
button.addEventListener("keyup", (event) => {
if (event.key === " " || event.key === "Enter") {
release();
}
});
button.addEventListener("blur", release);
}
/**
* @param {Object} options
* @param {string} options.label
* @param {string} options.title
* @param {string} [options.className]
* @param {() => void} options.onHold
*/
export function createHoldButton(options) {
const button = createElement("button", "hold");
const label = document.createElement("span");
if (options.className) {
button.classList.add(options.className);
}
button.type = "button";
button.dataset.label = options.label;
button.title = options.title;
label.append(options.label);
button.append(label);
bindHold(button, options.onHold);
return button;
}
+56
View File
@@ -0,0 +1,56 @@
main.wallets {
.hold {
position: relative;
isolation: isolate;
overflow: hidden;
color: color-mix(in oklch, var(--gray) 76%, transparent);
background: transparent;
--hold-progress: 0;
--hold-progress-width: 0%;
&::before,
&::after {
content: "";
position: absolute;
inset: 0;
opacity: 0;
}
&::before {
z-index: -1;
background: var(--red);
transform: scaleX(var(--hold-progress));
transform-origin: left;
}
&::after {
content: attr(data-label);
display: flex;
align-items: center;
justify-content: center;
padding: inherit;
color: var(--black);
pointer-events: none;
white-space: nowrap;
clip-path: inset(0 calc(100% - var(--hold-progress-width)) 0 0);
}
span {
color: inherit;
}
&:is(:hover, :focus-visible, :active):not(.holding) {
color: var(--red);
background: transparent;
}
&.active {
color: var(--red);
}
&.active::before,
&.active::after {
opacity: 1;
}
}
}
+319
View File
@@ -0,0 +1,319 @@
import { brk } from "../utils/client.js";
import {
setStatus,
withBusy,
} from "./dom.js";
import { createEmpty } from "./empty/index.js";
import { getErrorMessage } from "./errors.js";
import { createAddForm } from "./add/index.js";
import { createLayout } from "./layout/index.js";
import { redaction } from "./redaction/index.js";
import { readWalletSourceText } from "./add/source.js";
import { scanStatus } from "./wallet/status.js";
import { createSelector } from "./selector/index.js";
import { createStart } from "./start/index.js";
import {
createWalletPanel,
renderWalletPanel,
} from "./wallet/index.js";
import { createVault } from "./vault/index.js";
import { generateAddressesFromWalletSource } from "./derive/index.js";
import { syncBtcAmounts } from "./amount/index.js";
/**
* @typedef {import("./scan/index.js").WalletScan} WalletScan
* @typedef {import("./vault/index.js").StoredWallet} StoredWallet
* @typedef {import("./vault/index.js").WalletRuntime} WalletRuntime
*/
export function createWalletsPage() {
const {
main,
utilities,
privacyButton,
sessionButton,
selector: selectorElement,
walletList,
content,
addDialog,
} = createLayout();
const vault = createVault();
const selector = createSelector(walletList, {
getSelectedId() {
return vault.selectedId;
},
onSelect: select,
onAdd() {
openAdd();
},
onDelete() {
deleteWallet(vault.selectedId);
},
});
redaction.syncButton(privacyButton);
/**
* @param {string} walletId
*/
function select(walletId) {
vault.select(walletId);
render();
}
function lock() {
vault.lock();
render();
}
function reset() {
vault.reset();
render();
}
function startEphemeral() {
vault.startEphemeral();
render();
}
function clearEphemeral() {
vault.clearEphemeral();
render();
}
/**
* @param {string} walletId
*/
function deleteWallet(walletId) {
void vault.deleteWallet(walletId).then(() => {
render();
}, (error) => {
console.error(error);
});
}
function openAdd() {
addDialog.replaceChildren(createAddForm({
onCancel() {
addDialog.close();
},
onSubmit(submit) {
return submitAdd(submit);
},
}));
addDialog.showModal();
}
privacyButton.addEventListener("click", () => {
redaction.toggle(privacyButton);
syncBtcAmounts();
});
sessionButton.addEventListener("click", () => {
if (vault.isEphemeral()) {
clearEphemeral();
return;
}
lock();
});
/**
* @param {"create" | "unlock"} mode
*/
function renderStart(mode) {
content.replaceChildren(createStart({
mode,
onPassword(password, button, status) {
return mode === "unlock"
? unlock(password, button, status)
: setup(password, button, status);
},
onEphemeral() {
startEphemeral();
},
onReset: mode === "unlock" ? reset : undefined,
}));
}
/**
* @param {StoredWallet} wallet
* @param {WalletRuntime} runtime
*/
function renderUnlocked(wallet, runtime) {
const panel = createWalletPanel();
content.replaceChildren(...panel.nodes);
if (runtime.scan) {
renderWalletData(runtime.scan, panel);
setStatus(panel.status, "Ready");
return;
}
scanStatus.setPending(panel.status);
void runtime.load({
client: brk,
onProgress(progress) {
scanStatus.setProgress(panel.status, progress);
},
}).then((scan) => {
if (!isCurrentPanel(wallet, runtime, panel)) return;
renderWalletData(scan, panel);
setStatus(panel.status, "Ready");
}, (error) => {
if (isCurrentPanel(wallet, runtime, panel)) {
setStatus(panel.status, getErrorMessage(error));
}
});
}
/**
* @param {StoredWallet} wallet
* @param {WalletRuntime} runtime
* @param {ReturnType<typeof createWalletPanel>} panel
*/
function isCurrentPanel(wallet, runtime, panel) {
return (
vault.isCurrent(wallet, runtime) &&
!vault.isLocked() &&
vault.selectedId === wallet.id &&
panel.results.isConnected
);
}
/**
* @param {WalletScan} scan
* @param {ReturnType<typeof createWalletPanel>} panel
*/
function renderWalletData(scan, panel) {
renderWalletPanel(scan, panel, brk);
}
/**
* @param {string} password
* @param {HTMLButtonElement} button
* @param {HTMLElement} status
* @returns {Promise<boolean>}
*/
async function unlock(password, button, status) {
let unlocked = false;
await withBusy(button, "Unlock", "Unlocking", async () => {
setStatus(status, "");
try {
await vault.unlock(password);
unlocked = true;
render();
} catch {
unlocked = false;
}
});
return unlocked;
}
/**
* @param {string} password
* @param {HTMLButtonElement} button
* @param {HTMLElement} status
*/
async function setup(password, button, status) {
await withBusy(button, "Create", "Creating", async () => {
setStatus(status, "");
try {
await vault.setup(password);
render();
} catch (error) {
setStatus(status, getErrorMessage(error));
}
});
}
function renderContent() {
const needsSetup = vault.needsSetup();
const locked = vault.isLocked();
const ephemeral = vault.isEphemeral();
const current = vault.current();
const empty = !needsSetup && !locked && !current;
utilities.hidden = locked || needsSetup || empty;
selectorElement.hidden = locked || needsSetup || empty;
sessionButton.hidden = locked || needsSetup || (!vault.hasPassword && !ephemeral);
sessionButton.textContent = ephemeral ? "Clear" : "Lock";
if (needsSetup) {
renderStart("create");
return;
}
if (locked) {
renderStart("unlock");
return;
}
if (!current) {
content.replaceChildren(createEmpty({
onAdd() {
openAdd();
},
onClear: ephemeral ? clearEphemeral : undefined,
}));
return;
}
renderUnlocked(current.wallet, current.runtime);
}
function render() {
if (vault.isLocked()) {
selector.clear();
} else {
selector.render(vault.wallets);
}
renderContent();
}
/**
* @param {Object} options
* @param {HTMLInputElement} options.name
* @param {HTMLTextAreaElement} options.source
* @param {HTMLButtonElement} options.submit
* @param {HTMLFormElement} options.form
*/
async function submitAdd({
name,
source,
submit,
form,
}) {
await withBusy(submit, "Add", "Adding", async () => {
source.removeAttribute("aria-invalid");
try {
const value = readWalletSourceText(source.value);
await generateAddressesFromWalletSource(value, { count: 1 });
await vault.addWallet({
name: name.value,
source: value,
});
form.reset();
addDialog.close();
render();
} catch {
source.setAttribute("aria-invalid", "true");
source.focus();
}
});
}
render();
return main;
}
+48
View File
@@ -0,0 +1,48 @@
import { createElement } from "../dom.js";
/**
* @typedef {Object} WalletsLayout
* @property {HTMLElement} main
* @property {HTMLElement} utilities
* @property {HTMLButtonElement} privacyButton
* @property {HTMLButtonElement} sessionButton
* @property {HTMLElement} selector
* @property {HTMLElement} walletList
* @property {HTMLElement} content
* @property {HTMLDialogElement} addDialog
*/
/**
* @returns {WalletsLayout}
*/
export function createLayout() {
const main = createElement("main", "wallets");
const utilities = document.createElement("footer");
const privacyButton = document.createElement("button");
const sessionButton = document.createElement("button");
const selector = createElement("section", "selector");
const walletList = document.createElement("nav");
const content = document.createElement("article");
const addDialog = document.createElement("dialog");
privacyButton.type = "button";
sessionButton.type = "button";
sessionButton.append("Lock");
content.setAttribute("aria-live", "polite");
walletList.setAttribute("tabindex", "0");
walletList.setAttribute("aria-label", "Wallets");
utilities.append(privacyButton, sessionButton);
selector.append(walletList);
main.append(selector, content, utilities, addDialog);
return {
main,
utilities,
privacyButton,
sessionButton,
selector,
walletList,
content,
addDialog,
};
}
+18
View File
@@ -0,0 +1,18 @@
main.wallets {
> footer {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
align-self: end;
@media (max-width: 34rem) {
justify-content: center;
}
}
> article {
display: grid;
gap: 1.5rem;
}
}
+59
View File
@@ -0,0 +1,59 @@
import { rapidHashV3Prefix } from "./hash.js";
const MIN_PREFIX_NIBBLES = 4;
const MAX_PREFIX_NIBBLES = 16;
/**
* @typedef {import("../derive/index.js").AddressType} AddressType
* @typedef {import("../derive/index.js").GeneratedAddress} GeneratedAddress
*/
/**
* @typedef {Object} AddrHashPrefixMatches
* @property {AddressType} addrType
* @property {string} prefix
* @property {boolean} truncated
* @property {string[]} addresses
*/
/**
* @typedef {Object} AddressClient
* @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressHashPrefixMatches
*/
/**
* @param {AddressClient} client
* @param {GeneratedAddress} generated
* @param {number} nibbles
* @returns {Promise<AddrHashPrefixMatches>}
*/
async function fetchPrefixMatches(client, generated, nibbles) {
const prefix = rapidHashV3Prefix(generated.payload, nibbles);
return /** @type {AddrHashPrefixMatches} */ (
await client.getAddressHashPrefixMatches(generated.addrType, prefix, {
cache: false,
})
);
}
/**
* @param {AddressClient} client
* @param {GeneratedAddress} generated
* @returns {Promise<AddrHashPrefixMatches>}
*/
export async function findUsablePrefixBucket(client, generated) {
for (
let nibbles = MIN_PREFIX_NIBBLES;
nibbles <= MAX_PREFIX_NIBBLES;
nibbles += 1
) {
const matches = await fetchPrefixMatches(client, generated, nibbles);
if (matches.truncated) continue;
return matches;
}
throw new Error("Address prefix bucket is too large");
}
+107
View File
@@ -0,0 +1,107 @@
const MASK_64 = 0xffffffffffffffffn;
const DEFAULT_SECRETS = /** @type {const} */ ([
0x2d358dccaa6c78a5n,
0x8bb84b93962eacc9n,
0x4b33a62ed433d4a3n,
0x4d5a2da51de1aa47n,
0xa0761d6478bd642fn,
0xe7037ed1a0b428dbn,
0x90ed1765281c388cn,
]);
const DEFAULT_SEED = rapidHashSeed(0n);
/**
* @param {bigint} value
*/
function u64(value) {
return value & MASK_64;
}
/**
* @param {bigint} left
* @param {bigint} right
*/
function rapidMix(left, right) {
const result = u64(left) * u64(right);
return u64(result) ^ u64(result >> 64n);
}
/**
* @param {bigint} left
* @param {bigint} right
* @returns {[bigint, bigint]}
*/
function rapidMum(left, right) {
const result = u64(left) * u64(right);
return [u64(result), u64(result >> 64n)];
}
/**
* @param {bigint} seed
*/
function rapidHashSeed(seed) {
return u64(seed ^ rapidMix(seed ^ DEFAULT_SECRETS[2], DEFAULT_SECRETS[1]));
}
/**
* @param {Uint8Array} bytes
* @param {number} offset
*/
function readU64(bytes, offset) {
return (
BigInt(bytes[offset]) |
(BigInt(bytes[offset + 1]) << 8n) |
(BigInt(bytes[offset + 2]) << 16n) |
(BigInt(bytes[offset + 3]) << 24n) |
(BigInt(bytes[offset + 4]) << 32n) |
(BigInt(bytes[offset + 5]) << 40n) |
(BigInt(bytes[offset + 6]) << 48n) |
(BigInt(bytes[offset + 7]) << 56n)
);
}
/**
* @param {Uint8Array} bytes
*/
function rapidHashV3(bytes) {
const length = bytes.length;
if (length <= 16) {
throw new Error("Expected more than 16 bytes");
}
if (length > 32) {
throw new Error("Expected at most 32 bytes");
}
let seed = rapidMix(
readU64(bytes, 0) ^ DEFAULT_SECRETS[2],
readU64(bytes, 8) ^ DEFAULT_SEED,
);
let a = readU64(bytes, length - 16) ^ BigInt(length);
let b = readU64(bytes, length - 8);
a ^= DEFAULT_SECRETS[1];
b ^= seed;
[a, b] = rapidMum(a, b);
return rapidMix(a ^ 0xaaaaaaaaaaaaaaaan, b ^ DEFAULT_SECRETS[1] ^ BigInt(length));
}
/**
* @param {Uint8Array} bytes
*/
function rapidHashV3Hex(bytes) {
return rapidHashV3(bytes).toString(16).padStart(16, "0");
}
/**
* @param {Uint8Array} bytes
* @param {number} nibbles
*/
export function rapidHashV3Prefix(bytes, nibbles) {
return rapidHashV3Hex(bytes).slice(0, nibbles);
}
+208
View File
@@ -0,0 +1,208 @@
import { mapConcurrent } from "../concurrent.js";
import {
getAddressReceived,
getAddressSent,
getAddressTxCount,
} from "./stats.js";
import { findUsablePrefixBucket } from "./bucket.js";
import { isLocalClient } from "./local.js";
const LOOKUP_CONCURRENCY = 8;
/**
* @typedef {import("../derive/index.js").AddressType} AddressType
* @typedef {import("../derive/index.js").GeneratedAddress} GeneratedAddress
*/
/**
* @typedef {import("./stats.js").AddressStats} AddressStats
*/
/**
* @typedef {Object} WalletAddress
* @property {number} index
* @property {string} address
* @property {GeneratedAddress["script"]} script
* @property {GeneratedAddress["network"]} network
* @property {AddressType} addrType
* @property {number} balance
* @property {number} received
* @property {number} sent
* @property {number} txCount
* @property {number | undefined} typeIndex
* @property {string[]} historyAddresses
* @property {number} historyBucketSize
*/
/**
* @typedef {Object} AddressClient
* @property {string} domain
* @property {(address: string, options?: { cache?: boolean }) => Promise<unknown>} getAddress
* @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressHashPrefixMatches
*/
/**
* @param {GeneratedAddress} generated
* @param {number} historyBucketSize
* @returns {WalletAddress}
*/
function createEmptyWalletAddress(generated, historyBucketSize = 0) {
return {
index: generated.index,
address: generated.address,
script: generated.script,
network: generated.network,
addrType: generated.addrType,
balance: 0,
received: 0,
sent: 0,
txCount: 0,
typeIndex: undefined,
historyAddresses: [],
historyBucketSize,
};
}
/**
* @param {GeneratedAddress} generated
* @param {AddressStats} stats
* @param {readonly string[]} historyAddresses
* @param {number} historyBucketSize
* @returns {WalletAddress}
*/
function createWalletAddress(
generated,
stats,
historyAddresses,
historyBucketSize,
) {
const received = getAddressReceived(stats);
const sent = getAddressSent(stats);
return {
index: generated.index,
address: generated.address,
script: generated.script,
network: generated.network,
addrType: generated.addrType,
balance: received - sent,
received,
sent,
txCount: getAddressTxCount(stats),
typeIndex: stats.chainStats.typeIndex,
historyAddresses: [...historyAddresses],
historyBucketSize,
};
}
/**
* @param {unknown} error
*/
function isNotFound(error) {
return (
error instanceof Error &&
/** @type {{ status?: unknown }} */ (error).status === 404
);
}
/**
* @param {AddressClient} client
* @param {readonly string[]} addresses
* @param {Map<string, Promise<AddressStats>>} cache
*/
async function fetchBucketMetadata(client, addresses, cache) {
for (const address of addresses) {
if (!cache.has(address)) {
cache.set(
address,
client.getAddress(address, { cache: false }).then(
(stats) => /** @type {AddressStats} */ (stats),
),
);
}
}
await Promise.all(addresses.map((address) => cache.get(address)));
}
/**
* @param {AddressClient} client
* @param {GeneratedAddress} generated
* @returns {Promise<WalletAddress>}
*/
async function fetchDirectWalletAddress(client, generated) {
try {
const stats = /** @type {AddressStats} */ (
await client.getAddress(generated.address, { cache: false })
);
const historyAddresses =
getAddressTxCount(stats) > 0 ? [generated.address] : [];
return createWalletAddress(generated, stats, historyAddresses, 1);
} catch (error) {
if (isNotFound(error)) {
return createEmptyWalletAddress(generated, 1);
}
throw error;
}
}
/**
* @param {AddressClient} client
* @param {GeneratedAddress} generated
* @param {Map<string, Promise<AddressStats>>} metadataCache
* @returns {Promise<WalletAddress>}
*/
async function fetchWalletAddress(client, generated, metadataCache) {
const matches = await findUsablePrefixBucket(client, generated);
if (!matches.addresses.includes(generated.address)) {
return createEmptyWalletAddress(generated, matches.addresses.length);
}
await fetchBucketMetadata(client, matches.addresses, metadataCache);
const stats = await metadataCache.get(generated.address);
if (!stats) {
return createEmptyWalletAddress(generated);
}
const historyAddresses = [];
for (const address of matches.addresses) {
const bucketStats = await metadataCache.get(address);
if (bucketStats && getAddressTxCount(bucketStats) > 0) {
historyAddresses.push(address);
}
}
return createWalletAddress(
generated,
stats,
historyAddresses,
matches.addresses.length,
);
}
/**
* @param {AddressClient} client
* @param {readonly GeneratedAddress[]} generated
* @returns {Promise<WalletAddress[]>}
*/
export async function fetchWalletAddresses(client, generated) {
if (isLocalClient(client)) {
return mapConcurrent(generated, LOOKUP_CONCURRENCY, (address) => {
return fetchDirectWalletAddress(client, address);
});
}
const metadataCache =
/** @type {Map<string, Promise<AddressStats>>} */ (new Map());
return mapConcurrent(generated, LOOKUP_CONCURRENCY, (address) => {
return fetchWalletAddress(client, address, metadataCache);
});
}
+44
View File
@@ -0,0 +1,44 @@
const localDomains = new Set([
"localhost",
"127.0.0.1",
"0.0.0.0",
"::1",
"[::1]",
]);
/**
* @param {string} domain
*/
function isPrivateIpv4(domain) {
const parts = domain.split(".").map(Number);
if (
parts.length !== 4 ||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
) {
return false;
}
const [a, b] = parts;
return (
a === 10 ||
a === 127 ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168)
);
}
/**
* @param {{ domain: string }} client
*/
export function isLocalClient(client) {
const domain = client.domain.toLowerCase();
return (
localDomains.has(domain) ||
domain.endsWith(".local") ||
isPrivateIpv4(domain)
);
}
+40
View File
@@ -0,0 +1,40 @@
/**
* @typedef {Object} AddressStatsPart
* @property {number} fundedTxoSum
* @property {number} spentTxoSum
* @property {number} txCount
*/
/**
* @typedef {AddressStatsPart & {
* typeIndex: number,
* }} AddressChainStats
*/
/**
* @typedef {Object} AddressStats
* @property {string} address
* @property {AddressChainStats} chainStats
* @property {AddressStatsPart} mempoolStats
*/
/**
* @param {AddressStats} stats
*/
export function getAddressReceived(stats) {
return stats.chainStats.fundedTxoSum + stats.mempoolStats.fundedTxoSum;
}
/**
* @param {AddressStats} stats
*/
export function getAddressSent(stats) {
return stats.chainStats.spentTxoSum + stats.mempoolStats.spentTxoSum;
}
/**
* @param {AddressStats} stats
*/
export function getAddressTxCount(stats) {
return stats.chainStats.txCount + stats.mempoolStats.txCount;
}
+142
View File
@@ -0,0 +1,142 @@
const FIXED_PRIVATE_TEXT = "*****";
let hidden = false;
const effects = /** @type {RedactionEffect[]} */ ([]);
/**
* @typedef {"exact" | "fixed"} RedactionMode
*
* @typedef {Object} RedactionEffect
* @property {HTMLElement} element
* @property {() => void} sync
*/
function isHidden() {
return hidden;
}
/**
* @param {string} value
*/
function createText(value) {
return [...value].map((character) => {
return character === " " ? " " : "*";
}).join("");
}
/**
* @param {string} value
* @param {RedactionMode} mode
*/
function mask(value, mode) {
return mode === "fixed" ? FIXED_PRIVATE_TEXT : createText(value);
}
/**
* @param {HTMLElement} element
* @param {() => void} sync
*/
function addEffect(element, sync) {
effects.push({ element, sync });
sync();
}
/**
* @param {HTMLElement} element
* @param {string} value
* @param {RedactionMode} [mode]
*/
function setValue(element, value, mode = "exact") {
addEffect(element, () => {
element.textContent = hidden ? mask(value, mode) : value;
});
}
/**
* @param {HTMLElement} element
* @param {string} value
*/
function setTitle(element, value) {
addEffect(element, () => {
element.title = hidden ? createText(value) : value;
});
}
/**
* @param {HTMLElement} element
* @param {string} value
* @param {(text: string) => void} render
*/
function setAddress(element, value, render) {
addEffect(element, () => {
render(hidden ? createText(value) : value);
});
}
/**
* @param {HTMLInputElement | HTMLTextAreaElement} input
*/
function setInput(input) {
addEffect(input, () => {
if (input instanceof HTMLTextAreaElement) {
input.style.setProperty("-webkit-text-security", hidden ? "disc" : "");
} else {
input.type = hidden ? "password" : "text";
}
});
}
/**
* @template {keyof HTMLElementTagNameMap} Tag
* @param {Tag} tag
* @param {string} value
* @param {RedactionMode} [mode]
*/
function createValue(tag, value, mode = "exact") {
const element = document.createElement(tag);
setValue(element, value, mode);
return element;
}
function sync() {
for (let index = effects.length - 1; index >= 0; index -= 1) {
const effect = effects[index];
if (!effect.element.isConnected) {
effects.splice(index, 1);
} else {
effect.sync();
}
}
}
/**
* @param {HTMLButtonElement} button
*/
function syncButton(button) {
button.textContent = hidden ? "Reveal" : "Privacy";
button.setAttribute("aria-pressed", hidden ? "true" : "false");
}
/**
* @param {HTMLButtonElement} button
*/
function toggle(button) {
hidden = !hidden;
sync();
syncButton(button);
}
export const redaction = /** @type {const} */ ({
isHidden,
createText,
setValue,
setTitle,
setAddress,
setInput,
createValue,
syncButton,
toggle,
});
+104
View File
@@ -0,0 +1,104 @@
import { fetchWalletAddresses } from "../lookup/index.js";
import { generateAddressesFromWalletSource } from "../derive/index.js";
export const GAP_LIMIT = 10;
const SCAN_BATCH_SIZE = GAP_LIMIT;
const MAX_SCANNED_ADDRESSES = 1_000;
/**
* @typedef {import("../derive/address.js").AddressScript} AddressScript
* @typedef {import("../derive/index.js").AddressType} AddressType
* @typedef {import("../lookup/index.js").WalletAddress} WalletAddress
*/
/**
* @typedef {Object} AddressClient
* @property {string} domain
* @property {(address: string, options?: { cache?: boolean }) => Promise<unknown>} getAddress
* @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressHashPrefixMatches
*/
/**
* @typedef {Object} ScanProgress
* @property {number} scannedCount
* @property {number} unusedInRow
*/
/**
* @typedef {Object} ScanOptions
* @property {AddressScript} script
* @property {readonly number[]} path
* @property {string} [branchId]
* @property {(progress: ScanProgress) => void} [onProgress]
*/
/**
* @typedef {Object} ScanResult
* @property {WalletAddress[]} addresses
* @property {number} scannedCount
* @property {number} gapLimit
* @property {boolean} maxed
*/
/**
* @param {WalletAddress} address
*/
function isUsedAddress(address) {
return address.received > 0 || address.sent > 0 || address.txCount > 0;
}
/**
* @param {AddressClient} client
* @param {string} source
* @param {ScanOptions} options
* @returns {Promise<ScanResult>}
*/
export async function scanBranch(client, source, options) {
const addresses = /** @type {WalletAddress[]} */ ([]);
let unusedInRow = 0;
let nextStart = 0;
while (
unusedInRow < GAP_LIMIT &&
addresses.length < MAX_SCANNED_ADDRESSES
) {
const count = Math.min(
SCAN_BATCH_SIZE,
GAP_LIMIT - unusedInRow,
MAX_SCANNED_ADDRESSES - addresses.length,
);
const generated = await generateAddressesFromWalletSource(source, {
start: nextStart,
count,
script: options.script,
path: options.path,
branchId: options.branchId,
});
const batch = /** @type {WalletAddress[]} */ (
await fetchWalletAddresses(client, generated)
);
for (const address of batch) {
addresses.push(address);
unusedInRow = isUsedAddress(address) ? 0 : unusedInRow + 1;
if (unusedInRow >= GAP_LIMIT) {
break;
}
}
nextStart += count;
options.onProgress?.({
scannedCount: addresses.length,
unusedInRow,
});
}
return {
addresses,
scannedCount: addresses.length,
gapLimit: GAP_LIMIT,
maxed: addresses.length >= MAX_SCANNED_ADDRESSES,
};
}
+165
View File
@@ -0,0 +1,165 @@
import {
scanBranch,
GAP_LIMIT,
} from "./branch.js";
import {
getOutputDescriptorBranchIds,
isOutputDescriptor,
} from "../derive/index.js";
const keyBranches = /** @type {const} */ ([
{ id: "receive", label: "Receive", path: [0] },
{ id: "change", label: "Change", path: [1] },
{ id: "direct", label: "Direct", path: [] },
]);
const descriptorBranches = /** @type {const} */ ([
{ id: "receive", label: "Receive", path: [] },
{ id: "change", label: "Change", path: [] },
]);
/**
* @typedef {(typeof keyBranches[number] | typeof descriptorBranches[number])} WalletBranch
* @typedef {WalletBranch["id"]} WalletBranchId
*/
/**
* @typedef {import("../derive/address.js").AddressScript} AddressScript
* @typedef {import("../derive/index.js").AddressType} AddressType
* @typedef {import("./branch.js").WalletAddress} WalletAddress
*/
/**
* @typedef {Object} AddressClient
* @property {string} domain
* @property {(address: string, options?: { cache?: boolean }) => Promise<unknown>} getAddress
* @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressHashPrefixMatches
*/
/**
* @typedef {WalletAddress & {
* branchId: WalletBranchId,
* branchLabel: string,
* }} ScannedAddress
*/
/**
* @typedef {Object} ScanProgress
* @property {WalletBranchId} branchId
* @property {string} branchLabel
* @property {number} scannedCount
* @property {number} unusedInRow
*/
/**
* @typedef {Object} ScanBranchesOptions
* @property {AddressScript} script
* @property {(progress: ScanProgress) => void} [onProgress]
*/
/**
* @typedef {Object} ScanBranchesResult
* @property {ScannedAddress[]} addresses
* @property {ScannedAddress | undefined} receiveAddress
* @property {number} gapLimit
* @property {boolean} maxed
*/
/**
* @param {WalletAddress} address
*/
function isUsedAddress(address) {
return address.received > 0 || address.sent > 0 || address.txCount > 0;
}
/**
* @param {ScannedAddress} a
* @param {ScannedAddress} b
*/
function compareWalletAddresses(a, b) {
if (a.typeIndex === undefined) return 1;
if (b.typeIndex === undefined) return -1;
return b.typeIndex - a.typeIndex || a.index - b.index;
}
/**
* @param {WalletAddress} address
* @param {WalletBranch} branch
* @returns {ScannedAddress}
*/
function addBranch(address, branch) {
return {
...address,
branchId: branch.id,
branchLabel: branch.label,
};
}
/**
* @param {string} source
*/
function getWalletBranches(source) {
if (!isOutputDescriptor(source)) return keyBranches;
const branchIds = new Set(getOutputDescriptorBranchIds(source));
const branches = descriptorBranches.filter((branch) => {
return branchIds.has(branch.id);
});
return branches.length ? branches : [descriptorBranches[0]];
}
/**
* @param {AddressClient} client
* @param {string} source
* @param {ScanBranchesOptions} options
* @returns {Promise<ScanBranchesResult>}
*/
export async function scanBranches(client, source, options) {
const addresses = /** @type {ScannedAddress[]} */ ([]);
const branches = getWalletBranches(source);
const receiveBranch =
branches.find((branch) => branch.id === "receive") ?? branches[0];
/** @type {ScannedAddress | undefined} */
let receiveAddress;
let maxed = false;
for (const branch of branches) {
const scan = await scanBranch(client, source, {
script: options.script,
path: branch.path,
branchId: branch.id,
onProgress(progress) {
options.onProgress?.({
branchId: branch.id,
branchLabel: branch.label,
scannedCount: progress.scannedCount,
unusedInRow: progress.unusedInRow,
});
},
});
for (const address of scan.addresses) {
const branchedAddress = addBranch(address, branch);
if (!isUsedAddress(address)) {
if (!receiveAddress && branch.id === receiveBranch.id) {
receiveAddress = branchedAddress;
}
continue;
}
addresses.push(branchedAddress);
}
maxed = maxed || scan.maxed;
}
return {
addresses: addresses.sort(compareWalletAddresses),
receiveAddress,
gapLimit: GAP_LIMIT,
maxed,
};
}
+147
View File
@@ -0,0 +1,147 @@
import { scanBranches } from "./branches.js";
import { isOutputDescriptor } from "../derive/index.js";
import { parseOutputDescriptor } from "../derive/descriptor.js";
import { addressScripts } from "../derive/script.js";
/**
* @typedef {import("../derive/address.js").AddressScript} AddressScript
* @typedef {import("../derive/index.js").AddressType} AddressType
* @typedef {Awaited<ReturnType<typeof scanBranches>>["addresses"][number]} WalletAddress
* @typedef {Awaited<ReturnType<typeof scanBranches>>} ScriptScan
*/
/**
* @typedef {Object} WalletScan
* @property {WalletAddress[]} addresses
* @property {WalletAddress | undefined} receiveAddress
* @property {number} btcUsdPrice
*/
/**
* @typedef {Object} WalletScanClient
* @property {string} domain
* @property {(address: string, options?: { cache?: boolean }) => Promise<unknown>} getAddress
* @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressHashPrefixMatches
* @property {(options?: { cache?: boolean }) => Promise<unknown>} getLivePrice
*/
/**
* @typedef {Object} WalletScanProgress
* @property {string} branchLabel
* @property {number} scannedCount
* @property {number} unusedInRow
*/
/**
* @typedef {Object} ScanScript
* @property {AddressScript} id
* @property {string} label
*/
const descriptorScripts = /** @type {const} */ ({
v0_p2wsh_sortedmulti: "P2WSH",
});
/**
* @param {string} source
* @returns {readonly ScanScript[]}
*/
function getSourceScripts(source) {
if (isOutputDescriptor(source)) {
const script = parseOutputDescriptor(source).script;
return [{
id: script,
label: descriptorScripts[script],
}];
}
return addressScripts;
}
/**
* @param {WalletAddress} a
* @param {WalletAddress} b
*/
function compareWalletAddresses(a, b) {
return (
(b.typeIndex ?? -1) - (a.typeIndex ?? -1) ||
a.script.localeCompare(b.script) ||
a.branchLabel.localeCompare(b.branchLabel) ||
a.index - b.index
);
}
/**
* @param {ScriptScan} scan
*/
function getLatestSeenIndex(scan) {
return scan.addresses.reduce((latest, address) => {
return Math.max(latest, address.typeIndex ?? -1);
}, -1);
}
/**
* @param {readonly ScriptScan[]} scans
*/
function selectReceiveAddress(scans) {
let receiveAddress = scans.find((scan) => {
return scan.receiveAddress;
})?.receiveAddress;
let selectedSeenIndex = -1;
for (const scan of scans) {
const seenIndex = getLatestSeenIndex(scan);
const hasActivity = scan.addresses.length > 0;
if (
hasActivity &&
scan.receiveAddress &&
seenIndex >= selectedSeenIndex
) {
receiveAddress = scan.receiveAddress;
selectedSeenIndex = seenIndex;
}
}
return receiveAddress;
}
/**
* @param {Object} options
* @param {WalletScanClient} options.client
* @param {string} options.source
* @param {(progress: WalletScanProgress) => void} [options.onProgress]
* @returns {Promise<WalletScan>}
*/
export async function scanWalletAddresses({
client,
source,
onProgress,
}) {
const scans = /** @type {ScriptScan[]} */ ([]);
for (const script of getSourceScripts(source)) {
scans.push(await scanBranches(client, source, {
script: script.id,
onProgress(progress) {
onProgress?.({
...progress,
branchLabel: `${script.label} ${progress.branchLabel}`,
});
},
}));
}
const addresses = scans.flatMap((scan) => scan.addresses)
.sort(compareWalletAddresses);
const btcUsdPrice = /** @type {number} */ (
await client.getLivePrice({ cache: false })
);
return {
addresses,
receiveAddress: selectReceiveAddress(scans),
btcUsdPrice,
};
}
+130
View File
@@ -0,0 +1,130 @@
import { createHoldButton } from "../hold/index.js";
/**
* @typedef {Object} StoredWallet
* @property {string} id
* @property {string} name
*/
/**
* @typedef {Object} WalletSelectorOptions
* @property {() => string} getSelectedId
* @property {(walletId: string) => void} onSelect
* @property {() => void} onAdd
* @property {() => void} onDelete
*
* @typedef {Object} WalletSelectorButton
* @property {HTMLButtonElement} button
* @property {string} id
*/
/**
* @param {HTMLElement} walletList
* @param {StoredWallet[]} wallets
* @param {WalletSelectorOptions} options
* @returns {WalletSelectorButton[]}
*/
function renderButtons(walletList, wallets, options) {
const buttons = /** @type {WalletSelectorButton[]} */ ([]);
const add = document.createElement("button");
const remove = createHoldButton({
label: "DELETE",
title: "Hold to delete",
className: "delete",
onHold: options.onDelete,
});
walletList.replaceChildren();
for (const wallet of wallets) {
const button = document.createElement("button");
const selected = wallet.id === options.getSelectedId();
button.type = "button";
button.setAttribute("aria-pressed", selected ? "true" : "false");
button.append(wallet.name);
button.addEventListener("click", () => {
options.onSelect(wallet.id);
});
buttons.push({ button, id: wallet.id });
walletList.append(button);
}
add.type = "button";
add.append("ADD");
add.addEventListener("click", options.onAdd);
walletList.append(add);
if (wallets.length > 0) {
walletList.append(remove);
}
return buttons;
}
/**
* @param {HTMLElement} walletList
* @param {WalletSelectorOptions} options
*/
export function createSelector(walletList, options) {
/** @type {WalletSelectorButton[]} */
let buttons = [];
function selectSnappedWallet() {
if (buttons.length === 0) return;
const listRect = walletList.getBoundingClientRect();
const listCenter = listRect.left + listRect.width / 2;
const closest = buttons.reduce((best, item) => {
const rect = item.button.getBoundingClientRect();
const center = rect.left + rect.width / 2;
const distance = Math.abs(center - listCenter);
return distance < best.distance
? { item, distance }
: best;
}, {
item: buttons[0],
distance: Number.POSITIVE_INFINITY,
});
if (closest.item.id !== options.getSelectedId()) {
options.onSelect(closest.item.id);
}
}
walletList.addEventListener("scrollend", () => {
selectSnappedWallet();
});
walletList.addEventListener("wheel", (event) => {
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY)
? event.deltaX
: event.deltaY;
if (delta === 0) return;
const maxScrollLeft = walletList.scrollWidth - walletList.clientWidth;
const nextScrollLeft = Math.max(
0,
Math.min(maxScrollLeft, walletList.scrollLeft + delta),
);
if (nextScrollLeft === walletList.scrollLeft) return;
event.preventDefault();
walletList.scrollLeft = nextScrollLeft;
}, { passive: false });
return {
clear() {
walletList.replaceChildren();
buttons = [];
},
/**
* @param {StoredWallet[]} wallets
*/
render(wallets) {
buttons = renderButtons(walletList, wallets, options);
},
};
}
+56
View File
@@ -0,0 +1,56 @@
main.wallets {
.selector {
min-width: 0;
> nav {
display: flex;
gap: 0.5rem;
min-width: 0;
overflow-x: auto;
padding-bottom: 0.25rem;
overscroll-behavior-inline: contain;
scroll-padding-inline: var(--page-x);
scroll-snap-type: x proximity;
> button {
flex: 0 0 auto;
scroll-snap-align: center;
background: var(--gray);
color: var(--white);
font-size: var(--font-size-lg);
font-weight: 400;
line-height: 1;
@media (max-width: 56rem) {
font-size: var(--font-size-base);
}
&:hover {
color: var(--black);
background: var(--white);
}
&:active {
color: var(--black);
background: var(--orange);
}
&[aria-pressed="true"] {
color: var(--black);
background: var(--white);
}
&.delete {
color: color-mix(in oklch, var(--gray) 76%, transparent);
background: transparent;
&:hover,
&:active {
color: var(--red);
background: transparent;
}
}
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

+44
View File
@@ -0,0 +1,44 @@
import { createElement } from "../dom.js";
import { createPersistentVault } from "./persistent.js";
import { createStartStory } from "./story.js";
import { createTemporaryVault } from "./temporary.js";
/**
* @typedef {"create" | "unlock"} StartMode
*/
/**
* @typedef {Object} StartOptions
* @property {StartMode} mode
* @property {(password: string, button: HTMLButtonElement, status: HTMLElement) => boolean | void | Promise<boolean | void>} onPassword
* @property {() => void} onEphemeral
* @property {() => void} [onReset]
*/
/**
* @param {StartOptions} options
*/
export function createStart(options) {
const section = createElement("section", "start");
const modes = document.createElement("div");
const divider = document.createElement("p");
const persistent = createPersistentVault({
mode: options.mode,
onPassword: options.onPassword,
onReset: options.onReset,
});
divider.append("OR");
modes.append(
persistent.element,
divider,
createTemporaryVault(options.onEphemeral),
);
section.append(createStartStory(), modes);
queueMicrotask(() => {
persistent.password.focus({ preventScroll: true });
});
return section;
}
+71
View File
@@ -0,0 +1,71 @@
import { createResetButton } from "./reset/index.js";
/**
* @typedef {"create" | "unlock"} StartMode
*
* @typedef {Object} PersistentOptions
* @property {StartMode} mode
* @property {(password: string, button: HTMLButtonElement, status: HTMLElement) => boolean | void | Promise<boolean | void>} onPassword
* @property {() => void} [onReset]
*/
/**
* @param {PersistentOptions} options
*/
export function createPersistentVault(options) {
const persistent = document.createElement("section");
const title = document.createElement("h2");
const text = document.createElement("p");
const form = document.createElement("form");
const password = document.createElement("input");
const submit = document.createElement("button");
const status = document.createElement("output");
const unlock = options.mode === "unlock";
title.append("Persistent vault");
text.append(
unlock
? "Unlock the encrypted vault saved in this browser."
: "Create an encrypted vault saved in this browser.",
);
password.name = "password";
password.type = "password";
password.autocomplete = unlock ? "current-password" : "new-password";
password.autofocus = true;
password.placeholder = unlock ? "Password" : "Set password";
password.required = true;
submit.type = "submit";
submit.append(unlock ? "Unlock" : "Create");
form.append(password, submit);
function clearInvalid() {
password.removeAttribute("aria-invalid");
}
password.addEventListener("input", clearInvalid);
form.addEventListener("submit", (event) => {
event.preventDefault();
clearInvalid();
void (async () => {
const valid = await options.onPassword(password.value, submit, status);
if (valid === false) {
password.setAttribute("aria-invalid", "true");
password.focus({ preventScroll: true });
}
})();
});
persistent.append(title, text, form);
if (options.onReset) {
persistent.append(createResetButton(options.onReset));
}
persistent.append(status);
return {
element: persistent,
password,
};
}
+13
View File
@@ -0,0 +1,13 @@
import { createHoldButton } from "../../hold/index.js";
/**
* @param {() => void} onReset
*/
export function createResetButton(onReset) {
return createHoldButton({
label: "Reset vault",
title: "Hold to reset",
className: "reset",
onHold: onReset,
});
}
@@ -0,0 +1,8 @@
main.wallets {
.start {
.reset {
justify-self: start;
width: 100%;
}
}
}
+40
View File
@@ -0,0 +1,40 @@
/**
* @param {string} text
*/
function createDetail(text) {
const item = document.createElement("li");
item.append(text);
return item;
}
export function createStartStory() {
const story = document.createElement("article");
const title = document.createElement("h1");
const titleBreak = document.createElement("br");
const titleAccent = document.createElement("span");
const lead = document.createElement("p");
const details = document.createElement("ul");
const warningRule = document.createElement("hr");
const warning = document.createElement("small");
titleAccent.append("wallets");
title.append("Watch-only", titleBreak, titleAccent);
lead.append("View a Bitcoin wallet privately, without spending access.");
details.append(
createDetail("Open xpubs and watch-only descriptors."),
createDetail("Addresses are derived on your device."),
createDetail("Public lookups use anonymity sets."),
createDetail("Local servers fetch directly and are best."),
createDetail("Save encrypted wallets, or use a temporary session."),
);
warning.append(
"Use a VPN for extra network privacy.",
document.createElement("br"),
"On-chain address links will reduce anonymity.",
);
story.append(title, lead, details, warningRule, warning);
return story;
}
+177
View File
@@ -0,0 +1,177 @@
main.wallets {
.start {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(19rem, 26rem);
gap: 4rem;
align-items: center;
width: min(100%, 68rem);
min-height: calc(100dvh - 2 * var(--offset));
margin-inline: auto;
@media (max-width: 56rem) {
grid-template-columns: 1fr;
gap: 2rem;
align-content: center;
width: min(100%, 39rem);
margin-inline: 0 auto;
}
> article {
display: grid;
gap: 0.875rem;
}
h1 {
margin: 0;
font-size: 4.5rem;
font-weight: 400;
line-height: 0.95;
span {
color: var(--orange);
}
@media (max-width: 34rem) {
font-size: 3.5rem;
}
}
p {
margin: 0;
}
> article > p {
max-width: 35rem;
color: var(--white);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
}
> article > ul {
display: grid;
gap: 0.75rem;
margin: 0.5rem 0 0;
padding: 0;
list-style: none;
}
> article li {
display: grid;
grid-template-columns: 1rem minmax(0, 1fr);
gap: 0.75rem;
max-width: 34rem;
color: var(--white);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
&::before {
content: "";
width: 0.5rem;
height: 0.5rem;
border: 1px solid var(--orange);
border-radius: 50%;
margin-top: 0.5rem;
}
}
> article > hr {
width: min(100%, 34rem);
height: 0.5px;
border: 0;
margin: 0.125rem 0 0;
background: var(--gray);
}
> article > small {
max-width: 34rem;
color: var(--gray);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
}
> div {
display: grid;
gap: 0.875rem;
width: 100%;
> section {
display: grid;
gap: 0.5rem;
}
> p {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 0.625rem;
align-items: center;
color: var(--gray);
font-size: var(--font-size-xs);
line-height: 1;
&::before {
content: "";
height: 0.5px;
background: var(--gray);
}
&::after {
content: "";
height: 0.5px;
background: var(--gray);
}
}
h2 {
margin: 0;
color: var(--white);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
font-weight: 400;
line-height: var(--line-height-sm);
}
p {
color: var(--gray);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
}
form {
display: flex;
gap: 0;
width: 100%;
input {
flex: 1 1 auto;
display: block;
min-block-size: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
@media (max-width: 34rem) {
flex-direction: column;
input {
border-top-right-radius: var(--control-radius);
border-bottom-left-radius: 0;
}
button {
border-top-right-radius: 0;
border-bottom-left-radius: var(--control-radius);
}
}
}
> section[data-mode="temporary"] > button {
width: 100%;
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* @param {() => void} onStart
*/
export function createTemporaryVault(onStart) {
const temporary = document.createElement("section");
const title = document.createElement("h2");
const text = document.createElement("p");
const button = document.createElement("button");
temporary.dataset.mode = "temporary";
title.append("Temporary vault");
text.append("Wallets are never saved to this browser.");
button.type = "button";
button.append("Start temporary");
button.addEventListener("click", onStart);
temporary.append(title, text, button);
return temporary;
}
+31
View File
@@ -0,0 +1,31 @@
main.wallets {
--offset: 4rem;
--content-width: 72rem;
display: grid;
gap: 1.5rem;
align-content: start;
grid-template-rows: auto minmax(0, 1fr) auto;
width: min(100%, var(--content-width));
margin-inline: auto;
padding: var(--offset) var(--page-x);
scroll-padding-top: var(--offset);
output {
display: block;
min-height: var(--line-height-sm);
margin: 0;
color: var(--gray);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
}
.results {
min-width: 0;
}
textarea {
min-height: 7rem;
resize: vertical;
}
}
+165
View File
@@ -0,0 +1,165 @@
const ENCRYPTION_VERSION = 1;
const PBKDF2_ITERATIONS = 250_000;
const KEY_BITS = 256;
const SALT_BYTES = 16;
const IV_BYTES = 12;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
/**
* @typedef {Object} EncryptedSecret
* @property {1} version
* @property {"PBKDF2-SHA256"} kdf
* @property {number} iterations
* @property {"AES-GCM"} cipher
* @property {string} salt
* @property {string} iv
* @property {string} ciphertext
*/
/**
* @param {Uint8Array} bytes
*/
function toArrayBuffer(bytes) {
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
return buffer;
}
/**
* @param {Uint8Array} bytes
*/
function bytesToBase64(bytes) {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
/**
* @param {string} base64
*/
function base64ToBytes(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* @param {number} length
*/
function randomBytes(length) {
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return bytes;
}
/**
* @param {string} password
*/
async function importPassword(password) {
return crypto.subtle.importKey(
"raw",
encoder.encode(password),
"PBKDF2",
false,
["deriveKey"],
);
}
/**
* @param {string} password
* @param {Uint8Array} salt
* @param {number} iterations
*/
async function deriveKey(password, salt, iterations) {
const key = await importPassword(password);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
hash: "SHA-256",
salt: toArrayBuffer(salt),
iterations,
},
key,
{
name: "AES-GCM",
length: KEY_BITS,
},
false,
["encrypt", "decrypt"],
);
}
/**
* @param {string} secret
* @param {string} password
* @returns {Promise<EncryptedSecret>}
*/
async function encrypt(secret, password) {
const salt = randomBytes(SALT_BYTES);
const iv = randomBytes(IV_BYTES);
const key = await deriveKey(password, salt, PBKDF2_ITERATIONS);
const encrypted = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: toArrayBuffer(iv),
},
key,
encoder.encode(secret),
);
return {
version: ENCRYPTION_VERSION,
kdf: "PBKDF2-SHA256",
iterations: PBKDF2_ITERATIONS,
cipher: "AES-GCM",
salt: bytesToBase64(salt),
iv: bytesToBase64(iv),
ciphertext: bytesToBase64(new Uint8Array(encrypted)),
};
}
/**
* @param {EncryptedSecret} encrypted
* @param {string} password
*/
async function decrypt(encrypted, password) {
if (encrypted.version !== ENCRYPTION_VERSION) {
throw new Error("Unsupported wallet encryption version");
}
const salt = base64ToBytes(encrypted.salt);
const iv = base64ToBytes(encrypted.iv);
const ciphertext = base64ToBytes(encrypted.ciphertext);
const key = await deriveKey(password, salt, encrypted.iterations);
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: toArrayBuffer(iv),
},
key,
toArrayBuffer(ciphertext),
);
return decoder.decode(decrypted);
}
export const encryption = /** @type {const} */ ({
encrypt,
decrypt,
});
+192
View File
@@ -0,0 +1,192 @@
import { vaultStorage } from "./storage.js";
import { createRuntime } from "./runtime.js";
/**
* @typedef {import("./storage.js").StoredWallet} StoredWallet
* @typedef {import("./storage.js").AddWalletInput} AddWalletInput
* @typedef {ReturnType<typeof createRuntime>} WalletRuntime
*/
export function createVault() {
/** @type {StoredWallet[]} */
let wallets = [];
let selectedId = "";
let locked = hasVault();
let password = "";
let ephemeral = false;
/** @type {Map<string, WalletRuntime>} */
const runtimes = new Map();
function hasVault() {
return vaultStorage.has();
}
function syncSelected() {
selectedId = wallets.some((wallet) => wallet.id === selectedId)
? selectedId
: wallets[0]?.id ?? "";
}
function clear() {
wallets = [];
selectedId = "";
runtimes.clear();
}
/**
* @returns {StoredWallet | undefined}
*/
function selectedWallet() {
return wallets.find((wallet) => wallet.id === selectedId);
}
/**
* @returns {{ wallet: StoredWallet, runtime: WalletRuntime } | undefined}
*/
function current() {
const wallet = selectedWallet();
const runtime = wallet ? runtimes.get(wallet.id) : undefined;
return wallet && runtime ? { wallet, runtime } : undefined;
}
/**
* @param {StoredWallet} wallet
* @param {WalletRuntime} runtime
*/
function isCurrent(wallet, runtime) {
return runtimes.get(wallet.id) === runtime;
}
/**
* @param {string} walletId
*/
function select(walletId) {
selectedId = walletId;
syncSelected();
}
function lock() {
clear();
password = "";
ephemeral = false;
locked = hasVault();
}
function reset() {
vaultStorage.reset();
clear();
password = "";
ephemeral = false;
locked = false;
}
function startEphemeral() {
clear();
password = "";
ephemeral = true;
locked = false;
}
function clearEphemeral() {
clear();
password = "";
ephemeral = false;
locked = hasVault();
}
/**
* @param {string} pagePassword
*/
async function setup(pagePassword) {
await vaultStorage.setup(pagePassword);
clear();
password = pagePassword;
ephemeral = false;
locked = false;
}
/**
* @param {string} pagePassword
*/
async function unlock(pagePassword) {
wallets = await vaultStorage.load(pagePassword);
syncSelected();
runtimes.clear();
password = pagePassword;
ephemeral = false;
locked = false;
for (const wallet of wallets) {
runtimes.set(wallet.id, createRuntime(wallet.source));
}
}
/**
* @param {AddWalletInput} input
*/
async function addWallet(input) {
if (ephemeral) {
const wallet = vaultStorage.createWallet(input);
wallets = [...wallets, wallet];
selectedId = wallet.id;
locked = false;
runtimes.set(wallet.id, createRuntime(wallet.source));
return;
}
const added = await vaultStorage.addWallet(wallets, input, password);
wallets = added.wallets;
selectedId = added.wallet.id;
locked = false;
runtimes.set(added.wallet.id, createRuntime(added.wallet.source));
}
/**
* @param {string} walletId
*/
async function deleteWallet(walletId) {
if (ephemeral) {
wallets = wallets.filter((wallet) => wallet.id !== walletId);
} else {
wallets = await vaultStorage.deleteWallet(wallets, walletId, password);
}
runtimes.delete(walletId);
syncSelected();
}
return {
get wallets() {
return wallets;
},
get selectedId() {
return selectedId;
},
get hasPassword() {
return password !== "";
},
needsSetup() {
return !hasVault() && !password && !ephemeral;
},
isLocked() {
return !ephemeral && locked && hasVault();
},
isEphemeral() {
return ephemeral;
},
current,
isCurrent,
select,
lock,
reset,
startEphemeral,
clearEphemeral,
setup,
unlock,
addWallet,
deleteWallet,
};
}
+54
View File
@@ -0,0 +1,54 @@
import { scanWalletAddresses } from "../scan/index.js";
/**
* @typedef {import("../scan/index.js").WalletScan} WalletScan
* @typedef {import("../scan/index.js").WalletScanClient} WalletScanClient
* @typedef {import("../scan/index.js").WalletScanProgress} WalletScanProgress
*
* @typedef {Object} LoadOptions
* @property {WalletScanClient} client
* @property {(progress: WalletScanProgress) => void} [onProgress]
*/
/**
* @param {string} source
*/
export function createRuntime(source) {
/** @type {WalletScan | undefined} */
let scan;
/** @type {Promise<WalletScan> | undefined} */
let pending;
/**
* @param {LoadOptions} options
*/
function load(options) {
if (scan) return Promise.resolve(scan);
if (!pending) {
pending = scanWalletAddresses({
client: options.client,
source,
onProgress: options.onProgress,
}).then((nextScan) => {
scan = nextScan;
pending = undefined;
return nextScan;
}, (error) => {
pending = undefined;
throw error;
});
}
return pending;
}
return {
get scan() {
return scan;
},
load,
};
}
+157
View File
@@ -0,0 +1,157 @@
import { encryption } from "./encryption.js";
const STORAGE_KEY = "bitview.wallets.v3";
/**
* @typedef {import("./encryption.js").EncryptedSecret} EncryptedSecret
*/
/**
* @typedef {Object} StoredWallet
* @property {string} id
* @property {string} name
* @property {string} source
* @property {number} createdAt
* @property {number} updatedAt
*/
/**
* @typedef {Object} AddWalletInput
* @property {string} name
* @property {string} source
*/
/**
* @typedef {Object} WalletVault
* @property {StoredWallet[]} wallets
*/
/**
* @param {unknown} value
* @returns {EncryptedSecret | undefined}
*/
function readEncryptedVault(value) {
return value && typeof value === "object"
? /** @type {EncryptedSecret} */ (value)
: undefined;
}
/**
* @param {unknown} value
* @returns {WalletVault}
*/
function readVault(value) {
if (!value || typeof value !== "object" || !("wallets" in value)) {
return { wallets: [] };
}
return Array.isArray(value.wallets)
? /** @type {WalletVault} */ (value)
: { wallets: [] };
}
function createWalletId() {
return crypto.randomUUID();
}
function now() {
return Date.now();
}
function has() {
return Boolean(localStorage.getItem(STORAGE_KEY));
}
function reset() {
localStorage.removeItem(STORAGE_KEY);
}
/**
* @param {AddWalletInput} input
*/
function createWallet(input) {
const time = now();
return {
id: createWalletId(),
name: input.name.trim(),
source: input.source.trim(),
createdAt: time,
updatedAt: time,
};
}
/**
* @param {string} pagePassword
*/
async function setup(pagePassword) {
await writeWallets([], pagePassword);
}
/**
* @param {string} pagePassword
*/
async function load(pagePassword) {
const value = localStorage.getItem(STORAGE_KEY);
const encrypted = value ? readEncryptedVault(JSON.parse(value)) : undefined;
if (!encrypted) return [];
const decrypted = await encryption.decrypt(encrypted, pagePassword);
const vault = readVault(JSON.parse(decrypted));
return vault.wallets;
}
/**
* @param {StoredWallet[]} wallets
* @param {string} pagePassword
*/
async function writeWallets(wallets, pagePassword) {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(
await encryption.encrypt(JSON.stringify({ wallets }), pagePassword),
),
);
}
/**
* @param {StoredWallet[]} wallets
* @param {AddWalletInput} input
* @param {string} pagePassword
*/
async function addWallet(wallets, input, pagePassword) {
const wallet = createWallet(input);
const nextWallets = [...wallets, wallet];
await writeWallets(nextWallets, pagePassword);
return {
wallet,
wallets: nextWallets,
};
}
/**
* @param {StoredWallet[]} wallets
* @param {string} walletId
* @param {string} pagePassword
*/
async function deleteWallet(wallets, walletId, pagePassword) {
const nextWallets = wallets.filter((wallet) => wallet.id !== walletId);
await writeWallets(nextWallets, pagePassword);
return nextWallets;
}
export const vaultStorage = /** @type {const} */ ({
has,
reset,
createWallet,
setup,
load,
addWallet,
deleteWallet,
});
@@ -0,0 +1,79 @@
import { createElement } from "../../dom.js";
import { formatNumber } from "../../format.js";
import { redaction } from "../../redaction/index.js";
/**
* @typedef {import("../../scan/index.js").WalletAddress} WalletAddress
*/
/**
* @param {string} text
*/
export function createGroupedAddress(text) {
const element = createElement("code", "address");
const groups = text.match(/.{1,4}/g) ?? [];
for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
const group = document.createElement("span");
for (const character of groups[groupIndex]) {
if (Number.isNaN(Number(character))) {
group.append(character);
} else {
const number = document.createElement("var");
number.append(character);
group.append(number);
}
}
element.append(group);
if (groupIndex < groups.length - 1) {
element.append(" ");
}
}
return element;
}
/**
* @param {string} address
*/
function createPrivateAddress(address) {
const element = createGroupedAddress(address);
redaction.setAddress(element, address, (text) => {
element.replaceChildren(...createGroupedAddress(text).childNodes);
});
return element;
}
/**
* @param {WalletAddress} row
*/
function createAddressBadge(row) {
const badge = document.createElement("b");
const label = row.branchLabel?.toLowerCase() ?? "address";
badge.append(label, ` #${formatNumber(row.index)}`);
return badge;
}
/**
* @param {WalletAddress} row
*/
export function createAddressCellContent(row) {
const element = createElement("div", "address-cell");
const anonSet = document.createElement("small");
anonSet.append(`anon set: ${formatNumber(row.historyBucketSize)}`);
element.append(
createAddressBadge(row),
createPrivateAddress(row.address),
anonSet,
);
return element;
}
@@ -0,0 +1,42 @@
main.wallets {
.address-cell {
display: grid;
gap: 0.25rem;
> b {
display: inline-flex;
align-items: center;
justify-self: start;
min-height: 1rem;
border: 1px solid color-mix(in oklch, var(--gray) 28%, transparent);
border-radius: 0.25rem;
padding: 0 0.25rem;
color: color-mix(in oklch, var(--white) 76%, var(--gray));
font-weight: 400;
line-height: 1;
}
> small {
color: var(--gray);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
}
}
.address {
display: flex;
flex-wrap: wrap;
gap: 0 0.375rem;
max-width: 40rem;
> span {
color: var(--white);
white-space: nowrap;
> var {
color: color-mix(in oklch, var(--white) 50%, var(--gray));
font-style: normal;
}
}
}
}
@@ -0,0 +1,64 @@
import { createBtcAmount } from "../../amount/index.js";
import { createElement } from "../../dom.js";
import { formatNumber } from "../../format.js";
import { createAddressCellContent } from "../address/index.js";
/**
* @typedef {import("../../scan/index.js").WalletAddress} WalletAddress
* @typedef {import("../../scan/index.js").WalletScan} WalletScan
*/
/**
* @param {WalletAddress} address
*/
function createAddressRow(address) {
const row = document.createElement("li");
const meta = document.createElement("p");
meta.append(
createBtcAmount("span", address.balance),
" · ",
formatNumber(address.txCount),
address.txCount === 1 ? " tx" : " txs",
);
row.append(createAddressCellContent(address), meta);
return row;
}
/**
* @param {HTMLElement} panel
* @param {readonly WalletAddress[]} addresses
*/
function renderAddresses(panel, addresses) {
const section = createElement("section", "addresses");
const title = document.createElement("h2");
const list = document.createElement("ol");
title.append("Addresses");
for (const address of addresses) {
list.append(createAddressRow(address));
}
section.append(title, list);
panel.replaceChildren(section);
}
/**
* @param {WalletScan} scan
*/
export function createAddressesTab(scan) {
const panel = document.createElement("section");
let mounted = false;
return {
id: "addresses",
label: "Addresses",
panel,
mount() {
if (mounted) return;
mounted = true;
renderAddresses(panel, scan.addresses);
},
};
}
@@ -0,0 +1,49 @@
main.wallets {
.addresses {
display: grid;
gap: 1rem;
:is(h2, p) {
margin: 0;
}
h2 {
color: var(--white);
font-size: var(--font-size-lg);
font-weight: 400;
line-height: var(--line-height-lg);
}
> ol {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
> li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
align-items: center;
padding: 0.875rem 0;
border-bottom: 1px solid color-mix(
in oklch,
var(--gray) 18%,
transparent
);
@media (max-width: 34rem) {
grid-template-columns: 1fr;
}
> p {
color: var(--gray);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
white-space: nowrap;
}
}
}
}
}
@@ -0,0 +1,88 @@
import { mapConcurrent } from "../../concurrent.js";
const HISTORY_CONCURRENCY = 4;
const MAX_SELECTED_ADDRESS_TXS = 100;
const historyByBucketKey =
/** @type {Map<string, Promise<Map<string, unknown[]>>>} */ (new Map());
/**
* @typedef {import("../../scan/index.js").WalletAddress} WalletAddress
*/
/**
* @typedef {Object} AddressHistoryClient
* @property {(address: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressTxs
*/
/**
* @typedef {Object} AddressHistory
* @property {unknown[]} transactions
*/
/**
* @param {readonly string[]} addresses
*/
function createBucketKey(addresses) {
return [...addresses].sort().join("\n");
}
/**
* @param {AddressHistoryClient} client
* @param {readonly string[]} addresses
* @returns {Promise<Map<string, unknown[]>>}
*/
async function fetchBucketHistory(client, addresses) {
const entries = await mapConcurrent(
addresses,
HISTORY_CONCURRENCY,
async (address) => {
const transactions = /** @type {unknown[]} */ (
await client.getAddressTxs(address, { cache: false })
);
return /** @type {const} */ ([address, transactions]);
},
);
return new Map(entries);
}
/**
* @param {AddressHistoryClient} client
* @param {WalletAddress} address
* @returns {Promise<AddressHistory>}
*/
async function load(client, address) {
if (
address.txCount > MAX_SELECTED_ADDRESS_TXS ||
address.historyAddresses.length === 0
) {
return {
transactions: [],
};
}
const key = createBucketKey(address.historyAddresses);
let history = historyByBucketKey.get(key);
if (!history) {
history = fetchBucketHistory(client, address.historyAddresses).catch(
(error) => {
historyByBucketKey.delete(key);
throw error;
},
);
historyByBucketKey.set(key, history);
}
const bucketHistory = await history;
return {
transactions: bucketHistory.get(address.address) ?? [],
};
}
export const addressHistory = /** @type {const} */ ({
load,
});
@@ -0,0 +1,64 @@
import { addressHistory } from "./address.js";
import { readWalletTransaction } from "./transaction.js";
/**
* @typedef {import("../../scan/index.js").WalletAddress} WalletAddress
* @typedef {import("./transaction.js").WalletTransaction} WalletTransaction
*/
/**
* @typedef {Object} TransactionClient
* @property {(address: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressTxs
*/
/**
* @param {WalletAddress} address
*/
function isUsedAddress(address) {
return address.txCount > 0;
}
/**
* @param {WalletTransaction} a
* @param {WalletTransaction} b
*/
function compareTransactions(a, b) {
if (a.time === undefined && b.time === undefined) {
return a.txid.localeCompare(b.txid);
}
if (a.time === undefined) return -1;
if (b.time === undefined) return 1;
return b.time - a.time;
}
/**
* @param {TransactionClient} client
* @param {readonly WalletAddress[]} addresses
* @returns {Promise<WalletTransaction[]>}
*/
async function load(client, addresses) {
const transactionsById = /** @type {Map<string, WalletTransaction>} */ (
new Map()
);
const usedAddresses = addresses.filter(isUsedAddress);
for (const address of usedAddresses) {
const history = await addressHistory.load(client, address);
for (const transaction of history.transactions) {
const walletTransaction = readWalletTransaction(transaction, usedAddresses);
if (walletTransaction.txid) {
transactionsById.set(walletTransaction.txid, walletTransaction);
}
}
}
return [...transactionsById.values()].sort(compareTransactions);
}
export const historyCache = /** @type {const} */ ({
load,
});
@@ -0,0 +1,38 @@
import { createBtcAmount } from "../../amount/index.js";
import { redaction } from "../../redaction/index.js";
import { createAddressCellContent } from "../address/index.js";
/**
* @typedef {import("./transaction.js").WalletTransaction} WalletTransaction
*/
/**
* @param {WalletTransaction} transaction
*/
export function createTransactionDetails(transaction) {
const content = document.createElement("section");
const txid = document.createElement("code");
const meta = document.createElement("p");
const list = document.createElement("ul");
redaction.setTitle(txid, transaction.txid);
redaction.setValue(txid, transaction.txid);
meta.append(
transaction.status,
" · ",
createBtcAmount("span", transaction.amount, { signed: true }),
" · fee ",
createBtcAmount("span", transaction.fee),
);
for (const address of transaction.addresses) {
const item = document.createElement("li");
item.append(createAddressCellContent(address.walletAddress));
list.append(item);
}
content.append(txid, meta, list);
return content;
}
@@ -0,0 +1,82 @@
import { createElement } from "../../dom.js";
import { historyCache } from "./cache.js";
import { createTransactionSection } from "./section.js";
/**
* @typedef {import("./transaction.js").WalletTransaction} WalletTransaction
* @typedef {import("../../scan/index.js").WalletScan} WalletScan
* @typedef {Parameters<typeof historyCache.load>[0]} HistoryClient
*/
/**
* @param {readonly WalletTransaction[]} transactions
*/
function groupTransactionsByDate(transactions) {
const groups = /** @type {Map<string, WalletTransaction[]>} */ (new Map());
for (const transaction of transactions) {
const group = groups.get(transaction.date) ?? [];
group.push(transaction);
groups.set(transaction.date, group);
}
return groups;
}
/**
* @param {HTMLElement} element
* @param {readonly WalletTransaction[]} transactions
*/
function renderHistory(element, transactions) {
const activity = createElement("section", "activity");
const title = document.createElement("h2");
const groups = groupTransactionsByDate(transactions);
title.append("History");
activity.append(title);
if (transactions.length === 0) {
const empty = document.createElement("p");
empty.append("No activity yet");
activity.append(empty);
element.replaceChildren(activity);
return;
}
for (const [date, group] of groups) {
activity.append(createTransactionSection(date, group));
}
element.replaceChildren(activity);
}
/**
* @param {WalletScan} scan
* @param {HistoryClient} client
*/
export function createHistoryTab(scan, client) {
const panel = document.createElement("section");
let loaded = false;
return {
id: "history",
label: "History",
panel,
mount() {
if (loaded) return;
loaded = true;
panel.replaceChildren("Loading history");
void historyCache.load(client, scan.addresses).then(
(transactions) => {
renderHistory(panel, transactions);
},
() => {
panel.replaceChildren("History unavailable");
},
);
},
};
}
@@ -0,0 +1,79 @@
import { createBtcAmount } from "../../amount/index.js";
import { redaction } from "../../redaction/index.js";
import { createTransactionDetails } from "./details.js";
/**
* @typedef {import("./transaction.js").WalletTransaction} WalletTransaction
*/
const typeLabels = /** @type {const} */ ({
receive: "Received",
send: "Sent",
consolidation: "Consolidated",
});
/**
* @param {string} txid
*/
function formatTxid(txid) {
return txid.length > 16 ? `${txid.slice(0, 8)}...${txid.slice(-8)}` : txid;
}
/**
* @param {HTMLElement} element
* @param {WalletTransaction} transaction
*/
function appendTransactionDetail(element, transaction) {
if (transaction.type === "consolidation") {
element.append(
`${transaction.addresses.length} wallet addresses · fee only`,
);
return;
}
if (transaction.type === "send") {
element.append(
"to external wallet · fee ",
createBtcAmount("span", transaction.fee),
);
return;
}
element.append(transaction.status);
}
/**
* @param {WalletTransaction} transaction
*/
export function createTransactionRow(transaction) {
const row = document.createElement("li");
const details = document.createElement("details");
const summary = document.createElement("summary");
const main = document.createElement("header");
const label = document.createElement("strong");
const amount = createBtcAmount(
"span",
transaction.amount,
{ signed: true },
);
const detail = document.createElement("p");
const txid = document.createElement("code");
label.append(typeLabels[transaction.type]);
if (transaction.amount > 0) {
amount.classList.add("positive");
}
if (transaction.amount < 0) {
amount.classList.add("negative");
}
redaction.setTitle(txid, transaction.txid);
redaction.setValue(txid, formatTxid(transaction.txid));
appendTransactionDetail(detail, transaction);
detail.append(" · ", txid);
main.append(label, amount);
summary.append(main, detail);
details.append(summary, createTransactionDetails(transaction));
row.append(details);
return row;
}
@@ -0,0 +1,24 @@
import { redaction } from "../../redaction/index.js";
import { createTransactionRow } from "./row.js";
/**
* @typedef {import("./transaction.js").WalletTransaction} WalletTransaction
*/
/**
* @param {string} date
* @param {readonly WalletTransaction[]} transactions
*/
export function createTransactionSection(date, transactions) {
const section = document.createElement("section");
const heading = document.createElement("h3");
const list = document.createElement("ol");
heading.append(redaction.createValue("span", date, "fixed"));
for (const transaction of transactions) {
list.append(createTransactionRow(transaction));
}
section.append(heading, list);
return section;
}
@@ -0,0 +1,124 @@
main.wallets {
.activity {
display: grid;
gap: 1.25rem;
:is(h2, h3, p) {
margin: 0;
}
h2 {
color: var(--white);
font-size: var(--font-size-lg);
font-weight: 400;
line-height: var(--line-height-lg);
}
h3 {
color: var(--gray);
font-size: var(--font-size-xs);
font-weight: 400;
line-height: var(--line-height-xs);
text-transform: uppercase;
}
> section {
display: grid;
gap: 0.5rem;
> ol {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
> li {
border-bottom: 1px solid color-mix(
in oklch,
var(--gray) 18%,
transparent
);
> details {
&[open] {
display: grid;
gap: 0.75rem;
}
> summary {
display: grid;
gap: 0.25rem;
padding: 0.875rem 0;
list-style: none;
cursor: pointer;
&::marker,
&::-webkit-details-marker {
display: none;
content: "";
}
> header {
display: flex;
gap: 1rem;
align-items: baseline;
justify-content: space-between;
min-width: 0;
strong {
color: var(--white);
font-weight: 500;
}
> span {
color: var(--white);
white-space: nowrap;
&.positive {
color: var(--green);
}
&.negative {
color: var(--red);
}
}
}
> p {
min-width: 0;
color: var(--gray);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
code {
color: inherit;
font-family: inherit;
}
}
}
> section {
display: grid;
gap: 1rem;
code {
overflow-wrap: anywhere;
color: var(--white);
font-family: inherit;
}
> ul {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
}
}
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More