5 Commits

Author SHA1 Message Date
woikos
57434681f9 Release v1.1.0 - Add WebLN API support for Lightning wallet integration
- Add window.webln API for web app Lightning wallet integration
- Implement webln.enable(), getInfo(), sendPayment(), makeInvoice() methods
- Add WebLN permission prompts with proper amount display for payments
- Dispatch webln:ready and webln:enabled events per WebLN standard
- Add NWC client caching for persistent wallet connections
- Implement inline BOLT11 invoice amount parsing
- Always prompt for sendPayment (security-critical, irreversible)
- Add signMessage/verifyMessage stubs that return "not supported"
- Fix response handling for undefined returns in content script

Files modified:
- projects/common/src/lib/models/nostr.ts (WeblnMethod, ExtensionMethod types)
- projects/common/src/lib/models/webln.ts (new - WebLN response types)
- projects/common/src/public-api.ts (export webln types)
- projects/{chrome,firefox}/src/plebian-signer-extension.ts (window.webln)
- projects/{chrome,firefox}/src/background.ts (WebLN handlers)
- projects/{chrome,firefox}/src/background-common.ts (WebLN permissions)
- projects/{chrome,firefox}/public/prompt.html (WebLN cards)
- projects/{chrome,firefox}/src/prompt.ts (WebLN method handling)
- projects/common/src/lib/services/storage/types.ts (ExtensionMethod type)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 09:02:05 +01:00
woikos
586e2ab23f Release v1.0.11 - Add dev mode with test prompt button on all headers
- Add Dev Mode toggle to settings that persists in vault metadata
- Add test permission prompt button () to all page headers when dev mode enabled
- Move devMode and onTestPrompt to NavComponent base class for inheritance
- Refactor all home components to extend NavComponent
- Simplify permission prompt layout: remove duplicate domain from header
- Convert permission descriptions to flowing single paragraphs
- Update header-buttons styling for consistent lock/magic button layout

Files modified:
- projects/common/src/lib/common/nav-component.ts (devMode, onTestPrompt)
- projects/common/src/lib/services/storage/types.ts (devMode property)
- projects/common/src/lib/services/storage/signer-meta-handler.ts (setDevMode)
- projects/common/src/lib/styles/_common.scss (header-buttons styling)
- projects/*/src/app/components/home/*/settings.component.* (dev mode UI)
- projects/*/src/app/components/home/*/*.component.* (extend NavComponent)
- projects/*/public/prompt.html (simplified layout)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 07:41:51 +01:00
woikos
5ca6eb177c Release v1.0.10 - Add unlock popup for locked vault
- Add unlock popup window that appears when vault is locked and a NIP-07
  request is made (similar to permission prompt popup)
- Implement standalone vault unlock logic in background script using
  Argon2id key derivation and AES-GCM decryption
- Queue pending NIP-07 requests while waiting for unlock, process after
  success
- Add unlock.html and unlock.ts for both Chrome and Firefox extensions

Files modified:
- package.json (version bump to v1.0.10)
- projects/chrome/public/unlock.html (new)
- projects/chrome/src/unlock.ts (new)
- projects/chrome/src/background.ts
- projects/chrome/src/background-common.ts
- projects/chrome/custom-webpack.config.ts
- projects/chrome/tsconfig.app.json
- projects/firefox/public/unlock.html (new)
- projects/firefox/src/unlock.ts (new)
- projects/firefox/src/background.ts
- projects/firefox/src/background-common.ts
- projects/firefox/custom-webpack.config.ts
- projects/firefox/tsconfig.app.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 06:23:36 +01:00
woikos
ebc96e7201 Release v1.0.9 - Add wallet tab with Cashu and Lightning support
- Add wallet tab with NWC (Nostr Wallet Connect) Lightning support
- Add Cashu ecash wallet with mint management, send/receive tokens
- Add Cashu deposit feature (mint via Lightning invoice)
- Add token viewer showing proof amounts and timestamps
- Add refresh button with auto-refresh for spent proof detection
- Add browser sync warning for Cashu users on welcome screen
- Add Cashu onboarding info panel with storage considerations
- Add settings page sync info note explaining how to change sync
- Add backups page for vault snapshot management
- Add About section to identity (You) page
- Fix lint accessibility issues in wallet component

Files modified:
- projects/common/src/lib/services/nwc/* (new)
- projects/common/src/lib/services/cashu/* (new)
- projects/common/src/lib/services/storage/* (extended)
- projects/chrome/src/app/components/home/wallet/*
- projects/firefox/src/app/components/home/wallet/*
- projects/chrome/src/app/components/welcome/*
- projects/firefox/src/app/components/welcome/*
- projects/chrome/src/app/components/home/settings/*
- projects/firefox/src/app/components/home/settings/*
- projects/chrome/src/app/components/home/identity/*
- projects/firefox/src/app/components/home/identity/*
- projects/chrome/src/app/components/home/backups/* (new)
- projects/firefox/src/app/components/home/backups/* (new)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 15:40:25 +01:00
woikos
1f8d478cd7 Update repository URLs to GitHub
Change all references from git.mleku.dev/mleku/plebeian-signer to
github.com/PlebeianApp/plebeian-signer for the public release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 13:45:23 +01:00
103 changed files with 12892 additions and 524 deletions

View File

@@ -28,7 +28,7 @@ The repository is configured as monorepo to hold the extensions for Chrome and F
To build and run the Chrome extension from this code:
```
git clone https://git.mleku.dev/mleku/plebeian-signer
git clone https://github.com/PlebeianApp/plebeian-signer.git
cd plebeian-signer
npm ci
npm run build:chrome
@@ -46,7 +46,7 @@ then
To build and run the Firefox extension from this code:
```
git clone https://git.mleku.dev/mleku/plebeian-signer
git clone https://github.com/PlebeianApp/plebeian-signer.git
cd plebeian-signer
npm ci
npm run build:firefox

View File

@@ -51,8 +51,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumWarning": "20kB",
"maximumError": "25kB"
}
],
"optimization": {
@@ -154,8 +154,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumWarning": "20kB",
"maximumError": "25kB"
}
],
"optimization": {

View File

@@ -86,7 +86,7 @@ You have full control over your data:
## Open Source
Plebeian Signer is open source software. You can audit the code yourself:
- Repository: https://git.mleku.dev/mleku/plebeian-signer
- Repository: https://github.com/PlebeianApp/plebeian-signer
## Children's Privacy
@@ -99,7 +99,7 @@ If we make changes to this privacy policy, we will update the "Last Updated" dat
## Contact
For privacy-related questions or concerns, please open an issue on our repository:
https://git.mleku.dev/mleku/plebeian-signer/issues
https://github.com/PlebeianApp/plebeian-signer/issues
---

View File

@@ -69,7 +69,7 @@ You need to host the privacy policy at a public URL. Options:
2. **Simple webpage** - Create a basic HTML page
3. **Gist** - Create a public GitHub gist
Example URL format: `https://git.mleku.dev/mleku/plebeian-signer/src/branch/main/docs/store/PRIVACY_POLICY.md`
Example URL format: `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md`
---
@@ -102,8 +102,8 @@ Example URL format: `https://git.mleku.dev/mleku/plebeian-signer/src/branch/main
- Upload promotional tiles if you have them
**Additional Fields:**
- **Official URL:** `https://git.mleku.dev/mleku/plebeian-signer`
- **Support URL:** `https://git.mleku.dev/mleku/plebeian-signer/issues`
- **Official URL:** `https://github.com/PlebeianApp/plebeian-signer`
- **Support URL:** `https://github.com/PlebeianApp/plebeian-signer/issues`
### Step 4: Privacy Tab
@@ -181,8 +181,8 @@ Firefox may request source code because the extension uses bundled/minified Java
- **Categories:** Privacy & Security
**Additional Details:**
- **Homepage:** `https://git.mleku.dev/mleku/plebeian-signer`
- **Support URL:** `https://git.mleku.dev/mleku/plebeian-signer/issues`
- **Homepage:** `https://github.com/PlebeianApp/plebeian-signer`
- **Support URL:** `https://github.com/PlebeianApp/plebeian-signer/issues`
- **License:** Select appropriate license
- **Privacy Policy:** Paste URL to hosted privacy policy

View File

@@ -66,8 +66,8 @@ Plebeian Signer is open source and respects your privacy:
### Links
- Source Code: https://git.mleku.dev/mleku/plebeian-signer
- Report Issues: https://git.mleku.dev/mleku/plebeian-signer/issues
- Source Code: https://github.com/PlebeianApp/plebeian-signer
- Report Issues: https://github.com/PlebeianApp/plebeian-signer/issues
---

View File

@@ -5,7 +5,7 @@ Developer accounts are set up. This document covers the remaining steps.
## Privacy Policy URL
```
https://git.mleku.dev/mleku/plebeian-signer/src/branch/main/docs/store/PRIVACY_POLICY.md
https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md
```
## Screenshots Needed
@@ -48,7 +48,7 @@ Upload your screenshots.
| Field | Value |
|-------|-------|
| Single Purpose | Manage Nostr identities and sign cryptographic events for web applications |
| Privacy Policy URL | `https://git.mleku.dev/mleku/plebeian-signer/src/branch/main/docs/store/PRIVACY_POLICY.md` |
| Privacy Policy URL | `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md` |
**Permission Justifications:**
@@ -100,9 +100,9 @@ Build instructions to provide:
| Summary | Secure Nostr identity manager. Sign events without exposing private keys. Multi-identity support with NIP-07 compatibility. |
| Description | Copy from `docs/store/STORE_DESCRIPTION.md` |
| Categories | Privacy & Security |
| Homepage | `https://git.mleku.dev/mleku/plebeian-signer` |
| Support URL | `https://git.mleku.dev/mleku/plebeian-signer/issues` |
| Privacy Policy | `https://git.mleku.dev/mleku/plebeian-signer/src/branch/main/docs/store/PRIVACY_POLICY.md` |
| Homepage | `https://github.com/PlebeianApp/plebeian-signer` |
| Support URL | `https://github.com/PlebeianApp/plebeian-signer/issues` |
| Privacy Policy | `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md` |
Upload your screenshots.

325
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "plebeian-signer",
"version": "v0.0.9",
"version": "v1.0.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plebeian-signer",
"version": "v0.0.9",
"version": "v1.0.8",
"dependencies": {
"@angular/animations": "^19.0.0",
"@angular/common": "^19.0.0",
@@ -16,13 +16,16 @@
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@cashu/cashu-ts": "^3.2.0",
"@nostr-dev-kit/ndk": "^2.11.0",
"@popperjs/core": "^2.11.8",
"@types/qrcode": "^1.5.6",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"buffer": "^6.0.3",
"hash-wasm": "^4.11.0",
"nostr-tools": "^2.10.4",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"webextension-polyfill": "^0.12.0",
@@ -36,6 +39,7 @@
"@types/bootstrap": "^5.2.10",
"@types/chrome": "^0.0.293",
"@types/jasmine": "~5.1.0",
"@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.12.1",
"angular-eslint": "19.0.2",
"eslint": "^9.16.0",
@@ -4712,6 +4716,70 @@
"node": ">=6.9.0"
}
},
"node_modules/@cashu/cashu-ts": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-3.2.0.tgz",
"integrity": "sha512-wOdqenmPs92+5feU2GIg92QcdNmCdg4AIau7Lq6G/uw1t+t/osjygupr2dmDzdQx7JBWHHNoVaUDSJm1G8phYg==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^2.0.1",
"@noble/hashes": "^2.0.1",
"@scure/bip32": "^2.0.1"
},
"engines": {
"node": ">=22.4.0"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@noble/curves": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@scure/bip32": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@@ -8157,7 +8225,6 @@
"version": "22.13.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.0.tgz",
"integrity": "sha512-ClIbNe36lawluuvq3+YYhnIN2CELi+6q8NpnM7PYp4hBn/TatfboPgVSm2rwKRfnV2M+Ty9GWDFI64KEe+kysA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
@@ -8173,6 +8240,15 @@
"@types/node": "*"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.9.18",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz",
@@ -8237,6 +8313,13 @@
"@types/node": "*"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/webextension-polyfill": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/@types/webextension-polyfill/-/webextension-polyfill-0.12.1.tgz",
@@ -9245,7 +9328,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -9925,6 +10007,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001696",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001696.tgz",
@@ -10192,7 +10283,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -10205,7 +10295,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
@@ -10643,6 +10732,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -10772,6 +10870,12 @@
"node": ">=0.3.1"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dns-packet": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
@@ -12083,7 +12187,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -16124,7 +16227,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -16273,7 +16375,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -16495,6 +16596,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
@@ -16744,6 +16854,177 @@
"node": ">=0.9"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@@ -16952,7 +17233,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -16968,6 +17248,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -17646,6 +17932,12 @@
"node": ">= 0.8"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -19024,7 +19316,6 @@
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": {
@@ -19856,6 +20147,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wildcard": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
@@ -19877,7 +20174,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -19966,7 +20262,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -19976,14 +20271,12 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -19993,7 +20286,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -20008,7 +20300,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"

View File

@@ -1,12 +1,12 @@
{
"name": "plebeian-signer",
"version": "v1.0.8",
"version": "v1.1.0",
"custom": {
"chrome": {
"version": "v1.0.8"
"version": "v1.1.0"
},
"firefox": {
"version": "v1.0.8"
"version": "v1.1.0"
}
},
"scripts": {
@@ -36,13 +36,16 @@
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@cashu/cashu-ts": "^3.2.0",
"@nostr-dev-kit/ndk": "^2.11.0",
"@popperjs/core": "^2.11.8",
"@types/qrcode": "^1.5.6",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"buffer": "^6.0.3",
"hash-wasm": "^4.11.0",
"nostr-tools": "^2.10.4",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"webextension-polyfill": "^0.12.0",
@@ -56,6 +59,7 @@
"@types/bootstrap": "^5.2.10",
"@types/chrome": "^0.0.293",
"@types/jasmine": "~5.1.0",
"@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.12.1",
"angular-eslint": "19.0.2",
"eslint": "^9.16.0",

View File

@@ -22,5 +22,9 @@ module.exports = {
import: 'src/options.ts',
runtime: false,
},
unlock: {
import: 'src/unlock.ts',
runtime: false,
},
},
} as Configuration;

View File

@@ -2,8 +2,8 @@
"manifest_version": 3,
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
"version": "1.0.8",
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
"version": "1.1.0",
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
"options_page": "options.html",
"permissions": [
"windows",

View File

@@ -27,11 +27,66 @@
.page {
height: 100%;
display: grid;
grid-template-rows: 1fr 60px;
grid-template-rows: 1fr auto;
grid-template-columns: 1fr;
overflow-y: hidden;
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--size);
background: var(--background);
}
.action-row {
display: flex;
align-items: center;
gap: 8px;
}
.action-label {
width: 60px;
font-size: 13px;
font-weight: 500;
color: var(--muted-foreground);
}
.action-buttons {
display: flex;
gap: 8px;
flex: 1;
}
.action-buttons button {
flex: 1;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-reject {
background: var(--muted);
color: var(--foreground);
}
.btn-reject:hover {
background: var(--border);
}
.btn-accept {
background: var(--primary);
color: var(--primary-foreground);
}
.btn-accept:hover {
opacity: 0.9;
}
.card {
padding: var(--size);
background: var(--background-light);
@@ -54,6 +109,12 @@
font-size: 12px;
color: gray;
}
.description {
margin: 0;
text-align: center;
line-height: 1.5;
}
</style>
</head>
<body>
@@ -63,64 +124,31 @@
<span id="titleSpan" style="font-weight: 400 !important"></span>
</div>
<span
class="host-INSERT sam-align-self-center sam-text-muted"
style="font-weight: 500"
></span>
<!-- Card for getPublicKey -->
<div id="cardGetPublicKey" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">read your public key</b> <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your public key</b> for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card for getRelays -->
<div id="cardGetRelays" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">read your relays</b> <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your relays</b> for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card for signEvent -->
<div id="cardSignEvent" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">sign an event</b> (kind
<span id="kindSpan"></span>) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">sign an event</b> (kind <span id="kindSpan"></span>)
for the selected identity <b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for signEvent -->
@@ -130,20 +158,11 @@
<!-- Card for nip04.encrypt -->
<div id="cardNip04Encrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">encrypt a text</b> (NIP04) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">encrypt a text</b> (NIP04) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip04.encrypt -->
@@ -153,20 +172,11 @@
<!-- Card for nip44.encrypt -->
<div id="cardNip44Encrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">encrypt a text</b> (NIP44) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">encrypt a text</b> (NIP44) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip44.encrypt -->
@@ -176,20 +186,11 @@
<!-- Card for nip04.decrypt -->
<div id="cardNip04Decrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">decrypt a text</b> (NIP04) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">decrypt a text</b> (NIP04) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip04.decrypt -->
@@ -199,72 +200,83 @@
<!-- Card for nip44.decrypt -->
<div id="cardNip44Decrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">decrypt a text</b> (NIP44) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">decrypt a text</b> (NIP44) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip44.decrypt -->
<div id="card2Nip44Decrypt" class="card sam-mt sam-ml sam-mr">
<div id="card2Nip44Decrypt_text" class="text"></div>
</div>
<!-- Card for webln.enable -->
<div id="cardWeblnEnable" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">connect to your Lightning wallet</b>.
</p>
</div>
<!-- Card for webln.getInfo -->
<div id="cardWeblnGetInfo" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your wallet info</b>.
</p>
</div>
<!-- Card for webln.sendPayment -->
<div id="cardWeblnSendPayment" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">send a Lightning payment</b> of
<b id="paymentAmountSpan" class="color-primary"></b>.
</p>
</div>
<!-- Card2 for webln.sendPayment (shows invoice) -->
<div id="card2WeblnSendPayment" class="card sam-mt sam-ml sam-mr">
<div id="card2WeblnSendPayment_json" class="json"></div>
</div>
<!-- Card for webln.makeInvoice -->
<div id="cardWeblnMakeInvoice" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">create a Lightning invoice</b>
<span id="invoiceAmountSpan"></span>.
</p>
</div>
<!-- Card for webln.keysend -->
<div id="cardWeblnKeysend" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">send a keysend payment</b>.
</p>
</div>
</div>
<!------------->
<!-- ACTIONS -->
<!------------->
<div class="sam-footer-grid-2">
<div class="btn-group">
<button id="rejectOnceButton" type="button" class="btn btn-secondary">
Reject
</button>
<button
type="button"
class="btn btn-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button id="rejectAlwaysButton" class="dropdown-item">
Reject Always
</button>
</li>
</ul>
<div class="actions">
<div class="action-row">
<span class="action-label">Reject</span>
<div class="action-buttons">
<button id="rejectOnceButton" type="button" class="btn-reject">Once</button>
<button id="rejectAlwaysButton" type="button" class="btn-reject">Always</button>
</div>
</div>
<div class="btn-group">
<button id="approveAlwaysButton" type="button" class="btn btn-primary">
Approve Always
</button>
<button
type="button"
class="btn btn-primary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">
<li>
<button id="approveOnceButton" class="dropdown-item">
Approve Once
</button>
</li>
</ul>
<div class="action-row">
<span class="action-label">Accept</span>
<div class="action-buttons">
<button id="approveOnceButton" type="button" class="btn-accept">Once</button>
<button id="approveAlwaysButton" type="button" class="btn-accept">Always</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html>
<head>
<title>Plebeian Signer - Unlock</title>
<link rel="stylesheet" type="text/css" href="styles.css" />
<script src="scripts.js"></script>
<style>
/* Prevent white flash on load */
html { background-color: #0a0a0a; }
@media (prefers-color-scheme: light) {
html { background-color: #ffffff; }
}
body {
background: var(--background);
height: 100vh;
width: 100vw;
color: var(--foreground);
font-size: 16px;
margin: 0;
display: flex;
flex-direction: column;
}
.color-primary {
color: var(--primary);
}
.page {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--size);
box-sizing: border-box;
}
.header {
text-align: center;
font-size: 1.25rem;
font-weight: 500;
padding: var(--size) 0;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
}
.logo-frame {
border: 2px solid var(--secondary);
border-radius: 100%;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.logo-frame img {
display: block;
}
.input-group {
width: 100%;
max-width: 280px;
display: flex;
}
.input-group input {
flex: 1;
padding: 10px 12px;
border: 1px solid var(--border);
border-right: none;
border-radius: 6px 0 0 6px;
background: var(--background);
color: var(--foreground);
font-size: 14px;
}
.input-group input:focus {
outline: none;
border-color: var(--primary);
}
.input-group button {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 0 6px 6px 0;
background: var(--background-light);
color: var(--muted-foreground);
cursor: pointer;
}
.input-group button:hover {
background: var(--muted);
}
.unlock-btn {
width: 100%;
max-width: 280px;
padding: 10px 16px;
border: none;
border-radius: 6px;
background: var(--primary);
color: var(--primary-foreground);
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.unlock-btn:hover:not(:disabled) {
opacity: 0.9;
}
.unlock-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.alert {
position: fixed;
bottom: var(--size);
left: 50%;
transform: translateX(-50%);
padding: 10px 16px;
border-radius: 6px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.alert-danger {
background: var(--destructive);
color: var(--destructive-foreground);
}
.hidden {
display: none !important;
}
.deriving-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
z-index: 1000;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--muted);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.deriving-text {
color: var(--foreground);
font-size: 14px;
}
.host-info {
text-align: center;
font-size: 13px;
color: var(--muted-foreground);
margin-top: 8px;
}
.host-name {
color: var(--primary);
font-weight: 500;
}
</style>
</head>
<body>
<div class="page">
<div class="header">
<span class="brand">Plebeian Signer</span>
</div>
<div class="content">
<div class="logo-frame">
<img src="logo.svg" height="100" width="100" alt="" />
</div>
<div id="hostInfo" class="host-info hidden">
<span class="host-name" id="hostSpan"></span><br>
is requesting access
</div>
<div class="input-group sam-mt">
<input
id="passwordInput"
type="password"
placeholder="vault password"
autocomplete="current-password"
/>
<button id="togglePassword" type="button">
<i class="bi bi-eye"></i>
</button>
</div>
<button id="unlockBtn" type="button" class="unlock-btn" disabled>
<i class="bi bi-box-arrow-in-right"></i>
<span>Unlock</span>
</button>
</div>
</div>
<!-- Deriving overlay -->
<div id="derivingOverlay" class="deriving-overlay hidden">
<div class="spinner"></div>
<div class="deriving-text">Unlocking vault...</div>
</div>
<!-- Error alert -->
<div id="errorAlert" class="alert alert-danger hidden">
<i class="bi bi-exclamation-triangle"></i>
<span id="errorMessage">Invalid password</span>
</div>
<script src="unlock.js"></script>
</body>
</html>

View File

@@ -12,6 +12,7 @@ import { SettingsComponent } from './components/home/settings/settings.component
import { LogsComponent } from './components/home/logs/logs.component';
import { BookmarksComponent } from './components/home/bookmarks/bookmarks.component';
import { WalletComponent } from './components/home/wallet/wallet.component';
import { BackupsComponent } from './components/home/backups/backups.component';
import { NewIdentityComponent } from './components/new-identity/new-identity.component';
import { EditIdentityComponent } from './components/edit-identity/edit-identity.component';
import { HomeComponent as EditIdentityHomeComponent } from './components/edit-identity/home/home.component';
@@ -81,6 +82,10 @@ export const routes: Routes = [
path: 'wallet',
component: WalletComponent,
},
{
path: 'backups',
component: BackupsComponent,
},
],
},
{

View File

@@ -2,7 +2,9 @@
import {
BrowserSyncData,
BrowserSyncHandler,
CashuMint_ENCRYPTED,
Identity_ENCRYPTED,
NwcConnection_ENCRYPTED,
Permission_ENCRYPTED,
Relay_ENCRYPTED,
} from '@common';
@@ -57,6 +59,20 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
this.setPartialData_Relays(data);
}
async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.local.set(data);
this.setPartialData_NwcConnections(data);
}
async saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.local.set(data);
this.setPartialData_CashuMints(data);
}
async clearData(): Promise<void> {
const props = Object.keys(await this.loadUnmigratedData());
await chrome.storage.local.remove(props);

View File

@@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
CashuMint_ENCRYPTED,
Identity_ENCRYPTED,
NwcConnection_ENCRYPTED,
Permission_ENCRYPTED,
BrowserSyncHandler,
Relay_ENCRYPTED,
@@ -49,6 +51,20 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
this.setPartialData_Relays(data);
}
async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_NwcConnections(data);
}
async saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_CashuMints(data);
}
async clearData(): Promise<void> {
await chrome.storage.sync.clear();
}

View File

@@ -0,0 +1,86 @@
<div class="sam-text-header">
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<button class="back-btn" title="Go Back" (click)="goBack()">
<span class="emoji"></span>
</button>
<span>Backups</span>
</div>
<div class="backup-settings">
<div class="setting-row">
<label for="maxBackups">Max Auto Backups:</label>
<input
id="maxBackups"
type="number"
[value]="maxBackups"
min="1"
max="20"
(change)="onMaxBackupsChange($event)"
/>
</div>
<p class="setting-note">
Automatic backups are created when significant changes are made.
Manual and pre-restore backups are not counted toward this limit.
</p>
</div>
<button class="btn btn-primary create-btn" (click)="createManualBackup()">
Create Backup Now
</button>
<div class="backups-list">
@if (backups.length === 0) {
<div class="empty-state">
<span>No backups yet</span>
</div>
}
@for (backup of backups; track backup.id) {
<div class="backup-item">
<div class="backup-info">
<span class="backup-date">{{ formatDate(backup.createdAt) }}</span>
<div class="backup-meta">
<span class="backup-reason" [class]="getReasonClass(backup.reason)">
{{ getReasonLabel(backup.reason) }}
</span>
<span class="backup-identities">{{ backup.identityCount }} identity(ies)</span>
</div>
</div>
<div class="backup-actions">
<button
class="btn btn-sm btn-secondary"
(click)="
confirm.show(
'Restore this backup? A backup of your current state will be created first.',
restoreBackup.bind(this, backup.id)
)
"
[disabled]="restoringBackupId !== null"
>
{{ restoringBackupId === backup.id ? 'Restoring...' : 'Restore' }}
</button>
<button
class="btn btn-sm btn-danger"
(click)="
confirm.show(
'Delete this backup? This cannot be undone.',
deleteBackup.bind(this, backup.id)
)
"
>
Delete
</button>
</div>
</div>
}
</div>
<lib-confirm #confirm></lib-confirm>

View File

@@ -0,0 +1,192 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
gap: 12px;
}
.sam-text-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: bold;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.lock-btn,
.back-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
&:hover {
background: var(--muted);
}
.emoji {
font-size: 16px;
}
}
.backup-settings {
background: var(--muted);
padding: 12px;
border-radius: 8px;
}
.setting-row {
display: flex;
align-items: center;
gap: 12px;
label {
font-weight: 500;
}
input[type="number"] {
width: 60px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--background);
color: var(--foreground);
}
}
.setting-note {
margin-top: 8px;
font-size: 12px;
color: var(--muted-foreground);
}
.create-btn {
align-self: flex-start;
}
.backups-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
height: 100px;
color: var(--muted-foreground);
}
.backup-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
gap: 12px;
}
.backup-info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.backup-date {
font-weight: 500;
font-size: 13px;
}
.backup-meta {
display: flex;
gap: 8px;
font-size: 11px;
}
.backup-reason {
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
&.reason-auto {
background: var(--muted);
color: var(--muted-foreground);
}
&.reason-manual {
background: rgba(34, 197, 94, 0.2);
color: rgb(34, 197, 94);
}
&.reason-prerestore {
background: rgba(234, 179, 8, 0.2);
color: rgb(234, 179, 8);
}
}
.backup-identities {
color: var(--muted-foreground);
}
.backup-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-primary {
background: var(--primary);
color: var(--primary-foreground);
&:hover:not(:disabled) {
opacity: 0.9;
}
}
.btn-secondary {
background: var(--secondary);
color: var(--secondary-foreground);
&:hover:not(:disabled) {
background: var(--muted);
}
}
.btn-danger {
background: rgba(239, 68, 68, 0.2);
color: rgb(239, 68, 68);
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.3);
}
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}

View File

