diff --git a/src/i18n/en/translations.ts b/src/i18n/en/translations.ts index 3eacc0cd..6aa683d2 100644 --- a/src/i18n/en/translations.ts +++ b/src/i18n/en/translations.ts @@ -113,7 +113,10 @@ export default { gift: "Lightning Gift" }, remember_choice: "Remember my choice next time", - what_for: "What's this for?" + what_for: "What's this for?", + lnurl_withdrawal_in_progress: "LNUrl withdrawal in progress", + lnurl_amount_message: + "Enter LNUrl withdrawal amount between min: {{min}} and max: {{max}}" }, send: { search: { diff --git a/src/routes/Receive.tsx b/src/routes/Receive.tsx index 6a347fb3..aa389dc8 100644 --- a/src/routes/Receive.tsx +++ b/src/routes/Receive.tsx @@ -46,7 +46,7 @@ import { VStack } from "~/components"; import { useI18n } from "~/i18n/context"; -import { useMegaStore } from "~/state/megaStore"; +import { LnUrlData, useMegaStore } from "~/state/megaStore"; import { eify, objectToSearchParams, vibrateSuccess } from "~/utils"; type OnChainTx = { @@ -136,6 +136,14 @@ export function Receive() { const [detailsKind, setDetailsKind] = createSignal(); const [detailsId, setDetailsId] = createSignal(""); + const [lnUrlData, setLnUrlData] = createSignal(); + const [fixedAmount, setFixedAmount] = createSignal(false); + const [lnUrlExecuted, setLnUrlExecuted] = createSignal(false); + + if (state.lnUrlData) { + initLnUrlWithdrawal(state.lnUrlData); + } + const RECEIVE_FLAVORS = [ { value: "unified", @@ -177,6 +185,9 @@ export function Receive() { setPaymentInvoice(undefined); setError(""); setFlavor(state.preferredInvoiceType); + setLnUrlData(undefined); + setLnUrlExecuted(false); + setFixedAmount(false); } function openDetailsModal() { @@ -272,7 +283,10 @@ export function Receive() { async function onSubmit(e: Event) { e.preventDefault(); - + const lnUrl = lnUrlData(); + if (lnUrl) { + return await handleLnUrlWithdrawal(lnUrl); + } await getQr(); } @@ -328,6 +342,81 @@ export function Receive() { } } + function initLnUrlWithdrawal(lnUrlData: LnUrlData) { + console.log("handleLnUrlWithdrawal", lnUrlData.lnurl, lnUrlData.params); + actions.setScanResult(undefined); + actions.setLnUrlData(undefined); + setLnUrlData(lnUrlData); + setError(""); + setFlavor("lightning"); + const lnUrlParams = lnUrlData.params; + + if (lnUrlParams.min === lnUrlParams.max) { + setAmount(mSatsToSats(lnUrlParams.max)); + setFixedAmount(true); + } else { + setAmount(mSatsToSats(lnUrlParams.min)); + setFixedAmount(false); + } + + setReceiveState("edit"); + } + + async function handleLnUrlWithdrawal(lnUrlData: LnUrlData) { + const lnurl = lnUrlData.lnurl; + const lnUrlParams = lnUrlData.params; + const amt = amount(); + const amtError = validateAmount( + amt, + mSatsToSats(lnUrlParams.min), + mSatsToSats(lnUrlParams.max) + ); + if (amtError) { + showToast(new Error(amtError)); + return; + } + setLoading(true); + setReceiveState("show"); + try { + const success = await state.mutiny_wallet?.lnurl_withdraw( + lnurl, + amount() + ); + if (!success) { + setError("lnurl_withdraw failed"); + } else { + setReceiveState("paid"); + } + } catch (e) { + console.error("lnurl_withdraw failed", e); + showToast(eify(e)); + } finally { + setLnUrlExecuted(true); + setLoading(false); + } + } + + function validateAmount( + amount: bigint, + min: bigint, + max: bigint + ): string | undefined { + if (amount === 0n) return "amount is zero"; + if (amount < min) return "amount smaller min"; + if (amount > max) return "amount greater max"; + } + + function mSatsToSats(mSats: bigint) { + return mSats / 1000n; + } + + function lnUrlAmountText(lnUrlData: LnUrlData) { + return i18n.t("receive.lnurl_amount_message", { + min: mSatsToSats(lnUrlData.params.min).toLocaleString(), + max: mSatsToSats(lnUrlData.params.max).toLocaleString() + }); + } + function selectFlavor(flavor: string) { setFlavor(flavor as ReceiveFlavor); if (rememberChoice()) { @@ -336,6 +425,18 @@ export function Receive() { setMethodChooserOpen(false); } + function satsReceived() { + if (receiveState() === "paid" && paidState() === "lightning_paid") { + return paymentInvoice()?.amount_sats; + } + + if (lnUrlExecuted()) { + return amount(); + } + + return paymentTx()?.received; + } + const [paidState, { refetch }] = createResource(bip21Raw, checkIfPaid); createEffect(() => { @@ -369,13 +470,24 @@ export function Receive() { {i18n.t("receive.receive_bitcoin")} - +
+ + +

{lnUrlAmountText(lnUrlData()!)}

+
+
- + @@ -416,6 +533,13 @@ export function Receive() { amountSats={amount() ? amount().toString() : "0"} kind={flavor()} /> + +

+ {i18n.t("receive.lnurl_withdrawal_in_progress")}{" "} + ... +

+
+

{i18n.t("receive.keep_mutiny_open")}

@@ -451,7 +575,7 @@ export function Receive() {
{ if (!open) clearAll(); }} @@ -471,30 +595,21 @@ export function Receive() {

{receiveState() === "paid" && - paidState() === "lightning_paid" + (paidState() === "lightning_paid" || + lnUrlData()) ? i18n.t("receive.payment_received") : i18n.t("receive.payment_initiated")}

@@ -509,7 +624,12 @@ export function Receive() { {/*TODO: Confirmation time estimate still not possible needs to be implemented in mutiny-node first*/} - +

(); const navigate = useNavigate(); @@ -40,7 +42,19 @@ export function Scanner() { } } - // When we have a nice result we can head over to the send screen + function handleLnUrl( + lnUrl: string, + success: (result: LnUrlParams) => void + ) { + state.mutiny_wallet + ?.decode_lnurl(lnUrl) + .then((lnurlParams) => { + success(lnurlParams); + }) + .catch((e) => showToast(eify(e))); + } + + // When we have a nice result we can head over to the next screen createEffect(() => { if (scanResult()) { actions.handleIncomingString( @@ -49,8 +63,24 @@ export function Scanner() { showToast(error); }, (result) => { - actions.setScanResult(result); - navigate("/send"); + if (result.lnurl && !result.is_lnurl_auth) { + const lnurl = result.lnurl; + handleLnUrl(result.lnurl, (lnurlParams) => { + actions.setScanResult(result); + actions.setLnUrlData({ + lnurl: lnurl, + params: lnurlParams + }); + if (lnurlParams.tag === "withdrawRequest") { + navigate("/receive"); + } else { + navigate("/send"); + } + }); + } else { + actions.setScanResult(result); + navigate("/send"); + } } ); } diff --git a/src/routes/Send.tsx b/src/routes/Send.tsx index 6e856f8d..48bc947a 100644 --- a/src/routes/Send.tsx +++ b/src/routes/Send.tsx @@ -47,14 +47,11 @@ import { } from "~/components"; import { useI18n } from "~/i18n/context"; import { ParsedParams } from "~/logic/waila"; -import { useMegaStore } from "~/state/megaStore"; +import { LnUrlData, useMegaStore } from "~/state/megaStore"; import { eify, vibrateSuccess } from "~/utils"; export type SendSource = "lightning" | "onchain"; -// 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; @@ -361,7 +358,10 @@ export function Send() { const [parsingDestination, setParsingDestination] = createSignal(false); - function handleDestination(source: ParsedParams | undefined) { + function handleDestination( + source: ParsedParams | undefined, + lnUrlParams: LnUrlData | undefined + ) { if (!source) return; setParsingDestination(true); setOriginalScan(source.original); @@ -380,7 +380,10 @@ export function Send() { ); } else if (source.lnurl) { console.log("processing lnurl"); - processLnurl(source as ParsedParams & { lnurl: string }); + processLnurl( + source as ParsedParams & { lnurl: string }, + lnUrlParams + ); } else { setAmountSats(source.amount_sats || 0n); if (source.amount_sats) setIsAmtEditable(false); @@ -424,10 +427,15 @@ export function Send() { } // A ParsedParams with an lnurl in it - function processLnurl(source: ParsedParams & { lnurl: string }) { - state.mutiny_wallet - ?.decode_lnurl(source.lnurl) - .then((lnurlParams) => { + function processLnurl( + source: ParsedParams & { lnurl: string }, + lnurlData?: LnUrlData + ) { + const promise = lnurlData + ? Promise.resolve(lnurlData.params) + : state.mutiny_wallet?.decode_lnurl(source.lnurl); + promise + ?.then((lnurlParams) => { if (lnurlParams.tag === "payRequest") { if (lnurlParams.min == lnurlParams.max) { setAmountSats(lnurlParams.min / 1000n); @@ -472,8 +480,9 @@ export function Send() { // If we got here from a scan or search onMount(() => { if (state.scan_result) { - handleDestination(state.scan_result); + handleDestination(state.scan_result, state.lnUrlData); actions.setScanResult(undefined); + actions.setLnUrlData(undefined); } }); diff --git a/src/state/megaStore.tsx b/src/state/megaStore.tsx index e4e8b9e7..03a6b400 100644 --- a/src/state/megaStore.tsx +++ b/src/state/megaStore.tsx @@ -2,6 +2,7 @@ // Inspired by https://github.com/solidjs/solid-realworld/blob/main/src/store/index.js import { + LnUrlParams, MutinyBalance, MutinyWallet, TagItem @@ -73,11 +74,13 @@ type MegaStore = [ testflightPromptDismissed: boolean; should_zap_hodl: boolean; federations?: MutinyFederationIdentity[]; + lnUrlData?: LnUrlData; }, { setup(password?: string): Promise; deleteMutinyWallet(): Promise; setScanResult(scan_result: ParsedParams | undefined): void; + setLnUrlData(lnUrlData: LnUrlData | undefined): void; sync(): Promise; setHasBackedUp(): void; listTags(): Promise; @@ -102,6 +105,11 @@ type MegaStore = [ } ]; +export interface LnUrlData { + lnurl: string; + params: LnUrlParams; +} + export const Provider: ParentComponent = (props) => { const [searchParams] = useSearchParams(); const navigate = useNavigate(); @@ -141,7 +149,8 @@ export const Provider: ParentComponent = (props) => { should_zap_hodl: localStorage.getItem("should_zap_hodl") === "true", testflightPromptDismissed: localStorage.getItem("testflightPromptDismissed") === "true", - federations: undefined as MutinyFederationIdentity[] | undefined + federations: undefined as MutinyFederationIdentity[] | undefined, + lnUrlData: undefined as LnUrlData | undefined }); const actions = { @@ -327,6 +336,9 @@ export const Provider: ParentComponent = (props) => { return []; } }, + setLnUrlData(lnUrlData: LnUrlData | undefined) { + setState({ lnUrlData }); + }, async saveFiat(fiat: Currency) { localStorage.setItem("fiat_currency", JSON.stringify(fiat)); const price = await actions.fetchPrice(fiat);