Initial Commit
This commit is contained in:
commit
2d858727b0
10 changed files with 1128 additions and 0 deletions
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal 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
15
README.md
Normal 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
76
bun.lock
Normal 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
107
cashu.ts
Normal 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
172
examples/portalbtc-cli.ts
Normal 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
531
index.ts
Normal 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
124
npubCash.ts
Normal 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
16
package.json
Normal 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
30
paymentStatus.ts
Normal 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
23
tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue