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:
Mohan 2025-04-18 01:51:55 +02:00 committed by GitHub
parent ab5f93ff44
commit 9ddf2daafe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 613 additions and 65 deletions

View file

@ -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
VITE_TESTNET_STUB_PROVIDER_ADDRESS=/onion3/clztcslas7hlfrprevcdo3l6bczwa3cumr2b5up5nsumsj7sqgd3p2qd:9939/p2p/12D3KooWS1DtT4JmZoAS6m4wZcxXnUB3eVFNvW8hSPrAyCtVSSYm
VITE_TESTNET_STUB_PROVIDER_ADDRESS=/onion3/dmdrgmy27szmps3p5zqh4ujd7twoi2a5ao7mouugfg6owyj4ikd2h5yd:9939/p2p/12D3KooWCa6vLE6SFhEBs3EhsC5tCBoHKBLoLEo1riDDmcExr5BW

View file

@ -1,5 +1,6 @@
import { exhaustiveGuard } from "utils/typescriptUtils";
import {
ApprovalRequest,
ExpiredTimelocks,
GetSwapInfoResponse,
TauriSwapProgressEvent,
@ -209,3 +210,23 @@ export function isGetSwapInfoResponseWithTimelock(
): response is GetSwapInfoResponseExtWithTimelock {
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";
}

View file

@ -1,12 +1,11 @@
import { listen } from "@tauri-apps/api/event";
import { TauriSwapProgressEventWrapper, TauriContextStatusEvent, TauriLogEvent, BalanceResponse, TauriDatabaseStateEvent, TauriTimelockChangeEvent, TauriBackgroundRefundEvent, TauriTorEvent } from "models/tauriModel";
import { contextStatusEventReceived, receivedCliLog, rpcSetBalance, timelockChangeEventReceived, rpcSetBackgroundRefundState } from "store/features/rpcSlice";
import { TauriSwapProgressEventWrapper, TauriContextStatusEvent, TauriLogEvent, BalanceResponse, TauriDatabaseStateEvent, TauriTimelockChangeEvent, TauriBackgroundRefundEvent, ApprovalRequest } from "models/tauriModel";
import { contextStatusEventReceived, receivedCliLog, rpcSetBalance, timelockChangeEventReceived, rpcSetBackgroundRefundState, approvalEventReceived } from "store/features/rpcSlice";
import { swapProgressEventReceived } from "store/features/swapSlice";
import logger from "utils/logger";
import { updatePublicRegistry, updateRates } from "./api";
import { checkContextAvailability, getSwapInfo, initializeContext, updateAllNodeStatuses } from "./rpc";
import { store } from "./store/storeRenderer";
import { torEventReceived } from "store/features/torSlice";
// Update the public registry every 5 minutes
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);
store.dispatch(rpcSetBackgroundRefundState(event.payload));
})
listen<ApprovalRequest>("approval_event", (event) => {
logger.info("Received approval_event:", event.payload);
store.dispatch(approvalEventReceived(event.payload));
});
}

View file

@ -26,9 +26,7 @@ export default function RemainingFundsWillBeUsedAlert() {
variant="filled"
>
The remaining funds of <SatsAmount amount={balance} /> in the wallet
will be used for the next swap. If the remaining funds exceed the
minimum swap amount of the maker, a swap will be initiated
instantaneously.
will be used for the next swap
</Alert>
</Box>
);

View file

@ -2,6 +2,6 @@ import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle";
export default function ReceivedQuotePage() {
return (
<CircularProgressWithSubtitle description="Processing received quote" />
<CircularProgressWithSubtitle description="Syncing local wallet" />
);
}

View file

@ -1,18 +1,152 @@
import { TauriSwapProgressEventContent } from "models/tauriModelExt";
import { SatsAmount } from "renderer/components/other/Units";
import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle";
import { useState, useEffect } from 'react';
import { resolveApproval } from 'renderer/rpc';
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({
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 (
<CircularProgressWithSubtitle
description={
<InfoBox
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>
}
/>
);
}
}

View file

