Compare commits

...

2 Commits

Author SHA1 Message Date
triesap 5a69d305af Remove lightning vitest coverage 2026-02-17 20:01:03 +00:00
triesap ce62cafd59 Replace lightning toolkit with wallet adapter 2026-02-17 19:54:16 +00:00
22 changed files with 1060 additions and 139 deletions
+1 -2
View File
@@ -49,9 +49,8 @@
"@capacitor/push-notifications": "^8.0.0",
"@capawesome/capacitor-android-dark-mode-support": "^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/hashes": "^2.0.1",
"@pomade/core": "^0.0.12",
"@poppanator/sveltekit-svg": "^4.2.1",
"@sveltejs/adapter-static": "^3.0.10",
+3 -49
View File
@@ -47,15 +47,12 @@ importers:
'@capawesome/capacitor-badge':
specifier: ^8.0.0
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':
specifier: ^1.9.7
version: 1.9.7
'@noble/hashes':
specifier: ^2.0.1
version: 2.0.1
'@pomade/core':
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))
@@ -999,18 +996,6 @@ packages:
'@frostr/bifrost@1.0.7':
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':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -3506,14 +3491,6 @@ packages:
peerDependencies:
'@capacitor/core': ^7.0.0
nostr-tools@2.15.0:
resolution: {integrity: sha512-Jj/+UFbu3JbTAWP4ipPFNuyD4W5eVRBNAP+kmnoRCYp3bLmTrlQ0Qhs5O1xSQJTFpjdZqoS0zZOUKdxUdjc+pw==}
peerDependencies:
typescript: '>=5.0.0'
peerDependenciesMeta:
typescript:
optional: true
nostr-tools@2.20.0:
resolution: {integrity: sha512-Kq/2lMyeOdGvpDsYH2an8HP4H0aFCqwKythhTzxfgZTVv4L3NOgrJw2SxH8jkWlH8xPhWxGfN6lFtC+EAa2qYQ==}
peerDependencies:
@@ -5793,17 +5770,6 @@ snapshots:
transitivePeerDependencies:
- 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/node@0.16.7':
@@ -8447,18 +8413,6 @@ snapshots:
dependencies:
'@capacitor/core': 8.0.1
nostr-tools@2.15.0(typescript@5.9.3):
dependencies:
'@noble/ciphers': 0.5.3
'@noble/curves': 1.2.0
'@noble/hashes': 1.3.1
'@scure/base': 1.1.1
'@scure/bip32': 1.3.1
'@scure/bip39': 1.2.1
nostr-wasm: 0.1.0
optionalDependencies:
typescript: 5.9.3
nostr-tools@2.20.0(typescript@5.9.3):
dependencies:
'@noble/ciphers': 0.5.3
+9 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {debounce} from "throttle-debounce"
import {nwc} from "@getalby/sdk"
import {nwc} from "@lib/lightning"
import {sleep, assoc} from "@welshman/lib"
import type {NWCInfo} from "@welshman/util"
import {pubkey, userProfile, updateSession} from "@welshman/app"
@@ -33,8 +33,14 @@
loading = true
try {
await Promise.all([sleep(800), getWebLn().enable()])
const info = await getWebLn().getInfo()
const webLn = getWebLn()
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")) {
pushToast({
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import {Invoice} from "@getalby/lightning-tools/bolt11"
import {Invoice} from "@lib/lightning/bolt11"
import {debounce} from "throttle-debounce"
import {session} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl"
+1 -1
View File
@@ -1,5 +1,5 @@
<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 Bolt from "@assets/icons/bolt.svg?dataurl"
import Refresh from "@assets/icons/refresh.svg?dataurl"
+25 -71
View File
@@ -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 {get, derived} from "svelte/store"
import {
@@ -432,43 +433,34 @@ export const publishLeaveRequest = (params: LeaveRequestParams) =>
// Lightning
export const getWebLn = () => (window as any).webln
export const getWebLn = () => lightningWallet.getWebLn()
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) => {
const getWalletAdapter = (): IWallet => {
const $session = session.get()
if (!$session?.wallet) {
throw new Error("No wallet is connected")
}
if ($session.wallet.type === "nwc") {
const params: {invoice: string; amount?: number} = {invoice}
if (msats) params.amount = msats
return getNwcClient().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))
return lightningWallet.createWalletAdapter($session.wallet)
}
const withConnectedWallet = async <T>(f: (wallet: IWallet) => Promise<T>) => {
const wallet = getWalletAdapter()
try {
return await f(wallet)
} 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 = {
sats: number
description?: string
@@ -478,56 +470,18 @@ 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,
return withConnectedWallet(wallet =>
wallet.createInvoice({
sats: satAmount,
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
+86
View File
@@ -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
}
}
+63
View File
@@ -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),
}
}
+108
View File
@@ -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
}
+92
View File
@@ -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
}
}
+1
View File
@@ -0,0 +1 @@
export {Invoice} from "./Invoice"
+17
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
export * as bolt11 from "./bolt11"
export * as nwc from "./nwc"
export * as wallet from "./wallet"
+380
View File
@@ -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
}
}
+1
View File
@@ -0,0 +1 @@
export {NWCClient} from "./NWCClient"
+77
View File
@@ -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 {}
+16
View File
@@ -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
}
+50
View File
@@ -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()
}
}
+96
View File
@@ -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
}
}
+16
View File
@@ -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")
}
+5
View File
@@ -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"
+9 -12
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import {nwc} from "@getalby/sdk"
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 DownloadMinimalistic from "@assets/icons/download-minimalistic.svg?dataurl"
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
@@ -13,7 +12,7 @@
import WalletDisconnect from "@app/components/WalletDisconnect.svelte"
import WalletUpdateReceivingAddress from "@app/components/WalletUpdateReceivingAddress.svelte"
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 CheckCircle from "@assets/icons/check-circle.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
@@ -72,12 +71,10 @@
</p>
<p class="flex gap-2 whitespace-nowrap">
Balance:
{#await getWebLn()
?.enable()
.then(() => getWebLn().getBalance())}
{#await getWalletBalance()}
<span class="loading loading-spinner loading-sm"></span>
{:then res}
{new Intl.NumberFormat(LOCALE).format(res?.balance || 0)}
{:then balance}
{new Intl.NumberFormat(LOCALE).format(balance)}
{:catch}
[unknown]
{/await}
@@ -85,17 +82,17 @@
</p>
</div>
{: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">
<p>
Connected to <strong>{lud16}</strong> via <strong>{displayRelayUrl(relayUrl)}</strong>
</p>
<p class="flex gap-2 whitespace-nowrap">
Balance:
{#await new nwc.NWCClient({nostrWalletConnectUrl}).getBalance()}
{#await getWalletBalance()}
<span class="loading loading-spinner loading-sm"></span>
{:then res}
{new Intl.NumberFormat(LOCALE).format(fromMsats(res?.balance || 0))}
{:then balance}
{new Intl.NumberFormat(LOCALE).format(balance)}
{:catch}
[unknown]
{/await}