diff --git a/.cargo/config.toml b/.cargo/config.toml index e27705d7a3..9b0737155b 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -10,6 +10,9 @@ linker = "arm-linux-gnueabihf-gcc" [target.'cfg(all(windows, target_env = "msvc"))'] rustflags = ["-C", "link-arg=/STACK:8388608"] +[target.'cfg(macos)'] +rustflags = ["-C", "link-arg=-Wl,stack_size,41943040"] + # For the GNU/MinGW tool-chain [target.'cfg(all(windows, target_env = "gnu"))'] linker = "x86_64-w64-mingw32-g++" # Use g++ driver so -lstdc++ gets pulled in automatically diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97649afc23..08d072f790 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,14 +87,14 @@ jobs: - target: x86_64-pc-windows-gnu os: ubuntu-22.04 - runs-on: ${{ matrix.host }} + runs-on: ${{ matrix.os }} if: github.event_name == 'push' || !github.event.pull_request.draft steps: - uses: actions/checkout@v4 - name: Setup build environment (cli tools, dependencies) uses: ./.github/actions/setup-build-environment with: - host: ${{ matrix.host }} + host: ${{ matrix.os }} target: ${{ matrix.target }} - name: Build binary @@ -117,21 +117,21 @@ jobs: fail-fast: false matrix: os: [ubuntu-22.04, macos-15] - runs-on: ${{ matrix.host }} + runs-on: ${{ matrix.os }} if: github.event_name == 'push' || !github.event.pull_request.draft steps: - uses: actions/checkout@v4 - name: Setup build environment (cli tools, dependencies) uses: ./.github/actions/setup-build-environment with: - host: ${{ matrix.host }} + host: ${{ matrix.os }} - name: Run monero-harness tests - if: matrix.host == 'ubuntu-22.04' + if: matrix.os == 'ubuntu-22.04' run: cargo test --package monero-harness --all-features - - name: Run library tests for swap - run: cargo test --package swap --lib + - name: Run library tests + run: cargo test --lib docker_tests: strategy: @@ -182,6 +182,12 @@ jobs: test_name: alice_broken_wallet_rpc_after_started_btc_early_refund - package: swap test_name: happy_path_alice_does_not_send_transfer_proof + - package: swap + test_name: partial_refund_bob_claims_amnesty + - package: swap + test_name: partial_refund_alice_burns + - package: swap + test_name: partial_refund_alice_grants_final_amnesty - package: monero-tests test_name: reserve_proof - package: monero-tests diff --git a/.helix/ignore b/.helix/ignore index 47f73afa1a..a53623a692 100644 --- a/.helix/ignore +++ b/.helix/ignore @@ -1,2 +1,4 @@ src-tauri/gen/ +monero-sys/monero/ +monero-sys/monero-depends/ diff --git a/AGENT.md b/AGENT.md deleted file mode 100644 index 178c899058..0000000000 --- a/AGENT.md +++ /dev/null @@ -1,46 +0,0 @@ -# Repo Overview - -This repository hosts the core of the eigenwallet project. The code base is a Rust workspace with multiple crates and a Tauri based GUI. - -## Important directories - -- **swap/** – contains the main Rust crate with two binaries: - - `swap` – command line interface for performing swaps. - - `asb` – Automated Swap Backend for market makers. - It also hosts library code shared between the binaries and integration tests. -- **src-tauri/** – Rust crate that exposes the `swap` functionality to the Tauri front end and bundles the application. -- **src-gui/** – The front‑end written in TypeScript/React and bundled by Tauri. Communicates with `src-tauri` via Tauri commands. -- **monero-wallet/** – helper crates for interacting with the Monero ecosystem. -- **docs/** – Next.js documentation site. -- **dev-docs/** – additional markdown documentation for CLI and ASB. - -## Frequently edited files - -Looking at the latest ten pull requests in `git log`, the following files appear most often: - -| File | Times Changed | -| --------------------------- | ------------- | -| `src-tauri/Cargo.toml` | 7 | -| `Cargo.lock` | 7 | -| `CHANGELOG.md` | 7 | -| `swap/Cargo.toml` | 6 | -| `src-tauri/tauri.conf.json` | 5 | -| `.github/workflows/ci.yml` | 3 | - -Other files such as `swap/src/bin/asb.rs`, `swap/src/cli/api.rs`, and `src-gui/package.json` showed up less frequently. - -## Component interaction - -- The **swap** crate implements the atomic swap logic and provides a CLI. The binaries under `swap/src/bin` (`swap.rs` and `asb.rs`) start the client and maker services respectively. -- **src-tauri** wraps the swap crate and exposes its functionality to the GUI via Tauri commands. It also bundles the application with the `src-gui` assets. -- **src-gui** is the TypeScript/React interface. It communicates with the Rust back end through the commands defined in `src-tauri`. -- Helper crates like **monero-wallet** provide abstractions over external services. They are used by the swap crate to interact with Monero. -- Continuous integration and release workflows live in `.github/workflows`. They build binaries, create releases and lint the code base. - -## Pull request titles - -Use descriptive titles following the `(): ` format. Examples include: - -- `feat(gui): New feature` -- `fix(swap): Issue fixed` -- `refactor(ci): Ci changes` diff --git a/AGENT.md b/AGENT.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/AGENT.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..5b5c206e31 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ + - Read CONTRIBUTING.md and the code style guidelines! + - When asked about libp2p, check if a rust-libp2p folder exists which contains the cloned rust libp2p codebase. Read through to figure out what the best response it. If its a question about best practice when implementing protocols read @rust-libp2p/protocols/ specificially. + - Never do `cargo clean`. Building `monero-sys` takes ages, and cleaning the build cache will cause a full rebuilt (horrible). + `cargo clean` has never fixed a build problem. + - Before suggesting a change, always give at least a short (1 sentence) summary of which function you are editing and why. + - When being asked to add something, check whether there is a similar thing already implemented, the architecture of which you can follow. + For example, when asked to add a new Tauri command, check out how other tauri commands are implemented and what conventions they follow. + - + + - Think about seperation of concerns. This has many facets. But the most ofen there are questions like: + "Which part of the code should decide how to handle this situation". In the context of an error, the solution is: + - Never use fallback values. They lead to + - swallowed errors + - breaking invariances + - breaking other implicit assumptions + - destroy any meaning the value might have had. + Instead, if an error/invlaid state is encountered, the error should be propagated. + This is most often correctly done by using anyhow's "Context" and the question mark operator`.context("Failed to ")?`. + - Keep error handling simple: it is basically never wrong to just propagate the error using `?` and maybe add some basic context. + + Other facetts of seperation of concern include: + - should this function need to have access to this ? + - should this function decide a parameter itself or just take an argument? + + We follow the principle of LEAST SURPRISE. Take a step back, and come back with a fresh view. Then ask yourself: "would I expect this function to do ?". + If not, then don't do it. + +- coding style tips: + - keep the code succint. Prefer `if let` and `let ... else` to `match` whenever possible. + - avoid nesting if possible. + - prefer early returns to nesting. + +- Docker tests: We have an extended test suite that simulates a whole blockchain environment for the purpose of testing swaps end to end. + The docker tests are located in `swap/tests` and can be executed using `just docker_test `. Get a list of all docker tests by `just list-docker-tests`. +- If you changed something could possibly affect the success of a swap, make sure to run the integration tests that could possibly be affected. + Be very liberal with assuming what might be affected. +- If not explicitly instructed yet, ask the user whether you should add {unit, integration} tests if you just added / changed some behaviour/code +- The docker tests are long (multiple minutes) and produce tens of thousands of log messages. + Don't try to read all of that output, it will fill you context up before finishing + the initialization. + Instead, spawn them as a background-task (each as it's own). + Then you can simply check in on the current status by checking it's output every minute or so. + If you are claude, use claude codes native background task system and read from the `/tmp/claude/tasks/foo/output.tmp` pipe file, or whatever the path is. + If you are not claude, then do the thing that best accomplishises this. + +- Before claiming you finished, make sure everything compiles (`cargo c --all-features`). + Also all tests (`cargo c --tests`) and all targets (`cargo c --all-targets`) must compile. + + + diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index eb3a82a418..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -- When asked about libp2p, check if a rust-libp2p folder exists which contains the cloned rust libp2p codebase. Read through to figure out what the best response it. If its a question about best practice when implementing protocols read @rust-libp2p/protocols/ specificially. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65a65cdf5e..d50ba6b8dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,21 +6,38 @@ Thank you for wanting to contribute to this project! There are a couple of things we are going to look out for in PRs and knowing them upfront is going to reduce the number of times we will be going back and forth, making things more efficient. +0. **Read and comply with our [AI Policy](AI_POLICY.md)** 1. We have CI checks in place that validate formatting and code style. - Make sure `dprint check` and `cargo clippy` both finish without any warnings or errors. - If you don't already have it installed, you can obtain in [various ways](https://dprint.dev/install/). -2. Run the test suite with [cargo-nextest](https://nexte.st/docs/running/). - Install it using `cargo install cargo-nextest` and execute `cargo nextest run`. -3. All text document (`CHANGELOG.md`, `README.md`, etc) should follow the [semantic linebreaks](https://sembr.org/) specification. -4. We strive for atomic commits with good commit messages. + Make sure the branch is building with `--all-features` and `--all-targets` without errors + and all tests are passed. +2. All text document (`CHANGELOG.md`, `README.md`, etc) should follow the [semantic linebreaks](https://sembr.org/) specification. +3. We strive for atomic commits with good commit messages. As an inspiration, read [this](https://chris.beams.io/posts/git-commit/) blogpost. An atomic commit is a cohesive diff with formatting checks, linter and build passing. Ideally, all tests are passing as well but we acknowledge that this is not always possible depending on the change you are making. -5. If you are making any user visible changes, include a changelog entry. +4. If you are making any user visible changes, include a changelog entry. ## Contributing issues When contributing a feature request, please focus on your _problem_ as much as possible. It is okay to include ideas on how the feature should be implemented but they should be 2nd nature of your request. -For more loosely-defined problems and ideas, consider starting a [discussion](https://github.com/comit-network/xmr-btc-swap/discussions/new) instead of opening an issue. +## Code style + +### General + + - File structure + - The content of each file should be ordered in terms of importance / level of abstraction + - Public `struct`s, `enum`s and important constants should be at the top + - `impl` blocks should be below the type declarations + - Both the type declaration part and the implementation part of the file should be internally ordered by level of abstraction/ importance + - For example, `fn main` should always be at least at the top of the implementation + - Prefer early returns over nested `if`/`match` statements + - Don't use fallback values or silent failures + +### Rust + + - Use `cargo fmt` for formatting + - Make use of the powerful `if let` and `let ... else` pattern to enable early returns + - Make use of anyhows `.context` method and the `?` operator for concise error reporting + diff --git a/Cargo.lock b/Cargo.lock index ff296cc2a9..6838f36ab3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,7 +50,7 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "once_cell", "version_check", ] @@ -106,7 +106,7 @@ dependencies = [ "amplify_derive", "amplify_num", "ascii", - "getrandom 0.2.16", + "getrandom 0.2.17", "getrandom 0.3.4", "wasm-bindgen", ] @@ -274,7 +274,7 @@ dependencies = [ "cfg-if", "derive-deftly", "derive_builder_fork_arti", - "derive_more 2.1.0", + "derive_more 2.1.1", "educe", "fs-mistrust", "futures", @@ -287,7 +287,7 @@ dependencies = [ "rand 0.9.2", "safelog", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tor-async-utils", "tor-basic-utils", @@ -322,27 +322,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" -[[package]] -name = "ashpd" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" -dependencies = [ - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.2", - "raw-window-handle", - "serde", - "serde_repr", - "tokio", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - [[package]] name = "asn1-rs" version = "0.5.2" @@ -387,7 +366,7 @@ dependencies = [ "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -410,7 +389,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "synstructure 0.13.2", ] @@ -422,7 +401,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "synstructure 0.13.2", ] @@ -445,7 +424,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -496,13 +475,12 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.36" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37" +checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" dependencies = [ "compression-codecs", "compression-core", - "futures-core", "futures-io", "pin-project-lite", ] @@ -541,9 +519,9 @@ dependencies = [ [[package]] name = "async-lock" -version = "3.4.1" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ "event-listener", "event-listener-strategy", @@ -576,7 +554,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -597,28 +575,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - [[package]] name = "async-task" version = "4.7.1" @@ -633,7 +589,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -760,9 +716,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.15.1" +version = "1.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b5ce75405893cd713f9ab8e297d8e438f624dde7d706108285f7e17a25a180f" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" dependencies = [ "aws-lc-sys", "zeroize", @@ -770,9 +726,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.34.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "179c3777a8b5e70e90ea426114ffc565b2c1a9f82f6c4a0c5a34aa6ef5e781b6" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" dependencies = [ "cc", "cmake", @@ -782,9 +738,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "axum-macros", @@ -816,9 +772,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", @@ -841,7 +797,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -851,7 +807,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" dependencies = [ "futures-core", - "getrandom 0.2.16", + "getrandom 0.2.17", "instant", "pin-project-lite", "rand 0.8.5", @@ -916,9 +872,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.1" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bdk" @@ -930,7 +886,7 @@ dependencies = [ "bdk-macros", "bitcoin 0.29.2", "electrum-client 0.12.1", - "getrandom 0.2.16", + "getrandom 0.2.17", "js-sys", "log", "miniscript 9.2.1", @@ -1109,7 +1065,7 @@ dependencies = [ "hmac", "jsonrpc_client", "rand 0.8.5", - "reqwest 0.12.25", + "reqwest 0.12.28", "serde", "serde_json", "sha2", @@ -1163,7 +1119,7 @@ dependencies = [ "futures", "moka", "proptest", - "reqwest 0.12.25", + "reqwest 0.12.28", "rust_decimal", "serde", "serde_json", @@ -1250,7 +1206,7 @@ checksum = "e0b121a9fe0df916e362fb3271088d071159cdf11db0e4182d02152850756eff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1333,7 +1289,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1379,9 +1335,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "by_address" @@ -1474,9 +1430,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ "serde_core", ] @@ -1506,7 +1462,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -1516,7 +1472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" dependencies = [ "serde", - "toml 0.9.8", + "toml 0.9.11+spec-1.1.0", ] [[package]] @@ -1536,9 +1492,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.49" +version = "1.2.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" dependencies = [ "find-msvc-tools", "jobserver", @@ -1611,9 +1567,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -1678,9 +1634,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", "clap_derive", @@ -1688,9 +1644,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstream", "anstyle", @@ -1707,14 +1663,14 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "clipboard-win" @@ -1727,18 +1683,18 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.56" +version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b042e5d8a74ae91bb0961acd039822472ec99f8ab0948cbf6d1369588f8be586" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" dependencies = [ "cc", ] [[package]] name = "coarsetime" -version = "0.1.36" +version = "0.1.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91849686042de1b41cd81490edc83afbcb0abe5a9b6f2c4114f23ce8cca1bcf4" +checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" dependencies = [ "libc", "wasix", @@ -1764,11 +1720,11 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "colored" -version = "3.0.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1783,9 +1739,9 @@ dependencies = [ [[package]] name = "comfy-table" -version = "7.2.1" +version = "7.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" dependencies = [ "crossterm", "unicode-segmentation", @@ -1799,7 +1755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fd9b9dc67f2b3024582ec6d861950f0af0aeaabb8350ccda1f0e51ff8e5895c" dependencies = [ "compose_spec_macros", - "indexmap 2.12.1", + "indexmap 2.13.0", "ipnet", "itoa", "serde", @@ -1816,14 +1772,14 @@ checksum = "b77735bd89be8da01c8d7e61faec5a9ccb0e313cece3c773c6b3ae251b90c7d4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "compression-codecs" -version = "0.4.35" +version = "0.4.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0f7ac3e5b97fdce45e8922fb05cae2c37f7bbd63d30dd94821dacfd8f3f2bf2" +checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" dependencies = [ "compression-core", "flate2", @@ -1871,9 +1827,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.1" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b430743a6eb14e9764d4260d4c0d8123087d504eeb9c48f2b2a5e810dd369df4" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" dependencies = [ "encode_unicode", "libc", @@ -2035,7 +1991,7 @@ dependencies = [ "anes", "cast", "ciborium", - "clap 4.5.53", + "clap 4.5.54", "criterion-plot", "itertools 0.13.0", "num-traits", @@ -2200,7 +2156,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2210,7 +2166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2232,7 +2188,7 @@ dependencies = [ "cuprate-hex", "paste", "ref-cast", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2241,7 +2197,7 @@ version = "0.1.0" source = "git+https://github.com/Cuprate/cuprate.git#27eec55f5b7851a2b36e158065a6be95c091e904" dependencies = [ "bytes", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2279,7 +2235,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2298,9 +2254,9 @@ dependencies = [ [[package]] name = "cxx" -version = "1.0.190" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7620f6cfc4dcca21f2b085b7a890e16c60fd66f560cd69ee60594908dc72ab1" +checksum = "747d8437319e3a2f43d93b341c137927ca70c0f5dabeea7a005a73665e247c7e" dependencies = [ "cc", "cxx-build", @@ -2313,49 +2269,49 @@ dependencies = [ [[package]] name = "cxx-build" -version = "1.0.190" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9bc1a22964ff6a355fbec24cf68266a0ed28f8b84c0864c386474ea3d0e479" +checksum = "b0f4697d190a142477b16aef7da8a99bfdc41e7e8b1687583c0d23a79c7afc1e" dependencies = [ "cc", "codespan-reporting", - "indexmap 2.12.1", + "indexmap 2.13.0", "proc-macro2", "quote", "scratch", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "cxxbridge-cmd" -version = "1.0.190" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f29a879d35f7906e3c9b77d7a1005a6a0787d330c09dfe4ffb5f617728cb44" +checksum = "d0956799fa8678d4c50eed028f2de1c0552ae183c76e976cf7ca8c4e36a7c328" dependencies = [ - "clap 4.5.53", + "clap 4.5.54", "codespan-reporting", - "indexmap 2.12.1", + "indexmap 2.13.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "cxxbridge-flags" -version = "1.0.190" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d67109015f93f683e364085aa6489a5b2118b4a40058482101d699936a7836d6" +checksum = "23384a836ab4f0ad98ace7e3955ad2de39de42378ab487dc28d3990392cb283a" [[package]] name = "cxxbridge-macro" -version = "1.0.190" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d187e019e7b05a1f3e69a8396b70800ee867aa9fc2ab972761173ccee03742df" +checksum = "e6acc6b5822b9526adfb4fc377b67128fdd60aac757cc4a741a6278603f763cf" dependencies = [ - "indexmap 2.12.1", + "indexmap 2.13.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2437,7 +2393,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2451,7 +2407,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2484,7 +2440,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2495,20 +2451,20 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "data-encoding-macro" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47ce6c96ea0102f01122a185683611bd5ac8d99e62bc59dd12e6bda344ee673d" +checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" dependencies = [ "data-encoding", "data-encoding-macro-internal", @@ -2516,12 +2472,12 @@ dependencies = [ [[package]] name = "data-encoding-macro-internal" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" +checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2610,14 +2566,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "337f65eb93d9996551b9442423480eca4532586b337484446eb5138d0cd8fcf0" dependencies = [ "heck 0.5.0", - "indexmap 2.12.1", + "indexmap 2.13.0", "itertools 0.14.0", "proc-macro-crate 3.4.0", "proc-macro2", "quote", "sha3", "strum 0.27.2", - "syn 2.0.111", + "syn 2.0.114", "void", ] @@ -2629,7 +2585,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2650,7 +2606,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2681,7 +2637,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2704,29 +2660,29 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "derive_more" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", - "syn 2.0.111", + "syn 2.0.114", "unicode-xid", ] @@ -2889,16 +2845,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", -] - -[[package]] -name = "dlib" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" -dependencies = [ - "libloading 0.8.9", + "syn 2.0.114", ] [[package]] @@ -2921,7 +2868,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2962,9 +2909,9 @@ dependencies = [ [[package]] name = "dtoa" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" [[package]] name = "dtoa-short" @@ -3088,7 +3035,7 @@ dependencies = [ "byteorder", "libc", "log", - "rustls 0.23.35", + "rustls 0.23.36", "serde", "serde_json", "webpki-roots 0.25.4", @@ -3136,7 +3083,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.8", + "toml 0.9.11+spec-1.1.0", "vswhom", "winreg 0.55.0", ] @@ -3183,7 +3130,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3196,7 +3143,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3208,7 +3155,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3229,7 +3176,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3250,7 +3197,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3363,7 +3310,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3427,21 +3374,20 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", "libredox", - "windows-sys 0.60.2", ] [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" [[package]] name = "fixedbitset" @@ -3451,13 +3397,13 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", - "libz-rs-sys", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -3522,7 +3468,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3556,7 +3502,7 @@ dependencies = [ "libc", "pwd-grp", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "walkdir", ] @@ -3602,7 +3548,7 @@ version = "0.5.1" source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_arti_1_8_0#2a5db5823e2a5eb413d8ad433a4d3aba902bac07" dependencies = [ "fslock-arti-fork", - "thiserror 2.0.17", + "thiserror 2.0.18", "winapi", ] @@ -3713,7 +3659,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3733,7 +3679,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" dependencies = [ "futures-io", - "rustls 0.23.35", + "rustls 0.23.36", "rustls-pki-types", ] @@ -3927,9 +3873,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -3961,7 +3907,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -4053,7 +3999,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -4161,7 +4107,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -4176,7 +4122,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.12.1", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -4185,9 +4131,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -4195,7 +4141,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.4.0", - "indexmap 2.12.1", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -4548,7 +4494,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.12", + "h2 0.4.13", "http 1.4.0", "http-body 1.0.1", "httparse", @@ -4571,13 +4517,13 @@ dependencies = [ "hyper 1.8.1", "hyper-util", "log", - "rustls 0.23.35", - "rustls-native-certs 0.8.2", + "rustls 0.23.36", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", "tower-service", - "webpki-roots 1.0.4", + "webpki-roots 1.0.5", ] [[package]] @@ -4611,7 +4557,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.6.2", "system-configuration 0.6.1", "tokio", "tower-layer", @@ -4841,9 +4787,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -4930,9 +4876,9 @@ dependencies = [ [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", @@ -4983,9 +4929,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "javascriptcore-rs" @@ -5044,9 +4990,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -5092,7 +5038,7 @@ source = "git+https://github.com/delta1/rust-jsonrpc-client.git?rev=3b6081697cd6 dependencies = [ "async-trait", "jsonrpc_client_macro", - "reqwest 0.12.25", + "reqwest 0.12.28", "serde", "serde_json", "url", @@ -5141,7 +5087,7 @@ dependencies = [ "rustc-hash", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tower", "tracing", @@ -5160,11 +5106,11 @@ dependencies = [ "hyper-util", "jsonrpsee-core", "jsonrpsee-types", - "rustls 0.23.35", + "rustls 0.23.36", "rustls-platform-verifier", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tower", "url", @@ -5180,7 +5126,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -5202,7 +5148,7 @@ dependencies = [ "serde", "serde_json", "soketto", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-util", @@ -5219,7 +5165,7 @@ dependencies = [ "http 1.4.0", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -5280,7 +5226,7 @@ checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ "cssparser", "html5ever", - "indexmap 2.12.1", + "indexmap 2.13.0", "selectors", ] @@ -5313,7 +5259,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading 0.7.4", + "libloading", "once_cell", ] @@ -5325,9 +5271,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.178" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libgit2-sys" @@ -5351,16 +5297,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link 0.2.1", -] - [[package]] name = "liblzma" version = "0.4.5" @@ -5372,9 +5308,9 @@ dependencies = [ [[package]] name = "liblzma-sys" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736" +checksum = "9f2db66f3268487b5033077f266da6777d057949b8f93c8ad82e441df25e6186" dependencies = [ "cc", "libc", @@ -5383,9 +5319,9 @@ dependencies = [ [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libp2p" @@ -5397,7 +5333,7 @@ dependencies = [ "either", "futures", "futures-timer", - "getrandom 0.2.16", + "getrandom 0.2.17", "instant", "libp2p-allow-block-list", "libp2p-connection-limits", @@ -5509,7 +5445,7 @@ dependencies = [ "fnv", "futures", "futures-ticker", - "getrandom 0.2.16", + "getrandom 0.2.17", "hex_fmt", "instant", "libp2p-core", @@ -5566,7 +5502,7 @@ dependencies = [ "ring 0.17.14", "serde", "sha2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "zeroize", ] @@ -5700,7 +5636,7 @@ dependencies = [ "quinn", "rand 0.8.5", "ring 0.17.14", - "rustls 0.23.35", + "rustls 0.23.36", "socket2 0.5.10", "thiserror 1.0.69", "tokio", @@ -5787,7 +5723,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -5838,7 +5774,7 @@ dependencies = [ "libp2p-identity", "rcgen", "ring 0.17.14", - "rustls 0.23.35", + "rustls 0.23.36", "rustls-webpki 0.101.7", "thiserror 1.0.69", "x509-parser 0.16.0", @@ -5920,13 +5856,13 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.10.0", "libc", - "redox_syscall 0.5.18", + "redox_syscall 0.7.0", ] [[package]] @@ -5940,15 +5876,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libz-rs-sys" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15413ef615ad868d4d65dce091cb233b229419c7c0c4bcaa746c0901c49ff39c" -dependencies = [ - "zlib-rs", -] - [[package]] name = "libz-sys" version = "1.1.23" @@ -6055,13 +5982,13 @@ dependencies = [ [[package]] name = "match-lookup" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1265724d8cb29dbbc2b0f06fffb8bf1a8c0cf73a78eede9ba73a4a66c52a981e" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.114", ] [[package]] @@ -6072,7 +5999,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -6240,9 +6167,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.11" +version = "0.12.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" +checksum = "a3dec6bd31b08944e08b58fd99373893a6c17054d6f3ea5006cc894f4f4eee2a" dependencies = [ "async-lock", "crossbeam-channel", @@ -6253,7 +6180,6 @@ dependencies = [ "futures-util", "parking_lot 0.12.5", "portable-atomic", - "rustc_version", "smallvec", "tagptr", "uuid", @@ -6262,20 +6188,20 @@ dependencies = [ [[package]] name = "monero-address" version = "0.1.0" -source = "git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite#1a8662faf352e0b7a7b11e157286c0fdb4a65db4" +source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" dependencies = [ "monero-base58", "monero-ed25519", "monero-io", "monero-primitives", - "thiserror 2.0.17", + "thiserror 2.0.18", "zeroize", ] [[package]] name = "monero-base58" version = "0.1.0" -source = "git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite#1a8662faf352e0b7a7b11e157286c0fdb4a65db4" +source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" dependencies = [ "monero-primitives", "std-shims", @@ -6284,7 +6210,7 @@ dependencies = [ [[package]] name = "monero-borromean" version = "0.1.0" -source = "git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite#1a8662faf352e0b7a7b11e157286c0fdb4a65db4" +source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" dependencies = [ "curve25519-dalek", "monero-ed25519", @@ -6296,7 +6222,7 @@ dependencies = [ [[package]] name = "monero-bulletproofs" version = "0.1.0" -source = "git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite#1a8662faf352e0b7a7b11e157286c0fdb4a65db4" +source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" dependencies = [ "curve25519-dalek", "monero-bulletproofs-generators", @@ -6305,14 +6231,14 @@ dependencies = [ "monero-primitives", "rand_core 0.6.4", "std-shims", - "thiserror 2.0.17", + "thiserror 2.0.18", "zeroize", ] [[package]] name = "monero-bulletproofs-generators" version = "0.1.0" -source = "git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite#1a8662faf352e0b7a7b11e157286c0fdb4a65db4" +source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" dependencies = [ "curve25519-dalek", "monero-ed25519", @@ -6324,7 +6250,7 @@ dependencies = [ [[package]] name = "monero-clsag" version = "0.1.0" -source = "git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite#1a8662faf352e0b7a7b11e157286c0fdb4a65db4" +source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" dependencies = [ "curve25519-dalek", "group", @@ -6334,14 +6260,14 @@ dependencies = [ "rand_core 0.6.4", "std-shims", "subtle", - "thiserror 2.0.17", + "thiserror 2.0.18", "zeroize", ] [[package]] name = "monero-daemon-rpc" version = "0.1.0" -source = "git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite#1a8662faf352e0b7a7b11e157286c0fdb4a65db4" +source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" dependencies = [ "hex", "monero-address", @@ -6356,7 +6282,7 @@ dependencies = [ [[package]] name = "monero-ed25519" version = "0.1.0" -source = "git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite#1a8662faf352e0b7a7b11e157286c0fdb4a65db4" +source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" dependencies = [ "crypto-bigint", "curve25519-dalek", @@ -6371,7 +6297,7 @@ dependencies = [ [[package]] name = "monero-epee" version = "0.2.0" -source = "git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite#1a8662faf352e0b7a7b11e157286c0fdb4a65db4" +source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" [[package]] name = "monero-harness" @@ -6384,7 +6310,7 @@ dependencies = [ "monero-simple-request-rpc", "monero-sys", "rand 0.8.5", - "reqwest 0.12.25", + "reqwest 0.12.28", "testcontainers", "tokio", "tracing", @@ -6394,19 +6320,19 @@ dependencies = [ [[package]] name = "monero-interface" version = "0.1.0" -source = "git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite#1a8662faf352e0b7a7b11e157286c0fdb4a65db4" +source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" dependencies = [ "hex", "monero-oxide", "std-shims", - "thiserror 2.0.17", + "thiserror 2.0.18", "zeroize", ] [[package]] name = "monero-io" version = "0.1.0" -source = "git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite#1a8662faf352e0b7a7b11e157286c0fdb4a65db4" +source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" dependencies = [ "std-shims", ] @@ -6414,20 +6340,20 @@ dependencies = [ [[package]] name = "monero-mlsag" version = "0.1.0" -source = "git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite#1a8662faf352e0b7a7b11e157286c0fdb4a65db4" +source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" dependencies = [ "curve25519-dalek", "monero-ed25519", "monero-io", "std-shims", - "thiserror 2.0.17", + "thiserror 2.0.18", "zeroize", ] [[package]] name = "monero-oxide" version = "0.1.4-alpha" -source = "git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite#1a8662faf352e0b7a7b11e157286c0fdb4a65db4" +source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" dependencies = [ "curve25519-dalek", "hex-literal", @@ -6451,7 +6377,7 @@ dependencies = [ "curve25519-dalek-ng", "hex", "monero-address", - "monero-wallet 0.1.0 (git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite)", + "monero-wallet 0.1.0 (git+https://github.com/kayabaNerve/monero-oxide.git)", "serde", "typeshare", ] @@ -6459,7 +6385,7 @@ dependencies = [ [[package]] name = "monero-primitives" version = "0.1.0" -source = "git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite#1a8662faf352e0b7a7b11e157286c0fdb4a65db4" +source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" dependencies = [ "sha3", ] @@ -6472,7 +6398,7 @@ dependencies = [ "arti-client", "axum", "chrono", - "clap 4.5.53", + "clap 4.5.54", "crossbeam", "cuprate-epee-encoding", "futures", @@ -6511,7 +6437,7 @@ dependencies = [ [[package]] name = "monero-simple-request-rpc" version = "0.1.0" -source = "git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite#1a8662faf352e0b7a7b11e157286c0fdb4a65db4" +source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" dependencies = [ "digest_auth", "hex", @@ -6543,7 +6469,7 @@ dependencies = [ "sqlx", "swap-serde", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "throttle", "tokio", "tracing", @@ -6565,7 +6491,7 @@ dependencies = [ "monero-oxide-ext", "monero-simple-request-rpc", "monero-sys", - "monero-wallet 0.1.0 (git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite)", + "monero-wallet 0.1.0 (git+https://github.com/kayabaNerve/monero-oxide.git)", "monero-wallet-ng", "testcontainers", "tokio", @@ -6588,7 +6514,7 @@ dependencies = [ "monero-oxide-ext", "monero-simple-request-rpc", "monero-sys", - "monero-wallet 0.1.0 (git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite)", + "monero-wallet 0.1.0 (git+https://github.com/kayabaNerve/monero-oxide.git)", "monero-wallet-ng", "swap-core", "throttle", @@ -6602,7 +6528,7 @@ dependencies = [ [[package]] name = "monero-wallet" version = "0.1.0" -source = "git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite#1a8662faf352e0b7a7b11e157286c0fdb4a65db4" +source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" dependencies = [ "curve25519-dalek", "hex", @@ -6616,7 +6542,7 @@ dependencies = [ "rand_distr", "std-shims", "subtle", - "thiserror 2.0.17", + "thiserror 2.0.18", "zeroize", ] @@ -6632,7 +6558,7 @@ dependencies = [ "monero-interface", "monero-oxide", "monero-simple-request-rpc", - "monero-wallet 0.1.0 (git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite)", + "monero-wallet 0.1.0 (git+https://github.com/kayabaNerve/monero-oxide.git)", "serde", "serde_json", "thiserror 1.0.69", @@ -6643,9 +6569,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80986bbbcf925ebd3be54c26613d861255284584501595cf418320c078945608" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" dependencies = [ "num-traits", "pxfm", @@ -6668,7 +6594,7 @@ dependencies = [ "once_cell", "png 0.17.16", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows-sys 0.60.2", ] @@ -6737,7 +6663,7 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "schannel", "security-framework 2.11.1", @@ -6823,17 +6749,17 @@ dependencies = [ "log", "netlink-packet-core", "netlink-sys", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "netlink-sys" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" dependencies = [ "bytes", - "futures", + "futures-util", "libc", "log", "tokio", @@ -6875,7 +6801,6 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "memoffset", ] [[package]] @@ -6934,15 +6859,18 @@ dependencies = [ [[package]] name = "notify-types" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.10.0", +] [[package]] name = "ntapi" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" dependencies = [ "winapi", ] @@ -6984,9 +6912,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -7047,7 +6975,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -7378,7 +7306,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -7387,6 +7315,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-sys" version = "0.9.111" @@ -7454,7 +7388,7 @@ dependencies = [ "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -7623,9 +7557,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.4" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" dependencies = [ "memchr", "ucd-trie", @@ -7633,9 +7567,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.4" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f72981ade67b1ca6adc26ec221be9f463f2b5839c7508998daa17c23d94d7f" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" dependencies = [ "pest", "pest_generator", @@ -7643,22 +7577,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.4" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee9efd8cdb50d719a80088b76f81aec7c41ed6d522ee750178f83883d271625" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "pest_meta" -version = "2.8.4" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" dependencies = [ "pest", "sha2", @@ -7672,7 +7606,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap 2.12.1", + "indexmap 2.13.0", ] [[package]] @@ -7800,7 +7734,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -7813,7 +7747,7 @@ dependencies = [ "phf_shared 0.13.1", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -7869,7 +7803,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -7929,8 +7863,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap 2.12.1", - "quick-xml 0.38.4", + "indexmap 2.13.0", + "quick-xml", "serde", "time", ] @@ -8028,9 +7962,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" [[package]] name = "postage" @@ -8064,9 +7998,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppmd-rust" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d558c559f0450f16f2a27a1f017ef38468c1090c9ce63c8e51366232d53717b4" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" [[package]] name = "ppv-lite86" @@ -8099,7 +8033,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" dependencies = [ "equivalent", - "indexmap 2.12.1", + "indexmap 2.13.0", "serde", ] @@ -8129,7 +8063,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.23.9", + "toml_edit 0.23.10+spec-1.0.0", ] [[package]] @@ -8175,7 +8109,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -8186,9 +8120,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -8213,7 +8147,7 @@ checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -8264,7 +8198,7 @@ dependencies = [ "derive-deftly", "libc", "paste", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -8323,15 +8257,6 @@ dependencies = [ "unsigned-varint 0.8.0", ] -[[package]] -name = "quick-xml" -version = "0.37.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.38.4" @@ -8360,7 +8285,7 @@ checksum = "f71ee38b42f8459a88d3362be6f9b841ad2d5421844f61eb1c59c11bff3ac14a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -8376,9 +8301,9 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.35", - "socket2 0.6.1", - "thiserror 2.0.17", + "rustls 0.23.36", + "socket2 0.6.2", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -8396,10 +8321,10 @@ dependencies = [ "rand 0.9.2", "ring 0.17.14", "rustc-hash", - "rustls 0.23.35", + "rustls 0.23.36", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -8414,16 +8339,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -8482,7 +8407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -8512,7 +8437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -8530,14 +8455,14 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -8568,7 +8493,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b16df48f071248e67b8fc5e866d9448d45c08ad8b672baaaf796e2f15e606ff0" dependencies = [ "libc", - "rand_core 0.9.3", + "rand_core 0.9.5", "winapi", ] @@ -8587,7 +8512,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -8655,13 +8580,22 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "redox_users" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] @@ -8672,9 +8606,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -8694,7 +8628,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -8797,15 +8731,15 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.25" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", "futures-core", "futures-util", - "h2 0.4.12", + "h2 0.4.13", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -8817,8 +8751,8 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.35", - "rustls-native-certs 0.8.2", + "rustls 0.23.36", + "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_json", @@ -8835,7 +8769,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.4", + "webpki-roots 1.0.5", ] [[package]] @@ -8861,11 +8795,10 @@ dependencies = [ [[package]] name = "rfd" -version = "0.15.4" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" dependencies = [ - "ashpd", "block2", "dispatch2", "glib-sys", @@ -8881,7 +8814,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -8907,7 +8840,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted 0.9.0", "windows-sys 0.52.0", @@ -8915,9 +8848,9 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.7.45" +version = "0.7.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" dependencies = [ "bitvec", "bytecheck", @@ -8933,9 +8866,9 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.7.45" +version = "0.7.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" dependencies = [ "proc-macro2", "quote", @@ -8950,9 +8883,9 @@ checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" [[package]] name = "rsa" -version = "0.9.9" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ "const-oid", "digest 0.10.7", @@ -9004,9 +8937,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.39.0" +version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" dependencies = [ "arrayvec", "borsh", @@ -9044,9 +8977,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags 2.10.0", "errno", @@ -9094,16 +9027,16 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "aws-lc-rs", "log", "once_cell", "ring 0.17.14", "rustls-pki-types", - "rustls-webpki 0.103.8", + "rustls-webpki 0.103.9", "subtle", "zeroize", ] @@ -9114,7 +9047,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a07b7c1885bd8ed3831c289b7870b13ef46fe0e856d288c30d9cc17d75a2092" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.6", "rustls 0.19.1", "schannel", "security-framework 2.11.1", @@ -9122,11 +9055,11 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", "security-framework 3.5.1", @@ -9143,9 +9076,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -9162,10 +9095,10 @@ dependencies = [ "jni", "log", "once_cell", - "rustls 0.23.35", - "rustls-native-certs 0.8.2", + "rustls 0.23.36", + "rustls-native-certs 0.8.3", "rustls-platform-verifier-android", - "rustls-webpki 0.103.8", + "rustls-webpki 0.103.9", "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs 0.26.11", @@ -9190,9 +9123,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "aws-lc-rs", "ring 0.17.14", @@ -9253,20 +9186,20 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "safelog" version = "0.7.1" source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_arti_1_8_0#2a5db5823e2a5eb413d8ad433a4d3aba902bac07" dependencies = [ - "derive_more 2.1.0", + "derive_more 2.1.1", "educe", "either", "fluid-let", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -9325,9 +9258,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" dependencies = [ "dyn-clone", "ref-cast", @@ -9344,15 +9277,9 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.111", + "syn 2.0.114", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -9644,7 +9571,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -9655,7 +9582,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -9670,15 +9597,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -9700,7 +9627,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -9714,9 +9641,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ "serde_core", ] @@ -9753,9 +9680,9 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.12.1", + "indexmap 2.13.0", "schemars 0.9.0", - "schemars 1.1.0", + "schemars 1.2.0", "serde_core", "serde_json", "serde_with_macros 3.16.1", @@ -9783,7 +9710,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -9792,7 +9719,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.12.1", + "indexmap 2.13.0", "itoa", "ryu", "serde", @@ -9818,7 +9745,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -9924,10 +9851,11 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -10026,7 +9954,7 @@ dependencies = [ "paste", "serde", "slotmap", - "thiserror 2.0.17", + "thiserror 2.0.18", "void", ] @@ -10068,9 +9996,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -10216,7 +10144,7 @@ dependencies = [ "hashbrown 0.14.5", "hashlink", "hex", - "indexmap 2.12.1", + "indexmap 2.13.0", "log", "memchr", "once_cell", @@ -10247,7 +10175,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -10270,7 +10198,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.111", + "syn 2.0.114", "tempfile", "tokio", "url", @@ -10553,7 +10481,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -10565,7 +10493,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -10616,9 +10544,9 @@ dependencies = [ "proptest", "rand 0.8.5", "regex", - "reqwest 0.12.25", + "reqwest 0.12.28", "rust_decimal", - "rustls 0.23.35", + "rustls 0.23.36", "semver", "serde", "serde_json", @@ -10667,9 +10595,9 @@ dependencies = [ "monero-address", "monero-rpc-pool", "monero-sys", - "reqwest 0.12.25", + "reqwest 0.12.28", "rust_decimal", - "rustls 0.23.35", + "rustls 0.23.36", "serde", "serde_json", "structopt", @@ -10691,13 +10619,15 @@ name = "swap-controller" version = "3.6.7" dependencies = [ "anyhow", - "clap 4.5.53", + "clap 4.5.54", + "comfy-table", "jsonrpsee", "monero-oxide-ext", "rustyline", "shell-words", "swap-controller-api", "tokio", + "uuid", ] [[package]] @@ -10707,6 +10637,7 @@ dependencies = [ "bitcoin 0.32.8", "jsonrpsee", "serde", + "uuid", ] [[package]] @@ -10723,7 +10654,7 @@ dependencies = [ "futures", "monero-address", "monero-oxide-ext", - "monero-wallet 0.1.0 (git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite)", + "monero-wallet 0.1.0 (git+https://github.com/kayabaNerve/monero-oxide.git)", "proptest", "rand 0.8.5", "rand_chacha 0.3.1", @@ -10760,7 +10691,7 @@ dependencies = [ "anyhow", "bitcoin 0.32.8", "config", - "console 0.16.1", + "console 0.16.2", "dialoguer", "libp2p", "monero-address", @@ -10770,7 +10701,7 @@ dependencies = [ "swap-serde", "thiserror 1.0.69", "time", - "toml 0.9.8", + "toml 0.9.11+spec-1.1.0", "tracing", "url", ] @@ -10784,7 +10715,7 @@ dependencies = [ "bitcoin 0.32.8", "futures", "monero-oxide-ext", - "reqwest 0.12.25", + "reqwest 0.12.28", "rust_decimal", "serde", "serde_json", @@ -10820,6 +10751,7 @@ dependencies = [ "monero-oxide-ext", "rand 0.8.5", "rand_chacha 0.3.1", + "rust_decimal", "serde", "sha2", "sigma_fun", @@ -10828,6 +10760,7 @@ dependencies = [ "swap-serde", "thiserror 1.0.69", "tokio", + "tracing", "uuid", ] @@ -10843,7 +10776,7 @@ dependencies = [ "monero-address", "serde_yaml", "swap-env", - "toml 0.9.8", + "toml 0.9.11+spec-1.1.0", "url", "vergen 8.3.2", ] @@ -10853,6 +10786,7 @@ name = "swap-p2p" version = "0.1.0" dependencies = [ "anyhow", + "arti-client", "async-trait", "asynchronous-codec 0.7.0", "backoff", @@ -10861,8 +10795,10 @@ dependencies = [ "bmrng", "futures", "libp2p", + "libp2p-tor", "monero-address", "rand 0.8.5", + "rust_decimal", "semver", "serde", "serde_cbor", @@ -10873,7 +10809,9 @@ dependencies = [ "swap-serde", "thiserror 1.0.69", "tokio", + "tor-rtcompat", "tracing", + "tracing-subscriber", "typeshare", "unsigned-varint 0.8.0", "uuid", @@ -10899,7 +10837,7 @@ dependencies = [ "libp2p", "monero-address", "monero-oxide-ext", - "monero-wallet 0.1.0 (git+https://github.com/kayabaNerve/monero-oxide.git?branch=rpc-rewrite)", + "monero-wallet 0.1.0 (git+https://github.com/kayabaNerve/monero-oxide.git)", "serde", "serde_json", "thiserror 1.0.69", @@ -10930,9 +10868,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -10974,7 +10912,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -11100,7 +11038,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -11156,7 +11094,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.12.25", + "reqwest 0.12.28", "serde", "serde_json", "serde_repr", @@ -11167,7 +11105,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tray-icon", "url", @@ -11195,7 +11133,7 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.8", + "toml 0.9.11+spec-1.1.0", "walkdir", ] @@ -11217,9 +11155,9 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.111", + "syn 2.0.114", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "url", "uuid", @@ -11235,7 +11173,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "tauri-codegen", "tauri-utils", ] @@ -11253,7 +11191,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.8", + "toml 0.9.11+spec-1.1.0", "walkdir", ] @@ -11263,13 +11201,13 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28e78fb2c09a81546bcd376d34db4bda5769270d00990daa9f0d6e7ac1107e25" dependencies = [ - "clap 4.5.53", + "clap 4.5.54", "log", "serde", "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -11284,14 +11222,14 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "tauri-plugin-dialog" -version = "2.4.2" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" dependencies = [ "log", "raw-window-handle", @@ -11301,15 +11239,15 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", ] [[package]] name = "tauri-plugin-fs" -version = "2.4.4" +version = "2.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9" +checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" dependencies = [ "anyhow", "dunce", @@ -11322,16 +11260,16 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.17", - "toml 0.9.8", + "thiserror 2.0.18", + "toml 0.9.11+spec-1.1.0", "url", ] [[package]] name = "tauri-plugin-opener" -version = "2.5.2" +version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c26b72571d25dee25667940027114e60f569fc3974f8cefbe50c2cbc5fd65e3b" +checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" dependencies = [ "dunce", "glob", @@ -11343,7 +11281,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", "windows 0.61.3", "zbus", @@ -11361,14 +11299,14 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.3.6" +version = "2.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710" +checksum = "acba6b5ca527a96cdfcc96ae09b09ccb91ddff5e33978ca6873b96ea16bb404c" dependencies = [ "serde", "serde_json", "tauri", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", "zbus", @@ -11376,16 +11314,16 @@ dependencies = [ [[package]] name = "tauri-plugin-store" -version = "2.4.1" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a77036340a97eb5bbe1b3209c31e5f27f75e6f92a52fd9dd4b211ef08bf310" +checksum = "5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea" dependencies = [ "dunce", "serde", "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -11406,7 +11344,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest 0.12.25", + "reqwest 0.12.28", "semver", "serde", "serde_json", @@ -11414,7 +11352,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tokio", "url", @@ -11440,7 +11378,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", "webkit2gtk", "webview2-com", @@ -11505,8 +11443,8 @@ dependencies = [ "serde_json", "serde_with 3.16.1", "swift-rs", - "thiserror 2.0.17", - "toml 0.9.8", + "thiserror 2.0.18", + "toml 0.9.11+spec-1.1.0", "url", "urlpattern", "uuid", @@ -11521,14 +11459,14 @@ checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" dependencies = [ "dunce", "embed-resource", - "toml 0.9.8", + "toml 0.9.11+spec-1.1.0", ] [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", "getrandom 0.3.4", @@ -11594,11 +11532,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -11609,18 +11547,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -11655,9 +11593,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" dependencies = [ "deranged", "itoa", @@ -11665,22 +11603,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" dependencies = [ "num-conv", "time-core", @@ -11724,9 +11662,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", @@ -11734,9 +11672,8 @@ dependencies = [ "parking_lot 0.12.5", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.2", "tokio-macros", - "tracing", "windows-sys 0.61.2", ] @@ -11748,7 +11685,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -11778,15 +11715,15 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.35", + "rustls 0.23.36", "tokio", ] [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -11796,12 +11733,10 @@ dependencies = [ [[package]] name = "tokio-test" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" dependencies = [ - "async-stream", - "bytes", "futures-core", "tokio", "tokio-stream", @@ -11826,9 +11761,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -11853,14 +11788,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.8" +version = "0.9.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ - "indexmap 2.12.1", + "indexmap 2.13.0", "serde_core", - "serde_spanned 1.0.3", - "toml_datetime 0.7.3", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow 0.7.14", @@ -11877,9 +11812,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] @@ -11890,7 +11825,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.12.1", + "indexmap 2.13.0", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -11901,7 +11836,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.12.1", + "indexmap 2.13.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -11910,30 +11845,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.9" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ - "indexmap 2.12.1", - "toml_datetime 0.7.3", + "indexmap 2.13.0", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow 0.7.14", ] [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow 0.7.14", ] [[package]] name = "toml_writer" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tor-async-utils" @@ -11946,7 +11881,7 @@ dependencies = [ "oneshot-fused-workaround", "pin-project", "postage", - "thiserror 2.0.17", + "thiserror 2.0.18", "void", ] @@ -11955,7 +11890,7 @@ name = "tor-basic-utils" version = "0.37.0" source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_arti_1_8_0#2a5db5823e2a5eb413d8ad433a4d3aba902bac07" dependencies = [ - "derive_more 2.1.0", + "derive_more 2.1.1", "hex", "itertools 0.14.0", "libc", @@ -11965,7 +11900,7 @@ dependencies = [ "serde", "slab", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -11979,7 +11914,7 @@ dependencies = [ "educe", "getrandom 0.3.4", "safelog", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-error", "tor-llcrypto", "zeroize", @@ -11995,13 +11930,13 @@ dependencies = [ "bytes", "caret", "derive-deftly", - "derive_more 2.1.0", + "derive_more 2.1.1", "educe", "itertools 0.14.0", "paste", "rand 0.9.2", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-basic-utils", "tor-bytes", "tor-cert", @@ -12022,9 +11957,9 @@ source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_art dependencies = [ "caret", "derive_builder_fork_arti", - "derive_more 2.1.0", + "derive_more 2.1.1", "digest 0.10.7", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-bytes", "tor-checkable", "tor-error", @@ -12039,7 +11974,7 @@ dependencies = [ "async-trait", "caret", "derive_builder_fork_arti", - "derive_more 2.1.0", + "derive_more 2.1.1", "educe", "futures", "oneshot-fused-workaround", @@ -12047,7 +11982,7 @@ dependencies = [ "rand 0.9.2", "safelog", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -12073,7 +12008,7 @@ source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_art dependencies = [ "humantime", "signature", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-llcrypto", ] @@ -12087,7 +12022,7 @@ dependencies = [ "cfg-if", "derive-deftly", "derive_builder_fork_arti", - "derive_more 2.1.0", + "derive_more 2.1.1", "downcast-rs 2.0.2", "dyn-clone", "educe", @@ -12101,7 +12036,7 @@ dependencies = [ "retry-error", "safelog", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -12148,8 +12083,8 @@ dependencies = [ "serde-value", "serde_ignored", "strum 0.27.2", - "thiserror 2.0.17", - "toml 0.9.8", + "thiserror 2.0.18", + "toml 0.9.11+spec-1.1.0", "tor-basic-utils", "tor-error", "tor-rtcompat", @@ -12165,7 +12100,7 @@ dependencies = [ "directories", "serde", "shellexpand", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-error", "tor-general-addr", ] @@ -12177,7 +12112,7 @@ source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_art dependencies = [ "digest 0.10.7", "hex", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-llcrypto", ] @@ -12188,7 +12123,7 @@ source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_art dependencies = [ "async-compression", "base64ct", - "derive_more 2.1.0", + "derive_more 2.1.1", "futures", "hex", "http 1.4.0", @@ -12196,7 +12131,7 @@ dependencies = [ "httpdate", "itertools 0.14.0", "memchr", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-circmgr", "tor-error", "tor-hscrypto", @@ -12236,7 +12171,7 @@ dependencies = [ "async-trait", "base64ct", "derive_builder_fork_arti", - "derive_more 2.1.0", + "derive_more 2.1.1", "digest 0.10.7", "educe", "event-listener", @@ -12260,7 +12195,7 @@ dependencies = [ "signature", "static_assertions", "strum 0.27.2", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tor-async-utils", "tor-basic-utils", @@ -12287,13 +12222,13 @@ name = "tor-error" version = "0.37.0" source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_arti_1_8_0#2a5db5823e2a5eb413d8ad433a4d3aba902bac07" dependencies = [ - "derive_more 2.1.0", + "derive_more 2.1.1", "futures", "paste", "retry-error", "static_assertions", "strum 0.27.2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "void", ] @@ -12303,8 +12238,8 @@ name = "tor-general-addr" version = "0.37.0" source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_arti_1_8_0#2a5db5823e2a5eb413d8ad433a4d3aba902bac07" dependencies = [ - "derive_more 2.1.0", - "thiserror 2.0.17", + "derive_more 2.1.1", + "thiserror 2.0.18", "void", ] @@ -12317,7 +12252,7 @@ dependencies = [ "base64ct", "derive-deftly", "derive_builder_fork_arti", - "derive_more 2.1.0", + "derive_more 2.1.1", "dyn-clone", "educe", "futures", @@ -12332,7 +12267,7 @@ dependencies = [ "safelog", "serde", "strum 0.27.2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -12357,7 +12292,7 @@ source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_art dependencies = [ "async-trait", "derive-deftly", - "derive_more 2.1.0", + "derive_more 2.1.1", "educe", "either", "futures", @@ -12369,7 +12304,7 @@ dependencies = [ "safelog", "slotmap-careful", "strum 0.27.2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -12401,7 +12336,7 @@ dependencies = [ "cipher", "data-encoding", "derive-deftly", - "derive_more 2.1.0", + "derive_more 2.1.1", "digest 0.10.7", "hex", "humantime", @@ -12412,7 +12347,7 @@ dependencies = [ "serde", "signature", "subtle", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-basic-utils", "tor-bytes", "tor-error", @@ -12435,7 +12370,7 @@ dependencies = [ "cfg-if", "derive-deftly", "derive_builder_fork_arti", - "derive_more 2.1.0", + "derive_more 2.1.1", "digest 0.10.7", "educe", "fs-mistrust", @@ -12449,13 +12384,13 @@ dependencies = [ "oneshot-fused-workaround", "postage", "rand 0.9.2", - "rand_core 0.9.3", + "rand_core 0.9.5", "retry-error", "safelog", "serde", "serde_with 3.16.1", "strum 0.27.2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -12487,14 +12422,14 @@ version = "0.37.0" source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_arti_1_8_0#2a5db5823e2a5eb413d8ad433a4d3aba902bac07" dependencies = [ "derive-deftly", - "derive_more 2.1.0", + "derive_more 2.1.1", "downcast-rs 2.0.2", "paste", "rand 0.9.2", "rsa", "signature", "ssh-key", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-bytes", "tor-cert", "tor-checkable", @@ -12512,7 +12447,7 @@ dependencies = [ "cfg-if", "derive-deftly", "derive_builder_fork_arti", - "derive_more 2.1.0", + "derive_more 2.1.1", "downcast-rs 2.0.2", "dyn-clone", "fs-mistrust", @@ -12525,7 +12460,7 @@ dependencies = [ "serde", "signature", "ssh-key", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-basic-utils", "tor-bytes", "tor-config", @@ -12551,14 +12486,14 @@ dependencies = [ "caret", "derive-deftly", "derive_builder_fork_arti", - "derive_more 2.1.0", + "derive_more 2.1.1", "hex", "itertools 0.14.0", "safelog", "serde", "serde_with 3.16.1", "strum 0.27.2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-basic-utils", "tor-bytes", "tor-config", @@ -12578,7 +12513,7 @@ dependencies = [ "curve25519-dalek", "der-parser 10.0.0", "derive-deftly", - "derive_more 2.1.0", + "derive_more 2.1.1", "digest 0.10.7", "ed25519-dalek", "educe", @@ -12587,7 +12522,7 @@ dependencies = [ "rand 0.9.2", "rand_chacha 0.9.0", "rand_core 0.6.4", - "rand_core 0.9.3", + "rand_core 0.9.5", "rand_jitter", "rdrand", "rsa", @@ -12598,7 +12533,7 @@ dependencies = [ "sha3", "signature", "subtle", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-error", "tor-memquota", "visibility", @@ -12613,7 +12548,7 @@ source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_art dependencies = [ "futures", "humantime", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-error", "tor-rtcompat", "tracing", @@ -12627,7 +12562,7 @@ source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_art dependencies = [ "cfg-if", "derive-deftly", - "derive_more 2.1.0", + "derive_more 2.1.1", "dyn-clone", "educe", "futures", @@ -12638,7 +12573,7 @@ dependencies = [ "slotmap-careful", "static_assertions", "sysinfo", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -12656,7 +12591,7 @@ source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_art dependencies = [ "async-trait", "bitflags 2.10.0", - "derive_more 2.1.0", + "derive_more 2.1.1", "digest 0.10.7", "futures", "hex", @@ -12666,7 +12601,7 @@ dependencies = [ "rand 0.9.2", "serde", "strum 0.27.2", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tor-basic-utils", "tor-error", @@ -12690,7 +12625,7 @@ dependencies = [ "cipher", "derive-deftly", "derive_builder_fork_arti", - "derive_more 2.1.0", + "derive_more 2.1.1", "digest 0.10.7", "educe", "enumset", @@ -12707,7 +12642,7 @@ dependencies = [ "smallvec", "strum 0.27.2", "subtle", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tinystr", "tor-basic-utils", @@ -12733,7 +12668,7 @@ source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_art dependencies = [ "amplify", "derive-deftly", - "derive_more 2.1.0", + "derive_more 2.1.1", "filetime", "fs-mistrust", "fslock", @@ -12745,7 +12680,7 @@ dependencies = [ "sanitize-filename", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tor-async-utils", "tor-basic-utils", @@ -12770,7 +12705,7 @@ dependencies = [ "criterion-cycles-per-byte", "derive-deftly", "derive_builder_fork_arti", - "derive_more 2.1.0", + "derive_more 2.1.1", "digest 0.10.7", "educe", "enum_dispatch", @@ -12784,14 +12719,14 @@ dependencies = [ "pin-project", "postage", "rand 0.9.2", - "rand_core 0.9.3", + "rand_core 0.9.5", "safelog", "slotmap-careful", "smallvec", "static_assertions", "subtle", "sync_wrapper 1.0.2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-util", "tor-async-utils", @@ -12827,7 +12762,7 @@ dependencies = [ "caret", "paste", "serde_with 3.16.1", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-bytes", ] @@ -12837,7 +12772,7 @@ version = "0.37.0" source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_arti_1_8_0#2a5db5823e2a5eb413d8ad433a4d3aba902bac07" dependencies = [ "derive-deftly", - "derive_more 2.1.0", + "derive_more 2.1.1", "humantime", "tor-cert", "tor-checkable", @@ -12870,7 +12805,7 @@ dependencies = [ "async_executors", "asynchronous-codec 0.7.0", "coarsetime", - "derive_more 2.1.0", + "derive_more 2.1.1", "dyn-clone", "educe", "futures", @@ -12880,9 +12815,9 @@ dependencies = [ "paste", "pin-project", "rustls-pki-types", - "rustls-webpki 0.103.8", - "socket2 0.6.1", - "thiserror 2.0.17", + "rustls-webpki 0.103.9", + "socket2 0.6.2", + "thiserror 2.0.18", "tokio", "tokio-util", "tor-error", @@ -12900,7 +12835,7 @@ dependencies = [ "assert_matches", "async-trait", "derive-deftly", - "derive_more 2.1.0", + "derive_more 2.1.1", "educe", "futures", "humantime", @@ -12910,7 +12845,7 @@ dependencies = [ "priority-queue", "slotmap-careful", "strum 0.27.2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-error", "tor-general-addr", "tor-rtcompat", @@ -12930,7 +12865,7 @@ dependencies = [ "educe", "safelog", "subtle", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-bytes", "tor-error", ] @@ -12941,17 +12876,17 @@ version = "0.37.0" source = "git+https://github.com/eigenwallet/arti?branch=downgraded_rusqlite_arti_1_8_0#2a5db5823e2a5eb413d8ad433a4d3aba902bac07" dependencies = [ "derive-deftly", - "derive_more 2.1.0", + "derive_more 2.1.1", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "tor-memquota", ] [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -12995,9 +12930,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -13012,7 +12947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" dependencies = [ "crossbeam-channel", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tracing-subscriber", ] @@ -13025,14 +12960,14 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -13108,14 +13043,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "tray-icon" -version = "0.21.2" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3d5572781bee8e3f994d7467084e1b1fd7a93ce66bd480f8156ba89dee55a2b" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", "dirs", @@ -13129,7 +13064,7 @@ dependencies = [ "once_cell", "png 0.17.16", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows-sys 0.60.2", ] @@ -13174,9 +13109,9 @@ dependencies = [ [[package]] name = "typed-index-collections" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5318ee4ce62a4e948a33915574021a7a953d83e84fba6e25c72ffcfd7dad35ff" +checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd" dependencies = [ "bincode 2.0.1", "serde", @@ -13196,9 +13131,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "typeshare" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19be0f411120091e76e13e5a0186d8e2bcc3e7e244afdb70152197f1a8486ceb" +checksum = "da1bf9fe204f358ffea7f8f779b53923a20278b3ab8e8d97962c5e1b3a54edb7" dependencies = [ "chrono", "serde", @@ -13213,7 +13148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "621963e302416b389a1ec177397e9e62de849a78bd8205d428608553def75350" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -13303,9 +13238,9 @@ dependencies = [ [[package]] name = "unicase" -version = "2.8.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-bidi" @@ -13406,7 +13341,7 @@ name = "unstoppableswap-gui-rs" version = "3.6.7" dependencies = [ "dfx-swiss-sdk", - "rustls 0.23.35", + "rustls 0.23.36", "serde", "serde_json", "swap", @@ -13447,14 +13382,15 @@ checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -13489,9 +13425,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ "getrandom 0.3.4", "js-sys", @@ -13532,15 +13468,15 @@ dependencies = [ [[package]] name = "vergen" -version = "9.0.6" +version = "9.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b2bf58be11fc9414104c6d3a2e464163db5ef74b12296bda593cac37b6e4777" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" dependencies = [ "anyhow", "derive_builder", "rustversion", "time", - "vergen-lib", + "vergen-lib 9.1.0", ] [[package]] @@ -13554,8 +13490,8 @@ dependencies = [ "git2", "rustversion", "time", - "vergen 9.0.6", - "vergen-lib", + "vergen 9.1.0", + "vergen-lib 0.1.6", ] [[package]] @@ -13569,6 +13505,17 @@ dependencies = [ "rustversion", ] +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -13595,7 +13542,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -13666,9 +13613,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] @@ -13681,18 +13628,18 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasix" -version = "0.12.21" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fbb4ef9bbca0c1170e0b00dd28abc9e3b68669821600cad1caaed606583c6d" +checksum = "1757e0d1f8456693c7e5c6c629bdb54884e032aa0bb53c155f6a39f94440d332" dependencies = [ "wasi 0.11.1+wasi-snapshot-preview1", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -13703,11 +13650,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -13716,9 +13664,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -13726,22 +13674,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -13761,23 +13709,22 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" dependencies = [ "cc", "downcast-rs 1.2.1", "rustix", - "scoped-tls", "smallvec", "wayland-sys", ] [[package]] name = "wayland-client" -version = "0.31.11" +version = "0.31.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" dependencies = [ "bitflags 2.10.0", "rustix", @@ -13787,9 +13734,9 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.9" +version = "0.32.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" dependencies = [ "bitflags 2.10.0", "wayland-backend", @@ -13799,9 +13746,9 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" +checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" dependencies = [ "bitflags 2.10.0", "wayland-backend", @@ -13812,23 +13759,21 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.7" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" dependencies = [ "proc-macro2", - "quick-xml 0.37.5", + "quick-xml", "quote", ] [[package]] name = "wayland-sys" -version = "0.31.7" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" dependencies = [ - "dlib", - "log", "pkg-config", ] @@ -13840,9 +13785,9 @@ checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -13928,14 +13873,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" dependencies = [ - "webpki-root-certs 1.0.4", + "webpki-root-certs 1.0.5", ] [[package]] name = "webpki-root-certs" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee3e3b5f5e80bc89f30ce8d0343bf4e5f12341c51f3e26cbeecbc7c85443e85b" +checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" dependencies = [ "rustls-pki-types", ] @@ -13966,18 +13911,18 @@ checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" [[package]] name = "webpki-roots" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" dependencies = [ "rustls-pki-types", ] [[package]] name = "webview2-com" -version = "0.38.0" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", @@ -13989,22 +13934,22 @@ dependencies = [ [[package]] name = "webview2-com-macros" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "webview2-com-sys" -version = "0.38.0" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.17", + "thiserror 2.0.18", "windows 0.61.3", "windows-core 0.61.2", ] @@ -14164,7 +14109,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -14175,7 +14120,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -14611,9 +14556,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "wl-clipboard-rs" @@ -14625,7 +14570,7 @@ dependencies = [ "log", "os_pipe", "rustix", - "thiserror 2.0.17", + "thiserror 2.0.18", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -14673,7 +14618,7 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", "webkit2gtk", "webkit2gtk-sys", @@ -14867,15 +14812,15 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "synstructure 0.13.2", ] [[package]] name = "zbus" -version = "5.12.0" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" dependencies = [ "async-broadcast", "async-executor", @@ -14891,11 +14836,11 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix 0.30.1", + "libc", "ordered-stream", + "rustix", "serde", "serde_repr", - "tokio", "tracing", "uds_windows", "uuid", @@ -14908,14 +14853,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.12.0" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "zbus_names", "zvariant", "zvariant_utils", @@ -14923,34 +14868,33 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "static_assertions", "winnow 0.7.14", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -14970,7 +14914,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "synstructure 0.13.2", ] @@ -14985,13 +14929,13 @@ dependencies = [ [[package]] name = "zeroize_derive" -version = "1.4.2" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -15025,7 +14969,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -15043,7 +14987,7 @@ dependencies = [ "flate2", "getrandom 0.3.4", "hmac", - "indexmap 2.12.1", + "indexmap 2.13.0", "liblzma", "memchr", "pbkdf2", @@ -15057,9 +15001,15 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f936044d677be1a1168fae1d03b583a285a5dd9d8cbf7b24c23aa1fc775235" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + +[[package]] +name = "zmij" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" [[package]] name = "zopfli" @@ -15118,14 +15068,13 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.8.0" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" dependencies = [ "endi", "enumflags2", "serde", - "url", "winnow 0.7.14", "zvariant_derive", "zvariant_utils", @@ -15133,26 +15082,26 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.8.0" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.111", + "syn 2.0.114", "winnow 0.7.14", ] diff --git a/Cargo.toml b/Cargo.toml index 27bbbe56cd..0ebd9f497b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,12 +40,12 @@ bdk_wallet = "2.0.0" bitcoin = { version = "0.32", features = ["rand", "serde"] } # monero-oxide -monero-address = { git = "https://github.com/kayabaNerve/monero-oxide.git", branch = "rpc-rewrite" } -monero-daemon-rpc = { git = "https://github.com/kayabaNerve/monero-oxide.git", branch = "rpc-rewrite" } -monero-interface = { git = "https://github.com/kayabaNerve/monero-oxide.git", branch = "rpc-rewrite" } -monero-oxide = { git = "https://github.com/kayabaNerve/monero-oxide.git", branch = "rpc-rewrite" } -monero-oxide-wallet = { git = "https://github.com/kayabaNerve/monero-oxide.git", package = "monero-wallet", branch = "rpc-rewrite" } -monero-simple-request-rpc = { git = "https://github.com/kayabaNerve/monero-oxide.git", branch = "rpc-rewrite" } +monero-address = { git = "https://github.com/kayabaNerve/monero-oxide.git" } +monero-daemon-rpc = { git = "https://github.com/kayabaNerve/monero-oxide.git" } +monero-interface = { git = "https://github.com/kayabaNerve/monero-oxide.git" } +monero-oxide = { git = "https://github.com/kayabaNerve/monero-oxide.git" } +monero-oxide-wallet = { git = "https://github.com/kayabaNerve/monero-oxide.git", package = "monero-wallet" } +monero-simple-request-rpc = { git = "https://github.com/kayabaNerve/monero-oxide.git" } # Cryptography curve25519-dalek = { version = "4", package = "curve25519-dalek", features = ["rand_core", "serde"] } @@ -101,6 +101,7 @@ tor-cell = { git = "https://github.com/eigenwallet/arti", branch = "downgraded_r tor-hsservice = { git = "https://github.com/eigenwallet/arti", branch = "downgraded_rusqlite_arti_1_8_0" } tor-proto = { git = "https://github.com/eigenwallet/arti", branch = "downgraded_rusqlite_arti_1_8_0" } tor-rtcompat = { git = "https://github.com/eigenwallet/arti", branch = "downgraded_rusqlite_arti_1_8_0" } +libp2p-tor = { path = "./libp2p-tor" } # Terminal Utilities console = "0.16" @@ -122,6 +123,8 @@ crunchy = { git = "https://github.com/eira-fransham/crunchy", rev = "ba7b86cea6b [workspace.lints] rust.unused_crate_dependencies = "warn" +# prevents accidental infinite recursion when implementing a trait method by calling a method with the same name +rust.unconditional_recursion = "deny" [profile.release] codegen-units = 1 diff --git a/bitcoin-wallet/Cargo.toml b/bitcoin-wallet/Cargo.toml index dd4f62af4a..430dc56e6b 100644 --- a/bitcoin-wallet/Cargo.toml +++ b/bitcoin-wallet/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "bitcoin-wallet" version = "0.1.0" -edition = "2021" +edition = "2024" [dependencies] anyhow = { workspace = true } diff --git a/bitcoin-wallet/src/lib.rs b/bitcoin-wallet/src/lib.rs index 28778567fa..69959ae161 100644 --- a/bitcoin-wallet/src/lib.rs +++ b/bitcoin-wallet/src/lib.rs @@ -41,7 +41,7 @@ pub trait BitcoinWallet: Send + Sync { async fn sign_and_finalize(&self, psbt: bitcoin::psbt::Psbt) -> Result; - async fn broadcast( + async fn ensure_broadcasted( &self, transaction: bitcoin::Transaction, kind: &str, diff --git a/bitcoin-wallet/src/primitives.rs b/bitcoin-wallet/src/primitives.rs index 4755c1369c..2104d0fa5b 100644 --- a/bitcoin-wallet/src/primitives.rs +++ b/bitcoin-wallet/src/primitives.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use bitcoin::{FeeRate, ScriptBuf, Txid}; +use bitcoin::{FeeRate, ScriptBuf, Transaction, Txid}; /// An object that can estimate fee rates and minimum relay fees. pub trait EstimateFeeRate { @@ -192,6 +192,20 @@ impl Watchable for (Txid, ScriptBuf) { } } +// Watching a transaction by watching it's first output +// (because Electrum expects a script hash). This works +// because all outputs of a transaction have the same status. +impl Watchable for Transaction { + fn id(&self) -> Txid { + self.compute_txid() + } + + fn script(&self) -> ScriptBuf { + debug_assert!(!self.output.is_empty(), "Transaction has no outputs"); + self.output[0].script_pubkey.clone() + } +} + impl Watchable for &dyn Watchable { fn id(&self) -> Txid { (*self).id() @@ -252,4 +266,13 @@ impl ScriptStatus { pub fn has_been_seen(&self) -> bool { matches!(self, ScriptStatus::InMempool | ScriptStatus::Confirmed(_)) } + + pub fn confirmations(&self) -> u32 { + match self { + ScriptStatus::Unseen => 0, + ScriptStatus::InMempool => 0, + ScriptStatus::Retrying => 0, + ScriptStatus::Confirmed(confirmed) => confirmed.confirmations(), + } + } } diff --git a/bitcoin-wallet/src/wallet.rs b/bitcoin-wallet/src/wallet.rs index 3648d866b6..fd63abe8b9 100644 --- a/bitcoin-wallet/src/wallet.rs +++ b/bitcoin-wallet/src/wallet.rs @@ -13,8 +13,6 @@ use bdk_wallet::template::{Bip84, DescriptorTemplate}; use bdk_wallet::KeychainKind; use bdk_wallet::WalletPersister; use bdk_wallet::{Balance, PersistedWallet}; -#[allow(deprecated)] -use bitcoin::bip32::ExtendedPrivKey; use bitcoin::bip32::Xpriv; use bitcoin::{psbt::Psbt as PartiallySignedTransaction, Address, Amount, Transaction, Txid}; use bitcoin::{Psbt, ScriptBuf, Weight}; @@ -68,7 +66,7 @@ pub trait BitcoinTauriBackgroundTask: Send + Sync { } pub trait BitcoinWalletSeed { - fn derive_extended_private_key(&self, network: bitcoin::Network) -> Result; + fn derive_extended_private_key(&self, network: bitcoin::Network) -> Result; /// Same as `derive_extended_private_key`, but using the legacy BDK API. /// @@ -84,7 +82,8 @@ pub trait BitcoinWalletSeed { const TWENTY_PERCENT: Decimal = Decimal::from_parts(20, 0, 0, false, 2); pub const MAX_RELATIVE_TX_FEE: Decimal = TWENTY_PERCENT; pub const MAX_ABSOLUTE_TX_FEE: Amount = Amount::from_sat(100_000); -pub const MIN_ABSOLUTE_TX_FEE: Amount = Amount::from_sat(1000); +pub const MIN_ABSOLUTE_TX_FEE_SATS: u64 = 1000; +pub const MIN_ABSOLUTE_TX_FEE: Amount = Amount::from_sat(MIN_ABSOLUTE_TX_FEE_SATS); pub const DUST_AMOUNT: Amount = Amount::from_sat(546); /// This is our wrapper around a bdk wallet and a corresponding @@ -760,6 +759,25 @@ impl Wallet { Ok((txid, subscription)) } + /// Broadcast a transaction, but only if it's not already in the mempool/blockchain. + /// Return txid and a subcription to it's status in either case. + pub async fn ensure_broadcasted( + &self, + tx: Transaction, + kind: &str, + ) -> Result<(Txid, Subscription)> { + let txid = tx.compute_txid(); + + let status = self.status_of_script(&tx).await?; + + if matches!(status, ScriptStatus::InMempool | ScriptStatus::Confirmed(_)) { + let subscription = self.subscribe_to(Box::new(tx)).await; + return Ok((txid, subscription)); + } + + self.broadcast(tx, kind).await + } + pub async fn get_raw_transaction(&self, txid: Txid) -> Result>> { self.get_tx(txid) .await @@ -1756,11 +1774,15 @@ impl Client { // Collect all successful history entries from all servers. let mut all_history_items: Vec = Vec::new(); + let mut any_success = false; let mut first_error = None; for result in results { match result { - Ok(history) => all_history_items.extend(history), + Ok(history) => { + any_success = true; + all_history_items.extend(history); + } Err(e) => { if first_error.is_none() { first_error = Some(e); @@ -1769,12 +1791,10 @@ impl Client { } } - // If we got no history items at all, and there was an error, propagate it. - // Otherwise, it's valid for a script to have no history. - if all_history_items.is_empty() { - if let Some(err) = first_error { - return Err(err.into()); - } + // If any of the calls succeeded, that is fine. Only if none + // succeeded we return the error. + if !any_success && let Some(err) = first_error { + return Err(err.into()); } // Use a map to find the best (highest confirmation) entry for each transaction. @@ -2122,12 +2142,12 @@ impl BitcoinWallet for Wallet { Wallet::sign_and_finalize(self, psbt).await } - async fn broadcast( + async fn ensure_broadcasted( &self, - transaction: bitcoin::Transaction, + tx: bitcoin::Transaction, kind: &str, ) -> Result<(Txid, Subscription)> { - Wallet::broadcast(self, transaction, kind).await + Wallet::ensure_broadcasted(self, tx, kind).await } async fn sync(&self) -> Result<()> { @@ -2220,7 +2240,7 @@ impl EstimateFeeRate for Client { } async fn min_relay_fee(&self) -> Result { - self.min_relay_fee().await + Client::min_relay_fee(self).await } } @@ -2893,6 +2913,14 @@ impl TestWalletBuilder { #[async_trait::async_trait] #[allow(unused)] impl BitcoinWallet for Wallet { + async fn ensure_broadcasted( + &self, + tx: bitcoin::Transaction, + kind: &str, + ) -> Result<(Txid, Subscription)> { + unimplemented!("stub method called erroneously") + } + async fn balance(&self) -> Result { unimplemented!("stub method called erroneously") } @@ -2932,14 +2960,6 @@ impl BitcoinWallet for Wallet { unimplemented!("stub method called erroneously") } - async fn broadcast( - &self, - transaction: bitcoin::Transaction, - kind: &str, - ) -> Result<(Txid, Subscription)> { - unimplemented!("stub method called erroneously") - } - async fn sync(&self) -> Result<()> { unimplemented!("stub method called erroneously") } diff --git a/justfile b/justfile index f77297e6c7..a561eee619 100644 --- a/justfile +++ b/justfile @@ -30,10 +30,10 @@ test-ffi-address: # Start the Tauri app tauri: - cd src-tauri && cargo tauri dev --no-watch -- -vv -- --testnet + cd src-tauri && RUST_MIN_STACK=41943040 RUST_BACKTRACE=1 cargo tauri dev --no-watch -- -vv -- --testnet tauri-mainnet: - cd src-tauri && cargo tauri dev --no-watch -- -vv + cd src-tauri && RUST_BACKTRACE=1 cargo tauri dev --no-watch -- -vv # Install the GUI dependencies gui_install: @@ -60,9 +60,13 @@ build-gui-windows: tests: cargo nextest run +# List all available docker integration tests +list-docker-tests: + @find swap/tests -maxdepth 1 -type f -name "*.rs" | xargs -n1 basename | sed 's/\.rs$//' | sort + # Run docker tests (e.g., "just docker_test happy_path_alice_developer_tip") docker_test test_name: - cargo test --package swap --test {{test_name}} -- --nocapture + RUST_BACKTRACE=1 cargo test --package swap --test {{test_name}} -- --nocapture docker_test_happy_path: just docker_test happy_path @@ -83,7 +87,7 @@ swap: # Run the asb on testnet asb-testnet: - ASB_DEV_ADDR_OUTPUT_PATH="$PWD/src-gui/.env.development" cargo run -p swap-asb --bin asb -- --testnet --trace start --rpc-bind-port 9944 --rpc-bind-host 0.0.0.0 + cargo run -p swap-asb --bin asb -- --testnet --trace start --rpc-bind-port 9944 --rpc-bind-host 0.0.0.0 # Launch the ASB controller REPL against a local testnet ASB instance asb-testnet-controller: @@ -108,10 +112,14 @@ fmt: generate-sqlx-cache: ./dev-scripts/regenerate_sqlx_cache.sh + +alias eslint := check_gui_eslint # Run eslint for the GUI frontend check_gui_eslint: cd src-gui && yarn run eslint +alias tsc := check_gui_tsc + # Run the typescript type checker for the GUI frontend check_gui_tsc: cd src-gui && yarn run tsc --noEmit diff --git a/monero-harness/src/lib.rs b/monero-harness/src/lib.rs index 0a7dee5eca..b561cee0c8 100644 --- a/monero-harness/src/lib.rs +++ b/monero-harness/src/lib.rs @@ -300,7 +300,8 @@ impl<'c> Monero { Ok(()) } - pub async fn generate_block(&self) -> Result<()> { + /// Generates 15 blocks + pub async fn generate_blocks(&self) -> Result<()> { let miner_wallet = self.wallet("miner")?; let miner_address = miner_wallet.address().await?.to_string(); self.monerod().generate_blocks(15, &miner_address).await?; diff --git a/monero-harness/tests/wallet.rs b/monero-harness/tests/wallet.rs index 3a47c0fc70..830d70467c 100644 --- a/monero-harness/tests/wallet.rs +++ b/monero-harness/tests/wallet.rs @@ -53,7 +53,7 @@ async fn fund_transfer_and_check_tx_key() { .await .unwrap(); - monero.generate_block().await.unwrap(); + monero.generate_blocks().await.unwrap(); tracing::info!("Waiting for Bob to catch up"); @@ -69,7 +69,7 @@ async fn fund_transfer_and_check_tx_key() { .await .unwrap(); - monero.generate_block().await.unwrap(); + monero.generate_blocks().await.unwrap(); wait_for_wallet_to_catch_up(bob_wallet, 0).await; @@ -86,7 +86,7 @@ async fn fund_transfer_and_check_tx_key() { .await .unwrap(); - monero.generate_block().await.unwrap(); + monero.generate_blocks().await.unwrap(); wait_for_wallet_to_catch_up(alice_wallet, 0).await; diff --git a/monero-oxide-ext/src/lib.rs b/monero-oxide-ext/src/lib.rs index 0517cafcd0..67d61344de 100644 --- a/monero-oxide-ext/src/lib.rs +++ b/monero-oxide-ext/src/lib.rs @@ -254,6 +254,14 @@ impl Amount { self.0 } + /// Get the amount in Monero. + pub fn as_xmr(self) -> f64 { + // Inefficient, but most safe way: monero-rs does it this way, too + let mut buf = String::new(); + fmt_piconero_in_xmr(self.as_pico(), &mut buf).expect("string to be writable"); + buf.parse().expect("Monero amount is floating point number") + } + /// Create an [`Amount`] with monero precision and the given number of monero, string in the format `"1.2"` or `"1"`. pub fn parse_monero(xmr: &str) -> Result { if xmr.is_empty() { diff --git a/monero-sys/build.rs b/monero-sys/build.rs index d5f60c851c..13e2a26121 100644 --- a/monero-sys/build.rs +++ b/monero-sys/build.rs @@ -144,7 +144,7 @@ fn main() { .display() .to_string(); config.define("CMAKE_TOOLCHAIN_FILE", toolchain_file.clone()); - println!("cargo:warning=Using toolchain file: {toolchain_file}"); + println!("cargo:debug=Using toolchain file: {toolchain_file}"); let depends_lib_dir = contrib_depends_dir.join(format!("{target}/lib")); @@ -443,7 +443,7 @@ fn compile_dependencies( "aarch64-apple-ios-sim" => "aarch64-apple-iossimulator".to_string(), _ => target, }; - println!("cargo:warning=Building for target: {target}"); + println!("cargo:debug=Building for target: {target}"); match target.as_str() { "x86_64-apple-darwin" @@ -459,7 +459,7 @@ fn compile_dependencies( _ => panic!("target unsupported: {target}"), } - println!("cargo:warning=Running make HOST={target} in contrib/depends",); + println!("cargo:debug=Running make HOST={target} in contrib/depends",); // Copy monero-depends to out_dir/depends in order to build the dependencies there match fs_extra::copy_items( @@ -554,7 +554,7 @@ fn apply_patches() -> Result<(), Box> { for embedded in EMBEDDED_PATCHES { println!( - "cargo:warning=Processing embedded patch: {} ({})", + "cargo:debug=Processing embedded patch: {} ({})", embedded.name, embedded.description ); @@ -567,14 +567,14 @@ fn apply_patches() -> Result<(), Box> { } println!( - "cargo:warning=Found {} file(s) in patch {}", + "cargo:debug=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}"); + println!("cargo:debug=Applying patch to file: {file_path}"); // Parse the individual file patch let patch = diffy::Patch::from_str(&patch_content) @@ -591,7 +591,7 @@ fn apply_patches() -> Result<(), Box> { // Check if patch is already applied by trying to reverse it if diffy::apply(¤t, &patch.reverse()).is_ok() { - println!("cargo:warning=Patch for {file_path} already applied – skipping",); + println!("cargo:debug=Patch for {file_path} already applied – skipping",); continue; } @@ -601,11 +601,11 @@ fn apply_patches() -> Result<(), Box> { 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:debug=Successfully applied patch to: {file_path}"); } println!( - "cargo:warning=Successfully applied all file patches for: {} ({})", + "cargo:debug=Successfully applied all file patches for: {} ({})", embedded.name, embedded.description ); } diff --git a/monero-tests/Cargo.toml b/monero-tests/Cargo.toml index d3e25d28c5..9b83fb1e2a 100644 --- a/monero-tests/Cargo.toml +++ b/monero-tests/Cargo.toml @@ -3,6 +3,7 @@ name = "monero-tests" version = "0.1.0" edition = "2024" + [dev-dependencies] monero-address = { workspace = true } monero-daemon-rpc = { workspace = true } @@ -22,5 +23,7 @@ tracing-subscriber = { workspace = true } uuid = { workspace = true } zeroize = { workspace = true } -[lints] -workspace = true +# Each test counts as a seperate crate, and would individually get +# warnigns for "unused" dependencies. This silences them +[lints.rust] +unused_crate_dependencies = "allow" diff --git a/monero-tests/src/lib.rs b/monero-tests/src/lib.rs index 2235e70956..8b13789179 100644 --- a/monero-tests/src/lib.rs +++ b/monero-tests/src/lib.rs @@ -1 +1 @@ -// Empty but necessary for cargo + diff --git a/monero-tests/tests/reserve_proof.rs b/monero-tests/tests/reserve_proof.rs index c21af2f304..20960b82d4 100644 --- a/monero-tests/tests/reserve_proof.rs +++ b/monero-tests/tests/reserve_proof.rs @@ -31,7 +31,7 @@ async fn setup(cli: &Cli) -> anyhow::Result> { // Fund alice's wallet miner.sweep(&alice.address().await?).await?; - monero.generate_block().await?; + monero.generate_blocks().await?; alice.refresh().await?; let alice_balance = alice.balance().await?; diff --git a/monero-tests/tests/subaddresses.rs b/monero-tests/tests/subaddresses.rs index 29e5704f10..a8a2ae4ded 100644 --- a/monero-tests/tests/subaddresses.rs +++ b/monero-tests/tests/subaddresses.rs @@ -53,16 +53,16 @@ async fn subaddress_methods_and_balances() -> anyhow::Result<()> { ); // Mine a block to confirm the transaction and make funds visible - monero.generate_block().await?; + monero.generate_blocks().await?; // Import tx keys so Alice scans this transaction explicitly let sa1_txkey = tx_receipt .tx_keys - .get(&alice_sa1) + .get(&alice_sa1.to_string()) .context("tx key not found for alice subaddress 1")?; let sa2_txkey = tx_receipt .tx_keys - .get(&alice_sa2) + .get(&alice_sa2.to_string()) .context("tx key not found for alice subaddress 2")?; tracing::info!("Importing tx keys for Alice subaddresses"); @@ -82,7 +82,7 @@ async fn subaddress_methods_and_balances() -> anyhow::Result<()> { let bal_sa1 = per_sub.get(&1).copied().unwrap_or(0); let bal_sa2 = per_sub.get(&2).copied().unwrap_or(0); - tracing::info!(bal_sa1=%monero::Amount::from_pico(bal_sa1), bal_sa2=%monero::Amount::from_pico(bal_sa2), "Per-subaddress balances"); + tracing::info!(bal_sa1=%monero_oxide_ext::Amount::from_pico(bal_sa1), bal_sa2=%monero_oxide_ext::Amount::from_pico(bal_sa2), "Per-subaddress balances"); assert!(bal_sa1 > 0, "Subaddress 1 expected to have received funds"); assert!(bal_sa2 > 0, "Subaddress 2 expected to have received funds"); diff --git a/monero-tests/tests/transfers.rs b/monero-tests/tests/transfers.rs index a6c98ffd84..fde1b015a7 100644 --- a/monero-tests/tests/transfers.rs +++ b/monero-tests/tests/transfers.rs @@ -42,7 +42,7 @@ async fn monero_transfers() -> anyhow::Result<()> { "Expect one tx key for each non-change output" ); - monero.generate_block().await?; + monero.generate_blocks().await?; let alice_txkey = tx_receipt .tx_keys diff --git a/monero-tests/tests/transfers_wrong_key.rs b/monero-tests/tests/transfers_wrong_key.rs index 407798c9aa..95c7ba12e7 100644 --- a/monero-tests/tests/transfers_wrong_key.rs +++ b/monero-tests/tests/transfers_wrong_key.rs @@ -41,7 +41,7 @@ async fn monero_transfers_wrong_key() { "Expect one tx key for the output" ); - monero.generate_block().await.unwrap(); + monero.generate_blocks().await.unwrap(); // Use a wrong private key (just a simple constant key, not the real transfer key) let wrong_key = monero_oxide_ext::PrivateKey::from_slice(&[ diff --git a/src-gui/src/dev/mockSwapEvents.ts b/src-gui/src/dev/mockSwapEvents.ts new file mode 100644 index 0000000000..56e05ce917 --- /dev/null +++ b/src-gui/src/dev/mockSwapEvents.ts @@ -0,0 +1,385 @@ +import { + ApprovalRequest, + BidQuote, + LockBitcoinDetails, + MoneroAddressPool, + QuoteWithAddress, + TauriSwapProgressEvent, +} from "models/tauriModel"; + +// Mock transaction IDs +const MOCK_BTC_LOCK_TXID = + "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"; +const MOCK_XMR_LOCK_TXID = + "a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8"; +const MOCK_XMR_REDEEM_TXID = + "b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9"; +const MOCK_BTC_CANCEL_TXID = + "c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0"; +const MOCK_BTC_REFUND_TXID = + "d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1"; +const MOCK_BTC_EARLY_REFUND_TXID = + "e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2"; +const MOCK_BTC_PARTIAL_REFUND_TXID = + "f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3"; +const MOCK_BTC_AMNESTY_TXID = + "a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4"; +const MOCK_BTC_REFUND_BURN_TXID = + "b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5"; +const MOCK_BTC_FINAL_AMNESTY_TXID = + "c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"; + +// Mock timelock blocks for earnest deposit +const EARNEST_DEPOSIT_TARGET_BLOCKS = 3; + +// Mock amounts for partial refund scenarios +const MOCK_BTC_LOCK_AMOUNT = 50_000_000; // 0.5 BTC +const MOCK_BTC_AMNESTY_AMOUNT = 1_000_000; // 0.01 BTC (2% of lock amount) + +// Mock addresses +const MOCK_BTC_DEPOSIT_ADDRESS = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"; +const MOCK_XMR_ADDRESS = + "888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H"; + +export const MOCK_SWAP_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + +const MOCK_QUOTE: BidQuote = { + price: 0.007, + min_quantity: 10_000_000, + max_quantity: 100_000_000, + refund_policy: { type: "FullRefund" }, +}; + +const MOCK_QUOTE_WITH_ADDRESS: QuoteWithAddress = { + multiaddr: "/ip4/127.0.0.1/tcp/9939", + peer_id: "12D3KooWCdMKjesXMJz1SiZ7HgotrxuqhQJbP5sgBm2BwP1cqThi", + quote: MOCK_QUOTE, + version: "3.6.1", +}; + +const MOCK_QUOTE_PARTIAL_REFUND: BidQuote = { + price: 0.0068, + min_quantity: 5_000_000, + max_quantity: 200_000_000, + refund_policy: { type: "PartialRefund", content: { anti_spam_deposit_ratio: 0.02 } }, +}; + +const MOCK_QUOTE_WITH_ADDRESS_PARTIAL: QuoteWithAddress = { + multiaddr: "/ip4/192.168.1.50/tcp/9940", + peer_id: "12D3KooWEyoppNCUzN3sX7atGxPHvqgZvUNQmKzz1mQvNfFhuqP9", + quote: MOCK_QUOTE_PARTIAL_REFUND, + version: "3.6.1", +}; + +const MOCK_RECEIVE_POOL: MoneroAddressPool = [ + { address: MOCK_XMR_ADDRESS, percentage: 100, label: "Main" }, +]; + +const XMR_TARGET_CONFIRMATIONS = 10; + +// Base scenario: swap start -> XMR locked (10 confirmations) +const baseScenario: TauriSwapProgressEvent[] = [ + { type: "ReceivedQuote", content: MOCK_QUOTE }, + { + type: "WaitingForBtcDeposit", + content: { + deposit_address: MOCK_BTC_DEPOSIT_ADDRESS, + max_giveable: 0, + min_bitcoin_lock_tx_fee: 1000, + known_quotes: [MOCK_QUOTE_WITH_ADDRESS, MOCK_QUOTE_WITH_ADDRESS_PARTIAL], + }, + }, + { type: "SwapSetupInflight", content: { btc_lock_amount: 50_000_000 } }, + { type: "RetrievingMoneroBlockheight" }, + { type: "BtcLockPublishInflight" }, + // BTC lock confirmations: 0, 1, 2 + { type: "BtcLockTxInMempool", content: { btc_lock_txid: MOCK_BTC_LOCK_TXID, btc_lock_confirmations: 0 } }, + { type: "BtcLockTxInMempool", content: { btc_lock_txid: MOCK_BTC_LOCK_TXID, btc_lock_confirmations: 1 } }, + { type: "BtcLockTxInMempool", content: { btc_lock_txid: MOCK_BTC_LOCK_TXID, btc_lock_confirmations: 2 } }, + { type: "VerifyingXmrLockTx", content: { xmr_lock_txid: MOCK_XMR_LOCK_TXID } }, + // XMR lock confirmations: 0 through 10 + ...Array.from({ length: XMR_TARGET_CONFIRMATIONS + 1 }, (_, i) => ({ + type: "XmrLockTxInMempool" as const, + content: { + xmr_lock_txid: MOCK_XMR_LOCK_TXID, + xmr_lock_tx_confirmations: i, + xmr_lock_tx_target_confirmations: XMR_TARGET_CONFIRMATIONS, + }, + })), +]; + +const happyPath: TauriSwapProgressEvent[] = [ + ...baseScenario, + { type: "PreflightEncSig" }, + { type: "InflightEncSig" }, + { type: "EncryptedSignatureSent" }, + { type: "RedeemingMonero" }, + { + type: "XmrRedeemInMempool", + content: { xmr_redeem_txids: [MOCK_XMR_REDEEM_TXID], xmr_receive_pool: MOCK_RECEIVE_POOL }, + }, + { type: "Released" }, +]; + +const cooperativeRedeem: TauriSwapProgressEvent[] = [ + ...baseScenario, + { type: "AttemptingCooperativeRedeem" }, + { type: "CooperativeRedeemAccepted" }, + { type: "RedeemingMonero" }, + { + type: "XmrRedeemInMempool", + content: { xmr_redeem_txids: [MOCK_XMR_REDEEM_TXID], xmr_receive_pool: MOCK_RECEIVE_POOL }, + }, + { type: "Released" }, +]; + +const cooperativeRedeemRejected: TauriSwapProgressEvent[] = [ + ...baseScenario, + { type: "AttemptingCooperativeRedeem" }, + { type: "CooperativeRedeemRejected", content: { reason: "Peer offline" } }, + { type: "WaitingForCancelTimelockExpiration" }, + { type: "CancelTimelockExpired" }, + { type: "BtcCancelled", content: { btc_cancel_txid: MOCK_BTC_CANCEL_TXID } }, + { type: "BtcRefundPublished", content: { btc_refund_txid: MOCK_BTC_REFUND_TXID } }, + { type: "BtcRefunded", content: { btc_refund_txid: MOCK_BTC_REFUND_TXID } }, + { type: "Released" }, +]; + +const earlyRefund: TauriSwapProgressEvent[] = [ + ...baseScenario, + { type: "BtcEarlyRefundPublished", content: { btc_early_refund_txid: MOCK_BTC_EARLY_REFUND_TXID } }, + { type: "BtcEarlyRefunded", content: { btc_early_refund_txid: MOCK_BTC_EARLY_REFUND_TXID } }, + { type: "Released" }, +]; + +const partialRefundWithAmnesty: TauriSwapProgressEvent[] = [ + ...baseScenario, + { type: "WaitingForCancelTimelockExpiration" }, + { type: "CancelTimelockExpired" }, + { type: "BtcCancelled", content: { btc_cancel_txid: MOCK_BTC_CANCEL_TXID } }, + { + type: "BtcPartialRefundPublished", + content: { + btc_partial_refund_txid: MOCK_BTC_PARTIAL_REFUND_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + }, + }, + { + type: "BtcPartiallyRefunded", + content: { + btc_partial_refund_txid: MOCK_BTC_PARTIAL_REFUND_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + }, + }, + // Waiting for earnest deposit timelock: 3/3, 2/3, 1/3, 0/3 blocks remaining + ...Array.from({ length: EARNEST_DEPOSIT_TARGET_BLOCKS + 1 }, (_, i) => ({ + type: "WaitingForEarnestDepositTimelockExpiration" as const, + content: { + btc_partial_refund_txid: MOCK_BTC_PARTIAL_REFUND_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + target_blocks: EARNEST_DEPOSIT_TARGET_BLOCKS, + blocks_until_expiry: EARNEST_DEPOSIT_TARGET_BLOCKS - i, + }, + })), + { + type: "BtcAmnestyPublished", + content: { + btc_amnesty_txid: MOCK_BTC_AMNESTY_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + }, + }, + { + type: "BtcAmnestyReceived", + content: { + btc_amnesty_txid: MOCK_BTC_AMNESTY_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + }, + }, + { type: "Released" }, +]; + +const partialRefundWithBurn: TauriSwapProgressEvent[] = [ + ...baseScenario, + { type: "WaitingForCancelTimelockExpiration" }, + { type: "CancelTimelockExpired" }, + { type: "BtcCancelled", content: { btc_cancel_txid: MOCK_BTC_CANCEL_TXID } }, + { + type: "BtcPartialRefundPublished", + content: { + btc_partial_refund_txid: MOCK_BTC_PARTIAL_REFUND_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + }, + }, + { + type: "BtcPartiallyRefunded", + content: { + btc_partial_refund_txid: MOCK_BTC_PARTIAL_REFUND_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + }, + }, + // Waiting for earnest deposit timelock: 3/3, 2/3, 1/3, 0/3 blocks remaining + ...Array.from({ length: EARNEST_DEPOSIT_TARGET_BLOCKS + 1 }, (_, i) => ({ + type: "WaitingForEarnestDepositTimelockExpiration" as const, + content: { + btc_partial_refund_txid: MOCK_BTC_PARTIAL_REFUND_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + target_blocks: EARNEST_DEPOSIT_TARGET_BLOCKS, + blocks_until_expiry: EARNEST_DEPOSIT_TARGET_BLOCKS - i, + }, + })), + { + type: "BtcRefundBurnPublished", + content: { + btc_refund_burn_txid: MOCK_BTC_REFUND_BURN_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + }, + }, + { + type: "BtcRefundBurnt", + content: { + btc_refund_burn_txid: MOCK_BTC_REFUND_BURN_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + }, + }, + { type: "Released" }, +]; + +const partialRefundWithBurnAndFinalAmnesty: TauriSwapProgressEvent[] = [ + ...baseScenario, + { type: "WaitingForCancelTimelockExpiration" }, + { type: "CancelTimelockExpired" }, + { type: "BtcCancelled", content: { btc_cancel_txid: MOCK_BTC_CANCEL_TXID } }, + { + type: "BtcPartialRefundPublished", + content: { + btc_partial_refund_txid: MOCK_BTC_PARTIAL_REFUND_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + }, + }, + { + type: "BtcPartiallyRefunded", + content: { + btc_partial_refund_txid: MOCK_BTC_PARTIAL_REFUND_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + }, + }, + // Waiting for earnest deposit timelock: 3/3, 2/3, 1/3, 0/3 blocks remaining + ...Array.from({ length: EARNEST_DEPOSIT_TARGET_BLOCKS + 1 }, (_, i) => ({ + type: "WaitingForEarnestDepositTimelockExpiration" as const, + content: { + btc_partial_refund_txid: MOCK_BTC_PARTIAL_REFUND_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + target_blocks: EARNEST_DEPOSIT_TARGET_BLOCKS, + blocks_until_expiry: EARNEST_DEPOSIT_TARGET_BLOCKS - i, + }, + })), + { + type: "BtcRefundBurnPublished", + content: { + btc_refund_burn_txid: MOCK_BTC_REFUND_BURN_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + }, + }, + { + type: "BtcRefundBurnt", + content: { + btc_refund_burn_txid: MOCK_BTC_REFUND_BURN_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + }, + }, + { + type: "BtcFinalAmnestyPublished", + content: { + btc_final_amnesty_txid: MOCK_BTC_FINAL_AMNESTY_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + }, + }, + { + type: "BtcFinalAmnestyConfirmed", + content: { + btc_final_amnesty_txid: MOCK_BTC_FINAL_AMNESTY_TXID, + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + }, + }, + { type: "Released" }, +]; + +export const scenarios: Record = { + happyPath, + cooperativeRedeem, + cooperativeRedeemRejected, + earlyRefund, + partialRefundWithAmnesty, + partialRefundWithBurn, + partialRefundWithBurnAndFinalAmnesty, +}; + +export type MockScenario = keyof typeof scenarios; + +// Mock LockBitcoin approval requests for testing confirmation screen + +// Partial refund version (5% amnesty) +const MOCK_LOCK_BITCOIN_DETAILS_PARTIAL: LockBitcoinDetails = { + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_network_fee: 5000, + xmr_receive_amount: 7_000_000_000_000, // 7 XMR in piconeros + monero_receive_pool: MOCK_RECEIVE_POOL, + swap_id: MOCK_SWAP_ID, + btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, + has_full_refund_signature: false, +}; + +// Full refund version (no amnesty) +const MOCK_LOCK_BITCOIN_DETAILS_FULL: LockBitcoinDetails = { + btc_lock_amount: MOCK_BTC_LOCK_AMOUNT, + btc_network_fee: 5000, + xmr_receive_amount: 7_000_000_000_000, // 7 XMR in piconeros + monero_receive_pool: MOCK_RECEIVE_POOL, + swap_id: MOCK_SWAP_ID, + btc_amnesty_amount: 0, + has_full_refund_signature: true, +}; + +const PARTIAL_REFUND_SCENARIOS: MockScenario[] = [ + "partialRefundWithAmnesty", + "partialRefundWithBurn", + "partialRefundWithBurnAndFinalAmnesty", +]; + +export function isPartialRefundScenario(scenario: MockScenario): boolean { + return PARTIAL_REFUND_SCENARIOS.includes(scenario); +} + +export function getMockLockBitcoinApproval(scenario: MockScenario | null): ApprovalRequest { + const isPartial = scenario !== null && isPartialRefundScenario(scenario); + return { + request_id: "00000000-0000-0000-0000-000000000001", + request: { + type: "LockBitcoin", + content: isPartial ? MOCK_LOCK_BITCOIN_DETAILS_PARTIAL : MOCK_LOCK_BITCOIN_DETAILS_FULL, + }, + request_status: { + state: "Pending", + content: { + expiration_ts: Number.MAX_SAFE_INTEGER, + }, + }, + }; +} diff --git a/src-gui/src/models/storeModel.ts b/src-gui/src/models/storeModel.ts index 56e14588be..9065265e8d 100644 --- a/src-gui/src/models/storeModel.ts +++ b/src-gui/src/models/storeModel.ts @@ -11,4 +11,6 @@ export interface SwapSlice { state: SwapState | null; logs: CliLog[]; spawnType: SwapSpawnType | null; + /** DEV ONLY: When true, prevents Tauri calls in the swap progress listener */ + _mockOnlyDisableTauriCallsOnSwapProgress: boolean; } diff --git a/src-gui/src/models/tauriModelExt.ts b/src-gui/src/models/tauriModelExt.ts index a4bb74d7a0..f3c9efdaaa 100644 --- a/src-gui/src/models/tauriModelExt.ts +++ b/src-gui/src/models/tauriModelExt.ts @@ -51,9 +51,17 @@ export enum BobStateName { CancelTimelockExpired = "cancel timelock is expired", BtcCancelled = "btc is cancelled", BtcRefundPublished = "btc refund is published", + BtcPartialRefundPublished = "btc partial refund is published", BtcEarlyRefundPublished = "btc early refund is published", BtcRefunded = "btc is refunded", BtcEarlyRefunded = "btc is early refunded", + BtcPartiallyRefunded = "btc is partially refunded", + BtcAmnestyPublished = "btc amnesty is published", + BtcAmnestyReceived = "btc amnesty is confirmed", + BtcRefundBurnPublished = "btc refund burn is published", + BtcRefundBurnt = "btc refund is burnt", + BtcFinalAmnestyPublished = "btc final amnesty is published", + BtcFinalAmnestyConfirmed = "btc final amnesty is confirmed", XmrRedeemed = "xmr is redeemed", BtcPunished = "btc is punished", SafelyAborted = "safely aborted", @@ -87,10 +95,26 @@ export function bobStateNameToHumanReadable(stateName: BobStateName): string { return "Bitcoin refund published"; case BobStateName.BtcEarlyRefundPublished: return "Bitcoin early refund published"; + case BobStateName.BtcPartialRefundPublished: + return "Bitcoin partial refund published"; + case BobStateName.BtcAmnestyPublished: + return "Bitcoin amnesty was granted"; case BobStateName.BtcRefunded: return "Bitcoin refunded"; case BobStateName.BtcEarlyRefunded: return "Bitcoin early refunded"; + case BobStateName.BtcPartiallyRefunded: + return "Bitcoin partially refunded"; + case BobStateName.BtcAmnestyReceived: + return "Bitcoin amnesty was received"; + case BobStateName.BtcRefundBurnPublished: + return "Bitcoin refund burn published"; + case BobStateName.BtcRefundBurnt: + return "Bitcoin refund is burnt"; + case BobStateName.BtcFinalAmnestyPublished: + return "Bitcoin final amnesty published"; + case BobStateName.BtcFinalAmnestyConfirmed: + return "Bitcoin final amnesty received"; case BobStateName.XmrRedeemed: return "Monero redeemed"; case BobStateName.BtcPunished: @@ -110,6 +134,14 @@ export type GetSwapInfoResponseExt = GetSwapInfoResponse & { export type TimelockNone = Extract; export type TimelockCancel = Extract; export type TimelockPunish = Extract; +export type TimelockWaitingForRemainingRefund = Extract< + ExpiredTimelocks, + { type: "WaitingForRemainingRefund" } +>; +export type TimelockRemainingRefund = Extract< + ExpiredTimelocks, + { type: "RemainingRefund" } +>; // This function returns the absolute block number of the timelock relative to the block the tx_lock was included in export function getAbsoluteBlock( @@ -126,6 +158,13 @@ export function getAbsoluteBlock( if (timelock.type === "Punish") { return cancelTimelock + punishTimelock; } + // These states are for the partial refund path - we're past cancel/punish timelocks + if (timelock.type === "WaitingForRemainingRefund") { + return cancelTimelock + punishTimelock; + } + if (timelock.type === "RemainingRefund") { + return cancelTimelock + punishTimelock; + } // We match all cases return exhaustiveGuard(timelock); @@ -136,7 +175,11 @@ export type BobStateNameRunningSwap = Exclude< | BobStateName.Started | BobStateName.SwapSetupCompleted | BobStateName.BtcRefunded + | BobStateName.BtcAmnestyReceived + | BobStateName.BtcRefunded | BobStateName.BtcEarlyRefunded + | BobStateName.BtcRefundBurnt + | BobStateName.BtcFinalAmnestyConfirmed | BobStateName.BtcPunished | BobStateName.SafelyAborted | BobStateName.XmrRedeemed @@ -154,6 +197,9 @@ export function isBobStateNameRunningSwap( BobStateName.SwapSetupCompleted, BobStateName.BtcRefunded, BobStateName.BtcEarlyRefunded, + BobStateName.BtcAmnestyReceived, + BobStateName.BtcRefundBurnt, + BobStateName.BtcFinalAmnestyConfirmed, BobStateName.BtcPunished, BobStateName.SafelyAborted, BobStateName.XmrRedeemed, diff --git a/src-gui/src/renderer/background.ts b/src-gui/src/renderer/background.ts index ba98e572df..2c03d77520 100644 --- a/src-gui/src/renderer/background.ts +++ b/src-gui/src/renderer/background.ts @@ -135,7 +135,10 @@ listen(TAURI_UNIFIED_EVENT_CHANNEL_NAME, (event) => { switch (channelName) { case "SwapProgress": - store.dispatch(swapProgressEventReceived(eventData)); + // Skip when mocking is enabled (DEV only) - mock dispatches bypass this listener + if (!store.getState().swap._mockOnlyDisableTauriCallsOnSwapProgress) { + store.dispatch(swapProgressEventReceived(eventData)); + } break; case "CliLog": diff --git a/src-gui/src/renderer/components/alert/SwapStatusAlert/SwapStatusAlert.tsx b/src-gui/src/renderer/components/alert/SwapStatusAlert/SwapStatusAlert.tsx index d75d618b3d..22a17dbded 100644 --- a/src-gui/src/renderer/components/alert/SwapStatusAlert/SwapStatusAlert.tsx +++ b/src-gui/src/renderer/components/alert/SwapStatusAlert/SwapStatusAlert.tsx @@ -161,6 +161,77 @@ function PunishTimelockExpiredAlert() { ); } +/** + * Sub-component for alerts when waiting for remaining refund timelock. + * This occurs after a partial refund was confirmed but we're waiting for the amnesty timelock. + */ +function WaitingForRemainingRefundTimelockAlert({ + blocksLeft, +}: { + blocksLeft: number; +}) { + return ( + + Waiting{" "} + {" "} + for the amnesty timelock to expire + , + "The maker can burn the remaining Bitcoin before the timelock expires", + "If the maker doesn't burn it, you can claim the remaining Bitcoin once the timelock expires", + "Keep the app running or resume the swap once the timelock expires", + ]} + /> + ); +} + +/** + * Sub-component for alerts when remaining refund timelock has expired. + * The amnesty transaction can now be published. + */ +function RemainingRefundTimelockExpiredAlert() { + return ( + + ); +} + +/** + * Sub-component for alerts when the maker has burnt the amnesty output. + */ +function BtcRefundBurnPublishedAlert() { + return ( + + ); +} + +/** + * Sub-component for alerts when the maker has published the final amnesty transaction. + */ +function BtcFinalAmnestyPublishedAlert() { + return ( + + ); +} + /** * Main component for displaying the appropriate swap alert status text. * @param swap - The swap information. @@ -175,6 +246,10 @@ export function StateAlert({ timelock: ExpiredTimelocks | null; isRunning: boolean; }) { + if (swap == null) { + return null; + } + switch (swap.state_name) { // This is the state where the swap is safe because the other party has redeemed the Bitcoin // It cannot be punished anymore @@ -189,9 +264,13 @@ export function StateAlert({ case BobStateName.XmrLocked: case BobStateName.EncSigSent: case BobStateName.CancelTimelockExpired: + // Even if the refund transactions have been published, it cannot be + // guaranteed that they will be confirmed in time + // falls through case BobStateName.BtcCancelled: - case BobStateName.BtcRefundPublished: // Even if the transactions have been published, it cannot be - case BobStateName.BtcEarlyRefundPublished: // guaranteed that they will be confirmed in time + case BobStateName.BtcRefundPublished: + case BobStateName.BtcPartialRefundPublished: + case BobStateName.BtcEarlyRefundPublished: if (timelock != null) { switch (timelock.type) { case "None": @@ -209,16 +288,50 @@ export function StateAlert({ ); case "Punish": return ; + // These two timelock types only exist once the partial refund tx has been confirmed + // They shouldn't occur for these states, so return null + case "WaitingForRemainingRefund": + case "RemainingRefund": + return null; default: exhaustiveGuard(timelock); } } return ; + case BobStateName.BtcPartiallyRefunded: + // Reuse existing timelock alerts for the amnesty waiting period + if (timelock != null) { + switch (timelock.type) { + case "WaitingForRemainingRefund": + return ( + + ); + case "RemainingRefund": + return ; + default: + return null; + } + } + return null; + + case BobStateName.BtcRefundBurnPublished: + return ; + + case BobStateName.BtcFinalAmnestyPublished: + return ; + + case BobStateName.BtcAmnestyPublished: + // Amnesty tx published, waiting for confirmation - no specific alert needed + return null; + // If the Bitcoin lock transaction has not been published yet // there is no need to display an alert case BobStateName.BtcLockReadyToPublish: return null; + default: exhaustiveGuard(swap.state_name); } @@ -270,7 +383,7 @@ export default function SwapStatusAlert({ return ( Swap {swap.swap_id} is not running - )} - + ) + } + {timelock && } - + ); } diff --git a/src-gui/src/renderer/components/inputs/NumberInput.tsx b/src-gui/src/renderer/components/inputs/NumberInput.tsx index 4fa2f18158..8909417bf4 100644 --- a/src-gui/src/renderer/components/inputs/NumberInput.tsx +++ b/src-gui/src/renderer/components/inputs/NumberInput.tsx @@ -42,10 +42,15 @@ export default function NumberInput({ return 0; }; - const [userPrecision, setUserPrecision] = useState(() => - getDecimalPrecision(step), - ); // Track user's decimal precision - const [minPrecision, setMinPrecision] = useState(3); + // minPrecision is purely derived from step - no state needed + const minPrecision = getDecimalPrecision(step); + + // Track user's decimal precision, reset when step changes + const [userPrecision, setUserPrecision] = useState(() => minPrecision); + + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset state when prop changes + useEffect(() => setUserPrecision(minPrecision), [step, minPrecision]); + const appliedPrecision = userPrecision > minPrecision ? userPrecision : minPrecision; @@ -61,12 +66,6 @@ export default function NumberInput({ } }, [placeholder, isFocused, value, onChange]); - // Update precision when step changes - useEffect(() => { - setUserPrecision(getDecimalPrecision(step)); - setMinPrecision(getDecimalPrecision(step)); - }, [step]); - // Measure text width to size input dynamically useEffect(() => { if (measureRef.current) { diff --git a/src-gui/src/renderer/components/modal/donation-tip/DonationTipDialog.tsx b/src-gui/src/renderer/components/modal/donation-tip/DonationTipDialog.tsx index 7e089a157a..2d8bea415d 100644 --- a/src-gui/src/renderer/components/modal/donation-tip/DonationTipDialog.tsx +++ b/src-gui/src/renderer/components/modal/donation-tip/DonationTipDialog.tsx @@ -23,7 +23,7 @@ import { } from "store/hooks"; import ExternalLink from "renderer/components/other/ExternalLink"; import GitHubIcon from "@mui/icons-material/GitHub"; -import { useState, useEffect } from "react"; +import { useState } from "react"; const GITHUB_BOUNTIES_URL = "https://eigenwallet.org/bounties"; @@ -239,15 +239,14 @@ export function GlobalDonationTipDialog() { // "Latch" pattern: once conditions are met, we remember that the dialog should be shown. // This prevents the dialog from closing when the user moves their mouse (which would // break the idle condition). Once triggered, it stays triggered. + // Uses "adjust state during render" pattern instead of useEffect. const [hasTriggered, setHasTriggered] = useState(false); const shouldOpen = hasntSelectedTipYet && isExperiencedUser && isIdle; - useEffect(() => { - if (shouldOpen && !hasTriggered) { - setHasTriggered(true); - } - }, [shouldOpen, hasTriggered]); + if (shouldOpen && !hasTriggered) { + setHasTriggered(true); + } // Show dialog if we've triggered and user hasn't dismissed const open = hasTriggered && !dismissed; diff --git a/src-gui/src/renderer/components/modal/feedback/useFeedback.ts b/src-gui/src/renderer/components/modal/feedback/useFeedback.ts index deec3b9862..56fec91e0c 100644 --- a/src-gui/src/renderer/components/modal/feedback/useFeedback.ts +++ b/src-gui/src/renderer/components/modal/feedback/useFeedback.ts @@ -49,13 +49,12 @@ export function useFeedback() { }); const [logsState, setLogsState] = useState(initialLogsState); - const [isPending, setIsPending] = useState(false); const [error, setError] = useState(null); - const bodyTooLong = inputState.bodyText.length > MAX_FEEDBACK_LENGTH; - + // Fetch swap logs when selection changes useEffect(() => { if (inputState.selectedSwap === null) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear when deselected setLogsState((prev) => ({ ...prev, swapLogs: [] })); return; } @@ -76,41 +75,33 @@ export function useFeedback() { }); }, [inputState.selectedSwap, inputState.isSwapLogsRedacted]); + // Fetch/process daemon logs when settings change useEffect(() => { if (!inputState.attachDaemonLogs) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear when detached setLogsState((prev) => ({ ...prev, daemonLogs: [] })); return; } - try { - const hashedLogs = store.getState().logs?.state.logs ?? []; - - if (inputState.isDaemonLogsRedacted) { - const logs = hashedLogs.map((h) => h.log); - redactLogs(logs) - .then((redactedLogs) => { - setLogsState((prev) => ({ - ...prev, - daemonLogs: hashLogs(redactedLogs), - })); - setError(null); - }) - .catch((e) => { - logger.error(`Failed to redact daemon logs: ${e}`); - setLogsState((prev) => ({ ...prev, daemonLogs: [] })); - setError(`Failed to redact daemon logs: ${e}`); - }); - } else { - setLogsState((prev) => ({ - ...prev, - daemonLogs: hashedLogs, - })); - setError(null); - } - } catch (e) { - logger.error(`Failed to fetch daemon logs: ${e}`); - setLogsState((prev) => ({ ...prev, daemonLogs: [] })); - setError(`Failed to fetch daemon logs: ${e}`); + const hashedLogs = store.getState().logs?.state.logs ?? []; + + if (inputState.isDaemonLogsRedacted) { + const logs = hashedLogs.map((h) => h.log); + redactLogs(logs) + .then((redactedLogs) => { + setLogsState((prev) => ({ + ...prev, + daemonLogs: hashLogs(redactedLogs), + })); + setError(null); + }) + .catch((e) => { + logger.error(`Failed to redact daemon logs: ${e}`); + setLogsState((prev) => ({ ...prev, daemonLogs: [] })); + setError(`Failed to redact daemon logs: ${e}`); + }); + } else { + setLogsState((prev) => ({ ...prev, daemonLogs: hashedLogs })); } }, [inputState.attachDaemonLogs, inputState.isDaemonLogsRedacted]); diff --git a/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx b/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx index 9852eb5a22..a23154ef1c 100644 --- a/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx +++ b/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx @@ -20,7 +20,7 @@ import { CardContent, } from "@mui/material"; import NewPasswordInput from "renderer/components/other/NewPasswordInput"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { usePendingSeedSelectionApproval } from "store/hooks"; import { resolveApproval, checkSeed } from "renderer/rpc"; import { SeedChoice } from "models/tauriModel"; @@ -74,7 +74,7 @@ export default function SeedSelectionDialog() { >("RandomSeed"); const [customSeed, setCustomSeed] = useState(""); const [blockheightInput, setBlockheightInput] = useState(""); - const [isSeedValid, setIsSeedValid] = useState(false); + const [asyncSeedValidation, setAsyncSeedValidation] = useState(false); const [password, setPassword] = useState(""); const [isPasswordValid, setIsPasswordValid] = useState(true); const [walletPath, setWalletPath] = useState(""); @@ -87,26 +87,29 @@ export default function SeedSelectionDialog() { ? approval.request.content.recent_wallets : []; + // Only run async validation when in "FromSeed" mode with content + const needsSeedValidation = selectedOption === "FromSeed" && customSeed.trim(); + useEffect(() => { - if (selectedOption === "FromSeed" && customSeed.trim()) { - checkSeed(customSeed.trim()) - .then((valid) => { - setIsSeedValid(valid); - }) - .catch(() => { - setIsSeedValid(false); - }); - } else { - setIsSeedValid(false); - } - }, [customSeed, selectedOption]); + if (!needsSeedValidation) return; + + checkSeed(customSeed.trim()) + .then(setAsyncSeedValidation) + .catch(() => setAsyncSeedValidation(false)); + }, [customSeed, needsSeedValidation]); + + // isSeedValid is derived: only true if we need validation AND async check passed + const isSeedValid = needsSeedValidation && asyncSeedValidation; - // Auto-select the first recent wallet if available + // Auto-select the first recent wallet if available (one-time initialization) + const hasInitializedRef = useRef(false); useEffect(() => { - if (recentWallets.length > 0) { + if (recentWallets.length > 0 && !hasInitializedRef.current) { + hasInitializedRef.current = true; setSelectedOption("FromWalletPath"); setWalletPath(recentWallets[0]); } + // eslint-disable-next-line react-hooks/exhaustive-deps -- only init once when wallets available }, [recentWallets.length]); const selectWalletFile = async () => { diff --git a/src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx b/src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx index f270cb0127..7a9b27a846 100644 --- a/src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx +++ b/src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx @@ -1,19 +1,29 @@ import { Step, StepLabel, Stepper, Typography } from "@mui/material"; import { SwapState } from "models/storeModel"; -import { useAppSelector } from "store/hooks"; import logger from "utils/logger"; export enum PathType { HAPPY_PATH = "happy path", - UNHAPPY_PATH = "unhappy path", + RECOVERY_PATH = "recovery path", } -type PathStep = [type: PathType, step: number, isError: boolean]; +export enum RecoveryScenario { + GENERIC = "generic", + FULL_REFUND = "full_refund", + PARTIAL_REFUND = "partial_refund", + COOPERATIVE_REDEEM = "cooperative_redeem", +} + +type PathStep = [ + type: PathType, + step: number, + isError: boolean, + scenario?: RecoveryScenario, +]; /** * Determines the current step in the swap process based on the previous and latest state. - * @param prevState - The previous state of the swap process (null if it's the initial state) - * @param latestState - The latest state of the swap process + * @param state - The state of the swap process (null if it's the initial state) * @returns A tuple containing [PathType, activeStep, errorFlag] */ function getActiveStep(state: SwapState | null): PathStep | null { @@ -92,38 +102,61 @@ function getActiveStep(state: SwapState | null): PathStep | null { case "XmrRedeemInMempool": return [PathType.HAPPY_PATH, 4, false]; - // Unhappy Path States + // Recovery Path States - Generic (early states before we know outcome) - // Step 1: Cancel timelock has expired. Waiting for cancel transaction to be published + case "WaitingForCancelTimelockExpiration": case "CancelTimelockExpired": - return [PathType.UNHAPPY_PATH, 0, isReleased]; + return [PathType.RECOVERY_PATH, 0, isReleased, RecoveryScenario.GENERIC]; - // Step 2: Swap has been cancelled. Waiting for Bitcoin to be refunded case "BtcCancelled": - return [PathType.UNHAPPY_PATH, 1, isReleased]; + return [PathType.RECOVERY_PATH, 1, isReleased, RecoveryScenario.GENERIC]; + + // Recovery Path States - Full Refund - // Step 2: One of the two Bitcoin refund transactions have been published - // but they haven't been confirmed yet case "BtcRefundPublished": case "BtcEarlyRefundPublished": - return [PathType.UNHAPPY_PATH, 1, isReleased]; + return [PathType.RECOVERY_PATH, 1, isReleased, RecoveryScenario.FULL_REFUND]; - // Step 2: One of the two Bitcoin refund transactions have been confirmed case "BtcRefunded": case "BtcEarlyRefunded": - return [PathType.UNHAPPY_PATH, 2, false]; + return [PathType.RECOVERY_PATH, 2, false, RecoveryScenario.FULL_REFUND]; + + // Recovery Path States - Partial Refund + + case "BtcPartialRefundPublished": + return [PathType.RECOVERY_PATH, 1, isReleased, RecoveryScenario.PARTIAL_REFUND]; + + case "BtcPartiallyRefunded": + case "WaitingForEarnestDepositTimelockExpiration": + case "BtcAmnestyPublished": + return [PathType.RECOVERY_PATH, 2, isReleased, RecoveryScenario.PARTIAL_REFUND]; + + case "BtcAmnestyReceived": + return [PathType.RECOVERY_PATH, 3, false, RecoveryScenario.PARTIAL_REFUND]; + + case "BtcRefundBurnPublished": + return [PathType.RECOVERY_PATH, 2, true, RecoveryScenario.PARTIAL_REFUND]; + + case "BtcRefundBurnt": + return [PathType.RECOVERY_PATH, 2, true, RecoveryScenario.PARTIAL_REFUND]; + + case "BtcFinalAmnestyPublished": + return [PathType.RECOVERY_PATH, 2, isReleased, RecoveryScenario.PARTIAL_REFUND]; + + case "BtcFinalAmnestyConfirmed": + return [PathType.RECOVERY_PATH, 3, false, RecoveryScenario.PARTIAL_REFUND]; + + // Recovery Path States - Cooperative Redeem (after punishment) - // Step 2 (Failed): Failed to refund Bitcoin - // The timelock expired before we could refund, resulting in punishment case "BtcPunished": - return [PathType.UNHAPPY_PATH, 1, true]; + return [PathType.RECOVERY_PATH, 1, true, RecoveryScenario.COOPERATIVE_REDEEM]; - // Attempting cooperative redemption after punishment case "AttemptingCooperativeRedeem": case "CooperativeRedeemAccepted": - return [PathType.UNHAPPY_PATH, 1, isReleased]; + return [PathType.RECOVERY_PATH, 2, isReleased, RecoveryScenario.COOPERATIVE_REDEEM]; + case "CooperativeRedeemRejected": - return [PathType.UNHAPPY_PATH, 1, true]; + return [PathType.RECOVERY_PATH, 2, true, RecoveryScenario.COOPERATIVE_REDEEM]; case "Resuming": return null; @@ -163,15 +196,34 @@ function SwapStepper({ const HAPPY_PATH_STEP_LABELS = [ { label: "Locking your BTC", duration: "~12min" }, - { label: "They lock their XMR", duration: "~10min" }, + { label: "They lock their XMR", duration: "~20min" }, { label: "They redeem the BTC", duration: "~2min" }, - { label: "Redeeming your XMR", duration: "~10min" }, + { label: "Redeeming your XMR", duration: "~1min" }, ]; -const UNHAPPY_PATH_STEP_LABELS = [ - { label: "Cancelling swap", duration: "~1min" }, - { label: "Attempting recovery", duration: "~5min" }, -]; +const RECOVERY_STEP_LABELS: Record< + RecoveryScenario, + Array<{ label: string; duration: string }> +> = { + [RecoveryScenario.GENERIC]: [ + { label: "Cancelling swap", duration: "~1min" }, + { label: "Attempting recovery", duration: "" }, + ], + [RecoveryScenario.FULL_REFUND]: [ + { label: "Cancelling swap", duration: "~1min" }, + { label: "Bitcoin refunded", duration: "~5min" }, + ], + [RecoveryScenario.PARTIAL_REFUND]: [ + { label: "Cancelling swap", duration: "~1min" }, + { label: "Partial refund", duration: "~2min" }, + { label: "Reclaiming deposit", duration: "~30min" }, + ], + [RecoveryScenario.COOPERATIVE_REDEEM]: [ + { label: "Cancelling swap", duration: "~1min" }, + { label: "We have been punished", duration: "" }, + { label: "Attempting cooperative recovery", duration: "~2min" }, + ], +}; export default function SwapStateStepper({ state, @@ -184,12 +236,14 @@ export default function SwapStateStepper({ return null; } - const [pathType, activeStep, error] = result; + const [pathType, activeStep, error, scenario] = result; - const steps = - pathType === PathType.HAPPY_PATH - ? HAPPY_PATH_STEP_LABELS - : UNHAPPY_PATH_STEP_LABELS; + let steps: Array<{ label: string; duration: string }>; + if (pathType === PathType.HAPPY_PATH) { + steps = HAPPY_PATH_STEP_LABELS; + } else { + steps = RECOVERY_STEP_LABELS[scenario ?? RecoveryScenario.GENERIC]; + } return ; } diff --git a/src-gui/src/renderer/components/modal/swap/pages/MockSwapControls.tsx b/src-gui/src/renderer/components/modal/swap/pages/MockSwapControls.tsx new file mode 100644 index 0000000000..366388ba77 --- /dev/null +++ b/src-gui/src/renderer/components/modal/swap/pages/MockSwapControls.tsx @@ -0,0 +1,139 @@ +import { useState } from "react"; +import { + Box, + Button, + IconButton, + MenuItem, + Paper, + Select, + Switch, + Typography, +} from "@mui/material"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import { scenarios, MockScenario, MOCK_SWAP_ID, getMockLockBitcoinApproval } from "dev/mockSwapEvents"; +import { useAppDispatch } from "store/hooks"; +import { approvalEventReceived } from "store/features/rpcSlice"; +import { + swapProgressEventReceived, + swapReset, + setMockOnlyDisableTauriCallsOnSwapProgress, +} from "store/features/swapSlice"; + +export default function MockSwapControls() { + const dispatch = useAppDispatch(); + const [scenario, setScenario] = useState(null); + const [index, setIndex] = useState(0); + + const enabled = scenario !== null; + const total = scenario ? scenarios[scenario].length : 0; + + const dispatchMockState = (mockScenario: MockScenario, eventIndex: number) => { + const event = scenarios[mockScenario][eventIndex]; + dispatch( + swapProgressEventReceived({ + swap_id: MOCK_SWAP_ID, + event, + }), + ); + }; + + const handleMockConfirmation = () => { + dispatch(approvalEventReceived(getMockLockBitcoinApproval(scenario))); + }; + + const handleToggle = (checked: boolean) => { + if (checked) { + const firstScenario = Object.keys(scenarios)[0] as MockScenario; + setScenario(firstScenario); + setIndex(0); + dispatch(setMockOnlyDisableTauriCallsOnSwapProgress(true)); + dispatchMockState(firstScenario, 0); + } else { + setScenario(null); + setIndex(0); + dispatch(setMockOnlyDisableTauriCallsOnSwapProgress(false)); + dispatch(swapReset()); + } + }; + + const handleScenarioChange = (newScenario: MockScenario) => { + setScenario(newScenario); + setIndex(0); + dispatchMockState(newScenario, 0); + }; + + const prev = () => { + if (!scenario || index === 0) return; + const newIndex = index - 1; + setIndex(newIndex); + dispatchMockState(scenario, newIndex); + }; + + const next = () => { + if (!scenario || index >= total - 1) return; + const newIndex = index + 1; + setIndex(newIndex); + dispatchMockState(scenario, newIndex); + }; + + const currentStateName = scenario ? scenarios[scenario][index].type : null; + + return ( + + + handleToggle(e.target.checked)} + /> + + Mock + + + {enabled && ( + <> + + + + + {index + 1}/{total} + + + + + + {currentStateName} + + + )} + + + + ); +} diff --git a/src-gui/src/renderer/components/other/MonospaceTextBox.tsx b/src-gui/src/renderer/components/other/MonospaceTextBox.tsx index 069d2f0588..419423d914 100644 --- a/src-gui/src/renderer/components/other/MonospaceTextBox.tsx +++ b/src-gui/src/renderer/components/other/MonospaceTextBox.tsx @@ -19,9 +19,9 @@ export default function MonospaceTextBox({ display: "flex", alignItems: "center", justifyContent: "space-between", - backgroundColor: light ? "transparent" : theme.palette.grey[900], - borderRadius: 2, - border: light ? `1px solid ${theme.palette.grey[800]}` : "none", + backgroundColor: theme.palette.action.hover, + borderRadius: theme.shape.borderRadius, + border: "none", padding: theme.spacing(1), gap: 1, })} diff --git a/src-gui/src/renderer/components/other/ValidatedTextField.tsx b/src-gui/src/renderer/components/other/ValidatedTextField.tsx index 37a8c9d6ec..ca3573cadd 100644 --- a/src-gui/src/renderer/components/other/ValidatedTextField.tsx +++ b/src-gui/src/renderer/components/other/ValidatedTextField.tsx @@ -1,5 +1,5 @@ import { TextFieldProps, TextField } from "@mui/material"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; interface ValidatedTextFieldProps extends Omit { @@ -24,6 +24,10 @@ export default function ValidatedTextField({ }: ValidatedTextFieldProps) { const [inputValue, setInputValue] = useState(value || ""); + // Sync internal state with prop (controlled component pattern) + // eslint-disable-next-line react-hooks/set-state-in-effect -- sync internal state with external prop + useEffect(() => setInputValue(value || ""), [value]); + const handleChange = useCallback( (newValue: string) => { const trimmedValue = newValue.trim(); @@ -38,10 +42,6 @@ export default function ValidatedTextField({ [allowEmpty, isValid, onValidatedChange], ); - useEffect(() => { - setInputValue(value || ""); - }, [value]); - const isError = (allowEmpty && inputValue === "") || (inputValue === "" && noErrorWhenEmpty) ? false diff --git a/src-gui/src/renderer/components/pages/monero/SendTransactionModal.tsx b/src-gui/src/renderer/components/pages/monero/SendTransactionModal.tsx index e25aae0110..1f9dd5422b 100644 --- a/src-gui/src/renderer/components/pages/monero/SendTransactionModal.tsx +++ b/src-gui/src/renderer/components/pages/monero/SendTransactionModal.tsx @@ -27,7 +27,7 @@ export default function SendTransactionModal({ const showSuccess = successResponse !== null; - const handleClose = (event: unknown, reason: string) => { + const handleClose = (_: unknown, reason: string) => { // We want the user to explicitly close the dialog. // We do not close the dialog upon a backdrop click. if (reason === "backdropClick") { @@ -60,7 +60,7 @@ export default function SendTransactionModal({ )} {showSuccess && ( )} diff --git a/src-gui/src/renderer/components/pages/swap/swap/SwapStatePage.tsx b/src-gui/src/renderer/components/pages/swap/swap/SwapStatePage.tsx index c77865d9d0..9243ab5aed 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/SwapStatePage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/SwapStatePage.tsx @@ -8,6 +8,17 @@ import { BitcoinEarlyRefundPublishedPage, BitcoinRefundPublishedPage, } from "./done/BitcoinRefundedPage"; +import { + BitcoinPartialRefundPublished, + BitcoinPartiallyRefunded, + WaitingForEarnestDepositTimelockExpirationPage, + BitcoinAmnestyPublished, + BitcoinAmnestyReceived, + BitcoinRefundBurnPublished, + BitcoinRefundBurnt, + BitcoinFinalAmnestyPublished, + BitcoinFinalAmnestyConfirmed, +} from "./done/BitcoinPartialRefundPage"; import XmrRedeemInMempoolPage from "./done/XmrRedeemInMempoolPage"; import ProcessExitedPage from "./exited/ProcessExitedPage"; import BitcoinCancelledPage from "./in_progress/BitcoinCancelledPage"; @@ -106,7 +117,7 @@ export default function SwapStatePage({ state }: { state: SwapState | null }) { case "BtcCancelled": return ; - //// 4 different types of Bitcoin refund states we can be in + //// 8 different types of Bitcoin refund states we can be in case "BtcRefundPublished": // tx_refund has been published but has not been confirmed yet if (state.curr.type === "BtcRefundPublished") { return ; @@ -127,6 +138,53 @@ export default function SwapStatePage({ state }: { state: SwapState | null }) { return ; } break; + case "BtcPartialRefundPublished": + if (state.curr.type === "BtcPartialRefundPublished") { + return ; + } + break; + case "BtcPartiallyRefunded": + if (state.curr.type === "BtcPartiallyRefunded") { + return ; + } + break; + case "WaitingForEarnestDepositTimelockExpiration": + if (state.curr.type === "WaitingForEarnestDepositTimelockExpiration") { + return ; + } + break; + case "BtcAmnestyPublished": + if (state.curr.type === "BtcAmnestyPublished") { + return ; + } + break; + case "BtcAmnestyReceived": + if (state.curr.type === "BtcAmnestyReceived") { + return ; + } + break; + + //// 4 different types of refund burn / final amnesty states + case "BtcRefundBurnPublished": + if (state.curr.type === "BtcRefundBurnPublished") { + return ; + } + break; + case "BtcRefundBurnt": + if (state.curr.type === "BtcRefundBurnt") { + return ; + } + break; + case "BtcFinalAmnestyPublished": + if (state.curr.type === "BtcFinalAmnestyPublished") { + return ; + } + break; + case "BtcFinalAmnestyConfirmed": + if (state.curr.type === "BtcFinalAmnestyConfirmed") { + return ; + } + break; //// 4 different types of Bitcoin punished states we can be in case "BtcPunished": diff --git a/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx b/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx index 0411806372..f166d22ded 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx @@ -1,4 +1,5 @@ import { Box, Button, Dialog, DialogActions, Paper } from "@mui/material"; +import { useState } from "react"; import { useActiveSwapInfo, useAppSelector } from "store/hooks"; import SwapStatePage from "renderer/components/pages/swap/swap/SwapStatePage"; import CancelButton from "./CancelButton"; @@ -6,12 +7,11 @@ import SwapStateStepper from "renderer/components/modal/swap/SwapStateStepper"; import SwapStatusAlert from "renderer/components/alert/SwapStatusAlert/SwapStatusAlert"; import DebugPageSwitchBadge from "renderer/components/modal/swap/pages/DebugPageSwitchBadge"; import DebugPage from "renderer/components/modal/swap/pages/DebugPage"; -import { useState } from "react"; +import MockSwapControls from "renderer/components/modal/swap/pages/MockSwapControls"; export default function SwapWidget() { - const swap = useAppSelector((state) => state.swap); + const swapState = useAppSelector((state) => state.swap.state); const swapInfo = useActiveSwapInfo(); - const [debug, setDebug] = useState(false); return ( @@ -24,6 +24,7 @@ export default function SwapWidget() { onlyShowIfUnusualAmountOfTimeHasPassed /> )} + {import.meta.env.DEV && } - + - {swap.state !== null && ( + {swapState !== null && ( <> - + BtcAmnestyReceived (Bob claims amnesty via TxRefundAmnesty) + * b. BtcRefundBurnPublished -> BtcRefundBurnt (Alice burns amnesty via TxRefundBurn) + * -> optionally BtcFinalAmnestyPublished -> BtcFinalAmnestyConfirmed (Alice grants final amnesty) + */ + +import { Alert, Box, Button, DialogContentText, Typography } from "@mui/material"; +import { TauriSwapProgressEventContent } from "models/tauriModelExt"; +import { useActiveSwapInfo } from "store/hooks"; +import FeedbackInfoBox from "renderer/components/pages/help/FeedbackInfoBox"; +import BitcoinTransactionInfoBox from "renderer/components/pages/swap/swap/components/BitcoinTransactionInfoBox"; +import DiscordIcon from "renderer/components/icons/DiscordIcon"; +import MatrixIcon from "renderer/components/icons/MatrixIcon"; + +export function BitcoinPartialRefundPublished({ + btc_partial_refund_txid, + btc_lock_amount, + btc_amnesty_amount, +}: TauriSwapProgressEventContent<"BtcPartialRefundPublished">) { + return ( + + ); +} + +export function BitcoinPartiallyRefunded({ + btc_partial_refund_txid, + btc_lock_amount, + btc_amnesty_amount, +}: TauriSwapProgressEventContent<"BtcPartiallyRefunded">) { + return ( + + ); +} + +export function WaitingForEarnestDepositTimelockExpirationPage({ + btc_partial_refund_txid, + btc_lock_amount, + btc_amnesty_amount, + target_blocks, + blocks_until_expiry, +}: TauriSwapProgressEventContent<"WaitingForEarnestDepositTimelockExpiration">) { + const blocksConfirmed = target_blocks - blocks_until_expiry; + const atRiskPercent = Math.round((btc_amnesty_amount / btc_lock_amount) * 100); + + return ( + <> + + Waiting to claim the earnest deposit ({atRiskPercent}% of your Bitcoin). + The timelock of {target_blocks} Bitcoin blocks needs to expire first. + The maker can choose to withhold it during this time. + + + + ); +} + +function PartialRefundPage({ + txid, + confirmed, + btcLockAmount, + btcAmnestyAmount, +}: { + txid: string; + confirmed: boolean; + btcLockAmount: number; + btcAmnestyAmount: number; +}) { + const swap = useActiveSwapInfo(); + + const guaranteedPercent = Math.round(((btcLockAmount - btcAmnestyAmount) / btcLockAmount) * 100); + const atRiskPercent = Math.round((btcAmnestyAmount / btcLockAmount) * 100); + + const mainMessage = confirmed + ? `Refunded the first ${guaranteedPercent}% of your Bitcoin. The maker has a short time window to withhold the earnest deposit of ${atRiskPercent}%. Unless they do that we will claim it shortly.` + : `Refunding the first ${guaranteedPercent}% of your Bitcoin. The maker has a short time window to withhold the earnest deposit of ${atRiskPercent}%. Unless they do that we will claim it shortly.`; + + const additionalContent = swap ? ( + <> + {!confirmed && "Waiting for transaction to be confirmed..."} + {!confirmed &&
} + Refund address: {swap.btc_refund_address} + + ) : null; + + return ( + <> + {mainMessage} + + + Patience: We are first refunding the guaranteed {guaranteedPercent}% of the Bitcoin refund. + It is not guaranteed that we can claim the earnest deposit, which makes up the remaining {atRiskPercent}%. + The maker has a short timeframe to withhold the deposit, after that we can claim it. + + + + + + + ); +} + +// Amnesty pages - We're claiming the remaining Bitcoin ourselves (good outcome) + +export function BitcoinAmnestyPublished({ + btc_amnesty_txid, +}: TauriSwapProgressEventContent<"BtcAmnestyPublished">) { + return ( + + ); +} + +export function BitcoinAmnestyReceived({ + btc_amnesty_txid, +}: TauriSwapProgressEventContent<"BtcAmnestyReceived">) { + return ( + + ); +} + +function AmnestyPage({ + txid, + confirmed, +}: { + txid: string; + confirmed: boolean; +}) { + const swap = useActiveSwapInfo(); + + const mainMessage = confirmed + ? "All your Bitcoin have been refunded. The swap is complete." + : "The remaining Bitcoin (earnest deposit) are being released to you. Waiting for confirmation."; + + const additionalContent = swap ? ( + <> + {!confirmed && "Waiting for transaction to be confirmed..."} + {!confirmed &&
} + Refund address: {swap.btc_refund_address} + + ) : null; + + return ( + <> + + + {confirmed ? "Complete:" : "Almost there:"}{" "}{mainMessage} + + + + + + + + ); +} + +// Refund Burn pages - The maker actively withheld the remaining Bitcoin (bad outcome) +// Note: By default, the user would have received the remaining Bitcoin after a timelock. +// If we're in this state, it means the maker actively published TxBurn to revoke it. + +export function BitcoinRefundBurnPublished({ + btc_refund_burn_txid, + btc_lock_amount, + btc_amnesty_amount, +}: TauriSwapProgressEventContent<"BtcRefundBurnPublished">) { + return ( + + ); +} + +export function BitcoinRefundBurnt({ + btc_refund_burn_txid, + btc_lock_amount, + btc_amnesty_amount, +}: TauriSwapProgressEventContent<"BtcRefundBurnt">) { + return ( + + ); +} + +function RefundBurnPage({ + txid, + confirmed, + btcLockAmount, + btcAmnestyAmount, +}: { + txid: string; + confirmed: boolean; + btcLockAmount: number; + btcAmnestyAmount: number; +}) { + const atRiskPercent = Math.round((btcAmnestyAmount / btcLockAmount) * 100); + + const mainMessage = "The market maker is withholding the earnest deposit." + + return ( + + {mainMessage} + + + Earnest deposit withheld: The market maker has choosen to withhold the remaining {atRiskPercent}% of your Bitcoin refund. + + + + + + Why did this happen? Aborting a swap incurs significant costs on makers. + To prevent spam attacks, makers can choose to require an "earnest deposit", + which they can withhold if the swap is aborted. + + + Makers do not have access to the withheld deposit. + The maker you are swapping with has exercised their option to withhold, because they think you are spamming them. + + + + + You can contact the maker: If you think this was a mistake, you can contact the maker through our official + community channels. + The maker can still release the deposit. + +
+ + + + +
+ +
+ ); +} + +// Final Amnesty pages - The maker granted final amnesty after the user appealed + +export function BitcoinFinalAmnestyPublished({ + btc_final_amnesty_txid, +}: TauriSwapProgressEventContent<"BtcFinalAmnestyPublished">) { + return ; +} + +export function BitcoinFinalAmnestyConfirmed({ + btc_final_amnesty_txid, +}: TauriSwapProgressEventContent<"BtcFinalAmnestyConfirmed">) { + return ; +} + +function FinalAmnestyPage({ + txid, + confirmed, +}: { + txid: string; + confirmed: boolean; +}) { + const swap = useActiveSwapInfo(); + + const mainMessage = confirmed + ? "The market maker has release the earnest deposit they withheld. The refund is complete." + : "The market maker is releasing the earnest deposit they withheld. Waiting for transaction confirmation."; + + const additionalContent = swap ? ( + <> + {!confirmed && "Waiting for transaction to be confirmed..."} + {!confirmed &&
} + Refund address: {swap.btc_refund_address} + + ) : null; + + return ( + <> + {mainMessage} + + + Mercy granted: The market maker has decided to + release the earnest deposit, which they previously withheld. All your Bitcoin has now been + fully refunded. + + + + + + + ); +} diff --git a/src-gui/src/renderer/components/pages/swap/swap/done/BitcoinRefundedPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/done/BitcoinRefundedPage.tsx index 866a6fcc41..d1496b85a1 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/done/BitcoinRefundedPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/done/BitcoinRefundedPage.tsx @@ -91,3 +91,4 @@ function MultiBitcoinRefundedPage({ ); } + diff --git a/src-gui/src/renderer/components/pages/swap/swap/exited/ProcessExitedPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/exited/ProcessExitedPage.tsx index 5859c28019..18c94d1b59 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/exited/ProcessExitedPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/exited/ProcessExitedPage.tsx @@ -21,7 +21,10 @@ export default function ProcessExitedPage({ (prevState.type === "XmrRedeemInMempool" || prevState.type === "BtcRefunded" || prevState.type === "BtcPunished" || - prevState.type === "CooperativeRedeemRejected") + prevState.type === "CooperativeRedeemRejected" || + prevState.type === "BtcRefundBurnt" || + prevState.type === "BtcFinalAmnestyConfirmed" || + prevState.type === "BtcAmnestyReceived") ) { return ( ) { const request = useActiveLockBitcoinApprovalRequest(); + // Get market rate for markup calculation (must be called unconditionally) + const xmrBtcRate = useAppSelector((state) => state.rates.xmrBtcRate); const [timeLeft, setTimeLeft] = useState(0); const expirationTs = @@ -75,9 +78,18 @@ export default function SwapSetupInflightPage({ ); } - const { btc_network_fee, monero_receive_pool, xmr_receive_amount } = + const { btc_network_fee, monero_receive_pool, xmr_receive_amount, btc_amnesty_amount } = request.request.content; + // Calculate markup compared to market rate + const makerRate = satsToBtc(btc_lock_amount) / piconerosToXmr(Number(xmr_receive_amount)); + const markupPercent = xmrBtcRate != null ? getMarkup(makerRate, xmrBtcRate) : null; + + // Calculate refund percentages + const guaranteedRefundPercent = ((btc_lock_amount - btc_amnesty_amount) / btc_lock_amount) * 100; + const depositPercent = (btc_amnesty_amount / btc_lock_amount) * 100; + const hasDeposit = btc_amnesty_amount > 0; + return ( + {/* Info section: Rate and Refund details */} + + {markupPercent != null && ( + <> + + Rate: {Math.abs(markupPercent).toFixed(1)}% {markupPercent >= 0 ? "above" : "below"} market + + + + )} + {hasDeposit ? ( + + theme.palette.success.main + "15", + borderRadius: 1, + borderLeft: 3, + borderColor: "success.main", + }} + > + + {guaranteedRefundPercent.toFixed(0)}% guaranteed refund + + + theme.palette.info.main + "15", + borderRadius: 1, + borderLeft: 3, + borderColor: "info.main", + }} + > + + {depositPercent.toFixed(0)}% anti-spam deposit + + + └ Usually returned; maker may lock for abuse + + + + ) : ( + theme.palette.success.main + "15", + borderRadius: 1, + borderLeft: 3, + borderColor: "success.main", + }} + > + + Full refund if swap fails (guaranteed) + + + )} + + ( - +}: BitcoinSendSectionProps) { + return ( theme.palette.warning.light + "10", - background: (theme) => - `linear-gradient(135deg, ${theme.palette.warning.light}20, ${theme.palette.warning.light}05)`, + flexDirection: "column", + gap: 1, }} > - ({ - color: theme.palette.text.primary, - })} - > - You send - - ({ - fontWeight: "bold", - color: theme.palette.warning.dark, - textShadow: "0 1px 2px rgba(0,0,0,0.1)", - })} + theme.palette.warning.light + "10", + background: (theme) => + `linear-gradient(135deg, ${theme.palette.warning.light}20, ${theme.palette.warning.light}05)`, + }} > - - - + ({ + color: theme.palette.text.primary, + })} + > + You send + + ({ + fontWeight: "bold", + color: theme.palette.warning.dark, + textShadow: "0 1px 2px rgba(0,0,0,0.1)", + })} + > + + + - {/* Network fee box attached to the bottom */} - theme.palette.warning.main, - color: (theme) => theme.palette.warning.contrastText, - borderRadius: "4px", - fontSize: "0.75rem", - fontWeight: 600, - boxShadow: "0 2px 4px rgba(0,0,0,0.1)", - whiteSpace: "nowrap", - zIndex: 1, - }} - > - Network fee: + {/* Network fee box attached to the bottom */} + theme.palette.warning.main, + color: (theme) => theme.palette.warning.contrastText, + borderRadius: "4px", + fontSize: "0.75rem", + fontWeight: 600, + boxShadow: "0 2px 4px rgba(0,0,0,0.1)", + whiteSpace: "nowrap", + zIndex: 1, + }} + > + Network fee: + - -); + ) +}; interface PoolBreakdownProps { monero_receive_pool: Array<{ diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerDiscoveryStatus.tsx b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerDiscoveryStatus.tsx index 6256930101..828da25015 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerDiscoveryStatus.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerDiscoveryStatus.tsx @@ -24,7 +24,7 @@ import { Close as CloseIcon, Refresh as RefreshIcon, } from "@mui/icons-material"; -import { useEffect, useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import { useAppSelector } from "store/hooks"; import { QuoteStatus, ConnectionStatus } from "models/tauriModel"; import { selectPeers } from "store/selectors"; @@ -48,15 +48,17 @@ export default function MakerDiscoveryStatus() { .filter((p) => p.connection === ConnectionStatus.Connected) .map((p) => p.peer_id); - // Track peers that have ever been connected + // Track peers that have ever been connected (accumulating historical state) useEffect(() => { - if (connectedPeerIds.length > 0) { - setEverConnectedPeers((prev) => { - const updated = new Set(prev); - connectedPeerIds.forEach((id) => updated.add(id)); - return updated; - }); - } + if (connectedPeerIds.length === 0) return; + setEverConnectedPeers((prev) => { + const newIds = connectedPeerIds.filter((id) => !prev.has(id)); + if (newIds.length === 0) return prev; + const updated = new Set(prev); + newIds.forEach((id) => updated.add(id)); + return updated; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- peers is the stable source }, [peers]); const quotesInflight = peers.filter( diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx index a7a80ec201..0a6eff34a4 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx @@ -1,6 +1,6 @@ import { Box, Chip, Divider, Paper, Tooltip, Typography } from "@mui/material"; import Jdenticon from "renderer/components/other/Jdenticon"; -import { QuoteWithAddress } from "models/tauriModel"; +import { BidQuote, QuoteWithAddress, RefundPolicyWire } from "models/tauriModel"; import { MoneroSatsExchangeRate, MoneroSatsMarkup, @@ -10,6 +10,14 @@ import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import { resolveApproval } from "renderer/rpc"; import { isMakerVersionOutdated } from "utils/multiAddrUtils"; import WarningIcon from "@mui/icons-material/Warning"; +import { RefundPolicy } from "store/features/settingsSlice"; + +function getRefundPercentage(policy: RefundPolicyWire): number { + if (policy.type === "FullRefund") { + return 100; + } + return policy.content.anti_spam_deposit_ratio * 100; +} export default function MakerOfferItem({ quoteWithAddress, @@ -130,6 +138,7 @@ export default function MakerOfferItem({ size="small" /> + {EarnestDepositChip(quote)} {isMakerVersionOutdated(version) ? ( ); } + +function EarnestDepositChip(quote: BidQuote) { + const full_refund: boolean = quote.refund_policy.type === "FullRefund" ? true : quote.refund_policy.content.anti_spam_deposit_ratio === 0; + // Rounded to 0.001 precision + const earnest_deposit_ratio = Math.round( + (quote.refund_policy.type === "FullRefund" ? 0 : quote.refund_policy.content?.anti_spam_deposit_ratio) + * 1000 + ) / 1000; + const tooltip_text = full_refund ? "100% refund cryptographically guaranteed." : `If the swap is refunded, the maker may choose to freeze ${earnest_deposit_ratio * 100}% of your refund. This is allows them to protect themselves against griefing.`; + const text = full_refund ? "No earnest deposit" : `${earnest_deposit_ratio * 100}% earnest deposit`; + + return + + ; +} + diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index d1f57ae68e..ec819a4c60 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -703,6 +703,11 @@ export async function rejectApproval( } export async function refreshApprovals(): Promise { + // Skip when mocking is enabled (DEV only) + if (store.getState().swap._mockOnlyDisableTauriCallsOnSwapProgress) { + return; + } + const response = await invokeNoArgs( "get_pending_approvals", ); diff --git a/src-gui/src/store/features/swapSlice.ts b/src-gui/src/store/features/swapSlice.ts index d2acec5c7b..6bf863b53b 100644 --- a/src-gui/src/store/features/swapSlice.ts +++ b/src-gui/src/store/features/swapSlice.ts @@ -8,6 +8,8 @@ const initialState: SwapSlice = { // TODO: Remove this and replace logic entirely with Tauri events spawnType: null, + + _mockOnlyDisableTauriCallsOnSwapProgress: false, }; export const swapSlice = createSlice({ @@ -37,9 +39,19 @@ export const swapSlice = createSlice({ swapReset() { return initialState; }, + setMockOnlyDisableTauriCallsOnSwapProgress( + swap, + action: PayloadAction, + ) { + swap._mockOnlyDisableTauriCallsOnSwapProgress = action.payload; + }, }, }); -export const { swapReset, swapProgressEventReceived } = swapSlice.actions; +export const { + swapReset, + swapProgressEventReceived, + setMockOnlyDisableTauriCallsOnSwapProgress, +} = swapSlice.actions; export default swapSlice.reducer; diff --git a/src-gui/src/store/middleware/storeListener.ts b/src-gui/src/store/middleware/storeListener.ts index aff8524507..38fb9dfefe 100644 --- a/src-gui/src/store/middleware/storeListener.ts +++ b/src-gui/src/store/middleware/storeListener.ts @@ -159,6 +159,11 @@ export function createMainListeners() { listener.startListening({ actionCreator: swapProgressEventReceived, effect: async (action) => { + // Skip Tauri calls when mocking is enabled (DEV only) + if (store.getState().swap._mockOnlyDisableTauriCallsOnSwapProgress) { + return; + } + if (action.payload.event.type === "Released") { logger.info("Swap released, updating bitcoin balance..."); await checkBitcoinBalance(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c22cfdf026..39d5062c11 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -80,7 +80,8 @@ macro_rules! generate_command_handlers { get_context_status, get_monero_subaddresses, create_monero_subaddress, - set_monero_subaddress_label + set_monero_subaddress_label, + refresh_p2p ] }; } diff --git a/swap-asb/src/command.rs b/swap-asb/src/command.rs index 654af7109f..d6bb2b3afa 100644 --- a/swap-asb/src/command.rs +++ b/swap-asb/src/command.rs @@ -177,6 +177,14 @@ where env_config: env_config(testnet), cmd: Command::SafelyAbort { swap_id }, }, + RawCommand::ManualRecovery(ManualRecovery::GrantFinalAmnesty { swap_id }) => Arguments { + testnet, + json, + trace, + config_path: config_path(config, testnet)?, + env_config: env_config(testnet), + cmd: Command::GrantFinalAmnesty { swap_id }, + }, }; Ok(arguments) @@ -249,6 +257,9 @@ pub enum Command { SafelyAbort { swap_id: Uuid, }, + GrantFinalAmnesty { + swap_id: Uuid, + }, ExportBitcoinWallet, ExportMoneroWallet, ExportMoneroLockWallet { @@ -411,6 +422,16 @@ pub enum ManualRecovery { )] swap_id: Uuid, }, + #[structopt( + about = "Grant final amnesty to a swap in BtcRefundBurnConfirmed state, allowing the taker to claim the remaining funds." + )] + GrantFinalAmnesty { + #[structopt( + long = "swap-id", + help = "The swap id can be retrieved using the history subcommand" + )] + swap_id: Uuid, + }, } #[derive(structopt::StructOpt, Debug)] diff --git a/swap-asb/src/main.rs b/swap-asb/src/main.rs index 2e315cbbb6..859214a64a 100644 --- a/swap-asb/src/main.rs +++ b/swap-asb/src/main.rs @@ -26,7 +26,7 @@ use structopt::clap::ErrorKind; mod command; use command::{parse_args, Arguments, Command}; use swap::asb::rpc::RpcServer; -use swap::asb::{cancel, punish, redeem, refund, safely_abort, EventLoop, ExchangeRate, Finality}; +use swap::asb::{cancel, grant_final_amnesty, punish, redeem, refund, safely_abort, EventLoop, ExchangeRate, Finality}; use swap::common::tor::{bootstrap_tor_client, create_tor_client}; use swap::common::tracing_util::Format; use swap::common::{self, get_logs, warn_if_outdated}; @@ -38,7 +38,8 @@ use swap::protocol::alice::{run, AliceState, TipConfig}; use swap::protocol::{Database, State}; use swap::seed::Seed; use swap_env::config::{ - initial_setup, query_user_for_initial_config, read_config, Config, ConfigNotInitialized, + initial_setup, query_user_for_initial_config, read_config, validate_config, Config, + ConfigNotInitialized, }; use swap_feed; use swap_machine::alice::is_complete; @@ -140,19 +141,7 @@ pub async fn main() -> Result<()> { // Initialize tracing initialize_tracing(json, &config, trace)?; - // Check for conflicting env / config values - if config.monero.network != env_config.monero_network { - bail!(format!( - "Expected monero network in config file to be {:?} but was {:?}", - env_config.monero_network, config.monero.network - )); - } - if config.bitcoin.network != env_config.bitcoin_network { - bail!(format!( - "Expected bitcoin network in config file to be {:?} but was {:?}", - env_config.bitcoin_network, config.bitcoin.network - )); - } + validate_config(&config, env_config)?; let seed = Seed::from_file_or_generate(&config.data.dir) .await @@ -316,6 +305,7 @@ pub async fn main() -> Result<()> { config.maker.max_buy_btc, config.maker.external_bitcoin_redeem_address, tip_config, + config.maker.refund_policy, ) .unwrap(); @@ -483,6 +473,13 @@ pub async fn main() -> Result<()> { tracing::info!("Swap safely aborted"); } + Command::GrantFinalAmnesty { swap_id } => { + let db = open_db(db_file, AccessMode::ReadWrite, None).await?; + + grant_final_amnesty(swap_id, db).await?; + + tracing::info!("Final amnesty granted for swap {}", swap_id); + } Command::Redeem { swap_id, do_not_await_finality, @@ -544,7 +541,8 @@ pub async fn main() -> Result<()> { .next() .context("Couldn't find state Started for this swap")?; - let secret_spend_key = match state3.watch_for_btc_tx_refund(&bitcoin_wallet).await { + let secret_spend_key = match state3.watch_for_btc_tx_full_refund(&bitcoin_wallet).await + { Ok(secret) => secret, Err(error) => { tracing::error!( diff --git a/swap-controller-api/Cargo.toml b/swap-controller-api/Cargo.toml index 6cc73f689b..06bf31f14a 100644 --- a/swap-controller-api/Cargo.toml +++ b/swap-controller-api/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" bitcoin = { workspace = true } jsonrpsee = { workspace = true, features = ["macros", "server", "client-core", "http-client"] } serde = { workspace = true } +uuid = { workspace = true, features = ["serde"] } [lints] workspace = true diff --git a/swap-controller-api/src/lib.rs b/swap-controller-api/src/lib.rs index 6790dec75d..339c70d6c5 100644 --- a/swap-controller-api/src/lib.rs +++ b/swap-controller-api/src/lib.rs @@ -1,6 +1,7 @@ use jsonrpsee::proc_macros::rpc; use jsonrpsee::types::ErrorObjectOwned; use serde::{Deserialize, Serialize}; +use uuid::Uuid; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct BitcoinBalanceResponse { @@ -64,10 +65,22 @@ pub struct RegistrationStatusResponse { pub registrations: Vec, } +// TODO: we should not need both this and asb::SwapDetails #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Swap { - pub id: String, + pub swap_id: String, + pub start_date: String, pub state: String, + pub btc_lock_txid: String, + #[serde(with = "bitcoin::amount::serde::as_sat")] + pub btc_amount: bitcoin::Amount, + /// Monero amount in piconero + pub xmr_amount: u64, + /// Exchange rate: BTC per XMR (amount of BTC needed to buy 1 XMR) + #[serde(with = "bitcoin::amount::serde::as_sat")] + pub exchange_rate: bitcoin::Amount, + pub peer_id: String, + pub completed: bool, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -76,6 +89,12 @@ pub struct MoneroSeedResponse { pub restore_height: u64, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SetBurnOnRefundRequest { + pub swap_id: String, + pub burn: bool, +} + #[rpc(client, server)] pub trait AsbApi { #[method(name = "check_connection")] @@ -100,4 +119,9 @@ pub trait AsbApi { async fn get_swaps(&self) -> Result, ErrorObjectOwned>; #[method(name = "registration_status")] async fn registration_status(&self) -> Result; + #[method(name = "set_burn_on_refund")] + async fn set_withhold_deposit(&self, swap_id: Uuid, burn: bool) + -> Result<(), ErrorObjectOwned>; + #[method(name = "grant_mercy")] + async fn grant_mercy(&self, swap_id: Uuid) -> Result<(), ErrorObjectOwned>; } diff --git a/swap-controller/Cargo.toml b/swap-controller/Cargo.toml index 3a9fbb493e..518d69add5 100644 --- a/swap-controller/Cargo.toml +++ b/swap-controller/Cargo.toml @@ -10,12 +10,14 @@ path = "src/main.rs" [dependencies] anyhow = { workspace = true } clap = { version = "4", features = ["derive"] } +comfy-table = "7.2.1" jsonrpsee = { workspace = true, features = ["client-core", "http-client"] } monero-oxide-ext = { path = "../monero-oxide-ext" } rustyline = "17.0.0" shell-words = "1.1" swap-controller-api = { path = "../swap-controller-api" } tokio = { workspace = true } +uuid = { workspace = true, features = ["serde"] } [lints] workspace = true diff --git a/swap-controller/src/cli.rs b/swap-controller/src/cli.rs index f0f89f200f..1e458d7df5 100644 --- a/swap-controller/src/cli.rs +++ b/swap-controller/src/cli.rs @@ -1,4 +1,5 @@ use clap::{Parser, Subcommand}; +use uuid::Uuid; #[derive(Parser)] #[command(name = "asb-controller")] @@ -37,4 +38,17 @@ pub enum Cmd { GetSwaps, /// Show rendezvous registration status RegistrationStatus, + /// Set whether to burn Bitcoin on refund for a swap + SetWithholdDeposit { + /// The swap ID + swap_id: Uuid, + /// Whether to burn the Bitcoin (true or false) + #[arg(action = clap::ArgAction::Set)] + withhold: bool, + }, + /// Grant mercy (release the anti-spam deposit) for a swap in BtcWithheld state + GrantMercy { + /// The swap ID + swap_id: Uuid, + }, } diff --git a/swap-controller/src/main.rs b/swap-controller/src/main.rs index 8951484dd8..3aefe3e195 100644 --- a/swap-controller/src/main.rs +++ b/swap-controller/src/main.rs @@ -76,13 +76,41 @@ async fn dispatch(cmd: Cmd, client: impl AsbApiClient) -> anyhow::Result<()> { } Cmd::GetSwaps => { let swaps = client.get_swaps().await?; + + let mut table = comfy_table::Table::new(); + table.set_header([ + "ID", + "Started", + "State", + "BTC Lock TxID", + "BTC", + "XMR", + "Rate (BTC/XMR)", + "Peer ID", + "Completed", + ]); + if swaps.is_empty() { - println!("No swaps found"); + table.add_row(["No swaps found"]); } else { - for swap in swaps { - println!("{}: {}", swap.id, swap.state); + for swap in &swaps { + let xmr = monero_oxide_ext::Amount::from_pico(swap.xmr_amount); + table.add_row([ + &swap.swap_id, + &swap.start_date, + &swap.state, + &swap.btc_lock_txid, + &swap.btc_amount.to_string(), + // Floating point may introduce very small inaccuracies here + &format!("{:.12} XMR", xmr.as_xmr()), + &swap.exchange_rate.to_string(), + &swap.peer_id, + &swap.completed.to_string(), + ]); } } + + println!("{table}"); } Cmd::BitcoinSeed => { let response = client.bitcoin_seed().await?; @@ -95,7 +123,7 @@ async fn dispatch(cmd: Cmd, client: impl AsbApiClient) -> anyhow::Result<()> { println!("No rendezvous points configured"); } else { for item in response.registrations { - let address = item.address.as_deref().unwrap_or("?"); + let address = item.address.as_ref().map(String::as_str).unwrap_or("?"); println!( "Connection status to rendezvous point at \"{}\" is \"{:?}\". Registration status is \"{:?}\"", address, item.connection, item.registration @@ -103,6 +131,21 @@ async fn dispatch(cmd: Cmd, client: impl AsbApiClient) -> anyhow::Result<()> { } } } + Cmd::SetWithholdDeposit { + swap_id, + withhold: burn, + } => { + client.set_withhold_deposit(swap_id, burn).await?; + if burn { + println!("Withholding deposit should the taker refund for swap {swap_id}"); + } else { + println!("Not withholding deposit should the taker refund for swap {swap_id}"); + } + } + Cmd::GrantMercy { swap_id } => { + client.grant_mercy(swap_id).await?; + println!("Mercy granted for swap {swap_id}"); + } } Ok(()) } diff --git a/swap-core/proptest-regressions/bitcoin.txt b/swap-core/proptest-regressions/bitcoin.txt new file mode 100644 index 0000000000..29be8f4aca --- /dev/null +++ b/swap-core/proptest-regressions/bitcoin.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc dd09112d3bef8c0fb013a7fc8a1e2bb68e9a5db3041b954c7b7723e8fcace068 # shrinks to funding_amount = 3000, num_utxos = 1, sats_per_vb = 1, key = Xpriv { network: Test, depth: 0, parent_fingerprint: 00000000, child_number: Normal { index: 0 }, private_key: SecretKey(#dc659542f09c329f), chain_code: c94a49bbee951f7f7401c801db73c23cf17b0b3aa2f6246a8616fe2205a6ca51 }, alice = Point(0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798), bob = Point(02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5) diff --git a/swap-core/src/bitcoin.rs b/swap-core/src/bitcoin.rs index 2a320673ae..4ccd6fc34d 100644 --- a/swap-core/src/bitcoin.rs +++ b/swap-core/src/bitcoin.rs @@ -2,38 +2,46 @@ mod cancel; mod early_refund; +mod full_refund; mod lock; +mod mercy; +mod partial_refund; mod punish; +mod reclaim; mod redeem; -mod refund; mod timelocks; +mod withhold; pub use crate::bitcoin::cancel::TxCancel; pub use crate::bitcoin::early_refund::TxEarlyRefund; +pub use crate::bitcoin::full_refund::TxFullRefund; pub use crate::bitcoin::lock::TxLock; +pub use crate::bitcoin::mercy::TxMercy; +pub use crate::bitcoin::partial_refund::TxPartialRefund; pub use crate::bitcoin::punish::TxPunish; +pub use crate::bitcoin::reclaim::TxReclaim; pub use crate::bitcoin::redeem::TxRedeem; -pub use crate::bitcoin::refund::TxRefund; pub use crate::bitcoin::timelocks::{BlockHeight, ExpiredTimelocks}; -pub use crate::bitcoin::timelocks::{CancelTimelock, PunishTimelock}; +pub use crate::bitcoin::timelocks::{CancelTimelock, PunishTimelock, RemainingRefundTimelock}; +pub use crate::bitcoin::withhold::TxWithhold; pub use ::bitcoin::amount::Amount; pub use ::bitcoin::psbt::Psbt as PartiallySignedTransaction; pub use ::bitcoin::{Address, AddressType, Network, Transaction, Txid}; +pub use bitcoin_wallet::ScriptStatus; +pub use ecdsa_fun::Signature; pub use ecdsa_fun::adaptor::EncryptedSignature; pub use ecdsa_fun::fun::Scalar; -pub use ecdsa_fun::Signature; use ::bitcoin::hashes::Hash; use ::bitcoin::secp256k1::ecdsa; use ::bitcoin::sighash::SegwitV0Sighash as Sighash; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use bdk_wallet::miniscript::descriptor::Wsh; use bdk_wallet::miniscript::{Descriptor, Segwitv0}; -use bitcoin_wallet::primitives::ScriptStatus; +use ecdsa_fun::ECDSA; use ecdsa_fun::adaptor::{Adaptor, HashTranscript}; use ecdsa_fun::fun::Point; use ecdsa_fun::nonce::Deterministic; -use ecdsa_fun::ECDSA; use rand::{CryptoRng, RngCore}; use serde::{Deserialize, Serialize}; use sha2::Sha256; @@ -242,13 +250,30 @@ pub fn recover(S: PublicKey, sig: Signature, encsig: EncryptedSignature) -> Resu pub fn current_epoch( cancel_timelock: CancelTimelock, punish_timelock: PunishTimelock, + remaining_refund_timelock: Option, tx_lock_status: ScriptStatus, tx_cancel_status: ScriptStatus, + tx_partial_refund_status: Option, ) -> ExpiredTimelocks { if tx_cancel_status.is_confirmed_with(punish_timelock) { return ExpiredTimelocks::Punish; } + // Check if TxPartialRefund is confirmed and handle remaining refund timelock + // For old swaps, these will be None and we skip the partial refund checks + if let (Some(remaining_refund_timelock), Some(tx_partial_refund_status)) = + (remaining_refund_timelock, tx_partial_refund_status) + { + if tx_partial_refund_status.is_confirmed_with(remaining_refund_timelock) { + return ExpiredTimelocks::RemainingRefund; + } + if tx_partial_refund_status.is_confirmed() { + return ExpiredTimelocks::WaitingForRemainingRefund { + blocks_left: tx_partial_refund_status.blocks_left_until(remaining_refund_timelock), + }; + } + } + if tx_lock_status.is_confirmed_with(cancel_timelock) { return ExpiredTimelocks::Cancel { blocks_left: tx_cancel_status.blocks_left_until(punish_timelock), @@ -557,8 +582,8 @@ mod tests { /// subscriptions to the transaction are on index `0` when broadcasting the /// transaction. #[tokio::test] - async fn given_amounts_with_change_outputs_when_signing_tx_then_output_index_0_is_ensured_for_script( - ) { + async fn given_amounts_with_change_outputs_when_signing_tx_then_output_index_0_is_ensured_for_script() + { // This value is somewhat arbitrary but the indexation problem usually occurred // on the first or second value (i.e. 547, 548) We keep the test // iterations relatively low because these tests are expensive. @@ -700,9 +725,9 @@ TRACE bitcoin_wallet::wallet: Bitcoin transaction status changed txid=0000000000 mod cached_fee_estimator_tests { use super::*; - use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; - use tokio::time::{sleep, Duration}; + use std::sync::atomic::{AtomicU32, Ordering}; + use tokio::time::{Duration, sleep}; /// Mock fee estimator that tracks how many times methods are called #[derive(Clone)] diff --git a/swap-core/src/bitcoin/cancel.rs b/swap-core/src/bitcoin/cancel.rs index a04d3898be..a84d8e65e7 100644 --- a/swap-core/src/bitcoin/cancel.rs +++ b/swap-core/src/bitcoin/cancel.rs @@ -2,14 +2,14 @@ use crate::bitcoin::{self, CancelTimelock, PunishTimelock}; use crate::bitcoin::{ - build_shared_output_descriptor, Address, Amount, PublicKey, Transaction, TxLock, + Address, Amount, PublicKey, Transaction, TxLock, build_shared_output_descriptor, }; +use ::bitcoin::Weight; use ::bitcoin::sighash::SighashCache; use ::bitcoin::transaction::Version; -use ::bitcoin::Weight; use ::bitcoin::{ - locktime::absolute::LockTime as PackedLockTime, secp256k1, sighash::SegwitV0Sighash as Sighash, EcdsaSighashType, OutPoint, ScriptBuf, Sequence, TxIn, TxOut, Txid, + locktime::absolute::LockTime as PackedLockTime, secp256k1, sighash::SegwitV0Sighash as Sighash, }; use anyhow::Result; use bdk_wallet::miniscript::Descriptor; @@ -193,6 +193,43 @@ impl TxCancel { } } + pub fn build_refund_with_amnesty_transaction( + &self, + refund_address: &Address, + amnesty_descriptor: &Descriptor<::bitcoin::PublicKey>, + amnesty_amount: Amount, + spending_fee: Amount, + ) -> Transaction { + let previous_output = self.as_outpoint(); + + let tx_in = TxIn { + previous_output, + script_sig: Default::default(), + sequence: Sequence(0xFFFF_FFFF), + witness: Default::default(), + }; + + assert!(self.amount() > (amnesty_amount + spending_fee)); + let refund_amount = self.amount() - amnesty_amount - spending_fee; + + let tx_out_refund = TxOut { + value: refund_amount, + script_pubkey: refund_address.script_pubkey(), + }; + + let tx_out_amnesty = TxOut { + value: amnesty_amount, + script_pubkey: amnesty_descriptor.script_pubkey(), + }; + + Transaction { + version: Version(2), + lock_time: PackedLockTime::from_height(0).expect("0 to be below lock time threshold"), + input: vec![tx_in], + output: vec![tx_out_refund, tx_out_amnesty], + } + } + pub fn weight() -> Weight { Weight::from_wu(596) } diff --git a/swap-core/src/bitcoin/refund.rs b/swap-core/src/bitcoin/full_refund.rs similarity index 88% rename from swap-core/src/bitcoin/refund.rs rename to swap-core/src/bitcoin/full_refund.rs index 77fe2080ab..5e36ce875a 100644 --- a/swap-core/src/bitcoin/refund.rs +++ b/swap-core/src/bitcoin/full_refund.rs @@ -18,15 +18,23 @@ use std::sync::Arc; use super::extract_ecdsa_sig; +/// A transaction that refunds 100% of the locked Bitcoin. +/// Previously to the partial refund protocol change, this was the only type of refund transaction. +/// +/// Now there also is the partial refund transaction, which refunds only a portion of the locked Bitcoin. +/// For more information, see [#675](https://github.com/eigenwallet/core/pull/675). +/// +/// The main reason this struct is still here is to 1) keep backwards compatibility in the database +/// and 2) avoid having to pay fees for 2 Bitcoin transactions when we want to get a full refund anyway. #[derive(Debug, Clone)] -pub struct TxRefund { +pub struct TxFullRefund { inner: Transaction, digest: Sighash, cancel_output_descriptor: Descriptor<::bitcoin::PublicKey>, watch_script: ScriptBuf, } -impl TxRefund { +impl TxFullRefund { pub fn new(tx_cancel: &TxCancel, refund_address: &Address, spending_fee: Amount) -> Self { let tx_refund = tx_cancel.build_spend_transaction(refund_address, None, spending_fee); @@ -158,7 +166,7 @@ impl TxRefund { } } -impl Watchable for TxRefund { +impl Watchable for TxFullRefund { fn id(&self) -> Txid { self.txid() } diff --git a/swap-core/src/bitcoin/lock.rs b/swap-core/src/bitcoin/lock.rs index 35329431ee..b47bc9ddce 100644 --- a/swap-core/src/bitcoin/lock.rs +++ b/swap-core/src/bitcoin/lock.rs @@ -1,12 +1,12 @@ #![allow(non_snake_case)] -use crate::bitcoin::{build_shared_output_descriptor, Address, Amount, PublicKey, Transaction}; +use crate::bitcoin::{Address, Amount, PublicKey, Transaction, build_shared_output_descriptor}; use ::bitcoin::psbt::Psbt as PartiallySignedTransaction; use ::bitcoin::{OutPoint, TxIn, TxOut, Txid}; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use bdk_wallet::miniscript::Descriptor; use bdk_wallet::psbt::PsbtUtils; -use bitcoin::{locktime::absolute::LockTime as PackedLockTime, ScriptBuf, Sequence}; +use bitcoin::{ScriptBuf, Sequence, locktime::absolute::LockTime as PackedLockTime}; use bitcoin_wallet::primitives::Watchable; use serde::{Deserialize, Serialize}; diff --git a/swap-core/src/bitcoin/mercy.rs b/swap-core/src/bitcoin/mercy.rs new file mode 100644 index 0000000000..1630c621cf --- /dev/null +++ b/swap-core/src/bitcoin/mercy.rs @@ -0,0 +1,145 @@ +#![allow(non_snake_case)] + +use crate::bitcoin::withhold::TxWithhold; +use crate::bitcoin::{self, Address, Amount, PublicKey, Transaction}; +use ::bitcoin::sighash::SighashCache; +use ::bitcoin::{EcdsaSighashType, Txid, sighash::SegwitV0Sighash as Sighash}; +use ::bitcoin::{ScriptBuf, Weight, secp256k1}; +use anyhow::{Context, Result}; +use bdk_wallet::miniscript::Descriptor; +use bitcoin_wallet::primitives::Watchable; +use ecdsa_fun::Signature; +use std::collections::HashMap; + +/// TxFinalAmnesty spends the burn output of TxRefundBurn and sends it to +/// Bob's refund address. This allows Alice to voluntarily refund Bob even +/// after she has "burnt" the amnesty output. +/// +/// This transaction is presigned by Bob during swap setup, but Alice keeps +/// her signature private until she decides to cooperate (e.g., if Bob contacts +/// her to request the refund). +#[derive(Debug, Clone)] +pub struct TxMercy { + inner: Transaction, + digest: Sighash, + burn_output_descriptor: Descriptor<::bitcoin::PublicKey>, + watch_script: ScriptBuf, +} + +impl TxMercy { + pub fn new( + tx_refund_burn: &TxWithhold, + refund_address: &Address, + spending_fee: Amount, + ) -> Self { + // TODO: Handle case where fee >= burn amount more gracefully + assert!( + tx_refund_burn.amount() > spending_fee, + "TxFinalAmnesty fee ({}) must be less than burn amount ({})", + spending_fee, + tx_refund_burn.amount() + ); + + let tx_final_amnesty = tx_refund_burn.build_spend_transaction(refund_address, spending_fee); + + let digest = SighashCache::new(&tx_final_amnesty) + .p2wsh_signature_hash( + 0, // Only one input: burn output from tx_refund_burn + &tx_refund_burn + .burn_output_descriptor + .script_code() + .expect("scriptcode"), + tx_refund_burn.amount(), + EcdsaSighashType::All, + ) + .expect("sighash"); + + Self { + inner: tx_final_amnesty, + digest, + burn_output_descriptor: tx_refund_burn.burn_output_descriptor.clone(), + watch_script: refund_address.script_pubkey(), + } + } + + pub fn txid(&self) -> Txid { + self.inner.compute_txid() + } + + pub fn digest(&self) -> Sighash { + self.digest + } + + pub fn complete_as_alice( + &self, + a: bitcoin::SecretKey, + B: bitcoin::PublicKey, + sig_b: Signature, + ) -> Result { + let sig_a = a.sign(self.digest()); + + self.clone() + .add_signatures((a.public(), sig_a), (B, sig_b)) + .context("Couldn't add signatures to transaction") + } + + pub fn add_signatures( + self, + (A, sig_a): (PublicKey, Signature), + (B, sig_b): (PublicKey, Signature), + ) -> Result { + let satisfier = { + let mut satisfier = HashMap::with_capacity(2); + + let A = ::bitcoin::PublicKey { + compressed: true, + inner: secp256k1::PublicKey::from_slice(&A.0.to_bytes())?, + }; + let B = ::bitcoin::PublicKey { + compressed: true, + inner: secp256k1::PublicKey::from_slice(&B.0.to_bytes())?, + }; + + let sig_a = secp256k1::ecdsa::Signature::from_compact(&sig_a.to_bytes())?; + let sig_b = secp256k1::ecdsa::Signature::from_compact(&sig_b.to_bytes())?; + + // The order in which these are inserted doesn't matter + satisfier.insert( + A, + ::bitcoin::ecdsa::Signature { + signature: sig_a, + sighash_type: EcdsaSighashType::All, + }, + ); + satisfier.insert( + B, + ::bitcoin::ecdsa::Signature { + signature: sig_b, + sighash_type: EcdsaSighashType::All, + }, + ); + + satisfier + }; + + let mut tx = self.inner; + self.burn_output_descriptor + .satisfy(&mut tx.input[0], satisfier)?; + + Ok(tx) + } + + pub fn weight() -> Weight { + Weight::from_wu(548) + } +} + +impl Watchable for TxMercy { + fn id(&self) -> Txid { + self.txid() + } + + fn script(&self) -> ScriptBuf { + self.watch_script.clone() + } +} diff --git a/swap-core/src/bitcoin/partial_refund.rs b/swap-core/src/bitcoin/partial_refund.rs new file mode 100644 index 0000000000..b45861cdd6 --- /dev/null +++ b/swap-core/src/bitcoin/partial_refund.rs @@ -0,0 +1,271 @@ +#![allow(non_snake_case)] + +use crate::bitcoin; +use crate::bitcoin::{ + Address, Amount, EmptyWitnessStack, NoInputs, NotThreeWitnesses, PublicKey, TooManyInputs, + Transaction, TxCancel, build_shared_output_descriptor, verify_sig, +}; +use ::bitcoin::sighash::SighashCache; +use ::bitcoin::{EcdsaSighashType, Txid, sighash::SegwitV0Sighash as Sighash}; +use ::bitcoin::{ScriptBuf, Weight, secp256k1}; +use anyhow::{Context, Result, bail}; +use bdk_wallet::miniscript::Descriptor; +use bitcoin_wallet::primitives::Watchable; +use curve25519_dalek::scalar::Scalar; +use ecdsa_fun::Signature; +use std::collections::HashMap; +use std::sync::Arc; + +use super::extract_ecdsa_sig; +use super::timelocks::RemainingRefundTimelock; + +#[derive(Debug, Clone)] +pub struct TxPartialRefund { + inner: Transaction, + digest: Sighash, + cancel_output_descriptor: Descriptor<::bitcoin::PublicKey>, + pub(in crate::bitcoin) amnesty_output_descriptor: Descriptor<::bitcoin::PublicKey>, + watch_script: ScriptBuf, +} + +impl TxPartialRefund { + pub fn new( + tx_cancel: &TxCancel, + refund_address: &Address, + A: PublicKey, + B: PublicKey, + amnesty_amount: Amount, + spending_fee: Amount, + ) -> Result { + let amnesty_output_descriptor = build_shared_output_descriptor(A.0, B.0)?; + + let tx_refund = tx_cancel.build_refund_with_amnesty_transaction( + refund_address, + &amnesty_output_descriptor, + amnesty_amount, + spending_fee, + ); + + let digest = SighashCache::new(&tx_refund) + .p2wsh_signature_hash( + // Only one input: cancel transaction + 0, + &tx_cancel + .output_descriptor + .script_code() + .expect("scriptcode"), + tx_cancel.amount(), + EcdsaSighashType::All, + ) + .expect("sighash"); + + Ok(Self { + inner: tx_refund, + digest, + cancel_output_descriptor: tx_cancel.output_descriptor.clone(), + amnesty_output_descriptor, + watch_script: refund_address.script_pubkey(), + }) + } + + pub fn txid(&self) -> Txid { + self.inner.compute_txid() + } + + pub fn digest(&self) -> Sighash { + self.digest + } + + pub fn anti_spam_deposit(&self) -> Amount { + self.inner.output[1].value + } + + pub fn ani_spam_deposit_outpoint(&self) -> ::bitcoin::OutPoint { + ::bitcoin::OutPoint::new(self.txid(), 1) + } + + pub fn build_reclaim_transaction( + &self, + refund_address: &Address, + spending_fee: Amount, + remaining_refund_timelock: RemainingRefundTimelock, + ) -> Result { + use ::bitcoin::{ + Sequence, TxIn, TxOut, locktime::absolute::LockTime as PackedLockTime, + transaction::Version, + }; + + let tx_in = TxIn { + previous_output: self.ani_spam_deposit_outpoint(), + script_sig: Default::default(), + sequence: Sequence(remaining_refund_timelock.0), + witness: Default::default(), + }; + + let tx_out = TxOut { + value: self + .anti_spam_deposit() + .checked_sub(spending_fee) + .context("btc amnesty amount is less than spending fee")?, + script_pubkey: refund_address.script_pubkey(), + }; + + Ok(Transaction { + version: Version(2), + lock_time: PackedLockTime::from_height(0).expect("0 to be below lock time threshold"), + input: vec![tx_in], + output: vec![tx_out], + }) + } + + /// Build a transaction that spends the amnesty output to a new 2-of-2 multisig (burn output). + /// This is used by TxRefundBurn to "burn" the amnesty by moving it to another multisig. + /// Unlike `build_amnesty_spend_transaction`, this has no timelock. + pub fn build_withhold_transaction( + &self, + burn_output_descriptor: &Descriptor<::bitcoin::PublicKey>, + spending_fee: Amount, + ) -> Transaction { + use ::bitcoin::{ + Sequence, TxIn, TxOut, locktime::absolute::LockTime as PackedLockTime, + transaction::Version, + }; + + // TODO: Handle case where fee >= amnesty_amount more gracefully + assert!( + self.anti_spam_deposit() > spending_fee, + "Burn spend fee ({}) must be less than amnesty amount ({})", + spending_fee, + self.anti_spam_deposit() + ); + + let tx_in = TxIn { + previous_output: self.ani_spam_deposit_outpoint(), + script_sig: Default::default(), + sequence: Sequence(0xFFFF_FFFF), // No timelock + witness: Default::default(), + }; + + let tx_out = TxOut { + value: self.anti_spam_deposit() - spending_fee, + script_pubkey: burn_output_descriptor.script_pubkey(), + }; + + Transaction { + version: Version(2), + lock_time: PackedLockTime::from_height(0).expect("0 to be below lock time threshold"), + input: vec![tx_in], + output: vec![tx_out], + } + } + + pub fn add_signatures( + self, + (A, sig_a): (PublicKey, Signature), + (B, sig_b): (PublicKey, Signature), + ) -> Result { + let satisfier = { + let mut satisfier = HashMap::with_capacity(2); + + let A = ::bitcoin::PublicKey { + compressed: true, + inner: secp256k1::PublicKey::from_slice(&A.0.to_bytes())?, + }; + let B = ::bitcoin::PublicKey { + compressed: true, + inner: secp256k1::PublicKey::from_slice(&B.0.to_bytes())?, + }; + + let sig_a = secp256k1::ecdsa::Signature::from_compact(&sig_a.to_bytes())?; + let sig_b = secp256k1::ecdsa::Signature::from_compact(&sig_b.to_bytes())?; + + // The order in which these are inserted doesn't matter + satisfier.insert( + A, + ::bitcoin::ecdsa::Signature { + signature: sig_a, + sighash_type: EcdsaSighashType::All, + }, + ); + satisfier.insert( + B, + ::bitcoin::ecdsa::Signature { + signature: sig_b, + sighash_type: EcdsaSighashType::All, + }, + ); + + satisfier + }; + + let mut tx_refund = self.inner; + self.cancel_output_descriptor + .satisfy(&mut tx_refund.input[0], satisfier)?; + + Ok(tx_refund) + } + + pub fn extract_monero_private_key( + &self, + signed_refund_tx: Arc, + s_a: Scalar, + a: bitcoin::SecretKey, + S_b_bitcoin: bitcoin::PublicKey, + ) -> Result { + let tx_refund_sig = self + .extract_signature_by_key(signed_refund_tx, a.public()) + .context("Failed to extract signature from Bitcoin partial refund tx")?; + let tx_refund_encsig = a.encsign(S_b_bitcoin, self.digest()); + + let s_b = bitcoin::recover(S_b_bitcoin, tx_refund_sig, tx_refund_encsig) + .context("Failed to recover Monero secret key from Bitcoin signature")?; + + let s_b = crate::monero::primitives::private_key_from_secp256k1_scalar(s_b.into()); + + let spend_key = s_a + s_b; + + Ok(spend_key) + } + + fn extract_signature_by_key( + &self, + candidate_transaction: Arc, + B: PublicKey, + ) -> Result { + let input = match candidate_transaction.input.as_slice() { + [input] => input, + [] => bail!(NoInputs), + inputs => bail!(TooManyInputs(inputs.len())), + }; + + let sigs = match input.witness.to_vec().as_slice() { + [sig_1, sig_2, _script] => [sig_1, sig_2] + .into_iter() + .map(|sig| extract_ecdsa_sig(sig)) + .collect::, _>>(), + [] => bail!(EmptyWitnessStack), + witnesses => bail!(NotThreeWitnesses(witnesses.len())), + }?; + + let sig = sigs + .into_iter() + .find(|sig| verify_sig(&B, &self.digest(), sig).is_ok()) + .context("Neither signature on witness stack verifies against B")?; + + Ok(sig) + } + + pub fn weight() -> Weight { + Weight::from_wu(720) + } +} + +impl Watchable for TxPartialRefund { + fn id(&self) -> Txid { + self.txid() + } + + fn script(&self) -> ScriptBuf { + self.watch_script.clone() + } +} diff --git a/swap-core/src/bitcoin/reclaim.rs b/swap-core/src/bitcoin/reclaim.rs new file mode 100644 index 0000000000..66228ad8f6 --- /dev/null +++ b/swap-core/src/bitcoin/reclaim.rs @@ -0,0 +1,134 @@ +use crate::bitcoin::partial_refund::TxPartialRefund; +use crate::bitcoin::{self, Address, Amount, PublicKey, Transaction}; +use ::bitcoin::sighash::SighashCache; +use ::bitcoin::{EcdsaSighashType, Txid, sighash::SegwitV0Sighash as Sighash}; +use ::bitcoin::{ScriptBuf, Weight, secp256k1}; +use anyhow::{Context, Result}; +use bdk_wallet::miniscript::Descriptor; +use bitcoin_wallet::primitives::Watchable; +use ecdsa_fun::Signature; +use std::collections::HashMap; + +use super::timelocks::RemainingRefundTimelock; + +#[derive(Debug, Clone)] +pub struct TxReclaim { + inner: Transaction, + digest: Sighash, + amensty_output_descriptor: Descriptor<::bitcoin::PublicKey>, + watch_script: ScriptBuf, +} + +impl TxReclaim { + pub fn new( + tx_refund: &TxPartialRefund, + refund_address: &Address, + spending_fee: Amount, + remaining_refund_timelock: RemainingRefundTimelock, + ) -> Result { + let tx_refund_amnesty = tx_refund + .build_reclaim_transaction(refund_address, spending_fee, remaining_refund_timelock) + .context("Couldn't build tx refund amnesty")?; + + let digest = SighashCache::new(&tx_refund_amnesty) + .p2wsh_signature_hash( + 0, // Only one input: amnesty box from tx_refund + &tx_refund + .amnesty_output_descriptor + .script_code() + .expect("scriptcode"), + tx_refund.anti_spam_deposit(), + EcdsaSighashType::All, + ) + .expect("sighash"); + + Ok(Self { + inner: tx_refund_amnesty, + digest, + amensty_output_descriptor: tx_refund.amnesty_output_descriptor.clone(), + watch_script: refund_address.script_pubkey(), + }) + } + + pub fn txid(&self) -> Txid { + self.inner.compute_txid() + } + + pub fn digest(&self) -> Sighash { + self.digest + } + + pub fn complete_as_alice( + &self, + s_a: bitcoin::SecretKey, + B: bitcoin::PublicKey, + sig_b: Signature, + ) -> Result { + let digest = self.digest(); + let sig_a = s_a.sign(digest); + + self.clone() + .add_signatures((s_a.public(), sig_a), (B, sig_b)) + .context("Couldn't add signatures to transaction") + } + + pub fn add_signatures( + self, + (A, sig_a): (PublicKey, Signature), + (B, sig_b): (PublicKey, Signature), + ) -> Result { + let satisfier = { + let mut satisfier = HashMap::with_capacity(2); + + let A = ::bitcoin::PublicKey { + compressed: true, + inner: secp256k1::PublicKey::from_slice(&A.0.to_bytes())?, + }; + let B = ::bitcoin::PublicKey { + compressed: true, + inner: secp256k1::PublicKey::from_slice(&B.0.to_bytes())?, + }; + + let sig_a = secp256k1::ecdsa::Signature::from_compact(&sig_a.to_bytes())?; + let sig_b = secp256k1::ecdsa::Signature::from_compact(&sig_b.to_bytes())?; + + // The order in which these are inserted doesn't matter + satisfier.insert( + A, + ::bitcoin::ecdsa::Signature { + signature: sig_a, + sighash_type: EcdsaSighashType::All, + }, + ); + satisfier.insert( + B, + ::bitcoin::ecdsa::Signature { + signature: sig_b, + sighash_type: EcdsaSighashType::All, + }, + ); + + satisfier + }; + + let mut tx_refund = self.inner; + self.amensty_output_descriptor + .satisfy(&mut tx_refund.input[0], satisfier)?; + + Ok(tx_refund) + } + + pub fn weight() -> Weight { + Weight::from_wu(548) + } +} + +impl Watchable for TxReclaim { + fn id(&self) -> Txid { + self.txid() + } + + fn script(&self) -> ScriptBuf { + self.watch_script.clone() + } +} diff --git a/swap-core/src/bitcoin/timelocks.rs b/swap-core/src/bitcoin/timelocks.rs index 3034758650..2b8d777916 100644 --- a/swap-core/src/bitcoin/timelocks.rs +++ b/swap-core/src/bitcoin/timelocks.rs @@ -106,12 +106,59 @@ impl PartialEq for u32 { } } +/// How long a taker has to wait to refund the remaining Bitcoin after publishing +/// TxPartialRefund. +#[derive(Debug, Copy, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[serde(transparent)] +#[typeshare] +pub struct RemainingRefundTimelock(pub u32); + +impl From for u32 { + fn from(remainin_refund_timelock: RemainingRefundTimelock) -> Self { + remainin_refund_timelock.0 + } +} + +impl From for RemainingRefundTimelock { + fn from(number_of_blocks: u32) -> Self { + Self(number_of_blocks) + } +} + +impl RemainingRefundTimelock { + pub const fn new(number_of_blocks: u32) -> Self { + Self(number_of_blocks) + } +} + +impl Add for BlockHeight { + type Output = BlockHeight; + + fn add(self, rhs: RemainingRefundTimelock) -> Self::Output { + self + rhs.0 + } +} + +impl PartialOrd for u32 { + fn partial_cmp(&self, other: &RemainingRefundTimelock) -> Option { + self.partial_cmp(&other.0) + } +} + +impl PartialEq for u32 { + fn eq(&self, other: &RemainingRefundTimelock) -> bool { + self.eq(&other.0) + } +} + #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[serde(tag = "type", content = "content")] pub enum ExpiredTimelocks { None { blocks_left: u32 }, Cancel { blocks_left: u32 }, + WaitingForRemainingRefund { blocks_left: u32 }, + RemainingRefund, Punish, } @@ -130,8 +177,8 @@ mod tests { use crate::bitcoin::*; use bitcoin::secp256k1; use bitcoin_wallet::*; - use ecdsa_fun::fun::marker::{NonZero, Public}; use ecdsa_fun::fun::Point; + use ecdsa_fun::fun::marker::{NonZero, Public}; use rand::rngs::OsRng; #[test] @@ -142,8 +189,10 @@ mod tests { let expired_timelock = current_epoch( CancelTimelock::new(5), PunishTimelock::new(5), + None, tx_lock_status, tx_cancel_status, + None, ); assert!(matches!(expired_timelock, ExpiredTimelocks::None { .. })); @@ -157,8 +206,10 @@ mod tests { let expired_timelock = current_epoch( CancelTimelock::new(5), PunishTimelock::new(5), + None, tx_lock_status, tx_cancel_status, + None, ); assert!(matches!(expired_timelock, ExpiredTimelocks::Cancel { .. })); @@ -172,13 +223,54 @@ mod tests { let expired_timelock = current_epoch( CancelTimelock::new(5), PunishTimelock::new(5), + None, tx_lock_status, tx_cancel_status, + None, ); assert_eq!(expired_timelock, ExpiredTimelocks::Punish) } + #[test] + fn partial_refund_confirmed_waiting_for_remaining_refund_timelock() { + let tx_lock_status = ScriptStatus::from_confirmations(10); + let tx_cancel_status = ScriptStatus::from_confirmations(5); + let tx_partial_refund_status = ScriptStatus::from_confirmations(2); + + let expired_timelock = current_epoch( + CancelTimelock::new(5), + PunishTimelock::new(10), + Some(RemainingRefundTimelock::new(5)), + tx_lock_status, + tx_cancel_status, + Some(tx_partial_refund_status), + ); + + assert!(matches!( + expired_timelock, + ExpiredTimelocks::WaitingForRemainingRefund { .. } + )); + } + + #[test] + fn partial_refund_remaining_timelock_expired() { + let tx_lock_status = ScriptStatus::from_confirmations(10); + let tx_cancel_status = ScriptStatus::from_confirmations(5); + let tx_partial_refund_status = ScriptStatus::from_confirmations(5); + + let expired_timelock = current_epoch( + CancelTimelock::new(5), + PunishTimelock::new(10), + Some(RemainingRefundTimelock::new(5)), + tx_lock_status, + tx_cancel_status, + Some(tx_partial_refund_status), + ); + + assert_eq!(expired_timelock, ExpiredTimelocks::RemainingRefund); + } + #[test] fn tx_early_refund_has_correct_weight() { // TxEarlyRefund should have the same weight as other similar transactions @@ -186,7 +278,10 @@ mod tests { // It should be the same as TxRedeem and TxRefund weights since they have similar structure assert_eq!(TxEarlyRefund::weight() as u64, TxRedeem::weight().to_wu()); - assert_eq!(TxEarlyRefund::weight() as u64, TxRefund::weight().to_wu()); + assert_eq!( + TxEarlyRefund::weight() as u64, + TxFullRefund::weight().to_wu() + ); } #[test] diff --git a/swap-core/src/bitcoin/withhold.rs b/swap-core/src/bitcoin/withhold.rs new file mode 100644 index 0000000000..ddc39be5e3 --- /dev/null +++ b/swap-core/src/bitcoin/withhold.rs @@ -0,0 +1,205 @@ +#![allow(non_snake_case)] + +use crate::bitcoin::partial_refund::TxPartialRefund; +use crate::bitcoin::{ + self, Address, Amount, PublicKey, Transaction, build_shared_output_descriptor, +}; +use ::bitcoin::sighash::SighashCache; +use ::bitcoin::{EcdsaSighashType, Txid, sighash::SegwitV0Sighash as Sighash}; +use ::bitcoin::{OutPoint, ScriptBuf, Weight, secp256k1}; +use anyhow::{Context, Result}; +use bdk_wallet::miniscript::Descriptor; +use bitcoin_wallet::primitives::Watchable; +use ecdsa_fun::Signature; +use std::collections::HashMap; + +/// TxRefundBurn spends the amnesty output of TxPartialRefund and sends it to +/// a new 2-of-2 multisig. This allows Alice to "burn" the amnesty (prevent Bob +/// from claiming it via TxRefundAmnesty) while still allowing a later refund +/// via TxFinalAmnesty if Alice cooperates. +/// +/// Unlike TxRefundAmnesty, this transaction has no timelock - Alice can publish +/// it immediately after TxPartialRefund is confirmed. +#[derive(Debug, Clone)] +pub struct TxWithhold { + inner: Transaction, + digest: Sighash, + amnesty_output_descriptor: Descriptor<::bitcoin::PublicKey>, + pub(in crate::bitcoin) burn_output_descriptor: Descriptor<::bitcoin::PublicKey>, + watch_script: ScriptBuf, +} + +impl TxWithhold { + pub fn new( + tx_partial_refund: &TxPartialRefund, + A: PublicKey, + B: PublicKey, + spending_fee: Amount, + ) -> Result { + // TODO: Handle case where fee >= amnesty_amount more gracefully + // For now, assert to catch this during development + assert!( + tx_partial_refund.anti_spam_deposit() > spending_fee, + "TxRefundBurn fee ({}) must be less than amnesty amount ({})", + spending_fee, + tx_partial_refund.anti_spam_deposit() + ); + + let burn_output_descriptor = build_shared_output_descriptor(A.0, B.0)?; + + let tx_refund_burn = + tx_partial_refund.build_withhold_transaction(&burn_output_descriptor, spending_fee); + + let digest = SighashCache::new(&tx_refund_burn) + .p2wsh_signature_hash( + 0, // Only one input: amnesty output from tx_partial_refund + &tx_partial_refund + .amnesty_output_descriptor + .script_code() + .expect("scriptcode"), + tx_partial_refund.anti_spam_deposit(), + EcdsaSighashType::All, + ) + .expect("sighash"); + + let watch_script = burn_output_descriptor.script_pubkey(); + + Ok(Self { + inner: tx_refund_burn, + digest, + amnesty_output_descriptor: tx_partial_refund.amnesty_output_descriptor.clone(), + burn_output_descriptor, + watch_script, + }) + } + + pub fn txid(&self) -> Txid { + self.inner.compute_txid() + } + + pub fn digest(&self) -> Sighash { + self.digest + } + + pub fn amount(&self) -> Amount { + self.inner.output[0].value + } + + pub fn as_outpoint(&self) -> OutPoint { + OutPoint::new(self.txid(), 0) + } + + pub fn complete_as_alice( + &self, + a: bitcoin::SecretKey, + B: bitcoin::PublicKey, + sig_b: Signature, + ) -> Result { + let sig_a = a.sign(self.digest()); + + self.clone() + .add_signatures((a.public(), sig_a), (B, sig_b)) + .context("Couldn't add signatures to transaction") + } + + pub fn add_signatures( + self, + (A, sig_a): (PublicKey, Signature), + (B, sig_b): (PublicKey, Signature), + ) -> Result { + let satisfier = { + let mut satisfier = HashMap::with_capacity(2); + + let A = ::bitcoin::PublicKey { + compressed: true, + inner: secp256k1::PublicKey::from_slice(&A.0.to_bytes())?, + }; + let B = ::bitcoin::PublicKey { + compressed: true, + inner: secp256k1::PublicKey::from_slice(&B.0.to_bytes())?, + }; + + let sig_a = secp256k1::ecdsa::Signature::from_compact(&sig_a.to_bytes())?; + let sig_b = secp256k1::ecdsa::Signature::from_compact(&sig_b.to_bytes())?; + + // The order in which these are inserted doesn't matter + satisfier.insert( + A, + ::bitcoin::ecdsa::Signature { + signature: sig_a, + sighash_type: EcdsaSighashType::All, + }, + ); + satisfier.insert( + B, + ::bitcoin::ecdsa::Signature { + signature: sig_b, + sighash_type: EcdsaSighashType::All, + }, + ); + + satisfier + }; + + let mut tx = self.inner; + self.amnesty_output_descriptor + .satisfy(&mut tx.input[0], satisfier)?; + + Ok(tx) + } + + /// Build a transaction that spends the burn output to a destination address. + /// Used by TxFinalAmnesty to send the funds back to Bob's refund address. + pub fn build_spend_transaction( + &self, + destination: &Address, + spending_fee: Amount, + ) -> Transaction { + use ::bitcoin::{ + Sequence, TxIn, TxOut, locktime::absolute::LockTime as PackedLockTime, + transaction::Version, + }; + + // TODO: Handle case where fee >= burn amount more gracefully + // For now, assert to catch this during development + assert!( + self.amount() > spending_fee, + "TxFinalAmnesty fee ({}) must be less than burn amount ({})", + spending_fee, + self.amount() + ); + + let tx_in = TxIn { + previous_output: self.as_outpoint(), + script_sig: Default::default(), + sequence: Sequence(0xFFFF_FFFF), // No timelock + witness: Default::default(), + }; + + let tx_out = TxOut { + value: self.amount() - spending_fee, + script_pubkey: destination.script_pubkey(), + }; + + Transaction { + version: Version(2), + lock_time: PackedLockTime::from_height(0).expect("0 to be below lock time threshold"), + input: vec![tx_in], + output: vec![tx_out], + } + } + + pub fn weight() -> Weight { + Weight::from_wu(596) + } +} + +impl Watchable for TxWithhold { + fn id(&self) -> Txid { + self.txid() + } + + fn script(&self) -> ScriptBuf { + self.watch_script.clone() + } +} diff --git a/swap-core/src/monero/primitives.rs b/swap-core/src/monero/primitives.rs index 061ba0f876..beac72791c 100644 --- a/swap-core/src/monero/primitives.rs +++ b/swap-core/src/monero/primitives.rs @@ -1,10 +1,10 @@ use crate::bitcoin; -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use monero_address::{MoneroAddress, Network}; pub use monero_oxide_wallet::ed25519::Scalar; use rand::{CryptoRng, RngCore}; -use rust_decimal::prelude::*; use rust_decimal::Decimal; +use rust_decimal::prelude::*; use serde::{Deserialize, Serialize}; use std::fmt; use std::ops::Add; @@ -326,12 +326,10 @@ impl MoneroAddressPool { impl From<::monero_address::MoneroAddress> for MoneroAddressPool { fn from(address: ::monero_address::MoneroAddress) -> Self { - Self(vec![LabeledMoneroAddress::new( - address, - Decimal::from(1), - "user address".to_string(), - ) - .expect("Percentage 1 is always valid")]) + Self(vec![ + LabeledMoneroAddress::new(address, Decimal::from(1), "user address".to_string()) + .expect("Percentage 1 is always valid"), + ]) } } diff --git a/swap-db/src/alice.rs b/swap-db/src/alice.rs index 187a1b7a29..8d4183ba71 100644 --- a/swap-db/src/alice.rs +++ b/swap-db/src/alice.rs @@ -75,6 +75,29 @@ pub enum Alice { #[serde(with = "swap_serde::monero::private_key")] spend_key: monero::PrivateKey, }, + BtcPartiallyRefunded { + monero_wallet_restore_blockheight: BlockHeight, + transfer_proof: TransferProof, + state3: alice::State3, + #[serde(with = "swap_serde::monero::private_key")] + spend_key: monero::PrivateKey, + }, + XmrRefundable { + monero_wallet_restore_blockheight: BlockHeight, + transfer_proof: TransferProof, + state3: alice::State3, + #[serde(with = "swap_serde::monero::private_key")] + spend_key: monero::PrivateKey, + }, + BtcWithholdPublished { + state3: alice::State3, + }, + BtcMercyGranted { + state3: alice::State3, + }, + BtcMercyPublished { + state3: alice::State3, + }, Done(AliceEndState), } @@ -82,7 +105,10 @@ pub enum Alice { pub enum AliceEndState { SafelyAborted, BtcRedeemed, - XmrRefunded, + XmrRefunded { + #[serde(default)] + state3: Option, + }, BtcEarlyRefunded { state3: alice::State3, }, @@ -90,20 +116,22 @@ pub enum AliceEndState { state3: alice::State3, transfer_proof: TransferProof, }, + BtcWithheld { + state3: alice::State3, + }, + BtcMercyConfirmed { + state3: alice::State3, + }, } impl From for Alice { fn from(alice_state: AliceState) -> Self { match alice_state { - AliceState::Started { state3 } => Alice::Started { - state3: state3.as_ref().clone(), - }, - AliceState::BtcLockTransactionSeen { state3 } => Alice::BtcLockTransactionSeen { - state3: state3.as_ref().clone(), - }, - AliceState::BtcLocked { state3 } => Alice::BtcLocked { - state3: state3.as_ref().clone(), - }, + AliceState::Started { state3 } => Alice::Started { state3: *state3 }, + AliceState::BtcLockTransactionSeen { state3 } => { + Alice::BtcLockTransactionSeen { state3: *state3 } + } + AliceState::BtcLocked { state3 } => Alice::BtcLocked { state3: *state3 }, AliceState::XmrLockTransactionSent { monero_wallet_restore_blockheight, transfer_proof, @@ -111,7 +139,7 @@ impl From for Alice { } => Alice::XmrLockTransactionSent { monero_wallet_restore_blockheight, transfer_proof, - state3: state3.as_ref().clone(), + state3: *state3, }, AliceState::XmrLocked { monero_wallet_restore_blockheight, @@ -120,7 +148,7 @@ impl From for Alice { } => Alice::XmrLocked { monero_wallet_restore_blockheight, transfer_proof, - state3: state3.as_ref().clone(), + state3: *state3, }, AliceState::XmrLockTransferProofSent { monero_wallet_restore_blockheight, @@ -129,7 +157,7 @@ impl From for Alice { } => Alice::XmrLockTransferProofSent { monero_wallet_restore_blockheight, transfer_proof, - state3: state3.as_ref().clone(), + state3: *state3, }, AliceState::EncSigLearned { monero_wallet_restore_blockheight, @@ -139,14 +167,14 @@ impl From for Alice { } => Alice::EncSigLearned { monero_wallet_restore_blockheight, transfer_proof, - state3: state3.as_ref().clone(), + state3: *state3, encrypted_signature: encrypted_signature.as_ref().clone(), }, AliceState::BtcRedeemTransactionPublished { state3, transfer_proof, } => Alice::BtcRedeemTransactionPublished { - state3: state3.as_ref().clone(), + state3: *state3, transfer_proof, }, AliceState::BtcRedeemed => Alice::Done(AliceEndState::BtcRedeemed), @@ -157,7 +185,7 @@ impl From for Alice { } => Alice::BtcCancelled { monero_wallet_restore_blockheight, transfer_proof, - state3: state3.as_ref().clone(), + state3: *state3, }, AliceState::BtcRefunded { monero_wallet_restore_blockheight, @@ -168,14 +196,36 @@ impl From for Alice { monero_wallet_restore_blockheight, transfer_proof, spend_key, - state3: state3.as_ref().clone(), + state3: *state3, }, - AliceState::BtcEarlyRefundable { state3 } => Alice::BtcEarlyRefundable { - state3: state3.as_ref().clone(), + AliceState::BtcPartiallyRefunded { + monero_wallet_restore_blockheight, + transfer_proof, + spend_key, + state3, + } => Alice::BtcPartiallyRefunded { + monero_wallet_restore_blockheight, + transfer_proof, + state3: *state3, + spend_key, }, - AliceState::BtcEarlyRefunded(state3) => Alice::Done(AliceEndState::BtcEarlyRefunded { - state3: state3.as_ref().clone(), - }), + AliceState::XmrRefundable { + monero_wallet_restore_blockheight, + transfer_proof, + state3, + spend_key, + } => Alice::XmrRefundable { + monero_wallet_restore_blockheight, + transfer_proof, + state3: *state3, + spend_key, + }, + AliceState::BtcEarlyRefundable { state3 } => { + Alice::BtcEarlyRefundable { state3: *state3 } + } + AliceState::BtcEarlyRefunded(state3) => { + Alice::Done(AliceEndState::BtcEarlyRefunded { state3: *state3 }) + } AliceState::BtcPunishable { monero_wallet_restore_blockheight, transfer_proof, @@ -183,9 +233,24 @@ impl From for Alice { } => Alice::BtcPunishable { monero_wallet_restore_blockheight, transfer_proof, - state3: state3.as_ref().clone(), + state3: *state3, }, - AliceState::XmrRefunded => Alice::Done(AliceEndState::XmrRefunded), + AliceState::XmrRefunded { state3 } => Alice::Done(AliceEndState::XmrRefunded { + state3: state3.map(|s| s.as_ref().clone()), + }), + AliceState::BtcWithholdPublished { state3 } => { + Alice::BtcWithholdPublished { state3: *state3 } + } + AliceState::BtcWithholdConfirmed { state3 } => { + Alice::Done(AliceEndState::BtcWithheld { state3: *state3 }) + } + AliceState::BtcMercyGranted { state3 } => Alice::BtcMercyGranted { state3: *state3 }, + AliceState::BtcMercyPublished { state3 } => { + Alice::BtcMercyPublished { state3: *state3 } + } + AliceState::BtcMercyConfirmed { state3 } => { + Alice::Done(AliceEndState::BtcMercyConfirmed { state3: *state3 }) + } AliceState::WaitingForCancelTimelockExpiration { monero_wallet_restore_blockheight, transfer_proof, @@ -193,7 +258,7 @@ impl From for Alice { } => Alice::WaitingForCancelTimelockExpiration { monero_wallet_restore_blockheight, transfer_proof, - state3: state3.as_ref().clone(), + state3: *state3, }, AliceState::CancelTimelockExpired { monero_wallet_restore_blockheight, @@ -202,13 +267,13 @@ impl From for Alice { } => Alice::CancelTimelockExpired { monero_wallet_restore_blockheight, transfer_proof, - state3: state3.as_ref().clone(), + state3: *state3, }, AliceState::BtcPunished { state3, transfer_proof, } => Alice::Done(AliceEndState::BtcPunished { - state3: state3.as_ref().clone(), + state3: *state3, transfer_proof, }), AliceState::SafelyAborted => Alice::Done(AliceEndState::SafelyAborted), @@ -320,13 +385,46 @@ impl From for AliceState { spend_key, state3: Box::new(state3), }, + Alice::BtcPartiallyRefunded { + monero_wallet_restore_blockheight, + transfer_proof, + state3, + spend_key, + } => AliceState::BtcPartiallyRefunded { + monero_wallet_restore_blockheight, + transfer_proof, + spend_key, + state3: Box::new(state3), + }, Alice::BtcEarlyRefundable { state3 } => AliceState::BtcEarlyRefundable { state3: Box::new(state3), }, + Alice::XmrRefundable { + monero_wallet_restore_blockheight, + transfer_proof, + state3, + spend_key, + } => AliceState::XmrRefundable { + monero_wallet_restore_blockheight, + transfer_proof, + spend_key, + state3: Box::new(state3), + }, + Alice::BtcWithholdPublished { state3 } => AliceState::BtcWithholdPublished { + state3: Box::new(state3), + }, + Alice::BtcMercyGranted { state3 } => AliceState::BtcMercyGranted { + state3: Box::new(state3), + }, + Alice::BtcMercyPublished { state3 } => AliceState::BtcMercyPublished { + state3: Box::new(state3), + }, Alice::Done(end_state) => match end_state { AliceEndState::SafelyAborted => AliceState::SafelyAborted, AliceEndState::BtcRedeemed => AliceState::BtcRedeemed, - AliceEndState::XmrRefunded => AliceState::XmrRefunded, + AliceEndState::XmrRefunded { state3 } => AliceState::XmrRefunded { + state3: state3.map(Box::new), + }, AliceEndState::BtcPunished { state3, transfer_proof, @@ -337,6 +435,12 @@ impl From for AliceState { AliceEndState::BtcEarlyRefunded { state3 } => { AliceState::BtcEarlyRefunded(Box::new(state3)) } + AliceEndState::BtcWithheld { state3 } => AliceState::BtcWithholdConfirmed { + state3: Box::new(state3), + }, + AliceEndState::BtcMercyConfirmed { state3 } => AliceState::BtcMercyConfirmed { + state3: Box::new(state3), + }, }, } } @@ -366,7 +470,14 @@ impl fmt::Display for Alice { Alice::BtcCancelled { .. } => f.write_str("Bitcoin cancel transaction published"), Alice::BtcPunishable { .. } => f.write_str("Bitcoin punishable"), Alice::BtcRefunded { .. } => f.write_str("Monero refundable"), + Alice::BtcPartiallyRefunded { .. } => f.write_str("Monero refundable"), Alice::BtcEarlyRefundable { .. } => f.write_str("Bitcoin early refundable"), + Alice::XmrRefundable { .. } => f.write_str("Bitcoin early refundable"), + Alice::BtcWithholdPublished { .. } => { + f.write_str("Bitcoin withhold transaction published") + } + Alice::BtcMercyGranted { .. } => f.write_str("Bitcoin mercy initiated"), + Alice::BtcMercyPublished { .. } => f.write_str("Bitcoin mercy published"), Alice::Done(end_state) => write!(f, "Done: {}", end_state), } } diff --git a/swap-db/src/bob.rs b/swap-db/src/bob.rs index 09b9b867c3..91e77a52a1 100644 --- a/swap-db/src/bob.rs +++ b/swap-db/src/bob.rs @@ -7,7 +7,6 @@ use swap_machine::bob::BobState; #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub enum Bob { Started { - #[serde(with = "::bitcoin::amount::serde::as_sat")] btc_amount: bitcoin::Amount, #[serde(with = "swap_serde::bitcoin::address_serde")] change_address: bitcoin::Address, @@ -54,6 +53,14 @@ pub enum Bob { BtcCancelled(bob::State6), BtcRefundPublished(bob::State6), BtcEarlyRefundPublished(bob::State6), + BtcPartialRefundPublished(bob::State6), + BtcPartiallyRefunded(bob::State6), + WaitingForReclaimTimelockExpiration(bob::State6), + ReclaimTimelockExpired(bob::State6), + BtcReclaimPublished(bob::State6), + BtcWithholdPublished(bob::State6), + BtcWithheld(bob::State6), + BtcMercyPublished(bob::State6), Done(BobEndState), } @@ -63,6 +70,8 @@ pub enum BobEndState { XmrRedeemed { tx_lock_id: bitcoin::Txid }, BtcRefunded(Box), BtcEarlyRefunded(Box), + BtcReclaimConfirmed(Box), + BtcMercyConfirmed(Box), } impl From for Bob { @@ -126,6 +135,7 @@ impl From for Bob { BobState::BtcCancelled(state6) => Bob::BtcCancelled(state6), BobState::BtcRefundPublished(state6) => Bob::BtcRefundPublished(state6), BobState::BtcEarlyRefundPublished(state6) => Bob::BtcEarlyRefundPublished(state6), + BobState::BtcPartialRefundPublished(state6) => Bob::BtcPartialRefundPublished(state6), BobState::BtcPunished { state, tx_lock_id } => Bob::BtcPunished { state, tx_lock_id }, BobState::BtcRefunded(state6) => Bob::Done(BobEndState::BtcRefunded(Box::new(state6))), BobState::XmrRedeemed { tx_lock_id } => { @@ -134,6 +144,21 @@ impl From for Bob { BobState::BtcEarlyRefunded(state6) => { Bob::Done(BobEndState::BtcEarlyRefunded(Box::new(state6))) } + BobState::BtcPartiallyRefunded(state6) => Bob::BtcPartiallyRefunded(state6), + BobState::BtcReclaimPublished(state6) => Bob::BtcReclaimPublished(state6), + BobState::BtcReclaimConfirmed(state6) => { + Bob::Done(BobEndState::BtcReclaimConfirmed(Box::new(state6))) + } + BobState::WaitingForReclaimTimelockExpiration(state6) => { + Bob::WaitingForReclaimTimelockExpiration(state6) + } + BobState::ReclaimTimelockExpired(state6) => Bob::ReclaimTimelockExpired(state6), + BobState::BtcWithholdPublished(state6) => Bob::BtcWithholdPublished(state6), + BobState::BtcWithheld(state6) => Bob::BtcWithheld(state6), + BobState::BtcMercyPublished(state6) => Bob::BtcMercyPublished(state6), + BobState::BtcMercyConfirmed(state6) => { + Bob::Done(BobEndState::BtcMercyConfirmed(Box::new(state6))) + } BobState::SafelyAborted => Bob::Done(BobEndState::SafelyAborted), } } @@ -199,13 +224,25 @@ impl From for BobState { Bob::CancelTimelockExpired(state6) => BobState::CancelTimelockExpired(state6), Bob::BtcCancelled(state6) => BobState::BtcCancelled(state6), Bob::BtcRefundPublished(state6) => BobState::BtcRefundPublished(state6), + Bob::BtcPartialRefundPublished(state6) => BobState::BtcPartialRefundPublished(state6), + Bob::BtcPartiallyRefunded(state6) => BobState::BtcPartiallyRefunded(state6), + Bob::BtcReclaimPublished(state6) => BobState::BtcReclaimPublished(state6), Bob::BtcEarlyRefundPublished(state6) => BobState::BtcEarlyRefundPublished(state6), Bob::BtcPunished { state, tx_lock_id } => BobState::BtcPunished { state, tx_lock_id }, + Bob::WaitingForReclaimTimelockExpiration(state6) => { + BobState::WaitingForReclaimTimelockExpiration(state6) + } + Bob::ReclaimTimelockExpired(state6) => BobState::ReclaimTimelockExpired(state6), + Bob::BtcWithholdPublished(state6) => BobState::BtcWithholdPublished(state6), + Bob::BtcWithheld(state6) => BobState::BtcWithheld(state6), + Bob::BtcMercyPublished(state6) => BobState::BtcMercyPublished(state6), Bob::Done(end_state) => match end_state { BobEndState::SafelyAborted => BobState::SafelyAborted, BobEndState::XmrRedeemed { tx_lock_id } => BobState::XmrRedeemed { tx_lock_id }, BobEndState::BtcRefunded(state6) => BobState::BtcRefunded(*state6), BobEndState::BtcEarlyRefunded(state6) => BobState::BtcEarlyRefunded(*state6), + BobEndState::BtcReclaimConfirmed(state6) => BobState::BtcReclaimConfirmed(*state6), + BobEndState::BtcMercyConfirmed(state6) => BobState::BtcMercyConfirmed(*state6), }, } } @@ -230,10 +267,22 @@ impl fmt::Display for Bob { Bob::BtcCancelled(_) => f.write_str("Bitcoin refundable"), Bob::BtcRefundPublished { .. } => f.write_str("Bitcoin refund published"), Bob::BtcEarlyRefundPublished { .. } => f.write_str("Bitcoin early refund published"), + Bob::BtcPartialRefundPublished { .. } => { + f.write_str("Bitcoin partially refund published") + } Bob::BtcRedeemed(_) => f.write_str("Monero redeemable"), Bob::Done(end_state) => write!(f, "Done: {}", end_state), Bob::EncSigSent { .. } => f.write_str("Encrypted signature sent"), Bob::BtcPunished { .. } => f.write_str("Bitcoin punished"), + Bob::BtcPartiallyRefunded { .. } => f.write_str("Bitcoin partially refunded"), + Bob::BtcReclaimPublished { .. } => f.write_str("Bitcoin reclaim transaction published"), + Bob::WaitingForReclaimTimelockExpiration { .. } => { + f.write_str("Waiting for reclaim timelock to expire") + } + Bob::ReclaimTimelockExpired { .. } => f.write_str("Reclaim timelock expired"), + Bob::BtcWithholdPublished { .. } => f.write_str("Bitcoin withhold published"), + Bob::BtcWithheld { .. } => f.write_str("Bitcoin withheld"), + Bob::BtcMercyPublished { .. } => f.write_str("Bitcoin mercy published"), } } } diff --git a/swap-env/src/config.rs b/swap-env/src/config.rs index f976dd40d6..8f73ed73eb 100644 --- a/swap-env/src/config.rs +++ b/swap-env/src/config.rs @@ -1,10 +1,10 @@ use crate::defaults::{ - GetDefaults, BITFINEX_PRICE_TICKER_WS_URL, KRAKEN_PRICE_TICKER_WS_URL, + BITFINEX_PRICE_TICKER_WS_URL, GetDefaults, KRAKEN_PRICE_TICKER_WS_URL, KUCOIN_PRICE_TICKER_REST_URL, }; use crate::env::{Mainnet, Testnet}; use crate::prompt; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use config::ConfigError; use libp2p::core::Multiaddr; use rust_decimal::Decimal; @@ -101,23 +101,45 @@ pub struct Maker { #[serde(with = "::bitcoin::amount::serde::as_btc")] pub max_buy_btc: bitcoin::Amount, pub ask_spread: Decimal, + /// What refund conditions to give to takers. + #[serde(default)] + pub refund_policy: RefundPolicy, #[serde(default = "default_price_ticker_ws_url_kraken")] pub price_ticker_ws_url_kraken: Url, #[serde(default = "default_price_ticker_ws_url_bitfinex")] pub price_ticker_ws_url_bitfinex: Url, #[serde(default = "default_price_ticker_rest_url_kucoin")] pub price_ticker_rest_url_kucoin: Url, + /// If specified, Bitcoin received from successful swaps will be sent to this address. #[serde(default, with = "swap_serde::bitcoin::address_serde::option")] pub external_bitcoin_redeem_address: Option, /// Percentage (between 0.0 and 1.0) of the swap amount - // that will be donated to the project as part of the Monero lock transaction + /// that will be donated to the devepment fund as part of the Monero lock transaction. #[serde(default = "default_developer_tip")] pub developer_tip: Decimal, } -fn default_developer_tip() -> Decimal { - // By default, we do not tip - Decimal::ZERO +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct RefundPolicy { + /// Takers will only receive this percentage of their Bitcoin back by default. + /// Maker can still issue "amnesty" to refund the rest. + /// This protects the maker against griefing attacks. + #[serde(default = "default_anti_spam_deposit_ratio")] + pub anti_spam_deposit_ratio: Decimal, + /// If true, Alice will publish TxRefundBurn after refunding her XMR, + /// denying Bob access to the amnesty output. Alice can later grant + /// final amnesty to return the funds to Bob. + #[serde(default = "default_always_withhold_deposit")] + pub always_withhold_deposit: bool, +} + +impl Default for RefundPolicy { + fn default() -> Self { + Self { + anti_spam_deposit_ratio: default_anti_spam_deposit_ratio(), + always_withhold_deposit: false, + } + } } fn default_price_ticker_ws_url_kraken() -> Url { @@ -132,6 +154,18 @@ fn default_price_ticker_rest_url_kucoin() -> Url { Url::parse(KUCOIN_PRICE_TICKER_REST_URL).expect("default kucoin rest url to be valid") } +fn default_developer_tip() -> Decimal { + Decimal::ZERO +} + +fn default_anti_spam_deposit_ratio() -> Decimal { + Decimal::ZERO +} + +fn default_always_withhold_deposit() -> bool { + false +} + impl Config { pub fn read(config_file: D) -> Result where @@ -180,6 +214,40 @@ pub fn read_config(config_path: PathBuf) -> Result Result<()> { + if config.monero.network != env_config.monero_network { + bail!( + "Expected monero network in config file to be {:?} but was {:?}", + env_config.monero_network, + config.monero.network + ); + } + if config.bitcoin.network != env_config.bitcoin_network { + bail!( + "Expected bitcoin network in config file to be {:?} but was {:?}", + env_config.bitcoin_network, + config.bitcoin.network + ); + } + + let ratio = config.maker.refund_policy.anti_spam_deposit_ratio; + if ratio < Decimal::ZERO || ratio > Decimal::ONE { + bail!("anti_spam_deposit_ratio must be between 0 and 1, got {ratio}"); + } + if ratio > MAX_ANTI_SPAM_DEPOSIT_RATIO { + bail!( + "anti_spam_deposit_ratio of {ratio} exceeds maximum of {MAX_ANTI_SPAM_DEPOSIT_RATIO}. \ + Such a high deposit ratio is implausible and likely a misconfiguration." + ); + } + + Ok(()) +} + pub fn initial_setup(config_path: PathBuf, config: Config) -> Result<()> { let toml = toml::to_string(&config)?; @@ -249,6 +317,7 @@ pub fn query_user_for_initial_config_with_network( price_ticker_rest_url_kucoin: defaults.price_ticker_rest_url_kucoin, external_bitcoin_redeem_address: None, developer_tip, + refund_policy: defaults.refund_policy, }, }) } diff --git a/swap-env/src/defaults.rs b/swap-env/src/defaults.rs index 7c68b83479..2e41716799 100644 --- a/swap-env/src/defaults.rs +++ b/swap-env/src/defaults.rs @@ -1,3 +1,4 @@ +use crate::config::RefundPolicy; use crate::env::{Mainnet, Testnet}; use anyhow::{Context, Result}; use libp2p::Multiaddr; @@ -118,6 +119,7 @@ pub struct Defaults { pub bitcoin_confirmation_target: u16, pub use_mempool_space_fee_estimation: bool, pub developer_tip: Decimal, + pub refund_policy: RefundPolicy, } impl GetDefaults for Mainnet { @@ -135,6 +137,7 @@ impl GetDefaults for Mainnet { bitcoin_confirmation_target: 1, use_mempool_space_fee_estimation: true, developer_tip: Decimal::ZERO, + refund_policy: RefundPolicy::default(), }; Ok(defaults) @@ -156,6 +159,7 @@ impl GetDefaults for Testnet { bitcoin_confirmation_target: 1, use_mempool_space_fee_estimation: true, developer_tip: Decimal::ZERO, + refund_policy: RefundPolicy::default(), }; Ok(defaults) diff --git a/swap-env/src/env.rs b/swap-env/src/env.rs index 4e2a0618f3..dc984fbcaf 100644 --- a/swap-env/src/env.rs +++ b/swap-env/src/env.rs @@ -15,6 +15,7 @@ pub struct Config { pub bitcoin_avg_block_time: Duration, pub bitcoin_cancel_timelock: u32, pub bitcoin_punish_timelock: u32, + pub bitcoin_remaining_refund_timelock: u32, pub bitcoin_network: bitcoin::Network, pub monero_avg_block_time: Duration, pub monero_finality_confirmations: u64, @@ -61,6 +62,7 @@ impl GetConfig for Mainnet { bitcoin_avg_block_time: 10.std_minutes(), bitcoin_cancel_timelock: 72, bitcoin_punish_timelock: 144, + bitcoin_remaining_refund_timelock: 2, bitcoin_network: bitcoin::Network::Bitcoin, monero_avg_block_time: 2.std_minutes(), // If Alice cannot lock her Monero within this timeout, @@ -83,6 +85,7 @@ impl GetConfig for Testnet { bitcoin_avg_block_time: 10.std_minutes(), bitcoin_cancel_timelock: 12 * 3, bitcoin_punish_timelock: 24 * 3, + bitcoin_remaining_refund_timelock: 2, bitcoin_network: bitcoin::Network::Testnet, monero_avg_block_time: 2.std_minutes(), monero_lock_retry_timeout: 10.std_minutes(), @@ -103,6 +106,7 @@ impl GetConfig for Regtest { bitcoin_avg_block_time: 5.std_seconds(), bitcoin_cancel_timelock: 100, bitcoin_punish_timelock: 50, + bitcoin_remaining_refund_timelock: 5, bitcoin_network: bitcoin::Network::Regtest, monero_avg_block_time: 1.std_seconds(), monero_lock_retry_timeout: 1.std_minutes(), diff --git a/swap-machine/Cargo.toml b/swap-machine/Cargo.toml index e15b435a1a..9f4f70da2c 100644 --- a/swap-machine/Cargo.toml +++ b/swap-machine/Cargo.toml @@ -14,14 +14,21 @@ libp2p = { workspace = true } monero-address = { workspace = true } monero-oxide-ext = { path = "../monero-oxide-ext" } rand = { workspace = true } +rust_decimal = { workspace = true } rand_chacha = { workspace = true } serde = { workspace = true } sha2 = { workspace = true } -sigma_fun = { workspace = true, default-features = false, features = ["ed25519", "serde", "secp256k1", "alloc"] } +sigma_fun = { workspace = true, default-features = false, features = [ + "ed25519", + "serde", + "secp256k1", + "alloc", +] } swap-core = { path = "../swap-core" } swap-env = { path = "../swap-env" } swap-serde = { path = "../swap-serde" } thiserror = { workspace = true } +tracing = { workspace = true } uuid = { workspace = true, features = ["serde"] } [dev-dependencies] diff --git a/swap-machine/src/alice/mod.rs b/swap-machine/src/alice/mod.rs index 604af7cf9e..d9bf8c4f61 100644 --- a/swap-machine/src/alice/mod.rs +++ b/swap-machine/src/alice/mod.rs @@ -1,20 +1,21 @@ #![allow(non_snake_case)] -use crate::common::{Message0, Message1, Message2, Message3, Message4, CROSS_CURVE_PROOF_SYSTEM}; -use anyhow::{bail, Context, Result}; +use crate::common::{CROSS_CURVE_PROOF_SYSTEM, Message0, Message1, Message2, Message3, Message4}; +use anyhow::{Context, Result, bail}; use rand::{CryptoRng, RngCore}; use serde::{Deserialize, Serialize}; use sigma_fun::ext::dl_secp256k1_ed25519_eq::CrossCurveDLEQProof; -use std::fmt; +use std::fmt::{self, Debug}; use std::sync::Arc; use swap_core::bitcoin::{ - current_epoch, CancelTimelock, ExpiredTimelocks, PunishTimelock, Transaction, TxCancel, - TxEarlyRefund, TxPunish, TxRedeem, TxRefund, Txid, + CancelTimelock, ExpiredTimelocks, PunishTimelock, RemainingRefundTimelock, Transaction, + TxCancel, TxEarlyRefund, TxFullRefund, TxMercy, TxPartialRefund, TxPunish, TxReclaim, TxRedeem, + TxWithhold, Txid, current_epoch, }; use swap_core::compat::{IntoDalek, IntoDalekNg, IntoMoneroOxide}; -use swap_core::monero; -use swap_core::monero::primitives::{AmountExt, BlockHeight, TransferProof, TransferRequest}; use swap_core::monero::ScalarExt; +use swap_core::monero::primitives::{AmountExt, BlockHeight, TransferProof, TransferRequest}; +use swap_core::monero::{self, Scalar}; use swap_env::env::Config; use uuid::Uuid; @@ -64,18 +65,48 @@ pub enum AliceState { state3: Box, }, BtcEarlyRefunded(Box), + // We enter the refund states regardless of whether or not the refund + // transaction was confirmed because we do not care. We can extract the key + // we need to refund ourself regardless. BtcRefunded { monero_wallet_restore_blockheight: BlockHeight, transfer_proof: TransferProof, spend_key: monero_oxide_ext::PrivateKey, state3: Box, }, - BtcPunishable { + BtcPartiallyRefunded { + monero_wallet_restore_blockheight: BlockHeight, + transfer_proof: TransferProof, + spend_key: monero::PrivateKey, + state3: Box, + }, + XmrRefundable { monero_wallet_restore_blockheight: BlockHeight, transfer_proof: TransferProof, + spend_key: monero::PrivateKey, + state3: Box, + }, + // TODO: save redeem transaction id + XmrRefunded { + state3: Option>, + }, + BtcWithholdPublished { + state3: Box, + }, + BtcWithholdConfirmed { + state3: Box, + }, + /// Operator has decided to grant final amnesty to Bob. + /// This state will publish TxFinalAmnesty and transition to BtcRefundFinalAmnestyPublished. + BtcMercyGranted { + state3: Box, + }, + BtcMercyPublished { + state3: Box, + }, + BtcMercyConfirmed { state3: Box, }, - XmrRefunded, WaitingForCancelTimelockExpiration { monero_wallet_restore_blockheight: BlockHeight, transfer_proof: TransferProof, @@ -86,6 +117,11 @@ pub enum AliceState { transfer_proof: TransferProof, state3: Box, }, + BtcPunishable { + monero_wallet_restore_blockheight: BlockHeight, + transfer_proof: TransferProof, + state3: Box, + }, BtcPunished { state3: Box, transfer_proof: TransferProof, @@ -94,14 +130,20 @@ pub enum AliceState { } pub fn is_complete(state: &AliceState) -> bool { - matches!( - state, - AliceState::XmrRefunded - | AliceState::BtcRedeemed - | AliceState::BtcPunished { .. } - | AliceState::SafelyAborted - | AliceState::BtcEarlyRefunded(_) - ) + match state { + // XmrRefunded is only complete if we don't need to publish TxRefundBurn + AliceState::XmrRefunded { state3 } => match state3 { + Some(s3) if s3.should_publish_tx_refund_burn == Some(true) => false, + _ => true, + }, + AliceState::BtcRedeemed + | AliceState::BtcPunished { .. } + | AliceState::SafelyAborted + | AliceState::BtcEarlyRefunded(_) + | AliceState::BtcWithholdConfirmed { .. } + | AliceState::BtcMercyConfirmed { .. } => true, + _ => false, + } } impl fmt::Display for AliceState { @@ -127,13 +169,24 @@ impl fmt::Display for AliceState { AliceState::BtcPunished { .. } => write!(f, "btc is punished"), AliceState::SafelyAborted => write!(f, "safely aborted"), AliceState::BtcPunishable { .. } => write!(f, "btc is punishable"), - AliceState::XmrRefunded => write!(f, "xmr is refunded"), + AliceState::XmrRefunded { .. } => write!(f, "xmr is refunded"), + AliceState::BtcWithholdPublished { .. } => write!(f, "btc withhold published"), + AliceState::BtcWithholdConfirmed { .. } => write!(f, "btc withheld"), + AliceState::BtcMercyGranted { .. } => write!(f, "btc mercy granted"), + AliceState::BtcMercyPublished { .. } => { + write!(f, "btc mercy published") + } + AliceState::BtcMercyConfirmed { .. } => { + write!(f, "btc mercy confirmed") + } AliceState::WaitingForCancelTimelockExpiration { .. } => { write!(f, "waiting for cancel timelock expiration") } AliceState::CancelTimelockExpired { .. } => write!(f, "cancel timelock is expired"), AliceState::BtcEarlyRefundable { .. } => write!(f, "btc is early refundable"), AliceState::BtcEarlyRefunded(_) => write!(f, "btc is early refunded"), + AliceState::BtcPartiallyRefunded { .. } => write!(f, "btc is partially refunded"), + AliceState::XmrRefundable { .. } => write!(f, "xmr is refundable"), } } } @@ -149,12 +202,16 @@ pub struct State0 { dleq_proof_s_a: CrossCurveDLEQProof, btc: bitcoin::Amount, xmr: monero::Amount, + btc_amnesty_amount: Option, cancel_timelock: CancelTimelock, punish_timelock: PunishTimelock, + remaining_refund_timelock: Option, redeem_address: bitcoin::Address, punish_address: bitcoin::Address, tx_redeem_fee: bitcoin::Amount, tx_punish_fee: bitcoin::Amount, + tx_refund_burn_fee: Option, + should_publish_tx_refund_burn: Option, } impl State0 { @@ -162,11 +219,14 @@ impl State0 { pub fn new( btc: bitcoin::Amount, xmr: monero::Amount, + btc_amnesty_amount: bitcoin::Amount, env_config: Config, redeem_address: bitcoin::Address, punish_address: bitcoin::Address, tx_redeem_fee: bitcoin::Amount, tx_punish_fee: bitcoin::Amount, + tx_refund_burn_fee: bitcoin::Amount, + should_publish_tx_refund_burn: bool, rng: &mut R, ) -> Self where @@ -186,14 +246,18 @@ impl State0 { S_a_bitcoin: S_a_bitcoin.into(), S_a_monero: S_a_monero.compress().into(), dleq_proof_s_a, + btc_amnesty_amount: Some(btc_amnesty_amount), redeem_address, punish_address, btc, xmr, cancel_timelock: env_config.bitcoin_cancel_timelock.into(), punish_timelock: env_config.bitcoin_punish_timelock.into(), + remaining_refund_timelock: Some(env_config.bitcoin_remaining_refund_timelock.into()), tx_redeem_fee, tx_punish_fee, + tx_refund_burn_fee: Some(tx_refund_burn_fee), + should_publish_tx_refund_burn: Some(should_publish_tx_refund_burn), } } @@ -207,6 +271,22 @@ impl State0 { bail!("Bob's dleq proof doesn't verify") } + let amnesty_amount = self + .btc_amnesty_amount + .context("btc_amnesty_amount missing for new swap")?; + let tx_refund_burn_fee = self + .tx_refund_burn_fee + .context("tx_refund_burn_fee missing for new swap")?; + + crate::common::sanity_check_amnesty_amount( + self.btc, + amnesty_amount, + msg.tx_partial_refund_fee, + msg.tx_refund_amnesty_fee, + tx_refund_burn_fee, + msg.tx_final_amnesty_fee, + )?; + let v = self.v_a + msg.v_b; Ok(( @@ -224,15 +304,22 @@ impl State0 { dleq_proof_s_a: self.dleq_proof_s_a, btc: self.btc, xmr: self.xmr, + btc_amnesty_amount: self.btc_amnesty_amount, cancel_timelock: self.cancel_timelock, punish_timelock: self.punish_timelock, + remaining_refund_timelock: self.remaining_refund_timelock, refund_address: msg.refund_address, redeem_address: self.redeem_address, punish_address: self.punish_address, tx_redeem_fee: self.tx_redeem_fee, tx_punish_fee: self.tx_punish_fee, tx_refund_fee: msg.tx_refund_fee, + tx_partial_refund_fee: Some(msg.tx_partial_refund_fee), + tx_refund_amnesty_fee: Some(msg.tx_refund_amnesty_fee), + tx_refund_burn_fee: self.tx_refund_burn_fee, + tx_final_amnesty_fee: Some(msg.tx_final_amnesty_fee), tx_cancel_fee: msg.tx_cancel_fee, + should_publish_tx_refund_burn: self.should_publish_tx_refund_burn, }, )) } @@ -253,20 +340,27 @@ pub struct State1 { dleq_proof_s_a: CrossCurveDLEQProof, btc: bitcoin::Amount, xmr: monero::Amount, + btc_amnesty_amount: Option, cancel_timelock: CancelTimelock, punish_timelock: PunishTimelock, + remaining_refund_timelock: Option, refund_address: bitcoin::Address, redeem_address: bitcoin::Address, punish_address: bitcoin::Address, tx_redeem_fee: bitcoin::Amount, tx_punish_fee: bitcoin::Amount, tx_refund_fee: bitcoin::Amount, + tx_partial_refund_fee: Option, + tx_refund_amnesty_fee: Option, + tx_refund_burn_fee: Option, + tx_final_amnesty_fee: Option, tx_cancel_fee: bitcoin::Amount, + should_publish_tx_refund_burn: Option, } impl State1 { - pub fn next_message(&self) -> Message1 { - Message1 { + pub fn next_message(&self) -> Result { + Ok(Message1 { A: self.a.public(), S_a_monero: self.S_a_monero, S_a_bitcoin: self.S_a_bitcoin, @@ -276,13 +370,23 @@ impl State1 { punish_address: self.punish_address.clone(), tx_redeem_fee: self.tx_redeem_fee, tx_punish_fee: self.tx_punish_fee, - } + amnesty_amount: self + .btc_amnesty_amount + .context("Missing btc_amesty_amount for new swap that should have it")?, + tx_refund_burn_fee: self + .tx_refund_burn_fee + .context("Missing tx_refund_burn_fee for new swap that should have it")?, + }) } pub fn receive(self, msg: Message2) -> Result { - let tx_lock = - swap_core::bitcoin::TxLock::from_psbt(msg.psbt, self.a.public(), self.B, self.btc) - .context("Failed to re-construct TxLock from received PSBT")?; + let tx_lock = swap_core::bitcoin::TxLock::from_psbt( + msg.tx_lock_psbt, + self.a.public(), + self.B, + self.btc, + ) + .context("Failed to re-construct TxLock from received PSBT")?; Ok(State2 { a: self.a, @@ -293,8 +397,10 @@ impl State1 { v: self.v, btc: self.btc, xmr: self.xmr, + btc_amnesty_amount: self.btc_amnesty_amount, cancel_timelock: self.cancel_timelock, punish_timelock: self.punish_timelock, + remaining_refund_timelock: self.remaining_refund_timelock, refund_address: self.refund_address, redeem_address: self.redeem_address, punish_address: self.punish_address, @@ -302,7 +408,12 @@ impl State1 { tx_redeem_fee: self.tx_redeem_fee, tx_punish_fee: self.tx_punish_fee, tx_refund_fee: self.tx_refund_fee, + tx_partial_refund_fee: self.tx_partial_refund_fee, + tx_refund_amnesty_fee: self.tx_refund_amnesty_fee, + tx_refund_burn_fee: self.tx_refund_burn_fee, + tx_final_amnesty_fee: self.tx_final_amnesty_fee, tx_cancel_fee: self.tx_cancel_fee, + should_publish_tx_refund_burn: self.should_publish_tx_refund_burn, }) } } @@ -318,8 +429,10 @@ pub struct State2 { v: monero::PrivateViewKey, btc: bitcoin::Amount, xmr: monero::Amount, + btc_amnesty_amount: Option, cancel_timelock: CancelTimelock, punish_timelock: PunishTimelock, + remaining_refund_timelock: Option, refund_address: bitcoin::Address, redeem_address: bitcoin::Address, punish_address: bitcoin::Address, @@ -327,11 +440,16 @@ pub struct State2 { tx_redeem_fee: bitcoin::Amount, tx_punish_fee: bitcoin::Amount, tx_refund_fee: bitcoin::Amount, + tx_partial_refund_fee: Option, + tx_refund_amnesty_fee: Option, + tx_refund_burn_fee: Option, + tx_final_amnesty_fee: Option, tx_cancel_fee: bitcoin::Amount, + should_publish_tx_refund_burn: Option, } impl State2 { - pub fn next_message(&self) -> Message3 { + pub fn next_message(&self) -> Result { let tx_cancel = swap_core::bitcoin::TxCancel::new( &self.tx_lock, self.cancel_timelock, @@ -341,20 +459,57 @@ impl State2 { ) .expect("valid cancel tx"); - let tx_refund = - swap_core::bitcoin::TxRefund::new(&tx_cancel, &self.refund_address, self.tx_refund_fee); - // Alice encsigns the refund transaction(bitcoin) digest with Bob's monero - // pubkey(S_b). The refund transaction spends the output of - // tx_lock_bitcoin to Bob's refund address. + let tx_cancel_sig = self.a.sign(tx_cancel.digest()); + + // When the amnesty output is zero, we can't construct the tx partial refund transaction + // due to an integer underflow. + // We only send the cancel and full refund signatures. + if self.btc_amnesty_amount.unwrap_or(bitcoin::Amount::ZERO) == bitcoin::Amount::ZERO { + let tx_full_refund = + TxFullRefund::new(&tx_cancel, &self.refund_address, self.tx_refund_fee); + let tx_refund_encsig = self.a.encsign(self.S_b_bitcoin, tx_full_refund.digest()); + + return Ok(Message3 { + tx_cancel_sig, + tx_partial_refund_encsig: None, + tx_full_refund_encsig: Some(tx_refund_encsig), + tx_refund_amnesty_sig: None, + }); + } + + let tx_partial_refund = swap_core::bitcoin::TxPartialRefund::new( + &tx_cancel, + &self.refund_address, + self.a.public(), + self.B, + self.btc_amnesty_amount + .context("Missing btc_amnesty_amount for new swap that should have it")?, + self.tx_refund_fee, + )?; + // Alice encsigns the partial refund transaction(bitcoin) digest with Bob's monero + // pubkey(S_b). The partial refund transaction spends the output of + // tx_lock_bitcoin to Bob's refund address (except for the amnesty output). // recover(encsign(a, S_b, d), sign(a, d), S_b) = s_b where d is a digest, (a, // A) is alice's keypair and (s_b, S_b) is bob's keypair. - let tx_refund_encsig = self.a.encsign(self.S_b_bitcoin, tx_refund.digest()); + let tx_partial_refund_encsig = self.a.encsign(self.S_b_bitcoin, tx_partial_refund.digest()); - let tx_cancel_sig = self.a.sign(tx_cancel.digest()); - Message3 { + // Construct and sign TxRefundAmnesty + let tx_refund_amnesty = swap_core::bitcoin::TxReclaim::new( + &tx_partial_refund, + &self.refund_address, + self.tx_refund_amnesty_fee + .context("Missing tx_refund_amnesty_fee for new swap")?, + self.remaining_refund_timelock + .context("Missing remaining_refund_timelock for new swap")?, + )?; + let tx_refund_amnesty_sig = self.a.sign(tx_refund_amnesty.digest()); + + Ok(Message3 { tx_cancel_sig, - tx_refund_encsig, - } + tx_partial_refund_encsig: Some(tx_partial_refund_encsig), + tx_refund_amnesty_sig: Some(tx_refund_amnesty_sig), + tx_full_refund_encsig: None, + }) } pub fn receive(self, msg: Message4) -> Result { @@ -398,6 +553,108 @@ impl State2 { ) .context("Failed to verify early refund transaction")?; + // When the bitcoin amnesty amount is zero, we can't construct the transactions for the partial refund path. + // We sent Bob the encsig for the full refund path already, so we don't + // care about the partial refund path signatures of Bob anyway. + // We just save `None`. + if self.btc_amnesty_amount.unwrap_or(bitcoin::Amount::ZERO) == bitcoin::Amount::ZERO { + return Ok(State3 { + a: self.a, + B: self.B, + s_a: self.s_a, + S_b_monero: self.S_b_monero, + S_b_bitcoin: self.S_b_bitcoin, + v: self.v, + btc: self.btc, + xmr: self.xmr, + btc_amnesty_amount: self.btc_amnesty_amount, + cancel_timelock: self.cancel_timelock, + punish_timelock: self.punish_timelock, + remaining_refund_timelock: self.remaining_refund_timelock, + refund_address: self.refund_address, + redeem_address: self.redeem_address, + punish_address: self.punish_address, + tx_lock: self.tx_lock, + tx_punish_sig_bob: msg.tx_punish_sig, + tx_cancel_sig_bob: msg.tx_cancel_sig, + tx_early_refund_sig_bob: msg.tx_early_refund_sig.into(), + tx_refund_amnesty_sig_bob: None, + tx_redeem_fee: self.tx_redeem_fee, + tx_punish_fee: self.tx_punish_fee, + tx_refund_fee: self.tx_refund_fee, + tx_partial_refund_fee: self.tx_partial_refund_fee, + tx_refund_amnesty_fee: self.tx_refund_amnesty_fee, + tx_refund_burn_fee: self.tx_refund_burn_fee, + tx_final_amnesty_fee: self.tx_final_amnesty_fee, + tx_cancel_fee: self.tx_cancel_fee, + tx_refund_burn_sig_bob: None, + tx_final_amnesty_sig_bob: None, + should_publish_tx_refund_burn: self.should_publish_tx_refund_burn, + }); + } + + // Create TxRefundAmnesty ourself + let tx_partial_refund = TxPartialRefund::new( + &tx_cancel, + &self.refund_address, + self.a.public(), + self.B, + self.btc_amnesty_amount + .context("missing btc_amnesty_amount")?, + self.tx_partial_refund_fee + .context("missing tx_partial_refund_fee")?, + ) + .context("Couldn't construct TxPartialRefund")?; + let tx_refund_amnesty = TxReclaim::new( + &tx_partial_refund, + &self.refund_address, + self.tx_refund_amnesty_fee + .context("missing tx_refund_amnesty_fee")?, + self.remaining_refund_timelock + .context("missing remaining_refund_timelock")?, + )?; + + // Check if the provided signature by Bob is valid for the transaction + let tx_refund_amnesty_sig = msg + .tx_refund_amnesty_sig + .as_ref() + .context("Missing tx_refund_amnesty_sig from Bob")?; + swap_core::bitcoin::verify_sig(&self.B, &tx_refund_amnesty.digest(), tx_refund_amnesty_sig) + .context("Failed to verify refund amnesty transaction")?; + + // Create TxRefundBurn ourself + let tx_refund_burn = TxWithhold::new( + &tx_partial_refund, + self.a.public(), + self.B, + self.tx_refund_burn_fee + .context("missing tx_refund_burn_fee")?, + )?; + + // Check if the provided signature by Bob is valid for the transaction + let tx_refund_burn_sig = msg + .tx_refund_burn_sig + .as_ref() + .context("Missing tx_refund_burn_sig from Bob")?; + swap_core::bitcoin::verify_sig(&self.B, &tx_refund_burn.digest(), tx_refund_burn_sig) + .context("Failed to verify refund burn transaction")?; + + // Create TxFinalAmnesty ourself + let tx_final_amnesty = TxMercy::new( + &tx_refund_burn, + &self.refund_address, + self.tx_final_amnesty_fee + .context("missing tx_final_amnesty_fee")?, + ); + + // Check if the provided signature by Bob is valid for the transaction + let tx_final_amnesty_sig = msg + .tx_final_amnesty_sig + .as_ref() + .context("Missing tx_final_amnesty_sig from Bob")?; + swap_core::bitcoin::verify_sig(&self.B, &tx_final_amnesty.digest(), tx_final_amnesty_sig) + .context("Failed to verify final amnesty transaction")?; + Ok(State3 { a: self.a, B: self.B, @@ -407,8 +664,10 @@ impl State2 { v: self.v, btc: self.btc, xmr: self.xmr, + btc_amnesty_amount: self.btc_amnesty_amount, cancel_timelock: self.cancel_timelock, punish_timelock: self.punish_timelock, + remaining_refund_timelock: self.remaining_refund_timelock, refund_address: self.refund_address, redeem_address: self.redeem_address, punish_address: self.punish_address, @@ -416,10 +675,18 @@ impl State2 { tx_punish_sig_bob: msg.tx_punish_sig, tx_cancel_sig_bob: msg.tx_cancel_sig, tx_early_refund_sig_bob: msg.tx_early_refund_sig.into(), + tx_refund_amnesty_sig_bob: msg.tx_refund_amnesty_sig.into(), tx_redeem_fee: self.tx_redeem_fee, tx_punish_fee: self.tx_punish_fee, tx_refund_fee: self.tx_refund_fee, + tx_partial_refund_fee: self.tx_partial_refund_fee, + tx_refund_amnesty_fee: self.tx_refund_amnesty_fee, + tx_refund_burn_fee: self.tx_refund_burn_fee, + tx_final_amnesty_fee: self.tx_final_amnesty_fee, tx_cancel_fee: self.tx_cancel_fee, + tx_refund_burn_sig_bob: msg.tx_refund_burn_sig, + tx_final_amnesty_sig_bob: msg.tx_final_amnesty_sig, + should_publish_tx_refund_burn: self.should_publish_tx_refund_burn, }) } } @@ -434,11 +701,13 @@ pub struct State3 { S_b_monero: monero_oxide_ext::PublicKey, S_b_bitcoin: swap_core::bitcoin::PublicKey, pub v: monero::PrivateViewKey, - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub btc: bitcoin::Amount, pub xmr: monero::Amount, + pub btc_amnesty_amount: Option, pub cancel_timelock: CancelTimelock, pub punish_timelock: PunishTimelock, + #[serde(default)] + remaining_refund_timelock: Option, #[serde(with = "swap_serde::bitcoin::address_serde")] refund_address: bitcoin::Address, #[serde(with = "swap_serde::bitcoin::address_serde")] @@ -459,14 +728,34 @@ pub struct State3 { /// to wait for the timelock to expire. #[serde(default)] tx_early_refund_sig_bob: Option, - #[serde(with = "::bitcoin::amount::serde::as_sat")] + /// This field was added in PR [#675](https://github.com/eigenwallet/core/pull/344). + /// It is optional to maintain backwards compatibility with old swaps in the database. + /// Bob must send this to us during swap setup, in order for us to publish TxRefundAmnesty + /// in case of a refund. Otherwise Bob will only be partially refunded. + #[serde(default)] + tx_refund_amnesty_sig_bob: Option, tx_redeem_fee: bitcoin::Amount, - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub tx_punish_fee: bitcoin::Amount, - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub tx_refund_fee: bitcoin::Amount, - #[serde(with = "::bitcoin::amount::serde::as_sat")] + #[serde(default)] + pub tx_partial_refund_fee: Option, + #[serde(default)] + pub tx_refund_amnesty_fee: Option, + #[serde(default)] + pub tx_refund_burn_fee: Option, + #[serde(default)] + pub tx_final_amnesty_fee: Option, pub tx_cancel_fee: bitcoin::Amount, + #[serde(default)] + tx_refund_burn_sig_bob: Option, + #[serde(default)] + tx_final_amnesty_sig_bob: Option, + /// Whether Alice should publish TxRefundBurn to deny Bob's amnesty. + /// None = no decision yet (legacy swaps or awaiting controller input) + /// Some(false) = don't burn (default for new swaps) + /// Some(true) = burn the amnesty output + #[serde(default)] + pub should_publish_tx_refund_burn: Option, } impl State3 { @@ -478,12 +767,23 @@ impl State3 { let tx_lock_status = bitcoin_wallet.status_of_script(&self.tx_lock).await?; let tx_cancel_status = bitcoin_wallet.status_of_script(&tx_cancel).await?; + // Only check partial refund status if we have the data to construct it + // (old swaps won't have these fields) + let tx_partial_refund_status = + if let (Some(_), Some(_)) = (self.btc_amnesty_amount, self.tx_partial_refund_fee) { + let tx = self.tx_partial_refund()?; + Some(bitcoin_wallet.status_of_script(&tx).await?) + } else { + None + }; Ok(current_epoch( self.cancel_timelock, self.punish_timelock, + self.remaining_refund_timelock, tx_lock_status, tx_cancel_status, + tx_partial_refund_status, )) } @@ -513,14 +813,27 @@ impl State3 { .expect("valid cancel tx") } - pub fn tx_refund(&self) -> TxRefund { - swap_core::bitcoin::TxRefund::new( + pub fn tx_refund(&self) -> TxFullRefund { + swap_core::bitcoin::TxFullRefund::new( &self.tx_cancel(), &self.refund_address, self.tx_refund_fee, ) } + pub fn tx_partial_refund(&self) -> Result { + swap_core::bitcoin::TxPartialRefund::new( + &self.tx_cancel(), + &self.refund_address, + self.a.public(), + self.B, + self.btc_amnesty_amount + .context("Missing btc_amnesty_amount")?, + self.tx_partial_refund_fee + .context("Missing tx_partial_refund_fee")?, + ) + } + pub fn tx_redeem(&self) -> TxRedeem { TxRedeem::new(&self.tx_lock, &self.redeem_address, self.tx_redeem_fee) } @@ -533,7 +846,7 @@ impl State3 { ) } - pub fn extract_monero_private_key( + pub fn extract_monero_private_key_from_refund( &self, signed_refund_tx: Arc, ) -> Result { @@ -549,6 +862,20 @@ impl State3 { )) } + pub fn extract_monero_private_key_from_partial_refund( + &self, + signed_partial_refund_tx: Arc, + ) -> Result { + Ok(monero::PrivateKey::from_scalar(Scalar::from( + self.tx_partial_refund()?.extract_monero_private_key( + signed_partial_refund_tx, + self.s_a.into(), + self.a.clone(), + self.S_b_bitcoin, + )?, + ))) + } + pub async fn check_for_tx_cancel( &self, bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet, @@ -580,17 +907,93 @@ impl State3 { bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet, ) -> Result { let transaction = self.signed_cancel_transaction()?; - let (tx_id, _) = bitcoin_wallet.broadcast(transaction, "cancel").await?; + let (tx_id, _) = bitcoin_wallet + .ensure_broadcasted(transaction, "cancel") + .await?; Ok(tx_id) } + pub fn signed_bitcoin_amnesty_transaction(&self) -> Result { + let tx_partial_refund = self.tx_partial_refund()?; + let tx_amnesty = TxReclaim::new( + &tx_partial_refund, + &self.refund_address, + self.tx_refund_amnesty_fee + .context("Missing tx_refund_amnesty_fee")?, + self.remaining_refund_timelock + .context("Missing remaining_refund_timelock")?, + )?; + + tx_amnesty.complete_as_alice( + self.a.clone(), + self.B, + self.tx_refund_amnesty_sig_bob + .clone() + .context("missing Bob's signature for TxRefundAmnesty")?, + ) + } + + /// Check if we have Bob's signature for TxRefundBurn. + pub fn has_tx_refund_burn_sig(&self) -> bool { + self.tx_refund_burn_sig_bob.is_some() + } + + /// Construct TxRefundBurn from tx_partial_refund output. + pub fn tx_refund_burn(&self) -> Result { + TxWithhold::new( + &self.tx_partial_refund()?, + self.a.public(), + self.B, + self.tx_refund_burn_fee + .context("Missing tx_refund_burn_fee")?, + ) + } + + /// Construct signed TxRefundBurn using Alice's key and Bob's presigned signature. + pub fn signed_refund_burn_transaction(&self) -> Result { + let tx_refund_burn = self.tx_refund_burn()?; + + tx_refund_burn.complete_as_alice( + self.a.clone(), + self.B, + self.tx_refund_burn_sig_bob + .clone() + .context("missing Bob's signature for TxRefundBurn")?, + ) + } + + /// Construct TxFinalAmnesty from tx_refund_burn output. + pub fn tx_final_amnesty(&self) -> Result { + Ok(TxMercy::new( + &self.tx_refund_burn()?, + &self.refund_address, + self.tx_final_amnesty_fee + .context("Missing tx_final_amnesty_fee")?, + )) + } + + /// Construct signed TxFinalAmnesty using Alice's key and Bob's presigned signature. + pub fn signed_final_amnesty_transaction(&self) -> Result { + let tx_final_amnesty = self.tx_final_amnesty()?; + + tx_final_amnesty.complete_as_alice( + self.a.clone(), + self.B, + self.tx_final_amnesty_sig_bob + .clone() + .context("missing Bob's signature for TxFinalAmnesty")?, + ) + } + pub async fn punish_btc( &self, bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet, ) -> Result { let signed_tx_punish = self.signed_punish_transaction()?; - let (txid, subscription) = bitcoin_wallet.broadcast(signed_tx_punish, "punish").await?; + let (txid, subscription) = bitcoin_wallet + .ensure_broadcasted(signed_tx_punish, "punish") + .await?; subscription.wait_until_final().await?; Ok(txid) @@ -649,17 +1052,25 @@ impl State3 { let refund_tx = bitcoin_wallet .get_raw_transaction(self.tx_refund().txid()) .await?; + let partial_refund_tx = bitcoin_wallet + .get_raw_transaction(self.tx_partial_refund()?.txid()) + .await?; - match refund_tx { - Some(refund_tx) => { - let spend_key = self.extract_monero_private_key(refund_tx)?; + match (refund_tx, partial_refund_tx) { + (Some(refund_tx), _) => { + let spend_key = self.extract_monero_private_key_from_refund(refund_tx)?; + Ok(Some(spend_key)) + } + (_, Some(partial_refund_tx)) => { + let spend_key = + self.extract_monero_private_key_from_partial_refund(partial_refund_tx)?; Ok(Some(spend_key)) } - None => Ok(None), + (None, None) => Ok(None), } } - pub async fn watch_for_btc_tx_refund( + pub async fn watch_for_btc_tx_full_refund( &self, bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet, ) -> Result { @@ -676,6 +1087,24 @@ impl State3 { "Bitcoin refund transaction not found even though we saw it in the mempool previously", ) } + + pub async fn watch_for_btc_tx_partial_refund( + &self, + bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet, + ) -> Result { + let tx_refund_status = bitcoin_wallet + .subscribe_to(Box::new(self.tx_partial_refund()?)) + .await; + + tx_refund_status + .wait_until_seen() + .await + .context("Failed to monitor refund transaction")?; + + self.refund_btc(bitcoin_wallet).await?.context( + "Bitcoin refund transaction not found even though we saw it in the mempool previously", + ) + } } pub trait ReservesMonero { diff --git a/swap-machine/src/bob/mod.rs b/swap-machine/src/bob/mod.rs index b1c1bc1b4f..6f36f1e1c1 100644 --- a/swap-machine/src/bob/mod.rs +++ b/swap-machine/src/bob/mod.rs @@ -1,11 +1,11 @@ #![allow(non_snake_case)] -use crate::common::{Message0, Message1, Message2, Message3, Message4, CROSS_CURVE_PROOF_SYSTEM}; -use anyhow::{anyhow, bail, Context, Result}; +use crate::common::{CROSS_CURVE_PROOF_SYSTEM, Message0, Message1, Message2, Message3, Message4}; +use anyhow::{Context, Result, anyhow, bail}; use bitcoin_wallet::primitives::Subscription; +use ecdsa_fun::Signature; use ecdsa_fun::adaptor::{Adaptor, HashTranscript}; use ecdsa_fun::nonce::Deterministic; -use ecdsa_fun::Signature; use monero::BlockHeight; use rand::{CryptoRng, RngCore}; use serde::{Deserialize, Serialize}; @@ -14,8 +14,9 @@ use sigma_fun::ext::dl_secp256k1_ed25519_eq::CrossCurveDLEQProof; use std::fmt; use std::sync::Arc; use swap_core::bitcoin::{ - self, current_epoch, CancelTimelock, ExpiredTimelocks, PunishTimelock, Transaction, TxCancel, - TxLock, Txid, + self, CancelTimelock, ExpiredTimelocks, PunishTimelock, RemainingRefundTimelock, Transaction, + TxCancel, TxFullRefund, TxLock, TxMercy, TxPartialRefund, TxReclaim, TxWithhold, Txid, + current_epoch, }; use swap_core::compat::{IntoDalekNg, IntoMoneroOxide}; use swap_core::monero; @@ -26,7 +27,6 @@ use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub enum BobState { Started { - #[serde(with = "::bitcoin::amount::serde::as_sat")] btc_amount: bitcoin::Amount, tx_lock_fee: bitcoin::Amount, #[serde(with = "address_serde")] @@ -74,11 +74,30 @@ pub enum BobState { BtcCancelled(State6), BtcRefundPublished(State6), BtcEarlyRefundPublished(State6), + BtcPartialRefundPublished(State6), BtcRefunded(State6), BtcEarlyRefunded(State6), + BtcPartiallyRefunded(State6), + /// Waiting for RemainingRefundTimelock to expire after partial refund confirmed. + /// During this time, Alice may publish TxRefundBurn. + WaitingForReclaimTimelockExpiration(State6), + /// RemainingRefundTimelock has expired, we can now publish TxRefundAmnesty. + ReclaimTimelockExpired(State6), + /// Alice published TxRefundBurn before we could publish TxRefundAmnesty. + BtcWithholdPublished(State6), + /// TxRefundBurn has been confirmed. The amnesty output is now burnt. + BtcWithheld(State6), + BtcReclaimPublished(State6), + BtcReclaimConfirmed(State6), + /// Alice published TxFinalAmnesty (using our presigned signature) to refund us. + BtcMercyPublished(State6), + /// TxFinalAmnesty has been confirmed. We received the burnt funds back. + BtcMercyConfirmed(State6), XmrRedeemed { tx_lock_id: bitcoin::Txid, }, + /// If we do not refund within `PUNISH_TIMELOCK` blocks of either us or alice publishing + /// [TxCancel], then alice may punish us by forcibly redeeming the BTC. BtcPunished { state: State6, // TODO: This attribute is redundant and unused @@ -87,6 +106,52 @@ pub enum BobState { SafelyAborted, } +/// An enum abstracting over the different combination of +/// refund signatures Alice could have sent us. +/// Maintains backward compatibility with old swaps (which only had the full refund signature). +/// +/// # IMPORTANT +/// This enum must be `#[untagged]` and maintain the field names in order to be backwards compatible +/// with the database. +/// Changing any of that is a breaking change. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum RefundSignatures { + /// Alice has only signed the partial refund transaction (most cases). + /// Includes the amnesty signature which is always provided in new swaps. + Partial { + tx_partial_refund_encsig: bitcoin::EncryptedSignature, + tx_refund_amnesty_sig: bitcoin::Signature, + }, + /// Alice has signed both the partial and full refund transactions. + /// Includes the amnesty signature which is always provided in new swaps. + Full { + tx_partial_refund_encsig: bitcoin::EncryptedSignature, + // Serde rename keeps + untagged + flatten keeps this backwards compatible with old swaps in the database. + #[serde(rename = "tx_refund_encsig")] + tx_full_refund_encsig: bitcoin::EncryptedSignature, + tx_refund_amnesty_sig: bitcoin::Signature, + }, + /// Alice has only signed the full refund transaction. + /// This is only used to maintain backwards compatibility for older swaps + /// from before the partial refund protocol change. + /// See [#675](https://github.com/eigenwallet/core/pull/675). + Legacy { + // Serde rename keeps + untagged + flatten keeps this backwards compatible with old swaps in the database. + #[serde(rename = "tx_refund_encsig")] + tx_full_refund_encsig: bitcoin::EncryptedSignature, + }, +} + +/// Either a full refund or a partial refund +pub enum RefundType { + Full, + Partial { + total_swap_amount: bitcoin::Amount, + btc_amnesty_amount: bitcoin::Amount, + }, +} + impl fmt::Display for BobState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -110,15 +175,44 @@ impl fmt::Display for BobState { BobState::BtcCancelled(..) => write!(f, "btc is cancelled"), BobState::BtcRefundPublished { .. } => write!(f, "btc refund is published"), BobState::BtcEarlyRefundPublished { .. } => write!(f, "btc early refund is published"), + BobState::BtcPartialRefundPublished { .. } => { + write!(f, "btc partial refund is published") + } BobState::BtcRefunded(..) => write!(f, "btc is refunded"), BobState::XmrRedeemed { .. } => write!(f, "xmr is redeemed"), BobState::BtcPunished { .. } => write!(f, "btc is punished"), BobState::BtcEarlyRefunded { .. } => write!(f, "btc is early refunded"), + BobState::BtcPartiallyRefunded { .. } => write!(f, "btc is partially refunded"), + BobState::BtcReclaimPublished { .. } => write!(f, "btc amnesty is published"), + BobState::BtcReclaimConfirmed { .. } => write!(f, "btc amnesty is confirmed"), + BobState::WaitingForReclaimTimelockExpiration { .. } => { + write!(f, "waiting for remaining refund timelock to expire") + } + BobState::ReclaimTimelockExpired { .. } => { + write!(f, "remaining refund timelock expired") + } + BobState::BtcWithholdPublished { .. } => write!(f, "btc refund burn is published"), + BobState::BtcWithheld { .. } => write!(f, "btc refund is burnt"), + BobState::BtcMercyPublished { .. } => { + write!(f, "btc final amnesty is published") + } + BobState::BtcMercyConfirmed { .. } => { + write!(f, "btc final amnesty is confirmed") + } BobState::SafelyAborted => write!(f, "safely aborted"), } } } +impl fmt::Display for RefundType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RefundType::Full => write!(f, "full btc refund"), + RefundType::Partial { .. } => write!(f, "partial btc refund"), + } + } +} + impl BobState { /// Fetch the expired timelocks for the swap. /// Depending on the State, there are no locks to expire. @@ -145,10 +239,20 @@ impl BobState { BobState::CancelTimelockExpired(state) | BobState::BtcCancelled(state) | BobState::BtcRefundPublished(state) - | BobState::BtcEarlyRefundPublished(state) => { + | BobState::BtcEarlyRefundPublished(state) + | BobState::BtcPartialRefundPublished(state) + | BobState::BtcPartiallyRefunded(state) + | BobState::BtcReclaimPublished(state) + | BobState::BtcReclaimConfirmed(state) + | BobState::WaitingForReclaimTimelockExpiration(state) + | BobState::ReclaimTimelockExpired(state) + | BobState::BtcWithholdPublished(state) + | BobState::BtcWithheld(state) + | BobState::BtcMercyPublished(state) => { Some(state.expired_timelock(bitcoin_wallet.as_ref()).await?) } BobState::BtcPunished { .. } => Some(ExpiredTimelocks::Punish), + BobState::BtcMercyConfirmed(_) => Some(ExpiredTimelocks::RemainingRefund), BobState::BtcRefunded(_) | BobState::BtcEarlyRefunded { .. } | BobState::BtcRedeemed(_) @@ -162,6 +266,8 @@ pub fn is_complete(state: &BobState) -> bool { state, BobState::BtcRefunded(..) | BobState::BtcEarlyRefunded { .. } + | BobState::BtcReclaimConfirmed { .. } + | BobState::BtcMercyConfirmed { .. } | BobState::XmrRedeemed { .. } | BobState::SafelyAborted ) @@ -181,8 +287,12 @@ pub struct State0 { xmr: monero::Amount, cancel_timelock: CancelTimelock, punish_timelock: PunishTimelock, + remaining_refund_timelock: Option, refund_address: bitcoin::Address, min_monero_confirmations: u64, + tx_partial_refund_fee: Option, + tx_refund_amnesty_fee: Option, + tx_final_amnesty_fee: Option, tx_refund_fee: bitcoin::Amount, tx_cancel_fee: bitcoin::Amount, tx_lock_fee: bitcoin::Amount, @@ -197,8 +307,12 @@ impl State0 { xmr: monero::Amount, cancel_timelock: CancelTimelock, punish_timelock: PunishTimelock, + remaining_refund_timelock: RemainingRefundTimelock, refund_address: bitcoin::Address, min_monero_confirmations: u64, + tx_partial_refund_fee: bitcoin::Amount, + tx_refund_amnesty_fee: bitcoin::Amount, + tx_final_amnesty_fee: bitcoin::Amount, tx_refund_fee: bitcoin::Amount, tx_cancel_fee: bitcoin::Amount, tx_lock_fee: bitcoin::Amount, @@ -223,16 +337,20 @@ impl State0 { dleq_proof_s_b, cancel_timelock, punish_timelock, + remaining_refund_timelock: Some(remaining_refund_timelock), refund_address, min_monero_confirmations, + tx_partial_refund_fee: Some(tx_partial_refund_fee), + tx_refund_amnesty_fee: Some(tx_refund_amnesty_fee), + tx_final_amnesty_fee: Some(tx_final_amnesty_fee), tx_refund_fee, tx_cancel_fee, tx_lock_fee, } } - pub fn next_message(&self) -> Message0 { - Message0 { + pub fn next_message(&self) -> Result { + Ok(Message0 { swap_id: self.swap_id, B: self.b.public(), S_b_monero: self.S_b_monero, @@ -241,8 +359,17 @@ impl State0 { v_b: self.v_b, refund_address: self.refund_address.clone(), tx_refund_fee: self.tx_refund_fee, + tx_partial_refund_fee: self + .tx_partial_refund_fee + .context("tx_partial_refund_fee missing but required to setup swap")?, + tx_refund_amnesty_fee: self + .tx_refund_amnesty_fee + .context("tx_refund_amnesty_fee missing but required to setup swap")?, + tx_final_amnesty_fee: self + .tx_final_amnesty_fee + .context("tx_final_amnesty_fee missing but required to setup swap")?, tx_cancel_fee: self.tx_cancel_fee, - } + }) } pub async fn receive( @@ -259,6 +386,27 @@ impl State0 { bail!("Alice's dleq proof doesn't verify") } + { + let tx_partial_refund_fee = self + .tx_partial_refund_fee + .context("tx_partial_refund_fee missing for new swap")?; + let tx_refund_amnesty_fee = self + .tx_refund_amnesty_fee + .context("tx_refund_amnesty_fee missing for new swap")?; + let tx_final_amnesty_fee = self + .tx_final_amnesty_fee + .context("tx_final_amnesty_fee missing for new swap")?; + + crate::common::sanity_check_amnesty_amount( + self.btc, + msg.amnesty_amount, + tx_partial_refund_fee, + tx_refund_amnesty_fee, + msg.tx_refund_burn_fee, + tx_final_amnesty_fee, + )?; + } + let tx_lock = swap_core::bitcoin::TxLock::new( wallet, self.btc, @@ -278,8 +426,10 @@ impl State0 { S_a_bitcoin: msg.S_a_bitcoin, v, xmr: self.xmr, + btc_amnesty_amount: Some(msg.amnesty_amount), cancel_timelock: self.cancel_timelock, punish_timelock: self.punish_timelock, + remaining_refund_timelock: self.remaining_refund_timelock, refund_address: self.refund_address, redeem_address: msg.redeem_address, punish_address: msg.punish_address, @@ -287,6 +437,10 @@ impl State0 { min_monero_confirmations: self.min_monero_confirmations, tx_redeem_fee: msg.tx_redeem_fee, tx_refund_fee: self.tx_refund_fee, + tx_partial_refund_fee: self.tx_partial_refund_fee, + tx_refund_amnesty_fee: self.tx_refund_amnesty_fee, + tx_refund_burn_fee: Some(msg.tx_refund_burn_fee), + tx_final_amnesty_fee: self.tx_final_amnesty_fee, tx_punish_fee: msg.tx_punish_fee, tx_cancel_fee: self.tx_cancel_fee, }) @@ -303,13 +457,19 @@ pub struct State1 { S_a_bitcoin: bitcoin::PublicKey, v: monero::PrivateViewKey, xmr: monero::Amount, + btc_amnesty_amount: Option, cancel_timelock: CancelTimelock, punish_timelock: PunishTimelock, + remaining_refund_timelock: Option, refund_address: bitcoin::Address, redeem_address: bitcoin::Address, punish_address: bitcoin::Address, tx_lock: bitcoin::TxLock, min_monero_confirmations: u64, + tx_partial_refund_fee: Option, + tx_refund_amnesty_fee: Option, + tx_refund_burn_fee: Option, + tx_final_amnesty_fee: Option, tx_redeem_fee: bitcoin::Amount, tx_refund_fee: bitcoin::Amount, tx_punish_fee: bitcoin::Amount, @@ -319,7 +479,7 @@ pub struct State1 { impl State1 { pub fn next_message(&self) -> Message2 { Message2 { - psbt: self.tx_lock.clone().into(), + tx_lock_psbt: self.tx_lock.clone().into(), } } @@ -332,16 +492,66 @@ impl State1 { self.tx_cancel_fee, )?; - let tx_refund = - bitcoin::TxRefund::new(&tx_cancel, &self.refund_address, self.tx_refund_fee); - - bitcoin::verify_sig(&self.A, &tx_cancel.digest(), &msg.tx_cancel_sig)?; - bitcoin::verify_encsig( - self.A, - bitcoin::PublicKey::from(self.s_b.to_secpfun_scalar()), - &tx_refund.digest(), - &msg.tx_refund_encsig, - )?; + // Depending on which signatures we get, verify and store them + let refund_signatures = match ( + msg.tx_full_refund_encsig, + msg.tx_partial_refund_encsig, + msg.tx_refund_amnesty_sig, + ) { + // We got the encrypted signature for the full refund - awesome + (Some(tx_full_refund_encsig), _, _) => { + let tx_full_refund = + TxFullRefund::new(&tx_cancel, &self.refund_address, self.tx_refund_fee); + bitcoin::verify_encsig( + self.A, + bitcoin::PublicKey::from(self.s_b.to_secpfun_scalar()), + &tx_full_refund.digest(), + &tx_full_refund_encsig, + ) + .context("Couldn't verify Alice's signature on TxFullRefund")?; + + RefundSignatures::Legacy { + tx_full_refund_encsig, + } + } + // We got the encrypted signatures for the partial refund path. + (None, Some(tx_partial_refund_encsig), Some(tx_refund_amnesty_sig)) => { + let tx_partial_refund = TxPartialRefund::new( + &tx_cancel, + &self.refund_address, + self.A, + self.b.public(), + self.btc_amnesty_amount + .context("Missing btc_amnesty_amount")?, + self.tx_partial_refund_fee + .context("Missing tx_partial_refund_fee")?, + )?; + bitcoin::verify_encsig( + self.A, + bitcoin::PublicKey::from(self.s_b.to_secpfun_scalar()), + &tx_partial_refund.digest(), + &tx_partial_refund_encsig, + )?; + + let tx_refund_amnesty = TxReclaim::new( + &tx_partial_refund, + &self.refund_address, + self.tx_refund_amnesty_fee + .context("missing tx_refund_amnesty_fee")?, + self.remaining_refund_timelock + .context("missing remaining_refund_timelock")?, + )?; + bitcoin::verify_sig(&self.A, &tx_refund_amnesty.digest(), &tx_refund_amnesty_sig)?; + + RefundSignatures::Partial { + tx_partial_refund_encsig, + tx_refund_amnesty_sig, + } + } + (_, _, _) => anyhow::bail!( + "Alice sent us neither TxFullRefund encsig nor signatures for the partial refund path" + ), + }; Ok(State2 { A: self.A, @@ -351,17 +561,23 @@ impl State1 { S_a_bitcoin: self.S_a_bitcoin, v: self.v, xmr: self.xmr, + btc_amnesty_amount: self.btc_amnesty_amount, cancel_timelock: self.cancel_timelock, punish_timelock: self.punish_timelock, + remaining_refund_timelock: self.remaining_refund_timelock, refund_address: self.refund_address, redeem_address: self.redeem_address, punish_address: self.punish_address, tx_lock: self.tx_lock, tx_cancel_sig_a: msg.tx_cancel_sig, - tx_refund_encsig: msg.tx_refund_encsig, + refund_signatures, min_monero_confirmations: self.min_monero_confirmations, tx_redeem_fee: self.tx_redeem_fee, tx_refund_fee: self.tx_refund_fee, + tx_partial_refund_fee: self.tx_partial_refund_fee, + tx_refund_amnesty_fee: self.tx_refund_amnesty_fee, + tx_refund_burn_fee: self.tx_refund_burn_fee, + tx_final_amnesty_fee: self.tx_final_amnesty_fee, tx_punish_fee: self.tx_punish_fee, tx_cancel_fee: self.tx_cancel_fee, }) @@ -379,8 +595,10 @@ pub struct State2 { S_a_bitcoin: bitcoin::PublicKey, v: monero::PrivateViewKey, pub xmr: monero::Amount, + pub btc_amnesty_amount: Option, pub cancel_timelock: CancelTimelock, pub punish_timelock: PunishTimelock, + remaining_refund_timelock: Option, #[serde(with = "address_serde")] pub refund_address: bitcoin::Address, #[serde(with = "address_serde")] @@ -389,20 +607,23 @@ pub struct State2 { punish_address: bitcoin::Address, pub tx_lock: bitcoin::TxLock, tx_cancel_sig_a: Signature, - tx_refund_encsig: bitcoin::EncryptedSignature, + /// This field was changed in [#675](https://github.com/eigenwallet/core/pull/675). + /// It boils down to the same json except that it now may also contain a partial refund signature. + #[serde(flatten)] + pub refund_signatures: RefundSignatures, min_monero_confirmations: u64, - #[serde(with = "::bitcoin::amount::serde::as_sat")] tx_redeem_fee: bitcoin::Amount, - #[serde(with = "::bitcoin::amount::serde::as_sat")] tx_punish_fee: bitcoin::Amount, - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub tx_refund_fee: bitcoin::Amount, - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub tx_cancel_fee: bitcoin::Amount, + tx_partial_refund_fee: Option, + tx_refund_amnesty_fee: Option, + tx_refund_burn_fee: Option, + tx_final_amnesty_fee: Option, } impl State2 { - pub fn next_message(&self) -> Message4 { + pub fn next_message(&self) -> Result { let tx_cancel = TxCancel::new( &self.tx_lock, self.cancel_timelock, @@ -427,11 +648,66 @@ impl State2 { let tx_early_refund_sig = self.b.sign(tx_early_refund.digest()); - Message4 { + // We can only construct a valid TxRefundAmnesty/TxRefundBurn/TxFinalAmnesty when the amnesty amount + // is greater than zero. Thus we only send our signatures for them if that is the case. + // Alice accepts this because she sent us her signature for TxFullRefund already anyway. + let (tx_refund_amnesty_sig, tx_refund_burn_sig, tx_final_amnesty_sig) = + if self.btc_amnesty_amount.unwrap_or(bitcoin::Amount::ZERO) == bitcoin::Amount::ZERO { + (None, None, None) + } else { + let tx_partial_refund = TxPartialRefund::new( + &tx_cancel, + &self.refund_address, + self.A, + self.b.public(), + self.btc_amnesty_amount + .context("missing btc_amnesty_amount")?, + self.tx_partial_refund_fee + .context("missing tx_partial_refund_fee")?, + ) + .context("Couldn't construct TxPartialRefund")?; + let tx_refund_amnesty = TxReclaim::new( + &tx_partial_refund, + &self.refund_address, + self.tx_refund_amnesty_fee + .context("Missing tx_refund_amnesty_fee")?, + self.remaining_refund_timelock + .context("missing remaining_refund_timelock")?, + )?; + let tx_refund_amnesty_sig = self.b.sign(tx_refund_amnesty.digest()); + + let tx_refund_burn = TxWithhold::new( + &tx_partial_refund, + self.A, + self.b.public(), + self.tx_refund_burn_fee + .context("Missing tx_refund_burn_fee")?, + )?; + let tx_refund_burn_sig = self.b.sign(tx_refund_burn.digest()); + + let tx_final_amnesty = TxMercy::new( + &tx_refund_burn, + &self.refund_address, + self.tx_final_amnesty_fee + .context("Missing tx_final_amnesty_fee")?, + ); + let tx_final_amnesty_sig = self.b.sign(tx_final_amnesty.digest()); + + ( + Some(tx_refund_amnesty_sig), + Some(tx_refund_burn_sig), + Some(tx_final_amnesty_sig), + ) + }; + + Ok(Message4 { tx_punish_sig, tx_cancel_sig, tx_early_refund_sig, - } + tx_refund_amnesty_sig, + tx_refund_burn_sig, + tx_final_amnesty_sig, + }) } pub async fn lock_btc(self) -> Result<(State3, TxLock)> { @@ -444,16 +720,22 @@ impl State2 { S_a_bitcoin: self.S_a_bitcoin, v: self.v, xmr: self.xmr, + btc_amnesty_amount: self.btc_amnesty_amount, cancel_timelock: self.cancel_timelock, punish_timelock: self.punish_timelock, + remaining_refund_timelock: self.remaining_refund_timelock, refund_address: self.refund_address, redeem_address: self.redeem_address, tx_lock: self.tx_lock.clone(), tx_cancel_sig_a: self.tx_cancel_sig_a, - tx_refund_encsig: self.tx_refund_encsig, + refund_signatures: self.refund_signatures, min_monero_confirmations: self.min_monero_confirmations, tx_redeem_fee: self.tx_redeem_fee, tx_refund_fee: self.tx_refund_fee, + tx_partial_refund_fee: self.tx_partial_refund_fee, + tx_refund_amnesty_fee: self.tx_refund_amnesty_fee, + tx_refund_burn_fee: self.tx_refund_burn_fee, + tx_final_amnesty_fee: self.tx_final_amnesty_fee, tx_cancel_fee: self.tx_cancel_fee, }, self.tx_lock, @@ -472,21 +754,29 @@ pub struct State3 { S_a_bitcoin: bitcoin::PublicKey, v: monero::PrivateViewKey, xmr: monero::Amount, + btc_amnesty_amount: Option, pub cancel_timelock: CancelTimelock, punish_timelock: PunishTimelock, + remaining_refund_timelock: Option, #[serde(with = "address_serde")] refund_address: bitcoin::Address, #[serde(with = "address_serde")] redeem_address: bitcoin::Address, pub tx_lock: bitcoin::TxLock, tx_cancel_sig_a: Signature, - tx_refund_encsig: bitcoin::EncryptedSignature, + /// The (encrypted) signatures Alice sent us for the Bitcoin refund transaction(s). + /// + /// This field was changed in [#675](https://github.com/eigenwallet/core/pull/675). + /// It boils down to the same json except that it now may also contain a partial refund signature. + #[serde(flatten)] + pub refund_signatures: RefundSignatures, min_monero_confirmations: u64, - #[serde(with = "::bitcoin::amount::serde::as_sat")] tx_redeem_fee: bitcoin::Amount, - #[serde(with = "::bitcoin::amount::serde::as_sat")] tx_refund_fee: bitcoin::Amount, - #[serde(with = "::bitcoin::amount::serde::as_sat")] + tx_partial_refund_fee: Option, + tx_refund_amnesty_fee: Option, + tx_refund_burn_fee: Option, + tx_final_amnesty_fee: Option, tx_cancel_fee: bitcoin::Amount, } @@ -516,18 +806,24 @@ impl State3 { S_a_bitcoin: self.S_a_bitcoin, v: self.v, xmr: self.xmr, + btc_amnesty_amount: self.btc_amnesty_amount, cancel_timelock: self.cancel_timelock, punish_timelock: self.punish_timelock, + remaining_refund_timelock: self.remaining_refund_timelock, refund_address: self.refund_address, redeem_address: self.redeem_address, tx_lock: self.tx_lock, tx_cancel_sig_a: self.tx_cancel_sig_a, - tx_refund_encsig: self.tx_refund_encsig, + refund_signatures: self.refund_signatures, monero_wallet_restore_blockheight, lock_transfer_proof, tx_redeem_fee: self.tx_redeem_fee, tx_refund_fee: self.tx_refund_fee, tx_cancel_fee: self.tx_cancel_fee, + tx_partial_refund_fee: self.tx_partial_refund_fee, + tx_refund_amnesty_fee: self.tx_refund_amnesty_fee, + tx_refund_burn_fee: self.tx_refund_burn_fee, + tx_final_amnesty_fee: self.tx_final_amnesty_fee, } } @@ -540,13 +836,19 @@ impl State3 { monero_wallet_restore_blockheight, cancel_timelock: self.cancel_timelock, punish_timelock: self.punish_timelock, + remaining_refund_timelock: self.remaining_refund_timelock, refund_address: self.refund_address.clone(), tx_lock: self.tx_lock.clone(), tx_cancel_sig_a: self.tx_cancel_sig_a.clone(), - tx_refund_encsig: self.tx_refund_encsig.clone(), + refund_signatures: self.refund_signatures.clone(), tx_refund_fee: self.tx_refund_fee, tx_cancel_fee: self.tx_cancel_fee, + tx_partial_refund_fee: self.tx_partial_refund_fee, + tx_refund_amnesty_fee: self.tx_refund_amnesty_fee, + tx_refund_burn_fee: self.tx_refund_burn_fee, + tx_final_amnesty_fee: self.tx_final_amnesty_fee, xmr: self.xmr, + btc_amnesty_amount: self.btc_amnesty_amount, } } @@ -568,12 +870,30 @@ impl State3 { let tx_lock_status = bitcoin_wallet.status_of_script(&self.tx_lock).await?; let tx_cancel_status = bitcoin_wallet.status_of_script(&tx_cancel).await?; + let tx_partial_refund_status = + if let (Some(btc_amnesty_amount), Some(tx_partial_refund_fee)) = + (self.btc_amnesty_amount, self.tx_partial_refund_fee) + { + let tx = TxPartialRefund::new( + &tx_cancel, + &self.refund_address, + self.A, + self.b.public(), + btc_amnesty_amount, + tx_partial_refund_fee, + )?; + Some(bitcoin_wallet.status_of_script(&tx).await?) + } else { + None + }; Ok(current_epoch( self.cancel_timelock, self.punish_timelock, + self.remaining_refund_timelock, tx_lock_status, tx_cancel_status, + tx_partial_refund_status, )) } @@ -615,22 +935,29 @@ pub struct State4 { S_a_bitcoin: bitcoin::PublicKey, v: monero::PrivateViewKey, xmr: monero::Amount, + btc_amnesty_amount: Option, pub cancel_timelock: CancelTimelock, punish_timelock: PunishTimelock, + remaining_refund_timelock: Option, #[serde(with = "address_serde")] refund_address: bitcoin::Address, #[serde(with = "address_serde")] redeem_address: bitcoin::Address, pub tx_lock: bitcoin::TxLock, tx_cancel_sig_a: Signature, - tx_refund_encsig: bitcoin::EncryptedSignature, + /// This field was changed in [#675](https://github.com/eigenwallet/core/pull/675). + /// It boils down to the same json except that it now may also contain a partial refund signature. + #[serde(flatten)] + refund_signatures: RefundSignatures, monero_wallet_restore_blockheight: BlockHeight, lock_transfer_proof: TransferProofMaybeWithTxKey, #[serde(with = "::bitcoin::amount::serde::as_sat")] tx_redeem_fee: bitcoin::Amount, - #[serde(with = "::bitcoin::amount::serde::as_sat")] tx_refund_fee: bitcoin::Amount, - #[serde(with = "::bitcoin::amount::serde::as_sat")] + tx_partial_refund_fee: Option, + tx_refund_amnesty_fee: Option, + tx_refund_burn_fee: Option, + tx_final_amnesty_fee: Option, tx_cancel_fee: bitcoin::Amount, } @@ -658,6 +985,7 @@ impl State4 { s_b: self.s_b, v: self.v, xmr: self.xmr, + btc_amnesty_amount: self.btc_amnesty_amount, tx_lock: self.tx_lock.clone(), monero_wallet_restore_blockheight: self.monero_wallet_restore_blockheight, lock_transfer_proof: self.lock_transfer_proof.clone(), @@ -707,12 +1035,30 @@ impl State4 { let tx_lock_status = bitcoin_wallet.status_of_script(&self.tx_lock).await?; let tx_cancel_status = bitcoin_wallet.status_of_script(&tx_cancel).await?; + let tx_partial_refund_status = + if let (Some(btc_amnesty_amount), Some(tx_partial_refund_fee)) = + (self.btc_amnesty_amount, self.tx_partial_refund_fee) + { + let tx = TxPartialRefund::new( + &tx_cancel, + &self.refund_address, + self.A, + self.b.public(), + btc_amnesty_amount, + tx_partial_refund_fee, + )?; + Some(bitcoin_wallet.status_of_script(&tx).await?) + } else { + None + }; Ok(current_epoch( self.cancel_timelock, self.punish_timelock, + self.remaining_refund_timelock, tx_lock_status, tx_cancel_status, + tx_partial_refund_status, )) } @@ -725,13 +1071,19 @@ impl State4 { monero_wallet_restore_blockheight: self.monero_wallet_restore_blockheight, cancel_timelock: self.cancel_timelock, punish_timelock: self.punish_timelock, + remaining_refund_timelock: self.remaining_refund_timelock, refund_address: self.refund_address, tx_lock: self.tx_lock, tx_cancel_sig_a: self.tx_cancel_sig_a, - tx_refund_encsig: self.tx_refund_encsig, + refund_signatures: self.refund_signatures, tx_refund_fee: self.tx_refund_fee, tx_cancel_fee: self.tx_cancel_fee, xmr: self.xmr, + btc_amnesty_amount: self.btc_amnesty_amount, + tx_partial_refund_fee: self.tx_partial_refund_fee, + tx_refund_amnesty_fee: self.tx_refund_amnesty_fee, + tx_refund_burn_fee: self.tx_refund_burn_fee, + tx_final_amnesty_fee: self.tx_final_amnesty_fee, } } @@ -748,6 +1100,7 @@ pub struct State5 { s_b: monero::Scalar, v: monero::PrivateViewKey, xmr: monero::Amount, + btc_amnesty_amount: Option, tx_lock: bitcoin::TxLock, pub monero_wallet_restore_blockheight: BlockHeight, pub lock_transfer_proof: TransferProofMaybeWithTxKey, @@ -775,18 +1128,27 @@ pub struct State6 { s_b: monero::Scalar, v: monero::PrivateViewKey, pub xmr: monero::Amount, + /// How much of the locked Bitcoin will stay locked in case of a partial refund. + /// May still be retrieve by publishing the `TxAmnesty` transaction. + pub btc_amnesty_amount: Option, pub monero_wallet_restore_blockheight: BlockHeight, pub cancel_timelock: CancelTimelock, punish_timelock: PunishTimelock, + pub remaining_refund_timelock: Option, #[serde(with = "address_serde")] refund_address: bitcoin::Address, pub tx_lock: bitcoin::TxLock, tx_cancel_sig_a: Signature, - tx_refund_encsig: bitcoin::EncryptedSignature, - #[serde(with = "::bitcoin::amount::serde::as_sat")] + /// This field was changed in [#675](https://github.com/eigenwallet/core/pull/675). + /// It boils down to the same json except that it now may also contain a partial refund signature. + #[serde(flatten)] + pub refund_signatures: RefundSignatures, pub tx_refund_fee: bitcoin::Amount, - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub tx_cancel_fee: bitcoin::Amount, + pub tx_partial_refund_fee: Option, + pub tx_refund_amnesty_fee: Option, + pub tx_refund_burn_fee: Option, + pub tx_final_amnesty_fee: Option, } impl State6 { @@ -804,12 +1166,23 @@ impl State6 { let tx_lock_status = bitcoin_wallet.status_of_script(&self.tx_lock).await?; let tx_cancel_status = bitcoin_wallet.status_of_script(&tx_cancel).await?; + // Only check partial refund status if we have the data to construct it + // (old swaps won't have these fields) + let tx_partial_refund_status = + if let (Some(_), Some(_)) = (self.btc_amnesty_amount, self.tx_partial_refund_fee) { + let tx = self.construct_tx_partial_refund()?; + Some(bitcoin_wallet.status_of_script(&tx).await?) + } else { + None + }; Ok(current_epoch( self.cancel_timelock, self.punish_timelock, + self.remaining_refund_timelock, tx_lock_status, tx_cancel_status, + tx_partial_refund_status, )) } @@ -846,39 +1219,69 @@ impl State6 { .complete_as_bob(self.A, self.b.clone(), self.tx_cancel_sig_a.clone()) .context("Failed to complete Bitcoin cancel transaction")?; - let (tx_id, subscription) = bitcoin_wallet.broadcast(transaction, "cancel").await?; + let (tx_id, subscription) = bitcoin_wallet + .ensure_broadcasted(transaction, "cancel") + .await?; Ok((tx_id, subscription)) } - pub async fn publish_refund_btc( - &self, - bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet, - ) -> Result { - let signed_tx_refund = self.signed_refund_transaction()?; - let signed_tx_refund_txid = signed_tx_refund.compute_txid(); - bitcoin_wallet.broadcast(signed_tx_refund, "refund").await?; + /// Construct the best refund transaction based on the refund signatures Alice has sent us. + /// This is either `TxFullRefund` or `TxPartialRefund`. + /// Returns the fully constructed and signed transaction along with the refund type. + pub async fn construct_best_bitcoin_refund_tx(&self) -> Result<(Transaction, RefundType)> { + if self.refund_signatures.tx_full_refund_encsig().is_some() { + tracing::debug!("Have the full refund signature, constructing full Bitcoin refund"); + let tx_full_refund = self + .signed_full_refund_transaction() + .context("Couldn't construct TxFullRefund")?; + + return Ok((tx_full_refund, RefundType::Full)); + } - Ok(signed_tx_refund_txid) + if self.refund_signatures.tx_partial_refund_encsig().is_some() { + tracing::debug!( + "Don't have the full refund signature, constructing partial Bitcoin refund" + ); + + let tx_partial_refund = self + .signed_partial_refund_transaction() + .context("Couldn't construct TxPartialRefund")?; + let total_swap_amount = self.tx_lock.lock_amount(); + let btc_amnesty_amount = self.btc_amnesty_amount.context("Missing Bitcoin amnesty amount even though we don't have the full refund signature")?; + + return Ok(( + tx_partial_refund, + RefundType::Partial { + total_swap_amount, + btc_amnesty_amount, + }, + )); + } + + unreachable!("We always have either the partial or full refund encsig"); } - pub fn construct_tx_refund(&self) -> Result { + pub fn construct_tx_refund(&self) -> Result { let tx_cancel = self.construct_tx_cancel()?; let tx_refund = - bitcoin::TxRefund::new(&tx_cancel, &self.refund_address, self.tx_refund_fee); + bitcoin::TxFullRefund::new(&tx_cancel, &self.refund_address, self.tx_refund_fee); Ok(tx_refund) } - pub fn signed_refund_transaction(&self) -> Result { + pub fn signed_full_refund_transaction(&self) -> Result { + let tx_full_refund_encsig = self.refund_signatures.tx_full_refund_encsig().context( + "Can't sign full refund transaction because we don't have the necessary signature", + )?; + let tx_refund = self.construct_tx_refund()?; let adaptor = Adaptor::, Deterministic>::default(); let sig_b = self.b.sign(tx_refund.digest()); - let sig_a = - adaptor.decrypt_signature(&self.s_b.to_secpfun_scalar(), self.tx_refund_encsig.clone()); + let sig_a = adaptor.decrypt_signature(&self.s_b.to_secpfun_scalar(), tx_full_refund_encsig); let signed_tx_refund = tx_refund.add_signatures((self.A, sig_a), (self.b.public(), sig_b))?; @@ -886,6 +1289,92 @@ impl State6 { Ok(signed_tx_refund) } + pub fn construct_tx_partial_refund(&self) -> Result { + let tx_cancel = self.construct_tx_cancel()?; + bitcoin::TxPartialRefund::new( + &tx_cancel, + &self.refund_address, + self.A, + self.b.public(), + self.btc_amnesty_amount + .context("Can't construct TxPartialRefund because btc_amnesty_amount is missing")?, + self.tx_partial_refund_fee.context( + "Can't construct TxPartialRefund because tx_partial_refund_fee is missing", + )?, + ) + } + + pub fn signed_partial_refund_transaction(&self) -> Result { + let tx_partial_refund_encsig = self + .refund_signatures + .tx_partial_refund_encsig() + .context("Can't finalize TxPartialRefund because Alice's encsig is missing")?; + + let tx_partial_refund = self.construct_tx_partial_refund()?; + + let adaptor = Adaptor::, Deterministic>::default(); + + let sig_b = self.b.sign(tx_partial_refund.digest()); + let sig_a = + adaptor.decrypt_signature(&self.s_b.to_secpfun_scalar(), tx_partial_refund_encsig); + + let signed_tx_partial_refund = + tx_partial_refund.add_signatures((self.A, sig_a), (self.b.public(), sig_b))?; + + Ok(signed_tx_partial_refund) + } + + pub fn signed_amnesty_transaction(&self) -> Result { + let tx_amnesty = self.construct_tx_amnesty()?; + + let sig_a = self.refund_signatures.tx_refund_amnesty_sig().context( + "Can't sign amnesty transaction because Alice's amnesty signature is missing", + )?; + let sig_b = self.b.sign(tx_amnesty.digest()); + + let signed_tx_amnesty = + tx_amnesty.add_signatures((self.A, sig_a), (self.b.public(), sig_b))?; + + Ok(signed_tx_amnesty) + } + + pub fn construct_tx_amnesty(&self) -> Result { + let tx_partial_refund = self.construct_tx_partial_refund()?; + + bitcoin::TxReclaim::new( + &tx_partial_refund, + &self.refund_address, + self.tx_refund_amnesty_fee.context( + "Can't construct TxRefundAmnesty because tx_refund_amnesty_fee is missing", + )?, + self.remaining_refund_timelock.context( + "Can't construct TxRefundAmnesty because remaining_refund_timelock is missing", + )?, + ) + } + + pub fn construct_tx_refund_burn(&self) -> Result { + let tx_partial_refund = self.construct_tx_partial_refund()?; + bitcoin::TxWithhold::new( + &tx_partial_refund, + self.A, + self.b.public(), + self.tx_refund_burn_fee + .context("Can't construct TxRefundBurn because tx_refund_burn_fee is missing")?, + ) + } + + pub fn construct_tx_final_amnesty(&self) -> Result { + let tx_refund_burn = self.construct_tx_refund_burn()?; + Ok(bitcoin::TxMercy::new( + &tx_refund_burn, + &self.refund_address, + self.tx_final_amnesty_fee.context( + "Can't construct TxFinalAmnesty because tx_final_amnesty_fee is missing", + )?, + )) + } + pub fn construct_tx_early_refund(&self) -> bitcoin::TxEarlyRefund { bitcoin::TxEarlyRefund::new(&self.tx_lock, &self.refund_address, self.tx_refund_fee) } @@ -907,6 +1396,7 @@ impl State6 { s_b: self.s_b, v: self.v, xmr: self.xmr, + btc_amnesty_amount: self.btc_amnesty_amount, tx_lock: self.tx_lock.clone(), monero_wallet_restore_blockheight: self.monero_wallet_restore_blockheight, lock_transfer_proof, @@ -927,3 +1417,75 @@ impl State6 { Ok(tx) } } + +impl RefundSignatures { + pub fn from_possibly_full_refund_sig( + partial_refund_encsig: bitcoin::EncryptedSignature, + full_refund_encsig: Option, + refund_amnesty_sig: bitcoin::Signature, + ) -> Self { + if let Some(full_refund_encsig) = full_refund_encsig { + Self::Full { + tx_partial_refund_encsig: partial_refund_encsig, + tx_full_refund_encsig: full_refund_encsig, + tx_refund_amnesty_sig: refund_amnesty_sig, + } + } else { + Self::Partial { + tx_partial_refund_encsig: partial_refund_encsig, + tx_refund_amnesty_sig: refund_amnesty_sig, + } + } + } + + pub fn tx_full_refund_encsig(&self) -> Option { + match self { + RefundSignatures::Partial { .. } => None, + RefundSignatures::Full { + tx_full_refund_encsig, + .. + } => Some(tx_full_refund_encsig.clone()), + RefundSignatures::Legacy { + tx_full_refund_encsig, + } => Some(tx_full_refund_encsig.clone()), + } + } + + pub fn tx_partial_refund_encsig(&self) -> Option { + match self { + RefundSignatures::Partial { + tx_partial_refund_encsig, + .. + } => Some(tx_partial_refund_encsig.clone()), + RefundSignatures::Full { + tx_partial_refund_encsig, + .. + } => Some(tx_partial_refund_encsig.clone()), + RefundSignatures::Legacy { .. } => None, + } + } + + /// Returns Alice's signature for the amnesty transaction. + /// Only available for new swaps (Partial/Full variants), not Legacy swaps. + pub fn tx_refund_amnesty_sig(&self) -> Option { + match self { + RefundSignatures::Partial { + tx_refund_amnesty_sig, + .. + } => Some(tx_refund_amnesty_sig.clone()), + RefundSignatures::Full { + tx_refund_amnesty_sig, + .. + } => Some(tx_refund_amnesty_sig.clone()), + RefundSignatures::Legacy { .. } => None, + } + } + + pub fn has_full_refund_encsig(&self) -> bool { + self.tx_full_refund_encsig().is_some() + } + + pub fn has_partial_refund_encsig(&self) -> bool { + self.tx_partial_refund_encsig().is_some() + } +} diff --git a/swap-machine/src/common/mod.rs b/swap-machine/src/common/mod.rs index 4cfc614495..61a5f900a6 100644 --- a/swap-machine/src/common/mod.rs +++ b/swap-machine/src/common/mod.rs @@ -1,14 +1,15 @@ -use crate::alice::is_complete as alice_is_complete; use crate::alice::AliceState; -use crate::bob::is_complete as bob_is_complete; +use crate::alice::is_complete as alice_is_complete; use crate::bob::BobState; -use anyhow::Result; +use crate::bob::is_complete as bob_is_complete; +use anyhow::{Result, bail}; use async_trait::async_trait; use libp2p::{Multiaddr, PeerId}; +use rust_decimal::prelude::FromPrimitive; use serde::{Deserialize, Serialize}; use sha2::Sha256; -use sigma_fun::ext::dl_secp256k1_ed25519_eq::{CrossCurveDLEQ, CrossCurveDLEQProof}; use sigma_fun::HashTranscript; +use sigma_fun::ext::dl_secp256k1_ed25519_eq::{CrossCurveDLEQ, CrossCurveDLEQProof}; use std::convert::TryInto; use std::sync::LazyLock; use swap_core::bitcoin; @@ -35,10 +36,11 @@ pub struct Message0 { pub v_b: monero::PrivateViewKey, #[serde(with = "swap_serde::bitcoin::address_serde")] pub refund_address: bitcoin::Address, - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub tx_refund_fee: bitcoin::Amount, - #[serde(with = "::bitcoin::amount::serde::as_sat")] + pub tx_partial_refund_fee: bitcoin::Amount, + pub tx_refund_amnesty_fee: bitcoin::Amount, pub tx_cancel_fee: bitcoin::Amount, + pub tx_final_amnesty_fee: bitcoin::Amount, } #[allow(non_snake_case)] @@ -53,23 +55,30 @@ pub struct Message1 { pub redeem_address: bitcoin::Address, #[serde(with = "swap_serde::bitcoin::address_serde")] pub punish_address: bitcoin::Address, - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub tx_redeem_fee: bitcoin::Amount, - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub tx_punish_fee: bitcoin::Amount, + /// The amount of Bitcoin that Bob not get refunded unless Alice decides so. + /// Introduced in [#675](https://github.com/eigenwallet/core/pull/675) to combat spam. + pub amnesty_amount: bitcoin::Amount, + pub tx_refund_burn_fee: bitcoin::Amount, } #[allow(non_snake_case)] #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Message2 { - pub psbt: bitcoin::PartiallySignedTransaction, + pub tx_lock_psbt: bitcoin::PartiallySignedTransaction, } #[allow(non_snake_case)] #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Message3 { pub tx_cancel_sig: bitcoin::Signature, - pub tx_refund_encsig: bitcoin::EncryptedSignature, + // The following fields were reworked in [#675](https://github.com/eigenwallet/core/pull/675). + // Alice will send either the full refund encsig or signatures for both partial refund + // and tx refund amnesty. + pub tx_full_refund_encsig: Option, + pub tx_partial_refund_encsig: Option, + pub tx_refund_amnesty_sig: Option, } #[allow(non_snake_case)] @@ -78,6 +87,55 @@ pub struct Message4 { pub tx_punish_sig: bitcoin::Signature, pub tx_cancel_sig: bitcoin::Signature, pub tx_early_refund_sig: bitcoin::Signature, + pub tx_refund_amnesty_sig: Option, + pub tx_refund_burn_sig: Option, + pub tx_final_amnesty_sig: Option, +} + +/// Validates that the amnesty amount is within sane bounds. +/// +/// - If amnesty is zero, this is a full-refund swap and no checks are needed. +/// - Otherwise, the amnesty must cover all transaction fees that could be spent +/// from it (TxPartialRefund, TxReclaim, TxWithhold, TxMercy). +/// - The amnesty ratio (amnesty / lock amount) must not exceed +/// [`swap_env::config::MAX_ANTI_SPAM_DEPOSIT_RATIO`]. +pub fn sanity_check_amnesty_amount( + lock_amount: bitcoin::Amount, + amnesty_amount: bitcoin::Amount, + tx_partial_refund_fee: bitcoin::Amount, + tx_reclaim_fee: bitcoin::Amount, + tx_withhold_fee: bitcoin::Amount, + tx_mercy_fee: bitcoin::Amount, +) -> Result<()> { + if amnesty_amount == bitcoin::Amount::ZERO { + return Ok(()); + } + + let min_amnesty = tx_partial_refund_fee + tx_reclaim_fee + tx_withhold_fee + tx_mercy_fee; + if amnesty_amount < min_amnesty { + bail!( + "Amnesty amount ({amnesty_amount}) is less than the combined fees \ + for TxPartialRefund ({tx_partial_refund_fee}), TxReclaim ({tx_reclaim_fee}), \ + TxWithhold ({tx_withhold_fee}), and TxMercy ({tx_mercy_fee}). \ + The deposit would be consumed by fees.", + ); + } + + let amnesty_sats = rust_decimal::Decimal::from_u64(amnesty_amount.to_sat()) + .expect("amnesty sats to fit in Decimal"); + let lock_sats = + rust_decimal::Decimal::from_u64(lock_amount.to_sat()).expect("lock sats to fit in Decimal"); + let ratio = amnesty_sats / lock_sats; + + if ratio > swap_env::config::MAX_ANTI_SPAM_DEPOSIT_RATIO { + bail!( + "Amnesty ratio ({ratio}) exceeds maximum allowed ratio of {}. \ + The requested deposit is unreasonably high.", + swap_env::config::MAX_ANTI_SPAM_DEPOSIT_RATIO, + ); + } + + Ok(()) } #[allow(clippy::large_enum_variant)] @@ -157,6 +215,26 @@ pub trait Database { async fn get_state(&self, swap_id: Uuid) -> Result; async fn get_states(&self, swap_id: Uuid) -> Result>; async fn all(&self) -> Result>; + + /// Returns the current (latest) state and the starting state for a swap. + async fn get_current_and_starting_state(&self, swap_id: Uuid) -> Result<(State, State)> { + use anyhow::Context; + + let states = self + .get_states(swap_id) + .await + .context("Error fetching all states of swap from database")?; + let starting = states.first().cloned().context("No states found")?; + let current = states.last().cloned().context("No states found")?; + + // Sanity check: both states must be from the same role + match (¤t, &starting) { + (State::Alice(_), State::Alice(_)) | (State::Bob(_), State::Bob(_)) => {} + _ => anyhow::bail!("Current and starting states have mismatched roles"), + } + + Ok((current, starting)) + } async fn insert_buffered_transfer_proof( &self, swap_id: Uuid, @@ -168,3 +246,58 @@ pub trait Database { ) -> Result>; async fn has_swap(&self, swap_id: Uuid) -> Result; } + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin_wallet::{MIN_ABSOLUTE_TX_FEE, MIN_ABSOLUTE_TX_FEE_SATS}; + + /// 1 BTC lock amount. + const LOCK: bitcoin::Amount = bitcoin::Amount::from_sat(100_000_000); + const FEE: bitcoin::Amount = MIN_ABSOLUTE_TX_FEE; + /// Sum of all 4 fees (the lower bound). + const FEE_FLOOR: u64 = MIN_ABSOLUTE_TX_FEE_SATS * 4; + /// 20% of LOCK (the upper bound). + const RATIO_CEILING: u64 = 20_000_000; + + #[test] + fn zero_amnesty_always_passes() { + sanity_check_amnesty_amount(LOCK, bitcoin::Amount::ZERO, FEE, FEE, FEE, FEE) + .expect("zero amnesty should always pass"); + } + + #[test] + fn reject_amnesty_below_fee_floor() { + let amnesty = bitcoin::Amount::from_sat(FEE_FLOOR - 1); + sanity_check_amnesty_amount(LOCK, amnesty, FEE, FEE, FEE, FEE) + .expect_err("amnesty below fee floor should be rejected"); + } + + #[test] + fn pass_amnesty_at_fee_floor() { + let amnesty = bitcoin::Amount::from_sat(FEE_FLOOR); + sanity_check_amnesty_amount(LOCK, amnesty, FEE, FEE, FEE, FEE) + .expect("amnesty exactly at fee floor should pass"); + } + + #[test] + fn pass_medium_amnesty() { + let amnesty = bitcoin::Amount::from_sat(10_000_000); + sanity_check_amnesty_amount(LOCK, amnesty, FEE, FEE, FEE, FEE) + .expect("10% amnesty should pass"); + } + + #[test] + fn pass_amnesty_at_ratio_ceiling() { + let amnesty = bitcoin::Amount::from_sat(RATIO_CEILING); + sanity_check_amnesty_amount(LOCK, amnesty, FEE, FEE, FEE, FEE) + .expect("amnesty exactly at 20% ratio should pass"); + } + + #[test] + fn reject_amnesty_above_ratio_ceiling() { + let amnesty = bitcoin::Amount::from_sat(RATIO_CEILING + 1); + sanity_check_amnesty_amount(LOCK, amnesty, FEE, FEE, FEE, FEE) + .expect_err("amnesty above 20% ratio should be rejected"); + } +} diff --git a/swap-machine/src/lib.rs b/swap-machine/src/lib.rs index c7c738fd39..27c9130b1a 100644 --- a/swap-machine/src/lib.rs +++ b/swap-machine/src/lib.rs @@ -25,6 +25,8 @@ mod tests { .await; let spending_fee = Amount::from_sat(1_000); let btc_amount = Amount::from_sat(500_000); + let btc_amnesty_amount = Amount::from_sat(100_000); + let should_publish_tx_refund = false; let xmr_amount = swap_core::monero::primitives::Amount::from_pico(10000); let tx_redeem_fee = alice_wallet @@ -47,11 +49,14 @@ mod tests { let alice_state0 = alice::State0::new( btc_amount, xmr_amount, + btc_amnesty_amount, config, redeem_address, punish_address, tx_redeem_fee, tx_punish_fee, + spending_fee, + should_publish_tx_refund, &mut OsRng, ); @@ -62,17 +67,21 @@ mod tests { xmr_amount, CancelTimelock::new(config.bitcoin_cancel_timelock), PunishTimelock::new(config.bitcoin_punish_timelock), + RemainingRefundTimelock::new(config.bitcoin_remaining_refund_timelock), bob_wallet.new_address().await.unwrap(), config.monero_finality_confirmations, spending_fee, spending_fee, + spending_fee, + spending_fee, + spending_fee, tx_lock_fee, ); - let message0 = bob_state0.next_message(); + let message0 = bob_state0.next_message().unwrap(); let (_, alice_state1) = alice_state0.receive(message0).unwrap(); - let alice_message1 = alice_state1.next_message(); + let alice_message1 = alice_state1.next_message().unwrap(); let bob_state1 = bob_state0 .receive(&bob_wallet, alice_message1) @@ -81,10 +90,10 @@ mod tests { let bob_message2 = bob_state1.next_message(); let alice_state2 = alice_state1.receive(bob_message2).unwrap(); - let alice_message3 = alice_state2.next_message(); + let alice_message3 = alice_state2.next_message().unwrap(); let bob_state2 = bob_state1.receive(alice_message3).unwrap(); - let bob_message4 = bob_state2.next_message(); + let bob_message4 = bob_state2.next_message().unwrap(); let alice_state3 = alice_state2.receive(bob_message4).unwrap(); @@ -106,12 +115,16 @@ mod tests { let redeem_transaction = alice_state3 .signed_redeem_transaction(encrypted_signature) .unwrap(); - let refund_transaction = bob_state6.signed_refund_transaction().unwrap(); + let refund_transaction = bob_state6.signed_full_refund_transaction().unwrap(); assert_weight(redeem_transaction, TxRedeem::weight().to_wu(), "TxRedeem"); assert_weight(cancel_transaction, TxCancel::weight().to_wu(), "TxCancel"); assert_weight(punish_transaction, TxPunish::weight().to_wu(), "TxPunish"); - assert_weight(refund_transaction, TxRefund::weight().to_wu(), "TxRefund"); + assert_weight( + refund_transaction, + TxFullRefund::weight().to_wu(), + "TxRefund", + ); // Test TxEarlyRefund transaction let early_refund_transaction = alice_state3 @@ -135,6 +148,8 @@ mod tests { .await; let spending_fee = Amount::from_sat(1_000); let btc_amount = Amount::from_sat(500_000); + let btc_amnesty_amount = Amount::from_sat(100_000); + let should_publish_tx_refund = false; let xmr_amount = swap_core::monero::primitives::Amount::from_pico(10000); let tx_redeem_fee = alice_wallet @@ -153,11 +168,14 @@ mod tests { let alice_state0 = alice::State0::new( btc_amount, xmr_amount, + btc_amnesty_amount, config, refund_address.clone(), punish_address, tx_redeem_fee, tx_punish_fee, + spending_fee, + should_publish_tx_refund, &mut OsRng, ); @@ -168,17 +186,21 @@ mod tests { xmr_amount, CancelTimelock::new(config.bitcoin_cancel_timelock), PunishTimelock::new(config.bitcoin_punish_timelock), + RemainingRefundTimelock::new(config.bitcoin_remaining_refund_timelock), bob_wallet.new_address().await.unwrap(), config.monero_finality_confirmations, spending_fee, spending_fee, spending_fee, + spending_fee, + spending_fee, + spending_fee, ); // Complete the state machine up to State3 - let message0 = bob_state0.next_message(); + let message0 = bob_state0.next_message().unwrap(); let (_, alice_state1) = alice_state0.receive(message0).unwrap(); - let alice_message1 = alice_state1.next_message(); + let alice_message1 = alice_state1.next_message().unwrap(); let bob_state1 = bob_state0 .receive(&bob_wallet, alice_message1) @@ -187,10 +209,10 @@ mod tests { let bob_message2 = bob_state1.next_message(); let alice_state2 = alice_state1.receive(bob_message2).unwrap(); - let alice_message3 = alice_state2.next_message(); + let alice_message3 = alice_state2.next_message().unwrap(); let bob_state2 = bob_state1.receive(alice_message3).unwrap(); - let bob_message4 = bob_state2.next_message(); + let bob_message4 = bob_state2.next_message().unwrap(); let alice_state3 = alice_state2.receive(bob_message4).unwrap(); diff --git a/swap-orchestrator/src/lib.rs b/swap-orchestrator/src/lib.rs index 8b20132996..719099c42a 100644 --- a/swap-orchestrator/src/lib.rs +++ b/swap-orchestrator/src/lib.rs @@ -1,3 +1,9 @@ pub mod compose; pub mod containers; pub mod images; + +use anyhow as _; +use dialoguer as _; +use swap_env as _; +use toml as _; +use url as _; diff --git a/swap-orchestrator/src/main.rs b/swap-orchestrator/src/main.rs index cc4746dc2a..b992962220 100644 --- a/swap-orchestrator/src/main.rs +++ b/swap-orchestrator/src/main.rs @@ -3,9 +3,11 @@ mod containers; mod images; mod prompt; +use swap_orchestrator as _; + use crate::compose::{ - IntoSpec, OrchestratorDirectories, OrchestratorImage, OrchestratorImages, OrchestratorInput, - OrchestratorNetworks, ASB_DATA_DIR, DOCKER_COMPOSE_FILE, + ASB_DATA_DIR, DOCKER_COMPOSE_FILE, IntoSpec, OrchestratorDirectories, OrchestratorImage, + OrchestratorImages, OrchestratorInput, OrchestratorNetworks, }; use std::path::PathBuf; use swap_env::config::{ @@ -193,6 +195,7 @@ fn main() { price_ticker_ws_url_bitfinex: defaults.price_ticker_ws_url_bitfinex, price_ticker_rest_url_kucoin: defaults.price_ticker_rest_url_kucoin, external_bitcoin_redeem_address: None, + refund_policy: defaults.refund_policy, developer_tip, }, }; diff --git a/swap-orchestrator/tests/spec.rs b/swap-orchestrator/tests/spec.rs index 94a874945e..a24a882031 100644 --- a/swap-orchestrator/tests/spec.rs +++ b/swap-orchestrator/tests/spec.rs @@ -1,3 +1,5 @@ +#![allow(unused_crate_dependencies)] + use swap_orchestrator::compose::{ IntoSpec, OrchestratorDirectories, OrchestratorImage, OrchestratorImages, OrchestratorInput, OrchestratorNetworks, OrchestratorPorts, diff --git a/swap-p2p/Cargo.toml b/swap-p2p/Cargo.toml index 30c7ff0274..3c61b73963 100644 --- a/swap-p2p/Cargo.toml +++ b/swap-p2p/Cargo.toml @@ -16,6 +16,7 @@ swap-serde = { path = "../swap-serde" } async-trait = { workspace = true, optional = true } libp2p = { workspace = true, features = ["serde", "request-response", "rendezvous", "cbor", "json", "ping", "identify"] } + # Serialization asynchronous-codec = "0.7.0" serde = { workspace = true } @@ -27,6 +28,7 @@ unsigned-varint = { version = "0.8.0", features = ["codec", "asynchronous_codec" bitcoin = { workspace = true } monero-address = { workspace = true } rand = { workspace = true } +rust_decimal = { workspace = true } # Utils anyhow = { workspace = true } @@ -56,5 +58,11 @@ test-support = ["libp2p/noise", "libp2p/tcp", "libp2p/yamux", "libp2p/tokio", "l async-trait = { workspace = true } libp2p = { workspace = true, features = ["serde", "request-response", "rendezvous", "cbor", "json", "ping", "identify", "noise", "tcp", "yamux", "tokio", "dns"] } +# Networking (for the example) +libp2p-tor = { workspace = true } +tor-rtcompat = { workspace = true } +arti-client = {workspace = true } +tracing-subscriber = { workspace = true } + [lints] workspace = true diff --git a/swap-p2p/examples/fetch_quotes.rs b/swap-p2p/examples/fetch_quotes.rs index 23a0a5ba58..5ebdf7d283 100644 --- a/swap-p2p/examples/fetch_quotes.rs +++ b/swap-p2p/examples/fetch_quotes.rs @@ -1,22 +1,20 @@ +#![allow(unused_crate_dependencies)] + use anyhow::Result; -use arti_client::{config::TorClientConfigBuilder, TorClient}; +use arti_client::{TorClient, config::TorClientConfigBuilder}; use futures::StreamExt; use libp2p::core::muxing::StreamMuxerBox; use libp2p::core::transport::Boxed; use libp2p::core::upgrade::Version; -use libp2p::multiaddr::Protocol; -use libp2p::swarm::dial_opts::DialOpts; use libp2p::swarm::NetworkBehaviour; +use libp2p::{PeerId, SwarmBuilder, Transport, identity, yamux}; use libp2p::{dns, tcp}; -use libp2p::{identify, noise, ping, request_response}; -use libp2p::{identity, yamux, Multiaddr, PeerId, SwarmBuilder, Transport}; +use libp2p::{identify, noise, ping}; use libp2p_tor::{AddressConversion, TorTransport}; -use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use std::time::Duration; use swap_p2p::libp2p_ext::MultiAddrExt; -use swap_p2p::protocols::quote::BidQuote; -use swap_p2p::protocols::{quote, quotes_cached, rendezvous}; +use swap_p2p::protocols::{quotes_cached, rendezvous}; use tor_rtcompat::tokio::TokioRustlsRuntime; const USE_TOR: bool = true; @@ -130,7 +128,7 @@ async fn main() -> Result<()> { match event { libp2p::swarm::SwarmEvent::Behaviour(event) => match event { BehaviourEvent::Rendezvous(event) => match event { - rendezvous::discovery::Event::DiscoveredPeer { peer_id } => {} + rendezvous::discovery::Event::DiscoveredPeer { .. } => {} }, BehaviourEvent::Quote(quotes_cached::Event::CachedQuotes { quotes }) => { println!("================"); @@ -150,7 +148,7 @@ async fn main() -> Result<()> { } _ => {} }, - libp2p::swarm::SwarmEvent::ConnectionEstablished { peer_id, .. } => {} + libp2p::swarm::SwarmEvent::ConnectionEstablished { .. } => {} _ => {} } } diff --git a/swap-p2p/src/out_event/alice.rs b/swap-p2p/src/out_event/alice.rs index ada6c61189..51642300a9 100644 --- a/swap-p2p/src/out_event/alice.rs +++ b/swap-p2p/src/out_event/alice.rs @@ -1,10 +1,10 @@ -use libp2p::{identify, ping}; use libp2p::{ + PeerId, request_response::{ InboundFailure, InboundRequestId, OutboundFailure, OutboundRequestId, ResponseChannel, }, - PeerId, }; +use libp2p::{identify, ping}; use uuid::Uuid; use crate::protocols::rendezvous; @@ -16,8 +16,14 @@ use crate::protocols::{ #[derive(Debug)] pub enum OutEvent { SwapSetupInitiated { - send_wallet_snapshot: - bmrng::RequestReceiver, + // run_swap_setup in connection handler sends us the amount of + // Bitcoin Bob wants to send. + // We respond with a snapshot of our wallets and how much of that + // should go into the amnesty output + send_wallet_snapshot: bmrng::RequestReceiver< + bitcoin::Amount, + (swap_setup::alice::WalletSnapshot, bitcoin::Amount, bool), + >, }, SwapSetupCompleted { peer_id: PeerId, diff --git a/swap-p2p/src/protocols/quote.rs b/swap-p2p/src/protocols/quote.rs index bdb06613f0..430a6652d7 100644 --- a/swap-p2p/src/protocols/quote.rs +++ b/swap-p2p/src/protocols/quote.rs @@ -1,16 +1,48 @@ use crate::out_event; use libp2p::request_response::{self, ProtocolSupport}; use libp2p::{PeerId, StreamProtocol}; +use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use swap_core::bitcoin; +use swap_env::config::RefundPolicy; use typeshare::typeshare; -pub(crate) const PROTOCOL: &str = "/comit/xmr/btc/bid-quote/1.0.0"; +pub(crate) const PROTOCOL: &str = "/comit/xmr/btc/bid-quote/2.0.0"; pub type OutEvent = request_response::Event<(), BidQuote>; pub type Message = request_response::Message<(), BidQuote>; pub type Behaviour = request_response::json::Behaviour<(), BidQuote>; +/// The refund policy that will apply if the swap is cancelled. +/// Communicated in quotes so takers know the terms upfront. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[serde(tag = "type", content = "content")] +#[typeshare] +pub enum RefundPolicyWire { + /// Taker receives 100% of their Bitcoin back on refund. + FullRefund, + /// Taker receives a partial refund; the remainder goes to an amnesty output + /// that the maker may or may not release later. + PartialRefund { + /// Ratio (0.0-1.0) of Bitcoin that goes into the anti-spam deposit + /// and may be withheld by the maker. + #[typeshare(serialized_as = "number")] + anti_spam_deposit_ratio: Decimal, + }, +} + +impl From for RefundPolicyWire { + fn from(policy: RefundPolicy) -> Self { + if policy.anti_spam_deposit_ratio == Decimal::ONE { + RefundPolicyWire::FullRefund + } else { + RefundPolicyWire::PartialRefund { + anti_spam_deposit_ratio: policy.anti_spam_deposit_ratio, + } + } + } +} + #[derive(Debug, Clone, Copy, Default)] pub struct BidQuoteProtocol; @@ -25,17 +57,16 @@ impl AsRef for BidQuoteProtocol { #[typeshare] pub struct BidQuote { /// The price at which the maker is willing to buy at. - #[serde(with = "::bitcoin::amount::serde::as_sat")] #[typeshare(serialized_as = "number")] pub price: bitcoin::Amount, /// The minimum quantity the maker is willing to buy. - #[serde(with = "::bitcoin::amount::serde::as_sat")] #[typeshare(serialized_as = "number")] pub min_quantity: bitcoin::Amount, /// The maximum quantity the maker is willing to buy. - #[serde(with = "::bitcoin::amount::serde::as_sat")] #[typeshare(serialized_as = "number")] pub max_quantity: bitcoin::Amount, + /// The refund policy that will apply if the swap is cancelled. + pub refund_policy: RefundPolicyWire, /// Monero "ReserveProofV2" which proves that Alice has the funds to fulfill the quote. /// See "Zero to Monero" section 8.1.6 for more details. /// @@ -50,6 +81,7 @@ impl BidQuote { price: bitcoin::Amount::ZERO, min_quantity: bitcoin::Amount::ZERO, max_quantity: bitcoin::Amount::ZERO, + refund_policy: RefundPolicyWire::FullRefund, reserve_proof: None, }; } diff --git a/swap-p2p/src/protocols/swap_setup.rs b/swap-p2p/src/protocols/swap_setup.rs index 1d50b4a16b..776c0009ab 100644 --- a/swap-p2p/src/protocols/swap_setup.rs +++ b/swap-p2p/src/protocols/swap_setup.rs @@ -18,7 +18,7 @@ pub mod protocol { use libp2p::swarm::Stream; use void::Void; - use super::vendor_from_fn::{from_fn, FromFnUpgrade}; + use super::vendor_from_fn::{FromFnUpgrade, from_fn}; pub fn new() -> SwapSetup { from_fn( @@ -43,7 +43,6 @@ pub struct BlockchainNetwork { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SpotPriceRequest { - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub btc: bitcoin::Amount, pub blockchain_network: BlockchainNetwork, } @@ -58,19 +57,14 @@ pub enum SpotPriceResponse { pub enum SpotPriceError { NoSwapsAccepted, AmountBelowMinimum { - #[serde(with = "::bitcoin::amount::serde::as_sat")] min: bitcoin::Amount, - #[serde(with = "::bitcoin::amount::serde::as_sat")] buy: bitcoin::Amount, }, AmountAboveMaximum { - #[serde(with = "::bitcoin::amount::serde::as_sat")] max: bitcoin::Amount, - #[serde(with = "::bitcoin::amount::serde::as_sat")] buy: bitcoin::Amount, }, BalanceTooLow { - #[serde(with = "::bitcoin::amount::serde::as_sat")] buy: bitcoin::Amount, }, BlockchainNetworkMismatch { diff --git a/swap-p2p/src/protocols/swap_setup/alice.rs b/swap-p2p/src/protocols/swap_setup/alice.rs index a84a825d67..53427b7c7e 100644 --- a/swap-p2p/src/protocols/swap_setup/alice.rs +++ b/swap-p2p/src/protocols/swap_setup/alice.rs @@ -1,14 +1,14 @@ use crate::out_event; use crate::protocols::swap_setup; use crate::protocols::swap_setup::{ - protocol, BlockchainNetwork, SpotPriceError, SpotPriceRequest, SpotPriceResponse, + BlockchainNetwork, SpotPriceError, SpotPriceRequest, SpotPriceResponse, protocol, }; -use anyhow::{anyhow, Context, Result}; -use futures::future::BoxFuture; -use futures::stream::FuturesUnordered; +use anyhow::{Context, Result, anyhow}; use futures::AsyncWriteExt; use futures::FutureExt; use futures::StreamExt; +use futures::future::BoxFuture; +use futures::stream::FuturesUnordered; use libp2p::core::upgrade; use libp2p::swarm::handler::ConnectionEvent; use libp2p::swarm::{ConnectionHandler, ConnectionId}; @@ -29,7 +29,8 @@ use uuid::Uuid; #[allow(clippy::large_enum_variant)] pub enum OutEvent { Initiated { - send_wallet_snapshot: bmrng::RequestReceiver, + send_wallet_snapshot: + bmrng::RequestReceiver, }, Completed { peer_id: PeerId, @@ -54,6 +55,7 @@ pub struct WalletSnapshot { redeem_fee: bitcoin::Amount, punish_fee: bitcoin::Amount, + refund_burn_fee: bitcoin::Amount, } impl WalletSnapshot { @@ -63,6 +65,7 @@ impl WalletSnapshot { punish_address: bitcoin::Address, redeem_fee: bitcoin::Amount, punish_fee: bitcoin::Amount, + refund_burn_fee: bitcoin::Amount, ) -> Self { Self { unlocked_balance, @@ -71,6 +74,7 @@ impl WalletSnapshot { punish_address, redeem_fee, punish_fee, + refund_burn_fee, } } } @@ -259,7 +263,7 @@ impl Handler { #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub enum HandlerOutEvent { - Initiated(bmrng::RequestReceiver), + Initiated(bmrng::RequestReceiver), Completed(Result<(Uuid, State3)>), } @@ -290,13 +294,13 @@ where ) { match event { ConnectionEvent::FullyNegotiatedInbound(substream) => { - let mut substream = substream.protocol; + let substream = substream.protocol; let (sender, receiver) = - bmrng::channel_with_timeout::( - 1, - crate::defaults::SWAP_SETUP_CHANNEL_TIMEOUT, - ); + bmrng::channel_with_timeout::< + bitcoin::Amount, + (WalletSnapshot, bitcoin::Amount, bool), + >(1, crate::defaults::SWAP_SETUP_CHANNEL_TIMEOUT); let resume_only = self.resume_only; let min_buy = self.min_buy; @@ -305,141 +309,20 @@ where let env_config = self.env_config; // We wrap the entire handshake in a timeout future - let protocol = tokio::time::timeout(self.negotiation_timeout, async move { - let request = swap_setup::read_cbor_message::(&mut substream) - .await - .context("Failed to read spot price request")?; - - let wallet_snapshot = sender - .send_receive(request.btc) - .await - .context("Failed to receive wallet snapshot")?; - - // wrap all of these into another future so we can `return` from all the - // different blocks - let validate = async { - if resume_only { - return Err(Error::ResumeOnlyMode); - }; - - let blockchain_network = BlockchainNetwork { - bitcoin: env_config.bitcoin_network, - monero: env_config.monero_network, - }; - - if request.blockchain_network != blockchain_network { - return Err(Error::BlockchainNetworkMismatch { - cli: request.blockchain_network, - asb: blockchain_network, - }); - } - - let btc = request.btc; - - if btc < min_buy { - return Err(Error::AmountBelowMinimum { - min: min_buy, - buy: btc, - }); - } - - if btc > max_buy { - return Err(Error::AmountAboveMaximum { - max: max_buy, - buy: btc, - }); - } - - let rate = - latest_rate.map_err(|e| Error::LatestRateFetchFailed(Box::new(e)))?; - let xmr = rate - .sell_quote(btc) - .map_err(Error::SellQuoteCalculationFailed)?; - - let unlocked = wallet_snapshot.unlocked_balance; - - let needed_balance = xmr + wallet_snapshot.lock_fee.into(); - if unlocked.as_pico() < needed_balance.as_pico() { - tracing::warn!( - unlocked_balance = %unlocked, - needed_balance = %needed_balance, - "Rejecting swap, unlocked balance too low" - ); - return Err(Error::BalanceTooLow { - balance: wallet_snapshot.unlocked_balance, - buy: btc, - }); - } - - Ok(xmr) - }; - - let result = validate.await; - - let converted_result = match result { - Ok(xmr) => Ok(xmr.into()), - Err(e) => Err(e), - }; - swap_setup::write_cbor_message( - &mut substream, - SpotPriceResponse::from_result_ref(&converted_result), - ) - .await - .context("Failed to write spot price response")?; - - let xmr = converted_result?; - - let state0 = State0::new( - request.btc, - xmr, + let protocol = tokio::time::timeout( + self.negotiation_timeout, + run_swap_setup( + substream, + sender, + resume_only, env_config, - wallet_snapshot.redeem_address, - wallet_snapshot.punish_address, - wallet_snapshot.redeem_fee, - wallet_snapshot.punish_fee, - &mut rand::thread_rng(), - ); - - let message0 = swap_setup::read_cbor_message::(&mut substream) - .await - .context("Failed to read message0")?; - let (swap_id, state1) = state0 - .receive(message0) - .context("Failed to transition state0 -> state1 using message0")?; - - swap_setup::write_cbor_message(&mut substream, state1.next_message()) - .await - .context("Failed to send message1")?; - - let message2 = swap_setup::read_cbor_message::(&mut substream) - .await - .context("Failed to read message2")?; - let state2 = state1 - .receive(message2) - .context("Failed to transition state1 -> state2 using message2")?; - - swap_setup::write_cbor_message(&mut substream, state2.next_message()) - .await - .context("Failed to send message3")?; - - let message4 = swap_setup::read_cbor_message::(&mut substream) - .await - .context("Failed to read message4")?; - let state3 = state2 - .receive(message4) - .context("Failed to transition state2 -> state3 using message4")?; - - substream - .flush() - .await - .context("Failed to flush substream after all messages were sent")?; - substream - .close() - .await - .context("Failed to close substream after all messages were sent")?; - - Ok((swap_id, state3)) - }); + min_buy, + max_buy, + latest_rate.map_err(|error| { + Box::new(error) as Box + }), + ), + ); let max_seconds = self.negotiation_timeout.as_secs(); self.inbound_streams.push( @@ -560,3 +443,157 @@ impl Error { } } } + +async fn run_swap_setup( + mut substream: libp2p::swarm::Stream, + sender: bmrng::RequestSender, + resume_only: bool, + env_config: env::Config, + min_buy: bitcoin::Amount, + max_buy: bitcoin::Amount, + latest_rate: Result>, +) -> Result<(Uuid, State3)> { + let request = swap_setup::read_cbor_message::(&mut substream) + .await + .context("Failed to read spot price request")?; + + let (wallet_snapshot, btc_amnesty_amount, should_burn_on_refund) = sender + .send_receive(request.btc) + .await + .context("Failed to receive wallet snapshot")?; + + // wrap all of these into another future so we can `return` from all the + // different blocks + let validate = async { + if resume_only { + return Err(Error::ResumeOnlyMode); + }; + + let blockchain_network = BlockchainNetwork { + bitcoin: env_config.bitcoin_network, + monero: env_config.monero_network, + }; + + if request.blockchain_network != blockchain_network { + return Err(Error::BlockchainNetworkMismatch { + cli: request.blockchain_network, + asb: blockchain_network, + }); + } + + let btc = request.btc; + + if btc < min_buy { + return Err(Error::AmountBelowMinimum { + min: min_buy, + buy: btc, + }); + } + + if btc > max_buy { + return Err(Error::AmountAboveMaximum { + max: max_buy, + buy: btc, + }); + } + + let rate = latest_rate.map_err(Error::LatestRateFetchFailed)?; + let xmr = rate + .sell_quote(btc) + .map_err(Error::SellQuoteCalculationFailed)?; + + let unlocked = wallet_snapshot.unlocked_balance; + + let needed_balance = xmr + wallet_snapshot.lock_fee.into(); + if unlocked.as_pico() < needed_balance.as_pico() { + tracing::warn!( + unlocked_balance = %unlocked, + needed_balance = %needed_balance, + "Rejecting swap, unlocked balance too low" + ); + return Err(Error::BalanceTooLow { + balance: wallet_snapshot.unlocked_balance, + buy: btc, + }); + } + + Ok(xmr) + }; + + let result = validate.await; + + let converted_result = match result { + Ok(xmr) => Ok(xmr.into()), + Err(e) => Err(e), + }; + swap_setup::write_cbor_message( + &mut substream, + SpotPriceResponse::from_result_ref(&converted_result), + ) + .await + .context("Failed to write spot price response")?; + + let xmr = converted_result?; + + let state0 = State0::new( + request.btc, + xmr, + btc_amnesty_amount, + env_config, + wallet_snapshot.redeem_address, + wallet_snapshot.punish_address, + wallet_snapshot.redeem_fee, + wallet_snapshot.punish_fee, + wallet_snapshot.refund_burn_fee, + should_burn_on_refund, + &mut rand::thread_rng(), + ); + + let message0 = swap_setup::read_cbor_message::(&mut substream) + .await + .context("Failed to read message0")?; + let (swap_id, state1) = state0 + .receive(message0) + .context("Failed to transition state0 -> state1 using message0")?; + + swap_setup::write_cbor_message( + &mut substream, + state1 + .next_message() + .context("Couldn't construct Mesage1")?, + ) + .await + .context("Failed to send message1")?; + + let message2 = swap_setup::read_cbor_message::(&mut substream) + .await + .context("Failed to read message2")?; + let state2 = state1 + .receive(message2) + .context("Failed to transition state1 -> state2 using message2")?; + + swap_setup::write_cbor_message( + &mut substream, + state2.next_message().context("Couldn't produce Message3")?, + ) + .await + .context("Failed to send message3")?; + + let message4 = swap_setup::read_cbor_message::(&mut substream) + .await + .context("Failed to read message4")?; + let state3 = state2 + .receive(message4) + .context("Failed to transition state2 -> state3 using message4")?; + + substream + .flush() + .await + .context("Failed to flush substream after all messages were sent")?; + substream + .close() + .await + .context("Failed to close substream after all messages were sent")?; + + Ok((swap_id, state3)) +} diff --git a/swap-p2p/src/protocols/swap_setup/bob.rs b/swap-p2p/src/protocols/swap_setup/bob.rs index 3e9284ada1..245d11bb22 100644 --- a/swap-p2p/src/protocols/swap_setup/bob.rs +++ b/swap-p2p/src/protocols/swap_setup/bob.rs @@ -1,7 +1,7 @@ use crate::futures_util::FuturesHashSet; use crate::out_event; use crate::protocols::swap_setup::{ - protocol, BlockchainNetwork, SpotPriceError, SpotPriceResponse, + BlockchainNetwork, SpotPriceError, SpotPriceResponse, protocol, }; use anyhow::{Context, Result}; use bitcoin_wallet::BitcoinWallet; @@ -25,7 +25,7 @@ use swap_machine::bob::{State0, State2}; use swap_machine::common::{Message1, Message3}; use uuid::Uuid; -use super::{read_cbor_message, write_cbor_message, SpotPriceRequest}; +use super::{SpotPriceRequest, read_cbor_message, write_cbor_message}; // TODO: This should use redial::Behaviour to keep connections alive for peers with queued requests // TODO: Do not use swap_id as key inside the ConnectionHandler, use another key @@ -174,7 +174,10 @@ impl NetworkBehaviour for Behaviour { result, }); } else { - debug_assert!(false, "Received a swap setup result from a connection handler for which we have no inflight request stored"); + debug_assert!( + false, + "Received a swap setup result from a connection handler for which we have no inflight request stored" + ); } } @@ -326,6 +329,9 @@ pub struct NewSwap { pub btc: bitcoin::Amount, pub tx_lock_fee: bitcoin::Amount, pub tx_refund_fee: bitcoin::Amount, + pub tx_partial_refund_fee: bitcoin::Amount, + pub tx_refund_amnesty_fee: bitcoin::Amount, + pub tx_final_amnesty_fee: bitcoin::Amount, pub tx_cancel_fee: bitcoin::Amount, pub bitcoin_refund_address: bitcoin::Address, } @@ -401,7 +407,10 @@ impl ConnectionHandler for Handler { // In poll(..), we ensure that we never dispatch multiple concurrent swap setup requests for the same swap on the same ConnectionHandler // This invariant should therefore never be violated // TODO: Is this truly true? - assert!(!did_replace_existing_future, "Replacing an existing inflight swap setup request is not allowed. We should have checked for this invariant before instructing the Behaviour to start a substream."); + assert!( + !did_replace_existing_future, + "Replacing an existing inflight swap setup request is not allowed. We should have checked for this invariant before instructing the Behaviour to start a substream." + ); } libp2p::swarm::handler::ConnectionEvent::DialUpgradeError( libp2p::swarm::handler::DialUpgradeError { info, error }, @@ -462,7 +471,10 @@ impl ConnectionHandler for Handler { ); // TODO: Potentially make this a production assert - debug_assert!(false, "Multiple concurrent swap setup requests with the same swap id are not allowed."); + debug_assert!( + false, + "Multiple concurrent swap setup requests with the same swap id are not allowed." + ); continue; } @@ -478,7 +490,10 @@ impl ConnectionHandler for Handler { ); // TODO: Potentially make this a production assert - debug_assert!(false, "Multiple concurrent substream negotiations for the same swap id are not allowed."); + debug_assert!( + false, + "Multiple concurrent substream negotiations for the same swap id are not allowed." + ); continue; } @@ -557,8 +572,12 @@ async fn run_swap_setup( xmr, env_config.bitcoin_cancel_timelock.into(), env_config.bitcoin_punish_timelock.into(), + env_config.bitcoin_remaining_refund_timelock.into(), new_swap_request.bitcoin_refund_address.clone(), env_config.monero_finality_confirmations, + new_swap_request.tx_partial_refund_fee, + new_swap_request.tx_refund_amnesty_fee, + new_swap_request.tx_final_amnesty_fee, new_swap_request.tx_refund_fee, new_swap_request.tx_cancel_fee, new_swap_request.tx_lock_fee, @@ -569,9 +588,14 @@ async fn run_swap_setup( "Transitioned into state0 during swap setup", ); - write_cbor_message(&mut substream, state0.next_message()) - .await - .context("Failed to send state0 message to Alice")?; + write_cbor_message( + &mut substream, + state0 + .next_message() + .context("Couldn't generate Message0")?, + ) + .await + .context("Failed to send state0 message to Alice")?; let message1 = read_cbor_message::(&mut substream) .await .context("Failed to read message1 from Alice")?; @@ -600,9 +624,14 @@ async fn run_swap_setup( "Transitioned into state2 during swap setup", ); - write_cbor_message(&mut substream, state2.next_message()) - .await - .context("Failed to send state2 message")?; + write_cbor_message( + &mut substream, + state2 + .next_message() + .context("Couldn't construct Message4")?, + ) + .await + .context("Failed to send state2 message")?; substream .flush() @@ -644,10 +673,14 @@ pub enum Error { max: bitcoin::Amount, buy: bitcoin::Amount, }, - #[error("Seller's XMR balance is currently too low to fulfill the swap request to buy {buy}, please try again later")] + #[error( + "Seller's XMR balance is currently too low to fulfill the swap request to buy {buy}, please try again later" + )] BalanceTooLow { buy: bitcoin::Amount }, - #[error("Seller blockchain network {asb:?} setup did not match your blockchain network setup {cli:?}")] + #[error( + "Seller blockchain network {asb:?} setup did not match your blockchain network setup {cli:?}" + )] BlockchainNetworkMismatch { cli: BlockchainNetwork, asb: BlockchainNetwork, diff --git a/swap/src/asb.rs b/swap/src/asb.rs index de055d02a0..2457de7e69 100644 --- a/swap/src/asb.rs +++ b/swap/src/asb.rs @@ -11,6 +11,7 @@ pub use recovery::cancel::cancel; pub use recovery::punish::punish; pub use recovery::redeem::{redeem, Finality}; pub use recovery::refund::refund; +pub use recovery::grant_final_amnesty::grant_final_amnesty; pub use recovery::safely_abort::safely_abort; pub use recovery::{cancel, refund}; pub use swap_feed::{ExchangeRate, FixedRate, LatestRate, Rate}; diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index c40d49ade9..4e8957a032 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -6,7 +6,7 @@ use crate::asb::{Behaviour, OutEvent}; use crate::monero; use crate::network::cooperative_xmr_redeem_after_punish::CooperativeXmrRedeemRejectReason; use crate::network::cooperative_xmr_redeem_after_punish::Response::{Fullfilled, Rejected}; -use crate::network::quote::BidQuote; +use crate::network::quote::{BidQuote, RefundPolicyWire}; use crate::network::swap_setup::alice::WalletSnapshot; use crate::network::transfer_proof; use crate::protocol::alice::swap::has_already_processed_enc_sig; @@ -21,6 +21,7 @@ use libp2p::request_response::{OutboundFailure, OutboundRequestId, ResponseChann use libp2p::swarm::SwarmEvent; use libp2p::{PeerId, Swarm}; use moka::future::Cache; +use rust_decimal::prelude::FromPrimitive; use rust_decimal::Decimal; use std::collections::HashMap; use std::convert::TryInto; @@ -29,6 +30,7 @@ use std::io::Write; use std::sync::Arc; use std::time::Duration; use swap_core::bitcoin; +use swap_env::config::RefundPolicy; use swap_env::env; use swap_feed::LatestRate; use tokio::sync::{mpsc, oneshot}; @@ -51,6 +53,7 @@ where max_buy: bitcoin::Amount, external_redeem_address: Option, developer_tip: TipConfig, + refund_policy: RefundPolicy, /// Cache for quotes quote_cache: Cache, Arc>>, @@ -65,6 +68,11 @@ where /// the sender is removed from this map. recv_encrypted_signature: HashMap>, + /// Stores where to send burn-on-refund instructions to + /// The corresponding receiver is stored in the EventLoopHandle + /// Uses watch channel to allow multiple updates before consumption + recv_burn_on_refund_instruction: HashMap>>, + /// Once we receive an [`EncryptedSignature`] from Bob, we forward it to the EventLoopHandle. /// Once the EventLoopHandle acknowledges the receipt of the [`EncryptedSignature`], we need to confirm this to Bob. /// When the EventLoopHandle acknowledges the receipt, a future in this collection resolves and returns the libp2p channel @@ -142,6 +150,7 @@ where max_buy: bitcoin::Amount, external_redeem_address: Option, developer_tip: TipConfig, + refund_policy: RefundPolicy, ) -> Result<(Self, mpsc::Receiver, EventLoopService)> { let swap_channel = MpscChannels::default(); let (outgoing_transfer_proofs_sender, outgoing_transfer_proofs_requests) = @@ -162,8 +171,10 @@ where max_buy, external_redeem_address, developer_tip, + refund_policy, quote_cache, recv_encrypted_signature: Default::default(), + recv_burn_on_refund_instruction: Default::default(), inflight_encrypted_signatures: Default::default(), outgoing_transfer_proofs_requests, outgoing_transfer_proofs_sender, @@ -247,6 +258,15 @@ where } }; + // TODO: propagate error to the swap_setup routine instead of swallowing it + let (btc_amnesty_amount, should_publish_tx_refund_burn )= match apply_anti_spam_policy(btc, &self.refund_policy) { + Ok(amount) => amount, + Err(error) => { + tracing::error!("Swap request will be ignored because we were unable to create wallet snapshot for swap: {:#}", error); + continue; + } + }; + let wallet_snapshot = match capture_wallet_snapshot(self.bitcoin_wallet.clone(), &self.monero_wallet, &self.external_redeem_address, btc).await { Ok(wallet_snapshot) => wallet_snapshot, Err(error) => { @@ -256,7 +276,7 @@ where }; // Ignore result, we should never hit this because the receiver will alive as long as the connection is. - let _ = responder.respond(wallet_snapshot); + let _ = responder.respond((wallet_snapshot, btc_amnesty_amount, should_publish_tx_refund_burn)); } SwarmEvent::Behaviour(OutEvent::SwapSetupCompleted{peer_id, swap_id, state3}) => { if let Err(error) = self.handle_execution_setup_done(peer_id, swap_id, state3).await { @@ -267,7 +287,7 @@ where tracing::warn!(%peer, "Ignoring spot price request: {}", error); } SwarmEvent::Behaviour(OutEvent::QuoteRequested { channel, peer }) => { - match self.make_quote_or_use_cached(self.min_buy, self.max_buy, self.developer_tip.ratio).await { + match self.make_quote_or_use_cached(self.min_buy, self.max_buy, self.developer_tip.ratio, self.refund_policy.clone().into()).await { Ok(quote_arc) => { if self.swarm.behaviour_mut().quote.send_response(channel, (*quote_arc).clone()).is_err() { tracing::debug!(%peer, "Failed to respond with quote"); @@ -540,6 +560,19 @@ where let _ = respond_to.send(registrations); } + EventLoopRequest::SetBurnOnRefund { swap_id, burn, respond_to } => { + let result = if let Some(sender) = self.recv_burn_on_refund_instruction.get(&swap_id) { + sender.send(Some(burn)) + .map_err(|_| anyhow!("Failed to send burn instruction - receiver dropped")) + } else { + Err(anyhow!("No active swap found with id {}", swap_id)) + }; + let _ = respond_to.send(result); + } + EventLoopRequest::GrantFinalAmnesty { swap_id, respond_to } => { + let result = self.handle_grant_final_amnesty(swap_id).await; + let _ = respond_to.send(result); + } } } } @@ -553,6 +586,7 @@ where min_buy: bitcoin::Amount, max_buy: bitcoin::Amount, developer_tip: Decimal, + refund_policy: RefundPolicyWire, ) -> Result, Arc> { // We use the min and max buy amounts to create a unique key for the cache // Although these values stay constant over the lifetime of an instance of the asb, this might change in the future @@ -603,6 +637,7 @@ where get_reserved_items, get_reserve_proof, developer_tip, + refund_policy, ) .await; @@ -677,15 +712,70 @@ where self.recv_encrypted_signature .insert(swap_id, encrypted_signature_sender); + // Create a watch channel for burn-on-refund instructions + // Uses watch instead of bmrng to allow multiple updates before consumption + let (burn_instruction_sender, burn_instruction_receiver) = + tokio::sync::watch::channel(None); + self.recv_burn_on_refund_instruction + .insert(swap_id, burn_instruction_sender); + let transfer_proof_sender = self.outgoing_transfer_proofs_sender.clone(); EventLoopHandle { swap_id, peer, recv_encrypted_signature: tokio::sync::Mutex::new(Some(encrypted_signature_receiver)), + recv_burn_on_refund_instruction: tokio::sync::Mutex::new(burn_instruction_receiver), transfer_proof_sender: tokio::sync::Mutex::new(Some(transfer_proof_sender)), } } + + /// Handle a request to grant final amnesty for a swap. + /// + /// This checks that the swap is not currently running, transitions the + /// state to BtcFinalAmnestyGranted, and resumes the swap. + async fn handle_grant_final_amnesty(&mut self, swap_id: Uuid) -> Result<()> { + use crate::asb::grant_final_amnesty; + + // Check if swap is currently running + if self.recv_encrypted_signature.contains_key(&swap_id) + || self.recv_burn_on_refund_instruction.contains_key(&swap_id) + { + return Err(anyhow!( + "Cannot grant final amnesty while swap {} is still running", + swap_id + )); + } + + // Use the grant_final_amnesty function to transition the state + let new_state = grant_final_amnesty(swap_id, self.db.clone()).await?; + + // Get peer ID for this swap + let peer_id = self.db.get_peer_id(swap_id).await?; + + // Create handle and swap to resume + let handle = self.new_handle(peer_id, swap_id); + let swap = Swap { + event_loop_handle: handle, + bitcoin_wallet: self.bitcoin_wallet.clone(), + monero_wallet: self.monero_wallet.clone(), + env_config: self.env_config, + db: self.db.clone(), + state: new_state, + swap_id, + developer_tip: self.developer_tip.clone(), + }; + + // Send swap to be resumed + self.swap_sender + .send(swap) + .await + .context("Failed to send swap to be resumed")?; + + tracing::info!(%swap_id, "Granted final amnesty and resumed swap"); + + Ok(()) + } } // We use a Mutex here to allow recv_encrypted_signature and transfer_proof_sender to be accessed concurrently @@ -695,6 +785,7 @@ pub struct EventLoopHandle { peer: PeerId, recv_encrypted_signature: tokio::sync::Mutex>>, + recv_burn_on_refund_instruction: tokio::sync::Mutex>>, #[allow(clippy::type_complexity)] transfer_proof_sender: tokio::sync::Mutex< Option< @@ -815,6 +906,69 @@ impl EventLoopHandle { Ok(()) } + + /// Wait for a NEW burn-on-refund instruction from the operator + /// + /// This method waits until the operator sends a new decision via the EventLoopService. + /// Use this in select! arms to react to operator commands. + /// + /// Returns the new burn decision when one is received. + pub async fn wait_for_burn_on_refund_instruction(&self) -> Result { + let mut guard = self.recv_burn_on_refund_instruction.lock().await; + + guard + .changed() + .await + .map_err(|_| anyhow!("Burn instruction sender was dropped"))?; + + let value = *guard.borrow(); + Ok(value.expect("changed() returned Ok, so value should be set")) + } + + /// Get the current burn-on-refund instruction value + /// + /// Returns Some(bool) if an instruction has been set, None otherwise. + /// Use this to check the current decision before taking action. + pub async fn get_burn_on_refund_instruction(&self) -> Option { + let guard = self.recv_burn_on_refund_instruction.lock().await; + let value = *guard.borrow(); + value + } +} + +/// For a new swap of `swap_amount`, this function calculates how much +/// Bitcoin should go into the anti spam deposit incase of a refund. +/// Returns ZERO when anti_spam_deposit_ratio is 0, indicating immediate and full refund. +/// Also returns whether or not to always withhold the the anti spam deposit output if the taker refunds. +fn apply_anti_spam_policy( + swap_amount: bitcoin::Amount, + refund_policy: &RefundPolicy, +) -> Result<(bitcoin::Amount, bool)> { + let should_always_withhold = refund_policy.always_withhold_deposit; + + // When ratio is 0.0, no amnesty - use full refund path for fewer fees + if refund_policy.anti_spam_deposit_ratio == Decimal::ZERO { + return Ok((bitcoin::Amount::ZERO, should_always_withhold)); + } + + let btc_anti_spam_deposit_ratio = refund_policy.anti_spam_deposit_ratio; + + let amount_sats = swap_amount.to_sat(); + let amount_decimal = + Decimal::from_u64(amount_sats).context("Decimal overflowed by Bitcoin sats")?; + + let btc_amnesty_decimal = amount_decimal + .checked_mul(btc_anti_spam_deposit_ratio) + .context("Decimal overflow when computing amnesty amount in sats")? + .floor(); + let btc_amnesty_sats = btc_amnesty_decimal + .try_into() + .context("Couldn't convert Decimal to u64")?; + + Ok(( + bitcoin::Amount::from_sat(btc_amnesty_sats), + should_always_withhold, + )) } async fn capture_wallet_snapshot( @@ -841,6 +995,9 @@ async fn capture_wallet_snapshot( let punish_fee = bitcoin_wallet .estimate_fee(bitcoin::TxPunish::weight(), Some(transfer_amount)) .await?; + let refund_burn_fee = bitcoin_wallet + .estimate_fee(bitcoin::TxWithhold::weight(), Some(transfer_amount)) + .await?; Ok(WalletSnapshot::new( unlocked_balance.into(), @@ -848,6 +1005,7 @@ async fn capture_wallet_snapshot( punish_address, redeem_fee, punish_fee, + refund_burn_fee, )) } @@ -868,6 +1026,15 @@ mod service { Vec, >, }, + SetBurnOnRefund { + swap_id: Uuid, + burn: bool, + respond_to: oneshot::Sender>, + }, + GrantFinalAmnesty { + swap_id: Uuid, + respond_to: oneshot::Sender>, + }, } /// Tower service for communicating with the EventLoop @@ -913,6 +1080,39 @@ mod service { rx.await .map_err(|_| anyhow::anyhow!("EventLoop service did not respond")) } + + /// Set the burn-on-refund decision for a specific swap + /// + /// This can be called multiple times to update the decision before + /// the swap state machine polls for it. + pub async fn set_withhold_deposit(&self, swap_id: Uuid, burn: bool) -> anyhow::Result<()> { + let (tx, rx) = oneshot::channel(); + self.sender + .send(EventLoopRequest::SetBurnOnRefund { + swap_id, + burn, + respond_to: tx, + }) + .map_err(|_| anyhow::anyhow!("EventLoop service is down"))?; + rx.await + .map_err(|_| anyhow::anyhow!("EventLoop service did not respond"))? + } + + /// Grant final amnesty for a swap in BtcRefundBurnConfirmed state + /// + /// This transitions the swap to BtcFinalAmnestyGranted and resumes + /// the swap state machine to publish the final amnesty transaction. + pub async fn grant_mercy(&self, swap_id: Uuid) -> anyhow::Result<()> { + let (tx, rx) = oneshot::channel(); + self.sender + .send(EventLoopRequest::GrantFinalAmnesty { + swap_id, + respond_to: tx, + }) + .map_err(|_| anyhow::anyhow!("EventLoop service is down"))?; + rx.await + .map_err(|_| anyhow::anyhow!("EventLoop service did not respond"))? + } } } @@ -925,7 +1125,7 @@ mod quote { use tokio::time::timeout; use crate::{ - network::quote::{BidQuote, ReserveProofWithAddress}, + network::quote::{BidQuote, RefundPolicyWire, ReserveProofWithAddress}, protocol::alice::ReservesMonero, }; @@ -949,6 +1149,7 @@ mod quote { get_reserved_items: I, get_reserve_proof: P, developer_tip: Decimal, + refund_policy: RefundPolicyWire, ) -> Result, Arc> where LR: LatestRate, @@ -1018,6 +1219,7 @@ mod quote { price: ask_price, min_quantity: bitcoin::Amount::ZERO, max_quantity: bitcoin::Amount::ZERO, + refund_policy: refund_policy.clone(), reserve_proof, })); } @@ -1032,6 +1234,7 @@ mod quote { price: ask_price, min_quantity: min_buy, max_quantity: max_bitcoin_for_monero, + refund_policy: refund_policy.clone(), reserve_proof, })); } @@ -1040,6 +1243,7 @@ mod quote { price: ask_price, min_quantity: min_buy, max_quantity: max_buy, + refund_policy, reserve_proof, })) } @@ -1284,6 +1488,7 @@ mod tests { || async { Ok(reserved_items) }, || async { Err(anyhow::anyhow!("no reserve proof")) }, Decimal::ZERO, + RefundPolicyWire::FullRefund, ) .await .unwrap(); @@ -1316,6 +1521,7 @@ mod tests { || async { Ok(reserved_items) }, || async { Err(anyhow::anyhow!("no reserve proof")) }, Decimal::ZERO, + RefundPolicyWire::FullRefund, ) .await .unwrap(); @@ -1343,6 +1549,7 @@ mod tests { || async { Ok(reserved_items) }, || async { Err(anyhow::anyhow!("no reserve proof")) }, Decimal::ZERO, + RefundPolicyWire::FullRefund, ) .await .unwrap(); @@ -1368,6 +1575,7 @@ mod tests { || async { Ok(reserved_items) }, || async { Err(anyhow::anyhow!("no reserve proof")) }, Decimal::ZERO, + RefundPolicyWire::FullRefund, ) .await .unwrap(); @@ -1398,6 +1606,7 @@ mod tests { || async { Ok(reserved_items) }, || async { Err(anyhow::anyhow!("no reserve proof")) }, Decimal::ZERO, + RefundPolicyWire::FullRefund, ) .await .unwrap(); @@ -1422,6 +1631,7 @@ mod tests { || async { Ok(reserved_items) }, || async { Err(anyhow::anyhow!("no reserve proof")) }, Decimal::ZERO, + RefundPolicyWire::FullRefund, ) .await; @@ -1448,6 +1658,7 @@ mod tests { || async { Ok(reserved_items) }, || async { Err(anyhow::anyhow!("no reserve proof")) }, Decimal::ZERO, + RefundPolicyWire::FullRefund, ) .await .unwrap(); @@ -1460,7 +1671,36 @@ mod tests { #[tokio::test] async fn test_make_quote_with_developer_tip() { - todo!("implement once unit tests compile again") + let min_buy = bitcoin::Amount::from_sat(100_000); + let max_buy = bitcoin::Amount::from_sat(5_000_000); // High enough to be balance-limited + let rate = FixedRate::default(); + let balance = monero::Amount::parse_monero("1.0").unwrap(); + let reserved_items: Vec = vec![]; + let developer_tip = Decimal::new(5, 2); // 0.05 = 5% + + let result = make_quote( + min_buy, + max_buy, + rate.clone(), + || async { Ok(balance) }, + || async { Ok(reserved_items) }, + || async { Err(anyhow::anyhow!("no reserve proof")) }, + developer_tip, + RefundPolicyWire::FullRefund, + ) + .await + .unwrap(); + + // Compute expected max: effective balance is reduced by the tip multiplier + let unreserved = unreserved_monero_balance(balance, std::iter::empty(), developer_tip); + let expected_max = unreserved + .max_bitcoin_for_price(rate.value().ask().unwrap()) + .unwrap(); + + assert_eq!(result.min_quantity, min_buy); + assert_eq!(result.max_quantity, expected_max); + // The tip should have reduced max_quantity below max_buy + assert!(result.max_quantity < max_buy); } // Mock struct for testing diff --git a/swap/src/asb/recovery.rs b/swap/src/asb/recovery.rs index dd4a7b86a7..e730f77110 100644 --- a/swap/src/asb/recovery.rs +++ b/swap/src/asb/recovery.rs @@ -1,4 +1,5 @@ pub mod cancel; +pub mod grant_final_amnesty; pub mod punish; pub mod redeem; pub mod refund; diff --git a/swap/src/asb/recovery/cancel.rs b/swap/src/asb/recovery/cancel.rs index 6f8dc51000..f99118ad5b 100644 --- a/swap/src/asb/recovery/cancel.rs +++ b/swap/src/asb/recovery/cancel.rs @@ -30,6 +30,8 @@ pub async fn cancel( | AliceState::CancelTimelockExpired { monero_wallet_restore_blockheight, transfer_proof, state3} | AliceState::BtcCancelled { monero_wallet_restore_blockheight, transfer_proof, state3 } | AliceState::BtcRefunded { monero_wallet_restore_blockheight, transfer_proof, state3 ,.. } + | AliceState::BtcPartiallyRefunded { monero_wallet_restore_blockheight, transfer_proof, state3 ,.. } + | AliceState::XmrRefundable { monero_wallet_restore_blockheight, transfer_proof, state3 ,.. } | AliceState::BtcPunishable { monero_wallet_restore_blockheight, transfer_proof, state3 } => { (monero_wallet_restore_blockheight, transfer_proof, state3) } @@ -39,7 +41,12 @@ pub async fn cancel( // Alice already in final state | AliceState::BtcRedeemed - | AliceState::XmrRefunded + | AliceState::XmrRefunded { .. } + | AliceState::BtcWithholdPublished { .. } + | AliceState::BtcWithholdConfirmed { .. } + | AliceState::BtcMercyGranted { .. } + | AliceState::BtcMercyPublished { .. } + | AliceState::BtcMercyConfirmed { .. } | AliceState::BtcEarlyRefundable { .. } | AliceState::BtcEarlyRefunded(_) | AliceState::BtcPunished { .. } diff --git a/swap/src/asb/recovery/grant_final_amnesty.rs b/swap/src/asb/recovery/grant_final_amnesty.rs new file mode 100644 index 0000000000..4f5b898607 --- /dev/null +++ b/swap/src/asb/recovery/grant_final_amnesty.rs @@ -0,0 +1,29 @@ +use crate::protocol::alice::AliceState; +use crate::protocol::Database; +use anyhow::{bail, Result}; +use std::convert::TryInto; +use std::sync::Arc; +use uuid::Uuid; + +pub async fn grant_final_amnesty( + swap_id: Uuid, + db: Arc, +) -> Result { + let state = db.get_state(swap_id).await?.try_into()?; + + match state { + AliceState::BtcWithholdConfirmed { state3 } => { + let new_state = AliceState::BtcMercyGranted { state3 }; + + db.insert_latest_state(swap_id, new_state.clone().into()) + .await?; + + Ok(new_state) + } + _ => bail!( + "Cannot grant final amnesty for swap {} because it is in state {} which is not BtcRefundBurnConfirmed", + swap_id, + state + ), + } +} diff --git a/swap/src/asb/recovery/punish.rs b/swap/src/asb/recovery/punish.rs index a71a1b58a3..07657ce96a 100644 --- a/swap/src/asb/recovery/punish.rs +++ b/swap/src/asb/recovery/punish.rs @@ -34,13 +34,20 @@ pub async fn punish( // The state machine is in a state where punish is theoretically impossible but we try and punish anyway as this is what the user wants | AliceState::BtcRedeemTransactionPublished { state3, transfer_proof, .. } | AliceState::BtcRefunded { state3, transfer_proof,.. } => { (state3, transfer_proof) } + | AliceState::BtcPartiallyRefunded { state3, transfer_proof,.. } => { (state3, transfer_proof) } + | AliceState::XmrRefundable { state3, transfer_proof,.. } => { (state3, transfer_proof) } // Alice already in final state or at the start of the swap so we can't punish | AliceState::Started { .. } | AliceState::BtcLockTransactionSeen { .. } | AliceState::BtcLocked { .. } | AliceState::BtcRedeemed { .. } - | AliceState::XmrRefunded + | AliceState::XmrRefunded { .. } + | AliceState::BtcWithholdPublished { .. } + | AliceState::BtcWithholdConfirmed { .. } + | AliceState::BtcMercyGranted { .. } + | AliceState::BtcMercyPublished { .. } + | AliceState::BtcMercyConfirmed { .. } | AliceState::BtcPunished { .. } | AliceState::BtcEarlyRefundable { .. } | AliceState::BtcEarlyRefunded(_) diff --git a/swap/src/asb/recovery/redeem.rs b/swap/src/asb/recovery/redeem.rs index 586463bbee..406bb6eb91 100644 --- a/swap/src/asb/recovery/redeem.rs +++ b/swap/src/asb/recovery/redeem.rs @@ -40,7 +40,9 @@ pub async fn redeem( tracing::info!(%swap_id, "Trying to redeem swap"); let redeem_tx = state3.signed_redeem_transaction(*encrypted_signature)?; - let (txid, subscription) = bitcoin_wallet.broadcast(redeem_tx, "redeem").await?; + let (txid, subscription) = bitcoin_wallet + .ensure_broadcasted(redeem_tx, "redeem") + .await?; subscription.wait_until_seen().await?; @@ -87,9 +89,16 @@ pub async fn redeem( | AliceState::CancelTimelockExpired { .. } | AliceState::BtcCancelled { .. } | AliceState::BtcRefunded { .. } + | AliceState::BtcPartiallyRefunded { .. } | AliceState::BtcPunishable { .. } | AliceState::BtcRedeemed - | AliceState::XmrRefunded + | AliceState::XmrRefunded { .. } + | AliceState::BtcWithholdPublished { .. } + | AliceState::BtcWithholdConfirmed { .. } + | AliceState::BtcMercyGranted { .. } + | AliceState::BtcMercyPublished { .. } + | AliceState::BtcMercyConfirmed { .. } + | AliceState::XmrRefundable { .. } | AliceState::BtcEarlyRefundable { .. } | AliceState::BtcEarlyRefunded(_) | AliceState::BtcPunished { .. } diff --git a/swap/src/asb/recovery/refund.rs b/swap/src/asb/recovery/refund.rs index b747af5bc7..1c46c307e2 100644 --- a/swap/src/asb/recovery/refund.rs +++ b/swap/src/asb/recovery/refund.rs @@ -51,6 +51,8 @@ pub async fn refund( // Refund possible due to cancel transaction already being published | AliceState::BtcCancelled { transfer_proof, state3, .. } | AliceState::BtcRefunded { transfer_proof, state3, .. } + | AliceState::BtcPartiallyRefunded { transfer_proof, state3, .. } + | AliceState::XmrRefundable { transfer_proof, state3, .. } | AliceState::BtcPunishable { transfer_proof, state3, .. } => { (transfer_proof, state3) } @@ -58,7 +60,12 @@ pub async fn refund( // Alice already in final state AliceState::BtcRedeemTransactionPublished { .. } | AliceState::BtcRedeemed - | AliceState::XmrRefunded + | AliceState::XmrRefunded { .. } + | AliceState::BtcWithholdPublished { .. } + | AliceState::BtcWithholdConfirmed { .. } + | AliceState::BtcMercyGranted { .. } + | AliceState::BtcMercyPublished { .. } + | AliceState::BtcMercyConfirmed { .. } | AliceState::BtcEarlyRefundable { .. } | AliceState::BtcEarlyRefunded(_) | AliceState::BtcPunished { .. } @@ -71,7 +78,7 @@ pub async fn refund( state3.fetch_tx_refund(bitcoin_wallet.as_ref()).await? { tracing::debug!(%swap_id, "Bitcoin refund transaction found, extracting key to refund Monero"); - state3.extract_monero_private_key(published_refund_tx)? + state3.extract_monero_private_key_from_refund(published_refund_tx)? } else { let bob_peer_id = db.get_peer_id(swap_id).await?; bail!(Error::RefundTransactionNotPublishedYet(bob_peer_id),); @@ -95,7 +102,9 @@ pub async fn refund( ) .await?; - let state = AliceState::XmrRefunded; + let state = AliceState::XmrRefunded { + state3: Some(state3), + }; db.insert_latest_state(swap_id, state.clone().into()) .await?; diff --git a/swap/src/asb/recovery/safely_abort.rs b/swap/src/asb/recovery/safely_abort.rs index f9e5812a34..779bf65033 100644 --- a/swap/src/asb/recovery/safely_abort.rs +++ b/swap/src/asb/recovery/safely_abort.rs @@ -29,9 +29,16 @@ pub async fn safely_abort(swap_id: Uuid, db: Arc) -> Result Result, ErrorObjectOwned> { - let swaps = self.db.all().await.into_json_rpc_result()?; + use crate::protocol::alice::{is_complete, AliceState}; + use crate::protocol::State; - let swaps = swaps - .into_iter() - .map(|(swap_id, state)| { - let state_str = match state { - crate::protocol::State::Alice(state) => format!("{state}"), - crate::protocol::State::Bob(state) => format!("{state}"), - }; - - Swap { - id: swap_id.to_string(), - state: state_str, - } - }) - .collect(); - - Ok(swaps) + let swaps = self + .db + .all() + .await + .context("Error fetching all swap's from database") + .into_json_rpc_result()?; + let mut results = Vec::with_capacity(swaps.len()); + + for (swap_id, _) in swaps { + let (current, starting) = self + .db + .get_current_and_starting_state(swap_id) + .await + .context("Error fetching current and first state from database") + .into_json_rpc_result()?; + + let (State::Alice(current_alice), State::Alice(AliceState::Started { state3 })) = + (current, starting) + else { + continue; // Skip non-Alice swaps + }; + + let start_date = self + .db + .get_swap_start_date(swap_id) + .await + .into_json_rpc_result()?; + let peer_id = self.db.get_peer_id(swap_id).await.into_json_rpc_result()?; + + // Exchange rate: BTC per XMR (amount of BTC needed to buy 1 XMR) + let rate_btc_per_xmr = state3.btc.to_btc() / state3.xmr.as_xmr(); + let exchange_rate = bitcoin::Amount::from_btc(rate_btc_per_xmr) + .context("exchange rate should be valid") + .into_json_rpc_result()?; + + results.push(Swap { + swap_id: swap_id.to_string(), + start_date, + state: current_alice.to_string(), + btc_lock_txid: state3.tx_lock.txid().to_string(), + btc_amount: state3.btc, + xmr_amount: state3.xmr.as_pico(), + exchange_rate, + peer_id: peer_id.to_string(), + completed: is_complete(¤t_alice), + }); + } + + Ok(results) } async fn registration_status(&self) -> Result { @@ -209,6 +244,28 @@ impl AsbApiServer for RpcImpl { Ok(RegistrationStatusResponse { registrations }) } + + async fn set_withhold_deposit( + &self, + swap_id: Uuid, + burn: bool, + ) -> Result<(), ErrorObjectOwned> { + self.event_loop_service + .set_withhold_deposit(swap_id, burn) + .await + .into_json_rpc_result()?; + + Ok(()) + } + + async fn grant_mercy(&self, swap_id: Uuid) -> Result<(), ErrorObjectOwned> { + self.event_loop_service + .grant_mercy(swap_id) + .await + .into_json_rpc_result()?; + + Ok(()) + } } trait IntoJsonRpcResult { diff --git a/swap/src/bin/swap.rs b/swap/src/bin/swap.rs index b0f5b875b6..f32999099a 100644 --- a/swap/src/bin/swap.rs +++ b/swap/src/bin/swap.rs @@ -46,7 +46,7 @@ mod tests { use std::time::Duration; use swap::cli::api::request::determine_btc_to_swap; use swap::cli::QuoteWithAddress; - use swap::network::quote::BidQuote; + use swap::network::quote::{BidQuote, RefundPolicyWire}; use tracing::level_filters::LevelFilter; use tracing_ext::capture_logs; @@ -424,6 +424,7 @@ mod tests { price: Amount::from_btc(0.001).unwrap(), max_quantity, min_quantity, + refund_policy: RefundPolicyWire::FullRefund, reserve_proof: None, }, version: Some("1.0.0".parse().unwrap()), diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index 8f0928d100..a9fca0aba7 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -139,7 +139,7 @@ impl Request for MoneroRecoveryArgs { #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct WithdrawBtcArgs { #[typeshare(serialized_as = "number")] - #[serde(default, with = "::bitcoin::amount::serde::as_sat::opt")] + #[serde(default)] pub amount: Option, #[typeshare(serialized_as = "string")] #[serde(with = "swap_serde::bitcoin::address_serde")] @@ -150,7 +150,6 @@ pub struct WithdrawBtcArgs { #[derive(Serialize, Deserialize, Debug)] pub struct WithdrawBtcResponse { #[typeshare(serialized_as = "number")] - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub amount: bitcoin::Amount, pub txid: String, } @@ -184,18 +183,14 @@ pub struct GetSwapInfoResponse { #[typeshare(serialized_as = "number")] pub xmr_amount: monero::Amount, #[typeshare(serialized_as = "number")] - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub btc_amount: bitcoin::Amount, #[typeshare(serialized_as = "string")] pub tx_lock_id: Txid, #[typeshare(serialized_as = "number")] - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub tx_cancel_fee: bitcoin::Amount, #[typeshare(serialized_as = "number")] - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub tx_refund_fee: bitcoin::Amount, #[typeshare(serialized_as = "number")] - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub tx_lock_fee: bitcoin::Amount, pub btc_refund_address: String, pub cancel_timelock: CancelTimelock, @@ -246,7 +241,6 @@ pub struct BalanceArgs { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct BalanceResponse { #[typeshare(serialized_as = "number")] - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub balance: bitcoin::Amount, } @@ -1802,13 +1796,20 @@ impl CheckElectrumNodeArgs { return Ok(CheckElectrumNodeResponse { available: false }); }; - // Check if the node is available - let res = - bitcoin_wallet::Client::new(&[url.as_str().to_string()], Duration::from_secs(60)).await; + // Check if the node is available by performing a lightweight RPC call. + // This forces a real connection and TLS handshake (for ssl:// URLs). + let mut client = + match bitcoin_wallet::Client::new(&[url.as_str().to_string()], Duration::from_secs(60)) + .await + { + Ok(client) => client, + Err(_) => return Ok(CheckElectrumNodeResponse { available: false }), + }; - Ok(CheckElectrumNodeResponse { - available: res.is_ok(), - }) + // Force a rpc call for blockchain height. + let available = client.update_state(true).await.is_ok(); + + Ok(CheckElectrumNodeResponse { available }) } } diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index 31d237e29c..64f312f7b2 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -88,16 +88,19 @@ pub struct ContextStatus { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct LockBitcoinDetails { #[typeshare(serialized_as = "number")] - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub btc_lock_amount: bitcoin::Amount, #[typeshare(serialized_as = "number")] - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub btc_network_fee: bitcoin::Amount, #[typeshare(serialized_as = "number")] pub xmr_receive_amount: monero::Amount, pub monero_receive_pool: MoneroAddressPool, #[typeshare(serialized_as = "string")] pub swap_id: Uuid, + /// The amount of Bitcoin the taker will only be able to refund with cooperation from the maker + #[typeshare(serialized_as = "number")] + pub btc_amnesty_amount: bitcoin::Amount, + /// Whether we can guarantee we'll get the full refund + pub has_full_refund_signature: bool, } #[typeshare] @@ -106,7 +109,6 @@ pub struct SelectMakerDetails { #[typeshare(serialized_as = "string")] pub swap_id: Uuid, #[typeshare(serialized_as = "number")] - #[serde(with = "::bitcoin::amount::serde::as_sat")] pub btc_amount_to_swap: bitcoin::Amount, pub maker: QuoteWithAddress, } @@ -479,10 +481,13 @@ impl bitcoin_wallet::BitcoinTauriBackgroundTask for TauriBackgroundProgressHandle { fn update(&self, consumed: u64, total: u64) { - self.update(TauriBitcoinFullScanProgress::Known { - current_index: consumed, - assumed_total: total, - }); + TauriBackgroundProgressHandle::update( + self, + TauriBitcoinFullScanProgress::Known { + current_index: consumed, + assumed_total: total, + }, + ); } fn finish(&self) { @@ -494,7 +499,10 @@ impl bitcoin_wallet::BitcoinTauriBackgroundTask for TauriBackgroundProgressHandle { fn update(&self, consumed: u64, total: u64) { - self.update(TauriBitcoinSyncProgress::Known { consumed, total }); + TauriBackgroundProgressHandle::update( + self, + TauriBitcoinSyncProgress::Known { consumed, total }, + ); } fn finish(&self) { @@ -1026,16 +1034,13 @@ pub enum TauriSwapProgressEvent { #[typeshare(serialized_as = "string")] deposit_address: bitcoin::Address, #[typeshare(serialized_as = "number")] - #[serde(with = "::bitcoin::amount::serde::as_sat")] max_giveable: bitcoin::Amount, #[typeshare(serialized_as = "number")] - #[serde(with = "::bitcoin::amount::serde::as_sat")] min_bitcoin_lock_tx_fee: bitcoin::Amount, known_quotes: Vec, }, SwapSetupInflight { #[typeshare(serialized_as = "number")] - #[serde(with = "::bitcoin::amount::serde::as_sat")] btc_lock_amount: bitcoin::Amount, }, RetrievingMoneroBlockheight, @@ -1093,6 +1098,24 @@ pub enum TauriSwapProgressEvent { #[typeshare(serialized_as = "string")] btc_refund_txid: Txid, }, + BtcPartialRefundPublished { + #[typeshare(serialized_as = "string")] + btc_partial_refund_txid: Txid, + #[typeshare(serialized_as = "number")] + btc_lock_amount: bitcoin::Amount, + #[typeshare(serialized_as = "number")] + btc_amnesty_amount: bitcoin::Amount, + }, + // BtcAmnesty was published but not yet confirmed. + // Requires BtcPartialRefund to be published first. + BtcAmnestyPublished { + #[typeshare(serialized_as = "string")] + btc_amnesty_txid: Txid, + #[typeshare(serialized_as = "number")] + btc_lock_amount: bitcoin::Amount, + #[typeshare(serialized_as = "number")] + btc_amnesty_amount: bitcoin::Amount, + }, // tx_early_refund has been confirmed BtcEarlyRefunded { #[typeshare(serialized_as = "string")] @@ -1103,6 +1126,75 @@ pub enum TauriSwapProgressEvent { #[typeshare(serialized_as = "string")] btc_refund_txid: Txid, }, + // We got partially refunded. Might still be able to get amnesty. + BtcPartiallyRefunded { + #[typeshare(serialized_as = "string")] + btc_partial_refund_txid: Txid, + #[typeshare(serialized_as = "number")] + btc_lock_amount: bitcoin::Amount, + #[typeshare(serialized_as = "number")] + btc_amnesty_amount: bitcoin::Amount, + }, + /// Waiting for the earnest deposit timelock to expire after partial refund confirmed. + WaitingForEarnestDepositTimelockExpiration { + #[typeshare(serialized_as = "string")] + btc_partial_refund_txid: Txid, + #[typeshare(serialized_as = "number")] + btc_lock_amount: bitcoin::Amount, + #[typeshare(serialized_as = "number")] + btc_amnesty_amount: bitcoin::Amount, + /// Total blocks required for timelock (target) + #[typeshare(serialized_as = "number")] + target_blocks: u32, + /// Blocks remaining until expiry + #[typeshare(serialized_as = "number")] + blocks_until_expiry: u32, + }, + // BtcAmnesty was confirmed. + BtcAmnestyReceived { + #[typeshare(serialized_as = "string")] + btc_amnesty_txid: Txid, + #[typeshare(serialized_as = "number")] + btc_lock_amount: bitcoin::Amount, + #[typeshare(serialized_as = "number")] + btc_amnesty_amount: bitcoin::Amount, + }, + // TxRefundBurn has been published (waiting for confirmation) + BtcRefundBurnPublished { + #[typeshare(serialized_as = "string")] + btc_refund_burn_txid: Txid, + #[typeshare(serialized_as = "number")] + btc_lock_amount: bitcoin::Amount, + #[typeshare(serialized_as = "number")] + btc_amnesty_amount: bitcoin::Amount, + }, + // TxRefundBurn has been confirmed - amnesty output is burnt + BtcRefundBurnt { + #[typeshare(serialized_as = "string")] + btc_refund_burn_txid: Txid, + #[typeshare(serialized_as = "number")] + btc_lock_amount: bitcoin::Amount, + #[typeshare(serialized_as = "number")] + btc_amnesty_amount: bitcoin::Amount, + }, + // Alice published TxFinalAmnesty + BtcFinalAmnestyPublished { + #[typeshare(serialized_as = "string")] + btc_final_amnesty_txid: Txid, + #[typeshare(serialized_as = "number")] + btc_lock_amount: bitcoin::Amount, + #[typeshare(serialized_as = "number")] + btc_amnesty_amount: bitcoin::Amount, + }, + // TxFinalAmnesty has been confirmed - user received burnt funds back + BtcFinalAmnestyConfirmed { + #[typeshare(serialized_as = "string")] + btc_final_amnesty_txid: Txid, + #[typeshare(serialized_as = "number")] + btc_lock_amount: bitcoin::Amount, + #[typeshare(serialized_as = "number")] + btc_amnesty_amount: bitcoin::Amount, + }, BtcPunished, AttemptingCooperativeRedeem, CooperativeRedeemAccepted, diff --git a/swap/src/cli/cancel_and_refund.rs b/swap/src/cli/cancel_and_refund.rs index 3768072391..252671e529 100644 --- a/swap/src/cli/cancel_and_refund.rs +++ b/swap/src/cli/cancel_and_refund.rs @@ -6,6 +6,7 @@ use bitcoin::Txid; use bitcoin_wallet::BitcoinWallet; use std::sync::Arc; use swap_core::bitcoin::ExpiredTimelocks; +use swap_machine::bob::RefundType; use uuid::Uuid; pub async fn cancel_and_refund( @@ -71,11 +72,22 @@ pub async fn cancel( BobState::BtcCancelled(state6) => state6, BobState::BtcRefundPublished(state6) => state6, BobState::BtcEarlyRefundPublished(state6) => state6, + BobState::BtcPartialRefundPublished(state6) => state6, + BobState::BtcPartiallyRefunded(state6) => state6, + BobState::BtcReclaimConfirmed(state6) => state6, + BobState::BtcReclaimPublished(state6) => state6, + BobState::WaitingForReclaimTimelockExpiration(state6) => state6, + BobState::ReclaimTimelockExpired(state6) => state6, + BobState::BtcWithholdPublished(state6) => state6, + BobState::BtcMercyPublished(state6) => state6, + BobState::Started { .. } | BobState::BtcRedeemed(_) | BobState::XmrRedeemed { .. } | BobState::BtcPunished { .. } | BobState::BtcEarlyRefunded { .. } + | BobState::BtcWithheld { .. } + | BobState::BtcMercyConfirmed { .. } | BobState::SafelyAborted => bail!( "Cannot cancel swap {} because it is in state {} which is not cancellable.", swap_id, @@ -139,6 +151,17 @@ pub async fn cancel( Ok(ExpiredTimelocks::Cancel { .. }) => { bail!(err.context("Failed to cancel swap even though cancel timelock has expired. This is unexpected.")); } + Ok(ExpiredTimelocks::WaitingForRemainingRefund { blocks_left }) => { + bail!(err.context( + format!( + "Cannot cancel swap because partial refund is already in progress. Waiting {} blocks for amnesty timelock.", + blocks_left + ) + )); + } + Ok(ExpiredTimelocks::RemainingRefund) => { + bail!(err.context("Cannot cancel swap because we are in the partial refund phase. TxRefundAmnesty can be published.")); + } Err(timelock_err) => { bail!(err .context(timelock_err) @@ -187,12 +210,22 @@ pub async fn refund( BobState::BtcRefunded(state6) => state6, BobState::BtcRefundPublished(state6) => state6, BobState::BtcEarlyRefundPublished(state6) => state6, + BobState::BtcPartialRefundPublished(state6) => state6, + BobState::BtcPartiallyRefunded(state6) => state6, + BobState::BtcReclaimPublished(state6) => state6, + BobState::BtcReclaimConfirmed(state6) => state6, + BobState::WaitingForReclaimTimelockExpiration(state6) => state6, + BobState::ReclaimTimelockExpired(state6) => state6, + BobState::BtcWithholdPublished(state6) => state6, + BobState::BtcMercyPublished(state6) => state6, BobState::Started { .. } | BobState::SwapSetupCompleted(_) | BobState::BtcRedeemed(_) | BobState::BtcEarlyRefunded { .. } | BobState::XmrRedeemed { .. } | BobState::BtcPunished { .. } + | BobState::BtcWithheld { .. } + | BobState::BtcMercyConfirmed { .. } | BobState::SafelyAborted => bail!( "Cannot refund swap {} because it is in state {} which is not refundable.", swap_id, @@ -202,14 +235,39 @@ pub async fn refund( tracing::info!(%swap_id, "Attempting to manually refund swap"); + let (refund_tx, refund_type) = state6.construct_best_bitcoin_refund_tx().await?; + + tracing::info!("Attempting to publish Bitcoin refund transaction. Refund type: {refund_type}"); + // Attempt to just publish the refund transaction - match state6.publish_refund_btc(bitcoin_wallet.as_ref()).await { - Ok(_) => { - let state = BobState::BtcRefunded(state6); - db.insert_latest_state(swap_id, state.clone().into()) + match bitcoin_wallet + .ensure_broadcasted(refund_tx, &refund_type.to_string()) + .await + { + Ok((_txid, subscription)) => { + // First save the "published" state + let published_state = match &refund_type { + RefundType::Full => BobState::BtcRefundPublished(state6.clone()), + RefundType::Partial { .. } => BobState::BtcPartialRefundPublished(state6.clone()), + }; + + db.insert_latest_state(swap_id, published_state.into()) .await?; - Ok(state) + // Wait for the transaction to be confirmed + tracing::info!("Waiting for refund transaction to be confirmed..."); + subscription.wait_until_final().await?; + + // Now save and return the confirmed state + let confirmed_state = match refund_type { + RefundType::Full => BobState::BtcRefunded(state6), + RefundType::Partial { .. } => BobState::BtcPartiallyRefunded(state6), + }; + + db.insert_latest_state(swap_id, confirmed_state.clone().into()) + .await?; + + Ok(confirmed_state) } // If we fail to submit the refund transaction it can have one of two reasons: @@ -240,6 +298,20 @@ pub async fn refund( Ok(ExpiredTimelocks::Cancel { .. }) => { bail!(bitcoin_publication_err.context("Failed to refund swap even though cancel timelock has expired. This is unexpected.")); } + Ok(ExpiredTimelocks::WaitingForRemainingRefund { blocks_left }) => { + bail!( + bitcoin_publication_err.context(format!( + "Cannot refund swap yet. Partial refund was published but waiting {} blocks for amnesty timelock to expire.", + blocks_left + )) + ); + } + Ok(ExpiredTimelocks::RemainingRefund) => { + // TODO: Try to publish TxRefundAmnesty here instead of just reporting the state + bail!(bitcoin_publication_err.context( + "Amnesty timelock has expired. TxRefundAmnesty can be published." + )); + } Err(e) => { bail!(bitcoin_publication_err .context(e) diff --git a/swap/src/cli/command.rs b/swap/src/cli/command.rs index 3185caa949..383afae150 100644 --- a/swap/src/cli/command.rs +++ b/swap/src/cli/command.rs @@ -397,7 +397,6 @@ mod tests { use super::*; use crate::cli::api::api_test::*; - use swap_serde::monero::address::MoneroAddressNetworkMismatch; const BINARY_NAME: &str = "swap"; const ARGS_DATA_DIR: &str = "/tmp/dir/"; @@ -416,53 +415,6 @@ mod tests { .unwrap(); } - #[tokio::test] - async fn given_buy_xmr_on_mainnet_with_testnet_address_then_fails() { - let raw_ars = [ - BINARY_NAME, - "buy-xmr", - "--receive-address", - MONERO_STAGENET_ADDRESS, - "--change-address", - BITCOIN_TESTNET_ADDRESS, - "--seller", - MULTI_ADDRESS, - ]; - - let err = parse_args_and_apply_defaults(raw_ars).await.unwrap_err(); - assert_eq!( - err.downcast_ref::().unwrap(), - &MoneroAddressNetworkMismatch { - expected: monero_address::Network::Mainnet, - actual: monero_address::Network::Stagenet - } - ); - } - - #[tokio::test] - async fn given_buy_xmr_on_testnet_with_mainnet_address_then_fails() { - let raw_ars = [ - BINARY_NAME, - "--testnet", - "buy-xmr", - "--receive-address", - MONERO_MAINNET_ADDRESS, - "--change-address", - BITCOIN_MAINNET_ADDRESS, - "--seller", - MULTI_ADDRESS, - ]; - - let err = parse_args_and_apply_defaults(raw_ars).await.unwrap_err(); - assert_eq!( - err.downcast_ref::().unwrap(), - &MoneroAddressNetworkMismatch { - expected: monero_address::Network::Stagenet, - actual: monero_address::Network::Mainnet - } - ); - } - #[tokio::test] async fn given_resume_on_mainnet_then_defaults_to_mainnet() { let raw_ars = [BINARY_NAME, "resume", "--swap-id", SWAP_ID]; @@ -602,66 +554,4 @@ mod tests { simple_positive(&raw_ars, (true, true, None), cli_cmd).await; } - #[tokio::test] - async fn only_bech32_addresses_mainnet_are_allowed() { - // TODO: not apply defaults - let mut raw_ars = [ - BINARY_NAME, - "buy-xmr", - "--change-address", - "", - "--receive-address", - MONERO_MAINNET_ADDRESS, - "--seller", - MULTI_ADDRESS, - ]; - raw_ars[3] = "1A5btpLKZjgYm8R22rJAhdbTFVXgSRA2Mp"; - parse_args_and_custom(raw_ars, async |_, _, _| unreachable!()) - .await - .unwrap_err(); - - raw_ars[3] = "36vn4mFhmTXn7YcNwELFPxTXhjorw2ppu2"; - parse_args_and_custom(raw_ars, async |_, _, _| unreachable!()) - .await - .unwrap_err(); - - raw_ars[3] = "bc1qh4zjxrqe3trzg7s6m7y67q2jzrw3ru5mx3z7j3"; - let ParseResult::Success(_) = parse_args_and_custom(raw_ars, async |_, _, _| Ok(())) - .await - .unwrap() - else { - panic!() - }; - } - - #[tokio::test] - async fn only_bech32_addresses_testnet_are_allowed() { - let mut raw_ars = [ - BINARY_NAME, - "--testnet", - "buy-xmr", - "--change-address", - "", - "--receive-address", - MONERO_STAGENET_ADDRESS, - "--seller", - MULTI_ADDRESS, - ]; - raw_ars[4] = "n2czxyeFCQp9e8WRyGpy4oL4YfQAeKkkUH"; - parse_args_and_custom(raw_ars, async |_, _, _| unreachable!()) - .await - .unwrap_err(); - raw_ars[4] = "2ND9a4xmQG89qEWG3ETRuytjKpLmGrW7Jvf"; - parse_args_and_custom(raw_ars, async |_, _, _| unreachable!()) - .await - .unwrap_err(); - - raw_ars[4] = "tb1q958vfh3wkdp232pktq8zzvmttyxeqnj80zkz3v"; - let ParseResult::Success(_) = parse_args_and_custom(raw_ars, async |_, _, _| Ok(())) - .await - .unwrap() - else { - panic!() - }; - } } diff --git a/swap/src/cli/watcher.rs b/swap/src/cli/watcher.rs index 299f30b2f4..7dc542f3b4 100644 --- a/swap/src/cli/watcher.rs +++ b/swap/src/cli/watcher.rs @@ -107,7 +107,7 @@ impl Watcher { self.cached_timelocks.insert(swap_id, new_timelock_status); // If the swap has to be refunded, do it in the background - if let Some(ExpiredTimelocks::Cancel { .. }) = new_timelock_status { + if matches!(new_timelock_status, Some(ExpiredTimelocks::Cancel { .. }) | Some(ExpiredTimelocks::RemainingRefund)) { // If the swap is already refunded, we can skip the refund if matches!(state, BobState::BtcRefunded(_)) { continue; diff --git a/swap/src/common/tracing_util.rs b/swap/src/common/tracing_util.rs index 53cfff7c2f..6e785b529f 100644 --- a/swap/src/common/tracing_util.rs +++ b/swap/src/common/tracing_util.rs @@ -1,4 +1,4 @@ -use std::io::{self, IsTerminal}; +use std::io; use std::path::Path; use std::str::FromStr; @@ -110,10 +110,9 @@ pub fn init( ); // Layer for writing to the terminal - let is_terminal = std::io::stderr().is_terminal(); let terminal_layer = fmt::layer() .with_writer(std::io::stderr) - .with_ansi(is_terminal) + .with_ansi(true) .with_timer(UtcTime::rfc_3339()) .with_target(true) .with_file(true) diff --git a/swap/src/protocol/alice/swap.rs b/swap/src/protocol/alice/swap.rs index 1d9d9e8941..b2f5a5cbfe 100644 --- a/swap/src/protocol/alice/swap.rs +++ b/swap/src/protocol/alice/swap.rs @@ -272,7 +272,7 @@ where // Retry repeatedly to broadcast tx_early_refund result = async { backoff::future::retry_notify(backoff, || async { - bitcoin_wallet.broadcast(tx_early_refund.clone(), "early_refund").await.map_err(backoff::Error::transient) + bitcoin_wallet.ensure_broadcasted(tx_early_refund.clone(), "early_refund").await.map_err(backoff::Error::transient) }, |e, wait_time: Duration| { tracing::warn!( %tx_early_refund_txid, @@ -425,6 +425,17 @@ where state3, } } + burn_instruction = event_loop_handle.wait_for_burn_on_refund_instruction() => { + let burn = burn_instruction.context("Failed to receive burn instruction")?; + let mut updated_state3 = (*state3).clone(); + updated_state3.should_publish_tx_refund_burn = Some(burn); + + AliceState::XmrLockTransferProofSent { + monero_wallet_restore_blockheight, + transfer_proof, + state3: Box::new(updated_state3), + } + } } } AliceState::EncSigLearned { @@ -480,7 +491,7 @@ where } bitcoin_wallet - .broadcast(tx_redeem.clone(), "redeem") + .ensure_broadcasted(tx_redeem.clone(), "redeem") .await .map(Some) .map_err(backoff::Error::transient) @@ -543,15 +554,26 @@ where .subscribe_to(Box::new(state3.tx_lock.clone())) .await; - // TODO: Retry here - tx_lock_status_subscription - .wait_until_confirmed_with(state3.cancel_timelock) - .await?; + select! { + result = tx_lock_status_subscription.wait_until_confirmed_with(state3.cancel_timelock) => { + result?; + AliceState::CancelTimelockExpired { + monero_wallet_restore_blockheight, + transfer_proof, + state3, + } + } + burn_instruction = event_loop_handle.wait_for_burn_on_refund_instruction() => { + let burn = burn_instruction.context("Failed to receive burn instruction")?; + let mut updated_state3 = (*state3).clone(); + updated_state3.should_publish_tx_refund_burn = Some(burn); - AliceState::CancelTimelockExpired { - monero_wallet_restore_blockheight, - transfer_proof, - state3, + AliceState::WaitingForCancelTimelockExpiration { + monero_wallet_restore_blockheight, + transfer_proof, + state3: Box::new(updated_state3), + } + } } } AliceState::CancelTimelockExpired { @@ -613,8 +635,23 @@ where .subscribe_to(Box::new(state3.tx_cancel())) .await; + // We wait for either TxFullRefund or TxPartialRefund to be published + // - both allow us to extract the Monero refund key. + // Otherwise we punish, once that timelock expired. + + // TODO: should we retry here? select! { - spend_key = state3.watch_for_btc_tx_refund(&*bitcoin_wallet) => { + spend_key = state3.watch_for_btc_tx_full_refund(&*bitcoin_wallet) => { + let spend_key = spend_key?; + + AliceState::BtcRefunded { + monero_wallet_restore_blockheight, + transfer_proof, + spend_key, + state3, + } + } + spend_key = state3.watch_for_btc_tx_partial_refund(&*bitcoin_wallet) => { let spend_key = spend_key?; AliceState::BtcRefunded { @@ -633,13 +670,51 @@ where state3, } } + burn_instruction = event_loop_handle.wait_for_burn_on_refund_instruction() => { + let burn = burn_instruction.context("Failed to receive burn instruction")?; + let mut updated_state3 = (*state3).clone(); + updated_state3.should_publish_tx_refund_burn = Some(burn); + + AliceState::BtcCancelled { + monero_wallet_restore_blockheight, + transfer_proof, + state3: Box::new(updated_state3), + } + } } } AliceState::BtcRefunded { transfer_proof, spend_key, state3, - .. + monero_wallet_restore_blockheight, + } => AliceState::XmrRefundable { + monero_wallet_restore_blockheight, + transfer_proof, + spend_key, + state3, + }, + AliceState::BtcPartiallyRefunded { + transfer_proof, + spend_key, + state3, + monero_wallet_restore_blockheight, + } => { + // Bob has the pre-signed TxRefundAmnesty from swap setup and can + // publish it himself after the remaining refund timelock expires. + // TODO: implement system for publishing TxRefundBurn at this point + AliceState::XmrRefundable { + monero_wallet_restore_blockheight, + transfer_proof, + spend_key, + state3, + } + } + AliceState::XmrRefundable { + monero_wallet_restore_blockheight: _, + transfer_proof, + spend_key, + state3, } => { retry( "Refund Monero", @@ -660,7 +735,9 @@ where .await .expect("We should never run out of retries while refunding Monero"); - AliceState::XmrRefunded + AliceState::XmrRefunded { + state3: Some(state3), + } } AliceState::BtcPunishable { monero_wallet_restore_blockheight, @@ -697,7 +774,87 @@ where .await .expect("We should never run out of retries while publishing the punish transaction") } - AliceState::XmrRefunded => AliceState::XmrRefunded, + AliceState::XmrRefunded { state3 } => { + // Only publish TxRefundBurn + let Some(mut state3) = state3 else { + tracing::info!( + "Running a pre-partial refund swap, there is no amnesty output to burn" + ); + return Ok(AliceState::XmrRefunded { state3: None }); + }; + + // Fetch the burn decision, if it was made via the controller + if let Some(burn_decision) = event_loop_handle.get_burn_on_refund_instruction().await { + state3.should_publish_tx_refund_burn = Some(burn_decision); + } + + if !state3.should_publish_tx_refund_burn.unwrap_or(false) { + tracing::info!("Not instructed to partially burn the takers refund. Finishing"); + return Ok(AliceState::XmrRefunded { + state3: Some(state3), + }); + } + + let signed_tx = state3.signed_refund_burn_transaction().context("Can't burn the amnesty output after Bob refunded because we couldn't construct the ")?; + + bitcoin_wallet + .ensure_broadcasted(signed_tx, "refund_burn") + .await + .context("Couldn't publish TxRefundBurn")?; + + AliceState::BtcWithholdPublished { state3 } + } + AliceState::BtcWithholdPublished { state3 } => { + let tx_refund_burn = state3 + .tx_refund_burn() + .context("Can't construct TxRefundBurn even though we published it")?; + + let subscription = bitcoin_wallet.subscribe_to(Box::new(tx_refund_burn)).await; + + subscription + .wait_until_final() + .await + .context("Failed to wait for TxRefundBurn to be confirmed")?; + + AliceState::BtcWithholdConfirmed { state3 } + } + AliceState::BtcWithholdConfirmed { state3 } => { + // Nothing to do here. Final amnesty is triggered manually. + AliceState::BtcWithholdConfirmed { state3 } + } + AliceState::BtcMercyGranted { state3 } => { + // Operator has decided to grant final amnesty to Bob + let signed_tx = state3 + .signed_final_amnesty_transaction() + .context("Failed to construct signed TxFinalAmnesty")?; + + bitcoin_wallet + .ensure_broadcasted(signed_tx, "final_amnesty") + .await + .context("Failed to publish TxFinalAmnesty")?; + + tracing::info!("TxFinalAmnesty published successfully"); + + AliceState::BtcMercyPublished { state3 } + } + AliceState::BtcMercyPublished { state3 } => { + // Wait for TxFinalAmnesty to be confirmed + let tx_final_amnesty = state3 + .tx_final_amnesty() + .context("Couldn't construct TxFinalAmnesty even though we have published it")?; + + let subscription = bitcoin_wallet + .subscribe_to(Box::new(tx_final_amnesty)) + .await; + + subscription + .wait_until_final() + .await + .context("Failed to wait for TxFinalAmnesty to be confirmed")?; + + AliceState::BtcMercyConfirmed { state3 } + } + AliceState::BtcMercyConfirmed { state3 } => AliceState::BtcMercyConfirmed { state3 }, AliceState::BtcRedeemed => AliceState::BtcRedeemed, AliceState::BtcPunished { state3, @@ -840,28 +997,91 @@ pub(crate) fn has_already_processed_enc_sig(state: &AliceState) -> bool { #[cfg(test)] mod tests { + use super::build_transfer_destinations; + use crate::protocol::alice::TipConfig; + use rust_decimal::Decimal; + + const TEST_ADDRESS_STR: &str = "53gEuGZUhP9JMEBZoGaFNzhwEgiG7hwQdMCqFxiyiTeFPmkbt1mAoNybEUvYBKHcnrSgxnVWgZsTvRBaHBNXPa8tHiCU51a"; + + fn test_address() -> monero_address::MoneroAddress { + monero_address::MoneroAddress::from_str_with_unchecked_network(TEST_ADDRESS_STR).unwrap() + } + #[test] fn test_build_transfer_destinations_without_tip() { - todo!("implement once unit tests compile again") + let lock_amount = monero_oxide_ext::Amount::from_pico(1_000_000_000_000); // 1 XMR + let tip = TipConfig { + ratio: Decimal::ZERO, + address: test_address(), + }; + + let result = build_transfer_destinations(test_address(), lock_amount, tip).unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].1, lock_amount); } #[test] fn test_build_transfer_destinations_with_tip() { - todo!("implement once unit tests compile again") + let lock_amount = monero_oxide_ext::Amount::from_pico(10_000_000_000_000); // 10 XMR + let tip = TipConfig { + ratio: Decimal::new(1, 2), // 0.01 = 1% + address: test_address(), + }; + + let result = build_transfer_destinations(test_address(), lock_amount, tip).unwrap(); + + // Tip = 10 XMR * 0.01 = 0.1 XMR = 100_000_000_000 pico >> 30_000_000 threshold + assert_eq!(result.len(), 2); + assert_eq!(result[0].1, lock_amount); + assert_eq!(result[1].1, monero_oxide_ext::Amount::from_pico(100_000_000_000)); } #[test] fn test_build_transfer_destinations_with_small_tip() { - todo!("implement once unit tests compile again") + // ratio * amount < 30_000_000 piconero threshold + let lock_amount = monero_oxide_ext::Amount::from_pico(2_000_000_000); // 0.002 XMR + let tip = TipConfig { + ratio: Decimal::new(1, 2), // 0.01 + address: test_address(), + }; + + let result = build_transfer_destinations(test_address(), lock_amount, tip).unwrap(); + + // Tip = 0.002 XMR * 0.01 = 20_000_000 piconero < 30_000_000 threshold + assert_eq!(result.len(), 1); + assert_eq!(result[0].1, lock_amount); } #[test] fn test_build_transfer_destinations_with_zero_tip() { - todo!("implement once unit tests compile again") + // Nonzero ratio but tiny lock amount → effective tip rounds to near-zero + let lock_amount = monero_oxide_ext::Amount::from_pico(100); + let tip = TipConfig { + ratio: Decimal::new(1, 1), // 0.1 = 10% + address: test_address(), + }; + + let result = build_transfer_destinations(test_address(), lock_amount, tip).unwrap(); + + // Tip = 100 * 0.1 = 10 piconero << 30_000_000 threshold + assert_eq!(result.len(), 1); + assert_eq!(result[0].1, lock_amount); } #[test] fn test_build_transfer_destinations_with_fractional_tip() { - todo!("implement once unit tests compile again") + let lock_amount = monero_oxide_ext::Amount::from_pico(1_000_000_000_000); // 1 XMR + let tip = TipConfig { + ratio: Decimal::new(5, 3), // 0.005 = 0.5% + address: test_address(), + }; + + let result = build_transfer_destinations(test_address(), lock_amount, tip).unwrap(); + + // Tip = 1 XMR * 0.005 = 0.005 XMR = 5_000_000_000 pico >> 30_000_000 threshold + assert_eq!(result.len(), 2); + assert_eq!(result[0].1, lock_amount); + assert_eq!(result[1].1, monero_oxide_ext::Amount::from_pico(5_000_000_000)); } } diff --git a/swap/src/protocol/bob/swap.rs b/swap/src/protocol/bob/swap.rs index 5933bb10fc..b1173a9df3 100644 --- a/swap/src/protocol/bob/swap.rs +++ b/swap/src/protocol/bob/swap.rs @@ -13,10 +13,12 @@ use crate::protocol::bob::common::{ }; use crate::protocol::bob::*; use crate::protocol::{bob, Database}; -use anyhow::{Context as AnyContext, Result}; +use anyhow::{Context as AnyContext, Result, anyhow}; use std::sync::Arc; use std::time::Duration; -use swap_core::bitcoin::{ExpiredTimelocks, TxCancel, TxRefund}; +use swap_core::bitcoin::{ + ExpiredTimelocks, TxCancel, TxMercy, TxFullRefund, TxPartialRefund, TxReclaim, +}; use swap_core::monero::BlockHeight; use swap_env::env; use tokio::select; @@ -46,8 +48,10 @@ pub fn has_already_processed_transfer_proof(state: &BobState) -> bool { // - We want to attempt recovery via cooperative XMR redeem once. // - If unsuccessful, we exit to avoid an infinite retry loop. // - The swap can still be manually resumed later and retried if desired. +// +// The same is true for the BtcRefundBurnt. pub fn is_run_at_most_once(state: &BobState) -> bool { - matches!(state, BobState::BtcPunished { .. }) + matches!(state, BobState::BtcPunished { .. } | BobState::BtcWithheld(..)) } #[allow(clippy::too_many_arguments)] @@ -111,12 +115,26 @@ async fn next_state( change_address, tx_lock_fee, } => { - let tx_refund_fee = bitcoin_wallet - .estimate_fee(TxRefund::weight(), Some(btc_amount)) - .await?; let tx_cancel_fee = bitcoin_wallet .estimate_fee(TxCancel::weight(), Some(btc_amount)) .await?; + let tx_refund_fee = bitcoin_wallet + .estimate_fee(TxFullRefund::weight(), Some(btc_amount)) + .await?; + + // At this point we don't know how high btc_amnesty_amount is. + // This means we don't know how large the amount of the partial refund and amnesty transactions will be. + // We therefore specify the same upper limit on tx fees as for the other transactions, even though + // the maximum fee percentage might be higher due to that. + let tx_partial_refund_fee = bitcoin_wallet + .estimate_fee(TxPartialRefund::weight(), Some(btc_amount)) + .await?; + let tx_refund_amnesty_fee = bitcoin_wallet + .estimate_fee(TxReclaim::weight(), Some(btc_amount)) + .await?; + let tx_final_amnesty_fee = bitcoin_wallet + .estimate_fee(TxMercy::weight(), Some(btc_amount)) + .await?; // Emit an event to tauri that we are negotiating with the maker to lock the Bitcoin event_emitter.emit_swap_progress_event( @@ -132,6 +150,9 @@ async fn next_state( btc: btc_amount, tx_lock_fee, tx_refund_fee, + tx_partial_refund_fee, + tx_refund_amnesty_fee, + tx_final_amnesty_fee, tx_cancel_fee, bitcoin_refund_address: change_address, }) @@ -144,6 +165,7 @@ async fn next_state( BobState::SwapSetupCompleted(state2) => { // Alice and Bob have exchanged all necessary signatures let xmr_receive_amount = state2.xmr; + let btc_amnesty_amount = state2.btc_amnesty_amount.context("btc_amnesty_amount missing")?; // Sign the Bitcoin lock transaction let (state3, tx_lock) = state2.lock_btc().await?; @@ -162,9 +184,11 @@ async fn next_state( let details = LockBitcoinDetails { btc_lock_amount, btc_network_fee, + btc_amnesty_amount, xmr_receive_amount, monero_receive_pool, swap_id, + has_full_refund_signature: state3.refund_signatures.has_full_refund_encsig() }; // We request approval before publishing the Bitcoin lock transaction, @@ -245,29 +269,10 @@ async fn next_state( .emit_swap_progress_event(swap_id, TauriSwapProgressEvent::BtcLockPublishInflight); retry( - "Check and publish Bitcoin lock transaction", + "Publish Bitcoin lock transaction", || async { - // TODO: We could also only check this if the broadcast failed - - // Check if the transaction has already been broadcasted. - // It could be that the operation was aborted after the transaction reached the Electrum server - // but before we transitioned to the BtcLocked state - tracing::debug!(txid = %state3.tx_lock_id(), "Checking if Bitcoin lock transaction has already been published"); - - if state3 - .is_tx_lock_published(&*bitcoin_wallet) - .await - .map_err(backoff::Error::transient)? - { - tracing::info!(txid = %state3.tx_lock_id(), "Bitcoin lock transaction already published, skipping publish"); - return Ok(()); - } - - // Publish the signed Bitcoin lock transaction - tracing::info!(txid = %state3.tx_lock_id(), "Publishing Bitcoin lock transaction"); - bitcoin_wallet - .broadcast(btc_lock_tx_signed.clone(), "lock") + .ensure_broadcasted(btc_lock_tx_signed.clone(), "lock") .await .map_err(backoff::Error::transient)?; @@ -277,7 +282,7 @@ async fn next_state( None, ) .await - .context("Failed to check/publish Bitcoin lock transaction")?; + .context("Failed to publish Bitcoin lock transaction")?; BobState::BtcLocked { state3, @@ -844,6 +849,7 @@ async fn next_state( let bitcoin_wallet_for_retry = bitcoin_wallet.clone(); let state_for_retry = state.clone(); + retry( "Check timelocks and try to refund", || { @@ -857,11 +863,32 @@ async fn next_state( ))) } ExpiredTimelocks::Cancel { .. } => { - let btc_refund_txid = state.publish_refund_btc(&*bitcoin_wallet).await.context("Failed to publish refund transaction after ensuring cancel timelock has expired and refund timelock has not expired").map_err(backoff::Error::transient)?; - - tracing::info!(%btc_refund_txid, "Refunded our Bitcoin"); - - Ok(BobState::BtcRefundPublished(state.clone())) + // Publish the best Bitcoin refund transaction we can sign: + // - either full refund, if alice sent use that signature (prioritized) + // - or just partial refund. + tracing::debug!("Attempting to refund Bitcoin"); + + if state.refund_signatures.has_full_refund_encsig() { + let full_refund_tx = state.signed_full_refund_transaction().context("Couldn't construct full refund Bitcoin transaction")?; + tracing::debug!("Have full refund signature, attempting full refund"); + bitcoin_wallet.ensure_broadcasted(full_refund_tx, "full refund") + .await + .context("Couldn't ensure broadcast of Bitcoin full refund transaction") + .map_err(backoff::Error::transient)?; + + Ok(BobState::BtcRefundPublished(state.clone())) + } else if state.refund_signatures.has_partial_refund_encsig() { + let partial_refund_tx = state.signed_partial_refund_transaction().context("Couldn't construct partial refund Bitcoin transaction")?; + tracing::debug!("Don't have full refund signature, attempting partial refund"); + bitcoin_wallet.ensure_broadcasted(partial_refund_tx, "partial refund") + .await + .context("Couldn't ensure broadcast of Bitcoin partial refund transaction") + .map_err(backoff::Error::transient)?; + + Ok(BobState::BtcPartialRefundPublished(state.clone())) + } else { + Err(backoff::Error::permanent(anyhow!("Unreachable - We have neither partial nor full refund signatures"))) + } } ExpiredTimelocks::Punish => { let tx_lock_id = state.tx_lock_id(); @@ -871,6 +898,22 @@ async fn next_state( state, }) } + ExpiredTimelocks::WaitingForRemainingRefund { blocks_left } => { + // TxPartialRefund has been published, waiting for remaining_refund_timelock + // This is unusual from BtcCancelled state - means we published partial refund but crashed + // Retry until timelock expires + tracing::debug!("Partial refund published, waiting {} blocks for amnesty timelock", blocks_left); + Err(backoff::Error::transient(anyhow::anyhow!( + "Waiting for remaining refund timelock to expire. Blocks left: {}", + blocks_left + ))) + } + ExpiredTimelocks::RemainingRefund => { + // TxPartialRefund was published and timelock expired - publish TxRefundAmnesty + // Transition to BtcPartiallyRefunded which handles amnesty publication + tracing::info!("Remaining refund timelock expired, can publish amnesty transaction"); + Ok(BobState::BtcPartiallyRefunded(state)) + } } } }, @@ -885,7 +928,7 @@ async fn next_state( event_emitter.emit_swap_progress_event( swap_id, TauriSwapProgressEvent::BtcRefundPublished { - btc_refund_txid: state.signed_refund_transaction()?.compute_txid(), + btc_refund_txid: state.signed_full_refund_transaction()?.compute_txid(), }, ); @@ -978,16 +1021,99 @@ async fn next_state( }, } } + BobState::BtcPartialRefundPublished(state)=> { + // 1. Emit a Tauri event + event_emitter.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::BtcPartialRefundPublished { + btc_partial_refund_txid: state.construct_tx_partial_refund()?.txid(), + btc_lock_amount: state.tx_lock.lock_amount(), + btc_amnesty_amount: state.btc_amnesty_amount.unwrap_or(bitcoin::Amount::ZERO), + }, + ); + + // TxEarlyRefund might still get published+confirmed before the PartialRefund gets confirmed + // 2. Wait for either refund transaction to be confirmed + + let tx_partial_refund = state.construct_tx_partial_refund()?; + let tx_early_refund = state.construct_tx_early_refund(); + + let (tx_partial_refund_status, tx_early_refund_status) = tokio::join!( + bitcoin_wallet.subscribe_to(Box::new(tx_partial_refund.clone())), + bitcoin_wallet.subscribe_to(Box::new(tx_early_refund.clone())), + ); + + select!{ + _ = tx_partial_refund_status.wait_until_final() => { + tracing::info!("TxPartialRefund has been confirmed"); + BobState::BtcPartiallyRefunded(state) + } + _ = tx_early_refund_status.wait_until_final() => { + tracing::info!("TxEarlyRefund has been confirmed"); + BobState::BtcEarlyRefunded(state) + } + } + } + BobState::BtcPartiallyRefunded(state) => { + event_emitter.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::BtcPartiallyRefunded { + btc_partial_refund_txid: state.construct_tx_partial_refund()?.txid(), + btc_lock_amount: state.tx_lock.lock_amount(), + btc_amnesty_amount: state.btc_amnesty_amount.unwrap_or(bitcoin::Amount::ZERO), + }, + ); + + // Transition to waiting state where we race remaining_refund_timelock + // against Alice potentially publishing TxRefundBurn + BobState::WaitingForReclaimTimelockExpiration(state) + } BobState::BtcRefunded(state) => { event_emitter.emit_swap_progress_event( swap_id, TauriSwapProgressEvent::BtcRefunded { - btc_refund_txid: state.signed_refund_transaction()?.compute_txid(), + btc_refund_txid: state.signed_full_refund_transaction()?.compute_txid(), }, ); BobState::BtcRefunded(state) } + BobState::BtcReclaimPublished(state) => { + // Here we just wait for the amnesty transaction to be confirmed + let tx_amnesty = state.construct_tx_amnesty().context("Couldn't construct Bitcoin amnesty transaction")?; + + event_emitter.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::BtcAmnestyPublished { + btc_amnesty_txid: tx_amnesty.txid(), + btc_lock_amount: state.tx_lock.lock_amount(), + btc_amnesty_amount: state.btc_amnesty_amount.unwrap_or(bitcoin::Amount::ZERO), + }, + ); + + let subscription = bitcoin_wallet.subscribe_to(Box::new(tx_amnesty.clone())).await; + + retry("Waiting for Bitcoin amnesty transaction to be published by Alice", || async { + subscription.clone() + .wait_until_final() + .await + .context("Failed to wait for Bitcoin amnesty transaction to be confirmed") + .map_err(backoff::Error::transient)?; + + event_emitter.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::BtcAmnestyReceived { + btc_amnesty_txid: state.construct_tx_amnesty()?.txid(), + btc_lock_amount: state.tx_lock.lock_amount(), + btc_amnesty_amount: state.btc_amnesty_amount.unwrap_or(bitcoin::Amount::ZERO), + }, + ); + + Ok(BobState::BtcReclaimConfirmed(state.clone())) + }, None, None) + .await + .context("Failed to wait for Bitcoin amnesty transaction to be confirmed")? + } BobState::BtcPunished { state, tx_lock_id } => { tracing::info!("You have been punished for not refunding in time"); event_emitter.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::BtcPunished); @@ -1120,8 +1246,171 @@ async fn next_state( } }; } - // TODO: Emit a Tauri event here - BobState::BtcEarlyRefunded(state) => BobState::BtcEarlyRefunded(state), + BobState::BtcEarlyRefunded(state) => { + event_emitter.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::BtcEarlyRefunded { + btc_early_refund_txid: state.construct_tx_early_refund().txid(), + }); + BobState::BtcEarlyRefunded(state) + }, + BobState::BtcReclaimConfirmed(state) => { + event_emitter.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::BtcAmnestyReceived { + btc_amnesty_txid: state.construct_tx_amnesty()?.txid(), + btc_lock_amount: state.tx_lock.lock_amount(), + btc_amnesty_amount: state.btc_amnesty_amount.unwrap_or(bitcoin::Amount::ZERO), + }); + BobState::BtcReclaimConfirmed(state) + }, + BobState::WaitingForReclaimTimelockExpiration(state) => { + // Race between: + // - Remaining refund timelock expiring (so we can publish TxRefundAmnesty) + // - Alice publishing TxRefundBurn (burns the amnesty output) + let tx_partial_refund = state.construct_tx_partial_refund()?; + let tx_refund_burn = state.construct_tx_refund_burn()?; + + let remaining_refund_timelock = state.remaining_refund_timelock.context( + "Can't wait for remaining refund timelock because remaining_refund_timelock is missing", + )?; + + event_emitter.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::WaitingForEarnestDepositTimelockExpiration { + btc_partial_refund_txid: tx_partial_refund.txid(), + btc_lock_amount: state.tx_lock.lock_amount(), + btc_amnesty_amount: state.btc_amnesty_amount.unwrap_or(bitcoin::Amount::ZERO), + target_blocks: remaining_refund_timelock.into(), + blocks_until_expiry: remaining_refund_timelock.into(), + }, + ); + + let (tx_partial_refund_status, tx_refund_burn_status) = tokio::join!( + bitcoin_wallet.subscribe_to(Box::new(tx_partial_refund.clone())), + bitcoin_wallet.subscribe_to(Box::new(tx_refund_burn)), + ); + + // Emit a tauri event everytime the TxPartialRefund status changes so we can + // show an estimate when we will be able to claim the remaining bitcoin + let timelock_expired_future = tx_partial_refund_status.wait_until(|status| { + event_emitter.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::WaitingForEarnestDepositTimelockExpiration { + btc_partial_refund_txid: tx_partial_refund.txid(), + btc_lock_amount: state.tx_lock.lock_amount(), + btc_amnesty_amount: state.btc_amnesty_amount.unwrap_or(bitcoin::Amount::ZERO), + target_blocks: remaining_refund_timelock.into(), + blocks_until_expiry: status.confirmations(), + }, + ); + + status.is_confirmed_with(remaining_refund_timelock.0) + }); + + select! { + // Wait for remaining_refund_timelock confirmations on tx_partial_refund + result = timelock_expired_future => { + result?; + tracing::info!("Remaining refund timelock expired, can now publish TxRefundAmnesty"); + BobState::ReclaimTimelockExpired(state) + } + // Watch for Alice publishing TxRefundBurn + _ = tx_refund_burn_status.wait_until_seen() => { + tracing::info!("Alice published TxRefundBurn, amnesty output is being burnt"); + BobState::BtcWithholdPublished(state) + } + } + } + BobState::ReclaimTimelockExpired(state) => { + // TODO: We should retry this and the check + // First check if TxRefundBurn was seen (we may have missed it while offline) + let tx_refund_burn = state.construct_tx_refund_burn()?; + let tx_refund_burn_status = bitcoin_wallet.status_of_script(&tx_refund_burn).await?; + + if tx_refund_burn_status.has_been_seen() { + tracing::info!("TxRefundBurn was already published, transitioning to BtcRefundBurnPublished"); + return Ok(BobState::BtcWithholdPublished(state)); + } + + // TxRefundBurn not published, we can publish TxRefundAmnesty + // Alice always sends the amnesty signature in swap setup + let transaction = state.signed_amnesty_transaction() + .context("Couldn't construct Bitcoin amnesty transaction")?; + bitcoin_wallet.ensure_broadcasted(transaction, "amnesty") + .await + .context("Couldn't ensure broadcast of Bitcoin amnesty transaction")?; + BobState::BtcReclaimPublished(state) + } + BobState::BtcWithholdPublished(state) => { + // Wait for TxRefundBurn confirmation + let tx_refund_burn = state.construct_tx_refund_burn()?; + event_emitter.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::BtcRefundBurnPublished { + btc_refund_burn_txid: tx_refund_burn.txid(), + btc_lock_amount: state.tx_lock.lock_amount(), + btc_amnesty_amount: state.btc_amnesty_amount.unwrap_or(bitcoin::Amount::ZERO), + }, + ); + let subscription = bitcoin_wallet.subscribe_to(Box::new(tx_refund_burn)).await; + + subscription.wait_until_final().await?; + tracing::info!("TxRefundBurn confirmed, amnesty output is burnt"); + BobState::BtcWithheld(state) + } + BobState::BtcWithheld(state) => { + // Watch for Alice publishing TxFinalAmnesty + // Alice may grant final amnesty after burning our refund + // However, we don't expect Alice to publish the tx at once, if at all. + // Thus we only check once, and then stop the swap. + // User's can still manually resume the swap to check again. + let tx_refund_burn = state.construct_tx_refund_burn()?; + event_emitter.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::BtcRefundBurnt { + btc_refund_burn_txid: tx_refund_burn.txid(), + btc_lock_amount: state.tx_lock.lock_amount(), + btc_amnesty_amount: state.btc_amnesty_amount.unwrap_or(bitcoin::Amount::ZERO), + }, + ); + + let tx_final_amnesty = state.construct_tx_final_amnesty()?; + + let final_amnesty_status = bitcoin_wallet.status_of_script(&tx_final_amnesty).await.context("Failed to check TxFinalAmnesty status")?; + + if final_amnesty_status.has_been_seen() { + BobState::BtcMercyPublished(state) + } else { + BobState::BtcWithheld(state) + } + } + BobState::BtcMercyPublished(state) => { + // Wait for TxFinalAmnesty confirmation + let tx_final_amnesty = state.construct_tx_final_amnesty()?; + event_emitter.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::BtcFinalAmnestyPublished { + btc_final_amnesty_txid: tx_final_amnesty.txid(), + btc_lock_amount: state.tx_lock.lock_amount(), + btc_amnesty_amount: state.btc_amnesty_amount.unwrap_or(bitcoin::Amount::ZERO), + }, + ); + let subscription = bitcoin_wallet.subscribe_to(Box::new(tx_final_amnesty)).await; + + subscription.wait_until_final().await?; + tracing::info!("TxFinalAmnesty confirmed, received burnt funds back"); + BobState::BtcMercyConfirmed(state) + } + BobState::BtcMercyConfirmed(state) => { + // Terminal state - we received the burnt funds back + let tx_final_amnesty = state.construct_tx_final_amnesty()?; + event_emitter.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::BtcFinalAmnestyConfirmed { + btc_final_amnesty_txid: tx_final_amnesty.txid(), + btc_lock_amount: state.tx_lock.lock_amount(), + btc_amnesty_amount: state.btc_amnesty_amount.unwrap_or(bitcoin::Amount::ZERO), + }, + ); + BobState::BtcMercyConfirmed(state) + } BobState::SafelyAborted => BobState::SafelyAborted, BobState::XmrRedeemed { tx_lock_id } => { event_emitter.emit_swap_progress_event( diff --git a/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command.rs b/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command.rs index 01991be165..edb0b2880d 100644 --- a/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command.rs +++ b/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command.rs @@ -11,7 +11,7 @@ use swap::{asb, cli}; #[tokio::test] async fn given_alice_and_bob_manually_refund_after_funds_locked_both_refund() { - harness::setup_test(FastCancelConfig, None, |mut ctx| async move { + harness::setup_test(FastCancelConfig, None, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); diff --git a/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired.rs b/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired.rs index ddda32680a..89433e5c0a 100644 --- a/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired.rs +++ b/swap/tests/alice_and_bob_refund_using_cancel_and_refund_command_timelock_not_expired.rs @@ -12,7 +12,7 @@ use swap::{asb, cli}; #[tokio::test] async fn given_alice_and_bob_manually_cancel_when_timelock_not_expired_errors() { - harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); diff --git a/swap/tests/alice_and_bob_refund_using_cancel_then_refund_command.rs b/swap/tests/alice_and_bob_refund_using_cancel_then_refund_command.rs index 62c339bc1d..8936568c53 100644 --- a/swap/tests/alice_and_bob_refund_using_cancel_then_refund_command.rs +++ b/swap/tests/alice_and_bob_refund_using_cancel_then_refund_command.rs @@ -11,7 +11,7 @@ use swap::{asb, cli}; #[tokio::test] async fn given_alice_and_bob_manually_cancel_and_refund_after_funds_locked_both_refund() { - harness::setup_test(FastCancelConfig, None, |mut ctx| async move { + harness::setup_test(FastCancelConfig, None, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); diff --git a/swap/tests/alice_broken_wallet_rpc_after_started_btc_early_refund.rs b/swap/tests/alice_broken_wallet_rpc_after_started_btc_early_refund.rs index 1102516589..4a8a4a16ec 100644 --- a/swap/tests/alice_broken_wallet_rpc_after_started_btc_early_refund.rs +++ b/swap/tests/alice_broken_wallet_rpc_after_started_btc_early_refund.rs @@ -10,7 +10,7 @@ use crate::harness::SlowCancelConfig; #[tokio::test] async fn alice_zero_xmr_refunds_bitcoin() { - harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, None, |mut ctx| async move { let (bob_swap, bob_handle) = ctx.bob_swap().await; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); diff --git a/swap/tests/alice_empty_balance_after_started_btc_early_refund.rs b/swap/tests/alice_empty_balance_after_started_btc_early_refund.rs index 61d412414f..c63988260f 100644 --- a/swap/tests/alice_empty_balance_after_started_btc_early_refund.rs +++ b/swap/tests/alice_empty_balance_after_started_btc_early_refund.rs @@ -10,7 +10,7 @@ use crate::harness::SlowCancelConfig; #[tokio::test] async fn alice_zero_xmr_refunds_bitcoin() { - harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, None, |mut ctx| async move { let (bob_swap, bob_handle) = ctx.bob_swap().await; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); diff --git a/swap/tests/alice_manually_punishes_after_bob_dead.rs b/swap/tests/alice_manually_punishes_after_bob_dead.rs index 9cd9f4474d..f29fc0fdf3 100644 --- a/swap/tests/alice_manually_punishes_after_bob_dead.rs +++ b/swap/tests/alice_manually_punishes_after_bob_dead.rs @@ -14,7 +14,7 @@ use swap::protocol::{alice, bob}; /// punish command. Bob then cooperates with Alice and redeems XMR with her key. #[tokio::test] async fn alice_manually_punishes_after_bob_dead() { - harness::setup_test(FastPunishConfig, None, |mut ctx| async move { + harness::setup_test(FastPunishConfig, None, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); diff --git a/swap/tests/alice_manually_punishes_after_bob_dead_and_bob_cancels.rs b/swap/tests/alice_manually_punishes_after_bob_dead_and_bob_cancels.rs index 21d1cb1025..03bfaa1188 100644 --- a/swap/tests/alice_manually_punishes_after_bob_dead_and_bob_cancels.rs +++ b/swap/tests/alice_manually_punishes_after_bob_dead_and_bob_cancels.rs @@ -14,7 +14,7 @@ use swap::protocol::{alice, bob}; /// punish command. Then Bob tries to refund. #[tokio::test] async fn alice_manually_punishes_after_bob_dead_and_bob_cancels() { - harness::setup_test(FastPunishConfig, None, |mut ctx| async move { + harness::setup_test(FastPunishConfig, None, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); diff --git a/swap/tests/alice_manually_redeems_after_enc_sig_learned.rs b/swap/tests/alice_manually_redeems_after_enc_sig_learned.rs index ad2e97f596..bcb24c7560 100644 --- a/swap/tests/alice_manually_redeems_after_enc_sig_learned.rs +++ b/swap/tests/alice_manually_redeems_after_enc_sig_learned.rs @@ -11,7 +11,7 @@ use swap::protocol::{alice, bob}; /// after learning encsig from Bob #[tokio::test] async fn alice_manually_redeems_after_enc_sig_learned() { - harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, None, |mut ctx| async move { let (bob_swap, _) = ctx.bob_swap().await; let bob_swap = tokio::spawn(bob::run(bob_swap)); diff --git a/swap/tests/alice_punishes_after_restart_bob_dead.rs b/swap/tests/alice_punishes_after_restart_bob_dead.rs index cabf76a0b3..91518e2d7a 100644 --- a/swap/tests/alice_punishes_after_restart_bob_dead.rs +++ b/swap/tests/alice_punishes_after_restart_bob_dead.rs @@ -12,7 +12,7 @@ use swap::protocol::{alice, bob}; /// the encsig and fail to refund or redeem. Alice cancels and punishes. Bob then cooperates with Alice and redeems XMR with her key. #[tokio::test] async fn alice_punishes_after_restart_if_bob_dead() { - harness::setup_test(FastPunishConfig, None, |mut ctx| async move { + harness::setup_test(FastPunishConfig, None, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); diff --git a/swap/tests/alice_refunds_after_restart_bob_refunded.rs b/swap/tests/alice_refunds_after_restart_bob_refunded.rs index b79f52a2ad..7b3dd89832 100644 --- a/swap/tests/alice_refunds_after_restart_bob_refunded.rs +++ b/swap/tests/alice_refunds_after_restart_bob_refunded.rs @@ -10,7 +10,7 @@ use swap::protocol::{alice, bob}; /// Eventually Alice comes back online and refunds as well. #[tokio::test] async fn alice_refunds_after_restart_if_bob_already_refunded() { - harness::setup_test(FastCancelConfig, None, |mut ctx| async move { + harness::setup_test(FastCancelConfig, None, None, |mut ctx| async move { let (bob_swap, _) = ctx.bob_swap().await; let bob_swap = tokio::spawn(bob::run(bob_swap)); diff --git a/swap/tests/concurrent_bobs_after_xmr_lock_proof_sent.rs b/swap/tests/concurrent_bobs_after_xmr_lock_proof_sent.rs index e48683afe6..ec3053b4cc 100644 --- a/swap/tests/concurrent_bobs_after_xmr_lock_proof_sent.rs +++ b/swap/tests/concurrent_bobs_after_xmr_lock_proof_sent.rs @@ -9,7 +9,7 @@ use swap::protocol::{alice, bob}; #[tokio::test] async fn concurrent_bobs_after_xmr_lock_proof_sent() { - harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, None, |mut ctx| async move { let (bob_swap_1, bob_join_handle_1) = ctx.bob_swap().await; let swap_id = bob_swap_1.id; diff --git a/swap/tests/concurrent_bobs_before_xmr_lock_proof_sent.rs b/swap/tests/concurrent_bobs_before_xmr_lock_proof_sent.rs index 775a1e2a35..a255c5ea31 100644 --- a/swap/tests/concurrent_bobs_before_xmr_lock_proof_sent.rs +++ b/swap/tests/concurrent_bobs_before_xmr_lock_proof_sent.rs @@ -9,7 +9,7 @@ use swap::protocol::{alice, bob}; #[tokio::test] async fn concurrent_bobs_before_xmr_lock_proof_sent() { - harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, None, |mut ctx| async move { let (bob_swap_1, bob_join_handle_1) = ctx.bob_swap().await; let swap_id = bob_swap_1.id; diff --git a/swap/tests/ensure_same_swap_id.rs b/swap/tests/ensure_same_swap_id.rs index 8deb1428cc..309f89904a 100644 --- a/swap/tests/ensure_same_swap_id.rs +++ b/swap/tests/ensure_same_swap_id.rs @@ -5,7 +5,7 @@ use swap::protocol::bob; #[tokio::test] async fn ensure_same_swap_id_for_alice_and_bob() { - harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, None, |mut ctx| async move { let (bob_swap, _) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; tokio::spawn(bob::run(bob_swap)); diff --git a/swap/tests/happy_path.rs b/swap/tests/happy_path.rs index da0ee150bc..bb0b983c98 100644 --- a/swap/tests/happy_path.rs +++ b/swap/tests/happy_path.rs @@ -7,7 +7,7 @@ use tokio::join; #[tokio::test] async fn happy_path() { - harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, None, |mut ctx| async move { let (bob_swap, _) = ctx.bob_swap().await; let bob_swap = tokio::spawn(bob::run(bob_swap)); diff --git a/swap/tests/happy_path_alice_developer_tip.rs b/swap/tests/happy_path_alice_developer_tip.rs index eba9df567e..cc2be6b664 100644 --- a/swap/tests/happy_path_alice_developer_tip.rs +++ b/swap/tests/happy_path_alice_developer_tip.rs @@ -11,6 +11,7 @@ async fn happy_path_alice_developer_tip() { harness::setup_test( SlowCancelConfig, Some((Decimal::from_f32_retain(0.1).unwrap(), false)), + None, |mut ctx| async move { let (bob_swap, _) = ctx.bob_swap().await; let bob_swap = tokio::spawn(bob::run(bob_swap)); diff --git a/swap/tests/happy_path_alice_developer_tip_subaddress.rs b/swap/tests/happy_path_alice_developer_tip_subaddress.rs index df34d3c6d5..2589279b2d 100644 --- a/swap/tests/happy_path_alice_developer_tip_subaddress.rs +++ b/swap/tests/happy_path_alice_developer_tip_subaddress.rs @@ -11,6 +11,7 @@ async fn happy_path_alice_developer_tip_subaddress() { harness::setup_test( SlowCancelConfig, Some((Decimal::from_f32_retain(0.1).unwrap(), true)), + None, |mut ctx| async move { let (bob_swap, _) = ctx.bob_swap().await; let bob_swap = tokio::spawn(bob::run(bob_swap)); diff --git a/swap/tests/happy_path_alice_does_not_send_transfer_proof.rs b/swap/tests/happy_path_alice_does_not_send_transfer_proof.rs index 06faace348..8260b6fb8b 100644 --- a/swap/tests/happy_path_alice_does_not_send_transfer_proof.rs +++ b/swap/tests/happy_path_alice_does_not_send_transfer_proof.rs @@ -12,7 +12,7 @@ use swap::protocol::{alice, bob}; /// even when Alice goes offline and doesn't send the transfer proof. #[tokio::test] async fn given_alice_goes_offline_after_xmr_locked_bob_detects_xmr_via_view_key() { - harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, None, |mut ctx| async move { // Bob runs until he detects the Monero as having been locked let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; diff --git a/swap/tests/happy_path_bob_offline_while_alice_redeems_btc.rs b/swap/tests/happy_path_bob_offline_while_alice_redeems_btc.rs index 1b4774acef..0b97a659bf 100644 --- a/swap/tests/happy_path_bob_offline_while_alice_redeems_btc.rs +++ b/swap/tests/happy_path_bob_offline_while_alice_redeems_btc.rs @@ -8,37 +8,42 @@ use tokio::join; #[tokio::test] async fn given_bob_restarts_while_alice_redeems_btc() { - harness::setup_test(harness::SlowCancelConfig, None, |mut ctx| async move { - let (bob_swap, bob_handle) = ctx.bob_swap().await; - let swap_id = bob_swap.id; - - let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_encsig_sent)); - - let alice_swap = ctx.alice_next_swap().await; - let alice_swap = tokio::spawn(alice::run(alice_swap, FixedRate::default())); - - let (bob_state, alice_state) = join!(bob_swap, alice_swap); - ctx.assert_alice_redeemed(alice_state??).await; - assert!(matches!(bob_state??, BobState::EncSigSent { .. })); - - let (bob_swap, _) = ctx.stop_and_resume_bob_from_db(bob_handle, swap_id).await; - - if let BobState::EncSigSent(state4) = bob_swap.state.clone() { - bob_swap - .bitcoin_wallet - .subscribe_to(Box::new(state4.tx_lock)) - .await - .wait_until_confirmed_with(state4.cancel_timelock) - .await?; - } else { - panic!("Bob in unexpected state {}", bob_swap.state); - } - - // Restart Bob - let bob_state = bob::run(bob_swap).await?; - ctx.assert_bob_redeemed(bob_state).await; - - Ok(()) - }) + harness::setup_test( + harness::SlowCancelConfig, + None, + None, + |mut ctx| async move { + let (bob_swap, bob_handle) = ctx.bob_swap().await; + let swap_id = bob_swap.id; + + let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_encsig_sent)); + + let alice_swap = ctx.alice_next_swap().await; + let alice_swap = tokio::spawn(alice::run(alice_swap, FixedRate::default())); + + let (bob_state, alice_state) = join!(bob_swap, alice_swap); + ctx.assert_alice_redeemed(alice_state??).await; + assert!(matches!(bob_state??, BobState::EncSigSent { .. })); + + let (bob_swap, _) = ctx.stop_and_resume_bob_from_db(bob_handle, swap_id).await; + + if let BobState::EncSigSent(state4) = bob_swap.state.clone() { + bob_swap + .bitcoin_wallet + .subscribe_to(Box::new(state4.tx_lock)) + .await + .wait_until_confirmed_with(state4.cancel_timelock) + .await?; + } else { + panic!("Bob in unexpected state {}", bob_swap.state); + } + + // Restart Bob + let bob_state = bob::run(bob_swap).await?; + ctx.assert_bob_redeemed(bob_state).await; + + Ok(()) + }, + ) .await; } diff --git a/swap/tests/happy_path_restart_alice_after_xmr_locked.rs b/swap/tests/happy_path_restart_alice_after_xmr_locked.rs index 1461947c60..6ab91c02e2 100644 --- a/swap/tests/happy_path_restart_alice_after_xmr_locked.rs +++ b/swap/tests/happy_path_restart_alice_after_xmr_locked.rs @@ -8,7 +8,7 @@ use swap::protocol::{alice, bob}; #[tokio::test] async fn given_alice_restarts_after_xmr_is_locked_resume_swap() { - harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, None, |mut ctx| async move { let (bob_swap, _) = ctx.bob_swap().await; let bob_swap = tokio::spawn(bob::run(bob_swap)); diff --git a/swap/tests/happy_path_restart_bob_after_xmr_locked.rs b/swap/tests/happy_path_restart_bob_after_xmr_locked.rs index 07b44ebc7e..c35b8e065f 100644 --- a/swap/tests/happy_path_restart_bob_after_xmr_locked.rs +++ b/swap/tests/happy_path_restart_bob_after_xmr_locked.rs @@ -8,7 +8,7 @@ use swap::protocol::{alice, bob}; #[tokio::test] async fn given_bob_restarts_after_xmr_is_locked_resume_swap() { - harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_xmr_locked)); diff --git a/swap/tests/happy_path_restart_bob_before_xmr_locked.rs b/swap/tests/happy_path_restart_bob_before_xmr_locked.rs index 07b44ebc7e..c35b8e065f 100644 --- a/swap/tests/happy_path_restart_bob_before_xmr_locked.rs +++ b/swap/tests/happy_path_restart_bob_before_xmr_locked.rs @@ -8,7 +8,7 @@ use swap::protocol::{alice, bob}; #[tokio::test] async fn given_bob_restarts_after_xmr_is_locked_resume_swap() { - harness::setup_test(SlowCancelConfig, None, |mut ctx| async move { + harness::setup_test(SlowCancelConfig, None, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_xmr_locked)); diff --git a/swap/tests/harness/mod.rs b/swap/tests/harness/mod.rs index 768bfbde6d..27bba95e37 100644 --- a/swap/tests/harness/mod.rs +++ b/swap/tests/harness/mod.rs @@ -13,6 +13,7 @@ use rust_decimal::Decimal; use std::cmp::Ordering; use std::fmt; use std::path::PathBuf; +use swap_env::config::RefundPolicy; use std::sync::Arc; use std::time::Duration; @@ -49,6 +50,7 @@ use uuid::Uuid; pub async fn setup_test( _config: C, developer_tip_ratio: Option<(Decimal, bool)>, + refund_policy: Option, testfn: T, ) where T: Fn(TestContext) -> F, @@ -150,7 +152,13 @@ pub async fn setup_test( .parse() .expect("failed to parse Alice's address"); - let (alice_handle, alice_swap_handle) = start_alice( + let alice_rpc_port = std::net::TcpListener::bind(("127.0.0.1", 0)) + .unwrap() + .local_addr() + .unwrap() + .port(); + + let (alice_handle, alice_swap_handle, alice_rpc_server_handle) = start_alice( &alice_seed, alice_db_path.clone(), alice_listen_address.clone(), @@ -158,9 +166,15 @@ pub async fn setup_test( alice_bitcoin_wallet.clone(), alice_monero_wallet.clone(), developer_tip.clone(), + refund_policy.clone().unwrap_or_default(), + alice_rpc_port, ) .await; + let alice_rpc_client = jsonrpsee::http_client::HttpClientBuilder::default() + .build(format!("http://127.0.0.1:{}", alice_rpc_port)) + .expect("Failed to create RPC client"); + let bob_seed = Seed::random().unwrap(); let bob_starting_balances = StartingBalances::new(btc_amount * 10, monero::Amount::ZERO, None); let bob_monero_dir = TempDir::new().unwrap().path().join("bob-monero-wallets"); @@ -196,6 +210,9 @@ pub async fn setup_test( alice_seed, alice_db_path, alice_listen_address, + alice_rpc_port, + alice_rpc_server_handle, + alice_rpc_client, alice_starting_balances, alice_bitcoin_wallet, alice_monero_wallet, @@ -207,7 +224,9 @@ pub async fn setup_test( bob_monero_wallet, developer_tip_monero_wallet, developer_tip, + refund_policy: refund_policy.unwrap_or_default(), monerod_container_id: containers._monerod_container.id().to_string(), + monero, }; testfn(test).await.unwrap() @@ -298,7 +317,13 @@ async fn start_alice( bitcoin_wallet: Arc, monero_wallet: Arc, developer_tip: TipConfig, -) -> (AliceApplicationHandle, Receiver) { + refund_policy: RefundPolicy, + rpc_port: u16, +) -> ( + AliceApplicationHandle, + Receiver, + tokio_util::task::AbortOnDropHandle<()>, +) { if let Some(parent_dir) = db_path.parent() { ensure_directory_exists(parent_dir).unwrap(); } @@ -332,24 +357,41 @@ async fn start_alice( .unwrap(); swarm.listen_on(listen_address).unwrap(); - let (event_loop, swap_handle, _service) = asb::EventLoop::new( + let (event_loop, swap_handle, service) = asb::EventLoop::new( swarm, env_config, - bitcoin_wallet, - monero_wallet, - db, + bitcoin_wallet.clone(), + monero_wallet.clone(), + db.clone(), FixedRate::default(), min_buy, max_buy, None, developer_tip, + refund_policy, ) .unwrap(); + let rpc_server_handle = asb::rpc::RpcServer::start( + "127.0.0.1".to_string(), + rpc_port, + bitcoin_wallet, + monero_wallet, + service, + db, + ) + .await + .expect("Failed to start RPC server") + .spawn(); + let peer_id = event_loop.peer_id(); let handle = tokio::spawn(event_loop.run()); - (AliceApplicationHandle { handle, peer_id }, swap_handle) + ( + AliceApplicationHandle { handle, peer_id }, + swap_handle, + rpc_server_handle, + ) } #[allow(clippy::too_many_arguments)] @@ -424,6 +466,7 @@ async fn init_test_wallets( .finality_confirmations(1_u32) .target_block(1_u32) .sync_interval(Duration::from_secs(3)) // high sync interval to speed up tests + .use_mempool_space_fee_estimation(false) .build() .await .expect("could not init btc wallet"); @@ -645,7 +688,7 @@ impl BobParams { } } -pub struct BobApplicationHandle(JoinHandle<()>); +pub struct BobApplicationHandle(pub JoinHandle<()>); impl BobApplicationHandle { pub fn abort(&self) { @@ -670,10 +713,15 @@ pub struct TestContext { btc_amount: bitcoin::Amount, xmr_amount: monero::Amount, developer_tip: TipConfig, + refund_policy: RefundPolicy, alice_seed: Seed, alice_db_path: PathBuf, alice_listen_address: Multiaddr, + alice_rpc_port: u16, + #[allow(dead_code)] + alice_rpc_server_handle: tokio_util::task::AbortOnDropHandle<()>, + pub alice_rpc_client: jsonrpsee::http_client::HttpClient, alice_starting_balances: StartingBalances, alice_bitcoin_wallet: Arc, @@ -690,6 +738,10 @@ pub struct TestContext { // Store the container ID as String instead of reference monerod_container_id: String, + + // Handle for the Monero deamon. This allows us to skip waiting times by generating + // blocks instantly + pub monero: Monero, } impl TestContext { @@ -706,8 +758,12 @@ impl TestContext { pub async fn restart_alice(&mut self) { self.alice_handle.abort(); + // Abort the old RPC server to release the port before starting a new one + self.alice_rpc_server_handle.abort(); + // Small delay to ensure port is released + tokio::time::sleep(Duration::from_millis(100)).await; - let (alice_handle, alice_swap_handle) = start_alice( + let (alice_handle, alice_swap_handle, alice_rpc_server_handle) = start_alice( &self.alice_seed, self.alice_db_path.clone(), self.alice_listen_address.clone(), @@ -715,11 +771,14 @@ impl TestContext { self.alice_bitcoin_wallet.clone(), self.alice_monero_wallet.clone(), self.developer_tip.clone(), + self.refund_policy.clone(), + self.alice_rpc_port, ) .await; self.alice_handle = alice_handle; self.alice_swap_handle = alice_swap_handle; + self.alice_rpc_server_handle = alice_rpc_server_handle; } pub async fn alice_next_swap(&mut self) -> alice::Swap { @@ -775,7 +834,7 @@ impl TestContext { } pub async fn assert_alice_refunded(&mut self, state: AliceState) { - assert!(matches!(state, AliceState::XmrRefunded)); + assert!(matches!(state, AliceState::XmrRefunded { .. })); assert_eventual_balance( self.alice_bitcoin_wallet.as_ref(), @@ -795,6 +854,51 @@ impl TestContext { .unwrap(); } + pub async fn assert_alice_refund_burn_confirmed(&mut self, state: AliceState) { + assert!(matches!(state, AliceState::BtcWithholdConfirmed { .. })); + + // Same as refunded - Alice still has her XMR back + assert_eventual_balance( + self.alice_bitcoin_wallet.as_ref(), + Ordering::Equal, + self.alice_refunded_btc_balance(), + ) + .await + .unwrap(); + + assert_eventual_balance( + &*self.alice_monero_wallet.main_wallet().await, + Ordering::Greater, + self.alice_refunded_xmr_balance(), + ) + .await + .unwrap(); + } + + pub async fn assert_alice_final_amnesty_confirmed(&mut self, state: AliceState) { + assert!(matches!( + state, + AliceState::BtcMercyConfirmed { .. } + )); + + // Same as refunded - Alice still has her XMR back + assert_eventual_balance( + self.alice_bitcoin_wallet.as_ref(), + Ordering::Equal, + self.alice_refunded_btc_balance(), + ) + .await + .unwrap(); + + assert_eventual_balance( + &*self.alice_monero_wallet.main_wallet().await, + Ordering::Greater, + self.alice_refunded_xmr_balance(), + ) + .await + .unwrap(); + } + pub async fn assert_alice_developer_tip_received(&self) { assert_eventual_balance( &*self.developer_tip_monero_wallet.main_wallet().await, @@ -898,6 +1002,124 @@ impl TestContext { .unwrap(); } + pub async fn assert_bob_partially_refunded(&self, state: BobState) { + self.bob_bitcoin_wallet.sync().await.unwrap(); + + let (lock_tx_id, cancel_fee, partial_refund_fee, amnesty_amount) = match state { + BobState::BtcPartiallyRefunded(state6) => ( + state6.tx_lock_id(), + state6.tx_cancel_fee, + state6.tx_partial_refund_fee.expect("partial refund fee"), + state6.btc_amnesty_amount.expect("amnesty amount"), + ), + _ => panic!("Bob is not in btc partially refunded state: {:?}", state), + }; + let lock_tx_bitcoin_fee = self + .bob_bitcoin_wallet + .transaction_fee(lock_tx_id) + .await + .unwrap(); + + let btc_balance_after_swap = self.bob_bitcoin_wallet.balance().await.unwrap(); + let expected_balance = self.bob_starting_balances.btc + - lock_tx_bitcoin_fee + - cancel_fee + - partial_refund_fee + - amnesty_amount; + + assert_eq!(btc_balance_after_swap, expected_balance); + } + + pub async fn assert_bob_amnesty_received(&self, state: BobState) { + self.bob_bitcoin_wallet.sync().await.unwrap(); + + let (lock_tx_id, cancel_fee, partial_refund_fee, amnesty_fee) = match state { + BobState::BtcReclaimConfirmed(state6) => ( + state6.tx_lock_id(), + state6.tx_cancel_fee, + state6.tx_partial_refund_fee.expect("partial refund fee"), + state6.tx_refund_amnesty_fee.expect("amnesty fee"), + ), + _ => panic!("Bob is not in btc amnesty confirmed state: {:?}", state), + }; + let lock_tx_bitcoin_fee = self + .bob_bitcoin_wallet + .transaction_fee(lock_tx_id) + .await + .unwrap(); + + let btc_balance_after_swap = self.bob_bitcoin_wallet.balance().await.unwrap(); + // Bob gets full amount back minus all the fees + let expected_balance = self.bob_starting_balances.btc + - lock_tx_bitcoin_fee + - cancel_fee + - partial_refund_fee + - amnesty_fee; + + assert_eq!(btc_balance_after_swap, expected_balance); + } + + pub async fn assert_bob_refund_burnt(&self, state: BobState) { + self.bob_bitcoin_wallet.sync().await.unwrap(); + + let (lock_tx_id, cancel_fee, partial_refund_fee, amnesty_amount) = match state { + BobState::BtcWithheld(state6) => ( + state6.tx_lock_id(), + state6.tx_cancel_fee, + state6.tx_partial_refund_fee.expect("partial refund fee"), + state6.btc_amnesty_amount.expect("amnesty amount"), + ), + _ => panic!("Bob is not in btc refund burnt state: {:?}", state), + }; + let lock_tx_bitcoin_fee = self + .bob_bitcoin_wallet + .transaction_fee(lock_tx_id) + .await + .unwrap(); + + let btc_balance_after_swap = self.bob_bitcoin_wallet.balance().await.unwrap(); + // Bob lost the amnesty amount (it was burnt) + let expected_balance = self.bob_starting_balances.btc + - lock_tx_bitcoin_fee + - cancel_fee + - partial_refund_fee + - amnesty_amount; + + assert_eq!(btc_balance_after_swap, expected_balance); + } + + pub async fn assert_bob_final_amnesty_received(&self, state: BobState) { + self.bob_bitcoin_wallet.sync().await.unwrap(); + + let (lock_tx_id, cancel_fee, partial_refund_fee, final_amnesty_fee) = match state { + BobState::BtcMercyConfirmed(state6) => ( + state6.tx_lock_id(), + state6.tx_cancel_fee, + state6.tx_partial_refund_fee.expect("partial refund fee"), + state6.tx_final_amnesty_fee.expect("final amnesty fee"), + ), + _ => panic!( + "Bob is not in btc final amnesty confirmed state: {:?}", + state + ), + }; + let lock_tx_bitcoin_fee = self + .bob_bitcoin_wallet + .transaction_fee(lock_tx_id) + .await + .unwrap(); + + let btc_balance_after_swap = self.bob_bitcoin_wallet.balance().await.unwrap(); + // Bob gets full amount back via final amnesty + let expected_balance = self.bob_starting_balances.btc + - lock_tx_bitcoin_fee + - cancel_fee + - partial_refund_fee + - final_amnesty_fee; + + assert_eq!(btc_balance_after_swap, expected_balance); + } + fn alice_redeemed_xmr_balance(&self) -> monero::Amount { self.alice_starting_balances.xmr - self.xmr_amount } @@ -1212,6 +1434,22 @@ pub mod alice_run_until { pub fn is_btc_redeemed(state: &AliceState) -> bool { matches!(state, AliceState::BtcRedeemed { .. }) } + + pub fn is_btc_partially_refunded(state: &AliceState) -> bool { + matches!(state, AliceState::BtcPartiallyRefunded { .. }) + } + + pub fn is_xmr_refunded(state: &AliceState) -> bool { + matches!(state, AliceState::XmrRefunded { .. }) + } + + pub fn is_btc_refund_burn_confirmed(state: &AliceState) -> bool { + matches!(state, AliceState::BtcWithholdConfirmed { .. }) + } + + pub fn is_btc_final_amnesty_confirmed(state: &AliceState) -> bool { + matches!(state, AliceState::BtcMercyConfirmed { .. }) + } } pub mod bob_run_until { @@ -1232,6 +1470,30 @@ pub mod bob_run_until { pub fn is_encsig_sent(state: &BobState) -> bool { matches!(state, BobState::EncSigSent(..)) } + + pub fn is_btc_partially_refunded(state: &BobState) -> bool { + matches!(state, BobState::BtcPartiallyRefunded(..)) + } + + pub fn is_waiting_for_remaining_refund_timelock(state: &BobState) -> bool { + matches!(state, BobState::WaitingForReclaimTimelockExpiration(..)) + } + + pub fn is_remaining_refund_timelock_expired(state: &BobState) -> bool { + matches!(state, BobState::ReclaimTimelockExpired(..)) + } + + pub fn is_btc_amnesty_confirmed(state: &BobState) -> bool { + matches!(state, BobState::BtcReclaimConfirmed(..)) + } + + pub fn is_btc_refund_burnt(state: &BobState) -> bool { + matches!(state, BobState::BtcWithheld(..)) + } + + pub fn is_btc_final_amnesty_confirmed(state: &BobState) -> bool { + matches!(state, BobState::BtcMercyConfirmed(..)) + } } pub struct SlowCancelConfig; @@ -1267,3 +1529,35 @@ impl GetConfig for FastPunishConfig { } } } + +pub struct FastAmnestyConfig; + +impl GetConfig for FastAmnestyConfig { + fn get_config() -> Config { + Config { + bitcoin_cancel_timelock: CancelTimelock::new(10).into(), + bitcoin_remaining_refund_timelock: 3, + ..env::Regtest::get_config() + } + } +} + +/// Config with a longer remaining refund timelock for burn tests. +/// Alice needs time to refund XMR (which waits for 10 Monero confirmations) +/// before publishing the burn transaction. +pub struct SlowAmnestyConfig; + +impl GetConfig for SlowAmnestyConfig { + fn get_config() -> Config { + Config { + bitcoin_cancel_timelock: CancelTimelock::new(10).into(), + // Much longer timelock to give Alice time to: + // 1. Wait for 10 Monero confirmations on the lock tx + // 2. Sweep the XMR to her wallet + // 3. Then publish the burn transaction + // In regtest, each BTC block is ~5s, so 100 blocks is ~8 minutes + bitcoin_remaining_refund_timelock: 100, + ..env::Regtest::get_config() + } + } +} diff --git a/swap/tests/partial_refund_alice_burns.rs b/swap/tests/partial_refund_alice_burns.rs new file mode 100644 index 0000000000..344089794b --- /dev/null +++ b/swap/tests/partial_refund_alice_burns.rs @@ -0,0 +1,86 @@ +pub mod harness; + +use std::time::Duration; + +use harness::alice_run_until::is_xmr_lock_transaction_sent; +use harness::bob_run_until::is_btc_partially_refunded; +use harness::SlowAmnestyConfig; +use rust_decimal::Decimal; +use swap::asb::FixedRate; +use swap::protocol::alice::AliceState; +use swap::protocol::bob::BobState; +use swap::protocol::{alice, bob}; +use swap_env::config::RefundPolicy; + +/// Bob locks Btc and Alice locks Xmr. Alice does not act so Bob does a partial +/// refund. Alice then burns the refund, denying Bob access to the amnesty. +#[tokio::test] +async fn given_partial_refund_alice_burns_the_amnesty() { + // Use 95% refund ratio - Bob gets 95% immediately, 5% locked in amnesty + // Alice burns the amnesty + let refund_policy = Some(RefundPolicy { + anti_spam_deposit_ratio: Decimal::new(95, 2), // 0.95 = 95% + always_withhold_deposit: true, + }); + + harness::setup_test( + SlowAmnestyConfig, + None, + refund_policy, + |mut ctx| async move { + // Start Bob's swap + let (bob_swap, bob_join_handle) = ctx.bob_swap().await; + let bob_swap_id = bob_swap.id; + // Bob runs until he has done the partial refund + let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_partially_refunded)); + + // Alice sends XMR lock then stops + let alice_swap = ctx.alice_next_swap().await; + let alice_swap = tokio::spawn(alice::run_until( + alice_swap, + is_xmr_lock_transaction_sent, + FixedRate::default(), + )); + + // Alice finishes first (just sends XMR lock and stops) + let alice_state = alice_swap.await??; + assert!(matches!( + alice_state, + AliceState::XmrLockTransactionSent { .. } + )); + + // Bob runs until partial refund is done + let bob_state = bob_swap.await??; + assert!(matches!(bob_state, BobState::BtcPartiallyRefunded { .. })); + + // Restart Alice so she can refund her XMR and burn Bob's amnesty + // Alice needs to publish burn BEFORE Bob's remaining refund timelock expires + ctx.restart_alice().await; + let alice_swap = ctx.alice_next_swap().await; + let alice_swap = tokio::spawn(alice::run(alice_swap, FixedRate::default())); + + // Bob continues - he's watching for TxRefundBurn while waiting for timelock + // Alice's burn should get published before Bob's timelock expires + let (bob_swap, _) = ctx + .stop_and_resume_bob_from_db(bob_join_handle, bob_swap_id) + .await; + let bob_swap = tokio::spawn(bob::run(bob_swap)); + + // Generate some Monero blocks such that Alice's + // monero refund transaction gets confirmed in time. + tokio::time::sleep(Duration::from_secs(15)).await; + ctx.monero.generate_blocks().await?; + + // Bob should end up in BtcRefundBurnt because Alice's burn beat his amnesty + let bob_state = bob_swap.await??; + ctx.assert_bob_refund_burnt(bob_state).await; + + // Alice should be in refund burn confirmed state + let alice_state = alice_swap.await??; + ctx.assert_alice_refund_burn_confirmed(alice_state).await; + + Ok(()) + }, + ) + .await; +} diff --git a/swap/tests/partial_refund_alice_burns_after_command.rs b/swap/tests/partial_refund_alice_burns_after_command.rs new file mode 100644 index 0000000000..68c876a14e --- /dev/null +++ b/swap/tests/partial_refund_alice_burns_after_command.rs @@ -0,0 +1,96 @@ +pub mod harness; + +use std::time::Duration; + +use harness::alice_run_until::is_xmr_lock_transaction_sent; +use harness::bob_run_until::is_btc_partially_refunded; +use harness::SlowAmnestyConfig; +use rust_decimal::Decimal; +use swap::asb::FixedRate; +use swap::protocol::alice::AliceState; +use swap::protocol::bob::BobState; +use swap::protocol::{alice, bob}; +use swap_controller_api::AsbApiClient; +use swap_env::config::RefundPolicy; + +/// Bob locks Btc and Alice locks Xmr. Alice does not act so Bob does a partial +/// refund. Alice receives an RPC command to burn the amnesty and then burns it. +#[tokio::test] +async fn given_partial_refund_alice_burns_after_command() { + // Use 95% refund ratio - Bob gets 95% immediately, 5% locked in amnesty + // Alice does NOT burn by default - burn_on_refund is false + let refund_policy = Some(RefundPolicy { + anti_spam_deposit_ratio: Decimal::new(95, 2), // 0.95 = 95% + always_withhold_deposit: false, // Do not burn by default + }); + + harness::setup_test( + SlowAmnestyConfig, + None, + refund_policy, + |mut ctx| async move { + // Start Bob's swap + let (bob_swap, bob_join_handle) = ctx.bob_swap().await; + let bob_swap_id = bob_swap.id; + // Bob runs until he has done the partial refund + let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_partially_refunded)); + + // Alice sends XMR lock then stops + let alice_swap = ctx.alice_next_swap().await; + let alice_swap_id = alice_swap.swap_id; + let alice_swap = tokio::spawn(alice::run_until( + alice_swap, + is_xmr_lock_transaction_sent, + FixedRate::default(), + )); + + // Alice finishes first (just sends XMR lock and stops) + let alice_state = alice_swap.await??; + assert!(matches!( + alice_state, + AliceState::XmrLockTransactionSent { .. } + )); + + // Bob runs until partial refund is done + let bob_state = bob_swap.await??; + assert!(matches!(bob_state, BobState::BtcPartiallyRefunded { .. })); + + // Restart Alice so she can refund her XMR and burn Bob's amnesty + // Alice needs to publish burn BEFORE Bob's remaining refund timelock expires + ctx.restart_alice().await; + let alice_swap = ctx.alice_next_swap().await; + + // Send RPC command to Alice to burn this swap's amnesty + // Must be done AFTER restart (so EventLoopHandle exists) but BEFORE running the swap + ctx.alice_rpc_client + .set_withhold_deposit(alice_swap_id, true) + .await + .expect("Failed to send burn command to Alice"); + + let alice_swap = tokio::spawn(alice::run(alice_swap, FixedRate::default())); + + // Bob continues - he's watching for TxRefundBurn while waiting for timelock + // Alice's burn should get published before Bob's timelock expires + let (bob_swap, _) = ctx + .stop_and_resume_bob_from_db(bob_join_handle, bob_swap_id) + .await; + let bob_swap = tokio::spawn(bob::run(bob_swap)); + + // Generate some Monero blocks such that Alice's + // monero refund transaction gets confirmed in time. + tokio::time::sleep(Duration::from_secs(15)).await; + ctx.monero.generate_blocks().await?; + + // Bob should end up in BtcRefundBurnt because Alice's burn beat his amnesty + let bob_state = bob_swap.await??; + ctx.assert_bob_refund_burnt(bob_state).await; + + // Alice should be in refund burn confirmed state + let alice_state = alice_swap.await??; + ctx.assert_alice_refund_burn_confirmed(alice_state).await; + + Ok(()) + }, + ) + .await; +} diff --git a/swap/tests/partial_refund_alice_grants_final_amnesty.rs b/swap/tests/partial_refund_alice_grants_final_amnesty.rs new file mode 100644 index 0000000000..bc95767f8f --- /dev/null +++ b/swap/tests/partial_refund_alice_grants_final_amnesty.rs @@ -0,0 +1,115 @@ +pub mod harness; + +use std::time::Duration; + +use harness::FastAmnestyConfig; +use rust_decimal::Decimal; +use swap::asb::FixedRate; +use swap::protocol::alice::AliceState; +use swap::protocol::{alice, bob}; +use swap_controller_api::AsbApiClient; +use swap_env::config::RefundPolicy; +use swap_machine::bob::BobState; + +use crate::harness::alice_run_until::is_xmr_refunded; +use crate::harness::bob_run_until; + +/// Bob locks Btc and Alice locks Xmr. Alice does not act so Bob does a partial +/// refund. Alice burns the refund, then later grants final amnesty to Bob. +/// NOTE: This test cannot pass yet because we haven't implemented the manual +/// trigger for final amnesty. BtcRefundBurnConfirmed is currently terminal. +#[tokio::test] +async fn given_partial_refund_alice_grants_final_amnesty() { + // Use 95% refund ratio - Bob gets 95% immediately, 5% locked in amnesty + // Alice burns the amnesty, then grants final amnesty + let refund_policy = Some(RefundPolicy { + anti_spam_deposit_ratio: Decimal::new(95, 2), // 0.95 = 95% + always_withhold_deposit: true, + }); + + harness::setup_test( + FastAmnestyConfig, + None, + refund_policy, + |mut ctx| async move { + let (bob_swap, bob_app_handle) = ctx.bob_swap().await; + let bob_state = tokio::spawn(bob::run_until( + bob_swap, + bob_run_until::is_btc_partially_refunded, + )); + + let alice_swap = ctx.alice_next_swap().await; + let alice_swap = tokio::spawn(alice::run_until( + alice_swap, + is_xmr_refunded, + FixedRate::default(), + )); + + // Wait for bob to partially refund - stop here such that he doesn't publish amnesty + // TODO: fix regtest blocktimes instead + let _bob_state = bob_state.await??; + + let alice_state = alice_swap.await??; + assert!(matches!(alice_state, AliceState::XmrRefunded { .. })); + + ctx.monero.generate_blocks().await?; + + // Restart alice and wait for bob to be burnt. + ctx.restart_alice().await; + let alice_swap = ctx.alice_next_swap().await; + let swap_id = alice_swap.swap_id; + let alice_swap = tokio::spawn(alice::run(alice_swap, FixedRate::default())); + + // Give alice time to publish TxRefundBurn before restarting bob + tokio::time::sleep(Duration::from_secs(20)).await; + + let (bob_swap, bob_app_handle) = ctx + .stop_and_resume_bob_from_db(bob_app_handle, swap_id) + .await; + let bob_state = tokio::spawn(bob::run(bob_swap)); // Bob should stop automatically after BtcRefundBurnt + + let alice_state = alice_swap.await??; + assert!(matches!( + alice_state, + AliceState::BtcWithholdConfirmed { .. } + )); + + let bob_state = bob_state.await??; + assert!(matches!(bob_state, BobState::BtcWithheld(..))); + + // Simulate alice's controller sending the final amnesty command via `controller` cli + ctx.restart_alice().await; + ctx.alice_rpc_client.grant_mercy(swap_id).await?; + + let alice_swap = ctx.alice_next_swap().await; + let alice_swap = tokio::spawn(alice::run(alice_swap, FixedRate::default())); + + let (bob_swap, _) = ctx + .stop_and_resume_bob_from_db(bob_app_handle, swap_id) + .await; + assert!(matches!(bob_swap.state, BobState::BtcWithheld(..))); + + let alice_state = alice_swap.await??; + // Only start bob again after alice published the tx. otherwise bob immediately + // terminates when not finding the tx. + // TODO: maybe make bob check for a few minutes before giving up? + let bob_state = tokio::spawn(bob::run(bob_swap)); + let bob_state = bob_state.await??; + + assert!( + matches!( + alice_state, + AliceState::BtcMercyConfirmed { .. } + ), + "Actual state: {alice_state}" + ); + assert!( + matches!(bob_state, bob::BobState::BtcMercyConfirmed(..)), + "Actual state: {bob_state}" + ); + + Ok(()) + }, + ) + .await; +} diff --git a/swap/tests/partial_refund_bob_claims_amnesty.rs b/swap/tests/partial_refund_bob_claims_amnesty.rs new file mode 100644 index 0000000000..9ccd078e7a --- /dev/null +++ b/swap/tests/partial_refund_bob_claims_amnesty.rs @@ -0,0 +1,55 @@ +pub mod harness; + +use harness::alice_run_until::is_xmr_lock_transaction_sent; +use harness::FastAmnestyConfig; +use rust_decimal::Decimal; +use swap::asb::FixedRate; +use swap::protocol::alice::AliceState; +use swap::protocol::{alice, bob}; +use swap_env::config::RefundPolicy; + +/// Bob locks Btc and Alice locks Xmr. Alice does not act so Bob does a partial +/// refund, waits for the remaining refund timelock, and then claims the amnesty. +#[tokio::test] +async fn given_partial_refund_bob_claims_amnesty_after_timelock() { + // Use 95% refund ratio - Bob gets 95% immediately, 5% locked in amnesty + // Alice does NOT burn - Bob can claim amnesty after timelock + let refund_policy = Some(RefundPolicy { + anti_spam_deposit_ratio: Decimal::new(95, 2), // 0.95 = 95% + always_withhold_deposit: false, + }); + + harness::setup_test(FastAmnestyConfig, None, refund_policy, |mut ctx| async move { + let (bob_swap, _) = ctx.bob_swap().await; + let bob_swap = tokio::spawn(bob::run(bob_swap)); + + let alice_swap = ctx.alice_next_swap().await; + let alice_swap = tokio::spawn(alice::run_until( + alice_swap, + is_xmr_lock_transaction_sent, + FixedRate::default(), + )); + + // Alice finishes first (just sends XMR lock and stops) + let alice_state = alice_swap.await??; + assert!(matches!( + alice_state, + AliceState::XmrLockTransactionSent { .. } + )); + + // Bob takes longer: cancel timelock -> partial refund -> remaining refund timelock -> amnesty + let bob_state = bob_swap.await??; + ctx.assert_bob_amnesty_received(bob_state).await; + + // Restart Alice so she can refund her XMR + ctx.restart_alice().await; + let alice_swap = ctx.alice_next_swap().await; + let alice_swap = tokio::spawn(alice::run(alice_swap, FixedRate::default())); + + let alice_state = alice_swap.await??; + ctx.assert_alice_refunded(alice_state).await; + + Ok(()) + }) + .await; +} diff --git a/swap/tests/punish.rs b/swap/tests/punish.rs index 4829c7ebec..0d5d2d47db 100644 --- a/swap/tests/punish.rs +++ b/swap/tests/punish.rs @@ -10,7 +10,7 @@ use swap::protocol::{alice, bob}; /// the encsig and fail to refund or redeem. Alice punishes. Bob then cooperates with Alice and redeems XMR with her key. #[tokio::test] async fn alice_punishes_if_bob_never_acts_after_fund() { - harness::setup_test(FastPunishConfig, None, |mut ctx| async move { + harness::setup_test(FastPunishConfig, None, None, |mut ctx| async move { let (bob_swap, bob_join_handle) = ctx.bob_swap().await; let bob_swap_id = bob_swap.id; let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked));