feat: Orchestration & Controller (#492)

* fix: formatting in stress_test.rs

* refactor: move asb binary into swap-asb

* refactor(swap-asb): remove unused disable_timestamp argument

* fix(monero-sys): Include search path for aarch64-linux-gnu

* fix(swap): notpick formatting in swap.rs

* feat(swap-env): Split up config wizard, add default rendezvous points

* feat(swap-controller, swap-controller-api): Minimal maker shell with just a check-connection command

* fix(monero-rpc-pool): Use new axum route path syntax to prevent runtime panic

* feat(ci): Specify crate when building binaries; build asb-controller

* fix: Add swap-controller, swap-controller-api crates and their dependencies to Cargo.toml

* feat(Dockerfile): Build asb-controller; default to 1.87 rust toolchain

* feat(swap-orchestrator): Compose spec generator

* formatting: nitpicks

* fix: add swap-orchestrator auto generated files to gitginore

* refactoring(swap-orchestrator: Use Into<_> to derive asb::Network and electrs::Network from Bitcoin/Monero network, use defaults

* feat(swap-env): Change default bitcoin_confirmation_target to 1

* feat: Dockerfile for asb-controller, bitcoin-balance and monero-balance controller commands

* formatting: nitpicks

* changelog: default bitcoin finality confirmations change

* feat(ci): Build swap-orchestrator binary

* disable rpc server by default, split rpc-bind into rpc-bind-port and rpc-bind-host

* feat(swap-controller): Add monero-address command to print primary address of internal wallet

* chore: upgrade rustyline to 17.0.0

* changelog: Document CONTROLLER, ORCHESTRATOR and JSON-RPC server

* refactor: Change swap-orchestrator binary to just "orchestrator"

* refactor: let RpcServer::start(...) take port and host seperately

* default electrum servers in config wizard

* formatting

* feat(swap-orchestrator): README

* feat(swap-controller): Add Multiaddresses and ActiveConnections command

Signed-off-by: Binarybaron <binarybaron@protonmail.com>

* refactor(asb/event_loop.rs): Move quote logic and tower service into their own modules

* fix(swap): some unit tests

* feat(swap-controller): redumentary repl command auto complete

* formatting

* feat(swap-orchestrator): Burn Git commit hash into orchestrator binary

* feat(swap-orchestrator): burn git commit hash into binary when building from source

* feat(Dockerfiles): Build with --locked

* feat: derive ports for images from network combination

add some doc into the docker compose file

* small refactorings

* feat(swap-controller): Add get-swaps command

* feat: add more default electrum mainnet nodes

* feat: build asb-controller docker image in ci, move asb Dockerfile into swap-asb

* fix: do not allow pre-built docker images for now

* amend changelog

* remove default monero_daemon_url, default to None (Monero RPC pool)

* unify asb and orchestrator wizard for monero daemon url setup

---------

Signed-off-by: Binarybaron <binarybaron@protonmail.com>
This commit is contained in:
Mohan 2025-08-06 15:33:41 +02:00 committed by GitHub
parent 7c82853050
commit 97a4a31af9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 2979 additions and 704 deletions

View file

@ -1,5 +1,8 @@
# Rust build artifacts
target/
target-check/
Dockerfile
# IDE files
.vscode/

View file

@ -19,46 +19,110 @@ jobs:
matrix:
include:
- bin: swap
crate: swap
target: x86_64-unknown-linux-gnu
os: ubuntu-latest
archive_ext: tar
# Temporarily disabled armv7 Linux builds
# - bin: swap
# crate: swap
# target: armv7-unknown-linux-gnueabihf
# os: ubuntu-latest
# archive_ext: tar
- bin: swap
crate: swap
target: x86_64-apple-darwin
os: macos-13
archive_ext: tar
- bin: swap
crate: swap
target: aarch64-apple-darwin
os: macos-latest
archive_ext: tar
# Temporarily disabled Windows builds
# - bin: swap
# crate: swap
# target: x86_64-pc-windows-msvc
# os: windows-latest
# archive_ext: zip
- bin: asb
crate: swap
target: x86_64-unknown-linux-gnu
os: ubuntu-latest
archive_ext: tar
# Temporarily disabled armv7 Linux builds
# - bin: asb
# crate: swap
# target: armv7-unknown-linux-gnueabihf
# os: ubuntu-latest
# archive_ext: tar
- bin: asb
crate: swap
target: x86_64-apple-darwin
os: macos-13
archive_ext: tar
- bin: asb
crate: swap
target: aarch64-apple-darwin
os: macos-latest
archive_ext: tar
# Temporarily disabled Windows builds
# - bin: asb
# crate: swap
# target: x86_64-pc-windows-msvc
# os: windows-latest
# archive_ext: zip
- bin: asb-controller
crate: swap-controller
target: x86_64-unknown-linux-gnu
os: ubuntu-latest
archive_ext: tar
# Temporarily disabled armv7 Linux builds
# - bin: asb-controller
# crate: swap-controller
# target: armv7-unknown-linux-gnueabihf
# os: ubuntu-latest
# archive_ext: tar
- bin: asb-controller
crate: swap-controller
target: x86_64-apple-darwin
os: macos-13
archive_ext: tar
- bin: asb-controller
crate: swap-controller
target: aarch64-apple-darwin
os: macos-latest
archive_ext: tar
# Temporarily disabled Windows builds
# - bin: asb-controller
# crate: swap-controller
# target: x86_64-pc-windows-msvc
# os: windows-latest
# archive_ext: zip
- bin: orchestrator
crate: swap-orchestrator
target: x86_64-unknown-linux-gnu
os: ubuntu-latest
archive_ext: tar
# Temporarily disabled armv7 Linux builds
# - bin: orchestrator
# crate: swap-orchestrator
# target: armv7-unknown-linux-gnueabihf
# os: ubuntu-latest
# archive_ext: tar
- bin: orchestrator
crate: swap-orchestrator
target: x86_64-apple-darwin
os: macos-13
archive_ext: tar
- bin: orchestrator
crate: swap-orchestrator
target: aarch64-apple-darwin
os: macos-latest
archive_ext: tar
# Temporarily disabled Windows builds
# - bin: orchestrator
# crate: swap-orchestrator
# target: x86_64-pc-windows-msvc
# os: windows-latest
# archive_ext: zip
@ -138,11 +202,11 @@ jobs:
curl -L "https://github.com/cross-rs/cross/releases/download/v0.2.5/cross-x86_64-unknown-linux-gnu.tar.gz" | tar xzv
sudo mv cross /usr/bin
sudo mv cross-util /usr/bin
cross build --target=${{ matrix.target }} --release --package swap --bin ${{ matrix.bin }}
cross build --target=${{ matrix.target }} --release --package ${{ matrix.crate }} --bin ${{ matrix.bin }}
- name: Build ${{ matrix.target }} ${{ matrix.bin }} release binary
if: matrix.target != 'armv7-unknown-linux-gnueabihf'
run: cargo build --target=${{ matrix.target }} --release --package swap --bin ${{ matrix.bin }}
run: cargo build --target=${{ matrix.target }} --release --package ${{ matrix.crate }} --bin ${{ matrix.bin }}
- name: Smoke test the binary
if: matrix.target != 'armv7-unknown-linux-gnueabihf'
@ -255,22 +319,42 @@ jobs:
echo "preview=false" >> $GITHUB_OUTPUT
fi
- name: Build and push Docker image
- name: Build and push Docker image (asb)
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
file: ./swap-asb/Dockerfile
push: true
tags: |
${{ env.DOCKER_IMAGE_NAME }}:${{ github.event.release.tag_name }}
${{ env.DOCKER_IMAGE_NAME }}:latest
if: steps.docker_tags.outputs.preview == 'false'
- name: Build and push Docker image without latest tag (preview release)
- name: Build and push Docker image without latest tag (preview release) (asb)
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
file: ./swap-asb/Dockerfile
push: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:${{ github.event.release.tag_name }}
if: steps.docker_tags.outputs.preview == 'true'
- name: Build and push Docker image (asb)
uses: docker/build-push-action@v4
with:
context: .
file: ./swap-controller/Dockerfile
push: true
tags: |
${{ env.DOCKER_IMAGE_NAME }}:${{ github.event.release.tag_name }}
${{ env.DOCKER_IMAGE_NAME }}:latest
if: steps.docker_tags.outputs.preview == 'false'
- name: Build and push Docker image without latest tag (preview release) (asb)
uses: docker/build-push-action@v4
with:
context: .
file: ./swap-controller/Dockerfile
push: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:${{ github.event.release.tag_name }}
if: steps.docker_tags.outputs.preview == 'true'

15
.gitignore vendored
View file

@ -1,10 +1,19 @@
target/
target-check/
.vscode
.claude/settings.local.json
.DS_Store
build/
release-build.sh
cn_macos
target-check
monero-rpc-pool/temp_db.sqlite
monero-rpc-pool/temp.db
# auto-generated files by swap-orchestrator
swap-orchestrator/docker-compose.yml
swap-orchestrator/config.toml
# release build generator scripts
release-build.sh
cn_macos

View file

@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- GUI + CLI + ASB: The Monero RPC pool now caches TCP and Tor streams
- ASB: The default configuration has been adjusted to accept Bitcoin transactions as finalized after one block (one confirmation). Bitcoin double spends are essentially impossible for practical purposes. If one is swapping extremely large amounts, they can consider dialing `bitcoin.finality_confirmations` to `2` or `3`. This will however force the swap to take much longer to complete, and also increase the risk of a refund being made.
- ASB: The `monero.monero_node_pool` flag has been removed from the config. If you want to use the Monero Node Pool, you can now do so simply by omitting `monero.daemon_url` from the config.
- ASB: The `asb` now exposes a JSON-RPC endpoint at which it can receive commands. The `asb-controller` binary implements the client side of the JSON-RPC protocol. The JSON-RPC protocol is currently entirely read-only. This means it cannot be used to withdraw funds or change configurations. The JSON-RPC endpoint is disabled by default. It can be enabled by passing the `--rpc-bind-port 127.0.0.1:9944` and `--rpc-bind-host 127.0.0.1` flags to the `asb` binary.
- CONTROLLER: A new experimental `asb-controller` binary is now shipped. It is a CLI and REPL tool to interact with an ASB over JSON-RPC. It still has limited functionality, but will be extended in the future. Currently, it can be used to:
- Get the primary address of th Monero wallet
- Get the balance of the Monero wallet
- Get the balance of the Bitcoin wallet
- Get the list of external multiaddresses that the ASB is listening on
- Query the currently active peer-to-peer connections
- Get a basic list of swaps from the database
- ORCHESTRATOR: A new experiemental `orchestrator` binary is now shipped.
- The `orchestrator` is a lightweight tool to generate a production grade environement for running ASBs.
- It guides the user through a wizard and generates a custom [Docker compose](https://docs.docker.com/compose/) file which specifies a secure Docker environment for running ASBs.
- This will continue to evolve over time, and we will document this thoroughly once it is more stable.
## [3.0.0-beta.5] - 2025-08-04

404
Cargo.lock generated
View file

@ -570,9 +570,9 @@ dependencies = [
[[package]]
name = "async-lock"
version = "3.4.0"
version = "3.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18"
checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc"
dependencies = [
"event-listener",
"event-listener-strategy",
@ -1569,7 +1569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77"
dependencies = [
"serde",
"toml 0.9.4",
"toml 0.9.5",
]
[[package]]
@ -1845,6 +1845,33 @@ dependencies = [
"unicode-width 0.2.1",
]
[[package]]
name = "compose_spec"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fd9b9dc67f2b3024582ec6d861950f0af0aeaabb8350ccda1f0e51ff8e5895c"
dependencies = [
"compose_spec_macros",
"indexmap 2.10.0",
"ipnet",
"itoa",
"serde",
"serde_yaml",
"thiserror 1.0.69",
"url",
]
[[package]]
name = "compose_spec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b77735bd89be8da01c8d7e61faec5a9ccb0e313cece3c773c6b3ae251b90c7d4"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@ -3137,7 +3164,7 @@ dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 0.9.4",
"toml 0.9.5",
"vswhom",
"winreg 0.55.0",
]
@ -3169,6 +3196,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf"
[[package]]
name = "endian-type"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
[[package]]
name = "enum-as-inner"
version = "0.6.1"
@ -3270,9 +3303,9 @@ dependencies = [
[[package]]
name = "event-listener"
version = "5.4.0"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
@ -3307,6 +3340,17 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fd-lock"
version = "4.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix 1.0.8",
"windows-sys 0.59.0",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
@ -3628,9 +3672,9 @@ checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
version = "2.6.0"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
@ -4526,6 +4570,7 @@ dependencies = [
"http 1.3.1",
"hyper 1.6.0",
"hyper-util",
"log",
"rustls 0.23.31",
"rustls-native-certs 0.8.1",
"rustls-pki-types",
@ -4892,6 +4937,9 @@ name = "ipnet"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
dependencies = [
"serde",
]
[[package]]
name = "iri-string"
@ -5098,6 +5146,121 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "jsonrpsee"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fba77a59c4c644fd48732367624d1bcf6f409f9c9a286fbc71d2f1fc0b2ea16"
dependencies = [
"jsonrpsee-core",
"jsonrpsee-http-client",
"jsonrpsee-proc-macros",
"jsonrpsee-server",
"jsonrpsee-types",
"tokio",
"tracing",
]
[[package]]
name = "jsonrpsee-core"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "693c93cbb7db25f4108ed121304b671a36002c2db67dff2ee4391a688c738547"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"jsonrpsee-types",
"parking_lot 0.12.4",
"pin-project",
"rand 0.9.2",
"rustc-hash",
"serde",
"serde_json",
"thiserror 2.0.12",
"tokio",
"tower 0.5.2",
"tracing",
]
[[package]]
name = "jsonrpsee-http-client"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6962d2bd295f75e97dd328891e58fce166894b974c1f7ce2e7597f02eeceb791"
dependencies = [
"base64 0.22.1",
"http-body 1.0.1",
"hyper 1.6.0",
"hyper-rustls",
"hyper-util",
"jsonrpsee-core",
"jsonrpsee-types",
"rustls 0.23.31",
"rustls-platform-verifier",
"serde",
"serde_json",
"thiserror 2.0.12",
"tokio",
"tower 0.5.2",
"url",
]
[[package]]
name = "jsonrpsee-proc-macros"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fa4f5daed39f982a1bb9d15449a28347490ad42b212f8eaa2a2a344a0dce9e9"
dependencies = [
"heck 0.5.0",
"proc-macro-crate 3.3.0",
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "jsonrpsee-server"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38b0bcf407ac68d241f90e2d46041e6a06988f97fe1721fb80b91c42584fae6"
dependencies = [
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"hyper 1.6.0",
"hyper-util",
"jsonrpsee-core",
"jsonrpsee-types",
"pin-project",
"route-recognizer",
"serde",
"serde_json",
"soketto",
"thiserror 2.0.12",
"tokio",
"tokio-stream",
"tokio-util",
"tower 0.5.2",
"tracing",
]
[[package]]
name = "jsonrpsee-types"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66df7256371c45621b3b7d2fb23aea923d577616b9c0e9c0b950a6ea5c2be0ca"
dependencies = [
"http 1.3.1",
"serde",
"serde_json",
"thiserror 2.0.12",
]
[[package]]
name = "k12"
version = "0.3.0"
@ -6516,6 +6679,15 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nibble_vec"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43"
dependencies = [
"smallvec",
]
[[package]]
name = "nix"
version = "0.26.4"
@ -6564,9 +6736,9 @@ dependencies = [
[[package]]
name = "notify"
version = "8.1.0"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3163f59cd3fa0e9ef8c32f242966a7b9994fd7378366099593e0e73077cd8c97"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.9.1",
"inotify",
@ -7586,7 +7758,7 @@ checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1"
dependencies = [
"base64 0.22.1",
"indexmap 2.10.0",
"quick-xml 0.38.0",
"quick-xml 0.38.1",
"serde",
"time 0.3.41",
]
@ -7634,9 +7806,9 @@ dependencies = [
[[package]]
name = "polling"
version = "3.9.0"
version = "3.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee9b2fa7a4517d2c91ff5bc6c297a427a96749d15f98fcdbb22c05571a4d4b7"
checksum = "b5bd19146350fe804f7cb2669c851c03d69da628803dab0d98018142aaa5d829"
dependencies = [
"cfg-if",
"concurrent-queue",
@ -7950,9 +8122,9 @@ dependencies = [
[[package]]
name = "quick-xml"
version = "0.38.0"
version = "0.38.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8927b0664f5c5a98265138b7e3f90aa19a6b21353182469ace36d4ac527b7b1b"
checksum = "9845d9dccf565065824e69f9f235fafba1587031eda353c1f1561cd6a6be78f4"
dependencies = [
"memchr",
]
@ -8056,6 +8228,16 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "radix_trie"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd"
dependencies = [
"endian-type",
"nibble_vec",
]
[[package]]
name = "rand"
version = "0.7.3"
@ -8559,6 +8741,12 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "route-recognizer"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746"
[[package]]
name = "rsa"
version = "0.9.8"
@ -8796,6 +8984,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls 0.23.31",
"rustls-native-certs 0.8.1",
"rustls-platform-verifier-android",
"rustls-webpki 0.103.4",
"security-framework 3.2.0",
"security-framework-sys",
"webpki-root-certs 0.26.11",
"windows-sys 0.59.0",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.101.7"
@ -8835,6 +9050,28 @@ dependencies = [
"wait-timeout",
]
[[package]]
name = "rustyline"
version = "17.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed34fbd08950d17f8297e738d5b76acd4baab50c8d45008d498b4327feb43ea1"
dependencies = [
"bitflags 2.9.1",
"cfg-if",
"clipboard-win",
"fd-lock",
"home",
"libc",
"log",
"memchr",
"nix 0.30.1",
"radix_trie",
"unicode-segmentation",
"unicode-width 0.2.1",
"utf8parse",
"windows-sys 0.60.2",
]
[[package]]
name = "rw-stream-sink"
version = "0.4.0"
@ -9390,6 +9627,19 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.10.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serial_test"
version = "3.2.0"
@ -9585,9 +9835,9 @@ dependencies = [
[[package]]
name = "signal-hook-registry"
version = "1.4.5"
version = "1.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410"
checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b"
dependencies = [
"libc",
]
@ -9759,6 +10009,7 @@ dependencies = [
"base64 0.22.1",
"bytes",
"futures",
"http 1.3.1",
"httparse",
"log",
"rand 0.8.5",
@ -10258,6 +10509,7 @@ dependencies = [
"futures",
"get-port",
"hex",
"jsonrpsee",
"libp2p",
"libp2p-tor",
"mockito",
@ -10289,6 +10541,7 @@ dependencies = [
"sqlx",
"structopt",
"strum 0.26.3",
"swap-controller-api",
"swap-env",
"swap-feed",
"swap-fs",
@ -10319,6 +10572,60 @@ dependencies = [
"zip 0.5.13",
]
[[package]]
name = "swap-asb"
version = "3.0.0-beta.3"
dependencies = [
"anyhow",
"bitcoin 0.32.6",
"comfy-table",
"libp2p",
"monero-rpc-pool",
"monero-sys",
"rust_decimal",
"rustls 0.23.31",
"serde",
"serde_json",
"structopt",
"swap",
"swap-env",
"swap-feed",
"swap-serde",
"thiserror 1.0.69",
"tokio",
"tracing",
"tracing-subscriber",
"url",
"uuid",
"vergen",
]
[[package]]
name = "swap-controller"
version = "0.1.0"
dependencies = [
"anyhow",
"clap 4.5.42",
"jsonrpsee",
"monero",
"rustyline",
"serde_json",
"shell-words",
"swap-controller-api",
"tokio",
]
[[package]]
name = "swap-controller-api"
version = "0.1.0"
dependencies = [
"bitcoin 0.32.6",
"jsonrpsee",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "swap-env"
version = "0.1.0"
@ -10335,7 +10642,7 @@ dependencies = [
"swap-serde",
"thiserror 1.0.69",
"time 0.3.41",
"toml 0.8.23",
"toml 0.9.5",
"tracing",
"url",
]
@ -10369,6 +10676,23 @@ dependencies = [
"tracing",
]
[[package]]
name = "swap-orchestrator"
version = "0.1.0"
dependencies = [
"anyhow",
"bitcoin 0.32.6",
"chrono",
"compose_spec",
"dialoguer",
"monero",
"serde_yaml",
"swap-env",
"toml 0.9.5",
"url",
"vergen",
]
[[package]]
name = "swap-serde"
version = "0.1.0"
@ -11001,7 +11325,7 @@ checksum = "7c6d9028d41d4de835e3c482c677a8cb88137ac435d6ff9a71f392d4421576c9"
dependencies = [
"embed-resource",
"indexmap 2.10.0",
"toml 0.9.4",
"toml 0.9.5",
]
[[package]]
@ -11290,6 +11614,7 @@ dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
"tokio-util",
]
[[package]]
@ -11339,16 +11664,15 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.15"
version = "0.7.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df"
checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
dependencies = [
"bytes",
"futures-core",
"futures-io",
"futures-sink",
"futures-util",
"hashbrown 0.15.4",
"pin-project-lite",
"tokio",
]
@ -11367,9 +11691,9 @@ dependencies = [
[[package]]
name = "toml"
version = "0.9.4"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41ae868b5a0f67631c14589f7e250c1ea2c574ee5ba21c6c8dd4b1485705a5a1"
checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8"
dependencies = [
"indexmap 2.10.0",
"serde",
@ -11436,9 +11760,9 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.0.1"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30"
checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10"
dependencies = [
"winnow 0.7.12",
]
@ -12885,6 +13209,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "unsigned-varint"
version = "0.7.2"
@ -13410,6 +13740,24 @@ dependencies = [
"untrusted 0.9.0",
]
[[package]]
name = "webpki-root-certs"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e"
dependencies = [
"webpki-root-certs 1.0.2",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e4ffd8df1c57e87c325000a3d6ef93db75279dc3a231125aac571650f22b12a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.21.1"
@ -14434,9 +14782,9 @@ dependencies = [
[[package]]
name = "zerovec"
version = "0.11.2"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428"
checksum = "bdbb9122ea75b11bf96e7492afb723e8a7fbe12c67417aa95e7e3d18144d37cd"
dependencies = [
"yoke",
"zerofrom",

View file

@ -10,9 +10,13 @@ members = [
"monero-sys",
"src-tauri",
"swap",
"swap-asb",
"swap-controller",
"swap-controller-api",
"swap-env",
"swap-feed",
"swap-fs",
"swap-orchestrator",
"swap-serde",
"throttle",
]
@ -30,6 +34,7 @@ anyhow = "1"
backoff = { version = "0.4", features = ["futures", "tokio"] }
futures = { version = "0.3", default-features = false, features = ["std"] }
hex = "0.4"
jsonrpsee = { version = "0.25", default-features = false }
libp2p = { version = "0.53.2" }
monero = { version = "0.12", features = ["serde_support"] }
once_cell = "1.19"
@ -41,6 +46,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
thiserror = "1"
tokio = { version = "1", features = ["rt-multi-thread", "time", "macros", "sync"] }
toml = "0.9.5"
tracing = { version = "0.1", features = ["attributes"] }
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "ansi", "env-filter", "time", "tracing-log", "json"] }
typeshare = "1.0"
@ -54,6 +60,12 @@ tor-hsservice = { git = "https://github.com/eigenwallet/arti", rev = "18111286b5
tor-proto = { git = "https://github.com/eigenwallet/arti", rev = "18111286b5830cda88af5df1950b5e24ee5a8841" }
tor-rtcompat = { git = "https://github.com/eigenwallet/arti", rev = "18111286b5830cda88af5df1950b5e24ee5a8841" }
# Terminal Utilities
dialoguer = "0.11"
# Build dependencies
vergen = { version = "8.3", default-features = false, features = ["build", "git", "git2"] }
[patch.crates-io]
# patch until new release https://github.com/thomaseizinger/rust-jsonrpc-client/pull/51
jsonrpc_client = { git = "https://github.com/delta1/rust-jsonrpc-client.git", rev = "3b6081697cd616c952acb9c2f02d546357d35506" }

View file

@ -65,11 +65,11 @@ test_monero_sys:
# Builds the ASB and Swap binaries
swap:
cd swap && cargo build --bin asb --bin=swap
cargo build -p swap-asb --bin asb && cd swap && cargo build --bin=swap
# Run the asb on testnet
asb-testnet:
cd swap && cargo run --bin asb -- --trace --testnet start
cargo run -p swap-asb --bin asb -- --trace --testnet start
# Updates our submodules (currently only Monero C++ codebase)
update_submodules:

View file

@ -1,7 +1,11 @@
use arti_client::{TorClient, TorClientConfig};
use clap::Parser;
use monero::Network;
use monero_rpc_pool::{config::Config, create_app_with_receiver, database::{parse_network, network_to_string}};
use monero_rpc_pool::{
config::Config,
create_app_with_receiver,
database::{network_to_string, parse_network},
};
use reqwest;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;

View file

@ -103,7 +103,7 @@ pub async fn create_app_with_receiver(
// Build the app
let app = Router::new()
.route("/stats", get(stats_handler))
.route("/*path", any(proxy_handler))
.route("/{*path}", any(proxy_handler))
.layer(CorsLayer::permissive())
.with_state(app_state);

View file

@ -128,6 +128,7 @@ fn main() {
monero_build_dir.join("external/randomx").display()
);
println!("cargo:rustc-link-search=native=/usr/lib/x86_64-linux-gnu");
println!("cargo:rustc-link-search=native=/usr/lib/aarch64-linux-gnu");
println!(
"cargo:rustc-link-search=native={}",

View file

@ -1,5 +1,5 @@
[toolchain]
# also update this in the readme, changelog, and github actions
channel = "1.87"
channel = "1.87.0"
components = ["clippy"]
targets = ["armv7-unknown-linux-gnueabihf"]

View file

@ -120,6 +120,10 @@ const initialState: SettingsState = {
"ssl://bitcoin.stackwallet.com:50002",
"ssl://b.1209k.com:50002",
"tcp://electrum.coinucopia.io:50001",
"ssl://mainnet.foundationdevices.com:50002",
"tcp://bitcoin.lu.ke:50001",
"tcp://se-mma-crypto-payments-001.mullvad.net:50001",
"ssl://electrum.coinfinity.co:50002",
],
[Blockchain.Monero]: [],
},

43
swap-asb/Cargo.toml Normal file
View file

@ -0,0 +1,43 @@
[package]
name = "swap-asb"
version = "3.0.0-beta.3"
authors = ["The eigenwallet guys <hello@eigenwallet.org>", "The COMIT guys <hello@comit.network>"]
edition = "2021"
description = "ASB (Automated Swap Backend) binary for XMR/BTC atomic swaps."
[[bin]]
name = "asb"
path = "src/main.rs"
[dependencies]
# Core
anyhow = { workspace = true }
bitcoin = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
url = { workspace = true }
uuid = { workspace = true, features = ["serde"] }
# Workspace dependencies
monero-rpc-pool = { path = "../monero-rpc-pool" }
monero-sys = { path = "../monero-sys" }
swap = { path = "../swap" }
swap-env = { path = "../swap-env" }
swap-feed = { path = "../swap-feed" }
swap-serde = { path = "../swap-serde" }
# Async
tokio = { workspace = true, features = ["process", "fs", "net", "parking_lot", "rt"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
comfy-table = "7.1"
libp2p = { workspace = true, features = ["tcp", "yamux", "dns", "noise", "request-response", "ping", "rendezvous", "identify", "macros", "cbor", "json", "tokio", "serde", "rsa"] }
rust_decimal = { workspace = true, features = ["serde-float"] }
rustls = { version = "0.23", default-features = false, features = ["ring"] }
serde_json = { workspace = true }
structopt = "0.3"
[build-dependencies]
anyhow = { workspace = true }
vergen = { workspace = true }

View file

@ -1,11 +1,14 @@
# This Dockerfile builds the asb binary
FROM ubuntu:24.04 AS builder
# We need to build on Ubuntu because the monero-sys crate requires a bunch of system dependencies
# We will try to use a smaller image here at some point
#
# Latest Ubuntu 24.04 image as of Tue, 05 Aug 2025 15:34:08 GMT
FROM ubuntu:24.04@sha256:a08e551cb33850e4740772b38217fc1796a66da2506d312abe51acda354ff061 AS builder
WORKDIR /build
# Install dependencies
# See .github/workflows/action.yml as well
# See .github/workflows/action.yml
RUN apt-get update && \
apt-get install -y \
git \
@ -36,8 +39,9 @@ RUN apt-get update && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Rust 1.85
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain 1.85.0
# Install Rust 1.87.0
# See ./rust-toolchain.toml for the Rust version
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain 1.87.0
ENV PATH="/root/.cargo/bin:${PATH}"
COPY . .
@ -53,12 +57,15 @@ WORKDIR /build/swap
# Act as if we are in a GitHub Actions environment
ENV DOCKER_BUILD=true
RUN cargo build -vv --bin=asb
RUN cargo build --locked -vv -p swap-asb --bin=asb
RUN cargo build --locked -vv -p swap-controller --bin=asb-controller
FROM ubuntu:24.04
# Latest Ubuntu 24.04 image as of Tue, 05 Aug 2025 15:34:08 GMT
FROM ubuntu:24.04@sha256:a08e551cb33850e4740772b38217fc1796a66da2506d312abe51acda354ff061 AS runner
WORKDIR /data
COPY --from=builder /build/target/debug/asb /bin/asb
COPY --from=builder /build/target/debug/asb-controller /bin/asb-controller
ENTRYPOINT ["asb"]

9
swap-asb/build.rs Normal file
View file

@ -0,0 +1,9 @@
use anyhow::Result;
use vergen::EmitBuilder;
fn main() -> Result<()> {
EmitBuilder::builder()
.git_describe(true, true, None)
.emit()?;
Ok(())
}

View file

@ -1,12 +1,14 @@
use crate::bitcoin::{bitcoin_address, Amount};
use anyhow::Result;
use anyhow::{bail, Context, Result};
use bitcoin::address::NetworkUnchecked;
use bitcoin::Address;
use serde::Serialize;
use std::ffi::OsString;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::str::FromStr;
use structopt::StructOpt;
use swap_env::config::GetDefaults;
use swap::bitcoin::{bitcoin_address, Amount};
use swap_env::defaults::GetDefaults;
use swap_env::env;
use swap_env::env::GetConfig;
use uuid::Uuid;
@ -26,14 +28,27 @@ where
let command: RawCommand = args.cmd;
let arguments = match command {
RawCommand::Start { resume_only } => Arguments {
testnet,
json,
trace,
config_path: config_path(config, testnet)?,
env_config: env_config(testnet),
cmd: Command::Start { resume_only },
},
RawCommand::Start {
resume_only,
rpc_bind_host,
rpc_bind_port,
} => {
// Validate RPC bind arguments early
validate_rpc_bind_args(&rpc_bind_host, &rpc_bind_port)?;
Arguments {
testnet,
json,
trace,
config_path: config_path(config, testnet)?,
env_config: env_config(testnet),
cmd: Command::Start {
resume_only,
rpc_bind_host,
rpc_bind_port,
},
}
}
RawCommand::History { only_unfinished } => Arguments {
testnet,
json,
@ -202,6 +217,8 @@ pub struct Arguments {
pub enum Command {
Start {
resume_only: bool,
rpc_bind_host: Option<String>,
rpc_bind_port: Option<u16>,
},
History {
only_unfinished: bool,
@ -258,13 +275,6 @@ pub struct RawArguments {
#[structopt(long = "trace", help = "Also output verbose tracing logs to stdout")]
pub trace: bool,
#[structopt(
short,
long = "disable-timestamp",
help = "Disable timestamping of log messages"
)]
pub disable_timestamp: bool,
#[structopt(
long = "config",
help = "Provide a custom path to the configuration file. The configuration file must be a toml file.",
@ -286,6 +296,16 @@ pub enum RawCommand {
help = "For maintenance only. When set, no new swap requests will be accepted, but existing unfinished swaps will be resumed."
)]
resume_only: bool,
#[structopt(
long = "rpc-bind-host",
help = "Host address to bind the JSON-RPC server to (e.g., 127.0.0.1). Must be used together with --rpc-bind-port."
)]
rpc_bind_host: Option<String>,
#[structopt(
long = "rpc-bind-port",
help = "Port to bind the JSON-RPC server to (e.g., 9944). Must be used together with --rpc-bind-host."
)]
rpc_bind_port: Option<u16>,
},
#[structopt(about = "Prints all logging messages issued in the past.")]
Logs {
@ -390,6 +410,24 @@ pub struct RecoverCommandParams {
pub swap_id: Uuid,
}
fn validate_rpc_bind_args(host: &Option<String>, port: &Option<u16>) -> Result<()> {
match (host, port) {
(Some(host_str), Some(port_val)) => {
let socket_addr = format!("{}:{}", host_str, port_val);
SocketAddr::from_str(&socket_addr).context("Invalid socket address")?;
Ok(())
}
(None, None) => Ok(()),
(Some(_), None) => {
bail!("--rpc-bind-host was provided but --rpc-bind-port was not. Both must be provided together or neither.");
}
(None, Some(_)) => {
bail!("--rpc-bind-port was provided but --rpc-bind-host was not. Both must be provided together or neither.");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -414,7 +452,11 @@ mod tests {
trace: false,
config_path: default_mainnet_conf_path,
env_config: mainnet_env_config,
cmd: Command::Start { resume_only: false },
cmd: Command::Start {
resume_only: false,
rpc_bind_host: None,
rpc_bind_port: None,
},
};
let args = parse_args(raw_ars).unwrap();
assert_eq!(expected_args, args);
@ -456,6 +498,7 @@ mod tests {
trace: false,
config_path: default_mainnet_conf_path,
env_config: mainnet_env_config,
cmd: Command::Balance,
};
let args = parse_args(raw_ars).unwrap();
@ -480,6 +523,7 @@ mod tests {
trace: false,
config_path: default_mainnet_conf_path,
env_config: mainnet_env_config,
cmd: Command::WithdrawBtc {
amount: None,
address: bitcoin_address::parse_and_validate(BITCOIN_MAINNET_ADDRESS, false)
@ -510,6 +554,7 @@ mod tests {
trace: false,
config_path: default_mainnet_conf_path,
env_config: mainnet_env_config,
cmd: Command::Cancel {
swap_id: Uuid::parse_str(SWAP_ID).unwrap(),
},
@ -538,6 +583,7 @@ mod tests {
trace: false,
config_path: default_mainnet_conf_path,
env_config: mainnet_env_config,
cmd: Command::Refund {
swap_id: Uuid::parse_str(SWAP_ID).unwrap(),
},
@ -566,6 +612,7 @@ mod tests {
trace: false,
config_path: default_mainnet_conf_path,
env_config: mainnet_env_config,
cmd: Command::Punish {
swap_id: Uuid::parse_str(SWAP_ID).unwrap(),
},
@ -594,6 +641,7 @@ mod tests {
trace: false,
config_path: default_mainnet_conf_path,
env_config: mainnet_env_config,
cmd: Command::SafelyAbort {
swap_id: Uuid::parse_str(SWAP_ID).unwrap(),
},
@ -616,7 +664,12 @@ mod tests {
trace: false,
config_path: default_testnet_conf_path,
env_config: testnet_env_config,
cmd: Command::Start { resume_only: false },
cmd: Command::Start {
resume_only: false,
rpc_bind_host: None,
rpc_bind_port: None,
},
};
let args = parse_args(raw_ars).unwrap();
assert_eq!(expected_args, args);
@ -636,6 +689,7 @@ mod tests {
trace: false,
config_path: default_testnet_conf_path,
env_config: testnet_env_config,
cmd: Command::History {
only_unfinished: false,
},
@ -658,6 +712,7 @@ mod tests {
trace: false,
config_path: default_testnet_conf_path,
env_config: testnet_env_config,
cmd: Command::Balance,
};
let args = parse_args(raw_ars).unwrap();
@ -678,6 +733,7 @@ mod tests {
trace: false,
config_path: default_testnet_conf_path,
env_config: testnet_env_config,
cmd: Command::ExportMoneroWallet,
};
let args = parse_args(raw_ars).unwrap();
@ -705,6 +761,7 @@ mod tests {
trace: false,
config_path: default_testnet_conf_path,
env_config: testnet_env_config,
cmd: Command::WithdrawBtc {
amount: None,
address: bitcoin_address::parse_and_validate(BITCOIN_TESTNET_ADDRESS, true)
@ -735,6 +792,7 @@ mod tests {
trace: false,
config_path: default_testnet_conf_path,
env_config: testnet_env_config,
cmd: Command::Cancel {
swap_id: Uuid::parse_str(SWAP_ID).unwrap(),
},
@ -764,6 +822,7 @@ mod tests {
trace: false,
config_path: default_testnet_conf_path,
env_config: testnet_env_config,
cmd: Command::Refund {
swap_id: Uuid::parse_str(SWAP_ID).unwrap(),
},
@ -793,6 +852,7 @@ mod tests {
trace: false,
config_path: default_testnet_conf_path,
env_config: testnet_env_config,
cmd: Command::Punish {
swap_id: Uuid::parse_str(SWAP_ID).unwrap(),
},
@ -822,6 +882,7 @@ mod tests {
trace: false,
config_path: default_testnet_conf_path,
env_config: testnet_env_config,
cmd: Command::SafelyAbort {
swap_id: Uuid::parse_str(SWAP_ID).unwrap(),
},
@ -830,26 +891,6 @@ mod tests {
assert_eq!(expected_args, args);
}
#[test]
fn ensure_disable_timestamp_mapping() {
let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults()
.unwrap()
.config_path;
let mainnet_env_config = env::Mainnet::get_config();
let raw_ars = vec![BINARY_NAME, "--disable-timestamp", "start"];
let expected_args = Arguments {
testnet: false,
json: false,
trace: false,
config_path: default_mainnet_conf_path,
env_config: mainnet_env_config,
cmd: Command::Start { resume_only: false },
};
let args = parse_args(raw_ars).unwrap();
assert_eq!(expected_args, args);
}
#[test]
fn ensure_trace_mapping() {
let default_mainnet_conf_path = env::Mainnet::get_config_file_defaults()
@ -864,7 +905,12 @@ mod tests {
trace: true,
config_path: default_mainnet_conf_path,
env_config: mainnet_env_config,
cmd: Command::Start { resume_only: false },
cmd: Command::Start {
resume_only: false,
rpc_bind_host: None,
rpc_bind_port: None,
},
};
let args = parse_args(raw_ars).unwrap();
assert_eq!(expected_args, args);
@ -900,4 +946,26 @@ mod tests {
"Bitcoin address network mismatch, expected `Testnet`"
);
}
#[test]
fn test_rpc_bind_validation() {
// Both None should be valid
assert!(validate_rpc_bind_args(&None, &None).is_ok());
// Both Some should be valid with valid values
assert!(validate_rpc_bind_args(&Some("127.0.0.1".to_string()), &Some(9944)).is_ok());
assert!(validate_rpc_bind_args(&Some("0.0.0.0".to_string()), &Some(8080)).is_ok());
assert!(validate_rpc_bind_args(&Some("localhost".to_string()), &Some(3000)).is_ok());
// One Some, one None should be invalid
assert!(validate_rpc_bind_args(&Some("127.0.0.1".to_string()), &None).is_err());
assert!(validate_rpc_bind_args(&None, &Some(9944)).is_err());
// Invalid host should be invalid
assert!(validate_rpc_bind_args(&Some("invalid@host".to_string()), &Some(9944)).is_err());
assert!(validate_rpc_bind_args(&Some("".to_string()), &Some(9944)).is_err());
// Port 0 should be invalid
assert!(validate_rpc_bind_args(&Some("127.0.0.1".to_string()), &Some(0)).is_err());
}
}

View file

@ -23,7 +23,9 @@ use std::env;
use std::sync::Arc;
use structopt::clap;
use structopt::clap::ErrorKind;
use swap::asb::command::{parse_args, Arguments, Command};
mod command;
use command::{parse_args, Arguments, Command};
use swap::asb::rpc::RpcServer;
use swap::asb::{cancel, punish, redeem, refund, safely_abort, EventLoop, Finality, KrakenRate};
use swap::common::tor::{bootstrap_tor_client, create_tor_client};
use swap::common::tracing_util::Format;
@ -45,6 +47,25 @@ use uuid::Uuid;
const DEFAULT_WALLET_NAME: &str = "asb-wallet";
/// Initialize tracing with the specified configuration
fn initialize_tracing(json: bool, config: &Config, trace: bool) -> Result<()> {
let format = if json { Format::Json } else { Format::Raw };
let log_dir = config.data.dir.join("logs");
common::tracing_util::init(LevelFilter::DEBUG, format, log_dir, None, trace)
.expect("initialize tracing");
tracing::info!(
binary = "asb",
version = env!("VERGEN_GIT_DESCRIBE"),
os = std::env::consts::OS,
arch = std::env::consts::ARCH,
"Setting up context"
);
Ok(())
}
trait IntoDaemon {
fn into_daemon(self) -> Result<Daemon>;
}
@ -61,9 +82,11 @@ impl IntoDaemon for url::Url {
impl IntoDaemon for monero_rpc_pool::ServerInfo {
fn into_daemon(self) -> Result<Daemon> {
let address = format!("http://{}:{}", self.host, self.port);
let ssl = false; // Pool server always uses HTTP locally
Ok(Daemon { address, ssl })
Ok(Daemon {
address,
ssl: false,
})
}
}
@ -83,7 +106,6 @@ pub async fn main() -> Result<()> {
} = match parse_args(env::args_os()) {
Ok(args) => args,
Err(e) => {
// make sure to display the clap error message it exists
if let Some(clap_err) = e.downcast_ref::<clap::Error>() {
if let ErrorKind::HelpDisplayed | ErrorKind::VersionDisplayed = clap_err.kind {
println!("{}", clap_err.message);
@ -97,7 +119,7 @@ pub async fn main() -> Result<()> {
// Check in the background if there's a new version available
tokio::spawn(async move { warn_if_outdated(env!("CARGO_PKG_VERSION")).await });
// Read config from the specified path
// Read our config
let config = match read_config(config_path.clone())? {
Ok(config) => config,
Err(ConfigNotInitialized {}) => {
@ -107,17 +129,7 @@ pub async fn main() -> Result<()> {
};
// Initialize tracing
let format = if json { Format::Json } else { Format::Raw };
let log_dir = config.data.dir.join("logs");
common::tracing_util::init(LevelFilter::DEBUG, format, log_dir, None, trace)
.expect("initialize tracing");
tracing::info!(
binary = "asb",
version = env!("VERGEN_GIT_DESCRIBE"),
os = std::env::consts::OS,
arch = std::env::consts::ARCH,
"Setting up context"
);
initialize_tracing(json, &config, trace)?;
// Check for conflicting env / config values
if config.monero.network != env_config.monero_network {
@ -140,7 +152,11 @@ pub async fn main() -> Result<()> {
let db_file = config.data.dir.join("sqlite");
match cmd {
Command::Start { resume_only } => {
Command::Start {
resume_only,
rpc_bind_host,
rpc_bind_port,
} => {
let db = open_db(db_file, AccessMode::ReadWrite, None).await?;
// check and warn for duplicate rendezvous points
@ -243,16 +259,17 @@ pub async fn main() -> Result<()> {
tracing::info!(peer_id = %swarm.local_peer_id(), "Network layer initialized");
for external_address in config.network.external_addresses {
swarm.add_external_address(external_address);
for external_address in &config.network.external_addresses {
swarm.add_external_address(external_address.clone());
}
let (event_loop, mut swap_receiver) = EventLoop::new(
let bitcoin_wallet = Arc::new(bitcoin_wallet);
let (event_loop, mut swap_receiver, event_loop_service) = EventLoop::new(
swarm,
env_config,
Arc::new(bitcoin_wallet),
bitcoin_wallet.clone(),
monero_wallet.clone(),
db,
db.clone(),
kraken_rate.clone(),
config.maker.min_buy_btc,
config.maker.max_buy_btc,
@ -260,6 +277,23 @@ pub async fn main() -> Result<()> {
)
.unwrap();
// Start RPC server conditionally
let _rpc_server = if let (Some(host), Some(port)) = (rpc_bind_host, rpc_bind_port) {
let rpc_server = RpcServer::start(
host,
port,
bitcoin_wallet.clone(),
monero_wallet.clone(),
event_loop_service,
db,
)
.await?;
Some(rpc_server.spawn())
} else {
None
};
tokio::spawn(async move {
while let Some(swap) = swap_receiver.recv().await {
let rate = kraken_rate.clone();
@ -452,6 +486,7 @@ async fn init_bitcoin_wallet(
sync: bool,
) -> Result<bitcoin::Wallet> {
tracing::debug!("Opening Bitcoin wallet");
let wallet = bitcoin::wallet::WalletBuilder::default()
.seed(seed.clone())
.network(env_config.bitcoin_network)
@ -491,38 +526,34 @@ async fn init_monero_wallet(
) -> Result<Arc<monero::Wallets>> {
tracing::debug!("Initializing Monero wallets");
let daemon = if config.monero.monero_node_pool {
// Start the monero-rpc-pool and use it
tracing::info!("Starting Monero RPC Pool for ASB");
let daemon = match &config.monero.daemon_url {
// If a daemon URL is provided, use it
Some(url) => {
tracing::info!("Using direct Monero daemon connection: {url}");
let (server_info, _status_receiver, _pool_handle) =
monero_rpc_pool::start_server_with_random_port(
monero_rpc_pool::config::Config::new_random_port(
config.data.dir.join("monero-rpc-pool"),
env_config.monero_network,
),
)
.await
.context("Failed to start Monero RPC Pool for ASB")?;
url.clone()
.into_daemon()
.context("Failed to convert daemon URL to Daemon")?
}
// If no daemon URL is provided, start the monero-rpc-pool and use it
None => {
let (server_info, _status_receiver, _pool_handle) =
monero_rpc_pool::start_server_with_random_port(
monero_rpc_pool::config::Config::new_random_port(
config.data.dir.join("monero-rpc-pool"),
env_config.monero_network,
),
)
.await
.context("Failed to start Monero RPC Pool for ASB")?;
let pool_url = format!("http://{}:{}", server_info.host, server_info.port);
tracing::info!("Monero RPC Pool started for ASB on {}", pool_url);
let pool_url = format!("http://{}:{}", server_info.host, server_info.port);
tracing::info!("Monero RPC Pool started for ASB on {}", pool_url);
server_info
.into_daemon()
.context("Failed to convert ServerInfo to Daemon")?
} else {
tracing::info!(
"Using direct Monero daemon connection: {}",
config.monero.daemon_url
);
config
.monero
.daemon_url
.clone()
.into_daemon()
.context("Failed to convert daemon URL to Daemon")?
server_info
.into_daemon()
.context("Failed to convert ServerInfo to Daemon")?
}
};
let manager = monero::Wallets::new(

View file

@ -0,0 +1,16 @@
[package]
name = "swap-controller-api"
version = "0.1.0"
edition = "2021"
[dependencies]
bitcoin = { workspace = true }
jsonrpsee = { workspace = true, features = ["macros", "server", "client-core", "http-client"] }
serde = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
[lints]
workspace = true

View file

@ -0,0 +1,53 @@
use jsonrpsee::proc_macros::rpc;
use jsonrpsee::types::ErrorObjectOwned;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct BitcoinBalanceResponse {
#[serde(with = "bitcoin::amount::serde::as_sat")]
pub balance: bitcoin::Amount,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct MoneroBalanceResponse {
pub balance: u64,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct MoneroAddressResponse {
pub address: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct MultiaddressesResponse {
pub multiaddresses: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ActiveConnectionsResponse {
pub connections: usize,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Swap {
pub id: String,
pub state: String,
}
#[rpc(client, server)]
pub trait AsbApi {
#[method(name = "check_connection")]
async fn check_connection(&self) -> Result<(), ErrorObjectOwned>;
#[method(name = "bitcoin_balance")]
async fn bitcoin_balance(&self) -> Result<BitcoinBalanceResponse, ErrorObjectOwned>;
#[method(name = "monero_balance")]
async fn monero_balance(&self) -> Result<MoneroBalanceResponse, ErrorObjectOwned>;
#[method(name = "monero_address")]
async fn monero_address(&self) -> Result<MoneroAddressResponse, ErrorObjectOwned>;
#[method(name = "multiaddresses")]
async fn multiaddresses(&self) -> Result<MultiaddressesResponse, ErrorObjectOwned>;
#[method(name = "active_connections")]
async fn active_connections(&self) -> Result<ActiveConnectionsResponse, ErrorObjectOwned>;
#[method(name = "get_swaps")]
async fn get_swaps(&self) -> Result<Vec<Swap>, ErrorObjectOwned>;
}

View file

@ -0,0 +1,22 @@
[package]
name = "swap-controller"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "asb-controller"
path = "src/main.rs"
[dependencies]
anyhow = { workspace = true }
clap = { version = "4", features = ["derive"] }
jsonrpsee = { workspace = true, features = ["client-core", "http-client"] }
monero = { workspace = true }
rustyline = "17.0.0"
serde_json = { workspace = true }
shell-words = "1.1"
swap-controller-api = { path = "../swap-controller-api" }
tokio = { workspace = true }
[lints]
workspace = true

View file

@ -0,0 +1,17 @@
# Latest Rust 1.87.0 image as of Tue, 05 Aug 2025 15:34:08 GMT
# This needs to be built from the root of the Cargo workspace (root of git repo)
FROM rust:1.87.0-bookworm@sha256:251cec8da4689d180f124ef00024c2f83f79d9bf984e43c180a598119e326b84 AS builder
WORKDIR /build
# Copy the entire Cargo workspace
COPY . .
RUN cargo build --locked --bin asb-controller --release
# Latest Debian Bookworm image as of Tue, 05 Aug 2025 15:34:08 GMT
FROM debian:bookworm@sha256:b6507e340c43553136f5078284c8c68d86ec8262b1724dde73c325e8d3dcdeba AS runner
COPY --from=builder /build/target/release/asb-controller /usr/local/bin/asb-controller
ENTRYPOINT ["/usr/local/bin/asb-controller"]

View file

@ -0,0 +1,32 @@
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "asb-controller")]
#[command(about = "Control tool for ASB daemon")]
pub struct Cli {
/// RPC server URL
#[arg(long, default_value = "http://127.0.0.1:9944")]
pub url: String,
/// Command to execute (defaults to interactive shell if omitted)
#[command(subcommand)]
pub cmd: Option<Cmd>,
}
#[derive(Subcommand, Clone)]
pub enum Cmd {
/// Check connection to ASB server
CheckConnection,
/// Get Bitcoin balance
BitcoinBalance,
/// Get Monero balance
MoneroBalance,
/// Get Monero wallet address
MoneroAddress,
/// Get external multiaddresses
Multiaddresses,
/// Get active connection count
ActiveConnections,
/// Get list of swaps
GetSwaps,
}

View file

@ -0,0 +1,72 @@
mod cli;
mod repl;
use clap::Parser;
use cli::{Cli, Cmd};
use swap_controller_api::AsbApiClient;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let client = jsonrpsee::http_client::HttpClientBuilder::default().build(&cli.url)?;
match cli.cmd {
None => repl::run(client, dispatch).await?,
Some(cmd) => {
if let Err(e) = dispatch(cmd.clone(), client.clone()).await {
eprintln!("Command failed with error: {e:?}");
}
}
}
Ok(())
}
async fn dispatch(cmd: Cmd, client: impl AsbApiClient) -> anyhow::Result<()> {
match cmd {
Cmd::CheckConnection => {
client.check_connection().await?;
println!("Connected");
}
Cmd::BitcoinBalance => {
let response = client.bitcoin_balance().await?;
println!("Current Bitcoin balance is {} BTC", response.balance);
}
Cmd::MoneroBalance => {
let response = client.monero_balance().await?;
let amount = monero::Amount::from_pico(response.balance);
println!("Current Monero balance is {:.12} XMR", amount.as_xmr());
}
Cmd::MoneroAddress => {
let response = client.monero_address().await?;
println!("The primary Monero address is {}", response.address);
}
Cmd::Multiaddresses => {
let response = client.multiaddresses().await?;
if response.multiaddresses.is_empty() {
println!("No external multiaddresses configured");
} else {
for addr in response.multiaddresses {
println!("{}", addr);
}
}
}
Cmd::ActiveConnections => {
let response = client.active_connections().await?;
println!("Connected to {} peers", response.connections);
}
Cmd::GetSwaps => {
let swaps = client.get_swaps().await?;
if swaps.is_empty() {
println!("No swaps found");
} else {
for swap in swaps {
println!("{}: {}", swap.id, swap.state);
}
}
}
}
Ok(())
}

View file

@ -0,0 +1,181 @@
mod parse;
use crate::cli::Cmd;
use clap::{CommandFactory, Parser};
use rustyline::completion::{Completer, Pair};
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{error::ReadlineError, Editor};
use rustyline::{Context, Helper};
use std::future::Future;
use swap_controller_api::AsbApiClient;
struct CommandCompleter {
commands: Vec<String>,
}
impl CommandCompleter {
fn new() -> Self {
let mut commands = Vec::new();
// Extract command names from the Cmd enum using clap
#[derive(Parser)]
#[command(name = "")]
#[command(about = "")]
#[command(no_binary_name = true)]
struct TempReplCommand {
#[command(subcommand)]
cmd: Cmd,
}
let app = TempReplCommand::command();
for subcmd in app.get_subcommands() {
commands.push(subcmd.get_name().to_string());
// Also add aliases if any
for alias in subcmd.get_all_aliases() {
commands.push(alias.to_string());
}
}
// Add shell-specific commands
commands.extend_from_slice(&[
"help".to_string(),
"quit".to_string(),
"exit".to_string(),
":q".to_string(),
]);
commands.sort();
Self { commands }
}
}
impl Helper for CommandCompleter {}
impl Completer for CommandCompleter {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
let line = &line[..pos];
let words: Vec<&str> = line.split_whitespace().collect();
if words.is_empty() || (words.len() == 1 && !line.ends_with(' ')) {
// Complete command names
let start_pos = line.rfind(' ').map(|i| i + 1).unwrap_or(0);
let prefix = &line[start_pos..];
let matches: Vec<Pair> = self
.commands
.iter()
.filter(|cmd| cmd.starts_with(prefix))
.map(|cmd| Pair {
display: cmd.clone(),
replacement: cmd.clone(),
})
.collect();
Ok((start_pos, matches))
} else {
// No completion for command arguments yet
Ok((pos, Vec::new()))
}
}
}
impl Hinter for CommandCompleter {
type Hint = String;
}
impl Highlighter for CommandCompleter {}
impl Validator for CommandCompleter {}
pub async fn run<C, F, Fut>(client: C, dispatch: F) -> anyhow::Result<()>
where
C: AsbApiClient + Clone + Send + 'static,
F: Fn(Cmd, C) -> Fut + Clone + 'static,
Fut: Future<Output = anyhow::Result<()>>,
{
let completer = CommandCompleter::new();
let mut rl = Editor::new()?;
rl.set_helper(Some(completer));
println!("ASB Control Shell - Type 'help' for commands, 'quit' to exit\n");
loop {
let readline = rl.readline("asb> ");
match readline {
Ok(line) => {
rl.add_history_entry(line.as_str())?;
let line = line.trim();
if line.is_empty() {
continue;
}
match line {
"quit" | "exit" | ":q" => {
println!("Goodbye!");
break;
}
"help" => {
print_help();
}
_ => {
if let Some(cmd) = parse::parse_line(line) {
if let Err(e) = dispatch(cmd, client.clone()).await {
eprintln!("Command failed with error: {e:?}");
}
} else {
eprintln!(
"Unknown command: {}. Type 'help' for available commands.",
line
);
}
}
}
}
Err(ReadlineError::Interrupted) => {
println!("^C");
continue;
}
Err(ReadlineError::Eof) => {
println!("^D");
break;
}
Err(err) => {
eprintln!("Error: {:?}", err);
break;
}
}
}
Ok(())
}
fn print_help() {
use crate::cli::Cmd;
use clap::{CommandFactory, Parser};
#[derive(Parser)]
#[command(name = "")]
#[command(about = "")]
#[command(no_binary_name = true)]
struct ReplCommand {
#[command(subcommand)]
cmd: Cmd,
}
println!("Available commands:");
println!("{}", ReplCommand::command().render_help());
println!("\nAdditional shell commands:");
println!(" help Show this help message");
println!(" quit, exit, :q Exit the shell");
}

View file

@ -0,0 +1,33 @@
use crate::cli::Cmd;
use clap::Parser;
/// A wrapper for parsing REPL commands using clap
#[derive(Parser)]
#[command(name = "")]
#[command(about = "")]
#[command(no_binary_name = true)]
struct ReplCommand {
#[command(subcommand)]
cmd: Cmd,
}
/// Parse a line from the REPL into a command using clap's parser
pub fn parse_line(line: &str) -> Option<Cmd> {
let line = line.trim();
if line.is_empty() {
return None;
}
// Split the line into arguments, preserving quoted strings
let args = shell_words::split(line).ok()?;
// Try to parse using clap
match ReplCommand::try_parse_from(args) {
Ok(repl_cmd) => Some(repl_cmd.cmd),
Err(err) => {
// Print clap's error message (it's quite good)
eprintln!("{}", err);
None
}
}
}

View file

@ -7,7 +7,7 @@ edition = "2024"
anyhow = { workspace = true }
bitcoin = { workspace = true }
config = { version = "0.14", default-features = false, features = ["toml"] }
dialoguer = "0.11"
dialoguer = { workspace = true }
libp2p = { workspace = true, features = ["serde"] }
monero = { workspace = true }
rust_decimal = { workspace = true }
@ -16,7 +16,7 @@ swap-fs = { path = "../swap-fs" }
swap-serde = { path = "../swap-serde" }
thiserror = { workspace = true }
time = "0.3"
toml = "0.8"
toml = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }

View file

@ -1,85 +1,17 @@
use crate::defaults::GetDefaults;
use crate::env::{Mainnet, Testnet};
use crate::prompt;
use anyhow::{bail, Context, Result};
use config::ConfigError;
use dialoguer::theme::ColorfulTheme;
use dialoguer::{Input, Select};
use libp2p::core::Multiaddr;
use rust_decimal::prelude::FromPrimitive;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use swap_fs::{ensure_directory_exists, system_config_dir, system_data_dir};
use swap_fs::ensure_directory_exists;
use url::Url;
pub trait GetDefaults {
fn get_config_file_defaults() -> Result<Defaults>;
}
pub struct Defaults {
pub config_path: PathBuf,
data_dir: PathBuf,
listen_address_tcp: Multiaddr,
electrum_rpc_url: Url,
monero_daemon_address: Url,
price_ticker_ws_url: Url,
bitcoin_confirmation_target: u16,
}
impl GetDefaults for Testnet {
fn get_config_file_defaults() -> Result<Defaults> {
let defaults = Defaults {
config_path: default_asb_config_dir()?
.join("testnet")
.join("config.toml"),
data_dir: default_asb_data_dir()?.join("testnet"),
listen_address_tcp: Multiaddr::from_str("/ip4/0.0.0.0/tcp/9939")?,
electrum_rpc_url: Url::parse("ssl://electrum.blockstream.info:60002")?,
monero_daemon_address: Url::parse("http://node.sethforprivacy.com:38089")?,
price_ticker_ws_url: Url::parse("wss://ws.kraken.com")?,
bitcoin_confirmation_target: 1,
};
Ok(defaults)
}
}
impl GetDefaults for Mainnet {
fn get_config_file_defaults() -> Result<Defaults> {
let defaults = Defaults {
config_path: default_asb_config_dir()?
.join("mainnet")
.join("config.toml"),
data_dir: default_asb_data_dir()?.join("mainnet"),
listen_address_tcp: Multiaddr::from_str("/ip4/0.0.0.0/tcp/9939")?,
electrum_rpc_url: Url::parse("ssl://blockstream.info:700")?,
monero_daemon_address: Url::parse("nthpyro.dev:18089")?,
price_ticker_ws_url: Url::parse("wss://ws.kraken.com")?,
bitcoin_confirmation_target: 3,
};
Ok(defaults)
}
}
fn default_asb_config_dir() -> Result<PathBuf> {
system_config_dir()
.map(|dir| Path::join(&dir, "asb"))
.context("Could not generate default config file path")
}
fn default_asb_data_dir() -> Result<PathBuf> {
system_data_dir()
.map(|dir| Path::join(&dir, "asb"))
.context("Could not generate default config file path")
}
const DEFAULT_MIN_BUY_AMOUNT: f64 = 0.002f64;
const DEFAULT_MAX_BUY_AMOUNT: f64 = 0.02f64;
const DEFAULT_SPREAD: f64 = 0.02f64;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Config {
@ -91,34 +23,6 @@ pub struct Config {
pub maker: Maker,
}
impl Config {
pub fn read<D>(config_file: D) -> Result<Self, ConfigError>
where
D: AsRef<OsStr>,
{
let config_file = Path::new(&config_file);
let config = config::Config::builder()
.add_source(config::File::from(config_file))
.add_source(
config::Environment::with_prefix("ASB")
.separator("__")
.list_separator(","),
)
.build()?;
config.try_into()
}
}
impl TryFrom<config::Config> for Config {
type Error = config::ConfigError;
fn try_from(value: config::Config) -> Result<Self, Self::Error> {
value.try_deserialize()
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Data {
@ -162,16 +66,12 @@ fn default_use_mempool_space_fee_estimation() -> bool {
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Monero {
pub daemon_url: Url,
/// If None, we will use the Monero Remote Node Pool with built in defaults
#[serde(default)]
pub daemon_url: Option<Url>,
pub finality_confirmations: Option<u64>,
#[serde(with = "swap_serde::monero::network")]
pub network: monero::Network,
#[serde(default = "default_monero_node_pool")]
pub monero_node_pool: bool,
}
fn default_monero_node_pool() -> bool {
false
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
@ -181,6 +81,15 @@ pub struct TorConf {
pub hidden_service_num_intro_points: u8,
}
impl Default for TorConf {
fn default() -> Self {
Self {
register_hidden_service: true,
hidden_service_num_intro_points: 5,
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Maker {
@ -194,12 +103,31 @@ pub struct Maker {
pub external_bitcoin_redeem_address: Option<bitcoin::Address>,
}
impl Default for TorConf {
fn default() -> Self {
Self {
register_hidden_service: true,
hidden_service_num_intro_points: 5,
}
impl Config {
pub fn read<D>(config_file: D) -> Result<Self, ConfigError>
where
D: AsRef<OsStr>,
{
let config_file = Path::new(&config_file);
let config = config::Config::builder()
.add_source(config::File::from(config_file))
.add_source(
config::Environment::with_prefix("ASB")
.separator("__")
.list_separator(","),
)
.build()?;
config.try_into()
}
}
impl TryFrom<config::Config> for Config {
type Error = config::ConfigError;
fn try_from(value: config::Config) -> Result<Self, Self::Error> {
value.try_deserialize()
}
}
@ -236,145 +164,26 @@ pub fn initial_setup(config_path: PathBuf, config: Config) -> Result<()> {
Ok(())
}
pub fn query_user_for_initial_config(testnet: bool) -> Result<Config> {
let (bitcoin_network, monero_network, defaults) = if testnet {
tracing::info!("Running initial setup for testnet");
let bitcoin_network = bitcoin::Network::Testnet;
let monero_network = monero::Network::Stagenet;
let defaults = Testnet::get_config_file_defaults()?;
(bitcoin_network, monero_network, defaults)
} else {
tracing::info!("Running initial setup for mainnet");
let bitcoin_network = bitcoin::Network::Bitcoin;
let monero_network = monero::Network::Mainnet;
let defaults = Mainnet::get_config_file_defaults()?;
(bitcoin_network, monero_network, defaults)
pub fn query_user_for_initial_config_with_network(
bitcoin_network: bitcoin::Network,
monero_network: monero::Network,
) -> Result<Config> {
let defaults = match bitcoin_network {
bitcoin::Network::Bitcoin => Mainnet::get_config_file_defaults()?,
bitcoin::Network::Testnet => Testnet::get_config_file_defaults()?,
_ => bail!("Unsupported bitcoin network"),
};
println!();
let data_dir = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter data directory for asb or hit return to use default")
.default(
defaults
.data_dir
.to_str()
.context("Unsupported characters in default path")?
.to_string(),
)
.interact_text()?;
let data_dir: PathBuf = data_dir.as_str().parse()?;
let target_block = Input::with_theme(&ColorfulTheme::default())
.with_prompt("How fast should your Bitcoin transactions be confirmed? Your transaction fee will be calculated based on this target. Hit return to use default")
.default(defaults.bitcoin_confirmation_target)
.interact_text()?;
let listen_addresses = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter multiaddresses (comma separated) on which asb should list for peer-to-peer communications or hit return to use default")
.default( format!("{}", defaults.listen_address_tcp))
.interact_text()?;
let listen_addresses = listen_addresses
.split(',')
.map(|str| str.parse())
.collect::<Result<Vec<Multiaddr>, _>>()?;
let mut electrum_rpc_urls = Vec::new();
let mut electrum_number = 1;
let mut electrum_done = false;
println!(
"You can configure multiple Electrum servers for redundancy. At least one is required."
);
// Ask for the first electrum URL with a default
let electrum_rpc_url = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter first Electrum RPC URL or hit return to use default")
.default(defaults.electrum_rpc_url)
.interact_text()?;
electrum_rpc_urls.push(electrum_rpc_url);
electrum_number += 1;
// Ask for additional electrum URLs
while !electrum_done {
let prompt = format!(
"Enter additional Electrum RPC URL ({electrum_number}). Or just hit Enter to continue."
);
let electrum_url = Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt(prompt)
.allow_empty(true)
.interact_text()?;
if electrum_url.is_empty() {
electrum_done = true;
} else if electrum_rpc_urls
.iter()
.any(|url| url.to_string() == electrum_url)
{
println!("That Electrum URL is already in the list.");
} else {
let electrum_url = Url::parse(&electrum_url).context("Invalid Electrum URL")?;
electrum_rpc_urls.push(electrum_url);
electrum_number += 1;
}
}
let monero_daemon_url = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter Monero daemon url or hit enter to use default")
.default(defaults.monero_daemon_address)
.interact_text()?;
let register_hidden_service = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Do you want a Tor hidden service to be created? This will allow you to run from behind a firewall without opening ports, and hide your IP address. You do not have to run a Tor daemon yourself. We recommend this for most users. (y/n)")
.items(&["yes", "no"])
.default(0)
.interact()?
== 0;
let min_buy = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter minimum Bitcoin amount you are willing to accept per swap or hit enter to use default.")
.default(DEFAULT_MIN_BUY_AMOUNT)
.interact_text()?;
let min_buy = bitcoin::Amount::from_btc(min_buy)?;
let max_buy = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter maximum Bitcoin amount you are willing to accept per swap or hit enter to use default.")
.default(DEFAULT_MAX_BUY_AMOUNT)
.interact_text()?;
let max_buy = bitcoin::Amount::from_btc(max_buy)?;
let ask_spread = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter spread (in percent; value between 0.x and 1.0) to be used on top of the market rate or hit enter to use default.")
.default(DEFAULT_SPREAD)
.interact_text()?;
if !(0.0..=1.0).contains(&ask_spread) {
bail!(format!("Invalid spread {}. For the spread value floating point number in interval [0..1] are allowed.", ask_spread))
}
let ask_spread = Decimal::from_f64(ask_spread).context("Unable to parse spread")?;
let mut number = 1;
let mut done = false;
let mut rendezvous_points = Vec::new();
println!("ASB can register with multiple rendezvous nodes for discoverability. This can also be edited in the config file later.");
while !done {
let prompt = format!(
"Enter the address for rendezvous node ({number}). Or just hit Enter to continue."
);
let rendezvous_addr = Input::<Multiaddr>::with_theme(&ColorfulTheme::default())
.with_prompt(prompt)
.allow_empty(true)
.interact_text()?;
if rendezvous_addr.is_empty() {
done = true;
} else if rendezvous_points.contains(&rendezvous_addr) {
println!("That rendezvous address is already in the list.");
} else {
rendezvous_points.push(rendezvous_addr);
number += 1;
}
}
let data_dir = prompt::data_directory(&defaults.data_dir)?;
let target_block = prompt::bitcoin_confirmation_target(defaults.bitcoin_confirmation_target)?;
let listen_addresses = prompt::listen_addresses(&defaults.listen_address_tcp)?;
let electrum_rpc_urls = prompt::electrum_rpc_urls(&defaults.electrum_rpc_urls)?;
let monero_daemon_url = prompt::monero_daemon_url()?;
let register_hidden_service = prompt::tor_hidden_service()?;
let min_buy = prompt::min_buy_amount()?;
let max_buy = prompt::max_buy_amount()?;
let ask_spread = prompt::ask_spread()?;
let rendezvous_points = prompt::rendezvous_points()?;
println!();
@ -382,7 +191,7 @@ pub fn query_user_for_initial_config(testnet: bool) -> Result<Config> {
data: Data { dir: data_dir },
network: Network {
listen: listen_addresses,
rendezvous_point: rendezvous_points, // keeping the singular key name for backcompat
rendezvous_point: rendezvous_points,
external_addresses: vec![],
},
bitcoin: Bitcoin {
@ -396,7 +205,6 @@ pub fn query_user_for_initial_config(testnet: bool) -> Result<Config> {
daemon_url: monero_daemon_url,
finality_confirmations: None,
network: monero_network,
monero_node_pool: false,
},
tor: TorConf {
register_hidden_service,
@ -412,161 +220,16 @@ pub fn query_user_for_initial_config(testnet: bool) -> Result<Config> {
})
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use tempfile::tempdir;
pub fn query_user_for_initial_config(testnet: bool) -> Result<Config> {
let (bitcoin_network, monero_network) = if testnet {
let bitcoin_network = bitcoin::Network::Testnet;
let monero_network = monero::Network::Stagenet;
(bitcoin_network, monero_network)
} else {
let bitcoin_network = bitcoin::Network::Bitcoin;
let monero_network = monero::Network::Mainnet;
(bitcoin_network, monero_network)
};
// these tests are run serially since env vars affect the whole process
#[test]
#[serial]
fn config_roundtrip_testnet() {
let temp_dir = tempdir().unwrap().path().to_path_buf();
let config_path = Path::join(&temp_dir, "config.toml");
let defaults = Testnet::get_config_file_defaults().unwrap();
let expected = Config {
data: Data {
dir: Default::default(),
},
bitcoin: Bitcoin {
electrum_rpc_urls: vec![defaults.electrum_rpc_url],
target_block: defaults.bitcoin_confirmation_target,
finality_confirmations: None,
network: bitcoin::Network::Testnet,
use_mempool_space_fee_estimation: true,
},
network: Network {
listen: vec![defaults.listen_address_tcp],
rendezvous_point: vec![],
external_addresses: vec![],
},
monero: Monero {
daemon_url: defaults.monero_daemon_address,
finality_confirmations: None,
network: monero::Network::Stagenet,
monero_node_pool: false,
},
tor: Default::default(),
maker: Maker {
min_buy_btc: bitcoin::Amount::from_btc(DEFAULT_MIN_BUY_AMOUNT).unwrap(),
max_buy_btc: bitcoin::Amount::from_btc(DEFAULT_MAX_BUY_AMOUNT).unwrap(),
ask_spread: Decimal::from_f64(DEFAULT_SPREAD).unwrap(),
price_ticker_ws_url: defaults.price_ticker_ws_url,
external_bitcoin_redeem_address: None,
},
};
initial_setup(config_path.clone(), expected.clone()).unwrap();
let actual = read_config(config_path).unwrap().unwrap();
assert_eq!(expected, actual);
}
#[test]
#[serial]
fn config_roundtrip_mainnet() {
let temp_dir = tempdir().unwrap().path().to_path_buf();
let config_path = Path::join(&temp_dir, "config.toml");
let defaults = Mainnet::get_config_file_defaults().unwrap();
let expected = Config {
data: Data {
dir: Default::default(),
},
bitcoin: Bitcoin {
electrum_rpc_urls: vec![defaults.electrum_rpc_url],
target_block: defaults.bitcoin_confirmation_target,
finality_confirmations: None,
network: bitcoin::Network::Bitcoin,
use_mempool_space_fee_estimation: true,
},
network: Network {
listen: vec![defaults.listen_address_tcp],
rendezvous_point: vec![],
external_addresses: vec![],
},
monero: Monero {
daemon_url: defaults.monero_daemon_address,
finality_confirmations: None,
network: monero::Network::Mainnet,
monero_node_pool: false,
},
tor: Default::default(),
maker: Maker {
min_buy_btc: bitcoin::Amount::from_btc(DEFAULT_MIN_BUY_AMOUNT).unwrap(),
max_buy_btc: bitcoin::Amount::from_btc(DEFAULT_MAX_BUY_AMOUNT).unwrap(),
ask_spread: Decimal::from_f64(DEFAULT_SPREAD).unwrap(),
price_ticker_ws_url: defaults.price_ticker_ws_url,
external_bitcoin_redeem_address: None,
},
};
initial_setup(config_path.clone(), expected.clone()).unwrap();
let actual = read_config(config_path).unwrap().unwrap();
assert_eq!(expected, actual);
}
#[test]
#[serial]
fn env_override() {
let temp_dir = tempfile::tempdir().unwrap().path().to_path_buf();
let config_path = Path::join(&temp_dir, "config.toml");
let defaults = Mainnet::get_config_file_defaults().unwrap();
let dir = PathBuf::from("/tmp/dir");
std::env::set_var("ASB__DATA__DIR", dir.clone());
let addr1 = "/dns4/example.com/tcp/9939";
let addr2 = "/ip4/1.2.3.4/tcp/9940";
let external_addresses = vec![addr1.parse().unwrap(), addr2.parse().unwrap()];
let listen = external_addresses.clone();
std::env::set_var(
"ASB__NETWORK__EXTERNAL_ADDRESSES",
format!("{},{}", addr1, addr2),
);
std::env::set_var("ASB__NETWORK__LISTEN", format!("{},{}", addr1, addr2));
let expected = Config {
data: Data { dir },
bitcoin: Bitcoin {
electrum_rpc_urls: vec![defaults.electrum_rpc_url],
target_block: defaults.bitcoin_confirmation_target,
finality_confirmations: None,
network: bitcoin::Network::Bitcoin,
use_mempool_space_fee_estimation: true,
},
network: Network {
listen,
rendezvous_point: vec![],
external_addresses,
},
monero: Monero {
daemon_url: defaults.monero_daemon_address,
finality_confirmations: None,
network: monero::Network::Mainnet,
monero_node_pool: false,
},
tor: Default::default(),
maker: Maker {
min_buy_btc: bitcoin::Amount::from_btc(DEFAULT_MIN_BUY_AMOUNT).unwrap(),
max_buy_btc: bitcoin::Amount::from_btc(DEFAULT_MAX_BUY_AMOUNT).unwrap(),
ask_spread: Decimal::from_f64(DEFAULT_SPREAD).unwrap(),
price_ticker_ws_url: defaults.price_ticker_ws_url,
external_bitcoin_redeem_address: None,
},
};
initial_setup(config_path.clone(), expected.clone()).unwrap();
let actual = read_config(config_path).unwrap().unwrap();
assert_eq!(expected, actual);
std::env::remove_var("ASB__DATA__DIR");
std::env::remove_var("ASB__NETWORK__EXTERNAL_ADDRESSES");
std::env::remove_var("ASB__NETWORK__LISTEN");
}
query_user_for_initial_config_with_network(bitcoin_network, monero_network)
}

139
swap-env/src/defaults.rs Normal file
View file

@ -0,0 +1,139 @@
use crate::env::{Mainnet, Testnet};
use anyhow::{Context, Result};
use libp2p::Multiaddr;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use swap_fs::{system_config_dir, system_data_dir};
use url::Url;
pub const DEFAULT_MIN_BUY_AMOUNT: f64 = 0.002f64;
pub const DEFAULT_MAX_BUY_AMOUNT: f64 = 0.02f64;
pub const DEFAULT_SPREAD: f64 = 0.02f64;
pub const KRAKEN_PRICE_TICKER_WS_URL: &str = "wss://ws.kraken.com";
pub fn default_rendezvous_points() -> Vec<Multiaddr> {
vec![
"/dns4/discover.unstoppableswap.net/tcp/8888/p2p/12D3KooWA6cnqJpVnreBVnoro8midDL9Lpzmg8oJPoAGi7YYaamE".parse().unwrap(),
"/dns4/discover2.unstoppableswap.net/tcp/8888/p2p/12D3KooWGRvf7qVQDrNR5nfYD6rKrbgeTi9x8RrbdxbmsPvxL4mw".parse().unwrap(),
"/dns4/darkness.su/tcp/8888/p2p/12D3KooWFQAgVVS9t9UgL6v1sLprJVM7am5hFK7vy9iBCCoCBYmU".parse().unwrap(),
"/dns4/eigen.center/tcp/8888/p2p/12D3KooWS5RaYJt4ANKMH4zczGVhNcw5W214e2DDYXnjs5Mx5zAT".parse().unwrap(),
"/dns4/swapanarchy.cfd/tcp/8888/p2p/12D3KooWRtyVpmyvwzPYXuWyakFbRKhyXGrjhq6tP7RrBofpgQGp".parse().unwrap(),
]
}
pub fn default_electrum_servers_mainnet() -> Vec<Url> {
vec![
Url::parse("ssl://electrum.blockstream.info:50002")
.expect("default electrum server url to be valid"),
Url::parse("tcp://electrum.blockstream.info:50001")
.expect("default electrum server url to be valid"),
Url::parse("ssl://bitcoin.stackwallet.com:50002")
.expect("default electrum server url to be valid"),
Url::parse("ssl://b.1209k.com:50002").expect("default electrum server url to be valid"),
Url::parse("tcp://electrum.coinucopia.io:50001")
.expect("default electrum server url to be valid"),
Url::parse("ssl://mainnet.foundationdevices.com:50002")
.expect("default electrum server url to be valid"),
Url::parse("tcp://bitcoin.lu.ke:50001").expect("default electrum server url to be valid"),
Url::parse("tcp://se-mma-crypto-payments-001.mullvad.net:50001")
.expect("default electrum server url to be valid"),
Url::parse("ssl://electrum.coinfinity.co:50002")
.expect("default electrum server url to be valid"),
]
}
pub fn default_electrum_servers_testnet() -> Vec<Url> {
vec![
Url::parse("ssl://ax101.blockeng.ch:60002")
.expect("default electrum server url to be valid"),
Url::parse("ssl://blackie.c3-soft.com:57006")
.expect("default electrum server url to be valid"),
Url::parse("ssl://v22019051929289916.bestsrv.de:50002")
.expect("default electrum server url to be valid"),
Url::parse("tcp://v22019051929289916.bestsrv.de:50001")
.expect("default electrum server url to be valid"),
Url::parse("tcp://electrum.blockstream.info:60001")
.expect("default electrum server url to be valid"),
Url::parse("ssl://electrum.blockstream.info:60002")
.expect("default electrum server url to be valid"),
Url::parse("ssl://blockstream.info:993").expect("default electrum server url to be valid"),
Url::parse("tcp://blockstream.info:143").expect("default electrum server url to be valid"),
Url::parse("ssl://testnet.qtornado.com:51002")
.expect("default electrum server url to be valid"),
Url::parse("tcp://testnet.qtornado.com:51001")
.expect("default electrum server url to be valid"),
Url::parse("tcp://testnet.aranguren.org:51001")
.expect("default electrum server url to be valid"),
Url::parse("ssl://testnet.aranguren.org:51002")
.expect("default electrum server url to be valid"),
Url::parse("ssl://testnet.qtornado.com:50002")
.expect("default electrum server url to be valid"),
Url::parse("ssl://bitcoin.devmole.eu:5010")
.expect("default electrum server url to be valid"),
Url::parse("tcp://bitcoin.devmole.eu:5000")
.expect("default electrum server url to be valid"),
]
}
pub trait GetDefaults {
fn get_config_file_defaults() -> Result<Defaults>;
}
pub struct Defaults {
pub config_path: PathBuf,
pub data_dir: PathBuf,
pub listen_address_tcp: Multiaddr,
pub electrum_rpc_urls: Vec<Url>,
pub price_ticker_ws_url: Url,
pub bitcoin_confirmation_target: u16,
pub use_mempool_space_fee_estimation: bool,
}
impl GetDefaults for Mainnet {
fn get_config_file_defaults() -> Result<Defaults> {
let defaults = Defaults {
config_path: default_asb_config_dir()?
.join("mainnet")
.join("config.toml"),
data_dir: default_asb_data_dir()?.join("mainnet"),
listen_address_tcp: Multiaddr::from_str("/ip4/0.0.0.0/tcp/9939")?,
electrum_rpc_urls: default_electrum_servers_mainnet(),
price_ticker_ws_url: Url::parse(KRAKEN_PRICE_TICKER_WS_URL)?,
bitcoin_confirmation_target: 1,
use_mempool_space_fee_estimation: true,
};
Ok(defaults)
}
}
impl GetDefaults for Testnet {
fn get_config_file_defaults() -> Result<Defaults> {
let defaults = Defaults {
config_path: default_asb_config_dir()?
.join("testnet")
.join("config.toml"),
data_dir: default_asb_data_dir()?.join("testnet"),
listen_address_tcp: Multiaddr::from_str("/ip4/0.0.0.0/tcp/9939")?,
electrum_rpc_urls: default_electrum_servers_testnet(),
price_ticker_ws_url: Url::parse(KRAKEN_PRICE_TICKER_WS_URL)?,
bitcoin_confirmation_target: 1,
use_mempool_space_fee_estimation: true,
};
Ok(defaults)
}
}
fn default_asb_config_dir() -> Result<PathBuf> {
system_config_dir()
.map(|dir| Path::join(&dir, "asb"))
.context("Could not generate default config file path")
}
fn default_asb_data_dir() -> Result<PathBuf> {
system_data_dir()
.map(|dir| Path::join(&dir, "asb"))
.context("Could not generate default config file path")
}

View file

@ -1,2 +1,4 @@
pub mod config;
pub mod defaults;
pub mod env;
pub mod prompt;

235
swap-env/src/prompt.rs Normal file
View file

@ -0,0 +1,235 @@
use std::path::{Path, PathBuf};
use crate::defaults::{
default_rendezvous_points, DEFAULT_MAX_BUY_AMOUNT, DEFAULT_MIN_BUY_AMOUNT, DEFAULT_SPREAD,
};
use anyhow::{bail, Context, Result};
use dialoguer::Confirm;
use dialoguer::{theme::ColorfulTheme, Input, Select};
use libp2p::Multiaddr;
use rust_decimal::prelude::FromPrimitive;
use rust_decimal::Decimal;
use url::Url;
/// Prompt user for data directory
pub fn data_directory(default_data_dir: &Path) -> Result<PathBuf> {
let data_dir = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter data directory for asb or hit return to use default")
.default(
default_data_dir
.to_str()
.context("Unsupported characters in default path")?
.to_string(),
)
.interact_text()?;
Ok(data_dir.as_str().parse()?)
}
/// Prompt user for Bitcoin confirmation target
pub fn bitcoin_confirmation_target(default_target: u16) -> Result<u16> {
Input::with_theme(&ColorfulTheme::default())
.with_prompt("How fast should your Bitcoin transactions be confirmed? Your transaction fee will be calculated based on this target. Hit return to use default")
.default(default_target)
.interact_text()
.map_err(Into::into)
}
/// Prompt user for listen addresses
pub fn listen_addresses(default_listen_address: &Multiaddr) -> Result<Vec<Multiaddr>> {
let listen_addresses = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter multiaddresses (comma separated) on which asb should list for peer-to-peer communications or hit return to use default")
.default(format!("{}", default_listen_address))
.interact_text()?;
listen_addresses
.split(',')
.map(|str| str.parse())
.collect::<Result<Vec<Multiaddr>, _>>()
.map_err(Into::into)
}
/// Prompt user for electrum RPC URLs
pub fn electrum_rpc_urls(default_electrum_urls: &Vec<Url>) -> Result<Vec<Url>> {
println!(
"You can configure multiple Electrum servers for redundancy. At least one is required."
);
println!("The following default Electrum RPC URLs are available. We recommend using them.");
for (i, url) in default_electrum_urls.iter().enumerate() {
println!("{}: {}", i + 1, url);
}
// Ask if the user wants to use the default Electrum RPC URLs
let mut electrum_rpc_urls = match Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Do you want to use the default Electrum RPC URLs?")
.default(true)
.interact()?
{
true => default_electrum_urls.clone(),
false => Vec::new(),
};
let mut electrum_number = 1 + electrum_rpc_urls.len();
let mut electrum_done = false;
// Ask for additional electrum URLs
while !electrum_done {
let prompt = format!(
"Enter additional Electrum RPC URL ({electrum_number}). Or just hit Enter to continue."
);
let electrum_url = Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt(prompt)
.allow_empty(true)
.interact_text()?;
if electrum_url.is_empty() {
electrum_done = true;
} else if electrum_rpc_urls
.iter()
.any(|url| url.to_string() == electrum_url)
{
println!("That Electrum URL is already in the list.");
} else {
let electrum_url = Url::parse(&electrum_url).context("Invalid Electrum URL")?;
electrum_rpc_urls.push(electrum_url);
electrum_number += 1;
}
}
Ok(electrum_rpc_urls)
}
/// Prompt user for Monero daemon URL
/// If the user hits enter, we will use the Monero RPC pool (None)
pub fn monero_daemon_url() -> Result<Option<Url>> {
let type_choice = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Do you want to use the Monero RPC pool or a remote node?")
.items(&["Use the Monero RPC pool", "Use a remote node"])
.default(0)
.interact()?;
match type_choice {
0 => Ok(None),
1 => {
let input = Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt("Enter Monero daemon URL")
.interact_text()?;
Ok(Some(Url::parse(&input)?))
}
_ => unreachable!(),
}
}
/// Prompt user for Tor hidden service registration
pub fn tor_hidden_service() -> Result<bool> {
println!("Your ASB needs to be reachable from the outside world to provide quotes to takers.");
println!(
"Your ASB can run a hidden service for itself. It'll be reachable at an .onion address."
);
println!("You do not have to run a Tor daemon yourself. You do not have to manage anything.");
println!("This will hide your IP address and allow you to run from behind a firewall without opening ports.");
println!();
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Do you want a Tor hidden service to be created?")
.items(&[
"Yes, run a hidden service",
"No, do not run a hidden service",
])
.default(0)
.interact()?;
Ok(selection == 0)
}
/// Prompt user for minimum Bitcoin buy amount
pub fn min_buy_amount() -> Result<bitcoin::Amount> {
let min_buy = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter minimum Bitcoin amount you are willing to accept per swap or hit enter to use default.")
.default(DEFAULT_MIN_BUY_AMOUNT)
.interact_text()?;
bitcoin::Amount::from_btc(min_buy).map_err(Into::into)
}
/// Prompt user for maximum Bitcoin buy amount
pub fn max_buy_amount() -> Result<bitcoin::Amount> {
let max_buy = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter maximum Bitcoin amount you are willing to accept per swap or hit enter to use default.")
.default(DEFAULT_MAX_BUY_AMOUNT)
.interact_text()?;
bitcoin::Amount::from_btc(max_buy).map_err(Into::into)
}
/// Prompt user for ask spread
pub fn ask_spread() -> Result<Decimal> {
let ask_spread = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter spread (in percent; value between 0.x and 1.0) to be used on top of the market rate or hit enter to use default.")
.default(DEFAULT_SPREAD)
.interact_text()?;
if !(0.0..=1.0).contains(&ask_spread) {
bail!(format!("Invalid spread {}. For the spread value floating point number in interval [0..1] are allowed.", ask_spread))
}
Decimal::from_f64(ask_spread).context("Unable to parse spread")
}
/// Prompt user for rendezvous points
pub fn rendezvous_points() -> Result<Vec<Multiaddr>> {
println!("Your ASB can register with multiple rendezvous nodes for discoverability.");
println!(
"They act as sort of bootstrap nodes for peer discovery within the peer-to-peer network."
);
println!();
println!(
"The following rendezvous points are ran by community members. We recommend using them."
);
println!();
let default_rendezvous_points = default_rendezvous_points();
for (i, point) in default_rendezvous_points.iter().enumerate() {
println!("{}: {}", i + 1, point);
}
// Ask if the user wants to use the default rendezvous points
let use_default_rendezvous_points = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Do you want to use the default rendezvous points? (y/n)")
.items(&[
"Use default rendezvous points",
"Do not use default rendezvous points",
])
.default(0)
.interact()?;
let mut rendezvous_points = match use_default_rendezvous_points {
0 => {
println!("You can now configure additional rendezvous points.");
default_rendezvous_points
}
_ => Vec::new(),
};
let mut number = 1 + rendezvous_points.len();
let mut done = false;
while !done {
let prompt = format!(
"Enter the address for rendezvous node ({number}). Or just hit Enter to continue."
);
let rendezvous_addr = Input::<Multiaddr>::with_theme(&ColorfulTheme::default())
.with_prompt(prompt)
.allow_empty(true)
.interact_text()?;
if rendezvous_addr.is_empty() {
done = true;
} else if rendezvous_points.contains(&rendezvous_addr) {
println!("That rendezvous address is already in the list.");
} else {
rendezvous_points.push(rendezvous_addr);
number += 1;
}
}
Ok(rendezvous_points)
}

View file

@ -0,0 +1,27 @@
[package]
name = "swap-orchestrator"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "orchestrator"
path = "src/main.rs"
[dependencies]
anyhow = { workspace = true }
bitcoin = { workspace = true }
chrono = "0.4.41"
compose_spec = "0.3.0"
dialoguer = { workspace = true }
monero = { workspace = true }
serde_yaml = "0.9.34"
swap-env = { path = "../swap-env" }
toml = { workspace = true }
url = { workspace = true }
[build-dependencies]
anyhow = { workspace = true }
vergen = { workspace = true }
[lints]
workspace = true

View file

@ -0,0 +1,44 @@
# Orchestrator
The `orchestrator` tool helps you setup a secure, reliable and production environment for running an ASB.
The `orchestrator` guides you through a series of prompts to generate a customized [Docker](https://docs.docker.com/) environment using [Docker Compose](https://docs.docker.com/compose/).
## Getting started
To generate the `config.toml` and `docker-compose.yml` files, run:
```bash
./orchestrator
```
```bash
cargo run --bin orchestrator
```
To start the environment, run:
```bash
docker compose up -d
```
## Architecture
The `orchestrator` generates a `docker-compose.yml` file that includes the following containers:
- `asb`: Accepts connections from the outside world. Provides sell offers to the outside world. Manages your Bitcoin and Monero wallet. Controls the swap process.
- `asb-controller`: Shell to send control commands to the `asb` container.
- `bitcoind`: Bitcoin node.
- `electrs`: Electrum server.
- `monerod`: Monero node.
## Why Docker?
Your ASB will potentially be managing fairly large amounts of funds. This means you have to keep it secure and running reliably. Docker handles some of this for you.
Most importantly Docker provides:
- **System Isolation**: Containers are isolated from each other on the operating system level. If one of the nodes were to be affected by a vulnerability, your funds are still safe.
- **Network Isolation**: Docker only exposes the peer-to-peer port of your ASB to the outside world. Bitcoin, Monero and Electrum containers are only allowed outbound connections. The RPC control port of your ASB is only accessible within Docker inside of an internal network, accessible only to the `asb-controller` container.
- **Reproducibility**: A virtual environment is created for each container. This means that quirks on your system (e.g outdated `glibc`) will not become an issue.
- **Building from source**: Building from source is as simple as passing a flag to Docker. You do not have to install any dependencies on your system.

View file

@ -0,0 +1,10 @@
use anyhow::Result;
use vergen::EmitBuilder;
fn main() -> Result<()> {
EmitBuilder::builder()
.git_describe(true, true, None)
.git_sha(false) // false = short hash, true = full hash
.emit()?;
Ok(())
}

View file

@ -0,0 +1,34 @@
use crate::{
compose::{Flag, IntoFlag},
flag,
};
/// Wrapper around the network used for ASB
/// There are only two combinations of networks that are supported:
/// - Mainnet Bitcoin & Mainnet Monero
/// - Testnet Bitcoin & Stagenet Monero
pub struct Network((monero::Network, bitcoin::Network));
impl Network {
pub fn new(monero: monero::Network, bitcoin: bitcoin::Network) -> Self {
Self((monero, bitcoin))
}
}
impl IntoFlag for Network {
fn to_flag(self) -> Flag {
match self.0 {
(monero::Network::Mainnet, bitcoin::Network::Bitcoin) => flag!("--mainnet"),
(monero::Network::Stagenet, bitcoin::Network::Testnet) => flag!("--testnet"),
_ => panic!("Only either Mainnet Bitcoin & Mainnet Monero or Testnet Bitcoin & Stagenet Monero are supported"),
}
}
fn to_display(self) -> &'static str {
match self.0 {
(monero::Network::Mainnet, bitcoin::Network::Bitcoin) => "mainnet",
(monero::Network::Stagenet, bitcoin::Network::Testnet) => "testnet",
_ => panic!("Only either Mainnet Bitcoin & Mainnet Monero or Testnet Bitcoin & Stagenet Monero are supported"),
}
}
}

View file

@ -0,0 +1,425 @@
use crate::{asb, electrs, images::PINNED_GIT_REPOSITORY};
use compose_spec::Compose;
use std::{
fmt::{self, Display},
path::PathBuf,
};
pub const ASB_DATA_DIR: &str = "/asb-data";
pub const ASB_CONFIG_FILE: &str = "config.toml";
pub const DOCKER_COMPOSE_FILE: &str = "./docker-compose.yml";
pub struct OrchestratorInput {
pub ports: OrchestratorPorts,
pub networks: OrchestratorNetworks<monero::Network, bitcoin::Network>,
pub images: OrchestratorImages<OrchestratorImage>,
pub directories: OrchestratorDirectories,
}
pub struct OrchestratorDirectories {
pub asb_data_dir: PathBuf,
}
#[derive(Clone)]
pub struct OrchestratorNetworks<MN: IntoFlag + Clone, BN: IntoFlag + Clone> {
pub monero: MN,
pub bitcoin: BN,
}
pub struct OrchestratorImages<T: IntoImageAttribute> {
pub monerod: T,
pub electrs: T,
pub bitcoind: T,
pub asb: T,
pub asb_controller: T,
}
pub struct OrchestratorPorts {
pub monerod_rpc: u16,
pub bitcoind_rpc: u16,
pub bitcoind_p2p: u16,
pub electrs: u16,
pub asb_libp2p: u16,
pub asb_rpc_port: u16,
}
impl Into<OrchestratorPorts> for OrchestratorNetworks<monero::Network, bitcoin::Network> {
fn into(self) -> OrchestratorPorts {
match (self.monero, self.bitcoin) {
(monero::Network::Mainnet, bitcoin::Network::Bitcoin) => OrchestratorPorts {
monerod_rpc: 18081,
bitcoind_rpc: 8332,
bitcoind_p2p: 8333,
electrs: 50001,
asb_libp2p: 9839,
asb_rpc_port: 9944,
},
(monero::Network::Stagenet, bitcoin::Network::Testnet) => OrchestratorPorts {
monerod_rpc: 38081,
bitcoind_rpc: 18332,
bitcoind_p2p: 18333,
electrs: 50001,
asb_libp2p: 9839,
asb_rpc_port: 9944,
},
_ => panic!("Unsupported Bitcoin / Monero network combination"),
}
}
}
impl Into<asb::Network> for OrchestratorNetworks<monero::Network, bitcoin::Network> {
fn into(self) -> asb::Network {
asb::Network::new(self.monero, self.bitcoin)
}
}
impl Into<electrs::Network> for OrchestratorNetworks<monero::Network, bitcoin::Network> {
fn into(self) -> electrs::Network {
electrs::Network::new(self.bitcoin)
}
}
impl OrchestratorDirectories {
pub fn asb_config_path_inside_container(&self) -> PathBuf {
self.asb_data_dir.join(ASB_CONFIG_FILE)
}
pub fn asb_config_path_on_host(&self) -> &'static str {
// The config file is in the same directory as the docker-compose.yml file
"./config.toml"
}
}
/// See: https://docs.docker.com/reference/compose-file/build/#illustrative-example
#[derive(Debug, Clone)]
pub struct DockerBuildInput {
// Usually this is the root of the Cargo workspace
pub context: &'static str,
// Usually this is the path to the Dockerfile
pub dockerfile: &'static str,
}
/// Specified a docker image to use
/// The image can either be pulled from a registry or built from source
pub enum OrchestratorImage {
Registry(String),
Build(DockerBuildInput),
}
#[macro_export]
macro_rules! flag {
($flag:expr) => {
Flag(Some($flag.to_string()))
};
($flag:expr, $($args:expr),*) => {
flag!(format!($flag, $($args),*))
};
}
macro_rules! command {
($command:expr $(, $flag:expr)* $(,)?) => {
Flags(vec![flag!($command) $(, $flag)*])
};
}
fn build(input: OrchestratorInput) -> String {
// Every docker compose project has a name
// The name is prefixed to the container names
// See: https://docs.docker.com/compose/how-tos/project-name/#set-a-project-name
let project_name = format!(
"{}_monero_{}_bitcoin",
input.networks.monero.to_display(),
input.networks.bitcoin.to_display()
);
let asb_config_path = PathBuf::from(ASB_DATA_DIR).join(ASB_CONFIG_FILE);
let asb_network: asb::Network = input.networks.clone().into();
let command_asb = command![
"asb",
asb_network.to_flag(),
flag!("--config={}", asb_config_path.display()),
flag!("start"),
flag!("--rpc-bind-port={}", input.ports.asb_rpc_port),
flag!("--rpc-bind-host=0.0.0.0"),
];
let command_monerod = command![
"monerod",
input.networks.monero.to_flag(),
flag!("--rpc-bind-ip=0.0.0.0"),
flag!("--rpc-bind-port={}", input.ports.monerod_rpc),
flag!("--data-dir=/monerod-data/"),
flag!("--confirm-external-bind"),
flag!("--restricted-rpc"),
flag!("--non-interactive"),
flag!("--enable-dns-blocklist"),
];
let command_bitcoind = command![
"bitcoind",
input.networks.bitcoin.to_flag(),
flag!("-rpcallowip=0.0.0.0/0"),
flag!("-rpcbind=0.0.0.0:{}", input.ports.bitcoind_rpc),
flag!("-bind=0.0.0.0:{}", input.ports.bitcoind_p2p),
flag!("-datadir=/bitcoind-data/"),
flag!("-dbcache=16384"),
// These are required for electrs
// See: See: https://github.com/romanz/electrs/blob/master/doc/config.md#bitcoind-configuration
flag!("-server=1"),
flag!("-prune=0"),
flag!("-txindex=1"),
];
let electrs_network: electrs::Network = input.networks.clone().into();
let command_electrs = command![
"electrs",
electrs_network.to_flag(),
flag!("--daemon-dir=/bitcoind-data/"),
flag!("--db-dir=/electrs-data/db"),
flag!("--daemon-rpc-addr=bitcoind:{}", input.ports.bitcoind_rpc),
flag!("--daemon-p2p-addr=bitcoind:{}", input.ports.bitcoind_p2p),
flag!("--electrum-rpc-addr=0.0.0.0:{}", input.ports.electrs),
flag!("--log-filters=INFO"),
];
let command_asb_controller = command![
"asb-controller",
flag!("--url=http://asb:{}", input.ports.asb_rpc_port),
];
let date = chrono::Utc::now()
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string();
let compose_str = format!(
"\
# This file was auto-generated by `orchestrator` on {date}
#
# It is pinned to build the `asb` and `asb-controller` images from this commit:
# {PINNED_GIT_REPOSITORY}
#
# If the code does not match the hash, the build will fail. This ensures that the code cannot be altered by Github.
# The compiled `orchestrator` has this hash burned into the binary.
#
# To update the `asb` and `asb-controller` images, you need to either:
# - re-compile the `orchestrator` binary from a commit from Github
# - download a newer pre-compiled version of the `orchestrator` binary from Github.
#
# After updating the `orchestrator` binary, re-generate the compose file by running `orchestrator` again.
#
# The used images for `bitcoind`, `monerod`, `electrs` are pinned to specific hashes which prevents them from being altered by the Docker registry.
#
# Please check for new releases regularly. Breaking network changes are rare, but they do happen from time to time.
name: {project_name}
services:
monerod:
container_name: monerod
{image_monerod}
restart: unless-stopped
user: root
volumes:
- 'monerod-data:/monerod-data/'
expose:
- {port_monerod_rpc}
entrypoint: ''
command: {command_monerod}
bitcoind:
container_name: bitcoind
{image_bitcoind}
restart: unless-stopped
volumes:
- 'bitcoind-data:/bitcoind-data/'
expose:
- {port_bitcoind_rpc}
- {port_bitcoind_p2p}
user: root
entrypoint: ''
command: {command_bitcoind}
electrs:
container_name: electrs
{image_electrs}
restart: unless-stopped
user: root
depends_on:
- bitcoind
volumes:
- 'bitcoind-data:/bitcoind-data'
- 'electrs-data:/electrs-data'
expose:
- {electrs_port}
entrypoint: ''
command: {command_electrs}
asb:
container_name: asb
{image_asb}
restart: unless-stopped
depends_on:
- electrs
volumes:
- '{asb_config_path_on_host}:{asb_config_path_inside_container}'
- 'asb-data:{asb_data_dir}'
ports:
- '0.0.0.0:{asb_port}:{asb_port}'
entrypoint: ''
command: {command_asb}
asb-controller:
container_name: asb-controller
{image_asb_controller}
stdin_open: true
tty: true
restart: unless-stopped
depends_on:
- asb
entrypoint: ''
command: {command_asb_controller}
volumes:
monerod-data:
bitcoind-data:
electrs-data:
asb-data:
",
port_monerod_rpc = input.ports.monerod_rpc,
port_bitcoind_rpc = input.ports.bitcoind_rpc,
port_bitcoind_p2p = input.ports.bitcoind_p2p,
electrs_port = input.ports.electrs,
asb_port = input.ports.asb_libp2p,
image_monerod = input.images.monerod.to_image_attribute(),
image_electrs = input.images.electrs.to_image_attribute(),
image_bitcoind = input.images.bitcoind.to_image_attribute(),
image_asb = input.images.asb.to_image_attribute(),
image_asb_controller = input.images.asb_controller.to_image_attribute(),
asb_data_dir = input.directories.asb_data_dir.display(),
asb_config_path_on_host = input.directories.asb_config_path_on_host(),
asb_config_path_inside_container = input.directories.asb_config_path_inside_container().display(),
);
validate_compose(&compose_str);
compose_str
}
pub struct Flags(Vec<Flag>);
/// Displays a list of flags into the "Exec form" supported by Docker
/// This is documented here:
/// https://docs.docker.com/reference/dockerfile/#exec-form
///
/// E.g ["/bin/bash", "-c", "echo hello"]
impl Display for Flags {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Collect all non-none flags
let flags = self
.0
.iter()
.filter_map(|f| f.0.as_ref())
.collect::<Vec<_>>();
// Put the " around each flag, join with a comma, put the whole thing in []
write!(
f,
"[{}]",
flags
.into_iter()
.map(|f| format!("\"{}\"", f))
.collect::<Vec<_>>()
.join(",")
)
}
}
pub struct Flag(pub Option<String>);
impl Display for Flag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(s) = &self.0 {
return write!(f, "{}", s);
}
Ok(())
}
}
pub trait IntoFlag {
/// Converts into a flag that can be used in a docker compose file
fn to_flag(self) -> Flag;
/// Converts into a string that can be used for display purposes
fn to_display(self) -> &'static str;
}
impl IntoFlag for monero::Network {
/// This is documented here:
/// https://docs.getmonero.org/interacting/monerod-reference/#pick-monero-network-blockchain
fn to_flag(self) -> Flag {
Flag(match self {
monero::Network::Mainnet => None,
monero::Network::Stagenet => Some("--stagenet".to_string()),
monero::Network::Testnet => Some("--testnet".to_string()),
})
}
fn to_display(self) -> &'static str {
match self {
monero::Network::Mainnet => "mainnet",
monero::Network::Stagenet => "stagenet",
monero::Network::Testnet => "testnet",
}
}
}
impl IntoFlag for bitcoin::Network {
/// This is documented here:
/// https://www.mankier.com/1/bitcoind
fn to_flag(self) -> Flag {
Flag(Some(match self {
bitcoin::Network::Bitcoin => "-chain=main".to_string(),
bitcoin::Network::Testnet => "-chain=test".to_string(),
_ => panic!("Only Mainnet and Testnet are supported"),
}))
}
fn to_display(self) -> &'static str {
match self {
bitcoin::Network::Bitcoin => "mainnet",
bitcoin::Network::Testnet => "testnet",
_ => panic!("Only Mainnet and Testnet are supported"),
}
}
}
pub trait IntoSpec {
fn to_spec(self) -> String;
}
impl IntoSpec for OrchestratorInput {
fn to_spec(self) -> String {
build(self)
}
}
/// Converts something into either a:
/// - image: <image>
/// - build: <url to git repo>
pub trait IntoImageAttribute {
fn to_image_attribute(self) -> String;
}
impl IntoImageAttribute for OrchestratorImage {
fn to_image_attribute(self) -> String {
match self {
OrchestratorImage::Registry(image) => format!("image: {}", image),
OrchestratorImage::Build(input) => format!(
r#"build: {{ context: "{}", dockerfile: "{}" }}"#,
input.context, input.dockerfile
),
}
}
}
fn validate_compose(compose_str: &str) {
serde_yaml::from_str::<Compose>(compose_str).expect(&format!(
"Generated compose spec to be valid. But it was not. This is the spec: \n\n{}",
compose_str
));
}

View file

@ -0,0 +1,33 @@
use crate::{
compose::{Flag, IntoFlag},
flag,
};
/// Wrapper around a Bitcoin network for Electrs
/// Electrs needs a different network flag than bitcoind
#[derive(Clone)]
pub struct Network(bitcoin::Network);
impl Network {
pub fn new(bitcoin: bitcoin::Network) -> Self {
Self(bitcoin)
}
}
impl IntoFlag for Network {
fn to_flag(self) -> Flag {
match self.0 {
bitcoin::Network::Bitcoin => flag!("--network=mainnet"),
bitcoin::Network::Testnet => flag!("--network=testnet"),
_ => panic!("Only Mainnet and Testnet are supported"),
}
}
fn to_display(self) -> &'static str {
match self.0 {
bitcoin::Network::Bitcoin => "mainnet",
bitcoin::Network::Testnet => "testnet",
_ => panic!("Only Mainnet and Testnet are supported"),
}
}
}

View file

@ -0,0 +1,45 @@
use crate::compose::DockerBuildInput;
/// At compile time, we determine the git repository and commit hash
/// This is then burned into the binary as a static string
/// If the Git hash doesn't match, Docker will fail to build the image
pub static PINNED_GIT_REPOSITORY: &str = concat!(
"https://github.com/eigenwallet/core.git#",
env!("VERGEN_GIT_SHA")
);
/// All of these images are pinned to a specific commit
/// This ensures that the images cannot be altered by the registry
/// monerod v0.18.4.1 (https://github.com/sethforprivacy/simple-monerod-docker/pkgs/container/simple-monerod/471968653)
pub static MONEROD_IMAGE: &str = "ghcr.io/sethforprivacy/simple-monerod@sha256:f30e5706a335c384e4cf420215cbffd1196f0b3a11d4dd4e819fe3e0bca41ec5";
/// electrs v0.10.9 (https://hub.docker.com/layers/getumbrel/electrs/v0.10.9/images/sha256-738d066836953c28936eab59fd87bf5f940d457260d0d13cfc99b06513248fee)
pub static ELECTRS_IMAGE: &str =
"getumbrel/electrs@sha256:622657fbdc7331a69f5b3444e6f87867d51ac27d90c399c8bf25d9aab020052b";
/// bitcoind v28.1 (https://hub.docker.com/layers/getumbrel/bitcoind/v28.1/images/sha256-8a20dc4efd799c17fd20f27cc62a36d1ef157e2ef074a898eae88c712b8d0e24)
pub static BITCOIND_IMAGE: &str =
"getumbrel/bitcoind@sha256:c565266ea302c9ab2fc490f04ff14e584210cde3d0d991b8309157e5dfae9e8d";
/// eigenwallet asb v3.0.0-beta.5 (https://github.com/eigenwallet/core/pkgs/container/asb/477796831?tag=3.0.0-beta.5)
pub static ASB_IMAGE: &str = "ghcr.io/eigenwallet/asb@sha256:ad0daf2ee68d05f6cb08df3d4ec856a07b0fb00df62dd5412298ecc2380f4ca6";
// TOOD: Add pre-built images here
/// eigenwallet asb v3.0.0-beta.5 (https://github.com/eigenwallet/core/commit/886dbcbef2dda534d1a0763750f1e6c5e1f57564)
// pub static ASB_IMAGE_FROM_SOURCE: &str = "https://github.com/eigenwallet/core.git#886dbcbef2dda534d1a0763750f1e6c5e1f57564";
// TODO: Allowing using a local git repository here
pub static ASB_IMAGE_FROM_SOURCE: DockerBuildInput = DockerBuildInput {
// The context is the root of the Cargo workspace
context: PINNED_GIT_REPOSITORY,
// The Dockerfile of the asb is in the swap-asb crate
dockerfile: "./swap-asb/Dockerfile",
};
pub static ASB_CONTROLLER_IMAGE_FROM_SOURCE: DockerBuildInput = DockerBuildInput {
// The context is the root of the Cargo workspace
context: PINNED_GIT_REPOSITORY,
// The Dockerfile of the asb-controller is in the swap-controller crate
dockerfile: "./swap-controller/Dockerfile",
};

View file

@ -0,0 +1,4 @@
pub mod asb;
pub mod compose;
pub mod electrs;
pub mod images;

View file

@ -0,0 +1,250 @@
mod asb;
mod compose;
mod electrs;
mod images;
use crate::compose::{
IntoSpec, OrchestratorDirectories, OrchestratorImage, OrchestratorImages, OrchestratorInput,
OrchestratorNetworks, DOCKER_COMPOSE_FILE,
};
use std::path::PathBuf;
use swap_env::config::{Bitcoin, Config, Data, Maker, Monero, Network, TorConf};
use swap_env::prompt as config_prompt;
use swap_env::{defaults::GetDefaults, env::Mainnet, env::Testnet};
use url::Url;
use crate::compose::ASB_DATA_DIR;
fn main() {
let (bitcoin_network, monero_network) = prompt::network();
let defaults = match (bitcoin_network, monero_network) {
(bitcoin::Network::Bitcoin, monero::Network::Mainnet) => {
Mainnet::get_config_file_defaults().expect("defaults to be available")
}
(bitcoin::Network::Testnet, monero::Network::Stagenet) => {
Testnet::get_config_file_defaults().expect("defaults to be available")
}
_ => panic!("Unsupported Bitcoin / Monero network combination"),
};
// TOOD: Allow pre-built images here
//let build_type = prompt::build_type();
let min_buy_btc = config_prompt::min_buy_amount().expect("Failed to prompt for min buy amount");
let max_buy_btc = config_prompt::max_buy_amount().expect("Failed to prompt for max buy amount");
let ask_spread = config_prompt::ask_spread().expect("Failed to prompt for ask spread");
let rendezvous_points =
config_prompt::rendezvous_points().expect("Failed to prompt for rendezvous points");
let tor_hidden_service =
config_prompt::tor_hidden_service().expect("Failed to prompt for tor hidden service");
let listen_addresses = config_prompt::listen_addresses(&defaults.listen_address_tcp)
.expect("Failed to prompt for listen addresses");
let monero_node_type = prompt::monero_node_type();
let electrum_server_type = prompt::electrum_server_type(&defaults.electrum_rpc_urls);
let recipe = OrchestratorInput {
ports: OrchestratorNetworks {
monero: monero_network,
bitcoin: bitcoin_network,
}
.into(),
networks: OrchestratorNetworks {
monero: monero_network,
bitcoin: bitcoin_network,
},
images: OrchestratorImages {
monerod: OrchestratorImage::Registry(images::MONEROD_IMAGE.to_string()),
electrs: OrchestratorImage::Registry(images::ELECTRS_IMAGE.to_string()),
bitcoind: OrchestratorImage::Registry(images::BITCOIND_IMAGE.to_string()),
// TODO: Allow pre-built images here
asb: OrchestratorImage::Build(images::ASB_IMAGE_FROM_SOURCE.clone()),
// TODO: Allow pre-built images here
asb_controller: OrchestratorImage::Build(
images::ASB_CONTROLLER_IMAGE_FROM_SOURCE.clone(),
),
},
directories: OrchestratorDirectories {
asb_data_dir: PathBuf::from(ASB_DATA_DIR),
},
};
let electrs_url = Url::parse(&format!("tcp://electrs:{}", recipe.ports.electrs))
.expect("electrs url to be convertible to a valid url");
let monerod_daemon_url = Url::parse(&format!("http://monerod:{}", recipe.ports.monerod_rpc))
.expect("monerod daemon url to be convertible to a valid url");
let config = Config {
data: Data {
dir: recipe.directories.asb_data_dir.clone(),
},
network: Network {
listen: listen_addresses,
rendezvous_point: rendezvous_points,
external_addresses: vec![],
},
bitcoin: Bitcoin {
electrum_rpc_urls: match electrum_server_type {
// If user chose the included option, we will use the electrs url from the container
ElectrumServerType::Included => vec![electrs_url],
ElectrumServerType::Remote(electrum_servers) => electrum_servers,
},
network: bitcoin_network,
target_block: defaults.bitcoin_confirmation_target,
use_mempool_space_fee_estimation: defaults.use_mempool_space_fee_estimation,
// This means that we will use the default set in swap-env/src/env.rs
finality_confirmations: None,
},
monero: Monero {
daemon_url: match monero_node_type.clone() {
MoneroNodeType::Included => Some(monerod_daemon_url),
MoneroNodeType::Pool => None,
MoneroNodeType::Remote(url) => Some(url),
},
network: monero_network,
// This means that we will use the default set in swap-env/src/env.rs
finality_confirmations: None,
},
tor: TorConf {
register_hidden_service: tor_hidden_service,
..Default::default()
},
maker: Maker {
min_buy_btc,
max_buy_btc,
ask_spread,
price_ticker_ws_url: defaults.price_ticker_ws_url,
external_bitcoin_redeem_address: None,
},
};
// Write the compose to ./docker-compose.yml and the config to ./config.toml
let asb_config_path = recipe.directories.asb_config_path_on_host();
let compose = recipe.to_spec();
std::fs::write(DOCKER_COMPOSE_FILE, compose).expect("Failed to write docker-compose.yml");
std::fs::write(
asb_config_path,
toml::to_string(&config).expect("Failed to write config.toml"),
)
.expect("Failed to write config.toml");
println!();
println!("Run `docker compose up -d` to start the services.");
}
#[derive(Debug)]
enum BuildType {
Source,
Prebuilt,
}
#[derive(Clone)]
enum MoneroNodeType {
Included, // Run a Monero node
Pool, // Use the Monero Remote Node Pool with built in defaults
Remote(Url), // Use a specific remote Monero node
}
enum ElectrumServerType {
Included, // Run a Bitcoin node and Electrum server
Remote(Vec<Url>), // Use a specific remote Electrum server
}
mod prompt {
use dialoguer::{theme::ColorfulTheme, Input, Select};
use swap_env::prompt as config_prompt;
use url::Url;
use crate::{BuildType, ElectrumServerType, MoneroNodeType};
pub fn network() -> (bitcoin::Network, monero::Network) {
let network = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Which network do you want to run on?")
.items(&[
"Mainnet Bitcoin & Mainnet Monero",
"Testnet Bitcoin & Stagenet Monero",
])
.default(0)
.interact()
.expect("Failed to select network");
match network {
0 => (bitcoin::Network::Bitcoin, monero::Network::Mainnet),
1 => (bitcoin::Network::Testnet, monero::Network::Stagenet),
_ => unreachable!(),
}
}
#[allow(dead_code)] // will be used in the future
pub fn build_type() -> BuildType {
let build_type = Select::with_theme(&ColorfulTheme::default())
.with_prompt("How do you want to build the Docker image for the ASB?")
.items(&[
"Build Docker image from source (can take >1h)",
"Prebuild Docker image (pinned to a specific commit with SHA256 hash)",
])
.default(0)
.interact()
.expect("Failed to select build type");
match build_type {
0 => BuildType::Source,
1 => BuildType::Prebuilt,
_ => unreachable!(),
}
}
pub fn monero_node_type() -> MoneroNodeType {
let node_choice = Select::with_theme(&ColorfulTheme::default())
.with_prompt(
"Do you want to include a Monero node or use an existing node/remote node?",
)
.items(&[
"Include a full Monero node",
"Use an existing node or remote node",
])
.default(0)
.interact()
.expect("Failed to select node choice");
match node_choice {
0 => MoneroNodeType::Included,
1 => {
match config_prompt::monero_daemon_url()
.expect("Failed to prompt for Monero daemon URL")
{
Some(url) => MoneroNodeType::Remote(url),
None => MoneroNodeType::Pool,
}
}
_ => unreachable!(),
}
}
pub fn electrum_server_type(default_electrum_urls: &Vec<Url>) -> ElectrumServerType {
let electrum_server_type = Select::with_theme(&ColorfulTheme::default())
.with_prompt("How do you want to connect to the Bitcoin network?")
.items(&[
"Run a full Bitcoin node & Electrum server",
"List of remote Electrum servers",
])
.default(0)
.interact()
.expect("Failed to select electrum server type");
match electrum_server_type {
0 => ElectrumServerType::Included,
1 => {
println!("Okay, let's use remote Electrum servers!");
let electrum_servers = config_prompt::electrum_rpc_urls(default_electrum_urls)
.expect("Failed to prompt for electrum servers");
ElectrumServerType::Remote(electrum_servers)
}
_ => unreachable!(),
}
}
}

View file

@ -0,0 +1,39 @@
use swap_orchestrator::compose::{
IntoSpec, OrchestratorImage, OrchestratorImages, OrchestratorInput, OrchestratorNetworks,
OrchestratorPorts,
};
use swap_orchestrator::{asb, electrs, images};
#[test]
fn test_orchestrator_spec_generation() {
let input = OrchestratorInput {
ports: OrchestratorPorts {
monerod_rpc: 38081,
bitcoind_rpc: 18332,
bitcoind_p2p: 18333,
electrs: 60001,
asb_libp2p: 9839,
asb_rpc_port: 9944,
},
networks: OrchestratorNetworks {
monero: monero::Network::Stagenet,
bitcoin: bitcoin::Network::Testnet,
electrs: electrs::Network::Testnet,
asb: asb::Network::Testnet,
},
images: OrchestratorImages {
monerod: OrchestratorImage::Registry(images::MONEROD_IMAGE.to_string()),
electrs: OrchestratorImage::Registry(images::ELECTRS_IMAGE.to_string()),
bitcoind: OrchestratorImage::Registry(images::BITCOIND_IMAGE.to_string()),
asb: OrchestratorImage::Build(images::ASB_IMAGE.to_string()),
asb_controller: OrchestratorImage::Build(images::ASB_CONTROLLER_IMAGE.to_string()),
},
directories: OrchestratorDirectories {
asb_data_dir: PathBuf::from(ASB_DATA_DIR),
},
};
let spec = input.to_spec();
println!("{}", spec);
}

View file

@ -44,6 +44,7 @@ electrum-pool = { path = "../electrum-pool" }
fns = "0.0.7"
futures = { workspace = true }
hex = { workspace = true }
jsonrpsee = { workspace = true, features = ["server"] }
libp2p = { workspace = true, features = ["tcp", "yamux", "dns", "noise", "request-response", "ping", "rendezvous", "identify", "macros", "cbor", "json", "tokio", "serde", "rsa"] }
libp2p-tor = { path = "../libp2p-tor", features = ["listen-onion-service"] }
moka = { version = "0.12", features = ["sync", "future"] }
@ -72,6 +73,7 @@ sigma_fun = { version = "0.7", default-features = false, features = ["ed25519",
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls"] }
structopt = "0.3"
strum = { version = "0.26", features = ["derive"] }
swap-controller-api = { path = "../swap-controller-api" }
swap-env = { path = "../swap-env" }
swap-feed = { path = "../swap-feed" }
swap-fs = { path = "../swap-fs" }
@ -115,4 +117,4 @@ testcontainers = "0.15"
[build-dependencies]
anyhow = { workspace = true }
vergen = { version = "8.3", default-features = false, features = ["build", "git", "git2"] }
vergen = { workspace = true }

View file

@ -1,7 +1,7 @@
pub mod command;
mod event_loop;
mod network;
mod recovery;
pub mod rpc;
pub use event_loop::{EventLoop, EventLoopHandle};
pub use network::behaviour::{Behaviour, OutEvent};

View file

@ -1,3 +1,6 @@
use self::quote::{
make_quote, unlocked_monero_balance_with_timeout, QuoteCacheKey, QUOTE_CACHE_TTL,
};
use crate::asb::{Behaviour, OutEvent};
use crate::network::cooperative_xmr_redeem_after_punish::CooperativeXmrRedeemRejectReason;
use crate::network::cooperative_xmr_redeem_after_punish::Response::{Fullfilled, Rejected};
@ -5,7 +8,7 @@ use crate::network::quote::BidQuote;
use crate::network::swap_setup::alice::WalletSnapshot;
use crate::network::transfer_proof;
use crate::protocol::alice::swap::has_already_processed_enc_sig;
use crate::protocol::alice::{AliceState, ReservesMonero, State3, Swap};
use crate::protocol::alice::{AliceState, State3, Swap};
use crate::protocol::{Database, State};
use crate::{bitcoin, monero};
use anyhow::{anyhow, Context, Result};
@ -16,7 +19,6 @@ use libp2p::request_response::{OutboundFailure, OutboundRequestId, ResponseChann
use libp2p::swarm::SwarmEvent;
use libp2p::{PeerId, Swarm};
use moka::future::Cache;
use monero::Amount;
use std::collections::HashMap;
use std::convert::TryInto;
use std::fmt::Debug;
@ -25,18 +27,9 @@ use std::time::Duration;
use swap_env::env;
use swap_feed::LatestRate;
use tokio::sync::{mpsc, oneshot};
use tokio::time::timeout;
use uuid::Uuid;
/// The time-to-live for quotes in the cache
const QUOTE_CACHE_TTL: Duration = Duration::from_secs(120);
/// The key for the quote cache
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct QuoteCacheKey {
min_buy: bitcoin::Amount,
max_buy: bitcoin::Amount,
}
pub use service::{EventLoopRequest, EventLoopService};
#[allow(missing_debug_implementations)]
pub struct EventLoop<LR>
@ -98,6 +91,9 @@ where
oneshot::Sender<Result<(), OutboundFailure>>,
)>,
/// Channel for service requests
service_requests: mpsc::UnboundedReceiver<EventLoopRequest>,
/// Temporarily stores transfer proof requests for peers that are currently disconnected.
///
/// When a transfer proof cannot be sent because there's no connection to the peer:
@ -139,10 +135,11 @@ where
min_buy: bitcoin::Amount,
max_buy: bitcoin::Amount,
external_redeem_address: Option<bitcoin::Address>,
) -> Result<(Self, mpsc::Receiver<Swap>)> {
) -> Result<(Self, mpsc::Receiver<Swap>, EventLoopService)> {
let swap_channel = MpscChannels::default();
let (outgoing_transfer_proofs_sender, outgoing_transfer_proofs_requests) =
tokio::sync::mpsc::unbounded_channel();
let (service_sender, service_requests) = mpsc::unbounded_channel();
let quote_cache = Cache::builder().time_to_live(QUOTE_CACHE_TTL).build();
@ -162,16 +159,24 @@ where
inflight_encrypted_signatures: Default::default(),
outgoing_transfer_proofs_requests,
outgoing_transfer_proofs_sender,
service_requests,
buffered_transfer_proofs: Default::default(),
inflight_transfer_proofs: Default::default(),
};
Ok((event_loop, swap_channel.receiver))
let service = EventLoopService::new(service_sender);
Ok((event_loop, swap_channel.receiver, service))
}
pub fn peer_id(&self) -> PeerId {
*Swarm::local_peer_id(&self.swarm)
}
pub fn external_addresses(&self) -> Vec<libp2p::Multiaddr> {
self.swarm.external_addresses().cloned().collect()
}
pub async fn run(mut self) {
// ensure that these streams are NEVER empty, otherwise it will
// terminate forever.
@ -488,6 +493,19 @@ where
},
Some(response_channel) = self.inflight_encrypted_signatures.next() => {
let _ = self.swarm.behaviour_mut().encrypted_signature.send_response(response_channel, ());
},
Some(request) = self.service_requests.recv() => {
match request {
EventLoopRequest::GetMultiaddresses { respond_to } => {
let peer_id = *self.swarm.local_peer_id();
let addresses = self.swarm.external_addresses().cloned().collect();
let _ = respond_to.send((peer_id, addresses));
}
EventLoopRequest::GetActiveConnections { respond_to } => {
let count = self.swarm.connected_peers().count();
let _ = respond_to.send(count);
}
}
}
}
}
@ -726,118 +744,185 @@ impl EventLoopHandle {
}
}
/// Computes a quote given the provided dependencies
pub async fn make_quote<LR, F, Fut, I, Fut2, T>(
min_buy: bitcoin::Amount,
max_buy: bitcoin::Amount,
mut latest_rate: LR,
get_unlocked_balance: F,
get_reserved_items: I,
) -> Result<Arc<BidQuote>, Arc<anyhow::Error>>
where
LR: LatestRate,
F: FnOnce() -> Fut,
Fut: futures::Future<Output = Result<Amount, anyhow::Error>>,
I: FnOnce() -> Fut2,
Fut2: futures::Future<Output = Result<Vec<T>, anyhow::Error>>,
T: ReservesMonero,
{
let ask_price = latest_rate
.latest_rate()
.map_err(|e| Arc::new(anyhow!(e).context("Failed to get latest rate")))?
.ask()
.map_err(|e| Arc::new(e.context("Failed to compute asking price")))?;
mod service {
use super::*;
// Get the unlocked balance
let unlocked_balance = get_unlocked_balance()
.await
.context("Failed to get unlocked Monero balance")
.map_err(Arc::new)?;
// Get the reserved amounts
let reserved_amounts: Vec<Amount> = get_reserved_items()
.await
.context("Failed to get reserved items")
.map_err(Arc::new)?
.into_iter()
.map(|item| item.reserved_monero())
.collect();
let unreserved_xmr_balance =
unreserved_monero_balance(unlocked_balance, reserved_amounts.into_iter());
let max_bitcoin_for_monero = unreserved_xmr_balance
.max_bitcoin_for_price(ask_price)
.ok_or_else(|| {
Arc::new(anyhow!(
"Bitcoin price ({}) x Monero ({}) overflow",
ask_price,
unreserved_xmr_balance
))
})?;
tracing::trace!(%ask_price, %unreserved_xmr_balance, %max_bitcoin_for_monero, "Computed quote");
if min_buy > max_bitcoin_for_monero {
tracing::trace!(
"Your Monero balance is too low to initiate a swap, as your minimum swap amount is {}. You could at most swap {}",
min_buy, max_bitcoin_for_monero
);
return Ok(Arc::new(BidQuote {
price: ask_price,
min_quantity: bitcoin::Amount::ZERO,
max_quantity: bitcoin::Amount::ZERO,
}));
/// Request types for the EventLoop service with typed responders
#[derive(Debug)]
pub enum EventLoopRequest {
GetMultiaddresses {
respond_to: oneshot::Sender<(PeerId, Vec<libp2p::Multiaddr>)>,
},
GetActiveConnections {
respond_to: oneshot::Sender<usize>,
},
}
if max_buy > max_bitcoin_for_monero {
tracing::trace!(
"Your Monero balance is too low to initiate a swap with the maximum swap amount {} that you have specified in your config. You can at most swap {}",
max_buy, max_bitcoin_for_monero
);
/// Tower service for communicating with the EventLoop
#[derive(Debug, Clone)]
pub struct EventLoopService {
sender: mpsc::UnboundedSender<EventLoopRequest>,
}
return Ok(Arc::new(BidQuote {
impl EventLoopService {
pub fn new(sender: mpsc::UnboundedSender<EventLoopRequest>) -> Self {
Self { sender }
}
/// Get multiaddresses and peer ID from the event loop
pub async fn get_multiaddresses(&self) -> anyhow::Result<(PeerId, Vec<libp2p::Multiaddr>)> {
let (tx, rx) = oneshot::channel();
self.sender
.send(EventLoopRequest::GetMultiaddresses { respond_to: tx })
.map_err(|_| anyhow::anyhow!("EventLoop service is down"))?;
rx.await
.map_err(|_| anyhow::anyhow!("EventLoop service did not respond"))
}
/// Get the number of active connections from the event loop
pub async fn get_active_connections(&self) -> anyhow::Result<usize> {
let (tx, rx) = oneshot::channel();
self.sender
.send(EventLoopRequest::GetActiveConnections { respond_to: tx })
.map_err(|_| anyhow::anyhow!("EventLoop service is down"))?;
rx.await
.map_err(|_| anyhow::anyhow!("EventLoop service did not respond"))
}
}
}
mod quote {
use crate::monero::Amount;
use anyhow::{anyhow, Context};
use std::{sync::Arc, time::Duration};
use swap_feed::LatestRate;
use tokio::time::timeout;
use crate::{network::quote::BidQuote, protocol::alice::ReservesMonero};
/// The time-to-live for quotes in the cache
pub const QUOTE_CACHE_TTL: Duration = Duration::from_secs(120);
/// The key for the quote cache
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct QuoteCacheKey {
pub min_buy: bitcoin::Amount,
pub max_buy: bitcoin::Amount,
}
/// Computes a quote given the provided dependencies
pub async fn make_quote<LR, F, Fut, I, Fut2, T>(
min_buy: bitcoin::Amount,
max_buy: bitcoin::Amount,
mut latest_rate: LR,
get_unlocked_balance: F,
get_reserved_items: I,
) -> Result<Arc<BidQuote>, Arc<anyhow::Error>>
where
LR: LatestRate,
F: FnOnce() -> Fut,
Fut: futures::Future<Output = Result<Amount, anyhow::Error>>,
I: FnOnce() -> Fut2,
Fut2: futures::Future<Output = Result<Vec<T>, anyhow::Error>>,
T: ReservesMonero,
{
let ask_price = latest_rate
.latest_rate()
.map_err(|e| Arc::new(anyhow!(e).context("Failed to get latest rate")))?
.ask()
.map_err(|e| Arc::new(e.context("Failed to compute asking price")))?;
// Get the unlocked balance
let unlocked_balance = get_unlocked_balance()
.await
.context("Failed to get unlocked Monero balance")
.map_err(Arc::new)?;
// Get the reserved amounts
let reserved_amounts: Vec<_> = get_reserved_items()
.await
.context("Failed to get reserved items")
.map_err(Arc::new)?
.into_iter()
.map(|item| item.reserved_monero())
.collect();
let unreserved_xmr_balance =
unreserved_monero_balance(unlocked_balance, reserved_amounts.into_iter());
let max_bitcoin_for_monero = unreserved_xmr_balance
.max_bitcoin_for_price(ask_price)
.ok_or_else(|| {
Arc::new(anyhow!(
"Bitcoin price ({}) x Monero ({}) overflow",
ask_price,
unreserved_xmr_balance
))
})?;
tracing::trace!(%ask_price, %unreserved_xmr_balance, %max_bitcoin_for_monero, "Computed quote");
if min_buy > max_bitcoin_for_monero {
tracing::trace!(
"Your Monero balance is too low to initiate a swap, as your minimum swap amount is {}. You could at most swap {}",
min_buy, max_bitcoin_for_monero
);
return Ok(Arc::new(BidQuote {
price: ask_price,
min_quantity: bitcoin::Amount::ZERO,
max_quantity: bitcoin::Amount::ZERO,
}));
}
if max_buy > max_bitcoin_for_monero {
tracing::trace!(
"Your Monero balance is too low to initiate a swap with the maximum swap amount {} that you have specified in your config. You can at most swap {}",
max_buy, max_bitcoin_for_monero
);
return Ok(Arc::new(BidQuote {
price: ask_price,
min_quantity: min_buy,
max_quantity: max_bitcoin_for_monero,
}));
}
Ok(Arc::new(BidQuote {
price: ask_price,
min_quantity: min_buy,
max_quantity: max_bitcoin_for_monero,
}));
max_quantity: max_buy,
}))
}
Ok(Arc::new(BidQuote {
price: ask_price,
min_quantity: min_buy,
max_quantity: max_buy,
}))
}
/// Calculates the unreserved Monero balance by subtracting reserved amounts from unlocked balance
pub fn unreserved_monero_balance(
unlocked_balance: Amount,
reserved_amounts: impl Iterator<Item = Amount>,
) -> Amount {
// Get the sum of all the individual reserved amounts
let total_reserved = reserved_amounts.fold(Amount::ZERO, |acc, amount| acc + amount);
/// Calculates the unreserved Monero balance by subtracting reserved amounts from unlocked balance
pub fn unreserved_monero_balance(
unlocked_balance: Amount,
reserved_amounts: impl Iterator<Item = Amount>,
) -> Amount {
// Get the sum of all the individual reserved amounts
let total_reserved = reserved_amounts.fold(Amount::ZERO, |acc, amount| acc + amount);
// Check how much of our unlocked balance is left when we
// take into account the reserved amounts
unlocked_balance
.checked_sub(total_reserved)
.unwrap_or(Amount::ZERO)
}
// Check how much of our unlocked balance is left when we
// take into account the reserved amounts
unlocked_balance
.checked_sub(total_reserved)
.unwrap_or(Amount::ZERO)
}
/// Returns the unlocked Monero balance from the wallet
pub async fn unlocked_monero_balance_with_timeout(
wallet: Arc<crate::monero::Wallet>,
) -> Result<Amount, anyhow::Error> {
/// This is how long we maximally wait for the wallet operation
const MONERO_WALLET_OPERATION_TIMEOUT: Duration = Duration::from_secs(10);
/// Returns the unlocked Monero balance from the wallet
async fn unlocked_monero_balance_with_timeout(
wallet: Arc<monero::Wallet>,
) -> Result<Amount, anyhow::Error> {
/// This is how long we maximally wait for the wallet operation
const MONERO_WALLET_OPERATION_TIMEOUT: Duration = Duration::from_secs(10);
let balance = timeout(MONERO_WALLET_OPERATION_TIMEOUT, wallet.unlocked_balance())
.await
.context("Timeout while getting unlocked balance from Monero wallet")?;
let balance = timeout(MONERO_WALLET_OPERATION_TIMEOUT, wallet.unlocked_balance())
.await
.context("Timeout while getting unlocked balance from Monero wallet")?;
Ok(balance.into())
Ok(balance.into())
}
}
#[allow(missing_debug_implementations)]
@ -855,8 +940,17 @@ impl<T> Default for MpscChannels<T> {
#[cfg(test)]
mod tests {
use swap_feed::FixedRate;
use crate::{
asb::event_loop::quote::{make_quote, unreserved_monero_balance},
protocol::alice::ReservesMonero,
};
use super::*;
use crate::monero::Amount;
#[tokio::test]
async fn test_unreserved_monero_balance_with_no_reserved_amounts() {
let balance = Amount::from_monero(10.0).unwrap();
@ -869,59 +963,59 @@ mod tests {
#[tokio::test]
async fn test_unreserved_monero_balance_with_reserved_amounts() {
let balance = Amount::from_monero(10.0).unwrap();
let balance = monero::Amount::from_monero(10.0).unwrap();
let reserved_amounts = vec![
Amount::from_monero(2.0).unwrap(),
Amount::from_monero(3.0).unwrap(),
monero::Amount::from_monero(2.0).unwrap(),
monero::Amount::from_monero(3.0).unwrap(),
];
let result = unreserved_monero_balance(balance, reserved_amounts.into_iter());
let expected = Amount::from_monero(5.0).unwrap();
let expected = monero::Amount::from_monero(5.0).unwrap();
assert_eq!(result, expected);
}
#[tokio::test]
async fn test_unreserved_monero_balance_insufficient_balance() {
let balance = Amount::from_monero(5.0).unwrap();
let balance = monero::Amount::from_monero(5.0).unwrap();
let reserved_amounts = vec![
Amount::from_monero(3.0).unwrap(),
Amount::from_monero(4.0).unwrap(), // Total reserved > balance
monero::Amount::from_monero(3.0).unwrap(),
monero::Amount::from_monero(4.0).unwrap(), // Total reserved > balance
];
let result = unreserved_monero_balance(balance, reserved_amounts.into_iter());
// Should return zero when reserved > balance
assert_eq!(result, Amount::ZERO);
assert_eq!(result, monero::Amount::ZERO);
}
#[tokio::test]
async fn test_unreserved_monero_balance_exact_match() {
let balance = Amount::from_monero(10.0).unwrap();
let balance = monero::Amount::from_monero(10.0).unwrap();
let reserved_amounts = vec![
Amount::from_monero(4.0).unwrap(),
Amount::from_monero(6.0).unwrap(), // Exactly equals balance
monero::Amount::from_monero(4.0).unwrap(),
monero::Amount::from_monero(6.0).unwrap(), // Exactly equals balance
];
let result = unreserved_monero_balance(balance, reserved_amounts.into_iter());
assert_eq!(result, Amount::ZERO);
assert_eq!(result, monero::Amount::ZERO);
}
#[tokio::test]
async fn test_unreserved_monero_balance_zero_balance() {
let balance = Amount::ZERO;
let reserved_amounts = vec![Amount::from_monero(1.0).unwrap()];
let balance = monero::Amount::ZERO;
let reserved_amounts = vec![monero::Amount::from_monero(1.0).unwrap()];
let result = unreserved_monero_balance(balance, reserved_amounts.into_iter());
assert_eq!(result, Amount::ZERO);
assert_eq!(result, monero::Amount::ZERO);
}
#[tokio::test]
async fn test_unreserved_monero_balance_empty_reserved_amounts() {
let balance = Amount::from_monero(5.0).unwrap();
let reserved_amounts: Vec<Amount> = vec![];
let balance = monero::Amount::from_monero(5.0).unwrap();
let reserved_amounts: Vec<MockReservedItem> = vec![];
let result = unreserved_monero_balance(balance, reserved_amounts.into_iter());
@ -930,12 +1024,12 @@ mod tests {
#[tokio::test]
async fn test_unreserved_monero_balance_large_amounts() {
let balance = Amount::from_piconero(1_000_000_000);
let reserved_amounts = vec![Amount::from_piconero(300_000_000)];
let balance = monero::Amount::from_piconero(1_000_000_000);
let reserved_amounts = vec![monero::Amount::from_piconero(300_000_000)];
let result = unreserved_monero_balance(balance, reserved_amounts.into_iter());
let expected = Amount::from_piconero(700_000_000);
let expected = monero::Amount::from_piconero(700_000_000);
assert_eq!(result, expected);
}
@ -944,7 +1038,7 @@ mod tests {
let min_buy = bitcoin::Amount::from_sat(100_000);
let max_buy = bitcoin::Amount::from_sat(500_000);
let rate = FixedRate::default();
let balance = Amount::from_monero(1.0).unwrap();
let balance = monero::Amount::from_monero(1.0).unwrap();
let reserved_items: Vec<MockReservedItem> = vec![];
let result = make_quote(
@ -967,13 +1061,13 @@ mod tests {
let min_buy = bitcoin::Amount::from_sat(50_000);
let max_buy = bitcoin::Amount::from_sat(300_000);
let rate = FixedRate::default();
let balance = Amount::from_monero(1.0).unwrap();
let balance = monero::Amount::from_monero(1.0).unwrap();
let reserved_items = vec![
MockReservedItem {
reserved: Amount::from_monero(0.2).unwrap(),
reserved: monero::Amount::from_monero(0.2).unwrap(),
},
MockReservedItem {
reserved: Amount::from_monero(0.3).unwrap(),
reserved: monero::Amount::from_monero(0.3).unwrap(),
},
];
@ -999,7 +1093,7 @@ mod tests {
let min_buy = bitcoin::Amount::from_sat(600_000); // More than available
let max_buy = bitcoin::Amount::from_sat(800_000);
let rate = FixedRate::default();
let balance = Amount::from_monero(0.5).unwrap(); // Only 0.005 BTC worth at rate 0.01
let balance = monero::Amount::from_monero(0.5).unwrap(); // Only 0.005 BTC worth at rate 0.01
let reserved_items: Vec<MockReservedItem> = vec![];
let result = make_quote(
@ -1022,7 +1116,7 @@ mod tests {
let min_buy = bitcoin::Amount::from_sat(100_000);
let max_buy = bitcoin::Amount::from_sat(800_000); // More than available
let rate = FixedRate::default();
let balance = Amount::from_monero(0.6).unwrap(); // 0.006 BTC worth at rate 0.01
let balance = monero::Amount::from_monero(0.6).unwrap(); // 0.006 BTC worth at rate 0.01
let reserved_items: Vec<MockReservedItem> = vec![];
let result = make_quote(
@ -1048,9 +1142,9 @@ mod tests {
let min_buy = bitcoin::Amount::from_sat(100_000);
let max_buy = bitcoin::Amount::from_sat(500_000);
let rate = FixedRate::default();
let balance = Amount::from_monero(1.0).unwrap();
let balance = monero::Amount::from_monero(1.0).unwrap();
let reserved_items = vec![MockReservedItem {
reserved: Amount::from_monero(1.0).unwrap(), // All balance reserved
reserved: monero::Amount::from_monero(1.0).unwrap(), // All balance reserved
}];
let result = make_quote(
@ -1096,7 +1190,7 @@ mod tests {
let min_buy = bitcoin::Amount::from_sat(100_000);
let max_buy = bitcoin::Amount::from_sat(500_000);
let rate = FixedRate::default();
let balance = Amount::from_monero(1.0).unwrap();
let balance = monero::Amount::from_monero(1.0).unwrap();
let reserved_items: Vec<MockReservedItem> = vec![];
let result = make_quote(

3
swap/src/asb/rpc/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod server;
pub use server::RpcServer;

160
swap/src/asb/rpc/server.rs Normal file
View file

@ -0,0 +1,160 @@
use crate::asb::event_loop::EventLoopService;
use crate::protocol::Database;
use crate::{bitcoin, monero};
use anyhow::{Context, Result};
use jsonrpsee::server::{ServerBuilder, ServerHandle};
use jsonrpsee::types::error::ErrorCode;
use jsonrpsee::types::ErrorObjectOwned;
use std::sync::Arc;
use swap_controller_api::{
ActiveConnectionsResponse, AsbApiServer, BitcoinBalanceResponse, MoneroAddressResponse,
MoneroBalanceResponse, MultiaddressesResponse, Swap,
};
use tokio_util::task::AbortOnDropHandle;
pub struct RpcServer {
handle: ServerHandle,
}
impl RpcServer {
pub async fn start(
host: String,
port: u16,
bitcoin_wallet: Arc<bitcoin::Wallet>,
monero_wallet: Arc<monero::Wallets>,
event_loop_service: EventLoopService,
db: Arc<dyn Database + Send + Sync>,
) -> Result<Self> {
let server = ServerBuilder::default()
.build((host, port))
.await
.context("Failed to build RPC server")?;
let addr = server.local_addr()?;
let rpc_impl = RpcImpl {
bitcoin_wallet,
monero_wallet,
event_loop_service,
db,
};
let handle = server.start(rpc_impl.into_rpc());
tracing::info!("JSON-RPC server listening on {}", addr);
Ok(Self { handle })
}
/// Spawn the server in a new tokio task
pub fn spawn(self) -> AbortOnDropHandle<()> {
AbortOnDropHandle::new(tokio::spawn(async move {
self.handle.stopped().await;
}))
}
}
pub struct RpcImpl {
bitcoin_wallet: Arc<bitcoin::Wallet>,
monero_wallet: Arc<monero::Wallets>,
event_loop_service: EventLoopService,
db: Arc<dyn Database + Send + Sync>,
}
#[async_trait::async_trait]
impl AsbApiServer for RpcImpl {
async fn check_connection(&self) -> Result<(), ErrorObjectOwned> {
Ok(())
}
async fn bitcoin_balance(&self) -> Result<BitcoinBalanceResponse, ErrorObjectOwned> {
let balance = self.bitcoin_wallet.balance().await.into_json_rpc_result()?;
Ok(BitcoinBalanceResponse { balance })
}
async fn monero_balance(&self) -> Result<MoneroBalanceResponse, ErrorObjectOwned> {
let wallet = self.monero_wallet.main_wallet().await;
let balance = wallet.total_balance().await;
Ok(MoneroBalanceResponse {
balance: balance.as_pico(),
})
}
async fn monero_address(&self) -> Result<MoneroAddressResponse, ErrorObjectOwned> {
let wallet = self.monero_wallet.main_wallet().await;
let address = wallet.main_address().await;
Ok(MoneroAddressResponse {
address: address.to_string(),
})
}
async fn multiaddresses(&self) -> Result<MultiaddressesResponse, ErrorObjectOwned> {
let (_, addresses) = self
.event_loop_service
.get_multiaddresses()
.await
.into_json_rpc_result()?;
// TODO: Concenate peer id to the multiaddresses
let multiaddresses = addresses.iter().map(|addr| addr.to_string()).collect();
Ok(MultiaddressesResponse { multiaddresses })
}
async fn active_connections(&self) -> Result<ActiveConnectionsResponse, ErrorObjectOwned> {
let connections = self
.event_loop_service
.get_active_connections()
.await
.into_json_rpc_result()?;
Ok(ActiveConnectionsResponse { connections })
}
async fn get_swaps(&self) -> Result<Vec<Swap>, ErrorObjectOwned> {
let swaps = self.db.all().await.into_json_rpc_result()?;
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)
}
}
trait IntoJsonRpcResult<T> {
fn into_json_rpc_result(self) -> Result<T, ErrorObjectOwned>;
}
impl<T> IntoJsonRpcResult<T> for anyhow::Result<T> {
fn into_json_rpc_result(self) -> Result<T, ErrorObjectOwned> {
self.map_err(|e| e.into_json_rpc_error())
}
}
trait IntoJsonRpcError {
fn into_json_rpc_error(self) -> ErrorObjectOwned;
}
impl IntoJsonRpcError for anyhow::Error {
fn into_json_rpc_error(self) -> ErrorObjectOwned {
ErrorObjectOwned::owned(
ErrorCode::InternalError.code(),
format!("{self:?}"),
None::<()>,
)
}
}

View file

@ -582,8 +582,8 @@ mod tests {
&mut OsRng,
btc_amount,
xmr_amount,
config.bitcoin_cancel_timelock,
config.bitcoin_punish_timelock,
config.bitcoin_cancel_timelock.into(),
config.bitcoin_punish_timelock.into(),
bob_wallet.new_address().await.unwrap(),
config.monero_finality_confirmations,
spending_fee,
@ -687,8 +687,8 @@ mod tests {
&mut OsRng,
btc_amount,
xmr_amount,
config.bitcoin_cancel_timelock,
config.bitcoin_punish_timelock,
config.bitcoin_cancel_timelock.into(),
config.bitcoin_punish_timelock.into(),
bob_wallet.new_address().await.unwrap(),
config.monero_finality_confirmations,
spending_fee,

View file

@ -2966,7 +2966,7 @@ impl TestWalletBuilder {
// Fund the wallet with fake utxos
for _ in 0..self.num_utxos {
receive_output_in_latest_block(&mut locked_wallet, self.utxo_amount);
receive_output_in_latest_block(&mut locked_wallet, Amount::from_sat(self.utxo_amount));
}
// Create another block to confirm the utxos

View file

@ -240,10 +240,13 @@ async fn next_state(
// 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
if let Ok(Some(_)) = bitcoin_wallet.get_raw_transaction(state3.tx_lock_id()).await {
if let Ok(Some(_)) = bitcoin_wallet
.get_raw_transaction(state3.tx_lock_id())
.await
{
tracing::info!(txid = %state3.tx_lock_id(), "Bitcoin lock transaction already published, skipping publish");
}else {
// Publish the signed Bitcoin lock transaction
} else {
// Publish the signed Bitcoin lock transaction
let (..) = bitcoin_wallet.broadcast(btc_lock_tx_signed, "lock").await?;
}

View file

@ -268,7 +268,7 @@ async fn start_alice(
.unwrap();
swarm.listen_on(listen_address).unwrap();
let (event_loop, swap_handle) = asb::EventLoop::new(
let (event_loop, swap_handle, _service) = asb::EventLoop::new(
swarm,
env_config,
bitcoin_wallet,