Wallet receive flow (#15) (#52)

* Pin sharp via pnpm override, add wallet receive

* Revert toast success styling

* Route receive through wallet connect

* Simplify receive invoice validation

* Polish receive modal layout

* Clarify NWC client config

* Adjust wallet action layout on mobile
This commit is contained in:
Tyson Lupul
2026-02-05 20:51:59 +00:00
committed by GitHub
parent f132d22308
commit 4dfbb437f9
8 changed files with 525 additions and 403 deletions
+4 -1
View File
@@ -92,6 +92,9 @@
], ],
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"sharp" "sharp"
] ],
"overrides": {
"sharp": "0.35.0-rc.0"
}
} }
} }
+277 -385
View File
File diff suppressed because it is too large Load Diff
+16 -4
View File
@@ -14,6 +14,7 @@
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {errorMessage} from "@lib/util"
import {payInvoice} from "@app/core/commands" import {payInvoice} from "@app/core/commands"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {clearModals} from "@app/util/modal" import {clearModals} from "@app/util/modal"
@@ -29,14 +30,18 @@
loading = true loading = true
try { try {
await payInvoice(invoice!.paymentRequest, sats * 1000) if (hasAmount) {
await payInvoice(invoice!.paymentRequest)
} else {
await payInvoice(invoice!.paymentRequest, sats * 1000)
}
pushToast({message: `Payment sent!`}) pushToast({message: `Payment sent!`})
clearModals() clearModals()
} catch (e) { } catch (e) {
console.error(e) console.error(e)
const message = String(e).replace(/^.*Error: /, "") const message = errorMessage(e)
pushToast({ pushToast({
theme: "error", theme: "error",
@@ -50,6 +55,7 @@
let loading = $state(false) let loading = $state(false)
let invoice: Invoice | undefined = $state() let invoice: Invoice | undefined = $state()
let sats = $state(0) let sats = $state(0)
const hasAmount = $derived((invoice?.satoshi ?? 0) > 0)
</script> </script>
<Modal> <Modal>
@@ -60,7 +66,7 @@
</ModalHeader> </ModalHeader>
{#if invoice} {#if invoice}
<div class="card2 bg-alt flex flex-col gap-2"> <div class="card2 bg-alt flex flex-col gap-2">
{#if $session?.wallet?.type === "webln" && invoice.satoshi === 0} {#if $session?.wallet?.type === "webln" && !hasAmount}
<p class="text-sm opacity-75"> <p class="text-sm opacity-75">
Uh oh! It looks like your current wallet doesn't support invoices without an amount. See Uh oh! It looks like your current wallet doesn't support invoices without an amount. See
if you can get a lightning invoice with a pre-set amount. if you can get a lightning invoice with a pre-set amount.
@@ -101,7 +107,13 @@
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button class="btn btn-primary" onclick={confirm} disabled={!invoice || sats === 0 || loading}> <Button
class="btn btn-primary"
onclick={confirm}
disabled={!invoice ||
sats === 0 ||
loading ||
($session?.wallet?.type === "webln" && !hasAmount)}>
{#if loading} {#if loading}
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
{:else} {:else}
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import {Invoice} from "@getalby/lightning-tools/bolt11"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {errorMessage} from "@lib/util"
import QRCode from "@app/components/QRCode.svelte"
import {createInvoice} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
const back = () => history.back()
const create = async () => {
const satAmount = Math.floor(Number(sats))
if (!Number.isFinite(satAmount) || satAmount <= 0) {
pushToast({theme: "error", message: "Enter an amount greater than 0."})
return
}
loading = true
try {
const paymentRequest = await createInvoice({sats: satAmount})
invoice = new Invoice({pr: paymentRequest})
sats = invoice.satoshi && invoice.satoshi > 0 ? invoice.satoshi : satAmount
pushToast({message: "Invoice created"})
} catch (e) {
console.warn(e)
const message = errorMessage(e)
pushToast({
theme: "error",
message: `Failed to create invoice: ${message}`,
})
} finally {
loading = false
}
}
const reset = () => {
invoice = undefined
sats = 10
}
let loading = $state(false)
let invoice: Invoice | undefined = $state()
let sats = $state(10)
const invoiceHasAmount = $derived((invoice?.satoshi ?? 0) > 0)
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Receive with Lightning</ModalTitle>
<ModalSubtitle>Use your wallet to receive Bitcoin payments over lightning.</ModalSubtitle>
</ModalHeader>
{#if invoice}
<div class="card2 bg-alt flex flex-col gap-2">
<div class="flex flex-col items-center gap-6 pt-4">
<QRCode code={invoice.paymentRequest} class="w-full max-w-64" />
<p class="text-center text-sm opacity-75">Scan with your wallet, or click to copy.</p>
</div>
<p class="text-sm opacity-75">
Share this invoice to receive <strong>{sats}</strong> satoshis
{invoiceHasAmount ? "" : " (payer chooses the exact amount)"}.
</p>
</div>
{:else}
<div class="card2 bg-alt flex flex-col gap-2">
<FieldInline>
{#snippet label()}
Amount (satoshis)
{/snippet}
{#snippet input()}
<div class="flex flex-grow justify-end">
<label class="input input-bordered flex items-center gap-2">
<Icon icon={Bolt} />
<input bind:value={sats} type="number" class="w-14" placeholder="0" />
</label>
</div>
{/snippet}
</FieldInline>
<p class="text-sm opacity-75">Enter an amount to generate an invoice with your wallet.</p>
</div>
{/if}
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
{#if invoice}
<Button class="btn btn-neutral" onclick={reset} disabled={loading}>Clear Invoice</Button>
{:else}
<Button class="btn btn-primary" onclick={create} disabled={loading}>
{#if loading}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={Bolt} />
{/if}
Create Invoice
</Button>
{/if}
</ModalFooter>
</Modal>
+2 -1
View File
@@ -17,6 +17,7 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte" import EmojiButton from "@lib/components/EmojiButton.svelte"
import {errorMessage} from "@lib/util"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import {payInvoice} from "@app/core/commands" import {payInvoice} from "@app/core/commands"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
@@ -85,7 +86,7 @@
} catch (e) { } catch (e) {
console.error(e) console.error(e)
const message = String(e).replace(/^.*Error: /, "") const message = errorMessage(e)
pushToast({ pushToast({
theme: "error", theme: "error",
+78 -1
View File
@@ -425,6 +425,22 @@ export const publishLeaveRequest = (params: LeaveRequestParams) =>
export const getWebLn = () => (window as any).webln export const getWebLn = () => (window as any).webln
export const getNwcClient = () => {
const $session = session.get()
if (!$session?.wallet || $session.wallet.type !== "nwc") {
throw new Error("No NWC wallet is connected")
}
const {info} = $session.wallet
if (info.nostrWalletConnectUrl) {
return new nwc.NWCClient({nostrWalletConnectUrl: info.nostrWalletConnectUrl})
}
return new nwc.NWCClient(info)
}
export const payInvoice = async (invoice: string, msats?: number) => { export const payInvoice = async (invoice: string, msats?: number) => {
const $session = session.get() const $session = session.get()
@@ -435,7 +451,7 @@ export const payInvoice = async (invoice: string, msats?: number) => {
if ($session.wallet.type === "nwc") { if ($session.wallet.type === "nwc") {
const params: {invoice: string; amount?: number} = {invoice} const params: {invoice: string; amount?: number} = {invoice}
if (msats) params.amount = msats if (msats) params.amount = msats
return new nwc.NWCClient($session.wallet.info).payInvoice(params) return getNwcClient().payInvoice(params)
} else if ($session.wallet.type === "webln") { } else if ($session.wallet.type === "webln") {
if (msats) throw new Error("Unable to pay zero invoices with webln") if (msats) throw new Error("Unable to pay zero invoices with webln")
return getWebLn() return getWebLn()
@@ -444,6 +460,67 @@ export const payInvoice = async (invoice: string, msats?: number) => {
} }
} }
export type CreateInvoiceParams = {
sats: number
description?: string
}
export const createInvoice = async ({
sats,
description = "Receive via lightning",
}: CreateInvoiceParams) => {
const $session = session.get()
if (!$session?.wallet) {
throw new Error("No wallet is connected")
}
const satAmount = Math.floor(sats)
if (!Number.isFinite(satAmount) || satAmount <= 0) {
throw new Error("Invalid satoshi amount")
}
if ($session.wallet.type === "nwc") {
const createdInvoice = await getNwcClient().makeInvoice({
amount: satAmount * 1000,
description,
})
if (!createdInvoice.invoice) {
throw new Error("NWC wallet failed to return an invoice")
}
return createdInvoice.invoice
}
if ($session.wallet.type === "webln") {
const webLn = getWebLn()
if (!webLn) {
throw new Error("WebLN not available")
}
await webLn.enable()
const response = await webLn.makeInvoice({
amount: satAmount,
defaultMemo: description,
})
const paymentRequest =
typeof response === "string" ? response : response?.paymentRequest || response?.pr || ""
if (!paymentRequest) {
throw new Error("Invalid payment request returned from WebLN")
}
return paymentRequest
}
throw new Error("Unsupported wallet type")
}
// File upload // File upload
export const normalizeBlossomUrl = (url: string) => normalizeUrl(url.replace(/^ws/, "http")) export const normalizeBlossomUrl = (url: string) => normalizeUrl(url.replace(/^ws/, "http"))
+2
View File
@@ -17,6 +17,8 @@ export const daysBetween = (start: number, end: number) => [...range(start, end,
export const ucFirst = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1) export const ucFirst = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1)
export const errorMessage = (err: unknown) => String(err).replace(/^.*Error: /, "")
export const buildUrl = (base: string | URL, ...pathname: string[]) => { export const buildUrl = (base: string | URL, ...pathname: string[]) => {
const url = new URL(base) const url = new URL(base)
+28 -11
View File
@@ -3,10 +3,12 @@
import {LOCALE} from "@welshman/lib" import {LOCALE} from "@welshman/lib"
import {displayRelayUrl, isNWCWallet, fromMsats} from "@welshman/util" import {displayRelayUrl, isNWCWallet, fromMsats} from "@welshman/util"
import {session, pubkey, profilesByPubkey} from "@welshman/app" import {session, pubkey, profilesByPubkey} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl" import DownloadMinimalistic from "@assets/icons/download-minimalistic.svg?dataurl"
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import WalletPay from "@app/components/WalletPay.svelte" import WalletPay from "@app/components/WalletPay.svelte"
import WalletReceive from "@app/components/WalletReceive.svelte"
import WalletConnect from "@app/components/WalletConnect.svelte" import WalletConnect from "@app/components/WalletConnect.svelte"
import WalletDisconnect from "@app/components/WalletDisconnect.svelte" import WalletDisconnect from "@app/components/WalletDisconnect.svelte"
import WalletUpdateReceivingAddress from "@app/components/WalletUpdateReceivingAddress.svelte" import WalletUpdateReceivingAddress from "@app/components/WalletUpdateReceivingAddress.svelte"
@@ -31,6 +33,13 @@
) )
const pay = () => pushModal(WalletPay) const pay = () => pushModal(WalletPay)
const receive = () => {
if ($session?.wallet) {
pushModal(WalletReceive)
} else {
pushModal(WalletConnect)
}
}
</script> </script>
<div class="content column gap-4"> <div class="content column gap-4">
@@ -94,10 +103,24 @@
</p> </p>
</div> </div>
{/if} {/if}
<Button class="btn btn-neutral btn-sm" onclick={disconnect}> <div class="flex flex-col gap-4 lg:flex-row lg:justify-between">
<Icon icon={CloseCircle} /> <Button class="btn btn-neutral btn-sm" onclick={disconnect}>
Disconnect Wallet <Icon icon={CloseCircle} />
</Button> Disconnect Wallet
</Button>
<div class="flex w-full gap-4 lg:w-auto">
<Button class="btn btn-primary btn-sm flex-1 justify-center lg:flex-none" onclick={pay}>
<Icon icon={UploadMinimalistic} />
Send
</Button>
<Button
class="btn btn-secondary btn-sm flex-1 justify-center lg:flex-none"
onclick={receive}>
<Icon icon={DownloadMinimalistic} />
Receive
</Button>
</div>
</div>
{:else} {:else}
<p class="py-12 text-center opacity-75">No wallet connected</p> <p class="py-12 text-center opacity-75">No wallet connected</p>
{/if} {/if}
@@ -122,10 +145,4 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="flex justify-center py-12">
<Button class="btn btn-primary" onclick={pay}>
<Icon icon={Bolt} />
Pay With Lightning
</Button>
</div>
</div> </div>