mirror of
https://github.com/comit-network/xmr-btc-swap.git
synced 2025-08-24 22:19:37 -04:00
feat(gui): Approve dialog before publishing Bitcoin lock transaction (#291)
This diff introduces a new "approvals" mechanism that alters the swap flow by requiring explicit user intervention before the Bitcoin lock transaction is broadcast. Previously, the Bitcoin lock was executed automatically without any user prompt. Now, the backend defines `ApprovalRequestType` (e.g. a `PreBtcLock` variant with details like `btc_lock_amount`, `btc_network_fee`, and `xmr_receive_amount`) and `ApprovalEvent` (with statuses such as `Pending`, `Resolved`, and `Rejected`). The method `request_approval()` in the `TauriHandle` struct uses a oneshot channel and concurrent timeout handling via `tokio::select!` to wait for the user's decision. Based on the outcome—explicit approval or timeout/rejection—the approval event is emitted through the `emit_approval()` helper, thereby gating the subsequent broadcast of the Bitcoin lock transaction. On the UI side, changes have been made to reflect the new flow; the modal (for example, in `SwapSetupInflightPage.tsx`) now displays the swap details along with explicit action buttons that call `resolveApproval()` via RPC when clicked. The Redux store, selectors, and hooks like `usePendingPreBtcLockApproval()` have been updated to track and display these approval events. As a result, the overall functionality now requires the user to explicitly approve the swap offer before proceeding, ensuring they are aware of the swap's key parameters and that the locking of funds occurs only after their confirmation.
This commit is contained in:
parent
ab5f93ff44
commit
9ddf2daafe
20 changed files with 613 additions and 65 deletions
|
@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- GUI: The user will now be asked to approve the swap offer again before the Bitcoin lock transaction is published. Makers should take care to only assume a swap has been accepted by the taker if the Bitcoin lock transaction is detected (`Advancing state state=bitcoin lock transaction in mempool ...`). Swaps that have been safely aborted will not be displayed in the GUI anymore.
|
||||||
|
|
||||||
## [1.0.0-rc.16] - 2025-04-17
|
## [1.0.0-rc.16] - 2025-04-17
|
||||||
|
|
||||||
- ASB: Quotes are now cached (Time-to-live of 2 minutes) to avoid overloading the maker with requests in times of high demand
|
- ASB: Quotes are now cached (Time-to-live of 2 minutes) to avoid overloading the maker with requests in times of high demand
|
||||||
|
|
43
Cargo.lock
generated
43
Cargo.lock
generated
|
@ -3309,6 +3309,18 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
"wasi 0.14.2+wasi-0.2.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ghash"
|
name = "ghash"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
@ -7126,6 +7138,12 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "radium"
|
name = "radium"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
@ -11463,6 +11481,7 @@ dependencies = [
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -11527,11 +11546,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.11.0"
|
version = "1.16.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a"
|
checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.15",
|
"getrandom 0.3.2",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -11667,6 +11686,15 @@ version = "0.11.0+wasi-snapshot-preview1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.14.2+wasi-0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen-rt",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasite"
|
name = "wasite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
@ -12407,6 +12435,15 @@ dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rt"
|
||||||
|
version = "0.39.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.9.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "write16"
|
name = "write16"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
# You can configure the address of a locally running testnet asb. It'll displayed in the GUI. This is useful for testing
|
# You can configure the address of a locally running testnet asb. It'll displayed in the GUI. This is useful for testing
|
||||||
VITE_TESTNET_STUB_PROVIDER_ADDRESS=/onion3/clztcslas7hlfrprevcdo3l6bczwa3cumr2b5up5nsumsj7sqgd3p2qd:9939/p2p/12D3KooWS1DtT4JmZoAS6m4wZcxXnUB3eVFNvW8hSPrAyCtVSSYm
|
VITE_TESTNET_STUB_PROVIDER_ADDRESS=/onion3/dmdrgmy27szmps3p5zqh4ujd7twoi2a5ao7mouugfg6owyj4ikd2h5yd:9939/p2p/12D3KooWCa6vLE6SFhEBs3EhsC5tCBoHKBLoLEo1riDDmcExr5BW
|
|
@ -1,5 +1,6 @@
|
||||||
import { exhaustiveGuard } from "utils/typescriptUtils";
|
import { exhaustiveGuard } from "utils/typescriptUtils";
|
||||||
import {
|
import {
|
||||||
|
ApprovalRequest,
|
||||||
ExpiredTimelocks,
|
ExpiredTimelocks,
|
||||||
GetSwapInfoResponse,
|
GetSwapInfoResponse,
|
||||||
TauriSwapProgressEvent,
|
TauriSwapProgressEvent,
|
||||||
|
@ -209,3 +210,23 @@ export function isGetSwapInfoResponseWithTimelock(
|
||||||
): response is GetSwapInfoResponseExtWithTimelock {
|
): response is GetSwapInfoResponseExtWithTimelock {
|
||||||
return response.timelock !== null;
|
return response.timelock !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PendingApprovalRequest = Extract<ApprovalRequest, { state: "Pending" }>;
|
||||||
|
|
||||||
|
export type PendingLockBitcoinApprovalRequest = PendingApprovalRequest & {
|
||||||
|
content: {
|
||||||
|
details: { type: "LockBitcoin" };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isPendingLockBitcoinApprovalEvent(
|
||||||
|
event: ApprovalRequest,
|
||||||
|
): event is PendingLockBitcoinApprovalRequest {
|
||||||
|
// Check if the request is pending
|
||||||
|
if (event.state !== "Pending") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the request is a LockBitcoin request
|
||||||
|
return event.content.details.type === "LockBitcoin";
|
||||||
|
}
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { TauriSwapProgressEventWrapper, TauriContextStatusEvent, TauriLogEvent, BalanceResponse, TauriDatabaseStateEvent, TauriTimelockChangeEvent, TauriBackgroundRefundEvent, TauriTorEvent } from "models/tauriModel";
|
import { TauriSwapProgressEventWrapper, TauriContextStatusEvent, TauriLogEvent, BalanceResponse, TauriDatabaseStateEvent, TauriTimelockChangeEvent, TauriBackgroundRefundEvent, ApprovalRequest } from "models/tauriModel";
|
||||||
import { contextStatusEventReceived, receivedCliLog, rpcSetBalance, timelockChangeEventReceived, rpcSetBackgroundRefundState } from "store/features/rpcSlice";
|
import { contextStatusEventReceived, receivedCliLog, rpcSetBalance, timelockChangeEventReceived, rpcSetBackgroundRefundState, approvalEventReceived } from "store/features/rpcSlice";
|
||||||
import { swapProgressEventReceived } from "store/features/swapSlice";
|
import { swapProgressEventReceived } from "store/features/swapSlice";
|
||||||
import logger from "utils/logger";
|
import logger from "utils/logger";
|
||||||
import { updatePublicRegistry, updateRates } from "./api";
|
import { updatePublicRegistry, updateRates } from "./api";
|
||||||
import { checkContextAvailability, getSwapInfo, initializeContext, updateAllNodeStatuses } from "./rpc";
|
import { checkContextAvailability, getSwapInfo, initializeContext, updateAllNodeStatuses } from "./rpc";
|
||||||
import { store } from "./store/storeRenderer";
|
import { store } from "./store/storeRenderer";
|
||||||
import { torEventReceived } from "store/features/torSlice";
|
|
||||||
|
|
||||||
// Update the public registry every 5 minutes
|
// Update the public registry every 5 minutes
|
||||||
const PROVIDER_UPDATE_INTERVAL = 5 * 60 * 1_000;
|
const PROVIDER_UPDATE_INTERVAL = 5 * 60 * 1_000;
|
||||||
|
@ -84,4 +83,9 @@ export async function setupBackgroundTasks(): Promise<void> {
|
||||||
logger.info('Received background refund event', event.payload);
|
logger.info('Received background refund event', event.payload);
|
||||||
store.dispatch(rpcSetBackgroundRefundState(event.payload));
|
store.dispatch(rpcSetBackgroundRefundState(event.payload));
|
||||||
})
|
})
|
||||||
|
|
||||||
|
listen<ApprovalRequest>("approval_event", (event) => {
|
||||||
|
logger.info("Received approval_event:", event.payload);
|
||||||
|
store.dispatch(approvalEventReceived(event.payload));
|
||||||
|
});
|
||||||
}
|
}
|
|
@ -26,9 +26,7 @@ export default function RemainingFundsWillBeUsedAlert() {
|
||||||
variant="filled"
|
variant="filled"
|
||||||
>
|
>
|
||||||
The remaining funds of <SatsAmount amount={balance} /> in the wallet
|
The remaining funds of <SatsAmount amount={balance} /> in the wallet
|
||||||
will be used for the next swap. If the remaining funds exceed the
|
will be used for the next swap
|
||||||
minimum swap amount of the maker, a swap will be initiated
|
|
||||||
instantaneously.
|
|
||||||
</Alert>
|
</Alert>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,6 +2,6 @@ import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle";
|
||||||
|
|
||||||
export default function ReceivedQuotePage() {
|
export default function ReceivedQuotePage() {
|
||||||
return (
|
return (
|
||||||
<CircularProgressWithSubtitle description="Processing received quote" />
|
<CircularProgressWithSubtitle description="Syncing local wallet" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,152 @@
|
||||||
import { TauriSwapProgressEventContent } from "models/tauriModelExt";
|
import { useState, useEffect } from 'react';
|
||||||
import { SatsAmount } from "renderer/components/other/Units";
|
import { resolveApproval } from 'renderer/rpc';
|
||||||
import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle";
|
import { PendingLockBitcoinApprovalRequest, TauriSwapProgressEventContent } from 'models/tauriModelExt';
|
||||||
|
import {
|
||||||
|
SatsAmount,
|
||||||
|
PiconeroAmount,
|
||||||
|
MoneroBitcoinExchangeRateFromAmounts
|
||||||
|
} from 'renderer/components/other/Units';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Divider,
|
||||||
|
} from '@material-ui/core';
|
||||||
|
import { makeStyles, createStyles, Theme } from '@material-ui/core/styles';
|
||||||
|
import { useActiveSwapId, usePendingLockBitcoinApproval } from 'store/hooks';
|
||||||
|
import PromiseInvokeButton from 'renderer/components/PromiseInvokeButton';
|
||||||
|
import InfoBox from 'renderer/components/modal/swap/InfoBox';
|
||||||
|
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
|
||||||
|
import CheckIcon from '@material-ui/icons/Check';
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
detailGrid: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'auto 1fr',
|
||||||
|
rowGap: theme.spacing(1),
|
||||||
|
columnGap: theme.spacing(2),
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBlock: theme.spacing(2),
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
},
|
||||||
|
receiveValue: {
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme.palette.success.main,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
marginTop: theme.spacing(2),
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: theme.spacing(2),
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/// A hook that returns the LockBitcoin confirmation request for the active swap
|
||||||
|
/// Returns null if no confirmation request is found
|
||||||
|
function useActiveLockBitcoinApprovalRequest(): PendingLockBitcoinApprovalRequest | null {
|
||||||
|
const approvals = usePendingLockBitcoinApproval();
|
||||||
|
const activeSwapId = useActiveSwapId();
|
||||||
|
|
||||||
|
return approvals
|
||||||
|
?.find(r => r.content.details.content.swap_id === activeSwapId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SwapSetupInflightPage({
|
export default function SwapSetupInflightPage({
|
||||||
btc_lock_amount,
|
btc_lock_amount,
|
||||||
btc_tx_lock_fee,
|
}: TauriSwapProgressEventContent<'SwapSetupInflight'>) {
|
||||||
}: TauriSwapProgressEventContent<"SwapSetupInflight">) {
|
const classes = useStyles();
|
||||||
|
const request = useActiveLockBitcoinApprovalRequest();
|
||||||
|
|
||||||
|
const [timeLeft, setTimeLeft] = useState<number>(0);
|
||||||
|
|
||||||
|
const expiresAtMs = request?.content.expiration_ts * 1000 || 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tick = () => {
|
||||||
|
const remainingMs = Math.max(expiresAtMs - Date.now(), 0);
|
||||||
|
setTimeLeft(Math.ceil(remainingMs / 1000));
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
const id = setInterval(tick, 250);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [expiresAtMs]);
|
||||||
|
|
||||||
|
// If we do not have an approval request yet for the Bitcoin lock transaction, we haven't received the offer from Alice yet
|
||||||
|
// Display a loading spinner to the user for as long as the swap_setup request is in flight
|
||||||
|
if (!request) {
|
||||||
|
return <CircularProgressWithSubtitle description={<>Negotiating offer for <SatsAmount amount={btc_lock_amount} /></>} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { btc_network_fee, xmr_receive_amount } = request.content.details.content;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CircularProgressWithSubtitle
|
<InfoBox
|
||||||
description={
|
title="Approve Swap"
|
||||||
|
icon={<></>}
|
||||||
|
loading={false}
|
||||||
|
mainContent={
|
||||||
<>
|
<>
|
||||||
Starting swap with maker to lock <SatsAmount amount={btc_lock_amount} />
|
<Divider />
|
||||||
|
<Box className={classes.detailGrid}>
|
||||||
|
<Typography className={classes.label}>You send</Typography>
|
||||||
|
<Typography>
|
||||||
|
<SatsAmount amount={btc_lock_amount} />
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography className={classes.label}>Bitcoin network fees</Typography>
|
||||||
|
<Typography>
|
||||||
|
<SatsAmount amount={btc_network_fee} />
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography className={classes.label}>You receive</Typography>
|
||||||
|
<Typography className={classes.receiveValue}>
|
||||||
|
<PiconeroAmount amount={xmr_receive_amount} />
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography className={classes.label}>Exchange rate</Typography>
|
||||||
|
<Typography>
|
||||||
|
<MoneroBitcoinExchangeRateFromAmounts
|
||||||
|
satsAmount={btc_lock_amount}
|
||||||
|
piconeroAmount={xmr_receive_amount}
|
||||||
|
displayMarkup
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
additionalContent={
|
||||||
|
<Box className={classes.actions}>
|
||||||
|
<PromiseInvokeButton
|
||||||
|
variant="text"
|
||||||
|
size="large"
|
||||||
|
className={classes.cancelButton}
|
||||||
|
onInvoke={() => resolveApproval(request.content.request_id, false)}
|
||||||
|
displayErrorSnackbar
|
||||||
|
requiresContext
|
||||||
|
>
|
||||||
|
Deny
|
||||||
|
</PromiseInvokeButton>
|
||||||
|
|
||||||
|
<PromiseInvokeButton
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
onInvoke={() => resolveApproval(request.content.request_id, true)}
|
||||||
|
displayErrorSnackbar
|
||||||
|
requiresContext
|
||||||
|
endIcon={<CheckIcon />}
|
||||||
|
>
|
||||||
|
{`Confirm & lock BTC (${timeLeft}s)`}
|
||||||
|
</PromiseInvokeButton>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -109,7 +109,7 @@ export default function InitPage() {
|
||||||
onInvoke={init}
|
onInvoke={init}
|
||||||
displayErrorSnackbar
|
displayErrorSnackbar
|
||||||
>
|
>
|
||||||
Request quote and start swap
|
Begin swap
|
||||||
</PromiseInvokeButton>
|
</PromiseInvokeButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { Box, makeStyles, Typography } from "@material-ui/core";
|
import { Box, makeStyles, Typography } from "@material-ui/core";
|
||||||
import { TauriSwapProgressEventContent } from "models/tauriModelExt";
|
import { TauriSwapProgressEventContent } from "models/tauriModelExt";
|
||||||
import { useAppSelector } from "store/hooks";
|
|
||||||
import BitcoinIcon from "../../../../icons/BitcoinIcon";
|
import BitcoinIcon from "../../../../icons/BitcoinIcon";
|
||||||
import { MoneroSatsExchangeRate, SatsAmount } from "../../../../other/Units";
|
import { MoneroSatsExchangeRate, SatsAmount } from "../../../../other/Units";
|
||||||
import DepositAddressInfoBox from "../../DepositAddressInfoBox";
|
import DepositAddressInfoBox from "../../DepositAddressInfoBox";
|
||||||
|
@ -57,18 +56,17 @@ export default function WaitingForBtcDepositPage({
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
All Bitcoin sent to this this address will converted into
|
Bitcoin sent to this this address will be converted into
|
||||||
Monero at an exchance rate of{" "}
|
Monero at an exchange rate of{" ≈ "}
|
||||||
<MoneroSatsExchangeRate rate={quote.price} displayMarkup={true} />
|
<MoneroSatsExchangeRate rate={quote.price} displayMarkup={true} />
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
The network fee of{" "}
|
The Network fee of{" ≈ "}
|
||||||
<SatsAmount amount={min_bitcoin_lock_tx_fee} /> will
|
<SatsAmount amount={min_bitcoin_lock_tx_fee} /> will
|
||||||
automatically be deducted from the deposited coins
|
automatically be deducted from the deposited coins
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
The swap will start automatically as soon as the minimum
|
After the deposit is detected, you'll get to confirm the exact details before your funds are locked
|
||||||
amount is deposited.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<DepositAmountHelper
|
<DepositAmountHelper
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import { invoke as invokeUnsafe } from "@tauri-apps/api/core";
|
import { invoke as invokeUnsafe } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import {
|
import {
|
||||||
BalanceArgs,
|
BalanceArgs,
|
||||||
BalanceResponse,
|
BalanceResponse,
|
||||||
BuyXmrArgs,
|
BuyXmrArgs,
|
||||||
BuyXmrResponse,
|
BuyXmrResponse,
|
||||||
TauriLogEvent,
|
|
||||||
GetLogsArgs,
|
GetLogsArgs,
|
||||||
GetLogsResponse,
|
GetLogsResponse,
|
||||||
GetSwapInfoResponse,
|
GetSwapInfoResponse,
|
||||||
|
@ -14,12 +12,8 @@ import {
|
||||||
ResumeSwapArgs,
|
ResumeSwapArgs,
|
||||||
ResumeSwapResponse,
|
ResumeSwapResponse,
|
||||||
SuspendCurrentSwapResponse,
|
SuspendCurrentSwapResponse,
|
||||||
TauriContextStatusEvent,
|
|
||||||
TauriSwapProgressEventWrapper,
|
|
||||||
WithdrawBtcArgs,
|
WithdrawBtcArgs,
|
||||||
WithdrawBtcResponse,
|
WithdrawBtcResponse,
|
||||||
TauriDatabaseStateEvent,
|
|
||||||
TauriTimelockChangeEvent,
|
|
||||||
GetSwapInfoArgs,
|
GetSwapInfoArgs,
|
||||||
ExportBitcoinWalletResponse,
|
ExportBitcoinWalletResponse,
|
||||||
CheckMoneroNodeArgs,
|
CheckMoneroNodeArgs,
|
||||||
|
@ -28,25 +22,21 @@ import {
|
||||||
CheckElectrumNodeArgs,
|
CheckElectrumNodeArgs,
|
||||||
CheckElectrumNodeResponse,
|
CheckElectrumNodeResponse,
|
||||||
GetMoneroAddressesResponse,
|
GetMoneroAddressesResponse,
|
||||||
TauriBackgroundRefundEvent,
|
|
||||||
GetDataDirArgs,
|
GetDataDirArgs,
|
||||||
|
ResolveApprovalArgs,
|
||||||
|
ResolveApprovalResponse,
|
||||||
} from "models/tauriModel";
|
} from "models/tauriModel";
|
||||||
import {
|
import {
|
||||||
contextStatusEventReceived,
|
|
||||||
receivedCliLog,
|
|
||||||
rpcSetBackgroundRefundState,
|
|
||||||
rpcSetBalance,
|
rpcSetBalance,
|
||||||
rpcSetSwapInfo,
|
rpcSetSwapInfo,
|
||||||
timelockChangeEventReceived,
|
|
||||||
} from "store/features/rpcSlice";
|
} from "store/features/rpcSlice";
|
||||||
import { swapProgressEventReceived } from "store/features/swapSlice";
|
|
||||||
import { store } from "./store/storeRenderer";
|
import { store } from "./store/storeRenderer";
|
||||||
import { Maker } from "models/apiModel";
|
import { Maker } from "models/apiModel";
|
||||||
import { providerToConcatenatedMultiAddr } from "utils/multiAddrUtils";
|
import { providerToConcatenatedMultiAddr } from "utils/multiAddrUtils";
|
||||||
import { MoneroRecoveryResponse } from "models/rpcModel";
|
import { MoneroRecoveryResponse } from "models/rpcModel";
|
||||||
import { ListSellersResponse } from "../models/tauriModel";
|
import { ListSellersResponse } from "../models/tauriModel";
|
||||||
import logger from "utils/logger";
|
import logger from "utils/logger";
|
||||||
import { getNetwork, getNetworkName, isTestnet } from "store/config";
|
import { getNetwork, isTestnet } from "store/config";
|
||||||
import { Blockchain, Network } from "store/features/settingsSlice";
|
import { Blockchain, Network } from "store/features/settingsSlice";
|
||||||
import { setStatus } from "store/features/nodesSlice";
|
import { setStatus } from "store/features/nodesSlice";
|
||||||
import { discoveredMakersByRendezvous } from "store/features/makersSlice";
|
import { discoveredMakersByRendezvous } from "store/features/makersSlice";
|
||||||
|
@ -60,7 +50,7 @@ export async function fetchSellersAtPresetRendezvousPoints() {
|
||||||
const response = await listSellersAtRendezvousPoint(rendezvousPoint);
|
const response = await listSellersAtRendezvousPoint(rendezvousPoint);
|
||||||
store.dispatch(discoveredMakersByRendezvous(response.sellers));
|
store.dispatch(discoveredMakersByRendezvous(response.sellers));
|
||||||
|
|
||||||
logger.log(`Discovered ${response.sellers.length} sellers at rendezvous point ${rendezvousPoint} during startup fetch`);
|
logger.info(`Discovered ${response.sellers.length} sellers at rendezvous point ${rendezvousPoint} during startup fetch`);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -285,3 +275,7 @@ export async function getDataDir(): Promise<string> {
|
||||||
is_testnet: testnet,
|
is_testnet: testnet,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveApproval(requestId: string, accept: boolean): Promise<void> {
|
||||||
|
await invoke<ResolveApprovalArgs, ResolveApprovalResponse>("resolve_approval_request", { request_id: requestId, accept });
|
||||||
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
TauriContextStatusEvent,
|
TauriContextStatusEvent,
|
||||||
TauriTimelockChangeEvent,
|
TauriTimelockChangeEvent,
|
||||||
BackgroundRefundState,
|
BackgroundRefundState,
|
||||||
|
ApprovalRequest,
|
||||||
} from "models/tauriModel";
|
} from "models/tauriModel";
|
||||||
import { MoneroRecoveryResponse } from "../../models/rpcModel";
|
import { MoneroRecoveryResponse } from "../../models/rpcModel";
|
||||||
import { GetSwapInfoResponseExt } from "models/tauriModelExt";
|
import { GetSwapInfoResponseExt } from "models/tauriModelExt";
|
||||||
|
@ -32,6 +33,10 @@ interface State {
|
||||||
swapId: string;
|
swapId: string;
|
||||||
state: BackgroundRefundState;
|
state: BackgroundRefundState;
|
||||||
} | null;
|
} | null;
|
||||||
|
approvalRequests: {
|
||||||
|
// Store the full event, keyed by request_id
|
||||||
|
[requestId: string]: ApprovalRequest;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RPCSlice {
|
export interface RPCSlice {
|
||||||
|
@ -52,6 +57,7 @@ const initialState: RPCSlice = {
|
||||||
updateState: false,
|
updateState: false,
|
||||||
},
|
},
|
||||||
backgroundRefund: null,
|
backgroundRefund: null,
|
||||||
|
approvalRequests: {},
|
||||||
},
|
},
|
||||||
logs: [],
|
logs: [],
|
||||||
};
|
};
|
||||||
|
@ -138,6 +144,11 @@ export const rpcSlice = createSlice({
|
||||||
state: action.payload.state,
|
state: action.payload.state,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
approvalEventReceived(slice, action: PayloadAction<ApprovalRequest>) {
|
||||||
|
const event = action.payload;
|
||||||
|
const requestId = event.content.request_id;
|
||||||
|
slice.state.approvalRequests[requestId] = event;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -152,7 +163,8 @@ export const {
|
||||||
rpcSetMoneroRecoveryKeys,
|
rpcSetMoneroRecoveryKeys,
|
||||||
rpcResetMoneroRecoveryKeys,
|
rpcResetMoneroRecoveryKeys,
|
||||||
rpcSetBackgroundRefundState,
|
rpcSetBackgroundRefundState,
|
||||||
timelockChangeEventReceived
|
timelockChangeEventReceived,
|
||||||
|
approvalEventReceived,
|
||||||
} = rpcSlice.actions;
|
} = rpcSlice.actions;
|
||||||
|
|
||||||
export default rpcSlice.reducer;
|
export default rpcSlice.reducer;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { sortBy } from "lodash";
|
import { sortBy } from "lodash";
|
||||||
import { BobStateName, GetSwapInfoResponseExt } from "models/tauriModelExt";
|
import { BobStateName, GetSwapInfoResponseExt, PendingApprovalRequest, PendingLockBitcoinApprovalRequest } from "models/tauriModelExt";
|
||||||
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
|
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
|
||||||
import type { AppDispatch, RootState } from "renderer/store/storeRenderer";
|
import type { AppDispatch, RootState } from "renderer/store/storeRenderer";
|
||||||
import { parseDateString } from "utils/parseUtils";
|
import { parseDateString } from "utils/parseUtils";
|
||||||
|
@ -16,22 +16,29 @@ export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||||
export function useResumeableSwapsCount(
|
export function useResumeableSwapsCount(
|
||||||
additionalFilter?: (s: GetSwapInfoResponseExt) => boolean,
|
additionalFilter?: (s: GetSwapInfoResponseExt) => boolean,
|
||||||
) {
|
) {
|
||||||
|
const saneSwapInfos = useSaneSwapInfos();
|
||||||
|
|
||||||
return useAppSelector(
|
return useAppSelector(
|
||||||
(state) =>
|
(state) =>
|
||||||
Object.values(state.rpc.state.swapInfos).filter(
|
saneSwapInfos.filter(
|
||||||
(swapInfo: GetSwapInfoResponseExt) =>
|
(swapInfo: GetSwapInfoResponseExt) =>
|
||||||
!swapInfo.completed && (additionalFilter == null || additionalFilter(swapInfo))
|
!swapInfo.completed && (additionalFilter == null || additionalFilter(swapInfo))
|
||||||
).length,
|
).length,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts the number of resumeable swaps excluding:
|
||||||
|
* - Punished swaps
|
||||||
|
* - Swaps where the sanity check was not passed (e.g. they were aborted)
|
||||||
|
*/
|
||||||
export function useResumeableSwapsCountExcludingPunished() {
|
export function useResumeableSwapsCountExcludingPunished() {
|
||||||
return useResumeableSwapsCount(
|
return useResumeableSwapsCount(
|
||||||
(s) => s.state_name !== BobStateName.BtcPunished,
|
(s) => s.state_name !== BobStateName.BtcPunished && s.state_name !== BobStateName.SwapSetupCompleted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if we have a swap that is running
|
||||||
export function useIsSwapRunning() {
|
export function useIsSwapRunning() {
|
||||||
return useAppSelector(
|
return useAppSelector(
|
||||||
(state) =>
|
(state) =>
|
||||||
|
@ -43,6 +50,8 @@ export function useIsContextAvailable() {
|
||||||
return useAppSelector((state) => state.rpc.status?.type === "Available");
|
return useAppSelector((state) => state.rpc.status?.type === "Available");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// We do not use a sanity check here, as opposed to the other useSwapInfo hooks,
|
||||||
|
/// because we are explicitly asking for a specific swap
|
||||||
export function useSwapInfo(
|
export function useSwapInfo(
|
||||||
swapId: string | null,
|
swapId: string | null,
|
||||||
): GetSwapInfoResponseExt | null {
|
): GetSwapInfoResponseExt | null {
|
||||||
|
@ -51,7 +60,7 @@ export function useSwapInfo(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useActiveSwapId() {
|
export function useActiveSwapId(): string | null {
|
||||||
return useAppSelector((s) => s.swap.state?.swapId ?? null);
|
return useAppSelector((s) => s.swap.state?.swapId ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,11 +89,36 @@ export function useAllMakers() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSwapInfosSortedByDate() {
|
/// This hook returns the all swap infos, as an array
|
||||||
|
/// Excluding those who are in a state where it's better to hide them from the user
|
||||||
|
export function useSaneSwapInfos() {
|
||||||
const swapInfos = useAppSelector((state) => state.rpc.state.swapInfos);
|
const swapInfos = useAppSelector((state) => state.rpc.state.swapInfos);
|
||||||
|
return Object.values(swapInfos).filter((swap) => {
|
||||||
|
// We hide swaps that are in the SwapSetupCompleted state
|
||||||
|
// This is because they are probably ones where:
|
||||||
|
// 1. The user force stopped the swap while we were waiting for their confirmation of the offer
|
||||||
|
// 2. We where therefore unable to transition to SafelyAborted
|
||||||
|
if (swap.state_name === BobStateName.SwapSetupCompleted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We hide swaps that were safely aborted
|
||||||
|
// No funds were locked. Cannot be resumed.
|
||||||
|
// Wouldn't be beneficial to show them to the user
|
||||||
|
if (swap.state_name === BobStateName.SafelyAborted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This hook returns the swap infos sorted by date
|
||||||
|
export function useSwapInfosSortedByDate() {
|
||||||
|
const swapInfos = useSaneSwapInfos();
|
||||||
|
|
||||||
return sortBy(
|
return sortBy(
|
||||||
Object.values(swapInfos),
|
swapInfos,
|
||||||
(swap) => -parseDateString(swap.start_date),
|
(swap) => -parseDateString(swap.start_date),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -103,3 +137,13 @@ export function useNodes<T>(selector: (nodes: NodesSlice) => T): T {
|
||||||
const nodes = useAppSelector((state) => state.nodes);
|
const nodes = useAppSelector((state) => state.nodes);
|
||||||
return selector(nodes);
|
return selector(nodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usePendingApprovals(): PendingApprovalRequest[] {
|
||||||
|
const approvals = useAppSelector((state) => state.rpc.state.approvalRequests);
|
||||||
|
return Object.values(approvals).filter((c) => c.state === "Pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePendingLockBitcoinApproval(): PendingLockBitcoinApprovalRequest[] {
|
||||||
|
const approvals = usePendingApprovals();
|
||||||
|
return approvals.filter((c) => c.content.details.type === "LockBitcoin");
|
||||||
|
}
|
|
@ -28,6 +28,7 @@ tauri-plugin-shell = "^2.0.0"
|
||||||
tauri-plugin-store = "^2.0.0"
|
tauri-plugin-store = "^2.0.0"
|
||||||
tauri-plugin-updater = "^2.1.0"
|
tauri-plugin-updater = "^2.1.0"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
uuid = "1.16.0"
|
||||||
|
|
||||||
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
||||||
tauri-plugin-cli = "^2.0.0"
|
tauri-plugin-cli = "^2.0.0"
|
||||||
|
|
|
@ -9,7 +9,8 @@ use swap::cli::{
|
||||||
CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse,
|
CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse,
|
||||||
ExportBitcoinWalletArgs, GetDataDirArgs, GetHistoryArgs, GetLogsArgs,
|
ExportBitcoinWalletArgs, GetDataDirArgs, GetHistoryArgs, GetLogsArgs,
|
||||||
GetMoneroAddressesArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs,
|
GetMoneroAddressesArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs,
|
||||||
MoneroRecoveryArgs, ResumeSwapArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs,
|
MoneroRecoveryArgs, ResolveApprovalArgs, ResumeSwapArgs, SuspendCurrentSwapArgs,
|
||||||
|
WithdrawBtcArgs,
|
||||||
},
|
},
|
||||||
tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings},
|
tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings},
|
||||||
Context, ContextBuilder,
|
Context, ContextBuilder,
|
||||||
|
@ -17,6 +18,7 @@ use swap::cli::{
|
||||||
command::{Bitcoin, Monero},
|
command::{Bitcoin, Monero},
|
||||||
};
|
};
|
||||||
use tauri::{async_runtime::RwLock, Manager, RunEvent};
|
use tauri::{async_runtime::RwLock, Manager, RunEvent};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Trait to convert Result<T, E> to Result<T, String>
|
/// Trait to convert Result<T, E> to Result<T, String>
|
||||||
/// Tauri commands require the error type to be a string
|
/// Tauri commands require the error type to be a string
|
||||||
|
@ -183,7 +185,8 @@ pub fn run() {
|
||||||
check_monero_node,
|
check_monero_node,
|
||||||
check_electrum_node,
|
check_electrum_node,
|
||||||
get_wallet_descriptor,
|
get_wallet_descriptor,
|
||||||
get_data_dir
|
get_data_dir,
|
||||||
|
resolve_approval_request,
|
||||||
])
|
])
|
||||||
.setup(setup)
|
.setup(setup)
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
|
@ -224,6 +227,7 @@ tauri_command!(monero_recovery, MoneroRecoveryArgs);
|
||||||
tauri_command!(get_logs, GetLogsArgs);
|
tauri_command!(get_logs, GetLogsArgs);
|
||||||
tauri_command!(list_sellers, ListSellersArgs);
|
tauri_command!(list_sellers, ListSellersArgs);
|
||||||
tauri_command!(cancel_and_refund, CancelAndRefundArgs);
|
tauri_command!(cancel_and_refund, CancelAndRefundArgs);
|
||||||
|
tauri_command!(resolve_approval_request, ResolveApprovalArgs);
|
||||||
|
|
||||||
// These commands require no arguments
|
// These commands require no arguments
|
||||||
tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args);
|
tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args);
|
||||||
|
|
|
@ -194,7 +194,6 @@ pub struct Context {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A conveniant builder struct for [`Context`].
|
/// A conveniant builder struct for [`Context`].
|
||||||
#[derive(Debug)]
|
|
||||||
#[must_use = "ContextBuilder must be built to be useful"]
|
#[must_use = "ContextBuilder must be built to be useful"]
|
||||||
pub struct ContextBuilder {
|
pub struct ContextBuilder {
|
||||||
monero: Option<Monero>,
|
monero: Option<Monero>,
|
||||||
|
@ -512,6 +511,10 @@ impl Context {
|
||||||
pub fn bitcoin_wallet(&self) -> Option<Arc<bitcoin::Wallet>> {
|
pub fn bitcoin_wallet(&self) -> Option<Arc<bitcoin::Wallet>> {
|
||||||
self.bitcoin_wallet.clone()
|
self.bitcoin_wallet.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn tauri_handle(&self) -> Option<TauriHandle> {
|
||||||
|
self.tauri_handle.clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for Context {
|
impl fmt::Debug for Context {
|
||||||
|
|
|
@ -1375,3 +1375,32 @@ impl CheckElectrumNodeArgs {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
pub struct ResolveApprovalArgs {
|
||||||
|
pub request_id: String,
|
||||||
|
pub accept: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
pub struct ResolveApprovalResponse {
|
||||||
|
pub success: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Request for ResolveApprovalArgs {
|
||||||
|
type Response = ResolveApprovalResponse;
|
||||||
|
|
||||||
|
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
|
||||||
|
let request_id = Uuid::parse_str(&self.request_id).context("Invalid request ID")?;
|
||||||
|
|
||||||
|
if let Some(handle) = ctx.tauri_handle.clone() {
|
||||||
|
handle.resolve_approval(request_id, self.accept).await?;
|
||||||
|
} else {
|
||||||
|
bail!("Cannot resolve approval without a Tauri handle");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ResolveApprovalResponse { success: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,8 +1,15 @@
|
||||||
|
use crate::bitcoin;
|
||||||
use crate::{bitcoin::ExpiredTimelocks, monero, network::quote::BidQuote};
|
use crate::{bitcoin::ExpiredTimelocks, monero, network::quote::BidQuote};
|
||||||
use anyhow::Result;
|
use anyhow::{anyhow, Context, Result};
|
||||||
use bitcoin::Txid;
|
use bitcoin::Txid;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use strum::Display;
|
use strum::Display;
|
||||||
|
use tokio::sync::{oneshot, Mutex as TokioMutex};
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
@ -16,12 +23,70 @@ const TIMELOCK_CHANGE_EVENT_NAME: &str = "timelock-change";
|
||||||
const CONTEXT_INIT_PROGRESS_EVENT_NAME: &str = "context-init-progress-update";
|
const CONTEXT_INIT_PROGRESS_EVENT_NAME: &str = "context-init-progress-update";
|
||||||
const BALANCE_CHANGE_EVENT_NAME: &str = "balance-change";
|
const BALANCE_CHANGE_EVENT_NAME: &str = "balance-change";
|
||||||
const BACKGROUND_REFUND_EVENT_NAME: &str = "background-refund";
|
const BACKGROUND_REFUND_EVENT_NAME: &str = "background-refund";
|
||||||
|
const APPROVAL_EVENT_NAME: &str = "approval_event";
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[typeshare]
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct LockBitcoinDetails {
|
||||||
|
#[typeshare(serialized_as = "number")]
|
||||||
|
#[serde(with = "::bitcoin::util::amount::serde::as_sat")]
|
||||||
|
pub btc_lock_amount: bitcoin::Amount,
|
||||||
|
#[typeshare(serialized_as = "number")]
|
||||||
|
#[serde(with = "::bitcoin::util::amount::serde::as_sat")]
|
||||||
|
pub btc_network_fee: bitcoin::Amount,
|
||||||
|
#[typeshare(serialized_as = "number")]
|
||||||
|
pub xmr_receive_amount: monero::Amount,
|
||||||
|
#[typeshare(serialized_as = "string")]
|
||||||
|
pub swap_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", content = "content")]
|
||||||
|
pub enum ApprovalRequestDetails {
|
||||||
|
/// Request approval before locking Bitcoin.
|
||||||
|
/// Contains specific details for review.
|
||||||
|
LockBitcoin(LockBitcoinDetails),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "state", content = "content")]
|
||||||
|
pub enum ApprovalRequest {
|
||||||
|
Pending {
|
||||||
|
request_id: String,
|
||||||
|
#[typeshare(serialized_as = "number")]
|
||||||
|
expiration_ts: u64,
|
||||||
|
details: ApprovalRequestDetails,
|
||||||
|
},
|
||||||
|
Resolved {
|
||||||
|
request_id: String,
|
||||||
|
details: ApprovalRequestDetails,
|
||||||
|
},
|
||||||
|
Rejected {
|
||||||
|
request_id: String,
|
||||||
|
details: ApprovalRequestDetails,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingApproval {
|
||||||
|
responder: Option<oneshot::Sender<bool>>,
|
||||||
|
details: ApprovalRequestDetails,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
expiration_ts: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "tauri")]
|
||||||
|
struct TauriHandleInner {
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
pending_approvals: TokioMutex<HashMap<Uuid, PendingApproval>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct TauriHandle(
|
pub struct TauriHandle(
|
||||||
#[cfg(feature = "tauri")]
|
#[cfg(feature = "tauri")]
|
||||||
#[cfg_attr(feature = "tauri", allow(unused))]
|
#[cfg_attr(feature = "tauri", allow(unused))]
|
||||||
std::sync::Arc<tauri::AppHandle>,
|
Arc<TauriHandleInner>,
|
||||||
);
|
);
|
||||||
|
|
||||||
impl TauriHandle {
|
impl TauriHandle {
|
||||||
|
@ -29,20 +94,146 @@ impl TauriHandle {
|
||||||
pub fn new(tauri_handle: tauri::AppHandle) -> Self {
|
pub fn new(tauri_handle: tauri::AppHandle) -> Self {
|
||||||
Self(
|
Self(
|
||||||
#[cfg(feature = "tauri")]
|
#[cfg(feature = "tauri")]
|
||||||
std::sync::Arc::new(tauri_handle),
|
Arc::new(TauriHandleInner {
|
||||||
|
app_handle: tauri_handle,
|
||||||
|
pending_approvals: TokioMutex::new(HashMap::new()),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
pub fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()> {
|
pub fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()> {
|
||||||
#[cfg(feature = "tauri")]
|
#[cfg(feature = "tauri")]
|
||||||
tauri::Emitter::emit(self.0.as_ref(), event, payload).map_err(anyhow::Error::from)?;
|
{
|
||||||
|
let inner = self.0.as_ref();
|
||||||
|
tauri::Emitter::emit(&inner.app_handle, event, payload).map_err(anyhow::Error::from)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper to emit a approval event via the unified event name
|
||||||
|
fn emit_approval(&self, event: ApprovalRequest) -> Result<()> {
|
||||||
|
self.emit_tauri_event(APPROVAL_EVENT_NAME, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn request_approval(
|
||||||
|
&self,
|
||||||
|
request_type: ApprovalRequestDetails,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<bool> {
|
||||||
|
#[cfg(not(feature = "tauri"))]
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "tauri")]
|
||||||
|
{
|
||||||
|
// Compute absolute expiration timestamp, and UUID for the request
|
||||||
|
let request_id = Uuid::new_v4();
|
||||||
|
let now_secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
let expiration_ts = now_secs + timeout_secs;
|
||||||
|
|
||||||
|
// Build the approval event
|
||||||
|
let details = request_type.clone();
|
||||||
|
let pending_event = ApprovalRequest::Pending {
|
||||||
|
request_id: request_id.to_string(),
|
||||||
|
expiration_ts,
|
||||||
|
details: details.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit the creation of the approval request to the frontend
|
||||||
|
self.emit_approval(pending_event.clone())?;
|
||||||
|
|
||||||
|
tracing::debug!(%request_id, request=?pending_event, "Emitted approval request event");
|
||||||
|
|
||||||
|
// Construct the data structure we use to internally track the approval request
|
||||||
|
let (responder, receiver) = oneshot::channel();
|
||||||
|
let timeout_duration = Duration::from_secs(timeout_secs);
|
||||||
|
|
||||||
|
let pending = PendingApproval {
|
||||||
|
responder: Some(responder),
|
||||||
|
details: request_type.clone(),
|
||||||
|
expiration_ts,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lock map and insert the pending approval
|
||||||
|
{
|
||||||
|
let mut pending_map = self.0.pending_approvals.lock().await;
|
||||||
|
pending_map.insert(request_id, pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if the request will be accepted or rejected
|
||||||
|
// Either by being resolved by the user, or by timing out
|
||||||
|
let accepted = tokio::select! {
|
||||||
|
res = receiver => res.map_err(|_| anyhow!("Approval responder dropped"))?,
|
||||||
|
_ = tokio::time::sleep(timeout_duration) => {
|
||||||
|
tracing::debug!(%request_id, "Approval request timed out and was therefore rejected");
|
||||||
|
false
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut map = self.0.pending_approvals.lock().await;
|
||||||
|
if let Some(pending) = map.remove(&request_id) {
|
||||||
|
let event = if accepted {
|
||||||
|
ApprovalRequest::Resolved {
|
||||||
|
request_id: request_id.to_string(),
|
||||||
|
details: pending.details,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ApprovalRequest::Rejected {
|
||||||
|
request_id: request_id.to_string(),
|
||||||
|
details: pending.details,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.emit_approval(event)?;
|
||||||
|
tracing::debug!(%request_id, %accepted, "Resolved approval request");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(accepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve_approval(&self, request_id: Uuid, accepted: bool) -> Result<()> {
|
||||||
|
#[cfg(not(feature = "tauri"))]
|
||||||
|
{
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Cannot resolve approval: Tauri feature not enabled."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "tauri")]
|
||||||
|
{
|
||||||
|
let mut pending_map = self.0.pending_approvals.lock().await;
|
||||||
|
if let Some(pending) = pending_map.get_mut(&request_id) {
|
||||||
|
let _ = pending
|
||||||
|
.responder
|
||||||
|
.take()
|
||||||
|
.context("Approval responder was already consumed")?
|
||||||
|
.send(accepted);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Approval not found or already handled"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait TauriEmitter {
|
pub trait TauriEmitter {
|
||||||
|
fn request_approval<'life0, 'async_trait>(
|
||||||
|
&'life0 self,
|
||||||
|
request_type: ApprovalRequestDetails,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<bool>> + Send + 'async_trait>>
|
||||||
|
where
|
||||||
|
'life0: 'async_trait,
|
||||||
|
Self: 'async_trait;
|
||||||
|
|
||||||
fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()>;
|
fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()>;
|
||||||
|
|
||||||
fn emit_swap_progress_event(&self, swap_id: Uuid, event: TauriSwapProgressEvent) {
|
fn emit_swap_progress_event(&self, swap_id: Uuid, event: TauriSwapProgressEvent) {
|
||||||
|
@ -94,6 +285,18 @@ pub trait TauriEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TauriEmitter for TauriHandle {
|
impl TauriEmitter for TauriHandle {
|
||||||
|
fn request_approval<'life0, 'async_trait>(
|
||||||
|
&'life0 self,
|
||||||
|
request_type: ApprovalRequestDetails,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<bool>> + Send + 'async_trait>>
|
||||||
|
where
|
||||||
|
'life0: 'async_trait,
|
||||||
|
Self: 'async_trait,
|
||||||
|
{
|
||||||
|
Box::pin(self.request_approval(request_type, timeout_secs))
|
||||||
|
}
|
||||||
|
|
||||||
fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()> {
|
fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()> {
|
||||||
self.emit_tauri_event(event, payload)
|
self.emit_tauri_event(event, payload)
|
||||||
}
|
}
|
||||||
|
@ -103,9 +306,28 @@ impl TauriEmitter for Option<TauriHandle> {
|
||||||
fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()> {
|
fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
Some(tauri) => tauri.emit_tauri_event(event, payload),
|
Some(tauri) => tauri.emit_tauri_event(event, payload),
|
||||||
|
|
||||||
|
// If no TauriHandle is available, we just ignore the event and pretend as if it was emitted
|
||||||
None => Ok(()),
|
None => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn request_approval<'life0, 'async_trait>(
|
||||||
|
&'life0 self,
|
||||||
|
request_type: ApprovalRequestDetails,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<bool>> + Send + 'async_trait>>
|
||||||
|
where
|
||||||
|
'life0: 'async_trait,
|
||||||
|
Self: 'async_trait,
|
||||||
|
{
|
||||||
|
Box::pin(async move {
|
||||||
|
match self {
|
||||||
|
Some(tauri) => tauri.request_approval(request_type, timeout_secs).await,
|
||||||
|
None => Ok(true),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
|
|
|
@ -81,7 +81,7 @@ where
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
minutes = %env_config.bitcoin_lock_mempool_timeout.as_secs_f64() / 60.0,
|
minutes = %env_config.bitcoin_lock_mempool_timeout.as_secs_f64() / 60.0,
|
||||||
"TxLock lock was not seen in mempool in time",
|
"TxLock lock was not seen in mempool in time. Alice might have denied our offer.",
|
||||||
);
|
);
|
||||||
AliceState::SafelyAborted
|
AliceState::SafelyAborted
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,22 @@
|
||||||
use crate::bitcoin::wallet::ScriptStatus;
|
use crate::bitcoin::wallet::ScriptStatus;
|
||||||
use crate::bitcoin::{ExpiredTimelocks, TxCancel, TxRefund};
|
use crate::bitcoin::{ExpiredTimelocks, TxCancel, TxRefund};
|
||||||
use crate::cli::api::tauri_bindings::{TauriEmitter, TauriHandle, TauriSwapProgressEvent};
|
use crate::cli::api::tauri_bindings::ApprovalRequestDetails;
|
||||||
|
use crate::cli::api::tauri_bindings::{
|
||||||
|
LockBitcoinDetails, TauriEmitter, TauriHandle, TauriSwapProgressEvent,
|
||||||
|
};
|
||||||
use crate::cli::EventLoopHandle;
|
use crate::cli::EventLoopHandle;
|
||||||
use crate::network::cooperative_xmr_redeem_after_punish::Response::{Fullfilled, Rejected};
|
use crate::network::cooperative_xmr_redeem_after_punish::Response::{Fullfilled, Rejected};
|
||||||
use crate::network::swap_setup::bob::NewSwap;
|
use crate::network::swap_setup::bob::NewSwap;
|
||||||
use crate::protocol::bob::state::*;
|
use crate::protocol::bob::state::*;
|
||||||
use crate::protocol::{bob, Database};
|
use crate::protocol::{bob, Database};
|
||||||
use crate::{bitcoin, monero};
|
use crate::{bitcoin, monero};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context as AnyContext, Result};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::select;
|
use tokio::select;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const PRE_BTC_LOCK_APPROVAL_TIMEOUT_SECS: u64 = 120;
|
||||||
|
|
||||||
pub fn is_complete(state: &BobState) -> bool {
|
pub fn is_complete(state: &BobState) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
state,
|
state,
|
||||||
|
@ -145,6 +150,8 @@ async fn next_state(
|
||||||
// which can lead to the wallet not detect the transaction.
|
// which can lead to the wallet not detect the transaction.
|
||||||
let monero_wallet_restore_blockheight = monero_wallet.block_height().await?;
|
let monero_wallet_restore_blockheight = monero_wallet.block_height().await?;
|
||||||
|
|
||||||
|
let xmr_receive_amount = state2.xmr;
|
||||||
|
|
||||||
// Alice and Bob have exchanged info
|
// Alice and Bob have exchanged info
|
||||||
// Sign the Bitcoin lock transaction
|
// Sign the Bitcoin lock transaction
|
||||||
let (state3, tx_lock) = state2.lock_btc().await?;
|
let (state3, tx_lock) = state2.lock_btc().await?;
|
||||||
|
@ -153,12 +160,50 @@ async fn next_state(
|
||||||
.await
|
.await
|
||||||
.context("Failed to sign Bitcoin lock transaction")?;
|
.context("Failed to sign Bitcoin lock transaction")?;
|
||||||
|
|
||||||
// Publish the signed Bitcoin lock transaction
|
let btc_network_fee = tx_lock.fee().context("Failed to get fee")?;
|
||||||
let (..) = bitcoin_wallet.broadcast(signed_tx, "lock").await?;
|
let btc_lock_amount = bitcoin::Amount::from_sat(
|
||||||
|
signed_tx
|
||||||
|
.output
|
||||||
|
.get(0)
|
||||||
|
.context("Failed to get lock amount")?
|
||||||
|
.value,
|
||||||
|
);
|
||||||
|
|
||||||
BobState::BtcLocked {
|
let request = ApprovalRequestDetails::LockBitcoin(LockBitcoinDetails {
|
||||||
state3,
|
btc_lock_amount,
|
||||||
monero_wallet_restore_blockheight,
|
btc_network_fee,
|
||||||
|
xmr_receive_amount,
|
||||||
|
swap_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// We request approval before publishing the Bitcoin lock transaction, as the exchange rate determined at this step might be different from the
|
||||||
|
// we previously displayed to the user.
|
||||||
|
let approval_result = event_emitter
|
||||||
|
.request_approval(request, PRE_BTC_LOCK_APPROVAL_TIMEOUT_SECS)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match approval_result {
|
||||||
|
Ok(true) => {
|
||||||
|
tracing::debug!("User approved swap offer");
|
||||||
|
|
||||||
|
// Publish the signed Bitcoin lock transaction
|
||||||
|
let (..) = bitcoin_wallet.broadcast(signed_tx, "lock").await?;
|
||||||
|
|
||||||
|
BobState::BtcLocked {
|
||||||
|
state3,
|
||||||
|
monero_wallet_restore_blockheight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
tracing::warn!("User denied or timed out on swap offer approval");
|
||||||
|
|
||||||
|
BobState::SafelyAborted
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(%err, "Failed to get user approval for swap offer. Assuming swap was aborted.");
|
||||||
|
|
||||||
|
BobState::SafelyAborted
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Bob has locked Bitcoin
|
// Bob has locked Bitcoin
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue