Compare commits
126 Commits
rendezvous
...
master
Author | SHA1 | Date |
---|---|---|
bors[bot] | 4212504941 | 3 years ago |
dependabot[bot] | ad531ef328 | 3 years ago |
bors[bot] | 0d7eb669e8 | 3 years ago |
dependabot[bot] | 302f433416 | 3 years ago |
bors[bot] | cd5d2353ae | 3 years ago |
dependabot[bot] | 59b7baee2d | 3 years ago |
bors[bot] | 64b44af18e | 3 years ago |
dependabot[bot] | 210913af48 | 3 years ago |
bors[bot] | 518b812551 | 3 years ago |
dependabot[bot] | 3818cb4f48 | 3 years ago |
bors[bot] | 04bbcb1fc9 | 3 years ago |
bors[bot] | 8c1ebd2cc2 | 3 years ago |
dependabot[bot] | c01e19f432 | 3 years ago |
bors[bot] | e032e30da6 | 3 years ago |
dependabot[bot] | 4a2bfbf9cf | 3 years ago |
bors[bot] | c0be41f9a0 | 3 years ago |
bors[bot] | cd5a1376d3 | 3 years ago |
Thomas Eizinger | 6c446825b7 | 3 years ago |
Thomas Eizinger | 1af0623c85 | 3 years ago |
bors[bot] | cdb2939746 | 3 years ago |
dependabot[bot] | f08a4d535f | 3 years ago |
COMIT Botty McBotface | 7126d77dc1 | 3 years ago |
bors[bot] | b2c377005b | 3 years ago |
Thomas Eizinger | 0296509110 | 3 years ago |
Thomas Eizinger | 475057abda | 3 years ago |
Thomas Eizinger | e4b5e28a93 | 3 years ago |
Thomas Eizinger | 148fdb8d0a | 3 years ago |
dependabot[bot] | 339b1e4758 | 3 years ago |
bors[bot] | 4405dbcf9c | 3 years ago |
bors[bot] | 956e710f48 | 3 years ago |
dependabot[bot] | 5cfd50f50c | 3 years ago |
Thomas Eizinger | 819feafc77 | 3 years ago |
bors[bot] | 52d5f2d83b | 3 years ago |
dependabot[bot] | 0174170afe | 3 years ago |
bors[bot] | 6ec5e34f80 | 3 years ago |
dependabot[bot] | 83c378db15 | 3 years ago |
dependabot[bot] | d25d7c7bbe | 3 years ago |
bors[bot] | d1f68f6672 | 3 years ago |
bors[bot] | 57bdd5020f | 3 years ago |
dependabot[bot] | f7ccfb96fd | 3 years ago |
dependabot[bot] | 634b1d3877 | 3 years ago |
dependabot[bot] | 8ea0877dc1 | 3 years ago |
bors[bot] | 723e94622b | 3 years ago |
Thomas Eizinger | 5c4aec6ae3 | 3 years ago |
bors[bot] | 21f4295e94 | 3 years ago |
COMIT Botty McBotface | 403e3d2b33 | 3 years ago |
bors[bot] | 9eb82cd0a9 | 3 years ago |
Daniel Karzel | 5e2e0f7dc0 | 3 years ago |
Daniel Karzel | e72922923a | 3 years ago |
Thomas Eizinger | d21bd556ec | 3 years ago |
bors[bot] | 3e3015a478 | 3 years ago |
dependabot[bot] | e43c9a8d6d | 3 years ago |
bors[bot] | 9fc53d3f84 | 3 years ago |
Daniel Karzel | 0dc3943d9c | 3 years ago |
bors[bot] | 6208689237 | 3 years ago |
bors[bot] | 238e52228e | 3 years ago |
bors[bot] | 00f581dee1 | 3 years ago |
Daniel Karzel | ab24f7bce5 | 3 years ago |
bors[bot] | b7a832eb7a | 3 years ago |
bors[bot] | 41982e0ff9 | 3 years ago |
Thomas Eizinger | 2c8bbe4913 | 3 years ago |
Thomas Eizinger | 94f089f4f2 | 3 years ago |
Thomas Eizinger | 367d75cab6 | 3 years ago |
Thomas Eizinger | 46ffc34f40 | 3 years ago |
Thomas Eizinger | 991dbf496e | 3 years ago |
Thomas Eizinger | 2eb7fab0c3 | 3 years ago |
Daniel Karzel | 6abf83f4ad | 3 years ago |
Thomas Eizinger | cacfc50fb2 | 3 years ago |
Thomas Eizinger | 56a48e71ef | 3 years ago |
Thomas Eizinger | 56ea23c2a3 | 3 years ago |
Thomas Eizinger | a347dd8b97 | 3 years ago |
bors[bot] | c275e33a6c | 3 years ago |
binarybaron | 357f4a0711 | 3 years ago |
bors[bot] | 7ff57ff0d4 | 3 years ago |
dependabot[bot] | e90ceb392e | 3 years ago |
bors[bot] | ab0f429eea | 3 years ago |
COMIT Botty McBotface | 50da958078 | 3 years ago |
bors[bot] | 668a41e6e2 | 3 years ago |
Thomas Eizinger | 714514edbc | 3 years ago |
bors[bot] | 93a69563a9 | 3 years ago |
Daniel Karzel | ffad47d515 | 3 years ago |
Thomas Eizinger | 5c37fe6733 | 3 years ago |
Thomas Eizinger | bbc3a49f41 | 3 years ago |
Thomas Eizinger | 987f8abb9d | 3 years ago |
Thomas Eizinger | 09f395a26b | 3 years ago |
Thomas Eizinger | 40eccd089f | 3 years ago |
Thomas Eizinger | 3b1789fe07 | 3 years ago |
Daniel Karzel | 91b0a0863b | 3 years ago |
bors[bot] | 15751f8a0e | 3 years ago |
Thomas Eizinger | 8f50eb2f34 | 3 years ago |
Thomas Eizinger | 9119ce5cc4 | 3 years ago |
Thomas Eizinger | 78480547d5 | 3 years ago |
bors[bot] | fa1a5e6efb | 3 years ago |
Thomas Eizinger | 1d0d38cd48 | 3 years ago |
bors[bot] | c8b29aecd1 | 3 years ago |
binarybaron | bdfa6e1f9f | 3 years ago |
bors[bot] | 3a99b753ed | 3 years ago |
Thomas Eizinger | acfd2dd6bb | 3 years ago |
Thomas Eizinger | 5463bde4f8 | 3 years ago |
Thomas Eizinger | 683d565679 | 3 years ago |
Thomas Eizinger | 8b59ac26ba | 3 years ago |
Daniel Karzel | 625ff4868a | 3 years ago |
Thomas Eizinger | 348fca0827 | 3 years ago |
Thomas Eizinger | e642f5c148 | 3 years ago |
rishflab | 93a0692998 | 3 years ago |
Daniel Karzel | ff10edd8a4 | 3 years ago |
Daniel Karzel | f45cde84ab | 3 years ago |
Thomas Eizinger | b4fafeba6b | 3 years ago |
Thomas Eizinger | e163942850 | 3 years ago |
Daniel Karzel | ff8cca2e27 | 3 years ago |
bors[bot] | 4cd27e372c | 3 years ago |
bors[bot] | 72673fa166 | 3 years ago |
Thomas Eizinger | d49f4ea60d | 3 years ago |
Thomas Eizinger | ec4234fbb9 | 3 years ago |
Thomas Eizinger | c2daf7a11e | 3 years ago |
bors[bot] | 962b648911 | 3 years ago |
bors[bot] | 206c98d71b | 3 years ago |
bors[bot] | f89bc701a5 | 3 years ago |
dependabot[bot] | dcd854e697 | 3 years ago |
dependabot[bot] | f33e50ab19 | 3 years ago |
dependabot[bot] | 35f481f4ae | 3 years ago |
dependabot[bot] | 15cf4ff638 | 3 years ago |
dependabot[bot] | 31be076fd1 | 3 years ago |
Thomas Eizinger | 8057b45e17 | 3 years ago |
Thomas Eizinger | 92ed8d9c04 | 3 years ago |
Thomas Eizinger | ec59184e85 | 3 years ago |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,6 @@
|
|||||||
|
# Documentation
|
||||||
|
|
||||||
|
This directory hosts various pieces of documentation.
|
||||||
|
|
||||||
|
- [`swap` CLI](./cli/README.md)
|
||||||
|
- [`asb` service](./asb/README.md)
|
@ -0,0 +1,139 @@
|
|||||||
|
# Swap CLI
|
||||||
|
|
||||||
|
The CLI defaults to **mainnet** (from version 0.6.0 onwards).
|
||||||
|
For testing and to familiarise yourself with the tool, we recommend you to try it on testnet first.
|
||||||
|
To do that, pass the `--testnet` flag with the actual command:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
swap --testnet <SUBCOMMAND>
|
||||||
|
```
|
||||||
|
|
||||||
|
The two main commands of the CLI are:
|
||||||
|
|
||||||
|
- `buy-xmr`: for swapping BTC to XMR with a particular seller
|
||||||
|
- `list-sellers`: for discovering available sellers through a rendezvous point
|
||||||
|
|
||||||
|
Running `swap --help` gives us roughly the following output:
|
||||||
|
|
||||||
|
```
|
||||||
|
swap 0.8.0
|
||||||
|
The COMIT guys <hello@comit.network>
|
||||||
|
CLI for swapping BTC for XMR
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
swap [FLAGS] [OPTIONS] <SUBCOMMAND>
|
||||||
|
|
||||||
|
FLAGS:
|
||||||
|
--debug Activate debug logging
|
||||||
|
-h, --help Prints help information
|
||||||
|
-j, --json Outputs all logs in JSON format instead of plain text
|
||||||
|
--testnet Swap on testnet and assume testnet defaults for data-dir and the blockchain related parameters
|
||||||
|
-V, --version Prints version information
|
||||||
|
|
||||||
|
OPTIONS:
|
||||||
|
--data-base-dir <data> The base data directory to be used for mainnet / testnet specific data like database, wallets etc
|
||||||
|
|
||||||
|
SUBCOMMANDS:
|
||||||
|
buy-xmr Start a BTC for XMR swap
|
||||||
|
list-sellers Discover and list sellers (i.e. ASB providers)
|
||||||
|
|
||||||
|
cancel Try to cancel an ongoing swap (expert users only)
|
||||||
|
help Prints this message or the help of the given subcommand(s)
|
||||||
|
history Show a list of past, ongoing and completed swaps
|
||||||
|
refund Try to cancel a swap and refund the BTC (expert users only)
|
||||||
|
resume Resume a swap
|
||||||
|
```
|
||||||
|
|
||||||
|
## Swapping BTC for XMR
|
||||||
|
|
||||||
|
Running `swap buy-xmr --help` gives us roughly the following output:
|
||||||
|
|
||||||
|
```
|
||||||
|
swap-buy-xmr 0.8.0
|
||||||
|
Start a BTC for XMR swap
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
swap buy-xmr [FLAGS] [OPTIONS] --change-address <bitcoin-change-address> --receive-address <monero-receive-address> --seller <seller>
|
||||||
|
|
||||||
|
FLAGS:
|
||||||
|
-h, --help Prints help information
|
||||||
|
--testnet Swap on testnet and assume testnet defaults for data-dir and the blockchain related parameters
|
||||||
|
-V, --version Prints version information
|
||||||
|
|
||||||
|
OPTIONS:
|
||||||
|
--change-address <bitcoin-change-address> The bitcoin address where any form of change or excess funds should be sent to
|
||||||
|
--receive-address <monero-receive-address> The monero address where you would like to receive monero
|
||||||
|
--seller <seller> The seller's address. Must include a peer ID part, i.e. `/p2p/`
|
||||||
|
|
||||||
|
--electrum-rpc <bitcoin-electrum-rpc-url> Provide the Bitcoin Electrum RPC URL
|
||||||
|
--bitcoin-target-block <bitcoin-target-block> Estimate Bitcoin fees such that transactions are confirmed within the specified number of blocks
|
||||||
|
--monero-daemon-address <monero-daemon-address> Specify to connect to a monero daemon of your choice: <host>:<port>
|
||||||
|
--tor-socks5-port <tor-socks5-port> Your local Tor socks5 proxy port [default: 9050]
|
||||||
|
```
|
||||||
|
|
||||||
|
This command has three core options:
|
||||||
|
|
||||||
|
- `--change-address`: A Bitcoin address you control. Will be used for refunds of any kind.
|
||||||
|
- `--receive-address`: A Monero address you control. This is where you will receive the Monero after the swap.
|
||||||
|
- `--seller`: The multiaddress of the seller you want to swap with.
|
||||||
|
|
||||||
|
## Discovering sellers
|
||||||
|
|
||||||
|
Running `swap list-sellers --help` gives us roughly the following output:
|
||||||
|
|
||||||
|
```
|
||||||
|
swap-list-sellers 0.8.0
|
||||||
|
Discover and list sellers (i.e. ASB providers)
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
swap list-sellers [FLAGS] [OPTIONS]
|
||||||
|
|
||||||
|
FLAGS:
|
||||||
|
-h, --help Prints help information
|
||||||
|
--testnet Swap on testnet and assume testnet defaults for data-dir and the blockchain related parameters
|
||||||
|
-V, --version Prints version information
|
||||||
|
|
||||||
|
OPTIONS:
|
||||||
|
--rendezvous-point <rendezvous-point> Address of the rendezvous point you want to use to discover ASBs
|
||||||
|
--tor-socks5-port <tor-socks5-port> Your local Tor socks5 proxy port [default: 9050]
|
||||||
|
```
|
||||||
|
|
||||||
|
Running `swap --testnet list-sellers --rendezvous-point /dnsaddr/rendezvous.coblox.tech/p2p/12D3KooWQUt9DkNZxEn2R5ymJzWj15MpG6mTW84kyd8vDaRZi46o` will give you something like:
|
||||||
|
|
||||||
|
```
|
||||||
|
Connected to rendezvous point, discovering nodes in 'xmr-btc-swap-testnet' namespace ...
|
||||||
|
Discovered peer 12D3KooWPZ69DRp4wbGB3wJsxxsg1XW1EVZ2evtVwcARCF3a1nrx at /dns4/ac4hgzmsmekwekjbdl77brufqqbylddugzze4tel6qsnlympgmr46iid.onion/tcp/8765
|
||||||
|
+----------------+----------------+----------------+--------+----------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
|
| PRICE | MIN_QUANTITY | MAX_QUANTITY | STATUS | ADDRESS |
|
||||||
|
+====================================================================================================================================================================================================+
|
||||||
|
| 0.00665754 BTC | 0.00010000 BTC | 0.00100000 BTC | Online | /dns4/ac4hgzmsmekwekjbdl77brufqqbylddugzze4tel6qsnlympgmr46iid.onion/tcp/8765/p2p/12D3KooWPZ69DRp4wbGB3wJsxxsg1XW1EVZ2evtVwcARCF3a1nrx |
|
||||||
|
+----------------+----------------+----------------+--------+----------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
or this if a node is not reachable:
|
||||||
|
|
||||||
|
```
|
||||||
|
Connected to rendezvous point, discovering nodes in 'xmr-btc-swap-testnet' namespace ...
|
||||||
|
Discovered peer 12D3KooWPZ69DRp4wbGB3wJsxxsg1XW1EVZ2evtVwcARCF3a1nrx at /dns4/ac4hgzmsmekwekjbdl77brufqqbylddugzze4tel6qsnlympgmr46iid.onion/tcp/8765
|
||||||
|
+-------+--------------+--------------+-------------+----------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
|
| PRICE | MIN_QUANTITY | MAX_QUANTITY | STATUS | ADDRESS |
|
||||||
|
+============================================================================================================================================================================================+
|
||||||
|
| ??? | ??? | ??? | Unreachable | /dns4/ac4hgzmsmekwekjbdl77brufqqbylddugzze4tel6qsnlympgmr46iid.onion/tcp/8765/p2p/12D3KooWPZ69DRp4wbGB3wJsxxsg1XW1EVZ2evtVwcARCF3a1nrx |
|
||||||
|
+-------+--------------+--------------+-------------+----------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Automating discover and swapping
|
||||||
|
|
||||||
|
The `buy-xmr` and `list-sellers` command have been designed to be composed.
|
||||||
|
[This script](./discover_and_take.sh) is example of what can be done.
|
||||||
|
Deciding on the seller to use is non-trivial to automate which is why it is not implemented as part of the tool.
|
||||||
|
|
||||||
|
## Tor
|
||||||
|
|
||||||
|
By default, the CLI will look for Tor at the default socks port `9050` and automatically route all traffic with a seller through Tor.
|
||||||
|
This allows swapping with sellers that are only reachable with an onion address.
|
||||||
|
|
||||||
|
Disclaimer:
|
||||||
|
Communication with public blockchain explorers (Electrum, public XMR nodes) currently goes through clearnet.
|
||||||
|
For complete anonymity it is recommended to run your own blockchain nodes.
|
||||||
|
Use `swap buy-xmr --help` to see configuration options.
|
@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# This is a utility script to showcase how the swap CLI can discover sellers and then trigger a swap using the discovered sellers
|
||||||
|
#
|
||||||
|
# 1st param: Path to the "swap" binary (aka the swap CLI)
|
||||||
|
# 2nd param: Multiaddress of the rendezvous node to be used for discovery
|
||||||
|
# 3rd param: Your Monero stagenet address where the XMR will be received
|
||||||
|
# 4th param: Your bech32 Bitcoin testnet address that will be used for any change output (e.g. refund scenario or when swapping an amount smaller than the transferred BTC)
|
||||||
|
#
|
||||||
|
# Example usage:
|
||||||
|
# discover_and_take.sh "PATH/TO/swap" "/dnsaddr/rendezvous.coblox.tech/p2p/12D3KooWQUt9DkNZxEn2R5ymJzWj15MpG6mTW84kyd8vDaRZi46o" "YOUR_XMR_STAGENET_ADDRESS" "YOUR_BECH32_BITCOIN_TESTNET_ADDRESS"
|
||||||
|
|
||||||
|
CLI_PATH=$1
|
||||||
|
RENDEZVOUS_POINT=$2
|
||||||
|
YOUR_MONERO_ADDR=$3
|
||||||
|
YOUR_BITCOIN_ADDR=$4
|
||||||
|
|
||||||
|
CLI_LIST_SELLERS="$CLI_PATH --testnet --json --debug list-sellers --rendezvous-point $RENDEZVOUS_POINT"
|
||||||
|
echo "Requesting sellers with command: $CLI_LIST_SELLERS"
|
||||||
|
echo
|
||||||
|
|
||||||
|
BEST_SELLER=$($CLI_LIST_SELLERS | jq -s -c 'min_by(.status .Online .price)' | jq -r '.multiaddr, (.status .Online .price), (.status .Online .min_quantity), (.status .Online .max_quantity)')
|
||||||
|
read ADDR PRICE MIN MAX < <(echo $BEST_SELLER)
|
||||||
|
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "Seller with best price:"
|
||||||
|
echo " multiaddr : $ADDR"
|
||||||
|
echo " price : $PRICE sat"
|
||||||
|
echo " min_quantity: $MIN sat"
|
||||||
|
echo " max_quantity: $MAX sat"
|
||||||
|
|
||||||
|
echo
|
||||||
|
|
||||||
|
CLI_SWAP="$CLI_PATH --testnet --debug buy-xmr --receive-address $YOUR_MONERO_ADDR --change-address $YOUR_BITCOIN_ADDR --seller $ADDR"
|
||||||
|
|
||||||
|
echo "Starting swap with best seller using command $CLI_SWAP"
|
||||||
|
echo
|
||||||
|
$CLI_SWAP
|
@ -0,0 +1,9 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use vergen::{vergen, Config, SemverKind};
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let mut config = Config::default();
|
||||||
|
*config.git_mut().semver_kind_mut() = SemverKind::Lightweight;
|
||||||
|
|
||||||
|
vergen(config)
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
# Seeds for failure cases proptest has generated in the past. It is
|
||||||
|
# automatically read and these particular cases re-run before any
|
||||||
|
# novel cases are generated.
|
||||||
|
#
|
||||||
|
# It is recommended to check this file in to source control so that
|
||||||
|
# everyone who runs the test benefits from these saved cases.
|
||||||
|
cc 849f8b01f49fc9a913100203698a9151d8de8a37564e1d3b1e3b4169e192f58a # shrinks to funding_amount = 290250686, num_utxos = 3, sats_per_vb = 75.35638, key = ExtendedPrivKey { network: Regtest, depth: 0, parent_fingerprint: 00000000, child_number: Normal { index: 0 }, private_key: [private key data], chain_code: 0b7a29ca6990bbc9b9187c1d1a07e2cf68e32f5ce55d2df01edf8a4ac2ee2a4b }, alice = Point<Normal,Public,NonZero>(0299a8c6a662e2e9e8ee7c6889b75a51c432812b4bf70c1d76eace63abc1bdfb1b), bob = Point<Normal,Public,NonZero>(027165b1f9924030c90d38c511da0f4397766078687997ed34d6ef2743d2a7bbed)
|
@ -1,116 +0,0 @@
|
|||||||
use crate::asb::event_loop::LatestRate;
|
|
||||||
use crate::env;
|
|
||||||
use crate::network::quote::BidQuote;
|
|
||||||
use crate::network::swap_setup::alice;
|
|
||||||
use crate::network::swap_setup::alice::WalletSnapshot;
|
|
||||||
use crate::network::{encrypted_signature, quote, transfer_proof};
|
|
||||||
use crate::protocol::alice::State3;
|
|
||||||
use anyhow::{anyhow, Error};
|
|
||||||
use libp2p::ping::{Ping, PingEvent};
|
|
||||||
use libp2p::request_response::{RequestId, ResponseChannel};
|
|
||||||
use libp2p::{NetworkBehaviour, PeerId};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum OutEvent {
|
|
||||||
SwapSetupInitiated {
|
|
||||||
send_wallet_snapshot: bmrng::RequestReceiver<bitcoin::Amount, WalletSnapshot>,
|
|
||||||
},
|
|
||||||
SwapSetupCompleted {
|
|
||||||
peer_id: PeerId,
|
|
||||||
swap_id: Uuid,
|
|
||||||
state3: Box<State3>,
|
|
||||||
},
|
|
||||||
SwapDeclined {
|
|
||||||
peer: PeerId,
|
|
||||||
error: alice::Error,
|
|
||||||
},
|
|
||||||
QuoteRequested {
|
|
||||||
channel: ResponseChannel<BidQuote>,
|
|
||||||
peer: PeerId,
|
|
||||||
},
|
|
||||||
TransferProofAcknowledged {
|
|
||||||
peer: PeerId,
|
|
||||||
id: RequestId,
|
|
||||||
},
|
|
||||||
EncryptedSignatureReceived {
|
|
||||||
msg: Box<encrypted_signature::Request>,
|
|
||||||
channel: ResponseChannel<()>,
|
|
||||||
peer: PeerId,
|
|
||||||
},
|
|
||||||
Failure {
|
|
||||||
peer: PeerId,
|
|
||||||
error: Error,
|
|
||||||
},
|
|
||||||
/// "Fallback" variant that allows the event mapping code to swallow certain
|
|
||||||
/// events that we don't want the caller to deal with.
|
|
||||||
Other,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OutEvent {
|
|
||||||
pub fn unexpected_request(peer: PeerId) -> OutEvent {
|
|
||||||
OutEvent::Failure {
|
|
||||||
peer,
|
|
||||||
error: anyhow!("Unexpected request received"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unexpected_response(peer: PeerId) -> OutEvent {
|
|
||||||
OutEvent::Failure {
|
|
||||||
peer,
|
|
||||||
error: anyhow!("Unexpected response received"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `NetworkBehaviour` that represents an XMR/BTC swap node as Alice.
|
|
||||||
#[derive(NetworkBehaviour)]
|
|
||||||
#[behaviour(out_event = "OutEvent", event_process = false)]
|
|
||||||
#[allow(missing_debug_implementations)]
|
|
||||||
pub struct Behaviour<LR>
|
|
||||||
where
|
|
||||||
LR: LatestRate + Send + 'static,
|
|
||||||
{
|
|
||||||
pub quote: quote::Behaviour,
|
|
||||||
pub swap_setup: alice::Behaviour<LR>,
|
|
||||||
pub transfer_proof: transfer_proof::Behaviour,
|
|
||||||
pub encrypted_signature: encrypted_signature::Behaviour,
|
|
||||||
|
|
||||||
/// Ping behaviour that ensures that the underlying network connection is
|
|
||||||
/// still alive. If the ping fails a connection close event will be
|
|
||||||
/// emitted that is picked up as swarm event.
|
|
||||||
ping: Ping,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<LR> Behaviour<LR>
|
|
||||||
where
|
|
||||||
LR: LatestRate + Send + 'static,
|
|
||||||
{
|
|
||||||
pub fn new(
|
|
||||||
min_buy: bitcoin::Amount,
|
|
||||||
max_buy: bitcoin::Amount,
|
|
||||||
latest_rate: LR,
|
|
||||||
resume_only: bool,
|
|
||||||
env_config: env::Config,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
quote: quote::asb(),
|
|
||||||
swap_setup: alice::Behaviour::new(
|
|
||||||
min_buy,
|
|
||||||
max_buy,
|
|
||||||
env_config,
|
|
||||||
latest_rate,
|
|
||||||
resume_only,
|
|
||||||
),
|
|
||||||
transfer_proof: transfer_proof::alice(),
|
|
||||||
encrypted_signature: encrypted_signature::alice(),
|
|
||||||
ping: Ping::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<PingEvent> for OutEvent {
|
|
||||||
fn from(_: PingEvent) -> Self {
|
|
||||||
OutEvent::Other
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,441 @@
|
|||||||
|
use crate::asb::event_loop::LatestRate;
|
||||||
|
use crate::env;
|
||||||
|
use crate::network::quote::BidQuote;
|
||||||
|
use crate::network::rendezvous::XmrBtcNamespace;
|
||||||
|
use crate::network::swap_setup::alice;
|
||||||
|
use crate::network::swap_setup::alice::WalletSnapshot;
|
||||||
|
use crate::network::transport::authenticate_and_multiplex;
|
||||||
|
use crate::network::{encrypted_signature, quote, transfer_proof};
|
||||||
|
use crate::protocol::alice::State3;
|
||||||
|
use anyhow::{anyhow, Error, Result};
|
||||||
|
use futures::FutureExt;
|
||||||
|
use libp2p::core::connection::ConnectionId;
|
||||||
|
use libp2p::core::muxing::StreamMuxerBox;
|
||||||
|
use libp2p::core::transport::Boxed;
|
||||||
|
use libp2p::dns::TokioDnsConfig;
|
||||||
|
use libp2p::ping::{Ping, PingConfig, PingEvent};
|
||||||
|
use libp2p::request_response::{RequestId, ResponseChannel};
|
||||||
|
use libp2p::swarm::{
|
||||||
|
DialPeerCondition, IntoProtocolsHandler, NetworkBehaviour, NetworkBehaviourAction,
|
||||||
|
PollParameters, ProtocolsHandler,
|
||||||
|
};
|
||||||
|
use libp2p::tcp::TokioTcpConfig;
|
||||||
|
use libp2p::websocket::WsConfig;
|
||||||
|
use libp2p::{identity, Multiaddr, NetworkBehaviour, PeerId, Transport};
|
||||||
|
use std::task::Poll;
|
||||||
|
use std::time::Duration;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub mod transport {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Creates the libp2p transport for the ASB.
|
||||||
|
pub fn new(identity: &identity::Keypair) -> Result<Boxed<(PeerId, StreamMuxerBox)>> {
|
||||||
|
let tcp = TokioTcpConfig::new().nodelay(true);
|
||||||
|
let tcp_with_dns = TokioDnsConfig::system(tcp)?;
|
||||||
|
let websocket_with_dns = WsConfig::new(tcp_with_dns.clone());
|
||||||
|
|
||||||
|
let transport = tcp_with_dns.or_transport(websocket_with_dns).boxed();
|
||||||
|
|
||||||
|
authenticate_and_multiplex(transport, identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod behaviour {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum OutEvent {
|
||||||
|
SwapSetupInitiated {
|
||||||
|
send_wallet_snapshot: bmrng::RequestReceiver<bitcoin::Amount, WalletSnapshot>,
|
||||||
|
},
|
||||||
|
SwapSetupCompleted {
|
||||||
|
peer_id: PeerId,
|
||||||
|
swap_id: Uuid,
|
||||||
|
state3: State3,
|
||||||
|
},
|
||||||
|
SwapDeclined {
|
||||||
|
peer: PeerId,
|
||||||
|
error: alice::Error,
|
||||||
|
},
|
||||||
|
QuoteRequested {
|
||||||
|
channel: ResponseChannel<BidQuote>,
|
||||||
|
peer: PeerId,
|
||||||
|
},
|
||||||
|
TransferProofAcknowledged {
|
||||||
|
peer: PeerId,
|
||||||
|
id: RequestId,
|
||||||
|
},
|
||||||
|
EncryptedSignatureReceived {
|
||||||
|
msg: encrypted_signature::Request,
|
||||||
|
channel: ResponseChannel<()>,
|
||||||
|
peer: PeerId,
|
||||||
|
},
|
||||||
|
Rendezvous(libp2p::rendezvous::Event),
|
||||||
|
Failure {
|
||||||
|
peer: PeerId,
|
||||||
|
error: Error,
|
||||||
|
},
|
||||||
|
/// "Fallback" variant that allows the event mapping code to swallow
|
||||||
|
/// certain events that we don't want the caller to deal with.
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutEvent {
|
||||||
|
pub fn unexpected_request(peer: PeerId) -> OutEvent {
|
||||||
|
OutEvent::Failure {
|
||||||
|
peer,
|
||||||
|
error: anyhow!("Unexpected request received"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unexpected_response(peer: PeerId) -> OutEvent {
|
||||||
|
OutEvent::Failure {
|
||||||
|
peer,
|
||||||
|
error: anyhow!("Unexpected response received"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `NetworkBehaviour` that represents an XMR/BTC swap node as Alice.
|
||||||
|
#[derive(NetworkBehaviour)]
|
||||||
|
#[behaviour(out_event = "OutEvent", event_process = false)]
|
||||||
|
#[allow(missing_debug_implementations)]
|
||||||
|
pub struct Behaviour<LR>
|
||||||
|
where
|
||||||
|
LR: LatestRate + Send + 'static,
|
||||||
|
{
|
||||||
|
pub rendezvous: libp2p::swarm::toggle::Toggle<rendezous::Behaviour>,
|
||||||
|
pub quote: quote::Behaviour,
|
||||||
|
pub swap_setup: alice::Behaviour<LR>,
|
||||||
|
pub transfer_proof: transfer_proof::Behaviour,
|
||||||
|
pub encrypted_signature: encrypted_signature::Behaviour,
|
||||||
|
|
||||||
|
/// Ping behaviour that ensures that the underlying network connection
|
||||||
|
/// is still alive. If the ping fails a connection close event
|
||||||
|
/// will be emitted that is picked up as swarm event.
|
||||||
|
ping: Ping,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<LR> Behaviour<LR>
|
||||||
|
where
|
||||||
|
LR: LatestRate + Send + 'static,
|
||||||
|
{
|
||||||
|
pub fn new(
|
||||||
|
min_buy: bitcoin::Amount,
|
||||||
|
max_buy: bitcoin::Amount,
|
||||||
|
latest_rate: LR,
|
||||||
|
resume_only: bool,
|
||||||
|
env_config: env::Config,
|
||||||
|
rendezvous_params: Option<(identity::Keypair, PeerId, Multiaddr, XmrBtcNamespace)>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
rendezvous: libp2p::swarm::toggle::Toggle::from(rendezvous_params.map(
|
||||||
|
|(identity, rendezvous_peer_id, rendezvous_address, namespace)| {
|
||||||
|
rendezous::Behaviour::new(
|
||||||
|
identity,
|
||||||
|
rendezvous_peer_id,
|
||||||
|
rendezvous_address,
|
||||||
|
namespace,
|
||||||
|
None, // use default ttl on rendezvous point
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
quote: quote::asb(),
|
||||||
|
swap_setup: alice::Behaviour::new(
|
||||||
|
min_buy,
|
||||||
|
max_buy,
|
||||||
|
env_config,
|
||||||
|
latest_rate,
|
||||||
|
resume_only,
|
||||||
|
),
|
||||||
|
transfer_proof: transfer_proof::alice(),
|
||||||
|
encrypted_signature: encrypted_signature::alice(),
|
||||||
|
ping: Ping::new(PingConfig::new().with_keep_alive(true)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PingEvent> for OutEvent {
|
||||||
|
fn from(_: PingEvent) -> Self {
|
||||||
|
OutEvent::Other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<libp2p::rendezvous::Event> for OutEvent {
|
||||||
|
fn from(event: libp2p::rendezvous::Event) -> Self {
|
||||||
|
OutEvent::Rendezvous(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod rendezous {
|
||||||
|
use super::*;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
#[derive(PartialEq)]
|
||||||
|
enum ConnectionStatus {
|
||||||
|
Disconnected,
|
||||||
|
Dialling,
|
||||||
|
Connected,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RegistrationStatus {
|
||||||
|
RegisterOnNextConnection,
|
||||||
|
Pending,
|
||||||
|
Registered {
|
||||||
|
re_register_in: Pin<Box<tokio::time::Sleep>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Behaviour {
|
||||||
|
inner: libp2p::rendezvous::Rendezvous,
|
||||||
|
rendezvous_point: Multiaddr,
|
||||||
|
rendezvous_peer_id: PeerId,
|
||||||
|
namespace: XmrBtcNamespace,
|
||||||
|
registration_status: RegistrationStatus,
|
||||||
|
connection_status: ConnectionStatus,
|
||||||
|
registration_ttl: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Behaviour {
|
||||||
|
pub fn new(
|
||||||
|
identity: identity::Keypair,
|
||||||
|
rendezvous_peer_id: PeerId,
|
||||||
|
rendezvous_address: Multiaddr,
|
||||||
|
namespace: XmrBtcNamespace,
|
||||||
|
registration_ttl: Option<u64>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: libp2p::rendezvous::Rendezvous::new(
|
||||||
|
identity,
|
||||||
|
libp2p::rendezvous::Config::default(),
|
||||||
|
),
|
||||||
|
rendezvous_point: rendezvous_address,
|
||||||
|
rendezvous_peer_id,
|
||||||
|
namespace,
|
||||||
|
registration_status: RegistrationStatus::RegisterOnNextConnection,
|
||||||
|
connection_status: ConnectionStatus::Disconnected,
|
||||||
|
registration_ttl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register(&mut self) {
|
||||||
|
self.inner.register(
|
||||||
|
self.namespace.into(),
|
||||||
|
self.rendezvous_peer_id,
|
||||||
|
self.registration_ttl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetworkBehaviour for Behaviour {
|
||||||
|
type ProtocolsHandler =
|
||||||
|
<libp2p::rendezvous::Rendezvous as NetworkBehaviour>::ProtocolsHandler;
|
||||||
|
type OutEvent = libp2p::rendezvous::Event;
|
||||||
|
|
||||||
|
fn new_handler(&mut self) -> Self::ProtocolsHandler {
|
||||||
|
self.inner.new_handler()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn addresses_of_peer(&mut self, peer_id: &PeerId) -> Vec<Multiaddr> {
|
||||||
|
if peer_id == &self.rendezvous_peer_id {
|
||||||
|
return vec![self.rendezvous_point.clone()];
|
||||||
|
}
|
||||||
|
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_connected(&mut self, peer_id: &PeerId) {
|
||||||
|
if peer_id == &self.rendezvous_peer_id {
|
||||||
|
self.connection_status = ConnectionStatus::Connected;
|
||||||
|
|
||||||
|
match &self.registration_status {
|
||||||
|
RegistrationStatus::RegisterOnNextConnection => {
|
||||||
|
self.register();
|
||||||
|
self.registration_status = RegistrationStatus::Pending;
|
||||||
|
}
|
||||||
|
RegistrationStatus::Registered { .. } => {}
|
||||||
|
RegistrationStatus::Pending => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_disconnected(&mut self, peer_id: &PeerId) {
|
||||||
|
if peer_id == &self.rendezvous_peer_id {
|
||||||
|
self.connection_status = ConnectionStatus::Disconnected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_event(
|
||||||
|
&mut self,
|
||||||
|
peer_id: PeerId,
|
||||||
|
connection: ConnectionId,
|
||||||
|
event: <<Self::ProtocolsHandler as IntoProtocolsHandler>::Handler as ProtocolsHandler>::OutEvent,
|
||||||
|
) {
|
||||||
|
self.inner.inject_event(peer_id, connection, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_dial_failure(&mut self, peer_id: &PeerId) {
|
||||||
|
if peer_id == &self.rendezvous_peer_id {
|
||||||
|
self.connection_status = ConnectionStatus::Disconnected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn poll(&mut self, cx: &mut std::task::Context<'_>, params: &mut impl PollParameters) -> Poll<NetworkBehaviourAction<<<Self::ProtocolsHandler as IntoProtocolsHandler>::Handler as ProtocolsHandler>::InEvent, Self::OutEvent>>{
|
||||||
|
match &mut self.registration_status {
|
||||||
|
RegistrationStatus::RegisterOnNextConnection => match self.connection_status {
|
||||||
|
ConnectionStatus::Disconnected => {
|
||||||
|
self.connection_status = ConnectionStatus::Dialling;
|
||||||
|
|
||||||
|
return Poll::Ready(NetworkBehaviourAction::DialPeer {
|
||||||
|
peer_id: self.rendezvous_peer_id,
|
||||||
|
condition: DialPeerCondition::Disconnected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ConnectionStatus::Dialling => {}
|
||||||
|
ConnectionStatus::Connected => {
|
||||||
|
self.registration_status = RegistrationStatus::Pending;
|
||||||
|
self.register();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RegistrationStatus::Registered { re_register_in } => {
|
||||||
|
if let Poll::Ready(()) = re_register_in.poll_unpin(cx) {
|
||||||
|
match self.connection_status {
|
||||||
|
ConnectionStatus::Connected => {
|
||||||
|
self.registration_status = RegistrationStatus::Pending;
|
||||||
|
self.register();
|
||||||
|
}
|
||||||
|
ConnectionStatus::Disconnected => {
|
||||||
|
self.registration_status =
|
||||||
|
RegistrationStatus::RegisterOnNextConnection;
|
||||||
|
|
||||||
|
return Poll::Ready(NetworkBehaviourAction::DialPeer {
|
||||||
|
peer_id: self.rendezvous_peer_id,
|
||||||
|
condition: DialPeerCondition::Disconnected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ConnectionStatus::Dialling => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RegistrationStatus::Pending => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let inner_poll = self.inner.poll(cx, params);
|
||||||
|
|
||||||
|
// reset the timer if we successfully registered
|
||||||
|
if let Poll::Ready(NetworkBehaviourAction::GenerateEvent(
|
||||||
|
libp2p::rendezvous::Event::Registered { ttl, .. },
|
||||||
|
)) = &inner_poll
|
||||||
|
{
|
||||||
|
let half_of_ttl = Duration::from_secs(*ttl) / 2;
|
||||||
|
|
||||||
|
self.registration_status = RegistrationStatus::Registered {
|
||||||
|
re_register_in: Box::pin(tokio::time::sleep(half_of_ttl)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
inner_poll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::network::test::{new_swarm, SwarmExt};
|
||||||
|
use futures::StreamExt;
|
||||||
|
use libp2p::swarm::SwarmEvent;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn given_no_initial_connection_when_constructed_asb_connects_and_registers_with_rendezvous_node(
|
||||||
|
) {
|
||||||
|
let mut rendezvous_node = new_swarm(|_, identity| {
|
||||||
|
libp2p::rendezvous::Rendezvous::new(identity, libp2p::rendezvous::Config::default())
|
||||||
|
});
|
||||||
|
let rendezvous_address = rendezvous_node.listen_on_random_memory_address().await;
|
||||||
|
|
||||||
|
let mut asb = new_swarm(|_, identity| {
|
||||||
|
rendezous::Behaviour::new(
|
||||||
|
identity,
|
||||||
|
*rendezvous_node.local_peer_id(),
|
||||||
|
rendezvous_address,
|
||||||
|
XmrBtcNamespace::Testnet,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
asb.listen_on_random_memory_address().await; // this adds an external address
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
rendezvous_node.next().await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let asb_registered = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
if let SwarmEvent::Behaviour(libp2p::rendezvous::Event::Registered { .. }) =
|
||||||
|
asb.select_next_some().await
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), asb_registered)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn asb_automatically_re_registers() {
|
||||||
|
let min_ttl = 5;
|
||||||
|
let mut rendezvous_node = new_swarm(|_, identity| {
|
||||||
|
libp2p::rendezvous::Rendezvous::new(
|
||||||
|
identity,
|
||||||
|
libp2p::rendezvous::Config::default().with_min_ttl(min_ttl),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let rendezvous_address = rendezvous_node.listen_on_random_memory_address().await;
|
||||||
|
|
||||||
|
let mut asb = new_swarm(|_, identity| {
|
||||||
|
rendezous::Behaviour::new(
|
||||||
|
identity,
|
||||||
|
*rendezvous_node.local_peer_id(),
|
||||||
|
rendezvous_address,
|
||||||
|
XmrBtcNamespace::Testnet,
|
||||||
|
Some(5),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
asb.listen_on_random_memory_address().await; // this adds an external address
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
rendezvous_node.next().await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let asb_registered_three_times = tokio::spawn(async move {
|
||||||
|
let mut number_of_registrations = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let SwarmEvent::Behaviour(libp2p::rendezvous::Event::Registered { .. }) =
|
||||||
|
asb.select_next_some().await
|
||||||
|
{
|
||||||
|
number_of_registrations += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if number_of_registrations == 3 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), asb_registered_three_times)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,19 +0,0 @@
|
|||||||
use crate::network::transport::authenticate_and_multiplex;
|
|
||||||
use anyhow::Result;
|
|
||||||
use libp2p::core::muxing::StreamMuxerBox;
|
|
||||||
use libp2p::core::transport::Boxed;
|
|
||||||
use libp2p::dns::TokioDnsConfig;
|
|
||||||
use libp2p::tcp::TokioTcpConfig;
|
|
||||||
use libp2p::websocket::WsConfig;
|
|
||||||
use libp2p::{identity, PeerId, Transport};
|
|
||||||
|
|
||||||
/// Creates the libp2p transport for the ASB.
|
|
||||||
pub fn new(identity: &identity::Keypair) -> Result<Boxed<(PeerId, StreamMuxerBox)>> {
|
|
||||||
let tcp = TokioTcpConfig::new().nodelay(true);
|
|
||||||
let tcp_with_dns = TokioDnsConfig::system(tcp)?;
|
|
||||||
let websocket_with_dns = WsConfig::new(tcp_with_dns.clone());
|
|
||||||
|
|
||||||
let transport = tcp_with_dns.or_transport(websocket_with_dns).boxed();
|
|
||||||
|
|
||||||
authenticate_and_multiplex(transport, identity)
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,365 @@
|
|||||||
|
use crate::network::quote::BidQuote;
|
||||||
|
use crate::network::rendezvous::XmrBtcNamespace;
|
||||||
|
use crate::network::{quote, swarm};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use futures::StreamExt;
|
||||||
|
use libp2p::multiaddr::Protocol;
|
||||||
|
use libp2p::ping::{Ping, PingConfig, PingEvent};
|
||||||
|
use libp2p::rendezvous::{Namespace, Rendezvous};
|
||||||
|
use libp2p::request_response::{RequestResponseEvent, RequestResponseMessage};
|
||||||
|
use libp2p::swarm::SwarmEvent;
|
||||||
|
use libp2p::{identity, rendezvous, Multiaddr, PeerId, Swarm};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_with::{serde_as, DisplayFromStr};
|
||||||
|
use std::collections::hash_map::Entry;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Returns sorted list of sellers, with [Online](Status::Online) listed first.
|
||||||
|
///
|
||||||
|
/// First uses the rendezvous node to discover peers in the given namespace,
|
||||||
|
/// then fetches a quote from each peer that was discovered. If fetching a quote
|
||||||
|
/// from a discovered peer fails the seller's status will be
|
||||||
|
/// [Unreachable](Status::Unreachable).
|
||||||
|
pub async fn list_sellers(
|
||||||
|
rendezvous_node_peer_id: PeerId,
|
||||||
|
rendezvous_node_addr: Multiaddr,
|
||||||
|
namespace: XmrBtcNamespace,
|
||||||
|
tor_socks5_port: u16,
|
||||||
|
identity: identity::Keypair,
|
||||||
|
) -> Result<Vec<Seller>> {
|
||||||
|
let behaviour = Behaviour {
|
||||||
|
rendezvous: Rendezvous::new(identity.clone(), rendezvous::Config::default()),
|
||||||
|
quote: quote::cli(),
|
||||||
|
ping: Ping::new(
|
||||||
|
PingConfig::new()
|
||||||
|
.with_keep_alive(false)
|
||||||
|
.with_interval(Duration::from_secs(86_400)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let mut swarm = swarm::cli(identity, tor_socks5_port, behaviour).await?;
|
||||||
|
|
||||||
|
swarm
|
||||||
|
.behaviour_mut()
|
||||||
|
.quote
|
||||||
|
.add_address(&rendezvous_node_peer_id, rendezvous_node_addr.clone());
|
||||||
|
swarm
|
||||||
|
.dial(&rendezvous_node_peer_id)
|
||||||
|
.context("Failed to dial rendezvous node")?;
|
||||||
|
|
||||||
|
let event_loop = EventLoop::new(
|
||||||
|
swarm,
|
||||||
|
rendezvous_node_peer_id,
|
||||||
|
rendezvous_node_addr,
|
||||||
|
namespace,
|
||||||
|
);
|
||||||
|
let sellers = event_loop.run().await;
|
||||||
|
|
||||||
|
Ok(sellers)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Debug, Serialize, PartialEq, Eq, Hash, Ord, PartialOrd)]
|
||||||
|
pub struct Seller {
|
||||||
|
pub status: Status,
|
||||||
|
#[serde_as(as = "DisplayFromStr")]
|
||||||
|
pub multiaddr: Multiaddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, PartialEq, Eq, Hash, Copy, Clone, Ord, PartialOrd)]
|
||||||
|
pub enum Status {
|
||||||
|
Online(BidQuote),
|
||||||
|
Unreachable,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum OutEvent {
|
||||||
|
Rendezvous(rendezvous::Event),
|
||||||
|
Quote(quote::OutEvent),
|
||||||
|
Ping(PingEvent),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<rendezvous::Event> for OutEvent {
|
||||||
|
fn from(event: rendezvous::Event) -> Self {
|
||||||
|
OutEvent::Rendezvous(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<quote::OutEvent> for OutEvent {
|
||||||
|
fn from(event: quote::OutEvent) -> Self {
|
||||||
|
OutEvent::Quote(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(libp2p::NetworkBehaviour)]
|
||||||
|
#[behaviour(event_process = false)]
|
||||||
|
#[behaviour(out_event = "OutEvent")]
|
||||||
|
struct Behaviour {
|
||||||
|
rendezvous: Rendezvous,
|
||||||
|
quote: quote::Behaviour,
|
||||||
|
ping: Ping,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum QuoteStatus {
|
||||||
|
Pending,
|
||||||
|
Received(Status),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum State {
|
||||||
|
WaitForDiscovery,
|
||||||
|
WaitForQuoteCompletion,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EventLoop {
|
||||||
|
swarm: Swarm<Behaviour>,
|
||||||
|
rendezvous_peer_id: PeerId,
|
||||||
|
rendezvous_addr: Multiaddr,
|
||||||
|
namespace: XmrBtcNamespace,
|
||||||
|
reachable_asb_address: HashMap<PeerId, Multiaddr>,
|
||||||
|
unreachable_asb_address: HashMap<PeerId, Multiaddr>,
|
||||||
|
asb_quote_status: HashMap<PeerId, QuoteStatus>,
|
||||||
|
state: State,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventLoop {
|
||||||
|
fn new(
|
||||||
|
swarm: Swarm<Behaviour>,
|
||||||
|
rendezvous_peer_id: PeerId,
|
||||||
|
rendezvous_addr: Multiaddr,
|
||||||
|
namespace: XmrBtcNamespace,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
swarm,
|
||||||
|
rendezvous_peer_id,
|
||||||
|
rendezvous_addr,
|
||||||
|
namespace,
|
||||||
|
reachable_asb_address: Default::default(),
|
||||||
|
unreachable_asb_address: Default::default(),
|
||||||
|
asb_quote_status: Default::default(),
|
||||||
|
state: State::WaitForDiscovery,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(mut self) -> Vec<Seller> {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
swarm_event = self.swarm.select_next_some() => {
|
||||||
|
match swarm_event {
|
||||||
|
SwarmEvent::ConnectionEstablished { peer_id, endpoint, .. } => {
|
||||||
|
if peer_id == self.rendezvous_peer_id{
|
||||||
|
tracing::info!(
|
||||||
|
"Connected to rendezvous point, discovering nodes in '{}' namespace ...",
|
||||||
|
self.namespace
|
||||||
|
);
|
||||||
|
|
||||||
|
self.swarm.behaviour_mut().rendezvous.discover(
|
||||||
|
Some(Namespace::new(self.namespace.to_string()).expect("our namespace to be a correct string")),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
self.rendezvous_peer_id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let address = endpoint.get_remote_address();
|
||||||
|
self.reachable_asb_address.insert(peer_id, address.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SwarmEvent::UnreachableAddr { peer_id, error, address, .. } => {
|
||||||
|
if address == self.rendezvous_addr {
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to connect to rendezvous point at {}: {}",
|
||||||
|
address,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
// if the rendezvous node is unreachable we just stop
|
||||||
|
return Vec::new();
|
||||||
|
} else {
|
||||||
|
tracing::debug!(
|
||||||
|
"Failed to connect to peer at {}: {}",
|
||||||
|
address,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
self.unreachable_asb_address.insert(peer_id, address.clone());
|
||||||
|
|
||||||
|
match self.asb_quote_status.entry(peer_id) {
|
||||||
|
Entry::Occupied(mut entry) => {
|
||||||
|
entry.insert(QuoteStatus::Received(Status::Unreachable));
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
tracing::debug!(%peer_id, %error, "Connection error with unexpected peer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SwarmEvent::Behaviour(OutEvent::Rendezvous(
|
||||||
|
rendezvous::Event::Discovered { registrations, .. },
|
||||||
|
)) => {
|
||||||
|
self.state = State::WaitForQuoteCompletion;
|
||||||
|
|
||||||
|
for registration in registrations {
|
||||||
|
let peer = registration.record.peer_id();
|
||||||
|
for address in registration.record.addresses() {
|
||||||
|
tracing::info!("Discovered peer {} at {}", peer, address);
|
||||||
|
|
||||||
|
let p2p_suffix = Protocol::P2p(*peer.as_ref());
|
||||||
|
let _address_with_p2p = if !address
|
||||||
|
.ends_with(&Multiaddr::empty().with(p2p_suffix.clone()))
|
||||||
|
{
|
||||||
|
address.clone().with(p2p_suffix)
|
||||||
|
} else {
|
||||||
|
address.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
self.asb_quote_status.insert(peer, QuoteStatus::Pending);
|
||||||
|
|
||||||
|
// add all external addresses of that peer to the quote behaviour
|
||||||
|
self.swarm.behaviour_mut().quote.add_address(&peer, address.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// request the quote, if we are not connected to the peer it will be dialed automatically
|
||||||
|
let _request_id = self.swarm.behaviour_mut().quote.send_request(&peer, ());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SwarmEvent::Behaviour(OutEvent::Quote(quote_response)) => {
|
||||||
|
match quote_response {
|
||||||
|
RequestResponseEvent::Message { peer, message } => {
|
||||||
|
match message {
|
||||||
|
RequestResponseMessage::Response { response, .. } => {
|
||||||
|
if self.asb_quote_status.insert(peer, QuoteStatus::Received(Status::Online(response))).is_none() {
|
||||||
|
tracing::error!(%peer, "Received bid quote from unexpected peer, this record will be removed!");
|
||||||
|
self.asb_quote_status.remove(&peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RequestResponseMessage::Request { .. } => unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RequestResponseEvent::OutboundFailure { peer, error, .. } => {
|
||||||
|
if peer == self.rendezvous_peer_id {
|
||||||
|
tracing::debug!(%peer, "Outbound failure when communicating with rendezvous node: {:#}", error);
|
||||||
|
} else {
|
||||||
|
tracing::debug!(%peer, "Ignoring seller, because unable to request quote: {:#}", error);
|
||||||
|
self.asb_quote_status.remove(&peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RequestResponseEvent::InboundFailure { peer, error, .. } => {
|
||||||
|
if peer == self.rendezvous_peer_id {
|
||||||
|
tracing::debug!(%peer, "Inbound failure when communicating with rendezvous node: {:#}", error);
|
||||||
|
} else {
|
||||||
|
tracing::debug!(%peer, "Ignoring seller, because unable to request quote: {:#}", error);
|
||||||
|
self.asb_quote_status.remove(&peer);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RequestResponseEvent::ResponseSent { .. } => unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.state {
|
||||||
|
State::WaitForDiscovery => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
State::WaitForQuoteCompletion => {
|
||||||
|
let all_quotes_fetched = self
|
||||||
|
.asb_quote_status
|
||||||
|
.iter()
|
||||||
|
.map(|(peer_id, quote_status)| match quote_status {
|
||||||
|
QuoteStatus::Pending => Err(StillPending {}),
|
||||||
|
QuoteStatus::Received(Status::Online(quote)) => {
|
||||||
|
let address = self
|
||||||
|
.reachable_asb_address
|
||||||
|
.get(&peer_id)
|
||||||
|
.expect("if we got a quote we must have stored an address");
|
||||||
|
|
||||||
|
Ok(Seller {
|
||||||
|
multiaddr: address.clone(),
|
||||||
|
status: Status::Online(*quote),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
QuoteStatus::Received(Status::Unreachable) => {
|
||||||
|
let address = self
|
||||||
|
.unreachable_asb_address
|
||||||
|
.get(&peer_id)
|
||||||
|
.expect("if we got a quote we must have stored an address");
|
||||||
|
|
||||||
|
Ok(Seller {
|
||||||
|
multiaddr: address.clone(),
|
||||||
|
status: Status::Unreachable,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>();
|
||||||
|
|
||||||
|
match all_quotes_fetched {
|
||||||
|
Ok(mut sellers) => {
|
||||||
|
sellers.sort();
|
||||||
|
break sellers;
|
||||||
|
}
|
||||||
|
Err(StillPending {}) => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct StillPending {}
|
||||||
|
|
||||||
|
impl From<PingEvent> for OutEvent {
|
||||||
|
fn from(event: PingEvent) -> Self {
|
||||||
|
OutEvent::Ping(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sellers_sort_with_unreachable_coming_last() {
|
||||||
|
let mut list = vec![
|
||||||
|
Seller {
|
||||||
|
multiaddr: "/ip4/127.0.0.1/tcp/1234".parse().unwrap(),
|
||||||
|
status: Status::Unreachable,
|
||||||
|
},
|
||||||
|
Seller {
|
||||||
|
multiaddr: Multiaddr::empty(),
|
||||||
|
status: Status::Unreachable,
|
||||||
|
},
|
||||||
|
Seller {
|
||||||
|
multiaddr: "/ip4/127.0.0.1/tcp/5678".parse().unwrap(),
|
||||||
|
status: Status::Online(BidQuote {
|
||||||
|
price: Default::default(),
|
||||||
|
min_quantity: Default::default(),
|
||||||
|
max_quantity: Default::default(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
list.sort();
|
||||||
|
|
||||||
|
assert_eq!(list, vec![
|
||||||
|
Seller {
|
||||||
|
multiaddr: "/ip4/127.0.0.1/tcp/5678".parse().unwrap(),
|
||||||
|
status: Status::Online(BidQuote {
|
||||||
|
price: Default::default(),
|
||||||
|
min_quantity: Default::default(),
|
||||||
|
max_quantity: Default::default(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
Seller {
|
||||||
|
multiaddr: Multiaddr::empty(),
|
||||||
|
status: Status::Unreachable
|
||||||
|
},
|
||||||
|
Seller {
|
||||||
|
multiaddr: "/ip4/127.0.0.1/tcp/1234".parse().unwrap(),
|
||||||
|
status: Status::Unreachable
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
use libp2p::multiaddr::Protocol;
|
||||||
|
use libp2p::{Multiaddr, PeerId};
|
||||||
|
|
||||||
|
pub trait MultiAddrExt {
|
||||||
|
fn extract_peer_id(&self) -> Option<PeerId>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MultiAddrExt for Multiaddr {
|
||||||
|
fn extract_peer_id(&self) -> Option<PeerId> {
|
||||||
|
match self.iter().last()? {
|
||||||
|
Protocol::P2p(multihash) => PeerId::from_multihash(multihash).ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1 +0,0 @@
|
|||||||
|
|
@ -1 +0,0 @@
|
|||||||
|
|
@ -0,0 +1,29 @@
|
|||||||
|
use libp2p::rendezvous::Namespace;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||||
|
pub enum XmrBtcNamespace {
|
||||||
|
Mainnet,
|
||||||
|
Testnet,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAINNET: &str = "xmr-btc-swap-mainnet";
|
||||||
|
const TESTNET: &str = "xmr-btc-swap-testnet";
|
||||||
|
|
||||||
|
impl fmt::Display for XmrBtcNamespace {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
XmrBtcNamespace::Mainnet => write!(f, "{}", MAINNET),
|
||||||
|
XmrBtcNamespace::Testnet => write!(f, "{}", TESTNET),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<XmrBtcNamespace> for Namespace {
|
||||||
|
fn from(namespace: XmrBtcNamespace) -> Self {
|
||||||
|
match namespace {
|
||||||
|
XmrBtcNamespace::Mainnet => Namespace::from_static(MAINNET),
|
||||||
|
XmrBtcNamespace::Testnet => Namespace::from_static(TESTNET),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
pub mod ecdsa_fun {
|
||||||
|
use super::*;
|
||||||
|
use ::ecdsa_fun::fun::marker::{Mark, NonZero, Normal};
|
||||||
|
use ::ecdsa_fun::fun::{Point, Scalar, G};
|
||||||
|
|
||||||
|
pub fn point() -> impl Strategy<Value = Point> {
|
||||||
|
scalar().prop_map(|mut scalar| Point::from_scalar_mul(&G, &mut scalar).mark::<Normal>())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scalar() -> impl Strategy<Value = Scalar> {
|
||||||
|
prop::array::uniform32(0..255u8).prop_filter_map("generated the 0 element", |bytes| {
|
||||||
|
Scalar::from_bytes_mod_order(bytes).mark::<NonZero>()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod bitcoin {
|
||||||
|
use super::*;
|
||||||
|
use ::bitcoin::util::bip32::ExtendedPrivKey;
|
||||||
|
use ::bitcoin::Network;
|
||||||
|
|
||||||
|
pub fn extended_priv_key() -> impl Strategy<Value = ExtendedPrivKey> {
|
||||||
|
prop::array::uniform8(0..255u8).prop_filter_map("invalid secret key generated", |bytes| {
|
||||||
|
ExtendedPrivKey::new_master(Network::Regtest, &bytes).ok()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,65 @@
|
|||||||
|
#![allow(clippy::unwrap_used)] // This is only meant to be used in tests.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tracing::subscriber;
|
||||||
|
use tracing_subscriber::filter::LevelFilter;
|
||||||
|
use tracing_subscriber::fmt::MakeWriter;
|
||||||
|
|
||||||
|
/// Setup tracing with a capturing writer, allowing assertions on the log
|
||||||
|
/// messages.
|
||||||
|
///
|
||||||
|
/// Time and ANSI are disabled to make the output more predictable and
|
||||||
|
/// readable.
|
||||||
|
pub fn capture_logs(min_level: LevelFilter) -> MakeCapturingWriter {
|
||||||
|
let make_writer = MakeCapturingWriter::default();
|
||||||
|
|
||||||
|
let guard = subscriber::set_default(
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_ansi(false)
|
||||||
|
.without_time()
|
||||||
|
.with_writer(make_writer.clone())
|
||||||
|
.with_env_filter(format!("{}", min_level))
|
||||||
|
.finish(),
|
||||||
|
);
|
||||||
|
// don't clean up guard we stay initialized
|
||||||
|
std::mem::forget(guard);
|
||||||
|
|
||||||
|
make_writer
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
pub struct MakeCapturingWriter {
|
||||||
|
writer: CapturingWriter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MakeCapturingWriter {
|
||||||
|
pub fn captured(&self) -> String {
|
||||||
|
let captured = &self.writer.captured;
|
||||||
|
let cursor = captured.lock().unwrap();
|
||||||
|
String::from_utf8(cursor.clone().into_inner()).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MakeWriter for MakeCapturingWriter {
|
||||||
|
type Writer = CapturingWriter;
|
||||||
|
|
||||||
|
fn make_writer(&self) -> Self::Writer {
|
||||||
|
self.writer.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
pub struct CapturingWriter {
|
||||||
|
captured: Arc<Mutex<io::Cursor<Vec<u8>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl io::Write for CapturingWriter {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||||
|
self.captured.lock().unwrap().write(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in new issue