Initial Commit

This commit is contained in:
Danny Morabito 2025-07-09 12:45:59 +02:00
commit 2d858727b0
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
10 changed files with 1128 additions and 0 deletions

34
.gitignore vendored Normal file
View file

@ -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

15
README.md Normal file
View file

@ -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.

76
bun.lock Normal file
View file

@ -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=="],
}
}

107
cashu.ts Normal file
View file

@ -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<void> {
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?.();
}
}

172
examples/portalbtc-cli.ts Normal file
View file

@ -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 <destination> [amountSat]
// bun run examples/portalbtc-cli.ts redeem-token <cashu-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<Proof[]> {
return this.data.proofs;
}
async getTxns(): Promise<CashuTxn[]> {
return this.data.txns;
}
async getLastRedeemedCashuQuoteTimestamp(): Promise<number> {
return this.data.lastRedeemedCashuQuoteTimestamp;
}
async persistProofs(proofs: Proof[]): Promise<void> {
this.data.proofs = proofs;
this.flush();
}
async persistTxns(txns: CashuTxn[]): Promise<void> {
this.data.txns = txns;
this.flush();
}
async persistLastRedeemedCashuQuoteTimestamp(
timestamp: number,
): Promise<void> {
this.data.lastRedeemedCashuQuoteTimestamp = timestamp;
this.flush();
}
}
function usage() {
console.log(`Usage:
balance Show wallet balances (on-chain + Cashu)
invoice <amountSat> Generate a Lightning invoice
pay <destination> [amountSat] Pay a destination (invoice / LNURL / address)
redeem-token <cashu-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);
});

531
index.ts Normal file
View file

@ -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<Proof[]>
*/
async redeemCashuQuotes() {
return this.cashuSDK.redeemCashuQuotes();
}
/**
* Generates a Lightning invoice
*
* @param amountSat - The amount to generate the invoice for
* @returns Promise<string> - The Lightning invoice
*/
async generateBolt11Invoice(amountSat: number): Promise<string> {
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<void>
*/
async redeemToken(token: string): Promise<void> {
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<void>
*/
async payBolt11InvoiceUsingCashu(invoice: string): Promise<void> {
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<InputType> - The parsed payment
*/
async parsePayment(destination: string): Promise<InputType> {
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<void> - 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<Payment[]> - 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<string, (() => 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<string> - 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;
}
}

124
npubCash.ts Normal file
View file

@ -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<T>(
{ url, method, body }: {
url: string;
method: string;
body?: BodyInit | null;
},
): Promise<T> {
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<T>);
}
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;
}
}

16
package.json Normal file
View file

@ -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"
}
}

30
paymentStatus.ts Normal file
View file

@ -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<Proof[]>;
getTxns(): Promise<CashuTxn[]>;
getLastRedeemedCashuQuoteTimestamp(): Promise<number>;
persistProofs(proofs: Proof[]): Promise<void>;
persistTxns(txns: CashuTxn[]): Promise<void>;
persistLastRedeemedCashuQuoteTimestamp(timestamp: number): Promise<void>;
}

23
tsconfig.json Normal file
View file

@ -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
}
}