import { MutinyInvoice, TagItem } from "@mutinywallet/mutiny-wasm"; import { useLocation, useNavigate, useSearchParams } from "@solidjs/router"; import { Eye, EyeOff, Link, X, Zap } from "lucide-solid"; import { createEffect, createMemo, createResource, createSignal, JSX, Match, onMount, Show, Suspense, Switch } from "solid-js"; import { ActivityDetailsModal, AmountEditable, AmountFiat, AmountSats, BackPop, Button, DefaultMain, Failure, Fee, FeeDisplay, HackActivityType, InfoBox, LabelCircle, LoadingShimmer, MegaCheck, MethodChoice, MutinyWalletGuard, NavBar, SharpButton, showToast, SimpleInput, SmallHeader, StringShower, SuccessModal, UnstyledBackPop, VStack } from "~/components"; import { useI18n } from "~/i18n/context"; import { ParsedParams } from "~/logic/waila"; import { useMegaStore } from "~/state/megaStore"; import { eify, vibrateSuccess } from "~/utils"; export type SendSource = "lightning" | "onchain"; export type PrivacyLevel = "Public" | "Private" | "Anonymous" | "Not Available"; // const TEST_DEST = "bitcoin:tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe?amount=0.00001&label=heyo&lightning=lntbs10u1pjrwrdedq8dpjhjmcnp4qd60w268ve0jencwzhz048ruprkxefhj0va2uspgj4q42azdg89uupp5gngy2pqte5q5uvnwcxwl2t8fsdlla5s6xl8aar4xcsvxeus2w2pqsp5n5jp3pz3vpu92p3uswttxmw79a5lc566herwh3f2amwz2sp6f9tq9qyysgqcqpcxqrpwugv5m534ww5ukcf6sdw2m75f2ntjfh3gzeqay649256yvtecgnhjyugf74zakaf56sdh66ec9fqep2kvu6xv09gcwkv36rrkm38ylqsgpw3yfjl" // const TEST_DEST_ADDRESS = "tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe" // TODO: better success / fail type type SentDetails = { amount?: bigint; destination?: string; txid?: string; payment_hash?: string; failure_reason?: string; fee_estimate?: bigint | number; }; function DestinationShower(props: { source: SendSource; description?: string; address?: string; invoice?: MutinyInvoice; nodePubkey?: string; lnurl?: string; lightning_address?: string; contact?: TagItem; }) { return ( } /> } icon={} /> } icon={} /> } /> } icon={} /> } icon={} /> ); } export function DestinationItem(props: { title: string; value: JSX.Element; icon: JSX.Element; }) { return (
{props.icon}
{props.title}
{props.value}
); } export function Send() { const [state, actions, sw] = useMegaStore(); const navigate = useNavigate(); const [params, setParams] = useSearchParams(); const i18n = useI18n(); const [amountInput, setAmountInput] = createSignal(""); const [whatForInput, setWhatForInput] = createSignal(""); // These can be derived from the destination or set by the user const [amountSats, setAmountSats] = createSignal(0n); const [unparsedAmount, setUnparsedAmount] = createSignal(true); // These are derived from the incoming destination const [isAmtEditable, setIsAmtEditable] = createSignal(true); const [source, setSource] = createSignal("lightning"); const [invoice, setInvoice] = createSignal(); const [nodePubkey, setNodePubkey] = createSignal(); const [lnurlp, setLnurlp] = createSignal(); const [lnAddress, setLnAddress] = createSignal(); const [originalScan, setOriginalScan] = createSignal(); const [address, setAddress] = createSignal(); const [payjoinEnabled, setPayjoinEnabled] = createSignal(); const [description, setDescription] = createSignal(); const [contactId, setContactId] = createSignal(); const [isHodlInvoice, setIsHodlInvoice] = createSignal(false); // Is sending / sent const [sending, setSending] = createSignal(false); const [sentDetails, setSentDetails] = createSignal(); // Details Modal const [detailsOpen, setDetailsOpen] = createSignal(false); const [detailsKind, setDetailsKind] = createSignal(); const [detailsId, setDetailsId] = createSignal(""); // Errors const [error, setError] = createSignal(); function openDetailsModal() { const paymentTxId = sentDetails()?.txid ? sentDetails() ? sentDetails()?.txid : undefined : sentDetails() ? sentDetails()?.payment_hash : undefined; const kind = sentDetails()?.txid ? "OnChain" : "Lightning"; 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); } // TODO: can I dedupe this from the search page? async function parsePaste(text: string) { await actions.handleIncomingString( text, (error) => { showToast(error); }, (result) => { actions.setScanResult(result); navigate("/send", { state: { previous: "/search" } }); } ); } // TODO: do we actually use this anywhere? // send?invoice=... need to check for wallet because we can't parse until we have the wallet createEffect(() => { if (params.invoice && state.load_stage === "done") { parsePaste(params.invoice); setParams({ invoice: undefined }); } }); const maxOnchain = createMemo(() => { const conf = state.balance?.confirmed ?? 0n; const unc = state.balance?.unconfirmed ?? 0n; const fed = state.balance?.federation ?? 0n; if (fed > conf + unc) { return fed; } else { return conf + unc; } }); const maxLightning = createMemo(() => { const fed = state.balance?.federation ?? 0n; const ln = state.balance?.lightning ?? 0n; if (fed > ln) { return fed; } else { return ln; } }); const maxAmountSats = createMemo(() => { return source() === "onchain" ? maxOnchain() : maxLightning(); }); const isMax = createMemo(() => { if (source() === "onchain") { return amountSats() === maxOnchain(); } }); // Rerun every time the source or amount changes to check for amount errors createEffect(() => { // Don't recompute if sending if (sending()) return; if (source() === "onchain" && maxOnchain() < amountSats()) { setError(i18n.t("send.error_low_balance")); return; } if ( source() === "lightning" && (state.balance?.lightning ?? 0n) <= amountSats() && (state.balance?.federation ?? 0n) <= amountSats() ) { setError(i18n.t("send.error_low_balance")); return; } if ( source() === "lightning" && !!invoice()?.amount_sats && amountSats() !== invoice()?.amount_sats ) { setError( i18n.t("send.error_invoice_match", { amount: invoice()?.amount_sats?.toLocaleString() }) ); return; } setError(undefined); }); // Rerun every time the amount changes if we're onchain const [feeEstimate, { refetch }] = createResource(async () => { // If it's under the dust limit don't bother if (amountSats() < 546n) return undefined; if ( source() === "onchain" && amountSats() && amountSats() > 0n && address() ) { try { // If max we want to use the sweep fee estimator if (isMax()) { return await sw.estimate_sweep_tx_fee(address()!); } const estimate = await sw.estimate_tx_fee( address()!, amountSats(), undefined ); console.log("estimate", estimate); return estimate; } catch (e) { // This is usually because the amount is too small or too large so we can ignore console.error(e); } } return undefined; }); createEffect(() => { if (amountSats() && amountSats() > 0n) { refetch(); } }); const [parsingDestination, setParsingDestination] = createSignal(false); const [decodingLnUrl, setDecodingLnUrl] = createSignal(false); function handleDestination(source: ParsedParams | undefined) { if (!source) return; setParsingDestination(true); setOriginalScan(source.original); try { if (source.address) setAddress(source.address); if (source.payjoin_enabled) setPayjoinEnabled(source.payjoin_enabled); if (source.memo) setDescription(source.memo); if (source.contact_id) setContactId(source.contact_id); if (source.invoice) { processInvoice(source as ParsedParams & { invoice: string }); } else if (source.node_pubkey) { processNodePubkey( source as ParsedParams & { node_pubkey: string } ); } else if (source.lnurl) { console.log("processing lnurl"); processLnurl(source as ParsedParams & { lnurl: string }); } else { setAmountSats(source.amount_sats || 0n); if (source.amount_sats) setIsAmtEditable(false); setSource("onchain"); } // Return the source just to trigger `decodedDestination` as not undefined return source; } catch (e) { console.error("error", e); } finally { setParsingDestination(false); } } // A ParsedParams with an invoice in it function processInvoice(source: ParsedParams & { invoice: string }) { sw.decode_invoice(source.invoice!) .then((invoice) => { if (!invoice) return; if (invoice.expire <= Date.now() / 1000) { navigate("/search"); throw new Error(i18n.t("send.error_expired")); } if (invoice.amount_sats) { setAmountSats(invoice.amount_sats); setIsAmtEditable(false); } setInvoice(invoice); setIsHodlInvoice(invoice.potential_hodl_invoice); setSource("lightning"); }) .catch((e) => showToast(eify(e))); } // A ParsedParams with a node_pubkey in it function processNodePubkey(source: ParsedParams & { node_pubkey: string }) { setAmountSats(source.amount_sats || 0n); setNodePubkey(source.node_pubkey); setSource("lightning"); } // A ParsedParams with an lnurl in it function processLnurl(source: ParsedParams & { lnurl: string }) { setDecodingLnUrl(true); sw.decode_lnurl(source.lnurl) .then((lnurlParams) => { setDecodingLnUrl(false); if (lnurlParams.tag === "payRequest") { if (lnurlParams.min == lnurlParams.max) { setAmountSats(lnurlParams.min / 1000n); setIsAmtEditable(false); } else { setAmountSats(source.amount_sats || 0n); } if (source.lightning_address) { setLnAddress(source.lightning_address); setIsHodlInvoice( source.lightning_address .toLowerCase() .includes("zeuspay.com") ); } setLnurlp(source.lnurl); setSource("lightning"); } // TODO: this is a bit of a hack, ideally we do more nav from the megastore if (lnurlParams.tag === "withdrawRequest") { actions.setScanResult(source); navigate("/redeem"); } }) .catch((e) => showToast(eify(e))); } createEffect(() => { if (amountInput() === "") { setAmountSats(0n); } else { const parsed = BigInt(amountInput()); console.log("parsed", parsed); if (!parsed) { setUnparsedAmount(true); } if (parsed > 0n) { setAmountSats(parsed); setUnparsedAmount(false); } else { setUnparsedAmount(true); } } }); // If we got here from a scan or search onMount(() => { if (state.scan_result) { handleDestination(state.scan_result); actions.setScanResult(undefined); } }); async function handleSend() { try { setSending(true); const bolt11 = invoice()?.bolt11; const sentDetails: Partial = {}; const tags = contactId() ? [contactId()!] : []; if (whatForInput()) { tags.push(whatForInput().trim()); } if (source() === "lightning" && invoice() && bolt11) { sentDetails.destination = bolt11; // If the invoice has sats use that, otherwise we pass the user-defined amount if (invoice()?.amount_sats) { const payment = await sw.pay_invoice( bolt11, undefined, tags ); sentDetails.amount = payment?.amount_sats; sentDetails.payment_hash = payment?.payment_hash; sentDetails.fee_estimate = payment?.fees_paid || 0; } else { const payment = await sw.pay_invoice( bolt11, amountSats(), tags ); sentDetails.amount = payment?.amount_sats; sentDetails.payment_hash = payment?.payment_hash; sentDetails.fee_estimate = payment?.fees_paid || 0; } } else if (source() === "lightning" && nodePubkey()) { const payment = await sw.keysend( nodePubkey()!, amountSats(), undefined, // todo add optional keysend message tags ); // TODO: handle timeouts if (!payment?.paid) { throw new Error(i18n.t("send.error_keysend")); } else { sentDetails.amount = payment?.amount_sats; sentDetails.payment_hash = payment?.payment_hash; sentDetails.fee_estimate = payment?.fees_paid || 0; } } else if (source() === "lightning" && lnurlp()) { const zapNpub = visibility() !== "Not Available" && contact()?.npub ? contact()?.npub : undefined; const payment = await sw.lnurl_pay( lnurlp()!, amountSats(), zapNpub, // zap_npub tags, whatForInput(), // comment visibility() ); sentDetails.payment_hash = payment?.payment_hash; if (!payment?.paid) { throw new Error(i18n.t("send.error_LNURL")); } else { sentDetails.amount = payment?.amount_sats; sentDetails.payment_hash = payment?.payment_hash; sentDetails.fee_estimate = payment?.fees_paid || 0; } } else if (source() === "onchain" && address()) { if (isMax()) { // If we're trying to send the max amount, use the sweep method instead of regular send // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const txid = await sw.sweep_wallet(address()!, tags); sentDetails.amount = amountSats(); sentDetails.destination = address(); sentDetails.txid = txid; sentDetails.fee_estimate = feeEstimate.latest ?? 0; } else if (payjoinEnabled()) { const txid = await sw.send_payjoin( originalScan()!, amountSats(), tags ); sentDetails.amount = amountSats(); sentDetails.destination = address(); sentDetails.txid = txid; sentDetails.fee_estimate = feeEstimate.latest ?? 0; } else { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const txid = await sw.send_to_address( address()!, amountSats(), tags ); sentDetails.amount = amountSats(); sentDetails.destination = address(); sentDetails.txid = txid; sentDetails.fee_estimate = feeEstimate.latest ?? 0; } } if (sentDetails.payment_hash || sentDetails.txid) { setSentDetails(sentDetails as SentDetails); await vibrateSuccess(); } else { // TODO: what should we do here? hopefully this never happens? console.error("failed to send: no payment hash or txid"); } } catch (e) { const error = eify(e); setSentDetails({ failure_reason: error.message }); // TODO: figure out ux of when we want to show toast vs error screen // showToast(eify(e)) console.error(e); } finally { setSending(false); } } const sendButtonDisabled = createMemo(() => { return ( unparsedAmount() || parsingDestination() || sending() || amountSats() == 0n || amountSats() === undefined || (source() === "onchain" && amountSats() < 546n) || !!error() ); }); const lightningMethod = createMemo(() => { return { method: "lightning", maxAmountSats: maxLightning() }; }); const onchainMethod = createMemo(() => { return { method: "onchain", maxAmountSats: maxOnchain() }; }); const sendMethods = createMemo(() => { if (lnAddress() || lnurlp() || nodePubkey()) { return [lightningMethod()]; } if (invoice() && address()) { return [lightningMethod(), onchainMethod()]; } if (invoice()) { return [lightningMethod()]; } if (address()) { return [onchainMethod()]; } // We should never get here console.error("No send methods found"); return []; }); function setSourceFromMethod(method: MethodChoice) { if (method.method === "lightning") { setSource("lightning"); } else if (method.method === "onchain") { setSource("onchain"); } } const activeMethod = createMemo(() => { if (source() === "lightning") { return lightningMethod(); } else if (source() === "onchain") { return onchainMethod(); } }); const [visibility, setVisibility] = createSignal("Not Available"); // If the contact has an npub and it's an lnurlp send set the default visibility to private zap createEffect(() => { contact()?.npub && lnurlp() && setVisibility("Private"); }); function toggleVisibility() { if (visibility() === "Not Available") { setVisibility("Private"); } else if (visibility() === "Private") { setVisibility("Public"); } else { setVisibility("Not Available"); } } async function getContact(id: string) { console.log("fetching contact", id); try { const contact = await sw.get_tag_item(id); console.log("fetching contact", contact); // This shouldn't happen if (!contact) throw new Error("Contact not found"); return contact; } catch (e) { console.error(e); showToast(eify(e)); } } const [contact] = createResource(contactId, getContact); const location = useLocation(); return ( { if (!open) setSentDetails(undefined); }} onConfirm={() => { setSentDetails(undefined); const state = location.state as { previous?: string }; // If we're coming from a chat, we want to go back to the chat // Otherwise we want to go home if ( state?.previous && state?.previous.includes("chat/") ) { navigate(state?.previous); } else { navigate("/"); } }} >

{sentDetails()?.amount ? source() === "onchain" ? i18n.t("send.payment_initiated") : i18n.t("send.payment_sent") : sentDetails()?.failure_reason}


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

}>
{/* Need both these versions so that we make sure to get the right initial amount on load */} sendButtonDisabled() ? undefined : handleSend() } activeMethod={activeMethod()} methods={sendMethods()} setChosenMethod={setSourceFromMethod} />

{i18n.t("send.payjoin_send")}

sendButtonDisabled() ? undefined : handleSend() } activeMethod={activeMethod()} methods={sendMethods()} setChosenMethod={setSourceFromMethod} />

{i18n.t("send.hodl_invoice_warning")}

{error()}

{i18n.t("send.private")} {i18n.t("send.privatezap")} {i18n.t("send.publiczap")}
{ e.preventDefault(); if (!sendButtonDisabled()) { await handleSend(); } }} > setWhatForInput(e.currentTarget.value) } value={whatForInput()} />
); }