feat(gui): Voluntary donations (#418)

* poc: monero receive pool with multiple redeem addresses for bob with given ratios

* fix: use new monero_receive_pool arg for buy_xmr

* update sweep/sweep_multi to return TxReceipt instead of String containing txid

* fix test (generate 1 block before checking balance after transfer)

* add move distribute function to rust, add property tests

* use rust distribute

* update sqlx cache/tempdb

* sqlx fix

* feat: update ui to display the monero address pool

* fix: remove unused functions, set dispatcher for tracing in wallet threads, use new subtract_fee wallet2 functionality

* Add patch system

* add wallet2_api_allow_subtract_from_fee patch

* apply git patches

* split monero-sys patches into chunks

* refactor

* .sqlx needs to be commited, revert unbound issue

* display pool on XmrRedeemInMempoolPage.tsx page, commit .sqlx folder

* fmt

* refactor

* assert MoneroAddressPool is on correct network, differntiate between stagenet and mainnet donaiton address

* looks ok

* re-add retry logic, database errors, ...

* add test

* add tests

* fmt comments, changelog

---------

Co-authored-by: Binarybaron <binarybaron@protonmail.com>
This commit is contained in:
Raphael 2025-06-25 16:37:47 +02:00 committed by GitHub
parent cd4aa5201a
commit 11b891f530
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 2091 additions and 380 deletions

View file

@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
- GUI: Users can donate a small percentage of their swap to the projects donation address. Donations will be used to fund development. This is completely optional and **disabled** by default. Monero is used exclusively for donations, ensuring full anonymity for users. Donations are only ever send for successful swaps (not refunded ones). We clearly and transparently state where how much Monero is going before the user approves a swap.
## [2.3.0-beta.2] - 2025-06-24 ## [2.3.0-beta.2] - 2025-06-24
- ASB + GUI + CLI: We now cache fee estimates for the Bitcoin wallet for up to 2 minutes. This improves the speed of fee estimation and reduces the number of requests to the Electrum servers. - ASB + GUI + CLI: We now cache fee estimates for the Bitcoin wallet for up to 2 minutes. This improves the speed of fee estimation and reduces the number of requests to the Electrum servers.

246
Cargo.lock generated
View file

@ -400,7 +400,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"synstructure 0.13.2", "synstructure 0.13.2",
] ]
@ -423,7 +423,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -565,7 +565,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -605,7 +605,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -622,7 +622,7 @@ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -811,7 +811,7 @@ checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -900,9 +900,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "base64ct" name = "base64ct"
version = "1.6.0" version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]] [[package]]
name = "bdk" name = "bdk"
@ -1204,7 +1204,7 @@ checksum = "e0b121a9fe0df916e362fb3271088d071159cdf11db0e4182d02152850756eff"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -1296,7 +1296,7 @@ dependencies = [
"proc-macro-crate 3.3.0", "proc-macro-crate 3.3.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -1685,7 +1685,7 @@ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -2084,7 +2084,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [ dependencies = [
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -2094,7 +2094,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
dependencies = [ dependencies = [
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -2143,7 +2143,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -2185,7 +2185,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"scratch", "scratch",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -2198,7 +2198,7 @@ dependencies = [
"codespan-reporting", "codespan-reporting",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -2216,7 +2216,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustversion", "rustversion",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -2288,7 +2288,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim 0.11.1", "strsim 0.11.1",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -2321,7 +2321,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [ dependencies = [
"darling_core 0.20.11", "darling_core 0.20.11",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -2347,7 +2347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976"
dependencies = [ dependencies = [
"data-encoding", "data-encoding",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -2430,7 +2430,7 @@ dependencies = [
"quote", "quote",
"sha3", "sha3",
"strum 0.27.1", "strum 0.27.1",
"syn 2.0.103", "syn 2.0.104",
"void", "void",
] ]
@ -2442,7 +2442,7 @@ checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -2463,7 +2463,7 @@ dependencies = [
"darling 0.20.11", "darling 0.20.11",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -2494,7 +2494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [ dependencies = [
"derive_builder_core", "derive_builder_core",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -2517,7 +2517,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustc_version", "rustc_version",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -2547,7 +2547,7 @@ dependencies = [
"convert_case 0.6.0", "convert_case 0.6.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"unicode-xid", "unicode-xid",
] ]
@ -2560,7 +2560,7 @@ dependencies = [
"convert_case 0.7.1", "convert_case 0.7.1",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"unicode-xid", "unicode-xid",
] ]
@ -2577,6 +2577,15 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "diffy"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b545b8c50194bdd008283985ab0b31dba153cfd5b3066a92770634fbc0d7d291"
dependencies = [
"nu-ansi-term 0.50.1",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.9.0" version = "0.9.0"
@ -2706,7 +2715,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -2729,7 +2738,7 @@ checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -2994,7 +3003,7 @@ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -3007,7 +3016,7 @@ dependencies = [
"num-traits", "num-traits",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -3028,7 +3037,17 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
]
[[package]]
name = "env_logger"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3"
dependencies = [
"log",
"regex",
] ]
[[package]] [[package]]
@ -3258,7 +3277,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -3446,7 +3465,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -3800,7 +3819,7 @@ dependencies = [
"proc-macro-error", "proc-macro-error",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -3908,7 +3927,7 @@ dependencies = [
"proc-macro-error", "proc-macro-error",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -4815,9 +4834,9 @@ dependencies = [
[[package]] [[package]]
name = "jpeg-decoder" name = "jpeg-decoder"
version = "0.3.1" version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
@ -5462,7 +5481,7 @@ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -5985,8 +6004,11 @@ dependencies = [
"cmake", "cmake",
"cxx", "cxx",
"cxx-build", "cxx-build",
"diffy",
"futures", "futures",
"monero", "monero",
"quickcheck",
"quickcheck_macros",
"tempfile", "tempfile",
"testcontainers", "testcontainers",
"tokio", "tokio",
@ -6280,6 +6302,15 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "nu-ansi-term"
version = "0.50.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399"
dependencies = [
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.6" version = "0.4.6"
@ -6355,23 +6386,24 @@ dependencies = [
[[package]] [[package]]
name = "num_enum" name = "num_enum"
version = "0.7.3" version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a"
dependencies = [ dependencies = [
"num_enum_derive", "num_enum_derive",
"rustversion",
] ]
[[package]] [[package]]
name = "num_enum_derive" name = "num_enum_derive"
version = "0.7.3" version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d"
dependencies = [ dependencies = [
"proc-macro-crate 3.3.0", "proc-macro-crate 3.3.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -6698,7 +6730,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -6978,7 +7010,7 @@ dependencies = [
"pest_meta", "pest_meta",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -7105,7 +7137,7 @@ dependencies = [
"phf_shared 0.11.3", "phf_shared 0.11.3",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -7152,7 +7184,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -7437,7 +7469,7 @@ checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -7551,6 +7583,28 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quickcheck"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6"
dependencies = [
"env_logger",
"log",
"rand 0.8.5",
]
[[package]]
name = "quickcheck_macros"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f71ee38b42f8459a88d3362be6f9b841ad2d5421844f61eb1c59c11bff3ac14a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.8" version = "0.11.8"
@ -7851,7 +7905,7 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -8137,7 +8191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6268b74858287e1a062271b988a0c534bf85bbeb567fe09331bf40ed78113d5" checksum = "f6268b74858287e1a062271b988a0c534bf85bbeb567fe09331bf40ed78113d5"
dependencies = [ dependencies = [
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -8429,7 +8483,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"serde_derive_internals", "serde_derive_internals",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -8733,7 +8787,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -8744,7 +8798,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -8786,7 +8840,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -8860,7 +8914,7 @@ dependencies = [
"darling 0.20.11", "darling 0.20.11",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -8885,7 +8939,7 @@ checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -9325,7 +9379,7 @@ dependencies = [
"quote", "quote",
"sqlx-core", "sqlx-core",
"sqlx-macros-core", "sqlx-macros-core",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -9348,7 +9402,7 @@ dependencies = [
"sqlx-mysql", "sqlx-mysql",
"sqlx-postgres", "sqlx-postgres",
"sqlx-sqlite", "sqlx-sqlite",
"syn 2.0.103", "syn 2.0.104",
"tokio", "tokio",
"url", "url",
] ]
@ -9618,7 +9672,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustversion", "rustversion",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -9631,7 +9685,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustversion", "rustversion",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -9760,9 +9814,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.103" version = "2.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4307e30089d6fd6aff212f2da3a1f9e32f3223b1f010fb09b7c95f90f3ca1e8" checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -9798,7 +9852,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -9902,7 +9956,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -10019,7 +10073,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"syn 2.0.103", "syn 2.0.104",
"tauri-utils", "tauri-utils",
"thiserror 2.0.12", "thiserror 2.0.12",
"time 0.3.41", "time 0.3.41",
@ -10037,7 +10091,7 @@ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"tauri-codegen", "tauri-codegen",
"tauri-utils", "tauri-utils",
] ]
@ -10242,7 +10296,7 @@ dependencies = [
"tokio", "tokio",
"url", "url",
"windows-sys 0.60.2", "windows-sys 0.60.2",
"zip 4.1.0", "zip 4.2.0",
] ]
[[package]] [[package]]
@ -10435,7 +10489,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -10446,7 +10500,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -10574,7 +10628,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -11844,7 +11898,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -11885,7 +11939,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
dependencies = [ dependencies = [
"matchers", "matchers",
"nu-ansi-term", "nu-ansi-term 0.46.0",
"once_cell", "once_cell",
"regex", "regex",
"serde", "serde",
@ -11918,7 +11972,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568"
dependencies = [ dependencies = [
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -12021,7 +12075,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a615d6c2764852a2e88a4f16e9ce1ea49bb776b5872956309e170d63a042a34f" checksum = "a615d6c2764852a2e88a4f16e9ce1ea49bb776b5872956309e170d63a042a34f"
dependencies = [ dependencies = [
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -12221,7 +12275,7 @@ dependencies = [
"tauri-plugin-updater", "tauri-plugin-updater",
"tracing", "tracing",
"uuid", "uuid",
"zip 4.1.0", "zip 4.2.0",
] ]
[[package]] [[package]]
@ -12353,7 +12407,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -12474,7 +12528,7 @@ dependencies = [
"log", "log",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -12509,7 +12563,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"wasm-bindgen-backend", "wasm-bindgen-backend",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -12760,7 +12814,7 @@ checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -12938,7 +12992,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -12949,7 +13003,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -12960,7 +13014,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -12971,7 +13025,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -12992,9 +13046,9 @@ dependencies = [
[[package]] [[package]]
name = "windows-registry" name = "windows-registry"
version = "0.5.2" version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3bab093bdd303a1240bb99b8aba8ea8a69ee19d34c9e2ef9594e708a4878820" checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [ dependencies = [
"windows-link", "windows-link",
"windows-result 0.3.4", "windows-result 0.3.4",
@ -13643,7 +13697,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"synstructure 0.13.2", "synstructure 0.13.2",
] ]
@ -13690,7 +13744,7 @@ dependencies = [
"proc-macro-crate 3.3.0", "proc-macro-crate 3.3.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"zbus_names", "zbus_names",
"zvariant", "zvariant",
"zvariant_utils", "zvariant_utils",
@ -13725,7 +13779,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -13745,7 +13799,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"synstructure 0.13.2", "synstructure 0.13.2",
] ]
@ -13766,7 +13820,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -13799,7 +13853,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@ -13818,9 +13872,9 @@ dependencies = [
[[package]] [[package]]
name = "zip" name = "zip"
version = "4.1.0" version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af7dcdb4229c0e79c2531a24de7726a0e980417a74fb4d030a35f535665439a0" checksum = "95ab361742de920c5535880f89bbd611ee62002bf11341d16a5f057bb8ba6899"
dependencies = [ dependencies = [
"aes", "aes",
"arbitrary", "arbitrary",
@ -13912,7 +13966,7 @@ dependencies = [
"proc-macro-crate 3.3.0", "proc-macro-crate 3.3.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"zvariant_utils", "zvariant_utils",
] ]
@ -13926,6 +13980,6 @@ dependencies = [
"quote", "quote",
"serde", "serde",
"static_assertions", "static_assertions",
"syn 2.0.103", "syn 2.0.104",
"winnow 0.7.11", "winnow 0.7.11",
] ]

View file

@ -280,20 +280,12 @@ impl<'c> Monero {
Ok(()) Ok(())
} }
/// Funds a specific wallet address with XMR pub async fn generate_block(&self) -> Result<()> {
/// let miner_wallet = self.wallet("miner")?;
/// This function is useful when you want to fund an address that isn't managed by let miner_address = miner_wallet.address().await?.to_string();
/// a wallet in the testcontainer setup, like an external wallet address. self.monerod()
pub async fn fund_address(&self, address: &str, amount: u64) -> Result<()> { .generate_blocks(15, miner_address.clone())
let monerod = &self.monerod; .await?;
// Make sure miner has funds by generating blocks
monerod.generate_blocks(120, address.to_string()).await?;
// Mine more blocks to confirm the transaction
monerod.generate_blocks(10, address.to_string()).await?;
tracing::info!("Successfully funded address with {} piconero", amount);
Ok(()) Ok(())
} }
@ -484,6 +476,11 @@ impl MoneroWallet {
} }
pub async fn transfer(&self, address: &Address, amount_pico: u64) -> Result<TxReceipt> { pub async fn transfer(&self, address: &Address, amount_pico: u64) -> Result<TxReceipt> {
tracing::info!(
"`{}` transferring {}",
self.name,
Amount::from_pico(amount_pico),
);
let amount = Amount::from_pico(amount_pico); let amount = Amount::from_pico(amount_pico);
self.wallet self.wallet
.transfer(address, amount) .transfer(address, amount)
@ -491,6 +488,31 @@ impl MoneroWallet {
.context("Failed to perform transfer") .context("Failed to perform transfer")
} }
pub async fn sweep(&self, address: &Address) -> Result<TxReceipt> {
tracing::info!("`{}` sweeping", self.name);
self.wallet
.sweep(address)
.await
.context("Failed to perform sweep")?
.into_iter()
.next()
.context("No transaction receipts returned from sweep")
}
pub async fn sweep_multi(&self, addresses: &[Address], ratios: &[f64]) -> Result<TxReceipt> {
tracing::info!("`{}` sweeping multi ({:?})", self.name, ratios);
self.balance().await?;
self.wallet
.sweep_multi(addresses, ratios)
.await
.context("Failed to perform sweep")?
.into_iter()
.next()
.context("No transaction receipts returned from sweep")
}
pub async fn blockchain_height(&self) -> Result<u64> { pub async fn blockchain_height(&self) -> Result<u64> {
self.wallet.blockchain_height().await self.wallet.blockchain_height().await
} }

View file

@ -1,3 +1,4 @@
use monero::Amount;
use monero_harness::{Monero, MoneroWalletRpc}; use monero_harness::{Monero, MoneroWalletRpc};
use std::time::Duration; use std::time::Duration;
use testcontainers::clients::Cli; use testcontainers::clients::Cli;
@ -14,18 +15,27 @@ async fn fund_transfer_and_check_tx_key() {
let fund_alice: u64 = 1_000_000_000_000; let fund_alice: u64 = 1_000_000_000_000;
let fund_bob = 0; let fund_bob = 0;
let fund_candice = 0;
let send_to_bob = 5_000_000_000; let send_to_bob = 5_000_000_000;
let tc = Cli::default(); let tc = Cli::default();
let (monero, _monerod_container, _wallet_containers) = let (monero, _monerod_container, _wallet_containers) =
Monero::new(&tc, vec!["alice", "bob"]).await.unwrap(); Monero::new(&tc, vec!["alice", "bob", "candice"])
.await
.unwrap();
let alice_wallet = monero.wallet("alice").unwrap(); let alice_wallet = monero.wallet("alice").unwrap();
let bob_wallet = monero.wallet("bob").unwrap(); let bob_wallet = monero.wallet("bob").unwrap();
let candice_wallet = monero.wallet("candice").unwrap();
monero.init_miner().await.unwrap(); monero.init_miner().await.unwrap();
monero.init_wallet("alice", vec![fund_alice]).await.unwrap(); monero.init_wallet("alice", vec![fund_alice]).await.unwrap();
monero.init_wallet("bob", vec![fund_bob]).await.unwrap(); monero.init_wallet("bob", vec![fund_bob]).await.unwrap();
monero
.init_wallet("candice", vec![fund_candice])
.await
.unwrap();
monero.start_miner().await.unwrap(); monero.start_miner().await.unwrap();
let miner_address = monero.wallet("miner").unwrap().address().await.unwrap();
tracing::info!("Waiting for Alice to catch up"); tracing::info!("Waiting for Alice to catch up");
@ -44,12 +54,62 @@ async fn fund_transfer_and_check_tx_key() {
.await .await
.unwrap(); .unwrap();
monero.generate_block().await.unwrap();
tracing::info!("Waiting for Bob to catch up"); tracing::info!("Waiting for Bob to catch up");
wait_for_wallet_to_catch_up(bob_wallet, send_to_bob).await; wait_for_wallet_to_catch_up(bob_wallet, send_to_bob).await;
tracing::info!("Bob caught up");
let got_bob_balance = bob_wallet.balance().await.unwrap(); let got_bob_balance = bob_wallet.balance().await.unwrap();
assert_eq!(send_to_bob, got_bob_balance, "Funds not transferred to Bob"); assert_eq!(send_to_bob, got_bob_balance, "Funds not transferred to Bob");
bob_wallet
.sweep(&alice_wallet.address().await.unwrap())
.await
.unwrap();
monero.generate_block().await.unwrap();
wait_for_wallet_to_catch_up(bob_wallet, 0).await;
assert_eq!(0, bob_wallet.balance().await.unwrap(), "Bob not swept");
alice_wallet
.sweep_multi(
&[
bob_wallet.address().await.unwrap(),
candice_wallet.address().await.unwrap(),
],
&[99.9, 0.1],
)
.await
.unwrap();
monero.generate_block().await.unwrap();
wait_for_wallet_to_catch_up(alice_wallet, 0).await;
assert_eq!(0, alice_wallet.balance().await.unwrap(), "Alice not swept");
bob_wallet.refresh().await.unwrap();
candice_wallet.refresh().await.unwrap();
let bob_balance = bob_wallet.balance().await.unwrap();
let candice_balance = candice_wallet.balance().await.unwrap();
tracing::info!(
bob_balance = bob_balance,
candice_balance = candice_balance,
"Bob and Candice balances"
);
assert!(0 < bob_balance, "Bob not funded");
assert!(0 < candice_balance, "Candice not funded");
assert!(0 < bob_balance, "Bob not funded");
assert!(0 < candice_balance, "Candice not funded");
} }
async fn wait_for_wallet_to_catch_up(wallet: &MoneroWalletRpc, expected_balance: u64) { async fn wait_for_wallet_to_catch_up(wallet: &MoneroWalletRpc, expected_balance: u64) {
@ -65,4 +125,10 @@ async fn wait_for_wallet_to_catch_up(wallet: &MoneroWalletRpc, expected_balance:
wallet.refresh().await.unwrap(); wallet.refresh().await.unwrap();
sleep(Duration::from_secs(2)).await; sleep(Duration::from_secs(2)).await;
} }
tracing::warn!(
"Wallet {} not caught up to expected balance of {}",
wallet.name(),
Amount::from_pico(expected_balance)
);
} }

View file

@ -5,7 +5,7 @@ edition = "2021"
[dependencies] [dependencies]
anyhow = "1.0.98" anyhow = "1.0.98"
backoff = "0.4.0" backoff = { version = "0.4.0", features = ["futures", "tokio"] }
cxx = "1.0.137" cxx = "1.0.137"
monero = { version = "0.12", features = ["serde_support"] } monero = { version = "0.12", features = ["serde_support"] }
tokio = { version = "1.44.2", features = ["sync", "time", "rt"] } tokio = { version = "1.44.2", features = ["sync", "time", "rt"] }
@ -14,10 +14,13 @@ tracing = "0.1.41"
[build-dependencies] [build-dependencies]
cmake = "0.1.54" cmake = "0.1.54"
cxx-build = "1.0.137" cxx-build = "1.0.137"
diffy = "0.4.2"
[dev-dependencies] [dev-dependencies]
anyhow = "1.0.98" anyhow = "1.0.98"
futures = "0.3.31" futures = "0.3.31"
quickcheck = "1.0"
quickcheck_macros = "1.0"
tempfile = "3.19.1" tempfile = "3.19.1"
testcontainers = "0.15" testcontainers = "0.15"
tokio = { version = "1.44.2", features = ["full"] } tokio = { version = "1.44.2", features = ["full"] }

View file

@ -1,15 +1,52 @@
use cmake::Config; use cmake::Config;
use std::fs;
use std::path::Path;
/// Represents a patch to be applied to the Monero codebase
struct EmbeddedPatch {
name: &'static str,
description: &'static str,
patch_unified: &'static str,
}
/// Macro to create embedded patches with compile-time file inclusion
macro_rules! embedded_patch {
($name:literal, $description:literal, $patch_file:literal) => {
EmbeddedPatch {
name: $name,
description: $description,
patch_unified: include_str!($patch_file),
}
};
}
/// Embedded patches applied at compile time
const EMBEDDED_PATCHES: &[EmbeddedPatch] = &[embedded_patch!(
"wallet2_api_allow_subtract_from_fee",
"Adds subtract_fee_from_outputs parameter to wallet2_api transaction creation methods",
"patches/wallet2_api_allow_subtract_from_fee.patch"
)];
fn main() { fn main() {
let is_github_actions: bool = std::env::var("GITHUB_ACTIONS").is_ok(); let is_github_actions: bool = std::env::var("GITHUB_ACTIONS").is_ok();
let is_docker_build: bool = std::env::var("DOCKER_BUILD").is_ok(); let is_docker_build: bool = std::env::var("DOCKER_BUILD").is_ok();
// Only rerun this when the bridge.rs or static_bridge.h file changes. // Eerun this when the bridge.rs or static_bridge.h file changes.
println!("cargo:rerun-if-changed=src/bridge.rs"); println!("cargo:rerun-if-changed=src/bridge.rs");
println!("cargo:rerun-if-changed=src/bridge.h"); println!("cargo:rerun-if-changed=src/bridge.h");
// Rerun if this build script changes (since it contains embedded patches)
println!("cargo:rerun-if-changed=build.rs");
// Rerun if the patches directory or any patch files change
println!("cargo:rerun-if-changed=patches");
// Apply embedded patches before building
apply_embedded_patches().expect("Failed to apply embedded patches");
// Build with the monero library all dependencies required // Build with the monero library all dependencies required
let mut config = Config::new("monero"); let mut config = Config::new("monero");
let output_directory = config let output_directory = config
.build_target("wallet_api") .build_target("wallet_api")
// Builds currently fail in Release mode // Builds currently fail in Release mode
@ -38,6 +75,7 @@ fn main() {
.define("GTEST_HAS_ABSL", "OFF") .define("GTEST_HAS_ABSL", "OFF")
// Use lightweight crypto library // Use lightweight crypto library
.define("MONERO_WALLET_CRYPTO_LIBRARY", "cn") .define("MONERO_WALLET_CRYPTO_LIBRARY", "cn")
.build_arg("-Wno-dev") // Disable warnings we can't fix anyway
.build_arg(match (is_github_actions, is_docker_build) { .build_arg(match (is_github_actions, is_docker_build) {
(true, _) => "-j1", (true, _) => "-j1",
(_, true) => "-j1", (_, true) => "-j1",
@ -266,3 +304,130 @@ fn main() {
build.compile("monero-sys"); build.compile("monero-sys");
} }
/// Split a multi-file patch into individual file patches
fn split_patch_by_files(
patch_content: &str,
) -> Result<Vec<(String, String)>, Box<dyn std::error::Error>> {
let mut file_patches = Vec::new();
let lines: Vec<&str> = patch_content.lines().collect();
let mut current_file_patch = String::new();
let mut current_file_path: Option<String> = None;
let mut in_file_section = false;
for line in lines {
if line.starts_with("diff --git ") {
// Save previous file patch if we have one
if let Some(file_path) = current_file_path.take() {
if !current_file_patch.trim().is_empty() {
file_patches.push((file_path, current_file_patch.clone()));
}
}
// Start new file patch
current_file_patch.clear();
current_file_patch.push_str(line);
current_file_patch.push('\n');
// Extract file path from diff line (e.g., "diff --git a/src/wallet/api/wallet.cpp b/src/wallet/api/wallet.cpp")
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 {
let file_path = parts[2].strip_prefix("a/").unwrap_or(parts[2]);
current_file_path = Some(file_path.to_string());
}
in_file_section = true;
} else if in_file_section {
current_file_patch.push_str(line);
current_file_patch.push('\n');
}
}
// Don't forget the last file
if let Some(file_path) = current_file_path {
if !current_file_patch.trim().is_empty() {
file_patches.push((file_path, current_file_patch));
}
}
Ok(file_patches)
}
fn apply_embedded_patches() -> Result<(), Box<dyn std::error::Error>> {
let monero_dir = Path::new("monero");
if !monero_dir.exists() {
return Err("Monero directory not found. Please ensure the monero submodule is initialized and present.".into());
}
for embedded in EMBEDDED_PATCHES {
println!(
"cargo:warning=Processing embedded patch: {} ({})",
embedded.name, embedded.description
);
// Split the patch into individual file patches
let file_patches = split_patch_by_files(embedded.patch_unified)
.map_err(|e| format!("Failed to split patch {}: {}", embedded.name, e))?;
if file_patches.is_empty() {
return Err(format!("No file patches found in patch {}", embedded.name).into());
}
println!(
"cargo:warning=Found {} file(s) in patch {}",
file_patches.len(),
embedded.name
);
// Apply each file patch individually
for (file_path, patch_content) in file_patches {
println!("cargo:warning=Applying patch to file: {}", file_path);
// Parse the individual file patch
let patch = diffy::Patch::from_str(&patch_content)
.map_err(|e| format!("Failed to parse patch for {}: {}", file_path, e))?;
let target_path = monero_dir.join(&file_path);
if !target_path.exists() {
return Err(format!("Target file {} not found!", file_path).into());
}
let current = fs::read_to_string(&target_path)
.map_err(|e| format!("Failed to read {}: {}", file_path, e))?;
let patched = match diffy::apply(&current, &patch) {
Ok(p) => p,
Err(_) => {
// Try reversing the patch if that succeeds the file already contains the changes
if diffy::apply(&current, &patch.reverse()).is_ok() {
println!(
"cargo:warning=Patch for {} already applied skipping",
file_path
);
continue;
} else {
return Err(format!(
"Failed to apply patch to {}: hunk mismatch (not already applied)",
file_path
)
.into());
}
}
};
fs::write(&target_path, patched)
.map_err(|e| format!("Failed to write {}: {}", file_path, e))?;
println!("cargo:warning=Successfully applied patch to: {}", file_path);
}
println!(
"cargo:warning=Successfully applied all file patches for: {} ({})",
embedded.name, embedded.description
);
}
Ok(())
}

View file

@ -0,0 +1,63 @@
# Applies the new functionality from: https://github.com/monero-project/monero/pull/8861
# to the wallet2_api
# The pull request only added it for wallet2 (which is different from wallet2_api)
diff --git a/src/wallet/api/wallet.cpp b/src/wallet/api/wallet.cpp
index 96393eaaa..f2f8e7fbb 100644
--- a/src/wallet/api/wallet.cpp
+++ b/src/wallet/api/wallet.cpp
@@ -1777,7 +1777,7 @@ PendingTransaction* WalletImpl::restoreMultisigTransaction(const string& signDat
// - unconfirmed_transfer_details;
// - confirmed_transfer_details)
-PendingTransaction *WalletImpl::createTransactionMultDest(const std::vector<string> &dst_addr, const string &payment_id, optional<std::vector<uint64_t>> amount, uint32_t mixin_count, PendingTransaction::Priority priority, uint32_t subaddr_account, std::set<uint32_t> subaddr_indices)
+PendingTransaction *WalletImpl::createTransactionMultDest(const std::vector<string> &dst_addr, const string &payment_id, optional<std::vector<uint64_t>> amount, uint32_t mixin_count, PendingTransaction::Priority priority, uint32_t subaddr_account, std::set<uint32_t> subaddr_indices, std::set<uint32_t> subtract_fee_from_outputs)
{
clearStatus();
@@ -1862,7 +1862,7 @@ PendingTransaction *WalletImpl::createTransactionMultDest(const std::vector<stri
if (amount) {
transaction->m_pending_tx = m_wallet->create_transactions_2(dsts, fake_outs_count,
adjusted_priority,
- extra, subaddr_account, subaddr_indices);
+ extra, subaddr_account, subaddr_indices, subtract_fee_from_outputs);
} else {
transaction->m_pending_tx = m_wallet->create_transactions_all(0, info.address, info.is_subaddress, 1, fake_outs_count,
adjusted_priority,
@@ -1949,7 +1949,7 @@ PendingTransaction *WalletImpl::createTransaction(const string &dst_addr, const
PendingTransaction::Priority priority, uint32_t subaddr_account, std::set<uint32_t> subaddr_indices)
{
- return createTransactionMultDest(std::vector<string> {dst_addr}, payment_id, amount ? (std::vector<uint64_t> {*amount}) : (optional<std::vector<uint64_t>>()), mixin_count, priority, subaddr_account, subaddr_indices);
+ return createTransactionMultDest(std::vector<string> {dst_addr}, payment_id, amount ? (std::vector<uint64_t> {*amount}) : (optional<std::vector<uint64_t>>()), mixin_count, priority, subaddr_account, subaddr_indices, {});
}
PendingTransaction *WalletImpl::createSweepUnmixableTransaction()
diff --git a/src/wallet/api/wallet.h b/src/wallet/api/wallet.h
index b7f77a186..f81f13ac2 100644
--- a/src/wallet/api/wallet.h
+++ b/src/wallet/api/wallet.h
@@ -157,7 +157,8 @@ public:
optional<std::vector<uint64_t>> amount, uint32_t mixin_count,
PendingTransaction::Priority priority = PendingTransaction::Priority_Low,
uint32_t subaddr_account = 0,
- std::set<uint32_t> subaddr_indices = {}) override;
+ std::set<uint32_t> subaddr_indices = {},
+ std::set<uint32_t> subtract_fee_from_outputs = {}) override;
PendingTransaction * createTransaction(const std::string &dst_addr, const std::string &payment_id,
optional<uint64_t> amount, uint32_t mixin_count,
PendingTransaction::Priority priority = PendingTransaction::Priority_Low,
diff --git a/src/wallet/api/wallet2_api.h b/src/wallet/api/wallet2_api.h
index ca807ac87..f5f0a8f39 100644
--- a/src/wallet/api/wallet2_api.h
+++ b/src/wallet/api/wallet2_api.h
@@ -936,7 +936,8 @@ struct Wallet
optional<std::vector<uint64_t>> amount, uint32_t mixin_count,
PendingTransaction::Priority = PendingTransaction::Priority_Low,
uint32_t subaddr_account = 0,
- std::set<uint32_t> subaddr_indices = {}) = 0;
+ std::set<uint32_t> subaddr_indices = {},
+ std::set<uint32_t> subtract_fee_from_outputs = {}) = 0;
/*!
* \brief createTransaction creates transaction. if dst_addr is an integrated address, payment_id is ignored

View file

@ -122,6 +122,9 @@ namespace Monero
return wallet.createTransaction(dest_address, "", Monero::optional<uint64_t>(amount), 0, PendingTransaction::Priority_Default); return wallet.createTransaction(dest_address, "", Monero::optional<uint64_t>(amount), 0, PendingTransaction::Priority_Default);
} }
/**
* Create a transaction that spends all the unlocked balance to a single destination.
*/
inline PendingTransaction *createSweepTransaction( inline PendingTransaction *createSweepTransaction(
Wallet &wallet, Wallet &wallet,
const std::string &dest_address) const std::string &dest_address)
@ -129,6 +132,52 @@ namespace Monero
return wallet.createTransaction(dest_address, "", Monero::optional<uint64_t>(), 0, PendingTransaction::Priority_Default); return wallet.createTransaction(dest_address, "", Monero::optional<uint64_t>(), 0, PendingTransaction::Priority_Default);
} }
/**
* Creates a transaction that spends the unlocked balance to multiple destinations with given ratios.
* Ratiosn must sum to 1.
*/
inline PendingTransaction *createTransactionMultiDest(
Wallet &wallet,
const std::vector<std::string> &dest_addresses,
const std::vector<uint64_t> &amounts)
{
size_t n = dest_addresses.size();
// Check if we have any destinations at all
if (n == 0)
{
// wallet.setStatusError("Number of destinations must be greater than 0");
return nullptr;
}
// Check if the number of destinations and sweep ratios match
if (amounts.size() != n)
{
// wallet.setStatusError("Number of destinations and sweep ratios must match");
return nullptr;
}
// Build the actual multidest transaction
// No change left -> wallet drops it
// N outputs, fee should be the same as the one estimated above
// Find the highest output and choose it for subtract_fee_indices
std::set<uint32_t> subtract_fee_indices;
auto max_it = std::max_element(amounts.begin(), amounts.end());
size_t max_index = std::distance(amounts.begin(), max_it);
subtract_fee_indices.insert(static_cast<uint32_t>(max_index));
return wallet.createTransactionMultDest(
dest_addresses,
"", // No Payment ID
Monero::optional<std::vector<uint64_t>>(amounts),
0, // No mixin count
PendingTransaction::Priority_Default,
0, // subaddr_account
{}, // subaddr_indices
subtract_fee_indices); // Subtract fee from all outputs
}
inline bool setWalletDaemon(Wallet &wallet, const std::string &daemon_address) inline bool setWalletDaemon(Wallet &wallet, const std::string &daemon_address)
{ {
return wallet.setDaemon(daemon_address); return wallet.setDaemon(daemon_address);
@ -169,6 +218,13 @@ namespace Monero
{ {
return std::make_unique<std::string>(wallet.filename()); return std::make_unique<std::string>(wallet.filename());
} }
inline void vector_string_push_back(
std::vector<std::string> &v,
const std::string &s)
{
v.push_back(s);
}
} }
#include "easylogging++.h" #include "easylogging++.h"
@ -226,7 +282,7 @@ namespace monero_rust_log
} }
// Call the rust function to forward the log message. // Call the rust function to forward the log message.
monero_rust_log::forward_cpp_log( forward_cpp_log(
span_name.c_str(), span_name.c_str(),
level, level,
m->file().length() > 0 ? m->file() : "", m->file().length() > 0 ? m->file() : "",

View file

@ -232,6 +232,15 @@ pub mod ffi {
dest_address: &CxxString, dest_address: &CxxString,
) -> Result<*mut PendingTransaction>; ) -> Result<*mut PendingTransaction>;
/// Create a multi-sweep transaction.
fn createTransactionMultiDest(
wallet: Pin<&mut Wallet>,
dest_addresses: &CxxVector<CxxString>,
amounts: &CxxVector<u64>,
) -> *mut PendingTransaction;
fn vector_string_push_back(v: Pin<&mut CxxVector<CxxString>>, s: &CxxString);
/// Get the status of a pending transaction. /// Get the status of a pending transaction.
fn status(self: &PendingTransaction) -> Result<i32>; fn status(self: &PendingTransaction) -> Result<i32>;

View file

@ -18,8 +18,9 @@ use std::{
}; };
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use backoff::{future, retry_notify}; use backoff::{future::retry_notify, retry_notify as blocking_retry_notify};
use cxx::let_cxx_string; use cxx::{let_cxx_string, CxxString, CxxVector, UniquePtr};
use monero::Amount;
use tokio::sync::{ use tokio::sync::{
mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}, mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender},
oneshot, oneshot,
@ -111,6 +112,7 @@ pub struct TxStatus {
pub struct TxReceipt { pub struct TxReceipt {
pub txid: String, pub txid: String,
pub tx_key: String, pub tx_key: String,
/// The blockchain height at the time of publication.
pub height: u64, pub height: u64,
} }
@ -142,9 +144,15 @@ impl WalletHandle {
let thread_name = format!("wallet-{}", wallet_name); let thread_name = format!("wallet-{}", wallet_name);
// Capture current dispatcher before spawning
let current_dispatcher = tracing::dispatcher::get_default(|d| d.clone());
std::thread::Builder::new() std::thread::Builder::new()
.name(thread_name) .name(thread_name)
.spawn(move || { .spawn(move || {
// Set the dispatcher for this thread
let _guard = tracing::dispatcher::set_default(&current_dispatcher);
let mut manager = WalletManager::new(daemon.clone(), &wallet_name) let mut manager = WalletManager::new(daemon.clone(), &wallet_name)
.expect("wallet manager to be created"); .expect("wallet manager to be created");
let wallet = manager let wallet = manager
@ -188,11 +196,17 @@ impl WalletHandle {
let thread_name = format!("wallet-{}", wallet_name); let thread_name = format!("wallet-{}", wallet_name);
// Capture current dispatcher before spawning
let current_dispatcher = tracing::dispatcher::get_default(|d| d.clone());
// Spawn the wallet thread all interactions with the wallet must // Spawn the wallet thread all interactions with the wallet must
// happen on the same OS thread. // happen on the same OS thread.
std::thread::Builder::new() std::thread::Builder::new()
.name(thread_name) .name(thread_name)
.spawn(move || { .spawn(move || {
// Set the dispatcher for this thread
let _guard = tracing::dispatcher::set_default(&current_dispatcher);
// Create the wallet manager in this thread first. // Create the wallet manager in this thread first.
let mut manager = WalletManager::new(daemon.clone(), &wallet_name) let mut manager = WalletManager::new(daemon.clone(), &wallet_name)
.expect("wallet manager to be created"); .expect("wallet manager to be created");
@ -266,9 +280,15 @@ impl WalletHandle {
let thread_name = format!("wallet-{}", wallet_name); let thread_name = format!("wallet-{}", wallet_name);
// Capture current dispatcher before spawning
let current_dispatcher = tracing::dispatcher::get_default(|d| d.clone());
std::thread::Builder::new() std::thread::Builder::new()
.name(thread_name) .name(thread_name)
.spawn(move || { .spawn(move || {
// Set the dispatcher for this thread
let _guard = tracing::dispatcher::set_default(&current_dispatcher);
let wallet_name = path let wallet_name = path
.split('/') .split('/')
.last() .last()
@ -379,7 +399,7 @@ impl WalletHandle {
) -> anyhow::Result<TxReceipt> { ) -> anyhow::Result<TxReceipt> {
let address = *address; let address = *address;
future::retry_notify(backoff(None, None), || async { retry_notify(backoff(None, None), || async {
self.call(move |wallet| wallet.transfer(&address, amount)) self.call(move |wallet| wallet.transfer(&address, amount))
.await .await
.map_err(backoff::Error::transient) .map_err(backoff::Error::transient)
@ -391,10 +411,10 @@ impl WalletHandle {
} }
/// Sweep all funds to an address. /// Sweep all funds to an address.
pub async fn sweep(&self, address: &monero::Address) -> anyhow::Result<Vec<String>> { pub async fn sweep(&self, address: &monero::Address) -> anyhow::Result<Vec<TxReceipt>> {
let address = *address; let address = *address;
future::retry_notify(backoff(None, None), || async { retry_notify(backoff(None, None), || async {
self.call(move |wallet| wallet.sweep(&address)) self.call(move |wallet| wallet.sweep(&address))
.await .await
.map_err(backoff::Error::transient) .map_err(backoff::Error::transient)
@ -415,6 +435,21 @@ impl WalletHandle {
self.call(move |wallet| wallet.creation_height()).await self.call(move |wallet| wallet.creation_height()).await
} }
/// Sweep all funds to a set of addresses.
pub async fn sweep_multi(
&self,
addresses: &[monero::Address],
percentages: &[f64],
) -> anyhow::Result<Vec<TxReceipt>> {
let addresses = addresses.to_vec();
let percentages = percentages.to_vec();
tracing::debug!(addresses=?addresses, percentages=?percentages, "Sweeping multi");
self.call(move |wallet| wallet.sweep_multi(&addresses, &percentages))
.await
}
/// Get the unlocked balance of the wallet. /// Get the unlocked balance of the wallet.
pub async fn unlocked_balance(&self) -> monero::Amount { pub async fn unlocked_balance(&self) -> monero::Amount {
self.call(move |wallet| wallet.unlocked_balance()).await self.call(move |wallet| wallet.unlocked_balance()).await
@ -999,7 +1034,7 @@ impl FfiWallet {
tracing::debug!(address=%wallet.main_address(), "Initializing wallet"); tracing::debug!(address=%wallet.main_address(), "Initializing wallet");
retry_notify( blocking_retry_notify(
backoff(None, None), backoff(None, None),
|| { || {
wallet wallet
@ -1418,7 +1453,7 @@ impl FfiWallet {
/// Sweep all funds from the wallet to a specified address. /// Sweep all funds from the wallet to a specified address.
/// Returns a list of transaction ids of the created transactions. /// Returns a list of transaction ids of the created transactions.
fn sweep(&mut self, address: &monero::Address) -> anyhow::Result<Vec<String>> { fn sweep(&mut self, address: &monero::Address) -> anyhow::Result<Vec<TxReceipt>> {
tracing::info!("Sweeping funds to {}, refreshing wallet first", address); tracing::info!("Sweeping funds to {}, refreshing wallet first", address);
self.refresh_blocking()?; self.refresh_blocking()?;
@ -1447,7 +1482,190 @@ impl FfiWallet {
// Dispose of the transaction to avoid leaking memory. // Dispose of the transaction to avoid leaking memory.
self.dispose_transaction(pending_tx); self.dispose_transaction(pending_tx);
result.map(|_| txids) // Check for errors only after cleaning up the memory.
result.context("Failed to publish transaction")?;
// Get the receipts for the transactions.
let mut receipts = Vec::new();
for txid in txids {
let_cxx_string!(txid_cxx = &txid);
let tx_key = ffi::walletGetTxKey(&self.inner, &txid_cxx)
.context("Failed to get tx key from wallet: FFI call failed with exception")?
.to_string();
let height = self.blockchain_height();
receipts.push(TxReceipt {
txid: txid.clone(),
tx_key,
height,
});
}
Ok(receipts)
}
/// Sweep all funds to a set of addresses with a set of ratios.
fn sweep_multi(
&mut self,
addresses: &[monero::Address],
ratios: &[f64],
) -> anyhow::Result<Vec<TxReceipt>> {
tracing::warn!("STARTED MULTI SWEEP");
if addresses.len() == 0 {
bail!("No addresses to sweep to");
}
if addresses.len() != ratios.len() {
bail!("Number of addresses and ratios must match");
}
tracing::info!(
"Sweeping funds to {} addresses, refreshing wallet first",
addresses.len()
);
self.refresh_blocking()?;
let balance = self.unlocked_balance();
// Since we're using "subtract fee from outputs", we distribute the full balance
// The underlying transaction creation will subtract the fee proportionally from each output
let amounts = FfiWallet::distribute(balance, ratios)?;
tracing::debug!(%balance, num_outputs = addresses.len(), outputs=?amounts, "Distributing funds to outputs");
// Build a C++ vector of destination addresses
let mut cxx_addrs: UniquePtr<CxxVector<CxxString>> = CxxVector::<CxxString>::new();
for addr in addresses {
let_cxx_string!(s = addr.to_string());
ffi::vector_string_push_back(cxx_addrs.pin_mut(), &s);
}
// Build a C++ vector of amounts
let mut cxx_amounts: UniquePtr<CxxVector<u64>> = CxxVector::<u64>::new();
for &amount in &amounts {
cxx_amounts.pin_mut().push(amount.as_pico());
}
// Create the multi-sweep pending transaction
let raw_tx = ffi::createTransactionMultiDest(
self.inner.pinned(),
cxx_addrs.as_ref().unwrap(),
cxx_amounts.as_ref().unwrap(),
);
if raw_tx.is_null() {
self.check_error()
.context("Failed to create multi-sweep transaction")?;
anyhow::bail!("Failed to create multi-sweep transaction");
}
let mut pending_tx = PendingTransaction(raw_tx);
// Get the txids from the pending transaction before we publish,
// otherwise it might be null.
let txids: Vec<String> = ffi::pendingTransactionTxIds(&pending_tx)
.context("Failed to get txids of pending transaction: FFI call failed with exception")?
.into_iter()
.map(|s| s.to_string())
.collect();
// Publish the transaction
let result = pending_tx
.publish()
.context("Failed to publish transaction");
// Dispose of the transaction to avoid leaking memory.
self.dispose_transaction(pending_tx);
// Check for errors only after cleaning up the memory.
result.context("Failed to publish transaction")?;
// Get the receipts for the transactions.
let mut receipts = Vec::new();
for txid in txids {
let_cxx_string!(txid_cxx = &txid);
let tx_key = ffi::walletGetTxKey(&self.inner, &txid_cxx)
.context("Failed to get tx key from wallet: FFI call failed with exception")?
.to_string();
let height = self.blockchain_height();
receipts.push(TxReceipt {
txid: txid.clone(),
tx_key,
height,
});
}
Ok(receipts)
}
/// Distribute the funds in the wallet to a set of addresses with a set of percentages,
/// such that the complete balance is spent (takes fee into account).
///
/// # Arguments
///
/// * `balance` - The total balance to distribute
/// * `percentages` - A slice of percentages that must sum to 100.0
///
/// # Returns
///
/// A vector of Monero amounts proportional to the input percentages.
/// The last amount gets any remainder to ensure exact distribution.
///
/// # Errors
///
/// Returns an error if:
/// - Percentages don't sum to 100.0
/// - Balance is zero
/// - There are more outputs than piconeros in balance
fn distribute(balance: monero::Amount, percentages: &[f64]) -> Result<Vec<monero::Amount>> {
if percentages.is_empty() {
bail!("No ratios to distribute to");
}
const TOLERANCE: f64 = 1e-6;
let sum: f64 = percentages.iter().sum();
if (sum - 100.0).abs() > TOLERANCE {
bail!("Percentages must sum to 100 (actual sum: {})", sum);
}
// Handle the case where distributable amount is zero
if balance.as_pico() == 0 {
bail!("Zero balance to distribute");
}
// Check if the distributable amount is enough to cover at least one piconero per output
if balance.as_pico() < percentages.len() as u64 {
bail!("More outputs than piconeros in balance");
}
let mut amounts = Vec::new();
let mut total = Amount::ZERO;
// Distribute amounts according to ratios, except for the last one
for &percentage in &percentages[..percentages.len() - 1] {
let amount_pico = ((balance.as_pico() as f64) * percentage / 100.0).floor() as u64;
let amount = Amount::from_pico(amount_pico);
amounts.push(amount);
total += amount;
}
// Give the remainder to the last recipient to ensure exact distribution
let remainder = balance.checked_sub(total).context(format!(
"Underflow when calculating rest (unexpected) - balance {}, distributed: {}",
balance, total,
))?;
amounts.push(remainder);
Ok(amounts)
} }
/// Dispose (deallocate) a pending transaction object. /// Dispose (deallocate) a pending transaction object.
@ -1685,3 +1903,176 @@ fn backoff(
.with_max_interval(max_interval) .with_max_interval(max_interval)
.build() .build()
} }
#[cfg(test)]
mod tests {
use super::*;
use quickcheck::TestResult;
use quickcheck_macros::quickcheck;
#[quickcheck]
fn prop_distribute_sum_equals_balance(balance_pico: u64, percentages: Vec<f64>) -> TestResult {
// Filter out invalid inputs
if percentages.is_empty() || balance_pico == 0 {
return TestResult::discard();
}
// Ensure percentages are valid (non-negative and sum to approximately 100.0)
if percentages.iter().any(|&p| p < 0.0 || p > 100.0) {
return TestResult::discard();
}
let percentage_sum: f64 = percentages.iter().sum();
if (percentage_sum - 100.0).abs() > 1e-6 {
return TestResult::discard();
}
let balance = monero::Amount::from_pico(balance_pico);
let amounts = FfiWallet::distribute(balance, &percentages);
// Property: sum of distributed amounts should equal balance
let total_distributed: u64 = amounts.unwrap().iter().map(|a| a.as_pico()).sum();
let expected = balance.as_pico();
TestResult::from_bool(total_distributed == expected)
}
#[quickcheck]
fn prop_distribute_count_matches_percentages(
balance_pico: u64,
percentages: Vec<f64>,
) -> TestResult {
if percentages.is_empty() || balance_pico == 0 {
return TestResult::discard();
}
if percentages.iter().any(|&p| p < 0.0 || p > 100.0) {
return TestResult::discard();
}
let percentage_sum: f64 = percentages.iter().sum();
if (percentage_sum - 100.0).abs() > 1e-6 {
return TestResult::discard();
}
let balance = monero::Amount::from_pico(balance_pico);
let amounts = FfiWallet::distribute(balance, &percentages).unwrap();
// Property: number of amounts should equal number of percentages
TestResult::from_bool(amounts.len() == percentages.len())
}
#[quickcheck]
fn prop_distribute_respects_percentages(
balance_pico: u64,
percentages: Vec<f64>,
) -> TestResult {
if percentages.len() < 2 || balance_pico == 0 {
return TestResult::discard();
}
if percentages.iter().any(|&p| p < 0.0 || p > 100.0) {
return TestResult::discard();
}
let percentage_sum: f64 = percentages.iter().sum();
if (percentage_sum - 100.0).abs() > 1e-6 {
return TestResult::discard();
}
let balance = monero::Amount::from_pico(balance_pico);
let amounts = FfiWallet::distribute(balance, &percentages).unwrap();
// Property: percentages should be approximately respected (except for rounding)
// We check all but the last amount since the last one gets the remainder
let mut percentages_respected = true;
for i in 0..percentages.len() - 1 {
let expected_amount =
((balance.as_pico() as f64) * percentages[i] / 100.0).floor() as u64;
if amounts[i].as_pico() != expected_amount {
percentages_respected = false;
break;
}
}
TestResult::from_bool(percentages_respected)
}
#[test]
fn test_distribute_empty_percentages() {
let balance = monero::Amount::from_pico(1000);
let percentages: Vec<f64> = vec![];
let amounts = FfiWallet::distribute(balance, &percentages);
assert!(amounts.is_err());
}
#[test]
fn test_distribute_zero_balance() {
let balance = monero::Amount::from_pico(0);
let percentages = vec![50.0, 50.0];
let amounts = FfiWallet::distribute(balance, &percentages);
assert!(amounts.is_err());
}
#[test]
fn test_distribute_insufficient_balance_for_outputs() {
let balance = monero::Amount::from_pico(2);
let percentages = vec![30.0, 30.0, 40.0]; // 3 outputs but only 2 piconeros
let amounts = FfiWallet::distribute(balance, &percentages);
assert!(amounts.is_err());
}
#[test]
fn test_distribute_simple_case() {
let balance = monero::Amount::from_pico(1000);
let percentages = vec![50.0, 30.0, 20.0];
let amounts = FfiWallet::distribute(balance, &percentages).unwrap();
assert_eq!(amounts.len(), 3);
// Total should equal balance
let total: u64 = amounts.iter().map(|a| a.as_pico()).sum();
assert_eq!(total, 1000);
// First two amounts should respect percentages exactly
assert_eq!(amounts[0].as_pico(), 500); // 50% of 1000
assert_eq!(amounts[1].as_pico(), 300); // 30% of 1000
// Last amount gets remainder: 1000 - 500 - 300 = 200
assert_eq!(amounts[2].as_pico(), 200);
}
#[test]
fn test_distribute_small_donation() {
let balance = monero::Amount::from_pico(1000);
let percentages = vec![99.9, 0.1];
let amounts = FfiWallet::distribute(balance, &percentages).unwrap();
assert_eq!(amounts.len(), 2);
// Total should equal balance
let total: u64 = amounts.iter().map(|a| a.as_pico()).sum();
assert_eq!(total, 1000);
// First amount should respect percentage exactly
assert_eq!(amounts[0].as_pico(), 999); // 99.9% of 1000 (floored)
// Last amount gets remainder: 1000 - 999 = 1
assert_eq!(amounts[1].as_pico(), 1);
}
#[test]
fn test_distribute_percentages_not_sum_to_100() {
let balance = monero::Amount::from_pico(1000);
let percentages = vec![50.0, 30.0]; // Only sums to 80%
let amounts = FfiWallet::distribute(balance, &percentages);
assert!(amounts.is_err());
}
}

View file

@ -1,2 +1,2 @@
# You can configure the address of a locally running testnet asb. It'll displayed in the GUI. This is useful for testing # You can configure the address of a locally running testnet asb. It'll displayed in the GUI. This is useful for testing
VITE_TESTNET_STUB_PROVIDER_ADDRESS=/onion3/dmdrgmy27szmps3p5zqh4ujd7twoi2a5ao7mouugfg6owyj4ikd2h5yd:9939/p2p/12D3KooWCa6vLE6SFhEBs3EhsC5tCBoHKBLoLEo1riDDmcExr5BW # VITE_TESTNET_STUB_PROVIDER_ADDRESS=/onion3/dmdrgmy27szmps3p5zqh4ujd7twoi2a5ao7mouugfg6owyj4ikd2h5yd:9939/p2p/12D3KooWCa6vLE6SFhEBs3EhsC5tCBoHKBLoLEo1riDDmcExr5BW

View file

@ -3,7 +3,7 @@ import { ReactNode } from "react";
type Props = { type Props = {
id?: string; id?: string;
title: ReactNode; title: ReactNode | null;
mainContent: ReactNode; mainContent: ReactNode;
additionalContent: ReactNode; additionalContent: ReactNode;
loading: boolean; loading: boolean;
@ -30,7 +30,7 @@ export default function InfoBox({
gap: 1, gap: 1,
}} }}
> >
<Typography variant="subtitle1">{title}</Typography> {title ? <Typography variant="subtitle1">{title}</Typography> : null}
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{icon} {icon}
{mainContent} {mainContent}

View file

@ -1,6 +1,7 @@
import { Link, Typography } from "@mui/material"; import { Box, Link, Typography } from "@mui/material";
import { ReactNode } from "react"; import { ReactNode } from "react";
import InfoBox from "./InfoBox"; import InfoBox from "./InfoBox";
import TruncatedText from "renderer/components/other/TruncatedText";
export type TransactionInfoBoxProps = { export type TransactionInfoBoxProps = {
title: string; title: string;
@ -24,12 +25,14 @@ export default function TransactionInfoBox({
title={title} title={title}
mainContent={ mainContent={
<Typography variant="h5"> <Typography variant="h5">
{txId ?? "Transaction ID not available"} <TruncatedText truncateMiddle limit={40}>
{txId ?? "Transaction ID not available"}
</TruncatedText>
</Typography> </Typography>
} }
loading={loading} loading={loading}
additionalContent={ additionalContent={
<> <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="subtitle2">{additionalContent}</Typography> <Typography variant="subtitle2">{additionalContent}</Typography>
{explorerUrlCreator != null && {explorerUrlCreator != null &&
txId != null && ( // Only show the link if the txId is not null and we have a creator for the explorer URL txId != null && ( // Only show the link if the txId is not null and we have a creator for the explorer URL
@ -39,7 +42,7 @@ export default function TransactionInfoBox({
</Link> </Link>
</Typography> </Typography>
)} )}
</> </Box>
} }
icon={icon} icon={icon}
/> />

View file

@ -1,4 +1,4 @@
import { Box, DialogContentText } from "@mui/material"; import { Box, DialogContentText, Typography } from "@mui/material";
import { TauriSwapProgressEventContent } from "models/tauriModelExt"; import { TauriSwapProgressEventContent } from "models/tauriModelExt";
import FeedbackInfoBox from "../../../../pages/help/FeedbackInfoBox"; import FeedbackInfoBox from "../../../../pages/help/FeedbackInfoBox";
import MoneroTransactionInfoBox from "../../MoneroTransactionInfoBox"; import MoneroTransactionInfoBox from "../../MoneroTransactionInfoBox";
@ -11,8 +11,8 @@ export default function XmrRedeemInMempoolPage(
return ( return (
<Box> <Box>
<DialogContentText> <DialogContentText>
The swap was successful and the Monero has been sent to the address you The swap was successful and the Monero has been sent to the following
specified. The swap is completed and you may exit the application now. address(es). The swap is completed and you may exit the application now.
</DialogContentText> </DialogContentText>
<Box <Box
style={{ style={{
@ -24,7 +24,55 @@ export default function XmrRedeemInMempoolPage(
<MoneroTransactionInfoBox <MoneroTransactionInfoBox
title="Monero Redeem Transaction" title="Monero Redeem Transaction"
txId={xmr_redeem_txid} txId={xmr_redeem_txid}
additionalContent={`The funds have been sent to the address ${state.xmr_redeem_address}`} additionalContent={
<Box sx={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{state.xmr_receive_pool.map((pool, index) => (
<Box
key={index}
sx={{
display: "flex",
flexDirection: "column",
gap: 0.5,
padding: 1,
border: 1,
borderColor: "divider",
borderRadius: 1,
backgroundColor: (theme) => theme.palette.action.hover,
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography
variant="body2"
sx={(theme) => ({
fontWeight: 600,
color: theme.palette.text.primary,
})}
>
{pool.label} ({pool.percentage}%)
</Typography>
</Box>
<Typography
variant="caption"
sx={{
fontFamily: "monospace",
color: (theme) => theme.palette.text.secondary,
wordBreak: "break-all",
}}
>
{pool.address}
</Typography>
</Box>
))}
</Box>
</Box>
}
loading={false} loading={false}
/> />
<FeedbackInfoBox /> <FeedbackInfoBox />

View file

@ -4,17 +4,14 @@ import {
PendingLockBitcoinApprovalRequest, PendingLockBitcoinApprovalRequest,
TauriSwapProgressEventContent, TauriSwapProgressEventContent,
} from "models/tauriModelExt"; } from "models/tauriModelExt";
import { import { SatsAmount, PiconeroAmount } from "renderer/components/other/Units";
SatsAmount,
PiconeroAmount,
MoneroBitcoinExchangeRateFromAmounts,
} from "renderer/components/other/Units";
import { Box, Typography, Divider } from "@mui/material"; import { Box, Typography, Divider } from "@mui/material";
import { useActiveSwapId, usePendingLockBitcoinApproval } from "store/hooks"; import { useActiveSwapId, usePendingLockBitcoinApproval } from "store/hooks";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import InfoBox from "renderer/components/modal/swap/InfoBox";
import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle"; import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle";
import CheckIcon from "@mui/icons-material/Check"; import CheckIcon from "@mui/icons-material/Check";
import ArrowRightAltIcon from "@mui/icons-material/ArrowRightAlt";
import TruncatedText from "renderer/components/other/TruncatedText";
/// A hook that returns the LockBitcoin confirmation request for the active swap /// A hook that returns the LockBitcoin confirmation request for the active swap
/// Returns null if no confirmation request is found /// Returns null if no confirmation request is found
@ -63,107 +60,412 @@ export default function SwapSetupInflightPage({
); );
} }
const { btc_network_fee, xmr_receive_amount } = const { btc_network_fee, monero_receive_pool, xmr_receive_amount } =
request.content.details.content; request.content.details.content;
return ( return (
<InfoBox <>
title="Approve Swap" {/* Grid layout for perfect alignment */}
icon={<></>} <Box
loading={false} sx={{
mainContent={ display: "grid",
<> gridTemplateColumns: "max-content auto max-content",
<Divider /> gap: "1.5rem",
alignItems: "stretch",
justifyContent: "center",
}}
>
{/* Row 1: Bitcoin box */}
<Box>
<BitcoinMainBox btc_lock_amount={btc_lock_amount} />
</Box>
{/* Row 1: Animated arrow */}
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<AnimatedArrow />
</Box>
{/* Row 1: Monero main box */}
<Box>
<MoneroMainBox
monero_receive_pool={monero_receive_pool}
xmr_receive_amount={xmr_receive_amount}
/>
</Box>
{/* Row 2: Empty space */}
<Box />
{/* Row 2: Empty space */}
<Box />
{/* Row 2: Secondary content */}
<Box>
<MoneroSecondaryContent
monero_receive_pool={monero_receive_pool}
xmr_receive_amount={xmr_receive_amount}
/>
</Box>
</Box>
<Box
sx={{
marginTop: 2,
display: "flex",
justifyContent: "center",
gap: 2,
}}
>
<PromiseInvokeButton
variant="text"
size="large"
sx={(theme) => ({ color: theme.palette.text.secondary })}
onInvoke={() => resolveApproval(request.content.request_id, false)}
displayErrorSnackbar
requiresContext
>
Deny
</PromiseInvokeButton>
<PromiseInvokeButton
variant="contained"
color="primary"
size="large"
onInvoke={() => resolveApproval(request.content.request_id, true)}
displayErrorSnackbar
requiresContext
endIcon={<CheckIcon />}
>
{`Confirm (${timeLeft}s)`}
</PromiseInvokeButton>
</Box>
</>
);
}
/**
* Pure presentational components -------------------------------------------------
* They live in the same file to avoid additional imports yet keep
* JSX for the main page tidy. All styling values are kept identical
* to their previous inline counterparts so that the visual appearance
* stays exactly the same while making the code easier to reason about.
*/
interface BitcoinSendSectionProps {
btc_lock_amount: number;
btc_network_fee: number;
}
const BitcoinMainBox = ({ btc_lock_amount }: { btc_lock_amount: number }) => (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: 1.5,
border: 1,
gap: "0.5rem 1rem",
borderColor: "warning.main",
borderRadius: 1,
backgroundColor: (theme) => theme.palette.warning.light + "10",
background: (theme) =>
`linear-gradient(135deg, ${theme.palette.warning.light}20, ${theme.palette.warning.light}05)`,
flex: "1 1 0",
height: "100%", // Match the height of the Monero box
}}
>
<Typography
variant="body1"
sx={(theme) => ({
color: theme.palette.text.primary,
})}
>
You send
</Typography>
<Typography
variant="body1"
sx={(theme) => ({
fontWeight: "bold",
color: theme.palette.warning.dark,
textShadow: "0 1px 2px rgba(0,0,0,0.1)",
})}
>
<SatsAmount amount={btc_lock_amount} />
</Typography>
</Box>
);
interface PoolBreakdownProps {
monero_receive_pool: Array<{
address: string;
label: string;
percentage: number;
}>;
xmr_receive_amount: number;
}
const PoolBreakdown = ({
monero_receive_pool,
xmr_receive_amount,
}: PoolBreakdownProps) => {
// Find the pool entry with the highest percentage to exclude it (since it's shown in main box)
const highestPercentagePool = monero_receive_pool.reduce((prev, current) =>
prev.percentage > current.percentage ? prev : current,
);
// Filter out the highest percentage pool since it's already displayed in the main box
const remainingPools = monero_receive_pool.filter(
(pool) => pool !== highestPercentagePool,
);
return (
<Box
sx={{ display: "flex", flexDirection: "column", gap: 1, width: "100%" }}
>
{remainingPools.map((pool) => (
<Box
key={pool.address}
sx={{
display: "flex",
justifyContent: "flex-start",
alignItems: "stretch",
padding: pool.percentage >= 5 ? 1.5 : 1.2,
border: 1,
borderColor:
pool.percentage >= 5 ? "success.main" : "success.light",
borderRadius: 1,
backgroundColor: (theme) =>
pool.percentage >= 5
? theme.palette.success.light + "10"
: theme.palette.action.hover,
width: "100%", // Ensure full width
minWidth: 0,
opacity: pool.percentage >= 5 ? 1 : 0.75,
transform: pool.percentage >= 5 ? "scale(1)" : "scale(0.95)",
animation:
pool.percentage >= 5
? "poolPulse 2s ease-in-out infinite"
: "none",
"@keyframes poolPulse": {
"0%": {
transform: "scale(1)",
opacity: 1,
},
"50%": {
transform: "scale(1.02)",
opacity: 0.95,
},
"100%": {
transform: "scale(1)",
opacity: 1,
},
},
}}
>
<Box <Box
sx={{ sx={{
display: "grid", display: "flex",
gridTemplateColumns: "auto 1fr", flexDirection: "column",
rowGap: 1, gap: 0.5,
columnGap: 2, flex: "1 1 0",
alignItems: "center", minWidth: 0,
marginBlock: 2,
}} }}
> >
<Typography <Typography
sx={(theme) => ({ color: theme.palette.text.secondary })} variant="body2"
>
You send
</Typography>
<Typography>
<SatsAmount amount={btc_lock_amount} />
</Typography>
<Typography
sx={(theme) => ({ color: theme.palette.text.secondary })}
>
Bitcoin network fees
</Typography>
<Typography>
<SatsAmount amount={btc_network_fee} />
</Typography>
<Typography
sx={(theme) => ({ color: theme.palette.text.secondary })}
>
You receive
</Typography>
<Typography
sx={(theme) => ({ sx={(theme) => ({
fontWeight: "bold", color: theme.palette.text.primary,
color: theme.palette.success.main, fontSize: "0.75rem",
fontWeight: 600,
})} })}
> >
<PiconeroAmount amount={xmr_receive_amount} /> {pool.label === "user address" ? "Your Wallet" : pool.label}
</Typography> </Typography>
<Typography <Typography
sx={(theme) => ({ color: theme.palette.text.secondary })} variant="body2"
sx={{
fontFamily: "monospace",
fontSize: "0.75rem",
color: (theme) => theme.palette.text.secondary,
}}
> >
Exchange rate <TruncatedText truncateMiddle limit={15}>
</Typography> {pool.address}
<Typography> </TruncatedText>
<MoneroBitcoinExchangeRateFromAmounts
satsAmount={btc_lock_amount}
piconeroAmount={xmr_receive_amount}
displayMarkup
/>
</Typography> </Typography>
</Box> </Box>
</> <Box
} sx={{
additionalContent={ display: "flex",
<Box flexDirection: "column",
alignItems: "flex-end",
gap: 0.5,
flex: "0 0 auto",
minWidth: 140,
justifyContent: "center",
}}
>
{pool.percentage >= 5 && (
<Typography
variant="body2"
sx={(theme) => ({
fontWeight: "bold",
color: theme.palette.success.main,
fontSize: "0.875rem",
whiteSpace: "nowrap",
})}
>
<PiconeroAmount
amount={(pool.percentage * Number(xmr_receive_amount)) / 100}
/>
</Typography>
)}
<Typography
variant="caption"
sx={(theme) => ({
color: theme.palette.text.secondary,
whiteSpace: "nowrap",
})}
>
{pool.percentage}%
</Typography>
</Box>
</Box>
))}
</Box>
);
};
interface MoneroReceiveSectionProps {
monero_receive_pool: PoolBreakdownProps["monero_receive_pool"];
xmr_receive_amount: number;
}
const MoneroMainBox = ({
monero_receive_pool,
xmr_receive_amount,
}: MoneroReceiveSectionProps) => {
// Find the pool entry with the highest percentage
const highestPercentagePool = monero_receive_pool.reduce((prev, current) =>
prev.percentage > current.percentage ? prev : current,
);
return (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: 1.5,
border: 1,
gap: "0.5rem 1rem",
borderColor: "success.main",
borderRadius: 1,
backgroundColor: (theme) => theme.palette.success.light + "10",
background: (theme) =>
`linear-gradient(135deg, ${theme.palette.success.light}20, ${theme.palette.success.light}05)`,
flex: "1 1 0",
}}
>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
<Typography
variant="body1"
sx={(theme) => ({
color: theme.palette.text.primary,
fontWeight: 700,
letterSpacing: 0.5,
})}
>
{highestPercentagePool.label}
</Typography>
<Typography
variant="caption"
sx={{ sx={{
marginTop: 2, fontFamily: "monospace",
display: "flex", fontSize: "0.65rem",
justifyContent: "flex-end", color: (theme) => theme.palette.text.secondary,
gap: 2,
}} }}
> >
<PromiseInvokeButton <TruncatedText truncateMiddle limit={15}>
variant="text" {highestPercentagePool.address}
size="large" </TruncatedText>
sx={(theme) => ({ color: theme.palette.text.secondary })} </Typography>
onInvoke={() => resolveApproval(request.content.request_id, false)} </Box>
displayErrorSnackbar <Box
requiresContext sx={{
> display: "flex",
Deny flexDirection: "column",
</PromiseInvokeButton> alignItems: "flex-end",
justifyContent: "center",
<PromiseInvokeButton }}
variant="contained" >
color="primary" <Typography
size="large" variant="h5"
onInvoke={() => resolveApproval(request.content.request_id, true)} sx={(theme) => ({
displayErrorSnackbar fontWeight: "bold",
requiresContext color: theme.palette.success.dark,
endIcon={<CheckIcon />} textShadow: "0 1px 2px rgba(0,0,0,0.1)",
> })}
{`Confirm & lock BTC (${timeLeft}s)`} >
</PromiseInvokeButton> <PiconeroAmount
</Box> amount={
} (highestPercentagePool.percentage * Number(xmr_receive_amount)) /
/> 100
}
/>
</Typography>
</Box>
</Box>
); );
} };
const MoneroSecondaryContent = ({
monero_receive_pool,
xmr_receive_amount,
}: MoneroReceiveSectionProps) => (
<PoolBreakdown
monero_receive_pool={monero_receive_pool}
xmr_receive_amount={xmr_receive_amount}
/>
);
// Arrow animation styling extracted for reuse
const arrowSx = {
fontSize: "3rem",
color: (theme: any) => theme.palette.primary.main,
animation: "slideArrow 2s infinite",
"@keyframes slideArrow": {
"0%": {
opacity: 0.6,
transform: "translateX(-8px)",
},
"50%": {
opacity: 1,
transform: "translateX(8px)",
},
"100%": {
opacity: 0.6,
transform: "translateX(-8px)",
},
},
} as const;
const AnimatedArrow = () => (
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
alignSelf: "center",
flex: "0 0 auto",
}}
>
<ArrowRightAltIcon sx={arrowSx} />
</Box>
);

View file

@ -6,7 +6,7 @@ import BitcoinAddressTextField from "renderer/components/inputs/BitcoinAddressTe
import MoneroAddressTextField from "renderer/components/inputs/MoneroAddressTextField"; import MoneroAddressTextField from "renderer/components/inputs/MoneroAddressTextField";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { buyXmr } from "renderer/rpc"; import { buyXmr } from "renderer/rpc";
import { useAppSelector } from "store/hooks"; import { useAppSelector, useSettings } from "store/hooks";
export default function InitPage() { export default function InitPage() {
const [redeemAddress, setRedeemAddress] = useState(""); const [redeemAddress, setRedeemAddress] = useState("");
@ -18,12 +18,14 @@ export default function InitPage() {
const [refundAddressValid, setRefundAddressValid] = useState(false); const [refundAddressValid, setRefundAddressValid] = useState(false);
const selectedMaker = useAppSelector((state) => state.makers.selectedMaker); const selectedMaker = useAppSelector((state) => state.makers.selectedMaker);
const donationRatio = useSettings((s) => s.donateToDevelopment);
async function init() { async function init() {
await buyXmr( await buyXmr(
selectedMaker, selectedMaker,
useExternalRefundAddress ? refundAddress : null, useExternalRefundAddress ? refundAddress : null,
redeemAddress, redeemAddress,
donationRatio,
); );
} }

View file

@ -20,16 +20,14 @@ import {
useTheme, useTheme,
Switch, Switch,
SelectChangeEvent, SelectChangeEvent,
TextField,
ToggleButton, ToggleButton,
ToggleButtonGroup, ToggleButtonGroup,
Chip,
LinearProgress,
} from "@mui/material"; } from "@mui/material";
import { import {
addNode, addNode,
addRendezvousPoint, addRendezvousPoint,
Blockchain, Blockchain,
DonateToDevelopmentTip,
FiatCurrency, FiatCurrency,
moveUpNode, moveUpNode,
Network, Network,
@ -41,12 +39,12 @@ import {
setTheme, setTheme,
setTorEnabled, setTorEnabled,
setUseMoneroRpcPool, setUseMoneroRpcPool,
setDonateToDevelopment,
} from "store/features/settingsSlice"; } from "store/features/settingsSlice";
import { useAppDispatch, useNodes, useSettings } from "store/hooks"; import { useAppDispatch, useNodes, useSettings } from "store/hooks";
import ValidatedTextField from "renderer/components/other/ValidatedTextField"; import ValidatedTextField from "renderer/components/other/ValidatedTextField";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import HelpIcon from "@mui/icons-material/HelpOutline"; import HelpIcon from "@mui/icons-material/HelpOutline";
import { ReactNode, useState, useEffect } from "react"; import { ReactNode, useState } from "react";
import { Theme } from "renderer/components/theme"; import { Theme } from "renderer/components/theme";
import { import {
Add, Add,
@ -61,8 +59,6 @@ import { getNetwork } from "store/config";
import { currencySymbol } from "utils/formatUtils"; import { currencySymbol } from "utils/formatUtils";
import InfoBox from "renderer/components/modal/swap/InfoBox"; import InfoBox from "renderer/components/modal/swap/InfoBox";
import { isValidMultiAddressWithPeerId } from "utils/parseUtils"; import { isValidMultiAddressWithPeerId } from "utils/parseUtils";
import { useAppSelector } from "store/hooks";
import { getNodeStatus } from "renderer/rpc"; import { getNodeStatus } from "renderer/rpc";
import { setStatus } from "store/features/nodesSlice"; import { setStatus } from "store/features/nodesSlice";
@ -95,6 +91,7 @@ export default function SettingsBox() {
<Table> <Table>
<TableBody> <TableBody>
<TorSettings /> <TorSettings />
<DonationTipSetting />
<ElectrumRpcUrlSetting /> <ElectrumRpcUrlSetting />
<MoneroRpcPoolSetting /> <MoneroRpcPoolSetting />
<MoneroNodeUrlSetting /> <MoneroNodeUrlSetting />
@ -835,3 +832,127 @@ function RendezvousPointsSetting() {
</TableRow> </TableRow>
); );
} }
/**
* A setting that allows you to set a development donation tip amount
*/
function DonationTipSetting() {
const donateToDevelopment = useSettings((s) => s.donateToDevelopment);
const dispatch = useAppDispatch();
const handleTipSelect = (tipAmount: DonateToDevelopmentTip) => {
dispatch(setDonateToDevelopment(tipAmount));
};
const formatTipLabel = (tip: DonateToDevelopmentTip) => {
if (tip === false) return "0%";
return `${(tip * 100).toFixed(2)}%`;
};
const getTipButtonColor = (
tip: DonateToDevelopmentTip,
isSelected: boolean,
) => {
// Only show colored if selected and > 0
if (isSelected && tip !== false) {
return "#198754"; // Green for any tip > 0
}
return "#6c757d"; // Gray for all unselected or no tip
};
const getTipButtonSelectedColor = (tip: DonateToDevelopmentTip) => {
if (tip === false) return "#5c636a"; // Darker gray
return "#146c43"; // Darker green for any tip > 0
};
return (
<TableRow>
<TableCell>
<SettingLabel
label="Tip to the developers"
tooltip="Support the development of UnstoppableSwap by donating a small percentage of your swaps. Donations go directly to paying for infrastructure costs and developers"
/>
</TableCell>
<TableCell>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<ToggleButtonGroup
value={donateToDevelopment}
exclusive
onChange={(event, newValue) => {
if (newValue !== null) {
handleTipSelect(newValue);
}
}}
aria-label="Development tip amount"
size="small"
sx={{
width: "100%",
gap: 1,
"& .MuiToggleButton-root": {
flex: 1,
borderRadius: "8px",
fontWeight: "600",
textTransform: "none",
border: "2px solid",
"&:not(:first-of-type)": {
marginLeft: "8px",
borderLeft: "2px solid",
},
},
}}
>
{([false, 0.0005, 0.0075] as const).map((tipAmount) => (
<ToggleButton
key={String(tipAmount)}
value={tipAmount}
sx={{
borderColor: `${getTipButtonColor(tipAmount, donateToDevelopment === tipAmount)} !important`,
color:
donateToDevelopment === tipAmount
? "white"
: getTipButtonColor(
tipAmount,
donateToDevelopment === tipAmount,
),
backgroundColor:
donateToDevelopment === tipAmount
? getTipButtonColor(
tipAmount,
donateToDevelopment === tipAmount,
)
: "transparent",
"&:hover": {
backgroundColor: `${getTipButtonSelectedColor(tipAmount)} !important`,
color: "white !important",
},
"&.Mui-selected": {
backgroundColor: `${getTipButtonColor(tipAmount, true)} !important`,
color: "white !important",
"&:hover": {
backgroundColor: `${getTipButtonSelectedColor(tipAmount)} !important`,
},
},
}}
>
{formatTipLabel(tipAmount)}
</ToggleButton>
))}
</ToggleButtonGroup>
<Typography variant="subtitle2">
<ul style={{ margin: 0, padding: "0 1.5rem" }}>
<li>
Tips go <strong>directly</strong> towards paying for
infrastructure costs and developers
</li>
<li>
Only ever sent for <strong>successful</strong> swaps
</li>{" "}
(refunds are not counted)
<li>Monero is used for the tips, giving you full anonymity</li>
</ul>
</Typography>
</Box>
</TableCell>
</TableRow>
);
}

View file

@ -6,13 +6,12 @@ import {
TableCell, TableCell,
TableContainer, TableContainer,
TableRow, TableRow,
Typography,
} from "@mui/material"; } from "@mui/material";
import { OpenInNew } from "@mui/icons-material";
import { GetSwapInfoResponse } from "models/tauriModel"; import { GetSwapInfoResponse } from "models/tauriModel";
import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox"; import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox";
import MonospaceTextBox from "renderer/components/other/MonospaceTextBox"; import MonospaceTextBox from "renderer/components/other/MonospaceTextBox";
import { import {
MoneroBitcoinExchangeRate,
MoneroBitcoinExchangeRateFromAmounts, MoneroBitcoinExchangeRateFromAmounts,
PiconeroAmount, PiconeroAmount,
SatsAmount, SatsAmount,
@ -109,6 +108,48 @@ export default function HistoryRowExpanded({
</Link> </Link>
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow>
<TableCell>Monero receive pool</TableCell>
<TableCell>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{swap.monero_receive_pool.map((pool, index) => (
<Box
key={index}
sx={{
display: "flex",
flexDirection: "column",
gap: 0.5,
padding: 1,
border: 1,
borderColor: "divider",
borderRadius: 1,
backgroundColor: (theme) => theme.palette.action.hover,
}}
>
<Typography
variant="body2"
sx={(theme) => ({
fontWeight: 600,
color: theme.palette.text.primary,
})}
>
{pool.label} ({pool.percentage}%)
</Typography>
<Typography
variant="caption"
sx={{
fontFamily: "monospace",
color: (theme) => theme.palette.text.secondary,
wordBreak: "break-all",
}}
>
{pool.address}
</Typography>
</Box>
))}
</Box>
</TableCell>
</TableRow>
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>

View file

@ -27,6 +27,7 @@ import {
ResolveApprovalResponse, ResolveApprovalResponse,
RedactArgs, RedactArgs,
RedactResponse, RedactResponse,
LabeledMoneroAddress,
} from "models/tauriModel"; } from "models/tauriModel";
import { rpcSetBalance, rpcSetSwapInfo } from "store/features/rpcSlice"; import { rpcSetBalance, rpcSetSwapInfo } from "store/features/rpcSlice";
import { store } from "./store/storeRenderer"; import { store } from "./store/storeRenderer";
@ -36,14 +37,46 @@ import { MoneroRecoveryResponse } from "models/rpcModel";
import { ListSellersResponse } from "../models/tauriModel"; import { ListSellersResponse } from "../models/tauriModel";
import logger from "utils/logger"; import logger from "utils/logger";
import { getNetwork, isTestnet } from "store/config"; import { getNetwork, isTestnet } from "store/config";
import { Blockchain, Network } from "store/features/settingsSlice"; import {
Blockchain,
DonateToDevelopmentTip,
Network,
} from "store/features/settingsSlice";
import { setStatus } from "store/features/nodesSlice"; import { setStatus } from "store/features/nodesSlice";
import { discoveredMakersByRendezvous } from "store/features/makersSlice"; import { discoveredMakersByRendezvous } from "store/features/makersSlice";
import { CliLog } from "models/cliModel"; import { CliLog } from "models/cliModel";
import { logsToRawString, parseLogsFromString } from "utils/parseUtils"; import { logsToRawString, parseLogsFromString } from "utils/parseUtils";
/// These are the official donation address for the UnstoppableSwap/core project
const DONATION_ADDRESS_MAINNET =
"49LEH26DJGuCyr8xzRAzWPUryzp7bpccC7Hie1DiwyfJEyUKvMFAethRLybDYrFdU1eHaMkKQpUPebY4WT3cSjEvThmpjPa";
const DONATION_ADDRESS_STAGENET =
"56E274CJxTyVuuFG651dLURKyneoJ5LsSA5jMq4By9z9GBNYQKG8y5ejTYkcvZxarZW6if14ve8xXav2byK4aRnvNdKyVxp";
/// Signature by binarybaron for the donation address
/// https://github.com/binarybaron/
///
/// Get the key from:
/// - https://github.com/UnstoppableSwap/core/blob/master/utils/gpg_keys/binarybaron.asc
/// - https://unstoppableswap.net/binarybaron.asc
const DONATION_ADDRESS_MAINNET_SIG = `
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
56E274CJxTyVuuFG651dLURKyneoJ5LsSA5jMq4By9z9GBNYQKG8y5ejTYkcvZxarZW6if14ve8xXav2byK4aRnvNdKyVxp is our donation address (signed by binarybaron)
-----BEGIN PGP SIGNATURE-----
iHUEARYKAB0WIQQ1qETX9LVbxE4YD/GZt10+FHaibgUCaFvzWQAKCRCZt10+FHai
bvC6APoCzCto6RsNYwUr7j1ou3xeVNiwMkUQbE0erKt70pT+tQD/fAvPxHtPyb56
XGFQ0pxL1PKzMd9npBGmGJhC4aTljQ4=
=OUK4
-----END PGP SIGNATURE-----
`;
export const PRESET_RENDEZVOUS_POINTS = [ export const PRESET_RENDEZVOUS_POINTS = [
"/dnsaddr/xxmr.cheap/p2p/12D3KooWMk3QyPS8D1d1vpHZoY7y2MnXdPE5yV6iyPvyuj4zcdxT", "/dns4/discover.unstoppableswap.net/tcp/8888/p2p/12D3KooWA6cnqJpVnreBVnoro8midDL9Lpzmg8oJPoAGi7YYaamE",
"/dns4/discover2.unstoppableswap.net/tcp/8888/p2p/12D3KooWGRvf7qVQDrNR5nfYD6rKrbgeTi9x8RrbdxbmsPvxL4mw",
"/dns4/darkness.su/tcp/8888/p2p/12D3KooWFQAgVVS9t9UgL6v1sLprJVM7am5hFK7vy9iBCCoCBYmU",
]; ];
export async function fetchSellersAtPresetRendezvousPoints() { export async function fetchSellersAtPresetRendezvousPoints() {
@ -142,20 +175,39 @@ export async function buyXmr(
seller: Maker, seller: Maker,
bitcoin_change_address: string | null, bitcoin_change_address: string | null,
monero_receive_address: string, monero_receive_address: string,
donation_percentage: DonateToDevelopmentTip,
) { ) {
await invoke<BuyXmrArgs, BuyXmrResponse>( const address_pool: LabeledMoneroAddress[] = [];
"buy_xmr", if (donation_percentage !== false) {
bitcoin_change_address == null const donation_address = isTestnet()
? { ? DONATION_ADDRESS_STAGENET
seller: providerToConcatenatedMultiAddr(seller), : DONATION_ADDRESS_MAINNET;
monero_receive_address,
} address_pool.push(
: { {
seller: providerToConcatenatedMultiAddr(seller), address: monero_receive_address,
monero_receive_address, percentage: 100 - donation_percentage * 100,
bitcoin_change_address, label: "Your wallet",
}, },
); {
address: donation_address,
percentage: donation_percentage * 100,
label: "Tip to the developers",
},
);
} else {
address_pool.push({
address: monero_receive_address,
percentage: 100,
label: "Your wallet",
});
}
await invoke<BuyXmrArgs, BuyXmrResponse>("buy_xmr", {
seller: providerToConcatenatedMultiAddr(seller),
monero_receive_pool: address_pool,
bitcoin_change_address,
});
} }
export async function resumeSwap(swapId: string) { export async function resumeSwap(swapId: string) {

View file

@ -1,6 +1,8 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Theme } from "renderer/components/theme"; import { Theme } from "renderer/components/theme";
export type DonateToDevelopmentTip = false | 0.0005 | 0.0075;
const DEFAULT_RENDEZVOUS_POINTS = [ const DEFAULT_RENDEZVOUS_POINTS = [
"/dns4/discover.unstoppableswap.net/tcp/8888/p2p/12D3KooWA6cnqJpVnreBVnoro8midDL9Lpzmg8oJPoAGi7YYaamE", "/dns4/discover.unstoppableswap.net/tcp/8888/p2p/12D3KooWA6cnqJpVnreBVnoro8midDL9Lpzmg8oJPoAGi7YYaamE",
"/dns4/discover2.unstoppableswap.net/tcp/8888/p2p/12D3KooWGRvf7qVQDrNR5nfYD6rKrbgeTi9x8RrbdxbmsPvxL4mw", "/dns4/discover2.unstoppableswap.net/tcp/8888/p2p/12D3KooWGRvf7qVQDrNR5nfYD6rKrbgeTi9x8RrbdxbmsPvxL4mw",
@ -22,6 +24,9 @@ export interface SettingsState {
userHasSeenIntroduction: boolean; userHasSeenIntroduction: boolean;
/// List of rendezvous points /// List of rendezvous points
rendezvousPoints: string[]; rendezvousPoints: string[];
/// Does the user want to donate parts of his swaps to funding the development
/// of the project?
donateToDevelopment: DonateToDevelopmentTip;
} }
export enum FiatCurrency { export enum FiatCurrency {
@ -124,6 +129,7 @@ const initialState: SettingsState = {
useMoneroRpcPool: true, // Default to using RPC pool useMoneroRpcPool: true, // Default to using RPC pool
userHasSeenIntroduction: false, userHasSeenIntroduction: false,
rendezvousPoints: DEFAULT_RENDEZVOUS_POINTS, rendezvousPoints: DEFAULT_RENDEZVOUS_POINTS,
donateToDevelopment: false, // Default to no donation
}; };
const alertsSlice = createSlice({ const alertsSlice = createSlice({
@ -212,6 +218,12 @@ const alertsSlice = createSlice({
setUseMoneroRpcPool(slice, action: PayloadAction<boolean>) { setUseMoneroRpcPool(slice, action: PayloadAction<boolean>) {
slice.useMoneroRpcPool = action.payload; slice.useMoneroRpcPool = action.payload;
}, },
setDonateToDevelopment(
slice,
action: PayloadAction<DonateToDevelopmentTip>,
) {
slice.donateToDevelopment = action.payload;
},
}, },
}); });
@ -228,6 +240,7 @@ export const {
setUserHasSeenIntroduction, setUserHasSeenIntroduction,
addRendezvousPoint, addRendezvousPoint,
removeRendezvousPoint, removeRendezvousPoint,
setDonateToDevelopment,
} = alertsSlice.actions; } = alertsSlice.actions;
export default alertsSlice.reducer; export default alertsSlice.reducer;

1
swap/.gitignore vendored
View file

@ -1,2 +1 @@
tempdb tempdb
.sqlx

View file

@ -12,9 +12,7 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [ "nullable": [false]
false
]
}, },
"hash": "081c729a0f1ad6e4ff3e13d6702c946bc4d37d50f40670b4f51d2efcce595aa6" "hash": "081c729a0f1ad6e4ff3e13d6702c946bc4d37d50f40670b4f51d2efcce595aa6"
} }

View file

@ -12,9 +12,7 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [ "nullable": [true]
true
]
}, },
"hash": "0d465a17ebbb5761421def759c73cad023c30705d5b41a1399ef79d8d2571d7c" "hash": "0d465a17ebbb5761421def759c73cad023c30705d5b41a1399ef79d8d2571d7c"
} }

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n insert into monero_addresses (\n swap_id,\n address\n ) values (?, ?);\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "50a5764546f69c118fa0b64120da50f51073d36257d49768de99ff863e3511e0"
}

View file

@ -17,10 +17,7 @@
"parameters": { "parameters": {
"Right": 0 "Right": 0
}, },
"nullable": [ "nullable": [true, true]
true,
true
]
}, },
"hash": "5cc61dd0315571bc198401a354cd9431ee68360941f341386cbacf44ea598de8" "hash": "5cc61dd0315571bc198401a354cd9431ee68360941f341386cbacf44ea598de8"
} }

View file

@ -17,10 +17,7 @@
"parameters": { "parameters": {
"Right": 0 "Right": 0
}, },
"nullable": [ "nullable": [false, false]
false,
false
]
}, },
"hash": "6130b6cdd184181f890964eb460741f5cf23b5237fb676faed009106627a4ca6" "hash": "6130b6cdd184181f890964eb460741f5cf23b5237fb676faed009106627a4ca6"
} }

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n insert into monero_addresses (\n swap_id,\n address,\n percentage,\n label\n ) values (?, ?, ?, ?);\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "7c37de52b3bb2ccd0868ccb861127416848d85eaebe8245c58d5beac7d537087"
}

View file

@ -12,9 +12,7 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [ "nullable": [false]
false
]
}, },
"hash": "88f761a4f7a0429cad1df0b1bebb1c0a27b2a45656549b23076d7542cfa21ecf" "hash": "88f761a4f7a0429cad1df0b1bebb1c0a27b2a45656549b23076d7542cfa21ecf"
} }

View file

@ -12,9 +12,7 @@
"parameters": { "parameters": {
"Right": 0 "Right": 0
}, },
"nullable": [ "nullable": [false]
false
]
}, },
"hash": "98a8b7f4971e0eb4ab8f5aa688aa22e7fdc6b925de211f7784782f051c2dcd8c" "hash": "98a8b7f4971e0eb4ab8f5aa688aa22e7fdc6b925de211f7784782f051c2dcd8c"
} }

View file

@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT address\n FROM monero_addresses\n WHERE swap_id = ?\n ",
"describe": {
"columns": [
{
"name": "address",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "ce270dd4a4b9615695a79864240c5401e2122077365e5e5a19408c068c7f9454"
}

View file

@ -12,9 +12,7 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [ "nullable": [false]
false
]
}, },
"hash": "d78acba5eb8563826dd190e0886aa665aae3c6f1e312ee444e65df1c95afe8b2" "hash": "d78acba5eb8563826dd190e0886aa665aae3c6f1e312ee444e65df1c95afe8b2"
} }

View file

@ -0,0 +1,28 @@
{
"db_name": "SQLite",
"query": "\n SELECT address, percentage, label\n FROM monero_addresses\n WHERE swap_id = ?\n ",
"describe": {
"columns": [
{
"name": "address",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "percentage",
"ordinal": 1,
"type_info": "Float"
},
{
"name": "label",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [false, false, false]
},
"hash": "dff8b986c3dde27b8121775e48a58564fa346b038866699210a63f8a33b03f0b"
}

View file

@ -12,9 +12,7 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [ "nullable": [false]
false
]
}, },
"hash": "e05620f420f8c1022971eeb66a803323a8cf258cbebb2834e3f7cf8f812fa646" "hash": "e05620f420f8c1022971eeb66a803323a8cf258cbebb2834e3f7cf8f812fa646"
} }

View file

@ -12,9 +12,7 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [ "nullable": [false]
false
]
}, },
"hash": "e9d422daf774d099fcbde6c4cda35821da948bd86cc57798b4d8375baf0b51ae" "hash": "e9d422daf774d099fcbde6c4cda35821da948bd86cc57798b4d8375baf0b51ae"
} }

View file

@ -0,0 +1,29 @@
-- The user can now have multiple monero receive addresses
-- for a single swap
-- Each address has a percentage (0 to 1) of the amount they'll receive of the total of the swap amount
-- The sum of the percentages must for a single swap MUST be 1
-- Add percentage column with default value of 1.0
ALTER TABLE monero_addresses ADD COLUMN percentage REAL NOT NULL DEFAULT 1.0;
-- SQLite doesn't support dropping PRIMARY KEY constraint directly
-- We need to recreate the table without the PRIMARY KEY on swap_id
CREATE TABLE monero_addresses_temp
(
swap_id TEXT NOT NULL,
address TEXT NOT NULL,
percentage REAL NOT NULL DEFAULT 1.0,
label TEXT NOT NULL DEFAULT 'user address'
);
-- Copy data from the original table
INSERT INTO monero_addresses_temp (swap_id, address, percentage)
SELECT swap_id, address, percentage FROM monero_addresses;
-- Drop the original table
DROP TABLE monero_addresses;
-- Rename the temporary table
ALTER TABLE monero_addresses_temp RENAME TO monero_addresses;
-- Create an index on swap_id for performance
CREATE INDEX idx_monero_addresses_swap_id ON monero_addresses(swap_id);

View file

@ -190,6 +190,7 @@ pub struct Context {
bitcoin_wallet: Option<Arc<bitcoin::Wallet>>, bitcoin_wallet: Option<Arc<bitcoin::Wallet>>,
monero_manager: Option<Arc<monero::Wallets>>, monero_manager: Option<Arc<monero::Wallets>>,
tor_client: Option<Arc<TorClient<TokioRustlsRuntime>>>, tor_client: Option<Arc<TorClient<TokioRustlsRuntime>>>,
#[allow(dead_code)]
monero_rpc_pool_handle: Option<Arc<monero_rpc_pool::PoolHandle>>, monero_rpc_pool_handle: Option<Arc<monero_rpc_pool::PoolHandle>>,
} }

View file

@ -7,6 +7,7 @@ use crate::cli::{list_sellers as list_sellers_impl, EventLoop, SellerStatus};
use crate::common::{get_logs, redact}; use crate::common::{get_logs, redact};
use crate::libp2p_ext::MultiAddrExt; use crate::libp2p_ext::MultiAddrExt;
use crate::monero::wallet_rpc::MoneroDaemon; use crate::monero::wallet_rpc::MoneroDaemon;
use crate::monero::MoneroAddressPool;
use crate::network::quote::{BidQuote, ZeroQuoteReceived}; use crate::network::quote::{BidQuote, ZeroQuoteReceived};
use crate::network::swarm; use crate::network::swarm;
use crate::protocol::bob::{BobState, Swap}; use crate::protocol::bob::{BobState, Swap};
@ -59,8 +60,7 @@ pub struct BuyXmrArgs {
pub seller: Multiaddr, pub seller: Multiaddr,
#[typeshare(serialized_as = "Option<string>")] #[typeshare(serialized_as = "Option<string>")]
pub bitcoin_change_address: Option<bitcoin::Address<NetworkUnchecked>>, pub bitcoin_change_address: Option<bitcoin::Address<NetworkUnchecked>>,
#[typeshare(serialized_as = "string")] pub monero_receive_pool: MoneroAddressPool,
pub monero_receive_address: monero::Address,
} }
#[typeshare] #[typeshare]
@ -231,6 +231,7 @@ pub struct GetSwapInfoResponse {
pub cancel_timelock: CancelTimelock, pub cancel_timelock: CancelTimelock,
pub punish_timelock: PunishTimelock, pub punish_timelock: PunishTimelock,
pub timelock: Option<ExpiredTimelocks>, pub timelock: Option<ExpiredTimelocks>,
pub monero_receive_pool: MoneroAddressPool,
} }
impl Request for GetSwapInfoArgs { impl Request for GetSwapInfoArgs {
@ -559,6 +560,8 @@ pub async fn get_swap_info(
let timelock = swap_state.expired_timelocks(bitcoin_wallet.clone()).await?; let timelock = swap_state.expired_timelocks(bitcoin_wallet.clone()).await?;
let monero_receive_pool = context.db.get_monero_address_pool(args.swap_id).await?;
Ok(GetSwapInfoResponse { Ok(GetSwapInfoResponse {
swap_id: args.swap_id, swap_id: args.swap_id,
seller: AliceAddress { seller: AliceAddress {
@ -578,6 +581,7 @@ pub async fn get_swap_info(
cancel_timelock, cancel_timelock,
punish_timelock, punish_timelock,
timelock, timelock,
monero_receive_pool,
}) })
} }
@ -590,9 +594,11 @@ pub async fn buy_xmr(
let BuyXmrArgs { let BuyXmrArgs {
seller, seller,
bitcoin_change_address, bitcoin_change_address,
monero_receive_address, monero_receive_pool,
} = buy_xmr; } = buy_xmr;
monero_receive_pool.assert_network(context.config.env_config.monero_network)?;
let bitcoin_wallet = Arc::clone( let bitcoin_wallet = Arc::clone(
context context
.bitcoin_wallet .bitcoin_wallet
@ -653,7 +659,7 @@ pub async fn buy_xmr(
context context
.db .db
.insert_monero_address(swap_id, monero_receive_address) .insert_monero_address_pool(swap_id, monero_receive_pool.clone())
.await?; .await?;
tracing::debug!(peer_id = %swarm.local_peer_id(), "Network layer initialized"); tracing::debug!(peer_id = %swarm.local_peer_id(), "Network layer initialized");
@ -769,7 +775,7 @@ pub async fn buy_xmr(
monero_wallet, monero_wallet,
env_config, env_config,
event_loop_handle, event_loop_handle,
monero_receive_address, monero_receive_pool.clone(),
bitcoin_change_address, bitcoin_change_address,
tx_lock_amount, tx_lock_amount,
tx_lock_fee tx_lock_fee
@ -847,7 +853,7 @@ pub async fn resume_swap(
let (event_loop, event_loop_handle) = let (event_loop, event_loop_handle) =
EventLoop::new(swap_id, swarm, seller_peer_id, context.db.clone())?; EventLoop::new(swap_id, swarm, seller_peer_id, context.db.clone())?;
let monero_receive_address = context.db.get_monero_address(swap_id).await?; let monero_receive_pool = context.db.get_monero_address_pool(swap_id).await?;
let swap = Swap::from_db( let swap = Swap::from_db(
Arc::clone(&context.db), Arc::clone(&context.db),
@ -865,7 +871,7 @@ pub async fn resume_swap(
.clone(), .clone(),
context.config.env_config, context.config.env_config,
event_loop_handle, event_loop_handle,
monero_receive_address, monero_receive_pool,
) )
.await? .await?
.with_event_emitter(context.tauri_handle.clone()); .with_event_emitter(context.tauri_handle.clone());

View file

@ -1,5 +1,6 @@
use super::request::BalanceResponse; use super::request::BalanceResponse;
use crate::bitcoin; use crate::bitcoin;
use crate::monero::MoneroAddressPool;
use crate::{bitcoin::ExpiredTimelocks, monero, network::quote::BidQuote}; use crate::{bitcoin::ExpiredTimelocks, monero, network::quote::BidQuote};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use bitcoin::Txid; use bitcoin::Txid;
@ -43,6 +44,7 @@ pub struct LockBitcoinDetails {
pub btc_network_fee: bitcoin::Amount, pub btc_network_fee: bitcoin::Amount,
#[typeshare(serialized_as = "number")] #[typeshare(serialized_as = "number")]
pub xmr_receive_amount: monero::Amount, pub xmr_receive_amount: monero::Amount,
pub monero_receive_pool: MoneroAddressPool,
#[typeshare(serialized_as = "string")] #[typeshare(serialized_as = "string")]
pub swap_id: Uuid, pub swap_id: Uuid,
} }
@ -629,8 +631,7 @@ pub enum TauriSwapProgressEvent {
XmrRedeemInMempool { XmrRedeemInMempool {
#[typeshare(serialized_as = "Vec<string>")] #[typeshare(serialized_as = "Vec<string>")]
xmr_redeem_txids: Vec<monero::TxHash>, xmr_redeem_txids: Vec<monero::TxHash>,
#[typeshare(serialized_as = "string")] xmr_receive_pool: MoneroAddressPool,
xmr_redeem_address: monero::Address,
}, },
CancelTimelockExpired, CancelTimelockExpired,
BtcCancelled { BtcCancelled {

View file

@ -4,8 +4,8 @@ use crate::cli::api::request::{
GetHistoryArgs, ListSellersArgs, MoneroRecoveryArgs, Request, ResumeSwapArgs, WithdrawBtcArgs, GetHistoryArgs, ListSellersArgs, MoneroRecoveryArgs, Request, ResumeSwapArgs, WithdrawBtcArgs,
}; };
use crate::cli::api::Context; use crate::cli::api::Context;
use crate::monero;
use crate::monero::monero_address; use crate::monero::monero_address;
use crate::monero::{self, MoneroAddressPool};
use anyhow::Result; use anyhow::Result;
use bitcoin::address::NetworkUnchecked; use bitcoin::address::NetworkUnchecked;
use libp2p::core::Multiaddr; use libp2p::core::Multiaddr;
@ -68,8 +68,8 @@ where
monero_receive_address, monero_receive_address,
tor, tor,
} => { } => {
let monero_receive_address = let monero_receive_pool: MoneroAddressPool =
monero_address::validate_is_testnet(monero_receive_address, is_testnet)?; monero_address::validate_is_testnet(monero_receive_address, is_testnet)?.into();
let bitcoin_change_address = bitcoin_change_address let bitcoin_change_address = bitcoin_change_address
.map(|address| bitcoin_address::validate(address, is_testnet)) .map(|address| bitcoin_address::validate(address, is_testnet))
@ -91,7 +91,7 @@ where
BuyXmrArgs { BuyXmrArgs {
seller, seller,
bitcoin_change_address, bitcoin_change_address,
monero_receive_address, monero_receive_pool,
} }
.request(context.clone()) .request(context.clone())
.await?; .await?;

View file

@ -1,11 +1,15 @@
use crate::cli::api::tauri_bindings::TauriEmitter; use crate::cli::api::tauri_bindings::TauriEmitter;
use crate::cli::api::tauri_bindings::TauriHandle; use crate::cli::api::tauri_bindings::TauriHandle;
use crate::database::Swap; use crate::database::Swap;
use crate::monero::{Address, TransferProof}; use crate::monero::LabeledMoneroAddress;
use crate::monero::MoneroAddressPool;
use crate::monero::TransferProof;
use crate::protocol::{Database, State}; use crate::protocol::{Database, State};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use libp2p::{Multiaddr, PeerId}; use libp2p::{Multiaddr, PeerId};
use rust_decimal::prelude::{FromPrimitive, ToPrimitive};
use rust_decimal::Decimal;
use sqlx::sqlite::{Sqlite, SqliteConnectOptions}; use sqlx::sqlite::{Sqlite, SqliteConnectOptions};
use sqlx::{ConnectOptions, Pool, SqlitePool}; use sqlx::{ConnectOptions, Pool, SqlitePool};
use std::path::Path; use std::path::Path;
@ -96,43 +100,76 @@ impl Database for SqliteDatabase {
Ok(peer_id) Ok(peer_id)
} }
async fn insert_monero_address(&self, swap_id: Uuid, address: Address) -> Result<()> { async fn insert_monero_address_pool(
&self,
swap_id: Uuid,
address: MoneroAddressPool,
) -> Result<()> {
let swap_id = swap_id.to_string(); let swap_id = swap_id.to_string();
let address = address.to_string();
sqlx::query!( for labeled_address in address.iter() {
r#" let address_str = labeled_address.address().to_string();
insert into monero_addresses ( let percentage_f64 = labeled_address
swap_id, .percentage()
address .to_f64()
) values (?, ?); .expect("Decimal should convert to f64");
"#, let label_str = labeled_address.label();
swap_id,
address sqlx::query!(
) r#"
.execute(&self.pool) insert into monero_addresses (
.await?; swap_id,
address,
percentage,
label
) values (?, ?, ?, ?);
"#,
swap_id,
address_str,
percentage_f64,
label_str
)
.execute(&self.pool)
.await?;
}
Ok(()) Ok(())
} }
async fn get_monero_address(&self, swap_id: Uuid) -> Result<Address> { async fn get_monero_address_pool(&self, swap_id: Uuid) -> Result<MoneroAddressPool> {
let swap_id = swap_id.to_string(); let swap_id = swap_id.to_string();
let row = sqlx::query!( let row = sqlx::query!(
r#" r#"
SELECT address SELECT address, percentage, label
FROM monero_addresses FROM monero_addresses
WHERE swap_id = ? WHERE swap_id = ?
"#, "#,
swap_id swap_id
) )
.fetch_one(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
let address = row.address.parse()?; if row.is_empty() {
return Err(anyhow!(
"No Monero address pool found for swap ID: {}",
swap_id
));
}
Ok(address) let addresses = row
.iter()
.map(|row| -> Result<LabeledMoneroAddress> {
let address = row.address.parse()?;
let percentage = Decimal::from_f64(row.percentage).expect("Invalid percentage");
let label = row.label.clone();
LabeledMoneroAddress::new(address, percentage, label)
.map_err(|e| anyhow::anyhow!("Invalid percentage in database: {}", e))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(MoneroAddressPool::new(addresses))
} }
async fn get_monero_addresses(&self) -> Result<Vec<monero::Address>> { async fn get_monero_addresses(&self) -> Result<Vec<monero::Address>> {
@ -484,17 +521,55 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn test_insert_load_monero_address() -> Result<()> { async fn test_insert_and_load_monero_address_pool() -> Result<()> {
use crate::monero::{LabeledMoneroAddress, MoneroAddressPool};
use rust_decimal::Decimal;
let db = setup_test_db().await?; let db = setup_test_db().await?;
let swap_id = Uuid::new_v4(); let swap_id = Uuid::new_v4();
let monero_address = "53gEuGZUhP9JMEBZoGaFNzhwEgiG7hwQdMCqFxiyiTeFPmkbt1mAoNybEUvYBKHcnrSgxnVWgZsTvRBaHBNXPa8tHiCU51a".parse()?;
db.insert_monero_address(swap_id, monero_address).await?; // Create multiple labeled addresses with valid percentages that sum to 1
let address1 = "53gEuGZUhP9JMEBZoGaFNzhwEgiG7hwQdMCqFxiyiTeFPmkbt1mAoNybEUvYBKHcnrSgxnVWgZsTvRBaHBNXPa8tHiCU51a".parse()?; // Stagenet address
let address2 = "44Ato7HveWidJYUAVw5QffEcEtSH1DwzSP3FPPkHxNAS4LX9CqgucphTisH978FLHE34YNEx7FcbBfQLQUU8m3NUC4VqsRa".parse()?; // Mainnet address
let address3 = "53gEuGZUhP9JMEBZoGaFNzhwEgiG7hwQdMCqFxiyiTeFPmkbt1mAoNybEUvYBKHcnrSgxnVWgZsTvRBaHBNXPa8tHiCU51a".parse()?; // Same as address1 for simplicity
let loaded_monero_address = db.get_monero_address(swap_id).await?; let labeled_addresses = vec![
LabeledMoneroAddress::new(address1, Decimal::new(5, 1), "Primary".to_string())
.map_err(|e| anyhow!(e))?, // 0.5
LabeledMoneroAddress::new(address2, Decimal::new(3, 1), "Secondary".to_string())
.map_err(|e| anyhow!(e))?, // 0.3
LabeledMoneroAddress::new(address3, Decimal::new(2, 1), "Tertiary".to_string())
.map_err(|e| anyhow!(e))?, // 0.2
];
assert_eq!(monero_address, loaded_monero_address); let address_pool = MoneroAddressPool::new(labeled_addresses);
// Insert the address pool
db.insert_monero_address_pool(swap_id, address_pool.clone())
.await?;
// Load the address pool back
let loaded_address_pool = db.get_monero_address_pool(swap_id).await?;
// Verify they are equal
assert_eq!(address_pool.addresses(), loaded_address_pool.addresses());
assert_eq!(
address_pool.percentages(),
loaded_address_pool.percentages()
);
// Verify each labeled address individually
let original_addresses: Vec<_> = address_pool.iter().collect();
let loaded_addresses: Vec<_> = loaded_address_pool.iter().collect();
assert_eq!(original_addresses.len(), loaded_addresses.len());
for (orig, loaded) in original_addresses.iter().zip(loaded_addresses.iter()) {
assert_eq!(orig.address(), loaded.address());
assert_eq!(orig.percentage(), loaded.percentage());
assert_eq!(orig.label(), loaded.label());
}
Ok(()) Ok(())
} }

View file

@ -220,6 +220,142 @@ impl Amount {
} }
} }
/// A Monero address with an associated percentage and human-readable label.
///
/// This structure represents a destination address for Monero transactions
/// along with the percentage of funds it should receive and a descriptive label.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[typeshare]
pub struct LabeledMoneroAddress {
#[typeshare(serialized_as = "string")]
address: monero::Address,
#[typeshare(serialized_as = "number")]
percentage: Decimal,
label: String,
}
impl LabeledMoneroAddress {
/// Creates a new labeled Monero address.
///
/// # Arguments
///
/// * `address` - The Monero address
/// * `percentage` - The percentage of funds (between 0.0 and 1.0)
/// * `label` - A human-readable label for this address
///
/// # Errors
///
/// Returns an error if the percentage is not between 0.0 and 1.0 inclusive.
pub fn new(
address: monero::Address,
percentage: Decimal,
label: String,
) -> Result<Self, String> {
if percentage < Decimal::ZERO || percentage > Decimal::ONE {
return Err(format!(
"Percentage must be between 0 and 1 inclusive, got: {}",
percentage
));
}
Ok(Self {
address,
percentage,
label,
})
}
/// Returns the Monero address.
pub fn address(&self) -> monero::Address {
self.address
}
/// Returns the percentage as a decimal.
pub fn percentage(&self) -> Decimal {
self.percentage
}
/// Returns the human-readable label.
pub fn label(&self) -> &str {
&self.label
}
}
/// A collection of labeled Monero addresses that can receive funds in a transaction.
///
/// This structure manages multiple destination addresses with their associated
/// percentages and labels. It's used for splitting Monero transactions across
/// multiple recipients, such as for donations or multi-destination swaps.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[typeshare]
pub struct MoneroAddressPool(Vec<LabeledMoneroAddress>);
use rust_decimal::prelude::ToPrimitive;
impl MoneroAddressPool {
/// Creates a new address pool from a vector of labeled addresses.
///
/// # Arguments
///
/// * `addresses` - Vector of labeled Monero addresses
pub fn new(addresses: Vec<LabeledMoneroAddress>) -> Self {
Self(addresses)
}
/// Returns a vector of all Monero addresses in the pool.
pub fn addresses(&self) -> Vec<monero::Address> {
self.0.iter().map(|address| address.address()).collect()
}
/// Returns a vector of all percentages as f64 values.
pub fn percentages(&self) -> Vec<f64> {
self.0
.iter()
.map(|address| {
address
.percentage()
.to_f64()
.expect("Decimal should convert to f64")
})
.collect()
}
/// Returns an iterator over the labeled addresses.
pub fn iter(&self) -> impl Iterator<Item = &LabeledMoneroAddress> {
self.0.iter()
}
/// Validates that all addresses in the pool are on the expected network.
///
/// # Arguments
///
/// * `network` - The expected Monero network
///
/// # Errors
///
/// Returns an error if any address is on a different network than expected.
pub fn assert_network(&self, network: Network) -> Result<()> {
for address in self.0.iter() {
if address.address().network != network {
bail!("Address pool contains addresses on the wrong network (address {} is on {:?}, expected {:?})", address.address(), address.address().network, network);
}
}
Ok(())
}
}
impl From<::monero::Address> for MoneroAddressPool {
fn from(address: ::monero::Address) -> Self {
Self(vec![LabeledMoneroAddress::new(
address,
Decimal::from(1),
"user address".to_string(),
)
.expect("Percentage 1 is always valid")])
}
}
impl Add for Amount { impl Add for Amount {
type Output = Amount; type Output = Amount;
@ -760,4 +896,27 @@ mod tests {
let min_balance = large_amount.min_conservative_balance_to_spend(); let min_balance = large_amount.min_conservative_balance_to_spend();
assert!(min_balance > large_amount); assert!(min_balance > large_amount);
} }
#[test]
fn labeled_monero_address_percentage_validation() {
use rust_decimal::Decimal;
let address = "53gEuGZUhP9JMEBZoGaFNzhwEgiG7hwQdMCqFxiyiTeFPmkbt1mAoNybEUvYBKHcnrSgxnVWgZsTvRBaHBNXPa8tHiCU51a".parse().unwrap();
// Valid percentages should work
assert!(LabeledMoneroAddress::new(address, Decimal::ZERO, "test".to_string()).is_ok());
assert!(LabeledMoneroAddress::new(address, Decimal::ONE, "test".to_string()).is_ok());
assert!(LabeledMoneroAddress::new(address, Decimal::new(5, 1), "test".to_string()).is_ok()); // 0.5
// Invalid percentages should fail
assert!(
LabeledMoneroAddress::new(address, Decimal::new(-1, 0), "test".to_string()).is_err()
);
assert!(
LabeledMoneroAddress::new(address, Decimal::new(11, 1), "test".to_string()).is_err()
); // 1.1
assert!(
LabeledMoneroAddress::new(address, Decimal::new(2, 0), "test".to_string()).is_err()
); // 2.0
}
} }

View file

@ -1,3 +1,4 @@
use crate::monero::MoneroAddressPool;
use crate::protocol::alice::swap::is_complete as alice_is_complete; use crate::protocol::alice::swap::is_complete as alice_is_complete;
use crate::protocol::alice::AliceState; use crate::protocol::alice::AliceState;
use crate::protocol::bob::swap::is_complete as bob_is_complete; use crate::protocol::bob::swap::is_complete as bob_is_complete;
@ -139,8 +140,12 @@ impl TryInto<AliceState> for State {
pub trait Database { pub trait Database {
async fn insert_peer_id(&self, swap_id: Uuid, peer_id: PeerId) -> Result<()>; async fn insert_peer_id(&self, swap_id: Uuid, peer_id: PeerId) -> Result<()>;
async fn get_peer_id(&self, swap_id: Uuid) -> Result<PeerId>; async fn get_peer_id(&self, swap_id: Uuid) -> Result<PeerId>;
async fn insert_monero_address(&self, swap_id: Uuid, address: monero::Address) -> Result<()>; async fn insert_monero_address_pool(
async fn get_monero_address(&self, swap_id: Uuid) -> Result<monero::Address>; &self,
swap_id: Uuid,
address: MoneroAddressPool,
) -> Result<()>;
async fn get_monero_address_pool(&self, swap_id: Uuid) -> Result<MoneroAddressPool>;
async fn get_monero_addresses(&self) -> Result<Vec<monero::Address>>; async fn get_monero_addresses(&self) -> Result<Vec<monero::Address>>;
async fn insert_address(&self, peer_id: PeerId, address: Multiaddr) -> Result<()>; async fn insert_address(&self, peer_id: PeerId, address: Multiaddr) -> Result<()>;
async fn get_addresses(&self, peer_id: PeerId) -> Result<Vec<Multiaddr>>; async fn get_addresses(&self, peer_id: PeerId) -> Result<Vec<Multiaddr>>;

View file

@ -4,6 +4,7 @@ use anyhow::Result;
use uuid::Uuid; use uuid::Uuid;
use crate::cli::api::tauri_bindings::TauriHandle; use crate::cli::api::tauri_bindings::TauriHandle;
use crate::monero::MoneroAddressPool;
use crate::protocol::Database; use crate::protocol::Database;
use crate::{bitcoin, cli, env, monero}; use crate::{bitcoin, cli, env, monero};
@ -22,7 +23,7 @@ pub struct Swap {
pub monero_wallet: Arc<monero::Wallets>, pub monero_wallet: Arc<monero::Wallets>,
pub env_config: env::Config, pub env_config: env::Config,
pub id: Uuid, pub id: Uuid,
pub monero_receive_address: monero::Address, pub monero_receive_pool: MoneroAddressPool,
pub event_emitter: Option<TauriHandle>, pub event_emitter: Option<TauriHandle>,
} }
@ -35,7 +36,7 @@ impl Swap {
monero_wallet: Arc<monero::Wallets>, monero_wallet: Arc<monero::Wallets>,
env_config: env::Config, env_config: env::Config,
event_loop_handle: cli::EventLoopHandle, event_loop_handle: cli::EventLoopHandle,
monero_receive_address: monero::Address, monero_receive_pool: MoneroAddressPool,
bitcoin_change_address: bitcoin::Address, bitcoin_change_address: bitcoin::Address,
btc_amount: bitcoin::Amount, btc_amount: bitcoin::Amount,
tx_lock_fee: bitcoin::Amount, tx_lock_fee: bitcoin::Amount,
@ -52,7 +53,7 @@ impl Swap {
monero_wallet, monero_wallet,
env_config, env_config,
id, id,
monero_receive_address, monero_receive_pool,
event_emitter: None, event_emitter: None,
} }
} }
@ -65,7 +66,7 @@ impl Swap {
monero_wallet: Arc<monero::Wallets>, monero_wallet: Arc<monero::Wallets>,
env_config: env::Config, env_config: env::Config,
event_loop_handle: cli::EventLoopHandle, event_loop_handle: cli::EventLoopHandle,
monero_receive_address: monero::Address, monero_receive_pool: MoneroAddressPool,
) -> Result<Self> { ) -> Result<Self> {
let state = db.get_state(id).await?.try_into()?; let state = db.get_state(id).await?.try_into()?;
@ -77,7 +78,7 @@ impl Swap {
monero_wallet, monero_wallet,
env_config, env_config,
id, id,
monero_receive_address, monero_receive_pool,
event_emitter: None, event_emitter: None,
}) })
} }

View file

@ -5,7 +5,7 @@ use crate::bitcoin::{
TxLock, Txid, Wallet, TxLock, Txid, Wallet,
}; };
use crate::monero::wallet::WatchRequest; use crate::monero::wallet::WatchRequest;
use crate::monero::{self, TxHash}; use crate::monero::{self, MoneroAddressPool, TxHash};
use crate::monero::{monero_private_key, TransferProof}; use crate::monero::{monero_private_key, TransferProof};
use crate::monero_ext::ScalarExt; use crate::monero_ext::ScalarExt;
use crate::protocol::{Message0, Message1, Message2, Message3, Message4, CROSS_CURVE_PROOF_SYSTEM}; use crate::protocol::{Message0, Message1, Message2, Message3, Message4, CROSS_CURVE_PROOF_SYSTEM};
@ -717,7 +717,7 @@ impl State5 {
&self, &self,
monero_wallet: &monero::Wallets, monero_wallet: &monero::Wallets,
swap_id: Uuid, swap_id: Uuid,
monero_receive_address: monero::Address, monero_receive_pool: MoneroAddressPool,
) -> Result<Vec<TxHash>> { ) -> Result<Vec<TxHash>> {
let (spend_key, view_key) = self.xmr_keys(); let (spend_key, view_key) = self.xmr_keys();
@ -742,15 +742,17 @@ impl State5 {
.await .await
.context("Couldn't get Monero blockheight")?; .context("Couldn't get Monero blockheight")?;
tracing::debug!(%swap_id, receive_address=%monero_receive_address, "Sweeping Monero to receive address"); tracing::debug!(%swap_id, receive_address=?monero_receive_pool, "Sweeping Monero to receive address");
let tx_hashes = wallet let tx_hashes = wallet
.clone() .sweep_multi(
.sweep(&monero_receive_address.clone()) &monero_receive_pool.addresses(),
&monero_receive_pool.percentages(),
)
.await .await
.context("Failed to redeem Monero")? .context("Failed to redeem Monero")?
.into_iter() .into_iter()
.map(TxHash) .map(|tx_receipt| TxHash(tx_receipt.txid))
.collect(); .collect();
tracing::info!(%swap_id, txids=?tx_hashes, "Monero sweep completed"); tracing::info!(%swap_id, txids=?tx_hashes, "Monero sweep completed");

View file

@ -6,6 +6,7 @@ use crate::cli::api::tauri_bindings::{
}; };
use crate::cli::EventLoopHandle; use crate::cli::EventLoopHandle;
use crate::common::retry; use crate::common::retry;
use crate::monero::MoneroAddressPool;
use crate::network::cooperative_xmr_redeem_after_punish::Response::{Fullfilled, Rejected}; use crate::network::cooperative_xmr_redeem_after_punish::Response::{Fullfilled, Rejected};
use crate::network::swap_setup::bob::NewSwap; use crate::network::swap_setup::bob::NewSwap;
use crate::protocol::bob::state::*; use crate::protocol::bob::state::*;
@ -17,7 +18,7 @@ use std::time::Duration;
use tokio::select; use tokio::select;
use uuid::Uuid; use uuid::Uuid;
const PRE_BTC_LOCK_APPROVAL_TIMEOUT_SECS: u64 = 120; const PRE_BTC_LOCK_APPROVAL_TIMEOUT_SECS: u64 = 60 * 3;
pub fn is_complete(state: &BobState) -> bool { pub fn is_complete(state: &BobState) -> bool {
matches!( matches!(
@ -75,7 +76,7 @@ pub async fn run_until(
swap.db.clone(), swap.db.clone(),
swap.bitcoin_wallet.as_ref(), swap.bitcoin_wallet.as_ref(),
swap.monero_wallet.clone(), swap.monero_wallet.clone(),
swap.monero_receive_address, swap.monero_receive_pool.clone(),
swap.event_emitter.clone(), swap.event_emitter.clone(),
) )
.await?; .await?;
@ -102,7 +103,7 @@ async fn next_state(
db: Arc<dyn Database + Send + Sync>, db: Arc<dyn Database + Send + Sync>,
bitcoin_wallet: &bitcoin::Wallet, bitcoin_wallet: &bitcoin::Wallet,
monero_wallet: Arc<monero::Wallets>, monero_wallet: Arc<monero::Wallets>,
monero_receive_address: monero::Address, monero_receive_pool: MoneroAddressPool,
event_emitter: Option<TauriHandle>, event_emitter: Option<TauriHandle>,
) -> Result<BobState> { ) -> Result<BobState> {
tracing::debug!(%state, "Advancing state"); tracing::debug!(%state, "Advancing state");
@ -167,6 +168,7 @@ async fn next_state(
btc_lock_amount, btc_lock_amount,
btc_network_fee, btc_network_fee,
xmr_receive_amount, xmr_receive_amount,
monero_receive_pool,
swap_id, swap_id,
}); });
@ -285,14 +287,34 @@ async fn next_state(
let transfer_proof_watcher = event_loop_handle.recv_transfer_proof(); let transfer_proof_watcher = event_loop_handle.recv_transfer_proof();
let cancel_timelock_expires = tx_lock_status.wait_until(|status| { let cancel_timelock_expires = tx_lock_status.wait_until(|status| {
// Emit a tauri event on new confirmations // Emit a tauri event on new confirmations
if let ScriptStatus::Confirmed(confirmed) = status { match status {
event_emitter.emit_swap_progress_event( ScriptStatus::Confirmed(confirmed) => {
swap_id, event_emitter.emit_swap_progress_event(
TauriSwapProgressEvent::BtcLockTxInMempool { swap_id,
btc_lock_txid: state3.tx_lock_id(), TauriSwapProgressEvent::BtcLockTxInMempool {
btc_lock_confirmations: Some(u64::from(confirmed.confirmations())), btc_lock_txid: state3.tx_lock_id(),
}, btc_lock_confirmations: Some(u64::from(confirmed.confirmations())),
); },
);
}
ScriptStatus::InMempool => {
event_emitter.emit_swap_progress_event(
swap_id,
TauriSwapProgressEvent::BtcLockTxInMempool {
btc_lock_txid: state3.tx_lock_id(),
btc_lock_confirmations: Some(0),
},
);
}
ScriptStatus::Unseen | ScriptStatus::Retrying => {
event_emitter.emit_swap_progress_event(
swap_id,
TauriSwapProgressEvent::BtcLockTxInMempool {
btc_lock_txid: state3.tx_lock_id(),
btc_lock_confirmations: None,
},
);
}
} }
// Stop when the cancel timelock expires // Stop when the cancel timelock expires
@ -518,7 +540,7 @@ async fn next_state(
"Refund Monero", "Refund Monero",
|| async { || async {
state state
.redeem_xmr(&monero_wallet, swap_id, monero_receive_address) .redeem_xmr(&monero_wallet, swap_id, monero_receive_pool.clone())
.await .await
.map_err(backoff::Error::transient) .map_err(backoff::Error::transient)
}, },
@ -532,7 +554,7 @@ async fn next_state(
swap_id, swap_id,
TauriSwapProgressEvent::XmrRedeemInMempool { TauriSwapProgressEvent::XmrRedeemInMempool {
xmr_redeem_txids, xmr_redeem_txids,
xmr_redeem_address: monero_receive_address, xmr_receive_pool: monero_receive_pool.clone(),
}, },
); );
@ -730,7 +752,7 @@ async fn next_state(
"Redeeming Monero", "Redeeming Monero",
|| async { || async {
state5 state5
.redeem_xmr(&monero_wallet, swap_id, monero_receive_address) .redeem_xmr(&monero_wallet, swap_id, monero_receive_pool.clone())
.await .await
.map_err(backoff::Error::transient) .map_err(backoff::Error::transient)
}, },
@ -745,7 +767,7 @@ async fn next_state(
swap_id, swap_id,
TauriSwapProgressEvent::XmrRedeemInMempool { TauriSwapProgressEvent::XmrRedeemInMempool {
xmr_redeem_txids, xmr_redeem_txids,
xmr_redeem_address: monero_receive_address, xmr_receive_pool: monero_receive_pool.clone(),
}, },
); );
@ -812,7 +834,7 @@ async fn next_state(
// We don't have the txids of the redeem transaction here, so we can't emit them // We don't have the txids of the redeem transaction here, so we can't emit them
// We return an empty array instead // We return an empty array instead
xmr_redeem_txids: vec![], xmr_redeem_txids: vec![],
xmr_redeem_address: monero_receive_address, xmr_receive_pool: monero_receive_pool.clone(),
}, },
); );
BobState::XmrRedeemed { tx_lock_id } BobState::XmrRedeemed { tx_lock_id }

Binary file not shown.

View file

@ -492,7 +492,12 @@ impl BobParams {
self.monero_wallet.clone(), self.monero_wallet.clone(),
self.env_config, self.env_config,
handle, handle,
self.monero_wallet.main_wallet().await.main_address().await, self.monero_wallet
.main_wallet()
.await
.main_address()
.await
.into(),
) )
.await?; .await?;
@ -524,7 +529,12 @@ impl BobParams {
self.monero_wallet.clone(), self.monero_wallet.clone(),
self.env_config, self.env_config,
handle, handle,
self.monero_wallet.main_wallet().await.main_address().await, self.monero_wallet
.main_wallet()
.await
.main_address()
.await
.into(),
self.bitcoin_wallet.new_address().await?, self.bitcoin_wallet.new_address().await?,
btc_amount, btc_amount,
bitcoin::Amount::from_sat(1000), // Fixed fee of 1000 satoshis for now bitcoin::Amount::from_sat(1000), // Fixed fee of 1000 satoshis for now