commit 2d858727b05f9d66e4f028c086d93cebf769f3b8 Author: Danny Morabito Date: Wed Jul 9 12:45:59 2025 +0200 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..98732ed --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# portalbtc-lib + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.2.17. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..f56ded6 --- /dev/null +++ b/bun.lock @@ -0,0 +1,76 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "portalbtc-lib", + "dependencies": { + "@breeztech/breez-sdk-liquid": "^0.9.2-rc2", + "@cashu/cashu-ts": "^2.5.2", + "nostr-tools": "^2.15.0", + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.8.3", + }, + }, + }, + "packages": { + "@breeztech/breez-sdk-liquid": ["@breeztech/breez-sdk-liquid@0.9.2-rc2", "", {}, "sha512-6t7b6RphlO/vH3VZn2MXJOotT8bvc4WlDqXBb2wWitpubOfIvVvCIoo+gSezlc0U87ovLF0NOEzYTQkKBtlU0g=="], + + "@cashu/cashu-ts": ["@cashu/cashu-ts@2.5.2", "", { "dependencies": { "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@scure/bip32": "^1.5.0", "buffer": "^6.0.3" } }, "sha512-AjfDOZKb3RWWhmpHABC4KJxwJs3wp6eOFg6U3S6d3QOqtSoNkceMTn6lLN4/bYQarLR19rysbrIJ8MHsSwNxeQ=="], + + "@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="], + + "@noble/curves": ["@noble/curves@1.9.2", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-HxngEd2XUcg9xi20JkwlLCtYwfoFw4JGkuZpT+WlsPD4gB/cxkvTD8fSsoAnphGZhFdZYKeQIPCuFlWPm1uE0g=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="], + + "@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], + + "@scure/bip39": ["@scure/bip39@1.2.1", "", { "dependencies": { "@noble/hashes": "~1.3.0", "@scure/base": "~1.1.0" } }, "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg=="], + + "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="], + + "@types/node": ["@types/node@24.0.12", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-LtOrbvDf5ndC9Xi+4QZjVL0woFymF/xSTKZKPgrrl7H7XoeDvnD+E2IclKVDyaK9UM756W/3BXqSU+JEHopA9g=="], + + "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "nostr-tools": ["nostr-tools@2.15.0", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@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" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-Jj/+UFbu3JbTAWP4ipPFNuyD4W5eVRBNAP+kmnoRCYp3bLmTrlQ0Qhs5O1xSQJTFpjdZqoS0zZOUKdxUdjc+pw=="], + + "nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + + "@scure/bip32/@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="], + + "@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + + "nostr-tools/@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="], + + "nostr-tools/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="], + + "nostr-tools/@scure/bip32": ["@scure/bip32@1.3.1", "", { "dependencies": { "@noble/curves": "~1.1.0", "@noble/hashes": "~1.3.1", "@scure/base": "~1.1.0" } }, "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A=="], + + "nostr-tools/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + + "nostr-tools/@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="], + + "nostr-tools/@scure/bip32/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + + "nostr-tools/@scure/bip32/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="], + } +} diff --git a/cashu.ts b/cashu.ts new file mode 100644 index 0000000..4717e81 --- /dev/null +++ b/cashu.ts @@ -0,0 +1,107 @@ +import { CashuMint, type Proof } from "@cashu/cashu-ts"; +import { CashuWallet } from "@cashu/cashu-ts"; +import NpubCash from "./npubCash"; +import type { CashuStore, CashuTxn } from "./paymentStatus"; +import PortalBtcWallet from "."; + +export default class PortalBtcWalletCashu { + private npubCash: NpubCash; + private cashuTxns: CashuTxn[] = []; + private proofs: Proof[] = []; + private onBalanceUpdated: (() => void) | null = null; + + get txns() { + return this.cashuTxns; + } + + get cashuWallet() { + const mint = new CashuMint(PortalBtcWallet.MINT_URL); + return new CashuWallet(mint); + } + + get balance() { + return this.proofs.reduce((sum, p) => sum + p.amount, 0); + } + + constructor( + mnemonic: string, + private cashuStore: CashuStore, + ) { + this.npubCash = new NpubCash(mnemonic); + } + + async init(onBalanceUpdated: () => void) { + this.cashuTxns = await this.cashuStore.getTxns(); + this.proofs = await this.cashuStore.getProofs(); + this.onBalanceUpdated = onBalanceUpdated; + } + + async redeemCashuQuotes() { + const attempt = this.npubCash.tryRedeemUnredeemedCashuQuotes( + await this.cashuStore.getLastRedeemedCashuQuoteTimestamp(), + ); + for await (const quote of attempt) { + this.cashuTxns.push({ + txId: `cashu-quote-${quote.quoteId}`, + paymentType: "receive", + amountSat: quote.amountSat, + timestamp: quote.timestamp, + status: "complete", + }); + } + const proofs = await attempt.next(); + if (!proofs.done || typeof proofs.value === "undefined") return []; + this.proofs.push(...proofs.value); + await this.persistState(); + this.onBalanceUpdated?.(); + return proofs.value; + } + + async persistState() { + await this.cashuStore.persistProofs(this.proofs); + await this.cashuStore.persistTxns(this.cashuTxns); + await this.cashuStore.persistLastRedeemedCashuQuoteTimestamp( + this.cashuTxns[this.cashuTxns.length - 1]?.timestamp ?? 0, + ); + } + + async meltProofsToPayInvoice(invoice: string) { + const meltQuote = await this.cashuWallet.createMeltQuote(invoice); + const amountToMelt = meltQuote.amount + meltQuote.fee_reserve; + const { keep, send } = await this.cashuWallet.send( + amountToMelt, + this.proofs, + { + includeFees: true, + }, + ); + const { change } = await this.cashuWallet.meltProofs(meltQuote, send); + this.proofs = [...keep, ...change]; + this.cashuTxns.push({ + txId: `cashu-send-${Date.now()}`, + paymentType: "send", + amountSat: amountToMelt, + timestamp: Math.floor(Date.now() / 1000), + status: "complete", + }); + await this.persistState(); + this.onBalanceUpdated?.(); + } + + async redeemToken(token: string): Promise { + if (!token.trim()) throw new Error("Token is empty"); + + const received = await this.cashuWallet.receive(token.trim()); + this.proofs.push(...received); + const amountReceived = received.reduce((sum, p) => sum + p.amount, 0); + this.cashuTxns.push({ + txId: `cashu-receive-${Date.now()}`, + paymentType: "receive", + amountSat: amountReceived, + timestamp: Math.floor(Date.now() / 1000), + status: "complete", + }); + await this.persistState(); + this.onBalanceUpdated?.(); + } +} diff --git a/examples/portalbtc-cli.ts b/examples/portalbtc-cli.ts new file mode 100644 index 0000000..4319a28 --- /dev/null +++ b/examples/portalbtc-cli.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env bun + +// Example CLI for interacting with PortalBtcWallet +// Usage examples: +// bun run examples/portalbtc-cli.ts balance +// bun run examples/portalbtc-cli.ts invoice 1000 +// bun run examples/portalbtc-cli.ts pay [amountSat] +// bun run examples/portalbtc-cli.ts redeem-token +// bun run examples/portalbtc-cli.ts list [limit] [offset] +// +// Environment variables required: +// MNEMONIC – BIP39 seed words for the wallet (string, required) +// BREEZ_API_KEY – Breez SDK API key (string, required) +// NETWORK – "mainnet" | "testnet" (default: "testnet") + +import PortalBtcWallet from "../index"; +import { PaymentStatus } from "../paymentStatus"; +import type { CashuStore, CashuTxn } from "../paymentStatus"; +import type { Proof } from "@cashu/cashu-ts"; +import fs from "node:fs"; +import path from "node:path"; + +interface CashuPersistenceFile { + proofs: Proof[]; + txns: CashuTxn[]; + lastRedeemedCashuQuoteTimestamp: number; +} + +class FileCashuStore implements CashuStore { + private data: CashuPersistenceFile; + + constructor(private filePath: string) { + if (fs.existsSync(filePath)) { + this.data = JSON.parse(fs.readFileSync(filePath, "utf-8")); + } else { + this.data = { + proofs: [], + txns: [], + lastRedeemedCashuQuoteTimestamp: 0, + }; + this.flush(); + } + } + + private flush() { + fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2)); + } + + async getProofs(): Promise { + return this.data.proofs; + } + + async getTxns(): Promise { + return this.data.txns; + } + + async getLastRedeemedCashuQuoteTimestamp(): Promise { + return this.data.lastRedeemedCashuQuoteTimestamp; + } + + async persistProofs(proofs: Proof[]): Promise { + this.data.proofs = proofs; + this.flush(); + } + + async persistTxns(txns: CashuTxn[]): Promise { + this.data.txns = txns; + this.flush(); + } + + async persistLastRedeemedCashuQuoteTimestamp( + timestamp: number, + ): Promise { + this.data.lastRedeemedCashuQuoteTimestamp = timestamp; + this.flush(); + } +} + +function usage() { + console.log(`Usage: + balance – Show wallet balances (on-chain + Cashu) + invoice – Generate a Lightning invoice + pay [amountSat] – Pay a destination (invoice / LNURL / address) + redeem-token – Redeem a Cashu token + list [limit] [offset] – List recent payments (default limit 20) + `); + process.exit(1); +} + +async function main() { + const [cmd, ...rest] = process.argv.slice(2); + if (!cmd) usage(); + + const mnemonic = process.env.MNEMONIC; + const network = (process.env.NETWORK ?? "testnet") as "mainnet" | "testnet"; + + if (!mnemonic) { + console.error( + "Error: MNEMONIC environment variable is required.", + ); + process.exit(1); + } + + const statePath = path.resolve( + path.dirname(new URL(import.meta.url).pathname), + ".portalbtc_state.json", + ); + const cashuStore = new FileCashuStore(statePath); + + const wallet = await PortalBtcWallet.create( + mnemonic, + cashuStore, + false, + network, + ); + + switch (cmd) { + case "balance": { + console.log("Balance (sats):", wallet.balance); + console.log(" – Cashu balance:", wallet.cashuBalance); + console.log(" – Pending balance:", wallet.pendingBalance); + console.log(" – Lightning address:", wallet.lightningAddress); + break; + } + case "invoice": { + const amountSat = Number.parseInt(rest[0] ?? "0", 10); + if (!amountSat) { + console.error("Amount (sat) required for invoice command"); + process.exit(1); + } + const invoice = await wallet.generateBolt11Invoice(amountSat); + console.log("Generated invoice:", invoice); + break; + } + case "pay": { + const destination = rest[0]; + const amountArg = rest[1] ? Number.parseInt(rest[1], 10) : 0; + if (!destination) { + console.error("Destination required for pay command"); + process.exit(1); + } + for await (const status of wallet.pay(destination, amountArg)) { + console.log("→", PaymentStatus[status.status], status); + } + break; + } + case "redeem-token": { + const token = rest[0]; + if (!token) { + console.error("Cashu token required for redeem-token command"); + process.exit(1); + } + await wallet.redeemToken(token); + console.log("Token redeemed successfully"); + break; + } + case "list": { + const limit = rest[0] ? Number.parseInt(rest[0], 10) : 20; + const offset = rest[1] ? Number.parseInt(rest[1], 10) : 0; + const payments = await wallet.listPayments(limit, offset); + console.log(payments); + break; + } + default: + usage(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..b5d7753 --- /dev/null +++ b/index.ts @@ -0,0 +1,531 @@ +import initBreez, { + type BindingLiquidSdk, + connect as breezConnect, + defaultConfig as defaultBreezConfig, + type InputType, + type LogEntry, + type Payment, + type PaymentMethod, + type PreparePayOnchainRequest, + type PrepareReceiveRequest, + type PrepareSendRequest, + setLogger, +} from "@breeztech/breez-sdk-liquid/web"; +import { privateKeyFromSeedWords as nostrPrivateKeyFromSeedWords } from "nostr-tools/nip06"; +import { getPublicKey, nip19 } from "nostr-tools"; +import { type CashuStore, type CashuTxn, PaymentStatus } from "./paymentStatus"; +export { type CashuStore, PaymentStatus } from "./paymentStatus"; +import PortalBtcWalletCashu from "./cashu"; + +type CombinedPayment = Payment | CashuTxn; + +export type { CombinedPayment as Payment }; + +export default class PortalBtcWallet { + static MINT_URL = "https://mint.minibits.cash/Bitcoin"; + + private breezSDK: BindingLiquidSdk | null = null; + private cashuSDK: PortalBtcWalletCashu; + + private balances = { + balance: 0, + pendingReceive: 0, + pendingSend: 0, + }; + + get cashuBalance() { + return this.cashuSDK.balance; + } + + get balance() { + return this.balances.balance + this.cashuBalance; + } + + get pendingBalance() { + return this.balances.pendingReceive - this.balances.pendingSend; + } + + get npub() { + const nostrPrivateKey = nostrPrivateKeyFromSeedWords(this.mnemonic); + const nostrPubkey = getPublicKey(nostrPrivateKey); + return nip19.npubEncode(nostrPubkey); + } + + get lightningAddress() { + return `${this.npub}@${this.baseDomain}`; + } + + static async create( + mnemonic: string, + cashuStore: CashuStore, + loggingEnabled = false, + network: "mainnet" | "testnet" = "testnet", + breezApiKey?: string, + cashuMeltThreshold?: number, + baseDomain?: string, + ) { + const wallet = new PortalBtcWallet( + mnemonic, + cashuStore, + loggingEnabled, + network, + breezApiKey, + cashuMeltThreshold, + baseDomain, + ); + await wallet.init(); + return wallet; + } + + private constructor( + private mnemonic: string, + cashuStore: CashuStore, + private loggingEnabled = false, + private network: "mainnet" | "testnet" = "testnet", + private breezApiKey = + "MIIBajCCARygAwIBAgIHPgwAQY4DlTAFBgMrZXAwEDEOMAwGA1UEAxMFQnJlZXowHhcNMjUwNTA1MTY1OTM5WhcNMzUwNTAzMTY1OTM5WjAnMQwwCgYDVQQKEwNBcngxFzAVBgNVBAMTDkRhbm55IE1vcmFiaXRvMCowBQYDK2VwAyEA0IP1y98gPByiIMoph1P0G6cctLb864rNXw1LRLOpXXejfjB8MA4GA1UdDwEB/wQEAwIFoDAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTaOaPuXmtLDTJVv++VYBiQr9gHCTAfBgNVHSMEGDAWgBTeqtaSVvON53SSFvxMtiCyayiYazAcBgNVHREEFTATgRFkYW5ueUBhcngtY2NuLmNvbTAFBgMrZXADQQAwJoh9BG8rEH1sOl+BpS12oNSwzgQga8ZcIAZ8Bjmd6QT4GSST0nLj06fs49pCkiULOl9ZoRIeIMc3M1XqV5UA", + private cashuMeltThreshold = 2000, + private baseDomain = "portalbtc.live", + ) { + this.cashuSDK = new PortalBtcWalletCashu( + mnemonic, + cashuStore, + ); + } + + private async init() { + await initBreez(); + this.breezSDK = await breezConnect({ + mnemonic: this.mnemonic, + config: defaultBreezConfig(this.network, this.breezApiKey), + }); + this.breezSDK.addEventListener({ + onEvent: (e) => { + if (this.loggingEnabled) { + console.log(e); + } + this.refreshBreezBalances(); + this.emitEvent("balanceUpdated"); + }, + }); + setLogger({ + log: (e: LogEntry) => { + if (this.loggingEnabled) { + console.log(`[Breez] [${e.level}] ${e.line}`); + } + }, + }); + await this.cashuSDK.init(() => this.emitEvent("balanceUpdated")); + await this.redeemCashuQuotes(); + await this.maybeMelt(); + } + + private async refreshBreezBalances() { + if (!this.breezSDK) throw new Error("Breez SDK not initialized"); + const info = await this.breezSDK.getInfo(); + this.balances.balance = info?.walletInfo.balanceSat ?? 0; + this.balances.pendingReceive = info?.walletInfo.pendingReceiveSat ?? 0; + this.balances.pendingSend = info?.walletInfo.pendingSendSat ?? 0; + } + + private async maybeMelt() { + if (!this.breezSDK) throw new Error("Breez SDK not initialized"); + if (this.cashuBalance < this.cashuMeltThreshold) return; + const cashuAmountToSwapToLiquid = this.cashuBalance - + (this.cashuBalance % this.cashuMeltThreshold); + const invoice = await this.generateBolt11Invoice(cashuAmountToSwapToLiquid); + await this.cashuSDK.meltProofsToPayInvoice(invoice); + this.emitEvent("balanceUpdated"); + } + + /** + * Redeems unredeemed Cashu quotes + * + * @returns Promise + */ + async redeemCashuQuotes() { + return this.cashuSDK.redeemCashuQuotes(); + } + + /** + * Generates a Lightning invoice + * + * @param amountSat - The amount to generate the invoice for + * @returns Promise - The Lightning invoice + */ + async generateBolt11Invoice(amountSat: number): Promise { + if (!this.breezSDK) throw new Error("Breez SDK not initialized"); + const prepare = await this.breezSDK?.prepareReceivePayment({ + paymentMethod: "bolt11Invoice", + amount: { type: "bitcoin", payerAmountSat: amountSat }, + amountSat: amountSat, + } as PrepareReceiveRequest); + const { destination } = await this.breezSDK.receivePayment({ + prepareResponse: prepare, + }); + return destination as string; + } + + /** + * Redeems a Cashu token + * + * @param token - The Cashu token to redeem + * @returns Promise + */ + async redeemToken(token: string): Promise { + await this.cashuSDK.redeemToken(token); + await this.maybeMelt(); + this.emitEvent("balanceUpdated"); + } + + /** + * Pays a Lightning invoice using Cashu + * + * @param invoice - The Lightning invoice to pay + * @returns Promise + */ + async payBolt11InvoiceUsingCashu(invoice: string): Promise { + if (!invoice.trim()) throw new Error("Invoice is empty"); + await this.cashuSDK.meltProofsToPayInvoice(invoice); + } + + private async *payBolt11Invoice( + parsedPayment: InputType, + destination: string, + amount: number, + ) { + if (!this.breezSDK) throw new Error("Breez SDK not initialized"); + if (parsedPayment.type !== "bolt11") { + throw new Error("Invalid payment type"); + } + + try { + yield { + status: PaymentStatus.AttemptingCashuPayment, + error: null, + }; + await this.payBolt11InvoiceUsingCashu(destination); + yield { + status: PaymentStatus.PaymentSent, + error: null, + paymentHash: parsedPayment.invoice.paymentHash, + }; + return; + } catch (cashuErr) { + yield { + status: PaymentStatus.CashuPaymentFailed, + error: cashuErr instanceof Error ? cashuErr.message : "Unknown error", + }; + } + yield { + status: PaymentStatus.AttemptingLightningPayment, + error: null, + }; + const prep = await this.breezSDK.prepareSendPayment({ + destination, + amount: amount > 0 + ? { type: "bitcoin", receiverAmountSat: amount } + : undefined, + }); + const res = await this.breezSDK.sendPayment({ prepareResponse: prep }); + const paymentDetails = res.payment.details; + if (paymentDetails.type === "lightning") { + yield { + status: PaymentStatus.PaymentSent, + error: null, + preimage: paymentDetails.preimage, + paymentHash: paymentDetails.paymentHash, + }; + } else { + yield { + status: PaymentStatus.PaymentSent, + error: null, + }; + } + } + + private async *payChainAddress( + parsedPayment: InputType, + amount: number, + ) { + if (!this.breezSDK) throw new Error("Breez SDK not initialized"); + if ( + parsedPayment.type !== "bitcoinAddress" && + parsedPayment.type !== "liquidAddress" + ) { + throw new Error("Invalid payment type"); + } + if (amount <= 0) { + yield { + status: PaymentStatus.AmountRequired, + error: "Amount required for bitcoin address", + }; + return; + } + + const onChainRequest: PreparePayOnchainRequest = { + amount: { type: "bitcoin", receiverAmountSat: amount }, + }; + + yield { + status: PaymentStatus.PreparingOnchainPayment, + error: null, + }; + const onChainPreparedResponse = await this.breezSDK.preparePayOnchain( + onChainRequest, + ); + + yield { + status: PaymentStatus.BroadcastingOnchainPayment, + error: null, + }; + await this.breezSDK.payOnchain({ + address: parsedPayment.address.address, + prepareResponse: onChainPreparedResponse, + }); + yield { + status: PaymentStatus.PaymentSent, + error: null, + }; + } + + /** + * Parses a payment destination + * + * @param destination - The payment destination + * @returns Promise - The parsed payment + */ + async parsePayment(destination: string): Promise { + if (!this.breezSDK) throw new Error("Breez SDK not initialized"); + const parsedPayment = await this.breezSDK.parse(destination); + if (!parsedPayment) throw new Error("Failed to parse payment"); + return parsedPayment; + } + + /** + * Pays a payment destination + * + * @param destination - The payment destination + * @param amount - The amount to pay + * @returns Promise - The payment status + */ + async *pay( + destination: string, + amount = 0, + ) { + if (!this.breezSDK) throw new Error("Breez SDK not initialized"); + yield { + status: PaymentStatus.ParsingDestination, + error: null, + }; + const parsedPayment = await this.parsePayment(destination); + try { + if (parsedPayment.type === "bolt11") { + yield* this.payBolt11Invoice(parsedPayment, destination, amount); + this.emitEvent("balanceUpdated"); + return; + } + + const paymentTypesThatRequireAmount = [ + "bitcoinAddress", + "liquidAddress", + "bolt12Offer", + "nodeId", + ]; + + if (paymentTypesThatRequireAmount.includes(parsedPayment.type)) { + if (amount <= 0) { + yield { + status: PaymentStatus.AmountRequired, + error: "Amount required for payment type", + }; + return; + } + } + + if ( + parsedPayment.type === "bitcoinAddress" || + parsedPayment.type === "liquidAddress" + ) { + yield* this.payChainAddress(parsedPayment, amount); + this.emitEvent("balanceUpdated"); + return; + } + + const paymentRequest: PrepareSendRequest = { destination }; + if (amount > 0) { + paymentRequest.amount = { + type: "bitcoin", + receiverAmountSat: amount, + }; + } + + yield { + status: PaymentStatus.PreparingOnchainPayment, + error: null, + }; + const prep = await this.breezSDK.prepareSendPayment( + paymentRequest, + ); + yield { + status: PaymentStatus.BroadcastingOnchainPayment, + error: null, + }; + await this.breezSDK.sendPayment({ prepareResponse: prep }); + yield { + status: PaymentStatus.PaymentSent, + error: null, + }; + this.emitEvent("balanceUpdated"); + return; + } catch (e: unknown) { + if (e instanceof Error) { + const raw = e.message; + const protoMatch = raw.match(/Generic error:.*?\(\"(.+?)\"\)/); + yield { + status: PaymentStatus.PaymentFailed, + error: protoMatch ? protoMatch[1] : raw, + }; + } else { + yield { + status: PaymentStatus.PaymentFailed, + error: "Send failed", + }; + } + } + } + + /** + * Lists payments + * + * @param limit - The number of payments to list + * @param offset - The offset to start listing from + * @returns Promise - The payments + */ + async listPayments(limit: number, offset: number) { + const allPayments: CombinedPayment[] = []; + const breezPayments = await this.breezSDK?.listPayments({}); + if (breezPayments) { + allPayments.push(...breezPayments); + } + allPayments.push(...this.cashuSDK.txns); + const sortedPayments = allPayments.sort((a, b) => + b.timestamp - a.timestamp + ); + return sortedPayments.slice(offset, offset + limit); + } + + private eventListeners: Record void)[]> = {}; + + /** + * Adds an event listener + * + * @param event - The event to listen for + * @param listener - The listener function + */ + addEventListener(event: "balanceUpdated", listener: () => void) { + if (!this.eventListeners[event]) { + this.eventListeners[event] = []; + } + this.eventListeners[event].push(listener); + } + + private emitEvent(event: "balanceUpdated") { + if (this.loggingEnabled) { + console.log(`[${event}]`); + } + for (const listener of this.eventListeners[event] ?? []) { + listener(); + } + } + + /** + * Refunds a payment + * + * @param payment - The payment to refund + */ + async refundPayment(payment: Payment) { + if (!this.breezSDK) throw new Error("Breez SDK not initialized"); + const prepareAddr = await this.breezSDK.prepareReceivePayment({ + paymentMethod: "bitcoinAddress", + }); + const receiveRes = await this.breezSDK.receivePayment({ + prepareResponse: prepareAddr, + }); + const refundAddress = receiveRes.destination as string; + + try { + const refundables = await this.breezSDK.listRefundables(); + let swapAddress: string | undefined; + if (refundables.length === 1) { + swapAddress = refundables[0]?.swapAddress; + } else { + swapAddress = refundables.find( + (r) => + r.amountSat === payment.amountSat && + Math.abs(r.timestamp - payment.timestamp) < 300, + )?.swapAddress; + } + + if (!swapAddress) { + throw new Error("Could not identify refundable swap for this payment."); + } + + const fees = await this.breezSDK.recommendedFees(); + const feeRateSatPerVbyte = fees.economyFee; + + const refundRequest = { + swapAddress, + refundAddress, + feeRateSatPerVbyte, + } as const; + + try { + await this.breezSDK.prepareRefund(refundRequest); + } catch (err) { + console.warn( + "prepareRefund failed (may be expected for some swaps)", + err, + ); + return; + } + + await this.breezSDK.refund(refundRequest); + } catch (err) { + console.error("Refund failed", err); + throw new Error( + `Refund failed: ${err instanceof Error ? err.message : err}`, + ); + } + } + + /** + * Generates a new address for a payment method + * + * @param paymentMethod - The payment method to generate an address for + * @returns Promise - The new address + */ + async generateAddress(paymentMethod: PaymentMethod) { + if (!this.breezSDK) throw new Error("Breez SDK not initialized"); + const prep = await this.breezSDK.prepareReceivePayment({ paymentMethod }); + const res = await this.breezSDK.receivePayment({ prepareResponse: prep }); + const rawDestination = res.destination; + + if (rawDestination === undefined) { + throw new Error("Failed to generate address: destination is undefined."); + } + + let destination = rawDestination; + + const colonIndex = destination.indexOf(":"); + if (colonIndex !== -1) { + destination = destination.substring(colonIndex + 1); + } + + const questionMarkIndex = destination.indexOf("?"); + if (questionMarkIndex !== -1) { + destination = destination.substring(0, questionMarkIndex); + } + return destination; + } +} diff --git a/npubCash.ts b/npubCash.ts new file mode 100644 index 0000000..3f4a96f --- /dev/null +++ b/npubCash.ts @@ -0,0 +1,124 @@ +import { + CashuMint, + CashuWallet, + MintQuoteState, + type Proof, +} from "@cashu/cashu-ts"; + +import { privateKeyFromSeedWords as nostrPrivateKeyFromSeedWords } from "nostr-tools/nip06"; +import { finalizeEvent, nip98 } from "nostr-tools"; +import type { BodyInit } from "bun"; + +interface NpubCashQuote { + createdAt: number; + paidAt?: number; + expiresAt: number; + mintUrl: string; + quoteId: string; + request: string; + amount: number; + state: "PAID" | "ISSUED" | "INFLIGHT"; + locked: boolean; +} + +interface NpubCashPaginationMetadata { + total: number; + limit: number; +} + +export default class NpubCash { + constructor(private mnemonic: string) {} + + private async fetchWithNip98( + { url, method, body }: { + url: string; + method: string; + body?: BodyInit | null; + }, + ): Promise { + const nostrPrivateKey = nostrPrivateKeyFromSeedWords(this.mnemonic); + const urlWithoutQueryParams = url.split("?")[0] ?? ""; + const npubCashNip98 = await nip98.getToken( + urlWithoutQueryParams, + method, + (event) => finalizeEvent(event, nostrPrivateKey), + ); + return fetch(url, { + method, + headers: { + "Authorization": `Nostr ${npubCashNip98}`, + }, + body, + }).then((data) => data.json() as Promise); + } + + private async getQuotes(since: number, limit: number, offset: number) { + return this.fetchWithNip98< + { + error: false; + data: { quotes: NpubCashQuote[] }; + metadata: NpubCashPaginationMetadata; + } | { + error: true; + message: string; + } + >({ + url: + `https://npubx.cash/api/v2/wallet/quotes?since=${since}&limit=${limit}&offset=${offset}`, + method: "GET", + }); + } + + private async getAllPaidQuotes(since: number) { + const quotes: NpubCashQuote[] = []; + let offset = 0; + while (true) { + const currentQuotes = await this.getQuotes(since, 50, offset); + if (currentQuotes.error === false) { + quotes.push(...currentQuotes.data.quotes); + if (quotes.length >= currentQuotes.metadata.total) { + break; + } + } else { + throw new Error(currentQuotes.message ?? "Unknown error"); + } + offset += 50; + } + return quotes.sort((a, b) => a.createdAt - b.createdAt).filter( + (quote) => quote.state === "PAID", + ); + } + + async *tryRedeemUnredeemedCashuQuotes( + latestRedeemedCashuQuoteTimestamp: number, + ) { + const proofs: Proof[] = []; + const quotes: NpubCashQuote[] = await this.getAllPaidQuotes( + latestRedeemedCashuQuoteTimestamp, + ); + for (const quote of quotes) { + const mint = new CashuMint(quote.mintUrl); + const wallet = new CashuWallet(mint); + const req = await mint.checkMintQuote(quote.quoteId); + if ( + req.state === MintQuoteState.PAID && typeof quote.paidAt === "number" + ) { + const newProofs = await wallet.mintProofs( + quote.amount, + quote.quoteId, + ); + proofs.push(...newProofs); + const amountReceived = newProofs.reduce( + (sum, p) => sum + p.amount, + 0, + ); + yield { + quoteId: quote.quoteId, + amountSat: amountReceived, + timestamp: quote.paidAt, + }; + } + } + return proofs; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5ae495f --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "portalbtc-lib", + "module": "index.ts", + "type": "module", + "version": "0.0.1", + "description": "Portal BTC Wallet", + "dependencies": { + "@breeztech/breez-sdk-liquid": "^0.9.2-rc2", + "@cashu/cashu-ts": "^2.5.2", + "nostr-tools": "^2.15.0" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.8.3" + } +} diff --git a/paymentStatus.ts b/paymentStatus.ts new file mode 100644 index 0000000..bd78b13 --- /dev/null +++ b/paymentStatus.ts @@ -0,0 +1,30 @@ +import type { Proof } from "@cashu/cashu-ts"; + +export const PaymentStatus = { + ParsingDestination: 0, + AttemptingCashuPayment: 1, + AttemptingLightningPayment: 2, + CashuPaymentFailed: 3, + AmountRequired: 4, + PreparingOnchainPayment: 5, + BroadcastingOnchainPayment: 6, + PaymentFailed: 0xfe, + PaymentSent: 0xff, +} as const; + +export interface CashuTxn { + txId: string; + paymentType: "receive" | "send"; + amountSat: number; + timestamp: number; + status: "complete"; +} + +export interface CashuStore { + getProofs(): Promise; + getTxns(): Promise; + getLastRedeemedCashuQuoteTimestamp(): Promise; + persistProofs(proofs: Proof[]): Promise; + persistTxns(txns: CashuTxn[]): Promise; + persistLastRedeemedCashuQuoteTimestamp(timestamp: number): Promise; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b595374 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "lib": [ + "ESNext" + ], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "allowJs": false, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}