depolymerization

wire gateway for Bitcoin/Ethereum
Log | Files | Refs | Submodules | README | LICENSE

commit 2885b53d08fabeaaf594c55a67a5c3f9596088b1
parent e034eeb86398da01d4894dbab50c45ece97bdc58
Author: Antoine A <>
Date:   Thu, 19 Jun 2025 14:00:57 +0200

common: rewrite wires as taler adapter

Diffstat:
M.gitignore | 6++++--
A.gitmodules | 7+++++++
MCargo.lock | 443+++++++++++++++++++++++++++++++++++--------------------------------------------
MCargo.toml | 16+++++++++++-----
Abootstrap | 26++++++++++++++++++++++++++
Dbtc-wire/Cargo.toml | 41-----------------------------------------
Dbtc-wire/src/bin/segwit-demo.rs | 95-------------------------------------------------------------------------------
Dbtc-wire/src/btc_config.rs | 137-------------------------------------------------------------------------------
Dbtc-wire/src/fail_point.rs | 31-------------------------------
Dbtc-wire/src/lib.rs | 244-------------------------------------------------------------------------------
Dbtc-wire/src/loops.rs | 38--------------------------------------
Dbtc-wire/src/loops/analysis.rs | 43-------------------------------------------
Dbtc-wire/src/loops/watcher.rs | 36------------------------------------
Dbtc-wire/src/loops/worker.rs | 625-------------------------------------------------------------------------------
Dbtc-wire/src/main.rs | 176-------------------------------------------------------------------------------
Dbtc-wire/src/rpc.rs | 628-------------------------------------------------------------------------------
Dbtc-wire/src/rpc_utils.rs | 82-------------------------------------------------------------------------------
Dbtc-wire/src/sql.rs | 55-------------------------------------------------------
Dbtc-wire/src/taler_utils.rs | 47-----------------------------------------------
Abuild-system/configure.py | 9+++++++++
Abuild-system/taler-build-scripts | 1+
Mcommon/Cargo.toml | 9++-------
Dcommon/src/config.rs | 224-------------------------------------------------------------------------------
Dcommon/src/currency.rs | 89-------------------------------------------------------------------------------
Mcommon/src/lib.rs | 16+---------------
Mcommon/src/log.rs | 29+++--------------------------
Dcommon/src/payto.rs | 92-------------------------------------------------------------------------------
Mcommon/src/reconnect.rs | 73++++++++++++++-----------------------------------------------------------
Mcommon/src/sql.rs | 19+++++++++++--------
Acontrib/depolymerizer-bitcoin-dbconfig | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontrib/depolymerizer-ethereum-dbconfig | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adatabase-versioning/depolymerizer-bitcoin-0001.sql | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adatabase-versioning/depolymerizer-bitcoin-drop.sql | 30++++++++++++++++++++++++++++++
Adatabase-versioning/depolymerizer-bitcoin-procedures.sql | 40++++++++++++++++++++++++++++++++++++++++
Adatabase-versioning/depolymerizer-ethereum-0001.sql | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adatabase-versioning/depolymerizer-ethereum-drop.sql | 30++++++++++++++++++++++++++++++
Adatabase-versioning/depolymerizer-ethereum-procedures.sql | 40++++++++++++++++++++++++++++++++++++++++
Adatabase-versioning/versioning.sql | 294+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ddb/btc.sql | 43-------------------------------------------
Ddb/common.sql | 32--------------------------------
Ddb/eth.sql | 44--------------------------------------------
Adepolymerizer-bitcoin/Cargo.toml | 46++++++++++++++++++++++++++++++++++++++++++++++
Rbtc-wire/README.md -> depolymerizer-bitcoin/README.md | 0
Rbtc-wire/benches/metadata.rs -> depolymerizer-bitcoin/benches/metadata.rs | 0
Adepolymerizer-bitcoin/depolymerizer-bitcoin.conf | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-bitcoin/src/api.rs | 408+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-bitcoin/src/bin/segwit-demo.rs | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-bitcoin/src/config.rs | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-bitcoin/src/fail_point.rs | 31+++++++++++++++++++++++++++++++
Adepolymerizer-bitcoin/src/lib.rs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-bitcoin/src/loops.rs | 38++++++++++++++++++++++++++++++++++++++
Adepolymerizer-bitcoin/src/loops/analysis.rs | 42++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-bitcoin/src/loops/watcher.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-bitcoin/src/loops/worker.rs | 636+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-bitcoin/src/main.rs | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-bitcoin/src/payto.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-bitcoin/src/rpc.rs | 623+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-bitcoin/src/rpc_utils.rs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rbtc-wire/src/segwit.rs -> depolymerizer-bitcoin/src/segwit.rs | 0
Adepolymerizer-bitcoin/src/sql.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-bitcoin/src/taler_utils.rs | 33+++++++++++++++++++++++++++++++++
Adepolymerizer-bitcoin/tests/api.rs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-ethereum/Cargo.toml | 38++++++++++++++++++++++++++++++++++++++
Reth-wire/README.md -> depolymerizer-ethereum/README.md | 0
Adepolymerizer-ethereum/depolymerizer-ethereum.conf | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-ethereum/src/api.rs | 404+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-ethereum/src/config.rs | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-ethereum/src/fail_point.rs | 31+++++++++++++++++++++++++++++++
Adepolymerizer-ethereum/src/lib.rs | 240+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-ethereum/src/loops.rs | 38++++++++++++++++++++++++++++++++++++++
Adepolymerizer-ethereum/src/loops/analysis.rs | 33+++++++++++++++++++++++++++++++++
Adepolymerizer-ethereum/src/loops/watcher.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-ethereum/src/loops/worker.rs | 530+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-ethereum/src/main.rs | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-ethereum/src/payto.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-ethereum/src/rpc.rs | 573+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-ethereum/src/sql.rs | 40++++++++++++++++++++++++++++++++++++++++
Adepolymerizer-ethereum/src/taler_util.rs | 36++++++++++++++++++++++++++++++++++++
Adepolymerizer-ethereum/tests/api.rs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adoc/prebuilt | 1+
Deth-wire/Cargo.toml | 27---------------------------
Deth-wire/src/fail_point.rs | 31-------------------------------
Deth-wire/src/lib.rs | 311-------------------------------------------------------------------------------
Deth-wire/src/loops.rs | 38--------------------------------------
Deth-wire/src/loops/analysis.rs | 34----------------------------------
Deth-wire/src/loops/watcher.rs | 41-----------------------------------------
Deth-wire/src/loops/worker.rs | 524-------------------------------------------------------------------------------
Deth-wire/src/main.rs | 158-------------------------------------------------------------------------------
Deth-wire/src/rpc.rs | 589-------------------------------------------------------------------------------
Deth-wire/src/rpc_utils.rs | 36------------------------------------
Deth-wire/src/sql.rs | 46----------------------------------------------
Deth-wire/src/taler_util.rs | 46----------------------------------------------
Minstrumentation/Cargo.toml | 7+++----
Minstrumentation/conf/bitcoin.conf | 4++++
Minstrumentation/conf/bitcoin2.conf | 4++++
Dinstrumentation/conf/bitcoin_auth0.conf | 11-----------
Dinstrumentation/conf/bitcoin_auth1.conf | 13-------------
Dinstrumentation/conf/bitcoin_auth2.conf | 11-----------
Dinstrumentation/conf/bitcoin_auth3.conf | 10----------
Dinstrumentation/conf/bitcoin_auth4.conf | 10----------
Dinstrumentation/conf/bitcoin_auth5.conf | 11-----------
Minstrumentation/conf/taler_btc.conf | 22++++++++++++----------
Dinstrumentation/conf/taler_btc_auth.conf | 19-------------------
Minstrumentation/conf/taler_btc_bump.conf | 27++++++++++++++-------------
Minstrumentation/conf/taler_btc_lifetime.conf | 29++++++++++++++++-------------
Minstrumentation/conf/taler_eth.conf | 22++++++++++++----------
Minstrumentation/conf/taler_eth_bump.conf | 25++++++++++++++-----------
Minstrumentation/conf/taler_eth_lifetime.conf | 29+++++++++++++++++------------
Minstrumentation/src/btc.rs | 381++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Minstrumentation/src/eth.rs | 218+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Dinstrumentation/src/gateway.rs | 240-------------------------------------------------------------------------------
Minstrumentation/src/main.rs | 18+++---------------
Minstrumentation/src/utils.rs | 157+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mmakefile | 64++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Muri-pack/Cargo.toml | 2+-
Dwire-gateway/Cargo.toml | 30------------------------------
Dwire-gateway/README.md | 41-----------------------------------------
Dwire-gateway/src/main.rs | 400-------------------------------------------------------------------------------
118 files changed, 7304 insertions(+), 6271 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,5 +1,6 @@ /target log +configure /.vscode /docs/* !/docs/*.docx @@ -10,4 +11,5 @@ log !/docs/figures !/docs/tables /tmp -taler.conf -\ No newline at end of file +taler.conf +*.mk +\ No newline at end of file diff --git a/.gitmodules b/.gitmodules @@ -0,0 +1,7 @@ +[submodule "build-system/taler-build-scripts"] + path = build-system/taler-build-scripts + url = ../build-common.git +[submodule "doc/prebuilt"] + path = doc/prebuilt + url = ../taler-docs.git + branch = prebuilt diff --git a/Cargo.lock b/Cargo.lock @@ -33,21 +33,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] name = "anes" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -311,28 +296,10 @@ dependencies = [ ] [[package]] -name = "btc-wire" -version = "0.1.0" -dependencies = [ - "bech32", - "bitcoin", - "clap", - "common", - "const-hex", - "criterion", - "data-encoding", - "rust-ini", - "serde", - "serde_json", - "serde_repr", - "thiserror", -] - -[[package]] name = "bumpalo" -version = "3.18.1" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "byteorder" @@ -368,18 +335,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" [[package]] -name = "chrono" -version = "0.4.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" -dependencies = [ - "android-tzdata", - "iana-time-zone", - "num-traits", - "windows-link", -] - -[[package]] name = "ciborium" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -479,18 +434,15 @@ dependencies = [ "bitcoin", "const-hex", "ethereum-types", - "exponential-backoff", - "flexi_logger", - "log", "postgres", "rand 0.9.1", "sqlx", "taler-api", "taler-common", "thiserror", + "tracing", "uri-pack", "url", - "zeroize", ] [[package]] @@ -504,15 +456,15 @@ dependencies = [ [[package]] name = "console" -version = "0.15.11" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "2e09ced7ebbccb63b4c65413d821f2e00ce54c5ca4514ddc6b3c892fdbcbc69d" dependencies = [ "encode_unicode", "libc", "once_cell", "unicode-width", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -584,12 +536,6 @@ dependencies = [ ] [[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -624,25 +570,22 @@ dependencies = [ [[package]] name = "criterion" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +checksum = "3bf7af66b0989381bd0be551bd7cc91912a655a58c6918420c9527b1fd8b4679" dependencies = [ "anes", "cast", "ciborium", "clap", "criterion-plot", - "is-terminal", - "itertools", + "itertools 0.13.0", "num-traits", - "once_cell", "oorandom", "plotters", "rayon", "regex", "serde", - "serde_derive", "serde_json", "tinytemplate", "walkdir", @@ -655,7 +598,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ "cast", - "itertools", + "itertools 0.10.5", ] [[package]] @@ -694,9 +637,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" @@ -805,10 +748,51 @@ dependencies = [ ] [[package]] -name = "data-encoding" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +name = "depolymerizer-bitcoin" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "base64", + "bech32", + "bitcoin", + "clap", + "common", + "const-hex", + "criterion", + "serde", + "serde_json", + "serde_repr", + "sqlx", + "taler-api", + "taler-common", + "taler-test-utils", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "depolymerizer-ethereum" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "clap", + "common", + "const-hex", + "ethereum-types", + "rustc-hex", + "serde", + "serde_json", + "sqlx", + "taler-api", + "taler-common", + "taler-test-utils", + "thiserror", + "tokio", + "tracing", +] [[package]] name = "der" @@ -931,12 +915,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -951,19 +935,6 @@ dependencies = [ ] [[package]] -name = "eth-wire" -version = "0.1.0" -dependencies = [ - "clap", - "common", - "const-hex", - "ethereum-types", - "serde", - "serde_json", - "thiserror", -] - -[[package]] name = "ethbloom" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1000,15 +971,6 @@ dependencies = [ ] [[package]] -name = "exponential-backoff" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "949eb68d436415e37b7a69c49a9900d5337616b0e420377ccc48038b86261e16" -dependencies = [ - "fastrand", -] - -[[package]] name = "fallible-iterator" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1048,17 +1010,6 @@ dependencies = [ ] [[package]] -name = "flexi_logger" -version = "0.30.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb03342077df16d5b1400d7bed00156882846d7a479ff61a6f10594bcc3423d8" -dependencies = [ - "chrono", - "log", - "thiserror", -] - -[[package]] name = "flume" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1262,12 +1213,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1397,30 +1342,6 @@ dependencies = [ ] [[package]] -name = "iana-time-zone" -version = "0.1.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] name = "icu_collections" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1544,9 +1465,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", "hashbrown 0.15.4", @@ -1554,14 +1475,14 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.17.11" +version = "0.17.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +checksum = "4adb2ee6ad319a912210a36e56e3623555817bcc877a7e6e8802d1d69c4d8056" dependencies = [ "console", - "number_prefix", "portable-atomic", "unicode-width", + "unit-prefix", "web-time", ] @@ -1569,18 +1490,18 @@ dependencies = [ name = "instrumentation" version = "0.1.0" dependencies = [ + "anyhow", "bitcoin", - "btc-wire", "clap", "clap_mangen", "color-backtrace", "common", "const-hex", - "eth-wire", + "depolymerizer-bitcoin", + "depolymerizer-ethereum", "ethereum-types", "fastrand", "indicatif", - "libdeflater", "owo-colors", "rust-ini", "signal-child", @@ -1592,14 +1513,14 @@ dependencies = [ ] [[package]] -name = "is-terminal" -version = "0.4.16" +name = "io-uring" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" dependencies = [ - "hermit-abi", + "bitflags", + "cfg-if", "libc", - "windows-sys 0.59.0", ] [[package]] @@ -1618,6 +1539,15 @@ dependencies = [ ] [[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] name = "itoa" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1864,12 +1794,6 @@ dependencies = [ ] [[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - -[[package]] name = "object" version = "0.36.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1914,9 +1838,9 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "owo-colors" -version = "4.2.1" +version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26995317201fa17f3656c36716aed4a7c81743a9634ac4c99c0eeda495db0cec" +checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" [[package]] name = "parking" @@ -2577,9 +2501,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.13.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf65a400f8f66fb7b0552869ad70157166676db75ed8181f8104ea91cf9d0b42" +checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" dependencies = [ "serde", "serde_derive", @@ -2588,9 +2512,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.13.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81679d9ed988d5e9a5e6531dc3f2c28efbd639cbd1dfb628df08edea6004da77" +checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" dependencies = [ "darling", "proc-macro2", @@ -2941,9 +2865,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.103" +version = "2.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4307e30089d6fd6aff212f2da3a1f9e32f3223b1f010fb09b7c95f90f3ca1e8" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" dependencies = [ "proc-macro2", "quote", @@ -2970,9 +2894,9 @@ dependencies = [ [[package]] name = "taler-api" version = "0.0.0" -source = "git+https://git.taler.net/taler-rust.git/#9d2bcbf4d1fe2e4efb2a024966077efeaddec0ae" dependencies = [ "axum", + "base64", "dashmap", "ed25519-dalek", "http-body-util", @@ -2992,7 +2916,6 @@ dependencies = [ [[package]] name = "taler-common" version = "0.0.0" -source = "git+https://git.taler.net/taler-rust.git/#9d2bcbf4d1fe2e4efb2a024966077efeaddec0ae" dependencies = [ "anyhow", "clap", @@ -3015,6 +2938,26 @@ dependencies = [ ] [[package]] +name = "taler-test-utils" +version = "0.0.0" +dependencies = [ + "axum", + "http-body-util", + "libdeflater", + "serde", + "serde_json", + "serde_urlencoded", + "sqlx", + "taler-api", + "taler-common", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] name = "tempfile" version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3148,16 +3091,18 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.45.1" +version = "1.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "1140bb80481756a8cbe10541f37433b459c5aa1e727b4c020fbfebdc25bf3ec4" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "pin-project-lite", "signal-hook-registry", + "slab", "socket2", "tokio-macros", "windows-sys 0.52.0", @@ -3374,6 +3319,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" [[package]] +name = "unit-prefix" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817" + +[[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3599,14 +3550,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.0", + "webpki-roots 1.0.1", ] [[package]] name = "webpki-roots" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" +checksum = "8782dd5a41a24eed3a4f40b606249b3e236ca61adf1f25ea4d45c73de122b502" dependencies = [ "rustls-pki-types", ] @@ -3654,65 +3605,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link", -] - -[[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3740,6 +3632,15 @@ dependencies = [ ] [[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.2", +] + +[[package]] name = "windows-targets" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3763,7 +3664,7 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", @@ -3771,6 +3672,22 @@ dependencies = [ ] [[package]] +name = "windows-targets" +version = "0.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3783,6 +3700,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3795,6 +3718,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3807,12 +3736,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3825,6 +3766,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3837,6 +3784,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3849,6 +3802,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3861,20 +3820,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "wire-gateway" -version = "0.1.0" -dependencies = [ - "axum", - "bitcoin", - "clap", - "common", - "ethereum-types", - "sqlx", - "taler-api", - "taler-common", - "time", - "tokio", -] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "wit-bindgen-rt" diff --git a/Cargo.toml b/Cargo.toml @@ -1,9 +1,8 @@ [workspace] resolver = "3" members = [ - "wire-gateway", - "btc-wire", - "eth-wire", + "depolymerizer-bitcoin", + "depolymerizer-ethereum", "uri-pack", "common", "instrumentation", @@ -32,8 +31,9 @@ sqlx = { version = "0.8", features = [ "time", ], default-features = false } url = { version = "2.2", features = ["serde"] } -taler-common = { git = "https://git.taler.net/taler-rust.git/" } -taler-api = { git = "https://git.taler.net/taler-rust.git/" } +taler-common = { path = "../taler-rust/common/taler-common" } +taler-api = { path = "../taler-rust/common/taler-api" } +taler-test-utils = { path = "../taler-rust/common/taler-test-utils" } bitcoin = { version = "0.32.5", features = [ "std", "serde", @@ -43,3 +43,8 @@ ethereum-types = { version = "0.15.1", default-features = false, features = [ ] } hex = { package = "const-hex", version = "1.9.1" } clap = { version = "4.5", features = ["derive"] } +anyhow = "1" +tracing = "0.1" +tracing-subscriber = "0.3" +criterion = "0.6" +base64 = "0.22.1" +\ No newline at end of file diff --git a/bootstrap b/bootstrap @@ -0,0 +1,25 @@ +#!/bin/sh + +# Bootstrap the repository. Used when the repository is checked out from git. +# When using the source tarball, running this script is not necessary. + +set -eu + +if ! git --version >/dev/null; then + echo "git not installed" + exit 1 +fi + +if ! python3 --version >/dev/null; then + echo "python3 not installed" + exit 1 +fi + +# Make sure that "git pull" et al. also update +# submodules to avoid accidental rollbacks. +git config --local submodule.recurse true + +git submodule sync +git submodule update --init +rm -f ./configure +cp build-system/taler-build-scripts/configure ./configure +\ No newline at end of file diff --git a/btc-wire/Cargo.toml b/btc-wire/Cargo.toml @@ -1,41 +0,0 @@ -[package] -name = "btc-wire" -version = "0.1.0" -edition.workspace = true -authors.workspace = true -homepage.workspace = true -repository.workspace = true -license-file.workspace = true - -[features] -# Enable random failures -fail = [] - -[dependencies] -# Typed bitcoin rpc types -bitcoin.workspace = true -# Cli args parser -clap.workspace = true -# Bech32 encoding and decoding -bech32 = "0.11.0" -# Serialization library -serde.workspace = true -serde_json.workspace = true -serde_repr = "0.1.16" -# Error macros -thiserror.workspace = true -data-encoding = "2.4.0" -# Common lib -common = { path = "../common" } -# Ini parser -rust-ini = "0.21.0" -# Hexadecimal encoding -hex.workspace = true - -[dev-dependencies] -# statistics-driven micro-benchmarks -criterion = "0.5.1" - -[[bench]] -name = "metadata" -harness = false diff --git a/btc-wire/src/bin/segwit-demo.rs b/btc-wire/src/bin/segwit-demo.rs @@ -1,95 +0,0 @@ -use std::str::FromStr; - -use bech32::Hrp; -use bitcoin::{Address, Amount, Network}; -use btc_wire::{guess_network, segwit::decode_segwit_msg}; -use btc_wire::{rpc_utils, segwit::encode_segwit_addr}; -use common::{rand_slice, taler_common::types::base32}; - -pub fn main() { - let address = Address::from_str("tb1qhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v") - .unwrap() - .assume_checked(); - let amount = Amount::from_sat(5000000); - let reserve_pub = "54ZN9AMVN1R0YZ68ZPVHHQA4KZE1V037M05FNMYH4JQ596YAKJEG"; - let btc = amount.to_btc(); - - println!("Ⅰ - Parse payto uri"); - println!( - "Got payto uri: payto://bitcoin/{}?amount=BTC:{}&subject={}", - address, btc, reserve_pub - ); - println!( - "Send {} BTC to {} with reserve public key {}", - btc, address, reserve_pub - ); - - println!("\nⅡ - Generate fake segwit addresses"); - let decoded: [u8; 32] = base32::decode(reserve_pub.as_bytes()).unwrap(); - println!("Decode reserve public key: 0x{}", hex::encode(&decoded[..])); - let prefix: [u8; 4] = rand_slice(); - println!("Generate random prefix 0x{}", hex::encode(prefix)); - println!( - "Split reserve public key in two:\n0x{}\n0x{}", - hex::encode(&decoded[..16]), - hex::encode(&decoded[16..]) - ); - let mut first_half = [&prefix, &decoded[..16]].concat(); - let mut second_half = [&prefix, &decoded[16..]].concat(); - println!( - "Concatenate random prefix with each reserve public key half:\n0x{}\n0x{}", - hex::encode(&first_half), - hex::encode(&second_half) - ); - first_half[0] &= 0b0111_1111; - second_half[0] |= 0b1000_0000; - println!( - "Set first bit of the first half:\n0x{}\nUnset first bit of the second half:\n0x{}", - hex::encode(&first_half), - hex::encode(&second_half) - ); - // bech32: https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki - let hrp = match guess_network(&address) { - Network::Bitcoin => "bc", - Network::Testnet | Network::Signet => "tb", - Network::Regtest => "bcrt", - _ => unimplemented!(), - }; - let hrp = Hrp::parse(hrp).unwrap(); - let first = encode_segwit_addr(hrp, first_half[..].try_into().unwrap()); - let second = encode_segwit_addr(hrp, second_half[..].try_into().unwrap()); - println!( - "Encode each half using bech32 to generate a segwit address:\n{}\n{}", - first, second - ); - - println!("\nⅢ - Send to many"); - let minimum = rpc_utils::segwit_min_amount().to_btc(); - println!("Send a single bitcoin transaction with the three addresses as recipient as follow:"); - println!( - "\nIn bitcoincore wallet use 'Add Recipient' button to add two additional recipient and copy adresses and amounts" - ); - let first = Address::from_str(&first).unwrap().assume_checked(); - let second = Address::from_str(&second).unwrap().assume_checked(); - for (address, amount) in [(&address, btc), (&first, minimum), (&second, minimum)] { - println!("{} {:.8} BTC", address, amount); - } - println!("\nIn Electrum wallet paste the following three lines in 'Pay to' field :"); - for (address, amount) in [(&address, btc), (&first, minimum), (&second, minimum)] { - println!("{},{:.8}", address, amount); - } - println!( - "Make sure the amount show 0.10000588 BTC, else you have to change the base unit to BTC" - ); - - let key1 = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; - let key2 = "tb1qzxwu2p7urkqx0gq2ltfazf9w2jdu48ya8qwlm0"; - let key3 = "tb1qzxwu2pef8a224xagwq8hej8akuvd63yluu3wrh"; - let addresses = vec![key1, key2, key3]; - let dec = decode_segwit_msg(&addresses); - - println!( - "Decode reserve public key: 0x{}", - hex::encode(&dec.unwrap()[..]) - ); -} diff --git a/btc-wire/src/btc_config.rs b/btc-wire/src/btc_config.rs @@ -1,137 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -use std::{ - net::SocketAddr, - path::{Path, PathBuf}, - str::FromStr, -}; - -use bitcoin::Network; -use common::{ - currency::CurrencyBtc, - log::{OrFail, fail}, -}; - -use crate::{ - check_network_currency, - rpc_utils::{chain_dir, rpc_port}, -}; - -pub const WIRE_WALLET_NAME: &str = "wire"; - -#[derive(Debug, Clone)] -pub enum BtcAuth { - Cookie(PathBuf), - Auth(String), -} - -/// Bitcoin config relevant for btc-wire -#[derive(Debug, Clone)] -pub struct BitcoinConfig { - pub network: Network, - pub addr: SocketAddr, - pub auth: BtcAuth, -} - -impl BitcoinConfig { - /// Load from bitcoin path - pub fn load(path: impl AsRef<Path>, currency: CurrencyBtc) -> Result<Self, ini::Error> { - let path = path.as_ref(); - let conf = if path.is_dir() { - ini::Ini::load_from_file(path.join("bitcoin.conf")) - } else { - ini::Ini::load_from_file(path) - }?; - - let main = conf.general_section(); - - if !main.contains_key("txindex") { - fail("require a bitcoind node running with 'txindex' option"); - } - - if !main.contains_key("maxtxfee") { - fail("require a bitcoind node running with 'maxtxfee' option"); - } - - let network = if let Some("1") = main.get("testnet") { - Network::Testnet - } else if let Some("1") = main.get("signet") { - Network::Signet - } else if let Some("1") = main.get("regtest") { - Network::Regtest - } else { - Network::Bitcoin - }; - - check_network_currency(network, currency); - - let section = match network { - Network::Bitcoin => Some(main), - Network::Testnet => conf.section(Some("test")), - Network::Signet => conf.section(Some("signet")), - Network::Regtest => conf.section(Some("regtest")), - _ => unimplemented!(), - }; - - let port = if let Some(addr) = section.and_then(|s| s.get("rpcport")) { - addr.parse() - .or_fail(|_| "bitcoin config 'rpcport' is not a valid port number".into()) - } else { - rpc_port(network) - }; - - let addr = if let Some(addr) = section.and_then(|s| s.get("rpcbind")) { - SocketAddr::from_str(addr) - .or_fail(|_| "bitcoin config 'rpcbind' is not a valid socket address".into()) - } else { - ([127, 0, 0, 1], port).into() - }; - - let auth = if let (Some(login), Some(passwd)) = ( - section.and_then(|s| s.get("rpcuser")), - section.and_then(|s| s.get("rpcpassword")), - ) { - BtcAuth::Auth(format!("{}:{}", login, passwd)) - } else if let (Some(login), Some(passwd)) = (main.get("rpcuser"), main.get("rpcpassword")) { - BtcAuth::Auth(format!("{}:{}", login, passwd)) - } else { - let cookie_file = if let Some(path) = section.and_then(|s| s.get("rpccookiefile")) { - path - } else if let Some(path) = main.get("rpccookiefile") { - path - } else { - ".cookie" - } - .to_string(); - let data_dir = if let Some(path) = section.and_then(|s| s.get("datadir")) { - PathBuf::from(path) - } else if let Some(path) = main.get("datadir") { - PathBuf::from(path) - } else if path.is_file() { - path.parent().unwrap().to_path_buf() - } else { - path.to_path_buf() - }; - BtcAuth::Cookie(data_dir.join(chain_dir(network)).join(cookie_file)) - }; - - Ok(Self { - network, - addr, - auth, - }) - } -} diff --git a/btc-wire/src/fail_point.rs b/btc-wire/src/fail_point.rs @@ -1,31 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -#[derive(Debug, thiserror::Error)] -#[error("{0}")] -pub struct Injected(&'static str); - -/// Inject random failure when 'fail' feature is used -#[allow(unused_variables)] -pub fn fail_point(msg: &'static str, prob: f32) -> Result<(), Injected> { - #[cfg(feature = "fail")] - return if common::rand::random::<f32>() < prob { - Err(Injected(msg)) - } else { - Ok(()) - }; - - Ok(()) -} diff --git a/btc-wire/src/lib.rs b/btc-wire/src/lib.rs @@ -1,244 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -use std::path::{Path, PathBuf}; -use std::str::FromStr; - -use bitcoin::{Address, Amount, Network, Txid, hashes::hex::FromHex}; -use btc_config::BitcoinConfig; -use common::{ - config::TalerConfig, - currency::{Currency, CurrencyBtc}, - log::{OrFail, fail}, - postgres, - taler_common::{api_common::EddsaPublicKey, types::amount::Amount as TalerAmount}, - url::Url, -}; -use rpc::{Category, Rpc, Transaction}; -use rpc_utils::{default_data_dir, segwit_min_amount, sender_address}; -use segwit::{decode_segwit_msg, encode_segwit_key}; -use taler_utils::taler_to_btc; - -pub mod btc_config; -pub mod rpc; -pub mod rpc_utils; -pub mod segwit; -pub mod taler_utils; - -#[derive(Debug, thiserror::Error)] -pub enum GetSegwitErr { - #[error(transparent)] - Decode(#[from] segwit::DecodeSegWitErr), - #[error(transparent)] - RPC(#[from] rpc::Error), -} - -#[derive(Debug, thiserror::Error)] -pub enum GetOpReturnErr { - #[error("Missing opreturn")] - MissingOpReturn, - #[error(transparent)] - RPC(#[from] rpc::Error), -} - -/// An extended bitcoincore JSON-RPC api client who can send and retrieve metadata with their transaction -impl Rpc { - /// Send a transaction with a 32B key as metadata encoded using fake segwit addresses - pub fn send_segwit_key( - &mut self, - to: &Address, - amount: &Amount, - metadata: &[u8; 32], - ) -> rpc::Result<Txid> { - let network = guess_network(to); - let hrp = match network { - Network::Bitcoin => bech32::hrp::BC, - Network::Testnet | Network::Signet => bech32::hrp::TB, - Network::Regtest => bech32::hrp::BCRT, - _ => unimplemented!(), - }; - let addresses = encode_segwit_key(hrp, metadata); - let addresses = [ - Address::from_str(&addresses[0]).unwrap().assume_checked(), - Address::from_str(&addresses[1]).unwrap().assume_checked(), - ]; - let mut recipients = vec![(to, amount)]; - let min = segwit_min_amount(); - recipients.extend(addresses.iter().map(|addr| (addr, &min))); - self.send_many(recipients) - } - - /// Get detailed information about an in-wallet transaction and it's 32B metadata key encoded using fake segwit addresses - pub fn get_tx_segwit_key( - &mut self, - id: &Txid, - ) -> Result<(Transaction, EddsaPublicKey), GetSegwitErr> { - let full = self.get_tx(id)?; - - let addresses: Vec<String> = full - .decoded - .vout - .iter() - .filter_map(|it| { - it.script_pub_key - .address - .as_ref() - .map(|addr| addr.clone().assume_checked().to_string()) - }) - .collect(); - - let metadata = decode_segwit_msg(&addresses)?; - - Ok((full, metadata)) - } - - /// Get detailed information about an in-wallet transaction and its op_return metadata - pub fn get_tx_op_return( - &mut self, - id: &Txid, - ) -> Result<(Transaction, Vec<u8>), GetOpReturnErr> { - let full = self.get_tx(id)?; - - let op_return_out = full - .decoded - .vout - .iter() - .find(|it| it.script_pub_key.asm.starts_with("OP_RETURN")) - .ok_or(GetOpReturnErr::MissingOpReturn)?; - - let hex = op_return_out.script_pub_key.asm.split_once(' ').unwrap().1; - // Op return payload is always encoded in hexadecimal - let metadata = Vec::from_hex(hex).unwrap(); - - Ok((full, metadata)) - } - - /// Bounce a transaction bask to its sender - /// - /// There is no reliable way to bounce a transaction as you cannot know if the addresses - /// used are shared or come from a third-party service. We only send back to the first input - /// address as a best-effort gesture. - pub fn bounce( - &mut self, - id: &Txid, - bounce_fee: &Amount, - metadata: Option<&[u8]>, - ) -> Result<Txid, rpc::Error> { - let full = self.get_tx(id)?; - let detail = &full.details[0]; - assert!(detail.category == Category::Receive); - - let amount = detail.amount.to_unsigned().unwrap(); - let sender = sender_address(self, &full)?; - let bounce_amount = Amount::from_sat(amount.to_sat().saturating_sub(bounce_fee.to_sat())); - // Send refund making recipient pay the transaction fees - self.send(&sender, &bounce_amount, metadata, true) - } -} - -const DEFAULT_CONFIRMATION: u16 = 6; -const DEFAULT_BOUNCE_FEE: &str = "0.00001"; - -pub struct WireState { - pub confirmation: u32, - pub max_confirmation: u32, - pub btc_config: BitcoinConfig, - pub bounce_fee: Amount, - pub lifetime: Option<u32>, - pub bump_delay: Option<u32>, - pub base_url: Url, - pub db_config: postgres::Config, - pub currency: CurrencyBtc, -} - -impl WireState { - pub fn load_taler_config(file: Option<&Path>) -> Self { - let (taler_config, path, currency) = load_taler_config(file); - let btc_config = - BitcoinConfig::load(path, currency).or_fail(|e| format!("bitcoin config: {}", e)); - let init_confirmation = taler_config.confirmation().unwrap_or(DEFAULT_CONFIRMATION) as u32; - Self { - confirmation: init_confirmation, - max_confirmation: init_confirmation * 2, - bounce_fee: config_bounce_fee(&taler_config.bounce_fee(), currency), - lifetime: taler_config.wire_lifetime(), - bump_delay: taler_config.bump_delay(), - base_url: taler_config.base_url(), - db_config: taler_config.db_config(), - currency, - btc_config, - } - } -} - -// Load taler config with btc-wire specific config -pub fn load_taler_config(file: Option<&Path>) -> (TalerConfig, PathBuf, CurrencyBtc) { - let config = TalerConfig::load(file); - let path = config.path("CONF_PATH").unwrap_or_else(default_data_dir); - let currency = match config.currency { - Currency::BTC(it) => it, - _ => fail(format!( - "currency {} is not supported by btc-wire", - config.currency.to_str() - )), - }; - (config, path, currency) -} - -// Parse bitcoin amount from config bounce fee -fn config_bounce_fee(bounce_fee: &Option<String>, currency: CurrencyBtc) -> Amount { - let config = bounce_fee.as_deref().unwrap_or(DEFAULT_BOUNCE_FEE); - TalerAmount::from_str(&format!("{}:{}", currency.to_str(), config)) - .map_err(|s| s.to_string()) - .and_then(|a| taler_to_btc(&a, currency)) - .or_fail(|a| { - format!( - "config BOUNCE_FEE={} is not a valid bitcoin amount: {}", - config, a - ) - }) -} - -// Check network match config currency -fn check_network_currency(network: Network, currency: CurrencyBtc) { - let expected = match network { - Network::Bitcoin => CurrencyBtc::Main, - Network::Testnet => CurrencyBtc::Test, - Network::Regtest => CurrencyBtc::Dev, - _ => unimplemented!(), - }; - if currency != expected { - fail(format_args!( - "config currency is incompatible with node network, CURRENCY = {} expected {}", - currency.to_str(), - expected.to_str() - )) - } -} - -pub fn guess_network(address: &Address) -> Network { - let addr = address.as_unchecked(); - for network in [ - Network::Bitcoin, - Network::Regtest, - Network::Signet, - Network::Regtest, - ] { - if addr.is_valid_for_network(network) { - return network; - } - } - unreachable!() -} diff --git a/btc-wire/src/loops.rs b/btc-wire/src/loops.rs @@ -1,38 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use btc_wire::rpc; -use common::postgres; - -use crate::fail_point::Injected; - -pub mod analysis; -pub mod watcher; -pub mod worker; - -#[derive(Debug, thiserror::Error)] -pub enum LoopError { - #[error(transparent)] - Rpc(#[from] rpc::Error), - #[error("DB {0}")] - DB(#[from] postgres::Error), - #[error("Another btc-wire process is running concurrently")] - Concurrency, - #[error(transparent)] - Injected(#[from] Injected), -} - -pub type LoopResult<T> = Result<T, LoopError>; diff --git a/btc-wire/src/loops/analysis.rs b/btc-wire/src/loops/analysis.rs @@ -1,43 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -use btc_wire::rpc::{ChainTipsStatus, Rpc}; -use common::log::log::warn; - -use super::LoopResult; - -/// Analyse blockchain behavior and return the new confirmation delay -pub fn analysis(rpc: &mut Rpc, current: u32, max: u32) -> LoopResult<u32> { - // Get biggest known valid fork - let fork = rpc - .get_chain_tips()? - .into_iter() - .filter_map(|t| (t.status == ChainTipsStatus::ValidFork).then_some(t.length)) - .max() - .unwrap_or(0) as u32; - // If new fork is bigger than what current confirmation delay protect against - if fork >= current { - // Limit confirmation growth - let new_conf = fork.saturating_add(1).min(max); - warn!( - "analysis: found dangerous fork of {} blocks, adapt confirmation to {} blocks capped at {}, you should update taler.conf", - fork, new_conf, max - ); - return Ok(new_conf); - } - - // TODO smarter analysis: suspicious transaction value, limit wire bitcoin throughput - Ok(current) -} diff --git a/btc-wire/src/loops/watcher.rs b/btc-wire/src/loops/watcher.rs @@ -1,36 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -use btc_wire::rpc::AutoRpcCommon; -use common::{log::log::error, reconnect::AutoReconnectDb}; -use std::time::Duration; - -use super::LoopResult; - -/// Wait for new block and notify arrival with postgreSQL notifications -pub fn watcher(mut rpc: AutoRpcCommon, mut db: AutoReconnectDb) { - loop { - let rpc = rpc.client(); - let db = db.client(); - let result: LoopResult<()> = (|| loop { - db.execute("NOTIFY new_block", &[])?; - rpc.wait_for_new_block()?; - })(); - if let Err(e) = result { - error!("watcher: {}", e); - std::thread::sleep(Duration::from_secs(5)); - } - } -} diff --git a/btc-wire/src/loops/worker.rs b/btc-wire/src/loops/worker.rs @@ -1,625 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -use std::{collections::HashMap, fmt::Write, time::SystemTime}; - -use bitcoin::{Amount as BtcAmount, BlockHash, Txid, hashes::Hash}; -use btc_wire::{ - GetOpReturnErr, GetSegwitErr, - rpc::{self, AutoRpcWallet, Category, ErrorCode, Rpc, Transaction}, - rpc_utils::sender_address, - taler_utils::btc_to_taler, -}; -use common::{ - log::{ - OrFail, - log::{error, info, warn}, - }, - metadata::OutMetadata, - postgres, - reconnect::AutoReconnectDb, - sql::{sql_base_32, sql_url}, - status::{BounceStatus, DebitStatus}, - taler_common::{api_common::ShortHashCode, types::timestamp::Timestamp}, -}; -use postgres::{Client, fallible_iterator::FallibleIterator}; - -use crate::{ - WireState, - fail_point::fail_point, - sql::{sql_addr, sql_btc_amount, sql_txid}, -}; - -use super::{LoopError, LoopResult, analysis::analysis}; - -/// Synchronize local db with blockchain and perform transactions -pub fn worker(mut rpc: AutoRpcWallet, mut db: AutoReconnectDb, mut state: WireState) { - let mut lifetime = state.lifetime; - let mut status = true; - let mut skip_notification = false; - - loop { - // Check lifetime - if let Some(nb) = lifetime.as_mut() { - if *nb == 0 { - info!("Reach end of lifetime"); - return; - } else { - *nb -= 1; - } - } - - // Connect - let rpc = rpc.client(); - let db = db.client(); - - let result: LoopResult<()> = (|| { - // Listen to all channels - db.batch_execute("LISTEN new_block; LISTEN new_tx")?; - // Wait for the next notification - { - let mut ntf = db.notifications(); - if !skip_notification && ntf.is_empty() { - // Block until next notification - ntf.blocking_iter().next()?; - } - // Conflate all notifications - let mut iter = ntf.iter(); - while iter.next()?.is_some() {} - } - - // It is not possible to atomically update the blockchain and the database. - // When we failed to sync the database and the blockchain state we rely on - // sync_chain to recover the lost updates. - // When this function is running concurrently, it not possible to known another - // execution has failed, and this can lead to a transaction being sent multiple time. - // To ensure only a single version of this function is running at a given time we rely - // on postgres advisory lock - - // Take the lock - let row = db.query_one("SELECT pg_try_advisory_lock(42)", &[])?; - let locked: bool = row.get(0); - if !locked { - return Err(LoopError::Concurrency); - } - - // Perform analysis - state.confirmation = analysis(rpc, state.confirmation, state.max_confirmation)?; - - // Sync chain - if let Some(stuck) = sync_chain(rpc, db, &state, &mut status)? { - // As we are now in sync with the blockchain if a transaction has Requested status it have not been sent - - // Send requested debits - while debit(db, rpc, &state)? {} - - // Bump stuck transactions - for id in stuck { - let bump = rpc.bump_fee(&id)?; - fail_point("(injected) fail bump", 0.3)?; - let row = db.query_one( - "UPDATE tx_out SET txid=$1 WHERE txid=$2 RETURNING wtid", - &[ - &bump.txid.as_byte_array().as_slice(), - &id.as_byte_array().as_slice(), - ], - )?; - let wtid: ShortHashCode = sql_base_32(&row, 0); - info!(">> (bump) {wtid} replace {id} with {}", bump.txid); - } - - // Send requested bounce - while bounce(db, rpc, &state.bounce_fee)? {} - } - - Ok(()) - })(); - if let Err(e) = result { - error!("worker: {e}"); - // When we catch an error, we sometimes want to retry immediately (eg. reconnect to RPC or DB). - // Bitcoin error codes are generic. We need to match the msg to get precise ones. Some errors - // can resolve themselves when a new block is mined (new fees, new transactions). Our simple - // approach is to wait for the next loop when an RPC error is caught to prevent endless logged errors. - skip_notification = !matches!( - e, - LoopError::Rpc(rpc::Error::RPC { .. } | rpc::Error::Bitcoin(_)) - | LoopError::Concurrency - | LoopError::Injected(_) - ); - } else { - skip_notification = false; - } - } -} - -/// Retrieve last stored hash -fn last_hash(db: &mut Client) -> Result<BlockHash, postgres::Error> { - let row = db.query_one("SELECT value FROM state WHERE name='last_hash'", &[])?; - Ok(BlockHash::from_slice(row.get(0)).unwrap()) -} - -/// Parse new transactions, return stuck transactions if the database is up to date with the latest mined block -fn sync_chain( - rpc: &mut Rpc, - db: &mut Client, - state: &WireState, - status: &mut bool, -) -> LoopResult<Option<Vec<Txid>>> { - // Get stored last_hash - let last_hash = last_hash(db)?; - // Get the current confirmation delay - let conf_delay = state.confirmation; - - // Get a set of transactions ids to parse - let (txs, removed, lastblock): ( - HashMap<Txid, (Category, i32)>, - HashMap<Txid, (Category, i32)>, - BlockHash, - ) = { - // Get all transactions made since this block - let list = rpc.list_since_block(Some(&last_hash), conf_delay)?; - // Only keep ids and category - let txs = list - .transactions - .into_iter() - .map(|tx| (tx.txid, (tx.category, tx.confirmations))) - .collect(); - let removed = list - .removed - .into_iter() - .map(|tx| (tx.txid, (tx.category, tx.confirmations))) - .collect(); - (txs, removed, list.lastblock) - }; - - // Check if a confirmed incoming transaction have been removed by a blockchain reorganization - let new_status = sync_chain_removed(&txs, &removed, rpc, db, conf_delay as i32)?; - - // Sync status with database - if *status != new_status { - let mut tx = db.transaction()?; - tx.execute( - "UPDATE state SET value=$1 WHERE name='status'", - &[&[new_status as u8].as_slice()], - )?; - tx.execute("NOTIFY status", &[])?; - tx.commit()?; - *status = new_status; - if new_status { - info!("Recovered lost transactions"); - } - } - if !new_status { - return Ok(None); - } - - let mut stuck = vec![]; - - for (id, (category, confirmations)) in txs { - match category { - Category::Send => { - if sync_chain_outgoing(&id, confirmations, rpc, db, state)? { - stuck.push(id); - } - } - Category::Receive if confirmations >= conf_delay as i32 => { - sync_chain_incoming_confirmed(&id, rpc, db, state)? - } - _ => { - // Ignore coinbase and unconfirmed send transactions - } - } - } - - // Move last_hash forward - db.execute( - "UPDATE state SET value=$1 WHERE name='last_hash' AND value=$2", - &[ - &lastblock.as_byte_array().as_slice(), - &last_hash.as_byte_array().as_slice(), - ], - )?; - - Ok(Some(stuck)) -} - -/// Sync database with removed transactions, return false if bitcoin backing is compromised -fn sync_chain_removed( - txs: &HashMap<Txid, (Category, i32)>, - removed: &HashMap<Txid, (Category, i32)>, - rpc: &mut Rpc, - db: &mut Client, - min_confirmations: i32, -) -> LoopResult<bool> { - // A removed incoming transaction is a correctness issues in only two cases: - // - it is a confirmed credit registered in the database - // - it is an invalid transactions already bounced - // Those two cases can compromise bitcoin backing - // Removed outgoing transactions will be retried automatically by the node - - let mut blocking_debit = Vec::new(); - let mut blocking_bounce = Vec::new(); - - // Only keep incoming transaction that are not reconfirmed - // TODO study risk of accepting only mined transactions for faster recovery - for (id, _) in removed.iter().filter(|(id, (cat, _))| { - *cat == Category::Receive - && txs - .get(*id) - .map(|(_, confirmations)| *confirmations < min_confirmations) - .unwrap_or(true) - }) { - match rpc.get_tx_segwit_key(id) { - Ok((full, key)) => { - // Credits are only problematic if not reconfirmed and stored in the database - if db - .query_opt( - "SELECT 1 FROM tx_in WHERE reserve_pub=$1", - &[&key.as_slice()], - )? - .is_some() - { - let debit_addr = sender_address(rpc, &full)?; - blocking_debit.push((key, id, debit_addr)); - } - } - Err(err) => match err { - GetSegwitErr::Decode(_) => { - // Invalid tx are only problematic if already bounced - if let Some(row) = db.query_opt( - "SELECT txid FROM bounce WHERE bounced=$1 AND txid IS NOT NULL", - &[&id.as_byte_array().as_slice()], - )? { - blocking_bounce.push((sql_txid(&row, 0), id)); - } else { - // Remove transaction from bounce table - db.execute( - "DELETE FROM bounce WHERE bounced=$1", - &[&id.as_byte_array().as_slice()], - )?; - } - } - GetSegwitErr::RPC(it) => return Err(it.into()), - }, - } - } - - if !blocking_bounce.is_empty() || !blocking_debit.is_empty() { - let mut buf = "The following transaction have been removed from the blockchain, bitcoin backing is compromised until the transaction reappear:".to_string(); - for (key, id, addr) in blocking_debit { - write!(&mut buf, "\n\tcredit {key} in {id} from {addr}",).unwrap(); - } - for (id, bounced) in blocking_bounce { - write!(&mut buf, "\n\tbounced {id} in {bounced}").unwrap(); - } - error!("{}", buf); - Ok(false) - } else { - Ok(true) - } -} - -/// Sync database with an incoming confirmed transaction -fn sync_chain_incoming_confirmed( - id: &Txid, - rpc: &mut Rpc, - db: &mut Client, - state: &WireState, -) -> Result<(), LoopError> { - match rpc.get_tx_segwit_key(id) { - Ok((full, reserve_pub)) => { - // Store transactions in database - let debit_addr = sender_address(rpc, &full)?; - let credit_addr = full.details[0].address.clone().unwrap().assume_checked(); - let amount = btc_to_taler(&full.amount, state.currency); - let nb = db.execute("INSERT INTO tx_in (received, amount, reserve_pub, debit_acc, credit_acc) VALUES ($1, ($2, $3)::taler_amount, $4, $5, $6) ON CONFLICT (reserve_pub) DO NOTHING ", &[ - &((full.time * 1000000) as i64), &(amount.val as i64), &(amount.frac as i32), &reserve_pub.as_slice(), &debit_addr.to_string(), &credit_addr.to_string() - ])?; - if nb > 0 { - info!("<< {amount} {reserve_pub} in {id} from {debit_addr}"); - } - } - Err(err) => match err { - GetSegwitErr::Decode(_) => { - // If encoding is wrong request a bounce - db.execute( - "INSERT INTO bounce (created, bounced) VALUES ($1, $2) ON CONFLICT (bounced) DO NOTHING", - &[&Timestamp::now().as_sql_micros(), &id.as_byte_array().as_slice()], - )?; - } - GetSegwitErr::RPC(e) => return Err(e.into()), - }, - } - Ok(()) -} - -/// Sync database with a debit transaction, return true if stuck -fn sync_chain_debit( - id: &Txid, - full: &Transaction, - wtid: &ShortHashCode, - rpc: &mut Rpc, - db: &mut Client, - confirmations: i32, - state: &WireState, -) -> LoopResult<bool> { - let credit_addr = full.details[0].address.clone().unwrap().assume_checked(); - let amount = btc_to_taler(&full.amount, state.currency); - - if confirmations < 0 { - if full.replaced_by_txid.is_none() { - // Handle conflicting tx - let nb_row = db.execute( - "UPDATE tx_out SET status=$1, txid=NULL where txid=$2", - &[ - &(DebitStatus::Requested as i16), - &id.as_byte_array().as_slice(), - ], - )?; - if nb_row > 0 { - warn!(">> (conflict) {wtid} in {id} to {credit_addr}"); - } - } - } else { - // Get previous out tx - let row = db.query_opt( - "SELECT id,status,txid FROM tx_out WHERE wtid=$1 FOR UPDATE", - &[&wtid.as_slice()], - )?; - if let Some(row) = row { - // If already in database, sync status - let row_id: i64 = row.get(0); - let status: i16 = row.get(1); - match DebitStatus::try_from(status as u8).unwrap() { - DebitStatus::Requested => { - let nb_row = db.execute( - "UPDATE tx_out SET status=$1, txid=$2 WHERE id=$3 AND status=$4", - &[ - &(DebitStatus::Sent as i16), - &id.as_byte_array().as_slice(), - &row_id, - &status, - ], - )?; - if nb_row > 0 { - warn!(">> (recovered) {amount} {wtid} in {id} to {credit_addr}"); - } - } - DebitStatus::Sent => { - if let Some(txid) = full.replaces_txid { - let stored_id = sql_txid(&row, 2); - if txid == stored_id { - let nb_row = db.execute( - "UPDATE tx_out SET txid=$1 WHERE txid=$2", - &[ - &id.as_byte_array().as_slice(), - &txid.as_byte_array().as_slice(), - ], - )?; - if nb_row > 0 { - info!(">> (recovered) {wtid} replace {txid} with {id}",); - } - } - } - } - } - } else { - // Else add to database - let debit_addr = sender_address(rpc, full)?; - let nb = db.execute( - "INSERT INTO tx_out (created, amount, wtid, debit_acc, credit_acc, exchange_url, status, txid, request_uid) VALUES ($1, ($2, $3)::taler_amount, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (wtid) DO NOTHING", - &[&((full.time*1000000) as i64), &(amount.val as i64), &(amount.frac as i32), &wtid.as_slice(), &debit_addr.to_string(), &credit_addr.to_string(), &state.base_url.as_ref(), &(DebitStatus::Sent as i16), &id.as_byte_array().as_slice(), &None::<&[u8]>], - )?; - if nb > 0 { - warn!(">> (onchain) {amount} {wtid} in {id} to {credit_addr}",); - } - } - - // Check if stuck - if let Some(delay) = state.bump_delay { - if confirmations == 0 && full.replaced_by_txid.is_none() { - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_secs(); - if now - full.time > delay as u64 { - return Ok(true); - } - } - } - } - Ok(false) -} - -/// Sync database with an outgoing bounce transaction -fn sync_chain_bounce( - id: &Txid, - bounced: &Txid, - db: &mut Client, - confirmations: i32, -) -> LoopResult<()> { - if confirmations < 0 { - // Handle conflicting tx - let nb_row = db.execute( - "UPDATE bounce SET status=$1, txid=NULL where txid=$2", - &[ - &(BounceStatus::Requested as i16), - &id.as_byte_array().as_slice(), - ], - )?; - if nb_row > 0 { - warn!("|| (conflict) {bounced} in {id}"); - } - } else { - // Get previous bounce - let row = db.query_opt( - "SELECT id, status FROM bounce WHERE bounced=$1", - &[&bounced.as_byte_array().as_slice()], - )?; - if let Some(row) = row { - // If already in database, sync status - let row_id: i64 = row.get(0); - let status: i16 = row.get(1); - match BounceStatus::try_from(status as u8).unwrap() { - BounceStatus::Requested => { - let nb_row = db.execute( - "UPDATE bounce SET status=$1, txid=$2 WHERE id=$3 AND status=$4", - &[ - &(BounceStatus::Sent as i16), - &id.as_byte_array().as_slice(), - &row_id, - &status, - ], - )?; - if nb_row > 0 { - warn!("|| (recovered) {bounced} in {id}"); - } - } - BounceStatus::Ignored => { - error!("watcher: ignored bounce {bounced} found in chain at {id}") - } - BounceStatus::Sent => { /* Status is correct */ } - } - } else { - // Else add to database - let nb = db.execute( - "INSERT INTO bounce (created, bounced, txid, status) VALUES ($1, $2, $3, $4) ON CONFLICT (txid) DO NOTHING", - &[&Timestamp::now().as_sql_micros(), &bounced.as_byte_array().as_slice(), &id.as_byte_array().as_slice(), &(BounceStatus::Sent as i16)], - )?; - if nb > 0 { - warn!("|| (onchain) {bounced} in {id}"); - } - } - } - - Ok(()) -} - -/// Sync database with an outgoing transaction, return true if stuck -fn sync_chain_outgoing( - id: &Txid, - confirmations: i32, - rpc: &mut Rpc, - db: &mut Client, - state: &WireState, -) -> LoopResult<bool> { - match rpc - .get_tx_op_return(id) - .map(|(full, bytes)| (full, OutMetadata::decode(&bytes))) - { - Ok((full, Ok(info))) => match info { - OutMetadata::Debit { wtid, .. } => { - return sync_chain_debit(id, &full, &wtid, rpc, db, confirmations, state); - } - OutMetadata::Bounce { bounced } => { - sync_chain_bounce(id, &Txid::from_byte_array(bounced), db, confirmations)? - } - }, - Ok((_, Err(e))) => warn!("send: decode-info {id} - {e}"), - Err(e) => match e { - GetOpReturnErr::MissingOpReturn => { /* Ignore */ } - GetOpReturnErr::RPC(e) => return Err(e)?, - }, - } - Ok(false) -} - -/// Send a debit transaction on the blockchain, return false if no more requested transactions are found -fn debit(db: &mut Client, rpc: &mut Rpc, state: &WireState) -> LoopResult<bool> { - // We rely on the advisory lock to ensure we are the only one sending transactions - let row = db.query_opt( - "SELECT id, (amount).val, (amount).frac, wtid, credit_acc, exchange_url FROM tx_out WHERE status=$1 ORDER BY created LIMIT 1", - &[&(DebitStatus::Requested as i16)], - )?; - if let Some(row) = &row { - let id: i64 = row.get(0); - let amount = sql_btc_amount(row, 1, state.currency); - let wtid: ShortHashCode = sql_base_32(row, 3); - let addr = sql_addr(row, 4); - let url = sql_url(row, 5); - let metadata = OutMetadata::Debit { - wtid: wtid.clone(), - url, - }; - - let tx_id = rpc.send( - &addr, - &amount, - Some(&metadata.encode().or_fail(|e| format!("{}", e))), - false, - )?; - fail_point("(injected) fail debit", 0.3)?; - db.execute( - "UPDATE tx_out SET status=$1, txid=$2 WHERE id=$3", - &[ - &(DebitStatus::Sent as i16), - &tx_id.as_byte_array().as_slice(), - &id, - ], - )?; - let amount = btc_to_taler(&amount.to_signed().unwrap(), state.currency); - info!(">> {amount} {wtid} in {tx_id} to {addr}"); - } - Ok(row.is_some()) -} - -/// Bounce a transaction on the blockchain, return false if no more requested transactions are found -fn bounce(db: &mut Client, rpc: &mut Rpc, fee: &BtcAmount) -> LoopResult<bool> { - // We rely on the advisory lock to ensure we are the only one sending transactions - let row = db.query_opt( - "SELECT id, bounced FROM bounce WHERE status=$1 ORDER BY created LIMIT 1", - &[&(BounceStatus::Requested as i16)], - )?; - if let Some(row) = &row { - let id: i64 = row.get(0); - let bounced: Txid = sql_txid(row, 1); - let metadata = OutMetadata::Bounce { - bounced: *bounced.as_byte_array(), - }; - - match rpc.bounce( - &bounced, - fee, - Some(&metadata.encode().or_fail(|e| format!("{}", e))), - ) { - Ok(it) => { - fail_point("(injected) fail bounce", 0.3)?; - db.execute( - "UPDATE bounce SET txid=$1, status=$2 WHERE id=$3", - &[ - &it.as_byte_array().as_slice(), - &(BounceStatus::Sent as i16), - &id, - ], - )?; - info!("|| {bounced} in {it}"); - } - Err(err) => match err { - rpc::Error::RPC { - code: ErrorCode::RpcWalletInsufficientFunds | ErrorCode::RpcWalletError, - msg, - } => { - db.execute( - "UPDATE bounce SET status=$1 WHERE id=$2", - &[&(BounceStatus::Ignored as i16), &id], - )?; - info!("|| (ignore) {bounced} because {msg}"); - } - e => Err(e)?, - }, - } - } - Ok(row.is_some()) -} diff --git a/btc-wire/src/main.rs b/btc-wire/src/main.rs @@ -1,176 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -use bitcoin::{Network, hashes::Hash}; -use btc_wire::{ - WireState, - btc_config::{BitcoinConfig, WIRE_WALLET_NAME}, - load_taler_config, - rpc::{self, ErrorCode, Rpc, auto_rpc_common, auto_rpc_wallet}, -}; -use clap::Parser; -use common::{ - log::{OrFail, log::info}, - named_spawn, password, - postgres::NoTls, - reconnect::auto_reconnect_db, -}; -use loops::LoopResult; -use std::path::PathBuf; - -use crate::loops::{watcher::watcher, worker::worker}; - -mod fail_point; -mod loops; -mod sql; - -/// Taler wire for bitcoincore -#[derive(clap::Parser, Debug)] -struct Args { - /// Override default configuration file path - #[clap(global = true, short, long)] - config: Option<PathBuf>, - #[clap(subcommand)] - init: Option<Init>, -} - -#[derive(clap::Subcommand, Debug)] -enum Init { - /// Initialize database schema and state - Initdb, - /// Generate bitcoin wallet and initialize state - Initwallet, -} - -/// TODO support external signer https://github.com/bitcoin/bitcoin/blob/master/doc/external-signer.md - -fn main() { - common::log::init(); - let args = Args::parse(); - - match args.init { - Some(cmd) => init(args.config, cmd).or_fail(|e| format!("{}", e)), - None => run(args.config), - } -} - -fn init(config: Option<PathBuf>, init: Init) -> LoopResult<()> { - // Parse taler config - let (taler_config, path, currency) = load_taler_config(config.as_deref()); - // Connect to database - let mut db = taler_config.db_config().connect(NoTls)?; - // Parse bitcoin config - let btc_conf = - BitcoinConfig::load(path, currency).or_fail(|e| format!("bitcoin config: {}", e)); - // Connect to bitcoin node - let mut rpc = Rpc::common(&btc_conf).or_fail(|e| format!("rpc connect: {}", e)); - match init { - Init::Initdb => { - let mut tx = db.transaction()?; - // Load schema - tx.batch_execute(include_str!("../../db/btc.sql"))?; - // Init status to true - tx - .execute( - "INSERT INTO state (name, value) VALUES ('status', $1) ON CONFLICT (name) DO NOTHING", - &[&[1u8].as_slice()], - )?; - // Init last_hash if not already set - let genesis_hash = rpc.get_genesis()?; - tx - .execute( - "INSERT INTO state (name, value) VALUES ('last_hash', $1) ON CONFLICT (name) DO NOTHING", - &[&genesis_hash.as_byte_array().as_slice()], - )?; - tx.commit()?; - println!("Database initialised"); - } - Init::Initwallet => { - // Create wallet - let passwd = password(); - let created = match rpc.create_wallet(WIRE_WALLET_NAME, &passwd) { - Err(rpc::Error::RPC { - code: ErrorCode::RpcWalletError, - .. - }) => false, - Err(e) => panic!("{}", e), - Ok(_) => true, - }; - - rpc.load_wallet(WIRE_WALLET_NAME).ok(); - - // Load previous address - // TODO Use address label instead of the database ? - let prev_addr = db.query_opt("SELECT value FROM state WHERE name = 'addr'", &[])?; - let addr = if let Some(row) = prev_addr { - String::from_utf8(row.get(0)).unwrap() - } else { - // Or generate a new one - let new = Rpc::wallet(&btc_conf, WIRE_WALLET_NAME) - .or_fail(|e| format!("rpc connect: {}", e)) - .gen_addr()?; - db.execute( - "INSERT INTO state (name, value) VALUES ('addr', $1)", - &[&new.to_string().as_bytes()], - )?; - new.to_string() - }; - - if created { - println!("Created new wallet"); - } else { - println!("Found already existing wallet") - } - println!( - "You must backup the generated key file and your chosen password, more info there: https://github.com/bitcoin/bitcoin/blob/master/doc/managing-wallets.md#14-backing-up-the-wallet" - ); - println!("Public address is {}", &addr); - println!("Add the following line into taler.conf:"); - println!("[depolymerizer-bitcoin]"); - println!("PAYTO = payto://bitcoin/{}", addr); - } - } - Ok(()) -} - -fn run(config: Option<PathBuf>) { - let state = WireState::load_taler_config(config.as_deref()); - - #[cfg(feature = "fail")] - if state.btc_config.network == Network::Regtest { - common::log::log::warn!("Running with random failures"); - } else { - common::log::log::error!("Running with random failures is unsuitable for production"); - std::process::exit(1); - } - let chain_name = match state.btc_config.network { - Network::Bitcoin => "main", - Network::Testnet => "test", - Network::Signet => "signet", - Network::Regtest => "regtest", - _ => unreachable!(), - }; - info!("Running on {chain_name} chain"); - // TODO Check wire wallet own config PAYTO address - - let rpc_watcher = auto_rpc_common(state.btc_config.clone()); - let rpc_worker = auto_rpc_wallet(state.btc_config.clone(), WIRE_WALLET_NAME); - - let db_watcher = auto_reconnect_db(state.db_config.clone()); - let db_worker = auto_reconnect_db(state.db_config.clone()); - named_spawn("watcher", move || watcher(rpc_watcher, db_watcher)); - worker(rpc_worker, db_worker, state); - info!("btc-wire stopped"); -} diff --git a/btc-wire/src/rpc.rs b/btc-wire/src/rpc.rs @@ -1,628 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -//! This is a very simple RPC client designed only for a specific bitcoind version -//! and to use on an secure localhost connection to a trusted node -//! -//! No http format or body length check as we trust the node output -//! No asynchronous request as bitcoind put requests in a queue and process -//! them synchronously and we do not want to fill this queue -//! -//! We only parse the thing we actually use, this reduce memory usage and -//! make our code more compatible with future deprecation -//! -//! bitcoincore RPC documentation: <https://bitcoincore.org/en/doc/23.0.0/> - -use bitcoin::{Address, Amount, BlockHash, SignedAmount, Txid, address::NetworkUnchecked}; -use common::{log::log::error, password, reconnect::AutoReconnect}; -use data_encoding::BASE64; -use serde_json::{Value, json}; -use std::{ - fmt::Debug, - io::{self, BufRead, BufReader, Write}, - net::TcpStream, - time::{Duration, Instant}, -}; - -use crate::btc_config::{BitcoinConfig, BtcAuth}; - -pub type AutoRpcWallet = AutoReconnect<(BitcoinConfig, &'static str), Rpc>; - -/// Create a reconnecting rpc connection with an unlocked wallet -pub fn auto_rpc_wallet(config: BitcoinConfig, wallet: &'static str) -> AutoRpcWallet { - AutoReconnect::new( - (config, wallet), - |(config, wallet)| { - let mut rpc = Rpc::wallet(config, wallet) - .map_err(|err| error!("connect RPC: {}", err)) - .ok()?; - rpc.load_wallet(wallet).ok(); - rpc.unlock_wallet(&password()) - .map_err(|err| error!("connect RPC: {}", err)) - .ok()?; - Some(rpc) - }, - |client| client.get_chain_tips().is_err(), - ) -} - -pub type AutoRpcCommon = AutoReconnect<BitcoinConfig, Rpc>; - -/// Create a reconnecting rpc connection -pub fn auto_rpc_common(config: BitcoinConfig) -> AutoRpcCommon { - AutoReconnect::new( - config, - |config| { - Rpc::common(config) - .map_err(|err| error!("connect RPC: {}", err)) - .ok() - }, - |client| client.get_chain_tips().is_err(), - ) -} - -#[derive(Debug, serde::Serialize)] -struct RpcRequest<'a, T: serde::Serialize> { - method: &'a str, - id: u64, - params: &'a T, -} - -#[derive(Debug, serde::Deserialize)] -#[serde(untagged)] -enum RpcResponse<T> { - RpcResponse { - result: Option<T>, - error: Option<RpcError>, - id: u64, - }, - Error(String), -} - -#[derive(Debug, serde::Deserialize)] -struct RpcError { - code: ErrorCode, - message: String, -} - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("IO: {0:?}")] - Transport(#[from] std::io::Error), - #[error("RPC: {code:?} - {msg}")] - RPC { code: ErrorCode, msg: String }, - #[error("BTC: {0}")] - Bitcoin(String), - #[error("JSON: {0}")] - Json(#[from] serde_json::Error), - #[error("Null rpc, no result or error")] - Null, -} - -pub type Result<T> = std::result::Result<T, Error>; - -const EMPTY: [(); 0] = []; - -fn expect_null(result: Result<()>) -> Result<()> { - match result { - Err(Error::Null) => Ok(()), - i => i, - } -} - -/// Bitcoin RPC connection -pub struct Rpc { - last_call: Instant, - path: String, - id: u64, - cookie: String, - conn: BufReader<TcpStream>, - buf: Vec<u8>, -} - -impl Rpc { - /// Start a RPC connection - pub fn common(config: &BitcoinConfig) -> io::Result<Self> { - Self::new(config, None) - } - - /// Start a wallet RPC connection - pub fn wallet(config: &BitcoinConfig, wallet: &str) -> io::Result<Self> { - Self::new(config, Some(wallet)) - } - - fn new(config: &BitcoinConfig, wallet: Option<&str>) -> io::Result<Self> { - let path = if let Some(wallet) = wallet { - format!("/wallet/{}", wallet) - } else { - String::from("/") - }; - let token = match &config.auth { - BtcAuth::Cookie(path) => std::fs::read(path)?, - BtcAuth::Auth(s) => s.as_bytes().to_vec(), - }; - // Open connection - let sock = TcpStream::connect_timeout(&config.addr, Duration::from_secs(5))?; - let conn = BufReader::new(sock); - - Ok(Self { - last_call: Instant::now(), - path, - id: 0, - cookie: format!("Basic {}", BASE64.encode(&token)), - conn, - buf: Vec::new(), - }) - } - - fn call<T>(&mut self, method: &str, params: &impl serde::Serialize) -> Result<T> - where - T: serde::de::DeserializeOwned + Debug, - { - let request = RpcRequest { - method, - id: self.id, - params, - }; - - // Serialize the body first so we can set the Content-Length header. - let body = serde_json::to_vec(&request)?; - let buf = &mut self.buf; - buf.clear(); - // Write HTTP request - { - let sock = self.conn.get_mut(); - // Send HTTP request - writeln!(buf, "POST {} HTTP/1.1\r", self.path)?; - // Write headers - writeln!(buf, "Accept: application/json-rpc\r")?; - writeln!(buf, "Authorization: {}\r", self.cookie)?; - writeln!(buf, "Content-Type: application/json-rpc\r")?; - writeln!(buf, "Content-Length: {}\r", body.len())?; - // Write separator - writeln!(buf, "\r")?; - sock.write_all(buf)?; - buf.clear(); - // Write body - sock.write_all(&body)?; - sock.flush()?; - } - // Skip response - let sock = &mut self.conn; - loop { - let amount = sock.read_until(b'\n', buf)?; - let sep = buf[..amount] == [b'\r', b'\n']; - buf.clear(); - if sep { - break; - } - self.last_call = Instant::now(); - } - // Read body - let amount = sock.read_until(b'\n', buf)?; - let response: RpcResponse<T> = serde_json::from_slice(&buf[..amount])?; - match response { - RpcResponse::RpcResponse { result, error, id } => { - assert_eq!(self.id, id); - self.id += 1; - if let Some(ok) = result { - Ok(ok) - } else { - Err(match error { - Some(err) => Error::RPC { - code: err.code, - msg: err.message, - }, - None => Error::Null, - }) - } - } - RpcResponse::Error(msg) => Err(Error::Bitcoin(msg)), - } - } - - /* ----- Wallet management ----- */ - - /// Create encrypted native bitcoin wallet - pub fn create_wallet(&mut self, name: &str, passwd: &str) -> Result<Wallet> { - self.call("createwallet", &(name, (), (), passwd, (), true)) - } - - /// Load existing wallet - pub fn load_wallet(&mut self, name: &str) -> Result<Wallet> { - self.call("loadwallet", &[name]) - } - - /// Unlock loaded wallet - pub fn unlock_wallet(&mut self, passwd: &str) -> Result<()> { - // TODO Capped at 3yrs, is it enough ? - expect_null(self.call("walletpassphrase", &(passwd, 100000000))) - } - - /* ----- Wallet utils ----- */ - - /// Generate a new address fot the current wallet - pub fn gen_addr(&mut self) -> Result<Address> { - Ok(self - .call::<Address<NetworkUnchecked>>("getnewaddress", &EMPTY)? - .assume_checked()) - } - - /// Get current balance amount - pub fn get_balance(&mut self) -> Result<Amount> { - let btc: f64 = self.call("getbalance", &EMPTY)?; - Ok(Amount::from_btc(btc).unwrap()) - } - - /* ----- Mining ----- */ - - /// Mine a certain amount of block to profit a given address - pub fn mine(&mut self, nb: u16, address: &Address) -> Result<Vec<BlockHash>> { - self.call("generatetoaddress", &(nb, address)) - } - - /* ----- Getter ----- */ - - /// Get blockchain info - pub fn get_blockchain_info(&mut self) -> Result<BlockchainInfo> { - self.call("getblockchaininfo", &EMPTY) - } - - /// Get chain tips - pub fn get_chain_tips(&mut self) -> Result<Vec<ChainTips>> { - self.call("getchaintips", &EMPTY) - } - - /// Get wallet transaction info from id - pub fn get_tx(&mut self, id: &Txid) -> Result<Transaction> { - self.call("gettransaction", &(id, (), true)) - } - - /// Get transaction inputs and outputs - pub fn get_input_output(&mut self, id: &Txid) -> Result<InputOutput> { - self.call("getrawtransaction", &(id, true)) - } - - /// Get genesis block hash - pub fn get_genesis(&mut self) -> Result<BlockHash> { - self.call("getblockhash", &[0]) - } - - /* ----- Transactions ----- */ - - /// Send bitcoin transaction - pub fn send( - &mut self, - to: &Address, - amount: &Amount, - data: Option<&[u8]>, - subtract_fee: bool, - ) -> Result<Txid> { - self.send_custom([], [(to, amount)], data, subtract_fee) - .map(|it| it.txid) - } - - /// Send bitcoin transaction with multiple recipients - pub fn send_many<'a>( - &mut self, - to: impl IntoIterator<Item = (&'a Address, &'a Amount)>, - ) -> Result<Txid> { - self.send_custom([], to, None, false).map(|it| it.txid) - } - - fn send_custom<'a>( - &mut self, - from: impl IntoIterator<Item = &'a Txid>, - to: impl IntoIterator<Item = (&'a Address, &'a Amount)>, - data: Option<&[u8]>, - subtract_fee: bool, - ) -> Result<SendResult> { - // We use the experimental 'send' rpc command as it is the only capable to send metadata in a single rpc call - let inputs: Vec<_> = from - .into_iter() - .enumerate() - .map(|(i, id)| json!({"txid": id.to_string(), "vout": i})) - .collect(); - let mut outputs: Vec<Value> = to - .into_iter() - .map(|(addr, amount)| json!({&addr.to_string(): amount.to_btc()})) - .collect(); - let nb_outputs = outputs.len(); - if let Some(data) = data { - assert!(!data.is_empty(), "No medatata"); - assert!(data.len() <= 80, "Max 80 bytes"); - outputs.push(json!({ "data".to_string(): hex::encode(data) })); - } - self.call( - "send", - &( - outputs, - (), - (), - (), - SendOption { - add_inputs: true, - inputs, - subtract_fee_from_outputs: if subtract_fee { - (0..nb_outputs).collect() - } else { - vec![] - }, - replaceable: true, - }, - ), - ) - } - - /// Bump transaction fees of a wallet debit - pub fn bump_fee(&mut self, id: &Txid) -> Result<BumpResult> { - self.call("bumpfee", &[id]) - } - - /// Abandon a pending transaction - pub fn abandon_tx(&mut self, id: &Txid) -> Result<()> { - expect_null(self.call("abandontransaction", &[&id])) - } - - /* ----- Watcher ----- */ - - /// Block until a new block is mined - pub fn wait_for_new_block(&mut self) -> Result<Nothing> { - self.call("waitfornewblock", &[0]) - } - - /// List new and removed transaction since a block - pub fn list_since_block( - &mut self, - hash: Option<&BlockHash>, - confirmation: u32, - ) -> Result<ListSinceBlock> { - self.call("listsinceblock", &(hash, confirmation.max(1), (), true)) - } - - /* ----- Cluster ----- */ - - /// Try a connection to a node once - pub fn add_node(&mut self, addr: &str) -> Result<()> { - expect_null(self.call("addnode", &(addr, "onetry"))) - } - - /// Immediately disconnects from the specified peer node. - pub fn disconnect_node(&mut self, addr: &str) -> Result<()> { - expect_null(self.call("disconnectnode", &(addr, ()))) - } - - /* ----- Control ------ */ - - /// Request a graceful shutdown - pub fn stop(&mut self) -> Result<String> { - self.call("stop", &()) - } -} - -#[derive(Debug, serde::Deserialize)] -pub struct Wallet { - pub name: String, -} - -#[derive(Clone, Debug, serde::Deserialize)] -pub struct BlockchainInfo { - pub blocks: u64, - #[serde(rename = "bestblockhash")] - pub best_block_hash: BlockHash, -} - -#[derive(Debug, serde::Deserialize)] -pub struct BumpResult { - pub txid: Txid, -} - -#[derive(Debug, serde::Serialize)] -pub struct SendOption { - pub add_inputs: bool, - pub inputs: Vec<Value>, - pub subtract_fee_from_outputs: Vec<usize>, - pub replaceable: bool, -} - -#[derive(Debug, serde::Deserialize)] -pub struct SendResult { - pub txid: Txid, -} - -/// Enum to represent the category of a transaction. -#[derive(Copy, PartialEq, Eq, Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Category { - Send, - Receive, - Generate, - Immature, - Orphan, -} - -#[derive(Debug, serde::Deserialize)] -pub struct TransactionDetail { - pub address: Option<Address<NetworkUnchecked>>, - pub category: Category, - #[serde(with = "bitcoin::amount::serde::as_btc")] - pub amount: SignedAmount, - #[serde(default, with = "bitcoin::amount::serde::as_btc::opt")] - pub fee: Option<SignedAmount>, - /// Ony for send transaction - pub abandoned: Option<bool>, -} - -#[derive(Debug, serde::Deserialize)] -pub struct ListTransaction { - pub confirmations: i32, - pub txid: Txid, - pub category: Category, -} - -#[derive(Debug, serde::Deserialize)] -pub struct ListSinceBlock { - pub transactions: Vec<ListTransaction>, - #[serde(default)] - pub removed: Vec<ListTransaction>, - pub lastblock: BlockHash, -} - -#[derive(Debug, serde::Deserialize)] -pub struct VoutScriptPubKey { - pub asm: String, - // nulldata do not have an address - pub address: Option<Address<NetworkUnchecked>>, -} - -#[derive(Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Vout { - #[serde(with = "bitcoin::amount::serde::as_btc")] - pub value: Amount, - pub n: u32, - pub script_pub_key: VoutScriptPubKey, -} - -#[derive(Debug, serde::Deserialize)] -pub struct Vin { - /// Not provided for coinbase txs. - pub txid: Option<Txid>, - /// Not provided for coinbase txs. - pub vout: Option<u32>, -} - -#[derive(Debug, serde::Deserialize)] -pub struct InputOutput { - pub vin: Vec<Vin>, - pub vout: Vec<Vout>, -} - -#[derive(Debug, serde::Deserialize)] -pub struct Transaction { - pub confirmations: i32, - pub time: u64, - #[serde(with = "bitcoin::amount::serde::as_btc")] - pub amount: SignedAmount, - #[serde(default, with = "bitcoin::amount::serde::as_btc::opt")] - pub fee: Option<SignedAmount>, - pub replaces_txid: Option<Txid>, - pub replaced_by_txid: Option<Txid>, - pub details: Vec<TransactionDetail>, - pub decoded: InputOutput, -} - -#[derive(Clone, PartialEq, Eq, serde::Deserialize, Debug)] -pub struct ChainTips { - #[serde(rename = "branchlen")] - pub length: usize, - pub status: ChainTipsStatus, -} - -#[derive(Copy, serde::Deserialize, Clone, PartialEq, Eq, Debug)] -#[serde(rename_all = "lowercase")] -pub enum ChainTipsStatus { - Invalid, - #[serde(rename = "headers-only")] - HeadersOnly, - #[serde(rename = "valid-headers")] - ValidHeaders, - #[serde(rename = "valid-fork")] - ValidFork, - Active, -} - -#[derive(Debug, serde::Deserialize)] -pub struct Nothing {} - -/// Bitcoin RPC error codes <https://github.com/bitcoin/bitcoin/blob/master/src/rpc/protocol.h> -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde_repr::Deserialize_repr)] -#[repr(i32)] -pub enum ErrorCode { - RpcInvalidRequest = -32600, - RpcMethodNotFound = -32601, - RpcInvalidParams = -32602, - RpcInternalError = -32603, - RpcParseError = -32700, - - /// std::exception thrown in command handling - RpcMiscError = -1, - /// Unexpected type was passed as parameter - RpcTypeError = -3, - /// Invalid address or key - RpcInvalidAddressOrKey = -5, - /// Ran out of memory during operation - RpcOutOfMemory = -7, - /// Invalid, missing or duplicate parameter - RpcInvalidParameter = -8, - /// Database error - RpcDatabaseError = -20, - /// Error parsing or validating structure in raw format - RpcDeserializationError = -22, - /// General error during transaction or block submission - RpcVerifyError = -25, - /// Transaction or block was rejected by network rules - RpcVerifyRejected = -26, - /// Transaction already in chain - RpcVerifyAlreadyInChain = -27, - /// Client still warming up - RpcInWarmup = -28, - /// RPC method is deprecated - RpcMethodDeprecated = -32, - /// Bitcoin is not connected - RpcClientNotConnected = -9, - /// Still downloading initial blocks - RpcClientInInitialDownload = -10, - /// Node is already added - RpcClientNodeAlreadyAdded = -23, - /// Node has not been added before - RpcClientNodeNotAdded = -24, - /// Node to disconnect not found in connected nodes - RpcClientNodeNotConnected = -29, - /// Invalid IP/Subnet - RpcClientInvalidIpOrSubnet = -30, - /// No valid connection manager instance found - RpcClientP2pDisabled = -31, - /// Max number of outbound or block-relay connections already open - RpcClientNodeCapacityReached = -34, - /// No mempool instance found - RpcClientMempoolDisabled = -33, - /// Unspecified problem with wallet (key not found etc.) - RpcWalletError = -4, - /// Not enough funds in wallet or account - RpcWalletInsufficientFunds = -6, - /// Invalid label name - RpcWalletInvalidLabelName = -11, - /// Keypool ran out, call keypoolrefill first - RpcWalletKeypoolRanOut = -12, - /// Enter the wallet passphrase with walletpassphrase first - RpcWalletUnlockNeeded = -13, - /// The wallet passphrase entered was incorrect - RpcWalletPassphraseIncorrect = -14, - /// Command given in wrong wallet encryption state (encrypting an encrypted wallet etc.) - RpcWalletWrongEncState = -15, - /// Failed to encrypt the wallet - RpcWalletEncryptionFailed = -16, - /// Wallet is already unlocked - RpcWalletAlreadyUnlocked = -17, - /// Invalid wallet specified - RpcWalletNotFound = -18, - /// No wallet specified (error when there are multiple wallets loaded) - RpcWalletNotSpecified = -19, - /// This same wallet is already loaded - RpcWalletAlreadyLoaded = -35, - /// Server is in safe mode, and command is not allowed in safe mode - RpcForbiddenBySafeMode = -2, -} diff --git a/btc-wire/src/rpc_utils.rs b/btc-wire/src/rpc_utils.rs @@ -1,82 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -use std::{path::PathBuf, str::FromStr}; - -use bitcoin::{Address, Amount, Network}; - -use crate::rpc::{self, Rpc, Transaction}; - -/// Default chain dir <https://github.com/bitcoin/bitcoin/blob/master/doc/files.md#data-directory-location> -pub fn chain_dir(network: Network) -> &'static str { - match network { - Network::Bitcoin => "main", - Network::Testnet => "testnet3", - Network::Regtest => "regtest", - Network::Signet => "signet", - _ => unimplemented!(), - } -} - -/// Default rpc port <https://github.com/bitcoin/bitcoin/blob/master/share/examples/bitcoin.conf> -pub fn rpc_port(network: Network) -> u16 { - match network { - Network::Bitcoin => 8332, - Network::Testnet => 18332, - Network::Regtest => 18443, - Network::Signet => 38333, - _ => unimplemented!(), - } -} - -/// Default bitcoin data_dir <https://github.com/bitcoin/bitcoin/blob/master/doc/bitcoin-conf.md> -pub fn default_data_dir() -> PathBuf { - if cfg!(target_os = "windows") { - PathBuf::from_str(&std::env::var("APPDATA").unwrap()) - .unwrap() - .join("Bitcoin") - } else if cfg!(target_os = "linux") { - PathBuf::from_str(&std::env::var("HOME").unwrap()) - .unwrap() - .join(".bitcoin") - } else if cfg!(target_os = "macos") { - PathBuf::from_str(&std::env::var("HOME").unwrap()) - .unwrap() - .join("Library/Application Support/Bitcoin") - } else { - unimplemented!("Only windows, linux or macos") - } -} - -/// Minimum dust amount to perform a transaction to a segwit address -pub fn segwit_min_amount() -> Amount { - // https://github.com/bitcoin/bitcoin/blob/master/src/policy/policy.cpp - Amount::from_sat(294) -} - -/// Get the first sender address from a raw transaction -pub fn sender_address(rpc: &mut Rpc, full: &Transaction) -> rpc::Result<Address> { - let first = &full.decoded.vin[0]; - let tx = rpc.get_input_output(&first.txid.unwrap())?; - Ok(tx - .vout - .into_iter() - .find(|it| it.n == first.vout.unwrap()) - .unwrap() - .script_pub_key - .address - .unwrap() - .assume_checked()) -} diff --git a/btc-wire/src/sql.rs b/btc-wire/src/sql.rs @@ -1,55 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use std::str::FromStr as _; - -use bitcoin::{Address, Amount as BtcAmount, Txid, hashes::Hash}; -use common::currency::CurrencyBtc; -use common::log::OrFail; -use common::postgres::Row; -use common::sql::sql_amount; - -use btc_wire::taler_utils::taler_to_btc; - -/// Bitcoin amount from sql -pub fn sql_btc_amount(row: &Row, idx: usize, currency: CurrencyBtc) -> BtcAmount { - let amount = sql_amount(row, idx, currency.to_str()); - taler_to_btc(&amount, currency).or_fail(|_| { - format!( - "Database invariant: expected an bitcoin amount got {}", - amount - ) - }) -} - -/// Bitcoin address from sql -pub fn sql_addr(row: &Row, idx: usize) -> Address { - let str = row.get(idx); - Address::from_str(str) - .or_fail(|_| format!("Database invariant: expected an bitcoin address got {str}")) - .assume_checked() -} - -/// Bitcoin transaction id from sql -pub fn sql_txid(row: &Row, idx: usize) -> Txid { - let slice: &[u8] = row.get(idx); - Txid::from_slice(slice).or_fail(|_| { - format!( - "Database invariant: expected a transaction if got an array of {}B", - slice.len() - ) - }) -} diff --git a/btc-wire/src/taler_utils.rs b/btc-wire/src/taler_utils.rs @@ -1,47 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -//! Utils function to convert taler API types to bitcoin API types - -use bitcoin::{Amount as BtcAmount, SignedAmount}; -use common::{ - currency::CurrencyBtc, - taler_common::types::amount::{Amount, FRAC_BASE}, -}; - -/// Transform a btc amount into a taler amount -pub fn btc_to_taler(amount: &SignedAmount, currency: CurrencyBtc) -> Amount { - let unsigned = amount.abs().to_unsigned().unwrap(); - let sat = unsigned.to_sat(); - Amount::new( - currency.to_str(), - sat / 100_000_000, - (sat % 100_000_000) as u32, - ) -} - -/// Transform a taler amount into a btc amount -pub fn taler_to_btc(amount: &Amount, currency: CurrencyBtc) -> Result<BtcAmount, String> { - if amount.currency.as_ref() != currency.to_str() { - return Err(format!( - "expected currency {} got {}", - currency.to_str(), - amount.currency - )); - } - - let sat = amount.val * FRAC_BASE as u64 + amount.frac as u64; - Ok(BtcAmount::from_sat(sat)) -} diff --git a/build-system/configure.py b/build-system/configure.py @@ -0,0 +1,9 @@ +# This configure.py.template file is in the public domain. + +from talerbuildconfig import * + +b = BuildConfig() +b.enable_prefix() +b.enable_configmk() +b.add_tool(PosixTool("find")) +b.run() diff --git a/build-system/taler-build-scripts b/build-system/taler-build-scripts @@ -0,0 +1 @@ +Subproject commit 884e13fe65b584f63d4cf92348fab1136af4bd69 diff --git a/common/Cargo.toml b/common/Cargo.toml @@ -12,22 +12,16 @@ license-file.workspace = true url.workspace = true # Error macros thiserror.workspace = true -# Logging -flexi_logger = { version = "0.30.1", default-features = false } -log = "0.4.20" # Postgres client postgres = "0.19.7" # Secure random rand = { version = "0.9.0" } -# Securely zero memory -zeroize = "1.6.0" # Optimized uri binary format uri-pack = { path = "../uri-pack" } -# Exponential backoff generator -exponential-backoff = "1.2.0" taler-common.workspace = true taler-api.workspace = true sqlx.workspace = true bitcoin.workspace = true ethereum-types.workspace = true hex.workspace = true +tracing.workspace = true +\ No newline at end of file diff --git a/common/src/config.rs b/common/src/config.rs @@ -1,224 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -use sqlx::postgres::PgConnectOptions; -use std::{ - fs::Permissions, - net::SocketAddr, - os::unix::fs::PermissionsExt, - path::{Path, PathBuf}, - process::Command, - str::FromStr, -}; -use taler_api::{Serve, auth::AuthMethod}; -use taler_common::{ - config::{Config, Section, parser::ConfigErr}, - types::payto::PaytoURI, -}; -use url::Url; - -use crate::{ - currency::Currency, - log::{OrFail, fail}, -}; - -// Depolymerizer taler config -pub struct TalerConfig { - cfg: Config, - section_name: &'static str, - pub currency: Currency, -} - -impl TalerConfig { - pub fn load(file: Option<&Path>) -> Self { - // Load config using taler-exchange-config - let mut cmd = Command::new("taler-exchange-config"); - cmd.arg("-d"); - if let Some(path) = file { - cmd.arg("-c"); - cmd.arg(path); - } - let output = cmd - .output() - .or_fail(|e| format!("Failed to execute taler-exchange-config: {}", e)); - if !output.status.success() { - fail(format_args!( - "taler-exchange-config failure:\n{}", - String::from_utf8_lossy(&output.stderr) - )); - } - - // Parse ini config - let conf = Config::from_mem(&String::from_utf8_lossy(&output.stdout)) - .or_fail(|e| format!("config format: {}", e)); - let currency = conf.section("taler").str("currency").require().unwrap(); - let currency = Currency::from_str(&currency) - .or_fail(|_| format!("config CURRENCY={} is an unsupported currency", currency)); - let section_name = match currency { - Currency::BTC(_) => "depolymerizer-bitcoin", - Currency::ETH(_) => "depolymerizer-ethereum", - }; - - Self { - cfg: conf, - section_name, - currency, - } - } - - fn section(&self) -> Section { - self.cfg.section(self.section_name) - } - - fn non_zero_option(&self, name: &str) -> Option<u32> { - self.section() - .number::<u32>(name) - .opt() - .unwrap() - .filter(|it| *it != 0) - } -} - -impl TalerConfig { - /* ----- Common ----- */ - - pub fn db_config(&self) -> postgres::Config { - self.section() - .parse("Postgres", "DB_URL") - .require() - .unwrap() - } - - pub fn base_url(&self) -> Url { - self.cfg - .section("exchange") - .url("BASE_URL") - .require() - .unwrap() - } - - /* ----- Wire Gateway ----- */ - - pub fn payto(&self) -> PaytoURI { - self.section().payto("PAYTO").require().unwrap() - } - - pub fn port(&self) -> u16 { - self.section().number("PORT").default(8080).unwrap() - } - - pub fn unix_path(&self) -> Option<PathBuf> { - self.section() - .path("UNIXPATH") - .opt() - .unwrap() - .map(|e| e.into()) - } - - pub fn http_lifetime(&self) -> Option<u32> { - self.non_zero_option("HTTP_LIFETIME") - } - - pub fn auth_method(&self) -> AuthMethod { - let sect = self.section(); - let kind = sect - .value("auth method", "AUTH_METHOD", |s| match s { - "none" | "basic" => Ok(s), - unknown => Err(format!( - "unknown config auth method AUTH_METHOD={unknown} expected 'none' or 'basic'" - )), - }) - .require() - .unwrap(); - match kind { - "none" => AuthMethod::None, - "basic" => AuthMethod::Basic(sect.str("AUTH_TOKEN").require().unwrap()), - _ => unreachable!(), - } - } - - /* ----- Wire Common ----- */ - - pub fn confirmation(&self) -> Option<u16> { - self.section().number("CONFIRMATION").opt().unwrap() - } - - pub fn bounce_fee(&self) -> Option<String> { - self.section().str("BOUNCE_FEE").opt().unwrap() - } - - pub fn wire_lifetime(&self) -> Option<u32> { - self.non_zero_option("WIRE_LIFETIME") - } - - pub fn bump_delay(&self) -> Option<u32> { - self.non_zero_option("BUMP_DELAY") - } - - /* ----- Custom ----- */ - - pub fn path(&self, name: &str) -> Option<PathBuf> { - self.section().path(name).opt().unwrap().map(|e| e.into()) - } -} - -pub struct WireGatewayCfg { - pub auth: AuthMethod, - pub http_lifetime: Option<u32>, - pub db: PgConnectOptions, - pub payto: PaytoURI, - pub currency: Currency, - pub serve: Serve, -} - -impl WireGatewayCfg { - pub fn parse(path: Option<&Path>) -> Result<Self, ConfigErr> { - let tmp = TalerConfig::load(path); - let sect = tmp.section(); - let auth = { - let kind = sect - .value("auth method", "AUTH_METHOD", |s| match s { - "none" | "basic" => Ok(s), - unknown => Err(format!( - "unknown config auth method AUTH_METHOD={unknown} expected 'none' or 'basic'" - )), - }) - .require()?; - match kind { - "none" => AuthMethod::None, - "basic" => AuthMethod::Basic(sect.str("AUTH_TOKEN").require()?), - _ => unreachable!(), - } - }; - let serve = if let Some(path) = sect.path("UNIXPATH").opt()? { - Serve::Unix { - path, - permission: Permissions::from_mode(0o660), - } - } else { - let port = sect.number("PORT").default(8080)?; - Serve::Tcp(SocketAddr::from(([0, 0, 0, 0], port))) - }; - - Ok(Self { - auth, - http_lifetime: tmp.non_zero_option("HTTP_LIFETIME"), - db: sect.postgres("DB_URL").require()?, - payto: sect.payto("PAYTO").require()?, - currency: tmp.currency, - serve, - }) - } -} diff --git a/common/src/currency.rs b/common/src/currency.rs @@ -1,89 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use std::str::FromStr; - -pub const MAIN_BTC: &str = "BITCOINBTC"; -pub const REGTEST_BTC: &str = "TESTBTC"; -pub const TESTNET_BTC: &str = "DEVBTC"; -pub const MAIN_ETH: &str = "ETHEREUMETH"; -pub const GOERLI_ETH: &str = "GOERLIETH"; -pub const DEV_ETH: &str = "DEVETH"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Currency { - ETH(CurrencyEth), - BTC(CurrencyBtc), -} - -impl Currency { - pub const fn to_str(&self) -> &'static str { - match self { - Currency::BTC(btc) => btc.to_str(), - Currency::ETH(eth) => eth.to_str(), - } - } -} - -impl FromStr for Currency { - type Err = (); - - fn from_str(s: &str) -> Result<Self, Self::Err> { - Ok(match s { - MAIN_BTC => Currency::BTC(CurrencyBtc::Main), - REGTEST_BTC => Currency::BTC(CurrencyBtc::Test), - TESTNET_BTC => Currency::BTC(CurrencyBtc::Dev), - MAIN_ETH => Currency::ETH(CurrencyEth::Main), - GOERLI_ETH => Currency::ETH(CurrencyEth::Goerli), - DEV_ETH => Currency::ETH(CurrencyEth::Dev), - _ => return Err(()), - }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CurrencyBtc { - Main, - Test, - Dev, -} - -impl CurrencyBtc { - pub const fn to_str(&self) -> &'static str { - match self { - CurrencyBtc::Main => MAIN_BTC, - CurrencyBtc::Test => REGTEST_BTC, - CurrencyBtc::Dev => TESTNET_BTC, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CurrencyEth { - Main, - Dev, - Goerli, -} - -impl CurrencyEth { - pub const fn to_str(&self) -> &'static str { - match self { - CurrencyEth::Main => MAIN_ETH, - CurrencyEth::Goerli => GOERLI_ETH, - CurrencyEth::Dev => DEV_ETH, - } - } -} diff --git a/common/src/lib.rs b/common/src/lib.rs @@ -13,22 +13,17 @@ You should have received a copy of the GNU Affero General Public License along with TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use std::{process::exit, thread::JoinHandle}; +use std::thread::JoinHandle; -use ::log::error; use rand::{RngCore, rngs::ThreadRng}; -use zeroize::Zeroizing; pub use postgres; pub use rand; pub use taler_common; pub use url; -pub mod config; -pub mod currency; pub mod log; pub mod metadata; -pub mod payto; pub mod reconnect; pub mod sql; pub mod status; @@ -52,12 +47,3 @@ where .spawn(f) .unwrap() } - -/// Read password from env -pub fn password() -> Zeroizing<String> { - let passwd = std::env::var("PASSWORD").unwrap_or_else(|_| { - error!("Missing env var PASSWORD"); - exit(1); - }); - Zeroizing::new(passwd) -} diff --git a/common/src/log.rs b/common/src/log.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2022 Taler Systems SA + Copyright (C) 2022-2025 Taler Systems SA TALER is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software @@ -13,32 +13,9 @@ You should have received a copy of the GNU Affero General Public License along with TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use flexi_logger::{DeferredNow, LogSpecification, Record}; -pub use log; -use log::error; use std::{fmt::Display, process::exit}; -fn custom_format( - w: &mut dyn std::io::Write, - now: &mut DeferredNow, - record: &Record, -) -> Result<(), std::io::Error> { - write!( - w, - "{} {} {}", - now.format("%+"), - record.level(), - &record.args() - ) -} - -pub fn init() { - flexi_logger::Logger::with(LogSpecification::info()) - .log_to_stderr() - .format(custom_format) - .start() - .unwrap(); -} +use tracing::error; pub trait OrFail<T, E> { fn or_fail<F: FnOnce(E) -> String>(self, lambda: F) -> T; @@ -58,6 +35,6 @@ impl<T> OrFail<T, ()> for Option<T> { /// Log error message then exit pub fn fail(msg: impl Display) -> ! { - error!("{}", msg); + error!("{msg}"); exit(1); } diff --git a/common/src/payto.rs b/common/src/payto.rs @@ -1,92 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use std::str::FromStr; - -use taler_common::types::payto::{PaytoErr, PaytoImpl, PaytoURI}; - -const BITCOIN: &str = "bitcoin"; -const ETHEREUM: &str = "ethereum"; - -pub struct BtcAccount(pub bitcoin::Address); - -#[derive(Debug, thiserror::Error)] -pub enum BtcErr { - #[error("missing bitcoin address in path")] - MissingAddr, - #[error(transparent)] - Addr(#[from] bitcoin::address::ParseError), -} - -impl PaytoImpl for BtcAccount { - fn as_payto(&self) -> PaytoURI { - PaytoURI::from_parts(BITCOIN, format_args!("/{}", self.0)) - } - - fn parse(uri: &PaytoURI) -> Result<Self, PaytoErr> { - let url = uri.as_ref(); - if url.domain() != Some(BITCOIN) { - return Err(PaytoErr::UnsupportedKind( - BITCOIN, - url.domain().unwrap_or_default().to_owned(), - )); - } - let Some(mut segments) = url.path_segments() else { - return Err(PaytoErr::custom(BtcErr::MissingAddr)); - }; - let Some(addr) = segments.next() else { - return Err(PaytoErr::custom(BtcErr::MissingAddr)); - }; - let addr = - bitcoin::Address::from_str(addr).map_err(|e| PaytoErr::custom(BtcErr::Addr(e)))?; - Ok(Self(addr.assume_checked())) - } -} - -pub struct EthAccount(pub ethereum_types::Address); - -#[derive(Debug, thiserror::Error)] -pub enum EthErr { - #[error("missing ethereum address in path")] - MissingAddr, - #[error("malformed ethereum address")] - Addr, -} - -impl PaytoImpl for EthAccount { - fn as_payto(&self) -> PaytoURI { - PaytoURI::from_parts(ETHEREUM, format_args!("/{}", hex::encode(self.0))) - } - - fn parse(uri: &PaytoURI) -> Result<Self, PaytoErr> { - let url = uri.as_ref(); - if url.domain() != Some(ETHEREUM) { - return Err(PaytoErr::UnsupportedKind( - BITCOIN, - url.domain().unwrap_or_default().to_owned(), - )); - } - let Some(mut segments) = url.path_segments() else { - return Err(PaytoErr::custom(EthErr::MissingAddr)); - }; - let Some(addr) = segments.next() else { - return Err(PaytoErr::custom(EthErr::MissingAddr)); - }; - let addr = - ethereum_types::Address::from_str(addr).map_err(|_| PaytoErr::custom(EthErr::Addr))?; - Ok(Self(addr)) - } -} diff --git a/common/src/reconnect.rs b/common/src/reconnect.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2022 Taler Systems SA + Copyright (C) 2022-2025 Taler Systems SA TALER is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software @@ -13,65 +13,20 @@ You should have received a copy of the GNU Affero General Public License along with TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use std::time::Duration; +use postgres::NoTls; +use taler_common::ExpoBackoffDecorr; -use exponential_backoff::Backoff; -use log::error; -use postgres::{Client, NoTls}; - -const MIN_RECONNECT_DELAY: Duration = Duration::from_millis(300); -const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(10); -const VALID_DELAY: Duration = Duration::from_secs(3); - -pub struct AutoReconnect<S, C> { - config: S, - client: C, - connect: fn(&S) -> Option<C>, - check: fn(&mut C) -> bool, +pub fn client_jitter() -> ExpoBackoffDecorr { + ExpoBackoffDecorr::new(200, 15 * 1000, 2.0) } -impl<S, C> AutoReconnect<S, C> { - pub fn new(config: S, connect: fn(&S) -> Option<C>, check: fn(&mut C) -> bool) -> Self { - Self { - client: Self::connect(&config, connect), - connect, - check, - config, - } - } - - /// Create a new client, loop on error - fn connect(config: &S, connect: fn(&S) -> Option<C>) -> C { - let backoff = Backoff::new(8, MIN_RECONNECT_DELAY, MAX_RECONNECT_DELAY); - let mut iter = backoff.iter(); - loop { - match connect(config) { - Some(new) => return new, - None => std::thread::sleep(iter.next().unwrap_or(MAX_RECONNECT_DELAY)), - } - } - } - - /// Get a mutable connection, block until a connection can be established - pub fn client(&mut self) -> &mut C { - if (self.check)(&mut self.client) { - self.client = Self::connect(&self.config, self.connect); - } - &mut self.client - } -} - -pub type AutoReconnectDb = AutoReconnect<postgres::Config, Client>; - -pub fn auto_reconnect_db(config: postgres::Config) -> AutoReconnectDb { - AutoReconnect::new( - config, - |config| { - config - .connect(NoTls) - .map_err(|err| error!("connect DB: {}", err)) - .ok() - }, - |client| client.is_valid(VALID_DELAY).is_err(), - ) +pub fn connect_db( + cfg: &postgres::Config, + schema: &str, +) -> Result<postgres::Client, postgres::Error> { + let mut client = cfg.connect(NoTls)?; + client.batch_execute(&format!( + "SET search_path TO {schema};SET default_transaction_isolation = 'serializable';" + ))?; + Ok(client) } diff --git a/common/src/sql.rs b/common/src/sql.rs @@ -19,7 +19,11 @@ use std::str::FromStr; use postgres::Row; use taler_common::{ api_common::SafeU64, - types::{amount::Amount, base32::Base32, payto::PaytoURI}, + types::{ + amount::{Amount, Currency}, + base32::Base32, + payto::PaytoURI, + }, }; use url::Url; @@ -28,17 +32,18 @@ use crate::log::OrFail; /// URL from sql pub fn sql_url(row: &Row, idx: usize) -> Url { let str: &str = row.get(idx); - Url::from_str(str).or_fail(|_| format!("Database invariant: expected an url got {}", str)) + Url::from_str(str).or_fail(|_| format!("Database invariant: expected an url got {str}")) } /// Payto from sql pub fn sql_payto(row: &Row, idx: usize) -> PaytoURI { let str: &str = row.get(idx); - PaytoURI::from_str(str).or_fail(|_| format!("Database invariant: expected a payto got {}", str)) + PaytoURI::from_str(str).or_fail(|_| format!("Database invariant: expected a payto got {str}")) } /// Ethereum amount from sql -pub fn sql_amount(row: &Row, idx: usize, currency: &str) -> Amount { +pub fn sql_amount(row: &Row, idx: usize, currency: &Currency) -> Amount { + // TODO use decimal instead let val: i64 = row.get(idx); let frac: i32 = row.get(idx + 1); Amount::new(currency, val as u64, frac as u32) @@ -49,8 +54,7 @@ pub fn sql_array<const N: usize>(row: &Row, idx: usize) -> [u8; N] { let slice: &[u8] = row.get(idx); slice.try_into().or_fail(|_| { format!( - "Database invariant: expected an byte array of {}B for {}B", - N, + "Database invariant: expected an byte array of {N}B for {}B", slice.len() ) }) @@ -61,8 +65,7 @@ pub fn sql_base_32<const N: usize>(row: &Row, idx: usize) -> Base32<N> { let slice: &[u8] = row.get(idx); slice.try_into().or_fail(|_| { format!( - "Database invariant: expected a base32 byte array of {}B for {}B", - N, + "Database invariant: expected a base32 byte array of {N}B for {}B", slice.len() ) }) diff --git a/contrib/depolymerizer-bitcoin-dbconfig b/contrib/depolymerizer-bitcoin-dbconfig @@ -0,0 +1,162 @@ +#!/bin/bash +# This file is part of GNU TALER. +# Copyright (C) 2025 Taler Systems SA +# +# TALER is free software; you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free Software +# Foundation; either version 2.1, or (at your option) any later version. +# +# TALER is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along with +# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +# +# @author Antoine d'Aligny + +# Error checking on +set -eu + +# 1 is true, 0 is false +RESET_DB=0 +FORCE_PERMS=0 +SKIP_INIT=0 +DBUSER="depolymerizer-bitcoin-httpd" +DBGROUP="depolymerizer-bitcoin-db" +CFGFILE="/etc/depolymerizer-bitcoin/depolymerizer-bitcoin.conf" + +# Parse command-line options +while getopts 'c:g:hprsu:' OPTION; do + case "$OPTION" in + c) + CFGFILE="$OPTARG" + ;; + g) + DBGROUP="$OPTARG" + ;; + h) + echo 'Supported options:' + echo " -c FILENAME -- use configuration FILENAME (default: $CFGFILE)" + echo " -g GROUP -- depolymerizer-bitcoin to be run by GROUP (default: $DBGROUP)" + echo " -h -- print this help text" + echo " -r -- reset database (dangerous)" + echo " -p -- force permission setup even without database initialization" + echo " -s -- skip database initialization" + echo " -u USER -- depolymerizer-bitcoin to be run by USER (default: $DBUSER)" + exit 0 + ;; + p) + FORCE_PERMS="1" + ;; + r) + RESET_DB="1" + ;; + s) + SKIP_INIT="1" + ;; + u) + DBUSER="$OPTARG" + ;; + ?) + echo "Unrecognized command line option '$OPTION'" 1 &>2 + exit 1 + ;; + esac +done + +function exit_fail() { + echo "$@" >&2 + exit 1 +} + +if ! id postgres >/dev/null; then + exit_fail "Could not find 'postgres' user. Please install Postgresql first" +fi + +if ! depolymerizer-bitcoin --version 2>/dev/null; then + exit_fail "Required 'depolymerizer-bitcoin' not found. Please fix your installation." +fi + +if [ "$(id -u)" -ne 0 ]; then + exit_fail "This script must be run as root" +fi + +# Check OS users exist +if ! id "$DBUSER" >/dev/null; then + exit_fail "Could not find '$DBUSER' user. Please set it up first" +fi + +# Create DB user matching OS user name +echo "Setting up database user '$DBUSER'." 1>&2 +if ! sudo -i -u postgres createuser "$DBUSER" 2>/dev/null; then + echo "Database user '$DBUSER' already existed. Continuing anyway." 1>&2 +fi + +# Check database name +DBPATH=$(depolymerizer-bitcoin -c "$CFGFILE" config get depolymerizer-bitcoindb-postgres CONFIG) +if ! echo "$DBPATH" | grep "postgres://" >/dev/null; then + exit_fail "Invalid database configuration value '$DBPATH'." 1>&2 +fi +DBNAME=$(echo "$DBPATH" | sed -e "s/postgres:\/\/.*\///" -e "s/?.*//") + +# Reset database +if sudo -i -u postgres psql "$DBNAME" </dev/null 2>/dev/null; then + if [ 1 = "$RESET_DB" ]; then + echo "Deleting existing database '$DBNAME'." 1>&2 + if ! sudo -i -u postgres dropdb "$DBNAME"; then + exit_fail "Failed to delete existing database '$DBNAME'" + fi + DO_CREATE=1 + else + echo "Database '$DBNAME' already exists, continuing anyway." + DO_CREATE=0 + fi +else + DO_CREATE=1 +fi + +# Create database +if [ 1 = "$DO_CREATE" ]; then + echo "Creating database '$DBNAME'." 1>&2 + if ! sudo -i -u postgres createdb -O "$DBUSER" "$DBNAME"; then + exit_fail "Failed to create database '$DBNAME'" + fi +fi + +# Run dbinit +if [ 0 = "$SKIP_INIT" ]; then + if ! sudo -u "$DBUSER" taler-depolymerizer-bitcoin dbinit -c "$CFGFILE"; then + exit_fail "Failed to initialize database schema" + fi +fi + +# Set permission for group user +if [ 0 = "$SKIP_INIT" ] || [ 1 = "$FORCE_PERMS" ]; then + # Create DB group matching OS group name + echo "Setting up database group '$DBGROUP'." 1>&2 + if ! sudo -i -u postgres createuser "$DBGROUP" 2>/dev/null; then + echo "Database group '$DBGROUP' already existed. Continuing anyway." 1>&2 + fi + if ! echo "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO \"$DBGROUP\"" | + sudo -i -u postgres psql "$DBNAME"; then + exit_fail "Failed to grant access to '$DBGROUP'." + fi + + # Update group users rights + DB_GRP="$(getent group "$DBGROUP" | sed -e "s/.*://g" -e "s/,/ /g")" + echo "Initializing permissions for '$DB_GRP' users." 1>&2 + for GROUPIE in $DB_GRP; do + if [ "$GROUPIE" != "$DBUSER" ]; then + if ! sudo -i -u postgres createuser "$GROUPIE" 2>/dev/null; then + echo "Database user '$GROUPIE' already existed. Continuing anyway." 1>&2 + fi + fi + if ! echo "GRANT ROLE \"$DBGROUP\" ON SCHEMA exchange TO \"$GROUPIE\"" | + sudo -i -u postgres psql "$DBNAME"; then + exit_fail "Failed to make '$GROUPIE' part of '$DBGROUP' db group." + fi + done +fi + +echo "Database configuration finished." 1>&2 diff --git a/contrib/depolymerizer-ethereum-dbconfig b/contrib/depolymerizer-ethereum-dbconfig @@ -0,0 +1,162 @@ +#!/bin/bash +# This file is part of GNU TALER. +# Copyright (C) 2025 Taler Systems SA +# +# TALER is free software; you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free Software +# Foundation; either version 2.1, or (at your option) any later version. +# +# TALER is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along with +# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +# +# @author Antoine d'Aligny + +# Error checking on +set -eu + +# 1 is true, 0 is false +RESET_DB=0 +FORCE_PERMS=0 +SKIP_INIT=0 +DBUSER="depolymerizer-ethereum-httpd" +DBGROUP="depolymerizer-ethereum-db" +CFGFILE="/etc/depolymerizer-ethereum/depolymerizer-ethereum.conf" + +# Parse command-line options +while getopts 'c:g:hprsu:' OPTION; do + case "$OPTION" in + c) + CFGFILE="$OPTARG" + ;; + g) + DBGROUP="$OPTARG" + ;; + h) + echo 'Supported options:' + echo " -c FILENAME -- use configuration FILENAME (default: $CFGFILE)" + echo " -g GROUP -- depolymerizer-ethereum to be run by GROUP (default: $DBGROUP)" + echo " -h -- print this help text" + echo " -r -- reset database (dangerous)" + echo " -p -- force permission setup even without database initialization" + echo " -s -- skip database initialization" + echo " -u USER -- depolymerizer-ethereum to be run by USER (default: $DBUSER)" + exit 0 + ;; + p) + FORCE_PERMS="1" + ;; + r) + RESET_DB="1" + ;; + s) + SKIP_INIT="1" + ;; + u) + DBUSER="$OPTARG" + ;; + ?) + echo "Unrecognized command line option '$OPTION'" 1 &>2 + exit 1 + ;; + esac +done + +function exit_fail() { + echo "$@" >&2 + exit 1 +} + +if ! id postgres >/dev/null; then + exit_fail "Could not find 'postgres' user. Please install Postgresql first" +fi + +if ! depolymerizer-ethereum --version 2>/dev/null; then + exit_fail "Required 'depolymerizer-ethereum' not found. Please fix your installation." +fi + +if [ "$(id -u)" -ne 0 ]; then + exit_fail "This script must be run as root" +fi + +# Check OS users exist +if ! id "$DBUSER" >/dev/null; then + exit_fail "Could not find '$DBUSER' user. Please set it up first" +fi + +# Create DB user matching OS user name +echo "Setting up database user '$DBUSER'." 1>&2 +if ! sudo -i -u postgres createuser "$DBUSER" 2>/dev/null; then + echo "Database user '$DBUSER' already existed. Continuing anyway." 1>&2 +fi + +# Check database name +DBPATH=$(depolymerizer-ethereum -c "$CFGFILE" config get depolymerizer-ethereumdb-postgres CONFIG) +if ! echo "$DBPATH" | grep "postgres://" >/dev/null; then + exit_fail "Invalid database configuration value '$DBPATH'." 1>&2 +fi +DBNAME=$(echo "$DBPATH" | sed -e "s/postgres:\/\/.*\///" -e "s/?.*//") + +# Reset database +if sudo -i -u postgres psql "$DBNAME" </dev/null 2>/dev/null; then + if [ 1 = "$RESET_DB" ]; then + echo "Deleting existing database '$DBNAME'." 1>&2 + if ! sudo -i -u postgres dropdb "$DBNAME"; then + exit_fail "Failed to delete existing database '$DBNAME'" + fi + DO_CREATE=1 + else + echo "Database '$DBNAME' already exists, continuing anyway." + DO_CREATE=0 + fi +else + DO_CREATE=1 +fi + +# Create database +if [ 1 = "$DO_CREATE" ]; then + echo "Creating database '$DBNAME'." 1>&2 + if ! sudo -i -u postgres createdb -O "$DBUSER" "$DBNAME"; then + exit_fail "Failed to create database '$DBNAME'" + fi +fi + +# Run dbinit +if [ 0 = "$SKIP_INIT" ]; then + if ! sudo -u "$DBUSER" taler-depolymerizer-ethereum dbinit -c "$CFGFILE"; then + exit_fail "Failed to initialize database schema" + fi +fi + +# Set permission for group user +if [ 0 = "$SKIP_INIT" ] || [ 1 = "$FORCE_PERMS" ]; then + # Create DB group matching OS group name + echo "Setting up database group '$DBGROUP'." 1>&2 + if ! sudo -i -u postgres createuser "$DBGROUP" 2>/dev/null; then + echo "Database group '$DBGROUP' already existed. Continuing anyway." 1>&2 + fi + if ! echo "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO \"$DBGROUP\"" | + sudo -i -u postgres psql "$DBNAME"; then + exit_fail "Failed to grant access to '$DBGROUP'." + fi + + # Update group users rights + DB_GRP="$(getent group "$DBGROUP" | sed -e "s/.*://g" -e "s/,/ /g")" + echo "Initializing permissions for '$DB_GRP' users." 1>&2 + for GROUPIE in $DB_GRP; do + if [ "$GROUPIE" != "$DBUSER" ]; then + if ! sudo -i -u postgres createuser "$GROUPIE" 2>/dev/null; then + echo "Database user '$GROUPIE' already existed. Continuing anyway." 1>&2 + fi + fi + if ! echo "GRANT ROLE \"$DBGROUP\" ON SCHEMA exchange TO \"$GROUPIE\"" | + sudo -i -u postgres psql "$DBNAME"; then + exit_fail "Failed to make '$GROUPIE' part of '$DBGROUP' db group." + fi + done +fi + +echo "Database configuration finished." 1>&2 diff --git a/database-versioning/depolymerizer-bitcoin-0001.sql b/database-versioning/depolymerizer-bitcoin-0001.sql @@ -0,0 +1,62 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2025 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +SELECT _v.register_patch('depolymerizer-bitcoin-0001', NULL, NULL); + +CREATE SCHEMA depolymerizer_bitcoin; +SET search_path TO depolymerizer_bitcoin; + +CREATE TYPE taler_amount AS (val INT8, frac INT4); +COMMENT ON TYPE taler_amount IS 'Stores an amount, fraction is in units of 1/100000000 of the base value'; + +CREATE TABLE state ( + name TEXT NOT NULL PRIMARY KEY, + value BYTEA NOT NULL +); +COMMENT ON TABLE state IS 'Key value state'; + +CREATE TABLE tx_in ( + id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + received INT8 NOT NULL, + amount taler_amount NOT NULL, + reserve_pub BYTEA NOT NULL UNIQUE CHECK (LENGTH(reserve_pub)=32), + debit_acc TEXT NOT NULL, + credit_acc TEXT NOT NULL +); +COMMENT ON TABLE state IS 'Incoming transactions'; + +CREATE TABLE tx_out ( + id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + created INT8 NOT NULL, + amount taler_amount NOT NULL, + wtid BYTEA NOT NULL UNIQUE CHECK (LENGTH(wtid)=32), + debit_acc TEXT, + credit_acc TEXT NOT NULL, + credit_name TEXT, + exchange_url TEXT NOT NULL, + request_uid BYTEA UNIQUE CHECK (LENGTH(request_uid)=64), + status SMALLINT NOT NULL DEFAULT 0, + txid BYTEA UNIQUE CHECK (LENGTH(txid)=32) +); +COMMENT ON TABLE state IS 'Outgoing transactions'; + +CREATE TABLE bounce ( + id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + bounced BYTEA UNIQUE NOT NULL, + txid BYTEA UNIQUE CHECK (LENGTH(txid)=32), + created INT8 NOT NULL, + status SMALLINT NOT NULL DEFAULT 0 +); +COMMENT ON TABLE state IS 'Bounced incoming transactions'; +\ No newline at end of file diff --git a/database-versioning/depolymerizer-bitcoin-drop.sql b/database-versioning/depolymerizer-bitcoin-drop.sql @@ -0,0 +1,29 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2025 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +DO +$do$ +DECLARE + patch text; +BEGIN + IF EXISTS(SELECT FROM information_schema.schemata WHERE schema_name='_v') THEN + FOR patch IN SELECT patch_name FROM _v.patches WHERE patch_name LIKE 'depolymerizer-bitcoin-%' LOOP + PERFORM _v.unregister_patch(patch); + END LOOP; + END IF; +END +$do$; + +DROP SCHEMA IF EXISTS depolymerizer_bitcoin CASCADE; +\ No newline at end of file diff --git a/database-versioning/depolymerizer-bitcoin-procedures.sql b/database-versioning/depolymerizer-bitcoin-procedures.sql @@ -0,0 +1,39 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2025 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +SET search_path TO depolymerizer_bitcoin; + +-- Remove all existing functions +DO +$do$ +DECLARE + _sql text; +BEGIN + SELECT INTO _sql + string_agg(format('DROP %s %s CASCADE;' + , CASE prokind + WHEN 'f' THEN 'FUNCTION' + WHEN 'p' THEN 'PROCEDURE' + END + , oid::regprocedure) + , E'\n') + FROM pg_proc + WHERE pronamespace = 'depolymerizer_bitcoin'::regnamespace; + + IF _sql IS NOT NULL THEN + EXECUTE _sql; + END IF; +END +$do$; +\ No newline at end of file diff --git a/database-versioning/depolymerizer-ethereum-0001.sql b/database-versioning/depolymerizer-ethereum-0001.sql @@ -0,0 +1,61 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2025 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +SELECT _v.register_patch('depolymerizer-ethereum-0001', NULL, NULL); + +CREATE SCHEMA depolymerizer_ethereum; +SET search_path TO depolymerizer_ethereum; + +CREATE TYPE taler_amount AS (val INT8, frac INT4); +COMMENT ON TYPE taler_amount IS 'Stores an amount, fraction is in units of 1/100000000 of the base value'; + +CREATE TABLE state ( + name TEXT NOT NULL PRIMARY KEY, + value BYTEA NOT NULL +); +COMMENT ON TABLE state IS 'Key value state'; + +CREATE TABLE tx_in ( + id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + received INT8 NOT NULL, + amount taler_amount NOT NULL, + reserve_pub BYTEA NOT NULL UNIQUE CHECK (LENGTH(reserve_pub)=32), + debit_acc BYTEA NOT NULL CHECK (LENGTH(debit_acc)=20) +); +COMMENT ON TABLE state IS 'Incoming transactions'; + +CREATE TABLE tx_out ( + id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + created INT8 NOT NULL, + amount taler_amount NOT NULL, + wtid BYTEA NOT NULL UNIQUE CHECK (LENGTH(wtid)=32), + credit_acc BYTEA NOT NULL CHECK (LENGTH(credit_acc)=20), + credit_name TEXT, + exchange_url TEXT NOT NULL, + request_uid BYTEA UNIQUE CHECK (LENGTH(request_uid)=64), + status SMALLINT NOT NULL DEFAULT 0, + txid BYTEA UNIQUE CHECK (LENGTH(txid)=32), + sent INT8 +); +COMMENT ON TABLE state IS 'Outgoing transactions'; + +CREATE TABLE bounce ( + id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + bounced BYTEA UNIQUE NOT NULL CHECK (LENGTH(bounced)=32), + txid BYTEA UNIQUE CHECK (LENGTH(txid)=32), + created INT8 NOT NULL, + status SMALLINT NOT NULL DEFAULT 0 +); +COMMENT ON TABLE state IS 'Bounced incoming transactions'; +\ No newline at end of file diff --git a/database-versioning/depolymerizer-ethereum-drop.sql b/database-versioning/depolymerizer-ethereum-drop.sql @@ -0,0 +1,29 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2025 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +DO +$do$ +DECLARE + patch text; +BEGIN + IF EXISTS(SELECT FROM information_schema.schemata WHERE schema_name='_v') THEN + FOR patch IN SELECT patch_name FROM _v.patches WHERE patch_name LIKE 'depolymerizer-ethereum-%' LOOP + PERFORM _v.unregister_patch(patch); + END LOOP; + END IF; +END +$do$; + +DROP SCHEMA IF EXISTS depolymerizer_ethereum CASCADE; +\ No newline at end of file diff --git a/database-versioning/depolymerizer-ethereum-procedures.sql b/database-versioning/depolymerizer-ethereum-procedures.sql @@ -0,0 +1,39 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2025 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +SET search_path TO depolymerizer_ethereum; + +-- Remove all existing functions +DO +$do$ +DECLARE + _sql text; +BEGIN + SELECT INTO _sql + string_agg(format('DROP %s %s CASCADE;' + , CASE prokind + WHEN 'f' THEN 'FUNCTION' + WHEN 'p' THEN 'PROCEDURE' + END + , oid::regprocedure) + , E'\n') + FROM pg_proc + WHERE pronamespace = 'depolymerizer_ethereum'::regnamespace; + + IF _sql IS NOT NULL THEN + EXECUTE _sql; + END IF; +END +$do$; +\ No newline at end of file diff --git a/database-versioning/versioning.sql b/database-versioning/versioning.sql @@ -0,0 +1,294 @@ +-- LICENSE AND COPYRIGHT +-- +-- Copyright (C) 2010 Hubert depesz Lubaczewski +-- +-- This program is distributed under the (Revised) BSD License: +-- L<http://www.opensource.org/licenses/bsd-license.php> +-- +-- Redistribution and use in source and binary forms, with or without +-- modification, are permitted provided that the following conditions +-- are met: +-- +-- * Redistributions of source code must retain the above copyright +-- notice, this list of conditions and the following disclaimer. +-- +-- * Redistributions in binary form must reproduce the above copyright +-- notice, this list of conditions and the following disclaimer in the +-- documentation and/or other materials provided with the distribution. +-- +-- * Neither the name of Hubert depesz Lubaczewski's Organization +-- nor the names of its contributors may be used to endorse or +-- promote products derived from this software without specific +-- prior written permission. +-- +-- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +-- AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +-- IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +-- DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +-- FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +-- DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +-- SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +-- CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +-- OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +-- OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-- +-- Code origin: https://gitlab.com/depesz/Versioning/blob/master/install.versioning.sql +-- +-- +-- # NAME +-- +-- **Versioning** - simplistic take on tracking and applying changes to databases. +-- +-- # DESCRIPTION +-- +-- This project strives to provide simple way to manage changes to +-- database. +-- +-- Instead of making changes on development server, then finding +-- differences between production and development, deciding which ones +-- should be installed on production, and finding a way to install them - +-- you start with writing diffs themselves! +-- +-- # INSTALLATION +-- +-- To install versioning simply run install.versioning.sql in your database +-- (all of them: production, stage, test, devel, ...). +-- +-- # USAGE +-- +-- In your files with patches to database, put whole logic in single +-- transaction, and use \_v.\* functions - usually \_v.register_patch() at +-- least to make sure everything is OK. +-- +-- For example. Let's assume you have patch files: +-- +-- ## 0001.sql: +-- +-- ``` +-- create table users (id serial primary key, username text); +-- ``` +-- +-- ## 0002.sql: +-- +-- ``` +-- insert into users (username) values ('depesz'); +-- ``` +-- To change it to use versioning you would change the files, to this +-- state: +-- +-- 0000.sql: +-- +-- ``` +-- BEGIN; +-- select _v.register_patch('000-base', NULL, NULL); +-- create table users (id serial primary key, username text); +-- COMMIT; +-- ``` +-- +-- ## 0002.sql: +-- +-- ``` +-- BEGIN; +-- select _v.register_patch('001-users', ARRAY['000-base'], NULL); +-- insert into users (username) values ('depesz'); +-- COMMIT; +-- ``` +-- +-- This will make sure that patch 001-users can only be applied after +-- 000-base. +-- +-- # AVAILABLE FUNCTIONS +-- +-- ## \_v.register_patch( TEXT ) +-- +-- Registers named patch, or dies if it is already registered. +-- +-- Returns integer which is id of patch in \_v.patches table - only if it +-- succeeded. +-- +-- ## \_v.register_patch( TEXT, TEXT[] ) +-- +-- Same as \_v.register_patch( TEXT ), but checks is all given patches (given as +-- array in second argument) are already registered. +-- +-- ## \_v.register_patch( TEXT, TEXT[], TEXT[] ) +-- +-- Same as \_v.register_patch( TEXT, TEXT[] ), but also checks if there are no conflicts with preexisting patches. +-- +-- Third argument is array of names of patches that conflict with current one. So +-- if any of them is installed - register_patch will error out. +-- +-- ## \_v.unregister_patch( TEXT ) +-- +-- Removes information about given patch from the versioning data. +-- +-- It doesn't remove objects that were created by this patch - just removes +-- metainformation. +-- +-- ## \_v.assert_user_is_superuser() +-- +-- Make sure that current patch is being loaded by superuser. +-- +-- If it's not - it will raise exception, and break transaction. +-- +-- ## \_v.assert_user_is_not_superuser() +-- +-- Make sure that current patch is not being loaded by superuser. +-- +-- If it is - it will raise exception, and break transaction. +-- +-- ## \_v.assert_user_is_one_of(TEXT, TEXT, ... ) +-- +-- Make sure that current patch is being loaded by one of listed users. +-- +-- If ```current_user``` is not listed as one of arguments - function will raise +-- exception and break the transaction. + +BEGIN; + + +-- This file adds versioning support to database it will be loaded to. +-- It requires that PL/pgSQL is already loaded - will raise exception otherwise. +-- All versioning "stuff" (tables, functions) is in "_v" schema. + +-- All functions are defined as 'RETURNS SETOF INT4' to be able to make them to RETURN literally nothing (0 rows). +-- >> RETURNS VOID<< IS similar, but it still outputs "empty line" in psql when calling +CREATE SCHEMA IF NOT EXISTS _v; +COMMENT ON SCHEMA _v IS 'Schema for versioning data and functionality.'; + +CREATE TABLE IF NOT EXISTS _v.patches ( + patch_name TEXT PRIMARY KEY, + applied_tsz TIMESTAMPTZ NOT NULL DEFAULT now(), + applied_by TEXT NOT NULL, + requires TEXT[], + conflicts TEXT[] +); +COMMENT ON TABLE _v.patches IS 'Contains information about what patches are currently applied on database.'; +COMMENT ON COLUMN _v.patches.patch_name IS 'Name of patch, has to be unique for every patch.'; +COMMENT ON COLUMN _v.patches.applied_tsz IS 'When the patch was applied.'; +COMMENT ON COLUMN _v.patches.applied_by IS 'Who applied this patch (PostgreSQL username)'; +COMMENT ON COLUMN _v.patches.requires IS 'List of patches that are required for given patch.'; +COMMENT ON COLUMN _v.patches.conflicts IS 'List of patches that conflict with given patch.'; + +CREATE OR REPLACE FUNCTION _v.register_patch( IN in_patch_name TEXT, IN in_requirements TEXT[], in_conflicts TEXT[], OUT versioning INT4 ) RETURNS setof INT4 AS $$ +DECLARE + t_text TEXT; + t_text_a TEXT[]; + i INT4; +BEGIN + -- Thanks to this we know only one patch will be applied at a time + LOCK TABLE _v.patches IN EXCLUSIVE MODE; + + SELECT patch_name INTO t_text FROM _v.patches WHERE patch_name = in_patch_name; + IF FOUND THEN + RAISE EXCEPTION 'Patch % is already applied!', in_patch_name; + END IF; + + t_text_a := ARRAY( SELECT patch_name FROM _v.patches WHERE patch_name = any( in_conflicts ) ); + IF array_upper( t_text_a, 1 ) IS NOT NULL THEN + RAISE EXCEPTION 'Versioning patches conflict. Conflicting patche(s) installed: %.', array_to_string( t_text_a, ', ' ); + END IF; + + IF array_upper( in_requirements, 1 ) IS NOT NULL THEN + t_text_a := '{}'; + FOR i IN array_lower( in_requirements, 1 ) .. array_upper( in_requirements, 1 ) LOOP + SELECT patch_name INTO t_text FROM _v.patches WHERE patch_name = in_requirements[i]; + IF NOT FOUND THEN + t_text_a := t_text_a || in_requirements[i]; + END IF; + END LOOP; + IF array_upper( t_text_a, 1 ) IS NOT NULL THEN + RAISE EXCEPTION 'Missing prerequisite(s): %.', array_to_string( t_text_a, ', ' ); + END IF; + END IF; + + INSERT INTO _v.patches (patch_name, applied_tsz, applied_by, requires, conflicts ) VALUES ( in_patch_name, now(), current_user, coalesce( in_requirements, '{}' ), coalesce( in_conflicts, '{}' ) ); + RETURN; +END; +$$ language plpgsql; +COMMENT ON FUNCTION _v.register_patch( TEXT, TEXT[], TEXT[] ) IS 'Function to register patches in database. Raises exception if there are conflicts, prerequisites are not installed or the migration has already been installed.'; + +CREATE OR REPLACE FUNCTION _v.register_patch( TEXT, TEXT[] ) RETURNS setof INT4 AS $$ + SELECT _v.register_patch( $1, $2, NULL ); +$$ language sql; +COMMENT ON FUNCTION _v.register_patch( TEXT, TEXT[] ) IS 'Wrapper to allow registration of patches without conflicts.'; +CREATE OR REPLACE FUNCTION _v.register_patch( TEXT ) RETURNS setof INT4 AS $$ + SELECT _v.register_patch( $1, NULL, NULL ); +$$ language sql; +COMMENT ON FUNCTION _v.register_patch( TEXT ) IS 'Wrapper to allow registration of patches without requirements and conflicts.'; + +CREATE OR REPLACE FUNCTION _v.unregister_patch( IN in_patch_name TEXT, OUT versioning INT4 ) RETURNS setof INT4 AS $$ +DECLARE + i INT4; + t_text_a TEXT[]; +BEGIN + -- Thanks to this we know only one patch will be applied at a time + LOCK TABLE _v.patches IN EXCLUSIVE MODE; + + t_text_a := ARRAY( SELECT patch_name FROM _v.patches WHERE in_patch_name = ANY( requires ) ); + IF array_upper( t_text_a, 1 ) IS NOT NULL THEN + RAISE EXCEPTION 'Cannot uninstall %, as it is required by: %.', in_patch_name, array_to_string( t_text_a, ', ' ); + END IF; + + DELETE FROM _v.patches WHERE patch_name = in_patch_name; + GET DIAGNOSTICS i = ROW_COUNT; + IF i < 1 THEN + RAISE EXCEPTION 'Patch % is not installed, so it can''t be uninstalled!', in_patch_name; + END IF; + + RETURN; +END; +$$ language plpgsql; +COMMENT ON FUNCTION _v.unregister_patch( TEXT ) IS 'Function to unregister patches in database. Dies if the patch is not registered, or if unregistering it would break dependencies.'; + +CREATE OR REPLACE FUNCTION _v.assert_patch_is_applied( IN in_patch_name TEXT ) RETURNS TEXT as $$ +DECLARE + t_text TEXT; +BEGIN + SELECT patch_name INTO t_text FROM _v.patches WHERE patch_name = in_patch_name; + IF NOT FOUND THEN + RAISE EXCEPTION 'Patch % is not applied!', in_patch_name; + END IF; + RETURN format('Patch %s is applied.', in_patch_name); +END; +$$ language plpgsql; +COMMENT ON FUNCTION _v.assert_patch_is_applied( TEXT ) IS 'Function that can be used to make sure that patch has been applied.'; + +CREATE OR REPLACE FUNCTION _v.assert_user_is_superuser() RETURNS TEXT as $$ +DECLARE + v_super bool; +BEGIN + SELECT usesuper INTO v_super FROM pg_user WHERE usename = current_user; + IF v_super THEN + RETURN 'assert_user_is_superuser: OK'; + END IF; + RAISE EXCEPTION 'Current user is not superuser - cannot continue.'; +END; +$$ language plpgsql; +COMMENT ON FUNCTION _v.assert_user_is_superuser() IS 'Function that can be used to make sure that patch is being applied using superuser account.'; + +CREATE OR REPLACE FUNCTION _v.assert_user_is_not_superuser() RETURNS TEXT as $$ +DECLARE + v_super bool; +BEGIN + SELECT usesuper INTO v_super FROM pg_user WHERE usename = current_user; + IF v_super THEN + RAISE EXCEPTION 'Current user is superuser - cannot continue.'; + END IF; + RETURN 'assert_user_is_not_superuser: OK'; +END; +$$ language plpgsql; +COMMENT ON FUNCTION _v.assert_user_is_not_superuser() IS 'Function that can be used to make sure that patch is being applied using normal (not superuser) account.'; + +CREATE OR REPLACE FUNCTION _v.assert_user_is_one_of(VARIADIC p_acceptable_users TEXT[] ) RETURNS TEXT as $$ +DECLARE +BEGIN + IF current_user = any( p_acceptable_users ) THEN + RETURN 'assert_user_is_one_of: OK'; + END IF; + RAISE EXCEPTION 'User is not one of: % - cannot continue.', p_acceptable_users; +END; +$$ language plpgsql; +COMMENT ON FUNCTION _v.assert_user_is_one_of(TEXT[]) IS 'Function that can be used to make sure that patch is being applied by one of defined users.'; + +COMMIT; diff --git a/db/btc.sql b/db/btc.sql @@ -1,42 +0,0 @@ -CREATE TYPE taler_amount AS (val INT8, frac INT4); -COMMENT ON TYPE taler_amount - IS 'Stores an amount, fraction is in units of 1/100000000 of the base value'; - --- Key value state -CREATE TABLE state ( - name TEXT NOT NULL PRIMARY KEY, - value BYTEA NOT NULL -); - --- Incoming transactions -CREATE TABLE tx_in ( - id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - received INT8 NOT NULL, - amount taler_amount NOT NULL, - reserve_pub BYTEA NOT NULL UNIQUE CHECK (LENGTH(reserve_pub)=32), - debit_acc TEXT NOT NULL, - credit_acc TEXT NOT NULL -); - --- Outgoing transactions -CREATE TABLE tx_out ( - id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - created INT8 NOT NULL, - amount taler_amount NOT NULL, - wtid BYTEA NOT NULL UNIQUE CHECK (LENGTH(wtid)=32), - debit_acc TEXT NOT NULL, - credit_acc TEXT NOT NULL, - exchange_url TEXT NOT NULL, - request_uid BYTEA UNIQUE CHECK (LENGTH(request_uid)=64), - status SMALLINT NOT NULL DEFAULT 0, - txid BYTEA UNIQUE CHECK (LENGTH(txid)=32) -); - --- Bounced transaction -CREATE TABLE bounce ( - id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - bounced BYTEA UNIQUE NOT NULL, - txid BYTEA UNIQUE CHECK (LENGTH(txid)=32), - created INT8 NOT NULL, - status SMALLINT NOT NULL DEFAULT 0 -) -\ No newline at end of file diff --git a/db/common.sql b/db/common.sql @@ -1,31 +0,0 @@ -CREATE TYPE taler_amount AS (val INT8, frac INT4); -COMMENT ON TYPE taler_amount - IS 'Stores an amount, fraction is in units of 1/100000000 of the base value'; - --- Key value state -CREATE TABLE state ( - name TEXT NOT NULL PRIMARY KEY, - value BYTEA NOT NULL -); - --- Incoming transactions -CREATE TABLE tx_in ( - id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - created INT8 NOT NULL DEFAULT now(), - amount taler_amount NOT NULL, - reserve_pub BYTEA NOT NULL UNIQUE CHECK (LENGTH(reserve_pub)=32), - debit_acc TEXT NOT NULL, - credit_acc TEXT NOT NULL -); - --- Outgoing transactions -CREATE TABLE tx_out ( - id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - created INT8 NOT NULL DEFAULT now(), - amount taler_amount NOT NULL, - wtid BYTEA NOT NULL UNIQUE CHECK (LENGTH(wtid)=32), - debit_acc TEXT NOT NULL, - credit_acc TEXT NOT NULL, - exchange_url TEXT NOT NULL, - request_uid BYTEA UNIQUE CHECK (LENGTH(request_uid)=64) -); -\ No newline at end of file diff --git a/db/eth.sql b/db/eth.sql @@ -1,43 +0,0 @@ -CREATE TYPE taler_amount AS (val INT8, frac INT4); -COMMENT ON TYPE taler_amount - IS 'Stores an amount, fraction is in units of 1/100000000 of the base value'; - --- Key value state -CREATE TABLE state ( - name TEXT NOT NULL PRIMARY KEY, - value BYTEA NOT NULL -); - --- Incoming transactions -CREATE TABLE tx_in ( - id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - received INT8 NOT NULL, - amount taler_amount NOT NULL, - reserve_pub BYTEA NOT NULL UNIQUE CHECK (LENGTH(reserve_pub)=32), - debit_acc BYTEA NOT NULL CHECK (LENGTH(debit_acc)=20), - credit_acc BYTEA NOT NULL CHECK (LENGTH(credit_acc)=20) -); - --- Outgoing transactions -CREATE TABLE tx_out ( - id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - created INT8 NOT NULL, - amount taler_amount NOT NULL, - wtid BYTEA NOT NULL UNIQUE CHECK (LENGTH(wtid)=32), - debit_acc BYTEA NOT NULL CHECK (LENGTH(debit_acc)=20), - credit_acc BYTEA NOT NULL CHECK (LENGTH(credit_acc)=20), - exchange_url TEXT NOT NULL, - request_uid BYTEA UNIQUE CHECK (LENGTH(request_uid)=64), - status SMALLINT NOT NULL DEFAULT 0, - txid BYTEA UNIQUE CHECK (LENGTH(txid)=32), - sent INT8 DEFAULT NULL -); - --- Bounced transaction -CREATE TABLE bounce ( - id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - bounced BYTEA UNIQUE NOT NULL CHECK (LENGTH(bounced)=32), - txid BYTEA UNIQUE CHECK (LENGTH(txid)=32), - created INT8 NOT NULL, - status SMALLINT NOT NULL DEFAULT 0 -) -\ No newline at end of file diff --git a/depolymerizer-bitcoin/Cargo.toml b/depolymerizer-bitcoin/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "depolymerizer-bitcoin" +version = "0.1.0" +edition.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license-file.workspace = true + +[features] +# Enable random failures +fail = [] + +[dependencies] +# Typed bitcoin rpc types +bitcoin.workspace = true +# Cli args parser +clap.workspace = true +# Bech32 encoding and decoding +bech32 = "0.11.0" +# Serialization library +serde.workspace = true +serde_json.workspace = true +serde_repr = "0.1.16" +# Error macros +thiserror.workspace = true +# Common lib +common = { path = "../common" } +# Hexadecimal encoding +hex.workspace = true +anyhow.workspace = true +taler-api.workspace = true +taler-common.workspace = true +sqlx.workspace = true +tokio.workspace = true +tracing.workspace = true +axum.workspace = true +base64.workspace = true + +[dev-dependencies] +criterion.workspace = true +taler-test-utils.workspace = true + +[[bench]] +name = "metadata" +harness = false diff --git a/btc-wire/README.md b/depolymerizer-bitcoin/README.md diff --git a/btc-wire/benches/metadata.rs b/depolymerizer-bitcoin/benches/metadata.rs diff --git a/depolymerizer-bitcoin/depolymerizer-bitcoin.conf b/depolymerizer-bitcoin/depolymerizer-bitcoin.conf @@ -0,0 +1,105 @@ +[depolymerizer-bitcoin] +# Bitcoin wallet address to advertise +WALLET = + +# Legal name of the wallet owner +NAME = + +[depolymerizer-bitcoin-worker] +# Name of the wallet to sync +WALLET_NAME = + +# Password of the encrypted wallet +PASSWORD = + +# Number of blocks to consider a transaction confirmed +CONFIRMATION = 6 + +# An additional fee to deduce from the bounced amount +# BOUNCE_FEE = BTC:0 + +# Specify the account type and therefore the indexing behavior. +# This can either can be normal or exchange. +# Exchange accounts bounce invalid incoming Taler transactions. +ACCOUNT_TYPE = exchange + +# Number of worker's loops before wire implementation shut +LIFETIME = 0 + +# Delay in seconds before bumping an unconfirmed transaction fee (0 mean never) +BUMP_DELAY = 0 + +# RPC server address +RPC_BIND = 127.0.0.1:8332 + +# RPC authentication scheme, this can either can be basic or cookie +RPC_AUTH_METHOD = cookie + +# Path to the bitcoind cookie file +RPC_COOKIE_FILE = $HOME/.bitcoin/.cookie + +# User name for basic authentication scheme +# RPC_USERNAME = + +# Password for basic authentication scheme +# RPC_PASSWORD = + +[depolymerizer-bitcoin-httpd] +# How "depolymerizer-bitcoin serve" serves its API, this can either be tcp or unix +SERVE = tcp + +# Port on which the HTTP server listens, e.g. 9967. Only used if SERVE is tcp. +PORT = 8080 + +# Which IP address should we bind to? E.g. ``127.0.0.1`` or ``::1``for loopback. Only used if SERVE is tcp. +BIND_TO = 0.0.0.0 + +# Which unix domain path should we bind to? Only used if SERVE is unix. +# UNIXPATH = depolymerizer-bitcoin.sock + +# What should be the file access permissions for UNIXPATH? Only used if SERVE is unix. +# UNIXPATH_MODE = 660 + +# Number of requests to serve before server shutdown (0 mean never) +LIFETIME = 0 + +[depolymerizer-bitcoin-httpd-wire-gateway-api] +# Whether to serve the Wire Gateway API +ENABLED = NO + +# Authentication scheme, this can either can be basic, bearer or none. +AUTH_METHOD = bearer + +# User name for basic authentication scheme +# USERNAME = + +# Password for basic authentication scheme +# PASSWORD = + +# Token for bearer authentication scheme +TOKEN = + + +[depolymerizer-bitcoin-httpd-revenue-api] +# Whether to serve the Revenue API +ENABLED = NO + +# Authentication scheme, this can either can be basic, bearer or none. +AUTH_METHOD = bearer + +# User name for basic authentication scheme +# USERNAME = + +# Password for basic authentication scheme +# PASSWORD = + +# Token for bearer authentication scheme +TOKEN = + + +[depolymerizer-bitcoindb-postgres] +# DB connection string +CONFIG = postgres:///depolymerizer-bitcoin + +# Where are the SQL files to setup our tables? +SQL_DIR = ${DATADIR}/sql/ +\ No newline at end of file diff --git a/depolymerizer-bitcoin/src/api.rs b/depolymerizer-bitcoin/src/api.rs @@ -0,0 +1,408 @@ +/* + This file is part of TALER + Copyright (C) 2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::{IntoResponse as _, Response}, +}; +use bitcoin::address::NetworkUnchecked; +use sqlx::{ + PgPool, QueryBuilder, Row, + postgres::{PgListener, PgRow}, +}; +use taler_api::{ + api::{TalerApi, wire::WireGateway}, + db::{BindHelper as _, TypeHelper as _, history, page}, + error::{ApiResult, failure, failure_status, not_implemented}, +}; +use taler_common::{ + api_params::{History, Page}, + api_wire::{ + AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddKycauthResponse, + IncomingBankTransaction, IncomingHistory, OutgoingBankTransaction, OutgoingHistory, + TransferList, TransferRequest, TransferResponse, TransferState, TransferStatus, + }, + error_code::ErrorCode, + types::{amount::Currency, payto::PaytoURI, timestamp::Timestamp}, +}; +use taler_common::{api_wire::TransferListStatus, types::payto::PaytoImpl}; +use tokio::{sync::watch::Sender, time::sleep}; +use tracing::error; + +use crate::payto::{BtcWallet, FullBtcPayto}; + +pub struct ServerState { + pool: PgPool, + payto: PaytoURI, + currency: Currency, + status: AtomicBool, + taler_in_channel: Sender<i64>, + taler_out_channel: Sender<i64>, +} + +pub async fn notification_listener( + pool: PgPool, + taler_in_channel: Sender<i64>, + taler_out_channel: Sender<i64>, +) -> sqlx::Result<()> { + taler_api::notification::notification_listener!(&pool, + "taler_in" => (row_id: i64) { + taler_in_channel.send_replace(row_id); + }, + "taler_out" => (row_id: i64) { + taler_out_channel.send_replace(row_id); + } + ) +} + +impl ServerState { + pub async fn start(pool: sqlx::PgPool, payto: PaytoURI, currency: Currency) -> Arc<Self> { + let taler_in_channel = Sender::new(0); + let taler_out_channel = Sender::new(0); + let tmp = Self { + pool: pool.clone(), + payto, + currency, + status: AtomicBool::new(true), + taler_in_channel: taler_in_channel.clone(), + taler_out_channel: taler_out_channel.clone(), + }; + let state = Arc::new(tmp); + tokio::spawn(status_watcher(state.clone())); + tokio::spawn(notification_listener( + pool, + taler_in_channel, + taler_out_channel, + )); + state + } +} + +impl TalerApi for ServerState { + fn currency(&self) -> &str { + self.currency.as_ref() + } + + fn implementation(&self) -> Option<&str> { + None + } +} + +fn sql_payto<I: sqlx::ColumnIndex<PgRow>>(r: &PgRow, addr: I, name: I) -> sqlx::Result<PaytoURI> { + let addr = r + .try_get_parse::<_, _, bitcoin::Address<NetworkUnchecked>>(addr)? + .assume_checked(); + let name: Option<&str> = r.try_get(name)?; + + Ok(BtcWallet(addr) + .as_payto() + .as_full_payto(name.unwrap_or("Bitcoin User"))) +} + +fn sql_generic_payto<I: sqlx::ColumnIndex<PgRow>>(row: &PgRow, idx: I) -> sqlx::Result<PaytoURI> { + let addr = row + .try_get_parse::<_, _, bitcoin::Address<NetworkUnchecked>>(idx)? + .assume_checked(); + + Ok(BtcWallet(addr).as_payto().as_full_payto("Bitcoin User")) +} + +impl WireGateway for ServerState { + async fn transfer(&self, req: TransferRequest) -> ApiResult<TransferResponse> { + let creditor = FullBtcPayto::try_from(&req.credit_account)?; + + // TODO use plpgsql transaction + // Handle idempotence, check previous transaction with the same request_uid + let row = sqlx::query("SELECT (amount).val, (amount).frac, exchange_url, wtid, credit_acc, credit_name, id, created FROM tx_out WHERE request_uid = $1").bind(req.request_uid.as_slice()) + .fetch_optional(&self.pool) + .await?; + if let Some(r) = row { + // TODO store names? + let prev: TransferRequest = TransferRequest { + request_uid: req.request_uid.clone(), + amount: r.try_get_amount_i(0, &self.currency)?, + exchange_base_url: r.try_get_url("exchange_url")?, + wtid: r.try_get_base32("wtid")?, + credit_account: sql_payto(&r, "credit_acc", "credit_name")?, + }; + if prev == req { + // Idempotence + return Ok(TransferResponse { + row_id: r.try_get_safeu64("id")?, + timestamp: r.try_get_timestamp("created")?, + }); + } else { + return Err(failure( + ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED, + format!("Request UID {} already used", req.request_uid), + )); + } + } + + let timestamp = Timestamp::now(); + let r = sqlx::query( + "INSERT INTO tx_out (created, amount, wtid, debit_acc, credit_acc, credit_name, exchange_url, request_uid) VALUES ($1, ($2, $3)::taler_amount, $4, $5, $6, $7, $8, $9) ON CONFLICT (wtid) DO NOTHING RETURNING id" + ) + .bind_timestamp(&Timestamp::now()) + .bind_amount(&req.amount) + .bind(req.wtid.as_slice()) + .bind(self.payto.to_string()) + .bind(creditor.0.to_string()) + .bind(&creditor.name) + .bind(req.exchange_base_url.as_str()) + .bind(req.request_uid.as_slice()) + .fetch_optional(&self.pool) + .await?; + let Some(r) = r else { + return Err(failure( + ErrorCode::BANK_TRANSFER_WTID_REUSED, + format!("wtid {} already used", req.request_uid), + )); + }; + let row_id = r.try_get_safeu64(0)?; + sqlx::query("NOTIFY new_tx").execute(&self.pool).await?; + sqlx::query("SELECT pg_notify('taler_out', '' || $1)") + .bind(*row_id as i64) + .execute(&self.pool) + .await?; + + Ok(TransferResponse { timestamp, row_id }) + } + + async fn transfer_page( + &self, + params: Page, + status: Option<TransferState>, + ) -> ApiResult<TransferList> { + let debit_account = self.payto.clone(); + if status.is_some_and(|s| s != TransferState::success) { + return Ok(TransferList { + transfers: Vec::new(), + debit_account, + }); + } + let transfers = page( + &self.pool, + "id", + &params, + || { + QueryBuilder::new( + " + SELECT + id, + status, + (amount).val as amount_val, + (amount).frac as amount_frac, + credit_acc, + credit_name, + created + FROM tx_out WHERE request_uid IS NOT NULL AND + ", + ) + }, + |r: PgRow| { + Ok(TransferListStatus { + row_id: r.try_get_safeu64("id")?, + // TODO Fetch inner status + status: TransferState::success, + amount: r.try_get_amount("amount", &self.currency)?, + credit_account: sql_payto(&r, "credit_acc", "credit_name")?, + timestamp: r.try_get_timestamp("created")?, + }) + }, + ) + .await?; + Ok(TransferList { + transfers, + debit_account, + }) + } + + async fn transfer_by_id(&self, id: u64) -> ApiResult<Option<TransferStatus>> { + Ok(sqlx::query( + " + SELECT + status, + (amount).val as amount_val, + (amount).frac as amount_frac, + exchange_url, + wtid, + credit_acc, + credit_name, + created + FROM tx_out WHERE request_uid IS NOT NULL AND id = $1 + ", + ) + .bind(id as i64) + .try_map(|r: PgRow| { + Ok(TransferStatus { + // TODO Fetch inner status + status: TransferState::success, + status_msg: None, + amount: r.try_get_amount("amount", &self.currency)?, + origin_exchange_url: r.try_get("exchange_url")?, + wtid: r.try_get_base32("wtid")?, + credit_account: sql_payto(&r, "credit_acc", "credit_name")?, + timestamp: r.try_get_timestamp("created")?, + }) + }) + .fetch_optional(&self.pool) + .await?) + } + + async fn outgoing_history(&self, params: History) -> ApiResult<OutgoingHistory> { + let outgoing_transactions = history( + &self.pool, + "id", + &params, + || self.taler_out_channel.subscribe(), + || QueryBuilder::new( + "SELECT id, created, (amount).val, (amount).frac, wtid, credit_acc, credit_name, exchange_url FROM tx_out WHERE" + ), |r| { + Ok(OutgoingBankTransaction { + row_id: r.try_get_safeu64(0)?, + date: r.try_get_timestamp(1)?, + amount: r.try_get_amount_i(2, &self.currency)?, + wtid: r.try_get_base32(4)?, + credit_account: sql_payto(&r, "credit_acc", "credit_name")?, + exchange_base_url: r.try_get_url("exchange_url")?, + }) + }).await?; + Ok(OutgoingHistory { + debit_account: self.payto.clone(), + outgoing_transactions, + }) + } + + async fn incoming_history(&self, params: History) -> ApiResult<IncomingHistory> { + let incoming_transactions = history( + &self.pool, + "id", + &params, + || self.taler_in_channel.subscribe(), + || { + QueryBuilder::new( + "SELECT id, received, (amount).val, (amount).frac, reserve_pub, debit_acc FROM tx_in WHERE" + ) + }, + |r| { + Ok(IncomingBankTransaction::Reserve { + row_id: r.try_get_safeu64(0)?, + date: r.try_get_timestamp(1)?, + amount: r.try_get_amount_i(2, &self.currency)?, + reserve_pub: r.try_get_base32(4)?, + debit_account: sql_generic_payto(&r, 5)?, + }) + }, + ) + .await?; + Ok(IncomingHistory { + credit_account: self.payto.clone(), + incoming_transactions, + }) + } + + async fn add_incoming_reserve( + &self, + req: AddIncomingRequest, + ) -> ApiResult<AddIncomingResponse> { + let debtor = FullBtcPayto::try_from(&req.debit_account)?; + let timestamp = Timestamp::now(); + let r = sqlx::query("INSERT INTO tx_in (received, amount, reserve_pub, debit_acc, credit_acc) VALUES ($1, ($2, $3)::taler_amount, $4, $5, $6) ON CONFLICT (reserve_pub) DO NOTHING RETURNING id") + .bind_timestamp(&Timestamp::now()) + .bind_amount(&req.amount) + .bind(req.reserve_pub.as_slice()) + .bind(debtor.0.to_string()) + .bind(self.payto.to_string()) + .fetch_optional(&self.pool).await?; + let Some(r) = r else { + return Err(failure( + ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT, + "reserve_pub used already".to_owned(), + )); + }; + let row_id = r.try_get_safeu64(0)?; + sqlx::query("SELECT pg_notify('taler_in', '' || $1)") + .bind(*row_id as i64) + .execute(&self.pool) + .await?; + Ok(AddIncomingResponse { timestamp, row_id }) + } + + async fn add_incoming_kyc(&self, _req: AddKycauthRequest) -> ApiResult<AddKycauthResponse> { + Err(not_implemented( + "depolymerizer-bitcoin does not supports KYC", + )) + } + + fn support_account_check(&self) -> bool { + false + } +} + +pub async fn status_middleware( + State(state): State<Arc<ServerState>>, + request: Request, + next: Next, +) -> Response { + if !state.status.load(Ordering::Relaxed) { + failure_status( + ErrorCode::GENERIC_INTERNAL_INVARIANT_FAILURE, + "Currency backing is compromised until the transaction reappear", + StatusCode::BAD_GATEWAY, + ) + .into_response() + } else { + next.run(request).await + } +} + +/// Listen to backend status change +async fn status_watcher(state: Arc<ServerState>) { + async fn inner(state: &ServerState) -> Result<(), sqlx::error::Error> { + let mut listener = PgListener::connect_with(&state.pool).await?; + listener.listen("status").await?; + loop { + // Sync state + let row = sqlx::query("SELECT value FROM state WHERE name = 'status'") + .fetch_one(&state.pool) + .await?; + let status: &[u8] = row.try_get(0)?; + assert!(status.len() == 1 && status[0] < 2); + state.status.store(status[0] == 1, Ordering::SeqCst); + // Wait for next notification + listener.recv().await?; + } + } + + loop { + if let Err(err) = inner(&state).await { + error!("status-watcher: {}", err); + // TODO better sleep + sleep(Duration::from_secs(5)).await; + } + } +} diff --git a/depolymerizer-bitcoin/src/bin/segwit-demo.rs b/depolymerizer-bitcoin/src/bin/segwit-demo.rs @@ -0,0 +1,88 @@ +use std::str::FromStr; + +use bech32::Hrp; +use bitcoin::{Address, Amount, Network}; +use common::{rand_slice, taler_common::types::base32}; +use depolymerizer_bitcoin::{ + guess_network, rpc_utils, + segwit::{decode_segwit_msg, encode_segwit_addr}, +}; + +pub fn main() { + let address = Address::from_str("tb1qhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v") + .unwrap() + .assume_checked(); + let amount = Amount::from_sat(5000000); + let reserve_pub = "54ZN9AMVN1R0YZ68ZPVHHQA4KZE1V037M05FNMYH4JQ596YAKJEG"; + let btc = amount.to_btc(); + + println!("Ⅰ - Parse payto uri"); + println!("Got payto uri: payto://bitcoin/{address}?amount=BTC:{btc}&subject={reserve_pub}"); + println!("Send {btc} BTC to {address} with reserve public key {reserve_pub}"); + + println!("\nⅡ - Generate fake segwit addresses"); + let decoded: [u8; 32] = base32::decode(reserve_pub.as_bytes()).unwrap(); + println!("Decode reserve public key: 0x{}", hex::encode(&decoded[..])); + let prefix: [u8; 4] = rand_slice(); + println!("Generate random prefix 0x{}", hex::encode(prefix)); + println!( + "Split reserve public key in two:\n0x{}\n0x{}", + hex::encode(&decoded[..16]), + hex::encode(&decoded[16..]) + ); + let mut first_half = [&prefix, &decoded[..16]].concat(); + let mut second_half = [&prefix, &decoded[16..]].concat(); + println!( + "Concatenate random prefix with each reserve public key half:\n0x{}\n0x{}", + hex::encode(&first_half), + hex::encode(&second_half) + ); + first_half[0] &= 0b0111_1111; + second_half[0] |= 0b1000_0000; + println!( + "Set first bit of the first half:\n0x{}\nUnset first bit of the second half:\n0x{}", + hex::encode(&first_half), + hex::encode(&second_half) + ); + // bech32: https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki + let hrp = match guess_network(&address) { + Network::Bitcoin => "bc", + Network::Testnet | Network::Signet => "tb", + Network::Regtest => "bcrt", + _ => unimplemented!(), + }; + let hrp = Hrp::parse(hrp).unwrap(); + let first = encode_segwit_addr(hrp, first_half[..].try_into().unwrap()); + let second = encode_segwit_addr(hrp, second_half[..].try_into().unwrap()); + println!("Encode each half using bech32 to generate a segwit address:\n{first}\n{second}"); + + println!("\nⅢ - Send to many"); + let minimum = rpc_utils::segwit_min_amount().to_btc(); + println!("Send a single bitcoin transaction with the three addresses as recipient as follow:"); + println!( + "\nIn bitcoincore wallet use 'Add Recipient' button to add two additional recipient and copy adresses and amounts" + ); + let first = Address::from_str(&first).unwrap().assume_checked(); + let second = Address::from_str(&second).unwrap().assume_checked(); + for (address, amount) in [(&address, btc), (&first, minimum), (&second, minimum)] { + println!("{address} {amount:.8} BTC"); + } + println!("\nIn Electrum wallet paste the following three lines in 'Pay to' field :"); + for (address, amount) in [(&address, btc), (&first, minimum), (&second, minimum)] { + println!("{address},{amount:.8}"); + } + println!( + "Make sure the amount show 0.10000588 BTC, else you have to change the base unit to BTC" + ); + + let key1 = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; + let key2 = "tb1qzxwu2p7urkqx0gq2ltfazf9w2jdu48ya8qwlm0"; + let key3 = "tb1qzxwu2pef8a224xagwq8hej8akuvd63yluu3wrh"; + let addresses = vec![key1, key2, key3]; + let dec = decode_segwit_msg(&addresses); + + println!( + "Decode reserve public key: 0x{}", + hex::encode(&dec.unwrap()[..]) + ); +} diff --git a/depolymerizer-bitcoin/src/config.rs b/depolymerizer-bitcoin/src/config.rs @@ -0,0 +1,177 @@ +/* + This file is part of TALER + Copyright (C) 2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::net::SocketAddr; + +use bitcoin::Amount; +use common::postgres; +use taler_api::{ + Serve, + config::{ApiCfg, DbCfg}, +}; +use taler_common::{ + config::{Config, ValueErr}, + map_config, + types::{amount::Currency, payto::PaytoURI}, +}; + +use crate::{ + payto::{BtcWallet, FullBtcPayto}, + taler_utils::taler_to_btc, +}; + +pub fn parse_db_cfg(cfg: &Config) -> Result<DbCfg, ValueErr> { + DbCfg::parse(cfg.section("depolymerizer-bitcoindb-postgres")) +} + +pub fn parse_account_payto(cfg: &Config) -> Result<FullBtcPayto, ValueErr> { + let sect = cfg.section("depolymerizer-bitcoin"); + let wallet: BtcWallet = sect.parse("bitcoin wallet address", "WALLET").require()?; + let name = sect.str("NAME").require()?; + + Ok(FullBtcPayto::new(wallet, name)) +} + +#[derive(Debug, Clone)] +pub enum RpcAuth { + Cookie(String), + Basic(String), +} + +pub struct ServeCfg { + pub payto: PaytoURI, + pub serve: Serve, + pub wire_gateway: Option<ApiCfg>, + pub revenue: Option<ApiCfg>, + pub currency: Currency, + pub lifetime: Option<u32>, +} + +impl ServeCfg { + pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let payto = parse_account_payto(cfg)?; + + let sect = cfg.section("depolymerizer-bitcoin-httpd"); + + let lifetime = sect.number("LIFETIME").opt()?.filter(|it| *it != 0); + + let serve = Serve::parse(sect)?; + + let wire_gateway = + ApiCfg::parse(cfg.section("depolymerizer-bitcoin-httpd-wire-gateway-api"))?; + let revenue = ApiCfg::parse(cfg.section("depolymerizer-bitcoin-httpd-revenue-api"))?; + + let sect = cfg.section("depolymerizer-bitcoin"); + Ok(Self { + currency: sect.parse("currency", "CURRENCY").require()?, + lifetime, + payto: payto.as_payto(), + serve, + wire_gateway, + revenue, + }) + } +} + +#[derive(Debug, Clone)] + +pub struct WorkerCfg { + pub confirmation: u32, + pub max_confirmation: u32, + pub bounce_fee: Amount, + pub lifetime: Option<u32>, + pub bump_delay: Option<u32>, + pub db_config: postgres::Config, + pub currency: Currency, + pub rpc_cfg: RpcCfg, + pub wallet_cfg: WalletCfg, +} + +impl WorkerCfg { + pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let currency: Currency = cfg + .section("depolymerizer-bitcoin") + .parse("currency", "CURRENCY") + .require()?; + + let sect = cfg.section("depolymerizer-bitcoin-worker"); + let confirmation = sect.number("CONFIRMATION").require()?; + + Ok(Self { + confirmation, + max_confirmation: confirmation * 2, + bounce_fee: sect + .amount("BOUNCE_FEE", currency.as_ref()) + .opt()? + .map(|it| taler_to_btc(&it)) + .unwrap_or_default(), + lifetime: sect.number("LIFETIME").opt()?.filter(|it| *it != 0), + bump_delay: sect.number("BUMP_DELAY").opt()?.filter(|it| *it != 0), + db_config: cfg + .section("depolymerizer-bitcoindb-postgres") + .parse("Postgres", "CONFIG") + .require() + .unwrap(), + currency, + rpc_cfg: RpcCfg::parse(cfg)?, + wallet_cfg: WalletCfg::parse(cfg)?, + }) + } +} + +#[derive(Debug, Clone)] +pub struct WalletCfg { + pub name: String, + pub password: Option<String>, +} + +impl WalletCfg { + pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let sect = cfg.section("depolymerizer-bitcoin-worker"); + let password = sect.str("PASSWORD").opt()?; + let name = sect.str("WALLET_NAME").require()?; + + Ok(Self { name, password }) + } +} + +#[derive(Debug, Clone)] +pub struct RpcCfg { + pub addr: SocketAddr, + pub auth: RpcAuth, +} + +impl RpcCfg { + pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let sect = cfg.section("depolymerizer-bitcoin-worker"); + + let addr = sect.parse("RPC server address", "RPC_BIND").require()?; + + let auth = map_config!(sect, "auth_method", "RPC_AUTH_METHOD", + "basic" => { + let username = sect.str("RPC_USERNAME").require()?; + let password = sect.str("RPC_PASSWORD").require()?; + Ok(RpcAuth::Basic(format!("{username}:{password}"))) + }, + "cookie" => { + Ok(RpcAuth::Cookie(sect.path("RPC_COOKIE_FILE").require()?)) + } + ) + .require()?; + + Ok(Self { addr, auth }) + } +} diff --git a/depolymerizer-bitcoin/src/fail_point.rs b/depolymerizer-bitcoin/src/fail_point.rs @@ -0,0 +1,31 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub struct Injected(&'static str); + +/// Inject random failure when 'fail' feature is used +#[allow(unused_variables)] +pub fn fail_point(msg: &'static str, prob: f32) -> Result<(), Injected> { + #[cfg(feature = "fail")] + return if common::rand::random::<f32>() < prob { + Err(Injected(msg)) + } else { + Ok(()) + }; + + Ok(()) +} diff --git a/depolymerizer-bitcoin/src/lib.rs b/depolymerizer-bitcoin/src/lib.rs @@ -0,0 +1,159 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +use std::str::FromStr; + +use bitcoin::{Address, Amount, Network, Txid, hashes::hex::FromHex}; +use rpc::{Category, Rpc, Transaction}; +use rpc_utils::{segwit_min_amount, sender_address}; +use segwit::{decode_segwit_msg, encode_segwit_key}; +use taler_common::{api_common::EddsaPublicKey, config::parser::ConfigSource}; + +pub mod api; +pub mod config; +pub mod payto; +pub mod rpc; +pub mod rpc_utils; +pub mod segwit; +pub mod taler_utils; + +pub const CONFIG_SOURCE: ConfigSource = ConfigSource::simple("depolymerizer-bitcoin"); +pub const DB_SCHEMA: &str = "depolymerizer_bitcoin"; + +#[derive(Debug, thiserror::Error)] +pub enum GetSegwitErr { + #[error(transparent)] + Decode(#[from] segwit::DecodeSegWitErr), + #[error(transparent)] + RPC(#[from] rpc::Error), +} + +#[derive(Debug, thiserror::Error)] +pub enum GetOpReturnErr { + #[error("Missing opreturn")] + MissingOpReturn, + #[error(transparent)] + RPC(#[from] rpc::Error), +} + +/// An extended bitcoincore JSON-RPC api client who can send and retrieve metadata with their transaction +impl Rpc { + /// Send a transaction with a 32B key as metadata encoded using fake segwit addresses + pub fn send_segwit_key( + &mut self, + to: &Address, + amount: &Amount, + metadata: &[u8; 32], + ) -> rpc::Result<Txid> { + let network = guess_network(to); + let hrp = match network { + Network::Bitcoin => bech32::hrp::BC, + Network::Testnet | Network::Signet => bech32::hrp::TB, + Network::Regtest => bech32::hrp::BCRT, + _ => unimplemented!(), + }; + let addresses = encode_segwit_key(hrp, metadata); + let addresses = [ + Address::from_str(&addresses[0]).unwrap().assume_checked(), + Address::from_str(&addresses[1]).unwrap().assume_checked(), + ]; + let mut recipients = vec![(to, amount)]; + let min = segwit_min_amount(); + recipients.extend(addresses.iter().map(|addr| (addr, &min))); + self.send_many(recipients) + } + + /// Get detailed information about an in-wallet transaction and it's 32B metadata key encoded using fake segwit addresses + pub fn get_tx_segwit_key( + &mut self, + id: &Txid, + ) -> Result<(Transaction, EddsaPublicKey), GetSegwitErr> { + let full = self.get_tx(id)?; + + let addresses: Vec<String> = full + .decoded + .vout + .iter() + .filter_map(|it| { + it.script_pub_key + .address + .as_ref() + .map(|addr| addr.clone().assume_checked().to_string()) + }) + .collect(); + + let metadata = decode_segwit_msg(&addresses)?; + + Ok((full, metadata)) + } + + /// Get detailed information about an in-wallet transaction and its op_return metadata + pub fn get_tx_op_return( + &mut self, + id: &Txid, + ) -> Result<(Transaction, Vec<u8>), GetOpReturnErr> { + let full = self.get_tx(id)?; + + let op_return_out = full + .decoded + .vout + .iter() + .find(|it| it.script_pub_key.asm.starts_with("OP_RETURN")) + .ok_or(GetOpReturnErr::MissingOpReturn)?; + + let hex = op_return_out.script_pub_key.asm.split_once(' ').unwrap().1; + // Op return payload is always encoded in hexadecimal + let metadata = Vec::from_hex(hex).unwrap(); + + Ok((full, metadata)) + } + + /// Bounce a transaction bask to its sender + /// + /// There is no reliable way to bounce a transaction as you cannot know if the addresses + /// used are shared or come from a third-party service. We only send back to the first input + /// address as a best-effort gesture. + pub fn bounce( + &mut self, + id: &Txid, + bounce_fee: &Amount, + metadata: Option<&[u8]>, + ) -> Result<Txid, rpc::Error> { + let full = self.get_tx(id)?; + let detail = &full.details[0]; + assert!(detail.category == Category::Receive); + + let amount = detail.amount.to_unsigned().unwrap(); + let sender = sender_address(self, &full)?; + let bounce_amount = Amount::from_sat(amount.to_sat().saturating_sub(bounce_fee.to_sat())); + // Send refund making recipient pay the transaction fees + self.send(&sender, &bounce_amount, metadata, true) + } +} + +pub fn guess_network(address: &Address) -> Network { + let addr = address.as_unchecked(); + for network in [ + Network::Bitcoin, + Network::Regtest, + Network::Signet, + Network::Regtest, + ] { + if addr.is_valid_for_network(network) { + return network; + } + } + unreachable!() +} diff --git a/depolymerizer-bitcoin/src/loops.rs b/depolymerizer-bitcoin/src/loops.rs @@ -0,0 +1,38 @@ +/* + This file is part of TALER + Copyright (C) 2022 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use common::postgres; +use depolymerizer_bitcoin::rpc; + +use crate::fail_point::Injected; + +pub mod analysis; +pub mod watcher; +pub mod worker; + +#[derive(Debug, thiserror::Error)] +pub enum LoopError { + #[error(transparent)] + Rpc(#[from] rpc::Error), + #[error("DB {0}")] + DB(#[from] postgres::Error), + #[error("Another btc-wire process is running concurrently")] + Concurrency, + #[error(transparent)] + Injected(#[from] Injected), +} + +pub type LoopResult<T> = Result<T, LoopError>; diff --git a/depolymerizer-bitcoin/src/loops/analysis.rs b/depolymerizer-bitcoin/src/loops/analysis.rs @@ -0,0 +1,42 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +use depolymerizer_bitcoin::rpc::{ChainTipsStatus, Rpc}; +use tracing::warn; + +use super::LoopResult; + +/// Analyse blockchain behavior and return the new confirmation delay +pub fn analysis(rpc: &mut Rpc, current: u32, max: u32) -> LoopResult<u32> { + // Get biggest known valid fork + let fork = rpc + .get_chain_tips()? + .into_iter() + .filter_map(|t| (t.status == ChainTipsStatus::ValidFork).then_some(t.length)) + .max() + .unwrap_or(0) as u32; + // If new fork is bigger than what current confirmation delay protect against + if fork >= current { + // Limit confirmation growth + let new_conf = fork.saturating_add(1).min(max); + warn!( + "analysis: found dangerous fork of {fork} blocks, adapt confirmation to {new_conf} blocks capped at {max}, you should update taler.conf" + ); + return Ok(new_conf); + } + + // TODO smarter analysis: suspicious transaction value, limit wire bitcoin throughput + Ok(current) +} diff --git a/depolymerizer-bitcoin/src/loops/watcher.rs b/depolymerizer-bitcoin/src/loops/watcher.rs @@ -0,0 +1,44 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +use common::{ + postgres, + reconnect::{client_jitter, connect_db}, +}; +use depolymerizer_bitcoin::{DB_SCHEMA, config::RpcCfg, rpc::rpc_common}; +use std::time::Duration; +use tracing::error; + +use super::LoopResult; + +/// Wait for new block and notify arrival with postgreSQL notifications +pub fn watcher(rpc_cfg: &RpcCfg, db_cfg: &postgres::Config) { + let mut jitter = client_jitter(); + loop { + let result: LoopResult<()> = (|| { + let mut rpc = rpc_common(rpc_cfg)?; + let mut db = connect_db(db_cfg, DB_SCHEMA)?; + loop { + db.execute("NOTIFY new_block", &[])?; + rpc.wait_for_new_block()?; + jitter.reset(); + } + })(); + if let Err(e) = result { + error!("watcher: {e}"); + std::thread::sleep(Duration::from_millis(jitter.next() as u64)); + } + } +} diff --git a/depolymerizer-bitcoin/src/loops/worker.rs b/depolymerizer-bitcoin/src/loops/worker.rs @@ -0,0 +1,636 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +use std::{ + collections::HashMap, + fmt::Write, + time::{Duration, SystemTime}, +}; + +use bitcoin::{Amount as BtcAmount, BlockHash, Txid, hashes::Hash}; +use common::{ + log::OrFail, + metadata::OutMetadata, + postgres, + reconnect::{client_jitter, connect_db}, + sql::{sql_base_32, sql_url}, + status::{BounceStatus, DebitStatus}, + taler_common::{api_common::ShortHashCode, types::timestamp::Timestamp}, +}; +use depolymerizer_bitcoin::{ + DB_SCHEMA, GetOpReturnErr, GetSegwitErr, + rpc::{self, Category, ErrorCode, Rpc, Transaction, rpc_wallet}, + rpc_utils::sender_address, + taler_utils::btc_to_taler, +}; +use postgres::{Client, fallible_iterator::FallibleIterator}; +use tracing::{error, info, warn}; + +use crate::{ + WorkerCfg, + fail_point::fail_point, + sql::{sql_addr, sql_btc_amount, sql_txid}, +}; + +use super::{LoopError, LoopResult, analysis::analysis}; + +/// Synchronize local db with blockchain and perform transactions +pub fn worker(mut state: WorkerCfg) { + let mut jitter = client_jitter(); + let mut lifetime = state.lifetime; + let mut status = true; + let mut skip_notification = true; + + loop { + let result: LoopResult<()> = (|| { + // Connect + let rpc = &mut rpc_wallet(&state.rpc_cfg, &state.wallet_cfg)?; + let db = &mut connect_db(&state.db_config, DB_SCHEMA)?; + + // It is not possible to atomically update the blockchain and the database. + // When we failed to sync the database and the blockchain state we rely on + // sync_chain to recover the lost updates. + // When this function is running concurrently, it not possible to known another + // execution has failed, and this can lead to a transaction being sent multiple time. + // To ensure only a single version of this function is running at a given time we rely + // on postgres advisory lock + + // Take the lock + let row = db.query_one("SELECT pg_try_advisory_lock(42)", &[])?; + let locked: bool = row.get(0); + if !locked { + return Err(LoopError::Concurrency); + } + + loop { + // Listen to all channels + db.batch_execute("LISTEN new_block; LISTEN new_tx")?; + // Wait for the next notification + { + let mut ntf = db.notifications(); + if !skip_notification && ntf.is_empty() { + // Block until next notification + ntf.blocking_iter().next()?; + } + // Conflate all notifications + let mut iter = ntf.iter(); + while iter.next()?.is_some() {} + } + + // Check lifetime + if let Some(nb) = lifetime.as_mut() { + if *nb == 0 { + info!("Reach end of lifetime"); + return Ok(()); + } else { + *nb -= 1; + } + } + + // Perform analysis + state.confirmation = analysis(rpc, state.confirmation, state.max_confirmation)?; + + // Sync chain + if let Some(stuck) = sync_chain(rpc, db, &state, &mut status)? { + // As we are now in sync with the blockchain if a transaction has Requested status it have not been sent + + // Send requested debits + while debit(db, rpc, &state)? {} + + // Bump stuck transactions + for id in stuck { + let bump = rpc.bump_fee(&id)?; + fail_point("(injected) fail bump", 0.3)?; + let row = db.query_one( + "UPDATE tx_out SET txid=$1 WHERE txid=$2 RETURNING wtid", + &[ + &bump.txid.as_byte_array().as_slice(), + &id.as_byte_array().as_slice(), + ], + )?; + let wtid: ShortHashCode = sql_base_32(&row, 0); + info!(">> (bump) {wtid} replace {id} with {}", bump.txid); + } + + // Send requested bounce + while bounce(db, rpc, &state.bounce_fee)? {} + } + + skip_notification = false; + jitter.reset(); + } + })(); + if let Err(e) = result { + error!("worker: {e}"); + // When we catch an error, we sometimes want to retry immediately (eg. reconnect to RPC or DB). + // Bitcoin error codes are generic. We need to match the msg to get precise ones. Some errors + // can resolve themselves when a new block is mined (new fees, new transactions). Our simple + // approach is to wait for the next loop when an RPC error is caught to prevent endless logged errors. + skip_notification = matches!( + e, + LoopError::Rpc(rpc::Error::Transport(_)) + | LoopError::DB(_) + | LoopError::Injected(_) + ); + std::thread::sleep(Duration::from_millis(jitter.next() as u64)); + } else { + return; + } + } +} + +/// Retrieve last stored hash +fn last_hash(db: &mut Client) -> Result<BlockHash, postgres::Error> { + let row = db.query_one("SELECT value FROM state WHERE name='last_hash'", &[])?; + Ok(BlockHash::from_slice(row.get(0)).unwrap()) +} + +/// Parse new transactions, return stuck transactions if the database is up to date with the latest mined block +fn sync_chain( + rpc: &mut Rpc, + db: &mut Client, + state: &WorkerCfg, + status: &mut bool, +) -> LoopResult<Option<Vec<Txid>>> { + // Get stored last_hash + let last_hash = last_hash(db)?; + // Get the current confirmation delay + let conf_delay = state.confirmation; + + // Get a set of transactions ids to parse + let (txs, removed, lastblock): ( + HashMap<Txid, (Category, i32)>, + HashMap<Txid, (Category, i32)>, + BlockHash, + ) = { + // Get all transactions made since this block + let list = rpc.list_since_block(Some(&last_hash), conf_delay)?; + // Only keep ids and category + let txs = list + .transactions + .into_iter() + .map(|tx| (tx.txid, (tx.category, tx.confirmations))) + .collect(); + let removed = list + .removed + .into_iter() + .map(|tx| (tx.txid, (tx.category, tx.confirmations))) + .collect(); + (txs, removed, list.lastblock) + }; + + // Check if a confirmed incoming transaction have been removed by a blockchain reorganization + let new_status = sync_chain_removed(&txs, &removed, rpc, db, conf_delay as i32)?; + + // Sync status with database + if *status != new_status { + let mut tx = db.transaction()?; + tx.execute( + "UPDATE state SET value=$1 WHERE name='status'", + &[&[new_status as u8].as_slice()], + )?; + tx.execute("NOTIFY status", &[])?; + tx.commit()?; + *status = new_status; + if new_status { + info!("Recovered lost transactions"); + } + } + if !new_status { + return Ok(None); + } + + let mut stuck = vec![]; + + for (id, (category, confirmations)) in txs { + match category { + Category::Send => { + if sync_chain_outgoing(&id, confirmations, rpc, db, state)? { + stuck.push(id); + } + } + Category::Receive if confirmations >= conf_delay as i32 => { + sync_chain_incoming_confirmed(&id, rpc, db, state)? + } + _ => { + // Ignore coinbase and unconfirmed send transactions + } + } + } + + // Move last_hash forward + db.execute( + "UPDATE state SET value=$1 WHERE name='last_hash' AND value=$2", + &[ + &lastblock.as_byte_array().as_slice(), + &last_hash.as_byte_array().as_slice(), + ], + )?; + + Ok(Some(stuck)) +} + +/// Sync database with removed transactions, return false if bitcoin backing is compromised +fn sync_chain_removed( + txs: &HashMap<Txid, (Category, i32)>, + removed: &HashMap<Txid, (Category, i32)>, + rpc: &mut Rpc, + db: &mut Client, + min_confirmations: i32, +) -> LoopResult<bool> { + // A removed incoming transaction is a correctness issues in only two cases: + // - it is a confirmed credit registered in the database + // - it is an invalid transactions already bounced + // Those two cases can compromise bitcoin backing + // Removed outgoing transactions will be retried automatically by the node + + let mut blocking_debit = Vec::new(); + let mut blocking_bounce = Vec::new(); + + // Only keep incoming transaction that are not reconfirmed + // TODO study risk of accepting only mined transactions for faster recovery + for (id, _) in removed.iter().filter(|(id, (cat, _))| { + *cat == Category::Receive + && txs + .get(*id) + .map(|(_, confirmations)| *confirmations < min_confirmations) + .unwrap_or(true) + }) { + match rpc.get_tx_segwit_key(id) { + Ok((full, key)) => { + // Credits are only problematic if not reconfirmed and stored in the database + if db + .query_opt( + "SELECT 1 FROM tx_in WHERE reserve_pub=$1", + &[&key.as_slice()], + )? + .is_some() + { + let debit_addr = sender_address(rpc, &full)?; + blocking_debit.push((key, id, debit_addr)); + } + } + Err(err) => match err { + GetSegwitErr::Decode(_) => { + // Invalid tx are only problematic if already bounced + if let Some(row) = db.query_opt( + "SELECT txid FROM bounce WHERE bounced=$1 AND txid IS NOT NULL", + &[&id.as_byte_array().as_slice()], + )? { + blocking_bounce.push((sql_txid(&row, 0), id)); + } else { + // Remove transaction from bounce table + db.execute( + "DELETE FROM bounce WHERE bounced=$1", + &[&id.as_byte_array().as_slice()], + )?; + } + } + GetSegwitErr::RPC(it) => return Err(it.into()), + }, + } + } + + if !blocking_bounce.is_empty() || !blocking_debit.is_empty() { + let mut buf = "The following transaction have been removed from the blockchain, bitcoin backing is compromised until the transaction reappear:".to_string(); + for (key, id, addr) in blocking_debit { + write!(&mut buf, "\n\tcredit {key} in {id} from {addr}",).unwrap(); + } + for (id, bounced) in blocking_bounce { + write!(&mut buf, "\n\tbounced {id} in {bounced}").unwrap(); + } + error!("{buf}"); + Ok(false) + } else { + Ok(true) + } +} + +/// Sync database with an incoming confirmed transaction +fn sync_chain_incoming_confirmed( + id: &Txid, + rpc: &mut Rpc, + db: &mut Client, + state: &WorkerCfg, +) -> Result<(), LoopError> { + match rpc.get_tx_segwit_key(id) { + Ok((full, reserve_pub)) => { + // Store transactions in database + let debit_addr = sender_address(rpc, &full)?; + let credit_addr = full.details[0].address.clone().unwrap().assume_checked(); + let amount = btc_to_taler(&full.amount, &state.currency); + let update = db.query_opt("INSERT INTO tx_in (received, amount, reserve_pub, debit_acc, credit_acc) VALUES ($1, ($2, $3)::taler_amount, $4, $5, $6) ON CONFLICT (reserve_pub) DO NOTHING RETURNING id", &[ + &((full.time * 1000000) as i64), &(amount.val as i64), &(amount.frac as i32), &reserve_pub.as_slice(), &debit_addr.to_string(), &credit_addr.to_string() + ])?; + if let Some(row) = update { + info!("<< {amount} {reserve_pub} in {id} from {debit_addr}"); + let id: i64 = row.try_get(0)?; + db.execute("SELECT pg_notify('taler_in', $1)", &[&id.to_string()])?; + } + } + Err(err) => match err { + GetSegwitErr::Decode(_) => { + // If encoding is wrong request a bounce + db.execute( + "INSERT INTO bounce (created, bounced) VALUES ($1, $2) ON CONFLICT (bounced) DO NOTHING", + &[&Timestamp::now().as_sql_micros(), &id.as_byte_array().as_slice()], + )?; + } + GetSegwitErr::RPC(e) => return Err(e.into()), + }, + } + Ok(()) +} + +/// Sync database with a debit transaction, return true if stuck +fn sync_chain_debit( + id: &Txid, + full: &Transaction, + wtid: &ShortHashCode, + rpc: &mut Rpc, + db: &mut Client, + confirmations: i32, + state: &WorkerCfg, +) -> LoopResult<bool> { + let credit_addr = full.details[0].address.clone().unwrap().assume_checked(); + let amount = btc_to_taler(&full.amount, &state.currency); + + if confirmations < 0 { + if full.replaced_by_txid.is_none() { + // Handle conflicting tx + let nb_row = db.execute( + "UPDATE tx_out SET status=$1, txid=NULL where txid=$2", + &[ + &(DebitStatus::Requested as i16), + &id.as_byte_array().as_slice(), + ], + )?; + if nb_row > 0 { + warn!(">> (conflict) {wtid} in {id} to {credit_addr}"); + } + } + } else { + // Get previous out tx + let row = db.query_opt( + "SELECT id,status,txid FROM tx_out WHERE wtid=$1 FOR UPDATE", + &[&wtid.as_slice()], + )?; + if let Some(row) = row { + // If already in database, sync status + let row_id: i64 = row.get(0); + let status: i16 = row.get(1); + match DebitStatus::try_from(status as u8).unwrap() { + DebitStatus::Requested => { + let nb_row = db.execute( + "UPDATE tx_out SET status=$1, txid=$2 WHERE id=$3 AND status=$4", + &[ + &(DebitStatus::Sent as i16), + &id.as_byte_array().as_slice(), + &row_id, + &status, + ], + )?; + if nb_row > 0 { + warn!(">> (recovered) {amount} {wtid} in {id} to {credit_addr}"); + } + } + DebitStatus::Sent => { + if let Some(txid) = full.replaces_txid { + let stored_id = sql_txid(&row, 2); + if txid == stored_id { + let nb_row = db.execute( + "UPDATE tx_out SET txid=$1 WHERE txid=$2", + &[ + &id.as_byte_array().as_slice(), + &txid.as_byte_array().as_slice(), + ], + )?; + if nb_row > 0 { + info!(">> (recovered) {wtid} replace {txid} with {id}",); + } + } + } + } + } + } else { + // Else add to database + let debit_addr = sender_address(rpc, full)?; + let update = db.query_opt( + "INSERT INTO tx_out (created, amount, wtid, debit_acc, credit_acc, exchange_url, status, txid, request_uid) VALUES ($1, ($2, $3)::taler_amount, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (wtid) DO NOTHING RETURNING id", + &[&((full.time*1000000) as i64), &(amount.val as i64), &(amount.frac as i32), &wtid.as_slice(), &debit_addr.to_string(), &credit_addr.to_string(), &"https://exchange.url.TODO/", &(DebitStatus::Sent as i16), &id.as_byte_array().as_slice(), &None::<&[u8]>], + )?; + if let Some(row) = update { + warn!(">> (onchain) {amount} {wtid} in {id} to {credit_addr}",); + let id: i64 = row.try_get(0)?; + db.execute("SELECT pg_notify('taler_out', $1)", &[&id.to_string()])?; + } + } + + // Check if stuck + if let Some(delay) = state.bump_delay { + if confirmations == 0 && full.replaced_by_txid.is_none() { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + if now - full.time > delay as u64 { + return Ok(true); + } + } + } + } + Ok(false) +} + +/// Sync database with an outgoing bounce transaction +fn sync_chain_bounce( + id: &Txid, + bounced: &Txid, + db: &mut Client, + confirmations: i32, +) -> LoopResult<()> { + if confirmations < 0 { + // Handle conflicting tx + let nb_row = db.execute( + "UPDATE bounce SET status=$1, txid=NULL where txid=$2", + &[ + &(BounceStatus::Requested as i16), + &id.as_byte_array().as_slice(), + ], + )?; + if nb_row > 0 { + warn!("|| (conflict) {bounced} in {id}"); + } + } else { + // Get previous bounce + let row = db.query_opt( + "SELECT id, status FROM bounce WHERE bounced=$1", + &[&bounced.as_byte_array().as_slice()], + )?; + if let Some(row) = row { + // If already in database, sync status + let row_id: i64 = row.get(0); + let status: i16 = row.get(1); + match BounceStatus::try_from(status as u8).unwrap() { + BounceStatus::Requested => { + let nb_row = db.execute( + "UPDATE bounce SET status=$1, txid=$2 WHERE id=$3 AND status=$4", + &[ + &(BounceStatus::Sent as i16), + &id.as_byte_array().as_slice(), + &row_id, + &status, + ], + )?; + if nb_row > 0 { + warn!("|| (recovered) {bounced} in {id}"); + } + } + BounceStatus::Ignored => { + error!("watcher: ignored bounce {bounced} found in chain at {id}") + } + BounceStatus::Sent => { /* Status is correct */ } + } + } else { + // Else add to database + let nb = db.execute( + "INSERT INTO bounce (created, bounced, txid, status) VALUES ($1, $2, $3, $4) ON CONFLICT (txid) DO NOTHING", + &[&Timestamp::now().as_sql_micros(), &bounced.as_byte_array().as_slice(), &id.as_byte_array().as_slice(), &(BounceStatus::Sent as i16)], + )?; + if nb > 0 { + warn!("|| (onchain) {bounced} in {id}"); + } + } + } + + Ok(()) +} + +/// Sync database with an outgoing transaction, return true if stuck +fn sync_chain_outgoing( + id: &Txid, + confirmations: i32, + rpc: &mut Rpc, + db: &mut Client, + state: &WorkerCfg, +) -> LoopResult<bool> { + match rpc + .get_tx_op_return(id) + .map(|(full, bytes)| (full, OutMetadata::decode(&bytes))) + { + Ok((full, Ok(info))) => match info { + OutMetadata::Debit { wtid, .. } => { + return sync_chain_debit(id, &full, &wtid, rpc, db, confirmations, state); + } + OutMetadata::Bounce { bounced } => { + sync_chain_bounce(id, &Txid::from_byte_array(bounced), db, confirmations)? + } + }, + Ok((_, Err(e))) => warn!("send: decode-info {id} - {e}"), + Err(e) => match e { + GetOpReturnErr::MissingOpReturn => { /* Ignore */ } + GetOpReturnErr::RPC(e) => return Err(e)?, + }, + } + Ok(false) +} + +/// Send a debit transaction on the blockchain, return false if no more requested transactions are found +fn debit(db: &mut Client, rpc: &mut Rpc, state: &WorkerCfg) -> LoopResult<bool> { + // We rely on the advisory lock to ensure we are the only one sending transactions + let row = db.query_opt( + "SELECT id, (amount).val, (amount).frac, wtid, credit_acc, exchange_url FROM tx_out WHERE status=$1 ORDER BY created LIMIT 1", + &[&(DebitStatus::Requested as i16)], + )?; + if let Some(row) = &row { + let id: i64 = row.get(0); + let amount = sql_btc_amount(row, 1, &state.currency); + let wtid: ShortHashCode = sql_base_32(row, 3); + let addr = sql_addr(row, 4); + let url = sql_url(row, 5); + let metadata = OutMetadata::Debit { + wtid: wtid.clone(), + url, + }; + + let tx_id = rpc.send( + &addr, + &amount, + Some(&metadata.encode().or_fail(|e| format!("{e}"))), + false, + )?; + fail_point("(injected) fail debit", 0.3)?; + db.execute( + "UPDATE tx_out SET status=$1, txid=$2 WHERE id=$3", + &[ + &(DebitStatus::Sent as i16), + &tx_id.as_byte_array().as_slice(), + &id, + ], + )?; + let amount = btc_to_taler(&amount.to_signed().unwrap(), &state.currency); + info!(">> {amount} {wtid} in {tx_id} to {addr}"); + } + Ok(row.is_some()) +} + +/// Bounce a transaction on the blockchain, return false if no more requested transactions are found +fn bounce(db: &mut Client, rpc: &mut Rpc, fee: &BtcAmount) -> LoopResult<bool> { + // We rely on the advisory lock to ensure we are the only one sending transactions + let row = db.query_opt( + "SELECT id, bounced FROM bounce WHERE status=$1 ORDER BY created LIMIT 1", + &[&(BounceStatus::Requested as i16)], + )?; + if let Some(row) = &row { + let id: i64 = row.get(0); + let bounced: Txid = sql_txid(row, 1); + let metadata = OutMetadata::Bounce { + bounced: *bounced.as_byte_array(), + }; + + match rpc.bounce( + &bounced, + fee, + Some(&metadata.encode().or_fail(|e| format!("{e}"))), + ) { + Ok(it) => { + fail_point("(injected) fail bounce", 0.3)?; + db.execute( + "UPDATE bounce SET txid=$1, status=$2 WHERE id=$3", + &[ + &it.as_byte_array().as_slice(), + &(BounceStatus::Sent as i16), + &id, + ], + )?; + info!("|| {bounced} in {it}"); + } + Err(err) => match err { + rpc::Error::RPC { + code: ErrorCode::RpcWalletInsufficientFunds | ErrorCode::RpcWalletError, + msg, + } => { + db.execute( + "UPDATE bounce SET status=$1 WHERE id=$2", + &[&(BounceStatus::Ignored as i16), &id], + )?; + info!("|| (ignore) {bounced} because {msg}"); + } + e => Err(e)?, + }, + } + } + Ok(row.is_some()) +} diff --git a/depolymerizer-bitcoin/src/main.rs b/depolymerizer-bitcoin/src/main.rs @@ -0,0 +1,193 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +use axum::{Router, middleware}; +use bitcoin::hashes::Hash; +use clap::Parser; +use common::named_spawn; +use depolymerizer_bitcoin::{ + CONFIG_SOURCE, DB_SCHEMA, + api::{ServerState, status_middleware}, + config::{ServeCfg, WorkerCfg, parse_db_cfg}, + rpc::Rpc, +}; +use taler_api::api::TalerRouter as _; +use taler_common::{ + CommonArgs, + cli::{ConfigCmd, long_version}, + config::Config, + db::{dbinit, pool}, + taler_main, +}; +use tracing::info; + +use crate::loops::{watcher::watcher, worker::worker}; + +mod fail_point; +mod loops; +mod sql; + +/// Taler adapter for bitcoincore +#[derive(clap::Parser, Debug)] +#[command(long_version = long_version(), about, long_about = None)] +struct Args { + #[clap(flatten)] + common: CommonArgs, + #[clap(subcommand)] + cmd: Command, +} + +#[derive(clap::Subcommand, Debug)] +enum Command { + /// Initialize btc-wire database + Dbinit { + /// Reset database (DANGEROUS: All existing data is lost) + #[clap(long, short)] + reset: bool, + }, + /// TODO + Setup { + #[clap(long, short)] + reset: bool, + }, + /// Run btc-wire worker + Worker { + /// Execute once and return + #[clap(long, short)] + transient: bool, + }, + /// Run btc-wire HTTP server + Serve { + /// Check whether an API is in use (if it's useful to start the HTTP + /// server). Exit with 0 if at least one API is enabled, otherwise 1 + #[clap(long)] + check: bool, + }, + #[command(subcommand)] + Config(ConfigCmd), +} + +/// TODO support external signer https://github.com/bitcoin/bitcoin/blob/master/doc/external-signer.md + +async fn app(args: Args, cfg: Config) -> anyhow::Result<()> { + match args.cmd { + Command::Dbinit { reset } => { + let cfg = parse_db_cfg(&cfg)?; + let pool = pool(cfg.cfg, DB_SCHEMA).await?; + let mut conn = pool.acquire().await?; + dbinit( + &mut conn, + cfg.sql_dir.as_ref(), + CONFIG_SOURCE.component_name, + reset, + ) + .await?; + } + Command::Setup { reset } => { + info!("Connect to bitcoind"); + let state = WorkerCfg::parse(&cfg)?; + let mut rpc = Rpc::wallet(&state.rpc_cfg, &state.wallet_cfg.name)?; + let info = rpc.get_blockchain_info()?; + info!("Running on {} chain", info.chain); + + #[cfg(feature = "fail")] + if info.chain != "regtest" { + anyhow::bail!("Running with random failures is unsuitable for production"); + } + let genesis_hash = rpc.get_genesis().unwrap(); + // TODO wait for the blockchain to sync + // TODO Check wire wallet own config PAYTO address + + info!("Check wallet"); + rpc.load_wallet(&state.wallet_cfg.name)?; + if let Some(password) = &state.wallet_cfg.password { + rpc.unlock_wallet(password)?; + } + + info!("Setup database state"); + let db_cfg = parse_db_cfg(&cfg)?; + let pool = pool(db_cfg.cfg, DB_SCHEMA).await?; + + // Init status to true + sqlx::query("INSERT INTO state (name, value) VALUES ('status', $1) ON CONFLICT (name) DO NOTHING") + .bind([1u8]) + .execute( &pool).await?; + sqlx::query("INSERT INTO state (name, value) VALUES ('last_hash', $1) ON CONFLICT (name) DO NOTHING") + .bind(genesis_hash.as_byte_array().as_slice()) + .execute( &pool).await?; + // TODO reset ? + + println!("Database initialised"); + } + Command::Worker { transient } => { + let state = WorkerCfg::parse(&cfg)?; + + #[cfg(feature = "fail")] + tracing::warn!("Running with random failures"); + // TODO Check wire wallet own config PAYTO address + + named_spawn("worker", move || { + let tmp = state.clone(); + named_spawn("watcher", move || watcher(&tmp.rpc_cfg, &tmp.db_config)); + worker(state) + }) + .join() + .unwrap(); + + info!("btc-wire stopped"); + } + Command::Serve { check } => { + if check { + let cfg = ServeCfg::parse(&cfg)?; + if cfg.revenue.is_none() && cfg.wire_gateway.is_none() { + std::process::exit(1); + } + } else { + let db = parse_db_cfg(&cfg)?; + let pool = pool(db.cfg, DB_SCHEMA).await?; + let cfg = ServeCfg::parse(&cfg)?; + let api = ServerState::start(pool, cfg.payto, cfg.currency).await; + let mut router = Router::new(); + + if let Some(cfg) = cfg.wire_gateway { + router = router.wire_gateway(api.clone(), cfg.auth); + } else { + panic!("lol") + } + + /*if let Some(cfg) = cfg.revenue { + router = router.revenue(api, cfg.auth); + }*/ + // TODO http lifetime + router + .layer(middleware::from_fn_with_state( + api.clone(), + status_middleware, + )) + .serve(cfg.serve, cfg.lifetime) + .await?; + } + } + Command::Config(cfg_cmd) => cfg_cmd.run(cfg)?, + } + Ok(()) +} + +fn main() { + let args = Args::parse(); + taler_main(CONFIG_SOURCE, args.common.clone(), |cfg| async move { + app(args, cfg).await + }) +} diff --git a/depolymerizer-bitcoin/src/payto.rs b/depolymerizer-bitcoin/src/payto.rs @@ -0,0 +1,70 @@ +/* + This file is part of TALER + Copyright (C) 2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::str::FromStr; + +use bitcoin::Address; +use taler_common::types::payto::{FullPayto, Payto, PaytoErr, PaytoImpl, PaytoURI, TransferPayto}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BtcWallet(pub Address); + +const BITCOIN: &str = "bitcoin"; + +#[derive(Debug, thiserror::Error)] +#[error("missing wallet addr in path")] +pub struct MissingAddr; + +impl PaytoImpl for BtcWallet { + fn as_payto(&self) -> PaytoURI { + PaytoURI::from_parts(BITCOIN, format_args!("/{}", self.0)) + } + + fn parse(raw: &PaytoURI) -> Result<Self, PaytoErr> { + let url = raw.as_ref(); + if url.domain() != Some(BITCOIN) { + return Err(PaytoErr::UnsupportedKind( + BITCOIN, + url.domain().unwrap_or_default().to_owned(), + )); + } + let Some(mut segments) = url.path_segments() else { + return Err(PaytoErr::custom(MissingAddr)); + }; + let Some(first) = segments.next() else { + return Err(PaytoErr::custom(MissingAddr)); + }; + let address = Address::from_str(first).map_err(PaytoErr::custom)?; + Ok(Self(address.assume_checked())) + } +} + +impl FromStr for BtcWallet { + type Err = bitcoin::address::ParseError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + Ok(Self(Address::from_str(s)?.assume_checked())) + } +} + +/// Parse a bitcoin payto URI, panic if malformed +pub fn btc_payto(url: impl AsRef<str>) -> FullBtcPayto { + url.as_ref().parse().expect("invalid btc payto") +} + +pub type BtcPayto = Payto<BtcWallet>; +pub type FullBtcPayto = FullPayto<BtcWallet>; +pub type TransferBtcPayto = TransferPayto<BtcWallet>; diff --git a/depolymerizer-bitcoin/src/rpc.rs b/depolymerizer-bitcoin/src/rpc.rs @@ -0,0 +1,623 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +//! This is a very simple RPC client designed only for a specific bitcoind version +//! and to use on an secure localhost connection to a trusted node +//! +//! No http format or body length check as we trust the node output +//! No asynchronous request as bitcoind put requests in a queue and process +//! them synchronously and we do not want to fill this queue +//! +//! We only parse the thing we actually use, this reduce memory usage and +//! make our code more compatible with future deprecation +//! +//! bitcoincore RPC documentation: <https://bitcoincore.org/en/doc/23.0.0/> + +use base64::Engine; +use base64::prelude::BASE64_STANDARD; +use bitcoin::{Address, Amount, BlockHash, SignedAmount, Txid, address::NetworkUnchecked}; +use serde_json::{Value, json}; +use std::{ + fmt::Debug, + io::{self, BufRead, BufReader, Write}, + net::TcpStream, + time::{Duration, Instant}, +}; + +use crate::config::{RpcAuth, RpcCfg, WalletCfg}; + +/// Create a rpc connection with an unlocked wallet +pub fn rpc_wallet(config: &RpcCfg, wallet: &WalletCfg) -> Result<Rpc> { + let mut rpc = Rpc::wallet(config, &wallet.name)?; + rpc.load_wallet(&wallet.name)?; + if let Some(password) = &wallet.password { + rpc.unlock_wallet(password)?; + } + Ok(rpc) +} + +/// Create a rpc connection +pub fn rpc_common(config: &RpcCfg) -> Result<Rpc> { + Ok(Rpc::common(config)?) +} + +#[derive(Debug, serde::Serialize)] +struct RpcRequest<'a, T: serde::Serialize> { + method: &'a str, + id: u64, + params: &'a T, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +enum RpcResponse<T> { + RpcResponse { + result: Option<T>, + error: Option<RpcError>, + id: u64, + }, + Error(String), +} + +#[derive(Debug, serde::Deserialize)] +struct RpcError { + code: ErrorCode, + message: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("IO: {0:?}")] + Transport(#[from] std::io::Error), + #[error("RPC: {code:?} - {msg}")] + RPC { code: ErrorCode, msg: String }, + #[error("BTC: {0}")] + Bitcoin(String), + #[error("JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("Null rpc, no result or error")] + Null, +} + +pub type Result<T> = std::result::Result<T, Error>; + +const EMPTY: [(); 0] = []; + +fn expect_null(result: Result<()>) -> Result<()> { + match result { + Err(Error::Null) => Ok(()), + i => i, + } +} + +/// Bitcoin RPC connection +pub struct Rpc { + last_call: Instant, + path: String, + id: u64, + cookie: String, + conn: BufReader<TcpStream>, + buf: Vec<u8>, +} + +impl Rpc { + /// Start a RPC connection + pub fn common(cfg: &RpcCfg) -> io::Result<Self> { + Self::new(cfg, None) + } + + /// Start a wallet RPC connection + pub fn wallet(cfg: &RpcCfg, wallet: &str) -> io::Result<Self> { + Self::new(cfg, Some(wallet)) + } + + fn new(cfg: &RpcCfg, wallet: Option<&str>) -> io::Result<Self> { + let path = if let Some(wallet) = wallet { + format!("/wallet/{wallet}") + } else { + String::from("/") + }; + + // TODO error + let token = match &cfg.auth { + RpcAuth::Basic(s) => s.as_bytes().to_vec(), + RpcAuth::Cookie(path) => std::fs::read(path)?, + }; + // Open connection + let sock = TcpStream::connect_timeout(&cfg.addr, Duration::from_secs(5))?; + let conn = BufReader::new(sock); + + Ok(Self { + last_call: Instant::now(), + path, + id: 0, + cookie: format!("Basic {}", BASE64_STANDARD.encode(&token)), + conn, + buf: Vec::new(), + }) + } + + fn call<T>(&mut self, method: &str, params: &impl serde::Serialize) -> Result<T> + where + T: serde::de::DeserializeOwned + Debug, + { + let request = RpcRequest { + method, + id: self.id, + params, + }; + + // Serialize the body first so we can set the Content-Length header. + let body = serde_json::to_vec(&request)?; + let buf = &mut self.buf; + buf.clear(); + // Write HTTP request + { + let sock = self.conn.get_mut(); + // Send HTTP request + writeln!(buf, "POST {} HTTP/1.1\r", self.path)?; + // Write headers + writeln!(buf, "Accept: application/json-rpc\r")?; + writeln!(buf, "Authorization: {}\r", self.cookie)?; + writeln!(buf, "Content-Type: application/json-rpc\r")?; + writeln!(buf, "Content-Length: {}\r", body.len())?; + // Write separator + writeln!(buf, "\r")?; + sock.write_all(buf)?; + buf.clear(); + // Write body + sock.write_all(&body)?; + sock.flush()?; + } + // Skip response + let sock = &mut self.conn; + loop { + let amount = sock.read_until(b'\n', buf)?; + let sep = buf[..amount] == [b'\r', b'\n']; + buf.clear(); + if sep { + break; + } + self.last_call = Instant::now(); + } + // Read body + let amount = sock.read_until(b'\n', buf)?; + let response: RpcResponse<T> = serde_json::from_slice(&buf[..amount])?; + match response { + RpcResponse::RpcResponse { result, error, id } => { + assert_eq!(self.id, id); + self.id += 1; + if let Some(ok) = result { + Ok(ok) + } else { + Err(match error { + Some(err) => Error::RPC { + code: err.code, + msg: err.message, + }, + None => Error::Null, + }) + } + } + RpcResponse::Error(msg) => Err(Error::Bitcoin(msg)), + } + } + + /* ----- Wallet management ----- */ + + /// Create encrypted native bitcoin wallet + pub fn create_wallet(&mut self, name: &str, passwd: &str) -> Result<Wallet> { + self.call("createwallet", &(name, (), (), passwd, (), true)) + } + + /// Load existing wallet + pub fn load_wallet(&mut self, name: &str) -> Result<Wallet> { + match self.call("loadwallet", &[name]) { + Err(Error::RPC { + code: ErrorCode::RpcWalletAlreadyLoaded, + .. + }) => Ok(Wallet { + name: name.to_string(), + }), + it => it, + } + } + + /// Unlock loaded wallet + pub fn unlock_wallet(&mut self, passwd: &str) -> Result<()> { + // TODO Capped at 3yrs, is it enough ? + expect_null(self.call("walletpassphrase", &(passwd, 100000000))) + } + + /* ----- Wallet utils ----- */ + + /// Generate a new address fot the current wallet + pub fn gen_addr(&mut self) -> Result<Address> { + Ok(self + .call::<Address<NetworkUnchecked>>("getnewaddress", &EMPTY)? + .assume_checked()) + } + + /// Get current balance amount + pub fn get_balance(&mut self) -> Result<Amount> { + let btc: f64 = self.call("getbalance", &EMPTY)?; + Ok(Amount::from_btc(btc).unwrap()) + } + + /* ----- Mining ----- */ + + /// Mine a certain amount of block to profit a given address + pub fn mine(&mut self, nb: u16, address: &Address) -> Result<Vec<BlockHash>> { + self.call("generatetoaddress", &(nb, address)) + } + + /* ----- Getter ----- */ + + /// Get blockchain info + pub fn get_blockchain_info(&mut self) -> Result<BlockchainInfo> { + self.call("getblockchaininfo", &EMPTY) + } + + /// Get chain tips + pub fn get_chain_tips(&mut self) -> Result<Vec<ChainTips>> { + self.call("getchaintips", &EMPTY) + } + + /// Get wallet transaction info from id + pub fn get_tx(&mut self, id: &Txid) -> Result<Transaction> { + self.call("gettransaction", &(id, (), true)) + } + + /// Get transaction inputs and outputs + pub fn get_input_output(&mut self, id: &Txid) -> Result<InputOutput> { + self.call("getrawtransaction", &(id, true)) + } + + /// Get genesis block hash + pub fn get_genesis(&mut self) -> Result<BlockHash> { + self.call("getblockhash", &[0]) + } + + /* ----- Transactions ----- */ + + /// Send bitcoin transaction + pub fn send( + &mut self, + to: &Address, + amount: &Amount, + data: Option<&[u8]>, + subtract_fee: bool, + ) -> Result<Txid> { + self.send_custom([], [(to, amount)], data, subtract_fee) + .map(|it| it.txid) + } + + /// Send bitcoin transaction with multiple recipients + pub fn send_many<'a>( + &mut self, + to: impl IntoIterator<Item = (&'a Address, &'a Amount)>, + ) -> Result<Txid> { + self.send_custom([], to, None, false).map(|it| it.txid) + } + + fn send_custom<'a>( + &mut self, + from: impl IntoIterator<Item = &'a Txid>, + to: impl IntoIterator<Item = (&'a Address, &'a Amount)>, + data: Option<&[u8]>, + subtract_fee: bool, + ) -> Result<SendResult> { + // We use the experimental 'send' rpc command as it is the only capable to send metadata in a single rpc call + let inputs: Vec<_> = from + .into_iter() + .enumerate() + .map(|(i, id)| json!({"txid": id.to_string(), "vout": i})) + .collect(); + let mut outputs: Vec<Value> = to + .into_iter() + .map(|(addr, amount)| json!({&addr.to_string(): amount.to_btc()})) + .collect(); + let nb_outputs = outputs.len(); + if let Some(data) = data { + assert!(!data.is_empty(), "No medatata"); + assert!(data.len() <= 80, "Max 80 bytes"); + outputs.push(json!({ "data".to_string(): hex::encode(data) })); + } + self.call( + "send", + &( + outputs, + (), + (), + (), + SendOption { + add_inputs: true, + inputs, + subtract_fee_from_outputs: if subtract_fee { + (0..nb_outputs).collect() + } else { + vec![] + }, + replaceable: true, + }, + ), + ) + } + + /// Bump transaction fees of a wallet debit + pub fn bump_fee(&mut self, id: &Txid) -> Result<BumpResult> { + self.call("bumpfee", &[id]) + } + + /// Abandon a pending transaction + pub fn abandon_tx(&mut self, id: &Txid) -> Result<()> { + expect_null(self.call("abandontransaction", &[&id])) + } + + /* ----- Watcher ----- */ + + /// Block until a new block is mined + pub fn wait_for_new_block(&mut self) -> Result<Nothing> { + self.call("waitfornewblock", &[0]) + } + + /// List new and removed transaction since a block + pub fn list_since_block( + &mut self, + hash: Option<&BlockHash>, + confirmation: u32, + ) -> Result<ListSinceBlock> { + self.call("listsinceblock", &(hash, confirmation.max(1), (), true)) + } + + /* ----- Cluster ----- */ + + /// Try a connection to a node once + pub fn add_node(&mut self, addr: &str) -> Result<()> { + expect_null(self.call("addnode", &(addr, "onetry"))) + } + + /// Immediately disconnects from the specified peer node. + pub fn disconnect_node(&mut self, addr: &str) -> Result<()> { + expect_null(self.call("disconnectnode", &(addr, ()))) + } + + /* ----- Control ------ */ + + /// Request a graceful shutdown + pub fn stop(&mut self) -> Result<String> { + self.call("stop", &()) + } +} + +#[derive(Debug, serde::Deserialize)] +pub struct Wallet { + pub name: String, +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct BlockchainInfo { + pub chain: String, + #[serde(rename = "verificationprogress")] + pub verification_progress: f32, + #[serde(rename = "initialblockdownload")] + pub initial_block_download: bool, + pub blocks: u64, + #[serde(rename = "bestblockhash")] + pub best_block_hash: BlockHash, +} + +#[derive(Debug, serde::Deserialize)] +pub struct BumpResult { + pub txid: Txid, +} + +#[derive(Debug, serde::Serialize)] +pub struct SendOption { + pub add_inputs: bool, + pub inputs: Vec<Value>, + pub subtract_fee_from_outputs: Vec<usize>, + pub replaceable: bool, +} + +#[derive(Debug, serde::Deserialize)] +pub struct SendResult { + pub txid: Txid, +} + +/// Enum to represent the category of a transaction. +#[derive(Copy, PartialEq, Eq, Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Category { + Send, + Receive, + Generate, + Immature, + Orphan, +} + +#[derive(Debug, serde::Deserialize)] +pub struct TransactionDetail { + pub address: Option<Address<NetworkUnchecked>>, + pub category: Category, + #[serde(with = "bitcoin::amount::serde::as_btc")] + pub amount: SignedAmount, + #[serde(default, with = "bitcoin::amount::serde::as_btc::opt")] + pub fee: Option<SignedAmount>, + /// Ony for send transaction + pub abandoned: Option<bool>, +} + +#[derive(Debug, serde::Deserialize)] +pub struct ListTransaction { + pub confirmations: i32, + pub txid: Txid, + pub category: Category, +} + +#[derive(Debug, serde::Deserialize)] +pub struct ListSinceBlock { + pub transactions: Vec<ListTransaction>, + #[serde(default)] + pub removed: Vec<ListTransaction>, + pub lastblock: BlockHash, +} + +#[derive(Debug, serde::Deserialize)] +pub struct VoutScriptPubKey { + pub asm: String, + // nulldata do not have an address + pub address: Option<Address<NetworkUnchecked>>, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Vout { + #[serde(with = "bitcoin::amount::serde::as_btc")] + pub value: Amount, + pub n: u32, + pub script_pub_key: VoutScriptPubKey, +} + +#[derive(Debug, serde::Deserialize)] +pub struct Vin { + /// Not provided for coinbase txs. + pub txid: Option<Txid>, + /// Not provided for coinbase txs. + pub vout: Option<u32>, +} + +#[derive(Debug, serde::Deserialize)] +pub struct InputOutput { + pub vin: Vec<Vin>, + pub vout: Vec<Vout>, +} + +#[derive(Debug, serde::Deserialize)] +pub struct Transaction { + pub confirmations: i32, + pub time: u64, + #[serde(with = "bitcoin::amount::serde::as_btc")] + pub amount: SignedAmount, + #[serde(default, with = "bitcoin::amount::serde::as_btc::opt")] + pub fee: Option<SignedAmount>, + pub replaces_txid: Option<Txid>, + pub replaced_by_txid: Option<Txid>, + pub details: Vec<TransactionDetail>, + pub decoded: InputOutput, +} + +#[derive(Clone, PartialEq, Eq, serde::Deserialize, Debug)] +pub struct ChainTips { + #[serde(rename = "branchlen")] + pub length: usize, + pub status: ChainTipsStatus, +} + +#[derive(Copy, serde::Deserialize, Clone, PartialEq, Eq, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ChainTipsStatus { + Invalid, + #[serde(rename = "headers-only")] + HeadersOnly, + #[serde(rename = "valid-headers")] + ValidHeaders, + #[serde(rename = "valid-fork")] + ValidFork, + Active, +} + +#[derive(Debug, serde::Deserialize)] +pub struct Nothing {} + +/// Bitcoin RPC error codes <https://github.com/bitcoin/bitcoin/blob/master/src/rpc/protocol.h> +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde_repr::Deserialize_repr)] +#[repr(i32)] +pub enum ErrorCode { + RpcInvalidRequest = -32600, + RpcMethodNotFound = -32601, + RpcInvalidParams = -32602, + RpcInternalError = -32603, + RpcParseError = -32700, + + /// std::exception thrown in command handling + RpcMiscError = -1, + /// Unexpected type was passed as parameter + RpcTypeError = -3, + /// Invalid address or key + RpcInvalidAddressOrKey = -5, + /// Ran out of memory during operation + RpcOutOfMemory = -7, + /// Invalid, missing or duplicate parameter + RpcInvalidParameter = -8, + /// Database error + RpcDatabaseError = -20, + /// Error parsing or validating structure in raw format + RpcDeserializationError = -22, + /// General error during transaction or block submission + RpcVerifyError = -25, + /// Transaction or block was rejected by network rules + RpcVerifyRejected = -26, + /// Transaction already in chain + RpcVerifyAlreadyInChain = -27, + /// Client still warming up + RpcInWarmup = -28, + /// RPC method is deprecated + RpcMethodDeprecated = -32, + /// Bitcoin is not connected + RpcClientNotConnected = -9, + /// Still downloading initial blocks + RpcClientInInitialDownload = -10, + /// Node is already added + RpcClientNodeAlreadyAdded = -23, + /// Node has not been added before + RpcClientNodeNotAdded = -24, + /// Node to disconnect not found in connected nodes + RpcClientNodeNotConnected = -29, + /// Invalid IP/Subnet + RpcClientInvalidIpOrSubnet = -30, + /// No valid connection manager instance found + RpcClientP2pDisabled = -31, + /// Max number of outbound or block-relay connections already open + RpcClientNodeCapacityReached = -34, + /// No mempool instance found + RpcClientMempoolDisabled = -33, + /// Unspecified problem with wallet (key not found etc.) + RpcWalletError = -4, + /// Not enough funds in wallet or account + RpcWalletInsufficientFunds = -6, + /// Invalid label name + RpcWalletInvalidLabelName = -11, + /// Keypool ran out, call keypoolrefill first + RpcWalletKeypoolRanOut = -12, + /// Enter the wallet passphrase with walletpassphrase first + RpcWalletUnlockNeeded = -13, + /// The wallet passphrase entered was incorrect + RpcWalletPassphraseIncorrect = -14, + /// Command given in wrong wallet encryption state (encrypting an encrypted wallet etc.) + RpcWalletWrongEncState = -15, + /// Failed to encrypt the wallet + RpcWalletEncryptionFailed = -16, + /// Wallet is already unlocked + RpcWalletAlreadyUnlocked = -17, + /// Invalid wallet specified + RpcWalletNotFound = -18, + /// No wallet specified (error when there are multiple wallets loaded) + RpcWalletNotSpecified = -19, + /// This same wallet is already loaded + RpcWalletAlreadyLoaded = -35, + /// Server is in safe mode, and command is not allowed in safe mode + RpcForbiddenBySafeMode = -2, +} diff --git a/depolymerizer-bitcoin/src/rpc_utils.rs b/depolymerizer-bitcoin/src/rpc_utils.rs @@ -0,0 +1,82 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +use std::{path::PathBuf, str::FromStr}; + +use bitcoin::{Address, Amount, Network}; + +use crate::rpc::{self, Rpc, Transaction}; + +/// Default chain dir <https://github.com/bitcoin/bitcoin/blob/master/doc/files.md#data-directory-location> +pub fn chain_dir(network: Network) -> &'static str { + match network { + Network::Bitcoin => "main", + Network::Testnet => "testnet3", + Network::Regtest => "regtest", + Network::Signet => "signet", + _ => unimplemented!(), + } +} + +/// Default rpc port <https://github.com/bitcoin/bitcoin/blob/master/share/examples/bitcoin.conf> +pub fn rpc_port(network: Network) -> u16 { + match network { + Network::Bitcoin => 8332, + Network::Testnet => 18332, + Network::Regtest => 18443, + Network::Signet => 38333, + _ => unimplemented!(), + } +} + +/// Default bitcoin data_dir <https://github.com/bitcoin/bitcoin/blob/master/doc/bitcoin-conf.md> +pub fn default_data_dir() -> PathBuf { + if cfg!(target_os = "windows") { + PathBuf::from_str(&std::env::var("APPDATA").unwrap()) + .unwrap() + .join("Bitcoin") + } else if cfg!(target_os = "linux") { + PathBuf::from_str(&std::env::var("HOME").unwrap()) + .unwrap() + .join(".bitcoin") + } else if cfg!(target_os = "macos") { + PathBuf::from_str(&std::env::var("HOME").unwrap()) + .unwrap() + .join("Library/Application Support/Bitcoin") + } else { + unimplemented!("Only windows, linux or macos") + } +} + +/// Minimum dust amount to perform a transaction to a segwit address +pub fn segwit_min_amount() -> Amount { + // https://github.com/bitcoin/bitcoin/blob/master/src/policy/policy.cpp + Amount::from_sat(294) +} + +/// Get the first sender address from a raw transaction +pub fn sender_address(rpc: &mut Rpc, full: &Transaction) -> rpc::Result<Address> { + let first = &full.decoded.vin[0]; + let tx = rpc.get_input_output(&first.txid.unwrap())?; + Ok(tx + .vout + .into_iter() + .find(|it| it.n == first.vout.unwrap()) + .unwrap() + .script_pub_key + .address + .unwrap() + .assume_checked()) +} diff --git a/btc-wire/src/segwit.rs b/depolymerizer-bitcoin/src/segwit.rs diff --git a/depolymerizer-bitcoin/src/sql.rs b/depolymerizer-bitcoin/src/sql.rs @@ -0,0 +1,48 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::str::FromStr as _; + +use bitcoin::{Address, Amount as BtcAmount, Txid, hashes::Hash}; +use common::postgres::Row; +use common::{log::OrFail, sql::sql_amount}; +use depolymerizer_bitcoin::taler_utils::taler_to_btc; +use taler_common::types::amount::Currency; + +/// Bitcoin amount from sql +pub fn sql_btc_amount(row: &Row, idx: usize, currency: &Currency) -> BtcAmount { + let amount = sql_amount(row, idx, currency); + taler_to_btc(&amount) +} + +/// Bitcoin address from sql +pub fn sql_addr(row: &Row, idx: usize) -> Address { + let str = row.get(idx); + Address::from_str(str) + .or_fail(|_| format!("Database invariant: expected an bitcoin address got {str}")) + .assume_checked() +} + +/// Bitcoin transaction id from sql +pub fn sql_txid(row: &Row, idx: usize) -> Txid { + let slice: &[u8] = row.get(idx); + Txid::from_slice(slice).or_fail(|_| { + format!( + "Database invariant: expected a transaction if got an array of {}B", + slice.len() + ) + }) +} diff --git a/depolymerizer-bitcoin/src/taler_utils.rs b/depolymerizer-bitcoin/src/taler_utils.rs @@ -0,0 +1,33 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +//! Utils function to convert taler API types to bitcoin API types + +use bitcoin::{Amount as BtcAmount, SignedAmount}; +use common::taler_common::types::amount::{Amount, FRAC_BASE}; +use taler_common::types::amount::Currency; + +/// Transform a btc amount into a taler amount +pub fn btc_to_taler(amount: &SignedAmount, currency: &Currency) -> Amount { + let unsigned = amount.abs().to_unsigned().unwrap(); + let sat = unsigned.to_sat(); + Amount::new(currency, sat / 100_000_000, (sat % 100_000_000) as u32) +} + +/// Transform a taler amount into a btc amount +pub fn taler_to_btc(amount: &Amount) -> BtcAmount { + let sat = amount.val * FRAC_BASE as u64 + amount.frac as u64; + BtcAmount::from_sat(sat) +} diff --git a/depolymerizer-bitcoin/tests/api.rs b/depolymerizer-bitcoin/tests/api.rs @@ -0,0 +1,105 @@ +/* + This file is part of TALER + Copyright (C) 2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::str::FromStr; + +use axum::Router; +use depolymerizer_bitcoin::{CONFIG_SOURCE, api::ServerState}; +use sqlx::PgPool; +use taler_api::{api::TalerRouter as _, auth::AuthMethod}; +use taler_common::{ + api_common::{HashCode, ShortHashCode}, + api_wire::{OutgoingHistory, TransferState, WireConfig}, + types::{amount::Currency, payto::payto}, +}; +use taler_test_utils::{ + db_test_setup, json, + routine::{admin_add_incoming_routine, routine_pagination, transfer_routine}, + server::TestServer, +}; + +async fn setup() -> (Router, PgPool) { + let pool = db_test_setup(CONFIG_SOURCE).await; + let api = ServerState::start( + pool.clone(), + payto("payto://bitcoin/1FfmbHfnpaZjKFvyi1okTjJJusN455paPH"), + Currency::from_str("BTC").unwrap(), + ) + .await; + let server = Router::new() + .wire_gateway(api.clone(), AuthMethod::None) + .finalize(); + + (server, pool) +} + +#[tokio::test] +async fn config() { + let (server, _) = setup().await; + server + .get("/taler-wire-gateway/config") + .await + .assert_ok_json::<WireConfig>(); +} + +#[tokio::test] +async fn transfer() { + let (server, _) = setup().await; + transfer_routine( + &server, + TransferState::success, + &payto("payto://bitcoin/1FfmbHfnpaZjKFvyi1okTjJJusN455paPH?receiver-name=Anonymous"), + ) + .await; +} + +#[tokio::test] +async fn outgoing_history() { + let (server, _) = setup().await; + routine_pagination::<OutgoingHistory, _>( + &server, + "/taler-wire-gateway/history/outgoing", + |it| { + it.outgoing_transactions + .into_iter() + .map(|it| *it.row_id as i64) + .collect() + }, + |s, _| async { + s.post("/taler-wire-gateway/transfer").json( + &json!({ + "request_uid": HashCode::rand(), + "amount": "BTC:10", + "exchange_base_url": "http://exchange.taler/", + "wtid": ShortHashCode::rand(), + "credit_account": "payto://bitcoin/1FfmbHfnpaZjKFvyi1okTjJJusN455paPH?receiver-name=Anonymous", + }) + ).await; + }, + ) + .await; +} + +#[tokio::test] +async fn admin_add_incoming() { + let (server, _) = setup().await; + admin_add_incoming_routine( + &server, + &payto("payto://bitcoin/1FfmbHfnpaZjKFvyi1okTjJJusN455paPH?receiver-name=Anonymous"), + false, + ) + .await; +} diff --git a/depolymerizer-ethereum/Cargo.toml b/depolymerizer-ethereum/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "depolymerizer-ethereum" +version = "0.1.0" +edition.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license-file.workspace = true + +[features] +# Enable random failures +fail = [] + +[dependencies] +# Cli args +clap.workspace = true +# Serialization library +serde.workspace = true +serde_json.workspace = true +# Hexadecimal encoding +hex.workspace = true +# Ethereum serializable types +ethereum-types.workspace = true +# Error macros +thiserror.workspace = true +# Common lib +common = { path = "../common" } +taler-api.workspace = true +taler-common.workspace = true +rustc-hex = "2.1" +anyhow.workspace = true +sqlx.workspace = true +axum.workspace = true +tokio.workspace = true +tracing.workspace = true + +[dev-dependencies] +taler-test-utils.workspace = true diff --git a/eth-wire/README.md b/depolymerizer-ethereum/README.md diff --git a/depolymerizer-ethereum/depolymerizer-ethereum.conf b/depolymerizer-ethereum/depolymerizer-ethereum.conf @@ -0,0 +1,90 @@ +[depolymerizer-ethereum] +# Ethereum account address to sync +ACCOUNT = + +# Legal name of the account owner +NAME = + +[depolymerizer-ethereum-worker] +# Password of the encrypted wallet +PASSWORD = + +# Number of blocks to consider a transaction confirmed +CONFIRMATION = 37 + +# An additional fee to deduce from the bounced amount +# BOUNCE_FEE = BTC:0 + +# Specify the account type and therefore the indexing behavior. +# This can either can be normal or exchange. +# Exchange accounts bounce invalid incoming Taler transactions. +ACCOUNT_TYPE = exchange + +# Number of worker's loops before wire implementation shut +LIFETIME = 0 + +# Delay in seconds before bumping an unconfirmed transaction fee (0 mean never) +BUMP_DELAY = 0 + +# Path to the ethereum RPC server +IPC_PATH = $HOME/.ethereum + +[depolymerizer-ethereum-httpd] +# How "depolymerizer-ethereum serve" serves its API, this can either be tcp or unix +SERVE = tcp + +# Port on which the HTTP server listens, e.g. 9967. Only used if SERVE is tcp. +PORT = 8080 + +# Which IP address should we bind to? E.g. ``127.0.0.1`` or ``::1``for loopback. Only used if SERVE is tcp. +BIND_TO = 0.0.0.0 + +# Which unix domain path should we bind to? Only used if SERVE is unix. +# UNIXPATH = depolymerizer-ethereum.sock + +# What should be the file access permissions for UNIXPATH? Only used if SERVE is unix. +# UNIXPATH_MODE = 660 + +# Number of requests to serve before server shutdown (0 mean never) +LIFETIME = 0 + +[depolymerizer-ethereum-httpd-wire-gateway-api] +# Whether to serve the Wire Gateway API +ENABLED = NO + +# Authentication scheme, this can either can be basic, bearer or none. +AUTH_METHOD = bearer + +# User name for basic authentication scheme +# USERNAME = + +# Password for basic authentication scheme +# PASSWORD = + +# Token for bearer authentication scheme +TOKEN = + + +[depolymerizer-ethereum-httpd-revenue-api] +# Whether to serve the Revenue API +ENABLED = NO + +# Authentication scheme, this can either can be basic, bearer or none. +AUTH_METHOD = bearer + +# User name for basic authentication scheme +# USERNAME = + +# Password for basic authentication scheme +# PASSWORD = + +# Token for bearer authentication scheme +TOKEN = + + +[depolymerizer-ethereumdb-postgres] +# DB connection string +CONFIG = postgres:///depolymerizer-ethereum + +# Where are the SQL files to setup our tables? +SQL_DIR = ${DATADIR}/sql/ +\ No newline at end of file diff --git a/depolymerizer-ethereum/src/api.rs b/depolymerizer-ethereum/src/api.rs @@ -0,0 +1,404 @@ +/* + This file is part of TALER + Copyright (C) 2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::{IntoResponse as _, Response}, +}; +use sqlx::{ + PgPool, QueryBuilder, Row, + postgres::{PgListener, PgRow}, +}; +use taler_api::{ + api::{TalerApi, wire::WireGateway}, + db::{BindHelper as _, TypeHelper as _, history, page}, + error::{ApiResult, failure, failure_status, not_implemented}, +}; +use taler_common::{ + api_params::{History, Page}, + api_wire::{ + AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddKycauthResponse, + IncomingBankTransaction, IncomingHistory, OutgoingBankTransaction, OutgoingHistory, + TransferList, TransferRequest, TransferResponse, TransferState, TransferStatus, + }, + error_code::ErrorCode, + types::{amount::Currency, payto::PaytoURI, timestamp::Timestamp}, +}; +use taler_common::{api_wire::TransferListStatus, types::payto::PaytoImpl}; +use tokio::{sync::watch::Sender, time::sleep}; +use tracing::error; + +use crate::payto::{EthAccount, FullEthPayto}; + +pub struct ServerState { + pool: PgPool, + payto: PaytoURI, + currency: Currency, + status: AtomicBool, + taler_in_channel: Sender<i64>, + taler_out_channel: Sender<i64>, +} + +pub async fn notification_listener( + pool: PgPool, + taler_in_channel: Sender<i64>, + taler_out_channel: Sender<i64>, +) -> sqlx::Result<()> { + taler_api::notification::notification_listener!(&pool, + "taler_in" => (row_id: i64) { + taler_in_channel.send_replace(row_id); + }, + "taler_out" => (row_id: i64) { + taler_out_channel.send_replace(row_id); + } + ) +} + +impl ServerState { + pub async fn start(pool: sqlx::PgPool, payto: PaytoURI, currency: Currency) -> Arc<Self> { + let taler_in_channel = Sender::new(0); + let taler_out_channel = Sender::new(0); + let tmp = Self { + pool: pool.clone(), + payto, + currency, + status: AtomicBool::new(true), + taler_in_channel: taler_in_channel.clone(), + taler_out_channel: taler_out_channel.clone(), + }; + let state = Arc::new(tmp); + tokio::spawn(status_watcher(state.clone())); + tokio::spawn(notification_listener( + pool, + taler_in_channel, + taler_out_channel, + )); + state + } +} + +impl TalerApi for ServerState { + fn currency(&self) -> &str { + self.currency.as_ref() + } + + fn implementation(&self) -> Option<&str> { + None + } +} + +fn sql_payto<I: sqlx::ColumnIndex<PgRow>>(r: &PgRow, addr: I, name: I) -> sqlx::Result<PaytoURI> { + let it: [u8; 20] = r.try_get(addr)?; + let addr = ethereum_types::Address::from_slice(&it); + let name: Option<&str> = r.try_get(name)?; + + Ok(EthAccount(addr) + .as_payto() + .as_full_payto(name.unwrap_or("Ethereum User"))) +} + +fn sql_generic_payto<I: sqlx::ColumnIndex<PgRow>>(row: &PgRow, idx: I) -> sqlx::Result<PaytoURI> { + let it: [u8; 20] = row.try_get(idx)?; + let addr = ethereum_types::Address::from_slice(&it); + + Ok(EthAccount(addr).as_payto().as_full_payto("Ethereum User")) +} + +impl WireGateway for ServerState { + async fn transfer(&self, req: TransferRequest) -> ApiResult<TransferResponse> { + let creditor = FullEthPayto::try_from(&req.credit_account)?; + + // TODO use plpgsql transaction + // Handle idempotence, check previous transaction with the same request_uid + let row = sqlx::query("SELECT (amount).val, (amount).frac, exchange_url, wtid, credit_acc, credit_name, id, created FROM tx_out WHERE request_uid = $1").bind(req.request_uid.as_slice()) + .fetch_optional(&self.pool) + .await?; + if let Some(r) = row { + // TODO store names? + let prev: TransferRequest = TransferRequest { + request_uid: req.request_uid.clone(), + amount: r.try_get_amount_i(0, &self.currency)?, + exchange_base_url: r.try_get_url("exchange_url")?, + wtid: r.try_get_base32("wtid")?, + credit_account: sql_payto(&r, "credit_acc", "credit_name")?, + }; + dbg!(&prev.credit_account, &req.credit_account); + if prev == req { + // Idempotence + return Ok(TransferResponse { + row_id: r.try_get_safeu64("id")?, + timestamp: r.try_get_timestamp("created")?, + }); + } else { + return Err(failure( + ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED, + format!("Request UID {} already used", req.request_uid), + )); + } + } + + let timestamp = Timestamp::now(); + let r = sqlx::query( + "INSERT INTO tx_out (created, amount, wtid, credit_acc, credit_name, exchange_url, request_uid) VALUES ($1, ($2, $3)::taler_amount, $4, $5, $6, $7, $8) ON CONFLICT (wtid) DO NOTHING RETURNING id" + ) + .bind_timestamp(&Timestamp::now()) + .bind_amount(&req.amount) + .bind(req.wtid.as_slice()) + .bind(creditor.0.as_bytes()) + .bind(&creditor.name) + .bind(req.exchange_base_url.as_str()) + .bind(req.request_uid.as_slice()) + .fetch_optional(&self.pool) + .await?; + let Some(r) = r else { + return Err(failure( + ErrorCode::BANK_TRANSFER_WTID_REUSED, + format!("wtid {} already used", req.request_uid), + )); + }; + let row_id = r.try_get_safeu64(0)?; + sqlx::query("NOTIFY new_tx").execute(&self.pool).await?; + sqlx::query("SELECT pg_notify('taler_out', '' || $1)") + .bind(*row_id as i64) + .execute(&self.pool) + .await?; + + Ok(TransferResponse { timestamp, row_id }) + } + + async fn transfer_page( + &self, + params: Page, + status: Option<TransferState>, + ) -> ApiResult<TransferList> { + let debit_account = self.payto.clone(); + if status.is_some_and(|s| s != TransferState::success) { + return Ok(TransferList { + transfers: Vec::new(), + debit_account, + }); + } + let transfers = page( + &self.pool, + "id", + &params, + || { + QueryBuilder::new( + " + SELECT + id, + status, + (amount).val as amount_val, + (amount).frac as amount_frac, + credit_acc, + credit_name, + created + FROM tx_out WHERE request_uid IS NOT NULL AND + ", + ) + }, + |r: PgRow| { + Ok(TransferListStatus { + row_id: r.try_get_safeu64("id")?, + // TODO Fetch inner status + status: TransferState::success, + amount: r.try_get_amount("amount", &self.currency)?, + credit_account: sql_payto(&r, "credit_acc", "credit_name")?, + timestamp: r.try_get_timestamp("created")?, + }) + }, + ) + .await?; + Ok(TransferList { + transfers, + debit_account, + }) + } + + async fn transfer_by_id(&self, id: u64) -> ApiResult<Option<TransferStatus>> { + Ok(sqlx::query( + " + SELECT + status, + (amount).val as amount_val, + (amount).frac as amount_frac, + exchange_url, + wtid, + credit_acc, + credit_name, + created + FROM tx_out WHERE request_uid IS NOT NULL AND id = $1 + ", + ) + .bind(id as i64) + .try_map(|r: PgRow| { + Ok(TransferStatus { + // TODO Fetch inner status + status: TransferState::success, + status_msg: None, + amount: r.try_get_amount("amount", &self.currency)?, + origin_exchange_url: r.try_get("exchange_url")?, + wtid: r.try_get_base32("wtid")?, + credit_account: sql_payto(&r, "credit_acc", "credit_name")?, + timestamp: r.try_get_timestamp("created")?, + }) + }) + .fetch_optional(&self.pool) + .await?) + } + + async fn outgoing_history(&self, params: History) -> ApiResult<OutgoingHistory> { + let outgoing_transactions = history( + &self.pool, + "id", + &params, + || self.taler_out_channel.subscribe(), + || QueryBuilder::new( + "SELECT id, created, (amount).val, (amount).frac, wtid, credit_acc, credit_name, exchange_url FROM tx_out WHERE" + ), |r| { + Ok(OutgoingBankTransaction { + row_id: r.try_get_safeu64(0)?, + date: r.try_get_timestamp(1)?, + amount: r.try_get_amount_i(2, &self.currency)?, + wtid: r.try_get_base32(4)?, + credit_account: sql_payto(&r, "credit_acc", "credit_name")?, + exchange_base_url: r.try_get_url("exchange_url")?, + }) + }).await?; + Ok(OutgoingHistory { + debit_account: self.payto.clone(), + outgoing_transactions, + }) + } + + async fn incoming_history(&self, params: History) -> ApiResult<IncomingHistory> { + let incoming_transactions = history( + &self.pool, + "id", + &params, + || self.taler_in_channel.subscribe(), + || { + QueryBuilder::new( + "SELECT id, received, (amount).val, (amount).frac, reserve_pub, debit_acc FROM tx_in WHERE" + ) + }, + |r| { + Ok(IncomingBankTransaction::Reserve { + row_id: r.try_get_safeu64(0)?, + date: r.try_get_timestamp(1)?, + amount: r.try_get_amount_i(2, &self.currency)?, + reserve_pub: r.try_get_base32(4)?, + debit_account: sql_generic_payto(&r, 5)?, + }) + }, + ) + .await?; + Ok(IncomingHistory { + credit_account: self.payto.clone(), + incoming_transactions, + }) + } + + async fn add_incoming_reserve( + &self, + req: AddIncomingRequest, + ) -> ApiResult<AddIncomingResponse> { + let debtor = FullEthPayto::try_from(&req.debit_account)?; + let timestamp = Timestamp::now(); + let r = sqlx::query("INSERT INTO tx_in (received, amount, reserve_pub, debit_acc) VALUES ($1, ($2, $3)::taler_amount, $4, $5) ON CONFLICT (reserve_pub) DO NOTHING RETURNING id") + .bind_timestamp(&Timestamp::now()) + .bind_amount(&req.amount) + .bind(req.reserve_pub.as_slice()) + .bind(debtor.0.as_bytes()) + .fetch_optional(&self.pool).await?; + let Some(r) = r else { + return Err(failure( + ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT, + "reserve_pub used already".to_owned(), + )); + }; + let row_id = r.try_get_safeu64(0)?; + sqlx::query("SELECT pg_notify('taler_in', '' || $1)") + .bind(*row_id as i64) + .execute(&self.pool) + .await?; + Ok(AddIncomingResponse { timestamp, row_id }) + } + + async fn add_incoming_kyc(&self, _req: AddKycauthRequest) -> ApiResult<AddKycauthResponse> { + Err(not_implemented( + "depolymerizer-bitcoin does not supports KYC", + )) + } + + fn support_account_check(&self) -> bool { + false + } +} + +pub async fn status_middleware( + State(state): State<Arc<ServerState>>, + request: Request, + next: Next, +) -> Response { + if !state.status.load(Ordering::Relaxed) { + failure_status( + ErrorCode::GENERIC_INTERNAL_INVARIANT_FAILURE, + "Currency backing is compromised until the transaction reappear", + StatusCode::BAD_GATEWAY, + ) + .into_response() + } else { + next.run(request).await + } +} + +/// Listen to backend status change +async fn status_watcher(state: Arc<ServerState>) { + async fn inner(state: &ServerState) -> Result<(), sqlx::error::Error> { + let mut listener = PgListener::connect_with(&state.pool).await?; + listener.listen("status").await?; + loop { + // Sync state + let row = sqlx::query("SELECT value FROM state WHERE name = 'status'") + .fetch_one(&state.pool) + .await?; + let status: &[u8] = row.try_get(0)?; + assert!(status.len() == 1 && status[0] < 2); + state.status.store(status[0] == 1, Ordering::SeqCst); + // Wait for next notification + listener.recv().await?; + } + } + + loop { + if let Err(err) = inner(&state).await { + error!("status-watcher: {}", err); + // TODO better sleep + sleep(Duration::from_secs(5)).await; + } + } +} diff --git a/depolymerizer-ethereum/src/config.rs b/depolymerizer-ethereum/src/config.rs @@ -0,0 +1,129 @@ +/* + This file is part of TALER + Copyright (C) 2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use common::postgres; +use ethereum_types::U256; +use taler_api::{ + Serve, + config::{ApiCfg, DbCfg}, +}; +use taler_common::{ + config::{Config, ValueErr}, + types::{amount::Currency, payto::PaytoURI}, +}; + +use crate::{ + payto::{EthAccount, FullEthPayto}, + taler_util::taler_to_eth, +}; + +pub fn parse_db_cfg(cfg: &Config) -> Result<DbCfg, ValueErr> { + DbCfg::parse(cfg.section("depolymerizer-ethereumdb-postgres")) +} + +pub fn parse_account_payto(cfg: &Config) -> Result<FullEthPayto, ValueErr> { + let sect = cfg.section("depolymerizer-ethereum"); + let wallet: EthAccount = sect + .parse("ethereum account address", "ACCOUNT") + .require()?; + let name = sect.str("NAME").require()?; + + Ok(FullEthPayto::new(wallet, name)) +} + +pub struct ServeCfg { + pub payto: PaytoURI, + pub serve: Serve, + pub wire_gateway: Option<ApiCfg>, + pub revenue: Option<ApiCfg>, + pub currency: Currency, + pub lifetime: Option<u32>, +} + +impl ServeCfg { + pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let payto = parse_account_payto(cfg)?; + + let sect = cfg.section("depolymerizer-ethereum-httpd"); + + let lifetime = sect.number("LIFETIME").opt()?.filter(|it| *it != 0); + + let serve = Serve::parse(sect)?; + + let wire_gateway = + ApiCfg::parse(cfg.section("depolymerizer-ethereum-httpd-wire-gateway-api"))?; + let revenue = ApiCfg::parse(cfg.section("depolymerizer-ethereum-httpd-revenue-api"))?; + + let sect = cfg.section("depolymerizer-ethereum"); + Ok(Self { + currency: sect.parse("currency", "CURRENCY").require()?, + lifetime, + payto: payto.as_payto(), + serve, + wire_gateway, + revenue, + }) + } +} + +#[derive(Debug, Clone)] + +pub struct WorkerCfg { + pub confirmation: u32, + pub max_confirmation: u32, + pub bounce_fee: U256, + pub ipc_path: String, + pub lifetime: Option<u32>, + pub bump_delay: Option<u32>, + pub db_config: postgres::Config, + pub currency: Currency, + pub account: EthAccount, + pub password: String, +} + +impl WorkerCfg { + pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let sect = cfg.section("depolymerizer-ethereum"); + let currency: Currency = sect.parse("currency", "CURRENCY").require()?; + let account = sect.parse("Ethereum account", "ACCOUNT").require()?; + + let sect = cfg.section("depolymerizer-ethereum-worker"); + let confirmation = sect.number("CONFIRMATION").require()?; + let ipc_path = sect.path("IPC_PATH").require()?; + let password = sect.str("PASSWORD").require()?; + + Ok(Self { + account, + ipc_path, + confirmation, + max_confirmation: confirmation * 2, + bounce_fee: sect + .amount("BOUNCE_FEE", currency.as_ref()) + .opt()? + .map(|it| taler_to_eth(&it)) + .unwrap_or(U256::zero()), + lifetime: sect.number("LIFETIME").opt()?.filter(|it| *it != 0), + bump_delay: sect.number("BUMP_DELAY").opt()?.filter(|it| *it != 0), + db_config: cfg + .section("depolymerizer-ethereumdb-postgres") + .parse("Postgres", "CONFIG") + .require() + .unwrap(), + currency, + password, + }) + } +} diff --git a/depolymerizer-ethereum/src/fail_point.rs b/depolymerizer-ethereum/src/fail_point.rs @@ -0,0 +1,31 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub struct Injected(&'static str); + +/// Inject random failure when 'fail' feature is used +#[allow(unused_variables)] +pub fn fail_point(msg: &'static str, prob: f32) -> Result<(), Injected> { + #[cfg(feature = "fail")] + return if common::rand::random::<f32>() < prob { + Err(Injected(msg)) + } else { + Ok(()) + }; + + Ok(()) +} diff --git a/depolymerizer-ethereum/src/lib.rs b/depolymerizer-ethereum/src/lib.rs @@ -0,0 +1,240 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::fmt::Debug; + +use common::{ + log::OrFail, + metadata::{InMetadata, OutMetadata}, + taler_common::api_common::{EddsaPublicKey, ShortHashCode}, + url::Url, +}; +use ethereum_types::{Address, H256, U64, U256}; +use rpc::{Rpc, RpcClient, RpcStream, Transaction, hex::Hex}; +use serde::de::DeserializeOwned; +use taler_common::config::parser::ConfigSource; + +pub mod api; +pub mod config; +pub mod payto; +pub mod rpc; +pub mod taler_util; + +pub const CONFIG_SOURCE: ConfigSource = ConfigSource::simple("depolymerizer-ethereum"); +pub const DB_SCHEMA: &str = "depolymerizer_ethereum"; + +/// An extended geth JSON-RPC api client who can send and retrieve metadata with their transaction +pub trait RpcExtended: RpcClient { + /// Perform a wire credit + fn credit( + &mut self, + from: Address, + to: Address, + value: U256, + reserve_pub: EddsaPublicKey, + ) -> rpc::Result<H256> { + let metadata = InMetadata::Credit { reserve_pub }; + self.send_transaction(&rpc::TransactionRequest { + from, + to, + value, + nonce: None, + gas_price: None, + data: Hex(metadata.encode()), + }) + } + + /// Perform a wire debit + fn debit( + &mut self, + from: Address, + to: Address, + value: U256, + wtid: ShortHashCode, + url: Url, + ) -> rpc::Result<H256> { + let metadata = OutMetadata::Debit { wtid, url }; + self.send_transaction(&rpc::TransactionRequest { + from, + to, + value, + nonce: None, + gas_price: None, + data: Hex(metadata.encode().or_fail(|e| format!("{e}"))), + }) + } + + /// Perform a Taler bounce + fn bounce(&mut self, hash: H256, bounce_fee: U256) -> rpc::Result<Option<H256>> { + let tx = self + .get_transaction(&hash)? + .expect("Cannot bounce a non existent transaction"); + let bounce_value = tx.value.saturating_sub(bounce_fee); + let metadata = OutMetadata::Bounce { bounced: hash.0 }; + let mut request = rpc::TransactionRequest { + from: tx.to.expect("Cannot bounce contract transaction"), + to: tx.from.expect("Cannot bounce coinbase transaction"), + value: bounce_value, + nonce: None, + gas_price: None, + data: Hex(metadata.encode().or_fail(|e| format!("{e}"))), + }; + // Estimate fee price using node + let fill = self.fill_transaction(&request)?; + // Deduce fee price from bounced value + request.value = request + .value + .saturating_sub(fill.tx.gas * fill.tx.gas_price.or(fill.tx.max_fee_per_gas).unwrap()); + Ok(if request.value.is_zero() { + None + } else { + Some(self.send_transaction(&request)?) + }) + } + + /// List new and removed transaction since the last sync state and the size of the reorganized fork if any, returning a new sync state + fn list_since_sync( + &mut self, + address: &Address, + state: SyncState, + min_confirmation: u32, + ) -> rpc::Result<ListSinceSync> { + let match_tx = |txs: Vec<Transaction>, confirmations: u32| -> Vec<SyncTransaction> { + txs.into_iter() + .filter_map(|tx| { + (tx.from == Some(*address) || tx.to == Some(*address)) + .then_some(SyncTransaction { tx, confirmations }) + }) + .collect() + }; + + let mut txs = Vec::new(); + let mut removed = Vec::new(); + let mut fork_len = 0; + + // Add pending transaction + txs.extend(match_tx(self.pending_transactions()?, 0)); + + let latest = self.latest_block()?; + + let mut confirmation = 1; + let mut chain_cursor = latest.clone(); + + // Move until tip height + while chain_cursor.number.unwrap() != state.tip_height { + txs.extend(match_tx(chain_cursor.transactions, confirmation)); + chain_cursor = self.block(&chain_cursor.parent_hash)?.unwrap(); + confirmation += 1; + } + + // Check if fork + if chain_cursor.hash.unwrap() != state.tip_hash { + let mut fork_cursor = self.block(&state.tip_hash)?.unwrap(); + // Move until found common parent + while fork_cursor.hash != chain_cursor.hash { + txs.extend(match_tx(chain_cursor.transactions, confirmation)); + removed.extend(match_tx(fork_cursor.transactions, confirmation)); + chain_cursor = self.block(&chain_cursor.parent_hash)?.unwrap(); + fork_cursor = self.block(&fork_cursor.parent_hash)?.unwrap(); + confirmation += 1; + fork_len += 1; + } + } + + // Move until last conf + while chain_cursor.number.unwrap() > state.conf_height { + txs.extend(match_tx(chain_cursor.transactions, confirmation)); + chain_cursor = self.block(&chain_cursor.parent_hash)?.unwrap(); + confirmation += 1; + } + + Ok(ListSinceSync { + txs, + removed, + fork_len, + state: SyncState { + tip_hash: latest.hash.unwrap(), + tip_height: latest.number.unwrap(), + conf_height: latest + .number + .unwrap() + .saturating_sub(U64::from(min_confirmation)), + }, + }) + } +} + +impl RpcExtended for Rpc {} +impl<N: Debug + DeserializeOwned> RpcExtended for RpcStream<'_, N> {} + +pub struct SyncTransaction { + pub tx: Transaction, + pub confirmations: u32, +} + +pub struct ListSinceSync { + pub txs: Vec<SyncTransaction>, + pub removed: Vec<SyncTransaction>, + pub state: SyncState, + pub fork_len: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SyncState { + pub tip_hash: H256, + pub tip_height: U64, + pub conf_height: U64, +} + +impl SyncState { + pub fn to_bytes(&self) -> [u8; 48] { + let mut bytes = [0; 48]; + bytes[..32].copy_from_slice(self.tip_hash.as_bytes()); + bytes[32..40].copy_from_slice(&self.tip_height.to_little_endian()); + bytes[40..].copy_from_slice(&self.conf_height.to_little_endian()); + bytes + } + + pub fn from_bytes(bytes: &[u8; 48]) -> Self { + Self { + tip_hash: H256::from_slice(&bytes[..32]), + tip_height: U64::from_little_endian(&bytes[32..40]), + conf_height: U64::from_little_endian(&bytes[40..]), + } + } +} + +#[cfg(test)] +mod test { + use common::{rand::random, rand_slice}; + use ethereum_types::{H256, U64}; + + use crate::SyncState; + + #[test] + fn to_from_bytes_block_state() { + for _ in 0..4 { + let state = SyncState { + tip_hash: H256::from_slice(&rand_slice::<32>()), + tip_height: U64::from(random::<u64>()), + conf_height: U64::from(random::<u64>()), + }; + let encoded = state.to_bytes(); + let decoded = SyncState::from_bytes(&encoded); + assert_eq!(state, decoded); + } + } +} diff --git a/depolymerizer-ethereum/src/loops.rs b/depolymerizer-ethereum/src/loops.rs @@ -0,0 +1,38 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use common::postgres; +use depolymerizer_ethereum::rpc; + +use crate::fail_point::Injected; + +pub mod analysis; +pub mod watcher; +pub mod worker; + +#[derive(Debug, thiserror::Error)] +pub enum LoopError { + #[error("RPC {0}")] + Rpc(#[from] rpc::Error), + #[error("DB {0}")] + DB(#[from] postgres::Error), + #[error("Another eth-wire process is running concurrently")] + Concurrency, + #[error(transparent)] + Injected(#[from] Injected), +} + +pub type LoopResult<T> = Result<T, LoopError>; diff --git a/depolymerizer-ethereum/src/loops/analysis.rs b/depolymerizer-ethereum/src/loops/analysis.rs @@ -0,0 +1,33 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use tracing::warn; + +use super::LoopResult; + +/// Analyse blockchain behavior and adapt confirmations in real time +pub fn analysis(fork: u32, current: u32, max: u32) -> LoopResult<u32> { + // If new fork is bigger than what current confirmation delay protect against + if fork >= current { + // Limit confirmation growth + let new_conf = fork.saturating_add(1).min(max); + warn!( + "analysis: found dangerous fork of {fork} blocks, adapt confirmation to {new_conf} blocks capped at {max}, you should update taler.conf" + ); + return Ok(new_conf); + } + Ok(current) +} diff --git a/depolymerizer-ethereum/src/loops/watcher.rs b/depolymerizer-ethereum/src/loops/watcher.rs @@ -0,0 +1,43 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +use std::time::Duration; + +use common::reconnect::{client_jitter, connect_db}; +use depolymerizer_ethereum::{DB_SCHEMA, config::WorkerCfg, rpc::rpc_common}; +use tracing::error; + +use super::LoopResult; + +/// Wait for new block and notify arrival with postgreSQL notifications +pub fn watcher(state: &WorkerCfg) { + let mut jitter = client_jitter(); + loop { + let result: LoopResult<()> = (|| { + let rpc = &mut rpc_common(&state.ipc_path)?; + let db = &mut connect_db(&state.db_config, DB_SCHEMA)?; + let mut notifier = rpc.subscribe_new_head()?; + loop { + db.execute("NOTIFY new_block", &[])?; + notifier.next()?; + jitter.reset(); + } + })(); + if let Err(e) = result { + error!("watcher: {e}"); + std::thread::sleep(Duration::from_millis(jitter.next() as u64)); + } + } +} diff --git a/depolymerizer-ethereum/src/loops/worker.rs b/depolymerizer-ethereum/src/loops/worker.rs @@ -0,0 +1,530 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +use std::{fmt::Write, time::Duration}; + +use common::{ + metadata::{InMetadata, OutMetadata}, + postgres::{Client, fallible_iterator::FallibleIterator}, + reconnect::{client_jitter, connect_db}, + sql::{sql_array, sql_base_32, sql_url}, + status::{BounceStatus, DebitStatus}, + taler_common::{api_common::ShortHashCode, types::timestamp::Timestamp}, +}; +use depolymerizer_ethereum::{ + DB_SCHEMA, ListSinceSync, RpcExtended, SyncState, SyncTransaction, + config::WorkerCfg, + rpc::{self, Rpc, RpcClient, Transaction, TransactionRequest, rpc_wallet}, + taler_util::eth_to_taler, +}; +use ethereum_types::{Address, H256, U256}; +use tracing::{error, info, warn}; + +use crate::{ + fail_point::fail_point, + loops::LoopError, + sql::{sql_addr, sql_eth_amount, sql_hash}, +}; + +use super::{LoopResult, analysis::analysis}; + +pub fn worker(mut state: WorkerCfg) { + let mut jitter = client_jitter(); + let mut lifetime = state.lifetime; + let mut status = true; + let mut skip_notification = true; + + loop { + let result: LoopResult<()> = (|| { + // Connect + let rpc = &mut rpc_wallet(&state.ipc_path, &state.password, &state.account.0)?; + let db = &mut connect_db(&state.db_config, DB_SCHEMA)?; + + loop { + // Listen to all channels + db.batch_execute("LISTEN new_block; LISTEN new_tx")?; + // Wait for the next notification + { + let mut ntf = db.notifications(); + if !skip_notification && ntf.is_empty() { + // Block until next notification + ntf.blocking_iter().next()?; + } + // Conflate all notifications + let mut iter = ntf.iter(); + while iter.next()?.is_some() {} + } + + // Check lifetime + if let Some(nb) = lifetime.as_mut() { + if *nb == 0 { + info!("Reach end of lifetime"); + return Ok(()); + } else { + *nb -= 1; + } + } + + // It is not possible to atomically update the blockchain and the database. + // When we failed to sync the database and the blockchain state we rely on + // sync_chain to recover the lost updates. + // When this function is running concurrently, it not possible to known another + // execution has failed, and this can lead to a transaction being sent multiple time. + // To ensure only a single version of this function is running at a given time we rely + // on postgres advisory lock + + // Take the lock + let row = db.query_one("SELECT pg_try_advisory_lock(42)", &[])?; + let locked: bool = row.get(0); + if !locked { + return Err(LoopError::Concurrency); + } + + // Get stored sync state + let row = db.query_one("SELECT value FROM state WHERE name='sync'", &[])?; + let sync_state = SyncState::from_bytes(&sql_array(&row, 0)); + + // Get changes + let list = rpc.list_since_sync(&state.account.0, sync_state, state.confirmation)?; + + // Perform analysis + state.confirmation = + analysis(list.fork_len, state.confirmation, state.max_confirmation)?; + + // Sync chain + if sync_chain(db, &state, &mut status, list)? { + // As we are now in sync with the blockchain if a transaction has Requested status it have not been sent + + // Send requested debits + while debit(db, rpc, &state)? {} + + // Bump stuck transactions + while bump(db, rpc, &state)? {} + + // Send requested bounce + while bounce(db, rpc, state.bounce_fee)? {} + } + + skip_notification = false; + jitter.reset(); + } + })(); + + if let Err(e) = result { + error!("worker: {e}"); + // When we catch an error, we sometimes want to retry immediately (eg. reconnect to RPC or DB). + // Rpc error codes are generic. We need to match the msg to get precise ones. Some errors + // can resolve themselves when a new block is mined (new fees, new transactions). Our simple + // approach is to wait for the next loop when an RPC error is caught to prevent endless logged errors. + skip_notification = matches!( + e, + LoopError::Rpc(rpc::Error::Transport(_)) + | LoopError::DB(_) + | LoopError::Injected(_) + ); + std::thread::sleep(Duration::from_millis(jitter.next() as u64)); + } else { + return; + } + } +} + +/// Parse new transactions, return true if the database is up to date with the latest mined block +fn sync_chain( + db: &mut Client, + state: &WorkerCfg, + status: &mut bool, + list: ListSinceSync, +) -> LoopResult<bool> { + // Get the current confirmation delay + let conf_delay = state.confirmation; + + // Check if a confirmed incoming transaction have been removed by a blockchain reorganization + let new_status = + sync_chain_removed(&list.txs, &list.removed, db, &state.account.0, conf_delay)?; + + // Sync status with database + if *status != new_status { + let mut tx = db.transaction()?; + tx.execute( + "UPDATE state SET value=$1 WHERE name='status'", + &[&[new_status as u8].as_slice()], + )?; + tx.execute("NOTIFY status", &[])?; + tx.commit()?; + *status = new_status; + if new_status { + info!("Recovered lost transactions"); + } + } + if !new_status { + return Ok(false); + } + + for sync_tx in list.txs { + let tx = &sync_tx.tx; + if tx.to == Some(state.account.0) && sync_tx.confirmations >= conf_delay { + sync_chain_incoming_confirmed(tx, db, state)?; + } else if tx.from == Some(state.account.0) { + sync_chain_outgoing(&sync_tx, db, state)?; + } + } + + db.execute( + "UPDATE state SET value=$1 WHERE name='sync'", + &[&list.state.to_bytes().as_slice()], + )?; + Ok(true) +} + +/// Sync database with removed transactions, return false if bitcoin backing is compromised +fn sync_chain_removed( + txs: &[SyncTransaction], + removed: &[SyncTransaction], + db: &mut Client, + addr: &Address, + min_confirmation: u32, +) -> LoopResult<bool> { + // A removed incoming transaction is a correctness issues in only two cases: + // - it is a confirmed credit registered in the database + // - it is an invalid transactions already bounced + // Those two cases can compromise bitcoin backing + // Removed outgoing transactions will be retried automatically by the node + + let mut blocking_credit = Vec::new(); + let mut blocking_bounce = Vec::new(); + + // Only keep incoming transaction that are not reconfirmed + // TODO study risk of accepting only mined transactions for faster recovery + for tx in removed + .iter() + .filter(|sync_tx| { + sync_tx.tx.to == Some(*addr) + && txs + .iter() + .all(|it| it.tx.hash != sync_tx.tx.hash || it.confirmations < min_confirmation) + }) + .map(|s| &s.tx) + { + match InMetadata::decode(&tx.input) { + Ok(metadata) => match metadata { + InMetadata::Credit { reserve_pub } => { + // Credits are only problematic if not reconfirmed and stored in the database + if db + .query_opt( + "SELECT 1 FROM tx_in WHERE reserve_pub=$1", + &[&reserve_pub.as_slice()], + )? + .is_some() + { + blocking_credit.push((reserve_pub, tx.hash, tx.from.unwrap())); + } + } + }, + Err(_) => { + // Invalid tx are only problematic if if not reconfirmed and already bounced + if let Some(row) = db.query_opt( + "SELECT txid FROM bounce WHERE bounced=$1 AND txid IS NOT NULL", + &[&tx.hash.as_ref()], + )? { + blocking_bounce.push((sql_hash(&row, 0), tx.hash)); + } else { + // Remove transaction from bounce table + db.execute("DELETE FROM bounce WHERE bounced=$1", &[&tx.hash.as_ref()])?; + } + } + } + } + + if !blocking_bounce.is_empty() || !blocking_credit.is_empty() { + let mut buf = "The following transaction have been removed from the blockchain, ethereum backing is compromised until the transaction reappear:".to_string(); + for (key, id, addr) in blocking_credit { + write!( + &mut buf, + "\n\tcredit {key} in {} from {}", + hex::encode(id), + hex::encode(addr) + ) + .unwrap(); + } + for (id, bounced) in blocking_bounce { + write!( + &mut buf, + "\n\tbounce {} in {}", + hex::encode(id), + hex::encode(bounced) + ) + .unwrap(); + } + error!("{buf}"); + Ok(false) + } else { + Ok(true) + } +} + +/// Sync database with an incoming confirmed transaction +fn sync_chain_incoming_confirmed( + tx: &Transaction, + db: &mut Client, + state: &WorkerCfg, +) -> Result<(), LoopError> { + match InMetadata::decode(&tx.input) { + Ok(metadata) => match metadata { + InMetadata::Credit { reserve_pub } => { + let amount = eth_to_taler(&tx.value, &state.currency); + let credit_addr = tx.from.expect("Not coinbase"); + let nb = db.execute("INSERT INTO tx_in (received, amount, reserve_pub, debit_acc) VALUES ($1, ($2, $3)::taler_amount, $4, $5) ON CONFLICT (reserve_pub) DO NOTHING ", &[ + &Timestamp::now().as_sql_micros(), &(amount.val as i64), &(amount.frac as i32), &reserve_pub.as_slice(), &credit_addr.as_bytes() + ])?; + if nb > 0 { + info!( + "<< {amount} {reserve_pub} in {} from {}", + hex::encode(tx.hash), + hex::encode(credit_addr), + ); + } + } + }, + Err(_) => { + // If encoding is wrong request a bounce + db.execute( + "INSERT INTO bounce (created, bounced) VALUES ($1, $2) ON CONFLICT (bounced) DO NOTHING", + &[&Timestamp::now().as_sql_micros(), &tx.hash.as_ref()], + )?; + } + } + Ok(()) +} + +/// Sync database with an outgoing transaction +fn sync_chain_outgoing(tx: &SyncTransaction, db: &mut Client, state: &WorkerCfg) -> LoopResult<()> { + let SyncTransaction { tx, confirmations } = tx; + match OutMetadata::decode(&tx.input) { + Ok(metadata) => match metadata { + OutMetadata::Debit { wtid, url } => { + let amount = eth_to_taler(&tx.value, &state.currency); + let credit_addr = tx.to.unwrap(); + // Get previous out tx + let row = db.query_opt( + "SELECT id, status, sent FROM tx_out WHERE wtid=$1 FOR UPDATE", + &[&wtid.as_slice()], + )?; + if let Some(row) = row { + // If already in database, sync status + let row_id: i64 = row.get(0); + let status: i16 = row.get(1); + let sent: Option<i64> = row.get(2); + + let expected_status = DebitStatus::Sent as i16; + let expected_send = sent.filter(|_| *confirmations == 0); + if status != expected_status || sent != expected_send { + let nb_row = db.execute( + "UPDATE tx_out SET status=$1, txid=$2, sent=NULL WHERE id=$3 AND status=$4", + &[ + &(DebitStatus::Sent as i16), + &tx.hash.as_ref(), + &row_id, + &status, + ], + )?; + if nb_row > 0 { + match DebitStatus::try_from(status as u8).unwrap() { + DebitStatus::Requested => { + warn!( + ">> (recovered) {amount} {wtid} in {} to {}", + hex::encode(tx.hash), + hex::encode(credit_addr) + ); + } + DebitStatus::Sent => { /* Status is correct */ } + } + } + } + } else { + // Else add to database + let nb = db.execute( + "INSERT INTO tx_out (created, amount, wtid, credit_acc, exchange_url, status, txid, request_uid) VALUES ($1, ($2, $3)::taler_amount, $4, $5, $6, $7, $8, $9) ON CONFLICT (wtid) DO NOTHING", + &[&Timestamp::now().as_sql_micros(), &(amount.val as i64), &(amount.frac as i32), &wtid.as_slice(), &credit_addr.as_bytes(), &url.to_string(), &(DebitStatus::Sent as i16), &tx.hash.as_ref(), &None::<&[u8]>], + )?; + if nb > 0 { + warn!( + ">> (onchain) {amount} {wtid} in {} to {}", + hex::encode(tx.hash), + hex::encode(credit_addr) + ); + } + } + } + OutMetadata::Bounce { bounced } => { + let bounced = H256::from_slice(&bounced); + // Get previous bounce + let row = db.query_opt( + "SELECT id, status FROM bounce WHERE bounced=$1", + &[&bounced.as_ref()], + )?; + if let Some(row) = row { + // If already in database, sync status + let row_id: i64 = row.get(0); + let status: i16 = row.get(1); + match BounceStatus::try_from(status as u8).unwrap() { + BounceStatus::Requested => { + let nb_row = db.execute( + "UPDATE bounce SET status=$1, txid=$2 WHERE id=$3 AND status=$4", + &[ + &(BounceStatus::Sent as i16), + &tx.hash.as_ref(), + &row_id, + &status, + ], + )?; + if nb_row > 0 { + warn!( + "|| (recovered) {} in {}", + hex::encode(bounced), + hex::encode(tx.hash) + ); + } + } + BounceStatus::Ignored => error!( + "watcher: ignored bounce {} found in chain at {}", + bounced, + hex::encode(tx.hash) + ), + BounceStatus::Sent => { /* Status is correct */ } + } + } else { + // Else add to database + let nb = db.execute( + "INSERT INTO bounce (created, bounced, txid, status) VALUES ($1, $2, $3, $4) ON CONFLICT (txid) DO NOTHING", + &[&Timestamp::now().as_sql_micros(), &bounced.as_ref(), &tx.hash.as_ref(), &(BounceStatus::Sent as i16)], + )?; + if nb > 0 { + warn!( + "|| (onchain) {} in {}", + hex::encode(bounced), + hex::encode(tx.hash) + ); + } + } + } + }, + Err(_) => { /* Ignore */ } + } + Ok(()) +} + +/// Send a debit transaction on the blockchain, return false if no more requested transactions are found +fn debit(db: &mut Client, rpc: &mut Rpc, state: &WorkerCfg) -> LoopResult<bool> { + // We rely on the advisory lock to ensure we are the only one sending transactions + let row = db.query_opt( +"SELECT id, (amount).val, (amount).frac, wtid, credit_acc, exchange_url FROM tx_out WHERE status=$1 ORDER BY created LIMIT 1", +&[&(DebitStatus::Requested as i16)], +)?; + if let Some(row) = &row { + let id: i64 = row.get(0); + let amount = sql_eth_amount(row, 1, &state.currency); + let wtid: ShortHashCode = sql_base_32(row, 3); + let addr = sql_addr(row, 4); + let url = sql_url(row, 5); + let now = Timestamp::now(); + let tx_id = rpc.debit(state.account.0, addr, amount, wtid.clone(), url)?; + fail_point("(injected) fail debit", 0.3)?; + db.execute( + "UPDATE tx_out SET status=$1, txid=$2, sent=$3 WHERE id=$4", + &[ + &(DebitStatus::Sent as i16), + &tx_id.as_ref(), + &now.as_sql_micros(), + &id, + ], + )?; + let amount = eth_to_taler(&amount, &state.currency); + info!( + ">> {amount} {wtid} in {} to {}", + hex::encode(tx_id), + hex::encode(addr) + ); + } + Ok(row.is_some()) +} + +/// Bump a stuck transaction, return false if no more stuck transactions are found +fn bump(db: &mut Client, rpc: &mut Rpc, state: &WorkerCfg) -> LoopResult<bool> { + if let Some(delay) = state.bump_delay { + let now = Timestamp::now().as_sql_micros(); + // We rely on the advisory lock to ensure we are the only one sending transactions + let row = db.query_opt( + "SELECT id, txid FROM tx_out WHERE status=$1 AND $2 - sent > $3 ORDER BY created LIMIT 1", + &[&(DebitStatus::Sent as i16), &now, &((delay * 1000000) as i64)], + )?; + if let Some(row) = &row { + let now = Timestamp::now(); + let id: i64 = row.get(0); + let txid = sql_hash(row, 1); + let tx = rpc.get_transaction(&txid)?.expect("Bump existing tx"); + rpc.send_transaction(&TransactionRequest { + from: tx.from.unwrap(), + to: tx.to.unwrap(), + value: tx.value, + gas_price: None, + data: tx.input, + nonce: Some(tx.nonce), + })?; + let row = db.query_one( + "UPDATE tx_out SET sent=$1 WHERE id=$2 RETURNING wtid", + &[&now.as_sql_micros(), &id], + )?; + let wtid: ShortHashCode = sql_base_32(&row, 0); + info!(">> (bump) {wtid} in {}", hex::encode(txid)); + } + Ok(row.is_some()) + } else { + Ok(false) + } +} + +/// Bounce a transaction on the blockchain, return false if no more requested transactions are found +fn bounce(db: &mut Client, rpc: &mut Rpc, fee: U256) -> LoopResult<bool> { + // We rely on the advisory lock to ensure we are the only one sending transactions + let row = db.query_opt( + "SELECT id, bounced FROM bounce WHERE status=$1 ORDER BY created LIMIT 1", + &[&(BounceStatus::Requested as i16)], + )?; + if let Some(row) = &row { + let id: i64 = row.get(0); + let bounced: H256 = sql_hash(row, 1); + + let bounce = rpc.bounce(bounced, fee)?; + match bounce { + Some(hash) => { + fail_point("(injected) fail bounce", 0.3)?; + db.execute( + "UPDATE bounce SET txid=$1, status=$2 WHERE id=$3", + &[&hash.as_ref(), &(BounceStatus::Sent as i16), &id], + )?; + info!("|| {} in {}", hex::encode(bounced), hex::encode(hash)); + } + None => { + db.execute( + "UPDATE bounce SET status=$1 WHERE id=$2", + &[&(BounceStatus::Ignored as i16), &id], + )?; + info!("|| (ignore) {} ", hex::encode(bounced)); + } + } + } + Ok(row.is_some()) +} diff --git a/depolymerizer-ethereum/src/main.rs b/depolymerizer-ethereum/src/main.rs @@ -0,0 +1,214 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use axum::{Router, middleware}; +use clap::Parser; +use common::{ + named_spawn, + taler_common::{ + CommonArgs, + cli::{ConfigCmd, long_version}, + config::Config, + }, +}; +use depolymerizer_ethereum::{ + CONFIG_SOURCE, DB_SCHEMA, SyncState, + api::{ServerState, status_middleware}, + config::{ServeCfg, WorkerCfg, parse_db_cfg}, + rpc::{Rpc, RpcClient}, +}; +use loops::{watcher::watcher, worker::worker}; +use taler_api::api::TalerRouter as _; +use taler_common::{ + db::{dbinit, pool}, + taler_main, +}; +use tracing::info; + +mod fail_point; +mod loops; +mod sql; + +/// Taler wire for geth +#[derive(clap::Parser, Debug)] +#[command(long_version = long_version(), about, long_about = None)] +struct Args { + #[clap(flatten)] + common: CommonArgs, + #[clap(subcommand)] + cmd: Command, +} + +#[derive(clap::Subcommand, Debug)] +enum Command { + /// Initialize btc-wire database + Dbinit { + /// Reset database (DANGEROUS: All existing data is lost) + #[clap(long, short)] + reset: bool, + }, + /// TODO + Setup { + #[clap(long, short)] + reset: bool, + }, + /// Run btc-wire worker + Worker { + /// Execute once and return + #[clap(long, short)] + transient: bool, + }, + /// Run btc-wire HTTP server + Serve { + /// Check whether an API is in use (if it's useful to start the HTTP + /// server). Exit with 0 if at least one API is enabled, otherwise 1 + #[clap(long)] + check: bool, + }, + #[command(subcommand)] + Config(ConfigCmd), +} + +/* +fn run(config: Option<PathBuf>) { + let state = WireState::load_taler_config(config.as_deref()); + + let rpc_worker = auto_rpc_wallet(state.ipc_path.clone(), state.account.0); + let rpc_watcher = auto_rpc_common(state.ipc_path.clone()); + + let db_watcher = auto_reconnect_db(state.db_config.clone(), "TODO".to_owned()); + let db_worker = auto_reconnect_db(state.db_config.clone(), "TODO".to_owned()); + + named_spawn("watcher", move || watcher(rpc_watcher, db_watcher)); + worker(rpc_worker, db_worker, state); + info!("eth-wire stopped"); +}*/ + +async fn app(args: Args, cfg: Config) -> anyhow::Result<()> { + match args.cmd { + Command::Dbinit { reset } => { + let cfg = parse_db_cfg(&cfg)?; + let pool = pool(cfg.cfg, DB_SCHEMA).await?; + let mut conn = pool.acquire().await?; + dbinit( + &mut conn, + cfg.sql_dir.as_ref(), + CONFIG_SOURCE.component_name, + reset, + ) + .await?; + } + Command::Setup { reset } => { + info!("Connect to geth"); + let state = WorkerCfg::parse(&cfg)?; + let mut rpc = Rpc::new(&state.ipc_path)?; + let block = rpc.earliest_block()?; + + /*#[cfg(feature = "fail")] + if info.chain != "regtest" { + anyhow::bail!("Running with random failures is unsuitable for production"); + }*/ + + //TODO + // TODO wait for the blockchain to sync + // TODO Check wire wallet own config PAYTO address + + info!("Check wallet"); + // TODO + + info!("Setup database state"); + let db_cfg = parse_db_cfg(&cfg)?; + let state = SyncState { + tip_hash: block.hash.unwrap(), + tip_height: block.number.unwrap(), + conf_height: block.number.unwrap(), + }; + let pool = pool(db_cfg.cfg, DB_SCHEMA).await?; + + // Init status to true + sqlx::query("INSERT INTO state (name, value) VALUES ('status', $1) ON CONFLICT (name) DO NOTHING") + .bind([1u8]) + .execute( &pool).await?; + sqlx::query( + "INSERT INTO state (name, value) VALUES ('sync', $1) ON CONFLICT (name) DO NOTHING", + ) + .bind(state.to_bytes()) + .execute(&pool) + .await?; + // TODO reset ? + + println!("Database initialised"); + } + Command::Worker { transient } => { + let state = WorkerCfg::parse(&cfg)?; + + #[cfg(feature = "fail")] + tracing::warn!("Running with random failures"); + // TODO Check wire wallet own config PAYTO address + + named_spawn("worker", move || { + let tmp = state.clone(); + named_spawn("watcher", move || watcher(&tmp)); + worker(state) + }) + .join() + .unwrap(); + + info!("btc-wire stopped"); + } + Command::Serve { check } => { + if check { + let cfg = ServeCfg::parse(&cfg)?; + if cfg.revenue.is_none() && cfg.wire_gateway.is_none() { + std::process::exit(1); + } + } else { + let db = parse_db_cfg(&cfg)?; + let pool = pool(db.cfg, DB_SCHEMA).await?; + let cfg = ServeCfg::parse(&cfg)?; + let api = ServerState::start(pool, cfg.payto, cfg.currency).await; + let mut router = Router::new(); + + if let Some(cfg) = cfg.wire_gateway { + router = router.wire_gateway(api.clone(), cfg.auth); + } else { + panic!("lol") + } + + /*if let Some(cfg) = cfg.revenue { + router = router.revenue(api, cfg.auth); + }*/ + // TODO http lifetime + router + .layer(middleware::from_fn_with_state( + api.clone(), + status_middleware, + )) + .serve(cfg.serve, cfg.lifetime) + .await?; + } + } + Command::Config(cfg_cmd) => cfg_cmd.run(cfg)?, + } + Ok(()) +} + +fn main() { + let args = Args::parse(); + taler_main(CONFIG_SOURCE, args.common.clone(), |cfg| async move { + app(args, cfg).await + }) +} diff --git a/depolymerizer-ethereum/src/payto.rs b/depolymerizer-ethereum/src/payto.rs @@ -0,0 +1,75 @@ +/* + This file is part of TALER + Copyright (C) 2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::str::FromStr; + +use ethereum_types::Address; +use rustc_hex::FromHexError; +use taler_common::types::payto::{FullPayto, Payto, PaytoErr, PaytoImpl, PaytoURI, TransferPayto}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EthAccount(pub Address); + +const ETHEREUM: &str = "ethereum"; + +#[derive(Debug, thiserror::Error)] +pub enum EthErr { + #[error("missing ethereum address in path")] + MissingAddr, + #[error("malformed ethereum address: {0}")] + Addr(FromHexError), +} + +impl PaytoImpl for EthAccount { + fn as_payto(&self) -> PaytoURI { + PaytoURI::from_parts(ETHEREUM, format_args!("/{}", hex::encode(self.0))) + } + + fn parse(raw: &PaytoURI) -> Result<Self, PaytoErr> { + let url = raw.as_ref(); + if url.domain() != Some(ETHEREUM) { + return Err(PaytoErr::UnsupportedKind( + ETHEREUM, + url.domain().unwrap_or_default().to_owned(), + )); + } + let Some(mut segments) = url.path_segments() else { + return Err(PaytoErr::custom(EthErr::MissingAddr)); + }; + let Some(first) = segments.next() else { + return Err(PaytoErr::custom(EthErr::MissingAddr)); + }; + let addr = Address::from_str(first).map_err(|e| PaytoErr::custom(EthErr::Addr(e)))?; + Ok(Self(addr)) + } +} + +impl FromStr for EthAccount { + type Err = FromHexError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + Ok(Self(Address::from_str(s)?)) + } +} + +/// Parse an ethereum payto URI, panic if malformed +pub fn eth_payto(url: impl AsRef<str>) -> FullEthPayto { + url.as_ref().parse().expect("invalid eth payto") +} + +pub type EthPayto = Payto<EthAccount>; +pub type FullEthPayto = FullPayto<EthAccount>; +pub type TransferEthPayto = TransferPayto<EthAccount>; diff --git a/depolymerizer-ethereum/src/rpc.rs b/depolymerizer-ethereum/src/rpc.rs @@ -0,0 +1,573 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +//! This is a very simple RPC client designed only for a specific geth version +//! and to use on an secure unix domain socket to a trusted node +//! +//! We only parse the thing we actually use, this reduce memory usage and +//! make our code more compatible with future deprecation + +use common::url::Url; +use ethereum_types::{Address, H160, H256, U64, U256}; +use serde::de::DeserializeOwned; +use std::{ + fmt::Debug, + io::{self, BufWriter, ErrorKind, Read, Write}, + os::unix::net::UnixStream, + path::Path, +}; + +use self::hex::Hex; + +/// Create a rpc connection with an unlocked wallet +pub fn rpc_wallet(ipc_path: impl AsRef<Path>, password: &str, address: &Address) -> Result<Rpc> { + let mut rpc = Rpc::new(ipc_path)?; + rpc.unlock_account(address, password)?; + Ok(rpc) +} + +/// Create a rpc connection +pub fn rpc_common(ipc_path: impl AsRef<Path>) -> Result<Rpc> { + Ok(Rpc::new(ipc_path)?) +} + +#[derive(Debug, serde::Serialize)] +struct RpcRequest<'a, T: serde::Serialize> { + jsonrpc: &'static str, + method: &'a str, + id: u64, + params: &'a T, +} + +#[derive(Debug, serde::Deserialize)] +struct RpcResponse<T> { + result: Option<T>, + error: Option<RpcErr>, + id: u64, +} + +#[derive(Debug, serde::Deserialize)] +struct RpcErr { + code: i64, + message: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{0:?}")] + Transport(#[from] std::io::Error), + #[error("{code:?} - {msg}")] + RPC { code: i64, msg: String }, + #[error("JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("Null rpc, no result or error")] + Null, +} + +pub type Result<T> = std::result::Result<T, Error>; + +const EMPTY: [(); 0] = []; + +/// Ethereum RPC connection +pub struct Rpc { + id: u64, + conn: BufWriter<UnixStream>, + read_buf: Vec<u8>, + cursor: usize, +} + +impl Rpc { + /// Start a RPC connection, path can be datadir or ipc path + pub fn new(path: impl AsRef<Path>) -> io::Result<Self> { + let path = path.as_ref(); + + let conn = if path.is_dir() { + UnixStream::connect(path.join("geth.ipc")) + } else { + UnixStream::connect(path) + }?; + + Ok(Self { + id: 0, + conn: BufWriter::new(conn), + read_buf: vec![0u8; 8 * 1024], + cursor: 0, + }) + } + + fn send(&mut self, method: &str, params: &impl serde::Serialize) -> Result<()> { + let request = RpcRequest { + method, + id: self.id, + params, + jsonrpc: "2.0", + }; + + // Send request + serde_json::to_writer(&mut self.conn, &request)?; + self.conn.flush()?; + Ok(()) + } + + fn receive<T>(&mut self) -> Result<T> + where + T: serde::de::DeserializeOwned + Debug, + { + loop { + // Read one + let pos = self.read_buf[..self.cursor] + .iter() + .position(|c| *c == b'\n') + .map(|pos| pos + 1); // Move after newline + if let Some(pos) = pos { + match serde_json::from_slice(&self.read_buf[..pos]) { + Ok(response) => { + self.read_buf.copy_within(pos..self.cursor, 0); + self.cursor -= pos; + return Ok(response); + } + Err(err) => return Err(err)?, + } + } // Or read more + + // Double buffer size if full + if self.cursor == self.read_buf.len() { + self.read_buf.resize(self.cursor * 2, 0); + } + match self.conn.get_mut().read(&mut self.read_buf[self.cursor..]) { + Ok(0) => Err(std::io::Error::new( + ErrorKind::UnexpectedEof, + "RPC EOF".to_string(), + ))?, + Ok(nb) => self.cursor += nb, + Err(e) if e.kind() == ErrorKind::Interrupted => {} + Err(e) => Err(e)?, + } + } + } + + pub fn subscribe_new_head(&mut self) -> Result<RpcStream<Nothing>> { + let id: String = self.call("eth_subscribe", &["newHeads"])?; + Ok(RpcStream::new(self, id)) + } + + fn handle_response<T>(&mut self, response: RpcResponse<T>) -> Result<T> { + assert_eq!(self.id, response.id); + self.id += 1; + if let Some(ok) = response.result { + Ok(ok) + } else { + Err(match response.error { + Some(err) => Error::RPC { + code: err.code, + msg: err.message, + }, + None => Error::Null, + }) + } + } +} + +impl RpcClient for Rpc { + fn call<T>(&mut self, method: &str, params: &impl serde::Serialize) -> Result<T> + where + T: serde::de::DeserializeOwned + Debug, + { + self.send(method, params)?; + let response = self.receive()?; + self.handle_response(response) + } +} + +#[derive(Debug, serde::Deserialize)] +pub struct NotificationContent<T> { + subscription: String, + result: T, +} + +#[derive(Debug, serde::Deserialize)] + +struct Notification<T> { + params: NotificationContent<T>, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +enum NotificationOrResponse<T, N> { + Notification(Notification<N>), + Response(RpcResponse<T>), +} +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +enum SubscribeDirtyFix { + Fix(RpcResponse<bool>), + Id(RpcResponse<String>), +} + +/// A notification stream wrapping an rpc client +pub struct RpcStream<'a, N: Debug + DeserializeOwned> { + rpc: &'a mut Rpc, + id: String, + buff: Vec<N>, +} + +impl<'a, N: Debug + DeserializeOwned> RpcStream<'a, N> { + fn new(rpc: &'a mut Rpc, id: String) -> Self { + Self { + rpc, + id, + buff: vec![], + } + } + + /// Block until next notification + pub fn next(&mut self) -> Result<N> { + match self.buff.pop() { + // Consume buffered notifications + Some(prev) => Ok(prev), + // Else read next one + None => { + let notification: Notification<N> = self.rpc.receive()?; + let notification = notification.params; + assert_eq!(self.id, notification.subscription); + Ok(notification.result) + } + } + } +} + +impl<N: Debug + DeserializeOwned> Drop for RpcStream<'_, N> { + fn drop(&mut self) { + let Self { rpc, id, .. } = self; + // Request unsubscription, ignoring error + rpc.send("eth_unsubscribe", &[id]).ok(); + // Ignore all buffered notification until subscription response + while let Ok(response) = rpc.receive::<NotificationOrResponse<bool, N>>() { + match response { + NotificationOrResponse::Notification(_) => { /* Ignore */ } + NotificationOrResponse::Response(_) => return, + } + } + } +} + +impl<N: Debug + DeserializeOwned> RpcClient for RpcStream<'_, N> { + fn call<T>(&mut self, method: &str, params: &impl serde::Serialize) -> Result<T> + where + T: serde::de::DeserializeOwned + Debug, + { + self.rpc.send(method, params)?; + loop { + // Buffer notifications until response + let response: NotificationOrResponse<T, N> = self.rpc.receive()?; + match response { + NotificationOrResponse::Notification(n) => { + let n = n.params; + assert_eq!(self.id, n.subscription); + self.buff.push(n.result); + } + NotificationOrResponse::Response(response) => { + return self.rpc.handle_response(response); + } + } + } + } +} + +pub trait RpcClient { + fn call<T>(&mut self, method: &str, params: &impl serde::Serialize) -> Result<T> + where + T: serde::de::DeserializeOwned + Debug; + + /* ----- Account management ----- */ + + /// List registered account + fn list_accounts(&mut self) -> Result<Vec<Address>> { + self.call("personal_listAccounts", &EMPTY) + } + + /// Create a new encrypted account + fn new_account(&mut self, passwd: &str) -> Result<Address> { + self.call("personal_newAccount", &[passwd]) + } + + /// Unlock an existing account + fn unlock_account(&mut self, account: &Address, passwd: &str) -> Result<bool> { + self.call("personal_unlockAccount", &(account, passwd, 0)) + } + + /* ----- Getter ----- */ + + /// Get a transaction by hash + fn get_transaction(&mut self, hash: &H256) -> Result<Option<Transaction>> { + match self.call("eth_getTransactionByHash", &[hash]) { + Err(Error::Null) => Ok(None), + r => r, + } + } + + /// Get a transaction receipt by hash + fn get_transaction_receipt(&mut self, hash: &H256) -> Result<Option<TransactionReceipt>> { + match self.call("eth_getTransactionReceipt", &[hash]) { + Err(Error::Null) => Ok(None), + r => r, + } + } + + /// Get block by hash + fn block(&mut self, hash: &H256) -> Result<Option<Block>> { + match self.call("eth_getBlockByHash", &(hash, &true)) { + Err(Error::Null) => Ok(None), + r => r, + } + } + + /// Get pending transactions + fn pending_transactions(&mut self) -> Result<Vec<Transaction>> { + self.call("eth_pendingTransactions", &EMPTY) + } + + /// Get latest block + fn latest_block(&mut self) -> Result<Block> { + self.call("eth_getBlockByNumber", &("latest", &true)) + } + + /// Get earliest block (genesis if not pruned) + fn earliest_block(&mut self) -> Result<Block> { + self.call("eth_getBlockByNumber", &("earliest", &true)) + } + + /// Get latest account balance + fn get_balance_latest(&mut self, addr: &Address) -> Result<U256> { + self.call("eth_getBalance", &(addr, "latest")) + } + + /// Get pending account balance + fn get_balance_pending(&mut self, addr: &Address) -> Result<U256> { + self.call("eth_getBalance", &(addr, "pending")) + } + + /// Get pending account balance + fn height(&mut self) -> Result<U64> { + self.call("eth_blockNumber", &EMPTY) + } + + /// Get node info + fn node_info(&mut self) -> Result<NodeInfo> { + self.call("admin_nodeInfo", &EMPTY) + } + + /* ----- Transactions ----- */ + + /// Fill missing options from transaction request with default values + fn fill_transaction(&mut self, req: &TransactionRequest) -> Result<Filled> { + self.call("eth_fillTransaction", &[req]) + } + + /// Send ethereum transaction + fn send_transaction(&mut self, req: &TransactionRequest) -> Result<H256> { + self.call("eth_sendTransaction", &[req]) + } + + /* ----- Miner ----- */ + + fn miner_set_etherbase(&mut self, addr: &H160) -> Result<bool> { + self.call("miner_setEtherbase", &[addr]) + } + + /// Start mining + fn miner_start(&mut self) -> Result<()> { + match self.call("miner_start", &EMPTY) { + Err(Error::Null) => Ok(()), + i => i, + } + } + + /// Stop mining + fn miner_stop(&mut self) -> Result<()> { + match self.call("miner_stop", &EMPTY) { + Err(Error::Null) => Ok(()), + i => i, + } + } + + /* ----- Peer management ----- */ + + fn export_chain(&mut self, path: &str) -> Result<bool> { + self.call("admin_exportChain", &[path]) + } + + fn import_chain(&mut self, path: &str) -> Result<bool> { + self.call("admin_importChain", &[path]) + } +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct Block { + pub hash: Option<H256>, + /// Block number (None if pending) + pub number: Option<U64>, + #[serde(rename = "parentHash")] + pub parent_hash: H256, + pub transactions: Vec<Transaction>, +} + +#[derive(Debug, serde::Deserialize)] +pub struct Nothing {} + +/// Description of a Transaction, pending or in the chain. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct Transaction { + pub hash: H256, + pub nonce: U256, + /// Sender address (None when coinbase) + pub from: Option<Address>, + /// Recipient address (None when contract creation) + pub to: Option<Address>, + /// Transferred value + pub value: U256, + /// Input data + pub input: Hex, +} + +/// Description of a Transaction, pending or in the chain. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct TransactionReceipt { + /// Gas used by this transaction alone. + #[serde(rename = "gasUsed")] + pub gas_used: U256, + /// Effective gas price + #[serde(rename = "effectiveGasPrice")] + pub effective_gas_price: Option<U256>, +} + +/// Fill result +#[derive(Debug, serde::Deserialize)] +pub struct Filled { + pub tx: FilledGas, +} + +/// Filles gas +#[derive(Debug, serde::Deserialize)] +pub struct FilledGas { + /// Supplied gas + pub gas: U256, + #[serde(rename = "gasPrice")] + pub gas_price: Option<U256>, + #[serde(rename = "maxFeePerGas")] + pub max_fee_per_gas: Option<U256>, +} + +/// Send Transaction Parameters +#[derive(Debug, serde::Serialize)] +pub struct TransactionRequest { + /// Sender address + pub from: Address, + /// Recipient address + pub to: Address, + /// Transferred value + pub value: U256, + /// Gas price (None for sensible default) + #[serde(rename = "gasPrice")] + pub gas_price: Option<U256>, + /// Transaction data + pub data: Hex, + /// Transaction nonce (None for next available nonce) + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option<U256>, +} + +#[derive(Debug, serde::Deserialize)] +pub struct NodeInfo { + pub enode: Url, +} + +pub mod hex { + use std::{ + fmt, + ops::{Deref, DerefMut}, + }; + + use serde::{ + Deserialize, Deserializer, Serialize, Serializer, + de::{Error, Unexpected, Visitor}, + }; + + /// Raw bytes wrapper + #[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] + pub struct Hex(pub Vec<u8>); + + impl Deref for Hex { + type Target = Vec<u8>; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl DerefMut for Hex { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } + } + + impl Serialize for Hex { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_str(&hex::encode_prefixed(&self.0)) + } + } + + impl<'a> Deserialize<'a> for Hex { + fn deserialize<D>(deserializer: D) -> Result<Hex, D::Error> + where + D: Deserializer<'a>, + { + deserializer.deserialize_identifier(BytesVisitor) + } + } + + struct BytesVisitor; + + impl Visitor<'_> for BytesVisitor { + type Value = Hex; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + write!(formatter, "a 0x-prefixed hex-encoded vector of bytes") + } + + fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> + where + E: Error, + { + if value.len() >= 2 && &value[0..2] == "0x" { + let bytes = hex::decode(&value[2..]) + .map_err(|e| Error::custom(format!("Invalid hex: {e}")))?; + Ok(Hex(bytes)) + } else { + Err(Error::invalid_value(Unexpected::Str(value), &"0x prefix")) + } + } + + fn visit_string<E>(self, value: String) -> Result<Self::Value, E> + where + E: Error, + { + self.visit_str(value.as_ref()) + } + } +} diff --git a/depolymerizer-ethereum/src/sql.rs b/depolymerizer-ethereum/src/sql.rs @@ -0,0 +1,40 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +use common::{ + postgres::Row, + sql::{sql_amount, sql_array}, +}; +use depolymerizer_ethereum::taler_util::taler_to_eth; +use ethereum_types::{H160, H256, U256}; +use taler_common::types::amount::Currency; + +/// Ethereum amount from sql +pub fn sql_eth_amount(row: &Row, idx: usize, currency: &Currency) -> U256 { + let amount = sql_amount(row, idx, currency); + taler_to_eth(&amount) +} + +/// Ethereum address from sql +pub fn sql_addr(row: &Row, idx: usize) -> H160 { + let array: [u8; 20] = sql_array(row, idx); + H160::from_slice(&array) +} + +/// Ethereum hash from sql +pub fn sql_hash(row: &Row, idx: usize) -> H256 { + let array: [u8; 32] = sql_array(row, idx); + H256::from_slice(&array) +} diff --git a/depolymerizer-ethereum/src/taler_util.rs b/depolymerizer-ethereum/src/taler_util.rs @@ -0,0 +1,36 @@ +/* + This file is part of TALER + Copyright (C) 2022-2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use common::taler_common::types::amount::{Amount, FRAC_BASE}; +use ethereum_types::U256; +use taler_common::types::amount::Currency; + +pub const WEI: u64 = 1_000_000_000_000_000_000; +pub const TRUNC: u64 = WEI / FRAC_BASE as u64; + +/// Transform a eth amount into a taler amount +pub fn eth_to_taler(amount: &U256, currency: &Currency) -> Amount { + Amount::new( + currency, + (amount / WEI).as_u64(), + ((amount % WEI) / TRUNC).as_u32(), + ) +} + +/// Transform a eth amount into a btc amount +pub fn taler_to_eth(amount: &Amount) -> U256 { + U256::from(amount.val) * WEI + U256::from(amount.frac) * TRUNC +} diff --git a/depolymerizer-ethereum/tests/api.rs b/depolymerizer-ethereum/tests/api.rs @@ -0,0 +1,105 @@ +/* + This file is part of TALER + Copyright (C) 2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::str::FromStr; + +use axum::Router; +use depolymerizer_ethereum::{CONFIG_SOURCE, api::ServerState}; +use sqlx::PgPool; +use taler_api::{api::TalerRouter as _, auth::AuthMethod}; +use taler_common::{ + api_common::{HashCode, ShortHashCode}, + api_wire::{OutgoingHistory, TransferState, WireConfig}, + types::{amount::Currency, payto::payto}, +}; +use taler_test_utils::{ + db_test_setup, json, + routine::{admin_add_incoming_routine, routine_pagination, transfer_routine}, + server::TestServer, +}; + +async fn setup() -> (Router, PgPool) { + let pool = db_test_setup(CONFIG_SOURCE).await; + let api = ServerState::start( + pool.clone(), + payto("payto://ethereum/06012c8cf97bead5deae237070f9587f8e7a266d"), + Currency::from_str("ETH").unwrap(), + ) + .await; + let server = Router::new() + .wire_gateway(api.clone(), AuthMethod::None) + .finalize(); + + (server, pool) +} + +#[tokio::test] +async fn config() { + let (server, _) = setup().await; + server + .get("/taler-wire-gateway/config") + .await + .assert_ok_json::<WireConfig>(); +} + +#[tokio::test] +async fn transfer() { + let (server, _) = setup().await; + transfer_routine( + &server, + TransferState::success, + &payto("payto://ethereum/06012c8cf97bead5deae237070f9587f8e7a266d?receiver-name=Anonymous"), + ) + .await; +} + +#[tokio::test] +async fn outgoing_history() { + let (server, _) = setup().await; + routine_pagination::<OutgoingHistory, _>( + &server, + "/taler-wire-gateway/history/outgoing", + |it| { + it.outgoing_transactions + .into_iter() + .map(|it| *it.row_id as i64) + .collect() + }, + |s, _| async { + s.post("/taler-wire-gateway/transfer").json( + &json!({ + "request_uid": HashCode::rand(), + "amount": "ETH:10", + "exchange_base_url": "http://exchange.taler/", + "wtid": ShortHashCode::rand(), + "credit_account": "payto://ethereum/06012c8cf97bead5deae237070f9587f8e7a266d?receiver-name=Anonymous", + }) + ).await; + }, + ) + .await; +} + +#[tokio::test] +async fn admin_add_incoming() { + let (server, _) = setup().await; + admin_add_incoming_routine( + &server, + &payto("payto://ethereum/06012c8cf97bead5deae237070f9587f8e7a266d?receiver-name=Anonymous"), + false, + ) + .await; +} diff --git a/doc/prebuilt b/doc/prebuilt @@ -0,0 +1 @@ +Subproject commit 3ef1480c35cf756935371587f760b0aa438dba62 diff --git a/eth-wire/Cargo.toml b/eth-wire/Cargo.toml @@ -1,27 +0,0 @@ -[package] -name = "eth-wire" -version = "0.1.0" -edition.workspace = true -authors.workspace = true -homepage.workspace = true -repository.workspace = true -license-file.workspace = true - -[features] -# Enable random failures -fail = [] - -[dependencies] -# Cli args -clap.workspace = true -# Serialization library -serde.workspace = true -serde_json.workspace = true -# Hexadecimal encoding -hex.workspace = true -# Ethereum serializable types -ethereum-types.workspace = true -# Error macros -thiserror.workspace = true -# Common lib -common = { path = "../common" } diff --git a/eth-wire/src/fail_point.rs b/eth-wire/src/fail_point.rs @@ -1,31 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -#[derive(Debug, thiserror::Error)] -#[error("{0}")] -pub struct Injected(&'static str); - -/// Inject random failure when 'fail' feature is used -#[allow(unused_variables)] -pub fn fail_point(msg: &'static str, prob: f32) -> Result<(), Injected> { - #[cfg(feature = "fail")] - return if common::rand::random::<f32>() < prob { - Err(Injected(msg)) - } else { - Ok(()) - }; - - Ok(()) -} diff --git a/eth-wire/src/lib.rs b/eth-wire/src/lib.rs @@ -1,311 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use std::{ - fmt::Debug, - path::{Path, PathBuf}, - str::FromStr, -}; - -use common::{ - config::TalerConfig, - currency::{Currency, CurrencyEth}, - log::{OrFail, fail}, - metadata::{InMetadata, OutMetadata}, - payto::EthAccount, - postgres, - taler_common::{ - api_common::{EddsaPublicKey, ShortHashCode}, - types::{amount::Amount, payto::PaytoImpl}, - }, - url::Url, -}; -use ethereum_types::{Address, H256, U64, U256}; -use rpc::{Rpc, RpcClient, RpcStream, Transaction, hex::Hex}; -use rpc_utils::default_data_dir; -use serde::de::DeserializeOwned; -use taler_util::taler_to_eth; - -pub mod rpc; -mod rpc_utils; -pub mod taler_util; - -/// An extended geth JSON-RPC api client who can send and retrieve metadata with their transaction -pub trait RpcExtended: RpcClient { - /// Perform a wire credit - fn credit( - &mut self, - from: Address, - to: Address, - value: U256, - reserve_pub: EddsaPublicKey, - ) -> rpc::Result<H256> { - let metadata = InMetadata::Credit { reserve_pub }; - self.send_transaction(&rpc::TransactionRequest { - from, - to, - value, - nonce: None, - gas_price: None, - data: Hex(metadata.encode()), - }) - } - - /// Perform a wire debit - fn debit( - &mut self, - from: Address, - to: Address, - value: U256, - wtid: ShortHashCode, - url: Url, - ) -> rpc::Result<H256> { - let metadata = OutMetadata::Debit { wtid, url }; - self.send_transaction(&rpc::TransactionRequest { - from, - to, - value, - nonce: None, - gas_price: None, - data: Hex(metadata.encode().or_fail(|e| format!("{}", e))), - }) - } - - /// Perform a Taler bounce - fn bounce(&mut self, hash: H256, bounce_fee: U256) -> rpc::Result<Option<H256>> { - let tx = self - .get_transaction(&hash)? - .expect("Cannot bounce a non existent transaction"); - let bounce_value = tx.value.saturating_sub(bounce_fee); - let metadata = OutMetadata::Bounce { bounced: hash.0 }; - let mut request = rpc::TransactionRequest { - from: tx.to.expect("Cannot bounce contract transaction"), - to: tx.from.expect("Cannot bounce coinbase transaction"), - value: bounce_value, - nonce: None, - gas_price: None, - data: Hex(metadata.encode().or_fail(|e| format!("{}", e))), - }; - // Estimate fee price using node - let fill = self.fill_transaction(&request)?; - // Deduce fee price from bounced value - request.value = request - .value - .saturating_sub(fill.tx.gas * fill.tx.gas_price.or(fill.tx.max_fee_per_gas).unwrap()); - Ok(if request.value.is_zero() { - None - } else { - Some(self.send_transaction(&request)?) - }) - } - - /// List new and removed transaction since the last sync state and the size of the reorganized fork if any, returning a new sync state - fn list_since_sync( - &mut self, - address: &Address, - state: SyncState, - min_confirmation: u32, - ) -> rpc::Result<ListSinceSync> { - let match_tx = |txs: Vec<Transaction>, confirmations: u32| -> Vec<SyncTransaction> { - txs.into_iter() - .filter_map(|tx| { - (tx.from == Some(*address) || tx.to == Some(*address)) - .then_some(SyncTransaction { tx, confirmations }) - }) - .collect() - }; - - let mut txs = Vec::new(); - let mut removed = Vec::new(); - let mut fork_len = 0; - - // Add pending transaction - txs.extend(match_tx(self.pending_transactions()?, 0)); - - let latest = self.latest_block()?; - - let mut confirmation = 1; - let mut chain_cursor = latest.clone(); - - // Move until tip height - while chain_cursor.number.unwrap() != state.tip_height { - txs.extend(match_tx(chain_cursor.transactions, confirmation)); - chain_cursor = self.block(&chain_cursor.parent_hash)?.unwrap(); - confirmation += 1; - } - - // Check if fork - if chain_cursor.hash.unwrap() != state.tip_hash { - let mut fork_cursor = self.block(&state.tip_hash)?.unwrap(); - // Move until found common parent - while fork_cursor.hash != chain_cursor.hash { - txs.extend(match_tx(chain_cursor.transactions, confirmation)); - removed.extend(match_tx(fork_cursor.transactions, confirmation)); - chain_cursor = self.block(&chain_cursor.parent_hash)?.unwrap(); - fork_cursor = self.block(&fork_cursor.parent_hash)?.unwrap(); - confirmation += 1; - fork_len += 1; - } - } - - // Move until last conf - while chain_cursor.number.unwrap() > state.conf_height { - txs.extend(match_tx(chain_cursor.transactions, confirmation)); - chain_cursor = self.block(&chain_cursor.parent_hash)?.unwrap(); - confirmation += 1; - } - - Ok(ListSinceSync { - txs, - removed, - fork_len, - state: SyncState { - tip_hash: latest.hash.unwrap(), - tip_height: latest.number.unwrap(), - conf_height: latest - .number - .unwrap() - .saturating_sub(U64::from(min_confirmation)), - }, - }) - } -} - -impl RpcExtended for Rpc {} -impl<N: Debug + DeserializeOwned> RpcExtended for RpcStream<'_, N> {} - -pub struct SyncTransaction { - pub tx: Transaction, - pub confirmations: u32, -} - -pub struct ListSinceSync { - pub txs: Vec<SyncTransaction>, - pub removed: Vec<SyncTransaction>, - pub state: SyncState, - pub fork_len: u32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SyncState { - pub tip_hash: H256, - pub tip_height: U64, - pub conf_height: U64, -} - -impl SyncState { - pub fn to_bytes(&self) -> [u8; 48] { - let mut bytes = [0; 48]; - bytes[..32].copy_from_slice(self.tip_hash.as_bytes()); - bytes[32..40].copy_from_slice(&self.tip_height.to_little_endian()); - bytes[40..].copy_from_slice(&self.conf_height.to_little_endian()); - bytes - } - - pub fn from_bytes(bytes: &[u8; 48]) -> Self { - Self { - tip_hash: H256::from_slice(&bytes[..32]), - tip_height: U64::from_little_endian(&bytes[32..40]), - conf_height: U64::from_little_endian(&bytes[40..]), - } - } -} - -const DEFAULT_CONFIRMATION: u16 = 37; -const DEFAULT_BOUNCE_FEE: &str = "0.00001"; - -pub struct WireState { - pub confirmation: u32, - pub max_confirmations: u32, - pub bounce_fee: U256, - pub ipc_path: PathBuf, - pub lifetime: Option<u32>, - pub bump_delay: Option<u32>, - pub base_url: Url, - pub account: EthAccount, - pub db_config: postgres::Config, - pub currency: CurrencyEth, -} - -impl WireState { - pub fn load_taler_config(file: Option<&Path>) -> Self { - let (taler_config, ipc_path, currency) = load_taler_config(file); - let init_confirmation = taler_config.confirmation().unwrap_or(DEFAULT_CONFIRMATION) as u32; - let account = EthAccount::parse(&taler_config.payto()).unwrap(); - Self { - confirmation: init_confirmation, - max_confirmations: init_confirmation * 2, - ipc_path, - bounce_fee: config_bounce_fee(&taler_config.bounce_fee(), currency), - lifetime: taler_config.wire_lifetime(), - bump_delay: taler_config.bump_delay(), - base_url: taler_config.base_url(), - db_config: taler_config.db_config(), - account, - currency, - } - } -} - -// Load taler config with eth-wire specific config -pub fn load_taler_config(file: Option<&Path>) -> (TalerConfig, PathBuf, CurrencyEth) { - let config = TalerConfig::load(file); - let path = config.path("IPC_PATH").unwrap_or_else(default_data_dir); - let currency = match config.currency { - Currency::ETH(it) => it, - _ => fail(format!( - "currency {} is not supported by eth-wire", - config.currency.to_str() - )), - }; - (config, path, currency) -} - -// Parse ethereum value from config bounce fee -fn config_bounce_fee(bounce_fee: &Option<String>, currency: CurrencyEth) -> U256 { - let config = bounce_fee.as_deref().unwrap_or(DEFAULT_BOUNCE_FEE); - Amount::from_str(&format!("{}:{}", currency.to_str(), config)) - .map_err(|s| s.to_string()) - .and_then(|a| taler_to_eth(&a, currency)) - .or_fail(|a| { - format!( - "config BOUNCE_FEE={} is not a valid ethereum amount: {}", - config, a - ) - }) -} - -#[cfg(test)] -mod test { - use common::{rand::random, rand_slice}; - use ethereum_types::{H256, U64}; - - use crate::SyncState; - - #[test] - fn to_from_bytes_block_state() { - for _ in 0..4 { - let state = SyncState { - tip_hash: H256::from_slice(&rand_slice::<32>()), - tip_height: U64::from(random::<u64>()), - conf_height: U64::from(random::<u64>()), - }; - let encoded = state.to_bytes(); - let decoded = SyncState::from_bytes(&encoded); - assert_eq!(state, decoded); - } - } -} diff --git a/eth-wire/src/loops.rs b/eth-wire/src/loops.rs @@ -1,38 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use common::postgres; -use eth_wire::rpc; - -use crate::fail_point::Injected; - -pub mod analysis; -pub mod watcher; -pub mod worker; - -#[derive(Debug, thiserror::Error)] -pub enum LoopError { - #[error("RPC {0}")] - Rpc(#[from] rpc::Error), - #[error("DB {0}")] - DB(#[from] postgres::Error), - #[error("Another eth-wire process is running concurrently")] - Concurrency, - #[error(transparent)] - Injected(#[from] Injected), -} - -pub type LoopResult<T> = Result<T, LoopError>; diff --git a/eth-wire/src/loops/analysis.rs b/eth-wire/src/loops/analysis.rs @@ -1,34 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use common::log::log::warn; - -use super::LoopResult; - -/// Analyse blockchain behavior and adapt confirmations in real time -pub fn analysis(fork: u32, current: u32, max: u32) -> LoopResult<u32> { - // If new fork is bigger than what current confirmation delay protect against - if fork >= current { - // Limit confirmation growth - let new_conf = fork.saturating_add(1).min(max); - warn!( - "analysis: found dangerous fork of {} blocks, adapt confirmation to {} blocks capped at {}, you should update taler.conf", - fork, new_conf, max - ); - return Ok(new_conf); - } - Ok(current) -} diff --git a/eth-wire/src/loops/watcher.rs b/eth-wire/src/loops/watcher.rs @@ -1,41 +0,0 @@ -use std::time::Duration; - -/* - This file is part of TALER - Copyright (C) 2022 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -use common::{log::log::error, reconnect::AutoReconnectDb}; -use eth_wire::rpc::AutoRpcCommon; - -use super::LoopResult; - -/// Wait for new block and notify arrival with postgreSQL notifications -pub fn watcher(mut rpc: AutoRpcCommon, mut db: AutoReconnectDb) { - loop { - let rpc = rpc.client(); - let db = db.client(); - - let result: LoopResult<()> = (|| { - let mut notifier = rpc.subscribe_new_head()?; - loop { - db.execute("NOTIFY new_block", &[])?; - notifier.next()?; - } - })(); - if let Err(e) = result { - error!("watcher: {}", e); - std::thread::sleep(Duration::from_secs(5)); - } - } -} diff --git a/eth-wire/src/loops/worker.rs b/eth-wire/src/loops/worker.rs @@ -1,524 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -use std::fmt::Write; - -use common::{ - log::log::{error, info, warn}, - metadata::{InMetadata, OutMetadata}, - postgres::{Client, fallible_iterator::FallibleIterator}, - reconnect::AutoReconnectDb, - sql::{sql_array, sql_base_32, sql_url}, - status::{BounceStatus, DebitStatus}, - taler_common::{api_common::ShortHashCode, types::timestamp::Timestamp}, -}; -use eth_wire::{ - ListSinceSync, RpcExtended, SyncState, SyncTransaction, - rpc::{self, AutoRpcWallet, Rpc, RpcClient, Transaction, TransactionRequest}, - taler_util::eth_to_taler, -}; -use ethereum_types::{Address, H256, U256}; - -use crate::{ - WireState, - fail_point::fail_point, - loops::LoopError, - sql::{sql_addr, sql_eth_amount, sql_hash}, -}; - -use super::{LoopResult, analysis::analysis}; - -pub fn worker(mut rpc: AutoRpcWallet, mut db: AutoReconnectDb, mut state: WireState) { - let mut lifetime = state.lifetime; - let mut status = true; - let mut skip_notification = false; - - loop { - // Check lifetime - if let Some(nb) = lifetime.as_mut() { - if *nb == 0 { - info!("Reach end of lifetime"); - return; - } else { - *nb -= 1; - } - } - - // Connect - let rpc = rpc.client(); - let db = db.client(); - - let result: LoopResult<()> = (|| { - // Listen to all channels - db.batch_execute("LISTEN new_block; LISTEN new_tx")?; - // Wait for the next notification - { - let mut ntf = db.notifications(); - if !skip_notification && ntf.is_empty() { - // Block until next notification - ntf.blocking_iter().next()?; - } - // Conflate all notifications - let mut iter = ntf.iter(); - while iter.next()?.is_some() {} - } - - // It is not possible to atomically update the blockchain and the database. - // When we failed to sync the database and the blockchain state we rely on - // sync_chain to recover the lost updates. - // When this function is running concurrently, it not possible to known another - // execution has failed, and this can lead to a transaction being sent multiple time. - // To ensure only a single version of this function is running at a given time we rely - // on postgres advisory lock - - // Take the lock - let row = db.query_one("SELECT pg_try_advisory_lock(42)", &[])?; - let locked: bool = row.get(0); - if !locked { - return Err(LoopError::Concurrency); - } - - // Get stored sync state - let row = db.query_one("SELECT value FROM state WHERE name='sync'", &[])?; - let sync_state = SyncState::from_bytes(&sql_array(&row, 0)); - - // Get changes - let list = rpc.list_since_sync(&state.account.0, sync_state, state.confirmation)?; - - // Perform analysis - state.confirmation = - analysis(list.fork_len, state.confirmation, state.max_confirmations)?; - - // Sync chain - if sync_chain(db, &state, &mut status, list)? { - // As we are now in sync with the blockchain if a transaction has Requested status it have not been sent - - // Send requested debits - while debit(db, rpc, &state)? {} - - // Bump stuck transactions - while bump(db, rpc, &state)? {} - - // Send requested bounce - while bounce(db, rpc, state.bounce_fee)? {} - } - Ok(()) - })(); - - if let Err(e) = result { - error!("worker: {}", e); - // When we catch an error, we sometimes want to retry immediately (eg. reconnect to RPC or DB). - // Rpc error codes are generic. We need to match the msg to get precise ones. Some errors - // can resolve themselves when a new block is mined (new fees, new transactions). Our simple - // approach is to wait for the next loop when an RPC error is caught to prevent endless logged errors. - skip_notification = !matches!( - e, - LoopError::Rpc(rpc::Error::RPC { .. }) - | LoopError::Concurrency - | LoopError::Injected(_) - ); - } else { - skip_notification = false; - } - } -} - -/// Parse new transactions, return true if the database is up to date with the latest mined block -fn sync_chain( - db: &mut Client, - state: &WireState, - status: &mut bool, - list: ListSinceSync, -) -> LoopResult<bool> { - // Get the current confirmation delay - let conf_delay = state.confirmation; - - // Check if a confirmed incoming transaction have been removed by a blockchain reorganization - let new_status = - sync_chain_removed(&list.txs, &list.removed, db, &state.account.0, conf_delay)?; - - // Sync status with database - if *status != new_status { - let mut tx = db.transaction()?; - tx.execute( - "UPDATE state SET value=$1 WHERE name='status'", - &[&[new_status as u8].as_slice()], - )?; - tx.execute("NOTIFY status", &[])?; - tx.commit()?; - *status = new_status; - if new_status { - info!("Recovered lost transactions"); - } - } - if !new_status { - return Ok(false); - } - - for sync_tx in list.txs { - let tx = &sync_tx.tx; - if tx.to == Some(state.account.0) && sync_tx.confirmations >= conf_delay { - sync_chain_incoming_confirmed(tx, db, state)?; - } else if tx.from == Some(state.account.0) { - sync_chain_outgoing(&sync_tx, db, state)?; - } - } - - db.execute( - "UPDATE state SET value=$1 WHERE name='sync'", - &[&list.state.to_bytes().as_slice()], - )?; - Ok(true) -} - -/// Sync database with removed transactions, return false if bitcoin backing is compromised -fn sync_chain_removed( - txs: &[SyncTransaction], - removed: &[SyncTransaction], - db: &mut Client, - addr: &Address, - min_confirmation: u32, -) -> LoopResult<bool> { - // A removed incoming transaction is a correctness issues in only two cases: - // - it is a confirmed credit registered in the database - // - it is an invalid transactions already bounced - // Those two cases can compromise bitcoin backing - // Removed outgoing transactions will be retried automatically by the node - - let mut blocking_credit = Vec::new(); - let mut blocking_bounce = Vec::new(); - - // Only keep incoming transaction that are not reconfirmed - // TODO study risk of accepting only mined transactions for faster recovery - for tx in removed - .iter() - .filter(|sync_tx| { - sync_tx.tx.to == Some(*addr) - && txs - .iter() - .all(|it| it.tx.hash != sync_tx.tx.hash || it.confirmations < min_confirmation) - }) - .map(|s| &s.tx) - { - match InMetadata::decode(&tx.input) { - Ok(metadata) => match metadata { - InMetadata::Credit { reserve_pub } => { - // Credits are only problematic if not reconfirmed and stored in the database - if db - .query_opt( - "SELECT 1 FROM tx_in WHERE reserve_pub=$1", - &[&reserve_pub.as_slice()], - )? - .is_some() - { - blocking_credit.push((reserve_pub, tx.hash, tx.from.unwrap())); - } - } - }, - Err(_) => { - // Invalid tx are only problematic if if not reconfirmed and already bounced - if let Some(row) = db.query_opt( - "SELECT txid FROM bounce WHERE bounced=$1 AND txid IS NOT NULL", - &[&tx.hash.as_ref()], - )? { - blocking_bounce.push((sql_hash(&row, 0), tx.hash)); - } else { - // Remove transaction from bounce table - db.execute("DELETE FROM bounce WHERE bounced=$1", &[&tx.hash.as_ref()])?; - } - } - } - } - - if !blocking_bounce.is_empty() || !blocking_credit.is_empty() { - let mut buf = "The following transaction have been removed from the blockchain, ethereum backing is compromised until the transaction reappear:".to_string(); - for (key, id, addr) in blocking_credit { - write!( - &mut buf, - "\n\tcredit {key} in {} from {}", - hex::encode(id), - hex::encode(addr) - ) - .unwrap(); - } - for (id, bounced) in blocking_bounce { - write!( - &mut buf, - "\n\tbounce {} in {}", - hex::encode(id), - hex::encode(bounced) - ) - .unwrap(); - } - error!("{}", buf); - Ok(false) - } else { - Ok(true) - } -} - -/// Sync database with an incoming confirmed transaction -fn sync_chain_incoming_confirmed( - tx: &Transaction, - db: &mut Client, - state: &WireState, -) -> Result<(), LoopError> { - match InMetadata::decode(&tx.input) { - Ok(metadata) => match metadata { - InMetadata::Credit { reserve_pub } => { - let amount = eth_to_taler(&tx.value, state.currency); - let credit_addr = tx.from.expect("Not coinbase"); - let nb = db.execute("INSERT INTO tx_in (received, amount, reserve_pub, debit_acc, credit_acc) VALUES ($1, ($2, $3)::taler_amount, $4, $5, $6) ON CONFLICT (reserve_pub) DO NOTHING ", &[ - &Timestamp::now().as_sql_micros(), &(amount.val as i64), &(amount.frac as i32), &reserve_pub.as_slice(), &credit_addr.as_bytes(), &state.account.0.as_bytes() - ])?; - if nb > 0 { - info!( - "<< {amount} {reserve_pub} in {} from {}", - hex::encode(tx.hash), - hex::encode(credit_addr), - ); - } - } - }, - Err(_) => { - // If encoding is wrong request a bounce - db.execute( - "INSERT INTO bounce (created, bounced) VALUES ($1, $2) ON CONFLICT (bounced) DO NOTHING", - &[&Timestamp::now().as_sql_micros(), &tx.hash.as_ref()], - )?; - } - } - Ok(()) -} - -/// Sync database with an outgoing transaction -fn sync_chain_outgoing(tx: &SyncTransaction, db: &mut Client, state: &WireState) -> LoopResult<()> { - let SyncTransaction { tx, confirmations } = tx; - match OutMetadata::decode(&tx.input) { - Ok(metadata) => match metadata { - OutMetadata::Debit { wtid, .. } => { - let amount = eth_to_taler(&tx.value, state.currency); - let credit_addr = tx.to.unwrap(); - // Get previous out tx - let row = db.query_opt( - "SELECT id, status, sent FROM tx_out WHERE wtid=$1 FOR UPDATE", - &[&wtid.as_slice()], - )?; - if let Some(row) = row { - // If already in database, sync status - let row_id: i64 = row.get(0); - let status: i16 = row.get(1); - let sent: Option<i64> = row.get(2); - - let expected_status = DebitStatus::Sent as i16; - let expected_send = sent.filter(|_| *confirmations == 0); - if status != expected_status || sent != expected_send { - let nb_row = db.execute( - "UPDATE tx_out SET status=$1, txid=$2, sent=NULL WHERE id=$3 AND status=$4", - &[ - &(DebitStatus::Sent as i16), - &tx.hash.as_ref(), - &row_id, - &status, - ], - )?; - if nb_row > 0 { - match DebitStatus::try_from(status as u8).unwrap() { - DebitStatus::Requested => { - warn!( - ">> (recovered) {amount} {wtid} in {} to {}", - hex::encode(tx.hash), - hex::encode(credit_addr) - ); - } - DebitStatus::Sent => { /* Status is correct */ } - } - } - } - } else { - // Else add to database - let nb = db.execute( - "INSERT INTO tx_out (created, amount, wtid, debit_acc, credit_acc, exchange_url, status, txid, request_uid) VALUES ($1, ($2, $3)::taler_amount, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (wtid) DO NOTHING", - &[&Timestamp::now().as_sql_micros(), &(amount.val as i64), &(amount.frac as i32), &wtid.as_slice(), &state.account.0.as_bytes(), &credit_addr.as_bytes(), &state.base_url.as_ref(), &(DebitStatus::Sent as i16), &tx.hash.as_ref(), &None::<&[u8]>], - )?; - if nb > 0 { - warn!( - ">> (onchain) {amount} {wtid} in {} to {}", - hex::encode(tx.hash), - hex::encode(credit_addr) - ); - } - } - } - OutMetadata::Bounce { bounced } => { - let bounced = H256::from_slice(&bounced); - // Get previous bounce - let row = db.query_opt( - "SELECT id, status FROM bounce WHERE bounced=$1", - &[&bounced.as_ref()], - )?; - if let Some(row) = row { - // If already in database, sync status - let row_id: i64 = row.get(0); - let status: i16 = row.get(1); - match BounceStatus::try_from(status as u8).unwrap() { - BounceStatus::Requested => { - let nb_row = db.execute( - "UPDATE bounce SET status=$1, txid=$2 WHERE id=$3 AND status=$4", - &[ - &(BounceStatus::Sent as i16), - &tx.hash.as_ref(), - &row_id, - &status, - ], - )?; - if nb_row > 0 { - warn!( - "|| (recovered) {} in {}", - hex::encode(bounced), - hex::encode(tx.hash) - ); - } - } - BounceStatus::Ignored => error!( - "watcher: ignored bounce {} found in chain at {}", - bounced, - hex::encode(tx.hash) - ), - BounceStatus::Sent => { /* Status is correct */ } - } - } else { - // Else add to database - let nb = db.execute( - "INSERT INTO bounce (created, bounced, txid, status) VALUES ($1, $2, $3, $4) ON CONFLICT (txid) DO NOTHING", - &[&Timestamp::now().as_sql_micros(), &bounced.as_ref(), &tx.hash.as_ref(), &(BounceStatus::Sent as i16)], - )?; - if nb > 0 { - warn!( - "|| (onchain) {} in {}", - hex::encode(bounced), - hex::encode(tx.hash) - ); - } - } - } - }, - Err(_) => { /* Ignore */ } - } - Ok(()) -} - -/// Send a debit transaction on the blockchain, return false if no more requested transactions are found -fn debit(db: &mut Client, rpc: &mut Rpc, state: &WireState) -> LoopResult<bool> { - // We rely on the advisory lock to ensure we are the only one sending transactions - let row = db.query_opt( -"SELECT id, (amount).val, (amount).frac, wtid, credit_acc, exchange_url FROM tx_out WHERE status=$1 ORDER BY created LIMIT 1", -&[&(DebitStatus::Requested as i16)], -)?; - if let Some(row) = &row { - let id: i64 = row.get(0); - let amount = sql_eth_amount(row, 1, state.currency); - let wtid: ShortHashCode = sql_base_32(row, 3); - let addr = sql_addr(row, 4); - let url = sql_url(row, 5); - let now = Timestamp::now(); - let tx_id = rpc.debit(state.account.0, addr, amount, wtid.clone(), url)?; - fail_point("(injected) fail debit", 0.3)?; - db.execute( - "UPDATE tx_out SET status=$1, txid=$2, sent=$3 WHERE id=$4", - &[ - &(DebitStatus::Sent as i16), - &tx_id.as_ref(), - &now.as_sql_micros(), - &id, - ], - )?; - let amount = eth_to_taler(&amount, state.currency); - info!( - ">> {amount} {wtid} in {} to {}", - hex::encode(tx_id), - hex::encode(addr) - ); - } - Ok(row.is_some()) -} - -/// Bump a stuck transaction, return false if no more stuck transactions are found -fn bump(db: &mut Client, rpc: &mut Rpc, state: &WireState) -> LoopResult<bool> { - if let Some(delay) = state.bump_delay { - let now = Timestamp::now().as_sql_micros(); - // We rely on the advisory lock to ensure we are the only one sending transactions - let row = db.query_opt( - "SELECT id, txid FROM tx_out WHERE status=$1 AND $2 - sent > $3 ORDER BY created LIMIT 1", - &[&(DebitStatus::Sent as i16), &now, &((delay * 1000000) as i64)], - )?; - if let Some(row) = &row { - let now = Timestamp::now(); - let id: i64 = row.get(0); - let txid = sql_hash(row, 1); - let tx = rpc.get_transaction(&txid)?.expect("Bump existing tx"); - rpc.send_transaction(&TransactionRequest { - from: tx.from.unwrap(), - to: tx.to.unwrap(), - value: tx.value, - gas_price: None, - data: tx.input, - nonce: Some(tx.nonce), - })?; - let row = db.query_one( - "UPDATE tx_out SET sent=$1 WHERE id=$2 RETURNING wtid", - &[&now.as_sql_micros(), &id], - )?; - let wtid: ShortHashCode = sql_base_32(&row, 0); - info!(">> (bump) {wtid} in {}", hex::encode(txid)); - } - Ok(row.is_some()) - } else { - Ok(false) - } -} - -/// Bounce a transaction on the blockchain, return false if no more requested transactions are found -fn bounce(db: &mut Client, rpc: &mut Rpc, fee: U256) -> LoopResult<bool> { - // We rely on the advisory lock to ensure we are the only one sending transactions - let row = db.query_opt( - "SELECT id, bounced FROM bounce WHERE status=$1 ORDER BY created LIMIT 1", - &[&(BounceStatus::Requested as i16)], - )?; - if let Some(row) = &row { - let id: i64 = row.get(0); - let bounced: H256 = sql_hash(row, 1); - - let bounce = rpc.bounce(bounced, fee)?; - match bounce { - Some(hash) => { - fail_point("(injected) fail bounce", 0.3)?; - db.execute( - "UPDATE bounce SET txid=$1, status=$2 WHERE id=$3", - &[&hash.as_ref(), &(BounceStatus::Sent as i16), &id], - )?; - info!("|| {} in {}", hex::encode(bounced), hex::encode(hash)); - } - None => { - db.execute( - "UPDATE bounce SET status=$1 WHERE id=$2", - &[&(BounceStatus::Ignored as i16), &id], - )?; - info!("|| (ignore) {} ", hex::encode(bounced)); - } - } - } - Ok(row.is_some()) -} diff --git a/eth-wire/src/main.rs b/eth-wire/src/main.rs @@ -1,158 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use std::path::PathBuf; - -use clap::Parser; -use common::{ - log::{OrFail, log::info}, - named_spawn, password, - postgres::NoTls, - reconnect::auto_reconnect_db, -}; -use eth_wire::{ - SyncState, WireState, load_taler_config, - rpc::{Rpc, RpcClient, auto_rpc_common, auto_rpc_wallet}, -}; -use ethereum_types::H160; -use loops::{LoopResult, watcher::watcher, worker::worker}; - -mod fail_point; -mod loops; -mod sql; - -/// Taler wire for geth -#[derive(clap::Parser, Debug)] -struct Args { - /// Override default configuration file path - #[clap(global = true, short, long)] - config: Option<PathBuf>, - #[clap(subcommand)] - init: Option<Init>, -} - -#[derive(clap::Subcommand, Debug)] -enum Init { - /// Initialize database schema and state - Initdb, - /// Generate ethereum wallet and initialize state - Initwallet, -} - -fn main() { - common::log::init(); - let args = Args::parse(); - - match args.init { - Some(cmd) => init(args.config, cmd).or_fail(|e| format!("{}", e)), - None => run(args.config), - } -} - -fn init(config: Option<PathBuf>, init: Init) -> LoopResult<()> { - // Parse taler config - let (taler_config, ipc_path, _) = load_taler_config(config.as_deref()); - // Connect to database - let mut db = taler_config.db_config().connect(NoTls)?; - // Connect to ethereum node - let mut rpc = Rpc::new(ipc_path).or_fail(|e| format!("rpc connect: {}", e)); - - match init { - Init::Initdb => { - let mut tx = db.transaction()?; - // Load schema - tx.batch_execute(include_str!("../../db/eth.sql"))?; - // Init status to true - tx - .execute( - "INSERT INTO state (name, value) VALUES ('status', $1) ON CONFLICT (name) DO NOTHING", - &[&[1u8].as_slice()], - )?; - // Init sync if not already set - let block = rpc.earliest_block()?; - let state = SyncState { - tip_hash: block.hash.unwrap(), - tip_height: block.number.unwrap(), - conf_height: block.number.unwrap(), - }; - tx.execute( - "INSERT INTO state (name, value) VALUES ('sync', $1) ON CONFLICT (name) DO NOTHING", - &[&state.to_bytes().as_slice()], - )?; - tx.commit()?; - println!("Database initialised"); - } - Init::Initwallet => { - // Skip previous blocks - let block = rpc.latest_block()?; - let state = SyncState { - tip_hash: block.hash.unwrap(), - tip_height: block.number.unwrap(), - conf_height: block.number.unwrap(), - }; - let prev_addr = db.query_opt("SELECT value FROM state WHERE name = 'addr'", &[])?; - let (addr, created) = if let Some(row) = prev_addr { - (H160::from_slice(row.get(0)), false) - } else { - // Or generate a new one - let passwd = password(); - let new = rpc.new_account(&passwd)?; - db.execute( - "INSERT INTO state (name, value) VALUES ('addr', $1)", - &[&new.as_bytes()], - )?; - let nb_row = db.execute( - "UPDATE state SET value=$1 WHERE name='sync'", - &[&state.to_bytes().as_slice()], - )?; - if nb_row > 0 { - println!("Skipped {} previous block", state.conf_height); - } - (new, true) - }; - - if created { - println!("Created new wallet"); - } else { - println!("Found already existing wallet") - }; - - let addr = hex::encode(addr.as_bytes()); - println!( - "You must backup the generated key file and your chosen password, more info there: https://geth.ethereum.org/docs/install-and-build/backup-restore" - ); - println!("Public address is {}", &addr); - println!("Add the following line into taler.conf:"); - println!("[depolymerizer-ethereum]"); - println!("PAYTO = payto://ethereum/{}", addr); - } - } - Ok(()) -} - -fn run(config: Option<PathBuf>) { - let state = WireState::load_taler_config(config.as_deref()); - - let rpc_worker = auto_rpc_wallet(state.ipc_path.clone(), state.account.0); - let rpc_watcher = auto_rpc_common(state.ipc_path.clone()); - - let db_watcher = auto_reconnect_db(state.db_config.clone()); - let db_worker = auto_reconnect_db(state.db_config.clone()); - - named_spawn("watcher", move || watcher(rpc_watcher, db_watcher)); - worker(rpc_worker, db_worker, state); - info!("eth-wire stopped"); -} diff --git a/eth-wire/src/rpc.rs b/eth-wire/src/rpc.rs @@ -1,589 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -//! This is a very simple RPC client designed only for a specific geth version -//! and to use on an secure unix domain socket to a trusted node -//! -//! We only parse the thing we actually use, this reduce memory usage and -//! make our code more compatible with future deprecation - -use common::{log::log::error, password, reconnect::AutoReconnect, url::Url}; -use ethereum_types::{Address, H160, H256, U64, U256}; -use serde::de::DeserializeOwned; -use std::{ - fmt::Debug, - io::{self, BufWriter, ErrorKind, Read, Write}, - os::unix::net::UnixStream, - path::{Path, PathBuf}, -}; - -use self::hex::Hex; - -pub type AutoRpcWallet = AutoReconnect<(PathBuf, Address), Rpc>; - -/// Create a reconnecting rpc connection with an unlocked wallet -pub fn auto_rpc_wallet(ipc_path: PathBuf, address: Address) -> AutoRpcWallet { - AutoReconnect::new( - (ipc_path, address), - |(path, address)| { - let mut rpc = Rpc::new(path) - .map_err(|err| error!("connect RPC: {}", err)) - .ok()?; - rpc.unlock_account(address, &password()) - .map_err(|err| error!("connect RPC: {}", err)) - .ok()?; - Some(rpc) - }, - |client| client.node_info().is_err(), - ) -} - -pub type AutoRpcCommon = AutoReconnect<PathBuf, Rpc>; - -/// Create a reconnecting rpc connection -pub fn auto_rpc_common(ipc_path: PathBuf) -> AutoRpcCommon { - AutoReconnect::new( - ipc_path, - |path| { - Rpc::new(path) - .map_err(|err| error!("connect RPC: {}", err)) - .ok() - }, - |client| client.node_info().is_err(), - ) -} -#[derive(Debug, serde::Serialize)] -struct RpcRequest<'a, T: serde::Serialize> { - jsonrpc: &'static str, - method: &'a str, - id: u64, - params: &'a T, -} - -#[derive(Debug, serde::Deserialize)] -struct RpcResponse<T> { - result: Option<T>, - error: Option<RpcErr>, - id: u64, -} - -#[derive(Debug, serde::Deserialize)] -struct RpcErr { - code: i64, - message: String, -} - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("{0:?}")] - Transport(#[from] std::io::Error), - #[error("{code:?} - {msg}")] - RPC { code: i64, msg: String }, - #[error("JSON: {0}")] - Json(#[from] serde_json::Error), - #[error("Null rpc, no result or error")] - Null, -} - -pub type Result<T> = std::result::Result<T, Error>; - -const EMPTY: [(); 0] = []; - -/// Ethereum RPC connection -pub struct Rpc { - id: u64, - conn: BufWriter<UnixStream>, - read_buf: Vec<u8>, - cursor: usize, -} - -impl Rpc { - /// Start a RPC connection, path can be datadir or ipc path - pub fn new(path: impl AsRef<Path>) -> io::Result<Self> { - let path = path.as_ref(); - - let conn = if path.is_dir() { - UnixStream::connect(path.join("geth.ipc")) - } else { - UnixStream::connect(path) - }?; - - Ok(Self { - id: 0, - conn: BufWriter::new(conn), - read_buf: vec![0u8; 8 * 1024], - cursor: 0, - }) - } - - fn send(&mut self, method: &str, params: &impl serde::Serialize) -> Result<()> { - let request = RpcRequest { - method, - id: self.id, - params, - jsonrpc: "2.0", - }; - - // Send request - serde_json::to_writer(&mut self.conn, &request)?; - self.conn.flush()?; - Ok(()) - } - - fn receive<T>(&mut self) -> Result<T> - where - T: serde::de::DeserializeOwned + Debug, - { - loop { - // Read one - let pos = self.read_buf[..self.cursor] - .iter() - .position(|c| *c == b'\n') - .map(|pos| pos + 1); // Move after newline - if let Some(pos) = pos { - match serde_json::from_slice(&self.read_buf[..pos]) { - Ok(response) => { - self.read_buf.copy_within(pos..self.cursor, 0); - self.cursor -= pos; - return Ok(response); - } - Err(err) => return Err(err)?, - } - } // Or read more - - // Double buffer size if full - if self.cursor == self.read_buf.len() { - self.read_buf.resize(self.cursor * 2, 0); - } - match self.conn.get_mut().read(&mut self.read_buf[self.cursor..]) { - Ok(0) => Err(std::io::Error::new( - ErrorKind::UnexpectedEof, - "RPC EOF".to_string(), - ))?, - Ok(nb) => self.cursor += nb, - Err(e) if e.kind() == ErrorKind::Interrupted => {} - Err(e) => Err(e)?, - } - } - } - - pub fn subscribe_new_head(&mut self) -> Result<RpcStream<Nothing>> { - let id: String = self.call("eth_subscribe", &["newHeads"])?; - Ok(RpcStream::new(self, id)) - } - - fn handle_response<T>(&mut self, response: RpcResponse<T>) -> Result<T> { - assert_eq!(self.id, response.id); - self.id += 1; - if let Some(ok) = response.result { - Ok(ok) - } else { - Err(match response.error { - Some(err) => Error::RPC { - code: err.code, - msg: err.message, - }, - None => Error::Null, - }) - } - } -} - -impl RpcClient for Rpc { - fn call<T>(&mut self, method: &str, params: &impl serde::Serialize) -> Result<T> - where - T: serde::de::DeserializeOwned + Debug, - { - self.send(method, params)?; - let response = self.receive()?; - self.handle_response(response) - } -} - -#[derive(Debug, serde::Deserialize)] -pub struct NotificationContent<T> { - subscription: String, - result: T, -} - -#[derive(Debug, serde::Deserialize)] - -struct Notification<T> { - params: NotificationContent<T>, -} - -#[derive(Debug, serde::Deserialize)] -#[serde(untagged)] -enum NotificationOrResponse<T, N> { - Notification(Notification<N>), - Response(RpcResponse<T>), -} -#[derive(Debug, serde::Deserialize)] -#[serde(untagged)] -enum SubscribeDirtyFix { - Fix(RpcResponse<bool>), - Id(RpcResponse<String>), -} - -/// A notification stream wrapping an rpc client -pub struct RpcStream<'a, N: Debug + DeserializeOwned> { - rpc: &'a mut Rpc, - id: String, - buff: Vec<N>, -} - -impl<'a, N: Debug + DeserializeOwned> RpcStream<'a, N> { - fn new(rpc: &'a mut Rpc, id: String) -> Self { - Self { - rpc, - id, - buff: vec![], - } - } - - /// Block until next notification - pub fn next(&mut self) -> Result<N> { - match self.buff.pop() { - // Consume buffered notifications - Some(prev) => Ok(prev), - // Else read next one - None => { - let notification: Notification<N> = self.rpc.receive()?; - let notification = notification.params; - assert_eq!(self.id, notification.subscription); - Ok(notification.result) - } - } - } -} - -impl<N: Debug + DeserializeOwned> Drop for RpcStream<'_, N> { - fn drop(&mut self) { - let Self { rpc, id, .. } = self; - // Request unsubscription, ignoring error - rpc.send("eth_unsubscribe", &[id]).ok(); - // Ignore all buffered notification until subscription response - while let Ok(response) = rpc.receive::<NotificationOrResponse<bool, N>>() { - match response { - NotificationOrResponse::Notification(_) => { /* Ignore */ } - NotificationOrResponse::Response(_) => return, - } - } - } -} - -impl<N: Debug + DeserializeOwned> RpcClient for RpcStream<'_, N> { - fn call<T>(&mut self, method: &str, params: &impl serde::Serialize) -> Result<T> - where - T: serde::de::DeserializeOwned + Debug, - { - self.rpc.send(method, params)?; - loop { - // Buffer notifications until response - let response: NotificationOrResponse<T, N> = self.rpc.receive()?; - match response { - NotificationOrResponse::Notification(n) => { - let n = n.params; - assert_eq!(self.id, n.subscription); - self.buff.push(n.result); - } - NotificationOrResponse::Response(response) => { - return self.rpc.handle_response(response); - } - } - } - } -} - -pub trait RpcClient { - fn call<T>(&mut self, method: &str, params: &impl serde::Serialize) -> Result<T> - where - T: serde::de::DeserializeOwned + Debug; - - /* ----- Account management ----- */ - - /// List registered account - fn list_accounts(&mut self) -> Result<Vec<Address>> { - self.call("personal_listAccounts", &EMPTY) - } - - /// Create a new encrypted account - fn new_account(&mut self, passwd: &str) -> Result<Address> { - self.call("personal_newAccount", &[passwd]) - } - - /// Unlock an existing account - fn unlock_account(&mut self, account: &Address, passwd: &str) -> Result<bool> { - self.call("personal_unlockAccount", &(account, passwd, 0)) - } - - /* ----- Getter ----- */ - - /// Get a transaction by hash - fn get_transaction(&mut self, hash: &H256) -> Result<Option<Transaction>> { - match self.call("eth_getTransactionByHash", &[hash]) { - Err(Error::Null) => Ok(None), - r => r, - } - } - - /// Get a transaction receipt by hash - fn get_transaction_receipt(&mut self, hash: &H256) -> Result<Option<TransactionReceipt>> { - match self.call("eth_getTransactionReceipt", &[hash]) { - Err(Error::Null) => Ok(None), - r => r, - } - } - - /// Get block by hash - fn block(&mut self, hash: &H256) -> Result<Option<Block>> { - match self.call("eth_getBlockByHash", &(hash, &true)) { - Err(Error::Null) => Ok(None), - r => r, - } - } - - /// Get pending transactions - fn pending_transactions(&mut self) -> Result<Vec<Transaction>> { - self.call("eth_pendingTransactions", &EMPTY) - } - - /// Get latest block - fn latest_block(&mut self) -> Result<Block> { - self.call("eth_getBlockByNumber", &("latest", &true)) - } - - /// Get earliest block (genesis if not pruned) - fn earliest_block(&mut self) -> Result<Block> { - self.call("eth_getBlockByNumber", &("earliest", &true)) - } - - /// Get latest account balance - fn get_balance_latest(&mut self, addr: &Address) -> Result<U256> { - self.call("eth_getBalance", &(addr, "latest")) - } - - /// Get pending account balance - fn get_balance_pending(&mut self, addr: &Address) -> Result<U256> { - self.call("eth_getBalance", &(addr, "pending")) - } - - /// Get node info - fn node_info(&mut self) -> Result<NodeInfo> { - self.call("admin_nodeInfo", &EMPTY) - } - - /* ----- Transactions ----- */ - - /// Fill missing options from transaction request with default values - fn fill_transaction(&mut self, req: &TransactionRequest) -> Result<Filled> { - self.call("eth_fillTransaction", &[req]) - } - - /// Send ethereum transaction - fn send_transaction(&mut self, req: &TransactionRequest) -> Result<H256> { - self.call("eth_sendTransaction", &[req]) - } - - /* ----- Miner ----- */ - - fn miner_set_etherbase(&mut self, addr: &H160) -> Result<bool> { - self.call("miner_setEtherbase", &[addr]) - } - - /// Start mining - fn miner_start(&mut self) -> Result<()> { - match self.call("miner_start", &EMPTY) { - Err(Error::Null) => Ok(()), - i => i, - } - } - - /// Stop mining - fn miner_stop(&mut self) -> Result<()> { - match self.call("miner_stop", &EMPTY) { - Err(Error::Null) => Ok(()), - i => i, - } - } - - /* ----- Peer management ----- */ - - fn export_chain(&mut self, path: &str) -> Result<bool> { - self.call("admin_exportChain", &[path]) - } - - fn import_chain(&mut self, path: &str) -> Result<bool> { - self.call("admin_importChain", &[path]) - } -} - -#[derive(Debug, Clone, serde::Deserialize)] -pub struct Block { - pub hash: Option<H256>, - /// Block number (None if pending) - pub number: Option<U64>, - #[serde(rename = "parentHash")] - pub parent_hash: H256, - pub transactions: Vec<Transaction>, -} - -#[derive(Debug, serde::Deserialize)] -pub struct Nothing {} - -/// Description of a Transaction, pending or in the chain. -#[derive(Debug, Clone, serde::Deserialize)] -pub struct Transaction { - pub hash: H256, - pub nonce: U256, - /// Sender address (None when coinbase) - pub from: Option<Address>, - /// Recipient address (None when contract creation) - pub to: Option<Address>, - /// Transferred value - pub value: U256, - /// Input data - pub input: Hex, -} - -/// Description of a Transaction, pending or in the chain. -#[derive(Debug, Clone, serde::Deserialize)] -pub struct TransactionReceipt { - /// Gas used by this transaction alone. - #[serde(rename = "gasUsed")] - pub gas_used: U256, - /// Effective gas price - #[serde(rename = "effectiveGasPrice")] - pub effective_gas_price: Option<U256>, -} - -/// Fill result -#[derive(Debug, serde::Deserialize)] -pub struct Filled { - pub tx: FilledGas, -} - -/// Filles gas -#[derive(Debug, serde::Deserialize)] -pub struct FilledGas { - /// Supplied gas - pub gas: U256, - #[serde(rename = "gasPrice")] - pub gas_price: Option<U256>, - #[serde(rename = "maxFeePerGas")] - pub max_fee_per_gas: Option<U256>, -} - -/// Send Transaction Parameters -#[derive(Debug, serde::Serialize)] -pub struct TransactionRequest { - /// Sender address - pub from: Address, - /// Recipient address - pub to: Address, - /// Transferred value - pub value: U256, - /// Gas price (None for sensible default) - #[serde(rename = "gasPrice")] - pub gas_price: Option<U256>, - /// Transaction data - pub data: Hex, - /// Transaction nonce (None for next available nonce) - #[serde(skip_serializing_if = "Option::is_none")] - pub nonce: Option<U256>, -} - -#[derive(Debug, serde::Deserialize)] -pub struct NodeInfo { - pub enode: Url, -} - -pub mod hex { - use std::{ - fmt, - ops::{Deref, DerefMut}, - }; - - use serde::{ - Deserialize, Deserializer, Serialize, Serializer, - de::{Error, Unexpected, Visitor}, - }; - - /// Raw bytes wrapper - #[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] - pub struct Hex(pub Vec<u8>); - - impl Deref for Hex { - type Target = Vec<u8>; - - fn deref(&self) -> &Self::Target { - &self.0 - } - } - - impl DerefMut for Hex { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } - } - - impl Serialize for Hex { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: Serializer, - { - serializer.serialize_str(&hex::encode_prefixed(&self.0)) - } - } - - impl<'a> Deserialize<'a> for Hex { - fn deserialize<D>(deserializer: D) -> Result<Hex, D::Error> - where - D: Deserializer<'a>, - { - deserializer.deserialize_identifier(BytesVisitor) - } - } - - struct BytesVisitor; - - impl Visitor<'_> for BytesVisitor { - type Value = Hex; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - write!(formatter, "a 0x-prefixed hex-encoded vector of bytes") - } - - fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> - where - E: Error, - { - if value.len() >= 2 && &value[0..2] == "0x" { - let bytes = hex::decode(&value[2..]) - .map_err(|e| Error::custom(format!("Invalid hex: {}", e)))?; - Ok(Hex(bytes)) - } else { - Err(Error::invalid_value(Unexpected::Str(value), &"0x prefix")) - } - } - - fn visit_string<E>(self, value: String) -> Result<Self::Value, E> - where - E: Error, - { - self.visit_str(value.as_ref()) - } - } -} diff --git a/eth-wire/src/rpc_utils.rs b/eth-wire/src/rpc_utils.rs @@ -1,36 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use std::{path::PathBuf, str::FromStr}; - -/// Default geth data_dir <https://geth.ethereum.org/docs/install-and-build/backup-restore#data-directory> -pub fn default_data_dir() -> PathBuf { - if cfg!(target_os = "windows") { - PathBuf::from_str(&std::env::var("APPDATA").unwrap()) - .unwrap() - .join("Ethereum") - } else if cfg!(target_os = "linux") { - PathBuf::from_str(&std::env::var("HOME").unwrap()) - .unwrap() - .join(".ethereum") - } else if cfg!(target_os = "macos") { - PathBuf::from_str(&std::env::var("HOME").unwrap()) - .unwrap() - .join("Library/Ethereum") - } else { - unimplemented!("Only windows, linux or macos") - } -} diff --git a/eth-wire/src/sql.rs b/eth-wire/src/sql.rs @@ -1,46 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -use common::{ - currency::CurrencyEth, - log::OrFail, - postgres::Row, - sql::{sql_amount, sql_array}, -}; -use eth_wire::taler_util::taler_to_eth; -use ethereum_types::{H160, H256, U256}; - -/// Ethereum amount from sql -pub fn sql_eth_amount(row: &Row, idx: usize, currency: CurrencyEth) -> U256 { - let amount = sql_amount(row, idx, currency.to_str()); - taler_to_eth(&amount, currency).or_fail(|_| { - format!( - "Database invariant: expected an ethereum amount got {}", - amount - ) - }) -} - -/// Ethereum address from sql -pub fn sql_addr(row: &Row, idx: usize) -> H160 { - let array: [u8; 20] = sql_array(row, idx); - H160::from_slice(&array) -} - -/// Ethereum hash from sql -pub fn sql_hash(row: &Row, idx: usize) -> H256 { - let array: [u8; 32] = sql_array(row, idx); - H256::from_slice(&array) -} diff --git a/eth-wire/src/taler_util.rs b/eth-wire/src/taler_util.rs @@ -1,46 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use common::{ - currency::CurrencyEth, - taler_common::types::amount::{Amount, FRAC_BASE}, -}; -use ethereum_types::U256; - -pub const WEI: u64 = 1_000_000_000_000_000_000; -pub const TRUNC: u64 = WEI / FRAC_BASE as u64; - -/// Transform a eth amount into a taler amount -pub fn eth_to_taler(amount: &U256, currency: CurrencyEth) -> Amount { - Amount::new( - currency.to_str(), - (amount / WEI).as_u64(), - ((amount % WEI) / TRUNC).as_u32(), - ) -} - -/// Transform a eth amount into a btc amount -pub fn taler_to_eth(amount: &Amount, currency: CurrencyEth) -> Result<U256, String> { - if amount.currency.as_ref() != currency.to_str() { - return Err(format!( - "expected currency {} got {}", - currency.to_str(), - amount.currency - )); - } - - Ok(U256::from(amount.val) * WEI + U256::from(amount.frac) * TRUNC) -} diff --git a/instrumentation/Cargo.toml b/instrumentation/Cargo.toml @@ -12,16 +12,14 @@ license-file.workspace = true clap.workspace = true common = { path = "../common" } # Bitcoin -btc-wire = { path = "../btc-wire" } +depolymerizer-bitcoin = { path = "../depolymerizer-bitcoin" } bitcoin.workspace = true # Ethereum -eth-wire = { path = "../eth-wire" } +depolymerizer-ethereum = { path = "../depolymerizer-ethereum" } ethereum-types.workspace = true hex.workspace = true # Wire Gateway ureq = { version = "3.0.0", features = ["json"] } -# In memory deflate library -libdeflater = "1.19.0" # Generate temporary files tempfile = "3.3.0" # RNG @@ -39,6 +37,7 @@ indicatif = "0.17.7" thread-local-panic-hook = "0.1.0" taler-common.workspace = true taler-api.workspace = true +anyhow.workspace = true [build-dependencies] clap_mangen = "0.2.14" diff --git a/instrumentation/conf/bitcoin.conf b/instrumentation/conf/bitcoin.conf @@ -3,6 +3,10 @@ txindex=1 maxtxfee=0.01 fallbackfee=0.00000001 rpcservertimeout=15 +dbcache=4 +maxmempool=5 +par=2 +rpcthreads=5 [regtest] port=8345 diff --git a/instrumentation/conf/bitcoin2.conf b/instrumentation/conf/bitcoin2.conf @@ -3,6 +3,10 @@ txindex=1 maxtxfee=0.01 fallbackfee=0.00000001 rpcservertimeout=0 +dbcache=4 +maxmempool=5 +par=2 +rpcthreads=5 [regtest] port=8346 diff --git a/instrumentation/conf/bitcoin_auth0.conf b/instrumentation/conf/bitcoin_auth0.conf @@ -1,10 +0,0 @@ -regtest=1 -txindex=1 -maxtxfee=0.1 -fallbackfee=0.00000001 -rpcuser=bob -rpcpassword=password - -[regtest] -port=8346 -rpcport=18346 -\ No newline at end of file diff --git a/instrumentation/conf/bitcoin_auth1.conf b/instrumentation/conf/bitcoin_auth1.conf @@ -1,12 +0,0 @@ -regtest=1 -txindex=1 -maxtxfee=0.1 -fallbackfee=0.00000001 -rpcuser=bob -rpcpassword=password - -[regtest] -port=8346 -rpcport=18346 -rpcuser=alice -rpcpassword=password TODO -\ No newline at end of file diff --git a/instrumentation/conf/bitcoin_auth2.conf b/instrumentation/conf/bitcoin_auth2.conf @@ -1,10 +0,0 @@ -regtest=1 -txindex=1 -maxtxfee=0.1 -fallbackfee=0.00000001 - -[regtest] -port=8346 -rpcport=18346 -rpcuser=alice -rpcpassword=password -\ No newline at end of file diff --git a/instrumentation/conf/bitcoin_auth3.conf b/instrumentation/conf/bitcoin_auth3.conf @@ -1,9 +0,0 @@ -regtest=1 -txindex=1 -maxtxfee=0.1 -fallbackfee=0.00000001 - -[regtest] -port=8346 -rpcport=18346 -rpccookiefile=catch_me_if_you_can -\ No newline at end of file diff --git a/instrumentation/conf/bitcoin_auth4.conf b/instrumentation/conf/bitcoin_auth4.conf @@ -1,9 +0,0 @@ -regtest=1 -txindex=1 -maxtxfee=0.1 -fallbackfee=0.00000001 -rpccookiefile=catch_me_if_you_can - -[regtest] -port=8346 -rpcport=18346 -\ No newline at end of file diff --git a/instrumentation/conf/bitcoin_auth5.conf b/instrumentation/conf/bitcoin_auth5.conf @@ -1,10 +0,0 @@ -regtest=1 -txindex=1 -maxtxfee=0.1 -fallbackfee=0.00000001 -rpccookiefile=catch_me_if_you_can - -[regtest] -port=8346 -rpcport=18346 -rpccookiefile=cannot_touch_this -\ No newline at end of file diff --git a/instrumentation/conf/taler_btc.conf b/instrumentation/conf/taler_btc.conf @@ -1,12 +1,14 @@ -[taler] -CURRENCY = DEVBTC - -[exchange] -BASE_URL = http://test.com - [depolymerizer-bitcoin] -DB_URL = postgres://localhost:5454/postgres?user=postgres&password=password -PORT = 8060 -PAYTO = payto://bitcoin/bcrt1qgkgxkjj27g3f7s87mcvjjsghay7gh34cx39prj +CURRENCY = DEVBTC + +[depolymerizer-bitcoin-worker] +BOUNCE_FEE = DEVBTC:0.00001 CONFIRMATION = 3 -AUTH_METHOD = none + +[depolymerizer-bitcoin-httpd-wire-gateway-api] +ENABLED = YES +AUTH_METHOD = none + +[depolymerizer-bitcoin-httpd-revenue-api] +ENABLED = YES +AUTH_METHOD = none diff --git a/instrumentation/conf/taler_btc_auth.conf b/instrumentation/conf/taler_btc_auth.conf @@ -1,19 +0,0 @@ -[taler] -CURRENCY = DEVBTC - -[exchange] -BASE_URL = http://test.com - -[exchange-accountcredentials-admin] -# FIXME: should be changed to http://localhost:8060/accounts/admin/taler-wire-gateway/ for uniformity... -WIRE_GATEWAY_URL = http://localhost:8060/ -WIRE_GATEWAY_AUTH_METHOD = basic -USERNAME = admin -PASSWORD = password - -[depolymerizer-bitcoin] -DB_URL = postgres://localhost:5454/postgres?user=postgres&password=password -PORT = 8060 -PAYTO = payto://bitcoin/bcrt1qgkgxkjj27g3f7s87mcvjjsghay7gh34cx39prj -AUTH_METHOD = basic -AUTH_TOKEN = YWRtaW46cGFzc3dvcmQ= diff --git a/instrumentation/conf/taler_btc_bump.conf b/instrumentation/conf/taler_btc_bump.conf @@ -1,14 +1,15 @@ -[taler] -CURRENCY = DEVBTC +[depolymerizer-bitcoin] +CURRENCY = DEVBTC -[exchange] -BASE_URL = http://test.com +[depolymerizer-bitcoin-worker] +BOUNCE_FEE = DEVBTC:0.00001 +CONFIRMATION = 3 +BUMP_DELAY = 5 -[depolymerizer-bitcoin] -DB_URL = postgres://localhost:5454/postgres?user=postgres&password=password -PORT = 8060 -PAYTO = payto://bitcoin/bcrt1qgkgxkjj27g3f7s87mcvjjsghay7gh34cx39prj -CONFIRMATION = 3 -BUMP_DELAY = 5 -BOUNCE_FEE = 0.00001 -AUTH_METHOD = none -\ No newline at end of file +[depolymerizer-bitcoin-httpd-wire-gateway-api] +ENABLED = YES +AUTH_METHOD = none + +[depolymerizer-bitcoin-httpd-revenue-api] +ENABLED = YES +AUTH_METHOD = none +\ No newline at end of file diff --git a/instrumentation/conf/taler_btc_lifetime.conf b/instrumentation/conf/taler_btc_lifetime.conf @@ -1,14 +1,18 @@ -[taler] -CURRENCY = DEVBTC +[depolymerizer-bitcoin] +CURRENCY = DEVBTC -[exchange] -BASE_URL = http://test.com +[depolymerizer-bitcoin-worker] +BOUNCE_FEE = DEVBTC:0.00001 +CONFIRMATION = 3 +LIFETIME = 10 -[depolymerizer-bitcoin] -DB_URL = postgres://localhost:5454/postgres?user=postgres&password=password -PORT = 8060 -PAYTO = payto://bitcoin/bcrt1qgkgxkjj27g3f7s87mcvjjsghay7gh34cx39prj -CONFIRMATION = 3 -HTTP_LIFETIME = 10 -WIRE_LIFETIME = 10 -AUTH_METHOD = none -\ No newline at end of file +[depolymerizer-bitcoin-httpd] +LIFETIME = 10 + +[depolymerizer-bitcoin-httpd-wire-gateway-api] +ENABLED = YES +AUTH_METHOD = none + +[depolymerizer-bitcoin-httpd-revenue-api] +ENABLED = YES +AUTH_METHOD = none diff --git a/instrumentation/conf/taler_eth.conf b/instrumentation/conf/taler_eth.conf @@ -1,11 +1,14 @@ -[taler] -CURRENCY = DEVETH - -[exchange] -BASE_URL = http://test.com - [depolymerizer-ethereum] -DB_URL = postgres://localhost:5454/postgres?user=postgres&password=password -PORT = 8060 +CURRENCY = DEVETH + +[depolymerizer-ethereum-worker] +BOUNCE_FEE = DEVETH:0.00001 CONFIRMATION = 3 -AUTH_METHOD = none -\ No newline at end of file + +[depolymerizer-ethereum-httpd-wire-gateway-api] +ENABLED = YES +AUTH_METHOD = none + +[depolymerizer-ethereum-httpd-revenue-api] +ENABLED = YES +AUTH_METHOD = none diff --git a/instrumentation/conf/taler_eth_bump.conf b/instrumentation/conf/taler_eth_bump.conf @@ -1,12 +1,15 @@ -[taler] -CURRENCY = DEVETH - -[exchange] -BASE_URL = http://test.com - [depolymerizer-ethereum] -DB_URL = postgres://localhost:5454/postgres?user=postgres&password=password -PORT = 8060 +CURRENCY = DEVETH + +[depolymerizer-ethereum-worker] +BOUNCE_FEE = DEVETH:0.00001 CONFIRMATION = 3 -BUMP_DELAY = 5 -AUTH_METHOD = none -\ No newline at end of file +BUMP_DELAY = 5 + +[depolymerizer-ethereum-httpd-wire-gateway-api] +ENABLED = YES +AUTH_METHOD = none + +[depolymerizer-ethereum-httpd-revenue-api] +ENABLED = YES +AUTH_METHOD = none +\ No newline at end of file diff --git a/instrumentation/conf/taler_eth_lifetime.conf b/instrumentation/conf/taler_eth_lifetime.conf @@ -1,13 +1,18 @@ -[taler] -CURRENCY = DEVETH +[depolymerizer-ethereum] +CURRENCY = DEVETH -[exchange] -BASE_URL = http://test.com +[depolymerizer-ethereum-worker] +BOUNCE_FEE = DEVETH:0.00001 +CONFIRMATION = 3 +LIFETIME = 10 -[depolymerizer-ethereum] -DB_URL = postgres://localhost:5454/postgres?user=postgres&password=password -PORT = 8060 -CONFIRMATION = 3 -HTTP_LIFETIME = 10 -WIRE_LIFETIME = 10 -AUTH_METHOD = none -\ No newline at end of file +[depolymerizer-ethereum-httpd] +LIFETIME = 10 + +[depolymerizer-ethereum-httpd-wire-gateway-api] +ENABLED = YES +AUTH_METHOD = none + +[depolymerizer-ethereum-httpd-revenue-api] +ENABLED = YES +AUTH_METHOD = none +\ No newline at end of file diff --git a/instrumentation/src/btc.rs b/instrumentation/src/btc.rs @@ -15,38 +15,32 @@ */ use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, ops::{Deref, DerefMut}, - path::{Path, PathBuf}, - str::FromStr, - thread::sleep, + path::Path, time::Duration, }; -use bitcoin::{Address, Amount, BlockHash, Network, SignedAmount, Txid, hashes::Hash}; -use btc_wire::{ - WireState, - btc_config::BitcoinConfig, - rpc::{self, Category, ErrorCode, Rpc}, - rpc_utils::{self, segwit_min_amount}, - taler_utils::btc_to_taler, +use bitcoin::{Address, Amount, BlockHash, SignedAmount}; +use common::taler_common::{ + api_common::{EddsaPublicKey, ShortHashCode}, + types::base32::Base32, }; -use common::{ - currency::CurrencyBtc, - metadata::OutMetadata, - payto::BtcAccount, - postgres::NoTls, - taler_common::{ - api_common::{EddsaPublicKey, ShortHashCode}, - types::base32::Base32, - }, +use depolymerizer_bitcoin::{ + CONFIG_SOURCE, + config::{RpcAuth, RpcCfg, ServeCfg, WorkerCfg}, + payto::BtcWallet, + rpc::{Category, Rpc}, + rpc_utils::segwit_min_amount, + taler_utils::btc_to_taler, }; -use indicatif::ProgressBar; -use taler_common::types::payto::Payto; +use ini::Ini; +use taler_common::{config::Config, types::payto::Payto}; use tempfile::TempDir; use crate::utils::{ - ChildGuard, TalerCtx, TestCtx, check_incoming, check_outgoing, cmd_redirect, cmd_redirect_ok, - print_now, retry, retry_opt, transfer, unused_port, + ChildGuard, TalerCtx, TestCtx, cmd_redirect, patch_config, print_now, retry, retry_opt, + transfer, unused_port, }; pub const CLIENT: &str = "client"; @@ -80,7 +74,8 @@ fn wait_for_pending(since: &mut BlockHash, client_rpc: &mut Rpc, wire_rpc: &mut } pub fn online_test(config: Option<&Path>, base_url: &str) { - let state = WireState::load_taler_config(config); + todo!(); + /*let state = WireState::parse(config); if state.btc_config.network == Network::Bitcoin { panic!("You should never run this test on a real bitcoin network"); @@ -206,13 +201,13 @@ pub fn online_test(config: Option<&Path>, base_url: &str) { println!("Get back some money"); let wtid = Base32::rand(); - transfer( + /*transfer( base_url, &wtid, &state.base_url, Payto::new(BtcAccount(client_addr)).as_payto(), &taler_test_amount, - ); + );*/ wait_for_pending(&mut since, &mut client_rpc, &mut wire_rpc); println!("Check balances"); @@ -220,11 +215,11 @@ pub fn online_test(config: Option<&Path>, base_url: &str) { assert_eq!(new_client_balance + test_amount, last_client_balance); println!("Check outgoing history"); - assert!(check_outgoing( + /*assert!(check_outgoing( base_url, &state.base_url, &[(wtid, taler_test_amount)] - )); + ));*/*/ } pub struct BtcCtx { @@ -238,7 +233,8 @@ pub struct BtcCtx { wire_addr: Address, pub client_addr: Address, reserve_addr: Address, - state: WireState, + worker_cfg: WorkerCfg, + serve_cfg: ServeCfg, conf: u16, ctx: TalerCtx, node2_addr: String, @@ -259,64 +255,98 @@ impl DerefMut for BtcCtx { } impl BtcCtx { - pub fn config(ctx: TestCtx, config: &str) { - // Bitcoin config - let config = PathBuf::from_str("instrumentation/conf") - .unwrap() - .join(config); + pub fn config( + ctx: &TestCtx, + btc_patch: impl FnOnce(&mut Ini, &Path), + cfg_patch: impl FnOnce(&mut Ini, &Path), + ) { + // Patch configs let wire_dir = TempDir::new().unwrap(); let wire_dir = wire_dir.path(); - std::fs::copy(config, wire_dir.join("bitcoin.conf")).unwrap(); + let port = unused_port(); + let rpc_port = unused_port(); + + patch_config( + "instrumentation/conf/bitcoin.conf", + wire_dir.join("bitcoin.conf"), + |cfg| { + cfg.with_section(Some("regtest")) + .set("bind", format!("127.0.0.1:{port}")) + .set("rpcport", format!("{rpc_port}")); + btc_patch(cfg, wire_dir) + }, + ); + patch_config( + "instrumentation/conf/taler_btc.conf", + wire_dir.join("config.conf"), + |cfg| { + cfg.with_section(Some("depolymerizer-bitcoin-worker")) + .set("RPC_BIND", format!("127.0.0.1:{rpc_port}")); + cfg_patch(cfg, wire_dir) + }, + ); // Load config - let config = BitcoinConfig::load(wire_dir.join("bitcoin.conf"), CurrencyBtc::Dev).unwrap(); + let cfg = Config::from_file(CONFIG_SOURCE, Some(wire_dir.join("config.conf"))).unwrap(); + let rpc_cfg = RpcCfg::parse(&cfg).unwrap(); // Start bitcoin nodes - let _btc_node = cmd_redirect( + let _node = cmd_redirect( "bitcoind", &[&format!("-datadir={}", wire_dir.to_string_lossy())], ctx.log("bitcoind"), ); // Connect - retry(|| { - Rpc::common(&config) - .ok() - .and_then(|mut it| it.get_blockchain_info().ok()) - .is_some() - }) + retry_opt(|| { + let mut client = Rpc::common(&rpc_cfg)?; + client.get_blockchain_info()?; + Ok::<_, anyhow::Error>(()) + }); } - fn patch_config(from: &str, to: PathBuf, port: u16, rpc_port: u16) { - let mut config = ini::Ini::load_from_file(from).unwrap(); - config - .with_section(Some("regtest")) - .set("bind", format!("127.0.0.1:{port}")) - .set("rpcport", format!("{rpc_port}")); - config.write_to_file(to).unwrap(); + fn patch_btc_config(from: impl AsRef<Path>, to: impl AsRef<Path>, port: u16, rpc_port: u16) { + patch_config(from, to, |cfg| { + cfg.with_section(Some("regtest")) + .set("bind", format!("127.0.0.1:{port}")) + .set("rpcport", format!("{rpc_port}")); + }) } - pub fn setup(ctx: &TestCtx, taler_config: &str, stressed: bool) -> Self { - let mut ctx = TalerCtx::new(ctx, "btc-wire", taler_config, stressed); + pub fn setup(ctx: &TestCtx, config: &str, stressed: bool) -> Self { + let mut ctx = TalerCtx::new(ctx, "depolymerizer-bitcoin", config, stressed); + + ctx.dbinit(); + // Choose unused port let btc_port = unused_port(); let btc_rpc_port = unused_port(); let btc2_port = unused_port(); let btc2_rpc_port = unused_port(); + // Bitcoin config - Self::patch_config( + Self::patch_btc_config( "instrumentation/conf/bitcoin.conf", ctx.wire_dir.join("bitcoin.conf"), btc_port, btc_rpc_port, ); - Self::patch_config( + Self::patch_btc_config( "instrumentation/conf/bitcoin2.conf", ctx.wire2_dir.join("bitcoin.conf"), btc2_port, btc2_rpc_port, ); + patch_config(&ctx.conf, &ctx.conf, |cfg| { + cfg.with_section(Some("depolymerizer-bitcoin-worker")) + .set("RPC_BIND", format!("127.0.0.1:{btc_rpc_port}")) + .set("WALLET_NAME", "wire") + .set( + "RPC_COOKIE_FILE", + ctx.wire_dir.join("regtest/.cookie").to_string_lossy(), + ); + }); + // Load config - let state = WireState::load_taler_config(Some(&ctx.conf)); - let btc_config2 = - BitcoinConfig::load(ctx.wire2_dir.join("bitcoin.conf"), state.currency).unwrap(); + let config = Config::from_file(CONFIG_SOURCE, Some(&ctx.conf)).unwrap(); + let cfg = WorkerCfg::parse(&config).unwrap(); // Start bitcoin nodes let btc_node = cmd_redirect( "bitcoind", @@ -329,30 +359,30 @@ impl BtcCtx { ctx.log("bitcoind2"), ); - let mut common_rpc = retry_opt(|| Rpc::common(&state.btc_config).ok()); - ctx.init_db(); - // Generate wallet - cmd_redirect_ok( - &ctx.wire_bin_path, - &["-c", ctx.conf.to_str().unwrap(), "initwallet"], - ctx.log("cmd"), - "wire initwallet", - ); - ctx.run(); - // Setup wallets + let mut common_rpc = retry_opt(|| Rpc::common(&cfg.rpc_cfg)); let node2_addr = format!("127.0.0.1:{btc2_port}"); common_rpc.add_node(&node2_addr).unwrap(); - for name in ["client", "reserve"] { + for name in ["wire", "client", "reserve"] { common_rpc.create_wallet(name, "").unwrap(); } - let common_rpc2 = retry_opt(|| Rpc::common(&btc_config2).ok()); + let common_rpc2 = retry_opt(|| { + Rpc::common(&RpcCfg { + addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), btc2_rpc_port), + auth: RpcAuth::Cookie( + ctx.wire2_dir + .join("regtest/.cookie") + .to_string_lossy() + .to_string(), + ), + }) + }); // Generate money - let mut reserve_rpc = Rpc::wallet(&state.btc_config, "reserve").unwrap(); - let mut client_rpc = Rpc::wallet(&state.btc_config, "client").unwrap(); - let mut wire_rpc = Rpc::wallet(&state.btc_config, "wire").unwrap(); + let mut reserve_rpc = Rpc::wallet(&cfg.rpc_cfg, "reserve").unwrap(); + let mut client_rpc = Rpc::wallet(&cfg.rpc_cfg, "client").unwrap(); + let mut wire_rpc = Rpc::wallet(&cfg.rpc_cfg, "wire").unwrap(); let reserve_addr = reserve_rpc.gen_addr().unwrap(); let client_addr = client_rpc.gen_addr().unwrap(); let wire_addr = wire_rpc.gen_addr().unwrap(); @@ -362,6 +392,19 @@ impl BtcCtx { .unwrap(); common_rpc.mine(1, &reserve_addr).unwrap(); + patch_config(&ctx.conf, &ctx.conf, |cfg| { + cfg.with_section(Some("depolymerizer-bitcoin")) + .set("NAME", "Exchange Owner") + .set("WALLET", reserve_addr.to_string()); + }); + + let config = Config::from_file(CONFIG_SOURCE, Some(&ctx.conf)).unwrap(); + let serve_cfg = ServeCfg::parse(&config).unwrap(); + + // Setup & run + ctx.setup(); + ctx.run(); + Self { ctx, btc_node, @@ -372,8 +415,9 @@ impl BtcCtx { wire_addr, client_addr, reserve_addr, - conf: state.confirmation as u16, - state, + conf: cfg.confirmation as u16, + worker_cfg: cfg, + serve_cfg, _btc_node2, common_rpc2, node2_addr, @@ -381,18 +425,8 @@ impl BtcCtx { } pub fn reset_db(&mut self) { - let hash: BlockHash = self.common_rpc.get_genesis().unwrap(); - let mut db = self.ctx.taler_conf.db_config().connect(NoTls).unwrap(); - let mut tx = db.transaction().unwrap(); - // Clear transaction tables and reset state - tx.batch_execute("DELETE FROM tx_in;DELETE FROM tx_out;DELETE FROM bounce;") - .unwrap(); - tx.execute( - "UPDATE state SET value=$1 WHERE name='last_hash'", - &[&hash.as_byte_array().as_slice()], - ) - .unwrap(); - tx.commit().unwrap(); + self.ctx.reset_db(); + self.ctx.setup(); } pub fn stop_node(&mut self) { @@ -405,8 +439,13 @@ impl BtcCtx { self.common_rpc.disconnect_node(&self.node2_addr).unwrap(); } - pub fn cluster_fork(&mut self, length: u16) { - self.common_rpc2.mine(length, &self.reserve_addr).unwrap(); + pub fn cluster_fork(&mut self) { + let node1_height = self.common_rpc.get_blockchain_info().unwrap().blocks; + let node2_height = self.common_rpc2.get_blockchain_info().unwrap().blocks; + let diff = node1_height - node2_height; + self.common_rpc2 + .mine((diff + 1) as u16, &self.reserve_addr) + .unwrap(); self.common_rpc.add_node(&self.node2_addr).unwrap(); } @@ -420,15 +459,15 @@ impl BtcCtx { let mut args = vec![datadir.as_str()]; args.extend_from_slice(additional_args); self.btc_node = cmd_redirect("bitcoind", &args, self.ctx.log("bitcoind")); - self.common_rpc = retry_opt(|| Rpc::common(&self.state.btc_config).ok()); + self.common_rpc = retry_opt(|| Rpc::common(&self.worker_cfg.rpc_cfg)); self.common_rpc.add_node(&self.node2_addr).unwrap(); for name in ["client", "reserve", "wire"] { self.common_rpc.load_wallet(name).ok(); } - self.reserve_rpc = Rpc::wallet(&self.state.btc_config, "reserve").unwrap(); - self.client_rpc = Rpc::wallet(&self.state.btc_config, "client").unwrap(); - self.wire_rpc = Rpc::wallet(&self.state.btc_config, "wire").unwrap(); + self.reserve_rpc = Rpc::wallet(&self.worker_cfg.rpc_cfg, "reserve").unwrap(); + self.client_rpc = Rpc::wallet(&self.worker_cfg.rpc_cfg, "client").unwrap(); + self.wire_rpc = Rpc::wallet(&self.worker_cfg.rpc_cfg, "wire").unwrap(); } /* ----- Transaction ------ */ @@ -443,9 +482,10 @@ impl BtcCtx { transfer( &self.ctx.gateway_url, metadata, - &self.state.base_url, - Payto::new(BtcAccount(self.client_addr.clone())).as_payto(), - &btc_to_taler(&amount.to_signed().unwrap(), self.state.currency), + Payto::new(BtcWallet(self.client_addr.clone())) + .as_payto() + .as_full_payto("name"), + &btc_to_taler(&amount.to_signed().unwrap(), &self.worker_cfg.currency), ) } @@ -505,13 +545,16 @@ impl BtcCtx { } fn expect_balance(&mut self, balance: Amount, mine: bool, lambda: fn(&mut Self) -> Amount) { - retry(|| { - let check = balance == lambda(self); - if !check && mine { - self.next_block(); - } - check - }); + retry( + || { + let check = balance == lambda(self); + if !check && mine { + self.next_block(); + } + check + }, + "balance", + ); } pub fn expect_client_balance(&mut self, balance: Amount, mine: bool) { @@ -530,7 +573,7 @@ impl BtcCtx { .map(|(metadata, amount)| { ( metadata.clone(), - btc_to_taler(&amount.to_signed().unwrap(), self.state.currency), + btc_to_taler(&amount.to_signed().unwrap(), &self.worker_cfg.currency), ) }) .collect(); @@ -543,11 +586,11 @@ impl BtcCtx { .map(|(metadata, amount)| { ( metadata.clone(), - btc_to_taler(&amount.to_signed().unwrap(), self.state.currency), + btc_to_taler(&amount.to_signed().unwrap(), &self.worker_cfg.currency), ) }) .collect(); - self.ctx.expect_debits(&self.state.base_url, &txs) + self.ctx.expect_debits(&txs) } } @@ -571,7 +614,7 @@ pub fn wire(ctx: TestCtx) { } ctx.next_conf(); ctx.expect_credits(&txs); - ctx.expect_wire_balance(balance, true); + ctx.expect_wire_balance(balance, false); }; ctx.step("Debit"); @@ -597,7 +640,7 @@ pub fn wire(ctx: TestCtx) { let mut balance = ctx.wire_balance(); for n in 10..40 { ctx.malformed_credit(&Amount::from_sat(n * 1000)); - balance += ctx.state.bounce_fee; + balance += ctx.worker_cfg.bounce_fee; } ctx.next_conf(); ctx.expect_wire_balance(balance, true); @@ -610,21 +653,24 @@ pub fn lifetime(ctx: TestCtx) { let mut ctx = BtcCtx::setup(&ctx, "taler_btc_lifetime.conf", false); ctx.step("Check lifetime"); // Start up - retry(|| ctx.wire_running() && ctx.gateway_running()); + retry( + || ctx.wire_running() && ctx.gateway_running(), + "both running", + ); // Consume wire lifetime - for _ in 0..=ctx.taler_conf.wire_lifetime().unwrap() + 2 { + for _ in 0..=ctx.worker_cfg.lifetime.unwrap() + 2 { ctx.credit(segwit_min_amount(), &Base32::rand()); ctx.next_block(); std::thread::sleep(Duration::from_millis(200)); } - retry(|| !ctx.wire_running()); + retry(|| !ctx.wire_running(), "wire not running"); // Consume gateway lifetime - for _ in 0..=ctx.taler_conf.http_lifetime().unwrap() { + for _ in 0..=ctx.serve_cfg.lifetime.unwrap() { ctx.debit(segwit_min_amount(), &Base32::rand()); ctx.next_block(); } // End down - retry(|| !ctx.gateway_running()); + retry(|| !ctx.gateway_running(), "server not running"); } /// Check the capacity of wire-gateway and btc-wire to recover from database and node loss @@ -666,11 +712,7 @@ pub fn reconnect(ctx: TestCtx) { let amount = Amount::from_sat(2000); ctx.debit(amount, &metadata); debits.push((metadata, amount)); - ctx.next_block(); - sleep(Duration::from_secs(3)); - ctx.next_block(); - sleep(Duration::from_secs(3)); - ctx.next_block(); + ctx.next_conf(); ctx.expect_debits(&debits); ctx.expect_credits(&credits); } @@ -731,7 +773,7 @@ pub fn stress(ctx: TestCtx) { let mut balance = ctx.wire_balance(); for n in 10..30 { ctx.malformed_credit(&Amount::from_sat(n * 1000)); - balance += ctx.state.bounce_fee; + balance += ctx.worker_cfg.bounce_fee; } ctx.next_conf(); ctx.expect_wire_balance(balance, true); @@ -765,7 +807,7 @@ pub fn conflict(tctx: TestCtx) { // Perform debit ctx.debit(Amount::from_sat(400000), &Base32::rand()); - retry(|| ctx.wire_balance() < wire); + retry(|| ctx.wire_balance() < wire, "balance"); // Abandon pending transaction ctx.restart_node(&["-minrelaytxfee=0.0001"]); @@ -775,13 +817,13 @@ pub fn conflict(tctx: TestCtx) { // Generate conflict ctx.debit(Amount::from_sat(500000), &Base32::rand()); - retry(|| ctx.wire_balance() < wire); + retry(|| ctx.wire_balance() < wire, "balance"); // Resend conflicting transaction ctx.restart_node(&[]); ctx.next_block(); let wire = ctx.wire_balance(); - retry(|| ctx.wire_balance() < wire); + retry(|| ctx.wire_balance() < wire, "balance"); } ctx.step("Setup"); @@ -797,7 +839,7 @@ pub fn conflict(tctx: TestCtx) { let bounce_amount = Amount::from_sat(4000000); ctx.malformed_credit(&bounce_amount); ctx.next_conf(); - let fee = ctx.state.bounce_fee; + let fee = ctx.worker_cfg.bounce_fee; ctx.expect_wire_balance(wire + fee, true); // Abandon pending transaction @@ -808,13 +850,13 @@ pub fn conflict(tctx: TestCtx) { // Generate conflict let amount = Amount::from_sat(50000); ctx.debit(amount, &Base32::rand()); - retry(|| ctx.wire_balance() < (wire + bounce_amount)); + retry(|| ctx.wire_balance() < (wire + bounce_amount), "balance"); // Resend conflicting transaction ctx.restart_node(&[]); let wire = ctx.wire_balance(); ctx.next_block(); - retry(|| ctx.wire_balance() < wire); + retry(|| ctx.wire_balance() < wire, "balance"); } } @@ -838,7 +880,7 @@ pub fn reorg(ctx: TestCtx) { // Perform fork and check btc-wire hard error ctx.expect_gateway_up(); - ctx.cluster_fork(22); + ctx.cluster_fork(); ctx.expect_wire_balance(before, false); ctx.expect_gateway_down(); @@ -866,7 +908,7 @@ pub fn reorg(ctx: TestCtx) { // Perform fork and check btc-wire still up ctx.expect_gateway_up(); - ctx.cluster_fork(22); + ctx.cluster_fork(); ctx.expect_client_balance(before, false); ctx.expect_gateway_up(); @@ -887,19 +929,19 @@ pub fn reorg(ctx: TestCtx) { let mut after = ctx.wire_balance(); for n in 10..21 { ctx.malformed_credit(&Amount::from_sat(n * 1000)); - after += ctx.state.bounce_fee; + after += ctx.worker_cfg.bounce_fee; } ctx.next_conf(); ctx.expect_wire_balance(after, true); // Perform fork and check btc-wire hard error ctx.expect_gateway_up(); - ctx.cluster_fork(22); + ctx.cluster_fork(); ctx.expect_wire_balance(before, false); ctx.expect_gateway_down(); // Recover orphaned transaction - ctx.mine(10); + ctx.mine(12); ctx.expect_wire_balance(after, false); ctx.expect_gateway_up(); } @@ -920,7 +962,7 @@ pub fn hell(ctx: TestCtx) { // Perform fork and check btc-wire hard error ctx.expect_gateway_up(); - ctx.cluster_fork(ctx.conf * 2); + ctx.cluster_fork(); ctx.expect_gateway_down(); // Generate conflict @@ -949,7 +991,7 @@ pub fn hell(ctx: TestCtx) { let amount = Amount::from_sat(420000); ctx.malformed_credit(&amount); ctx.next_conf(); - let fee = ctx.state.bounce_fee; + let fee = ctx.worker_cfg.bounce_fee; ctx.expect_wire_balance(fee, true); }); } @@ -972,7 +1014,7 @@ pub fn analysis(ctx: TestCtx) { // Perform fork and check btc-wire hard error ctx.expect_gateway_up(); - ctx.cluster_fork(5); + ctx.cluster_fork(); ctx.expect_wire_balance(before, false); ctx.expect_gateway_down(); @@ -992,9 +1034,8 @@ pub fn analysis(ctx: TestCtx) { // Perform fork and check btc-wire learned from previous attack ctx.expect_gateway_up(); - ctx.cluster_fork(5); + ctx.cluster_fork(); ctx.expect_wire_balance(before, false); - std::thread::sleep(Duration::from_secs(3)); // Give some time for the gateway to be down ctx.expect_gateway_up(); } @@ -1017,7 +1058,7 @@ pub fn bumpfee(tctx: TestCtx) { let wire = ctx.wire_balance(); let amount = Amount::from_sat(40000); ctx.debit(amount, &Base32::rand()); - retry(|| ctx.wire_balance() < wire); + retry(|| ctx.wire_balance() < wire, "balance"); // Bump min relay fee making the previous debit stuck ctx.restart_node(&["-minrelaytxfee=0.0001"]); @@ -1037,10 +1078,10 @@ pub fn bumpfee(tctx: TestCtx) { let wire = ctx.wire_balance(); let amount = Amount::from_sat(40000); ctx.debit(amount, &Base32::rand()); - retry(|| ctx.wire_balance() < wire); + retry(|| ctx.wire_balance() < wire, "balance"); // Bump min relay fee and fork making the previous debit stuck and problematic - ctx.cluster_fork(6); + ctx.cluster_fork(); ctx.restart_node(&["-minrelaytxfee=0.0001"]); // Check bump happen @@ -1072,7 +1113,7 @@ pub fn bumpfee(tctx: TestCtx) { total_amount += amount; ctx.debit(amount, &Base32::rand()); } - retry(|| ctx.wire_balance() < wire - total_amount); + retry(|| ctx.wire_balance() < wire - total_amount, "balance"); // Bump min relay fee making the previous debits stuck ctx.restart_node(&["-minrelaytxfee=0.0001"]); @@ -1128,16 +1169,54 @@ pub fn maxfee(ctx: TestCtx) { /// Test btc-wire ability to configure itself from bitcoin configuration pub fn config(ctx: TestCtx) { - for n in 0..5 { - let config_name = format!("bitcoin_auth{}.conf", n); - ctx.step(format!("Config {}", config_name)); - BtcCtx::config( - TestCtx::new( - &format!("config/{config_name}"), - ProgressBar::hidden(), - ctx.db.clone(), - ), - &config_name, - ); - } + // Connect with cookie files + ctx.step("Cookie"); + BtcCtx::config( + &ctx, + |btc, dir| { + btc.with_section(None::<&str>).set( + "rpccookiefile", + dir.join("catch_me_if_you_can").to_string_lossy(), + ); + }, + |cfg, dir| { + cfg.with_section(Some("depolymerizer-bitcoin-worker")).set( + "RPC_COOKIE_FILE", + dir.join("catch_me_if_you_can").to_string_lossy(), + ); + }, + ); + + // Connect with password + ctx.step("Password"); + BtcCtx::config( + &ctx, + |btc, _| { + btc.with_section(None::<&str>) + .set("rpcuser", "bob") + .set("rpcpassword", "password"); + }, + |cfg, _| { + cfg.with_section(Some("depolymerizer-bitcoin-worker")) + .set("RPC_AUTH_METHOD", "basic") + .set("RPC_USERNAME", "bob") + .set("RPC_PASSWORD", "password"); + }, + ); + + // Connect with token + ctx.step("Token"); + BtcCtx::config( + &ctx, + |btc, _| { + btc.with_section(None::<&str>) + .set("rpcauth", "bob:9641cec731e1fad1ded02e1d31536e44$36b8b8af0a38104997a57f017805ff56bf8963ae4a2ed40252ca0e0e070fc19e"); + }, + |cfg, _| { + cfg.with_section(Some("depolymerizer-bitcoin-worker")) + .set("RPC_AUTH_METHOD", "basic") + .set("RPC_USERNAME", "bob") + .set("RPC_PASSWORD", "password"); + }, + ); } diff --git a/instrumentation/src/eth.rs b/instrumentation/src/eth.rs @@ -23,23 +23,24 @@ use std::{ use common::{ metadata::OutMetadata, - payto::EthAccount, - postgres::NoTls, taler_common::{ api_common::{EddsaPublicKey, ShortHashCode}, types::{base32::Base32, payto::Payto}, }, }; -use eth_wire::{ - RpcExtended, SyncState, WireState, +use depolymerizer_ethereum::{ + CONFIG_SOURCE, RpcExtended, SyncState, + config::{ServeCfg, WorkerCfg}, + payto::EthAccount, rpc::{Rpc, RpcClient, TransactionRequest, hex::Hex}, taler_util::{TRUNC, eth_to_taler}, }; use ethereum_types::{H160, H256, U256}; +use taler_common::config::Config; use crate::utils::{ ChildGuard, TalerCtx, TestCtx, check_incoming, check_outgoing, cmd_out, cmd_redirect, - cmd_redirect_ok, print_now, retry, retry_opt, transfer, unused_port, + cmd_redirect_ok, patch_config, print_now, retry, retry_opt, transfer, unused_port, }; fn wait_for_pending(rpc: &mut Rpc) { @@ -55,11 +56,12 @@ fn wait_for_pending(rpc: &mut Rpc) { } pub fn online_test(config: Option<&Path>, base_url: &str) { - let state = WireState::load_taler_config(config); + let cfg = Config::from_file(CONFIG_SOURCE, config).unwrap(); + let state = WorkerCfg::parse(&cfg).unwrap(); // TODO eth network check let min_fund = U256::from(100_000 * TRUNC); let test_amount = U256::from(20_000 * TRUNC); - let taler_test_amount = eth_to_taler(&test_amount, state.currency); + let taler_test_amount = eth_to_taler(&test_amount, &state.currency); let mut rpc = Rpc::new(state.ipc_path).unwrap(); @@ -196,7 +198,6 @@ pub fn online_test(config: Option<&Path>, base_url: &str) { transfer( base_url, &wtid, - &state.base_url, Payto::new(EthAccount(client_addr)).as_payto(), &taler_test_amount, ); @@ -207,11 +208,7 @@ pub fn online_test(config: Option<&Path>, base_url: &str) { assert_eq!(new_client_balance + test_amount, last_client_balance); println!("Check outgoing history"); - assert!(check_outgoing( - base_url, - &state.base_url, - &[(wtid, taler_test_amount)] - )); + assert!(check_outgoing(base_url, &[(wtid, taler_test_amount)])); } struct EthCtx { @@ -220,8 +217,8 @@ struct EthCtx { wire_addr: H160, client_addr: H160, reserve_addr: H160, - state: WireState, - conf: u16, + worker_cfg: WorkerCfg, + serve_cfg: ServeCfg, ctx: TalerCtx, passwd: String, } @@ -242,12 +239,15 @@ impl DerefMut for EthCtx { impl EthCtx { pub fn setup(ctx: &TestCtx, config: &str, stressed: bool) -> Self { - let mut ctx = TalerCtx::new(ctx, "eth-wire", config, stressed); + let mut ctx = TalerCtx::new(ctx, "depolymerizer-ethereum", config, stressed); + + ctx.dbinit(); + // Init chain - let passwd = std::env::var("PASSWORD").unwrap(); + let passwd: String = (0..30).map(|_| fastrand::alphanumeric()).collect(); let pswd_path = ctx.dir.path().join("pswd"); - std::fs::write(&pswd_path, passwd.as_bytes()).unwrap(); - for _ in ["reserve", "client"] { + std::fs::write(&pswd_path, &passwd).unwrap(); + for _ in ["reserve", "client", "wire"] { cmd_redirect_ok( "geth", &[ @@ -273,8 +273,12 @@ impl EthCtx { "list", ], ); - let reserve = &list[13..][..40]; - let client = &list.lines().nth(1).unwrap()[13..][..40]; + let mut addrs = list.lines().map(|l| &l[13..][..40]); + let (reserve, client, wire) = ( + addrs.next().unwrap(), + addrs.next().unwrap(), + addrs.next().unwrap(), + ); let genesis = format!( "{{ \"config\": {{ @@ -296,12 +300,11 @@ impl EthCtx { \"difficulty\": \"1\", \"gasLimit\": \"0\", \"baseFeePerGas\": null, - \"extraData\": \"0x0000000000000000000000000000000000000000000000000000000000000000{}0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\", + \"extraData\": \"0x0000000000000000000000000000000000000000000000000000000000000000{reserve}0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\", \"alloc\": {{ - \"{}\": {{ \"balance\": \"10000000000000000000\" }} + \"{client}\": {{ \"balance\": \"10000000000000000000\" }} }} - }}", - reserve, client + }}" ); std::fs::write(ctx.wire_dir.join("genesis.json"), genesis.as_bytes()).unwrap(); @@ -334,6 +337,9 @@ impl EthCtx { &[ "--datadir", ctx.wire_dir.to_str().unwrap(), + "--cache", + "16", + "--nodiscover", "--lightkdf", "--miner.gasprice", "10", @@ -345,25 +351,26 @@ impl EthCtx { ], ctx.log("geth"), ); - let mut rpc = retry_opt(|| Rpc::new(&ctx.wire_dir).ok()); - ctx.init_db(); + let mut rpc = retry_opt(|| Rpc::new(&ctx.wire_dir)); // Generate wallet - let out = cmd_out( - &ctx.wire_bin_path, - &["-c", ctx.conf.to_str().unwrap(), "initwallet"], - ); + patch_config(&ctx.conf, &ctx.conf, |cfg| { + cfg.with_section(Some("depolymerizer-ethereum")) + .set("NAME", "Exchange Owner") + .set("ACCOUNT", wire); + cfg.with_section(Some("depolymerizer-ethereum-worker")) + .set("PASSWORD", &passwd) + .set("IPC_PATH", ctx.wire_dir.to_str().unwrap()); + }); - let mut config = ini::Ini::load_from_file(&ctx.conf).unwrap(); - config.with_section(Some("depolymerizer-ethereum")).set( - "PAYTO", - out.lines().nth(6).unwrap().split(" = ").last().unwrap(), - ); - config.write_to_file(&ctx.conf).unwrap(); + let cfg = Config::from_file(CONFIG_SOURCE, Some(&ctx.conf)).unwrap(); + let worker_cfg = WorkerCfg::parse(&cfg).unwrap(); + let serve_cfg = ServeCfg::parse(&cfg).unwrap(); + // Setup & run + ctx.setup(); ctx.run(); - let state = WireState::load_taler_config(Some(&ctx.conf)); let accounts = rpc.list_accounts().unwrap(); let reserve_addr = accounts[0]; let client_addr = accounts[1]; @@ -378,32 +385,16 @@ impl EthCtx { reserve_addr, client_addr, wire_addr, - conf: state.confirmation as u16, - state, + worker_cfg, + serve_cfg, ctx, passwd, } } pub fn reset_db(&mut self) { - let block = self.rpc.earliest_block().unwrap(); - let mut db = self.ctx.taler_conf.db_config().connect(NoTls).unwrap(); - let mut tx = db.transaction().unwrap(); - // Clear transaction tables and reset state - tx.batch_execute("DELETE FROM tx_in;DELETE FROM tx_out;DELETE FROM bounce;") - .unwrap(); - tx.execute( - "UPDATE state SET value=$1 WHERE name='sync'", - &[&SyncState { - tip_hash: block.hash.unwrap(), - tip_height: block.number.unwrap(), - conf_height: block.number.unwrap(), - } - .to_bytes() - .as_slice()], - ) - .unwrap(); - tx.commit().unwrap(); + self.ctx.reset_db(); + self.ctx.setup(); } pub fn stop_node(&mut self) { @@ -449,7 +440,7 @@ impl EthCtx { ); } - pub fn cluster_fork(&mut self, length: u16) { + pub fn cluster_fork(&mut self) { let node2 = cmd_redirect( "geth", &[ @@ -457,6 +448,9 @@ impl EthCtx { self.ctx.wire2_dir.to_str().unwrap(), "--keystore", self.ctx.wire_dir.join("keystore").to_str().unwrap(), + "--cache", + "16", + "--nodiscover", "--lightkdf", "--miner.gasprice", "10", @@ -468,8 +462,16 @@ impl EthCtx { ], self.ctx.log("geth2"), ); - let mut rpc = retry_opt(|| Rpc::new(&self.ctx.wire2_dir).ok()); - Self::_mine(&mut rpc, &self.reserve_addr, length, &self.passwd); + let mut rpc = retry_opt(|| Rpc::new(&self.ctx.wire2_dir)); + let node1_height = self.rpc.height().unwrap(); + let node2_height = rpc.height().unwrap(); + let diff = node1_height - node2_height; + Self::_mine( + &mut rpc, + &self.reserve_addr, + diff.as_u32() as u16 + 10, + &self.passwd, + ); let path = self.ctx.dir.path().join("chain"); let path = path.to_str().unwrap(); Self::export(&mut rpc, path); @@ -509,7 +511,7 @@ impl EthCtx { ]; args.extend_from_slice(additional_args); self.node = cmd_redirect("geth", &args, self.ctx.log("geth")); - self.rpc = retry_opt(|| Rpc::new(&self.ctx.wire_dir).ok()); + self.rpc = retry_opt(|| Rpc::new(&self.ctx.wire_dir)); for addr in [&self.wire_addr, &self.client_addr, &self.reserve_addr] { self.rpc.unlock_account(addr, &self.passwd).unwrap(); } @@ -536,9 +538,10 @@ impl EthCtx { transfer( &self.ctx.gateway_url, wtid, - &self.state.base_url, - Payto::new(EthAccount(self.client_addr)).as_payto(), - &eth_to_taler(&amount, self.state.currency), + Payto::new(EthAccount(self.client_addr)) + .as_payto() + .as_full_payto("Anonymous"), + &eth_to_taler(&amount, &self.worker_cfg.currency), ) } @@ -598,7 +601,7 @@ impl EthCtx { } pub fn next_conf(&mut self) { - self.mine(self.conf) + self.mine(self.worker_cfg.confirmation as u16) } pub fn next_block(&mut self) { @@ -620,13 +623,16 @@ impl EthCtx { } fn expect_balance(&mut self, balance: U256, mine: bool, lambda: fn(&mut Self) -> U256) { - retry(|| { - let check = balance == lambda(self); - if !check && mine { - self.next_block(); - } - check - }); + retry( + || { + let check = balance == lambda(self); + if !check && mine { + self.next_block(); + } + check + }, + "balance", + ); } pub fn expect_client_balance(&mut self, balance: U256, mine: bool) { @@ -642,7 +648,12 @@ impl EthCtx { pub fn expect_credits(&self, txs: &[(EddsaPublicKey, U256)]) { let txs: Vec<_> = txs .iter() - .map(|(metadata, amount)| (metadata.clone(), eth_to_taler(amount, self.state.currency))) + .map(|(metadata, amount)| { + ( + metadata.clone(), + eth_to_taler(amount, &self.worker_cfg.currency), + ) + }) .collect(); self.ctx.expect_credits(&txs) } @@ -650,9 +661,14 @@ impl EthCtx { pub fn expect_debits(&self, txs: &[(ShortHashCode, U256)]) { let txs: Vec<_> = txs .iter() - .map(|(metadata, amount)| (metadata.clone(), eth_to_taler(amount, self.state.currency))) + .map(|(metadata, amount)| { + ( + metadata.clone(), + eth_to_taler(amount, &self.worker_cfg.currency), + ) + }) .collect(); - self.ctx.expect_debits(&self.state.base_url, &txs) + self.ctx.expect_debits(&txs) } } @@ -700,7 +716,7 @@ pub fn wire(ctx: TestCtx) { let mut balance = ctx.wire_balance(); for n in 10..40 { ctx.malformed_credit(ctx.amount(n * 1000)); - balance += ctx.state.bounce_fee; + balance += ctx.worker_cfg.bounce_fee; } ctx.next_conf(); ctx.expect_wire_balance(balance, true); @@ -713,17 +729,23 @@ pub fn lifetime(ctx: TestCtx) { let mut ctx = EthCtx::setup(&ctx, "taler_eth_lifetime.conf", false); ctx.step("Check lifetime"); // Start up - retry(|| ctx.wire_running() && ctx.gateway_running()); + retry( + || ctx.wire_running() && ctx.gateway_running(), + "both running", + ); // Consume lifetime - for n in 0..=ctx.taler_conf.wire_lifetime().unwrap() { + for n in 0..=ctx.worker_cfg.lifetime.unwrap() { ctx.credit(ctx.amount(n * 1000), &Base32::rand()); ctx.next_block(); } - for n in 0..=ctx.taler_conf.http_lifetime().unwrap() { + for n in 0..=ctx.serve_cfg.lifetime.unwrap() { ctx.debit(ctx.amount(n * 1000), &Base32::rand()); } // End down - retry(|| !ctx.wire_running() && !ctx.gateway_running()); + retry( + || !ctx.wire_running() && !ctx.gateway_running(), + "both down", + ); } /// Check the capacity of wire-gateway and eth-wire to recover from database and node loss @@ -832,7 +854,7 @@ pub fn stress(ctx: TestCtx) { let mut balance = ctx.wire_balance(); for n in 10..30 { ctx.malformed_credit(ctx.amount(n * 1000)); - balance += ctx.state.bounce_fee; + balance += ctx.worker_cfg.bounce_fee; } ctx.next_conf(); ctx.expect_wire_balance(balance, true); @@ -869,12 +891,12 @@ pub fn reorg(ctx: TestCtx) { // Perform fork and check eth-wire hard error ctx.expect_gateway_up(); - ctx.cluster_fork(10); + ctx.cluster_fork(); ctx.expect_wire_balance(before, false); ctx.expect_gateway_down(); // Recover orphaned transaction - ctx.mine(6); + ctx.next_conf(); ctx.expect_wire_balance(after, false); ctx.expect_gateway_up(); } @@ -897,7 +919,7 @@ pub fn reorg(ctx: TestCtx) { // Perform fork and check eth-wire still up ctx.expect_gateway_up(); - ctx.cluster_fork(10); + ctx.cluster_fork(); ctx.expect_client_balance(before, false); ctx.expect_gateway_up(); @@ -916,14 +938,14 @@ pub fn reorg(ctx: TestCtx) { let mut after = ctx.wire_balance(); for n in 10..21 { ctx.malformed_credit(ctx.amount(n * 1000)); - after += ctx.state.bounce_fee; + after += ctx.worker_cfg.bounce_fee; } ctx.next_conf(); ctx.expect_wire_balance(after, true); // Perform fork and check eth-wire hard error ctx.expect_gateway_up(); - ctx.cluster_fork(10); + ctx.cluster_fork(); ctx.expect_wire_balance(before, false); ctx.expect_gateway_down(); @@ -951,7 +973,7 @@ pub fn hell(ctx: TestCtx) { // Perform fork and check eth-wire hard error ctx.expect_gateway_up(); - ctx.cluster_fork(ctx.conf * 2); + ctx.cluster_fork(); ctx.expect_gateway_down(); // Generate conflict @@ -980,7 +1002,10 @@ pub fn hell(ctx: TestCtx) { let amount = ctx.amount(420000); ctx.malformed_credit(amount); ctx.next_conf(); - retry(|| ctx.wire_balance_pending() == ctx.state.bounce_fee); + retry( + || ctx.wire_balance_pending() == ctx.worker_cfg.bounce_fee, + "balance", + ); }); } @@ -1002,7 +1027,7 @@ pub fn analysis(ctx: TestCtx) { // Perform fork and check eth-wire hard error ctx.expect_gateway_up(); - ctx.cluster_fork(5); + ctx.cluster_fork(); ctx.expect_wire_balance(before, false); ctx.expect_gateway_down(); @@ -1021,7 +1046,7 @@ pub fn analysis(ctx: TestCtx) { // Perform fork and check eth-wire learned from previous attack ctx.expect_gateway_up(); - ctx.cluster_fork(5); + ctx.cluster_fork(); ctx.expect_wire_balance(before, false); std::thread::sleep(Duration::from_secs(3)); ctx.expect_gateway_up(); @@ -1043,7 +1068,7 @@ pub fn bumpfee(tctx: TestCtx) { let wire = ctx.wire_balance(); let amount = ctx.amount(40000); ctx.debit(amount, &Base32::rand()); - retry(|| ctx.wire_balance_pending() < wire); + retry(|| ctx.wire_balance_pending() < wire, "balance"); // Bump min relay fee making the previous debit stuck ctx.restart_node(&["--miner.gasprice", "1000"]); @@ -1063,10 +1088,10 @@ pub fn bumpfee(tctx: TestCtx) { let wire = ctx.wire_balance(); let amount = ctx.amount(40000); ctx.debit(amount, &Base32::rand()); - retry(|| ctx.wire_balance_pending() < wire); + retry(|| ctx.wire_balance_pending() < wire, "balance"); // Bump min relay fee and fork making the previous debit stuck and problematic - ctx.cluster_fork(6); + ctx.cluster_fork(); ctx.restart_node(&["--miner.gasprice", "2000"]); // Check bump happen @@ -1096,7 +1121,10 @@ pub fn bumpfee(tctx: TestCtx) { total_amount += amount; ctx.debit(amount, &Base32::rand()); } - retry(|| ctx.wire_balance_pending() < wire - total_amount); + retry( + || ctx.wire_balance_pending() < wire - total_amount, + "balance", + ); // Bump min relay fee making the previous debits stuck ctx.restart_node(&["--miner.gasprice", "1000"]); diff --git a/instrumentation/src/gateway.rs b/instrumentation/src/gateway.rs @@ -1,240 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use std::str::FromStr; - -use common::{ - payto::BtcAccount, - taler_common::{ - api_wire::TransferRequest, - types::{ - amount::Amount, - base32::Base32, - payto::{PaytoURI, payto}, - }, - }, -}; -use libdeflater::{CompressionLvl, Compressor}; -use taler_common::types::payto::Payto; - -use crate::{ - btc::BtcCtx, - utils::{TestCtx, cmd_out, cmd_redirect_ok, gateway_error, http_code}, -}; - -fn client_transfer(gateway_url: &str, payto_url: &PaytoURI, amount: &str) -> String { - cmd_out( - "taler-exchange-wire-gateway-client", - &["-b", gateway_url, "-C", payto_url.raw(), "-a", amount], - ) -} - -/// Test wire-gateway conformance to documentation and its security -pub fn api(ctx: TestCtx) { - ctx.step("Setup"); - let ctx = BtcCtx::setup(&ctx, "taler_btc.conf", false); - - ctx.step("Gateway API"); - { - // Perform debits - let mut amounts = Vec::new(); - for n in 1..10 { - let amount = format!("{}:0.000{}", ctx.taler_conf.currency.to_str(), n); - cmd_out( - "taler-exchange-wire-gateway-client", - &[ - "-b", - &ctx.gateway_url, - "-D", - Payto::new(BtcAccount(ctx.client_addr.clone())) - .as_payto() - .raw(), - "-a", - &amount, - ], - ); - amounts.push(amount); - } - - // Check history - let result = cmd_out( - "taler-exchange-wire-gateway-client", - &["-b", &ctx.gateway_url, "-i"], - ); - for amount in &amounts { - assert!(result.contains(amount)); - } - - // Perform credits - let mut amounts = Vec::new(); - for n in 1..10 { - let amount = format!("{}:0.0000{}", ctx.taler_conf.currency.to_str(), n); - client_transfer( - &ctx.gateway_url, - &Payto::new(BtcAccount(ctx.client_addr.clone())).as_payto(), - &amount, - ); - amounts.push(amount); - } - - // Check history - let result = cmd_out( - "taler-exchange-wire-gateway-client", - &["-b", &ctx.gateway_url, "-o"], - ); - for amount in &amounts { - assert!(result.contains(amount)); - } - }; - - ctx.step("Endpoint & Method"); - { - // Unknown endpoint - gateway_error(&format!("{}test", ctx.gateway_url), 404); - // Method not allowed - gateway_error(&format!("{}transfer", ctx.gateway_url), 405); - } - - let amount = &format!("{}:0.00042", ctx.taler_conf.currency.to_str()); - let btc_payto = Payto::new(BtcAccount(ctx.client_addr.clone())).as_payto(); - - ctx.step("Request format"); - { - // Bad payto_url - for url in [ - //"http://bitcoin/$CLIENT", - "payto://btc/$CLIENT", - "payto://bitcoin/$CLIENT?id=admin", - "payto://bitcoin/$CLIENT#admin", - "payto://bitcoin/42$CLIENT", - ] { - let url = url.replace("$CLIENT", &ctx.client_addr.to_string()); - let result = client_transfer(&ctx.gateway_url, &payto(url), amount); - assert!(result.contains("(400/24)")); - } - - // Bad transaction amount - let result = client_transfer(&ctx.gateway_url, &btc_payto, "ATC:0.00042"); - assert!(result.contains("(400/30)")); - - // Bad history delta - for delta in [ - "incoming?offset=-1", - "outgoing?offset=-1", - "incoming?delta=0", - "outgoing?delta=0", - ] { - let code = - http_code(ureq::get(&format!("{}history/{}", ctx.gateway_url, delta)).call()); - assert_eq!(code, 400); - } - } - - ctx.step("Transfer idempotence"); - { - let mut request = TransferRequest { - request_uid: Base32::rand(), - amount: Amount::from_str(amount).unwrap(), - exchange_base_url: ctx.taler_conf.base_url(), - wtid: Base32::rand(), - credit_account: btc_payto, - }; - // Same - assert_eq!( - http_code(ureq::post(&format!("{}transfer", ctx.gateway_url)).send_json(&request)), - 200 - ); - assert_eq!( - http_code(ureq::post(&format!("{}transfer", ctx.gateway_url)).send_json(&request)), - 200 - ); - // Collision - request.amount.frac += 42; - assert_eq!( - http_code(ureq::post(&format!("{}transfer", ctx.gateway_url)).send_json(&request)), - 409 - ); - } - - ctx.step("Security"); - { - let big_hello: String = (0..1000).map(|_| "Hello_world").collect(); - // Huge body - assert_eq!( - http_code(ureq::post(&format!("{}transfer", ctx.gateway_url)).send_json(&big_hello)), - 400 - ); - - // Body length liar - assert_eq!( - http_code( - ureq::post(&format!("{}transfer", ctx.gateway_url)) - .header("Content-Length", "1024") - .send_json(&big_hello) - ), - 400 - ); - - // Compression bomb - let mut compressor = Compressor::new(CompressionLvl::best()); - let mut compressed = vec![0u8; compressor.deflate_compress_bound(big_hello.len())]; - let size = compressor - .deflate_compress(big_hello.as_bytes(), &mut compressed) - .unwrap(); - compressed.resize(size, 0); - assert_eq!( - http_code( - ureq::post(&format!("{}transfer", ctx.gateway_url)) - .header("Content-Encoding", "deflate") - .send(&compressed) - ), - 400 - ); - } -} - -/// Check btc-wire and wire-gateway correctly stop when a lifetime limit is configured -pub fn auth(ctx: TestCtx) { - ctx.step("Setup"); - let ctx = BtcCtx::setup(&ctx, "taler_btc_auth.conf", false); - - ctx.step("Authentication"); - - // No auth - assert_eq!( - http_code(ureq::get(&format!("{}history/outgoing", ctx.gateway_url)).call()), - 401 - ); - - // Auth - cmd_redirect_ok( - "taler-exchange-wire-gateway-client", - &[ - "--config", - ctx.conf.to_str().unwrap(), - "-s", - "exchange-accountcredentials-admin", - "-C", - Payto::new(BtcAccount(ctx.client_addr.clone())) - .as_payto() - .raw(), - "-a", - &format!("{}:0.00042", ctx.taler_conf.currency.to_str()), - ], - ctx.log("client"), - "", - ); -} diff --git a/instrumentation/src/main.rs b/instrumentation/src/main.rs @@ -23,7 +23,6 @@ use std::{ use clap::Parser; use color_backtrace::termcolor::NoColor; -use common::{config::TalerConfig, currency::Currency}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use owo_colors::OwoColorize; use thread_local_panic_hook::set_hook; @@ -33,7 +32,6 @@ use crate::utils::{TestCtx, try_cmd_redirect}; mod btc; mod eth; -mod gateway; mod utils; /// Depolymerizer instrumentation test @@ -60,20 +58,19 @@ enum Cmd { } pub fn main() { - common::log::init(); color_backtrace::install(); let args = Args::parse(); match args.cmd { Cmd::Online { config } => { - let taler_config = TalerConfig::load(config.as_deref()); + /*let taler_config = TalerConfig::load(config.as_deref()); let base_url = format!("http://localhost:{}/", taler_config.port()); match taler_config.currency { Currency::BTC(_) => btc::online_test(config.as_deref(), &base_url), Currency::ETH(_) => eth::online_test(config.as_deref(), &base_url), } - println!("Instrumentation test successful"); + println!("Instrumentation test successful");*/ } Cmd::Offline { filters } => { std::fs::remove_dir_all("log").ok(); @@ -83,19 +80,12 @@ pub fn main() { let p = ProgressBar::new_spinner(); p.set_style(ProgressStyle::with_template("building {msg} {elapsed:.dim}").unwrap()); p.enable_steady_tick(Duration::from_millis(1000)); - build_bin(&p, "wire-gateway", Some("test"), "wire-gateway"); - for name in ["btc-wire", "eth-wire"] { + for name in ["depolymerizer-bitcoin", "depolymerizer-ethereum"] { build_bin(&p, name, None, name); build_bin(&p, name, Some("fail"), &format!("{name}-fail")); } p.finish_and_clear(); - // Generate password - let pwd: String = (0..30).map(|_| fastrand::alphanumeric()).collect(); - unsafe { - std::env::set_var("PASSWORD", pwd); - } - // Run tests let m = MultiProgress::new(); let start_style = @@ -210,8 +200,6 @@ pub fn build_bin(p: &ProgressBar, name: &str, features: Option<&str>, bin_name: } pub const TESTS: &[(fn(TestCtx), &str)] = &[ - (gateway::api, "gateway_api"), - (gateway::auth, "gateway_auth"), (btc::wire, "btc_wire"), (btc::lifetime, "btc_lifetime"), (btc::reconnect, "btc_reconnect"), diff --git a/instrumentation/src/utils.rs b/instrumentation/src/utils.rs @@ -15,7 +15,7 @@ */ use std::{ - fmt::Display, + fmt::{Debug, Display}, io::Write as _, net::{Ipv4Addr, SocketAddrV4, TcpListener, TcpStream}, ops::{Deref, DerefMut}, @@ -28,7 +28,6 @@ use std::{ }; use common::{ - config::TalerConfig, taler_common::{ api_common::{EddsaPublicKey, ShortHashCode}, api_wire::{IncomingBankTransaction, IncomingHistory, OutgoingHistory, TransferRequest}, @@ -37,18 +36,19 @@ use common::{ url::Url, }; use indicatif::ProgressBar; +use ini::Ini; use tempfile::TempDir; use ureq::http::Response; pub fn print_now(disp: impl Display) { - print!("{}", disp); + print!("{disp}"); std::io::stdout().flush().unwrap(); } #[must_use] pub fn check_incoming(base_url: &str, txs: &[(EddsaPublicKey, Amount)]) -> bool { - let mut res = ureq::get(&format!("{}history/incoming", base_url)) - .query("delta", format!("-{}", txs.len())) + let mut res = ureq::get(&format!("{base_url}history/incoming")) + .query("delta", format!("-{}", 100)) .call() .unwrap(); if txs.is_empty() { @@ -95,7 +95,7 @@ pub fn gateway_error(path: &str, error: u16) { #[must_use] pub fn check_gateway_down(base_url: &str) -> bool { matches!( - ureq::get(&format!("{}history/incoming", base_url)) + ureq::get(&format!("{base_url}history/incoming")) .query("delta", "-5") .call(), Err(ureq::Error::StatusCode(504 | 502)) @@ -104,21 +104,15 @@ pub fn check_gateway_down(base_url: &str) -> bool { #[must_use] pub fn check_gateway_up(base_url: &str) -> bool { - ureq::get(&format!("{}config", base_url)).call().is_ok() + ureq::get(&format!("{base_url}config")).call().is_ok() } -pub fn transfer( - base_url: &str, - wtid: &[u8; 32], - url: &Url, - credit_account: PaytoURI, - amount: &Amount, -) { - ureq::post(&format!("{}transfer", base_url)) +pub fn transfer(base_url: &str, wtid: &[u8; 32], credit_account: PaytoURI, amount: &Amount) { + ureq::post(&format!("{base_url}transfer")) .send_json(TransferRequest { request_uid: Base32::rand(), amount: amount.clone(), - exchange_base_url: url.clone(), + exchange_base_url: Url::parse("https://exchange.test/").unwrap(), wtid: Base32::from(*wtid), credit_account, }) @@ -126,8 +120,8 @@ pub fn transfer( } #[must_use] -pub fn check_outgoing(base_url: &str, url: &Url, txs: &[(ShortHashCode, Amount)]) -> bool { - let mut res = ureq::get(&format!("{}history/outgoing", base_url)) +pub fn check_outgoing(base_url: &str, txs: &[(ShortHashCode, Amount)]) -> bool { + let mut res = ureq::get(format!("{base_url}history/outgoing")) .query("delta", format!("-{}", txs.len())) .call() .unwrap(); @@ -144,7 +138,7 @@ pub fn check_outgoing(base_url: &str, url: &Url, txs: &[(ShortHashCode, Amount)] history .outgoing_transactions .iter() - .any(|h| h.wtid == *wtid && &h.exchange_base_url == url && &h.amount == amount) + .any(|h| h.wtid == *wtid && &h.amount == amount) }) } } @@ -199,7 +193,7 @@ pub fn cmd_redirect(cmd: &str, args: &[&str], path: impl AsRef<Path>) -> ChildGu pub fn cmd_ok(mut child: ChildGuard, name: &str) { let result = child.0.wait().unwrap(); if !result.success() { - panic!("cmd {} failed", name); + panic!("cmd {name} failed"); } } @@ -209,11 +203,11 @@ pub fn cmd_redirect_ok(cmd: &str, args: &[&str], path: impl AsRef<Path>, name: & } #[track_caller] -pub fn retry_opt<T>(mut lambda: impl FnMut() -> Option<T>) -> T { +pub fn retry_opt<T, E: Debug>(mut lambda: impl FnMut() -> Result<T, E>) -> T { let start = Instant::now(); loop { let result = lambda(); - if result.is_none() && start.elapsed() < Duration::from_secs(60) { + if result.is_err() && start.elapsed() < Duration::from_secs(30) { sleep(Duration::from_millis(500)); } else { return result.unwrap(); @@ -222,8 +216,8 @@ pub fn retry_opt<T>(mut lambda: impl FnMut() -> Option<T>) -> T { } #[track_caller] -pub fn retry(mut lambda: impl FnMut() -> bool) { - retry_opt(|| lambda().then_some(())) +pub fn retry(mut lambda: impl FnMut() -> bool, msg: &str) { + retry_opt(|| lambda().then_some(()).ok_or(msg)) } #[derive(Clone)] @@ -273,7 +267,6 @@ pub struct TalerCtx { pub wire_dir: PathBuf, pub wire2_dir: PathBuf, pub conf: PathBuf, - pub taler_conf: TalerConfig, ctx: TestCtx, pub wire_bin_path: String, stressed: bool, @@ -299,45 +292,30 @@ impl TalerCtx { // Find unused port let gateway_port = unused_port(); + let gateway_url = format!("http://localhost:{gateway_port}/taler-wire-gateway/"); // Generate taler config from base + let wire_name = wire_name.into(); let config = PathBuf::from_str("instrumentation/conf") .unwrap() .join(config); - let mut config = ini::Ini::load_from_file(config).unwrap(); - let section = config - .sections() - .find(|it| { - it.map(|it| it.starts_with("depolymerizer-")) - .unwrap_or(false) - }) - .unwrap_or_default() - .map(|it| it.to_string()); - config - .with_section(Some("exchange-accountcredentials-admin")) - .set( - "WIRE_GATEWAY_URL", - format!("http://localhost:{gateway_port}/"), - ); - config - .with_section(section) - .set("CONF_PATH", wire_dir.to_string_lossy()) - .set("IPC_PATH", wire_dir.to_string_lossy()) - .set("DB_URL", ctx.db.postgres_uri(&ctx.name)) + let mut cfg = ini::Ini::load_from_file(config).unwrap(); + cfg.with_section(Some("exchange-accountcredentials-admin")) + .set("WIRE_GATEWAY_URL", &gateway_url); + cfg.with_section(Some(format!("{wire_name}db-postgres"))) + .set("CONFIG", ctx.db.postgres_uri(&ctx.name)); + cfg.with_section(Some(format!("{wire_name}-httpd"))) .set("PORT", gateway_port.to_string()); - config.write_to_file(&conf).unwrap(); - let taler_conf = TalerConfig::load(Some(&conf)); + cfg.write_to_file(&conf).unwrap(); - let wire_name = wire_name.into(); Self { dir, ctx: ctx.clone(), - gateway_url: format!("http://localhost:{}/", taler_conf.port()), + gateway_url, wire_dir, wire2_dir, conf, - taler_conf, wire_bin_path: if stressed { format!("log/bin/{wire_name}-fail") } else { @@ -351,43 +329,61 @@ impl TalerCtx { } } - pub fn init_db(&self) { + pub fn dbinit(&self) { self.db.create_db(&self.ctx.name); // Init db cmd_redirect_ok( &self.wire_bin_path, - &["-c", self.conf.to_string_lossy().as_ref(), "initdb"], + &["-c", self.conf.to_string_lossy().as_ref(), "dbinit"], + self.log("cmd"), + "wire dbinit", + ); + } + + pub fn reset_db(&self) { + // Reset db + cmd_redirect_ok( + &self.wire_bin_path, + &["-c", self.conf.to_string_lossy().as_ref(), "dbinit", "-r"], + self.log("cmd"), + "wire dbinit reset", + ); + } + + pub fn setup(&self) { + // Init db + cmd_redirect_ok( + &self.wire_bin_path, + &["-c", self.conf.to_string_lossy().as_ref(), "setup"], self.log("cmd"), - "wire initdb", + "wire setup", ); } pub fn run(&mut self) { // Run gateway self.gateway = Some(cmd_redirect( - "log/bin/wire-gateway", - &["-c", self.conf.to_string_lossy().as_ref()], + &self.wire_bin_path, + &["-c", self.conf.to_string_lossy().as_ref(), "serve"], self.log("gateway"), )); // Start wires self.wire = Some(cmd_redirect( &self.wire_bin_path, - &["-c", self.conf.to_string_lossy().as_ref()], - self.log("wire"), + &["-c", self.conf.to_string_lossy().as_ref(), "worker"], + self.log("worker"), )); self.wire2 = self.stressed.then(|| { cmd_redirect( &self.wire_bin_path, - &["-c", self.conf.to_string_lossy().as_ref()], - self.log("wire1"), + &["-c", self.conf.to_string_lossy().as_ref(), "worker"], + self.log("worker+"), ) }); // Wait for gateway to be up - retry(|| { - TcpStream::connect(SocketAddrV4::new(Ipv4Addr::LOCALHOST, self.gateway_port)).is_ok() - }); + retry_opt(|| TcpStream::connect(SocketAddrV4::new(Ipv4Addr::LOCALHOST, self.gateway_port))); } /* ----- Process ----- */ @@ -411,19 +407,22 @@ impl TalerCtx { /* ----- Wire Gateway -----*/ pub fn expect_credits(&self, txs: &[(EddsaPublicKey, Amount)]) { - retry(|| check_incoming(&self.gateway_url, txs)) + retry(|| check_incoming(&self.gateway_url, txs), "check_incoming") } - pub fn expect_debits(&self, base_url: &Url, txs: &[(ShortHashCode, Amount)]) { - retry(|| check_outgoing(&self.gateway_url, base_url, txs)) + pub fn expect_debits(&self, txs: &[(ShortHashCode, Amount)]) { + retry(|| check_outgoing(&self.gateway_url, txs), "check_outgoing") } pub fn expect_gateway_up(&self) { - retry(|| check_gateway_up(&self.gateway_url)); + retry(|| check_gateway_up(&self.gateway_url), "check_gateway_up"); } pub fn expect_gateway_down(&self) { - retry(|| check_gateway_down(&self.gateway_url)); + retry( + || check_gateway_down(&self.gateway_url), + "check_gateway_down", + ); } } @@ -488,7 +487,7 @@ impl TestDb { ); let tmp = Self { dir, _db: db }; // Wait for postgres to start - retry(|| tmp.execute_sql("SELECT true")); + retry(|| tmp.execute_sql("SELECT true"), "test db"); tmp } @@ -527,20 +526,20 @@ impl TestDb { pub fn create_db(&self, name: &str) { self.execute_sql(&format!( " - DROP DATABASE IF EXISTS {name}; - CREATE DATABASE {name}; - " + DROP DATABASE IF EXISTS {name}; + CREATE DATABASE {name}; + " )); } pub fn stop_db(&self, name: &str) { self.execute_sql(&format!( " - UPDATE pg_database SET datallowconn=false WHERE datname='{name}'; - SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname='{name}' AND pid <> pg_backend_pid(); - " + UPDATE pg_database SET datallowconn=false WHERE datname='{name}'; + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname='{name}' AND pid <> pg_backend_pid(); + " )); } pub fn resume_db(&self, name: &str) { @@ -549,3 +548,9 @@ impl TestDb { )); } } + +pub fn patch_config(from: impl AsRef<Path>, to: impl AsRef<Path>, patch: impl FnOnce(&mut Ini)) { + let mut cfg = ini::Ini::load_from_file(from).unwrap(); + patch(&mut cfg); + cfg.write_to_file(to).unwrap(); +} diff --git a/makefile b/makefile @@ -1,16 +1,56 @@ -install: - cargo install --path btc-wire --bin btc-wire - cargo install --path eth-wire --bin eth-wire - cargo install --path wire-gateway +# This Makefile has been placed under the public domain +-include build-system/config.mk -segwit_demo: - cargo run --release --bin segwit-demo +export GIT_HASH=$(shell git rev-parse --short HEAD) +# Absolute DESTDIR or empty string if DESTDIR unset/empty +abs_destdir=$(abspath $(DESTDIR)) + +share_dir=$(abs_destdir)$(prefix)/share +man_dir=$(share_dir)/man +bin_dir=$(abs_destdir)$(prefix)/bin +lib_dir=$(abs_destdir)$(prefix)/lib + +all: build + +.PHONY: build +build: + cargo build --release + +.PHONY: install-nobuild +install-nobuild: + # Bitcoin + install -m 644 -D -t $(share_dir)/depolymerizer-bitcoin/config.d depolymerizer-bitcoin/depolymerizer-bitcoin.conf + install -m 644 -D -t $(share_dir)/depolymerizer-bitcoin/sql database-versioning/versioning.sql + install -m 644 -D -t $(share_dir)/depolymerizer-bitcoin/sql database-versioning/depolymerizer-bitcoin*.sql + install -D -t $(bin_dir) contrib/depolymerizer-bitcoin-dbconfig + install -D -t $(bin_dir) target/release/depolymerizer-bitcoin + # Ethereum + install -m 644 -D -t $(share_dir)/depolymerizer-ethereum/config.d depolymerizer-ethereum/depolymerizer-ethereum.conf + install -m 644 -D -t $(share_dir)/depolymerizer-ethereum/sql database-versioning/versioning.sql + install -m 644 -D -t $(share_dir)/depolymerizer-ethereum/sql database-versioning/depolymerizer-ethereum*.sql + install -D -t $(bin_dir) contrib/depolymerizer-ethereum-dbconfig + install -D -t $(bin_dir) target/release/depolymerizer-ethereum + +.PHONY: install +install: build install-nobuild + +.PHONY: check +check: install-nobuild + cargo test + +.PHONY: test test: - RUST_BACKTRACE=full cargo run --profile dev --bin instrumentation -- offline + RUST_BACKTRACE=true cargo run --profile dev --bin instrumentation -- offline + +.PHONY: doc +doc: + cargo doc -check: - cargo check +.PHONY: deb +deb: + cargo deb -v -p taler-magnet-bank --deb-version=$(shell ./contrib/ci/version.sh) -msrv: - cargo msrv --min 1.70.0 -\ No newline at end of file +.PHONY: ci +ci: + contrib/ci/run-all-jobs.sh +\ No newline at end of file diff --git a/uri-pack/Cargo.toml b/uri-pack/Cargo.toml @@ -18,7 +18,7 @@ serde_json.workspace = true # Url parser url.workspace = true # statistics-driven micro-benchmarks -criterion = "0.5.1" +criterion.workspace = true # Fast insecure random fastrand = "2.0.1" # Fuzzing test diff --git a/wire-gateway/Cargo.toml b/wire-gateway/Cargo.toml @@ -1,30 +0,0 @@ -[package] -name = "wire-gateway" -version = "0.1.0" -edition.workspace = true -authors.workspace = true -homepage.workspace = true -repository.workspace = true -license-file.workspace = true - -[dependencies] -axum.workspace = true -taler-api.workspace = true -taler-common.workspace = true -# Async runtime -tokio = { workspace = true, features = ["net", "macros", "rt-multi-thread"] } -# Async postgres client -sqlx.workspace = true -# Common lib -common = { path = "../common" } -# Bitcoin types -bitcoin.workspace = true -# Ethereum types -ethereum-types.workspace = true -# Cli args parser -clap.workspace = true -time = "0.3" - -[features] -# Enable test admin endpoint -test = [] diff --git a/wire-gateway/README.md b/wire-gateway/README.md @@ -1,41 +0,0 @@ -# wire-gateway - -Rust server for -[Taler Wire Gateway HTTP API](https://docs.taler.net/core/api-wire.html) - -## Database schema - -The server is wire implementation agnostic, it only requires a Postgres database -with the following schema: - -```sql --- Key value state -CREATE TABLE state ( - name TEXT PRIMARY KEY, - value BYTEA NOT NULL -); - --- Incoming transactions -CREATE TABLE tx_in ( - id SERIAL PRIMARY KEY, - _date TIMESTAMP NOT NULL DEFAULT now(), - amount TEXT NOT NULL, - reserve_pub BYTEA NOT NULL UNIQUE, - debit_acc TEXT NOT NULL, - credit_acc TEXT NOT NULL -); - --- Outgoing transactions -CREATE TABLE tx_out ( - id SERIAL PRIMARY KEY, - _date TIMESTAMP NOT NULL DEFAULT now(), - amount TEXT NOT NULL, - wtid BYTEA NOT NULL UNIQUE, - debit_acc TEXT NOT NULL, - credit_acc TEXT NOT NULL, - exchange_url TEXT NOT NULL, - request_uid BYTEA UNIQUE -); -``` - -Implementation specific schema can be found in the [db](../db) directory. diff --git a/wire-gateway/src/main.rs b/wire-gateway/src/main.rs @@ -1,400 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -use axum::{ - extract::{Request, State}, - http::StatusCode, - middleware::{self, Next}, - response::{IntoResponse, Response}, -}; -use bitcoin::address::NetworkUnchecked; -use clap::Parser; -use common::{ - config::WireGatewayCfg, - currency::Currency, - log::{ - OrFail, - log::{error, info}, - }, - payto::{BtcAccount, EthAccount}, -}; -use sqlx::{PgPool, QueryBuilder, postgres::PgListener}; -use sqlx::{Row, postgres::PgRow}; -use std::{ - path::PathBuf, - str::FromStr as _, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, -}; -use taler_api::{ - api::{ - TalerApi, TalerRouter, - wire::{self, WireGateway}, - }, - db::{BindHelper, TypeHelper, page}, - error::{ApiResult, failure, failure_status}, -}; -use taler_common::{ - api_params::{History, Page}, - api_wire::{ - AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddKycauthResponse, - IncomingBankTransaction, IncomingHistory, OutgoingBankTransaction, OutgoingHistory, - TransferList, TransferRequest, TransferResponse, TransferState, TransferStatus, - }, - error_code::ErrorCode, - types::{ - payto::{Payto, PaytoURI}, - timestamp::Timestamp, - }, -}; -use tokio::time::sleep; - -pub enum Address<'a> { - BTC(&'a str), - ETH([u8; 20]), -} - -struct ServerState { - pool: PgPool, - payto: PaytoURI, - currency: Currency, - status: AtomicBool, -} - -impl ServerState { - pub fn sql_payto(&self, row: &PgRow, idx: usize) -> sqlx::Result<PaytoURI> { - Ok(match self.currency { - Currency::ETH(_) => { - let it: [u8; 20] = row.try_get(idx)?; - let addr = ethereum_types::Address::from_slice(&it); - Payto::new(EthAccount(addr)) - .as_payto() - .as_full_payto("Ethereum User") - } - Currency::BTC(_) => { - let addr = row - .try_get_parse::<_, _, bitcoin::Address<NetworkUnchecked>>(idx)? - .assume_checked(); - Payto::new(BtcAccount(addr)) - .as_payto() - .as_full_payto("Bitcoin User") - } - }) - } - - pub fn payto_addr<'a>(&self, payto: &'a PaytoURI) -> Result<Address<'a>, String> { - let url = payto.as_ref(); - Ok(match self.currency { - Currency::ETH(_) => { - if url.domain() != Some("ethereum") { - return Err(format!( - "Expected domain 'ethereum' got '{}'", - url.domain().unwrap_or_default() - )); - } - let str = url.path().trim_start_matches('/'); - Address::ETH( - ethereum_types::Address::from_str(str) - .map_err(|e| e.to_string())? - .to_fixed_bytes(), - ) - } - Currency::BTC(_) => { - if url.domain() != Some("bitcoin") { - return Err(format!( - "Expected domain 'bitcoin' got '{}'", - url.domain().unwrap_or_default() - )); - } - let str = url.path().trim_start_matches('/'); - bitcoin::Address::from_str(str).map_err(|e| e.to_string())?; - Address::BTC(str) - } - }) - } -} - -impl TalerApi for ServerState { - fn currency(&self) -> &str { - self.currency.to_str() - } - - fn implementation(&self) -> Option<&str> { - None - } -} - -impl WireGateway for ServerState { - async fn transfer(&self, req: TransferRequest) -> ApiResult<TransferResponse> { - if !check_payto(&req.credit_account, self.currency) { - return Err(failure(ErrorCode::GENERIC_PAYTO_URI_MALFORMED, "bad payto")); - } - - // Handle idempotence, check previous transaction with the same request_uid - let row = sqlx::query("SELECT (amount).val, (amount).frac, exchange_url, wtid, credit_acc, id, created FROM tx_out WHERE request_uid = $1").bind(req.request_uid.as_slice()) - .fetch_optional(&self.pool) - .await?; - if let Some(r) = row { - // TODO store names? - let prev = TransferRequest { - request_uid: req.request_uid.clone(), - amount: r.try_get_amount_i(0, self.currency())?, - exchange_base_url: r.try_get_url(2)?, - wtid: r.try_get_base32(3)?, - credit_account: self.sql_payto(&r, 4)?, - }; - if prev == req { - // Idempotence - return Ok(TransferResponse { - row_id: r.try_get_safeu64(5)?, - timestamp: r.try_get_timestamp(6)?, - }); - } else { - return Err(failure( - ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED, - format!("Request UID {} already used", req.request_uid), - )); - } - } - - let timestamp = Timestamp::now(); - let q = sqlx::query( - "INSERT INTO tx_out (created, amount, wtid, debit_acc, credit_acc, exchange_url, request_uid) VALUES ($1, ($2, $3)::taler_amount, $4, $5, $6, $7, $8) RETURNING id" - ) - .bind_timestamp(&Timestamp::now()) - .bind_amount(&req.amount) - .bind(req.wtid.as_slice()); - let q = match self.payto_addr(&self.payto).unwrap() { - Address::BTC(a) => q.bind(a), - Address::ETH(a) => q.bind(a), - }; - let q = match self.payto_addr(&req.credit_account).unwrap() { - Address::BTC(a) => q.bind(a), - Address::ETH(a) => q.bind(a), - }; - let r = q - .bind(req.exchange_base_url.as_str()) - .bind(req.request_uid.as_slice()) - .fetch_one(&self.pool) - .await?; - sqlx::query("NOTIFY new_tx").execute(&self.pool).await?; - - Ok(TransferResponse { - timestamp, - row_id: r.try_get_safeu64(0)?, - }) - } - - async fn transfer_page( - &self, - _page: Page, - _status: Option<TransferState>, - ) -> ApiResult<TransferList> { - unimplemented!("depolymerization does not supports transfer details API") - } - - async fn transfer_by_id(&self, _id: u64) -> ApiResult<Option<TransferStatus>> { - unimplemented!("depolymerization does not supports transfer details API") - } - - async fn outgoing_history(&self, params: History) -> ApiResult<OutgoingHistory> { - let outgoing_transactions = page(&self.pool, "id", &params.page, || QueryBuilder::new( - "SELECT id, created, (amount).val, (amount).frac, wtid, credit_acc, exchange_url FROM tx_out WHERE" - ), |r| { - Ok(OutgoingBankTransaction { - row_id: r.try_get_safeu64(0)?, - date: r.try_get_timestamp(1)?, - amount: r.try_get_amount_i(2, self.currency())?, - wtid: r.try_get_base32(4)?, - credit_account: self.sql_payto(&r, 5)?, - exchange_base_url: r.try_get_url(6)?, - }) - }).await?; - Ok(OutgoingHistory { - debit_account: self.payto.clone(), - outgoing_transactions, - }) - } - - async fn incoming_history(&self, params: History) -> ApiResult<IncomingHistory> { - let incoming_transactions = page( - &self.pool, - "id", - &params.page, - || { - QueryBuilder::new( - "SELECT id, received, (amount).val, (amount).frac, reserve_pub, debit_acc FROM tx_in WHERE" - ) - }, - |r| { - Ok(IncomingBankTransaction::Reserve { - row_id: r.try_get_safeu64(0)?, - date: r.try_get_timestamp(1)?, - amount: r.try_get_amount_i(2, self.currency())?, - reserve_pub: r.try_get_base32(4)?, - debit_account: self.sql_payto(&r, 5)?, - }) - }, - ) - .await?; - Ok(IncomingHistory { - credit_account: self.payto.clone(), - incoming_transactions, - }) - } - - async fn add_incoming_reserve( - &self, - req: AddIncomingRequest, - ) -> ApiResult<AddIncomingResponse> { - let timestamp = Timestamp::now(); - let r = sqlx::query("INSERT INTO tx_in (received, amount, reserve_pub, debit_acc, credit_acc) VALUES ($1, ($2, $3)::taler_amount, $4, $5, $6) RETURNING id") - .bind_timestamp(&Timestamp::now()) - .bind_amount(&req.amount) - .bind(req.reserve_pub.as_slice()) - .bind(req.debit_account.raw()) - .bind("payto://bitcoin/bcrt1qgkgxkjj27g3f7s87mcvjjsghay7gh34cx39prj") - .fetch_one(&self.pool).await?; - Ok(AddIncomingResponse { - timestamp, - row_id: r.try_get_safeu64(0)?, - }) - } - - async fn add_incoming_kyc(&self, _req: AddKycauthRequest) -> ApiResult<AddKycauthResponse> { - unimplemented!("depolymerization does not supports KYC") - } - - fn support_account_check(&self) -> bool { - false - } -} - -async fn status_middleware( - State(state): State<Arc<ServerState>>, - request: Request, - next: Next, -) -> Response { - if !state.status.load(Ordering::Relaxed) { - failure_status( - ErrorCode::GENERIC_INTERNAL_INVARIANT_FAILURE, - "Currency backing is compromised until the transaction reappear", - StatusCode::BAD_GATEWAY, - ) - .into_response() - } else { - next.run(request).await - } -} - -/// Taler wire gateway server for depolymerizer -#[derive(clap::Parser, Debug)] -struct Args { - /// Override default configuration file path - #[clap(global = true, short, long)] - config: Option<PathBuf>, -} - -#[tokio::main] -async fn main() { - common::log::init(); - #[cfg(feature = "test")] - common::log::log::warn!("Running with test admin endpoint unsuitable for production"); - - let args = Args::parse(); - - let config = WireGatewayCfg::parse(args.config.as_deref()).or_fail(|e| e.to_string()); - - // Parse postgres url - let pool = PgPool::connect_with(config.db).await.unwrap(); - - let state = Arc::new(ServerState { - pool, - status: AtomicBool::new(true), - payto: config.payto, - currency: config.currency, - }); - - tokio::spawn(status_watcher(state.clone())); - wire::router(state.clone()) - .auth(config.auth) - .layer(middleware::from_fn_with_state( - state.clone(), - status_middleware, - )) - .serve(config.serve, config.http_lifetime) - .await - .unwrap(); - info!("wire-gateway stopped"); -} - -/// Check if a payto is valid the configured currency -fn check_payto(payto: &PaytoURI, currency: Currency) -> bool { - match currency { - Currency::ETH(_) => check_pay_to_eth(payto), - Currency::BTC(_) => check_pay_to_btc(payto), - } -} - -/// Check if an url is a valid bitcoin payto url -fn check_pay_to_btc(payto: &PaytoURI) -> bool { - let url = payto.as_ref(); - url.domain() == Some("bitcoin") - && url.username() == "" - && url.password().is_none() - && url.query().is_none() - && url.fragment().is_none() - && bitcoin::Address::from_str(url.path().trim_start_matches('/')).is_ok() -} - -/// Check if an url is a valid ethereum payto url -fn check_pay_to_eth(payto: &PaytoURI) -> bool { - let url = payto.as_ref(); - url.domain() == Some("ethereum") - && url.username() == "" - && url.password().is_none() - && url.query().is_none() - && url.fragment().is_none() - && ethereum_types::H160::from_str(url.path().trim_start_matches('/')).is_ok() -} - -/// Listen to backend status change -async fn status_watcher(state: Arc<ServerState>) { - async fn inner(state: &ServerState) -> Result<(), sqlx::error::Error> { - let mut listener = PgListener::connect_with(&state.pool).await?; - listener.listen("status").await?; - loop { - // Sync state - let row = sqlx::query("SELECT value FROM state WHERE name = 'status'") - .fetch_one(&state.pool) - .await?; - let status: &[u8] = row.try_get(0)?; - assert!(status.len() == 1 && status[0] < 2); - state.status.store(status[0] == 1, Ordering::SeqCst); - // Wait for next notification - listener.recv().await?; - } - } - - loop { - if let Err(err) = inner(&state).await { - error!("status-watcher: {}", err); - sleep(Duration::from_secs(5)).await; - } - } -}