clean code, and use newly created PortalBtcWallet library

This commit is contained in:
Danny Morabito 2025-07-09 18:51:42 +02:00
parent 9825c53c1c
commit 45dfe1a1c7
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
22 changed files with 503 additions and 1087 deletions

View file

@ -13,20 +13,19 @@
"lint": "prettier --check ." "lint": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@breeztech/breez-sdk-liquid": "^0.9.2-rc2",
"@cashu/cashu-ts": "^2.5.2",
"@libsql/client": "^0.15.9", "@libsql/client": "^0.15.9",
"@scure/base": "^1.2.6", "@scure/base": "^1.2.6",
"@scure/bip39": "^1.6.0", "@scure/bip39": "^1.6.0",
"@sveltejs/adapter-auto": "^6.0.1", "@sveltejs/adapter-auto": "^6.0.1",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"@types/qrcode": "^1.5.5",
"dexie": "^4.0.11", "dexie": "^4.0.11",
"iconify-icon": "^3.0.0", "iconify-icon": "^3.0.0",
"nostr-tools": "^2.15.0", "nostr-tools": "^2.15.0",
"portalbtc-lib": "git+ssh://git@git.arx-ccn.com:222/Arx/PortalBTCLib.git#2d858727b05f9d66e4f028c086d93cebf769f3b8",
"qrcode": "^1.5.4" "qrcode": "^1.5.4"
}, },
"devDependencies": { "devDependencies": {
"@types/qrcode": "^1.5.5",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",

View file

@ -1,59 +0,0 @@
import { getMnemonic } from "$lib/wallet.svelte";
import initBreez, {
type BindingLiquidSdk,
connect as breezConnect,
defaultConfig as defaultBreezConfig,
} from "@breeztech/breez-sdk-liquid/web";
initBreez();
let breezSDK: BindingLiquidSdk;
let initialized = false;
export async function getBreezSDK() {
if (initialized) return breezSDK;
const mnemonic = await getMnemonic();
breezSDK = await breezConnect({
mnemonic,
config: defaultBreezConfig(
"mainnet",
"MIIBajCCARygAwIBAgIHPgwAQY4DlTAFBgMrZXAwEDEOMAwGA1UEAxMFQnJlZXowHhcNMjUwNTA1MTY1OTM5WhcNMzUwNTAzMTY1OTM5WjAnMQwwCgYDVQQKEwNBcngxFzAVBgNVBAMTDkRhbm55IE1vcmFiaXRvMCowBQYDK2VwAyEA0IP1y98gPByiIMoph1P0G6cctLb864rNXw1LRLOpXXejfjB8MA4GA1UdDwEB/wQEAwIFoDAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTaOaPuXmtLDTJVv++VYBiQr9gHCTAfBgNVHSMEGDAWgBTeqtaSVvON53SSFvxMtiCyayiYazAcBgNVHREEFTATgRFkYW5ueUBhcngtY2NuLmNvbTAFBgMrZXADQQAwJoh9BG8rEH1sOl+BpS12oNSwzgQga8ZcIAZ8Bjmd6QT4GSST0nLj06fs49pCkiULOl9ZoRIeIMc3M1XqV5UA",
), // this key can be shared, it's fine
});
breezSDK.addEventListener({
onEvent: (e) => {
console.log(e);
refreshBalances();
},
});
initialized = true;
return breezSDK;
}
export async function initBalances() {
const sdk = await getBreezSDK();
const info = await sdk.getInfo();
balances.balance = info.walletInfo.balanceSat;
balances.pendingReceive = info.walletInfo.pendingReceiveSat;
balances.pendingSend = info.walletInfo.pendingSendSat;
}
export const balances = $state({
balance: 0,
pendingReceive: 0,
pendingSend: 0,
tick: 0,
});
export async function refreshBalances() {
const sdk = await getBreezSDK();
const info = await sdk.getInfo();
balances.balance = info.walletInfo.balanceSat;
balances.pendingReceive = info.walletInfo.pendingReceiveSat;
balances.pendingSend = info.walletInfo.pendingSendSat;
balances.tick++;
}

View file

@ -1,393 +0,0 @@
import {
CashuMint,
CashuWallet,
getDecodedToken,
MintQuoteState,
type Proof,
} from "@cashu/cashu-ts";
import { get, writable } from "svelte/store";
import { getMnemonic } from "$lib/wallet.svelte";
import { privateKeyFromSeedWords as nostrPrivateKeyFromSeedWords } from "nostr-tools/nip06";
import { getBreezSDK } from "$lib/breez.svelte";
import { finalizeEvent, getPublicKey, nip19, nip98 } from "nostr-tools";
import Dexie, { type Table } from "dexie";
import { BASE_DOMAIN } from "$lib/config";
interface MetaEntry {
key: string;
value: string;
}
interface CashuTxn {
txId: string;
paymentType: "receive" | "send";
amountSat: number;
timestamp: number;
status: "complete";
}
class CashuDB extends Dexie {
proofs!: Table<Proof, string>;
meta!: Table<MetaEntry, string>;
txns!: Table<CashuTxn, string>;
constructor() {
super("cashu");
this.version(1).stores({
proofs: "&secret",
meta: "&key",
});
this.version(2).stores({
txns: "&txId",
});
}
}
export const cashuDB = new CashuDB();
export type { CashuTxn };
const MINT_URL = "https://mint.minibits.cash/Bitcoin";
interface CashuState {
balance: number;
meltThreshold: number;
lastUpdated: number;
}
export const cashuState = writable<CashuState>({
balance: 0,
meltThreshold: 2000,
lastUpdated: 0,
});
let proofs: Proof[] = [];
async function loadProofs() {
proofs = await cashuDB.proofs.toArray();
updateBalance();
}
async function persistProofs() {
await cashuDB.proofs.clear();
if (proofs.length) await cashuDB.proofs.bulkPut(proofs);
}
async function fetchWithNip98<T>(
{ url, method, body }: {
url: string;
method: string;
body?: BodyInit | null;
},
): Promise<T> {
const mnemonic = await getMnemonic();
const nostrPrivateKey = nostrPrivateKeyFromSeedWords(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());
}
export async function getNpub() {
const mnemonic = await getMnemonic();
const nostrPrivateKey = nostrPrivateKeyFromSeedWords(mnemonic);
const nostrPubkey = getPublicKey(nostrPrivateKey);
return nip19.npubEncode(nostrPubkey);
}
export async function getCashuAddress() {
return `${await getNpub()}@${BASE_DOMAIN}`;
}
interface NpubCashQuote {
createdAt: number;
paidAt?: number;
expiresAt: number;
mintUrl: string;
quoteId: string;
request: string;
amount: number;
state: "PAID" | "ISSUED" | "INFLIGHT";
locked: boolean;
}
interface PaginationMetadata {
total: number;
limit: number;
}
async function tryRedeemUnredeemedCashuQuotes() {
const lastRedeemedCashuQuoteTimestamp =
localStorage.getItem("lastRedeemedCashuQuoteTimestamp") || 0;
let quotes: NpubCashQuote[] = [];
while (true) {
const currentQuotes = await fetchWithNip98<
{
error: false;
data: { quotes: NpubCashQuote[] };
metadata: PaginationMetadata;
} | {
error: true;
message: string;
}
>({
url:
`https://npubx.cash/api/v2/wallet/quotes?since=${lastRedeemedCashuQuoteTimestamp}&limit=50&offset=${quotes.length}`,
method: "GET",
});
if (currentQuotes.error === false) {
quotes.push(...currentQuotes.data.quotes);
if (quotes.length >= currentQuotes.metadata.total) {
break;
}
} else {
throw new Error(currentQuotes.message);
}
}
quotes = quotes.sort((a, b) => a.createdAt - b.createdAt);
for (const quote of quotes) {
if (quote.state === "PAID") {
const mint = new CashuMint(quote.mintUrl);
const wallet = new CashuWallet(mint);
const req = await mint.checkMintQuote(quote.quoteId);
if (req.state === MintQuoteState.PAID && quote.paidAt) {
const newProofs = await wallet.mintProofs(quote.amount, quote.quoteId);
proofs.push(...newProofs);
await persistProofs();
const amountReceived = newProofs.reduce((sum, p) => sum + p.amount, 0);
cashuTxns.update((txs) => {
txs.push({
txId: `cashu-quote-${quote.quoteId}`,
paymentType: "receive",
amountSat: amountReceived,
timestamp: quote.paidAt
? Math.floor(quote.paidAt)
: Math.floor(Date.now() / 1000),
status: "complete",
});
return txs;
});
persistCashuTxns();
updateBalance();
localStorage.setItem(
"lastRedeemedCashuQuoteTimestamp",
quote.paidAt.toString(),
);
}
}
}
return quotes;
}
let wallet: CashuWallet | null = null;
let walletPromise: Promise<CashuWallet> | null = null;
export async function getWallet(): Promise<CashuWallet> {
if (wallet) return wallet;
if (walletPromise) return walletPromise;
walletPromise = (async () => {
try {
await loadProofs();
try {
await getMnemonic();
} catch (_) {}
const mint = new CashuMint(MINT_URL);
const w = new CashuWallet(mint);
await w.loadMint();
tryRedeemUnredeemedCashuQuotes()
.then(() => persistProofs())
.catch((err) => console.error("Failed background redemption", err));
getCashuAddress()
.then((addr) => console.log(`cashu addr: ${addr}`))
.catch(() => {});
wallet = w;
return w;
} catch (err) {
walletPromise = null;
throw err;
} finally {
setTimeout(() => {
if (wallet) walletPromise = null;
}, 0);
}
})();
return walletPromise;
}
let quoteRedeemInterval: ReturnType<typeof setInterval> | undefined;
if (quoteRedeemInterval) clearInterval(quoteRedeemInterval);
quoteRedeemInterval = setInterval(
() => tryRedeemUnredeemedCashuQuotes().then(maybeMelt),
1000 * 5,
);
function updateBalance() {
const balance = proofs.reduce((sum, p) => sum + p.amount, 0);
cashuState.update((s) => ({ ...s, balance, lastUpdated: s.lastUpdated + 1 }));
}
export async function redeemToken(token: string): Promise<void> {
if (!token.trim()) throw new Error("Token is empty");
const parsedToken = getDecodedToken(token);
const mint = new CashuMint(parsedToken.mint);
const wallet = new CashuWallet(mint);
const received = await wallet.receive(token.trim());
proofs.push(...received);
const amountReceived = received.reduce((sum, p) => sum + p.amount, 0);
cashuTxns.update((txs) => {
txs.push({
txId: `cashu-receive-${Date.now()}`,
paymentType: "receive",
amountSat: amountReceived,
timestamp: Math.floor(Date.now() / 1000),
status: "complete",
});
return txs;
});
persistCashuTxns();
updateBalance();
await persistProofs();
await maybeMelt();
}
type PrepareReceiveRequest = {
paymentMethod: "bolt11Invoice";
amountSat: number;
};
async function createMeltInvoice(amountSat: number): Promise<string> {
const breezSDK = await getBreezSDK();
const prepare = await breezSDK.prepareReceivePayment({
paymentMethod: "bolt11Invoice",
amount: { type: "bitcoin", payerAmountSat: amountSat },
amountSat: amountSat,
} as PrepareReceiveRequest);
const { destination } = await breezSDK.receivePayment({
prepareResponse: prepare,
});
return destination as string;
}
let tryingToMelt = false;
async function maybeMelt() {
if (tryingToMelt) return;
tryingToMelt = true;
const { balance, meltThreshold } = get(cashuState);
if (balance < meltThreshold) return;
try {
const wallet = await getWallet();
const invoice = await createMeltInvoice(balance - (balance % 2000));
const meltQuote = await wallet.createMeltQuote(invoice);
const amountToMelt = meltQuote.amount + meltQuote.fee_reserve;
const { keep, send } = await wallet.send(amountToMelt, proofs, {
includeFees: true,
});
proofs = keep;
await persistProofs();
const { change } = await wallet.meltProofs(meltQuote, send);
proofs.push(...change);
await persistProofs();
cashuTxns.update((txs) => {
txs.push({
txId: `cashu-send-${Date.now()}`,
paymentType: "send",
amountSat: amountToMelt,
timestamp: Math.floor(Date.now() / 1000),
status: "complete",
});
return txs;
});
persistCashuTxns();
updateBalance();
} catch (err) {
console.error("Failed to melt Cashu balance", err);
}
tryingToMelt = false;
}
export const cashuTxns = writable<CashuTxn[]>([]);
async function loadCashuTxns() {
try {
const txns = await cashuDB.txns.toArray();
cashuTxns.set(txns);
} catch (err) {
console.error("Failed to load Cashu txns", err);
}
}
async function persistCashuTxns() {
try {
const txns = get(cashuTxns);
await cashuDB.txns.clear();
if (txns.length) await cashuDB.txns.bulkPut(txns);
} catch (err) {
console.error("Failed to persist Cashu txns", err);
}
}
loadCashuTxns();
export async function payBolt11Invoice(invoice: string): Promise<void> {
if (!invoice.trim()) throw new Error("Invoice is empty");
const wallet = await getWallet();
const meltQuote = await wallet.createMeltQuote(invoice);
const amountToMelt = meltQuote.amount + meltQuote.fee_reserve;
const { balance } = get(cashuState);
if (amountToMelt > balance) {
throw new Error("Insufficient Cashu balance");
}
const { keep, send } = await wallet.send(amountToMelt, proofs, {
includeFees: true,
});
proofs = keep;
await persistProofs();
const { change } = await wallet.meltProofs(meltQuote, send);
proofs.push(...change);
await persistProofs();
cashuTxns.update((txs) => {
txs.push({
txId: `cashu-send-${Date.now()}`,
paymentType: "send",
amountSat: amountToMelt,
timestamp: Math.floor(Date.now() / 1000),
status: "complete",
});
return txs;
});
persistCashuTxns();
updateBalance();
}

View file

@ -1,15 +1,21 @@
<script lang="ts"> <script lang="ts">
import { satsComma } from "$lib"; import { satsComma } from "$lib";
import { cashuState } from "$lib/cashu.svelte"; import { walletState } from "$lib/wallet.svelte";
import LoadingIndicator from "./LoadingIndicator.svelte";
const { balance = 0, pending = 0 }: { balance: number; pending: number } = let loading = $derived(!$walletState.open);
$props(); let balance = $derived(
$walletState.open ? $walletState.balance - $walletState.cashuBalance : 0
);
let pending = $derived($walletState.open ? $walletState.pendingBalance : 0);
let cashuBalance = $derived(
$walletState.open ? $walletState.cashuBalance : 0
);
const formattedTotal = $derived(satsComma(balance + $cashuState.balance)); const formattedTotal = $derived(satsComma(balance + cashuBalance));
const formattedPending = $derived(satsComma(pending)); const formattedPending = $derived(satsComma(pending));
const formattedLightning = $derived(satsComma(balance)); const formattedLightning = $derived(satsComma(balance));
const formattedCashu = $derived(satsComma($cashuState.balance)); const formattedCashu = $derived(satsComma(cashuBalance));
const cashuThreshold = $derived(satsComma($cashuState.meltThreshold));
let showSplit = $state(false); let showSplit = $state(false);
let containerEl: HTMLButtonElement; let containerEl: HTMLButtonElement;
@ -38,24 +44,48 @@
aria-label="Toggle balance split" aria-label="Toggle balance split"
> >
<div class="balance-label">Total Balance</div> <div class="balance-label">Total Balance</div>
<div class="balance-value">{formattedTotal} sats</div> <div class="balance-value">
{#if loading}
<LoadingIndicator />
{:else}
{formattedTotal} sats
{/if}
</div>
<div class="pending-label">Pending</div> <div class="pending-label">Pending</div>
<div class="pending-value">{formattedPending} sats</div> <div class="pending-value">
{#if loading}
<LoadingIndicator />
{:else}
{formattedPending} sats
{/if}
</div>
{#if showSplit} {#if showSplit}
<div class="popover"> <div class="popover">
<div class="split-row lightning"> <div class="split-row lightning">
<iconify-icon icon="icon-park-twotone:lightning" width="32" height="32" <iconify-icon icon="icon-park-twotone:lightning" width="32" height="32"
></iconify-icon> ></iconify-icon>
<span class="label">Lightning</span> <span class="label">Lightning</span>
<span class="amount">{formattedLightning} sats</span> <span class="amount">
{#if loading}
<LoadingIndicator />
{:else}
{formattedLightning} sats
{/if}
</span>
</div> </div>
<div class="split-row cashu"> <div class="split-row cashu">
<iconify-icon icon="tdesign:nut-filled" width="32" height="32" <iconify-icon icon="tdesign:nut-filled" width="32" height="32"
></iconify-icon> ></iconify-icon>
<span class="label">Cashu</span> <span class="label">Cashu</span>
<span class="amount">{formattedCashu} sats</span> <span class="amount">
{#if loading}
<LoadingIndicator />
{:else}
{formattedCashu} sats
{/if}
</span>
</div> </div>
<div class="threshold">Auto-melt at {cashuThreshold} sats</div> <div class="threshold">Auto-melt at 2000 sats</div>
</div> </div>
{/if} {/if}
</button> </button>

View file

@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import Button from "$lib/components/Button.svelte"; import Button from "$lib/components/Button.svelte";
import { redeemToken } from "$lib/cashu.svelte"; import { walletState } from "$lib/wallet.svelte";
let tokenInput = $state(""); let tokenInput = $state("");
function redeem() { function redeem() {
redeemToken(tokenInput).catch((e) => alert(e.message)); if (!$walletState.open) return;
$walletState.wallet.redeemToken(tokenInput).catch((e) => alert(e.message));
tokenInput = ""; tokenInput = "";
} }
</script> </script>

View file

@ -1,26 +1,16 @@
<script lang="ts"> <script lang="ts">
import Button from "./Button.svelte"; import Button from "./Button.svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { getMnemonic } from "$lib/wallet.svelte"; import { walletState } from "$lib/wallet.svelte";
import { onMount } from "svelte";
let seedPhrase = $state("");
let showSeedPhrase = $state(false); let showSeedPhrase = $state(false);
onMount(async () => {
try {
seedPhrase = await getMnemonic();
} catch (err) {
console.error("Failed to get mnemonic:", err);
}
});
function toggleSeedPhrase() { function toggleSeedPhrase() {
showSeedPhrase = !showSeedPhrase; showSeedPhrase = !showSeedPhrase;
} }
function copySeedPhrase() { function copySeedPhrase() {
navigator.clipboard.writeText(seedPhrase); navigator.clipboard.writeText($walletState.mnemonic!);
} }
function resetWebsite() { function resetWebsite() {
@ -57,7 +47,7 @@
{#if showSeedPhrase} {#if showSeedPhrase}
<div class="seed-phrase"> <div class="seed-phrase">
{seedPhrase} {$walletState.mnemonic!}
</div> </div>
{/if} {/if}
</div> </div>

View file

@ -2,7 +2,7 @@
import Button from "./Button.svelte"; import Button from "./Button.svelte";
import { BASE_DOMAIN } from "$lib/config"; import { BASE_DOMAIN } from "$lib/config";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getNpub } from "$lib/cashu.svelte"; import { walletState } from "$lib/wallet.svelte";
let username = $state(""); let username = $state("");
let existingUsername = $state(""); let existingUsername = $state("");
@ -17,7 +17,8 @@
async function checkExistingUsername() { async function checkExistingUsername() {
try { try {
const userNpub = await getNpub(); if (!$walletState.open) return;
const userNpub = $walletState.wallet.npub;
const response = await fetch( const response = await fetch(
`/api/check-username?npub=${encodeURIComponent(userNpub)}` `/api/check-username?npub=${encodeURIComponent(userNpub)}`
); );
@ -45,7 +46,8 @@
registrationStatus = ""; registrationStatus = "";
try { try {
const userNpub = await getNpub(); if (!$walletState.open) return;
const userNpub = $walletState.wallet.npub;
const response = await fetch( const response = await fetch(
`/.well-known/lnurlp/${encodeURIComponent(username)}`, `/.well-known/lnurlp/${encodeURIComponent(username)}`,
{ {

View file

@ -0,0 +1,33 @@
<script lang="ts">
let { size = 32 }: { size?: number } = $props();
</script>
<div class="loading-indicator">
<div class="loading-indicator-inner" style:--size={size}></div>
</div>
<style>
.loading-indicator {
display: inline-flex;
justify-content: center;
align-items: center;
}
.loading-indicator-inner {
border: 8px solid var(--primary-color);
border-top: 8px solid var(--accent-color);
border-radius: 50%;
width: var(--size);
height: var(--size);
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View file

@ -1,26 +1,24 @@
<script lang="ts"> <script lang="ts">
import { decryptMnemonic } from "$lib/wallet.svelte"; import {
decryptMnemonic,
hasMnemonic,
MNEMONIC_KEY,
openWallet,
} from "$lib/wallet.svelte";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { onMount } from "svelte";
let { let { onunlock }: { onunlock: () => void } = $props();
open = $bindable(),
unlock,
}: { open: boolean; unlock: (pw: string) => void } = $props();
let dialogEl: HTMLDialogElement | null = $state(null); let dialogEl: HTMLDialogElement | null = $state(null);
let password = $state(""); let password = $state("");
let error = $state(""); let error = $state("");
let isValidating = $state(false); let isValidating = $state(false);
$effect(() => { onMount(() => {
if (open) dialogEl?.showModal(); dialogEl?.showModal();
else dialogEl?.close();
}); });
function closeDialog() {
open = false;
}
async function attemptUnlock() { async function attemptUnlock() {
if (!password.trim()) return; if (!password.trim()) return;
@ -29,55 +27,52 @@
try { try {
if (!browser) throw new Error("Not in browser"); if (!browser) throw new Error("Not in browser");
const encryptedSeed = localStorage.getItem("seed"); if (!hasMnemonic()) throw new Error("No encrypted seed found");
if (!encryptedSeed) throw new Error("No encrypted seed found"); const encryptedMnemonic = localStorage.getItem(MNEMONIC_KEY)!;
await decryptMnemonic(encryptedSeed, password); const mnemonic = await decryptMnemonic(encryptedMnemonic, password);
unlock(password); await openWallet(mnemonic);
password = ""; onunlock();
error = ""; dialogEl?.close();
} catch (err) { } catch (err) {
error = "Incorrect password"; error = "Incorrect password";
console.error("Password validation failed:", err); console.error("Password validation failed:", err);
} finally {
isValidating = false; isValidating = false;
} }
} }
</script> </script>
{#if open} <dialog bind:this={dialogEl}>
<dialog bind:this={dialogEl} onclose={closeDialog}> <h2>Unlock Wallet</h2>
<h2>Unlock Wallet</h2> <p>Enter your wallet password to decrypt your seed.</p>
<p>Enter your wallet password to decrypt your seed.</p> {#if error}
{#if error} <p
<p style="color: var(--error-color); font-size: 0.7rem; margin-bottom: 1rem;"
style="color: var(--error-color); font-size: 0.7rem; margin-bottom: 1rem;" >
> {error}
{error} </p>
</p> {/if}
{/if} <input
<input type="password"
type="password" class="retro-input"
class="retro-input" bind:value={password}
bind:value={password} placeholder="Password"
placeholder="Password" disabled={isValidating}
onkeydown={(e) => {
if (e.key === "Enter" && !isValidating) {
attemptUnlock();
}
}}
/>
<div style="margin-top:1rem;text-align:center;">
<button
class="retro-btn primary"
disabled={isValidating} disabled={isValidating}
onkeydown={(e) => { onclick={() => {
if (e.key === "Enter" && !isValidating) { attemptUnlock();
attemptUnlock(); }}>{isValidating ? "Validating..." : "Unlock"}</button
} >
}} </div>
/> </dialog>
<div style="margin-top:1rem;text-align:center;">
<button
class="retro-btn primary"
disabled={isValidating}
onclick={() => {
attemptUnlock();
}}>{isValidating ? "Validating..." : "Unlock"}</button
>
</div>
</dialog>
{/if}
<style> <style>
dialog { dialog {

View file

@ -1,42 +1,27 @@
<script lang="ts"> <script lang="ts">
import { balances, getBreezSDK } from "$lib/breez.svelte";
import PaymentHistoryItem from "$lib/components/PaymentHistoryItem.svelte"; import PaymentHistoryItem from "$lib/components/PaymentHistoryItem.svelte";
import type { import LoadingIndicator from "$lib/components/LoadingIndicator.svelte";
BindingLiquidSdk, import { walletState } from "$lib/wallet.svelte";
Payment,
} from "@breeztech/breez-sdk-liquid/web";
import { cashuTxns } from "$lib/cashu.svelte";
let payments = $state<Payment[]>([]); let payments = $derived(
$walletState.open
const combinedPayments = $derived( ? $walletState.wallet?.listPayments(100, 0)
[...payments, ...$cashuTxns].sort((a, b) => b.timestamp - a.timestamp) : Promise.resolve([])
); );
async function loadPayments() {
try {
const breezSDK = await getBreezSDK();
const result = await breezSDK.listPayments({});
payments = result;
} catch (err) {
console.error("Failed to load payments", err);
}
}
$effect(() => {
balances.tick;
loadPayments();
});
</script> </script>
<div class="payment-history"> <div class="payment-history">
{#if combinedPayments.length === 0} {#await payments}
<p class="empty">No payments yet.</p> <LoadingIndicator />
{:else} {:then payments}
{#each combinedPayments as payment (payment.txId)} {#if payments.length === 0}
<PaymentHistoryItem {payment} /> <p class="empty">No payments yet.</p>
{/each} {:else}
{/if} {#each payments as payment (payment.txId)}
<PaymentHistoryItem {payment} />
{/each}
{/if}
{/await}
</div> </div>
<style> <style>

View file

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { Payment } from "@breeztech/breez-sdk-liquid/web"; import type { Payment } from "@breeztech/breez-sdk-liquid/web";
import { satsComma } from "$lib"; import { satsComma } from "$lib";
import { getBreezSDK } from "$lib/breez.svelte";
import Button from "$lib/components/Button.svelte"; import Button from "$lib/components/Button.svelte";
import { walletState } from "$lib/wallet.svelte";
interface CashuPayment { interface CashuPayment {
txId: string; txId: string;
@ -29,60 +29,15 @@
let isRefunding = $state(false); let isRefunding = $state(false);
async function refundPayment() { async function refundPayment() {
if (!$walletState.open) return;
if (isRefunding) return; if (isRefunding) return;
isRefunding = true; isRefunding = true;
const breezSDK = await getBreezSDK(); if (payment.paymentType !== "send") return;
const prepareAddr = await breezSDK.prepareReceivePayment({ if (payment.txId?.startsWith("cashu-")) return;
paymentMethod: "bitcoinAddress",
});
const receiveRes = await breezSDK.receivePayment({
prepareResponse: prepareAddr,
});
const refundAddress = receiveRes.destination as string;
try { try {
const refundables = await breezSDK.listRefundables(); await $walletState.wallet.refundPayment(payment);
let swapAddress: string | undefined; } catch (e) {
if (refundables.length === 1) { console.error(e);
swapAddress = refundables[0].swapAddress;
} else {
swapAddress = refundables.find(
(r) =>
r.amountSat === payment.amountSat &&
Math.abs(r.timestamp - payment.timestamp) < 300
)?.swapAddress;
}
if (!swapAddress) {
alert("Could not identify refundable swap for this payment.");
return;
}
const fees = await breezSDK.recommendedFees();
const feeRateSatPerVbyte = fees.economyFee;
const refundRequest = {
swapAddress,
refundAddress,
feeRateSatPerVbyte,
} as const;
try {
await breezSDK.prepareRefund(refundRequest);
} catch (err) {
console.warn(
"prepareRefund failed (may be expected for some swaps)",
err
);
}
await breezSDK.refund(refundRequest);
alert(
"Refund transaction broadcasted. It may take a moment to appear in history."
);
} catch (err) {
console.error("Refund failed", err);
alert(`Refund failed: ${err instanceof Error ? err.message : err}`);
} finally { } finally {
isRefunding = false; isRefunding = false;
} }
@ -105,7 +60,7 @@
</a> </a>
{:else if (payment as Payment).details.type === "lightning"} {:else if (payment as Payment).details.type === "lightning"}
<a <a
href="https://liquid.network/tx/{payment.details.claimTxId}" href="https://liquid.network/tx/{payment.claimTxId}"
target="_blank" target="_blank"
> >
...{payment.txId?.slice(-16)} ...{payment.txId?.slice(-16)}

View file

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import Button from "$lib/components/Button.svelte";
import Tabs from "$lib/components/Tabs.svelte"; import Tabs from "$lib/components/Tabs.svelte";
import CashuTab from "$lib/components/CashuTab.svelte"; import CashuTab from "$lib/components/CashuTab.svelte";
import ReceiveTab from "$lib/components/ReceiveTab.svelte"; import ReceiveTab from "$lib/components/ReceiveTab.svelte";
import { walletState } from "$lib/wallet.svelte";
let { open = $bindable() }: { open: boolean } = $props(); let { open = $bindable() }: { open: boolean } = $props();
@ -23,27 +23,29 @@
<div class="content"> <div class="content">
<h3>Receive</h3> <h3>Receive</h3>
<Tabs labels={["Bitcoin", "Lightning", "Liquid", "Cashu"]}> {#if $walletState.open}
{#snippet children(idx)} <Tabs labels={["Bitcoin", "Lightning", "Liquid", "Cashu"]}>
{#if idx === 0} {#snippet children(idx)}
<ReceiveTab type="bitcoin" alt="Bitcoin Address" /> {#if idx === 0}
{:else if idx === 1} <ReceiveTab type="bitcoin" />
<Tabs labels={["Address", "BOLT12"]}> {:else if idx === 1}
{#snippet children(idx)} <Tabs labels={["Address", "BOLT12"]}>
{#if idx === 0} {#snippet children(idx)}
<ReceiveTab type="lightning" alt="Lightning Address" /> {#if idx === 0}
{:else} <ReceiveTab type="lightning" />
<ReceiveTab type="bolt12" alt="Lightning Address" /> {:else}
{/if} <ReceiveTab type="bolt12" />
{/snippet} {/if}
</Tabs> {/snippet}
{:else if idx === 2} </Tabs>
<ReceiveTab type="liquid" alt="Liquid Address" /> {:else if idx === 2}
{:else} <ReceiveTab type="liquid" />
<CashuTab /> {:else}
{/if} <CashuTab />
{/snippet} {/if}
</Tabs> {/snippet}
</Tabs>
{/if}
</div> </div>
</dialog> </dialog>

View file

@ -1,54 +1,47 @@
<script lang="ts"> <script lang="ts">
import { getBreezSDK } from "$lib/breez.svelte"; import { walletState } from "$lib/wallet.svelte";
import { getCashuAddress } from "$lib/cashu.svelte";
import Button from "$lib/components/Button.svelte"; import Button from "$lib/components/Button.svelte";
import QRCode from "qrcode"; import QRCode from "qrcode";
import { onMount } from "svelte";
import LoadingIndicator from "$lib/components/LoadingIndicator.svelte";
const { type, alt }: { type: string; alt: string } = $props(); const { type }: { type: string } = $props();
const paymentMethod = $derived( let paymentText = $state("");
type === "bitcoin" const loading = $derived(paymentText === "");
? "bitcoinAddress"
: type === "bolt12"
? "bolt12Offer"
: type === "lightning"
? "bolt11Invoice"
: "liquidAddress"
);
const paymentText = $derived(
paymentMethod === "bolt11Invoice"
? getCashuAddress()
: getBreezSDK().then((breezSDK) =>
breezSDK.prepareReceivePayment({ paymentMethod }).then((prep) =>
breezSDK.receivePayment({ prepareResponse: prep }).then((res) => {
let destination = res.destination;
if (destination.includes(":"))
destination = destination.split(":")[1];
if (destination.includes("?"))
destination = destination.split("?")[0];
return destination;
})
)
)
);
const qr = $derived( const qr = $derived(
paymentText.then((text) => QRCode.toDataURL(`${type}:${paymentText}`, {
QRCode.toDataURL(`${type}:${text}`, { errorCorrectionLevel: "H",
errorCorrectionLevel: "H", })
})
)
); );
onMount(async () => {
if (!$walletState.open) return;
const paymentMethod =
type === "bitcoin"
? "bitcoinAddress"
: type === "bolt12"
? "bolt12Offer"
: type === "lightning"
? "bolt11Invoice"
: "liquidAddress";
if (paymentMethod === "bolt11Invoice") {
paymentText = $walletState.wallet.lightningAddress;
} else {
paymentText = await $walletState.wallet.generateAddress(paymentMethod);
}
});
</script> </script>
{#await qr} {#if !loading}
<div class="spinner"></div> {#await qr}
{:then qrDataUrl} <LoadingIndicator size={120} />
<img class="qr" src={qrDataUrl} alt="${alt}" /> {:then qrDataUrl}
{/await} <img class="qr" src={qrDataUrl} alt={paymentText} />
{/await}
{#await paymentText then paymentText}
<pre class="addr">{paymentText}</pre> <pre class="addr">{paymentText}</pre>
<Button <Button
class="copy-btn" class="copy-btn"
@ -56,7 +49,9 @@
> >
Copy Copy
</Button> </Button>
{/await} {:else}
<LoadingIndicator />
{/if}
<style> <style>
img.qr { img.qr {

View file

@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { getBreezSDK } from "$lib/breez.svelte";
import { send as sendPayment } from "$lib/payments";
import Button from "$lib/components/Button.svelte"; import Button from "$lib/components/Button.svelte";
import type { InputType } from "@breeztech/breez-sdk-liquid/web"; import type { InputType } from "@breeztech/breez-sdk-liquid/web";
import { walletState } from "$lib/wallet.svelte";
import { PaymentStatus } from "portalbtc-lib";
let { open = $bindable(), onsent }: { open: boolean; onsent: () => void } = let { open = $bindable() }: { open: boolean } = $props();
$props();
let destination = $state(""); let destination = $state("");
@ -21,8 +20,9 @@
return; return;
} }
try { try {
const breezSDK = await getBreezSDK(); if (!$walletState.open) return;
const parsed: InputType = await breezSDK.parse(destination); const parsed: InputType =
await $walletState.wallet.parsePayment(destination);
if ( if (
parsed.type === "bolt11" && parsed.type === "bolt11" &&
parsed.invoice.amountMsat !== undefined && parsed.invoice.amountMsat !== undefined &&
@ -42,16 +42,51 @@
let dialogEl: HTMLDialogElement; let dialogEl: HTMLDialogElement;
async function handleSend() { async function handleSend() {
const sendGenerator = sendPayment(destination, Number(amountSat) || 0); if (!$walletState.open) throw new Error("Wallet not open");
const sendGenerator = $walletState.wallet.pay(
destination,
Number(amountSat) || 0
);
for await (const { for await (const {
status: sendStatus, status: sendStatus,
error: sendError, error: sendError,
} of sendGenerator) { } of sendGenerator) {
if (sendError) return alert(sendError); if (sendError && sendStatus !== PaymentStatus.CashuPaymentFailed)
status = sendStatus || ""; return alert(sendError);
switch (sendStatus) {
case PaymentStatus.ParsingDestination:
status = "Parsing destination...";
break;
case PaymentStatus.AttemptingCashuPayment:
status = "Attempting cashu payment...";
break;
case PaymentStatus.AttemptingLightningPayment:
status = "Attempting lightning payment...";
break;
case PaymentStatus.CashuPaymentFailed:
status = "Cashu payment failed";
break;
case PaymentStatus.AmountRequired:
status = "Amount required";
break;
case PaymentStatus.PreparingOnchainPayment:
status = "Preparing onchain payment...";
break;
case PaymentStatus.BroadcastingOnchainPayment:
status = "Broadcasting onchain payment...";
break;
case PaymentStatus.PaymentFailed:
status = "Payment failed";
break;
case PaymentStatus.PaymentSent:
status = "Payment sent";
break;
default:
status = "Unknown status";
break;
}
} }
onsent();
setTimeout(closeDialog, 1500); setTimeout(closeDialog, 1500);
} }

View file

@ -1,7 +1,10 @@
import { MAX_SATS_RECEIVE, MIN_SATS_RECEIVE } from "$lib/config"; import { MAX_SATS_RECEIVE, MIN_SATS_RECEIVE } from "$lib/config";
import { getDb } from "$lib/database"; import { getDb } from "$lib/database";
import { bech32 } from "@scure/base"; import { bech32 } from "@scure/base";
import type { Dexie, Table } from "dexie";
import { type Writable, writable } from "svelte/store";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { browser } from "$app/environment";
export function satsComma(sats: number) { export function satsComma(sats: number) {
const chars = sats.toString().split("").reverse(); const chars = sats.toString().split("").reverse();
@ -74,3 +77,60 @@ export async function getUser(username: string) {
); );
return result.rows[0]; return result.rows[0];
} }
export function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
}
export interface MetaEntry {
key: string;
value: string;
}
export function persistentDbWritable<T, DB extends Dexie>(
key: string,
initial: T,
db: DB & { meta: Table<MetaEntry, string> },
): Writable<T> {
const store = writable<T>(initial);
if (browser) {
(async () => {
try {
const entry = await db.meta.get(key);
if (entry) {
store.set(JSON.parse(entry.value));
} else {
const raw = localStorage.getItem(key);
if (raw !== null) {
store.set(JSON.parse(raw));
await db.meta.put({ key, value: raw });
localStorage.removeItem(key);
}
}
} catch (err) {
console.error(`NWC store load error for ${key}`, err);
}
})();
store.subscribe(async (value) => {
try {
await db.meta.put({ key, value: JSON.stringify(value) });
} catch (err) {
console.error(`NWC store persist error for ${key}`, err);
}
});
}
return store;
}

View file

@ -4,23 +4,19 @@ import {
generateSecretKey, generateSecretKey,
getPublicKey, getPublicKey,
nip04, nip04,
nip19,
SimplePool, SimplePool,
verifyEvent, verifyEvent,
} from "nostr-tools"; } from "nostr-tools";
import type { Event as NostrEvent } from "nostr-tools"; import type { Event as NostrEvent } from "nostr-tools";
import { derived, get, type Writable, writable } from "svelte/store"; import { derived, get } from "svelte/store";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { getBreezSDK, refreshBalances } from "$lib/breez.svelte";
import type { BindingLiquidSdk } from "@breeztech/breez-sdk-liquid/web";
import { import {
bytesToHex, bytesToHex,
hexToBytes, hexToBytes,
type MetaEntry, type MetaEntry,
persistentDbWritable, persistentDbWritable,
} from "./utils"; } from "$lib";
import { cashuDB, cashuState, getNpub } from "./cashu.svelte"; import { walletState } from "$lib/wallet.svelte";
import { send } from "./payments";
interface PaidInvoice { interface PaidInvoice {
paymentHash: string; paymentHash: string;
@ -81,8 +77,7 @@ interface JsonRpcReq {
| "get_balance" | "get_balance"
| "list_transactions" | "list_transactions"
| "get_info" | "get_info"
| "make_invoice" | "make_invoice";
| "lookup_invoice";
params: Record<string, unknown>; params: Record<string, unknown>;
} }
@ -161,7 +156,6 @@ async function connect(): Promise<void> {
if (!maybePriv) throw new Error("NWC privkey missing"); if (!maybePriv) throw new Error("NWC privkey missing");
const privkey = maybePriv; const privkey = maybePriv;
const pubkey = getPublicKey(hexToBytes(privkey)); const pubkey = getPublicKey(hexToBytes(privkey));
const breezSDK = await getBreezSDK();
pool = new SimplePool(); pool = new SimplePool();
sub = pool.subscribeMany(relays, [ sub = pool.subscribeMany(relays, [
@ -171,7 +165,7 @@ async function connect(): Promise<void> {
}, },
], { ], {
onevent: async (evt: NostrEvent) => { onevent: async (evt: NostrEvent) => {
await handleEvent(evt, privkey, pubkey, relays, breezSDK); await handleEvent(evt, privkey, pubkey, relays);
}, },
}); });
} }
@ -181,7 +175,6 @@ async function handleEvent(
privkey: string, privkey: string,
ourPubkey: string, ourPubkey: string,
relays: string[], relays: string[],
breezSDK: BindingLiquidSdk,
): Promise<void> { ): Promise<void> {
try { try {
if (!verifyEvent(evt)) return; if (!verifyEvent(evt)) return;
@ -231,6 +224,8 @@ async function handleEvent(
async function processRpc( async function processRpc(
req: JsonRpcReq, req: JsonRpcReq,
): Promise<unknown> { ): Promise<unknown> {
const currentWalletState = get(walletState);
if (!currentWalletState.open) throw new Error("Wallet not open");
console.log("processRpc", req); console.log("processRpc", req);
switch (req.method) { switch (req.method) {
case "pay_invoice": { case "pay_invoice": {
@ -239,102 +234,51 @@ async function processRpc(
throw { code: "INTERNAL", message: "missing_invoice" }; throw { code: "INTERNAL", message: "missing_invoice" };
} }
const breezSDK = await getBreezSDK(); if (await nwcDB.paidInvoices.get(destination)) {
const parsed = await breezSDK.parse(destination); throw { code: "OTHER", message: "invoice_already_paid" };
if (parsed.type !== "bolt11") {
throw { code: "INTERNAL", message: "not a bolt11 invoice" };
}
const paymentHash = (parsed as any).invoice.paymentHash;
const existing = await nwcDB.paidInvoices.get(paymentHash);
if (existing) {
throw { code: "OTHER", message: "invoice already paid" };
} }
const sender = send(destination, 0, parsed); const sender = currentWalletState.wallet.pay(destination, 0);
let res = await sender.next(); let res;
while (!res.done) { try {
res = await sender.next(); res = await sender.next();
while (!res.done) {
res = await sender.next();
}
} catch (err) {
throw { code: "PAYMENT_FAILED", message: (err as Error).message };
} }
if (res.value.error) { await nwcDB.paidInvoices.put({
throw { code: "PAYMENT_FAILED", message: res.value.error }; paymentHash: destination,
} timestamp: Math.floor(Date.now() / 1000),
}).catch((err) => {
console.error("Failed to save paid invoice", err);
});
if (res.value.paymentHash) { return {};
await nwcDB.paidInvoices.put({ // TODO: check if it works without the preimage and paymentHash
paymentHash: res.value.paymentHash, // return {
timestamp: Math.floor(Date.now() / 1000), // preimage: res.value.preimage,
}); // paymentHash: res.value.paymentHash,
} // };
await refreshBalances();
return {
preimage: res.value.preimage,
paymentHash: res.value.paymentHash,
};
} }
case "get_balance": { case "get_balance": {
const breezSDK = await getBreezSDK();
const info = await breezSDK.getInfo();
return { return {
balance: info.walletInfo.balanceSat * 1000 + get(cashuState).balance * balance: currentWalletState.balance * 1000,
1000,
}; };
} }
case "list_transactions": { case "list_transactions": {
const from_timestamp =
(req.params?.from_timestamp as number | undefined) ?? 0;
const to_timestamp = (req.params?.to_timestamp as number | undefined) ??
undefined;
const limit = (req.params?.limit as number | undefined) ?? 20; const limit = (req.params?.limit as number | undefined) ?? 20;
const offset = (req.params?.offset as number | undefined) ?? undefined; const offset = (req.params?.offset as number | undefined) ?? 0;
const breezSDK = await getBreezSDK(); const transactions = await currentWalletState.wallet.listPayments(
const liquidPayments = (await breezSDK.listPayments({
fromTimestamp: from_timestamp,
toTimestamp: to_timestamp,
limit, limit,
offset, offset,
})) as any[]; );
return { transactions };
const cashuTxns = await cashuDB.txns.toArray();
const allTxns = [
...liquidPayments.map((p) => ({
type: p.paymentType,
invoice: p.bolt11,
description: p.description,
payment_hash: p.paymentHash,
preimage: p.paymentPreimage,
amount: p.amountSat * 1000,
fees_paid: p.feesSat * 1000,
created_at: Math.floor(p.paymentTime / 1000),
settled_at: p.status === "complete"
? Math.floor(p.paymentTime / 1000)
: undefined,
})),
...cashuTxns.map((p) => ({
type: p.paymentType,
invoice: "",
description: "Cashu",
payment_hash: p.txId,
preimage: "",
amount: p.amountSat * 1000,
fees_paid: 0,
created_at: p.timestamp,
settled_at: p.timestamp,
})),
].sort((a, b) => b.created_at - a.created_at);
return {
transactions: allTxns,
};
} }
case "get_info": { case "get_info": {
const breezSDK = await getBreezSDK();
const info = await breezSDK.getInfo();
const privkey = get(nwcPrivkey); const privkey = get(nwcPrivkey);
if (!privkey) throw new Error("missing_privkey"); if (!privkey) throw new Error("missing_privkey");
@ -349,7 +293,6 @@ async function processRpc(
"list_transactions", "list_transactions",
"get_info", "get_info",
"make_invoice", "make_invoice",
"lookup_invoice",
], ],
notifications: [], notifications: [],
}; };
@ -361,7 +304,9 @@ async function processRpc(
throw { code: "INTERNAL", message: "missing_amount" }; throw { code: "INTERNAL", message: "missing_amount" };
} }
const npub = await getNpub(); const npub = currentWalletState.wallet.npub;
if (!npub) throw new Error("Wallet not open");
const res = await fetch( const res = await fetch(
`/.well-known/lnurl-pay/callback/${npub}?amount=${amountMsat}`, `/.well-known/lnurl-pay/callback/${npub}?amount=${amountMsat}`,
); );
@ -385,35 +330,6 @@ async function processRpc(
invoice, invoice,
}; };
} }
case "lookup_invoice": {
const paymentHash = req.params?.payment_hash as string | undefined;
if (!paymentHash) {
throw { code: "INTERNAL", message: "missing_payment_hash" };
}
const breezSDK = await getBreezSDK();
const payments = (await breezSDK.listPayments({})) as any[];
const payment = payments.find((p) => p.paymentHash === paymentHash);
if (!payment) {
throw { code: "NOT_FOUND", message: "invoice_not_found" };
}
return {
type: payment.paymentType,
invoice: payment.bolt11,
description: payment.description,
payment_hash: payment.paymentHash,
preimage: payment.paymentPreimage,
amount: payment.amountSat * 1000,
fees_paid: payment.feesSat * 1000,
created_at: Math.floor(payment.paymentTime / 1000),
settled_at: payment.status === "complete"
? Math.floor(payment.paymentTime / 1000)
: undefined,
};
}
default: default:
throw { code: "NOT_IMPLEMENTED", message: "unknown_method" }; throw { code: "NOT_IMPLEMENTED", message: "unknown_method" };
} }

View file

@ -1,120 +0,0 @@
import { getBreezSDK } from "$lib/breez.svelte";
import { payBolt11Invoice as payBolt11InvoiceUsingCashu } from "$lib/cashu.svelte";
import type {
InputType,
PreparePayOnchainRequest,
PreparePayOnchainResponse,
PrepareSendRequest,
PrepareSendResponse,
} from "@breeztech/breez-sdk-liquid/web";
export async function* send(
destination: string,
amount = 0,
parsed?: InputType,
) {
try {
yield {
status: "Parsing destination…",
error: null,
};
const breezSDK = await getBreezSDK();
const parsedInvoice: InputType = parsed ??
await breezSDK.parse(destination);
if (parsedInvoice.type === "bolt11") {
try {
yield {
status: "Attempting Cashu payment…",
error: null,
};
await payBolt11InvoiceUsingCashu(destination);
return {
status: "✅ Payment sent",
error: null,
preimage: undefined,
paymentHash: (parsedInvoice as any).invoice.paymentHash,
};
} catch (cashuErr) {
console.warn("Cashu payment failed, falling back to Breez", cashuErr);
}
}
if (parsedInvoice.type === "bitcoinAddress") {
if (amount <= 0) {
return {
status: null,
error: "Amount required for bitcoin address",
};
}
const req: PreparePayOnchainRequest = {
amount: { type: "bitcoin", receiverAmountSat: amount },
};
yield {
status: "Preparing on-chain payment…",
error: null,
};
const prep: PreparePayOnchainResponse = await breezSDK.preparePayOnchain(
req,
);
yield {
status: "Broadcasting…",
error: null,
};
await breezSDK.payOnchain({
address: parsedInvoice.address.address,
prepareResponse: prep,
});
} else {
const req: PrepareSendRequest = { destination };
if (amount > 0) {
req.amount = {
type: "bitcoin",
receiverAmountSat: amount,
};
}
yield {
status: "Preparing…",
error: null,
};
const prep: PrepareSendResponse = await breezSDK.prepareSendPayment(req);
yield {
status: "Sending…",
error: null,
};
const res = await breezSDK.sendPayment({ prepareResponse: prep });
return {
status: "✅ Payment sent",
error: null,
preimage: (res.payment as any)?.paymentPreimage,
paymentHash: (res.payment as any)?.paymentHash,
};
}
return {
status: "✅ Payment sent",
error: null,
preimage: undefined,
paymentHash: undefined,
};
} catch (e: unknown) {
console.error("Send failed", e);
if (e instanceof Error) {
const raw = e.message;
const protoMatch = raw.match(/Generic error:.*?\(\"(.+?)\"\)/);
return {
status: null,
error: protoMatch ? protoMatch[1] : raw,
};
}
return {
status: null,
error: "Send failed",
};
}
}

View file

@ -1,60 +0,0 @@
import { browser } from "$app/environment";
import type { Dexie, Table } from "dexie";
import { type Writable, writable } from "svelte/store";
export function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
}
export interface MetaEntry {
key: string;
value: string;
}
export function persistentDbWritable<T, DB extends Dexie>(
key: string,
initial: T,
db: DB & { meta: Table<MetaEntry, string> },
): Writable<T> {
const store = writable<T>(initial);
if (browser) {
(async () => {
try {
const entry = await db.meta.get(key);
if (entry) {
store.set(JSON.parse(entry.value));
} else {
const raw = localStorage.getItem(key);
if (raw !== null) {
store.set(JSON.parse(raw));
await db.meta.put({ key, value: raw });
localStorage.removeItem(key);
}
}
} catch (err) {
console.error(`NWC store load error for ${key}`, err);
}
})();
store.subscribe(async (value) => {
try {
await db.meta.put({ key, value: JSON.stringify(value) });
} catch (err) {
console.error(`NWC store persist error for ${key}`, err);
}
});
}
return store;
}

View file

@ -5,38 +5,85 @@ import {
decrypt as nip49decrypt, decrypt as nip49decrypt,
encrypt as nip49encrypt, encrypt as nip49encrypt,
} from "nostr-tools/nip49"; } from "nostr-tools/nip49";
import type { Proof } from "@cashu/cashu-ts";
import Dexie from "dexie";
import type { Table } from "dexie";
import PortalBtcWallet, { type CashuStore } from "portalbtc-lib";
import { writable } from "svelte/store";
const SEED_KEY = "seed"; interface MetaEntry {
let cachedMnemonic = $state<{ mnemonic: string | undefined }>({ key: string;
mnemonic: undefined, value: string;
}); }
export const passwordRequest = $state({ interface CashuTxn {
pending: false, txId: string;
resolve: undefined as ((password: string) => void) | undefined, paymentType: "receive" | "send";
reject: undefined as ((error: Error) => void) | undefined, amountSat: number;
}); timestamp: number;
status: "complete";
}
let previouslyInputPassword = $state<{ pass: string | undefined }>({ class CashuDB extends Dexie {
pass: undefined, proofs!: Table<Proof, string>;
}); meta!: Table<MetaEntry, string>;
txns!: Table<CashuTxn, string>;
function requestPassword(): Promise<string> { constructor() {
if (!browser) throw new Error("Password input only available in browser"); super("cashu");
if (previouslyInputPassword.pass) { this.version(1).stores({
return Promise.resolve(previouslyInputPassword.pass); proofs: "&secret",
meta: "&key",
});
this.version(2).stores({
txns: "&txId",
});
}
}
export class DexieBasedCashuStore implements CashuStore {
private readonly db: CashuDB = new CashuDB();
async getProofs() {
return this.db.proofs.toArray();
} }
return new Promise((resolve, reject) => { async getTxns() {
passwordRequest.pending = true; return this.db.txns.toArray();
passwordRequest.resolve = (pw) => { }
previouslyInputPassword.pass = pw;
resolve(pw); async getLastRedeemedCashuQuoteTimestamp() {
}; const meta = await this.db.meta.get("lastRedeemedCashuQuoteTimestamp");
passwordRequest.reject = reject; return meta?.value ? parseInt(meta.value) : 0;
}); }
async setLastRedeemedCashuQuoteTimestamp(timestamp: number) {
await this.db.meta.put({
key: "lastRedeemedCashuQuoteTimestamp",
value: timestamp.toString(),
});
}
async persistProofs(proofs: Proof[]) {
await this.db.proofs.clear();
if (proofs.length) await this.db.proofs.bulkPut(proofs);
}
async persistTxns(txns: CashuTxn[]) {
await this.db.txns.clear();
if (txns.length) await this.db.txns.bulkPut(txns);
}
async persistLastRedeemedCashuQuoteTimestamp(timestamp: number) {
await this.db.meta.put({
key: "lastRedeemedCashuQuoteTimestamp",
value: timestamp.toString(),
});
}
} }
export const MNEMONIC_KEY = "seed";
export function createMnemonic(): string { export function createMnemonic(): string {
return generateMnemonic(wordlist, 128); return generateMnemonic(wordlist, 128);
} }
@ -65,26 +112,62 @@ export async function decryptMnemonic(
return new TextDecoder().decode(decryptedBytes); return new TextDecoder().decode(decryptedBytes);
} }
export function storeEncryptedSeed(encrypted: string) { export function storeEncryptedMnemonic(encrypted: string) {
if (browser) localStorage.setItem(SEED_KEY, encrypted); if (browser) localStorage.setItem(MNEMONIC_KEY, encrypted);
} }
export function hasSeed(): boolean { export function hasMnemonic(): boolean {
if (!browser) return false; if (!browser) return false;
return localStorage.getItem(SEED_KEY) !== null; return localStorage.getItem(MNEMONIC_KEY) !== null;
} }
export async function getMnemonic(): Promise<string> { export const walletState = writable<
if (cachedMnemonic.mnemonic) return cachedMnemonic.mnemonic; | {
if (!browser) throw new Error("Not in browser"); open: false;
const enc = localStorage.getItem(SEED_KEY); wallet: undefined;
if (!enc) throw new Error("Seed not initialised"); mnemonic: undefined;
const password = await requestPassword(); balance: number;
const mnemonic = await decryptMnemonic(enc, password); pendingBalance: number;
cachedMnemonic.mnemonic = mnemonic; cashuBalance: number;
return mnemonic; }
} | {
open: true;
mnemonic: string;
wallet: PortalBtcWallet;
balance: number;
pendingBalance: number;
cashuBalance: number;
}
>({
open: false,
wallet: undefined,
mnemonic: undefined,
balance: 0,
pendingBalance: 0,
cashuBalance: 0,
});
export function cacheMnemonic(mnemonic: string) { export async function openWallet(mnemonic: string) {
cachedMnemonic.mnemonic = mnemonic; const wallet = await PortalBtcWallet.create(
mnemonic,
new DexieBasedCashuStore(),
false,
"mainnet",
);
wallet.addEventListener("balanceUpdated", () => {
walletState.update((state) => ({
...state,
balance: wallet.balance,
pendingBalance: wallet.pendingBalance,
cashuBalance: wallet.cashuBalance,
}));
});
walletState.set({
open: true,
mnemonic,
wallet,
balance: wallet.balance,
pendingBalance: wallet.pendingBalance,
cashuBalance: wallet.cashuBalance,
});
} }

View file

@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import "$lib/style.css"; import "$lib/style.css";
import "iconify-icon"; import "iconify-icon";
import { hasSeed, passwordRequest } from "$lib/wallet.svelte"; import { hasMnemonic, walletState } from "$lib/wallet.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import PasswordDialog from "$lib/components/PasswordDialog.svelte"; import PasswordDialog from "$lib/components/PasswordDialog.svelte";
import { page } from "$app/stores"; import { page } from "$app/state";
import { start as startNwc } from "$lib/nwc.svelte"; import { start as startNwc } from "$lib/nwc.svelte";
import SplashScreen from "$lib/components/SplashScreen.svelte"; import SplashScreen from "$lib/components/SplashScreen.svelte";
import ErrorDialog from "$lib/components/ErrorDialog.svelte"; import ErrorDialog from "$lib/components/ErrorDialog.svelte";
@ -16,7 +16,7 @@
stack?: string; stack?: string;
}; };
let passwordDialogOpen = $state(false); let { children } = $props();
let showSplash = $state(true); let showSplash = $state(true);
let currentError = $state<AppError | null>(null); let currentError = $state<AppError | null>(null);
let showInstallPrompt = $state(false); let showInstallPrompt = $state(false);
@ -33,32 +33,15 @@
} }
} }
function handleUnlockAttempt(pw: string) {
if (passwordRequest.resolve) {
passwordRequest.resolve(pw);
passwordRequest.resolve = undefined;
passwordRequest.reject = undefined;
passwordRequest.pending = false;
startNwc();
}
passwordDialogOpen = false;
}
function goToSettings() { function goToSettings() {
goto("/settings"); goto("/settings");
} }
const { children } = $props(); const isOnSetup = $derived(page.route.id?.startsWith("/setup"));
const isOnSettings = $derived(page.route.id?.startsWith("/settings"));
$effect(() => { const showSettingsButton = $derived(
if (passwordRequest.pending && !passwordDialogOpen) { !isOnSetup && !isOnSettings && hasMnemonic()
passwordDialogOpen = true; );
}
});
const isOnSetup = $derived($page.route.id?.startsWith("/setup"));
const isOnSettings = $derived($page.route.id?.startsWith("/settings"));
const showSettingsButton = $derived(!isOnSetup && !isOnSettings && hasSeed());
onMount(() => { onMount(() => {
setTimeout(() => { setTimeout(() => {
@ -96,7 +79,7 @@
} }
}); });
if (!hasSeed() && !isOnSetup) { if (!hasMnemonic() && !isOnSetup) {
goto("/setup"); goto("/setup");
} }
}); });
@ -121,12 +104,14 @@
</div> </div>
{/if} {/if}
{#if !isOnSetup || !passwordRequest.pending} {#if isOnSetup || $walletState.open}
{@render children()} {@render children()}
{/if} {/if}
</main> </main>
<PasswordDialog bind:open={passwordDialogOpen} unlock={handleUnlockAttempt} /> {#if !$walletState.open && !isOnSetup}
<PasswordDialog onunlock={() => startNwc()} />
{/if}
{/if} {/if}
<ErrorDialog error={currentError} onclose={() => (currentError = null)} /> <ErrorDialog error={currentError} onclose={() => (currentError = null)} />

View file

@ -1,24 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { BindingLiquidSdk } from "@breeztech/breez-sdk-liquid/web";
import { balances, getBreezSDK, initBalances } from "$lib/breez.svelte";
import BalanceDisplay from "$lib/components/BalanceDisplay.svelte"; import BalanceDisplay from "$lib/components/BalanceDisplay.svelte";
import PaymentHistory from "$lib/components/PaymentHistory.svelte"; import PaymentHistory from "$lib/components/PaymentHistory.svelte";
import ReceiveDialog from "$lib/components/ReceiveDialog.svelte"; import ReceiveDialog from "$lib/components/ReceiveDialog.svelte";
import SendDialog from "$lib/components/SendDialog.svelte"; import SendDialog from "$lib/components/SendDialog.svelte";
import Button from "$lib/components/Button.svelte"; import Button from "$lib/components/Button.svelte";
import { refreshBalances } from "$lib/breez.svelte";
import { onMount } from "svelte";
let breezSDK = $state<BindingLiquidSdk | undefined>(undefined);
let receiveDialogOpen = $state(false); let receiveDialogOpen = $state(false);
let sendDialogOpen = $state(false); let sendDialogOpen = $state(false);
onMount(async () => {
initBalances();
breezSDK = await getBreezSDK();
});
function openReceiveDialog() { function openReceiveDialog() {
receiveDialogOpen = true; receiveDialogOpen = true;
} }
@ -26,20 +15,13 @@
function openSendDialog() { function openSendDialog() {
sendDialogOpen = true; sendDialogOpen = true;
} }
function onPaymentSent() {
refreshBalances();
}
</script> </script>
<ReceiveDialog bind:open={receiveDialogOpen} /> <ReceiveDialog bind:open={receiveDialogOpen} />
<SendDialog bind:open={sendDialogOpen} onsent={onPaymentSent} /> <SendDialog bind:open={sendDialogOpen} />
<div class="container"> <div class="container">
<BalanceDisplay <BalanceDisplay />
balance={balances.balance}
pending={balances.pendingReceive - balances.pendingSend}
/>
<div class="retro-card send-receive-buttons"> <div class="retro-card send-receive-buttons">
<Button variant="primary" onclick={openReceiveDialog}>Receive</Button> <Button variant="primary" onclick={openReceiveDialog}>Receive</Button>
<Button variant="danger" onclick={openSendDialog}>Send</Button> <Button variant="danger" onclick={openSendDialog}>Send</Button>

View file

@ -4,9 +4,9 @@
import { import {
createMnemonic, createMnemonic,
encryptMnemonic, encryptMnemonic,
storeEncryptedSeed, storeEncryptedMnemonic,
cacheMnemonic,
isValidMnemonic, isValidMnemonic,
openWallet,
} from "$lib/wallet.svelte"; } from "$lib/wallet.svelte";
import { start as startNwc } from "$lib/nwc.svelte"; import { start as startNwc } from "$lib/nwc.svelte";
@ -77,8 +77,8 @@
return; return;
} }
const encrypted = await encryptMnemonic(userMnemonic, password); const encrypted = await encryptMnemonic(userMnemonic, password);
storeEncryptedSeed(encrypted); storeEncryptedMnemonic(encrypted);
cacheMnemonic(userMnemonic); await openWallet(userMnemonic);
startNwc(); startNwc();