Let's get this started
This commit is contained in:
commit
c6ce63b6fa
46 changed files with 3983 additions and 0 deletions
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
portal-btc.db
|
1
.npmrc
Normal file
1
.npmrc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
engine-strict=true
|
59
README.md
Normal file
59
README.md
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
# Portal BTC Wallet
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="static/favicon.png" width="128" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 align="center">Your portal to everything Bitcoin</h3>
|
||||||
|
|
||||||
|
Portal is a self-custodial web wallet for Bitcoiners who want to use Bitcoin, including lightning, liquid and cashu but don't want to run their own Lightning node.
|
||||||
|
|
||||||
|
It's built for simplicity, speed, and interoperability, giving you the tools to use Bitcoin on your own terms.
|
||||||
|
|
||||||
|
It's a Progressive Web App (PWA), which means you can "install" it on your phone or desktop for a native-like experience without going through an app store.
|
||||||
|
|
||||||
|
## What can it do?
|
||||||
|
|
||||||
|
- **Go Beyond the Main Chain**: Portal supports multiple Bitcoin layers in one interface.
|
||||||
|
- **Liquid Network**: Make fast, confidential transactions using Bitcoin's most mature sidechain. Powered by the Breez SDK.
|
||||||
|
- **Cashu**: Experiment with truly private, Chaumian e-cash for small, anonymous payments.
|
||||||
|
- **Get Your Own Lightning Address**: Stop messing with invoices. Get a clean, reusable Lightning address (e.g., `you@portalbtc.live`) for easy payments via LNURL.
|
||||||
|
- **Connect to Nostr, Safely**: Use Nostr Wallet Connect (NWC) to interact with other Nostr apps without ever exposing your private keys.
|
||||||
|
- **You're in Control**: This is a self-custodial wallet. Your keys, your coins.
|
||||||
|
|
||||||
|
## How It's Built
|
||||||
|
|
||||||
|
Portal is built on a modern, open-source stack. We believe in transparency, so you can always see how it works under the hood.
|
||||||
|
|
||||||
|
- **Framework**: [SvelteKit](https://kit.svelte.dev/)
|
||||||
|
- **Bitcoin/Lightning**: [Breez SDK (Liquid)](https://breez.technology/), [cashu-ts](https://github.com/cashubtc/cashu-ts)
|
||||||
|
- **Nostr**: [nostr-tools](https://github.com/nostr-protocol/nostr-tools)
|
||||||
|
- **Database**: [Turso / libsql](https://turso.tech/)
|
||||||
|
- **UI**: Svelte 5
|
||||||
|
|
||||||
|
## Run it Yourself
|
||||||
|
|
||||||
|
Want to hack on Portal or run your own instance? It's easy.
|
||||||
|
|
||||||
|
**You'll need:**
|
||||||
|
- [Bun](https://bun.sh/)
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Clone the repository:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone ssh://git@git.arx-ccn.com:222/Arx/PortalBTC.git
|
||||||
|
```
|
||||||
|
2. Jump into the directory and install packages:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd portal-btc-wallet && bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the local development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
You should now be able to see it running at `http://localhost:5173`.
|
40
package.json
Normal file
40
package.json
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"name": "portal-btc-wallet",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"lint": "prettier --check ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@breeztech/breez-sdk-liquid": "^0.9.2-rc2",
|
||||||
|
"@cashu/cashu-ts": "^2.5.2",
|
||||||
|
"@libsql/client": "^0.15.9",
|
||||||
|
"@scure/base": "^1.2.6",
|
||||||
|
"@scure/bip39": "^1.6.0",
|
||||||
|
"@sveltejs/adapter-auto": "^6.0.1",
|
||||||
|
"@sveltejs/adapter-node": "^5.2.12",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"dexie": "^4.0.11",
|
||||||
|
"iconify-icon": "^3.0.0",
|
||||||
|
"nostr-tools": "^2.15.0",
|
||||||
|
"qrcode": "^1.5.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/kit": "^2.16.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^6.2.6",
|
||||||
|
"vite-plugin-devtools-json": "^0.2.0"
|
||||||
|
}
|
||||||
|
}
|
13
src/app.d.ts
vendored
Normal file
13
src/app.d.ts
vendored
Normal 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
13
src/app.html
Normal 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
59
src/lib/breez.svelte.ts
Normal 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
372
src/lib/cashu.svelte.ts
Normal 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();
|
||||||
|
}
|
143
src/lib/components/BalanceDisplay.svelte
Normal file
143
src/lib/components/BalanceDisplay.svelte
Normal 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>
|
21
src/lib/components/Button.svelte
Normal file
21
src/lib/components/Button.svelte
Normal 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>
|
29
src/lib/components/CashuTab.svelte
Normal file
29
src/lib/components/CashuTab.svelte
Normal 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>
|
122
src/lib/components/DangerZone.svelte
Normal file
122
src/lib/components/DangerZone.svelte
Normal 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>
|
47
src/lib/components/ErrorDialog.svelte
Normal file
47
src/lib/components/ErrorDialog.svelte
Normal 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>
|
59
src/lib/components/InstallPrompt.svelte
Normal file
59
src/lib/components/InstallPrompt.svelte
Normal 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>
|
156
src/lib/components/LightningSettings.svelte
Normal file
156
src/lib/components/LightningSettings.svelte
Normal 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>
|
93
src/lib/components/NostrWalletConnect.svelte
Normal file
93
src/lib/components/NostrWalletConnect.svelte
Normal 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>
|
101
src/lib/components/PasswordDialog.svelte
Normal file
101
src/lib/components/PasswordDialog.svelte
Normal 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>
|
55
src/lib/components/PaymentHistory.svelte
Normal file
55
src/lib/components/PaymentHistory.svelte
Normal 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>
|
177
src/lib/components/PaymentHistoryItem.svelte
Normal file
177
src/lib/components/PaymentHistoryItem.svelte
Normal 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>
|
65
src/lib/components/ReceiveDialog.svelte
Normal file
65
src/lib/components/ReceiveDialog.svelte
Normal 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>
|
88
src/lib/components/ReceiveTab.svelte
Normal file
88
src/lib/components/ReceiveTab.svelte
Normal 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>
|
160
src/lib/components/SendDialog.svelte
Normal file
160
src/lib/components/SendDialog.svelte
Normal 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>
|
158
src/lib/components/SplashScreen.svelte
Normal file
158
src/lib/components/SplashScreen.svelte
Normal 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>
|
50
src/lib/components/Tabs.svelte
Normal file
50
src/lib/components/Tabs.svelte
Normal 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
8
src/lib/config.ts
Normal 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
25
src/lib/database.ts
Normal 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
76
src/lib/index.ts
Normal 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
459
src/lib/nwc.svelte.ts
Normal 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
120
src/lib/payments.ts
Normal 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
223
src/lib/style.css
Normal 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
60
src/lib/utils.ts
Normal 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
90
src/lib/wallet.svelte.ts
Normal 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
145
src/routes/+layout.svelte
Normal 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
1
src/routes/+layout.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const ssr = false;
|
49
src/routes/+page.svelte
Normal file
49
src/routes/+page.svelte
Normal 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>
|
|
@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
139
src/routes/.well-known/lnurlp/[username]/+server.ts
Normal file
139
src/routes/.well-known/lnurlp/[username]/+server.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
33
src/routes/api/check-username/+server.ts
Normal file
33
src/routes/api/check-username/+server.ts
Normal 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");
|
||||||
|
}
|
||||||
|
};
|
49
src/routes/settings/+page.svelte
Normal file
49
src/routes/settings/+page.svelte
Normal 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>
|
203
src/routes/setup/+page.svelte
Normal file
203
src/routes/setup/+page.svelte
Normal 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
65
src/service-worker.js
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import { build, files, version } from "$service-worker";
|
||||||
|
|
||||||
|
const CACHE = `cache-${version}`;
|
||||||
|
|
||||||
|
const ASSETS = [...build, ...files];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
async function addFilesToCache() {
|
||||||
|
const cache = await caches.open(CACHE);
|
||||||
|
await cache.addAll(ASSETS);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(addFilesToCache());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
async function deleteOldCaches() {
|
||||||
|
for (const key of await caches.keys()) {
|
||||||
|
if (key !== CACHE) await caches.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(deleteOldCaches());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
if (event.request.method !== "GET") return;
|
||||||
|
|
||||||
|
async function respond() {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
const cache = await caches.open(CACHE);
|
||||||
|
|
||||||
|
if (ASSETS.includes(url.pathname)) {
|
||||||
|
const response = await cache.match(url.pathname);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(event.request);
|
||||||
|
|
||||||
|
if (!(response instanceof Response)) {
|
||||||
|
throw new Error("invalid response from fetch");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
cache.put(event.request, response.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
const response = await cache.match(event.request);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(respond());
|
||||||
|
});
|
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 MiB |
21
static/manifest.json
Normal file
21
static/manifest.json
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "Portal BTC Wallet",
|
||||||
|
"short_name": "Portal BTC",
|
||||||
|
"description": "Your universal wallet for everything Bitcoin and Nostr.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/favicon.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/favicon.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
19
svelte.config.js
Normal file
19
svelte.config.js
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import adapter from "@sveltejs/adapter-node";
|
||||||
|
// import adapter from '@sveltejs/adapter-auto';
|
||||||
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
// Consult https://svelte.dev/docs/kit/integrations
|
||||||
|
// for more information about preprocessors
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
|
kit: {
|
||||||
|
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||||
|
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||||
|
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||||
|
adapter: adapter(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||||
|
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||||
|
}
|
9
vite.config.ts
Normal file
9
vite.config.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()],
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ["@breeztech/breez-sdk-liquid"],
|
||||||
|
},
|
||||||
|
});
|
Loading…
Add table
Add a link
Reference in a new issue