diff --git a/package.json b/package.json index 10f0b49..75e0eb8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/breez.svelte.ts b/src/lib/breez.svelte.ts deleted file mode 100644 index fad730e..0000000 --- a/src/lib/breez.svelte.ts +++ /dev/null @@ -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++; -} diff --git a/src/lib/cashu.svelte.ts b/src/lib/cashu.svelte.ts deleted file mode 100644 index cdd8363..0000000 --- a/src/lib/cashu.svelte.ts +++ /dev/null @@ -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; - meta!: Table; - txns!: Table; - - 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({ - 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( - { url, method, body }: { - url: string; - method: string; - body?: BodyInit | null; - }, -): Promise { - 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 | null = null; - -export async function getWallet(): Promise { - 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 | 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 { - 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 { - 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([]); - -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 { - 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(); -} diff --git a/src/lib/components/BalanceDisplay.svelte b/src/lib/components/BalanceDisplay.svelte index b31004b..282a2f0 100644 --- a/src/lib/components/BalanceDisplay.svelte +++ b/src/lib/components/BalanceDisplay.svelte @@ -1,15 +1,21 @@ diff --git a/src/lib/components/DangerZone.svelte b/src/lib/components/DangerZone.svelte index 5342d54..f1923f2 100644 --- a/src/lib/components/DangerZone.svelte +++ b/src/lib/components/DangerZone.svelte @@ -1,26 +1,16 @@ + +
+
+
+ + diff --git a/src/lib/components/PasswordDialog.svelte b/src/lib/components/PasswordDialog.svelte index 7026b64..c69e063 100644 --- a/src/lib/components/PasswordDialog.svelte +++ b/src/lib/components/PasswordDialog.svelte @@ -1,26 +1,24 @@ -{#if open} - -

Unlock Wallet

-

Enter your wallet password to decrypt your seed.

- {#if error} -

- {error} -

- {/if} - +

Unlock Wallet

+

Enter your wallet password to decrypt your seed.

+ {#if error} +

+ {error} +

+ {/if} + { + if (e.key === "Enter" && !isValidating) { + attemptUnlock(); + } + }} + /> +
+ -
-
-{/if} + onclick={() => { + attemptUnlock(); + }}>{isValidating ? "Validating..." : "Unlock"} + +