get rid of unified receive

only do one at a time, address or invoice

move method chooser to receive amount screen

fix flicker on send / swap pages

amountless on-chain, fix receive warnings

fix unnecessary "Failed to conduct wallet operation" errors

close receive method chooser automatically
This commit is contained in:
Paul Miller
2024-04-22 16:00:39 -05:00
parent 11cc9eb860
commit a4981cb8cf
10 changed files with 325 additions and 275 deletions

View File

@@ -77,10 +77,10 @@ test("fedmint join, receive, send", async ({ page }) => {
const value = await qrCode.getAttribute("value");
// The SVG's value property includes "bitcoin:t"
expect(value).toContain("bitcoin:t");
// The SVG's value property includes "bitcoin:l"
expect(value).toContain("lightning:l");
const lightningInvoice = value?.split("lightning=")[1];
const lightningInvoice = value?.split("lightning:")[1];
// Post the lightning invoice to the server
const _response = await fetch(

View File

@@ -51,10 +51,10 @@ test("rountrip receive and send", async ({ page }) => {
const value = await qrCode.getAttribute("value");
// The SVG's value property includes "bitcoin:t"
expect(value).toContain("bitcoin:t");
// The SVG's value property includes "lightning:l"
expect(value).toContain("lightning:l");
const lightningInvoice = value?.split("lightning=")[1];
const lightningInvoice = value?.split("lightning:")[1];
// Post the lightning invoice to the server
const _response = await fetch(

View File

@@ -108,13 +108,10 @@
"receive_add_the_sender": "Add the sender for your records",
"keep_mutiny_open": "Keep Mutiny open to complete the payment.",
"choose_payment_format": "Choose payment format",
"unified_label": "Unified",
"unified_caption": "Combines a bitcoin address and a lightning invoice. Sender chooses payment method.",
"lightning_label": "Lightning invoice",
"lightning_caption": "Ideal for small transactions. Usually lower fees than on-chain.",
"onchain_label": "Bitcoin address",
"onchain_caption": "On-chain, just like Satoshi did it. Ideal for very large transactions.",
"unified_setup_fee": "A lightning setup fee of {{amount}} SATS will be charged if paid over lightning.",
"lightning_setup_fee": "A lightning setup fee of {{amount}} SATS will be charged for this receive.",
"amount": "Amount",
"fee": "+ Fee",
@@ -123,11 +120,11 @@
"channel_size": "Channel size",
"channel_reserve": "- Channel reserve",
"error_under_min_lightning": "Defaulting to On-chain. Amount is too small for your initial Lightning receive.",
"error_creating_unified": "Defaulting to On-chain. Something went wrong when creating the unified address",
"error_creating_address": "Something went wrong when creating the on-chain address",
"error_creating_unified": "Defaulting to On-chain. Something went wrong when creating the Lightning invoice.",
"error_creating_address": "Something went wrong when creating the on-chain address.",
"amount_editable": {
"receive_too_small": "Your first receive needs to be {{amount}} SATS or greater.",
"setup_fee_lightning": "A lightning setup fee will be charged if paid over lightning.",
"setup_fee_lightning": "A lightning setup fee will be charged.",
"more_than_21m": "There are only 21 million bitcoin.",
"set_amount": "Set amount",
"max": "MAX",
@@ -142,7 +139,6 @@
"integrated_qr": {
"onchain": "On-chain",
"lightning": "Lightning",
"unified": "Unified",
"gift": "Lightning Gift"
},
"remember_choice": "Remember my choice next time",
@@ -150,7 +146,9 @@
"method_help": {
"title": "Receive Method",
"body": "Lightning receives will automatically go into your chosen federation. You can swap to self-custodial later if you want."
}
},
"receive_strings_error": "Something went wrong generating an invoice or on-chain address.",
"error_under_min_onchain": "That's under the dust limit! On-chain transactions should be much bigger."
},
"send": {
"search": {

View File

@@ -59,7 +59,6 @@ export const AmountEditable: ParentComponent<{
state.fiat,
sw
).then((sats) => {
console.log("sats", sats);
setLocalFiat(sats);
});
}

View File

@@ -39,16 +39,6 @@ function KindIndicator(props: { kind: ReceiveFlavor | "gift" | "lnAddress" }) {
</h3>
<Zap class="h-4 w-4" />
</Match>
<Match when={props.kind === "unified"}>
<h3 class="font-semibold">
{i18n.t("receive.integrated_qr.unified")}
</h3>
<div class="flex gap-1">
<Zap class="h-4 w-4" />
<Link class="h-4 w-4" />
</div>
</Match>
</Switch>
</div>
);

View File

@@ -3,11 +3,13 @@ import { createResource, Match, Switch } from "solid-js";
import { InfoBox } from "~/components/InfoBox";
import { FeesModal } from "~/components/MoreInfoModal";
import { useI18n } from "~/i18n/context";
import { ReceiveFlavor } from "~/routes";
import { useMegaStore } from "~/state/megaStore";
export function ReceiveWarnings(props: {
amountSats: bigint;
from_fedi_to_ln?: boolean;
flavor?: ReceiveFlavor;
}) {
const i18n = useI18n();
const [state, _actions, sw] = useMegaStore();
@@ -37,17 +39,19 @@ export function ReceiveWarnings(props: {
if (state.federations?.length !== 0 && props.from_fedi_to_ln !== true) {
return undefined;
}
if (
(state.balance?.lightning || 0n) === 0n &&
!state.settings?.lsps_connection_string
) {
return i18n.t("receive.amount_editable.receive_too_small", {
amount: "100,000"
});
}
if (props.flavor === "lightning") {
if (
(state.balance?.lightning || 0n) === 0n &&
!state.settings?.lsps_connection_string
) {
return i18n.t("receive.amount_editable.receive_too_small", {
amount: "100,000"
});
}
if (props.amountSats > (inboundCapacity() || 0n)) {
return i18n.t("receive.amount_editable.setup_fee_lightning");
if (props.amountSats > (inboundCapacity() || 0n)) {
return i18n.t("receive.amount_editable.setup_fee_lightning");
}
}
return undefined;
@@ -65,8 +69,21 @@ export function ReceiveWarnings(props: {
}
};
const tooSmallWarning = () => {
if (
props.flavor === "onchain" &&
props.amountSats > 0n &&
props.amountSats < 546n
) {
return i18n.t("receive.error_under_min_onchain");
}
};
return (
<Switch>
<Match when={tooSmallWarning()}>
<InfoBox accent="red">{tooSmallWarning()}</InfoBox>
</Match>
<Match when={sillyAmountWarning()}>
<InfoBox accent="red">{sillyAmountWarning()}</InfoBox>
</Match>

View File

@@ -1,9 +1,6 @@
import {
MutinyBip21RawMaterials,
MutinyInvoice
} from "@mutinywallet/mutiny-wasm";
import { MutinyInvoice } from "@mutinywallet/mutiny-wasm";
import { useNavigate } from "@solidjs/router";
import { ArrowLeftRight, CircleHelp, Users } from "lucide-solid";
import { CircleHelp, Link, Users, Zap } from "lucide-solid";
import {
createEffect,
createMemo,
@@ -23,7 +20,6 @@ import {
BackButton,
BackLink,
Button,
Checkbox,
DefaultMain,
Fee,
FeesModal,
@@ -36,6 +32,7 @@ import {
MutinyWalletGuard,
NavBar,
ReceiveWarnings,
SharpButton,
showToast,
SimpleDialog,
SimpleInput,
@@ -71,37 +68,74 @@ export type OnChainTx = {
};
};
export type ReceiveFlavor = "unified" | "lightning" | "onchain";
export type ReceiveFlavor = "lightning" | "onchain";
type ReceiveState = "edit" | "show" | "paid";
type PaidState = "lightning_paid" | "onchain_paid";
function FeeWarning(props: { fee: bigint; flavor: ReceiveFlavor }) {
const i18n = useI18n();
return (
// TODO: probably won't always be fixed 2500?
<Show when={props.fee > 1000n}>
<Switch>
<Match when={props.flavor === "unified"}>
<InfoBox accent="blue">
{i18n.t("receive.unified_setup_fee", {
amount: props.fee.toLocaleString()
})}
<FeesModal />
</InfoBox>
</Match>
<Match when={props.flavor === "lightning"}>
<InfoBox accent="blue">
{i18n.t("receive.lightning_setup_fee", {
amount: props.fee.toLocaleString()
})}
<FeesModal />
</InfoBox>
</Match>
</Switch>
<Show when={props.fee > 1000n && props.flavor === "lightning"}>
<InfoBox accent="blue">
{i18n.t("receive.lightning_setup_fee", {
amount: props.fee.toLocaleString()
})}
<FeesModal />
</InfoBox>
</Show>
);
}
function FlavorChooser(props: {
flavor: ReceiveFlavor;
setFlavor: (value: string) => void;
}) {
const [methodChooserOpen, setMethodChooserOpen] = createSignal(false);
const i18n = useI18n();
const RECEIVE_FLAVORS = [
{
value: "lightning",
label: i18n.t("receive.lightning_label"),
caption: i18n.t("receive.lightning_caption")
},
{
value: "onchain",
label: i18n.t("receive.onchain_label"),
caption: i18n.t("receive.onchain_caption")
}
];
return (
<>
<SharpButton onClick={() => setMethodChooserOpen(true)}>
{props.flavor === "lightning" ? (
<Zap class="h-4 w-4" />
) : (
<Link class="h-4 w-4" />
)}
{props.flavor === "lightning" ? "Lightning" : "On-chain"}
</SharpButton>
<SimpleDialog
title={i18n.t("receive.choose_payment_format")}
open={methodChooserOpen()}
setOpen={(open) => setMethodChooserOpen(open)}
>
<StyledRadioGroup
initialValue={props.flavor}
onValueChange={(flavor) => {
props.setFlavor(flavor);
setMethodChooserOpen(false);
}}
choices={RECEIVE_FLAVORS}
accent="white"
vertical
delayOnChange
/>
</SimpleDialog>
</>
);
}
function ReceiveMethodHelp() {
const i18n = useI18n();
const [open, setOpen] = createSignal(false);
@@ -123,7 +157,7 @@ function ReceiveMethodHelp() {
}
export function Receive() {
const [state, actions, sw] = useMegaStore();
const [state, _actions, sw] = useMegaStore();
const navigate = useNavigate();
const i18n = useI18n();
@@ -131,8 +165,16 @@ export function Receive() {
const [whatForInput, setWhatForInput] = createSignal("");
const [receiveState, setReceiveState] = createSignal<ReceiveState>("edit");
const [bip21Raw, setBip21Raw] = createSignal<MutinyBip21RawMaterials>();
const [unified, setUnified] = createSignal("");
// We use these for displaying the QR
const [receiveStrings, setReceiveStrings] = createSignal<{
lightning?: string;
onchain?: string;
}>();
// We use these for checking the payment status
const [rawReceiveStrings, setRawReceiveStrings] = createSignal<{
bolt11?: string;
address?: string;
}>();
const [lspFee, setLspFee] = createSignal(0n);
@@ -140,10 +182,8 @@ export function Receive() {
const [paymentTx, setPaymentTx] = createSignal<OnChainTx>();
const [paymentInvoice, setPaymentInvoice] = createSignal<MutinyInvoice>();
// The flavor of the receive (defaults to unified)
const [flavor, setFlavor] = createSignal<ReceiveFlavor>(
state.preferredInvoiceType
);
// The flavor of the receive
const [flavor, setFlavor] = createSignal<ReceiveFlavor>("lightning");
// loading state for the continue button
const [loading, setLoading] = createSignal(false);
@@ -154,47 +194,19 @@ export function Receive() {
const [detailsKind, setDetailsKind] = createSignal<HackActivityType>();
const [detailsId, setDetailsId] = createSignal<string>("");
const RECEIVE_FLAVORS = [
{
value: "unified",
label: i18n.t("receive.unified_label"),
caption: i18n.t("receive.unified_caption")
},
{
value: "lightning",
label: i18n.t("receive.lightning_label"),
caption: i18n.t("receive.lightning_caption")
},
{
value: "onchain",
label: i18n.t("receive.onchain_label"),
caption: i18n.t("receive.onchain_caption")
}
];
const [rememberChoice, setRememberChoice] = createSignal(false);
const receiveString = createMemo(() => {
if (unified() && receiveState() === "show") {
if (flavor() === "unified") {
return unified();
} else if (flavor() === "lightning") {
return bip21Raw()?.invoice ?? "";
} else if (flavor() === "onchain") {
return bip21Raw()?.address ?? "";
}
}
});
function clearAll() {
setAmount(0n);
function clearAllButAmount() {
setReceiveState("edit");
setBip21Raw(undefined);
setUnified("");
setReceiveStrings(undefined);
setPaymentTx(undefined);
setPaymentInvoice(undefined);
setError("");
setFlavor(state.preferredInvoiceType);
}
function clearAll() {
clearAllButAmount();
setAmount(0n);
setFlavor("lightning");
setWhatForInput("");
}
function openDetailsModal() {
@@ -221,67 +233,43 @@ export function Receive() {
setDetailsOpen(true);
}
async function getUnifiedQr(amount: bigint) {
console.log("get unified amount", amount);
const bigAmount = BigInt(amount);
setLoading(true);
// Both paths use tags so we'll do this once
let tags;
const receiveTags = createMemo(() => {
return whatForInput() ? [whatForInput().trim()] : [];
});
async function getLightningReceiveString(amount: bigint) {
try {
tags = whatForInput() ? [whatForInput().trim()] : [];
const inv = await sw.create_invoice(amount, receiveTags());
const bolt11 = inv?.bolt11;
setRawReceiveStrings({ bolt11 });
return `lightning:${bolt11}`;
} catch (e) {
showToast(eify(e));
console.error(e);
setLoading(false);
return;
}
}
// Happy path
// First we try to get both an invoice and an address
async function getOnchainReceiveString(amount?: bigint) {
try {
console.log("big amount", bigAmount);
const raw = await sw.create_bip21(bigAmount, tags);
// Save the raw info so we can watch the address and invoice
setBip21Raw(raw);
console.log("raw", raw);
const params = objectToSearchParams({
amount: raw?.btc_amount,
lightning: raw?.invoice
});
setLoading(false);
return `bitcoin:${raw?.address}?${params}`;
} catch (e) {
console.error(e);
if (e === "Satoshi amount is invalid") {
setError(i18n.t("receive.error_under_min_lightning"));
} else {
setError(i18n.t("receive.error_creating_unified"));
if (amount && amount < 546n) {
throw new Error(i18n.t("receive.error_under_min_onchain"));
}
}
const raw = await sw.get_new_address(receiveTags());
const address = raw?.address;
// If we didn't return before this, that means create_bip21 failed
// So now we'll just try and get an address without the invoice
try {
const raw = await sw.get_new_address(tags);
// Save the raw info so we can watch the address
setBip21Raw(raw);
setFlavor("onchain");
// We won't meddle with a "unified" QR here
return raw?.address;
if (amount && amount > 0n) {
const btc_amount = await sw.convert_sats_to_btc(amount);
const params = objectToSearchParams({
amount: btc_amount.toString()
});
setRawReceiveStrings({ address });
return `bitcoin:${address}?${params}`;
} else {
return `bitcoin:${address}`;
}
} catch (e) {
// If THAT failed we're really screwed
showToast(eify(i18n.t("receive.error_creating_address")));
console.error(e);
} finally {
setLoading(false);
}
}
@@ -292,25 +280,55 @@ export function Receive() {
}
async function getQr() {
if (amount()) {
const unifiedQr = await getUnifiedQr(amount());
setLoading(true);
try {
if (flavor() === "lightning") {
const lightning = await getLightningReceiveString(amount());
setReceiveStrings({ lightning });
}
setUnified(unifiedQr || "");
setReceiveState("show");
if (flavor() === "onchain") {
const onchain = await getOnchainReceiveString(amount());
setReceiveStrings({ onchain });
}
if (!receiveStrings()?.lightning && !receiveStrings()?.onchain) {
throw new Error(i18n.t("receive.receive_strings_error"));
}
if (!error()) {
setReceiveState("show");
}
} catch (e) {
console.error(e);
showToast(eify(e));
}
setLoading(false);
}
async function checkIfPaid(
bip21?: MutinyBip21RawMaterials
): Promise<PaidState | undefined> {
if (bip21) {
console.debug("checking if paid...");
const lightning = bip21.invoice;
const address = bip21.address;
const qrString = createMemo(() => {
if (receiveState() === "show") {
if (flavor() === "lightning") {
return receiveStrings()?.lightning;
} else if (flavor() === "onchain") {
return receiveStrings()?.onchain;
}
}
});
async function checkIfPaid(receiveStrings?: {
bolt11?: string;
address?: string;
}): Promise<PaidState | undefined> {
if (receiveStrings) {
const lightning = receiveStrings.bolt11;
const address = receiveStrings.address;
try {
// Lightning invoice might be blank
if (lightning) {
console.log("checking invoice", lightning);
const invoice = await sw.get_invoice(lightning);
// If the invoice has a fees amount that's probably the LSP fee
@@ -326,15 +344,16 @@ export function Receive() {
}
}
const tx = (await sw.check_address(address)) as
| OnChainTx
| undefined;
if (address) {
console.log("checking address", address);
const tx = await sw.check_address(address);
if (tx) {
setReceiveState("paid");
setPaymentTx(tx);
await vibrateSuccess();
return "onchain_paid";
if (tx) {
setReceiveState("paid");
setPaymentTx(tx);
await vibrateSuccess();
return "onchain_paid";
}
}
} catch (e) {
console.error(e);
@@ -342,33 +361,29 @@ export function Receive() {
}
}
function selectFlavor(flavor: string) {
setFlavor(flavor as ReceiveFlavor);
if (rememberChoice()) {
actions.setPreferredInvoiceType(flavor as ReceiveFlavor);
}
setMethodChooserOpen(false);
}
const [paidState, { refetch }] = createResource(bip21Raw, checkIfPaid);
const [paidState, { refetch }] = createResource(
rawReceiveStrings,
checkIfPaid
);
createEffect(() => {
const interval = setInterval(() => {
if (receiveState() === "show") refetch();
}, 1000); // Poll every second
if (receiveState() !== "show") {
clearInterval(interval);
}
onCleanup(() => {
clearInterval(interval);
});
});
const [methodChooserOpen, setMethodChooserOpen] = createSignal(false);
return (
<MutinyWalletGuard>
<DefaultMain>
<Show when={receiveState() === "show"} fallback={<BackLink />}>
<BackButton
onClick={() => clearAll()}
onClick={() => clearAllButAmount()}
title={i18n.t("receive.edit")}
showOnDesktop
/>
@@ -383,17 +398,26 @@ export function Receive() {
{i18n.t("receive.receive_bitcoin")}
</LargeHeader>
<Switch>
<Match when={!unified() || receiveState() === "edit"}>
<Match
when={!receiveStrings() || receiveState() === "edit"}
>
<div class="flex-1" />
<VStack>
<AmountEditable
initialAmountSats={amount() || "0"}
setAmountSats={setAmount}
onSubmit={getQr}
/>
<div class="mx-auto flex w-full max-w-[400px] flex-col items-center">
<AmountEditable
initialAmountSats={amount() || "0"}
setAmountSats={setAmount}
onSubmit={getQr}
/>
<FlavorChooser
flavor={flavor()}
setFlavor={setFlavor}
/>
</div>
<ReceiveWarnings
amountSats={amount() || 0n}
from_fedi_to_ln={false}
flavor={flavor()}
/>
</VStack>
<div class="flex-1" />
@@ -417,7 +441,9 @@ export function Receive() {
/>
</form>
<Button
disabled={!amount()}
disabled={
!amount() && !(flavor() === "onchain")
}
intent="green"
onClick={onSubmit}
loading={loading()}
@@ -426,7 +452,7 @@ export function Receive() {
</Button>
</VStack>
</Match>
<Match when={unified() && receiveState() === "show"}>
<Match when={receiveStrings() && receiveState() === "show"}>
<FeeWarning fee={lspFee()} flavor={flavor()} />
<Show when={error()}>
<InfoBox accent="red">
@@ -434,42 +460,13 @@ export function Receive() {
</InfoBox>
</Show>
<IntegratedQr
value={receiveString() ?? ""}
value={qrString() ?? ""}
amountSats={amount() ? amount().toString() : "0"}
kind={flavor()}
/>
<p class="text-center text-m-grey-350">
{i18n.t("receive.keep_mutiny_open")}
</p>
{/* Only show method chooser when we have an invoice */}
<Show when={bip21Raw()?.invoice}>
<button
class="mx-auto flex items-center gap-2 pb-8 font-bold text-m-grey-400"
onClick={() => setMethodChooserOpen(true)}
>
<span>{i18n.t("receive.choose_format")}</span>
<ArrowLeftRight class="h-4 w-4" />
</button>
<SimpleDialog
title={i18n.t("receive.choose_payment_format")}
open={methodChooserOpen()}
setOpen={(open) => setMethodChooserOpen(open)}
>
<StyledRadioGroup
initialValue={flavor()}
onValueChange={selectFlavor}
choices={RECEIVE_FLAVORS}
accent="white"
vertical
delayOnChange
/>
<Checkbox
label={i18n.t("receive.remember_choice")}
checked={rememberChoice()}
onChange={setRememberChoice}
/>
</SimpleDialog>
</Show>
</Match>
<Match when={receiveState() === "paid"}>
<SuccessModal

View File

@@ -276,7 +276,6 @@ export function Send() {
// Rerun every time the source or amount changes to check for amount errors
createEffect(() => {
setError(undefined);
// Don't recompute if sending
if (sending()) return;
if (source() === "onchain" && maxOnchain() < amountSats()) {
@@ -303,10 +302,13 @@ export function Send() {
);
return;
}
setError(undefined);
});
// Rerun every time the amount changes if we're onchain
const feeEstimate = createAsync(async () => {
const [feeEstimate, { refetch }] = createResource(async () => {
// If it's under the dust limit don't bother
if (amountSats() < 546n) return undefined;
if (
source() === "onchain" &&
amountSats() &&
@@ -327,12 +329,19 @@ export function Send() {
console.log("estimate", estimate);
return estimate;
} catch (e) {
setError(eify(e).message);
// 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);
@@ -540,7 +549,7 @@ export function Send() {
sentDetails.amount = amountSats();
sentDetails.destination = address();
sentDetails.txid = txid;
sentDetails.fee_estimate = feeEstimate() ?? 0;
sentDetails.fee_estimate = feeEstimate.latest ?? 0;
} else if (payjoinEnabled()) {
const txid = await sw.send_payjoin(
originalScan()!,
@@ -550,7 +559,7 @@ export function Send() {
sentDetails.amount = amountSats();
sentDetails.destination = address();
sentDetails.txid = txid;
sentDetails.fee_estimate = feeEstimate() ?? 0;
sentDetails.fee_estimate = feeEstimate.latest ?? 0;
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const txid = await sw.send_to_address(
@@ -561,7 +570,7 @@ export function Send() {
sentDetails.amount = amountSats();
sentDetails.destination = address();
sentDetails.txid = txid;
sentDetails.fee_estimate = feeEstimate() ?? 0;
sentDetails.fee_estimate = feeEstimate.latest ?? 0;
}
}
if (sentDetails.payment_hash || sentDetails.txid) {
@@ -589,6 +598,7 @@ export function Send() {
sending() ||
amountSats() == 0n ||
amountSats() === undefined ||
(source() === "onchain" && amountSats() < 546n) ||
!!error()
);
});
@@ -802,10 +812,10 @@ export function Send() {
/>
</Show>
<Suspense>
<Show when={feeEstimate()}>
<Show when={feeEstimate.latest}>
<FeeDisplay
amountSats={amountSats().toString()}
fee={feeEstimate()!.toString()}
fee={feeEstimate.latest!.toString()}
maxAmountSats={maxAmountSats()}
/>
</Show>

View File

@@ -2,6 +2,7 @@ import { createForm, required } from "@modular-forms/solid";
import { MutinyChannel } from "@mutinywallet/mutiny-wasm";
import { createAsync, useNavigate } from "@solidjs/router";
import {
createEffect,
createMemo,
createResource,
createSignal,
@@ -187,25 +188,28 @@ export function Swap() {
);
});
const amountWarning = createAsync(async () => {
if (amountSats() === 0n || !!channelOpenResult()) {
const [amountWarning, { refetch: refetchAmountWarning }] = createResource(
async () => {
if (amountSats() === 0n || !!channelOpenResult()) {
return undefined;
}
if (amountSats() < 100000n) {
return i18n.t("swap.channel_too_small", { amount: "100,000" });
}
if (
amountSats() >
(state.balance?.confirmed || 0n) +
(state.balance?.unconfirmed || 0n) ||
!feeEstimate()
) {
return i18n.t("swap.insufficient_funds");
}
return undefined;
}
if (amountSats() < 100000n) {
return i18n.t("swap.channel_too_small", { amount: "100,000" });
}
if (
amountSats() >
(state.balance?.confirmed || 0n) +
(state.balance?.unconfirmed || 0n)
) {
return i18n.t("swap.insufficient_funds");
}
return undefined;
});
);
function calculateMaxOnchain() {
return (
@@ -222,31 +226,42 @@ export function Swap() {
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;
const [feeEstimate, { refetch: refetchFeeEstimate }] = createResource(
async () => {
// If it's under the dust limit don't bother
if (amountSats() < 546n) return undefined;
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;
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;
}
);
createEffect(() => {
if (amountSats()) {
refetchAmountWarning();
refetchFeeEstimate();
}
return undefined;
});
return (
@@ -419,18 +434,22 @@ export function Swap() {
]}
/>
<Suspense>
<Show when={feeEstimate()}>
<Show
when={feeEstimate.latest && amountSats() > 0n}
>
<FeeDisplay
amountSats={amountSats().toString()}
fee={feeEstimate()!.toString()}
fee={feeEstimate.latest!.toString()}
maxAmountSats={maxOnchain()}
/>
</Show>
</Suspense>
<Suspense>
<Show when={amountWarning() && amountSats() > 0n}>
<Show
when={amountWarning.latest && amountSats() > 0n}
>
<InfoBox accent={"red"}>
{amountWarning()}
{amountWarning.latest}
</InfoBox>
</Show>
</Suspense>

View File

@@ -505,6 +505,26 @@ export async function create_bip21(
} as MutinyBip21RawMaterials;
}
/**
* Creates a lightning invoice. The amount should be in satoshis.
* If no amount is provided, the invoice will be created with no amount.
* If no description is provided, the invoice will be created with no description.
*
* If the manager has more than one node it will create a phantom invoice.
* If there is only one node it will create an invoice just for that node.
* @param {bigint} amount
* @param {(string)[]} labels
* @returns {Promise<MutinyInvoice>}
*/
export async function create_invoice(
amount: bigint,
labels: string[]
): Promise<MutinyInvoice | undefined> {
const invoice = await wallet!.create_invoice(amount, labels);
if (!invoice) return undefined;
return destructureInvoice(invoice);
}
/**
* Estimates the onchain fee for a transaction sweep our on-chain balance
* to the given address.