commit c6ce63b6fa05d3238f4874a33a7180a6236d4214 Author: Danny Morabito Date: Sun Jul 6 15:56:28 2025 +0200 Let's get this started diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c300f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +portal-btc.db diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ff1707 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Portal BTC Wallet + +

+ +

+ +

Your portal to everything Bitcoin

+ +Portal is a self-custodial web wallet for Bitcoiners who want to use Bitcoin, including lightning, liquid and cashu but don't want to run their own Lightning node. + +It's built for simplicity, speed, and interoperability, giving you the tools to use Bitcoin on your own terms. + +It's a Progressive Web App (PWA), which means you can "install" it on your phone or desktop for a native-like experience without going through an app store. + +## What can it do? + +- **Go Beyond the Main Chain**: Portal supports multiple Bitcoin layers in one interface. + - **Liquid Network**: Make fast, confidential transactions using Bitcoin's most mature sidechain. Powered by the Breez SDK. + - **Cashu**: Experiment with truly private, Chaumian e-cash for small, anonymous payments. +- **Get Your Own Lightning Address**: Stop messing with invoices. Get a clean, reusable Lightning address (e.g., `you@portalbtc.live`) for easy payments via LNURL. +- **Connect to Nostr, Safely**: Use Nostr Wallet Connect (NWC) to interact with other Nostr apps without ever exposing your private keys. +- **You're in Control**: This is a self-custodial wallet. Your keys, your coins. + +## How It's Built + +Portal is built on a modern, open-source stack. We believe in transparency, so you can always see how it works under the hood. + +- **Framework**: [SvelteKit](https://kit.svelte.dev/) +- **Bitcoin/Lightning**: [Breez SDK (Liquid)](https://breez.technology/), [cashu-ts](https://github.com/cashubtc/cashu-ts) +- **Nostr**: [nostr-tools](https://github.com/nostr-protocol/nostr-tools) +- **Database**: [Turso / libsql](https://turso.tech/) +- **UI**: Svelte 5 + +## Run it Yourself + +Want to hack on Portal or run your own instance? It's easy. + +**You'll need:** +- [Bun](https://bun.sh/) + +**Steps:** +1. Clone the repository: + +```sh +git clone ssh://git@git.arx-ccn.com:222/Arx/PortalBTC.git +``` +2. Jump into the directory and install packages: + +```sh +cd portal-btc-wallet && bun install +``` + +3. Start the local development server: + +```sh +bun run dev +``` + +You should now be able to see it running at `http://localhost:5173`. diff --git a/package.json b/package.json new file mode 100644 index 0000000..10f0b49 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "portal-btc-wallet", + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "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", + "qrcode": "^1.5.4" + }, + "devDependencies": { + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.3", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.8.3", + "vite": "^6.2.6", + "vite-plugin-devtools-json": "^0.2.0" + } +} diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..520c421 --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..8055785 --- /dev/null +++ b/src/app.html @@ -0,0 +1,13 @@ + + + + + + + %sveltekit.head% + + + +
%sveltekit.body%
+ + diff --git a/src/lib/breez.svelte.ts b/src/lib/breez.svelte.ts new file mode 100644 index 0000000..fad730e --- /dev/null +++ b/src/lib/breez.svelte.ts @@ -0,0 +1,59 @@ +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 new file mode 100644 index 0000000..4dca9b4 --- /dev/null +++ b/src/lib/cashu.svelte.ts @@ -0,0 +1,372 @@ +import { + CashuMint, + CashuWallet, + 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 | undefined; + +const walletReady = (async () => { + if (wallet) return wallet; + await loadProofs(); + try { + await getMnemonic(); + } catch (_) {} + + const mint = new CashuMint(MINT_URL); + wallet = new CashuWallet(mint); + await wallet.loadMint(); + await tryRedeemUnredeemedCashuQuotes(); + + try { + console.log(`cashu addr: ${await getCashuAddress()}`); + } catch (_) { + } + + await persistProofs(); + return wallet; +})(); + +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 wallet = await walletReady; + 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 walletReady; + 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 walletReady; + 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 new file mode 100644 index 0000000..b31004b --- /dev/null +++ b/src/lib/components/BalanceDisplay.svelte @@ -0,0 +1,143 @@ + + + + + diff --git a/src/lib/components/Button.svelte b/src/lib/components/Button.svelte new file mode 100644 index 0000000..9d64593 --- /dev/null +++ b/src/lib/components/Button.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/CashuTab.svelte b/src/lib/components/CashuTab.svelte new file mode 100644 index 0000000..ba42654 --- /dev/null +++ b/src/lib/components/CashuTab.svelte @@ -0,0 +1,29 @@ + + +

Redeem Cashu Token

+ + + + diff --git a/src/lib/components/DangerZone.svelte b/src/lib/components/DangerZone.svelte new file mode 100644 index 0000000..5342d54 --- /dev/null +++ b/src/lib/components/DangerZone.svelte @@ -0,0 +1,122 @@ + + +
+

Danger Zone

+

⚠️ These actions can result in permanent loss of funds!

+ +
+

Seed Phrase

+

Your seed phrase is used to recover your wallet. Keep it safe!

+ +
+ + {#if showSeedPhrase} + + {/if} +
+ + {#if showSeedPhrase} +
+ {seedPhrase} +
+ {/if} +
+ +
+

Reset Website

+

+ This will permanently delete all your data including your wallet! It can + only be recovered if you have your seed phrase saved. +

+ + +
+
+ + diff --git a/src/lib/components/ErrorDialog.svelte b/src/lib/components/ErrorDialog.svelte new file mode 100644 index 0000000..5a1e7ef --- /dev/null +++ b/src/lib/components/ErrorDialog.svelte @@ -0,0 +1,47 @@ + + + + {#if error} + + {/if} + diff --git a/src/lib/components/InstallPrompt.svelte b/src/lib/components/InstallPrompt.svelte new file mode 100644 index 0000000..cf734d7 --- /dev/null +++ b/src/lib/components/InstallPrompt.svelte @@ -0,0 +1,59 @@ + + +
+
+ +

Install Portal BTC Wallet

+

+ The Portal BTC Wallet works best when installed as a progressive web + application. Click the button below to install the Portal BTC Wallet app + on your device. +

+ +
+
+ + diff --git a/src/lib/components/LightningSettings.svelte b/src/lib/components/LightningSettings.svelte new file mode 100644 index 0000000..99dcd9c --- /dev/null +++ b/src/lib/components/LightningSettings.svelte @@ -0,0 +1,156 @@ + + +
+

Lightning Address

+ + {#if isCheckingUsername} +

Checking for existing username...

+ {:else if hasExistingUsername} +

+ Your Lightning address: {existingUsername}@{BASE_DOMAIN} +

+ {:else} +

+ Register a username to receive Lightning payments at {username || + "yourname"}@{BASE_DOMAIN} +

+ +
+ + (username = (e.target as HTMLInputElement).value)} + placeholder="Enter username" + class="retro-input" + disabled={isRegistering} + /> +
+ + + + {#if registrationStatus} +

+ {registrationStatus} +

+ {/if} + {/if} +
+ + diff --git a/src/lib/components/NostrWalletConnect.svelte b/src/lib/components/NostrWalletConnect.svelte new file mode 100644 index 0000000..cbebb80 --- /dev/null +++ b/src/lib/components/NostrWalletConnect.svelte @@ -0,0 +1,93 @@ + + +
+

Nostr Wallet Connect

+

Configure your wallet to work with Nostr Wallet Connect clients.

+

+ Nostr Wallet Connect is a protocol for connecting to Nostr wallets. It + allows you to send Nostr zaps from your Portal BTC Wallet. +

+ +
+ +
+ + +
+
+ +
+ + (customRelays = (e.target as HTMLInputElement).value)} + placeholder="wss://relay1.com, wss://relay2.com" + class="retro-input" + /> + +
+
+ + diff --git a/src/lib/components/PasswordDialog.svelte b/src/lib/components/PasswordDialog.svelte new file mode 100644 index 0000000..7026b64 --- /dev/null +++ b/src/lib/components/PasswordDialog.svelte @@ -0,0 +1,101 @@ + + +{#if open} + +

Unlock Wallet

+

Enter your wallet password to decrypt your seed.

+ {#if error} +

+ {error} +

+ {/if} + { + if (e.key === "Enter" && !isValidating) { + attemptUnlock(); + } + }} + /> +
+ +
+
+{/if} + + diff --git a/src/lib/components/PaymentHistory.svelte b/src/lib/components/PaymentHistory.svelte new file mode 100644 index 0000000..4d0418d --- /dev/null +++ b/src/lib/components/PaymentHistory.svelte @@ -0,0 +1,55 @@ + + +
+ {#if combinedPayments.length === 0} +

No payments yet.

+ {:else} + {#each combinedPayments as payment (payment.txId)} + + {/each} + {/if} +
+ + diff --git a/src/lib/components/PaymentHistoryItem.svelte b/src/lib/components/PaymentHistoryItem.svelte new file mode 100644 index 0000000..ae87507 --- /dev/null +++ b/src/lib/components/PaymentHistoryItem.svelte @@ -0,0 +1,177 @@ + + +
+
+ {formattedDate} + {payment.status} + {#if payment.paymentType === "receive" && !payment.txId?.startsWith("cashu-")} + + {#if (payment as Payment).details.type === "bitcoin"} + + ...{payment.txId?.slice(-16)} + + {:else if (payment as Payment).details.type === "liquid"} + + ...{payment.txId?.slice(-16)} + + {:else if (payment as Payment).details.type === "lightning"} + + ...{payment.txId?.slice(-16)} + + {/if} + + {/if} +
+ + {formattedAmt} + + {#if payment.status === "refundable"} + + {/if} +
+ + diff --git a/src/lib/components/ReceiveDialog.svelte b/src/lib/components/ReceiveDialog.svelte new file mode 100644 index 0000000..6ea3096 --- /dev/null +++ b/src/lib/components/ReceiveDialog.svelte @@ -0,0 +1,65 @@ + + + (open = false)}> +
+

Receive

+ + + {#snippet children(idx)} + {#if idx === 0} + + {:else if idx === 1} + + {#snippet children(idx)} + {#if idx === 0} + + {:else} + + {/if} + {/snippet} + + {:else if idx === 2} + + {:else} + + {/if} + {/snippet} + + + +
+
+ + diff --git a/src/lib/components/ReceiveTab.svelte b/src/lib/components/ReceiveTab.svelte new file mode 100644 index 0000000..b764451 --- /dev/null +++ b/src/lib/components/ReceiveTab.svelte @@ -0,0 +1,88 @@ + + +{#await qr} +
+{:then qrDataUrl} + ${alt} +{/await} + +{#await paymentText then paymentText} +
{paymentText}
+{/await} + + diff --git a/src/lib/components/SendDialog.svelte b/src/lib/components/SendDialog.svelte new file mode 100644 index 0000000..317ec96 --- /dev/null +++ b/src/lib/components/SendDialog.svelte @@ -0,0 +1,160 @@ + + + (open = false)}> +

Send Payment

+ + + + {#if requireAmount} + + {/if} + + {#if status} +

{status}

+ {/if} + +
+ + +
+
+ + diff --git a/src/lib/components/SplashScreen.svelte b/src/lib/components/SplashScreen.svelte new file mode 100644 index 0000000..353a8d1 --- /dev/null +++ b/src/lib/components/SplashScreen.svelte @@ -0,0 +1,158 @@ + + +
{ + mouseX = 0; + mouseY = 0; + }} +> +
+
+
+
+ +
+
+
+ + diff --git a/src/lib/components/Tabs.svelte b/src/lib/components/Tabs.svelte new file mode 100644 index 0000000..3290210 --- /dev/null +++ b/src/lib/components/Tabs.svelte @@ -0,0 +1,50 @@ + + +
+ {#each labels as label, i} + + {/each} +
+ +{#if labels.length > 0} + {@render children(active)} +{/if} + + diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..3e9d33b --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,8 @@ +import { browser } from "$app/environment"; + +export const MIN_SATS_RECEIVE = 1; +export const MAX_SATS_RECEIVE = 20_000_000; +export const BASE_DOMAIN = "portalbtc.live"; +export const ENCRYPTION_KEY = browser + ? "" + : process.env.ENCRYPTION_KEY || "portal-btc"; diff --git a/src/lib/database.ts b/src/lib/database.ts new file mode 100644 index 0000000..b3fc487 --- /dev/null +++ b/src/lib/database.ts @@ -0,0 +1,25 @@ +import { type Client, createClient } from "@libsql/client"; +import { ENCRYPTION_KEY } from "./config"; +import { browser } from "$app/environment"; + +const db = browser ? null : createClient({ + url: "file:portal-btc.db", + encryptionKey: ENCRYPTION_KEY, +}); + +let tablesCreated = false; + +export async function getDb(): Promise { + if (browser) throw new Error("getDb can only be called on the server"); + if (!db) throw new Error("Database not initialized"); + if (!tablesCreated) { + await db.batch( + [ + "CREATE TABLE IF NOT EXISTS users (username TEXT, npub TEXT)", + ], + "write", + ); + tablesCreated = true; + } + return db; +} diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..e404bcb --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1,76 @@ +import { MAX_SATS_RECEIVE, MIN_SATS_RECEIVE } from "$lib/config"; +import { getDb } from "$lib/database"; +import { bech32 } from "@scure/base"; +import { nip19 } from "nostr-tools"; + +export function satsComma(sats: number) { + const chars = sats.toString().split("").reverse(); + const formatted = chars + .reduce( + (acc, char, i) => (i % 3 === 0 ? `${acc} ${char}` : acc + char), + "", + ) + .trim() + .split("") + .reverse() + .join(""); + return formatted; +} + +export function clamp(min: number, max: number, v: number) { + return Math.max(min, Math.min(max, v)); +} + +export function encodeLNURL(url: string): string { + const words = bech32.toWords(new TextEncoder().encode(url)); + return bech32.encode("lnurl", words, 1023); +} + +export function validateLightningInvoiceAmount( + amount: string, + { isMillisats }: { isMillisats: boolean }, +) { + const minMillisats = MIN_SATS_RECEIVE * 1000; + const maxMillisats = MAX_SATS_RECEIVE * 1000; + + const amountNumber = Number(amount); + if (Number.isNaN(amountNumber)) return false; + if (isMillisats && amountNumber < minMillisats) return false; + if (!isMillisats && amountNumber < MIN_SATS_RECEIVE) return false; + if (isMillisats && amountNumber > maxMillisats) return false; + if (!isMillisats && amountNumber > MAX_SATS_RECEIVE) return false; + return true; +} + +export async function userExists(username: string) { + const db = await getDb(); + const result = await db!.execute( + "SELECT * FROM users WHERE username = ? OR npub = ?", + [username, username], + ); + return result.rows.length > 0; +} + +export function isNpub(data: string): data is `npub1${string}` { + try { + nip19.decode(data); + return true; + } catch { + return false; + } +} + +export async function userExistsOrNpub(username: string) { + const userExistsResult = await userExists(username); + if (userExistsResult) return true; + return isNpub(username); +} + +export async function getUser(username: string) { + const db = await getDb(); + const result = await db!.execute( + "SELECT * FROM users WHERE username = ? OR npub = ?", + [username, username], + ); + return result.rows[0]; +} diff --git a/src/lib/nwc.svelte.ts b/src/lib/nwc.svelte.ts new file mode 100644 index 0000000..c55948b --- /dev/null +++ b/src/lib/nwc.svelte.ts @@ -0,0 +1,459 @@ +import Dexie, { type Table } from "dexie"; +import { + finalizeEvent, + 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 { 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"; + +interface PaidInvoice { + paymentHash: string; + timestamp: number; +} + +class NwcDB extends Dexie { + meta!: Table; + paidInvoices!: Table; + + constructor() { + super("nwc"); + this.version(2).stores({ + meta: "&key", + paidInvoices: "&paymentHash", + }); + } +} + +const nwcDB = new NwcDB(); + +export const nwcPrivkey = persistentDbWritable( + "nwc_privkey", + null, + nwcDB, +); + +export const nwcSecret = persistentDbWritable( + "nwc_secret", + null, + nwcDB, +); + +export const nwcRelays = persistentDbWritable( + "nwc_relays", + [ + "wss://relay.arx-ccn.com", + ], + nwcDB, +); + +export const connectUri = derived( + [nwcPrivkey, nwcSecret, nwcRelays], + ([$nwcPrivkey, $nwcSecret, $nwcRelays]) => { + if (!$nwcPrivkey || !$nwcSecret) return ""; + const privBytes = hexToBytes($nwcPrivkey); + const relayParam = encodeURIComponent($nwcRelays.join(",")); + return `nostr+walletconnect://${ + getPublicKey(privBytes) + }?relay=${relayParam}&secret=${$nwcSecret}`; + }, +); + +interface JsonRpcReq { + id: string; + method: + | "pay_invoice" + | "get_balance" + | "list_transactions" + | "get_info" + | "make_invoice" + | "lookup_invoice"; + params: Record; +} + +interface JsonRpcSuccess { + result_type: string; + result: T; +} + +interface JsonRpcError { + result_type: string; + error: { + code: + | "RATE_LIMITED" + | "NOT_IMPLEMENTED" + | "INSUFFICIENT_BALANCE" + | "QUOTA_EXCEEDED" + | "RESTRICTED" + | "UNAUTHORIZED" + | "INTERNAL" + | "NOT_FOUND" + | "PAYMENT_FAILED" + | "OTHER"; + message: string; + }; +} + +type JsonRpcResp = JsonRpcSuccess | JsonRpcError; + +let pool: SimplePool | null = null; +let sub: ReturnType | null = null; +let active = false; + +export async function start(): Promise { + if (!browser || active) return; + ensurePrivkey(); + ensureSecret(); + await connect(); + active = true; +} + +export function stop(): void { + if (!browser || !active) return; + sub?.close(); + pool?.close([]); + pool = null; + sub = null; + active = false; +} + +nwcRelays.subscribe(() => { + if (active) { + stop(); + start(); + } +}); + +function ensurePrivkey(): string { + const current = get(nwcPrivkey); + if (current) return current; + const pk = bytesToHex(generateSecretKey()); + nwcPrivkey.set(pk); + return pk; +} + +function ensureSecret(): string { + const current = get(nwcSecret); + if (current) return current; + const secret = bytesToHex(generateSecretKey()); + nwcSecret.set(secret); + return secret; +} + +async function connect(): Promise { + const relays = get(nwcRelays); + const maybePriv = get(nwcPrivkey); + 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, [ + { + kinds: [23194], // NWC request events + "#p": [pubkey], + }, + ], { + onevent: async (evt: NostrEvent) => { + await handleEvent(evt, privkey, pubkey, relays, breezSDK); + }, + }); +} + +async function handleEvent( + evt: NostrEvent, + privkey: string, + ourPubkey: string, + relays: string[], + breezSDK: BindingLiquidSdk, +): Promise { + try { + if (!verifyEvent(evt)) return; + const decrypted = await nip04.decrypt( + hexToBytes(privkey), + evt.pubkey, + evt.content, + ); + const req = JSON.parse(decrypted) as JsonRpcReq; + + const result = await processRpc(req); + await sendResponse( + req.id, + req.method, + result, + evt.pubkey, + privkey, + ourPubkey, + relays, + evt.id, + ); + } catch (err) { + console.error("NWC handleEvent error", err); + const req = JSON.parse( + await nip04.decrypt(hexToBytes(privkey), evt.pubkey, evt.content), + ) as JsonRpcReq; + const errorResp: JsonRpcError = { + result_type: req.method, + error: { + code: "INTERNAL", + message: (err as Error).message, + }, + }; + await sendResponse( + req.id, + req.method, + errorResp, + evt.pubkey, + privkey, + ourPubkey, + relays, + evt.id, + ); + } +} + +async function processRpc( + req: JsonRpcReq, +): Promise { + console.log("processRpc", req); + switch (req.method) { + case "pay_invoice": { + const destination = req.params?.invoice as string | undefined; + if (!destination) { + 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" }; + } + + const sender = send(destination, 0, parsed); + let res = await sender.next(); + while (!res.done) { + res = await sender.next(); + } + + if (res.value.error) { + throw { code: "PAYMENT_FAILED", message: res.value.error }; + } + + if (res.value.paymentHash) { + await nwcDB.paidInvoices.put({ + paymentHash: res.value.paymentHash, + timestamp: Math.floor(Date.now() / 1000), + }); + } + + await refreshBalances(); + 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, + }; + } + 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 breezSDK = await getBreezSDK(); + const liquidPayments = (await breezSDK.listPayments({ + fromTimestamp: from_timestamp, + toTimestamp: to_timestamp, + 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, + }; + } + case "get_info": { + const breezSDK = await getBreezSDK(); + const info = await breezSDK.getInfo(); + const privkey = get(nwcPrivkey); + if (!privkey) throw new Error("missing_privkey"); + + return { + alias: "Portal BTC Wallet", + color: "#ff6b35", + pubkey: getPublicKey(hexToBytes(privkey)), + network: "mainnet", + methods: [ + "pay_invoice", + "get_balance", + "list_transactions", + "get_info", + "make_invoice", + "lookup_invoice", + ], + notifications: [], + }; + } + case "make_invoice": { + const amountMsat = req.params?.amount as number | undefined; + + if (!amountMsat) { + throw { code: "INTERNAL", message: "missing_amount" }; + } + + const npub = await getNpub(); + const res = await fetch( + `/.well-known/lnurl-pay/callback/${npub}?amount=${amountMsat}`, + ); + + if (!res.ok) { + const errorText = await res.text(); + console.error("Failed to create invoice:", errorText); + throw { + code: "INTERNAL", + message: `Failed to create invoice: ${errorText}`, + }; + } + + const { pr: invoice } = await res.json(); + + if (!invoice) { + throw { code: "INTERNAL", message: "failed_to_create_invoice" }; + } + + return { + 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" }; + } +} + +async function sendResponse( + id: string, + method: string, + payload: JsonRpcResp | unknown, + receiverPubkey: string, + privkey: string, + ourPubkey: string, + relays: string[], + reqId: string, +): Promise { + const resp: JsonRpcResp = (payload as JsonRpcError).error + ? (payload as JsonRpcError) + : { result_type: method, result: payload }; + + const content = JSON.stringify({ id, ...resp }); + const encrypted = await nip04.encrypt( + hexToBytes(privkey), + receiverPubkey, + content, + ); + + const event: NostrEvent = { + kind: 23195, + pubkey: ourPubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["p", receiverPubkey], + ["e", reqId], + ], + content: encrypted, + } as NostrEvent; + + const signedEvent = finalizeEvent( + event, + hexToBytes(privkey), + ); + pool?.publish(relays, signedEvent); +} diff --git a/src/lib/payments.ts b/src/lib/payments.ts new file mode 100644 index 0000000..6bad09d --- /dev/null +++ b/src/lib/payments.ts @@ -0,0 +1,120 @@ +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", + }; + } +} diff --git a/src/lib/style.css b/src/lib/style.css new file mode 100644 index 0000000..b231a3e --- /dev/null +++ b/src/lib/style.css @@ -0,0 +1,223 @@ +@import url("https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap"); + +:root { + --primary-color: #ffcc00; + --accent-color: #ff006e; + --surface-color: #242424; + --surface-alt-color: #313131; + --text-color: #ffffff; + --success-color: #4caf50; + --error-color: #f44336; +} + +* { + box-sizing: border-box; + scrollbar-width: thin; + scrollbar-color: var(--accent-color) var(--surface-alt-color); +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; + font-family: "Press Start 2P", "VT323", monospace, sans-serif; + background: + radial-gradient(circle at 50% 0%, #2e004d 0%, #10001b 60%) fixed, + #0c0c0c + url("data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0h10v10H0z' fill='%23131313'/%3E%3Cpath d='M0 0h5v5H0z' fill='%23222222'/%3E%3Cpath d='M5 5h5v5H5z' fill='%23222222'/%3E%3C/svg%3E") + repeat fixed; + color: var(--text-color); + letter-spacing: 0.5px; + line-height: 1.2; + text-shadow: 1px 1px 0 #000; +} + +.app-wrapper { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + min-height: 100%; + padding: 2rem 1rem 4rem; +} + +.container { + width: 100%; + max-width: 28rem; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.send-receive-buttons { + display: flex; + gap: 0.75rem; + margin: 1rem 0 1.5rem; + justify-content: space-between; +} + +.retro-card { + position: relative; + background: linear-gradient(180deg, #2f2f2f 0%, #1d1d1d 75%); + border: 3px solid var(--accent-color); + border-radius: 8px; + padding: 1rem 1.25rem; + box-shadow: + 0 0 0 4px #000, + 0 0 0 8px var(--primary-color); +} + +.retro-card::before { + content: ""; + position: absolute; + inset: 0; + border-radius: 4px; + padding: 2px; + background: linear-gradient(145deg, rgba(255, 255, 255, 0.12), transparent); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; +} + +.retro-btn { + position: relative; + display: inline-block; + font-family: "Press Start 2P", monospace; + font-size: 0.8rem; + padding: 0.65rem 1.25rem; + cursor: pointer; + color: var(--text-color); + border: 3px solid var(--accent-color); + border-radius: 4px; + background: linear-gradient( + 145deg, + var(--surface-alt-color) 0%, + #1a1a1a 100% + ); + box-shadow: 4px 4px 0 #000; + text-transform: uppercase; + transition: + transform 0.1s ease, + filter 0.15s ease; + overflow: hidden; +} + +.retro-btn::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 50%; + background: rgba(255, 255, 255, 0.06); + transform: translateY(-100%); + pointer-events: none; + transition: transform 0.3s ease; +} + +.retro-btn:hover::before { + transform: translateY(0%); +} + +.retro-btn:hover { + filter: brightness(1.15); +} + +.retro-btn.primary { + background: linear-gradient(145deg, var(--primary-color) 0%, #d4af00 100%); + color: #000; +} + +.retro-btn.danger { + background: linear-gradient(145deg, var(--error-color) 0%, #a83226 100%); +} + +.retro-btn:active { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 #000; +} + +dialog { + position: relative; + background: var(--surface-color); + outline: none; + width: 100%; + max-width: clamp(450px, 80vw, 650px); + max-height: 60vh; + border: 3px solid var(--accent-color); + border-radius: 8px; + box-shadow: + 0 0 0 4px #000, + 0 0 0 8px var(--primary-color); + font-family: "Press Start 2P", monospace; + color: var(--text-color); + padding: 2rem 2.5rem; +} + +dialog::backdrop { + background: rgba(0, 0, 0, 0.8); +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +*::-webkit-scrollbar-track { + background: var(--surface-alt-color); +} + +*::-webkit-scrollbar-thumb { + background-color: var(--accent-color); + border: 2px solid #000; + border-radius: 8px; +} + +*::-webkit-scrollbar-thumb:hover { + background-color: var(--primary-color); +} + +*::-webkit-scrollbar-corner { + background: var(--surface-alt-color); +} + +.retro-input { + font-family: "Press Start 2P", monospace; + font-size: 0.8rem; + padding: 0.65rem 1.25rem; + color: var(--text-color); + background: linear-gradient( + 145deg, + var(--surface-alt-color) 0%, + #1a1a1a 100% + ); + border: 3px solid var(--accent-color); + border-radius: 4px; + box-shadow: 4px 4px 0 #000; + transition: filter 0.15s ease; +} + +.retro-input:focus { + outline: none; + filter: brightness(1.15); +} + +.retro-input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.retro-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + filter: none; +} + +.retro-btn:disabled:hover { + filter: none; + transform: none; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..982e6ac --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,60 @@ +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( + key: string, + initial: T, + db: DB & { meta: Table }, +): Writable { + const store = writable(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; +} diff --git a/src/lib/wallet.svelte.ts b/src/lib/wallet.svelte.ts new file mode 100644 index 0000000..bd93ded --- /dev/null +++ b/src/lib/wallet.svelte.ts @@ -0,0 +1,90 @@ +import { browser } from "$app/environment"; +import { generateMnemonic, validateMnemonic } from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english"; +import { + decrypt as nip49decrypt, + encrypt as nip49encrypt, +} from "nostr-tools/nip49"; + +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 { + if (!browser) throw new Error("Password input only available in browser"); + if (previouslyInputPassword.pass) { + return Promise.resolve(previouslyInputPassword.pass); + } + + return new Promise((resolve, reject) => { + passwordRequest.pending = true; + passwordRequest.resolve = (pw) => { + previouslyInputPassword.pass = pw; + resolve(pw); + }; + passwordRequest.reject = reject; + }); +} + +export function createMnemonic(): string { + return generateMnemonic(wordlist, 128); +} + +export function isValidMnemonic(mnemonic: string): boolean { + return validateMnemonic(mnemonic, wordlist); +} + +export function isValidWord(word: string): boolean { + return wordlist.includes(word.toLowerCase()); +} + +export async function encryptMnemonic( + mnemonic: string, + password: string, +): Promise { + const secretKey = new TextEncoder().encode(mnemonic); + return nip49encrypt(secretKey, password); +} + +export async function decryptMnemonic( + data: string, + password: string, +): Promise { + const decryptedBytes = nip49decrypt(data, password); + return new TextDecoder().decode(decryptedBytes); +} + +export function storeEncryptedSeed(encrypted: string) { + if (browser) localStorage.setItem(SEED_KEY, encrypted); +} + +export function hasSeed(): boolean { + if (!browser) return false; + return localStorage.getItem(SEED_KEY) !== null; +} + +export async function getMnemonic(): Promise { + 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 function cacheMnemonic(mnemonic: string) { + cachedMnemonic.mnemonic = mnemonic; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..28b8362 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,145 @@ + + +{#if showInstallPrompt && deferredPrompt} + +{:else if showSplash} + +{:else} +
+ {#if showSettingsButton} +
+ +
+ {/if} + + {#if !isOnSetup || !passwordRequest.pending} + {@render children()} + {/if} +
+ + +{/if} + + (currentError = null)} /> + + diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/src/routes/+layout.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..7852ec8 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,49 @@ + + + + + +
+ +
+ + +
+ + +
diff --git a/src/routes/.well-known/lnurl-pay/callback/[username]/+server.ts b/src/routes/.well-known/lnurl-pay/callback/[username]/+server.ts new file mode 100644 index 0000000..755cbd4 --- /dev/null +++ b/src/routes/.well-known/lnurl-pay/callback/[username]/+server.ts @@ -0,0 +1,64 @@ +import { error, json } from "@sveltejs/kit"; +import { MAX_SATS_RECEIVE, MIN_SATS_RECEIVE } from "$lib/config"; +import { + getUser, + isNpub, + userExistsOrNpub, + validateLightningInvoiceAmount, +} from "$lib"; + +/** @type {import('./$types').RequestHandler} */ +export async function GET({ params, url }) { + const { username } = params; + const amount = url.searchParams.get("amount"); + + if (!username || !amount) { + throw error(400, "User and amount parameters are required"); + } + + if (!/^[a-z0-9\-_]+$/.test(username)) { + throw error(400, "Invalid username format"); + } + + if (!(await userExistsOrNpub(username))) { + throw error(404, "User not found"); + } + + if ( + !amount || !validateLightningInvoiceAmount(amount, { isMillisats: true }) + ) { + throw error( + 400, + `Amount must be between ${MIN_SATS_RECEIVE} and ${MAX_SATS_RECEIVE} millisats`, + ); + } + + const user = isNpub(username) ? username : (await getUser(username))?.npub; + + if (!user) { + throw error(404, "User not found"); + } + + const npubxRequest = + `https://npubx.cash/.well-known/lnurlp/${user}?amount=${amount}`; + + const response = await fetch(npubxRequest); + + if (!response.ok) { + throw error(500, "Failed to create swap"); + } + + const data = await response.json(); + + return json(data); +} + +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); +} diff --git a/src/routes/.well-known/lnurlp/[username]/+server.ts b/src/routes/.well-known/lnurlp/[username]/+server.ts new file mode 100644 index 0000000..e1d9bde --- /dev/null +++ b/src/routes/.well-known/lnurlp/[username]/+server.ts @@ -0,0 +1,139 @@ +import { error, json } from "@sveltejs/kit"; +import { BASE_DOMAIN, MAX_SATS_RECEIVE, MIN_SATS_RECEIVE } from "$lib/config"; +import { + isNpub, + userExists, + userExistsOrNpub, + validateLightningInvoiceAmount, +} from "$lib"; +import { getDb } from "$lib/database"; +import { nip19 } from "nostr-tools"; + +interface LNURLPayRequest { + callback: string; + minSendable: number; + maxSendable: number; + metadata: string; + tag: "payRequest"; +} + +/** @type {import('./$types').RequestHandler} */ +export async function GET({ params, url, request }) { + const { username } = params; + const amount = url.searchParams.get("amount"); + + if (!/^[a-z0-9\-_.]+$/.test(username)) { + throw error(400, "Invalid username format"); + } + + if (!(await userExistsOrNpub(username))) { + throw error(404, "User not found"); + } + + if ( + amount && !validateLightningInvoiceAmount(amount, { isMillisats: true }) + ) { + throw error( + 400, + `Amount must be between ${MIN_SATS_RECEIVE} and ${MAX_SATS_RECEIVE} millisats`, + ); + } + + const domain = request.headers.get("host") || new URL(BASE_DOMAIN).host; + const identifier = `${username}@${domain}`; + + const response: LNURLPayRequest = { + callback: `https://${domain}/.well-known/lnurl-pay/callback/${ + encodeURIComponent( + username, + ) + }`, + minSendable: MIN_SATS_RECEIVE * 1000, + maxSendable: MAX_SATS_RECEIVE * 1000, + metadata: JSON.stringify([ + ["text/plain", `Pay ${username}`], + ["text/identifier", identifier], + ]), + tag: "payRequest", + }; + + return json(response); +} + +/** @type {import('./$types').RequestHandler} */ +export async function POST({ params, url, request }) { + // TODO: creating a username should cost a small amount of sats + // 105k sats for 1 character names + // 63k sats for 2 character names + // 42k sats for 3 character names + // 21k sats for 4 character names + // 10k sats otherwise + + const { username } = params; + if (username.length > 32) { + throw error(400, "Username is too long. Maximum length is 32 characters."); + } + if (username.length < 5) { + throw error( + 400, + "Username is too short. Minimum length is 5 characters. Shorter usernames are reserved for future use.", + ); + } + + if (!/^[a-z0-9\-_]+$/.test(username)) { + throw error( + 400, + "Invalid username format. Only lowercase letters, numbers, and hyphens are allowed.", + ); + } + + if (await userExists(username)) { + throw error(400, "User already exists"); + } + + if (isNpub(username)) { + throw error( + 400, + "Username is a valid npub. Please use a username instead.", + ); + } + + const db = await getDb(); + + const body = await request.json(); + const { npub }: { + npub: `npub1${string}`; + } = body; + + if (!npub) { + throw error(400, "Missing npub"); + } + + try { + const decoded = nip19.decode(npub); + if (decoded.type !== "npub") { + throw error(400, "Invalid npub"); + } + } catch { + throw error(400, "Invalid npub"); + } + + await db.execute( + "INSERT INTO users (username, npub) VALUES (?, ?)", + [username, npub], + ); + + return json({ + success: true, + }); +} + +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); +} diff --git a/src/routes/api/check-username/+server.ts b/src/routes/api/check-username/+server.ts new file mode 100644 index 0000000..691c2ed --- /dev/null +++ b/src/routes/api/check-username/+server.ts @@ -0,0 +1,33 @@ +import { error, json, type RequestHandler } from "@sveltejs/kit"; +import { getDb } from "$lib/database"; + +export const GET: RequestHandler = async ({ url }) => { + const npub = url.searchParams.get("npub"); + + if (!npub) { + throw error(400, "Missing npub parameter"); + } + + try { + const db = await getDb(); + const result = await db.execute( + "SELECT username FROM users WHERE npub = ?", + [npub], + ); + + if (result.rows.length > 0) { + return json({ + username: result.rows[0].username, + exists: true, + }); + } else { + return json({ + username: null, + exists: false, + }); + } + } catch (err) { + console.error("Database error:", err); + throw error(500, "Database error"); + } +}; diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte new file mode 100644 index 0000000..4f7ae29 --- /dev/null +++ b/src/routes/settings/+page.svelte @@ -0,0 +1,49 @@ + + +
+
+
+

Settings

+ +
+
+ + + + + + +
+ + diff --git a/src/routes/setup/+page.svelte b/src/routes/setup/+page.svelte new file mode 100644 index 0000000..0b15b74 --- /dev/null +++ b/src/routes/setup/+page.svelte @@ -0,0 +1,203 @@ + + +
+ {#if stage === "choice"} +

Wallet Setup

+
+ This is a web wallet. Your funds ARE at risk. Store only amounts + you are willing to lose. For larger amounts, use a hardware wallet. +
+

Choose how you want to set up your wallet:

+
+ + +
+ {:else if stage === "mnemonic"} +

Wallet Setup

+
+ This is a web wallet. Your funds ARE at risk. Store only amounts + you are willing to lose. For larger amounts, use a hardware wallet. +
+

+ Please write down these 12 words in order and keep them safe. They are the + only way to recover your funds. +

+
+ {#each userMnemonic.split(" ") as word, i} +
{i + 1}. {word}
+ {/each} +
+
+ +
+ {:else if stage === "existing"} +

Enter Existing Seed

+

Enter your existing 12-word seed phrase to restore your wallet.

+
+ +
+ {#if mnemonicError} +

{mnemonicError}

+ {/if} +
+ +
+ {:else if stage === "password"} +

Set Password

+

+ Choose a password (min 8 characters). It will encrypt your seed locally. +

+
+ +
+
+ +
+ {#if passwordError} +

{passwordError}

+ {/if} +
+ +
+ {/if} +
+ + diff --git a/src/service-worker.js b/src/service-worker.js new file mode 100644 index 0000000..bf45cf9 --- /dev/null +++ b/src/service-worker.js @@ -0,0 +1,65 @@ +import { build, files, version } from "$service-worker"; + +const CACHE = `cache-${version}`; + +const ASSETS = [...build, ...files]; + +self.addEventListener("install", (event) => { + async function addFilesToCache() { + const cache = await caches.open(CACHE); + await cache.addAll(ASSETS); + } + + event.waitUntil(addFilesToCache()); +}); + +self.addEventListener("activate", (event) => { + async function deleteOldCaches() { + for (const key of await caches.keys()) { + if (key !== CACHE) await caches.delete(key); + } + } + + event.waitUntil(deleteOldCaches()); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return; + + async function respond() { + const url = new URL(event.request.url); + const cache = await caches.open(CACHE); + + if (ASSETS.includes(url.pathname)) { + const response = await cache.match(url.pathname); + + if (response) { + return response; + } + } + + try { + const response = await fetch(event.request); + + if (!(response instanceof Response)) { + throw new Error("invalid response from fetch"); + } + + if (response.status === 200) { + cache.put(event.request, response.clone()); + } + + return response; + } catch (err) { + const response = await cache.match(event.request); + + if (response) { + return response; + } + + throw err; + } + } + + event.respondWith(respond()); +}); diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000..f0b3435 Binary files /dev/null and b/static/favicon.png differ diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..bf6e2f3 --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "Portal BTC Wallet", + "short_name": "Portal BTC", + "description": "Your universal wallet for everything Bitcoin and Nostr.", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#000000", + "icons": [ + { + "src": "/favicon.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicon.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..a76e883 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,19 @@ +import adapter from "@sveltejs/adapter-node"; +// import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://svelte.dev/docs/kit/integrations + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter(), + }, +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0b2d886 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b184126 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,9 @@ +import { sveltekit } from "@sveltejs/kit/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [sveltekit()], + optimizeDeps: { + exclude: ["@breeztech/breez-sdk-liquid"], + }, +});