diff --git a/src/Pages/Metrics/adminSwaps/TxSwaps.tsx b/src/Pages/Metrics/adminSwaps/TxSwaps.tsx index c0dd5fb1..4f75995a 100644 --- a/src/Pages/Metrics/adminSwaps/TxSwaps.tsx +++ b/src/Pages/Metrics/adminSwaps/TxSwaps.tsx @@ -2,23 +2,25 @@ import { useEffect, useMemo, useState } from "react" import { Period } from "@/Components/Dropdowns/LVDropdown" import { toast } from "react-toastify"; import * as Types from '../../../Api/pub/autogenerated/ts/types'; -import { IonButton, IonContent, IonItem, IonPage, IonHeader, useIonLoading, useIonRouter, IonRow, IonText, IonInput } from "@ionic/react"; -import { flashOutline, linkOutline, personOutline } from "ionicons/icons"; +import { IonButton, IonContent, IonItem, IonPage, IonHeader, useIonLoading, useIonRouter, IonRow, IonText, IonInput, IonCard, IonCardContent, IonCardHeader, IonCardTitle, IonBadge, IonIcon } from "@ionic/react"; +import { flashOutline, linkOutline, personOutline, walletOutline, checkmarkCircle, closeCircle } from "ionicons/icons"; import MetricsSubPageToolbar from "@/Layout2/Metrics/MetricsSubPageToolbar"; import { useAppSelector } from "@/State/store/hooks"; import { NprofileView, selectAdminNprofileViews } from "@/State/scoped/backups/sources/selectors"; import { selectSelectedMetricsAdminSourceId } from "@/State/runtime/slice"; -import { TxSwapsList, TransactionSwapQuote } from "../../../Api/pub/autogenerated/ts/types"; +import { TxSwapsList, TransactionSwapQuote, TxSwapOperation, UserOperationType } from "../../../Api/pub/autogenerated/ts/types"; import { fetcher, FetcherFuncs } from "../fetcher"; import AmountInput from "@/Components/AmountInput"; + + export default function TxSwaps({ adminSource }: { adminSource: NprofileView | undefined }) { const router = useIonRouter(); const [amount, setAmount] = useState(""); const [quotes, setQuotes] = useState([]); const [address, setAddress] = useState(""); - const [swaps, setSwaps] = useState({ quotes: [], swaps: [] }); + const [swaps, setSwaps] = useState({ swaps: [] }); const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -35,7 +37,7 @@ export default function TxSwaps({ adminSource }: { adminSource: NprofileView | u useEffect(() => { - fetchSwaps() + fetchSwaps(); }, [adminSource?.sourceId]) const fetchSwaps = async () => { @@ -67,11 +69,21 @@ export default function TxSwaps({ adminSource }: { adminSource: NprofileView | u } + const oldQuotes = "quotes" in swaps && Array.isArray(swaps.quotes) ? swaps.quotes : []; + const completedSwaps = swaps.swaps ?? []; + return {error && ( -
+
Something went wrong
-
{error}
+
{error}
void fetchSwaps()}>Retry router.push("/metrics/select", "back")}> @@ -80,60 +92,220 @@ export default function TxSwaps({ adminSource }: { adminSource: NprofileView | u
)} - {quotes.length === 0 && <> - Amount received by destination address - { setAmount(text); return text; }} - unit="sats" - onToggleUnit={() => { }} - /> - - Request Quotes - - } - {quotes.length > 0 && New Quotes} - {quotes.length > 0 && quotes.map(quote =>
- Quote from {quote.service_url} - {quote.transaction_amount_sats} sats will be sent to address - {quote.swap_fee_sats + quote.chain_fee_sats} sats will be paid for the swap - {quote.service_fee_sats} sats will be paid for the service - {quote.service_fee_sats + quote.invoice_amount_sats} sats will be the total spent for the swap - - Address to send to - setAddress(e.detail.value || "")} - /> - doSwap(quote.swap_operation_id)}>Swap -
)} - {quotes.length > 0 && Change Swap Amount} - - {swaps.quotes.length > 0 && Old Quotes} - {swaps.quotes.map(quote =>
- Quote from {quote.service_url} - {quote.transaction_amount_sats} sats will be sent to address - {quote.swap_fee_sats + quote.chain_fee_sats} sats will be paid for the swap - {quote.service_fee_sats} sats will be paid for the service - {quote.service_fee_sats + quote.invoice_amount_sats} sats will be the total spent for the swap - - Address to send to - setAddress(e.detail.value || "")} - /> - doSwap(quote.swap_operation_id)}>Swap -
)} - {swaps.swaps.length > 0 && Swaps} - {swaps.swaps.map(op =>
- - {!op.failure_reason && success: {op.operation_payment?.amount} sats to {op.address_paid}} - {op.failure_reason && failed: {op.operation_payment?.amount} sats to {op.address_paid} reason: {op.failure_reason}} - -
)} + {quotes.length === 0 && ( + + + Request New Transaction Swap Quote + + +
+ + Amount received by destination address (in sats) + + { setAmount(text); return text; }} + unit="sats" + onToggleUnit={() => { }} + /> + + + Get Quotes from Services + +
+
+
+ )} + {quotes.length > 0 && ( + <> + + + New Quotes Available + + {quotes.map(quote => ( + + +
+ + {quote.service_url} + + New Quote +
+
+ +
+
+
+ Sent to address: + {quote.transaction_amount_sats.toLocaleString()} sats +
+
+ Swap + chain fee: + {(quote.swap_fee_sats + quote.chain_fee_sats).toLocaleString()} sats +
+
+ Service fee: + {quote.service_fee_sats.toLocaleString()} sats +
+
+ Total spent: + {(quote.service_fee_sats + quote.invoice_amount_sats).toLocaleString()} sats +
+
+
+
+ Destination address to send to: +
+ setAddress(e.detail.value || "")} + placeholder="Enter on-chain address" + style={{ + marginTop: '4px', + background: 'var(--ion-color-light)', + borderRadius: '4px', + fontFamily: 'monospace', + fontSize: '0.85rem' + }} + /> +
+ doSwap(quote.swap_operation_id)} + disabled={!address?.trim()} + > + + Execute Swap + +
+
+
+ ))} + + Cancel & Request New Amount + + + )} + + {/* Swap operations list */} + + +
+ Swap Operations + void fetchSwaps()}> + Refresh + +
+
+ + {(oldQuotes.length > 0 || completedSwaps.length > 0) && ( + + {oldQuotes.length > 0 && <>Pending quotes: {oldQuotes.length}} + {oldQuotes.length > 0 && completedSwaps.length > 0 && " · "} + {completedSwaps.length > 0 && <>Completed: {completedSwaps.length}} + + )} + +
+ + {oldQuotes.length === 0 && completedSwaps.length === 0 && quotes.length === 0 && ( + + + + +

No swap operations yet

+

Request a quote above to get started with transaction swaps

+
+
+
+ )} + + {oldQuotes.length > 0 && ( + <> + + Pending Quotes + + {oldQuotes.map(quote => ( + + + {quote.service_url} + + +
+
+ Sent to address: + {quote.transaction_amount_sats.toLocaleString()} sats +
+
+ Swap + chain fee: + {(quote.swap_fee_sats + quote.chain_fee_sats).toLocaleString()} sats +
+
+ Total spent: + {(quote.service_fee_sats + quote.invoice_amount_sats).toLocaleString()} sats +
+ setAddress(e.detail.value || "")} + placeholder="Enter on-chain address" + style={{ marginTop: '8px' }} + /> + doSwap(quote.swap_operation_id)} disabled={!address?.trim()}> + Execute Swap + +
+
+
+ ))} + + )} + + {completedSwaps.length > 0 && ( + <> + + Completed Swaps + + {completedSwaps.map(op => ( + + +
+ {!op.failure_reason ? ( + <> + + Success: {op.operation_payment?.amount?.toLocaleString()} sats to {op.address_paid ?? '—'} + + ) : ( + <> + + Failed: {op.operation_payment?.amount?.toLocaleString()} sats to {op.address_paid ?? '—'} — {op.failure_reason} + + )} +
+
+
+ ))} + + )} } diff --git a/src/Pages/Swaps/index.tsx b/src/Pages/Swaps/index.tsx index d42871f9..4a9cfd88 100644 --- a/src/Pages/Swaps/index.tsx +++ b/src/Pages/Swaps/index.tsx @@ -1,127 +1,369 @@ -import { IonButton, IonFooter, IonHeader, IonInput, IonItem, IonPage, IonRow, IonText } from "@ionic/react"; +import { IonButton, IonContent, IonFooter, IonHeader, IonIcon, IonInput, IonPage, IonRow, IonText, IonCard, IonCardContent, IonCardHeader, IonCardTitle, IonBadge } from "@ionic/react"; import BackToolbar from "@/Layout2/BackToolbar"; import AmountInput from "@/Components/AmountInput"; import { useEffect, useState } from "react"; import SpendFromDropdown from "@/Components/Dropdowns/SpendFromDropdown"; import { getNostrClient } from "@/Api/nostr"; -import { TransactionSwapQuote, SwapsList } from "@/Api/pub/autogenerated/ts/types"; +import { TransactionSwapQuote, TxSwapsList, TxSwapOperation } from "@/Api/pub/autogenerated/ts/types"; import { toast } from "react-toastify"; import { selectNprofileViews } from "@/State/scoped/backups/sources/selectors"; import { useAppSelector } from "@/State/store/hooks"; +import { flashOutline, checkmarkCircle, closeCircle, walletOutline } from "ionicons/icons"; +import { useIonLoading } from "@ionic/react"; + export default function Swaps() { const [amount, setAmount] = useState(""); const [quotes, setQuotes] = useState([]); const [address, setAddress] = useState(""); const nprofileViews = useAppSelector(selectNprofileViews); const [selectedView, setSelectedView] = useState(nprofileViews[0]); - const [swaps, setSwaps] = useState({ quotes: [], swaps: [] }); + const [swaps, setSwaps] = useState({ swaps: [] }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [presentLoading, dismissLoading] = useIonLoading(); + const fetchSwaps = async () => { - const client = await getNostrClient({ pubkey: selectedView.lpk, relays: selectedView.relays }, selectedView.keys) - const swaps = await client.ListSwaps() - if (swaps.status !== "OK") { - toast.error(swaps.reason); - return; + if (!selectedView) return; + setError(null); + setLoading(true); + try { + await dismissLoading(); + await presentLoading("Fetching swaps..."); + const client = await getNostrClient({ pubkey: selectedView.lpk, relays: selectedView.relays }, selectedView.keys); + const result = await client.ListTxSwaps(); + await dismissLoading(); + if (result.status !== "OK") { + console.error("Failed to load swaps", result); + setError(result.reason ?? "Failed to load swaps"); + toast.error(result.reason); + return; + } + setSwaps({ swaps: result.swaps }); + } catch (e) { + await dismissLoading(); + const msg = e instanceof Error ? e.message : "Failed to load swaps"; + setError(msg); + toast.error(msg); + } finally { + setLoading(false); } - setSwaps(swaps); - } + }; + useEffect(() => { fetchSwaps(); - }, [selectedView]) + }, [selectedView?.sourceId ?? selectedView?.lpk]); + const requestQuote = async () => { - const client = await getNostrClient({ pubkey: selectedView.lpk, relays: selectedView.relays }, selectedView.keys) - const quote = await client.GetTransactionSwapQuotes({ transaction_amount_sats: +amount }) - if (quote.status !== "OK") { - toast.error(quote.reason); - return; + if (!selectedView) return; + setError(null); + try { + await dismissLoading(); + await presentLoading("Fetching quote..."); + const client = await getNostrClient({ pubkey: selectedView.lpk, relays: selectedView.relays }, selectedView.keys); + const quote = await client.GetTransactionSwapQuotes({ transaction_amount_sats: +amount }); + await dismissLoading(); + if (quote.status !== "OK") { + console.error("Failed to get quotes", quote); + setError(quote.reason ?? "Failed to get quotes"); + toast.error(quote.reason); + return; + } + setQuotes(quote.quotes ?? []); + } catch (e) { + await dismissLoading(); + const msg = e instanceof Error ? e.message : "Failed to get quotes"; + setError(msg); + toast.error(msg); } - setQuotes(quote.quotes); - } + }; + const doSwap = async (swapOpId: string) => { - const client = await getNostrClient({ pubkey: selectedView.lpk, relays: selectedView.relays }, selectedView.keys) - const res = await client.PayAddress({ - address: address, - amountSats: 0, - satsPerVByte: 0, - swap_operation_id: swapOpId, - }) - if (res.status !== "OK") { - fetchSwaps(); - toast.error(res.reason); + if (!selectedView) return; + if (!address?.trim()) { + toast.error("Enter a destination address"); return; } - fetchSwaps(); - toast.success("Swap successful"); - } + setError(null); + try { + await dismissLoading(); + await presentLoading("Doing swap..."); + const client = await getNostrClient({ pubkey: selectedView.lpk, relays: selectedView.relays }, selectedView.keys); + const res = await client.PayAddress({ + address: address.trim(), + amountSats: 0, + satsPerVByte: 0, + swap_operation_id: swapOpId, + }); + await dismissLoading(); + if (res.status !== "OK") { + console.error("Failed to pay swap", res); + setError(res.reason ?? "Swap failed"); + toast.error(res.reason); + void fetchSwaps(); + return; + } + toast.success("Swap successful"); + setAddress(""); + void fetchSwaps(); + } catch (e) { + await dismissLoading(); + const msg = e instanceof Error ? e.message : "Swap failed"; + setError(msg); + toast.error(msg); + void fetchSwaps(); + } + }; + const cancelSwap = () => { setQuotes([]); - fetchSwaps(); - } + void fetchSwaps(); + }; + + const oldQuotes = "quotes" in swaps && Array.isArray(swaps.quotes) ? swaps.quotes : []; + const completedSwaps = swaps.swaps ?? []; + return ( - {/* */} - - - + + + + + + {error && ( +
+
Something went wrong
+
{error}
+ void fetchSwaps()}>Retry +
+ )} + + {quotes.length === 0 && ( + + + Request New Transaction Swap Quote + + +
+ + Amount received by destination address (in sats) + + { setAmount(text); return text; }} + unit="sats" + onToggleUnit={() => { }} + /> + void requestQuote()} + disabled={!amount || Number(amount) <= 0} + > + + Get Quotes from Services + +
+
+
+ )} + + {quotes.length > 0 && ( + <> + + + New Quotes Available + + {quotes.map(quote => ( + + +
+ + {quote.service_url} + + New Quote +
+
+ +
+
+
+ Sent to address: + {quote.transaction_amount_sats.toLocaleString()} sats +
+
+ Swap + chain fee: + {(quote.swap_fee_sats + quote.chain_fee_sats).toLocaleString()} sats +
+
+ Service fee: + {quote.service_fee_sats.toLocaleString()} sats +
+
+ Total spent: + {(quote.service_fee_sats + quote.invoice_amount_sats).toLocaleString()} sats +
+
+
+
+ Destination address to send to: +
+ setAddress(e.detail.value || "")} + placeholder="Enter on-chain address" + style={{ + marginTop: '4px', + background: 'var(--ion-color-light)', + borderRadius: '4px', + fontFamily: 'monospace', + fontSize: '0.85rem' + }} + /> +
+ void doSwap(quote.swap_operation_id)} + disabled={!address?.trim()} + > + + Execute Swap + +
+
+
+ ))} + + Cancel & Request New Amount + + + )} - {quotes.length === 0 && <> - Amount received by destination address - { setAmount(text); return text; }} - unit="sats" - onToggleUnit={() => { }} - /> - - Request Quote - - } - {quotes.length > 0 && New Quotes} - {quotes.length > 0 && quotes.map(quote =>
- Quote from {quote.service_url} - {quote.transaction_amount_sats} sats will be sent to address - {quote.swap_fee_sats + quote.chain_fee_sats} sats will be paid for the swap - {quote.service_fee_sats} sats will be paid for the service - {quote.service_fee_sats + quote.invoice_amount_sats} sats will be the total spent for the swap + {/* Swap operations list */} + + +
+ Swap Operations + void fetchSwaps()}> + Refresh + +
+
+ + {(oldQuotes.length > 0 || completedSwaps.length > 0) && ( + + {oldQuotes.length > 0 && <>Pending quotes: {oldQuotes.length}} + {oldQuotes.length > 0 && completedSwaps.length > 0 && " · "} + {completedSwaps.length > 0 && <>Completed: {completedSwaps.length}} + + )} + +
- Address to send to - setAddress(e.detail.value || "")} - /> - doSwap(quote.swap_operation_id)}>Swap -
)} - {quotes.length > 0 && Change Swap Amount} + {oldQuotes.length === 0 && completedSwaps.length === 0 && quotes.length === 0 && ( + + + + +

No swap operations yet

+

Request a quote above to get started with transaction swaps

+
+
+
+ )} - {swaps.quotes.length > 0 && Old Quotes} - {swaps.quotes.map(quote =>
- Quote from {quote.service_url} - {quote.transaction_amount_sats} sats will be sent to address - {quote.swap_fee_sats + quote.chain_fee_sats} sats will be paid for the swap - {quote.service_fee_sats} sats will be paid for the service - {quote.service_fee_sats + quote.invoice_amount_sats} sats will be the total spent for the swap + {oldQuotes.length > 0 && ( + <> + + Pending Quotes + + {oldQuotes.map(quote => ( + + + {quote.service_url} + + +
+
+ Sent to address: + {quote.transaction_amount_sats.toLocaleString()} sats +
+
+ Swap + chain fee: + {(quote.swap_fee_sats + quote.chain_fee_sats).toLocaleString()} sats +
+
+ Total spent: + {(quote.service_fee_sats + quote.invoice_amount_sats).toLocaleString()} sats +
+ setAddress(e.detail.value || "")} + placeholder="Enter on-chain address" + style={{ marginTop: '8px' }} + /> + void doSwap(quote.swap_operation_id)} disabled={!address?.trim()}> + Execute Swap + +
+
+
+ ))} + + )} - Address to send to - setAddress(e.detail.value || "")} - /> - doSwap(quote.swap_operation_id)}>Swap -
)} - {swaps.swaps.length > 0 && Swaps} - {swaps.swaps.map(op =>
- - {!op.failure_reason && success: {op.operation_payment?.amount} sats to {op.address_paid}} - {op.failure_reason && failed: {op.operation_payment?.amount} sats to {op.address_paid} reason: {op.failure_reason}} - -
)} - - -
- ) -} \ No newline at end of file + {completedSwaps.length > 0 && ( + <> + + Completed Swaps + + {completedSwaps.map((op: TxSwapOperation) => ( + + +
+ {!op.failure_reason ? ( + <> + + Success: {op.operation_payment?.amount?.toLocaleString()} sats to {op.address_paid ?? '—'} + + ) : ( + <> + + Failed: {op.operation_payment?.amount?.toLocaleString()} sats to {op.address_paid ?? '—'} — {op.failure_reason} + + )} +
+
+
+ ))} + + )} + + + + ); +}