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

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