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 ."
},
"dependencies": {
"@breeztech/breez-sdk-liquid": "^0.9.2-rc2",
"@cashu/cashu-ts": "^2.5.2",
"@libsql/client": "^0.15.9",
"@scure/base": "^1.2.6",
"@scure/bip39": "^1.6.0",
"@sveltejs/adapter-auto": "^6.0.1",
"@sveltejs/adapter-node": "^5.2.12",
"@types/qrcode": "^1.5.5",
"dexie": "^4.0.11",
"iconify-icon": "^3.0.0",
"nostr-tools": "^2.15.0",
"portalbtc-lib": "git+ssh://git@git.arx-ccn.com:222/Arx/PortalBTCLib.git#2d858727b05f9d66e4f028c086d93cebf769f3b8",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@types/qrcode": "^1.5.5",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"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">
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 } =
$props();
let loading = $derived(!$walletState.open);
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 formattedLightning = $derived(satsComma(balance));
const formattedCashu = $derived(satsComma($cashuState.balance));
const cashuThreshold = $derived(satsComma($cashuState.meltThreshold));
const formattedCashu = $derived(satsComma(cashuBalance));
let showSplit = $state(false);
let containerEl: HTMLButtonElement;
@ -38,24 +44,48 @@
aria-label="Toggle balance split"
>
<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-value">{formattedPending} sats</div>
<div class="pending-value">
{#if loading}
<LoadingIndicator />
{:else}
{formattedPending} sats
{/if}
</div>
{#if showSplit}
<div class="popover">
<div class="split-row lightning">
<iconify-icon icon="icon-park-twotone:lightning" width="32" height="32"
></iconify-icon>
<span class="label">Lightning</span>
<span class="amount">{formattedLightning} sats</span>
<span class="amount">
{#if loading}
<LoadingIndicator />
{:else}
{formattedLightning} sats
{/if}
</span>
</div>
<div class="split-row cashu">
<iconify-icon icon="tdesign:nut-filled" width="32" height="32"
></iconify-icon>
<span class="label">Cashu</span>
<span class="amount">{formattedCashu} sats</span>
<span class="amount">
{#if loading}
<LoadingIndicator />
{:else}
{formattedCashu} sats
{/if}
</span>
</div>
<div class="threshold">Auto-melt at {cashuThreshold} sats</div>
<div class="threshold">Auto-melt at 2000 sats</div>
</div>
{/if}
</button>

View file

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

View file

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

View file

@ -2,7 +2,7 @@
import Button from "./Button.svelte";
import { BASE_DOMAIN } from "$lib/config";
import { onMount } from "svelte";
import { getNpub } from "$lib/cashu.svelte";
import { walletState } from "$lib/wallet.svelte";
let username = $state("");
let existingUsername = $state("");
@ -17,7 +17,8 @@
async function checkExistingUsername() {
try {
const userNpub = await getNpub();
if (!$walletState.open) return;
const userNpub = $walletState.wallet.npub;
const response = await fetch(
`/api/check-username?npub=${encodeURIComponent(userNpub)}`
);
@ -45,7 +46,8 @@
registrationStatus = "";
try {
const userNpub = await getNpub();
if (!$walletState.open) return;
const userNpub = $walletState.wallet.npub;
const response = await fetch(
`/.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">
import { decryptMnemonic } from "$lib/wallet.svelte";
import {
decryptMnemonic,
hasMnemonic,
MNEMONIC_KEY,
openWallet,
} from "$lib/wallet.svelte";
import { browser } from "$app/environment";
import { onMount } from "svelte";
let {
open = $bindable(),
unlock,
}: { open: boolean; unlock: (pw: string) => void } = $props();
let { onunlock }: { onunlock: () => void } = $props();
let dialogEl: HTMLDialogElement | null = $state(null);
let password = $state("");
let error = $state("");
let isValidating = $state(false);
$effect(() => {
if (open) dialogEl?.showModal();
else dialogEl?.close();
onMount(() => {
dialogEl?.showModal();
});
function closeDialog() {
open = false;
}
async function attemptUnlock() {
if (!password.trim()) return;
@ -29,23 +27,21 @@
try {
if (!browser) throw new Error("Not in browser");
const encryptedSeed = localStorage.getItem("seed");
if (!encryptedSeed) throw new Error("No encrypted seed found");
await decryptMnemonic(encryptedSeed, password);
unlock(password);
password = "";
error = "";
if (!hasMnemonic()) throw new Error("No encrypted seed found");
const encryptedMnemonic = localStorage.getItem(MNEMONIC_KEY)!;
const mnemonic = await decryptMnemonic(encryptedMnemonic, password);
await openWallet(mnemonic);
onunlock();
dialogEl?.close();
} catch (err) {
error = "Incorrect password";
console.error("Password validation failed:", err);
} finally {
isValidating = false;
}
}
</script>
{#if open}
<dialog bind:this={dialogEl} onclose={closeDialog}>
<dialog bind:this={dialogEl}>
<h2>Unlock Wallet</h2>
<p>Enter your wallet password to decrypt your seed.</p>
{#if error}
@ -77,7 +73,6 @@
>
</div>
</dialog>
{/if}
<style>
dialog {

View file

@ -1,42 +1,27 @@
<script lang="ts">
import { balances, getBreezSDK } from "$lib/breez.svelte";
import PaymentHistoryItem from "$lib/components/PaymentHistoryItem.svelte";
import type {
BindingLiquidSdk,
Payment,
} from "@breeztech/breez-sdk-liquid/web";
import { cashuTxns } from "$lib/cashu.svelte";
import LoadingIndicator from "$lib/components/LoadingIndicator.svelte";
import { walletState } from "$lib/wallet.svelte";
let payments = $state<Payment[]>([]);
const combinedPayments = $derived(
[...payments, ...$cashuTxns].sort((a, b) => b.timestamp - a.timestamp)
let payments = $derived(
$walletState.open
? $walletState.wallet?.listPayments(100, 0)
: 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>
<div class="payment-history">
{#if combinedPayments.length === 0}
{#await payments}
<LoadingIndicator />
{:then payments}
{#if payments.length === 0}
<p class="empty">No payments yet.</p>
{:else}
{#each combinedPayments as payment (payment.txId)}
{#each payments as payment (payment.txId)}
<PaymentHistoryItem {payment} />
{/each}
{/if}
{/await}
</div>
<style>

View file

@ -1,8 +1,8 @@
<script lang="ts">
import type { Payment } from "@breeztech/breez-sdk-liquid/web";
import { satsComma } from "$lib";
import { getBreezSDK } from "$lib/breez.svelte";
import Button from "$lib/components/Button.svelte";
import { walletState } from "$lib/wallet.svelte";
interface CashuPayment {
txId: string;
@ -29,60 +29,15 @@
let isRefunding = $state(false);
async function refundPayment() {
if (!$walletState.open) return;
if (isRefunding) return;
isRefunding = true;
const breezSDK = await getBreezSDK();
const prepareAddr = await breezSDK.prepareReceivePayment({
paymentMethod: "bitcoinAddress",
});
const receiveRes = await breezSDK.receivePayment({
prepareResponse: prepareAddr,
});
const refundAddress = receiveRes.destination as string;
if (payment.paymentType !== "send") return;
if (payment.txId?.startsWith("cashu-")) return;
try {
const refundables = await 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) {
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}`);
await $walletState.wallet.refundPayment(payment);
} catch (e) {
console.error(e);
} finally {
isRefunding = false;
}
@ -105,7 +60,7 @@
</a>
{:else if (payment as Payment).details.type === "lightning"}
<a
href="https://liquid.network/tx/{payment.details.claimTxId}"
href="https://liquid.network/tx/{payment.claimTxId}"
target="_blank"
>
...{payment.txId?.slice(-16)}

View file

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

View file

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

View file

@ -1,11 +1,10 @@
<script lang="ts">
import { getBreezSDK } from "$lib/breez.svelte";
import { send as sendPayment } from "$lib/payments";
import Button from "$lib/components/Button.svelte";
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 } =
$props();
let { open = $bindable() }: { open: boolean } = $props();
let destination = $state("");
@ -21,8 +20,9 @@
return;
}
try {
const breezSDK = await getBreezSDK();
const parsed: InputType = await breezSDK.parse(destination);
if (!$walletState.open) return;
const parsed: InputType =
await $walletState.wallet.parsePayment(destination);
if (
parsed.type === "bolt11" &&
parsed.invoice.amountMsat !== undefined &&
@ -42,16 +42,51 @@
let dialogEl: HTMLDialogElement;
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 {
status: sendStatus,
error: sendError,
} of sendGenerator) {
if (sendError) return alert(sendError);
status = sendStatus || "";
if (sendError && sendStatus !== PaymentStatus.CashuPaymentFailed)
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);
}

View file

@ -1,7 +1,10 @@
import { MAX_SATS_RECEIVE, MIN_SATS_RECEIVE } from "$lib/config";
import { getDb } from "$lib/database";
import { bech32 } from "@scure/base";
import type { Dexie, Table } from "dexie";
import { type Writable, writable } from "svelte/store";
import { nip19 } from "nostr-tools";
import { browser } from "$app/environment";
export function satsComma(sats: number) {
const chars = sats.toString().split("").reverse();
@ -74,3 +77,60 @@ export async function getUser(username: string) {
);
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,
getPublicKey,
nip04,
nip19,
SimplePool,
verifyEvent,
} 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 { getBreezSDK, refreshBalances } from "$lib/breez.svelte";
import type { BindingLiquidSdk } from "@breeztech/breez-sdk-liquid/web";
import {
bytesToHex,
hexToBytes,
type MetaEntry,
persistentDbWritable,
} from "./utils";
import { cashuDB, cashuState, getNpub } from "./cashu.svelte";
import { send } from "./payments";
} from "$lib";
import { walletState } from "$lib/wallet.svelte";
interface PaidInvoice {
paymentHash: string;
@ -81,8 +77,7 @@ interface JsonRpcReq {
| "get_balance"
| "list_transactions"
| "get_info"
| "make_invoice"
| "lookup_invoice";
| "make_invoice";
params: Record<string, unknown>;
}
@ -161,7 +156,6 @@ async function connect(): Promise<void> {
if (!maybePriv) throw new Error("NWC privkey missing");
const privkey = maybePriv;
const pubkey = getPublicKey(hexToBytes(privkey));
const breezSDK = await getBreezSDK();
pool = new SimplePool();
sub = pool.subscribeMany(relays, [
@ -171,7 +165,7 @@ async function connect(): Promise<void> {
},
], {
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,
ourPubkey: string,
relays: string[],
breezSDK: BindingLiquidSdk,
): Promise<void> {
try {
if (!verifyEvent(evt)) return;
@ -231,6 +224,8 @@ async function handleEvent(
async function processRpc(
req: JsonRpcReq,
): Promise<unknown> {
const currentWalletState = get(walletState);
if (!currentWalletState.open) throw new Error("Wallet not open");
console.log("processRpc", req);
switch (req.method) {
case "pay_invoice": {
@ -239,102 +234,51 @@ async function processRpc(
throw { code: "INTERNAL", message: "missing_invoice" };
}
const breezSDK = await getBreezSDK();
const parsed = await breezSDK.parse(destination);
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" };
if (await nwcDB.paidInvoices.get(destination)) {
throw { code: "OTHER", message: "invoice_already_paid" };
}
const sender = send(destination, 0, parsed);
let res = await sender.next();
const sender = currentWalletState.wallet.pay(destination, 0);
let res;
try {
res = await sender.next();
while (!res.done) {
res = await sender.next();
}
if (res.value.error) {
throw { code: "PAYMENT_FAILED", message: res.value.error };
} catch (err) {
throw { code: "PAYMENT_FAILED", message: (err as Error).message };
}
if (res.value.paymentHash) {
await nwcDB.paidInvoices.put({
paymentHash: res.value.paymentHash,
paymentHash: destination,
timestamp: Math.floor(Date.now() / 1000),
}).catch((err) => {
console.error("Failed to save paid invoice", err);
});
}
await refreshBalances();
return {
preimage: res.value.preimage,
paymentHash: res.value.paymentHash,
};
return {};
// TODO: check if it works without the preimage and paymentHash
// return {
// preimage: res.value.preimage,
// paymentHash: res.value.paymentHash,
// };
}
case "get_balance": {
const breezSDK = await getBreezSDK();
const info = await breezSDK.getInfo();
return {
balance: info.walletInfo.balanceSat * 1000 + get(cashuState).balance *
1000,
balance: currentWalletState.balance * 1000,
};
}
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 offset = (req.params?.offset as number | undefined) ?? undefined;
const offset = (req.params?.offset as number | undefined) ?? 0;
const breezSDK = await getBreezSDK();
const liquidPayments = (await breezSDK.listPayments({
fromTimestamp: from_timestamp,
toTimestamp: to_timestamp,
const transactions = await currentWalletState.wallet.listPayments(
limit,
offset,
})) as any[];
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,
};
);
return { transactions };
}
case "get_info": {
const breezSDK = await getBreezSDK();
const info = await breezSDK.getInfo();
const privkey = get(nwcPrivkey);
if (!privkey) throw new Error("missing_privkey");
@ -349,7 +293,6 @@ async function processRpc(
"list_transactions",
"get_info",
"make_invoice",
"lookup_invoice",
],
notifications: [],
};
@ -361,7 +304,9 @@ async function processRpc(
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(
`/.well-known/lnurl-pay/callback/${npub}?amount=${amountMsat}`,
);
@ -385,35 +330,6 @@ async function processRpc(
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:
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,37 +5,84 @@ import {
decrypt as nip49decrypt,
encrypt as nip49encrypt,
} 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";
let cachedMnemonic = $state<{ mnemonic: string | undefined }>({
mnemonic: undefined,
});
export const passwordRequest = $state({
pending: false,
resolve: undefined as ((password: string) => void) | undefined,
reject: undefined as ((error: Error) => void) | undefined,
});
let previouslyInputPassword = $state<{ pass: string | undefined }>({
pass: undefined,
});
function requestPassword(): Promise<string> {
if (!browser) throw new Error("Password input only available in browser");
if (previouslyInputPassword.pass) {
return Promise.resolve(previouslyInputPassword.pass);
interface MetaEntry {
key: string;
value: string;
}
return new Promise((resolve, reject) => {
passwordRequest.pending = true;
passwordRequest.resolve = (pw) => {
previouslyInputPassword.pass = pw;
resolve(pw);
};
passwordRequest.reject = reject;
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 class DexieBasedCashuStore implements CashuStore {
private readonly db: CashuDB = new CashuDB();
async getProofs() {
return this.db.proofs.toArray();
}
async getTxns() {
return this.db.txns.toArray();
}
async getLastRedeemedCashuQuoteTimestamp() {
const meta = await this.db.meta.get("lastRedeemedCashuQuoteTimestamp");
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 {
return generateMnemonic(wordlist, 128);
@ -65,26 +112,62 @@ export async function decryptMnemonic(
return new TextDecoder().decode(decryptedBytes);
}
export function storeEncryptedSeed(encrypted: string) {
if (browser) localStorage.setItem(SEED_KEY, encrypted);
export function storeEncryptedMnemonic(encrypted: string) {
if (browser) localStorage.setItem(MNEMONIC_KEY, encrypted);
}
export function hasSeed(): boolean {
export function hasMnemonic(): boolean {
if (!browser) return false;
return localStorage.getItem(SEED_KEY) !== null;
return localStorage.getItem(MNEMONIC_KEY) !== null;
}
export async function getMnemonic(): Promise<string> {
if (cachedMnemonic.mnemonic) return cachedMnemonic.mnemonic;
if (!browser) throw new Error("Not in browser");
const enc = localStorage.getItem(SEED_KEY);
if (!enc) throw new Error("Seed not initialised");
const password = await requestPassword();
const mnemonic = await decryptMnemonic(enc, password);
cachedMnemonic.mnemonic = mnemonic;
return mnemonic;
export const walletState = writable<
| {
open: false;
wallet: undefined;
mnemonic: undefined;
balance: number;
pendingBalance: number;
cashuBalance: number;
}
| {
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) {
cachedMnemonic.mnemonic = mnemonic;
export async function openWallet(mnemonic: string) {
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">
import "$lib/style.css";
import "iconify-icon";
import { hasSeed, passwordRequest } from "$lib/wallet.svelte";
import { hasMnemonic, walletState } from "$lib/wallet.svelte";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
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 SplashScreen from "$lib/components/SplashScreen.svelte";
import ErrorDialog from "$lib/components/ErrorDialog.svelte";
@ -16,7 +16,7 @@
stack?: string;
};
let passwordDialogOpen = $state(false);
let { children } = $props();
let showSplash = $state(true);
let currentError = $state<AppError | null>(null);
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() {
goto("/settings");
}
const { children } = $props();
$effect(() => {
if (passwordRequest.pending && !passwordDialogOpen) {
passwordDialogOpen = true;
}
});
const isOnSetup = $derived($page.route.id?.startsWith("/setup"));
const isOnSettings = $derived($page.route.id?.startsWith("/settings"));
const showSettingsButton = $derived(!isOnSetup && !isOnSettings && hasSeed());
const isOnSetup = $derived(page.route.id?.startsWith("/setup"));
const isOnSettings = $derived(page.route.id?.startsWith("/settings"));
const showSettingsButton = $derived(
!isOnSetup && !isOnSettings && hasMnemonic()
);
onMount(() => {
setTimeout(() => {
@ -96,7 +79,7 @@
}
});
if (!hasSeed() && !isOnSetup) {
if (!hasMnemonic() && !isOnSetup) {
goto("/setup");
}
});
@ -121,12 +104,14 @@
</div>
{/if}
{#if !isOnSetup || !passwordRequest.pending}
{#if isOnSetup || $walletState.open}
{@render children()}
{/if}
</main>
<PasswordDialog bind:open={passwordDialogOpen} unlock={handleUnlockAttempt} />
{#if !$walletState.open && !isOnSetup}
<PasswordDialog onunlock={() => startNwc()} />
{/if}
{/if}
<ErrorDialog error={currentError} onclose={() => (currentError = null)} />

View file

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

View file

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