From 60c9da845b947d7d41d8f1283231c1e897bf3fe6 Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Thu, 2 May 2024 17:59:26 -0500 Subject: [PATCH] add back swaps and do some cleanup --- public/i18n/en.json | 3 +- src/components/BalanceBox.tsx | 19 +- src/components/ImportExport.tsx | 3 +- src/router.tsx | 6 +- src/routes/Swap.tsx | 471 ++++++++++++++++++++++++++++++++ src/routes/SwapLightning.tsx | 324 ++++++++++++++++++++++ src/routes/index.ts | 2 + src/utils/fetchZaps.ts | 3 - src/workers/walletWorker.ts | 130 ++++----- 9 files changed, 870 insertions(+), 91 deletions(-) create mode 100644 src/routes/Swap.tsx create mode 100644 src/routes/SwapLightning.tsx diff --git a/public/i18n/en.json b/public/i18n/en.json index 57ec35d9..101da1a7 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -400,7 +400,8 @@ "import_state": "Import State From File", "confirm_replace": "Do you want to replace your state with", "password": "Enter your password to decrypt", - "decrypt_wallet": "Decrypt Wallet" + "decrypt_wallet": "Decrypt Wallet", + "decrypt_export": "Enter your password to save" }, "logs": { "title": "Download debug logs", diff --git a/src/components/BalanceBox.tsx b/src/components/BalanceBox.tsx index e88d0ff0..e0914424 100644 --- a/src/components/BalanceBox.tsx +++ b/src/components/BalanceBox.tsx @@ -1,6 +1,6 @@ import { A, useNavigate } from "@solidjs/router"; import { Shuffle, Users } from "lucide-solid"; -import { Match, Show, Suspense, Switch } from "solid-js"; +import { createMemo, Match, Show, Suspense, Switch } from "solid-js"; import { AmountFiat, @@ -51,13 +51,18 @@ export function BalanceBox(props: { loading?: boolean; small?: boolean }) { const navigate = useNavigate(); const i18n = useI18n(); - const totalOnchain = () => - (state.balance?.confirmed || 0n) + - (state.balance?.unconfirmed || 0n) + - (state.balance?.force_close || 0n); + const totalOnchain = createMemo( + () => + (state.balance?.confirmed || 0n) + + (state.balance?.unconfirmed || 0n) + + (state.balance?.force_close || 0n) + ); - const usableOnchain = () => - (state.balance?.confirmed || 0n) + (state.balance?.unconfirmed || 0n); + const usableOnchain = createMemo( + () => + (state.balance?.confirmed || 0n) + + (state.balance?.unconfirmed || 0n) + ); return ( diff --git a/src/components/ImportExport.tsx b/src/components/ImportExport.tsx index c176268e..04114658 100644 --- a/src/components/ImportExport.tsx +++ b/src/components/ImportExport.tsx @@ -188,9 +188,10 @@ export function ImportExport(props: { emergency?: boolean }) { {/* TODO: this is pretty redundant with the DecryptDialog, could make a shared component */} setExportDecrypt(false)} >
diff --git a/src/router.tsx b/src/router.tsx index 0c3e174f..474dffb9 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -31,7 +31,9 @@ import { RequestRoute, Scanner, Search, - Send + Send, + Swap, + SwapLightning } from "~/routes"; import { Admin, @@ -175,6 +177,8 @@ export function Router() { + + diff --git a/src/routes/Swap.tsx b/src/routes/Swap.tsx new file mode 100644 index 00000000..b38f8768 --- /dev/null +++ b/src/routes/Swap.tsx @@ -0,0 +1,471 @@ +import { createForm, required } from "@modular-forms/solid"; +import { MutinyChannel } from "@mutinywallet/mutiny-wasm"; +import { createAsync, useNavigate } from "@solidjs/router"; +import { + createMemo, + createResource, + createSignal, + For, + Match, + Show, + Suspense, + Switch +} from "solid-js"; + +import { + ActivityDetailsModal, + AmountEditable, + AmountFiat, + BackLink, + Button, + Card, + DefaultMain, + FeeDisplay, + HackActivityType, + InfoBox, + LargeHeader, + MegaCheck, + MegaEx, + MutinyWalletGuard, + NavBar, + showToast, + SuccessModal, + TextField, + VStack +} from "~/components"; +import { useI18n } from "~/i18n/context"; +import { useMegaStore } from "~/state/megaStore"; +import { eify, vibrateSuccess } from "~/utils"; + +const CHANNEL_FEE_ESTIMATE_ADDRESS = + "bc1qf7546vg73ddsjznzq57z3e8jdn6gtw6au576j07kt6d9j7nz8mzsyn6lgf"; + +type PeerConnectForm = { + peer: string; +}; + +type ChannelOpenDetails = { + channel?: MutinyChannel; + failure_reason?: Error; +}; + +export function Swap() { + const [state, _actions, sw] = useMegaStore(); + const navigate = useNavigate(); + const i18n = useI18n(); + + const [amountSats, setAmountSats] = createSignal(0n); + const [isConnecting, setIsConnecting] = createSignal(false); + + const [loading, setLoading] = createSignal(false); + + const [selectedPeer, setSelectedPeer] = createSignal(""); + + // Details Modal + const [detailsOpen, setDetailsOpen] = createSignal(false); + const [detailsKind, setDetailsKind] = createSignal(); + const [detailsId, setDetailsId] = createSignal(""); + + const [channelOpenResult, setChannelOpenResult] = + createSignal(); + + function openDetailsModal() { + const paymentTxId = + channelOpenResult()?.channel?.outpoint?.split(":")[0]; + const kind: HackActivityType = "ChannelOpen"; + + console.log("Opening details modal: ", paymentTxId, kind); + + if (!paymentTxId) { + console.warn("No id provided to openDetailsModal"); + return; + } + if (paymentTxId !== undefined) { + setDetailsId(paymentTxId); + } + setDetailsKind(kind); + setDetailsOpen(true); + } + + function resetState() { + setAmountSats(0n); + setIsConnecting(false); + setLoading(false); + setSelectedPeer(""); + setChannelOpenResult(undefined); + } + + const hasLsp = () => { + return !!state.settings?.lsp; + }; + + const getPeers = async () => { + return await sw?.list_peers(); + }; + + const [peers, { refetch }] = createResource(getPeers); + + const [_peerForm, { Form, Field }] = createForm(); + + const onSubmit = async (values: PeerConnectForm) => { + setIsConnecting(true); + try { + const peerConnectString = values.peer.trim(); + + await sw.connect_to_peer(peerConnectString); + + await refetch(); + + // If peers list contains the peer we just connected to, select it + const peer = peers()?.find( + (p) => p.pubkey === peerConnectString.split("@")[0] + ); + + if (peer) { + setSelectedPeer(peer.pubkey); + } else { + showToast(new Error(i18n.t("swap.peer_not_found"))); + } + } catch (e) { + showToast(eify(e)); + } finally { + setIsConnecting(false); + } + }; + + const handlePeerSelect = ( + e: Event & { + currentTarget: HTMLSelectElement; + target: HTMLSelectElement; + } + ) => { + setSelectedPeer(e.currentTarget.value); + }; + + const handleSwap = async () => { + if (canSwap()) { + try { + setLoading(true); + + let peer = undefined; + + if (!hasLsp()) { + peer = selectedPeer(); + } + + if (isMax()) { + const new_channel = await sw.sweep_all_to_channel(peer); + + setChannelOpenResult({ channel: new_channel }); + } else { + const new_channel = await sw.open_channel( + peer, + amountSats() + ); + + setChannelOpenResult({ channel: new_channel }); + } + + await vibrateSuccess(); + } catch (e) { + setChannelOpenResult({ failure_reason: eify(e) }); + } finally { + setLoading(false); + } + } + }; + + const canSwap = () => { + const balance = + (state.balance?.confirmed || 0n) + + (state.balance?.unconfirmed || 0n); + const network = state.network || "signet"; + + if (network === "bitcoin") { + return ( + (!!selectedPeer() || !!hasLsp()) && + amountSats() >= 100000n && + amountSats() <= balance + ); + } else { + return ( + (!!selectedPeer() || !!hasLsp()) && + amountSats() >= 10000n && + amountSats() <= balance + ); + } + }; + + const amountWarning = createAsync(async () => { + if (amountSats() === 0n || !!channelOpenResult()) { + return undefined; + } + + const network = state.network || "signet"; + + if (network === "bitcoin" && amountSats() < 100000n) { + return i18n.t("swap.channel_too_small", { amount: "100,000" }); + } + + if (amountSats() < 10000n) { + return i18n.t("swap.channel_too_small", { amount: "10,000" }); + } + + if ( + amountSats() > + (state.balance?.confirmed || 0n) + + (state.balance?.unconfirmed || 0n) || + !feeEstimate() + ) { + return i18n.t("swap.insufficient_funds"); + } + + return undefined; + }); + + function calculateMaxOnchain() { + return ( + (state.balance?.confirmed ?? 0n) + + (state.balance?.unconfirmed ?? 0n) + ); + } + + const maxOnchain = createMemo(() => { + return calculateMaxOnchain(); + }); + + const isMax = createMemo(() => { + return amountSats() === calculateMaxOnchain(); + }); + + const feeEstimate = createAsync(async () => { + const max = maxOnchain(); + // If max we want to use the sweep fee estimator + if (amountSats() > 0n && amountSats() === max) { + try { + return await sw.estimate_sweep_channel_open_fee(); + } catch (e) { + console.error(e); + return undefined; + } + } + + if (amountSats() > 0n) { + try { + return await sw.estimate_tx_fee( + CHANNEL_FEE_ESTIMATE_ADDRESS, + amountSats(), + undefined + ); + } catch (e) { + console.error(e); + return undefined; + } + } + return undefined; + }); + + return ( + + + + {i18n.t("swap.header")} + { + if (!open) resetState(); + }} + onConfirm={() => { + resetState(); + navigate("/"); + }} + > + + + +

+ {channelOpenResult()?.failure_reason + ? channelOpenResult()?.failure_reason + ?.message + : ""} +

+ {/*TODO: Error hint needs to be added for possible failure reasons*/} +
+ + + + + +
+

+ {i18n.t("swap.initiated")} +

+

+ {i18n.t("swap.sats_added", { + amount: ( + Number( + channelOpenResult()?.channel + ?.balance + ) + + Number( + channelOpenResult()?.channel + ?.reserve + ) + ).toLocaleString() + })} +

+
+ + + +
+
+
+

+ {i18n.t("common.view_payment_details")} +

+ {/*
{JSON.stringify(channelOpenResult()?.channel?.value, null, 2)}
*/} +
+
+
+
+
+ + + + +
+ + +
+ + + + {(field, props) => ( + + )} + + + + +
+
+
+ + + 0n}> + + + + + 0n}> + + {amountWarning()} + + + +
+
+ + + +
+ + + + ); +} diff --git a/src/routes/SwapLightning.tsx b/src/routes/SwapLightning.tsx new file mode 100644 index 00000000..5fb94aca --- /dev/null +++ b/src/routes/SwapLightning.tsx @@ -0,0 +1,324 @@ +import { FedimintSweepResult } from "@mutinywallet/mutiny-wasm"; +import { useNavigate } from "@solidjs/router"; +import { + createMemo, + createSignal, + Match, + Show, + Suspense, + Switch +} from "solid-js"; + +import { + AmountEditable, + AmountFiat, + BackButton, + BackLink, + Button, + DefaultMain, + Failure, + Fee, + FeeDisplay, + InfoBox, + LargeHeader, + MegaCheck, + MutinyWalletGuard, + NavBar, + ReceiveWarnings, + SuccessModal, + VStack +} from "~/components"; +import { useI18n } from "~/i18n/context"; +import { useMegaStore } from "~/state/megaStore"; +import { eify, vibrateSuccess } from "~/utils"; + +type SweepResultDetails = { + result?: FedimintSweepResult; + failure_reason?: string; +}; + +export function SwapLightning() { + const [state, _actions, sw] = useMegaStore(); + const navigate = useNavigate(); + const i18n = useI18n(); + + const [stage, setStage] = createSignal<"start" | "preview" | "done">( + "start" + ); + + const [amountSats, setAmountSats] = createSignal(0n); + const [feeSats, setFeeSats] = createSignal(0n); + const [maxFederationBalanceBeforeSwap, setMaxFederationBalanceBeforeSwap] = + createSignal(0n); + + const [loading, setLoading] = createSignal(false); + + const [sweepResult, setSweepResult] = createSignal(); + + function resetState() { + setAmountSats(0n); + setFeeSats(0n); + setMaxFederationBalanceBeforeSwap(0n); + setLoading(false); + setSweepResult(undefined); + setStage("start"); + } + + const handleSwap = async () => { + try { + setLoading(true); + setFeeEstimateWarning(undefined); + + if (isMax()) { + const result = await sw.sweep_federation_balance(undefined); + + setSweepResult({ result: result }); + } else { + const result = await sw.sweep_federation_balance(amountSats()); + + setSweepResult({ result: result }); + } + + await vibrateSuccess(); + } catch (e) { + const error = eify(e); + setSweepResult({ failure_reason: error.message }); + console.error(e); + } finally { + setLoading(false); + } + }; + + const canSwap = () => { + const balance = state.balance?.federation || 0n; + return amountSats() > 0n && amountSats() <= balance; + }; + + const amountWarning = () => { + if (amountSats() === 0n || !!sweepResult() || loading()) { + return undefined; + } + + if (amountSats() > (state.balance?.federation || 0n)) { + return i18n.t("swap_lightning.insufficient_funds"); + } + + return undefined; + }; + + function calculateMaxFederation() { + return state.balance?.federation ?? 0n; + } + + const maxFederationBalance = createMemo(() => { + return calculateMaxFederation(); + }); + + const isMax = createMemo(() => { + return amountSats() === calculateMaxFederation(); + }); + + const feeIsSet = createMemo(() => { + return feeSats() !== 0n; + }); + + const [feeEstimateWarning, setFeeEstimateWarning] = createSignal(); + + const feeEstimate = async () => { + try { + setLoading(true); + setFeeEstimateWarning(undefined); + + const fee = await sw.estimate_sweep_federation_fee( + isMax() ? undefined : amountSats() + ); + + if (fee) { + setFeeSats(fee); + } + setMaxFederationBalanceBeforeSwap(calculateMaxFederation()); + + setStage("preview"); + } catch (e) { + console.error(e); + setFeeEstimateWarning(i18n.t("swap_lightning.too_small")); + } finally { + setLoading(false); + } + }; + + return ( + + + + + + + {i18n.t("swap_lightning.header")} + + + + setStage("start")} + showOnDesktop + /> + + {i18n.t("swap_lightning.header_preview")} + + + + { + if (!open) resetState(); + }} + onConfirm={() => { + resetState(); + navigate("/"); + }} + > + + + + + + +
+

+ {i18n.t("swap_lightning.completed")} +

+

+ {i18n.t("swap_lightning.sats_added", { + amount: Number( + sweepResult()?.result?.amount + ).toLocaleString() + })} +

+
+ + + +
+
+
+ +
+
+
+
+
+ + + + + + 0n} + > + + {amountWarning()} + + + + + + {feeEstimateWarning()} + + + + +
+ + + + + + + + + + + 0n} + > + + {amountWarning()} + + + +
+ + + + + +
+ + + + ); +} diff --git a/src/routes/index.ts b/src/routes/index.ts index b5daa207..cd7fa32f 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -10,3 +10,5 @@ export * from "./Profile"; export * from "./Chat"; export * from "./Request"; export * from "./EditProfile"; +export * from "./Swap"; +export * from "./SwapLightning"; diff --git a/src/utils/fetchZaps.ts b/src/utils/fetchZaps.ts index 238f3e09..2d68cf73 100644 --- a/src/utils/fetchZaps.ts +++ b/src/utils/fetchZaps.ts @@ -72,8 +72,6 @@ async function simpleZapFromEvent( event: NostrEvent, sw: WalletWorker ): Promise { - console.log("simpleZapFromEvent", event); - if (event.kind === 9735 && event.tags?.length > 0) { const to = findByTag(event.tags, "p") || ""; const request = JSON.parse( @@ -96,7 +94,6 @@ async function simpleZapFromEvent( try { // We hardcode the "bitcoin" network because we don't have a good source of mutinynet zaps const decoded = await sw.decode_invoice(bolt11, "bitcoin"); - console.log("decoded", decoded); if (decoded?.amount_sats) { amount = decoded.amount_sats; } else { diff --git a/src/workers/walletWorker.ts b/src/workers/walletWorker.ts index c79db081..8c6ec0b2 100644 --- a/src/workers/walletWorker.ts +++ b/src/workers/walletWorker.ts @@ -4,6 +4,7 @@ import initMutinyWallet, { ChannelClosure, FederationBalance, FederationBalances, + FedimintSweepResult, LnUrlParams, MutinyBalance, MutinyBip21RawMaterials, @@ -306,6 +307,7 @@ export async function get_contact_for_npub( npub: string ): Promise { const contact = await wallet!.get_contact_for_npub(npub); + if (!contact) return undefined; return { ...contact?.value }; } @@ -328,6 +330,7 @@ export async function create_new_contact( export async function get_tag_item(id: string): Promise { const tagItem = await wallet!.get_tag_item(id); + if (!tagItem) return undefined; return { ...tagItem?.value }; } @@ -1139,7 +1142,6 @@ export async function open_channel( amount: bigint ): Promise { const channel = await wallet!.open_channel(to_pubkey, amount); - console.log("channel", channel); return { ...channel.value } as MutinyChannel; } @@ -1494,6 +1496,55 @@ export async function get_device_lock_remaining_secs( ); } +/** + * Opens a channel from our selected node to the given pubkey. + * It will spend the all the on-chain utxo in full to fund the channel. + * + * The node must be online and have a connection to the peer. + * @param {string | undefined} [to_pubkey] + * @returns {Promise} + */ +export async function sweep_all_to_channel( + to_pubkey?: string +): Promise { + return await wallet!.sweep_all_to_channel(to_pubkey); +} + +/** + * Estimates the onchain fee for sweeping our on-chain balance to open a lightning channel. + * The fee rate is in sat/vbyte. + * @param {number | undefined} [fee_rate] + * @returns {bigint} + */ +export async function estimate_sweep_channel_open_fee( + fee_rate?: number | undefined +): Promise { + return await wallet!.estimate_sweep_channel_open_fee(fee_rate); +} + +/** + * Sweep the federation balance into a lightning channel + * @param {bigint | undefined} [amount] + * @returns {Promise} + */ +export async function sweep_federation_balance( + amount?: bigint +): Promise { + const result = await wallet!.sweep_federation_balance(amount); + return { ...result.value } as FedimintSweepResult; +} + +/** + * Estimate the fee before trying to sweep from federation + * @param {bigint | undefined} [amount] + * @returns {Promise} + */ +export async function estimate_sweep_federation_fee( + amount?: bigint +): Promise { + return await wallet!.estimate_sweep_federation_fee(amount); +} + export async function parse_params(params: string): Promise { const paramsResult = await new PaymentParams(params); // PAIN just another object rebuild @@ -1521,80 +1572,3 @@ export async function parse_params(params: string): Promise { string: paramsResult.string } as PaymentParams; } - -// export class PaymentParams { -// free(): void; -// /** -// * @param {string} string -// */ -// constructor(string: string); -// /** -// * @param {string} network -// * @returns {boolean | undefined} -// */ -// valid_for_network(network: string): boolean | undefined; -// /** -// */ -// readonly address: string | undefined; -// /** -// */ -// readonly amount_msats: bigint | undefined; -// /** -// */ -// readonly amount_sats: bigint | undefined; -// /** -// */ -// readonly cashu_token: string | undefined; -// /** -// */ -// readonly disable_output_substitution: boolean | undefined; -// /** -// */ -// readonly fedimint_invite_code: string | undefined; -// /** -// */ -// readonly fedimint_oob_notes: string | undefined; -// /** -// */ -// readonly invoice: string | undefined; -// /** -// */ -// readonly is_lnurl_auth: boolean; -// /** -// */ -// readonly lightning_address: string | undefined; -// /** -// */ -// readonly lnurl: string | undefined; -// /** -// */ -// readonly memo: string | undefined; -// /** -// */ -// readonly network: string | undefined; -// /** -// */ -// readonly node_pubkey: string | undefined; -// /** -// */ -// readonly nostr_pubkey: string | undefined; -// /** -// */ -// readonly nostr_wallet_auth: string | undefined; -// /** -// */ -// readonly offer: string | undefined; -// /** -// */ -// readonly payjoin_endpoint: string | undefined; -// /** -// */ -// readonly payjoin_supported: boolean; -// /** -// */ -// readonly refund: string | undefined; -// /** -// */ -// readonly string: string; -// } -// /**