Add lightning invoice payments

This commit is contained in:
Jon Staab
2025-11-12 16:20:38 -08:00
parent c05d7e99e2
commit 9cae4da9f4
11 changed files with 189 additions and 12 deletions

View File

@@ -1,5 +1,10 @@
# Changelog
# Current
* Switch back to indexeddb to fix memory and performance
* Add pay invoice functionality
# 1.5.3
* Add space edit form

View File

@@ -51,6 +51,7 @@
"@capacitor/push-notifications": "^7.0.3",
"@capawesome/capacitor-android-dark-mode-support": "^7.0.0",
"@capawesome/capacitor-badge": "^7.0.1",
"@getalby/lightning-tools": "^6.0.0",
"@getalby/sdk": "^5.1.2",
"@poppanator/sveltekit-svg": "^4.2.1",
"@sentry/browser": "^8.55.0",

BIN
pnpm-lock.yaml generated

Binary file not shown.

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import {Invoice} from "@getalby/lightning-tools/bolt11"
import {debounce} from "throttle-debounce"
import {session} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Scanner from "@lib/components/Scanner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {payInvoice} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
import {clearModals} from "@app/util/modal"
const back = () => history.back()
const onScan = debounce(1000, async (data: string) => {
invoice = new Invoice({pr: data})
sats = invoice.satoshi || 0
})
const confirm = async () => {
loading = true
try {
await payInvoice(invoice!.paymentRequest, sats * 1000)
pushToast({message: `Payment sent!`})
clearModals()
} catch (e) {
console.error(e)
const message = String(e).replace(/^.*Error: /, "")
pushToast({
theme: "error",
message: `Failed to send payment: ${message}`,
})
} finally {
loading = false
}
}
let loading = $state(false)
let invoice: Invoice | undefined = $state()
let sats = $state(0)
</script>
<div class="column gap-4">
<ModalHeader>
{#snippet title()}
<div>Pay with Lightning</div>
{/snippet}
{#snippet info()}
Use your Nostr wallet to send Bitcoin payments over lightning.
{/snippet}
</ModalHeader>
{#if invoice}
<div class="card2 bg-alt flex flex-col gap-2">
{#if $session?.wallet?.type === "webln" && invoice.satoshi === 0}
<p class="text-sm opacity-75">
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.
</p>
{:else}
<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"
disabled={invoice!.satoshi > 0} />
</label>
</div>
{/snippet}
</FieldInline>
<p class="text-sm opacity-75">
You're about to pay a bitcoin lightning invoice with the following description:
<strong>{invoice.description || "[no description]"}</strong>"
</p>
{/if}
</div>
{:else}
<Scanner onscan={onScan} />
<p class="text-center text-sm opacity-75">
To make a payment, scan a lightning invoice with your camera.
</p>
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={confirm} disabled={!invoice || sats === 0 || loading}>
{#if loading}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={Bolt} />
{/if}
Confirm Payment
</Button>
</ModalFooter>
</div>

View File

@@ -592,7 +592,7 @@ export const publishLeaveRequest = (params: LeaveRequestParams) =>
export const getWebLn = () => (window as any).webln
export const payInvoice = async (invoice: string) => {
export const payInvoice = async (invoice: string, msats?: number) => {
const $session = session.get()
if (!$session?.wallet) {
@@ -600,8 +600,11 @@ export const payInvoice = async (invoice: string) => {
}
if ($session.wallet.type === "nwc") {
return new nwc.NWCClient($session.wallet.info).payInvoice({invoice})
const params: {invoice: string; amount?: number} = {invoice}
if (msats) params.amount = msats
return new nwc.NWCClient($session.wallet.info).payInvoice(params)
} else if ($session.wallet.type === "webln") {
if (msats) throw new Error("Unable to pay zero invoices with webln")
return getWebLn()
.enable()
.then(() => getWebLn().sendPayment(invoice))

View File

@@ -1,6 +1,6 @@
import {reject, call, identity} from "@welshman/lib"
import {call} from "@welshman/lib"
import {Preferences} from "@capacitor/preferences"
import {Encoding, Filesystem, Directory} from "@capacitor/filesystem"
import {Filesystem, Directory} from "@capacitor/filesystem"
import {IDB} from "@lib/indexeddb"
export const kv = call(() => {

View File

@@ -1,4 +1,4 @@
import {prop, call, on, throttle, fromPairs, batch} from "@welshman/lib"
import {on, throttle, fromPairs, batch} from "@welshman/lib"
import {throttled, freshness} from "@welshman/store"
import {
ALERT_ANDROID,

View File

@@ -5,11 +5,38 @@
const {onscan} = $props()
const changeCamera = async () => {
if (camera && scanner) {
loading = true
try {
await scanner.setCamera(camera)
} catch (error) {
console.error("Failed to switch camera:", error)
} finally {
loading = false
}
}
}
let video: HTMLVideoElement
let scanner: QrScanner
let loading = $state(true)
let cameras = $state<QrScanner.Camera[]>([])
let camera = $state<string>("")
onMount(() => {
QrScanner.listCameras(true)
.then(async () => {
cameras = await QrScanner.listCameras(true)
if (cameras.length > 0) {
camera = cameras[0].id
}
})
.catch(error => {
console.error("Failed to list cameras:", error)
})
scanner = new QrScanner(video, r => onscan(r.data), {
returnDetailedScanResult: true,
})
@@ -22,11 +49,21 @@
})
</script>
<div class="bg-alt flex min-h-48 w-full flex-col items-center justify-center rounded p-px">
<div class="bg-alt relative flex min-h-48 w-full flex-col items-center justify-center rounded p-px">
{#if loading}
<p class="py-20">
<Spinner loading>Loading your camera...</Spinner>
</p>
{/if}
<video class="m-auto rounded" class:h-0={loading} bind:this={video}></video>
{#if cameras.length > 1}
<select
class="select select-bordered select-sm absolute bottom-1 right-1"
bind:value={camera}
onchange={changeCamera}>
{#each cameras as camera}
<option value={camera.id}>{camera.label || `Camera ${camera.id}`}</option>
{/each}
</select>
{/if}
</div>

View File

@@ -1,10 +1,8 @@
import {openDB, deleteDB} from "idb"
import type {IDBPDatabase} from "idb"
import {writable} from "svelte/store"
import type {Unsubscriber} from "svelte/store"
import {call, defer} from "@welshman/lib"
import {call} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {withGetter} from "@welshman/store"
export type IDBAdapter = {
name: string
@@ -83,11 +81,11 @@ export class IDB {
// If we're closing, ignore any lingering requests
if ([IDBStatus.Closed, IDBStatus.Closing].includes(this.status)) return
return f(await this.idbp)
return f(await this.idbp!)
}
getAll = async <T>(table: string): Promise<T[]> =>
this._withIDBP(async idbp => {
getAll = async <T>(table: string): Promise<T[]> => {
const result = await this._withIDBP(async idbp => {
const tx = idbp.transaction(table, "readwrite")
const store = tx.objectStore(table)
const result = await store.getAll()
@@ -97,6 +95,9 @@ export class IDB {
return result
})
return result || []
}
bulkPut = async <T>(table: string, data: Iterable<T>) =>
this._withIDBP(async idbp => {
const tx = idbp.transaction(table, "readwrite")

View File

@@ -3,8 +3,10 @@
import {LOCALE} from "@welshman/lib"
import {displayRelayUrl, isNWCWallet, fromMsats} from "@welshman/util"
import {session, pubkey, profilesByPubkey} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import WalletPay from "@app/components/WalletPay.svelte"
import WalletConnect from "@app/components/WalletConnect.svelte"
import WalletDisconnect from "@app/components/WalletDisconnect.svelte"
import WalletUpdateReceivingAddress from "@app/components/WalletUpdateReceivingAddress.svelte"
@@ -27,6 +29,8 @@
const walletLud16 = $derived(
$session?.wallet && isNWCWallet($session.wallet) ? $session.wallet.info.lud16 : undefined,
)
const pay = () => pushModal(WalletPay)
</script>
<div class="content column gap-4">
@@ -118,4 +122,10 @@
</div>
{/if}
</div>
<div class="flex justify-center py-12">
<Button class="btn btn-primary" onclick={pay}>
<Icon icon={Bolt} />
Pay With Lightning
</Button>
</div>
</div>

View File

@@ -10,6 +10,15 @@ config({path: ".env.template"})
export default defineConfig({
server: {
port: 1847,
// host: "0.0.0.0",
// strictPort: true,
// allowedHosts: ["coracle-client.ngrok.io"],
// hmr: {
// protocol: "wss",
// host: "coracle-client.ngrok.io",
// clientPort: 443,
// },
// cors: true,
},
build: {
sourcemap: true,