mirror of
https://github.com/comit-network/xmr-btc-swap.git
synced 2025-08-24 14:15:55 -04:00
feat(protocol): Early Bitcoin refund (#344)
* feat(protocol): Early Bitcoin refund Alice can choose to let Bob refund his Bitcoin early (before TxCancel timelock expires) * feat: Let Bob check for TxEarlyRefund * fix: compile errors feat(alice): if we cannot lock Monero within 2 minutes, early refund bitcoin * satisfy clippy * fix(gui): Emit tauri event when Bitcoin is early refunded * tests progress * rename AliceStates * progress: working prototype! * add unit tests for tx_early_refund construction (tx_early_refund_can_be_constructed_and_signed, tx_early weight check) * fix compile error in swap/tests/alice_zero_xmr_early_refund.rs * only make [`tx_early_refund_sig_bob`] optional in state machine, not message machine * feat: working integration test alice_zero_xmr_early_refund.rs * fix tests * add changelog entry, add integration test with broken monero-wallet-rpc simulation * amend * amend changelog * nitpick * feat(gui): Bump MIN_ASB_VERSION to 2.0.0 * feat(bob): explicitly check for tx_early_rewfund * refactor(bob): Assume tx_early_refund will not be published if timelock has expired * add todo * refactor * refactor(swap): Differentiate between BtcRefundPublished, BtcEarlyRefundPublished, BtcEarlyRefunded and BtcRefunded * refactor: move weight of tx_early into TX_EARLY_REFUND_WEIGHT const * efactor(swap): Differentiate between BtcRefundPublished,BtcEarlyRefundPublished, BtcEarlyRefunded and BtcRefunded * small refactors * nitpciks * dprint fmt * add context to get_raw_transaction * refactor: remove duplicated code in watch_for_redeem_btc, dprint fmt * fix: parse -5 electrum transaction not found error correctly * refactor: send btc_refund_finalized flag to tauri with BtcRefunded state * remove uncessary .context * dprint dfmt * remove unused import * refactor: explicitly mark state3.expired_timelocks(...) as transient error when locking Monero * use .context instead of ok_or_else(...) * fix: in get_raw_transaction also check for "missing transaction" * add 4 different types of tauri events for different refund states * display BobStateName.BtcEarlyRefunded as done state * add global bottom to DialogContentText * fix(gui): Add missing padding in SwapDialog * proof of concept: electrum load balancer * load balancer progress * wrap Mutex<Vec<Arc<BdkElectrumClient<Client>>>> in electrum_balancer in another Arc, free locks as fast as possible * refactor * refactor(electrum balancer): use OnceCell to do lazy initilization * tests * refactor(electrum): enhance error handling with MultiError for comprehensive failure analysis This commit introduces a robust MultiError system for the Electrum balancer that collects and exposes all individual node failures, enabling better error analysis and decision making. Key improvements: - Add MultiError struct with methods for inspecting all collected errors from failed nodes - Modify electrum_balancer::call() to return MultiError instead of single Error - Enhance Client::get_tx() to properly detect transaction-not-found across multiple nodes - Add call_async_with_multi_error() method for detailed async error analysis - Improve error tracing and logging throughout the Bitcoin wallet operations - Add comprehensive test coverage for MultiError functionality and edge cases - Remove obsolete should_retry_on_error() logic in favor of MultiError inspection The MultiError type maintains backward compatibility through automatic conversion to Error while providing rich error analysis capabilities for callers that need detailed failure information. This particularly improves handling of transaction-not-found scenarios where different nodes may return different error formats. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * add changelog entry for electrum node balancing * refactors of electrum balancer * only warn if .check_for_tx_early_refund fails * clippy * remove verbose message * use AtomicUsize * final touches * log libp2p crates * merge master * display LinearProgressWithSubtitle as indeterminate if progress=100% * let broadcast return a MultiError, log all libp2p crates * nitpick * make clippy happy * log "kind" for join_all load balancer * add kind to join_all method, show warning alert if alice takes a long time to redeem Bitcoin * parse multierrors correctly * fmt --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
8aad1bdf75
commit
07f935bfbc
54 changed files with 3204 additions and 392 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -238,6 +238,8 @@ jobs:
|
|||
concurrent_bobs_after_xmr_lock_proof_sent,
|
||||
alice_manually_redeems_after_enc_sig_learned,
|
||||
happy_path_bob_offline_while_alice_redeems_btc,
|
||||
alice_empty_balance_after_started_btc_early_refund,
|
||||
alice_broken_wallet_rpc_after_started_btc_early_refund
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -1,2 +1,5 @@
|
|||
{
|
||||
"rust-analyzer.check.command": "check",
|
||||
"rust-analyzer.check.extraArgs": ["--lib", "--bins"],
|
||||
"rust-analyzer.cargo.buildScripts.enable": true
|
||||
}
|
|
@ -7,8 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
- BREAKING PROTOCOL CHANGE: Takers/GUIs running `>= 2.0.0` will not be able to initiate new swaps with makers/asbs running `< 2.0.0`. Please upgrade as soon as possible. Already started swaps from older versions are not be affected.
|
||||
- Taker and Maker now collaboratively sign a `tx_refund_early` Bitcoin transaction in the negotiation phase which allows the maker to refund the Bitcoin for the taker without having to wait for the 12h cancel timelock to expire.
|
||||
- `tx_refund_early` will only be published if the maker has not locked their Monero yet. This allows swaps to be refunded quickly if the maker doesn't have enough funds available or their daemon is not fully synced. The taker can then use the refunded Bitcoin to start a new swap.
|
||||
- ASB: The maker will take Monero funds needed for ongoing swaps into consideration when making a quote. A warning will be displayed if the Monero funds do not cover all ongoing swaps.
|
||||
- ASB: Return a zero quote when quoting fails instead of letting the request time out
|
||||
- GUI + CLI + ASB: We now do load balancing over multiple Electrum servers. This improves the reliability of all our interactions with the Bitcoin network. When transactions are published they are broadcast to all servers in parallel.
|
||||
- ASB: The `electrum_rpc_url` option has been removed. A new `electrum_rpc_urls` option has been added. Use it to specify a list of Electrum servers to use. If you want you can continue using a single server by providing a single URL. For most makers we recommend:
|
||||
- Running your own [electrs](https://github.com/romanz/electrs/) server
|
||||
- Optionally providing 2-5 fallback servers. The order of the servers does matter. Electrum servers at the front of the list have priority and will be tried first. You should place your own server at the front of the list.
|
||||
- A list of public Electrum servers can be found [here](https://1209k.com/bitcoin-eye/ele.php?chain=btc)
|
||||
|
||||
## [1.1.7] - 2025-06-04
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ pub trait MoneroWalletRpc {
|
|||
async fn get_accounts(&self, tag: String) -> GetAccounts;
|
||||
async fn open_wallet(&self, filename: String) -> WalletOpened;
|
||||
async fn close_wallet(&self) -> WalletClosed;
|
||||
async fn stop_wallet(&self) -> WalletStopped;
|
||||
async fn create_wallet(&self, filename: String, language: String) -> WalletCreated;
|
||||
async fn transfer(
|
||||
&self,
|
||||
|
@ -224,6 +225,7 @@ pub struct Version {
|
|||
pub type WalletCreated = Empty;
|
||||
pub type WalletClosed = Empty;
|
||||
pub type WalletOpened = Empty;
|
||||
pub type WalletStopped = Empty;
|
||||
|
||||
/// Zero-sized struct to allow serde to deserialize an empty JSON object.
|
||||
///
|
||||
|
|
|
@ -29,7 +29,10 @@ export enum BobStateName {
|
|||
BtcRedeemed = "btc is redeemed",
|
||||
CancelTimelockExpired = "cancel timelock is expired",
|
||||
BtcCancelled = "btc is cancelled",
|
||||
BtcRefundPublished = "btc refund is published",
|
||||
BtcEarlyRefundPublished = "btc early refund is published",
|
||||
BtcRefunded = "btc is refunded",
|
||||
BtcEarlyRefunded = "btc is early refunded",
|
||||
XmrRedeemed = "xmr is redeemed",
|
||||
BtcPunished = "btc is punished",
|
||||
SafelyAborted = "safely aborted",
|
||||
|
@ -55,8 +58,14 @@ export function bobStateNameToHumanReadable(stateName: BobStateName): string {
|
|||
return "Cancel timelock expired";
|
||||
case BobStateName.BtcCancelled:
|
||||
return "Bitcoin cancelled";
|
||||
case BobStateName.BtcRefundPublished:
|
||||
return "Bitcoin refund published";
|
||||
case BobStateName.BtcEarlyRefundPublished:
|
||||
return "Bitcoin early refund published";
|
||||
case BobStateName.BtcRefunded:
|
||||
return "Bitcoin refunded";
|
||||
case BobStateName.BtcEarlyRefunded:
|
||||
return "Bitcoin early refunded";
|
||||
case BobStateName.XmrRedeemed:
|
||||
return "Monero redeemed";
|
||||
case BobStateName.BtcPunished:
|
||||
|
@ -102,6 +111,7 @@ export type BobStateNameRunningSwap = Exclude<
|
|||
| BobStateName.Started
|
||||
| BobStateName.SwapSetupCompleted
|
||||
| BobStateName.BtcRefunded
|
||||
| BobStateName.BtcEarlyRefunded
|
||||
| BobStateName.BtcPunished
|
||||
| BobStateName.SafelyAborted
|
||||
| BobStateName.XmrRedeemed
|
||||
|
@ -122,6 +132,7 @@ export function isBobStateNameRunningSwap(
|
|||
BobStateName.Started,
|
||||
BobStateName.SwapSetupCompleted,
|
||||
BobStateName.BtcRefunded,
|
||||
BobStateName.BtcEarlyRefunded,
|
||||
BobStateName.BtcPunished,
|
||||
BobStateName.SafelyAborted,
|
||||
BobStateName.XmrRedeemed,
|
||||
|
@ -131,6 +142,7 @@ export function isBobStateNameRunningSwap(
|
|||
export type BobStateNameCompletedSwap =
|
||||
| BobStateName.XmrRedeemed
|
||||
| BobStateName.BtcRefunded
|
||||
| BobStateName.BtcEarlyRefunded
|
||||
| BobStateName.BtcPunished
|
||||
| BobStateName.SafelyAborted;
|
||||
|
||||
|
@ -140,6 +152,7 @@ export function isBobStateNameCompletedSwap(
|
|||
return [
|
||||
BobStateName.XmrRedeemed,
|
||||
BobStateName.BtcRefunded,
|
||||
BobStateName.BtcEarlyRefunded,
|
||||
BobStateName.BtcPunished,
|
||||
BobStateName.SafelyAborted,
|
||||
].includes(state);
|
||||
|
@ -150,7 +163,9 @@ export type BobStateNamePossiblyCancellableSwap =
|
|||
| BobStateName.XmrLockProofReceived
|
||||
| BobStateName.XmrLocked
|
||||
| BobStateName.EncSigSent
|
||||
| BobStateName.CancelTimelockExpired;
|
||||
| BobStateName.CancelTimelockExpired
|
||||
| BobStateName.BtcRefundPublished
|
||||
| BobStateName.BtcEarlyRefundPublished;
|
||||
|
||||
/**
|
||||
Checks if a swap is in a state where it can possibly be cancelled
|
||||
|
@ -161,6 +176,7 @@ The following conditions must be met:
|
|||
- The bitcoin must not be cancelled
|
||||
- The bitcoin must not be refunded
|
||||
- The bitcoin must not be punished
|
||||
- The bitcoin must not be early refunded
|
||||
|
||||
See: https://github.com/comit-network/xmr-btc-swap/blob/7023e75bb51ab26dff4c8fcccdc855d781ca4b15/swap/src/cli/cancel.rs#L16-L35
|
||||
*/
|
||||
|
@ -173,6 +189,8 @@ export function isBobStateNamePossiblyCancellableSwap(
|
|||
BobStateName.XmrLocked,
|
||||
BobStateName.EncSigSent,
|
||||
BobStateName.CancelTimelockExpired,
|
||||
BobStateName.BtcRefundPublished,
|
||||
BobStateName.BtcEarlyRefundPublished,
|
||||
].includes(state);
|
||||
}
|
||||
|
||||
|
@ -182,7 +200,9 @@ export type BobStateNamePossiblyRefundableSwap =
|
|||
| BobStateName.XmrLocked
|
||||
| BobStateName.EncSigSent
|
||||
| BobStateName.CancelTimelockExpired
|
||||
| BobStateName.BtcCancelled;
|
||||
| BobStateName.BtcCancelled
|
||||
| BobStateName.BtcRefundPublished
|
||||
| BobStateName.BtcEarlyRefundPublished;
|
||||
|
||||
/**
|
||||
Checks if a swap is in a state where it can possibly be refunded (meaning it's not impossible)
|
||||
|
@ -205,6 +225,8 @@ export function isBobStateNamePossiblyRefundableSwap(
|
|||
BobStateName.EncSigSent,
|
||||
BobStateName.CancelTimelockExpired,
|
||||
BobStateName.BtcCancelled,
|
||||
BobStateName.BtcRefundPublished,
|
||||
BobStateName.BtcEarlyRefundPublished,
|
||||
].includes(state);
|
||||
}
|
||||
|
||||
|
|
|
@ -91,16 +91,13 @@ function BitcoinLockedNoTimelockExpiredStateAlert({
|
|||
return (
|
||||
<MessageList
|
||||
messages={[
|
||||
isRunning
|
||||
? "We are waiting for the other party to lock their Monero"
|
||||
: null,
|
||||
<>
|
||||
If the swap isn't completed in{" "}
|
||||
<HumanizedBitcoinBlockDuration
|
||||
blocks={timelock.content.blocks_left}
|
||||
displayBlocks={false}
|
||||
/>
|
||||
, it needs to be refunded
|
||||
, it will be refunded
|
||||
</>,
|
||||
"For that, you need to have the app open sometime within the refund period",
|
||||
<>
|
||||
|
@ -187,6 +184,8 @@ export function StateAlert({
|
|||
case BobStateName.EncSigSent:
|
||||
case BobStateName.CancelTimelockExpired:
|
||||
case BobStateName.BtcCancelled:
|
||||
case BobStateName.BtcRefundPublished: // Even if the transactions have been published, it cannot be
|
||||
case BobStateName.BtcEarlyRefundPublished: // guaranteed that they will be confirmed in time
|
||||
if (swap.timelock != null) {
|
||||
switch (swap.timelock.type) {
|
||||
case "None":
|
||||
|
@ -220,6 +219,13 @@ export function StateAlert({
|
|||
}
|
||||
}
|
||||
|
||||
// How many blocks need to be left for the timelock to be considered unusual
|
||||
// A bit arbitrary but we don't want to alarm the user
|
||||
// 72 is the default cancel timelock in blocks
|
||||
// 4 blocks are around 40 minutes
|
||||
// If the swap has taken longer than 40 minutes, we consider it unusual
|
||||
const UNUSUAL_AMOUNT_OF_TIME_HAS_PASSED_THRESHOLD = 72 - 4;
|
||||
|
||||
/**
|
||||
* Main component for displaying the swap status alert.
|
||||
* @param swap - The swap information.
|
||||
|
@ -228,9 +234,11 @@ export function StateAlert({
|
|||
export default function SwapStatusAlert({
|
||||
swap,
|
||||
isRunning,
|
||||
onlyShowIfUnusualAmountOfTimeHasPassed,
|
||||
}: {
|
||||
swap: GetSwapInfoResponseExt;
|
||||
isRunning: boolean;
|
||||
onlyShowIfUnusualAmountOfTimeHasPassed?: boolean;
|
||||
}): JSX.Element | null {
|
||||
// If the swap is completed, we do not need to display anything
|
||||
if (!isGetSwapInfoResponseRunningSwap(swap)) {
|
||||
|
@ -242,6 +250,16 @@ export default function SwapStatusAlert({
|
|||
return null;
|
||||
}
|
||||
|
||||
// If we are only showing if an unusual amount of time has passed, we need to check if the swap has been running for a while
|
||||
if (
|
||||
onlyShowIfUnusualAmountOfTimeHasPassed &&
|
||||
swap.timelock.type === "None" &&
|
||||
swap.timelock.content.blocks_left >
|
||||
UNUSUAL_AMOUNT_OF_TIME_HAS_PASSED_THRESHOLD
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
key={swap.swap_id}
|
||||
|
|
|
@ -53,7 +53,10 @@ export function LinearProgressWithSubtitle({
|
|||
width: "10rem",
|
||||
}}
|
||||
>
|
||||
<LinearProgress variant="determinate" value={value} />
|
||||
<LinearProgress
|
||||
variant={value === 100 ? "indeterminate" : "determinate"}
|
||||
value={value}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
import { Button, Dialog, DialogActions, DialogContent } from "@mui/material";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
} from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { swapReset } from "store/features/swapSlice";
|
||||
import { useAppDispatch, useAppSelector, useIsSwapRunning } from "store/hooks";
|
||||
|
@ -49,15 +55,25 @@ export default function SwapDialog({
|
|||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
flex: 1,
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{debug ? (
|
||||
<DebugPage />
|
||||
) : (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
justifyContent: "space-between",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<SwapStatePage state={swap.state} />
|
||||
<SwapStateStepper state={swap.state} />
|
||||
</>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
|
|
|
@ -98,8 +98,15 @@ function getActiveStep(state: SwapState | null): PathStep | null {
|
|||
case "BtcCancelled":
|
||||
return [PathType.UNHAPPY_PATH, 1, isReleased];
|
||||
|
||||
// Step 2: Swap cancelled and Bitcoin refunded successfully
|
||||
// Step 2: One of the two Bitcoin refund transactions have been published
|
||||
// but they haven't been confirmed yet
|
||||
case "BtcRefundPublished":
|
||||
case "BtcEarlyRefundPublished":
|
||||
return [PathType.UNHAPPY_PATH, 1, isReleased];
|
||||
|
||||
// Step 2: One of the two Bitcoin refund transactions have been confirmed
|
||||
case "BtcRefunded":
|
||||
case "BtcEarlyRefunded":
|
||||
return [PathType.UNHAPPY_PATH, 2, false];
|
||||
|
||||
// Step 2 (Failed): Failed to refund Bitcoin
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
import { Box } from "@mui/material";
|
||||
import { SwapState } from "models/storeModel";
|
||||
import { TauriSwapProgressEventType } from "models/tauriModelExt";
|
||||
import CircularProgressWithSubtitle from "../CircularProgressWithSubtitle";
|
||||
import BitcoinPunishedPage from "./done/BitcoinPunishedPage";
|
||||
import BitcoinRefundedPage from "./done/BitcoinRefundedPage";
|
||||
import {
|
||||
BitcoinRefundedPage,
|
||||
BitcoinEarlyRefundedPage,
|
||||
BitcoinEarlyRefundPublishedPage,
|
||||
BitcoinRefundPublishedPage,
|
||||
} from "./done/BitcoinRefundedPage";
|
||||
import XmrRedeemInMempoolPage from "./done/XmrRedeemInMempoolPage";
|
||||
import ProcessExitedPage from "./exited/ProcessExitedPage";
|
||||
import BitcoinCancelledPage from "./in_progress/BitcoinCancelledPage";
|
||||
|
@ -16,13 +21,16 @@ import XmrLockedPage from "./in_progress/XmrLockedPage";
|
|||
import XmrLockTxInMempoolPage from "./in_progress/XmrLockInMempoolPage";
|
||||
import InitPage from "./init/InitPage";
|
||||
import WaitingForBitcoinDepositPage from "./init/WaitingForBitcoinDepositPage";
|
||||
import { exhaustiveGuard } from "utils/typescriptUtils";
|
||||
|
||||
export default function SwapStatePage({ state }: { state: SwapState | null }) {
|
||||
if (state === null) {
|
||||
return <InitPage />;
|
||||
}
|
||||
|
||||
switch (state.curr.type) {
|
||||
const type: TauriSwapProgressEventType = state.curr.type;
|
||||
|
||||
switch (type) {
|
||||
case "RequestingQuote":
|
||||
return <CircularProgressWithSubtitle description="Requesting quote..." />;
|
||||
case "Resuming":
|
||||
|
@ -30,13 +38,26 @@ export default function SwapStatePage({ state }: { state: SwapState | null }) {
|
|||
case "ReceivedQuote":
|
||||
return <ReceivedQuotePage />;
|
||||
case "WaitingForBtcDeposit":
|
||||
return <WaitingForBitcoinDepositPage {...state.curr.content} />;
|
||||
// This double check is necessary for the typescript compiler to infer types
|
||||
if (state.curr.type === "WaitingForBtcDeposit") {
|
||||
return <WaitingForBitcoinDepositPage {...state.curr.content} />;
|
||||
}
|
||||
break;
|
||||
case "SwapSetupInflight":
|
||||
return <SwapSetupInflightPage {...state.curr.content} />;
|
||||
if (state.curr.type === "SwapSetupInflight") {
|
||||
return <SwapSetupInflightPage {...state.curr.content} />;
|
||||
}
|
||||
break;
|
||||
case "BtcLockTxInMempool":
|
||||
return <BitcoinLockTxInMempoolPage {...state.curr.content} />;
|
||||
if (state.curr.type === "BtcLockTxInMempool") {
|
||||
return <BitcoinLockTxInMempoolPage {...state.curr.content} />;
|
||||
}
|
||||
break;
|
||||
case "XmrLockTxInMempool":
|
||||
return <XmrLockTxInMempoolPage {...state.curr.content} />;
|
||||
if (state.curr.type === "XmrLockTxInMempool") {
|
||||
return <XmrLockTxInMempoolPage {...state.curr.content} />;
|
||||
}
|
||||
break;
|
||||
case "XmrLocked":
|
||||
return <XmrLockedPage />;
|
||||
case "EncryptedSignatureSent":
|
||||
|
@ -44,15 +65,43 @@ export default function SwapStatePage({ state }: { state: SwapState | null }) {
|
|||
case "BtcRedeemed":
|
||||
return <BitcoinRedeemedPage />;
|
||||
case "XmrRedeemInMempool":
|
||||
return <XmrRedeemInMempoolPage {...state.curr.content} />;
|
||||
if (state.curr.type === "XmrRedeemInMempool") {
|
||||
return <XmrRedeemInMempoolPage {...state.curr.content} />;
|
||||
}
|
||||
break;
|
||||
case "CancelTimelockExpired":
|
||||
return <CancelTimelockExpiredPage />;
|
||||
case "BtcCancelled":
|
||||
return <BitcoinCancelledPage />;
|
||||
case "BtcRefunded":
|
||||
return <BitcoinRefundedPage {...state.curr.content} />;
|
||||
|
||||
//// 4 different types of Bitcoin refund states we can be in
|
||||
case "BtcRefundPublished": // tx_refund has been published but has not been confirmed yet
|
||||
if (state.curr.type === "BtcRefundPublished") {
|
||||
return <BitcoinRefundPublishedPage {...state.curr.content} />;
|
||||
}
|
||||
break;
|
||||
case "BtcEarlyRefundPublished": // tx_early_refund has been published but has not been confirmed yet
|
||||
if (state.curr.type === "BtcEarlyRefundPublished") {
|
||||
return <BitcoinEarlyRefundPublishedPage {...state.curr.content} />;
|
||||
}
|
||||
break;
|
||||
case "BtcRefunded": // tx_refund has been confirmed
|
||||
if (state.curr.type === "BtcRefunded") {
|
||||
return <BitcoinRefundedPage {...state.curr.content} />;
|
||||
}
|
||||
break;
|
||||
case "BtcEarlyRefunded": // tx_early_refund has been confirmed
|
||||
if (state.curr.type === "BtcEarlyRefunded") {
|
||||
return <BitcoinEarlyRefundedPage {...state.curr.content} />;
|
||||
}
|
||||
break;
|
||||
|
||||
//// 4 different types of Bitcoin punished states we can be in
|
||||
case "BtcPunished":
|
||||
return <BitcoinPunishedPage state={state.curr} />;
|
||||
if (state.curr.type === "BtcPunished") {
|
||||
return <BitcoinPunishedPage state={state.curr} />;
|
||||
}
|
||||
break;
|
||||
case "AttemptingCooperativeRedeem":
|
||||
return (
|
||||
<CircularProgressWithSubtitle description="Attempting to redeem the Monero with the help of the other party" />
|
||||
|
@ -62,18 +111,14 @@ export default function SwapStatePage({ state }: { state: SwapState | null }) {
|
|||
<CircularProgressWithSubtitle description="The other party is cooperating with us to redeem the Monero..." />
|
||||
);
|
||||
case "CooperativeRedeemRejected":
|
||||
return <BitcoinPunishedPage state={state.curr} />;
|
||||
if (state.curr.type === "CooperativeRedeemRejected") {
|
||||
return <BitcoinPunishedPage state={state.curr} />;
|
||||
}
|
||||
break;
|
||||
case "Released":
|
||||
return <ProcessExitedPage prevState={state.prev} swapId={state.swapId} />;
|
||||
|
||||
default:
|
||||
// TODO: Use this when we have all states implemented, ensures we don't forget to implement a state
|
||||
// return exhaustiveGuard(state.curr.type);
|
||||
return (
|
||||
<Box>
|
||||
No information to display
|
||||
<br />
|
||||
State: {JSON.stringify(state, null, 4)}
|
||||
</Box>
|
||||
);
|
||||
return exhaustiveGuard(type);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,14 +4,66 @@ import { useActiveSwapInfo } from "store/hooks";
|
|||
import FeedbackInfoBox from "../../../../pages/help/FeedbackInfoBox";
|
||||
import BitcoinTransactionInfoBox from "../../BitcoinTransactionInfoBox";
|
||||
|
||||
export default function BitcoinRefundedPage({
|
||||
export function BitcoinRefundPublishedPage({
|
||||
btc_refund_txid,
|
||||
}: TauriSwapProgressEventContent<"BtcRefundPublished">) {
|
||||
return (
|
||||
<MultiBitcoinRefundedPage
|
||||
btc_refund_txid={btc_refund_txid}
|
||||
btc_refund_finalized={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BitcoinEarlyRefundPublishedPage({
|
||||
btc_early_refund_txid,
|
||||
}: TauriSwapProgressEventContent<"BtcEarlyRefundPublished">) {
|
||||
return (
|
||||
<MultiBitcoinRefundedPage
|
||||
btc_refund_txid={btc_early_refund_txid}
|
||||
btc_refund_finalized={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BitcoinRefundedPage({
|
||||
btc_refund_txid,
|
||||
}: TauriSwapProgressEventContent<"BtcRefunded">) {
|
||||
// TODO: Reimplement this using Tauri
|
||||
return (
|
||||
<MultiBitcoinRefundedPage
|
||||
btc_refund_txid={btc_refund_txid}
|
||||
btc_refund_finalized={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BitcoinEarlyRefundedPage({
|
||||
btc_early_refund_txid,
|
||||
}: TauriSwapProgressEventContent<"BtcEarlyRefunded">) {
|
||||
return (
|
||||
<MultiBitcoinRefundedPage
|
||||
btc_refund_txid={btc_early_refund_txid}
|
||||
btc_refund_finalized={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiBitcoinRefundedPage({
|
||||
btc_refund_txid,
|
||||
btc_refund_finalized,
|
||||
}: {
|
||||
btc_refund_txid: string;
|
||||
btc_refund_finalized: boolean;
|
||||
}) {
|
||||
const swap = useActiveSwapInfo();
|
||||
const additionalContent = swap
|
||||
? `Refund address: ${swap.btc_refund_address}`
|
||||
: null;
|
||||
const additionalContent = swap ? (
|
||||
<>
|
||||
{!btc_refund_finalized &&
|
||||
"Waiting for refund transaction to be confirmed"}
|
||||
{!btc_refund_finalized && <br />}
|
||||
Refund address: {swap.btc_refund_address}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
|
@ -27,13 +79,10 @@ export default function BitcoinRefundedPage({
|
|||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{
|
||||
// TODO: We should display the confirmation count here
|
||||
}
|
||||
<BitcoinTransactionInfoBox
|
||||
title="Bitcoin Refund Transaction"
|
||||
txId={btc_refund_txid}
|
||||
loading={false}
|
||||
loading={!btc_refund_finalized}
|
||||
additionalContent={additionalContent}
|
||||
/>
|
||||
<FeedbackInfoBox />
|
||||
|
|
|
@ -1,7 +1,25 @@
|
|||
import SwapStatusAlert from "renderer/components/alert/SwapStatusAlert/SwapStatusAlert";
|
||||
import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle";
|
||||
import { useActiveSwapInfo, useSwapInfosSortedByDate } from "store/hooks";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
export default function EncryptedSignatureSentPage() {
|
||||
const swap = useActiveSwapInfo();
|
||||
|
||||
return (
|
||||
<CircularProgressWithSubtitle description="Waiting for them to redeem the Bitcoin" />
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<SwapStatusAlert
|
||||
swap={swap}
|
||||
isRunning={true}
|
||||
onlyShowIfUnusualAmountOfTimeHasPassed={true}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "10rem",
|
||||
}}
|
||||
>
|
||||
<CircularProgressWithSubtitle description="Waiting for them to redeem the Bitcoin" />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ export default function ReceivedQuotePage() {
|
|||
|
||||
return (
|
||||
<LinearProgressWithSubtitle
|
||||
description={`Syncing Bitcoin wallet (${percentage}%)`}
|
||||
description={`Syncing Bitcoin wallet`}
|
||||
value={percentage}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -17,9 +17,10 @@ export function AmountWithUnit({
|
|||
exchangeRate?: Amount;
|
||||
parenthesisText?: string;
|
||||
}) {
|
||||
const [fetchFiatPrices, fiatCurrency] = useSettings(
|
||||
(settings) => [settings.fetchFiatPrices, settings.fiatCurrency],
|
||||
);
|
||||
const [fetchFiatPrices, fiatCurrency] = useSettings((settings) => [
|
||||
settings.fetchFiatPrices,
|
||||
settings.fiatCurrency,
|
||||
]);
|
||||
const title =
|
||||
fetchFiatPrices &&
|
||||
exchangeRate != null &&
|
||||
|
|
|
@ -35,11 +35,7 @@ import {
|
|||
Network,
|
||||
setTheme,
|
||||
} from "store/features/settingsSlice";
|
||||
import {
|
||||
useAppDispatch,
|
||||
useNodes,
|
||||
useSettings,
|
||||
} from "store/hooks";
|
||||
import { useAppDispatch, useNodes, useSettings } from "store/hooks";
|
||||
import ValidatedTextField from "renderer/components/other/ValidatedTextField";
|
||||
import HelpIcon from "@mui/icons-material/HelpOutline";
|
||||
import { ReactNode, useState } from "react";
|
||||
|
|
|
@ -76,6 +76,14 @@ export default function HistoryRowActions(swap: GetSwapInfoResponse) {
|
|||
);
|
||||
}
|
||||
|
||||
if (swap.state_name === BobStateName.BtcEarlyRefunded) {
|
||||
return (
|
||||
<Tooltip title="This swap is completed. Your Bitcoin has been refunded.">
|
||||
<DoneIcon style={{ color: green[500] }} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (swap.state_name === BobStateName.BtcPunished) {
|
||||
return (
|
||||
<Tooltip title="You have been punished. You can attempt to recover the Monero with the help of the other party but that is not guaranteed to work">
|
||||
|
|
|
@ -35,6 +35,13 @@ const baseTheme: ThemeOptions = {
|
|||
},
|
||||
},
|
||||
},
|
||||
MuiDialogContentText: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
marginBottom: "0.5rem",
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
|
|
|
@ -218,39 +218,40 @@ export async function initializeContext() {
|
|||
const testnet = isTestnet();
|
||||
const useTor = store.getState().settings.enableTor;
|
||||
|
||||
// This looks convoluted but it does the following:
|
||||
// - Fetch the status of all nodes for each blockchain in parallel
|
||||
// - Return the first available node for each blockchain
|
||||
// - If no node is available for a blockchain, return null for that blockchain
|
||||
const [bitcoinNode, moneroNode] = await Promise.all(
|
||||
[Blockchain.Bitcoin, Blockchain.Monero].map(async (blockchain) => {
|
||||
const nodes = store.getState().settings.nodes[network][blockchain];
|
||||
// Get all Bitcoin nodes without checking availability
|
||||
// The backend ElectrumBalancer will handle load balancing and failover
|
||||
const bitcoinNodes =
|
||||
store.getState().settings.nodes[network][Blockchain.Bitcoin];
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// For Monero nodes, check availability and use the first working one
|
||||
const moneroNodes =
|
||||
store.getState().settings.nodes[network][Blockchain.Monero];
|
||||
let moneroNode = null;
|
||||
|
||||
try {
|
||||
return await Promise.any(
|
||||
nodes.map(async (node) => {
|
||||
const isAvailable = await getNodeStatus(node, blockchain, network);
|
||||
if (moneroNodes.length > 0) {
|
||||
try {
|
||||
moneroNode = await Promise.any(
|
||||
moneroNodes.map(async (node) => {
|
||||
const isAvailable = await getNodeStatus(
|
||||
node,
|
||||
Blockchain.Monero,
|
||||
network,
|
||||
);
|
||||
if (isAvailable) {
|
||||
return node;
|
||||
}
|
||||
throw new Error(`Monero node ${node} is not available`);
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// If no Monero node is available, use null
|
||||
moneroNode = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isAvailable) {
|
||||
return node;
|
||||
}
|
||||
|
||||
throw new Error(`No available ${blockchain} node found`);
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Initialize Tauri settings with null values
|
||||
// Initialize Tauri settings
|
||||
const tauriSettings: TauriSettings = {
|
||||
electrum_rpc_url: bitcoinNode,
|
||||
electrum_rpc_urls: bitcoinNodes,
|
||||
monero_node_url: moneroNode,
|
||||
use_tor: useTor,
|
||||
};
|
||||
|
@ -324,12 +325,11 @@ export async function updateAllNodeStatuses() {
|
|||
const network = getNetwork();
|
||||
const settings = store.getState().settings;
|
||||
|
||||
// For all nodes, check if they are available and store the new status (in parallel)
|
||||
// Only check Monero nodes, skip Bitcoin nodes since we pass all electrum servers
|
||||
// to the backend without checking them (ElectrumBalancer handles failover)
|
||||
await Promise.all(
|
||||
Object.values(Blockchain).flatMap((blockchain) =>
|
||||
settings.nodes[network][blockchain].map((node) =>
|
||||
updateNodeStatus(node, blockchain, network),
|
||||
),
|
||||
settings.nodes[network][Blockchain.Monero].map((node) =>
|
||||
updateNodeStatus(node, Blockchain.Monero, network),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -78,12 +78,21 @@ const initialState: SettingsState = {
|
|||
nodes: {
|
||||
[Network.Testnet]: {
|
||||
[Blockchain.Bitcoin]: [
|
||||
"ssl://ax101.blockeng.ch:60002",
|
||||
"ssl://blackie.c3-soft.com:57006",
|
||||
"ssl://v22019051929289916.bestsrv.de:50002",
|
||||
"tcp://v22019051929289916.bestsrv.de:50001",
|
||||
"tcp://electrum.blockstream.info:60001",
|
||||
"ssl://electrum.blockstream.info:60002",
|
||||
"ssl://blockstream.info:993",
|
||||
"tcp://blockstream.info:143",
|
||||
"ssl://testnet.aranguren.org:51002",
|
||||
"ssl://testnet.qtornado.com:51002",
|
||||
"tcp://testnet.qtornado.com:51001",
|
||||
"tcp://testnet.aranguren.org:51001",
|
||||
"ssl://bitcoin.stagemole.eu:5010",
|
||||
"tcp://bitcoin.stagemole.eu:5000",
|
||||
"ssl://testnet.aranguren.org:51002",
|
||||
"ssl://testnet.qtornado.com:50002",
|
||||
"ssl://bitcoin.devmole.eu:5010",
|
||||
"tcp://bitcoin.devmole.eu:5000",
|
||||
],
|
||||
[Blockchain.Monero]: [],
|
||||
},
|
||||
|
|
|
@ -4,7 +4,8 @@ import semver from "semver";
|
|||
import { isTestnet } from "store/config";
|
||||
|
||||
// const MIN_ASB_VERSION = "1.0.0-alpha.1" // First version to support new libp2p protocol
|
||||
const MIN_ASB_VERSION = "1.1.0-rc.3"; // First version with support for bdk > 1.0
|
||||
// const MIN_ASB_VERSION = "1.1.0-rc.3" // First version with support for bdk > 1.0
|
||||
const MIN_ASB_VERSION = "2.0.0-beta.1"; // First version with support for tx_early_refund
|
||||
|
||||
export function providerToConcatenatedMultiAddr(provider: Maker) {
|
||||
return new Multiaddr(provider.multiAddr)
|
||||
|
|
|
@ -374,7 +374,7 @@ async fn initialize_context(
|
|||
|
||||
let context_result = ContextBuilder::new(testnet)
|
||||
.with_bitcoin(Bitcoin {
|
||||
bitcoin_electrum_rpc_url: settings.electrum_rpc_url.clone(),
|
||||
bitcoin_electrum_rpc_urls: settings.electrum_rpc_urls.clone(),
|
||||
bitcoin_target_block: None,
|
||||
})
|
||||
.with_monero(Monero {
|
||||
|
|
|
@ -37,7 +37,7 @@ dialoguer = "0.11"
|
|||
directories-next = "2"
|
||||
ecdsa_fun = { version = "0.10", default-features = false, features = ["libsecp_compat", "serde", "adaptor"] }
|
||||
ed25519-dalek = "1"
|
||||
futures = { version = "0.3", default-features = false }
|
||||
futures = { version = "0.3", default-features = false, features = ["std"] }
|
||||
hex = "0.4"
|
||||
libp2p = { version = "0.53.2", features = ["tcp", "yamux", "dns", "noise", "request-response", "ping", "rendezvous", "identify", "macros", "cbor", "json", "tokio", "serde", "rsa"] }
|
||||
libp2p-community-tor = { git = "https://github.com/umgefahren/libp2p-tor", branch = "main", features = ["listen-onion-service"] }
|
||||
|
|
|
@ -177,10 +177,52 @@ mod addr_list {
|
|||
}
|
||||
}
|
||||
|
||||
mod electrum_urls {
|
||||
use serde::de::Unexpected;
|
||||
use serde::{de, Deserialize, Deserializer};
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Url>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = Value::deserialize(deserializer)?;
|
||||
return match s {
|
||||
Value::String(s) => {
|
||||
let list: Result<Vec<_>, _> = s
|
||||
.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.trim().parse().map_err(de::Error::custom))
|
||||
.collect();
|
||||
Ok(list?)
|
||||
}
|
||||
Value::Array(a) => {
|
||||
let list: Result<Vec<_>, _> = a
|
||||
.iter()
|
||||
.map(|v| {
|
||||
if let Value::String(s) = v {
|
||||
s.trim().parse().map_err(de::Error::custom)
|
||||
} else {
|
||||
Err(de::Error::custom("expected a string"))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(list?)
|
||||
}
|
||||
value => Err(de::Error::invalid_type(
|
||||
Unexpected::Other(&value.to_string()),
|
||||
&"a string or array",
|
||||
)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Bitcoin {
|
||||
pub electrum_rpc_url: Url,
|
||||
#[serde(deserialize_with = "electrum_urls::deserialize")]
|
||||
pub electrum_rpc_urls: Vec<Url>,
|
||||
pub target_block: u16,
|
||||
pub finality_confirmations: Option<u32>,
|
||||
#[serde(with = "crate::bitcoin::network")]
|
||||
|
@ -309,10 +351,40 @@ pub fn query_user_for_initial_config(testnet: bool) -> Result<Config> {
|
|||
.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 Electrum RPC URL or hit return to use 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::<Url>::with_theme(&ColorfulTheme::default())
|
||||
.with_prompt(prompt)
|
||||
.allow_empty(true)
|
||||
.interact_text()?;
|
||||
if electrum_url.as_str().is_empty() {
|
||||
electrum_done = true;
|
||||
} else if electrum_rpc_urls.contains(&electrum_url) {
|
||||
println!("That Electrum URL is already in the list.");
|
||||
} else {
|
||||
electrum_rpc_urls.push(electrum_url);
|
||||
electrum_number += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let monero_wallet_rpc_url = Input::with_theme(&ColorfulTheme::default())
|
||||
.with_prompt("Enter Monero Wallet RPC URL or hit enter to use default")
|
||||
|
@ -379,7 +451,7 @@ pub fn query_user_for_initial_config(testnet: bool) -> Result<Config> {
|
|||
external_addresses: vec![],
|
||||
},
|
||||
bitcoin: Bitcoin {
|
||||
electrum_rpc_url,
|
||||
electrum_rpc_urls,
|
||||
target_block,
|
||||
finality_confirmations: None,
|
||||
network: bitcoin_network,
|
||||
|
@ -424,7 +496,7 @@ mod tests {
|
|||
dir: Default::default(),
|
||||
},
|
||||
bitcoin: Bitcoin {
|
||||
electrum_rpc_url: defaults.electrum_rpc_url,
|
||||
electrum_rpc_urls: vec![defaults.electrum_rpc_url],
|
||||
target_block: defaults.bitcoin_confirmation_target,
|
||||
finality_confirmations: None,
|
||||
network: bitcoin::Network::Testnet,
|
||||
|
@ -469,7 +541,7 @@ mod tests {
|
|||
dir: Default::default(),
|
||||
},
|
||||
bitcoin: Bitcoin {
|
||||
electrum_rpc_url: defaults.electrum_rpc_url,
|
||||
electrum_rpc_urls: vec![defaults.electrum_rpc_url],
|
||||
target_block: defaults.bitcoin_confirmation_target,
|
||||
finality_confirmations: None,
|
||||
network: bitcoin::Network::Bitcoin,
|
||||
|
@ -524,7 +596,7 @@ mod tests {
|
|||
let expected = Config {
|
||||
data: Data { dir },
|
||||
bitcoin: Bitcoin {
|
||||
electrum_rpc_url: defaults.electrum_rpc_url,
|
||||
electrum_rpc_urls: vec![defaults.electrum_rpc_url],
|
||||
target_block: defaults.bitcoin_confirmation_target,
|
||||
finality_confirmations: None,
|
||||
network: bitcoin::Network::Bitcoin,
|
||||
|
|
|
@ -1149,7 +1149,7 @@ mod tests {
|
|||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Failed to get unlocked Monero balance to construct quote"));
|
||||
.contains("Failed to get unlocked Monero balance"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
@ -38,6 +38,8 @@ pub async fn cancel(
|
|||
// Alice already in final state
|
||||
| AliceState::BtcRedeemed
|
||||
| AliceState::XmrRefunded
|
||||
| AliceState::BtcEarlyRefundable { .. }
|
||||
| AliceState::BtcEarlyRefunded(_)
|
||||
| AliceState::BtcPunished { .. }
|
||||
| AliceState::SafelyAborted => bail!("Swap is in state {} which is not cancelable", state),
|
||||
};
|
||||
|
|
|
@ -39,6 +39,8 @@ pub async fn punish(
|
|||
| AliceState::BtcRedeemed
|
||||
| AliceState::XmrRefunded
|
||||
| AliceState::BtcPunished { .. }
|
||||
| AliceState::BtcEarlyRefundable { .. }
|
||||
| AliceState::BtcEarlyRefunded(_)
|
||||
| AliceState::SafelyAborted => bail!(Error::SwapNotPunishable(state)),
|
||||
};
|
||||
|
||||
|
|
|
@ -82,6 +82,8 @@ pub async fn redeem(
|
|||
| AliceState::BtcPunishable { .. }
|
||||
| AliceState::BtcRedeemed
|
||||
| AliceState::XmrRefunded
|
||||
| AliceState::BtcEarlyRefundable { .. }
|
||||
| AliceState::BtcEarlyRefunded(_)
|
||||
| AliceState::BtcPunished { .. }
|
||||
| AliceState::SafelyAborted => bail!(
|
||||
"Cannot redeem swap {} because it is in state {} which cannot be manually redeemed",
|
||||
|
|
|
@ -56,14 +56,16 @@ pub async fn refund(
|
|||
AliceState::BtcRedeemTransactionPublished { .. }
|
||||
| AliceState::BtcRedeemed
|
||||
| AliceState::XmrRefunded
|
||||
| AliceState::BtcEarlyRefundable { .. }
|
||||
| AliceState::BtcEarlyRefunded(_)
|
||||
| AliceState::BtcPunished { .. }
|
||||
| AliceState::SafelyAborted => bail!(Error::SwapNotRefundable(state)),
|
||||
};
|
||||
|
||||
tracing::info!(%swap_id, "Trying to manually refund swap");
|
||||
|
||||
let spend_key = if let Ok(published_refund_tx) =
|
||||
state3.fetch_tx_refund(bitcoin_wallet.as_ref()).await
|
||||
let spend_key = if let Some(published_refund_tx) =
|
||||
state3.fetch_tx_refund(bitcoin_wallet.as_ref()).await?
|
||||
{
|
||||
tracing::debug!(%swap_id, "Bitcoin refund transaction found, extracting key to refund Monero");
|
||||
state3.extract_monero_private_key(published_refund_tx)?
|
||||
|
|
|
@ -31,6 +31,8 @@ pub async fn safely_abort(swap_id: Uuid, db: Arc<dyn Database>) -> Result<AliceS
|
|||
| AliceState::BtcPunishable { .. }
|
||||
| AliceState::BtcRedeemed
|
||||
| AliceState::XmrRefunded
|
||||
| AliceState::BtcEarlyRefundable { .. }
|
||||
| AliceState::BtcEarlyRefunded(_)
|
||||
| AliceState::BtcPunished { .. }
|
||||
| AliceState::SafelyAborted => bail!(
|
||||
"Cannot safely abort swap {} because it is in state {} which cannot be safely aborted",
|
||||
|
|
|
@ -418,7 +418,14 @@ async fn init_bitcoin_wallet(
|
|||
let wallet = bitcoin::wallet::WalletBuilder::default()
|
||||
.seed(seed.clone())
|
||||
.network(env_config.bitcoin_network)
|
||||
.electrum_rpc_url(config.bitcoin.electrum_rpc_url.as_str().to_string())
|
||||
.electrum_rpc_urls(
|
||||
config
|
||||
.bitcoin
|
||||
.electrum_rpc_urls
|
||||
.iter()
|
||||
.map(|url| url.as_str().to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
)
|
||||
.persister(bitcoin::wallet::PersisterConfig::SqliteFile {
|
||||
data_dir: config.data.dir.clone(),
|
||||
})
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
pub mod electrum_balancer;
|
||||
pub mod wallet;
|
||||
|
||||
mod cancel;
|
||||
mod early_refund;
|
||||
mod lock;
|
||||
mod punish;
|
||||
mod redeem;
|
||||
|
@ -8,6 +10,7 @@ mod refund;
|
|||
mod timelocks;
|
||||
|
||||
pub use crate::bitcoin::cancel::{CancelTimelock, PunishTimelock, TxCancel};
|
||||
pub use crate::bitcoin::early_refund::TxEarlyRefund;
|
||||
pub use crate::bitcoin::lock::TxLock;
|
||||
pub use crate::bitcoin::punish::TxPunish;
|
||||
pub use crate::bitcoin::redeem::TxRedeem;
|
||||
|
@ -435,6 +438,11 @@ pub enum RpcErrorCode {
|
|||
RpcVerifyAlreadyInChain,
|
||||
/// General error during transaction or block submission
|
||||
RpcVerifyError,
|
||||
/// Invalid address or key. Error code -5. Is throwns when a transaction is not found.
|
||||
/// See:
|
||||
/// - https://github.com/bitcoin/bitcoin/blob/ae024137bda9fe189f4e7ccf26dbaffd44cbbeb6/src/rpc/mempool.cpp#L470-L472
|
||||
/// - https://github.com/bitcoin/bitcoin/blob/ae024137bda9fe189f4e7ccf26dbaffd44cbbeb6/src/rpc/rawtransaction.cpp#L352-L368
|
||||
RpcInvalidAddressOrKey,
|
||||
}
|
||||
|
||||
impl From<RpcErrorCode> for i64 {
|
||||
|
@ -443,11 +451,55 @@ impl From<RpcErrorCode> for i64 {
|
|||
RpcErrorCode::RpcVerifyError => -25,
|
||||
RpcErrorCode::RpcVerifyRejected => -26,
|
||||
RpcErrorCode::RpcVerifyAlreadyInChain => -27,
|
||||
RpcErrorCode::RpcInvalidAddressOrKey => -5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_rpc_error_code(error: &anyhow::Error) -> anyhow::Result<i64> {
|
||||
// First try to extract an Electrum error from a MultiError if present
|
||||
if let Some(multi_error) = error.downcast_ref::<crate::bitcoin::electrum_balancer::MultiError>()
|
||||
{
|
||||
// Try to find the first Electrum error in the MultiError
|
||||
for single_error in multi_error.iter() {
|
||||
if let bdk_electrum::electrum_client::Error::Protocol(serde_json::Value::String(
|
||||
string,
|
||||
)) = single_error
|
||||
{
|
||||
let json = serde_json::from_str(
|
||||
&string
|
||||
.replace("sendrawtransaction RPC error:", "")
|
||||
.replace("daemon error:", ""),
|
||||
)?;
|
||||
|
||||
let json_map = match json {
|
||||
serde_json::Value::Object(map) => map,
|
||||
_ => continue, // Try next error if this one isn't a JSON object
|
||||
};
|
||||
|
||||
let error_code_value = match json_map.get("code") {
|
||||
Some(val) => val,
|
||||
None => continue, // Try next error if no error code field
|
||||
};
|
||||
|
||||
let error_code_number = match error_code_value {
|
||||
serde_json::Value::Number(num) => num,
|
||||
_ => continue, // Try next error if error code isn't a number
|
||||
};
|
||||
|
||||
if let Some(int) = error_code_number.as_i64() {
|
||||
return Ok(int);
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we couldn't extract an RPC error code from any error in the MultiError
|
||||
bail!(
|
||||
"Error is of incorrect variant. We expected an Electrum error, but got: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
// Original logic for direct Electrum errors
|
||||
let string = match error.downcast_ref::<bdk_electrum::electrum_client::Error>() {
|
||||
Some(bdk_electrum::electrum_client::Error::Protocol(serde_json::Value::String(string))) => {
|
||||
string
|
||||
|
@ -458,7 +510,11 @@ pub fn parse_rpc_error_code(error: &anyhow::Error) -> anyhow::Result<i64> {
|
|||
),
|
||||
};
|
||||
|
||||
let json = serde_json::from_str(&string.replace("sendrawtransaction RPC error:", ""))?;
|
||||
let json = serde_json::from_str(
|
||||
&string
|
||||
.replace("sendrawtransaction RPC error:", "")
|
||||
.replace("daemon error:", ""),
|
||||
)?;
|
||||
|
||||
let json_map = match json {
|
||||
serde_json::Value::Object(map) => map,
|
||||
|
@ -643,6 +699,128 @@ mod tests {
|
|||
assert_weight(cancel_transaction, TxCancel::weight().to_wu(), "TxCancel");
|
||||
assert_weight(punish_transaction, TxPunish::weight().to_wu(), "TxPunish");
|
||||
assert_weight(refund_transaction, TxRefund::weight().to_wu(), "TxRefund");
|
||||
|
||||
// Test TxEarlyRefund transaction
|
||||
let early_refund_transaction = alice_state3
|
||||
.signed_early_refund_transaction()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_weight(
|
||||
early_refund_transaction,
|
||||
TxEarlyRefund::weight() as u64,
|
||||
"TxEarlyRefund",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tx_early_refund_can_be_constructed_and_signed() {
|
||||
let alice_wallet = TestWalletBuilder::new(Amount::ONE_BTC.to_sat())
|
||||
.build()
|
||||
.await;
|
||||
let bob_wallet = TestWalletBuilder::new(Amount::ONE_BTC.to_sat())
|
||||
.build()
|
||||
.await;
|
||||
let spending_fee = Amount::from_sat(1_000);
|
||||
let btc_amount = Amount::from_sat(500_000);
|
||||
let xmr_amount = crate::monero::Amount::from_piconero(10000);
|
||||
|
||||
let tx_redeem_fee = alice_wallet
|
||||
.estimate_fee(TxRedeem::weight(), Some(btc_amount))
|
||||
.await
|
||||
.unwrap();
|
||||
let tx_punish_fee = alice_wallet
|
||||
.estimate_fee(TxPunish::weight(), Some(btc_amount))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let refund_address = alice_wallet.new_address().await.unwrap();
|
||||
let punish_address = alice_wallet.new_address().await.unwrap();
|
||||
|
||||
let config = Regtest::get_config();
|
||||
let alice_state0 = alice::State0::new(
|
||||
btc_amount,
|
||||
xmr_amount,
|
||||
config,
|
||||
refund_address.clone(),
|
||||
punish_address,
|
||||
tx_redeem_fee,
|
||||
tx_punish_fee,
|
||||
&mut OsRng,
|
||||
);
|
||||
|
||||
let bob_state0 = bob::State0::new(
|
||||
Uuid::new_v4(),
|
||||
&mut OsRng,
|
||||
btc_amount,
|
||||
xmr_amount,
|
||||
config.bitcoin_cancel_timelock,
|
||||
config.bitcoin_punish_timelock,
|
||||
bob_wallet.new_address().await.unwrap(),
|
||||
config.monero_finality_confirmations,
|
||||
spending_fee,
|
||||
spending_fee,
|
||||
spending_fee,
|
||||
);
|
||||
|
||||
// Complete the state machine up to State3
|
||||
let message0 = bob_state0.next_message();
|
||||
let (_, alice_state1) = alice_state0.receive(message0).unwrap();
|
||||
let alice_message1 = alice_state1.next_message();
|
||||
|
||||
let bob_state1 = bob_state0
|
||||
.receive(&bob_wallet, alice_message1)
|
||||
.await
|
||||
.unwrap();
|
||||
let bob_message2 = bob_state1.next_message();
|
||||
|
||||
let alice_state2 = alice_state1.receive(bob_message2).unwrap();
|
||||
let alice_message3 = alice_state2.next_message();
|
||||
|
||||
let bob_state2 = bob_state1.receive(alice_message3).unwrap();
|
||||
let bob_message4 = bob_state2.next_message();
|
||||
|
||||
let alice_state3 = alice_state2.receive(bob_message4).unwrap();
|
||||
|
||||
// Test TxEarlyRefund construction
|
||||
let tx_early_refund = alice_state3.tx_early_refund();
|
||||
|
||||
// Verify basic properties
|
||||
assert_eq!(tx_early_refund.txid(), tx_early_refund.txid()); // Should be deterministic
|
||||
assert!(tx_early_refund.digest() != Sighash::all_zeros()); // Should have valid digest
|
||||
|
||||
// Test that it can be signed and completed
|
||||
let early_refund_transaction = alice_state3
|
||||
.signed_early_refund_transaction()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// Verify the transaction has expected structure
|
||||
assert_eq!(early_refund_transaction.input.len(), 1); // One input from lock tx
|
||||
assert_eq!(early_refund_transaction.output.len(), 1); // One output to refund address
|
||||
assert_eq!(
|
||||
early_refund_transaction.output[0].script_pubkey,
|
||||
refund_address.script_pubkey()
|
||||
);
|
||||
|
||||
// Verify the input is spending the lock transaction
|
||||
assert_eq!(
|
||||
early_refund_transaction.input[0].previous_output,
|
||||
alice_state3.tx_lock.as_outpoint()
|
||||
);
|
||||
|
||||
// Verify the amount is correct (lock amount minus fee)
|
||||
let expected_amount = alice_state3.tx_lock.lock_amount() - alice_state3.tx_refund_fee;
|
||||
assert_eq!(early_refund_transaction.output[0].value, expected_amount);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tx_early_refund_has_correct_weight() {
|
||||
// TxEarlyRefund should have the same weight as other similar transactions
|
||||
assert_eq!(TxEarlyRefund::weight(), 548);
|
||||
|
||||
// It should be the same as TxRedeem and TxRefund weights since they have similar structure
|
||||
assert_eq!(TxEarlyRefund::weight() as u64, TxRedeem::weight().to_wu());
|
||||
assert_eq!(TxEarlyRefund::weight() as u64, TxRefund::weight().to_wu());
|
||||
}
|
||||
|
||||
// Weights fluctuate because of the length of the signatures. Valid ecdsa
|
||||
|
|
127
swap/src/bitcoin/early_refund.rs
Normal file
127
swap/src/bitcoin/early_refund.rs
Normal file
|
@ -0,0 +1,127 @@
|
|||
use crate::bitcoin;
|
||||
use ::bitcoin::sighash::SighashCache;
|
||||
use ::bitcoin::{secp256k1, ScriptBuf};
|
||||
use ::bitcoin::{sighash::SegwitV0Sighash as Sighash, EcdsaSighashType, Txid};
|
||||
use anyhow::{Context, Result};
|
||||
use bdk_wallet::miniscript::Descriptor;
|
||||
use bitcoin::{Address, Amount, Transaction};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::wallet::Watchable;
|
||||
use super::TxLock;
|
||||
|
||||
const TX_EARLY_REFUND_WEIGHT: usize = 548;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TxEarlyRefund {
|
||||
inner: Transaction,
|
||||
digest: Sighash,
|
||||
lock_output_descriptor: Descriptor<::bitcoin::PublicKey>,
|
||||
watch_script: ScriptBuf,
|
||||
}
|
||||
|
||||
impl TxEarlyRefund {
|
||||
pub fn new(tx_lock: &TxLock, refund_address: &Address, spending_fee: Amount) -> Self {
|
||||
let tx = tx_lock.build_spend_transaction(refund_address, None, spending_fee);
|
||||
|
||||
let digest = SighashCache::new(&tx)
|
||||
.p2wsh_signature_hash(
|
||||
0,
|
||||
&tx_lock
|
||||
.output_descriptor
|
||||
.script_code()
|
||||
.expect("TxLock should have a script code"),
|
||||
tx_lock.lock_amount(),
|
||||
EcdsaSighashType::All,
|
||||
)
|
||||
.expect("sighash");
|
||||
|
||||
Self {
|
||||
inner: tx,
|
||||
digest,
|
||||
lock_output_descriptor: tx_lock.output_descriptor.clone(),
|
||||
watch_script: refund_address.script_pubkey(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn txid(&self) -> Txid {
|
||||
self.inner.compute_txid()
|
||||
}
|
||||
|
||||
pub fn digest(&self) -> Sighash {
|
||||
self.digest
|
||||
}
|
||||
|
||||
pub fn complete(
|
||||
self,
|
||||
tx_early_refund_sig: bitcoin::Signature,
|
||||
a: bitcoin::SecretKey,
|
||||
B: bitcoin::PublicKey,
|
||||
) -> Result<Transaction> {
|
||||
let sig_a = a.sign(self.digest());
|
||||
let sig_b = tx_early_refund_sig;
|
||||
|
||||
self.add_signatures((a.public(), sig_a), (B, sig_b))
|
||||
}
|
||||
|
||||
fn add_signatures(
|
||||
self,
|
||||
(A, sig_a): (bitcoin::PublicKey, bitcoin::Signature),
|
||||
(B, sig_b): (bitcoin::PublicKey, bitcoin::Signature),
|
||||
) -> Result<Transaction> {
|
||||
let satisfier = {
|
||||
let mut satisfier = HashMap::with_capacity(2);
|
||||
|
||||
let A = ::bitcoin::PublicKey {
|
||||
compressed: true,
|
||||
inner: secp256k1::PublicKey::from_slice(&A.0.to_bytes())?,
|
||||
};
|
||||
let B = ::bitcoin::PublicKey {
|
||||
compressed: true,
|
||||
inner: secp256k1::PublicKey::from_slice(&B.0.to_bytes())?,
|
||||
};
|
||||
|
||||
let sig_a = secp256k1::ecdsa::Signature::from_compact(&sig_a.to_bytes())?;
|
||||
let sig_b = secp256k1::ecdsa::Signature::from_compact(&sig_b.to_bytes())?;
|
||||
|
||||
// The order in which these are inserted doesn't matter
|
||||
satisfier.insert(
|
||||
A,
|
||||
::bitcoin::ecdsa::Signature {
|
||||
signature: sig_a,
|
||||
sighash_type: EcdsaSighashType::All,
|
||||
},
|
||||
);
|
||||
satisfier.insert(
|
||||
B,
|
||||
::bitcoin::ecdsa::Signature {
|
||||
signature: sig_b,
|
||||
sighash_type: EcdsaSighashType::All,
|
||||
},
|
||||
);
|
||||
|
||||
satisfier
|
||||
};
|
||||
|
||||
let mut tx_early_refund = self.inner;
|
||||
self.lock_output_descriptor
|
||||
.satisfy(&mut tx_early_refund.input[0], satisfier)
|
||||
.context("Failed to satisfy inputs with given signatures")?;
|
||||
|
||||
Ok(tx_early_refund)
|
||||
}
|
||||
|
||||
pub fn weight() -> usize {
|
||||
TX_EARLY_REFUND_WEIGHT
|
||||
}
|
||||
}
|
||||
|
||||
impl Watchable for TxEarlyRefund {
|
||||
fn id(&self) -> Txid {
|
||||
self.txid()
|
||||
}
|
||||
|
||||
fn script(&self) -> ScriptBuf {
|
||||
self.watch_script.clone()
|
||||
}
|
||||
}
|
1294
swap/src/bitcoin/electrum_balancer.rs
Normal file
1294
swap/src/bitcoin/electrum_balancer.rs
Normal file
File diff suppressed because it is too large
Load diff
|
@ -15,7 +15,7 @@ use std::sync::Arc;
|
|||
|
||||
use super::extract_ecdsa_sig;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TxRefund {
|
||||
inner: Transaction,
|
||||
digest: Sighash,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use crate::bitcoin::{Address, Amount, Transaction};
|
||||
use crate::bitcoin::{parse_rpc_error_code, Address, Amount, RpcErrorCode, Transaction};
|
||||
use crate::cli::api::tauri_bindings::{
|
||||
TauriBackgroundProgress, TauriBitcoinFullScanProgress, TauriBitcoinSyncProgress, TauriEmitter,
|
||||
TauriHandle,
|
||||
|
@ -6,8 +6,9 @@ use crate::cli::api::tauri_bindings::{
|
|||
use crate::seed::Seed;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use bdk_chain::spk_client::{SyncRequest, SyncRequestBuilder};
|
||||
use bdk_chain::CheckPoint;
|
||||
use bdk_electrum::electrum_client::{ElectrumApi, GetHistoryRes};
|
||||
use bdk_electrum::BdkElectrumClient;
|
||||
|
||||
use bdk_wallet::bitcoin::FeeRate;
|
||||
use bdk_wallet::bitcoin::Network;
|
||||
use bdk_wallet::export::FullyNodedExport;
|
||||
|
@ -40,6 +41,7 @@ use tracing::{debug_span, Instrument};
|
|||
|
||||
use super::bitcoin_address::revalidate_network;
|
||||
use super::BlockHeight;
|
||||
use crate::bitcoin::electrum_balancer::ElectrumBalancer;
|
||||
use derive_builder::Builder;
|
||||
|
||||
/// We allow transaction fees of up to 20% of the transferred amount to ensure
|
||||
|
@ -82,8 +84,8 @@ pub struct Wallet<Persister = Connection, C = Client> {
|
|||
|
||||
/// This is our wrapper around a bdk electrum client.
|
||||
pub struct Client {
|
||||
/// The underlying bdk electrum client.
|
||||
electrum: Arc<BdkElectrumClient<bdk_electrum::electrum_client::Client>>,
|
||||
/// The underlying electrum balancer for load balancing across multiple servers.
|
||||
inner: Arc<ElectrumBalancer>,
|
||||
/// The history of transactions for each script.
|
||||
script_history: BTreeMap<ScriptBuf, Vec<GetHistoryRes>>,
|
||||
/// The subscriptions to the status of transactions.
|
||||
|
@ -113,7 +115,7 @@ pub struct Client {
|
|||
pub struct WalletConfig {
|
||||
seed: Seed,
|
||||
network: Network,
|
||||
electrum_rpc_url: String,
|
||||
electrum_rpc_urls: Vec<String>,
|
||||
persister: PersisterConfig,
|
||||
finality_confirmations: u32,
|
||||
target_block: u32,
|
||||
|
@ -133,7 +135,8 @@ impl WalletBuilder {
|
|||
.validate_config()
|
||||
.map_err(|e| anyhow!("Builder validation failed: {e}"))?;
|
||||
|
||||
let client = Client::new(&config.electrum_rpc_url, config.sync_interval)
|
||||
let client = Client::new(&config.electrum_rpc_urls, config.sync_interval)
|
||||
.await
|
||||
.context("Failed to create Electrum client")?;
|
||||
|
||||
match &config.persister {
|
||||
|
@ -352,7 +355,7 @@ impl Wallet {
|
|||
pub async fn with_sqlite(
|
||||
seed: &Seed,
|
||||
network: Network,
|
||||
electrum_rpc_url: &str,
|
||||
electrum_rpc_urls: &[String],
|
||||
data_dir: impl AsRef<Path>,
|
||||
finality_confirmations: u32,
|
||||
target_block: u32,
|
||||
|
@ -370,7 +373,7 @@ impl Wallet {
|
|||
let wallet_exists = wallet_path.exists();
|
||||
|
||||
// Connect to the electrum server.
|
||||
let client = Client::new(electrum_rpc_url, sync_interval)?;
|
||||
let client = Client::new(electrum_rpc_urls, sync_interval).await?;
|
||||
|
||||
// Make sure the wallet directory exists.
|
||||
tokio::fs::create_dir_all(&wallet_dir).await?;
|
||||
|
@ -417,7 +420,7 @@ impl Wallet {
|
|||
pub async fn with_sqlite_in_memory(
|
||||
seed: &Seed,
|
||||
network: Network,
|
||||
electrum_rpc_url: &str,
|
||||
electrum_rpc_urls: &[String],
|
||||
finality_confirmations: u32,
|
||||
target_block: u32,
|
||||
sync_interval: Duration,
|
||||
|
@ -426,7 +429,9 @@ impl Wallet {
|
|||
Self::create_new(
|
||||
seed.derive_extended_private_key(network)?,
|
||||
network,
|
||||
Client::new(electrum_rpc_url, sync_interval).expect("Failed to create electrum client"),
|
||||
Client::new(electrum_rpc_urls, sync_interval)
|
||||
.await
|
||||
.expect("Failed to create electrum client"),
|
||||
|| {
|
||||
bdk_wallet::rusqlite::Connection::open_in_memory()
|
||||
.context("Failed to open in-memory SQLite database")
|
||||
|
@ -512,7 +517,7 @@ impl Wallet {
|
|||
|
||||
let full_scan = wallet.start_full_scan().inspect(callback);
|
||||
|
||||
let full_scan_result = client.electrum.full_scan(
|
||||
let full_scan_response = client.inner.get_any_client().await?.full_scan(
|
||||
full_scan,
|
||||
Self::SCAN_STOP_GAP as usize,
|
||||
Self::SCAN_BATCH_SIZE as usize,
|
||||
|
@ -529,7 +534,7 @@ impl Wallet {
|
|||
.context("Failed to create wallet with persister")?;
|
||||
|
||||
// Apply the full scan result to the wallet
|
||||
wallet.apply_update(full_scan_result)?;
|
||||
wallet.apply_update(full_scan_response)?;
|
||||
wallet.persist(&mut persister)?;
|
||||
|
||||
progress_handle.finish();
|
||||
|
@ -632,15 +637,48 @@ impl Wallet {
|
|||
.await;
|
||||
|
||||
let client = self.electrum_client.lock().await;
|
||||
client
|
||||
.transaction_broadcast(&transaction)
|
||||
let broadcast_results = client
|
||||
.transaction_broadcast_all(&transaction)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("Failed to broadcast Bitcoin {} transaction {}", kind, txid)
|
||||
format!(
|
||||
"Failed to broadcast Bitcoin {} transaction to any server {}",
|
||||
kind, txid
|
||||
)
|
||||
})?;
|
||||
|
||||
// Check if at least one broadcast succeeded
|
||||
let successful_count = broadcast_results.iter().filter(|r| r.is_ok()).count();
|
||||
let total_count = broadcast_results.len();
|
||||
|
||||
if successful_count == 0 {
|
||||
// Collect all errors to create a MultiError
|
||||
let errors: Vec<_> = broadcast_results
|
||||
.into_iter()
|
||||
.filter_map(|result| result.err())
|
||||
.collect();
|
||||
|
||||
let context = format!(
|
||||
"Bitcoin {} transaction {} failed to broadcast on all {} servers",
|
||||
kind, txid, total_count
|
||||
);
|
||||
|
||||
let multi_error = crate::bitcoin::electrum_balancer::MultiError::new(errors, context);
|
||||
return Err(anyhow::Error::from(multi_error));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
%txid, %kind,
|
||||
successful_broadcasts = successful_count,
|
||||
total_servers = total_count,
|
||||
"Published Bitcoin transaction (accepted at {}/{} servers)",
|
||||
successful_count, total_count
|
||||
);
|
||||
|
||||
// The transaction was accepted by the mempool
|
||||
// We know this because otherwise Electrum would have rejected it
|
||||
//
|
||||
|
||||
// Mark the transaction as unconfirmed in the mempool
|
||||
// This ensures it is used to calculate the balance from here on
|
||||
// out
|
||||
|
@ -649,17 +687,17 @@ impl Wallet {
|
|||
.expect("time went backwards")
|
||||
.as_secs();
|
||||
|
||||
let mut wallet = self.wallet.lock().await;
|
||||
let mut persister = self.persister.lock().await;
|
||||
wallet.apply_unconfirmed_txs(vec![(transaction, timestamp)]);
|
||||
wallet.persist(&mut persister)?;
|
||||
|
||||
tracing::info!(%txid, %kind, "Published Bitcoin transaction");
|
||||
{
|
||||
let mut wallet = self.wallet.lock().await;
|
||||
let mut persister = self.persister.lock().await;
|
||||
wallet.apply_unconfirmed_txs(vec![(transaction, timestamp)]);
|
||||
wallet.persist(&mut persister)?;
|
||||
}
|
||||
|
||||
Ok((txid, subscription))
|
||||
}
|
||||
|
||||
pub async fn get_raw_transaction(&self, txid: Txid) -> Result<Arc<Transaction>> {
|
||||
pub async fn get_raw_transaction(&self, txid: Txid) -> Result<Option<Arc<Transaction>>> {
|
||||
self.get_tx(txid)
|
||||
.await
|
||||
.with_context(|| format!("Could not get raw tx with id: {}", txid))
|
||||
|
@ -668,22 +706,28 @@ impl Wallet {
|
|||
// Returns the TxId of the last published Bitcoin transaction
|
||||
pub async fn last_published_txid(&self) -> Result<Txid> {
|
||||
let wallet = self.wallet.lock().await;
|
||||
let txs = wallet.transactions();
|
||||
let mut txs: Vec<_> = txs.collect();
|
||||
txs.sort_by(|tx1, tx2| tx2.chain_position.cmp(&tx1.chain_position));
|
||||
let tx = txs.first().context("No transactions found")?;
|
||||
|
||||
Ok(tx.tx_node.txid)
|
||||
// Get all the transactions sorted by recency
|
||||
let mut txs = wallet.transactions().collect::<Vec<_>>();
|
||||
txs.sort_by(|tx1, tx2| tx2.chain_position.cmp(&tx1.chain_position));
|
||||
|
||||
let last_tx = txs.first().context("No transactions found")?;
|
||||
|
||||
Ok(last_tx.tx_node.txid)
|
||||
}
|
||||
|
||||
pub async fn status_of_script<T>(&self, tx: &T) -> Result<ScriptStatus>
|
||||
where
|
||||
T: Watchable,
|
||||
{
|
||||
self.electrum_client.lock().await.status_of_script(tx, true)
|
||||
self.electrum_client
|
||||
.lock()
|
||||
.await
|
||||
.status_of_script(tx, true)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn subscribe_to(&self, tx: impl Watchable + Send + 'static) -> Subscription {
|
||||
pub async fn subscribe_to(&self, tx: impl Watchable + Send + Sync + 'static) -> Subscription {
|
||||
let txid = tx.id();
|
||||
let script = tx.script();
|
||||
|
||||
|
@ -692,6 +736,7 @@ impl Wallet {
|
|||
.lock()
|
||||
.await
|
||||
.status_of_script(&tx, false)
|
||||
.await
|
||||
{
|
||||
Ok(status) => Some(status),
|
||||
Err(err) => {
|
||||
|
@ -717,6 +762,7 @@ impl Wallet {
|
|||
let new_status = client.lock()
|
||||
.await
|
||||
.status_of_script(&tx, false)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::warn!(%txid, "Failed to get status of script: {:#}", error);
|
||||
ScriptStatus::Retrying
|
||||
|
@ -763,10 +809,11 @@ impl Wallet {
|
|||
}
|
||||
|
||||
/// Get a transaction from the Electrum server or the cache.
|
||||
pub async fn get_tx(&self, txid: Txid) -> Result<Arc<Transaction>> {
|
||||
pub async fn get_tx(&self, txid: Txid) -> Result<Option<Arc<Transaction>>> {
|
||||
let client = self.electrum_client.lock().await;
|
||||
let tx = client
|
||||
.get_tx(txid)
|
||||
.await
|
||||
.context("Failed to get transaction from cache or Electrum server")?;
|
||||
|
||||
Ok(tx)
|
||||
|
@ -780,9 +827,22 @@ impl Wallet {
|
|||
&self,
|
||||
max_num_chunks: u32,
|
||||
batch_size: u32,
|
||||
) -> Vec<SyncRequestBuilder<(bdk_wallet::KeychainKind, u32)>> {
|
||||
let wallet = self.wallet.lock().await;
|
||||
let spks: Vec<_> = wallet.spk_index().revealed_spks(..).collect();
|
||||
) -> Vec<SyncRequestBuilderFactory> {
|
||||
#[allow(clippy::type_complexity)]
|
||||
let (spks, chain_tip): (Vec<((KeychainKind, u32), ScriptBuf)>, CheckPoint) = {
|
||||
let wallet = self.wallet.lock().await;
|
||||
|
||||
let spks = wallet
|
||||
.spk_index()
|
||||
.revealed_spks(..)
|
||||
.map(|(index, spk)| (index, spk.clone()))
|
||||
.collect();
|
||||
|
||||
let chain_tip = wallet.local_chain().tip();
|
||||
|
||||
(spks, chain_tip)
|
||||
};
|
||||
|
||||
let total_spks =
|
||||
u32::try_from(spks.len()).expect("Number of SPKs should not exceed u32::MAX");
|
||||
|
||||
|
@ -806,18 +866,11 @@ impl Wallet {
|
|||
let mut chunks = Vec::new();
|
||||
|
||||
for spk_chunk in spks.chunks(chunk_size as usize) {
|
||||
let spk_chunk = spk_chunk.iter().cloned();
|
||||
|
||||
// Get the chain tip
|
||||
let chain_tip = wallet.local_chain().tip();
|
||||
|
||||
// Create a new SyncRequestBuilder with just the spks of the current chunk
|
||||
// We don't build the request here because the caller might want to add a custom callback
|
||||
let sync_request = SyncRequest::builder()
|
||||
.chain_tip(chain_tip)
|
||||
.spks_with_indexes(spk_chunk);
|
||||
|
||||
chunks.push(sync_request);
|
||||
let factory = SyncRequestBuilderFactory {
|
||||
chain_tip: chain_tip.clone(),
|
||||
spks: spk_chunk.to_vec(),
|
||||
};
|
||||
chunks.push(factory);
|
||||
}
|
||||
|
||||
chunks
|
||||
|
@ -828,13 +881,13 @@ impl Wallet {
|
|||
/// Call the callback with the cumulative progress of the sync
|
||||
pub async fn chunked_sync_with_callback(&self, callback: sync_ext::SyncCallback) -> Result<()> {
|
||||
// Construct the chunks to process
|
||||
let sync_requests = self
|
||||
let sync_request_factories = self
|
||||
.chunked_sync_request(Self::SCAN_CHUNKS, Self::SCAN_BATCH_SIZE)
|
||||
.await;
|
||||
|
||||
tracing::debug!(
|
||||
"Starting to sync Bitcoin wallet with {} concurrent chunks and batch size of {}",
|
||||
sync_requests.len(),
|
||||
sync_request_factories.len(),
|
||||
Self::SCAN_BATCH_SIZE
|
||||
);
|
||||
|
||||
|
@ -844,23 +897,25 @@ impl Wallet {
|
|||
// Assign each sync request:
|
||||
// 1. its individual callback which links back to the CumulativeProgress
|
||||
// 2. its chunk of the SyncRequest
|
||||
let sync_requests = sync_requests
|
||||
let sync_requests = sync_request_factories
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, sync_request)| {
|
||||
.map(|(index, sync_request_factory)| {
|
||||
let callback = cumulative_progress_handle
|
||||
.clone()
|
||||
.chunk_callback(callback.clone(), index as u64);
|
||||
|
||||
(callback, sync_request)
|
||||
(callback, sync_request_factory)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Create a vector of futures to process in parallel
|
||||
let futures = sync_requests.into_iter().map(|(callback, sync_request)| {
|
||||
self.sync_with_custom_callback(sync_request, callback)
|
||||
.in_current_span()
|
||||
});
|
||||
let futures = sync_requests
|
||||
.into_iter()
|
||||
.map(|(callback, sync_request_factory)| {
|
||||
self.sync_with_custom_callback(sync_request_factory, callback)
|
||||
.in_current_span()
|
||||
});
|
||||
|
||||
// Start timer to measure the time taken to sync the wallet
|
||||
let start_time = Instant::now();
|
||||
|
@ -891,34 +946,37 @@ impl Wallet {
|
|||
/// If no sync request is provided, we default to syncing all revealed spks.
|
||||
pub async fn sync_with_custom_callback(
|
||||
&self,
|
||||
sync_request: SyncRequestBuilder<(KeychainKind, u32)>,
|
||||
mut callback: InnerSyncCallback,
|
||||
sync_request_factory: SyncRequestBuilderFactory,
|
||||
callback: InnerSyncCallback,
|
||||
) -> Result<()> {
|
||||
let sync_request = sync_request
|
||||
.inspect(move |_, progress| {
|
||||
callback.call(progress.consumed() as u64, progress.total() as u64);
|
||||
})
|
||||
.build();
|
||||
let callback = Arc::new(SyncMutex::new(callback));
|
||||
|
||||
// We make a copy of the Arc<BdkElectrumClient> because we do not want to block the
|
||||
// other concurrently running syncs.
|
||||
let client = self.electrum_client.lock().await;
|
||||
let electrum_client = client.electrum.clone();
|
||||
drop(client); // We drop the lock to allow others to make a copy of the Arc<_>
|
||||
let sync_response = self
|
||||
.electrum_client
|
||||
.lock()
|
||||
.await
|
||||
.inner
|
||||
.call_async("sync_wallet", move |client| {
|
||||
let sync_request_factory = sync_request_factory.clone();
|
||||
let callback = callback.clone();
|
||||
|
||||
// The .sync(...) method is blocking
|
||||
// We spawn a blocking task to sync the wallet without blocking the tokio runtime
|
||||
let current_span = tracing::Span::current();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
current_span.in_scope(|| {
|
||||
electrum_client.sync(sync_request, Self::SCAN_BATCH_SIZE as usize, true)
|
||||
// Build the sync request
|
||||
let sync_request = sync_request_factory
|
||||
.build()
|
||||
.inspect(move |_, progress| {
|
||||
if let Ok(mut guard) = callback.lock() {
|
||||
guard.call(progress.consumed() as u64, progress.total() as u64);
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
client.sync(sync_request, Self::SCAN_BATCH_SIZE as usize, true)
|
||||
})
|
||||
})
|
||||
.await??;
|
||||
.await?;
|
||||
|
||||
// We only acquire the lock after the long running .sync(...) call has finished
|
||||
let mut wallet = self.wallet.lock().await;
|
||||
wallet.apply_update(res)?;
|
||||
wallet.apply_update(sync_response)?; // Use the full sync_response, not just chain_update
|
||||
|
||||
let mut persister = self.persister.lock().await;
|
||||
wallet.persist(&mut persister)?;
|
||||
|
@ -992,7 +1050,10 @@ impl Wallet {
|
|||
let transaction = self
|
||||
.get_tx(txid)
|
||||
.await
|
||||
.context("Could not find tx in bdk wallet when trying to determine fees")?;
|
||||
.context(
|
||||
"Could not fetch transaction from Electrum server while trying to determine fees",
|
||||
)?
|
||||
.ok_or_else(|| anyhow!("Transaction not found"))?;
|
||||
|
||||
let fee = self.wallet.lock().await.calculate_fee(&transaction)?;
|
||||
|
||||
|
@ -1227,17 +1288,21 @@ where
|
|||
.transpose()
|
||||
.context("Change address is not on the correct network")?;
|
||||
|
||||
let mut wallet = self.wallet.lock().await;
|
||||
let script = address.script_pubkey();
|
||||
|
||||
// Build the transaction with a dummy fee rate
|
||||
// just to figure out the final weight of the transaction
|
||||
// send_to_address(...) takes an absolute fee
|
||||
let mut tx_builder = wallet.build_tx();
|
||||
tx_builder.add_recipient(script.clone(), amount);
|
||||
tx_builder.fee_absolute(Amount::ZERO);
|
||||
let psbt = {
|
||||
let mut wallet = self.wallet.lock().await;
|
||||
|
||||
let psbt = tx_builder.finish()?;
|
||||
// Build the transaction with a dummy fee rate
|
||||
// just to figure out the final weight of the transaction
|
||||
// send_to_address(...) takes an absolute fee
|
||||
let mut tx_builder = wallet.build_tx();
|
||||
|
||||
tx_builder.add_recipient(script.clone(), amount);
|
||||
tx_builder.fee_absolute(Amount::ZERO);
|
||||
|
||||
tx_builder.finish()?
|
||||
};
|
||||
|
||||
let weight = psbt.unsigned_tx.weight();
|
||||
let fee = self.estimate_fee(weight, Some(amount)).await?;
|
||||
|
@ -1492,11 +1557,12 @@ where
|
|||
}
|
||||
|
||||
impl Client {
|
||||
/// Create a new client to this electrum server.
|
||||
pub fn new(electrum_rpc_url: &str, sync_interval: Duration) -> Result<Self> {
|
||||
let client = bdk_electrum::electrum_client::Client::new(electrum_rpc_url)?;
|
||||
/// Create a new client with multiple electrum servers for load balancing.
|
||||
pub async fn new(electrum_rpc_urls: &[String], sync_interval: Duration) -> Result<Self> {
|
||||
let balancer = ElectrumBalancer::new(electrum_rpc_urls.to_vec()).await?;
|
||||
|
||||
Ok(Self {
|
||||
electrum: Arc::new(BdkElectrumClient::new(client)),
|
||||
inner: Arc::new(balancer),
|
||||
script_history: Default::default(),
|
||||
last_sync: Instant::now()
|
||||
.checked_sub(sync_interval)
|
||||
|
@ -1510,7 +1576,7 @@ impl Client {
|
|||
/// Update the client state, if the refresh duration has passed.
|
||||
///
|
||||
/// Optionally force an update even if the sync interval has not passed.
|
||||
pub fn update_state(&mut self, force: bool) -> Result<()> {
|
||||
pub async fn update_state(&mut self, force: bool) -> Result<()> {
|
||||
let now = Instant::now();
|
||||
|
||||
if !force && now.duration_since(self.last_sync) < self.sync_interval {
|
||||
|
@ -1518,8 +1584,8 @@ impl Client {
|
|||
}
|
||||
|
||||
self.last_sync = now;
|
||||
self.update_script_histories()?;
|
||||
self.update_block_height()?;
|
||||
self.update_script_histories().await?;
|
||||
self.update_block_height().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -1529,19 +1595,21 @@ impl Client {
|
|||
/// As opposed to [`update_state`] this function does not
|
||||
/// check the time since the last update before refreshing
|
||||
/// It therefore also does not take a [`force`] parameter
|
||||
pub fn update_state_single(&mut self, script: &impl Watchable) -> Result<()> {
|
||||
self.update_script_history(script)?;
|
||||
self.update_block_height()?;
|
||||
pub async fn update_state_single(&mut self, script: &impl Watchable) -> Result<()> {
|
||||
self.update_script_history(script).await?;
|
||||
self.update_block_height().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the block height.
|
||||
fn update_block_height(&mut self) -> Result<()> {
|
||||
async fn update_block_height(&mut self) -> Result<()> {
|
||||
let latest_block = self
|
||||
.electrum
|
||||
.inner
|
||||
.block_headers_subscribe()
|
||||
.call_async("block_headers_subscribe", |client| {
|
||||
client.inner.block_headers_subscribe()
|
||||
})
|
||||
.await
|
||||
.context("Failed to subscribe to header notifications")?;
|
||||
let latest_block_height = BlockHeight::try_from(latest_block)?;
|
||||
|
||||
|
@ -1557,56 +1625,146 @@ impl Client {
|
|||
}
|
||||
|
||||
/// Update the script histories.
|
||||
fn update_script_histories(&mut self) -> Result<()> {
|
||||
let scripts = self.script_history.keys().map(|s| s.as_script());
|
||||
async fn update_script_histories(&mut self) -> Result<()> {
|
||||
let scripts: Vec<_> = self.script_history.keys().cloned().collect();
|
||||
|
||||
let histories: Vec<Vec<GetHistoryRes>> = self
|
||||
.electrum
|
||||
.inner
|
||||
.batch_script_get_history(scripts)
|
||||
.context("Failed to fetch script histories")?;
|
||||
|
||||
if histories.len() != self.script_history.len() {
|
||||
bail!(
|
||||
"Expected {} script histories, got {}",
|
||||
self.script_history.len(),
|
||||
histories.len()
|
||||
);
|
||||
// No need to do any network request if we have nothing to fetch
|
||||
if scripts.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let scripts = self.script_history.keys().cloned();
|
||||
self.script_history = scripts.zip(histories).collect();
|
||||
// Concurrently fetch the script histories from ALL electrum servers
|
||||
let results = self
|
||||
.inner
|
||||
.join_all("batch_script_get_history", {
|
||||
let scripts = scripts.clone();
|
||||
|
||||
move |client| {
|
||||
let script_refs: Vec<_> = scripts.iter().map(|s| s.as_script()).collect();
|
||||
client.inner.batch_script_get_history(script_refs)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
let successful_results: Vec<Vec<Vec<GetHistoryRes>>> = results
|
||||
.iter()
|
||||
.filter_map(|r| r.as_ref().ok())
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// If we didn't get a single successful request, we have to fail
|
||||
if successful_results.is_empty() {
|
||||
if let Some(Err(e)) = results.into_iter().find(|r| r.is_err()) {
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate through each script we fetched and find the highest
|
||||
// returned entry at any Electrum node
|
||||
for (script_index, script) in scripts.iter().enumerate() {
|
||||
let all_history_for_script: Vec<GetHistoryRes> = successful_results
|
||||
.iter()
|
||||
.filter_map(|server_result| server_result.get(script_index))
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let mut best_history: BTreeMap<Txid, GetHistoryRes> = BTreeMap::new();
|
||||
for item in all_history_for_script {
|
||||
best_history
|
||||
.entry(item.tx_hash)
|
||||
.and_modify(|current| {
|
||||
if item.height > current.height {
|
||||
*current = item.clone();
|
||||
}
|
||||
})
|
||||
.or_insert(item);
|
||||
}
|
||||
|
||||
let final_history: Vec<GetHistoryRes> = best_history.into_values().collect();
|
||||
self.script_history.insert(script.clone(), final_history);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the script history of a single script.
|
||||
pub fn update_script_history(&mut self, script: &impl Watchable) -> Result<()> {
|
||||
let (script, _) = script.script_and_txid();
|
||||
pub async fn update_script_history(&mut self, script: &impl Watchable) -> Result<()> {
|
||||
let (script_buf, _) = script.script_and_txid();
|
||||
let script_clone = script_buf.clone();
|
||||
|
||||
let history = self.electrum.inner.script_get_history(script.as_script())?;
|
||||
// Call all electrum servers in parallel to get script history.
|
||||
let results = self
|
||||
.inner
|
||||
.join_all("script_get_history", move |client| {
|
||||
client.inner.script_get_history(script_clone.as_script())
|
||||
})
|
||||
.await?;
|
||||
|
||||
self.script_history.insert(script, history);
|
||||
// Collect all successful history entries from all servers.
|
||||
let mut all_history_items: Vec<GetHistoryRes> = Vec::new();
|
||||
let mut first_error = None;
|
||||
|
||||
for result in results {
|
||||
match result {
|
||||
Ok(history) => all_history_items.extend(history),
|
||||
Err(e) => {
|
||||
if first_error.is_none() {
|
||||
first_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we got no history items at all, and there was an error, propagate it.
|
||||
// Otherwise, it's valid for a script to have no history.
|
||||
if all_history_items.is_empty() {
|
||||
if let Some(err) = first_error {
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Use a map to find the best (highest confirmation) entry for each transaction.
|
||||
let mut best_history: BTreeMap<Txid, GetHistoryRes> = BTreeMap::new();
|
||||
for item in all_history_items {
|
||||
best_history
|
||||
.entry(item.tx_hash)
|
||||
.and_modify(|current| {
|
||||
if item.height > current.height {
|
||||
*current = item.clone();
|
||||
}
|
||||
})
|
||||
.or_insert(item);
|
||||
}
|
||||
|
||||
let final_history: Vec<GetHistoryRes> = best_history.into_values().collect();
|
||||
|
||||
self.script_history.insert(script_buf, final_history);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Broadcast a transaction to the network.
|
||||
pub fn transaction_broadcast(&self, transaction: &Transaction) -> Result<Arc<Txid>> {
|
||||
// Broadcast the transaction to the network.
|
||||
let res = self
|
||||
.electrum
|
||||
.transaction_broadcast(transaction)
|
||||
.context("Failed to broadcast transaction")?;
|
||||
/// Broadcast a transaction to all known electrum servers in parallel.
|
||||
/// Returns the results from all servers - at least one success indicates successful broadcast.
|
||||
pub async fn transaction_broadcast_all(
|
||||
&self,
|
||||
transaction: &Transaction,
|
||||
) -> Result<Vec<Result<bitcoin::Txid, bdk_electrum::electrum_client::Error>>> {
|
||||
// Broadcast to all electrum servers in parallel
|
||||
let results = self.inner.broadcast_all(transaction.clone()).await?;
|
||||
|
||||
// Add the transaction to the cache.
|
||||
self.electrum.populate_tx_cache(vec![transaction.clone()]);
|
||||
// Add the transaction to the cache if at least one broadcast succeeded
|
||||
if results.iter().any(|r| r.is_ok()) {
|
||||
// Note: Perhaps it is better to only populate caches of the Electrum nodes
|
||||
// that accepted our transaction?
|
||||
self.inner.populate_tx_cache(vec![transaction.clone()]);
|
||||
}
|
||||
|
||||
Ok(Arc::new(res))
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Get the status of a script.
|
||||
pub fn status_of_script(
|
||||
pub async fn status_of_script(
|
||||
&mut self,
|
||||
script: &impl Watchable,
|
||||
force: bool,
|
||||
|
@ -1618,14 +1776,14 @@ impl Client {
|
|||
|
||||
// Immediately refetch the status of the script
|
||||
// when we first subscribe to it.
|
||||
self.update_state_single(script)?;
|
||||
self.update_state_single(script).await?;
|
||||
} else if force {
|
||||
// Immediately refetch the status of the script
|
||||
// when [`force`] is set to true
|
||||
self.update_state_single(script)?;
|
||||
self.update_state_single(script).await?;
|
||||
} else {
|
||||
// Otherwise, don't force a refetch.
|
||||
self.update_state(false)?;
|
||||
self.update_state(false).await?;
|
||||
}
|
||||
|
||||
let history = self.script_history.entry(script_buf).or_default();
|
||||
|
@ -1661,10 +1819,66 @@ impl Client {
|
|||
|
||||
/// Get a transaction from the Electrum server.
|
||||
/// Fails if the transaction is not found.
|
||||
pub fn get_tx(&self, txid: Txid) -> Result<Arc<Transaction>> {
|
||||
self.electrum
|
||||
.fetch_tx(txid)
|
||||
.context("Failed to get transaction from the Electrum server")
|
||||
pub async fn get_tx(&self, txid: Txid) -> Result<Option<Arc<Transaction>>> {
|
||||
match self
|
||||
.inner
|
||||
.call_async_with_multi_error("get_raw_transaction", move |client| {
|
||||
use bitcoin::consensus::Decodable;
|
||||
client.inner.transaction_get_raw(&txid).and_then(|raw| {
|
||||
let mut cursor = std::io::Cursor::new(&raw);
|
||||
bitcoin::Transaction::consensus_decode(&mut cursor).map_err(|e| {
|
||||
bdk_electrum::electrum_client::Error::Protocol(
|
||||
format!("Failed to deserialize transaction: {}", e).into(),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(tx) => {
|
||||
let tx = Arc::new(tx);
|
||||
// Note: Perhaps it is better to only populate caches of the Electrum nodes
|
||||
// that accepted our transaction?
|
||||
self.inner.populate_tx_cache(vec![(*tx).clone()]);
|
||||
Ok(Some(tx))
|
||||
}
|
||||
Err(multi_error) => {
|
||||
// Check if any error indicates the transaction doesn't exist
|
||||
let has_not_found = multi_error.any(|error| {
|
||||
let error_str = error.to_string();
|
||||
|
||||
// Check for specific error patterns that indicate "not found"
|
||||
if error_str.contains("\"code\": Number(-5)")
|
||||
|| error_str.contains("No such mempool or blockchain transaction")
|
||||
|| error_str.contains("missing transaction")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also try to parse the RPC error code if possible
|
||||
let err_anyhow = anyhow::anyhow!(error_str);
|
||||
if let Ok(error_code) = parse_rpc_error_code(&err_anyhow) {
|
||||
if error_code == i64::from(RpcErrorCode::RpcInvalidAddressOrKey) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
});
|
||||
|
||||
if has_not_found {
|
||||
tracing::trace!(
|
||||
txid = %txid,
|
||||
error_count = multi_error.len(),
|
||||
"Transaction not found indicated by one or more Electrum servers"
|
||||
);
|
||||
Ok(None)
|
||||
} else {
|
||||
let err = anyhow::anyhow!(multi_error);
|
||||
Err(err.context("Failed to get transaction from the Electrum server"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate the fee rate to be included in a block at the given offset.
|
||||
|
@ -1672,9 +1886,14 @@ impl Client {
|
|||
/// Calls under the hood: https://developer.bitcoin.org/reference/rpc/estimatesmartfee.html
|
||||
///
|
||||
/// This uses estimatesmartfee of bitcoind
|
||||
pub fn estimate_fee_rate(&self, target_block: u32) -> Result<FeeRate> {
|
||||
pub async fn estimate_fee_rate(&self, target_block: u32) -> Result<FeeRate> {
|
||||
// Get the fee rate in Bitcoin per kilobyte
|
||||
let btc_per_kvb = self.electrum.inner.estimate_fee(target_block as usize)?;
|
||||
let btc_per_kvb = self
|
||||
.inner
|
||||
.call_async("estimate_fee", move |client| {
|
||||
client.inner.estimate_fee(target_block as usize)
|
||||
})
|
||||
.await?;
|
||||
|
||||
// If the fee rate is less than 0, return an error
|
||||
// The Electrum server returns a value <= 0 if it cannot estimate the fee rate.
|
||||
|
@ -1709,16 +1928,18 @@ impl Client {
|
|||
/// Calculates the fee_rate needed to be included in a block at the given offset.
|
||||
/// We calculate how many vMB we are away from the tip of the mempool.
|
||||
/// This method adapts faster to sudden spikes in the mempool.
|
||||
fn estimate_fee_rate_from_histogram(&self, target_block: u32) -> Result<FeeRate> {
|
||||
async fn estimate_fee_rate_from_histogram(&self, target_block: u32) -> Result<FeeRate> {
|
||||
// Assume we want to get into the next block:
|
||||
// We want to be 80% of the block size away from the tip of the mempool.
|
||||
const HISTOGRAM_SAFETY_MARGIN: f32 = 0.8;
|
||||
|
||||
// First we fetch the fee histogram from the Electrum server
|
||||
let fee_histogram = self
|
||||
.electrum
|
||||
.inner
|
||||
.raw_call("mempool.get_fee_histogram", vec![])?;
|
||||
.call_async("get_fee_histogram", move |client| {
|
||||
client.inner.raw_call("mempool.get_fee_histogram", vec![])
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Parse the histogram as array of [fee, vsize] pairs
|
||||
let histogram: Vec<(f64, u64)> = serde_json::from_value(fee_histogram)?;
|
||||
|
@ -1767,7 +1988,10 @@ impl Client {
|
|||
|
||||
/// Get the minimum relay fee rate from the Electrum server.
|
||||
async fn min_relay_fee(&self) -> Result<FeeRate> {
|
||||
let min_relay_btc_per_kvb = self.electrum.inner.relay_fee()?;
|
||||
let min_relay_btc_per_kvb = self
|
||||
.inner
|
||||
.call_async("relay_fee", |client| client.inner.relay_fee())
|
||||
.await?;
|
||||
|
||||
// Convert to sat / kB without ever constructing an Amount from the float
|
||||
// Simply by multiplying the float with the satoshi value of 1 BTC.
|
||||
|
@ -1792,10 +2016,11 @@ impl Client {
|
|||
|
||||
impl EstimateFeeRate for Client {
|
||||
async fn estimate_feerate(&self, target_block: u32) -> Result<FeeRate> {
|
||||
// Run both fee rate estimation methods in sequence
|
||||
// TOOD: Once the Electrum client is async, use tokio::join! here to parallelize the calls
|
||||
let electrum_conservative_fee_rate = self.estimate_fee_rate(target_block);
|
||||
let electrum_histogram_fee_rate = self.estimate_fee_rate_from_histogram(target_block);
|
||||
// Now that the Electrum client methods are async, we can parallelize the calls
|
||||
let (electrum_conservative_fee_rate, electrum_histogram_fee_rate) = tokio::join!(
|
||||
self.estimate_fee_rate(target_block),
|
||||
self.estimate_fee_rate_from_histogram(target_block)
|
||||
);
|
||||
|
||||
match (electrum_conservative_fee_rate, electrum_histogram_fee_rate) {
|
||||
// If both the histogram and conservative fee rate are successful, we use the higher one
|
||||
|
@ -3085,3 +3310,17 @@ TRACE swap::bitcoin::wallet: Bitcoin transaction status changed txid=00000000000
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SyncRequestBuilderFactory {
|
||||
chain_tip: bdk_wallet::chain::CheckPoint,
|
||||
spks: Vec<((KeychainKind, u32), ScriptBuf)>,
|
||||
}
|
||||
|
||||
impl SyncRequestBuilderFactory {
|
||||
fn build(self) -> SyncRequestBuilder<(KeychainKind, u32)> {
|
||||
SyncRequest::builder()
|
||||
.chain_tip(self.chain_tip)
|
||||
.spks_with_indexes(self.spks)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,7 +25,6 @@ use tokio::task::JoinHandle;
|
|||
use tor_rtcompat::tokio::TokioRustlsRuntime;
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing::Level;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::watcher::Watcher;
|
||||
|
@ -336,7 +335,7 @@ impl ContextBuilder {
|
|||
let initialize_bitcoin_wallet = async {
|
||||
match self.bitcoin {
|
||||
Some(bitcoin) => {
|
||||
let (url, target_block) = bitcoin.apply_defaults(self.is_testnet)?;
|
||||
let (urls, target_block) = bitcoin.apply_defaults(self.is_testnet)?;
|
||||
|
||||
let bitcoin_progress_handle = tauri_handle
|
||||
.new_background_process_with_initial_progress(
|
||||
|
@ -345,7 +344,7 @@ impl ContextBuilder {
|
|||
);
|
||||
|
||||
let wallet = init_bitcoin_wallet(
|
||||
url,
|
||||
urls,
|
||||
seed,
|
||||
data_dir,
|
||||
env_config,
|
||||
|
@ -514,7 +513,7 @@ impl fmt::Debug for Context {
|
|||
}
|
||||
|
||||
async fn init_bitcoin_wallet(
|
||||
electrum_rpc_url: Url,
|
||||
electrum_rpc_urls: Vec<String>,
|
||||
seed: &Seed,
|
||||
data_dir: &Path,
|
||||
env_config: EnvConfig,
|
||||
|
@ -524,7 +523,7 @@ async fn init_bitcoin_wallet(
|
|||
let mut builder = bitcoin::wallet::WalletBuilder::default()
|
||||
.seed(seed.clone())
|
||||
.network(env_config.bitcoin_network)
|
||||
.electrum_rpc_url(electrum_rpc_url.as_str().to_string())
|
||||
.electrum_rpc_urls(electrum_rpc_urls)
|
||||
.persister(bitcoin::wallet::PersisterConfig::SqliteFile {
|
||||
data_dir: data_dir.to_path_buf(),
|
||||
})
|
||||
|
|
|
@ -1382,7 +1382,7 @@ impl CheckElectrumNodeArgs {
|
|||
};
|
||||
|
||||
// Check if the node is available
|
||||
let res = wallet::Client::new(url.as_str(), Duration::from_secs(60));
|
||||
let res = wallet::Client::new(&[url.as_str().to_string()], Duration::from_secs(60)).await;
|
||||
|
||||
Ok(CheckElectrumNodeResponse {
|
||||
available: res.is_ok(),
|
||||
|
|
|
@ -12,7 +12,6 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|||
use strum::Display;
|
||||
use tokio::sync::{oneshot, Mutex as TokioMutex};
|
||||
use typeshare::typeshare;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[typeshare]
|
||||
|
@ -442,9 +441,10 @@ pub struct TauriBackgroundProgressHandle<T: Clone> {
|
|||
impl<T: Clone> TauriBackgroundProgressHandle<T> {
|
||||
/// Update the progress of this background process
|
||||
/// Updates after finish() has been called will be ignored
|
||||
#[cfg(feature = "tauri")]
|
||||
pub fn update(&self, progress: T) {
|
||||
// Silently fail if the background process has already been finished
|
||||
if self.is_finished.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
tracing::trace!(%self.id, "Ignoring update to background progress because it has already been finished");
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -456,6 +456,11 @@ impl<T: Clone> TauriBackgroundProgressHandle<T> {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tauri"))]
|
||||
pub fn update(&self, _progress: T) {
|
||||
// Do nothing when tauri is not enabled
|
||||
}
|
||||
|
||||
/// Mark this background process as completed
|
||||
/// All subsequent update() calls will be ignored
|
||||
pub fn finish(&self) {
|
||||
|
@ -626,6 +631,24 @@ pub enum TauriSwapProgressEvent {
|
|||
#[typeshare(serialized_as = "string")]
|
||||
btc_cancel_txid: Txid,
|
||||
},
|
||||
// tx_early_refund has been published but has not been confirmed yet
|
||||
// we can still transition into BtcRefunded from here
|
||||
BtcEarlyRefundPublished {
|
||||
#[typeshare(serialized_as = "string")]
|
||||
btc_early_refund_txid: Txid,
|
||||
},
|
||||
// tx_refund has been published but has not been confirmed yet
|
||||
// we can still transition into BtcEarlyRefunded from here
|
||||
BtcRefundPublished {
|
||||
#[typeshare(serialized_as = "string")]
|
||||
btc_refund_txid: Txid,
|
||||
},
|
||||
// tx_early_refund has been confirmed
|
||||
BtcEarlyRefunded {
|
||||
#[typeshare(serialized_as = "string")]
|
||||
btc_early_refund_txid: Txid,
|
||||
},
|
||||
// tx_refund has been confirmed
|
||||
BtcRefunded {
|
||||
#[typeshare(serialized_as = "string")]
|
||||
btc_refund_txid: Txid,
|
||||
|
@ -679,9 +702,8 @@ pub enum BackgroundRefundState {
|
|||
pub struct TauriSettings {
|
||||
/// The URL of the Monero node e.g `http://xmr.node:18081`
|
||||
pub monero_node_url: Option<String>,
|
||||
/// The URL of the Electrum RPC server e.g `ssl://bitcoin.com:50001`
|
||||
#[typeshare(serialized_as = "string")]
|
||||
pub electrum_rpc_url: Option<Url>,
|
||||
/// The URLs of the Electrum RPC servers e.g `["ssl://bitcoin.com:50001", "ssl://backup.com:50001"]`
|
||||
pub electrum_rpc_urls: Vec<String>,
|
||||
/// Whether to initialize and use a tor client.
|
||||
pub use_tor: bool,
|
||||
}
|
||||
|
|
|
@ -46,12 +46,15 @@ pub async fn cancel(
|
|||
BobState::CancelTimelockExpired(state6) => state6,
|
||||
BobState::BtcRefunded(state6) => state6,
|
||||
BobState::BtcCancelled(state6) => state6,
|
||||
BobState::BtcRefundPublished(state6) => state6,
|
||||
BobState::BtcEarlyRefundPublished(state6) => state6,
|
||||
|
||||
BobState::Started { .. }
|
||||
| BobState::SwapSetupCompleted(_)
|
||||
| BobState::BtcRedeemed(_)
|
||||
| BobState::XmrRedeemed { .. }
|
||||
| BobState::BtcPunished { .. }
|
||||
| BobState::BtcEarlyRefunded { .. }
|
||||
| BobState::SafelyAborted => bail!(
|
||||
"Cannot cancel swap {} because it is in state {} which is not cancellable.",
|
||||
swap_id,
|
||||
|
@ -75,11 +78,12 @@ pub async fn cancel(
|
|||
// 2. The cancel transaction has already been published by Alice
|
||||
Err(err) => {
|
||||
// Check if Alice has already published the cancel transaction while we were absent
|
||||
if let Ok(tx) = state6.check_for_tx_cancel(bitcoin_wallet.as_ref()).await {
|
||||
if let Some(tx) = state6.check_for_tx_cancel(bitcoin_wallet.as_ref()).await? {
|
||||
let state = BobState::BtcCancelled(state6);
|
||||
db.insert_latest_state(swap_id, state.clone().into())
|
||||
.await?;
|
||||
tracing::info!("Alice has already cancelled the swap");
|
||||
|
||||
return Ok((tx.compute_txid(), state));
|
||||
}
|
||||
|
||||
|
@ -140,10 +144,13 @@ pub async fn refund(
|
|||
BobState::EncSigSent(state4) => state4.cancel(),
|
||||
BobState::CancelTimelockExpired(state6) => state6,
|
||||
BobState::BtcCancelled(state6) => state6,
|
||||
BobState::BtcRefunded(state6) => state6,
|
||||
BobState::BtcRefundPublished(state6) => state6,
|
||||
BobState::BtcEarlyRefundPublished(state6) => state6,
|
||||
BobState::Started { .. }
|
||||
| BobState::SwapSetupCompleted(_)
|
||||
| BobState::BtcRedeemed(_)
|
||||
| BobState::BtcRefunded(_)
|
||||
| BobState::BtcEarlyRefunded { .. }
|
||||
| BobState::XmrRedeemed { .. }
|
||||
| BobState::BtcPunished { .. }
|
||||
| BobState::SafelyAborted => bail!(
|
||||
|
|
|
@ -11,10 +11,8 @@ use bitcoin::address::NetworkUnchecked;
|
|||
use libp2p::core::Multiaddr;
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use structopt::{clap, StructOpt};
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::api::request::GetLogsArgs;
|
||||
|
@ -463,8 +461,8 @@ pub struct Monero {
|
|||
|
||||
#[derive(structopt::StructOpt, Debug)]
|
||||
pub struct Bitcoin {
|
||||
#[structopt(long = "electrum-rpc", help = "Provide the Bitcoin Electrum RPC URL")]
|
||||
pub bitcoin_electrum_rpc_url: Option<Url>,
|
||||
#[structopt(long = "electrum-rpc", help = "Provide the Bitcoin Electrum RPC URLs")]
|
||||
pub bitcoin_electrum_rpc_urls: Vec<String>,
|
||||
|
||||
#[structopt(
|
||||
long = "bitcoin-target-block",
|
||||
|
@ -474,13 +472,13 @@ pub struct Bitcoin {
|
|||
}
|
||||
|
||||
impl Bitcoin {
|
||||
pub fn apply_defaults(self, testnet: bool) -> Result<(Url, u16)> {
|
||||
let bitcoin_electrum_rpc_url = if let Some(url) = self.bitcoin_electrum_rpc_url {
|
||||
url
|
||||
pub fn apply_defaults(self, testnet: bool) -> Result<(Vec<String>, u16)> {
|
||||
let bitcoin_electrum_rpc_urls = if !self.bitcoin_electrum_rpc_urls.is_empty() {
|
||||
self.bitcoin_electrum_rpc_urls
|
||||
} else if testnet {
|
||||
Url::from_str(DEFAULT_ELECTRUM_RPC_URL_TESTNET)?
|
||||
vec![DEFAULT_ELECTRUM_RPC_URL_TESTNET.to_string()]
|
||||
} else {
|
||||
Url::from_str(DEFAULT_ELECTRUM_RPC_URL)?
|
||||
vec![DEFAULT_ELECTRUM_RPC_URL.to_string()]
|
||||
};
|
||||
|
||||
let bitcoin_target_block = if let Some(target_block) = self.bitcoin_target_block {
|
||||
|
@ -491,7 +489,7 @@ impl Bitcoin {
|
|||
DEFAULT_BITCOIN_CONFIRMATION_TARGET
|
||||
};
|
||||
|
||||
Ok((bitcoin_electrum_rpc_url, bitcoin_target_block))
|
||||
Ok((bitcoin_electrum_rpc_urls, bitcoin_target_block))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -33,14 +33,41 @@ pub fn init(
|
|||
tauri_handle: Option<TauriHandle>,
|
||||
trace_stdout: bool,
|
||||
) -> Result<()> {
|
||||
let ALL_CRATES: Vec<&str> = vec![
|
||||
"swap",
|
||||
"asb",
|
||||
let TOR_CRATES: Vec<&str> = vec!["arti"];
|
||||
|
||||
let LIBP2P_CRATES: Vec<&str> = vec![
|
||||
// Main libp2p crates
|
||||
"libp2p",
|
||||
"libp2p_swarm",
|
||||
"libp2p_core",
|
||||
"libp2p_tcp",
|
||||
"libp2p_noise",
|
||||
"libp2p_community_tor",
|
||||
"unstoppableswap-gui-rs",
|
||||
"arti",
|
||||
// Specific libp2p module targets that appear in logs
|
||||
"libp2p_core::transport",
|
||||
"libp2p_core::transport::choice",
|
||||
"libp2p_core::transport::dummy",
|
||||
"libp2p_swarm::connection",
|
||||
"libp2p_swarm::dial",
|
||||
"libp2p_tcp::transport",
|
||||
"libp2p_noise::protocol",
|
||||
"libp2p_identify",
|
||||
"libp2p_ping",
|
||||
"libp2p_request_response",
|
||||
"libp2p_kad",
|
||||
"libp2p_dns",
|
||||
"libp2p_yamux",
|
||||
"libp2p_quic",
|
||||
"libp2p_websocket",
|
||||
"libp2p_relay",
|
||||
"libp2p_autonat",
|
||||
"libp2p_mdns",
|
||||
"libp2p_gossipsub",
|
||||
"libp2p_rendezvous",
|
||||
"libp2p_dcutr",
|
||||
];
|
||||
let OUR_CRATES: Vec<&str> = vec!["swap", "asb"];
|
||||
|
||||
let OUR_CRATES: Vec<&str> = vec!["swap", "asb", "unstoppableswap-gui-rs"];
|
||||
|
||||
// General log file for non-verbose logs
|
||||
let file_appender: RollingFileAppender = tracing_appender::rolling::never(&dir, "swap-all.log");
|
||||
|
@ -66,15 +93,20 @@ pub fn init(
|
|||
.with_filter(env_filter(level_filter, OUR_CRATES.clone())?);
|
||||
|
||||
// Layer for writing to the verbose log file
|
||||
// Crates: swap, asb, libp2p_community_tor, unstoppableswap-gui-rs, arti (all relevant crates)
|
||||
// Level: TRACE
|
||||
// Crates: All crates with different levels (libp2p at INFO+, others at TRACE)
|
||||
// Level: TRACE for our crates, INFO for libp2p, TRACE for tor
|
||||
let tracing_file_layer = fmt::layer()
|
||||
.with_writer(tracing_file_appender)
|
||||
.with_ansi(false)
|
||||
.with_timer(UtcTime::rfc_3339())
|
||||
.with_target(false)
|
||||
.json()
|
||||
.with_filter(env_filter(LevelFilter::TRACE, ALL_CRATES.clone())?);
|
||||
.with_filter(env_filter_with_libp2p_info(
|
||||
LevelFilter::TRACE,
|
||||
OUR_CRATES.clone(),
|
||||
LIBP2P_CRATES.clone(),
|
||||
TOR_CRATES.clone(),
|
||||
)?);
|
||||
|
||||
// Layer for writing to the terminal
|
||||
// Crates: swap, asb
|
||||
|
@ -87,20 +119,30 @@ pub fn init(
|
|||
.with_target(false);
|
||||
|
||||
// Layer for writing to the Tauri guest. This will be displayed in the GUI.
|
||||
// Crates: swap, asb, libp2p_community_tor, unstoppableswap-gui-rs, arti
|
||||
// Level: Passed in
|
||||
// Crates: All crates with libp2p at INFO+ level
|
||||
// Level: Passed in for our crates, INFO for libp2p
|
||||
let tauri_layer = fmt::layer()
|
||||
.with_writer(TauriWriter::new(tauri_handle))
|
||||
.with_ansi(false)
|
||||
.with_timer(UtcTime::rfc_3339())
|
||||
.with_target(true)
|
||||
.json()
|
||||
.with_filter(env_filter(level_filter, ALL_CRATES.clone())?);
|
||||
.with_filter(env_filter_with_libp2p_info(
|
||||
level_filter,
|
||||
OUR_CRATES.clone(),
|
||||
LIBP2P_CRATES.clone(),
|
||||
TOR_CRATES.clone(),
|
||||
)?);
|
||||
|
||||
// If trace_stdout is true, we log all messages to the terminal
|
||||
// Otherwise, we only log the bare minimum
|
||||
let terminal_layer_env_filter = match trace_stdout {
|
||||
true => env_filter(LevelFilter::TRACE, ALL_CRATES.clone())?,
|
||||
true => env_filter_with_libp2p_info(
|
||||
LevelFilter::TRACE,
|
||||
OUR_CRATES.clone(),
|
||||
LIBP2P_CRATES.clone(),
|
||||
TOR_CRATES.clone(),
|
||||
)?,
|
||||
false => env_filter(level_filter, OUR_CRATES.clone())?,
|
||||
};
|
||||
let final_terminal_layer = match format {
|
||||
|
@ -142,6 +184,37 @@ fn env_filter(level_filter: LevelFilter, crates: Vec<&str>) -> Result<EnvFilter>
|
|||
Ok(filter)
|
||||
}
|
||||
|
||||
/// This function controls which crate's logs actually get logged and from which level, with libp2p crates at INFO level or higher.
|
||||
fn env_filter_with_libp2p_info(
|
||||
level_filter: LevelFilter,
|
||||
our_crates: Vec<&str>,
|
||||
libp2p_crates: Vec<&str>,
|
||||
tor_crates: Vec<&str>,
|
||||
) -> Result<EnvFilter> {
|
||||
let mut filter = EnvFilter::from_default_env();
|
||||
|
||||
// Add directives for each crate in the provided list
|
||||
for crate_name in our_crates {
|
||||
filter = filter.add_directive(Directive::from_str(&format!(
|
||||
"{}={}",
|
||||
crate_name, &level_filter
|
||||
))?);
|
||||
}
|
||||
|
||||
for crate_name in libp2p_crates {
|
||||
filter = filter.add_directive(Directive::from_str(&format!("{}=INFO", crate_name))?);
|
||||
}
|
||||
|
||||
for crate_name in tor_crates {
|
||||
filter = filter.add_directive(Directive::from_str(&format!(
|
||||
"{}={}",
|
||||
crate_name, &level_filter
|
||||
))?);
|
||||
}
|
||||
|
||||
Ok(filter)
|
||||
}
|
||||
|
||||
/// A writer that forwards tracing log messages to the tauri guest.
|
||||
#[derive(Clone)]
|
||||
pub struct TauriWriter {
|
||||
|
|
|
@ -60,6 +60,9 @@ pub enum Alice {
|
|||
transfer_proof: TransferProof,
|
||||
state3: alice::State3,
|
||||
},
|
||||
BtcEarlyRefundable {
|
||||
state3: alice::State3,
|
||||
},
|
||||
BtcRefunded {
|
||||
monero_wallet_restore_blockheight: BlockHeight,
|
||||
transfer_proof: TransferProof,
|
||||
|
@ -75,6 +78,7 @@ pub enum AliceEndState {
|
|||
SafelyAborted,
|
||||
BtcRedeemed,
|
||||
XmrRefunded,
|
||||
BtcEarlyRefunded { state3: alice::State3 },
|
||||
BtcPunished { state3: alice::State3 },
|
||||
}
|
||||
|
||||
|
@ -154,6 +158,12 @@ impl From<AliceState> for Alice {
|
|||
spend_key,
|
||||
state3: state3.as_ref().clone(),
|
||||
},
|
||||
AliceState::BtcEarlyRefundable { state3 } => Alice::BtcEarlyRefundable {
|
||||
state3: state3.as_ref().clone(),
|
||||
},
|
||||
AliceState::BtcEarlyRefunded(state3) => Alice::Done(AliceEndState::BtcEarlyRefunded {
|
||||
state3: state3.as_ref().clone(),
|
||||
}),
|
||||
AliceState::BtcPunishable {
|
||||
monero_wallet_restore_blockheight,
|
||||
transfer_proof,
|
||||
|
@ -254,7 +264,6 @@ impl From<Alice> for AliceState {
|
|||
transfer_proof,
|
||||
state3: Box::new(state3),
|
||||
},
|
||||
|
||||
Alice::BtcPunishable {
|
||||
monero_wallet_restore_blockheight,
|
||||
transfer_proof,
|
||||
|
@ -275,6 +284,9 @@ impl From<Alice> for AliceState {
|
|||
spend_key,
|
||||
state3: Box::new(state3),
|
||||
},
|
||||
Alice::BtcEarlyRefundable { state3 } => AliceState::BtcEarlyRefundable {
|
||||
state3: Box::new(state3),
|
||||
},
|
||||
Alice::Done(end_state) => match end_state {
|
||||
AliceEndState::SafelyAborted => AliceState::SafelyAborted,
|
||||
AliceEndState::BtcRedeemed => AliceState::BtcRedeemed,
|
||||
|
@ -282,6 +294,9 @@ impl From<Alice> for AliceState {
|
|||
AliceEndState::BtcPunished { state3 } => AliceState::BtcPunished {
|
||||
state3: Box::new(state3),
|
||||
},
|
||||
AliceEndState::BtcEarlyRefunded { state3 } => {
|
||||
AliceState::BtcEarlyRefunded(Box::new(state3))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -308,6 +323,7 @@ impl fmt::Display for Alice {
|
|||
Alice::BtcCancelled { .. } => f.write_str("Bitcoin cancel transaction published"),
|
||||
Alice::BtcPunishable { .. } => f.write_str("Bitcoin punishable"),
|
||||
Alice::BtcRefunded { .. } => f.write_str("Monero refundable"),
|
||||
Alice::BtcEarlyRefundable { .. } => f.write_str("Bitcoin early refundable"),
|
||||
Alice::Done(end_state) => write!(f, "Done: {}", end_state),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,6 +39,8 @@ pub enum Bob {
|
|||
BtcRedeemed(bob::State5),
|
||||
CancelTimelockExpired(bob::State6),
|
||||
BtcCancelled(bob::State6),
|
||||
BtcRefundPublished(bob::State6),
|
||||
BtcEarlyRefundPublished(bob::State6),
|
||||
Done(BobEndState),
|
||||
}
|
||||
|
||||
|
@ -47,6 +49,7 @@ pub enum BobEndState {
|
|||
SafelyAborted,
|
||||
XmrRedeemed { tx_lock_id: bitcoin::Txid },
|
||||
BtcRefunded(Box<bob::State6>),
|
||||
BtcEarlyRefunded(Box<bob::State6>),
|
||||
}
|
||||
|
||||
impl From<BobState> for Bob {
|
||||
|
@ -83,11 +86,16 @@ impl From<BobState> for Bob {
|
|||
BobState::BtcRedeemed(state5) => Bob::BtcRedeemed(state5),
|
||||
BobState::CancelTimelockExpired(state6) => Bob::CancelTimelockExpired(state6),
|
||||
BobState::BtcCancelled(state6) => Bob::BtcCancelled(state6),
|
||||
BobState::BtcRefundPublished(state6) => Bob::BtcRefundPublished(state6),
|
||||
BobState::BtcEarlyRefundPublished(state6) => Bob::BtcEarlyRefundPublished(state6),
|
||||
BobState::BtcPunished { state, tx_lock_id } => Bob::BtcPunished { state, tx_lock_id },
|
||||
BobState::BtcRefunded(state6) => Bob::Done(BobEndState::BtcRefunded(Box::new(state6))),
|
||||
BobState::XmrRedeemed { tx_lock_id } => {
|
||||
Bob::Done(BobEndState::XmrRedeemed { tx_lock_id })
|
||||
}
|
||||
BobState::BtcEarlyRefunded(state6) => {
|
||||
Bob::Done(BobEndState::BtcEarlyRefunded(Box::new(state6)))
|
||||
}
|
||||
BobState::SafelyAborted => Bob::Done(BobEndState::SafelyAborted),
|
||||
}
|
||||
}
|
||||
|
@ -127,11 +135,14 @@ impl From<Bob> for BobState {
|
|||
Bob::BtcRedeemed(state5) => BobState::BtcRedeemed(state5),
|
||||
Bob::CancelTimelockExpired(state6) => BobState::CancelTimelockExpired(state6),
|
||||
Bob::BtcCancelled(state6) => BobState::BtcCancelled(state6),
|
||||
Bob::BtcRefundPublished(state6) => BobState::BtcRefundPublished(state6),
|
||||
Bob::BtcEarlyRefundPublished(state6) => BobState::BtcEarlyRefundPublished(state6),
|
||||
Bob::BtcPunished { state, tx_lock_id } => BobState::BtcPunished { state, tx_lock_id },
|
||||
Bob::Done(end_state) => match end_state {
|
||||
BobEndState::SafelyAborted => BobState::SafelyAborted,
|
||||
BobEndState::XmrRedeemed { tx_lock_id } => BobState::XmrRedeemed { tx_lock_id },
|
||||
BobEndState::BtcRefunded(state6) => BobState::BtcRefunded(*state6),
|
||||
BobEndState::BtcEarlyRefunded(state6) => BobState::BtcEarlyRefunded(*state6),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -149,6 +160,8 @@ impl fmt::Display for Bob {
|
|||
Bob::XmrLocked { .. } => f.write_str("Monero locked"),
|
||||
Bob::CancelTimelockExpired(_) => f.write_str("Cancel timelock is expired"),
|
||||
Bob::BtcCancelled(_) => f.write_str("Bitcoin refundable"),
|
||||
Bob::BtcRefundPublished { .. } => f.write_str("Bitcoin refund published"),
|
||||
Bob::BtcEarlyRefundPublished { .. } => f.write_str("Bitcoin early refund published"),
|
||||
Bob::BtcRedeemed(_) => f.write_str("Monero redeemable"),
|
||||
Bob::Done(end_state) => write!(f, "Done: {}", end_state),
|
||||
Bob::EncSigSent { .. } => f.write_str("Encrypted signature sent"),
|
||||
|
|
|
@ -16,6 +16,8 @@ pub struct Config {
|
|||
pub bitcoin_network: bitcoin::Network,
|
||||
pub monero_avg_block_time: Duration,
|
||||
pub monero_finality_confirmations: u64,
|
||||
// If Alice does manage to lock her Monero within this timeout, she will initiate an early refund of the Bitcoin.
|
||||
pub monero_lock_retry_timeout: Duration,
|
||||
#[serde(with = "monero_network")]
|
||||
pub monero_network: monero::Network,
|
||||
}
|
||||
|
@ -54,6 +56,11 @@ impl GetConfig for Mainnet {
|
|||
bitcoin_punish_timelock: PunishTimelock::new(144),
|
||||
bitcoin_network: bitcoin::Network::Bitcoin,
|
||||
monero_avg_block_time: 2.std_minutes(),
|
||||
// If Alice has enough funds and an internet connection,
|
||||
// she should be able to lock her Monero within 5 minutes
|
||||
// One issue is that we do not do output management in a good way right now
|
||||
// which is why we sometimes have to wait for 20 minutes for funds to become spendable
|
||||
monero_lock_retry_timeout: 22.std_minutes(),
|
||||
monero_finality_confirmations: 10,
|
||||
monero_network: monero::Network::Mainnet,
|
||||
}
|
||||
|
@ -71,6 +78,7 @@ impl GetConfig for Testnet {
|
|||
bitcoin_punish_timelock: PunishTimelock::new(24),
|
||||
bitcoin_network: bitcoin::Network::Testnet,
|
||||
monero_avg_block_time: 2.std_minutes(),
|
||||
monero_lock_retry_timeout: 25.std_minutes(),
|
||||
monero_finality_confirmations: 10,
|
||||
monero_network: monero::Network::Stagenet,
|
||||
}
|
||||
|
@ -88,6 +96,7 @@ impl GetConfig for Regtest {
|
|||
bitcoin_punish_timelock: PunishTimelock::new(50),
|
||||
bitcoin_network: bitcoin::Network::Regtest,
|
||||
monero_avg_block_time: 1.std_seconds(),
|
||||
monero_lock_retry_timeout: 1.std_minutes(),
|
||||
monero_finality_confirmations: 10,
|
||||
monero_network: monero::Network::Mainnet, // yes this is strange
|
||||
}
|
||||
|
|
|
@ -283,6 +283,11 @@ impl Wallet {
|
|||
Ok(self.inner.get_height().await?)
|
||||
}
|
||||
|
||||
/// Checks if the wallet-rpc is alive by checking if the version is available
|
||||
pub async fn is_alive(&self) -> Result<bool> {
|
||||
Ok(self.inner.get_version().await.is_ok())
|
||||
}
|
||||
|
||||
pub fn get_main_address(&self) -> Address {
|
||||
self.main_address
|
||||
}
|
||||
|
@ -326,6 +331,12 @@ impl Wallet {
|
|||
}
|
||||
unreachable!("Loop should have returned by now");
|
||||
}
|
||||
|
||||
pub async fn stop(&self) -> Result<()> {
|
||||
self.inner.stop_wallet().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait until the specified transfer has been completed or failed.
|
||||
|
|
|
@ -176,7 +176,7 @@ async fn choose_monero_daemon(network: Network) -> Result<MoneroDaemon, Error> {
|
|||
return Ok(daemon.clone());
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, %daemon, "Failed to connect to Monero daemon");
|
||||
tracing::debug!(?err, %daemon, "Failed to connect to Monero daemon");
|
||||
continue;
|
||||
}
|
||||
Ok(false) => continue,
|
||||
|
|
|
@ -74,6 +74,7 @@ pub struct Message3 {
|
|||
pub struct Message4 {
|
||||
tx_punish_sig: bitcoin::Signature,
|
||||
tx_cancel_sig: bitcoin::Signature,
|
||||
tx_early_refund_sig: bitcoin::Signature,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use crate::bitcoin::{
|
||||
current_epoch, CancelTimelock, ExpiredTimelocks, PunishTimelock, Transaction, TxCancel,
|
||||
TxPunish, TxRedeem, TxRefund, Txid,
|
||||
TxEarlyRefund, TxPunish, TxRedeem, TxRefund, Txid,
|
||||
};
|
||||
use crate::env::Config;
|
||||
use crate::monero::wallet::{watch_for_transfer, TransferRequest, WatchRequest};
|
||||
|
@ -29,6 +29,9 @@ pub enum AliceState {
|
|||
BtcLocked {
|
||||
state3: Box<State3>,
|
||||
},
|
||||
BtcEarlyRefundable {
|
||||
state3: Box<State3>,
|
||||
},
|
||||
XmrLockTransactionSent {
|
||||
monero_wallet_restore_blockheight: BlockHeight,
|
||||
transfer_proof: TransferProof,
|
||||
|
@ -59,6 +62,7 @@ pub enum AliceState {
|
|||
transfer_proof: TransferProof,
|
||||
state3: Box<State3>,
|
||||
},
|
||||
BtcEarlyRefunded(Box<State3>),
|
||||
BtcRefunded {
|
||||
monero_wallet_restore_blockheight: BlockHeight,
|
||||
transfer_proof: TransferProof,
|
||||
|
@ -107,6 +111,8 @@ impl fmt::Display for AliceState {
|
|||
AliceState::BtcPunishable { .. } => write!(f, "btc is punishable"),
|
||||
AliceState::XmrRefunded => write!(f, "xmr is refunded"),
|
||||
AliceState::CancelTimelockExpired { .. } => write!(f, "cancel timelock is expired"),
|
||||
AliceState::BtcEarlyRefundable { .. } => write!(f, "btc is early refundable"),
|
||||
AliceState::BtcEarlyRefunded(_) => write!(f, "btc is early refunded"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -334,6 +340,7 @@ impl State2 {
|
|||
}
|
||||
|
||||
pub fn receive(self, msg: Message4) -> Result<State3> {
|
||||
// Create the TxCancel transaction ourself
|
||||
let tx_cancel = bitcoin::TxCancel::new(
|
||||
&self.tx_lock,
|
||||
self.cancel_timelock,
|
||||
|
@ -341,17 +348,31 @@ impl State2 {
|
|||
self.B,
|
||||
self.tx_cancel_fee,
|
||||
)?;
|
||||
|
||||
// Check if the provided signature by Bob is valid for the transaction
|
||||
bitcoin::verify_sig(&self.B, &tx_cancel.digest(), &msg.tx_cancel_sig)
|
||||
.context("Failed to verify cancel transaction")?;
|
||||
|
||||
// Create the TxPunish transaction ourself
|
||||
let tx_punish = bitcoin::TxPunish::new(
|
||||
&tx_cancel,
|
||||
&self.punish_address,
|
||||
self.punish_timelock,
|
||||
self.tx_punish_fee,
|
||||
);
|
||||
|
||||
// Check if the provided signature by Bob is valid for the transaction
|
||||
bitcoin::verify_sig(&self.B, &tx_punish.digest(), &msg.tx_punish_sig)
|
||||
.context("Failed to verify punish transaction")?;
|
||||
|
||||
// Create the TxEarlyRefund transaction ourself
|
||||
let tx_early_refund =
|
||||
bitcoin::TxEarlyRefund::new(&self.tx_lock, &self.refund_address, self.tx_refund_fee);
|
||||
|
||||
// Check if the provided signature by Bob is valid for the transaction
|
||||
bitcoin::verify_sig(&self.B, &tx_early_refund.digest(), &msg.tx_early_refund_sig)
|
||||
.context("Failed to verify early refund transaction")?;
|
||||
|
||||
Ok(State3 {
|
||||
a: self.a,
|
||||
B: self.B,
|
||||
|
@ -369,6 +390,7 @@ impl State2 {
|
|||
tx_lock: self.tx_lock,
|
||||
tx_punish_sig_bob: msg.tx_punish_sig,
|
||||
tx_cancel_sig_bob: msg.tx_cancel_sig,
|
||||
tx_early_refund_sig_bob: msg.tx_early_refund_sig.into(),
|
||||
tx_redeem_fee: self.tx_redeem_fee,
|
||||
tx_punish_fee: self.tx_punish_fee,
|
||||
tx_refund_fee: self.tx_refund_fee,
|
||||
|
@ -399,6 +421,17 @@ pub struct State3 {
|
|||
pub tx_lock: bitcoin::TxLock,
|
||||
tx_punish_sig_bob: bitcoin::Signature,
|
||||
tx_cancel_sig_bob: bitcoin::Signature,
|
||||
/// This field was added in this pull request:
|
||||
/// https://github.com/UnstoppableSwap/core/pull/344
|
||||
///
|
||||
/// Previously this did not exist. To avoid deserialization failing for
|
||||
/// older swaps we default it to None.
|
||||
///
|
||||
/// The signature is not essential for the protocol to work. It is used optionally
|
||||
/// to allow Alice to refund the Bitcoin early. If it is not present, Bob will have
|
||||
/// to wait for the timelock to expire.
|
||||
#[serde(default)]
|
||||
tx_early_refund_sig_bob: Option<bitcoin::Signature>,
|
||||
#[serde(with = "::bitcoin::amount::serde::as_sat")]
|
||||
tx_redeem_fee: bitcoin::Amount,
|
||||
#[serde(with = "::bitcoin::amount::serde::as_sat")]
|
||||
|
@ -477,6 +510,10 @@ impl State3 {
|
|||
TxRedeem::new(&self.tx_lock, &self.redeem_address, self.tx_redeem_fee)
|
||||
}
|
||||
|
||||
pub fn tx_early_refund(&self) -> TxEarlyRefund {
|
||||
bitcoin::TxEarlyRefund::new(&self.tx_lock, &self.refund_address, self.tx_refund_fee)
|
||||
}
|
||||
|
||||
pub fn extract_monero_private_key(
|
||||
&self,
|
||||
published_refund_tx: Arc<bitcoin::Transaction>,
|
||||
|
@ -492,18 +529,26 @@ impl State3 {
|
|||
pub async fn check_for_tx_cancel(
|
||||
&self,
|
||||
bitcoin_wallet: &bitcoin::Wallet,
|
||||
) -> Result<Arc<Transaction>> {
|
||||
) -> Result<Option<Arc<Transaction>>> {
|
||||
let tx_cancel = self.tx_cancel();
|
||||
let tx = bitcoin_wallet.get_raw_transaction(tx_cancel.txid()).await?;
|
||||
let tx = bitcoin_wallet
|
||||
.get_raw_transaction(tx_cancel.txid())
|
||||
.await
|
||||
.context("Failed to check for existence of tx_cancel")?;
|
||||
|
||||
Ok(tx)
|
||||
}
|
||||
|
||||
pub async fn fetch_tx_refund(
|
||||
&self,
|
||||
bitcoin_wallet: &bitcoin::Wallet,
|
||||
) -> Result<Arc<Transaction>> {
|
||||
) -> Result<Option<Arc<Transaction>>> {
|
||||
let tx_refund = self.tx_refund();
|
||||
let tx = bitcoin_wallet.get_raw_transaction(tx_refund.txid()).await?;
|
||||
let tx = bitcoin_wallet
|
||||
.get_raw_transaction(tx_refund.txid())
|
||||
.await
|
||||
.context("Failed to fetch Bitcoin refund transaction")?;
|
||||
|
||||
Ok(tx)
|
||||
}
|
||||
|
||||
|
@ -578,6 +623,22 @@ impl State3 {
|
|||
.context("Failed to complete Bitcoin punish transaction")
|
||||
}
|
||||
|
||||
/// Construct tx_early_refund, sign it with Bob's signature and our own.
|
||||
/// If we do not have a Bob's signature stored, we return None.
|
||||
pub fn signed_early_refund_transaction(&self) -> Option<Result<bitcoin::Transaction>> {
|
||||
let tx_early_refund = self.tx_early_refund();
|
||||
|
||||
if let Some(signature) = &self.tx_early_refund_sig_bob {
|
||||
let tx = tx_early_refund
|
||||
.complete(signature.clone(), self.a.clone(), self.B)
|
||||
.context("Failed to complete Bitcoin early refund transaction");
|
||||
|
||||
Some(tx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn tx_punish(&self) -> TxPunish {
|
||||
bitcoin::TxPunish::new(
|
||||
&self.tx_cancel(),
|
||||
|
@ -586,6 +647,27 @@ impl State3 {
|
|||
self.tx_punish_fee,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn watch_for_btc_tx_refund(
|
||||
&self,
|
||||
bitcoin_wallet: &bitcoin::Wallet,
|
||||
) -> Result<monero::PrivateKey> {
|
||||
let tx_refund_status = bitcoin_wallet.subscribe_to(self.tx_refund()).await;
|
||||
|
||||
tx_refund_status
|
||||
.wait_until_seen()
|
||||
.await
|
||||
.context("Failed to monitor refund transaction")?;
|
||||
|
||||
let published_refund_tx = bitcoin_wallet
|
||||
.get_raw_transaction(self.tx_refund().txid())
|
||||
.await?
|
||||
.context("Bitcoin refund transaction not found even though we saw it in the mempool previously. Maybe our Electrum server has cleared its mempool?")?;
|
||||
|
||||
let spend_key = self.extract_monero_private_key(published_refund_tx)?;
|
||||
|
||||
Ok(spend_key)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ReservesMonero {
|
||||
|
|
|
@ -96,6 +96,7 @@ where
|
|||
}
|
||||
AliceState::BtcLockTransactionSeen { state3 } => {
|
||||
let tx_lock_status = bitcoin_wallet.subscribe_to(state3.tx_lock.clone()).await;
|
||||
|
||||
match timeout(
|
||||
env_config.bitcoin_lock_confirmed_timeout,
|
||||
tx_lock_status.wait_until_final(),
|
||||
|
@ -108,7 +109,8 @@ where
|
|||
minutes = %env_config.bitcoin_lock_confirmed_timeout.as_secs_f64() / 60.0,
|
||||
"TxLock lock did not get enough confirmations in time",
|
||||
);
|
||||
AliceState::SafelyAborted
|
||||
|
||||
AliceState::BtcEarlyRefundable { state3 }
|
||||
}
|
||||
Ok(res) => {
|
||||
res?;
|
||||
|
@ -117,11 +119,13 @@ where
|
|||
}
|
||||
}
|
||||
AliceState::BtcLocked { state3 } => {
|
||||
// We will retry indefinitely to lock the Monero funds, until the swap is cancelled
|
||||
// Sometimes locking the Monero can fail e.g due to the daemon not being fully synced
|
||||
// We will retry indefinitely to lock the Monero funds, until either:
|
||||
// - the cancel timelock expires
|
||||
// - we do not manage to lock the Monero funds within the timeout
|
||||
let backoff = backoff::ExponentialBackoffBuilder::new()
|
||||
.with_max_elapsed_time(None)
|
||||
.with_max_interval(Duration::from_secs(60))
|
||||
.with_max_elapsed_time(Some(env_config.monero_lock_retry_timeout))
|
||||
.with_max_interval(Duration::from_secs(30))
|
||||
.build();
|
||||
|
||||
let transfer_proof = backoff::future::retry_notify(backoff, || async {
|
||||
|
@ -129,7 +133,7 @@ where
|
|||
// If the swap is cancelled, there is no need to lock the Monero funds anymore
|
||||
// because there is no way for the swap to succeed.
|
||||
if !matches!(
|
||||
state3.expired_timelocks(bitcoin_wallet).await?,
|
||||
state3.expired_timelocks(bitcoin_wallet).await.context("Failed to check for expired timelocks before locking Monero").map_err(backoff::Error::transient)?,
|
||||
ExpiredTimelocks::None { .. }
|
||||
) {
|
||||
return Ok(None);
|
||||
|
@ -160,12 +164,11 @@ where
|
|||
wait_time.as_secs()
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("We should never run out of retries while locking Monero");
|
||||
.await;
|
||||
|
||||
match transfer_proof {
|
||||
// If the transfer was successful, we transition to the next state
|
||||
Some((monero_wallet_restore_blockheight, transfer_proof)) => {
|
||||
Ok(Some((monero_wallet_restore_blockheight, transfer_proof))) => {
|
||||
AliceState::XmrLockTransactionSent {
|
||||
monero_wallet_restore_blockheight,
|
||||
transfer_proof,
|
||||
|
@ -174,13 +177,49 @@ where
|
|||
}
|
||||
// If we were not able to lock the Monero funds before the timelock expired,
|
||||
// we can safely abort the swap because we did not lock any funds
|
||||
None => {
|
||||
// We do not do an early refund because Bob can refund himself (timelock expired)
|
||||
Ok(None) => {
|
||||
tracing::info!(
|
||||
swap_id = %swap_id,
|
||||
"We did not manage to lock the Monero funds before the timelock expired. Aborting swap."
|
||||
);
|
||||
|
||||
AliceState::SafelyAborted
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
swap_id = %swap_id,
|
||||
error = ?e,
|
||||
"Failed to lock Monero within {} seconds. We will do an early refund of the Bitcoin. We didn't lock any Monero funds so this is safe.",
|
||||
env_config.monero_lock_retry_timeout.as_secs()
|
||||
);
|
||||
|
||||
AliceState::BtcEarlyRefundable { state3 }
|
||||
}
|
||||
}
|
||||
}
|
||||
AliceState::BtcEarlyRefundable { state3 } => {
|
||||
if let Some(tx_early_refund) = state3.signed_early_refund_transaction() {
|
||||
let tx_early_refund = tx_early_refund?;
|
||||
let tx_early_refund_txid = tx_early_refund.compute_txid();
|
||||
|
||||
// Broadcast the early refund transaction
|
||||
let (_, _) = bitcoin_wallet
|
||||
.broadcast(tx_early_refund, "early_refund")
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
%tx_early_refund_txid,
|
||||
"Refunded Bitcoin early for Bob"
|
||||
);
|
||||
|
||||
AliceState::BtcEarlyRefunded(state3)
|
||||
} else {
|
||||
// We do not have Bob's signature for the early refund transaction
|
||||
// Therefore we cannot do an early refund.
|
||||
// We abort the swap on our side.
|
||||
// Bob will have to wait for the timelock to expire then refund himself.
|
||||
AliceState::SafelyAborted
|
||||
}
|
||||
}
|
||||
AliceState::XmrLockTransactionSent {
|
||||
|
@ -391,12 +430,17 @@ where
|
|||
transfer_proof,
|
||||
state3,
|
||||
} => {
|
||||
if state3.check_for_tx_cancel(bitcoin_wallet).await.is_err() {
|
||||
if state3.check_for_tx_cancel(bitcoin_wallet).await?.is_none() {
|
||||
// If Bob hasn't yet broadcasted the cancel transaction, Alice has to publish it
|
||||
// to be able to eventually punish. Since the punish timelock is
|
||||
// relative to the publication of the cancel transaction we have to ensure it
|
||||
// gets published once the cancel timelock expires.
|
||||
|
||||
if let Err(e) = state3.submit_tx_cancel(bitcoin_wallet).await {
|
||||
// TODO: Actually ensure the transaction is published
|
||||
// What about a wrapper function ensure_tx_published that repeats the tx submission until
|
||||
// our subscription sees it in the mempool?
|
||||
|
||||
tracing::debug!(
|
||||
"Assuming cancel transaction is already broadcasted because we failed to publish: {:#}",
|
||||
e
|
||||
|
@ -415,17 +459,11 @@ where
|
|||
transfer_proof,
|
||||
state3,
|
||||
} => {
|
||||
let (tx_refund_status, tx_cancel_status) = tokio::join!(
|
||||
bitcoin_wallet.subscribe_to(state3.tx_refund()),
|
||||
bitcoin_wallet.subscribe_to(state3.tx_cancel())
|
||||
);
|
||||
let tx_cancel_status = bitcoin_wallet.subscribe_to(state3.tx_cancel()).await;
|
||||
|
||||
select! {
|
||||
seen_refund = tx_refund_status.wait_until_seen() => {
|
||||
seen_refund.context("Failed to monitor refund transaction")?;
|
||||
|
||||
let published_refund_tx = bitcoin_wallet.get_raw_transaction(state3.tx_refund().txid()).await?;
|
||||
let spend_key = state3.extract_monero_private_key(published_refund_tx)?;
|
||||
spend_key = state3.watch_for_btc_tx_refund(bitcoin_wallet) => {
|
||||
let spend_key = spend_key?;
|
||||
|
||||
AliceState::BtcRefunded {
|
||||
monero_wallet_restore_blockheight,
|
||||
|
@ -511,7 +549,8 @@ where
|
|||
let published_refund_tx = bitcoin_wallet
|
||||
.get_raw_transaction(state3.tx_refund().txid())
|
||||
.await
|
||||
.context("Failed to fetch refund transaction after assuming it was included because the punish transaction failed")?;
|
||||
.context("Failed to fetch refund transaction after assuming it was included because the punish transaction failed")?
|
||||
.context("Bitcoin refund transaction not found")?;
|
||||
|
||||
let spend_key = state3.extract_monero_private_key(published_refund_tx)?;
|
||||
|
||||
|
@ -527,6 +566,7 @@ where
|
|||
AliceState::XmrRefunded => AliceState::XmrRefunded,
|
||||
AliceState::BtcRedeemed => AliceState::BtcRedeemed,
|
||||
AliceState::BtcPunished { state3 } => AliceState::BtcPunished { state3 },
|
||||
AliceState::BtcEarlyRefunded(state3) => AliceState::BtcEarlyRefunded(state3),
|
||||
AliceState::SafelyAborted => AliceState::SafelyAborted,
|
||||
})
|
||||
}
|
||||
|
@ -538,6 +578,7 @@ pub fn is_complete(state: &AliceState) -> bool {
|
|||
| AliceState::BtcRedeemed
|
||||
| AliceState::BtcPunished { .. }
|
||||
| AliceState::SafelyAborted
|
||||
| AliceState::BtcEarlyRefunded(_)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -46,7 +46,10 @@ pub enum BobState {
|
|||
BtcRedeemed(State5),
|
||||
CancelTimelockExpired(State6),
|
||||
BtcCancelled(State6),
|
||||
BtcRefundPublished(State6),
|
||||
BtcEarlyRefundPublished(State6),
|
||||
BtcRefunded(State6),
|
||||
BtcEarlyRefunded(State6),
|
||||
XmrRedeemed {
|
||||
tx_lock_id: bitcoin::Txid,
|
||||
},
|
||||
|
@ -71,9 +74,12 @@ impl fmt::Display for BobState {
|
|||
BobState::BtcRedeemed(..) => write!(f, "btc is redeemed"),
|
||||
BobState::CancelTimelockExpired(..) => write!(f, "cancel timelock is expired"),
|
||||
BobState::BtcCancelled(..) => write!(f, "btc is cancelled"),
|
||||
BobState::BtcRefundPublished { .. } => write!(f, "btc refund is published"),
|
||||
BobState::BtcEarlyRefundPublished { .. } => write!(f, "btc early refund is published"),
|
||||
BobState::BtcRefunded(..) => write!(f, "btc is refunded"),
|
||||
BobState::XmrRedeemed { .. } => write!(f, "xmr is redeemed"),
|
||||
BobState::BtcPunished { .. } => write!(f, "btc is punished"),
|
||||
BobState::BtcEarlyRefunded { .. } => write!(f, "btc is early refunded"),
|
||||
BobState::SafelyAborted => write!(f, "safely aborted"),
|
||||
}
|
||||
}
|
||||
|
@ -97,13 +103,17 @@ impl BobState {
|
|||
BobState::XmrLocked(state) | BobState::EncSigSent(state) => {
|
||||
Some(state.expired_timelock(&bitcoin_wallet).await?)
|
||||
}
|
||||
BobState::CancelTimelockExpired(state) | BobState::BtcCancelled(state) => {
|
||||
BobState::CancelTimelockExpired(state)
|
||||
| BobState::BtcCancelled(state)
|
||||
| BobState::BtcRefundPublished(state)
|
||||
| BobState::BtcEarlyRefundPublished(state) => {
|
||||
Some(state.expired_timelock(&bitcoin_wallet).await?)
|
||||
}
|
||||
BobState::BtcPunished { .. } => Some(ExpiredTimelocks::Punish),
|
||||
BobState::BtcRefunded(_) | BobState::BtcRedeemed(_) | BobState::XmrRedeemed { .. } => {
|
||||
None
|
||||
}
|
||||
BobState::BtcRefunded(_)
|
||||
| BobState::BtcEarlyRefunded { .. }
|
||||
| BobState::BtcRedeemed(_)
|
||||
| BobState::XmrRedeemed { .. } => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -280,6 +290,7 @@ impl State1 {
|
|||
self.b.public(),
|
||||
self.tx_cancel_fee,
|
||||
)?;
|
||||
|
||||
let tx_refund =
|
||||
bitcoin::TxRefund::new(&tx_cancel, &self.refund_address, self.tx_refund_fee);
|
||||
|
||||
|
@ -357,7 +368,9 @@ impl State2 {
|
|||
self.tx_cancel_fee,
|
||||
)
|
||||
.expect("valid cancel tx");
|
||||
|
||||
let tx_cancel_sig = self.b.sign(tx_cancel.digest());
|
||||
|
||||
let tx_punish = bitcoin::TxPunish::new(
|
||||
&tx_cancel,
|
||||
&self.punish_address,
|
||||
|
@ -366,9 +379,15 @@ impl State2 {
|
|||
);
|
||||
let tx_punish_sig = self.b.sign(tx_punish.digest());
|
||||
|
||||
let tx_early_refund =
|
||||
bitcoin::TxEarlyRefund::new(&self.tx_lock, &self.refund_address, self.tx_refund_fee);
|
||||
|
||||
let tx_early_refund_sig = self.b.sign(tx_early_refund.digest());
|
||||
|
||||
Message4 {
|
||||
tx_punish_sig,
|
||||
tx_cancel_sig,
|
||||
tx_early_refund_sig,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -506,6 +525,7 @@ impl State3 {
|
|||
tx_cancel_status,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn attempt_cooperative_redeem(
|
||||
&self,
|
||||
s_a: monero::PrivateKey,
|
||||
|
@ -519,6 +539,23 @@ impl State3 {
|
|||
monero_wallet_restore_blockheight,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn construct_tx_early_refund(&self) -> bitcoin::TxEarlyRefund {
|
||||
bitcoin::TxEarlyRefund::new(&self.tx_lock, &self.refund_address, self.tx_refund_fee)
|
||||
}
|
||||
|
||||
pub async fn check_for_tx_early_refund(
|
||||
&self,
|
||||
bitcoin_wallet: &bitcoin::Wallet,
|
||||
) -> Result<Option<Arc<Transaction>>> {
|
||||
let tx_early_refund = self.construct_tx_early_refund();
|
||||
let tx = bitcoin_wallet
|
||||
.get_raw_transaction(tx_early_refund.txid())
|
||||
.await
|
||||
.context("Failed to check for existence of tx_early_refund")?;
|
||||
|
||||
Ok(tx)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
|
@ -547,25 +584,32 @@ pub struct State4 {
|
|||
}
|
||||
|
||||
impl State4 {
|
||||
pub async fn check_for_tx_redeem(&self, bitcoin_wallet: &bitcoin::Wallet) -> Result<State5> {
|
||||
pub async fn check_for_tx_redeem(
|
||||
&self,
|
||||
bitcoin_wallet: &bitcoin::Wallet,
|
||||
) -> Result<Option<State5>> {
|
||||
let tx_redeem =
|
||||
bitcoin::TxRedeem::new(&self.tx_lock, &self.redeem_address, self.tx_redeem_fee);
|
||||
let tx_redeem_encsig = self.b.encsign(self.S_a_bitcoin, tx_redeem.digest());
|
||||
|
||||
let tx_redeem_candidate = bitcoin_wallet.get_raw_transaction(tx_redeem.txid()).await?;
|
||||
|
||||
let tx_redeem_sig =
|
||||
tx_redeem.extract_signature_by_key(tx_redeem_candidate, self.b.public())?;
|
||||
let s_a = bitcoin::recover(self.S_a_bitcoin, tx_redeem_sig, tx_redeem_encsig)?;
|
||||
let s_a = monero::private_key_from_secp256k1_scalar(s_a.into());
|
||||
if let Some(tx_redeem_candidate) = tx_redeem_candidate {
|
||||
let tx_redeem_sig =
|
||||
tx_redeem.extract_signature_by_key(tx_redeem_candidate, self.b.public())?;
|
||||
let s_a = bitcoin::recover(self.S_a_bitcoin, tx_redeem_sig, tx_redeem_encsig)?;
|
||||
let s_a = monero::private_key_from_secp256k1_scalar(s_a.into());
|
||||
|
||||
Ok(State5 {
|
||||
s_a,
|
||||
s_b: self.s_b,
|
||||
v: self.v,
|
||||
tx_lock: self.tx_lock.clone(),
|
||||
monero_wallet_restore_blockheight: self.monero_wallet_restore_blockheight,
|
||||
})
|
||||
Ok(Some(State5 {
|
||||
s_a,
|
||||
s_b: self.s_b,
|
||||
v: self.v,
|
||||
tx_lock: self.tx_lock.clone(),
|
||||
monero_wallet_restore_blockheight: self.monero_wallet_restore_blockheight,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tx_redeem_encsig(&self) -> bitcoin::EncryptedSignature {
|
||||
|
@ -577,7 +621,6 @@ impl State4 {
|
|||
pub async fn watch_for_redeem_btc(&self, bitcoin_wallet: &bitcoin::Wallet) -> Result<State5> {
|
||||
let tx_redeem =
|
||||
bitcoin::TxRedeem::new(&self.tx_lock, &self.redeem_address, self.tx_redeem_fee);
|
||||
let tx_redeem_encsig = self.b.encsign(self.S_a_bitcoin, tx_redeem.digest());
|
||||
|
||||
bitcoin_wallet
|
||||
.subscribe_to(tx_redeem.clone())
|
||||
|
@ -585,19 +628,10 @@ impl State4 {
|
|||
.wait_until_seen()
|
||||
.await?;
|
||||
|
||||
let tx_redeem_candidate = bitcoin_wallet.get_raw_transaction(tx_redeem.txid()).await?;
|
||||
let state5 = self.check_for_tx_redeem(bitcoin_wallet).await?;
|
||||
|
||||
let tx_redeem_sig =
|
||||
tx_redeem.extract_signature_by_key(tx_redeem_candidate, self.b.public())?;
|
||||
let s_a = bitcoin::recover(self.S_a_bitcoin, tx_redeem_sig, tx_redeem_encsig)?;
|
||||
let s_a = monero::private_key_from_secp256k1_scalar(s_a.into());
|
||||
|
||||
Ok(State5 {
|
||||
s_a,
|
||||
s_b: self.s_b,
|
||||
v: self.v,
|
||||
tx_lock: self.tx_lock.clone(),
|
||||
monero_wallet_restore_blockheight: self.monero_wallet_restore_blockheight,
|
||||
state5.ok_or_else(|| {
|
||||
anyhow!("Bitcoin redeem transaction was not found in the chain even though we previously saw it in the mempool. Our Electrum server might have cleared its mempool?")
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -641,6 +675,10 @@ impl State4 {
|
|||
tx_cancel_fee: self.tx_cancel_fee,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn construct_tx_early_refund(&self) -> bitcoin::TxEarlyRefund {
|
||||
bitcoin::TxEarlyRefund::new(&self.tx_lock, &self.refund_address, self.tx_refund_fee)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
|
@ -697,11 +735,11 @@ pub struct State6 {
|
|||
s_b: monero::Scalar,
|
||||
v: monero::PrivateViewKey,
|
||||
pub monero_wallet_restore_blockheight: BlockHeight,
|
||||
cancel_timelock: CancelTimelock,
|
||||
pub cancel_timelock: CancelTimelock,
|
||||
punish_timelock: PunishTimelock,
|
||||
#[serde(with = "address_serde")]
|
||||
refund_address: bitcoin::Address,
|
||||
tx_lock: bitcoin::TxLock,
|
||||
pub tx_lock: bitcoin::TxLock,
|
||||
tx_cancel_sig_a: Signature,
|
||||
tx_refund_encsig: bitcoin::EncryptedSignature,
|
||||
#[serde(with = "::bitcoin::amount::serde::as_sat")]
|
||||
|
@ -733,6 +771,7 @@ impl State6 {
|
|||
tx_cancel_status,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn construct_tx_cancel(&self) -> Result<bitcoin::TxCancel> {
|
||||
bitcoin::TxCancel::new(
|
||||
&self.tx_lock,
|
||||
|
@ -742,13 +781,17 @@ impl State6 {
|
|||
self.tx_cancel_fee,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn check_for_tx_cancel(
|
||||
&self,
|
||||
bitcoin_wallet: &bitcoin::Wallet,
|
||||
) -> Result<Arc<Transaction>> {
|
||||
) -> Result<Option<Arc<Transaction>>> {
|
||||
let tx_cancel = self.construct_tx_cancel()?;
|
||||
|
||||
let tx = bitcoin_wallet.get_raw_transaction(tx_cancel.txid()).await?;
|
||||
let tx = bitcoin_wallet
|
||||
.get_raw_transaction(tx_cancel.txid())
|
||||
.await
|
||||
.context("Failed to check for existence of tx_cancel")?;
|
||||
|
||||
Ok(tx)
|
||||
}
|
||||
|
@ -778,11 +821,18 @@ impl State6 {
|
|||
Ok(signed_tx_refund_txid)
|
||||
}
|
||||
|
||||
pub fn signed_refund_transaction(&self) -> Result<Transaction> {
|
||||
pub fn construct_tx_refund(&self) -> Result<bitcoin::TxRefund> {
|
||||
let tx_cancel = self.construct_tx_cancel()?;
|
||||
|
||||
let tx_refund =
|
||||
bitcoin::TxRefund::new(&tx_cancel, &self.refund_address, self.tx_refund_fee);
|
||||
|
||||
Ok(tx_refund)
|
||||
}
|
||||
|
||||
pub fn signed_refund_transaction(&self) -> Result<Transaction> {
|
||||
let tx_refund = self.construct_tx_refund()?;
|
||||
|
||||
let adaptor = Adaptor::<HashTranscript<Sha256>, Deterministic<Sha256>>::default();
|
||||
|
||||
let sig_b = self.b.sign(tx_refund.digest());
|
||||
|
@ -791,12 +841,18 @@ impl State6 {
|
|||
|
||||
let signed_tx_refund =
|
||||
tx_refund.add_signatures((self.A, sig_a), (self.b.public(), sig_b))?;
|
||||
|
||||
Ok(signed_tx_refund)
|
||||
}
|
||||
|
||||
pub fn construct_tx_early_refund(&self) -> bitcoin::TxEarlyRefund {
|
||||
bitcoin::TxEarlyRefund::new(&self.tx_lock, &self.refund_address, self.tx_refund_fee)
|
||||
}
|
||||
|
||||
pub fn tx_lock_id(&self) -> bitcoin::Txid {
|
||||
self.tx_lock.txid()
|
||||
}
|
||||
|
||||
pub fn attempt_cooperative_redeem(&self, s_a: monero::PrivateKey) -> State5 {
|
||||
State5 {
|
||||
s_a,
|
||||
|
@ -806,4 +862,18 @@ impl State6 {
|
|||
monero_wallet_restore_blockheight: self.monero_wallet_restore_blockheight,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_for_tx_early_refund(
|
||||
&self,
|
||||
bitcoin_wallet: &bitcoin::Wallet,
|
||||
) -> Result<Option<Arc<Transaction>>> {
|
||||
let tx_early_refund = self.construct_tx_early_refund();
|
||||
|
||||
let tx = bitcoin_wallet
|
||||
.get_raw_transaction(tx_early_refund.txid())
|
||||
.await
|
||||
.context("Failed to check for existence of tx_early_refund")?;
|
||||
|
||||
Ok(tx)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,7 +21,10 @@ const PRE_BTC_LOCK_APPROVAL_TIMEOUT_SECS: u64 = 120;
|
|||
pub fn is_complete(state: &BobState) -> bool {
|
||||
matches!(
|
||||
state,
|
||||
BobState::BtcRefunded(..) | BobState::XmrRedeemed { .. } | BobState::SafelyAborted
|
||||
BobState::BtcRefunded(..)
|
||||
| BobState::BtcEarlyRefunded { .. }
|
||||
| BobState::XmrRedeemed { .. }
|
||||
| BobState::SafelyAborted
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -222,9 +225,13 @@ async fn next_state(
|
|||
},
|
||||
);
|
||||
|
||||
let tx_lock_status = bitcoin_wallet.subscribe_to(state3.tx_lock.clone()).await;
|
||||
let (tx_early_refund_status, tx_lock_status) = tokio::join!(
|
||||
bitcoin_wallet.subscribe_to(state3.construct_tx_early_refund()),
|
||||
bitcoin_wallet.subscribe_to(state3.tx_lock.clone())
|
||||
);
|
||||
|
||||
// Check whether we can cancel the swap, and do so if possible
|
||||
// Check explicitly whether the cancel timelock has expired
|
||||
// (Most likely redundant but cannot hurt)
|
||||
if state3
|
||||
.expired_timelock(bitcoin_wallet)
|
||||
.await?
|
||||
|
@ -234,6 +241,21 @@ async fn next_state(
|
|||
return Ok(BobState::CancelTimelockExpired(state4));
|
||||
};
|
||||
|
||||
// Check explicitly whether Alice has published the early refund transaction
|
||||
// (Most likely redundant because we already do this below but cannot hurt)
|
||||
// We only warn if this fail here
|
||||
if let Ok(Some(_)) = state3
|
||||
.check_for_tx_early_refund(bitcoin_wallet)
|
||||
.await
|
||||
.inspect_err(|err| {
|
||||
tracing::warn!(?err, "Failed to check for early refund transaction");
|
||||
})
|
||||
{
|
||||
return Ok(BobState::BtcEarlyRefundPublished(
|
||||
state3.cancel(monero_wallet_restore_blockheight),
|
||||
));
|
||||
}
|
||||
|
||||
tracing::info!("Waiting for Alice to lock Monero");
|
||||
|
||||
// Check if we have already buffered the XMR transfer proof
|
||||
|
@ -243,7 +265,6 @@ async fn next_state(
|
|||
.context("Failed to get buffered transfer proof")?
|
||||
{
|
||||
tracing::debug!(txid = %transfer_proof.tx_hash(), "Found buffered transfer proof");
|
||||
tracing::info!(txid = %transfer_proof.tx_hash(), "Alice locked Monero");
|
||||
|
||||
return Ok(BobState::XmrLockProofReceived {
|
||||
state: state3,
|
||||
|
@ -271,19 +292,21 @@ async fn next_state(
|
|||
});
|
||||
|
||||
select! {
|
||||
// Alice sent us the transfer proof for the Monero she locked
|
||||
// Wait for Alice to publish the early refund transaction
|
||||
_ = tx_early_refund_status.wait_until_seen() => {
|
||||
BobState::BtcEarlyRefundPublished(state3.cancel(monero_wallet_restore_blockheight))
|
||||
},
|
||||
// Wait for Alice to send us the transfer proof for the Monero she locked
|
||||
transfer_proof = transfer_proof_watcher => {
|
||||
let transfer_proof = transfer_proof?;
|
||||
|
||||
tracing::info!(txid = %transfer_proof.tx_hash(), "Alice locked Monero");
|
||||
|
||||
BobState::XmrLockProofReceived {
|
||||
state: state3,
|
||||
lock_transfer_proof: transfer_proof,
|
||||
monero_wallet_restore_blockheight
|
||||
}
|
||||
},
|
||||
// The cancel timelock expired before Alice locked her Monero
|
||||
// Wait for the cancel timelock to expire
|
||||
result = cancel_timelock_expires => {
|
||||
result?;
|
||||
tracing::info!("Alice took too long to lock Monero, cancelling the swap");
|
||||
|
@ -298,6 +321,8 @@ async fn next_state(
|
|||
lock_transfer_proof,
|
||||
monero_wallet_restore_blockheight,
|
||||
} => {
|
||||
tracing::info!(txid = %lock_transfer_proof.tx_hash(), "Alice locked Monero");
|
||||
|
||||
event_emitter.emit_swap_progress_event(
|
||||
swap_id,
|
||||
TauriSwapProgressEvent::XmrLockTxInMempool {
|
||||
|
@ -306,8 +331,6 @@ async fn next_state(
|
|||
},
|
||||
);
|
||||
|
||||
let tx_lock_status = bitcoin_wallet.subscribe_to(state.tx_lock.clone()).await;
|
||||
|
||||
// Check if the cancel timelock has expired
|
||||
// If it has, we have to cancel the swap
|
||||
if state
|
||||
|
@ -320,6 +343,13 @@ async fn next_state(
|
|||
));
|
||||
};
|
||||
|
||||
let tx_early_refund = state.construct_tx_early_refund();
|
||||
|
||||
let (tx_lock_status, tx_early_refund_status) = tokio::join!(
|
||||
bitcoin_wallet.subscribe_to(state.tx_lock.clone()),
|
||||
bitcoin_wallet.subscribe_to(tx_early_refund.clone())
|
||||
);
|
||||
|
||||
// Clone these so that we can move them into the listener closure
|
||||
let tauri_clone = event_emitter.clone();
|
||||
let transfer_proof_clone = lock_transfer_proof.clone();
|
||||
|
@ -348,6 +378,7 @@ async fn next_state(
|
|||
);
|
||||
|
||||
select! {
|
||||
// Wait for the Monero lock transaction to be fully confirmed
|
||||
received_xmr = watch_future => {
|
||||
match received_xmr {
|
||||
Ok(()) =>
|
||||
|
@ -365,10 +396,19 @@ async fn next_state(
|
|||
},
|
||||
}
|
||||
}
|
||||
// Wait for the cancel timelock to expire
|
||||
result = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => {
|
||||
result?;
|
||||
BobState::CancelTimelockExpired(state.cancel(monero_wallet_restore_blockheight))
|
||||
}
|
||||
},
|
||||
// Wait for Alice to publish the early refund transaction
|
||||
// There is really no reason at all for Alice to ever do an early refund
|
||||
// after she has locked her Monero because she won't be able to refund her
|
||||
// Monero without our Bitcoin refund transaction
|
||||
// However, theoretically it's possible so we check for it
|
||||
_ = tx_early_refund_status.wait_until_seen() => {
|
||||
BobState::BtcEarlyRefundPublished(state.cancel(monero_wallet_restore_blockheight))
|
||||
},
|
||||
}
|
||||
}
|
||||
BobState::XmrLocked(state) => {
|
||||
|
@ -377,12 +417,10 @@ async fn next_state(
|
|||
// In case we send the encrypted signature to Alice, but she doesn't give us a confirmation
|
||||
// We need to check if she still published the Bitcoin redeem transaction
|
||||
// Otherwise we risk staying stuck in "XmrLocked"
|
||||
if let Ok(state5) = state.check_for_tx_redeem(bitcoin_wallet).await {
|
||||
if let Some(state5) = state.check_for_tx_redeem(bitcoin_wallet).await? {
|
||||
return Ok(BobState::BtcRedeemed(state5));
|
||||
}
|
||||
|
||||
let tx_lock_status = bitcoin_wallet.subscribe_to(state.tx_lock.clone()).await;
|
||||
|
||||
// Check whether we can cancel the swap and do so if possible.
|
||||
if state
|
||||
.expired_timelock(bitcoin_wallet)
|
||||
|
@ -392,9 +430,15 @@ async fn next_state(
|
|||
return Ok(BobState::CancelTimelockExpired(state.cancel()));
|
||||
}
|
||||
|
||||
// Alice has locked their Monero
|
||||
let (tx_lock_status, tx_early_refund_status) = tokio::join!(
|
||||
bitcoin_wallet.subscribe_to(state.tx_lock.clone()),
|
||||
bitcoin_wallet.subscribe_to(state.construct_tx_early_refund())
|
||||
);
|
||||
|
||||
// Alice has locked her Monero
|
||||
// Bob sends Alice the encrypted signature which allows her to sign and broadcast the Bitcoin redeem transaction
|
||||
select! {
|
||||
// Wait for the confirmation from Alice that she has received the encrypted signature
|
||||
result = event_loop_handle.send_encrypted_signature(state.tx_redeem_encsig()) => {
|
||||
match result {
|
||||
Ok(_) => BobState::EncSigSent(state),
|
||||
|
@ -404,10 +448,19 @@ async fn next_state(
|
|||
}
|
||||
}
|
||||
},
|
||||
// Wait for the cancel timelock to expire
|
||||
result = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => {
|
||||
result?;
|
||||
BobState::CancelTimelockExpired(state.cancel())
|
||||
}
|
||||
// Wait for Alice to publish the early refund transaction
|
||||
// There is really no reason at all for Alice to ever refund the Bitcoin
|
||||
// after she has locked her Monero because she won't be able to refund her
|
||||
// Monero without our Bitcoin refund transaction
|
||||
// However, theoretically it's possible so we check for it
|
||||
_ = tx_early_refund_status.wait_until_seen() => {
|
||||
BobState::BtcEarlyRefundPublished(state.cancel())
|
||||
},
|
||||
}
|
||||
}
|
||||
BobState::EncSigSent(state) => {
|
||||
|
@ -417,12 +470,10 @@ async fn next_state(
|
|||
// We need to make sure that Alice did not publish the redeem transaction while we were offline
|
||||
// Even if the cancel timelock expired, if Alice published the redeem transaction while we were away we cannot miss it
|
||||
// If we do we cannot refund and will never be able to leave the "CancelTimelockExpired" state
|
||||
if let Ok(state5) = state.check_for_tx_redeem(bitcoin_wallet).await {
|
||||
if let Some(state5) = state.check_for_tx_redeem(bitcoin_wallet).await? {
|
||||
return Ok(BobState::BtcRedeemed(state5));
|
||||
}
|
||||
|
||||
let tx_lock_status = bitcoin_wallet.subscribe_to(state.tx_lock.clone()).await;
|
||||
|
||||
if state
|
||||
.expired_timelock(bitcoin_wallet)
|
||||
.await?
|
||||
|
@ -431,14 +482,30 @@ async fn next_state(
|
|||
return Ok(BobState::CancelTimelockExpired(state.cancel()));
|
||||
}
|
||||
|
||||
let (tx_lock_status, tx_early_refund_status) = tokio::join!(
|
||||
bitcoin_wallet.subscribe_to(state.tx_lock.clone()),
|
||||
bitcoin_wallet.subscribe_to(state.construct_tx_early_refund())
|
||||
);
|
||||
|
||||
select! {
|
||||
// Wait for Alice to redeem the Bitcoin
|
||||
// We can then extract the key and redeem our Monero
|
||||
state5 = state.watch_for_redeem_btc(bitcoin_wallet) => {
|
||||
BobState::BtcRedeemed(state5?)
|
||||
},
|
||||
// Wait for the cancel timelock to expire
|
||||
result = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => {
|
||||
result?;
|
||||
BobState::CancelTimelockExpired(state.cancel())
|
||||
}
|
||||
// Wait for Alice to publish the early refund transaction
|
||||
// There is really no reason at all for Alice to ever refund the Bitcoin
|
||||
// after she has locked her Monero because she won't be able to refund her
|
||||
// Monero without our Bitcoin refund transaction
|
||||
// However, theoretically it's possible so we check for it
|
||||
_ = tx_early_refund_status.wait_until_seen() => {
|
||||
BobState::BtcEarlyRefundPublished(state.cancel())
|
||||
},
|
||||
}
|
||||
}
|
||||
BobState::BtcRedeemed(state) => {
|
||||
|
@ -464,26 +531,38 @@ async fn next_state(
|
|||
tx_lock_id: state.tx_lock_id(),
|
||||
}
|
||||
}
|
||||
BobState::CancelTimelockExpired(state4) => {
|
||||
BobState::CancelTimelockExpired(state6) => {
|
||||
event_emitter
|
||||
.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::CancelTimelockExpired);
|
||||
|
||||
if let Err(err) = state4.check_for_tx_cancel(bitcoin_wallet).await {
|
||||
tracing::debug!(
|
||||
%err,
|
||||
"Couldn't find tx_cancel yet, publishing ourselves"
|
||||
);
|
||||
state4.submit_tx_cancel(bitcoin_wallet).await?;
|
||||
if state6.check_for_tx_cancel(bitcoin_wallet).await?.is_none() {
|
||||
tracing::debug!("Couldn't find tx_cancel yet, publishing ourselves");
|
||||
|
||||
if let Err(tx_cancel_err) = state6.submit_tx_cancel(bitcoin_wallet).await {
|
||||
tracing::warn!(err = %tx_cancel_err, "Failed to publish tx_cancel even though it is not present in the chain. Did Alice already refund us our Bitcoin early?");
|
||||
|
||||
// If tx_cancel is not present in the chain and we fail to publish it. There's only one logical conclusion:
|
||||
// The tx_lock UTXO has been spent by the tx_early_refund transaction
|
||||
// Therefore we check for the early refund transaction
|
||||
match state6.check_for_tx_early_refund(bitcoin_wallet).await? {
|
||||
Some(_) => {
|
||||
return Ok(BobState::BtcEarlyRefundPublished(state6));
|
||||
}
|
||||
None => {
|
||||
bail!("Failed to publish tx_cancel even though it is not present. We also did not find tx_early_refund in the chain. This is unexpected. Could be an issue with the Electrum server? tx_cancel_err: {:?}", tx_cancel_err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BobState::BtcCancelled(state4)
|
||||
BobState::BtcCancelled(state6)
|
||||
}
|
||||
BobState::BtcCancelled(state) => {
|
||||
let btc_cancel_txid = state.construct_tx_cancel()?.txid();
|
||||
|
||||
event_emitter.emit_swap_progress_event(
|
||||
swap_id,
|
||||
TauriSwapProgressEvent::BtcCancelled {
|
||||
btc_cancel_txid: state.construct_tx_cancel()?.txid(),
|
||||
},
|
||||
TauriSwapProgressEvent::BtcCancelled { btc_cancel_txid },
|
||||
);
|
||||
|
||||
// Bob has cancelled the swap
|
||||
|
@ -496,35 +575,121 @@ async fn next_state(
|
|||
ExpiredTimelocks::Cancel { .. } => {
|
||||
let btc_refund_txid = state.publish_refund_btc(bitcoin_wallet).await?;
|
||||
|
||||
event_emitter.emit_swap_progress_event(
|
||||
swap_id,
|
||||
TauriSwapProgressEvent::BtcRefunded { btc_refund_txid },
|
||||
);
|
||||
tracing::info!(%btc_refund_txid, "Refunded our Bitcoin");
|
||||
|
||||
BobState::BtcRefunded(state)
|
||||
}
|
||||
ExpiredTimelocks::Punish => {
|
||||
tracing::info!("You have been punished for not refunding in time");
|
||||
BobState::BtcPunished {
|
||||
tx_lock_id: state.tx_lock_id(),
|
||||
state,
|
||||
}
|
||||
BobState::BtcRefundPublished(state)
|
||||
}
|
||||
ExpiredTimelocks::Punish => BobState::BtcPunished {
|
||||
tx_lock_id: state.tx_lock_id(),
|
||||
state,
|
||||
},
|
||||
}
|
||||
}
|
||||
BobState::BtcRefunded(state4) => {
|
||||
BobState::BtcRefundPublished(state) => {
|
||||
// Emit a Tauri event
|
||||
event_emitter.emit_swap_progress_event(
|
||||
swap_id,
|
||||
TauriSwapProgressEvent::BtcRefunded {
|
||||
btc_refund_txid: state4.signed_refund_transaction()?.compute_txid(),
|
||||
TauriSwapProgressEvent::BtcRefundPublished {
|
||||
btc_refund_txid: state.signed_refund_transaction()?.compute_txid(),
|
||||
},
|
||||
);
|
||||
|
||||
BobState::BtcRefunded(state4)
|
||||
// Watch for the refund transaction to be confirmed by its txid
|
||||
let tx_refund = state.construct_tx_refund()?;
|
||||
let tx_early_refund = state.construct_tx_early_refund();
|
||||
|
||||
let (tx_refund_status, tx_early_refund_status) = tokio::join!(
|
||||
bitcoin_wallet.subscribe_to(tx_refund.clone()),
|
||||
bitcoin_wallet.subscribe_to(tx_early_refund.clone()),
|
||||
);
|
||||
|
||||
// Either of these two refund transactions could have been published
|
||||
// They are mutually exclusive since they spend the same UTXO
|
||||
// We wait for either of them to be confirmed, then transition into
|
||||
// BtcRefunded state with the txid of the confirmed transaction
|
||||
select! {
|
||||
// Wait for the refund transaction to be confirmed
|
||||
_ = tx_refund_status.wait_until_final() => {
|
||||
let tx_refund_txid = tx_refund.txid();
|
||||
|
||||
event_emitter.emit_swap_progress_event(
|
||||
swap_id,
|
||||
TauriSwapProgressEvent::BtcRefunded { btc_refund_txid: tx_refund_txid },
|
||||
);
|
||||
|
||||
BobState::BtcRefunded(state)
|
||||
},
|
||||
// Wait for the early refund transaction to be confirmed
|
||||
_ = tx_early_refund_status.wait_until_final() => {
|
||||
let tx_early_refund_txid = tx_early_refund.txid();
|
||||
|
||||
tracing::info!(%tx_early_refund_txid, "Alice refunded us our Bitcoin early");
|
||||
|
||||
event_emitter.emit_swap_progress_event(
|
||||
swap_id,
|
||||
TauriSwapProgressEvent::BtcRefunded { btc_refund_txid: tx_early_refund_txid },
|
||||
);
|
||||
|
||||
BobState::BtcEarlyRefunded(state)
|
||||
},
|
||||
}
|
||||
}
|
||||
BobState::BtcEarlyRefundPublished(state) => {
|
||||
let tx_early_refund_tx = state.construct_tx_early_refund();
|
||||
let tx_early_refund_txid = tx_early_refund_tx.txid();
|
||||
|
||||
tracing::info!(%tx_early_refund_txid, "Alice has refunded us our Bitcoin early");
|
||||
|
||||
// Emit Tauri event
|
||||
event_emitter.emit_swap_progress_event(
|
||||
swap_id,
|
||||
TauriSwapProgressEvent::BtcEarlyRefundPublished {
|
||||
btc_early_refund_txid: tx_early_refund_txid,
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for confirmations
|
||||
let (tx_lock_status, tx_early_refund_status) = tokio::join!(
|
||||
bitcoin_wallet.subscribe_to(state.tx_lock.clone()),
|
||||
bitcoin_wallet.subscribe_to(tx_early_refund_tx.clone()),
|
||||
);
|
||||
|
||||
select! {
|
||||
// The early refund transaction has been published but we cannot guarantee
|
||||
// that it will be confirmed before the cancel timelock expires
|
||||
result = tx_early_refund_status.wait_until_final() => {
|
||||
result?;
|
||||
|
||||
event_emitter.emit_swap_progress_event(
|
||||
swap_id,
|
||||
TauriSwapProgressEvent::BtcRefunded { btc_refund_txid: tx_early_refund_txid },
|
||||
);
|
||||
|
||||
BobState::BtcEarlyRefunded(state)
|
||||
},
|
||||
// We cannot guarantee that tx_early_refund will be confirmed before the cancel timelock expires
|
||||
// Once it expires we will also publish the cancel and refund transactions
|
||||
// We will then race to see which one (tx_early_refund or tx_refund) is confirmed first
|
||||
// Both transactions refund the Bitcoin to our refund address
|
||||
_ = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => {
|
||||
BobState::CancelTimelockExpired(state)
|
||||
},
|
||||
}
|
||||
}
|
||||
BobState::BtcRefunded(state) => {
|
||||
event_emitter.emit_swap_progress_event(
|
||||
swap_id,
|
||||
TauriSwapProgressEvent::BtcRefunded {
|
||||
btc_refund_txid: state.signed_refund_transaction()?.compute_txid(),
|
||||
},
|
||||
);
|
||||
|
||||
BobState::BtcRefunded(state)
|
||||
}
|
||||
BobState::BtcPunished { state, tx_lock_id } => {
|
||||
event_emitter.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::BtcPunished);
|
||||
tracing::info!("You have been punished for not refunding in time");
|
||||
|
||||
event_emitter.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::BtcPunished);
|
||||
event_emitter.emit_swap_progress_event(
|
||||
swap_id,
|
||||
TauriSwapProgressEvent::AttemptingCooperativeRedeem,
|
||||
|
@ -619,6 +784,7 @@ async fn next_state(
|
|||
};
|
||||
}
|
||||
// TODO: Emit a Tauri event here
|
||||
BobState::BtcEarlyRefunded(state) => BobState::BtcEarlyRefunded(state),
|
||||
BobState::SafelyAborted => BobState::SafelyAborted,
|
||||
BobState::XmrRedeemed { tx_lock_id } => {
|
||||
event_emitter.emit_swap_progress_event(
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
pub mod harness;
|
||||
|
||||
use harness::bob_run_until::is_btc_locked;
|
||||
use swap::asb::FixedRate;
|
||||
use swap::protocol::alice::AliceState;
|
||||
use swap::protocol::bob::BobState;
|
||||
use swap::protocol::{alice, bob};
|
||||
|
||||
use crate::harness::SlowCancelConfig;
|
||||
|
||||
#[tokio::test]
|
||||
async fn alice_zero_xmr_refunds_bitcoin() {
|
||||
harness::setup_test(SlowCancelConfig, |mut ctx| async move {
|
||||
let (bob_swap, bob_handle) = ctx.bob_swap().await;
|
||||
let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked));
|
||||
|
||||
// Run until the Bitcoin lock transaction is seen
|
||||
let alice_swap = ctx.alice_next_swap().await;
|
||||
let swap_id = alice_swap.swap_id;
|
||||
let alice_swap = tokio::spawn(alice::run_until(
|
||||
alice_swap,
|
||||
|state| matches!(state, AliceState::BtcLockTransactionSeen { .. }),
|
||||
FixedRate::default(),
|
||||
));
|
||||
|
||||
// Wait for both Alice and Bob to reach the Bitcoin locked state
|
||||
let alice_state = alice_swap.await??;
|
||||
let bob_state = bob_swap.await??;
|
||||
|
||||
assert!(matches!(
|
||||
alice_state,
|
||||
AliceState::BtcLockTransactionSeen { .. }
|
||||
));
|
||||
assert!(matches!(bob_state, BobState::BtcLocked { .. }));
|
||||
|
||||
// Kill the monero-wallet-rpc of Alice
|
||||
// This will prevent her from locking her Monero
|
||||
// in turn forcing her into an early refund
|
||||
ctx.stop_alice_monero_wallet_rpc().await;
|
||||
|
||||
ctx.restart_alice().await;
|
||||
let (swap, _) = ctx.stop_and_resume_bob_from_db(bob_handle, swap_id).await;
|
||||
|
||||
let bob_swap = tokio::spawn(bob::run(swap));
|
||||
|
||||
let alice_swap = ctx.alice_next_swap().await;
|
||||
let alice_swap = tokio::spawn(alice::run(alice_swap, FixedRate::default()));
|
||||
|
||||
let alice_state = alice_swap.await??;
|
||||
let bob_state = bob_swap.await??;
|
||||
|
||||
assert!(matches!(alice_state, AliceState::BtcEarlyRefunded(_)));
|
||||
assert!(matches!(bob_state, BobState::BtcEarlyRefunded(_)));
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
pub mod harness;
|
||||
|
||||
use harness::bob_run_until::is_btc_locked;
|
||||
use swap::asb::FixedRate;
|
||||
use swap::protocol::alice::AliceState;
|
||||
use swap::protocol::bob::BobState;
|
||||
use swap::protocol::{alice, bob};
|
||||
|
||||
use crate::harness::SlowCancelConfig;
|
||||
|
||||
#[tokio::test]
|
||||
async fn alice_zero_xmr_refunds_bitcoin() {
|
||||
harness::setup_test(SlowCancelConfig, |mut ctx| async move {
|
||||
let (bob_swap, bob_handle) = ctx.bob_swap().await;
|
||||
let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked));
|
||||
|
||||
// Run until the Bitcoin lock transaction is seen
|
||||
let alice_swap = ctx.alice_next_swap().await;
|
||||
let swap_id = alice_swap.swap_id;
|
||||
let alice_swap = tokio::spawn(alice::run_until(
|
||||
alice_swap,
|
||||
|state| matches!(state, AliceState::BtcLockTransactionSeen { .. }),
|
||||
FixedRate::default(),
|
||||
));
|
||||
|
||||
// Wait for both Alice and Bob to reach the Bitcoin locked state
|
||||
let alice_state = alice_swap.await??;
|
||||
let bob_state = bob_swap.await??;
|
||||
|
||||
assert!(matches!(
|
||||
alice_state,
|
||||
AliceState::BtcLockTransactionSeen { .. }
|
||||
));
|
||||
assert!(matches!(bob_state, BobState::BtcLocked { .. }));
|
||||
|
||||
// Empty Alice Monero Wallet
|
||||
// This will prevent Alice from locking her Monero
|
||||
// in turn forcing an early refund
|
||||
ctx.empty_alice_monero_wallet().await;
|
||||
ctx.assert_alice_monero_wallet_empty().await;
|
||||
|
||||
ctx.restart_alice().await;
|
||||
let (swap, _) = ctx.stop_and_resume_bob_from_db(bob_handle, swap_id).await;
|
||||
|
||||
let bob_swap = tokio::spawn(bob::run(swap));
|
||||
|
||||
let alice_swap = ctx.alice_next_swap().await;
|
||||
let alice_swap = tokio::spawn(alice::run(alice_swap, FixedRate::default()));
|
||||
|
||||
let alice_state = alice_swap.await??;
|
||||
let bob_state = bob_swap.await??;
|
||||
|
||||
assert!(matches!(alice_state, AliceState::BtcEarlyRefunded(_)));
|
||||
assert!(matches!(bob_state, BobState::BtcEarlyRefunded(_)));
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
mod bitcoind;
|
||||
mod electrs;
|
||||
|
||||
use ::monero::Address;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use bitcoin_harness::{BitcoindRpcApi, Client};
|
||||
|
@ -12,6 +13,7 @@ use monero_harness::{image, Monero};
|
|||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use swap::asb::FixedRate;
|
||||
|
@ -315,7 +317,7 @@ async fn init_test_wallets(
|
|||
let btc_wallet = swap::bitcoin::wallet::WalletBuilder::default()
|
||||
.seed(seed.clone())
|
||||
.network(env_config.bitcoin_network)
|
||||
.electrum_rpc_url(electrum_rpc_url.as_str().to_string())
|
||||
.electrum_rpc_urls(vec![electrum_rpc_url.as_str().to_string()])
|
||||
.persister(swap::bitcoin::wallet::PersisterConfig::InMemorySqlite)
|
||||
.finality_confirmations(1_u32)
|
||||
.target_block(1_u32)
|
||||
|
@ -833,6 +835,49 @@ impl TestContext {
|
|||
|
||||
Ok(self.bob_starting_balances.btc - self.btc_amount - lock_tx_bitcoin_fee)
|
||||
}
|
||||
|
||||
pub async fn stop_alice_monero_wallet_rpc(&self) {
|
||||
self.alice_monero_wallet.lock().await.stop().await.unwrap();
|
||||
|
||||
// Wait until the monero-wallet-rpc is fully stopped
|
||||
// stop_wallet() internally sets a flag in the monero-wallet-rpc (`m_stop`) which
|
||||
// is only checked every once in a while
|
||||
loop {
|
||||
if !self
|
||||
.alice_monero_wallet
|
||||
.lock()
|
||||
.await
|
||||
.is_alive()
|
||||
.await
|
||||
.unwrap()
|
||||
{
|
||||
tracing::info!("Alice Monero Wallet RPC stopped");
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn empty_alice_monero_wallet(&self) {
|
||||
self.alice_monero_wallet
|
||||
.lock()
|
||||
.await
|
||||
.re_open()
|
||||
.await
|
||||
.unwrap();
|
||||
self.alice_monero_wallet.lock().await.sweep_all(Address::from_str("49LEH26DJGuCyr8xzRAzWPUryzp7bpccC7Hie1DiwyfJEyUKvMFAethRLybDYrFdU1eHaMkKQpUPebY4WT3cSjEvThmpjPa").unwrap()).await.unwrap();
|
||||
}
|
||||
|
||||
pub async fn assert_alice_monero_wallet_empty(&self) {
|
||||
assert_eventual_balance(
|
||||
&*self.alice_monero_wallet.lock().await,
|
||||
Ordering::Equal,
|
||||
monero::Amount::ZERO,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_eventual_balance<A: fmt::Display + PartialOrd>(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue