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")}
{sending()
? i18n.t("send.sending")
: i18n.t("send.confirm_send")}
);
}