@@ -0,0 +1,125 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import {
ConfirmComponent,
LoggerService,
NavComponent,
SignerMetaData_VaultSnapshot,
StartupService,
} from '@common';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
@Component({
selector: 'app-backups',
templateUrl: './backups.component.html',
styleUrl: './backups.component.scss',
imports: [ConfirmComponent],
})
export class BackupsComponent extends NavComponent implements OnInit {
readonly #router = inject(Router);
readonly #startup = inject(StartupService);
readonly #logger = inject(LoggerService);
backups: SignerMetaData_VaultSnapshot[] = [];
maxBackups = 5;
restoringBackupId: string | null = null;
ngOnInit(): void {
this.loadBackups();
this.maxBackups = this.storage.getSignerMetaHandler().getMaxBackups();
}
loadBackups(): void {
this.backups = this.storage.getSignerMetaHandler().getBackups();
}
async onMaxBackupsChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const value = parseInt(input.value, 10);
if (!isNaN(value) && value >= 1 && value <= 20) {
this.maxBackups = value;
await this.storage.getSignerMetaHandler().setMaxBackups(value);
}
}
async createManualBackup(): Promise<void> {
const browserSyncData = this.storage.getBrowserSyncHandler().browserSyncData;
if (browserSyncData) {
await this.storage.getSignerMetaHandler().createBackup(browserSyncData, 'manual');
this.loadBackups();
}
}
async restoreBackup(backupId: string): Promise<void> {
this.restoringBackupId = backupId;
try {
// First, create a pre-restore backup of current state
const currentData = this.storage.getBrowserSyncHandler().browserSyncData;
if (currentData) {
await this.storage.getSignerMetaHandler().createBackup(currentData, 'pre-restore');
}
// Get the backup data
const backupData = this.storage.getSignerMetaHandler().getBackupData(backupId);
if (!backupData) {
throw new Error('Backup not found');
}
// Import the backup
await this.storage.deleteVault(true);
await this.storage.importVault(backupData);
this.#logger.logVaultImport('Backup Restore');
this.storage.isInitialized = false;
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
console.error('Failed to restore backup:', error);
this.restoringBackupId = null;
}
}
async deleteBackup(backupId: string): Promise<void> {
await this.storage.getSignerMetaHandler().deleteBackup(backupId);
this.loadBackups();
}
formatDate(isoDate: string): string {
const date = new Date(isoDate);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
getReasonLabel(reason?: string): string {
switch (reason) {
case 'auto':
return 'Auto';
case 'manual':
return 'Manual';
case 'pre-restore':
return 'Pre-Restore';
default:
return 'Unknown';
}
}
getReasonClass(reason?: string): string {
switch (reason) {
case 'auto':
return 'reason-auto';
case 'manual':
return 'reason-manual';
case 'pre-restore':
return 'reason-prerestore';
default:
return '';
}
}
goBack(): void {
this.#router.navigateByUrl('/home/settings');
}
async onClickLock(): Promise<void> {
this.#logger.logVaultLock();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,9 +1,16 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>Bookmarks</span>
<button class="add-btn" title="Bookmark This Page" (click)="onBookmarkThisPage()">
<span class="emoji"></span>

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { Bookmark, LoggerService, SignerMetaData, StorageService } from '@common';
import { Bookmark, LoggerService, NavComponent, SignerMetaData } from '@common';
import { ChromeMetaHandler } from '../../../common/data/chrome-meta-handler';
@Component({
@@ -9,10 +9,9 @@ import { ChromeMetaHandler } from '../../../common/data/chrome-meta-handler';
styleUrl: './bookmarks.component.scss',
imports: [],
})
export class BookmarksComponent implements OnInit {
export class BookmarksComponent extends NavComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #metaHandler = new ChromeMetaHandler();
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
bookmarks: Bookmark[] = [];
@@ -93,7 +92,7 @@ export class BookmarksComponent implements OnInit {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,9 +1,16 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="custom-header" style="position: sticky; top: 0">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span class="text">Identities</span>
<button class="add-btn" title="New Identity" (click)="onClickNewIdentity()">

View File

@@ -19,9 +19,16 @@
background: var(--background);
position: relative;
.lock-btn,
.add-btn {
.header-buttons {
position: absolute;
left: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.header-btn,
.add-btn {
background: transparent;
border: none;
padding: 8px;
@@ -41,11 +48,8 @@
}
}
.lock-btn {
left: 0;
}
.add-btn {
position: absolute;
right: 0;
}

View File

@@ -4,6 +4,7 @@ import {
IconButtonComponent,
Identity_DECRYPTED,
LoggerService,
NavComponent,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
@@ -17,8 +18,8 @@ import {
styleUrl: './identities.component.scss',
imports: [IconButtonComponent, ToastComponent],
})
export class IdentitiesComponent implements OnInit {
readonly storage = inject(StorageService);
export class IdentitiesComponent extends NavComponent implements OnInit {
override readonly storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);

View File

@@ -1,9 +1,16 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>You</span>
<button class="edit-btn" title="Edit profile" (click)="onClickEditProfile()">
<span class="emoji">📝</span>
@@ -73,4 +80,12 @@
</div>
</div>
<!-- About section -->
@if (aboutText) {
<div class="about-section">
<div class="about-header">About</div>
<div class="about-content">{{ aboutText }}</div>
</div>
}
<lib-toast #toast></lib-toast>

View File

@@ -185,4 +185,33 @@
opacity: 1;
}
}
.about-section {
margin: var(--size);
margin-top: 0;
flex-shrink: 0;
max-height: 150px;
display: flex;
flex-direction: column;
.about-header {
font-size: 0.85rem;
font-weight: 600;
color: var(--muted-foreground);
margin-bottom: var(--size-h);
}
.about-content {
flex: 1;
overflow-y: auto;
font-size: 0.9rem;
line-height: 1.5;
color: var(--foreground);
background: var(--background-light);
border-radius: var(--radius-sm);
padding: var(--size);
white-space: pre-wrap;
word-break: break-word;
}
}
}

View File

@@ -3,11 +3,11 @@ import { Router } from '@angular/router';
import {
Identity_DECRYPTED,
LoggerService,
NavComponent,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
PubkeyComponent,
StorageService,
ToastComponent,
VisualNip05Pipe,
validateNip05,
@@ -19,7 +19,7 @@ import {
templateUrl: './identity.component.html',
styleUrl: './identity.component.scss',
})
export class IdentityComponent implements OnInit {
export class IdentityComponent extends NavComponent implements OnInit {
selectedIdentity: Identity_DECRYPTED | undefined;
selectedIdentityNpub: string | undefined;
profile: ProfileMetadata | null = null;
@@ -27,7 +27,6 @@ export class IdentityComponent implements OnInit {
validating = false;
loading = true;
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);
@@ -52,6 +51,10 @@ export class IdentityComponent implements OnInit {
return this.profile?.banner;
}
get aboutText(): string | undefined {
return this.profile?.about;
}
copyToClipboard(pubkey: string | undefined) {
if (!pubkey) {
return;
@@ -78,17 +81,17 @@ export class IdentityComponent implements OnInit {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
async #loadData() {
try {
const selectedIdentityId =
this.#storage.getBrowserSessionHandler().browserSessionData
this.storage.getBrowserSessionHandler().browserSessionData
?.selectedIdentityId ?? null;
const identity = this.#storage
const identity = this.storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find(
(x) => x.id === selectedIdentityId

View File

@@ -1,7 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span> Plebeian Signer </span>
</div>
@@ -11,9 +18,9 @@
<span> Source code</span>
<a
href="https://git.mleku.dev/mleku/plebeian-signer"
href="https://github.com/PlebeianApp/plebeian-signer"
target="_blank"
>
git.mleku.dev/mleku/plebeian-signer
github.com/PlebeianApp/plebeian-signer
</a>

View File

@@ -1,6 +1,6 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, StorageService } from '@common';
import { LoggerService, NavComponent } from '@common';
import packageJson from '../../../../../../../package.json';
@Component({
@@ -8,16 +8,15 @@ import packageJson from '../../../../../../../package.json';
templateUrl: './info.component.html',
styleUrl: './info.component.scss',
})
export class InfoComponent {
export class InfoComponent extends NavComponent {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
version = packageJson.custom.chrome.version;
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,7 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>Logs</span>
<div class="logs-actions">
<button class="btn btn-sm btn-secondary" title="Refresh logs" (click)="onRefresh()">Refresh</button>

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, LogEntry, StorageService } from '@common';
import { LoggerService, LogEntry, NavComponent } from '@common';
import { DatePipe } from '@angular/common';
@Component({
@@ -9,9 +9,8 @@ import { DatePipe } from '@angular/common';
styleUrl: './logs.component.scss',
imports: [DatePipe],
})
export class LogsComponent implements OnInit {
export class LogsComponent extends NavComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
get logs(): LogEntry[] {
@@ -46,7 +45,7 @@ export class LogsComponent implements OnInit {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,25 +1,47 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span> Settings </span>
</div>
<span>SYNC: {{ syncFlow }}</span>
<button class="btn btn-primary" (click)="onClickExportVault()">
Export Vault
</button>
<button class="btn btn-primary" (click)="navigate('/vault-import')">
Import Vault
</button>
<div class="vault-buttons">
<button class="btn btn-primary" (click)="onClickExportVault()">
Export Vault
</button>
<button class="btn btn-primary" (click)="navigate('/vault-import')">
Import Vault
</button>
</div>
<lib-nav-item text="💾 Backups" (click)="navigate('/home/backups')"></lib-nav-item>
<lib-nav-item text="🪵 Logs" (click)="navigate('/home/logs')"></lib-nav-item>
<lib-nav-item text="💡 Info" (click)="navigate('/home/info')"></lib-nav-item>
<div class="dev-mode-row">
<label class="toggle-label">
<input type="checkbox" [checked]="devMode" (change)="onToggleDevMode($event)" />
<span>Dev Mode</span>
</label>
</div>
<div class="sam-flex-grow"></div>
<div class="sync-info">
<span class="sync-label">SYNC: {{ syncFlow }}</span>
<p class="sync-note">
To change sync mode, export your vault, reset the extension,
and re-import with the desired sync setting.
</p>
</div>
<button
class="btn btn-danger"
(click)="

View File

@@ -15,3 +15,46 @@
visibility: hidden;
}
}
.vault-buttons {
display: flex;
gap: var(--size);
button {
flex: 1;
}
}
.dev-mode-row {
display: flex;
align-items: center;
gap: var(--size);
.toggle-label {
display: flex;
align-items: center;
gap: var(--size-h);
cursor: pointer;
font-size: 0.9rem;
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
}
}
.sync-info {
.sync-label {
display: block;
font-weight: 500;
}
.sync-note {
margin: var(--size-h) 0 0 0;
font-size: 0.85rem;
color: var(--muted-foreground);
line-height: 1.4;
}
}

View File

@@ -12,6 +12,7 @@ import {
StorageService,
} from '@common';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
import { Buffer } from 'buffer';
@Component({
selector: 'app-settings',
@@ -22,6 +23,7 @@ import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage
export class SettingsComponent extends NavComponent implements OnInit {
readonly #router = inject(Router);
syncFlow: string | undefined;
override devMode = false;
readonly #storage = inject(StorageService);
readonly #startup = inject(StartupService);
@@ -45,6 +47,44 @@ export class SettingsComponent extends NavComponent implements OnInit {
default:
break;
}
// Load dev mode setting
this.devMode = this.#storage.getSignerMetaHandler().signerMetaData?.devMode ?? false;
}
async onToggleDevMode(event: Event) {
const checked = (event.target as HTMLInputElement).checked;
this.devMode = checked;
await this.#storage.getSignerMetaHandler().setDevMode(checked);
}
override async onTestPrompt() {
// Open a test permission prompt window
const testEvent = {
kind: 1,
content: 'This is a test note for permission prompt preview.',
tags: [],
created_at: Math.floor(Date.now() / 1000),
};
const base64Event = Buffer.from(JSON.stringify(testEvent, null, 2)).toString('base64');
const currentIdentity = this.#storage.getBrowserSessionHandler().browserSessionData?.identities.find(
i => i.id === this.#storage.getBrowserSessionHandler().browserSessionData?.selectedIdentityId
);
const nick = currentIdentity?.nick ?? 'Test Identity';
const width = 375;
const height = 600;
const left = Math.round((screen.width - width) / 2);
const top = Math.round((screen.height - height) / 2);
chrome.windows.create({
type: 'popup',
url: `prompt.html?method=signEvent&host=example.com&id=test-${Date.now()}&nick=${encodeURIComponent(nick)}&event=${base64Event}`,
width,
height,
left,
top,
});
}
async onResetExtension() {

View File

@@ -1,14 +1,660 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<span>Wallet</span>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
@if (showBackButton) {
<button class="back-btn" title="Go Back" (click)="goBack()">
<span class="emoji"></span>
</button>
}
<span>{{ title }}</span>
<div class="section-btns">
<button
class="section-btn"
[class.active]="activeSection.startsWith('cashu')"
title="Cashu"
(click)="setSection('cashu')"
>
<span class="emoji">🥜</span>
</button>
<button
class="section-btn"
[class.active]="activeSection.startsWith('lightning')"
title="Lightning"
(click)="setSection('lightning')"
>
<span class="emoji"></span>
</button>
</div>
</div>
<div class="wallet-container">
<div class="empty-state">
<span class="sam-text-muted">
Wallet functionality coming soon.
</span>
</div>
<!-- Main wallet menu -->
@if (activeSection === 'main') {
<div class="wallet-menu">
<button class="wallet-menu-item" (click)="setSection('cashu')">
<span class="emoji">🥜</span>
<span class="label">Cashu</span>
<span class="balance">{{ formatCashuBalance(totalCashuBalance) }} sats</span>
</button>
<button class="wallet-menu-item" (click)="setSection('lightning')">
<span class="emoji"></span>
<span class="label">Lightning</span>
<span class="balance">{{ formatBalance(totalLightningBalance) }} sats</span>
</button>
</div>
}
<!-- Cashu mint list -->
@else if (activeSection === 'cashu') {
<div class="lightning-section">
@if (mints.length === 0) {
<div class="cashu-onboarding">
@if (showCashuInfo) {
<div class="info-panel">
<h3>Welcome to Cashu Wallet</h3>
<div class="info-section">
<h4>Storage Considerations</h4>
@if (currentSyncFlow === BrowserSyncFlow.BROWSER_SYNC) {
<div class="warning-box">
<p><strong>Browser Sync is enabled</strong></p>
<p>
Sync storage is limited to ~100KB shared across all your vault data
(identities, permissions, relays, and Cashu tokens). This limits
your Cashu wallet to approximately 300-400 tokens.
</p>
<p>
For larger Cashu holdings, consider disabling browser sync which
provides ~5MB of local storage (~18,000+ tokens).
</p>
<button class="link-btn" (click)="navigateToSettings()">
Change Sync Settings
</button>
</div>
} @else {
<div class="success-box">
<p><strong>Local Storage Mode</strong></p>
<p>
You have ~5MB of local storage available, which can hold
thousands of Cashu tokens. Your data stays on this device only.
</p>
</div>
}
</div>
<div class="info-section">
<h4>Backup Your Wallet</h4>
<p>
<strong>Important:</strong> Cashu tokens are bearer assets.
If you lose your vault backup, you lose your tokens permanently.
</p>
<p>
Vault exports are saved to your browser's downloads folder.
Configure this to point to either:
</p>
<ul>
<li>Your backup storage device (external drive, NAS)</li>
<li>A folder synced by your backup tool (Syncthing, rsync, etc.)</li>
</ul>
<p class="browser-url">
<code>{{ browserDownloadSettingsUrl }}</code>
</p>
<button class="link-btn" (click)="navigateToSettings()">
Go to Backup Settings
</button>
</div>
<button class="dismiss-btn" (click)="dismissCashuInfo()">
Got it, let me add a mint
</button>
</div>
} @else {
<div class="empty-state">
<span class="sam-text-muted">No mints connected yet.</span>
<button class="show-info-btn" (click)="showCashuInfo = true">
Show storage info
</button>
</div>
}
</div>
} @else {
<div class="wallet-list">
@for (mint of mints; track mint.id) {
<button class="wallet-list-item" (click)="selectMint(mint.id)">
<span class="wallet-name">{{ mint.name }}</span>
<span class="wallet-balance">{{ formatCashuBalance(mint.cachedBalance) }} sats</span>
</button>
}
</div>
}
<button class="add-wallet-btn" (click)="showAddMint()">
<span class="emoji">+</span>
<span>Add Mint</span>
</button>
</div>
}
<!-- Cashu mint detail -->
@else if (activeSection === 'cashu-detail' && selectedMint) {
<div class="wallet-detail">
<div class="balance-row">
<div class="balance-display compact">
<span class="balance-value">{{ formatCashuBalance(selectedMintBalance) }}</span>
<span class="balance-unit">sats</span>
</div>
<button
class="refresh-icon-btn"
(click)="refreshMint()"
[disabled]="refreshingMint"
title="Refresh"
>
<span class="emoji" [class.spinning]="refreshingMint">🔄</span>
</button>
</div>
@if (refreshError) {
<div class="error-message small">{{ refreshError }}</div>
}
<div class="action-buttons">
<button class="action-btn deposit-btn" (click)="showDeposit()">
Deposit
</button>
<button class="action-btn receive-btn" (click)="showReceive()">
Receive
</button>
<button class="action-btn send-btn" (click)="showSend()" [disabled]="selectedMintBalance === 0">
Send
</button>
</div>
<!-- Token viewer section -->
<div class="token-section">
<div class="section-title">Tokens ({{ selectedMintProofs.length }})</div>
@if (selectedMintProofs.length === 0) {
<div class="empty-text">No tokens stored</div>
} @else {
<div class="token-list">
@for (proof of selectedMintProofs; track proof.secret) {
<div class="token-item">
<span class="token-amount">{{ proof.amount }}</span>
<span class="token-time">{{ formatProofTime(proof.receivedAt) }}</span>
</div>
}
</div>
}
</div>
<div class="wallet-info">
<div class="info-row">
<span class="info-label">Mint URL</span>
<span class="info-value">{{ selectedMint.mintUrl }}</span>
</div>
<div class="info-row">
<span class="info-label">Unit</span>
<span class="info-value">{{ selectedMint.unit }}</span>
</div>
</div>
<button class="delete-btn" (click)="deleteMint()">
Delete Mint
</button>
</div>
}
<!-- Cashu add mint form -->
@else if (activeSection === 'cashu-add') {
<div class="add-wallet-form">
<div class="form-group">
<label for="mintName">Mint Name</label>
<input
id="mintName"
type="text"
[(ngModel)]="newMintName"
placeholder="My Mint"
[disabled]="addingMint"
/>
</div>
<div class="form-group">
<label for="mintUrl">Mint URL</label>
<input
id="mintUrl"
type="text"
[(ngModel)]="newMintUrl"
placeholder="https://mint.example.com"
[disabled]="addingMint"
/>
</div>
@if (mintError) {
<div class="error-message">{{ mintError }}</div>
}
@if (mintTestResult) {
<div class="success-message">{{ mintTestResult }}</div>
}
<div class="form-actions">
<button
class="test-btn"
(click)="testMint()"
[disabled]="testingMint || addingMint"
>
{{ testingMint ? 'Testing...' : 'Test Connection' }}
</button>
<button
class="add-btn"
(click)="addMint()"
[disabled]="addingMint"
>
{{ addingMint ? 'Adding...' : 'Add Mint' }}
</button>
</div>
</div>
}
<!-- Cashu receive token -->
@else if (activeSection === 'cashu-receive') {
<div class="add-wallet-form">
<div class="form-group">
<label for="receiveToken">Paste Cashu Token</label>
<textarea
id="receiveToken"
[(ngModel)]="receiveToken"
placeholder="cashuB..."
rows="5"
[disabled]="receivingToken"
></textarea>
</div>
@if (receiveError) {
<div class="error-message">{{ receiveError }}</div>
}
@if (receiveResult) {
<div class="success-message">{{ receiveResult }}</div>
}
<div class="form-actions">
<button
class="add-btn full-width"
(click)="receiveTokens()"
[disabled]="receivingToken"
>
{{ receivingToken ? 'Receiving...' : 'Receive Tokens' }}
</button>
</div>
</div>
}
<!-- Cashu send token -->
@else if (activeSection === 'cashu-send') {
<div class="add-wallet-form">
<div class="balance-info">
Available: {{ formatCashuBalance(selectedMintBalance) }} sats
</div>
<div class="form-group">
<label for="sendAmount">Amount (sats)</label>
<input
id="sendAmount"
type="number"
[(ngModel)]="sendAmount"
placeholder="0"
min="1"
[max]="selectedMintBalance"
[disabled]="sendingToken"
/>
</div>
@if (sendError) {
<div class="error-message">{{ sendError }}</div>
}
@if (sendResult) {
<div class="token-result">
<span class="token-label">Token to Share</span>
<textarea readonly rows="4">{{ sendResult }}</textarea>
<button class="copy-btn" (click)="copyToken()">
Copy Token
</button>
</div>
}
@if (!sendResult) {
<div class="form-actions">
<button
class="add-btn full-width"
(click)="sendTokens()"
[disabled]="sendingToken || sendAmount <= 0"
>
{{ sendingToken ? 'Creating...' : 'Create Token' }}
</button>
</div>
}
</div>
}
<!-- Cashu deposit (mint via Lightning) -->
@else if (activeSection === 'cashu-mint' && selectedMint) {
<div class="add-wallet-form">
@if (!depositInvoice) {
<div class="form-group">
<label for="depositAmount">Amount (sats)</label>
<input
id="depositAmount"
type="number"
[(ngModel)]="depositAmount"
placeholder="1000"
min="1"
[disabled]="creatingDepositQuote"
/>
</div>
@if (depositError) {
<div class="error-message">{{ depositError }}</div>
}
<div class="form-actions">
<button
class="add-btn full-width"
(click)="createDepositInvoice()"
[disabled]="creatingDepositQuote || depositAmount <= 0"
>
{{ creatingDepositQuote ? 'Creating...' : 'Create Invoice' }}
</button>
</div>
}
@if (depositInvoice) {
<div class="invoice-result">
@if (depositInvoiceQr) {
<img [src]="depositInvoiceQr" alt="Invoice QR Code" class="qr-code" />
}
<div class="deposit-status">
@if (depositQuoteState === 'UNPAID') {
<span class="status-waiting">Waiting for payment...</span>
@if (checkingDepositPayment) {
<span class="status-checking">checking</span>
}
} @else if (depositQuoteState === 'PAID') {
<span class="status-paid">Payment received! Claiming tokens...</span>
} @else if (depositQuoteState === 'ISSUED') {
<span class="status-issued">✓ Tokens received!</span>
}
</div>
@if (depositError) {
<div class="error-message">{{ depositError }}</div>
}
@if (depositSuccess) {
<div class="success-message">{{ depositSuccess }}</div>
}
@if (depositQuoteState === 'UNPAID') {
<div class="invoice-text">{{ depositInvoice }}</div>
<button class="copy-btn" (click)="copyDepositInvoice()">
Copy Invoice
</button>
}
</div>
}
</div>
}
<!-- Lightning wallet list -->
@else if (activeSection === 'lightning') {
<div class="lightning-section">
@if (connections.length === 0) {
<div class="empty-state">
<span class="sam-text-muted">
No wallets connected yet.
</span>
</div>
} @else {
<div class="wallet-list">
@for (conn of connections; track conn.id) {
<button class="wallet-list-item" (click)="selectConnection(conn.id)">
<span class="wallet-name">{{ conn.name }}</span>
<span class="wallet-balance">{{ formatBalance(conn.cachedBalance) }} sats</span>
</button>
}
</div>
}
<button class="add-wallet-btn" (click)="showAddConnection()">
<span class="emoji">+</span>
<span>Add NWC Connection</span>
</button>
</div>
}
<!-- Lightning wallet detail -->
@else if (activeSection === 'lightning-detail' && selectedConnection) {
<div class="wallet-detail">
<div class="balance-row">
<div class="balance-display compact">
<span class="balance-value">{{ formatBalance(selectedConnection.cachedBalance) }}</span>
<span class="balance-unit">sats</span>
</div>
<button class="refresh-icon-btn" (click)="refreshWallet()" title="Refresh">
<span class="emoji">🔄</span>
</button>
</div>
<div class="action-buttons">
<button class="action-btn receive-btn" (click)="showLnReceive()">
Receive
</button>
<button class="action-btn send-btn" (click)="showLnPay()">
Pay
</button>
</div>
<div class="wallet-info">
<div class="info-row">
<span class="info-label">Relay</span>
<span class="info-value">{{ selectedConnection.relayUrl }}</span>
</div>
@if (selectedConnection.lud16) {
<button class="info-row-btn" (click)="copyLightningAddress()">
<span class="info-label">Lightning Address</span>
<span class="info-value">
{{ selectedConnection.lud16 }}
<span class="copy-hint">{{ addressCopied ? '✓ Copied' : '(tap to copy)' }}</span>
</span>
</button>
}
</div>
<!-- Transaction History -->
<div class="transaction-section">
<div class="section-title">Transactions</div>
@if (loadingTransactions) {
<div class="loading-text">Loading...</div>
} @else if (transactionsNotSupported) {
<div class="not-supported-text">Transaction history not supported by this wallet</div>
} @else if (transactionsError) {
<div class="error-text">{{ transactionsError }}</div>
} @else if (transactions.length === 0) {
<div class="empty-text">No transactions yet</div>
} @else {
<div class="transaction-list">
@for (tx of transactions; track tx.payment_hash) {
<div class="transaction-item" [class.incoming]="tx.type === 'incoming'" [class.outgoing]="tx.type === 'outgoing'">
<span class="tx-icon">{{ tx.type === 'incoming' ? '⬇' : '⬆' }}</span>
<span class="tx-type">{{ tx.type === 'incoming' ? 'Received' : 'Sent' }}</span>
<span class="tx-amount">{{ formatBalance(tx.amount) }}</span>
<span class="tx-time">{{ formatTransactionTime(tx.created_at) }}</span>
</div>
}
</div>
}
</div>
<button class="delete-btn-small" (click)="deleteConnection()">
Delete Wallet
</button>
</div>
}
<!-- Lightning receive invoice -->
@else if (activeSection === 'lightning-receive' && selectedConnection) {
<div class="add-wallet-form">
<div class="form-group">
<label for="lnReceiveAmount">Amount (sats)</label>
<input
id="lnReceiveAmount"
type="number"
[(ngModel)]="lnReceiveAmount"
placeholder="1000"
min="1"
[disabled]="generatingInvoice"
/>
</div>
<div class="form-group">
<label for="lnReceiveDescription">Description (optional)</label>
<input
id="lnReceiveDescription"
type="text"
[(ngModel)]="lnReceiveDescription"
placeholder="Payment for..."
[disabled]="generatingInvoice"
/>
</div>
@if (lnReceiveError) {
<div class="error-message">{{ lnReceiveError }}</div>
}
@if (!generatedInvoice) {
<div class="form-actions">
<button
class="add-btn full-width"
(click)="createReceiveInvoice()"
[disabled]="generatingInvoice || lnReceiveAmount <= 0"
>
{{ generatingInvoice ? 'Generating...' : 'Generate Invoice' }}
</button>
</div>
}
@if (generatedInvoice) {
<div class="invoice-result">
@if (generatedInvoiceQr) {
<img [src]="generatedInvoiceQr" alt="Invoice QR Code" class="qr-code" />
}
<div class="invoice-text">{{ generatedInvoice }}</div>
<button class="copy-btn" (click)="copyInvoice()">
{{ invoiceCopied ? 'Copied!' : 'Copy Invoice' }}
</button>
</div>
}
</div>
}
<!-- Pay Modal Overlay -->
@if (showPayModal && selectedConnection) {
<div class="modal-overlay" role="dialog" aria-modal="true" tabindex="-1" (click)="closePayModal()" (keydown.escape)="closePayModal()">
<div class="modal-content" role="document" (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()">
<div class="modal-header">
<span>Pay Invoice</span>
<button class="modal-close" (click)="closePayModal()">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="payInput">Lightning Address or Invoice</label>
<textarea
id="payInput"
[(ngModel)]="payInput"
placeholder="user@domain.com or lnbc1..."
rows="3"
[disabled]="paying"
></textarea>
</div>
<div class="form-group">
<label for="payAmount">Amount (sats) - required for addresses</label>
<input
id="payAmount"
type="number"
[(ngModel)]="payAmount"
placeholder="Optional for invoices"
min="1"
[disabled]="paying"
/>
</div>
@if (paymentError) {
<div class="error-message">{{ paymentError }}</div>
}
@if (paymentSuccess) {
<div class="success-message payment-success">Payment Successful!</div>
}
@if (!paymentSuccess) {
<div class="form-actions">
<button class="test-btn" (click)="closePayModal()" [disabled]="paying">
Cancel
</button>
<button
class="add-btn"
(click)="payInvoiceOrAddress()"
[disabled]="paying || !payInput.trim()"
>
{{ paying ? 'Paying...' : 'Pay' }}
</button>
</div>
}
</div>
</div>
</div>
}
<!-- Add wallet form -->
@else if (activeSection === 'lightning-add') {
<div class="add-wallet-form">
<div class="form-group">
<label for="walletName">Wallet Name</label>
<input
id="walletName"
type="text"
[(ngModel)]="newWalletName"
placeholder="My Lightning Wallet"
[disabled]="addingConnection"
/>
</div>
<div class="form-group">
<label for="walletUrl">NWC Connection URL</label>
<textarea
id="walletUrl"
[(ngModel)]="newWalletUrl"
placeholder="nostr+walletconnect://..."
rows="3"
[disabled]="addingConnection"
></textarea>
</div>
@if (connectionError) {
<div class="error-message">{{ connectionError }}</div>
}
@if (connectionTestResult) {
<div class="success-message">{{ connectionTestResult }}</div>
}
@if (nwcService.logs.length > 0) {
<div class="nwc-log">
<div class="log-header">
<span>Connection Log</span>
<button class="log-clear-btn" (click)="nwcService.clearLogs()">Clear</button>
</div>
<div class="log-entries">
@for (entry of nwcService.logs; track entry.timestamp) {
<div class="log-entry" [class.log-warn]="entry.level === 'warn'" [class.log-error]="entry.level === 'error'">
<span class="log-time">{{ entry.timestamp | date:'HH:mm:ss' }}</span>
<span class="log-message">{{ entry.message }}</span>
</div>
}
</div>
</div>
}
<div class="form-actions">
<button
class="test-btn"
(click)="testConnection()"
[disabled]="testingConnection || addingConnection"
>
{{ testingConnection ? 'Testing...' : 'Test Connection' }}
</button>
<button
class="add-btn"
(click)="addConnection()"
[disabled]="addingConnection"
>
{{ addingConnection ? 'Adding...' : 'Add Wallet' }}
</button>
</div>
</div>
}
</div>

View File

@@ -1,21 +1,951 @@
import { Component, inject } from '@angular/core';
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, StorageService } from '@common';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import {
LoggerService,
NavComponent,
NwcService,
NwcConnection_DECRYPTED,
CashuService,
CashuMint_DECRYPTED,
CashuProof,
NwcLookupInvoiceResult,
BrowserSyncFlow,
} from '@common';
import * as QRCode from 'qrcode';
type WalletSection =
| 'main'
| 'cashu'
| 'cashu-detail'
| 'cashu-add'
| 'cashu-receive'
| 'cashu-send'
| 'cashu-mint'
| 'lightning'
| 'lightning-detail'
| 'lightning-add'
| 'lightning-receive'
| 'lightning-pay';
@Component({
selector: 'app-wallet',
templateUrl: './wallet.component.html',
styleUrl: './wallet.component.scss',
imports: [],
imports: [CommonModule, FormsModule],
})
export class WalletComponent {
export class WalletComponent extends NavComponent implements OnInit, OnDestroy {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly nwcService = inject(NwcService);
readonly cashuService = inject(CashuService);
activeSection: WalletSection = 'main';
selectedConnectionId: string | null = null;
selectedMintId: string | null = null;
// Form fields for adding new NWC connection
newWalletName = '';
newWalletUrl = '';
addingConnection = false;
testingConnection = false;
connectionError = '';
connectionTestResult = '';
// Form fields for adding new Cashu mint
newMintName = '';
newMintUrl = '';
addingMint = false;
testingMint = false;
mintError = '';
mintTestResult = '';
// Cashu receive/send fields
receiveToken = '';
receivingToken = false;
receiveError = '';
receiveResult = '';
sendAmount = 0;
sendingToken = false;
sendError = '';
sendResult = '';
// Cashu mint (deposit) fields
depositAmount = 0;
creatingDepositQuote = false;
depositQuoteId = '';
depositInvoice = '';
depositInvoiceQr = '';
depositError = '';
depositSuccess = '';
checkingDepositPayment = false;
depositQuoteState: 'UNPAID' | 'PAID' | 'ISSUED' = 'UNPAID';
private depositPollingInterval: ReturnType<typeof setInterval> | null = null;
// Loading states
loadingBalances = false;
balanceError = '';
// Lightning transaction history
transactions: NwcLookupInvoiceResult[] = [];
loadingTransactions = false;
transactionsError = '';
transactionsNotSupported = false;
// Lightning receive
lnReceiveAmount = 0;
lnReceiveDescription = '';
generatingInvoice = false;
generatedInvoice = '';
generatedInvoiceQr = '';
lnReceiveError = '';
invoiceCopied = false;
// Lightning pay
showPayModal = false;
payInput = '';
payAmount = 0;
paying = false;
paymentSuccess = false;
paymentError = '';
// Clipboard feedback
addressCopied = false;
// Cashu onboarding info
showCashuInfo = true;
currentSyncFlow: BrowserSyncFlow = BrowserSyncFlow.NO_SYNC;
readonly BrowserSyncFlow = BrowserSyncFlow; // Expose enum to template
readonly browserDownloadSettingsUrl = 'chrome://settings/downloads';
// Cashu mint refresh
refreshingMint = false;
refreshError = '';
get title(): string {
switch (this.activeSection) {
case 'cashu':
return 'Cashu';
case 'cashu-detail':
return this.selectedMint?.name ?? 'Mint';
case 'cashu-add':
return 'Add Mint';
case 'cashu-receive':
return 'Receive';
case 'cashu-send':
return 'Send';
case 'cashu-mint':
return 'Deposit';
case 'lightning':
return 'Lightning';
case 'lightning-detail':
return this.selectedConnection?.name ?? 'Wallet';
case 'lightning-add':
return 'Add Wallet';
case 'lightning-receive':
return 'Receive';
case 'lightning-pay':
return 'Pay';
default:
return 'Wallet';
}
}
get showBackButton(): boolean {
return this.activeSection !== 'main';
}
get connections(): NwcConnection_DECRYPTED[] {
return this.nwcService.getConnections();
}
get selectedConnection(): NwcConnection_DECRYPTED | undefined {
if (!this.selectedConnectionId) return undefined;
return this.nwcService.getConnection(this.selectedConnectionId);
}
get totalLightningBalance(): number {
return this.nwcService.getCachedTotalBalance();
}
get mints(): CashuMint_DECRYPTED[] {
return this.cashuService.getMints();
}
get selectedMint(): CashuMint_DECRYPTED | undefined {
if (!this.selectedMintId) return undefined;
return this.cashuService.getMint(this.selectedMintId);
}
get totalCashuBalance(): number {
return this.cashuService.getCachedTotalBalance();
}
get selectedMintBalance(): number {
if (!this.selectedMintId) return 0;
return this.cashuService.getBalance(this.selectedMintId);
}
get selectedMintProofs(): CashuProof[] {
if (!this.selectedMintId) return [];
return this.cashuService.getProofs(this.selectedMintId);
}
ngOnInit(): void {
// Load current sync flow setting
this.currentSyncFlow = this.storage.getSyncFlow();
// Refresh balances on init if we have connections
if (this.connections.length > 0) {
this.refreshAllBalances();
}
}
ngOnDestroy(): void {
this.nwcService.disconnectAll();
this.stopDepositPolling();
}
setSection(section: WalletSection) {
this.activeSection = section;
this.connectionError = '';
this.connectionTestResult = '';
}
goBack() {
switch (this.activeSection) {
case 'lightning-detail':
case 'lightning-add':
this.activeSection = 'lightning';
this.selectedConnectionId = null;
this.resetAddForm();
this.resetLightningForms();
break;
case 'lightning-receive':
case 'lightning-pay':
this.activeSection = 'lightning-detail';
this.resetLightningForms();
break;
case 'cashu-detail':
case 'cashu-add':
this.activeSection = 'cashu';
this.selectedMintId = null;
this.resetAddMintForm();
break;
case 'cashu-receive':
case 'cashu-send':
case 'cashu-mint':
this.activeSection = 'cashu-detail';
this.resetReceiveSendForm();
this.resetDepositForm();
break;
case 'lightning':
case 'cashu':
this.activeSection = 'main';
break;
}
}
selectConnection(connectionId: string) {
this.selectedConnectionId = connectionId;
this.activeSection = 'lightning-detail';
this.loadTransactions(connectionId);
}
private resetLightningForms() {
this.lnReceiveAmount = 0;
this.lnReceiveDescription = '';
this.generatingInvoice = false;
this.generatedInvoice = '';
this.generatedInvoiceQr = '';
this.lnReceiveError = '';
this.invoiceCopied = false;
this.payInput = '';
this.payAmount = 0;
this.paying = false;
this.paymentSuccess = false;
this.paymentError = '';
this.showPayModal = false;
}
showAddConnection() {
this.resetAddForm();
this.activeSection = 'lightning-add';
}
private resetAddForm() {
this.newWalletName = '';
this.newWalletUrl = '';
this.connectionError = '';
this.connectionTestResult = '';
this.addingConnection = false;
this.testingConnection = false;
}
async testConnection() {
if (!this.newWalletUrl.trim()) {
this.connectionError = 'Please enter an NWC URL';
return;
}
this.testingConnection = true;
this.connectionError = '';
this.connectionTestResult = '';
this.nwcService.clearLogs();
try {
const info = await this.nwcService.testConnection(this.newWalletUrl);
this.connectionTestResult = `Connected! ${info.alias ? 'Wallet: ' + info.alias : ''}`;
// Hide logs on success
this.nwcService.clearLogs();
} catch (error) {
this.connectionError =
error instanceof Error ? error.message : 'Connection test failed';
// Keep logs visible on failure for debugging
} finally {
this.testingConnection = false;
}
}
async addConnection() {
if (!this.newWalletName.trim()) {
this.connectionError = 'Please enter a wallet name';
return;
}
if (!this.newWalletUrl.trim()) {
this.connectionError = 'Please enter an NWC URL';
return;
}
this.addingConnection = true;
this.connectionError = '';
try {
await this.nwcService.addConnection(
this.newWalletName.trim(),
this.newWalletUrl.trim()
);
// Refresh the balance for the new connection
const connections = this.nwcService.getConnections();
const newConnection = connections[connections.length - 1];
if (newConnection) {
try {
await this.nwcService.getBalance(newConnection.id);
} catch {
// Ignore balance fetch error
}
}
this.goBack();
} catch (error) {
this.connectionError =
error instanceof Error ? error.message : 'Failed to add connection';
} finally {
this.addingConnection = false;
}
}
async deleteConnection() {
if (!this.selectedConnectionId) return;
const connection = this.selectedConnection;
if (
!confirm(`Delete wallet "${connection?.name}"? This cannot be undone.`)
) {
return;
}
try {
await this.nwcService.deleteConnection(this.selectedConnectionId);
this.goBack();
} catch (error) {
console.error('Failed to delete connection:', error);
}
}
// Cashu methods
selectMint(mintId: string) {
this.selectedMintId = mintId;
this.activeSection = 'cashu-detail';
// Auto-refresh to check for spent proofs
this.refreshMint();
}
async refreshMint() {
if (!this.selectedMintId || this.refreshingMint) return;
this.refreshingMint = true;
this.refreshError = '';
try {
const removedAmount = await this.cashuService.checkProofsSpent(this.selectedMintId);
if (removedAmount > 0) {
// Balance was updated, proofs were spent
console.log(`Removed ${removedAmount} sats of spent proofs`);
}
} catch (error) {
this.refreshError = error instanceof Error ? error.message : 'Failed to refresh';
console.error('Failed to refresh mint:', error);
} finally {
this.refreshingMint = false;
}
}
showAddMint() {
this.resetAddMintForm();
this.activeSection = 'cashu-add';
}
showReceive() {
this.resetReceiveSendForm();
this.activeSection = 'cashu-receive';
}
showSend() {
this.resetReceiveSendForm();
this.activeSection = 'cashu-send';
}
private resetAddMintForm() {
this.newMintName = '';
this.newMintUrl = '';
this.mintError = '';
this.mintTestResult = '';
this.addingMint = false;
this.testingMint = false;
}
private resetReceiveSendForm() {
this.receiveToken = '';
this.receivingToken = false;
this.receiveError = '';
this.receiveResult = '';
this.sendAmount = 0;
this.sendingToken = false;
this.sendError = '';
this.sendResult = '';
}
private resetDepositForm() {
this.depositAmount = 0;
this.creatingDepositQuote = false;
this.depositQuoteId = '';
this.depositInvoice = '';
this.depositInvoiceQr = '';
this.depositError = '';
this.depositSuccess = '';
this.checkingDepositPayment = false;
this.depositQuoteState = 'UNPAID';
this.stopDepositPolling();
}
private stopDepositPolling() {
if (this.depositPollingInterval) {
clearInterval(this.depositPollingInterval);
this.depositPollingInterval = null;
}
}
async testMint() {
if (!this.newMintUrl.trim()) {
this.mintError = 'Please enter a mint URL';
return;
}
this.testingMint = true;
this.mintError = '';
this.mintTestResult = '';
try {
const info = await this.cashuService.testMintConnection(
this.newMintUrl.trim()
);
this.mintTestResult = `Connected! ${info.name ? 'Mint: ' + info.name : ''}`;
} catch (error) {
this.mintError =
error instanceof Error ? error.message : 'Connection test failed';
} finally {
this.testingMint = false;
}
}
async addMint() {
if (!this.newMintName.trim()) {
this.mintError = 'Please enter a mint name';
return;
}
if (!this.newMintUrl.trim()) {
this.mintError = 'Please enter a mint URL';
return;
}
this.addingMint = true;
this.mintError = '';
try {
await this.cashuService.addMint(
this.newMintName.trim(),
this.newMintUrl.trim()
);
this.goBack();
} catch (error) {
this.mintError =
error instanceof Error ? error.message : 'Failed to add mint';
} finally {
this.addingMint = false;
}
}
async deleteMint() {
if (!this.selectedMintId) return;
const mint = this.selectedMint;
if (!confirm(`Delete mint "${mint?.name}"? Any tokens stored will be lost. This cannot be undone.`)) {
return;
}
try {
await this.cashuService.deleteMint(this.selectedMintId);
this.goBack();
} catch (error) {
console.error('Failed to delete mint:', error);
}
}
async receiveTokens() {
if (!this.receiveToken.trim()) {
this.receiveError = 'Please paste a Cashu token';
return;
}
this.receivingToken = true;
this.receiveError = '';
this.receiveResult = '';
try {
const result = await this.cashuService.receive(this.receiveToken.trim());
this.receiveResult = `Received ${result.amount} sats!`;
this.receiveToken = '';
} catch (error) {
this.receiveError =
error instanceof Error ? error.message : 'Failed to receive token';
} finally {
this.receivingToken = false;
}
}
async sendTokens() {
if (!this.selectedMintId) return;
if (this.sendAmount <= 0) {
this.sendError = 'Please enter a valid amount';
return;
}
const balance = this.selectedMintBalance;
if (this.sendAmount > balance) {
this.sendError = `Insufficient balance. You have ${balance} sats`;
return;
}
this.sendingToken = true;
this.sendError = '';
this.sendResult = '';
try {
const result = await this.cashuService.send(
this.selectedMintId,
this.sendAmount
);
this.sendResult = result.token;
this.sendAmount = 0;
} catch (error) {
this.sendError =
error instanceof Error ? error.message : 'Failed to create token';
} finally {
this.sendingToken = false;
}
}
copyToken() {
if (this.sendResult) {
navigator.clipboard.writeText(this.sendResult);
}
}
async checkProofs() {
if (!this.selectedMintId) return;
try {
const removedAmount = await this.cashuService.checkProofsSpent(
this.selectedMintId
);
if (removedAmount > 0) {
alert(`Removed ${removedAmount} sats of spent proofs.`);
} else {
alert('All proofs are valid.');
}
} catch (error) {
console.error('Failed to check proofs:', error);
}
}
// Cashu deposit (mint) methods
showDeposit() {
this.resetDepositForm();
this.activeSection = 'cashu-mint';
}
async createDepositInvoice() {
if (!this.selectedMintId) return;
if (this.depositAmount <= 0) {
this.depositError = 'Please enter an amount';
return;
}
this.creatingDepositQuote = true;
this.depositError = '';
this.depositInvoice = '';
this.depositInvoiceQr = '';
try {
const quote = await this.cashuService.createMintQuote(
this.selectedMintId,
this.depositAmount
);
this.depositQuoteId = quote.quoteId;
this.depositInvoice = quote.invoice;
this.depositQuoteState = quote.state;
// Generate QR code
this.depositInvoiceQr = await QRCode.toDataURL(quote.invoice, {
width: 200,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
});
// Start polling for payment
this.startDepositPolling();
} catch (error) {
this.depositError =
error instanceof Error ? error.message : 'Failed to create invoice';
} finally {
this.creatingDepositQuote = false;
}
}
private startDepositPolling() {
// Poll every 3 seconds for payment confirmation
this.depositPollingInterval = setInterval(async () => {
await this.checkDepositPayment();
}, 3000);
}
async checkDepositPayment() {
if (!this.selectedMintId || !this.depositQuoteId) return;
this.checkingDepositPayment = true;
try {
const quote = await this.cashuService.checkMintQuote(
this.selectedMintId,
this.depositQuoteId
);
this.depositQuoteState = quote.state;
if (quote.state === 'PAID') {
// Invoice is paid, claim the tokens
this.stopDepositPolling();
await this.claimDepositTokens();
} else if (quote.state === 'ISSUED') {
// Already claimed
this.stopDepositPolling();
this.depositSuccess = 'Tokens already claimed!';
}
} catch (error) {
// Don't show error for polling failures, just log
console.error('Failed to check payment:', error);
} finally {
this.checkingDepositPayment = false;
}
}
async claimDepositTokens() {
if (!this.selectedMintId || !this.depositQuoteId) return;
try {
const result = await this.cashuService.mintTokens(
this.selectedMintId,
this.depositAmount,
this.depositQuoteId
);
this.depositSuccess = `Received ${result.amount} sats!`;
this.depositQuoteState = 'ISSUED';
} catch (error) {
this.depositError =
error instanceof Error ? error.message : 'Failed to claim tokens';
}
}
async copyDepositInvoice() {
if (this.depositInvoice) {
await navigator.clipboard.writeText(this.depositInvoice);
}
}
formatCashuBalance(sats: number | undefined): string {
return this.cashuService.formatBalance(sats);
}
async refreshBalance(connectionId: string) {
try {
await this.nwcService.getBalance(connectionId);
} catch (error) {
console.error('Failed to refresh balance:', error);
}
}
async refreshAllBalances() {
this.loadingBalances = true;
this.balanceError = '';
try {
await this.nwcService.getAllBalances();
} catch {
this.balanceError = 'Failed to refresh some balances';
} finally {
this.loadingBalances = false;
}
}
formatBalance(millisats: number | undefined): string {
if (millisats === undefined) return '—';
// Convert millisats to sats with 3 decimal places
const sats = millisats / 1000;
return sats.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 3,
});
}
// Lightning transaction methods
async loadTransactions(connectionId: string) {
this.loadingTransactions = true;
this.transactionsError = '';
this.transactionsNotSupported = false;
try {
this.transactions = await this.nwcService.listTransactions(connectionId, {
limit: 20,
});
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
if (errorMsg.includes('NOT_IMPLEMENTED') || errorMsg.includes('not supported')) {
this.transactionsNotSupported = true;
} else {
this.transactionsError = errorMsg;
}
this.transactions = [];
} finally {
this.loadingTransactions = false;
}
}
async refreshWallet() {
if (!this.selectedConnectionId) return;
// Refresh balance and transactions in parallel
await Promise.all([
this.refreshBalance(this.selectedConnectionId),
this.loadTransactions(this.selectedConnectionId),
]);
}
showLnReceive() {
this.resetLightningForms();
this.activeSection = 'lightning-receive';
}
showLnPay() {
this.resetLightningForms();
this.showPayModal = true;
}
closePayModal() {
this.showPayModal = false;
this.resetLightningForms();
}
async createReceiveInvoice() {
if (!this.selectedConnectionId) return;
if (this.lnReceiveAmount <= 0) {
this.lnReceiveError = 'Please enter an amount';
return;
}
this.generatingInvoice = true;
this.lnReceiveError = '';
this.generatedInvoice = '';
this.generatedInvoiceQr = '';
try {
const result = await this.nwcService.makeInvoice(
this.selectedConnectionId,
this.lnReceiveAmount * 1000, // Convert sats to millisats
this.lnReceiveDescription || undefined
);
this.generatedInvoice = result.invoice;
// Generate QR code
this.generatedInvoiceQr = await QRCode.toDataURL(result.invoice, {
width: 200,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
});
} catch (error) {
this.lnReceiveError =
error instanceof Error ? error.message : 'Failed to create invoice';
} finally {
this.generatingInvoice = false;
}
}
async copyInvoice() {
if (this.generatedInvoice) {
await navigator.clipboard.writeText(this.generatedInvoice);
this.invoiceCopied = true;
setTimeout(() => (this.invoiceCopied = false), 2000);
}
}
async copyLightningAddress() {
const lud16 = this.selectedConnection?.lud16;
if (lud16) {
await navigator.clipboard.writeText(lud16);
this.addressCopied = true;
setTimeout(() => (this.addressCopied = false), 2000);
}
}
async payInvoiceOrAddress() {
if (!this.selectedConnectionId || !this.payInput.trim()) {
this.paymentError = 'Please enter a lightning address or invoice';
return;
}
this.paying = true;
this.paymentError = '';
this.paymentSuccess = false;
try {
let invoice = this.payInput.trim();
// Check if it's a lightning address
if (this.nwcService.isLightningAddress(invoice)) {
if (this.payAmount <= 0) {
this.paymentError = 'Please enter an amount for lightning address payments';
this.paying = false;
return;
}
// Resolve lightning address to invoice
invoice = await this.nwcService.resolveLightningAddress(
invoice,
this.payAmount * 1000 // Convert sats to millisats
);
}
// Pay the invoice
await this.nwcService.payInvoice(
this.selectedConnectionId,
invoice,
this.payAmount > 0 ? this.payAmount * 1000 : undefined
);
this.paymentSuccess = true;
// Refresh balance and transactions after payment
await this.refreshWallet();
// Close modal after a delay
setTimeout(() => {
this.closePayModal();
}, 2000);
} catch (error) {
this.paymentError =
error instanceof Error ? error.message : 'Payment failed';
} finally {
this.paying = false;
}
}
formatTransactionTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
}
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
}
formatProofTime(isoTimestamp: string | undefined): string {
if (!isoTimestamp) return '—';
const date = new Date(isoTimestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
}
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
// Cashu onboarding methods
dismissCashuInfo() {
this.showCashuInfo = false;
}
navigateToSettings() {
this.#router.navigateByUrl('/home/settings');
}
}

View File

@@ -37,6 +37,26 @@
<span> Sync OFF</span>
</button>
<div class="storage-info">
<details>
<summary>Important for Cashu wallet users</summary>
<p>
Browser sync storage is limited to ~100KB shared across all data
(identities, permissions, relays, and Cashu tokens).
</p>
<p>
If you plan to use the Cashu ecash wallet with significant balances,
choose <strong>"Sync OFF"</strong> which provides ~5MB of local storage
(enough for ~18,000+ tokens vs ~300-400 with sync).
</p>
<p>
<strong>Note:</strong> Cashu tokens are bearer assets. If you lose your
vault backup, you lose your tokens permanently. Make sure to configure
regular backups.
</p>
</details>
</div>
<div class="sam-flex-grow"></div>
<span class="sam-text-muted sam-text-md sam-mb">

View File

@@ -6,3 +6,41 @@
padding-left: var(--size);
padding-right: var(--size);
}
.storage-info {
margin-top: 1rem;
width: 100%;
details {
background: rgba(255, 193, 7, 0.1);
border: 1px solid var(--warning, #ffc107);
border-radius: 6px;
padding: 0.5rem;
summary {
cursor: pointer;
font-weight: 500;
font-size: 0.9rem;
color: var(--warning, #ffc107);
&:hover {
text-decoration: underline;
}
}
p {
margin: 0.75rem 0 0 0;
font-size: 0.85rem;
line-height: 1.4;
color: var(--text-muted, #6c757d);
&:last-child {
margin-bottom: 0.5rem;
}
strong {
color: var(--text, #212529);
}
}
}
}

View File

@@ -17,7 +17,7 @@ export class WhitelistedAppsComponent extends NavComponent {
@ViewChild('toast') toast!: ToastComponent;
@ViewChild('confirm') confirm!: ConfirmComponent;
readonly storage = inject(StorageService);
override readonly storage = inject(StorageService);
readonly #router = inject(Router);
get whitelistedHosts(): string[] {

View File

@@ -6,16 +6,40 @@ import {
CryptoHelper,
SignerMetaData,
Identity_DECRYPTED,
Identity_ENCRYPTED,
Nip07Method,
Nip07MethodPolicy,
NostrHelper,
Permission_DECRYPTED,
Permission_ENCRYPTED,
Relay_DECRYPTED,
Relay_ENCRYPTED,
NwcConnection_DECRYPTED,
NwcConnection_ENCRYPTED,
CashuMint_DECRYPTED,
CashuMint_ENCRYPTED,
deriveKeyArgon2,
ExtensionMethod,
WeblnMethod,
} from '@common';
import { ChromeMetaHandler } from './app/common/data/chrome-meta-handler';
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
import { Buffer } from 'buffer';
// Unlock request/response message types
export interface UnlockRequestMessage {
type: 'unlock-request';
id: string;
password: string;
}
export interface UnlockResponseMessage {
type: 'unlock-response';
id: string;
success: boolean;
error?: string;
}
export const debug = function (message: any) {
const dateString = new Date().toISOString();
console.log(`[Plebeian Signer - ${dateString}]: ${JSON.stringify(message)}`);
@@ -33,7 +57,7 @@ export interface PromptResponseMessage {
}
export interface BackgroundRequestMessage {
method: Nip07Method;
method: ExtensionMethod;
params: any;
host: string;
}
@@ -196,11 +220,51 @@ export const checkPermissions = function (
return undefined;
};
/**
* Check if a method is a WebLN method
*/
export const isWeblnMethod = function (method: ExtensionMethod): method is WeblnMethod {
return method.startsWith('webln.');
};
/**
* Check WebLN permissions for a host.
* Note: WebLN permissions are NOT tied to identities since the wallet is global.
* For sendPayment, always returns undefined (require user prompt for security).
*/
export const checkWeblnPermissions = function (
browserSessionData: BrowserSessionData,
host: string,
method: WeblnMethod
): boolean | undefined {
// sendPayment ALWAYS requires user approval (security-critical, irreversible)
if (method === 'webln.sendPayment') {
return undefined;
}
// keysend also requires approval
if (method === 'webln.keysend') {
return undefined;
}
// For other WebLN methods, check stored permissions
// WebLN permissions use 'webln' as the identityId
const permissions = browserSessionData.permissions.filter(
(x) => x.identityId === 'webln' && x.host === host && x.method === method
);
if (permissions.length === 0) {
return undefined;
}
return permissions.every((x) => x.methodPolicy === 'allow');
};
export const storePermission = async function (
browserSessionData: BrowserSessionData,
identity: Identity_DECRYPTED,
identity: Identity_DECRYPTED | null,
host: string,
method: Nip07Method,
method: ExtensionMethod,
methodPolicy: Nip07MethodPolicy,
kind?: number
) {
@@ -209,11 +273,14 @@ export const storePermission = async function (
throw new Error(`Could not retrieve sync data`);
}
// For WebLN methods, use 'webln' as identityId since wallet is global
const identityId = identity?.id ?? 'webln';
const permission: Permission_DECRYPTED = {
id: crypto.randomUUID(),
identityId: identity.id,
identityId,
host,
method,
method: method as Nip07Method, // Cast for storage compatibility
methodPolicy,
kind,
};
@@ -372,3 +439,352 @@ const encrypt = async function (
// v1: Use password with PBKDF2
return await CryptoHelper.encrypt(value, sessionData.iv, sessionData.vaultPassword!);
};
// ==========================================
// Unlock Vault Logic (for background script)
// ==========================================
/**
* Decrypt a value using AES-GCM with pre-derived key (v2)
*/
async function decryptV2(
encryptedBase64: string,
ivBase64: string,
keyBase64: string
): Promise<string> {
const keyBytes = Buffer.from(keyBase64, 'base64');
const iv = Buffer.from(ivBase64, 'base64');
const cipherText = Buffer.from(encryptedBase64, 'base64');
const key = await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'AES-GCM' },
false,
['decrypt']
);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
cipherText
);
return new TextDecoder().decode(decrypted);
}
/**
* Decrypt a value using PBKDF2 (v1)
*/
async function decryptV1(
encryptedBase64: string,
ivBase64: string,
password: string
): Promise<string> {
return CryptoHelper.decrypt(encryptedBase64, ivBase64, password);
}
/**
* Generic decrypt function that handles both v1 and v2
*/
async function decryptValue(
encrypted: string,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<string> {
if (isV2) {
return decryptV2(encrypted, iv, keyOrPassword);
}
return decryptV1(encrypted, iv, keyOrPassword);
}
/**
* Parse decrypted value to the desired type
*/
function parseValue(value: string, type: 'string' | 'number' | 'boolean'): any {
switch (type) {
case 'number':
return parseInt(value);
case 'boolean':
return value === 'true';
default:
return value;
}
}
/**
* Decrypt an identity
*/
async function decryptIdentity(
identity: Identity_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<Identity_DECRYPTED> {
return {
id: await decryptValue(identity.id, iv, keyOrPassword, isV2),
nick: await decryptValue(identity.nick, iv, keyOrPassword, isV2),
createdAt: await decryptValue(identity.createdAt, iv, keyOrPassword, isV2),
privkey: await decryptValue(identity.privkey, iv, keyOrPassword, isV2),
};
}
/**
* Decrypt a permission
*/
async function decryptPermission(
permission: Permission_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<Permission_DECRYPTED> {
const decrypted: Permission_DECRYPTED = {
id: await decryptValue(permission.id, iv, keyOrPassword, isV2),
identityId: await decryptValue(permission.identityId, iv, keyOrPassword, isV2),
host: await decryptValue(permission.host, iv, keyOrPassword, isV2),
method: await decryptValue(permission.method, iv, keyOrPassword, isV2) as Nip07Method,
methodPolicy: await decryptValue(permission.methodPolicy, iv, keyOrPassword, isV2) as Nip07MethodPolicy,
};
if (permission.kind) {
decrypted.kind = parseValue(await decryptValue(permission.kind, iv, keyOrPassword, isV2), 'number');
}
return decrypted;
}
/**
* Decrypt a relay
*/
async function decryptRelay(
relay: Relay_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<Relay_DECRYPTED> {
return {
id: await decryptValue(relay.id, iv, keyOrPassword, isV2),
identityId: await decryptValue(relay.identityId, iv, keyOrPassword, isV2),
url: await decryptValue(relay.url, iv, keyOrPassword, isV2),
read: parseValue(await decryptValue(relay.read, iv, keyOrPassword, isV2), 'boolean'),
write: parseValue(await decryptValue(relay.write, iv, keyOrPassword, isV2), 'boolean'),
};
}
/**
* Decrypt an NWC connection
*/
async function decryptNwcConnection(
nwc: NwcConnection_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<NwcConnection_DECRYPTED> {
const decrypted: NwcConnection_DECRYPTED = {
id: await decryptValue(nwc.id, iv, keyOrPassword, isV2),
name: await decryptValue(nwc.name, iv, keyOrPassword, isV2),
connectionUrl: await decryptValue(nwc.connectionUrl, iv, keyOrPassword, isV2),
walletPubkey: await decryptValue(nwc.walletPubkey, iv, keyOrPassword, isV2),
relayUrl: await decryptValue(nwc.relayUrl, iv, keyOrPassword, isV2),
secret: await decryptValue(nwc.secret, iv, keyOrPassword, isV2),
createdAt: await decryptValue(nwc.createdAt, iv, keyOrPassword, isV2),
};
if (nwc.lud16) {
decrypted.lud16 = await decryptValue(nwc.lud16, iv, keyOrPassword, isV2);
}
if (nwc.cachedBalance) {
decrypted.cachedBalance = parseValue(await decryptValue(nwc.cachedBalance, iv, keyOrPassword, isV2), 'number');
}
if (nwc.cachedBalanceAt) {
decrypted.cachedBalanceAt = await decryptValue(nwc.cachedBalanceAt, iv, keyOrPassword, isV2);
}
return decrypted;
}
/**
* Decrypt a Cashu mint
*/
async function decryptCashuMint(
mint: CashuMint_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<CashuMint_DECRYPTED> {
const proofsJson = await decryptValue(mint.proofs, iv, keyOrPassword, isV2);
const decrypted: CashuMint_DECRYPTED = {
id: await decryptValue(mint.id, iv, keyOrPassword, isV2),
name: await decryptValue(mint.name, iv, keyOrPassword, isV2),
mintUrl: await decryptValue(mint.mintUrl, iv, keyOrPassword, isV2),
unit: await decryptValue(mint.unit, iv, keyOrPassword, isV2),
createdAt: await decryptValue(mint.createdAt, iv, keyOrPassword, isV2),
proofs: JSON.parse(proofsJson),
};
if (mint.cachedBalance) {
decrypted.cachedBalance = parseValue(await decryptValue(mint.cachedBalance, iv, keyOrPassword, isV2), 'number');
}
if (mint.cachedBalanceAt) {
decrypted.cachedBalanceAt = await decryptValue(mint.cachedBalanceAt, iv, keyOrPassword, isV2);
}
return decrypted;
}
/**
* Handle an unlock request from the unlock popup
*/
export async function handleUnlockRequest(
password: string
): Promise<{ success: boolean; error?: string }> {
try {
debug('handleUnlockRequest: Starting unlock...');
// Check if already unlocked
const existingSession = await getBrowserSessionData();
if (existingSession) {
debug('handleUnlockRequest: Already unlocked');
return { success: true };
}
// Get sync data
const browserSyncData = await getBrowserSyncData();
if (!browserSyncData) {
return { success: false, error: 'No vault data found' };
}
// Verify password
const passwordHash = await CryptoHelper.hash(password);
if (passwordHash !== browserSyncData.vaultHash) {
return { success: false, error: 'Invalid password' };
}
debug('handleUnlockRequest: Password verified');
// Detect vault version
const isV2 = !!browserSyncData.salt;
debug(`handleUnlockRequest: Vault version: ${isV2 ? 'v2' : 'v1'}`);
let keyOrPassword: string;
let vaultKey: string | undefined;
let vaultPassword: string | undefined;
if (isV2) {
// v2: Derive key with Argon2id (~3 seconds)
debug('handleUnlockRequest: Deriving Argon2id key...');
const saltBytes = Buffer.from(browserSyncData.salt!, 'base64');
const keyBytes = await deriveKeyArgon2(password, saltBytes);
vaultKey = Buffer.from(keyBytes).toString('base64');
keyOrPassword = vaultKey;
debug('handleUnlockRequest: Key derived');
} else {
// v1: Use password directly
vaultPassword = password;
keyOrPassword = password;
}
// Decrypt identities
debug('handleUnlockRequest: Decrypting identities...');
const decryptedIdentities: Identity_DECRYPTED[] = [];
for (const identity of browserSyncData.identities) {
const decrypted = await decryptIdentity(identity, browserSyncData.iv, keyOrPassword, isV2);
decryptedIdentities.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedIdentities.length} identities`);
// Decrypt permissions
debug('handleUnlockRequest: Decrypting permissions...');
const decryptedPermissions: Permission_DECRYPTED[] = [];
for (const permission of browserSyncData.permissions) {
try {
const decrypted = await decryptPermission(permission, browserSyncData.iv, keyOrPassword, isV2);
decryptedPermissions.push(decrypted);
} catch (e) {
debug(`handleUnlockRequest: Skipping corrupted permission: ${e}`);
}
}
debug(`handleUnlockRequest: Decrypted ${decryptedPermissions.length} permissions`);
// Decrypt relays
debug('handleUnlockRequest: Decrypting relays...');
const decryptedRelays: Relay_DECRYPTED[] = [];
for (const relay of browserSyncData.relays) {
const decrypted = await decryptRelay(relay, browserSyncData.iv, keyOrPassword, isV2);
decryptedRelays.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedRelays.length} relays`);
// Decrypt NWC connections
debug('handleUnlockRequest: Decrypting NWC connections...');
const decryptedNwcConnections: NwcConnection_DECRYPTED[] = [];
for (const nwc of browserSyncData.nwcConnections ?? []) {
const decrypted = await decryptNwcConnection(nwc, browserSyncData.iv, keyOrPassword, isV2);
decryptedNwcConnections.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedNwcConnections.length} NWC connections`);
// Decrypt Cashu mints
debug('handleUnlockRequest: Decrypting Cashu mints...');
const decryptedCashuMints: CashuMint_DECRYPTED[] = [];
for (const mint of browserSyncData.cashuMints ?? []) {
const decrypted = await decryptCashuMint(mint, browserSyncData.iv, keyOrPassword, isV2);
decryptedCashuMints.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedCashuMints.length} Cashu mints`);
// Decrypt selectedIdentityId
let decryptedSelectedIdentityId: string | null = null;
if (browserSyncData.selectedIdentityId !== null) {
decryptedSelectedIdentityId = await decryptValue(
browserSyncData.selectedIdentityId,
browserSyncData.iv,
keyOrPassword,
isV2
);
}
debug(`handleUnlockRequest: selectedIdentityId: ${decryptedSelectedIdentityId}`);
// Build session data
const browserSessionData: BrowserSessionData = {
vaultPassword: isV2 ? undefined : vaultPassword,
vaultKey: isV2 ? vaultKey : undefined,
iv: browserSyncData.iv,
salt: browserSyncData.salt,
permissions: decryptedPermissions,
identities: decryptedIdentities,
selectedIdentityId: decryptedSelectedIdentityId,
relays: decryptedRelays,
nwcConnections: decryptedNwcConnections,
cashuMints: decryptedCashuMints,
};
// Save session data
debug('handleUnlockRequest: Saving session data...');
await chrome.storage.session.set(browserSessionData);
debug('handleUnlockRequest: Unlock complete!');
return { success: true };
} catch (error: any) {
debug(`handleUnlockRequest: Error: ${error.message}`);
return { success: false, error: error.message || 'Unlock failed' };
}
}
/**
* Open the unlock popup window
*/
export async function openUnlockPopup(host?: string): Promise<void> {
const width = 375;
const height = 500;
const { top, left } = await getPosition(width, height);
const id = crypto.randomUUID();
let url = `unlock.html?id=${id}`;
if (host) {
url += `&host=${encodeURIComponent(host)}`;
}
await chrome.windows.create({
type: 'popup',
url,
height,
width,
top,
left,
});
}

View File

@@ -3,26 +3,106 @@ import {
backgroundLogNip07Action,
backgroundLogPermissionStored,
NostrHelper,
NwcClient,
NwcConnection_DECRYPTED,
WeblnMethod,
Nip07Method,
GetInfoResponse,
SendPaymentResponse,
RequestInvoiceResponse,
} from '@common';
import {
BackgroundRequestMessage,
checkPermissions,
checkWeblnPermissions,
debug,
getBrowserSessionData,
getPosition,
handleUnlockRequest,
isWeblnMethod,
nip04Decrypt,
nip04Encrypt,
nip44Decrypt,
nip44Encrypt,
openUnlockPopup,
PromptResponse,
PromptResponseMessage,
shouldRecklessModeApprove,
signEvent,
storePermission,
UnlockRequestMessage,
UnlockResponseMessage,
} from './background-common';
import browser from 'webextension-polyfill';
import { Buffer } from 'buffer';
// Cache for NWC clients to avoid reconnecting for each request
const nwcClientCache = new Map<string, NwcClient>();
/**
* Get or create an NWC client for a connection
*/
async function getNwcClient(connection: NwcConnection_DECRYPTED): Promise<NwcClient> {
const cached = nwcClientCache.get(connection.id);
if (cached && cached.isConnected()) {
return cached;
}
const client = new NwcClient({
walletPubkey: connection.walletPubkey,
relayUrl: connection.relayUrl,
secret: connection.secret,
});
await client.connect();
nwcClientCache.set(connection.id, client);
return client;
}
/**
* Parse invoice amount from a BOLT11 invoice string
* Returns amount in satoshis, or undefined if no amount specified
*/
function parseInvoiceAmount(invoice: string): number | undefined {
try {
// BOLT11 invoices start with 'ln' followed by network prefix and amount
// Format: ln[network][amount][multiplier]1[data]
// Examples: lnbc1500n1... (1500 sat), lnbc1m1... (0.001 BTC = 100000 sat)
const match = invoice.toLowerCase().match(/^ln(bc|tb|tbs|bcrt)(\d+)([munp])?1/);
if (!match) {
return undefined;
}
const amountStr = match[2];
const multiplier = match[3];
let amount = parseInt(amountStr, 10);
// Apply multiplier (amount is in BTC by default)
switch (multiplier) {
case 'm': // milli-bitcoin (0.001 BTC)
amount = amount * 100000;
break;
case 'u': // micro-bitcoin (0.000001 BTC)
amount = amount * 100;
break;
case 'n': // nano-bitcoin (0.000000001 BTC) = 0.1 sat
amount = Math.floor(amount / 10);
break;
case 'p': // pico-bitcoin (0.000000000001 BTC) = 0.0001 sat
amount = Math.floor(amount / 10000);
break;
default:
// No multiplier means BTC
amount = amount * 100000000;
}
return amount;
} catch {
return undefined;
}
}
type Relays = Record<string, { read: boolean; write: boolean }>;
const openPrompts = new Map<
@@ -33,8 +113,49 @@ const openPrompts = new Map<
}
>();
// Track if unlock popup is already open
let unlockPopupOpen = false;
// Queue of pending NIP-07 requests waiting for unlock
const pendingRequests: {
request: BackgroundRequestMessage;
resolve: (result: any) => void;
reject: (error: any) => void;
}[] = [];
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
debug('Message received');
// Handle unlock request from unlock popup
if ((message as UnlockRequestMessage)?.type === 'unlock-request') {
const unlockReq = message as UnlockRequestMessage;
debug('Processing unlock request');
const result = await handleUnlockRequest(unlockReq.password);
const response: UnlockResponseMessage = {
type: 'unlock-response',
id: unlockReq.id,
success: result.success,
error: result.error,
};
if (result.success) {
unlockPopupOpen = false;
// Process any pending NIP-07 requests
debug(`Processing ${pendingRequests.length} pending requests`);
while (pendingRequests.length > 0) {
const pending = pendingRequests.shift()!;
try {
const pendingResult = await processNip07Request(pending.request);
pending.resolve(pendingResult);
} catch (error) {
pending.reject(error);
}
}
}
return response;
}
const request = message as BackgroundRequestMessage | PromptResponseMessage;
debug(request);
@@ -55,6 +176,36 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
// Vault is locked - open unlock popup and queue the request
const req = request as BackgroundRequestMessage;
debug('Vault locked, opening unlock popup');
if (!unlockPopupOpen) {
unlockPopupOpen = true;
await openUnlockPopup(req.host);
}
// Queue this request to be processed after unlock
return new Promise((resolve, reject) => {
pendingRequests.push({ request: req, resolve, reject });
});
}
// Process the request (NIP-07 or WebLN)
const req = request as BackgroundRequestMessage;
if (isWeblnMethod(req.method)) {
return processWeblnRequest(req);
}
return processNip07Request(req);
});
/**
* Process a NIP-07 request after vault is unlocked
*/
async function processNip07Request(req: BackgroundRequestMessage): Promise<any> {
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
throw new Error('Plebeian Signer vault not unlocked by the user.');
}
@@ -67,8 +218,6 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
throw new Error('No Nostr identity available at endpoint.');
}
const req = request as BackgroundRequestMessage;
// Check reckless mode first
const recklessApprove = await shouldRecklessModeApprove(req.host);
debug(`recklessApprove result: ${recklessApprove}`);
@@ -80,7 +229,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
browserSessionData,
currentIdentity,
req.host,
req.method,
req.method as Nip07Method,
req.params
);
debug(`permissionState result: ${permissionState}`);
@@ -212,4 +361,147 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
default:
throw new Error(`Not supported request method '${req.method}'.`);
}
});
}
/**
* Process a WebLN request after vault is unlocked
*/
async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any> {
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
throw new Error('Plebeian Signer vault not unlocked by the user.');
}
const nwcConnections = browserSessionData.nwcConnections ?? [];
const method = req.method as WeblnMethod;
// webln.enable just checks if NWC is configured
if (method === 'webln.enable') {
if (nwcConnections.length === 0) {
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
}
debug('WebLN enabled');
return { enabled: true }; // Return explicit value (undefined gets filtered by content script)
}
// All other methods require an NWC connection
const defaultConnection = nwcConnections[0];
if (!defaultConnection) {
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
}
// Check reckless mode (but still prompt for payments)
const recklessApprove = await shouldRecklessModeApprove(req.host);
// Check WebLN permissions
const permissionState = recklessApprove && method !== 'webln.sendPayment' && method !== 'webln.keysend'
? true
: checkWeblnPermissions(browserSessionData, req.host, method);
if (permissionState === false) {
throw new Error('Permission denied');
}
if (permissionState === undefined) {
// Ask user for permission
const width = 375;
const height = 600;
const { top, left } = await getPosition(width, height);
// For sendPayment, include the invoice amount in the prompt data
let promptParams = req.params ?? {};
if (method === 'webln.sendPayment' && req.params?.paymentRequest) {
const amountSats = parseInvoiceAmount(req.params.paymentRequest);
promptParams = { ...promptParams, amountSats };
}
const base64Event = Buffer.from(
JSON.stringify(promptParams, undefined, 2)
).toString('base64');
const response = await new Promise<PromptResponse>((resolve, reject) => {
const id = crypto.randomUUID();
openPrompts.set(id, { resolve, reject });
browser.windows.create({
type: 'popup',
url: `prompt.html?method=${method}&host=${req.host}&id=${id}&nick=WebLN&event=${base64Event}`,
height,
width,
top,
left,
});
});
debug(response);
// Store permission for non-payment methods
if ((response === 'approve' || response === 'reject') && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
const policy = response === 'approve' ? 'allow' : 'deny';
await storePermission(
browserSessionData,
null, // WebLN has no identity
req.host,
method,
policy
);
await backgroundLogPermissionStored(req.host, method, policy);
}
if (['reject', 'reject-once'].includes(response)) {
throw new Error('Permission denied');
}
}
// Execute the WebLN method
let result: any;
const client = await getNwcClient(defaultConnection);
switch (method) {
case 'webln.getInfo': {
const info = await client.getInfo();
result = {
node: {
alias: info.alias,
pubkey: info.pubkey,
color: info.color,
},
} as GetInfoResponse;
debug('webln.getInfo result:');
debug(result);
return result;
}
case 'webln.sendPayment': {
const invoice = req.params.paymentRequest;
const payResult = await client.payInvoice({ invoice });
result = { preimage: payResult.preimage } as SendPaymentResponse;
debug('webln.sendPayment result:');
debug(result);
return result;
}
case 'webln.makeInvoice': {
// Convert sats to millisats (NWC uses millisats)
const amountSats = typeof req.params.amount === 'string'
? parseInt(req.params.amount, 10)
: req.params.amount ?? req.params.defaultAmount ?? 0;
const amountMsat = amountSats * 1000;
const invoiceResult = await client.makeInvoice({
amount: amountMsat,
description: req.params.defaultMemo,
});
result = { paymentRequest: invoiceResult.invoice } as RequestInvoiceResponse;
debug('webln.makeInvoice result:');
debug(result);
return result;
}
case 'webln.keysend':
throw new Error('keysend is not yet supported');
default:
throw new Error(`Not supported WebLN method '${method}'.`);
}
}

View File

@@ -5,6 +5,7 @@ import {
} from '@common';
import './app/common/extensions/array';
import browser from 'webextension-polyfill';
import { v4 as uuidv4 } from 'uuid';
//
// Functions
@@ -105,8 +106,12 @@ document.addEventListener('DOMContentLoaded', async () => {
}
newSnapshots.push({
id: uuidv4(),
fileName: file.name,
createdAt: new Date().toISOString(),
data: vault,
identityCount: vault.identities?.length ?? 0,
reason: 'manual',
});
}

View File

@@ -1,11 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Event, EventTemplate } from 'nostr-tools';
import { Nip07Method } from '@common';
import { Event as NostrEvent, EventTemplate } from 'nostr-tools';
import { ExtensionMethod } from '@common';
// Extend Window interface for NIP-07
// Extend Window interface for NIP-07 and WebLN
declare global {
interface Window {
nostr?: any;
webln?: any;
}
}
@@ -38,7 +39,7 @@ class Messenger {
window.addEventListener('message', this.#handleCallResponse.bind(this));
}
async request(method: Nip07Method, params: any): Promise<any> {
async request(method: ExtensionMethod, params: any): Promise<any> {
const id = generateUUID();
return new Promise((resolve, reject) => {
@@ -89,7 +90,7 @@ const nostr = {
return pubkey;
},
async signEvent(event: EventTemplate): Promise<Event> {
async signEvent(event: EventTemplate): Promise<NostrEvent> {
debug('signEvent received');
const signedEvent = await this.messenger.request('signEvent', event);
debug('signEvent response:');
@@ -158,6 +159,92 @@ const nostr = {
window.nostr = nostr as any;
// WebLN types (inline to avoid build issues with @common types in injected script)
interface RequestInvoiceArgs {
amount?: string | number;
defaultAmount?: string | number;
minimumAmount?: string | number;
maximumAmount?: string | number;
defaultMemo?: string;
}
interface KeysendArgs {
destination: string;
amount: string | number;
customRecords?: Record<string, string>;
}
// Create a shared messenger instance for WebLN
const weblnMessenger = nostr.messenger;
const webln = {
enabled: false,
async enable(): Promise<void> {
debug('webln.enable received');
await weblnMessenger.request('webln.enable', {});
this.enabled = true;
debug('webln.enable completed');
// Dispatch webln:enabled event as per WebLN spec
window.dispatchEvent(new Event('webln:enabled'));
},
async getInfo(): Promise<{ node: { alias?: string; pubkey?: string; color?: string } }> {
debug('webln.getInfo received');
const info = await weblnMessenger.request('webln.getInfo', {});
debug('webln.getInfo response:');
debug(info);
return info;
},
async sendPayment(paymentRequest: string): Promise<{ preimage: string }> {
debug('webln.sendPayment received');
const result = await weblnMessenger.request('webln.sendPayment', { paymentRequest });
debug('webln.sendPayment response:');
debug(result);
return result;
},
async keysend(args: KeysendArgs): Promise<{ preimage: string }> {
debug('webln.keysend received');
const result = await weblnMessenger.request('webln.keysend', args);
debug('webln.keysend response:');
debug(result);
return result;
},
async makeInvoice(
args: string | number | RequestInvoiceArgs
): Promise<{ paymentRequest: string }> {
debug('webln.makeInvoice received');
// Normalize args to RequestInvoiceArgs
let normalizedArgs: RequestInvoiceArgs;
if (typeof args === 'string' || typeof args === 'number') {
normalizedArgs = { amount: args };
} else {
normalizedArgs = args;
}
const result = await weblnMessenger.request('webln.makeInvoice', normalizedArgs);
debug('webln.makeInvoice response:');
debug(result);
return result;
},
signMessage(): Promise<{ message: string; signature: string }> {
throw new Error('signMessage is not supported - NWC does not provide node signing capabilities');
},
verifyMessage(): Promise<void> {
throw new Error('verifyMessage is not supported - NWC does not provide message verification');
},
};
window.webln = webln as any;
// Dispatch webln:ready event to signal that webln is available
// This is dispatched on document as per the WebLN standard
document.dispatchEvent(new Event('webln:ready'));
const debug = function (value: any) {
console.log(JSON.stringify(value));
};

View File

@@ -1,5 +1,5 @@
import browser from 'webextension-polyfill';
import { Nip07Method } from '@common';
import { ExtensionMethod } from '@common';
import { PromptResponse, PromptResponseMessage } from './background-common';
/**
@@ -14,7 +14,7 @@ function base64ToUtf8(base64: string): string {
const params = new URLSearchParams(location.search);
const id = params.get('id') as string;
const method = params.get('method') as Nip07Method;
const method = params.get('method') as ExtensionMethod;
const host = params.get('host') as string;
const nick = params.get('nick') as string;
@@ -58,6 +58,26 @@ switch (method) {
title = 'Get Relays';
break;
case 'webln.enable':
title = 'Enable WebLN';
break;
case 'webln.getInfo':
title = 'Wallet Info';
break;
case 'webln.sendPayment':
title = 'Send Payment';
break;
case 'webln.makeInvoice':
title = 'Create Invoice';
break;
case 'webln.keysend':
title = 'Keysend Payment';
break;
default:
break;
}
@@ -185,6 +205,65 @@ if (cardNip44DecryptElement && card2Nip44DecryptElement) {
}
}
// WebLN card visibility
const cardWeblnEnableElement = document.getElementById('cardWeblnEnable');
if (cardWeblnEnableElement) {
if (method !== 'webln.enable') {
cardWeblnEnableElement.style.display = 'none';
}
}
const cardWeblnGetInfoElement = document.getElementById('cardWeblnGetInfo');
if (cardWeblnGetInfoElement) {
if (method !== 'webln.getInfo') {
cardWeblnGetInfoElement.style.display = 'none';
}
}
const cardWeblnSendPaymentElement = document.getElementById('cardWeblnSendPayment');
const card2WeblnSendPaymentElement = document.getElementById('card2WeblnSendPayment');
if (cardWeblnSendPaymentElement && card2WeblnSendPaymentElement) {
if (method === 'webln.sendPayment') {
// Display amount in sats
const paymentAmountSpan = document.getElementById('paymentAmountSpan');
if (paymentAmountSpan && eventParsed.amountSats !== undefined) {
paymentAmountSpan.innerText = `${eventParsed.amountSats.toLocaleString()} sats`;
} else if (paymentAmountSpan) {
paymentAmountSpan.innerText = 'unknown amount';
}
// Show invoice in json card
const card2WeblnSendPayment_jsonElement = document.getElementById('card2WeblnSendPayment_json');
if (card2WeblnSendPayment_jsonElement && eventParsed.paymentRequest) {
card2WeblnSendPayment_jsonElement.innerText = eventParsed.paymentRequest;
}
} else {
cardWeblnSendPaymentElement.style.display = 'none';
card2WeblnSendPaymentElement.style.display = 'none';
}
}
const cardWeblnMakeInvoiceElement = document.getElementById('cardWeblnMakeInvoice');
if (cardWeblnMakeInvoiceElement) {
if (method === 'webln.makeInvoice') {
const invoiceAmountSpan = document.getElementById('invoiceAmountSpan');
if (invoiceAmountSpan) {
const amount = eventParsed.amount ?? eventParsed.defaultAmount;
if (amount) {
invoiceAmountSpan.innerText = ` for ${Number(amount).toLocaleString()} sats`;
}
}
} else {
cardWeblnMakeInvoiceElement.style.display = 'none';
}
}
const cardWeblnKeysendElement = document.getElementById('cardWeblnKeysend');
if (cardWeblnKeysendElement) {
if (method !== 'webln.keysend') {
cardWeblnKeysendElement.style.display = 'none';
}
}
//
// Functions
//

View File

@@ -0,0 +1,106 @@
import browser from 'webextension-polyfill';
export interface UnlockRequestMessage {
type: 'unlock-request';
id: string;
password: string;
}
export interface UnlockResponseMessage {
type: 'unlock-response';
id: string;
success: boolean;
error?: string;
}
const params = new URLSearchParams(location.search);
const id = params.get('id') as string;
const host = params.get('host');
// Elements
const passwordInput = document.getElementById('passwordInput') as HTMLInputElement;
const togglePasswordBtn = document.getElementById('togglePassword');
const unlockBtn = document.getElementById('unlockBtn') as HTMLButtonElement;
const derivingOverlay = document.getElementById('derivingOverlay');
const errorAlert = document.getElementById('errorAlert');
const errorMessage = document.getElementById('errorMessage');
const hostInfo = document.getElementById('hostInfo');
const hostSpan = document.getElementById('hostSpan');
// Show host info if available
if (host && hostInfo && hostSpan) {
hostSpan.innerText = host;
hostInfo.classList.remove('hidden');
}
// Toggle password visibility
togglePasswordBtn?.addEventListener('click', () => {
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
togglePasswordBtn.innerHTML = '<i class="bi bi-eye-slash"></i>';
} else {
passwordInput.type = 'password';
togglePasswordBtn.innerHTML = '<i class="bi bi-eye"></i>';
}
});
// Enable/disable unlock button based on password input
passwordInput?.addEventListener('input', () => {
unlockBtn.disabled = !passwordInput.value;
});
// Handle enter key
passwordInput?.addEventListener('keyup', (e) => {
if (e.key === 'Enter' && passwordInput.value) {
attemptUnlock();
}
});
// Handle unlock button click
unlockBtn?.addEventListener('click', attemptUnlock);
async function attemptUnlock() {
if (!passwordInput?.value) return;
// Show deriving overlay
derivingOverlay?.classList.remove('hidden');
errorAlert?.classList.add('hidden');
const message: UnlockRequestMessage = {
type: 'unlock-request',
id,
password: passwordInput.value,
};
try {
const response = await browser.runtime.sendMessage(message) as UnlockResponseMessage;
if (response.success) {
// Success - close the window
window.close();
} else {
// Failed - show error
derivingOverlay?.classList.add('hidden');
showError(response.error || 'Invalid password');
}
} catch (error) {
console.error('Failed to send unlock message:', error);
derivingOverlay?.classList.add('hidden');
showError('Failed to unlock vault');
}
}
function showError(message: string) {
if (errorAlert && errorMessage) {
errorMessage.innerText = message;
errorAlert.classList.remove('hidden');
setTimeout(() => {
errorAlert.classList.add('hidden');
}, 3000);
}
}
// Focus password input on load
document.addEventListener('DOMContentLoaded', () => {
passwordInput?.focus();
});

View File

@@ -12,7 +12,8 @@
"src/plebian-signer-extension.ts",
"src/plebian-signer-content-script.ts",
"src/prompt.ts",
"src/options.ts"
"src/options.ts",
"src/unlock.ts"
],
"include": ["src/**/*.d.ts"]
}

View File

@@ -1,8 +1,29 @@
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { StorageService } from '../services/storage/storage.service';
import { Buffer } from 'buffer';
declare const chrome: {
windows: {
create: (options: {
type: string;
url: string;
width: number;
height: number;
left: number;
top: number;
}) => void;
};
};
export class NavComponent {
readonly #router = inject(Router);
protected readonly storage = inject(StorageService);
devMode = false;
constructor() {
this.devMode = this.storage.getSignerMetaHandler().signerMetaData?.devMode ?? false;
}
navigateBack() {
window.history.back();
@@ -11,4 +32,32 @@ export class NavComponent {
navigate(path: string) {
this.#router.navigate([path]);
}
onTestPrompt() {
const testEvent = {
kind: 1,
content: 'This is a test note for permission prompt preview.',
tags: [],
created_at: Math.floor(Date.now() / 1000),
};
const base64Event = Buffer.from(JSON.stringify(testEvent, null, 2)).toString('base64');
const currentIdentity = this.storage.getBrowserSessionHandler().browserSessionData?.identities.find(
i => i.id === this.storage.getBrowserSessionHandler().browserSessionData?.selectedIdentityId
);
const nick = currentIdentity?.nick ?? 'Test Identity';
const width = 375;
const height = 600;
const left = Math.round((screen.width - width) / 2);
const top = Math.round((screen.height - height) / 2);
chrome.windows.create({
type: 'popup',
url: `prompt.html?method=signEvent&host=example.com&id=test-${Date.now()}&nick=${encodeURIComponent(nick)}&event=${base64Event}`,
width,
height,
left,
top,
});
}
}

View File

@@ -8,3 +8,12 @@ export type Nip07Method =
| 'nip44.decrypt';
export type Nip07MethodPolicy = 'allow' | 'deny';
export type WeblnMethod =
| 'webln.enable'
| 'webln.getInfo'
| 'webln.sendPayment'
| 'webln.makeInvoice'
| 'webln.keysend';
export type ExtensionMethod = Nip07Method | WeblnMethod;

View File

@@ -0,0 +1,41 @@
/**
* WebLN API Types
* Based on the WebLN specification: https://webln.dev/
*/
export interface WebLNNode {
alias?: string;
pubkey?: string;
color?: string;
}
export interface GetInfoResponse {
node: WebLNNode;
}
export interface SendPaymentResponse {
preimage: string;
}
export interface RequestInvoiceArgs {
amount?: string | number;
defaultAmount?: string | number;
minimumAmount?: string | number;
maximumAmount?: string | number;
defaultMemo?: string;
}
export interface RequestInvoiceResponse {
paymentRequest: string;
}
export interface KeysendArgs {
destination: string;
amount: string | number;
customRecords?: Record<string, string>;
}
export interface SignMessageResponse {
message: string;
signature: string;
}

View File

@@ -0,0 +1,450 @@
import { Injectable } from '@angular/core';
import {
Mint,
Wallet,
getDecodedToken,
getEncodedTokenV4,
Token,
Proof,
CheckStateEnum,
} from '@cashu/cashu-ts';
import { StorageService, CashuMint_DECRYPTED, CashuProof } from '@common';
import {
CashuReceiveResult,
CashuSendResult,
DecodedCashuToken,
CashuMintInfo,
CashuMintQuote,
CashuMintResult,
MintQuoteState,
} from './types';
interface CachedWallet {
wallet: Wallet;
mint: Mint;
mintId: string;
}
/**
* Angular service for managing Cashu ecash wallets
*/
@Injectable({
providedIn: 'root',
})
export class CashuService {
private wallets = new Map<string, CachedWallet>();
constructor(private storageService: StorageService) {}
/**
* Get all Cashu mints from storage
*/
getMints(): CashuMint_DECRYPTED[] {
const sessionData =
this.storageService.getBrowserSessionHandler().browserSessionData;
return sessionData?.cashuMints ?? [];
}
/**
* Get a single Cashu mint by ID
*/
getMint(mintId: string): CashuMint_DECRYPTED | undefined {
return this.getMints().find((m) => m.id === mintId);
}
/**
* Get a mint by URL
*/
getMintByUrl(mintUrl: string): CashuMint_DECRYPTED | undefined {
const normalizedUrl = mintUrl.replace(/\/$/, '');
return this.getMints().find((m) => m.mintUrl === normalizedUrl);
}
/**
* Add a new Cashu mint connection
*/
async addMint(name: string, mintUrl: string): Promise<CashuMint_DECRYPTED> {
// Test the mint connection first
await this.testMintConnection(mintUrl);
// Add to storage
return await this.storageService.addCashuMint({
name,
mintUrl,
unit: 'sat',
});
}
/**
* Delete a Cashu mint connection
*/
async deleteMint(mintId: string): Promise<void> {
// Remove from cache
this.wallets.delete(mintId);
await this.storageService.deleteCashuMint(mintId);
}
/**
* Get or create a wallet for a mint
*/
private async getWallet(mintId: string): Promise<CachedWallet> {
// Check cache
const cached = this.wallets.get(mintId);
if (cached) {
return cached;
}
// Get mint data from storage
const mintData = this.getMint(mintId);
if (!mintData) {
throw new Error('Mint not found');
}
// Create mint and wallet instances
const mint = new Mint(mintData.mintUrl);
const wallet = new Wallet(mint, { unit: mintData.unit || 'sat' });
// Load mint keys
await wallet.loadMint();
// Cache it
const cachedWallet: CachedWallet = {
wallet,
mint,
mintId,
};
this.wallets.set(mintId, cachedWallet);
return cachedWallet;
}
/**
* Test a mint connection by fetching its info
*/
async testMintConnection(mintUrl: string): Promise<CashuMintInfo> {
const normalizedUrl = mintUrl.replace(/\/$/, '');
const mint = new Mint(normalizedUrl);
const info = await mint.getInfo();
return {
name: info.name,
description: info.description,
version: info.version,
contact: info.contact?.map((c) => ({ method: c.method, info: c.info })),
nuts: info.nuts,
};
}
/**
* Decode a Cashu token without claiming it
*/
decodeToken(token: string): DecodedCashuToken | null {
try {
const decoded = getDecodedToken(token);
const proofs = decoded.proofs;
const amount = proofs.reduce((sum, p) => sum + p.amount, 0);
return {
mint: decoded.mint,
unit: decoded.unit || 'sat',
amount,
proofs,
};
} catch {
return null;
}
}
/**
* Receive a Cashu token
* This validates and claims the proofs, then stores them
*/
async receive(token: string): Promise<CashuReceiveResult> {
// Decode the token
const decoded = this.decodeToken(token);
if (!decoded) {
throw new Error('Invalid token format');
}
// Check if we have this mint
let mintData = this.getMintByUrl(decoded.mint);
// If we don't have this mint, add it automatically
if (!mintData) {
// Use the mint URL as the name initially
const urlObj = new URL(decoded.mint);
mintData = await this.storageService.addCashuMint({
name: urlObj.hostname,
mintUrl: decoded.mint,
unit: decoded.unit || 'sat',
});
}
// Get the wallet for this mint
const { wallet } = await this.getWallet(mintData.id);
// Receive the token (this swaps proofs with the mint)
const receivedProofs = await wallet.receive(token);
// Convert to our proof format with timestamp
const now = new Date().toISOString();
const newProofs: CashuProof[] = receivedProofs.map((p: Proof) => ({
id: p.id,
amount: p.amount,
secret: p.secret,
C: p.C,
receivedAt: now,
}));
// Merge with existing proofs
const existingProofs = mintData!.proofs || [];
const allProofs = [...existingProofs, ...newProofs];
// Update storage
await this.storageService.updateCashuMintProofs(mintData!.id, allProofs);
// Calculate received amount
const amount = newProofs.reduce((sum, p) => sum + p.amount, 0);
return {
amount,
mintUrl: decoded.mint,
mintId: mintData!.id,
};
}
/**
* Send Cashu tokens
* Creates an encoded token from existing proofs
*/
async send(mintId: string, amount: number): Promise<CashuSendResult> {
const mintData = this.getMint(mintId);
if (!mintData) {
throw new Error('Mint not found');
}
// Check we have enough balance
const balance = this.getBalance(mintId);
if (balance < amount) {
throw new Error(`Insufficient balance. Have ${balance} sats, need ${amount} sats`);
}
// Get the wallet
const { wallet } = await this.getWallet(mintId);
// Convert our proofs to the format cashu-ts expects
const proofs: Proof[] = mintData.proofs.map((p) => ({
id: p.id,
amount: p.amount,
secret: p.secret,
C: p.C,
}));
// Send - this returns send proofs and keep proofs (change)
const { send, keep } = await wallet.send(amount, proofs);
// Create the token to share
const token: Token = {
mint: mintData.mintUrl,
proofs: send,
unit: mintData.unit || 'sat',
};
const encodedToken = getEncodedTokenV4(token);
// Update our stored proofs to only keep the change (new proofs from mint)
const now = new Date().toISOString();
const keepProofs: CashuProof[] = keep.map((p: Proof) => ({
id: p.id,
amount: p.amount,
secret: p.secret,
C: p.C,
receivedAt: now,
}));
await this.storageService.updateCashuMintProofs(mintId, keepProofs);
return {
token: encodedToken,
amount,
};
}
/**
* Check if any proofs have been spent
* Removes spent proofs from storage
*/
async checkProofsSpent(mintId: string): Promise<number> {
const mintData = this.getMint(mintId);
if (!mintData) {
throw new Error('Mint not found');
}
if (mintData.proofs.length === 0) {
return 0;
}
const { wallet } = await this.getWallet(mintId);
// Only the secret field is needed for checking proof states
const proofsToCheck = mintData.proofs.map((p) => ({ secret: p.secret }));
// Check which proofs are spent using v3 API
const proofStates = await wallet.checkProofsStates(proofsToCheck);
// Filter out spent proofs
const unspentProofs: CashuProof[] = [];
let removedAmount = 0;
for (let i = 0; i < mintData.proofs.length; i++) {
if (proofStates[i].state !== CheckStateEnum.SPENT) {
unspentProofs.push(mintData.proofs[i]);
} else {
removedAmount += mintData.proofs[i].amount;
}
}
// Update storage if any were spent
if (removedAmount > 0) {
await this.storageService.updateCashuMintProofs(mintId, unspentProofs);
}
return removedAmount;
}
/**
* Create a mint quote (Lightning invoice) for depositing sats
* Returns a Lightning invoice that when paid will allow minting tokens
*/
async createMintQuote(mintId: string, amount: number): Promise<CashuMintQuote> {
const mintData = this.getMint(mintId);
if (!mintData) {
throw new Error('Mint not found');
}
if (amount <= 0) {
throw new Error('Amount must be greater than 0');
}
const { wallet } = await this.getWallet(mintId);
// Create a mint quote - this returns a Lightning invoice
const quote = await wallet.createMintQuote(amount);
return {
quoteId: quote.quote,
invoice: quote.request,
amount: amount,
state: quote.state as MintQuoteState,
expiry: quote.expiry,
};
}
/**
* Check the status of a mint quote
* Returns the current state (UNPAID, PAID, ISSUED)
*/
async checkMintQuote(mintId: string, quoteId: string): Promise<CashuMintQuote> {
const mintData = this.getMint(mintId);
if (!mintData) {
throw new Error('Mint not found');
}
const { wallet } = await this.getWallet(mintId);
// Check the quote status
const quote = await wallet.checkMintQuote(quoteId);
return {
quoteId: quote.quote,
invoice: quote.request,
amount: 0, // Amount not returned in check response
state: quote.state as MintQuoteState,
expiry: quote.expiry,
};
}
/**
* Mint tokens after paying the Lightning invoice
* This claims the tokens and stores them
*/
async mintTokens(mintId: string, amount: number, quoteId: string): Promise<CashuMintResult> {
const mintData = this.getMint(mintId);
if (!mintData) {
throw new Error('Mint not found');
}
const { wallet } = await this.getWallet(mintId);
// Mint the proofs
const mintedProofs = await wallet.mintProofs(amount, quoteId);
// Convert to our proof format with timestamp
const now = new Date().toISOString();
const newProofs: CashuProof[] = mintedProofs.map((p: Proof) => ({
id: p.id,
amount: p.amount,
secret: p.secret,
C: p.C,
receivedAt: now,
}));
// Merge with existing proofs
const existingProofs = mintData.proofs || [];
const allProofs = [...existingProofs, ...newProofs];
// Update storage
await this.storageService.updateCashuMintProofs(mintId, allProofs);
// Calculate minted amount
const mintedAmount = newProofs.reduce((sum, p) => sum + p.amount, 0);
return {
amount: mintedAmount,
mintId: mintId,
};
}
/**
* Get balance for a specific mint (in satoshis)
*/
getBalance(mintId: string): number {
const mintData = this.getMint(mintId);
if (!mintData) {
return 0;
}
return mintData.proofs.reduce((sum, p) => sum + p.amount, 0);
}
/**
* Get proofs for a specific mint
*/
getProofs(mintId: string): CashuProof[] {
const mintData = this.getMint(mintId);
if (!mintData) {
return [];
}
return mintData.proofs;
}
/**
* Get total balance across all mints (in satoshis)
*/
getTotalBalance(): number {
const mints = this.getMints();
return mints.reduce((sum, m) => sum + this.getBalance(m.id), 0);
}
/**
* Get cached total balance (same as getTotalBalance for Cashu since it's all local)
*/
getCachedTotalBalance(): number {
return this.getTotalBalance();
}
/**
* Format a balance for display (Cashu uses satoshis, not millisatoshis)
*/
formatBalance(sats: number | undefined): string {
if (sats === undefined) return '—';
return sats.toLocaleString('en-US');
}
}

View File

@@ -0,0 +1,71 @@
import type { Proof } from '@cashu/cashu-ts';
/**
* Result from receiving a Cashu token
*/
export interface CashuReceiveResult {
amount: number; // Amount received in satoshis
mintUrl: string; // Mint the tokens were from
mintId: string; // ID of the mint in our storage
}
/**
* Result from sending Cashu tokens
*/
export interface CashuSendResult {
token: string; // Encoded token to share (cashuB...)
amount: number; // Amount in satoshis
}
/**
* Information about a decoded Cashu token
*/
export interface DecodedCashuToken {
mint: string; // Mint URL
unit: string; // Unit (usually 'sat')
amount: number; // Total amount in the token
proofs: Proof[]; // The individual proofs
}
/**
* Mint contact info
*/
export interface MintContact {
method: string;
info: string;
}
/**
* Mint information returned when testing a connection
*/
export interface CashuMintInfo {
name?: string;
description?: string;
version?: string;
contact?: MintContact[];
nuts: Record<string, unknown>;
}
/**
* State of a mint quote
*/
export type MintQuoteState = 'UNPAID' | 'PAID' | 'ISSUED';
/**
* Result from creating a mint quote (Lightning invoice to deposit)
*/
export interface CashuMintQuote {
quoteId: string; // Quote ID for checking status and claiming
invoice: string; // Lightning invoice to pay
amount: number; // Amount in satoshis
state: MintQuoteState; // Current state of the quote
expiry?: number; // Expiry timestamp (unix seconds)
}
/**
* Result from minting tokens after paying the invoice
*/
export interface CashuMintResult {
amount: number; // Amount minted in satoshis
mintId: string; // ID of the mint
}

View File

@@ -0,0 +1,541 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NostrHelper } from '@common';
import { finalizeEvent, nip04, nip44, getPublicKey } from 'nostr-tools';
import {
NwcRequest,
NwcResponse,
NwcGetBalanceResult,
NwcGetInfoResult,
NwcPayInvoiceParams,
NwcPayInvoiceResult,
NwcMakeInvoiceParams,
NwcMakeInvoiceResult,
NwcListTransactionsParams,
NwcListTransactionsResult,
NWC_METHODS,
} from './types';
export interface NwcConnectionData {
walletPubkey: string;
relayUrl: string;
secret: string;
}
export type NwcLogLevel = 'info' | 'warn' | 'error';
export type NwcLogCallback = (level: NwcLogLevel, message: string) => void;
interface PendingRequest {
resolve: (value: NwcResponse) => void;
reject: (reason: Error) => void;
timeout: ReturnType<typeof setTimeout>;
request: NwcRequest;
isRetry: boolean;
}
type EncryptionMode = 'nip44' | 'nip04';
/**
* NWC Client for communicating with NIP-47 wallet services
*/
export class NwcClient {
private ws: WebSocket | null = null;
private connected = false;
private pendingRequests = new Map<string, PendingRequest>();
private subscriptionId: string | null = null;
private conversationKey: Uint8Array;
private clientPubkey: string;
private encryptionMode: EncryptionMode = 'nip44';
private logCallback: NwcLogCallback | null = null;
constructor(
private connectionData: NwcConnectionData,
logCallback?: NwcLogCallback
) {
this.logCallback = logCallback ?? null;
// Derive the conversation key for NIP-44 encryption
this.conversationKey = nip44.v2.utils.getConversationKey(
NostrHelper.hex2bytes(connectionData.secret),
connectionData.walletPubkey
);
// Derive our public key from the secret
this.clientPubkey = getPublicKey(
NostrHelper.hex2bytes(connectionData.secret)
);
}
private log(level: NwcLogLevel, message: string): void {
if (this.logCallback) {
this.logCallback(level, message);
}
}
/**
* Connect to the NWC relay
*/
async connect(): Promise<void> {
if (this.connected) {
return;
}
return new Promise((resolve, reject) => {
try {
this.log('info', `Connecting to ${this.connectionData.relayUrl}...`);
this.ws = new WebSocket(this.connectionData.relayUrl);
const timeout = setTimeout(() => {
this.log('error', 'Connection timeout');
reject(new Error('Connection timeout'));
this.disconnect();
}, 10000);
this.ws.onopen = () => {
clearTimeout(timeout);
this.connected = true;
this.log('info', 'Connected to relay');
this.subscribe();
resolve();
};
this.ws.onerror = () => {
clearTimeout(timeout);
this.log('error', 'WebSocket error');
reject(new Error('WebSocket error'));
};
this.ws.onclose = () => {
this.connected = false;
this.subscriptionId = null;
// Reject all pending requests
for (const [, pending] of this.pendingRequests) {
clearTimeout(pending.timeout);
pending.reject(new Error('Connection closed'));
}
this.pendingRequests.clear();
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
};
} catch (error) {
reject(error);
}
});
}
/**
* Disconnect from the relay
*/
disconnect(): void {
if (this.ws) {
if (this.subscriptionId) {
this.ws.send(JSON.stringify(['CLOSE', this.subscriptionId]));
}
this.ws.close();
this.ws = null;
}
this.connected = false;
this.subscriptionId = null;
}
/**
* Check if connected
*/
isConnected(): boolean {
return this.connected && this.ws?.readyState === WebSocket.OPEN;
}
/**
* Get wallet info
*/
async getInfo(): Promise<NwcGetInfoResult> {
const response = await this.sendRequest({
method: NWC_METHODS.GET_INFO,
});
if (response.error) {
throw new Error(response.error.message);
}
return response.result as unknown as NwcGetInfoResult;
}
/**
* Get wallet balance
*/
async getBalance(): Promise<NwcGetBalanceResult> {
const response = await this.sendRequest({
method: NWC_METHODS.GET_BALANCE,
});
if (response.error) {
throw new Error(response.error.message);
}
return response.result as unknown as NwcGetBalanceResult;
}
/**
* Pay a Lightning invoice
*/
async payInvoice(params: NwcPayInvoiceParams): Promise<NwcPayInvoiceResult> {
const response = await this.sendRequest({
method: NWC_METHODS.PAY_INVOICE,
params: params as unknown as Record<string, unknown>,
});
if (response.error) {
throw new Error(response.error.message);
}
return response.result as unknown as NwcPayInvoiceResult;
}
/**
* Create a Lightning invoice
*/
async makeInvoice(
params: NwcMakeInvoiceParams
): Promise<NwcMakeInvoiceResult> {
const response = await this.sendRequest({
method: NWC_METHODS.MAKE_INVOICE,
params: params as unknown as Record<string, unknown>,
});
if (response.error) {
throw new Error(response.error.message);
}
return response.result as unknown as NwcMakeInvoiceResult;
}
/**
* List transaction history
*/
async listTransactions(
params?: NwcListTransactionsParams
): Promise<NwcListTransactionsResult> {
const response = await this.sendRequest({
method: NWC_METHODS.LIST_TRANSACTIONS,
params: params as unknown as Record<string, unknown>,
});
if (response.error) {
throw new Error(response.error.message);
}
return response.result as unknown as NwcListTransactionsResult;
}
/**
* Encrypt content using current encryption mode
*/
private async encryptContent(plaintext: string): Promise<string> {
if (this.encryptionMode === 'nip04') {
return nip04.encrypt(
this.connectionData.secret,
this.connectionData.walletPubkey,
plaintext
);
} else {
return nip44.v2.encrypt(plaintext, this.conversationKey);
}
}
/**
* Send a request to the wallet
*/
private async sendRequest(
request: NwcRequest,
timeoutMs = 30000,
isRetry = false
): Promise<NwcResponse> {
if (!this.isConnected()) {
await this.connect();
}
// Encrypt the request content
const plaintext = JSON.stringify(request);
this.log(
'info',
`Sending ${request.method} request (using ${this.encryptionMode.toUpperCase()})`
);
const ciphertext = await this.encryptContent(plaintext);
// Create the NIP-47 request event (kind 23194)
const eventTemplate = {
kind: 23194,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', this.connectionData.walletPubkey]],
content: ciphertext,
};
// Sign with the client secret
const signedEvent = finalizeEvent(
eventTemplate,
NostrHelper.hex2bytes(this.connectionData.secret)
);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(signedEvent.id);
this.log('error', `Request timeout for ${request.method}`);
reject(new Error('Request timeout'));
}, timeoutMs);
this.pendingRequests.set(signedEvent.id, {
resolve,
reject,
timeout,
request,
isRetry,
});
// Send the event
this.ws!.send(JSON.stringify(['EVENT', signedEvent]));
});
}
/**
* Retry a request with NIP-04 encryption
*/
private async retryWithNip04(request: NwcRequest): Promise<NwcResponse> {
this.log('warn', 'Retrying with NIP-04 encryption...');
this.encryptionMode = 'nip04';
return this.sendRequest(request, 30000, true);
}
/**
* Subscribe to response events from the wallet
*/
private subscribe(): void {
if (!this.ws || !this.connected) {
return;
}
// Generate a subscription ID
this.subscriptionId = Math.random().toString(36).substring(2, 15);
// Subscribe to kind 23195 (response) events addressed to us
const filter = {
kinds: [23195],
'#p': [this.clientPubkey],
since: Math.floor(Date.now() / 1000) - 10, // Last 10 seconds
};
this.ws.send(JSON.stringify(['REQ', this.subscriptionId, filter]));
}
/**
* Handle incoming WebSocket messages
*/
private handleMessage(data: string): void {
try {
const message = JSON.parse(data);
if (!Array.isArray(message)) {
return;
}
const [type, ...rest] = message;
switch (type) {
case 'EVENT':
this.handleEvent(rest[1]);
break;
case 'OK':
// Event was received by relay
break;
case 'EOSE':
// End of stored events
break;
case 'NOTICE':
this.log('warn', `Relay notice: ${rest[0]}`);
break;
}
} catch (error) {
this.log('error', `Error parsing message: ${(error as Error).message}`);
}
}
/**
* Check if an error indicates a decryption/encryption problem
*/
private isEncryptionError(errorMsg: string): boolean {
const lowerMsg = errorMsg.toLowerCase();
return (
lowerMsg.includes('decrypt') ||
lowerMsg.includes('initialization vector') ||
lowerMsg.includes('iv') ||
lowerMsg.includes('encrypt') ||
lowerMsg.includes('cipher') ||
lowerMsg.includes('parse')
);
}
/**
* Handle an incoming event (response from wallet)
*/
private async handleEvent(event: any): Promise<void> {
if (!event || event.kind !== 23195) {
return;
}
// Check if this event is from the wallet
if (event.pubkey !== this.connectionData.walletPubkey) {
return;
}
// Find the request ID from the 'e' tag
const eTag = event.tags?.find((t: string[]) => t[0] === 'e');
if (!eTag) {
return;
}
const requestId = eTag[1];
const pending = this.pendingRequests.get(requestId);
if (!pending) {
// Response for unknown request (might be old or from another session)
return;
}
// Clear the timeout and remove from pending
clearTimeout(pending.timeout);
this.pendingRequests.delete(requestId);
try {
// Try to decrypt the response
let decrypted: string;
// First, check if content looks like plain JSON (unencrypted error)
if (
event.content.startsWith('{') ||
event.content.startsWith('"')
) {
// Might be unencrypted error response
try {
const parsed = JSON.parse(event.content);
// If it has an error field, this is an unencrypted error response
if (parsed.error) {
this.log(
'error',
`Wallet error: ${parsed.error.message || JSON.stringify(parsed.error)}`
);
// Check if it's an encryption error and we haven't retried yet
const errorMsg =
parsed.error.message || JSON.stringify(parsed.error);
if (
!pending.isRetry &&
this.encryptionMode === 'nip44' &&
this.isEncryptionError(errorMsg)
) {
this.log(
'warn',
'Wallet returned encryption error, switching to NIP-04'
);
try {
const retryResponse = await this.retryWithNip04(pending.request);
pending.resolve(retryResponse);
return;
} catch (retryError) {
pending.reject(retryError as Error);
return;
}
}
pending.resolve(parsed as NwcResponse);
return;
}
} catch {
// Not valid JSON, continue with decryption
}
}
// Detect encryption format and decrypt
// NIP-04 format contains "?iv=" in the ciphertext
if (event.content.includes('?iv=')) {
this.log('info', 'Decrypting response (NIP-04 format)');
decrypted = await nip04.decrypt(
this.connectionData.secret,
this.connectionData.walletPubkey,
event.content
);
} else {
this.log('info', 'Decrypting response (NIP-44 format)');
try {
decrypted = nip44.v2.decrypt(event.content, this.conversationKey);
} catch (nip44Error) {
// NIP-44 decryption failed, maybe it's NIP-04 without standard format?
// Try NIP-04 as fallback
this.log(
'warn',
`NIP-44 decryption failed: ${(nip44Error as Error).message}, trying NIP-04...`
);
try {
decrypted = await nip04.decrypt(
this.connectionData.secret,
this.connectionData.walletPubkey,
event.content
);
} catch {
// Both failed, throw original error
throw nip44Error;
}
}
}
const response = JSON.parse(decrypted) as NwcResponse;
// Check if the decrypted response contains an encryption error
if (response.error) {
const errorMsg = response.error.message || '';
if (
!pending.isRetry &&
this.encryptionMode === 'nip44' &&
this.isEncryptionError(errorMsg)
) {
this.log(
'warn',
`Wallet returned encryption error: ${errorMsg}, retrying with NIP-04`
);
try {
const retryResponse = await this.retryWithNip04(pending.request);
pending.resolve(retryResponse);
return;
} catch (retryError) {
pending.reject(retryError as Error);
return;
}
}
this.log('error', `Wallet error: ${errorMsg}`);
} else {
this.log('info', 'Request successful');
}
pending.resolve(response);
} catch (error) {
const errorMsg = (error as Error).message;
this.log('error', `Failed to decrypt response: ${errorMsg}`);
// If this is an encryption error and we haven't retried, try NIP-04
if (
!pending.isRetry &&
this.encryptionMode === 'nip44' &&
this.isEncryptionError(errorMsg)
) {
this.log('warn', 'Decryption failed, retrying with NIP-04 encryption');
try {
const retryResponse = await this.retryWithNip04(pending.request);
pending.resolve(retryResponse);
return;
} catch (retryError) {
pending.reject(retryError as Error);
return;
}
}
pending.reject(new Error(`Failed to decrypt response: ${errorMsg}`));
}
}
}

View File

@@ -0,0 +1,416 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { StorageService, NwcConnection_DECRYPTED } from '@common';
import { NwcClient, NwcConnectionData, NwcLogLevel, NwcLogCallback } from './nwc-client';
import {
NwcGetInfoResult,
NwcPayInvoiceResult,
NwcMakeInvoiceResult,
NwcListTransactionsParams,
NwcLookupInvoiceResult,
} from './types';
import { parseNwcUrl } from '../storage/related/nwc';
export interface NwcLogEntry {
timestamp: Date;
level: NwcLogLevel;
message: string;
}
interface CachedClient {
client: NwcClient;
connectionId: string;
}
/**
* Angular service for managing NWC wallet connections
*/
@Injectable({
providedIn: 'root',
})
export class NwcService {
private clients = new Map<string, CachedClient>();
private _logs$ = new BehaviorSubject<NwcLogEntry[]>([]);
private maxLogs = 100;
/** Observable stream of NWC log entries */
readonly logs$ = this._logs$.asObservable();
constructor(private storageService: StorageService) {}
/** Get current logs */
get logs(): NwcLogEntry[] {
return this._logs$.value;
}
/** Clear all logs */
clearLogs(): void {
this._logs$.next([]);
}
/** Add a log entry */
private addLog(level: NwcLogLevel, message: string): void {
const entry: NwcLogEntry = {
timestamp: new Date(),
level,
message,
};
const logs = [entry, ...this._logs$.value].slice(0, this.maxLogs);
this._logs$.next(logs);
}
/** Create a log callback for the NWC client */
private createLogCallback(): NwcLogCallback {
return (level: NwcLogLevel, message: string) => {
this.addLog(level, message);
};
}
/**
* Parse and validate an NWC URL
*/
parseNwcUrl(url: string): {
walletPubkey: string;
relayUrl: string;
secret: string;
lud16?: string;
} | null {
return parseNwcUrl(url);
}
/**
* Get all NWC connections from storage
*/
getConnections(): NwcConnection_DECRYPTED[] {
const sessionData =
this.storageService.getBrowserSessionHandler().browserSessionData;
return sessionData?.nwcConnections ?? [];
}
/**
* Get a single NWC connection by ID
*/
getConnection(connectionId: string): NwcConnection_DECRYPTED | undefined {
return this.getConnections().find((c) => c.id === connectionId);
}
/**
* Add a new NWC connection
*/
async addConnection(name: string, connectionUrl: string): Promise<void> {
await this.storageService.addNwcConnection({ name, connectionUrl });
}
/**
* Delete an NWC connection
*/
async deleteConnection(connectionId: string): Promise<void> {
// Disconnect and remove the client if it exists
this.disconnectClient(connectionId);
await this.storageService.deleteNwcConnection(connectionId);
}
/**
* Get a connected client for a connection, creating it if necessary
*/
private async getClient(connectionId: string): Promise<NwcClient> {
// Check if we have a cached client
const cached = this.clients.get(connectionId);
if (cached && cached.client.isConnected()) {
return cached.client;
}
// Get the connection data
const connection = this.getConnection(connectionId);
if (!connection) {
throw new Error('Connection not found');
}
// Create a new client
const connectionData: NwcConnectionData = {
walletPubkey: connection.walletPubkey,
relayUrl: connection.relayUrl,
secret: connection.secret,
};
const client = new NwcClient(connectionData, this.createLogCallback());
await client.connect();
// Cache the client
this.clients.set(connectionId, {
client,
connectionId,
});
return client;
}
/**
* Disconnect a client
*/
private disconnectClient(connectionId: string): void {
const cached = this.clients.get(connectionId);
if (cached) {
cached.client.disconnect();
this.clients.delete(connectionId);
}
}
/**
* Disconnect all clients
*/
disconnectAll(): void {
for (const cached of this.clients.values()) {
cached.client.disconnect();
}
this.clients.clear();
}
/**
* Get wallet info for a connection
*/
async getInfo(connectionId: string): Promise<NwcGetInfoResult> {
const client = await this.getClient(connectionId);
return client.getInfo();
}
/**
* Get balance for a connection (in millisatoshis)
*/
async getBalance(connectionId: string): Promise<number> {
const client = await this.getClient(connectionId);
const result = await client.getBalance();
// Update the cached balance in storage
await this.storageService.updateNwcConnectionBalance(
connectionId,
result.balance
);
return result.balance;
}
/**
* Get balances for all connections
* Returns a map of connectionId -> balance in millisatoshis
*/
async getAllBalances(): Promise<Map<string, number>> {
const balances = new Map<string, number>();
const connections = this.getConnections();
const results = await Promise.allSettled(
connections.map(async (conn) => {
try {
const balance = await this.getBalance(conn.id);
return { id: conn.id, balance };
} catch (error) {
// Return cached balance if available
if (conn.cachedBalance !== undefined) {
return { id: conn.id, balance: conn.cachedBalance };
}
throw error;
}
})
);
for (const result of results) {
if (result.status === 'fulfilled') {
balances.set(result.value.id, result.value.balance);
}
}
return balances;
}
/**
* Get total balance across all connections (in millisatoshis)
*/
async getTotalBalance(): Promise<number> {
const balances = await this.getAllBalances();
let total = 0;
for (const balance of balances.values()) {
total += balance;
}
return total;
}
/**
* Get cached total balance (without making network requests)
*/
getCachedTotalBalance(): number {
const connections = this.getConnections();
let total = 0;
for (const conn of connections) {
if (conn.cachedBalance !== undefined) {
total += conn.cachedBalance;
}
}
return total;
}
/**
* Pay a Lightning invoice
*/
async payInvoice(
connectionId: string,
invoice: string,
amountMsat?: number
): Promise<NwcPayInvoiceResult> {
const client = await this.getClient(connectionId);
const result = await client.payInvoice({
invoice,
amount: amountMsat,
});
// Refresh balance after payment
try {
await this.getBalance(connectionId);
} catch {
// Ignore balance refresh errors
}
return result;
}
/**
* Create a Lightning invoice
*/
async makeInvoice(
connectionId: string,
amountMsat: number,
description?: string
): Promise<NwcMakeInvoiceResult> {
const client = await this.getClient(connectionId);
return client.makeInvoice({
amount: amountMsat,
description,
});
}
/**
* List transaction history
*/
async listTransactions(
connectionId: string,
params?: NwcListTransactionsParams
): Promise<NwcLookupInvoiceResult[]> {
const client = await this.getClient(connectionId);
const result = await client.listTransactions(params);
return result.transactions;
}
/**
* Resolve a Lightning Address (user@domain.com) to a bolt11 invoice
* Uses LNURL-pay protocol
*/
async resolveLightningAddress(
address: string,
amountMsat: number
): Promise<string> {
// Parse lightning address
const match = address.match(/^([^@]+)@([^@]+)$/);
if (!match) {
throw new Error('Invalid lightning address format');
}
const [, name, domain] = match;
// Fetch LNURL-pay endpoint
const lnurlpUrl = `https://${domain}/.well-known/lnurlp/${name}`;
this.addLog('info', `Fetching LNURL-pay from ${domain}...`);
const response = await fetch(lnurlpUrl);
if (!response.ok) {
throw new Error(`Failed to fetch LNURL-pay: ${response.status}`);
}
const lnurlpData = await response.json();
// Validate response
if (lnurlpData.status === 'ERROR') {
throw new Error(lnurlpData.reason || 'LNURL-pay error');
}
if (!lnurlpData.callback) {
throw new Error('Invalid LNURL-pay response: missing callback');
}
// Check amount bounds
const minSendable = lnurlpData.minSendable || 1000;
const maxSendable = lnurlpData.maxSendable || 100000000000;
if (amountMsat < minSendable) {
throw new Error(
`Amount too small. Minimum: ${Math.ceil(minSendable / 1000)} sats`
);
}
if (amountMsat > maxSendable) {
throw new Error(
`Amount too large. Maximum: ${Math.floor(maxSendable / 1000)} sats`
);
}
// Request invoice from callback
const callbackUrl = new URL(lnurlpData.callback);
callbackUrl.searchParams.set('amount', amountMsat.toString());
this.addLog('info', 'Requesting invoice...');
const invoiceResponse = await fetch(callbackUrl.toString());
if (!invoiceResponse.ok) {
throw new Error(`Failed to get invoice: ${invoiceResponse.status}`);
}
const invoiceData = await invoiceResponse.json();
if (invoiceData.status === 'ERROR') {
throw new Error(invoiceData.reason || 'Failed to get invoice');
}
if (!invoiceData.pr) {
throw new Error('Invalid invoice response: missing payment request');
}
this.addLog('info', 'Invoice received');
return invoiceData.pr;
}
/**
* Check if a string is a lightning address (user@domain)
*/
isLightningAddress(input: string): boolean {
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(input);
}
/**
* Check if a string is a bolt11 invoice
*/
isBolt11Invoice(input: string): boolean {
return /^ln(bc|tb|tbs)[0-9a-z]+$/i.test(input.toLowerCase());
}
/**
* Test a connection by getting wallet info
*/
async testConnection(connectionUrl: string): Promise<NwcGetInfoResult> {
this.addLog('info', 'Testing NWC connection...');
const parsed = this.parseNwcUrl(connectionUrl);
if (!parsed) {
this.addLog('error', 'Invalid NWC URL');
throw new Error('Invalid NWC URL');
}
const client = new NwcClient(parsed, this.createLogCallback());
try {
await client.connect();
const info = await client.getInfo();
this.addLog('info', `Connection test successful: ${info.alias || 'wallet'}`);
return info;
} catch (error) {
this.addLog('error', `Connection test failed: ${(error as Error).message}`);
throw error;
} finally {
client.disconnect();
}
}
}

View File

@@ -0,0 +1,130 @@
/**
* NIP-47 NWC Protocol Types
*/
export interface NwcRequest {
method: string;
params?: Record<string, unknown>;
}
export interface NwcResponse {
result_type: string;
error?: {
code: string;
message: string;
};
result?: Record<string, unknown>;
}
export interface NwcGetInfoResult {
alias?: string;
color?: string;
pubkey?: string;
network?: string;
block_height?: number;
block_hash?: string;
methods?: string[];
}
export interface NwcGetBalanceResult {
balance: number; // Balance in millisatoshis
}
export interface NwcPayInvoiceParams {
invoice: string;
amount?: number; // Optional amount in millisatoshis (for zero-amount invoices)
}
export interface NwcPayInvoiceResult {
preimage: string;
}
export interface NwcMakeInvoiceParams {
amount: number; // Amount in millisatoshis
description?: string;
description_hash?: string;
expiry?: number; // Expiry in seconds
}
export interface NwcMakeInvoiceResult {
type: 'incoming';
invoice: string;
description?: string;
description_hash?: string;
preimage?: string;
payment_hash: string;
amount: number;
fees_paid?: number;
created_at: number;
expires_at: number;
settled_at?: number;
metadata?: Record<string, unknown>;
}
export interface NwcLookupInvoiceParams {
payment_hash?: string;
invoice?: string;
}
export interface NwcLookupInvoiceResult {
type: 'incoming' | 'outgoing';
invoice?: string;
description?: string;
description_hash?: string;
preimage?: string;
payment_hash: string;
amount: number;
fees_paid?: number;
created_at: number;
expires_at?: number;
settled_at?: number;
metadata?: Record<string, unknown>;
}
export interface NwcListTransactionsParams {
from?: number;
until?: number;
limit?: number;
offset?: number;
unpaid?: boolean;
type?: 'incoming' | 'outgoing';
}
export interface NwcListTransactionsResult {
transactions: NwcLookupInvoiceResult[];
}
/**
* NWC Error Codes
*/
export const NWC_ERROR_CODES = {
RATE_LIMITED: 'RATE_LIMITED',
NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE',
QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
RESTRICTED: 'RESTRICTED',
UNAUTHORIZED: 'UNAUTHORIZED',
INTERNAL: 'INTERNAL',
OTHER: 'OTHER',
PAYMENT_FAILED: 'PAYMENT_FAILED',
NOT_FOUND: 'NOT_FOUND',
} as const;
export type NwcErrorCode = (typeof NWC_ERROR_CODES)[keyof typeof NWC_ERROR_CODES];
/**
* NWC Method names (from NIP-47)
*/
export const NWC_METHODS = {
GET_INFO: 'get_info',
GET_BALANCE: 'get_balance',
PAY_INVOICE: 'pay_invoice',
MAKE_INVOICE: 'make_invoice',
LOOKUP_INVOICE: 'lookup_invoice',
LIST_TRANSACTIONS: 'list_transactions',
PAY_KEYSEND: 'pay_keysend',
MULTI_PAY_INVOICE: 'multi_pay_invoice',
MULTI_PAY_KEYSEND: 'multi_pay_keysend',
} as const;
export type NwcMethod = (typeof NWC_METHODS)[keyof typeof NWC_METHODS];

View File

@@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
CashuMint_ENCRYPTED,
Identity_ENCRYPTED,
NwcConnection_ENCRYPTED,
Permission_ENCRYPTED,
Relay_ENCRYPTED,
} from './types';
@@ -104,6 +106,38 @@ export abstract class BrowserSyncHandler {
this.#browserSyncData.relays = Array.from(data.relays);
}
/**
* Persist the NWC connections to the sync data storage.
*
* ATTENTION: In your implementation, make sure to call "setPartialData_NwcConnections(..)" at the end to update the in-memory data.
*/
abstract saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
}): Promise<void>;
setPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
}) {
if (!this.#browserSyncData) {
return;
}
this.#browserSyncData.nwcConnections = Array.from(data.nwcConnections);
}
/**
* Persist the Cashu mints to the sync data storage.
*
* ATTENTION: In your implementation, make sure to call "setPartialData_CashuMints(..)" at the end to update the in-memory data.
*/
abstract saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[];
}): Promise<void>;
setPartialData_CashuMints(data: { cashuMints: CashuMint_ENCRYPTED[] }) {
if (!this.#browserSyncData) {
return;
}
this.#browserSyncData.cashuMints = Array.from(data.cashuMints);
}
/**
* Clear all data from the sync data storage.
*/

View File

@@ -0,0 +1,361 @@
import {
CryptoHelper,
CashuMint_DECRYPTED,
CashuMint_ENCRYPTED,
CashuProof,
StorageService,
} from '@common';
import { LockedVaultContext } from './identity';
/**
* Validate a Cashu mint URL
*/
export function isValidMintUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch {
return false;
}
}
export const addCashuMint = async function (
this: StorageService,
data: {
name: string;
mintUrl: string;
unit?: string;
}
): Promise<CashuMint_DECRYPTED> {
this.assureIsInitialized();
// Validate the mint URL
if (!isValidMintUrl(data.mintUrl)) {
throw new Error('Invalid mint URL format');
}
// Normalize URL (remove trailing slash)
const normalizedUrl = data.mintUrl.replace(/\/$/, '');
// Check if a mint with the same URL already exists
const existingMint = (
this.getBrowserSessionHandler().browserSessionData?.cashuMints ?? []
).find((x) => x.mintUrl === normalizedUrl);
if (existingMint) {
throw new Error(
`A connection to this mint already exists: ${existingMint.name}`
);
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData) {
throw new Error('Browser session data is undefined.');
}
const decryptedMint: CashuMint_DECRYPTED = {
id: CryptoHelper.v4(),
name: data.name,
mintUrl: normalizedUrl,
unit: data.unit ?? 'sat',
createdAt: new Date().toISOString(),
proofs: [], // Start with no proofs
cachedBalance: 0,
cachedBalanceAt: new Date().toISOString(),
};
// Initialize array if needed
if (!browserSessionData.cashuMints) {
browserSessionData.cashuMints = [];
}
// Add the new mint to the session data
browserSessionData.cashuMints.push(decryptedMint);
this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Encrypt the new mint and add it to the sync data
const encryptedMint = await encryptCashuMint.call(this, decryptedMint);
const encryptedMints = [
...(this.getBrowserSyncHandler().browserSyncData?.cashuMints ?? []),
encryptedMint,
];
await this.getBrowserSyncHandler().saveAndSetPartialData_CashuMints({
cashuMints: encryptedMints,
});
return decryptedMint;
};
export const deleteCashuMint = async function (
this: StorageService,
mintId: string
): Promise<void> {
this.assureIsInitialized();
if (!mintId) {
return;
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
// Remove from session data
browserSessionData.cashuMints = (browserSessionData.cashuMints ?? []).filter(
(x) => x.id !== mintId
);
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Handle Sync data
const encryptedMintId = await this.encrypt(mintId);
await this.getBrowserSyncHandler().saveAndSetPartialData_CashuMints({
cashuMints: (browserSyncData.cashuMints ?? []).filter(
(x) => x.id !== encryptedMintId
),
});
};
/**
* Update the proofs for a Cashu mint
* This is called after send/receive operations
*/
export const updateCashuMintProofs = async function (
this: StorageService,
mintId: string,
proofs: CashuProof[]
): Promise<void> {
this.assureIsInitialized();
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
const sessionMint = (browserSessionData.cashuMints ?? []).find(
(x) => x.id === mintId
);
const encryptedMintId = await this.encrypt(mintId);
const syncMint = (browserSyncData.cashuMints ?? []).find(
(x) => x.id === encryptedMintId
);
if (!sessionMint || !syncMint) {
throw new Error('Cashu mint not found for proofs update.');
}
const now = new Date().toISOString();
// Calculate balance from proofs (sum of all proof amounts in satoshis)
const balance = proofs.reduce((sum, p) => sum + p.amount, 0);
// Update session data
sessionMint.proofs = proofs;
sessionMint.cachedBalance = balance;
sessionMint.cachedBalanceAt = now;
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Update sync data
syncMint.proofs = await this.encrypt(JSON.stringify(proofs));
syncMint.cachedBalance = await this.encrypt(balance.toString());
syncMint.cachedBalanceAt = await this.encrypt(now);
await this.getBrowserSyncHandler().saveAndSetPartialData_CashuMints({
cashuMints: browserSyncData.cashuMints ?? [],
});
};
export const encryptCashuMint = async function (
this: StorageService,
mint: CashuMint_DECRYPTED
): Promise<CashuMint_ENCRYPTED> {
const encrypted: CashuMint_ENCRYPTED = {
id: await this.encrypt(mint.id),
name: await this.encrypt(mint.name),
mintUrl: await this.encrypt(mint.mintUrl),
unit: await this.encrypt(mint.unit),
createdAt: await this.encrypt(mint.createdAt),
proofs: await this.encrypt(JSON.stringify(mint.proofs)),
};
if (mint.cachedBalance !== undefined) {
encrypted.cachedBalance = await this.encrypt(mint.cachedBalance.toString());
}
if (mint.cachedBalanceAt) {
encrypted.cachedBalanceAt = await this.encrypt(mint.cachedBalanceAt);
}
return encrypted;
};
export const decryptCashuMint = async function (
this: StorageService,
mint: CashuMint_ENCRYPTED,
withLockedVault: LockedVaultContext | undefined = undefined
): Promise<CashuMint_DECRYPTED> {
if (typeof withLockedVault === 'undefined') {
// Normal decryption with unlocked vault
const proofsJson = await this.decrypt(mint.proofs, 'string');
const decrypted: CashuMint_DECRYPTED = {
id: await this.decrypt(mint.id, 'string'),
name: await this.decrypt(mint.name, 'string'),
mintUrl: await this.decrypt(mint.mintUrl, 'string'),
unit: await this.decrypt(mint.unit, 'string'),
createdAt: await this.decrypt(mint.createdAt, 'string'),
proofs: JSON.parse(proofsJson) as CashuProof[],
};
if (mint.cachedBalance) {
decrypted.cachedBalance = await this.decrypt(mint.cachedBalance, 'number');
}
if (mint.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decrypt(
mint.cachedBalanceAt,
'string'
);
}
return decrypted;
}
// v2: Use pre-derived key
if (withLockedVault.keyBase64) {
const proofsJson = await this.decryptWithLockedVaultV2(
mint.proofs,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
);
const decrypted: CashuMint_DECRYPTED = {
id: await this.decryptWithLockedVaultV2(
mint.id,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
name: await this.decryptWithLockedVaultV2(
mint.name,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
mintUrl: await this.decryptWithLockedVaultV2(
mint.mintUrl,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
unit: await this.decryptWithLockedVaultV2(
mint.unit,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
createdAt: await this.decryptWithLockedVaultV2(
mint.createdAt,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
proofs: JSON.parse(proofsJson) as CashuProof[],
};
if (mint.cachedBalance) {
decrypted.cachedBalance = await this.decryptWithLockedVaultV2(
mint.cachedBalance,
'number',
withLockedVault.iv,
withLockedVault.keyBase64
);
}
if (mint.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decryptWithLockedVaultV2(
mint.cachedBalanceAt,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
);
}
return decrypted;
}
// v1: Use password (PBKDF2)
const proofsJson = await this.decryptWithLockedVault(
mint.proofs,
'string',
withLockedVault.iv,
withLockedVault.password!
);
const decrypted: CashuMint_DECRYPTED = {
id: await this.decryptWithLockedVault(
mint.id,
'string',
withLockedVault.iv,
withLockedVault.password!
),
name: await this.decryptWithLockedVault(
mint.name,
'string',
withLockedVault.iv,
withLockedVault.password!
),
mintUrl: await this.decryptWithLockedVault(
mint.mintUrl,
'string',
withLockedVault.iv,
withLockedVault.password!
),
unit: await this.decryptWithLockedVault(
mint.unit,
'string',
withLockedVault.iv,
withLockedVault.password!
),
createdAt: await this.decryptWithLockedVault(
mint.createdAt,
'string',
withLockedVault.iv,
withLockedVault.password!
),
proofs: JSON.parse(proofsJson) as CashuProof[],
};
if (mint.cachedBalance) {
decrypted.cachedBalance = await this.decryptWithLockedVault(
mint.cachedBalance,
'number',
withLockedVault.iv,
withLockedVault.password!
);
}
if (mint.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decryptWithLockedVault(
mint.cachedBalanceAt,
'string',
withLockedVault.iv,
withLockedVault.password!
);
}
return decrypted;
};
export const decryptCashuMints = async function (
this: StorageService,
mints: CashuMint_ENCRYPTED[],
withLockedVault: LockedVaultContext | undefined = undefined
): Promise<CashuMint_DECRYPTED[]> {
const decryptedMints: CashuMint_DECRYPTED[] = [];
for (const mint of mints) {
const decryptedMint = await decryptCashuMint.call(
this,
mint,
withLockedVault
);
decryptedMints.push(decryptedMint);
}
return decryptedMints;
};

View File

@@ -0,0 +1,419 @@
import {
CryptoHelper,
NwcConnection_DECRYPTED,
NwcConnection_ENCRYPTED,
StorageService,
} from '@common';
import { LockedVaultContext } from './identity';
/**
* Parse a nostr+walletconnect:// URL into its components
*/
export function parseNwcUrl(url: string): {
walletPubkey: string;
relayUrl: string;
secret: string;
lud16?: string;
} | null {
try {
// Format: nostr+walletconnect://<pubkey>?relay=<url>&secret=<hex>&lud16=<optional>
const match = url.match(/^nostr\+walletconnect:\/\/([a-f0-9]{64})\?(.+)$/i);
if (!match) {
return null;
}
const walletPubkey = match[1].toLowerCase();
const params = new URLSearchParams(match[2]);
const relayUrl = params.get('relay');
const secret = params.get('secret');
const lud16 = params.get('lud16') || undefined;
if (!relayUrl || !secret) {
return null;
}
// Validate secret is 64-char hex
if (!/^[a-f0-9]{64}$/i.test(secret)) {
return null;
}
return {
walletPubkey,
relayUrl: decodeURIComponent(relayUrl),
secret: secret.toLowerCase(),
lud16,
};
} catch {
return null;
}
}
export const addNwcConnection = async function (
this: StorageService,
data: {
name: string;
connectionUrl: string;
}
): Promise<void> {
this.assureIsInitialized();
// Parse the NWC URL
const parsed = parseNwcUrl(data.connectionUrl);
if (!parsed) {
throw new Error('Invalid NWC URL format');
}
// Check if a connection with the same wallet pubkey already exists
const existingConnection = (
this.getBrowserSessionHandler().browserSessionData?.nwcConnections ?? []
).find((x) => x.walletPubkey === parsed.walletPubkey);
if (existingConnection) {
throw new Error(
`A connection to this wallet already exists: ${existingConnection.name}`
);
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData) {
throw new Error('Browser session data is undefined.');
}
const decryptedConnection: NwcConnection_DECRYPTED = {
id: CryptoHelper.v4(),
name: data.name,
connectionUrl: data.connectionUrl,
walletPubkey: parsed.walletPubkey,
relayUrl: parsed.relayUrl,
secret: parsed.secret,
lud16: parsed.lud16,
createdAt: new Date().toISOString(),
};
// Initialize array if needed
if (!browserSessionData.nwcConnections) {
browserSessionData.nwcConnections = [];
}
// Add the new connection to the session data
browserSessionData.nwcConnections.push(decryptedConnection);
this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Encrypt the new connection and add it to the sync data
const encryptedConnection = await encryptNwcConnection.call(
this,
decryptedConnection
);
const encryptedConnections = [
...(this.getBrowserSyncHandler().browserSyncData?.nwcConnections ?? []),
encryptedConnection,
];
await this.getBrowserSyncHandler().saveAndSetPartialData_NwcConnections({
nwcConnections: encryptedConnections,
});
};
export const deleteNwcConnection = async function (
this: StorageService,
connectionId: string
): Promise<void> {
this.assureIsInitialized();
if (!connectionId) {
return;
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
// Remove from session data
browserSessionData.nwcConnections = (
browserSessionData.nwcConnections ?? []
).filter((x) => x.id !== connectionId);
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Handle Sync data
const encryptedConnectionId = await this.encrypt(connectionId);
await this.getBrowserSyncHandler().saveAndSetPartialData_NwcConnections({
nwcConnections: (browserSyncData.nwcConnections ?? []).filter(
(x) => x.id !== encryptedConnectionId
),
});
};
export const updateNwcConnectionBalance = async function (
this: StorageService,
connectionId: string,
balanceMillisats: number
): Promise<void> {
this.assureIsInitialized();
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
const sessionConnection = (browserSessionData.nwcConnections ?? []).find(
(x) => x.id === connectionId
);
const encryptedConnectionId = await this.encrypt(connectionId);
const syncConnection = (browserSyncData.nwcConnections ?? []).find(
(x) => x.id === encryptedConnectionId
);
if (!sessionConnection || !syncConnection) {
throw new Error('NWC connection not found for balance update.');
}
const now = new Date().toISOString();
// Update session data
sessionConnection.cachedBalance = balanceMillisats;
sessionConnection.cachedBalanceAt = now;
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Update sync data
syncConnection.cachedBalance = await this.encrypt(balanceMillisats.toString());
syncConnection.cachedBalanceAt = await this.encrypt(now);
await this.getBrowserSyncHandler().saveAndSetPartialData_NwcConnections({
nwcConnections: browserSyncData.nwcConnections ?? [],
});
};
export const encryptNwcConnection = async function (
this: StorageService,
connection: NwcConnection_DECRYPTED
): Promise<NwcConnection_ENCRYPTED> {
const encrypted: NwcConnection_ENCRYPTED = {
id: await this.encrypt(connection.id),
name: await this.encrypt(connection.name),
connectionUrl: await this.encrypt(connection.connectionUrl),
walletPubkey: await this.encrypt(connection.walletPubkey),
relayUrl: await this.encrypt(connection.relayUrl),
secret: await this.encrypt(connection.secret),
createdAt: await this.encrypt(connection.createdAt),
};
if (connection.lud16) {
encrypted.lud16 = await this.encrypt(connection.lud16);
}
if (connection.cachedBalance !== undefined) {
encrypted.cachedBalance = await this.encrypt(
connection.cachedBalance.toString()
);
}
if (connection.cachedBalanceAt) {
encrypted.cachedBalanceAt = await this.encrypt(connection.cachedBalanceAt);
}
return encrypted;
};
export const decryptNwcConnection = async function (
this: StorageService,
connection: NwcConnection_ENCRYPTED,
withLockedVault: LockedVaultContext | undefined = undefined
): Promise<NwcConnection_DECRYPTED> {
if (typeof withLockedVault === 'undefined') {
// Normal decryption with unlocked vault
const decrypted: NwcConnection_DECRYPTED = {
id: await this.decrypt(connection.id, 'string'),
name: await this.decrypt(connection.name, 'string'),
connectionUrl: await this.decrypt(connection.connectionUrl, 'string'),
walletPubkey: await this.decrypt(connection.walletPubkey, 'string'),
relayUrl: await this.decrypt(connection.relayUrl, 'string'),
secret: await this.decrypt(connection.secret, 'string'),
createdAt: await this.decrypt(connection.createdAt, 'string'),
};
if (connection.lud16) {
decrypted.lud16 = await this.decrypt(connection.lud16, 'string');
}
if (connection.cachedBalance) {
decrypted.cachedBalance = await this.decrypt(
connection.cachedBalance,
'number'
);
}
if (connection.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decrypt(
connection.cachedBalanceAt,
'string'
);
}
return decrypted;
}
// v2: Use pre-derived key
if (withLockedVault.keyBase64) {
const decrypted: NwcConnection_DECRYPTED = {
id: await this.decryptWithLockedVaultV2(
connection.id,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
name: await this.decryptWithLockedVaultV2(
connection.name,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
connectionUrl: await this.decryptWithLockedVaultV2(
connection.connectionUrl,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
walletPubkey: await this.decryptWithLockedVaultV2(
connection.walletPubkey,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
relayUrl: await this.decryptWithLockedVaultV2(
connection.relayUrl,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
secret: await this.decryptWithLockedVaultV2(
connection.secret,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
createdAt: await this.decryptWithLockedVaultV2(
connection.createdAt,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
};
if (connection.lud16) {
decrypted.lud16 = await this.decryptWithLockedVaultV2(
connection.lud16,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
);
}
if (connection.cachedBalance) {
decrypted.cachedBalance = await this.decryptWithLockedVaultV2(
connection.cachedBalance,
'number',
withLockedVault.iv,
withLockedVault.keyBase64
);
}
if (connection.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decryptWithLockedVaultV2(
connection.cachedBalanceAt,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
);
}
return decrypted;
}
// v1: Use password (PBKDF2)
const decrypted: NwcConnection_DECRYPTED = {
id: await this.decryptWithLockedVault(
connection.id,
'string',
withLockedVault.iv,
withLockedVault.password!
),
name: await this.decryptWithLockedVault(
connection.name,
'string',
withLockedVault.iv,
withLockedVault.password!
),
connectionUrl: await this.decryptWithLockedVault(
connection.connectionUrl,
'string',
withLockedVault.iv,
withLockedVault.password!
),
walletPubkey: await this.decryptWithLockedVault(
connection.walletPubkey,
'string',
withLockedVault.iv,
withLockedVault.password!
),
relayUrl: await this.decryptWithLockedVault(
connection.relayUrl,
'string',
withLockedVault.iv,
withLockedVault.password!
),
secret: await this.decryptWithLockedVault(
connection.secret,
'string',
withLockedVault.iv,
withLockedVault.password!
),
createdAt: await this.decryptWithLockedVault(
connection.createdAt,
'string',
withLockedVault.iv,
withLockedVault.password!
),
};
if (connection.lud16) {
decrypted.lud16 = await this.decryptWithLockedVault(
connection.lud16,
'string',
withLockedVault.iv,
withLockedVault.password!
);
}
if (connection.cachedBalance) {
decrypted.cachedBalance = await this.decryptWithLockedVault(
connection.cachedBalance,
'number',
withLockedVault.iv,
withLockedVault.password!
);
}
if (connection.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decryptWithLockedVault(
connection.cachedBalanceAt,
'string',
withLockedVault.iv,
withLockedVault.password!
);
}
return decrypted;
};
export const decryptNwcConnections = async function (
this: StorageService,
connections: NwcConnection_ENCRYPTED[],
withLockedVault: LockedVaultContext | undefined = undefined
): Promise<NwcConnection_DECRYPTED[]> {
const decryptedConnections: NwcConnection_DECRYPTED[] = [];
for (const connection of connections) {
const decryptedConnection = await decryptNwcConnection.call(
this,
connection,
withLockedVault
);
decryptedConnections.push(decryptedConnection);
}
return decryptedConnections;
};

View File

@@ -8,7 +8,9 @@ import {
deriveKeyArgon2,
} from '@common';
import { Buffer } from 'buffer';
import { decryptCashuMints, encryptCashuMint } from './cashu';
import { decryptIdentities, encryptIdentity, LockedVaultContext } from './identity';
import { decryptNwcConnections, encryptNwcConnection } from './nwc';
import { decryptPermissions } from './permission';
import { decryptRelays, encryptRelay } from './relay';
@@ -34,6 +36,8 @@ export const createNewVault = async function (
identities: [],
permissions: [],
relays: [],
nwcConnections: [],
cashuMints: [],
selectedIdentityId: null,
};
await this.getBrowserSessionHandler().saveFullData(sessionData);
@@ -47,6 +51,8 @@ export const createNewVault = async function (
identities: [],
permissions: [],
relays: [],
nwcConnections: [],
cashuMints: [],
selectedIdentityId: null,
};
await this.getBrowserSyncHandler().saveAndSetFullData(syncData);
@@ -133,6 +139,22 @@ export const unlockVault = async function (
);
console.log('[vault] Decrypted', decryptedRelays.length, 'relays');
console.log('[vault] Decrypting NWC connections...');
const decryptedNwcConnections = await decryptNwcConnections.call(
this,
browserSyncData.nwcConnections ?? [],
withLockedVault
);
console.log('[vault] Decrypted', decryptedNwcConnections.length, 'NWC connections');
console.log('[vault] Decrypting Cashu mints...');
const decryptedCashuMints = await decryptCashuMints.call(
this,
browserSyncData.cashuMints ?? [],
withLockedVault
);
console.log('[vault] Decrypted', decryptedCashuMints.length, 'Cashu mints');
console.log('[vault] Decrypting selectedIdentityId...');
let decryptedSelectedIdentityId: string | null = null;
if (browserSyncData.selectedIdentityId !== null) {
@@ -163,6 +185,8 @@ export const unlockVault = async function (
identities: decryptedIdentities,
selectedIdentityId: decryptedSelectedIdentityId,
relays: decryptedRelays,
nwcConnections: decryptedNwcConnections,
cashuMints: decryptedCashuMints,
};
console.log('[vault] Saving session data...');
@@ -234,6 +258,20 @@ async function migrateVaultV1ToV2(
encryptedPermissions.push(encryptedPermission);
}
// Re-encrypt NWC connections
const encryptedNwcConnections = [];
for (const nwcConnection of browserSessionData.nwcConnections ?? []) {
const encrypted = await encryptNwcConnection.call(this, nwcConnection);
encryptedNwcConnections.push(encrypted);
}
// Re-encrypt Cashu mints
const encryptedCashuMints = [];
for (const cashuMint of browserSessionData.cashuMints ?? []) {
const encrypted = await encryptCashuMint.call(this, cashuMint);
encryptedCashuMints.push(encrypted);
}
const encryptedSelectedIdentityId = browserSessionData.selectedIdentityId
? await this.encrypt(browserSessionData.selectedIdentityId)
: null;
@@ -247,6 +285,8 @@ async function migrateVaultV1ToV2(
identities: encryptedIdentities,
permissions: encryptedPermissions,
relays: encryptedRelays,
nwcConnections: encryptedNwcConnections,
cashuMints: encryptedCashuMints,
selectedIdentityId: encryptedSelectedIdentityId,
};

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Bookmark, BrowserSyncFlow, SignerMetaData } from './types';
import { Bookmark, BrowserSyncData, BrowserSyncFlow, SignerMetaData, SignerMetaData_VaultSnapshot } from './types';
import { v4 as uuidv4 } from 'uuid';
export abstract class SignerMetaHandler {
get signerMetaData(): SignerMetaData | undefined {
@@ -8,7 +9,8 @@ export abstract class SignerMetaHandler {
#signerMetaData?: SignerMetaData;
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'recklessMode', 'whitelistedHosts', 'bookmarks'];
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'maxBackups', 'recklessMode', 'whitelistedHosts', 'bookmarks', 'devMode'];
readonly DEFAULT_MAX_BACKUPS = 5;
/**
* Load the full data from the storage. If the storage is used for storing
* other data (e.g. browser sync data when the user decided to NOT sync),
@@ -56,6 +58,21 @@ export abstract class SignerMetaHandler {
await this.saveFullData(this.#signerMetaData);
}
/**
* Sets dev mode and immediately saves it.
*/
async setDevMode(enabled: boolean): Promise<void> {
if (!this.#signerMetaData) {
this.#signerMetaData = {
devMode: enabled,
};
} else {
this.#signerMetaData.devMode = enabled;
}
await this.saveFullData(this.#signerMetaData);
}
/**
* Adds a host to the whitelist and immediately saves it.
*/
@@ -111,4 +128,120 @@ export abstract class SignerMetaHandler {
getBookmarks(): Bookmark[] {
return this.#signerMetaData?.bookmarks ?? [];
}
/**
* Gets the maximum number of backups to keep.
*/
getMaxBackups(): number {
return this.#signerMetaData?.maxBackups ?? this.DEFAULT_MAX_BACKUPS;
}
/**
* Sets the maximum number of backups to keep and immediately saves it.
*/
async setMaxBackups(count: number): Promise<void> {
const clampedCount = Math.max(1, Math.min(20, count)); // Clamp between 1-20
if (!this.#signerMetaData) {
this.#signerMetaData = {
maxBackups: clampedCount,
};
} else {
this.#signerMetaData.maxBackups = clampedCount;
}
await this.saveFullData(this.#signerMetaData);
}
/**
* Gets all vault backups, sorted newest first.
*/
getBackups(): SignerMetaData_VaultSnapshot[] {
const backups = this.#signerMetaData?.vaultSnapshots ?? [];
return [...backups].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
/**
* Gets a specific backup by ID.
*/
getBackupById(id: string): SignerMetaData_VaultSnapshot | undefined {
return this.#signerMetaData?.vaultSnapshots?.find(b => b.id === id);
}
/**
* Creates a new backup of the vault data.
* Automatically removes old backups if exceeding maxBackups.
*/
async createBackup(
browserSyncData: BrowserSyncData,
reason: 'manual' | 'auto' | 'pre-restore' = 'manual'
): Promise<SignerMetaData_VaultSnapshot> {
const now = new Date();
const dateTimeString = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
const identityCount = browserSyncData.identities?.length ?? 0;
const snapshot: SignerMetaData_VaultSnapshot = {
id: uuidv4(),
fileName: `Vault Backup - ${dateTimeString}`,
createdAt: now.toISOString(),
data: JSON.parse(JSON.stringify(browserSyncData)), // Deep clone
identityCount,
reason,
};
if (!this.#signerMetaData) {
this.#signerMetaData = {
vaultSnapshots: [snapshot],
};
} else {
const existingBackups = this.#signerMetaData.vaultSnapshots ?? [];
existingBackups.push(snapshot);
// Enforce max backups limit (only for auto backups, keep manual and pre-restore)
const maxBackups = this.getMaxBackups();
const autoBackups = existingBackups.filter(b => b.reason === 'auto');
const otherBackups = existingBackups.filter(b => b.reason !== 'auto');
// Sort auto backups by date (newest first) and keep only maxBackups
autoBackups.sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
const trimmedAutoBackups = autoBackups.slice(0, maxBackups);
this.#signerMetaData.vaultSnapshots = [...otherBackups, ...trimmedAutoBackups];
}
await this.saveFullData(this.#signerMetaData);
return snapshot;
}
/**
* Deletes a backup by ID.
*/
async deleteBackup(backupId: string): Promise<boolean> {
if (!this.#signerMetaData?.vaultSnapshots) {
return false;
}
const initialLength = this.#signerMetaData.vaultSnapshots.length;
this.#signerMetaData.vaultSnapshots = this.#signerMetaData.vaultSnapshots.filter(
b => b.id !== backupId
);
if (this.#signerMetaData.vaultSnapshots.length < initialLength) {
await this.saveFullData(this.#signerMetaData);
return true;
}
return false;
}
/**
* Gets the data from a backup for restoration.
* Note: The caller should create a pre-restore backup before calling this.
*/
getBackupData(backupId: string): BrowserSyncData | undefined {
const backup = this.getBackupById(backupId);
return backup?.data;
}
}

View File

@@ -20,6 +20,17 @@ import {
import { deletePermission } from './related/permission';
import { createNewVault, deleteVault, unlockVault } from './related/vault';
import { addRelay, deleteRelay, updateRelay } from './related/relay';
import {
addNwcConnection,
deleteNwcConnection,
updateNwcConnectionBalance,
} from './related/nwc';
import {
addCashuMint,
deleteCashuMint,
updateCashuMintProofs,
} from './related/cashu';
import { CashuMint_DECRYPTED, CashuProof } from './types';
export interface StorageServiceConfig {
browserSessionHandler: BrowserSessionHandler;
@@ -176,6 +187,43 @@ export class StorageService {
await updateRelay.call(this, relayClone);
}
async addNwcConnection(data: {
name: string;
connectionUrl: string;
}): Promise<void> {
await addNwcConnection.call(this, data);
}
async deleteNwcConnection(connectionId: string): Promise<void> {
await deleteNwcConnection.call(this, connectionId);
}
async updateNwcConnectionBalance(
connectionId: string,
balanceMillisats: number
): Promise<void> {
await updateNwcConnectionBalance.call(this, connectionId, balanceMillisats);
}
async addCashuMint(data: {
name: string;
mintUrl: string;
unit?: string;
}): Promise<CashuMint_DECRYPTED> {
return await addCashuMint.call(this, data);
}
async deleteCashuMint(mintId: string): Promise<void> {
await deleteCashuMint.call(this, mintId);
}
async updateCashuMintProofs(
mintId: string,
proofs: CashuProof[]
): Promise<void> {
await updateCashuMintProofs.call(this, mintId, proofs);
}
exportVault(): string {
this.assureIsInitialized();
const vaultJson = JSON.stringify(
@@ -226,6 +274,17 @@ export class StorageService {
return this.#signerMetaHandler;
}
/**
* Get the current browser sync flow setting.
* Returns NO_SYNC if not initialized or no setting found.
*/
getSyncFlow(): BrowserSyncFlow {
if (!this.isInitialized || !this.#signerMetaHandler?.signerMetaData) {
return BrowserSyncFlow.NO_SYNC;
}
return this.#signerMetaHandler.signerMetaData.syncFlow ?? BrowserSyncFlow.NO_SYNC;
}
/**
* Throws an exception if the service is not initialized.
*/

View File

@@ -1,10 +1,10 @@
import { Nip07Method, Nip07MethodPolicy } from '@common';
import { ExtensionMethod, Nip07MethodPolicy } from '@common';
export interface Permission_DECRYPTED {
id: string;
identityId: string;
host: string;
method: Nip07Method;
method: ExtensionMethod;
methodPolicy: Nip07MethodPolicy;
kind?: number;
}
@@ -43,6 +43,80 @@ export interface Relay_ENCRYPTED {
write: string;
}
/**
* NWC (Nostr Wallet Connect) connection - Decrypted
* Stores NIP-47 wallet connection data
*/
export interface NwcConnection_DECRYPTED {
id: string;
name: string; // User-defined wallet name
connectionUrl: string; // Full nostr+walletconnect:// URL
walletPubkey: string; // Wallet service pubkey
relayUrl: string; // Relay URL for NWC communication
secret: string; // Client secret key (32-byte hex)
lud16?: string; // Optional lightning address
createdAt: string; // ISO timestamp
cachedBalance?: number; // Balance in millisatoshis
cachedBalanceAt?: string; // ISO timestamp when balance was fetched
}
/**
* NWC connection - Encrypted for storage
*/
export interface NwcConnection_ENCRYPTED {
id: string;
name: string;
connectionUrl: string;
walletPubkey: string;
relayUrl: string;
secret: string;
lud16?: string;
createdAt: string;
cachedBalance?: string; // Encrypted as string
cachedBalanceAt?: string;
}
/**
* Cashu Proof - represents a single ecash token
* This is the actual money stored locally
*/
export interface CashuProof {
id: string; // Keyset ID from mint
amount: number; // Satoshi amount
secret: string; // Blinded secret
C: string; // Unblinded signature (commitment)
receivedAt?: string; // ISO timestamp when token was received
}
/**
* Cashu Mint Connection - Decrypted
* Stores NIP-60 Cashu mint connection data with local proofs
*/
export interface CashuMint_DECRYPTED {
id: string;
name: string; // User-defined mint name
mintUrl: string; // Mint API URL
unit: string; // Unit (default: 'sat')
createdAt: string; // ISO timestamp
proofs: CashuProof[]; // Unspent proofs for this mint
cachedBalance?: number; // Sum of proof amounts (sats)
cachedBalanceAt?: string; // When balance was calculated
}
/**
* Cashu Mint Connection - Encrypted for storage
*/
export interface CashuMint_ENCRYPTED {
id: string;
name: string;
mintUrl: string;
unit: string;
createdAt: string;
proofs: string; // JSON stringified and encrypted
cachedBalance?: string;
cachedBalanceAt?: string;
}
export interface BrowserSyncData_PART_Unencrypted {
version: number;
iv: string;
@@ -57,6 +131,8 @@ export interface BrowserSyncData_PART_Encrypted {
permissions: Permission_ENCRYPTED[];
identities: Identity_ENCRYPTED[];
relays: Relay_ENCRYPTED[];
nwcConnections?: NwcConnection_ENCRYPTED[];
cashuMints?: CashuMint_ENCRYPTED[];
}
export type BrowserSyncData = BrowserSyncData_PART_Unencrypted &
@@ -83,11 +159,17 @@ export interface BrowserSessionData {
identities: Identity_DECRYPTED[];
selectedIdentityId: string | null;
relays: Relay_DECRYPTED[];
nwcConnections?: NwcConnection_DECRYPTED[];
cashuMints?: CashuMint_DECRYPTED[];
}
export interface SignerMetaData_VaultSnapshot {
id: string;
fileName: string;
createdAt: string; // ISO timestamp
data: BrowserSyncData;
identityCount: number;
reason?: 'manual' | 'auto' | 'pre-restore'; // Why was this backup created
}
export const SIGNER_META_DATA_KEY = {
@@ -109,6 +191,9 @@ export interface SignerMetaData {
vaultSnapshots?: SignerMetaData_VaultSnapshot[];
// Maximum number of automatic backups to keep (default: 5)
maxBackups?: number;
// Reckless mode: auto-approve all actions without prompting
recklessMode?: boolean;
@@ -117,6 +202,9 @@ export interface SignerMetaData {
// User bookmarks
bookmarks?: Bookmark[];
// Dev mode: show test permission prompt button in settings
devMode?: boolean;
}
/**

View File

@@ -16,9 +16,16 @@
letter-spacing: 0.1rem;
}
.lock-btn {
.header-buttons {
position: absolute;
left: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.lock-btn,
.header-btn {
background: transparent;
border: none;
padding: 8px;
@@ -37,6 +44,12 @@
font-size: 20px;
}
}
// For backwards compatibility with single lock-btn
> .lock-btn {
position: absolute;
left: 0;
}
}
.sam-footer-grid-2 {

View File

@@ -19,6 +19,7 @@ export * from './lib/helpers/nip05-validator';
// Models
export * from './lib/models/nostr';
export * from './lib/models/webln';
// Services (and related)
export * from './lib/services/storage/storage.service';
@@ -26,6 +27,13 @@ export * from './lib/services/storage/types';
export * from './lib/services/storage/browser-sync-handler';
export * from './lib/services/storage/browser-session-handler';
export * from './lib/services/storage/signer-meta-handler';
export * from './lib/services/storage/related/nwc';
export * from './lib/services/storage/related/cashu';
export * from './lib/services/nwc/nwc.service';
export * from './lib/services/nwc/nwc-client';
export * from './lib/services/nwc/types';
export * from './lib/services/cashu/cashu.service';
export * from './lib/services/cashu/types';
export * from './lib/services/logger/logger.service';
export * from './lib/services/startup/startup.service';
export * from './lib/services/profile-metadata/profile-metadata.service';

View File

@@ -22,5 +22,9 @@ module.exports = {
import: 'src/options.ts',
runtime: false,
},
unlock: {
import: 'src/unlock.ts',
runtime: false,
},
},
} as Configuration;

View File

@@ -2,8 +2,8 @@
"manifest_version": 3,
"name": "Plebeian Signer",
"description": "Nostr Identity Manager & Signer",
"version": "1.0.8",
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
"version": "1.1.0",
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
"options_page": "options.html",
"permissions": [
"storage"

View File

@@ -27,11 +27,66 @@
.page {
height: 100%;
display: grid;
grid-template-rows: 1fr 60px;
grid-template-rows: 1fr auto;
grid-template-columns: 1fr;
overflow-y: hidden;
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--size);
background: var(--background);
}
.action-row {
display: flex;
align-items: center;
gap: 8px;
}
.action-label {
width: 60px;
font-size: 13px;
font-weight: 500;
color: var(--muted-foreground);
}
.action-buttons {
display: flex;
gap: 8px;
flex: 1;
}
.action-buttons button {
flex: 1;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-reject {
background: var(--muted);
color: var(--foreground);
}
.btn-reject:hover {
background: var(--border);
}
.btn-accept {
background: var(--primary);
color: var(--primary-foreground);
}
.btn-accept:hover {
opacity: 0.9;
}
.card {
padding: var(--size);
background: var(--background-light);
@@ -54,6 +109,12 @@
font-size: 12px;
color: gray;
}
.description {
margin: 0;
text-align: center;
line-height: 1.5;
}
</style>
</head>
<body>
@@ -63,64 +124,31 @@
<span id="titleSpan" style="font-weight: 400 !important"></span>
</div>
<span
class="host-INSERT sam-align-self-center sam-text-muted"
style="font-weight: 500"
></span>
<!-- Card for getPublicKey -->
<div id="cardGetPublicKey" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">read your public key</b> <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your public key</b> for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card for getRelays -->
<div id="cardGetRelays" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">read your relays</b> <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your relays</b> for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card for signEvent -->
<div id="cardSignEvent" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">sign an event</b> (kind
<span id="kindSpan"></span>) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">sign an event</b> (kind <span id="kindSpan"></span>)
for the selected identity <b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for signEvent -->
@@ -130,20 +158,11 @@
<!-- Card for nip04.encrypt -->
<div id="cardNip04Encrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">encrypt a text</b> (NIP04) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">encrypt a text</b> (NIP04) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip04.encrypt -->
@@ -153,20 +172,11 @@
<!-- Card for nip44.encrypt -->
<div id="cardNip44Encrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">encrypt a text</b> (NIP44) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">encrypt a text</b> (NIP44) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip44.encrypt -->
@@ -176,20 +186,11 @@
<!-- Card for nip04.decrypt -->
<div id="cardNip04Decrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">decrypt a text</b> (NIP04) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">decrypt a text</b> (NIP04) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip04.decrypt -->
@@ -199,72 +200,83 @@
<!-- Card for nip44.decrypt -->
<div id="cardNip44Decrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">decrypt a text</b> (NIP44) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">decrypt a text</b> (NIP44) for the selected identity
<b class="nick-INSERT color-primary"></b>.
</p>
</div>
<!-- Card2 for nip44.decrypt -->
<div id="card2Nip44Decrypt" class="card sam-mt sam-ml sam-mr">
<div id="card2Nip44Decrypt_text" class="text"></div>
</div>
<!-- Card for webln.enable -->
<div id="cardWeblnEnable" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">connect to your Lightning wallet</b>.
</p>
</div>
<!-- Card for webln.getInfo -->
<div id="cardWeblnGetInfo" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">read your wallet info</b>.
</p>
</div>
<!-- Card for webln.sendPayment -->
<div id="cardWeblnSendPayment" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">send a Lightning payment</b> of
<b id="paymentAmountSpan" class="color-primary"></b>.
</p>
</div>
<!-- Card2 for webln.sendPayment (shows invoice) -->
<div id="card2WeblnSendPayment" class="card sam-mt sam-ml sam-mr">
<div id="card2WeblnSendPayment_json" class="json"></div>
</div>
<!-- Card for webln.makeInvoice -->
<div id="cardWeblnMakeInvoice" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">create a Lightning invoice</b>
<span id="invoiceAmountSpan"></span>.
</p>
</div>
<!-- Card for webln.keysend -->
<div id="cardWeblnKeysend" class="card sam-mt sam-ml sam-mr">
<p class="description">
<b class="host-INSERT color-primary"></b> is requesting permission to
<b class="color-primary">send a keysend payment</b>.
</p>
</div>
</div>
<!------------->
<!-- ACTIONS -->
<!------------->
<div class="sam-footer-grid-2">
<div class="btn-group">
<button id="rejectOnceButton" type="button" class="btn btn-secondary">
Reject
</button>
<button
type="button"
class="btn btn-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button id="rejectAlwaysButton" class="dropdown-item">
Reject Always
</button>
</li>
</ul>
<div class="actions">
<div class="action-row">
<span class="action-label">Reject</span>
<div class="action-buttons">
<button id="rejectOnceButton" type="button" class="btn-reject">Once</button>
<button id="rejectAlwaysButton" type="button" class="btn-reject">Always</button>
</div>
</div>
<div class="btn-group">
<button id="approveAlwaysButton" type="button" class="btn btn-primary">
Approve Always
</button>
<button
type="button"
class="btn btn-primary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">
<li>
<button id="approveOnceButton" class="dropdown-item">
Approve Once
</button>
</li>
</ul>
<div class="action-row">
<span class="action-label">Accept</span>
<div class="action-buttons">
<button id="approveOnceButton" type="button" class="btn-accept">Once</button>
<button id="approveAlwaysButton" type="button" class="btn-accept">Always</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html>
<head>
<title>Plebeian Signer - Unlock</title>
<link rel="stylesheet" type="text/css" href="styles.css" />
<script src="scripts.js"></script>
<style>
/* Prevent white flash on load */
html { background-color: #0a0a0a; }
@media (prefers-color-scheme: light) {
html { background-color: #ffffff; }
}
body {
background: var(--background);
height: 100vh;
width: 100vw;
color: var(--foreground);
font-size: 16px;
margin: 0;
display: flex;
flex-direction: column;
}
.color-primary {
color: var(--primary);
}
.page {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--size);
box-sizing: border-box;
}
.header {
text-align: center;
font-size: 1.25rem;
font-weight: 500;
padding: var(--size) 0;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
}
.logo-frame {
border: 2px solid var(--secondary);
border-radius: 100%;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.logo-frame img {
display: block;
}
.input-group {
width: 100%;
max-width: 280px;
display: flex;
}
.input-group input {
flex: 1;
padding: 10px 12px;
border: 1px solid var(--border);
border-right: none;
border-radius: 6px 0 0 6px;
background: var(--background);
color: var(--foreground);
font-size: 14px;
}
.input-group input:focus {
outline: none;
border-color: var(--primary);
}
.input-group button {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 0 6px 6px 0;
background: var(--background-light);
color: var(--muted-foreground);
cursor: pointer;
}
.input-group button:hover {
background: var(--muted);
}
.unlock-btn {
width: 100%;
max-width: 280px;
padding: 10px 16px;
border: none;
border-radius: 6px;
background: var(--primary);
color: var(--primary-foreground);
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.unlock-btn:hover:not(:disabled) {
opacity: 0.9;
}
.unlock-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.alert {
position: fixed;
bottom: var(--size);
left: 50%;
transform: translateX(-50%);
padding: 10px 16px;
border-radius: 6px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.alert-danger {
background: var(--destructive);
color: var(--destructive-foreground);
}
.hidden {
display: none !important;
}
.deriving-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
z-index: 1000;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--muted);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.deriving-text {
color: var(--foreground);
font-size: 14px;
}
.host-info {
text-align: center;
font-size: 13px;
color: var(--muted-foreground);
margin-top: 8px;
}
.host-name {
color: var(--primary);
font-weight: 500;
}
</style>
</head>
<body>
<div class="page">
<div class="header">
<span class="brand">Plebeian Signer</span>
</div>
<div class="content">
<div class="logo-frame">
<img src="logo.svg" height="100" width="100" alt="" />
</div>
<div id="hostInfo" class="host-info hidden">
<span class="host-name" id="hostSpan"></span><br>
is requesting access
</div>
<div class="input-group sam-mt">
<input
id="passwordInput"
type="password"
placeholder="vault password"
autocomplete="current-password"
/>
<button id="togglePassword" type="button">
<i class="bi bi-eye"></i>
</button>
</div>
<button id="unlockBtn" type="button" class="unlock-btn" disabled>
<i class="bi bi-box-arrow-in-right"></i>
<span>Unlock</span>
</button>
</div>
</div>
<!-- Deriving overlay -->
<div id="derivingOverlay" class="deriving-overlay hidden">
<div class="spinner"></div>
<div class="deriving-text">Unlocking vault...</div>
</div>
<!-- Error alert -->
<div id="errorAlert" class="alert alert-danger hidden">
<i class="bi bi-exclamation-triangle"></i>
<span id="errorMessage">Invalid password</span>
</div>
<script src="unlock.js"></script>
</body>
</html>

View File

@@ -9,6 +9,7 @@ import { SettingsComponent } from './components/home/settings/settings.component
import { LogsComponent } from './components/home/logs/logs.component';
import { BookmarksComponent } from './components/home/bookmarks/bookmarks.component';
import { WalletComponent } from './components/home/wallet/wallet.component';
import { BackupsComponent } from './components/home/backups/backups.component';
import { NewIdentityComponent } from './components/new-identity/new-identity.component';
import { EditIdentityComponent } from './components/edit-identity/edit-identity.component';
import { HomeComponent as EditIdentityHomeComponent } from './components/edit-identity/home/home.component';
@@ -81,6 +82,10 @@ export const routes: Routes = [
path: 'wallet',
component: WalletComponent,
},
{
path: 'backups',
component: BackupsComponent,
},
],
},
{

View File

@@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
CashuMint_ENCRYPTED,
Identity_ENCRYPTED,
NwcConnection_ENCRYPTED,
Permission_ENCRYPTED,
BrowserSyncHandler,
Relay_ENCRYPTED,
@@ -56,6 +58,20 @@ export class FirefoxSyncNoHandler extends BrowserSyncHandler {
this.setPartialData_Relays(data);
}
async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
}): Promise<void> {
await browser.storage.local.set(data);
this.setPartialData_NwcConnections(data);
}
async saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[];
}): Promise<void> {
await browser.storage.local.set(data);
this.setPartialData_CashuMints(data);
}
async clearData(): Promise<void> {
const props = Object.keys(await this.loadUnmigratedData());
await browser.storage.local.remove(props);

View File

@@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
CashuMint_ENCRYPTED,
Identity_ENCRYPTED,
NwcConnection_ENCRYPTED,
Permission_ENCRYPTED,
BrowserSyncHandler,
Relay_ENCRYPTED,
@@ -50,6 +52,20 @@ export class FirefoxSyncYesHandler extends BrowserSyncHandler {
this.setPartialData_Relays(data);
}
async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
}): Promise<void> {
await browser.storage.sync.set(data);
this.setPartialData_NwcConnections(data);
}
async saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[];
}): Promise<void> {
await browser.storage.sync.set(data);
this.setPartialData_CashuMints(data);
}
async clearData(): Promise<void> {
await browser.storage.sync.clear();
}

View File

@@ -0,0 +1,86 @@
<div class="sam-text-header">
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<button class="back-btn" title="Go Back" (click)="goBack()">
<span class="emoji"></span>
</button>
<span>Backups</span>
</div>
<div class="backup-settings">
<div class="setting-row">
<label for="maxBackups">Max Auto Backups:</label>
<input
id="maxBackups"
type="number"
[value]="maxBackups"
min="1"
max="20"
(change)="onMaxBackupsChange($event)"
/>
</div>
<p class="setting-note">
Automatic backups are created when significant changes are made.
Manual and pre-restore backups are not counted toward this limit.
</p>
</div>
<button class="btn btn-primary create-btn" (click)="createManualBackup()">
Create Backup Now
</button>
<div class="backups-list">
@if (backups.length === 0) {
<div class="empty-state">
<span>No backups yet</span>
</div>
}
@for (backup of backups; track backup.id) {
<div class="backup-item">
<div class="backup-info">
<span class="backup-date">{{ formatDate(backup.createdAt) }}</span>
<div class="backup-meta">
<span class="backup-reason" [class]="getReasonClass(backup.reason)">
{{ getReasonLabel(backup.reason) }}
</span>
<span class="backup-identities">{{ backup.identityCount }} identity(ies)</span>
</div>
</div>
<div class="backup-actions">
<button
class="btn btn-sm btn-secondary"
(click)="
confirm.show(
'Restore this backup? A backup of your current state will be created first.',
restoreBackup.bind(this, backup.id)
)
"
[disabled]="restoringBackupId !== null"
>
{{ restoringBackupId === backup.id ? 'Restoring...' : 'Restore' }}
</button>
<button
class="btn btn-sm btn-danger"
(click)="
confirm.show(
'Delete this backup? This cannot be undone.',
deleteBackup.bind(this, backup.id)
)
"
>
Delete
</button>
</div>
</div>
}
</div>
<lib-confirm #confirm></lib-confirm>

View File

@@ -0,0 +1,192 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
gap: 12px;
}
.sam-text-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: bold;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.lock-btn,
.back-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
&:hover {
background: var(--muted);
}
.emoji {
font-size: 16px;
}
}
.backup-settings {
background: var(--muted);
padding: 12px;
border-radius: 8px;
}
.setting-row {
display: flex;
align-items: center;
gap: 12px;
label {
font-weight: 500;
}
input[type="number"] {
width: 60px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--background);
color: var(--foreground);
}
}
.setting-note {
margin-top: 8px;
font-size: 12px;
color: var(--muted-foreground);
}
.create-btn {
align-self: flex-start;
}
.backups-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
height: 100px;
color: var(--muted-foreground);
}
.backup-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
gap: 12px;
}
.backup-info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.backup-date {
font-weight: 500;
font-size: 13px;
}
.backup-meta {
display: flex;
gap: 8px;
font-size: 11px;
}
.backup-reason {
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
&.reason-auto {
background: var(--muted);
color: var(--muted-foreground);
}
&.reason-manual {
background: rgba(34, 197, 94, 0.2);
color: rgb(34, 197, 94);
}
&.reason-prerestore {
background: rgba(234, 179, 8, 0.2);
color: rgb(234, 179, 8);
}
}
.backup-identities {
color: var(--muted-foreground);
}
.backup-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-primary {
background: var(--primary);
color: var(--primary-foreground);
&:hover:not(:disabled) {
opacity: 0.9;
}
}
.btn-secondary {
background: var(--secondary);
color: var(--secondary-foreground);
&:hover:not(:disabled) {
background: var(--muted);
}
}
.btn-danger {
background: rgba(239, 68, 68, 0.2);
color: rgb(239, 68, 68);
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.3);
}
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}

View File

@@ -0,0 +1,125 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import {
ConfirmComponent,
LoggerService,
NavComponent,
SignerMetaData_VaultSnapshot,
StartupService,
} from '@common';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
@Component({
selector: 'app-backups',
templateUrl: './backups.component.html',
styleUrl: './backups.component.scss',
imports: [ConfirmComponent],
})
export class BackupsComponent extends NavComponent implements OnInit {
readonly #router = inject(Router);
readonly #startup = inject(StartupService);
readonly #logger = inject(LoggerService);
backups: SignerMetaData_VaultSnapshot[] = [];
maxBackups = 5;
restoringBackupId: string | null = null;
ngOnInit(): void {
this.loadBackups();
this.maxBackups = this.storage.getSignerMetaHandler().getMaxBackups();
}
loadBackups(): void {
this.backups = this.storage.getSignerMetaHandler().getBackups();
}
async onMaxBackupsChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const value = parseInt(input.value, 10);
if (!isNaN(value) && value >= 1 && value <= 20) {
this.maxBackups = value;
await this.storage.getSignerMetaHandler().setMaxBackups(value);
}
}
async createManualBackup(): Promise<void> {
const browserSyncData = this.storage.getBrowserSyncHandler().browserSyncData;
if (browserSyncData) {
await this.storage.getSignerMetaHandler().createBackup(browserSyncData, 'manual');
this.loadBackups();
}
}
async restoreBackup(backupId: string): Promise<void> {
this.restoringBackupId = backupId;
try {
// First, create a pre-restore backup of current state
const currentData = this.storage.getBrowserSyncHandler().browserSyncData;
if (currentData) {
await this.storage.getSignerMetaHandler().createBackup(currentData, 'pre-restore');
}
// Get the backup data
const backupData = this.storage.getSignerMetaHandler().getBackupData(backupId);
if (!backupData) {
throw new Error('Backup not found');
}
// Import the backup
await this.storage.deleteVault(true);
await this.storage.importVault(backupData);
this.#logger.logVaultImport('Backup Restore');
this.storage.isInitialized = false;
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
console.error('Failed to restore backup:', error);
this.restoringBackupId = null;
}
}
async deleteBackup(backupId: string): Promise<void> {
await this.storage.getSignerMetaHandler().deleteBackup(backupId);
this.loadBackups();
}
formatDate(isoDate: string): string {
const date = new Date(isoDate);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
getReasonLabel(reason?: string): string {
switch (reason) {
case 'auto':
return 'Auto';
case 'manual':
return 'Manual';
case 'pre-restore':
return 'Pre-Restore';
default:
return 'Unknown';
}
}
getReasonClass(reason?: string): string {
switch (reason) {
case 'auto':
return 'reason-auto';
case 'manual':
return 'reason-manual';
case 'pre-restore':
return 'reason-prerestore';
default:
return '';
}
}
goBack(): void {
this.#router.navigateByUrl('/home/settings');
}
async onClickLock(): Promise<void> {
this.#logger.logVaultLock();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,9 +1,16 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>Bookmarks</span>
<button class="add-btn" title="Bookmark This Page" (click)="onBookmarkThisPage()">
<span class="emoji"></span>

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { Bookmark, LoggerService, SignerMetaData, StorageService } from '@common';
import { Bookmark, LoggerService, NavComponent, SignerMetaData } from '@common';
import { FirefoxMetaHandler } from '../../../common/data/firefox-meta-handler';
import browser from 'webextension-polyfill';
@@ -10,10 +10,9 @@ import browser from 'webextension-polyfill';
styleUrl: './bookmarks.component.scss',
imports: [],
})
export class BookmarksComponent implements OnInit {
export class BookmarksComponent extends NavComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #metaHandler = new FirefoxMetaHandler();
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
bookmarks: Bookmark[] = [];
@@ -94,7 +93,7 @@ export class BookmarksComponent implements OnInit {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,9 +1,16 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="custom-header" style="position: sticky; top: 0">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span class="text">Identities</span>
<button class="add-btn" title="New Identity" (click)="onClickNewIdentity()">

View File

@@ -19,9 +19,16 @@
background: var(--background);
position: relative;
.lock-btn,
.add-btn {
.header-buttons {
position: absolute;
left: 0;
display: flex;
flex-direction: row;
align-items: center;
}
.header-btn,
.add-btn {
background: transparent;
border: none;
padding: 8px;
@@ -41,11 +48,8 @@
}
}
.lock-btn {
left: 0;
}
.add-btn {
position: absolute;
right: 0;
}

View File

@@ -4,6 +4,7 @@ import {
IconButtonComponent,
Identity_DECRYPTED,
LoggerService,
NavComponent,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
@@ -17,8 +18,8 @@ import {
templateUrl: './identities.component.html',
styleUrl: './identities.component.scss',
})
export class IdentitiesComponent implements OnInit {
readonly storage = inject(StorageService);
export class IdentitiesComponent extends NavComponent implements OnInit {
override readonly storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);

View File

@@ -1,9 +1,16 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>You</span>
<button class="edit-btn" title="Edit profile" (click)="onClickEditProfile()">
<span class="emoji">📝</span>
@@ -73,4 +80,12 @@
</div>
</div>
<!-- About section -->
@if (aboutText) {
<div class="about-section">
<div class="about-header">About</div>
<div class="about-content">{{ aboutText }}</div>
</div>
}
<lib-toast #toast></lib-toast>

View File

@@ -185,4 +185,33 @@
opacity: 1;
}
}
.about-section {
margin: var(--size);
margin-top: 0;
flex-shrink: 0;
max-height: 150px;
display: flex;
flex-direction: column;
.about-header {
font-size: 0.85rem;
font-weight: 600;
color: var(--muted-foreground);
margin-bottom: var(--size-h);
}
.about-content {
flex: 1;
overflow-y: auto;
font-size: 0.9rem;
line-height: 1.5;
color: var(--foreground);
background: var(--background-light);
border-radius: var(--radius-sm);
padding: var(--size);
white-space: pre-wrap;
word-break: break-word;
}
}
}

View File

@@ -3,11 +3,11 @@ import { Router } from '@angular/router';
import {
Identity_DECRYPTED,
LoggerService,
NavComponent,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
PubkeyComponent,
StorageService,
ToastComponent,
VisualNip05Pipe,
validateNip05,
@@ -19,7 +19,7 @@ import {
templateUrl: './identity.component.html',
styleUrl: './identity.component.scss',
})
export class IdentityComponent implements OnInit {
export class IdentityComponent extends NavComponent implements OnInit {
selectedIdentity: Identity_DECRYPTED | undefined;
selectedIdentityNpub: string | undefined;
profile: ProfileMetadata | null = null;
@@ -27,7 +27,6 @@ export class IdentityComponent implements OnInit {
validating = false;
loading = true;
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);
@@ -52,6 +51,10 @@ export class IdentityComponent implements OnInit {
return this.profile?.banner;
}
get aboutText(): string | undefined {
return this.profile?.about;
}
copyToClipboard(pubkey: string | undefined) {
if (!pubkey) {
return;
@@ -78,17 +81,17 @@ export class IdentityComponent implements OnInit {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
async #loadData() {
try {
const selectedIdentityId =
this.#storage.getBrowserSessionHandler().browserSessionData
this.storage.getBrowserSessionHandler().browserSessionData
?.selectedIdentityId ?? null;
const identity = this.#storage
const identity = this.storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find(
(x) => x.id === selectedIdentityId

View File

@@ -1,7 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span> Plebeian Signer </span>
</div>
@@ -10,7 +17,7 @@
<span>&nbsp;</span>
<span> Source code</span>
<a href="https://git.mleku.dev/mleku/plebeian-signer" target="_blank">
git.mleku.dev/mleku/plebeian-signer
<a href="https://github.com/PlebeianApp/plebeian-signer" target="_blank">
github.com/PlebeianApp/plebeian-signer
</a>

View File

@@ -1,6 +1,6 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, StorageService } from '@common';
import { LoggerService, NavComponent } from '@common';
import packageJson from '../../../../../../../package.json';
@Component({
@@ -8,16 +8,15 @@ import packageJson from '../../../../../../../package.json';
templateUrl: './info.component.html',
styleUrl: './info.component.scss',
})
export class InfoComponent {
export class InfoComponent extends NavComponent {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
version = packageJson.custom.firefox.version;
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,7 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span>Logs</span>
<div class="logs-actions">
<button class="btn btn-sm btn-secondary" title="Refresh logs" (click)="onRefresh()">Refresh</button>

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, LogEntry, StorageService } from '@common';
import { LoggerService, LogEntry, NavComponent } from '@common';
import { DatePipe } from '@angular/common';
@Component({
@@ -9,9 +9,8 @@ import { DatePipe } from '@angular/common';
styleUrl: './logs.component.scss',
imports: [DatePipe],
})
export class LogsComponent implements OnInit {
export class LogsComponent extends NavComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
get logs(): LogEntry[] {
@@ -46,7 +45,7 @@ export class LogsComponent implements OnInit {
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,30 +1,52 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
<span> Settings </span>
</div>
<span>SYNC: {{ syncFlow }}</span>
<button class="btn btn-primary" (click)="onClickExportVault()">
Export Vault
</button>
<button class="btn btn-primary" (click)="navigate('/vault-import')">
Import Vault
</button>
<div class="vault-buttons">
<button class="btn btn-primary" (click)="onClickExportVault()">
Export Vault
</button>
<button class="btn btn-primary" (click)="navigate('/vault-import')">
Import Vault
</button>
</div>
<lib-nav-item text="💾 Backups" (click)="navigate('/home/backups')"></lib-nav-item>
<lib-nav-item text="🪵 Logs" (click)="navigate('/home/logs')"></lib-nav-item>
<lib-nav-item text="💡 Info" (click)="navigate('/home/info')"></lib-nav-item>
<div class="dev-mode-row">
<label class="toggle-label">
<input type="checkbox" [checked]="devMode" (change)="onToggleDevMode($event)" />
<span>Dev Mode</span>
</label>
</div>
<div class="sam-flex-grow"></div>
<div class="sync-info">
<span class="sync-label">SYNC: {{ syncFlow }}</span>
<p class="sync-note">
To change sync mode, export your vault, reset the extension,
and re-import with the desired sync setting.
</p>
</div>
<button
class="btn btn-danger"
(click)="
confirm.show(
'Do you really want to reset your extension? All data will be lost.',
'Do you really want to reset your extension? Every data will be lost.',
onResetExtension.bind(this)
)
"

View File

@@ -15,3 +15,46 @@
visibility: hidden;
}
}
.vault-buttons {
display: flex;
gap: var(--size);
button {
flex: 1;
}
}
.dev-mode-row {
display: flex;
align-items: center;
gap: var(--size);
.toggle-label {
display: flex;
align-items: center;
gap: var(--size-h);
cursor: pointer;
font-size: 0.9rem;
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
}
}
.sync-info {
.sync-label {
display: block;
font-weight: 500;
}
.sync-note {
margin: var(--size-h) 0 0 0;
font-size: 0.85rem;
color: var(--muted-foreground);
line-height: 1.4;
}
}

View File

@@ -11,6 +11,8 @@ import {
StorageService,
} from '@common';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
import { Buffer } from 'buffer';
import browser from 'webextension-polyfill';
@Component({
selector: 'app-settings',
@@ -21,6 +23,7 @@ import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage
export class SettingsComponent extends NavComponent implements OnInit {
readonly #router = inject(Router);
syncFlow: string | undefined;
override devMode = false;
readonly #storage = inject(StorageService);
readonly #startup = inject(StartupService);
@@ -44,6 +47,44 @@ export class SettingsComponent extends NavComponent implements OnInit {
default:
break;
}
// Load dev mode setting
this.devMode = this.#storage.getSignerMetaHandler().signerMetaData?.devMode ?? false;
}
async onToggleDevMode(event: Event) {
const checked = (event.target as HTMLInputElement).checked;
this.devMode = checked;
await this.#storage.getSignerMetaHandler().setDevMode(checked);
}
override async onTestPrompt() {
// Open a test permission prompt window
const testEvent = {
kind: 1,
content: 'This is a test note for permission prompt preview.',
tags: [],
created_at: Math.floor(Date.now() / 1000),
};
const base64Event = Buffer.from(JSON.stringify(testEvent, null, 2)).toString('base64');
const currentIdentity = this.#storage.getBrowserSessionHandler().browserSessionData?.identities.find(
i => i.id === this.#storage.getBrowserSessionHandler().browserSessionData?.selectedIdentityId
);
const nick = currentIdentity?.nick ?? 'Test Identity';
const width = 375;
const height = 600;
const left = Math.round((screen.width - width) / 2);
const top = Math.round((screen.height - height) / 2);
browser.windows.create({
type: 'popup',
url: `prompt.html?method=signEvent&host=example.com&id=test-${Date.now()}&nick=${encodeURIComponent(nick)}&event=${base64Event}`,
width,
height,
left,
top,
});
}
async onResetExtension() {

View File

@@ -1,14 +1,660 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<span>Wallet</span>
<div class="header-buttons">
<button class="header-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
@if (devMode) {
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
<span class="emoji"></span>
</button>
}
</div>
@if (showBackButton) {
<button class="back-btn" title="Go Back" (click)="goBack()">
<span class="emoji"></span>
</button>
}
<span>{{ title }}</span>
<div class="section-btns">
<button
class="section-btn"
[class.active]="activeSection.startsWith('cashu')"
title="Cashu"
(click)="setSection('cashu')"
>
<span class="emoji">🥜</span>
</button>
<button
class="section-btn"
[class.active]="activeSection.startsWith('lightning')"
title="Lightning"
(click)="setSection('lightning')"
>
<span class="emoji"></span>
</button>
</div>
</div>
<div class="wallet-container">
<div class="empty-state">
<span class="sam-text-muted">
Wallet functionality coming soon.
</span>
</div>
<!-- Main wallet menu -->
@if (activeSection === 'main') {
<div class="wallet-menu">
<button class="wallet-menu-item" (click)="setSection('cashu')">
<span class="emoji">🥜</span>
<span class="label">Cashu</span>
<span class="balance">{{ formatCashuBalance(totalCashuBalance) }} sats</span>
</button>
<button class="wallet-menu-item" (click)="setSection('lightning')">
<span class="emoji"></span>
<span class="label">Lightning</span>
<span class="balance">{{ formatBalance(totalLightningBalance) }} sats</span>
</button>
</div>
}
<!-- Cashu mint list -->
@else if (activeSection === 'cashu') {
<div class="lightning-section">
@if (mints.length === 0) {
<div class="cashu-onboarding">
@if (showCashuInfo) {
<div class="info-panel">
<h3>Welcome to Cashu Wallet</h3>
<div class="info-section">
<h4>Storage Considerations</h4>
@if (currentSyncFlow === BrowserSyncFlow.BROWSER_SYNC) {
<div class="warning-box">
<p><strong>Browser Sync is enabled</strong></p>
<p>
Sync storage is limited to ~100KB shared across all your vault data
(identities, permissions, relays, and Cashu tokens). This limits
your Cashu wallet to approximately 300-400 tokens.
</p>
<p>
For larger Cashu holdings, consider disabling browser sync which
provides ~5MB of local storage (~18,000+ tokens).
</p>
<button class="link-btn" (click)="navigateToSettings()">
Change Sync Settings
</button>
</div>
} @else {
<div class="success-box">
<p><strong>Local Storage Mode</strong></p>
<p>
You have ~5MB of local storage available, which can hold
thousands of Cashu tokens. Your data stays on this device only.
</p>
</div>
}
</div>
<div class="info-section">
<h4>Backup Your Wallet</h4>
<p>
<strong>Important:</strong> Cashu tokens are bearer assets.
If you lose your vault backup, you lose your tokens permanently.
</p>
<p>
Vault exports are saved to your browser's downloads folder.
Configure this to point to either:
</p>
<ul>
<li>Your backup storage device (external drive, NAS)</li>
<li>A folder synced by your backup tool (Syncthing, rsync, etc.)</li>
</ul>
<p class="browser-url">
<code>{{ browserDownloadSettingsUrl }}</code>
</p>
<button class="link-btn" (click)="navigateToSettings()">
Go to Backup Settings
</button>
</div>
<button class="dismiss-btn" (click)="dismissCashuInfo()">
Got it, let me add a mint
</button>
</div>
} @else {
<div class="empty-state">
<span class="sam-text-muted">No mints connected yet.</span>
<button class="show-info-btn" (click)="showCashuInfo = true">
Show storage info
</button>
</div>
}
</div>
} @else {
<div class="wallet-list">
@for (mint of mints; track mint.id) {
<button class="wallet-list-item" (click)="selectMint(mint.id)">
<span class="wallet-name">{{ mint.name }}</span>
<span class="wallet-balance">{{ formatCashuBalance(mint.cachedBalance) }} sats</span>
</button>
}
</div>
}
<button class="add-wallet-btn" (click)="showAddMint()">
<span class="emoji">+</span>
<span>Add Mint</span>
</button>
</div>
}
<!-- Cashu mint detail -->
@else if (activeSection === 'cashu-detail' && selectedMint) {
<div class="wallet-detail">
<div class="balance-row">
<div class="balance-display compact">
<span class="balance-value">{{ formatCashuBalance(selectedMintBalance) }}</span>
<span class="balance-unit">sats</span>
</div>
<button
class="refresh-icon-btn"
(click)="refreshMint()"
[disabled]="refreshingMint"
title="Refresh"
>
<span class="emoji" [class.spinning]="refreshingMint">🔄</span>
</button>
</div>
@if (refreshError) {
<div class="error-message small">{{ refreshError }}</div>
}
<div class="action-buttons">
<button class="action-btn deposit-btn" (click)="showDeposit()">
Deposit
</button>
<button class="action-btn receive-btn" (click)="showReceive()">
Receive
</button>
<button class="action-btn send-btn" (click)="showSend()" [disabled]="selectedMintBalance === 0">
Send
</button>
</div>
<!-- Token viewer section -->
<div class="token-section">
<div class="section-title">Tokens ({{ selectedMintProofs.length }})</div>
@if (selectedMintProofs.length === 0) {
<div class="empty-text">No tokens stored</div>
} @else {
<div class="token-list">
@for (proof of selectedMintProofs; track proof.secret) {
<div class="token-item">
<span class="token-amount">{{ proof.amount }}</span>
<span class="token-time">{{ formatProofTime(proof.receivedAt) }}</span>
</div>
}
</div>
}
</div>
<div class="wallet-info">
<div class="info-row">
<span class="info-label">Mint URL</span>
<span class="info-value">{{ selectedMint.mintUrl }}</span>
</div>
<div class="info-row">
<span class="info-label">Unit</span>
<span class="info-value">{{ selectedMint.unit }}</span>
</div>
</div>
<button class="delete-btn" (click)="deleteMint()">
Delete Mint
</button>
</div>
}
<!-- Cashu add mint form -->
@else if (activeSection === 'cashu-add') {
<div class="add-wallet-form">
<div class="form-group">
<label for="mintName">Mint Name</label>
<input
id="mintName"
type="text"
[(ngModel)]="newMintName"
placeholder="My Mint"
[disabled]="addingMint"
/>
</div>
<div class="form-group">
<label for="mintUrl">Mint URL</label>
<input
id="mintUrl"
type="text"
[(ngModel)]="newMintUrl"
placeholder="https://mint.example.com"
[disabled]="addingMint"
/>
</div>
@if (mintError) {
<div class="error-message">{{ mintError }}</div>
}
@if (mintTestResult) {
<div class="success-message">{{ mintTestResult }}</div>
}
<div class="form-actions">
<button
class="test-btn"
(click)="testMint()"
[disabled]="testingMint || addingMint"
>
{{ testingMint ? 'Testing...' : 'Test Connection' }}
</button>
<button
class="add-btn"
(click)="addMint()"
[disabled]="addingMint"
>
{{ addingMint ? 'Adding...' : 'Add Mint' }}
</button>
</div>
</div>
}
<!-- Cashu receive token -->
@else if (activeSection === 'cashu-receive') {
<div class="add-wallet-form">
<div class="form-group">
<label for="receiveToken">Paste Cashu Token</label>
<textarea
id="receiveToken"
[(ngModel)]="receiveToken"
placeholder="cashuB..."
rows="5"
[disabled]="receivingToken"
></textarea>
</div>
@if (receiveError) {
<div class="error-message">{{ receiveError }}</div>
}
@if (receiveResult) {
<div class="success-message">{{ receiveResult }}</div>
}
<div class="form-actions">
<button
class="add-btn full-width"
(click)="receiveTokens()"
[disabled]="receivingToken"
>
{{ receivingToken ? 'Receiving...' : 'Receive Tokens' }}
</button>
</div>
</div>
}
<!-- Cashu send token -->
@else if (activeSection === 'cashu-send') {
<div class="add-wallet-form">
<div class="balance-info">
Available: {{ formatCashuBalance(selectedMintBalance) }} sats
</div>
<div class="form-group">
<label for="sendAmount">Amount (sats)</label>
<input
id="sendAmount"
type="number"
[(ngModel)]="sendAmount"
placeholder="0"
min="1"
[max]="selectedMintBalance"
[disabled]="sendingToken"
/>
</div>
@if (sendError) {
<div class="error-message">{{ sendError }}</div>
}
@if (sendResult) {
<div class="token-result">
<span class="token-label">Token to Share</span>
<textarea readonly rows="4">{{ sendResult }}</textarea>
<button class="copy-btn" (click)="copyToken()">
Copy Token
</button>
</div>
}
@if (!sendResult) {
<div class="form-actions">
<button
class="add-btn full-width"
(click)="sendTokens()"
[disabled]="sendingToken || sendAmount <= 0"
>
{{ sendingToken ? 'Creating...' : 'Create Token' }}
</button>
</div>
}
</div>
}
<!-- Cashu deposit (mint via Lightning) -->
@else if (activeSection === 'cashu-mint' && selectedMint) {
<div class="add-wallet-form">
@if (!depositInvoice) {
<div class="form-group">
<label for="depositAmount">Amount (sats)</label>
<input
id="depositAmount"
type="number"
[(ngModel)]="depositAmount"
placeholder="1000"
min="1"
[disabled]="creatingDepositQuote"
/>
</div>
@if (depositError) {
<div class="error-message">{{ depositError }}</div>
}
<div class="form-actions">
<button
class="add-btn full-width"
(click)="createDepositInvoice()"
[disabled]="creatingDepositQuote || depositAmount <= 0"
>
{{ creatingDepositQuote ? 'Creating...' : 'Create Invoice' }}
</button>
</div>
}
@if (depositInvoice) {
<div class="invoice-result">
@if (depositInvoiceQr) {
<img [src]="depositInvoiceQr" alt="Invoice QR Code" class="qr-code" />
}
<div class="deposit-status">
@if (depositQuoteState === 'UNPAID') {
<span class="status-waiting">Waiting for payment...</span>
@if (checkingDepositPayment) {
<span class="status-checking">checking</span>
}
} @else if (depositQuoteState === 'PAID') {
<span class="status-paid">Payment received! Claiming tokens...</span>
} @else if (depositQuoteState === 'ISSUED') {
<span class="status-issued">✓ Tokens received!</span>
}
</div>
@if (depositError) {
<div class="error-message">{{ depositError }}</div>
}
@if (depositSuccess) {
<div class="success-message">{{ depositSuccess }}</div>
}
@if (depositQuoteState === 'UNPAID') {
<div class="invoice-text">{{ depositInvoice }}</div>
<button class="copy-btn" (click)="copyDepositInvoice()">
Copy Invoice
</button>
}
</div>
}
</div>
}
<!-- Lightning wallet list -->
@else if (activeSection === 'lightning') {
<div class="lightning-section">
@if (connections.length === 0) {
<div class="empty-state">
<span class="sam-text-muted">
No wallets connected yet.
</span>
</div>
} @else {
<div class="wallet-list">
@for (conn of connections; track conn.id) {
<button class="wallet-list-item" (click)="selectConnection(conn.id)">
<span class="wallet-name">{{ conn.name }}</span>
<span class="wallet-balance">{{ formatBalance(conn.cachedBalance) }} sats</span>
</button>
}
</div>
}
<button class="add-wallet-btn" (click)="showAddConnection()">
<span class="emoji">+</span>
<span>Add NWC Connection</span>
</button>
</div>
}
<!-- Lightning wallet detail -->
@else if (activeSection === 'lightning-detail' && selectedConnection) {
<div class="wallet-detail">
<div class="balance-row">
<div class="balance-display compact">
<span class="balance-value">{{ formatBalance(selectedConnection.cachedBalance) }}</span>
<span class="balance-unit">sats</span>
</div>
<button class="refresh-icon-btn" (click)="refreshWallet()" title="Refresh">
<span class="emoji">🔄</span>
</button>
</div>
<div class="action-buttons">
<button class="action-btn receive-btn" (click)="showLnReceive()">
Receive
</button>
<button class="action-btn send-btn" (click)="showLnPay()">
Pay
</button>
</div>
<div class="wallet-info">
<div class="info-row">
<span class="info-label">Relay</span>
<span class="info-value">{{ selectedConnection.relayUrl }}</span>
</div>
@if (selectedConnection.lud16) {
<button class="info-row-btn" (click)="copyLightningAddress()">
<span class="info-label">Lightning Address</span>
<span class="info-value">
{{ selectedConnection.lud16 }}
<span class="copy-hint">{{ addressCopied ? '✓ Copied' : '(tap to copy)' }}</span>
</span>
</button>
}
</div>
<!-- Transaction History -->
<div class="transaction-section">
<div class="section-title">Transactions</div>
@if (loadingTransactions) {
<div class="loading-text">Loading...</div>
} @else if (transactionsNotSupported) {
<div class="not-supported-text">Transaction history not supported by this wallet</div>
} @else if (transactionsError) {
<div class="error-text">{{ transactionsError }}</div>
} @else if (transactions.length === 0) {
<div class="empty-text">No transactions yet</div>
} @else {
<div class="transaction-list">
@for (tx of transactions; track tx.payment_hash) {
<div class="transaction-item" [class.incoming]="tx.type === 'incoming'" [class.outgoing]="tx.type === 'outgoing'">
<span class="tx-icon">{{ tx.type === 'incoming' ? '⬇' : '⬆' }}</span>
<span class="tx-type">{{ tx.type === 'incoming' ? 'Received' : 'Sent' }}</span>
<span class="tx-amount">{{ formatBalance(tx.amount) }}</span>
<span class="tx-time">{{ formatTransactionTime(tx.created_at) }}</span>
</div>
}
</div>
}
</div>
<button class="delete-btn-small" (click)="deleteConnection()">
Delete Wallet
</button>
</div>
}
<!-- Lightning receive invoice -->
@else if (activeSection === 'lightning-receive' && selectedConnection) {
<div class="add-wallet-form">
<div class="form-group">
<label for="lnReceiveAmount">Amount (sats)</label>
<input
id="lnReceiveAmount"
type="number"
[(ngModel)]="lnReceiveAmount"
placeholder="1000"
min="1"
[disabled]="generatingInvoice"
/>
</div>
<div class="form-group">
<label for="lnReceiveDescription">Description (optional)</label>
<input
id="lnReceiveDescription"
type="text"
[(ngModel)]="lnReceiveDescription"
placeholder="Payment for..."
[disabled]="generatingInvoice"
/>
</div>
@if (lnReceiveError) {
<div class="error-message">{{ lnReceiveError }}</div>
}
@if (!generatedInvoice) {
<div class="form-actions">
<button
class="add-btn full-width"
(click)="createReceiveInvoice()"
[disabled]="generatingInvoice || lnReceiveAmount <= 0"
>
{{ generatingInvoice ? 'Generating...' : 'Generate Invoice' }}
</button>
</div>
}
@if (generatedInvoice) {
<div class="invoice-result">
@if (generatedInvoiceQr) {
<img [src]="generatedInvoiceQr" alt="Invoice QR Code" class="qr-code" />
}
<div class="invoice-text">{{ generatedInvoice }}</div>
<button class="copy-btn" (click)="copyInvoice()">
{{ invoiceCopied ? 'Copied!' : 'Copy Invoice' }}
</button>
</div>
}
</div>
}
<!-- Pay Modal Overlay -->
@if (showPayModal && selectedConnection) {
<div class="modal-overlay" role="dialog" aria-modal="true" tabindex="-1" (click)="closePayModal()" (keydown.escape)="closePayModal()">
<div class="modal-content" role="document" (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()">
<div class="modal-header">
<span>Pay Invoice</span>
<button class="modal-close" (click)="closePayModal()">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="payInput">Lightning Address or Invoice</label>
<textarea
id="payInput"
[(ngModel)]="payInput"
placeholder="user@domain.com or lnbc1..."
rows="3"
[disabled]="paying"
></textarea>
</div>
<div class="form-group">
<label for="payAmount">Amount (sats) - required for addresses</label>
<input
id="payAmount"
type="number"
[(ngModel)]="payAmount"
placeholder="Optional for invoices"
min="1"
[disabled]="paying"
/>
</div>
@if (paymentError) {
<div class="error-message">{{ paymentError }}</div>
}
@if (paymentSuccess) {
<div class="success-message payment-success">Payment Successful!</div>
}
@if (!paymentSuccess) {
<div class="form-actions">
<button class="test-btn" (click)="closePayModal()" [disabled]="paying">
Cancel
</button>
<button
class="add-btn"
(click)="payInvoiceOrAddress()"
[disabled]="paying || !payInput.trim()"
>
{{ paying ? 'Paying...' : 'Pay' }}
</button>
</div>
}
</div>
</div>
</div>
}
<!-- Add wallet form -->
@else if (activeSection === 'lightning-add') {
<div class="add-wallet-form">
<div class="form-group">
<label for="walletName">Wallet Name</label>
<input
id="walletName"
type="text"
[(ngModel)]="newWalletName"
placeholder="My Lightning Wallet"
[disabled]="addingConnection"
/>
</div>
<div class="form-group">
<label for="walletUrl">NWC Connection URL</label>
<textarea
id="walletUrl"
[(ngModel)]="newWalletUrl"
placeholder="nostr+walletconnect://..."
rows="3"
[disabled]="addingConnection"
></textarea>
</div>
@if (connectionError) {
<div class="error-message">{{ connectionError }}</div>
}
@if (connectionTestResult) {
<div class="success-message">{{ connectionTestResult }}</div>
}
@if (nwcService.logs.length > 0) {
<div class="nwc-log">
<div class="log-header">
<span>Connection Log</span>
<button class="log-clear-btn" (click)="nwcService.clearLogs()">Clear</button>
</div>
<div class="log-entries">
@for (entry of nwcService.logs; track entry.timestamp) {
<div class="log-entry" [class.log-warn]="entry.level === 'warn'" [class.log-error]="entry.level === 'error'">
<span class="log-time">{{ entry.timestamp | date:'HH:mm:ss' }}</span>
<span class="log-message">{{ entry.message }}</span>
</div>
}
</div>
</div>
}
<div class="form-actions">
<button
class="test-btn"
(click)="testConnection()"
[disabled]="testingConnection || addingConnection"
>
{{ testingConnection ? 'Testing...' : 'Test Connection' }}
</button>
<button
class="add-btn"
(click)="addConnection()"
[disabled]="addingConnection"
>
{{ addingConnection ? 'Adding...' : 'Add Wallet' }}
</button>
</div>
</div>
}
</div>

View File

@@ -1,21 +1,951 @@
import { Component, inject } from '@angular/core';
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, StorageService } from '@common';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import {
LoggerService,
NavComponent,
NwcService,
NwcConnection_DECRYPTED,
CashuService,
CashuMint_DECRYPTED,
CashuProof,
NwcLookupInvoiceResult,
BrowserSyncFlow,
} from '@common';
import * as QRCode from 'qrcode';
type WalletSection =
| 'main'
| 'cashu'
| 'cashu-detail'
| 'cashu-add'
| 'cashu-receive'
| 'cashu-send'
| 'cashu-mint'
| 'lightning'
| 'lightning-detail'
| 'lightning-add'
| 'lightning-receive'
| 'lightning-pay';
@Component({
selector: 'app-wallet',
templateUrl: './wallet.component.html',
styleUrl: './wallet.component.scss',
imports: [],
imports: [CommonModule, FormsModule],
})
export class WalletComponent {
export class WalletComponent extends NavComponent implements OnInit, OnDestroy {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly nwcService = inject(NwcService);
readonly cashuService = inject(CashuService);
activeSection: WalletSection = 'main';
selectedConnectionId: string | null = null;
selectedMintId: string | null = null;
// Form fields for adding new NWC connection
newWalletName = '';
newWalletUrl = '';
addingConnection = false;
testingConnection = false;
connectionError = '';
connectionTestResult = '';
// Form fields for adding new Cashu mint
newMintName = '';
newMintUrl = '';
addingMint = false;
testingMint = false;
mintError = '';
mintTestResult = '';
// Cashu receive/send fields
receiveToken = '';
receivingToken = false;
receiveError = '';
receiveResult = '';
sendAmount = 0;
sendingToken = false;
sendError = '';
sendResult = '';
// Cashu mint (deposit) fields
depositAmount = 0;
creatingDepositQuote = false;
depositQuoteId = '';
depositInvoice = '';
depositInvoiceQr = '';
depositError = '';
depositSuccess = '';
checkingDepositPayment = false;
depositQuoteState: 'UNPAID' | 'PAID' | 'ISSUED' = 'UNPAID';
private depositPollingInterval: ReturnType<typeof setInterval> | null = null;
// Loading states
loadingBalances = false;
balanceError = '';
// Lightning transaction history
transactions: NwcLookupInvoiceResult[] = [];
loadingTransactions = false;
transactionsError = '';
transactionsNotSupported = false;
// Lightning receive
lnReceiveAmount = 0;
lnReceiveDescription = '';
generatingInvoice = false;
generatedInvoice = '';
generatedInvoiceQr = '';
lnReceiveError = '';
invoiceCopied = false;
// Lightning pay
showPayModal = false;
payInput = '';
payAmount = 0;
paying = false;
paymentSuccess = false;
paymentError = '';
// Clipboard feedback
addressCopied = false;
// Cashu onboarding info
showCashuInfo = true;
currentSyncFlow: BrowserSyncFlow = BrowserSyncFlow.NO_SYNC;
readonly BrowserSyncFlow = BrowserSyncFlow; // Expose enum to template
readonly browserDownloadSettingsUrl = 'about:preferences#general';
// Cashu mint refresh
refreshingMint = false;
refreshError = '';
get title(): string {
switch (this.activeSection) {
case 'cashu':
return 'Cashu';
case 'cashu-detail':
return this.selectedMint?.name ?? 'Mint';
case 'cashu-add':
return 'Add Mint';
case 'cashu-receive':
return 'Receive';
case 'cashu-send':
return 'Send';
case 'cashu-mint':
return 'Deposit';
case 'lightning':
return 'Lightning';
case 'lightning-detail':
return this.selectedConnection?.name ?? 'Wallet';
case 'lightning-add':
return 'Add Wallet';
case 'lightning-receive':
return 'Receive';
case 'lightning-pay':
return 'Pay';
default:
return 'Wallet';
}
}
get showBackButton(): boolean {
return this.activeSection !== 'main';
}
get connections(): NwcConnection_DECRYPTED[] {
return this.nwcService.getConnections();
}
get selectedConnection(): NwcConnection_DECRYPTED | undefined {
if (!this.selectedConnectionId) return undefined;
return this.nwcService.getConnection(this.selectedConnectionId);
}
get totalLightningBalance(): number {
return this.nwcService.getCachedTotalBalance();
}
get mints(): CashuMint_DECRYPTED[] {
return this.cashuService.getMints();
}
get selectedMint(): CashuMint_DECRYPTED | undefined {
if (!this.selectedMintId) return undefined;
return this.cashuService.getMint(this.selectedMintId);
}
get totalCashuBalance(): number {
return this.cashuService.getCachedTotalBalance();
}
get selectedMintBalance(): number {
if (!this.selectedMintId) return 0;
return this.cashuService.getBalance(this.selectedMintId);
}
get selectedMintProofs(): CashuProof[] {
if (!this.selectedMintId) return [];
return this.cashuService.getProofs(this.selectedMintId);
}
ngOnInit(): void {
// Load current sync flow setting
this.currentSyncFlow = this.storage.getSyncFlow();
// Refresh balances on init if we have connections
if (this.connections.length > 0) {
this.refreshAllBalances();
}
}
ngOnDestroy(): void {
this.nwcService.disconnectAll();
this.stopDepositPolling();
}
setSection(section: WalletSection) {
this.activeSection = section;
this.connectionError = '';
this.connectionTestResult = '';
}
goBack() {
switch (this.activeSection) {
case 'lightning-detail':
case 'lightning-add':
this.activeSection = 'lightning';
this.selectedConnectionId = null;
this.resetAddForm();
this.resetLightningForms();
break;
case 'lightning-receive':
case 'lightning-pay':
this.activeSection = 'lightning-detail';
this.resetLightningForms();
break;
case 'cashu-detail':
case 'cashu-add':
this.activeSection = 'cashu';
this.selectedMintId = null;
this.resetAddMintForm();
break;
case 'cashu-receive':
case 'cashu-send':
case 'cashu-mint':
this.activeSection = 'cashu-detail';
this.resetReceiveSendForm();
this.resetDepositForm();
break;
case 'lightning':
case 'cashu':
this.activeSection = 'main';
break;
}
}
selectConnection(connectionId: string) {
this.selectedConnectionId = connectionId;
this.activeSection = 'lightning-detail';
this.loadTransactions(connectionId);
}
private resetLightningForms() {
this.lnReceiveAmount = 0;
this.lnReceiveDescription = '';
this.generatingInvoice = false;
this.generatedInvoice = '';
this.generatedInvoiceQr = '';
this.lnReceiveError = '';
this.invoiceCopied = false;
this.payInput = '';
this.payAmount = 0;
this.paying = false;
this.paymentSuccess = false;
this.paymentError = '';
this.showPayModal = false;
}
showAddConnection() {
this.resetAddForm();
this.activeSection = 'lightning-add';
}
private resetAddForm() {
this.newWalletName = '';
this.newWalletUrl = '';
this.connectionError = '';
this.connectionTestResult = '';
this.addingConnection = false;
this.testingConnection = false;
}
async testConnection() {
if (!this.newWalletUrl.trim()) {
this.connectionError = 'Please enter an NWC URL';
return;
}
this.testingConnection = true;
this.connectionError = '';
this.connectionTestResult = '';
this.nwcService.clearLogs();
try {
const info = await this.nwcService.testConnection(this.newWalletUrl);
this.connectionTestResult = `Connected! ${info.alias ? 'Wallet: ' + info.alias : ''}`;
// Hide logs on success
this.nwcService.clearLogs();
} catch (error) {
this.connectionError =
error instanceof Error ? error.message : 'Connection test failed';
// Keep logs visible on failure for debugging
} finally {
this.testingConnection = false;
}
}
async addConnection() {
if (!this.newWalletName.trim()) {
this.connectionError = 'Please enter a wallet name';
return;
}
if (!this.newWalletUrl.trim()) {
this.connectionError = 'Please enter an NWC URL';
return;
}
this.addingConnection = true;
this.connectionError = '';
try {
await this.nwcService.addConnection(
this.newWalletName.trim(),
this.newWalletUrl.trim()
);
// Refresh the balance for the new connection
const connections = this.nwcService.getConnections();
const newConnection = connections[connections.length - 1];
if (newConnection) {
try {
await this.nwcService.getBalance(newConnection.id);
} catch {
// Ignore balance fetch error
}
}
this.goBack();
} catch (error) {
this.connectionError =
error instanceof Error ? error.message : 'Failed to add connection';
} finally {
this.addingConnection = false;
}
}
async deleteConnection() {
if (!this.selectedConnectionId) return;
const connection = this.selectedConnection;
if (
!confirm(`Delete wallet "${connection?.name}"? This cannot be undone.`)
) {
return;
}
try {
await this.nwcService.deleteConnection(this.selectedConnectionId);
this.goBack();
} catch (error) {
console.error('Failed to delete connection:', error);
}
}
// Cashu methods
selectMint(mintId: string) {
this.selectedMintId = mintId;
this.activeSection = 'cashu-detail';
// Auto-refresh to check for spent proofs
this.refreshMint();
}
async refreshMint() {
if (!this.selectedMintId || this.refreshingMint) return;
this.refreshingMint = true;
this.refreshError = '';
try {
const removedAmount = await this.cashuService.checkProofsSpent(this.selectedMintId);
if (removedAmount > 0) {
// Balance was updated, proofs were spent
console.log(`Removed ${removedAmount} sats of spent proofs`);
}
} catch (error) {
this.refreshError = error instanceof Error ? error.message : 'Failed to refresh';
console.error('Failed to refresh mint:', error);
} finally {
this.refreshingMint = false;
}
}
showAddMint() {
this.resetAddMintForm();
this.activeSection = 'cashu-add';
}
showReceive() {
this.resetReceiveSendForm();
this.activeSection = 'cashu-receive';
}
showSend() {
this.resetReceiveSendForm();
this.activeSection = 'cashu-send';
}
private resetAddMintForm() {
this.newMintName = '';
this.newMintUrl = '';
this.mintError = '';
this.mintTestResult = '';
this.addingMint = false;
this.testingMint = false;
}
private resetReceiveSendForm() {
this.receiveToken = '';
this.receivingToken = false;
this.receiveError = '';
this.receiveResult = '';
this.sendAmount = 0;
this.sendingToken = false;
this.sendError = '';
this.sendResult = '';
}
private resetDepositForm() {
this.depositAmount = 0;
this.creatingDepositQuote = false;
this.depositQuoteId = '';
this.depositInvoice = '';
this.depositInvoiceQr = '';
this.depositError = '';
this.depositSuccess = '';
this.checkingDepositPayment = false;
this.depositQuoteState = 'UNPAID';
this.stopDepositPolling();
}
private stopDepositPolling() {
if (this.depositPollingInterval) {
clearInterval(this.depositPollingInterval);
this.depositPollingInterval = null;
}
}
async testMint() {
if (!this.newMintUrl.trim()) {
this.mintError = 'Please enter a mint URL';
return;
}
this.testingMint = true;
this.mintError = '';
this.mintTestResult = '';
try {
const info = await this.cashuService.testMintConnection(
this.newMintUrl.trim()
);
this.mintTestResult = `Connected! ${info.name ? 'Mint: ' + info.name : ''}`;
} catch (error) {
this.mintError =
error instanceof Error ? error.message : 'Connection test failed';
} finally {
this.testingMint = false;
}
}
async addMint() {
if (!this.newMintName.trim()) {
this.mintError = 'Please enter a mint name';
return;
}
if (!this.newMintUrl.trim()) {
this.mintError = 'Please enter a mint URL';
return;
}
this.addingMint = true;
this.mintError = '';
try {
await this.cashuService.addMint(
this.newMintName.trim(),
this.newMintUrl.trim()
);
this.goBack();
} catch (error) {
this.mintError =
error instanceof Error ? error.message : 'Failed to add mint';
} finally {
this.addingMint = false;
}
}
async deleteMint() {
if (!this.selectedMintId) return;
const mint = this.selectedMint;
if (!confirm(`Delete mint "${mint?.name}"? Any tokens stored will be lost. This cannot be undone.`)) {
return;
}
try {
await this.cashuService.deleteMint(this.selectedMintId);
this.goBack();
} catch (error) {
console.error('Failed to delete mint:', error);
}
}
async receiveTokens() {
if (!this.receiveToken.trim()) {
this.receiveError = 'Please paste a Cashu token';
return;
}
this.receivingToken = true;
this.receiveError = '';
this.receiveResult = '';
try {
const result = await this.cashuService.receive(this.receiveToken.trim());
this.receiveResult = `Received ${result.amount} sats!`;
this.receiveToken = '';
} catch (error) {
this.receiveError =
error instanceof Error ? error.message : 'Failed to receive token';
} finally {
this.receivingToken = false;
}
}
async sendTokens() {
if (!this.selectedMintId) return;
if (this.sendAmount <= 0) {
this.sendError = 'Please enter a valid amount';
return;
}
const balance = this.selectedMintBalance;
if (this.sendAmount > balance) {
this.sendError = `Insufficient balance. You have ${balance} sats`;
return;
}
this.sendingToken = true;
this.sendError = '';
this.sendResult = '';
try {
const result = await this.cashuService.send(
this.selectedMintId,
this.sendAmount
);
this.sendResult = result.token;
this.sendAmount = 0;
} catch (error) {
this.sendError =
error instanceof Error ? error.message : 'Failed to create token';
} finally {
this.sendingToken = false;
}
}
copyToken() {
if (this.sendResult) {
navigator.clipboard.writeText(this.sendResult);
}
}
async checkProofs() {
if (!this.selectedMintId) return;
try {
const removedAmount = await this.cashuService.checkProofsSpent(
this.selectedMintId
);
if (removedAmount > 0) {
alert(`Removed ${removedAmount} sats of spent proofs.`);
} else {
alert('All proofs are valid.');
}
} catch (error) {
console.error('Failed to check proofs:', error);
}
}
// Cashu deposit (mint) methods
showDeposit() {
this.resetDepositForm();
this.activeSection = 'cashu-mint';
}
async createDepositInvoice() {
if (!this.selectedMintId) return;
if (this.depositAmount <= 0) {
this.depositError = 'Please enter an amount';
return;
}
this.creatingDepositQuote = true;
this.depositError = '';
this.depositInvoice = '';
this.depositInvoiceQr = '';
try {
const quote = await this.cashuService.createMintQuote(
this.selectedMintId,
this.depositAmount
);
this.depositQuoteId = quote.quoteId;
this.depositInvoice = quote.invoice;
this.depositQuoteState = quote.state;
// Generate QR code
this.depositInvoiceQr = await QRCode.toDataURL(quote.invoice, {
width: 200,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
});
// Start polling for payment
this.startDepositPolling();
} catch (error) {
this.depositError =
error instanceof Error ? error.message : 'Failed to create invoice';
} finally {
this.creatingDepositQuote = false;
}
}
private startDepositPolling() {
// Poll every 3 seconds for payment confirmation
this.depositPollingInterval = setInterval(async () => {
await this.checkDepositPayment();
}, 3000);
}
async checkDepositPayment() {
if (!this.selectedMintId || !this.depositQuoteId) return;
this.checkingDepositPayment = true;
try {
const quote = await this.cashuService.checkMintQuote(
this.selectedMintId,
this.depositQuoteId
);
this.depositQuoteState = quote.state;
if (quote.state === 'PAID') {
// Invoice is paid, claim the tokens
this.stopDepositPolling();
await this.claimDepositTokens();
} else if (quote.state === 'ISSUED') {
// Already claimed
this.stopDepositPolling();
this.depositSuccess = 'Tokens already claimed!';
}
} catch (error) {
// Don't show error for polling failures, just log
console.error('Failed to check payment:', error);
} finally {
this.checkingDepositPayment = false;
}
}
async claimDepositTokens() {
if (!this.selectedMintId || !this.depositQuoteId) return;
try {
const result = await this.cashuService.mintTokens(
this.selectedMintId,
this.depositAmount,
this.depositQuoteId
);
this.depositSuccess = `Received ${result.amount} sats!`;
this.depositQuoteState = 'ISSUED';
} catch (error) {
this.depositError =
error instanceof Error ? error.message : 'Failed to claim tokens';
}
}
async copyDepositInvoice() {
if (this.depositInvoice) {
await navigator.clipboard.writeText(this.depositInvoice);
}
}
formatCashuBalance(sats: number | undefined): string {
return this.cashuService.formatBalance(sats);
}
async refreshBalance(connectionId: string) {
try {
await this.nwcService.getBalance(connectionId);
} catch (error) {
console.error('Failed to refresh balance:', error);
}
}
async refreshAllBalances() {
this.loadingBalances = true;
this.balanceError = '';
try {
await this.nwcService.getAllBalances();
} catch {
this.balanceError = 'Failed to refresh some balances';
} finally {
this.loadingBalances = false;
}
}
formatBalance(millisats: number | undefined): string {
if (millisats === undefined) return '—';
// Convert millisats to sats with 3 decimal places
const sats = millisats / 1000;
return sats.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 3,
});
}
// Lightning transaction methods
async loadTransactions(connectionId: string) {
this.loadingTransactions = true;
this.transactionsError = '';
this.transactionsNotSupported = false;
try {
this.transactions = await this.nwcService.listTransactions(connectionId, {
limit: 20,
});
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
if (errorMsg.includes('NOT_IMPLEMENTED') || errorMsg.includes('not supported')) {
this.transactionsNotSupported = true;
} else {
this.transactionsError = errorMsg;
}
this.transactions = [];
} finally {
this.loadingTransactions = false;
}
}
async refreshWallet() {
if (!this.selectedConnectionId) return;
// Refresh balance and transactions in parallel
await Promise.all([
this.refreshBalance(this.selectedConnectionId),
this.loadTransactions(this.selectedConnectionId),
]);
}
showLnReceive() {
this.resetLightningForms();
this.activeSection = 'lightning-receive';
}
showLnPay() {
this.resetLightningForms();
this.showPayModal = true;
}
closePayModal() {
this.showPayModal = false;
this.resetLightningForms();
}
async createReceiveInvoice() {
if (!this.selectedConnectionId) return;
if (this.lnReceiveAmount <= 0) {
this.lnReceiveError = 'Please enter an amount';
return;
}
this.generatingInvoice = true;
this.lnReceiveError = '';
this.generatedInvoice = '';
this.generatedInvoiceQr = '';
try {
const result = await this.nwcService.makeInvoice(
this.selectedConnectionId,
this.lnReceiveAmount * 1000, // Convert sats to millisats
this.lnReceiveDescription || undefined
);
this.generatedInvoice = result.invoice;
// Generate QR code
this.generatedInvoiceQr = await QRCode.toDataURL(result.invoice, {
width: 200,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
});
} catch (error) {
this.lnReceiveError =
error instanceof Error ? error.message : 'Failed to create invoice';
} finally {
this.generatingInvoice = false;
}
}
async copyInvoice() {
if (this.generatedInvoice) {
await navigator.clipboard.writeText(this.generatedInvoice);
this.invoiceCopied = true;
setTimeout(() => (this.invoiceCopied = false), 2000);
}
}
async copyLightningAddress() {
const lud16 = this.selectedConnection?.lud16;
if (lud16) {
await navigator.clipboard.writeText(lud16);
this.addressCopied = true;
setTimeout(() => (this.addressCopied = false), 2000);
}
}
async payInvoiceOrAddress() {
if (!this.selectedConnectionId || !this.payInput.trim()) {
this.paymentError = 'Please enter a lightning address or invoice';
return;
}
this.paying = true;
this.paymentError = '';
this.paymentSuccess = false;
try {
let invoice = this.payInput.trim();
// Check if it's a lightning address
if (this.nwcService.isLightningAddress(invoice)) {
if (this.payAmount <= 0) {
this.paymentError = 'Please enter an amount for lightning address payments';
this.paying = false;
return;
}
// Resolve lightning address to invoice
invoice = await this.nwcService.resolveLightningAddress(
invoice,
this.payAmount * 1000 // Convert sats to millisats
);
}
// Pay the invoice
await this.nwcService.payInvoice(
this.selectedConnectionId,
invoice,
this.payAmount > 0 ? this.payAmount * 1000 : undefined
);
this.paymentSuccess = true;
// Refresh balance and transactions after payment
await this.refreshWallet();
// Close modal after a delay
setTimeout(() => {
this.closePayModal();
}, 2000);
} catch (error) {
this.paymentError =
error instanceof Error ? error.message : 'Payment failed';
} finally {
this.paying = false;
}
}
formatTransactionTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
}
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
}
formatProofTime(isoTimestamp: string | undefined): string {
if (!isoTimestamp) return '—';
const date = new Date(isoTimestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
}
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
await this.storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
// Cashu onboarding methods
dismissCashuInfo() {
this.showCashuInfo = false;
}
navigateToSettings() {
this.#router.navigateByUrl('/home/settings');
}
}

View File

@@ -37,6 +37,26 @@
<span> Sync OFF</span>
</button>
<div class="storage-info">
<details>
<summary>Important for Cashu wallet users</summary>
<p>
Browser sync storage is limited to ~100KB shared across all data
(identities, permissions, relays, and Cashu tokens).
</p>
<p>
If you plan to use the Cashu ecash wallet with significant balances,
choose <strong>"Sync OFF"</strong> which provides ~5MB of local storage
(enough for ~18,000+ tokens vs ~300-400 with sync).
</p>
<p>
<strong>Note:</strong> Cashu tokens are bearer assets. If you lose your
vault backup, you lose your tokens permanently. Make sure to configure
regular backups.
</p>
</details>
</div>
<div class="sam-flex-grow"></div>
<span class="sam-text-muted sam-text-md sam-mb">

View File

@@ -6,3 +6,41 @@
padding-left: var(--size);
padding-right: var(--size);
}
.storage-info {
margin-top: 1rem;
width: 100%;
details {
background: rgba(255, 193, 7, 0.1);
border: 1px solid var(--warning, #ffc107);
border-radius: 6px;
padding: 0.5rem;
summary {
cursor: pointer;
font-weight: 500;
font-size: 0.9rem;
color: var(--warning, #ffc107);
&:hover {
text-decoration: underline;
}
}
p {
margin: 0.75rem 0 0 0;
font-size: 0.85rem;
line-height: 1.4;
color: var(--text-muted, #6c757d);
&:last-child {
margin-bottom: 0.5rem;
}
strong {
color: var(--text, #212529);
}
}
}
}

View File

@@ -18,7 +18,7 @@ export class WhitelistedAppsComponent extends NavComponent {
@ViewChild('toast') toast!: ToastComponent;
@ViewChild('confirm') confirm!: ConfirmComponent;
readonly storage = inject(StorageService);
override readonly storage = inject(StorageService);
readonly #router = inject(Router);
get whitelistedHosts(): string[] {

View File

@@ -6,16 +6,40 @@ import {
CryptoHelper,
SignerMetaData,
Identity_DECRYPTED,
Identity_ENCRYPTED,
Nip07Method,
Nip07MethodPolicy,
NostrHelper,
Permission_DECRYPTED,
Permission_ENCRYPTED,
Relay_DECRYPTED,
Relay_ENCRYPTED,
NwcConnection_DECRYPTED,
NwcConnection_ENCRYPTED,
CashuMint_DECRYPTED,
CashuMint_ENCRYPTED,
deriveKeyArgon2,
ExtensionMethod,
WeblnMethod,
} from '@common';
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
import { FirefoxMetaHandler } from './app/common/data/firefox-meta-handler';
import browser from 'webextension-polyfill';
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
import { Buffer } from 'buffer';
import browser from 'webextension-polyfill';
// Unlock request/response message types
export interface UnlockRequestMessage {
type: 'unlock-request';
id: string;
password: string;
}
export interface UnlockResponseMessage {
type: 'unlock-response';
id: string;
success: boolean;
error?: string;
}
export const debug = function (message: any) {
const dateString = new Date().toISOString();
@@ -34,7 +58,7 @@ export interface PromptResponseMessage {
}
export interface BackgroundRequestMessage {
method: Nip07Method;
method: ExtensionMethod;
params: any;
host: string;
}
@@ -96,13 +120,9 @@ export const getBrowserSyncData = async function (): Promise<
let browserSyncData: BrowserSyncData | undefined;
if (signerMetaData.syncFlow === BrowserSyncFlow.NO_SYNC) {
browserSyncData = (await browser.storage.local.get(
null
)) as unknown as BrowserSyncData;
browserSyncData = (await browser.storage.local.get(null)) as unknown as BrowserSyncData;
} else if (signerMetaData.syncFlow === BrowserSyncFlow.BROWSER_SYNC) {
browserSyncData = (await browser.storage.sync.get(
null
)) as unknown as BrowserSyncData;
browserSyncData = (await browser.storage.sync.get(null)) as unknown as BrowserSyncData;
}
return browserSyncData;
@@ -201,11 +221,51 @@ export const checkPermissions = function (
return undefined;
};
/**
* Check if a method is a WebLN method
*/
export const isWeblnMethod = function (method: ExtensionMethod): method is WeblnMethod {
return method.startsWith('webln.');
};
/**
* Check WebLN permissions for a host.
* Note: WebLN permissions are NOT tied to identities since the wallet is global.
* For sendPayment, always returns undefined (require user prompt for security).
*/
export const checkWeblnPermissions = function (
browserSessionData: BrowserSessionData,
host: string,
method: WeblnMethod
): boolean | undefined {
// sendPayment ALWAYS requires user approval (security-critical, irreversible)
if (method === 'webln.sendPayment') {
return undefined;
}
// keysend also requires approval
if (method === 'webln.keysend') {
return undefined;
}
// For other WebLN methods, check stored permissions
// WebLN permissions use 'webln' as the identityId
const permissions = browserSessionData.permissions.filter(
(x) => x.identityId === 'webln' && x.host === host && x.method === method
);
if (permissions.length === 0) {
return undefined;
}
return permissions.every((x) => x.methodPolicy === 'allow');
};
export const storePermission = async function (
browserSessionData: BrowserSessionData,
identity: Identity_DECRYPTED,
identity: Identity_DECRYPTED | null,
host: string,
method: Nip07Method,
method: ExtensionMethod,
methodPolicy: Nip07MethodPolicy,
kind?: number
) {
@@ -214,11 +274,14 @@ export const storePermission = async function (
throw new Error(`Could not retrieve sync data`);
}
// For WebLN methods, use 'webln' as identityId since wallet is global
const identityId = identity?.id ?? 'webln';
const permission: Permission_DECRYPTED = {
id: crypto.randomUUID(),
identityId: identity.id,
identityId,
host,
method,
method: method as Nip07Method, // Cast for storage compatibility
methodPolicy,
kind,
};
@@ -377,3 +440,352 @@ const encrypt = async function (
// v1: Use password with PBKDF2
return await CryptoHelper.encrypt(value, sessionData.iv, sessionData.vaultPassword!);
};
// ==========================================
// Unlock Vault Logic (for background script)
// ==========================================
/**
* Decrypt a value using AES-GCM with pre-derived key (v2)
*/
async function decryptV2(
encryptedBase64: string,
ivBase64: string,
keyBase64: string
): Promise<string> {
const keyBytes = Buffer.from(keyBase64, 'base64');
const iv = Buffer.from(ivBase64, 'base64');
const cipherText = Buffer.from(encryptedBase64, 'base64');
const key = await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'AES-GCM' },
false,
['decrypt']
);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
cipherText
);
return new TextDecoder().decode(decrypted);
}
/**
* Decrypt a value using PBKDF2 (v1)
*/
async function decryptV1(
encryptedBase64: string,
ivBase64: string,
password: string
): Promise<string> {
return CryptoHelper.decrypt(encryptedBase64, ivBase64, password);
}
/**
* Generic decrypt function that handles both v1 and v2
*/
async function decryptValue(
encrypted: string,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<string> {
if (isV2) {
return decryptV2(encrypted, iv, keyOrPassword);
}
return decryptV1(encrypted, iv, keyOrPassword);
}
/**
* Parse decrypted value to the desired type
*/
function parseValue(value: string, type: 'string' | 'number' | 'boolean'): any {
switch (type) {
case 'number':
return parseInt(value);
case 'boolean':
return value === 'true';
default:
return value;
}
}
/**
* Decrypt an identity
*/
async function decryptIdentity(
identity: Identity_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<Identity_DECRYPTED> {
return {
id: await decryptValue(identity.id, iv, keyOrPassword, isV2),
nick: await decryptValue(identity.nick, iv, keyOrPassword, isV2),
createdAt: await decryptValue(identity.createdAt, iv, keyOrPassword, isV2),
privkey: await decryptValue(identity.privkey, iv, keyOrPassword, isV2),
};
}
/**
* Decrypt a permission
*/
async function decryptPermission(
permission: Permission_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<Permission_DECRYPTED> {
const decrypted: Permission_DECRYPTED = {
id: await decryptValue(permission.id, iv, keyOrPassword, isV2),
identityId: await decryptValue(permission.identityId, iv, keyOrPassword, isV2),
host: await decryptValue(permission.host, iv, keyOrPassword, isV2),
method: await decryptValue(permission.method, iv, keyOrPassword, isV2) as Nip07Method,
methodPolicy: await decryptValue(permission.methodPolicy, iv, keyOrPassword, isV2) as Nip07MethodPolicy,
};
if (permission.kind) {
decrypted.kind = parseValue(await decryptValue(permission.kind, iv, keyOrPassword, isV2), 'number');
}
return decrypted;
}
/**
* Decrypt a relay
*/
async function decryptRelay(
relay: Relay_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<Relay_DECRYPTED> {
return {
id: await decryptValue(relay.id, iv, keyOrPassword, isV2),
identityId: await decryptValue(relay.identityId, iv, keyOrPassword, isV2),
url: await decryptValue(relay.url, iv, keyOrPassword, isV2),
read: parseValue(await decryptValue(relay.read, iv, keyOrPassword, isV2), 'boolean'),
write: parseValue(await decryptValue(relay.write, iv, keyOrPassword, isV2), 'boolean'),
};
}
/**
* Decrypt an NWC connection
*/
async function decryptNwcConnection(
nwc: NwcConnection_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<NwcConnection_DECRYPTED> {
const decrypted: NwcConnection_DECRYPTED = {
id: await decryptValue(nwc.id, iv, keyOrPassword, isV2),
name: await decryptValue(nwc.name, iv, keyOrPassword, isV2),
connectionUrl: await decryptValue(nwc.connectionUrl, iv, keyOrPassword, isV2),
walletPubkey: await decryptValue(nwc.walletPubkey, iv, keyOrPassword, isV2),
relayUrl: await decryptValue(nwc.relayUrl, iv, keyOrPassword, isV2),
secret: await decryptValue(nwc.secret, iv, keyOrPassword, isV2),
createdAt: await decryptValue(nwc.createdAt, iv, keyOrPassword, isV2),
};
if (nwc.lud16) {
decrypted.lud16 = await decryptValue(nwc.lud16, iv, keyOrPassword, isV2);
}
if (nwc.cachedBalance) {
decrypted.cachedBalance = parseValue(await decryptValue(nwc.cachedBalance, iv, keyOrPassword, isV2), 'number');
}
if (nwc.cachedBalanceAt) {
decrypted.cachedBalanceAt = await decryptValue(nwc.cachedBalanceAt, iv, keyOrPassword, isV2);
}
return decrypted;
}
/**
* Decrypt a Cashu mint
*/
async function decryptCashuMint(
mint: CashuMint_ENCRYPTED,
iv: string,
keyOrPassword: string,
isV2: boolean
): Promise<CashuMint_DECRYPTED> {
const proofsJson = await decryptValue(mint.proofs, iv, keyOrPassword, isV2);
const decrypted: CashuMint_DECRYPTED = {
id: await decryptValue(mint.id, iv, keyOrPassword, isV2),
name: await decryptValue(mint.name, iv, keyOrPassword, isV2),
mintUrl: await decryptValue(mint.mintUrl, iv, keyOrPassword, isV2),
unit: await decryptValue(mint.unit, iv, keyOrPassword, isV2),
createdAt: await decryptValue(mint.createdAt, iv, keyOrPassword, isV2),
proofs: JSON.parse(proofsJson),
};
if (mint.cachedBalance) {
decrypted.cachedBalance = parseValue(await decryptValue(mint.cachedBalance, iv, keyOrPassword, isV2), 'number');
}
if (mint.cachedBalanceAt) {
decrypted.cachedBalanceAt = await decryptValue(mint.cachedBalanceAt, iv, keyOrPassword, isV2);
}
return decrypted;
}
/**
* Handle an unlock request from the unlock popup
*/
export async function handleUnlockRequest(
password: string
): Promise<{ success: boolean; error?: string }> {
try {
debug('handleUnlockRequest: Starting unlock...');
// Check if already unlocked
const existingSession = await getBrowserSessionData();
if (existingSession) {
debug('handleUnlockRequest: Already unlocked');
return { success: true };
}
// Get sync data
const browserSyncData = await getBrowserSyncData();
if (!browserSyncData) {
return { success: false, error: 'No vault data found' };
}
// Verify password
const passwordHash = await CryptoHelper.hash(password);
if (passwordHash !== browserSyncData.vaultHash) {
return { success: false, error: 'Invalid password' };
}
debug('handleUnlockRequest: Password verified');
// Detect vault version
const isV2 = !!browserSyncData.salt;
debug(`handleUnlockRequest: Vault version: ${isV2 ? 'v2' : 'v1'}`);
let keyOrPassword: string;
let vaultKey: string | undefined;
let vaultPassword: string | undefined;
if (isV2) {
// v2: Derive key with Argon2id (~3 seconds)
debug('handleUnlockRequest: Deriving Argon2id key...');
const saltBytes = Buffer.from(browserSyncData.salt!, 'base64');
const keyBytes = await deriveKeyArgon2(password, saltBytes);
vaultKey = Buffer.from(keyBytes).toString('base64');
keyOrPassword = vaultKey;
debug('handleUnlockRequest: Key derived');
} else {
// v1: Use password directly
vaultPassword = password;
keyOrPassword = password;
}
// Decrypt identities
debug('handleUnlockRequest: Decrypting identities...');
const decryptedIdentities: Identity_DECRYPTED[] = [];
for (const identity of browserSyncData.identities) {
const decrypted = await decryptIdentity(identity, browserSyncData.iv, keyOrPassword, isV2);
decryptedIdentities.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedIdentities.length} identities`);
// Decrypt permissions
debug('handleUnlockRequest: Decrypting permissions...');
const decryptedPermissions: Permission_DECRYPTED[] = [];
for (const permission of browserSyncData.permissions) {
try {
const decrypted = await decryptPermission(permission, browserSyncData.iv, keyOrPassword, isV2);
decryptedPermissions.push(decrypted);
} catch (e) {
debug(`handleUnlockRequest: Skipping corrupted permission: ${e}`);
}
}
debug(`handleUnlockRequest: Decrypted ${decryptedPermissions.length} permissions`);
// Decrypt relays
debug('handleUnlockRequest: Decrypting relays...');
const decryptedRelays: Relay_DECRYPTED[] = [];
for (const relay of browserSyncData.relays) {
const decrypted = await decryptRelay(relay, browserSyncData.iv, keyOrPassword, isV2);
decryptedRelays.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedRelays.length} relays`);
// Decrypt NWC connections
debug('handleUnlockRequest: Decrypting NWC connections...');
const decryptedNwcConnections: NwcConnection_DECRYPTED[] = [];
for (const nwc of browserSyncData.nwcConnections ?? []) {
const decrypted = await decryptNwcConnection(nwc, browserSyncData.iv, keyOrPassword, isV2);
decryptedNwcConnections.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedNwcConnections.length} NWC connections`);
// Decrypt Cashu mints
debug('handleUnlockRequest: Decrypting Cashu mints...');
const decryptedCashuMints: CashuMint_DECRYPTED[] = [];
for (const mint of browserSyncData.cashuMints ?? []) {
const decrypted = await decryptCashuMint(mint, browserSyncData.iv, keyOrPassword, isV2);
decryptedCashuMints.push(decrypted);
}
debug(`handleUnlockRequest: Decrypted ${decryptedCashuMints.length} Cashu mints`);
// Decrypt selectedIdentityId
let decryptedSelectedIdentityId: string | null = null;
if (browserSyncData.selectedIdentityId !== null) {
decryptedSelectedIdentityId = await decryptValue(
browserSyncData.selectedIdentityId,
browserSyncData.iv,
keyOrPassword,
isV2
);
}
debug(`handleUnlockRequest: selectedIdentityId: ${decryptedSelectedIdentityId}`);
// Build session data
const browserSessionData: BrowserSessionData = {
vaultPassword: isV2 ? undefined : vaultPassword,
vaultKey: isV2 ? vaultKey : undefined,
iv: browserSyncData.iv,
salt: browserSyncData.salt,
permissions: decryptedPermissions,
identities: decryptedIdentities,
selectedIdentityId: decryptedSelectedIdentityId,
relays: decryptedRelays,
nwcConnections: decryptedNwcConnections,
cashuMints: decryptedCashuMints,
};
// Save session data
debug('handleUnlockRequest: Saving session data...');
await browser.storage.session.set(browserSessionData as unknown as Record<string, unknown>);
debug('handleUnlockRequest: Unlock complete!');
return { success: true };
} catch (error: any) {
debug(`handleUnlockRequest: Error: ${error.message}`);
return { success: false, error: error.message || 'Unlock failed' };
}
}
/**
* Open the unlock popup window
*/
export async function openUnlockPopup(host?: string): Promise<void> {
const width = 375;
const height = 500;
const { top, left } = await getPosition(width, height);
const id = crypto.randomUUID();
let url = `unlock.html?id=${id}`;
if (host) {
url += `&host=${encodeURIComponent(host)}`;
}
await browser.windows.create({
type: 'popup',
url,
height,
width,
top,
left,
});
}

View File

@@ -3,26 +3,106 @@ import {
backgroundLogNip07Action,
backgroundLogPermissionStored,
NostrHelper,
NwcClient,
NwcConnection_DECRYPTED,
WeblnMethod,
Nip07Method,
GetInfoResponse,
SendPaymentResponse,
RequestInvoiceResponse,
} from '@common';
import {
BackgroundRequestMessage,
checkPermissions,
checkWeblnPermissions,
debug,
getBrowserSessionData,
getPosition,
handleUnlockRequest,
isWeblnMethod,
nip04Decrypt,
nip04Encrypt,
nip44Decrypt,
nip44Encrypt,
openUnlockPopup,
PromptResponse,
PromptResponseMessage,
shouldRecklessModeApprove,
signEvent,
storePermission,
UnlockRequestMessage,
UnlockResponseMessage,
} from './background-common';
import browser from 'webextension-polyfill';
import { Buffer } from 'buffer';
// Cache for NWC clients to avoid reconnecting for each request
const nwcClientCache = new Map<string, NwcClient>();
/**
* Get or create an NWC client for a connection
*/
async function getNwcClient(connection: NwcConnection_DECRYPTED): Promise<NwcClient> {
const cached = nwcClientCache.get(connection.id);
if (cached && cached.isConnected()) {
return cached;
}
const client = new NwcClient({
walletPubkey: connection.walletPubkey,
relayUrl: connection.relayUrl,
secret: connection.secret,
});
await client.connect();
nwcClientCache.set(connection.id, client);
return client;
}
/**
* Parse invoice amount from a BOLT11 invoice string
* Returns amount in satoshis, or undefined if no amount specified
*/
function parseInvoiceAmount(invoice: string): number | undefined {
try {
// BOLT11 invoices start with 'ln' followed by network prefix and amount
// Format: ln[network][amount][multiplier]1[data]
// Examples: lnbc1500n1... (1500 sat), lnbc1m1... (0.001 BTC = 100000 sat)
const match = invoice.toLowerCase().match(/^ln(bc|tb|tbs|bcrt)(\d+)([munp])?1/);
if (!match) {
return undefined;
}
const amountStr = match[2];
const multiplier = match[3];
let amount = parseInt(amountStr, 10);
// Apply multiplier (amount is in BTC by default)
switch (multiplier) {
case 'm': // milli-bitcoin (0.001 BTC)
amount = amount * 100000;
break;
case 'u': // micro-bitcoin (0.000001 BTC)
amount = amount * 100;
break;
case 'n': // nano-bitcoin (0.000000001 BTC) = 0.1 sat
amount = Math.floor(amount / 10);
break;
case 'p': // pico-bitcoin (0.000000000001 BTC) = 0.0001 sat
amount = Math.floor(amount / 10000);
break;
default:
// No multiplier means BTC
amount = amount * 100000000;
}
return amount;
} catch {
return undefined;
}
}
type Relays = Record<string, { read: boolean; write: boolean }>;
const openPrompts = new Map<
@@ -33,8 +113,49 @@ const openPrompts = new Map<
}
>();
// Track if unlock popup is already open
let unlockPopupOpen = false;
// Queue of pending NIP-07 requests waiting for unlock
const pendingRequests: {
request: BackgroundRequestMessage;
resolve: (result: any) => void;
reject: (error: any) => void;
}[] = [];
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
debug('Message received');
// Handle unlock request from unlock popup
if ((message as UnlockRequestMessage)?.type === 'unlock-request') {
const unlockReq = message as UnlockRequestMessage;
debug('Processing unlock request');
const result = await handleUnlockRequest(unlockReq.password);
const response: UnlockResponseMessage = {
type: 'unlock-response',
id: unlockReq.id,
success: result.success,
error: result.error,
};
if (result.success) {
unlockPopupOpen = false;
// Process any pending NIP-07 requests
debug(`Processing ${pendingRequests.length} pending requests`);
while (pendingRequests.length > 0) {
const pending = pendingRequests.shift()!;
try {
const pendingResult = await processNip07Request(pending.request);
pending.resolve(pendingResult);
} catch (error) {
pending.reject(error);
}
}
}
return response;
}
const request = message as BackgroundRequestMessage | PromptResponseMessage;
debug(request);
@@ -55,6 +176,36 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
// Vault is locked - open unlock popup and queue the request
const req = request as BackgroundRequestMessage;
debug('Vault locked, opening unlock popup');
if (!unlockPopupOpen) {
unlockPopupOpen = true;
await openUnlockPopup(req.host);
}
// Queue this request to be processed after unlock
return new Promise((resolve, reject) => {
pendingRequests.push({ request: req, resolve, reject });
});
}
// Process the request (NIP-07 or WebLN)
const req = request as BackgroundRequestMessage;
if (isWeblnMethod(req.method)) {
return processWeblnRequest(req);
}
return processNip07Request(req);
});
/**
* Process a NIP-07 request after vault is unlocked
*/
async function processNip07Request(req: BackgroundRequestMessage): Promise<any> {
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
throw new Error('Plebeian Signer vault not unlocked by the user.');
}
@@ -67,8 +218,6 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
throw new Error('No Nostr identity available at endpoint.');
}
const req = request as BackgroundRequestMessage;
// Check reckless mode first
const recklessApprove = await shouldRecklessModeApprove(req.host);
debug(`recklessApprove result: ${recklessApprove}`);
@@ -80,7 +229,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
browserSessionData,
currentIdentity,
req.host,
req.method,
req.method as Nip07Method,
req.params
);
debug(`permissionState result: ${permissionState}`);
@@ -212,4 +361,147 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
default:
throw new Error(`Not supported request method '${req.method}'.`);
}
});
}
/**
* Process a WebLN request after vault is unlocked
*/
async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any> {
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
throw new Error('Plebeian Signer vault not unlocked by the user.');
}
const nwcConnections = browserSessionData.nwcConnections ?? [];
const method = req.method as WeblnMethod;
// webln.enable just checks if NWC is configured
if (method === 'webln.enable') {
if (nwcConnections.length === 0) {
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
}
debug('WebLN enabled');
return { enabled: true }; // Return explicit value (undefined gets filtered by content script)
}
// All other methods require an NWC connection
const defaultConnection = nwcConnections[0];
if (!defaultConnection) {
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
}
// Check reckless mode (but still prompt for payments)
const recklessApprove = await shouldRecklessModeApprove(req.host);
// Check WebLN permissions
const permissionState = recklessApprove && method !== 'webln.sendPayment' && method !== 'webln.keysend'
? true
: checkWeblnPermissions(browserSessionData, req.host, method);
if (permissionState === false) {
throw new Error('Permission denied');
}
if (permissionState === undefined) {
// Ask user for permission
const width = 375;
const height = 600;
const { top, left } = await getPosition(width, height);
// For sendPayment, include the invoice amount in the prompt data
let promptParams = req.params ?? {};
if (method === 'webln.sendPayment' && req.params?.paymentRequest) {
const amountSats = parseInvoiceAmount(req.params.paymentRequest);
promptParams = { ...promptParams, amountSats };
}
const base64Event = Buffer.from(
JSON.stringify(promptParams, undefined, 2)
).toString('base64');
const response = await new Promise<PromptResponse>((resolve, reject) => {
const id = crypto.randomUUID();
openPrompts.set(id, { resolve, reject });
browser.windows.create({
type: 'popup',
url: `prompt.html?method=${method}&host=${req.host}&id=${id}&nick=WebLN&event=${base64Event}`,
height,
width,
top,
left,
});
});
debug(response);
// Store permission for non-payment methods
if ((response === 'approve' || response === 'reject') && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
const policy = response === 'approve' ? 'allow' : 'deny';
await storePermission(
browserSessionData,
null, // WebLN has no identity
req.host,
method,
policy
);
await backgroundLogPermissionStored(req.host, method, policy);
}
if (['reject', 'reject-once'].includes(response)) {
throw new Error('Permission denied');
}
}
// Execute the WebLN method
let result: any;
const client = await getNwcClient(defaultConnection);
switch (method) {
case 'webln.getInfo': {
const info = await client.getInfo();
result = {
node: {
alias: info.alias,
pubkey: info.pubkey,
color: info.color,
},
} as GetInfoResponse;
debug('webln.getInfo result:');
debug(result);
return result;
}
case 'webln.sendPayment': {
const invoice = req.params.paymentRequest;
const payResult = await client.payInvoice({ invoice });
result = { preimage: payResult.preimage } as SendPaymentResponse;
debug('webln.sendPayment result:');
debug(result);
return result;
}
case 'webln.makeInvoice': {
// Convert sats to millisats (NWC uses millisats)
const amountSats = typeof req.params.amount === 'string'
? parseInt(req.params.amount, 10)
: req.params.amount ?? req.params.defaultAmount ?? 0;
const amountMsat = amountSats * 1000;
const invoiceResult = await client.makeInvoice({
amount: amountMsat,
description: req.params.defaultMemo,
});
result = { paymentRequest: invoiceResult.invoice } as RequestInvoiceResponse;
debug('webln.makeInvoice result:');
debug(result);
return result;
}
case 'webln.keysend':
throw new Error('keysend is not yet supported');
default:
throw new Error(`Not supported WebLN method '${method}'.`);
}
}

View File

@@ -5,6 +5,7 @@ import {
} from '@common';
import './app/common/extensions/array';
import browser from 'webextension-polyfill';
import { v4 as uuidv4 } from 'uuid';
//
// Functions
@@ -105,8 +106,12 @@ document.addEventListener('DOMContentLoaded', async () => {
}
newSnapshots.push({
id: uuidv4(),
fileName: file.name,
createdAt: new Date().toISOString(),
data: vault,
identityCount: vault.identities?.length ?? 0,
reason: 'manual',
});
}

View File

@@ -1,11 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Event, EventTemplate } from 'nostr-tools';
import { Nip07Method } from '@common';
import { Event as NostrEvent, EventTemplate } from 'nostr-tools';
import { ExtensionMethod } from '@common';
// Extend Window interface for NIP-07
// Extend Window interface for NIP-07 and WebLN
declare global {
interface Window {
nostr?: any;
webln?: any;
}
}
@@ -38,7 +39,7 @@ class Messenger {
window.addEventListener('message', this.#handleCallResponse.bind(this));
}
async request(method: Nip07Method, params: any): Promise<any> {
async request(method: ExtensionMethod, params: any): Promise<any> {
const id = generateUUID();
return new Promise((resolve, reject) => {
@@ -89,7 +90,7 @@ const nostr = {
return pubkey;
},
async signEvent(event: EventTemplate): Promise<Event> {
async signEvent(event: EventTemplate): Promise<NostrEvent> {
debug('signEvent received');
const signedEvent = await this.messenger.request('signEvent', event);
debug('signEvent response:');
@@ -158,6 +159,92 @@ const nostr = {
window.nostr = nostr as any;
// WebLN types (inline to avoid build issues with @common types in injected script)
interface RequestInvoiceArgs {
amount?: string | number;
defaultAmount?: string | number;
minimumAmount?: string | number;
maximumAmount?: string | number;
defaultMemo?: string;
}
interface KeysendArgs {
destination: string;
amount: string | number;
customRecords?: Record<string, string>;
}
// Create a shared messenger instance for WebLN
const weblnMessenger = nostr.messenger;
const webln = {
enabled: false,
async enable(): Promise<void> {
debug('webln.enable received');
await weblnMessenger.request('webln.enable', {});
this.enabled = true;
debug('webln.enable completed');
// Dispatch webln:enabled event as per WebLN spec
window.dispatchEvent(new Event('webln:enabled'));
},
async getInfo(): Promise<{ node: { alias?: string; pubkey?: string; color?: string } }> {
debug('webln.getInfo received');
const info = await weblnMessenger.request('webln.getInfo', {});
debug('webln.getInfo response:');
debug(info);
return info;
},
async sendPayment(paymentRequest: string): Promise<{ preimage: string }> {
debug('webln.sendPayment received');
const result = await weblnMessenger.request('webln.sendPayment', { paymentRequest });
debug('webln.sendPayment response:');
debug(result);
return result;
},
async keysend(args: KeysendArgs): Promise<{ preimage: string }> {
debug('webln.keysend received');
const result = await weblnMessenger.request('webln.keysend', args);
debug('webln.keysend response:');
debug(result);
return result;
},
async makeInvoice(
args: string | number | RequestInvoiceArgs
): Promise<{ paymentRequest: string }> {
debug('webln.makeInvoice received');
// Normalize args to RequestInvoiceArgs
let normalizedArgs: RequestInvoiceArgs;
if (typeof args === 'string' || typeof args === 'number') {
normalizedArgs = { amount: args };
} else {
normalizedArgs = args;
}
const result = await weblnMessenger.request('webln.makeInvoice', normalizedArgs);
debug('webln.makeInvoice response:');
debug(result);
return result;
},
signMessage(): Promise<{ message: string; signature: string }> {
throw new Error('signMessage is not supported - NWC does not provide node signing capabilities');
},
verifyMessage(): Promise<void> {
throw new Error('verifyMessage is not supported - NWC does not provide message verification');
},
};
window.webln = webln as any;
// Dispatch webln:ready event to signal that webln is available
// This is dispatched on document as per the WebLN standard
document.dispatchEvent(new Event('webln:ready'));
const debug = function (value: any) {
console.log(JSON.stringify(value));
};

View File

@@ -1,5 +1,5 @@
import browser from 'webextension-polyfill';
import { Nip07Method } from '@common';
import { ExtensionMethod } from '@common';
import { PromptResponse, PromptResponseMessage } from './background-common';
/**
@@ -14,7 +14,7 @@ function base64ToUtf8(base64: string): string {
const params = new URLSearchParams(location.search);
const id = params.get('id') as string;
const method = params.get('method') as Nip07Method;
const method = params.get('method') as ExtensionMethod;
const host = params.get('host') as string;
const nick = params.get('nick') as string;
@@ -58,6 +58,26 @@ switch (method) {
title = 'Get Relays';
break;
case 'webln.enable':
title = 'Enable WebLN';
break;
case 'webln.getInfo':
title = 'Wallet Info';
break;
case 'webln.sendPayment':
title = 'Send Payment';
break;
case 'webln.makeInvoice':
title = 'Create Invoice';
break;
case 'webln.keysend':
title = 'Keysend Payment';
break;
default:
break;
}
@@ -186,6 +206,65 @@ if (cardNip44DecryptElement && card2Nip44DecryptElement) {
}
}
// WebLN card visibility
const cardWeblnEnableElement = document.getElementById('cardWeblnEnable');
if (cardWeblnEnableElement) {
if (method !== 'webln.enable') {
cardWeblnEnableElement.style.display = 'none';
}
}
const cardWeblnGetInfoElement = document.getElementById('cardWeblnGetInfo');
if (cardWeblnGetInfoElement) {
if (method !== 'webln.getInfo') {
cardWeblnGetInfoElement.style.display = 'none';
}
}
const cardWeblnSendPaymentElement = document.getElementById('cardWeblnSendPayment');
const card2WeblnSendPaymentElement = document.getElementById('card2WeblnSendPayment');
if (cardWeblnSendPaymentElement && card2WeblnSendPaymentElement) {
if (method === 'webln.sendPayment') {
// Display amount in sats
const paymentAmountSpan = document.getElementById('paymentAmountSpan');
if (paymentAmountSpan && eventParsed.amountSats !== undefined) {
paymentAmountSpan.innerText = `${eventParsed.amountSats.toLocaleString()} sats`;
} else if (paymentAmountSpan) {
paymentAmountSpan.innerText = 'unknown amount';
}
// Show invoice in json card
const card2WeblnSendPayment_jsonElement = document.getElementById('card2WeblnSendPayment_json');
if (card2WeblnSendPayment_jsonElement && eventParsed.paymentRequest) {
card2WeblnSendPayment_jsonElement.innerText = eventParsed.paymentRequest;
}
} else {
cardWeblnSendPaymentElement.style.display = 'none';
card2WeblnSendPaymentElement.style.display = 'none';
}
}
const cardWeblnMakeInvoiceElement = document.getElementById('cardWeblnMakeInvoice');
if (cardWeblnMakeInvoiceElement) {
if (method === 'webln.makeInvoice') {
const invoiceAmountSpan = document.getElementById('invoiceAmountSpan');
if (invoiceAmountSpan) {
const amount = eventParsed.amount ?? eventParsed.defaultAmount;
if (amount) {
invoiceAmountSpan.innerText = ` for ${Number(amount).toLocaleString()} sats`;
}
}
} else {
cardWeblnMakeInvoiceElement.style.display = 'none';
}
}
const cardWeblnKeysendElement = document.getElementById('cardWeblnKeysend');
if (cardWeblnKeysendElement) {
if (method !== 'webln.keysend') {
cardWeblnKeysendElement.style.display = 'none';
}
}
//
// Functions
//

View File

@@ -0,0 +1,106 @@
import browser from 'webextension-polyfill';
export interface UnlockRequestMessage {
type: 'unlock-request';
id: string;
password: string;
}
export interface UnlockResponseMessage {
type: 'unlock-response';
id: string;
success: boolean;
error?: string;
}
const params = new URLSearchParams(location.search);
const id = params.get('id') as string;
const host = params.get('host');
// Elements
const passwordInput = document.getElementById('passwordInput') as HTMLInputElement;
const togglePasswordBtn = document.getElementById('togglePassword');
const unlockBtn = document.getElementById('unlockBtn') as HTMLButtonElement;
const derivingOverlay = document.getElementById('derivingOverlay');
const errorAlert = document.getElementById('errorAlert');
const errorMessage = document.getElementById('errorMessage');
const hostInfo = document.getElementById('hostInfo');
const hostSpan = document.getElementById('hostSpan');
// Show host info if available
if (host && hostInfo && hostSpan) {
hostSpan.innerText = host;
hostInfo.classList.remove('hidden');
}
// Toggle password visibility
togglePasswordBtn?.addEventListener('click', () => {
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
togglePasswordBtn.innerHTML = '<i class="bi bi-eye-slash"></i>';
} else {
passwordInput.type = 'password';
togglePasswordBtn.innerHTML = '<i class="bi bi-eye"></i>';
}
});
// Enable/disable unlock button based on password input
passwordInput?.addEventListener('input', () => {
unlockBtn.disabled = !passwordInput.value;
});
// Handle enter key
passwordInput?.addEventListener('keyup', (e) => {
if (e.key === 'Enter' && passwordInput.value) {
attemptUnlock();
}
});
// Handle unlock button click
unlockBtn?.addEventListener('click', attemptUnlock);
async function attemptUnlock() {
if (!passwordInput?.value) return;
// Show deriving overlay
derivingOverlay?.classList.remove('hidden');
errorAlert?.classList.add('hidden');
const message: UnlockRequestMessage = {
type: 'unlock-request',
id,
password: passwordInput.value,
};
try {
const response = await browser.runtime.sendMessage(message) as UnlockResponseMessage;
if (response.success) {
// Success - close the window
window.close();
} else {
// Failed - show error
derivingOverlay?.classList.add('hidden');
showError(response.error || 'Invalid password');
}
} catch (error) {
console.error('Failed to send unlock message:', error);
derivingOverlay?.classList.add('hidden');
showError('Failed to unlock vault');
}
}
function showError(message: string) {
if (errorAlert && errorMessage) {
errorMessage.innerText = message;
errorAlert.classList.remove('hidden');
setTimeout(() => {
errorAlert.classList.add('hidden');
}, 3000);
}
}
// Focus password input on load
document.addEventListener('DOMContentLoaded', () => {
passwordInput?.focus();
});

Some files were not shown because too many files have changed in this diff Show More