Replace lightning toolkit with wallet adapter
This commit is contained in:
+5
-3
@@ -11,6 +11,7 @@
|
|||||||
"lint": "prettier --check src && eslint src",
|
"lint": "prettier --check src && eslint src",
|
||||||
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
||||||
"format:all": "prettier --write src",
|
"format:all": "prettier --write src",
|
||||||
|
"test": "vitest run",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"eslint-config-prettier": "^9.1.2",
|
"eslint-config-prettier": "^9.1.2",
|
||||||
"eslint-plugin-svelte": "^2.46.1",
|
"eslint-plugin-svelte": "^2.46.1",
|
||||||
"globals": "^15.15.0",
|
"globals": "^15.15.0",
|
||||||
|
"jsdom": "^24.0.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-svelte": "^3.4.1",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
@@ -33,7 +35,8 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.53.1",
|
"typescript-eslint": "^8.53.1",
|
||||||
"vite": "^5.4.21"
|
"vite": "^5.4.21",
|
||||||
|
"vitest": "^1.6.0"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -49,9 +52,8 @@
|
|||||||
"@capacitor/push-notifications": "^8.0.0",
|
"@capacitor/push-notifications": "^8.0.0",
|
||||||
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
|
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
|
||||||
"@capawesome/capacitor-badge": "^8.0.0",
|
"@capawesome/capacitor-badge": "^8.0.0",
|
||||||
"@getalby/lightning-tools": "^6.1.0",
|
|
||||||
"@getalby/sdk": "^5.1.2",
|
|
||||||
"@noble/curves": "^1.9.7",
|
"@noble/curves": "^1.9.7",
|
||||||
|
"@noble/hashes": "^2.0.1",
|
||||||
"@pomade/core": "^0.0.12",
|
"@pomade/core": "^0.0.12",
|
||||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
|||||||
Generated
+3
-29
@@ -47,15 +47,12 @@ importers:
|
|||||||
'@capawesome/capacitor-badge':
|
'@capawesome/capacitor-badge':
|
||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.0.0(@capacitor/core@8.0.1)
|
version: 8.0.0(@capacitor/core@8.0.1)
|
||||||
'@getalby/lightning-tools':
|
|
||||||
specifier: ^6.1.0
|
|
||||||
version: 6.1.0
|
|
||||||
'@getalby/sdk':
|
|
||||||
specifier: ^5.1.2
|
|
||||||
version: 5.1.2(typescript@5.9.3)
|
|
||||||
'@noble/curves':
|
'@noble/curves':
|
||||||
specifier: ^1.9.7
|
specifier: ^1.9.7
|
||||||
version: 1.9.7
|
version: 1.9.7
|
||||||
|
'@noble/hashes':
|
||||||
|
specifier: ^2.0.1
|
||||||
|
version: 2.0.1
|
||||||
'@pomade/core':
|
'@pomade/core':
|
||||||
specifier: ^0.0.12
|
specifier: ^0.0.12
|
||||||
version: 0.0.12(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.4)(@welshman/net@0.8.4(@welshman/lib@0.8.4)(@welshman/util@0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.4(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.4)(@welshman/net@0.8.4(@welshman/lib@0.8.4)(@welshman/util@0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))
|
version: 0.0.12(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.4)(@welshman/net@0.8.4(@welshman/lib@0.8.4)(@welshman/util@0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.4(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.4)(@welshman/net@0.8.4(@welshman/lib@0.8.4)(@welshman/util@0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))
|
||||||
@@ -999,18 +996,6 @@ packages:
|
|||||||
'@frostr/bifrost@1.0.7':
|
'@frostr/bifrost@1.0.7':
|
||||||
resolution: {integrity: sha512-9PO8s8ra7Cf94HqsF0sArRkLLFYqDyGfRKUOflTWMGgaDvSWIksNA8PckcXvy5/G6u4RtAkTAqki47+ga+7yow==}
|
resolution: {integrity: sha512-9PO8s8ra7Cf94HqsF0sArRkLLFYqDyGfRKUOflTWMGgaDvSWIksNA8PckcXvy5/G6u4RtAkTAqki47+ga+7yow==}
|
||||||
|
|
||||||
'@getalby/lightning-tools@5.2.1':
|
|
||||||
resolution: {integrity: sha512-dxOmJLJAh6qJ8rsbA5/Bwj7MSI9X3RkxxqmCedl5rfP+yKwNSdfu8i4EiCZN/tk2hNBJb8GHSCcPRNZfwfmEHg==}
|
|
||||||
engines: {node: '>=14'}
|
|
||||||
|
|
||||||
'@getalby/lightning-tools@6.1.0':
|
|
||||||
resolution: {integrity: sha512-rGurar9X4Gm+9xwoNYS8s9YLK7ZYqvbqv4KbHLYV0LEeB0HxZHRgmxblGqg+fYfp6iiYHx+edIgUpt9rS3VwFw==}
|
|
||||||
engines: {node: '>=14'}
|
|
||||||
|
|
||||||
'@getalby/sdk@5.1.2':
|
|
||||||
resolution: {integrity: sha512-yUF9LhuvdIFOwjV1aG0ryzfwDiGBFk/CRLkRvrrM9dsE38SUjKsf1FDga5jxsKMu80nWcPZR9TiGGASWedoYPA==}
|
|
||||||
engines: {node: '>=14'}
|
|
||||||
|
|
||||||
'@humanfs/core@0.19.1':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -5793,17 +5778,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
'@getalby/lightning-tools@5.2.1': {}
|
|
||||||
|
|
||||||
'@getalby/lightning-tools@6.1.0': {}
|
|
||||||
|
|
||||||
'@getalby/sdk@5.1.2(typescript@5.9.3)':
|
|
||||||
dependencies:
|
|
||||||
'@getalby/lightning-tools': 5.2.1
|
|
||||||
nostr-tools: 2.15.0(typescript@5.9.3)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- typescript
|
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.7':
|
'@humanfs/node@0.16.7':
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {debounce} from "throttle-debounce"
|
import {debounce} from "throttle-debounce"
|
||||||
import {nwc} from "@getalby/sdk"
|
import {nwc} from "@lib/lightning"
|
||||||
import {sleep, assoc} from "@welshman/lib"
|
import {sleep, assoc} from "@welshman/lib"
|
||||||
import type {NWCInfo} from "@welshman/util"
|
import type {NWCInfo} from "@welshman/util"
|
||||||
import {pubkey, userProfile, updateSession} from "@welshman/app"
|
import {pubkey, userProfile, updateSession} from "@welshman/app"
|
||||||
@@ -33,8 +33,14 @@
|
|||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([sleep(800), getWebLn().enable()])
|
const webLn = getWebLn()
|
||||||
const info = await getWebLn().getInfo()
|
|
||||||
|
if (!webLn) {
|
||||||
|
throw new Error("WebLN not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([sleep(800), webLn.enable()])
|
||||||
|
const info = (await webLn.getInfo?.()) || {}
|
||||||
|
|
||||||
if (!info?.supports?.includes("lightning")) {
|
if (!info?.supports?.includes("lightning")) {
|
||||||
pushToast({
|
pushToast({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {Invoice} from "@getalby/lightning-tools/bolt11"
|
import {Invoice} from "@lib/lightning/bolt11"
|
||||||
import {debounce} from "throttle-debounce"
|
import {debounce} from "throttle-debounce"
|
||||||
import {session} from "@welshman/app"
|
import {session} from "@welshman/app"
|
||||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {Invoice} from "@getalby/lightning-tools/bolt11"
|
import {Invoice} from "@lib/lightning/bolt11"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
import Refresh from "@assets/icons/refresh.svg?dataurl"
|
import Refresh from "@assets/icons/refresh.svg?dataurl"
|
||||||
|
|||||||
+25
-71
@@ -1,4 +1,5 @@
|
|||||||
import {nwc} from "@getalby/sdk"
|
import {wallet as lightningWallet} from "@lib/lightning"
|
||||||
|
import type {IWallet} from "@lib/lightning/wallet"
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {get, derived} from "svelte/store"
|
import {get, derived} from "svelte/store"
|
||||||
import {
|
import {
|
||||||
@@ -432,43 +433,34 @@ export const publishLeaveRequest = (params: LeaveRequestParams) =>
|
|||||||
|
|
||||||
// Lightning
|
// Lightning
|
||||||
|
|
||||||
export const getWebLn = () => (window as any).webln
|
export const getWebLn = () => lightningWallet.getWebLn()
|
||||||
|
|
||||||
export const getNwcClient = () => {
|
const getWalletAdapter = (): IWallet => {
|
||||||
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) => {
|
|
||||||
const $session = session.get()
|
const $session = session.get()
|
||||||
|
|
||||||
if (!$session?.wallet) {
|
if (!$session?.wallet) {
|
||||||
throw new Error("No wallet is connected")
|
throw new Error("No wallet is connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($session.wallet.type === "nwc") {
|
return lightningWallet.createWalletAdapter($session.wallet)
|
||||||
const params: {invoice: string; amount?: number} = {invoice}
|
}
|
||||||
if (msats) params.amount = msats
|
|
||||||
return getNwcClient().payInvoice(params)
|
const withConnectedWallet = async <T>(f: (wallet: IWallet) => Promise<T>) => {
|
||||||
} else if ($session.wallet.type === "webln") {
|
const wallet = getWalletAdapter()
|
||||||
if (msats) throw new Error("Unable to pay zero invoices with webln")
|
|
||||||
return getWebLn()
|
try {
|
||||||
.enable()
|
return await f(wallet)
|
||||||
.then(() => getWebLn().sendPayment(invoice))
|
} finally {
|
||||||
|
wallet.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getWalletBalance = () => withConnectedWallet(wallet => wallet.getBalanceSats())
|
||||||
|
|
||||||
|
export const payInvoice = async (invoice: string, msats?: number) => {
|
||||||
|
return withConnectedWallet(wallet => wallet.payInvoice({invoice, msats}))
|
||||||
|
}
|
||||||
|
|
||||||
export type CreateInvoiceParams = {
|
export type CreateInvoiceParams = {
|
||||||
sats: number
|
sats: number
|
||||||
description?: string
|
description?: string
|
||||||
@@ -478,56 +470,18 @@ export const createInvoice = async ({
|
|||||||
sats,
|
sats,
|
||||||
description = "Receive via lightning",
|
description = "Receive via lightning",
|
||||||
}: CreateInvoiceParams) => {
|
}: CreateInvoiceParams) => {
|
||||||
const $session = session.get()
|
|
||||||
|
|
||||||
if (!$session?.wallet) {
|
|
||||||
throw new Error("No wallet is connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
const satAmount = Math.floor(sats)
|
const satAmount = Math.floor(sats)
|
||||||
|
|
||||||
if (!Number.isFinite(satAmount) || satAmount <= 0) {
|
if (!Number.isFinite(satAmount) || satAmount <= 0) {
|
||||||
throw new Error("Invalid satoshi amount")
|
throw new Error("Invalid satoshi amount")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($session.wallet.type === "nwc") {
|
return withConnectedWallet(wallet =>
|
||||||
const createdInvoice = await getNwcClient().makeInvoice({
|
wallet.createInvoice({
|
||||||
amount: satAmount * 1000,
|
sats: satAmount,
|
||||||
description,
|
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
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {describe, expect, it} from "vitest"
|
||||||
|
import {mockDateNow} from "../test/helpers"
|
||||||
|
import {Invoice} from "./Invoice"
|
||||||
|
|
||||||
|
describe("Invoice", () => {
|
||||||
|
const invoiceString =
|
||||||
|
"lnbc20u1p3y0x3hpp5743k2g0fsqqxj7n8qzuhns5gmkk4djeejk3wkp64ppevgekvc0jsdqcve5kzar2v9nr5gpqd4hkuetesp5ez2g297jduwc20t6lmqlsg3man0vf2jfd8ar9fh8fhn2g8yttfkqxqy9gcqcqzys9qrsgqrzjqtx3k77yrrav9hye7zar2rtqlfkytl094dsp0ms5majzth6gt7ca6uhdkxl983uywgqqqqlgqqqvx5qqjqrzjqd98kxkpyw0l9tyy8r8q57k7zpy9zjmh6sez752wj6gcumqnj3yxzhdsmg6qq56utgqqqqqqqqqqqeqqjq7jd56882gtxhrjm03c93aacyfy306m4fq0tskf83c0nmet8zc2lxyyg3saz8x6vwcp26xnrlagf9semau3qm2glysp7sv95693fphvsp54l567"
|
||||||
|
|
||||||
|
it("parses invoice data", () => {
|
||||||
|
const invoice = new Invoice({pr: invoiceString})
|
||||||
|
|
||||||
|
expect(invoice.satoshi).toBe(2000)
|
||||||
|
expect(invoice.description).toBe("fiatjaf: money")
|
||||||
|
expect(invoice.paymentHash).toBe(
|
||||||
|
"f5636521e98000697a6700b979c288ddad56cb3995a2eb07550872c466ccc3e5",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("tracks expiry", () => {
|
||||||
|
const invoice = new Invoice({pr: invoiceString})
|
||||||
|
|
||||||
|
const restoreBefore = mockDateNow(invoice.timestamp * 1000)
|
||||||
|
expect(invoice.hasExpired()).toBe(false)
|
||||||
|
restoreBefore()
|
||||||
|
|
||||||
|
const restoreAfter = mockDateNow((invoice.timestamp + (invoice.expiry ?? 0) + 1) * 1000)
|
||||||
|
expect(invoice.hasExpired()).toBe(true)
|
||||||
|
restoreAfter()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("validates preimages", () => {
|
||||||
|
const invoice = new Invoice({pr: invoiceString})
|
||||||
|
expect(invoice.validatePreimage("00")).toBe(false)
|
||||||
|
expect(invoice.validatePreimage("not-hex")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {sha256} from "@noble/hashes/sha2.js"
|
||||||
|
import {bytesToHex, hexToBytes} from "@welshman/lib"
|
||||||
|
import {decodeInvoice} from "./decoder"
|
||||||
|
import type {InvoiceArgs, SuccessAction} from "./types"
|
||||||
|
|
||||||
|
export class Invoice {
|
||||||
|
paymentRequest: string
|
||||||
|
paymentHash: string
|
||||||
|
preimage: string | undefined
|
||||||
|
verify: string | undefined
|
||||||
|
satoshi: number
|
||||||
|
expiry: number | undefined
|
||||||
|
timestamp: number
|
||||||
|
createdDate: Date
|
||||||
|
expiryDate: Date | undefined
|
||||||
|
description: string | undefined
|
||||||
|
successAction: SuccessAction | undefined
|
||||||
|
|
||||||
|
constructor(args: InvoiceArgs) {
|
||||||
|
this.paymentRequest = args.pr
|
||||||
|
if (!this.paymentRequest) {
|
||||||
|
throw new Error("Invalid payment request")
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = decodeInvoice(this.paymentRequest)
|
||||||
|
if (!decoded) {
|
||||||
|
throw new Error("Failed to decode payment request")
|
||||||
|
}
|
||||||
|
|
||||||
|
this.paymentHash = decoded.paymentHash
|
||||||
|
this.satoshi = decoded.satoshi
|
||||||
|
this.timestamp = decoded.timestamp
|
||||||
|
this.expiry = decoded.expiry
|
||||||
|
this.createdDate = new Date(this.timestamp * 1000)
|
||||||
|
this.expiryDate = this.expiry ? new Date((this.timestamp + this.expiry) * 1000) : undefined
|
||||||
|
this.description = decoded.description
|
||||||
|
this.verify = args.verify
|
||||||
|
this.preimage = args.preimage
|
||||||
|
this.successAction = args.successAction
|
||||||
|
}
|
||||||
|
|
||||||
|
async isPaid(): Promise<boolean> {
|
||||||
|
if (this.preimage) return this.validatePreimage(this.preimage)
|
||||||
|
if (this.verify) return this.verifyPayment()
|
||||||
|
throw new Error("Could not verify payment")
|
||||||
|
}
|
||||||
|
|
||||||
|
validatePreimage(preimage: string): boolean {
|
||||||
|
if (!preimage || !this.paymentHash) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const preimageHash = bytesToHex(sha256(hexToBytes(preimage)))
|
||||||
|
return this.paymentHash === preimageHash
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyPayment(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (!this.verify) {
|
||||||
|
throw new Error("LNURL verify not available")
|
||||||
|
}
|
||||||
|
const response = await fetch(this.verify)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Verification request failed: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
const json = (await response.json()) as {preimage?: string; settled?: boolean}
|
||||||
|
if (json.preimage) {
|
||||||
|
this.preimage = json.preimage
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(json.settled)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to check LNURL-verify", error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasExpired() {
|
||||||
|
if (this.expiryDate) {
|
||||||
|
return this.expiryDate.getTime() < Date.now()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {describe, expect, it} from "vitest"
|
||||||
|
import {hrpToMillisats, parseHrpAmount} from "./amount"
|
||||||
|
|
||||||
|
describe("bolt11 amount parsing", () => {
|
||||||
|
it("parses hrp amounts", () => {
|
||||||
|
expect(hrpToMillisats("20u")).toBe(2_000_000n)
|
||||||
|
expect(hrpToMillisats("1m")).toBe(100_000_000n)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses bolt11 hrp", () => {
|
||||||
|
const parsed = parseHrpAmount("lnbc20u")
|
||||||
|
expect(parsed?.millisats).toBe(2_000_000n)
|
||||||
|
expect(parsed?.satoshi).toBe(2000)
|
||||||
|
expect(parseHrpAmount("lnbc")).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
const DIVISORS = {
|
||||||
|
m: 1_000n,
|
||||||
|
u: 1_000_000n,
|
||||||
|
n: 1_000_000_000n,
|
||||||
|
p: 1_000_000_000_000n,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_MILLISATS = 2_100_000_000_000_000_000n
|
||||||
|
const MILLISATS_PER_BTC = 100_000_000_000n
|
||||||
|
|
||||||
|
export const hrpToMillisats = (hrpAmount: string) => {
|
||||||
|
if (!hrpAmount) {
|
||||||
|
throw new Error("Missing amount")
|
||||||
|
}
|
||||||
|
|
||||||
|
let divisor: keyof typeof DIVISORS | undefined
|
||||||
|
let value = hrpAmount
|
||||||
|
|
||||||
|
const last = hrpAmount.slice(-1)
|
||||||
|
if (/^[munp]$/.test(last)) {
|
||||||
|
divisor = last as keyof typeof DIVISORS
|
||||||
|
value = hrpAmount.slice(0, -1)
|
||||||
|
} else if (/^[^0-9]$/.test(last)) {
|
||||||
|
throw new Error("Invalid amount multiplier")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^\d+$/.test(value)) {
|
||||||
|
throw new Error("Invalid amount value")
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueBig = BigInt(value)
|
||||||
|
|
||||||
|
const millisats = divisor
|
||||||
|
? (valueBig * MILLISATS_PER_BTC) / DIVISORS[divisor]
|
||||||
|
: valueBig * MILLISATS_PER_BTC
|
||||||
|
|
||||||
|
if (divisor === "p" && valueBig % 10n !== 0n) {
|
||||||
|
throw new Error("Invalid pico bitcoin amount")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (millisats > MAX_MILLISATS) {
|
||||||
|
throw new Error("Amount is outside of valid range")
|
||||||
|
}
|
||||||
|
|
||||||
|
return millisats
|
||||||
|
}
|
||||||
|
|
||||||
|
export const millisatsToSats = (millisats: bigint) => Number(millisats / 1_000n)
|
||||||
|
|
||||||
|
export const parseHrpAmount = (hrp: string) => {
|
||||||
|
const match = hrp.match(/^lnbc(\d+[munp]?)$/)
|
||||||
|
if (!match) {
|
||||||
|
if (hrp === "lnbc") return undefined
|
||||||
|
throw new Error("Invalid bolt11 prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
const millisats = hrpToMillisats(match[1])
|
||||||
|
|
||||||
|
return {
|
||||||
|
millisats,
|
||||||
|
satoshi: millisatsToSats(millisats),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {describe, expect, it} from "vitest"
|
||||||
|
import {bech32Decode, convertBits} from "./bech32"
|
||||||
|
|
||||||
|
describe("bech32", () => {
|
||||||
|
it("decodes bolt11 invoices", () => {
|
||||||
|
const invoice =
|
||||||
|
"lnbc20u1p3y0x3hpp5743k2g0fsqqxj7n8qzuhns5gmkk4djeejk3wkp64ppevgekvc0jsdqcve5kzar2v9nr5gpqd4hkuetesp5ez2g297jduwc20t6lmqlsg3man0vf2jfd8ar9fh8fhn2g8yttfkqxqy9gcqcqzys9qrsgqrzjqtx3k77yrrav9hye7zar2rtqlfkytl094dsp0ms5majzth6gt7ca6uhdkxl983uywgqqqqlgqqqvx5qqjqrzjqd98kxkpyw0l9tyy8r8q57k7zpy9zjmh6sez752wj6gcumqnj3yxzhdsmg6qq56utgqqqqqqqqqqqeqqjq7jd56882gtxhrjm03c93aacyfy306m4fq0tskf83c0nmet8zc2lxyyg3saz8x6vwcp26xnrlagf9semau3qm2glysp7sv95693fphvsp54l567"
|
||||||
|
|
||||||
|
const decoded = bech32Decode(invoice)
|
||||||
|
|
||||||
|
expect(decoded.hrp).toBe("lnbc20u")
|
||||||
|
expect(decoded.words.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("converts bit groups", () => {
|
||||||
|
const data = new Uint8Array([1, 2, 3])
|
||||||
|
const words = convertBits(data, 8, 5, true)
|
||||||
|
const restored = convertBits(words, 5, 8, false)
|
||||||
|
|
||||||
|
expect(restored).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
|
|
||||||
|
const CHARSET_REV = new Map<string, number>(CHARSET.split("").map((char, index) => [char, index]))
|
||||||
|
|
||||||
|
const GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||||
|
|
||||||
|
const hrpExpand = (hrp: string) => {
|
||||||
|
const result: number[] = []
|
||||||
|
for (let i = 0; i < hrp.length; i += 1) {
|
||||||
|
const code = hrp.charCodeAt(i)
|
||||||
|
result.push(code >> 5)
|
||||||
|
}
|
||||||
|
result.push(0)
|
||||||
|
for (let i = 0; i < hrp.length; i += 1) {
|
||||||
|
const code = hrp.charCodeAt(i)
|
||||||
|
result.push(code & 31)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const polymod = (values: number[]) => {
|
||||||
|
let chk = 1
|
||||||
|
for (const value of values) {
|
||||||
|
const top = chk >> 25
|
||||||
|
chk = ((chk & 0x1ffffff) << 5) ^ value
|
||||||
|
for (let i = 0; i < GENERATORS.length; i += 1) {
|
||||||
|
if (((top >> i) & 1) !== 0) {
|
||||||
|
chk ^= GENERATORS[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chk
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyChecksum = (hrp: string, data: number[]) => polymod([...hrpExpand(hrp), ...data]) === 1
|
||||||
|
|
||||||
|
export const bech32Decode = (input: string) => {
|
||||||
|
if (input.length < 8) {
|
||||||
|
throw new Error("Invalid bech32 length")
|
||||||
|
}
|
||||||
|
|
||||||
|
const lower = input.toLowerCase()
|
||||||
|
const upper = input.toUpperCase()
|
||||||
|
if (input !== lower && input !== upper) {
|
||||||
|
throw new Error("Invalid bech32 casing")
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = lower
|
||||||
|
const separator = normalized.lastIndexOf("1")
|
||||||
|
if (separator < 1 || separator + 7 > normalized.length) {
|
||||||
|
throw new Error("Invalid bech32 separator")
|
||||||
|
}
|
||||||
|
|
||||||
|
const hrp = normalized.slice(0, separator)
|
||||||
|
const dataPart = normalized.slice(separator + 1)
|
||||||
|
const data: number[] = []
|
||||||
|
|
||||||
|
for (const char of dataPart) {
|
||||||
|
const value = CHARSET_REV.get(char)
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new Error("Invalid bech32 character")
|
||||||
|
}
|
||||||
|
data.push(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyChecksum(hrp, data)) {
|
||||||
|
throw new Error("Invalid bech32 checksum")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hrp,
|
||||||
|
words: data.slice(0, -6),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const convertBits = (
|
||||||
|
data: number[] | Uint8Array,
|
||||||
|
fromBits: number,
|
||||||
|
toBits: number,
|
||||||
|
pad: boolean,
|
||||||
|
) => {
|
||||||
|
let acc = 0
|
||||||
|
let bits = 0
|
||||||
|
const result: number[] = []
|
||||||
|
const maxValue = (1 << toBits) - 1
|
||||||
|
|
||||||
|
for (const value of data) {
|
||||||
|
if (value < 0 || value >> fromBits !== 0) {
|
||||||
|
throw new Error("Invalid value for conversion")
|
||||||
|
}
|
||||||
|
acc = (acc << fromBits) | value
|
||||||
|
bits += fromBits
|
||||||
|
while (bits >= toBits) {
|
||||||
|
bits -= toBits
|
||||||
|
result.push((acc >> bits) & maxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pad) {
|
||||||
|
if (bits > 0) {
|
||||||
|
result.push((acc << (toBits - bits)) & maxValue)
|
||||||
|
}
|
||||||
|
} else if (bits >= fromBits || ((acc << (toBits - bits)) & maxValue) !== 0) {
|
||||||
|
throw new Error("Invalid padding")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {describe, expect, it} from "vitest"
|
||||||
|
import {decodeInvoice} from "./decoder"
|
||||||
|
|
||||||
|
describe("bolt11 decoder", () => {
|
||||||
|
it("decodes required fields", () => {
|
||||||
|
const invoice =
|
||||||
|
"lnbc20u1p3y0x3hpp5743k2g0fsqqxj7n8qzuhns5gmkk4djeejk3wkp64ppevgekvc0jsdqcve5kzar2v9nr5gpqd4hkuetesp5ez2g297jduwc20t6lmqlsg3man0vf2jfd8ar9fh8fhn2g8yttfkqxqy9gcqcqzys9qrsgqrzjqtx3k77yrrav9hye7zar2rtqlfkytl094dsp0ms5majzth6gt7ca6uhdkxl983uywgqqqqlgqqqvx5qqjqrzjqd98kxkpyw0l9tyy8r8q57k7zpy9zjmh6sez752wj6gcumqnj3yxzhdsmg6qq56utgqqqqqqqqqqqeqqjq7jd56882gtxhrjm03c93aacyfy306m4fq0tskf83c0nmet8zc2lxyyg3saz8x6vwcp26xnrlagf9semau3qm2glysp7sv95693fphvsp54l567"
|
||||||
|
|
||||||
|
const decoded = decodeInvoice(invoice)
|
||||||
|
|
||||||
|
expect(decoded?.paymentHash).toBe(
|
||||||
|
"f5636521e98000697a6700b979c288ddad56cb3995a2eb07550872c466ccc3e5",
|
||||||
|
)
|
||||||
|
expect(decoded?.satoshi).toBe(2000)
|
||||||
|
expect(decoded?.timestamp).toBe(1648859703)
|
||||||
|
expect(decoded?.expiry).toBe(172800)
|
||||||
|
expect(decoded?.description).toBe("fiatjaf: money")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import {bytesToHex, textDecoder} from "@welshman/lib"
|
||||||
|
import {bech32Decode, convertBits} from "./bech32"
|
||||||
|
import {parseHrpAmount} from "./amount"
|
||||||
|
|
||||||
|
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
|
const SIGNATURE_WORDS = 104
|
||||||
|
|
||||||
|
export type DecodedInvoice = {
|
||||||
|
paymentHash: string
|
||||||
|
satoshi: number
|
||||||
|
timestamp: number
|
||||||
|
expiry: number | undefined
|
||||||
|
description: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
type TagResult = {
|
||||||
|
paymentHash?: string
|
||||||
|
description?: string
|
||||||
|
expiry?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordsToNumber = (words: number[]) => words.reduce((acc, word) => acc * 32 + word, 0)
|
||||||
|
|
||||||
|
const decodeTagData = (tag: string, words: number[]) => {
|
||||||
|
if (tag === "p") {
|
||||||
|
const bytes = Uint8Array.from(convertBits(words, 5, 8, false))
|
||||||
|
return {paymentHash: bytesToHex(bytes)}
|
||||||
|
}
|
||||||
|
if (tag === "d") {
|
||||||
|
const bytes = Uint8Array.from(convertBits(words, 5, 8, false))
|
||||||
|
return {description: textDecoder.decode(bytes)}
|
||||||
|
}
|
||||||
|
if (tag === "x") {
|
||||||
|
return {expiry: wordsToNumber(words)}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodeTags = (words: number[]) => {
|
||||||
|
const dataEnd = words.length - SIGNATURE_WORDS
|
||||||
|
if (dataEnd < 7) {
|
||||||
|
throw new Error("Invalid bolt11 data length")
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = wordsToNumber(words.slice(0, 7))
|
||||||
|
const tagResult: TagResult = {}
|
||||||
|
|
||||||
|
let index = 7
|
||||||
|
while (index < dataEnd) {
|
||||||
|
const tagValue = words[index]
|
||||||
|
const tag = CHARSET[tagValue]
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
if (!tag) {
|
||||||
|
throw new Error("Invalid bolt11 tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataLength = (words[index] << 5) + words[index + 1]
|
||||||
|
index += 2
|
||||||
|
|
||||||
|
const tagWords = words.slice(index, index + dataLength)
|
||||||
|
index += dataLength
|
||||||
|
|
||||||
|
Object.assign(tagResult, decodeTagData(tag, tagWords))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {timestamp, tagResult}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decodeInvoice = (invoice: string): DecodedInvoice | null => {
|
||||||
|
if (!invoice) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {hrp, words} = bech32Decode(invoice)
|
||||||
|
const amount = parseHrpAmount(hrp)
|
||||||
|
const {timestamp, tagResult} = decodeTags(words)
|
||||||
|
|
||||||
|
if (!tagResult.paymentHash) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentHash: tagResult.paymentHash,
|
||||||
|
satoshi: amount?.satoshi ?? 0,
|
||||||
|
timestamp,
|
||||||
|
expiry: tagResult.expiry,
|
||||||
|
description: tagResult.description,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {Invoice} from "./Invoice"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export type InvoiceArgs = {
|
||||||
|
pr: string
|
||||||
|
verify?: string
|
||||||
|
preimage?: string
|
||||||
|
successAction?: SuccessAction
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SuccessAction =
|
||||||
|
| {
|
||||||
|
tag: "message"
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
tag: "url"
|
||||||
|
description: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * as bolt11 from "./bolt11"
|
||||||
|
export * as nwc from "./nwc"
|
||||||
|
export * as wallet from "./wallet"
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import {describe, expect, it} from "vitest"
|
||||||
|
import {getPubkey, makeSecret, normalizeRelayUrl} from "@welshman/util"
|
||||||
|
import {NWCClient} from "./NWCClient"
|
||||||
|
|
||||||
|
describe("NWCClient", () => {
|
||||||
|
it("builds and accepts wallet connect urls", () => {
|
||||||
|
const secret = makeSecret()
|
||||||
|
const walletSecret = makeSecret()
|
||||||
|
const walletPubkey = getPubkey(walletSecret)
|
||||||
|
const relayUrls = ["wss://relay.one", "wss://relay.two"]
|
||||||
|
|
||||||
|
const client = new NWCClient({
|
||||||
|
relayUrls,
|
||||||
|
walletPubkey,
|
||||||
|
secret,
|
||||||
|
lud16: "user@example.com",
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = client.options.nostrWalletConnectUrl
|
||||||
|
|
||||||
|
expect(url).toContain(`nostr+walletconnect://${walletPubkey}?`)
|
||||||
|
expect(url).toContain(`relay=${relayUrls[0]}`)
|
||||||
|
expect(url).toContain(`relay=${relayUrls[1]}`)
|
||||||
|
expect(url).toContain(`pubkey=${getPubkey(secret)}`)
|
||||||
|
expect(url).toContain(`secret=${secret}`)
|
||||||
|
|
||||||
|
const parsedClient = new NWCClient({nostrWalletConnectUrl: url})
|
||||||
|
|
||||||
|
expect(parsedClient.walletPubkey).toBe(walletPubkey)
|
||||||
|
expect(parsedClient.relayUrls).toEqual(relayUrls.map(normalizeRelayUrl))
|
||||||
|
expect(parsedClient.options.secret).toBe(secret)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("accepts a single relay url", () => {
|
||||||
|
const secret = makeSecret()
|
||||||
|
const walletSecret = makeSecret()
|
||||||
|
const walletPubkey = getPubkey(walletSecret)
|
||||||
|
|
||||||
|
const client = new NWCClient({
|
||||||
|
relayUrl: "wss://relay.single",
|
||||||
|
walletPubkey,
|
||||||
|
secret,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(client.relayUrls).toEqual([normalizeRelayUrl("wss://relay.single")])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("requires hex keys", () => {
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
new NWCClient({
|
||||||
|
relayUrl: "wss://relay.single",
|
||||||
|
walletPubkey: "npub1nothex",
|
||||||
|
secret: makeSecret(),
|
||||||
|
}),
|
||||||
|
).toThrow("Invalid wallet pubkey")
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
new NWCClient({
|
||||||
|
relayUrl: "wss://relay.single",
|
||||||
|
walletPubkey: getPubkey(makeSecret()),
|
||||||
|
secret: "nsec1nothex",
|
||||||
|
}),
|
||||||
|
).toThrow("Invalid secret key")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {PublishStatus, publish, request} from "@welshman/net"
|
||||||
|
import {Nip01Signer, decrypt} from "@welshman/signer"
|
||||||
|
import {
|
||||||
|
getPubkey,
|
||||||
|
makeEvent,
|
||||||
|
normalizeRelayUrl,
|
||||||
|
type SignedEvent,
|
||||||
|
type TrustedEvent,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import type {
|
||||||
|
Nip47EncryptionType,
|
||||||
|
Nip47GetBalanceResponse,
|
||||||
|
Nip47GetInfoResponse,
|
||||||
|
Nip47MakeInvoiceRequest,
|
||||||
|
Nip47PayInvoiceRequest,
|
||||||
|
Nip47PayResponse,
|
||||||
|
Nip47TimeoutValues,
|
||||||
|
Nip47Transaction,
|
||||||
|
Nip47Method,
|
||||||
|
} from "./types"
|
||||||
|
import {
|
||||||
|
Nip47NetworkError,
|
||||||
|
Nip47PublishError,
|
||||||
|
Nip47PublishTimeoutError,
|
||||||
|
Nip47ReplyTimeoutError,
|
||||||
|
Nip47ResponseDecodingError,
|
||||||
|
Nip47ResponseValidationError,
|
||||||
|
Nip47UnsupportedEncryptionError,
|
||||||
|
Nip47WalletError,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
const NIP47_INFO_KIND = 13194
|
||||||
|
const NIP47_REQUEST_KIND = 23194
|
||||||
|
const NIP47_RESPONSE_KIND = 23195
|
||||||
|
const NIP47_VERSION = "1.0"
|
||||||
|
const OUTBOUND_ENCRYPTION: Nip47EncryptionType = "nip44_v2"
|
||||||
|
|
||||||
|
const hexKeyPattern = /^[0-9a-f]{64}$/i
|
||||||
|
|
||||||
|
const normalizeHexKey = (key: string, label: string) => {
|
||||||
|
const normalized = key.trim()
|
||||||
|
|
||||||
|
if (!hexKeyPattern.test(normalized)) {
|
||||||
|
throw new Error(`Invalid ${label}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
type Nip47Response<T> = {
|
||||||
|
result?: T
|
||||||
|
error?: {
|
||||||
|
message?: string
|
||||||
|
code?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const withTimeout = async <T>(timeoutMs: number, f: (signal: AbortSignal) => Promise<T>) => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await f(controller.signal)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NWCOptions = {
|
||||||
|
relayUrls: string[]
|
||||||
|
relayUrl: string
|
||||||
|
walletPubkey: string
|
||||||
|
secret: string
|
||||||
|
lud16?: string
|
||||||
|
nostrWalletConnectUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewNWCClientOptions = {
|
||||||
|
relayUrls?: string[]
|
||||||
|
relayUrl?: string
|
||||||
|
secret?: string
|
||||||
|
walletPubkey?: string
|
||||||
|
nostrWalletConnectUrl?: string
|
||||||
|
lud16?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseWalletConnectUrl = (walletConnectUrl: string): NewNWCClientOptions => {
|
||||||
|
const normalized = walletConnectUrl
|
||||||
|
.replace("nostrwalletconnect://", "http://")
|
||||||
|
.replace("nostr+walletconnect://", "http://")
|
||||||
|
.replace("nostrwalletconnect:", "http://")
|
||||||
|
.replace("nostr+walletconnect:", "http://")
|
||||||
|
|
||||||
|
const url = new URL(normalized)
|
||||||
|
const relayParams = url.searchParams.getAll("relay")
|
||||||
|
|
||||||
|
if (!relayParams.length) {
|
||||||
|
throw new Error("No relay URL found in connection string")
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: NewNWCClientOptions = {
|
||||||
|
walletPubkey: url.host,
|
||||||
|
relayUrls: relayParams,
|
||||||
|
relayUrl: relayParams[0],
|
||||||
|
nostrWalletConnectUrl: walletConnectUrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = url.searchParams.get("secret")
|
||||||
|
if (secret) {
|
||||||
|
options.secret = secret
|
||||||
|
}
|
||||||
|
|
||||||
|
const lud16 = url.searchParams.get("lud16")
|
||||||
|
if (lud16) {
|
||||||
|
options.lud16 = lud16
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NWCClient {
|
||||||
|
signer: Nip01Signer
|
||||||
|
relayUrls: string[]
|
||||||
|
walletPubkey: string
|
||||||
|
secret: string
|
||||||
|
lud16: string | undefined
|
||||||
|
options: NWCOptions
|
||||||
|
private encryptionReady = false
|
||||||
|
|
||||||
|
constructor(options: NewNWCClientOptions = {}) {
|
||||||
|
if (options.nostrWalletConnectUrl) {
|
||||||
|
options = {
|
||||||
|
...parseWalletConnectUrl(options.nostrWalletConnectUrl),
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const relayUrls = options.relayUrls || (options.relayUrl ? [options.relayUrl] : [])
|
||||||
|
|
||||||
|
if (!relayUrls.length) {
|
||||||
|
throw new Error("Missing relay url")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.walletPubkey) {
|
||||||
|
throw new Error("Missing wallet pubkey")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.secret) {
|
||||||
|
throw new Error("Missing secret key")
|
||||||
|
}
|
||||||
|
|
||||||
|
this.relayUrls = uniq(relayUrls.map(normalizeRelayUrl))
|
||||||
|
this.secret = normalizeHexKey(options.secret, "secret key")
|
||||||
|
this.lud16 = options.lud16
|
||||||
|
this.walletPubkey = normalizeHexKey(options.walletPubkey, "wallet pubkey")
|
||||||
|
this.signer = Nip01Signer.fromSecret(this.secret)
|
||||||
|
|
||||||
|
const nostrWalletConnectUrl =
|
||||||
|
options.nostrWalletConnectUrl || this.buildNostrWalletConnectUrl(true)
|
||||||
|
|
||||||
|
this.options = {
|
||||||
|
relayUrls: this.relayUrls,
|
||||||
|
relayUrl: this.relayUrls[0],
|
||||||
|
walletPubkey: this.walletPubkey,
|
||||||
|
secret: this.secret,
|
||||||
|
lud16: this.lud16,
|
||||||
|
nostrWalletConnectUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInfo(): Promise<Nip47GetInfoResponse> {
|
||||||
|
return await this.executeNip47Request<Nip47GetInfoResponse>(
|
||||||
|
"get_info",
|
||||||
|
{},
|
||||||
|
response => Array.isArray(response.methods),
|
||||||
|
{replyTimeout: 10000},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalance(): Promise<Nip47GetBalanceResponse> {
|
||||||
|
return await this.executeNip47Request<Nip47GetBalanceResponse>(
|
||||||
|
"get_balance",
|
||||||
|
{},
|
||||||
|
response => typeof response.balance === "number",
|
||||||
|
{replyTimeout: 10000},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async payInvoice(request: Nip47PayInvoiceRequest): Promise<Nip47PayResponse> {
|
||||||
|
return await this.executeNip47Request<Nip47PayResponse>(
|
||||||
|
"pay_invoice",
|
||||||
|
request,
|
||||||
|
response => response !== undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async makeInvoice(request: Nip47MakeInvoiceRequest): Promise<Nip47Transaction> {
|
||||||
|
if (!request.amount) {
|
||||||
|
throw new Error("No amount specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.executeNip47Request<Nip47Transaction>("make_invoice", request, response =>
|
||||||
|
Boolean(response.invoice),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
// No-op. The welshman network layer manages relay socket lifecycle.
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildNostrWalletConnectUrl(includeSecret = true) {
|
||||||
|
let url = `nostr+walletconnect://${this.walletPubkey}?relay=${this.relayUrls.join(
|
||||||
|
"&relay=",
|
||||||
|
)}&pubkey=${this.publicKey}`
|
||||||
|
|
||||||
|
if (includeSecret) {
|
||||||
|
url = `${url}&secret=${this.secret}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.lud16) {
|
||||||
|
url = `${url}&lud16=${this.lud16}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private get publicKey() {
|
||||||
|
return getPubkey(this.secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeNip47Request<T>(
|
||||||
|
nip47Method: Nip47Method,
|
||||||
|
params: unknown,
|
||||||
|
resultValidator: (result: T) => boolean,
|
||||||
|
timeoutValues?: Nip47TimeoutValues,
|
||||||
|
): Promise<T> {
|
||||||
|
await this.assertWalletSupportsNip44()
|
||||||
|
|
||||||
|
const command = {method: nip47Method, params}
|
||||||
|
const encryptedCommand = await this.signer.nip44.encrypt(
|
||||||
|
this.walletPubkey,
|
||||||
|
JSON.stringify(command),
|
||||||
|
)
|
||||||
|
const template = makeEvent(NIP47_REQUEST_KIND, {
|
||||||
|
tags: [
|
||||||
|
["p", this.walletPubkey],
|
||||||
|
["v", NIP47_VERSION],
|
||||||
|
["encryption", OUTBOUND_ENCRYPTION],
|
||||||
|
],
|
||||||
|
content: encryptedCommand,
|
||||||
|
})
|
||||||
|
const event = await this.signer.sign(template)
|
||||||
|
const replyTimeout = timeoutValues?.replyTimeout || 60_000
|
||||||
|
const publishTimeout = timeoutValues?.publishTimeout || 5_000
|
||||||
|
const responseListener = this.listenForResponse(event.id, replyTimeout)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.publishRequest(event, publishTimeout)
|
||||||
|
} catch (error) {
|
||||||
|
responseListener.cancel()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseEvent = await responseListener.promise
|
||||||
|
let response: Nip47Response<T>
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decryptedContent = await decrypt(this.signer, this.walletPubkey, responseEvent.content)
|
||||||
|
response = JSON.parse(decryptedContent) as Nip47Response<T>
|
||||||
|
} catch {
|
||||||
|
throw new Nip47ResponseDecodingError("failed to deserialize response", "INTERNAL")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.result) {
|
||||||
|
if (resultValidator(response.result)) {
|
||||||
|
return response.result
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Nip47ResponseValidationError(
|
||||||
|
"response from NWC failed validation: " + JSON.stringify(response.result),
|
||||||
|
"INTERNAL",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Nip47WalletError(
|
||||||
|
response.error?.message || "unknown Error",
|
||||||
|
response.error?.code || "INTERNAL",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private listenForResponse(eventId: string, timeoutMs: number) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
let responseEvent: TrustedEvent | undefined
|
||||||
|
|
||||||
|
const promise = request({
|
||||||
|
relays: this.relayUrls,
|
||||||
|
filters: [{kinds: [NIP47_RESPONSE_KIND], authors: [this.walletPubkey], "#e": [eventId]}],
|
||||||
|
signal: controller.signal,
|
||||||
|
onEvent: (event: TrustedEvent) => {
|
||||||
|
if (!responseEvent) {
|
||||||
|
responseEvent = event
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (!responseEvent) {
|
||||||
|
throw new Nip47ReplyTimeoutError(`reply timeout: event ${eventId}`, "INTERNAL")
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseEvent
|
||||||
|
})
|
||||||
|
.finally(() => clearTimeout(timeoutId))
|
||||||
|
|
||||||
|
return {
|
||||||
|
promise,
|
||||||
|
cancel: () => controller.abort(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async publishRequest(event: SignedEvent, timeout: number) {
|
||||||
|
const resultsByRelay = await publish({
|
||||||
|
relays: this.relayUrls,
|
||||||
|
event,
|
||||||
|
timeout,
|
||||||
|
})
|
||||||
|
const results = Object.values(resultsByRelay)
|
||||||
|
|
||||||
|
if (results.some(result => result.status === PublishStatus.Success)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.every(result => result.status === PublishStatus.Timeout)) {
|
||||||
|
throw new Nip47PublishTimeoutError(`publish timeout: ${event.id}`, "INTERNAL")
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Nip47PublishError(
|
||||||
|
"failed to publish: " + results.map(result => `${result.relay}:${result.status}`).join(", "),
|
||||||
|
"INTERNAL",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertWalletSupportsNip44() {
|
||||||
|
if (this.encryptionReady) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoEvents = await withTimeout(5_000, signal =>
|
||||||
|
request({
|
||||||
|
relays: this.relayUrls,
|
||||||
|
filters: [{kinds: [NIP47_INFO_KIND], authors: [this.walletPubkey], limit: 1}],
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const info = infoEvents[0]
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
throw new Nip47NetworkError("no info event (kind 13194) returned from relay", "OTHER")
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptionTag = info.tags.find(tag => tag[0] === "encryption")
|
||||||
|
const supportedEncryptions = (encryptionTag?.[1] || "").split(" ").filter(Boolean)
|
||||||
|
const isNip44Supported =
|
||||||
|
supportedEncryptions.length > 0
|
||||||
|
? supportedEncryptions.includes(OUTBOUND_ENCRYPTION)
|
||||||
|
: info.tags.some(tag => tag[0] === "v" && tag[1]?.includes(NIP47_VERSION))
|
||||||
|
|
||||||
|
if (!isNip44Supported) {
|
||||||
|
throw new Nip47UnsupportedEncryptionError(
|
||||||
|
"wallet does not support required nip44_v2 encryption",
|
||||||
|
"UNSUPPORTED_ENCRYPTION",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.encryptionReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {NWCClient} from "./NWCClient"
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
export type Nip47EncryptionType = "nip44_v2"
|
||||||
|
|
||||||
|
export type Nip47Method =
|
||||||
|
| "get_info"
|
||||||
|
| "get_balance"
|
||||||
|
| "make_invoice"
|
||||||
|
| "pay_invoice"
|
||||||
|
| (string & {})
|
||||||
|
|
||||||
|
export type Nip47GetInfoResponse = {
|
||||||
|
alias?: string
|
||||||
|
color?: string
|
||||||
|
pubkey?: string
|
||||||
|
network?: string
|
||||||
|
block_height?: number
|
||||||
|
block_hash?: string
|
||||||
|
methods?: Nip47Method[]
|
||||||
|
notifications?: string[]
|
||||||
|
metadata?: unknown
|
||||||
|
lud16?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Nip47GetBalanceResponse = {
|
||||||
|
balance: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Nip47PayInvoiceRequest = {
|
||||||
|
invoice: string
|
||||||
|
amount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Nip47PayResponse = {
|
||||||
|
preimage?: string
|
||||||
|
fees_paid?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Nip47MakeInvoiceRequest = {
|
||||||
|
amount: number
|
||||||
|
description?: string
|
||||||
|
description_hash?: string
|
||||||
|
expiry?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Nip47Transaction = {
|
||||||
|
invoice: string
|
||||||
|
amount?: number
|
||||||
|
description?: string
|
||||||
|
payment_hash?: string
|
||||||
|
preimage?: string
|
||||||
|
created_at?: number
|
||||||
|
expires_at?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Nip47TimeoutValues = {
|
||||||
|
replyTimeout?: number
|
||||||
|
publishTimeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Nip47Error extends Error {
|
||||||
|
code: string
|
||||||
|
constructor(message: string, code: string) {
|
||||||
|
super(message)
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Nip47NetworkError extends Nip47Error {}
|
||||||
|
|
||||||
|
export class Nip47WalletError extends Nip47Error {}
|
||||||
|
|
||||||
|
export class Nip47TimeoutError extends Nip47Error {}
|
||||||
|
export class Nip47PublishTimeoutError extends Nip47TimeoutError {}
|
||||||
|
export class Nip47ReplyTimeoutError extends Nip47TimeoutError {}
|
||||||
|
export class Nip47PublishError extends Nip47Error {}
|
||||||
|
export class Nip47ResponseDecodingError extends Nip47Error {}
|
||||||
|
export class Nip47ResponseValidationError extends Nip47Error {}
|
||||||
|
export class Nip47UnsupportedEncryptionError extends Nip47Error {}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {vi} from "vitest"
|
||||||
|
|
||||||
|
export const stubFetch = (
|
||||||
|
impl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> = () =>
|
||||||
|
Promise.reject(new Error("fetch not mocked")),
|
||||||
|
) => {
|
||||||
|
const original = globalThis.fetch
|
||||||
|
const mock = vi.fn(impl) as unknown as typeof fetch
|
||||||
|
globalThis.fetch = mock
|
||||||
|
|
||||||
|
return {
|
||||||
|
mock,
|
||||||
|
restore: () => {
|
||||||
|
globalThis.fetch = original
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockDateNow = (timestampMs: number) => {
|
||||||
|
const spy = vi.spyOn(Date, "now").mockReturnValue(timestampMs)
|
||||||
|
return () => spy.mockRestore()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import {afterEach, vi} from "vitest"
|
||||||
|
|
||||||
|
const originalFetch: typeof fetch | undefined = globalThis.fetch
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (globalThis.fetch !== originalFetch) {
|
||||||
|
globalThis.fetch = originalFetch as typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import {describe, expect, it} from "vitest"
|
||||||
|
import {mockDateNow, stubFetch} from "./helpers"
|
||||||
|
|
||||||
|
describe("lightning test harness", () => {
|
||||||
|
it("supports time and fetch helpers", async () => {
|
||||||
|
const restoreDate = mockDateNow(1717171717171)
|
||||||
|
expect(Date.now()).toBe(1717171717171)
|
||||||
|
restoreDate()
|
||||||
|
|
||||||
|
const {mock, restore} = stubFetch(async () => ({ok: true}) as Response)
|
||||||
|
|
||||||
|
const response = await fetch("https://example.com")
|
||||||
|
expect(response.ok).toBe(true)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
restore()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export type WalletPayInvoiceParams = {
|
||||||
|
invoice: string
|
||||||
|
msats?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WalletCreateInvoiceParams = {
|
||||||
|
sats: number
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWallet {
|
||||||
|
getBalanceSats: () => Promise<number>
|
||||||
|
payInvoice: (params: WalletPayInvoiceParams) => Promise<unknown>
|
||||||
|
createInvoice: (params: WalletCreateInvoiceParams) => Promise<string>
|
||||||
|
close: () => void
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {fromMsats} from "@welshman/util"
|
||||||
|
import type {NWCInfo} from "@welshman/util"
|
||||||
|
import {NWCClient} from "../nwc"
|
||||||
|
import type {IWallet, WalletCreateInvoiceParams, WalletPayInvoiceParams} from "./IWallet"
|
||||||
|
|
||||||
|
export class NWCWallet implements IWallet {
|
||||||
|
readonly client: NWCClient
|
||||||
|
|
||||||
|
constructor(info: NWCInfo) {
|
||||||
|
this.client = info.nostrWalletConnectUrl
|
||||||
|
? new NWCClient({nostrWalletConnectUrl: info.nostrWalletConnectUrl})
|
||||||
|
: new NWCClient(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalanceSats() {
|
||||||
|
const response = await this.client.getBalance()
|
||||||
|
|
||||||
|
return fromMsats(response.balance || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
payInvoice({invoice, msats}: WalletPayInvoiceParams) {
|
||||||
|
const params: {invoice: string; amount?: number} = {invoice}
|
||||||
|
|
||||||
|
if (msats) {
|
||||||
|
params.amount = msats
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.client.payInvoice(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInvoice({
|
||||||
|
sats,
|
||||||
|
description = "Receive via lightning",
|
||||||
|
}: WalletCreateInvoiceParams): Promise<string> {
|
||||||
|
const response = await this.client.makeInvoice({
|
||||||
|
amount: sats * 1000,
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.invoice) {
|
||||||
|
throw new Error("NWC wallet failed to return an invoice")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.invoice
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {describe, expect, it, vi} from "vitest"
|
||||||
|
import {WebLNWallet, type WebLNProvider} from "./WebLNWallet"
|
||||||
|
|
||||||
|
const makeProvider = (): WebLNProvider => ({
|
||||||
|
enable: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getBalance: vi.fn().mockResolvedValue({balance: 123}),
|
||||||
|
sendPayment: vi.fn().mockResolvedValue({preimage: "00"}),
|
||||||
|
makeInvoice: vi.fn().mockResolvedValue({paymentRequest: "lnbc123"}),
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("WebLNWallet", () => {
|
||||||
|
it("returns balance in sats", async () => {
|
||||||
|
const provider = makeProvider()
|
||||||
|
const wallet = new WebLNWallet(provider)
|
||||||
|
|
||||||
|
await expect(wallet.getBalanceSats()).resolves.toBe(123)
|
||||||
|
expect(provider.enable).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects msat amount overrides", async () => {
|
||||||
|
const wallet = new WebLNWallet(makeProvider())
|
||||||
|
|
||||||
|
await expect(wallet.payInvoice({invoice: "lnbc123", msats: 1000})).rejects.toThrow(
|
||||||
|
"Unable to pay zero invoices with webln",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("normalizes invoice responses", async () => {
|
||||||
|
const provider = makeProvider()
|
||||||
|
provider.makeInvoice = vi.fn().mockResolvedValue("lnbc456")
|
||||||
|
const wallet = new WebLNWallet(provider)
|
||||||
|
|
||||||
|
await expect(wallet.createInvoice({sats: 10})).resolves.toBe("lnbc456")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type {WebLNInfo} from "@welshman/util"
|
||||||
|
import type {IWallet, WalletCreateInvoiceParams, WalletPayInvoiceParams} from "./IWallet"
|
||||||
|
|
||||||
|
export type WebLNBalanceResponse = {
|
||||||
|
balance?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebLNInvoiceResponse =
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
paymentRequest?: string
|
||||||
|
pr?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebLNProvider = {
|
||||||
|
enable: () => Promise<void>
|
||||||
|
getInfo?: () => Promise<WebLNInfo>
|
||||||
|
getBalance?: () => Promise<WebLNBalanceResponse>
|
||||||
|
sendPayment?: (invoice: string) => Promise<unknown>
|
||||||
|
makeInvoice?: (args: {amount: number; defaultMemo?: string}) => Promise<WebLNInvoiceResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWebLn = () => (window as unknown as {webln?: WebLNProvider}).webln
|
||||||
|
|
||||||
|
export class WebLNWallet implements IWallet {
|
||||||
|
constructor(private provider: WebLNProvider | undefined) {}
|
||||||
|
|
||||||
|
async getBalanceSats() {
|
||||||
|
const provider = this.requireProvider()
|
||||||
|
|
||||||
|
if (!provider.getBalance) {
|
||||||
|
throw new Error("WebLN wallet does not support balance checks")
|
||||||
|
}
|
||||||
|
|
||||||
|
await provider.enable()
|
||||||
|
|
||||||
|
const response = await provider.getBalance()
|
||||||
|
|
||||||
|
return Math.floor(response.balance || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async payInvoice({invoice, msats}: WalletPayInvoiceParams) {
|
||||||
|
const provider = this.requireProvider()
|
||||||
|
|
||||||
|
if (msats) {
|
||||||
|
throw new Error("Unable to pay zero invoices with webln")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider.sendPayment) {
|
||||||
|
throw new Error("WebLN wallet does not support sending payments")
|
||||||
|
}
|
||||||
|
|
||||||
|
await provider.enable()
|
||||||
|
|
||||||
|
return provider.sendPayment(invoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInvoice({
|
||||||
|
sats,
|
||||||
|
description = "Receive via lightning",
|
||||||
|
}: WalletCreateInvoiceParams): Promise<string> {
|
||||||
|
const provider = this.requireProvider()
|
||||||
|
|
||||||
|
if (!provider.makeInvoice) {
|
||||||
|
throw new Error("WebLN wallet does not support creating invoices")
|
||||||
|
}
|
||||||
|
|
||||||
|
await provider.enable()
|
||||||
|
|
||||||
|
const response = await provider.makeInvoice({
|
||||||
|
amount: sats,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
// WebLN providers are managed by the browser extension.
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireProvider() {
|
||||||
|
if (!this.provider) {
|
||||||
|
throw new Error("WebLN not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.provider
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {describe, expect, it, vi} from "vitest"
|
||||||
|
import {WalletType, getPubkey, makeSecret} from "@welshman/util"
|
||||||
|
import {createWalletAdapter} from "./factory"
|
||||||
|
import {NWCWallet} from "./NWCWallet"
|
||||||
|
import {WebLNWallet, type WebLNProvider} from "./WebLNWallet"
|
||||||
|
|
||||||
|
describe("wallet factory", () => {
|
||||||
|
it("creates NWC adapters", () => {
|
||||||
|
const secret = makeSecret()
|
||||||
|
const walletPubkey = getPubkey(makeSecret())
|
||||||
|
const adapter = createWalletAdapter({
|
||||||
|
type: WalletType.NWC,
|
||||||
|
info: {
|
||||||
|
lud16: "wallet@example.com",
|
||||||
|
secret,
|
||||||
|
relayUrl: "wss://relay.example.com",
|
||||||
|
walletPubkey,
|
||||||
|
nostrWalletConnectUrl: `nostr+walletconnect://${walletPubkey}?relay=wss://relay.example.com&secret=${secret}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(adapter).toBeInstanceOf(NWCWallet)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("creates WebLN adapters", () => {
|
||||||
|
const provider: WebLNProvider = {
|
||||||
|
enable: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getBalance: vi.fn().mockResolvedValue({balance: 1}),
|
||||||
|
sendPayment: vi.fn().mockResolvedValue({}),
|
||||||
|
makeInvoice: vi.fn().mockResolvedValue("lnbc1"),
|
||||||
|
}
|
||||||
|
const browserWindow = window as Window & {webln?: WebLNProvider}
|
||||||
|
browserWindow.webln = provider
|
||||||
|
|
||||||
|
const adapter = createWalletAdapter({
|
||||||
|
type: WalletType.WebLN,
|
||||||
|
info: {supports: ["lightning"]},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(adapter).toBeInstanceOf(WebLNWallet)
|
||||||
|
|
||||||
|
delete browserWindow.webln
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {isNWCWallet, isWebLNWallet, type Wallet} from "@welshman/util"
|
||||||
|
import {NWCWallet} from "./NWCWallet"
|
||||||
|
import {WebLNWallet, getWebLn} from "./WebLNWallet"
|
||||||
|
import type {IWallet} from "./IWallet"
|
||||||
|
|
||||||
|
export const createWalletAdapter = (wallet: Wallet): IWallet => {
|
||||||
|
if (isNWCWallet(wallet)) {
|
||||||
|
return new NWCWallet(wallet.info)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWebLNWallet(wallet)) {
|
||||||
|
return new WebLNWallet(getWebLn())
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unsupported wallet type")
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export {createWalletAdapter} from "./factory"
|
||||||
|
export type {IWallet, WalletCreateInvoiceParams, WalletPayInvoiceParams} from "./IWallet"
|
||||||
|
export {NWCWallet} from "./NWCWallet"
|
||||||
|
export {WebLNWallet, getWebLn} from "./WebLNWallet"
|
||||||
|
export type {WebLNProvider} from "./WebLNWallet"
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {nwc} from "@getalby/sdk"
|
|
||||||
import {LOCALE} from "@welshman/lib"
|
import {LOCALE} from "@welshman/lib"
|
||||||
import {displayRelayUrl, isNWCWallet, fromMsats} from "@welshman/util"
|
import {displayRelayUrl, isNWCWallet} from "@welshman/util"
|
||||||
import {session, pubkey, profilesByPubkey} from "@welshman/app"
|
import {session, pubkey, profilesByPubkey} from "@welshman/app"
|
||||||
import DownloadMinimalistic from "@assets/icons/download-minimalistic.svg?dataurl"
|
import DownloadMinimalistic from "@assets/icons/download-minimalistic.svg?dataurl"
|
||||||
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
|
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
|
||||||
@@ -13,7 +12,7 @@
|
|||||||
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"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {getWebLn} from "@app/core/commands"
|
import {getWalletBalance} from "@app/core/commands"
|
||||||
import Wallet2 from "@assets/icons/wallet.svg?dataurl"
|
import Wallet2 from "@assets/icons/wallet.svg?dataurl"
|
||||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
@@ -72,12 +71,10 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="flex gap-2 whitespace-nowrap">
|
<p class="flex gap-2 whitespace-nowrap">
|
||||||
Balance:
|
Balance:
|
||||||
{#await getWebLn()
|
{#await getWalletBalance()}
|
||||||
?.enable()
|
|
||||||
.then(() => getWebLn().getBalance())}
|
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
{:then res}
|
{:then balance}
|
||||||
{new Intl.NumberFormat(LOCALE).format(res?.balance || 0)}
|
{new Intl.NumberFormat(LOCALE).format(balance)}
|
||||||
{:catch}
|
{:catch}
|
||||||
[unknown]
|
[unknown]
|
||||||
{/await}
|
{/await}
|
||||||
@@ -85,17 +82,17 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if $session.wallet.type === "nwc"}
|
{:else if $session.wallet.type === "nwc"}
|
||||||
{@const {lud16, relayUrl, nostrWalletConnectUrl} = $session.wallet.info}
|
{@const {lud16, relayUrl} = $session.wallet.info}
|
||||||
<div class="flex flex-col justify-between gap-2 lg:flex-row">
|
<div class="flex flex-col justify-between gap-2 lg:flex-row">
|
||||||
<p>
|
<p>
|
||||||
Connected to <strong>{lud16}</strong> via <strong>{displayRelayUrl(relayUrl)}</strong>
|
Connected to <strong>{lud16}</strong> via <strong>{displayRelayUrl(relayUrl)}</strong>
|
||||||
</p>
|
</p>
|
||||||
<p class="flex gap-2 whitespace-nowrap">
|
<p class="flex gap-2 whitespace-nowrap">
|
||||||
Balance:
|
Balance:
|
||||||
{#await new nwc.NWCClient({nostrWalletConnectUrl}).getBalance()}
|
{#await getWalletBalance()}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
{:then res}
|
{:then balance}
|
||||||
{new Intl.NumberFormat(LOCALE).format(fromMsats(res?.balance || 0))}
|
{new Intl.NumberFormat(LOCALE).format(balance)}
|
||||||
{:catch}
|
{:catch}
|
||||||
[unknown]
|
[unknown]
|
||||||
{/await}
|
{/await}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {defineConfig} from "vitest/config"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
include: ["src/lib/lightning/**/*.test.ts"],
|
||||||
|
restoreMocks: true,
|
||||||
|
setupFiles: ["src/lib/lightning/test/setup.ts"],
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user