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

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>