From a7be1c9114c5870cbab7c04babf2f44041e0dd7a Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Tue, 2 Jun 2026 10:45:21 +0200 Subject: [PATCH 1/4] fix(gui): offer flickering Cache the sorted offer list for 5 seconds so it doesn't reshuffle on every Tauri update. The Select button keeps its snapshot-derived request id; if that approval has expired by click time, wait up to 5s for a fresh pending approval with the same peer id before erroring. --- .../DepositAndChooseOfferPage.tsx | 8 +- .../MakerOfferItem.tsx | 10 +- .../useCachedMakerOffers.ts | 96 +++++++++++++++++++ .../useResolveSelectMakerApproval.ts | 68 +++++++++++++ 4 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useCachedMakerOffers.ts create mode 100644 src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useResolveSelectMakerApproval.ts diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/DepositAndChooseOfferPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/DepositAndChooseOfferPage.tsx index 21e4b956b5..0956e516e1 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/DepositAndChooseOfferPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/DepositAndChooseOfferPage.tsx @@ -14,12 +14,12 @@ import SortIcon from "@mui/icons-material/Sort"; import CheckIcon from "@mui/icons-material/Check"; import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox"; import MakerOfferItem from "./MakerOfferItem"; -import { usePendingSelectMakerApproval } from "store/hooks"; import MakerDiscoveryStatus from "./MakerDiscoveryStatus"; import { TauriSwapProgressEventContent } from "models/tauriModelExt"; import { SatsAmount } from "renderer/components/other/Units"; import { useEffect, useState } from "react"; -import { sortApprovalsAndKnownQuotes, OfferSortMode } from "utils/sortUtils"; +import { OfferSortMode } from "utils/sortUtils"; +import { useCachedMakerOffers } from "./useCachedMakerOffers"; const SORT_OPTIONS: { value: OfferSortMode; label: string }[] = [ { value: "large", label: "Large swaps" }, @@ -32,14 +32,12 @@ export default function DepositAndChooseOfferPage({ max_giveable, known_quotes, }: TauriSwapProgressEventContent<"WaitingForBtcDeposit">) { - const pendingSelectMakerApprovals = usePendingSelectMakerApproval(); const [currentPage, setCurrentPage] = useState(1); const [sortMode, setSortMode] = useState("small"); const [sortAnchorEl, setSortAnchorEl] = useState(null); const offersPerPage = 3; - const makerOffers = sortApprovalsAndKnownQuotes( - pendingSelectMakerApprovals, + const makerOffers = useCachedMakerOffers( known_quotes, sortMode, offersPerPage, diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx index dc6bfbc0e4..4b35b0aa92 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx @@ -30,7 +30,7 @@ import { SatsAmount, } from "renderer/components/other/Units"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; -import { resolveApproval } from "renderer/rpc"; +import { useResolveSelectMakerApproval } from "./useResolveSelectMakerApproval"; import WarningIcon from "@mui/icons-material/Warning"; import FavoriteIcon from "@mui/icons-material/Favorite"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; @@ -68,6 +68,7 @@ export default function MakerOfferItem({ const isOutOfLiquidity = quote.max_quantity == 0; const isTooOld = isMakerVersionTooOld(version); const priorityMaker = showAsPriority ? getPriorityMaker(peer_id) : undefined; + const resolveSelectMakerApproval = useResolveSelectMakerApproval(); return ( { - if (!requestId) { - throw new Error("Request ID is required"); - } - return resolveApproval(requestId, true as unknown as object); - }} + onInvoke={() => resolveSelectMakerApproval(peer_id, requestId)} displayErrorSnackbar disabled={!requestId} tooltipTitle={ diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useCachedMakerOffers.ts b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useCachedMakerOffers.ts new file mode 100644 index 0000000000..a112c2f9b9 --- /dev/null +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useCachedMakerOffers.ts @@ -0,0 +1,96 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { QuoteWithAddress, RefundPolicyWire } from "models/tauriModel"; +import { usePendingSelectMakerApproval } from "store/hooks"; +import { + OfferSortMode, + SortedMakerEntry, + sortApprovalsAndKnownQuotes, +} from "utils/sortUtils"; + +const REFRESH_INTERVAL_MS = 5_000; + +function refundPolicyEqual(a: RefundPolicyWire, b: RefundPolicyWire): boolean { + if (a.type !== b.type) return false; + if (a.type === "FullRefund") return true; + return ( + a.content.anti_spam_deposit_ratio === + (b as Extract).content + .anti_spam_deposit_ratio + ); +} + +function offersEqual(a: SortedMakerEntry[], b: SortedMakerEntry[]): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const x = a[i]; + const y = b[i]; + if (x.isDuplicate !== y.isDuplicate) return false; + if (x.approval?.request_id !== y.approval?.request_id) return false; + const xq = x.quote_with_address; + const yq = y.quote_with_address; + if (xq.peer_id !== yq.peer_id) return false; + if (xq.multiaddr !== yq.multiaddr) return false; + if (xq.version !== yq.version) return false; + if (xq.quote.price !== yq.quote.price) return false; + if (xq.quote.min_quantity !== yq.quote.min_quantity) return false; + if (xq.quote.max_quantity !== yq.quote.max_quantity) return false; + if (!refundPolicyEqual(xq.quote.refund_policy, yq.quote.refund_policy)) + return false; + } + return true; +} + +// The sorted list re-shuffles whenever the backend streams an approval or +// quote update. We snapshot it and only refresh on a fixed cadence so cards +// don't visibly flicker. The snapshot is also refreshed immediately on +// sort-mode change and whenever a new peer appears, so newly-discovered +// makers don't get stuck behind the cadence. +export function useCachedMakerOffers( + known_quotes: QuoteWithAddress[], + sortMode: OfferSortMode, + offersPerPage: number, +): SortedMakerEntry[] { + const pendingApprovals = usePendingSelectMakerApproval(); + + const liveOffers = useMemo( + () => + sortApprovalsAndKnownQuotes( + pendingApprovals, + known_quotes, + sortMode, + offersPerPage, + ), + [pendingApprovals, known_quotes, sortMode, offersPerPage], + ); + + const liveOffersRef = useRef(liveOffers); + liveOffersRef.current = liveOffers; + + const [snapshot, setSnapshot] = useState(liveOffers); + + useEffect(() => { + setSnapshot(liveOffersRef.current); + }, [sortMode]); + + useEffect(() => { + const snapshotPeers = new Set( + snapshot.map((o) => o.quote_with_address.peer_id), + ); + const hasNewPeer = liveOffers.some( + (o) => !snapshotPeers.has(o.quote_with_address.peer_id), + ); + if (hasNewPeer) setSnapshot(liveOffers); + }, [snapshot, liveOffers]); + + useEffect(() => { + const id = window.setInterval(() => { + setSnapshot((prev) => + offersEqual(prev, liveOffersRef.current) ? prev : liveOffersRef.current, + ); + }, REFRESH_INTERVAL_MS); + return () => window.clearInterval(id); + }, []); + + return snapshot; +} diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useResolveSelectMakerApproval.ts b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useResolveSelectMakerApproval.ts new file mode 100644 index 0000000000..cb8bb7990f --- /dev/null +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useResolveSelectMakerApproval.ts @@ -0,0 +1,68 @@ +import { useCallback } from "react"; +import { store, type RootState } from "renderer/store/storeRenderer"; +import { resolveApproval } from "renderer/rpc"; +import { isPendingSelectMakerApprovalEvent } from "models/tauriModelExt"; + +const WAIT_FOR_APPROVAL_MS = 5_000; + +function findPendingApprovalForPeer( + state: RootState, + peerId: string, +): string | null { + const requests = Object.values(state.rpc.state.approvalRequests); + for (const req of requests) { + if (req.request_status.state !== "Pending") continue; + if (!isPendingSelectMakerApprovalEvent(req)) continue; + if (req.request.content.maker.peer_id === peerId) return req.request_id; + } + return null; +} + +/** + * The snapshot list shown to the user can hold a request id that has already + * expired by the time they click Select. In that case we wait briefly for the + * backend to emit a fresh pending approval for the same peer and resolve that + * one instead, so a stale snapshot doesn't surface as a confusing error. + */ +export function useResolveSelectMakerApproval() { + return useCallback( + async (peerId: string, snapshotRequestId: string | undefined) => { + const tryResolve = (id: string) => + resolveApproval(id, true as unknown as object); + + if (snapshotRequestId) { + const stillPending = + store.getState().rpc.state.approvalRequests[snapshotRequestId] + ?.request_status.state === "Pending"; + if (stillPending) return tryResolve(snapshotRequestId); + } + + const immediate = findPendingApprovalForPeer(store.getState(), peerId); + if (immediate) return tryResolve(immediate); + + const freshId = await new Promise((resolve) => { + const timeout = window.setTimeout(() => { + unsubscribe(); + resolve(null); + }, WAIT_FOR_APPROVAL_MS); + + const unsubscribe = store.subscribe(() => { + const found = findPendingApprovalForPeer(store.getState(), peerId); + if (found) { + window.clearTimeout(timeout); + unsubscribe(); + resolve(found); + } + }); + }); + + if (!freshId) { + throw new Error( + "This offer is no longer available. Please pick another maker.", + ); + } + return tryResolve(freshId); + }, + [], + ); +} From 7d3e9f44c63e453a5f42da9d298a9de97b4626c7 Mon Sep 17 00:00:00 2001 From: binarybaron Date: Thu, 4 Jun 2026 15:24:08 +0200 Subject: [PATCH 2/4] simplify --- .../useCachedMakerOffers.ts | 37 ++----------------- 1 file changed, 3 insertions(+), 34 deletions(-) diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useCachedMakerOffers.ts b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useCachedMakerOffers.ts index a112c2f9b9..ce8b8ee4fe 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useCachedMakerOffers.ts +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useCachedMakerOffers.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { QuoteWithAddress, RefundPolicyWire } from "models/tauriModel"; +import { QuoteWithAddress } from "models/tauriModel"; import { usePendingSelectMakerApproval } from "store/hooks"; +import _ from "lodash"; import { OfferSortMode, SortedMakerEntry, @@ -9,38 +10,6 @@ import { const REFRESH_INTERVAL_MS = 5_000; -function refundPolicyEqual(a: RefundPolicyWire, b: RefundPolicyWire): boolean { - if (a.type !== b.type) return false; - if (a.type === "FullRefund") return true; - return ( - a.content.anti_spam_deposit_ratio === - (b as Extract).content - .anti_spam_deposit_ratio - ); -} - -function offersEqual(a: SortedMakerEntry[], b: SortedMakerEntry[]): boolean { - if (a === b) return true; - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - const x = a[i]; - const y = b[i]; - if (x.isDuplicate !== y.isDuplicate) return false; - if (x.approval?.request_id !== y.approval?.request_id) return false; - const xq = x.quote_with_address; - const yq = y.quote_with_address; - if (xq.peer_id !== yq.peer_id) return false; - if (xq.multiaddr !== yq.multiaddr) return false; - if (xq.version !== yq.version) return false; - if (xq.quote.price !== yq.quote.price) return false; - if (xq.quote.min_quantity !== yq.quote.min_quantity) return false; - if (xq.quote.max_quantity !== yq.quote.max_quantity) return false; - if (!refundPolicyEqual(xq.quote.refund_policy, yq.quote.refund_policy)) - return false; - } - return true; -} - // The sorted list re-shuffles whenever the backend streams an approval or // quote update. We snapshot it and only refresh on a fixed cadence so cards // don't visibly flicker. The snapshot is also refreshed immediately on @@ -86,7 +55,7 @@ export function useCachedMakerOffers( useEffect(() => { const id = window.setInterval(() => { setSnapshot((prev) => - offersEqual(prev, liveOffersRef.current) ? prev : liveOffersRef.current, + _.isEqual(prev, liveOffersRef.current) ? prev : liveOffersRef.current, ); }, REFRESH_INTERVAL_MS); return () => window.clearInterval(id); From 9a213c89a5e1b2f76759f6482eff239e048365ad Mon Sep 17 00:00:00 2001 From: binarybaron Date: Thu, 4 Jun 2026 15:25:28 +0200 Subject: [PATCH 3/4] add changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a48e83b779..bdba3f23aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- GUI: Fix flickering of offers - Remove three dead rendezvous points ## [4.7.10] - 2026-06-02 From b408152c471ab581fc9dd586488122fc08d19cea Mon Sep 17 00:00:00 2001 From: binarybaron Date: Thu, 4 Jun 2026 15:33:39 +0200 Subject: [PATCH 4/4] fix(gui): refresh offer snapshot on Bitcoin balance change --- .../deposit_and_choose_offer/useCachedMakerOffers.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useCachedMakerOffers.ts b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useCachedMakerOffers.ts index ce8b8ee4fe..a70fbadb67 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useCachedMakerOffers.ts +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/useCachedMakerOffers.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { QuoteWithAddress } from "models/tauriModel"; -import { usePendingSelectMakerApproval } from "store/hooks"; +import { useAppSelector, usePendingSelectMakerApproval } from "store/hooks"; import _ from "lodash"; import { OfferSortMode, @@ -13,14 +13,16 @@ const REFRESH_INTERVAL_MS = 5_000; // The sorted list re-shuffles whenever the backend streams an approval or // quote update. We snapshot it and only refresh on a fixed cadence so cards // don't visibly flicker. The snapshot is also refreshed immediately on -// sort-mode change and whenever a new peer appears, so newly-discovered -// makers don't get stuck behind the cadence. +// sort-mode change, on Bitcoin balance change, and whenever a new peer +// appears, so newly-discovered makers and freshly-deposited funds don't get +// stuck behind the cadence. export function useCachedMakerOffers( known_quotes: QuoteWithAddress[], sortMode: OfferSortMode, offersPerPage: number, ): SortedMakerEntry[] { const pendingApprovals = usePendingSelectMakerApproval(); + const bitcoinBalance = useAppSelector((state) => state.bitcoinWallet.balance); const liveOffers = useMemo( () => @@ -40,7 +42,7 @@ export function useCachedMakerOffers( useEffect(() => { setSnapshot(liveOffersRef.current); - }, [sortMode]); + }, [sortMode, bitcoinBalance]); useEffect(() => { const snapshotPeers = new Set(