Let's get this started

This commit is contained in:
Danny Morabito 2025-07-06 15:56:28 +02:00
commit c6ce63b6fa
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
46 changed files with 3983 additions and 0 deletions

25
.gitignore vendored Normal file
View file

@ -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

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

59
README.md Normal file
View file

@ -0,0 +1,59 @@
# Portal BTC Wallet
<p align="center">
<img src="static/favicon.png" width="128" />
</p>
<h3 align="center">Your portal to everything Bitcoin</h3>
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`.

40
package.json Normal file
View file

@ -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"
}
}

13
src/app.d.ts vendored Normal file
View file

@ -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 {};

13
src/app.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<link rel="manifest" href="/manifest.json" />
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

59
src/lib/breez.svelte.ts Normal file
View file

@ -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++;
}

372
src/lib/cashu.svelte.ts Normal file
View file

@ -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<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 | 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<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 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<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 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<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 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();
}

View file

@ -0,0 +1,143 @@
<script lang="ts">
import { satsComma } from "$lib";
import { cashuState } from "$lib/cashu.svelte";
const { balance = 0, pending = 0 }: { balance: number; pending: number } =
$props();
const formattedTotal = $derived(satsComma(balance + $cashuState.balance));
const formattedPending = $derived(satsComma(pending));
const formattedLightning = $derived(satsComma(balance));
const formattedCashu = $derived(satsComma($cashuState.balance));
const cashuThreshold = $derived(satsComma($cashuState.meltThreshold));
let showSplit = $state(false);
let containerEl: HTMLButtonElement;
function toggleSplit() {
showSplit = !showSplit;
}
$effect(() => {
function handleOutside(e: MouseEvent) {
if (containerEl && !containerEl.contains(e.target as Node)) {
showSplit = false;
}
}
if (showSplit) {
window.addEventListener("click", handleOutside);
return () => window.removeEventListener("click", handleOutside);
}
});
</script>
<button
class="balance-container"
bind:this={containerEl}
onclick={toggleSplit}
aria-label="Toggle balance split"
>
<div class="balance-label">Total Balance</div>
<div class="balance-value">{formattedTotal} sats</div>
<div class="pending-label">Pending</div>
<div class="pending-value">{formattedPending} sats</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>
</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>
</div>
<div class="threshold">Auto-melt at {cashuThreshold} sats</div>
</div>
{/if}
</button>
<style>
.balance-container {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: 1rem;
background: var(--surface-color);
border: 3px solid var(--accent-color);
border-radius: 8px;
box-shadow:
0 0 0 4px #000,
0 0 0 8px var(--primary-color);
padding: 1.2rem 1.5rem;
position: relative;
cursor: pointer;
}
.balance-label {
font-size: 0.9rem;
color: var(--primary-color);
margin-bottom: 0.2rem;
letter-spacing: 1px;
text-shadow: 1px 1px 0 #000;
}
.balance-value {
font-size: 1.5rem;
font-weight: bold;
color: var(--text-color);
margin-bottom: 0.5rem;
text-shadow:
2px 2px 0 #000,
0 0 8px var(--accent-color);
}
.pending-label {
font-size: 0.8rem;
color: var(--accent-color);
text-shadow: 1px 1px 0 #000;
}
.pending-value {
font-size: 1rem;
color: var(--accent-color);
text-shadow: 1px 1px 0 #000;
}
.popover {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.5rem;
background: var(--surface-alt-color);
border: 2px solid var(--accent-color);
border-radius: 6px;
padding: 0.8rem 1rem;
box-shadow:
0 0 0 3px #000,
0 0 0 6px var(--primary-color);
z-index: 10;
min-width: 220px;
}
.split-row {
font-size: 0.9rem;
margin-bottom: 0.4rem;
color: var(--text-color);
text-shadow: 1px 1px 0 #000;
display: flex;
align-items: center;
gap: 0.5rem;
}
.split-row .label {
margin-left: 0.2rem;
}
.split-row .amount {
margin-left: auto;
text-align: right;
min-width: 6ch;
}
.threshold {
font-size: 0.75rem;
color: var(--accent-color);
text-shadow: 1px 1px 0 #000;
}
</style>

View file

@ -0,0 +1,21 @@
<script lang="ts">
type BtnType = "button" | "submit" | "reset";
const {
variant = "",
class: externalClass = "",
disabled = false,
type: btnType = "button" as BtnType,
onclick,
children,
} = $props();
</script>
<button
class={`retro-btn ${variant} ${externalClass}`.trim()}
{disabled}
type={btnType}
{onclick}
>
{@render children()}
</button>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import Button from "$lib/components/Button.svelte";
import { redeemToken } from "$lib/cashu.svelte";
let tokenInput = $state("");
function redeem() {
redeemToken(tokenInput).catch((e) => alert(e.message));
tokenInput = "";
}
</script>
<h4>Redeem Cashu Token</h4>
<textarea bind:value={tokenInput} placeholder="Paste Cashu token here" rows="3"
></textarea>
<Button onclick={redeem}>Redeem</Button>
<style>
textarea {
width: 100%;
margin: 0.8rem 0;
font-family: monospace;
padding: 0.5rem;
border: 2px solid var(--accent-color);
border-radius: 4px;
background: var(--surface-alt-color);
color: var(--text-color);
}
</style>

View file

@ -0,0 +1,122 @@
<script lang="ts">
import Button from "./Button.svelte";
import { goto } from "$app/navigation";
import { getMnemonic } from "$lib/wallet.svelte";
import { onMount } from "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);
}
function resetWebsite() {
if (
confirm(
"Are you sure you want to reset the website? This will clear all data including your wallet. Make sure you have your seed phrase saved!"
)
) {
if (confirm("This action cannot be undone. Are you absolutely sure?")) {
localStorage.clear();
indexedDB.deleteDatabase("nwc");
goto("/setup");
}
}
}
</script>
<div class="retro-card danger-zone">
<h2>Danger Zone</h2>
<p>⚠️ These actions can result in permanent loss of funds!</p>
<div class="danger-section">
<h3>Seed Phrase</h3>
<p>Your seed phrase is used to recover your wallet. Keep it safe!</p>
<div class="seed-actions">
<Button onclick={toggleSeedPhrase}>
{showSeedPhrase ? "Hide" : "Show"} Seed Phrase
</Button>
{#if showSeedPhrase}
<Button onclick={copySeedPhrase}>Copy Seed</Button>
{/if}
</div>
{#if showSeedPhrase}
<div class="seed-phrase">
{seedPhrase}
</div>
{/if}
</div>
<div class="danger-section">
<h3>Reset Website</h3>
<p>
This will permanently delete all your data including your wallet! It can
only be recovered if you have your seed phrase saved.
</p>
<Button variant="danger" onclick={resetWebsite}>Reset Website</Button>
</div>
</div>
<style>
h2 {
margin: 0 0 1rem 0;
font-size: 1rem;
}
h3 {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
}
.seed-actions {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.seed-phrase {
background: var(--surface-alt-color);
padding: 1rem;
border-radius: 4px;
font-size: 0.7rem;
line-height: 1.4;
border: 2px solid var(--accent-color);
word-break: break-all;
}
.danger-zone {
border-color: var(--error-color);
}
.danger-zone h2 {
color: var(--error-color);
}
.danger-section {
margin-bottom: 2rem;
}
.danger-section:last-child {
margin-bottom: 0;
}
.danger-section h3 {
color: var(--error-color);
}
</style>

View file

@ -0,0 +1,47 @@
<script lang="ts">
import Button from "./Button.svelte";
type AppError = {
message: string;
stack?: string;
};
const { error, onclose } = $props<{
error: AppError | null;
onclose: () => void;
}>();
let dialogEl: HTMLDialogElement | undefined;
$effect(() => {
if (error && !dialogEl?.open) {
dialogEl?.showModal();
} else if (!error && dialogEl?.open) {
dialogEl?.close();
}
});
</script>
<dialog bind:this={dialogEl} {onclose}>
{#if error}
<div class="modal-box">
<h3 class="font-bold text-lg text-error">An Error Occurred</h3>
<div class="py-4">
<p class="font-semibold">Message:</p>
<p class="bg-base-200 p-2 rounded">{error.message}</p>
</div>
{#if error.stack}
<div class="mb-4">
<p class="font-semibold">Stack Trace:</p>
<pre
class="bg-base-200 p-2 rounded text-sm overflow-auto max-h-60"><code
>{error.stack}</code
></pre>
</div>
{/if}
<div class="modal-action">
<Button onclick={onclose} class="btn-primary">OK</Button>
</div>
</div>
{/if}
</dialog>

View file

@ -0,0 +1,59 @@
<script lang="ts">
import Button from "./Button.svelte";
const { onInstall } = $props<{ onInstall: () => void }>();
</script>
<div class="install-prompt-overlay">
<div class="install-prompt">
<img src="/favicon.png" alt="Portal BTC Logo" class="logo" />
<h2>Install Portal BTC Wallet</h2>
<p>
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.
</p>
<Button onclick={onInstall}>Install App</Button>
</div>
</div>
<style>
.logo {
width: 100px;
height: 100px;
margin-bottom: 1rem;
}
.install-prompt-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.install-prompt {
background: #1e1e1e;
padding: 2rem;
border-radius: 8px;
text-align: center;
max-width: 80%;
border: 2px solid #444;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
}
h2 {
margin-bottom: 1rem;
color: #fff;
}
p {
margin-bottom: 2rem;
color: #ccc;
}
</style>

View file

@ -0,0 +1,156 @@
<script lang="ts">
import Button from "./Button.svelte";
import { BASE_DOMAIN } from "$lib/config";
import { onMount } from "svelte";
import { getNpub } from "$lib/cashu.svelte";
let username = $state("");
let existingUsername = $state("");
let hasExistingUsername = $state(false);
let registrationStatus = $state("");
let isRegistering = $state(false);
let isCheckingUsername = $state(true);
onMount(async () => {
await checkExistingUsername();
});
async function checkExistingUsername() {
try {
const userNpub = await getNpub();
const response = await fetch(
`/api/check-username?npub=${encodeURIComponent(userNpub)}`
);
if (response.ok) {
const data = await response.json();
if (data.username) {
existingUsername = data.username;
hasExistingUsername = true;
}
}
} catch (err) {
console.error("Failed to check existing username:", err);
} finally {
isCheckingUsername = false;
}
}
async function registerUsername() {
if (!username.trim()) {
registrationStatus = "Please enter a username";
return;
}
isRegistering = true;
registrationStatus = "";
try {
const userNpub = await getNpub();
const response = await fetch(
`/.well-known/lnurlp/${encodeURIComponent(username)}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
npub: userNpub,
}),
}
);
if (response.ok) {
registrationStatus = "Username registered successfully!";
existingUsername = username;
hasExistingUsername = true;
username = "";
} else {
const errorText = await response.text();
registrationStatus = `Registration failed: ${errorText}`;
}
} catch (err) {
registrationStatus = `Registration failed: ${err instanceof Error ? err.message : "Unknown error"}`;
} finally {
isRegistering = false;
}
}
</script>
<div class="retro-card">
<h2>Lightning Address</h2>
{#if isCheckingUsername}
<p>Checking for existing username...</p>
{:else if hasExistingUsername}
<p>
Your Lightning address: <strong>{existingUsername}@{BASE_DOMAIN}</strong>
</p>
{:else}
<p>
Register a username to receive Lightning payments at {username ||
"yourname"}@{BASE_DOMAIN}
</p>
<div class="form-group">
<label for="username">Username:</label>
<input
id="username"
type="text"
value={username}
oninput={(e) => (username = (e.target as HTMLInputElement).value)}
placeholder="Enter username"
class="retro-input"
disabled={isRegistering}
/>
</div>
<Button
variant="primary"
onclick={registerUsername}
disabled={isRegistering}
>
{isRegistering ? "Registering..." : "Register Username"}
</Button>
{#if registrationStatus}
<p
class="status-message"
class:error={registrationStatus.includes("failed")}
>
{registrationStatus}
</p>
{/if}
{/if}
</div>
<style>
h2 {
margin: 0 0 1rem 0;
font-size: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.8rem;
}
.retro-input {
width: 100%;
margin-bottom: 0.5rem;
}
.status-message {
margin-top: 0.5rem;
font-size: 0.7rem;
color: var(--success-color);
}
.status-message.error {
color: var(--error-color);
}
</style>

View file

@ -0,0 +1,93 @@
<script lang="ts">
import Button from "./Button.svelte";
import { connectUri, nwcRelays } from "$lib/nwc.svelte";
let customRelays = $state("");
function updateNwcRelays() {
const relays = customRelays
.split(",")
.map((r) => r.trim())
.filter((r) => r);
if (relays.length > 0) {
nwcRelays.set(relays);
}
}
function copyNwcUri() {
navigator.clipboard.writeText($connectUri);
}
$effect(() => {
customRelays = $nwcRelays.join(", ");
});
</script>
<div class="retro-card">
<h2>Nostr Wallet Connect</h2>
<p>Configure your wallet to work with Nostr Wallet Connect clients.</p>
<p>
Nostr Wallet Connect is a protocol for connecting to Nostr wallets. It
allows you to send Nostr zaps from your Portal BTC Wallet.
</p>
<div class="form-group">
<label for="nwc-uri">Connection URI:</label>
<div class="input-with-button">
<input
id="nwc-uri"
type="text"
value={$connectUri}
readonly
class="retro-input"
/>
<Button onclick={copyNwcUri}>Copy</Button>
</div>
</div>
<div class="form-group">
<label for="relays">Custom Relays (comma-separated):</label>
<input
id="relays"
type="text"
value={customRelays}
oninput={(e) => (customRelays = (e.target as HTMLInputElement).value)}
placeholder="wss://relay1.com, wss://relay2.com"
class="retro-input"
/>
<Button onclick={updateNwcRelays}>Update Relays</Button>
</div>
</div>
<style>
h2 {
margin: 0 0 1rem 0;
font-size: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.8rem;
}
.retro-input {
width: 100%;
margin-bottom: 0.5rem;
}
.input-with-button {
display: flex;
gap: 0.5rem;
align-items: flex-end;
}
.input-with-button .retro-input {
flex: 1;
margin-bottom: 0;
}
</style>

View file

@ -0,0 +1,101 @@
<script lang="ts">
import { decryptMnemonic } from "$lib/wallet.svelte";
import { browser } from "$app/environment";
let {
open = $bindable(),
unlock,
}: { open: boolean; unlock: (pw: string) => 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();
});
function closeDialog() {
open = false;
}
async function attemptUnlock() {
if (!password.trim()) return;
error = "";
isValidating = true;
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 = "";
} catch (err) {
error = "Incorrect password";
console.error("Password validation failed:", err);
} finally {
isValidating = false;
}
}
</script>
{#if open}
<dialog bind:this={dialogEl} onclose={closeDialog}>
<h2>Unlock Wallet</h2>
<p>Enter your wallet password to decrypt your seed.</p>
{#if error}
<p
style="color: var(--error-color); font-size: 0.7rem; margin-bottom: 1rem;"
>
{error}
</p>
{/if}
<input
type="password"
class="retro-input"
bind:value={password}
placeholder="Password"
disabled={isValidating}
onkeydown={(e) => {
if (e.key === "Enter" && !isValidating) {
attemptUnlock();
}
}}
/>
<div style="margin-top:1rem;text-align:center;">
<button
class="retro-btn primary"
disabled={isValidating}
onclick={() => {
attemptUnlock();
}}>{isValidating ? "Validating..." : "Unlock"}</button
>
</div>
</dialog>
{/if}
<style>
dialog {
z-index: 2000;
}
dialog::backdrop {
background: rgba(0, 0, 0, 1);
}
h2 {
margin-top: 0;
margin-bottom: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1.25rem;
font-size: 0.75rem;
}
input {
width: 100%;
}
</style>

View file

@ -0,0 +1,55 @@
<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";
let payments = $state<Payment[]>([]);
const combinedPayments = $derived(
[...payments, ...$cashuTxns].sort((a, b) => b.timestamp - a.timestamp)
);
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}
<p class="empty">No payments yet.</p>
{:else}
{#each combinedPayments as payment (payment.txId)}
<PaymentHistoryItem {payment} />
{/each}
{/if}
</div>
<style>
.payment-history {
display: flex;
flex-direction: column;
width: 100%;
}
.empty {
text-align: center;
font-size: 0.9rem;
opacity: 0.8;
margin: 0.5rem 0;
}
</style>

View file

@ -0,0 +1,177 @@
<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";
interface CashuPayment {
txId: string;
paymentType: "receive" | "send";
amountSat: number;
timestamp: number;
status: string;
}
type PaymentLike = Payment | CashuPayment;
const { payment }: { payment: PaymentLike } = $props();
const formattedAmt = $derived.by(() => {
const prefix = payment.paymentType === "receive" ? "+" : "-";
return `${prefix}${satsComma(payment.amountSat)} sats`;
});
const formattedDate = $derived.by(() => {
const d = new Date(payment.timestamp * 1000);
return d.toISOString().slice(0, 16).replace("T", " ");
});
let isRefunding = $state(false);
async function refundPayment() {
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;
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}`);
} finally {
isRefunding = false;
}
}
</script>
<div class="item" class:failed={payment.status === "failed"}>
<div class="info">
<span class="date">{formattedDate}</span>
<span class="status">{payment.status}</span>
{#if payment.paymentType === "receive" && !payment.txId?.startsWith("cashu-")}
<span class="status">
{#if (payment as Payment).details.type === "bitcoin"}
<a href="https://mempool.space/tx/{payment.txId}" target="_blank">
...{payment.txId?.slice(-16)}
</a>
{:else if (payment as Payment).details.type === "liquid"}
<a href="https://liquid.network/tx/{payment.txId}" target="_blank">
...{payment.txId?.slice(-16)}
</a>
{:else if (payment as Payment).details.type === "lightning"}
<a
href="https://liquid.network/tx/{payment.details.claimTxId}"
target="_blank"
>
...{payment.txId?.slice(-16)}
</a>
{/if}
</span>
{/if}
</div>
<span class="amount {payment.paymentType}">{formattedAmt}</span>
{#if payment.status === "refundable"}
<Button variant="danger" onclick={refundPayment} disabled={isRefunding}>
{#if isRefunding}
Refunding…
{:else}
Refund
{/if}
</Button>
{/if}
</div>
<style>
a {
color: white;
}
.item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.8rem 1rem;
margin-bottom: 0.5rem;
border: 2px solid var(--accent-color);
border-radius: 6px;
background: rgba(0, 0, 0, 0.3);
font-family: "Press Start 2P", "VT323", monospace, sans-serif;
}
.info {
display: flex;
flex-direction: column;
}
.amount {
font-weight: bold;
text-shadow: 1px 1px 0 #000;
font-size: 0.8rem;
}
.amount.receive {
color: var(--success-color, #4caf50);
}
.amount.send {
color: var(--error-color, #f44336);
}
.status {
font-size: 0.7rem;
opacity: 0.8;
}
.date {
font-size: 0.65rem;
opacity: 0.6;
}
.failed {
border-color: var(--error-color);
opacity: 0.5;
transition: opacity 0.2s ease-in-out;
&:hover {
opacity: 1;
}
}
</style>

View file

@ -0,0 +1,65 @@
<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";
let { open = $bindable() }: { open: boolean } = $props();
let dialogEl: HTMLDialogElement | null;
$effect(() => {
if (open) dialogEl?.showModal();
else dialogEl?.close();
});
function closeDialog() {
open = false;
}
</script>
<dialog bind:this={dialogEl} onclose={() => (open = false)}>
<div class="content">
<h3>Receive</h3>
<Tabs labels={["Bitcoin", "Lightning", "Liquid", "Cashu"]}>
{#snippet children(idx)}
{#if idx === 0}
<ReceiveTab type="bitcoin" alt="Bitcoin Address" />
{:else if idx === 1}
<Tabs labels={["Address", "BOLT12"]}>
{#snippet children(idx)}
{#if idx === 0}
<ReceiveTab type="lightning" alt="Lightning Address" />
{:else}
<ReceiveTab type="bolt12" alt="Lightning Address" />
{/if}
{/snippet}
</Tabs>
{:else if idx === 2}
<ReceiveTab type="liquid" alt="Liquid Address" />
{:else}
<CashuTab />
{/if}
{/snippet}
</Tabs>
<Button onclick={closeDialog}>Confirm</Button>
</div>
</dialog>
<style>
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
h3 {
font-size: 1.2rem;
color: var(--accent-color);
margin-bottom: 1rem;
text-shadow: 2px 2px 0 #000;
letter-spacing: 2px;
}
</style>

View file

@ -0,0 +1,88 @@
<script lang="ts">
import { getBreezSDK } from "$lib/breez.svelte";
import { getCashuAddress } from "$lib/cashu.svelte";
import QRCode from "qrcode";
const { type, alt }: { type: string; alt: string } = $props();
const paymentMethod = $derived(
type === "bitcoin"
? "bitcoinAddress"
: type === "bolt12"
? "bolt12Offer"
: type === "lightning"
? "bolt11Invoice"
: "liquidAddress"
);
const paymentText = $derived(
paymentMethod === "bolt11Invoice"
? getCashuAddress()
: getBreezSDK().then((breezSDK) =>
breezSDK.prepareReceivePayment({ paymentMethod }).then((prep) =>
breezSDK.receivePayment({ prepareResponse: prep }).then((res) => {
let destination = res.destination;
if (destination.includes(":"))
destination = destination.split(":")[1];
if (destination.includes("?"))
destination = destination.split("?")[0];
return destination;
})
)
)
);
const qr = $derived(
paymentText.then((text) =>
QRCode.toDataURL(`${type}:${text}`, {
errorCorrectionLevel: "H",
})
)
);
</script>
{#await qr}
<div class="spinner"></div>
{:then qrDataUrl}
<img class="qr" src={qrDataUrl} alt="${alt}" />
{/await}
{#await paymentText then paymentText}
<pre class="addr">{paymentText}</pre>
{/await}
<style>
img.qr {
width: auto;
height: auto;
max-width: 400px;
max-height: 400px;
border: 2px solid var(--primary-color);
margin-bottom: 1.5rem;
background: #fff;
}
pre.addr {
font-family: monospace;
background: rgba(0, 0, 0, 0.2);
padding: 0.5rem;
border-radius: 4px;
overflow-x: auto;
max-width: 100%;
margin-bottom: 0.8rem;
color: var(--text-color);
}
.spinner {
width: 120px;
height: 120px;
border: 10px solid var(--surface-alt-color);
border-top: 10px solid var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 1.5rem auto 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,160 @@
<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";
let { open = $bindable(), onsent }: { open: boolean; onsent: () => void } =
$props();
let destination = $state("");
let amountSat = $state<number | "">("");
let status = $state("");
let requireAmount = $state(false);
$effect(() => {
(async () => {
if (!destination.trim()) {
requireAmount = false;
return;
}
try {
const breezSDK = await getBreezSDK();
console.log(breezSDK);
const parsed: InputType = await breezSDK.parse(destination);
if (
parsed.type === "bolt11" &&
parsed.invoice.amountMsat !== undefined &&
parsed.invoice.amountMsat > 0
) {
requireAmount = false;
amountSat = "";
} else {
requireAmount = true;
}
} catch {
requireAmount = true;
}
})();
});
let dialogEl: HTMLDialogElement;
async function handleSend() {
const sendGenerator = sendPayment(destination, Number(amountSat) || 0);
for await (const {
status: sendStatus,
error: sendError,
} of sendGenerator) {
if (sendError) return alert(sendError);
status = sendStatus || "";
}
onsent();
setTimeout(closeDialog, 1500);
}
$effect(() => {
if (open) {
status = "";
destination = "";
amountSat = "";
dialogEl.showModal();
} else dialogEl.close();
});
function closeDialog() {
open = false;
}
</script>
<dialog bind:this={dialogEl} onclose={() => (open = false)}>
<h3>Send Payment</h3>
<label>
Destination
<input
type="text"
bind:value={destination}
placeholder="bolt12 / invoice / liquid / bitcoin address"
required
/>
</label>
{#if requireAmount}
<label>
Amount (sats)
<input
type="number"
min="0"
step="1"
bind:value={amountSat}
placeholder="amount in sats"
/>
</label>
{/if}
{#if status}
<p class="status">{status}</p>
{/if}
<div class="actions">
<Button variant="primary" onclick={handleSend} disabled={!destination}>
Send
</Button>
<Button onclick={closeDialog}>Cancel</Button>
</div>
</dialog>
<style>
dialog {
position: relative;
background: var(--surface-color);
outline: none;
width: 100%;
max-width: clamp(450px, 80vw, 650px);
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;
}
h3 {
font-size: 1.2rem;
color: var(--accent-color);
margin-bottom: 1.5rem;
text-shadow: 2px 2px 0 #000;
letter-spacing: 2px;
}
label {
display: flex;
flex-direction: column;
font-size: 0.8rem;
margin-bottom: 1rem;
}
input {
margin-top: 0.4rem;
font-family: inherit;
font-size: 0.9rem;
padding: 0.4rem 0.6rem;
border: 2px solid var(--accent-color);
border-radius: 4px;
background: rgba(0, 0, 0, 0.2);
color: var(--text-color);
}
.status {
margin: 0.5rem 0 1rem;
font-size: 0.7rem;
color: var(--accent-color);
}
.actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
</style>

View file

@ -0,0 +1,158 @@
<script lang="ts">
let containerEl: HTMLDivElement;
let mouseX = $state(0);
let mouseY = $state(0);
function handleMouseMove(event: MouseEvent) {
if (!containerEl) return;
const { clientX, clientY } = event;
const { left, top, width, height } = containerEl.getBoundingClientRect();
mouseX = (clientX - left - width / 2) / (width / 2);
mouseY = (clientY - top - height / 2) / (height / 2);
}
</script>
<div
class="splash-screen"
bind:this={containerEl}
onmousemove={handleMouseMove}
onmouseleave={() => {
mouseX = 0;
mouseY = 0;
}}
>
<div class="grid-container" style="--mouse-x: {mouseX}; --mouse-y: {mouseY};">
<div class="grid" />
</div>
<div class="logo-container">
<img src="/favicon.png" alt="Portal BTC Logo" class="logo" />
</div>
<div class="scanlines"></div>
</div>
<style>
.splash-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: radial-gradient(ellipse at center, #1b2735 0%, #090a0f 100%);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
color: white;
opacity: 1;
transition: opacity 1s ease-out;
overflow: hidden;
}
.grid-container {
position: absolute;
width: 100%;
height: 100%;
perspective: 400px;
transform-style: preserve-3d;
transform: rotateX(calc(var(--mouse-y) * -5deg))
rotateY(calc(var(--mouse-x) * 5deg));
transition: transform 0.2s linear;
}
.grid {
position: absolute;
width: 250vw;
height: 250vh;
top: -75vh;
left: -75vw;
background-image:
linear-gradient(rgba(0, 255, 255, 0.4) 1px, transparent 1px),
linear-gradient(to right, rgba(0, 255, 255, 0.4) 1px, transparent 1px);
background-size: 50px 50px;
transform: rotateX(80deg) translateY(200px) translateZ(-300px);
animation: moveGrid 5s linear infinite;
will-change: background-position;
}
@keyframes moveGrid {
from {
background-position: 0 0;
}
to {
background-position: 0 -100px;
}
}
.logo-container {
position: relative;
width: 40vw;
max-width: 300px;
animation: flicker 3s infinite;
}
.logo {
width: 100%;
height: auto;
object-fit: contain;
animation: pulse 2.5s infinite ease-in-out;
filter: drop-shadow(0 0 7px #ff00ff) drop-shadow(0 0 15px #00ffff)
drop-shadow(0 0 20px #ff00ff);
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
@keyframes flicker {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.95;
}
52% {
opacity: 1;
}
55% {
opacity: 0.98;
}
56% {
opacity: 1;
}
}
.scanlines {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
to bottom,
rgba(18, 16, 16, 0) 50%,
rgba(0, 0, 0, 0.25) 50%
);
background-size: 100% 4px;
animation: scanlines 0.2s linear infinite;
pointer-events: none;
z-index: 10000;
}
@keyframes scanlines {
0% {
background-position: 0 0;
}
100% {
background-position: 0 4px;
}
}
</style>

View file

@ -0,0 +1,50 @@
<script lang="ts">
import type { Snippet } from "svelte";
const {
labels = [] as string[],
children,
}: { labels: string[]; children: Snippet<[number]> } = $props();
let active = $state(0);
function select(index: number) {
active = index;
}
</script>
<div class="tabs">
{#each labels as label, i}
<button class:selected={i === active} onclick={() => select(i)}>
{label}
</button>
{/each}
</div>
{#if labels.length > 0}
{@render children(active)}
{/if}
<style>
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tabs button {
flex: 1;
background: var(--surface-alt-color);
color: var(--text-color);
border: 2px solid var(--accent-color);
border-radius: 4px;
font-family: inherit;
font-size: 0.8rem;
padding: 0.4rem 0.2rem;
cursor: pointer;
transition: background 0.2s;
}
.tabs button.selected {
background: var(--accent-color);
color: var(--surface-color);
}
</style>

8
src/lib/config.ts Normal file
View file

@ -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";

25
src/lib/database.ts Normal file
View file

@ -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<Client> {
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;
}

76
src/lib/index.ts Normal file
View file

@ -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];
}

459
src/lib/nwc.svelte.ts Normal file
View file

@ -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<MetaEntry, string>;
paidInvoices!: Table<PaidInvoice, string>;
constructor() {
super("nwc");
this.version(2).stores({
meta: "&key",
paidInvoices: "&paymentHash",
});
}
}
const nwcDB = new NwcDB();
export const nwcPrivkey = persistentDbWritable<string | null, NwcDB>(
"nwc_privkey",
null,
nwcDB,
);
export const nwcSecret = persistentDbWritable<string | null, NwcDB>(
"nwc_secret",
null,
nwcDB,
);
export const nwcRelays = persistentDbWritable<string[], NwcDB>(
"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<string, unknown>;
}
interface JsonRpcSuccess<T = unknown> {
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<SimplePool["subscribeMany"]> | null = null;
let active = false;
export async function start(): Promise<void> {
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<void> {
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<void> {
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<unknown> {
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<void> {
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);
}

120
src/lib/payments.ts Normal file
View file

@ -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",
};
}
}

223
src/lib/style.css Normal file
View file

@ -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;
}

60
src/lib/utils.ts Normal file
View file

@ -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<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;
}

90
src/lib/wallet.svelte.ts Normal file
View file

@ -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<string> {
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<string> {
const secretKey = new TextEncoder().encode(mnemonic);
return nip49encrypt(secretKey, password);
}
export async function decryptMnemonic(
data: string,
password: string,
): Promise<string> {
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<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 function cacheMnemonic(mnemonic: string) {
cachedMnemonic.mnemonic = mnemonic;
}

145
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,145 @@
<script lang="ts">
import "$lib/style.css";
import "iconify-icon";
import { hasSeed, passwordRequest } 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 { start as startNwc } from "$lib/nwc.svelte";
import SplashScreen from "$lib/components/SplashScreen.svelte";
import ErrorDialog from "$lib/components/ErrorDialog.svelte";
import InstallPrompt from "$lib/components/InstallPrompt.svelte";
type AppError = {
message: string;
stack?: string;
};
let passwordDialogOpen = $state(false);
let showSplash = $state(true);
let currentError = $state<AppError | null>(null);
let showInstallPrompt = $state(false);
let deferredPrompt: Event | undefined = $state(undefined);
async function handleInstallClick() {
if (deferredPrompt) {
(deferredPrompt as any).prompt();
const { outcome } = await (deferredPrompt as any).userChoice;
if (outcome === "accepted") {
deferredPrompt = undefined;
showInstallPrompt = false;
}
}
}
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());
onMount(() => {
setTimeout(() => {
showSplash = false;
}, 3000);
window.addEventListener("error", (event: ErrorEvent) => {
event.preventDefault();
if (event.message.indexOf("Svelte error")) return;
currentError = { message: event.message, stack: event.error?.stack };
});
window.addEventListener(
"unhandledrejection",
(event: PromiseRejectionEvent) => {
const reason = event.reason;
if (reason instanceof Error) {
if (reason.message.indexOf("Svelte error")) return;
currentError = { message: reason.message, stack: reason.stack };
} else {
if (String(reason).indexOf("Svelte error")) return;
currentError = { message: String(reason) };
}
}
);
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
deferredPrompt = e;
if (
!window.matchMedia("(display-mode: standalone)").matches &&
/Mobi|Android/i.test(navigator.userAgent)
) {
showInstallPrompt = true;
}
});
if (!hasSeed() && !isOnSetup) {
goto("/setup");
}
});
</script>
{#if showInstallPrompt && deferredPrompt}
<InstallPrompt onInstall={handleInstallClick} />
{:else if showSplash}
<SplashScreen />
{:else}
<main class="app-wrapper">
{#if showSettingsButton}
<div class="settings-button-container">
<button class="retro-btn settings-btn" onclick={goToSettings}>
<iconify-icon icon="mdi:cog" width="16" height="16"></iconify-icon>
Settings
</button>
</div>
{/if}
{#if !isOnSetup || !passwordRequest.pending}
{@render children()}
{/if}
</main>
<PasswordDialog bind:open={passwordDialogOpen} unlock={handleUnlockAttempt} />
{/if}
<ErrorDialog error={currentError} onclose={() => (currentError = null)} />
<style>
.settings-button-container {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 10;
}
.settings-btn {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.7rem;
padding: 0.5rem 0.75rem;
}
</style>

1
src/routes/+layout.ts Normal file
View file

@ -0,0 +1 @@
export const ssr = false;

49
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,49 @@
<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;
}
function openSendDialog() {
sendDialogOpen = true;
}
function onPaymentSent() {
refreshBalances();
}
</script>
<ReceiveDialog bind:open={receiveDialogOpen} />
<SendDialog bind:open={sendDialogOpen} onsent={onPaymentSent} />
<div class="container">
<BalanceDisplay
balance={balances.balance}
pending={balances.pendingReceive - balances.pendingSend}
/>
<div class="retro-card send-receive-buttons">
<Button variant="primary" onclick={openReceiveDialog}>Receive</Button>
<Button variant="danger" onclick={openSendDialog}>Send</Button>
</div>
<PaymentHistory />
</div>

View file

@ -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",
},
});
}

View file

@ -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",
},
});
}

View file

@ -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");
}
};

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { goto } from "$app/navigation";
import Button from "$lib/components/Button.svelte";
import LightningSettings from "$lib/components/LightningSettings.svelte";
import NostrWalletConnect from "$lib/components/NostrWalletConnect.svelte";
import DangerZone from "$lib/components/DangerZone.svelte";
function goBack() {
goto("/");
}
</script>
<div class="container">
<div class="retro-card">
<div class="header">
<h1>Settings</h1>
<Button onclick={goBack}>Back</Button>
</div>
</div>
<LightningSettings />
<NostrWalletConnect />
<DangerZone />
</div>
<style>
.container {
width: 100%;
max-width: 32rem;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 1.5rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
h1 {
margin: 0;
font-size: 1.2rem;
}
</style>

View file

@ -0,0 +1,203 @@
<script lang="ts">
import { goto } from "$app/navigation";
import Button from "$lib/components/Button.svelte";
import {
createMnemonic,
encryptMnemonic,
storeEncryptedSeed,
cacheMnemonic,
isValidMnemonic,
} from "$lib/wallet.svelte";
import { start as startNwc } from "$lib/nwc.svelte";
let stage: "choice" | "mnemonic" | "existing" | "password" | "done" =
"choice";
let password: string;
let confirmPassword: string;
let passwordError = "";
let userMnemonic = "";
let mnemonicError = "";
function chooseNewSeed() {
userMnemonic = createMnemonic();
stage = "mnemonic";
}
function chooseExistingSeed() {
stage = "existing";
}
function continueToPassword() {
stage = "password";
}
function validateAndContinue() {
mnemonicError = "";
const trimmedMnemonic = userMnemonic.trim();
if (!trimmedMnemonic) {
mnemonicError = "Please enter your seed phrase";
return;
}
const cleanedMnemonic = cleanMnemonicInput(trimmedMnemonic);
if (!isValidMnemonic(cleanedMnemonic)) {
mnemonicError =
"Invalid seed phrase. Please check your words and try again.";
return;
}
userMnemonic = cleanedMnemonic;
stage = "password";
}
function cleanMnemonicInput(input: string): string {
const numberedPattern = /\d+\.\s*([a-zA-Z]+)/g;
const matches = input.match(numberedPattern);
if (matches) {
const words = matches.map((match) =>
match.replace(/\d+\.\s*/, "").trim()
);
return words.join(" ");
}
return input.replace(/\s+/g, " ");
}
async function finishSetup() {
passwordError = "";
if (!password || password.length < 8) {
passwordError = "Password must be at least 8 characters";
return;
}
if (!confirmPassword || password !== confirmPassword) {
passwordError = "Passwords do not match";
return;
}
const encrypted = await encryptMnemonic(userMnemonic, password);
storeEncryptedSeed(encrypted);
cacheMnemonic(userMnemonic);
startNwc();
goto("/");
}
</script>
<div class="container">
{#if stage === "choice"}
<h1>Wallet Setup</h1>
<div class="warning">
This is a web wallet. Your funds <strong>ARE</strong> at risk. Store only amounts
you are willing to lose. For larger amounts, use a hardware wallet.
</div>
<p>Choose how you want to set up your wallet:</p>
<div class="choice-buttons">
<Button variant="primary" onclick={chooseNewSeed}>Create New Seed</Button>
<Button variant="secondary" onclick={chooseExistingSeed}>
Enter Existing Seed
</Button>
</div>
{:else if stage === "mnemonic"}
<h1>Wallet Setup</h1>
<div class="warning">
This is a web wallet. Your funds <strong>ARE</strong> at risk. Store only amounts
you are willing to lose. For larger amounts, use a hardware wallet.
</div>
<p>
Please write down these 12 words in order and keep them safe. They are the
only way to recover your funds.
</p>
<div class="mnemonic">
{#each userMnemonic.split(" ") as word, i}
<div>{i + 1}. {word}</div>
{/each}
</div>
<div style="margin-top: 1rem;">
<Button variant="primary" onclick={continueToPassword}>
I have saved my seed
</Button>
</div>
{:else if stage === "existing"}
<h1>Enter Existing Seed</h1>
<p>Enter your existing 12-word seed phrase to restore your wallet.</p>
<div>
<textarea
class="retro-input seed-input"
placeholder="Enter your 12-word seed phrase separated by spaces"
bind:value={userMnemonic}
oninput={(e) => {
userMnemonic = cleanMnemonicInput(
(e.target as HTMLTextAreaElement).value
);
}}
rows="3"
></textarea>
</div>
{#if mnemonicError}
<p style="color: red;">{mnemonicError}</p>
{/if}
<div style="margin-top: 1rem;">
<Button variant="primary" onclick={validateAndContinue}>Continue</Button>
</div>
{:else if stage === "password"}
<h1>Set Password</h1>
<p>
Choose a password (min 8 characters). It will encrypt your seed locally.
</p>
<div>
<input
type="password"
class="retro-input"
placeholder="Password"
bind:value={password}
/>
</div>
<div>
<input
type="password"
class="retro-input"
placeholder="Confirm Password"
bind:value={confirmPassword}
/>
</div>
{#if passwordError}
<p style="color: red;">{passwordError}</p>
{/if}
<div style="margin-top: 1rem;">
<Button variant="primary" onclick={finishSetup}>Finish Setup</Button>
</div>
{/if}
</div>
<style>
.choice-buttons {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.seed-input {
width: 100%;
min-height: 80px;
resize: vertical;
font-family: monospace;
}
.mnemonic {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.5rem;
background: #111;
padding: 1rem;
border-radius: 6px;
font-family: monospace;
}
.warning {
background: #330000;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
color: #ff5555;
}
</style>

65
src/service-worker.js Normal file
View file

@ -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());
});

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

21
static/manifest.json Normal file
View file

@ -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"
}
]
}

19
svelte.config.js Normal file
View file

@ -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;

19
tsconfig.json Normal file
View file

@ -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
}

9
vite.config.ts Normal file
View file

@ -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"],
},
});