commit 2885b53d08fabeaaf594c55a67a5c3f9596088b1
parent e034eeb86398da01d4894dbab50c45ece97bdc58
Author: Antoine A <>
Date: Thu, 19 Jun 2025 14:00:57 +0200
common: rewrite wires as taler adapter
Diffstat:
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(¤cy)
- .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",
+ ¶ms,
+ || {
+ 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",
+ ¶ms,
+ || 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",
+ ¶ms,
+ || 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",
+ ¶ms,
+ || {
+ 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",
+ ¶ms,
+ || 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",
+ ¶ms,
+ || 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(),
- ð_to_taler(&amount, self.state.currency),
+ Payto::new(EthAccount(self.client_addr))
+ .as_payto()
+ .as_full_payto("Anonymous"),
+ ð_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", ¶ms.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",
- ¶ms.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;
- }
- }
-}