@ -109,7 +109,7 @@ export default function InitPage() {
onInvoke={init}
displayErrorSnackbar
>
Request quote and start swap
Begin swap
</PromiseInvokeButton>
</Box>
</Box>

View file

@ -1,6 +1,5 @@
import { Box, makeStyles, Typography } from "@material-ui/core";
import { TauriSwapProgressEventContent } from "models/tauriModelExt";
import { useAppSelector } from "store/hooks";
import BitcoinIcon from "../../../../icons/BitcoinIcon";
import { MoneroSatsExchangeRate, SatsAmount } from "../../../../other/Units";
import DepositAddressInfoBox from "../../DepositAddressInfoBox";
@ -57,18 +56,17 @@ export default function WaitingForBtcDepositPage({
)}
</li>
<li>
All Bitcoin sent to this this address will converted into
Monero at an exchance rate of{" "}
Bitcoin sent to this this address will be converted into
Monero at an exchange rate of{" ≈ "}
<MoneroSatsExchangeRate rate={quote.price} displayMarkup={true} />
</li>
<li>
The network fee of{" "}
The Network fee of{" "}
<SatsAmount amount={min_bitcoin_lock_tx_fee} /> will
automatically be deducted from the deposited coins
</li>
<li>
The swap will start automatically as soon as the minimum
amount is deposited.
After the deposit is detected, you'll get to confirm the exact details before your funds are locked
</li>
<li>
<DepositAmountHelper

View file

@ -1,11 +1,9 @@
import { invoke as invokeUnsafe } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import {
BalanceArgs,
BalanceResponse,
BuyXmrArgs,
BuyXmrResponse,
TauriLogEvent,
GetLogsArgs,
GetLogsResponse,
GetSwapInfoResponse,
@ -14,12 +12,8 @@ import {
ResumeSwapArgs,
ResumeSwapResponse,
SuspendCurrentSwapResponse,
TauriContextStatusEvent,
TauriSwapProgressEventWrapper,
WithdrawBtcArgs,
WithdrawBtcResponse,
TauriDatabaseStateEvent,
TauriTimelockChangeEvent,
GetSwapInfoArgs,
ExportBitcoinWalletResponse,
CheckMoneroNodeArgs,
@ -28,25 +22,21 @@ import {
CheckElectrumNodeArgs,
CheckElectrumNodeResponse,
GetMoneroAddressesResponse,
TauriBackgroundRefundEvent,
GetDataDirArgs,
ResolveApprovalArgs,
ResolveApprovalResponse,
} from "models/tauriModel";
import {
contextStatusEventReceived,
receivedCliLog,
rpcSetBackgroundRefundState,
rpcSetBalance,
rpcSetSwapInfo,
timelockChangeEventReceived,
} from "store/features/rpcSlice";
import { swapProgressEventReceived } from "store/features/swapSlice";
import { store } from "./store/storeRenderer";
import { Maker } from "models/apiModel";
import { providerToConcatenatedMultiAddr } from "utils/multiAddrUtils";
import { MoneroRecoveryResponse } from "models/rpcModel";
import { ListSellersResponse } from "../models/tauriModel";
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 { setStatus } from "store/features/nodesSlice";
import { discoveredMakersByRendezvous } from "store/features/makersSlice";
@ -60,7 +50,7 @@ export async function fetchSellersAtPresetRendezvousPoints() {
const response = await listSellersAtRendezvousPoint(rendezvousPoint);
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,
});
}
export async function resolveApproval(requestId: string, accept: boolean): Promise<void> {
await invoke<ResolveApprovalArgs, ResolveApprovalResponse>("resolve_approval_request", { request_id: requestId, accept });
}

View file

@ -6,6 +6,7 @@ import {
TauriContextStatusEvent,
TauriTimelockChangeEvent,
BackgroundRefundState,
ApprovalRequest,
} from "models/tauriModel";
import { MoneroRecoveryResponse } from "../../models/rpcModel";
import { GetSwapInfoResponseExt } from "models/tauriModelExt";
@ -32,6 +33,10 @@ interface State {
swapId: string;
state: BackgroundRefundState;
} | null;
approvalRequests: {
// Store the full event, keyed by request_id
[requestId: string]: ApprovalRequest;
};
}
export interface RPCSlice {
@ -52,6 +57,7 @@ const initialState: RPCSlice = {
updateState: false,
},
backgroundRefund: null,
approvalRequests: {},
},
logs: [],
};
@ -138,6 +144,11 @@ export const rpcSlice = createSlice({
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,
rpcResetMoneroRecoveryKeys,
rpcSetBackgroundRefundState,
timelockChangeEventReceived
timelockChangeEventReceived,
approvalEventReceived,
} = rpcSlice.actions;
export default rpcSlice.reducer;

View file

@ -1,5 +1,5 @@
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 type { AppDispatch, RootState } from "renderer/store/storeRenderer";
import { parseDateString } from "utils/parseUtils";
@ -16,22 +16,29 @@ export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
export function useResumeableSwapsCount(
additionalFilter?: (s: GetSwapInfoResponseExt) => boolean,
) {
const saneSwapInfos = useSaneSwapInfos();
return useAppSelector(
(state) =>
Object.values(state.rpc.state.swapInfos).filter(
saneSwapInfos.filter(
(swapInfo: GetSwapInfoResponseExt) =>
!swapInfo.completed && (additionalFilter == null || additionalFilter(swapInfo))
).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() {
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() {
return useAppSelector(
(state) =>
@ -43,6 +50,8 @@ export function useIsContextAvailable() {
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(
swapId: string | 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);
}
@ -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);
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(
Object.values(swapInfos),
swapInfos,
(swap) => -parseDateString(swap.start_date),
);
}
@ -103,3 +137,13 @@ export function useNodes<T>(selector: (nodes: NodesSlice) => T): T {
const nodes = useAppSelector((state) => state.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");
}