From 6fb614101558239706c03b5306f06d9fd60edc87 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 12 Sep 2026 00:07:42 +0200 Subject: [PATCH] Initial commit --- BROWSER-PROMPT.md | 76 + DESIGN-integrated-wallet.md | 450 ++ DESIGN-password-manager.md | 209 + DESIGN-wallet-multi-account-amendment.md | 436 ++ GOTCHAS.md | 121 + PACKAGING-PROMPT.md | 91 + PENDING.md | 43 + README.md | 27 + RELEASE-HANDOFF.md | 94 + ROADMAP-identity-wallet.md | 215 + SESSION-PROMPT-identity-wallet.md | 105 + addon-inject-preload.js | 36 + addon-tab-preload.js | 20 + addon-update-pubkeys.js | 21 + addon-updater.js | 290 + addons-host.js | 616 ++ address-picker-preload.js | 9 + address-picker.html | 66 + approval-preload.js | 9 + approval.html | 96 + bcnr-origin.js | 103 + bcnr-preload.js | 27 + bundled-addons/aegis/addon.json | 16 + bundled-addons/aegis/electrum-servers.json | 9 + bundled-addons/aegis/index.js | 1922 ++++++ bundled-addons/aegis/lib/base58check.js | 83 + bundled-addons/aegis/lib/cashaddr.js | 102 + .../aegis/lib/chain-bch-imported.js | 146 + bundled-addons/aegis/lib/chain-bch.js | 156 + bundled-addons/aegis/lib/chain-btc.js | 531 ++ bundled-addons/aegis/lib/chain-dgb.js | 451 ++ bundled-addons/aegis/lib/chain-eth.js | 361 + bundled-addons/aegis/lib/chain-sia.js | 182 + bundled-addons/aegis/lib/chain-sol.js | 473 ++ bundled-addons/aegis/lib/chain-tron.js | 353 + .../aegis/lib/dgb/core/address.d.ts | 10 + bundled-addons/aegis/lib/dgb/core/address.js | 53 + .../aegis/lib/dgb/core/descriptor.d.ts | 5 + .../aegis/lib/dgb/core/descriptor.js | 18 + bundled-addons/aegis/lib/dgb/core/hd.d.ts | 8 + bundled-addons/aegis/lib/dgb/core/hd.js | 24 + bundled-addons/aegis/lib/dgb/core/index.d.ts | 6 + bundled-addons/aegis/lib/dgb/core/index.js | 7 + .../aegis/lib/dgb/core/network.d.ts | 13 + bundled-addons/aegis/lib/dgb/core/network.js | 74 + .../aegis/lib/dgb/core/package.json | 1 + bundled-addons/aegis/lib/dgb/core/seed.d.ts | 4 + bundled-addons/aegis/lib/dgb/core/seed.js | 17 + bundled-addons/aegis/lib/dgb/core/wif.d.ts | 27 + bundled-addons/aegis/lib/dgb/core/wif.js | 72 + bundled-addons/aegis/lib/dgb/psbt/build.d.ts | 4 + bundled-addons/aegis/lib/dgb/psbt/build.js | 56 + bundled-addons/aegis/lib/dgb/psbt/index.d.ts | 3 + bundled-addons/aegis/lib/dgb/psbt/index.js | 4 + .../aegis/lib/dgb/psbt/package.json | 1 + bundled-addons/aegis/lib/dgb/psbt/sign.d.ts | 9 + bundled-addons/aegis/lib/dgb/psbt/sign.js | 25 + bundled-addons/aegis/lib/dgb/psbt/types.d.ts | 23 + bundled-addons/aegis/lib/dgb/psbt/types.js | 2 + bundled-addons/aegis/lib/eip712.js | 143 + bundled-addons/aegis/lib/electrum.js | 131 + bundled-addons/aegis/lib/keys.js | 66 + bundled-addons/aegis/lib/prices.js | 98 + bundled-addons/aegis/lib/sia/keys.js | 29 + bundled-addons/aegis/lib/sia/sia.js | 142 + bundled-addons/aegis/lib/sia/wallet.js | 201 + bundled-addons/aegis/lib/sia/walletd.js | 42 + bundled-addons/aegis/lib/sol-spl.js | 220 + bundled-addons/aegis/lib/tx.js | 122 + bundled-addons/aegis/lib/wallet.js | 243 + bundled-addons/aegis/lib/wc-sign.js | 95 + bundled-addons/aegis/lib/wc.js | 196 + bundled-addons/aegis/panel.html | 420 ++ bundled-addons/aegis/panel.js | 1158 ++++ bundled-addons/aegis/qr.js | 218 + bundled-addons/aegis/wallet-inject.js | 506 ++ bundled-addons/notepad/addon.json | 10 + bundled-addons/notepad/index.js | 13 + bundled-addons/notepad/note.html | 100 + bundled-addons/screenshot/addon.json | 11 + bundled-addons/screenshot/editor.css | 164 + bundled-addons/screenshot/editor.html | 148 + bundled-addons/screenshot/editor.js | 763 +++ bundled-addons/screenshot/index.js | 162 + bundled-addons/screenshot/panel-preload.js | 115 + bundled-addons/screenshot/panel.html | 547 ++ chrome.html | 1556 +++++ collision-preload.js | 7 + collision.html | 170 + dev/README.md | 44 + dev/bcnr-selftest.js | 68 + dev/list-tlds.mjs | 22 + dev/origin-selftest.mjs | 72 + dev/probe-site.mjs | 65 + dev/registry-decide.mjs | 21 + dev/rescheck.mjs | 9 + dev/selftest.js | 82 + dev/show-tlds-bch.mjs | 7 + dev/test-directip.mjs | 32 + dev/test-our-indexer.mjs | 34 + docs/ADDON-UPDATES.md | 138 + downloads-preload.js | 11 + downloads.html | 101 + engine-picker-preload.js | 9 + engine-picker.html | 59 + error-preload.js | 12 + error.html | 253 + home-preload.js | 30 + home.html | 290 + lib/hermes.js | 176 + lib/package.json | 5 + link-status-preload.js | 5 + link-status.html | 31 + main.js | 5145 ++++++++++++++ messages-preload.js | 25 + messages.html | 242 + nsis/make-icons.mjs | 142 + package-lock.json | 5991 +++++++++++++++++ package.json | 146 + popover-preload.js | 9 + popover.html | 131 + preload.js | 99 + pw-fill-preload.js | 6 + pw-fill.html | 41 + scripts/generate-update-keypair.mjs | 41 + scripts/sign-addon-update.mjs | 85 + settings-preload.js | 52 + settings.html | 1401 ++++ sidebar-preload.js | 97 + snapshots/bns-name-snapshot.json | 1 + test-installer.wsb | 14 + 131 files changed, 31451 insertions(+) create mode 100644 BROWSER-PROMPT.md create mode 100644 DESIGN-integrated-wallet.md create mode 100644 DESIGN-password-manager.md create mode 100644 DESIGN-wallet-multi-account-amendment.md create mode 100644 GOTCHAS.md create mode 100644 PACKAGING-PROMPT.md create mode 100644 PENDING.md create mode 100644 README.md create mode 100644 RELEASE-HANDOFF.md create mode 100644 ROADMAP-identity-wallet.md create mode 100644 SESSION-PROMPT-identity-wallet.md create mode 100644 addon-inject-preload.js create mode 100644 addon-tab-preload.js create mode 100644 addon-update-pubkeys.js create mode 100644 addon-updater.js create mode 100644 addons-host.js create mode 100644 address-picker-preload.js create mode 100644 address-picker.html create mode 100644 approval-preload.js create mode 100644 approval.html create mode 100644 bcnr-origin.js create mode 100644 bcnr-preload.js create mode 100644 bundled-addons/aegis/addon.json create mode 100644 bundled-addons/aegis/electrum-servers.json create mode 100644 bundled-addons/aegis/index.js create mode 100644 bundled-addons/aegis/lib/base58check.js create mode 100644 bundled-addons/aegis/lib/cashaddr.js create mode 100644 bundled-addons/aegis/lib/chain-bch-imported.js create mode 100644 bundled-addons/aegis/lib/chain-bch.js create mode 100644 bundled-addons/aegis/lib/chain-btc.js create mode 100644 bundled-addons/aegis/lib/chain-dgb.js create mode 100644 bundled-addons/aegis/lib/chain-eth.js create mode 100644 bundled-addons/aegis/lib/chain-sia.js create mode 100644 bundled-addons/aegis/lib/chain-sol.js create mode 100644 bundled-addons/aegis/lib/chain-tron.js create mode 100644 bundled-addons/aegis/lib/dgb/core/address.d.ts create mode 100644 bundled-addons/aegis/lib/dgb/core/address.js create mode 100644 bundled-addons/aegis/lib/dgb/core/descriptor.d.ts create mode 100644 bundled-addons/aegis/lib/dgb/core/descriptor.js create mode 100644 bundled-addons/aegis/lib/dgb/core/hd.d.ts create mode 100644 bundled-addons/aegis/lib/dgb/core/hd.js create mode 100644 bundled-addons/aegis/lib/dgb/core/index.d.ts create mode 100644 bundled-addons/aegis/lib/dgb/core/index.js create mode 100644 bundled-addons/aegis/lib/dgb/core/network.d.ts create mode 100644 bundled-addons/aegis/lib/dgb/core/network.js create mode 100644 bundled-addons/aegis/lib/dgb/core/package.json create mode 100644 bundled-addons/aegis/lib/dgb/core/seed.d.ts create mode 100644 bundled-addons/aegis/lib/dgb/core/seed.js create mode 100644 bundled-addons/aegis/lib/dgb/core/wif.d.ts create mode 100644 bundled-addons/aegis/lib/dgb/core/wif.js create mode 100644 bundled-addons/aegis/lib/dgb/psbt/build.d.ts create mode 100644 bundled-addons/aegis/lib/dgb/psbt/build.js create mode 100644 bundled-addons/aegis/lib/dgb/psbt/index.d.ts create mode 100644 bundled-addons/aegis/lib/dgb/psbt/index.js create mode 100644 bundled-addons/aegis/lib/dgb/psbt/package.json create mode 100644 bundled-addons/aegis/lib/dgb/psbt/sign.d.ts create mode 100644 bundled-addons/aegis/lib/dgb/psbt/sign.js create mode 100644 bundled-addons/aegis/lib/dgb/psbt/types.d.ts create mode 100644 bundled-addons/aegis/lib/dgb/psbt/types.js create mode 100644 bundled-addons/aegis/lib/eip712.js create mode 100644 bundled-addons/aegis/lib/electrum.js create mode 100644 bundled-addons/aegis/lib/keys.js create mode 100644 bundled-addons/aegis/lib/prices.js create mode 100644 bundled-addons/aegis/lib/sia/keys.js create mode 100644 bundled-addons/aegis/lib/sia/sia.js create mode 100644 bundled-addons/aegis/lib/sia/wallet.js create mode 100644 bundled-addons/aegis/lib/sia/walletd.js create mode 100644 bundled-addons/aegis/lib/sol-spl.js create mode 100644 bundled-addons/aegis/lib/tx.js create mode 100644 bundled-addons/aegis/lib/wallet.js create mode 100644 bundled-addons/aegis/lib/wc-sign.js create mode 100644 bundled-addons/aegis/lib/wc.js create mode 100644 bundled-addons/aegis/panel.html create mode 100644 bundled-addons/aegis/panel.js create mode 100644 bundled-addons/aegis/qr.js create mode 100644 bundled-addons/aegis/wallet-inject.js create mode 100644 bundled-addons/notepad/addon.json create mode 100644 bundled-addons/notepad/index.js create mode 100644 bundled-addons/notepad/note.html create mode 100644 bundled-addons/screenshot/addon.json create mode 100644 bundled-addons/screenshot/editor.css create mode 100644 bundled-addons/screenshot/editor.html create mode 100644 bundled-addons/screenshot/editor.js create mode 100644 bundled-addons/screenshot/index.js create mode 100644 bundled-addons/screenshot/panel-preload.js create mode 100644 bundled-addons/screenshot/panel.html create mode 100644 chrome.html create mode 100644 collision-preload.js create mode 100644 collision.html create mode 100644 dev/README.md create mode 100644 dev/bcnr-selftest.js create mode 100644 dev/list-tlds.mjs create mode 100644 dev/origin-selftest.mjs create mode 100644 dev/probe-site.mjs create mode 100644 dev/registry-decide.mjs create mode 100644 dev/rescheck.mjs create mode 100644 dev/selftest.js create mode 100644 dev/show-tlds-bch.mjs create mode 100644 dev/test-directip.mjs create mode 100644 dev/test-our-indexer.mjs create mode 100644 docs/ADDON-UPDATES.md create mode 100644 downloads-preload.js create mode 100644 downloads.html create mode 100644 engine-picker-preload.js create mode 100644 engine-picker.html create mode 100644 error-preload.js create mode 100644 error.html create mode 100644 home-preload.js create mode 100644 home.html create mode 100644 lib/hermes.js create mode 100644 lib/package.json create mode 100644 link-status-preload.js create mode 100644 link-status.html create mode 100644 main.js create mode 100644 messages-preload.js create mode 100644 messages.html create mode 100644 nsis/make-icons.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 popover-preload.js create mode 100644 popover.html create mode 100644 preload.js create mode 100644 pw-fill-preload.js create mode 100644 pw-fill.html create mode 100644 scripts/generate-update-keypair.mjs create mode 100644 scripts/sign-addon-update.mjs create mode 100644 settings-preload.js create mode 100644 settings.html create mode 100644 sidebar-preload.js create mode 100644 snapshots/bns-name-snapshot.json create mode 100644 test-installer.wsb diff --git a/BROWSER-PROMPT.md b/BROWSER-PROMPT.md new file mode 100644 index 00000000..28ab6f18 --- /dev/null +++ b/BROWSER-PROMPT.md @@ -0,0 +1,76 @@ +# Prompt for a new session: build Theseus, the browser + +Copy everything below the line into a fresh coding-CLI session (suggested +working directory: `D:\Dev\BnsBrowser`). + +--- + +Build **Theseus Navigator** ("Theseus" for short) — a standalone desktop web +browser with native support for decentralized `.bch` domain names, resolved +from the Bitcoin Cash blockchain. Windows 10 first; Electron is the expected +shell (Chromium engine, no forking). + +Branding: *Theseus Navigator, by Silent Mode — a Deviant project*. The name refers to the +thread through the labyrinth: the blockchain is the thread. Read +`D:\Dev\SilentMode\README.md` first for the whole picture. `theseus.bch` is +already registered on chipnet and can serve the browser's own homepage. + +## Context — a working stack already exists + +`D:\Dev\NameCoin` contains a complete, tested prototype (read its README.md, +PROTOCOL.md, and ROADMAP.md first): + +- `src/lib/bns.js` — the resolver core: reads the "BNS1" protocol from BCH + chipnet via electrum (mainnet-js). Names are CashTokens NFTs; records are + JSON in OP_RETURN (`ip`, `u`, `p`, `h`, `s3`, `tls` types); discovery via a + beacon address. Subdomains map through the parent name (see bnsd.js). +- `src/daemon/bnsd.js` — reference gateway: serves `h` (on-chain HTML), `p` + (reverse proxy), `s3` (Sia storage via an S3 endpoint configured in + `sia-s3.json`), redirects `u`, and resolves `ip` records at DNS level. +- `ca/` — a local root CA ("BNS Local Root CA") that signs per-name certs; + fingerprints can be pinned via on-chain `tls` records. +- Live test names on chipnet right now: `hello.bch` (on-chain HTML), + `coinspectrum.bch` (ip → real nginx server), `siatest.bch` and + `coinspectrum.deviant.bch` (Sia-backed, subdomain namespace), + `theseus.bch` and `silentmode.bch` (on-chain HTML, brand pages). + +Reuse `bns.js` as a library (npm file: dependency or copied module) — do not +reimplement the protocol. The browser must NOT require the system daemon, +NRPT rules, or the OS trust store: everything in-process. + +## Requirements + +1. **Chrome-like shell**: tabs, address bar, back/forward/reload, history. + Electron BrowserViews/WebContentsViews per tab. +2. **Native .bch resolution**: typing `hello.bch` or `x.deviant.bch` resolves + via the BNS core inside the app (no OS DNS involvement). Everything else + browses the normal web unchanged. +3. **Record handling**: `h` render directly; `ip` connect to the host (http); + `p`/`s3` fetch via an in-app gateway on a loopback port; `u` redirect. +4. **TLS for .bch without touching the OS store**: intercept at the session + level (Electron `setCertificateVerifyProc`) — accept certs for .bch hosts + when they chain to the BNS root or match the name's on-chain `tls` + fingerprint; normal CA validation for everything else. +5. **UI truthfulness**: a visible indicator when a page came from the chain / + Sia / a direct ip record, showing the NFT category and record type — the + "padlock" for decentralized provenance. +6. **Fallback honesty**: unregistered .bch → a clean NXDOMAIN page naming the + chain; electrum outages → readable error, not a hang. +7. Package with electron-builder into a Windows installer (unsigned is fine). + +## Definition of done + +All four live names above load correctly in tabs alongside normal websites +(e.g. wikipedia.org), with provenance indicators, from a packaged .exe on +Windows 10 — no admin rights, no system daemon, no OS DNS or cert changes. + +## Constraints & notes + +- BCH chipnet; electrum servers as in mainnet-js defaults (see bns.js usage). +- `sia-s3.json` in D:\Dev\NameCoin holds the S3 endpoint + keys for `s3` + records (server-side s3d at https://coinspectrum.duckdns.org:8600) — copy + it into the browser's config directory; do not commit it. +- Wallet/registration UI is OUT of scope (exists in the NameCoin repo's + wallet app); this session is the *viewing* side only. +- Test names may be unconfirmed on chipnet — the resolver handles mempool + entries; height -1 is normal. diff --git a/DESIGN-integrated-wallet.md b/DESIGN-integrated-wallet.md new file mode 100644 index 00000000..bce6114a --- /dev/null +++ b/DESIGN-integrated-wallet.md @@ -0,0 +1,450 @@ +# Theseus integrated BCH wallet — design + +Companion to [`ROADMAP-identity-wallet.md`](ROADMAP-identity-wallet.md) Strand B +and [`DESIGN-password-manager.md`](DESIGN-password-manager.md) (same crypto +discipline, distinct purpose subtree). Same shape as the password design doc: +threat model → permission model → API → UI → sequencing → open questions. + +The reference point is deliberate: **MetaMask in Brave.** A wallet the user +already trusts, injected as `window.bcnr` into every page, permissioned per +origin, and additionally exposed to *outside* dApps via WizardConnect so a +mobile-only site can pair Theseus like a hardware wallet. + +Everything below is chipnet only until explicitly directed otherwise. + +--- + +## 0. Scope + +**In:** +- `window.bcnr` provider injected on every page (read-only immediately; + permissioned methods gated per origin). +- The user's wallet lives in Theseus. No external key material. +- Modal-confirmed operations: `signMessage`, `sendPayment`, `registerName`, + `updateName`, `transferName`. +- WizardConnect **responder**: dApps outside Theseus (or inside it) can pair + via `wiz://` URI + QR to sign transactions with the Theseus wallet. +- Settings → Wallet section: connected sites, per-site/per-method revoke, + transaction history. +- Reuse of the already-shipped `BuiltInWallet` + ([Argus/src/lib/wallet-web.js:BuiltInWallet](../Argus/src/lib/wallet-web.js)) + as the signing core. +- Reuse of the already-shipped registration/update flow + ([Argus/src/lib/register-tx.js](../Argus/src/lib/register-tx.js) + + [Argus/src/lib/registrar.js](../Argus/src/lib/registrar.js)). + +**Out (deliberate, deferred, or handled elsewhere):** +- Ethereum / Solana / other-chain. BCH only in this strand. +- Hardware-wallet abstraction (Ledger/Trezor). Phase 4 conversation. +- WalletConnect v2. Explicitly dropped 2026-07-27 in favour of WizardConnect + (see [`Decentralized.DNS/REGISTRATION-STATUS.md`](../Decentralized.DNS/REGISTRATION-STATUS.md)). + We do not want a Reown project ID as a dependency. +- CashConnect V0. Cannot mint name certificates by design + ([`REGISTRATION-STATUS.md`](../Decentralized.DNS/REGISTRATION-STATUS.md) § + *CashConnect cannot mint*). +- Nostr messenger (NIP-07 client for arbitrary Nostr apps). Separate purpose + subtree, Strand B.5 in the roadmap, not this design. +- Multi-account UX. One account per Theseus profile in v1. + +--- + +## 1. Threat model + +The provider becomes malware infrastructure the moment its permission model +fails. Three attack surfaces to lock down: + +### 1.1 Malicious page reading the provider surface + +Every loaded page sees `window.bcnr`. It must not be possible to enumerate +addresses, balances, or names without an explicit user grant. + +- **Read-only methods** (`resolveName`, `getBcnrTlds`, `isRegistered`) are + chain queries — they leak nothing about the user. No permission. +- **All identifying methods** (`getAccounts`, `signMessage`, `sendPayment`, + `registerName`, `updateName`, `transferName`) require an origin permission + that started with a modal. +- **`getAccounts` returns `[]` for un-granted origins**, not a rejection. + Rejection is a signal; empty is opaque. + +### 1.2 Compromised page after permission granted + +An origin can be captured (XSS, subdomain takeover, malicious ad on a trusted +host) after the user granted a permission. Assume this. + +- **Per-method scope**, not just per-origin. Granting "sign message" does not + grant "send payment". A page has to earn each modal separately. +- **`sendPayment` has a per-approval amount cap.** Approving `example.bch` for + payments does not approve unlimited spending — the modal shows an amount, and + a bigger amount re-prompts. "Approve unlimited" is not a UI option. +- **Every mint / update / transfer is modal-confirmed every time.** On-chain + writes are irrevocable; there is no "trust for the session" mode. + +### 1.3 Origin confusion (phishing, homoglyph, subdomain) + +- Origin is bound to the **eTLD+1 via the public suffix list snapshot** shipped + with Theseus (same rule as password autofill, A.2 in the roadmap). No runtime + fetch, no third-party dependency. +- **BCNR names get a distinct origin tier** in the confirmation modal: + `alice.bch` is a first-class origin (backed by an on-chain certificate), + `alice.com` is a second-class origin (registrar-leased). The modal shows the + distinction so the user knows what they're trusting. +- **The recipient's cash address is shown verbatim in the confirmation.** If + the address changes between two confirmations from the same origin during one + session, a red warning banner appears — "the address this site is asking you + to sign for has changed since your last approval." + +### 1.4 What we are NOT defending against + +- A user manually copying their recovery phrase into a phishing site. No wallet + can defend against this and pretending otherwise misleads users. +- A user who unlocks the wallet on a shared machine and walks away. Auto-lock + helps but is not a substitute. +- Compromise of Theseus itself (RCE, malicious extension, keylogger). The + password vault and the wallet share the same threat model here — game over. + +--- + +## 2. Permission model + +### 2.1 Per-origin, per-method, per-limit + +Stored in `/wallet-permissions.json`: + +```json +{ + "silentmode.bch": { + "grantedAt": 1728000000000, + "methods": { + "signMessage": { "grants": 12 }, + "sendPayment": { "capSats": 100000, "used": 45000, "grants": 3 }, + "registerName": { "grants": 1 } + } + } +} +``` + +- `capSats` is the **per-approval** cap the site was granted, not a cumulative + budget. A payment above it re-prompts with a new modal. +- `used` and `grants` are for the Settings audit trail. The user sees "this + site has spent X and signed Y messages" — accountability. +- **No wildcard origins.** Every subdomain is a separate grant. + +### 2.2 First-use flow + +1. Page calls `bcnr.requestAccount()` (or any permissioned method). +2. Theseus renders a **connect modal** as a `WebContentsView` overlay (same + pattern as popover, engine-picker, downloads). +3. Modal shows: + - Origin, with the BCNR-vs-ICANN badge. + - What is being asked (specific method, specific parameters). + - "Connect to this site with account `bchtest:qxxx…yyy`" (the wallet's + receive address 0, truncated with copy-full option). + - **Two buttons**: `Approve once` (single-shot, no permission stored) and + `Approve for this site` (grant persisted). +4. On approve, the permission is written and the promise resolves. +5. On reject, the promise resolves with the same "empty result" pattern as an + un-granted origin (no distinguishing signal to the page). + +### 2.3 Revocation + +Settings > Wallet > Connected sites. Table: +`origin | granted | last used | scopes | [revoke]`. Per-scope revoke checkboxes, +plus a nuclear "revoke all". Revocation clears `wallet-permissions.json` and +takes effect on the origin's next call (no restart needed). + +### 2.4 What the provider CAN'T do + +- Read the vault, seed, or any other purpose subtree. +- Sign anything without a modal. +- Enumerate connected sites (that's a settings-scope API, not page-scope). +- Watch the wallet balance across origins (each origin sees only its own + granted accounts — usually one). + +--- + +## 3. `window.bcnr` provider — the API + +Injected by a preload extension registered via +`session.defaultSession.setPreloads(...)` so it is present on every page. The +preload uses `contextBridge.exposeInMainWorld("bcnr", …)` and forwards to main +via IPC on a dedicated `bcnr:*` channel. + +### 3.1 Read-only (no permission) + +```ts +bcnr.resolveName(name: string): Promise +bcnr.isRegistered(name: string): Promise +bcnr.getBcnrTlds(): Promise // ['bch','p2p','nav',...] +bcnr.getRecordVersion(name: string): Promise // for cache invalidation +``` + +### 3.2 Identity (permissioned; first call → modal) + +```ts +bcnr.requestAccount(): Promise // returns cashaddr or null +bcnr.getAccounts(): Promise // [] if not granted +bcnr.signMessage({ message: string, address?: string, userPrompt?: string }): + Promise<{ signature: string, address: string }> // BIP-137 +``` + +### 3.3 Payments (permissioned; per-amount modal) + +```ts +bcnr.sendPayment({ + to: string, // cashaddr OR BCNR name resolvable to an `a` record + amountSats: bigint, + memo?: string, + userPrompt?: string, +}): Promise<{ txid: string }> +``` + +### 3.4 Name operations (each modal-confirmed) + +```ts +bcnr.registerName({ name: string, records?: Record, userPrompt?: string }): + Promise<{ txid: string, category: string }> +bcnr.updateName({ name: string, records: Record, userPrompt?: string }): + Promise<{ txid: string }> +bcnr.transferName({ name: string, toAddress: string, userPrompt?: string }): + Promise<{ txid: string }> +``` + +### 3.5 Events + +```ts +bcnr.on('accountsChanged', (accounts: string[]) => void) +bcnr.on('permissionsRevoked', (methods: string[]) => void) +bcnr.on('lock', () => void) // wallet auto-locked +``` + +### 3.6 Match with WizardConnect / WC2 shape where cheap + +`signMessage` returns `{signature, address}` — same shape as +`bch_signMessage` in the wc2-bch-bcr namespace (see +[Parameters/WalletConnect/WalletConnect_integration_reference.md](../../Deviant/Parameters/WalletConnect/WalletConnect_integration_reference.md) +§4.2). Pages that already speak WC2 can adapt with a two-line shim. + +We do NOT re-expose the WC2 wire format (`stringify` with `` +markers) at the provider layer — that is a WC2 transport concern, not a +provider API concern. The provider takes plain values; the WizardConnect +responder (below) is where the wire format matters. + +--- + +## 4. WizardConnect responder + +The MetaMask analogue is "the wallet is here in the browser." The extra thing +we get from WizardConnect is "a dApp on another device — a mobile site, a +desktop app — can pair with the Theseus wallet by scanning a `wiz://` QR." This +is exactly the responder side of what Deviant is finishing +([`Parameters/WalletConnect/WIZARDCONNECT-INTEGRATION-GUIDE.md`](../../Deviant/Parameters/WalletConnect/WIZARDCONNECT-INTEGRATION-GUIDE.md)), +adapted to Theseus. + +### 4.1 Wallet-side pieces + +Same shape as Deviant: + +- `@wizardconnect/wallet@0.2.2` for the SDK; already tested against the + 0.2.4-core wire protocol. `nostr-tools` and `ws` are already in Theseus's + deps (verified 2026-08-13). +- A **`WalletAdapter`** wrapping Theseus's `BuiltInWallet`. Theseus's wallet + is sync-in-main-process behind the unlock modal, which is simpler than + Deviant's async biometric-KeyStore case: + - `walletName: "Theseus"`, `walletIcon: ` + - `getRelayPrivateKey(uri): Uint8Array` = `sha256(sha256(utf8(uri)))` + (identical to Deviant's implementation — deterministic per URI so a + reconnect keeps the same Nostr identity, different dApps cannot + correlate; matches `@wizardconnect/wallet@0.2.2` types) + - `getXpub(path): string` — reads pre-cached xpubs from `BuiltInWallet` + - `getPublicKey(path, index)` — throws; the shipped SDK never calls it + - `signTransaction(request): Promise<{ signedTransaction: string }>` — + unwraps the `WcSignTransactionRequest` (the bug Deviant hit and fixed — + see the integration guide §4), renders the same signing modal the + `window.bcnr` provider uses, and signs via `coSignTransaction`-equivalent + logic in `wallet-web.js` +- Signing uses **SIGHASH_ALL | FORKID | UTXOS (0x61)**. Normative in the + WizardConnect spec; Theseus's signer must accept this flag alongside 0x41. + See Deviant `signer.ts:51` `WIZARDCONNECT_SIGHASH` for the reference. + +### 4.2 Pairing UX + +- Address-bar chip when a `wiz://` URI is present on a page (paste-detect). +- Settings > Wallet > "Pair with a dApp" — QR scanner (uses the camera if + available; otherwise paste), takes a `wiz://` URI, opens the wallet-side + connection. +- **Sign-request modal is the SAME modal** as the in-Theseus `bcnr` provider. + A dApp asking for a signature via `window.bcnr` from a loaded page and a + dApp asking via WizardConnect from a paired external site both hit the same + confirmation surface. This is the property that makes the responder feel + "already part of the browser" rather than a second wallet UX. + +### 4.3 Session persistence + +- Reuse `@wizardconnect/dapp`'s `session` mechanism on the wallet side: the + Nostr identity per URI is deterministic, so a reconnect after a Theseus + restart is transparent. +- Persist active pairings in `/wallet-wz-sessions.json`. +- Settings > Wallet > Paired dApps: list, revoke, "disconnect all". + +### 4.4 The distinction to keep visible in code + +**In-Theseus dApp using `window.bcnr` = local IPC.** No relay, no Nostr, no +transport. Fast and private. + +**External dApp using WizardConnect = Nostr relay.** Encrypted end-to-end (NIP-17 +gift-wrap), but the relay sees timing and message sizes. Users should know they +picked the second one when they scanned a QR. + +The wallet doesn't need to distinguish for *signing purposes* — same modal, +same signer, same permission model — but the address-bar chip and the settings +page should visibly separate `Connected sites` from `Paired dApps`. + +--- + +## 5. UI surfaces + +New WebContentsView overlays, each needing a preload extension registered and +listed in `TheseusNavigator/package.json` `build.files` (per `GOTCHAS.md`): + +| Overlay | Preload | Purpose | +|---|---|---| +| `wallet-connect-modal.html` | `wallet-connect-preload.js` | first-use connect prompt | +| `wallet-sign-modal.html` | `wallet-sign-preload.js` | signature/payment/name-op confirmation | +| `wallet-pair-modal.html` | `wallet-pair-preload.js` | WizardConnect QR scan / URI paste | + +The **address-bar chip** grows an icon when: +- The current origin has an active permission (green key) +- The current page contains a `wiz://` URI (purple wand) +- The wallet is locked and the page is asking for something (grey with a lock) + +Click the chip → shows a summary: which origin, which methods granted, spend +so far, revoke options. + +**Settings > Wallet** — a new pane alongside Passwords: +- Account row: address, balance, "backup phrase" (behind master-password + re-prompt) +- Connected sites table +- Paired dApps table (WizardConnect) +- Transaction history (local; the on-chain source of truth is queryable via + the chip-per-tx explorer link) +- Auto-lock timeout +- **Danger zone**: reset wallet (wipes local state, does not touch on-chain + registrations — the certificates stay in the on-chain wallet the seed + controls) + +--- + +## 6. Storage & unlock + +- **Wallet key material lives in `/wallet.enc`**, encrypted the same + way passwords are: PBKDF2 → AES-256-GCM under the master password (see + [DESIGN-password-manager.md](DESIGN-password-manager.md) for the discipline). +- **Same master password** unlocks both the vault and the wallet. Unlocking one + unlocks the other for the session. The user picks the password once. +- **Distinct purpose subtree** — the seed derives a wallet purpose root via + HKDF with `info = "silentmode/wallet/0"`, independent of the password purpose + root at `"silentmode/passwords/0"`. Extend `password-vault.js`'s + `seedToPurposeRoot(...)` — it already takes a purpose string. +- **The signing key is derived on demand** from the purpose root, not held in + RAM long-term. Each `signMessage` / `sendPayment` reprises the derivation + from the unlocked purpose root. +- **Auto-lock** re-encrypts the purpose root under a fresh session key that is + discarded on lock. Unlock re-derives from the master password. + +Cross-strand rule: **never derive a wallet key from the password purpose root +(or vice versa).** This is the discipline that keeps a website-XSS-driven +password leak from also leaking wallet keys. Documented explicitly in +`DESIGN-password-manager.md`. + +--- + +## 7. Sequencing — the roadmap in order + +| Phase | Roadmap ref | What ships | Depends on | +|---|---|---|---| +| B.1 | this doc | design doc, user aligned on permission model | — | +| B.2a | roadmap B.2 | preload injection scaffold, read-only APIs only | — | +| B.2b | roadmap B.2 | eTLD+1 origin binding (share PSL snapshot with A.2) | — | +| B.3a | roadmap B.3 | wallet unlock flow reused from password vault | A.2 shipped | +| B.3b | roadmap B.3 | `signMessage` + connect modal + settings-connected-sites | B.2 + B.3a | +| B.3c | roadmap B.3 | `sendPayment` with per-amount cap | B.3b | +| B.4 | roadmap B.4 | `registerName` / `updateName` / `transferName` | B.3b | +| **W.1** | *(new)* | WizardConnect responder — WalletAdapter + pair modal | B.3b | +| **W.2** | *(new)* | Settings-paired-dApps, session persistence, revoke | W.1 | + +WizardConnect is new to the roadmap — the existing `B.6 — Advanced connect- +wallet UX` bullet was vague and `B.5 — Nostr messenger` conflates transport +with client. Suggest updating the roadmap: **replace `B.6` with `W.1`+`W.2` +above**, keep `B.5` for a Nostr messenger client if that's still wanted, but +they are independent projects that happen to share a transport. + +W.1 depends on B.3b (the sign modal) so the same modal serves both surfaces. +Do NOT ship W.1 before there is a working sign modal, or the WizardConnect +responder will ship its own approval UI and the two will drift. + +--- + +## 8. Open questions — decide before writing B.2+ code + +1. **Chain switching.** Chipnet only in v1 — but what does `window.bcnr` do on + a mainnet dApp that expects mainnet? Options: + (a) Error with a clear message, + (b) present a "this is a chipnet wallet" modal, + (c) support both networks with a per-origin chain preference. + *Recommend (a) for v1; revisit at mainnet switch.* +2. **`bcnr.sendPayment` recipient by name.** If `to: "alice.bch"`, do we + resolve the `a` record (payment cashaddr) automatically, or force the caller + to `bcnr.resolveName` first and pass a cashaddr? *Recommend auto-resolve; + show the resolved address in the modal for the user to check.* +3. **QR scanner for pairing.** Camera-based (Electron desktop can access + camera, but macOS permission is friction) vs paste-only. *Recommend + paste-first, camera as an enhancement in W.2 or later.* +4. **BCNR-name provenance in the modal.** Show a "registered on-chain since + height X, by category Y" line for BCNR-native origins? Helpful for advanced + users, noise for others. *Recommend a collapsed disclosure that opens on + click, not a default-visible field.* +5. **Rate-limiting on read-only methods.** A page loop calling `resolveName` + thousands of times could DoS the resolver pool. *Recommend a per-origin + token bucket in the preload — 20 requests/second, drops the excess with a + normal-looking error.* +6. **Backup phrase display.** Behind the master password re-prompt, plain + text or blur-until-hover? *Recommend blur-until-hover with a "why is this + dangerous" panel that must be dismissed once before the phrase reveals.* +7. **Transaction history storage.** Local JSON, encrypted like the vault, or + just derived from chain queries on demand? *Recommend derived-on-demand for + v1 — no new storage, no sync surface, and the chain is authoritative.* +8. **`transferName` — does it need commit-reveal?** No, because a certificate + transfer is just a token send; the commit-reveal spec applies to + *registrations*. But the modal should still say "this sends the on-chain + ownership token to a stranger and cannot be undone." + +--- + +## 9. What this design does NOT decide + +- The exact modal visual design. Ship a functional MVP; polish in a UX pass. +- The wallet's home screen (balance, recent transactions). Settings > Wallet + is sufficient in v1; a dedicated pane is a v2 conversation. +- On-chain price display (fiat quotes). Deliberate — Silent Mode does not run + a fiat oracle, and pulling from an external price API undoes half the point + of the project. +- Multi-account UX. Single account per profile; sub-accounts / vanity + accounts are a future strand. +- Whether the wallet ships to Ariadne mobile as a companion. That is a + separate mobile project; the design here informs it but does not require it. + +--- + +## 10. References (do not re-derive) + +- Existing wallet: + [`Argus/src/lib/wallet-web.js:BuiltInWallet`](../Argus/src/lib/wallet-web.js) +- Existing registration flow: [`Argus/src/lib/registrar.js`](../Argus/src/lib/registrar.js), + [`Argus/src/lib/register-tx.js`](../Argus/src/lib/register-tx.js) +- Existing dApp-side WizardConnect (SilentMode registrar): [`Argus/src/lib/connect/wizardconnect.js`](../Argus/src/lib/connect/wizardconnect.js) +- Existing wallet-side WizardConnect (Deviant, chipnet-validated): + [`../../Deviant/Parameters/WalletConnect/WIZARDCONNECT-INTEGRATION-GUIDE.md`](../../Deviant/Parameters/WalletConnect/WIZARDCONNECT-INTEGRATION-GUIDE.md) +- WizardConnect LGPL analysis: [`../Decentralized.DNS/WIZARDCONNECT-LICENSE-FINDING.md`](../Decentralized.DNS/WIZARDCONNECT-LICENSE-FINDING.md) +- Crypto discipline (purpose subtrees, PBKDF2, HKDF): + [`DESIGN-password-manager.md`](DESIGN-password-manager.md) +- Registration flow status: [`../Decentralized.DNS/REGISTRATION-STATUS.md`](../Decentralized.DNS/REGISTRATION-STATUS.md) diff --git a/DESIGN-password-manager.md b/DESIGN-password-manager.md new file mode 100644 index 00000000..14b56e39 --- /dev/null +++ b/DESIGN-password-manager.md @@ -0,0 +1,209 @@ +# Theseus password manager — design + +Built-in password manager local to Theseus, derived from a Bitcoin Cash-style +seed. The seed is the single root of trust; passwords are one purpose among +many (BCH wallet, messenger, future identity uses) all held under distinct +hardened derivation subtrees so a leak in one purpose can't compromise +another. + +**Status**: design + Phase 1 (local vault, settings UI, no autofill, no sync). + +## Threat model + +**In scope:** +- Local process compromise reads plaintext passwords ONLY while the vault is + unlocked. Locked-vault-on-disk is opaque. +- Disk exfiltration (stolen laptop, forensic image) yields only the + encrypted vault. No plaintext, no seed material, no metadata about which + sites the user has passwords for. +- A malicious web page CANNOT ask the password manager for anything. + Autofill (phase 2) will happen via a Theseus-controlled contentScript + bound to the origin; there is no `window.passwords` API. + +**Out of scope (phase 1):** +- Malware running with keyboard-input capability. (No password manager + survives a keylogger.) +- Physical shoulder-surfing when the vault is unlocked and shown. +- Backup / cloud sync — phase 2 (opt-in, Sia). + +**Explicitly rejected:** +- Reusing Chromium's `chrome.storage` or Electron's `safeStorage` as the + sole cryptographic layer. Both are DPAPI-backed on Windows (encrypted + by the OS user's DPAPI key). Fine as a *belt* alongside our AES-GCM + *suspenders*, not as a standalone. + +## Crypto + +### Root + +The user provides ONE of: +- Their BCH wallet seed (BIP39 mnemonic) — unified identity, one backup +- A fresh seed generated in Phase 1 setup — isolated from any BCH funds + +Seed is never persisted by the password vault. What is persisted is a +per-purpose derived root, encrypted under the master password. + +### Hardened derivation discipline + +Every purpose gets its own subtree, using a purpose byte that is +distinct from BIP44's coin-type space: + +``` +BIP32-style: m / purpose' / subpurpose' +Passwords: m / 1381' / 0' (1381 = 0x555 = arbitrary picked; documented) +Messenger: m / 1414' / 0' (reserved for future) +BCH wallet: m / 44' / 145' (SLIP-44 coin 145 — untouched) +``` + +Hardened (`'`) means the parent public key alone cannot derive child keys +— you need the parent private key. So even if a password-purpose child +key leaks, an attacker cannot walk backward to the BCH wallet subtree. + +### Vault key + +``` +master_key_material = PBKDF2( + masterPassword, + salt = 16 random bytes stored in vault header, + iterations = 200_000, + hash = SHA-256, + keylen = 32 +) +vault_key = AES-256-GCM key(master_key_material) +``` + +200k PBKDF2 iterations balances phone-CPU login latency (~200ms) against +brute-force cost. Bumped to 600k on desktop-detected CPUs in Phase 2. + +### Vault encryption + +``` +vault_on_disk = { + version: 1, + kdf: { name: "PBKDF2", iters: 200_000, salt: }, + iv: <12-byte hex>, + ciphertext: , // AES-GCM(vault_key, iv, JSON.stringify(plaintext)) + tag: <16-byte hex, appended>, +} + +plaintext = { + purposeRoot: <32-byte hex>, // the m/1381'/0' node — derived once at setup + entries: [ + { id, domain, username, addedAt, + // one of: + literal: , // legacy pasted password (encrypted with vault_key) + generated: { version, rules } // deterministic — re-derived from purposeRoot on demand + }, + ... + ] +} +``` + +Every entry carries a stable `id` (UUIDv4) so autofill (phase 2) can bind +by id, not by domain+username (which can change). + +### Deterministic derivation recipe (the "Generate" button) + +For a `generated` entry, the password is not stored — it's computed: + +``` +info = "silentmode-passwords-v1|" + domain + "|" + username + "|v" + version +bits = HKDF(hash=SHA-256, key=purposeRoot, salt=, info) → 32 bytes +password = mapBytesToRules(bits, rules) +``` + +`rules` default: +``` +{ length: 20, upper: true, lower: true, digits: true, symbols: true } +``` + +`mapBytesToRules` is a template scheme: take the first 4 bytes to seed a +DRBG, produce N chars from the requested character classes with guaranteed +inclusion of at least one from each enabled class. Same input → same +password on every device holding the seed. + +Version-bumping (`v2` etc.) is how a user "rotates" a deterministic +password without ever losing the old one — old services rejecting a +rotation can still be logged into by looking up v1. + +## Vault file + +Location: `/passwords.vault` (single file). + +Never written unencrypted. On save: build the new plaintext, encrypt with +a fresh IV, write atomically (`.tmp` + rename). + +The file's presence is not itself sensitive — it just says "this user has +opted into the password manager". Contents are opaque. + +## Runtime + +- **Unlock state** lives in the main process only. Never sent to renderers + in plaintext except in response to explicit `password-get(id)` calls. +- Vault stays unlocked for the current session. Auto-locks on: + - Explicit lock button + - App quit (before the storage-clear ran) + - N minutes of settings-page inactivity — phase 2 knob +- No BROWSER autofill in phase 1. Users copy from Settings → Passwords. + +## UI (phase 1) + +New sidebar entry in Settings between Search and Naming: **Passwords**. + +Two states: + +**Locked / not-yet-set-up:** +- "Set up password vault" — one-time form: + - Master password (with confirm) + - Source of derivation seed: "Use my Ariadne wallet seed" (default) OR "Generate a new seed for passwords" + - "Create vault" — writes the encrypted vault file + +- "Unlock vault" (when the file exists) — master password only + +**Unlocked:** +- List of entries: favicon + domain + username + reveal / copy / delete +- "Add new entry": domain, username, password (paste) OR "Generate" button +- "Lock now" at the top-right + +## IPC surface (through settings-preload) + +``` +password-status() → { setup: bool, unlocked: bool } +password-setup(masterPw, seedSource) → { ok: true } | { err } +password-unlock(masterPw) → { ok: true, entries: [...] } | { err: "bad password" } +password-lock() → true +password-list() → array of entries (metadata only) +password-get(id) → { password: } (only while unlocked) +password-add(entry) → id +password-update(id, patch) → true +password-remove(id) → true +password-generate({ domain, username, version, rules }) → <plaintext string> +``` + +Renderers NEVER see the seed / purposeRoot / vault key / master password +past the unlock call. + +## Phase 2 — autofill + Sia backup + +- **Autofill**: contentScript watches `input[type=password]` on load, + binds by (public-suffix-list-derived) eTLD+1 origin so `evil-google.com` + can't fill `google.com` entries. Toolbar key icon + right-click "Fill + password" menu. +- **Sia backup**: user provides a Sia S3 endpoint + credentials (or reuses + the operator relay). Vault encrypted-blob is uploaded on save; restored + on new device by pointing at the same endpoint with the master password. + +## Phase 3 — unified identity + +- Same seed → Nostr messaging keys under `m/1414'/0'`. Compatible with all + Nostr clients (secp256k1 keys, npub/nsec encoding). +- `window.bcnr` provider spec (Web3-style) exposing signed BCNR name + operations to Silent Mode pages — decision to be made per SECURITY.md. + +## Explicitly not doing + +- Cloud sync via anything but Sia. No opinionated third-party. +- Chromium's password autofill UI. Its ergonomics are Google-Sync-shaped + and don't fit our threat model. +- Silent-Mode-only browser extension. The whole thing is built-in — no + install / no separate origin / no extension permissions to grant. diff --git a/DESIGN-wallet-multi-account-amendment.md b/DESIGN-wallet-multi-account-amendment.md new file mode 100644 index 00000000..426247f8 --- /dev/null +++ b/DESIGN-wallet-multi-account-amendment.md @@ -0,0 +1,436 @@ +# Theseus wallet — multi-account amendment + +Amendment to [`DESIGN-integrated-wallet.md`](DESIGN-integrated-wallet.md) §0 +("Multi-account UX. One account per Theseus profile in v1."). Proposes lifting +that restriction under a specific discipline that keeps the single-seed HKDF +purpose-subtree model intact. + +Status: **draft** — not accepted, not scheduled. Written 2026-09-09 as the +formal path to "Theseus recognises the master password and shows all of my +existing wallets, including externally-generated ones", raised while migrating +the Deviant `Keys/chipnet-keystore.json` to master-password encryption. + +Companion documents: +- [`DESIGN-integrated-wallet.md`](DESIGN-integrated-wallet.md) — the v1 design this amends +- [`DESIGN-password-manager.md`](DESIGN-password-manager.md) — the crypto discipline (PBKDF2, HKDF, purpose subtrees) +- [`../Argus/src/lib/password-vault.js`](../Argus/src/lib/password-vault.js) — the implemented vault primitives +- [`../Argus/src/lib/wallet.js`](../Argus/src/lib/wallet.js) — the just-landed cross-repo loader (Option A) that resolves `loadWallet('sirius.x')` from either SilentMode's own `wallets.json` or Deviant's encrypted `Keys/chipnet-keystore.json` + +--- + +## 0. Why amend now + +Three facts changed since §0 was written: + +1. **The user has 15 chipnet wallets already** — 5 HD seeds plus 10 single-key + WIFs — spread across Deviant (`Keys/chipnet-keystore.json`, encrypted with + PBKDF2/AES-256-GCM and the master password) and SilentMode + (`Argus/wallets.json`, plaintext). Consolidating them under Theseus is the + natural next step now that both projects use the same master password. +2. **`password-vault.js` already ships** the primitives that make this cheap: + `unlockVault(path, pw)` returns a state object; `seedToPurposeRoot(seed, + purpose)` derives an HKDF subtree deterministically; `bytesToHex/hexToBytes` + are the wire format. There is no new crypto to design — only new state to + serialise. +3. **The Deviant wallet dApp (`shells/app/`) already has multi-account UX**, + including `MultiKeyStore.WalletSummary { id, label, network, cashaddr, kind: + 'independent' | 'hd-account' }`. The alternative to amending Theseus is to + host that dApp inside Theseus. This document argues the amendment is + *additionally* worth doing, not a replacement for that path. + +The core question §0 punted on was not "is multi-account complicated?" — it was +"does supporting external key material contaminate the single-seed model?" +This document answers: **no, if we keep external material in a distinct storage +lane with a separate origin tier in every UI surface.** + +--- + +## 1. What "multi-account" means here — three distinct notions + +We need to keep these apart because they have different threat models: + +| Kind | Storage | Origin | Recovery | Example | +|---|---|---|---|---| +| **Primary account (unchanged)** | HKDF subtree under `silentmode/wallet/0/<index>` | Derived from the profile's single seed | Recover the seed → recover every primary account | The wallet a fresh Theseus profile creates for you | +| **Primary sub-accounts (new)** | HKDF subtree at index N under the same purpose root | Same seed | Same seed | "Trading", "Savings", "GMX game bets" — different indexes off the same profile seed | +| **External accounts (new)** | Separately encrypted blob keyed by the same master password | Independent (imported seed OR raw WIF) | Recover the *external material* — the profile seed does NOT recover them | Deviant's `sirius.x`, `bns-beacon`, the a-g throughput test WIFs | + +The confusion §0 avoided was mixing categories 1 and 3 in one bag. The +amendment keeps them **in separate files** on disk. The user sees one unified +UI, the crypto keeps them completely isolated. + +--- + +## 2. Threat-model deltas from §1 of the base doc + +### 2.1 New surface: importing external key material + +Every import path is a potential exfiltration event. Three attack shapes: + +- **Shoulder-surf on paste** — someone reads the seed / WIF as it's typed or + pasted. Mitigation: never render the imported string; the modal shows only a + masked derived cashaddr after the import completes ("Import a wallet ending + in …abc123?"). No "show me the WIF I just imported" affordance. +- **Import from clipboard by malicious page** — a page holding + `navigator.clipboard.readText()` permission could feed material INTO the + import flow. Mitigation: **import is a settings-page action only**, never + reachable from `window.bcnr` or WizardConnect. There is no + `bcnr.importWallet()` method. Ever. +- **Import from disk by malicious extension** — Theseus itself can't fully + defend against this (§1.4 base doc), but the imported material lives in a + separately-encrypted file so a compromised in-memory purpose-root leak does + not leak the imports. + +### 2.2 New surface: rendering many wallets in one UI + +The user's clicks now cost more, because a wrong-account signature is +irrecoverable. Mitigations: + +- **Explicit signer selection per sign-request.** The sign modal shows + `Signing as: [Sirius.x · BNS name buyer ▾]` with the account picker OPEN by + default (not collapsed). If a page requested a specific account via + `bcnr.requestAccount({ account: 'sirius.x' })`, the modal still shows every + account the user could have chosen — the requested one is pre-selected and + the picker collapsed, but expanding it is one click. +- **Category badges everywhere the account appears.** The wallet metadata + already carries a `category` field (`bns`, `bns-infra`, `chipnet-test`, + `operational`, …). The modal chip is coloured by category so `sirius.x` + (BNS) is visually distinct from `gmx` (operational) even if the user only + glanced at the modal. +- **"Recently used" is per-origin.** `deviant.bch` remembers which account you + last signed with — but it does not silently reuse it; it pre-selects it in + the modal, and a category change ("last time you used a `bns` account here, + now the page wants an `operational` one") gets a red warning banner. + +### 2.3 What we still do NOT defend against (unchanged from base doc §1.4) + +Imports of a seed the user already leaked, RCE on Theseus itself, keylogger. +These remain out-of-scope; the amendment does not widen the scope, only the +attack surface within it. + +--- + +## 3. Storage model + +### 3.1 The primary vault stays as designed + +`<userData>/wallet.enc` (from `DESIGN-integrated-wallet.md` §6) holds the +profile's single seed. Every primary sub-account is derived on demand via +`seedToPurposeRoot(seed, "wallet/0")` → BIP32-style index. Nothing about the +existing design changes. + +### 3.2 New file: `<userData>/wallet-imports.enc` + +Same crypto envelope as `wallet.enc` (PBKDF2-SHA256 → AES-256-GCM), same +master password, separate KDF salt and IV. This is the ONLY place external key +material lives. Format after decryption: + +```json +{ + "version": "silentmode/wallet-imports/1", + "generated": "2026-09-09T12:00:00Z", + "accounts": { + "sirius-x": { + "kind": "seed", + "seed": "…", + "path": "m/44'/145'/0'/0/0", + "cashaddr": "bchtest:qzy92wznza359wzqz5r732wylfyrgl4djsl2agdp0p", + "label": "Sirius.x · BNS name buyer", + "category": "bns", + "source": "imported-from-D:/Dev/Deviant/Keys/chipnet-keystore.json#sirius-x", + "createdAt": 1725840000000 + }, + "gmx": { + "kind": "wif", + "wif": "…", + "cashaddr": "bchtest:qz040z8hza8n96llrk5kewp5dmyr5rd7csj8rasu9d", + "label": "GMX platform operator", + "category": "operational", + "source": "imported-from-D:/Dev/Deviant/Keys/chipnet-keystore.json#gmx", + "createdAt": 1725840000001 + } + } +} +``` + +The `accounts` shape deliberately mirrors the fields already used by Deviant's +`chipnet-keystore/1` format (`cashaddr`, `label`, `category`, `source`, `path`, +`seed`, `wif`) so a Deviant → Theseus import is a one-to-one field mapping, +not a schema translation. + +**Why a separate file, not one file with two sections?** Blast radius. A bug +that corrupts `wallet-imports.enc` cannot destroy the primary seed. A revoke +"remove all imports" clears one file — the primary vault is untouched. This +is the same discipline `DESIGN-password-manager.md` uses to keep the password +vault distinct from the seed vault: distinct files, same master password. + +### 3.3 One-way pointer: primary → imports, never the other way + +The primary vault gets a new small field: + +```json +{ …existing wallet.enc payload…, "importsExist": true } +``` + +...updated on every import/revoke. Loading the primary vault tells Theseus +whether it should also try to decrypt `wallet-imports.enc`. The **imports +file NEVER references the primary** — an attacker who exfiltrates +`wallet-imports.enc` learns nothing about the profile seed, not even its +existence. + +### 3.4 What the master password unlocks in one session + +On `unlockVault(masterPw)`: + +1. Derive `vaultKey` (PBKDF2 with `wallet.enc`'s salt). +2. Decrypt `wallet.enc` → get profile seed → derive `purposes/wallet/0`, + `purposes/passwords/0`, `purposes/messenger/0`. +3. If `importsExist`: derive `vaultKey'` (PBKDF2 with + `wallet-imports.enc`'s DIFFERENT salt — deliberately not the same key + material) and decrypt. + +Both keys are held in RAM for the session; both are wiped on auto-lock. A +malicious page that gets an in-memory read of the wallet purpose root learns +nothing about imports. + +--- + +## 4. UX + +### 4.1 Settings > Wallet — the accounts panel + +Replaces the base doc's "Account row: address, balance, backup phrase". + +``` +── Accounts ───────────────────────────────────────────────────── + [+ Create sub-account] [+ Import external wallet] + + ● Primary [rename ▎revoke] + bchtest:qxxx…yyy · category "primary" + + ● Trading (primary sub-account #1) [rename ▎revoke] + bchtest:qaaa…bbb · category "primary" + + ── Imported ───────────────────────────────────────────────── + ● Sirius.x · BNS name buyer [rename ▎revoke] + bchtest:qzy…dp0p · category "bns" + source: Deviant/Keys/chipnet-keystore.json#sirius-x + + ● BNS TLD Beacon (.asm/.x) [rename ▎revoke] + bchtest:qzc…pmerh · category "bns-infra" + source: Deviant/Keys/chipnet-keystore.json#bns-tld-beacon + + ● GMX platform operator [rename ▎revoke] + bchtest:qz0…rasu9d · category "operational" + source: Deviant/Keys/chipnet-keystore.json#gmx +───────────────────────────────────────────────────────────────── +``` + +- Primary and sub-accounts always appear above imports. This is not just + cosmetic — it reflects that the profile seed is the wallet's identity. +- Imports are grouped by `category` when there are more than 5. `bns`, + `bns-infra`, `operational`, `chipnet-test` collapse into collapsible groups. +- Revoke removes an import (from `wallet-imports.enc`); it does NOT touch the + on-chain material and warns clearly: "The on-chain wallet still exists. Its + seed/WIF is only removed from this Theseus profile." +- **No affordance to view seed / WIF material after import.** Recovery lives + in the SOURCE (Deviant's keystore, or wherever the user got it from). §2.1 + shoulder-surf mitigation. + +### 4.2 Sign-request modal — the account picker + +Same modal as base doc §5, extended: + +- Account row becomes `Signing as: [dropdown ▾]`. +- Dropdown lists every unlocked account, grouped: Primary → sub-accounts → imports. +- Each row shows label + truncated cashaddr + category chip. +- Origin's "recently used" account is pre-selected (§2.2 above). +- If the calling page passed `bcnr.requestAccount({ account: 'sirius.x' })` + and the account exists, it's pre-selected. If it doesn't exist, the modal + says "This site is asking for an account you don't have. Sign as:" and the + user picks from what they do have. + +### 4.3 Import flow + +Two entry points, both in Settings > Wallet: + +1. **"Import external wallet" → paste seed OR WIF.** + The pasted string never renders back; the modal shows only what will be + stored (`kind`, derived `cashaddr` truncated, `label` input, `category` + picker). + +2. **"Import from Deviant keystore" (if `D:/Dev/Deviant/Keys/chipnet-keystore.json` + is reachable).** Detects the file, prompts for its master password (which + must equal Theseus's — refuses otherwise with a clear message: "The Deviant + keystore uses a different master password. Change one of them first."), + decrypts, shows a preview list of every entry (label + cashaddr + category + only), user picks which to import. Bulk-select shortcuts by category + ("all `bns`", "all `operational`"). + +The "different master password" rejection is a deliberate ergonomic +constraint: we choose the pain of "unify your passwords once" over the pain +of "which password unlocks what in this profile". + +--- + +## 5. API surface for pages (deltas from §3 of base doc) + +Backwards-compatible additions only: + +```ts +bcnr.requestAccount({ account?: string, category?: string }): Promise<string | null> +// If neither `account` nor `category` given: current behaviour (returns cashaddr +// of the default account, or null if not granted). +// If `account`: requests a specific labelled account. Modal pre-selects it; if +// it doesn't exist, the modal falls back to a picker over what the user has. +// If `category`: requests any account in that category (e.g. "bns"). Useful +// for the SilentMode registrar UI which knows it wants a BNS-holding wallet +// but not which one. + +bcnr.getAccounts({ category?: string }): Promise<Array<{ + cashaddr: string; + label: string; + category: string; +}>> +// Returns granted accounts. Optionally filtered by category. Note: the user +// grants per-account, not per-category — this method never leaks accounts the +// origin was not granted access to. +``` + +The `bcnr.signMessage` / `bcnr.sendPayment` / `bcnr.registerName` signatures +gain an optional `account?: string` parameter that pre-selects an account in +the modal. Non-passing preserves current behaviour. + +**Not added:** no method to import, revoke, or rename. All state-modifying +account operations are settings-page only (§2.1). + +--- + +## 6. Compatibility with Option A (the cross-repo loader that shipped 2026-09-09) + +Option A lets `Argus/src/lib/wallet.js:loadWallet('sirius.x')` transparently +read Deviant's encrypted keystore. That path is authoritative today and stays +authoritative after this amendment ships: + +- Theseus imports are **copies**, not sources of truth. Revoking an import in + Theseus does not affect Deviant's keystore or SilentMode's `wallets.json`. +- If the user rotates a key in Deviant's keystore, the corresponding Theseus + import goes stale. Theseus should detect this by comparing derived + `cashaddr` on unlock — a mismatch surfaces a banner: "The Deviant source of + Sirius.x has changed since this import. [Re-import] or [Remove]." +- The two systems remain independent by design. This is not a bug; it is the + same discipline that keeps password-vault seeds separate from wallet seeds + (§3.4 base doc). + +--- + +## 7. Sequencing — where this slots into the base roadmap + +Adds to the base doc's §7: + +| Phase | What ships | Depends on | +|---|---|---| +| B.3d | Sub-account creation (primary purpose subtree, indexes 1..N) — `Trading` / `Savings` labels | B.3a shipped | +| **M.1** | Import external seed / WIF, `wallet-imports.enc` file, account picker in sign modal | B.3b + `password-vault.js` shipped ✓ | +| **M.2** | Import from Deviant keystore (file detection + bulk-select UX) | M.1 + `chipnet-keystore/2-encrypted` format stable ✓ | +| **M.3** | `bcnr.requestAccount({ account, category })` + `getAccounts({ category })` API extensions | M.1 | +| **M.4** | Stale-import detection (source-file cashaddr comparison) | M.2 | + +M.1 is the load-bearing phase — everything else composes on top of the +`wallet-imports.enc` file. M.2 through M.4 are refinements the user's specific +setup makes worthwhile. + +Do NOT ship M.2 (Deviant integration) before M.1 (generic import). The +generic-first path keeps the design honest — if the amendment cannot be used +by a user who never touches Deviant, we've over-fitted. + +--- + +## 8. Open questions — decide before writing M.1 code + +1. **What's the primary account's "category"?** Options: `primary` (mirrors + the model), leave the field null, or use the user-picked purpose ("Personal + spending"). *Recommend `primary` — it's the one category name that never + collides with an import.* + +2. **How does WizardConnect surface multi-account?** External pairings via + `wiz://` — the `@wizardconnect/wallet@0.2.2` SDK's WalletAdapter has one + `getXpub(path)` and one `signTransaction(request)`. Neither takes an + account parameter. Options: pair-per-account (each account has its own + `wiz://` URI); pair-per-profile with account selection at sign time (like + in-page dApps); or accept that WizardConnect only exposes the primary + account. *Recommend option 3 for M.1 (only-primary), revisit when a user + asks. Multi-account WizardConnect is a real cross-cutting design that + deserves its own doc.* + +3. **Do we let the profile primary seed be *replaced* by an import?** The + proposal above says no — imports are always in the imports file, and the + primary purpose subtrees stay tied to the profile seed. A user who wants + "my Deviant seed as the profile seed" would create a fresh profile and + restore that seed at profile-creation. *Recommend keeping the restriction + — it makes the sub-account / import distinction fundamental rather than + optional.* + +4. **Auto-lock behaviour with imports.** Auto-lock re-encrypts the primary + purpose root under a session key (§6 base doc). Do we do the same for the + imports? Or wipe the imports outright and force a re-decrypt on unlock? + *Recommend the same treatment as primary — session-key re-encryption. The + cost is symmetric.* + +5. **Export.** Can the user export an imported wallet's key back out of + Theseus? *Recommend no by default (§4.1 no-view-material rule), but a + "Show recovery WIF for this import" behind master-password reprompt AND + "why is this dangerous" panel (like the base doc §8.6 for the profile + seed) is a reasonable escape hatch for users who need to move a wallet + between machines.* + +6. **Sub-account discovery.** If a user restores their profile seed on a new + Theseus install, do we auto-scan chipnet for used sub-account addresses? + *Recommend a "Scan for used sub-accounts" button in settings, not + automatic. Scanning is a chain-load event; make the user ask for it.* + +7. **Category taxonomy.** The Deviant keystore uses `bns`, `bns-infra`, + `chipnet-test`, `operational`, `primary`, `hd-general`. Should Theseus + enforce a fixed set or allow free-form strings? *Recommend free-form + strings with the six above as autocomplete suggestions. Constraining now + would force a v2 migration when a user coins a new category.* + +--- + +## 9. What this amendment does NOT decide + +- The wallet-imports file format after v1. Version-tag it now; iterate as + needed. The `version: "silentmode/wallet-imports/1"` field exists precisely + so a v2 migration is a well-defined event, not a guess. +- The Deviant-side story. Deviant's `chipnet-keystore.json` continues to be + read by Deviant scripts (via `scripts/lib/chipnet-keystore.mjs`) and by + SilentMode's `Argus/src/lib/wallet.js` (via the Option A fall-through). This + amendment adds a THIRD reader (Theseus) — it does not consolidate the two + existing readers. +- The mobile story. Ariadne (mobile companion, base doc §9) is out of scope. + When it lands, the same `wallet-imports.enc` format ports over — that's the + point of designing it version-tagged and portable. + +--- + +## 10. Suggested acceptance path + +If the reader agrees with this amendment: + +1. Merge M.1 into the roadmap between B.3b and B.4 in + `ROADMAP-identity-wallet.md`. +2. Move the base doc's §0 "Multi-account UX. One account per Theseus profile + in v1." to §9 "What this design does NOT decide, in v1 — this is now + addressed by [`DESIGN-wallet-multi-account-amendment.md`](DESIGN-wallet-multi-account-amendment.md)". +3. Address the open questions in §8 above in the roadmap discussion, not by + editing this file — the design is stable, only the answers are open. + +If the reader disagrees, the fallback path is what the base doc already +implies: **host the Deviant wallet dApp inside Theseus, once `window.bcnr` +ships** (base doc §4.4 — "External dApp using WizardConnect = Nostr relay", +minus the Nostr relay because the dApp is loaded IN Theseus, not paired +externally). That path requires no Theseus code changes beyond what B.2–B.4 +already deliver, and it inherits Deviant's own multi-account UX for free. The +trade-off is that "my wallet" and "the Deviant wallet loaded in my wallet +browser" become two things instead of one; this amendment is the case for +one. diff --git a/GOTCHAS.md b/GOTCHAS.md new file mode 100644 index 00000000..220e03d4 --- /dev/null +++ b/GOTCHAS.md @@ -0,0 +1,121 @@ +# Theseus gotchas + +Non-obvious things sessions keep re-deriving. If you found this file because +something broke in a way that felt spooky, the answer is probably here. + +## `build.files` in [package.json](package.json) is EXPLICIT + +electron-builder ships **only what's listed** in `build.files`. Every runtime- +loaded HTML/preload/module MUST be there. A missing entry causes the +`loadFile("foo.html")` in a WebContentsView to silently open blank in the +packaged app — visually indistinguishable from "the button does nothing". + +`npm start` (dev) never catches this because it reads from the source tree +directly. Only a real `npm run dist` + install exposes it. + +Every time you add a new `WebContentsView` or `loadFile`, add its HTML and +preload to the `files` array. Sanity check after building: + +``` +node -e "const b = require('fs').readFileSync('dist-public/win-unpacked/resources/app.asar'); for (const n of ['engine-picker.html','popover.html','downloads.html','engine-picker-preload.js','popover-preload.js','downloads-preload.js']) console.log(n, b.indexOf(Buffer.from(n)) >= 0 ? 'OK' : 'MISSING');" +``` + +This has bit us once (search-picker dropdown, shipped `6bbccf9c`, blank +picker). Do not let it bite twice. + +## Building — admin terminal the first time + +`npm run dist` needs an **admin** terminal on the machine's first Theseus +build so `electron-builder`'s `winCodeSign` package can extract its +`darwin/` symlinks (requires `SeCreateSymbolicLink`). Subsequent builds +reuse the cache under `$LOCALAPPDATA/electron-builder/Cache/winCodeSign/` +and don't need admin. + +If DNS is misbehaving on the machine, pin these hosts in +`C:\Windows\System32\drivers\etc\hosts` before building so electron-builder +can fetch: `github.com`, `codeload.github.com`, +`objects.githubusercontent.com`, `release-assets.githubusercontent.com`. +Strip them after. + +## Reproducible builds + +Set both before `npm run dist`: +- `SOURCE_DATE_EPOCH` — freezes timestamps so a rebuild with no content + changes produces the same hash. Bump per release, not per rebuild. +- `CSC_IDENTITY_AUTO_DISCOVERY=false` — stops electron-builder searching + for a signing cert (we don't sign; SECURITY.md rule 2). + +## Native `<select>` popup theme + +Chromium's native `<select>` popup uses the page's `color-scheme`. When it's +`"light dark"` (both accepted), Chromium picks by the OS scheme — so a +dark-themed app on a light OS shows a light popup and vice versa. + +Fix, currently in [settings.html](settings.html): +```css +:root { color-scheme: dark; } +@media (prefers-color-scheme: light) { :root { color-scheme: light; } } +``` +`nativeTheme.themeSource` (driven by `settings.theme`) sets +`prefers-color-scheme`, so this stays in sync automatically. Do not remove. + +Chromium also respects `select option { background; color }` in the popup +on Windows — a belt-and-braces override alongside `color-scheme`. + +## The resolver in Theseus is `resolver-web.mjs`, not `.js` + +Packaged builds ship `Argus/src/lib/resolver-web.js` as `resolver-web.mjs` +(see `extraResources` in [package.json](package.json)). Renamed because +`resources/` has no adjacent `package.json` so a `.js` gets treated as +CommonJS and its `export`s fail. Dev reads the engine copy directly (Argus +is `type: module` so `.js` works there). + +## The resolver's electrum WS `directIP` fallback + +[main.js](main.js) passes `directIP: true` to `resolveHost` so the engine +can dial the chipnet electrum servers by pinned IP if system DNS is dead. +Do not remove — this is what keeps `.bch` resolution working when the +adapter DNS is broken (a real failure mode we've hit). + +## Publishing new hashes end-to-end + +1. Build (see above). +2. Update `../site/releases-manifest.json` AND + `../site/tools/index.html` AND `../site/releases/index.html` with the + fresh SHA-256s + release date. All three must match. +3. Deploy: + ``` + scp dist-public/*.exe ../site/releases-manifest.json silentmode:/opt/silent-mode/dl/ + cd ../Argus && node src/lib/sia-upload.js ../site bns/silentmode + ``` +4. On-chain (wallet spend, user's action): + `releases.silentmode.bch` currently publishes + `{"u":"https://dl.silentmode.st/releases-manifest.json"}` — a compact + `u` record because on-chain hashes wouldn't fit the 200-byte + OP_RETURN. Only re-publish if the URL changes or if we ever put full + hashes on-chain. + +## Sia upload from this machine fails without a `hosts` pin + +`node src/lib/sia-upload.js` fails with `getaddrinfo EAI_FAIL +coinspectrum.duckdns.org` unless the host is pinned in +`C:\Windows\System32\drivers\etc\hosts`: +`195.184.247.106 coinspectrum.duckdns.org`. This is a DNS quirk of the +machine, not a code bug — related to the strict-resolver behavior the +Ariadne daemon's EDNS fix addresses. + +## Deploy directory quirks + +- `/opt/silent-mode/dl/` is served as `dl.silentmode.st` — installers + + manifest live here. +- `silentmode.st` proxies to Sia (`bns/silentmode/`) — the HTML pages + live there. +- The `bns` gateway does NOT auto-index directories. `silentmode.st/apps/` + is a 404 (`NoSuchKey`); users must hit `/apps/index.html`. Fix upstream + in `bnsd.js` if it ever matters. + +## Firefox needs a restart after Ariadne CA install/rotation + +Firefox only reads root CAs at startup. The Ariadne installer offers a +`-CloseFirefox` task by default when Firefox is running so this doesn't +bite. Chrome/Edge pick up the new root immediately. diff --git a/PACKAGING-PROMPT.md b/PACKAGING-PROMPT.md new file mode 100644 index 00000000..5cb2274c --- /dev/null +++ b/PACKAGING-PROMPT.md @@ -0,0 +1,91 @@ +# Theseus Navigator — packaging session brief + +Goal of this session: turn Theseus from "runs via `npm start`" into a +**downloadable, secret-free Windows installer** hosted at `dl.silentmode.st`, +with its SHA-256 published on-chain. Do it in the three phases below, in order. + +Working dir: `D:\Dev\SilentMode\TheseusNavigator`. Prefix shell commands with +`rtk`. Windows + PowerShell primary; Bash tool available. + +**Read first:** `..\README.md` (hub), `..\BUILD-ROADMAP.md` (this is Stage 5), +`..\SECURITY.md` (Rules 0/2/3 govern this session), `..\_coordination\INTERFACES.md` +(the resolver contract), and this file. Theseus already works for `.bch`; the +engine (`..\Argus\src\lib\resolver-web.js`) is already multi-TLD and zero-dep. + +--- + +## Current state (verified 2026-07-25) +- Theseus **runs** (`npm start`): Electron, tabs, back/fwd/reload/home, address + bar, DuckDuckGo search box, home page, **Tor toggle** (bundled `tor/tor.exe`, + routes web session + resolver WS + content fetch via SOCKS). +- Resolution reuses `..\Argus\src\lib\resolver-web.js` (import in `main.js`). +- `bns://` custom protocol serves content; provenance strip shows chain/Sia/server. +- **NOT** packaged: no `electron-builder`, no installer. +- **Two blockers for a public build (must fix):** + 1. `main.js` `resolveHost()` is still `.bch`-only + (`host.replace(/\.bch$/,"").split(".").pop()`), and `navigate()` / + `serveBns()` / `subFolder()` assume `.bch`. The engine is multi-TLD; the + browser must catch up. + 2. `serveBns()` fetches `s3` content with **your Sia credentials** + (`getAws()` reads `..\Argus\sia-s3.json`). **A public build MUST NOT ship + credentials** (SECURITY.md Rule 0). Switch `s3` to the public gateway. + +## Phase 1 — Multi-TLD + registry badge (browser catches up to engine) +- `resolveHost(host)`: stop stripping `.bch`/`.pop()`. Pass the **full host** to + `resolveName(host, { WebSocket: currentWS() })` — it already normalizes any + `<sub>.<label>.<tld>` to the canonical key. Cache/serve by full host. +- `navigate()`: replace `host.endsWith(".bch")` checks with "is this a BNS name?" + = has a dot and the rightmost label is a known BNS TLD (`bch`, `p2p`, `deviant`, + `bit`, …) — everything else is normal web. Keep a small TLD allowlist constant. +- `serveBns()` + `subFolder()`: generalize off `.bch` (drop tld + label to get + the subfolder — mirror `resolver-web.js`/the gateway). +- **Registry badge (trusted chrome — the security invariant):** in the toolbar + provenance strip (chrome, NOT page content), show the **registry / TLD** the + name resolved under (e.g. "BitcoinCash · .bch"). This is where the future + multi-registry `.bit` (BCH vs Namecoin) switch will surface. Unspoofable + because it's painted by the browser, not the page. +- Verify: `hello.bch`, `demo.p2p`, `coinspectrum.deviant.bch` all load in tabs; + badge shows the right TLD; normal web (wikipedia.org) still works. + +## Phase 2 — Secret-free content (SECURITY.md Rule 0) +- Replace the `getAws()` signed-S3 path in `serveBns()` with a plain fetch from + the **public gateway**: `https://navigate.st/bns/<name>/<path>` (or the + subdomain form). The gateway already serves `s3`/`ip`/`h`/`u` and injects no + secrets. Remove the `aws4fetch` dependency and any read of `sia-s3.json`. +- Net effect: a shipped Theseus contains **no credentials**. `s3` sites render by + asking the public relay for the bytes; resolution stays local/trustless. +- Grep the repo + built output for `sia-s3`, `accessKey`, `secretKey`, + `C:\\Users\\`, the OS username — must be clean before packaging. + +## Phase 3 — Package, scrub, publish +- Add `electron-builder`; target Windows **NSIS installer** + a **portable .exe**. + Bundle `tor/`, `chrome.html`, `home.html`, `preload.js`, and the resolver it + imports (either vendor `resolver-web.js` into the app or include `../Argus` + path in `files`). Confirm `tor.exe` and geoip files are packaged. +- **Unsigned by choice** (SECURITY.md Rule 2). Document the SmartScreen warning. +- **Scrub fingerprints** (Rule 3): build in a neutral path or strip absolute + paths/source maps; no real name/email/username in metadata; set the app + `author` to "Silent Mode". Normalize timestamps where feasible. +- Compute **SHA-256** of the installer. Publish the hash **on-chain** — a BNS + record (e.g. a `releases` name) or note the mechanism in `..\SECURITY.md`. +- Deploy the installer to **`dl.silentmode.st`** (nginx already serves + `/opt/silent-mode/dl/` on the VPS via the `silentmode-st` / `dl` vhost). Update + the download page (`..\site\index.html`) — swap the "packaging in progress" + chip for a real link + the SHA-256 + "unsigned, verify the hash" note. +- Verify end to end: download the installer on a clean path, install, launch, + load `hello.bch` + a Sia site + normal web, confirm no secrets in the install + dir, confirm the published hash matches. + +## Guardrails +- Do NOT touch `D:\Dev\NameCoin` (live/canonical) or register/spend on-chain + beyond (optionally) one release-hash record. Reads are fine. +- Do NOT ship `sia-s3.json`, `ca/root-ca.key`, `wallets.json`, any keystore/ + password. Follow the SECURITY.md pre-release checklist. +- Elevated installs, code-signing, and AMO steps are the user's to run — don't + attempt them; hand off exact commands. + +## Definition of done +A downloadable **`dl.silentmode.st`** Windows installer that: contains no +secrets, resolves `.bch`/`.p2p`/etc. with a trusted-chrome registry badge, +renders Sia sites via the public gateway, browses the normal web, and whose +SHA-256 is published on-chain and shown on the download page. diff --git a/PENDING.md b/PENDING.md new file mode 100644 index 00000000..81312ddc --- /dev/null +++ b/PENDING.md @@ -0,0 +1,43 @@ +# Pending — uncommitted Theseus changes + +Working-tree state that hasn't been committed or shipped. Read this before +touching the same files. Update when you land or add work. + +Format: each group has a `session:` label so parallel sessions can see +what belongs to whom and coordinate. Naming convention: +`YYYY-MM-DD:short-slug` for date-bounded work, or `parallel:<theme>` for +work owned by a session that isn't the current one. + +Last updated: 2026-08-02. + +--- + +_No uncommitted work owned by this session. Last shipment: +Setup `b62ecf528dc672a07a144af5a8bbb5772b2853b4ad5eec5926bdc6247792ad53`, +portable `ba80a808446bd75a6b649d93094af9e21be38476dd23980bad7ac0e228a6ce76` +(0.0.2, 2026-08-02 — shield green polish + picker→Search + toggle-vs-remove). +Rollback points on the VPS at `/opt/silent-mode/dl/_prev/`:_ +- `TheseusNavigator-Setup-0.0.1-81831c08.exe` + `-portable-b4807aa1.exe` + `releases-manifest-2026-08-02-live-81831c08.json` — one revision back (parallel-session 0.0.1 that was live just before this ship) +- `TheseusNavigator-Setup-0.0.1-418e7bba.exe` + `-portable-93d57963.exe` + `releases-manifest-2026-08-02c.json` — this session's 0.0.1 shield ship +- `TheseusNavigator-Setup-0.0.1-208b059a.exe` + `-portable-bf089765.exe` + `releases-manifest-2026-08-02b.json` — pre-shield +- `TheseusNavigator-Setup-0.0.1-036e94c7.exe` + `-portable-fb0b2693.exe` + `releases-manifest-2026-08-02.json` — original 0.0.1 dropdown-fix build + +--- + +## session: `parallel:collision-policy` + +Owner's focus: BCNR ↔ ICANN name collision handling. Not mine — leave for +that session to land or bundle by explicit ask. + +- Android / mobile: [../AriadneResolver/mobile/app/AndroidManifest.xml](../AriadneResolver/mobile/app/AndroidManifest.xml), + [BchFetcher.java](../AriadneResolver/mobile/app/src/com/silentmode/ariadne/BchFetcher.java), + [Bns.java](../AriadneResolver/mobile/app/src/com/silentmode/ariadne/Bns.java), + [build.ps1](../AriadneResolver/mobile/build.ps1) — mobile-side collision changes. +- Repo-wide: `.gitignore` gained a `/.keys/` line (harmless). + +## Live state + +Shipped hashes live at `../site/releases-manifest.json` and +`https://dl.silentmode.st/releases-manifest.json`. On-chain pointer at +`releases.silentmode.bch`. See [GOTCHAS.md](GOTCHAS.md) for the release +gotchas. diff --git a/README.md b/README.md new file mode 100644 index 00000000..c315fc7f --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Theseus Navigator + +The browser — the consumer face of the stack. Native `.bch` support with the +resolver built in, so a user installs **one app** instead of modifying their +operating system. Named for the thread through the labyrinth: the chain is the +thread. + +## Scope +- Electron shell (Chromium engine, no forking): tabs, address bar, history. +- **In-process `.bch` resolution** — no system daemon, no NRPT, no OS + trust-store changes; reuses `bns.js` from the BNS repo as a library. +- Record handling: `h` render, `ip` connect, `p`/`s3` via in-app gateway, + `u` redirect. +- **TLS via cert-verify hook** (Electron `setCertificateVerifyProc`) against the + BNS root / on-chain `tls` fingerprints — no OS store touched. +- **Provenance indicator**: shows whether a page came from the chain / Sia / a + direct server, with NFT category and record type — the decentralized padlock. + +## Status: not started +Build it in its own session/repo. The ready-to-paste brief is +**`BROWSER-PROMPT.md`** (a reference copy in this folder; canonical source is +`D:\Dev\NameCoin\BROWSER-PROMPT.md` — if they diverge, NameCoin wins). It points +here and reuses the resolver core. `theseus.bch` is registered and should +eventually serve the browser's own homepage over the protocol it implements. + +## Roadmap +[BUILD-ROADMAP.md](../BUILD-ROADMAP.md) Stage 5. diff --git a/RELEASE-HANDOFF.md b/RELEASE-HANDOFF.md new file mode 100644 index 00000000..be21c150 --- /dev/null +++ b/RELEASE-HANDOFF.md @@ -0,0 +1,94 @@ +# Theseus Navigator v0.0.2 — release handoff + +Built 2026-08-05 from `D:\Dev\SilentMode\TheseusNavigator`. This release adds the +cross-site embed cookie shim (`applyEmbedCookieShim()` — SameSite=None; Secure on an +allowlist so captcha-gated faucet iframes work); packaging is done. +The steps below are the ones this session must NOT do for you — they spend +on-chain and touch the VPS. Run them yourself; exact commands are given. + +## Artifacts (in `dist-public/`) + +| File | Size | SHA-256 | +|---|---|---| +| `TheseusNavigator-Setup-0.0.2.exe` (NSIS installer) | ~92 MB | `b3a810472b06273c1cc846adcb84366ad9114276a984bed7347c26f019497c56` | +| `TheseusNavigator-0.0.2-portable.exe` (portable) | ~92 MB | `37679d293715b9e1881d38ee7e92a5a7448f3c523a14919637afe732e2478ce1` | + +> **The binaries have been rebuilt more than once and the hashes changed each +> time** (unsigned reproducible builds still differ if the toolchain changes). The +> values above were computed from `dist-public/` on 2026-08-05 and match what `site/` +> and `site/releases-manifest.json` publish. **Always re-hash immediately before the +> Step 2 on-chain publish** — a rebuild between writing a doc and spending the +> transaction silently invalidates it, and that step cannot be undone. + +Verified clean: no `sia-s3`/`accessKey`/`secretKey`/`aws4fetch` and no +`C:\Users\valer` / username strings in `app.asar` or bundled resources. + +Re-verify the hashes any time: + +```bash +cd D:\Dev\SilentMode\TheseusNavigator\dist-public && sha256sum *.exe +``` + +## Step 1 — Deploy to `dl.silentmode.st` (your VPS) + +nginx already serves `/opt/silent-mode/dl/` on the `dl` / `silentmode-st` vhost. +Upload both installers + the manifest (run over the anonymous VPN/Tor per +SECURITY.md §4): + +```bash +scp dist-public/TheseusNavigator-Setup-0.0.2.exe dist-public/TheseusNavigator-0.0.2-portable.exe site/releases-manifest.json <vps>:/opt/silent-mode/dl/ +``` + +**Manifest moved (2026-07-26).** `releases-manifest.json` is now multi-product +(Theseus + Ariadne Android) and lives at `site/releases-manifest.json` — note +the `scp` line above copies it from `site/`, not from `dist-public/`. The old +Theseus-only copy in `dist-public/` was deleted the same day: uploading it would +have wiped the Android APK's checksum off the live manifest. Do not re-create +one there — that is electron-builder's output directory, and hand-placed files +are lost on the next `npm run dist`. + +The download page (`site/index.html`) already links these exact filenames at +`https://dl.silentmode.st/…`, so no page edit is needed once they land. + +## Step 2 — Publish the hash on-chain (`releases.silentmode.bch`) + +This spends chipnet funds and uses the wallet seed — your action, not the +session's. The engine tool is `Argus/src/update.js`. Publish the manifest as an +inline `h` record so `releases.silentmode.bch` itself serves the hash list +(same mechanism as `hello.bch`): + +```bash +cd D:\Dev\SilentMode\Argus +node src/update.js releases.silentmode "{\"h\":\"<html><body><pre>Theseus 0.0.2\nSetup b3a810472b06273c1cc846adcb84366ad9114276a984bed7347c26f019497c56\nPortable 37679d293715b9e1881d38ee7e92a5a7448f3c523a14919637afe732e2478ce1\nAriadne Android 0.2\nAPK 635c8f04d44ef855a8390b9eeddb8cd2d50622e81cc4e5004e8daffc1bb0425c</pre></body></html>\"}" +``` + +- If `releases.silentmode` is not yet registered, register it first with + `node src/register.js releases.silentmode "{...}"`. +- If the full HTML exceeds the OP_RETURN size your relay policy accepts, store a + compact record instead — e.g. `{"u":"https://dl.silentmode.st/releases-manifest.json"}` + — but the trustless ideal is the hashes themselves on-chain. +- **Do this from `D:\Dev\SilentMode\Argus`, never `D:\Dev\NameCoin` (live).** + +## Step 3 — Optional per SECURITY.md §5 (multi-channel) + +- Create a torrent/magnet of each installer; add the magnet to the manifest + + download page. +- Mirror to IPFS (`ipfs add`) and Sia; list all mirrors, all verified against + the on-chain hash. +- Sign `releases-manifest.json` with your pseudonymous minisign key. + +## Build reproducibility note (for whoever rebuilds) + +`npm run dist` fetches electron-builder's `winCodeSign` package, whose macOS +symlinks fail to extract on Windows without admin/Developer Mode. Since builds +are **unsigned by choice** (SECURITY.md §2), pre-extract it once without the +`darwin` folder, then the build runs clean: + +```bash +CACHE="$LOCALAPPDATA/electron-builder/Cache/winCodeSign" +node_modules/7zip-bin/win/x64/7za.exe x "$CACHE"/<downloaded>.7z -o"$CACHE/winCodeSign-2.6.0" -xr!darwin -y +``` + +Build with a fixed `SOURCE_DATE_EPOCH` (set to 2026-08-05 / `1785888000` for this release) and +`CSC_IDENTITY_AUTO_DISCOVERY=false` so timestamps don't fingerprint the build +and no signing identity is sought. diff --git a/ROADMAP-identity-wallet.md b/ROADMAP-identity-wallet.md new file mode 100644 index 00000000..48fcad5b --- /dev/null +++ b/ROADMAP-identity-wallet.md @@ -0,0 +1,215 @@ +# Identity + wallet roadmap + +Two independent strands, both rooted in the same BCH-style seed-with-hardened- +derivation model. Order them however makes sense to the session picking this +up; strands don't block each other. + +**Strand A — Password manager.** Local-first vault, deterministic derivation +per site+username, optional Sia backup for cross-device. +**Strand B — Browser-integrated BCH wallet.** MetaMask-style provider so +Silent Mode pages (and any BCH-native dApp) can request signatures, +payments, and BCNR name operations from the user's wallet. + +Both use the SAME seed via distinct hardened purpose subtrees so one +compromise can't leak the other: + +``` +m / 44' / 145' BCH wallet (SLIP-44) ← wallet already ships +m / 1381' / 0' passwords ← phase 1 shipped +m / 1414' / 0' messenger (Nostr etc.) ← future +``` + +Same seed, three purposes, no cross-derivation. See +[DESIGN-password-manager.md](DESIGN-password-manager.md) for the discipline. + +**Add-on roots (decided 2026-09-06, do not change).** The vault never stores +the BIP-39 seed, only per-purpose roots, so add-ons cannot get a fresh +seed-level purpose after setup. `api.vault.derive("<addonId>/<path>")` +therefore returns HKDF-SHA256(passwords/0 root, info = +`silentmode/addons/<addonId>/<path>`). The bundled BCH wallet uses +`bchwallet/mainnet/0` as its BIP32 master seed and derives `m/44'/145'/0'` +from it. This is deterministic from the mnemonic (mnemonic → seed → +passwords/0 → wallet) and works for random-seed vaults too. Changing the +info string or the parent root would orphan every wallet's funds; recovery +without Theseus is via the account xprv shown in the wallet's Settings. + +The bundled Siacoin wallet follows the same rule with `siawallet/mainnet/0` +as the 32-byte seed for walletd's `KeyFromSeed(seed, index)`; addresses are +standard unlock hashes. It talks to a user-configured walletd node (no +default endpoint ships in the add-on — hosted providers put an access key +in the URL path). + +## Strand A — Password manager + +### A.1 — SHIPPED (0.0.3, 9c09955e) +- Local encrypted vault at `<userData>/passwords.vault` +- AES-256-GCM under PBKDF2 (200k iters, SHA-256) master-password key +- BIP39 mnemonic → seed → HKDF purpose root +- Deterministic derivation per (domain, username, version, rules) +- Settings UI: setup / locked / unlocked panels, add/edit/reveal/copy/delete +- IPC surface: pwStatus/Setup/Unlock/Lock/List/Get/Add/Update/Remove/Generate +- 12 crypto unit tests, all passing + +### A.2 — Autofill (~ 1 week) +The user-visible next step. Right now users must copy from Settings. + +- **ContentScript** injected on every page (Electron + `session.defaultSession.setPreloads` OR per-webContents preload extension) +- Watch `input[type=password]` + adjacent username field on load AND on + DOM mutation (SPAs). Debounce. +- **Origin binding via eTLD+1** using the public suffix list. Ship a + bundled snapshot of https://publicsuffix.org/list/public_suffix_list.dat + under `Argus/data/` so `evil-google.com` can't fill `google.com`. +- **Toolbar affordance**: a key icon in the address bar OR the shield's + overlay grows a "Passwords" chip when entries exist for the current + origin. Click → pick which credential to fill. +- **Keyboard shortcut**: Ctrl+Shift+L to fill the current form. +- **Save prompt**: when a form submit yields a password field the vault + hasn't seen, prompt "Save this password? (Generate strong one instead?)" +- **Per-origin permission** cached: sites that were already refused don't + re-prompt. +- Tests: origin-binding table (eTLD+1 for suffix corner cases — + `*.co.uk`, `*.compute.amazonaws.com`, IDN), autofill DOM injection + vector coverage. + +### A.3 — Sia backup / cross-device restore (~ 3-5 days) +Opt-in cloud sync of the encrypted vault blob. + +- New setting: "Sync vault to Sia". Off by default. +- User provides their own Sia S3 endpoint + credentials (like the operator's + `sia-s3.json`), OR opts to use the operator's public relay at + `sia://silentmode.st/vault/<vaultId>` (metadata leaks: only "this + vaultId exists", nothing else — vault is encrypted end-to-end). +- On save: upload the encrypted-blob bytes to `bucket/vault-<id>.bin`. + Include an integrity hash in the local vault header. +- On new-device setup: user picks "Restore from Sia" → enters vaultId + + master password → download blob → decrypt. +- **Explicit non-goal**: no operational sync (last-write-wins across + devices, no merge). Phase A.3 is "clone the vault to another laptop", + not "keep two laptops perfectly in step". Multi-device concurrent-edit + is A.4 if we ever want it. + +### A.4 — Nice-to-haves +Order these however makes sense; each is small and independent. +- Import from Bitwarden JSON / KeePass CSV / Firefox CSV +- Export to same +- Password strength meter on the paste-a-password entry +- Auto-lock after N minutes of Theseus idle +- Password-history per entry (previous versions kept, so a rotation + doesn't lose the old password until the user confirms it's no longer + needed anywhere) +- Compromised-password check via HIBP k-anonymity API (opt-in, sends + a 5-char SHA-1 prefix only) +- Password-strength / composition rules from the site's `passwordrules` + attribute (WICG spec) so generated passwords satisfy site policy + +## Strand B — Browser-integrated BCH wallet + +MetaMask-style `window.bcnr` (or `window.bch`) provider that lets a page +request signatures, payments, and name operations from the user's wallet. +The wallet already exists in `site/js/` for the registrar flow; this +strand generalises it to a per-origin API for any page. + +### B.1 — Design (start here) (~ 2-3 days) + +Ship the doc before writing code. Two hard problems to nail: + +1. **Permission model**. Chrome dapp providers have taught us how easily + this becomes malware infrastructure. The doc must lock down: + - Permission granularity: per-origin, per-method, per-amount + - First-use always shows a modal (never auto-approves) + - Persistent permissions revocable in Settings > Wallet > Sites + - "You're on a BCNR name backed by an on-chain contract" vs "You're + on random-site.com" — trust badges the user can see before signing + - Anti-phishing: display the recipient's cash address in the + confirmation dialog with a big warning if the address changes + mid-session + +2. **API surface**. Match existing convention where possible so BCH dApp + developers don't learn a Silent Mode-only dialect: + - `bcnr.getAccounts()` — returns cash addresses the user has approved + for this origin + - `bcnr.requestAccount()` — first-use modal + - `bcnr.signMessage({message, address})` — BIP-137 style, used for + BCNR name registration and auth flows + - `bcnr.sendPayment({to, amount, memo})` — modal-confirmed BCH send + - `bcnr.resolveName(name)` — no permission needed; BCNR lookup + - `bcnr.registerName({name, records})`, `bcnr.updateName(name, records)` + — modal-confirmed; ties into the site/register.html flow + +Deliverable: `TheseusNavigator/DESIGN-integrated-wallet.md` in the same +shape as `DESIGN-password-manager.md`. + +### B.2 — Injection scaffold + read-only APIs (~ 1 week) + +- ContentScript that injects `window.bcnr` on every page load. Isolated + world (Electron's `contextIsolation: true` already enforces this) so + page JS can't tamper with the provider. +- Provider is an event-emitting object that speaks JSON-RPC over + postMessage to the preload, which forwards to main via IPC. +- Read-only methods (no permission needed): `resolveName`, `getBcnrTlds`, + `isRegistered`, `getRecordVersion`. +- Tests: page-script → provider → main round trip; provider surface + matches the spec. + +### B.3 — Signature + payment APIs (~ 2-3 weeks) + +- Permission-gated: `requestAccount`, `signMessage`, `sendPayment`. +- Reuse the existing wallet in `site/js/` (libauth-based) as the signing + core; wrap it in a main-process handler that reads the wallet's + `wallets.json` (already encrypted with the wallet passphrase). +- Modal confirmations rendered as a new WebContentsView overlay (same + pattern as popover/engine-picker/downloads). +- Site permissions persisted in `userData/wallet-permissions.json`. +- Settings → Wallet section: list connected sites, revoke per-site or + per-method. + +### B.4 — BCNR name operations (~ 1 week) + +- `registerName`, `updateName` — thin wrappers over the existing + `Argus/src/register.js` / `update.js` flows. +- Sanity: same modal-first pattern; show the exact record payload before + signing (users need to see what's going on-chain permanently). + +### B.5 — Nostr messenger integration (~ 2-3 weeks) + +Bring the messenger strand in as a parallel purpose. Same seed under +`m/1414'/0'`, so users who set up passwords in strand A already have +messenger keys. + +- Derive Nostr keys (secp256k1, npub/nsec encoding) from the messenger + purpose root +- `window.nostr` NIP-07 provider (widely-supported protocol, dozens of + compatible clients) +- Chrome integration: address bar chip when a Nostr-native site connects +- Threat model: Nostr keys don't hold funds, but do bind identity — + compromise = impersonation. Same modal-first permission model. + +### B.6 — Advanced: connect-wallet UX + +- QR-pairing so a mobile Ariadne can act as a hardware-wallet-like + signer for a desktop Theseus +- Multisig confirmations (2-of-3 with a hardware wallet + Theseus + + Nostr-relayed remote signer) + +## Cross-strand dependencies + +- Both strands rely on the crypto module at + `Argus/src/lib/password-vault.js`. Extract `bip39ToSeed` + + `seedToPurposeRoot` into a `seed-derive.js` module the wallet strand + can also import cleanly. +- Both strands should share the master-password unlock. If a user has + the vault unlocked and the wallet locked (or vice versa), the second + unlock should not re-prompt. + +## Non-goals + +- No third-party cloud sync. Sia is the only sync target we bless. +- No Chromium password-manager UI reuse. Ergonomics don't fit our threat + model (Google Sync-shaped). +- No Ethereum / Solana / other-chain support in Strand B. This is BCH- + specific; multi-chain is a separate future decision. +- No hardware-wallet abstraction layer in phase 1. Ledger/Trezor + integration is a phase 4 conversation. +- No autofill for credit cards, addresses, or contact forms. Each has + security nuances that deserve their own design pass. Password-only. diff --git a/SESSION-PROMPT-identity-wallet.md b/SESSION-PROMPT-identity-wallet.md new file mode 100644 index 00000000..a1954991 --- /dev/null +++ b/SESSION-PROMPT-identity-wallet.md @@ -0,0 +1,105 @@ +# Session kickoff — identity + wallet work + +Paste the block below into a fresh coding-CLI session started in +`D:\Dev\SilentMode`. Zero prior context needed. + +--- + +You are picking up **identity + wallet** work on Silent Mode's **Theseus +Navigator** — the Electron browser with in-process BCNR resolution. Two +independent strands: **password manager** (phase 1 shipped, needs +autofill + Sia sync next) and **browser-integrated BCH wallet** (design ++ scaffold, then permissioned APIs). Both derive from the same BCH-style +seed via distinct hardened purpose subtrees. + +**Read these first, in order, before touching anything:** +1. `HANDOFF.md` — session hand-off protocol + subproject index. Learn + the `PENDING.md` per-subproject convention, the session-naming rule, + and coordination guidance for concurrent sessions. +2. `TheseusNavigator/README.md` — scope + status of the browser. +3. `TheseusNavigator/PENDING.md` — current uncommitted work, grouped by + session. Read the other session's group FIRST so you don't stomp. +4. `TheseusNavigator/GOTCHAS.md` — non-obvious traps (`build.files`, + admin-terminal-for-first-build, native-select popup theme, DNS + hosts-pin for Sia upload, etc.). Save yourself an hour. +5. `TheseusNavigator/ROADMAP-identity-wallet.md` — the full roadmap for + both strands, sorted into small deliverables. +6. `TheseusNavigator/DESIGN-password-manager.md` — the crypto discipline + + threat model. Any new derivation MUST reuse the "purpose subtree" + pattern documented there. +7. If you'll touch the wallet strand: read + `Argus/src/lib/register-tx.js` + `Argus/src/lib/wallet-web.js` to see + how the existing signing works, and `site/register.html` for how the + built-in wallet UI is currently invoked from a page. +8. `SESSION-CONTEXT.md` — the product context (BCNR terminology, + resolver rules, hosting model, on-chain invariants). + +**What is already shipped (Theseus 0.0.3, `9c09955e…`):** +- Password vault + deterministic derivation (`Argus/src/lib/password-vault.js`, + 12 tests passing). +- Settings > Passwords section: setup / locked / unlocked panels; add, + reveal, copy, delete entries. No autofill yet — users copy from + Settings and paste into sites. +- Everything from earlier UX rounds: shield security badge, three-tier + engine catalog, download tracker, cache/history storage settings, + fingerprint spoofing selects, theme cards. + +**What has NOT been designed yet — flag these decisions before starting:** +- Wallet strand's permission model + API surface. Write + `TheseusNavigator/DESIGN-integrated-wallet.md` before B.2+ code. +- The autofill contentScript's origin-binding rule details (eTLD+1 via + public-suffix list — bundle the snapshot; do not fetch at runtime). +- Sia sync UX (user's own credentials vs operator relay). Design in the + A.3 write-up before code. + +**Coordination:** +- A parallel session (labeled `parallel:*` in PENDING.md) has been very + active in this repo — mobile Ariadne, VPS infrastructure, docs, and + frequent snapshot commits. Look at `git log --oneline -20` before + touching a file to know if it recently changed. Commit small and often + so your working tree isn't a shared bucket. +- Use session label `YYYY-MM-DD:identity-wallet` in `PENDING.md` for + anything you leave uncommitted. +- Deploys are one-at-a-time. Check `PENDING.md` before starting one. + +**Ship discipline (from `HANDOFF.md` and `GOTCHAS.md`):** +- Every new `WebContentsView` or `loadFile` — add the target file to + `TheseusNavigator/package.json` `build.files`. Verify present in + `dist-public/win-unpacked/resources/app.asar` after build (there's a + one-liner probe in `GOTCHAS.md`). +- Build: from `TheseusNavigator/`, `SOURCE_DATE_EPOCH=<epoch> + CSC_IDENTITY_AUTO_DISCOVERY=false npm run dist`. Admin terminal only + the first time on a fresh machine (winCodeSign symlinks). +- Ship sequence: backup live to `/opt/silent-mode/dl/_prev/`, scp new + `.exe`s + manifest, `node Argus/src/lib/sia-upload.js ../site + bns/silentmode`. Rollback commands documented in `PENDING.md` under + "Last shipment". +- On-chain `releases.silentmode.bch` publishes the manifest URL, not + fixed hashes — you don't need a wallet spend to make new hashes + reachable via BCNR unless the URL itself changes. + +**Where to start (pick one, in order of user demand):** +1. **A.2 — password autofill.** The user-visible next step. Small enough + to ship in one round. Users are currently copying passwords from + Settings; autofill removes that friction. Design detail is in + `ROADMAP-identity-wallet.md`. +2. **B.1 — wallet design doc.** No code yet. Write + `DESIGN-integrated-wallet.md`, get user alignment on the permission + model, then B.2 (injection scaffold + read-only APIs). +3. **A.3 — Sia backup.** Small feature, unlocks cross-device story. + Blocks on user picking "own creds vs operator relay" default. + +**Then tell me what you want to do.** If unspecified, start with A.2 +(autofill) — it's the highest-leverage next step in Strand A and the +crypto foundation is already tested + shipped. + +**Conventions:** +- Prefix shell commands with `rtk` (user convention, see + `~/.claude/CLAUDE.md`). +- Windows + PowerShell primary; Bash tool available for POSIX. +- Chipnet only unless explicitly directed to touch mainnet. +- Never ship secrets (`Argus/wallets.json`, `sia-s3.json`, + `ca/root-ca.key`). The build enforces this; don't weaken the check. +- Never put the operator's name or email in any file. +- Recovery phrases, elevated steps, captchas, and on-chain wallet + spends are the user's to perform — hand off with exact commands. diff --git a/addon-inject-preload.js b/addon-inject-preload.js new file mode 100644 index 00000000..50711b99 --- /dev/null +++ b/addon-inject-preload.js @@ -0,0 +1,36 @@ +// Session-wide preload that runs every page-inject add-on's bridge script in +// the isolated world of tabs whose URL matches the add-on's declared origin +// patterns. Registered via session.defaultSession.setPreloads in main.js +// alongside bcnr-preload.js. +// +// The decision of WHICH scripts apply is made in main against the sender's +// committed URL, not against anything the page can influence. Each script +// gets a `theseus` object scoped to its add-on id: +// theseus.contextBridge — expose an API into the page's main world +// theseus.invoke(msg, payload) — call the add-on's onMessage(msg) handler +// theseus.origin — the page origin main will show the user +// plus a `require` that only resolves "electron" so scripts written in the +// ordinary preload idiom keep working. +const { contextBridge, ipcRenderer } = require("electron"); + +let injections = []; +try { injections = ipcRenderer.sendSync("addon-inject-scripts", location.href) || []; } +catch (e) { console.warn("[theseus] add-on inject query failed:", e?.message || e); } +for (const inj of injections) { + const id = String(inj.id); + const theseus = Object.freeze({ + id, + origin: inj.origin, + contextBridge, + invoke: (msg, payload) => ipcRenderer.invoke("addon-page-msg", id, String(msg), payload), + }); + const scopedRequire = (name) => { + if (name === "electron") return { contextBridge }; + throw new Error(`addon inject scripts may only require("electron") — got ${name}`); + }; + try { + new Function("theseus", "require", inj.source)(theseus, scopedRequire); + } catch (e) { + console.warn(`[theseus] add-on "${id}" page-inject failed:`, e?.message || e); + } +} diff --git a/addon-tab-preload.js b/addon-tab-preload.js new file mode 100644 index 00000000..b8a49b1d --- /dev/null +++ b/addon-tab-preload.js @@ -0,0 +1,20 @@ +// Preload for full-tab pages opened by api.openTab("<file>", {query}) — the +// "open-tab" capability. Same window.silentmode surface as the sidebar +// preload (storage / invoke / on) but WITHOUT the sidebar's picker strip and +// resize grip, which have no place in a normal tab. Main derives the add-on +// identity from the sender's file:// URL, so a page hosted anywhere else +// gets nothing back from these handlers. +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("silentmode", { + storage: { + get: (key, fallback = null) => ipcRenderer.invoke("addon-storage-get", key, fallback), + set: (key, value) => ipcRenderer.invoke("addon-storage-set", key, value), + all: () => ipcRenderer.invoke("addon-storage-all"), + }, + invoke: (msg, payload) => ipcRenderer.invoke("addon-msg", String(msg), payload), + on: (msg, cb) => ipcRenderer.on("addon-event", (_e, name, payload) => { if (name === msg) cb(payload); }), + // Close the tab this page lives in. Used by editor "Discard" and by + // Escape. Main derives the tab from the sender's webContents id so an + // add-on page can only close its own tab, never anyone else's. + closeTab: () => ipcRenderer.invoke("addon-tab-close"), +}); diff --git a/addon-update-pubkeys.js b/addon-update-pubkeys.js new file mode 100644 index 00000000..e8e32ce0 --- /dev/null +++ b/addon-update-pubkeys.js @@ -0,0 +1,21 @@ +// Ed25519 public keys authorized to sign bundled-addon updates. +// +// A pubkey listed here can, at runtime, cause Theseus to REPLACE any user's +// bundled add-on with a signed payload fetched over the network. Only add +// pubkeys the Silent Mode operator controls. +// +// Rotation: +// 1. Generate a new keypair with scripts/generate-update-keypair.mjs. +// 2. Sign next updates.json entries with BOTH old and new key. +// 3. Ship a Theseus release adding the new pubkey to this array (both live). +// 4. After users have updated past that release, ship a follow-up release +// removing the old pubkey; stop signing with it. +// +// Empty array means "no update endpoint" — the client short-circuits and +// makes no outbound requests. This is the safe default for a fresh build. + +module.exports = { + PUBKEYS_HEX: [ + "732b1263a236b0030383a2376597cfa43c3624b3ca2912a46134f8f2a06e6012", // Silent Mode ops, generated 2026-09-07 + ], +}; diff --git a/addon-updater.js b/addon-updater.js new file mode 100644 index 00000000..f89fce5f --- /dev/null +++ b/addon-updater.js @@ -0,0 +1,290 @@ +// Signed add-on update client. +// +// A bundled add-on can advertise an updateURL in its addon.json. On a +// background boot task, Theseus fetches that URL, expects a signed +// updates.json manifest, verifies the signature against a hardcoded set +// of operator pubkeys (addon-update-pubkeys.js), and stages any newer +// signed version under <userData>/addons-updates-staged/<id>-<version>/. +// The NEXT launch's promoteStagedUpdates() moves the staged copy into +// <userData>/addons/<id>/, reusing the same backup dance seedBundledAddons +// already uses so any local edits the user made survive. +// +// Trust model: +// - Signature: Ed25519 over "silentmode.addon-update-v1|<id>|<version>| +// <tarball-sha256>", verified against any pubkey in PUBKEYS_HEX. +// - Payload: gzipped tar. SHA-256 is checked against the signed manifest +// before extraction. Extraction uses the system `tar` (shipped with +// Win10 1803+, present on macOS/Linux) via execFileSync; no npm deps. +// - The extracted addon.json's id + version must match the manifest, +// otherwise the stage is discarded. +// - Empty pubkey list = skip everything, no outbound requests. + +const fs = require("node:fs"); +const fsp = require("node:fs/promises"); +const path = require("node:path"); +const os = require("node:os"); +const crypto = require("node:crypto"); +const https = require("node:https"); +const http = require("node:http"); +const { execFileSync } = require("node:child_process"); + +const SIG_DOMAIN = "silentmode.addon-update-v1"; +const MAX_MANIFEST_BYTES = 128 * 1024; // updates.json shouldn't exceed 128 KB +const MAX_TARBALL_BYTES = 16 * 1024 * 1024; // an add-on payload above 16 MB is suspicious +const MAX_REDIRECTS = 3; + +function cmpVer(a, b) { + const A = String(a || "").split(".").map((n) => parseInt(n, 10) || 0); + const B = String(b || "").split(".").map((n) => parseInt(n, 10) || 0); + const L = Math.max(A.length, B.length); + for (let i = 0; i < L; i++) { + const x = A[i] || 0, y = B[i] || 0; + if (x !== y) return x < y ? -1 : 1; + } + return 0; +} + +function readAddonJson(dir) { + try { return JSON.parse(fs.readFileSync(path.join(dir, "addon.json"), "utf8")); } + catch { return null; } +} + +// ---------- staged-update promotion -------------------------------------- +// Called BEFORE seedBundledAddons at boot. Any staged folder whose version +// beats the currently installed copy is promoted; older or matching stages +// are cleaned up so they don't loop on every boot. +function promoteStagedUpdates({ addonsDir, backupsDir, stagedDir, logger }) { + const log = logger || (() => {}); + if (!fs.existsSync(stagedDir)) return; + let entries; + try { entries = fs.readdirSync(stagedDir, { withFileTypes: true }); } + catch (e) { log("promote: readdir failed:", e?.message); return; } + for (const de of entries) { + if (!de.isDirectory()) continue; + const from = path.join(stagedDir, de.name); + const manifest = readAddonJson(from); + if (!manifest || !manifest.id || !manifest.version) { + log(`promote: drop malformed stage ${de.name}`); + try { fs.rmSync(from, { recursive: true, force: true }); } catch {} + continue; + } + const target = path.join(addonsDir, manifest.id); + const currentVer = readAddonJson(target)?.version; + if (currentVer && cmpVer(currentVer, manifest.version) >= 0) { + log(`promote: drop stage ${manifest.id}@${manifest.version} — installed ${currentVer} is newer or equal`); + try { fs.rmSync(from, { recursive: true, force: true }); } catch {} + continue; + } + try { fs.mkdirSync(backupsDir, { recursive: true }); } catch {} + if (fs.existsSync(target)) { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backup = path.join(backupsDir, `${manifest.id}-${currentVer ?? "unknown"}-${stamp}`); + try { fs.renameSync(target, backup); } + catch (e) { log(`promote: backup ${manifest.id} failed, keeping staged for next boot:`, e?.message); continue; } + } + try { fs.renameSync(from, target); log(`promote: ${manifest.id} -> ${manifest.version}`); } + catch (e) { + // Cross-drive rename can fail on Windows; copy then rm. + try { fs.cpSync(from, target, { recursive: true }); fs.rmSync(from, { recursive: true, force: true }); log(`promote: ${manifest.id} -> ${manifest.version} (via copy)`); } + catch (ee) { log(`promote: move ${manifest.id} failed:`, ee.message); } + } + } +} + +// ---------- HTTP helpers -------------------------------------------------- +function httpGet(url, { timeoutMs = 15000, maxBytes = MAX_MANIFEST_BYTES, redirectsLeft = MAX_REDIRECTS } = {}) { + return new Promise((resolve, reject) => { + let u; + try { u = new URL(url); } catch (e) { return reject(new Error(`bad url: ${url}`)); } + if (u.protocol !== "http:" && u.protocol !== "https:") return reject(new Error(`unsupported scheme: ${u.protocol}`)); + const lib = u.protocol === "http:" ? http : https; + const req = lib.get(u, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); + if (redirectsLeft <= 0) return reject(new Error(`too many redirects for ${url}`)); + const next = new URL(res.headers.location, url).toString(); + return resolve(httpGet(next, { timeoutMs, maxBytes, redirectsLeft: redirectsLeft - 1 })); + } + if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode} for ${url}`)); } + const chunks = []; + let total = 0; + res.on("data", (c) => { + total += c.length; + if (total > maxBytes) { req.destroy(new Error(`response over ${maxBytes} bytes`)); return; } + chunks.push(c); + }); + res.on("end", () => resolve(Buffer.concat(chunks))); + res.on("error", reject); + }); + req.setTimeout(timeoutMs, () => req.destroy(new Error(`timeout ${timeoutMs}ms for ${url}`))); + req.on("error", reject); + }); +} + +// ---------- Ed25519 signature verification ------------------------------- +// Node accepts raw Ed25519 pubkeys via SPKI-wrapped DER. We prepend the +// 12-byte header for the OID + BIT STRING framing so verify() takes it. +function verifySignature(id, version, tarballSha256, sigB64, pubkeysHex) { + const canonical = `${SIG_DOMAIN}|${id}|${version}|${tarballSha256}`; + let sig; + try { sig = Buffer.from(sigB64, "base64"); } catch { return false; } + if (sig.length !== 64) return false; + const msg = Buffer.from(canonical); + for (const hex of pubkeysHex) { + const pkRaw = Buffer.from(hex, "hex"); + if (pkRaw.length !== 32) continue; + const der = Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), pkRaw]); + let key; + try { key = crypto.createPublicKey({ key: der, format: "der", type: "spki" }); } + catch { continue; } + try { if (crypto.verify(null, msg, key, sig)) return true; } catch { /* next */ } + } + return false; +} + +// ---------- single-add-on staging ---------------------------------------- +async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, log, timeoutMs }) { + let manifestBuf; + try { manifestBuf = await httpGet(updateURL, { timeoutMs, maxBytes: MAX_MANIFEST_BYTES }); } + catch (e) { log(`updates: fetch ${id} failed:`, e.message); return { status: "fetch-failed", detail: e.message }; } + + let manifest; + try { manifest = JSON.parse(manifestBuf.toString("utf8")); } + catch (e) { log(`updates: manifest ${id} unparseable:`, e.message); return { status: "fetch-failed", detail: "manifest not JSON: " + e.message }; } + + const addons = Array.isArray(manifest?.addons) ? manifest.addons : []; + let best = null; + for (const e of addons) { + if (!e?.version || !e?.url || !e?.sha256 || !e?.sig) continue; + if (currentVer && cmpVer(e.version, currentVer) <= 0) continue; + if (!best || cmpVer(e.version, best.version) > 0) best = e; + } + if (!best) return { status: "up-to-date" }; + + if (!verifySignature(id, best.version, best.sha256, best.sig, pubkeysHex)) { + log(`updates: ${id}@${best.version} signature INVALID, skipping`); + return { status: "signature-invalid", newVer: best.version }; + } + + const stageOut = path.join(stagedDir, `${id}-${best.version}`); + const stagedVer = readAddonJson(stageOut)?.version; + if (stagedVer === best.version) { log(`updates: ${id}@${best.version} already staged`); return { status: "already-staged", newVer: best.version, stagePath: stageOut }; } + if (fs.existsSync(stageOut)) { try { fs.rmSync(stageOut, { recursive: true, force: true }); } catch {} } + + let tarball; + try { tarball = await httpGet(best.url, { timeoutMs: 60000, maxBytes: MAX_TARBALL_BYTES }); } + catch (e) { log(`updates: tarball ${id}@${best.version} fetch failed:`, e.message); return { status: "fetch-failed", newVer: best.version, detail: "tarball: " + e.message }; } + + const gotHash = crypto.createHash("sha256").update(tarball).digest("hex"); + if (gotHash.toLowerCase() !== String(best.sha256).toLowerCase()) { + log(`updates: ${id}@${best.version} sha256 mismatch (${gotHash} vs ${best.sha256}), skipping`); + return { status: "sha256-mismatch", newVer: best.version }; + } + + const tag = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`; + const tmpFile = path.join(os.tmpdir(), `sm-addon-${id}-${best.version}-${tag}.tgz`); + const tmpDir = path.join(os.tmpdir(), `sm-addon-${id}-${best.version}-${tag}.dir`); + try { + await fsp.writeFile(tmpFile, tarball); + await fsp.mkdir(tmpDir, { recursive: true }); + // Refuses `..` entries by default in modern tar. -P NOT passed = paths + // stay stripped/relative. + // Two Windows tar quirks we sidestep with one small trick: + // 1. Git-Bash tar (MSYS2) sees drive letters as host:file remote syntax + // without --force-local. + // 2. Even with --force-local, MSYS2's argument-conversion layer mangles + // backslashes it doesn't understand, so `C:\Users\...\Temp\dir` + // arrives at tar as `C:\\Users...\\dir` and it can't open the path. + // Forward-slash paths dodge both — bsdtar (Win10 built-in), GNU tar, + // and MSYS2 tar all accept `C:/Users/...` as a plain path. + // BUT: Windows 10's built-in bsdtar does NOT recognise --force-local at + // all and errors out with "unknown option". Try WITHOUT the flag first + // (safe with posix paths on every tar we care about) and fall back to + // WITH it for MSYS2 tar which parses `C:/…` as a host prefix. Either + // path is a single tar invocation — the fallback only fires on the + // exit-code failure of the first. + const posix = (p) => p.replace(/\\/g, "/"); + const baseArgs = ["-x", "-z", "-f", posix(tmpFile), "-C", posix(tmpDir)]; + try { + execFileSync("tar", baseArgs, { stdio: "ignore" }); + } catch (e1) { + try { + execFileSync("tar", ["--force-local", ...baseArgs], { stdio: "ignore" }); + } catch (e2) { + // Surface the first error; --force-local retry is opportunistic. + throw e1; + } + } + } catch (e) { + log(`updates: extract ${id}@${best.version} failed:`, e.message); + try { fs.rmSync(tmpFile, { force: true }); } catch {} + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} + return { status: "extract-failed", newVer: best.version, detail: e.message }; + } + + const extracted = readAddonJson(tmpDir); + if (!extracted || extracted.id !== id || extracted.version !== best.version) { + log(`updates: extracted ${id}@${best.version} manifest mismatch (got ${extracted?.id}@${extracted?.version}), dropping`); + try { fs.rmSync(tmpFile, { force: true }); } catch {} + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} + return { status: "manifest-mismatch", newVer: best.version, detail: `got ${extracted?.id}@${extracted?.version}` }; + } + try { fs.mkdirSync(stagedDir, { recursive: true }); } catch {} + try { fs.renameSync(tmpDir, stageOut); } + catch { + try { fs.cpSync(tmpDir, stageOut, { recursive: true }); fs.rmSync(tmpDir, { recursive: true, force: true }); } + catch (ee) { log(`updates: stage move ${id}@${best.version} failed:`, ee.message); return { status: "extract-failed", newVer: best.version, detail: "stage move: " + ee.message }; } + } + try { fs.rmSync(tmpFile, { force: true }); } catch {} + log(`updates: staged ${id}@${best.version} — will apply on next launch`); + return { status: "staged", newVer: best.version, stagePath: stageOut }; +} + +// Returns a `report` array: one entry per installed add-on the client +// considered — { id, currentVer, updateURL, status, detail? }. Status is +// one of: "no-update-url" | "fetch-failed" | "up-to-date" | "signature-invalid" +// | "sha256-mismatch" | "extract-failed" | "manifest-mismatch" +// | "staged" | "already-staged". The manual UI in Settings > Extensions +// uses this to tell the user WHY nothing landed instead of a single +// "up to date" that hides fetch/verify failures. +async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, timeoutMs = 15000, logger }) { + const log = logger || (() => {}); + const report = []; + if (!Array.isArray(pubkeysHex) || pubkeysHex.length === 0) { + log("updates: no operator pubkeys configured — skipping update check"); + return { report, skipped: "no-pubkeys" }; + } + let entries; + try { entries = fs.readdirSync(addonsDir, { withFileTypes: true }); } + catch { return { report, skipped: "no-addons-dir" }; } + const pending = []; + for (const de of entries) { + if (!de.isDirectory()) continue; + const manifest = readAddonJson(path.join(addonsDir, de.name)); + if (!manifest?.id) continue; + if (!manifest?.updateURL) { + report.push({ id: manifest.id, currentVer: manifest.version, updateURL: null, status: "no-update-url" }); + continue; + } + pending.push( + stageOne({ + id: manifest.id, + currentVer: manifest.version, + updateURL: manifest.updateURL, + stagedDir, pubkeysHex, log, timeoutMs, + }) + .then((r) => report.push({ id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, ...r })) + .catch((e) => { log(`updates: ${manifest.id} unexpected error:`, e.message); report.push({ id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, status: "unexpected-error", detail: e.message }); }) + ); + } + await Promise.all(pending); + return { report }; +} + +module.exports = { + cmpVer, + promoteStagedUpdates, + checkAndStageUpdates, + verifySignature, + SIG_DOMAIN, +}; diff --git a/addons-host.js b/addons-host.js new file mode 100644 index 00000000..3e6031fa --- /dev/null +++ b/addons-host.js @@ -0,0 +1,616 @@ +// Theseus add-on framework — loader + API surface. +// +// Add-ons live in <userData>/addons/<id>/ as ordinary folders on disk. Each +// carries an `addon.json` manifest and (per the manifest's `main` field) a +// CommonJS entry that exports `activate(api)` and optionally `deactivate()`. +// Nothing about an add-on ships in the Theseus repo or installer — drop a +// folder, restart Theseus, it's live. This is the same trust model as +// dev-mode browser extensions: the user is choosing to run local code with +// the app's full privileges. +// +// Loading is synchronous at app-ready time; there is no hot-reload. Failed +// activations are logged and skipped without breaking the app. +// +// Persistence: +// settings.disabledAddons — ids the user has toggled off +// <userData>/addons-data/<id>.json — per-add-on kv store (api.storage) + +const fs = require("node:fs"); +const path = require("node:path"); + +// Extension points the framework understands. Extending this list means also +// teaching main.js and (typically) the chrome renderer about the new point. +// Right now only sidebar panels are wired — future rev adds toolbar-chip, +// proxy, page-inject, etc. +const KNOWN_CAPABILITIES = new Set([ + "sidebar-panel", "session-proxy", + // vault-derive: api.vault.derive(purposePath) — HKDF child of the password + // vault's root, namespaced under the add-on id. + // page-inject: manifest["page-inject"] = { preload, origins } — the add-on's + // preload source runs in the isolated world of every tab whose + // URL matches one of the origin patterns. + // approval-modal: api.approvalModal({...}) — user-facing consent dialog over + // the active tab, resolved by main. + // capture-tab: api.captureTab({mode, ...}) + api.saveCapture({dataUrl, filename}) + // — snapshot the active tab (visible viewport / full page / + // user-drawn rectangle) and save the result through the app's + // downloads pipeline. The add-on sees pixels of whatever the + // current tab is showing, so this is the same trust bar as a + // page-inject add-on that matches "*://*/*". + // toolbar-menu: manifest["toolbar-menu"] = { title?, icon?, items:[{id,label,icon?}] } + // — chrome renders a dropdown under the add-on's dock icon; + // picking an item dispatches "menu-select" with {id} to the + // add-on's onMessage("menu-select", …) handler. + // open-tab: api.openTab(pathOrUrl, {query?}) — for a bare http(s) URL + // this stays available without the capability (legacy). + // Declaring "open-tab" additionally lets the add-on open + // one of its OWN HTML files as a full Theseus tab, with a + // lean preload so the page can keep talking to the add-on + // via window.silentmode.invoke(). + "vault-derive", "page-inject", "approval-modal", "capture-tab", + "toolbar-menu", "open-tab", +]); + +// Chrome-style match pattern → predicate. "<scheme>://<host>/<path>" where +// scheme may be "*", host may start with "*." (matches the bare host and any +// subdomain) or be "*", and path is a glob where "*" matches anything. +// bns:// (how Theseus fetches BCNR sites internally) is folded into https:// +// so a pattern written the way the address bar shows it keeps working. +function compileOriginPattern(pattern) { + const m = /^(\*|[a-z][a-z0-9+.-]*):\/\/(\*|\*\.[^/*]+|[^/*]+)(\/.*)?$/i.exec(String(pattern).trim()); + if (!m) throw new Error(`bad origin pattern: ${pattern}`); + const [, scheme, host, pathGlob = "/*"] = m; + const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const schemeRe = scheme === "*" ? "https?" : esc(scheme.toLowerCase()); + let hostRe; + if (host === "*") hostRe = "[^/]+"; + else if (host.startsWith("*.")) hostRe = `(?:[^/]+\\.)?${esc(host.slice(2).toLowerCase())}`; + else hostRe = esc(host.toLowerCase()); + const pathRe = pathGlob.split("*").map(esc).join(".*"); + const re = new RegExp(`^${schemeRe}://${hostRe}(?::\\d+)?${pathRe}$`, "i"); + return (url) => re.test(String(url).replace(/^bns:\/\//i, "https://")); +} +function urlMatchesAny(url, matchers) { + for (const fn of matchers) { try { if (fn(url)) return true; } catch {} } + return false; +} + +// Manifest field guardrails. Reject anything shape-suspicious so a bad +// addon.json can't get past the loader gate. +function validateManifest(raw, folderName) { + const m = raw && typeof raw === "object" ? raw : {}; + const id = String(m.id || "").trim(); + if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(id)) { + throw new Error(`invalid or missing "id" (allowed: [a-z0-9._-], up to 64 chars) — folder ${folderName}`); + } + const name = String(m.name || id); + const version = String(m.version || "0.0.0"); + const description = String(m.description || ""); + const author = String(m.author || ""); + const icon = String(m.icon || "🧩"); + const main = String(m.main || "index.js"); + if (main.includes("..") || path.isAbsolute(main)) { + throw new Error(`addon "${id}": main must be a relative path inside the addon folder`); + } + const capabilities = Array.isArray(m.capabilities) ? m.capabilities.map(String) : []; + for (const cap of capabilities) { + if (!KNOWN_CAPABILITIES.has(cap)) { + // Not fatal — log later. Unknown caps are silently dropped. + } + } + let pageInject = null; + if (capabilities.includes("page-inject")) { + const pi = m["page-inject"]; + if (!pi || typeof pi !== "object") throw new Error(`addon "${id}": "page-inject" capability needs a "page-inject" manifest block`); + const preload = String(pi.preload || ""); + if (!preload || preload.includes("..") || path.isAbsolute(preload)) { + throw new Error(`addon "${id}": page-inject.preload must be a relative path inside the addon folder`); + } + const origins = Array.isArray(pi.origins) ? pi.origins.map(String) : []; + if (!origins.length) throw new Error(`addon "${id}": page-inject.origins must list at least one pattern`); + pageInject = { preload, origins, matchers: origins.map(compileOriginPattern) }; + } + let toolbarMenu = null; + if (capabilities.includes("toolbar-menu")) { + const tm = m["toolbar-menu"]; + if (!tm || typeof tm !== "object") { + throw new Error(`addon "${id}": "toolbar-menu" capability needs a "toolbar-menu" manifest block`); + } + const items = Array.isArray(tm.items) ? tm.items : []; + if (!items.length) throw new Error(`addon "${id}": toolbar-menu.items must list at least one entry`); + const seen = new Set(); + const cleanItems = items.map((it, idx) => { + const iid = String(it && it.id || "").trim(); + if (!iid || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(iid)) { + throw new Error(`addon "${id}": toolbar-menu.items[${idx}].id is required and must match [a-z0-9._-]`); + } + if (seen.has(iid)) throw new Error(`addon "${id}": toolbar-menu.items[${idx}].id "${iid}" duplicates an earlier entry`); + seen.add(iid); + const label = String(it.label || iid); + const itemIcon = it.icon == null ? "" : String(it.icon); + return { id: iid, label, icon: itemIcon }; + }); + toolbarMenu = { + title: tm.title == null ? name : String(tm.title), + icon: tm.icon == null ? icon : String(tm.icon), + items: cleanItems, + }; + } + // `absorbs`: legacy add-on ids whose vault-derive namespace this add-on + // inherits. Set on a superseding add-on (e.g. aegis absorbs siawallet) so + // funds derived under the old id's paths stay reachable through the new + // one. Each entry is validated as an id itself and gates vault.derive by + // (own id OR one of these) in makeApi below. + const absorbs = Array.isArray(m.absorbs) ? m.absorbs.map(String).filter(Boolean) : []; + for (const a of absorbs) { + if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(a)) { + throw new Error(`addon "${id}": absorbs entry "${a}" is not a valid add-on id`); + } + if (a === id) throw new Error(`addon "${id}": absorbs cannot list its own id`); + } + return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, absorbs }; +} + +// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest +// of the app queries via `getActive()` / `getInstalled()`. +class AddonHost { + constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, vaultImports, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, openSettings, captureTab, saveCapture }) { + this.addonsDir = addonsDir; + this.dataDir = dataDir; + this.isDisabled = isDisabled || (() => false); + this.log = logger || ((...a) => console.log("[addons]", ...a)); + this._installed = []; // [{ manifest, folder, error? }] + this._active = new Map(); // id -> { manifest, folder, exports, sidebarPanels: [...], handlers: Map, inject } + // Capability hooks injected by main. Each is (args..., addonId) so main + // can log/gate per add-on. Missing hook = capability unavailable. + this._vaultDerive = typeof vaultDerive === "function" ? vaultDerive : null; + // vaultImports: main-process shim {list, add, remove, signer} that owns + // wallet-imports.enc. Same trust tier as vaultDerive — an add-on that + // holds vault-derive can also see imports (design §3.2 co-tenancy). + this._vaultImports = vaultImports && typeof vaultImports.list === "function" ? vaultImports : null; + // vaultLifecycle: main-process shim {status, setup, unlock, lock} so the + // wallet add-on can drive vault setup/unlock without redirecting users + // to Settings > Passwords. Same "vault-derive" capability gate. + this._vaultLifecycle = arguments[0].vaultLifecycle && typeof arguments[0].vaultLifecycle.unlock === "function" + ? arguments[0].vaultLifecycle : null; + this._approvalModal = typeof approvalModal === "function" ? approvalModal : null; + this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null; + // Add-ons live outside the app's node_modules tree, so a bare require() + // from their folder can't see Theseus's deps (ws, @noble/*, …). Main + // hands us its own require so add-ons can share the bundled tree. + this._hostRequire = typeof hostRequire === "function" ? hostRequire : null; + // ESM-only deps (@noble/*, @scure/*) can't be require()d by Electron's + // Node; hostImport resolves them from the app tree and import()s them. + this._hostImport = typeof hostImport === "function" ? hostImport : null; + this._openTab = typeof openTab === "function" ? openTab : null; + // open-tab: opens one of the add-on's own HTML files as a full Theseus tab. + // Signature: (addonId, relPath, queryString) => Promise<void>. + this._openAddonTab = typeof openAddonTab === "function" ? openAddonTab : null; + // openSettings: opens Theseus's Settings tab, optionally scrolled to a + // named section (e.g. "passwords"). Uses the same IPC route the picker + // uses for "Search settings…". Signature: (section?: string) => void. + this._openSettings = typeof openSettings === "function" ? openSettings : null; + // Session-proxy hook — injected by main so add-ons can swap the default + // session's proxy rules (e.g. a "route everything through my VPS" add-on). + // Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise<void> + // A `null` rule clears the proxy. Kept as a callback rather than requiring + // the loader itself import electron. + this._setSessionProxy = typeof setSessionProxy === "function" ? setSessionProxy : null; + // capture-tab hooks — main captures/saves; the loader only enforces the + // manifest gate. + this._captureTab = typeof captureTab === "function" ? captureTab : null; + this._saveCapture = typeof saveCapture === "function" ? saveCapture : null; + // api.whenUiReady() plumbing — see signalUiReady(). + this._uiReady = false; + this._uiReadyWaiters = []; + } + + // main calls this once the browser chrome has painted. Add-ons that pull + // in heavy dependencies (Aegis: noble curve precompute, bitcoinjs, libauth, + // WizardConnect) gate that work on api.whenUiReady() so module evaluation + // doesn't land on the main thread while chrome.html is still trying to + // paint. Sticky: a later discoverAndActivate() resolves immediately. + signalUiReady() { + this._uiReady = true; + for (const resolve of this._uiReadyWaiters.splice(0)) { try { resolve(); } catch {} } + } + + ensureDirs() { + for (const d of [this.addonsDir, this.dataDir]) { + try { fs.mkdirSync(d, { recursive: true }); } catch (e) { this.log("mkdir failed", d, e?.message); } + } + } + + // api.vault.lifecycle namespace: unlock/setup/status/lock the vault. Same + // "vault-derive" cap. Purpose: let the wallet add-on drive vault setup from + // its own gate instead of redirecting users to Settings > Passwords. + _makeLifecycleApi(manifest) { + const requireCap = () => { + if (!manifest.capabilities.includes("vault-derive")) { + throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); + } + if (!this._vaultLifecycle) throw new Error("vault.lifecycle unavailable (host not wired)"); + }; + return { + status: async () => { requireCap(); return this._vaultLifecycle.status(); }, + unlock: async (pw) => { requireCap(); return this._vaultLifecycle.unlock(String(pw || ""), manifest.id); }, + setup: async (pw, seedSource) => { requireCap(); return this._vaultLifecycle.setup(String(pw || ""), seedSource, manifest.id); }, + lock: async () => { requireCap(); return this._vaultLifecycle.lock(manifest.id); }, + }; + } + + // api.vault.imports namespace factory. Gated by the "vault-derive" cap + // because the two surfaces sit at the same trust tier (design §3.2). If + // main didn't wire the vaultImports shim, calls throw a clear error. + _makeImportsApi(manifest) { + const requireCap = () => { + if (!manifest.capabilities.includes("vault-derive")) { + throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); + } + if (!this._vaultImports) throw new Error("vault.imports unavailable (host not wired)"); + }; + return { + list: async () => { requireCap(); return this._vaultImports.list(); }, + add: async (spec) => { requireCap(); return this._vaultImports.add(spec, manifest.id); }, + remove: async (id) => { requireCap(); return this._vaultImports.remove(String(id || ""), manifest.id); }, + signer: async (id) => { requireCap(); return this._vaultImports.signer(String(id || ""), manifest.id); }, + }; + } + + discoverAndActivate() { + this.ensureDirs(); + this._deactivateAll(); + this._installed = []; + let entries = []; + try { entries = fs.readdirSync(this.addonsDir, { withFileTypes: true }); } catch { entries = []; } + for (const dirent of entries) { + if (!dirent.isDirectory()) continue; + const folder = path.join(this.addonsDir, dirent.name); + try { + const manifest = this._readManifest(folder, dirent.name); + this._installed.push({ manifest, folder }); + if (this.isDisabled(manifest.id)) { + this.log(`skipping disabled add-on ${manifest.id}`); + continue; + } + this._activateOne(manifest, folder); + } catch (e) { + this.log(`failed to load ${dirent.name}: ${e?.message || e}`); + // A manifest that parsed but whose activate() threw was already + // pushed above — replace it rather than listing the add-on twice. + const i = this._installed.findIndex((x) => x.folder === folder); + const entry = { manifest: null, folder, error: String(e?.message || e) }; + if (i >= 0) this._installed[i] = entry; else this._installed.push(entry); + } + } + return this.snapshot(); + } + + _readManifest(folder, folderName) { + const p = path.join(folder, "addon.json"); + const raw = JSON.parse(fs.readFileSync(p, "utf8")); + return validateManifest(raw, folderName); + } + + _activateOne(manifest, folder) { + const mainPath = path.join(folder, manifest.main); + // require() from a folder outside asar is fine — Electron just uses Node's + // resolver. This is where the trust decision lives: we're loading arbitrary + // JS into the main process with full API access. + let mod; + try { + // Bust the require cache so a manual reload (future feature) picks up + // edits — cheap since add-ons are small. + delete require.cache[require.resolve(mainPath)]; + mod = require(mainPath); + } catch (e) { + throw new Error(`require() failed: ${e?.message || e}`); + } + if (!mod || typeof mod.activate !== "function") { + throw new Error(`main file must export an activate(api) function`); + } + const active = { manifest, folder, exports: mod, sidebarPanels: [], handlers: new Map(), inject: null }; + if (manifest.pageInject) { + // Read the inject source once at activation. It's shipped to every + // matching tab's preload verbatim, so a syntax error surfaces in the + // tab's console, not here — but a missing file is fatal for the add-on. + const abs = path.join(folder, manifest.pageInject.preload); + let source; + try { source = fs.readFileSync(abs, "utf8"); } + catch (e) { throw new Error(`page-inject preload not readable: ${abs} (${e?.message || e})`); } + active.inject = { source, matchers: manifest.pageInject.matchers, origins: manifest.pageInject.origins }; + } + const api = this._makeApi(active); + try { mod.activate(api); } + catch (e) { throw new Error(`activate() threw: ${e?.message || e}`); } + this._active.set(manifest.id, active); + this.log(`activated ${manifest.id} v${manifest.version}`); + } + + // Tear down every active add-on before a re-discover so long-lived state + // (sockets, timers) from a previous activation doesn't pile up. + _deactivateAll() { + for (const [id, active] of this._active) { + try { if (typeof active.exports.deactivate === "function") active.exports.deactivate(); } + catch (e) { this.log(`[${id}] deactivate() threw: ${e?.message || e}`); } + } + this._active.clear(); + } + + _makeApi(active) { + const { manifest, folder } = active; + const storageFile = path.join(this.dataDir, `${manifest.id}.json`); + return { + // Metadata the add-on may want to reflect on + id: manifest.id, + folder, + log: (...a) => this.log(`[${manifest.id}]`, ...a), + // Persistent per-add-on storage. Small kv JSON on disk. + storage: { + get: (key, fallback = null) => { + try { + const raw = JSON.parse(fs.readFileSync(storageFile, "utf8")); + return key in raw ? raw[key] : fallback; + } catch { return fallback; } + }, + set: (key, value) => { + let store = {}; + try { store = JSON.parse(fs.readFileSync(storageFile, "utf8")); } catch {} + store[key] = value; + try { fs.writeFileSync(storageFile, JSON.stringify(store)); } catch (e) { this.log(`[${manifest.id}] storage.set failed:`, e?.message); } + }, + all: () => { try { return JSON.parse(fs.readFileSync(storageFile, "utf8")); } catch { return {}; } }, + }, + // Register a sidebar panel — a right-side WebContentsView that hosts + // one of the add-on's HTML pages. `page` is a path RELATIVE to the + // add-on folder. `title` shows in the sidebar tab strip. `icon` is + // a short emoji/glyph. + registerSidebarPanel: ({ id, title, icon = manifest.icon, page }) => { + if (!id || !title || !page) throw new Error(`registerSidebarPanel needs {id, title, page}`); + const abs = path.join(folder, String(page).replace(/^[\\/]/, "")); + if (!fs.existsSync(abs)) throw new Error(`sidebar panel page not found: ${abs}`); + // Namespaced id so two add-ons can't collide. + const panelId = `${manifest.id}:${id}`; + active.sidebarPanels.push({ panelId, title, icon, pageFile: abs, addonId: manifest.id }); + this.log(`[${manifest.id}] registered sidebar panel: ${panelId}`); + }, + // Swap the default session's proxy. Rules follow Chromium's proxy + // format ("socks5://1.2.3.4:1080" for a single SOCKS server, + // "http=1.2.3.4:8080;https=5.6.7.8:8080" for scheme-split HTTP, etc.). + // Pass null to clear. Add-ons that opt into this capability are + // fully replacing the browser's outgoing network path — they're + // the trust boundary while active. Same-signature as the built-in + // Tor toggle uses under the hood. + setSessionProxy: async (rules) => { + if (!this._setSessionProxy) { + this.log(`[${manifest.id}] setSessionProxy unavailable (host not wired)`); + return; + } + if (!manifest.capabilities.includes("session-proxy")) { + throw new Error(`add-on "${manifest.id}" must declare the "session-proxy" capability in addon.json`); + } + await this._setSessionProxy(rules, manifest.id); + }, + // Resolve a module from Theseus's own dependency tree. Add-ons run with + // the app's full privileges anyway; this only saves them from shipping + // a second copy of ws / @noble / etc. + require: (name) => { + if (!this._hostRequire) throw new Error(`api.require unavailable (host not wired)`); + return this._hostRequire(name); + }, + // Same, for ES-module-only packages: resolves to a Promise of the + // module namespace. + import: async (name) => { + if (!this._hostImport) throw new Error(`api.import unavailable (host not wired)`); + return this._hostImport(name); + }, + // Open a new Theseus tab. Two shapes: + // - api.openTab("https://…") — no capability needed + // - api.openTab("editor.html", { query: {...} }) — opens one of the + // add-on's OWN files as a full tab; requires the "open-tab" cap. + // Path is resolved inside the add-on folder and rejected if it + // escapes it (path traversal). Query is URL-encoded. The page + // loads under addon-tab-preload.js so window.silentmode.invoke() + // reaches the same handlers as a sidebar panel — main gates by + // sender URL so a page hosted anywhere else gets nothing back. + openTab: (pathOrUrl, opts) => { + const s = String(pathOrUrl || ""); + // Bare http(s) URL with no opts — legacy behaviour, unchanged. + if (/^https?:\/\//i.test(s) && !opts) { + if (!this._openTab) throw new Error("openTab unavailable (host not wired)"); + this._openTab(s, manifest.id); + return; + } + if (!manifest.capabilities.includes("open-tab")) { + throw new Error(`add-on "${manifest.id}" must declare the "open-tab" capability in addon.json to open its own files in a tab`); + } + if (!this._openAddonTab) throw new Error("openAddonTab unavailable (host not wired)"); + if (!s || path.isAbsolute(s) || s.includes("..")) { + throw new Error(`openTab: path must be a relative file inside the add-on folder (got "${s}")`); + } + let qs = ""; + if (opts && opts.query && typeof opts.query === "object") { + const usp = new URLSearchParams(); + for (const [k, v] of Object.entries(opts.query)) usp.append(String(k), String(v)); + qs = usp.toString(); + } + return this._openAddonTab(manifest.id, s, qs); + }, + // Open Theseus's Settings tab, optionally scrolled to a named section + // (validated against a known list in main). No capability needed — + // it's the same thing the user could do from the ⋮ menu, just a + // one-click shortcut so add-ons can point users at the right place + // (e.g. Aegis's "Set up vault" gate → Passwords). + openSettings: (section) => { + if (!this._openSettings) throw new Error("openSettings unavailable (host not wired)"); + this._openSettings(typeof section === "string" ? section : ""); + }, + // Resolves once the browser chrome has painted (immediately if it + // already has). Put expensive dependency loading behind this so it + // never competes with the first frame at launch. + whenUiReady: () => (this._uiReady ? Promise.resolve() : new Promise((resolve) => this._uiReadyWaiters.push(resolve))), + // Panel ↔ activate() messaging. Panels (and, for page-inject add-ons, + // injected page bridges) call into the add-on with a message name + + // one JSON payload; the handler's return value goes back as the + // response. `ctx.from` is "panel" or "page"; pages also carry + // `ctx.origin` ("https://host") so the add-on can scope permissions. + onMessage: (msg, handler) => { + if (typeof msg !== "string" || !msg || typeof handler !== "function") throw new Error(`onMessage needs (name, fn)`); + active.handlers.set(msg, handler); + }, + // Push an event to the add-on's own sidebar panel if it's currently + // loaded. Fire-and-forget; silently dropped when the panel is closed. + emit: (msg, payload) => { + if (this._emitToPanel) this._emitToPanel(manifest.id, String(msg), payload); + }, + // vault-derive: a 32-byte HKDF child of the password vault's root, + // keyed by a path that MUST start with this add-on's id — or one of + // the ids it declared under `absorbs` in addon.json, so a superseding + // add-on can keep deriving the same keys as the add-on it replaced + // (funds stay reachable across the transition). Resolves only once + // the user has unlocked the vault (main polls; the await can be long). + vault: { + derive: async (purposePath) => { + if (!manifest.capabilities.includes("vault-derive")) { + throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); + } + if (!this._vaultDerive) throw new Error(`vault.derive unavailable (host not wired)`); + const p = String(purposePath || ""); + if (/[^a-z0-9/._-]/i.test(p) || p.includes("..")) { + throw new Error(`vault.derive: purposePath must look like "${manifest.id}/<name>"`); + } + const allowed = [manifest.id, ...(manifest.absorbs || [])]; + if (!allowed.some((prefix) => p.startsWith(prefix + "/"))) { + const list = allowed.length > 1 + ? `one of "${allowed.join('", "')}"` + : `"${manifest.id}"`; + throw new Error(`vault.derive: purposePath must start with ${list} + "/"`); + } + return this._vaultDerive(p, manifest.id); + }, + imports: this._makeImportsApi(manifest), + lifecycle: this._makeLifecycleApi(manifest), + }, + // approval-modal: ask the user. Resolves to the chosen action id, or + // "cancel" (Escape / mask click / window closed). With `checkbox` set + // and ticked, the id comes back suffixed "+<checkbox.id>"; with + // `select` {id, label, options:[{value,label}]} and a non-empty value + // chosen, "+<select.id>=<value>". + approvalModal: async (opts) => { + if (!manifest.capabilities.includes("approval-modal")) { + throw new Error(`add-on "${manifest.id}" must declare the "approval-modal" capability in addon.json`); + } + if (!this._approvalModal) throw new Error(`approvalModal unavailable (host not wired)`); + return this._approvalModal(opts || {}, manifest.id); + }, + // capture-tab: snapshot the currently-active tab. + // opts.mode "visible" | "full" | "region" (required) + // opts.format "png" | "jpeg" (default "png") + // opts.quality 1-100 (jpeg only, default 90) + // opts.overlaySource string (region only — DOM + // code the add-on wants injected while the user + // drags a selection. Must resolve to `{x,y,w,h}` + // in CSS pixels; return null/undefined to cancel.) + // Resolves to `{ dataUrl, width, height, host, format }`. + captureTab: async (opts) => { + if (!manifest.capabilities.includes("capture-tab")) { + throw new Error(`add-on "${manifest.id}" must declare the "capture-tab" capability in addon.json`); + } + if (!this._captureTab) throw new Error(`captureTab unavailable (host not wired)`); + return this._captureTab(opts || {}, manifest.id); + }, + // capture-tab: route an in-memory image into the app's downloads pipeline + // so it lands in the user's Downloads folder AND shows up in the + // download-chip list the same way any HTTP download would. + // opts.dataUrl "data:image/png;base64,…" (required) + // opts.filename filename shown in the chip (required) + // Resolves to `{ savePath }`. + saveCapture: async (opts) => { + if (!manifest.capabilities.includes("capture-tab")) { + throw new Error(`add-on "${manifest.id}" must declare the "capture-tab" capability in addon.json`); + } + if (!this._saveCapture) throw new Error(`saveCapture unavailable (host not wired)`); + return this._saveCapture(opts || {}, manifest.id); + }, + }; + } + + // Route a message to an add-on's registered handler. Callers (main) have + // already established WHO is asking; `ctx` carries that provenance. + async dispatch(id, msg, payload, ctx) { + const active = this._active.get(id); + if (!active) throw new Error(`add-on "${id}" is not active`); + const handler = active.handlers.get(String(msg)); + if (!handler) throw new Error(`add-on "${id}" has no handler for "${msg}"`); + return handler(payload, ctx || {}); + } + hasHandler(id, msg) { + const active = this._active.get(id); + return !!(active && active.handlers.has(String(msg))); + } + // Inject scripts that apply to a tab URL — [{ id, source }]. + injectionsFor(url) { + const out = []; + for (const active of this._active.values()) { + if (active.inject && urlMatchesAny(url, active.inject.matchers)) { + out.push({ id: active.manifest.id, source: active.inject.source }); + } + } + return out; + } + // Does this add-on's page-inject declaration cover the URL? Used to gate + // page → add-on IPC so a non-matching page can't spoof a matching one. + pageAllowed(id, url) { + const active = this._active.get(id); + return !!(active && active.inject && urlMatchesAny(url, active.inject.matchers)); + } + + // Read-only views for the rest of the app. + snapshot() { + return { + installed: this._installed.map(({ manifest, folder, error }) => ({ + id: manifest?.id ?? null, + name: manifest?.name ?? null, + version: manifest?.version ?? null, + description: manifest?.description ?? "", + author: manifest?.author ?? "", + icon: manifest?.icon ?? "🧩", + capabilities: manifest?.capabilities ?? [], + folder, + enabled: manifest?.id ? this._active.has(manifest.id) : false, + error: error || null, + })), + sidebarPanels: this.getSidebarPanels(), + toolbarMenus: this.getToolbarMenus(), + }; + } + getSidebarPanels() { + const out = []; + for (const active of this._active.values()) out.push(...active.sidebarPanels); + return out; + } + // Menu declarations from every active add-on that carries a toolbar-menu + // manifest block. Chrome renders one dock button per entry, opens the + // dropdown, then dispatches "menu-select" with the picked item id. + getToolbarMenus() { + const out = []; + for (const active of this._active.values()) { + const tm = active.manifest.toolbarMenu; + if (!tm) continue; + out.push({ + addonId: active.manifest.id, + title: tm.title, + icon: tm.icon, + items: tm.items.map((it) => ({ id: it.id, label: it.label, icon: it.icon })), + }); + } + return out; + } + getInstalled() { return this._installed.slice(); } + isActive(id) { return this._active.has(id); } + // Absolute folder of an active add-on, or null. Public so main can resolve + // add-on-relative paths (openAddonTab) without reaching into internals. + folderOf(id) { const a = this._active.get(id); return a ? a.folder : null; } +} + +module.exports = { AddonHost, KNOWN_CAPABILITIES, validateManifest, compileOriginPattern }; diff --git a/address-picker-preload.js b/address-picker-preload.js new file mode 100644 index 00000000..bd7656e8 --- /dev/null +++ b/address-picker-preload.js @@ -0,0 +1,9 @@ +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("picker", { + onSuggestions: (cb) => ipcRenderer.on("address-suggest", (_e, d) => cb(d)), + onCursor: (cb) => ipcRenderer.on("address-cursor", (_e, dir) => cb(dir)), + pick: (url) => ipcRenderer.invoke("address-pick", url), + forget: (url) => ipcRenderer.invoke("address-forget", url), + close: () => ipcRenderer.invoke("close-address-picker"), + resize: (h) => ipcRenderer.invoke("address-picker-resize", h), +}); diff --git a/address-picker.html b/address-picker.html new file mode 100644 index 00000000..499ab893 --- /dev/null +++ b/address-picker.html @@ -0,0 +1,66 @@ +<!doctype html> +<html><head><meta charset="utf-8"> +<style> + :root { color-scheme: light dark; font-family: system-ui, sans-serif; } + html, body { margin: 0; background: transparent; } + .menu { background: #1c222c; border: 1px solid #ffffff26; border-radius: 10px; box-shadow: 0 12px 34px #000c; overflow: hidden; color: #e7eaf1; } + .item { display: flex; align-items: center; gap: 10px; padding: 8px 14px; cursor: pointer; font-size: 13px; border-bottom: 1px solid #ffffff08; } + .item:last-child { border-bottom: 0; } + .item:hover, .item.on { background: #ffffff12; } + .item .ic { flex: none; font-size: 12px; color: #7f8aa0; width: 14px; text-align: center; } + .item .txt { flex: 1; overflow: hidden; } + .item .url { color: #e7eaf1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; } + .item .ttl { color: #7f8aa0; font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .item .del { flex: none; padding: 2px 8px; border-radius: 5px; color: #7f8aa0; font-size: 14px; + opacity: 0; cursor: pointer; user-select: none; } + .item:hover .del { opacity: .65; } + .item .del:hover { opacity: 1; color: #f6768a; background: rgba(246,118,138,.12); } + .empty { padding: 14px; color: #7f8aa0; font-size: 12.5px; text-align: center; } + @media (prefers-color-scheme: light) { + .menu { background: #ffffff; border-color: rgba(0,0,0,.15); color: #1a1f28; } + .item:hover, .item.on { background: rgba(0,0,0,.05); } + .item { border-bottom-color: rgba(0,0,0,.06); } + .item .url { color: #1a1f28; } + .item .ttl, .item .ic, .item .del, .empty { color: #7b8494; } + } +</style></head> +<body> + <div class="menu"><div id="list"></div></div> +<script> + const $ = (id) => document.getElementById(id); + const esc = (s) => String(s || "").replace(/</g, "&lt;").replace(/"/g, "&quot;"); + let items = []; let cursor = -1; + function report() { requestAnimationFrame(() => { try { window.picker.resize(document.querySelector(".menu").offsetHeight); } catch (e) {} }); } + function paint() { + const list = $("list"); + if (!items.length) { list.innerHTML = `<div class="empty">No history yet.</div>`; report(); return; } + list.innerHTML = items.map((h, i) => + `<div class="item ${i === cursor ? "on" : ""}" data-url="${esc(h.url)}"><span class="ic">🕘</span><span class="txt"><div class="url">${esc(h.url)}</div>${h.title ? `<div class="ttl">${esc(h.title)}</div>` : ""}</span><span class="del" data-del="${esc(h.url)}" title="Remove from history">✕</span></div>` + ).join(""); + list.querySelectorAll(".item").forEach((el) => el.onclick = (ev) => { + // Delete-x click removes the entry instead of navigating. + if (ev.target.dataset.del) { + ev.stopPropagation(); + const url = ev.target.dataset.del; + window.picker.forget(url); + // Optimistically drop it from the list so the UI reacts before main + // pushes the refreshed suggestions. + items = items.filter((h) => h.url !== url); + if (cursor >= items.length) cursor = items.length - 1; + paint(); + return; + } + window.picker.pick(el.dataset.url); + }); + report(); + } + window.picker.onSuggestions((data) => { items = data.suggestions || []; cursor = -1; paint(); }); + window.picker.onCursor((dir) => { + if (!items.length) return; + if (dir === "next") cursor = (cursor + 1) % items.length; + else if (dir === "prev") cursor = (cursor - 1 + items.length) % items.length; + else if (dir === "enter") { if (cursor >= 0) window.picker.pick(items[cursor].url); return; } + paint(); + }); +</script> +</body></html> diff --git a/approval-preload.js b/approval-preload.js new file mode 100644 index 00000000..b1e539d2 --- /dev/null +++ b/approval-preload.js @@ -0,0 +1,9 @@ +// Preload for the add-on approval overlay (approval.html). Main pushes one +// request at a time via `approval-show`; the page answers with the chosen +// action id through `approval-pick`. Nothing else is exposed — the overlay +// is a pure consent surface. +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("approval", { + onShow: (cb) => ipcRenderer.on("approval-show", (_e, req) => cb(req)), + pick: (reqId, action, checked, extra) => ipcRenderer.invoke("approval-pick", reqId, action, !!checked, String(extra || "")), +}); diff --git a/approval.html b/approval.html new file mode 100644 index 00000000..ef8179f5 --- /dev/null +++ b/approval.html @@ -0,0 +1,96 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Approval</title> +<style> + :root { color-scheme: light dark; + --surface:#1c222c; --surface2:#0f1621; --line:rgba(255,255,255,.12); + --ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --danger:#f6768a; } + @media (prefers-color-scheme: light) { + :root { --surface:#ffffff; --surface2:#f1f4fa; --line:rgba(0,0,0,.12); + --ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; + /* Darker acid for light backgrounds — ~5.5:1 on white. */ + --acid: #0AC18E; } + } + * { box-sizing: border-box; } + html, body { margin: 0; height: 100%; background: transparent; } + body { font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: var(--ink); } + .promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.45); + display: grid; place-items: start center; padding-top: 48px; } + .promptbox { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; + padding: 16px 18px 14px; width: min(460px, calc(100vw - 32px)); + box-shadow: 0 20px 60px #000d; animation: pop .12s ease-out; } + @keyframes pop { from { transform: translateY(-6px); opacity: 0; } to { transform: none; opacity: 1; } } + .who { display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 11.5px; margin-bottom: 8px; } + .who .addon { color: var(--mut); } + .title { font-size: 15px; font-weight: 650; letter-spacing: .1px; margin: 0 0 10px; } + .origin { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; + background: var(--surface2); border: 1px solid rgb(from var(--acid) r g b / .35); color: var(--acid); + border-radius: 8px; padding: 6px 10px; margin-bottom: 12px; + font: 13px/1.3 ui-monospace, "Cascadia Code", Consolas, monospace; word-break: break-all; } + .origin .lbl { color: var(--dim); font: 11px system-ui, sans-serif; white-space: nowrap; } + .body { white-space: pre-wrap; color: var(--ink); margin-bottom: 12px; } + .rows { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin-bottom: 12px; + background: var(--surface2); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; } + .rows .k { color: var(--dim); font-size: 12px; white-space: nowrap; } + .rows .v { word-break: break-all; } + .rows .v.mono { font: 12.5px/1.4 ui-monospace, "Cascadia Code", Consolas, monospace; } + .rows .v.strong { font-weight: 650; font-size: 14px; } + label.chk { display: flex; align-items: center; gap: 8px; color: var(--mut); font-size: 12.5px; margin-bottom: 12px; cursor: pointer; } + label.chk select { flex: 1; padding: 5px 8px; border-radius: 6px; background: var(--surface2); color: var(--ink); + border: 1px solid var(--line); font: inherit; font-size: 12.5px; } + .pact { display: flex; gap: 6px; justify-content: flex-end; } + .pbtn { padding: 7px 14px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface2); + color: var(--ink); cursor: pointer; font: inherit; font-size: 12.5px; } + .pbtn:hover { border-color: rgb(from var(--acid) r g b / .35); } + .pbtn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; font-weight: 650; } + .pbtn.danger { background: var(--danger); color: #0b0e14; border-color: transparent; font-weight: 650; } + .pbtn:focus-visible { outline: 2px solid rgb(from var(--acid) r g b / .6); outline-offset: 1px; } +</style> +</head> +<body> +<script> + const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" })[c]); + let current = null; + function finish(action, checked) { + if (!current) return; + const { reqId } = current; + const extra = document.getElementById("sel") ? document.getElementById("sel").value : ""; + current = null; + document.body.innerHTML = ""; + window.approval.pick(reqId, action, checked, extra); + } + window.approval.onShow((req) => { + current = req; + const rows = Array.isArray(req.rows) ? req.rows : []; + const actions = Array.isArray(req.actions) && req.actions.length ? req.actions : [{ id: "ok", label: "OK", primary: true }]; + const hasCancel = actions.some((a) => a.id === "cancel"); + document.body.innerHTML = + `<div class="promptmask"><div class="promptbox" role="dialog" aria-modal="true"> + <div class="who"><span>🧩</span><span class="addon">${esc(req.addonName || req.addonId)}</span><span>·</span><span>asks for your approval</span></div> + <h1 class="title">${esc(req.title || "Approve?")}</h1> + ${req.origin ? `<div class="origin"><span class="lbl">from</span><span>${esc(req.origin)}</span></div>` : ""} + ${req.body ? `<div class="body">${esc(req.body)}</div>` : ""} + ${rows.length ? `<div class="rows">${rows.map((r) => `<div class="k">${esc(r.label)}</div><div class="v${r.mono ? " mono" : ""}${r.strong ? " strong" : ""}">${esc(r.value)}</div>`).join("")}</div>` : ""} + ${req.checkbox ? `<label class="chk"><input type="checkbox" id="chk"> ${esc(req.checkbox.label || "Always allow")}</label>` : ""} + ${req.select ? `<label class="chk"><span>${esc(req.select.label)}</span><select id="sel">${req.select.options.map((o) => `<option value="${esc(o.value)}">${esc(o.label)}</option>`).join("")}</select></label>` : ""} + <div class="pact"> + ${hasCancel ? "" : `<button class="pbtn" type="button" data-id="cancel">Cancel</button>`} + ${actions.map((a) => `<button class="pbtn${a.primary ? " primary" : ""}${a.danger ? " danger" : ""}" type="button" data-id="${esc(a.id)}">${esc(a.label || a.id)}</button>`).join("")} + </div> + </div></div>`; + const mask = document.querySelector(".promptmask"); + mask.addEventListener("mousedown", (e) => { if (e.target === mask) finish("cancel", false); }); + document.querySelectorAll("button[data-id]").forEach((b) => { + b.addEventListener("click", () => finish(b.dataset.id, !!document.getElementById("chk")?.checked)); + }); + // Focus the non-destructive default so Enter never blindly approves a + // spend; the user has to tab or click onto the primary action. + const first = document.querySelector('button[data-id="cancel"]') || document.querySelector("button"); + setTimeout(() => first && first.focus(), 0); + }); + document.addEventListener("keydown", (e) => { if (e.key === "Escape") finish("cancel", false); }); +</script> +</body> +</html> diff --git a/bcnr-origin.js b/bcnr-origin.js new file mode 100644 index 00000000..5c7b1df3 --- /dev/null +++ b/bcnr-origin.js @@ -0,0 +1,103 @@ +// origin.js — compute the "permission origin" for a page. +// +// Permissions are keyed off the eTLD+1 of the page's URL, not its full origin: +// pay.merchant.com → permission origin merchant.com +// blog.merchant.com → permission origin merchant.com (same as pay.) +// evil.com → permission origin evil.com (different) +// pay.silentmode.bch → permission origin silentmode.bch (BNS root) +// +// This matches how MetaMask, browser cookies (SameSite), and CORS all think of +// origins — the boundary of trust is the registrable domain, not the specific +// subdomain. A dApp that already got "always allow signMessage" on +// checkout.merchant.com should NOT need to re-approve when it navigates to +// account.merchant.com. But evil.com cannot piggyback on merchant.com's grant. +// +// For ICANN TLDs we use the Public Suffix List via the `psl` package — the +// same list Chromium uses — which handles the multi-part cases (.co.uk, +// .github.io, ...). For BNS we key off the passed-in bcnrTlds array so a +// name like foo.wallet gets treated as a public suffix once the on-chain TLD +// list includes "wallet". +// +// The function is pure: no I/O, no imports of Electron. Tested in isolation +// by dev/origin-selftest.mjs. +const psl = require("psl"); + +// Opaque or non-webby origins that never hold permissions. Returned as +// literal strings so calling code can compare and treat them uniformly. +const OPAQUE = new Set(["data:", "blob:", "javascript:"]); + +function isIpLiteral(hostname) { + // Bracketed IPv6 (URL.hostname strips brackets — the check on ':' inside + // catches those) or a bare IPv4. + if (!hostname) return false; + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return true; + if (hostname.includes(":")) return true; + return false; +} + +// Given "pay.silentmode.bch" + bcnrTlds ["bch","wallet"] → "silentmode.bch". +// Given "silentmode.bch" alone → "silentmode.bch". A bare TLD ("bch") returns +// null — no registrable label to key permissions off. +function bnsEtldPlusOne(hostname, bcnrTlds) { + const labels = String(hostname).toLowerCase().split(".").filter(Boolean); + if (labels.length < 2) return null; + const tld = labels[labels.length - 1]; + if (!bcnrTlds.includes(tld)) { + // Not a BCNR-native TLD — treat the whole hostname as the origin. Safer + // than pretending we know the suffix; a name like foo.privateTld will + // land here until the TLD list catches up. + return labels.join("."); + } + // BCNR names are one label below the TLD: `{label}.{tld}` is the root + // identity, subdomains extend it. Registrable = label + tld. + return `${labels[labels.length - 2]}.${tld}`; +} + +// Main entry. Returns a string origin key, or null for un-resolvable input. +// Strings intentionally include the scheme when it matters (`about:blank`, +// `file://`) so an ICANN eTLD+1 can never collide with a special origin. +function originOf(urlString, { bcnrTlds = ["bch"] } = {}) { + if (!urlString || typeof urlString !== "string") return null; + let u; + try { u = new URL(urlString); } catch { return null; } + + if (OPAQUE.has(u.protocol)) return null; // opaque — never gets permissions + + if (u.protocol === "about:") { + // about:blank, about:srcdoc — normalize to their canonical form. + return `about:${u.pathname || "blank"}`; + } + if (u.protocol === "file:") return "file://"; // one bucket for all local files + + const host = u.hostname.toLowerCase(); + if (!host) return null; + + // IP literals and localhost: key by scheme + host + port. Common for dev + // servers. A permission granted to http://localhost:3000 does NOT extend + // to :3001 or to another IP. + if (host === "localhost" || isIpLiteral(host)) { + const port = u.port ? `:${u.port}` : ""; + return `${u.protocol}//${host}${port}`; + } + + if (u.protocol === "bns:") { + return bnsEtldPlusOne(host, bcnrTlds); + } + + if (u.protocol === "http:" || u.protocol === "https:") { + const parsed = psl.parse(host); + if (parsed.error || !parsed.domain) { + // Bare TLD, invalid, or a listed public suffix with no registrable + // label above it — no permission origin. Fall back to full host so + // odd cases don't silently share a bucket. + return host; + } + return parsed.domain; // e.g. merchant.com, foo.co.uk, user.github.io + } + + // Any other scheme (chrome://, chrome-extension://, ...) — key by scheme + + // host so it doesn't collide with a real origin. + return `${u.protocol}//${host}`; +} + +module.exports = { originOf, bnsEtldPlusOne }; diff --git a/bcnr-preload.js b/bcnr-preload.js new file mode 100644 index 00000000..efcd483a --- /dev/null +++ b/bcnr-preload.js @@ -0,0 +1,27 @@ +// bcnr-preload.js — session-wide preload that installs `window.bcnr` on every +// page (regular tabs, popups, chrome/settings/etc). Reads only — no signing, +// no wallet unlock, no permission prompts. These four methods query the same +// resolver Theseus already runs for its address bar; nothing about the local +// user leaks, so no origin gate is needed for this surface. +// +// Sequencing: registered via `session.defaultSession.setPreloads([...])` in +// main.js at whenReady, which runs BEFORE per-WebContentsView preloads (home, +// settings, popover, etc.), so those preloads still install their own bridges +// on top of `window.bcnr`. See DESIGN-integrated-wallet.md §3 for the full +// API surface. +const { contextBridge, ipcRenderer } = require("electron"); + +// Every method returns a Promise; a name that fails to resolve or isn't +// registered comes back as `null` (not an error) so page code can treat +// "no such name" as data, not an exception. `getBcnrTlds` always returns +// an array — even the seed ["bch"] before the on-chain list has landed. +contextBridge.exposeInMainWorld("bcnr", { + resolveName: (name) => ipcRenderer.invoke("bcnr:resolveName", name), + isRegistered: (name) => ipcRenderer.invoke("bcnr:isRegistered", name), + getBcnrTlds: () => ipcRenderer.invoke("bcnr:getBcnrTlds"), + getRecordVersion: (name) => ipcRenderer.invoke("bcnr:getRecordVersion", name), + // Diagnostic — the eTLD+1 permission origin Theseus computes for THIS page. + // dApp devs use this to see how their subdomains bucket under one grant. + // Returns null for opaque origins (data:, blob:) which never hold grants. + getOrigin: () => ipcRenderer.invoke("bcnr:getOrigin"), +}); diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json new file mode 100644 index 00000000..05d0171a --- /dev/null +++ b/bundled-addons/aegis/addon.json @@ -0,0 +1,16 @@ +{ + "id": "aegis", + "name": "Aegis Wallet", + "version": "0.6.2", + "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.", + "author": "Silent Mode", + "icon": "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'%3E%3Cpolygon points='16,2 28,9 28,23 16,30 4,23 4,9' fill='%230a0a0d' stroke='%23D6FF3D' stroke-width='1.6' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='none' stroke='%23D6FF3D' stroke-width='1.4'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23D6FF3D'/%3E%3C/svg%3E", + "main": "index.js", + "updateURL": "https://navigate.st/bns/theseus.x/extensions/aegis/updates.json", + "capabilities": ["sidebar-panel", "vault-derive", "page-inject", "approval-modal"], + "absorbs": ["bchwallet", "siawallet"], + "page-inject": { + "preload": "wallet-inject.js", + "origins": ["https://*/*"] + } +} diff --git a/bundled-addons/aegis/electrum-servers.json b/bundled-addons/aegis/electrum-servers.json new file mode 100644 index 00000000..a3681e3d --- /dev/null +++ b/bundled-addons/aegis/electrum-servers.json @@ -0,0 +1,9 @@ +[ + "wss://bch.imaginary.cash:50004", + "wss://electrum.imaginary.cash:50004", + "wss://fulcrum.jettscythe.xyz:50004", + "wss://bch.loping.net:50004", + "wss://bch.soul-dev.com:50004", + "wss://cashnode.bch.ninja:50004", + "wss://blackie.c3-soft.com:50004" +] diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js new file mode 100644 index 00000000..00f30032 --- /dev/null +++ b/bundled-addons/aegis/index.js @@ -0,0 +1,1922 @@ +// Aegis — multi-chain wallet bundled with Theseus. activate() runs in the +// main process; every wallet's key material lives here, in memory, and is +// re-derived from the password vault on every launch. Nothing secret is ever +// written to disk or logged. +// +// A single addon can hold many wallets — one per {chain, network}, or several +// sub-accounts of the same chain — and each wallet is backed by its own +// vault-derived 32-byte HKDF root. Chains today: BCH, Tron mainnet, Tron +// Nile testnet. Adding a fourth chain is a new adapter file under lib/ and +// an entry in the CHAIN_REGISTRY below. +// +// Back-compat: the addon id stays "bchwallet" (the manifest label became +// Aegis, but the id gates the vault-derive namespace and older vaults have +// funds against it). The legacy BCH default wallet uses purpose +// "bchwallet/mainnet/0" — byte-identical to the pre-multi-wallet build — +// so on-disk funds are untouched. See memory bchwallet-vault-root-derivation. +const path = require("node:path"); +const fs = require("node:fs"); + +const LEGACY_BCH_PURPOSE = "bchwallet/mainnet/0"; +const LEGACY_BCH_WALLET_ID = "bch-default"; + +let ctx = null; + +// ---- deps ------------------------------------------------------------------- + +async function loadDeps(api) { + const { secp256k1 } = await api.import("@noble/curves/secp256k1.js"); + const { ed25519 } = await api.import("@noble/curves/ed25519.js"); + const { sha256 } = await api.import("@noble/hashes/sha2.js"); + const { hkdf } = await api.import("@noble/hashes/hkdf.js"); + const { ripemd160 } = await api.import("@noble/hashes/legacy.js"); + const { keccak_256 } = await api.import("@noble/hashes/sha3.js"); + const { blake2b } = await api.import("@noble/hashes/blake2.js"); + const { HDKey } = await api.import("@scure/bip32"); + const WebSocket = api.require("ws"); + const cashaddr = require("./lib/cashaddr.js"); + const keysLib = require("./lib/keys.js")({ HDKey, secp256k1, sha256, ripemd160, cashaddr }); + const tx = require("./lib/tx.js")({ sha256 }); + const electrum = require("./lib/electrum.js")({ WebSocket, log: (...a) => api.log("electrum", ...a) }); + const base58check = require("./lib/base58check.js")({ sha256 }); + const bchAdapter = require("./lib/chain-bch.js")({ + HDKey, secp256k1, sha256, ripemd160, cashaddr, keysLib, tx, electrum, WebSocket, + }); + const tronAdapter = require("./lib/chain-tron.js")({ + HDKey, secp256k1, sha256, keccak_256, base58check, + }); + const siaAdapter = require("./lib/chain-sia.js")({ ed25519, blake2b }); + const ethAdapter = require("./lib/chain-eth.js")({ HDKey, secp256k1, keccak_256 }); + const eip712 = require("./lib/eip712.js")({ keccak_256 }); + // Solana uses the raw base58 alphabet (no checksum), which lives inside + // base58check as encodeBase58 / decodeBase58 — expose them under a + // `{encode, decode}` shape the SOL adapter reads from. + const solBase58 = { encode: base58check.encodeBase58, decode: base58check.decodeBase58 }; + const solAdapter = require("./lib/chain-sol.js")({ ed25519, base58: solBase58, sha256 }); + // DGB delegates address derivation + PSBT to the vendored @dgb-wallet/* + // packages under lib/dgb/. Those are ESM; the peer deps (bitcoinjs-lib, + // bip32, ecpair, @bitcoinerlab/secp256k1) are CommonJS and reachable via + // api.require from the Theseus dependency tree. + const { pathToFileURL } = require("node:url"); + // DGB's bundled ESM packages import their peer deps by bare specifier + // ("bip32", "bitcoinjs-lib", …). When aegis is loaded from a copy in + // userData/addons/, Node's ESM resolver can't reach Theseus's node_modules + // from that path — so the import throws. Wrap it: DGB just becomes + // unavailable, the rest of Aegis keeps working. + let dgbCore = null, dgbPsbt = null, dgbAdapter = null; + try { + dgbCore = await import(pathToFileURL(path.join(api.folder, "lib/dgb/core/index.js")).href); + dgbPsbt = await import(pathToFileURL(path.join(api.folder, "lib/dgb/psbt/index.js")).href); + } catch (e) { + api.log("dgb unavailable:", e?.message || e); + } + const bitcoinjs = api.require("bitcoinjs-lib"); + const { BIP32Factory } = api.require("bip32"); + const { ECPairFactory } = api.require("ecpair"); + const ecc = api.require("@bitcoinerlab/secp256k1"); + if (dgbCore && dgbPsbt) { + dgbAdapter = require("./lib/chain-dgb.js")({ + dgbCore, dgbPsbt, bitcoinjs, + bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc, + sha256, electrum, + }); + } + const btcAdapter = require("./lib/chain-btc.js")({ + bitcoinjs, bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc, + sha256, electrum, + }); + const bip39 = api.require("bip39"); + // Imported BCH — a lean read-only adapter for wallets whose key material + // lives in Theseus's wallet-imports.enc. Same electrum/cashaddr surface as + // the primary BCH adapter but a single fixed address per wallet. + const importedBchAdapter = require("./lib/chain-bch-imported.js")({ + sha256, ripemd160, cashaddr, electrum, WebSocket, tx, + }); + // WizardConnect: LGPL-3.0-or-later. Dynamic-linked via api.import so the + // §4d combined-work requirement (dynamic linkage + license notice + source + // availability) is met — package sources ship with npm. + const wcCore = await api.import("@wizardconnect/core"); + const wcWallet = await api.import("@wizardconnect/wallet"); + const libauth = await api.import("@bitauth/libauth"); + return { HDKey, secp256k1, ed25519, sha256, hkdf, ripemd160, keccak_256, blake2b, + cashaddr, keysLib, tx, electrum, base58check, + bchAdapter, tronAdapter, siaAdapter, dgbAdapter, ethAdapter, solAdapter, btcAdapter, + importedBchAdapter, bip39, + dgbCore, dgbPsbt, bitcoinjs, ecc, eip712, + wcCore, wcWallet, libauth }; +} + +// ---- servers --------------------------------------------------------------- + +function bchDefaultServers(api) { + try { return JSON.parse(fs.readFileSync(path.join(api.folder, "electrum-servers.json"), "utf8")); } + catch { return []; } +} +function bchServerList(api) { + const custom = api.storage.get("servers", null); + return Array.isArray(custom) && custom.length ? custom : bchDefaultServers(api); +} + +// ---- chain registry -------------------------------------------------------- +// Two-level structure so the panel can present coin-then-network as separate +// picks. `coin` fields are chain-wide; `networks[<id>]` fields override or +// add to them per-network. `logo` is the SVG key panel.js draws from. +const COINS = { + bch: { + chain: "bch", + label: "Bitcoin Cash", + short: "BCH", + ticker: "BCH", + decimals: 8, + color: "#0ac18e", + logo: "bch", + supportsMessageSign: true, + supportsPageInject: true, // window.bitcoincash on *.x pages + networks: { + mainnet: { + id: "mainnet", label: "Mainnet", testnet: false, + // Legacy default wallet uses the flat "bchwallet/mainnet/0" purpose; + // new BCH mainnet sub-accounts start at index 1 under the /bch/ prefix. + purposePrefix: "bchwallet/bch/", + startIndex: 1, + }, + chipnet: { + id: "chipnet", label: "Chipnet testnet", testnet: true, + purposePrefix: "bchwallet/bch/chipnet/", + startIndex: 0, + }, + }, + }, + trx: { + chain: "trx", + label: "Tron", + short: "TRX", + ticker: "TRX", + decimals: 6, + color: "#ff060a", + logo: "trx", + supportsMessageSign: true, + supportsPageInject: true, // window.tronWeb everywhere + networks: { + mainnet: { + id: "mainnet", label: "Mainnet", testnet: false, + purposePrefix: "bchwallet/trx/mainnet/", startIndex: 0, + }, + nile: { + id: "nile", label: "Nile testnet", testnet: true, + purposePrefix: "bchwallet/trx/nile/", startIndex: 0, + }, + }, + }, + sc: { + chain: "sc", + label: "Siacoin", + short: "SC", + ticker: "SC", + decimals: 24, + color: "#20be82", + logo: "sc", + supportsMessageSign: true, + supportsPageInject: false, + // First SC wallet in Aegis reuses the legacy standalone-siawallet purpose + // so pre-Aegis funds carry over automatically (addon.json declares + // `absorbs: ["siawallet"]` to allow the derivation). Second+ use the new + // Aegis-namespaced prefix. + networks: { + mainnet: { + id: "mainnet", label: "Mainnet", testnet: false, + purposePrefix: "bchwallet/sc/mainnet/", startIndex: 1, + legacyFirstPurpose: "siawallet/mainnet/0", + }, + }, + }, + dgb: { + chain: "dgb", + label: "DigiByte", + short: "DGB", + ticker: "DGB", + decimals: 8, + color: "#0066cc", + logo: "dgb", + supportsMessageSign: true, + supportsPageInject: false, + // BIP44/49/84/86 address families — the picker lives in the DGB + // settings block. Default is BIP84 (dgb1q…), which matches modern + // DGB Core, DigiByte-Go, and the SilentCode web-wallet. `coinType` + // per chain feeds the path builder below. + addressFamilies: [ + { id: "bip84", purpose: 84, label: "Native SegWit (dgb1q…)" }, + { id: "bip86", purpose: 86, label: "Taproot (dgb1p…)" }, + { id: "bip49", purpose: 49, label: "Wrapped SegWit (S…)" }, + { id: "bip44", purpose: 44, label: "Legacy P2PKH (D…)" }, + ], + coinType: 20, + defaultPurpose: 84, + networks: { + mainnet: { + id: "mainnet", label: "Mainnet", testnet: false, + purposePrefix: "bchwallet/dgb/mainnet/", startIndex: 0, + }, + }, + }, + eth: { + chain: "eth", + label: "Ethereum", + short: "ETH", + ticker: "ETH", + decimals: 18, + color: "#627eea", + logo: "eth", + supportsMessageSign: true, + supportsPageInject: false, // EIP-1193 provider is a follow-up + networks: { + mainnet: { + id: "mainnet", label: "Mainnet", testnet: false, + purposePrefix: "bchwallet/eth/mainnet/", startIndex: 0, + }, + sepolia: { + id: "sepolia", label: "Sepolia testnet", testnet: true, + purposePrefix: "bchwallet/eth/sepolia/", startIndex: 0, + }, + }, + }, + sol: { + chain: "sol", + label: "Solana", + short: "SOL", + ticker: "SOL", + decimals: 9, + color: "#9945ff", + logo: "sol", + supportsMessageSign: true, + supportsPageInject: false, + networks: { + mainnet: { + id: "mainnet", label: "Mainnet-beta", testnet: false, + purposePrefix: "bchwallet/sol/mainnet/", startIndex: 0, + }, + devnet: { + id: "devnet", label: "Devnet", testnet: true, + purposePrefix: "bchwallet/sol/devnet/", startIndex: 0, + }, + }, + }, + btc: { + chain: "btc", + label: "Bitcoin", + short: "BTC", + ticker: "BTC", + decimals: 8, + color: "#f7931a", + logo: "btc", + supportsMessageSign: true, + supportsPageInject: false, + // BIP44/49/84/86 across bc1q… / bc1p… / 3… / 1… on mainnet and + // tb1q… / tb1p… / 2… / m/n… on testnet3 + signet. Coin type shifts + // per network (0 for mainnet, 1 for both testnet3 and signet — SLIP-44 + // treats every Bitcoin testnet as coin type 1). + addressFamilies: [ + { id: "bip84", purpose: 84, label: "Native SegWit (bc1q… / tb1q…)" }, + { id: "bip86", purpose: 86, label: "Taproot (bc1p… / tb1p…)" }, + { id: "bip49", purpose: 49, label: "Wrapped SegWit (3… / 2…)" }, + { id: "bip44", purpose: 44, label: "Legacy P2PKH (1… / m…, n…)" }, + ], + coinType: { mainnet: 0, testnet: 1, signet: 1 }, + defaultPurpose: 84, + networks: { + mainnet: { + id: "mainnet", label: "Mainnet", testnet: false, + purposePrefix: "bchwallet/btc/mainnet/", startIndex: 0, + }, + testnet: { + id: "testnet", label: "Testnet3", testnet: true, + purposePrefix: "bchwallet/btc/testnet/", startIndex: 0, + }, + signet: { + id: "signet", label: "Signet", testnet: true, + purposePrefix: "bchwallet/btc/signet/", startIndex: 0, + }, + }, + }, +}; +function chainKey(chain, network) { return `${chain}:${network}`; } +// Coin type per (chain, network). A number literal on the COINS entry +// (DGB uses a single 20) or a per-network object ({mainnet: 0, testnet: 1} +// for BTC). Returns null when the chain doesn't declare a family picker. +function coinTypeFor(c, network) { + if (!c || c.coinType == null) return null; + return typeof c.coinType === "number" ? c.coinType : (c.coinType[network] ?? null); +} +// Full derivation paths per family for a given (chain, network) — expands +// the family list on the fly so each picker knows exactly which path a +// pick would produce. +function addressFamiliesFor(c, network) { + if (!c || !c.addressFamilies) return null; + const ct = coinTypeFor(c, network); + if (ct == null) return c.addressFamilies; + return c.addressFamilies.map((f) => ({ + ...f, + defaultAccountPath: `m/${f.purpose}'/${ct}'/0'`, + })); +} +function defaultAccountPathFor(c, network) { + const ct = coinTypeFor(c, network); + if (ct == null || c.defaultPurpose == null) return null; + return `m/${c.defaultPurpose}'/${ct}'/0'`; +} +// Custom EVM chains (EIP-3085) live in api.storage under `customEthChains`. +// Structured as { [chainId]: {chainId, chainName, rpcUrl, explorerTx, +// explorerAddr, ticker, addedAt, addedByOrigin} }. They're not in COINS at +// module-load time — we synthesize a chainMeta / mount entry from storage +// so wallet_addEthereumChain can register new networks at runtime without +// a Theseus restart. +const CUSTOM_ETH_PREFIX = "custom-"; +function customEthChains(api) { + const raw = api.storage.get("customEthChains", {}); + return raw && typeof raw === "object" ? raw : {}; +} +function customEthNetworkEntry(api, network) { + if (!network || !network.startsWith(CUSTOM_ETH_PREFIX)) return null; + const chainId = Number(network.slice(CUSTOM_ETH_PREFIX.length)); + if (!Number.isFinite(chainId)) return null; + const all = customEthChains(api); + const cfg = all[String(chainId)]; + if (!cfg) return null; + return { + id: network, + label: cfg.chainName || `EVM #${chainId}`, + chainId, + defaultRpc: cfg.rpcUrl, + explorerTx: cfg.explorerTx, + explorerAddr: cfg.explorerAddr, + ticker: cfg.ticker || "ETH", + faucet: null, + }; +} +function chainMeta(chain, network) { + // ETH custom-network fallback for EIP-3085 chains. + if (chain === "eth" && String(network || "").startsWith(CUSTOM_ETH_PREFIX)) { + const cfg = customEthNetworkEntry(ctx?.api, network); + if (!cfg) return null; + return { + chain: "eth", network: cfg.id, + label: "Ethereum · " + cfg.label, + short: cfg.ticker, ticker: cfg.ticker, decimals: 18, + color: COINS.eth.color, logo: COINS.eth.logo, + coinLabel: cfg.label, networkLabel: `chainId ${cfg.chainId}`, + testnet: false, + purposePrefix: `bchwallet/eth/${cfg.id}/`, startIndex: 0, + supportsMessageSign: true, supportsPageInject: false, + addressFamilies: null, defaultAccountPath: null, + isCustom: true, chainId: cfg.chainId, + }; + } + const c = COINS[chain]; const n = c && c.networks[network]; + if (!c || !n) return null; + return { + chain: c.chain, network: n.id, + label: c.label + " · " + n.label, short: c.short, ticker: c.ticker, decimals: c.decimals, + color: c.color, logo: c.logo, coinLabel: c.label, networkLabel: n.label, testnet: !!n.testnet, + purposePrefix: n.purposePrefix, startIndex: n.startIndex, + supportsMessageSign: !!c.supportsMessageSign, supportsPageInject: !!c.supportsPageInject, + addressFamilies: addressFamiliesFor(c, network), + defaultAccountPath: defaultAccountPathFor(c, network), + }; +} +function coinsForPanel() { + return Object.values(COINS).map((c) => ({ + chain: c.chain, label: c.label, short: c.short, ticker: c.ticker, color: c.color, logo: c.logo, decimals: c.decimals, + networks: Object.values(c.networks).map((n) => ({ id: n.id, label: n.label, testnet: !!n.testnet })), + })); +} + +// ---- wallet list ------------------------------------------------------------ + +function readWallets(api) { + const raw = api.storage.get("wallets", null); + return Array.isArray(raw) ? raw : null; +} +function writeWallets(api, list) { api.storage.set("wallets", list); } + +// Bring pre-multi-wallet storage forward: create the legacy BCH default entry +// and rehome its receiveCursor / txCache under the new per-wallet subkey. +function migrateLegacyStorage(api) { + if (readWallets(api)) return; // already multi-wallet + const legacyAccountPath = String(api.storage.get("accountPath", "") || "").trim() || "m/44'/145'/0'"; + const wallets = [{ + id: LEGACY_BCH_WALLET_ID, + label: "BCH — main", + chain: "bch", + network: "mainnet", + purpose: LEGACY_BCH_PURPOSE, + accountPath: legacyAccountPath, + isDefault: true, + isLegacy: true, + createdAt: 0, + }]; + writeWallets(api, wallets); + api.storage.set("selectedWalletId", LEGACY_BCH_WALLET_ID); + // Move per-wallet state under the scoped prefix used by chain-bch.js. + const prefix = `wallets/${LEGACY_BCH_WALLET_ID}/`; + for (const legacyKey of ["receiveCursor", "txCache"]) { + const v = api.storage.get(legacyKey, null); + if (v !== null && api.storage.get(prefix + legacyKey, null) === null) { + api.storage.set(prefix + legacyKey, v); + } + } + api.log("migrated legacy BCH wallet into multi-wallet layout"); +} + +function nextIndex(wallets, meta) { + let max = meta.startIndex - 1; + for (const w of wallets) { + if (chainKey(w.chain, w.network) !== chainKey(meta.chain, meta.network)) continue; + if (w.isLegacy) continue; + const m = /\/(\d+)$/.exec(w.purpose || ""); + const n = m ? Number(m[1]) : NaN; + if (Number.isFinite(n) && n > max) max = n; + } + return max + 1; +} + +function makeWalletId(meta, index) { + const n = String(meta.network).replace(/[^a-z0-9]/gi, ""); + return `${meta.chain}-${n}-${index}`; +} + +function autoLabel(meta, wallets) { + const same = wallets.filter((w) => chainKey(w.chain, w.network) === chainKey(meta.chain, meta.network)); + if (!same.length) return meta.short; + return `${meta.short} #${same.length + 1}`; +} + +// ---- runtime wallet map ----------------------------------------------------- +// A "runtime" is a mounted wallet: its adapter instance plus phase + error. +// activate() derives all of them in parallel once the vault unlocks. + +async function mountAllWallets() { + const c = ctx; + const walletList = readWallets(c.api) || []; + for (const w of walletList) { + if (!c.runtimes.has(w.id)) c.runtimes.set(w.id, { entry: w, phase: "locked", error: null, adapter: null }); + } + emitState(); + await Promise.all(walletList.map((w) => mountWallet(w))); +} + +async function mountWallet(entry) { + const c = ctx; + const rt = c.runtimes.get(entry.id) || { entry, phase: "locked", error: null, adapter: null }; + rt.entry = entry; + rt.phase = "locked"; rt.error = null; + c.runtimes.set(entry.id, rt); + emitState(); + + // Imported wallets skip the vault-derive/HKDF path entirely: their signer + // material is in wallet-imports.enc, and the runtime here is read-only so + // it doesn't need the material until spend support ships (M.1b). + if (entry.kind === "imported") { + try { + const adapter = new c.d.importedBchAdapter.ImportedBchWallet({ + walletId: entry.id, storage: c.api.storage, + log: (...a) => c.api.log(`[${entry.id}]`, ...a), + onChange: () => emitStateForWallet(entry.id), + network: entry.network, + cashaddr: entry.importedCashaddr, + servers: entry.network === "mainnet" ? bchServerList(c.api) : undefined, + }); + rt.adapter = adapter; rt.phase = "ready"; + adapter.refresh(true).catch((e) => c.api.log(`[${entry.id}] initial refresh:`, e?.message || e)); + emitStateForWallet(entry.id); + } catch (e) { + rt.phase = "error"; rt.error = e?.message || String(e); + emitState(); + } + return; + } + + let root; + try { + root = await c.api.vault.derive(entry.purpose); + } catch (e) { + const msg = e?.message || String(e); + rt.phase = /not set up/i.test(msg) ? "nosetup" : "error"; + rt.error = msg; + emitState(); + return; + } + if (ctx !== c) return; + try { + let adapter; + if (entry.chain === "bch") { + // Mainnet still honors the user-set custom electrum list; chipnet uses + // adapter-embedded defaults (no per-network custom list in this rev). + const servers = entry.network === "mainnet" ? bchServerList(c.api) : undefined; + adapter = new c.d.bchAdapter.BchWallet(root, { + walletId: entry.id, + storage: c.api.storage, + log: (...a) => c.api.log(`[${entry.id}]`, ...a), + onChange: () => emitStateForWallet(entry.id), + network: entry.network, + servers, + accountPath: entry.accountPath, + }); + } else if (entry.chain === "trx") { + adapter = new c.d.tronAdapter.TronWallet(root, entry.network, { + storage: c.api.storage, + log: (...a) => c.api.log(`[${entry.id}]`, ...a), + onChange: () => emitStateForWallet(entry.id), + }); + adapter.schedulePoll(20_000); + } else if (entry.chain === "sc") { + // walletdUrl is per-Sia-wallet (each sub-account may point at a + // different node) and stored under the scoped wallets/<id>/walletdUrl + // key. Empty = the panel shows a "point me at walletd" gate. + const walletdUrl = String(c.api.storage.get(`wallets/${entry.id}/walletdUrl`, "") || ""); + adapter = new c.d.siaAdapter.SiaWallet(root, { + walletId: entry.id, + storage: c.api.storage, + log: (...a) => c.api.log(`[${entry.id}]`, ...a), + onChange: () => emitStateForWallet(entry.id), + walletdUrl, + }); + if (walletdUrl) adapter.startPolling(); + } else if (entry.chain === "dgb") { + if (!c.d.dgbAdapter) throw new Error("DGB unavailable (bundled ESM couldn't resolve peer deps)"); + adapter = new c.d.dgbAdapter.DgbWallet(root, { + walletId: entry.id, + storage: c.api.storage, + log: (...a) => c.api.log(`[${entry.id}]`, ...a), + onChange: () => emitStateForWallet(entry.id), + accountPath: entry.accountPath, + }); + } else if (entry.chain === "eth") { + const rpcUrl = String(c.api.storage.get(`wallets/${entry.id}/rpcUrl`, "") || ""); + // Custom EIP-3085 chains resolve their config from storage rather than + // the built-in NETWORKS map. + const customNetwork = customEthNetworkEntry(c.api, entry.network); + adapter = new c.d.ethAdapter.EthWallet(root, entry.network, { + walletId: entry.id, + storage: c.api.storage, + log: (...a) => c.api.log(`[${entry.id}]`, ...a), + onChange: () => emitStateForWallet(entry.id), + rpcUrl, + customNetwork, + }); + adapter.schedulePoll(20_000); + } else if (entry.chain === "sol") { + const rpcUrl = String(c.api.storage.get(`wallets/${entry.id}/rpcUrl`, "") || ""); + adapter = new c.d.solAdapter.SolWallet(root, entry.network, { + walletId: entry.id, + storage: c.api.storage, + log: (...a) => c.api.log(`[${entry.id}]`, ...a), + onChange: () => emitStateForWallet(entry.id), + rpcUrl, + }); + adapter.schedulePoll(20_000); + } else if (entry.chain === "btc") { + adapter = new c.d.btcAdapter.BtcWallet(root, entry.network, { + walletId: entry.id, + storage: c.api.storage, + log: (...a) => c.api.log(`[${entry.id}]`, ...a), + onChange: () => emitStateForWallet(entry.id), + accountPath: entry.accountPath, + }); + } else { + throw new Error(`unknown chain ${entry.chain}`); + } + rt.adapter = adapter; + rt.phase = "ready"; + // Kick a first fetch. Errors here don't fail the mount — the panel shows + // them per-wallet via snapshot.error. + adapter.refresh(true).catch((e) => c.api.log(`[${entry.id}] initial refresh:`, e?.message || e)); + if (entry.chain === "bch" && c.wc) { + c.wc.startForWallet({ + walletId: entry.id, label: entry.label, + root32: root, accountPath: entry.accountPath || "m/44'/145'/0'", + }).catch((e) => c.api.log(`[${entry.id}] wc start:`, e?.message || e)); + } + emitStateForWallet(entry.id); + } catch (e) { + rt.phase = "error"; + rt.error = e?.message || String(e); + emitState(); + } finally { + // Wipe the root buffer — the adapter has already turned it into keys. + if (root) try { root.fill(0); } catch {} + } +} + +function unmountWallet(walletId) { + const rt = ctx.runtimes.get(walletId); + if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} } + if (ctx.wc) { try { ctx.wc.stopForWallet(walletId); } catch {} } + ctx.runtimes.delete(walletId); +} + +// ---- import derive helpers (client-side, cashaddr only) -------------------- +// The pasted material never leaves this process — main-side stores it once +// via api.vault.imports.add. These helpers only turn (seed+path) or WIF into +// a P2PKH cashaddr, which is safe to send back to the panel. + +function deriveCashaddrFromSeed(seedHex, path, prefix) { + const d = ctx.d; + const seed = new Uint8Array(seedHex.length / 2); + for (let i = 0; i < seed.length; i++) seed[i] = parseInt(seedHex.substr(i * 2, 2), 16); + const root = d.HDKey.fromMasterSeed(seed); + const node = root.derive(path); + const h160 = d.ripemd160(d.sha256(node.publicKey)); + return d.cashaddr.encode(prefix, 0, h160); +} +function deriveCashaddrFromWif(wif, prefix) { + const d = ctx.d; + // WIF layout: base58check(networkByte || privkey32 || [compressionByte 0x01]) + const raw = d.base58check.decode(wif); + if (raw.length !== 33 && raw.length !== 34) throw new Error(`bad WIF length ${raw.length}`); + // First byte is version (network); we allow any — BCH mainnet uses 0x80, + // testnet 0xEF. Both round-trip through the same address derivation below. + const priv = raw.slice(1, 33); + const compressed = raw.length === 34; // trailing 0x01 marker + const pub = d.secp256k1.getPublicKey(priv, compressed); + const h160 = d.ripemd160(d.sha256(pub)); + return d.cashaddr.encode(prefix, 0, h160); +} + +function buildWcApprovalBody(payload) { + const req = payload?.request || {}; + const tx = req.transaction || {}; + const dappName = tx.userPrompt || "unknown dapp"; + const inputCount = Array.isArray(req.inputPaths) ? req.inputPaths.length : "?"; + const bc = tx.broadcast ? "Dapp will broadcast after signing." : "Signed hex returned to dapp."; + const walletName = payload?.label || payload?.walletId || ""; + return "<div><b>" + dappName + "</b> requests a BCH transaction signature.</div>" + + "<div>Wallet: <b>" + walletName + "</b> · " + inputCount + " input(s).</div>" + + "<div class=hint>" + bc + " Signs with SIGHASH_ALL|FORKID|UTXOS.</div>"; +} + +// ---- state / snapshot ------------------------------------------------------- + +function selectedWalletId() { + const list = readWallets(ctx.api) || []; + if (!list.length) return null; + const saved = String(ctx.api.storage.get("selectedWalletId", "") || ""); + if (saved && list.some((w) => w.id === saved)) return saved; + const dflt = list.find((w) => w.isDefault) || list[0]; + return dflt.id; +} + +function walletEntries() { return readWallets(ctx.api) || []; } + +function overallPhase() { + // If ANY wallet is nosetup, treat the whole addon as nosetup — the user + // hasn't unlocked / set up the vault, so no wallet can work. + const runtimes = [...ctx.runtimes.values()]; + if (!runtimes.length) return "locked"; + if (runtimes.some((r) => r.phase === "nosetup")) return "nosetup"; + if (runtimes.some((r) => r.phase === "locked")) return "locked"; + return "ready"; +} + +function walletSummary(w) { + const meta = chainMeta(w.chain, w.network); + const rt = ctx.runtimes.get(w.id); + const snap = rt && rt.adapter ? rt.adapter.snapshot() : null; + return { + id: w.id, label: w.label, chain: w.chain, network: w.network, isDefault: !!w.isDefault, isLegacy: !!w.isLegacy, + logo: meta?.logo || null, color: meta?.color || "#888", + coinLabel: meta?.coinLabel || w.chain, networkLabel: meta?.networkLabel || w.network, testnet: !!meta?.testnet, + ticker: meta?.ticker || "?", short: meta?.short || w.chain, decimals: meta?.decimals || 8, + address: snap?.address || null, + balance: snap?.balance || { confirmed: 0, unconfirmed: 0 }, + phase: rt?.phase || "locked", + error: rt?.error || null, + }; +} + +function snapshotForSelected() { + const id = selectedWalletId(); + if (!id) return { phase: "empty" }; + const rt = ctx.runtimes.get(id); + const entry = walletEntries().find((w) => w.id === id); + const meta = entry ? chainMeta(entry.chain, entry.network) : null; + const base = { + walletId: id, + label: entry?.label, + chain: entry?.chain, + network: entry?.network, + isLegacy: !!entry?.isLegacy, + meta: meta ? { + logo: meta.logo, color: meta.color, short: meta.short, ticker: meta.ticker, decimals: meta.decimals, + coinLabel: meta.coinLabel, networkLabel: meta.networkLabel, testnet: meta.testnet, + addressFamilies: meta.addressFamilies, defaultAccountPath: meta.defaultAccountPath, + } : null, + supportsMessageSign: !!meta?.supportsMessageSign, + phase: rt?.phase || "locked", + error: rt?.error || null, + }; + if (rt && rt.adapter) Object.assign(base, rt.adapter.snapshot()); + return base; +} + +function fullState() { + return { + overallPhase: overallPhase(), + selectedWalletId: selectedWalletId(), + wallets: walletEntries().map(walletSummary), + selected: snapshotForSelected(), + bchServers: { + list: bchServerList(ctx.api), + custom: Array.isArray(ctx.api.storage.get("servers", null)), + }, + coins: coinsForPanel(), + prices: ctx.priceFeed ? ctx.priceFeed.snapshot() : { enabled: false, prices: {} }, + wc: ctx.wc ? ctx.wc.snapshot() : {}, + }; +} + +function emitState() { try { ctx.api.emit("state", fullState()); } catch {} } +function emitStateForWallet(id) { + // Any wallet change fans out to the panel with the full state so the + // wallet list balances update in the header too. + if (!ctx || !ctx.runtimes.has(id)) return; + emitState(); +} + +// ---- panel messages --------------------------------------------------------- + +function requireWallet(id) { + const rt = ctx.runtimes.get(id); + if (!rt || rt.phase !== "ready" || !rt.adapter) throw new Error("wallet is not ready (vault locked?)"); + return rt; +} +function requireSelected() { return requireWallet(selectedWalletId()); } +function fromPanel(m) { if (!m || m.from !== "panel") throw new Error("panel-only message"); } +function fromPage(m) { + if (!m || m.from !== "page" || !m.origin) throw new Error("page-only message"); + return m.origin; +} + +const fmtBch = (sats) => (Number(sats) / 1e8).toFixed(8).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1"); +const fmtTrx = (sun) => (Number(sun) / 1e6).toFixed(6).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1"); +function fmtValue(units, decimals) { + const n = Number(units) / Math.pow(10, decimals); + return n.toFixed(decimals).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1"); +} +// BigInt-safe display for SPL token amounts (raw units in u64 strings). +function fmtTokenAmount(rawStr, decimals) { + const s = String(rawStr || "0"); + const neg = s.startsWith("-"); + const abs = neg ? s.slice(1) : s; + const d = Number(decimals) || 0; + if (d === 0) return (neg ? "-" : "") + abs; + const pad = abs.padStart(d + 1, "0"); + const whole = pad.slice(0, pad.length - d); + const frac = pad.slice(pad.length - d).replace(/0+$/, ""); + return (neg ? "-" : "") + whole + (frac ? "." + frac : ""); +} + +function registerPanelMessages(api) { + api.onMessage("state", (_p, m) => { fromPanel(m); return fullState(); }); + api.onMessage("selectWallet", (p, m) => { + fromPanel(m); + const id = String(p && p.id || ""); + if (!walletEntries().some((w) => w.id === id)) throw new Error("unknown wallet"); + api.storage.set("selectedWalletId", id); + emitState(); + return fullState(); + }); + api.onMessage("addWallet", async (p, m) => { + fromPanel(m); + const chain = String(p && p.chain || ""); + const network = String(p && p.network || ""); + const meta = chainMeta(chain, network); + if (!meta) throw new Error("unknown chain/network"); + const list = walletEntries().slice(); + // If this coin+network declares a `legacyFirstPurpose` (SC does, to + // recover funds from the standalone siawallet addon) and no wallet of + // this coin+network exists yet, use that purpose verbatim. The bump + // to the /aegis-namespaced/ prefix only starts on the second sub-account. + const netMeta = COINS[chain]?.networks?.[network] || {}; + const existingForCoin = list.filter((w) => w.chain === chain && w.network === network && !w.isLegacy); + let purpose, isLegacy = false; + if (netMeta.legacyFirstPurpose && existingForCoin.length === 0 + && !list.some((w) => w.purpose === netMeta.legacyFirstPurpose)) { + purpose = netMeta.legacyFirstPurpose; + isLegacy = true; + } else { + const index = nextIndex(list, meta); + purpose = meta.purposePrefix + index; + } + const idIndex = nextIndex(list, meta); + const id = makeWalletId(meta, idIndex); + if (list.some((w) => w.id === id || w.purpose === purpose)) throw new Error("duplicate wallet"); + const label = String(p && p.label || "").trim() || autoLabel(meta, list); + const entry = { id, label, chain, network, purpose, isLegacy, createdAt: Date.now() }; + list.push(entry); + writeWallets(api, list); + api.storage.set("selectedWalletId", id); + ctx.runtimes.set(id, { entry, phase: "locked", error: null, adapter: null }); + emitState(); + await mountWallet(entry); + return fullState(); + }); + // Vault lifecycle from inside the wallet panel — no more redirecting the + // user to Settings > Passwords. After a successful unlock/setup we remount + // every wallet: the vault-derive route is now available. + api.onMessage("vaultSetup", async (p, m) => { + fromPanel(m); + const pw = String(p && p.masterPassword || ""); + const seedSource = p && p.seedSource; + await api.vault.lifecycle.setup(pw, seedSource); + await mountAllWallets(); + return fullState(); + }); + api.onMessage("vaultUnlock", async (p, m) => { + fromPanel(m); + const pw = String(p && p.masterPassword || ""); + await api.vault.lifecycle.unlock(pw); + await mountAllWallets(); + return fullState(); + }); + api.onMessage("vaultStatus", async (_p, m) => { + fromPanel(m); + return api.vault.lifecycle.status(); + }); + + // ---- wallet import (M.1 of DESIGN-wallet-multi-account-amendment.md) ---- + // Accepts either a BIP39 mnemonic (12/24 words) + BIP44 path, OR a raw WIF. + // Derives the P2PKH cashaddr client-side, stores the signer material via + // api.vault.imports.add (main-process holds it), then adds a slim Aegis + // wallet entry with kind=imported pointing at the returned importId. + api.onMessage("importWallet", async (p, m) => { + fromPanel(m); + const chain = String(p && p.chain || "bch"); + if (chain !== "bch") throw new Error("only BCH imports are supported in this build"); + const network = String(p && p.network || "chipnet"); + if (network !== "mainnet" && network !== "chipnet") throw new Error(`unsupported network ${network}`); + const label = String(p && p.label || "").trim(); + if (!label) throw new Error("label required"); + const category = String(p && p.category || "operational").trim(); + const prefix = network === "mainnet" ? "bitcoincash" : "bchtest"; + const source = String(p && p.source || "manual-paste"); + + let kind, seedHex, path, wif, cashaddrStr; + if (p && p.wif) { + kind = "wif"; + wif = String(p.wif).trim(); + cashaddrStr = deriveCashaddrFromWif(wif, prefix); + } else if (p && p.mnemonic) { + kind = "seed"; + const words = String(p.mnemonic).trim().split(/\s+/).length; + if (words !== 12 && words !== 15 && words !== 18 && words !== 21 && words !== 24) { + throw new Error(`mnemonic must be 12/15/18/21/24 words (got ${words})`); + } + if (!ctx.d.bip39.validateMnemonic(String(p.mnemonic).trim())) { + throw new Error("invalid BIP39 mnemonic (unknown word or bad checksum)"); + } + const seed = ctx.d.bip39.mnemonicToSeedSync(String(p.mnemonic).trim()); + seedHex = Buffer.from(seed).toString("hex"); + path = String(p.path || (network === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0")); + cashaddrStr = deriveCashaddrFromSeed(seedHex, path, prefix); + } else if (p && p.seedHex) { + kind = "seed"; + seedHex = String(p.seedHex).trim().toLowerCase().replace(/^0x/, ""); + if (!/^[0-9a-f]{64,128}$/.test(seedHex)) throw new Error("seedHex must be 32-64 bytes of hex"); + path = String(p.path || (network === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0")); + cashaddrStr = deriveCashaddrFromSeed(seedHex, path, prefix); + } else { + throw new Error("supply mnemonic, seedHex, or wif"); + } + + const spec = { kind, cashaddr: cashaddrStr, label, category, source }; + if (kind === "seed") { spec.seed = seedHex; spec.path = path; } + else { spec.wif = wif; } + const { id: importId } = await api.vault.imports.add(spec); + + // Persist as an Aegis wallet entry with kind=imported. Uses a distinct + // id prefix so it's obvious in storage that this row references the + // imports file rather than a vault-derive purpose. + const list = walletEntries().slice(); + const walletId = `bch-imported-${importId}`; + if (list.some((w) => w.id === walletId)) throw new Error("duplicate import id"); + const entry = { + id: walletId, label, chain: "bch", network, + kind: "imported", importId, importedCashaddr: cashaddrStr, importedCategory: category, + createdAt: Date.now(), + }; + list.push(entry); + writeWallets(api, list); + api.storage.set("selectedWalletId", walletId); + ctx.runtimes.set(walletId, { entry, phase: "locked", error: null, adapter: null }); + emitState(); + await mountWallet(entry); + return fullState(); + }); + + api.onMessage("removeWallet", (p, m) => { + fromPanel(m); + const id = String(p && p.id || ""); + const list = walletEntries(); + const entry = list.find((w) => w.id === id); + if (!entry) throw new Error("unknown wallet"); + if (entry.isDefault) throw new Error("the default wallet cannot be removed"); + const next = list.filter((w) => w.id !== id); + writeWallets(api, next); + if (selectedWalletId() === id) api.storage.set("selectedWalletId", next[0]?.id || ""); + unmountWallet(id); + // Drop per-wallet storage subtree. + const all = api.storage.all ? api.storage.all() : {}; + const prefix = `wallets/${id}/`; + for (const k of Object.keys(all)) if (k.startsWith(prefix)) api.storage.set(k, null); + emitState(); + return fullState(); + }); + api.onMessage("renameWallet", (p, m) => { + fromPanel(m); + const id = String(p && p.id || ""); + const label = String(p && p.label || "").trim().slice(0, 60); + if (!label) throw new Error("label required"); + const list = walletEntries(); + const entry = list.find((w) => w.id === id); + if (!entry) throw new Error("unknown wallet"); + entry.label = label; + writeWallets(api, list); + emitState(); + return fullState(); + }); + + api.onMessage("refresh", async (_p, m) => { fromPanel(m); const rt = requireSelected(); await rt.adapter.refresh(true); return snapshotForSelected(); }); + api.onMessage("nextAddress", (_p, m) => { + fromPanel(m); + const rt = requireSelected(); + if (rt.entry.chain !== "bch") throw new Error("only BCH wallets have multiple receive addresses"); + rt.adapter.nextAddress(); + return snapshotForSelected(); + }); + api.onMessage("openUrl", (p, m) => { fromPanel(m); api.openTab(String(p && p.url || "")); return true; }); + // Panel gate uses this to jump to Settings › Passwords when the vault is + // locked / not yet created — one-click bridge to Theseus's built-in UI. + api.onMessage("openSettings", (p, m) => { fromPanel(m); api.openSettings(String(p && p.section || "")); return true; }); + + api.onMessage("setBchServers", (p, m) => { + fromPanel(m); + const patch = p || {}; + if ("servers" in patch) { + const list = Array.isArray(patch.servers) ? patch.servers.map((s) => String(s).trim()).filter(Boolean) : []; + for (const s of list) if (!/^wss?:\/\/[^/\s]+$/i.test(s)) throw new Error(`server must be ws(s)://host:port — got ${s}`); + api.storage.set("servers", list.length ? list : null); + // Push the new server list into every mounted BCH wallet. + for (const rt of ctx.runtimes.values()) { + if (rt.entry.chain === "bch" && rt.adapter) rt.adapter.setServers(bchServerList(api)); + } + } + return fullState(); + }); + api.onMessage("setAccountPath", (p, m) => { + fromPanel(m); + const patch = p || {}; + const id = String(patch.id || selectedWalletId()); + const entry = walletEntries().find((w) => w.id === id); + if (!entry) throw new Error("unknown wallet"); + if (!["bch", "dgb", "btc"].includes(entry.chain)) throw new Error("account path is a BCH/BTC/DGB-only setting"); + const v = String(patch.accountPath || "").trim(); + if (v && !/^m(\/\d+'?)+$/.test(v)) throw new Error("derivation path must look like m/84'/0'/0'"); + const list = walletEntries(); + const idx = list.findIndex((w) => w.id === id); + // Per-network default: BTC/DGB come from the registry helper, BCH keeps + // its historical m/44'/145'/0'. + const dflt = entry.chain === "bch" + ? "m/44'/145'/0'" + : (defaultAccountPathFor(COINS[entry.chain], entry.network) || "m/84'/0'/0'"); + list[idx] = { ...list[idx], accountPath: v || dflt }; + writeWallets(api, list); + const rt = ctx.runtimes.get(id); + if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} rt.adapter = null; rt.phase = "locked"; } + mountWallet(list[idx]); + return fullState(); + }); + // ETH/SOL: per-wallet RPC URL, live-swap without key rebuild. + api.onMessage("setRpcUrl", (p, m) => { + fromPanel(m); + const patch = p || {}; + const id = String(patch.id || selectedWalletId()); + const entry = walletEntries().find((w) => w.id === id); + if (!entry) throw new Error("unknown wallet"); + if (entry.chain !== "eth" && entry.chain !== "sol") throw new Error("RPC URL is an ETH/SOL setting"); + const v = String(patch.rpcUrl || "").trim(); + if (v && !/^https?:\/\/[^\s]+$/i.test(v)) throw new Error("RPC URL must start with http:// or https://"); + api.storage.set(`wallets/${id}/rpcUrl`, v); + const rt = ctx.runtimes.get(id); + if (rt && rt.adapter && typeof rt.adapter.setRpcUrl === "function") { + rt.adapter.setRpcUrl(v); + rt.adapter.refresh().catch((e) => api.log(`[${id}] refresh:`, e?.message || e)); + } + emitState(); + return fullState(); + }); + // Sia-only: per-wallet walletd URL. Live: pushes the URL into the adapter + // without rebuilding the keys (the seed stays derived from the same + // vault path — only the node the wallet talks to changes). + api.onMessage("setWalletdUrl", (p, m) => { + fromPanel(m); + const patch = p || {}; + const id = String(patch.id || selectedWalletId()); + const entry = walletEntries().find((w) => w.id === id); + if (!entry) throw new Error("unknown wallet"); + if (entry.chain !== "sc") throw new Error("walletd URL is a Sia-only setting"); + const v = String(patch.walletdUrl || "").trim(); + if (v && !/^https?:\/\/[^\s]+$/i.test(v)) throw new Error("walletd URL must start with http:// or https://"); + api.storage.set(`wallets/${id}/walletdUrl`, v); + const rt = ctx.runtimes.get(id); + if (rt && rt.adapter) { + rt.adapter.setWalletdUrl(v); + if (v) rt.adapter.startPolling(); + rt.adapter.refresh(true).catch((e) => api.log(`[${id}] refresh:`, e?.message || e)); + } + emitState(); + return fullState(); + }); + + // Live plan preview for the selected wallet. + api.onMessage("planSend", async (p, m) => { + fromPanel(m); + const rt = requireSelected(); + const plan = await Promise.resolve(rt.adapter.plan(p || {})); + return describePlan(plan, rt.entry.chain, rt.entry.network); + }); + // SPL token plan/send — only meaningful when the selected wallet is SOL. + api.onMessage("planTokenSend", async (p, m) => { + fromPanel(m); + const rt = requireSelected(); + if (rt.entry.chain !== "sol" || typeof rt.adapter.planTokenTransfer !== "function") { + throw new Error("token send is a Solana-only flow"); + } + const plan = await rt.adapter.planTokenTransfer(p || {}); + return { + recipients: plan.recipients, fee: String(plan.fee), feeRate: String(plan.feeRate), + inputs: 0, change: "0", total: plan.total, mint: plan.mint, decimals: plan.decimals, + }; + }); + api.onMessage("sendToken", async (p, m) => { + fromPanel(m); + const rt = requireSelected(); + if (rt.entry.chain !== "sol") throw new Error("token send is Solana-only"); + const plan = await rt.adapter.planTokenTransfer(p || {}); + const meta = chainMeta("sol", rt.entry.network); + const tokenInfo = (rt.adapter.snapshot().tokens || []).find((t) => t.mint === plan.mint) || {}; + const rows = [ + { label: "To", value: plan.recipients[0].to, mono: true }, + { label: "Amount", value: `${fmtTokenAmount(plan.recipients[0].value, plan.decimals)} ${tokenInfo.symbol || "token"}`, strong: true }, + { label: "Mint", value: plan.mint, mono: true }, + { label: "Network fee", value: `~${fmtValue(Number(plan.fee), meta.decimals)} SOL${(plan._spl && plan._spl.destExists === false) ? " (includes new token account rent)" : ""}` }, + { label: "Wallet", value: `${rt.entry.label} — Solana · ${rt.entry.network}` }, + ]; + const pick = await api.approvalModal({ + title: `Send ${tokenInfo.symbol || "SPL token"}?`, + origin: "Aegis wallet panel", + rows, + actions: [{ id: "send", label: "Send", primary: true }], + }); + if (pick !== "send") throw new Error("cancelled"); + return rt.adapter.signAndBroadcastToken(plan); + }); + // Execute a send with approval overlay. + api.onMessage("send", async (p, m) => { + fromPanel(m); + const rt = requireSelected(); + const plan = await Promise.resolve(rt.adapter.plan(p || {})); + const d = describePlan(plan, rt.entry.chain, rt.entry.network); + const meta = chainMeta(rt.entry.chain, rt.entry.network); + const rows = [ + { label: "To", value: d.recipients[0].to, mono: true }, + { label: "Amount", value: `${fmtValue(d.recipients[0].value, meta.decimals)} ${meta.ticker}`, strong: true }, + { label: "Fee", value: rt.entry.chain === "bch" ? `${plan.fee} sat (${plan.feeRate} sat/B)` : `${fmtValue(plan.fee, meta.decimals)} ${meta.ticker}` }, + { label: "Total", value: `${fmtValue(d.total, meta.decimals)} ${meta.ticker}` }, + { label: "Wallet", value: `${rt.entry.label} — ${meta.coinLabel} · ${meta.networkLabel}` }, + ]; + const pick = await api.approvalModal({ + title: `Send ${meta.ticker}?`, + origin: "Aegis wallet panel", + rows, + actions: [{ id: "send", label: "Send", primary: true }], + }); + if (pick !== "send") throw new Error("cancelled"); + return rt.adapter.signAndBroadcast(plan); + }); + + api.onMessage("recovery", async (p, m) => { + fromPanel(m); + const id = String(p && p.id || selectedWalletId()); + const rt = requireWallet(id); + if (typeof rt.adapter.recovery !== "function") throw new Error("this chain does not expose recovery details"); + const r = rt.adapter.recovery(); + const out = { accountPath: r.accountPath, xpub: r.xpub, purpose: rt.entry.purpose }; + if (p && p.reveal) { + // Sia's "xprv" is really the 32-byte wallet seed (walletd KeyFromSeed); + // BCH/DGB's is the account xprv. Warning copy fits both. + const pick = await api.approvalModal({ + title: rt.entry.chain === "sc" ? "Reveal the wallet seed?" : "Reveal the account private key?", + origin: "Aegis wallet panel", + body: "Anyone holding this can spend every coin in this wallet. It stays on screen until you close the Settings tab.", + actions: [{ id: "reveal", label: "Reveal", danger: true }], + }); + if (pick === "reveal") out.xprv = r.xprv; + } + return out; + }); + + // Opt-in USD prices. Persist the choice so restart doesn't silently + // disable it, and kick a fetch immediately when switched on. + api.onMessage("setPricesEnabled", async (p, m) => { + fromPanel(m); + const on = !!(p && p.enabled); + api.storage.set("pricesEnabled", on); + if (ctx.priceFeed) await ctx.priceFeed.setEnabled(on); + return fullState(); + }); + api.onMessage("refreshPrices", async (_p, m) => { + fromPanel(m); + if (ctx.priceFeed) await ctx.priceFeed.refresh(); + return fullState(); + }); + + // WizardConnect: pair a wiz:// URI with a specific BCH wallet. + api.onMessage("wcConnect", async (p, m) => { + fromPanel(m); + if (!ctx.wc) throw new Error("WizardConnect not ready"); + const walletId = String(p && p.walletId || ""); + const uri = String(p && p.uri || ""); + await ctx.wc.connectUri(walletId, uri); + return fullState(); + }); + api.onMessage("wcDisconnect", async (p, m) => { + fromPanel(m); + if (!ctx.wc) throw new Error("WizardConnect not ready"); + await ctx.wc.disconnect(String(p && p.walletId || ""), String(p && p.connId || "")); + return fullState(); + }); + + api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); }); + api.onMessage("revoke", (p, m) => { + fromPanel(m); + const perms = permissions(api); + delete perms[String(p && p.origin || "")]; + api.storage.set("permissions", perms); + return perms; + }); +} + +// One "describePlan" is enough for both chains because plan() returns a +// common shape: {recipients:[{to,value}], fee, feeRate, total, inputs:[]…}. +function describePlan(plan) { + const sent = plan.recipients.reduce((a, r) => a + r.value, 0); + return { + recipients: plan.recipients, fee: plan.fee, feeRate: plan.feeRate, + inputs: (plan.inputs || []).length, change: plan.change || null, + total: plan.total != null ? plan.total : (sent + plan.fee), + }; +} + +// ---- dapp bridges (page → activate()) -------------------------------------- +// Permission model stays the same as the single-wallet build for BCH: +// { [origin]: { readAddress:true, sendTx:{capSats,usedSats,grantedAt}, +// trx: { readAddress:true, network } } } +// The BCH bridge always talks to the LEGACY default BCH wallet (the .x pages +// pre-date multi-wallet and cannot pick between them). The Tron bridge talks +// to the currently-SELECTED Tron wallet; if none is selected, requests fail. +const BCH_ALLOWANCES = [100000, 1000000, 10000000]; // 0.001, 0.01, 0.1 BCH +const pendingByOrigin = new Set(); +function permissions(api) { const p = api.storage.get("permissions", {}); return p && typeof p === "object" ? p : {}; } +async function withOriginLock(origin, fn) { + if (pendingByOrigin.has(origin)) throw new Error("a wallet request from this site is already waiting for approval"); + pendingByOrigin.add(origin); + try { return await fn(); } finally { pendingByOrigin.delete(origin); } +} + +// Only .x sites (BCNR-native TLD) get the BCH bridge, matching the pre- +// multi-wallet gate. Widening the manifest to https://*/* makes the Tron +// bridge available everywhere; the BCH side enforces its narrower rule +// inside the handlers. +function isBchOrigin(origin) { + try { const h = new URL(origin).hostname; return /\.x$/.test(h); } + catch { return false; } +} +function legacyBchRuntime() { + const rt = ctx.runtimes.get(LEGACY_BCH_WALLET_ID); + if (!rt || rt.phase !== "ready" || !rt.adapter) throw new Error("wallet is not ready (vault locked?)"); + return rt; +} +// The Tron bridge routes to the currently-selected wallet if it is Tron; +// otherwise it looks for the first ready Tron wallet on the selected network +// hint; else rejects with "no tron wallet". +function activeTronRuntime() { + const selId = selectedWalletId(); + const selRt = selId && ctx.runtimes.get(selId); + if (selRt && selRt.entry.chain === "trx" && selRt.phase === "ready") return selRt; + for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "trx" && rt.phase === "ready") return rt; + throw new Error("no Tron wallet available — add one in the Aegis sidebar"); +} + +function registerPageMessages(api) { + // ---- BCH bridge (unchanged behavior; wallet source is legacy default) ---- + api.onMessage("getAddress", async (_p, m) => { + const origin = fromPage(m); + if (!isBchOrigin(origin)) throw new Error("this site is not on a Bitcoin Cash origin"); + const rt = legacyBchRuntime(); + const perms = permissions(api); + if (perms[origin] && perms[origin].readAddress) return rt.adapter.current().address; + return withOriginLock(origin, async () => { + const pick = await api.approvalModal({ + title: "Share your Bitcoin Cash address?", + origin, + body: "The site will see your current receiving address and can look up its balance and history on the public chain.", + rows: [{ label: "Address", value: rt.adapter.current().address, mono: true }], + actions: [{ id: "allow", label: "Share", primary: true }], + checkbox: { id: "always", label: "Always allow this site to see my address" }, + }); + if (!pick.startsWith("allow")) throw new Error("user rejected"); + if (pick === "allow+always") { perms[origin] = { ...(perms[origin] || {}), readAddress: true }; api.storage.set("permissions", perms); emitState(); } + return rt.adapter.current().address; + }); + }); + api.onMessage("signAndSend", async (p, m) => { + const origin = fromPage(m); + if (!isBchOrigin(origin)) throw new Error("this site is not on a Bitcoin Cash origin"); + const rt = legacyBchRuntime(); + return withOriginLock(origin, async () => { + let plan; + try { plan = rt.adapter.plan(p || {}); } + catch (e) { throw new Error(/insufficient funds|too small/i.test(e?.message) ? "insufficient funds" : e?.message || String(e)); } + const d = describePlan(plan); + if (d.recipients.length > 8) throw new Error("too many outputs"); + const perms = permissions(api); + const budget = perms[origin] && perms[origin].sendTx; + const remaining = budget ? Math.max(0, (budget.capSats | 0) - (budget.usedSats | 0)) : 0; + if (budget && d.total <= remaining) { + const r = await rt.adapter.signAndBroadcast(plan); + budget.usedSats = (budget.usedSats | 0) + d.total; + api.storage.set("permissions", perms); + emitState(); + api.log(`silent send ${d.total} sat for ${origin}, ${remaining - d.total} sat of allowance left`); + return { txid: r.txid }; + } + const rows = d.recipients.map((r, i) => ({ label: d.recipients.length > 1 ? `To #${i + 1}` : "To", value: r.to, mono: true })); + rows.push({ label: "Amount", value: fmtBch(d.recipients.reduce((a, r) => a + r.value, 0)) + " BCH", strong: true }); + rows.push({ label: "Fee", value: `${plan.fee} sat (${plan.feeRate} sat/B)` }); + rows.push({ label: "Total", value: fmtBch(d.total) + " BCH" }); + const pick = await api.approvalModal({ + title: "Send Bitcoin Cash?", + origin, + body: budget + ? `This payment is over what is left of the site's allowance (${fmtBch(remaining)} BCH). Check the address and amount.` + : "This site is asking your wallet to pay. Check the address and amount.", + rows, + actions: [{ id: "send", label: "Send", primary: true }], + select: { + id: "cap", label: "Afterwards", + options: [{ value: "", label: "ask every time" }, ...BCH_ALLOWANCES.map((s) => ({ value: String(s), label: `allow up to ${fmtBch(s)} BCH more without asking` }))], + }, + }); + const [action, ...flags] = pick.split("+"); + if (action !== "send") throw new Error("user rejected"); + const cap = flags.find((f) => f.startsWith("cap=")); + const capSats = cap ? Number(cap.slice(4)) : 0; + if (BCH_ALLOWANCES.includes(capSats)) { + perms[origin] = { ...(perms[origin] || {}), sendTx: { capSats, usedSats: 0, grantedAt: Date.now() } }; + api.storage.set("permissions", perms); + } else if (budget) { + delete perms[origin].sendTx; + api.storage.set("permissions", perms); + } + emitState(); + const r = await rt.adapter.signAndBroadcast(plan); + return { txid: r.txid }; + }); + }); + api.onMessage("signMessage", async (p, m) => { + const origin = fromPage(m); + if (!isBchOrigin(origin)) throw new Error("this site is not on a Bitcoin Cash origin"); + const rt = legacyBchRuntime(); + const message = String(p && p.message != null ? p.message : ""); + if (message.length > 4096) throw new Error("message too long"); + return withOriginLock(origin, async () => { + const pick = await api.approvalModal({ + title: "Sign a message?", + origin, + body: "Signing proves you control the address below. It moves no coins.", + rows: [ + { label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true }, + { label: "Address", value: rt.adapter.current().address, mono: true }, + ], + actions: [{ id: "sign", label: "Sign", primary: true }], + }); + if (pick !== "sign") throw new Error("user rejected"); + return rt.adapter.signMessage(message); + }); + }); + + // ---- Tron bridge (tronWeb / tronLink) ----------------------------------- + api.onMessage("trx.requestAccounts", async (_p, m) => { + const origin = fromPage(m); + const rt = activeTronRuntime(); + const perms = permissions(api); + const alreadyOK = perms[origin] && perms[origin].trx && perms[origin].trx.readAddress; + const snap = rt.adapter.snapshot(); + if (alreadyOK) return { code: 200, address: snap.address, network: snap.network }; + return withOriginLock(origin, async () => { + const pick = await api.approvalModal({ + title: "Connect this site to your Tron wallet?", + origin, + body: "The site will see this address and can build transactions for you to sign.", + rows: [ + { label: "Address", value: snap.address, mono: true }, + { label: "Network", value: snap.network === "nile" ? "Nile testnet" : "Tron mainnet" }, + { label: "Wallet", value: `${rt.entry.label} — Tron · ${snap.network === "nile" ? "Nile testnet" : "Mainnet"}` }, + ], + actions: [{ id: "allow", label: "Connect", primary: true }], + checkbox: { id: "always", label: "Always allow this site to see this address" }, + }); + if (!pick.startsWith("allow")) throw new Error("user rejected"); + if (pick === "allow+always") { + perms[origin] = { ...(perms[origin] || {}), trx: { readAddress: true, network: snap.network } }; + api.storage.set("permissions", perms); + emitState(); + } + return { code: 200, address: snap.address, network: snap.network }; + }); + }); + api.onMessage("trx.getAccount", (_p, m) => { + const origin = fromPage(m); + const perms = permissions(api); + if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first"); + const rt = activeTronRuntime(); + const snap = rt.adapter.snapshot(); + return { address: snap.address, network: snap.network }; + }); + // Sign an arbitrary raw_data_hex the dapp built (with its own tronWeb). + // The wallet never guesses the intent — the approval overlay shows the + // decoded contract type and destination when it can, and always the txID. + api.onMessage("trx.signTransaction", async (p, m) => { + const origin = fromPage(m); + const rt = activeTronRuntime(); + const perms = permissions(api); + if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first"); + const tx = p && p.transaction; + if (!tx || typeof tx !== "object" || !tx.raw_data_hex || !tx.raw_data) throw new Error("bad transaction"); + return withOriginLock(origin, async () => { + const contract = (tx.raw_data.contract || [])[0]; + const type = contract?.type || "Contract"; + const rows = [{ label: "Type", value: type }, { label: "Tx ID", value: tx.txID || "(unset)", mono: true }]; + if (type === "TransferContract") { + const v = contract.parameter?.value || {}; + try { + const to = v.to_address ? ctx.d.tronAdapter.hexToAddress(v.to_address) : (v.to_address || ""); + const amount = Number(v.amount || 0); + rows.splice(1, 0, { label: "To", value: to, mono: true }, { label: "Amount", value: `${fmtTrx(amount)} TRX`, strong: true }); + } catch {} + } + rows.push({ label: "Wallet", value: `${rt.entry.label} — Tron · ${rt.adapter.snapshot().network === "nile" ? "Nile testnet" : "Mainnet"}` }); + const pick = await api.approvalModal({ + title: "Sign a Tron transaction?", + origin, + body: "The site built this transaction. Check the type, amount, and destination before signing.", + rows, + actions: [{ id: "sign", label: "Sign", primary: true }], + }); + if (pick !== "sign") throw new Error("user rejected"); + const sig = rt.adapter.signRawData(tx.raw_data_hex); + const signed = { ...tx, signature: [sig] }; + return signed; + }); + }); + api.onMessage("trx.sendRawTransaction", async (p, m) => { + const origin = fromPage(m); + const rt = activeTronRuntime(); + const perms = permissions(api); + if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first"); + const signedTx = p && p.transaction; + if (!signedTx || !signedTx.raw_data_hex || !Array.isArray(signedTx.signature)) throw new Error("bad signed tx"); + // No approval here — broadcasting a *signed* tx does not add any risk + // the sign step didn't already carry. Sites that don't want an extra + // network round-trip pass {broadcast:true} to sign; we support both. + return rt.adapter.broadcastSignedTx(signedTx); + }); + api.onMessage("trx.signMessageV2", async (p, m) => { + const origin = fromPage(m); + const rt = activeTronRuntime(); + const perms = permissions(api); + if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first"); + const message = String(p && p.message != null ? p.message : ""); + if (message.length > 4096) throw new Error("message too long"); + return withOriginLock(origin, async () => { + const snap = rt.adapter.snapshot(); + const pick = await api.approvalModal({ + title: "Sign a Tron message?", + origin, + body: "Signing proves you control this address. It moves no coins.", + rows: [ + { label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true }, + { label: "Address", value: snap.address, mono: true }, + ], + actions: [{ id: "sign", label: "Sign", primary: true }], + }); + if (pick !== "sign") throw new Error("user rejected"); + return rt.adapter.signMessageV2(message); + }); + }); + + // ---- Ethereum (EIP-1193) --------------------------------------------- + // Routes the same way as Tron: pick the currently-selected ETH wallet if + // any; else the first ready ETH wallet. Chain switches happen at the + // wallet-picker level, not here — dapps that call wallet_switchEthereumChain + // get a friendly "switch wallet in the Aegis sidebar" error. + function activeEthRuntime() { + const selId = selectedWalletId(); + const selRt = selId && ctx.runtimes.get(selId); + if (selRt && selRt.entry.chain === "eth" && selRt.phase === "ready") return selRt; + for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "eth" && rt.phase === "ready") return rt; + throw new Error("no Ethereum wallet available — add one in the Aegis sidebar"); + } + function ethConnectedFor(origin) { + const p = permissions(api)[origin]; + return !!(p && p.eth && p.eth.readAddress); + } + api.onMessage("eth.requestAccounts", async (_p, m) => { + const origin = fromPage(m); + const rt = activeEthRuntime(); + const snap = rt.adapter.snapshot(); + const chainIdHex = "0x" + Number(snap.chainId).toString(16); + const networkVersion = String(snap.chainId); + const perms = permissions(api); + if (perms[origin] && perms[origin].eth && perms[origin].eth.readAddress) { + return { address: snap.address, chainIdHex, networkVersion }; + } + return withOriginLock(origin, async () => { + const pick = await api.approvalModal({ + title: "Connect this site to your Ethereum wallet?", + origin, + body: "The site will see this address and can build transactions for you to sign.", + rows: [ + { label: "Address", value: snap.address, mono: true }, + { label: "Network", value: snap.network === "mainnet" ? "Ethereum mainnet" : "Sepolia testnet" }, + { label: "Wallet", value: `${rt.entry.label} — Ethereum · ${snap.network}` }, + ], + actions: [{ id: "allow", label: "Connect", primary: true }], + checkbox: { id: "always", label: "Always allow this site to see this address" }, + }); + if (!pick.startsWith("allow")) throw new Error("user rejected"); + if (pick === "allow+always") { + perms[origin] = { ...(perms[origin] || {}), eth: { readAddress: true, chainId: snap.chainId } }; + api.storage.set("permissions", perms); + emitState(); + } + return { address: snap.address, chainIdHex, networkVersion }; + }); + }); + api.onMessage("eth.personalSign", async (p, m) => { + const origin = fromPage(m); + if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first"); + const rt = activeEthRuntime(); + const message = String(p && p.message != null ? p.message : ""); + if (message.length > 4096) throw new Error("message too long"); + return withOriginLock(origin, async () => { + const pick = await api.approvalModal({ + title: "Sign an Ethereum message?", + origin, + body: "Signing proves you control this address. It moves no ETH.", + rows: [ + { label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true }, + { label: "Address", value: rt.adapter.snapshot().address, mono: true }, + ], + actions: [{ id: "sign", label: "Sign", primary: true }], + }); + if (pick !== "sign") throw new Error("user rejected"); + return rt.adapter.signMessage(message); + }); + }); + api.onMessage("eth.sendTransaction", async (p, m) => { + const origin = fromPage(m); + if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first"); + const rt = activeEthRuntime(); + const tx = (p && p.tx) || {}; + if (!tx.to) throw new Error("tx.to required"); + // MetaMask semantics: `value` and `gas`/`gasLimit` are hex-encoded wei; + // convert to numbers/bigints Aegis's own plan() understands. + const valueWei = tx.value ? BigInt(tx.value).toString() : "0"; + return withOriginLock(origin, async () => { + const snap = rt.adapter.snapshot(); + const plan = await rt.adapter.plan({ to: tx.to, amount: valueWei, sendMax: false }); + const meta = chainMeta("eth", rt.entry.network); + const pick = await api.approvalModal({ + title: "Send Ethereum transaction?", + origin, + body: tx.data && tx.data !== "0x" ? "This transaction carries call data (a contract call). Check the destination + value carefully." : "This site is asking your wallet to send ETH.", + rows: [ + { label: "To", value: plan.recipients[0].to, mono: true }, + { label: "Amount", value: `${fmtValue(plan.recipients[0].value, meta.decimals)} ETH`, strong: true }, + { label: "Fee (est.)", value: `${fmtValue(plan.fee, meta.decimals)} ETH` }, + { label: "Wallet", value: `${rt.entry.label} — Ethereum · ${snap.network}` }, + ], + actions: [{ id: "send", label: "Send", primary: true }], + }); + if (pick !== "send") throw new Error("user rejected"); + const r = await rt.adapter.signAndBroadcast(plan); + return { txid: r.txid }; + }); + }); + api.onMessage("eth.signTypedData", async (p, m) => { + const origin = fromPage(m); + if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first"); + const rt = activeEthRuntime(); + // Dapps send typedData as either a JSON string (older MetaMask spec) or + // an object (v4). Accept both; the encoder wants an object. + let td = p && p.typedData; + if (typeof td === "string") { try { td = JSON.parse(td); } catch (e) { throw new Error("typedData: JSON parse failed: " + e.message); } } + if (!td || typeof td !== "object") throw new Error("typedData required"); + // Compute the digest first — if the encoder rejects the input the user + // never sees an approval overlay for a broken payload. + let digest; + try { digest = ctx.d.eip712.digest(td); } + catch (e) { throw new Error("EIP-712 encode failed: " + e.message); } + // Approval overlay: show domain (name + chain), primary type, and a + // truncated JSON preview of the message so the user has a fighting + // chance to spot phishing. + const dom = td.domain || {}; + const domainSummary = [dom.name, dom.version && `v${dom.version}`, dom.chainId && `chain ${dom.chainId}`].filter(Boolean).join(" · ") || "(no domain)"; + const messagePreview = JSON.stringify(td.message, null, 2); + const preview = messagePreview.length > 600 ? messagePreview.slice(0, 600) + "…" : messagePreview; + return withOriginLock(origin, async () => { + const pick = await api.approvalModal({ + title: "Sign typed data (EIP-712)?", + origin, + body: "The site is asking you to sign a structured message. Verify the domain matches the site you're on — a mismatched domain is the classic phishing tell.", + rows: [ + { label: "Domain", value: domainSummary }, + { label: "Primary type", value: String(td.primaryType || "") }, + { label: "Message", value: preview, mono: true }, + { label: "Address", value: rt.adapter.snapshot().address, mono: true }, + ], + actions: [{ id: "sign", label: "Sign", primary: true }], + }); + if (pick !== "sign") throw new Error("user rejected"); + return rt.adapter.signTypedDataDigest(digest); + }); + }); + api.onMessage("eth.switchChain", async (p, m) => { + fromPage(m); + const wantHex = String(p && p.chainId || "").toLowerCase(); + const wantId = Number(wantHex); + if (!Number.isFinite(wantId) || wantId <= 0) throw new Error("bad chainId"); + // Find any wallet already on that chain and select it. + for (const rt of ctx.runtimes.values()) { + if (rt.entry.chain !== "eth" || rt.phase !== "ready") continue; + const snap = rt.adapter.snapshot(); + if (Number(snap.chainId) === wantId) { + api.storage.set("selectedWalletId", rt.entry.id); + emitState(); + return null; + } + } + // EIP-3326: throw the well-known "chain not added" code so dapps fall + // back to wallet_addEthereumChain. + const err = new Error(`Aegis: chainId ${wantHex} is not added. Ask via wallet_addEthereumChain.`); + err.code = 4902; + throw err; + }); + // EIP-3085: dapp asks Aegis to add a new EVM chain. On approval, we + // persist the chain config and create a wallet on it under the same + // vault-derived key. Existing addresses on that chain remain visible on + // whatever wallet they were funded on — a chain add doesn't move any + // key material, just registers the network. + api.onMessage("eth.addChain", async (p, m) => { + const origin = fromPage(m); + const spec = (p && p.params) || {}; + const chainIdHex = String(spec.chainId || "").toLowerCase(); + const chainId = Number(chainIdHex); + if (!chainIdHex.startsWith("0x") || !Number.isFinite(chainId) || chainId <= 0) { + throw new Error("wallet_addEthereumChain: chainId must be a positive hex integer (e.g. '0x89')"); + } + const chainName = String(spec.chainName || "").trim() || `EVM #${chainId}`; + const rpcUrls = Array.isArray(spec.rpcUrls) ? spec.rpcUrls.filter((u) => /^https?:\/\//i.test(u)) : []; + const rpcUrl = rpcUrls[0]; + if (!rpcUrl) throw new Error("wallet_addEthereumChain: at least one https rpcUrls entry is required"); + const explorerBase = Array.isArray(spec.blockExplorerUrls) && spec.blockExplorerUrls[0] + ? String(spec.blockExplorerUrls[0]).replace(/\/+$/, "") + : null; + const nc = spec.nativeCurrency || {}; + const ticker = String(nc.symbol || "ETH").slice(0, 6).toUpperCase(); + // Reject if a wallet on this chain already exists — no-op success per + // EIP-3085 conventions. + const existing = walletEntries().find((w) => w.chain === "eth" + && (w.network === CUSTOM_ETH_PREFIX + chainId + || (chainMeta("eth", w.network)?.chainId === chainId))); + if (existing) { + // Auto-connect the origin to this wallet — dapps expect the returned + // provider to be pointed at the added chain immediately. + const perms = permissions(api); + perms[origin] = { ...(perms[origin] || {}), eth: { readAddress: true, chainId } }; + api.storage.set("permissions", perms); + api.storage.set("selectedWalletId", existing.id); + emitState(); + return null; + } + return withOriginLock(origin, async () => { + const pick = await api.approvalModal({ + title: "Add an Ethereum chain?", + origin, + body: "The site is asking to add a new EVM network to Aegis. Verify the RPC and chain ID — a malicious 'chain add' can point you at a fraudulent RPC that intercepts your reads or signs.", + rows: [ + { label: "Chain name", value: chainName }, + { label: "Chain ID", value: `${chainId} (${chainIdHex})` }, + { label: "Native ticker", value: ticker }, + { label: "RPC", value: rpcUrl, mono: true }, + { label: "Explorer", value: explorerBase || "(none)", mono: true }, + ], + actions: [{ id: "add", label: "Add chain", primary: true }], + }); + if (pick !== "add") throw new Error("user rejected"); + // Persist chain config + create a wallet on it. + const chains = customEthChains(api); + chains[String(chainId)] = { + chainId, chainName, rpcUrl, + explorerTx: explorerBase ? explorerBase + "/tx/" : "", + explorerAddr: explorerBase ? explorerBase + "/address/" : "", + ticker, addedAt: Date.now(), addedByOrigin: origin, + }; + api.storage.set("customEthChains", chains); + const network = CUSTOM_ETH_PREFIX + chainId; + const meta = chainMeta("eth", network); + const list = walletEntries().slice(); + const index = nextIndex(list, meta); + const purpose = meta.purposePrefix + index; + const id = makeWalletId(meta, index); + const label = `${chainName} — ${meta.short}`; + const entry = { id, label, chain: "eth", network, purpose, createdAt: Date.now() }; + list.push(entry); + writeWallets(api, list); + api.storage.set("selectedWalletId", id); + // Grant the origin read access on this chain by default (they just + // approved adding it — implicit consent to also see the address). + const perms = permissions(api); + perms[origin] = { ...(perms[origin] || {}), eth: { readAddress: true, chainId } }; + api.storage.set("permissions", perms); + ctx.runtimes.set(id, { entry, phase: "locked", error: null, adapter: null }); + emitState(); + await mountWallet(entry); + return null; + }); + }); + // Cheap state peek — used by the main-world bridge right after a switch + // or add to emit accountsChanged / chainChanged without needing another + // approval overlay. Only returns the wallet the origin already sees. + api.onMessage("eth.state", (_p, m) => { + const origin = fromPage(m); + if (!ethConnectedFor(origin)) return { address: null, chainIdHex: "0x0", networkVersion: "0" }; + const rt = activeEthRuntime(); + const snap = rt.adapter.snapshot(); + return { + address: snap.address, + chainIdHex: "0x" + Number(snap.chainId).toString(16), + networkVersion: String(snap.chainId), + }; + }); + // Read passthrough: forward eth_getBalance / eth_call / etc. to the + // wallet's own configured RPC. Nothing here reveals the private key. + api.onMessage("eth.rpc", async (p, m) => { + fromPage(m); + const rt = activeEthRuntime(); + const method = String(p && p.method || ""); + const params = (p && p.params) || []; + if (!/^eth_|^net_|^web3_/.test(method)) throw new Error("Aegis: only eth_/net_/web3_ read methods are passed through"); + return rt.adapter._client.call(method, params); + }); + + // ---- Solana (wallet-adapter) ------------------------------------------ + function activeSolRuntime() { + const selId = selectedWalletId(); + const selRt = selId && ctx.runtimes.get(selId); + if (selRt && selRt.entry.chain === "sol" && selRt.phase === "ready") return selRt; + for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "sol" && rt.phase === "ready") return rt; + throw new Error("no Solana wallet available — add one in the Aegis sidebar"); + } + function solConnectedFor(origin) { + const p = permissions(api)[origin]; + return !!(p && p.sol && p.sol.readAddress); + } + api.onMessage("sol.connect", async (_p, m) => { + const origin = fromPage(m); + const rt = activeSolRuntime(); + const snap = rt.adapter.snapshot(); + if (solConnectedFor(origin)) return { address: snap.address, network: snap.network }; + return withOriginLock(origin, async () => { + const pick = await api.approvalModal({ + title: "Connect this site to your Solana wallet?", + origin, + body: "The site will see this address and can build transactions for you to sign.", + rows: [ + { label: "Address", value: snap.address, mono: true }, + { label: "Network", value: snap.network === "mainnet" ? "Mainnet-beta" : "Devnet" }, + { label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` }, + ], + actions: [{ id: "allow", label: "Connect", primary: true }], + checkbox: { id: "always", label: "Always allow this site to see this address" }, + }); + if (!pick.startsWith("allow")) throw new Error("user rejected"); + if (pick === "allow+always") { + const perms = permissions(api); + perms[origin] = { ...(perms[origin] || {}), sol: { readAddress: true, network: snap.network } }; + api.storage.set("permissions", perms); + emitState(); + } + return { address: snap.address, network: snap.network }; + }); + }); + api.onMessage("sol.signMessage", async (p, m) => { + const origin = fromPage(m); + if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first"); + const rt = activeSolRuntime(); + const b64 = String(p && p.messageB64 || ""); + const bytes = Buffer.from(b64, "base64"); + if (bytes.length > 4096) throw new Error("message too long"); + return withOriginLock(origin, async () => { + const preview = bytes.every((c) => c >= 0x20 && c < 0x7f) ? bytes.toString("utf8") : `<${bytes.length} bytes: 0x${bytes.toString("hex").slice(0, 60)}…>`; + const pick = await api.approvalModal({ + title: "Sign a Solana message?", + origin, + body: "Signing proves you control this address. It moves no SOL.", + rows: [ + { label: "Message", value: preview.length > 400 ? preview.slice(0, 400) + "…" : preview, mono: true }, + { label: "Address", value: rt.adapter.snapshot().address, mono: true }, + ], + actions: [{ id: "sign", label: "Sign", primary: true }], + }); + if (pick !== "sign") throw new Error("user rejected"); + return rt.adapter.signMessage(bytes); + }); + }); + // Dapp-built transaction. The main-world bridge passes the FULL wire + // (tx.serialize({requireAllSignatures:false, verifySignatures:false})) + // — signature slots the dapp already filled with partialSign() are + // preserved; our wallet only overwrites its own slot. That's the only + // way to sign multi-signer transactions the dapp has partially + // co-signed (co-signer sigs, ephemeral session keys, etc.). + api.onMessage("sol.signAndSend", async (p, m) => { + const origin = fromPage(m); + if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first"); + const rt = activeSolRuntime(); + const wireB64 = String(p && p.wireB64 || ""); + if (!wireB64) throw new Error("wireB64 required (full serialized transaction)"); + const wire = new Uint8Array(Buffer.from(wireB64, "base64")); + // Parse: compact-u16(sigCount) || sig[0..64]*sigCount || message + let off = 0; + const readCompactU16 = () => { + let n = 0, shift = 0; + while (true) { + const b = wire[off++]; + n |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + if (shift > 21) throw new Error("compact-u16 too long"); + } + return n; + }; + const sigCount = readCompactU16(); + if (sigCount < 1 || sigCount > 32) throw new Error("bad signature count " + sigCount); + const sigsStart = off; + const messageStart = sigsStart + sigCount * 64; + if (wire.length < messageStart) throw new Error("truncated tx wire"); + const messageBytes = wire.slice(messageStart); + // Parse the message enough to find our pubkey's index in the account + // list. Layout: header(3) || compactU16(keyCount) || key[32]*keyCount || … + if (messageBytes.length < 3 + 1 + 32) throw new Error("message too short"); + const numRequiredSigs = messageBytes[0]; + let moff = 3; + const readKeyCount = () => { + let n = 0, shift = 0; + while (true) { + const b = messageBytes[moff++]; + n |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + } + return n; + }; + const keyCount = readKeyCount(); + if (keyCount < 1 || keyCount > 64) throw new Error("bad account key count"); + // Find our public key among the key list. + const ourPub = rt.adapter._pub; + let ourIndex = -1; + for (let i = 0; i < keyCount; i++) { + const key = messageBytes.subarray(moff + i * 32, moff + (i + 1) * 32); + let eq = true; + for (let j = 0; j < 32; j++) if (key[j] !== ourPub[j]) { eq = false; break; } + if (eq) { ourIndex = i; break; } + } + if (ourIndex < 0) throw new Error("this wallet's key is not among the transaction's account keys"); + if (ourIndex >= numRequiredSigs) throw new Error(`this wallet's key is not a required signer (index ${ourIndex}, requiredSigs ${numRequiredSigs})`); + + return withOriginLock(origin, async () => { + const snap = rt.adapter.snapshot(); + const otherSigners = numRequiredSigs > 1 ? numRequiredSigs - 1 : 0; + const pick = await api.approvalModal({ + title: "Sign + send a Solana transaction?", + origin, + body: "The site built this transaction. Aegis can't decode arbitrary Solana instructions in this rev — verify the site before signing.", + rows: [ + { label: "Message size", value: `${messageBytes.length} bytes` }, + { label: "Required signers", value: otherSigners + ? `${numRequiredSigs} — you (slot #${ourIndex}) + ${otherSigners} other${otherSigners === 1 ? "" : "s"}` + : "1 — you" }, + { label: "Address", value: snap.address, mono: true }, + { label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` }, + ], + actions: [{ id: "send", label: "Sign & send", primary: true }], + }); + if (pick !== "send") throw new Error("user rejected"); + // Sign the message and patch our slot. Any partial signatures already + // in the wire (from tx.partialSign()) at other slots are preserved. + const sigInfo = rt.adapter.signMessage(messageBytes); + const sigBytes = ctx.d.base58check.decodeBase58(sigInfo.signature); + if (sigBytes.length !== 64) throw new Error("bad ed25519 signature length"); + const wireOut = new Uint8Array(wire); // copy so we don't mutate caller + wireOut.set(sigBytes, sigsStart + ourIndex * 64); + const wireB58 = ctx.d.base58check.encodeBase58(wireOut); + const txid = await rt.adapter._client.call("sendTransaction", [wireB58]); + if (typeof txid !== "string" || !txid.length) throw new Error("bad txid from RPC: " + JSON.stringify(txid)); + setTimeout(() => rt.adapter.refresh().catch(() => {}), 4000); + return { txid }; + }); + }); +} + +// ---- activate --------------------------------------------------------------- + +module.exports = { + activate(api) { + // No explicit icon — inherit manifest.icon (branded shield data URI) + // so the toolbar dock button renders the aegis.x brand mark instead + // of a fallback emoji. + api.registerSidebarPanel({ id: "main", title: "Wallet", page: "panel.html" }); + const c = ctx = { + api, + d: null, + runtimes: new Map(), // walletId -> { entry, phase, error, adapter } + priceFeed: require("./lib/prices.js")({ + log: (...a) => api.log("prices", ...a), + onChange: () => emitState(), + }), + wc: null, // WizardConnect manager, initialised when deps load + }; + migrateLegacyStorage(api); + registerPanelMessages(api); + registerPageMessages(api); + // Restore the user's opt-in choice from storage. Off by default so a + // fresh install never hits CoinGecko without asking. + if (api.storage.get("pricesEnabled", false)) c.priceFeed.setEnabled(true).catch(() => {}); + // The deps are heavy to evaluate (noble curve precompute, bitcoinjs, + // libauth, WizardConnect) and that all happens on the main thread. Wait + // for the browser chrome to paint so the cost never delays Theseus's + // first frame; older hosts without whenUiReady load immediately. + const uiReady = typeof api.whenUiReady === "function" ? api.whenUiReady() : Promise.resolve(); + uiReady.then(() => loadDeps(api)).then((d) => { + if (ctx !== c) return; + c.d = d; + // Start the WizardConnect manager once deps are ready. Per-wallet + // adapters get spun up in mountWallet() as each BCH wallet becomes + // available (only BCH today — hdwalletv1 is BCH-scoped). + c.wc = require("./lib/wc.js")({ + HDKey: d.HDKey, secp256k1: d.secp256k1, sha256: d.sha256, hkdf: d.hkdf, + WalletConnectionManager: d.wcWallet.WalletConnectionManager, + wcCore: d.wcCore, libauth: d.libauth, + log: (...a) => api.log("wc", ...a), + api, + // Bridge sign approvals through the addon's approval-modal capability. + approvalRequest: async (payload) => { + const dappName = payload.request?.transaction?.userPrompt || "dapp"; + const pick = await api.approvalModal({ + title: `Sign transaction for ${dappName}`, + body: buildWcApprovalBody(payload), + approve: "Sign", + reject: "Reject", + }); + return { approved: pick === "approve" }; + }, + }); + c.wc.onStateChange(() => emitState()); + return mountAllWallets(); + }).catch((e) => { + if (ctx !== c) return; + api.log("startup failed:", e?.message); + emitState(); + }); + }, + deactivate() { + const c = ctx; ctx = null; + if (!c) return; + try { c.priceFeed && c.priceFeed.dispose(); } catch {} + for (const rt of c.runtimes.values()) { + try { rt.adapter && rt.adapter.dispose(); } catch {} + } + c.runtimes.clear(); + }, +}; diff --git a/bundled-addons/aegis/lib/base58check.js b/bundled-addons/aegis/lib/base58check.js new file mode 100644 index 00000000..8cb4c8d8 --- /dev/null +++ b/bundled-addons/aegis/lib/base58check.js @@ -0,0 +1,83 @@ +// Base58Check encode/decode for Tron addresses (0x41 || H160 || sha256d[:4]). +// Bitcoin-style base58 alphabet; the caller supplies the raw 21-byte payload +// (version byte first) so this module knows nothing about Tron itself. +const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const INDEX = new Int8Array(128).fill(-1); +for (let i = 0; i < ALPHABET.length; i++) INDEX[ALPHABET.charCodeAt(i)] = i; + +module.exports = function makeBase58Check({ sha256 }) { + function encodeBase58(bytes) { + let zeros = 0; + while (zeros < bytes.length && bytes[zeros] === 0) zeros++; + // Convert base-256 → base-58 by repeated division. + const b58 = new Uint8Array(Math.ceil(bytes.length * 138 / 100 + 1)); + let length = 0; + for (let i = zeros; i < bytes.length; i++) { + let carry = bytes[i]; + let j = 0; + for (let k = b58.length - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) { + carry += (b58[k] << 8) >>> 0; + b58[k] = carry % 58; + carry = (carry / 58) | 0; + } + length = j; + } + // Skip leading zero-bytes in the base58 buffer, then prepend '1' per leading zero-byte in input. + let it = b58.length - length; + while (it < b58.length && b58[it] === 0) it++; + let out = ""; + for (let i = 0; i < zeros; i++) out += ALPHABET[0]; + for (; it < b58.length; it++) out += ALPHABET[b58[it]]; + return out; + } + + function decodeBase58(str) { + if (typeof str !== "string" || str.length === 0) throw new Error("base58: empty input"); + let zeros = 0; + while (zeros < str.length && str[zeros] === ALPHABET[0]) zeros++; + const out = new Uint8Array(Math.ceil(str.length * 733 / 1000 + 1)); + let length = 0; + for (let i = zeros; i < str.length; i++) { + const c = str.charCodeAt(i); + const val = c < 128 ? INDEX[c] : -1; + if (val < 0) throw new Error("base58: bad character " + JSON.stringify(str[i])); + let carry = val; + let j = 0; + for (let k = out.length - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) { + carry += 58 * out[k]; + out[k] = carry & 0xff; + carry >>>= 8; + } + length = j; + } + let it = out.length - length; + while (it < out.length && out[it] === 0) it++; + const total = zeros + (out.length - it); + const decoded = new Uint8Array(total); + for (let i = 0; i < zeros; i++) decoded[i] = 0; + let p = zeros; + while (it < out.length) decoded[p++] = out[it++]; + return decoded; + } + + function encodeCheck(payload) { + const bytes = payload instanceof Uint8Array ? payload : Uint8Array.from(payload); + const check = sha256(sha256(bytes)).slice(0, 4); + const full = new Uint8Array(bytes.length + 4); + full.set(bytes, 0); + full.set(check, bytes.length); + return encodeBase58(full); + } + + function decodeCheck(str) { + const full = decodeBase58(str); + if (full.length < 5) throw new Error("base58check: too short"); + const payload = full.slice(0, full.length - 4); + const check = full.slice(full.length - 4); + const want = sha256(sha256(payload)).slice(0, 4); + for (let i = 0; i < 4; i++) if (check[i] !== want[i]) throw new Error("base58check: bad checksum"); + return payload; + } + + return { encodeBase58, decodeBase58, encodeCheck, decodeCheck }; +}; diff --git a/bundled-addons/aegis/lib/cashaddr.js b/bundled-addons/aegis/lib/cashaddr.js new file mode 100644 index 00000000..67390e18 --- /dev/null +++ b/bundled-addons/aegis/lib/cashaddr.js @@ -0,0 +1,102 @@ +// CashAddr (BCH address format) encode/decode + legacy Base58Check decode, so +// the send form accepts whatever the user pastes but the UI only ever shows +// cashaddr. Pure JS, no deps — the polymod is tiny. +const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const CHARSET_REV = Object.fromEntries([...CHARSET].map((c, i) => [c, i])); + +function polymod(values) { + let c = 1n; + for (const d of values) { + const c0 = c >> 35n; + c = ((c & 0x07ffffffffn) << 5n) ^ BigInt(d); + if (c0 & 0x01n) c ^= 0x98f2bc8e61n; + if (c0 & 0x02n) c ^= 0x79b76d99e2n; + if (c0 & 0x04n) c ^= 0xf33e5fb3c4n; + if (c0 & 0x08n) c ^= 0xae2eabe2a8n; + if (c0 & 0x10n) c ^= 0x1e4f43e470n; + } + return c ^ 1n; +} +const prefixExpand = (prefix) => [...prefix].map((c) => c.charCodeAt(0) & 0x1f).concat([0]); + +function convertBits(data, from, to, pad) { + let acc = 0, bits = 0; const out = []; + const maxv = (1 << to) - 1; + for (const v of data) { + acc = (acc << from) | v; bits += from; + while (bits >= to) { bits -= to; out.push((acc >> bits) & maxv); } + } + if (pad) { if (bits > 0) out.push((acc << (to - bits)) & maxv); } + else if (bits >= from || ((acc << (to - bits)) & maxv)) throw new Error("cashaddr: bad padding"); + return out; +} + +// type: 0 = P2PKH, 1 = P2SH. hash: 20 bytes (the only size we emit). +function encode(prefix, type, hash) { + if (hash.length !== 20) throw new Error("cashaddr: only 160-bit hashes supported"); + const versionByte = (type << 3) | 0; // size bits 000 = 160 + const payload = convertBits([versionByte, ...hash], 8, 5, true); + const mod = polymod([...prefixExpand(prefix), ...payload, 0, 0, 0, 0, 0, 0, 0, 0]); + const checksum = []; + for (let i = 0; i < 8; i++) checksum.push(Number((mod >> BigInt(5 * (7 - i))) & 0x1fn)); + return prefix + ":" + [...payload, ...checksum].map((v) => CHARSET[v]).join(""); +} + +// Accepts "prefix:payload" or a bare payload (assumes defaultPrefix). +function decode(address, defaultPrefix = "bitcoincash") { + const raw = String(address).trim(); + if (raw !== raw.toLowerCase() && raw !== raw.toUpperCase()) throw new Error("cashaddr: mixed case"); + const s = raw.toLowerCase(); + const i = s.lastIndexOf(":"); + const prefix = i >= 0 ? s.slice(0, i) : defaultPrefix; + const payloadStr = i >= 0 ? s.slice(i + 1) : s; + if (!/^[a-z0-9]+$/.test(prefix) || payloadStr.length < 8) throw new Error("cashaddr: malformed"); + const values = [...payloadStr].map((c) => { + if (!(c in CHARSET_REV)) throw new Error("cashaddr: bad character"); + return CHARSET_REV[c]; + }); + if (polymod([...prefixExpand(prefix), ...values]) !== 0n) throw new Error("cashaddr: bad checksum"); + const data = convertBits(values.slice(0, -8), 5, 8, false); + const versionByte = data[0]; + const type = (versionByte >> 3) & 0x0f; + const size = [20, 24, 28, 32, 40, 48, 56, 64][versionByte & 0x07]; + const hash = Uint8Array.from(data.slice(1)); + if (hash.length !== size) throw new Error("cashaddr: hash size mismatch"); + return { prefix, type, hash }; +} + +// Legacy Base58Check (1... / 3... on mainnet). sha256 is injected so this +// file stays dependency-free. +const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +function decodeLegacy(address, sha256) { + const s = String(address).trim(); + let n = 0n; + for (const c of s) { + const v = B58.indexOf(c); + if (v < 0) throw new Error("base58: bad character"); + n = n * 58n + BigInt(v); + } + const bytes = []; + while (n > 0n) { bytes.unshift(Number(n & 0xffn)); n >>= 8n; } + for (const c of s) { if (c !== "1") break; bytes.unshift(0); } + if (bytes.length !== 25) throw new Error("base58: wrong length"); + const body = Uint8Array.from(bytes.slice(0, 21)); + const check = sha256(sha256(body)).slice(0, 4); + for (let k = 0; k < 4; k++) if (check[k] !== bytes[21 + k]) throw new Error("base58: bad checksum"); + const type = body[0] === 0x00 ? 0 : body[0] === 0x05 ? 1 : null; + if (type == null) throw new Error("base58: not a mainnet address"); + return { prefix: "bitcoincash", type, hash: body.slice(1) }; +} + +// Anything a user might paste -> { type, hash, cashaddr }. Rejects other +// prefixes so a chipnet address can never be paid on mainnet by accident. +function parseAny(input, sha256, prefix = "bitcoincash") { + const s = String(input || "").trim().replace(/^bitcoincash:\/\//i, "bitcoincash:"); + if (!s) throw new Error("empty address"); + const r = /^[13][1-9A-HJ-NP-Za-km-z]{25,34}$/.test(s) ? decodeLegacy(s, sha256) : decode(s, prefix); + if (r.prefix !== prefix) throw new Error(`address is for "${r.prefix}", expected "${prefix}"`); + if (r.type !== 0 && r.type !== 1) throw new Error("unsupported address type"); + return { type: r.type, hash: r.hash, cashaddr: encode(prefix, r.type, r.hash) }; +} + +module.exports = { encode, decode, decodeLegacy, parseAny }; diff --git a/bundled-addons/aegis/lib/chain-bch-imported.js b/bundled-addons/aegis/lib/chain-bch-imported.js new file mode 100644 index 00000000..13dac819 --- /dev/null +++ b/bundled-addons/aegis/lib/chain-bch-imported.js @@ -0,0 +1,146 @@ +// Imported BCH wallet — single-address, key material lives in Theseus's +// wallet-imports.enc (design §3.2). This adapter mirrors chain-bch.js's +// public shape (snapshot, refresh, plan, signAndBroadcast, dispose) but +// does NOT go through vault.derive + HKDF: derivation is direct from the +// seed+path or WIF that the user imported. +// +// M.1a scope: read-only (balance + history over Electrum). planSend/send +// throw with a clear message until M.1b lands the sign path. + +module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, electrum, WebSocket, tx }) { + + // Same electrum scripthash convention chain-bch uses: sha256(script), byte- + // reversed, hex. P2PKH-only for imports today — that's what every entry in + // Deviant's keystore is. + const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + const p2pkhScript = (h160) => Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]); + const scripthashOf = (script) => toHex(sha256(script).slice().reverse()); + const hash160 = (b) => ripemd160(sha256(b)); + + const IMPORTED_BCH_NETWORKS = { + mainnet: { + id: "mainnet", label: "Mainnet", prefix: "bitcoincash", + explorerTx: "https://blockchair.com/bitcoin-cash/transaction/", + explorerAddr: "https://blockchair.com/bitcoin-cash/address/", + defaultServers: [ + "wss://bch.imaginary.cash:50004", + "wss://cashnode.bch.ninja:50004", + "wss://electroncash.dk:50004", + "wss://fulcrum.jettscythe.xyz:50004", + ], + }, + chipnet: { + id: "chipnet", label: "Chipnet testnet", prefix: "bchtest", + explorerTx: "https://chipnet.imaginary.cash/tx/", + explorerAddr: "https://chipnet.imaginary.cash/address/", + defaultServers: [ + "wss://chipnet.imaginary.cash:50004", + "wss://chipnet.bch.ninja:50004", + ], + faucet: "https://tbch.googol.cash/", + }, + }; + + // Decode a cashaddr → 20-byte hash160 payload. We stored cashaddr at import + // time and use it here to compute the scripthash for Electrum without ever + // asking main for the signer material — that only happens at sign time. + function h160OfCashaddr(addr) { + const clean = String(addr || "").replace(/^bitcoincash:|^bchtest:/, ""); + const { type, hash } = cashaddr.decode(addr.includes(":") ? addr : "bitcoincash:" + clean); + if (type !== 0) throw new Error(`imported wallet must be P2PKH (got type ${type})`); + return hash; + } + + class ImportedBchWallet { + constructor({ walletId, storage, log = () => {}, onChange = () => {}, network = "mainnet", cashaddr: address, servers } = {}) { + const net = IMPORTED_BCH_NETWORKS[network]; + if (!net) throw new Error(`chain-bch-imported: unknown network ${network}`); + if (!address) throw new Error("chain-bch-imported: cashaddr required"); + this.walletId = walletId; + this.chain = "bch"; + this.network = net.id; + this._net = net; + this.log = log; + this.onChange = onChange; + this._address = address; + this._h160 = h160OfCashaddr(address); + this._script = p2pkhScript(this._h160); + this._scripthash = scripthashOf(this._script); + this._scriptHex = toHex(this._script); + this._servers = Array.isArray(servers) && servers.length ? servers : net.defaultServers.slice(); + this._client = new electrum.Client(this._servers); + this._client.onServer = () => this._emit(); + this._state = { + balance: { confirmed: 0, unconfirmed: 0 }, + history: [], + height: 0, + scanning: false, + error: null, + }; + } + + setServers(list) { + this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice(); + this._client.setServers(this._servers); + } + + _emit() { try { this.onChange(); } catch {} } + + snapshot() { + return { + chain: "bch", + network: this._net.id, + ticker: "BCH", + decimals: 8, + address: this._address, + addressIndex: 0, + addressPath: null, + balance: this._state.balance, + height: this._state.height, + history: this._state.history, + scanning: this._state.scanning, + error: this._state.error, + server: this._client.url || null, + servers: this._servers, + imported: true, + explorerTx: this._net.explorerTx, + explorerAddr: this._net.explorerAddr, + faucet: this._net.faucet, + }; + } + + async refresh(full) { + this._state.scanning = true; this._emit(); + try { + // Balance for this single scripthash. + const bal = await this._client.request("blockchain.scripthash.get_balance", [this._scripthash]); + this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) }; + if (full) { + const hist = await this._client.request("blockchain.scripthash.get_history", [this._scripthash]); + this._state.history = (hist || []).slice(-50).map((h) => ({ + txid: h.tx_hash, time: 0, delta: 0, confirmations: h.height > 0 ? 1 : 0, + })); + } + this._state.error = null; + } catch (e) { + this._state.error = e?.message || String(e); + } finally { + this._state.scanning = false; + this._emit(); + } + } + + nextAddress() { return { address: this._address, index: 0 }; } + current() { return { address: this._address, index: 0, branch: 0, path: null, h160: this._h160, script: this._script, scripthash: this._scripthash, scriptHex: this._scriptHex }; } + + plan() { throw new Error("Imported wallets are read-only in this build. Spending support ships in the next Aegis update."); } + signAndBroadcast() { throw new Error("Imported wallets are read-only in this build."); } + signMessage() { throw new Error("Imported wallets are read-only in this build."); } + + recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import (Deviant keystore or wherever you got the seed/WIF from)." }; } + + dispose() { try { this._client.disconnect(); } catch {} } + } + + return { ImportedBchWallet, IMPORTED_BCH_NETWORKS }; +}; diff --git a/bundled-addons/aegis/lib/chain-bch.js b/bundled-addons/aegis/lib/chain-bch.js new file mode 100644 index 00000000..cdddf399 --- /dev/null +++ b/bundled-addons/aegis/lib/chain-bch.js @@ -0,0 +1,156 @@ +// BCH chain adapter — wraps the existing keys.js / wallet.js / tx.js / +// electrum.js / cashaddr.js code with the common adapter shape that the +// multi-wallet manager talks to. Every BCH wallet is one BIP32 account +// derived from its own 32-byte root (from api.vault.derive). +// +// Deps are handed in so index.js loads @noble/* once and shares them across +// every wallet, rather than each adapter dynamic-importing on its own. + +const BCH_NETWORKS = { + mainnet: { + id: "mainnet", + label: "Mainnet", + prefix: "bitcoincash", + defaultAccountPath: "m/44'/145'/0'", + explorerTx: "https://blockchair.com/bitcoin-cash/transaction/", + explorerAddr: "https://blockchair.com/bitcoin-cash/address/", + defaultServers: [ + "wss://bch.imaginary.cash:50004", + "wss://cashnode.bch.ninja:50004", + "wss://electroncash.dk:50004", + "wss://fulcrum.jettscythe.xyz:50004", + ], + faucet: null, + }, + chipnet: { + id: "chipnet", + label: "Chipnet testnet", + prefix: "bchtest", + // BIP44 testnet coin type is 1 across every chain; the account is still 0. + defaultAccountPath: "m/44'/1'/0'", + explorerTx: "https://chipnet.imaginary.cash/tx/", + explorerAddr: "https://chipnet.imaginary.cash/address/", + defaultServers: [ + "wss://chipnet.imaginary.cash:50004", + "wss://chipnet.bch.ninja:50004", + ], + faucet: "https://tbch.googol.cash/", + }, +}; + +module.exports = function makeBchAdapter({ + HDKey, secp256k1, sha256, ripemd160, cashaddr, + keysLib, tx, electrum, WebSocket, +}) { + + // storage is the FULL api.storage. keyPrefix scopes every read/write under + // "wallets/<walletId>/…" so multiple BCH wallets don't stomp each other. + function scopedStorage(storage, keyPrefix) { + const k = (key) => keyPrefix + key; + return { + get: (key, fallback = null) => storage.get(k(key), fallback), + set: (key, value) => storage.set(k(key), value), + }; + } + + class BchWallet { + constructor(root32, { + walletId, storage, log = () => {}, onChange = () => {}, servers, + network = "mainnet", accountPath, + } = {}) { + if (!walletId) throw new Error("chain-bch: walletId required"); + const net = BCH_NETWORKS[network]; + if (!net) throw new Error(`chain-bch: unknown network ${network}`); + this.walletId = walletId; + this.chain = "bch"; + this.network = net.id; + this._net = net; + this.log = log; + this.onChange = onChange; + this.storage = scopedStorage(storage, `wallets/${walletId}/`); + // Servers: caller-provided override → user-set custom list (handled by + // index.js already, this is a fallback path) → the network's built-in + // defaults so a wallet always has somewhere to connect. + this._servers = Array.isArray(servers) && servers.length ? servers : net.defaultServers.slice(); + const wantPath = accountPath || net.defaultAccountPath; + this._accountPath = /^m(\/\d+'?)+$/.test(wantPath) ? wantPath : net.defaultAccountPath; + this._client = new electrum.Client(this._servers); + this._client.onServer = () => this._emit(); + this._keys = new keysLib.WalletKeys(root32, this._accountPath, net.prefix); + this._root = new Uint8Array(root32); + const walletFactory = require("./wallet.js"); + this._wallet = walletFactory({ + client: this._client, keys: this._keys, tx, cashaddr, sha256, + storage: this.storage, + log: (...a) => this.log(...a), + onChange: () => this._emit(), + }); + } + + setServers(list) { + this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice(); + this._client.setServers(this._servers); + } + + _emit() { try { this.onChange(); } catch {} } + + snapshot() { + const w = this._wallet.snapshot(); + return { + chain: "bch", + network: this._net.id, + ticker: "BCH", + decimals: 8, + address: w.address, + addressIndex: w.addressIndex, + addressPath: w.addressPath, + balance: w.balance, + height: w.height, + history: w.history, + scanning: w.scanning, + error: w.error, + server: this._client.url || null, + servers: this._servers, + accountPath: this._accountPath, + xpub: this._keys.xpub, + explorerTx: this._net.explorerTx, + explorerAddr: this._net.explorerAddr, + faucet: this._net.faucet, + }; + } + + async refresh(full) { return this._wallet.refresh(!!full); } + nextAddress() { return this._wallet.nextUnusedAddress(); } + current() { return this._wallet.current(); } + plan(spec) { + const targets = Array.isArray(spec.outputs) && spec.outputs.length + ? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value })) + : [{ to: spec.to, value: spec.amount ?? spec.value }]; + return this._wallet.plan({ targets, feeRate: spec.feeRate, sendMax: !!spec.sendMax }); + } + async signAndBroadcast(plan) { return this._wallet.signAndBroadcast(plan); } + // 65-byte BIP-137 recoverable signature — the format Electron Cash and + // most BCH tooling verify against. + signMessage(message) { + const enc = new TextEncoder(); + const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("too long"); return Uint8Array.from([b.length, ...b]); }; + const MAGIC = "Bitcoin Signed Message:\n"; + const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]); + const digest = sha256(sha256(payload)); + const entry = this.current(); + const sig = this._keys.signRecoverable(entry, digest); + return { address: entry.address, signature: Buffer.from(sig).toString("base64") }; + } + recovery() { + return { accountPath: this._accountPath, xpub: this._keys.xpub, xprv: this._keys.xprv }; + } + dispose() { + try { this._wallet.dispose(); } catch {} + try { this._keys.wipe(); } catch {} + try { this._client.disconnect(); } catch {} + if (this._root) this._root.fill(0); + } + } + + return { BchWallet, BCH_NETWORKS }; +}; diff --git a/bundled-addons/aegis/lib/chain-btc.js b/bundled-addons/aegis/lib/chain-btc.js new file mode 100644 index 00000000..a21525bf --- /dev/null +++ b/bundled-addons/aegis/lib/chain-btc.js @@ -0,0 +1,531 @@ +// Bitcoin (BTC) chain adapter — mainnet + testnet3. BIP84 native SegWit, +// bitcoinjs-lib for the tx/PSBT primitives, Aegis's own ElectrumX transport +// for the network side. Very close in shape to chain-dgb.js; the two could +// share a "bip84-electrum" helper later, but for now a distinct file keeps +// the chain-specific tuning (electrum pool, network object) visible. +// +// Address family: BIP84 only in this rev — bc1q… (mainnet) / tb1q… (testnet). +// BIP44 (1…) and BIP49 (3…) are trivially reachable by editing accountPath +// to m/44'/0'/0' or m/49'/0'/0' respectively; the PSBT layer already +// supports the resulting scripts because bitcoinjs-lib does. An explicit +// address-family picker like DGB's is a follow-up. + +const NETWORKS = { + mainnet: { + id: "mainnet", label: "Mainnet", + hrp: "bc", coinType: 0, + defaultAccountPath: "m/84'/0'/0'", + explorerTx: "https://mempool.space/tx/", + explorerAddr: "https://mempool.space/address/", + defaultServers: [ + "wss://electrum.blockstream.info:50004", + "wss://bitcoin.lu.ke:50004", + "wss://fulcrum.grey.pw:50004", + ], + faucet: null, + }, + testnet: { + id: "testnet", label: "Testnet3", + hrp: "tb", coinType: 1, + defaultAccountPath: "m/84'/1'/0'", + explorerTx: "https://mempool.space/testnet/tx/", + explorerAddr: "https://mempool.space/testnet/address/", + defaultServers: [ + "wss://testnet.aranguren.org:51004", + "wss://blockstream.info:993", + ], + faucet: "https://coinfaucet.eu/en/btc-testnet/", + }, + signet: { + // Signet (BIP-325) shares testnet's address format and SLIP-44 coin + // type (1), so bitcoinjs-lib's `networks.testnet` handles address + // derivation unchanged. The chain itself is a separate, permissioned + // testnet with its own genesis + signer-signed blocks; from a wallet's + // point of view, the only differences are the electrum pool serving + // it and the explorer URL for tx lookups. + id: "signet", label: "Signet", + hrp: "tb", coinType: 1, + defaultAccountPath: "m/84'/1'/0'", + explorerTx: "https://mempool.space/signet/tx/", + explorerAddr: "https://mempool.space/signet/address/", + defaultServers: [ + "wss://signet.aranguren.org:51102", + "wss://signet-electrumx.wakiyamap.dev:50003", + ], + faucet: "https://signetfaucet.com/", + }, +}; + +module.exports = function makeBtcAdapter({ + bitcoinjs, bip32Factory, ecpairFactory, ecc, sha256, electrum, +}) { + if (!bitcoinjs || !bip32Factory || !ecpairFactory || !ecc || !electrum) { + throw new Error("chain-btc: missing dep"); + } + const { payments, Psbt, networks: bjsNetworks } = bitcoinjs; + const bip32 = bip32Factory(ecc); + const ECPair = ecpairFactory(ecc); + // Taproot (p2tr) address derivation needs bitcoinjs-lib's schnorr backend + // wired to a curve implementation — @bitcoinerlab/secp256k1 provides both + // ECDSA and schnorr, so initEccLib once at load makes p2tr resolve. + try { bitcoinjs.initEccLib && bitcoinjs.initEccLib(ecc); } catch {} + + // Map our network id → bitcoinjs-lib Network object. Signet shares + // testnet's address prefixes + magic (BIP-325 defines only new consensus + // rules; the p2p / address layer stays testnet-compatible). + function bjsNetworkFor(id) { + if (id === "mainnet") return bjsNetworks.bitcoin; + if (id === "testnet" || id === "signet") return bjsNetworks.testnet; + throw new Error("chain-btc: unknown network " + id); + } + + const toHex = (b) => Buffer.from(b).toString("hex"); + const scripthashOf = (scriptBuf) => Buffer.from(sha256(scriptBuf)).reverse().toString("hex"); + + function scopedStorage(storage, keyPrefix) { + const k = (key) => keyPrefix + key; + return { + get: (key, fallback = null) => storage.get(k(key), fallback), + set: (key, value) => storage.set(k(key), value), + }; + } + + // Address-family shape from the derivation-path purpose. Every field the + // PSBT layer might need for signing an input funded by this family is + // captured here so signAndBroadcast has one code path per family. + function paymentFor(purpose, node, network) { + const pubkey = Buffer.from(node.publicKey); + if (purpose === 44) { + // Legacy P2PKH. Signing needs the full previous transaction + // (nonWitnessUtxo) — one extra electrum call per input at send time. + const p = payments.p2pkh({ pubkey, network }); + return { family: "bip44", address: p.address, output: Buffer.from(p.output), send: "p2pkh", needsPrevTx: true }; + } + if (purpose === 49) { + // P2SH-wrapped SegWit. PSBT needs the redeem script (the inner p2wpkh + // output) alongside the witnessUtxo. + const redeem = payments.p2wpkh({ pubkey, network }); + const p = payments.p2sh({ redeem, network }); + return { family: "bip49", address: p.address, output: Buffer.from(p.output), redeem: Buffer.from(redeem.output), send: "p2sh-p2wpkh" }; + } + if (purpose === 86) { + // BIP86 Taproot key-path. Signing goes through a tap-tweaked ECPair + // (see signerFor + signAndBroadcast); the internal 32-byte x-only + // pubkey is captured here so the PSBT input can carry it. + const internalPubkey = Buffer.from(pubkey.subarray(1, 33)); + const p = payments.p2tr({ internalPubkey, network }); + return { family: "bip86", address: p.address, output: Buffer.from(p.output), internalPubkey, send: "p2tr" }; + } + // Default: BIP84 native SegWit. + const p = payments.p2wpkh({ pubkey, network }); + return { family: "bip84", address: p.address, output: Buffer.from(p.output), send: "p2wpkh" }; + } + function purposeOfPath(accountPath) { + const m = /^m\/(\d+)'\//.exec(String(accountPath || "")); + return m ? Number(m[1]) : 84; + } + + class WalletKeys { + constructor(root32, accountPath, bjsNetwork) { + this._accountPath = /^m(\/\d+'?)+$/.test(accountPath) ? accountPath : "m/84'/0'/0'"; + this._purpose = purposeOfPath(this._accountPath); + this._network = bjsNetwork; + this._root = bip32.fromSeed(Buffer.from(root32), bjsNetwork); + this._account = this._root.derivePath(this._accountPath); + this._branch = [this._account.derive(0), this._account.derive(1)]; + this._cache = new Map(); + } + get xpub() { return this._account.neutered().toBase58(); } + get xprv() { return this._account.toBase58(); } + get accountPath() { return this._accountPath; } + get purpose() { return this._purpose; } + entry(branch, index) { + const k = branch + "/" + index; + let e = this._cache.get(k); + if (!e) { + const node = this._branch[branch].derive(index); + const pay = paymentFor(this._purpose, node, this._network); + e = { + branch, index, path: this._accountPath + "/" + branch + "/" + index, + publicKey: Buffer.from(node.publicKey), + family: pay.family, sendKind: pay.send, + script: pay.output, scriptHex: pay.output.toString("hex"), + scripthash: scripthashOf(pay.output), + address: pay.address, + redeemScript: pay.redeem || null, + tapInternalKey: pay.internalPubkey || null, + _node: node, + }; + this._cache.set(k, e); + } + return e; + } + signerFor(entry) { + return ECPair.fromPrivateKey(Buffer.from(entry._node.privateKey), { network: this._network }); + } + wipe() { + for (const e of this._cache.values()) e._node = null; + this._cache.clear(); + this._branch = null; + this._account = null; + this._root = null; + } + } + + // Fee vsize model per family. Values are rounded vsize contributions from + // standard tx-size tables; the estimator is pessimistic enough to cover a + // real broadcast without underpaying. + const OVERHEAD_VB = 10.5; + const OUTPUT_VB = 31; // P2WPKH / P2SH / P2PKH outputs are all ~31 vB give or take + const INPUT_VB = { + p2pkh: 148, // (32+4)+1+107+4 legacy input + "p2sh-p2wpkh": 91, // 40 base + ~205/4 witness + p2wpkh: 68, // 41 base + 108/4 witness + p2tr: 58, // 41 base + 66/4 witness (key-path) + }; + const feeVb = (kind, nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + nOut * OUTPUT_VB) * feePerVb); + + class BtcWallet { + constructor(root32, networkId, { + walletId, storage, log = () => {}, onChange = () => {}, servers, + accountPath, + } = {}) { + if (!walletId) throw new Error("chain-btc: walletId required"); + const net = NETWORKS[networkId]; + if (!net) throw new Error("chain-btc: unknown network " + networkId); + this.walletId = walletId; + this.chain = "btc"; + this.network = net.id; + this._net = net; + this._bjsNet = bjsNetworkFor(net.id); + this.log = log; + this.onChange = onChange; + this.storage = scopedStorage(storage, `wallets/${walletId}/`); + this._servers = Array.isArray(servers) && servers.length ? servers : net.defaultServers.slice(); + const wantPath = accountPath || net.defaultAccountPath; + this._keys = new WalletKeys(root32, wantPath, this._bjsNet); + this._root = new Uint8Array(root32); + this._client = new electrum.Client(this._servers); + this._client.onServer = () => this._emit(); + this._state = { + used: new Set(), + watched: new Map(), + height: 0, + balance: { confirmed: 0, unconfirmed: 0 }, + utxos: [], + history: [], + receiveIndex: 0, + scanning: false, + error: null, + }; + this._refreshTimer = null; + this._subscribedHeaders = false; + this._client.onNotify = (method, params) => { + if (method === "blockchain.headers.subscribe") { + const h = params && params[0] && params[0].height; + if (h) { this._state.height = h; this._scheduleRefresh(1500); } + } else if (method === "blockchain.scripthash.subscribe") { + this._scheduleRefresh(800); + } + }; + } + + _emit() { try { this.onChange(); } catch {} } + + async _historyOf(entry) { + const h = await this._client.call("blockchain.scripthash.get_history", [entry.scripthash]); + return Array.isArray(h) ? h : []; + } + async _scan() { + const cursor = Number(this.storage.get("receiveCursor", 0)) || 0; + const GAP = 20; + for (const branch of [0, 1]) { + let gap = 0, i = 0; + const minIndex = branch === 0 ? cursor + 1 : 0; + while (gap < GAP || i < minIndex + GAP) { + const batch = []; + for (let k = 0; k < 10; k++) batch.push(this._keys.entry(branch, i + k)); + const results = await Promise.all(batch.map((e) => this._historyOf(e))); + for (let k = 0; k < batch.length; k++) { + const e = batch[k]; + this._state.watched.set(e.scripthash, e); + if (results[k].length) { this._state.used.add(branch + "/" + e.index); gap = 0; } else gap++; + i++; + if (gap >= GAP && i >= minIndex + GAP) break; + } + } + } + let r = cursor; + while (this._state.used.has("0/" + r)) r++; + this._state.receiveIndex = r; + this._state.watched.set(this._keys.entry(0, r).scripthash, this._keys.entry(0, r)); + } + async _subscribeAll() { + if (!this._subscribedHeaders) { + this._subscribedHeaders = true; + const tip = await this._client.subscribe("blockchain.headers.subscribe", []); + if (tip && tip.height) this._state.height = tip.height; + } + await Promise.all([...this._state.watched.values()].map((e) => + this._client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {}))); + } + async _loadUtxos() { + const lists = await Promise.all([...this._state.watched.values()].map(async (e) => { + const u = await this._client.call("blockchain.scripthash.listunspent", [e.scripthash]); + return (Array.isArray(u) ? u : []).map((x) => ({ txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e })); + })); + this._state.utxos = lists.flat(); + let confirmed = 0, unconfirmed = 0; + for (const u of this._state.utxos) { if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; } + this._state.balance = { confirmed, unconfirmed }; + } + async _loadHistory() { + const entries = [...this._state.watched.values()].filter((e) => this._state.used.has(e.branch + "/" + e.index)); + const merged = new Map(); + const lists = await Promise.all(entries.map((e) => this._historyOf(e))); + for (const list of lists) for (const h of list) { + const prev = merged.get(h.tx_hash); + if (!prev || (h.height > 0 && prev.height <= 0)) merged.set(h.tx_hash, { txid: h.tx_hash, height: h.height }); + } + const ordered = [...merged.values()].sort((a, b) => { + const ha = a.height > 0 ? a.height : Infinity, hb = b.height > 0 ? b.height : Infinity; + return hb - ha; + }).slice(0, 25); + const ours = new Set([...this._state.watched.values()].map((e) => e.scriptHex)); + const out = []; + for (const h of ordered) { + let received = 0, spent = 0; + try { + const t = await this._client.call("blockchain.transaction.get", [h.txid, true]); + for (const o of t.vout || []) { + const hex = o.scriptPubKey && o.scriptPubKey.hex; + if (hex && ours.has(hex)) received += Math.round(Number(o.value || 0) * 1e8); + } + for (const i of t.vin || []) { + if (!i.txid) continue; + try { + const p = await this._client.call("blockchain.transaction.get", [i.txid, true]); + const po = p.vout && p.vout[i.vout]; + const hex = po && po.scriptPubKey && po.scriptPubKey.hex; + if (hex && ours.has(hex)) spent += Math.round(Number(po.value || 0) * 1e8); + } catch {} + } + out.push({ + txid: h.txid, height: h.height, confirmations: t.confirmations || 0, + time: t.blocktime || t.time || 0, + delta: received - spent, fee: null, to: null, + status: (t.confirmations || 0) > 0 ? "confirmed" : "pending", + kind: "transfer", + }); + } catch { + out.push({ txid: h.txid, height: h.height, confirmations: 0, time: 0, delta: 0, fee: null, to: null, status: "pending", kind: "transfer" }); + } + } + this._state.history = out; + } + async refresh(full = false) { + if (this._state.scanning) return; + this._state.scanning = true; this._state.error = null; this._emit(); + try { + if (full || !this._state.watched.size) await this._scan(); + else { + let r = Number(this.storage.get("receiveCursor", 0)) || 0; + while (this._state.used.has("0/" + r)) r++; + this._state.receiveIndex = r; + this._state.watched.set(this._keys.entry(0, r).scripthash, this._keys.entry(0, r)); + } + await this._loadUtxos(); + await this._loadHistory(); + await this._subscribeAll(); + for (const u of this._state.utxos) this._state.used.add(u.entry.branch + "/" + u.entry.index); + let r = Number(this.storage.get("receiveCursor", 0)) || 0; + while (this._state.used.has("0/" + r)) r++; + this._state.receiveIndex = r; + } catch (e) { + this._state.error = e?.message || String(e); + this.log("refresh failed:", this._state.error); + } finally { + this._state.scanning = false; + this._emit(); + } + } + _scheduleRefresh(ms = 800) { + clearTimeout(this._refreshTimer); + this._refreshTimer = setTimeout(() => this.refresh(false), ms); + } + + current() { return this._keys.entry(0, this._state.receiveIndex); } + nextAddress() { + let r = this._state.receiveIndex + 1; + while (this._state.used.has("0/" + r)) r++; + this.storage.set("receiveCursor", r); + this._state.receiveIndex = r; + const e = this._keys.entry(0, r); + this._state.watched.set(e.scripthash, e); + this._client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {}); + this._emit(); + return this.current(); + } + _changeEntry() { + let i = 0; + while (this._state.used.has("1/" + i)) i++; + return this._keys.entry(1, i); + } + + setServers(list) { + this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice(); + this._client.setServers(this._servers); + } + + plan({ to, amount, feeRate = 5, sendMax = false }) { + const rate = Math.min(500, Math.max(1, Number(feeRate) || 5)); + const dest = String(to || ""); + try { bitcoinjs.address.toOutputScript(dest, this._bjsNet); } + catch (e) { throw new Error(`bad Bitcoin address: ${e?.message || dest}`); } + const cur = this.current(); + if (!cur.sendKind) throw new Error(`no sender for ${cur.family} — registry bug`); + const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0)); + const change = this._changeEntry(); + const kind = cur.sendKind; + if (sendMax) { + const chosen = spendable; + const sum = chosen.reduce((a, u) => a + u.value, 0); + const fee = feeVb(kind, chosen.length, 1, rate); + if (sum <= fee) throw new Error("balance does not cover the fee"); + return { + _chosen: chosen, _rate: rate, _to: dest, _sendMax: true, _change: change, _kind: kind, + recipients: [{ to: dest, value: sum - fee }], + fee, feeRate: rate, change: 0, + total: sum, + }; + } + const value = Math.round(Number(amount) || 0); + if (!(value > 0)) throw new Error("amount must be > 0"); + let sum = 0; const chosen = []; + for (const u of spendable) { + chosen.push(u); sum += u.value; + const withChange = feeVb(kind, chosen.length, 2, rate); + if (sum >= value + withChange) { + const changeVal = sum - value - withChange; + const fee = changeVal > 546 ? withChange : sum - value; + return { + _chosen: chosen, _rate: rate, _to: dest, _value: value, + _change: change, _changeVal: changeVal > 546 ? changeVal : 0, _kind: kind, + recipients: [{ to: dest, value }], + fee, feeRate: rate, + change: changeVal > 546 ? changeVal : 0, + total: value + fee, + }; + } + } + throw new Error("insufficient funds"); + } + + async signAndBroadcast(plan) { + // BIP44 inputs need the whole previous transaction (nonWitnessUtxo) + // so PSBT can compute a legacy sighash; fetch each one in parallel + // before assembling the PSBT. + const needsPrev = plan._chosen.filter((u) => u.entry.family === "bip44"); + const prevHex = new Map(); + if (needsPrev.length) { + const results = await Promise.all(needsPrev.map((u) => + this._client.call("blockchain.transaction.get", [u.txid, false]) + )); + needsPrev.forEach((u, i) => prevHex.set(u.txid, String(results[i]))); + } + const psbt = new Psbt({ network: this._bjsNet }); + for (const u of plan._chosen) { + const inp = { hash: u.txid, index: u.vout }; + const fam = u.entry.family; + if (fam === "bip44") { + inp.nonWitnessUtxo = Buffer.from(prevHex.get(u.txid), "hex"); + } else { + inp.witnessUtxo = { script: u.entry.script, value: u.value }; + if (fam === "bip49" && u.entry.redeemScript) inp.redeemScript = u.entry.redeemScript; + if (fam === "bip86" && u.entry.tapInternalKey) inp.tapInternalKey = u.entry.tapInternalKey; + } + psbt.addInput(inp); + } + const outputs = [{ address: plan._to, value: plan._sendMax ? plan.recipients[0].value : plan._value }]; + if (!plan._sendMax && plan._changeVal > 0) { + outputs.push({ address: plan._change.address, value: plan._changeVal }); + } + for (const o of outputs) psbt.addOutput(o); + for (let i = 0; i < plan._chosen.length; i++) { + const entry = plan._chosen[i].entry; + // Taproot key-path: bitcoinjs-lib matches the signer's publicKey + // against the tweaked output key, so the signer has to be the + // internal ECPair tweaked with sha256("TapTweak" || internalPubkey). + // ECPair.tweak() from the ecpair package does exactly that (its + // internal state becomes the tap-tweaked keypair) and its + // signSchnorr is what PSBT calls for a key-path spend. + if (entry.family === "bip86") { + const raw = ECPair.fromPrivateKey(Buffer.from(entry._node.privateKey), { network: this._bjsNet }); + const tweak = bitcoinjs.crypto.taggedHash("TapTweak", entry.tapInternalKey); + const tweaked = raw.tweak(tweak); + psbt.signInput(i, tweaked); + } else { + psbt.signInput(i, this._keys.signerFor(entry)); + } + } + psbt.finalizeAllInputs(); + const tx = psbt.extractTransaction(); + const hex = tx.toHex(); + const txid = await this._client.call("blockchain.transaction.broadcast", [hex]); + if (typeof txid !== "string" || txid.length !== 64) throw new Error("broadcast rejected: " + JSON.stringify(txid)); + this.log("broadcast", txid); + this._scheduleRefresh(1200); + return { txid, hex, fee: plan.fee }; + } + + // BIP-137 recoverable over sha256d("Bitcoin Signed Message:\n" || msg). + signMessage(message) { + const enc = new TextEncoder(); + const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("too long"); return Uint8Array.from([b.length, ...b]); }; + const MAGIC = "Bitcoin Signed Message:\n"; + const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]); + const digest = sha256(sha256(payload)); + const entry = this.current(); + const signer = this._keys.signerFor(entry); + const sig = ecc.signRecoverable(Buffer.from(digest), signer.privateKey); + const out = Buffer.alloc(65); + out[0] = 27 + sig.recoveryId + 4; // +4 = compressed + Buffer.from(sig.signature).copy(out, 1); + return { address: entry.address, signature: out.toString("base64") }; + } + + recovery() { + return { accountPath: this._keys.accountPath, xpub: this._keys.xpub, xprv: this._keys.xprv }; + } + + snapshot() { + const cur = this.current(); + return { + chain: "btc", network: this._net.id, ticker: "BTC", decimals: 8, + address: cur.address, addressIndex: this._state.receiveIndex, + addressPath: cur.path, + balance: this._state.balance, + height: this._state.height, + history: this._state.history, + scanning: this._state.scanning, + error: this._state.error, + server: this._client.url || null, + servers: this._servers, + accountPath: this._keys.accountPath, + xpub: this._keys.xpub, + explorerTx: this._net.explorerTx, + explorerAddr: this._net.explorerAddr, + faucet: this._net.faucet, + }; + } + + dispose() { + clearTimeout(this._refreshTimer); + try { this._keys.wipe(); } catch {} + try { this._client.disconnect(); } catch {} + if (this._root) this._root.fill(0); + } + } + + return { BtcWallet, NETWORKS }; +}; diff --git a/bundled-addons/aegis/lib/chain-dgb.js b/bundled-addons/aegis/lib/chain-dgb.js new file mode 100644 index 00000000..85d32f4d --- /dev/null +++ b/bundled-addons/aegis/lib/chain-dgb.js @@ -0,0 +1,451 @@ +// DigiByte (DGB) chain adapter — thin bridge over the @dgb-wallet/* packages +// vendored under lib/dgb/ from D:\Dev\SilentCode\Digibyte\packages\{core,psbt}. +// Address derivation, network params and PSBT construction come from the +// upstream design; Aegis provides the runtime shell (ElectrumX transport, +// gap-limit scanning, wallet-manager plumbing). +// +// Backend: DGB ElectrumX pool via Theseus's existing lib/electrum.js — same +// TCP-over-wss stack the BCH wallet uses, no separate protocol adapter. +// Optional Blockbook mode is on the roadmap; ElectrumX is the default +// because it matches Aegis's transport shape and needs no per-server keys. +// +// Only BIP84 (m/84'/20'/0'/0/x → dgb1q…) is exposed in this rev; the +// vendored core also supports BIP44 (D…) and BIP49 (S…) — plumb them by +// switching the purpose passed to accountNode(). Address recovery from any +// BIP39 tool at coin type 20 is guaranteed by bitcoinjs-lib's Network +// object, so a seed exported here can be restored on iancoleman.io/bip39 +// or the SilentCode Digibyte web-wallet with matching addresses. + +const NETWORK = "mainnet"; +const DEFAULT_PURPOSE = 84; +const EXPLORER_TX = "https://digiexplorer.info/tx/"; +const EXPLORER_ADDR = "https://digiexplorer.info/address/"; +const DEFAULT_SERVERS = [ + "wss://electrum1.cyberbits.eu:50022", + "wss://electrum3.cyberbits.eu:50022", + "wss://electrum1.digibyteblockexplorer.com:50022", +]; + +module.exports = function makeDgbAdapter({ + dgbCore, // ESM namespace of @dgb-wallet/core (vendored) + dgbPsbt, // ESM namespace of @dgb-wallet/psbt (vendored) + bitcoinjs, // require("bitcoinjs-lib") + bip32Factory, // require("bip32").BIP32Factory + ecpairFactory, // require("ecpair").ECPairFactory + ecc, // require("@bitcoinerlab/secp256k1") + sha256, // @noble/hashes/sha2 (only used for scripthash reversal) + electrum, +}) { + if (!dgbCore || !dgbPsbt || !bitcoinjs || !bip32Factory || !ecpairFactory || !ecc || !electrum) { + throw new Error("chain-dgb: missing dep"); + } + const { rootFromSeed, accountNode, addressNode, p2wpkhAddress, digibyte, DGB_COIN_TYPE } = dgbCore; + const { buildPsbt, signAllInputs, finalizeAndExtract, feeSats } = dgbPsbt; + const { payments, Psbt } = bitcoinjs; + const bip32 = bip32Factory(ecc); + const ECPair = ecpairFactory(ecc); + + const toHex = (b) => Buffer.from(b).toString("hex"); + // Electrum scripthash: sha256(scriptPubKey), byte-reversed, hex. + function scripthashOf(scriptBuf) { + const h = sha256(scriptBuf); + const rev = Buffer.from(h).reverse(); + return rev.toString("hex"); + } + function scriptPubKeyBuf(pubkeyBuf) { + return payments.p2wpkh({ pubkey: pubkeyBuf, network: digibyte }).output; + } + + // ---- keys -------------------------------------------------------------- + // The HD node is the sole owner of the private key material; every derived + // entry keeps a reference so PSBT.signInput(index, node) can sign each + // input under its own key. + class WalletKeys { + constructor(root32, accountPath) { + const purpose = parsePurposeFromPath(accountPath) || DEFAULT_PURPOSE; + this._purpose = purpose; + // bip32.fromSeed uses bitcoinjs-lib's Network object — pass DGB's so + // extended keys serialize with the right BIP32 magic (0x0488B21E). + this._root = bip32.fromSeed(Buffer.from(root32), digibyte); + this._account = this._root.derivePath(`m/${purpose}'/${DGB_COIN_TYPE}'/0'`); + this._accountPath = `m/${purpose}'/${DGB_COIN_TYPE}'/0'`; + this._branch = [this._account.derive(0), this._account.derive(1)]; + this._cache = new Map(); + } + get xpub() { return this._account.neutered().toBase58(); } + get xprv() { return this._account.toBase58(); } + get accountPath() { return this._accountPath; } + entry(branch, index) { + const k = branch + "/" + index; + let e = this._cache.get(k); + if (!e) { + const node = this._branch[branch].derive(index); + const pubkey = Buffer.from(node.publicKey); + const address = p2wpkhAddress(node, digibyte); + const script = Buffer.from(scriptPubKeyBuf(pubkey)); + e = { + branch, index, path: this._accountPath + "/" + branch + "/" + index, + publicKey: pubkey, script, scriptHex: script.toString("hex"), + scripthash: scripthashOf(script), + address, + _node: node, + }; + this._cache.set(k, e); + } + return e; + } + signerFor(entry) { + // bitcoinjs-lib's PSBT accepts anything with .publicKey + .sign(hash). + // BIP32Interface fits, but ECPair.fromPrivateKey gives a plain signer + // that matches what the DGB web-wallet uses — pick that for parity. + return ECPair.fromPrivateKey(Buffer.from(entry._node.privateKey), { network: digibyte }); + } + wipe() { + // BIP32Interface holds Buffers; drop references so GC picks them up. + for (const e of this._cache.values()) e._node = null; + this._cache.clear(); + this._branch = null; + this._account = null; + this._root = null; + } + } + function parsePurposeFromPath(p) { + const m = /^m\/(\d+)'\/\d+'\/\d+'$/.exec(String(p || "")); + return m ? Number(m[1]) : null; + } + + // ---- vsize model (fee estimation ahead of PSBT.getFee) ----------------- + const OVERHEAD_VB = 10.5; + const P2WPKH_INPUT_VB = 68; + const P2WPKH_OUTPUT_VB = 31; + const feeVb = (nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * P2WPKH_INPUT_VB + nOut * P2WPKH_OUTPUT_VB) * feePerVb); + + function scopedStorage(storage, keyPrefix) { + const k = (key) => keyPrefix + key; + return { + get: (key, fallback = null) => storage.get(k(key), fallback), + set: (key, value) => storage.set(k(key), value), + }; + } + + // ---- wallet ------------------------------------------------------------ + class DgbWallet { + constructor(root32, { + walletId, storage, log = () => {}, onChange = () => {}, servers, + accountPath, + } = {}) { + if (!walletId) throw new Error("chain-dgb: walletId required"); + this.walletId = walletId; + this.chain = "dgb"; + this.network = NETWORK; + this.log = log; + this.onChange = onChange; + this.storage = scopedStorage(storage, `wallets/${walletId}/`); + this._servers = Array.isArray(servers) && servers.length ? servers : DEFAULT_SERVERS.slice(); + this._keys = new WalletKeys(root32, accountPath); + this._root = new Uint8Array(root32); + this._client = new electrum.Client(this._servers); + this._client.onServer = () => this._emit(); + this._state = { + used: new Set(), + watched: new Map(), + height: 0, + balance: { confirmed: 0, unconfirmed: 0 }, + utxos: [], + history: [], + receiveIndex: 0, + scanning: false, + error: null, + }; + this._refreshTimer = null; + this._subscribedHeaders = false; + this._client.onNotify = (method, params) => { + if (method === "blockchain.headers.subscribe") { + const h = params && params[0] && params[0].height; + if (h) { this._state.height = h; this._scheduleRefresh(1500); } + } else if (method === "blockchain.scripthash.subscribe") { + this._scheduleRefresh(800); + } + }; + } + + _emit() { try { this.onChange(); } catch {} } + + // ---- discovery (gap-limit) ----------------------------------------- + async _historyOf(entry) { + const h = await this._client.call("blockchain.scripthash.get_history", [entry.scripthash]); + return Array.isArray(h) ? h : []; + } + async _scan() { + const cursor = Number(this.storage.get("receiveCursor", 0)) || 0; + const GAP = 20; + for (const branch of [0, 1]) { + let gap = 0, i = 0; + const minIndex = branch === 0 ? cursor + 1 : 0; + while (gap < GAP || i < minIndex + GAP) { + const batch = []; + for (let k = 0; k < 10; k++) batch.push(this._keys.entry(branch, i + k)); + const results = await Promise.all(batch.map((e) => this._historyOf(e))); + for (let k = 0; k < batch.length; k++) { + const e = batch[k]; + this._state.watched.set(e.scripthash, e); + if (results[k].length) { this._state.used.add(branch + "/" + e.index); gap = 0; } else gap++; + i++; + if (gap >= GAP && i >= minIndex + GAP) break; + } + } + } + let r = cursor; + while (this._state.used.has("0/" + r)) r++; + this._state.receiveIndex = r; + this._state.watched.set(this._keys.entry(0, r).scripthash, this._keys.entry(0, r)); + } + async _subscribeAll() { + if (!this._subscribedHeaders) { + this._subscribedHeaders = true; + const tip = await this._client.subscribe("blockchain.headers.subscribe", []); + if (tip && tip.height) this._state.height = tip.height; + } + await Promise.all([...this._state.watched.values()].map((e) => + this._client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {}))); + } + async _loadUtxos() { + const lists = await Promise.all([...this._state.watched.values()].map(async (e) => { + const u = await this._client.call("blockchain.scripthash.listunspent", [e.scripthash]); + return (Array.isArray(u) ? u : []).map((x) => ({ txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e })); + })); + this._state.utxos = lists.flat(); + let confirmed = 0, unconfirmed = 0; + for (const u of this._state.utxos) { if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; } + this._state.balance = { confirmed, unconfirmed }; + } + async _loadHistory() { + // Best-effort tx-history summary: pull the transactions listed against + // any used scripthash, sum ours-vs-not to get a delta per tx. Fine on + // the DGB electrum pool (get_transaction verbose is supported). + const entries = [...this._state.watched.values()].filter((e) => this._state.used.has(e.branch + "/" + e.index)); + const merged = new Map(); + const lists = await Promise.all(entries.map((e) => this._historyOf(e))); + for (const list of lists) for (const h of list) { + const prev = merged.get(h.tx_hash); + if (!prev || (h.height > 0 && prev.height <= 0)) merged.set(h.tx_hash, { txid: h.tx_hash, height: h.height }); + } + const ordered = [...merged.values()].sort((a, b) => { + const ha = a.height > 0 ? a.height : Infinity, hb = b.height > 0 ? b.height : Infinity; + return hb - ha; + }).slice(0, 25); + const ours = new Set([...this._state.watched.values()].map((e) => e.scriptHex)); + const out = []; + for (const h of ordered) { + let received = 0, spent = 0; + try { + const t = await this._client.call("blockchain.transaction.get", [h.txid, true]); + for (const o of t.vout || []) { + const hex = o.scriptPubKey && o.scriptPubKey.hex; + if (hex && ours.has(hex)) received += Math.round(Number(o.value || 0) * 1e8); + } + for (const i of t.vin || []) { + if (!i.txid) continue; + try { + const p = await this._client.call("blockchain.transaction.get", [i.txid, true]); + const po = p.vout && p.vout[i.vout]; + const hex = po && po.scriptPubKey && po.scriptPubKey.hex; + if (hex && ours.has(hex)) spent += Math.round(Number(po.value || 0) * 1e8); + } catch {} + } + out.push({ + txid: h.txid, height: h.height, confirmations: t.confirmations || 0, + time: t.blocktime || t.time || 0, + delta: received - spent, fee: null, to: null, + status: (t.confirmations || 0) > 0 ? "confirmed" : "pending", + kind: "transfer", + }); + } catch { + out.push({ txid: h.txid, height: h.height, confirmations: 0, time: 0, delta: 0, fee: null, to: null, status: "pending", kind: "transfer" }); + } + } + this._state.history = out; + } + async refresh(full = false) { + if (this._state.scanning) return; + this._state.scanning = true; this._state.error = null; this._emit(); + try { + if (full || !this._state.watched.size) await this._scan(); + else { + let r = Number(this.storage.get("receiveCursor", 0)) || 0; + while (this._state.used.has("0/" + r)) r++; + this._state.receiveIndex = r; + this._state.watched.set(this._keys.entry(0, r).scripthash, this._keys.entry(0, r)); + } + await this._loadUtxos(); + await this._loadHistory(); + await this._subscribeAll(); + for (const u of this._state.utxos) this._state.used.add(u.entry.branch + "/" + u.entry.index); + let r = Number(this.storage.get("receiveCursor", 0)) || 0; + while (this._state.used.has("0/" + r)) r++; + this._state.receiveIndex = r; + } catch (e) { + this._state.error = e?.message || String(e); + this.log("refresh failed:", this._state.error); + } finally { + this._state.scanning = false; + this._emit(); + } + } + _scheduleRefresh(ms = 800) { + clearTimeout(this._refreshTimer); + this._refreshTimer = setTimeout(() => this.refresh(false), ms); + } + + current() { return this._keys.entry(0, this._state.receiveIndex); } + nextAddress() { + let r = this._state.receiveIndex + 1; + while (this._state.used.has("0/" + r)) r++; + this.storage.set("receiveCursor", r); + this._state.receiveIndex = r; + const e = this._keys.entry(0, r); + this._state.watched.set(e.scripthash, e); + this._client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {}); + this._emit(); + return this.current(); + } + _changeEntry() { + let i = 0; + while (this._state.used.has("1/" + i)) i++; + return this._keys.entry(1, i); + } + + setServers(list) { + this._servers = Array.isArray(list) && list.length ? list : DEFAULT_SERVERS.slice(); + this._client.setServers(this._servers); + } + + // ---- plan + sign (via @dgb-wallet/psbt) ----------------------------- + plan({ to, amount, feeRate = 20, sendMax = false }) { + const rate = Math.min(500, Math.max(1, Number(feeRate) || 20)); + const dest = String(to || ""); + // The `payments` decoder will reject anything that isn't a valid + // DGB address; catch and re-raise as a sane error. + try { payments.address({ address: dest, network: digibyte }); } + catch { + // bitcoinjs-lib's address decoder is `address.toOutputScript`, not + // payments.address; use it for validation. + try { bitcoinjs.address.toOutputScript(dest, digibyte); } + catch (e) { throw new Error(`bad DGB address: ${e?.message || dest}`); } + } + const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0)); + const change = this._changeEntry(); + if (sendMax) { + const chosen = spendable; + const sum = chosen.reduce((a, u) => a + u.value, 0); + const fee = feeVb(chosen.length, 1, rate); + if (sum <= fee) throw new Error("balance does not cover the fee"); + return { + _chosen: chosen, _rate: rate, _to: dest, _sendMax: true, _change: change, + recipients: [{ to: dest, value: sum - fee }], + fee, feeRate: rate, change: 0, + total: sum, + }; + } + const value = Math.round(Number(amount) || 0); + if (!(value > 0)) throw new Error("amount must be > 0"); + let sum = 0; const chosen = []; + for (const u of spendable) { + chosen.push(u); sum += u.value; + const withChange = feeVb(chosen.length, 2, rate); + if (sum >= value + withChange) { + const changeVal = sum - value - withChange; + const fee = changeVal > 546 ? withChange : sum - value; + return { + _chosen: chosen, _rate: rate, _to: dest, _value: value, + _change: change, _changeVal: changeVal > 546 ? changeVal : 0, + recipients: [{ to: dest, value }], + fee, feeRate: rate, + change: changeVal > 546 ? changeVal : 0, + total: value + fee, + }; + } + } + throw new Error("insufficient funds"); + } + + async signAndBroadcast(plan) { + const psbtInputs = plan._chosen.map((u) => ({ + txid: u.txid, + vout: u.vout, + witness: { scriptHex: u.entry.scriptHex, value: u.value }, + })); + const psbtOutputs = [{ address: plan._to, value: plan._sendMax ? plan.recipients[0].value : plan._value }]; + if (!plan._sendMax && plan._changeVal > 0) { + psbtOutputs.push({ address: plan._change.address, value: plan._changeVal }); + } + const psbt = buildPsbt({ inputs: psbtInputs, outputs: psbtOutputs }, digibyte); + // Sign per-input with the exact key that funded that UTXO. signAllInputs + // would work when all inputs share a key, but each derived address + // has its own key, so we go per-input. + for (let i = 0; i < plan._chosen.length; i++) { + const signer = this._keys.signerFor(plan._chosen[i].entry); + psbt.signInput(i, signer); + } + const { hex, txid } = finalizeAndExtract(psbt); + const broadcast = await this._client.call("blockchain.transaction.broadcast", [hex]); + if (typeof broadcast !== "string" || broadcast.length !== 64) { + throw new Error("broadcast rejected: " + JSON.stringify(broadcast)); + } + this.log("broadcast", txid); + this._scheduleRefresh(1200); + return { txid, hex, fee: plan.fee }; + } + + // BIP-137-style: 65-byte recoverable signature over sha256d(magic || msg). + signMessage(message) { + const enc = new TextEncoder(); + const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("too long"); return Uint8Array.from([b.length, ...b]); }; + const MAGIC = "DigiByte Signed Message:\n"; + const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]); + const digest = sha256(sha256(payload)); + const entry = this.current(); + const signer = this._keys.signerFor(entry); + // ECPair's `signSchnorr` and `sign` don't emit recoverable sigs; fall + // back to node's ecc.signRecoverable through @bitcoinerlab/secp256k1 + // (which the vendored @dgb-wallet/core already loaded). + const sig = ecc.signRecoverable(Buffer.from(digest), signer.privateKey); + const out = Buffer.alloc(65); + out[0] = 27 + sig.recoveryId + 4; // +4 = compressed pubkey + Buffer.from(sig.signature).copy(out, 1); + return { address: entry.address, signature: out.toString("base64") }; + } + + recovery() { + return { accountPath: this._keys.accountPath, xpub: this._keys.xpub, xprv: this._keys.xprv }; + } + + snapshot() { + const cur = this.current(); + return { + chain: "dgb", network: NETWORK, ticker: "DGB", decimals: 8, + address: cur.address, addressIndex: this._state.receiveIndex, + addressPath: cur.path, + balance: this._state.balance, + height: this._state.height, + history: this._state.history, + scanning: this._state.scanning, + error: this._state.error, + server: this._client.url || null, + servers: this._servers, + accountPath: this._keys.accountPath, + xpub: this._keys.xpub, + explorerTx: EXPLORER_TX, explorerAddr: EXPLORER_ADDR, faucet: null, + }; + } + + dispose() { + clearTimeout(this._refreshTimer); + try { this._keys.wipe(); } catch {} + try { this._client.disconnect(); } catch {} + if (this._root) this._root.fill(0); + } + } + + return { DgbWallet, EXPLORER_TX, EXPLORER_ADDR }; +}; diff --git a/bundled-addons/aegis/lib/chain-eth.js b/bundled-addons/aegis/lib/chain-eth.js new file mode 100644 index 00000000..a5c9bfd0 --- /dev/null +++ b/bundled-addons/aegis/lib/chain-eth.js @@ -0,0 +1,361 @@ +// Ethereum (EVM) chain adapter — mainnet + Sepolia testnet. One address per +// wallet, the same "TronLink shape" as chain-tron.js: BIP44 derivation, +// secp256k1 → keccak256 address, JSON-RPC backend, EIP-1559 send. +// +// Kept intentionally minimal: +// - Native ETH only. ERC-20 token support is a follow-up: it needs a token +// registry + eth_call for `balanceOf(address)` per token + a dedicated +// Send flow that builds an ERC-20 `transfer(to, value)` calldata. +// - No transaction history: without an indexer (Etherscan V2 / Alchemy) +// the JSON-RPC alone can't answer "which txs touched this address". +// The panel shows an empty history with a link to Etherscan. +// - EIP-1559 only (type 0x02). Legacy type 0x00 works too but isn't +// needed for mainnet or Sepolia in 2026. + +const NETWORKS = { + mainnet: { + id: "mainnet", label: "Mainnet", chainId: 1, + // Cloudflare's public Ethereum gateway — no key required, rate-limited + // but adequate for a per-user wallet. User can override in settings. + defaultRpc: "https://cloudflare-eth.com", + explorerTx: "https://etherscan.io/tx/", + explorerAddr: "https://etherscan.io/address/", + faucet: null, + }, + sepolia: { + id: "sepolia", label: "Sepolia testnet", chainId: 11155111, + defaultRpc: "https://ethereum-sepolia-rpc.publicnode.com", + explorerTx: "https://sepolia.etherscan.io/tx/", + explorerAddr: "https://sepolia.etherscan.io/address/", + faucet: "https://sepoliafaucet.com/", + }, +}; + +module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { + if (!HDKey || !secp256k1 || !keccak_256) throw new Error("chain-eth: missing dep"); + + const toHex = (b) => Buffer.from(b).toString("hex"); + const fromHex = (h) => Uint8Array.from(Buffer.from(String(h).replace(/^0x/i, ""), "hex")); + const stripHex = (h) => String(h).replace(/^0x/i, ""); + const hexToBig = (h) => BigInt("0x" + (stripHex(h) || "0")); + const bigToHex = (n) => "0x" + BigInt(n).toString(16); + const zeroBig = 0n; + + // ---- addresses ------------------------------------------------------- + // EIP-55 mixed-case checksum: lowercase hex, then flip case per keccak256 + // of the lowercase hex string (a-f digits get uppercased where the keccak + // nibble is >= 8). Never needed for wire format (RPCs accept lowercase), + // but it's what wallets show, so we return it that way. + function eip55(addressLowerHex) { + const lower = stripHex(addressLowerHex).toLowerCase(); + const hash = toHex(keccak_256(Buffer.from(lower, "utf8"))); + let out = "0x"; + for (let i = 0; i < lower.length; i++) { + const c = lower[i]; + out += /[0-9]/.test(c) ? c : (parseInt(hash[i], 16) >= 8 ? c.toUpperCase() : c); + } + return out; + } + function addressFromPubkey(uncompressed) { + const inner = uncompressed.slice(1); + const h = keccak_256(inner); + const h20 = h.slice(h.length - 20); + return eip55(toHex(h20)); + } + function decodeAddress(str) { + const s = String(str || "").trim(); + const hex = stripHex(s); + if (!/^[0-9a-fA-F]{40}$/.test(hex)) throw new Error("bad Ethereum address"); + // Reject checksum mismatches on mixed-case inputs (all-lower and all-upper + // pass unconditionally — that's the EIP-55 rule). + const lower = hex.toLowerCase(), upper = hex.toUpperCase(); + if (hex !== lower && hex !== upper) { + const want = stripHex(eip55(lower)); + if (hex !== want) throw new Error("EIP-55 checksum failed"); + } + return "0x" + lower; + } + + // ---- RLP encode ------------------------------------------------------ + // Minimal encoder — enough for EIP-1559 tx encoding. Follows the RLP spec + // (single byte < 0x80 → self; short string ≤ 55 → 0x80 + len + bytes; + // long string → 0x80 + 55 + lenOfLen + lenBytes + bytes; lists similarly + // with 0xc0/0xf7). + function rlpEncodeBytes(bytes) { + const b = Uint8Array.from(bytes); + if (b.length === 1 && b[0] < 0x80) return b; + if (b.length <= 55) return concat(Uint8Array.from([0x80 + b.length]), b); + const lenBytes = encodeIntBE(b.length); + return concat(Uint8Array.from([0xb7 + lenBytes.length]), lenBytes, b); + } + function rlpEncodeList(items) { + const encoded = items.map(rlpEncode); + const body = concat(...encoded); + if (body.length <= 55) return concat(Uint8Array.from([0xc0 + body.length]), body); + const lenBytes = encodeIntBE(body.length); + return concat(Uint8Array.from([0xf7 + lenBytes.length]), lenBytes, body); + } + function rlpEncode(item) { + if (item instanceof Uint8Array) return rlpEncodeBytes(item); + if (Array.isArray(item)) return rlpEncodeList(item); + if (typeof item === "bigint") return rlpEncodeBytes(bigToBytes(item)); + if (typeof item === "number") return rlpEncodeBytes(bigToBytes(BigInt(item))); + if (typeof item === "string") return rlpEncodeBytes(item.startsWith("0x") ? fromHex(item) : Buffer.from(item, "utf8")); + throw new Error("rlp: unsupported item type " + typeof item); + } + function bigToBytes(v) { + if (v < 0n) throw new Error("negative bigint"); + if (v === 0n) return new Uint8Array(0); + let hex = v.toString(16); + if (hex.length % 2) hex = "0" + hex; + return fromHex(hex); + } + function encodeIntBE(n) { + let hex = n.toString(16); + if (hex.length % 2) hex = "0" + hex; + return fromHex(hex); + } + function concat(...ps) { + const n = ps.reduce((a, p) => a + p.length, 0); + const out = new Uint8Array(n); let k = 0; + for (const p of ps) { out.set(p, k); k += p.length; } + return out; + } + + // ---- signing --------------------------------------------------------- + // EIP-1559 signed tx: 0x02 || RLP([chainId, nonce, maxPriorityFeePerGas, + // maxFeePerGas, gasLimit, to, value, data, accessList, + // yParity, r, s]) + // hash-to-sign: keccak256(0x02 || RLP([...same-without-sig-fields])) + function signTxEip1559(unsignedFields, privKey) { + const unsignedRlp = rlpEncodeList(unsignedFields); + const preimage = concat(Uint8Array.from([0x02]), unsignedRlp); + const hash = keccak_256(preimage); + const sig = secp256k1.sign(hash, privKey, { prehash: false, lowS: true, format: "recovered" }); + // noble returns [recid || r(32) || s(32)]; EIP-1559 uses yParity as + // 0 or 1 (recid directly, no +27 shift). + const yParity = sig[0]; + const r = sig.subarray(1, 33); + const s = sig.subarray(33, 65); + const signedFields = [...unsignedFields, yParity, stripLeadingZeros(r), stripLeadingZeros(s)]; + const signedRlp = rlpEncodeList(signedFields); + return "0x" + toHex(concat(Uint8Array.from([0x02]), signedRlp)); + } + function stripLeadingZeros(bytes) { + let i = 0; + while (i < bytes.length - 1 && bytes[i] === 0) i++; + return bytes.subarray(i); + } + + // ---- JSON-RPC client ------------------------------------------------- + function makeClient(rpcUrl) { + let seq = 1; + async function call(method, params = []) { + const r = await fetch(rpcUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: seq++, method, params }), + }); + if (!r.ok) throw new Error(`${method}: HTTP ${r.status}`); + const j = await r.json(); + if (j.error) throw new Error(`${method}: ${j.error.message || JSON.stringify(j.error)}`); + return j.result; + } + return { url: rpcUrl, call }; + } + + function scopedStorage(storage, keyPrefix) { + const k = (key) => keyPrefix + key; + return { + get: (key, fallback = null) => storage.get(k(key), fallback), + set: (key, value) => storage.set(k(key), value), + }; + } + + // ---- wallet ---------------------------------------------------------- + class EthWallet { + constructor(root32, networkId, { + walletId, storage, log = () => {}, onChange = () => {}, rpcUrl, + customNetwork, // { id, label, chainId, defaultRpc, explorerTx, explorerAddr, ticker } for EIP-3085 chains + } = {}) { + if (!walletId) throw new Error("chain-eth: walletId required"); + const net = customNetwork || NETWORKS[networkId]; + if (!net) throw new Error(`chain-eth: unknown network ${networkId}`); + this.walletId = walletId; + this.chain = "eth"; + this.network = net.id; + this._net = net; + this._ticker = net.ticker || "ETH"; + this.log = log; + this.onChange = onChange; + this.storage = scopedStorage(storage, `wallets/${walletId}/`); + const master = HDKey.fromMasterSeed(root32); + // BIP44 for Ethereum: m/44'/60'/0'/0/0 is the canonical first address. + const node = master.derive("m/44'/60'/0'/0/0"); + this._priv = node.privateKey; + this._pubUncompressed = secp256k1.getPublicKey(this._priv, false); + this.address = addressFromPubkey(this._pubUncompressed); + this._root = new Uint8Array(root32); + this._client = makeClient(String(rpcUrl || "").trim() || net.defaultRpc); + this._state = { + balance: { confirmed: "0", unconfirmed: "0" }, + history: [], + height: 0, + scanning: false, + error: null, + }; + this._pollTimer = null; + } + + setRpcUrl(url) { + const v = String(url || "").trim() || this._net.defaultRpc; + this._client = makeClient(v); + this._emit(); + } + _emit() { try { this.onChange(); } catch {} } + + // Wei is 10^18 native units; the panel formats via decimals=18. The + // ticker follows the chain's nativeCurrency (ETH on mainnet/Sepolia, + // MATIC on Polygon, etc.) so the send-approval overlay reads correctly. + snapshot() { + return { + chain: "eth", network: this._net.id, ticker: this._ticker, decimals: 18, + address: this.address, addressIndex: 0, + addressPath: "m/44'/60'/0'/0/0", + balance: this._state.balance, + height: this._state.height, + history: this._state.history, + scanning: this._state.scanning, + error: this._state.error, + server: this._client.url, + rpcUrl: this._client.url, + explorerTx: this._net.explorerTx, + explorerAddr: this._net.explorerAddr, + faucet: this._net.faucet, + chainId: this._net.chainId, + }; + } + + async refresh() { + if (this._state.scanning) return; + this._state.scanning = true; this._state.error = null; this._emit(); + try { + const [bal, block] = await Promise.all([ + this._client.call("eth_getBalance", [this.address, "latest"]), + this._client.call("eth_blockNumber", []), + ]); + this._state.balance = { confirmed: hexToBig(bal).toString(), unconfirmed: "0" }; + this._state.height = Number(hexToBig(block)); + } catch (e) { + this._state.error = e?.message || String(e); + this.log("refresh failed:", this._state.error); + } finally { + this._state.scanning = false; + this._emit(); + } + } + schedulePoll(ms = 20_000) { + clearTimeout(this._pollTimer); + this._pollTimer = setTimeout(() => this.refresh().finally(() => this.schedulePoll(ms)), ms); + } + + async plan({ to, amount, sendMax }) { + const dest = decodeAddress(to); + const from = this.address.toLowerCase(); + const [nonceHex, priorityHex, gasPriceHex, gasLimitHex] = await Promise.all([ + this._client.call("eth_getTransactionCount", [from, "pending"]), + this._client.call("eth_maxPriorityFeePerGas", []).catch(() => "0x59682f00"), // fallback: 1.5 gwei + this._client.call("eth_gasPrice", []), + Promise.resolve("0x5208"), // 21000 for a plain ETH transfer + ]); + const nonce = Number(hexToBig(nonceHex)); + const maxPriorityFeePerGas = hexToBig(priorityHex); + // maxFeePerGas heuristic: 2 * base fee + priority tip. base fee ~= + // gasPrice - priority tip on EIP-1559 chains; we approximate with the + // reported gasPrice as an upper bound plus the priority. + const baseGuess = hexToBig(gasPriceHex); + const maxFeePerGas = baseGuess * 2n + maxPriorityFeePerGas; + const gasLimit = hexToBig(gasLimitHex); + const fee = gasLimit * maxFeePerGas; + const bal = BigInt(this._state.balance.confirmed || "0"); + let value; + if (sendMax) { + if (bal <= fee) throw new Error("balance does not cover the gas fee"); + value = bal - fee; + } else { + value = BigInt(Math.round(Number(amount) || 0)); // wei + if (value <= 0n) throw new Error("amount must be > 0 wei"); + if (value + fee > bal) throw new Error("insufficient funds"); + } + return { + _draft: { + chainId: this._net.chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, + gasLimit, to: dest, value, data: "0x", accessList: [], + }, + recipients: [{ to: dest, value: value.toString() }], + fee: fee.toString(), + feeRate: maxFeePerGas.toString(), + inputs: [], + change: "0", + total: (value + fee).toString(), + }; + } + + async signAndBroadcast(plan) { + const d = plan && plan._draft; + if (!d) throw new Error("bad plan"); + const unsignedFields = [ + d.chainId, d.nonce, d.maxPriorityFeePerGas, d.maxFeePerGas, d.gasLimit, + fromHex(d.to.slice(2)), d.value, fromHex(""), [], + ]; + const rawTxHex = signTxEip1559(unsignedFields, this._priv); + const txid = await this._client.call("eth_sendRawTransaction", [rawTxHex]); + if (typeof txid !== "string" || !/^0x[0-9a-f]{64}$/i.test(txid)) throw new Error("bad txid from RPC: " + JSON.stringify(txid)); + this.log("broadcast", txid); + setTimeout(() => this.refresh(), 3000); + return { txid }; + } + + // EIP-712: sign a pre-computed typed-data digest with r||s||v (v = 27+recid). + signTypedDataDigest(digest32) { + const sig = secp256k1.sign(digest32, this._priv, { prehash: false, lowS: true, format: "recovered" }); + const out = new Uint8Array(65); + out.set(sig.subarray(1), 0); + out[64] = sig[0] + 27; + return { address: this.address, signature: "0x" + toHex(out) }; + } + // Ethereum personal_sign: keccak256("\x19Ethereum Signed Message:\n" + len + msg). + signMessage(message) { + const msg = String(message); + const enc = new TextEncoder(); + const body = enc.encode(msg); + const prefix = enc.encode("\x19Ethereum Signed Message:\n" + body.length); + const buf = concat(prefix, body); + const hash = keccak_256(buf); + const sig = secp256k1.sign(hash, this._priv, { prehash: false, lowS: true, format: "recovered" }); + // personal_sign format: r || s || v where v = 27 + recid. + const out = new Uint8Array(65); + out.set(sig.subarray(1), 0); + out[64] = sig[0] + 27; + return { address: this.address, signature: "0x" + toHex(out) }; + } + + recovery() { + // Ethereum wallets typically expose the raw private key hex; we do too, + // but only when the caller re-confirms in the approval overlay upstream. + return { + accountPath: "m/44'/60'/0'/0/0", + xpub: "0x" + toHex(this._pubUncompressed), + xprv: "0x" + toHex(this._priv), + }; + } + + dispose() { + clearTimeout(this._pollTimer); + try { this._priv && this._priv.fill(0); } catch {} + try { this._root && this._root.fill(0); } catch {} + } + } + + return { EthWallet, NETWORKS, addressFromPubkey, decodeAddress, eip55 }; +}; diff --git a/bundled-addons/aegis/lib/chain-sia.js b/bundled-addons/aegis/lib/chain-sia.js new file mode 100644 index 00000000..dac88eae --- /dev/null +++ b/bundled-addons/aegis/lib/chain-sia.js @@ -0,0 +1,182 @@ +// Sia (SC) chain adapter — v2 walletd-backed. Constructs a wallet from a +// 32-byte root and a user-supplied walletd URL. Amounts are hastings +// (1 SC = 10^24 hastings) and cross the panel boundary as decimal strings +// so the panel never has to touch BigInt precision. +// +// Deps: sia.js / keys.js / wallet.js / walletd.js — copied from the +// standalone siawallet addon on 2026-09-07 when Aegis absorbed it, so the +// key derivation + tx layout are byte-identical to the older addon. +// Existing on-chain funds carry over via the vault-derive absorb (aegis's +// manifest lists "siawallet" under absorbs, so the legacy purpose +// "siawallet/mainnet/0" resolves to the same seed inside Aegis). + +const SC = 10n ** 24n; +const EXPLORER_TX = "https://siascan.com/tx/"; +const EXPLORER_ADDR = "https://siascan.com/address/"; + +module.exports = function makeSiaAdapter({ ed25519, blake2b }) { + if (!ed25519 || !blake2b) throw new Error("chain-sia: missing dep"); + const sia = require("./sia/sia.js")({ ed25519, blake2b }); + const keysLib = require("./sia/keys.js")({ sia }); + const walletd = require("./sia/walletd.js")({ log: () => {} }); + const walletFactory = require("./sia/wallet.js"); + + function scopedStorage(storage, keyPrefix) { + const k = (key) => keyPrefix + key; + return { + get: (key, fallback = null) => storage.get(k(key), fallback), + set: (key, value) => storage.set(k(key), value), + }; + } + + class SiaWallet { + constructor(root32, { + walletId, storage, log = () => {}, onChange = () => {}, + walletdUrl = "", + } = {}) { + if (!walletId) throw new Error("chain-sia: walletId required"); + this.walletId = walletId; + this.chain = "sc"; + this.network = "mainnet"; + this.log = log; + this.onChange = onChange; + this.storage = scopedStorage(storage, `wallets/${walletId}/`); + this._root = new Uint8Array(root32); + this._keys = new keysLib.WalletKeys(root32); + this._walletdUrl = String(walletdUrl || "").trim(); + this._client = null; + this._wallet = null; + if (this._walletdUrl) this._build(); + } + + _build() { + if (this._wallet) { try { this._wallet.dispose(); } catch {} this._wallet = null; } + if (this._client) this._client.setBase(this._walletdUrl); + else this._client = new walletd.Client(this._walletdUrl); + this._wallet = walletFactory({ + client: this._client, keys: this._keys, sia, + storage: this.storage, + log: (...a) => this.log(...a), + onChange: () => { try { this.onChange(); } catch {} }, + }); + } + + setWalletdUrl(url) { + const v = String(url || "").trim(); + if (v === this._walletdUrl) return; + this._walletdUrl = v; + if (v) this._build(); else { try { this._wallet && this._wallet.dispose(); } catch {} this._wallet = null; } + } + + // The panel treats Sia amounts as decimal strings of hastings; the + // display layer picks how many SC-precision digits to show. + snapshot() { + const w = this._wallet && this._wallet.snapshot(); + const base = { + chain: "sc", network: "mainnet", ticker: "SC", decimals: 24, + address: null, addressIndex: 0, addressPath: `KeyFromSeed(seed, ${w?.addressIndex || 0})`, + balance: { confirmed: "0", unconfirmed: "0" }, + height: 0, history: [], scanning: false, error: null, + server: this._client ? this._client.displayUrl : null, + walletdUrl: this._walletdUrl, + needsWalletdUrl: !this._walletdUrl, + explorerTx: EXPLORER_TX, explorerAddr: EXPLORER_ADDR, faucet: null, + }; + if (w) { + base.address = w.address; + base.addressIndex = w.addressIndex; + // wallet.js exposes confirmed / immature; the panel's shared shape + // is confirmed / unconfirmed, so map immature → unconfirmed for + // visual parity with BCH and TRX (small semantic bend, but the + // number is the "not yet spendable" one either way). + base.balance = { confirmed: w.balance.confirmed, unconfirmed: w.balance.immature }; + base.height = w.height; + base.history = (w.history || []).map((r) => ({ + txid: r.id, delta: r.delta, to: r.to, from: null, + fee: null, time: r.time || 0, + confirmations: r.confirmations || 0, + status: r.confirmations > 0 ? "confirmed" : "pending", + kind: r.type, + })); + base.scanning = w.scanning; + base.error = w.error; + } + return base; + } + + async refresh(full) { + if (!this._wallet) return; + return this._wallet.refresh(!!full); + } + nextAddress() { + if (!this._wallet) throw new Error("no walletd URL configured"); + return this._wallet.nextUnusedAddress(); + } + current() { + if (!this._wallet) throw new Error("no walletd URL configured"); + return this._wallet.current(); + } + plan(spec) { + if (!this._wallet) throw new Error("no walletd URL configured"); + const targets = Array.isArray(spec.outputs) && spec.outputs.length + ? spec.outputs.map((o) => ({ to: o.to, value: toHastings(o.amount ?? o.value) })) + : [{ to: spec.to, value: toHastings(spec.amount ?? spec.value) }]; + const p = this._wallet.plan({ targets, feeMultiplier: spec.feeMultiplier || spec.feeRate, sendMax: !!spec.sendMax }); + // Present the plan in the common panel shape: BigInts as decimal + // strings, plus `total = sum(recipients) + fee`. + const sent = p.recipients.reduce((a, r) => a + BigInt(r.value), 0n); + return { + _sia: p, // internal handle so signAndBroadcast doesn't re-plan + recipients: p.recipients, + fee: p.fee.toString(), + feeRate: p.feePerByte.toString(), + inputs: p.tx.inputs, + change: p.change.toString(), + total: (sent + p.fee).toString(), + }; + } + async signAndBroadcast(plan) { + if (!this._wallet) throw new Error("no walletd URL configured"); + const inner = plan && plan._sia; + if (!inner) throw new Error("bad plan"); + return this._wallet.signAndBroadcast(inner); + } + // Sia signature = ed25519 over blake2b256 of the raw message. Not a + // BIP-137 style thing — dapps that want it should treat this as an + // opaque {publicKey, signature} pair verified via ed25519. + signMessage(message) { + const entry = this.current(); + const digest = sia.b256(new TextEncoder().encode(String(message))); + const sig = this._keys.sign(entry, digest); + return { + address: entry.address, + publicKey: "ed25519:" + sia.toHex(entry.pub), + signature: sia.toHex(sig), + }; + } + recovery() { + // Sia has no xpub/xprv notion here; the scheme is "seed + index". + return { + accountPath: `KeyFromSeed(seed, i)`, + xpub: sia.toHex(this._keys.entry(0).pub), // just the first pub for reference + xprv: this._keys.seedHex, + }; + } + startPolling() { if (this._wallet) this._wallet.startPolling(60_000); } + dispose() { + try { this._wallet && this._wallet.dispose(); } catch {} + try { this._keys && this._keys.wipe(); } catch {} + if (this._root) this._root.fill(0); + } + } + + // Amounts arrive as strings (hastings) or as small numbers. + function toHastings(v) { + if (typeof v === "bigint") return v; + const s = String(v ?? "0").trim(); + if (!/^\d+$/.test(s)) throw new Error("amount must be an integer number of hastings"); + return BigInt(s); + } + + return { SiaWallet, EXPLORER_TX, EXPLORER_ADDR }; +}; diff --git a/bundled-addons/aegis/lib/chain-sol.js b/bundled-addons/aegis/lib/chain-sol.js new file mode 100644 index 00000000..8d478292 --- /dev/null +++ b/bundled-addons/aegis/lib/chain-sol.js @@ -0,0 +1,473 @@ +// Solana (SOL) chain adapter — mainnet-beta + devnet. Ed25519 keypair per +// SLIP-0010 (all-hardened path), base58 address, native SOL transfers via +// the system program. Sits directly on the JSON-RPC endpoint; no @solana +// SDK dep so the addon stays lean. +// +// Derivation: m/44'/501'/0'/0' — Phantom's default path. Every segment is +// hardened per SLIP-0010 (ed25519 forbids non-hardened derivation because +// the point-add trick that BIP32 uses on secp256k1 doesn't exist for +// Curve25519). Multi-index accounts under one wallet aren't exposed here; +// each Aegis "sub-account" gets its own vault-derive purpose instead. +// +// Not implemented in this rev: +// - SPL token balances / transfers (needs Associated Token Account math +// and the SPL Token program's transfer instruction). +// - Transaction history (getSignaturesForAddress + getTransaction is +// doable but heavy for a first cut; the panel links to Solana Explorer +// for now). + +const NETWORKS = { + mainnet: { + id: "mainnet", label: "Mainnet", + defaultRpc: "https://api.mainnet-beta.solana.com", + explorerTx: "https://explorer.solana.com/tx/", + explorerAddr: "https://explorer.solana.com/address/", + explorerCluster: "", + faucet: null, + }, + devnet: { + id: "devnet", label: "Devnet", + defaultRpc: "https://api.devnet.solana.com", + explorerTx: "https://explorer.solana.com/tx/", + explorerAddr: "https://explorer.solana.com/address/", + explorerCluster: "?cluster=devnet", + faucet: "https://faucet.solana.com/", + }, +}; + +// System program's address is 32 bytes of zeros; base58 is "1111...1111". +const SYSTEM_PROGRAM = new Uint8Array(32); + +module.exports = function makeSolAdapter({ ed25519, base58, sha256 }) { + if (!ed25519 || !base58 || !sha256) throw new Error("chain-sol: missing dep"); + const spl = require("./sol-spl.js")({ ed25519, sha256, base58 }); + + // ---- HMAC-SHA512 (for SLIP-0010) ------------------------------------- + // Not in @noble/hashes/sha2 as a direct helper for SHA-512; @noble/hashes + // exports `hmac` in ./hmac. If unavailable, use Node's crypto — Electron + // main is Node, so require("crypto") always works. + const nodeCrypto = require("node:crypto"); + function hmacSha512(key, msg) { + return new Uint8Array(nodeCrypto.createHmac("sha512", Buffer.from(key)).update(Buffer.from(msg)).digest()); + } + + // ---- SLIP-0010 ed25519 derivation ------------------------------------ + const ED25519_MASTER_KEY = new TextEncoder().encode("ed25519 seed"); + function slip10Master(seed32) { + const I = hmacSha512(ED25519_MASTER_KEY, seed32); + return { key: I.slice(0, 32), chainCode: I.slice(32) }; + } + function slip10Derive(parent, indexHardened) { + // Data: 0x00 || parent.key || uint32BE(0x80000000 | index) + const idx = 0x80000000 | (indexHardened & 0x7fffffff); + const data = new Uint8Array(1 + 32 + 4); + data[0] = 0x00; + data.set(parent.key, 1); + // Write index as big-endian u32; JS bitwise is signed so |0 masks correctly. + data[33] = (idx >>> 24) & 0xff; + data[34] = (idx >>> 16) & 0xff; + data[35] = (idx >>> 8) & 0xff; + data[36] = idx & 0xff; + const I = hmacSha512(parent.chainCode, data); + return { key: I.slice(0, 32), chainCode: I.slice(32) }; + } + function derivePath(seed32, segments) { + let node = slip10Master(seed32); + for (const s of segments) node = slip10Derive(node, s); + return node; + } + // "m/44'/501'/0'/0'" → [44, 501, 0, 0]. Every SLIP-0010 ed25519 segment + // is hardened; the parser accepts either the standard "'" suffix or a + // bare integer (both mean the same for this curve). + function parseAllHardened(path) { + const parts = String(path || "").trim().split("/").filter((p) => p && p !== "m"); + return parts.map((p) => { + const m = /^(\d+)'?$/.exec(p); + if (!m) throw new Error("bad SOL derivation path: " + path); + return Number(m[1]); + }); + } + + // ---- Solana short-vec (compact-u16) ---------------------------------- + // Up to 3 bytes; 7 data bits per byte with continuation bit in position 7. + function encodeCompactU16(n) { + if (n < 0 || n > 0xffff) throw new Error("compact-u16 out of range"); + const out = []; + let rem = n; + while (true) { + let byte = rem & 0x7f; + rem >>= 7; + if (rem === 0) { out.push(byte); break; } + byte |= 0x80; + out.push(byte); + } + return Uint8Array.from(out); + } + + const concat = (...ps) => { + const n = ps.reduce((a, p) => a + p.length, 0); + const o = new Uint8Array(n); let k = 0; + for (const p of ps) { o.set(p, k); k += p.length; } + return o; + }; + const u64le = (n) => { + let v = BigInt(n); const o = new Uint8Array(8); + for (let i = 0; i < 8; i++) { o[i] = Number(v & 0xffn); v >>= 8n; } + return o; + }; + + // ---- transaction assembly -------------------------------------------- + // For a native SOL transfer between two addresses: + // accounts (writable-signed | readonly-signed | writable-unsigned | readonly-unsigned): + // [ from (WS), to (WU), systemProgram (RU) ] + // header = [1 required-sig, 0 readonly-signed, 1 readonly-unsigned] + // instructions = [{ programIdIndex: 2, accounts: [0, 1], data: u32(2) || u64(lamports) }] + function buildSolTransferMessage({ fromPub, toPub, lamports, recentBlockhash }) { + // account keys must be de-duplicated in the message; distinct here. + const keys = [fromPub, toPub, SYSTEM_PROGRAM]; + const header = Uint8Array.from([1, 0, 1]); + const keysSection = concat( + encodeCompactU16(keys.length), + ...keys.map((k) => Uint8Array.from(k)), + ); + // Instruction data: [2 (u32 LE = system Transfer discriminator), lamports (u64 LE)] + const instrData = concat(Uint8Array.from([2, 0, 0, 0]), u64le(lamports)); + const instr = concat( + Uint8Array.from([2]), // programIdIndex + encodeCompactU16(2), // account key count + Uint8Array.from([0, 1]), // account indexes (from, to) + encodeCompactU16(instrData.length), // data length + instrData, + ); + const instrSection = concat(encodeCompactU16(1), instr); + return concat(header, keysSection, recentBlockhash, instrSection); + } + + // ---- JSON-RPC -------------------------------------------------------- + function makeClient(rpcUrl) { + let seq = 1; + async function call(method, params = []) { + const r = await fetch(rpcUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: seq++, method, params }), + }); + if (!r.ok) throw new Error(`${method}: HTTP ${r.status}`); + const j = await r.json(); + if (j.error) throw new Error(`${method}: ${j.error.message || JSON.stringify(j.error)}`); + return j.result; + } + return { url: rpcUrl, call }; + } + + function scopedStorage(storage, keyPrefix) { + const k = (key) => keyPrefix + key; + return { + get: (key, fallback = null) => storage.get(k(key), fallback), + set: (key, value) => storage.set(k(key), value), + }; + } + + // ---- wallet ---------------------------------------------------------- + class SolWallet { + constructor(root32, networkId, { + walletId, storage, log = () => {}, onChange = () => {}, rpcUrl, + derivationPath = "m/44'/501'/0'/0'", + } = {}) { + if (!walletId) throw new Error("chain-sol: walletId required"); + const net = NETWORKS[networkId]; + if (!net) throw new Error(`chain-sol: unknown network ${networkId}`); + this.walletId = walletId; + this.chain = "sol"; + this.network = net.id; + this._net = net; + this.log = log; + this.onChange = onChange; + this.storage = scopedStorage(storage, `wallets/${walletId}/`); + this._path = derivationPath; + const derived = derivePath(root32, parseAllHardened(derivationPath)); + this._priv = derived.key; // 32-byte ed25519 seed + this._pub = ed25519.getPublicKey(this._priv); // 32 bytes + this.address = base58.encode(this._pub); + this._root = new Uint8Array(root32); + this._client = makeClient(String(rpcUrl || "").trim() || net.defaultRpc); + this._state = { + balance: { confirmed: 0, unconfirmed: 0 }, + history: [], + tokens: [], // [{mint, symbol, name, decimals, balance, tokenAccount, tokenProgram}] + height: 0, + scanning: false, + error: null, + }; + this._pollTimer = null; + } + + setRpcUrl(url) { + const v = String(url || "").trim() || this._net.defaultRpc; + this._client = makeClient(v); + this._emit(); + } + _emit() { try { this.onChange(); } catch {} } + + snapshot() { + return { + chain: "sol", network: this._net.id, ticker: "SOL", decimals: 9, + address: this.address, addressIndex: 0, addressPath: this._path, + balance: this._state.balance, + height: this._state.height, + history: this._state.history, + tokens: this._state.tokens, + scanning: this._state.scanning, + error: this._state.error, + server: this._client.url, + rpcUrl: this._client.url, + explorerTx: this._net.explorerTx, + explorerAddr: this._net.explorerAddr, + explorerSuffix: this._net.explorerCluster, + faucet: this._net.faucet, + }; + } + + async refresh() { + if (this._state.scanning) return; + this._state.scanning = true; this._state.error = null; this._emit(); + try { + const [balRes, slot, tokens] = await Promise.all([ + this._client.call("getBalance", [this.address]), + this._client.call("getSlot", []), + this._fetchTokens().catch((e) => { this.log("tokens fetch failed:", e?.message); return []; }), + ]); + // getBalance response: { context, value: lamports } + const lamports = balRes && typeof balRes === "object" ? Number(balRes.value || 0) : Number(balRes || 0); + this._state.balance = { confirmed: lamports, unconfirmed: 0 }; + this._state.height = Number(slot || 0); + this._state.tokens = tokens; + } catch (e) { + this._state.error = e?.message || String(e); + this.log("refresh failed:", this._state.error); + } finally { + this._state.scanning = false; + this._emit(); + } + } + // getTokenAccountsByOwner + parse. Result shape: + // {context, value: [{ pubkey, account: {data: {parsed: {info:{mint, tokenAmount:{amount,decimals,uiAmountString}}}, program, space}, executable, ...} }]} + // We ask for jsonParsed encoding so the RPC does the layout heavy-lift. + async _fetchTokens() { + const known = spl.KNOWN_TOKENS[this._net.id] || {}; + const call = (programB58) => this._client.call("getTokenAccountsByOwner", [ + this.address, + { programId: programB58 }, + { encoding: "jsonParsed", commitment: "confirmed" }, + ]); + const results = await Promise.all([ + call(spl.TOKEN_PROGRAM_ID_B58).catch(() => ({ value: [] })), + call(spl.TOKEN_2022_PROGRAM_ID_B58).catch(() => ({ value: [] })), + ]); + const out = []; + for (let p = 0; p < results.length; p++) { + const list = results[p]?.value || []; + const isTk22 = p === 1; + for (const it of list) { + const info = it?.account?.data?.parsed?.info; + if (!info) continue; + const mint = String(info.mint || ""); + const dec = Number(info.tokenAmount?.decimals || 0); + const rawAmount = String(info.tokenAmount?.amount || "0"); + const meta = known[mint]; + out.push({ + mint, tokenAccount: String(it.pubkey || ""), + tokenProgram: isTk22 ? spl.TOKEN_2022_PROGRAM_ID_B58 : spl.TOKEN_PROGRAM_ID_B58, + symbol: meta?.symbol || mint.slice(0, 6) + "…", + name: meta?.name || null, + decimals: dec, + balance: rawAmount, // string (u64) to preserve precision + isKnown: !!meta, + isToken2022: isTk22, + }); + } + } + // Sort known tokens first, then by balance desc. + out.sort((a, b) => (b.isKnown - a.isKnown) || (BigInt(b.balance) > BigInt(a.balance) ? 1 : -1)); + return out; + } + schedulePoll(ms = 20_000) { + clearTimeout(this._pollTimer); + this._pollTimer = setTimeout(() => this.refresh().finally(() => this.schedulePoll(ms)), ms); + } + + // Solana fees are (usually) 5000 lamports per signature; reserve that + // in max-mode. Real fee comes from the network on broadcast. + async plan({ to, amount, sendMax }) { + const toBytes = base58.decode(String(to || "").trim()); + if (!toBytes || toBytes.length !== 32) throw new Error("bad Solana address"); + const bal = this._state.balance.confirmed || 0; + const FEE = 5000; // lamports per signature + let lamports; + if (sendMax) { + if (bal <= FEE) throw new Error("balance does not cover the fee"); + lamports = bal - FEE; + } else { + lamports = Math.round(Number(amount) || 0); + if (!(lamports > 0)) throw new Error("amount must be > 0 lamports"); + if (lamports + FEE > bal) throw new Error("insufficient funds"); + } + // Fetch the fresh blockhash at plan time so signing can use it + // immediately — Solana blockhashes expire quickly (~150 slots ≈ 60s). + const { blockhash } = (await this._client.call("getLatestBlockhash", [])).value || {}; + if (!blockhash) throw new Error("could not fetch a recent blockhash"); + const recent = base58.decode(String(blockhash)); + return { + _draft: { toBytes, lamports, recentBlockhash: recent }, + recipients: [{ to: base58.encode(toBytes), value: lamports }], + fee: FEE, feeRate: FEE, + inputs: [], change: 0, + total: lamports + FEE, + }; + } + + async signAndBroadcast(plan) { + const d = plan && plan._draft; + if (!d) throw new Error("bad plan"); + const message = buildSolTransferMessage({ + fromPub: this._pub, toPub: d.toBytes, lamports: d.lamports, + recentBlockhash: d.recentBlockhash, + }); + const sig = ed25519.sign(message, this._priv); // 64 bytes + // Full transaction wire format: sig-count || sigs... || message + const sigCount = encodeCompactU16(1); + const wire = concat(sigCount, sig, message); + // Solana's sendTransaction accepts base58 (default) or base64 with + // {encoding:"base64"} in the second arg; we use base58 for parity + // with the rest of the ecosystem. + const wireBase58 = base58.encode(wire); + const txid = await this._client.call("sendTransaction", [wireBase58]); + if (typeof txid !== "string" || !txid.length) throw new Error("bad txid from RPC: " + JSON.stringify(txid)); + this.log("broadcast", txid); + setTimeout(() => this.refresh(), 4000); + return { txid }; + } + + // ---- SPL token send ----------------------------------------------- + // Build a TransferChecked (+ optional CreateAssociatedTokenAccountIdempotent) + // transaction moving `amount` (in raw token units) of a given mint to a + // recipient's ATA. `mintB58` is the mint address as a base58 string; + // decimals come from the sender's own token account (or the caller + // passes them explicitly if the sender's ATA is empty). + async planTokenTransfer({ mint, to, amount, decimals, tokenProgram }) { + const mintB58 = String(mint || ""); + const mintBytes = base58.decode(mintB58); + if (mintBytes.length !== 32) throw new Error("bad mint address"); + const recipientBytes = base58.decode(String(to || "").trim()); + if (recipientBytes.length !== 32) throw new Error("bad recipient address"); + // Resolve the token program for this mint from our own token list — + // Token-2022 mints need the 2022 program in the transfer instruction. + let tp = tokenProgram ? base58.decode(tokenProgram) : spl.TOKEN_PROGRAM_ID; + let dec = decimals; + const mine = (this._state.tokens || []).find((t) => t.mint === mintB58); + if (mine) { + tp = base58.decode(mine.tokenProgram); + if (dec == null) dec = mine.decimals; + } + if (dec == null) throw new Error("token decimals unknown — no ATA on this wallet for that mint"); + const amt = BigInt(amount); + if (amt <= 0n) throw new Error("amount must be > 0"); + if (mine && BigInt(mine.balance) < amt) throw new Error("insufficient token balance"); + const sourceATA = spl.associatedTokenAddress(this._pub, mintBytes, tp); + const destATA = spl.associatedTokenAddress(recipientBytes, mintBytes, tp); + // Ask the RPC whether the destination ATA already exists. If not, + // prepend a CreateIdempotent instruction so the transfer succeeds + // in one round-trip — the recipient never has to have interacted + // with this mint before. + const destATA_B58 = base58.encode(destATA); + const info = await this._client.call("getAccountInfo", [destATA_B58, { encoding: "base64" }]); + const destExists = !!(info && info.value); + const instructions = []; + if (!destExists) { + instructions.push(spl.createATAIdempotentInstruction({ + payer: this._pub, ata: destATA, owner: recipientBytes, mint: mintBytes, tokenProgram: tp, + })); + } + instructions.push(spl.transferCheckedInstruction({ + sourceATA, mint: mintBytes, destATA, owner: this._pub, + amount: amt, decimals: dec, tokenProgram: tp, + })); + const { blockhash } = (await this._client.call("getLatestBlockhash", [])).value || {}; + if (!blockhash) throw new Error("could not fetch a recent blockhash"); + const recentBlockhash = base58.decode(String(blockhash)); + return { + _spl: { instructions, recentBlockhash, destExists, sourceATA, destATA }, + recipients: [{ to: base58.encode(recipientBytes), value: amt.toString() }], + // Fee estimate: 5000 lamports per signature + ~2039280 rent-exempt + // if we're creating a new ATA. Real fee still comes from the network. + fee: destExists ? 5000 : 5000 + 2039280, + feeRate: 5000, + inputs: [], + change: 0, + total: amt.toString(), + mint: mintB58, + decimals: dec, + }; + } + async signAndBroadcastToken(plan) { + const sp = plan && plan._spl; + if (!sp) throw new Error("bad token plan"); + const message = spl.buildMessage({ + feePayer: this._pub, + instructions: sp.instructions, + recentBlockhash: sp.recentBlockhash, + }); + const sig = ed25519.sign(message, this._priv); + const encodeCompactU16 = (n) => { + const out = []; + let rem = n; + while (true) { + let byte = rem & 0x7f; rem >>= 7; + if (rem === 0) { out.push(byte); break; } + byte |= 0x80; out.push(byte); + } + return Uint8Array.from(out); + }; + const sigCount = encodeCompactU16(1); + const wire = new Uint8Array(sigCount.length + 64 + message.length); + wire.set(sigCount, 0); + wire.set(sig, sigCount.length); + wire.set(message, sigCount.length + 64); + const wireB58 = base58.encode(wire); + const txid = await this._client.call("sendTransaction", [wireB58]); + if (typeof txid !== "string" || !txid.length) throw new Error("bad txid from RPC: " + JSON.stringify(txid)); + this.log("SPL broadcast", txid); + setTimeout(() => this.refresh().catch(() => {}), 4000); + return { txid }; + } + + // Solana's convention: ed25519 signature over the raw message bytes, + // returned as {publicKey, signature} both base58. Dapps that follow + // the wallet-adapter standard verify against these. + signMessage(message) { + const bytes = new TextEncoder().encode(String(message)); + const sig = ed25519.sign(bytes, this._priv); + return { + address: this.address, + publicKey: base58.encode(this._pub), + signature: base58.encode(sig), + }; + } + + recovery() { + return { + accountPath: this._path, + xpub: base58.encode(this._pub), + xprv: Buffer.from(this._priv).toString("hex"), + }; + } + + dispose() { + clearTimeout(this._pollTimer); + try { this._priv && this._priv.fill(0); } catch {} + try { this._root && this._root.fill(0); } catch {} + } + } + + return { SolWallet, NETWORKS }; +}; diff --git a/bundled-addons/aegis/lib/chain-tron.js b/bundled-addons/aegis/lib/chain-tron.js new file mode 100644 index 00000000..eebbf7b2 --- /dev/null +++ b/bundled-addons/aegis/lib/chain-tron.js @@ -0,0 +1,353 @@ +// Tron chain adapter (mainnet + Nile testnet). One address per wallet, in the +// TronLink style: seed for this wallet → BIP32 → m/44'/195'/0'/0/0 → secp256k1 +// private key → uncompressed pubkey (drop 0x04) → keccak256 last 20 bytes → +// prepend 0x41 → base58check → "T..." address. +// +// Balance and history come from TronGrid (read; no key required). Send is +// build-locally-signed-locally-broadcast-remotely: +// POST /wallet/createtransaction {owner_address, to_address, amount, visible:true} +// → { raw_data, raw_data_hex, txID, ... } +// sig = secp256k1.sign(sha256(raw_data_hex), privateKey, {format:"recovered"}) +// → 65 bytes [r||s||recid] +// POST /wallet/broadcasttransaction {raw_data, raw_data_hex, txID, signature:[hexSig]} +// → { result: true/false, txid/message } +// +// Nile and mainnet share the address format (both prefix 0x41). The RPC host +// differs. Coin type 195 is used for both — the derivation-path *string* is +// identical; the difference between mainnet and Nile is which sub-wallet the +// user picked, which vault-derive purpose seeded it, and which RPC we hit. +// Two addresses; a mainnet wallet's key cannot accidentally sign for Nile +// (different key material entirely; the check is enforced at the seed layer). + +const NETWORKS = { + mainnet: { + id: "mainnet", + label: "Tron", + ticker: "TRX", + rpcBase: "https://api.trongrid.io", + explorerTx: "https://tronscan.org/#/transaction/", + explorerAddr: "https://tronscan.org/#/address/", + faucet: null, + }, + nile: { + id: "nile", + label: "Tron Nile testnet", + ticker: "TRX", + rpcBase: "https://nile.trongrid.io", + explorerTx: "https://nile.tronscan.org/#/transaction/", + explorerAddr: "https://nile.tronscan.org/#/address/", + faucet: "https://nileex.io/join/getJoinPage", + }, +}; + +module.exports = function makeTronAdapter({ HDKey, secp256k1, sha256, keccak_256, base58check }) { + if (!HDKey || !secp256k1 || !sha256 || !keccak_256 || !base58check) { + throw new Error("chain-tron: missing dep"); + } + const bytesToHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + const hexToBytes = (h) => { + const s = String(h).replace(/^0x/i, ""); + if (s.length % 2) throw new Error("hex: odd length"); + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); + return out; + }; + + // Derive the raw key material for a single Tron account. + // root32: 32-byte HKDF child from api.vault.derive. + // returns: { privateKey (Uint8Array 32), publicKey (Uint8Array 65 uncompressed), + // addressBytes (Uint8Array 21, prefix 0x41 || h20), address (base58) } + function deriveAccount(root32) { + const master = HDKey.fromMasterSeed(root32); + // Tron follows Ethereum-style non-hardened branch/index: m/44'/195'/0'/0/0. + const node = master.derive("m/44'/195'/0'/0/0"); + const priv = node.privateKey; + // Uncompressed pub is 65 bytes with a 0x04 prefix; drop it to feed keccak256. + const pubUncompressed = secp256k1.getPublicKey(priv, false); + const inner = pubUncompressed.slice(1); // 64 bytes + const kh = keccak_256(inner); // 32 bytes + const h20 = kh.slice(kh.length - 20); + const addrBytes = new Uint8Array(21); + addrBytes[0] = 0x41; + addrBytes.set(h20, 1); + const address = base58check.encodeCheck(addrBytes); + return { privateKey: priv, publicKey: pubUncompressed, addressBytes: addrBytes, address, node }; + } + + // "T..." → 21-byte payload (0x41 || h20). Throws on bad checksum / prefix. + function decodeAddress(str) { + const payload = base58check.decodeCheck(String(str).trim()); + if (payload.length !== 21) throw new Error("bad address length"); + if (payload[0] !== 0x41) throw new Error("bad address prefix (want 0x41 / T…)"); + return payload; + } + function hexToAddress(hex) { + const b = hexToBytes(hex); + if (b.length !== 21 || b[0] !== 0x41) throw new Error("bad address hex"); + return base58check.encodeCheck(b); + } + + function makeClient(networkId) { + const net = NETWORKS[networkId]; + if (!net) throw new Error(`unknown Tron network ${networkId}`); + async function rpc(pathPart, body) { + const url = net.rpcBase + pathPart; + let r; + try { r = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) }); } + catch (e) { throw new Error(`network: ${e?.message || e}`); } + if (!r.ok) throw new Error(`${pathPart}: HTTP ${r.status}`); + return r.json(); + } + async function get(pathPart) { + const url = net.rpcBase + pathPart; + const r = await fetch(url); + if (!r.ok) throw new Error(`${pathPart}: HTTP ${r.status}`); + return r.json(); + } + return { net, rpc, get }; + } + + class TronWallet { + constructor(root32, networkId, { storage, log = () => {}, onChange = () => {} } = {}) { + this.storage = storage; + this.log = log; + this.onChange = onChange; + this.client = makeClient(networkId); + this.net = this.client.net; + const acc = deriveAccount(root32); + // Root is stored so future revs can rotate accounts; the derived priv + // is what actually signs. Both wiped in dispose(). + this._root = new Uint8Array(root32); + this._priv = acc.privateKey; + this.publicKey = acc.publicKey; + this.addressBytes = acc.addressBytes; + this.address = acc.address; + this.state = { + balance: { confirmed: 0, unconfirmed: 0 }, + history: [], + height: 0, + scanning: false, + error: null, + server: this.net.rpcBase, + }; + this._pollTimer = null; + } + + // Public snapshot (no key material). + snapshot() { + return { + chain: "trx", + network: this.net.id, + ticker: this.net.ticker, + address: this.address, + addressIndex: 0, + addressPath: "m/44'/195'/0'/0/0", + balance: this.state.balance, + height: this.state.height, + history: this.state.history, + scanning: this.state.scanning, + error: this.state.error, + server: this.state.server, + explorerTx: this.net.explorerTx, + explorerAddr: this.net.explorerAddr, + faucet: this.net.faucet, + decimals: 6, + }; + } + + async refresh(_full = false) { + if (this.state.scanning) return; + this.state.scanning = true; this.state.error = null; this.onChange(); + try { + const [acc, txs] = await Promise.all([this._fetchAccount(), this._fetchHistory()]); + // TronGrid returns balance in "sun" (1 TRX = 1_000_000 sun). + const sun = Number(acc?.balance || 0); + this.state.balance = { confirmed: sun, unconfirmed: 0 }; + this.state.history = txs; + // Block height comes from any recent tx or a getnowblock call. + try { + const nb = await this.client.rpc("/wallet/getnowblock", {}); + this.state.height = Number(nb?.block_header?.raw_data?.number || 0); + } catch {} + } catch (e) { + this.state.error = e?.message || String(e); + this.log("refresh failed:", this.state.error); + } finally { + this.state.scanning = false; + this.onChange(); + } + } + + schedulePoll(ms = 20_000) { + clearTimeout(this._pollTimer); + this._pollTimer = setTimeout(() => this.refresh(false).finally(() => this.schedulePoll(ms)), ms); + } + + async _fetchAccount() { + const body = { address: this.address, visible: true }; + // /wallet/getaccount returns {} for never-funded addresses. + return this.client.rpc("/wallet/getaccount", body); + } + + async _fetchHistory() { + const url = `/v1/accounts/${encodeURIComponent(this.address)}/transactions?limit=25`; + let raw; + try { raw = await this.client.get(url); } catch (e) { this.log("history failed:", e?.message || e); return []; } + const list = Array.isArray(raw?.data) ? raw.data : []; + const now = Math.floor(Date.now() / 1000); + return list.map((t) => this._describeTx(t, now)).filter(Boolean); + } + + _describeTx(t, nowSec) { + const contract = t?.raw_data?.contract?.[0]; + const type = contract?.type; + const value = contract?.parameter?.value || {}; + const txID = t.txID || t.txid; + const time = Math.floor((t.block_timestamp || t.raw_data?.timestamp || 0) / 1000); + const conf = t.ret && Array.isArray(t.ret) ? (t.ret[0]?.contractRet === "SUCCESS" ? 1 : -1) : 0; + if (type === "TransferContract") { + // owner_address / to_address come as hex here (prefixed 41), regardless + // of visible:true (that flag only affects some endpoints). + let ownerB58 = "", toB58 = ""; + try { ownerB58 = value.owner_address ? hexToAddress(value.owner_address) : ""; } catch {} + try { toB58 = value.to_address ? hexToAddress(value.to_address) : ""; } catch {} + const amount = Number(value.amount || 0); + const inc = toB58 === this.address; + return { + txid: txID, + delta: inc ? amount : -amount, + to: inc ? null : toB58, + from: inc ? ownerB58 : null, + fee: t.net_fee || t.energy_fee || null, + time: time || 0, + confirmations: conf > 0 ? 1 : (conf < 0 ? 0 : 0), + status: conf > 0 ? "confirmed" : (conf < 0 ? "failed" : "pending"), + kind: "transfer", + }; + } + // Non-transfer contracts (delegation, votes, TRC20) — surface as a + // neutral entry so users see something happened without exaggerating. + return { + txid: txID, + delta: 0, + to: null, from: null, fee: null, + time: time || 0, + confirmations: conf > 0 ? 1 : 0, + status: conf > 0 ? "confirmed" : "other", + kind: type || "contract", + }; + } + + // Preview a send: no side effects, no signature. Returns the fields the + // panel needs plus the raw createtransaction result cached under _draft + // so signAndBroadcast doesn't re-fetch. + async plan({ to, amount, sendMax }) { + if (!to) throw new Error("recipient required"); + const dest = decodeAddress(to); + const balance = this.state.balance.confirmed; + // Tron doesn't have a fee-market for plain TRX transfers between accounts + // that carry enough bandwidth. For untouched addresses the network + // consumes a fixed 100_000 sun (0.1 TRX) burn from the sender if no + // free bandwidth is available. We show that as an upper-bound estimate. + const FEE_EST = 100_000; + let value; + if (sendMax) { + value = Math.max(0, balance - FEE_EST); + } else { + value = Math.round(Number(amount) || 0); + } + if (!(value > 0)) throw new Error("amount must be > 0 sun"); + if (value + FEE_EST > balance) throw new Error("insufficient funds"); + const body = { owner_address: this.address, to_address: base58check.encodeCheck(dest), amount: value, visible: true }; + const draft = await this.client.rpc("/wallet/createtransaction", body); + if (draft?.Error || !draft?.raw_data_hex) { + throw new Error("createtransaction: " + (draft?.Error || "empty response")); + } + const plan = { + recipients: [{ to: base58check.encodeCheck(dest), value }], + fee: FEE_EST, + feeRate: 1, + total: value + FEE_EST, + inputs: [], + _draft: draft, + }; + return plan; + } + + // Sign the raw_data_hex bytes with the wallet key and POST the signed tx. + async signAndBroadcast(plan) { + const draft = plan && plan._draft; + if (!draft || !draft.raw_data_hex) throw new Error("bad plan"); + const rawBytes = hexToBytes(draft.raw_data_hex); + const digest = sha256(rawBytes); + const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" }); + // Recovered format from noble is 65 bytes: [recid || r(32) || s(32)]. Tron + // wants [r(32) || s(32) || recid]. Reorder in place. + const trxSig = new Uint8Array(65); + trxSig.set(sig.subarray(1), 0); + trxSig[64] = sig[0]; + const body = { + raw_data: draft.raw_data, + raw_data_hex: draft.raw_data_hex, + txID: draft.txID, + visible: true, + signature: [bytesToHex(trxSig)], + }; + const r = await this.client.rpc("/wallet/broadcasttransaction", body); + if (r?.result !== true) { + const msg = r?.message ? Buffer.from(r.message, "hex").toString("utf8") : (r?.code || "broadcast rejected"); + throw new Error("broadcast: " + msg); + } + // Refresh soon so history/balance catch up. + setTimeout(() => this.refresh(false), 2500); + return { txid: draft.txID }; + } + + // BIP137-style signing isn't standard on Tron; dapps use signMessageV2 + // (tronWeb.trx.signMessageV2), which is a raw ECDSA over sha256 of the + // message bytes with a Tron prefix "\x19TRON Signed Message:\n32". Kept + // simple here: signMessageV2 always asks the user. + signMessageV2(message) { + const enc = new TextEncoder(); + const bodyBytes = enc.encode(String(message)); + const prefix = enc.encode("\x19TRON Signed Message:\n" + bodyBytes.length); + const buf = new Uint8Array(prefix.length + bodyBytes.length); + buf.set(prefix, 0); buf.set(bodyBytes, prefix.length); + const digest = keccak_256(buf); + const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" }); + const out = new Uint8Array(65); + out.set(sig.subarray(1), 0); + out[64] = sig[0] + 27; // Ethereum-style v = 27 + recid + return { address: this.address, signature: "0x" + bytesToHex(out) }; + } + + // Sign an arbitrary raw_data_hex the dapp built with its own tronWeb. + // The caller must have already presented an approval overlay. + signRawData(rawDataHex) { + const rawBytes = hexToBytes(rawDataHex); + const digest = sha256(rawBytes); + const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" }); + const trxSig = new Uint8Array(65); + trxSig.set(sig.subarray(1), 0); + trxSig[64] = sig[0]; + return bytesToHex(trxSig); + } + + async broadcastSignedTx(signedTx) { + const r = await this.client.rpc("/wallet/broadcasttransaction", signedTx); + if (r?.result !== true) { + const msg = r?.message ? Buffer.from(r.message, "hex").toString("utf8") : (r?.code || "broadcast rejected"); + throw new Error("broadcast: " + msg); + } + return { txid: signedTx.txID }; + } + + dispose() { + clearTimeout(this._pollTimer); + try { this._priv && this._priv.fill(0); } catch {} + try { this._root && this._root.fill(0); } catch {} + } + } + + return { TronWallet, deriveAccount, decodeAddress, hexToAddress, NETWORKS }; +}; diff --git a/bundled-addons/aegis/lib/dgb/core/address.d.ts b/bundled-addons/aegis/lib/dgb/core/address.d.ts new file mode 100644 index 00000000..fd2b7069 --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/core/address.d.ts @@ -0,0 +1,10 @@ +import type { BIP32Interface } from 'bip32'; +import { type Network } from './network.js'; +export declare function p2pkhAddress(node: BIP32Interface, network?: Network): string; +export declare function p2shP2wpkhAddress(node: BIP32Interface, network?: Network): string; +export declare function p2shP2wpkhAddressesBoth(node: BIP32Interface): { + modern: string; + legacy: string; +}; +export declare function p2wpkhAddress(node: BIP32Interface, network?: Network): string; +export declare function p2trAddress(node: BIP32Interface, network?: Network): string; diff --git a/bundled-addons/aegis/lib/dgb/core/address.js b/bundled-addons/aegis/lib/dgb/core/address.js new file mode 100644 index 00000000..f41fad52 --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/core/address.js @@ -0,0 +1,53 @@ +import { payments, initEccLib } from 'bitcoinjs-lib'; +import * as ecc from '@bitcoinerlab/secp256k1'; +import { digibyte, digibyteLegacyP2SH } from './network.js'; +// Required for Taproot address derivation (P2TR). +initEccLib(ecc); +export function p2pkhAddress(node, network = digibyte) { + const { address } = payments.p2pkh({ + pubkey: Buffer.from(node.publicKey), + network, + }); + if (!address) + throw new Error('p2pkh derivation returned no address'); + return address; +} +export function p2shP2wpkhAddress(node, network = digibyte) { + const redeem = payments.p2wpkh({ + pubkey: Buffer.from(node.publicKey), + network, + }); + const { address } = payments.p2sh({ redeem, network }); + if (!address) + throw new Error('p2sh-p2wpkh derivation returned no address'); + return address; +} +// Derive the "S..." (current DGB, scriptHash 0x3f) AND "3..." (legacy +// Bitcoin-compatible, scriptHash 0x05) BIP49 addresses for the same +// key. Ian Coleman's BIP39 tool and several older wallets generate the +// legacy "3..." variant, so any seed-recovery scan must check both. +export function p2shP2wpkhAddressesBoth(node) { + return { + modern: p2shP2wpkhAddress(node, digibyte), + legacy: p2shP2wpkhAddress(node, digibyteLegacyP2SH), + }; +} +export function p2wpkhAddress(node, network = digibyte) { + const { address } = payments.p2wpkh({ + pubkey: Buffer.from(node.publicKey), + network, + }); + if (!address) + throw new Error('p2wpkh derivation returned no address'); + return address; +} +// BIP86 Taproot address using the x-only pubkey with no script tree, +// which applies the standard BIP86 tweak internally in bitcoinjs-lib. +export function p2trAddress(node, network = digibyte) { + const internalPubkey = Buffer.from(node.publicKey.subarray(1, 33)); + const { address } = payments.p2tr({ internalPubkey, network }); + if (!address) + throw new Error('p2tr derivation returned no address'); + return address; +} +//# sourceMappingURL=address.js.map \ No newline at end of file diff --git a/bundled-addons/aegis/lib/dgb/core/descriptor.d.ts b/bundled-addons/aegis/lib/dgb/core/descriptor.d.ts new file mode 100644 index 00000000..b5de22f1 --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/core/descriptor.d.ts @@ -0,0 +1,5 @@ +import type { BIP32Interface } from 'bip32'; +import type { Purpose } from './hd.js'; +export declare function derivationPath(purpose: Purpose, account: number, change: 0 | 1, index: number): string; +export declare function accountXpub(accountNode: BIP32Interface): string; +export declare function accountXprv(accountNode: BIP32Interface): string; diff --git a/bundled-addons/aegis/lib/dgb/core/descriptor.js b/bundled-addons/aegis/lib/dgb/core/descriptor.js new file mode 100644 index 00000000..e904254a --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/core/descriptor.js @@ -0,0 +1,18 @@ +import { DGB_COIN_TYPE } from './network.js'; +// Convenience helper: full derivation-path string for a specific +// address, e.g. m/84'/20'/0'/0/5. +export function derivationPath(purpose, account, change, index) { + return `m/${purpose}'/${DGB_COIN_TYPE}'/${account}'/${change}/${index}`; +} +// xpub for an account, ready to be shared with a watch-only or +// external indexer. Never share the corresponding xprv. +export function accountXpub(accountNode) { + return accountNode.neutered().toBase58(); +} +export function accountXprv(accountNode) { + if (!accountNode.privateKey) { + throw new Error('Node has no private key; cannot export xprv'); + } + return accountNode.toBase58(); +} +//# sourceMappingURL=descriptor.js.map \ No newline at end of file diff --git a/bundled-addons/aegis/lib/dgb/core/hd.d.ts b/bundled-addons/aegis/lib/dgb/core/hd.d.ts new file mode 100644 index 00000000..6c328569 --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/core/hd.d.ts @@ -0,0 +1,8 @@ +import { type BIP32Interface } from 'bip32'; +import { type Network } from './network.js'; +export type Purpose = 44 | 49 | 84 | 86; +export declare const PURPOSE_LABEL: Record<Purpose, string>; +export declare function rootFromSeed(seed: Buffer, network?: Network): BIP32Interface; +export declare function accountNode(root: BIP32Interface, purpose: Purpose, account?: number): BIP32Interface; +export declare function addressNode(account: BIP32Interface, change: 0 | 1, index: number): BIP32Interface; +export type { BIP32Interface }; diff --git a/bundled-addons/aegis/lib/dgb/core/hd.js b/bundled-addons/aegis/lib/dgb/core/hd.js new file mode 100644 index 00000000..d0b6f19c --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/core/hd.js @@ -0,0 +1,24 @@ +import { BIP32Factory } from 'bip32'; +import * as ecc from '@bitcoinerlab/secp256k1'; +import { digibyte, DGB_COIN_TYPE } from './network.js'; +const bip32 = BIP32Factory(ecc); +export const PURPOSE_LABEL = { + 44: 'BIP44 legacy P2PKH', + 49: 'BIP49 P2SH-wrapped SegWit', + 84: 'BIP84 native SegWit v0', + 86: 'BIP86 Taproot (SegWit v1)', +}; +export function rootFromSeed(seed, network = digibyte) { + return bip32.fromSeed(seed, network); +} +// Standard account-level derivation: m/purpose'/coin'/account'. +// account defaults to 0 (the first account). +export function accountNode(root, purpose, account = 0) { + return root.derivePath(`m/${purpose}'/${DGB_COIN_TYPE}'/${account}'`); +} +// Address-level derivation from an account node. +// change = 0 for external (receive) addresses, 1 for internal (change). +export function addressNode(account, change, index) { + return account.derive(change).derive(index); +} +//# sourceMappingURL=hd.js.map \ No newline at end of file diff --git a/bundled-addons/aegis/lib/dgb/core/index.d.ts b/bundled-addons/aegis/lib/dgb/core/index.d.ts new file mode 100644 index 00000000..14d08354 --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/core/index.d.ts @@ -0,0 +1,6 @@ +export * from './network.js'; +export * from './seed.js'; +export * from './hd.js'; +export * from './address.js'; +export * from './wif.js'; +export * from './descriptor.js'; diff --git a/bundled-addons/aegis/lib/dgb/core/index.js b/bundled-addons/aegis/lib/dgb/core/index.js new file mode 100644 index 00000000..b9669ee3 --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/core/index.js @@ -0,0 +1,7 @@ +export * from './network.js'; +export * from './seed.js'; +export * from './hd.js'; +export * from './address.js'; +export * from './wif.js'; +export * from './descriptor.js'; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/bundled-addons/aegis/lib/dgb/core/network.d.ts b/bundled-addons/aegis/lib/dgb/core/network.d.ts new file mode 100644 index 00000000..10e1007b --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/core/network.d.ts @@ -0,0 +1,13 @@ +import type { networks } from 'bitcoinjs-lib'; +export type Network = (typeof networks)['bitcoin']; +export declare const digibyte: Network; +export declare const digibyteLegacyP2SH: Network; +export declare const digibyteLegacyWIF: Network; +export declare const digibyteTestnet: Network; +export declare const DGB_COIN_TYPE = 20; +export declare const DGB_P2P: { + readonly magic: 3669410810; + readonly defaultPort: 12024; + readonly rpcPort: 14022; + readonly dnsSeeds: readonly ["seed.digibyte.io", "seed.diginode.tools", "seed.digibyte.link", "seed.aroundtheblock.app", "seed.tuyul.cc"]; +}; diff --git a/bundled-addons/aegis/lib/dgb/core/network.js b/bundled-addons/aegis/lib/dgb/core/network.js new file mode 100644 index 00000000..b874df8a --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/core/network.js @@ -0,0 +1,74 @@ +// DigiByte mainnet parameters. Sourced from +// github.com/DigiByte-Core/digibyte src/kernel/chainparams.cpp CMainParams. +// +// Note on scriptHash: DGB Core defines BOTH SCRIPT_ADDRESS = 0x3f ("S..." +// addresses, the current default) and SCRIPT_ADDRESS2 = 0x05 ("3..." +// addresses, kept for Bitcoin compatibility). Older BIP39 tools and +// wallets that forked bitcoinjs-lib params generate 3-addresses; current +// DGB Core generates S-addresses. `digibyte` below uses the current +// default; use `digibyteLegacyP2SH` when recovering from Ian Coleman's +// BIP39 tool, older Atomic/Exodus vintages, or anything else that +// inherited Bitcoin's 0x05 P2SH byte. Both are valid on-chain. +export const digibyte = { + messagePrefix: '\x19DigiByte Signed Message:\n', + bech32: 'dgb', + bip32: { + public: 0x0488b21e, + private: 0x0488ade4, + }, + pubKeyHash: 0x1e, + scriptHash: 0x3f, + wif: 0x80, +}; +// Same as `digibyte`, but with the legacy Bitcoin-compatible P2SH +// version byte. Use for producing/scanning "3..."-style P2SH addresses +// during seed recovery from tools that predate the 0x3f switch. +// DGB Core keeps SCRIPT_ADDRESS_OLD = 5 for backward compatibility. +export const digibyteLegacyP2SH = { + ...digibyte, + scriptHash: 0x05, +}; +// Same as `digibyte`, but with the legacy WIF version byte. +// DGB Core keeps SECRET_KEY_OLD = 158 (0x9e) for backward compatibility. +// Older DGB tools may have exported private keys with this prefix. +// The wallet's WIF-import path should try both `digibyte` and this +// variant before rejecting a key. +export const digibyteLegacyWIF = { + ...digibyte, + wif: 0x9e, +}; +// DigiByte testnet parameters. From CTestNetParams in chainparams.cpp. +export const digibyteTestnet = { + messagePrefix: '\x19DigiByte Signed Message:\n', + bech32: 'dgbt', + bip32: { + public: 0x043587cf, + private: 0x04358394, + }, + pubKeyHash: 0x7e, + scriptHash: 0x8c, + wif: 0xef, +}; +// SLIP-0044 registered coin type. +export const DGB_COIN_TYPE = 20; +// P2P network constants (unused by @dgb-wallet/core itself, exposed for +// the P2P client package that will consume them). +export const DGB_P2P = { + // pchMessageStart in DGB Core chainparams.cpp is the byte sequence + // 0xFA 0xC3 0xB6 0xDA on the wire. `writeUInt32LE(magic)` writes + // least-significant-byte first, so the integer value stored here must + // be the LE-reading: 0xDAB6C3FA (byte 0 = 0xFA, byte 3 = 0xDA). + // Getting this wrong means peers see wrong-network frames and close + // immediately at handshake. + magic: 0xdab6c3fa, + defaultPort: 12024, + rpcPort: 14022, + dnsSeeds: [ + 'seed.digibyte.io', + 'seed.diginode.tools', + 'seed.digibyte.link', + 'seed.aroundtheblock.app', + 'seed.tuyul.cc', + ], +}; +//# sourceMappingURL=network.js.map \ No newline at end of file diff --git a/bundled-addons/aegis/lib/dgb/core/package.json b/bundled-addons/aegis/lib/dgb/core/package.json new file mode 100644 index 00000000..ddb9a1bd --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/core/package.json @@ -0,0 +1 @@ +{"type":"module","main":"./index.js"} diff --git a/bundled-addons/aegis/lib/dgb/core/seed.d.ts b/bundled-addons/aegis/lib/dgb/core/seed.d.ts new file mode 100644 index 00000000..94c97e3b --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/core/seed.d.ts @@ -0,0 +1,4 @@ +export type SeedStrength = 128 | 160 | 192 | 224 | 256; +export declare function generateSeedPhrase(strength?: SeedStrength): string; +export declare function validateSeedPhrase(phrase: string, wordlist?: string[]): boolean; +export declare function seedFromPhrase(phrase: string, passphrase?: string): Promise<Buffer>; diff --git a/bundled-addons/aegis/lib/dgb/core/seed.js b/bundled-addons/aegis/lib/dgb/core/seed.js new file mode 100644 index 00000000..49f8a26a --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/core/seed.js @@ -0,0 +1,17 @@ +import { generateMnemonic, validateMnemonic, mnemonicToSeed, wordlists, } from 'bip39'; +// Word count → entropy strength for BIP39. +// 12 → 128, 15 → 160, 18 → 192, 21 → 224, 24 → 256. +export function generateSeedPhrase(strength = 128) { + return generateMnemonic(strength); +} +// BIP39 checksum + wordlist validation. Returns false for typos, bad +// word counts, and out-of-wordlist words. +export function validateSeedPhrase(phrase, wordlist = wordlists.english) { + return validateMnemonic(phrase.trim(), wordlist); +} +// BIP39 seed derivation. Passphrase is the optional "25th word"; +// changing it produces a different wallet from the same mnemonic. +export async function seedFromPhrase(phrase, passphrase = '') { + return mnemonicToSeed(phrase.trim(), passphrase); +} +//# sourceMappingURL=seed.js.map \ No newline at end of file diff --git a/bundled-addons/aegis/lib/dgb/core/wif.d.ts b/bundled-addons/aegis/lib/dgb/core/wif.d.ts new file mode 100644 index 00000000..2d28c178 --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/core/wif.d.ts @@ -0,0 +1,27 @@ +import { type ECPairInterface } from 'ecpair'; +import { type Network } from './network.js'; +export type WifImportResult = { + keyPair: ECPairInterface; + variant: 'modern' | 'legacy'; +}; +export declare function importWif(wif: string): WifImportResult; +export declare function importWifStrict(wif: string, network?: Network): ECPairInterface; +export declare function exportWif(keyPair: ECPairInterface): string; +export interface PubkeyAddresses { + p2pkh: string; + p2shP2wpkhModern: string; + p2shP2wpkhLegacy: string; + p2wpkh: string; + p2tr: string; + scripts: { + p2pkh: string; + p2shP2wpkh: string; + p2wpkh: string; + p2tr: string; + }; +} +export declare function addressesForPubkey(pubkey: Buffer, network?: Network): PubkeyAddresses; +export interface WifImportWithAddresses extends WifImportResult, PubkeyAddresses { +} +export declare function importWifWithAddresses(wif: string): WifImportWithAddresses; +export type { ECPairInterface }; diff --git a/bundled-addons/aegis/lib/dgb/core/wif.js b/bundled-addons/aegis/lib/dgb/core/wif.js new file mode 100644 index 00000000..159b093c --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/core/wif.js @@ -0,0 +1,72 @@ +import { ECPairFactory } from 'ecpair'; +import * as ecc from '@bitcoinerlab/secp256k1'; +import { payments, initEccLib } from 'bitcoinjs-lib'; +import { digibyte, digibyteLegacyP2SH, digibyteLegacyWIF } from './network.js'; +const ECPair = ECPairFactory(ecc); +initEccLib(ecc); +// Import a DigiByte private key in WIF (Wallet Import Format). +// Tries the modern 0x80 prefix first, falls back to the legacy 0x9e +// prefix that older DGB tools produced. Rejects anything else with a +// clear message that identifies the network mismatch. +export function importWif(wif) { + const trimmed = wif.trim(); + try { + return { keyPair: ECPair.fromWIF(trimmed, digibyte), variant: 'modern' }; + } + catch { + // fall through to legacy attempt + } + try { + return { keyPair: ECPair.fromWIF(trimmed, digibyteLegacyWIF), variant: 'legacy' }; + } + catch (e) { + throw new Error(`Not a valid DigiByte WIF (tried both current 0x80 and legacy 0x9e prefixes). ` + + `Underlying error: ${e.message}. Check the key is for DGB mainnet, not testnet or another chain.`); + } +} +// Import against a specific network only (advanced / testing). +export function importWifStrict(wif, network = digibyte) { + return ECPair.fromWIF(wif.trim(), network); +} +export function exportWif(keyPair) { + return keyPair.toWIF(); +} +export function addressesForPubkey(pubkey, network = digibyte) { + const p2pkh = payments.p2pkh({ pubkey, network }); + const wpkhRedeem = payments.p2wpkh({ pubkey, network }); + // bitcoinjs-lib enforces `redeem.network === outerNetwork` (identity + // comparison, not shape). To render the legacy 3-prefix P2SH address + // we need a fresh redeem whose .network property is the legacy variant + // — same bytes on the wire, different object identity. + const wpkhRedeemLegacy = payments.p2wpkh({ pubkey, network: digibyteLegacyP2SH }); + const p2shModern = payments.p2sh({ redeem: wpkhRedeem, network }); + const p2shLegacy = payments.p2sh({ redeem: wpkhRedeemLegacy, network: digibyteLegacyP2SH }); + const p2wpkh = payments.p2wpkh({ pubkey, network }); + const p2tr = payments.p2tr({ internalPubkey: pubkey.subarray(1, 33), network }); + if (!p2pkh.address || !p2shModern.address || !p2shLegacy.address || !p2wpkh.address || !p2tr.address) { + throw new Error('bitcoinjs-lib returned an empty address for one of the payment types'); + } + if (!p2pkh.output || !p2shModern.output || !p2wpkh.output || !p2tr.output) { + throw new Error('bitcoinjs-lib returned an empty scriptPubKey for one of the payment types'); + } + return { + p2pkh: p2pkh.address, + p2shP2wpkhModern: p2shModern.address, + p2shP2wpkhLegacy: p2shLegacy.address, + p2wpkh: p2wpkh.address, + p2tr: p2tr.address, + scripts: { + p2pkh: Buffer.from(p2pkh.output).toString('hex'), + p2shP2wpkh: Buffer.from(p2shModern.output).toString('hex'), + p2wpkh: Buffer.from(p2wpkh.output).toString('hex'), + p2tr: Buffer.from(p2tr.output).toString('hex'), + }, + }; +} +export function importWifWithAddresses(wif) { + const imported = importWif(wif); + const pubkey = Buffer.from(imported.keyPair.publicKey); + const addrs = addressesForPubkey(pubkey); + return { ...imported, ...addrs }; +} +//# sourceMappingURL=wif.js.map \ No newline at end of file diff --git a/bundled-addons/aegis/lib/dgb/psbt/build.d.ts b/bundled-addons/aegis/lib/dgb/psbt/build.d.ts new file mode 100644 index 00000000..d962850a --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/psbt/build.d.ts @@ -0,0 +1,4 @@ +import { Psbt } from 'bitcoinjs-lib'; +import { type Network } from '@dgb-wallet/core'; +import type { BuildParams } from './types.js'; +export declare function buildPsbt(params: BuildParams, network?: Network): Psbt; diff --git a/bundled-addons/aegis/lib/dgb/psbt/build.js b/bundled-addons/aegis/lib/dgb/psbt/build.js new file mode 100644 index 00000000..1fe91424 --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/psbt/build.js @@ -0,0 +1,56 @@ +import { Psbt } from 'bitcoinjs-lib'; +import { digibyte } from '../core/index.js'; +// Construct an unsigned PSBT from a set of UTXOs and destination outputs. +// Does not add a change output — the caller decides change amount and +// address. Does not compute fees — the caller must have already subtracted +// fee from outputs. +export function buildPsbt(params, network = digibyte) { + const psbt = new Psbt({ network }); + const inputs = params.sortBip69 === false ? params.inputs : sortInputs(params.inputs); + const outputs = params.sortBip69 === false ? params.outputs : sortOutputs(params.outputs); + for (const u of inputs) { + psbt.addInput(inputToPsbtInput(u)); + } + for (const o of outputs) { + psbt.addOutput({ address: o.address, value: o.value }); + } + return psbt; +} +function inputToPsbtInput(u) { + const input = { + hash: u.txid, + index: u.vout, + }; + if (u.witness) { + input.witnessUtxo = { + script: Buffer.from(u.witness.scriptHex, 'hex'), + value: u.witness.value, + }; + } + if (u.nonWitnessTxHex) { + input.nonWitnessUtxo = Buffer.from(u.nonWitnessTxHex, 'hex'); + } + if (u.redeemScriptHex) { + input.redeemScript = Buffer.from(u.redeemScriptHex, 'hex'); + } + if (u.tapInternalKeyHex) { + input.tapInternalKey = Buffer.from(u.tapInternalKeyHex, 'hex'); + } + return input; +} +// BIP69 lexicographic ordering. Improves privacy by not revealing input +// selection order (which can hint at wallet coin-selection strategy). +function sortInputs(inputs) { + return [...inputs].sort((a, b) => { + const cmp = a.txid.localeCompare(b.txid); + return cmp !== 0 ? cmp : a.vout - b.vout; + }); +} +function sortOutputs(outputs) { + return [...outputs].sort((a, b) => { + if (a.value !== b.value) + return a.value - b.value; + return a.address.localeCompare(b.address); + }); +} +//# sourceMappingURL=build.js.map \ No newline at end of file diff --git a/bundled-addons/aegis/lib/dgb/psbt/index.d.ts b/bundled-addons/aegis/lib/dgb/psbt/index.d.ts new file mode 100644 index 00000000..e33aca92 --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/psbt/index.d.ts @@ -0,0 +1,3 @@ +export * from './types.js'; +export * from './build.js'; +export * from './sign.js'; diff --git a/bundled-addons/aegis/lib/dgb/psbt/index.js b/bundled-addons/aegis/lib/dgb/psbt/index.js new file mode 100644 index 00000000..26f62938 --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/psbt/index.js @@ -0,0 +1,4 @@ +export * from './types.js'; +export * from './build.js'; +export * from './sign.js'; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/bundled-addons/aegis/lib/dgb/psbt/package.json b/bundled-addons/aegis/lib/dgb/psbt/package.json new file mode 100644 index 00000000..ddb9a1bd --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/psbt/package.json @@ -0,0 +1 @@ +{"type":"module","main":"./index.js"} diff --git a/bundled-addons/aegis/lib/dgb/psbt/sign.d.ts b/bundled-addons/aegis/lib/dgb/psbt/sign.d.ts new file mode 100644 index 00000000..3df74500 --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/psbt/sign.d.ts @@ -0,0 +1,9 @@ +import type { Psbt } from 'bitcoinjs-lib'; +import type { ECPairInterface } from 'ecpair'; +export declare function signAllInputs(psbt: Psbt, keyPair: ECPairInterface): Psbt; +export declare function finalizeAndExtract(psbt: Psbt): { + hex: string; + txid: string; +}; +export declare function feeSats(psbt: Psbt): number; +export declare function feeRateSatsPerByte(psbt: Psbt): number; diff --git a/bundled-addons/aegis/lib/dgb/psbt/sign.js b/bundled-addons/aegis/lib/dgb/psbt/sign.js new file mode 100644 index 00000000..94a5a2f5 --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/psbt/sign.js @@ -0,0 +1,25 @@ +// Sign every input of a PSBT with the given key. Fails loudly if any +// input can't be signed by this key (rather than silently leaving it +// unsigned), so callers notice before broadcasting a half-signed tx. +export function signAllInputs(psbt, keyPair) { + psbt.signAllInputs(keyPair); + return psbt; +} +// After all inputs are signed by all necessary parties, finalize and +// extract the network-ready hex-encoded transaction. +export function finalizeAndExtract(psbt) { + psbt.finalizeAllInputs(); + const tx = psbt.extractTransaction(); + return { hex: tx.toHex(), txid: tx.getId() }; +} +// Fee computed from the difference between total input value and total +// output value. Requires all inputs to have witnessUtxo or +// nonWitnessUtxo populated (which `buildPsbt` in this package +// guarantees when the caller populates Utxo.value fields). +export function feeSats(psbt) { + return psbt.getFee(); +} +export function feeRateSatsPerByte(psbt) { + return psbt.getFeeRate(); +} +//# sourceMappingURL=sign.js.map \ No newline at end of file diff --git a/bundled-addons/aegis/lib/dgb/psbt/types.d.ts b/bundled-addons/aegis/lib/dgb/psbt/types.d.ts new file mode 100644 index 00000000..33f3542b --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/psbt/types.d.ts @@ -0,0 +1,23 @@ +export interface Utxo { + txid: string; + vout: number; + value: number; + address: string; + scriptPubKey: string; + witness?: { + scriptHex: string; + value: number; + }; + nonWitnessTxHex?: string; + redeemScriptHex?: string; + tapInternalKeyHex?: string; +} +export interface Output { + address: string; + value: number; +} +export interface BuildParams { + inputs: Utxo[]; + outputs: Output[]; + sortBip69?: boolean; +} diff --git a/bundled-addons/aegis/lib/dgb/psbt/types.js b/bundled-addons/aegis/lib/dgb/psbt/types.js new file mode 100644 index 00000000..718fd38a --- /dev/null +++ b/bundled-addons/aegis/lib/dgb/psbt/types.js @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/bundled-addons/aegis/lib/eip712.js b/bundled-addons/aegis/lib/eip712.js new file mode 100644 index 00000000..a9266795 --- /dev/null +++ b/bundled-addons/aegis/lib/eip712.js @@ -0,0 +1,143 @@ +// EIP-712 typed-data hashing (personal_sign's structured cousin). Produces +// the 32-byte digest that eth_signTypedData_v4 signs with the wallet's +// secp256k1 key. +// +// Reference: https://eips.ethereum.org/EIPS/eip-712 +// Digest = keccak256(0x19 || 0x01 || domainSeparator || hashStruct(primaryType, message)) +// - domainSeparator = hashStruct("EIP712Domain", typedData.domain) +// - hashStruct(type, data) = keccak256(typeHash(type) || encodeData(type, data)) +// - typeHash(type) = keccak256(encodeType(type)) +// - encodeType is the canonical string form; sub-types are appended in +// alphabetical order once, without recursion into themselves twice. +// +// This is enough for every mainstream EIP-712 payload — Permit / EIP-2612, +// OpenSea order signatures, WalletConnect handshakes, Snapshot votes. Not +// implemented: fixed-size arrays of atomic types wider than a byte (rare +// enough that no shipping dapp we care about uses them). + +module.exports = function makeEip712({ keccak_256 }) { + const enc = new TextEncoder(); + const concat = (...ps) => { + const n = ps.reduce((a, p) => a + p.length, 0); + const out = new Uint8Array(n); let k = 0; + for (const p of ps) { out.set(p, k); k += p.length; } + return out; + }; + const hex2bytes = (h) => { + const s = String(h).replace(/^0x/i, ""); + if (s.length % 2) throw new Error("hex: odd length"); + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); + return out; + }; + const bytesToBig = (b) => { + let v = 0n; for (const x of b) v = (v << 8n) | BigInt(x); return v; + }; + const bigToBe32 = (v, signed) => { + let n = BigInt(v); + if (n < 0n) { + if (!signed) throw new Error("negative value for unsigned type"); + // two's complement to 256 bits + n = (1n << 256n) + n; + } + const out = new Uint8Array(32); + for (let i = 31; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n; } + return out; + }; + + // encodeType walker — resolves the primary type + every struct it + // transitively references, then emits "Primary(...)Sub1(...)Sub2(...)" + // with sub-types in alphabetical order per the spec. + function findDependencies(primaryType, types, found = new Set()) { + if (found.has(primaryType) || !types[primaryType]) return found; + found.add(primaryType); + for (const f of types[primaryType]) { + const base = f.type.replace(/\[.*\]$/, ""); + if (types[base]) findDependencies(base, types, found); + } + return found; + } + function encodeType(primaryType, types) { + const deps = [...findDependencies(primaryType, types)].filter((t) => t !== primaryType).sort(); + const all = [primaryType, ...deps]; + return all.map((t) => `${t}(${types[t].map((f) => `${f.type} ${f.name}`).join(",")})`).join(""); + } + function typeHash(primaryType, types) { + return keccak_256(enc.encode(encodeType(primaryType, types))); + } + + // Encode one field value per its declared type. Struct + array types + // hash themselves to 32 bytes; atomics land in a 32-byte slot each. + function encodeValue(type, value, types) { + // Array types: `Type[]` (dynamic) or `Type[N]` (fixed) — both encode + // as keccak256(concat(encodeValue(baseType, element)...)) per EIP-712. + const arr = /^(.+)\[(\d*)\]$/.exec(type); + if (arr) { + const baseType = arr[1]; + const items = Array.isArray(value) ? value : []; + const encoded = items.map((v) => encodeValue(baseType, v, types)); + return keccak_256(concat(...encoded)); + } + // Struct types: hashStruct recursion. + if (types[type]) return hashStruct(type, value, types); + // Atomic types. + if (type === "string") return keccak_256(enc.encode(String(value ?? ""))); + if (type === "bytes") { + const b = typeof value === "string" ? hex2bytes(value) : Uint8Array.from(value || []); + return keccak_256(b); + } + if (type === "address") { + const h = hex2bytes(String(value || "0x0").replace(/^0x/, "")); + if (h.length !== 20) throw new Error("address must be 20 bytes"); + const out = new Uint8Array(32); + out.set(h, 12); + return out; + } + if (type === "bool") { + const out = new Uint8Array(32); + out[31] = value ? 1 : 0; + return out; + } + // bytesN (fixed): left-aligned in a 32-byte word. + const bytesN = /^bytes(\d+)$/.exec(type); + if (bytesN) { + const n = Number(bytesN[1]); + if (n < 1 || n > 32) throw new Error("bytesN out of range"); + const b = typeof value === "string" ? hex2bytes(value) : Uint8Array.from(value || []); + if (b.length !== n) throw new Error(`${type} expects ${n} bytes, got ${b.length}`); + const out = new Uint8Array(32); + out.set(b, 0); + return out; + } + // uint* / int*: encode as 32-byte big-endian. + const uintM = /^uint(\d*)$/.exec(type); + if (uintM) return bigToBe32(value, false); + const intM = /^int(\d*)$/.exec(type); + if (intM) return bigToBe32(value, true); + throw new Error("unsupported EIP-712 type: " + type); + } + + function encodeData(primaryType, data, types) { + const fields = types[primaryType]; + if (!fields) throw new Error("unknown type: " + primaryType); + const encoded = fields.map((f) => encodeValue(f.type, data ? data[f.name] : undefined, types)); + return concat(...encoded); + } + function hashStruct(primaryType, data, types) { + return keccak_256(concat(typeHash(primaryType, types), encodeData(primaryType, data, types))); + } + + // Full EIP-712 digest, ready for secp256k1.sign(digest, key). + function digest(typedData) { + const td = typedData && typeof typedData === "object" ? typedData : {}; + const types = td.types || {}; + if (!types.EIP712Domain) throw new Error("typedData.types.EIP712Domain missing"); + const primary = String(td.primaryType || ""); + if (!primary || !types[primary]) throw new Error(`typedData.primaryType "${primary}" not in types`); + const domainSeparator = hashStruct("EIP712Domain", td.domain || {}, types); + const messageHash = hashStruct(primary, td.message || {}, types); + return keccak_256(concat(Uint8Array.from([0x19, 0x01]), domainSeparator, messageHash)); + } + + return { digest, encodeType, typeHash, hashStruct }; +}; diff --git a/bundled-addons/aegis/lib/electrum.js b/bundled-addons/aegis/lib/electrum.js new file mode 100644 index 00000000..88f0859d --- /dev/null +++ b/bundled-addons/aegis/lib/electrum.js @@ -0,0 +1,131 @@ +// Electrum (Fulcrum) JSON-RPC over WebSocket for the wallet. One live +// connection at a time, chosen by walking the server list in order; the +// caller gets a stable `call()` that reconnects transparently on the next +// request after a drop. Notifications (headers / scripthash subscriptions) +// fan out to `onNotify`. +module.exports = function makeElectrum({ WebSocket, log = () => {} }) { + const CALL_TIMEOUT_MS = 20000; + + class Connection { + constructor(url) { + this.url = url; + this.id = 0; + this.pending = new Map(); + this.buf = ""; + this.closed = false; + this.onNotify = null; + this.onClose = null; + } + connect() { + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.url); + this.ws = ws; + const fail = (e) => { if (!this.closed) { this.closed = true; reject(e instanceof Error ? e : new Error("electrum ws error: " + this.url)); } }; + ws.on("open", async () => { + try { await this.call("server.version", ["theseus-bchwallet", "1.4"]); resolve(this); } + catch (e) { fail(e); this.close(); } + }); + ws.on("error", fail); + ws.on("message", (d) => this._onData(String(d))); + ws.on("close", () => { + this.closed = true; + for (const p of this.pending.values()) p.reject(new Error("electrum connection closed")); + this.pending.clear(); + if (this.onClose) this.onClose(); + }); + }); + } + _onData(chunk) { + this.buf += chunk; + let nl; + while ((nl = this.buf.indexOf("\n")) >= 0) { + const line = this.buf.slice(0, nl).trim(); + this.buf = this.buf.slice(nl + 1); + if (line) this._handleLine(line); + } + const rest = this.buf.trim(); + if (rest) { try { JSON.parse(rest); this._handleLine(rest); this.buf = ""; } catch {} } + } + _handleLine(line) { + let msg; + try { msg = JSON.parse(line); } catch { return; } + if (msg.id != null && this.pending.has(msg.id)) { + const p = this.pending.get(msg.id); + this.pending.delete(msg.id); + clearTimeout(p.timer); + if (msg.error) p.reject(new Error(typeof msg.error === "object" ? (msg.error.message || JSON.stringify(msg.error)) : String(msg.error))); + else p.resolve(msg.result); + } else if (msg.method && this.onNotify) { + this.onNotify(msg.method, msg.params || []); + } + } + call(method, params = []) { + if (this.closed) return Promise.reject(new Error("electrum connection closed")); + const id = ++this.id; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`electrum timeout: ${method}`)); } + }, CALL_TIMEOUT_MS); + this.pending.set(id, { resolve, reject, timer }); + try { this.ws.send(JSON.stringify({ id, method, params }) + "\n"); } + catch (e) { clearTimeout(timer); this.pending.delete(id); reject(e); } + }); + } + close() { this.closed = true; try { this.ws.close(); } catch {} } + } + + class Client { + constructor(servers) { + this.servers = servers.slice(); + this.conn = null; + this.connecting = null; + this.subscriptions = new Map(); // method+key -> params (replayed on reconnect) + this.onNotify = null; + this.onServer = null; // (url|null) connection state for the UI + } + setServers(servers) { + this.servers = servers.slice(); + this.disconnect(); + } + get url() { return this.conn && !this.conn.closed ? this.conn.url : null; } + async _ensure() { + if (this.conn && !this.conn.closed) return this.conn; + if (this.connecting) return this.connecting; + this.connecting = (async () => { + let lastErr; + for (const url of this.servers) { + try { + const c = await new Connection(url).connect(); + c.onNotify = (m, p) => { if (this.onNotify) this.onNotify(m, p); }; + c.onClose = () => { if (this.conn === c) { this.conn = null; if (this.onServer) this.onServer(null); } }; + this.conn = c; + log("connected", url); + if (this.onServer) this.onServer(url); + // Re-arm subscriptions so a reconnect keeps the live feed. + for (const params of this.subscriptions.values()) c.call(params[0], params[1]).catch(() => {}); + return c; + } catch (e) { lastErr = e; log("failed", url, e?.message); } + } + throw lastErr || new Error("no electrum server reachable"); + })(); + try { return await this.connecting; } + finally { this.connecting = null; } + } + async call(method, params = []) { + const c = await this._ensure(); + return c.call(method, params); + } + // Remember a subscription so it survives reconnects. + async subscribe(method, params = []) { + this.subscriptions.set(method + ":" + JSON.stringify(params), [method, params]); + return this.call(method, params); + } + clearSubscriptions() { this.subscriptions.clear(); } + disconnect() { + if (this.conn) { const c = this.conn; this.conn = null; c.close(); } + if (this.onServer) this.onServer(null); + } + } + + return { Client }; +}; diff --git a/bundled-addons/aegis/lib/keys.js b/bundled-addons/aegis/lib/keys.js new file mode 100644 index 00000000..95c087ef --- /dev/null +++ b/bundled-addons/aegis/lib/keys.js @@ -0,0 +1,66 @@ +// HD key tree for the wallet. Root = 32 bytes from api.vault.derive treated as +// a BIP32 master seed; account = m/44'/145'/0' (BCH, SLIP-44). Branch 0 is +// receive, branch 1 is change. Private keys never leave this module except +// through sign() / signRecoverable() for a specific entry. +module.exports = function makeKeys({ HDKey, secp256k1, sha256, ripemd160, cashaddr }) { + const hash160 = (b) => ripemd160(sha256(b)); + const p2pkhScript = (h160) => Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]); + const p2shScript = (h160) => Uint8Array.from([0xa9, 0x14, ...h160, 0x87]); + const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + // electrum scripthash: sha256(script), byte-reversed, hex. + const scripthash = (script) => toHex(sha256(script).slice().reverse()); + + class WalletKeys { + constructor(root32, accountPath, prefix) { + this.prefix = prefix; + this.accountPath = accountPath; + this._account = HDKey.fromMasterSeed(root32).derive(accountPath); + this._branch = [this._account.deriveChild(0), this._account.deriveChild(1)]; + this._cache = new Map(); // "branch/index" -> entry + } + get xpub() { return this._account.publicExtendedKey; } + // Revealed only on explicit user action in Settings (show recovery info). + get xprv() { return this._account.privateExtendedKey; } + entry(branch, index) { + const k = branch + "/" + index; + let e = this._cache.get(k); + if (!e) { + const node = this._branch[branch].deriveChild(index); + const h160 = hash160(node.publicKey); + const script = p2pkhScript(h160); + e = { + branch, index, path: this.accountPath + "/" + branch + "/" + index, + publicKey: node.publicKey, h160, script, scriptHex: toHex(script), + scripthash: scripthash(script), + address: cashaddr.encode(this.prefix, 0, h160), + _node: node, + }; + this._cache.set(k, e); + } + return e; + } + findByScriptHex(scriptHex) { + for (const e of this._cache.values()) if (e.scriptHex === scriptHex) return e; + return null; + } + // ECDSA over a 32-byte digest, DER-encoded, low-S (BCH consensus rule). + sign(entry, digest32) { + return secp256k1.sign(digest32, entry._node.privateKey, { prehash: false, lowS: true, format: "der" }); + } + // 65-byte BIP-137 signature: [27 + recid + 4 (compressed)] || r || s. + signRecoverable(entry, digest32) { + const sig = secp256k1.sign(digest32, entry._node.privateKey, { prehash: false, lowS: true, format: "recovered" }); + const out = new Uint8Array(65); + out[0] = 27 + sig[0] + 4; + out.set(sig.subarray(1), 1); + return out; + } + wipe() { + for (const e of this._cache.values()) { try { e._node.wipePrivateData(); } catch {} } + this._cache.clear(); + for (const b of this._branch) { try { b.wipePrivateData(); } catch {} } + try { this._account.wipePrivateData(); } catch {} + } + } + return { WalletKeys, hash160, p2pkhScript, p2shScript, scripthash, toHex }; +}; diff --git a/bundled-addons/aegis/lib/prices.js b/bundled-addons/aegis/lib/prices.js new file mode 100644 index 00000000..cf015b32 --- /dev/null +++ b/bundled-addons/aegis/lib/prices.js @@ -0,0 +1,98 @@ +// Fiat prices for every Aegis-supported coin — CoinGecko's free /simple/price +// endpoint, one request covers the lot. Opt-in via Settings so a +// privacy-conscious user isn't quietly telling CoinGecko when Aegis is open. +// +// Cache is in-memory (returned by fullState() → panel). The addon polls +// every 5 min while enabled; each fetch is cheap (~200 B response) and +// the free tier tolerates one call/5 min per client easily. +// +// Trade-off named in the settings copy: CoinGecko sees the browser's IP +// + a User-Agent every poll. Not seed-linked, not address-linked, but a +// data point. Off by default. + +const COIN_GECKO_IDS = { + bch: "bitcoin-cash", + btc: "bitcoin", + trx: "tron", + eth: "ethereum", + sol: "solana", + sc: "siacoin", + dgb: "digibyte", +}; + +const ENDPOINT = "https://api.coingecko.com/api/v3/simple/price"; +const POLL_MS = 5 * 60 * 1000; + +module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } = {}) { + const state = { + enabled: false, + prices: {}, // { <chain>: usd (number) } + fetchedAt: null, + error: null, + loading: false, + }; + let timer = null; + + async function fetchOnce() { + if (!state.enabled) return; + state.loading = true; state.error = null; onChange(); + try { + const ids = Object.values(COIN_GECKO_IDS).join(","); + const url = `${ENDPOINT}?ids=${encodeURIComponent(ids)}&vs_currencies=usd`; + const r = await fetch(url); + if (!r.ok) throw new Error(`CoinGecko HTTP ${r.status}`); + const body = await r.json(); + const next = {}; + for (const [chain, cgId] of Object.entries(COIN_GECKO_IDS)) { + const usd = body?.[cgId]?.usd; + if (typeof usd === "number") next[chain] = usd; + } + state.prices = next; + state.fetchedAt = Date.now(); + state.error = null; + } catch (e) { + state.error = e?.message || String(e); + log("price fetch failed:", state.error); + } finally { + state.loading = false; + onChange(); + } + } + + function schedule() { + clearTimeout(timer); + if (!state.enabled) return; + timer = setTimeout(async () => { await fetchOnce(); schedule(); }, POLL_MS); + } + + return { + // Snapshot for the panel: only what the UI needs. + snapshot() { + return { + enabled: state.enabled, + prices: state.prices, + fetchedAt: state.fetchedAt, + error: state.error, + loading: state.loading, + }; + }, + // Turn the feed on/off. Enabling triggers an immediate fetch so the + // panel doesn't wait 5 minutes for the first price. + async setEnabled(on) { + const changed = !!on !== state.enabled; + state.enabled = !!on; + if (!state.enabled) { + state.prices = {}; state.fetchedAt = null; state.error = null; + clearTimeout(timer); + if (changed) onChange(); + return; + } + onChange(); + await fetchOnce(); + schedule(); + }, + // Force-refresh — bound to a manual "refresh" button in the panel. + refresh() { return fetchOnce(); }, + dispose() { clearTimeout(timer); state.enabled = false; }, + }; +}; diff --git a/bundled-addons/aegis/lib/sia/keys.js b/bundled-addons/aegis/lib/sia/keys.js new file mode 100644 index 00000000..c7260b60 --- /dev/null +++ b/bundled-addons/aegis/lib/sia/keys.js @@ -0,0 +1,29 @@ +// Key tree for the Sia wallet: index i -> ed25519 key via walletd's +// KeyFromSeed(root, i), address = standard unlock hash of the public key. +// Private keys stay inside this module; sign() is the only way out. +module.exports = function makeKeys({ sia }) { + class WalletKeys { + constructor(root32) { + this._root = Uint8Array.from(root32); + this._cache = new Map(); + } + entry(index) { + let e = this._cache.get(index); + if (!e) { + const k = sia.keyFromSeed(this._root, index); + e = { index, pub: k.pub, address32: k.address32, address: k.address, _priv: k.priv }; + this._cache.set(index, e); + } + return e; + } + sign(entry, msg) { return sia.sign(entry._priv, msg); } + // Revealed only on explicit user action in Settings. + get seedHex() { return sia.toHex(this._root); } + wipe() { + for (const e of this._cache.values()) e._priv.fill(0); + this._cache.clear(); + this._root.fill(0); + } + } + return { WalletKeys }; +}; diff --git a/bundled-addons/aegis/lib/sia/sia.js b/bundled-addons/aegis/lib/sia/sia.js new file mode 100644 index 00000000..d7e119a5 --- /dev/null +++ b/bundled-addons/aegis/lib/sia/sia.js @@ -0,0 +1,142 @@ +// Sia (v2 era) primitives: the Sia binary encoder, standard unlock-hash +// addresses, walletd's per-index key derivation, the v2 input signature hash +// and transaction weight. Mirrors go.sia.tech/core/types; every encoding +// here was checked against a real mainnet v2 transaction. +module.exports = function makeSia({ ed25519, blake2b }) { + const b256 = (data) => blake2b(data, { dkLen: 32 }); + const enc = new TextEncoder(); + const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + const fromHex = (h) => Uint8Array.from(String(h).replace(/^0x/, "").match(/../g) || [], (x) => parseInt(x, 16)); + + // ---- encoder --------------------------------------------------------------- + class Encoder { + constructor() { this.parts = []; this.length = 0; } + write(b) { this.parts.push(b); this.length += b.length; return this; } + u8(n) { return this.write(Uint8Array.from([n & 0xff])); } + bool(v) { return this.u8(v ? 1 : 0); } + u64(n) { + let v = BigInt(n); const out = new Uint8Array(8); + for (let i = 0; i < 8; i++) { out[i] = Number(v & 0xffn); v >>= 8n; } + return this.write(out); + } + bytes(b) { return this.u64(b.length).write(b); } + str(s) { return this.bytes(enc.encode(s)); } + // Currency is a 128-bit little-endian pair (lo, hi). + currency(hastings) { + const v = BigInt(hastings); + if (v < 0n || v >= (1n << 128n)) throw new Error("currency out of range"); + return this.u64(v & ((1n << 64n) - 1n)).u64(v >> 64n); + } + bytesOut() { + const out = new Uint8Array(this.length); let o = 0; + for (const p of this.parts) { out.set(p, o); o += p.length; } + return out; + } + } + const SPECIFIER_ED25519 = (() => { const s = new Uint8Array(16); s.set(enc.encode("ed25519")); return s; })(); + + // ---- addresses --------------------------------------------------------------- + const LEAF = 0, NODE = 1; + const sumPair = (a, b) => b256(Uint8Array.from([NODE, ...a, ...b])); + const leaf = (bytes) => b256(Uint8Array.from([LEAF, ...bytes])); + // Merkle root of the standard UnlockConditions {timelock 0, [pk], sigs 1}. + function standardUnlockHash(pk) { + const timelockHash = leaf(new Encoder().u64(0).bytesOut()); + const keyHash = leaf(new Encoder().write(SPECIFIER_ED25519).bytes(pk).bytesOut()); + const sigsHash = leaf(new Encoder().u64(1).bytesOut()); + return sumPair(sumPair(timelockHash, keyHash), sigsHash); + } + const addressString = (addr32) => toHex(addr32) + toHex(b256(addr32).slice(0, 6)); + function parseAddress(s) { + const t = String(s || "").trim().toLowerCase().replace(/^addr:/, ""); + if (!/^[0-9a-f]{76}$/.test(t)) throw new Error("address must be 76 hex characters"); + const raw = fromHex(t); + const body = raw.slice(0, 32); + if (toHex(b256(body).slice(0, 6)) !== toHex(raw.slice(32))) throw new Error("address checksum is wrong"); + return { bytes: body, address: t }; + } + + // ---- keys -------------------------------------------------------------------- + // walletd: key_i = ed25519 seed blake2b(seed32 || index u64le). + function keyFromSeed(seed32, index) { + const priv = b256(new Encoder().write(seed32).u64(index).bytesOut()); + const pub = ed25519.getPublicKey(priv); + return { priv, pub, address32: standardUnlockHash(pub), address: addressString(standardUnlockHash(pub)) }; + } + const sign = (priv, msg) => ed25519.sign(msg, priv); + const verify = (pub, msg, sig) => ed25519.verify(sig, msg, pub); + + // ---- v2 transactions --------------------------------------------------------- + // tx: { inputs: [{ parentId(hex) }], outputs: [{ value(BigInt), address32 }], minerFee(BigInt) } + // Sig hash = blake2b("sia/sig/input|" || 0x02 || V2TransactionSemantics). + function inputSigHash(tx) { + const e = new Encoder().write(enc.encode("sia/sig/input|")).u8(2); + e.u64(tx.inputs.length); + for (const i of tx.inputs) e.write(fromHex(i.parentId)); + e.u64(tx.outputs.length); + for (const o of tx.outputs) e.currency(o.value).write(o.address32); + e.u64(0).u64(0); // siafund inputs / outputs + e.u64(0).u64(0).u64(0); // contracts, revisions, resolutions + e.u64(0); // attestations + e.bytes(new Uint8Array(0)); // arbitrary data + e.bool(false); // new foundation address + e.currency(tx.minerFee); + return b256(e.bytesOut()); + } + // Weight = length of the full V2Transaction encoding (fees are per byte). + // Signed inputs carry the parent element with its Merkle proof, the policy + // and one 64-byte signature. + function weight(tx) { + const e = new Encoder().u8(2); + let fields = 0; + if (tx.inputs.length) fields |= 1; if (tx.outputs.length) fields |= 2; if (tx.minerFee > 0n) fields |= 1 << 10; + e.u64(fields); + if (tx.inputs.length) { + e.u64(tx.inputs.length); + for (const i of tx.inputs) { + e.u64(i.leafIndex || 0).u64((i.merkleProof || []).length); + for (const p of i.merkleProof || []) e.write(fromHex(p)); + e.write(fromHex(i.parentId)).currency(i.value).write(i.address32).u64(i.maturityHeight || 0); + // SatisfiedPolicy: version 1, op 7 (unlock conditions), uc, 1 sig, 0 preimages + e.u8(1).u8(7).u64(0).u64(1).write(SPECIFIER_ED25519).bytes(i.pub).u64(1); + e.u64(1).write(new Uint8Array(64)).u64(0); + } + } + if (tx.outputs.length) { e.u64(tx.outputs.length); for (const o of tx.outputs) e.currency(o.value).write(o.address32); } + if (tx.minerFee > 0n) e.currency(tx.minerFee); + return e.length; + } + // walletd JSON for /api/txpool/broadcast. + function toJson(tx, sigs) { + return { + siacoinInputs: tx.inputs.map((i, k) => ({ + parent: i.element, + satisfiedPolicy: { + policy: { type: "uc", policy: { timelock: 0, publicKeys: ["ed25519:" + toHex(i.pub)], signaturesRequired: 1 } }, + signatures: [toHex(sigs[k])], + }, + })), + siacoinOutputs: tx.outputs.map((o) => ({ value: o.value.toString(), address: addressString(o.address32) })), + minerFee: tx.minerFee.toString(), + }; + } + + // ---- units ------------------------------------------------------------------- + const HASTINGS_PER_SC = 10n ** 24n; + function formatSC(hastings, decimals = 6) { + const v = BigInt(hastings); const neg = v < 0n; const a = neg ? -v : v; + const whole = a / HASTINGS_PER_SC; + let frac = (a % HASTINGS_PER_SC).toString().padStart(24, "0").slice(0, decimals).replace(/0+$/, ""); + if (frac.length < 2) frac = frac.padEnd(2, "0"); + return (neg ? "-" : "") + whole.toString() + "." + frac; + } + function parseSC(text) { + const s = String(text || "").trim().replace(/,/g, ""); + if (!/^\d*(\.\d*)?$/.test(s) || s === "" || s === ".") throw new Error("amount must be a number"); + const [w = "0", f = ""] = s.split("."); + if (f.length > 24) throw new Error("too many decimals"); + return BigInt(w || "0") * HASTINGS_PER_SC + BigInt((f + "0".repeat(24)).slice(0, 24)); + } + + return { Encoder, standardUnlockHash, addressString, parseAddress, keyFromSeed, sign, verify, inputSigHash, weight, toJson, formatSC, parseSC, HASTINGS_PER_SC, toHex, fromHex, b256 }; +}; diff --git a/bundled-addons/aegis/lib/sia/wallet.js b/bundled-addons/aegis/lib/sia/wallet.js new file mode 100644 index 00000000..a829237b --- /dev/null +++ b/bundled-addons/aegis/lib/sia/wallet.js @@ -0,0 +1,201 @@ +// Sia wallet state: address discovery, balance, history and v2 sends over a +// walletd client. Keys are the addon's derived key tree; nothing here touches +// UI or IPC. Amounts are BigInt hastings throughout. +module.exports = function makeWallet({ client, keys, sia, storage, log = () => {}, onChange = () => {} }) { + const GAP = 10; + const HISTORY_LIMIT = 25; + const state = { + used: new Set(), // indexes with any event + height: 0, + balance: { confirmed: 0n, immature: 0n }, + outputs: [], // spendable SiacoinElements with { index } + basis: null, + history: [], + receiveIndex: 0, + scanning: false, + error: null, + feePerByte: 0n, + }; + // Outputs we just spent stay hidden until walletd stops listing them. + const pendingSpent = new Map(); // id -> timestamp + + async function isUsed(entry) { + const ev = await client.events(entry.address, 1, 0); + return Array.isArray(ev) && ev.length > 0; + } + async function scan() { + const cursor = Number(storage.get("receiveCursor", 0)) || 0; + let gap = 0, i = 0; + while (gap < GAP || i < cursor + GAP) { + const e = keys.entry(i); + if (await isUsed(e)) { state.used.add(i); gap = 0; } else gap++; + i++; + } + let r = cursor; + while (state.used.has(r)) r++; + state.receiveIndex = r; + } + function watched() { + const idx = new Set(state.used); idx.add(state.receiveIndex); + return [...idx].map((i) => keys.entry(i)); + } + + async function loadOutputs() { + const tip = await client.tip(); + state.height = tip.height || 0; + let confirmed = 0n, immature = 0n; const outs = []; let basis = null; + for (const e of watched()) { + for (let offset = 0; ; offset += 100) { + const r = await client.outputs(e.address, 100, offset); + basis = r.basis || basis; + const list = Array.isArray(r.outputs) ? r.outputs : []; + for (const o of list) { + const v = BigInt(o.siacoinOutput.value); + if (o.maturityHeight > state.height) { immature += v; continue; } + if (pendingSpent.has(o.id)) continue; + confirmed += v; + outs.push({ element: o, id: o.id, value: v, entry: e }); + } + if (list.length < 100) break; + } + } + for (const [id, t] of pendingSpent) if (Date.now() - t > 20 * 60 * 1000) pendingSpent.delete(id); + state.outputs = outs; state.basis = basis; + state.balance = { confirmed, immature }; + try { state.feePerByte = await client.feePerByte(); } catch (e) { log("fee lookup failed:", e?.message); } + } + + // One row per event: net change for our addresses, type, confirmations. + async function loadHistory() { + const ours = new Set(watched().map((e) => e.address)); + const seen = new Map(); + for (const e of watched()) { + if (!state.used.has(e.index)) continue; + const evs = await client.events(e.address, HISTORY_LIMIT, 0); + for (const ev of Array.isArray(evs) ? evs : []) if (!seen.has(ev.id)) seen.set(ev.id, ev); + } + const rows = []; + for (const ev of seen.values()) { + let received = 0n, spent = 0n, to = null; + const d = ev.data || {}; + const type = String(ev.type || "").toLowerCase(); + if (type === "v2transaction" && d.transaction) { + for (const i of d.transaction.siacoinInputs || []) if (ours.has(i.parent.siacoinOutput.address)) spent += BigInt(i.parent.siacoinOutput.value); + for (const o of d.transaction.siacoinOutputs || []) { if (ours.has(o.address)) received += BigInt(o.value); else if (!to) to = o.address; } + } else if (type === "v1transaction" && d.transaction) { + for (const s of d.spentSiacoinElements || []) if (ours.has(s.siacoinOutput.address)) spent += BigInt(s.siacoinOutput.value); + for (const o of d.transaction.siacoinOutputs || []) { if (ours.has(o.address)) received += BigInt(o.value); else if (!to) to = o.address; } + } else if (d.siacoinElement) { + if (ours.has(d.siacoinElement.siacoinOutput.address)) received += BigInt(d.siacoinElement.siacoinOutput.value); + } + rows.push({ + id: ev.id, type: ev.type, height: ev.index ? ev.index.height : 0, confirmations: ev.confirmations || 0, + time: ev.timestamp ? Math.floor(Date.parse(ev.timestamp) / 1000) : 0, + delta: (received - spent).toString(), to: spent > received ? to : null, + maturityHeight: ev.maturityHeight || 0, + }); + } + rows.sort((a, b) => (b.height || Infinity) - (a.height || Infinity) || b.time - a.time); + state.history = rows.slice(0, HISTORY_LIMIT); + } + + async function refresh(full = false) { + if (state.scanning) return; + state.scanning = true; state.error = null; onChange(); + try { + if (full || !state.used.size && state.receiveIndex === 0) await scan(); + else { let r = Number(storage.get("receiveCursor", 0)) || 0; while (state.used.has(r)) r++; state.receiveIndex = r; } + await loadOutputs(); + await loadHistory(); + for (const o of state.outputs) state.used.add(o.entry.index); + let r = Number(storage.get("receiveCursor", 0)) || 0; + while (state.used.has(r)) r++; + state.receiveIndex = r; + } catch (e) { + state.error = e?.message || String(e); + log("refresh failed:", state.error); + } finally { state.scanning = false; onChange(); } + } + let pollTimer = null; + function startPolling(ms = 60000) { stopPolling(); pollTimer = setInterval(() => refresh(false), ms); } + function stopPolling() { clearInterval(pollTimer); pollTimer = null; } + + function current() { return keys.entry(state.receiveIndex); } + function nextUnusedAddress() { + let r = state.receiveIndex + 1; + while (state.used.has(r)) r++; + storage.set("receiveCursor", r); + state.receiveIndex = r; + onChange(); + return current(); + } + + // targets: [{ to, value: BigInt }]; feeMultiplier 1-3 over walletd's rate. + function plan({ targets, feeMultiplier = 1, sendMax = false }) { + const mult = BigInt(Math.min(3, Math.max(1, Math.round(Number(feeMultiplier) || 1)))); + const rate = state.feePerByte > 0n ? state.feePerByte * mult : 10n ** 19n * mult; + const outs = targets.map((t) => { + const a = sia.parseAddress(t.to); + return { value: BigInt(t.value || 0), address32: a.bytes, to: a.address }; + }); + const sorted = state.outputs.slice().sort((a, b) => (b.value > a.value ? 1 : b.value < a.value ? -1 : 0)); + const total = sorted.reduce((a, o) => a + o.value, 0n); + const change = current(); + const txOf = (inputs, outputs, fee) => ({ + inputs: inputs.map((o) => ({ + parentId: o.id, element: o.element, value: o.value, address32: o.entry.address32, pub: o.entry.pub, + leafIndex: o.element.stateElement.leafIndex, merkleProof: o.element.stateElement.merkleProof || [], maturityHeight: o.element.maturityHeight || 0, entry: o.entry, + })), + outputs, minerFee: fee, + }); + if (sendMax) { + if (outs.length !== 1) throw new Error("send max needs exactly one recipient"); + if (!sorted.length) throw new Error("no spendable balance"); + let fee = 0n; + for (let k = 0; k < 3; k++) fee = rate * BigInt(sia.weight(txOf(sorted, [{ value: total - fee, address32: outs[0].address32 }], fee))); + if (total <= fee) throw new Error("balance does not cover the fee"); + const tx = txOf(sorted, [{ value: total - fee, address32: outs[0].address32 }], fee); + return { tx, fee, recipients: [{ to: outs[0].to, value: (total - fee).toString() }], change: 0n, feePerByte: rate }; + } + const want = outs.reduce((a, o) => a + o.value, 0n); + for (const o of outs) if (o.value <= 0n) throw new Error("amount must be positive"); + const chosen = []; let sum = 0n; + for (const o of sorted) { + chosen.push(o); sum += o.value; + const withChange = [...outs, { value: 0n, address32: change.address32 }]; + const fee = rate * BigInt(sia.weight(txOf(chosen, withChange, 1n))); + if (sum >= want + fee) { + const rest = sum - want - fee; + const outputs = rest > 0n ? [...outs, { value: rest, address32: change.address32 }] : outs.slice(); + const tx = txOf(chosen, outputs, fee); + return { tx, fee, recipients: outs.map((o) => ({ to: o.to, value: o.value.toString() })), change: rest, feePerByte: rate }; + } + } + throw new Error("insufficient funds"); + } + + async function signAndBroadcast(p) { + const h = sia.inputSigHash(p.tx); + const sigs = p.tx.inputs.map((i) => keys.sign(i.entry, h)); + const json = sia.toJson(p.tx, sigs); + if (!state.basis) throw new Error("no chain basis for the outputs; refresh first"); + const r = await client.broadcast(state.basis, json); + const txid = r && r.v2transactions && r.v2transactions[0] && r.v2transactions[0].id; + for (const i of p.tx.inputs) pendingSpent.set(i.parentId, Date.now()); + log("broadcast", txid || "(no id returned)"); + setTimeout(() => refresh(false), 3000); + return { txid: txid || null, fee: p.fee.toString() }; + } + + function snapshot() { + const cur = current(); + return { + address: cur.address, addressIndex: state.receiveIndex, + balance: { confirmed: state.balance.confirmed.toString(), immature: state.balance.immature.toString() }, + height: state.height, history: state.history, outputCount: state.outputs.length, + feePerByte: state.feePerByte.toString(), scanning: state.scanning, error: state.error, + }; + } + function dispose() { stopPolling(); } + return { refresh, snapshot, nextUnusedAddress, current, plan, signAndBroadcast, startPolling, dispose, state }; +}; diff --git a/bundled-addons/aegis/lib/sia/walletd.js b/bundled-addons/aegis/lib/sia/walletd.js new file mode 100644 index 00000000..da489cd6 --- /dev/null +++ b/bundled-addons/aegis/lib/sia/walletd.js @@ -0,0 +1,42 @@ +// Thin client for the walletd HTTP API (go.sia.tech/walletd, index mode +// "full"). Only address-scoped reads plus txpool fee/broadcast are used, so +// any public or self-hosted walletd works; the URL is a user setting. +module.exports = function makeWalletd({ log = () => {} }) { + class Client { + constructor(baseUrl) { this.setBase(baseUrl); } + setBase(baseUrl) { + const u = String(baseUrl || "").trim().replace(/\/+$/, ""); + this.base = u ? (u.endsWith("/api") ? u : u + "/api") : ""; + } + // Everything after the host is the node's business; only the origin is + // ever logged or shown, since hosted providers key access on the path. + get displayUrl() { try { return new URL(this.base).origin; } catch { return this.base; } } + async _req(method, path, body) { + if (!this.base) throw new Error("no walletd URL configured"); + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 25000); + try { + const res = await fetch(this.base + path, { + method, signal: ctrl.signal, + headers: body !== undefined ? { "content-type": "application/json" } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`walletd ${res.status}: ${text.slice(0, 200).trim()}`); + try { return JSON.parse(text); } catch { return text; } + } finally { clearTimeout(timer); } + } + get(path) { return this._req("GET", path); } + post(path, body) { return this._req("POST", path, body); } + + tip() { return this.get("/consensus/tip"); } + // Recommended fee in hastings per byte (JSON string). + async feePerByte() { return BigInt(String(await this.get("/txpool/fee")).replace(/"/g, "")); } + balance(addr) { return this.get(`/addresses/${addr}/balance`); } + // { basis, outputs: [SiacoinElement] } — proofs are valid at `basis`. + outputs(addr, limit = 100, offset = 0) { return this.get(`/addresses/${addr}/outputs/siacoin?limit=${limit}&offset=${offset}`); } + events(addr, limit = 25, offset = 0) { return this.get(`/addresses/${addr}/events?limit=${limit}&offset=${offset}`); } + broadcast(basis, v2tx) { return this.post("/txpool/broadcast", { basis, transactions: [], v2transactions: [v2tx] }); } + } + return { Client }; +}; diff --git a/bundled-addons/aegis/lib/sol-spl.js b/bundled-addons/aegis/lib/sol-spl.js new file mode 100644 index 00000000..0b18fa98 --- /dev/null +++ b/bundled-addons/aegis/lib/sol-spl.js @@ -0,0 +1,220 @@ +// Solana Program Library (SPL) token primitives — PDA derivation, +// Associated Token Account math, and the two Token-program instructions +// this wallet needs at the bytecode level: `TransferChecked` (send SPL +// with a decimals sanity check) and `CreateAssociatedTokenAccountIdempotent` +// (make the receiver's token account inline, so the user doesn't have to +// pre-create it on any address they've never seen before). +// +// Every account address on Solana is a 32-byte ed25519 public key. A +// Program-Derived Address (PDA) is a 32-byte value that is NOT on the +// ed25519 curve — the runtime uses that fact as proof that no one holds +// its private key, so only the owning program can spend from it. To +// derive a PDA we sha256(seeds || programId || bump || "ProgramDerivedAddress") +// for bump = 255…0 and pick the first value that isn't a valid curve +// point. `isOnCurve` here defers to @noble/curves/ed25519's ExtendedPoint, +// which throws on invalid points; everything that decodes is on-curve. + +const PDA_MARKER = new TextEncoder().encode("ProgramDerivedAddress"); + +module.exports = function makeSolSpl({ ed25519, sha256, base58 }) { + if (!ed25519 || !sha256 || !base58) throw new Error("sol-spl: missing dep"); + + // ---- constants ------------------------------------------------------- + const TOKEN_PROGRAM_ID_B58 = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + const ASSOC_PROGRAM_ID_B58 = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"; + const TOKEN_2022_PROGRAM_ID_B58 = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"; + const TOKEN_PROGRAM_ID = base58.decode(TOKEN_PROGRAM_ID_B58); + const ASSOC_PROGRAM_ID = base58.decode(ASSOC_PROGRAM_ID_B58); + const TOKEN_2022_PROGRAM_ID = base58.decode(TOKEN_2022_PROGRAM_ID_B58); + const SYSTEM_PROGRAM_ID = new Uint8Array(32); + + // Known-token registry — just enough to give the panel a sensible label + // for the tokens users actually see day-to-day. Everything else falls + // back to the mint address itself (truncated in the UI). + const KNOWN_TOKENS = { + mainnet: { + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": { symbol: "USDC", decimals: 6, name: "USD Coin" }, + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB": { symbol: "USDT", decimals: 6, name: "Tether USD" }, + "So11111111111111111111111111111111111111112": { symbol: "wSOL", decimals: 9, name: "Wrapped SOL" }, + }, + devnet: { + "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU": { symbol: "USDC", decimals: 6, name: "USD Coin (devnet)" }, + }, + }; + + // ---- ed25519 curve check -------------------------------------------- + // A 32-byte pubkey is "on curve" if it decompresses to a valid Edwards + // point. @noble/curves v2 exposes Point.fromBytes(bytes) (throws on + // invalid); older versions exposed ExtendedPoint.fromHex(hex-string) + // — try each in order. + function isOnCurve(pubkey32) { + const P = ed25519.Point || ed25519.ExtendedPoint; + if (!P) return true; // no curve access — treat every value as + // on-curve (over-conservative; PDA loop falls + // through more than it should but never gets + // wrong). + try { + if (typeof P.fromBytes === "function") { P.fromBytes(pubkey32); return true; } + if (typeof P.fromHex === "function") { + const hex = Array.from(pubkey32, (b) => b.toString(16).padStart(2, "0")).join(""); + P.fromHex(hex); return true; + } + } catch { return false; } + return true; + } + + const concat = (...ps) => { + const n = ps.reduce((a, p) => a + p.length, 0); + const out = new Uint8Array(n); let k = 0; + for (const p of ps) { out.set(p, k); k += p.length; } + return out; + }; + const u64le = (n) => { + let v = BigInt(n); const o = new Uint8Array(8); + for (let i = 0; i < 8; i++) { o[i] = Number(v & 0xffn); v >>= 8n; } + return o; + }; + const encodeCompactU16 = (n) => { + const out = []; + let rem = n; + while (true) { + let byte = rem & 0x7f; rem >>= 7; + if (rem === 0) { out.push(byte); break; } + byte |= 0x80; out.push(byte); + } + return Uint8Array.from(out); + }; + + // ---- PDA / ATA ------------------------------------------------------- + function findProgramAddress(seeds, programId) { + for (let bump = 255; bump >= 0; bump--) { + const material = concat( + ...seeds.map((s) => Uint8Array.from(s)), + Uint8Array.from([bump]), + programId, + PDA_MARKER, + ); + const candidate = sha256(material); + if (!isOnCurve(candidate)) return { address: candidate, bump }; + } + throw new Error("no PDA found (unreachable)"); + } + // Standard ATA = PDA under ASSOC_PROGRAM_ID with seeds + // [ownerPubkey, TOKEN_PROGRAM_ID, mint]. + // We match the seed layout the @solana/spl-token library uses so + // addresses agree with any wallet or block explorer. + function associatedTokenAddress(owner, mint, tokenProgram = TOKEN_PROGRAM_ID) { + return findProgramAddress([owner, tokenProgram, mint], ASSOC_PROGRAM_ID).address; + } + + // ---- instruction encoders ------------------------------------------- + // SPL Token: TransferChecked (discriminator 12) — asserts amount+decimals + // against the mint so a UI bug can't move 1000× the intended value. + // accounts: [sourceATA (writable), mint (readonly), destATA (writable), owner (signer)] + // data: [12, amount:u64_le, decimals:u8] + function transferCheckedInstruction({ sourceATA, mint, destATA, owner, amount, decimals, tokenProgram = TOKEN_PROGRAM_ID }) { + return { + programId: tokenProgram, + keys: [ + { pubkey: sourceATA, isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: destATA, isSigner: false, isWritable: true }, + { pubkey: owner, isSigner: true, isWritable: false }, + ], + data: concat(Uint8Array.from([12]), u64le(amount), Uint8Array.from([decimals])), + }; + } + // Associated Token Account program: CreateIdempotent (discriminator 1) + // accounts: [payer(signer,writable), ata(writable), owner(readonly), + // mint(readonly), systemProgram(readonly), tokenProgram(readonly)] + // data: [1] (idempotent variant — no-op if the account exists) + function createATAIdempotentInstruction({ payer, ata, owner, mint, tokenProgram = TOKEN_PROGRAM_ID }) { + return { + programId: ASSOC_PROGRAM_ID, + keys: [ + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: ata, isSigner: false, isWritable: true }, + { pubkey: owner, isSigner: false, isWritable: false }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: SYSTEM_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: tokenProgram, isSigner: false, isWritable: false }, + ], + data: Uint8Array.from([1]), + }; + } + + // ---- transaction message builder ------------------------------------ + // Assembles the raw Solana message bytes for a single-fee-payer, single- + // signer transaction that may carry multiple instructions. Account keys + // are sorted per Solana's account-classification rules (writable-signed, + // readonly-signed, writable-unsigned, readonly-unsigned). + function buildMessage({ feePayer, instructions, recentBlockhash }) { + // 1. Collect every unique pubkey mentioned across instructions + + // include the fee payer + include each instruction's programId. + const keys = new Map(); // b58 → { pubkey, isSigner, isWritable } + const upsert = (pubkey, isSigner, isWritable) => { + const k = base58.encode(pubkey); + const cur = keys.get(k) || { pubkey, isSigner: false, isWritable: false }; + cur.isSigner = cur.isSigner || isSigner; + cur.isWritable = cur.isWritable || isWritable; + keys.set(k, cur); + }; + upsert(feePayer, true, true); + for (const ins of instructions) { + for (const k of ins.keys) upsert(k.pubkey, k.isSigner, k.isWritable); + upsert(ins.programId, false, false); + } + // 2. Classify + sort into the four buckets. + const bucket = { ws: [], rs: [], wu: [], ru: [] }; + for (const v of keys.values()) { + if (v.isSigner && v.isWritable) bucket.ws.push(v); + else if (v.isSigner) bucket.rs.push(v); + else if (v.isWritable) bucket.wu.push(v); + else bucket.ru.push(v); + } + // Fee payer MUST be at index 0 (Solana requires the first signer to + // be writable & to pay the fee). + const payerB58 = base58.encode(feePayer); + bucket.ws.sort((a, b) => (base58.encode(a.pubkey) === payerB58 ? -1 : base58.encode(b.pubkey) === payerB58 ? 1 : 0)); + const ordered = [...bucket.ws, ...bucket.rs, ...bucket.wu, ...bucket.ru]; + // 3. Encode the message. + const header = Uint8Array.from([ + bucket.ws.length + bucket.rs.length, // numRequiredSignatures + bucket.rs.length, // numReadonlySignedAccounts + bucket.ru.length, // numReadonlyUnsignedAccounts + ]); + const keysSection = concat( + encodeCompactU16(ordered.length), + ...ordered.map((v) => Uint8Array.from(v.pubkey)), + ); + const indexOf = new Map(ordered.map((v, i) => [base58.encode(v.pubkey), i])); + const insSection = concat( + encodeCompactU16(instructions.length), + ...instructions.map((ins) => { + const programIndex = indexOf.get(base58.encode(ins.programId)); + const accountBytes = Uint8Array.from(ins.keys.map((k) => indexOf.get(base58.encode(k.pubkey)))); + return concat( + Uint8Array.from([programIndex]), + encodeCompactU16(accountBytes.length), + accountBytes, + encodeCompactU16(ins.data.length), + ins.data, + ); + }), + ); + return concat(header, keysSection, recentBlockhash, insSection); + } + + return { + // constants + TOKEN_PROGRAM_ID, ASSOC_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, SYSTEM_PROGRAM_ID, + TOKEN_PROGRAM_ID_B58, ASSOC_PROGRAM_ID_B58, + KNOWN_TOKENS, + // helpers + isOnCurve, findProgramAddress, associatedTokenAddress, + // instruction encoders + transferCheckedInstruction, createATAIdempotentInstruction, + // tx assembly + buildMessage, + }; +}; diff --git a/bundled-addons/aegis/lib/tx.js b/bundled-addons/aegis/lib/tx.js new file mode 100644 index 00000000..d903da67 --- /dev/null +++ b/bundled-addons/aegis/lib/tx.js @@ -0,0 +1,122 @@ +// Transaction building for P2PKH spends on Bitcoin Cash: serialization, the +// BIP143-style replay-protected sighash (SIGHASH_ALL | FORKID), coin +// selection and fee estimation. Signing itself is delegated to WalletKeys so +// private keys stay in one module. +module.exports = function makeTx({ sha256 }) { + const SIGHASH_ALL_FORKID = 0x41; + const DUST = 546; + const P2PKH_INPUT_SIZE = 149; // outpoint 36 + len 1 + sig push 74 + pubkey push 34 + sequence 4 + const P2PKH_OUTPUT_SIZE = 34; + const OVERHEAD = 10; // version 4 + in/out counts 2 + locktime 4 + + const dsha = (b) => sha256(sha256(b)); + const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + const fromHex = (h) => Uint8Array.from(h.match(/../g) || [], (x) => parseInt(x, 16)); + const concat = (...parts) => { + const n = parts.reduce((a, p) => a + p.length, 0); + const out = new Uint8Array(n); let o = 0; + for (const p of parts) { out.set(p, o); o += p.length; } + return out; + }; + const u32le = (n) => Uint8Array.from([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]); + const u64le = (n) => { + let v = BigInt(n); const out = new Uint8Array(8); + for (let i = 0; i < 8; i++) { out[i] = Number(v & 0xffn); v >>= 8n; } + return out; + }; + const varint = (n) => { + if (n < 0xfd) return Uint8Array.from([n]); + if (n <= 0xffff) return Uint8Array.from([0xfd, n & 0xff, n >> 8]); + return concat(Uint8Array.from([0xfe]), u32le(n)); + }; + const varbytes = (b) => concat(varint(b.length), b); + const pushdata = (b) => { + if (b.length < 0x4c) return concat(Uint8Array.from([b.length]), b); + return concat(Uint8Array.from([0x4c, b.length]), b); + }; + const outpoint = (inp) => concat(fromHex(inp.txid).reverse(), u32le(inp.vout)); + + const estimateSize = (nIn, nOut) => OVERHEAD + nIn * P2PKH_INPUT_SIZE + nOut * P2PKH_OUTPUT_SIZE; + const feeFor = (nIn, nOut, satPerByte) => Math.ceil(estimateSize(nIn, nOut) * satPerByte); + + // inputs: [{ txid, vout, value, script(Uint8Array), sig?(Uint8Array) }] + // outputs: [{ value, script(Uint8Array) }] + function serialize(tx) { + return concat( + u32le(tx.version ?? 2), + varint(tx.inputs.length), + ...tx.inputs.map((i) => concat(outpoint(i), varbytes(i.unlocking || new Uint8Array(0)), u32le(i.sequence ?? 0xffffffff))), + varint(tx.outputs.length), + ...tx.outputs.map((o) => concat(u64le(o.value), varbytes(o.script))), + u32le(tx.locktime ?? 0), + ); + } + + function sighash(tx, index) { + const inp = tx.inputs[index]; + const hashPrevouts = dsha(concat(...tx.inputs.map(outpoint))); + const hashSequence = dsha(concat(...tx.inputs.map((i) => u32le(i.sequence ?? 0xffffffff)))); + const hashOutputs = dsha(concat(...tx.outputs.map((o) => concat(u64le(o.value), varbytes(o.script))))); + const preimage = concat( + u32le(tx.version ?? 2), + hashPrevouts, hashSequence, + outpoint(inp), + varbytes(inp.script), + u64le(inp.value), + u32le(inp.sequence ?? 0xffffffff), + hashOutputs, + u32le(tx.locktime ?? 0), + u32le(SIGHASH_ALL_FORKID), + ); + return dsha(preimage); + } + + // signer(input, index, digest) -> { sig: DER bytes, publicKey } + function sign(tx, signer) { + tx.inputs.forEach((inp, i) => { + const { sig, publicKey } = signer(inp, i, sighash(tx, i)); + inp.unlocking = concat(pushdata(concat(sig, Uint8Array.from([SIGHASH_ALL_FORKID]))), pushdata(publicKey)); + }); + const raw = serialize(tx); + return { raw, hex: toHex(raw), txid: toHex(dsha(raw).reverse()) }; + } + + // Largest-first accumulation. `targets` = [{ value, script }]; returns + // { inputs, outputs, fee, change } or throws when funds don't cover it. + // sendMax: spend every UTXO into targets[0] and no change. + function select(utxos, targets, satPerByte, changeScript, { sendMax = false } = {}) { + const sorted = utxos.slice().sort((a, b) => b.value - a.value); + const total = sorted.reduce((a, u) => a + u.value, 0); + if (sendMax) { + if (targets.length !== 1) throw new Error("send max needs exactly one recipient"); + const fee = feeFor(sorted.length, 1, satPerByte); + const value = total - fee; + if (!sorted.length || value < DUST) throw new Error("balance too small to send"); + return { inputs: sorted, outputs: [{ value, script: targets[0].script }], fee, change: 0 }; + } + const want = targets.reduce((a, t) => a + t.value, 0); + for (const t of targets) if (t.value < DUST) throw new Error(`amount below dust limit (${DUST} sat)`); + const chosen = []; let sum = 0; + for (const u of sorted) { + chosen.push(u); sum += u.value; + const feeWithChange = feeFor(chosen.length, targets.length + 1, satPerByte); + if (sum >= want + feeWithChange) { + const change = sum - want - feeWithChange; + if (change >= DUST) { + return { inputs: chosen, outputs: [...targets, { value: change, script: changeScript }], fee: feeWithChange, change }; + } + // Change would be dust: fold it into the fee, one output fewer. + const fee = sum - want; + return { inputs: chosen, outputs: targets.slice(), fee, change: 0 }; + } + const feeNoChange = feeFor(chosen.length, targets.length, satPerByte); + if (sum >= want + feeNoChange && sum - want - feeNoChange < DUST) { + return { inputs: chosen, outputs: targets.slice(), fee: sum - want, change: 0 }; + } + } + const short = want + feeFor(Math.max(1, sorted.length), targets.length + 1, satPerByte) - total; + throw new Error(`insufficient funds: need about ${short} more sat`); + } + + return { SIGHASH_ALL_FORKID, DUST, serialize, sighash, sign, select, feeFor, estimateSize, toHex, fromHex, dsha }; +}; diff --git a/bundled-addons/aegis/lib/wallet.js b/bundled-addons/aegis/lib/wallet.js new file mode 100644 index 00000000..16407c5d --- /dev/null +++ b/bundled-addons/aegis/lib/wallet.js @@ -0,0 +1,243 @@ +// Wallet state machine on top of an electrum client and a WalletKeys tree: +// address discovery (gap limit), balance, history with per-tx deltas, UTXO +// set and send construction. Knows nothing about UI or IPC. +module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, storage, log = () => {}, onChange = () => {} }) { + const GAP = 20; + const HISTORY_LIMIT = 25; + const sats = (bch) => Math.round(Number(bch) * 1e8); + + const state = { + used: new Set(), // "branch/index" with history + watched: new Map(), // scripthash -> entry + height: 0, + balance: { confirmed: 0, unconfirmed: 0 }, + utxos: [], // { txid, vout, value, height, entry } + history: [], // newest first + receiveIndex: 0, + scanning: false, + error: null, + }; + // Verbose transactions are public chain data; caching them on disk saves a + // round of fetches on every launch. + const txCache = storage.get("txCache", {}) || {}; + let refreshTimer = null; + let subscribedHeaders = false; + + function key(e) { return e.branch + "/" + e.index; } + function watch(e) { if (!state.watched.has(e.scripthash)) state.watched.set(e.scripthash, e); } + + async function historyOf(e) { + const h = await client.call("blockchain.scripthash.get_history", [e.scripthash]); + return Array.isArray(h) ? h : []; + } + + // Walk both branches until GAP consecutive unused indexes, always covering + // the user's chosen receive cursor so its lookahead stays subscribed. + async function scan() { + const cursor = Number(storage.get("receiveCursor", 0)) || 0; + for (const branch of [0, 1]) { + let gap = 0, i = 0; + const minIndex = branch === 0 ? cursor + 1 : 0; + while (gap < GAP || i < minIndex + GAP) { + const batch = []; + for (let k = 0; k < 10; k++) batch.push(keys.entry(branch, i + k)); + const results = await Promise.all(batch.map(historyOf)); + for (let k = 0; k < batch.length; k++) { + const e = batch[k]; watch(e); + if (results[k].length) { state.used.add(key(e)); gap = 0; } else gap++; + i++; + if (gap >= GAP && i >= minIndex + GAP) break; + } + } + } + // Current receive address: first unused at or after the cursor. + let r = cursor; + while (state.used.has("0/" + r)) r++; + state.receiveIndex = r; + watch(keys.entry(0, r)); + } + + async function subscribeAll() { + if (!subscribedHeaders) { + subscribedHeaders = true; + const tip = await client.subscribe("blockchain.headers.subscribe", []); + if (tip && tip.height) state.height = tip.height; + } + await Promise.all([...state.watched.values()].map((e) => + client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {}))); + } + + async function loadUtxos() { + const lists = await Promise.all([...state.watched.values()].map(async (e) => { + const u = await client.call("blockchain.scripthash.listunspent", [e.scripthash]); + return (Array.isArray(u) ? u : []).map((x) => ({ txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e })); + })); + state.utxos = lists.flat(); + let confirmed = 0, unconfirmed = 0; + for (const u of state.utxos) { if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; } + state.balance = { confirmed, unconfirmed }; + } + + async function getTx(txid) { + const c = txCache[txid]; + if (c && c.confirmations > 0) return c; + const raw = await client.call("blockchain.transaction.get", [txid, true]); + const slim = { + txid, + confirmations: raw.confirmations || 0, + time: raw.blocktime || raw.time || 0, + vin: (raw.vin || []).map((i) => ({ txid: i.txid, vout: i.vout })), + vout: (raw.vout || []).map((o) => ({ value: sats(o.value), scriptHex: o.scriptPubKey && o.scriptPubKey.hex })), + size: raw.size || 0, + }; + txCache[txid] = slim; + return slim; + } + + async function loadHistory() { + const entries = [...state.watched.values()].filter((e) => state.used.has(key(e))); + const merged = new Map(); + const lists = await Promise.all(entries.map(historyOf)); + for (const list of lists) for (const h of list) { + const prev = merged.get(h.tx_hash); + if (!prev || (h.height > 0 && prev.height <= 0)) merged.set(h.tx_hash, { txid: h.tx_hash, height: h.height }); + } + const ordered = [...merged.values()].sort((a, b) => { + const ha = a.height > 0 ? a.height : Infinity, hb = b.height > 0 ? b.height : Infinity; + return hb - ha; + }).slice(0, HISTORY_LIMIT); + const ours = new Set([...state.watched.values()].map((e) => e.scriptHex)); + const out = []; + for (const h of ordered) { + const t = await getTx(h.txid); + let received = 0, spent = 0, inputsTotal = 0, outputsTotal = 0, allInputsOurs = true; + for (const o of t.vout) { outputsTotal += o.value; if (ours.has(o.scriptHex)) received += o.value; } + for (const i of t.vin) { + if (!i.txid) continue; // coinbase + const p = await getTx(i.txid); + const po = p.vout[i.vout]; + if (!po) continue; + inputsTotal += po.value; + if (ours.has(po.scriptHex)) spent += po.value; else allInputsOurs = false; + } + const delta = received - spent; + let to = null; + if (delta < 0) { + const ext = t.vout.find((o) => !ours.has(o.scriptHex)); + if (ext && ext.scriptHex) to = scriptToAddress(ext.scriptHex); + } + out.push({ + txid: t.txid, height: h.height, confirmations: t.confirmations, time: t.time, + delta, fee: allInputsOurs && inputsTotal ? inputsTotal - outputsTotal : null, to, + }); + } + state.history = out; + storage.set("txCache", txCache); + } + + function scriptToAddress(scriptHex) { + try { + if (/^76a914[0-9a-f]{40}88ac$/.test(scriptHex)) return cashaddr.encode(keys.prefix, 0, tx.fromHex(scriptHex.slice(6, 46))); + if (/^a914[0-9a-f]{40}87$/.test(scriptHex)) return cashaddr.encode(keys.prefix, 1, tx.fromHex(scriptHex.slice(4, 44))); + } catch {} + return null; + } + + async function refresh(full = false) { + if (state.scanning) return; + state.scanning = true; state.error = null; onChange(); + try { + if (full || !state.watched.size) await scan(); + else { let r = Number(storage.get("receiveCursor", 0)) || 0; while (state.used.has("0/" + r)) r++; state.receiveIndex = r; watch(keys.entry(0, r)); } + await loadUtxos(); + await loadHistory(); + await subscribeAll(); + // A tx that just landed can mark the current receive address used. + for (const u of state.utxos) state.used.add(key(u.entry)); + let r = Number(storage.get("receiveCursor", 0)) || 0; + while (state.used.has("0/" + r)) r++; + if (r !== state.receiveIndex) { state.receiveIndex = r; watch(keys.entry(0, r)); } + } catch (e) { + state.error = e?.message || String(e); + log("refresh failed:", state.error); + } finally { + state.scanning = false; + onChange(); + } + } + function scheduleRefresh(ms = 800) { + clearTimeout(refreshTimer); + refreshTimer = setTimeout(() => refresh(false), ms); + } + + client.onNotify = (method, params) => { + if (method === "blockchain.headers.subscribe") { + const h = params && params[0] && params[0].height; + if (h) { state.height = h; scheduleRefresh(1500); } + } else if (method === "blockchain.scripthash.subscribe") { + scheduleRefresh(800); + } + }; + + function nextUnusedAddress() { + let r = state.receiveIndex + 1; + while (state.used.has("0/" + r)) r++; + storage.set("receiveCursor", r); + state.receiveIndex = r; + watch(keys.entry(0, r)); + client.subscribe("blockchain.scripthash.subscribe", [keys.entry(0, r).scripthash]).catch(() => {}); + onChange(); + return current(); + } + function current() { return keys.entry(0, state.receiveIndex); } + function changeEntry() { + let i = 0; + while (state.used.has("1/" + i)) i++; + return keys.entry(1, i); + } + + // targets: [{ to, value }] (value in sats; ignored for sendMax) -> unsigned plan. + function plan({ targets, feeRate = 1, sendMax = false }) { + const rate = Math.min(10, Math.max(1, Number(feeRate) || 1)); + const outs = targets.map((t) => { + const a = cashaddr.parseAny(t.to, sha256, keys.prefix); + const script = a.type === 0 + ? Uint8Array.from([0x76, 0xa9, 0x14, ...a.hash, 0x88, 0xac]) + : Uint8Array.from([0xa9, 0x14, ...a.hash, 0x87]); + return { value: Math.round(Number(t.value) || 0), script, to: a.cashaddr }; + }); + // Spend confirmed coins first; unconfirmed only when needed. + const spendable = state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0)); + const sel = tx.select(spendable, outs, rate, changeEntry().script, { sendMax }); + return { ...sel, feeRate: rate, recipients: outs.map((o, i) => ({ to: o.to, value: sel.outputs[i].value })) }; + } + + async function signAndBroadcast(p) { + const t = { inputs: p.inputs.map((u) => ({ ...u, script: u.entry.script })), outputs: p.outputs }; + const signed = tx.sign(t, (inp, _i, digest) => ({ sig: keys.sign(inp.entry, digest), publicKey: inp.entry.publicKey })); + const txid = await client.call("blockchain.transaction.broadcast", [signed.hex]); + if (typeof txid !== "string" || txid.length !== 64) throw new Error("broadcast rejected: " + JSON.stringify(txid)); + log("broadcast", txid); + scheduleRefresh(1200); + return { txid, hex: signed.hex, fee: p.fee }; + } + + function snapshot() { + const cur = current(); + return { + address: cur.address, + addressIndex: state.receiveIndex, + addressPath: cur.path, + balance: state.balance, + height: state.height, + history: state.history, + utxoCount: state.utxos.length, + scanning: state.scanning, + error: state.error, + }; + } + + function dispose() { clearTimeout(refreshTimer); } + + return { refresh, snapshot, nextUnusedAddress, current, plan, signAndBroadcast, dispose, state }; +}; diff --git a/bundled-addons/aegis/lib/wc-sign.js b/bundled-addons/aegis/lib/wc-sign.js new file mode 100644 index 00000000..5872afce --- /dev/null +++ b/bundled-addons/aegis/lib/wc-sign.js @@ -0,0 +1,95 @@ +// WizardConnect transaction signing for Aegis. +// +// The dapp hands us a full BCH transaction plus its source outputs. Per the +// WC protocol, we must sign every input with SIGHASH_ALL | FORKID | UTXOS. +// Any other sighash flag combination MUST be rejected (protocol/security). +// +// This module supports P2PKH inputs only. Contract inputs (a source output +// carrying a `contract` field) are rejected with a clear error — they need +// script-aware signing that Aegis's BCH runtime doesn't do today. + +// SIGHASH byte required for this protocol: SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS +// = 0x01 | 0x40 | 0x20 = 0x61. +const REQUIRED_SIGHASH = 0x61; + +function toHex(u8) { let s = ""; for (let i = 0; i < u8.length; i++) s += u8[i].toString(16).padStart(2, "0"); return s; } +function fromHex(h) { + const s = String(h || "").replace(/^0x/i, ""); + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16); + return out; +} + +function ensureTransaction(txOrHex, libauth) { + if (typeof txOrHex === "string") { + const dec = libauth.decodeTransactionCommon + ? libauth.decodeTransactionCommon(fromHex(txOrHex)) + : libauth.decodeTransaction(fromHex(txOrHex)); + if (typeof dec === "string") throw new Error(`wc-sign: bad tx hex — ${dec}`); + return dec; + } + return txOrHex; +} + +async function signTx({ request, account, branches, libauth, secp256k1 }) { + const { + generateSigningSerializationBCH, + hash256, encodeTransaction, + } = libauth; + + const tx = ensureTransaction(request.transaction, libauth); + const sourceOutputs = (request.sourceOutputs || []).map((o, i) => { + if (o.contract) throw new Error(`wc-sign: input ${i} spends a contract — unsupported`); + return { + lockingBytecode: o.lockingBytecode instanceof Uint8Array ? o.lockingBytecode : fromHex(o.lockingBytecode), + valueSatoshis: typeof o.valueSatoshis === "bigint" ? o.valueSatoshis : BigInt(o.valueSatoshis), + }; + }); + if (sourceOutputs.length !== tx.inputs.length) { + throw new Error(`wc-sign: sourceOutputs (${sourceOutputs.length}) ≠ inputs (${tx.inputs.length})`); + } + + const inputPathMap = new Map(); // inputIndex -> { branch, addressIndex } + for (const [inputIndex, pathName, addressIndex] of (request.inputPaths || [])) { + inputPathMap.set(Number(inputIndex), { pathName: String(pathName), addressIndex: Number(addressIndex) }); + } + + const signedInputs = tx.inputs.map((inp, i) => ({ ...inp })); + + for (let i = 0; i < tx.inputs.length; i++) { + const hint = inputPathMap.get(i); + if (!hint) throw new Error(`wc-sign: no path for input ${i}`); + const branch = branches[hint.pathName]; + if (!branch) throw new Error(`wc-sign: unknown path "${hint.pathName}"`); + const node = branch.deriveChild(hint.addressIndex); + + const preimage = generateSigningSerializationBCH({ + inputIndex: i, + signingSerializationType: new Uint8Array([REQUIRED_SIGHASH]), + sourceOutputs, + transaction: { ...tx, inputs: signedInputs }, + }); + const digest = hash256(preimage); + const sig = secp256k1.sign(digest, node.privateKey, { prehash: false, lowS: true, format: "der" }); + // signature || sighashType byte + const sigWithHash = new Uint8Array(sig.length + 1); + sigWithHash.set(sig, 0); sigWithHash[sig.length] = REQUIRED_SIGHASH; + + // P2PKH unlocking: <sig+hashtype> <pubkey> + const pushSig = new Uint8Array(1 + sigWithHash.length); + pushSig[0] = sigWithHash.length; + pushSig.set(sigWithHash, 1); + const pushPk = new Uint8Array(1 + node.publicKey.length); + pushPk[0] = node.publicKey.length; + pushPk.set(node.publicKey, 1); + + const unlocking = new Uint8Array(pushSig.length + pushPk.length); + unlocking.set(pushSig, 0); unlocking.set(pushPk, pushSig.length); + signedInputs[i].unlockingBytecode = unlocking; + } + + const encoded = encodeTransaction({ ...tx, inputs: signedInputs }); + return { signedTransaction: toHex(encoded) }; +} + +module.exports = { signTx, REQUIRED_SIGHASH }; diff --git a/bundled-addons/aegis/lib/wc.js b/bundled-addons/aegis/lib/wc.js new file mode 100644 index 00000000..41c03efd --- /dev/null +++ b/bundled-addons/aegis/lib/wc.js @@ -0,0 +1,196 @@ +// WizardConnect wallet-side bridge for Aegis. +// +// Aegis's BCH runtime acts as a WizardConnect wallet: sites we build (dapps) +// pair via a wiz:// URI, get xpubs for BCH derivation paths, and send us +// sign requests that we route through the existing approval-modal capability. +// +// LGPL boundary: @wizardconnect/{core,wallet} are dynamic-linked via +// api.import(); we do not statically embed them. Their sources live at +// https://github.com/whiterun-labs/wizardconnect (also on npm) and their +// LICENSE / copyright headers are shipped by npm inside the package. +// +// Docs: https://docs.riftenlabs.com/wizardconnect/ + +const WC_PATH_RECEIVE = "receive"; // m/44'/145'/0'/0 +const WC_PATH_CHANGE = "change"; // m/44'/145'/0'/1 +const WC_PATH_CAULDRON = "defi"; // m/44'/145'/0'/7 (BCH DEX ecosystem) + +const WALLET_ICON = "data:image/svg+xml;utf8," + encodeURIComponent( + `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'> + <polygon points='16,2 28,9 28,23 16,30 4,23 4,9' fill='#0a0a0d' stroke='#D6FF3D' stroke-width='1.6' stroke-linejoin='round'/> + <circle cx='16' cy='16' r='4.5' fill='none' stroke='#D6FF3D' stroke-width='1.4'/> + <circle cx='16' cy='16' r='1.6' fill='#D6FF3D'/> + </svg>` +); + +module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnectionManager, wcCore, libauth, log = () => {}, api, approvalRequest }) { + + // ---- WalletAdapter -------------------------------------------------------- + // + // Bound to one BCH runtime. Uses its 32-byte root to reproduce the account + // HDKey and to derive per-URI relay identities via HKDF, so reconnecting + // yields the same Nostr identity (dapp recognises us on reload). + function makeAdapter({ root32, accountPath, walletId, label }) { + const account = HDKey.fromMasterSeed(root32).derive(accountPath); + const branches = new Map(); + const branchFor = (childIndex) => { + let b = branches.get(childIndex); + if (!b) { b = account.deriveChild(childIndex); branches.set(childIndex, b); } + return b; + }; + // WC path enum → BCH child index (identity mapping today; enum members + // hold the numeric child index directly — see docs/protocol. + const childOf = (path) => Number(path); + + return { + walletName: label ? `Aegis · ${label}` : "Aegis", + walletIcon: WALLET_ICON, + + // Stable identity per pairing URI. HKDF salt binds it to this wallet's + // root, info binds it to the URI, so: + // - reconnecting to the same URI = same Nostr identity + // - two different URIs = uncorrelatable identities (privacy) + getRelayPrivateKey(uri) { + const salt = new TextEncoder().encode("aegis/wc/relay/v1"); + const info = new TextEncoder().encode(uri); + // 32 bytes for a Nostr secp256k1 private key. + return hkdf(sha256, root32, salt, info, 32); + }, + + getPublicKey(path, index) { + const branch = branchFor(childOf(path)); + const node = branch.deriveChild(Number(index)); + return node.publicKey; // 33 bytes compressed + }, + + getXpub(path) { + return branchFor(childOf(path)).publicExtendedKey; + }, + + // Sign a transaction the dapp has already assembled. See signTx.js for + // the heavy lifting (SIGHASH_ALL|FORKID|UTXOS enforcement, libauth + // preimage + secp256k1 der/lowS signatures). + async signTransaction(request) { + // Route through approval-modal first — the user always sees what + // they're signing before any private key touches the request. + if (!approvalRequest) throw new Error("no approval channel"); + const decision = await approvalRequest({ + kind: "wc-sign", + walletId, label, + request, + }); + if (!decision?.approved) throw new Error("cancelled"); + const { signTx } = require("./wc-sign.js"); + return signTx({ + request, + account, + branches: { receive: branchFor(0), change: branchFor(1), defi: branchFor(7) }, + libauth, secp256k1, + }); + }, + }; + } + + // ---- connection tracker -------------------------------------------------- + + // One manager per BCH wallet. We keep them in a per-walletId map so the + // panel can show "Wallet A connected to 2 dapps, Wallet B to none" etc. + const managers = new Map(); // walletId -> WalletConnectionManager + const uris = new Map(); // walletId -> Set<uri> (persisted) + const listeners = new Set(); // () => void — panel resubscribes on state change + + function fireStateChange() { for (const fn of listeners) try { fn(); } catch {} } + + function persist(walletId) { + const list = [...(uris.get(walletId) || new Set())]; + api.storage.set(`wc/${walletId}/uris`, list); + } + + async function startForWallet({ walletId, label, root32, accountPath }) { + if (managers.has(walletId)) return managers.get(walletId); + const adapter = makeAdapter({ root32, accountPath, walletId, label }); + const mgr = new WalletConnectionManager(adapter); + managers.set(walletId, mgr); + + mgr.on("connectionsChanged", fireStateChange); + mgr.on("connectionStatusChanged", fireStateChange); + mgr.on("remoteDisconnect", (connId, reason) => { + log(`wc[${walletId}] remote disconnect ${connId}: ${reason}`); + fireStateChange(); + }); + mgr.on("pendingSignRequest", async ({ connectionId, request }) => { + try { + const { signedTransaction } = await adapter.signTransaction(request); + await mgr.sendSignResponse(connectionId, request.sequence, signedTransaction); + } catch (e) { + log(`wc[${walletId}] sign failed:`, e?.message || e); + try { await mgr.sendSignError(connectionId, request.sequence, cleanErrForDapp(e)); } catch {} + } + }); + + // Restore persisted pairings. + uris.set(walletId, new Set(api.storage.get(`wc/${walletId}/uris`, []) || [])); + for (const uri of uris.get(walletId)) { + try { mgr.connect(uri); } catch (e) { log(`wc[${walletId}] reconnect failed:`, e?.message); } + } + return mgr; + } + + function stopForWallet(walletId) { + const mgr = managers.get(walletId); if (!mgr) return; + try { mgr.disconnectAll?.(); } catch {} + managers.delete(walletId); + uris.delete(walletId); + } + + async function connectUri(walletId, uri) { + const mgr = managers.get(walletId); + if (!mgr) throw new Error("wc: wallet not ready"); + const trimmed = String(uri || "").trim(); + if (!/^wiz:\/\//i.test(trimmed)) throw new Error("wc: URI must start with wiz://"); + const id = mgr.connect(trimmed); + const set = uris.get(walletId) || new Set(); + set.add(trimmed); + uris.set(walletId, set); + persist(walletId); + fireStateChange(); + return id; + } + + async function disconnect(walletId, connId) { + const mgr = managers.get(walletId); if (!mgr) return; + try { await mgr.disconnect(connId); } catch {} + // Trim the persisted URI so the next start doesn't re-add it. + const conn = [...(mgr.connections?.values?.() || [])].find((c) => c.id === connId); + if (conn?.uri) { + const set = uris.get(walletId); if (set) { set.delete(conn.uri); persist(walletId); } + } + fireStateChange(); + } + + function snapshot() { + const out = {}; + for (const [walletId, mgr] of managers) { + const list = [...(mgr.connections?.values?.() || [])].map((c) => ({ + id: c.id, + uri: c.uri, + dappName: c.dappName || null, + dappIcon: c.dappIcon || null, + status: c.status?.kind || String(c.status || "unknown"), + connectedAt: c.connectedAt || null, + })); + out[walletId] = list; + } + return out; + } + + function onStateChange(fn) { listeners.add(fn); return () => listeners.delete(fn); } + + function cleanErrForDapp(e) { + const m = String(e?.message || e); + if (m === "cancelled") return "user rejected"; + return m.replace(/\n[\s\S]*$/, "").slice(0, 200); + } + + return { startForWallet, stopForWallet, connectUri, disconnect, snapshot, onStateChange }; +}; diff --git a/bundled-addons/aegis/panel.html b/bundled-addons/aegis/panel.html new file mode 100644 index 00000000..d8df9181 --- /dev/null +++ b/bundled-addons/aegis/panel.html @@ -0,0 +1,420 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Aegis Wallet</title> +<!-- Aegis brand mark from aegis.x/brand/favicon.svg — kept byte-close so + the sidebar-tab icon reads as the identity a user just downloaded from. --> +<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A//www.w3.org/2000/svg'%20viewBox%3D'0%200%2032%2032'%20fill%3D'none'%3E%3Cpolygon%20points%3D'16%2C2%2028%2C9%2028%2C23%2016%2C30%204%2C23%204%2C9'%20fill%3D'%230a0a0d'%20stroke%3D'%23D6FF3D'%20stroke-width%3D'1.6'%20stroke-linejoin%3D'round'/%3E%3Ccircle%20cx%3D'16'%20cy%3D'16'%20r%3D'4.5'%20fill%3D'none'%20stroke%3D'%23D6FF3D'%20stroke-width%3D'1.4'/%3E%3Ccircle%20cx%3D'16'%20cy%3D'16'%20r%3D'1.6'%20fill%3D'%23D6FF3D'/%3E%3C/svg%3E"> +<style> + :root { color-scheme: light dark; + --bg:#0e131c; --panel:#141a24; --card:#0f1621; --line:rgba(255,255,255,.09); + --ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --danger:#f6768a; --ok:#5ad38a; + --bch:#0ac18e; --trx:#ff060a; --trxNile:#4d9dff; } + @media (prefers-color-scheme: light) { + :root { --bg:#f8faff; --panel:#ffffff; --card:#f1f4fa; --line:rgba(0,0,0,.10); + --ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; + /* Darker acid on light backgrounds — ~7:1 contrast on white. */ + --acid: #0AC18E; } + } + * { box-sizing: border-box; } + html, body { margin: 0; height: 100%; } + body { background: var(--bg); color: var(--ink); + font: 13.5px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + display: flex; flex-direction: column; } + header { padding: 10px 14px 10px; border-bottom: 1px solid var(--line); background: var(--panel); position: relative; } + .picker { display: flex; align-items: center; justify-content: space-between; gap: 8px; cursor: pointer; user-select: none; } + .picker .t { display: flex; align-items: center; gap: 7px; font-weight: 600; min-width: 0; } + .picker .t .badge { font-size: 15px; line-height: 1; } + .picker .t .lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .picker .caret { color: var(--mut); font-size: 10px; } + .picker:hover { color: var(--acid); } + .net { display: flex; align-items: center; gap: 6px; color: var(--dim); font-size: 11.5px; white-space: nowrap; } + .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--dim); } + .dot.on { background: var(--ok); } .dot.busy { background: #e0b341; } + .bal { margin-top: 8px; font-variant-numeric: tabular-nums; } + .bal .big { font-size: 22px; font-weight: 650; letter-spacing: .2px; } + .bal .big small { font-size: 13px; color: var(--mut); font-weight: 500; margin-left: 4px; } + .bal .sub { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; } + .bal .sub .netlbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + #drop { position: absolute; left: 10px; right: 10px; top: 100%; background: var(--panel); border: 1px solid var(--line); + border-radius: 10px; box-shadow: 0 8px 26px rgba(0,0,0,.3); z-index: 20; padding: 4px; margin-top: 4px; max-height: 60vh; overflow-y: auto; } + #drop[hidden] { display: none; } + #drop .row, #drop .coinrow { display: flex; align-items: center; gap: 9px; padding: 7px 8px; border-radius: 7px; cursor: pointer; } + #drop .row:hover, #drop .coinrow:hover { background: rgba(255,255,255,.05); } + #drop .row .m, #drop .coinrow .m { flex: 1; min-width: 0; } + #drop .row .m .l, #drop .coinrow .m .l { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + #drop .row .m .s, #drop .coinrow .m .s { color: var(--dim); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + #drop .row .v { color: var(--mut); font-size: 12px; font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; } + #drop .row .v .fs { color: var(--dim); font-size: 11px; margin-top: 2px; } + #drop .row.on { background: rgb(from var(--acid) r g b / .10); } + #drop .coinrow .caret { color: var(--dim); font-size: 11px; } + #drop hr { border: 0; border-top: 1px solid var(--line); margin: 6px 0; } + #drop .addhdr { padding: 5px 8px 3px; color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; } + #drop .netgroup { padding: 2px 4px 6px 40px; } + #drop .netgroup[hidden] { display: none; } + #drop .netchoice { padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 12.5px; color: var(--mut); } + #drop .netchoice:hover { background: rgba(255,255,255,.06); color: var(--ink); } + .ttag { display: inline-block; font-size: 9.5px; letter-spacing: .06em; padding: 1px 5px; border-radius: 3px; + background: rgba(224,179,65,.18); color: #e0b341; font-weight: 700; vertical-align: middle; margin-left: 2px; } + #hNet { color: var(--dim); font-size: 11px; margin-left: 4px; font-weight: 500; } + .fiat { color: var(--dim); font-size: 12.5px; margin-left: 10px; font-weight: 500; letter-spacing: .2px; } + .portfolio { margin-top: 6px; color: var(--mut); font-size: 11.5px; } + .portfolio b { color: var(--ink); font-weight: 600; } + .portfolio[hidden] { display: none; } + .picker-actions { display: flex; align-items: center; gap: 6px; } + .chip { background: transparent; border: 1px solid var(--line); color: var(--mut); border-radius: 6px; + padding: 0 8px; height: 22px; cursor: pointer; font: inherit; font-size: 13.5px; line-height: 1; + display: inline-flex; align-items: center; justify-content: center; } + .chip:hover { border-color: var(--acid); color: var(--acid); } + nav { display: flex; border-bottom: 1px solid var(--line); background: var(--panel); } + nav button { flex: 1; padding: 9px 0 8px; border: 0; background: transparent; color: var(--mut); cursor: pointer; + font: inherit; font-size: 12.5px; border-bottom: 2px solid transparent; } + nav button.on { color: var(--acid); border-bottom-color: var(--acid); } + nav button:hover { color: var(--ink); } + main { flex: 1; overflow: auto; padding: 14px; } + section[hidden] { display: none; } + .card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px; } + .mono { font: 12.5px/1.45 ui-monospace, "Cascadia Code", Consolas, monospace; word-break: break-all; } + .lbl { color: var(--dim); font-size: 11.5px; margin-bottom: 4px; } + .btn { padding: 7px 12px; border-radius: 7px; border: 1px solid var(--line); background: var(--panel); color: var(--ink); + cursor: pointer; font: inherit; font-size: 12.5px; } + .btn:hover { border-color: rgb(from var(--acid) r g b / .4); } + .btn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; font-weight: 650; } + .btn.primary:disabled { opacity: .45; cursor: default; } + .btn.danger { color: var(--danger); } + .btn.sm { padding: 4px 9px; font-size: 12px; } + .actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; } + .qrwrap { display: grid; place-items: center; padding: 12px; background: #fff; border-radius: 10px; margin-bottom: 12px; } + canvas { image-rendering: pixelated; } + input[type=text], input[type=number], input[type=password], textarea { width: 100%; padding: 7px 9px; border-radius: 7px; background: var(--panel); + border: 1px solid var(--line); color: var(--ink); font: inherit; font-size: 13px; outline: none; } + input:focus, textarea:focus { border-color: rgb(from var(--acid) r g b / .5); } + textarea { resize: vertical; min-height: 96px; font: 12px/1.45 ui-monospace, Consolas, monospace; } + .field { margin-bottom: 12px; } + .hint { color: var(--dim); font-size: 11.5px; margin-top: 4px; } + .switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; font-size: 12.5px; color: var(--ink); } + .switch input { accent-color: var(--brand, #d6ff3d); width: 15px; height: 15px; margin: 0; } + .amt { display: flex; gap: 6px; } + .amt input { flex: 1; } + .unit { display: flex; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; } + .unit button { border: 0; background: var(--panel); color: var(--mut); padding: 0 10px; cursor: pointer; font: inherit; font-size: 12px; } + .unit button.on { background: rgb(from var(--acid) r g b / .14); color: var(--acid); } + .fee { display: flex; align-items: center; gap: 10px; } + .fee input[type=range] { flex: 1; accent-color: var(--acid); } + .fee .v { color: var(--mut); font-size: 12px; min-width: 70px; text-align: right; font-variant-numeric: tabular-nums; } + .summary { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; font-size: 12.5px; margin-top: 10px; } + .summary .k { color: var(--dim); } .summary .v { text-align: right; font-variant-numeric: tabular-nums; } + .msg { margin-top: 10px; font-size: 12.5px; padding: 8px 10px; border-radius: 7px; } + .msg.err { background: rgba(246,118,138,.12); color: var(--danger); } + .msg.ok { background: rgba(90,211,138,.12); color: var(--ok); } + .msg a { color: inherit; } + .tx { display: grid; grid-template-columns: 22px 1fr auto; gap: 2px 10px; padding: 9px 4px; border-bottom: 1px solid var(--line); cursor: pointer; } + .tx:hover { background: rgba(255,255,255,.03); } + .tx .ic { font-size: 15px; line-height: 1.3; } + .tx .ic.in { color: var(--ok); } .tx .ic.out { color: var(--danger); } + .tx .what { font-size: 12.5px; } + .tx .when { color: var(--dim); font-size: 11.5px; grid-column: 2; } + .tx .amt2 { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; font-size: 12.5px; } + .tx .amt2.in { color: var(--ok); } + .tx .conf { grid-column: 3; text-align: right; color: var(--dim); font-size: 11px; } + .tx .conf.pending { color: #e0b341; } + .empty { color: var(--dim); text-align: center; padding: 30px 10px; font-size: 12.5px; } + .gate { padding: 40px 18px; text-align: center; color: var(--mut); } + .gate .big { font-size: 28px; margin-bottom: 8px; } + .gate b { color: var(--ink); } + .kv { margin-top: 10px; } + .kv .lbl { margin-top: 8px; } + a.link { color: var(--acid); text-decoration: none; cursor: pointer; } +</style> +</head> +<body> +<header> + <div class="picker" id="pickerBtn"> + <div class="t"> + <span class="badge" id="hBadge"></span> + <span class="lbl" id="hLabel">Aegis Wallet</span> + <span id="hNet"></span> + </div> + <div class="picker-actions"> + <button type="button" id="hAddWallet" class="chip" title="Add a new wallet">+</button> + <span class="caret">▾</span> + </div> + </div> + <div id="drop" hidden></div> + <div class="bal"> + <div class="big"> + <span id="balMain">—</span><small id="balTicker">—</small> + <span id="balFiat" class="fiat" hidden></span> + </div> + <div class="sub"> + <span class="netlbl" id="netlbl">connecting…</span> + <span class="net"><span class="dot" id="dot"></span></span> + </div> + <div id="portfolio" class="portfolio" hidden></div> + </div> +</header> +<nav> + <button data-tab="receive" class="on">Receive</button> + <button data-tab="send">Send</button> + <button data-tab="history">History</button> + <button data-tab="settings">Settings</button> +</nav> +<main> + <div id="gate" class="gate" hidden></div> + <div id="tabs"> + <section id="tab-receive"> + <div class="qrwrap"><canvas id="qr" width="200" height="200"></canvas></div> + <div class="card"> + <div class="lbl">Receiving address <span id="addrMeta"></span></div> + <div class="mono" id="addr">—</div> + <div class="actions"> + <button class="btn sm" id="copyAddr">Copy</button> + <button class="btn sm" id="nextAddr" hidden>Next unused address</button> + <button class="btn sm" id="viewAddr">Explorer</button> + <button class="btn sm" id="openFaucet" hidden>Faucet</button> + </div> + </div> + <div class="card" id="tokensCard" hidden style="margin-top:12px"> + <div class="lbl">Tokens</div> + <div id="tokensList" class="kv"></div> + <div class="hint">SPL tokens held by this wallet. Send by picking one under the Send tab's Asset dropdown.</div> + </div> + </section> + <section id="tab-send" hidden> + <div class="field" id="sendAssetField" hidden> + <div class="lbl">Asset</div> + <select id="sendAsset"></select> + <div class="hint">Pick the native coin or an SPL token in this wallet.</div> + </div> + <div class="field"> + <div class="lbl">Recipient</div> + <input type="text" id="sendTo" spellcheck="false" autocomplete="off" placeholder="…"> + <div class="hint" id="sendToHint"></div> + </div> + <div class="field"> + <div class="lbl">Amount</div> + <div class="amt"> + <input type="text" id="sendAmt" inputmode="decimal" autocomplete="off" placeholder="0.00"> + <div class="unit" id="unitPicker"></div> + <button class="btn sm" id="sendMax" type="button">Max</button> + </div> + </div> + <div class="field" id="feeField"> + <div class="lbl">Fee</div> + <div class="fee"><input type="range" id="feeRate" min="1" max="5" step="1" value="1"><span class="v" id="feeLbl">1 sat/B</span></div> + </div> + <div class="card"> + <div class="summary"> + <div class="k">Amount</div><div class="v" id="sumAmt">—</div> + <div class="k">Network fee</div><div class="v" id="sumFee">—</div> + <div class="k">Total</div><div class="v" id="sumTotal">—</div> + </div> + </div> + <div class="actions"><button class="btn primary" id="sendBtn" disabled>Send</button></div> + <div class="msg" id="sendMsg" hidden></div> + </section> + <section id="tab-history" hidden> + <div id="txlist"></div> + </section> + <section id="tab-settings" hidden> + <div class="card" id="walletManage" style="margin-bottom:14px"> + <div class="lbl">This wallet</div> + <div class="field" style="margin-top:6px;margin-bottom:8px"> + <input type="text" id="renameLabel" placeholder="Wallet name"> + </div> + <div class="actions"> + <button class="btn sm" id="renameBtn">Rename</button> + <button class="btn sm danger" id="removeBtn">Remove wallet</button> + </div> + <div class="hint" id="removeHint"></div> + </div> + + <div id="bchSettings" hidden> + <div class="field"> + <div class="lbl">Derivation path (account)</div> + <input type="text" id="setPath" spellcheck="false" placeholder="m/44'/145'/0'"> + <div class="hint">Changing this switches to a different set of addresses under the same wallet seed.</div> + </div> + <div class="field" id="bchServersRow"> + <div class="lbl">Electrum servers (shared across BCH mainnet wallets, one per line)</div> + <textarea id="setServers" spellcheck="false"></textarea> + <div class="hint" id="serverHint"></div> + </div> + <div class="actions"> + <button class="btn primary" id="applySettings">Apply</button> + <button class="btn" id="resetServers">Reset servers</button> + </div> + <div class="card" style="margin-top:16px"> + <div class="lbl">Recovery info</div> + <div class="hint">Keys come from your Theseus password vault under <span class="mono" id="purpose"></span>. The same vault seed on another machine recreates this wallet.</div> + <div class="kv" id="recovery"></div> + <div class="actions"> + <button class="btn" id="showXpub">Show account xpub</button> + <button class="btn danger" id="showXprv">Show account private key</button> + </div> + </div> + + <div class="card" style="margin-top:16px"> + <div class="lbl">WizardConnect</div> + <div class="hint" style="margin-bottom:8px"> + Pair this BCH wallet with a dapp that speaks the WizardConnect protocol + (Cauldron, Moria, or any site built on the SDK). Paste the <span class="mono">wiz://</span> + URI the dapp shows in its Connect dialog. Aegis signs every transaction only after you approve it. + </div> + <div class="field" style="margin-top:8px"> + <input type="text" id="wcUri" spellcheck="false" placeholder="wiz://?p=…&amp;s=…"> + </div> + <div class="actions"> + <button class="btn primary" id="wcConnectBtn">Connect</button> + </div> + <div class="msg" id="wcMsg" hidden></div> + <div class="kv" id="wcSites" style="margin-top:8px"></div> + </div> + </div> + + <div id="trxSettings" hidden> + <div class="card"> + <div class="lbl">Recovery info</div> + <div class="hint">Keys come from your Theseus password vault under <span class="mono" id="trxPurpose"></span>. Import into TronLink with the derivation path <span class="mono">m/44'/195'/0'/0/0</span> from the same vault seed.</div> + </div> + </div> + + <div id="scSettings" hidden> + <div class="field"> + <div class="lbl">walletd URL</div> + <input type="text" id="setWalletdUrl" spellcheck="false" placeholder="https://your-walletd.example/api"> + <div class="hint">Any public or self-hosted <span class="mono">go.sia.tech/walletd</span> in "full" index mode. The URL is per-wallet, so different Sia sub-accounts can point at different nodes. It is not shared with anyone else.</div> + </div> + <div class="actions"> + <button class="btn primary" id="applyWalletdUrl">Apply</button> + </div> + <div class="card" style="margin-top:16px"> + <div class="lbl">Recovery info</div> + <div class="hint">Keys come from your Theseus password vault under <span class="mono" id="scPurpose"></span>. Address scheme: walletd <span class="mono">KeyFromSeed(seed, index)</span>.</div> + <div class="kv" id="scRecovery"></div> + <div class="actions"> + <button class="btn danger" id="showScSeed">Reveal wallet seed</button> + </div> + </div> + </div> + + <div id="dgbSettings" hidden> + <div class="field"> + <div class="lbl">Address family</div> + <select id="setDgbFamily"></select> + <div class="hint">Picks the BIP purpose that shapes your DGB addresses. Switching rebuilds the wallet against a different set of addresses under the same seed — old funds don't move; they still live under the family they were received on.</div> + </div> + <div class="field"> + <div class="lbl">Derivation path (account)</div> + <input type="text" id="setDgbPath" spellcheck="false" placeholder="m/84'/20'/0'"> + <div class="hint">Auto-filled from the family above. Edit only if you need a non-default account.</div> + </div> + <div class="actions"> + <button class="btn primary" id="applyDgbPath">Apply</button> + </div> + <div class="card" style="margin-top:16px"> + <div class="lbl">Recovery info</div> + <div class="hint">Keys come from your Theseus password vault under <span class="mono" id="dgbPurpose"></span>. Any BIP39 tool set to <span class="mono">DGB</span> (coin type 20) at the same purpose can reproduce this wallet.</div> + <div class="kv" id="dgbRecovery"></div> + <div class="actions"> + <button class="btn" id="showDgbXpub">Show account xpub</button> + <button class="btn danger" id="showDgbXprv">Show account private key</button> + </div> + </div> + </div> + + <div id="btcSettings" hidden> + <div class="field"> + <div class="lbl">Address family</div> + <select id="setBtcFamily"></select> + <div class="hint">Picks the BIP purpose that shapes your Bitcoin addresses. Switching rebuilds the wallet against a different set of addresses under the same seed — old funds don't move; they still live under the family they were received on.</div> + </div> + <div class="field"> + <div class="lbl">Derivation path (account)</div> + <input type="text" id="setBtcPath" spellcheck="false" placeholder="m/84'/0'/0'"> + <div class="hint">Auto-filled from the family above. Edit only if you need a non-default account.</div> + </div> + <div class="actions"> + <button class="btn primary" id="applyBtcPath">Apply</button> + </div> + <div class="card" style="margin-top:16px"> + <div class="lbl">Recovery info</div> + <div class="hint">Keys come from your Theseus password vault under <span class="mono" id="btcPurpose"></span>. Any BIP39 tool at coin type 0 (mainnet) / 1 (testnet) and the same purpose can reproduce this wallet.</div> + <div class="kv" id="btcRecovery"></div> + <div class="actions"> + <button class="btn" id="showBtcXpub">Show account xpub</button> + <button class="btn danger" id="showBtcXprv">Show account private key</button> + </div> + </div> + </div> + + <div id="ethSettings" hidden> + <div class="field"> + <div class="lbl">RPC URL</div> + <input type="text" id="setEthRpcUrl" spellcheck="false" placeholder="https://cloudflare-eth.com"> + <div class="hint">JSON-RPC endpoint Aegis reads balances and broadcasts from. Any provider works — public gateways (Cloudflare, PublicNode), Alchemy, Infura, or your own node. Stored per-wallet; not shared.</div> + </div> + <div class="actions"> + <button class="btn primary" id="applyEthRpc">Apply</button> + </div> + <div class="card" style="margin-top:16px"> + <div class="lbl">Recovery info</div> + <div class="hint">Keys come from your Theseus password vault under <span class="mono" id="ethPurpose"></span>. Import into MetaMask / any Ethereum wallet using the derivation path <span class="mono">m/44'/60'/0'/0/0</span> from the same vault seed.</div> + <div class="kv" id="ethRecovery"></div> + <div class="actions"> + <button class="btn danger" id="showEthKey">Reveal private key</button> + </div> + </div> + </div> + + <div id="solSettings" hidden> + <div class="field"> + <div class="lbl">RPC URL</div> + <input type="text" id="setSolRpcUrl" spellcheck="false" placeholder="https://api.mainnet-beta.solana.com"> + <div class="hint">JSON-RPC endpoint. The public Solana RPCs are heavily rate-limited — for real use point Aegis at Helius / QuickNode / your own node.</div> + </div> + <div class="actions"> + <button class="btn primary" id="applySolRpc">Apply</button> + </div> + <div class="card" style="margin-top:16px"> + <div class="lbl">Recovery info</div> + <div class="hint">Keys come from your Theseus password vault under <span class="mono" id="solPurpose"></span>. Import into Phantom / Solflare with the derivation path <span class="mono">m/44'/501'/0'/0'</span> from the same vault seed.</div> + <div class="kv" id="solRecovery"></div> + <div class="actions"> + <button class="btn danger" id="showSolKey">Reveal wallet seed</button> + </div> + </div> + </div> + + <div class="card" style="margin-top:16px"> + <div class="lbl">Fiat prices</div> + <div class="hint" style="margin-bottom:10px"> + Off by default. When enabled, Aegis fetches USD prices for the seven supported coins from + <span class="mono">api.coingecko.com</span> every 5 minutes while the panel is open. + One HTTP request per interval, no keys and no address data — but CoinGecko can see your IP, + which is a signal that a wallet is open on this machine. + </div> + <div class="actions" style="align-items:center;gap:12px"> + <label class="switch"> + <input type="checkbox" id="pricesToggle"> + <span>Enable USD prices</span> + </label> + <button class="btn sm" id="refreshPrices" hidden>Refresh now</button> + <span class="hint" id="pricesStatus" style="margin-left:auto"></span> + </div> + </div> + + <div class="card" style="margin-top:16px"> + <div class="lbl">Connected sites</div> + <div class="hint">Sites allowed to see your address, allowances for silent BCH payments, and Tron dapps you've connected. Message signing always asks.</div> + <div id="sites" class="kv"></div> + </div> + <div class="msg err" id="settingsMsg" hidden></div> + </section> + </div> +</main> +<script src="qr.js"></script> +<script src="panel.js"></script> +</body> +</html> diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js new file mode 100644 index 00000000..d014c781 --- /dev/null +++ b/bundled-addons/aegis/panel.js @@ -0,0 +1,1158 @@ +// Aegis wallet panel. All state comes from activate() via window.silentmode. +// This file renders the multi-wallet picker, per-chain views, and collects +// input; it never touches keys or the vault. +const $ = (id) => document.getElementById(id); +const S = window.silentmode; +let state = null; // full state (all wallets + selected) +let tab = "receive"; +let unit = null; // "big" | "small" — chain-dependent +let sendMax = false; +let planTimer = null; +let lastPlan = null; +let settingsFilled = false; +// Selected asset for the Send tab. `null` = native coin. Otherwise a +// { mint, symbol, decimals } picked from the SOL wallet's SPL token list. +let sendAsset = null; + +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]); +const hostOf = (url) => { try { return new URL(url).host || url; } catch { return url; } }; +const openUrl = (url) => S.invoke("openUrl", { url }).catch(() => {}); +const cleanErr = (e) => String(e?.message || e).replace(/^Error invoking remote method '[^']+': Error: /, ""); + +// ---- coin logos ------------------------------------------------------------ +// Inline SVGs so the header, wallet picker and settings surface all render +// the same mark. Sized by the container via width/height attributes. +function logoSvg(logo, size) { + const s = size || 20; + if (logo === "bch") { + // All coin marks below are the canonical SVGs from + // github.com/spothq/cryptocurrency-icons — the permissive-licensed + // set most wallets, exchanges, and explorers standardised on, so + // Aegis's logos match what users see everywhere else. Inline so + // panel load doesn't fetch anything. + return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Bitcoin Cash" style="vertical-align:middle;flex:none"><g fill="none" fill-rule="evenodd"><circle cx="16" cy="16" fill="#8dc351" r="16"/><path d="M21.207 10.534c-.776-1.972-2.722-2.15-4.988-1.71l-.807-2.813-1.712.491.786 2.74c-.45.128-.908.27-1.363.41l-.79-2.758-1.711.49.805 2.813c-.368.114-.73.226-1.085.328l-.003-.01-2.362.677.525 1.83s1.258-.388 1.243-.358c.694-.199 1.035.139 1.2.468l.92 3.204c.047-.013.11-.029.184-.04l-.181.052 1.287 4.49c.032.227.004.612-.48.752.027.013-1.246.356-1.246.356l.247 2.143 2.228-.64c.415-.117.825-.227 1.226-.34l.817 2.845 1.71-.49-.807-2.815a65.74 65.74 0 001.372-.38l.802 2.803 1.713-.491-.814-2.84c2.831-.991 4.638-2.294 4.113-5.07-.422-2.234-1.724-2.912-3.471-2.836.848-.79 1.213-1.858.642-3.3zm-.65 6.77c.61 2.127-3.1 2.929-4.26 3.263l-1.081-3.77c1.16-.333 4.704-1.71 5.34.508zm-2.322-5.09c.554 1.935-2.547 2.58-3.514 2.857l-.98-3.419c.966-.277 3.915-1.455 4.494.563z" fill="#fff" fill-rule="nonzero"/></g></svg>`; + } + if (logo === "trx") { + return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Tron" style="vertical-align:middle;flex:none"><g fill="none"><circle fill="#EF0027" cx="16" cy="16" r="16"/><path d="M21.932 9.913L7.5 7.257l7.595 19.112 10.583-12.894-3.746-3.562zm-.232 1.17l2.208 2.099-6.038 1.093 3.83-3.192zm-5.142 2.973l-6.364-5.278 10.402 1.914-4.038 3.364zm-.453.934l-1.038 8.58L9.472 9.487l6.633 5.502zm.96.455l6.687-1.21-7.67 9.343.983-8.133z" fill="#FFF"/></g></svg>`; + } + if (logo === "sc") { + return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Siacoin" style="vertical-align:middle;flex:none"><g fill="none" fill-rule="evenodd"><circle cx="16" cy="16" r="16" fill="#20EE82"/><path fill="#FFF" d="M16 7.5a8.5 8.5 0 018.5 8.5v8.5H16a8.5 8.5 0 110-17zm5.1 13.6v-5.023c0-2.82-2.255-5.163-5.074-5.177a5.106 5.106 0 00-5.126 5.126c.014 2.819 2.358 5.074 5.177 5.074H21.1z"/></g></svg>`; + } + if (logo === "dgb") { + return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="DigiByte" style="vertical-align:middle;flex:none"><g fill="none" fill-rule="evenodd"><circle cx="16" cy="16" r="16" fill="#006AD2"/><path fill="#FFF" d="M12.368 25l.479-1.282-.85.084-.306.81c-.024.061-.044.125-.075.183-.067.125-.17.203-.313.204-.63.001-1.258 0-1.888-.001-.015 0-.03-.009-.063-.019l.402-1.085c-.733-.02-1.446-.032-2.156-.113.012-.133 4.062-10.345 4.223-10.652.04-.003.087-.01.135-.01h3.27c.033 0 .066 0 .098.002.331.025.515.305.4.623-.153.42-.315.838-.472 1.256l-2.058 5.474c-.021.056-.039.114-.065.19.058.003.103.009.148.007 3.096-.135 5.368-1.613 6.836-4.39a6.711 6.711 0 00.67-1.935c.073-.395.096-.791-.003-1.186a1.763 1.763 0 00-.698-1.03c-.468-.337-.994-.481-1.562-.484H7.5c.024-.06.035-.1.054-.136l1.388-2.501a.754.754 0 01.706-.418h5.866l.601-1.59h1.782c.044 0 .088-.003.13.003.127.02.2.12.181.25-.008.054-.028.106-.048.158-.123.331-.249.661-.372.992-.021.056-.038.113-.06.18h.805c.02-.043.04-.087.058-.132l.496-1.317c.05-.133.052-.134.185-.134.564 0 1.129-.002 1.693 0 .238.001.323.127.238.357-.135.369-.274.735-.412 1.102-.019.051-.036.103-.06.173.055.01.1.02.145.026.785.096 1.549.274 2.274.601.551.249 1.052.574 1.464 1.03.558.615.835 1.35.879 2.18.042.805-.105 1.581-.372 2.33-.632 1.775-1.53 3.388-2.83 4.747-.896.936-1.93 1.68-3.064 2.282-1.224.65-2.518 1.105-3.858 1.427-.12.03-.183.082-.224.2-.147.41-.303.818-.457 1.226-.095.25-.19.318-.452.318h-1.868z"/></g></svg>`; + } + if (logo === "btc") { + return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Bitcoin" style="vertical-align:middle;flex:none"><g fill="none" fill-rule="evenodd"><circle cx="16" cy="16" r="16" fill="#F7931A"/><path fill="#FFF" fill-rule="nonzero" d="M23.189 14.02c.314-2.096-1.283-3.223-3.465-3.975l.708-2.84-1.728-.43-.69 2.765c-.454-.114-.92-.22-1.385-.326l.695-2.783L15.596 6l-.708 2.839c-.376-.086-.746-.17-1.104-.26l.002-.009-2.384-.595-.46 1.846s1.283.294 1.256.312c.7.175.826.638.805 1.006l-.806 3.235c.048.012.11.03.18.057l-.183-.045-1.13 4.532c-.086.212-.303.531-.793.41.018.025-1.256-.313-1.256-.313l-.858 1.978 2.25.561c.418.105.828.215 1.231.318l-.715 2.872 1.727.43.708-2.84c.472.127.93.245 1.378.357l-.706 2.828 1.728.43.715-2.866c2.948.558 5.164.333 6.097-2.333.752-2.146-.037-3.385-1.588-4.192 1.13-.26 1.98-1.003 2.207-2.538zm-3.95 5.538c-.533 2.147-4.148.986-5.32.695l.95-3.805c1.172.293 4.929.872 4.37 3.11zm.535-5.569c-.487 1.953-3.495.96-4.47.717l.86-3.45c.975.243 4.118.696 3.61 2.733z"/></g></svg>`; + } + if (logo === "eth") { + return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Ethereum" style="vertical-align:middle;flex:none"><g fill="none" fill-rule="evenodd"><circle cx="16" cy="16" r="16" fill="#627EEA"/><g fill="#FFF" fill-rule="nonzero"><path fill-opacity=".602" d="M16.498 4v8.87l7.497 3.35z"/><path d="M16.498 4L9 16.22l7.498-3.35z"/><path fill-opacity=".602" d="M16.498 21.968v6.027L24 17.616z"/><path d="M16.498 27.995v-6.028L9 17.616z"/><path fill-opacity=".2" d="M16.498 20.573l7.497-4.353-7.497-3.348z"/><path fill-opacity=".602" d="M9 16.22l7.498 4.353v-7.701z"/></g></g></svg>`; + } + if (logo === "sol") { + return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Solana" style="vertical-align:middle;flex:none"><g fill="none"><circle fill="#66F9A1" cx="16" cy="16" r="16"/><path d="M9.925 19.687a.59.59 0 01.415-.17h14.366a.29.29 0 01.207.497l-2.838 2.815a.59.59 0 01-.415.171H7.294a.291.291 0 01-.207-.498l2.838-2.815zm0-10.517A.59.59 0 0110.34 9h14.366c.261 0 .392.314.207.498l-2.838 2.815a.59.59 0 01-.415.17H7.294a.291.291 0 01-.207-.497L9.925 9.17zm12.15 5.225a.59.59 0 00-.415-.17H7.294a.291.291 0 00-.207.498l2.838 2.815c.11.109.26.17.415.17h14.366a.291.291 0 00.207-.498l-2.838-2.815z" fill="#FFF"/></g></svg>`; + } + if (logo === "aegis") { + // Athena's aspis — hexagonal shield with a boss at center + four + // spoke marks. Same silhouette as the aegis.x hero SVG so the wallet + // and the marketing page read as one identity. + return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Aegis" style="vertical-align:middle;flex:none"> + <polygon points="16,2 29,9 29,23 16,30 3,23 3,9" fill="none" stroke="#d6ff3d" stroke-width="2" stroke-linejoin="round"/> + <circle cx="16" cy="16" r="4.3" fill="none" stroke="#d6ff3d" stroke-width="1.4"/> + <circle cx="16" cy="16" r="1.3" fill="#d6ff3d"/> + <path d="M16 10.5 v-2.4 M16 21.5 v2.4 M10.5 16 h-2.4 M21.5 16 h2.4" stroke="#d6ff3d" stroke-width="1.4" stroke-linecap="round"/> + </svg>`; + } + // Fallback = Aegis shield (rather than a "?"), so an unrecognised + // registry entry still looks intentional. + return logoSvg("aegis", s); +} +function testnetTag() { return `<span class="ttag">TEST</span>`; } + +// Selected wallet convenience. +const sel = () => state && state.selected; +const chain = () => sel()?.chain || ""; +const decimals = () => sel()?.meta?.decimals || 8; +const ticker = () => sel()?.meta?.ticker || ""; + +// Numbers past ~9e15 lose precision as JS `Number`, and Sia amounts live at +// 10^24-scale routinely. Use BigInt for anything that arrives as a string. +function fmtBig(units, dec) { + const d = dec != null ? dec : decimals(); + if (typeof units === "string" && /^-?\d+$/.test(units)) { + const neg = units.startsWith("-"); + const raw = neg ? units.slice(1) : units; + const bi = BigInt(raw || "0"); + const base = 10n ** BigInt(d); + const whole = (bi / base).toString(); + let frac = (bi % base).toString().padStart(d, "0").replace(/0+$/, ""); + // Show 8-digit precision at most for very small units; keep 2 dp minimum. + const cap = Math.min(d, 8); + if (frac.length > cap) frac = frac.slice(0, cap); + if (!frac) frac = ""; + return (neg ? "-" : "") + whole + (frac ? "." + frac : ""); + } + const s = (Number(units || 0) / Math.pow(10, d)).toFixed(d); + return s.replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1"); +} +function fmtSmall(units) { + if (typeof units === "string" && /^-?\d+$/.test(units)) return units.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return Number(units || 0).toLocaleString("en-US"); +} +function smallUnitLabel() { + const c = chain(); + if (c === "bch" || c === "dgb" || c === "btc") return "sat"; + if (c === "trx") return "sun"; + if (c === "sc") return "H"; + if (c === "eth") return "wei"; + if (c === "sol") return "lamports"; + return "u"; +} +// Some chains (SOL) suffix explorer URLs to tell devnet from mainnet. +function explorerHref(base, id) { + const s = sel(); + return base + id + (s?.explorerSuffix || ""); +} +function bigUnitLabel() { return ticker(); } + +// ---- fiat helpers ---------------------------------------------------------- +// Prices live in state.prices.{enabled, prices, fetchedAt}. When disabled +// or missing, fiat helpers return null and the caller renders nothing. +function priceFor(chain) { + if (!state?.prices?.enabled) return null; + return state.prices.prices?.[chain] ?? null; +} +// Convert native units (sats/lamports/wei/…) to a USD number, BigInt-safe +// for wide-decimals coins (SC=24, ETH=18) that overflow Number. +function usdOf(chain, units, decimals) { + const price = priceFor(chain); + if (price == null || !units) return null; + const d = Number(decimals) || 0; + if (typeof units === "string" && /^-?\d+$/.test(units)) { + // BigInt-safe: divide the units by 10^d first via BigInt, then use + // the fractional remainder as a Number multiplier for the last dp. + const neg = units.startsWith("-"); + const abs = neg ? units.slice(1) : units; + const base = 10n ** BigInt(d); + const bi = BigInt(abs); + const whole = Number(bi / base); + const frac = Number(bi % base) / Number(base); + return (neg ? -1 : 1) * (whole + frac) * price; + } + const n = Number(units) / Math.pow(10, d); + return n * price; +} +// Format a USD value for the UI. < $0.01 → "< $0.01", < $10 → 2dp, else +// grouped whole dollars with ".xx" fine detail. Skeleton "≈ $—" when the +// feed is enabled but hasn't returned yet. +function fmtFiat(usd) { + if (usd == null) return null; + if (usd === 0) return "$0.00"; + if (Math.abs(usd) < 0.01) return "< $0.01"; + if (Math.abs(usd) < 10) return "$" + usd.toFixed(2); + const int = Math.floor(usd); + const frac = Math.abs(usd - int).toFixed(2).slice(1); + return "$" + int.toLocaleString("en-US") + frac; +} +function fiatSkeleton() { + return state?.prices?.enabled ? "≈ $—" : null; +} + +// ---- tabs ------------------------------------------------------------------ + +document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab))); +function showTab(name) { + tab = name; + document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("on", b.dataset.tab === name)); + document.querySelectorAll("main section").forEach((s) => { s.hidden = s.id !== "tab-" + name; }); + if (name === "settings") { settingsFilled = false; fillSettings(); } + if (name === "send") applyUnitPicker(); + // Settings tab is always usable (fiat prices are a global setting); every + // other tab is gated by the wallet-ready state. Re-run gate visibility so + // switching TO or AWAY FROM Settings while locked does the right thing. + const s = sel(); + const ready = s && s.phase === "ready"; + const onSettings = name === "settings"; + $("tabs").hidden = !(ready || onSettings); + $("gate").hidden = ready || onSettings; +} + +// ---- wallet picker (two-step add) ------------------------------------------ + +$("pickerBtn").addEventListener("click", (e) => { + // The "+" chip inside the picker header opens the dropdown with the + // Add-wallet section pre-expanded — same UX as the empty-state gate + // button but always available. + const isAddChip = e.target && (e.target.id === "hAddWallet" || e.target.closest("#hAddWallet")); + const d = $("drop"); + if (isAddChip) { + d.hidden = false; + fillPicker(); + setTimeout(() => { const first = d.querySelector(".coinrow"); if (first) first.click(); }, 0); + e.stopPropagation(); + return; + } + d.hidden = !d.hidden; + if (!d.hidden) fillPicker(); +}); +document.addEventListener("click", (e) => { + const d = $("drop"); + if (d.hidden) return; + if (e.target.closest("#drop") || e.target.closest("#pickerBtn")) return; + d.hidden = true; +}); + +function fillPicker() { + const d = $("drop"); + const wallets = state?.wallets || []; + const coins = state?.coins || []; + const rowsHtml = wallets.map((w) => { + const on = w.id === state.selectedWalletId ? "on" : ""; + const totalUnits = w.balance ? (typeof w.balance.confirmed === "string" + ? (BigInt(w.balance.confirmed || "0") + BigInt(w.balance.unconfirmed || "0")).toString() + : (w.balance.confirmed || 0) + (w.balance.unconfirmed || 0)) : 0; + const bal = w.balance ? fmtBig(totalUnits, w.decimals) + " " + w.ticker : "—"; + // Fiat sits on a second line under the native balance, right-aligned. + // Testnet coins mirror mainnet prices, so we don't dim them. + const usd = usdOf(w.chain, totalUnits, w.decimals); + const fiat = fmtFiat(usd); + const fiatLine = fiat ? `<div class="fs">${esc(fiat)}</div>` : ""; + const sub = `${esc(w.coinLabel)} · ${esc(w.networkLabel)}${w.testnet ? " " + testnetTag() : ""}`; + return `<div class="row ${on}" data-select="${esc(w.id)}"> + ${logoSvg(w.logo, 22)} + <div class="m"><div class="l">${esc(w.label)}</div><div class="s">${sub}</div></div> + <div class="v"><div>${esc(bal)}</div>${fiatLine}</div> + </div>`; + }).join(""); + // "Add wallet" is a two-step flyout: first show coins, then that coin's + // networks. Nothing is created until the user clicks a specific network. + const coinRows = coins.map((c) => { + const testCount = c.networks.filter((n) => n.testnet).length; + const sub = c.networks.length > 1 + ? c.networks.map((n) => n.label).join(" · ") + : c.networks[0].label; + return `<div class="coinrow" data-coin="${esc(c.chain)}"> + ${logoSvg(c.logo, 22)} + <div class="m"><div class="l">${esc(c.label)}</div><div class="s">${esc(sub)}</div></div> + <div class="caret">▸</div> + </div> + <div class="netgroup" id="netgroup-${esc(c.chain)}" hidden> + ${c.networks.map((n) => `<div class="netchoice" data-add="${esc(c.chain + ":" + n.id)}"> + ${esc(n.label)}${n.testnet ? " " + testnetTag() : ""} + </div>`).join("")} + </div>`; + }).join(""); + d.innerHTML = + rowsHtml + + `<hr><div class="addhdr">+ Add wallet</div>${coinRows}` + + `<hr><div class="addhdr">↓ Import existing (BCH)</div> + <div class="coinrow" id="picker-import-bch"> + ${logoSvg("bch", 22)} + <div class="m"><div class="l">Import a BCH wallet</div><div class="s">Paste a BIP39 mnemonic + derivation path, or a WIF</div></div> + <div class="caret">›</div> + </div>`; + + d.querySelectorAll("[data-select]").forEach((r) => r.addEventListener("click", async () => { + d.hidden = true; + try { state = await S.invoke("selectWallet", { id: r.dataset.select }); settingsFilled = false; render(); } + catch (e) { showErr(cleanErr(e)); } + })); + d.querySelectorAll(".coinrow").forEach((r) => r.addEventListener("click", () => { + // Collapse other coins' network groups; toggle this one. + d.querySelectorAll(".netgroup").forEach((g) => { if (g.id !== "netgroup-" + r.dataset.coin) g.hidden = true; }); + d.querySelectorAll(".coinrow .caret").forEach((c) => { c.textContent = "▸"; }); + const group = d.querySelector("#netgroup-" + r.dataset.coin); + group.hidden = !group.hidden; + r.querySelector(".caret").textContent = group.hidden ? "▸" : "▾"; + })); + d.querySelectorAll("[data-add]").forEach((r) => r.addEventListener("click", async () => { + const [c, n] = r.dataset.add.split(":"); + d.hidden = true; + try { state = await S.invoke("addWallet", { chain: c, network: n }); settingsFilled = false; render(); } + catch (e) { showErr(cleanErr(e)); } + })); + const impBtn = $("picker-import-bch"); + if (impBtn) impBtn.addEventListener("click", () => { d.hidden = true; openImportModal("bch"); }); +} + +// Import modal — M.1 UX. Paste mnemonic + path OR WIF, choose network + label +// + category. Backend derives cashaddr and stores signer material in +// wallet-imports.enc (design §3.2). Modal is a plain overlay div injected +// into the panel body so it works over any tab. +function openImportModal(chain) { + const overlay = document.createElement("div"); + overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:9999;padding-top:24px"; + overlay.innerHTML = ` + <div style="width:min(94vw,380px);background:var(--panel,#12161e);border:1px solid var(--line,#2a2f38);border-radius:10px;padding:14px 14px 12px;box-shadow:0 10px 40px rgba(0,0,0,.4)"> + <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px"> + ${logoSvg("bch", 22)} + <div style="font-weight:600;flex:1">Import a BCH wallet</div> + <button class="btn sm" id="imClose" type="button">✕</button> + </div> + <div class="hint" style="margin-bottom:10px">Key material stays in Theseus's vault (wallet-imports.enc). Aegis derives only the address and shows the balance — spending support ships next.</div> + <div class="field"> + <div class="lbl">Network</div> + <div style="display:flex;gap:12px;font-size:12.5px;margin-top:4px"> + <label><input type="radio" name="imNet" value="chipnet" checked> Chipnet testnet</label> + <label><input type="radio" name="imNet" value="mainnet"> Mainnet</label> + </div> + </div> + <div class="field"> + <div class="lbl">Source</div> + <div style="display:flex;gap:12px;font-size:12.5px;margin-top:4px"> + <label><input type="radio" name="imKind" value="mnemonic" checked> BIP39 mnemonic</label> + <label><input type="radio" name="imKind" value="wif"> WIF</label> + </div> + </div> + <div class="field" id="imMnemonicField"> + <div class="lbl">Mnemonic (12/24 words)</div> + <textarea id="imMnemonic" spellcheck="false" rows="2" style="font-family:ui-monospace,monospace;font-size:12px" placeholder="paste the seed phrase"></textarea> + <div class="lbl" style="margin-top:6px">Derivation path</div> + <input type="text" id="imPath" spellcheck="false" placeholder="m/44'/1'/0'/0/0"> + <div class="hint">Default: <span class="mono">m/44'/1'/0'/0/0</span> for chipnet, <span class="mono">m/44'/145'/0'/0/0</span> for mainnet.</div> + </div> + <div class="field" id="imWifField" hidden> + <div class="lbl">WIF private key</div> + <input type="text" id="imWif" spellcheck="false" placeholder="Kx… or Lz… (base58check)"> + </div> + <div class="field"> + <div class="lbl">Label</div> + <input type="text" id="imLabel" placeholder="e.g. Sirius.x · BNS name buyer"> + </div> + <div class="field"> + <div class="lbl">Category</div> + <select id="imCategory" style="width:100%;padding:7px 9px;border-radius:7px;background:var(--panel);border:1px solid var(--line);color:var(--ink);font-size:13px"> + <option value="operational">operational</option> + <option value="bns">bns</option> + <option value="bns-infra">bns-infra</option> + <option value="chipnet-test">chipnet-test</option> + <option value="hd-general">hd-general</option> + <option value="primary">primary</option> + </select> + </div> + <div class="msg err" id="imMsg" hidden style="margin-top:8px"></div> + <div class="actions" style="justify-content:flex-end;margin-top:10px"> + <button class="btn" id="imCancel">Cancel</button> + <button class="btn primary" id="imGo">Import</button> + </div> + </div>`; + document.body.appendChild(overlay); + const close = () => { try { overlay.remove(); } catch {} }; + overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); + overlay.querySelector("#imClose").addEventListener("click", close); + overlay.querySelector("#imCancel").addEventListener("click", close); + // Toggle mnemonic vs WIF fields. + overlay.querySelectorAll('input[name="imKind"]').forEach((r) => r.addEventListener("change", () => { + const kind = overlay.querySelector('input[name="imKind"]:checked').value; + overlay.querySelector("#imMnemonicField").hidden = kind !== "mnemonic"; + overlay.querySelector("#imWifField").hidden = kind !== "wif"; + })); + // Update default path on network switch. + const setDefaultPath = () => { + const net = overlay.querySelector('input[name="imNet"]:checked').value; + const path = overlay.querySelector("#imPath"); + if (!path.value.trim()) path.value = net === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0"; + }; + overlay.querySelectorAll('input[name="imNet"]').forEach((r) => r.addEventListener("change", setDefaultPath)); + setDefaultPath(); + overlay.querySelector("#imGo").addEventListener("click", async () => { + const msg = overlay.querySelector("#imMsg"); msg.hidden = true; + const network = overlay.querySelector('input[name="imNet"]:checked').value; + const kind = overlay.querySelector('input[name="imKind"]:checked').value; + const label = overlay.querySelector("#imLabel").value.trim(); + const category = overlay.querySelector("#imCategory").value; + if (!label) { msg.textContent = "Label required."; msg.hidden = false; return; } + const payload = { chain: "bch", network, label, category }; + if (kind === "mnemonic") { + payload.mnemonic = overlay.querySelector("#imMnemonic").value.trim(); + payload.path = overlay.querySelector("#imPath").value.trim(); + if (!payload.mnemonic) { msg.textContent = "Mnemonic required."; msg.hidden = false; return; } + } else { + payload.wif = overlay.querySelector("#imWif").value.trim(); + if (!payload.wif) { msg.textContent = "WIF required."; msg.hidden = false; return; } + } + try { + state = await S.invoke("importWallet", payload); + close(); + render(); + } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } + }); +} +function showErr(text) { + const box = $("gate"); + box.hidden = false; box.innerHTML = `<div class="big">⚠</div><div>${esc(text)}</div>`; + setTimeout(() => { if (state?.selected?.phase === "ready") { box.hidden = true; } }, 3500); +} + +// ---- render ---------------------------------------------------------------- + +function render() { + if (!state) return; + const s = sel(); + const ready = s && s.phase === "ready"; + // Settings is the only always-usable tab (fiat prices, connected sites + // — nothing needs a live wallet). Every other tab is gated. + const onSettings = tab === "settings"; + $("tabs").hidden = !(ready || onSettings); + const gate = $("gate"); + gate.hidden = ready || onSettings; + if (onSettings) fillSettings(); + // Header: replace the badge slot with the coin's SVG and show + // <wallet label> <coin · network + optional TEST tag> + $("hBadge").innerHTML = s?.meta?.logo ? logoSvg(s.meta.logo, 22) : logoSvg(null, 22); + $("hLabel").textContent = s?.label || "Aegis Wallet"; + $("hNet").innerHTML = s?.meta + ? `${esc(s.meta.coinLabel)} · ${esc(s.meta.networkLabel)}${s.meta.testnet ? " " + testnetTag() : ""}` + : ""; + if (!ready) { + const copy = { + locked: ["🔒", "Unlock your password vault to open the wallet.", "Aegis derives its keys from the vault seed, so there is nothing separate to unlock — the vault is your wallet."], + nosetup: ["🗝", "Set up a password vault to create your wallet.", "Pick a master password on the next screen. Every Aegis wallet is derived from it — the same master password on another machine recreates the same addresses."], + error: ["⚠", "This wallet could not start.", s?.error || ""], + empty: ["🛡", "No wallets yet.", "Aegis derives every wallet from your Theseus password vault — there's no separate seed to import. Pick a coin below to create your first one."], + }[s?.phase || "locked"] || ["…", "Starting…", ""]; + const phase = s?.phase; + let form = ""; + if (phase === "nosetup") { + form = ` + <div class="gateform" style="margin-top:14px;display:flex;flex-direction:column;gap:8px;text-align:left"> + <input type="password" id="gateSetupPw" placeholder="Master password (4+ chars)" autocomplete="new-password"> + <input type="password" id="gateSetupPw2" placeholder="Confirm master password" autocomplete="new-password"> + <textarea id="gateSetupMnemonic" placeholder="BIP39 mnemonic — optional, 12 or 24 words" rows="2" spellcheck="false" style="font-family:ui-monospace,monospace;font-size:12px"></textarea> + <div class="hint">Optional. Paste a mnemonic to derive your vault from an existing seed (Ariadne mobile, another Theseus profile, etc.). Leave empty for a fresh independent seed.</div> + <div class="actions" style="justify-content:center;margin-top:6px"> + <button class="btn primary" id="gateSetupBtn">Create vault</button> + </div> + <div class="msg err" id="gateSetupMsg" hidden></div> + </div>`; + } else if (phase === "locked") { + form = ` + <div class="gateform" style="margin-top:14px;display:flex;flex-direction:column;gap:8px;text-align:left"> + <input type="password" id="gateUnlockPw" placeholder="Master password" autocomplete="current-password" autofocus> + <div class="actions" style="justify-content:center"> + <button class="btn primary" id="gateUnlockBtn">Unlock</button> + </div> + <div class="msg err" id="gateUnlockMsg" hidden></div> + </div>`; + } else if (phase === "empty") { + form = `<div class="actions" style="justify-content:center;margin-top:16px"><button class="btn primary" id="gateAddWallet">+ Add your first wallet</button></div>`; + } + gate.innerHTML = `<div class="big">${copy[0]}</div><div><b>${esc(copy[1])}</b></div><div class="hint" style="margin-top:8px">${esc(copy[2])}</div>${form}`; + if (phase === "empty") { + const btn = $("gateAddWallet"); + if (btn) btn.addEventListener("click", () => { + const d = $("drop"); + d.hidden = false; + fillPicker(); + setTimeout(() => { const first = d.querySelector(".coinrow"); if (first) first.click(); }, 0); + }); + } + if (phase === "locked") { + const doUnlock = async () => { + const pw = $("gateUnlockPw").value; + const msg = $("gateUnlockMsg"); msg.hidden = true; + if (!pw) return; + try { state = await S.invoke("vaultUnlock", { masterPassword: pw }); render(); } + catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } + }; + $("gateUnlockBtn").addEventListener("click", doUnlock); + $("gateUnlockPw").addEventListener("keydown", (e) => { if (e.key === "Enter") doUnlock(); }); + try { $("gateUnlockPw").focus(); } catch {} + } + if (phase === "nosetup") { + const doSetup = async () => { + const pw = $("gateSetupPw").value; + const pw2 = $("gateSetupPw2").value; + const mnemonic = $("gateSetupMnemonic").value.trim(); + const msg = $("gateSetupMsg"); msg.hidden = true; + if (!pw || pw.length < 4) { msg.textContent = "Master password must be 4+ characters."; msg.hidden = false; return; } + if (pw !== pw2) { msg.textContent = "Master passwords don't match."; msg.hidden = false; return; } + const seedSource = mnemonic ? { kind: "mnemonic", mnemonic } : { kind: "random" }; + try { + state = await S.invoke("vaultSetup", { masterPassword: pw, seedSource }); + render(); + } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } + }; + $("gateSetupBtn").addEventListener("click", doSetup); + } + } + const dot = $("dot"); + dot.className = "dot " + (s?.server ? (s?.scanning ? "busy" : "on") : ""); + $("netlbl").textContent = s?.server ? hostOf(s.server) + (s?.scanning ? " · syncing" : "") : (ready ? "connecting…" : (s?.network || "")); + if (ready) { + // Sia-specific gate: adapter is up, keys are derived, but no walletd URL + // means no balance / history until the user configures one in Settings. + if (chain() === "sc" && s.needsWalletdUrl) { + $("balMain").textContent = "—"; $("balTicker").textContent = s.meta.ticker; + $("netlbl").textContent = "point Aegis at a walletd node in Settings"; + $("tabs").hidden = true; + gate.hidden = false; + gate.innerHTML = `<div class="big">🗝</div><div><b>Point Aegis at a walletd node</b></div><div class="hint" style="margin-top:8px">Settings › Sia › walletd URL. Any public or self-hosted <span class="mono">go.sia.tech/walletd</span> in "full" index mode works.</div>`; + return; + } + const total = balanceSum(s.balance); + $("balMain").textContent = fmtBig(total); + $("balTicker").textContent = s.meta.ticker; + const uc = s.balance?.unconfirmed; + if (uc && uc !== "0" && uc !== 0) $("netlbl").textContent += ` · ${fmtBig(uc)} unconfirmed`; + // Fiat under the native amount (opt-in, might be null while loading). + const usd = usdOf(chain(), total, decimals()); + const fiat = fmtFiat(usd) || fiatSkeleton(); + $("balFiat").textContent = fiat || ""; + $("balFiat").hidden = !fiat; + } else { + $("balMain").textContent = "—"; $("balTicker").textContent = ""; + $("balFiat").hidden = true; + } + renderPortfolio(); + if (!ready) return; + const addr = s.address || ""; + if ($("addr").textContent !== addr) { + $("addr").textContent = addr; + drawQr(qrPayload(chain(), addr, sel()?.network)); + } + $("addrMeta").textContent = s.addressPath ? "· " + s.addressPath : ""; + $("nextAddr").hidden = chain() !== "bch"; + $("openFaucet").hidden = !s.faucet; + // Render SPL tokens list (SOL wallets only). Sending a token clicks + // through to the Send tab with that asset pre-picked. + renderTokens(); + applyUnitPicker(); + $("feeField").hidden = chain() !== "bch"; + renderHistory(); +} + +// Sum every wallet's confirmed+unconfirmed × price and show "≈ $X across N +// wallets" under the header. Only rendered when prices are on AND there are +// two or more wallets (a single wallet's fiat already sits in #balFiat). +function renderPortfolio() { + const el = $("portfolio"); + const wallets = state?.wallets || []; + if (!state?.prices?.enabled || wallets.length < 2) { el.hidden = true; return; } + let total = 0, priced = 0; + for (const w of wallets) { + const b = w.balance; + if (!b) continue; + const units = (typeof b.confirmed === "string") + ? (BigInt(b.confirmed || "0") + BigInt(b.unconfirmed || "0")).toString() + : (b.confirmed || 0) + (b.unconfirmed || 0); + const usd = usdOf(w.chain, units, w.decimals); + if (usd != null) { total += usd; priced++; } + } + if (!priced) { + el.hidden = false; + el.innerHTML = `Portfolio: <b>${esc(fiatSkeleton() || "—")}</b>`; + return; + } + const noun = wallets.length === 1 ? "wallet" : "wallets"; + el.hidden = false; + el.innerHTML = `Portfolio: <b>${esc(fmtFiat(total))}</b> across ${wallets.length} ${noun}`; +} + +function renderTokens() { + const s = sel(); + const tokens = (chain() === "sol" && s?.tokens) || []; + const card = $("tokensCard"); + card.hidden = tokens.length === 0; + if (!tokens.length) return; + const el = $("tokensList"); + el.innerHTML = tokens.map((t) => { + const dec = Number(t.decimals) || 0; + const bal = fmtTokenAmount(t.balance, dec); + return `<div class="tx" style="grid-template-columns:1fr auto auto;cursor:default"> + <div><div>${esc(t.symbol)}${t.name ? ' <span class="hint">' + esc(t.name) + '</span>' : ""}</div><div class="hint mono">${esc(t.mint.slice(0, 10))}…${esc(t.mint.slice(-6))}</div></div> + <div class="amt2 in" style="align-self:center">${esc(bal)}</div> + <button class="btn sm" data-mint="${esc(t.mint)}" data-symbol="${esc(t.symbol)}" data-decimals="${dec}" style="align-self:center">Send</button> + </div>`; + }).join(""); + el.querySelectorAll("button[data-mint]").forEach((b) => b.addEventListener("click", () => { + sendAsset = { mint: b.dataset.mint, symbol: b.dataset.symbol, decimals: Number(b.dataset.decimals) }; + showTab("send"); + })); +} +// Same shape as index.js's fmtTokenAmount — string-safe for u64 SPL amounts. +function fmtTokenAmount(rawStr, decimals) { + const s = String(rawStr || "0"); + const neg = s.startsWith("-"); + const abs = neg ? s.slice(1) : s; + const d = Number(decimals) || 0; + if (d === 0) return (neg ? "-" : "") + abs; + const pad = abs.padStart(d + 1, "0"); + const whole = pad.slice(0, pad.length - d); + const frac = pad.slice(pad.length - d).replace(/0+$/, ""); + return (neg ? "-" : "") + whole + (frac ? "." + frac : ""); +} + +function applyUnitPicker() { + const s = sel(); if (!s) return; + if (!unit) unit = "big"; + // ---- SPL asset picker (SOL wallets with tokens) --------------------- + const tokens = (chain() === "sol" && s.tokens) || []; + const assetField = $("sendAssetField"); + if (tokens.length) { + assetField.hidden = false; + const sel_ = $("sendAsset"); + // Rebuild whenever the asset set changes so a new token appears. + const key = tokens.map((t) => t.mint).join("|"); + if (sel_.dataset.key !== key) { + sel_.dataset.key = key; + sel_.innerHTML = `<option value="">SOL — native</option>` + tokens.map((t) => + `<option value="${esc(t.mint)}" data-symbol="${esc(t.symbol)}" data-decimals="${Number(t.decimals) || 0}">${esc(t.symbol)}${t.name ? " · " + esc(t.name) : ""}</option>` + ).join(""); + sel_.onchange = () => { + const opt = sel_.options[sel_.selectedIndex]; + sendAsset = opt && opt.value ? { mint: opt.value, symbol: opt.dataset.symbol, decimals: Number(opt.dataset.decimals) } : null; + applyUnitPicker(); schedulePlan(); + }; + } + // Reflect the current sendAsset back into the select. + sel_.value = sendAsset ? sendAsset.mint : ""; + } else { + assetField.hidden = true; + sendAsset = null; + } + const isToken = sendAsset != null; + const big = isToken ? sendAsset.symbol : bigUnitLabel(); + const small = isToken ? "raw" : smallUnitLabel(); + $("unitPicker").innerHTML = + `<button data-u="big" class="${unit === "big" ? "on" : ""}" type="button">${esc(big)}</button>` + + `<button data-u="small" class="${unit === "small" ? "on" : ""}" type="button">${esc(small)}</button>`; + $("unitPicker").querySelectorAll("button").forEach((b) => b.addEventListener("click", () => setUnit(b.dataset.u))); + $("sendTo").placeholder = ({ + bch: s.network === "chipnet" ? "bchtest:q… or legacy m…" : "bitcoincash:q… or legacy 1…", + btc: s.network === "testnet" ? "tb1q… (or 2… / m…, n…)" : "bc1q… (or bc1p…, 3…, 1…)", + trx: "T… (base58check, 34 chars)", + sc: "addr1… (76-hex + checksum)", + dgb: "dgb1q… (or D… / S… depending on family)", + eth: "0x… (40 hex chars, EIP-55)", + sol: "base58 public key (32 bytes)", + })[chain()] || "recipient address"; + $("sendAmt").placeholder = unit === "big" ? "0.00" : "0"; +} +function setUnit(u) { + if (u === unit) return; + const s = amountUnits(); + unit = u; + applyUnitPicker(); + if (s) $("sendAmt").value = unit === "big" ? fmtBig(s) : String(s); +} +function amountUnits() { + const raw = $("sendAmt").value.trim().replace(/,/g, ""); + if (!raw) return 0; + // For SPL tokens the amount is a raw u64 string in the token's own + // smallest unit — same BigInt-safe path SC uses. + const d = sendAsset ? Number(sendAsset.decimals) || 0 : decimals(); + const bigDecimals = sendAsset != null || d > 15; + if (unit === "small") { + if (bigDecimals) return raw.replace(/\D+/g, "") || "0"; + return Math.round(Number(raw)); + } + const [w, f = ""] = raw.split("."); + const frac = (f + "0".repeat(d)).slice(0, d); + if (bigDecimals) { + const total = (BigInt(w || "0") * (10n ** BigInt(d))) + BigInt(frac || "0"); + return total.toString(); + } + return Number(w || 0) * Math.pow(10, d) + Number(frac || 0); +} +// Sum "confirmed + unconfirmed" BigInt-safely (strings for SC, numbers elsewhere). +function balanceSum(b) { + if (!b) return 0; + if (typeof b.confirmed === "string" || typeof b.unconfirmed === "string") { + return (BigInt(b.confirmed || "0") + BigInt(b.unconfirmed || "0")).toString(); + } + return (b.confirmed || 0) + (b.unconfirmed || 0); +} + +// ---- history --------------------------------------------------------------- + +function renderHistory() { + const s = sel(); + const list = s?.history || []; + const el = $("txlist"); + if (!list.length) { el.innerHTML = `<div class="empty">${s?.scanning ? "Syncing…" : "No transactions yet."}</div>`; return; } + el.innerHTML = list.map((t) => { + const inc = t.delta >= 0; + const when = t.time ? new Date(t.time * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "pending"; + const who = inc ? (t.from ? "from " + shortAddr(t.from) : "") : (t.to ? "to " + shortAddr(t.to) : ""); + const what = (inc ? "Received" : "Sent") + (who ? " " + who : ""); + const conf = t.confirmations > 0 ? (t.confirmations >= 6 ? "confirmed" : t.confirmations + " conf") : (t.status === "failed" ? "failed" : "unconfirmed"); + const delta = Math.abs(t.delta || 0); + return `<div class="tx" data-txid="${esc(t.txid)}" title="${esc(t.txid)}"> + <div class="ic ${inc ? "in" : "out"}">${inc ? "↓" : "↑"}</div> + <div class="what">${esc(what)}</div> + <div class="amt2 ${inc ? "in" : ""}">${inc ? "+" : "−"}${delta ? fmtBig(delta) : "—"}</div> + <div class="when">${esc(when)}${t.fee != null ? " · fee " + fmtSmall(t.fee) + " " + smallUnitLabel() : ""}</div> + <div class="conf ${t.confirmations > 0 ? (t.status === "failed" ? "pending" : "") : "pending"}">${esc(conf)}</div> + </div>`; + }).join(""); + el.querySelectorAll(".tx").forEach((row) => row.addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, row.dataset.txid)))); +} +function shortAddr(a) { + if (!a) return ""; + const s = String(a).replace(/^bitcoincash:|^bchtest:/, ""); + return esc(s.slice(0, 10)) + "…" + esc(s.slice(-4)); +} + +// ---- QR -------------------------------------------------------------------- + +// Coin-scheme URI so wallet apps that scan know which chain the payment is +// for. Follows each chain's own convention (BIP21 for BTC-family, EIP-681 +// for ETH, Solana Pay for SOL, bare address for SC where no widely-agreed +// URI scheme exists). +function qrPayload(chain, address, network) { + if (chain === "bch") return (network === "chipnet" ? "bchtest:" : "bitcoincash:") + String(address).replace(/^bitcoincash:|^bchtest:/, ""); + if (chain === "btc") return "bitcoin:" + address; // BIP21 + if (chain === "dgb") return "digibyte:" + address; + if (chain === "eth") return "ethereum:" + address; + if (chain === "sol") return "solana:" + address; + if (chain === "trx") return "tron:" + address; + return String(address); +} +function drawQr(text) { + const cv = $("qr"); + const g = cv.getContext("2d"); + let q; + try { q = window.QR.build(text); } catch { g.clearRect(0, 0, cv.width, cv.height); return; } + const scale = Math.max(2, Math.floor(200 / (q.size + 2))); + const px = (q.size + 2) * scale; + cv.width = cv.height = px; + cv.style.width = cv.style.height = px + "px"; + g.fillStyle = "#fff"; g.fillRect(0, 0, px, px); + g.fillStyle = "#000"; + for (let r = 0; r < q.size; r++) for (let c = 0; c < q.size; c++) if (q.modules[r][c]) g.fillRect((c + 1) * scale, (r + 1) * scale, scale, scale); +} + +// ---- receive actions ------------------------------------------------------- + +$("copyAddr").addEventListener("click", async () => { + try { await navigator.clipboard.writeText(sel().address); flash($("copyAddr"), "Copied"); } catch {} +}); +$("nextAddr").addEventListener("click", async () => { + try { const s = await S.invoke("nextAddress"); state.selected = { ...state.selected, ...s }; render(); } + catch (e) { flash($("nextAddr"), "Failed"); } +}); +$("viewAddr").addEventListener("click", () => openUrl(explorerHref(sel().explorerAddr, sel().address))); +$("openFaucet").addEventListener("click", () => sel().faucet && openUrl(sel().faucet)); +function flash(btn, text) { + const old = btn.textContent; btn.textContent = text; + setTimeout(() => { btn.textContent = old; }, 1200); +} + +// ---- send ------------------------------------------------------------------ + +$("sendMax").addEventListener("click", () => { + sendMax = !sendMax; + $("sendMax").classList.toggle("primary", sendMax); + $("sendAmt").disabled = sendMax; + if (!sendMax) $("sendAmt").value = ""; + schedulePlan(); +}); +$("feeRate").addEventListener("input", () => { $("feeLbl").textContent = $("feeRate").value + " sat/B"; schedulePlan(); }); +["sendTo", "sendAmt"].forEach((id) => $(id).addEventListener("input", () => { if (id === "sendAmt" && sendMax) return; schedulePlan(); })); +function schedulePlan() { clearTimeout(planTimer); planTimer = setTimeout(updatePlan, 250); } +async function updatePlan() { + const to = $("sendTo").value.trim(); + const msg = $("sendMsg"); msg.hidden = true; + lastPlan = null; $("sendBtn").disabled = true; + $("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—"; + $("sendToHint").textContent = ""; + if (!to || (!sendMax && !amountUnits())) return; + try { + if (sendAsset) { + // SPL token flow — amount is raw units of the token's decimals. + const p = await S.invoke("planTokenSend", { mint: sendAsset.mint, to, amount: amountUnits() }); + lastPlan = { _token: true, ...p }; + $("sumAmt").textContent = fmtTokenAmount(p.recipients[0].value, sendAsset.decimals) + " " + sendAsset.symbol; + $("sumFee").textContent = fmtBig(p.fee, decimals()) + " SOL"; + $("sumTotal").textContent = fmtTokenAmount(p.total, sendAsset.decimals) + " " + sendAsset.symbol; + $("sendBtn").disabled = false; + return; + } + const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined; + const p = await S.invoke("planSend", { to, amount: amountUnits(), feeRate, sendMax }); + lastPlan = p; + $("sendToHint").textContent = p.recipients[0].to !== to ? "→ " + p.recipients[0].to : ""; + $("sumAmt").textContent = fmtBig(p.recipients[0].value) + " " + ticker(); + $("sumFee").textContent = chain() === "bch" + ? fmtSmall(p.fee) + " " + smallUnitLabel() + : fmtBig(p.fee) + " " + ticker(); + $("sumTotal").textContent = fmtBig(p.total) + " " + ticker(); + if (sendMax) $("sendAmt").value = unit === "big" ? fmtBig(p.recipients[0].value) : String(p.recipients[0].value); + $("sendBtn").disabled = false; + } catch (e) { + msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false; + } +} +$("sendBtn").addEventListener("click", async () => { + if (!lastPlan) return; + const msg = $("sendMsg"); msg.hidden = true; + $("sendBtn").disabled = true; $("sendBtn").textContent = "Waiting for approval…"; + try { + const isToken = sendAsset && lastPlan._token; + const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined; + const r = isToken + ? await S.invoke("sendToken", { mint: sendAsset.mint, to: $("sendTo").value.trim(), amount: amountUnits() }) + : await S.invoke("send", { to: $("sendTo").value.trim(), amount: amountUnits(), feeRate, sendMax }); + msg.className = "msg ok"; + msg.innerHTML = `Sent. <a class="link" data-tx="${esc(r.txid)}">${esc(r.txid.slice(0, 16))}…</a>`; + msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, r.txid))); + msg.hidden = false; + $("sendTo").value = ""; $("sendAmt").value = ""; sendMax = false; + $("sendMax").classList.remove("primary"); $("sendAmt").disabled = false; + lastPlan = null; + } catch (e) { + const t = cleanErr(e); + if (t !== "cancelled") { msg.className = "msg err"; msg.textContent = t; msg.hidden = false; } + $("sendBtn").disabled = !lastPlan; + } finally { $("sendBtn").textContent = "Send"; } +}); + +// ---- settings -------------------------------------------------------------- + +function fillSettings() { + // Global settings (fiat prices, connected sites) render even when there + // is no active wallet / the vault is locked. + renderPricesSetting(); + renderSites(); + const s = sel(); + const chainSetup = !!s && s.phase === "ready"; + $("walletManage").hidden = !chainSetup; + if (!chainSetup) { + $("bchSettings").hidden = true; + $("trxSettings").hidden = true; + $("scSettings").hidden = true; + $("dgbSettings").hidden = true; + $("btcSettings").hidden = true; + $("ethSettings").hidden = true; + $("solSettings").hidden = true; + return; + } + $("bchSettings").hidden = chain() !== "bch"; + $("trxSettings").hidden = chain() !== "trx"; + $("scSettings").hidden = chain() !== "sc"; + $("dgbSettings").hidden = chain() !== "dgb"; + $("btcSettings").hidden = chain() !== "btc"; + $("ethSettings").hidden = chain() !== "eth"; + $("solSettings").hidden = chain() !== "sol"; + $("removeBtn").disabled = !!s.isLegacy && s.chain === "bch"; + $("removeHint").textContent = (s.isLegacy && s.chain === "bch") + ? "The default BCH wallet cannot be removed (it protects legacy funds)." + : (s.isLegacy && s.chain === "sc" ? "Removing this wallet unlinks it from Aegis. Funds stay on-chain and reappear if you add a Siacoin wallet again with the legacy seed slot." : ""); + $("renameLabel").value = s.label || ""; + if (chain() === "bch") { + if (!settingsFilled) { + $("setPath").value = s.accountPath || ""; + $("setServers").value = (state.bchServers?.list || []).join("\n"); + settingsFilled = true; + } + $("bchServersRow").hidden = s.network !== "mainnet"; + $("serverHint").textContent = s.network !== "mainnet" + ? "Chipnet uses bundled defaults in this build." + : (state.bchServers?.custom ? "Custom list." : "Bundled defaults.") + (s.server ? " Connected to " + hostOf(s.server) + "." : " Not connected."); + $("purpose").textContent = "silentmode/addons/" + (s.purpose || ""); + renderWcSites(); + } else if (chain() === "trx") { + $("trxPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); + } else if (chain() === "sc") { + if (!settingsFilled) { + $("setWalletdUrl").value = s.walletdUrl || ""; + settingsFilled = true; + } + $("scPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); + $("scRecovery").innerHTML = ""; + } else if (chain() === "dgb") { + if (!settingsFilled) { + fillFamilyPicker("Dgb", s); + settingsFilled = true; + } + $("dgbPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); + $("dgbRecovery").innerHTML = ""; + } else if (chain() === "btc") { + if (!settingsFilled) { + fillFamilyPicker("Btc", s); + settingsFilled = true; + } + $("btcPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); + $("btcRecovery").innerHTML = ""; + } else if (chain() === "eth") { + if (!settingsFilled) { + $("setEthRpcUrl").value = s.rpcUrl || ""; + settingsFilled = true; + } + $("ethPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); + $("ethRecovery").innerHTML = ""; + } else if (chain() === "sol") { + if (!settingsFilled) { + $("setSolRpcUrl").value = s.rpcUrl || ""; + settingsFilled = true; + } + $("solPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); + $("solRecovery").innerHTML = ""; + } +} + +// Reflect the current price feed state into the Settings toggle + status +// line. Called from fillSettings() and whenever fresh state arrives. +function renderPricesSetting() { + const p = state?.prices; + const toggle = $("pricesToggle"); + if (!toggle) return; + toggle.checked = !!p?.enabled; + $("refreshPrices").hidden = !p?.enabled; + const st = $("pricesStatus"); + if (!p?.enabled) { st.textContent = "Disabled — no requests made."; return; } + if (p.loading) { st.textContent = "Fetching…"; return; } + if (p.error) { st.textContent = "Error: " + p.error; return; } + if (p.fetchedAt) { + const secs = Math.round((Date.now() - p.fetchedAt) / 1000); + const when = secs < 60 ? `${secs}s ago` : `${Math.round(secs / 60)}m ago`; + st.textContent = `Updated ${when} · ${Object.keys(p.prices || {}).length} coins.`; + return; + } + st.textContent = "Enabled — first fetch pending."; +} +async function renderSites() { + let perms = {}; + try { perms = await S.invoke("permissions"); } catch {} + const origins = Object.keys(perms).filter((o) => { + const p = perms[o]; + return p && (p.readAddress || p.sendTx || (p.trx && p.trx.readAddress)); + }); + const el = $("sites"); + if (!origins.length) { el.innerHTML = `<div class="hint">None yet.</div>`; return; } + el.innerHTML = origins.map((o) => { + const p = perms[o]; const what = []; + if (p.readAddress) what.push("BCH address"); + if (p.sendTx) what.push(`BCH payments: ${fmtBig(Math.max(0, p.sendTx.capSats - (p.sendTx.usedSats || 0)), 8)} of ${fmtBig(p.sendTx.capSats, 8)} BCH left`); + if (p.trx && p.trx.readAddress) what.push("Tron " + (p.trx.network === "nile" ? "Nile testnet" : "mainnet") + " address"); + return `<div class="tx" style="grid-template-columns:1fr auto;cursor:default"><div><div class="mono">${esc(o)}</div><div class="hint">${esc(what.join(" · "))}</div></div><button class="btn sm" data-origin="${esc(o)}">Revoke</button></div>`; + }).join(""); + el.querySelectorAll("button[data-origin]").forEach((b) => b.addEventListener("click", async () => { + try { await S.invoke("revoke", { origin: b.dataset.origin }); renderSites(); } catch {} + })); +} + +$("applySettings").addEventListener("click", async () => { + const msg = $("settingsMsg"); msg.hidden = true; + try { + const path = $("setPath").value.trim(); + const servers = $("setServers").value.split(/\n+/).map((s) => s.trim()).filter(Boolean); + if (path && path !== (sel().accountPath || "")) { + state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: path }); + } + if (sel().network === "mainnet") { + const currentJoined = (state.bchServers?.list || []).join(); + if (state.bchServers?.custom || servers.join() !== currentJoined) { + state = await S.invoke("setBchServers", { servers }); + } + } + settingsFilled = false; fillSettings(); render(); + flash($("applySettings"), "Applied"); + } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } +}); +$("resetServers").addEventListener("click", async () => { + try { state = await S.invoke("setBchServers", { servers: [] }); settingsFilled = false; fillSettings(); render(); } + catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } +}); +$("renameBtn").addEventListener("click", async () => { + const label = $("renameLabel").value.trim(); + if (!label) return; + try { state = await S.invoke("renameWallet", { id: state.selectedWalletId, label }); render(); flash($("renameBtn"), "Renamed"); } + catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } +}); +$("removeBtn").addEventListener("click", async () => { + const s = sel(); if (!s || s.isLegacy) return; + if (!confirm(`Remove the wallet "${s.label}"?\n\nThe on-chain address stays; the wallet is unlinked from Aegis. You can add it back later by creating a new wallet on the same coin + network.`)) return; + try { state = await S.invoke("removeWallet", { id: state.selectedWalletId }); settingsFilled = false; render(); } + catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } +}); +$("showXpub").addEventListener("click", async () => { + try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("recovery").innerHTML = recoveryHtml(r); } + catch (e) { $("recovery").textContent = cleanErr(e); } +}); +$("showXprv").addEventListener("click", async () => { + try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("recovery").innerHTML = recoveryHtml(r); } + catch (e) { $("recovery").textContent = cleanErr(e); } +}); +function recoveryHtml(r) { + let h = `<div class="lbl">Account path</div><div class="mono">${esc(r.accountPath)}</div><div class="lbl">Account xpub</div><div class="mono">${esc(r.xpub)}</div>`; + if (r.xprv) h += `<div class="lbl">Account private key (xprv)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>`; + return h; +} +document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => { + if (b.dataset.tab !== "settings") { + $("recovery").innerHTML = ""; + $("scRecovery").innerHTML = ""; + $("dgbRecovery").innerHTML = ""; + $("btcRecovery").innerHTML = ""; + $("ethRecovery").innerHTML = ""; + $("solRecovery").innerHTML = ""; + } +})); + +// Sia-specific settings. +$("applyWalletdUrl").addEventListener("click", async () => { + const msg = $("settingsMsg"); msg.hidden = true; + try { + state = await S.invoke("setWalletdUrl", { id: state.selectedWalletId, walletdUrl: $("setWalletdUrl").value.trim() }); + settingsFilled = false; fillSettings(); render(); flash($("applyWalletdUrl"), "Applied"); + } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } +}); +$("showScSeed").addEventListener("click", async () => { + try { + const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); + $("scRecovery").innerHTML = + `<div class="lbl">First address (index 0)</div><div class="mono">${esc(r.xpub || "")}</div>` + + (r.xprv ? `<div class="lbl">Wallet seed (hex)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>` : ""); + } catch (e) { $("scRecovery").textContent = cleanErr(e); } +}); + +// Family-picker helper used by both DGB and BTC. Prefix is "Dgb" or "Btc": +// the DOM IDs are #set<Prefix>Family + #set<Prefix>Path. +function fillFamilyPicker(prefix, s) { + const families = s.meta?.addressFamilies || []; + const current = String(s.accountPath || ""); + let currentId = families.find((f) => f.defaultAccountPath === current)?.id; + if (!currentId) { + const m = /^m\/(\d+)'/.exec(current); + const purpose = m ? Number(m[1]) : null; + currentId = families.find((f) => f.purpose === purpose)?.id || families[0]?.id; + } + $(`set${prefix}Path`).value = current || families[0]?.defaultAccountPath || ""; + $(`set${prefix}Family`).innerHTML = families.map((f) => + `<option value="${esc(f.id)}" data-path="${esc(f.defaultAccountPath)}" ${f.id === currentId ? "selected" : ""}>${esc(f.label)}</option>` + ).join(""); +} +// Any family select → auto-fill the sibling path input. +document.addEventListener("change", (e) => { + const t = e.target; + if (!t) return; + const m = /^set(Dgb|Btc)Family$/.exec(t.id || ""); + if (!m) return; + const opt = t.options[t.selectedIndex]; + if (opt && opt.dataset.path) $(`set${m[1]}Path`).value = opt.dataset.path; +}); +$("applyDgbPath").addEventListener("click", async () => { + const msg = $("settingsMsg"); msg.hidden = true; + try { + state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: $("setDgbPath").value.trim() }); + settingsFilled = false; fillSettings(); render(); flash($("applyDgbPath"), "Applied"); + } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } +}); +$("applyBtcPath").addEventListener("click", async () => { + const msg = $("settingsMsg"); msg.hidden = true; + try { + state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: $("setBtcPath").value.trim() }); + settingsFilled = false; fillSettings(); render(); flash($("applyBtcPath"), "Applied"); + } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } +}); +$("showBtcXpub").addEventListener("click", async () => { + try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("btcRecovery").innerHTML = recoveryHtml(r); } + catch (e) { $("btcRecovery").textContent = cleanErr(e); } +}); +$("showBtcXprv").addEventListener("click", async () => { + try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("btcRecovery").innerHTML = recoveryHtml(r); } + catch (e) { $("btcRecovery").textContent = cleanErr(e); } +}); + +// ETH / SOL: RPC URL. +$("applyEthRpc").addEventListener("click", async () => { + const msg = $("settingsMsg"); msg.hidden = true; + try { + state = await S.invoke("setRpcUrl", { id: state.selectedWalletId, rpcUrl: $("setEthRpcUrl").value.trim() }); + settingsFilled = false; fillSettings(); render(); flash($("applyEthRpc"), "Applied"); + } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } +}); +$("applySolRpc").addEventListener("click", async () => { + const msg = $("settingsMsg"); msg.hidden = true; + try { + state = await S.invoke("setRpcUrl", { id: state.selectedWalletId, rpcUrl: $("setSolRpcUrl").value.trim() }); + settingsFilled = false; fillSettings(); render(); flash($("applySolRpc"), "Applied"); + } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } +}); +$("showEthKey").addEventListener("click", async () => { + try { + const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); + $("ethRecovery").innerHTML = + `<div class="lbl">Address</div><div class="mono">${esc(sel().address || "")}</div>` + + `<div class="lbl">Public key (uncompressed hex)</div><div class="mono">${esc(r.xpub || "")}</div>` + + (r.xprv ? `<div class="lbl">Private key (hex)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>` : ""); + } catch (e) { $("ethRecovery").textContent = cleanErr(e); } +}); +$("showSolKey").addEventListener("click", async () => { + try { + const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); + $("solRecovery").innerHTML = + `<div class="lbl">Address (public key, base58)</div><div class="mono">${esc(r.xpub || "")}</div>` + + (r.xprv ? `<div class="lbl">Wallet seed (hex, 32 bytes)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>` : ""); + } catch (e) { $("solRecovery").textContent = cleanErr(e); } +}); +$("showDgbXpub").addEventListener("click", async () => { + try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("dgbRecovery").innerHTML = recoveryHtml(r); } + catch (e) { $("dgbRecovery").textContent = cleanErr(e); } +}); +$("showDgbXprv").addEventListener("click", async () => { + try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("dgbRecovery").innerHTML = recoveryHtml(r); } + catch (e) { $("dgbRecovery").textContent = cleanErr(e); } +}); + +// ---- WizardConnect (BCH only) --------------------------------------------- +function renderWcSites() { + const el = $("wcSites"); if (!el) return; + const walletId = state?.selectedWalletId; + const conns = (state?.wc && state.wc[walletId]) || []; + if (!conns.length) { el.innerHTML = `<div class="hint">No dapps paired yet.</div>`; return; } + el.innerHTML = conns.map((c) => { + const label = c.dappName || "(pairing…)"; + const iconHtml = c.dappIcon ? `<img src="${esc(c.dappIcon)}" style="width:18px;height:18px;border-radius:4px" onerror="this.hidden=true">` : ""; + return `<div class="tx" style="grid-template-columns:auto 1fr auto;cursor:default;align-items:center"> + <div>${iconHtml}</div> + <div><div>${esc(label)}</div><div class="hint mono">${esc((c.uri || "").slice(0, 46))}…</div></div> + <button class="btn sm" data-wcconn="${esc(c.id)}">Disconnect</button> + </div>`; + }).join(""); + el.querySelectorAll("button[data-wcconn]").forEach((b) => b.addEventListener("click", async () => { + try { state = await S.invoke("wcDisconnect", { walletId, connId: b.dataset.wcconn }); render(); } + catch (e) { const m = $("wcMsg"); m.className = "msg err"; m.textContent = cleanErr(e); m.hidden = false; } + })); +} +$("wcConnectBtn").addEventListener("click", async () => { + const walletId = state?.selectedWalletId; + const uri = $("wcUri").value.trim(); + const m = $("wcMsg"); m.hidden = true; + if (!uri) return; + try { + state = await S.invoke("wcConnect", { walletId, uri }); + $("wcUri").value = ""; + m.className = "msg ok"; m.textContent = "Pairing…"; m.hidden = false; + render(); + } catch (e) { + m.className = "msg err"; m.textContent = cleanErr(e); m.hidden = false; + } +}); + +// ---- prices toggle --------------------------------------------------------- +$("pricesToggle").addEventListener("change", async () => { + const on = $("pricesToggle").checked; + try { + state = await S.invoke("setPricesEnabled", { enabled: on }); + render(); if (tab === "settings") renderPricesSetting(); + } catch (e) { + // Roll the checkbox back if the host rejected the change. + $("pricesToggle").checked = !on; + $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; + } +}); +$("refreshPrices").addEventListener("click", async () => { + try { + await S.invoke("refreshPrices"); + // The host emits a state event on completion; the render will pick it up. + renderPricesSetting(); + } catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } +}); + +// ---- boot ------------------------------------------------------------------ +S.on("state", (s) => { state = s; render(); if (tab === "settings") fillSettings(); }); +(async () => { + try { state = await S.invoke("state"); render(); } + catch (e) { $("gate").hidden = false; $("gate").innerHTML = `<div class="big">⚠</div><div>${esc(cleanErr(e))}</div>`; } +})(); diff --git a/bundled-addons/aegis/qr.js b/bundled-addons/aegis/qr.js new file mode 100644 index 00000000..a0d29462 --- /dev/null +++ b/bundled-addons/aegis/qr.js @@ -0,0 +1,218 @@ +// Minimal QR encoder for the receive tab: byte mode, versions 1-10, error +// correction M (falls back to L when M won't fit). Returns a boolean matrix. +// Loaded as a plain script in panel.html and as a CommonJS module in tests. +(function (root) { + const EC_LEVELS = { L: 1, M: 0 }; + // Per version: [totalCodewords, {L:[ecPerBlock, [[blocks, dataCw], ...]], M:[...]}] + const TABLE = { + 1: [26, { L: [7, [[1, 19]]], M: [10, [[1, 16]]] }], + 2: [44, { L: [10, [[1, 34]]], M: [16, [[1, 28]]] }], + 3: [70, { L: [15, [[1, 55]]], M: [26, [[1, 44]]] }], + 4: [100, { L: [20, [[1, 80]]], M: [18, [[2, 32]]] }], + 5: [134, { L: [26, [[1, 108]]], M: [24, [[2, 43]]] }], + 6: [172, { L: [18, [[2, 68]]], M: [16, [[4, 27]]] }], + 7: [196, { L: [20, [[2, 78]]], M: [18, [[4, 31]]] }], + 8: [242, { L: [24, [[2, 97]]], M: [22, [[2, 38], [2, 39]]] }], + 9: [292, { L: [30, [[2, 116]]], M: [22, [[3, 36], [2, 37]]] }], + 10: [346, { L: [18, [[2, 68], [2, 69]]], M: [26, [[4, 43], [1, 44]]] }], + }; + const ALIGN = { 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50] }; + const REMAINDER = { 1: 0, 2: 7, 3: 7, 4: 7, 5: 7, 6: 7, 7: 0, 8: 0, 9: 0, 10: 0 }; + + // GF(256) with the QR polynomial 0x11d. + const EXP = new Uint8Array(512), LOG = new Uint8Array(256); + (function () { + let x = 1; + for (let i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; } + for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]; + })(); + const gmul = (a, b) => (a && b) ? EXP[LOG[a] + LOG[b]] : 0; + function generator(n) { + let g = [1]; + for (let i = 0; i < n; i++) { + const next = new Array(g.length + 1).fill(0); + for (let j = 0; j < g.length; j++) { next[j] ^= g[j]; next[j + 1] ^= gmul(g[j], EXP[i]); } + g = next; + } + return g; + } + function rsEncode(data, nEc) { + const g = generator(nEc); + const out = new Uint8Array(data.length + nEc); + out.set(data); + for (let i = 0; i < data.length; i++) { + const c = out[i]; + if (c) for (let j = 1; j < g.length; j++) out[i + j] ^= gmul(g[j], c); + } + return out.slice(data.length); + } + + function dataCapacity(v, ec) { return TABLE[v][1][ec][1].reduce((a, [b, d]) => a + b * d, 0); } + function pickVersion(len) { + for (const ec of ["M", "L"]) { + for (let v = 1; v <= 10; v++) { + const bitsNeeded = 4 + (v <= 9 ? 8 : 16) + len * 8; + if (bitsNeeded <= dataCapacity(v, ec) * 8) return { v, ec }; + } + } + throw new Error("qr: text too long"); + } + + function encodeData(bytes, v, ec) { + const cap = dataCapacity(v, ec); + const bits = []; + const push = (val, n) => { for (let i = n - 1; i >= 0; i--) bits.push((val >> i) & 1); }; + push(0b0100, 4); + push(bytes.length, v <= 9 ? 8 : 16); + for (const b of bytes) push(b, 8); + for (let i = 0; i < 4 && bits.length < cap * 8; i++) bits.push(0); + while (bits.length % 8) bits.push(0); + const data = new Uint8Array(cap); + for (let i = 0; i < bits.length / 8; i++) data[i] = parseInt(bits.slice(i * 8, i * 8 + 8).join(""), 2); + for (let i = bits.length / 8, k = 0; i < cap; i++, k++) data[i] = k % 2 ? 0x11 : 0xec; + return data; + } + + function interleave(data, v, ec) { + const [nEc, groups] = TABLE[v][1][ec]; + const blocks = []; let o = 0; + for (const [count, size] of groups) for (let i = 0; i < count; i++) { blocks.push(data.slice(o, o + size)); o += size; } + const ecBlocks = blocks.map((b) => rsEncode(b, nEc)); + const out = []; + const maxLen = Math.max(...blocks.map((b) => b.length)); + for (let i = 0; i < maxLen; i++) for (const b of blocks) if (i < b.length) out.push(b[i]); + for (let i = 0; i < nEc; i++) for (const b of ecBlocks) out.push(b[i]); + return out; + } + + // Remainder of value·x^degree modulo the generator poly (BCH error correction + // for the format / version fields). + function bch(value, poly, degree) { + let v = value << degree; + for (let i = 31 - Math.clz32(v); i >= degree; i--) if ((v >> i) & 1) v ^= poly << (i - degree); + return v; + } + const formatBits = (ec, mask) => { const d = (EC_LEVELS[ec] << 3) | mask; return ((d << 10) | bch(d, 0x537, 10)) ^ 0x5412; }; + const versionBits = (v) => (v << 12) | bch(v, 0x1f25, 12); + + function build(text) { + const bytes = typeof text === "string" ? new TextEncoder().encode(text) : Uint8Array.from(text); + const { v, ec } = pickVersion(bytes.length); + const size = v * 4 + 17; + const codewords = interleave(encodeData(bytes, v, ec), v, ec); + const grid = Array.from({ length: size }, () => new Uint8Array(size)); // 1 dark, 0 light + const fixed = Array.from({ length: size }, () => new Uint8Array(size)); // function pattern / reserved + const set = (r, c, val) => { grid[r][c] = val ? 1 : 0; fixed[r][c] = 1; }; + const finder = (r0, c0) => { + for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) { + const rr = r0 + r, cc = c0 + c; + if (rr < 0 || cc < 0 || rr >= size || cc >= size) continue; + const inner = r >= 0 && r <= 6 && c >= 0 && c <= 6; + const dark = inner && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4)); + set(rr, cc, dark); + } + }; + finder(0, 0); finder(0, size - 7); finder(size - 7, 0); + for (let i = 8; i < size - 8; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); } + const al = ALIGN[v], last = al.length - 1; + al.forEach((r, ri) => al.forEach((c, ci) => { + // The three corners that would sit on a finder pattern are omitted. + if ((ri === 0 && ci === 0) || (ri === 0 && ci === last) || (ri === last && ci === 0)) return; + for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) { + set(r + dr, c + dc, Math.max(Math.abs(dr), Math.abs(dc)) !== 1); + } + })); + set(size - 8, 8, 1); // dark module + // Reserve format areas (filled per mask below) and version areas. + for (let i = 0; i < 9; i++) { fixed[8][i] = 1; fixed[i][8] = 1; } + for (let i = 0; i < 8; i++) { fixed[8][size - 1 - i] = 1; fixed[size - 1 - i][8] = 1; } + if (v >= 7) { + const vb = versionBits(v); + for (let i = 0; i < 18; i++) { + const bit = (vb >> i) & 1; + set(Math.floor(i / 3), size - 11 + (i % 3), bit); + set(size - 11 + (i % 3), Math.floor(i / 3), bit); + } + } + // Zigzag data placement. + const bits = []; + for (const cw of codewords) for (let i = 7; i >= 0; i--) bits.push((cw >> i) & 1); + for (let i = 0; i < REMAINDER[v]; i++) bits.push(0); + let bi = 0, up = true; + for (let col = size - 1; col > 0; col -= 2) { + if (col === 6) col--; + for (let k = 0; k < size; k++) { + const r = up ? size - 1 - k : k; + for (const c of [col, col - 1]) { + if (fixed[r][c]) continue; + grid[r][c] = bi < bits.length ? bits[bi] : 0; + bi++; + } + } + up = !up; + } + // Try every mask, keep the lowest penalty. + const MASKS = [ + (i, j) => (i + j) % 2 === 0, (i) => i % 2 === 0, (_i, j) => j % 3 === 0, (i, j) => (i + j) % 3 === 0, + (i, j) => (Math.floor(i / 2) + Math.floor(j / 3)) % 2 === 0, (i, j) => ((i * j) % 2) + ((i * j) % 3) === 0, + (i, j) => (((i * j) % 2) + ((i * j) % 3)) % 2 === 0, (i, j) => (((i + j) % 2) + ((i * j) % 3)) % 2 === 0, + ]; + let best = null; + for (let m = 0; m < 8; m++) { + const g = grid.map((row) => Uint8Array.from(row)); + for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) if (!fixed[r][c] && MASKS[m](r, c)) g[r][c] ^= 1; + const fb = formatBits(ec, m); + for (let i = 0; i < 15; i++) { + const bit = (fb >> i) & 1; + // Copy 1: down column 8 (bits 0-7, skipping the timing row), then + // left along row 8 (bits 8-14). Copy 2: right end of row 8, bottom + // of column 8. + if (i < 6) g[i][8] = bit; else if (i === 6) g[7][8] = bit; else if (i === 7) g[8][8] = bit; + else if (i === 8) g[8][7] = bit; else g[8][14 - i] = bit; + if (i < 8) g[8][size - 1 - i] = bit; else g[size - 15 + i][8] = bit; + } + const p = penalty(g, size); + if (!best || p < best.p) best = { g, p, m }; + } + return { size, version: v, ec, mask: best.m, modules: best.g.map((row) => Array.from(row, (x) => !!x)) }; + } + + function penalty(g, n) { + let p = 0; + const runs = (get) => { + for (let a = 0; a < n; a++) { + let run = 1; + for (let b = 1; b <= n; b++) { + if (b < n && get(a, b) === get(a, b - 1)) run++; + else { if (run >= 5) p += 3 + run - 5; run = 1; } + } + } + }; + runs((a, b) => g[a][b]); runs((a, b) => g[b][a]); + for (let r = 0; r < n - 1; r++) for (let c = 0; c < n - 1; c++) { + const s = g[r][c] + g[r][c + 1] + g[r + 1][c] + g[r + 1][c + 1]; + if (s === 0 || s === 4) p += 3; + } + const pat = [1, 0, 1, 1, 1, 0, 1]; + const finderLike = (get, a) => { + for (let b = 0; b <= n - 7; b++) { + let ok = true; + for (let k = 0; k < 7; k++) if (get(a, b + k) !== pat[k]) { ok = false; break; } + if (!ok) continue; + const before = b >= 4 && [0, 1, 2, 3].every((k) => get(a, b - 1 - k) === 0); + const after = b + 10 < n && [0, 1, 2, 3].every((k) => get(a, b + 7 + k) === 0); + if (before || after) p += 40; + } + }; + for (let a = 0; a < n; a++) { finderLike((x, y) => g[x][y], a); finderLike((x, y) => g[y][x], a); } + let dark = 0; + for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) dark += g[r][c]; + const pct = (dark * 100) / (n * n); + p += Math.floor(Math.abs(pct - 50) / 5) * 10; + return p; + } + + const api = { build }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + else root.QR = api; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/bundled-addons/aegis/wallet-inject.js b/bundled-addons/aegis/wallet-inject.js new file mode 100644 index 00000000..ae4d1f2b --- /dev/null +++ b/bundled-addons/aegis/wallet-inject.js @@ -0,0 +1,506 @@ +// Aegis dapp bridges. Runs in the isolated world of every matching tab and +// puts up to two dapp APIs on the page's main world: +// +// window.bitcoincash Silent Mode's own BCH API (only on .x pages — +// matches the pre-multi-wallet gate). +// window.tronWeb TronLink-shaped Tron dapp bridge (on any page). +// window.tronLink TronLink shim; `tron_requestAccounts` triggers +// an approval overlay via activate(). +// +// Everything crosses into the wallet's activate() context in main, which +// shows the approval overlay and enforces per-origin permissions. Nothing +// here can sign, read a balance, or read an address on its own. +// +// `theseus` is provided by the host: { id, origin, contextBridge, invoke }. + +const call = (msg, payload) => + theseus.invoke(msg, payload).catch((e) => { + const text = String(e && e.message || e).replace(/^Error invoking remote method '[^']+': (Error: )?/, ""); + throw new Error(text); + }); + +// -------- BCH bridge (window.bitcoincash), only on .x pages ------------------ +const isBchOrigin = (() => { try { return /\.x$/i.test(location.hostname); } catch { return false; } })(); +if (isBchOrigin) { + theseus.contextBridge.exposeInMainWorld("bitcoincash", { + isTheseus: true, + version: "0.2.0", + network: "mainnet", + getAddress: () => call("getAddress"), + signAndSend: (txSpec) => call("signAndSend", txSpec && typeof txSpec === "object" ? txSpec : {}), + signMessage: (message) => call("signMessage", { message: String(message ?? "") }), + }); +} + +// -------- Tron bridge (window.tronWeb, window.tronLink), everywhere --------- +// +// Matches enough of the TronLink surface for read + send flows on dapps like +// tronscan, sunswap, and justlend (mainnet or Nile). Not implemented: +// TRC20/TRC721 abstractions, address utilities beyond base58<->hex, event +// emitters beyond the "message" events TronLink documents. + +let CACHED = { address: null, network: null }; +const listeners = new Set(); + +// Public copy of tronWeb.trx. +const trx = { + // Returns the account object TronGrid gives (may be {} for never-funded). + // Deliberately mirrors tronWeb.trx.getAccount. + getAccount: async () => call("trx.getAccount"), + // Sign an assembled transaction (dapp built it via its own tronWeb). + // Returns the same object with a `signature` field populated. + sign: async (transaction) => { + if (!transaction || typeof transaction !== "object" || !transaction.raw_data_hex) { + throw new Error("tronWeb.trx.sign: expected an assembled transaction with raw_data_hex"); + } + return call("trx.signTransaction", { transaction }); + }, + // Broadcast a signed transaction. Returns { result: true, txid } on success. + sendRawTransaction: async (signedTx) => { + const r = await call("trx.sendRawTransaction", { transaction: signedTx }); + return { result: true, txid: r.txid }; + }, + // Convenience wrappers matching the tronWeb surface — dapps call these + // when the wallet is supposed to build the tx too. Left unimplemented + // in this build so a dapp that hits them gets a clear error instead of + // silently unassembled behavior; assemble on the dapp side. + sendTrx: async () => { throw new Error("tronWeb.trx.sendTrx not implemented — build the transaction with tronWeb.transactionBuilder and call sign+sendRawTransaction"); }, + // TronLink documents signMessage (legacy, keccak of just the message) and + // signMessageV2 (adds the "\x19TRON Signed Message:\n<len>" prefix). We + // only implement V2 because that is what modern dapps use. + signMessageV2: async (message) => { + const r = await call("trx.signMessageV2", { message: String(message ?? "") }); + return r.signature; + }, +}; + +// Shallow tronWeb — enough for `tronWeb.defaultAddress.base58` / `.hex` reads +// and the trx methods. Populated once the user connects. +const tronWeb = { + ready: false, + defaultAddress: { base58: false, hex: false, name: false }, + fullNode: { host: "" }, + solidityNode: { host: "" }, + eventServer: { host: "" }, + trx, + // Utility helpers a few dapps poke at. + isConnected: () => tronWeb.ready === true && !!tronWeb.defaultAddress.base58, + address: { + fromHex: (hex) => hex, // no-op stub; dapps that need a real + toHex: (base58) => base58, // conversion should use their own copy. + }, + // Some dapps read this to decide "Tron mainnet vs testnet". + get currentNetwork() { return CACHED.network || null; }, +}; + +function nodeHost(network) { + return network === "nile" ? "https://nile.trongrid.io" : "https://api.trongrid.io"; +} +function applyConnection({ address, network }) { + CACHED = { address, network }; + tronWeb.defaultAddress = { base58: address || false, hex: false, name: false }; + tronWeb.ready = !!address; + tronLink.ready = !!address; + const host = nodeHost(network); + tronWeb.fullNode = { host }; + tronWeb.solidityNode = { host }; + tronWeb.eventServer = { host }; + // Broadcast on the window in the way TronLink documents. + try { + window.postMessage({ message: { action: "accountsChanged", data: { address } }, isTronLink: true }, location.origin); + window.postMessage({ message: { action: "setAccount", data: { address } }, isTronLink: true }, location.origin); + window.postMessage({ message: { action: "setNode", data: { node: { fullNode: host, solidityNode: host, eventServer: host, chain: network === "nile" ? "0xcd8690dc" : "0x2b6653dc" } } }, isTronLink: true }, location.origin); + } catch {} + for (const fn of listeners) { try { fn({ address, network }); } catch {} } +} + +const tronLink = { + ready: false, + tronWeb: tronWeb, + // TronLink's canonical request router. Handles at minimum tron_requestAccounts. + request: async (opts) => { + const method = String(opts?.method || ""); + if (method === "tron_requestAccounts" || method === "requestAccounts") { + const r = await call("trx.requestAccounts"); + applyConnection({ address: r.address, network: r.network }); + return { code: 200, message: "ok" }; + } + if (method === "tron_accounts") { + const r = await call("trx.getAccount").catch(() => null); + return r ? [r.address] : []; + } + throw new Error("Aegis: unsupported tronLink method " + method); + }, + // Dapps sometimes attach here to react to wallet changes. + on: (event, fn) => { + if (event === "message" || event === "accountsChanged" || event === "networkChanged") listeners.add(fn); + }, + removeListener: (_event, fn) => listeners.delete(fn), +}; + +theseus.contextBridge.exposeInMainWorld("tronWeb", tronWeb); +theseus.contextBridge.exposeInMainWorld("tronLink", tronLink); + +// -------- Main-world bridges (window.ethereum, window.solana) --------------- +// +// EIP-1193 (Ethereum) and the Solana wallet-adapter both expect the wallet +// object to touch the dapp's own JS values — Solana in particular passes +// `Transaction` instances whose `.serializeMessage()` method the wallet has +// to call. Electron's contextBridge shallow-copies function arguments +// between worlds and strips methods, so bridges that need to call methods +// on dapp-side objects have to live in the main world. We inject a `<script>` +// with the bridge source; it runs synchronously in the main world and talks +// to us via `window.postMessage` on a namespaced envelope. This mirrors how +// MetaMask and Phantom bridge extension code back to page code. + +// Namespace used on the postMessage envelope. Includes the addon id so a +// page that runs multiple dapp-wallet extensions doesn't misroute messages. +const AEGIS_TAG = "aegis-" + theseus.id; +const pendingCalls = new Map(); +window.addEventListener("message", async (e) => { + const d = e && e.data; + if (!d || d.aegisTag !== AEGIS_TAG) return; + if (d.kind === "request") { + // Forward main-world → isolated-world → addon. + try { + const result = await call(d.msg, d.payload); + window.postMessage({ aegisTag: AEGIS_TAG, kind: "response", id: d.id, ok: true, result }, location.origin); + } catch (err) { + window.postMessage({ aegisTag: AEGIS_TAG, kind: "response", id: d.id, ok: false, error: String(err && err.message || err) }, location.origin); + } + } +}); +function emitToMainWorld(event, data) { + try { window.postMessage({ aegisTag: AEGIS_TAG, kind: "event", event, data }, location.origin); } catch {} +} + +// The main-world bridge — installed as a page-level `<script>` so it can +// call methods on Transaction objects the dapp hands it, and so the globals +// it defines look like ordinary page code to the dapp. +const mainWorldSource = `(function () { + if (window.__aegisBridge) return; + window.__aegisBridge = true; + const TAG = ${JSON.stringify(AEGIS_TAG)}; + const pending = new Map(); + let seq = 1; + function invoke(msg, payload) { + const id = "r" + (seq++); + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + window.postMessage({ aegisTag: TAG, kind: "request", id, msg, payload }, location.origin); + }); + } + window.addEventListener("message", (e) => { + const d = e && e.data; + if (!d || d.aegisTag !== TAG) return; + if (d.kind === "response") { + const p = pending.get(d.id); + if (!p) return; + pending.delete(d.id); + d.ok ? p.resolve(d.result) : p.reject(new Error(d.error)); + } else if (d.kind === "event") { + dispatchEvent(d.event, d.data); + } + }); + + // ---- Ethereum (EIP-1193) ------------------------------------------------ + const ethListeners = { connect: [], disconnect: [], accountsChanged: [], chainChanged: [], message: [] }; + let ethState = { address: null, chainIdHex: "0x1", networkVersion: "1" }; + function ethEmit(event, data) { + for (const fn of ethListeners[event] || []) { try { fn(data); } catch {} } + } + // After a chain switch or add, refresh the local chainId/address state + // and fire the two events dapps expect (accountsChanged + + // chainChanged). MetaMask does the same round-trip after switchChain. + async function pullEthStateAndEmit() { + try { + const s = await invoke("eth.state"); + const oldChain = ethState.chainIdHex, oldAddr = ethState.address; + ethState.address = s.address || null; + ethState.chainIdHex = s.chainIdHex; + ethState.networkVersion = s.networkVersion; + window.ethereum.selectedAddress = ethState.address; + window.ethereum.chainId = ethState.chainIdHex; + window.ethereum.networkVersion = ethState.networkVersion; + if (s.chainIdHex !== oldChain) ethEmit("chainChanged", s.chainIdHex); + if ((s.address || null) !== oldAddr) ethEmit("accountsChanged", s.address ? [s.address] : []); + } catch {} + } + async function ethHandle(method, params) { + params = Array.isArray(params) ? params : (params ? [params] : []); + switch (method) { + case "eth_requestAccounts": { + const r = await invoke("eth.requestAccounts"); + ethState.address = r.address; + ethState.chainIdHex = r.chainIdHex; + ethState.networkVersion = r.networkVersion; + window.ethereum.selectedAddress = r.address; + window.ethereum.chainId = r.chainIdHex; + window.ethereum.networkVersion = r.networkVersion; + ethEmit("accountsChanged", [r.address]); + return [r.address]; + } + case "eth_accounts": + return ethState.address ? [ethState.address] : []; + case "eth_chainId": + return ethState.chainIdHex; + case "net_version": + return ethState.networkVersion; + case "personal_sign": { + // Both param orders are seen in the wild: [message, from] and [from, message]. + const [a, b] = params; + const looksLikeAddr = (s) => typeof s === "string" && /^0x[0-9a-fA-F]{40}$/.test(s); + const message = looksLikeAddr(a) ? b : a; + return (await invoke("eth.personalSign", { message: String(message) })).signature; + } + case "eth_sign": { + // Legacy method. Same shape as personal_sign for our purposes. + const [_from, msg] = params; + return (await invoke("eth.personalSign", { message: String(msg) })).signature; + } + case "eth_sendTransaction": { + const tx = params[0] || {}; + return (await invoke("eth.sendTransaction", { tx })).txid; + } + case "eth_signTypedData_v4": + case "eth_signTypedData": + case "eth_signTypedData_v3": { + // v3/v4 differ mostly in nested-struct support; the encoder handles + // both. v1 is the flat "type[]" schema that Metamask deprecated — + // reject it, dapps that still use v1 should upgrade. + const [a, b] = params; + const looksLikeAddr = (s) => typeof s === "string" && /^0x[0-9a-fA-F]{40}$/.test(s); + const typedData = looksLikeAddr(a) ? b : a; + return (await invoke("eth.signTypedData", { typedData })).signature; + } + case "wallet_switchEthereumChain": { + const target = String((params[0] && params[0].chainId) || "").toLowerCase(); + try { + const r = await invoke("eth.switchChain", { chainId: target }); + await pullEthStateAndEmit(); + return r; + } + catch (e) { + // EIP-3326: preserve the 4902 signal the isolated-world handler + // stamps on the Error so dapps fall through to addChain. + if (/is not added/i.test(e.message || "")) { const err = new Error(e.message); err.code = 4902; throw err; } + throw e; + } + } + case "wallet_addEthereumChain": { + // EIP-3085. Approval overlay + persistence live in the addon. + const r = await invoke("eth.addChain", { params: params[0] }); + await pullEthStateAndEmit(); + return r; + } + case "wallet_getPermissions": + case "wallet_requestPermissions": + // Minimum shape most dapps accept. + return [{ parentCapability: "eth_accounts" }]; + default: + // Read passthrough: eth_getBalance, eth_call, eth_blockNumber, etc. + return invoke("eth.rpc", { method, params }); + } + } + const ethereum = { + isAegis: true, + isMetaMask: false, + chainId: ethState.chainIdHex, + networkVersion: ethState.networkVersion, + selectedAddress: null, + request: (opts) => ethHandle(String(opts && opts.method || ""), (opts && opts.params) || []), + // EIP-1193 event surface. + on: (event, fn) => { if (ethListeners[event]) ethListeners[event].push(fn); }, + removeListener: (event, fn) => { + const arr = ethListeners[event]; if (!arr) return; + const i = arr.indexOf(fn); if (i >= 0) arr.splice(i, 1); + }, + // Legacy compat some old dapps still call. + enable: () => ethereum.request({ method: "eth_requestAccounts" }), + sendAsync: (payload, cb) => ethereum.request(payload).then((result) => cb(null, { id: payload.id, jsonrpc: "2.0", result }), (err) => cb(err)), + send: (methodOrPayload, params) => ethereum.request(typeof methodOrPayload === "string" ? { method: methodOrPayload, params } : methodOrPayload), + }; + + // ---- Solana (wallet-adapter shape) -------------------------------------- + const solListeners = { connect: [], disconnect: [], accountChanged: [] }; + let solState = { publicKey: null }; + function makePubkey(base58) { + return { + toString: () => base58, + toBase58: () => base58, + toBytes: () => base58ToBytes(base58), + equals: (other) => other && other.toString && other.toString() === base58, + _bn: null, + }; + } + // Local base58 decoder — needed to expose PublicKey.toBytes(). Alphabet + // matches Bitcoin's (the only base58 flavor in real use). + const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + function base58ToBytes(s) { + let zeros = 0; + while (zeros < s.length && s[zeros] === B58[0]) zeros++; + const out = new Uint8Array(Math.ceil(s.length * 733 / 1000 + 1)); + let length = 0; + for (let i = zeros; i < s.length; i++) { + const v = B58.indexOf(s[i]); + if (v < 0) throw new Error("bad base58 char " + s[i]); + let carry = v, j = 0; + for (let k = out.length - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) { + carry += 58 * out[k]; + out[k] = carry & 0xff; + carry >>>= 8; + } + length = j; + } + let it = out.length - length; + while (it < out.length && out[it] === 0) it++; + const total = zeros + (out.length - it); + const dec = new Uint8Array(total); + let p = zeros; + while (it < out.length) dec[p++] = out[it++]; + return dec; + } + function bytesToBase58(b) { + let zeros = 0; + while (zeros < b.length && b[zeros] === 0) zeros++; + const buf = new Uint8Array(Math.ceil(b.length * 138 / 100 + 1)); + let length = 0; + for (let i = zeros; i < b.length; i++) { + let carry = b[i], j = 0; + for (let k = buf.length - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) { + carry += (buf[k] << 8) >>> 0; + buf[k] = carry % 58; + carry = (carry / 58) | 0; + } + length = j; + } + let it = buf.length - length; + while (it < buf.length && buf[it] === 0) it++; + let out = ""; + for (let i = 0; i < zeros; i++) out += B58[0]; + for (; it < buf.length; it++) out += B58[buf[it]]; + return out; + } + function u8ToBase64(u8) { + let s = ""; + for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); + return btoa(s); + } + function base64ToU8(s) { + const bin = atob(s); + const u8 = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); + return u8; + } + async function solConnect() { + const r = await invoke("sol.connect"); + solState.publicKey = makePubkey(r.address); + window.solana.publicKey = solState.publicKey; + window.solana.isConnected = true; + for (const fn of solListeners.connect) { try { fn(solState.publicKey); } catch {} } + return { publicKey: solState.publicKey }; + } + async function solDisconnect() { + solState.publicKey = null; + window.solana.publicKey = null; + window.solana.isConnected = false; + for (const fn of solListeners.disconnect) { try { fn(); } catch {} } + } + async function solSignMessage(u8) { + if (!(u8 instanceof Uint8Array)) u8 = new Uint8Array(u8 || []); + const r = await invoke("sol.signMessage", { messageB64: u8ToBase64(u8) }); + return { publicKey: solState.publicKey, signature: base58ToBytes(r.signature) }; + } + // Solana Transaction objects have serializeMessage() → Uint8Array and + // addSignature(publicKey, sig) — we live in the main world, so we can + // call both. Wallets that live in an isolated content-script can't. + async function solSignAndSendTransaction(tx, opts) { + if (!tx || typeof tx.serialize !== "function") { + throw new Error("Aegis: pass a @solana/web3.js Transaction (needs .serialize)"); + } + // Serialize the FULL wire including any partial signatures the dapp + // has already collected (multi-signer flows: session keys, escrows, + // ephemeral co-signers). Aegis fills the wallet's own signature slot + // in the addon and leaves the other slots untouched. + const wire = tx.serialize({ requireAllSignatures: false, verifySignatures: false }); + const r = await invoke("sol.signAndSend", { wireB64: u8ToBase64(new Uint8Array(wire)) }); + return { signature: r.txid, publicKey: solState.publicKey }; + } + async function solSignTransaction(tx) { + if (!tx || typeof tx.serializeMessage !== "function" || typeof tx.addSignature !== "function") { + throw new Error("Aegis: pass a @solana/web3.js Transaction"); + } + const messageBytes = tx.serializeMessage(); + const r = await invoke("sol.signMessage", { messageB64: u8ToBase64(new Uint8Array(messageBytes)) }); + // r.signature is base58 of the 64-byte ed25519 sig. + tx.addSignature(solState.publicKey, base58ToBytes(r.signature)); + return tx; + } + const solana = { + isAegis: true, + isPhantom: true, // set so dapps that gate on isPhantom pick us + isConnected: false, + publicKey: null, + connect: async (opts) => solConnect(opts), + disconnect: solDisconnect, + signMessage: (u8) => solSignMessage(u8), + signTransaction: (tx) => solSignTransaction(tx), + signAllTransactions: async (txs) => { + const out = []; + for (const tx of txs) out.push(await solSignTransaction(tx)); + return out; + }, + signAndSendTransaction: (tx, opts) => solSignAndSendTransaction(tx, opts), + request: async (opts) => { + const method = String(opts && opts.method || ""); + const params = opts && opts.params || {}; + if (method === "connect") return solConnect(); + if (method === "disconnect") return solDisconnect(); + if (method === "signMessage") return solSignMessage(params.message); + if (method === "signTransaction") return solSignTransaction(params.transaction); + if (method === "signAndSendTransaction") return solSignAndSendTransaction(params.transaction, params.options); + throw new Error("Aegis: unsupported solana method " + method); + }, + on: (event, fn) => { if (solListeners[event]) solListeners[event].push(fn); }, + off: (event, fn) => { + const arr = solListeners[event]; if (!arr) return; + const i = arr.indexOf(fn); if (i >= 0) arr.splice(i, 1); + }, + removeAllListeners: () => { Object.keys(solListeners).forEach((k) => solListeners[k].length = 0); }, + }; + + function dispatchEvent(event, data) { + if (event === "eth.accountsChanged") { ethState.address = data.address || null; ethereum.selectedAddress = ethState.address; ethEmit("accountsChanged", data.address ? [data.address] : []); } + else if (event === "eth.chainChanged") { ethState.chainIdHex = data.chainIdHex; ethState.networkVersion = data.networkVersion; ethereum.chainId = data.chainIdHex; ethereum.networkVersion = data.networkVersion; ethEmit("chainChanged", data.chainIdHex); } + else if (event === "sol.accountChanged") { + const pk = data.address ? makePubkey(data.address) : null; + solState.publicKey = pk; solana.publicKey = pk; + for (const fn of solListeners.accountChanged || []) { try { fn(pk); } catch {} } + } + } + + // Install the globals. Defined lazily via Object.defineProperty so we + // survive dapps that check hasOwnProperty(window, "ethereum") after page + // load — MetaMask does the same trick. + try { Object.defineProperty(window, "ethereum", { value: ethereum, writable: false, configurable: false }); } catch { window.ethereum = ethereum; } + try { Object.defineProperty(window, "solana", { value: solana, writable: false, configurable: false }); } catch { window.solana = solana; } + // EIP-6963 provider announcement so wagmi / RainbowKit discover Aegis. + try { + const info = { uuid: crypto.randomUUID(), name: "Aegis", icon: "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpolygon points='16,2 29,9 29,23 16,30 3,23 3,9' fill='none' stroke='%23d6ff3d' stroke-width='2.5'/%3E%3Ccircle cx='16' cy='16' r='4.3' fill='none' stroke='%23d6ff3d' stroke-width='1.6'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23d6ff3d'/%3E%3C/svg%3E", rdns: "st.silentmode.aegis" }; + const announce = () => window.dispatchEvent(new CustomEvent("eip6963:announceProvider", { detail: Object.freeze({ info, provider: ethereum }) })); + announce(); + window.addEventListener("eip6963:requestProvider", announce); + } catch {} +})();`; + +// Actually push the script into the main world. Doing this at +// document_start (which is when this preload runs) means the bridge is in +// place before the dapp's own scripts execute. +try { + const s = document.createElement("script"); + s.textContent = mainWorldSource; + (document.head || document.documentElement).appendChild(s); + s.remove(); +} catch (e) { + console.warn("[aegis] main-world bridge install failed:", e && e.message || e); +} diff --git a/bundled-addons/notepad/addon.json b/bundled-addons/notepad/addon.json new file mode 100644 index 00000000..48f99eeb --- /dev/null +++ b/bundled-addons/notepad/addon.json @@ -0,0 +1,10 @@ +{ + "id": "notepad", + "name": "Notepad", + "version": "0.1.0", + "description": "Right-sidebar notes. Text lives on this machine only, autosaves as you type.", + "author": "Silent Mode", + "icon": "📝", + "main": "index.js", + "capabilities": ["sidebar-panel"] +} diff --git a/bundled-addons/notepad/index.js b/bundled-addons/notepad/index.js new file mode 100644 index 00000000..270ef87f --- /dev/null +++ b/bundled-addons/notepad/index.js @@ -0,0 +1,13 @@ +// Notepad — reference add-on. Registers one sidebar panel; the panel's +// HTML does all the actual work via window.silentmode.storage. +module.exports = { + activate(api) { + api.registerSidebarPanel({ + id: "main", + title: "Notepad", + icon: "📝", + page: "note.html", + }); + api.log("registered notepad panel"); + }, +}; diff --git a/bundled-addons/notepad/note.html b/bundled-addons/notepad/note.html new file mode 100644 index 00000000..d4f164c4 --- /dev/null +++ b/bundled-addons/notepad/note.html @@ -0,0 +1,100 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Notepad</title> +<style> + :root { color-scheme: light dark; + --bg:#0e131c; --panel:#141a24; --line:rgba(255,255,255,.09); + --ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; } + @media (prefers-color-scheme: light) { + :root { --bg:#f8faff; --panel:#ffffff; --line:rgba(0,0,0,.10); + --ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; } + } + * { box-sizing: border-box; } + html, body { margin: 0; height: 100%; } + body { background: var(--bg); color: var(--ink); + font: 14px/1.55 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + display: flex; flex-direction: column; } + header { display: flex; align-items: center; justify-content: space-between; + padding: 10px 14px; border-bottom: 1px solid var(--line); + background: var(--panel); } + header .t { font-weight: 600; letter-spacing: .2px; display: flex; gap: 8px; align-items: center; } + header .t .em { font-size: 15px; } + header .m { color: var(--dim); font-size: 11.5px; font-variant-numeric: tabular-nums; } + textarea { + flex: 1; width: 100%; padding: 14px 16px; border: none; outline: none; resize: none; + background: transparent; color: var(--ink); + font: 13.5px/1.65 ui-monospace, "Cascadia Code", Consolas, monospace; + } + textarea::placeholder { color: var(--dim); } + footer { padding: 6px 14px 10px; border-top: 1px solid var(--line); + color: var(--dim); font-size: 11px; display: flex; justify-content: space-between; } + footer .l { display: flex; gap: 10px; } + footer .l b { color: var(--mut); font-weight: 500; } + footer button { border: none; background: transparent; color: var(--mut); + cursor: pointer; font: inherit; padding: 0; } + footer button:hover { color: var(--acid); } +</style> +</head> +<body> +<header> + <div class="t"><span class="em">📝</span> <span>Notepad</span></div> + <div class="m" id="status">saved</div> +</header> +<textarea id="pad" placeholder="Notes. Autosaves as you type." + spellcheck="false" autofocus></textarea> +<footer> + <div class="l"><b><span id="cchars">0</span></b> chars · <b><span id="cwords">0</span></b> words</div> + <button id="clear" title="Clear all notes">clear</button> +</footer> + +<script> + const pad = document.getElementById("pad"); + const status = document.getElementById("status"); + const cchars = document.getElementById("cchars"); + const cwords = document.getElementById("cwords"); + + function updateCounts() { + const v = pad.value; + cchars.textContent = v.length; + cwords.textContent = v.trim() ? v.trim().split(/\s+/).length : 0; + } + + // Load previous text on open. + (async () => { + try { + const text = await window.silentmode.storage.get("text", ""); + pad.value = text || ""; + updateCounts(); + } catch (e) { console.warn("load failed:", e); } + })(); + + // Debounced autosave. + let saveTimer = null; + pad.addEventListener("input", () => { + updateCounts(); + status.textContent = "saving…"; + clearTimeout(saveTimer); + saveTimer = setTimeout(async () => { + try { + await window.silentmode.storage.set("text", pad.value); + status.textContent = "saved"; + } catch (e) { + status.textContent = "save failed"; + console.warn("save failed:", e); + } + }, 400); + }); + + document.getElementById("clear").addEventListener("click", async () => { + if (!pad.value) return; + if (!confirm("Clear all notes?")) return; + pad.value = ""; + updateCounts(); + try { await window.silentmode.storage.set("text", ""); status.textContent = "saved"; } catch {} + pad.focus(); + }); +</script> +</body> +</html> diff --git a/bundled-addons/screenshot/addon.json b/bundled-addons/screenshot/addon.json new file mode 100644 index 00000000..ed8f36b8 --- /dev/null +++ b/bundled-addons/screenshot/addon.json @@ -0,0 +1,11 @@ +{ + "id": "screenshot", + "name": "Screenshot", + "version": "0.6.4", + "description": "Capture the current tab — visible viewport, full page, or a rectangle you draw. Preview + annotate editor (crop, arrow, rect, ellipse, pen, text, blur/mosaic redaction, undo, copy, save) live inside the sidebar. Expand the sidebar to full window for a canvas-sized editor.", + "author": "Silent Mode", + "icon": "📸", + "main": "index.js", + "capabilities": ["sidebar-panel", "capture-tab"], + "updateURL": "https://navigate.st/bns/theseus.x/extensions/screenshot/updates.json" +} diff --git a/bundled-addons/screenshot/editor.css b/bundled-addons/screenshot/editor.css new file mode 100644 index 00000000..7b6c12d1 --- /dev/null +++ b/bundled-addons/screenshot/editor.css @@ -0,0 +1,164 @@ +/* Screenshot editor v2 — sidebar-embedded. Compact but not cramped; scales + the canvas to fit whatever width the sidebar has, up to full window when + the user hits the maximize button in the topbar. */ +:root { color-scheme: light dark; + --bg:#0e131c; --panel:#141a24; --panel2:#191f2b; --line:rgba(255,255,255,.09); + --ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; + --danger:#ff5b5b; --board:#0a0d13; } +@media (prefers-color-scheme: light) { + :root { --bg:#f8faff; --panel:#ffffff; --panel2:#eff3fb; --line:rgba(0,0,0,.10); + --ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid:#0AC18E; + --board:#dde3ee; } +} +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { background: var(--bg); color: var(--ink); + font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + display: flex; flex-direction: column; overflow: hidden; } +/* Author styles set `display: flex` on .empty/.stage — plain [hidden] + loses the cascade fight and the "Loading capture" pill stayed visible + over the drawn image. Force-hide takes it out for real. */ +[hidden] { display: none !important; } + +.topbar, .toolbar { display: flex; align-items: center; gap: 4px; padding: 6px 8px; + border-bottom: 1px solid var(--line); background: var(--panel); + user-select: none; flex-wrap: wrap; } +/* The topbar has fixed-size window controls only; no reason to ever wrap + its row. Filename lives in the footer now, so nothing can grow the row + past what the icons need. */ +.topbar { flex-wrap: nowrap; } +/* Toolbar layout: drawing tools live inside `.tool-cluster` which is a + flex-grow container centring its own contents. Copy/Save (`.tgroup + .actions`) sit outside it and pin to the right edge. On a maximized + sidebar the cluster centres in the empty space between name-area and + the actions; at narrow widths the whole cluster shrinks and wraps by + category, and the actions can wrap to their own row on the right. */ +.toolbar { align-items: flex-start; gap: 8px; row-gap: 6px; } +.tool-cluster { + display: flex; flex-wrap: wrap; align-items: center; + gap: 10px; row-gap: 6px; + flex: 1 1 auto; justify-content: center; +} +.tgroup { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; } +.tgroup.actions { flex: 0 0 auto; align-self: center; } + +/* Close-sidebar button — reads as danger without shouting. */ +#close-sidebar:hover, +#discard:hover { border-color: rgba(255,91,91,.55); color: var(--danger); } +.btn.danger { color: var(--danger); } +.btn.danger:hover { border-color: rgba(255,91,91,.55); } +.topbar .name { color: var(--dim); font-size: 11.5px; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; max-width: 200px; margin: 0 4px; } + +/* Footer strip that carries the filename + "Open in folder" affordance, + sitting under the canvas board. The filename truncates first so the + button never falls off the edge; the button never wraps. */ +.footer { display: flex; align-items: center; gap: 8px; + padding: 6px 10px; border-top: 1px solid var(--line); + background: var(--panel); font-size: 11.5px; } +.footer .fname { color: var(--dim); flex: 1 1 auto; min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.footer .fbtn { border: 1px solid var(--line); background: var(--panel2); + color: var(--ink); border-radius: 6px; cursor: pointer; + padding: 4px 8px; display: inline-flex; gap: 6px; align-items: center; + flex: 0 0 auto; font: inherit; line-height: 1; } +.footer .fbtn svg { width: 14px; height: 14px; display: block; } +.footer .fbtn:hover { border-color: rgb(from var(--acid) r g b / .55); } +.topbar .spacer, .toolbar .spacer { flex: 1; } + +.btn, .tool { + border: 1px solid var(--line); background: var(--panel2); color: var(--ink); + width: 30px; height: 30px; border-radius: 6px; cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + padding: 0; transition: background 100ms, border-color 100ms; + font: inherit; line-height: 0; +} +.btn:hover, .tool:hover { border-color: rgb(from var(--acid) r g b / .5); } +.btn:disabled, .tool:disabled { opacity: .4; cursor: default; } +.btn:disabled:hover, .tool:disabled:hover { border-color: var(--line); } +.tool.active { + background: rgb(from var(--acid) r g b / .15); + border-color: rgb(from var(--acid) r g b / .7); + color: var(--acid); +} +.btn svg, .tool svg { width: 16px; height: 16px; display: block; } +.btn.wide { + width: auto; padding: 0 10px; gap: 6px; line-height: 1; +} +.btn.wide span { font-size: 12px; } +.btn.primary { + background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; +} +.btn.primary:hover { filter: brightness(1.06); border-color: transparent; } + +.sep { width: 1px; height: 20px; background: var(--line); margin: 0 3px; } + +.swatch { + width: 22px; height: 22px; border-radius: 50%; padding: 0; + border: 2px solid var(--line); cursor: pointer; +} +.swatch.active { + border-color: var(--acid); + outline: 1px solid rgb(from var(--acid) r g b / .35); outline-offset: 1px; +} + +.width { + border: 1px solid var(--line); background: var(--panel2); color: var(--ink); + width: 30px; height: 30px; border-radius: 6px; cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; padding: 0; +} +.width.active { + border-color: rgb(from var(--acid) r g b / .7); + background: rgb(from var(--acid) r g b / .15); +} +.width .dot { border-radius: 50%; background: currentColor; color: var(--ink); } +.width.active .dot { color: var(--acid); } + +/* Canvas board -------------------------------------------------------- */ +.board { + flex: 1; overflow: auto; background: var(--board); + display: flex; align-items: flex-start; justify-content: center; + padding: 12px; +} +.empty { color: var(--dim); font-size: 13px; padding: 40px 20px; text-align: center; + display: flex; align-items: center; justify-content: center; height: 100%; } +.empty.err { color: var(--danger); } + +.stage { position: relative; transform-origin: top left; + box-shadow: 0 2px 12px rgba(0,0,0,.35); background: #fff; } +.stage canvas { display: block; position: absolute; left: 0; top: 0; } +.stage #base { position: static; } +.stage #over { pointer-events: none; } +.stage[data-tool="arrow"] { cursor: crosshair; } +.stage[data-tool="rect"] { cursor: crosshair; } +.stage[data-tool="ellipse"]{ cursor: crosshair; } +.stage[data-tool="pen"] { cursor: crosshair; } +.stage[data-tool="text"] { cursor: text; } +.stage[data-tool="crop"] { cursor: crosshair; } +.stage[data-tool="blur"] { cursor: crosshair; } + +/* Apply-crop is the primary action while a crop marquee is up. */ +#apply-crop { + background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; +} +#apply-crop:hover { filter: brightness(1.06); background: var(--acid); border-color: transparent; } +#cancel-crop:hover { border-color: rgba(255,91,91,.5); color: var(--danger); } + +.text-input { + position: absolute; z-index: 10; + background: rgba(20,26,36,.92); color: var(--ink); + border: 1px solid var(--acid); padding: 2px 6px; + font: 15px/1.2 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + outline: none; border-radius: 3px; min-width: 80px; +} + +.toast { + position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%) translateY(20px); + padding: 8px 14px; border-radius: 6px; background: rgba(11,14,20,.94); + color: var(--ink); font-size: 12.5px; border: 1px solid var(--line); + opacity: 0; pointer-events: none; transition: opacity 180ms, transform 180ms; + z-index: 100; max-width: 80vw; text-align: center; +} +.toast.on { opacity: 1; transform: translateX(-50%) translateY(0); } +.toast.err { border-color: rgba(255,91,91,.5); color: var(--danger); } +</style> diff --git a/bundled-addons/screenshot/editor.html b/bundled-addons/screenshot/editor.html new file mode 100644 index 00000000..25136815 --- /dev/null +++ b/bundled-addons/screenshot/editor.html @@ -0,0 +1,148 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Screenshot editor</title> +<link rel="stylesheet" href="editor.css"> +</head> +<body> +<div class="topbar"> + <button class="btn" id="back" title="Back to sidebar panel"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 8H3M6 5L3 8l3 3"/></svg> + </button> + + <!-- Crop mode splits the topbar into a confirm/cancel prompt while the + user is dragging a crop rectangle. Copy / Save / Discard live in + the toolbar now, so only the maximize + sound + close cluster on + the right needs to yield to the crop confirm buttons. --> + <button class="btn wide" id="apply-crop" hidden title="Apply the crop (Enter)"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8l3 3 7-7"/></svg> + <span>Apply crop</span> + </button> + <button class="btn wide" id="cancel-crop" hidden title="Cancel crop (Esc)"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg> + <span>Cancel</span> + </button> + + <span class="spacer"></span> + <button class="btn danger" id="discard" title="Discard this capture and return to the panel"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> + <path d="M3 4h10"/> + <path d="M6 4V2h4v2"/> + <path d="M4 4l1 10h6l1-10"/> + <path d="M7 6.5v5M9 6.5v5"/> + </svg> + </button> + <button class="btn" id="toggle-sound" title="Toggle sound"></button> + <button class="btn" id="toggle-max" title="Expand the sidebar to full window / restore"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/></svg> + </button> + <button class="btn" id="close-sidebar" title="Close the sidebar"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg> + </button> +</div> + +<!-- Toolbar rows are grouped so that when the sidebar is too narrow to + hold everything, whole categories wrap together (tools stay with + tools, swatches with swatches, widths with widths, history with + history) instead of one dot spilling to a lonely second row. + `.tool-cluster` centres itself so on a maximized sidebar the drawing + groups sit in the middle of the row and the Copy/Save actions + right-anchor cleanly against the edge, instead of the tools crowding + the left when there's a lot of empty space. --> +<div class="toolbar"> + <div class="tool-cluster"> + <div class="tgroup" role="group" aria-label="Tools"> + <button class="tool" data-tool="crop" title="Crop (C)"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 1v11h11M1 4h11v11"/></svg> + </button> + <button class="tool" data-tool="arrow" title="Arrow (A)"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13L13 3M13 3H8M13 3v5"/></svg> + </button> + <button class="tool" data-tool="rect" title="Rectangle (R)"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="11" height="9"/></svg> + </button> + <button class="tool" data-tool="ellipse" title="Ellipse (O)"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><ellipse cx="8" cy="8" rx="5.5" ry="4"/></svg> + </button> + <button class="tool" data-tool="pen" title="Pen (P)"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14c1-3 3-6 6-8s5-3 6-3l-3 4c-2 1-4 3-6 4s-2 2-3 3z"/></svg> + </button> + <button class="tool" data-tool="text" title="Text (T)"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 3h10M8 3v10M6 13h4"/></svg> + </button> + <button class="tool" data-tool="blur" title="Blur / mosaic redaction (B)"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="2.5" width="11" height="11"/><path d="M5 6h1M8 6h1M11 6h1M5 9h1M8 9h1M11 9h1M5 12h1M8 12h1M11 12h1"/></svg> + </button> + </div> + + <div class="tgroup" role="group" aria-label="Colours"> + <button class="swatch active" data-color="#ff3b30" style="background:#ff3b30" title="Red"></button> + <button class="swatch" data-color="#d6ff3d" style="background:#d6ff3d" title="Acid"></button> + <button class="swatch" data-color="#ffcc00" style="background:#ffcc00" title="Yellow"></button> + <button class="swatch" data-color="#0aa8ff" style="background:#0aa8ff" title="Blue"></button> + <button class="swatch" data-color="#ffffff" style="background:#ffffff" title="White"></button> + <button class="swatch" data-color="#000000" style="background:#000000" title="Black"></button> + </div> + + <div class="tgroup" role="group" aria-label="Widths"> + <button class="width" data-width="3" title="Thin"><span class="dot" style="width:4px;height:4px"></span></button> + <button class="width active" data-width="5" title="Medium"><span class="dot" style="width:7px;height:7px"></span></button> + <button class="width" data-width="9" title="Thick"><span class="dot" style="width:11px;height:11px"></span></button> + </div> + + <div class="tgroup" role="group" aria-label="History"> + <button class="btn" id="undo" title="Undo (Ctrl+Z)" disabled> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 8c0-3 2-5 5-5s5 2 5 5-2 5-5 5"/><path d="M6 5L3 8l3 3"/></svg> + </button> + <button class="btn" id="redo" title="Redo (Ctrl+Shift+Z)" disabled> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M13 8c0-3-2-5-5-5S3 5 3 8s2 5 5 5"/><path d="M10 5l3 3-3 3"/></svg> + </button> + </div> + </div> + + <!-- Actions cluster — Copy / Save — anchored to the right of the toolbar. + Uses margin-left:auto so on wide sidebars it lives out at the end + of the drawing-tool row; when the sidebar is narrow enough that the + drawing tools already ate the width, it wraps as its own row to the + right instead of jumping the tools around. --> + <div class="tgroup actions" role="group" aria-label="Actions"> + <button class="btn wide" id="copy" title="Copy PNG to clipboard (Ctrl+C)"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="4" width="9" height="10"/><path d="M3 12V3h9"/></svg> + <span>Copy</span> + </button> + <button class="btn wide primary" id="save" title="Save PNG (Ctrl+S)"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v9M4 7l4 4 4-4M2 14h12"/></svg> + <span>Save</span> + </button> + </div> +</div> + +<div class="board" id="board"> + <div class="empty" id="empty"> + <div>Loading capture…</div> + </div> + <div class="stage" id="stage" hidden> + <canvas id="base"></canvas> + <canvas id="over"></canvas> + </div> +</div> + +<!-- Footer strip: the current capture's filename lives here now (moved out + of the topbar so its length can never push the window-control icons + to a second row), with an "Open in folder" affordance for jumping to + the scratch dir in the OS file explorer. --> +<div class="footer"> + <span class="fname" id="name">screenshot</span> + <button class="fbtn" id="open-folder" title="Open the scratch folder in the file explorer"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 5h4l1.5 2H14v6H2z"/><path d="M2 5V3h5"/></svg> + <span>Open in folder</span> + </button> +</div> + +<div class="toast" id="toast"></div> +<a id="download-link" style="display:none"></a> + +<script src="editor.js"></script> +</body> +</html> diff --git a/bundled-addons/screenshot/editor.js b/bundled-addons/screenshot/editor.js new file mode 100644 index 00000000..f305c19a --- /dev/null +++ b/bundled-addons/screenshot/editor.js @@ -0,0 +1,763 @@ +// Screenshot editor v2. Lives inside the sidebar view; no separate tab. +// +// Load path (idempotent — this is the key change from v1's __pending drain): +// editor.html?name=<scratchfile.png> +// → silentmode.invoke("getBytes", {name}) +// → addon reads scratch/<name>.png from disk and returns +// { name, dataUrl:"data:image/png;base64,…", bytes, at, mode } +// → we set an <img> src to that data URL and draw it onto #base +// +// Two canvases: #base holds the committed image; #over is a preview layer +// that hosts the live drag preview for each drawing tool. On mouseup the +// tool commits its result by drawing #over onto #base, clearing #over, +// and pushing a fresh ImageData snapshot onto the undo stack. +// +// No cross-origin img loading (data: URLs are same-origin in Chromium), so +// the canvas never gets tainted — getImageData / toBlob keep working. + +const $ = (id) => document.getElementById(id); +const UNDO_MAX = 25; + +// ---- audio ----------------------------------------------------------- +// Same synth engine as the sidebar panel: preference stored under +// silentmode.storage as "soundOn" (default true), no .wav ships in the +// tarball. Photoshoot-style shutter for capture, quick "printer chirp" +// for copy — modeled on Firefox's Screenshots feedback tones. +let soundOn = true; +let _audio = null; +function _actx() { + if (!_audio) { try { _audio = new (window.AudioContext || window.webkitAudioContext)(); } catch {} } + if (_audio && _audio.state === "suspended") _audio.resume().catch(() => {}); + return _audio; +} +function _tone(freq, dur, type, vol, at) { + if (!soundOn) return; + const c = _actx(); if (!c) return; + const t0 = c.currentTime + (at || 0); + const osc = c.createOscillator(); + const g = c.createGain(); + osc.type = type || "sine"; + osc.frequency.setValueAtTime(freq, t0); + g.gain.setValueAtTime(vol || 0.15, t0); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + osc.connect(g); g.connect(c.destination); + osc.start(t0); + osc.stop(t0 + dur + 0.02); +} +// Filtered noise burst. `bp` = band-pass centre freq, `q` = sharpness of +// the band. Optional `pan` gives a random-amplitude micro-modulation +// texture — used by the paper-crumple pass. +function _noise(dur, vol, bp, q, at, texture) { + if (!soundOn) return; + const c = _actx(); if (!c) return; + const t0 = c.currentTime + (at || 0); + const n = Math.max(1, Math.floor(c.sampleRate * dur)); + const buf = c.createBuffer(1, n, c.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < n; i++) { + let s = Math.random() * 2 - 1; + if (texture) s *= 0.4 + Math.random() * 0.6; // crackle + d[i] = s; + } + const src = c.createBufferSource(); src.buffer = buf; + const flt = c.createBiquadFilter(); flt.type = "bandpass"; + flt.frequency.setValueAtTime(bp || 2000, t0); + flt.Q.setValueAtTime(q || 4, t0); + const g = c.createGain(); + g.gain.setValueAtTime(vol || 0.2, t0); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + src.connect(flt); flt.connect(g); g.connect(c.destination); + src.start(t0); + src.stop(t0 + dur + 0.02); +} +// Slowly-sweeping band-pass noise — used as the film-advance "whir" tail +// under the shutter click. +function _sweep(dur, vol, fromHz, toHz, q, at) { + if (!soundOn) return; + const c = _actx(); if (!c) return; + const t0 = c.currentTime + (at || 0); + const n = Math.max(1, Math.floor(c.sampleRate * dur)); + const buf = c.createBuffer(1, n, c.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1; + const src = c.createBufferSource(); src.buffer = buf; + const flt = c.createBiquadFilter(); flt.type = "bandpass"; + flt.frequency.setValueAtTime(fromHz, t0); + flt.frequency.exponentialRampToValueAtTime(toHz, t0 + dur); + flt.Q.setValueAtTime(q || 8, t0); + const g = c.createGain(); + g.gain.setValueAtTime(0.0001, t0); + g.gain.exponentialRampToValueAtTime(vol, t0 + dur * 0.2); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + src.connect(flt); flt.connect(g); g.connect(c.destination); + src.start(t0); + src.stop(t0 + dur + 0.02); +} +// Polaroid: sharp mechanical click (shutter mirror + curtain) followed by +// a short film-advance whir. Reads unmistakably as "camera taking a +// picture" rather than a UI beep. +function playShutter() { + _noise(0.02, 0.32, 5200, 8, 0); // sharp metallic tick + _tone(160, 0.03, "square", 0.14, 0.003); // mirror thud + _noise(0.03, 0.24, 3200, 5, 0.03); // curtain close + _tone(120, 0.04, "square", 0.10, 0.035); + _sweep(0.28, 0.10, 900, 400, 12, 0.07); // film-advance whir tail +} +// Printer chika-chika-chika — three descending percussive bursts modelled +// on a print head sweeping across a page. Fast and unmistakably "action +// happened", no residual hum. +function playCopy() { + _noise(0.028, 0.22, 3800, 9, 0.00); + _tone(1200, 0.03, "sine", 0.10, 0.00); + _noise(0.028, 0.22, 3200, 9, 0.06); + _tone(1000, 0.03, "sine", 0.10, 0.06); + _noise(0.028, 0.22, 2600, 9, 0.12); + _tone(800, 0.03, "sine", 0.10, 0.12); +} +// Photo dispensing — a soft pneumatic hiss with a small final click, the +// way a Polaroid ejects its print. +function playSave() { + _sweep(0.18, 0.14, 3800, 1600, 3, 0); + _noise(0.02, 0.20, 2400, 8, 0.18); + _tone(900, 0.04, "sine", 0.12, 0.20); +} +// Paper crumple — a long band-limited noise with texture crackle and a +// descending centre frequency, reading as "a page being scrunched". +function playDiscard() { + _noise(0.16, 0.28, 2600, 3, 0.00, true); // main body + _noise(0.12, 0.22, 1800, 3, 0.06, true); // trailing scrunch + _noise(0.08, 0.16, 1200, 3, 0.12, true); +} +function playCropApply() { _tone(1200, 0.05, "sine", 0.14, 0); _tone(1600, 0.07, "sine", 0.14, 0.04); } + +const base = $("base"); +const over = $("over"); +const stage = $("stage"); +const board = $("board"); +const empty = $("empty"); +const nameEl = $("name"); +const undoBtn = $("undo"); +const redoBtn = $("redo"); +const toastEl = $("toast"); +const dl = $("download-link"); + +const bctx = base.getContext("2d"); +const octx = over.getContext("2d"); + +const params = new URLSearchParams(location.search); +const NAME = params.get("name") || ""; + +let state = { + tool: "select", + color: "#ff3b30", + width: 5, + drag: null, // {x0,y0,x,y} + pen: null, // [{x,y}, …] + textInput: null, // {x, y, el} + cropRect: null, // committed-crop rectangle in canvas coords, {x,y,w,h} +}; + +let undo = []; +let redo = []; + +// -- utilities -------------------------------------------------------------- +function toast(msg, err) { + toastEl.textContent = msg; + toastEl.classList.toggle("err", !!err); + toastEl.classList.add("on"); + clearTimeout(toast._t); + toast._t = setTimeout(() => toastEl.classList.remove("on"), 1800); +} + +function updateUndoRedo() { + undoBtn.disabled = undo.length <= 1; // baseline snapshot always at index 0 + redoBtn.disabled = redo.length === 0; +} + +function pushSnapshot() { + try { + const snap = bctx.getImageData(0, 0, base.width, base.height); + undo.push(snap); + if (undo.length > UNDO_MAX) undo.shift(); + redo = []; + updateUndoRedo(); + } catch (e) { + console.warn("snapshot failed:", e); + } +} + +function restoreSnapshot(snap) { + if (!snap) return; + if (base.width !== snap.width || base.height !== snap.height) { + sizeCanvases(snap.width, snap.height); + } + bctx.putImageData(snap, 0, 0); +} + +function sizeCanvases(w, h) { + for (const c of [base, over]) { + c.width = w; + c.height = h; + c.style.width = w + "px"; + c.style.height = h + "px"; + } + stage.style.width = w + "px"; + stage.style.height = h + "px"; +} + +// Scale the stage down to fit inside the board when the image is larger +// than the visible area. Purely visual — drawing math stays in natural px. +function fitBoard() { + const availW = Math.max(50, board.clientWidth - 24); + const availH = Math.max(50, board.clientHeight - 24); + const s = Math.min(1, availW / base.width, availH / base.height); + const scale = s > 0 && Number.isFinite(s) ? s : 1; + stage.style.transform = `scale(${scale})`; + stage.style.width = (base.width * scale) + "px"; + stage.style.height = (base.height * scale) + "px"; + stage._scale = scale; +} + +// Convert a viewport-relative pointer event into natural canvas coords. +function pointToCanvas(ev) { + const rect = base.getBoundingClientRect(); + const scale = stage._scale || 1; + const cssW = rect.width, cssH = rect.height; + const x = (ev.clientX - rect.left) * (base.width / (cssW || 1)); + const y = (ev.clientY - rect.top) * (base.height / (cssH || 1)); + return { x, y }; +} + +// -- loading --------------------------------------------------------------- +async function loadFromAddon(name) { + if (!window.silentmode?.invoke) { + throw new Error("silentmode API not available in this view"); + } + const res = await window.silentmode.invoke("getBytes", { name }); + if (!res || !res.dataUrl) throw new Error("addon returned no data URL"); + return res; +} + +function loadImage(dataUrl) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error("image decode failed")); + // NB: no crossOrigin — data: URLs are same-origin, and setting it + // to anonymous would require CORS headers that a data URL can't carry, + // which is precisely what tripped up v1. + img.src = dataUrl; + }); +} + +async function init() { + document.title = NAME ? NAME + " — editor" : "Screenshot editor"; + nameEl.textContent = NAME || "screenshot"; + if (!NAME) { + empty.classList.add("err"); + empty.innerHTML = "<div>Missing <code>?name</code> — open a capture from the Screenshot sidebar.</div>"; + return; + } + let res; + try { res = await loadFromAddon(NAME); } + catch (e) { + console.warn("editor load failed:", e); + empty.classList.add("err"); + empty.innerHTML = `<div>Couldn't load capture: ${(e && e.message || e)}</div>`; + return; + } + let img; + try { img = await loadImage(res.dataUrl); } + catch (e) { + empty.classList.add("err"); + empty.innerHTML = `<div>Couldn't decode capture bytes (${res.bytes || 0} B)</div>`; + return; + } + sizeCanvases(img.naturalWidth, img.naturalHeight); + bctx.drawImage(img, 0, 0); + undo = []; + redo = []; + pushSnapshot(); + empty.hidden = true; + stage.hidden = false; + fitBoard(); +} +window.addEventListener("resize", () => { if (!stage.hidden) fitBoard(); }); + +// -- tool selection -------------------------------------------------------- +function setTool(name) { + // Leaving crop mode with an unapplied marquee drops it. + if (state.tool === "crop" && name !== "crop" && state.cropRect) { + state.cropRect = null; clearOver(); + } + state.tool = name; + stage.dataset.tool = name; + for (const b of document.querySelectorAll(".tool")) { + b.classList.toggle("active", b.dataset.tool === name); + } + cancelTextInput(); + updateCropUI(); +} + +// Show/hide the Apply crop / Cancel crop buttons in the top bar. Visible +// only while the tool is "crop" AND a rectangle has been drawn. +function updateCropUI() { + const on = state.tool === "crop" && !!state.cropRect; + const ac = document.getElementById("apply-crop"); + const cc = document.getElementById("cancel-crop"); + if (ac) ac.hidden = !on; + if (cc) cc.hidden = !on; +} + +// Apply the crop: resize the base canvas to the rect's size, draw the +// cropped region onto it, then clear undo/redo (the coordinate system has +// changed — pre-crop snapshots would restore into the wrong dimensions). +// Baseline snapshot for the cropped canvas becomes the new floor. +function applyCrop() { + const r = state.cropRect; + if (!r) return; + const w = Math.max(1, Math.round(r.w)); + const h = Math.max(1, Math.round(r.h)); + const x = Math.max(0, Math.round(r.x)); + const y = Math.max(0, Math.round(r.y)); + const tmp = document.createElement("canvas"); + tmp.width = w; tmp.height = h; + tmp.getContext("2d").drawImage(base, x, y, w, h, 0, 0, w, h); + sizeCanvases(w, h); + bctx.drawImage(tmp, 0, 0); + state.cropRect = null; + clearOver(); + undo = []; + redo = []; + pushSnapshot(); + fitBoard(); + updateCropUI(); + playCropApply(); + setTool("select"); +} +function cancelCrop() { + state.cropRect = null; + clearOver(); + updateCropUI(); +} +// Clicking an already-active tool toggles it off (back to "select" = no +// tool). The dedicated "Select" button used to live here but read as +// broken to users — clicking it did nothing visible. Toggle-off gives the +// same "put the pen down" affordance without a mystery button. +for (const b of document.querySelectorAll(".tool")) { + b.addEventListener("click", () => { + setTool(state.tool === b.dataset.tool ? "select" : b.dataset.tool); + }); +} +for (const b of document.querySelectorAll(".swatch")) { + b.addEventListener("click", () => { + state.color = b.dataset.color; + for (const x of document.querySelectorAll(".swatch")) x.classList.toggle("active", x === b); + }); +} +for (const b of document.querySelectorAll(".width")) { + b.addEventListener("click", () => { + state.width = Number(b.dataset.width) || 5; + for (const x of document.querySelectorAll(".width")) x.classList.toggle("active", x === b); + }); +} + +// -- drawing --------------------------------------------------------------- +function clearOver() { octx.clearRect(0, 0, over.width, over.height); } + +function strokeStyle() { + octx.strokeStyle = state.color; + octx.fillStyle = state.color; + octx.lineWidth = state.width; + octx.lineCap = "round"; + octx.lineJoin = "round"; +} + +function drawArrow(a, b) { + strokeStyle(); + octx.beginPath(); + octx.moveTo(a.x, a.y); octx.lineTo(b.x, b.y); octx.stroke(); + // arrowhead + const dx = b.x - a.x, dy = b.y - a.y; + const len = Math.hypot(dx, dy); + if (len < 1) return; + const head = Math.max(10, state.width * 3); + const ang = Math.atan2(dy, dx); + const spread = Math.PI / 6; + octx.beginPath(); + octx.moveTo(b.x, b.y); + octx.lineTo(b.x - head * Math.cos(ang - spread), b.y - head * Math.sin(ang - spread)); + octx.moveTo(b.x, b.y); + octx.lineTo(b.x - head * Math.cos(ang + spread), b.y - head * Math.sin(ang + spread)); + octx.stroke(); +} + +function drawRect(a, b) { + strokeStyle(); + const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y); + const w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y); + octx.strokeRect(x, y, w, h); +} + +function drawEllipse(a, b) { + strokeStyle(); + const cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2; + const rx = Math.abs(b.x - a.x) / 2, ry = Math.abs(b.y - a.y) / 2; + octx.beginPath(); + octx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); + octx.stroke(); +} + +function drawPen(points) { + if (!points || points.length < 2) return; + strokeStyle(); + octx.beginPath(); + octx.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length; i++) octx.lineTo(points[i].x, points[i].y); + octx.stroke(); +} + +// Crop overlay — dim the area outside the rect, thin dashed border. This +// is a live preview during drag AND the settled marquee while apply-crop +// is pending. +function drawCropRect(a, b) { + const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y); + const w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y); + clearOver(); + octx.save(); + octx.fillStyle = "rgba(10,13,19,0.55)"; + octx.fillRect(0, 0, over.width, over.height); + octx.clearRect(x, y, w, h); + octx.strokeStyle = "#d6ff3d"; + octx.lineWidth = 1.5; + octx.setLineDash([6, 4]); + octx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); + octx.restore(); + return { x, y, w, h }; +} + +// Mosaic redaction: downsample the selected region of #base to blocks of +// ~<blockSize> px, then paint the blocks back. The whole thing commits +// straight to #base (there's no live preview — the drag rect uses the +// crop-style marquee to show the target region). +function commitBlur(a, b) { + const x = Math.max(0, Math.min(a.x, b.x)) | 0; + const y = Math.max(0, Math.min(a.y, b.y)) | 0; + const w = Math.min(base.width - x, Math.abs(b.x - a.x) | 0); + const h = Math.min(base.height - y, Math.abs(b.y - a.y) | 0); + if (w < 4 || h < 4) return false; + const block = Math.max(6, Math.round(Math.min(w, h) / 12)); + const tmp = document.createElement("canvas"); + const scaleW = Math.max(1, Math.floor(w / block)); + const scaleH = Math.max(1, Math.floor(h / block)); + tmp.width = scaleW; tmp.height = scaleH; + const tctx = tmp.getContext("2d"); + tctx.imageSmoothingEnabled = true; + tctx.drawImage(base, x, y, w, h, 0, 0, scaleW, scaleH); + bctx.imageSmoothingEnabled = false; + bctx.drawImage(tmp, 0, 0, scaleW, scaleH, x, y, w, h); + bctx.imageSmoothingEnabled = true; + return true; +} + +function commitOver() { + bctx.drawImage(over, 0, 0); + clearOver(); + pushSnapshot(); +} + +// -- input -------------------------------------------------------------- +over.style.pointerEvents = "none"; // draw layer never blocks input; base receives +base.addEventListener("pointerdown", (ev) => { + if (state.tool === "select") return; + if (state.tool === "text") { + ev.preventDefault(); + openTextInput(pointToCanvas(ev)); + return; + } + base.setPointerCapture(ev.pointerId); + const p = pointToCanvas(ev); + if (state.tool === "pen") { state.pen = [p]; } + else { state.drag = { x0: p.x, y0: p.y, x: p.x, y: p.y }; } +}); +base.addEventListener("pointermove", (ev) => { + const p = pointToCanvas(ev); + if (state.tool === "pen" && state.pen) { + state.pen.push(p); + clearOver(); + drawPen(state.pen); + return; + } + if (!state.drag) return; + state.drag.x = p.x; state.drag.y = p.y; + const a = { x: state.drag.x0, y: state.drag.y0 }, b = { x: p.x, y: p.y }; + if (state.tool === "arrow") { clearOver(); drawArrow(a, b); } + else if (state.tool === "rect") { clearOver(); drawRect(a, b); } + else if (state.tool === "ellipse") { clearOver(); drawEllipse(a, b); } + else if (state.tool === "crop" + || state.tool === "blur") { drawCropRect(a, b); } +}); +base.addEventListener("pointerup", (ev) => { + try { base.releasePointerCapture(ev.pointerId); } catch {} + if (state.tool === "pen" && state.pen) { + if (state.pen.length >= 2) commitOver(); + else clearOver(); + state.pen = null; + return; + } + if (!state.drag) return; + const dx = state.drag.x - state.drag.x0, dy = state.drag.y - state.drag.y0; + if (Math.hypot(dx, dy) < 2) { clearOver(); state.drag = null; state.cropRect = null; updateCropUI(); return; } + const a = { x: state.drag.x0, y: state.drag.y0 }, b = { x: state.drag.x, y: state.drag.y }; + if (state.tool === "crop") { + // Don't commit yet — the marquee stays up until the user hits Apply. + state.cropRect = drawCropRect(a, b); + state.drag = null; + updateCropUI(); + return; + } + if (state.tool === "blur") { + if (commitBlur(a, b)) { clearOver(); pushSnapshot(); } + else clearOver(); + state.drag = null; + return; + } + commitOver(); + state.drag = null; +}); + +// -- text tool ----------------------------------------------------------- +// A little <input> floats over the canvas at the click point; Enter commits +// as fillText, Escape drops. Chases a few browser quirks: +// - Focus after the DOM mutation, not before: some Chromium builds +// drop focus() when the element hasn't yet been laid out. +// - Contain the input's own pointer events so a mousedown inside it +// doesn't bubble to the canvas and immediately re-fire openTextInput, +// spawning a fresh empty box. +// - Track height instead of a hard-coded 14 px offset so tall fonts sit +// on the click's baseline rather than 14 px above it. +function openTextInput(pt) { + cancelTextInput(); + const size = Math.max(16, state.width * 4); + const el = document.createElement("input"); + el.type = "text"; + el.className = "text-input"; + el.placeholder = "type, then Enter"; + el.autocomplete = "off"; + el.setAttribute("autocorrect", "off"); + el.setAttribute("spellcheck", "false"); + // Visual weight — a bright acid halo around the box + the actual + // ink colour on the text itself, so users see something clearly + // happened when they clicked. + el.style.color = state.color; + el.style.font = `${size}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`; + el.style.lineHeight = "1.15"; + el.style.background = "rgba(11,14,20,0.94)"; + el.style.border = "2px solid var(--acid, #d6ff3d)"; + el.style.boxShadow = "0 0 0 3px rgba(214,255,61,.25), 0 4px 14px rgba(0,0,0,.45)"; + const rect = base.getBoundingClientRect(); + const scale = stage._scale || 1; + const cssX = pt.x * scale + rect.left; + const cssY = pt.y * scale + rect.top; + el.style.left = cssX + "px"; + el.style.top = (cssY - size) + "px"; + document.body.appendChild(el); + // Three-way focus attempt — Chromium is racy about focusing an element + // that appeared mid-pointer-event. Synchronous focus() first (works on + // most builds), then a paint tick, then a short timer as belt-and-braces. + el.focus(); + requestAnimationFrame(() => el.focus()); + setTimeout(() => { if (state.textInput && state.textInput.el === el) el.focus(); }, 30); + state.textInput = { x: pt.x, y: pt.y, size, el }; + // Don't let the input's own pointerdown / mousedown / click bubble to + // the canvas — otherwise every click-through re-fires openTextInput on + // the base and spawns duplicate boxes. + const swallow = (ev) => ev.stopPropagation(); + for (const t of ["pointerdown", "mousedown", "click"]) el.addEventListener(t, swallow); + el.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") { commitTextInput(); ev.preventDefault(); ev.stopPropagation(); } + else if (ev.key === "Escape") { cancelTextInput(); ev.preventDefault(); ev.stopPropagation(); } + // Every other key stays inside the input — belt against a stray + // document-level keydown handler stealing focus. + else ev.stopPropagation(); + }); + el.addEventListener("blur", commitTextInput); +} +function commitTextInput() { + const ti = state.textInput; + if (!ti) return; + const value = ti.el.value; + const trimmed = value.trim(); + ti.el.remove(); + state.textInput = null; + if (!trimmed) return; + bctx.fillStyle = state.color; + bctx.font = `${ti.size}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`; + bctx.textBaseline = "alphabetic"; + bctx.fillText(value, ti.x, ti.y); + pushSnapshot(); +} +function cancelTextInput() { + if (!state.textInput) return; + state.textInput.el.remove(); + state.textInput = null; +} + +// -- undo / redo ------------------------------------------------------- +function doUndo() { + if (undo.length <= 1) return; + const cur = undo.pop(); + redo.push(cur); + restoreSnapshot(undo[undo.length - 1]); + updateUndoRedo(); +} +function doRedo() { + const snap = redo.pop(); + if (!snap) return; + undo.push(snap); + restoreSnapshot(snap); + updateUndoRedo(); +} +undoBtn.addEventListener("click", doUndo); +redoBtn.addEventListener("click", doRedo); + +// -- save / copy ------------------------------------------------------ +function canvasBlob() { + return new Promise((resolve, reject) => + base.toBlob((b) => b ? resolve(b) : reject(new Error("toBlob returned null")), "image/png")); +} +async function save() { + try { + const blob = await canvasBlob(); + const url = URL.createObjectURL(blob); + dl.href = url; + dl.download = NAME || "screenshot.png"; + dl.click(); + setTimeout(() => URL.revokeObjectURL(url), 5000); + playSave(); + toast(`Saved ${dl.download} (${(blob.size / 1024).toFixed(1)} KB)`); + } catch (e) { + toast("Save failed: " + (e && e.message || e), true); + } +} +async function copy() { + try { + const blob = await canvasBlob(); + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + playCopy(); + toast("Copied to clipboard"); + } catch (e) { + toast("Copy failed: " + (e && e.message || e), true); + } +} +$("save").addEventListener("click", save); +$("copy").addEventListener("click", copy); +const applyCropBtn = document.getElementById("apply-crop"); +const cancelCropBtn = document.getElementById("cancel-crop"); +if (applyCropBtn) applyCropBtn.addEventListener("click", applyCrop); +if (cancelCropBtn) cancelCropBtn.addEventListener("click", cancelCrop); + +// -- top-bar navigation ---------------------------------------------- +$("back").addEventListener("click", () => { location.href = "panel.html"; }); + +// "Open in folder" — sends the current capture's name to the addon's +// openFolder handler, which calls shell.showItemInFolder() so the file +// explorer opens with the exact scratch PNG highlighted. +const openFolderBtn = document.getElementById("open-folder"); +if (openFolderBtn) openFolderBtn.addEventListener("click", async () => { + try { + if (window.silentmode?.invoke) { + await window.silentmode.invoke("openFolder", { name: NAME || "" }); + } + } catch (e) { + console.warn("open-folder failed:", e); + toast("Couldn't open folder: " + (e && e.message || e), true); + } +}); + +// Discard — throw away this capture entirely (delete it from the Recent +// ring) and return to the panel. Back is non-destructive; Discard is not. +const discardBtn = document.getElementById("discard"); +if (discardBtn) discardBtn.addEventListener("click", async () => { + playDiscard(); + try { + if (NAME && window.silentmode?.invoke) { + await window.silentmode.invoke("clearRecent", { name: NAME }); + } + } catch (e) { console.warn("discard: clearRecent failed:", e); } + location.href = "panel.html"; +}); + +// Close the sidebar entirely — same IPC the dock icon toggles. We stop +// on the panel side (see panel.html); the editor mirrors the affordance +// so users don't need to navigate back before hiding the addon. +const closeSbBtn = document.getElementById("close-sidebar"); +if (closeSbBtn && window.silentmode?.sidebar) { + closeSbBtn.addEventListener("click", () => { + try { window.silentmode.sidebar.close(); } catch {} + }); +} + +// Sound-toggle button — mirrors the sidebar panel's, both persist via +// silentmode.storage so opening either surface reflects the same setting. +const soundBtn = $("toggle-sound"); +function paintSoundIcon() { + if (!soundBtn) return; + soundBtn.title = soundOn ? "Sounds on — click to mute" : "Sounds off — click to enable"; + soundBtn.innerHTML = soundOn + ? '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6v4h3l3.5 3V3L5.5 6z"/><path d="M11 5.5c1 .8 1 4.2 0 5"/><path d="M12.5 4c2 1.5 2 6.5 0 8"/></svg>' + : '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6v4h3l3.5 3V3L5.5 6z"/><path d="M11 5l4 6M15 5l-4 6"/></svg>'; +} +(async () => { + try { + const v = await window.silentmode?.storage?.get("soundOn", true); + soundOn = v !== false; + } catch {} + paintSoundIcon(); +})(); +if (soundBtn) soundBtn.addEventListener("click", async () => { + soundOn = !soundOn; + paintSoundIcon(); + try { await window.silentmode?.storage?.set("soundOn", soundOn); } catch {} + if (soundOn) playSave(); +}); + +const maxBtn = $("toggle-max"); +async function paintMaxIcon(isMax) { + maxBtn.title = isMax ? "Restore sidebar width" : "Expand the sidebar to full window"; + maxBtn.innerHTML = isMax + ? '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 2v4H2M10 2v4h4M6 14v-4H2M10 14v-4h4"/></svg>' + : '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/></svg>'; +} +if (window.silentmode?.sidebar) { + maxBtn.addEventListener("click", async () => { + try { await window.silentmode.sidebar.toggleMax(); } + catch (e) { console.warn("toggleMax failed:", e); } + }); + window.silentmode.sidebar.onMaxChange(paintMaxIcon); + window.silentmode.sidebar.isMax().then(paintMaxIcon).catch(() => {}); +} + +// -- keyboard shortcuts --------------------------------------------- +window.addEventListener("keydown", (ev) => { + if (state.textInput) return; + const meta = ev.ctrlKey || ev.metaKey; + if (meta && ev.key.toLowerCase() === "z" && !ev.shiftKey) { doUndo(); ev.preventDefault(); return; } + if (meta && ev.key.toLowerCase() === "z" && ev.shiftKey) { doRedo(); ev.preventDefault(); return; } + if (meta && ev.key.toLowerCase() === "s") { save(); ev.preventDefault(); return; } + if (meta && ev.key.toLowerCase() === "c") { copy(); ev.preventDefault(); return; } + if (state.tool === "crop" && state.cropRect) { + if (ev.key === "Enter") { applyCrop(); ev.preventDefault(); return; } + if (ev.key === "Escape") { cancelCrop(); ev.preventDefault(); return; } + } + if (ev.key === "a") setTool("arrow"); + else if (ev.key === "r") setTool("rect"); + else if (ev.key === "o") setTool("ellipse"); + else if (ev.key === "p") setTool("pen"); + else if (ev.key === "t") setTool("text"); + else if (ev.key === "c") setTool("crop"); + else if (ev.key === "b") setTool("blur"); + else if (ev.key === "Escape") setTool("select"); +}); + +init(); diff --git a/bundled-addons/screenshot/index.js b/bundled-addons/screenshot/index.js new file mode 100644 index 00000000..04f13e09 --- /dev/null +++ b/bundled-addons/screenshot/index.js @@ -0,0 +1,162 @@ +// Screenshot — capture the active tab, preview in the sidebar, then hand +// off to a full-tab editor only when the user asks for it. The old toolbar- +// dropdown flow auto-opened editor.html the instant a capture completed; +// that turned the editor tab into the ACTIVE tab, so any second click of +// the dropdown snapshotted the editor's still-blank canvas and every follow- +// up produced a white PNG. Sidebar-first breaks that loop entirely: the +// preview is served from a data URL inside the sidebar's own document, and +// the editor tab is opened only on an explicit "Open in editor" click. +// +// Flow: +// panel invokes "capture" → api.captureTab → write PNG to per-add-on +// scratch dir → return {name, dataUrl, width, height, host, bytes} to +// panel → panel renders <img src="data:…"> in a preview area. +// panel invokes "openInTab" → we stash the raw PNG in api.storage under +// "__pending" and openTab("editor.html") — the editor's preload lets it +// pull that entry out via silentmode.storage.get(). + +const fs = require("node:fs"); +const path = require("node:path"); + +const MAX_RECENT = 6; // ring size for the recent-captures list +const SCRATCH_DIR = "screenshot-scratch"; // sibling of the add-on's storage JSON + +module.exports = { + activate(api) { + api.registerSidebarPanel({ + id: "main", + title: "Screenshot", + icon: "📸", + page: "panel.html", + }); + + // Per-add-on scratch dir under <userData>/addons-data/. We don't touch + // the add-on folder itself — editing it there would confuse users who + // are inspecting the shipped source. api.folder = <userData>/addons/ + // screenshot/, so dirname twice lands on <userData>. + const dataParent = path.dirname(path.join(api.folder, "..")); + const scratchDir = path.join(dataParent, "addons-data", SCRATCH_DIR); + try { fs.mkdirSync(scratchDir, { recursive: true }); } + catch (e) { api.log("scratch mkdir failed:", e?.message); } + + // Region-select overlay source, loaded once at activation. Region mode + // hands this to capture-tab, which injects it into the target tab and + // awaits the {x,y,w,h} the overlay resolves with. + let overlaySource = ""; + try { overlaySource = fs.readFileSync(path.join(api.folder, "panel-preload.js"), "utf8"); } + catch (e) { api.log("panel-preload.js not readable:", e?.message); } + + function fileUrl(abs) { + return "file:///" + abs.replace(/\\/g, "/").replace(/^\/+/, "").replace(/#/g, "%23").replace(/\?/g, "%3F"); + } + function nowStamp() { + const d = new Date(); + const pad = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; + } + function pruneRecent(recent) { + const alive = recent.filter((r) => { try { fs.accessSync(r.path); return true; } catch { return false; } }); + const trimmed = alive.slice(0, MAX_RECENT); + const dropped = alive.slice(MAX_RECENT); + for (const r of dropped) { try { fs.unlinkSync(r.path); } catch {} } + return trimmed; + } + function writeScratch(dataUrl, name) { + const m = /^data:image\/png;base64,(.+)$/.exec(String(dataUrl)); + if (!m) throw new Error("expected image/png data URL from capture"); + const buf = Buffer.from(m[1], "base64"); + const file = path.join(scratchDir, name); + fs.writeFileSync(file, buf); + return { path: file, bytes: buf.length }; + } + function readAsDataUrl(absPath) { + const buf = fs.readFileSync(absPath); + return `data:image/png;base64,${buf.toString("base64")}`; + } + + // panel invokes "capture": take the shot, write to scratch, hand the + // data URL straight back so the sidebar can preview it immediately. + api.onMessage("capture", async (payload) => { + const mode = String(payload && payload.mode || "visible"); + if (mode !== "visible" && mode !== "full" && mode !== "region") { + throw new Error(`unknown capture mode: ${mode}`); + } + const opts = { mode, format: "png" }; + if (mode === "region") opts.overlaySource = overlaySource; + const cap = await api.captureTab(opts); + if (cap.cancelled) { api.log(`region capture cancelled`); return { ok: false, cancelled: true }; } + const name = `screenshot-${nowStamp()}${mode === "full" ? "-fullpage" : mode === "region" ? "-region" : ""}.png`; + const written = writeScratch(cap.dataUrl, name); + let recent = api.storage.get("recent", []); + if (!Array.isArray(recent)) recent = []; + recent.unshift({ id: name, name, path: written.path, bytes: written.bytes, at: Date.now(), mode }); + recent = pruneRecent(recent); + api.storage.set("recent", recent); + api.log(`captured ${mode} → ${name} (${written.bytes} bytes)`); + return { + ok: true, name, mode, + dataUrl: cap.dataUrl, + width: cap.width, height: cap.height, + host: cap.host, bytes: written.bytes, + }; + }); + + // (The old "arm" handler that pre-armed a __pending storage entry is + // gone: editor.html now pulls its bytes via getBytes on load, so there + // is nothing to hand off in advance. Idempotent — reopening the editor + // in the sidebar just re-fetches the bytes.) + + // Read the ring for a "recent captures" strip in the sidebar. Bytes are + // reported; the actual images are pulled through "getBytes" on demand + // so we don't ship every thumbnail through IPC on every panel open. + api.onMessage("listRecent", () => { + let recent = api.storage.get("recent", []); + if (!Array.isArray(recent)) recent = []; + recent = pruneRecent(recent); + api.storage.set("recent", recent); + return recent.map((r) => ({ id: r.id, name: r.name, bytes: r.bytes, at: r.at, mode: r.mode })); + }); + + // Panel asks for a specific past capture's bytes so it can preview it. + api.onMessage("getBytes", (payload) => { + const wantedName = String(payload && payload.name || ""); + const recent = api.storage.get("recent", []) || []; + const hit = recent.find((r) => r.id === wantedName); + if (!hit) throw new Error(`no recent capture "${wantedName}"`); + return { name: hit.name, dataUrl: readAsDataUrl(hit.path), bytes: hit.bytes, at: hit.at, mode: hit.mode }; + }); + + // Show the scratch dir (or a specific capture inside it) in the OS + // file explorer. `api.require("electron").shell.openPath` opens the + // folder; `showItemInFolder` opens the folder AND highlights the + // file — used when the panel/editor knows a specific capture. + api.onMessage("openFolder", (payload) => { + const { shell } = api.require("electron"); + const wantedName = payload && payload.name ? String(payload.name) : ""; + if (wantedName) { + const recent = api.storage.get("recent", []) || []; + const hit = recent.find((r) => r.id === wantedName); + if (hit) { shell.showItemInFolder(hit.path); return { ok: true, path: hit.path }; } + } + shell.openPath(scratchDir); + return { ok: true, path: scratchDir }; + }); + + api.onMessage("clearRecent", (payload) => { + const wantedName = payload && payload.name ? String(payload.name) : ""; + let recent = api.storage.get("recent", []) || []; + if (wantedName) { + const hit = recent.find((r) => r.id === wantedName); + if (hit) { try { fs.unlinkSync(hit.path); } catch {} } + recent = recent.filter((r) => r.id !== wantedName); + } else { + for (const r of recent) { try { fs.unlinkSync(r.path); } catch {} } + recent = []; + } + api.storage.set("recent", recent); + return { ok: true }; + }); + + api.log("registered screenshot v0.5.0 sidebar panel"); + }, +}; diff --git a/bundled-addons/screenshot/panel-preload.js b/bundled-addons/screenshot/panel-preload.js new file mode 100644 index 00000000..2fd94079 --- /dev/null +++ b/bundled-addons/screenshot/panel-preload.js @@ -0,0 +1,115 @@ +// Region-select overlay. Injected into the target tab by main.js via +// wc.executeJavaScript(); the last expression is what executeJavaScript +// resolves with, so the whole file is an IIFE that returns a Promise for +// {x, y, w, h} — or null when the user cancels. +// +// Coordinates are CSS pixels relative to the viewport (not the document), +// which is exactly what WebContents.capturePage(rect) wants. +// +// Runs in the page's world, so it shares globals with the page. That's +// acceptable here: the overlay lives for a few seconds while the user +// drags, then removes itself and its listeners. +(() => { + return new Promise((resolve) => { + // If a previous overlay is still up (double-click on the panel button), + // tear it down before installing a fresh one. + const prev = document.getElementById("__theseus_screenshot_overlay__"); + if (prev) prev.remove(); + + const overlay = document.createElement("div"); + overlay.id = "__theseus_screenshot_overlay__"; + overlay.style.cssText = [ + "position:fixed", "inset:0", "z-index:2147483647", + "cursor:crosshair", + "background:rgba(11,14,20,0.35)", + "user-select:none", "-webkit-user-select:none", + ].join(";"); + + const box = document.createElement("div"); + box.style.cssText = [ + "position:absolute", "left:0", "top:0", "width:0", "height:0", + "border:1px solid #d6ff3d", + "box-shadow:0 0 0 9999px rgba(11,14,20,0.35)", + "background:transparent", + "pointer-events:none", + ].join(";"); + box.hidden = true; + overlay.appendChild(box); + + const hint = document.createElement("div"); + hint.textContent = "Drag to select a region — Esc to cancel"; + hint.style.cssText = [ + "position:absolute", "top:12px", "left:50%", "transform:translateX(-50%)", + "padding:6px 12px", "border-radius:999px", + "background:rgba(11,14,20,0.92)", "color:#e7eaf1", + "font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif", + "border:1px solid rgba(214,255,61,0.30)", "pointer-events:none", + ].join(";"); + overlay.appendChild(hint); + + document.documentElement.appendChild(overlay); + + let startX = 0, startY = 0, dragging = false; + let curX = 0, curY = 0; + + const dpr = window.devicePixelRatio || 1; + + function updateBox(x, y, w, h) { + box.style.left = x + "px"; + box.style.top = y + "px"; + box.style.width = w + "px"; + box.style.height = h + "px"; + box.hidden = false; + } + + function finish(result) { + cleanup(); + resolve(result); + } + + function cleanup() { + overlay.removeEventListener("mousedown", onDown, true); + window.removeEventListener("mousemove", onMove, true); + window.removeEventListener("mouseup", onUp, true); + window.removeEventListener("keydown", onKey, true); + overlay.removeEventListener("contextmenu", onCtx, true); + try { overlay.remove(); } catch {} + } + + function onDown(e) { + if (e.button !== 0) return; + e.preventDefault(); e.stopPropagation(); + dragging = true; + startX = e.clientX; startY = e.clientY; + curX = startX; curY = startY; + updateBox(startX, startY, 0, 0); + } + function onMove(e) { + if (!dragging) return; + curX = e.clientX; curY = e.clientY; + const x = Math.min(startX, curX), y = Math.min(startY, curY); + const w = Math.abs(curX - startX), h = Math.abs(curY - startY); + updateBox(x, y, w, h); + } + function onUp(e) { + if (!dragging) return; + dragging = false; + const x = Math.min(startX, curX), y = Math.min(startY, curY); + const w = Math.abs(curX - startX), h = Math.abs(curY - startY); + // A click-with-no-drag is treated as cancel — capturing a 0px region + // isn't useful, and it lets the user bail without hunting for Esc. + if (w < 4 || h < 4) { finish(null); return; } + finish({ x, y, w, h, dpr }); + } + function onKey(e) { + if (e.key === "Escape") { e.preventDefault(); finish(null); } + } + function onCtx(e) { e.preventDefault(); finish(null); } + + overlay.addEventListener("mousedown", onDown, true); + window.addEventListener("mousemove", onMove, true); + window.addEventListener("mouseup", onUp, true); + window.addEventListener("keydown", onKey, true); + overlay.addEventListener("contextmenu", onCtx, true); + }); +})() diff --git a/bundled-addons/screenshot/panel.html b/bundled-addons/screenshot/panel.html new file mode 100644 index 00000000..cba5244f --- /dev/null +++ b/bundled-addons/screenshot/panel.html @@ -0,0 +1,547 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Screenshot</title> +<style> + :root { color-scheme: light dark; + --bg:#0e131c; --panel:#141a24; --line:rgba(255,255,255,.09); + --ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; + --btn:#1c2432; --btn-h:#242e40; --err:#ff9081; } + @media (prefers-color-scheme: light) { + :root { --bg:#f8faff; --panel:#ffffff; --line:rgba(0,0,0,.10); + --ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; + --btn:#f0f3fa; --btn-h:#e3e8f2; --acid:#088A66; --err:#c81f0f; } + } + * { box-sizing: border-box; } + html, body { margin: 0; height: 100%; } + body { background: var(--bg); color: var(--ink); + font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + display: flex; flex-direction: column; } + header { display: flex; align-items: center; + padding: 10px 14px; border-bottom: 1px solid var(--line); + background: var(--panel); gap: 8px; } + header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; min-width: 0; } + header .t .em { font-size: 15px; } + header .m { color: var(--dim); font-size: 11.5px; min-height: 15px; flex: 1; text-align: right; } + /* Force the sound / max / close cluster to hug the right edge no matter + what else fills the header. justify-content on the parent kept nudging + them mid-row when the status text was empty. */ + header #btn-sound { margin-left: auto; } + header .max { + border: 1px solid var(--line); border-radius: 6px; background: var(--btn); + color: var(--ink); cursor: pointer; padding: 4px 6px; font: inherit; line-height: 0; + } + header .max:hover { background: var(--btn-h); } + header .max svg { width: 14px; height: 14px; display: block; } + header .max.close:hover { border-color: rgba(255,91,91,.55); color: var(--err); } + main { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; } + .modes { display: flex; gap: 6px; } + button.mode { + flex: 1; min-width: 0; + padding: 10px 6px; border: 1px solid var(--line); border-radius: 8px; + background: var(--btn); color: var(--ink); cursor: pointer; + font: inherit; text-align: center; + display: flex; flex-direction: column; align-items: center; gap: 4px; + transition: background 120ms; + } + button.mode:hover:not(:disabled) { background: var(--btn-h); } + button.mode:disabled { opacity: .5; cursor: default; } + button.mode .em { font-size: 18px; line-height: 1; } + button.mode .lbl { font-size: 11.5px; } + .prev-wrap { + border: 1px dashed var(--line); border-radius: 8px; + background: + repeating-conic-gradient(rgba(255,255,255,.02) 0% 25%, transparent 0% 50%) + 50%/16px 16px; + padding: 6px; min-height: 120px; + display: flex; align-items: center; justify-content: center; + color: var(--dim); font-size: 12px; text-align: center; + } + .prev-wrap img { + max-width: 100%; max-height: 320px; border-radius: 4px; display: block; + background: #fff; + } + .meta { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; } + .meta .host { color: var(--mut); } + .actions { display: flex; gap: 6px; flex-wrap: wrap; } + .actions .row { display: flex; gap: 6px; flex: 1 1 100%; } + button.act { + flex: 1; padding: 9px 8px; border: 1px solid var(--line); border-radius: 8px; + background: var(--btn); color: var(--ink); cursor: pointer; font: inherit; + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + line-height: 1; + } + button.act svg { width: 14px; height: 14px; display: block; } + button.act.primary { background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; } + button.act:hover:not(:disabled) { background: var(--btn-h); } + button.act.primary:hover:not(:disabled) { filter: brightness(1.05); background: var(--acid); } + button.act:disabled { opacity: .45; cursor: default; } + .confirm { + display: none; gap: 6px; align-items: center; + padding: 8px 10px; border: 1px solid rgba(255,91,91,.40); border-radius: 8px; + background: rgba(255,91,91,.06); font-size: 12px; + } + .confirm.on { display: flex; } + .confirm .msg { flex: 1; color: var(--mut); } + .confirm button { + padding: 5px 10px; border: 1px solid var(--line); border-radius: 6px; + background: var(--btn); color: var(--ink); cursor: pointer; font: inherit; + } + .confirm button.yes { background: #ff5b5b; color: #101418; border-color: transparent; font-weight: 600; } + .confirm button:hover { background: var(--btn-h); } + .confirm button.yes:hover { filter: brightness(1.05); background: #ff5b5b; } + .status { color: var(--mut); font-size: 12px; min-height: 16px; } + .status.err { color: var(--err); } + .status.ok { color: var(--acid); } + .recent { display: none; } + .recent.on { display: block; } + .recent .lbl { color: var(--dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: .5px; margin: 4px 0 6px; display: flex; justify-content: space-between; } + .recent .strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; } + .recent .tile { + flex: 0 0 auto; width: 78px; height: 52px; border-radius: 4px; + border: 1px solid var(--line); background: var(--btn); + display: flex; align-items: center; justify-content: center; + cursor: pointer; overflow: hidden; position: relative; + } + .recent .tile img { width: 100%; height: 100%; object-fit: cover; background: #fff; } + .recent .tile:hover { border-color: var(--acid); } + .recent .tile .tdel { + position: absolute; top: 2px; right: 2px; + width: 18px; height: 18px; line-height: 16px; text-align: center; + background: rgba(11,14,20,.85); color: #fff; border-radius: 50%; + font-size: 14px; font-weight: 700; cursor: pointer; + opacity: 0; transition: opacity 120ms, background 120ms, color 120ms; + user-select: none; + } + .recent .tile:hover .tdel { opacity: 1; } + .recent .tile .tdel:hover { background: #ff5b5b; color: #101418; } + .recent .clear { background: none; border: 0; color: var(--dim); font: inherit; cursor: pointer; text-transform: none; letter-spacing: 0; } + .recent .clear:hover { color: var(--err); } +</style> +</head> +<body> +<header> + <div class="t"><span class="em">📸</span> <span>Screenshot</span></div> + <div class="m" id="hdr-status"></div> + <button class="max" id="btn-sound" title="Toggle sound" aria-label="Toggle sound"></button> + <button class="max" id="btn-max" title="Expand the sidebar to full window width" aria-label="Expand sidebar"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> + <path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4"/> + </svg> + </button> + <button class="max close" id="btn-close" title="Close the sidebar" aria-label="Close sidebar"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"> + <path d="M4 4l8 8M12 4l-8 8"/> + </svg> + </button> +</header> +<main> + <div class="modes"> + <button class="mode" data-mode="visible" title="Capture the visible part of the current tab"> + <span class="em">🖼️</span><span class="lbl">Visible</span> + </button> + <button class="mode" data-mode="full" title="Capture the entire scrollable page"> + <span class="em">📄</span><span class="lbl">Full page</span> + </button> + <button class="mode" data-mode="region" title="Drag a rectangle on the page to select what to capture"> + <span class="em">✂️</span><span class="lbl">Region</span> + </button> + </div> + + <div class="prev-wrap" id="prev-wrap"> + <span id="prev-empty">No capture yet. Pick a mode above.</span> + <img id="prev-img" alt="" hidden> + </div> + + <div class="meta"> + <span id="meta-size">—</span> + <span class="host" id="meta-host"></span> + </div> + + <div class="actions"> + <div class="row"> + <button class="act" id="btn-copy" disabled title="Copy the raw capture to the clipboard"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="4" width="9" height="10"/><path d="M3 12V3h9"/></svg> + <span>Copy</span> + </button> + <button class="act" id="btn-save" disabled title="Save the raw capture straight to Downloads"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v9M4 7l4 4 4-4M2 14h12"/></svg> + <span>Save</span> + </button> + </div> + <div class="row"> + <button class="act" id="btn-discard" disabled>Discard</button> + <button class="act primary" id="btn-edit" disabled title="Open the annotate editor — arrow, rect, ellipse, pen, text, undo, save, copy">Edit</button> + </div> + </div> + + <div class="status" id="status"></div> + + <div class="recent" id="recent"> + <div class="lbl"><span>Recent</span> + <button class="clear" id="clear-recent" title="Delete every past capture">clear all</button> + </div> + <div class="strip" id="recent-strip"></div> + <div class="confirm" id="clear-confirm"> + <span class="msg">Delete every past capture?</span> + <button id="clear-no">Cancel</button> + <button class="yes" id="clear-yes">Delete</button> + </div> + </div> + <a id="download-link" style="display:none"></a> +</main> + +<script> + const $ = (id) => document.getElementById(id); + const previewImg = $("prev-img"); + const previewEmpty = $("prev-empty"); + const status = $("status"); + const metaSize = $("meta-size"); + const metaHost = $("meta-host"); + const btnEdit = $("btn-edit"); + const btnDiscard = $("btn-discard"); + const btnCopy = $("btn-copy"); + const btnSave = $("btn-save"); + const btnMax = $("btn-max"); + const btnSound = $("btn-sound"); + const dl = $("download-link"); + + // ---- audio ---------------------------------------------------------- + // Synthesized in-page so the tarball stays small — no wav shipped. + // Preference is stored under silentmode.storage so the editor sees the + // same setting when it loads (default on). + let soundOn = true; + let audioCtx = null; + function actx() { + if (!audioCtx) { + try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch {} + } + // A user gesture is needed to resume in some Chromium setups; the + // toggle button click satisfies that. + if (audioCtx && audioCtx.state === "suspended") { audioCtx.resume().catch(() => {}); } + return audioCtx; + } + function tone(freq, dur, type, vol, at) { + if (!soundOn) return; + const c = actx(); if (!c) return; + const t0 = c.currentTime + (at || 0); + const osc = c.createOscillator(); + const g = c.createGain(); + osc.type = type || "sine"; + osc.frequency.setValueAtTime(freq, t0); + g.gain.setValueAtTime(vol || 0.15, t0); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + osc.connect(g); g.connect(c.destination); + osc.start(t0); + osc.stop(t0 + dur + 0.02); + } + // Filtered noise burst — camera clicks, printer thwacks, and (with + // texture=true) the crackle of crumpled paper. `bp` = band-pass centre + // freq, `q` = sharpness of the band. + function noise(dur, vol, bp, q, at, texture) { + if (!soundOn) return; + const c = actx(); if (!c) return; + const t0 = c.currentTime + (at || 0); + const n = Math.max(1, Math.floor(c.sampleRate * dur)); + const buf = c.createBuffer(1, n, c.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < n; i++) { + let s = Math.random() * 2 - 1; + if (texture) s *= 0.4 + Math.random() * 0.6; + d[i] = s; + } + const src = c.createBufferSource(); src.buffer = buf; + const flt = c.createBiquadFilter(); flt.type = "bandpass"; + flt.frequency.setValueAtTime(bp || 2000, t0); + flt.Q.setValueAtTime(q || 4, t0); + const g = c.createGain(); + g.gain.setValueAtTime(vol || 0.2, t0); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + src.connect(flt); flt.connect(g); g.connect(c.destination); + src.start(t0); + src.stop(t0 + dur + 0.02); + } + // Sweeping band-pass noise — used as the film-advance whir tail of the + // Polaroid shutter. + function sweep(dur, vol, fromHz, toHz, q, at) { + if (!soundOn) return; + const c = actx(); if (!c) return; + const t0 = c.currentTime + (at || 0); + const n = Math.max(1, Math.floor(c.sampleRate * dur)); + const buf = c.createBuffer(1, n, c.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1; + const src = c.createBufferSource(); src.buffer = buf; + const flt = c.createBiquadFilter(); flt.type = "bandpass"; + flt.frequency.setValueAtTime(fromHz, t0); + flt.frequency.exponentialRampToValueAtTime(toHz, t0 + dur); + flt.Q.setValueAtTime(q || 8, t0); + const g = c.createGain(); + g.gain.setValueAtTime(0.0001, t0); + g.gain.exponentialRampToValueAtTime(vol, t0 + dur * 0.2); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + src.connect(flt); flt.connect(g); g.connect(c.destination); + src.start(t0); + src.stop(t0 + dur + 0.02); + } + // Polaroid: mirror + curtain click, then a film-advance whir. + function playShutter() { + noise(0.02, 0.32, 5200, 8, 0); + tone(160, 0.03, "square", 0.14, 0.003); + noise(0.03, 0.24, 3200, 5, 0.03); + tone(120, 0.04, "square", 0.10, 0.035); + sweep(0.28, 0.10, 900, 400, 12, 0.07); + } + // Printer chika-chika-chika — 3 descending percussive bursts. + function playCopy() { + noise(0.028, 0.22, 3800, 9, 0.00); + tone(1200, 0.03, "sine", 0.10, 0.00); + noise(0.028, 0.22, 3200, 9, 0.06); + tone(1000, 0.03, "sine", 0.10, 0.06); + noise(0.028, 0.22, 2600, 9, 0.12); + tone(800, 0.03, "sine", 0.10, 0.12); + } + // Paper crumple — long band-limited noise with crackle texture and a + // descending centre frequency. + function playDiscard() { + noise(0.16, 0.28, 2600, 3, 0.00, true); + noise(0.12, 0.22, 1800, 3, 0.06, true); + noise(0.08, 0.16, 1200, 3, 0.12, true); + } + function paintSound() { + btnSound.title = soundOn ? "Sounds on — click to mute" : "Sounds off — click to enable"; + btnSound.innerHTML = soundOn + ? '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6v4h3l3.5 3V3L5.5 6z"/><path d="M11 5.5c1 .8 1 4.2 0 5"/><path d="M12.5 4c2 1.5 2 6.5 0 8"/></svg>' + : '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6v4h3l3.5 3V3L5.5 6z"/><path d="M11 5l4 6M15 5l-4 6"/></svg>'; + } + (async () => { + try { + const v = await window.silentmode.storage.get("soundOn", true); + soundOn = v !== false; // default on + } catch {} + paintSound(); + })(); + btnSound.addEventListener("click", async () => { + soundOn = !soundOn; + paintSound(); + try { await window.silentmode.storage.set("soundOn", soundOn); } catch {} + if (soundOn) playShutter(); // audible confirmation + }); + const hdrStatus = $("hdr-status"); + const recentBox = $("recent"); + const recentStrip = $("recent-strip"); + const clearBtn = $("clear-recent"); + + let last = null; // { name, dataUrl, width, height, host, bytes, mode } + let busy = false; + + const fmt = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + " MB" + : n >= 1e3 ? (n / 1e3).toFixed(0) + " KB" + : n + " B"; + + function setStatus(text, cls = "") { + status.textContent = text || ""; + status.className = "status" + (cls ? " " + cls : ""); + } + + function renderPreview() { + const on = !!(last && last.dataUrl); + previewImg.hidden = !on; + previewImg.src = on ? last.dataUrl : ""; + previewEmpty.hidden = on; + metaSize.textContent = on ? `${last.width}×${last.height} · ${fmt(last.bytes || 0)}` : "—"; + metaHost.textContent = on ? (last.host || "") : ""; + btnEdit.disabled = !on; + btnDiscard.disabled = !on; + btnCopy.disabled = !on; + btnSave.disabled = !on; + } + + async function refreshRecent() { + try { + const list = await window.silentmode.invoke("listRecent", {}); + if (!Array.isArray(list) || list.length === 0) { + recentBox.classList.remove("on"); + recentStrip.innerHTML = ""; + return; + } + recentBox.classList.add("on"); + recentStrip.innerHTML = list.map((r) => { + const nameAttr = r.name.replace(/"/g, "&quot;"); + return `<div class="tile" data-name="${nameAttr}" title="${nameAttr} · ${fmt(r.bytes || 0)}">` + + `<span class="tdel" data-del="${nameAttr}" title="Delete this capture">×</span>` + + `</div>`; + }).join(""); + for (const tile of recentStrip.querySelectorAll(".tile")) { + const name = tile.dataset.name; + try { + const b = await window.silentmode.invoke("getBytes", { name }); + if (b && b.dataUrl) { + // Keep the delete button; drop it back on top of the image. + const del = tile.querySelector(".tdel"); + tile.innerHTML = `<img src="${b.dataUrl}" alt="">`; + if (del) tile.appendChild(del); + } + } catch {} + tile.addEventListener("click", async (ev) => { + // The per-tile X sits inside the tile; don't count its click as + // a tile click. + if (ev.target && ev.target.classList.contains("tdel")) return; + try { + const b = await window.silentmode.invoke("getBytes", { name }); + if (!b) return; + last = { name: b.name, dataUrl: b.dataUrl, width: 0, height: 0, host: "", bytes: b.bytes }; + const im = new Image(); + im.onload = () => { last.width = im.naturalWidth; last.height = im.naturalHeight; renderPreview(); }; + im.src = b.dataUrl; + renderPreview(); + setStatus(`Loaded ${b.name}.`, "ok"); + } catch (e) { + setStatus("Couldn't load: " + (e && e.message || e), "err"); + } + }); + } + // Per-tile delete — no confirmation strip (single-item destroy is + // small enough to be safe with just the X; the sweeping "clear all" + // still gates on the inline red confirmation). + for (const del of recentStrip.querySelectorAll(".tdel")) { + del.addEventListener("click", async (ev) => { + ev.stopPropagation(); + const nm = del.dataset.del; + try { + await window.silentmode.invoke("clearRecent", { name: nm }); + if (last && last.name === nm) { last = null; renderPreview(); } + await refreshRecent(); + } catch (e) { + setStatus("Delete failed: " + (e && e.message || e), "err"); + } + }); + } + } catch (e) { + console.warn("refreshRecent failed:", e); + } + } + + async function doCapture(mode) { + if (busy) return; + busy = true; + hdrStatus.textContent = "capturing…"; + setStatus(""); + if (mode === "region") setStatus("Drag on the page. Esc to cancel."); + else if (mode === "full") setStatus("Rendering full page — this can take a moment."); + for (const b of document.querySelectorAll("button.mode")) b.disabled = true; + try { + const res = await window.silentmode.invoke("capture", { mode }); + if (res && res.cancelled) { + setStatus("Region capture cancelled."); + } else if (res && res.dataUrl) { + last = res; + renderPreview(); + playShutter(); + setStatus("Captured. Click Edit to annotate.", "ok"); + refreshRecent(); + } else { + setStatus("Nothing captured.", "err"); + } + } catch (e) { + console.warn("capture failed:", e); + setStatus(String(e && e.message || e), "err"); + } finally { + busy = false; + hdrStatus.textContent = ""; + for (const b of document.querySelectorAll("button.mode")) b.disabled = false; + } + } + + async function doEdit() { + if (!last || !last.name || busy) return; + // No IPC handshake — the editor pulls bytes on load via silentmode + // .invoke("getBytes", {name}) directly. That's idempotent, so a refresh + // or back-and-forth navigation always shows the image. + location.href = "editor.html?name=" + encodeURIComponent(last.name); + } + + async function toggleMax() { + try { await window.silentmode.sidebar.toggleMax(); } + catch (e) { console.warn("toggleMax failed:", e); } + } + + // Convert a base64 data URL into a Blob without a fetch round-trip. + function dataUrlToBlob(dataUrl) { + const m = /^data:([^;,]+);base64,(.+)$/.exec(String(dataUrl || "")); + if (!m) throw new Error("expected base64 data URL"); + const bin = atob(m[2]); + const buf = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); + return new Blob([buf], { type: m[1] }); + } + async function doCopy() { + if (!last || !last.dataUrl || busy) return; + try { + const blob = dataUrlToBlob(last.dataUrl); + await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); + playCopy(); + setStatus("Copied to clipboard.", "ok"); + } catch (e) { + console.warn("copy failed:", e); + setStatus("Copy failed: " + (e && e.message || e), "err"); + } + } + async function doSave() { + if (!last || !last.dataUrl || busy) return; + try { + const blob = dataUrlToBlob(last.dataUrl); + const url = URL.createObjectURL(blob); + dl.href = url; + dl.download = last.name || "screenshot.png"; + dl.click(); + setTimeout(() => URL.revokeObjectURL(url), 5000); + setStatus(`Saved ${dl.download} (${(blob.size / 1024).toFixed(1)} KB)`, "ok"); + } catch (e) { + console.warn("save failed:", e); + setStatus("Save failed: " + (e && e.message || e), "err"); + } + } + + for (const b of document.querySelectorAll("button.mode")) { + b.addEventListener("click", () => doCapture(b.dataset.mode)); + } + btnEdit.addEventListener("click", doEdit); + btnDiscard.addEventListener("click", () => { playDiscard(); last = null; renderPreview(); setStatus(""); }); + btnCopy.addEventListener("click", doCopy); + btnSave.addEventListener("click", doSave); + btnMax.addEventListener("click", toggleMax); + document.getElementById("btn-close").addEventListener("click", async () => { + // Explicit close from the sidebar itself — same IPC the toolbar dock + // icon toggles. No need to un-maximize first; setSidebar(false) on the + // main side flips visibility and the maximize state is remembered so + // the next open lands where it left off. + try { await window.silentmode.sidebar.close(); } + catch (e) { setStatus("Close failed: " + (e && e.message || e), "err"); } + }); + + // Inline confirmation for "clear all" — the native confirm() looks like an + // OS-modal from the last decade AND opens over the tab area (out of the + // sidebar's visual context). Toggle a styled strip inside the sidebar so + // the destructive action stays where the user is looking. + const confirmBox = $("clear-confirm"); + clearBtn.addEventListener("click", () => { + confirmBox.classList.add("on"); + clearBtn.disabled = true; + }); + $("clear-no").addEventListener("click", () => { + confirmBox.classList.remove("on"); + clearBtn.disabled = false; + }); + $("clear-yes").addEventListener("click", async () => { + confirmBox.classList.remove("on"); + clearBtn.disabled = false; + try { await window.silentmode.invoke("clearRecent", {}); await refreshRecent(); setStatus("Cleared past captures.", "ok"); } + catch (e) { setStatus("Clear failed: " + (e && e.message || e), "err"); } + }); + + refreshRecent(); +</script> +</body> +</html> diff --git a/chrome.html b/chrome.html new file mode 100644 index 00000000..b9128e84 --- /dev/null +++ b/chrome.html @@ -0,0 +1,1556 @@ +<!doctype html> +<html> +<head><meta charset="utf-8"> +<style> + :root { + color-scheme: light dark; font-family: system-ui, sans-serif; + --bg:#0f1420; --surface:#1b2330; --surface2:#171e2a; --active:#263041cc; + --ink:#e7eaf1; --mut:#b9c2d0; --dim:#8b98a9; --faint:#5e6678; + --line:rgba(255,255,255,.20); --line2:rgba(255,255,255,.13); --hover:rgba(255,255,255,.08); + /* Brand acid green. #d6ff3d reads ~10:1 on the dark ground; a darker + variant kicks in for light mode below so it stays legible. */ + --acid:#d6ff3d; + } + @media (prefers-color-scheme: light) { + :root { + /* Chrome strip is a soft neutral grey — flat white was too bright next + to the browsed page, and pure #ffffff tabs disappeared into the + window title bar on light Windows themes. --bg tints the whole + top strip; --surface (inactive tab) sits a hair above it; --active + (foreground tab) pops a bit brighter so the current tab reads. */ + --bg:#e6e8ec; --surface:#f2f4f7; --surface2:#eaecf0; --active:#ffffff; + --ink:#1a1f28; --mut:#3c4453; --dim:#697280; --faint:#98a1b0; + --line:rgba(0,0,0,.18); --line2:rgba(0,0,0,.11); --hover:rgba(0,0,0,.06); + /* Darker acid for light backgrounds — same hue family, ~5.5:1 + contrast on #ffffff so text and single-pixel accents stay + readable. Tint fills (rgb(from var(--acid) r g b / .08) etc.) stay as-is: + at low alpha the specific hue barely matters. */ + --acid: #0AC18E; + } + } + [hidden] { display: none !important; } + /* No `height: 100vh` — the chrome view is sized to CHROME_H by main.js, + and body took that height, so document.body.scrollHeight always equalled + the current CHROME_H. syncHeight() then just re-sent CHROME_H, so any + grow (for a menu) could never shrink back. Letting body size to its + content means scrollHeight is the natural chrome height, and syncHeight + shrinks correctly after every menu-close. */ + body { margin: 0; background: var(--bg); color: var(--ink); user-select: none; overflow: hidden; } + .tabs { display: flex; align-items: flex-end; gap: 4px; padding: 6px 8px 0; height: 34px; overflow: hidden; } + /* Same-size tabs: each tab claims an equal share of the row, capped at + 200px so 2 tabs don't stretch across the whole window. min-width lets + many tabs shrink cleanly while still showing a couple letters. */ + .tab { display: flex; align-items: center; gap: 8px; flex: 1 1 0; max-width: 200px; min-width: 60px; padding: 6px 10px; + background: var(--surface); border: 1px solid var(--line2); border-bottom: none; border-radius: 8px 8px 0 0; + font-size: 12.5px; cursor: default; color: var(--mut); user-select: none; } + .tab.dragging { opacity: .45; cursor: grabbing; } + .tab.dropbefore { box-shadow: -2px 0 0 var(--acid); } + .tab.dropafter { box-shadow: 2px 0 0 var(--acid); } + .tab.active { background: var(--active); color: var(--ink); } + .tab .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } + .tab .fav { width: 14px; height: 14px; flex: none; border-radius: 2px; object-fit: contain; } + .tab .spin { width: 10px; height: 10px; flex: none; border: 1.5px solid #ffffff2e; border-top-color: var(--acid); border-radius: 50%; animation: spin .7s linear infinite; } + @keyframes spin { to { transform: rotate(360deg); } } + .tab .x { opacity: .5; cursor: pointer; padding: 0 2px; border-radius: 4px; } + .tab .mute { font-size: 10px; opacity: .8; margin-right: 2px; } + /* Tab group visual: a colored dot before the title, plus a matching top + accent stripe on the tab itself so a whole group reads as one cluster + even when tabs are next to each other. */ + .tab .gdot { width: 8px; height: 8px; flex: none; border-radius: 50%; } + .tab.grp { box-shadow: inset 0 2px 0 var(--gc, transparent); } + /* Group chip that appears before the first tab of a group. Clicking + collapses the group (hides its member tabs) and shows a count. */ + .gchip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; + background: var(--surface); border: 1px solid var(--line2); border-bottom: none; + border-radius: 8px 8px 0 0; cursor: pointer; height: 28px; + font: 12px/1 system-ui, sans-serif; color: var(--mut); + box-shadow: inset 0 2px 0 var(--gc, transparent); } + .gchip:hover { color: var(--ink); } + .gchip .gcdot { width: 10px; height: 10px; border-radius: 50%; background: var(--gc); flex: none; } + .gchip .gccnt { color: var(--ink); font-weight: 600; } + .gchip.collapsed { padding: 4px 10px; } + /* Drag hover state — a tab being dragged onto a group chip. */ + .gchip.droptarget { background: rgb(from var(--acid) r g b / .15); border-color: var(--acid); } + /* Vertical popover: click a collapsed chip → list its member tabs. */ + .grouppop { position: fixed; z-index: 9999; background: var(--surface, #1c222c); border: 1px solid var(--line2); + border-radius: 10px; box-shadow: 0 12px 34px #000c; padding: 4px; min-width: 220px; max-width: 360px; + font-size: 13px; color: var(--ink); } + .grouppop .gph { padding: 6px 10px 4px; font-size: 11px; color: var(--dim); letter-spacing: .08em; text-transform: uppercase; + display: flex; align-items: center; gap: 8px; } + .grouppop .gph .gcdot { width: 8px; height: 8px; border-radius: 50%; } + .grouppop .gpit { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 6px; cursor: pointer; + white-space: nowrap; overflow: hidden; } + .grouppop .gpit:hover { background: var(--hover, #ffffff10); } + .grouppop .gpit.active { background: rgb(from var(--acid) r g b / .08); color: var(--acid); } + .grouppop .gpit .gfav { width: 14px; height: 14px; flex: none; object-fit: contain; border-radius: 2px; } + .grouppop .gpit .gt { overflow: hidden; text-overflow: ellipsis; flex: 1; } + .grouppop .gpit .gx { opacity: .5; padding: 0 4px; font-size: 12px; } + .grouppop .gpit:hover .gx { opacity: .9; } + .grouppop .gpit .gx:hover { color: #f6768a; opacity: 1; } + @media (prefers-color-scheme: light) { + .grouppop { background: #ffffff; border-color: rgba(0,0,0,.15); } + .grouppop .gpit:hover { background: rgba(0,0,0,.05); } + } + .tab.g-red, .gdot.g-red, .gcdot.g-red { --gc: #f6768a; } .gdot.g-red { background: #f6768a; } + .tab.g-orange, .gdot.g-orange, .gcdot.g-orange { --gc: #ffa96a; } .gdot.g-orange { background: #ffa96a; } + .tab.g-yellow, .gdot.g-yellow, .gcdot.g-yellow { --gc: #ffd44f; } .gdot.g-yellow { background: #ffd44f; } + .tab.g-green, .gdot.g-green, .gcdot.g-green { --gc: #4fd1a5; } .gdot.g-green { background: #4fd1a5; } + .tab.g-cyan, .gdot.g-cyan, .gcdot.g-cyan { --gc: #4fd1e5; } .gdot.g-cyan { background: #4fd1e5; } + .tab.g-blue, .gdot.g-blue, .gcdot.g-blue { --gc: #4b7bec; } .gdot.g-blue { background: #4b7bec; } + .tab.g-purple, .gdot.g-purple, .gcdot.g-purple { --gc: #b39ddb; } .gdot.g-purple { background: #b39ddb; } + /* Submenu popover for the tab context menu (Group → color). */ + .ctxmenu .mi.sub { display: flex; align-items: center; justify-content: space-between; gap: 12px; } + .ctxmenu .mi.sub::after { content: "▸"; color: var(--dim); font-size: 11px; } + .ctxmenu.sub2 { min-width: 140px; } + .ctxmenu .swatch { width: 12px; height: 12px; border-radius: 50%; display: inline-block; margin-right: 8px; vertical-align: -1px; border: 1px solid rgba(255,255,255,.10); } + .tab .x:hover { opacity: 1; background: var(--line2); } + .newtab { padding: 4px 10px; cursor: pointer; color: var(--dim); border-radius: 6px; font-size: 16px; } + .newtab:hover { background: var(--hover); color: var(--ink); } + .bar { display: flex; gap: 6px; align-items: center; padding: 6px 10px; } + .nav { display: flex; gap: 1px; } + .ic { width: 34px; height: 32px; display: grid; place-items: center; border-radius: 8px; cursor: pointer; + color: var(--mut); background: transparent; border: none; padding: 0; } + .ic svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } + .ic:hover { background: var(--line2); color: var(--ink); } + .ic.active { background: rgb(from var(--acid) r g b / .12); color: var(--acid); } + .ic:active { background: var(--line); } + .ic:disabled { opacity: .28; cursor: default; background: transparent; } + /* One toolbar button per registered addon sidebar-panel, plus a static + placeholder for the built-in wallet before it lands. Each button + shows the panel's icon (usually a single emoji from the manifest); + click toggles the sidebar open on that panel, or collapses if it + was already the active panel. */ + /* The dock and its inner buttons row never wrap — a shrunken slot makes + us collapse to the .extmore button (via data-extcollapse), it doesn't + stack the buttons vertically. inline-flex on #extbuttons keeps the + buttons on one line; overflow:hidden clips the last button just before + we swap to the collapsed dock in JS. */ + #extbuttons { display: inline-flex; flex-wrap: nowrap; gap: 2px; overflow: hidden; } + .extdock { display: flex; gap: 2px; align-items: center; padding-left: 4px; margin-left: 2px; + flex-wrap: nowrap; overflow: hidden; min-width: 0; + border-left: 1px solid var(--line); } + /* inline-grid (not grid): grid defaults to block-level, which was making + every extension button take its own line inside #extbuttons; inline-grid + keeps the icon-centering behaviour without stacking siblings vertically. */ + .extbtn { width: 30px; height: 30px; display: inline-grid; place-items: center; border-radius: 7px; + cursor: pointer; border: none; background: transparent; padding: 0; + font-size: 15px; line-height: 1; color: var(--mut); } + .extbtn:hover { background: var(--line2); color: var(--ink); } + .extbtn.active { background: rgb(from var(--acid) r g b / .12); color: var(--acid); box-shadow: inset 0 -2px 0 var(--acid); } + .extbtn.soon { opacity: .45; cursor: help; } + .extbtn.soon:hover { opacity: .7; background: var(--line2); } + /* downloads button — a small badge sits over the icon when there's activity */ + .dlbtn { position: relative; } + .dlbtn.spin::before { content: ""; position: absolute; inset: 4px; border-radius: 999px; + border: 1.5px solid transparent; border-top-color: #4b7bec; animation: dlspin .9s infinite linear; } + @keyframes dlspin { to { transform: rotate(360deg); } } + .dlbadge { position: absolute; top: 2px; right: 2px; min-width: 14px; height: 14px; padding: 0 3px; + background: #4b7bec; color: #fff; border-radius: 7px; font-size: 9.5px; font-weight: 700; + display: grid; place-items: center; line-height: 1; box-shadow: 0 0 0 2px var(--bg); } + .dlbtn.done .dlbadge { background: #4fd1a5; } + .dlbtn.err .dlbadge { background: #f6768a; } + /* indeterminate loading bar under the toolbar — collapses when idle so it + doesn't take a permanent 2px strip below the address bar */ + .loadbar { height: 0; overflow: hidden; background: transparent; transition: height .12s linear; } + .loadbar.on { height: 2px; } + .loadbar.on::after { content: ""; display: block; height: 100%; width: 35%; border-radius: 2px; + background: linear-gradient(90deg, transparent, #4b7bec, var(--acid), transparent); animation: loadslide 1.1s infinite linear; } + @keyframes loadslide { 0% { transform: translateX(-110%); } 100% { transform: translateX(400%); } } + /* address bar. flex:1 by default consumes remaining space; user can cap + it via Settings > Appearance > Address bar size so the extension dock + gets more room. min-width guards against squeezing the URL invisible. */ + /* The address bar and search bar share ONE flex budget between the nav + buttons and the trailing button dock — the whole .urlsearch unit takes + whatever space is left after the fixed items. A single drag handle + between them shifts the ratio (bigger URL bar = smaller search bar, + and vice versa) without moving anything else in the toolbar. When the + window is too narrow for both, the search bar hides (see the + data-responsive rules below); the URL bar keeps working alone. */ + /* margin-right creates a bit of breathing room between the search bar + and the trailing dock (Downloads, extensions, ⛓ Theseus) so they + never look like they collide. */ + .urlsearch { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 6px; margin-right: 10px; } + .urlwrap { position: relative; flex: 0 1 var(--urlbar-w, 60%); min-width: 220px; display: flex; align-items: center; gap: 4px; background: var(--surface); border: 1px solid var(--line); + border-radius: 999px; padding: 0 6px 0 4px; } + /* Search-box floor bumped so the "Search" placeholder always fits inside + the input — dropping below ~180px in normal font sizes was where the + placeholder text started getting clipped. */ + .searchbox { flex: 1 1 0; min-width: 180px; } + /* Single drag handle sitting between the two — a thin 6px column with a + col-resize cursor. Zero-sum semantics: dragging right grows the URL + bar and shrinks the search bar; dragging left does the inverse. */ + .bardrag { flex: 0 0 6px; align-self: stretch; cursor: col-resize; background: transparent; border-radius: 3px; } + .bardrag:hover, .bardrag.dragging { background: rgb(from var(--acid) r g b / .35); } + /* Adaptive toolbar. A ResizeObserver on .bar sets data-responsive: + "0" wide — everything visible (default) + "1" tight — auto-hide the search box, min-width for URL bar drops + "2" narrow — Theseus button collapses to just the gear icon + "3" xnarrow — extension dock collapses to a single 🧩 puzzle button + that opens a dropdown of every registered addon panel + User's explicit data-searchsize wins over auto-hide at level 1: if the + user picked "wide" or "normal" they still get it, but "normal" at very + narrow widths yields to the responsive collapse. */ + .bar[data-responsive="1"] .searchbox, + .bar[data-responsive="2"] .searchbox, + .bar[data-responsive="3"] .searchbox, + .bar[data-responsive="1"] .bardrag, + .bar[data-responsive="2"] .bardrag, + .bar[data-responsive="3"] .bardrag { display: none; } + .bar[data-responsive="2"] .logo span:not(.gear), + .bar[data-responsive="3"] .logo span:not(.gear) { display: none; } + .bar[data-responsive="2"] .logo, + .bar[data-responsive="3"] .logo { padding: 4px 8px; } + .bar[data-responsive="1"] .urlwrap, + .bar[data-responsive="2"] .urlwrap, + .bar[data-responsive="3"] .urlwrap { min-width: 120px; flex: 1 1 auto; } + /* Level 3: hide the per-extension button row, show the collapsed puzzle + button. Dropdown is a floating .extpop the JS builds on click. */ + /* Two paths to the collapsed dock: the coarse width-based level 3 + (window very narrow) AND the fine-grained data-extcollapse (dock's + own slot ran out of room even though the window is wide, e.g. the + user dragged the URL bar so wide the extensions couldn't fit). */ + .bar[data-responsive="3"] #extbuttons, + .bar[data-extcollapse="1"] #extbuttons { display: none; } + .extmore { display: none; } + .bar[data-responsive="3"] .extmore, + .bar[data-extcollapse="1"] .extmore { display: inline-grid; } + .extmore { width: 30px; height: 30px; place-items: center; border-radius: 7px; + cursor: pointer; border: none; background: transparent; padding: 0; + font-size: 15px; line-height: 1; color: var(--mut); } + .extmore:hover { background: var(--line2); color: var(--ink); } + .extmore.active { background: rgb(from var(--acid) r g b / .12); color: var(--acid); } + .extpop { position: fixed; z-index: 9999; background: var(--surface, #1c222c); + border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 12px 34px #000c; + padding: 4px; min-width: 180px; } + .extpop .epit { display: flex; align-items: center; gap: 10px; padding: 7px 10px; + border-radius: 6px; cursor: pointer; font-size: 13px; color: var(--ink); } + .extpop .epit:hover { background: var(--hover, #ffffff10); } + .extpop .epit.active { background: rgb(from var(--acid) r g b / .10); color: var(--acid); } + .extpop .epit .ic { width: 16px; text-align: center; font-size: 15px; line-height: 1; } + .extpop .epit .lbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .urlwrap:focus-within { border-color: #4b7bec; } + /* Site security shield — always present. Colour communicates state: + neutral (default) for home / resolving, green when the connection is + secure (BCNR chain-verified OR https:// clearnet), red when it isn't + (nxdomain / resolver error / plain http). Shape stays constant so the + button doesn't jump around when the state changes. */ + .secbadge { display: grid; place-items: center; width: 28px; height: 26px; border-radius: 6px; border: none; + background: transparent; cursor: pointer; padding: 0; color: var(--dim); } + .secbadge:hover { background: var(--line2); } + .secbadge svg { width: 16px; height: 16px; fill: currentColor; } + .secbadge.secure { color: #3fb950; } /* GitHub-style saturated green */ + .secbadge.insecure { color: #f6768a; } + .secbadge.warn { color: #f6ad55; } /* kept for legacy call sites */ + input { padding: 7px 6px; border-radius: 999px; border: none; background: transparent; color: inherit; font-size: 13.5px; outline: none; } + /* min-width: 0 is what keeps the trailing controls INSIDE the pill. An + <input> has an intrinsic minimum (~20 characters) that a flex item + won't shrink below by default, so on a narrow window the input held + its width and pushed the registry button and the star out past the + bar's right edge. */ + #url { flex: 1 1 0; min-width: 0; } + .secbadge, .pwchip, .star, .regbtn, .zoomchip { flex: none; } + /* Zoom indicator — only shown when the active tab isn't at 100 %. + Click resets (Ctrl+0); Ctrl +/- and Ctrl+wheel step the level. */ + .zoomchip { border: none; background: transparent; color: var(--mut); cursor: pointer; padding: 2px 5px; + border-radius: 6px; font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums; } + .zoomchip:hover { background: var(--line2); color: var(--ink); } + /* Ariadne's Thread button at the END of the address bar. One icon in + place of the old BCDN/ICANN chips: tinted acid when the page is served + from BCDN, blue for ICANN, neutral otherwise. A small caret marks a + collision candidate (the name exists on both registries). Click opens + a native menu: switch registry, remember the choice, collision policy, + resolver settings. */ + .regbtn { display: inline-flex; align-items: center; gap: 1px; height: 24px; padding: 0 3px 0 4px; border-radius: 6px; + border: none; background: transparent; cursor: pointer; color: var(--dim); } + .regbtn:hover { background: var(--line2); } + .regbtn svg { width: 18px; height: 18px; } + .regbtn.bcdn { color: var(--acid); } + .regbtn.icann { color: #4c9eff; } + .regbtn .cv { display: none; font-size: 8px; color: var(--dim); } + .regbtn.cand .cv { display: inline; } + .star { border: none; background: transparent; cursor: pointer; color: var(--dim); font-size: 15px; padding: 2px 4px; border-radius: 6px; } + .star:hover { background: var(--line2); color: var(--ink); } .star.on { color: #ffd23d; } + /* Update-available chip — shows when the releases manifest names a newer + version than this build. Two buttons: main body opens the download URL + in the system browser; ✕ dismisses for the session. */ + .upchip { display: inline-flex; align-items: center; gap: 0; background: rgb(from var(--acid) r g b / .14); + border: 1px solid color-mix(in srgb, var(--acid) 33%, transparent); border-radius: 999px; color: var(--acid); font-size: 12px; + font-weight: 600; padding: 0; overflow: hidden; } + .upchip .upcore { background: transparent; border: none; color: inherit; font: inherit; + padding: 5px 10px 5px 12px; cursor: pointer; white-space: nowrap; } + .upchip .upcore:hover { background: rgb(from var(--acid) r g b / .10); } + .upchip .updismiss { background: transparent; border: none; border-left: 1px solid color-mix(in srgb, var(--acid) 20%, transparent); + color: inherit; opacity: .55; padding: 5px 8px; cursor: pointer; font-size: 11px; } + .upchip .updismiss:hover { opacity: 1; } + /* Password-fill chip — only visible when the vault is unlocked AND the + current site has matching credentials. Count sits to the left of the key. */ + .pwchip { display: inline-flex; align-items: center; gap: 3px; border: none; background: transparent; color: #3fb950; cursor: pointer; padding: 2px 5px; border-radius: 6px; font-size: 11px; font-weight: 700; } + .pwchip:hover { background: rgba(63,185,80,.14); } + .pwchip svg { width: 14px; height: 14px; } + /* search box: engine icon (click = pick engine) + a wide typing area. + Width comes from the parent .urlsearch flex budget (flex: 1 1 0) — + the earlier top rule at line 172 sets that; here we only style. */ + .searchbox { display: flex; align-items: center; background: var(--surface); border: 1px solid var(--line); + border-radius: 999px; padding: 0 12px 0 3px; } + .searchbox:focus-within { border-color: #4b7bec; } + /* dropdown button shows the SELECTED engine's favicon (emoji fallback) + a caret */ + .engine-btn { display: flex; align-items: center; gap: 3px; height: 30px; border: none; cursor: pointer; + border-radius: 999px 0 0 999px; padding: 0 4px 0 8px; background: transparent; } + .engine-btn:hover { background-color: var(--hover); } + .engine-btn #engineIcon { display: grid; place-items: center; width: 16px; height: 16px; } + .engine-btn .eimg { width: 16px; height: 16px; border-radius: 3px; } + .engine-btn .esym { font-size: 14px; line-height: 1; } + .engine-btn .cv { font-size: 8px; color: var(--dim); } + #search { flex: 1; padding: 7px 4px; } + .tor { padding: 7px 10px; border-radius: 8px; border: 1px solid var(--line); background: #2b2f3a; color: var(--ink); cursor: pointer; white-space: nowrap; font-size: 12.5px; } + .tor.connecting { background: #7a5c14; } .tor.on { background: #6b3fa0; } + .logo { display: flex; align-items: center; gap: 6px; font-weight: 700; color: var(--acid); white-space: nowrap; + padding: 6px 12px; border: 1px solid color-mix(in srgb, var(--acid) 20%, transparent); border-radius: 8px; cursor: pointer; background: rgb(from var(--acid) r g b / .08); font-size: 13px; } + .logo:hover { background: rgb(from var(--acid) r g b / .16); } + .logo .gear { font-size: 12px; opacity: .8; } + /* favorites bar — new-tab page only, single compact row. Chips are a + uniform fixed width so the row reads as a neat grid regardless of the + underlying title length; the strip is 28px tall to fit the roomier chips. */ + .bookmarks { display: flex; align-items: center; gap: 6px; padding: 4px 10px 6px; height: 28px; overflow: hidden; } + /* Ctrl+F find-in-page bar. Sits below the address bar (above tabs body), + ~32px tall so it's part of the chrome view height like .tordisc / .bcnrbar. + syncHeight in the tabs handler picks it up automatically. */ + .findbar { display: flex; align-items: center; gap: 6px; padding: 5px 10px; border-top: 1px solid var(--line); background: var(--surface); } + .findbar input { flex: 1; min-width: 120px; padding: 4px 8px; border-radius: 6px; background: var(--surface2); color: var(--ink); + border: 1px solid var(--line); font: inherit; font-size: 12.5px; outline: none; } + .findbar input:focus { border-color: color-mix(in srgb, var(--acid) 55%, transparent); } + .findbar input.nomatch { border-color: rgba(246,118,138,.55); background: rgba(246,118,138,.10); } + .findbar .findcount { font-size: 12px; color: var(--dim); font-variant-numeric: tabular-nums; min-width: 46px; text-align: right; } + .findbar .findbtn { border: 1px solid var(--line); background: var(--surface2); color: var(--ink); border-radius: 6px; + padding: 3px 8px; font-size: 13px; cursor: pointer; } + .findbar .findbtn:hover { background: var(--surface); border-color: color-mix(in srgb, var(--acid) 35%, transparent); } + /* Fixed width (not max-width) so every chip is the same size — the row + reads as a grid. Title-only labels keep this width workable; renderer + trims descriptors after " — ". */ + .bm { display: flex; align-items: center; gap: 6px; width: 150px; flex: 0 0 150px; padding: 4px 10px; border-radius: 6px; + background: var(--surface2); border: 1px solid var(--hover); color: var(--mut); font-size: 12.5px; cursor: grab; white-space: nowrap; } + .bm:hover { background: var(--active); color: var(--ink); } + .bm.dragging { opacity: .45; cursor: grabbing; } + .bm.dropbefore { box-shadow: -2px 0 0 var(--acid); } + .bm.dropafter { box-shadow: 2px 0 0 var(--acid); } + .bm .bmfav { width: 14px; height: 14px; flex: none; object-fit: contain; border-radius: 2px; } + .bm .bt { overflow: hidden; text-overflow: ellipsis; flex: 1; } + /* In-chrome prompt modal (window.prompt is disabled in Electron BrowserViews, + so bookmark rename etc. use this instead). */ + .promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 10000; + display: grid; place-items: center; } + .promptbox { background: var(--surface, #1c222c); border: 1px solid var(--line); + border-radius: 10px; padding: 14px 16px 12px; min-width: 320px; max-width: 480px; + box-shadow: 0 20px 60px #000d; color: var(--ink); } + .promptbox .plbl { font-size: 12px; color: var(--mut); margin-bottom: 8px; } + .promptbox input { width: 100%; box-sizing: border-box; padding: 7px 10px; border-radius: 6px; + background: var(--surface2, #0f1621); border: 1px solid var(--line); + color: var(--ink); font: inherit; font-size: 13px; outline: none; } + .promptbox input:focus { border-color: rgb(from var(--acid) r g b / .55); } + .promptbox .pact { display: flex; gap: 6px; justify-content: flex-end; margin-top: 10px; } + .promptbox .pbtn { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--line); + background: var(--surface2, #0f1621); color: var(--ink); cursor: pointer; + font: inherit; font-size: 12.5px; } + .promptbox .pbtn:hover { border-color: rgb(from var(--acid) r g b / .35); } + .promptbox .pbtn.primary { background: var(--acid, var(--acid)); color: #0b0e14; border-color: transparent; font-weight: 600; } + .bm .bx { opacity: 0; cursor: pointer; font-size: 11px; } .bm:hover .bx { opacity: .55; } + .bm .bx:hover { opacity: 1; color: #f6768a; } + .bm-empty { color: var(--faint); font-size: 11.5px; white-space: nowrap; } + /* Right-click menu on the bookmarks bar (same visual as settings ctxmenu). */ + .ctxmenu { position: fixed; z-index: 9999; background: #1c222c; border: 1px solid var(--line); + border-radius: 8px; box-shadow: 0 12px 34px #000c; padding: 4px; min-width: 180px; font-size: 13px; color: var(--ink); } + .ctxmenu .mi { padding: 7px 12px; border-radius: 5px; cursor: pointer; white-space: nowrap; } + .ctxmenu .mi:hover { background: #ffffff10; } + .ctxmenu .mi.danger { color: #f6768a; } .ctxmenu .mi.danger:hover { background: rgba(246,118,138,.12); } + .ctxmenu .mi.off { color: var(--faint); cursor: default; } .ctxmenu .mi.off:hover { background: transparent; } + .ctxmenu .sep { height: 1px; background: var(--line); margin: 4px 0; } + @media (prefers-color-scheme: light) { + .ctxmenu { background: #ffffff; border-color: rgba(0,0,0,.15); } + .ctxmenu .mi:hover { background: rgba(0,0,0,.05); } + /* The old light-mode chips used the BCH dark #253A49 as their fill. + At small font sizes on light Windows the white-on-dark-navy label + fell below legible, so both .logo (⛓ Theseus) and .tor switch to + a light-grey chip with dark ink — same footprint, readable text. + Update pill picks up the same treatment. */ + .logo { background: #eef1f5; color: #253A49; border-color: rgba(0,0,0,.14); } + .logo:hover { background: #e2e6ec; } + .logo .gear { color: #253A49; opacity: .85; } + .tor { background: #eef1f5; color: #253A49; border-color: rgba(0,0,0,.14); } + .tor:hover { background: #e2e6ec; } + .tor.connecting { background: #fbe9b5; color: #5a3f00; border-color: rgba(0,0,0,.14); } + .tor.on { background: #e6d9f5; color: #3f1f6b; border-color: rgba(0,0,0,.14); } + .upchip { background: #eef1f5; color: #253A49; border-color: rgba(0,0,0,.14); } + .upchip .upcore:hover { background: #e2e6ec; } + .upchip .updismiss { border-left-color: rgba(0,0,0,.14); color: #253A49; } + } + .tordisc { font-size: 11.5px; color: #d9c7f2; background: #2a1c40; border-top: 1px solid #6b3fa055; padding: 5px 14px; } + .tordisc a { color: var(--acid); } + .bcnrbar { display: flex; align-items: center; gap: 10px; font-size: 11.5px; color: var(--mut); + background: #1a2417; border-top: 1px solid color-mix(in srgb, var(--acid) 20%, transparent); padding: 6px 14px; } + .bcnrbar .breg { font-size: 10px; letter-spacing: .03em; padding: 1px 7px; border-radius: 999px; + background: rgb(from var(--acid) r g b / .14); color: var(--acid); border: 1px solid color-mix(in srgb, var(--acid) 20%, transparent); } + .bcnrbar b { color: var(--ink); } + .bcnrbar .bopen { margin-left: auto; padding: 4px 11px; border-radius: 7px; border: 1px solid color-mix(in srgb, var(--acid) 33%, transparent); + background: rgb(from var(--acid) r g b / .12); color: var(--acid); cursor: pointer; font-size: 11.5px; } + .bcnrbar .bopen:hover { background: rgb(from var(--acid) r g b / .22); } + .bcnrbar .bdismiss { color: var(--dim); text-decoration: none; } .bcnrbar .bdismiss:hover { color: var(--ink); } +</style></head> +<body> + <div class="tabs" id="tabs"></div> + <div class="bar" id="bar"> + <div class="nav"> + <button class="ic" id="back" title="Back"><svg viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round"><path d="M13 8H3M6 5L3 8l3 3"/></svg></button> + <button class="ic" id="fwd" title="Forward"><svg viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8h10M10 5l3 3-3 3"/></svg></button> + <button class="ic" id="reload" title="Reload"><svg viewBox="0 0 16 16"><path d="M13 8 a5 5 0 1 1 -1.5 -3.6"/><path d="M13 2.5 L13 5 L10.5 5"/></svg></button> + <button class="ic" id="home" title="Home"><svg viewBox="0 0 16 16"><path d="M2.5 7.5 L8 3 L13.5 7.5"/><path d="M4 6.7 L4 13 L12 13 L12 6.7"/></svg></button> + </div> + <div class="urlsearch" id="urlsearch"> + <div class="urlwrap"> + <button class="secbadge" id="secbadge" title="Site information"> + <!-- Heraldic shield outline matching the reference image: flared top + shoulders, concave sides tapering to a sharp bottom point. Single + filled path, `currentColor` so state class toggles the tint. --> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" aria-hidden="true"> + <path d="M8 1.2 C6 1.2 3.5 1.7 2.5 2.3 Q2 2.6 2 3.5 L2 7 C2 11.2 4.2 13.7 8 15 C11.8 13.7 14 11.2 14 7 L14 3.5 Q14 2.6 13.5 2.3 C12.5 1.7 10 1.2 8 1.2 Z"/> + </svg> + </button> + <input id="url" placeholder="Ask a search engine or enter web address" spellcheck="false"> + <button class="zoomchip" id="zoomchip" title="Reset zoom (Ctrl+0)" hidden></button> + <button class="regbtn" id="reg" title="Ariadne's Thread" hidden> + <!-- Ariadne's thread: a spiral of semicircles (radii 1 → 5, + centres alternating 8 / 9) unwinding into a loose tail. --> + <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M9 7.5 A1 1 0 0 0 7 7.5 A2 2 0 0 0 11 7.5 A3 3 0 0 0 5 7.5 A4 4 0 0 0 13 7.5 A5 5 0 0 0 3 7.5 C3 11.2 6 14.6 13.6 13.4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg> + <span class="cv">▾</span> + </button> + <button class="pwchip" id="pwchip" title="Fill password for this site" hidden><span id="pwchipCount"></span><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6" cy="9" r="3.2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8.8 8.6 L14 8.6 M13.2 8.6 L13.2 11 M11.5 8.6 L11.5 10.4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button> + <button class="star" id="star" title="Save this page">☆</button> + </div> + <div class="bardrag" id="bardrag" title="Drag to shift the address / search bar ratio"></div> + <div class="searchbox"> + <button id="engineBtn" class="engine-btn" title="Choose search engine"><span id="engineIcon"></span><span class="cv">▾</span></button> + <input id="search" placeholder="Search" spellcheck="false"> + </div> + </div> + <button class="ic dlbtn" id="downloads" title="Downloads"> + <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2 V10 M4.5 6.5 L8 10 L11.5 6.5"/><path d="M3 12.5 L3 13.5 L13 13.5 L13 12.5"/></svg> + <span class="dlbadge" id="dlbadge" hidden>0</span> + </button> + <span class="extdock" id="extdock" hidden> + <span id="extbuttons"></span> + <button class="extmore" id="extmore" title="Extensions" aria-label="Extensions"></button> + </span> + <span class="upchip" id="upchip" hidden><button class="upcore" id="upDownload" title=""></button><button class="updismiss" id="upDismiss" title="Not now">✕</button></span> + <button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button> + <button class="logo" id="logo" title="Settings"><span>⛓ Theseus</span><span class="gear">⚙</span></button> + </div> + <div class="loadbar" id="loadbar"></div> + <div class="bookmarks" id="bookmarks" hidden></div> + <div class="findbar" id="findbar" hidden> + <input id="findInput" type="text" placeholder="Find in page" spellcheck="false" autocomplete="off"> + <span class="findcount" id="findCount">–</span> + <button class="findbtn" id="findPrev" title="Previous (Shift+Enter)">↑</button> + <button class="findbtn" id="findNext" title="Next (Enter)">↓</button> + <button class="findbtn" id="findClose" title="Close (Esc)">✕</button> + </div> + <div id="tordisc" class="tordisc" hidden> + Onion mode hides your IP from sites and your ISP (including .bch lookups). + It is <b>not full anonymity</b> — this browser can still be fingerprinted; for that use the Tor Browser. + <a href="#" id="tordismiss">got it</a> + </div> + <div id="bcnrbar" class="bcnrbar" hidden> + <span class="breg" id="bcnrreg">BCDN</span> + <span><b id="bcnrhost"></b> also exists on <b>BCDN</b> — the decentralized registry.</span> + <button id="bcnropen" class="bopen">Open on BCDN</button> + <a href="#" id="bcnrdismiss" class="bdismiss">dismiss</a> + </div> +<script> + const $ = (id) => document.getElementById(id); + const T = window.theseus; + function syncHeight() { requestAnimationFrame(() => T.setChromeHeight(document.body.scrollHeight)); } + const RELOAD_SVG = `<svg viewBox="0 0 16 16"><path d="M13 8 a5 5 0 1 1 -1.5 -3.6"/><path d="M13 2.5 L13 5 L10.5 5"/></svg>`; + const STOP_SVG = `<svg viewBox="0 0 16 16"><path d="M4.5 4.5 L11.5 11.5 M11.5 4.5 L4.5 11.5"/></svg>`; + + function goURL() { + const v = $("url").value.trim(); + if (!v) return; + // Blur so the tabs-event handler can update the URL bar to the actual + // loaded page — the "don't clobber the typed text" guard would otherwise + // keep the search keyword displayed while the results are loading. + $("url").blur(); + T.navigate(v); + } + // Address-bar suggestions — debounced call to main on every keystroke. + // Main computes matches from history and shows/hides a floating overlay + // WebContentsView anchored under the address bar's client rect. + let suggestTimer = null; + let pickerOpen = false; + const askSuggest = () => { + const q = $("url").value; + const r = $("url").getBoundingClientRect(); + const rect = { x: Math.round(r.left), y: Math.round(r.bottom + 4), w: Math.round(r.width + 40) }; + T.suggestAddress(q, rect); + // main hides itself when there are zero matches; assume open otherwise. + pickerOpen = true; + }; + // Whether the user has moved the picker's highlight since last typing. + // Enter with a highlight submits THAT url (via the picker), not the + // typed text — so pressing ↓ then Enter opens the highlighted entry + // instead of doing a web search for the letters you typed. + let pickerHasCursor = false; + const debouncedSuggest = () => { clearTimeout(suggestTimer); suggestTimer = setTimeout(askSuggest, 80); }; + $("url").addEventListener("input", () => { pickerHasCursor = false; debouncedSuggest(); }); + $("url").addEventListener("focus", debouncedSuggest); + $("url").addEventListener("blur", () => { + // Delay: gives a click on the picker time to register before we close. + setTimeout(() => { T.closeAddressPicker(); pickerOpen = false; pickerHasCursor = false; }, 160); + }); + $("url").addEventListener("keydown", (e) => { + if (e.key === "Enter") { + if (pickerOpen && pickerHasCursor) { + // Forward Enter to the picker so it navigates to the HIGHLIGHTED + // suggestion instead of running goURL against the typed text + // (which would search for the letters, not open the URL). + T.addressCursor("enter"); + return; + } + T.closeAddressPicker(); pickerOpen = false; pickerHasCursor = false; goURL(); + return; + } + if (e.key === "Escape") { T.closeAddressPicker(); pickerOpen = false; pickerHasCursor = false; return; } + if (!pickerOpen) return; + if (e.key === "ArrowDown") { e.preventDefault(); pickerHasCursor = true; T.addressCursor("next"); } + else if (e.key === "ArrowUp") { e.preventDefault(); pickerHasCursor = true; T.addressCursor("prev"); } + }); + // Enter submits the search; keep the query visible so the user can refine + // it or search again — clearing it on submit lost context and made refining + // annoying (esp. when the engine's own results page uses its own search box). + $("search").addEventListener("keydown", (e) => { if (e.key === "Enter") { const q = $("search").value.trim(); if (q) T.search(q); } }); + $("back").onclick = () => T.back(); + $("fwd").onclick = () => T.forward(); + // Shift-click on the reload button = hard reload (bypass cache), same + // convention as Chrome/Firefox. Keyboard alternative: Ctrl+Shift+R. + $("reload").onclick = (e) => T.reload(e.shiftKey); + $("home").onclick = () => T.goHome(); + $("logo").onclick = () => T.openSettings(); + $("secbadge").onclick = () => { const r = $("secbadge").getBoundingClientRect(); T.toggleSiteInfo({ x: Math.round(r.left), y: Math.round(r.bottom + 4) }); }; + + // ---- search-engine picker: the magnifier button opens a custom overlay + // dropdown (real favicons); here we only track the current engine's name. ---- + function setEnginePlaceholder(d) { + const cur = (d.engines || []).find((e) => e.id === d.current); + $("search").placeholder = cur ? "Search with " + cur.name : "Search"; + const icon = $("engineIcon"); + const sym = (cur && cur.sym) || "🔍"; + if (cur && cur.favicon) + icon.innerHTML = `<img class="eimg" src="${cur.favicon.replace(/"/g, "&quot;")}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'esym',textContent:'${sym}'}))">`; + else icon.innerHTML = `<span class="esym">${sym}</span>`; + } + $("engineBtn").onclick = () => { + const r = $("engineBtn").getBoundingClientRect(); + T.toggleEnginePicker({ x: Math.round(r.left), y: Math.round(r.bottom + 4) }); + }; + T.getSearchEngines().then(setEnginePlaceholder); + T.onEngines && T.onEngines(setEnginePlaceholder); + + // ---- downloads button: badge + click toggles the panel ---- + const dlBtn = $("downloads"), dlBadge = $("dlbadge"); + function renderDownloads(items) { + const list = items || []; + const active = list.filter((d) => d.state === "progressing").length; + const doneNew = list.filter((d) => d.state === "completed").length; + const failed = list.filter((d) => d.state === "interrupted").length; + dlBtn.classList.toggle("spin", active > 0); + dlBtn.classList.toggle("done", active === 0 && doneNew > 0 && failed === 0); + dlBtn.classList.toggle("err", active === 0 && failed > 0); + const badge = active || (doneNew + failed); + if (badge > 0) { dlBadge.hidden = false; dlBadge.textContent = badge > 99 ? "99+" : String(badge); } + else { dlBadge.hidden = true; } + } + dlBtn.onclick = () => { + const r = dlBtn.getBoundingClientRect(); + T.toggleDownloads({ x: Math.round(r.right - 340), y: Math.round(r.bottom + 4) }); + }; + T.getDownloads && T.getDownloads().then(renderDownloads); + T.onDownloads && T.onDownloads(renderDownloads); + + // ---- extension dock ---- + // One toolbar button per registered addon sidebar-panel, plus a static + // "coming soon" placeholder for Aegis (the built-in BCH wallet, in + // progress). Click a live button → open the sidebar on that panel; if + // the sidebar was already open on that panel, collapse it. + const extDock = $("extdock"); + // Add-on icon → HTML. Text by default (an emoji like 🛡, or "•"). An + // add-on that wants a branded mark sets manifest.icon to a data: URI + // (svg or png) and we render it as an <img> in the same slot. Anything + // else is HTML-escaped and stays text. Used by the dock buttons, the + // collapsed dock button and the collapsed dropdown alike. + function addonIconHtml(icon, size = 18) { + const s = String(icon || "•"); + if (/^data:image\//i.test(s)) return `<img src="${s.replace(/"/g,"&quot;")}" alt="" style="width:${size}px;height:${size}px;display:block;margin:0 auto">`; + return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); + } + const extButtons = $("extbuttons"); + // Flat list of dock buttons — an addon that registers a sidebar-panel and + // an addon that declares a toolbar-menu both get one entry here. panels + // stay clickable-toggle; menus open a floating dropdown positioned under + // the icon and dispatch the picked item back to the add-on. + let lastPanels = [], lastMenus = [], lastButtons = []; + let sidebarOpen = false, activePanelId = null; + let openMenuAddonId = null; // which addon's menu dropdown is currently open + function renderSidebarState(d) { + if (!d) return; + lastPanels = Array.isArray(d.panels) ? d.panels : []; + lastMenus = Array.isArray(d.toolbarMenus) ? d.toolbarMenus : []; + sidebarOpen = !!d.visible; + activePanelId = d.active || null; + // Build the flat button list, panels first (matches shipped order) then + // toolbar-menu buttons in registration order. + lastButtons = []; + for (const p of lastPanels) { + lastButtons.push({ kind: "panel", key: "p:" + p.panelId, panelId: p.panelId, icon: String(p.icon || "•"), title: p.title || "Extension" }); + } + for (const m of lastMenus) { + lastButtons.push({ kind: "menu", key: "m:" + m.addonId, addonId: m.addonId, icon: String(m.icon || "•"), title: m.title || m.addonId, items: m.items || [] }); + } + // Hide the dock entirely on a fresh install with no add-ons registered + // — no empty-slot artifact next to the download button. + extDock.hidden = lastButtons.length === 0; + extButtons.innerHTML = lastButtons.map((b) => { + const activePanel = b.kind === "panel" && sidebarOpen && b.panelId === activePanelId; + const activeMenu = b.kind === "menu" && openMenuAddonId === b.addonId; + const cls = (activePanel || activeMenu) ? " active" : ""; + const dataAttr = b.kind === "panel" + ? `data-panel="${b.panelId.replace(/"/g,"&quot;")}"` + : `data-menu="${b.addonId.replace(/"/g,"&quot;")}"`; + // Icon is text by default (an emoji like 🛡, or a "•" fallback). An + // add-on that wants a branded mark can set manifest.icon to a data: + // URI (svg or png) and we render it as an <img> instead — same size + // slot, no layout change. Anything else is HTML-escaped and stays + // text so the emoji case keeps working. + return `<button class="extbtn${cls}" ${dataAttr} title="${b.title.replace(/"/g,"&quot;")}">${addonIconHtml(b.icon)}</button>`; + }).join(""); + extButtons.querySelectorAll(".extbtn").forEach((btn) => { + btn.onclick = () => { + if (btn.dataset.panel) { + const pid = btn.dataset.panel; + closeToolbarMenu(); + if (sidebarOpen && pid === activePanelId) { T.closeSidebar && T.closeSidebar(); } + else { T.openSidebar && T.openSidebar(pid); } + } else if (btn.dataset.menu) { + openToolbarMenu(btn.dataset.menu, btn); + } + }; + }); + // Collapsed dock button: show the first button's icon (falling back + // to 🧩 if nothing is registered). Clicking it opens a dropdown of + // every registered surface so the user can pick. + const first = lastButtons[0]; + // Same renderer as the full-size buttons — a data: URI icon used to be + // dumped here as text ("data:image/svg+xml…") once the dock collapsed. + $("extmore").innerHTML = first ? addonIconHtml(first.icon) : "🧩"; + $("extmore").title = first ? "Extensions — " + first.title : "Extensions"; + $("extmore").classList.toggle("active", (sidebarOpen && !!activePanelId) || !!openMenuAddonId); + } + + // ---- toolbar-menu dropdown (per-addon) ---- + // Positions under the clicked icon, styled after .extpop. Clicking an + // item dispatches to main via addonMenuSelect(addonId, itemId) and + // closes; clicking outside the popover closes it. + function closeToolbarMenu() { + const p = document.getElementById("tbmenu"); + if (p) p.remove(); + if (openMenuAddonId) { + openMenuAddonId = null; + // Re-render dock so the "active" tint drops off the icon. + renderSidebarState({ visible: sidebarOpen, active: activePanelId, panels: lastPanels, toolbarMenus: lastMenus }); + } + } + function openToolbarMenu(addonId, anchorBtn) { + // Toggle: clicking the same icon while its menu is open closes it. + if (openMenuAddonId === addonId) { closeToolbarMenu(); return; } + closeToolbarMenu(); + const menu = lastMenus.find((m) => m.addonId === addonId); + if (!menu) return; + openMenuAddonId = addonId; + // Tint the anchor while the menu is showing; main clears it when the + // native menu closes via the "toolbar-menu-closed" event. + const btns = extButtons.querySelectorAll(".extbtn[data-menu]"); + btns.forEach((b) => b.classList.toggle("active", b.dataset.menu === addonId)); + $("extmore").classList.toggle("active", true); + // Hand off to a NATIVE OS menu. Renderer-DOM dropdowns get clipped by + // chrome.html's own WebContentsView height and hidden behind the tab + // view below — a native Menu.popup escapes that layering. + const r = anchorBtn.getBoundingClientRect(); + const rect = { x: Math.round(r.left), y: Math.round(r.bottom + 2), width: Math.round(r.width), height: Math.round(r.height) }; + if (T.toolbarMenuPopup) { + T.toolbarMenuPopup(addonId, rect).catch((err) => console.warn("toolbar-menu-popup failed:", err?.message || err)); + } + } + // Main tells us when the native menu is dismissed so we can clear the + // "active" tint. `openMenuAddonId` stays set until this arrives (a click + // on the same button while the menu is up is treated as "close by + // reopening intent" by the OS anyway). + if (T.onToolbarMenuClosed) T.onToolbarMenuClosed(() => closeToolbarMenu()); + T.sidebarState && T.sidebarState().then(renderSidebarState); + T.onSidebarState && T.onSidebarState(renderSidebarState); + + // Collapsed extensions dropdown — opens under the .extmore button at + // responsive level 3, lists every registered panel with icon + name. + function closeExtPop() { const p = document.getElementById("extpop"); if (p) p.remove(); } + $("extmore").onclick = (ev) => { + ev.stopPropagation(); + if (document.getElementById("extpop")) return closeExtPop(); + closeToolbarMenu(); + const rect = $("extmore").getBoundingClientRect(); + const pop = document.createElement("div"); + pop.id = "extpop"; pop.className = "extpop"; + // Include both sidebar panels AND toolbar-menu buttons at narrow widths. + // A panel row toggles the sidebar; a menu row opens a nested submenu. + const rows = []; + for (const p of lastPanels) { + const isActive = sidebarOpen && p.panelId === activePanelId; + rows.push(`<div class="epit${isActive ? " active" : ""}" data-panel="${p.panelId.replace(/"/g,"&quot;")}"><span class="ic">${addonIconHtml(p.icon, 16)}</span><span class="lbl">${(p.title || "Extension").replace(/</g,"&lt;")}</span></div>`); + } + for (const m of lastMenus) { + rows.push(`<div class="epit" data-menu="${m.addonId.replace(/"/g,"&quot;")}"><span class="ic">${addonIconHtml(m.icon, 16)}</span><span class="lbl">${(m.title || m.addonId).replace(/</g,"&lt;")}</span></div>`); + } + pop.innerHTML = rows.join("") || `<div class="epit" style="opacity:.6;cursor:default">No extensions installed</div>`; + document.body.appendChild(pop); + const r = pop.getBoundingClientRect(); + pop.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - r.width - 4)) + "px"; + pop.style.top = Math.round(rect.bottom + 4) + "px"; + pop.querySelectorAll(".epit[data-panel]").forEach((row) => { + row.onclick = () => { + const pid = row.dataset.panel; + closeExtPop(); + if (sidebarOpen && pid === activePanelId) { T.closeSidebar && T.closeSidebar(); } + else { T.openSidebar && T.openSidebar(pid); } + }; + }); + pop.querySelectorAll(".epit[data-menu]").forEach((row) => { + row.onclick = () => { + const aid = row.dataset.menu; + closeExtPop(); + // Re-anchor the submenu under the .extmore button since the + // per-icon dock button is hidden at this responsive level. + openToolbarMenu(aid, $("extmore")); + }; + }); + setTimeout(() => { + const off = (e) => { if (!pop.contains(e.target) && e.target !== $("extmore")) { closeExtPop(); document.removeEventListener("mousedown", off); } }; + document.addEventListener("mousedown", off); + }, 0); + }; + + // Responsive breakpoints on .bar width. ResizeObserver reacts to window + // resize, sidebar toggle, and any chrome-height change — all cheap since + // we only touch a single dataset attribute if the level changes. + const barEl = $("bar"); + const RESPONSIVE_BREAKS = [ + { w: 1050, level: "0" }, // wide + { w: 820, level: "1" }, // hide search + { w: 620, level: "2" }, // theseus icon-only + { w: 0, level: "3" }, // extensions → dropdown + ]; + function pickResponsive(w) { + for (const b of RESPONSIVE_BREAKS) if (w >= b.w) return b.level; + return "3"; + } + // Fine-grained overflow check: if the extension buttons can't fit their + // own slot in the bar, force the collapsed dock (single button + dropdown) + // regardless of window width. Fires when the user drags the URL bar wider, + // adds an extension, or a wider-icon extension gets registered. + const extButtonsEl = $("extbuttons"); + const extDockEl = $("extdock"); + // Cache the natural width of the buttons row while expanded. When the dock + // is currently collapsed, #extbuttons has display:none so scrollWidth is + // 0 — measuring it then would falsely decide "plenty of room" and expand, + // which would immediately overflow again → RO loop. Learn while expanded + // and trust that value while collapsed. 8px hysteresis on the re-expand + // side prevents boundary oscillation. + let naturalExtWidth = 0; + function updateExtCollapse() { + if (!extButtonsEl || !extDockEl) return; + const currentlyCollapsed = barEl.dataset.extcollapse === "1"; + if (!currentlyCollapsed) { + naturalExtWidth = Math.max(naturalExtWidth, extButtonsEl.scrollWidth); + // While expanded: collapse when .bar's contents overflow their slot + // (i.e. the row + everything else doesn't fit horizontally). + if (barEl.scrollWidth > barEl.clientWidth + 2 && barEl.dataset.responsive !== "3") { + barEl.dataset.extcollapse = "1"; + } + } else { + // While collapsed: re-expand only when there's demonstrably room in + // .bar for the buttons at their natural width. Sum the widths of all + // other .bar children (skipping display:none), add the collapsed + // dock's target size (natural), and compare to bar.clientWidth. 8px + // slack + require the strict "current bar isn't already overflowing" + // to keep the toggle from oscillating at the boundary. + if (barEl.scrollWidth > barEl.clientWidth + 2) return; // still cramped + const barStyle = getComputedStyle(barEl); + const gap = parseFloat(barStyle.gap) || 0; + let usedByOthers = 0; + let visibleKids = 0; + for (const k of barEl.children) { + if (getComputedStyle(k).display === "none") continue; + visibleKids += 1; + if (k === extDockEl) continue; + usedByOthers += k.getBoundingClientRect().width; + } + const gaps = gap * Math.max(0, visibleKids - 1); + // .bar has horizontal padding too — subtract it. + const padX = (parseFloat(barStyle.paddingLeft) || 0) + (parseFloat(barStyle.paddingRight) || 0); + const availableForDock = barEl.clientWidth - usedByOthers - gaps - padX; + if (availableForDock >= naturalExtWidth + 8) delete barEl.dataset.extcollapse; + } + } + const ro = new ResizeObserver((entries) => { + for (const e of entries) { + const level = pickResponsive(e.contentRect.width); + if (barEl.dataset.responsive !== level) { + barEl.dataset.responsive = level; + // Close the extmore dropdown if we shrank/grew across level 3. + if (level !== "3") closeExtPop(); + } + } + // Re-check overflow every time the bar resizes — cheap. + updateExtCollapse(); + }); + ro.observe(barEl); + // Also observe #extbuttons directly so that a new extension being + // registered (which changes scrollWidth without a bar resize) triggers a + // re-check. + if (extButtonsEl) new ResizeObserver(updateExtCollapse).observe(extButtonsEl); + // Initial pass — RO fires on subscribe, but only after the next frame; + // set an initial value so first paint doesn't flash the wide layout. + barEl.dataset.responsive = pickResponsive(barEl.getBoundingClientRect().width); + updateExtCollapse(); + + // ---- favorites bar (new-tab page only) ---- + let current = { url: "", title: "", favicon: null }, bookmarks = []; + // Site titles often end with a descriptor after " — " or " - ", e.g. + // "GAME.X — Bitcoin Cash game platform". The chip only has room for the + // leading brand token, so trim at the first em-dash / en-dash / hyphen + // that's surrounded by whitespace. The full title still shows on hover + // (tooltip below) so the descriptor isn't actually lost. + // + // Brand-case normalisation for Silent Mode's .x TLD family: sites save + // their title in whatever case they set (theseus.x, GAME.X, silentmode.x) + // — the chip renders them uniform as <Name>.X. Titles are Title-case by + // default; multi-word brand names (SilentMode, CoinSpectrum) have their + // canonical form in BRAND_CASE_X so they don't collapse to "Silentmode". + const BRAND_CASE_X = { + silentmode: "SilentMode", coinspectrum: "CoinSpectrum", + theseus: "Theseus", sirius: "Sirius", deviant: "Deviant", aegis: "Aegis", + ariadne: "Ariadne", argus: "Argus", hermes: "Hermes", + prometheus: "Prometheus", hephaestus: "Hephaestus", helios: "Helios", + atlas: "Atlas", katalogos: "Katalogos", game: "Game", + poutakidis: "Poutakidis", syskypo: "Syskypo", + }; + function bookmarkBrandCase(label) { + const m = label.match(/^([a-zA-Z][a-zA-Z0-9-]*)\.x$/i); + if (!m) return label; + const key = m[1].toLowerCase(); + const cased = BRAND_CASE_X[key] || (key.charAt(0).toUpperCase() + key.slice(1)); + return cased + ".X"; + } + function bookmarkShortLabel(title, url) { + const src = (title || url || "").trim(); + if (!src) return ""; + const cut = (src.split(/\s+[—–-]\s+/, 1)[0] || src); + return bookmarkBrandCase(cut); + } + function renderBookmarks() { + const box = $("bookmarks"); + box.innerHTML = bookmarks.length + ? bookmarks.map((b, i) => { + const fav = b.favicon + ? `<img class="bmfav" src="${String(b.favicon).replace(/"/g,"&quot;")}" onerror="this.remove()">` + : `<span class="bmfav" style="display:inline-block"></span>`; + const short = bookmarkShortLabel(b.title, b.url); + return `<div class="bm" draggable="true" data-i="${i}" data-url="${(b.url||"").replace(/"/g,"&quot;")}" title="${((b.title||b.url)+" — "+(b.url||"")).replace(/"/g,"&quot;")}">${fav}<span class="bt">${short.replace(/</g,"&lt;")}</span><span class="bx" data-x="${i}">✕</span></div>`; + }).join("") + : `<span class="bm-empty">No saved pages yet — click ☆ or right-click here to add one.</span>`; + box.querySelectorAll(".bm").forEach((el) => el.onclick = (e) => { + const i = Number(el.dataset.i); + if (e.target.dataset.x !== undefined) T.removeBookmark(bookmarks[i].url); + else T.navigate(bookmarks[i].url); + }); + // Drag-reorder chips — same shape as the tab strip. Drop side is chosen + // by whether the pointer is on the left/right half of the target chip. + // dragUrl is the identity we hand to bookmark-move; renderBookmarks + // fires again after the reorder, which rebinds these handlers cleanly. + let dragUrl = null; + box.querySelectorAll(".bm").forEach((row) => { + row.addEventListener("dragstart", (e) => { + dragUrl = row.dataset.url; + try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", dragUrl); } catch {} + row.classList.add("dragging"); + }); + row.addEventListener("dragend", () => { + row.classList.remove("dragging"); + box.querySelectorAll(".bm").forEach((r) => r.classList.remove("dropbefore", "dropafter")); + dragUrl = null; + }); + row.addEventListener("dragover", (e) => { + if (!dragUrl || row.dataset.url === dragUrl) return; + e.preventDefault(); e.dataTransfer.dropEffect = "move"; + const r = row.getBoundingClientRect(); + const before = (e.clientX - r.left) < r.width / 2; + row.classList.toggle("dropbefore", before); + row.classList.toggle("dropafter", !before); + }); + row.addEventListener("dragleave", () => row.classList.remove("dropbefore", "dropafter")); + row.addEventListener("drop", (e) => { + e.preventDefault(); + const targetUrl = row.dataset.url; + if (!dragUrl || dragUrl === targetUrl) return; + const r = row.getBoundingClientRect(); + const before = (e.clientX - r.left) < r.width / 2; + T.moveBookmark && T.moveBookmark(dragUrl, targetUrl, before ? "before" : "after"); + }); + }); + // Backfill: older bookmarks stored before favicon-support have no icon. + // If we're currently on that URL and have a favicon in hand, patch it in + // once so the next render draws it. + if (current.url && current.favicon) { + const stale = bookmarks.find((b) => b.url === current.url && !b.favicon); + if (stale && T.updateBookmark) T.updateBookmark(current.url, { favicon: current.favicon }); + } + updateStar(); syncHeight(); + } + // In-page prompt modal — window.prompt is disabled in Electron BrowserViews. + // Resolves to the trimmed string on OK, or null on Cancel / Escape / mask click. + function inPrompt(label, initial) { + return new Promise((resolve) => { + const mask = document.createElement("div"); mask.className = "promptmask"; + mask.innerHTML = `<div class="promptbox"><div class="plbl">${label.replace(/</g,"&lt;")}</div>` + + `<input type="text" spellcheck="false">` + + `<div class="pact"><button class="pbtn pcancel" type="button">Cancel</button><button class="pbtn primary pok" type="button">OK</button></div></div>`; + document.body.appendChild(mask); + const input = mask.querySelector("input"); + input.value = String(initial ?? ""); + const done = (val) => { try { mask.remove(); } catch {} document.removeEventListener("keydown", key); resolve(val); }; + const key = (e) => { if (e.key === "Escape") done(null); else if (e.key === "Enter") done(input.value.trim() || null); }; + document.addEventListener("keydown", key); + mask.addEventListener("mousedown", (e) => { if (e.target === mask) done(null); }); + mask.querySelector(".pcancel").addEventListener("click", () => done(null)); + mask.querySelector(".pok").addEventListener("click", () => done(input.value.trim() || null)); + setTimeout(() => { input.focus(); input.select(); }, 0); + }); + } + // Right-click context menu on the bookmarks bar. Two modes: + // - On a specific bookmark: Open / Edit title / Remove + // - On empty area OR the placeholder text: Add current page (or Remove + // if the page is already bookmarked) + function closeBmMenu() { const m = document.getElementById("bmMenu"); if (m) m.remove(); } + function openBmMenu(x, y, ctx) { + closeBmMenu(); + const items = []; + if (ctx.kind === "item") { + const bm = bookmarks[ctx.index]; + items.push({ label: "Open", act: () => T.navigate(bm.url) }); + items.push({ label: "Edit title…", act: async () => { + const nt = await inPrompt("Edit bookmark title", bm.title || bm.url); + if (nt && nt !== bm.title && T.updateBookmark) T.updateBookmark(bm.url, { title: nt }); + }}); + items.push({ label: "Remove", act: () => T.removeBookmark(bm.url), danger: true }); + items.push({ sep: true }); + } + if (current.url) { + const saved = bookmarks.some((b) => b.url === current.url); + items.push(saved + ? { label: "Remove current page", act: () => T.removeBookmark(current.url), danger: true } + : { label: "Add current page", act: () => T.addBookmark({ title: current.title || current.url, url: current.url, favicon: current.favicon || null }) }); + } else { + items.push({ label: "Add current page", disabled: true }); + } + const m = document.createElement("div"); + m.id = "bmMenu"; m.className = "ctxmenu"; + m.innerHTML = items.map((it, i) => it.sep + ? `<div class="sep"></div>` + : `<div class="mi${it.danger ? " danger" : ""}${it.disabled ? " off" : ""}" data-i="${i}">${it.label.replace(/</g, "&lt;")}</div>` + ).join(""); + document.body.appendChild(m); + const r = m.getBoundingClientRect(); + const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight; + m.style.left = Math.min(x, vw - r.width - 6) + "px"; + m.style.top = Math.min(y, vh - r.height - 6) + "px"; + m.querySelectorAll(".mi").forEach((el) => { + const it = items[Number(el.dataset.i)]; + if (it.disabled) return; + el.onclick = () => { closeBmMenu(); try { it.act(); } catch (e) { console.error(e); } }; + }); + setTimeout(() => { + const off = (ev) => { if (!m.contains(ev.target)) { closeBmMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } }; + const esc = (ev) => { if (ev.key === "Escape") { closeBmMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } }; + document.addEventListener("mousedown", off); + document.addEventListener("keydown", esc); + }, 0); + } + $("bookmarks").addEventListener("contextmenu", (e) => { + e.preventDefault(); + const bm = e.target.closest(".bm"); + openBmMenu(e.clientX, e.clientY, bm ? { kind: "item", index: Number(bm.dataset.i) } : { kind: "bar" }); + }); + function updateStar() { + const saved = !!current.url && bookmarks.some((b) => b.url === current.url); + const s = $("star"); s.textContent = saved ? "★" : "☆"; s.classList.toggle("on", saved); + s.title = saved ? "Remove from saved pages" : "Save this page"; + } + $("star").onclick = () => { + if (!current.url) return; + if (bookmarks.some((b) => b.url === current.url)) T.removeBookmark(current.url); + else T.addBookmark({ title: current.title || current.url, url: current.url, favicon: current.favicon || null }); + }; + T.getBookmarks().then((b) => { bookmarks = b || []; renderBookmarks(); }); + T.onBookmarks((b) => { bookmarks = b || []; renderBookmarks(); }); + + // ---- password-fill chip ---- + // Visible only when the vault is unlocked AND the current site has matching + // credentials. Click opens a floating picker of matches; clicking a match + // injects the fill into the active tab. + T.onPwAvailability && T.onPwAvailability((d) => { + const chip = $("pwchip"); if (!chip) return; + const n = d && d.count ? d.count : 0; + chip.hidden = n === 0; + $("pwchipCount").textContent = n > 1 ? String(n) : ""; + chip.title = n === 1 ? "Fill password for this site" : `${n} saved credentials for this site`; + }); + $("pwchip") && ($("pwchip").onclick = () => { + const r = $("pwchip").getBoundingClientRect(); + T.togglePwFill({ x: Math.round(r.right - 280), y: Math.round(r.bottom + 4) }); + }); + + // ---- update-available chip ---- + // Main sends "update-available" with { version, setupUrl, portableUrl, ... } + // when the releases manifest names a newer Theseus. Null clears the chip + // (up-to-date, or dismissed this session). + T.onUpdateAvailable && T.onUpdateAvailable((d) => { + const chip = $("upchip"); if (!chip) return; + if (!d) { chip.hidden = true; return; } + chip.hidden = false; + const url = d.setupUrl || d.portableUrl || ""; + const core = $("upDownload"); + // State machine: idle (nothing yet) -> downloading (silent fetch running, + // show percent) -> ready (chip becomes "Install & restart, one click") -> + // failed (fall back to explicit user-triggered download). + const state = d.downloadState || "idle"; + if (state === "downloading") { + const pct = d.downloadTotal ? Math.floor((d.downloadReceived / d.downloadTotal) * 100) : null; + core.textContent = pct != null ? `↓ ${pct}% — ${d.version}` : `↓ Downloading ${d.version}…`; + core.title = "Downloading update in the background"; + core.onclick = () => {}; + } else if (state === "ready") { + core.textContent = `✓ Install ${d.version} & restart`; + core.title = "One-click install: launches the installer silently and restarts Theseus"; + core.onclick = () => T.installUpdateNow && T.installUpdateNow(); + } else if (state === "failed") { + core.textContent = `↓ Update to ${d.version}`; + core.title = url ? `Retry download — ${url}` : "Retry download"; + core.onclick = () => { if (url) T.openUpdateDownload(url); }; + } else { + core.textContent = `↓ Update to ${d.version}`; + core.title = url ? `Download ${url}` : "Download the new version"; + core.onclick = () => { if (url) T.openUpdateDownload(url); }; + } + }); + $("upDismiss") && ($("upDismiss").onclick = () => T.dismissUpdate()); + + // ---- security badge + minimal registry indicator ---- + // Shield colour communicates connection state at a glance: + // neutral — home / resolving (no site OR pending) + // secure — BCDN chain-verified (kind:"ok") OR https:// clearnet + // insecure — nxdomain / resolver error / plain http:// clearnet + function setBadge(d) { + const b = $("secbadge"); + let cls = "secbadge", title = "Site information"; + const isHttps = typeof d?.url === "string" && d.url.startsWith("https:"); + const isHttp = typeof d?.url === "string" && d.url.startsWith("http:") && !isHttps; + if (!d || d.kind === "home") title = "Theseus"; + else if (d.kind === "ok") { cls = "secbadge secure"; title = "Secure · BCDN via " + (d.source || "on-chain") + " — click for details"; } + else if (d.kind === "web" && isHttps) { cls = "secbadge secure"; title = "Secure · ICANN (HTTPS) — click for details"; } + else if (d.kind === "web" && isHttp) { cls = "secbadge insecure"; title = "Not secure · plain HTTP — click for details"; } + else if (d.kind === "web") title = "Connection · ICANN — click for details"; + else if (d.kind === "resolving") title = "Resolving…"; + else if (d.kind === "nxdomain") { cls = "secbadge insecure"; title = "Not registered on BCDN"; } + else if (d.kind === "error") { cls = "secbadge insecure"; title = "Resolution error"; } + b.className = cls; b.title = title; + } + // Ariadne's Thread button: tint = registry serving the page, caret = the + // name exists on both registries. The menu itself is native (main.js + // registry-menu-popup) so it can't be clipped by the chrome view. + function setReg(d) { + const r = $("reg"); + if (!d || d.kind === "home") { r.hidden = true; r.className = "regbtn"; r.title = "Ariadne's Thread"; return; } + // Keep the last state during "resolving" so a switch reads as a toggle, + // not a disappear-and-reappear (user complaint 2026-08-02). + if (d.kind === "resolving") { r.hidden = false; return; } + r.hidden = false; + const isCand = !!d.tld && (d.kind === "ok" || d.kind === "web") && d.bcnrNativeTld === false; + const on = d.kind === "ok" ? "bcdn" : d.kind === "web" ? "icann" : ""; + r.className = "regbtn" + (on ? " " + on : "") + (isCand ? " cand" : ""); + r.title = on === "bcdn" ? "Serving from BCDN" + (isCand ? " · also exists on ICANN" : "") + " — click for options" + : on === "icann" ? "Serving from ICANN" + (isCand ? " · also exists on BCDN" : "") + " — click for options" + : "Ariadne's Thread — click for options"; + } + $("reg").onclick = () => { + const b = $("reg").getBoundingClientRect(); + if (T.registryMenuPopup) T.registryMenuPopup({ x: Math.round(b.left), y: Math.round(b.bottom + 2) }).catch(() => {}); + }; + + // ---- Tor ---- + let torShown = false; + $("tor").onclick = () => { T.toggleTor(); if (!torShown) { $("tordisc").hidden = false; torShown = true; syncHeight(); } }; + $("tordismiss").onclick = (e) => { e.preventDefault(); $("tordisc").hidden = true; syncHeight(); }; + + // ---- passive BCNR offer ---- + T.onBcnrOffer((d) => { + const bar = $("bcnrbar"); + if (d && d.host) { $("bcnrhost").textContent = d.host; $("bcnrreg").textContent = "BCDN"; bar.hidden = false; } + else bar.hidden = true; + syncHeight(); + }); + $("bcnropen").onclick = () => { $("bcnrbar").hidden = true; syncHeight(); T.switchToBcnr(); }; + $("bcnrdismiss").onclick = (e) => { e.preventDefault(); $("bcnrbar").hidden = true; syncHeight(); }; + T.onTor((d) => { + const b = $("tor"); b.className = "tor " + (d.state === "on" ? "on" : d.state === "connecting" ? "connecting" : ""); + b.textContent = d.state === "on" ? "🧅 Tor: On" : d.state === "connecting" ? "🧅 connecting…" : "🧅 Tor: Off"; + }); + + // Address-picker pick: the picker fires "address-pick" which routes to + // navigateTab, but the tabs event's focus-guard would leave the typed + // query in place if the URL input still had DOM focus. Force the full + // picked URL into the bar and drop focus so subsequent tabs events + // paint the loaded URL cleanly. Also arm a short-lived override that + // makes the next onTabs event overwrite the URL bar even if the input + // still shows focused — belt-and-braces for cases where blur() is + // async and the tabs event arrives first. + let overrideUrlBarUntil = 0; + T.onAddressPicked && T.onAddressPicked((url) => { + try { $("url").blur(); } catch (e) {} + $("url").value = String(url || ""); + overrideUrlBarUntil = Date.now() + 1500; + }); + + // ---- tabs ---- + $("zoomchip").onclick = () => T.zoomReset && T.zoomReset(); + T.onTabs((d) => { + $("back").disabled = !d.canBack; $("fwd").disabled = !d.canForward; + const zc = $("zoomchip"), z = Number(d.zoom) || 100; + zc.hidden = z === 100; zc.textContent = z + "%"; + // loading indicator: progress bar + reload↔stop button + $("loadbar").classList.toggle("on", !!d.loading); + // Any tabs event that fires while no menu is open is a good moment to + // reset the chrome view height to the natural body-scrollHeight — this + // eliminates the "chrome stays huge after a menu action" gap that could + // otherwise linger if a menu-close path missed syncHeight(). + if (!document.querySelector(".ctxmenu, .grouppop")) syncHeight(); + const rb = $("reload"); + if (d.loading) { rb.innerHTML = STOP_SVG; rb.title = "Stop"; rb.onclick = () => T.stop(); } + else { rb.innerHTML = RELOAD_SVG; rb.title = "Reload (Shift-click: hard reload)"; rb.onclick = (ev) => T.reload(ev.shiftKey); } + if (document.activeElement !== $("url") || Date.now() < overrideUrlBarUntil) $("url").value = d.url || ""; + current.url = d.url || ""; + const active = d.tabs.find((t) => t.active); + current.title = active ? active.title : ""; + current.favicon = active ? (active.favicon || null) : null; + updateStar(); + const box = $("tabs"); + // Render tabs with group awareness. For every group, emit a chip BEFORE + // the group's first tab. If the group is collapsed, tabs inside it are + // hidden and the chip shows the count as "[● 3]"; expanded chips show + // just the color dot. + const collapsed = new Set(d.collapsedGroups || []); + const groupsSeen = new Set(); + const groupsCount = {}; + for (const t of d.tabs) if (t.group) groupsCount[t.group] = (groupsCount[t.group] || 0) + 1; + box.innerHTML = d.tabs.map((t) => { + let out = ""; + // Chip appears once, at the FIRST tab of the group in render order. + if (t.group && !groupsSeen.has(t.group)) { + groupsSeen.add(t.group); + const isCollapsed = collapsed.has(t.group); + out += `<button class="gchip g-${t.group}${isCollapsed ? " collapsed" : ""}" data-group="${t.group}" title="${isCollapsed ? "Expand" : "Collapse"} ${t.group} group"><span class="gcdot g-${t.group}"></span>${isCollapsed ? `<span class="gccnt">${groupsCount[t.group]}</span>` : ""}</button>`; + } + // Skip rendering the tab itself if its group is collapsed. + if (t.group && collapsed.has(t.group)) return out; + // Loading spinner takes the icon slot while a page is loading, then + // hands it back to the favicon once page-favicon-updated fires. + const icon = t.loading + ? '<span class="spin"></span>' + : (t.favicon ? `<img class="fav" src="${String(t.favicon).replace(/"/g,"&quot;")}" onerror="this.remove()">` : ''); + const mute = t.muted ? `<span class="mute" title="Muted">🔇</span>` : ""; + const tt = ((t.title || "New Tab") + (t.url ? "\n" + t.url : "")).replace(/"/g, "&quot;"); + out += `<div class="tab ${t.active ? "active" : ""}${t.group ? " grp g-" + t.group : ""}" data-id="${t.id}" draggable="true" title="${tt}">${icon}<span class="t">${(t.title||"New Tab").replace(/</g,"&lt;")}</span>${mute}<span class="x" data-close="${t.id}">✕</span></div>`; + return out; + }).join("") + + `<span class="newtab" id="newtab">+</span>`; + // dragId is shared between tab-reorder handlers and group-chip drop + // handlers; declared here so both scopes see the same identity. + let dragId = null; + // Group chip clicks: + // collapsed chip → show a vertical popover listing the group's tabs + // expanded chip → toggle to collapsed + // Also acts as a drop target: drag any tab onto a chip to assign it + // to that group. + box.querySelectorAll(".gchip").forEach((chip) => { + chip.onclick = (ev) => { + ev.stopPropagation(); + const color = chip.dataset.group; + if (chip.classList.contains("collapsed")) { + openGroupPopover(chip, color, d); + } else { + T.tabGroupToggle && T.tabGroupToggle(color); + } + }; + chip.addEventListener("dragover", (ev) => { + if (dragId == null) return; + ev.preventDefault(); ev.dataTransfer.dropEffect = "move"; + chip.classList.add("droptarget"); + }); + chip.addEventListener("dragleave", () => chip.classList.remove("droptarget")); + chip.addEventListener("drop", (ev) => { + ev.preventDefault(); + chip.classList.remove("droptarget"); + if (dragId != null) T.tabGroup(dragId, chip.dataset.group); + dragId = null; + }); + }); + box.querySelectorAll(".tab").forEach((el) => el.onclick = (e) => { + if (e.target.dataset.close) T.closeTab(Number(e.target.dataset.close)); + else T.switchTab(Number(el.dataset.id)); + }); + // Right-click a tab → native OS menu (Reload / Duplicate / Group / Add + // to Bookmarks / Mute / Close), popped from main.js. Doing this in the + // DOM used to force the chrome view taller than its natural height so + // the menu was visible below the tabstrip — that opened a visible gap + // between the toolbar and the tab body. A native Menu.popup sits above + // every WebContentsView so nothing has to grow. + box.querySelectorAll(".tab").forEach((el) => el.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const id = Number(el.dataset.id); + if (T.tabContextMenuPopup) T.tabContextMenuPopup(id, { x: e.clientX, y: e.clientY }); + })); + // Drag-reorder — HTML5 drag events. Drop-side chosen by whether the pointer + // is on the left or right half of the target tab (matches Chrome UX). + // dragId already declared above so group-chip drop targets share it. + box.querySelectorAll(".tab").forEach((row) => { + row.addEventListener("dragstart", (e) => { + dragId = Number(row.dataset.id); + try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(dragId)); } catch {} + row.classList.add("dragging"); + }); + row.addEventListener("dragend", () => { + row.classList.remove("dragging"); + box.querySelectorAll(".tab").forEach((r) => r.classList.remove("dropbefore", "dropafter")); + dragId = null; + }); + row.addEventListener("dragover", (e) => { + if (dragId == null || Number(row.dataset.id) === dragId) return; + e.preventDefault(); e.dataTransfer.dropEffect = "move"; + const r = row.getBoundingClientRect(); + const before = (e.clientX - r.left) < r.width / 2; + row.classList.toggle("dropbefore", before); + row.classList.toggle("dropafter", !before); + }); + row.addEventListener("dragleave", () => row.classList.remove("dropbefore", "dropafter")); + row.addEventListener("drop", (e) => { + e.preventDefault(); + const targetId = Number(row.dataset.id); + if (dragId == null || dragId === targetId) return; + const r = row.getBoundingClientRect(); + const before = (e.clientX - r.left) < r.width / 2; + T.moveTab(dragId, targetId, before ? "before" : "after"); + }); + }); + $("newtab").onclick = () => T.newTab(); + }); + + // ---- Tab context menu (right-click a tab) ---- + const TAB_GROUP_COLORS = [ + { id: "red", label: "Red" }, { id: "orange", label: "Orange" }, + { id: "yellow", label: "Yellow" }, { id: "green", label: "Green" }, + { id: "cyan", label: "Cyan" }, { id: "blue", label: "Blue" }, + { id: "purple", label: "Purple" }, + ]; + function closeAllMenus() { + document.querySelectorAll(".ctxmenu, .grouppop").forEach((m) => m.remove()); + // Menus were rendered outside the chrome's normal flow (position:fixed), + // so closing them means the natural chrome height wins again. + syncHeight(); + } + // Vertical dropdown of a collapsed group's tabs. Click a row → switch to + // that tab (and expand the group so the newly-active tab is visible in + // the strip). The X on a row closes just that tab. Popover closes on + // outside click via the shared document listener at the bottom. + function openGroupPopover(chip, color, d) { + closeAllMenus(); + const rect = chip.getBoundingClientRect(); + const pop = document.createElement("div"); + pop.className = "grouppop"; + pop.dataset.group = color; + const groupTabs = d.tabs.filter((t) => t.group === color); + const header = document.createElement("div"); + header.className = "gph"; + header.innerHTML = '<span class="gcdot g-' + color + '"></span><span>' + color + ' group · ' + groupTabs.length + ' tab' + (groupTabs.length === 1 ? '' : 's') + '</span>'; + pop.appendChild(header); + for (const t of groupTabs) { + const row = document.createElement("div"); + row.className = "gpit" + (t.active ? " active" : ""); + const fav = t.favicon + ? '<img class="gfav" src="' + String(t.favicon).replace(/"/g, "&quot;") + '" onerror="this.remove()">' + : '<span class="gfav" style="display:inline-block"></span>'; + row.innerHTML = fav + '<span class="gt">' + String(t.title || "New Tab").replace(/</g, "&lt;") + '</span><span class="gx" data-close="' + t.id + '" title="Close tab">✕</span>'; + row.addEventListener("click", (ev) => { + if (ev.target.dataset.close) { + ev.stopPropagation(); + T.closeTab(Number(ev.target.dataset.close)); + // Rebuild popover after close (next onTabs update will refresh anyway). + return; + } + // Switch to this tab and expand the group so it's visible in the strip. + T.switchTab(t.id); + if (T.tabGroupToggle) T.tabGroupToggle(color); // was collapsed, now expand + closeAllMenus(); + }); + pop.appendChild(row); + } + pop.style.left = Math.round(rect.left) + "px"; + pop.style.top = Math.round(rect.bottom + 4) + "px"; + document.body.appendChild(pop); + const r = pop.getBoundingClientRect(); + if (r.right > window.innerWidth) pop.style.left = Math.max(4, window.innerWidth - r.width - 4) + "px"; + growChromeForMenu(); + } + function openTabContextMenu(x, y, t) { + closeAllMenus(); + const m = document.createElement("div"); + m.className = "ctxmenu"; + m.style.left = x + "px"; m.style.top = y + "px"; + // Build items + const item = (label, cls, fn) => { + const el = document.createElement("div"); + el.className = "mi" + (cls ? " " + cls : ""); + el.innerHTML = label; + el.onclick = (ev) => { ev.stopPropagation(); if (fn) fn(); closeAllMenus(); }; + return el; + }; + m.appendChild(item("Reload", "", () => T.tabReload(t.id))); + m.appendChild(item("Duplicate", "", () => T.tabDuplicate(t.id))); + // Group submenu — hover/click opens a second menu next to the first. + const gp = item("Group " + (t.group ? '<span class="swatch g-' + t.group + '"></span>' : ""), "sub", null); + gp.onclick = (ev) => { + ev.stopPropagation(); + const rect = gp.getBoundingClientRect(); + openGroupSubmenu(rect.right + 2, rect.top, t); + }; + m.appendChild(gp); + // Add to Bookmarks (disabled if the tab has no url — e.g. Home). + const bmDisabled = !t.url; + m.appendChild(item("Add to Bookmarks", bmDisabled ? "off" : "", bmDisabled ? null : () => T.tabBookmark(t.id))); + m.appendChild(item(t.muted ? "Unmute" : "Mute", "", () => T.tabMute(t.id))); + const sep = document.createElement("div"); sep.className = "sep"; m.appendChild(sep); + m.appendChild(item("Close", "danger", () => T.closeTab(t.id))); + document.body.appendChild(m); + // Clamp horizontally to the viewport. VERTICALLY: the chrome view has a + // fixed height (main.js CHROME_H), which clips anything below that line. + // Grow the chrome view so the menu is fully visible — restored on close. + const r = m.getBoundingClientRect(); + if (r.right > window.innerWidth) m.style.left = Math.max(4, window.innerWidth - r.width - 4) + "px"; + growChromeForMenu(); + } + function growChromeForMenu() { + // Wait a frame so all newly-added overlays contribute to the bounding box. + requestAnimationFrame(() => { + let needed = document.body.scrollHeight; + for (const m of document.querySelectorAll(".ctxmenu, .grouppop")) { + const r = m.getBoundingClientRect(); + needed = Math.max(needed, Math.ceil(r.bottom) + 4); + } + T.setChromeHeight(needed); + }); + } + function openGroupSubmenu(x, y, t) { + document.querySelectorAll(".ctxmenu.sub2").forEach((m) => m.remove()); + const m = document.createElement("div"); + m.className = "ctxmenu sub2"; + m.style.left = x + "px"; m.style.top = y + "px"; + const row = (html, fn) => { + const el = document.createElement("div"); + el.className = "mi"; + el.innerHTML = html; + el.onclick = (ev) => { ev.stopPropagation(); fn(); closeAllMenus(); }; + m.appendChild(el); + }; + row('<span class="swatch" style="background:transparent;border:1px dashed var(--line2)"></span>None' + (t.group ? "" : " ✓"), () => T.tabGroup(t.id, null)); + for (const c of TAB_GROUP_COLORS) { + row('<span class="swatch g-' + c.id + '"></span>' + c.label + (t.group === c.id ? " ✓" : ""), () => T.tabGroup(t.id, c.id)); + } + document.body.appendChild(m); + const r = m.getBoundingClientRect(); + if (r.right > window.innerWidth) m.style.left = Math.max(4, x - r.width - 6) + "px"; + growChromeForMenu(); + } + document.addEventListener("click", (e) => { + // Close any open menus on outside click. + if (!e.target.closest(".ctxmenu, .grouppop, .gchip")) closeAllMenus(); + }); + document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeAllMenus(); }); + + // ---- provenance ---- + T.onNav((d) => { + setBadge(d); setReg(d); + if (document.activeElement !== $("url")) { + if (d.kind === "home") $("url").value = ""; + else if (d.host && !$("url").value) $("url").value = d.host; + } + // Favorites bar shows only on the new-tab / home page (hides once a page loads). + // Bookmarks bar was originally home-only; users adding a bookmark from + // a real tab saw nothing visible. Keep it visible on every tab so the + // add / remove has an immediate on-screen effect. + $("bookmarks").hidden = false; + syncHeight(); + }); + + syncHeight(); + // Toolbar sizing: only urlBarWidthPx now matters. The URL bar and the + // search bar share one flex budget in .urlsearch — bigger URL bar means + // smaller search bar and vice versa, without moving any of the fixed + // items on either side. Old urlBarSize / searchBoxSize / searchBoxWidthPx + // settings are ignored (kept in the settings file for backward compat + // so an older version's chrome.html can still pick them up). + function applyBarSizes(s) { + if (!s) return; + const bar = $("bar"); + const uw = Number(s.urlBarWidthPx) || 0; + if (uw > 0) bar.style.setProperty("--urlbar-w", uw + "px"); + else bar.style.removeProperty("--urlbar-w"); + // The URL/search ratio change doesn't squeeze the extension dock any + // more (they live in different flex parents), but the responsive-hide + // rules for narrow bars still depend on updateExtCollapse's math, so + // re-check overflow after the layout settles. + if (typeof updateExtCollapse === "function") { + requestAnimationFrame(updateExtCollapse); + setTimeout(updateExtCollapse, 30); + setTimeout(updateExtCollapse, 120); + } + syncHeight(); + } + T.getSettings && T.getSettings().then(applyBarSizes); + T.onSettingsUpdate && T.onSettingsUpdate(applyBarSizes); + + // ---- Ctrl+F find-in-page bar ---- + // Chrome renderer owns the UI; main proxies findInPage / stopFindInPage + // and forwards Chromium's found-in-page events back so we can render + // "N of M". Enter jumps to next, Shift+Enter to previous, Esc closes. + const findbar = $("findbar"); + const findInput = $("findInput"); + const findCount = $("findCount"); + let findQuery = ""; + function openFind() { + findbar.hidden = false; + syncHeight(); + setTimeout(() => { findInput.focus(); findInput.select(); }, 0); + // Re-highlight the current query if there was one. + if (findInput.value.trim()) T.findInPage && T.findInPage(findInput.value.trim(), { findNext: true }); + } + function closeFind() { + findbar.hidden = true; + findQuery = ""; + findCount.textContent = "–"; + findInput.classList.remove("nomatch"); + T.findStop && T.findStop(); + syncHeight(); + } + function stepFind(forward) { + const q = findInput.value.trim(); + if (!q) return; + findQuery = q; + T.findInPage && T.findInPage(q, { forward, findNext: true }); + } + T.onFindOpen && T.onFindOpen(openFind); + T.onFindResult && T.onFindResult((r) => { + if (r.matches === 0) { + findCount.textContent = "0"; + findInput.classList.add("nomatch"); + } else { + findCount.textContent = r.activeMatchOrdinal + " / " + r.matches; + findInput.classList.remove("nomatch"); + } + }); + findInput.addEventListener("input", () => { + const q = findInput.value.trim(); + if (!q) { T.findStop && T.findStop(); findCount.textContent = "–"; findInput.classList.remove("nomatch"); return; } + // findNext: false → new query starts a fresh search from the top. + T.findInPage && T.findInPage(q, { findNext: q === findQuery, forward: true }); + findQuery = q; + }); + findInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); stepFind(!e.shiftKey); } + else if (e.key === "Escape") { e.preventDefault(); closeFind(); } + }); + $("findPrev").onclick = () => stepFind(false); + $("findNext").onclick = () => stepFind(true); + $("findClose").onclick = closeFind; + + // Single drag handle between the address bar and the search bar (Firefox + // model): dragging right grows the URL bar and shrinks the search bar, + // dragging left inverts. Zero-sum — nothing else in the toolbar moves. + // The URL bar's width is what we control directly; the search bar just + // fills whatever's left of the .urlsearch flex budget. Persisted as + // urlBarWidthPx on release. Bounds: + // min = URL bar's own min-width (matches the 220px CSS floor) + // max = urlsearch total width − handle − gap − minSearch(140) + // so the drag can never squeeze the search bar past its CSS floor either. + const barEl2 = $("bar"); + const handleEl = $("bardrag"); + const urlWrapEl = barEl2.querySelector(".urlwrap"); + const urlsearchEl = $("urlsearch"); + // Absolute minimums track the CSS min-widths so the drag can't push either + // bar below the size where its content stops fitting. On top of that we + // enforce a 20 % share floor for both bars: the ratio can run from 80/20 + // to 20/80 (URL bar / search bar). Combined with the CSS min-widths, the + // tighter of the two rules wins at any given container width. + const MIN_URL_PX = 220, MIN_SEARCH_PX = 180, HANDLE_W = 6, GAP = 6; + const MIN_SHARE = 0.20; + let dragging = false, startX = 0, startW = 0, lastW = 0; + handleEl.addEventListener("pointerdown", (e) => { + if (e.button !== 0) return; + dragging = true; + startX = e.clientX; + startW = urlWrapEl.getBoundingClientRect().width; + handleEl.setPointerCapture(e.pointerId); + handleEl.classList.add("dragging"); + e.preventDefault(); + }); + handleEl.addEventListener("pointermove", (e) => { + if (!dragging) return; + const total = urlsearchEl.getBoundingClientRect().width; + const minUrl = Math.max(MIN_URL_PX, Math.round(total * MIN_SHARE)); + const maxUrl = Math.min( + total - HANDLE_W - GAP * 2 - Math.max(MIN_SEARCH_PX, Math.round(total * MIN_SHARE)), + Math.round(total * (1 - MIN_SHARE)) + ); + const clampedMax = Math.max(minUrl, maxUrl); + const w = Math.round(Math.max(minUrl, Math.min(clampedMax, startW + (e.clientX - startX)))); + lastW = w; + barEl2.style.setProperty("--urlbar-w", w + "px"); + if (typeof updateExtCollapse === "function") updateExtCollapse(); + }); + const stopDrag = (e) => { + if (!dragging) return; + dragging = false; + handleEl.classList.remove("dragging"); + try { handleEl.releasePointerCapture(e.pointerId); } catch {} + if (lastW > 0 && T.setSetting) T.setSetting("urlBarWidthPx", lastW); + }; + handleEl.addEventListener("pointerup", stopDrag); + handleEl.addEventListener("pointercancel", stopDrag); + // Self-heal: some menu-close paths (drag interruptions, focus flips) can + // leave the chrome view taller than the natural body-scrollHeight. Every + // 750 ms with no menu open, re-sync — main.js only re-layouts when the + // value actually changes, so this is a cheap no-op in the steady state. + setInterval(() => { if (!document.querySelector(".ctxmenu, .grouppop")) syncHeight(); }, 750); +</script> +</body> +</html> diff --git a/collision-preload.js b/collision-preload.js new file mode 100644 index 00000000..e0c97a84 --- /dev/null +++ b/collision-preload.js @@ -0,0 +1,7 @@ +// Minimal, sandboxed IPC bridge for the "Open with…" collision prompt window. +// Only exposes the exact call the page needs: send the user's choice back to +// the main process (which awaits it via ipcMain.once). +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("collisionApi", { + choose(host, payload) { ipcRenderer.send(`collision-choose:${host}`, payload); }, +}); diff --git a/collision.html b/collision.html new file mode 100644 index 00000000..c6d9051f --- /dev/null +++ b/collision.html @@ -0,0 +1,170 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8" /> + <title>Open with…</title> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <style> + :root { + color-scheme: dark light; + --bg: #0f1116; + --fg: #e5e7eb; + --muted: #9ca3af; + --border: #2a2f3a; + --panel: #131722; + --panel-hover: #181e2b; + --bcdn: #d6ff3d; /* acid glow green (matches address-bar toggle) */ + --icann: #4c9eff; /* blue (matches address-bar toggle) */ + } + @media (prefers-color-scheme: light) { + :root { --bg:#f7f7f8; --fg:#111827; --muted:#5b6472; --border:#e5e7eb; --panel:#ffffff; --panel-hover:#f0f2f5; --bcdn:#7fa900; --icann:#1f6fd4; } + } + * { box-sizing: border-box; } + html, body { margin: 0; min-height: 100%; } + body { + background: var(--bg); color: var(--fg); + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 40px 24px; + } + main { width: 100%; max-width: 720px; } + header { margin-bottom: 28px; } + h1 { margin: 0 0 8px; font-size: 22px; font-weight: 600; letter-spacing: -.01em; } + .sub { color: var(--muted); font-size: 14px; } + .host { color: var(--fg); font-weight: 600; } + + .choices { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 28px; } + @media (max-width: 620px) { .choices { grid-template-columns: 1fr; } } + + .choice { + background: var(--panel); border: 2px solid var(--border); border-radius: 12px; + padding: 22px; color: var(--fg); text-align: left; cursor: pointer; + transition: border-color .12s, background .12s, transform .06s; + display: flex; flex-direction: column; gap: 8px; + } + .choice:hover { background: var(--panel-hover); } + .choice:active { transform: scale(.99); } + .choice.sel.bcdn { border-color: var(--bcdn); box-shadow: 0 0 0 1px var(--bcdn), 0 0 22px -8px var(--bcdn); } + .choice.sel.icann { border-color: var(--icann); box-shadow: 0 0 0 1px var(--icann), 0 0 22px -10px var(--icann); } + + .choice .head { display: flex; align-items: center; gap: 12px; } + .choice .radio { + flex: none; width: 18px; height: 18px; border-radius: 50%; + border: 2px solid var(--border); position: relative; + } + .choice.sel.bcdn .radio { border-color: var(--bcdn); background: radial-gradient(circle, var(--bcdn) 40%, transparent 42%); } + .choice.sel.icann .radio { border-color: var(--icann); background: radial-gradient(circle, var(--icann) 40%, transparent 42%); } + .choice .icon { font-size: 22px; line-height: 1; } + .choice .title { font-weight: 600; font-size: 16px; } + .choice.bcdn .title { color: var(--bcdn); } + .choice.icann .title { color: var(--icann); } + .choice .desc { font-size: 13.5px; color: var(--muted); } + + fieldset { border: 0; padding: 0; margin: 0 0 28px; display: flex; flex-direction: column; gap: 10px; } + fieldset legend { + color: var(--muted); font-size: 12px; padding: 0; margin-bottom: 8px; + text-transform: uppercase; letter-spacing: .06em; + } + fieldset .row { display: flex; align-items: center; gap: 10px; font-size: 14px; padding: 3px 0; } + fieldset .row input { accent-color: var(--bcdn); width: 16px; height: 16px; } + fieldset .row label { color: var(--fg); cursor: pointer; } + + .actions { display: flex; justify-content: flex-end; gap: 12px; } + button.act { + border: 1px solid var(--border); background: var(--panel); color: var(--fg); + padding: 11px 22px; border-radius: 8px; font-size: 14px; cursor: pointer; + transition: background .1s, filter .1s; + } + button.act:hover { background: var(--panel-hover); } + button.act.primary { background: var(--bcdn); color: #111; border-color: var(--bcdn); font-weight: 600; } + button.act.primary:hover { filter: brightness(1.06); } + kbd { border: 1px solid var(--border); border-radius: 3px; padding: 0 5px; font: 11px monospace; color: var(--muted); margin-left: 6px; } + </style> +</head> +<body> + <main> + <header> + <h1><span class="host" id="host"></span> exists in two registries</h1> + <div class="sub">Choose which one to open. You can change this later in Settings.</div> + </header> + + <div class="choices"> + <div class="choice bcdn sel" data-choice="bcnr" tabindex="0"> + <div class="head"> + <span class="radio"></span><span class="icon">⛓</span> + <span class="title">BCNR / BCDN</span> + </div> + <div class="desc">On-chain registration. Owned by the holder of a digital name certificate. The registry served by the blockchain root.</div> + </div> + <div class="choice icann" data-choice="icann" tabindex="0"> + <div class="head"> + <span class="radio"></span><span class="icon">🌐</span> + <span class="title">ICANN / IANA</span> + </div> + <div class="desc">DNS service coordinated by the ICANN. The registry served by the incumbent root.</div> + </div> + </div> + + <fieldset> + <legend>Remember this choice</legend> + <div class="row"><input type="radio" name="remember" id="rem-no" value="no" checked><label for="rem-no">Ask again next time</label></div> + <div class="row"><input type="radio" name="remember" id="rem-name" value="name"><label for="rem-name">Always use for <span class="host" id="host2"></span></label></div> + <div class="row"><input type="radio" name="remember" id="rem-tld" value="tld"><label for="rem-tld">Always use for all <span id="tld"></span> names</label></div> + </fieldset> + + <div class="actions"> + <button class="act" id="cancel">Cancel<kbd>Esc</kbd></button> + <button class="act primary" id="confirm">Open<kbd>Enter</kbd></button> + </div> + </main> + + <script> + const qs = new URLSearchParams(location.search); + const host = qs.get("host") || ""; + const tld = qs.get("tld") || ""; + const resturl = qs.get("resturl") || "/"; + document.getElementById("host").textContent = host; + document.getElementById("host2").textContent = host; + document.getElementById("tld").textContent = "." + tld; + document.title = "Open " + host + " with…"; + + let choice = "bcnr"; + const bcdnEl = document.querySelector('.choice[data-choice="bcnr"]'); + const icannEl = document.querySelector('.choice[data-choice="icann"]'); + function select(c) { + choice = c; + bcdnEl.classList.toggle("sel", c === "bcnr"); + icannEl.classList.toggle("sel", c === "icann"); + // primary button colour follows the selected choice + const p = document.getElementById("confirm"); + p.style.background = c === "bcnr" + ? "var(--bcdn)" + : "var(--icann)"; + p.style.borderColor = p.style.background; + p.style.color = c === "bcnr" ? "#111" : "#fff"; + } + select("bcnr"); // initial paint + for (const el of [bcdnEl, icannEl]) { + el.addEventListener("click", () => select(el.dataset.choice)); + el.addEventListener("keydown", (e) => { if (e.key === " " || e.key === "Enter") { e.preventDefault(); select(el.dataset.choice); } }); + } + function remember() { return document.querySelector('input[name="remember"]:checked')?.value || "no"; } + function confirm() { + const q = new URLSearchParams({ host, tld, choice, remember: remember(), resturl }); + // Navigate to the special bns:// endpoint that main.js's serveBns intercepts: + // it applies the remember choice and redirects to the real target. + location.href = "bns://collision-choose/?" + q.toString(); + } + function cancel() { + if (history.length > 1) history.back(); + else location.href = "about:blank"; + } + document.getElementById("confirm").addEventListener("click", confirm); + document.getElementById("cancel").addEventListener("click", cancel); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") cancel(); + if (e.key === "Enter" && e.target.tagName !== "BUTTON") confirm(); + }); + </script> +</body> +</html> diff --git a/dev/README.md b/dev/README.md new file mode 100644 index 00000000..69b19f74 --- /dev/null +++ b/dev/README.md @@ -0,0 +1,44 @@ +# Theseus dev/test harness + +Two ways to drive Theseus's resolution + content path headlessly, for testing +and debugging without clicking through the GUI. Both use the **real** resolver +(`Argus/src/lib/resolver-web.js`) and gateway path the shipped app uses. + +## `selftest.js` — full render (Electron) + +Runs the actual `serveBns` protocol handler (imported from `main.js`) in a +hidden offscreen Electron window, loads a `bns://` name, lets its JavaScript +execute, then reports what really rendered and writes a screenshot. + +```bash +npx electron dev/selftest.js hello.bch +npx electron dev/selftest.js coinspectrum.deviant.bch # JS-driven Sia site +THESEUS_SETTLE=8000 npx electron dev/selftest.js <name> # wait longer for data +``` + +Output: a JSON report (title, badge, stylesheet/script counts, local vs external +broken-image counts, visible-text length, failed loads, `verdict`) plus +`dev-out/render.png` and `dev-out/render.html`. Exit 0 = PASS. The verdict counts +only the site's own `bns://` assets — external CDN images are informational. + +This is the faithful version of manual tests #2 (Sia-via-gateway rendering) and +#3 (multi-TLD badge). `main.js` exports its handler and skips auto-launch when +`THESEUS_NO_AUTOSTART=1`, which the harness sets. + +## `probe-site.mjs` — content path only (Node, no Electron) + +Fast check with no display or Electron: resolves a name, fetches the index +through the gateway exactly as `serveBns` does (with the `<base>` strip), and +fetches each **static** same-origin asset, reporting status/content-type. + +```bash +node dev/probe-site.mjs coinspectrum.deviant.bch +``` + +Limitation: static-HTML only. It cannot see assets a page builds at runtime in +JavaScript — use `selftest.js` for JS-driven sites. + +## `dev-out/` + +Generated render artifacts (screenshot + HTML dump). Safe to delete; regenerated +on each `selftest.js` run. diff --git a/dev/bcnr-selftest.js b/dev/bcnr-selftest.js new file mode 100644 index 00000000..b11a9c74 --- /dev/null +++ b/dev/bcnr-selftest.js @@ -0,0 +1,68 @@ +// bcnr-selftest.js — end-to-end proof that `window.bcnr` from +// bcnr-preload.js reaches the main-process handlers over IPC and returns +// what the resolver sees. Mirrors dev/selftest.js style: sets +// THESEUS_NO_AUTOSTART=1, imports main.js, then wires the minimum bits +// (session preload + resolver warm-up) itself so the harness stays fast +// and hermetic. +// +// Usage: npx electron dev/bcnr-selftest.js <name> +// ^ optional; defaults to silentmode.bch +process.env.THESEUS_NO_AUTOSTART = "1"; +const path = require("path"); +const { app, BrowserWindow, session } = require("electron"); +// main.js exports resolveHost etc. for tests; importing it also registers +// the ipcMain.handle("bcnr:...") handlers at module load. +require("../main.js"); + +app.disableHardwareAcceleration(); +app.commandLine.appendSwitch("disable-gpu"); +app.commandLine.appendSwitch("no-sandbox"); + +const name = (process.argv[2] || "silentmode.bch").toLowerCase(); +const watchdog = setTimeout(() => { console.log("WATCHDOG"); try { app.exit(3); } catch {} }, 60000); + +app.whenReady().then(async () => { + // Install the same session-wide preload the shipped app installs. + session.defaultSession.setPreloads([path.join(__dirname, "..", "bcnr-preload.js")]); + + // No explicit warm-up: the first resolveName() call flows into + // resolveHost() which lazily runs ensureIndex() itself. Cold start can + // take a while (electrum handshake + full walk); the watchdog covers it. + const win = new BrowserWindow({ width: 800, height: 600, show: false, webPreferences: { offscreen: true } }); + const wc = win.webContents; + await wc.loadURL("about:blank"); + + const report = { name }; + try { + report.hasBridge = await wc.executeJavaScript("typeof window.bcnr === 'object'"); + report.methods = await wc.executeJavaScript("Object.keys(window.bcnr || {}).sort()"); + report.getBcnrTlds = await wc.executeJavaScript("window.bcnr.getBcnrTlds()"); + report.isRegistered = await wc.executeJavaScript(`window.bcnr.isRegistered(${JSON.stringify(name)})`); + report.getRecordVersion = await wc.executeJavaScript(`window.bcnr.getRecordVersion(${JSON.stringify(name)})`); + report.resolveName = await wc.executeJavaScript(`window.bcnr.resolveName(${JSON.stringify(name)})`); + // B.2b — the caller's eTLD+1 origin as Theseus sees it. From about:blank + // this must be "about:blank"; a data: URL would be null; a real dApp + // page would be its registrable domain. Full origin logic is unit-tested + // in dev/origin-selftest.mjs; this proves the IPC wiring uses it. + report.origin_aboutBlank = await wc.executeJavaScript("window.bcnr.getOrigin()"); + } catch (e) { report.error = e.message; } + + console.log("\n===== BCNR SELFTEST ====="); + console.log(JSON.stringify(report, null, 2)); + + // Verdict: bridge present, four methods, TLD list non-empty, and either + // the name resolved OR it plausibly doesn't exist (isRegistered === false + // + resolveName === null is a valid pass — resolver worked, name just + // isn't on chain). + const bridgeOk = report.hasBridge === true + && Array.isArray(report.methods) + && ["getBcnrTlds", "getOrigin", "getRecordVersion", "isRegistered", "resolveName"].every((m) => report.methods.includes(m)); + const tldsOk = Array.isArray(report.getBcnrTlds) && report.getBcnrTlds.length > 0; + const shapeOk = (report.isRegistered === true && report.resolveName && report.resolveName.name === name) + || (report.isRegistered === false && report.resolveName === null); + const originOk = report.origin_aboutBlank === "about:blank"; + report.verdict = bridgeOk && tldsOk && shapeOk && originOk ? "PASS" : "FAIL"; + console.log("verdict:", report.verdict); + clearTimeout(watchdog); + try { app.exit(report.verdict === "PASS" ? 0 : 1); } catch {} +}); diff --git a/dev/list-tlds.mjs b/dev/list-tlds.mjs new file mode 100644 index 00000000..734225b6 --- /dev/null +++ b/dev/list-tlds.mjs @@ -0,0 +1,22 @@ +// List every registered BNS name and group by TLD, using our own indexer. +import WebSocket from "ws"; +import { buildIndex, CHIPNET_ELECTRUM } from "../../Argus/src/lib/resolver-web.js"; + +const OURS = CHIPNET_ELECTRUM[0]; +const idx = await buildIndex({ WebSocket, electrum: [OURS], directIP: true }); + +const byTld = new Map(); +for (const [name, entry] of idx) { + const tld = name.split(".").pop(); + if (!byTld.has(tld)) byTld.set(tld, []); + byTld.get(tld).push({ name, records: Object.keys(entry.records) }); +} + +console.log(`total names: ${idx.size}`); +console.log(`distinct TLDs: ${byTld.size}\n`); +for (const [tld, entries] of [...byTld.entries()].sort((a, b) => b[1].length - a[1].length)) { + console.log(`.${tld} (${entries.length})`); + for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) { + console.log(` ${e.name.padEnd(28)} records: ${e.records.join(", ") || "—"}`); + } +} diff --git a/dev/origin-selftest.mjs b/dev/origin-selftest.mjs new file mode 100644 index 00000000..42d04e43 --- /dev/null +++ b/dev/origin-selftest.mjs @@ -0,0 +1,72 @@ +// origin-selftest.mjs — verify the pure origin helper covers every case that +// window.bcnr permissions (B.3) and the password-autofill eTLD+1 upgrade +// (main.js:1257 stub) will lean on. No Electron needed. +// +// Usage: node dev/origin-selftest.mjs +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +const { originOf, bnsEtldPlusOne } = require("../bcnr-origin.js"); + +const BCNR = ["bch", "wallet"]; // simulate the on-chain TLD list after warm-up + +const cases = [ + // ICANN — same permission origin for all subdomains of one registrable domain + ["https://checkout.merchant.com/pay", "merchant.com"], + ["https://blog.merchant.com/x", "merchant.com"], + ["https://merchant.com/", "merchant.com"], + // Multi-part public suffixes — the reason we ship a real PSL, not a regex + ["https://alice.co.uk/", "alice.co.uk"], + ["https://pages.user.github.io/repo", "user.github.io"], + // Different registrable domain → different bucket, no cross-grant + ["https://evil.com/", "evil.com"], + // BNS — root name is the identity; subdomains inherit it + ["bns://silentmode.bch/", "silentmode.bch"], + ["bns://pay.silentmode.bch/", "silentmode.bch"], + ["bns://mail.silentmode.bch/x/y", "silentmode.bch"], + ["bns://foo.wallet/", "foo.wallet"], + ["bns://bar.foo.wallet/", "foo.wallet"], + // BNS with an unknown-to-us TLD — safest is to key the full host so a + // future TLD unlock doesn't silently expand old permissions. + ["bns://foo.privateTld/", "foo.privatetld"], + // Dev origins — scheme+host+port matters, permissions don't cross ports + ["http://localhost:3000/", "http://localhost:3000"], + ["http://localhost:3001/", "http://localhost:3001"], + ["http://127.0.0.1:8080/x", "http://127.0.0.1:8080"], + // Special / opaque + ["about:blank", "about:blank"], + ["file:///C:/Users/me/foo.html", "file://"], + ["data:text/html,<h1>hi</h1>", null], + ["blob:https://example.com/uuid", null], + // Junk in → null out (no crash) + ["", null], + ["not a url", null], + [null, null], + [undefined, null], +]; + +let pass = 0, fail = 0; +for (const [input, expected] of cases) { + const got = originOf(input, { bcnrTlds: BCNR }); + const ok = got === expected; + console.log(`${ok ? "PASS" : "FAIL"} originOf(${JSON.stringify(input)}) → ${JSON.stringify(got)}${ok ? "" : ` (expected ${JSON.stringify(expected)})`}`); + ok ? pass++ : fail++; +} + +// Direct bnsEtldPlusOne coverage — the BNS-specific branch, since it's the +// one PSL cannot help with. +const bnsCases = [ + ["silentmode.bch", BCNR, "silentmode.bch"], + ["pay.silentmode.bch", BCNR, "silentmode.bch"], + ["a.b.c.silentmode.bch", BCNR, "silentmode.bch"], + ["bch", BCNR, null], // bare TLD — no registrable label + ["single", BCNR, null], +]; +for (const [host, tlds, expected] of bnsCases) { + const got = bnsEtldPlusOne(host, tlds); + const ok = got === expected; + console.log(`${ok ? "PASS" : "FAIL"} bnsEtldPlusOne(${JSON.stringify(host)}) → ${JSON.stringify(got)}${ok ? "" : ` (expected ${JSON.stringify(expected)})`}`); + ok ? pass++ : fail++; +} + +console.log(`\n${pass} pass, ${fail} fail`); +process.exit(fail ? 1 : 0); diff --git a/dev/probe-site.mjs b/dev/probe-site.mjs new file mode 100644 index 00000000..daa578e2 --- /dev/null +++ b/dev/probe-site.mjs @@ -0,0 +1,65 @@ +// Dev harness — exercise Theseus's `s3` content path (serveBns) WITHOUT Electron. +// Faithfully replicates what the browser triggers for a Sia site: resolve the +// name, fetch the index through the public gateway exactly as serveBns does, +// strip the gateway's <base>, then fetch every referenced same-origin asset the +// same way and report which load. This is the headless version of manual test +// #2 (Sia-via-gateway rendering). +// +// Usage: node dev/probe-site.mjs <name> e.g. coinspectrum.deviant.bch +import WebSocket from "ws"; +import { resolveName } from "../../Argus/src/lib/resolver-web.js"; + +const GATEWAY = "https://navigate.st"; // must match main.js +const host = (process.argv[2] || "coinspectrum.deviant.bch").toLowerCase(); + +// mirror of serveBns's s3 fetch for one request path +async function gw(reqPath) { + const r = await fetch(`${GATEWAY}/bns/${host}${reqPath}`); + const ct = r.headers.get("content-type") || ""; + const buf = Buffer.from(await r.arrayBuffer()); + return { status: r.status, ct, buf }; +} +const stripBase = (html) => html.replace(/<base\s+href="\/bns\/[^"]*">/i, ""); + +// same-origin STATIC asset refs a browser would request from bns://host/. +// Strip <script>/<style> bodies first so we don't match template literals or +// URLs built at runtime (those need a real render to verify — see selftest.js). +function assetPaths(html) { + const stripped = html + .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "") + .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ""); + const out = new Set(); + const re = /(?:href|src)\s*=\s*["']([^"']+)["']/gi; + let m; + while ((m = re.exec(stripped))) { + const u = m[1].trim(); + if (!u || u.startsWith("#") || u.startsWith("data:") || u.startsWith("mailto:")) continue; + if (/^[a-z]+:\/\//i.test(u) || u.startsWith("//")) continue; // external + if (u.includes("${") || u.includes("{{")) continue; // runtime-built + out.add(u.startsWith("/") ? u : "/" + u.replace(/^\.?\//, "")); + } + return [...out]; +} + +console.log(`\n=== probe ${host} ===`); +const entry = await resolveName(host, { WebSocket }); +if (!entry) { console.log("NXDOMAIN — not registered"); process.exit(1); } +console.log("records:", JSON.stringify(entry.records)); +if (!entry.records.s3) { console.log("(not an s3 site — this harness targets s3)"); process.exit(0); } + +const idx = await gw("/"); +const wasBase = /<base\s+href="\/bns\//i.test(idx.buf.toString("utf8")); +const html = stripBase(idx.buf.toString("utf8")); +console.log(`index: HTTP ${idx.status} ${idx.ct} base-injected-by-gateway=${wasBase} (stripped)`); + +const assets = assetPaths(html); +console.log(`\nassets referenced: ${assets.length}`); +let fail = 0; +for (const p of assets) { + const a = await gw(p); + const ok = a.status >= 200 && a.status < 300; + if (!ok) fail++; + console.log(` ${ok ? "OK " : "FAIL"} ${String(a.status).padEnd(3)} ${a.ct.split(";")[0].padEnd(24)} ${p}`); +} +console.log(`\nresult: ${assets.length - fail}/${assets.length} assets loaded, ${fail} failed`); +process.exit(fail ? 2 : 0); diff --git a/dev/registry-decide.mjs b/dev/registry-decide.mjs new file mode 100644 index 00000000..c94506ce --- /dev/null +++ b/dev/registry-decide.mjs @@ -0,0 +1,21 @@ +// Daemon decision logic (favor-BCNR, detect ICANN conflict), tested against the +// real chain + real ICANN DNS. This is what bnsd.js would run per query. +import WebSocket from "ws"; +import { resolveName } from "../../Argus/src/lib/resolver-web.js"; +import { Resolver } from "node:dns/promises"; +const icann = new Resolver(); icann.setServers(["1.1.1.1"]); // bypass local NRPT +async function decide(name) { + const [bcnr, ip] = await Promise.all([ + resolveName(name, { WebSocket }).catch(() => null), + icann.resolve4(name).then(a => a[0]).catch(() => null), + ]); + let d; + if (bcnr && ip) d = "CONFLICT → ask user (BCNR vs ICANN)"; + else if (bcnr) d = "serve BCNR (favored; no ICANN entry)"; + else if (ip) d = `forward ICANN (${ip})`; + else d = "NXDOMAIN (neither root has it)"; + console.log(`${name.padEnd(16)} BCNR=${bcnr ? "yes[" + Object.keys(bcnr.records) + "]" : "no ".padEnd(3)} ICANN=${(ip||"no").padEnd(15)} -> ${d}`); +} +const names = process.argv.slice(2).length ? process.argv.slice(2) : ["syskypo.de","google.de","go.dev","nothing-xyz.de"]; +for (const n of names) await decide(n); +process.exit(0); diff --git a/dev/rescheck.mjs b/dev/rescheck.mjs new file mode 100644 index 00000000..20f0007a --- /dev/null +++ b/dev/rescheck.mjs @@ -0,0 +1,9 @@ +import WebSocket from "ws"; +import { resolveName, CHIPNET_ELECTRUM } from "../../Argus/src/lib/resolver-web.js"; +console.log("electrum pool:", CHIPNET_ELECTRUM.map((s) => (typeof s === "string" ? s : s.url)).join(", ")); +for (const n of ["hello.bch","coinspectrum.bch","siatest.bch"]) { + const t=Date.now(); + try { const e=await resolveName(n,{WebSocket}); console.log(` ${n}: ${e?("OK ["+Object.keys(e.records)+"] "+(Date.now()-t)+"ms"):"NXDOMAIN"}`); } + catch(err){ console.log(` ${n}: ERROR ${err.message} (${Date.now()-t}ms)`); } +} +process.exit(0); diff --git a/dev/selftest.js b/dev/selftest.js new file mode 100644 index 00000000..84274ccd --- /dev/null +++ b/dev/selftest.js @@ -0,0 +1,82 @@ +// Electron render harness — loads a bns:// name through the REAL serveBns handler +// (imported from main.js), lets its JS execute, then reports what actually +// rendered: title, image load status, stylesheet/script counts, visible text +// length, and any failed resource loads. Also writes render.html + render.png. +// This is the full-fidelity version of manual test #2/#3 — it runs the same +// resolver + gateway path the shipped app uses. +// +// Usage: npx electron dev/selftest.js <name> e.g. hello.bch / coinspectrum.deviant.bch +process.env.THESEUS_NO_AUTOSTART = "1"; +const { app, BrowserWindow, protocol } = require("electron"); +const { serveBns, nativeTld, registryOf } = require("../main.js"); +const fs = require("fs"); +const path = require("path"); + +app.disableHardwareAcceleration(); +app.commandLine.appendSwitch("disable-gpu"); +app.commandLine.appendSwitch("no-sandbox"); + +const name = (process.argv[2] || "coinspectrum.deviant.bch").toLowerCase(); +const settleMs = Number(process.env.THESEUS_SETTLE || 5000); +const outDir = path.join(__dirname, "..", "dev-out"); + +// hard watchdog so the harness can never hang a CI/agent run +const watchdog = setTimeout(() => { console.log("WATCHDOG: timed out"); try { app.exit(3); } catch {} }, 45000); + +app.whenReady().then(async () => { + protocol.handle("bns", serveBns); + const win = new BrowserWindow({ width: 1200, height: 900, show: false, webPreferences: { offscreen: true } }); + const wc = win.webContents; + const failed = []; + wc.on("did-fail-load", (_e, code, desc, url, isMainFrame) => { if (code !== -3) failed.push({ code, desc, url, isMainFrame }); }); + + const tld = nativeTld(name); + const report = { name, url: `bns://${name}/`, badge: tld ? `${registryOf(tld)} · .${tld}` : null }; + try { + await wc.loadURL(`bns://${name}/`); + } catch (e) { report.loadError = e.message; } + + await new Promise((r) => setTimeout(r, settleMs)); // let JS build the DOM + fetch data + + try { + Object.assign(report, await wc.executeJavaScript(`(() => { + const imgs = [...document.images].map(i => { + const src = i.currentSrc || i.src || ''; + return { src: src.slice(0, 90), ok: i.complete && i.naturalWidth > 0, local: src.startsWith('bns:') }; + }); + const brokenLocal = imgs.filter(i => !i.ok && i.local); + return { + title: document.title, + stylesheets: document.styleSheets.length, + scripts: document.scripts.length, + imgTotal: imgs.length, + imgBrokenLocal: brokenLocal.length, // served by us (bns://) — a real failure + imgBrokenExternal: imgs.filter(i => !i.ok && !i.local).length, // 3rd-party CDN — informational + brokenLocalImgs: brokenLocal.map(i => i.src), + textLen: (document.body ? document.body.innerText : '').trim().length, + h1: (document.querySelector('h1,h2') || {}).innerText || null, + }; + })()`)); + } catch (e) { report.evalError = e.message; } + + report.failedLoads = failed; + + fs.mkdirSync(outDir, { recursive: true }); + try { + const html = await wc.executeJavaScript("document.documentElement.outerHTML"); + fs.writeFileSync(path.join(outDir, "render.html"), html); + } catch {} + try { + const img = await wc.capturePage(); + const png = img.toPNG(); + if (png && png.length > 0) { fs.writeFileSync(path.join(outDir, "render.png"), png); report.screenshot = `dev-out/render.png (${png.length} bytes)`; } + else report.screenshot = "empty (offscreen not compositing)"; + } catch (e) { report.screenshot = "error: " + e.message; } + + console.log("\n===== SELFTEST REPORT ====="); + console.log(JSON.stringify(report, null, 2)); + // Verdict ignores external CDN images — only our own (bns://) assets + page loads count. + report.verdict = (!report.loadError && !report.imgBrokenLocal && report.textLen > 0) ? "PASS" : "FAIL"; + clearTimeout(watchdog); + app.exit(report.verdict === "PASS" ? 0 : 2); +}); diff --git a/dev/show-tlds-bch.mjs b/dev/show-tlds-bch.mjs new file mode 100644 index 00000000..4c76c561 --- /dev/null +++ b/dev/show-tlds-bch.mjs @@ -0,0 +1,7 @@ +// Dump the full records payload of tlds.bch (the on-chain TLD registry). +import WebSocket from "ws"; +import { resolveName, CHIPNET_ELECTRUM } from "../../Argus/src/lib/resolver-web.js"; + +const OURS = CHIPNET_ELECTRUM[0]; +const entry = await resolveName("tlds.bch", { WebSocket, electrum: [OURS], directIP: true }); +console.log(JSON.stringify(entry, null, 2)); diff --git a/dev/test-directip.mjs b/dev/test-directip.mjs new file mode 100644 index 00000000..c823f6d5 --- /dev/null +++ b/dev/test-directip.mjs @@ -0,0 +1,32 @@ +// Prove the DNS-independent path: a WS wrapper that FAILS for any hostname URL, +// so a successful .bch resolution can only have come from the pinned IP + SNI. +import WebSocket from "ws"; +import { resolveName } from "../../Argus/src/lib/resolver-web.js"; + +const isIpUrl = (u) => /\/\/\d{1,3}(\.\d{1,3}){3}:/.test(u); + +class DnsDeadWS extends WebSocket { + constructor(url, opts) { + if (isIpUrl(url)) { + console.log(" → connecting by IP:", url, "SNI:", opts?.servername); + super(url, opts); + } else { + console.log(" ✗ hostname blocked (simulated dead DNS):", url); + super("wss://127.0.0.1:1"); // unreachable → error → triggers IP fallback + } + } +} + +console.log("resolving hello.bch with system DNS 'dead' (hostnames blocked)…"); +try { + const entry = await resolveName("hello.bch", { WebSocket: DnsDeadWS, directIP: true }); + if (entry) { + console.log("\n✅ RESOLVED via pinned IP — DNS-independent path works."); + console.log(" records:", Object.keys(entry.records).join(", ")); + } else { + console.log("\n⚠️ connected but hello.bch not found (name may be unregistered) — IP path still worked."); + } +} catch (e) { + console.log("\n❌ FAILED:", e.message); +} +process.exit(0); diff --git a/dev/test-our-indexer.mjs b/dev/test-our-indexer.mjs new file mode 100644 index 00000000..f25518f0 --- /dev/null +++ b/dev/test-our-indexer.mjs @@ -0,0 +1,34 @@ +// Prove Theseus can resolve hello.bch through ONLY our VPS-hosted BNS indexer. +// Uses the directIP path (pinned IP + SNI) because this machine's system resolver +// mangles Node's getaddrinfo — same reason test-directip.mjs exists. +import WebSocket from "ws"; +import { resolveName, buildIndex, CHIPNET_ELECTRUM } from "../../Argus/src/lib/resolver-web.js"; + +const OURS = CHIPNET_ELECTRUM[0]; // freshly added — Silent Mode's own server +const PUBLIC = CHIPNET_ELECTRUM.find(s => s.url.includes("chipnet.bch.ninja")); +const NAME = "hello.bch"; + +console.log("Our seed[0]:", OURS); +console.log("Public reference:", PUBLIC.url, "\n"); + +console.log("1) resolving", NAME, "via OUR indexer (directIP → pinned ip + SNI) …"); +const mine = await resolveName(NAME, { + WebSocket, electrum: [OURS], directIP: true, +}); +console.log(" ", mine ? "records: " + Object.keys(mine.records).join(", ") : "NOT FOUND"); +console.log(" txid:", mine?.txid, "height:", mine?.height, "category:", mine?.category); + +console.log("\n2) resolving", NAME, "via a public server (directIP) …"); +const ref = await resolveName(NAME, { + WebSocket, electrum: [PUBLIC], directIP: true, +}); +console.log(" ", ref ? "records: " + Object.keys(ref.records).join(", ") : "NOT FOUND"); + +console.log("\n3) diff:"); +const ok = mine && ref && + JSON.stringify(mine.records) === JSON.stringify(ref.records) && + mine.category === ref.category && + mine.txid === ref.txid; +console.log(ok ? " ✅ IDENTICAL — Theseus resolves hello.bch via our server." + : " ❌ mismatch:\n mine=" + JSON.stringify(mine) + "\n ref =" + JSON.stringify(ref)); +process.exit(ok ? 0 : 1); diff --git a/docs/ADDON-UPDATES.md b/docs/ADDON-UPDATES.md new file mode 100644 index 00000000..fc7e875a --- /dev/null +++ b/docs/ADDON-UPDATES.md @@ -0,0 +1,138 @@ +# Signed add-on updates + +An add-on can be updated at runtime, without waiting for the next Theseus +release, if its `addon.json` declares an `updateURL`. The client polls +that URL, verifies an Ed25519 signature against a hardcoded set of +operator pubkeys, stages the new copy under +`<userData>/addons-updates-staged/<id>-<version>/`, and promotes it on the +next launch — reusing the same backup-then-swap logic as +`seedBundledAddons`, so any local edits the user made survive as +`<userData>/addons-backups/<id>-<oldver>-<timestamp>/`. + +## Client boot flow + +``` +initAddons() +├── promoteStagedUpdates() # staged copy wins if newer than installed +├── seedBundledAddons() # bundle wins if newer than what's on disk +└── AddonHost.discoverAndActivate() +``` + +Then, 30 s after boot, `checkAndStageUpdates()` fetches every installed +add-on's `updateURL`, verifies its signed manifest, and lands any newer +signed version in the staged dir for the NEXT launch to promote. + +## Publishing an update + +### One-time: generate the operator keypair + +``` +node scripts/generate-update-keypair.mjs "$USERPROFILE/.silentmode/ops" +``` + +The private key stays there. Copy the printed pubkey hex into +`addon-update-pubkeys.js` and ship a Theseus release that bakes it in; +until you do, `checkAndStageUpdates` skips silently and the client makes +no outbound requests. + +### Every update + +Sign from a clean copy of the committed add-on (`git archive HEAD …`), not +the working tree, so another session's uncommitted edits don't ship: + +``` +node scripts/sign-addon-update.mjs \ + <clean-copy>/bundled-addons/screenshot \ + https://navigate.st/bns/theseus.x/extensions/screenshot \ + "$USERPROFILE/.silentmode/ops/addon-update-key.pem" --out out/ +``` + +Produces: +- `out/screenshot-<version>.tar.gz` — upload to `<base-url>/<id>-<version>.tar.gz` +- `out/screenshot-<version>.entry.json` — prepend to your live `updates.json` + +The channel files live on Sia, under `s3://bns/theseus/extensions/<id>/`, +and are served through the public gateway as +`https://navigate.st/bns/theseus.x/extensions/<id>/…` — that is what every +bundled add-on's `updateURL` points at. Nothing is on the VPS filesystem. +Upload with the Sia uploader (single file → directory prefix, not a full +target path): + +``` +node ../Argus/src/lib/sia-upload.js out/screenshot-<version>.tar.gz bns/theseus/extensions/screenshot +node ../Argus/src/lib/sia-upload.js updates.json bns/theseus/extensions/screenshot +``` + +Then confirm both URLs answer 200 through the gateway before relying on +the entry. Live channels: `screenshot` and `aegis`. + +## `updates.json` schema + +```json +{ + "addons": [ + { + "version": "0.3.0", + "url": "https://addons.silentmode.st/screenshot/screenshot-0.3.0.tar.gz", + "sha256": "hex", + "sig": "base64" + }, + { "version": "0.2.1", "url": "…", "sha256": "hex", "sig": "base64" } + ] +} +``` + +Order does not matter — the client picks the highest `version` greater +than what's installed. Keep old entries around only if you want to +support rollback via a manual downgrade tool; the client never picks +anything older than the installed copy. + +## Signature domain + +``` +message = "silentmode.addon-update-v1|<id>|<version>|<sha256>" +signature = Ed25519_sign(operator_privkey, message) +``` + +Domain-separated so the operator key can't be tricked into signing +something else (a website login token, a wallet message) that happens +to have the right shape. + +## Rotating the operator key + +1. `generate-update-keypair.mjs` a new one. +2. Publish next `updates.json` entries signed by BOTH old and new key + (the client accepts a signature from any pubkey in `PUBKEYS_HEX`). +3. Ship a Theseus release that adds the new pubkey. +4. After enough time has passed for existing installs to upgrade, ship + a follow-up release removing the old pubkey. Stop signing with it. + +## Threat model + +- **Compromised operator key** = update-tunnel compromise. Attackers can + push a malicious add-on to every install that has the endpoint + reachable. Mitigations under consideration: + - multi-sig: require signatures from N of M operator keys (not + implemented — would extend the `sig` field to an array) + - on-BCNR pubkey publication: pin the current pubkey set to a BNS + record so rotation is auditable (roadmap) +- **Manifest downgrade** — a malicious mirror serves an older signed + entry. The client refuses versions ≤ what's installed, so a rollback + attack cannot land an older signed payload as if it were an update. + A user who wipes `<userData>/addons/` and boots against a hostile + mirror could receive a stale-but-signed copy; the bundled fallback + from the Theseus release will beat it via `seedBundledAddons` unless + the bundle is older still. +- **Path traversal in tarball** — `tar` refuses `..` entries by default; + we do not pass `-P`. Extracted manifest's `id` + `version` are + re-checked against the signed values before staging. +- **Empty pubkey list** = feature off. No outbound requests, no updates, + no attack surface. Safe default for fresh builds. + +## Files + +- `addon-updater.js` — client-side fetch, verify, stage, promote. +- `addon-update-pubkeys.js` — hardcoded pubkey array (edit and rebuild + to rotate). +- `scripts/generate-update-keypair.mjs` — one-time keygen. +- `scripts/sign-addon-update.mjs` — package + sign an update. diff --git a/downloads-preload.js b/downloads-preload.js new file mode 100644 index 00000000..4b176f40 --- /dev/null +++ b/downloads-preload.js @@ -0,0 +1,11 @@ +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("dl", { + onDownloads: (cb) => ipcRenderer.on("downloads", (_e, d) => cb(d)), + close: () => ipcRenderer.invoke("close-downloads"), + resize: (h) => ipcRenderer.invoke("downloads-resize", h), + open: (id) => ipcRenderer.invoke("download-open", id), + show: (id) => ipcRenderer.invoke("download-show", id), + cancel: (id) => ipcRenderer.invoke("download-cancel", id), + clear: (id) => ipcRenderer.invoke("download-clear", id), + clearAll: () => ipcRenderer.invoke("downloads-clear-all"), +}); diff --git a/downloads.html b/downloads.html new file mode 100644 index 00000000..297cce55 --- /dev/null +++ b/downloads.html @@ -0,0 +1,101 @@ +<!doctype html> +<html><head><meta charset="utf-8"> +<style> + :root { color-scheme: light dark; font-family: system-ui, sans-serif; } + html, body { margin: 0; background: transparent; } + .menu { background: #1c222c; border: 1px solid #ffffff26; border-radius: 10px; box-shadow: 0 12px 34px #000c; overflow: hidden; color: #e7eaf1; } + .head { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px 6px; border-bottom: 1px solid #ffffff12; } + .head .t { font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: #8b98a9; } + .head .clr { font-size: 11.5px; color: #8b98a9; background: transparent; border: none; cursor: pointer; padding: 2px 6px; border-radius: 4px; } + .head .clr:hover { background: #ffffff10; color: #e7eaf1; } + .head .clr[disabled] { opacity: .35; cursor: default; } + .list { max-height: 380px; overflow-y: auto; } + .empty { padding: 22px 14px; text-align: center; color: #7f8aa0; font-size: 12.5px; } + .row { padding: 10px 14px; border-bottom: 1px solid #ffffff08; font-size: 12.5px; } + .row:last-child { border-bottom: 0; } + .row .top { display: flex; align-items: center; gap: 8px; } + .row .fn { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #e7eaf1; font-weight: 500; } + .row .st { font-size: 10.5px; color: #7f8aa0; } + .row .st.err { color: #f6768a; } + .row .st.ok { color: #4fd1a5; } + .row .mt { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 4px; color: #7f8aa0; font-size: 11px; } + .bar { height: 4px; background: #ffffff10; border-radius: 999px; overflow: hidden; margin-top: 6px; } + .bar > i { display: block; height: 100%; background: linear-gradient(90deg, #4b7bec, #d6ff3d); border-radius: 999px; transition: width .18s linear; } + .bar.indet > i { width: 35%; animation: slide 1.1s infinite linear; } + @keyframes slide { 0% { transform: translateX(-110%); } 100% { transform: translateX(400%); } } + .btns { display: flex; gap: 4px; } + .btn { background: transparent; border: none; color: #8b98a9; cursor: pointer; padding: 2px 5px; border-radius: 4px; font-size: 12px; } + .btn:hover { background: #ffffff10; color: #e7eaf1; } + .btn.rm:hover { color: #f6768a; } + @media (prefers-color-scheme: light) { + .menu { background: #ffffff; border-color: rgba(0,0,0,.15); color: #1a1f28; } + .head { border-bottom-color: rgba(0,0,0,.08); } + .head .t, .row .st, .row .mt, .empty, .btn { color: #7b8494; } + .head .clr:hover, .btn:hover { background: rgba(0,0,0,.05); color: #1a1f28; } + .row .fn { color: #1a1f28; } + .row { border-bottom-color: rgba(0,0,0,.06); } + .bar { background: rgba(0,0,0,.08); } + } +</style></head> +<body> + <div class="menu"> + <div class="head"> + <span class="t">Downloads</span> + <button class="clr" id="clr" disabled>Clear finished</button> + </div> + <div class="list" id="list"></div> + </div> +<script> + const $ = (id) => document.getElementById(id); + const esc = (s) => String(s || "").replace(/</g, "&lt;").replace(/"/g, "&quot;"); + const fmt = (n) => { + n = Number(n) || 0; + if (n < 1024) return n + " B"; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"; + if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB"; + return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB"; + }; + function report() { requestAnimationFrame(() => { try { window.dl.resize(document.querySelector(".menu").offsetHeight); } catch (e) {} }); } + window.dl.onDownloads((items) => { + const list = $("list"); + const has = items && items.length; + $("clr").disabled = !has || !items.some((d) => d.state !== "progressing"); + if (!has) { + list.innerHTML = `<div class="empty">No downloads yet.</div>`; + report(); return; + } + list.innerHTML = items.map((d) => { + const done = d.state === "completed"; + const err = d.state === "interrupted"; + const cancel = d.state === "cancelled"; + const inflight = d.state === "progressing" || d.state === "paused"; + const pct = d.total > 0 ? Math.min(100, Math.round((d.received / d.total) * 100)) : 0; + const barStyle = d.total > 0 ? `width:${pct}%` : "width:35%"; + const barClass = inflight && d.total === 0 ? "bar indet" : "bar"; + const statusText = done ? "Done" : err ? "Failed" : cancel ? "Cancelled" : (d.total > 0 ? pct + "%" : "…"); + const statusCls = done ? "st ok" : (err || cancel) ? "st err" : "st"; + const sizeText = d.total > 0 + ? `${fmt(d.received)} / ${fmt(d.total)}` + : (d.received > 0 ? fmt(d.received) : ""); + const actions = inflight + ? `<button class="btn" data-cancel="${d.id}" title="Cancel">✕</button>` + : (done + ? `<button class="btn" data-open="${d.id}" title="Open">Open</button> + <button class="btn" data-show="${d.id}" title="Show in folder">Folder</button> + <button class="btn rm" data-clear="${d.id}" title="Remove from list">✕</button>` + : `<button class="btn rm" data-clear="${d.id}" title="Remove from list">✕</button>`); + return `<div class="row"> + <div class="top"><span class="fn" title="${esc(d.filename)}">${esc(d.filename)}</span><span class="${statusCls}">${statusText}</span></div> + ${inflight || d.total > 0 ? `<div class="${barClass}"><i style="${barStyle}"></i></div>` : ""} + <div class="mt"><span>${esc(sizeText)}</span><span class="btns">${actions}</span></div> + </div>`; + }).join(""); + for (const el of list.querySelectorAll("[data-open]")) el.onclick = () => window.dl.open(+el.dataset.open); + for (const el of list.querySelectorAll("[data-show]")) el.onclick = () => window.dl.show(+el.dataset.show); + for (const el of list.querySelectorAll("[data-cancel]"))el.onclick = () => window.dl.cancel(+el.dataset.cancel); + for (const el of list.querySelectorAll("[data-clear]")) el.onclick = () => window.dl.clear(+el.dataset.clear); + report(); + }); + $("clr").onclick = () => window.dl.clearAll(); +</script> +</body></html> diff --git a/engine-picker-preload.js b/engine-picker-preload.js new file mode 100644 index 00000000..e4499889 --- /dev/null +++ b/engine-picker-preload.js @@ -0,0 +1,9 @@ +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("picker", { + onEngines: (cb) => ipcRenderer.on("engines", (_e, d) => cb(d)), + pick: (id) => ipcRenderer.invoke("pick-engine", id), + addDetected: () => ipcRenderer.invoke("add-detected-engine"), + openSettings: () => ipcRenderer.invoke("picker-open-settings"), + close: () => ipcRenderer.invoke("close-engine-picker"), + resize: (h) => ipcRenderer.invoke("ep-resize", h), +}); diff --git a/engine-picker.html b/engine-picker.html new file mode 100644 index 00000000..9fc44a62 --- /dev/null +++ b/engine-picker.html @@ -0,0 +1,59 @@ +<!doctype html> +<html><head><meta charset="utf-8"> +<style> + :root { color-scheme: light dark; font-family: system-ui, sans-serif; } + html, body { margin: 0; background: transparent; } + .menu { background: #1c222c; border: 1px solid #ffffff26; border-radius: 10px; box-shadow: 0 12px 34px #000c; overflow: hidden; color: #e7eaf1; } + .hdr { font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: #8b98a9; padding: 10px 14px 6px; } + .item { display: flex; align-items: center; gap: 10px; padding: 8px 14px; cursor: pointer; font-size: 13px; } + .item:hover { background: #ffffff12; } + .item .ic { width: 18px; height: 18px; flex: none; display: grid; place-items: center; } + .item .ic img { width: 16px; height: 16px; border-radius: 3px; } + .item .ic .em { font-size: 14px; } + .item .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .item .chk { color: #4fd1a5; font-size: 12px; } + .sep { height: 1px; background: #ffffff14; margin: 6px 0; } + .foot .item { color: #b9c2d0; } + @media (prefers-color-scheme: light) { + .menu { background: #ffffff; border-color: rgba(0,0,0,.15); color: #1a1f28; } + .hdr { color: #7b8494; } .item:hover { background: rgba(0,0,0,.05); } .sep { background: rgba(0,0,0,.10); } .foot .item { color: #3c4453; } + } +</style></head> +<body> + <div class="menu"> + <div id="groups"></div> + <div class="sep"></div> + <div class="foot"> + <div class="item" id="settings"><span class="ic"><span class="em">⚙️</span></span><span class="nm">Search settings…</span></div> + </div> + </div> +<script> + const $ = (id) => document.getElementById(id); + const esc = (s) => String(s || "").replace(/</g, "&lt;"); + function report() { requestAnimationFrame(() => { try { window.picker.resize(document.querySelector(".menu").offsetHeight); } catch (e) {} }); } + const KINDS = [ + { key: "search", label: "Search with" }, + { key: "llm", label: "Ask an AI" }, + ]; + const row = (e, current) => { + const ic = e.favicon + ? `<img src="${esc(e.favicon)}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'em',textContent:'${(e.sym||'🔍')}'}))">` + : `<span class="em">${e.sym || "🔍"}</span>`; + return `<div class="item" data-id="${esc(e.id)}"><span class="ic">${ic}</span><span class="nm">${esc(e.name)}</span>${e.id === current ? '<span class="chk">✓</span>' : ""}</div>`; + }; + window.picker.onEngines((d) => { + const engines = d.engines || []; + const groups = $("groups"); + // Preserve engineOrder within each kind (engines already come sorted). + const sections = KINDS.map(({ key, label }) => { + const rows = engines.filter((e) => (e.kind || "search") === key).map((e) => row(e, d.current)).join(""); + if (!rows) return ""; // hide a section with no engines + return `<div class="hdr">${label}</div>${rows}`; + }).join(""); + groups.innerHTML = sections; + groups.querySelectorAll(".item").forEach((el) => el.onclick = () => window.picker.pick(el.dataset.id)); + report(); + }); + $("settings").onclick = () => window.picker.openSettings(); +</script> +</body></html> diff --git a/error-preload.js b/error-preload.js new file mode 100644 index 00000000..76f8f7a6 --- /dev/null +++ b/error-preload.js @@ -0,0 +1,12 @@ +const { contextBridge, ipcRenderer } = require("electron"); +// Renderer -> main API for the branded error page. All calls are user-initiated +// button clicks; main validates the sender before acting so a hostile page +// can't drive navigation by loading window.errorpage. +contextBridge.exposeInMainWorld("errorpage", { + retry: (url) => ipcRenderer.invoke("error-retry", url), + goHome: () => ipcRenderer.invoke("error-home"), + searchFor: (text) => ipcRenderer.invoke("error-search", text), + registerOnSirius: (host) => ipcRenderer.invoke("error-register", host), + openExternal: (url) => ipcRenderer.invoke("error-open-external", url), + bnsSimilar: (host) => ipcRenderer.invoke("error-bns-similar", host), +}); diff --git a/error.html b/error.html new file mode 100644 index 00000000..7fe26802 --- /dev/null +++ b/error.html @@ -0,0 +1,253 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Load error</title> +<style> + :root { color-scheme: light dark; + --bg:#0b0e14; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09); + --ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; + --warn:#ffc75f; --err:#f6768a; --info:#4b7bec; } + @media (prefers-color-scheme: light) { + :root { --bg:#f6f8fb; --panel:#ffffff; --panel2:#f0f4f9; --line:rgba(0,0,0,.10); + --ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; + /* Darker acid for light backgrounds — ~5.5:1 on white. */ + --acid: #0AC18E; } + } + * { box-sizing: border-box } + html, body { margin: 0; height: 100% } + body { + background: radial-gradient(1100px 560px at 50% -12%, #16202e, var(--bg)); + color: var(--ink); font: 15px/1.6 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + display: grid; place-items: center; padding: 2rem 1.2rem; + } + .card { max-width: 640px; width: 100%; background: var(--panel); border: 1px solid var(--line); + border-radius: 14px; padding: 2rem 2.2rem 1.7rem; } + .head { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 1rem; } + .icon { width: 42px; height: 42px; flex: none; border-radius: 10px; display: grid; place-items: center; + font-size: 22px; background: rgba(246,118,138,.10); } + .icon.warn { background: rgba(255,199,95,.10) } + .icon.info { background: rgba(75,123,236,.10) } + h1 { font-size: 1.15rem; font-weight: 600; margin: .1rem 0 .1rem; letter-spacing: .1px } + .sub { color: var(--mut); font-size: 13.5px; margin: 0; } + .body { color: var(--ink); font-size: 14px; line-height: 1.62; margin: 1rem 0 .6rem; } + .body b { color: var(--acid); } + .body .host { font-family: ui-monospace, monospace; background: var(--panel2); padding: 1px 6px; + border-radius: 5px; color: var(--acid); word-break: break-all; } + .tried { margin: 1rem 0 0; padding: 12px 14px; background: var(--panel2); + border: 1px dashed var(--line); border-radius: 10px; font-size: 12.5px; color: var(--mut); } + .tried .t { display: flex; gap: 8px; align-items: center; padding: 3px 0; } + .tried .t .m { width: 12px; text-align: center; color: var(--err); } + .tried .t .m.ok { color: var(--acid) } + .suggest { margin: 1rem 0 0; padding: 12px 14px; background: var(--panel2); + border: 1px solid rgb(from var(--acid) r g b / .25); border-radius: 10px; font-size: 13px; color: var(--ink); } + .suggest .lbl { color: var(--mut); font-size: 12px; margin-bottom: 6px; } + .suggest .row { display: flex; flex-wrap: wrap; gap: 6px 8px; } + .suggest a { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 6px; + background: var(--panel); border: 1px solid var(--line); color: var(--acid); + font-family: ui-monospace, monospace; font-size: 12.5px; text-decoration: none; cursor: pointer; } + .suggest a:hover { border-color: rgb(from var(--acid) r g b / .55); filter: brightness(1.05); } + .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 1.3rem; } + .btn { font-family: inherit; font-size: 13.5px; padding: 9px 16px; border-radius: 9px; + border: 1px solid var(--line); background: var(--panel2); color: var(--ink); + cursor: pointer; transition: filter .1s, background .1s, border-color .1s; } + .btn:hover { background: var(--panel); border-color: rgb(from var(--acid) r g b / .35); filter: brightness(1.05); } + .btn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; font-weight: 600; } + .btn.primary:hover { background: var(--acid); filter: brightness(1.08); } + .details { margin-top: 1.2rem; padding-top: .9rem; border-top: 1px solid var(--line); + font: 11.5px/1.55 ui-monospace, monospace; color: var(--dim); } + .details .row { display: grid; grid-template-columns: 78px 1fr; gap: 8px; } + .details .row b { color: var(--mut); font-weight: 500; } +</style> +</head> +<body> +<div class="card"> + <div class="head"> + <div class="icon" id="icon">✕</div> + <div> + <h1 id="title">Couldn't load this page</h1> + <p class="sub" id="sub">The tab kept its address so you can edit and try again.</p> + </div> + </div> + + <div class="body" id="body"></div> + + <div class="tried" id="tried" hidden> + <div class="t"><span class="m">✕</span> <span>Bitcoin Cash Domain Names (BCDN) — no record on chain</span></div> + <div class="t"><span class="m">✕</span> <span>Clearnet DNS — no host at this name either</span></div> + </div> + + <div class="suggest" id="suggest" hidden> + <div class="lbl">Did you mean</div> + <div class="row" id="suggestRow"></div> + </div> + + <div class="actions" id="actions"></div> + + <div class="details"> + <div class="row"><b>URL</b><span id="dUrl"></span></div> + <div class="row"><b>Error</b><span id="dErr"></span></div> + </div> +</div> + +<script> + const q = new URLSearchParams(location.search); + let kind = q.get("kind") || "generic"; // offline | name-not-registered | name-unreachable | unreachable | tls | generic + const host = q.get("host") || ""; + const url = q.get("url") || ""; + const code = q.get("code") || ""; + const desc = q.get("desc") || ""; + + // Offline is a special case: if the browser process itself has no + // network, every load will fail here and we'd otherwise mis-report + // BCNR-eligible names as "not registered" (we couldn't reach the + // beacon to know). Detect it up front so the user sees a clear + // "you're offline" instead of a false verdict about the name. + if (typeof navigator !== "undefined" && navigator.onLine === false) kind = "offline"; + + const $ = (id) => document.getElementById(id); + const el = (tag, cls, txt) => { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }; + const hostSpan = (h) => { const s = el("span", "host"); s.textContent = h || "(no host)"; return s; }; + + document.title = host ? "Error — " + host : "Load error"; + $("dUrl").textContent = url || "(none)"; + $("dErr").textContent = (code ? code + " " : "") + (desc || ""); + + // Kind → title, subtitle, body, actions. + const configs = { + "offline": { + icon: "⌀", iconCls: "info", + title: "You appear to be offline", + sub: "No network connection detected", + body: (b) => { + b.append("This browser can't reach the network. Wi-Fi may be off, the cable may be unplugged, or a VPN / proxy may be dropping traffic. "); + b.append("Reconnect and press "); + const kbd = document.createElement("b"); kbd.textContent = "Retry"; b.append(kbd); + b.append("."); + if (host) { + b.append(document.createElement("br")); + b.append(document.createElement("br")); + b.append("Trying to open "); + b.append(hostSpan(host)); + b.append(" — the address is kept so you can retry once you're back online."); + } + }, + actions: ["retry", "home"], + }, + "name-not-registered": { + icon: "?", iconCls: "warn", + title: "That name isn't registered", + sub: "No BCDN record on chain, and no clearnet host at " + host, + body: (b) => { + b.append("Nothing answered when we asked for "); + b.append(hostSpan(host)); + b.append(". Bitcoin Cash Domain Names had no record for it, and ordinary DNS has no host at that name either."); + b.append(document.createElement("br")); + b.append(document.createElement("br")); + b.append("If it's supposed to be yours, register it on the chain. If you were guessing, try a search instead."); + }, + tried: true, + actions: ["register", "search", "retry", "home"], + }, + "name-unreachable": { + icon: "⌘", iconCls: "warn", + title: "This name didn't resolve", + sub: "Your DNS resolver didn't know " + host, + body: (b) => { + b.append("We couldn't turn "); + b.append(hostSpan(host)); + b.append(" into an address. Your network's resolver may be blocking it, offline, or the name simply doesn't exist. BCDN was tried first — if the host is a BCDN name, it's not registered on the chain either."); + }, + actions: ["retry", "search", "register", "home"], + }, + "unreachable": { + icon: "✕", iconCls: "", + title: "This site is unreachable", + sub: host ? ("Couldn't connect to " + host) : "The server didn't respond", + body: (b) => { + b.append("The name resolved, but the server at "); + b.append(hostSpan(host)); + b.append(" didn't answer. It may be down, blocking your network, or reachable only through Tor."); + }, + actions: ["retry", "tor-hint", "home"], + }, + "tls": { + icon: "!", iconCls: "warn", + title: "The site's certificate isn't trustworthy", + sub: host ? ("HTTPS handshake with " + host + " failed") : "Certificate error", + body: (b) => { + b.append("The server at "); + b.append(hostSpan(host)); + b.append(" presented a certificate the browser couldn't verify. This can mean the certificate has expired, doesn't match the hostname, was self-signed, or that someone is intercepting the connection."); + }, + actions: ["retry", "home"], + }, + "generic": { + icon: "✕", iconCls: "", + title: "Couldn't load this page", + sub: host ? ("Something went wrong loading " + host) : "Load failed", + body: (b) => { + b.append("Something interrupted the load of "); + b.append(hostSpan(host)); + b.append(". Details are at the bottom of this page."); + }, + actions: ["retry", "home"], + }, + }; + const c = configs[kind] || configs.generic; + $("icon").textContent = c.icon; + $("icon").className = "icon" + (c.iconCls ? " " + c.iconCls : ""); + $("title").textContent = c.title; + $("sub").textContent = c.sub; + c.body($("body")); + if (c.tried) $("tried").hidden = false; + + // Actions — order matters (first is primary). "register" only when we can + // construct a Sirius URL (need a host). + const btnDefs = { + retry: { label: "Retry", primary: true, fn: () => window.errorpage.retry(url) }, + search: { label: "Search for '" + host + "'", primary: false, fn: () => window.errorpage.searchFor(host) }, + register: { label: "Register on Sirius", primary: false, fn: () => window.errorpage.registerOnSirius(host) }, + home: { label: "Home", primary: false, fn: () => window.errorpage.goHome() }, + "tor-hint": { + label: "Open Tor guide", primary: false, + fn: () => window.errorpage.openExternal("https://silentmode.st/tools/#tor"), + }, + }; + const actionsBox = $("actions"); + let primaryUsed = false; + for (const a of c.actions) { + const def = btnDefs[a]; if (!def) continue; + if (a === "register" && !host) continue; + if (a === "search" && !host) continue; + const b = el("button", "btn" + (def.primary && !primaryUsed ? " primary" : ""), def.label); + if (def.primary) primaryUsed = true; + b.addEventListener("click", def.fn); + actionsBox.appendChild(b); + } + + // "Did you mean" — for BCNR-name failures, ask main for near-matches in + // the warm shared index (Levenshtein ≤ 2, same TLD). No network — the + // index is local, so this works offline too. Clicking a suggestion + // retries the load at that host, preserving the original path if any. + if ((kind === "name-not-registered" || kind === "name-unreachable") && host && window.errorpage?.bnsSimilar) { + window.errorpage.bnsSimilar(host).then((matches) => { + if (!Array.isArray(matches) || !matches.length) return; + const row = $("suggestRow"); + let origPath = "/"; + try { const u = new URL(url); origPath = u.pathname + u.search + u.hash; } catch {} + for (const name of matches) { + const a = el("a", null, name); + a.href = "#"; + a.addEventListener("click", (ev) => { + ev.preventDefault(); + window.errorpage.retry("https://" + name + origPath); + }); + row.appendChild(a); + } + $("suggest").hidden = false; + }).catch(() => {}); + } +</script> +</body> +</html> diff --git a/home-preload.js b/home-preload.js new file mode 100644 index 00000000..aa3b2c9b --- /dev/null +++ b/home-preload.js @@ -0,0 +1,30 @@ +// Preload for tab webContents that host the built-in home page. All non- +// settings tabs get this preload since we don't know in advance whether a +// tab will land on home.html; the corresponding IPC handlers in main.js +// validate the sender's URL is our own home.html file:// and reject any +// origin-mismatched call, so a third-party page can inspect the API's +// SHAPE but can't invoke it against local user data. +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("home", { + getCards: () => ipcRenderer.invoke("home-cards-get"), + setCards: (cards) => ipcRenderer.invoke("home-cards-set", cards), + resetCards: () => ipcRenderer.invoke("home-cards-reset"), + navigate: (url) => ipcRenderer.invoke("navigate", url), + // Current default search engine — used to populate the search box + // placeholder with the actual engine name (e.g. "Search the web with + // Startpage") instead of hardcoding DuckDuckGo. + getEngines: () => ipcRenderer.invoke("search-engines"), + // Main pushes an updated list here after a successful remote refresh + // (only when the user has not customised locally). home.html re-renders. + onCards: (cb) => ipcRenderer.on("home-cards", (_e, list) => cb(list)), +}); +// Same preload also serves the branded error page (error.html). Handlers in +// main.js sender-check for error.html so a random page seeing the API shape +// can't drive navigation. +contextBridge.exposeInMainWorld("errorpage", { + retry: (url) => ipcRenderer.invoke("error-retry", url), + goHome: () => ipcRenderer.invoke("error-home"), + searchFor: (text) => ipcRenderer.invoke("error-search", text), + registerOnSirius: (host) => ipcRenderer.invoke("error-register", host), + openExternal: (url) => ipcRenderer.invoke("error-open-external", url), +}); diff --git a/home.html b/home.html new file mode 100644 index 00000000..59ba93b0 --- /dev/null +++ b/home.html @@ -0,0 +1,290 @@ +<!doctype html> +<html> +<head><meta charset="utf-8"><title>Theseus</title> +<style> + :root { color-scheme: light dark; + --bg1: #16202e; --bg2: #0b0e14; + --card: #141a24; --card-hi: #18202c; --line: #ffffff12; --line2: #ffffff22; + --ink: #e7eaf1; --mut: #8b98a9; --dim: #5e6678; + --acid: #d6ff3d; --blue: #4b7bec; --sia: #b39ddb; --srv: #4fd1a5; } + * { box-sizing: border-box; } + body { margin: 0; min-height: 100vh; font-family: system-ui, sans-serif; + background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2)); + color: var(--ink); display: flex; flex-direction: column; align-items: center; } + .hero { text-align: center; margin-top: 8vh; padding: 0 1rem; width: 100%; } + .mark { font-size: 64px; line-height: 1; } + h1 { font-size: 2.4rem; margin: .4rem 0 .2rem; letter-spacing: .5px; } + h1 .g { color: var(--acid); } + .tag { color: var(--mut); margin: 0 0 1.6rem; } + form { display: flex; gap: 8px; justify-content: center; max-width: 620px; margin: 0 auto; padding: 0 1rem; } + form input { flex: 1; padding: 13px 18px; border-radius: 999px; border: 1px solid var(--line2); + background: var(--card); color: var(--ink); font-size: 15px; outline: none; } + form input:focus { border-color: var(--blue); } + form button { padding: 13px 20px; border-radius: 999px; border: none; background: var(--blue); color: #fff; font-size: 15px; cursor: pointer; } + .hint { color: var(--dim); font-size: 12.5px; margin-top: .8rem; } + + /* Cards — larger, responsive. Grid autoscales to viewport width: + wide screens fit 4-5 across, narrow screens collapse to 2, mobile to 1. */ + .cards-wrap { max-width: 1200px; width: 100%; margin: 3rem auto 4rem; padding: 0 1.2rem; } + .cards-hdr { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; padding: 0 4px; } + .cards-hdr .title { font-size: 12px; letter-spacing: .12em; text-transform: uppercase; color: var(--dim); } + .cards-hdr .tools button { background: transparent; border: 1px solid var(--line2); color: var(--mut); + border-radius: 8px; padding: 6px 12px; font-size: 12.5px; cursor: pointer; margin-left: 6px; } + .cards-hdr .tools button:hover { background: var(--card-hi); color: var(--ink); } + .cards-hdr .tools button.on { border-color: var(--acid); color: var(--acid); background: rgb(from var(--acid) r g b / .08); } + .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; } + @media (min-width: 900px) { .grid { gap: 18px; } } + @media (min-width: 1200px) { .grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; } } + @media (max-width: 600px) { .grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; } .cards-wrap { padding: 0 .8rem; } } + .card { display: block; text-decoration: none; color: inherit; background: var(--card); border: 1px solid var(--line); + border-radius: 14px; padding: 20px 20px 18px; transition: transform .15s, background .15s, border-color .15s; + position: relative; cursor: pointer; } + .card:hover { border-color: rgba(75,123,236,.4); background: var(--card-hi); transform: translateY(-2px); } + .card .n { font-weight: 600; color: var(--ink); font-size: 15.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + /* TLD suffix (.x, .bch, ...) rendered in the Silent Mode acid so the brand + accent reads instantly across the grid. Subtle glow reinforces it. */ + .card .n .tld { color: var(--acid); font-weight: 700; letter-spacing: .3px; + text-shadow: 0 0 8px rgb(from var(--acid) r g b / .35); } + .card .d { color: var(--mut); font-size: 13px; margin-top: 6px; line-height: 1.4; overflow: hidden; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } + .card:hover .d { color: var(--ink); } + .card .row { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; gap: 8px; } + .badge { display: inline-block; font-size: 10.5px; padding: 2px 9px; border-radius: 999px; letter-spacing: .02em; } + /* Provenance-typed badges (which chain/storage backed the site). */ + .b-on-chain { background: rgb(from var(--acid) r g b / .14); color: var(--acid); } + .b-sia { background: rgba(179,157,219,.14); color: var(--sia); } + .b-server { background: rgba(79,209,165,.14); color: var(--srv); } + /* Product-role badges — same acid family with per-role hue shifts. + Kept muted so the badge is legible but doesn't compete with the title. */ + .b-infrastructure { background: rgb(from var(--acid) r g b / .10); color: var(--acid); } + .b-navigator { background: rgba(75,123,236,.14); color: #7ea3ff; } + .b-registrar { background: rgb(from var(--acid) r g b / .10); color: var(--acid); } + .b-code-host { background: rgba(179,157,219,.14); color: var(--sia); } + .b-app-store { background: rgba(79,209,165,.14); color: var(--srv); } + .b-search { background: rgba(255,199,95,.14); color: #ffc75f; } + .b-messaging { background: rgba(246,118,138,.14); color: #f6768a; } + .b-default { background: rgba(139,152,169,.14); color: var(--mut); } + + /* Edit affordances — hidden until Edit mode toggled */ + .card .edit-actions { position: absolute; top: 8px; right: 8px; display: none; gap: 4px; } + .card .edit-actions button { border: 1px solid var(--line2); background: rgba(0,0,0,.35); color: var(--mut); + width: 26px; height: 26px; border-radius: 6px; cursor: pointer; font-size: 13px; padding: 0; } + .card .edit-actions button:hover { color: var(--ink); background: rgba(0,0,0,.55); } + .card .edit-actions button.rm:hover { color: #f6768a; border-color: rgba(246,118,138,.5); } + body.editing .card .edit-actions { display: flex; } + body.editing .card { cursor: default; } + body.editing .card:hover { transform: none; } + .add-card { display: none; align-items: center; justify-content: center; + background: transparent; border: 2px dashed var(--line2); border-radius: 14px; + color: var(--mut); font-size: 14px; cursor: pointer; padding: 20px; min-height: 100px; } + .add-card:hover { border-color: var(--acid); color: var(--acid); background: rgb(from var(--acid) r g b / .04); } + body.editing .add-card { display: flex; } + + /* Modal for add/edit */ + .modal-bg { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 100; + align-items: center; justify-content: center; padding: 1rem; } + .modal-bg.on { display: flex; } + .modal { background: var(--card); border: 1px solid var(--line2); border-radius: 12px; + padding: 22px; width: 100%; max-width: 460px; box-shadow: 0 20px 60px #000c; } + .modal h2 { margin: 0 0 14px; font-size: 17px; color: var(--ink); } + .modal label { display: block; font-size: 12.5px; color: var(--mut); margin: 10px 0 4px; letter-spacing: .04em; text-transform: uppercase; } + .modal input, .modal select { width: 100%; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--line2); + background: var(--bg2); color: var(--ink); font-size: 13.5px; outline: none; font-family: inherit; } + .modal input:focus, .modal select:focus { border-color: var(--blue); } + .modal .btns { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } + .modal button { border-radius: 8px; padding: 8px 16px; font-size: 13.5px; cursor: pointer; border: 1px solid var(--line2); + background: transparent; color: var(--mut); } + .modal button:hover { background: var(--card-hi); color: var(--ink); } + .modal button.primary { background: var(--blue); color: #fff; border-color: var(--blue); } + .modal button.primary:hover { background: #5a89f5; } + + footer { color: #47505f; font-size: 12px; padding-bottom: 2rem; text-align: center; } + footer b { color: #6b7688; } + + /* Light theme */ + @media (prefers-color-scheme: light) { + :root { --bg1: #dce6f2; --bg2: #eef0f4; + --card: #ffffff; --card-hi: #f4f7fb; --line: rgba(0,0,0,.10); --line2: rgba(0,0,0,.14); + --ink: #1a1f28; --mut: #4a5262; --dim: #8a93a2; + /* Darker acid for light backgrounds — ~5.5:1 on white. */ + --acid: #0AC18E; } + h1 .g { color: var(--acid); } + .modal { background: #ffffff; } + } +</style></head> +<body> + <div class="hero"> + <div class="mark">⛓️</div> + <h1>Theseus <span class="g">Navigator</span></h1> + <p class="tag">A browser that follows the thread. Names that live on the Bitcoin Cash chain.</p> + <form id="searchForm"> + <input id="q" name="q" placeholder="Search the web" autofocus spellcheck="false"> + <button type="submit">Search</button> + </form> + <p class="hint">Tip: type a <b>.bch</b> name in the address bar above to open a decentralized site.</p> + </div> + + <div class="cards-wrap"> + <div class="cards-hdr"> + <span class="title">Quick links</span> + <div class="tools"> + <button id="editBtn" type="button">Edit</button> + <button id="resetBtn" type="button" title="Restore the default set of cards" style="display:none">Reset to defaults</button> + </div> + </div> + <div class="grid" id="grid"></div> + </div> + + <div class="modal-bg" id="modal"> + <div class="modal"> + <h2 id="modalTitle">Add card</h2> + <label>Title</label><input id="mTitle" placeholder="e.g. hello.bch"> + <label>URL</label><input id="mUrl" placeholder="https://example.com/"> + <label>Subtitle (optional)</label><input id="mSub" placeholder="one-line description"> + <label>Badge</label> + <select id="mBadge"> + <option value="">— none —</option> + <option value="on-chain">on-chain</option> + <option value="Sia">Sia</option> + <option value="server">server</option> + <option value="default">(custom)</option> + </select> + <div class="btns"><button id="mCancel" type="button">Cancel</button><button id="mSave" class="primary" type="button">Save</button></div> + </div> + </div> + + <footer>Names resolved from the <b>Bitcoin Cash</b> blockchain — no registrar, no DNS.<br>Theseus, by Silent Mode — a Deviant project.</footer> + +<script> + const $ = (id) => document.getElementById(id); + const esc = (s) => String(s || "").replace(/</g, "&lt;"); + let cards = []; let editIdx = -1; + // Search form: submit → navigate the tab via IPC (main.js applies the + // user's default search engine + regional hints). + $("searchForm").addEventListener("submit", (e) => { + e.preventDefault(); + const q = $("q").value.trim(); if (!q) return; + if (window.home && window.home.navigate) window.home.navigate(q); + // Fallback if the preload didn't bind (shouldn't happen). Startpage + // over DuckDuckGo since that's Theseus's own default engine now. + else window.location.href = "https://www.startpage.com/do/search?q=" + encodeURIComponent(q); + }); + // Populate placeholder with the user's current default engine name so + // "Search the web with <engine>" reflects reality. Silently no-ops if + // the preload or IPC isn't available (dev-open of home.html in a plain + // Chromium tab). Also refreshes on onEngines events would be nice but + // main only broadcasts `engines` to chrome — home only queries at load, + // which is fine because switching engines is a settings action that + // usually implies a reload of the tab shortly after. + if (window.home && window.home.getEngines) { + window.home.getEngines().then((r) => { + const name = r && r.engines && r.engines.find((x) => x.id === r.current)?.name; + if (name) $("q").placeholder = "Search the web with " + name; + }).catch(() => {}); + } + function badgeClass(b) { + // Normalize badge label into a CSS class (spaces + case → dashes / lower). + if (!b) return "b-default"; + return "b-" + String(b).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + } + // Split a domain-style title so ".X" (or ".bch") can render in the acid brand + // color while the rest stays ink. Preserves capitals in the base label. + function styledTitle(t) { + const m = String(t).match(/^(.+?)(\.[A-Za-z]{1,10})$/); + if (!m) return esc(t); + return esc(m[1]) + '<span class="tld">' + esc(m[2]) + '</span>'; + } + function render() { + const g = $("grid"); + g.innerHTML = cards.map((c, i) => { + const badge = c.badge ? `<span class="badge ${badgeClass(c.badge)}">${esc(c.badge)}</span>` : ""; + return `<div class="card" data-i="${i}"> + <div class="edit-actions"> + <button class="ed" title="Edit">✎</button> + <button class="rm" title="Remove">✕</button> + </div> + <div class="n">${styledTitle(c.title)}</div> + <div class="d">${esc(c.sub || c.url)}</div> + <div class="row">${badge}</div> + </div>`; + }).join("") + `<div class="add-card" id="addBtn">+ Add card</div>`; + // Wire clicks: normal-mode = navigate, edit-mode = per-action buttons. + g.querySelectorAll(".card").forEach((el) => { + const i = Number(el.dataset.i); + el.onclick = (e) => { + if (document.body.classList.contains("editing")) { + if (e.target.classList.contains("rm")) return removeCard(i); + if (e.target.classList.contains("ed")) return openModal(i); + return; // click on card body in edit mode = no-op + } + // normal mode: navigate + if (window.home && window.home.navigate) window.home.navigate(cards[i].url); + else window.location.href = cards[i].url; + }; + }); + $("addBtn").onclick = () => openModal(-1); + } + function removeCard(i) { + if (!confirm(`Remove "${cards[i].title}" from Quick links?`)) return; + cards.splice(i, 1); persist(); render(); + } + function openModal(i) { + editIdx = i; + const isNew = i < 0; + $("modalTitle").textContent = isNew ? "Add card" : "Edit card"; + const c = isNew ? { title: "", url: "", sub: "", badge: "" } : cards[i]; + $("mTitle").value = c.title || ""; + $("mUrl").value = c.url || ""; + $("mSub").value = c.sub || ""; + $("mBadge").value = c.badge || ""; + $("modal").classList.add("on"); + setTimeout(() => $("mTitle").focus(), 20); + } + function closeModal() { $("modal").classList.remove("on"); } + $("mCancel").onclick = closeModal; + $("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); }); + document.addEventListener("keydown", (e) => { if (e.key === "Escape" && $("modal").classList.contains("on")) closeModal(); }); + $("mSave").onclick = () => { + const title = $("mTitle").value.trim(); + let url = $("mUrl").value.trim(); + if (!title || !url) { alert("Title and URL are required."); return; } + // Normalize URL: allow bare hosts like "example.com" (add https://). + if (!/^[a-z]+:\/\//i.test(url) && !/^[a-z]+:/.test(url)) url = "https://" + url; + const card = { title, url, sub: $("mSub").value.trim(), badge: $("mBadge").value }; + if (editIdx < 0) cards.push(card); else cards[editIdx] = card; + persist(); render(); closeModal(); + }; + $("editBtn").onclick = () => { + const editing = document.body.classList.toggle("editing"); + $("editBtn").classList.toggle("on", editing); + $("editBtn").textContent = editing ? "Done" : "Edit"; + $("resetBtn").style.display = editing ? "" : "none"; + }; + $("resetBtn").onclick = async () => { + if (!confirm("Restore the default Quick links? Your custom entries will be lost.")) return; + if (window.home) await window.home.resetCards(); + cards = window.home ? (await window.home.getCards()) : []; + render(); + }; + function persist() { if (window.home && window.home.setCards) window.home.setCards(cards); } + // Initial load — via IPC when available, otherwise a hardcoded fallback so + // opening home.html directly (e.g. from disk without Electron) still shows + // something instead of a blank grid. + (async () => { + if (window.home && window.home.getCards) cards = await window.home.getCards(); + if (!cards || !cards.length) cards = [ + { title: "hello.bch", url: "https://hello.bch/", sub: "On the blockchain itself.", badge: "on-chain" }, + { title: "silentmode.bch", url: "https://silentmode.bch/", sub: "The division behind this stack.", badge: "on-chain" }, + ]; + render(); + // If main pushes a refreshed list from the remote URL, adopt it — but + // only when the user hasn't customised (main gates this on its side too). + if (window.home && window.home.onCards) { + window.home.onCards((list) => { + if (Array.isArray(list) && list.length) { cards = list; render(); } + }); + } + })(); +</script> +</body> +</html> diff --git a/lib/hermes.js b/lib/hermes.js new file mode 100644 index 00000000..769a07d3 --- /dev/null +++ b/lib/hermes.js @@ -0,0 +1,176 @@ +// Hermes: name-addressed NIP-17 messaging over Nostr. +// +// This module lives inside Theseus because it depends on `nostr-tools` (a +// non-trivial dep the shared library layer, Argus, shouldn't carry). Loaded +// from main.js via dynamic import — same pattern as password-vault.js. +// +// Layers, sender → wire (NIP-17 + NIP-59): +// 1. rumor = unsigned kind:14 chat event +// 2. seal = kind:13 signed by sender (rumor encrypted to recipient) +// 3. wrap = kind:1059 signed by ephemeral (seal encrypted to recipient) +// +// Only the wrap goes on the wire; the relay sees an ephemeral pubkey, an +// opaque ciphertext, and a #p tag — never the sender or the plaintext. + +import { webcrypto as wc } from "node:crypto"; +import { + finalizeEvent, generateSecretKey, getPublicKey, getEventHash, + verifyEvent, nip19, nip44, +} from "nostr-tools"; + +const enc = new TextEncoder(); + +// ---- key derivation -------------------------------------------------------- +// Same shape password-vault uses: BIP-39 → 64-byte seed → 32-byte purpose root +// via HKDF-SHA256 with `info="silentmode/<purpose>"`. Different purpose string, +// so passwords/0 and messenger/0 are cryptographically disjoint — one leak +// never yields the other. + +/** BIP-39 mnemonic → 64-byte seed (PBKDF2-SHA512, 2048 iters, standard). */ +export async function bip39ToSeed(mnemonic, passphrase = "") { + const km = await wc.subtle.importKey("raw", + enc.encode(String(mnemonic).normalize("NFKD").trim()), + "PBKDF2", false, ["deriveBits"]); + const salt = enc.encode(("mnemonic" + String(passphrase || "")).normalize("NFKD")); + return new Uint8Array(await wc.subtle.deriveBits( + { name: "PBKDF2", salt, iterations: 2048, hash: "SHA-512" }, km, 512)); +} + +/** Seed → 32-byte purpose root via HKDF. Purpose string is public — the + * security comes from the seed, not the info parameter. */ +export async function seedToPurposeRoot(seedBytes, purpose) { + const key = await wc.subtle.importKey("raw", seedBytes, "HKDF", false, ["deriveBits"]); + const info = enc.encode(`silentmode/${purpose}`); + return new Uint8Array(await wc.subtle.deriveBits( + { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256)); +} + +/** BIP-39 mnemonic → { sk, pkHex, npub } — the Nostr identity for messaging. + * Path is "messenger/0" — reserved for Hermes in + * TheseusNavigator/ROADMAP-identity-wallet.md. */ +export async function nostrKeyFromMnemonic(mnemonic, passphrase = "") { + const seed = await bip39ToSeed(mnemonic, passphrase); + const sk = await seedToPurposeRoot(seed, "messenger/0"); + const pkHex = getPublicKey(sk); + return { sk, pkHex, npub: nip19.npubEncode(pkHex) }; +} + +/** Same identity but from an already-derived messenger/0 root (32 bytes) — + * used when the password vault already carries the root and re-entering the + * mnemonic would be redundant. Input is Uint8Array(32) or 64-char hex. */ +export function nostrKeyFromRoot(root) { + const sk = typeof root === "string" + ? Uint8Array.from(Buffer.from(root, "hex")) + : root; + if (!(sk instanceof Uint8Array) || sk.length !== 32) { + throw new Error("nostrKeyFromRoot: expected 32-byte Uint8Array or 64-char hex"); + } + const pkHex = getPublicKey(sk); + return { sk, pkHex, npub: nip19.npubEncode(pkHex) }; +} + +// ---- record parsers -------------------------------------------------------- +// The BNS resolver is transparent: whatever the name owner published in `r` +// lands as entry.records.<key>. Here we validate and normalise the two Hermes +// record types. +// +// np — Nostr pubkey, hex (64) or npub1… +// nr — comma/space-separated list of ws(s):// relay URLs + +const HEX_PUBKEY_RE = /^[0-9a-f]{64}$/i; + +export function parseNpRecord(raw) { + if (typeof raw !== "string" || !raw.trim()) throw new Error("np: empty or missing"); + const v = raw.trim(); + if (HEX_PUBKEY_RE.test(v)) return v.toLowerCase(); + if (v.startsWith("npub1")) { + const d = nip19.decode(v); + if (d.type !== "npub" || typeof d.data !== "string" || !HEX_PUBKEY_RE.test(d.data)) { + throw new Error("np: npub decoded to unexpected shape"); + } + return d.data.toLowerCase(); + } + throw new Error(`np: not hex(64) and not npub1…: ${v.slice(0, 24)}…`); +} + +export function parseNrRecord(raw, { defaults = [] } = {}) { + if (typeof raw !== "string" || !raw.trim()) return defaults.slice(); + const seen = new Set(); const out = []; + for (const tok of raw.split(/[,\s]+/)) { + const url = tok.trim(); + if (!/^wss?:\/\//i.test(url)) continue; + if (seen.has(url)) continue; + seen.add(url); out.push(url); + } + return out.length ? out : defaults.slice(); +} + +/** Extract Hermes recipient info from a resolver Entry. */ +export function parseHermesRecords(entry, { defaultRelays = [] } = {}) { + if (!entry || !entry.records) throw new Error("no on-chain entry for name"); + return { + npPk: parseNpRecord(entry.records.np), + relays: parseNrRecord(entry.records.nr, { defaults: defaultRelays }), + }; +} + +// ---- NIP-17 wrap / unwrap -------------------------------------------------- +const KIND_CHAT = 14; +const KIND_SEAL = 13; +const KIND_GIFT_WRAP = 1059; + +// NIP-59: randomise timestamps up to 2 days in the past so relays can't +// correlate wrap-arrival with real send-time. +const TWO_DAYS = 2 * 24 * 60 * 60; +const jitteredNow = () => + Math.floor(Date.now() / 1000) - Math.floor(Math.random() * TWO_DAYS); + +const convKey = (sk, pkHex) => nip44.v2.utils.getConversationKey(sk, pkHex); +const encrypt = (sk, pkHex, plain) => nip44.v2.encrypt(plain, convKey(sk, pkHex)); +const decrypt = (sk, pkHex, cipher) => nip44.v2.decrypt(cipher, convKey(sk, pkHex)); + +/** Wrap a plaintext chat message from senderSk to recipientPkHex. */ +export function wrapChat({ senderSk, recipientPkHex, text }) { + const senderPkHex = getPublicKey(senderSk); + + const rumor = { + kind: KIND_CHAT, + pubkey: senderPkHex, + created_at: Math.floor(Date.now() / 1000), + tags: [["p", recipientPkHex]], + content: text, + }; + rumor.id = getEventHash(rumor); + + const seal = finalizeEvent({ + kind: KIND_SEAL, + created_at: jitteredNow(), + tags: [], + content: encrypt(senderSk, recipientPkHex, JSON.stringify(rumor)), + }, senderSk); + + const ephemeralSk = generateSecretKey(); + return finalizeEvent({ + kind: KIND_GIFT_WRAP, + created_at: jitteredNow(), + tags: [["p", recipientPkHex]], + content: encrypt(ephemeralSk, recipientPkHex, JSON.stringify(seal)), + }, ephemeralSk); +} + +/** Unwrap a kind:1059 gift wrap for receiverSk. + * Returns { senderPkHex, text, createdAt } or throws on tamper. */ +export function unwrapChat({ receiverSk, wrap }) { + if (wrap.kind !== KIND_GIFT_WRAP) throw new Error(`not a gift wrap (kind ${wrap.kind})`); + if (!verifyEvent(wrap)) throw new Error("gift wrap signature invalid"); + + const seal = JSON.parse(decrypt(receiverSk, wrap.pubkey, wrap.content)); + if (seal.kind !== KIND_SEAL) throw new Error(`inner is not a seal (kind ${seal.kind})`); + if (!verifyEvent(seal)) throw new Error("seal signature invalid"); + + const rumor = JSON.parse(decrypt(receiverSk, seal.pubkey, seal.content)); + if (rumor.kind !== KIND_CHAT) throw new Error(`inner is not chat (kind ${rumor.kind})`); + if (rumor.pubkey !== seal.pubkey) throw new Error("rumor.pubkey does not match seal.pubkey"); + + return { senderPkHex: rumor.pubkey, text: rumor.content, createdAt: rumor.created_at }; +} diff --git a/lib/package.json b/lib/package.json new file mode 100644 index 00000000..a5de1fe0 --- /dev/null +++ b/lib/package.json @@ -0,0 +1,5 @@ +{ + "//": "This subfolder holds ES modules loaded from main.js via dynamic import.", + "//2": "Theseus's root package.json is commonjs; overriding here lets .js files here parse as ESM without renaming to .mjs.", + "type": "module" +} diff --git a/link-status-preload.js b/link-status-preload.js new file mode 100644 index 00000000..c19ecd6c --- /dev/null +++ b/link-status-preload.js @@ -0,0 +1,5 @@ +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("linkstatus", { + onUrl: (cb) => ipcRenderer.on("link-status-url", (_e, url) => cb(url)), + resize: (w, h) => ipcRenderer.invoke("link-status-resize", w, h), +}); diff --git a/link-status.html b/link-status.html new file mode 100644 index 00000000..63e7b2b4 --- /dev/null +++ b/link-status.html @@ -0,0 +1,31 @@ +<!doctype html> +<html><head><meta charset="utf-8"> +<style> + :root { color-scheme: light dark; font-family: system-ui, sans-serif; } + html, body { margin: 0; background: transparent; overflow: hidden; } + .pill { background: #1c222c; border: 1px solid #ffffff26; border-top: none; border-left: none; + border-radius: 0 8px 0 0; box-shadow: 0 -2px 12px #0006; + color: #b9c2d0; font-size: 11.5px; padding: 4px 10px 4px 8px; + display: inline-block; max-width: 100%; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; direction: ltr; } + /* Off-screen twin used only to measure the URL's natural width. The + visible pill is capped at 100% of this view, so measuring IT would + just report whatever width the view already has — the pill could + never grow past its first 100px and long links got cut short. */ + .measure { position: absolute; left: 0; top: 0; visibility: hidden; max-width: none; } + @media (prefers-color-scheme: light) { + .pill { background: #ffffff; color: #3c4453; border-color: rgba(0,0,0,.15); } + } +</style></head> +<body><span class="pill" id="p"></span><span class="pill measure" id="m" aria-hidden="true"></span> +<script> + const p = document.getElementById("p"); + const m = document.getElementById("m"); + window.linkstatus.onUrl((url) => { + const s = String(url || ""); + p.textContent = s; m.textContent = s; + // Ask main for the natural width; main clamps to the tab area and the + // visible pill ellipsises whatever doesn't fit. + requestAnimationFrame(() => { try { window.linkstatus.resize(m.offsetWidth + 2, p.offsetHeight + 2); } catch (e) {} }); + }); +</script></body></html> diff --git a/main.js b/main.js new file mode 100644 index 00000000..66a46e2c --- /dev/null +++ b/main.js @@ -0,0 +1,5145 @@ +// Theseus Navigator — Electron main process. +// Native .bch via a custom `bns://` protocol: resolves names with the shared +// portable resolver (Argus/resolver-web.js) and serves content itself (on-chain +// h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home +// page, and optional Tor onion routing. No system daemon; the app is the trust +// boundary. +const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog } = require("electron"); +const path = require("path"); +const http = require("http"); +const https = require("https"); +const tls = require("tls"); +const { spawn } = require("child_process"); +const fs = require("fs"); +const WebSocket = require("ws"); + +// Packaged builds ship the resolver and tor/ as unpacked resources (they can't +// run from inside app.asar); dev runs read them from the repo. +const RES_DIR = app.isPackaged ? process.resourcesPath : __dirname; +// THESEUS_USER_DATA points a dev run at a throwaway profile so it never +// touches (or races) the real install's settings, vault and add-ons. +if (process.env.THESEUS_USER_DATA) { + try { app.setPath("userData", path.resolve(process.env.THESEUS_USER_DATA)); } catch (e) { console.warn("userData override failed:", e?.message); } +} +// Bundled as .mjs so it loads as ES module in the packaged app (no package.json +// sits next to it in resources/, so a bare .js would be treated as CommonJS and +// fail on `export`). Dev reads the engine copy directly (Argus is type:module). +const RESOLVER = app.isPackaged + ? path.join(RES_DIR, "resolver-web.mjs") + : path.join(__dirname, "..", "Argus", "src", "lib", "resolver-web.js"); +// Password vault — same .mjs-in-resources pattern as the resolver. +const VAULT_MOD = app.isPackaged + ? path.join(RES_DIR, "password-vault.mjs") + : path.join(__dirname, "..", "Argus", "src", "lib", "password-vault.js"); +let vaultLib; +async function loadVaultLib() { + if (!vaultLib) vaultLib = await import(`file://${VAULT_MOD.replace(/\\/g, "/")}`); + return vaultLib; +} +// Hermes messaging module — same .mjs-in-resources / .js-in-dev pattern. +const HERMES_MOD = app.isPackaged + ? path.join(RES_DIR, "lib", "hermes.mjs") + : path.join(__dirname, "lib", "hermes.js"); +let hermesLib; +async function loadHermesLib() { + if (!hermesLib) hermesLib = await import(`file://${HERMES_MOD.replace(/\\/g, "/")}`); + return hermesLib; +} +// Built-in engines. Users can also add their own (settings.customEngines, +// each { id, name, url } where the url contains "%s" for the query). +// Catalog of built-in engines (users pick which to enable + can add their own). +// fav = the domain to load a real favicon from; sym = emoji fallback. +// kind = "search" (traditional search engines) or "llm" (AI answer engines). +// tier = "catalog" (curated first-class options shown in the primary Add +// panel) or "extra" (a wider bank hidden behind a filter box for +// discovery). Missing tier defaults to "catalog". +// URL routing is identical for all — kind and tier are display-only grouping. +const SEARCH_ENGINES = { + duckduckgo: { kind: "search", tier: "catalog", name: "DuckDuckGo", sym: "🦆", fav: "duckduckgo.com", url: (q) => "https://duckduckgo.com/?q=" + encodeURIComponent(q) }, + google: { kind: "search", tier: "catalog", name: "Google", sym: "🔵", fav: "www.google.com", url: (q, h = {}) => `https://www.google.com/search?q=${encodeURIComponent(q)}&hl=${h.hl || "en"}&gl=${h.gl || "us"}&pws=0` }, + brave: { kind: "search", tier: "catalog", name: "Brave", sym: "🦁", fav: "search.brave.com", url: (q) => "https://search.brave.com/search?q=" + encodeURIComponent(q) }, + bing: { kind: "search", tier: "catalog", name: "Bing", sym: "🔎", fav: "www.bing.com", url: (q) => "https://www.bing.com/search?q=" + encodeURIComponent(q) }, + startpage: { kind: "search", tier: "catalog", name: "Startpage", sym: "🛡️", fav: "www.startpage.com", url: (q) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent(q) }, + yandex: { kind: "search", tier: "catalog", name: "Yandex", sym: "🔴", fav: "yandex.com", url: (q) => "https://yandex.com/search/?text=" + encodeURIComponent(q) }, + ecosia: { kind: "search", tier: "catalog", name: "Ecosia", sym: "🌱", fav: "www.ecosia.org", url: (q) => "https://www.ecosia.org/search?q=" + encodeURIComponent(q) }, + mojeek: { kind: "search", tier: "catalog", name: "Mojeek", sym: "🧭", fav: "www.mojeek.com", url: (q) => "https://www.mojeek.com/search?q=" + encodeURIComponent(q) }, + // SearXNG is federated (dozens of public instances at searx.space); any single + // default becomes stale as instances rate-limit / die (searx.be is anti-bot-locked). + // Users who want SearXNG add their preferred instance via the custom URL form. + wikipedia: { kind: "search", tier: "catalog", name: "Wikipedia", sym: "📖", fav: "en.wikipedia.org", url: (q) => "https://en.wikipedia.org/wiki/Special:Search?search=" + encodeURIComponent(q) }, + // AI / LLM answer engines that ANSWER the URL query without requiring a login. + // ChatGPT / Claude / You.com's youchat all bounce to sign-in before running + // ?q=, so they'd fail silently as a "search engine" — omitted deliberately. + perplexity: { kind: "llm", tier: "catalog", name: "Perplexity", sym: "🧠", fav: "www.perplexity.ai", url: (q) => "https://www.perplexity.ai/search?q=" + encodeURIComponent(q) }, + phind: { kind: "llm", tier: "catalog", name: "Phind", sym: "🧑‍💻", fav: "www.phind.com", url: (q) => "https://www.phind.com/search?q=" + encodeURIComponent(q) }, + // ---- Extras: wider bank, discoverable via the Search filter box in Settings. + // These are known-working engines that don't require sign-in on ?q= but aren't + // first-class enough to sit in the primary catalog. Keep the list vetted — if + // an entry starts bouncing to a login gate, drop it (same rule as the LLMs). + marginalia: { kind: "search", tier: "extra", name: "Marginalia", sym: "🕸", fav: "search.marginalia.nu", url: (q) => "https://search.marginalia.nu/search?query=" + encodeURIComponent(q) }, + stract: { kind: "search", tier: "extra", name: "Stract", sym: "🧵", fav: "stract.com", url: (q) => "https://stract.com/search?q=" + encodeURIComponent(q) }, + yep: { kind: "search", tier: "extra", name: "Yep", sym: "✳️", fav: "yep.com", url: (q) => "https://yep.com/web?q=" + encodeURIComponent(q) }, + presearch: { kind: "search", tier: "extra", name: "Presearch", sym: "🔷", fav: "presearch.com", url: (q) => "https://presearch.com/search?q=" + encodeURIComponent(q) }, + metager: { kind: "search", tier: "extra", name: "MetaGer", sym: "🇩🇪", fav: "metager.org", url: (q) => "https://metager.org/meta/meta.ger3?eingabe=" + encodeURIComponent(q) }, + qwant: { kind: "search", tier: "extra", name: "Qwant", sym: "🇫🇷", fav: "www.qwant.com", url: (q) => "https://www.qwant.com/?q=" + encodeURIComponent(q) }, + swisscows: { kind: "search", tier: "extra", name: "Swisscows", sym: "🐄", fav: "swisscows.com", url: (q) => "https://swisscows.com/en/web?query=" + encodeURIComponent(q) }, + naver: { kind: "search", tier: "extra", name: "Naver", sym: "🇰🇷", fav: "www.naver.com", url: (q) => "https://search.naver.com/search.naver?query=" + encodeURIComponent(q) }, + baidu: { kind: "search", tier: "extra", name: "Baidu", sym: "🇨🇳", fav: "www.baidu.com", url: (q) => "https://www.baidu.com/s?wd=" + encodeURIComponent(q) }, +}; +// Engines enabled by default (shown in the toolbar dropdown). The rest are in the +// catalog and can be turned on from Settings. Custom + detected engines are always on. +const DEFAULT_ENABLED = ["startpage", "duckduckgo", "google", "brave", "bing"]; +// DuckDuckGo's icon service reliably returns a favicon for ANY domain from one +// privacy-respecting host — far more robust than guessing /favicon.ico per site. +// Search-engine favicon source. DuckDuckGo's icons.duckduckgo.com/ip3/… +// service was returning inconsistent results (Brave, Bing, Yandex etc. +// came back 404 → the settings row fell through to an emoji). Google's +// /s2/favicons service is materially more reliable, returns a real 32×32 +// PNG for essentially every host, and doesn't require login. Kept as a +// single point so the fallback source can be swapped again in one place. +const faviconUrl = (domain) => (domain ? `https://www.google.com/s2/favicons?domain=${domain}&sz=32` : null); +function customFavicon(url) { try { return faviconUrl(new URL(String(url).replace("%s", "x")).hostname); } catch { return null; } } +function isEnabled(id) { return (settings.enabledEngines || DEFAULT_ENABLED).includes(id); } +// Two-tier state: an engine is INSTALLED if it's in the user's Additional +// list (visible in Settings), and ENABLED if it's currently toggled on +// (visible in the toolbar dropdown). Toggle flips enabled only; right-click +// "Remove from list" is what actually removes an installed engine. +function isInstalled(id) { + if ((settings.customEngines || []).some((e) => e.id === id)) return true; // customs are always installed + return (settings.installedEngines || DEFAULT_ENABLED).includes(id); +} +function allEngines() { + const list = Object.entries(SEARCH_ENGINES).map(([id, e]) => + ({ id, name: e.name, sym: e.sym, favicon: faviconUrl(e.fav), kind: e.kind || "search", tier: e.tier || "catalog", builtin: true, installed: isInstalled(id), enabled: isEnabled(id) })); + for (const c of settings.customEngines || []) + list.push({ id: c.id, name: c.name, sym: c.sym || "🔍", favicon: customFavicon(c.url), kind: c.kind || "search", tier: "custom", builtin: false, installed: true, enabled: isEnabled(c.id) }); + // Apply the user's custom order; ids not in engineOrder keep their natural order (stable sort). + const order = settings.engineOrder || []; + return list.slice().sort((a, b) => { + const ia = order.indexOf(a.id), ib = order.indexOf(b.id); + if (ia === -1 && ib === -1) return 0; + if (ia === -1) return 1; + if (ib === -1) return -1; + return ia - ib; + }); +} +function enabledEnginesList() { return allEngines().filter((e) => e.enabled); } +// Region → 2-letter country code, for engines that accept a `gl`-style hint +// (Google's the notable one — without it Google may bounce a raw ?q= URL to +// a consent redirect or the region-detect start page instead of results). +const REGION_TO_COUNTRY = { + europe: "de", asia: "jp", north_america: "us", south_america: "br", + africa: "ke", middle_east: "ae", australia: "au", +}; +function searchHints() { + const loc = effLocale(); // e.g. "en-US" (or null → show real) + const region = settings.locationMode === "spoof" ? settings.locationRegion : null; + return { + hl: (loc || app.getLocale() || "en").split("-")[0], + gl: REGION_TO_COUNTRY[region] || (loc && loc.split("-")[1]?.toLowerCase()) || "us", + }; +} +function engineUrl(id, q) { + const h = searchHints(); + if (SEARCH_ENGINES[id]) return SEARCH_ENGINES[id].url(q, h); + const c = (settings.customEngines || []).find((e) => e.id === id); + return c ? c.url.replace(/%s/g, encodeURIComponent(q)) : SEARCH_ENGINES.duckduckgo.url(q, h); +} +const SEARCH = (q) => engineUrl(settings.searchEngine, q); +// Public content relay (secret-free): serves s3/ip/h/u without shipping keys. +const GATEWAY = "https://navigate.st"; + +// ---- BNS name detection (multi-TLD) -------------------------------------- +// Theseus is a BNS-native browser: BCNR is the priority registry for EVERY +// dotted host, regardless of TLD. The engine (resolver-web.js) resolves any +// <label>.<tld> from the BCNR beacon. Flow: +// 1. User navigates to `<label>.<tld>` (address bar or link click) +// 2. Theseus asks BCNR first +// 3. If BCNR has a record — serve it (on-chain h, Sia s3, direct ip, redirect u) +// 4. If BCNR NXDOMAINs or is unreachable — fall through to the real web +// (https://<host><path>), so users aren't locked out of the clearnet +// when the chain is down or the name isn't registered. +// Non-BNS-eligible hosts (bare IPv4/IPv6, localhost, single-label hostnames, +// non-http schemes) bypass BCNR and load directly. +// The former NATIVE/DUAL sets are gone — Theseus doesn't privilege ICANN. +const REGISTRY = "BCNR"; // user-facing registry label (Bitcoin Cash Name Registry) +const tldOf = (host) => { + const h = String(host).toLowerCase().replace(/\.$/, ""); + const dot = h.lastIndexOf("."); + return dot < 0 ? null : h.slice(dot + 1); +}; +// Any dotted host that isn't an IP or localhost is a BCNR candidate. +const isBnsHost = (host) => { + if (!host) return false; + const h = String(host).toLowerCase().replace(/\.$/, ""); + if (h === "localhost" || h.startsWith("localhost:")) return false; + if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/.test(h)) return false; // IPv4[:port] + if (h.startsWith("[")) return false; // IPv6 literal + const dot = h.lastIndexOf("."); + return dot > 0 && dot < h.length - 1; // has a real TLD +}; +// Kept as aliases so external callers (tests, module.exports) don't break. +const nativeTld = (host) => isBnsHost(host) ? tldOf(host) : null; +const dualTld = () => null; // dual-priority mode is gone — no ICANN-first TLDs +const registryOf = (_tld) => REGISTRY; + +// Address-bar heuristic: is this input a URL/hostname, or a search query? Mirrors +// what mainstream browsers do — anything with whitespace, or a bare word with no +// dot, is a search; a scheme, an IP, localhost, or a dotted host is a URL. +function looksLikeUrl(q) { + if (!q) return false; + if (/\s/.test(q)) return false; // has whitespace -> search + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(q)) return true; // scheme://… + if (/^localhost(:\d+)?([/?#]|$)/i.test(q)) return true; // localhost[:port] + if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?([/?#]|$)/.test(q)) return true; // IPv4[:port] + const host = q.split(/[/?#]/)[0]; // strip path/query/frag + return host.includes(".") && !host.startsWith(".") && !host.endsWith("."); // dotted host +} + +// ---- persistent user settings (userData/settings.json) ---- +const SETTINGS_DEFAULTS = { + webrtcMode: "public_only", // WebRTC IP policy: default | public_only | public_private | disable_udp + blockCamera: true, // deny camera by default (also hides camera labels from fingerprinting) + blockMicrophone: true, // deny microphone by default (also hides mic labels) + hideMediaDevices: true, // blank all enumerateDevices info (esp. speaker labels/ids) like Firefox + restoreSession: true, // reopen last session's tabs on launch + backgroundThrottle: true, // throttle inactive tabs / the window when unfocused + // Storage retention — nothing persists by default. Auto-clear on quit + // means a session leaves no trace on disk unless the user opts in per-type. + clearCookiesOnQuit: true, // drop cookies + logins + saved-form data + clearCacheOnQuit: true, // drop HTTP cache (images, scripts, etc.) + clearHistoryOnQuit: true, // drop navigation history + saved tabs + clearStorageOnQuit: true, // drop localStorage / IndexedDB / service workers / cache API + // Anti-fingerprinting — each: show (real) | hide (neutral) | spoof (auto decoy) | manual (user value) + timezoneMode: "show", timezoneValue: "Europe/Berlin", // IANA zone for manual + languageMode: "show", languageSpoof: "en-US", languageValue: "en-US", // spoof = top-10 pick, manual = free text + locationMode: "hide", locationRegion: "europe", locationLat: "40.7128", locationLon: "-74.0060", // spoof by region, or manual coords + searchEngine: "startpage",// default search engine (built-in id or a custom id) + installedEngines: DEFAULT_ENABLED.slice(), // built-in engines added to the user's list (visible in Settings) + enabledEngines: DEFAULT_ENABLED.slice(), // subset that's currently toggled on (shown in the toolbar dropdown) + engineOrder: [], // user-defined display order of engine ids (empty = natural) + customEngines: [], // user-added: [{ id, name, url-with-%s }] + theme: "dark", // dark | light | system — drives prefers-color-scheme in all views + // BCNR/ICANN collision policy (see SilentMode/Argus/DESIGN-collision-modes.md): + // "bcnr-first" — BCNR wins collisions (default). + // "icann-first" — ICANN wins collisions; BCNR fills gaps. + // "soft" — "Open with…" prompt on collision, remembered per name/TLD. + collisionPolicy: "bcnr-first", + // Add-on framework: ids the user has explicitly turned off. Installed but + // disabled add-ons are still discovered — they just never activate. + disabledAddons: [], + // Sidebar width in px. Adjusted by dragging the grip on the panel's left + // edge; persisted across launches. Clamped to [200, 800] on load. + sidebarWidth: 340, + // Toolbar bar sizes. The URL bar is flex:1 by default so it eats all + // remaining space; `compact`/`medium` cap it so the extension dock has + // room to grow. The search box is fixed-width; hidden removes it. + urlBarSize: "wide", // compact | medium | wide (default) + searchBoxSize: "normal", // hidden | compact | normal (default) | wide + // Drag-set widths in pixels. Non-zero → override the corresponding size + // preset; zero/null → follow the preset. Set by the toolbar drag handles. + urlBarWidthPx: 0, + searchBoxWidthPx: 0, + // DevTools dock position. "bottom" (default) opens the console under the + // tab, matching Chrome's own default; "sidebar" docks it in the right + // sidebar, replacing the add-on sidebar while it's open; "two-sidebars" + // opens the console AND lets the add-on sidebar stay visible on its own + // right-side dock — Electron's mode:right takes over the right edge, so + // "two-sidebars" is drawn as mode:right and the add-on sidebar is not + // forced closed; the two share the right area (add-on sidebar keeps its + // width, DevTools takes what's left). + devToolsDock: "bottom", // bottom | sidebar | two-sidebars +}; +// Applying the theme via nativeTheme.themeSource makes prefers-color-scheme update +// in every renderer (chrome, settings, popover, page views) with no per-view IPC. +function applyTheme() { + try { nativeTheme.themeSource = ["dark", "light", "system"].includes(settings.theme) ? settings.theme : "dark"; } catch {} +} +// Auto decoys used by "spoof" mode (plausible but not the user's real values). +const SPOOF = { tz: "America/New_York", lang: "en-US", lat: 40.7128, lon: -74.0060 }; +let settings = { ...SETTINGS_DEFAULTS }; +const settingsFile = () => path.join(app.getPath("userData"), "settings.json"); +function loadSettings() { + try { if (fs.existsSync(settingsFile())) settings = { ...SETTINGS_DEFAULTS, ...JSON.parse(fs.readFileSync(settingsFile(), "utf8")) }; } + catch (e) { console.error("settings load failed:", e.message); } + // Restore the persisted sidebar width so the first-open of a session + // uses whatever the user left it at last time. + const w = Number(settings.sidebarWidth) || SIDEBAR_W_DEFAULT; + sidebarW = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, w)); + // Normalize the search-engine state so the toolbar picker and Settings tab + // can never disagree. Two invariants: + // 1. Every enabledEngines id must also be in installedEngines. If a user + // manually edited settings.json (or an upgrade left the two out of + // sync), we add the missing installed rows now. + // 2. settings.searchEngine must be an enabled engine. If the default from + // SETTINGS_DEFAULTS points at an id the user has disabled, fall back + // to the first currently-enabled engine. + try { + const enabled = Array.isArray(settings.enabledEngines) ? settings.enabledEngines : DEFAULT_ENABLED.slice(); + const installed = Array.isArray(settings.installedEngines) ? settings.installedEngines : DEFAULT_ENABLED.slice(); + settings.installedEngines = [...new Set([...installed, ...enabled])]; + if (!enabled.includes(settings.searchEngine)) { + settings.searchEngine = enabled[0] || DEFAULT_ENABLED[0]; + } + } catch (e) { console.warn("engine normalize:", e?.message); } +} +function saveSettings() { + try { fs.writeFileSync(settingsFile(), JSON.stringify(settings, null, 2)); } catch (e) { console.error("settings save failed:", e.message); } +} +// ---- bookmarks / saved pages (userData/bookmarks.json) ---- +let bookmarks = []; +const bookmarksFile = () => path.join(app.getPath("userData"), "bookmarks.json"); +function loadBookmarks() { try { if (fs.existsSync(bookmarksFile())) bookmarks = JSON.parse(fs.readFileSync(bookmarksFile(), "utf8")); } catch (e) { console.error("bookmarks load failed:", e.message); } } +function saveBookmarks() { try { fs.writeFileSync(bookmarksFile(), JSON.stringify(bookmarks, null, 2)); } catch (e) { console.error("bookmarks save failed:", e.message); } } +function emitBookmarks() { try { chrome?.webContents.send("bookmarks", bookmarks); } catch {} } + +// ---- in-app update check (cheap) ------------------------------------------ +// Fetch the releases manifest at startup + every 6h. If it names a Theseus +// version newer than ours, surface a chip in the toolbar with a link to the +// download URL. No auto-install, no signing check — the on-chain pointer at +// releases.silentmode.bch publishes the SAME manifest URL, so users who want +// to verify integrity can compare the manifest hash to what BCNR returns. +const UPDATE_MANIFEST_URL = "https://dl.silentmode.st/releases-manifest.json"; +const UPDATE_DOWNLOAD_BASE = "https://dl.silentmode.st/"; +let updateAvailable = null; // { version, setupUrl, portableUrl, setupHash, portableHash, date } +let updateDismissedThisSession = false; +// Simple string version compare — "0.0.4" > "0.0.3" and "0.10.0" > "0.9.9". +function versionIsNewer(candidate, current) { + const a = String(candidate || "").split(".").map((n) => parseInt(n, 10) || 0); + const b = String(current || "").split(".").map((n) => parseInt(n, 10) || 0); + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i++) { + const x = a[i] || 0, y = b[i] || 0; + if (x > y) return true; + if (x < y) return false; + } + return false; // equal → not newer +} +async function checkForUpdate() { + try { + const controller = new AbortController(); + const to = setTimeout(() => controller.abort(), 5000); + const r = await fetch(UPDATE_MANIFEST_URL, { signal: controller.signal, cache: "no-store" }); + clearTimeout(to); + if (!r.ok) return; + const manifest = await r.json(); + const rel = (manifest.releases || []).find((x) => x.id === "theseus-navigator"); + if (!rel || !rel.version) return; + if (!versionIsNewer(rel.version, app.getVersion())) { + // Same version or older — nothing to offer. Clear any stale state so the + // chip disappears after the user has updated + relaunched. + if (updateAvailable) { updateAvailable = null; updateDownloadState = "idle"; updateDownloadPath = null; emitUpdateAvailable(); } + return; + } + const files = rel.files || {}; + const setupFile = Object.keys(files).find((k) => /Setup/i.test(k)); + const portableFile = Object.keys(files).find((k) => /portable/i.test(k)); + const nextAvailable = { + version: rel.version, + date: rel.date || "", + setupUrl: setupFile ? UPDATE_DOWNLOAD_BASE + setupFile : null, + portableUrl: portableFile ? UPDATE_DOWNLOAD_BASE + portableFile : null, + setupHash: setupFile ? files[setupFile] : null, + portableHash: portableFile ? files[portableFile] : null, + }; + const versionChanged = !updateAvailable || updateAvailable.version !== nextAvailable.version; + updateAvailable = nextAvailable; + if (versionChanged) { + // New candidate — reset any prior download state and kick off a fresh + // silent background fetch so the chip lands as "ready to install". + updateDownloadState = "idle"; + updateDownloadPath = null; + autoDownloadUpdate(); + } + emitUpdateAvailable(); + } catch { /* offline / manifest unreachable — silent */ } +} +// Silent background pre-download of the update installer. The user never +// has to click Download — clicking the chip goes straight to Install. +// State machine: idle -> downloading -> ready | failed. +let updateDownloadState = "idle"; +let updateDownloadPath = null; // path on disk once "ready" +let updateDownloadReceived = 0; // bytes so far +let updateDownloadTotal = 0; // total bytes +function autoDownloadUpdate() { + if (!updateAvailable || !updateAvailable.setupUrl) return; + if (updateDownloadState !== "idle") return; + updateDownloadState = "downloading"; + updateDownloadReceived = 0; + updateDownloadTotal = 0; + try { + session.defaultSession.downloadURL(updateAvailable.setupUrl); + console.log(`[update] silent fetch started: ${updateAvailable.setupUrl}`); + } catch (e) { + console.warn("update prefetch failed:", e?.message); + updateDownloadState = "failed"; + } + emitUpdateAvailable(); +} +function emitUpdateAvailable() { + const base = (updateDismissedThisSession || !updateAvailable) ? null : updateAvailable; + const payload = base ? { + ...base, + downloadState: updateDownloadState, // idle | downloading | ready | failed + downloadReceived: updateDownloadReceived, + downloadTotal: updateDownloadTotal, + } : null; + try { chrome?.webContents.send("update-available", payload); } catch {} +} + +// ---- home page editable cards (userData/home-cards.json) ------------------ +// Rendered by home.html as the "quick links" grid on the new-tab page. User +// can add/edit/remove via the page's edit mode. First-run seed = the classic +// Silent Mode showcase (hello.bch, theseus.bch, silentmode.bch, etc.). +const DEFAULT_HOME_CARDS = [ + { title: "hello.bch", url: "https://hello.bch/", sub: "A small page on the blockchain itself.", badge: "on-chain" }, + { title: "siatest.bch", url: "https://siatest.bch/", sub: "A page with no server, backed by Sia.", badge: "Sia" }, + { title: "SilentMode.X", url: "https://silentmode.x/", sub: "Infrastructure development for a decentralized web.", badge: "Infrastructure" }, + { title: "Theseus.X", url: "https://theseus.x/", sub: "The Web Navigator — this browser's own address.", badge: "Navigator" }, + { title: "Sirius.X", url: "https://sirius.x/", sub: "Register and manage BCDN names.", badge: "Registrar" }, + { title: "Hephaestus.X", url: "https://hephaestus.x/", sub: "The forge — Silent Mode's code host.", badge: "Code host" }, + { title: "Prometheus.X", url: "https://prometheus.x/", sub: "Decentralized App Marketplace.", badge: "App store" }, + { title: "Helios.X", url: "https://helios.x/", sub: "Search engine for the decentralized web (in design).", badge: "Search" }, + { title: "Hermes.X", url: "https://hermes.x/", sub: "Messaging — end-to-end encrypted over Nostr.", badge: "Messaging" }, +]; +// User's local edits win over everything else — that's the whole point of +// the edit mode. Remote pull only feeds the "defaults" tier so brand copy +// changes reach installs without a browser release. +const homeCardsFile = () => path.join(app.getPath("userData"), "home-cards.json"); +const homeCardsRemoteCache = () => path.join(app.getPath("userData"), "home-cards-remote.json"); +// The canonical remote card list is served from silentmode.st (and mirrored +// on silentmode.bch via Sia). Editing that file updates every install on +// its next launch — no reinstall required. +const HOME_CARDS_URL = "https://dl.silentmode.st/home-cards.json"; +const HOME_CARDS_REFRESH_MS = 6 * 60 * 60 * 1000; // every 6h +function loadHomeCards() { + // Priority: user's local edits > cached remote copy > code defaults. + try { + if (fs.existsSync(homeCardsFile())) { + const v = JSON.parse(fs.readFileSync(homeCardsFile(), "utf8")); + if (Array.isArray(v) && v.length) return v; + } + } catch (e) { console.error("home cards (user) load failed:", e.message); } + try { + if (fs.existsSync(homeCardsRemoteCache())) { + const v = JSON.parse(fs.readFileSync(homeCardsRemoteCache(), "utf8")); + if (Array.isArray(v) && v.length) return v; + } + } catch (e) { console.error("home cards (remote-cache) load failed:", e.message); } + return DEFAULT_HOME_CARDS.slice(); +} +function saveHomeCards(cards) { + try { fs.writeFileSync(homeCardsFile(), JSON.stringify(cards, null, 2)); } + catch (e) { console.error("home cards save failed:", e.message); } +} +// Fetch the canonical home-cards.json into the remote cache. Silent on any +// error (no network, 404, bad JSON, etc.) — the cache stays as-is and the +// user sees either their last cached set or the built-in defaults. +async function refreshRemoteHomeCards() { + try { + const controller = new AbortController(); + const to = setTimeout(() => controller.abort(), 6000); + const r = await fetch(HOME_CARDS_URL, { signal: controller.signal, cache: "no-store" }); + clearTimeout(to); + if (!r.ok) return; + const list = await r.json(); + if (!Array.isArray(list) || list.length === 0) return; + // Basic sanity: every entry must be an object with a string title + url. + const clean = list.filter((c) => c && typeof c.title === "string" && typeof c.url === "string"); + if (!clean.length) return; + fs.writeFileSync(homeCardsRemoteCache(), JSON.stringify(clean, null, 2)); + // Only push into open home tabs if the user hasn't overridden — their + // edits stay put. + if (!fs.existsSync(homeCardsFile())) { + for (const t of tabs) { + try { t.view.webContents.send("home-cards", clean); } catch {} + } + } + console.log(`[home-cards] refreshed from ${HOME_CARDS_URL}: ${clean.length} cards`); + } catch (e) { /* silent */ } +} +// Sender validation — only accept IPC from our own home.html file:// URL. +// Rejects third-party pages that see the API shape via the preload. +function isHomePageSender(sender) { + try { + const u = sender.getURL() || ""; + return u.startsWith("file://") && /home\.html(?:$|\?|#)/i.test(u); + } catch { return false; } +} +// Same origin-gating pattern for the branded error page. +function isErrorPageSender(sender) { + try { + const u = sender.getURL() || ""; + return u.startsWith("file://") && /error\.html(?:$|\?|#)/i.test(u); + } catch { return false; } +} + +// ---- address-bar history (userData/history.json) -------------------------- +// Suggestions dropdown source. Deduped LRU capped at HISTORY_CAP entries. +// Cleared on quit when settings.clearHistoryOnQuit is on (default). +const HISTORY_CAP = 500; +let history = []; // [{ url, title, ts }] +let historySaveTimer = null; +const historyFile = () => path.join(app.getPath("userData"), "history.json"); +function loadHistory() { try { if (fs.existsSync(historyFile())) history = JSON.parse(fs.readFileSync(historyFile(), "utf8")); } catch (e) { console.error("history load failed:", e.message); history = []; } } +function saveHistoryDebounced() { + clearTimeout(historySaveTimer); + historySaveTimer = setTimeout(() => { + try { fs.writeFileSync(historyFile(), JSON.stringify(history)); } catch (e) { console.error("history save failed:", e.message); } + }, 800); +} +function historyAdd(url, title) { + if (!url) return; + const clean = String(url).trim(); + // Skip internal / non-http(s) URLs — never useful in address suggestions. + if (!/^https?:\/\//i.test(clean) && !/^bns:\/\//i.test(clean)) return; + // LRU: remove any existing entry for this URL, unshift a fresh one to the top. + const i = history.findIndex((h) => h.url === clean); + if (i >= 0) history.splice(i, 1); + history.unshift({ url: clean, title: String(title || "").slice(0, 200), ts: Date.now() }); + if (history.length > HISTORY_CAP) history.length = HISTORY_CAP; + saveHistoryDebounced(); +} +// Rank matches: prefix-of-host wins, then prefix-of-URL, then contains, +// then recency. Cap results — the dropdown wants at most ~8 entries. +function historySearch(query, cap = 8) { + const q = String(query || "").trim().toLowerCase(); + if (!q) return history.slice(0, cap); + const scored = []; + for (const h of history) { + const u = h.url.toLowerCase(); + const host = u.replace(/^https?:\/\//, "").split(/[/?#]/)[0]; + let score; + if (host.startsWith(q)) score = 100; + else if (u.startsWith(q)) score = 80; + else if (host.includes(q)) score = 60; + else if (u.includes(q)) score = 40; + else if ((h.title || "").toLowerCase().includes(q)) score = 20; + else continue; + scored.push({ h, score }); + } + scored.sort((a, b) => (b.score - a.score) || (b.h.ts - a.h.ts)); + return scored.slice(0, cap).map((s) => s.h); +} + +// ---- BCNR/ICANN collisions (userData/collisions.json) -------------------- +// Per-name / per-TLD "always use X" overrides for soft "Open with…" mode. +// See D:\Dev\SilentMode\Argus\DESIGN-collision-modes.md for the full model. +let collisions = { byName: {}, byTld: {} }; +const collisionsFile = () => path.join(app.getPath("userData"), "collisions.json"); +function loadCollisions() { + try { if (fs.existsSync(collisionsFile())) collisions = { byName: {}, byTld: {}, ...JSON.parse(fs.readFileSync(collisionsFile(), "utf8")) }; } + catch (e) { console.error("collisions load failed:", e.message); } +} +function saveCollisions() { + try { fs.writeFileSync(collisionsFile(), JSON.stringify(collisions, null, 2)); } catch (e) { console.error("collisions save failed:", e.message); } +} +// per-name > per-TLD > hard policy. Returns "bcnr" | "icann" | null (null = ask in soft). +function overrideFor(host, tld) { + const n = String(host).toLowerCase(); + const t = String(tld || "").toLowerCase(); + if (collisions.byName[n]) return collisions.byName[n]; + if (collisions.byTld[t]) return collisions.byTld[t]; + return null; +} +// Cached BCNR-native TLD list from tlds.bch. Registered names under a native TLD +// are NOT collision candidates (whole TLD belongs to BCNR); non-native = might collide. +let bcnrTlds = ["bch"]; +function isBcnrNativeTld(tld) { return bcnrTlds.includes(String(tld || "").toLowerCase()); } +function refreshBcnrTlds(index) { + try { + const raw = index?.get?.("tlds.bch")?.records?.tlds; + if (typeof raw === "string") { + const list = raw.split(/\s+/).filter(Boolean).map((s) => s.toLowerCase()); + if (list.length) bcnrTlds = list; + } + } catch {} +} + +// (The old modal-based collisionPromptOnce() was removed 2026-08-02 — the +// prompt is now an in-tab full-page interstitial loaded from collision.html, +// wired via the bns://collision-choose/ handler in serveBns().) +function rememberCollision(host, tld, choice, remember) { + if (choice !== "bcnr" && choice !== "icann") return; + if (remember === "name") collisions.byName[String(host).toLowerCase()] = choice; + else if (remember === "tld") collisions.byTld[String(tld || "").toLowerCase()] = choice; + if (remember !== "no") saveCollisions(); +} + +function emitEngines() { + try { chrome?.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine }); } catch {} + if (epVisible) try { enginePicker?.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null }); } catch {} +} +// WebRTC IP-handling policy — the same control the "WebRTC Network Limiter" +// Chrome extension provides, done natively (that extension's chrome.privacy API +// isn't available in Electron, and this is more reliable). Tor forces the strongest. +const WEBRTC_POLICIES = { + default: "default", // allow all (may expose local IP) + public_only: "default_public_interface_only", // only the default public interface + public_private: "default_public_and_private_interfaces", + disable_udp: "disable_non_proxied_udp", // strongest (only proxied UDP) +}; +function webrtcPolicy() { + if (torState === "on") return "disable_non_proxied_udp"; + return WEBRTC_POLICIES[settings.webrtcMode] || "default_public_interface_only"; +} +// ---- anti-fingerprinting: timezone + language + location ---- +// Show/Hide/Spoof/Manual. Effective override, or null = "show" (real value). +function effTimezone() { + switch (settings.timezoneMode) { + case "hide": return "UTC"; + case "spoof": return SPOOF.tz; + case "manual": return settings.timezoneValue || "UTC"; + default: return null; + } +} +function effLocale() { + switch (settings.languageMode) { + case "hide": return "en-US"; + case "spoof": return settings.languageSpoof || SPOOF.lang; // chosen from the top-languages list + case "manual": return settings.languageValue || "en-US"; + default: return null; + } +} +// Representative coordinates per world region — used when the spoofed location is +// set to a region rather than exact coordinates (a major city stands in for each). +const REGIONS = { + europe: { lat: 52.5200, lon: 13.4050 }, // Berlin + asia: { lat: 35.6762, lon: 139.6503 }, // Tokyo + north_america: { lat: 40.7128, lon: -74.0060 }, // New York + south_america: { lat: -23.5505, lon: -46.6333 }, // São Paulo + africa: { lat: -1.2921, lon: 36.8219 }, // Nairobi + middle_east: { lat: 25.2048, lon: 55.2708 }, // Dubai + australia: { lat: -33.8688, lon: 151.2093 }, // Sydney +}; +// Geolocation: null = show (real, allowed); "deny" = hide (blocked); +// {lat,lon} = spoof (region-based) / manual (exact) — overridden in-page. +function effLocation() { + const m = settings.locationMode; + if (m === "hide") return "deny"; + if (m === "spoof") { const r = REGIONS[settings.locationRegion] || REGIONS.europe; return { lat: r.lat, lon: r.lon }; } + if (m === "manual") return { lat: Number(settings.locationLat) || 0, lon: Number(settings.locationLon) || 0 }; + return null; // show +} +// Applied per tab via CDP — the engine-level override the Tor/Mullvad browsers do: +// timezone -> Intl/Date; locale -> Intl + navigator.language(s). +async function applyFingerprint(wc) { + try { + if (!wc.debugger.isAttached()) wc.debugger.attach("1.3"); + const tz = effTimezone(); + await wc.debugger.sendCommand("Emulation.setTimezoneOverride", { timezoneId: tz || "" }); + const loc = effLocale(); + await wc.debugger.sendCommand("Emulation.setLocaleOverride", loc ? { locale: loc } : {}); + // setLocaleOverride covers Intl but NOT navigator.language(s) — inject a getter. + await wc.debugger.sendCommand("Page.enable"); + if (wc._langScript) { + try { await wc.debugger.sendCommand("Page.removeScriptToEvaluateOnNewDocument", { identifier: wc._langScript }); } catch {} + wc._langScript = null; + } + // Build one injected script covering navigator.language(s) and geolocation. + let src = ""; + if (loc) { + const langs = JSON.stringify([loc, loc.split("-")[0]]); + src += `Object.defineProperty(navigator,'language',{get:()=>${JSON.stringify(loc)},configurable:true});` + + `Object.defineProperty(navigator,'languages',{get:()=>${langs},configurable:true});`; + } + const geo = effLocation(); + if (geo && geo !== "deny") { // spoof/manual: override the reported coordinates + const pos = `{coords:{latitude:${geo.lat},longitude:${geo.lon},accuracy:100,altitude:null,altitudeAccuracy:null,heading:null,speed:null},timestamp:Date.now()}`; + src += `try{const p=()=>(${pos});if(navigator.geolocation){navigator.geolocation.getCurrentPosition=(ok)=>{try{ok(p())}catch(e){}};navigator.geolocation.watchPosition=(ok)=>{try{ok(p())}catch(e){}return 0};}}catch(e){}`; + } + // Media-device privacy: Chromium leaks audiooutput (speaker) labels + deviceIds + // via enumerateDevices even when camera/mic are blocked. Like Firefox, blank + // every device's label/deviceId/groupId and collapse to one entry per kind. + if (settings.hideMediaDevices) { + src += `try{const md=navigator.mediaDevices;if(md&&md.enumerateDevices){const o=md.enumerateDevices.bind(md);md.enumerateDevices=async()=>{let l=[];try{l=await o()}catch(e){}const ks=[...new Set(l.map(d=>d.kind))];return ks.map(kind=>({deviceId:'',kind:kind,label:'',groupId:'',toJSON(){return{deviceId:'',kind:kind,label:'',groupId:''}}}))};}}catch(e){}`; + } + if (src) { + const res = await wc.debugger.sendCommand("Page.addScriptToEvaluateOnNewDocument", { source: src }); + wc._langScript = res.identifier; + try { await wc.executeJavaScript(src); } catch {} // apply to the current page too + } + } catch { /* debugger busy (e.g. devtools) — best effort */ } +} +function applyFingerprintAll() { for (const t of tabs) applyFingerprint(t.view.webContents); } +// Storage retention — Chromium/Electron sessions accumulate cookies, HTTP +// cache, localStorage, IndexedDB, service workers, cache API by default. +// This wipes whichever the caller asked for. The `storages` list mirrors +// Chromium's clearStorageData taxonomy — we group them into a small user- +// facing bucket ("cookies" / "cache" / "storage") so settings stay simple. +async function clearBrowsingData({ cookies = false, cache = false, storage = false } = {}) { + const ses = session.defaultSession; + if (cache) { try { await ses.clearCache(); } catch (e) { console.warn("clearCache:", e.message); } } + const storages = []; + if (cookies) storages.push("cookies"); + if (storage) storages.push("localstorage", "indexdb", "serviceworkers", "cachestorage", "shadercache"); + if (storages.length) { + try { await ses.clearStorageData({ storages }); } + catch (e) { console.warn("clearStorageData:", e.message); } + } + // navigation history lives in each webContents; drop it too when history-clear was asked. + // (called separately by the before-quit hook, since history-clear also deletes session.json) +} +async function clearHistoryNow() { + for (const t of tabs) { + try { t.view.webContents.navigationHistory.clear(); } catch {} + } + try { fs.unlinkSync(sessionFile()); } catch {} + // Address-bar suggestions history — wipe both in-memory + on-disk. + history = []; + clearTimeout(historySaveTimer); historySaveTimer = null; + try { fs.unlinkSync(historyFile()); } catch {} +} +// Strip Electron + Theseus tokens from the User-Agent so Cloudflare's WAF +// (and other bot heuristics) don't flag every request. Verified: sending +// Mozilla/5.0 (…) theseus-navigator/0.3.22 Chrome/… Electron/33.4.11 Safari/537.36 +// to whybitcoincash.com got HTTP 503 from Cloudflare; the same request +// without the theseus + Electron tokens returns 200. Brave / Vivaldi / Slack +// (Electron) all do the same strip — a Chromium browser identifying itself +// as vanilla Chrome is standard practice in the Electron ecosystem. +function stockChromeUA() { + try { + return session.defaultSession.getUserAgent() + .replace(/ *theseus-navigator\/\S+/i, "") + .replace(/ *Electron\/\S+/i, ""); + } catch { return null; } +} +// Accept-Language header follows the locale setting (session-wide, best effort). +function applyAcceptLanguage() { + const loc = effLocale() || app.getLocale() || "en-US"; + try { + const ua = stockChromeUA() || session.defaultSession.getUserAgent(); + session.defaultSession.setUserAgent(ua, `${loc},${loc.split("-")[0]};q=0.8`); + } catch {} +} +// Client-hint headers (sec-ch-ua family) rewritten to look like stock Chrome. +// +// Why: Cloudflare Bot Fight Mode / Turnstile flag "UA claims Chrome but client +// hints don't confirm it" as bot. Electron's default sec-ch-ua reads +// "Chromium";v="130", "Not(A:Brand";v="99" +// — no "Google Chrome" brand (that's the closed-source Google branding +// Chromium doesn't carry). Combined with a UA already stripped of the +// Electron token, the mismatch is the fingerprint. Brave, Vivaldi and Opera +// solved this by shipping their own sec-ch-ua that INCLUDES Chrome-family +// brands so Cloudflare's allow-list catches them; whybitcoincash.com and +// other CF-fronted sites are what we run into without this. +// +// Approach: onBeforeSendHeaders across every session request. Overwrite +// sec-ch-ua and sec-ch-ua-full-version-list to a canonical stock-Chrome +// pair using Chromium's REAL major version from process.versions.chrome +// (so the story stays consistent — no version straddling to fingerprint). +// sec-ch-ua-mobile is pinned to "?0" (desktop) and sec-ch-ua-platform to +// the actual OS name so a Linux user still looks like a Linux user. +function applyClientHintsSpoof() { + try { + const chromeVer = String(process.versions.chrome || "130"); + const major = chromeVer.split(".")[0] || "130"; + const brands = `"Google Chrome";v="${major}", "Chromium";v="${major}", "Not?A_Brand";v="99"`; + const fullList = `"Google Chrome";v="${chromeVer}", "Chromium";v="${chromeVer}", "Not?A_Brand";v="99.0.0.0"`; + const platform = process.platform === "darwin" ? '"macOS"' + : process.platform === "win32" ? '"Windows"' + : '"Linux"'; + session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { + const h = details.requestHeaders || {}; + // Header names as Chromium sends them are typically kebab-case-lowercase; + // rewrite lowercase and also strip any Case-variant keys Electron + // may have set so we don't double up. + for (const k of Object.keys(h)) { + const kl = k.toLowerCase(); + if (kl === "sec-ch-ua" || kl === "sec-ch-ua-full-version-list" || + kl === "sec-ch-ua-mobile" || kl === "sec-ch-ua-platform") { + delete h[k]; + } + } + h["sec-ch-ua"] = brands; + h["sec-ch-ua-full-version-list"] = fullList; + h["sec-ch-ua-mobile"] = "?0"; + h["sec-ch-ua-platform"] = platform; + callback({ requestHeaders: h }); + }); + } catch (e) { console.warn("client-hints spoof setup failed:", e?.message); } +} +// ---- session restore + background throttling ---- +const sessionFile = () => path.join(app.getPath("userData"), "session.json"); +// v2 format: { v: 2, urls, active }. v1 was a bare array of URLs; reading one +// maps to active = last tab, which is what the old restore loop ended up +// showing (each createTab activated itself, so the rightmost tab won). +function saveSession() { + try { + const live = tabs.filter((t) => !t.settings && t.url); + const active = Math.max(0, live.findIndex((t) => t.id === activeId)); + fs.writeFileSync(sessionFile(), JSON.stringify({ v: 2, urls: live.map((t) => t.url), active })); + } catch (e) { console.error("session save failed:", e.message); } +} +function loadSession() { + try { + if (!fs.existsSync(sessionFile())) return { urls: [], active: 0 }; + const raw = JSON.parse(fs.readFileSync(sessionFile(), "utf8")); + const list = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.urls) ? raw.urls : []); + const urls = list.filter((u) => typeof u === "string" && u); + const last = Math.max(urls.length - 1, 0); + const active = Array.isArray(raw) ? last : Math.min(Math.max(Number(raw.active) || 0, 0), last); + return { urls, active }; + } catch { return { urls: [], active: 0 }; } +} +// Session restore, staggered. Creating every saved tab in one synchronous +// burst meant N renderer processes launching on top of chrome.html's first +// paint — on a slow or busy machine the window sat blank for seconds. The +// active tab is created first (so the user's page is on screen right away) +// and the rest arrive one per RESTORE_STAGGER_MS as background tabs, slotted +// into their saved strip positions. +const RESTORE_STAGGER_MS = 150; +function restoreTabs() { + const saved = settings.restoreSession ? loadSession() : { urls: [], active: 0 }; + if (!saved.urls.length) { createTab(); return; } + createTab(saved.urls[saved.active]); + const rest = saved.urls.map((url, i) => ({ url, i })).filter((x) => x.i !== saved.active); + const step = () => { + if (!rest.length || !win || win.isDestroyed()) return; + const { url, i } = rest.shift(); + try { + createTab(url, { background: true }); + // createTab appends; move the new tab to its saved slot. Lower indices + // are created first, so `i` is the right position on both sides of + // the active tab. + const t = tabs.pop(); + tabs.splice(Math.min(i, tabs.length), 0, t); + emitTabs(); + } catch (e) { console.warn("session restore: tab failed:", e?.message); } + setTimeout(step, RESTORE_STAGGER_MS); + }; + setTimeout(step, RESTORE_STAGGER_MS); +} +// ---- deferred overlay loads ---- +// The floating overlays (site-info popover, engine picker, downloads, +// address suggestions, password fill, link-status pill, add-on approval) +// are WebContentsViews created with the window, but their HTML used to be +// loaded in the same tick as chrome.html — seven extra renderers racing the +// toolbar for the first paint. They now load shortly after chrome reports +// did-finish-load, or on first use, whichever comes first. +const overlayLoads = []; // [{ view, file }] waiting for loadOverlays() +let overlaysLoaded = false; +function deferOverlayLoad(view, file) { overlayLoads.push({ view, file }); } +function loadOverlays() { + if (overlaysLoaded) return; + overlaysLoaded = true; + for (const { view, file } of overlayLoads.splice(0)) { + try { view.webContents.loadFile(file); } catch (e) { console.warn(`overlay load failed (${file}):`, e?.message); } + } +} +// Resolves once `view` has finished loading its page, loading the overlays +// first if that hasn't happened yet. Bounded so a wedged renderer can't +// hang the caller forever. +function overlayReady(view, timeoutMs = 4000) { + loadOverlays(); + const wc = view.webContents; + try { if (wc.getURL() && !wc.isLoading()) return Promise.resolve(); } catch {} + return new Promise((resolve) => { + const timer = setTimeout(done, timeoutMs); + function done() { clearTimeout(timer); wc.removeListener("did-finish-load", done); resolve(); } + wc.on("did-finish-load", done); + }); +} +// Post-paint boot. Runs once chrome.html has loaded (createWindow arms a +// fallback timer in case it never does). Everything here used to start in +// whenReady, before the toolbar had painted — the BNS index build, three +// network fetches, every restored tab and seven overlay renderers all piled +// onto the main thread in the same ~300 ms, and the window sat blank with +// a white toolbar strip until they drained. +let chromeReadyDone = false; +let bnsWarm = null; // promise from the warmFromSnapshot() kicked off in whenReady +function onChromeReady() { + if (chromeReadyDone) return; + chromeReadyDone = true; + if (addonHost) addonHost.signalUiReady(); + // Tabs wait for the BNS warm-up so sharedIndex is set before the first + // restored tab navigates; the rest of the multi-source refresh follows + // (see the comment block above startBnsPolling for how the sources + // cooperate). + (bnsWarm || warmFromSnapshot().catch(() => null)).then(() => { + try { restoreTabs(); } + catch (e) { console.error("restoreTabs failed:", e?.message); try { createTab(); } catch {} } + startBnsPolling(); + ensureIndex().catch(() => {}); // fallback for first launch without a bundled snapshot + }); + // Re-emit any pending update notice — harmless if nothing is pending. + emitUpdateAvailable(); + setTimeout(loadOverlays, 250); + refreshSnapshotFromSia().catch(() => {}); + checkForUpdate().catch(() => {}); + refreshRemoteHomeCards().catch(() => {}); +} +function applyThrottle() { + for (const t of tabs) { try { t.view.webContents.setBackgroundThrottling(settings.backgroundThrottle); } catch {} } +} +// Privacy-first permissions: Electron auto-grants everything by default. Deny the +// sensitive ones (camera/mic/geolocation/device access) — this also hides real +// media-device labels/ids from enumerateDevices. Handlers read settings live. +// Device permissions with no legitimate need here — always denied. +const SENSITIVE_DEVICE = new Set(["hid", "serial", "usb", "bluetooth", "midi", "midiSysex"]); +// A "media" request may ask for audio, video, or both — allow only if none blocked. +function mediaAllowed(kinds) { + if (kinds.includes("video") && settings.blockCamera) return false; + if (kinds.includes("audio") && settings.blockMicrophone) return false; + return true; +} +function applyPermissions() { + const ses = session.defaultSession; + ses.setPermissionRequestHandler((_wc, permission, callback, details) => { + if (permission === "media") return callback(mediaAllowed(details?.mediaTypes || [])); + if (permission === "geolocation") return callback(effLocation() !== "deny"); // allow unless "hide" + if (SENSITIVE_DEVICE.has(permission)) return callback(false); + callback(true); // benign UX permissions (fullscreen, pointerLock, …) + }); + ses.setPermissionCheckHandler((_wc, permission, _origin, details) => { + if (permission === "media") { + if (details?.mediaType === "video") return !settings.blockCamera; + if (details?.mediaType === "audio") return !settings.blockMicrophone; + return !(settings.blockCamera && settings.blockMicrophone); + } + if (permission === "geolocation") return effLocation() !== "deny"; + if (SENSITIVE_DEVICE.has(permission)) return false; + return true; + }); +} +// Cookie shim for cross-site embeds. Sites like the faucet hub's captcha-gated testnet +// faucets set session cookies with no SameSite attribute; Chromium defaults those to +// Lax and withholds them inside cross-site iframes, so cookie-bound captcha endpoints +// fail (tbch.googol.cash /captcha 500s without its session cookie). Rewriting their +// Set-Cookie to SameSite=None; Secure makes the cookie frame-eligible. Allowlist only — +// SameSite is CSRF protection, never relax it globally. NOTE: Electron keeps a single +// onHeadersReceived listener per session; if another is ever added, merge them. +const EMBED_COOKIE_SITES = ["https://tbch.googol.cash/*", "https://signetfaucet.com/*"]; +function applyEmbedCookieShim() { + session.defaultSession.webRequest.onHeadersReceived({ urls: EMBED_COOKIE_SITES }, (details, callback) => { + const headers = details.responseHeaders || {}; + for (const key of Object.keys(headers)) { + if (key.toLowerCase() !== "set-cookie") continue; + headers[key] = headers[key].map((c) => (/;\s*samesite=/i.test(c) ? c : c + "; SameSite=None; Secure")); + } + callback({ responseHeaders: headers }); + }); +} + +protocol.registerSchemesAsPrivileged([ + { scheme: "bns", privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } }, +]); + +let resolver; +async function getResolver() { + if (!resolver) resolver = await import(`file://${RESOLVER.replace(/\\/g, "/")}`); + return resolver; +} + +// ---- Tor (optional onion routing, toggled from the UI) ---- +// IP privacy, not full anonymity: this browser can still be fingerprinted. +const TOR_PORT = 9152; +const TOR_BIN = path.join(RES_DIR, "tor", "tor", "tor.exe"); +const TOR_GEOIP = path.join(RES_DIR, "tor", "data", "geoip"); +const TOR_GEOIP6 = path.join(RES_DIR, "tor", "data", "geoip6"); +let torProc = null, torState = "off"; +let torWsAgent = null; +let SocksProxyAgent; +async function loadSocks() { if (!SocksProxyAgent) ({ SocksProxyAgent } = await import("socks-proxy-agent")); } +function sendTor() { try { chrome?.webContents.send("tor", { state: torState }); } catch {} } +async function startTor() { + if (torProc) return; + torState = "connecting"; sendTor(); + await loadSocks(); + const dataDir = path.join(app.getPath("userData"), "tor-data"); + torProc = spawn(TOR_BIN, ["--SocksPort", String(TOR_PORT), "--ControlPort", "0", + "--DataDirectory", dataDir, "--GeoIPFile", TOR_GEOIP, "--GeoIPv6File", TOR_GEOIP6], { windowsHide: true }); + torProc.stdout.on("data", (d) => { if (/Bootstrapped 100%/.test(d.toString())) torReady(); }); + torProc.stderr.on("data", () => {}); + torProc.on("exit", () => { torProc = null; if (torState !== "off") torOff(); }); +} +function torReady() { + torState = "on"; + torWsAgent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`); + session.defaultSession.setProxy({ proxyRules: `socks5://127.0.0.1:${TOR_PORT}` }); + applyWebRTCPolicy(); + sendTor(); +} +function torOff() { + torState = "off"; torWsAgent = null; + session.defaultSession.setProxy({ proxyRules: "" }); + applyWebRTCPolicy(); + sendTor(); +} +function stopTor() { torOff(); if (torProc) { try { torProc.kill(); } catch {} torProc = null; } } +// While Tor is on, stop WebRTC from leaking the real IP around the SOCKS proxy +// (STUN/UDP bypasses an HTTP/SOCKS proxy — plain Electron doesn't block it the +// way the Tor Browser does). This is the usual reason a site still sees your IP. +function applyWebRTCPolicy() { + const policy = webrtcPolicy(); + for (const t of tabs) { try { t.view.webContents.setWebRTCIPHandlingPolicy(policy); } catch {} } +} +class TorWebSocket extends WebSocket { constructor(url, opts) { super(url, { agent: torWsAgent, ...opts }); } } +const currentWS = () => (torState === "on" ? TorWebSocket : WebSocket); + +function nodeRequest(urlStr, { method = "GET", headers = {}, agent } = {}) { + return new Promise((resolve, reject) => { + const u = new URL(urlStr); + const lib = u.protocol === "https:" ? https : http; + const req = lib.request(u, { method, headers, agent }, (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) })); + }); + req.on("error", reject); req.end(); + }); +} +async function contentFetch(url, init = {}) { + if (torState === "on") { await loadSocks(); return nodeRequest(url, { ...init, agent: new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`) }); } + const r = await fetch(url, init); + return { status: r.status, contentType: r.headers.get("content-type"), buffer: Buffer.from(await r.arrayBuffer()) }; +} + +// BNS `ip`-record fetch. The default `fetch` fails here for two reasons: +// 1. It follows the site's HTTP→HTTPS 301 into `https://<name>.<tld>/`, which +// isn't in ICANN DNS → "fetch failed". +// 2. It validates TLS against the public CA store, but BNS certs are signed +// by per-machine BNS root CAs — the trust anchor is the on-chain `tls` +// record's SHA-256 fingerprint, which we pin against here. See +// Argus/src/lib/ca.js for the underlying trust model, and the parallel +// implementation in Argus/src/gateway/public-gateway.mjs — keep both in +// step. This code path also runs through Tor when Tor is on. +function certFp(cert) { + const fp = cert && cert.fingerprint256; + return fp ? fp.toLowerCase().replace(/:/g, "") : ""; +} +async function pinnedHttpsGet(ip, port, servername, reqPath, expectedFp) { + const useTor = torState === "on"; + if (useTor) await loadSocks(); + return new Promise((resolve, reject) => { + const opts = { + host: ip, port, servername, method: "GET", path: reqPath, + headers: { host: servername, "user-agent": "theseus/1" }, + }; + opts["rejectUnauthorized"] = false; // fingerprint pin below is the gate. + if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`); + const req = https.request(opts, (res) => { + const gotFp = certFp(res.socket.getPeerCertificate(false)); + if (gotFp !== expectedFp) { + res.socket.destroy(); + return reject(new Error(`tls fingerprint mismatch for ${servername}: got ${gotFp}, expected ${expectedFp}`)); + } + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) })); + }); + req.setTimeout(15000, () => { req.destroy(new Error("tls request timeout")); }); + req.on("error", reject); + req.end(); + }); +} +async function httpGetByIp(ip, reqPath, hostHeader) { + const useTor = torState === "on"; + if (useTor) await loadSocks(); + return new Promise((resolve, reject) => { + const opts = { + host: ip, port: 80, method: "GET", path: reqPath, + headers: { host: hostHeader, "user-agent": "theseus/1" }, + }; + if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`); + const req = http.request(opts, (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) })); + }); + req.on("error", reject); req.end(); + }); +} +// Compose: pinned HTTPS if the on-chain `tls` fingerprint is available, else +// plain HTTP by IP. No silent HTTP fallback on pin failure — a mismatch means +// "not the site the chain says it is" and returning HTTP anyway would defeat +// the pin. +async function ipRequest(ip, reqPath, hostHeader, tlsFingerprint) { + if (tlsFingerprint) return await pinnedHttpsGet(ip, 443, hostHeader, reqPath, String(tlsFingerprint).toLowerCase()); + return await httpGetByIp(ip, reqPath, hostHeader); +} +// OpenSearch "scan": fetch a page's OpenSearch description and turn its HTML +// search template into our { name, url-with-%s } form. +async function fetchOpenSearch(href) { + try { + const r = await contentFetch(href, {}); + const xml = r.buffer.toString("utf8"); + const nameM = xml.match(/<ShortName>([^<]+)<\/ShortName>/i); + const urlM = xml.match(/<Url\b[^>]*type=["']text\/html["'][^>]*template=["']([^"']+)["']/i) + || xml.match(/<Url\b[^>]*template=["']([^"']+)["'][^>]*type=["']text\/html["']/i); + if (!urlM) return null; + const template = urlM[1].replace(/\{searchTerms\??\}/gi, "%s").replace(/\{[^}]*\}/g, ""); // drop other {params} + if (!template.includes("%s") || !/^https?:\/\//i.test(template)) return null; + return { name: (nameM ? nameM[1] : new URL(href).hostname).trim().slice(0, 40), url: template }; + } catch { return null; } +} + +// ---- electrum server pool: hardcoded seed + on-chain discovery, persisted ---- +// Bootstrap from the baked-in seed (with pinned IPs), then refresh from the +// on-chain ELECTRUM_LIST_NAME record so the pool can be rotated without a new +// build. The last discovered list is cached to disk and tried first next launch. +let electrumPool = null; +let lastElectrumRefresh = 0; +const electrumFile = () => path.join(app.getPath("userData"), "electrum-servers.json"); +const serverKey = (s) => (typeof s === "string" ? s : s && s.url); +function mergeServers(preferred, rest) { + const seen = new Set(), out = []; + for (const s of [...(preferred || []), ...(rest || [])]) { + const k = serverKey(s); + if (k && !seen.has(k)) { seen.add(k); out.push(s); } + } + return out; +} +async function initElectrumPool() { + const { CHIPNET_ELECTRUM } = await getResolver(); + let saved = []; + try { if (fs.existsSync(electrumFile())) saved = JSON.parse(fs.readFileSync(electrumFile(), "utf8")); } catch {} + electrumPool = mergeServers(saved, CHIPNET_ELECTRUM); // discovered first, seed always kept +} +async function refreshElectrumPool() { + try { + const { fetchElectrumServers } = await getResolver(); + const found = await fetchElectrumServers({ WebSocket: currentWS(), directIP: true, electrum: electrumPool }); + if (found && found.length) { + electrumPool = mergeServers(found, electrumPool); + try { fs.writeFileSync(electrumFile(), JSON.stringify(found, null, 2)); } catch {} + } + } catch { /* list unpublished or unreachable — keep the current pool */ } +} +function maybeRefreshElectrum() { + if (Date.now() - lastElectrumRefresh < 30 * 60 * 1000) return; + lastElectrumRefresh = Date.now(); + refreshElectrumPool(); // fire-and-forget +} + +const entries = new Map(); +// Cached chain index. Building it (connect + fetch every beacon tx) is the slow +// part, and it was happening on EVERY navigation. Build once, reuse for lookups, +// and refresh in the background — so .bch pages open near-instantly after the first. +let sharedIndex = null, indexBuiltAt = 0, indexBuilding = null; +const INDEX_TTL = 45_000; + +// ---- warm-start from a pre-fetched beacon snapshot ------------------------ +// +// The first ensureIndex() call walks the whole beacon over electrum — that's +// the "empty tab spinner" a user sees on cold start. The snapshot pipeline +// (Argus/src/publish-name-mirror.mjs) makes that walk skippable: an operator +// publishes the raw history+txs to Sia; every Theseus install carries a +// starter snapshot bundled at build time, then GETs a fresher one on boot. +// The warm sharedIndex is served immediately; the live buildIndex runs in the +// background to catch any events past the snapshot's asOfHeight. +// +// Sources tried in order: +// 1. app.getPath("userData")/bns-name-snapshot.json — the fresher copy +// written on our last successful Sia refresh (persisted across launches) +// 2. RES_DIR/bns-name-snapshot.json — the copy bundled with the build (stale +// by definition, but strictly better than "no index at all") +// Both are optional; if neither exists, ensureIndex() does what it always did +// and the user sees the same cold-start experience as before this change. +const SNAPSHOT_BUNDLED = path.join(RES_DIR, "bns-name-snapshot.json"); +const SIA_SNAPSHOT_URL = "https://s3.silentmode.st:8600/bns/name-list.json"; +function snapshotUserPath() { return path.join(app.getPath("userData"), "bns-name-snapshot.json"); } + +function readSnapshotFrom(p) { + try { + if (!fs.existsSync(p)) return null; + const parsed = JSON.parse(fs.readFileSync(p, "utf8")); + // A minimal shape check — buildIndexFromSnapshot will throw with a + // clear message on anything else, but we want to log which source + // was chosen for diagnostics. + if (!parsed || !Array.isArray(parsed.history)) return null; + return parsed; + } catch { return null; } +} + +// In-memory copy of the raw snapshot state (`{beacon, history, txs, ...}`) +// that drives the sharedIndex. Kept alongside sharedIndex so the delta poll +// can merge new beacon events into it without re-reading from disk on every +// refresh. Written to disk after each successful merge — the user cache is +// always the most up-to-date snapshot this process knows about, so a restart +// resumes from where we left off instead of from the stale bundled copy. +let currentSnapshotState = null; + +async function warmFromSnapshot() { + if (sharedIndex) return sharedIndex; // already warm — nothing to do + const { buildIndexFromSnapshot } = await getResolver(); + if (!buildIndexFromSnapshot) return null; // running against an older resolver-web.js + const snap = readSnapshotFrom(snapshotUserPath()) || readSnapshotFrom(SNAPSHOT_BUNDLED); + if (!snap) return null; + try { + const idx = buildIndexFromSnapshot({ snapshot: snap }); + sharedIndex = idx; + currentSnapshotState = snap; + // Deliberately set indexBuiltAt to 0 so the first real navigation still + // triggers a live refresh — the snapshot is a floor, not a ceiling. + indexBuiltAt = 0; + refreshBcnrTlds(idx); + console.log(`[bns] warm-started from snapshot: ${idx.size} names @ height=${snap.asOfHeight ?? "?"} root=${snap.root ?? "?"}`); + return idx; + } catch (e) { + console.warn("[bns] snapshot warm-start failed:", e.message); + return null; + } +} + +// ---- continuous background delta refresh -------------------------------- +// +// Every POLL_INTERVAL_MS the browser opens ONE electrum connection, fetches +// the beacon's current history (a single fast call), diffs it against the +// snapshot we already hold in memory, and only fetches the verbose tx bodies +// for the txids we don't have yet. Then we rebuild the index locally and +// persist the enlarged snapshot to disk. +// +// This turns "index refresh" from ~60 s of round-trips (fetch every verbose +// tx for the whole beacon) into ~1 s of round-trips per new event. And +// because it runs while the browser is idle, by the time the user actually +// types a name into the URL bar there is nothing to wait for. +// +// Sources conspiring for freshness: +// * warmFromSnapshot on boot — sharedIndex is warm before nav +// * this poll loop, every 30 s — keeps sharedIndex live and current +// * refreshSnapshotFromSia on boot — pulls the operator's published +// snapshot from Sia for the NEXT +// boot; if this browser was closed +// for a week, next launch skips +// days of catch-up +// * ensureIndex still exists — full-walk fallback for the case +// where the poll cannot connect +// (offline first launch, etc.) +const POLL_INTERVAL_MS = 30_000; +let pollInFlight = null; +let pollTimer = null; +let pollAttempts = 0, pollLastError = null; + +async function pollAndMerge() { + if (pollInFlight) return pollInFlight; + pollInFlight = (async () => { + pollAttempts++; + try { + const R = await getResolver(); + if (!R.connectElectrum || !R.BEACON_SCRIPTHASH || !R.buildIndexFromSnapshot) { + // Older resolver-web without the delta primitives — nothing to do. + return null; + } + if (!electrumPool) await initElectrumPool(); + + // Base state: memory > user cache > bundled > empty. The "empty" branch + // is what turns the very first cold start (no bundled snapshot present + // because we shipped a build that predates snapshotting) into a full + // rebuild — mergeFreshHistory will fetch every tx. + let snap = currentSnapshotState + || readSnapshotFrom(snapshotUserPath()) + || readSnapshotFrom(SNAPSHOT_BUNDLED) + || { beacon: R.BEACON_SCRIPTHASH, history: [], txs: {} }; + + const el = await R.connectElectrum({ + electrum: electrumPool, WebSocket: currentWS(), directIP: true, + }); + try { + const freshHistory = await el.call("blockchain.scripthash.get_history", [R.BEACON_SCRIPTHASH]); + const known = new Set(snap.history.map((h) => h.tx_hash)); + // Merge fresh into snapshot history (dedup by tx_hash, keep fresh height — + // an event that was mempool at snapshot time now has a real height). + const merged = new Map(snap.history.map((h) => [h.tx_hash, h])); + const txs = { ...(snap.txs || {}) }; + let added = 0; + for (const h of freshHistory) { + if (!known.has(h.tx_hash)) { + try { + txs[h.tx_hash] = await el.call("blockchain.transaction.get", [h.tx_hash, true]); + added++; + } catch { /* unreadable — the reduction rules ignore missing txs */ } + } + merged.set(h.tx_hash, { tx_hash: h.tx_hash, height: h.height }); + } + const history = [...merged.values()]; + currentSnapshotState = { ...snap, beacon: R.BEACON_SCRIPTHASH, history, txs }; + const idx = R.buildIndexFromSnapshot({ snapshot: currentSnapshotState }); + sharedIndex = idx; + indexBuiltAt = Date.now(); + pollLastError = null; + refreshBcnrTlds(idx); + // Persist for the next launch. Failure here is not fatal — worst case + // we redo this merge on the next start. + try { + fs.mkdirSync(path.dirname(snapshotUserPath()), { recursive: true }); + fs.writeFileSync(snapshotUserPath(), JSON.stringify(currentSnapshotState)); + } catch { /* readonly userdata / disk full — skip */ } + if (added > 0) { + console.log(`[bns] delta-refresh: +${added} new tx${added === 1 ? "" : "s"} (total ${history.length}, index has ${idx.size} names)`); + } + } finally { try { el.close(); } catch {} } + } catch (e) { + pollLastError = e && e.message || String(e); + // Silent — the browser stays usable via sharedIndex (last-known-good) or + // the ensureIndex fallback on the next navigation. + } finally { pollInFlight = null; } + })(); + return pollInFlight; +} + +function startBnsPolling() { + if (pollTimer) return; + // Fire immediately so the boot warm-start gets a delta pass right away, in + // parallel with the Sia refresh and the ensureIndex fallback below. Then + // every POLL_INTERVAL_MS while the browser is running. + pollAndMerge().catch(() => {}); + pollTimer = setInterval(() => pollAndMerge().catch(() => {}), POLL_INTERVAL_MS); +} +function stopBnsPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } + +// Fetch the latest published snapshot from Sia and persist it as the user +// copy — the next launch (or the next warmFromSnapshot call) picks it up. +// Fire-and-forget: failures are silent; the live buildIndex path is the +// authoritative catch-up. +async function refreshSnapshotFromSia() { + try { + const res = await fetch(SIA_SNAPSHOT_URL, { redirect: "follow" }); + if (!res.ok) return; + const body = await res.text(); + const parsed = JSON.parse(body); + if (!parsed || !Array.isArray(parsed.history)) return; + try { fs.mkdirSync(path.dirname(snapshotUserPath()), { recursive: true }); } catch {} + fs.writeFileSync(snapshotUserPath(), body); + console.log(`[bns] snapshot refreshed from Sia: ${parsed.history.length} beacon txs root=${parsed.root ?? "?"}`); + } catch { /* offline / Sia unreachable / bad JSON — the live path still works */ } +} + +async function ensureIndex(force = false) { + const { buildIndex } = await getResolver(); + if (!electrumPool) await initElectrumPool(); + if (!force && sharedIndex && Date.now() - indexBuiltAt < INDEX_TTL) return sharedIndex; + if (indexBuilding) return indexBuilding; // dedupe concurrent builds + indexBuilding = buildIndex({ WebSocket: currentWS(), directIP: true, electrum: electrumPool }) + .then((idx) => { sharedIndex = idx; indexBuiltAt = Date.now(); refreshBcnrTlds(idx); return idx; }) + .finally(() => { indexBuilding = null; }); + // If we have a stale index, don't block on the rebuild — serve stale, refresh async. + return (sharedIndex && !force) ? sharedIndex : indexBuilding; +} +async function resolveHost(host) { + const { normalizeName } = await getResolver(); + let key; try { key = normalizeName(host); } catch { return null; } + // Prefer the warm sharedIndex — the poll loop keeps it live. If we don't + // have one yet (very cold start, snapshot missing AND poll hasn't landed + // yet), fall through to a full ensureIndex build. + let idx = sharedIndex || (await ensureIndex()); + let entry = idx.get(key) ?? null; + // Miss on a possibly-stale index → try a fast delta refresh (1 history + + // only-new-tx bodies), not a full walk. Only if we've had time for at least + // one poll to land (indexBuiltAt updated by both ensureIndex and the delta + // poll). If the delta path is unavailable (older resolver-web), fall back + // to a full rebuild — same behavior as before this change. + if (!entry && Date.now() - indexBuiltAt > 8_000) { + const R = await getResolver(); + if (R.connectElectrum && R.buildIndexFromSnapshot) { + await pollAndMerge(); + entry = sharedIndex?.get(key) ?? null; + } else { + idx = await ensureIndex(true); + entry = idx.get(key) ?? null; + } + } + if (entry) entries.set(host.toLowerCase(), { entry, host: host.toLowerCase() }); + maybeRefreshElectrum(); + return entry; +} +const MIME = { html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8", css: "text/css", js: "text/javascript", + json: "application/json", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml", + ico: "image/x-icon", webp: "image/webp", woff2: "font/woff2", woff: "font/woff", txt: "text/plain", wasm: "application/wasm" }; +const guessType = (p) => MIME[p.split(".").pop()?.toLowerCase()] || "application/octet-stream"; + +async function serveBns(request) { + const url = new URL(request.url); + const host = url.hostname.toLowerCase(); + const reqPath = decodeURIComponent(url.pathname) || "/"; + + // (bns://collision-choose/ is handled by the will-navigate listener attached + // to each tab — it fires BEFORE the request reaches this protocol handler.) + + let rec = entries.get(host); + if (!rec) { try { await resolveHost(host); } catch {} rec = entries.get(host); } + if (!rec) return new Response("NXDOMAIN: " + host, { status: 404, headers: { "content-type": "text/plain" } }); + const r = rec.entry.records; + // Subdomain inheritance: `checkers.game.x` collapses to `game.x` in the + // registry (see resolver-web `normalizeName`). `ip` semantics apply to the + // whole namespace via Host routing; `s3` semantics are exact-key per name. + // For a subdomain, `ip` is the unambiguous parent intent — prefer it. For the + // apex (host === entry.name), current priority stands. See public-gateway.mjs + // for the full argument; keep this in step with that file. + const isSubdomain = host !== rec.entry.name; + const serveIp = async () => { + // See ipRequest above: HTTPS-with-fingerprint-pin against the on-chain `tls` + // record when available, HTTP fallback when not. Fixes serving BNS names + // whose server redirects :80→:443 (the plain-fetch path chokes on the + // redirect target because it isn't in ICANN DNS). + const up = await ipRequest(r.ip, reqPath + url.search, host, r.tls); + return new Response(up.buffer, { status: up.status, headers: { "content-type": up.contentType || guessType(reqPath) } }); + }; + // `p` — reverse-proxy the request to a full upstream URL. Address bar stays + // on the BNS host; unlike `ip`, uses the upstream's own DNS + public CA and + // sends `Host:` of the upstream so vhost-based origins answer correctly. + // Not applied under subdomain inheritance — see public-gateway.mjs and the + // matching test in record-picker.test.mjs for why. Preserve any path prefix + // in r.p (e.g. `{p:"https://api.host/v1"}` + request "/x" → ".../v1/x"). + const serveP = async () => { + const base = new URL(r.p); + const prefix = base.pathname === "/" ? "" : base.pathname.replace(/\/$/, ""); + const target = base.origin + prefix + reqPath + url.search; + const up = await fetch(target, { redirect: "manual" }); + const body = Buffer.from(await up.arrayBuffer()); + const ct = up.headers.get("content-type") || guessType(reqPath); + return new Response(body, { status: up.status, headers: { "content-type": ct } }); + }; + try { + if (isSubdomain && r.ip) return await serveIp(); + if (r.h) { if (reqPath === "/") return new Response(r.h, { headers: { "content-type": "text/html; charset=utf-8" } }); return new Response("not found", { status: 404 }); } + if (r.s3) { + // Secret-free: fetch Sia content from the public gateway (it holds the + // keys and owns the subfolder mapping) instead of signing S3 requests + // with credentials that must never ship in a public build. + const up = await contentFetch(`${GATEWAY}/bns/${host}${reqPath}${url.search}`, {}); + const ct = up.contentType && up.contentType !== "application/octet-stream" + ? up.contentType : guessType(reqPath === "/" ? "index.html" : reqPath); + let body = up.buffer; + if (ct.includes("text/html")) { + // Strip the gateway's path-form <base href="/bns/<name>/"> so assets + // resolve against the bns:// origin, not back through the relay. + body = Buffer.from(body.toString("utf8").replace(/<base\s+href="\/bns\/[^"]*">/i, ""), "utf8"); + } + return new Response(body, { status: up.status, headers: { "content-type": ct } }); + } + if (r.ip) return await serveIp(); + if (!isSubdomain && r.p) return await serveP(); + if (r.u) return Response.redirect(r.u, 302); + return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } }); + } catch (e) { return new Response("Theseus error: " + e.message, { status: 502 }); } +} + +// ---- window + tabs ---- +let win, chrome; +let CHROME_H = 84; // grows when an extra bar (Tor notice / BCNR offer) is shown +// Site-info popover: a floating overlay VIEW on top of the page content, so it +// never pushes the page down. Positioned under the address-bar badge on demand. +let popover, popVisible = false, popPos = { x: 8, y: 90 }; +const POP_W = 360; let popH = 210; // popH is updated to fit the popover's content +// Engine-picker: a second floating overlay VIEW (a custom dropdown that shows real +// engine favicons, like Firefox — a native <select> can't render images). +let enginePicker, epVisible = false, epPos = { x: 8, y: 90 }; +const EP_W = 250; let epH = 320; +// Downloads popover — a third floating overlay VIEW showing in-flight and +// recently-finished downloads. Anchored under the toolbar's download button. +let downloadsPop, dlVisible = false, dlPos = { x: 8, y: 90 }; +const DL_W = 340; let dlH = 240; +// Address-bar suggestions dropdown. Floating overlay under the address bar. +let addressPicker, apVisible = false, apPos = { x: 60, y: 70 }; +let apW = 520; let apH = 60; +// Password-fill picker — floating dropdown under a small key chip in the +// toolbar that appears only when the vault is unlocked AND the current +// site has matching credentials. +let pwFillPop, pwfVisible = false, pwfPos = { x: 8, y: 90 }; +const PWF_W = 280; let pwfH = 80; +// Link-hover status bar — small pill at the bottom-left of the window +// showing the href when the mouse hovers a link (Chrome / Firefox style). +// Hidden when hover leaves. Fed by webContents.update-target-url on every +// tab; the pill auto-sizes to its text. +let linkStatus, linkStatusVisible = false; +let linkStatusW = 100, linkStatusH = 22; +// Add-on sidebar — one right-anchored WebContentsView that hosts an add-on's +// registered panel HTML. First registered panel wins for the MVP; a tab +// strip / picker for multiple panels lands in a later rev. Sidebar loads +// nothing until the user actively opens it, so the perf cost of an unused +// add-on is nil. +let sidebar, sidebarVisible = false, sidebarActivePanelId = null; +// Sidebar width is user-adjustable via a drag grip on the panel's left edge. +// The value below is the default; settings.sidebarWidth overrides it once +// loadSettings() runs and persists any drag adjustment made by the user. +const SIDEBAR_W_MIN = 200, SIDEBAR_W_MAX = 800, SIDEBAR_W_DEFAULT = 340; +let sidebarW = SIDEBAR_W_DEFAULT; +// When a panel asks for "maximize" (screenshot editor wanting the full canvas +// area), we widen the sidebar to fill the window and remember the previous +// width so restore drops us back exactly. Non-persisted: closing/reopening +// Theseus always starts un-maximized. +let sidebarMaximized = false; +let sidebarPreMaxW = SIDEBAR_W_DEFAULT; +function sidebarMaxWidth() { + if (!win) return SIDEBAR_W_MAX; + const { width } = win.getContentBounds(); + return Math.max(SIDEBAR_W_MIN, width); +} +// The add-on host is the single point of truth for what's installed and +// active. Populated by initAddons() at app-ready time. +let addonHost = null; +// One-shot proxy-login handler installed by setSessionProxy when the +// extension provided credentials. Removed and re-installed on every +// setSessionProxy call so the current credentials always match the +// current proxy. +let proxyLoginHandler = null; +const { AddonHost } = require("./addons-host.js"); +const addonUpdater = require("./addon-updater.js"); +const { PUBKEYS_HEX: ADDON_UPDATE_PUBKEYS } = require("./addon-update-pubkeys.js"); +function addonsUserDir() { return path.join(app.getPath("userData"), "addons"); } +function addonsDataDir() { return path.join(app.getPath("userData"), "addons-data"); } +function addonsBackupDir() { return path.join(app.getPath("userData"), "addons-backups"); } +function addonsStagedDir() { return path.join(app.getPath("userData"), "addons-updates-staged"); } +function bundledAddonsDir() { return path.join(RES_DIR, "bundled-addons"); } +// Copy bundled reference add-ons (shipped inside resources/) into the user's +// addons directory. Users can then edit, disable, or delete them — the +// framework treats bundled and user add-ons identically, no special path +// handling. +// +// Update rule: reseed when the bundled addon.json version differs from the +// user's on-disk addon.json version. Before reseeding, rename the user copy +// to <id>-<oldver>-<stamp>/ under <userData>/addons-backups/ so any local +// edits survive. If the two versions match we leave the folder alone — +// users who fork by bumping their own version stay pinned; users who edit +// files without bumping accept upstream updates. +function readAddonVersion(dir) { + try { return JSON.parse(fs.readFileSync(path.join(dir, "addon.json"), "utf8"))?.version ?? null; } + catch { return null; } +} +// One-time migration for the bchwallet → aegis rename + the siawallet +// retirement. Idempotent: after the first run the sources are gone and +// subsequent runs are no-ops. +// +// - addons/bchwallet/ → addons-backups/bchwallet-migrated-<stamp>/ +// (bundle folder is now aegis/; leaving the old dir active would load +// the pre-rename copy as a second addon under the same id and clash). +// - addons-data/bchwallet.json → addons-data/aegis.json (COPY, so any +// downgrade to a 0.3.x build can still read its own storage). +// - addons/siawallet/ → addons-backups/siawallet-migrated-<stamp>/ +// (folded into Aegis via absorbs: ["siawallet"]; keeping it running +// would show duplicate Sia UI). Its data file stays under +// addons-data/ untouched — Aegis derives its own SC walletdUrl +// per-sub-wallet, so users re-paste their URL in Aegis Settings. +function migrateAegisRename() { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backups = addonsBackupDir(); + try { fs.mkdirSync(backups, { recursive: true }); } catch {} + const addonsRoot = addonsUserDir(); + const dataRoot = addonsDataDir(); + + const bchDir = path.join(addonsRoot, "bchwallet"); + const aegisDir = path.join(addonsRoot, "aegis"); + const bchJson = path.join(dataRoot, "bchwallet.json"); + const aegisJson = path.join(dataRoot, "aegis.json"); + // Copy legacy storage into the new file exactly once, only when the new + // file doesn't exist yet (a downgrade to 0.3.x would otherwise clobber + // fresh state written by 0.4+). + if (fs.existsSync(bchJson) && !fs.existsSync(aegisJson)) { + try { fs.copyFileSync(bchJson, aegisJson); console.log("[addons] migrated bchwallet.json -> aegis.json"); } + catch (err) { console.warn("[addons] migrate storage failed:", err?.message); } + } + // Retire the bchwallet folder EVERY LAUNCH it exists. The signed OTA + // update endpoint may still advertise `id=bchwallet` updates, and + // promoteStagedUpdates (which runs before us) could reinstall it. Left + // active it would load as a second Aegis under a stale id, doubling + // every wallet in the sidebar. + if (fs.existsSync(bchDir)) { + const backup = path.join(backups, `bchwallet-migrated-${stamp}`); + try { fs.renameSync(bchDir, backup); console.log(`[addons] retired addons/bchwallet -> addons-backups/${path.basename(backup)}${fs.existsSync(aegisDir) ? " (aegis already present)" : " (folder renamed to aegis/)"}`); } + catch (err) { console.warn("[addons] retire bchwallet failed:", err?.message); } + } + + const siaDir = path.join(addonsRoot, "siawallet"); + if (fs.existsSync(siaDir)) { + const backup = path.join(backups, `siawallet-migrated-${stamp}`); + try { fs.renameSync(siaDir, backup); console.log(`[addons] retired addons/siawallet -> addons-backups/${path.basename(backup)} (absorbed by aegis)`); } + catch (err) { console.warn("[addons] retire siawallet failed:", err?.message); } + } +} +function seedBundledAddons() { + const dst = addonsUserDir(); + try { fs.mkdirSync(dst, { recursive: true }); } catch {} + const src = bundledAddonsDir(); + if (!fs.existsSync(src)) return; + let entries = []; + try { entries = fs.readdirSync(src, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + if (!e.isDirectory()) continue; + const from = path.join(src, e.name); + const target = path.join(dst, e.name); + const bundleVer = readAddonVersion(from); + if (!bundleVer) continue; // broken bundle — skip rather than corrupt user state + if (fs.existsSync(target)) { + const userVer = readAddonVersion(target); + if (userVer === bundleVer) continue; + // Backups go in a sibling folder so AddonHost's directory scan doesn't + // pick them up as duplicate addons with the same manifest id. + const backupsRoot = addonsBackupDir(); + try { fs.mkdirSync(backupsRoot, { recursive: true }); } catch {} + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backup = path.join(backupsRoot, `${e.name}-${userVer ?? "unknown"}-${stamp}`); + try { fs.renameSync(target, backup); } + catch (err) { console.warn(`[addons] backup ${e.name} failed, skipping reseed:`, err?.message); continue; } + console.log(`[addons] reseed ${e.name}: ${userVer ?? "unknown"} -> ${bundleVer} (previous copy at addons-backups/${path.basename(backup)})`); + } + try { fs.cpSync(from, target, { recursive: true }); } + catch (err) { console.warn(`[addons] seed ${e.name} failed:`, err?.message); } + } +} +function initAddons() { + // Promote any signed add-on update staged by a previous run BEFORE we + // reseed from the bundle — a fresh install of a newer version from + // updateURL should win over the older bundled copy shipped inside the + // Theseus installer. + addonUpdater.promoteStagedUpdates({ + addonsDir: addonsUserDir(), + backupsDir: addonsBackupDir(), + stagedDir: addonsStagedDir(), + logger: (...a) => console.log("[addons]", ...a), + }); + // Retire the pre-rename bundle layouts before reseeding so the fresh + // aegis/ + siawallet-less state is what AddonHost enumerates. + migrateAegisRename(); + seedBundledAddons(); + addonHost = new AddonHost({ + addonsDir: addonsUserDir(), + dataDir: addonsDataDir(), + isDisabled: (id) => Array.isArray(settings.disabledAddons) && settings.disabledAddons.includes(id), + logger: (...a) => console.log("[addons]", ...a), + // Session-proxy capability. Add-ons that declare "session-proxy" in + // their manifest can call api.setSessionProxy(rules) to swap + // Chromium's outbound network path. Same primitive Tor uses. + // + // Authentication: Chromium's setProxy does NOT parse credentials from + // `socks5://user:pass@host:port` — it rejects it as ERR_NO_SUPPORTED_ + // PROXIES. Add-ons pass auth separately either as an object: + // api.setSessionProxy({ proxyRules, auth: { username, password } }) + // or inline URL — this hook strips the user:pass@ and installs a + // one-shot login handler on the default session that answers with + // the extracted credentials next time Chromium asks the proxy for auth. + setSessionProxy: async (rules, addonId) => { + const ses = session.defaultSession; + // Always clear any prior proxy-login handler before swapping. + if (proxyLoginHandler) { ses.off("login", proxyLoginHandler); proxyLoginHandler = null; } + if (rules == null || rules === "") { + console.log(`[addons] [${addonId}] clearing session proxy`); + try { await ses.setProxy({ proxyRules: "" }); } catch (e) { console.warn("proxy clear failed:", e?.message); } + return; + } + let opts; + let auth = null; + if (typeof rules === "string") { + // Parse inline creds: "scheme://user:pass@host:port". + const m = rules.match(/^([a-z0-9+.-]+:\/\/)([^:@\/]+):([^@\/]+)@(.+)$/i); + if (m) { opts = { proxyRules: m[1] + m[4] }; auth = { username: m[2], password: decodeURIComponent(m[3]) }; } + else opts = { proxyRules: rules }; + } else { + opts = { proxyRules: rules.proxyRules }; + if (rules.auth && rules.auth.username != null) auth = { username: String(rules.auth.username), password: String(rules.auth.password || "") }; + } + const publicRules = opts.proxyRules; // never log the password + console.log(`[addons] [${addonId}] setting session proxy:`, publicRules, auth ? "(auth pending)" : ""); + if (auth) { + // Chromium fires session#login with `authenticationResponseDetails.isProxy === true` + // when the proxy asks for creds. Answer once per session. + proxyLoginHandler = (event, _details, authInfo, callback) => { + if (!authInfo || !authInfo.isProxy) return; + event.preventDefault(); + callback(auth.username, auth.password); + }; + ses.on("login", proxyLoginHandler); + } + try { await ses.setProxy(opts); } catch (e) { console.warn("proxy set failed:", e?.message); } + }, + // vault-derive capability. Resolves once the vault is unlocked (the + // user types the master password at boot or later in Settings) with a + // 32-byte HKDF child of the vault's root. The vault never persists the + // BIP-39 seed — only per-purpose roots — so add-on material hangs off + // the passwords root under an "addons/" info label: recoverable from + // the same mnemonic on any device, and a derived password can't be + // walked back to it (HKDF is one-way). + vaultDerive: async (purposePath, addonId) => { + if (!fs.existsSync(vaultFile())) throw new Error("password vault is not set up"); + while (!vaultState) await new Promise((r) => setTimeout(r, 500)); + const v = await loadVaultLib(); + const wc = require("node:crypto").webcrypto; + const key = await wc.subtle.importKey("raw", v.hexToBytes(vaultState.purposeRoot), "HKDF", false, ["deriveBits"]); + const info = new TextEncoder().encode(`silentmode/addons/${purposePath}`); + console.log(`[addons] [${addonId}] vault.derive ${purposePath}`); + return new Uint8Array(await wc.subtle.deriveBits( + { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256)); + }, + vaultLifecycle: { + status: async () => ({ setup: fs.existsSync(vaultFile()), unlocked: !!vaultState }), + unlock: async (masterPassword, addonId) => { + if (!fs.existsSync(vaultFile())) throw new Error("no vault"); + const v = await loadVaultLib(); + vaultState = await v.unlockVault(vaultFile(), masterPassword); + importsState = null; + if (fs.existsSync(importsFile())) { + try { importsState = await v.unlockImports(importsFile(), masterPassword); } + catch (ie) { console.error("[imports] unlock via addon failed:", ie?.message); } + } + importsUnlockPw = masterPassword; + emitPwAvailability(); + console.log(`[addons] [${addonId}] vault.unlock`); + return { ok: true }; + }, + setup: async (masterPassword, seedSource, addonId) => { + if (!masterPassword || String(masterPassword).length < 4) throw new Error("master password too short"); + if (fs.existsSync(vaultFile())) throw new Error("vault already exists"); + const v = await loadVaultLib(); + let purposeRootHex, messengerRootHex; + if (seedSource && seedSource.kind === "mnemonic" && seedSource.mnemonic) { + const seed = await v.bip39ToSeed(String(seedSource.mnemonic)); + purposeRootHex = v.bytesToHex(await v.seedToPurposeRoot(seed, "passwords/0")); + messengerRootHex = v.bytesToHex(await v.seedToPurposeRoot(seed, "messenger/0")); + } else { + const root = require("node:crypto").webcrypto.getRandomValues(new Uint8Array(32)); + purposeRootHex = v.bytesToHex(root); + } + vaultState = await v.createVault(vaultFile(), masterPassword, purposeRootHex, + messengerRootHex ? { messengerRootHex } : {}); + importsUnlockPw = masterPassword; + emitPwAvailability(); + console.log(`[addons] [${addonId}] vault.setup`); + return { ok: true }; + }, + lock: async (addonId) => { + vaultState = null; importsState = null; importsUnlockPw = null; + emitPwAvailability(); + console.log(`[addons] [${addonId}] vault.lock`); + return { ok: true }; + }, + }, + vaultImports: { + list: async () => { + if (!vaultState) throw new Error("password vault is locked"); + const v = await loadVaultLib(); + return importsState ? v.listImportsMetadata(importsState) : []; + }, + add: async (spec, addonId) => { + if (!vaultState) throw new Error("password vault is locked"); + if (!importsUnlockPw) throw new Error("imports session credential missing (relock and unlock)"); + if (!spec || typeof spec !== "object") throw new Error("spec required"); + const kind = String(spec.kind || ""); + if (kind !== "seed" && kind !== "wif") throw new Error(`unknown kind: ${kind}`); + const cashaddr = String(spec.cashaddr || "").trim(); + if (!cashaddr) throw new Error("cashaddr required (caller derives)"); + const label = String(spec.label || "").trim().slice(0, 120); + if (!label) throw new Error("label required"); + const category = String(spec.category || "").trim().slice(0, 40) || "operational"; + const source = String(spec.source || "").trim().slice(0, 500); + const v = await loadVaultLib(); + if (!importsState) importsState = await v.createImports(importsFile(), importsUnlockPw); + const rawId = String(spec.id || label).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "wallet"; + let id = rawId, n = 1; + while (importsState.accounts[id]) { n++; id = `${rawId}-${n}`; } + const rec = { kind, cashaddr, label, category, source, createdAt: Date.now() }; + if (kind === "seed") { + if (!spec.seed || !spec.path) throw new Error("seed and path required for kind=seed"); + rec.seed = String(spec.seed); rec.path = String(spec.path); + } else { + if (!spec.wif) throw new Error("wif required for kind=wif"); + rec.wif = String(spec.wif); + } + importsState.accounts[id] = rec; + await v.saveImports(importsFile(), importsState); + console.log(`[addons] [${addonId}] vault.imports.add ${kind} → ${id}`); + return { id, entries: v.listImportsMetadata(importsState) }; + }, + remove: async (id, addonId) => { + if (!vaultState || !importsState) throw new Error("password vault is locked"); + if (!importsState.accounts[id]) throw new Error("no such import"); + delete importsState.accounts[id]; + const v = await loadVaultLib(); + await v.saveImports(importsFile(), importsState); + console.log(`[addons] [${addonId}] vault.imports.remove ${id}`); + return { entries: v.listImportsMetadata(importsState) }; + }, + signer: async (id, addonId) => { + if (!vaultState || !importsState) throw new Error("password vault is locked"); + const v = await loadVaultLib(); + console.log(`[addons] [${addonId}] vault.imports.signer ${id}`); + return v.getImportSigner(importsState, id); + }, + }, + approvalModal: (opts, addonId) => showApprovalModal(opts, addonId), + emitToPanel: (addonId, msg, payload) => { + if (!sidebar || !sidebarActivePanelId || !sidebarActivePanelId.startsWith(addonId + ":")) return; + try { sidebar.webContents.send("addon-event", msg, payload); } catch {} + }, + hostRequire: (name) => require(name), + hostImport: (name) => import(require("node:url").pathToFileURL(require.resolve(name)).href), + openTab: (url) => { if (win) createTab(url); }, + // openSettings: routes through the existing "open-settings" IPC handler + // so the same section-hint validation applies. Add-ons hit this when they + // want to point users at Passwords, Extensions, etc. + openSettings: (section) => { + const slug = typeof section === "string" && /^[a-z0-9-]{1,32}$/i.test(section) ? section.toLowerCase() : ""; + const ex = tabs.find((t) => t.settings); + if (ex) { + setActive(ex.id); + if (slug) { try { ex.view.webContents.send("focus-section", slug); } catch {} } + return; + } + createTab(null, { settings: true, settingsSection: slug }); + }, + // open-tab (addon-file variant): open one of the add-on's OWN files in a + // full tab. The path is joined against the resolved add-on folder and + // rejected if the result escapes it — belt-and-braces with the sanity + // check the api wrapper already does. The tab uses addon-tab-preload so + // window.silentmode.invoke() reaches the same handler surface as a + // sidebar panel; the sender-URL gate on addon-msg then confines the + // page to its own add-on's storage/handlers. + openAddonTab: (addonId, relPath, queryString) => { + if (!win) return; + const folder = addonHost && addonHost.folderOf(addonId); + if (!folder) throw new Error(`openAddonTab: no such active add-on "${addonId}"`); + const base = path.resolve(folder); + const abs = path.resolve(base, relPath); + const norm = abs.replace(/\\/g, "/").toLowerCase(); + const baseNorm = base.replace(/\\/g, "/").toLowerCase(); + if (norm !== baseNorm && !norm.startsWith(baseNorm + "/")) { + throw new Error(`openAddonTab: path "${relPath}" escapes add-on folder`); + } + if (!fs.existsSync(abs)) throw new Error(`openAddonTab: file not found: ${abs}`); + console.log(`[addons] [${addonId}] openAddonTab -> ${path.basename(abs)}${queryString ? "?"+queryString.slice(0,80)+(queryString.length>80?"…":"") : ""}`); + createTab(null, { addonFile: { absPath: abs, query: queryString || "", addonId } }); + }, + // capture-tab: three modes. + // visible — one WebContents.capturePage() of the current viewport. + // full — temporarily grow the tab's WebContentsView to the page's + // scrollHeight, capture, restore. Cheap and works for most + // pages; fixed-position headers/footers will repeat because + // they anchor to the viewport, which is a known trade-off + // (documented in the panel). Alternative would be a scroll- + // and-stitch pass; kept for a later revision. + // region — run the caller-supplied overlay source in the tab, wait + // for a rect (or null = cancel), then capturePage(rect). + captureTab: async (opts, addonId) => { + // Prefer the currently-active tab, BUT if that's an add-on-owned page + // (e.g. the screenshot editor is already up when the user re-picks a + // mode from the dropdown), fall back to the most-recently-active real + // tab. Otherwise a second capture snapshots the editor's still-blank + // canvas and every follow-up produces a white PNG. + let t = activeTab(); + if (t && (t.addonId || t.settings)) { + const fallback = tabById(lastCapturableTabId); + if (fallback && !fallback.addonId && !fallback.settings) t = fallback; + else { + // Last resort: any non-addon non-settings tab in the list. + t = tabs.find((x) => !x.addonId && !x.settings) || t; + } + } + if (!t) throw new Error("no active tab"); + if (t.addonId || t.settings) { + throw new Error("no capturable tab — open a page you'd like to shoot first"); + } + const wc = t.view.webContents; + const host = t?.prov?.host || (() => { try { return new URL(wc.getURL()).host; } catch { return ""; } })(); + const mode = String(opts?.mode || "visible"); + const format = opts?.format === "jpeg" ? "jpeg" : "png"; + const quality = Math.max(1, Math.min(100, Number(opts?.quality) || 90)); + // CDP-based capture. Page.captureScreenshot forces a fresh composite + // regardless of occlusion state, so it doesn't blank out when the tab + // view is marked hidden (which happened right after a native menu + // popup closed — the compositor stays throttled for a few frames and + // WebContents.capturePage() would snapshot a stale/transparent frame + // at the correct dimensions, which no size-based retry could catch). + // Reads PNG width/height from the IHDR chunk so we don't need a + // NativeImage roundtrip. + function pngDims(b64) { + const buf = Buffer.from(b64, "base64"); + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; + } + async function cdpCapture({ full = false, rect = null } = {}) { + const wasAttached = wc.debugger.isAttached(); + if (!wasAttached) { + try { wc.debugger.attach("1.3"); } + catch (e) { + if (!/already attached/i.test(String(e?.message))) throw e; + } + } + try { + const p = { format: format === "jpeg" ? "jpeg" : "png" }; + if (format === "jpeg") p.quality = quality; + if (rect) p.clip = { x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1 }; + if (full) p.captureBeyondViewport = true; + const { data } = await wc.debugger.sendCommand("Page.captureScreenshot", p); + const dataUrl = `data:image/${p.format};base64,${data}`; + const dims = p.format === "png" ? pngDims(data) : (rect ? { width: rect.width, height: rect.height } : null); + return { dataUrl, ...(dims || {}) }; + } finally { + // Only detach if WE attached; leave a pre-existing DevTools/other + // consumer's attachment alone. + if (!wasAttached) { try { wc.debugger.detach(); } catch {} } + } + } + if (mode === "visible") { + const r = await cdpCapture(); + console.log(`[addons] [${addonId}] captureTab visible ${r.width}x${r.height}`); + return { dataUrl: r.dataUrl, width: r.width, height: r.height, host, format }; + } + if (mode === "full") { + // Page.captureScreenshot with captureBeyondViewport does the whole + // scrollable page. Before we shoot, force the layout viewport to the + // window's full content width via Emulation.setDeviceMetricsOverride + // so an open sidebar (or any other on-screen chrome that narrowed + // the tab view) doesn't clip the capture — the shot always comes + // back at the page's natural full width, not the visible width. + const wasAttached = wc.debugger.isAttached(); + if (!wasAttached) { + try { wc.debugger.attach("1.3"); } + catch (e) { if (!/already attached/i.test(String(e?.message))) throw e; } + } + let overrode = false; + try { + const winW = (win?.getContentBounds()?.width) || 0; + const tabB = t.view.getBounds(); + const need = winW > tabB.width + 24 ? winW : 0; + if (need > 0) { + // dsf 0 = "let Chromium keep the real device scale factor". + // mobile false, deviceScaleFactor 0 keeps typography sane; + // height 0 tells CDP "use the current viewport height". + await wc.debugger.sendCommand("Emulation.setDeviceMetricsOverride", { + width: need, height: 0, deviceScaleFactor: 0, mobile: false, + }); + overrode = true; + // A frame or two so the reflow settles before we snapshot. + await new Promise((r) => setTimeout(r, 250)); + } + const r = await cdpCapture({ full: true }); + console.log(`[addons] [${addonId}] captureTab full ${r.width}x${r.height}${overrode ? ` (viewport widened to ${need}px)` : ""}`); + return { dataUrl: r.dataUrl, width: r.width, height: r.height, host, format }; + } finally { + if (overrode) { + try { await wc.debugger.sendCommand("Emulation.clearDeviceMetricsOverride"); } catch {} + } + if (!wasAttached) { try { wc.debugger.detach(); } catch {} } + } + } + if (mode === "region") { + const src = String(opts?.overlaySource || ""); + if (!src) throw new Error("region capture needs opts.overlaySource"); + // Overlay script runs in the target tab's world. It's expected to + // resolve (as the executeJavaScript result) with {x,y,w,h} in CSS + // pixels, or null when the user hits Escape / right-clicks. + const rectRaw = await wc.executeJavaScript(src, true); + if (!rectRaw || typeof rectRaw !== "object") { + console.log(`[addons] [${addonId}] captureTab region cancelled`); + return { dataUrl: "", width: 0, height: 0, host, format, cancelled: true }; + } + const rect = { + x: Math.max(0, Math.floor(rectRaw.x)), + y: Math.max(0, Math.floor(rectRaw.y)), + width: Math.max(1, Math.floor(rectRaw.w)), + height: Math.max(1, Math.floor(rectRaw.h)), + }; + const r = await cdpCapture({ rect }); + const s = { width: r.width || rect.width, height: r.height || rect.height }; + console.log(`[addons] [${addonId}] captureTab region ${s.width}x${s.height} @ ${rect.x},${rect.y}`); + return { dataUrl: r.dataUrl, width: s.width, height: s.height, host, format }; + } + throw new Error(`unknown capture mode: ${mode}`); + }, + // saveCapture writes the bytes to Downloads and synthesizes a completed + // download record so the chip shows the file with a Show-in-folder link, + // just like an HTTP save. session.downloadURL(dataUrl) would go through + // will-download, but data URLs come across with a synthetic filename that + // Electron won't let us override in-flight without gymnastics — writing + // directly is deterministic and produces the same user-facing artifact. + saveCapture: async (opts, addonId) => { + const dataUrl = String(opts?.dataUrl || ""); + const m = /^data:([^;,]+);base64,(.+)$/.exec(dataUrl); + if (!m) throw new Error("saveCapture: dataUrl must be base64-encoded"); + const mime = m[1]; + const bytes = Buffer.from(m[2], "base64"); + const raw = String(opts?.filename || "screenshot.png"); + // Strip path separators — add-on-provided filename must not escape the + // downloads folder. + const safe = raw.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 200) || "screenshot.png"; + const dlDir = app.getPath("downloads"); + let target = path.join(dlDir, safe); + // Uniquify: append " (n)" before the extension if the name is taken. + if (fs.existsSync(target)) { + const ext = path.extname(safe); + const stem = safe.slice(0, safe.length - ext.length); + for (let i = 2; i < 10000; i++) { + const cand = path.join(dlDir, `${stem} (${i})${ext}`); + if (!fs.existsSync(cand)) { target = cand; break; } + } + } + try { fs.writeFileSync(target, bytes); } + catch (e) { throw new Error(`saveCapture: write failed: ${e?.message || e}`); } + const id = nextDlId++; + const rec = { + id, + filename: path.basename(target), + url: `internal://addons/${addonId}/${path.basename(target)}`, + mime, + total: bytes.length, + received: bytes.length, + state: "completed", + savePath: target, + startedAt: Date.now(), + }; + downloads.unshift(rec); + emitDownloads(); + console.log(`[addons] [${addonId}] saveCapture wrote ${bytes.length} bytes → ${target}`); + return { savePath: target }; + }, + }); + addonHost.discoverAndActivate(); + const snap = addonHost.snapshot(); + console.log(`[addons] ${snap.installed.length} installed, ${snap.installed.filter((x) => x.enabled).length} enabled, ${snap.sidebarPanels.length} sidebar panels`); +} +// Given a webContents sender URL, work out which add-on folder it lives in. +// Used to gate storage IPC — a page hosted inside addons/<id>/ can only touch +// its own store. +function addonIdForSender(sender) { + try { + const u = new URL(sender.getURL()); + if (u.protocol !== "file:") return null; + const filePath = decodeURIComponent(u.pathname).replace(/^\/+/, ""); + const norm = filePath.replace(/\\/g, "/"); + const dirNorm = addonsUserDir().replace(/\\/g, "/").replace(/\/+$/, ""); + if (!norm.toLowerCase().startsWith(dirNorm.toLowerCase() + "/")) return null; + const rest = norm.slice(dirNorm.length + 1); + const first = rest.split("/")[0]; + return first || null; + } catch { return null; } +} +// In-memory download list. Not persisted: closing the browser clears history +// (the files are still on disk; only the list of "recent downloads" is dropped). +const downloads = []; let nextDlId = 1; const dlItems = new Map(); // id -> DownloadItem +const tabs = []; // { id, view, title, url, prov } +let activeId = null, tabSeq = 0; +const tabById = (id) => tabs.find((t) => t.id === id); +const activeTab = () => tabById(activeId); +// The most-recently-active non-addon tab. captureTab falls back to this when +// the currently-active tab is an add-on-owned page (e.g. the screenshot +// editor itself) — otherwise a re-triggered capture snapshots the editor's +// still-blank canvas instead of the page the user actually wants to shoot. +let lastCapturableTabId = null; + +// Provenance goes to BOTH the top chrome (registry badge + site-info panel) and +// the bottom status line, so the resolver detail lives on the bottom bar. +// Enrich prov with a "collision candidate?" flag so the popover switcher can +// know whether flipping BCNR<->ICANN is meaningful. A BCNR-native TLD (in +// tlds.bch) is NOT a collision candidate — the whole TLD is BCNR's. +function decorate(prov) { + if (!prov || !prov.tld) return prov; + return { ...prov, bcnrNativeTld: isBcnrNativeTld(prov.tld) }; +} +function pushNav(prov) { + const p = decorate(prov); + chrome?.webContents.send("nav", p); + if (popVisible) popover?.webContents.send("site-info", p); + emitPwAvailability(); +} + +function layout() { + if (!win) return; + const { width, height } = win.getContentBounds(); + chrome.setBounds({ x: 0, y: 0, width, height: CHROME_H }); + const bodyH = Math.max(0, height - CHROME_H); + // Sidebar (when visible) claims a fixed slice on the right; the tab views + // shrink to fit alongside it. When hidden, tabs get the full width. + const sideW = sidebarVisible ? sidebarW : 0; + const tabW = Math.max(0, width - sideW); + for (const t of tabs) t.view.setBounds({ x: 0, y: CHROME_H, width: tabW, height: bodyH }); + if (sidebar) sidebar.setBounds({ x: tabW, y: CHROME_H, width: sideW, height: bodyH }); + // Approval overlay sits exactly over the tab area — the page underneath + // keeps running; only pointer input is intercepted. + if (approvalPop) approvalPop.setBounds({ x: 0, y: CHROME_H, width: tabW, height: bodyH }); + positionPopover(); + positionEnginePicker(); + positionDownloads(); + positionAddressPicker(); + positionPwFill(); + if (linkStatusVisible) positionLinkStatus(); +} +function positionPopover() { + if (!popover) return; + const { width } = win.getContentBounds(); + const x = Math.max(6, Math.min(popPos.x, width - POP_W - 6)); + popover.setBounds({ x, y: popPos.y, width: POP_W, height: popH }); +} +function showPopover(show) { + if (!popover) return; + if (show) { + loadOverlays(); + positionPopover(); + // Re-add to the top of the z-order (tabs added later would otherwise cover it). + win.contentView.removeChildView(popover); + win.contentView.addChildView(popover); + popover.setVisible(true); popVisible = true; + popover.webContents.send("site-info", decorate(activeTab()?.prov) || { kind: "home" }); + } else { popover.setVisible(false); popVisible = false; } +} +function positionEnginePicker() { + if (!enginePicker) return; + const { width } = win.getContentBounds(); + const x = Math.max(6, Math.min(epPos.x, width - EP_W - 6)); + enginePicker.setBounds({ x, y: epPos.y, width: EP_W, height: epH }); +} +function showEnginePicker(show) { + if (!enginePicker) return; + if (show) { + loadOverlays(); + positionEnginePicker(); + win.contentView.removeChildView(enginePicker); + win.contentView.addChildView(enginePicker); + enginePicker.setVisible(true); epVisible = true; + enginePicker.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null }); + } else { enginePicker.setVisible(false); epVisible = false; } +} +function positionDownloads() { + if (!downloadsPop) return; + const { width } = win.getContentBounds(); + const x = Math.max(6, Math.min(dlPos.x, width - DL_W - 6)); + downloadsPop.setBounds({ x, y: dlPos.y, width: DL_W, height: dlH }); +} +function showDownloads(show) { + if (!downloadsPop) return; + if (show) { + loadOverlays(); + positionDownloads(); + win.contentView.removeChildView(downloadsPop); + win.contentView.addChildView(downloadsPop); + downloadsPop.setVisible(true); dlVisible = true; + downloadsPop.webContents.send("downloads", downloadsPublic()); + } else { downloadsPop.setVisible(false); dlVisible = false; } +} +function positionAddressPicker() { + if (!addressPicker) return; + const { width } = win.getContentBounds(); + const x = Math.max(6, Math.min(apPos.x, width - apW - 6)); + addressPicker.setBounds({ x, y: apPos.y, width: apW, height: apH }); +} +function positionPwFill() { + if (!pwFillPop) return; + const { width } = win.getContentBounds(); + const x = Math.max(6, Math.min(pwfPos.x, width - PWF_W - 6)); + pwFillPop.setBounds({ x, y: pwfPos.y, width: PWF_W, height: pwfH }); +} +function positionLinkStatus() { + if (!linkStatus || !win) return; + const { width, height } = win.getContentBounds(); + // The pill may grow to (almost) the full width of the tab area — long + // URLs stay readable — and ellipsises past that. It never runs under + // the sidebar. + const tabW = Math.max(0, width - (sidebarVisible ? sidebarW : 0)); + const w = Math.min(Math.max(120, linkStatusW), Math.max(200, tabW - 16)); + const h = Math.max(20, linkStatusH); + linkStatus.setBounds({ x: 0, y: Math.max(0, height - h), width: w, height: h }); +} +function showLinkStatus(url) { + if (!linkStatus) return; + const s = String(url || ""); + if (!s) { + if (linkStatusVisible) { linkStatus.setVisible(false); linkStatusVisible = false; } + return; + } + positionLinkStatus(); + // Raise the pill above any tab view that was added after it. + try { win.contentView.removeChildView(linkStatus); win.contentView.addChildView(linkStatus); } catch {} + linkStatus.setVisible(true); linkStatusVisible = true; + try { linkStatus.webContents.send("link-status-url", s); } catch {} +} +// Open (or close) the sidebar. Loading the panel HTML is lazy — the first +// open triggers loadFile; subsequent opens just flip visibility. +function toggleSidebar() { setSidebar(!sidebarVisible); } +function setSidebar(show, panelId) { + if (!sidebar) return; + const panels = addonHost ? addonHost.getSidebarPanels() : []; + if (show && panels.length === 0) { + // No add-on offers a sidebar panel — silently ignore. Settings surfaces + // the "install one" path. + return; + } + if (show) { + const wantId = panelId || sidebarActivePanelId || panels[0].panelId; + const panel = panels.find((p) => p.panelId === wantId) || panels[0]; + if (sidebarActivePanelId !== panel.panelId) { + sidebarActivePanelId = panel.panelId; + try { sidebar.webContents.loadFile(panel.pageFile); } catch (e) { console.warn("sidebar loadFile failed:", e?.message); } + } + sidebarVisible = true; + sidebar.setVisible(true); + try { win.contentView.removeChildView(sidebar); win.contentView.addChildView(sidebar); } catch {} + layout(); + try { sidebar.webContents.send("sidebar-visibility", true); } catch {} + try { chrome?.webContents.send("sidebar-state", { visible: true, active: sidebarActivePanelId, panels, toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [] }); } catch {} + } else { + sidebarVisible = false; + sidebar.setVisible(false); + layout(); + try { sidebar.webContents.send("sidebar-visibility", false); } catch {} + try { chrome?.webContents.send("sidebar-state", { visible: false, active: sidebarActivePanelId, panels, toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [] }); } catch {} + } +} +function showPwFill(show, matches) { + if (!pwFillPop) return; + if (show) { + loadOverlays(); + positionPwFill(); + win.contentView.removeChildView(pwFillPop); + win.contentView.addChildView(pwFillPop); + pwFillPop.setVisible(true); pwfVisible = true; + pwFillPop.webContents.send("pw-matches", { matches: matches || [] }); + } else { pwFillPop.setVisible(false); pwfVisible = false; } +} +// Compute credential matches for a host. Exact hostname match in phase-1; +// eTLD+1 upgrade queued for A.2.5 (needs the public-suffix-list snapshot). +function pwMatchesForHost(host) { + if (!vaultState || !host) return []; + const h = String(host).toLowerCase(); + return (vaultState.entries || []) + .filter((e) => e.domain === h) + .map((e) => ({ id: e.id, domain: e.domain, username: e.username || "" })); +} +// Emit the current tab's match count to chrome so the toolbar chip can +// show/hide + display the count. Cheap; called on nav + vault unlock/lock. +function emitPwAvailability() { + const t = activeTab(); + const host = t?.prov?.host || ""; + const count = pwMatchesForHost(host).length; + try { chrome?.webContents.send("pw-availability", { host, count }); } catch {} +} +// Inject a small script into the active tab that fills the first visible +// password field + tries to fill the adjacent/associated username field. +// Kept intentionally small — the whole autofill affordance is opt-in +// (user clicks the chip; nothing runs on page load). +async function pwFillIntoActiveTab(entry) { + const t = activeTab(); if (!t) return false; + const wc = t.view.webContents; + const script = `(() => { + const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 4 && r.height > 4; }; + const pwds = [...document.querySelectorAll('input[type=password]:not([disabled])')].filter(visible); + if (!pwds.length) return { ok: false, why: 'no-password-field' }; + const pw = pwds[0]; + const form = pw.closest('form'); + const scope = form ? form.querySelectorAll('input') : document.querySelectorAll('input'); + const users = [...scope].filter((el) => el !== pw && visible(el) && !el.disabled && + /^(?:text|email|tel|url|search|)$/i.test(el.type || 'text') && + /^(?:username|user|email|login|account|id)$/i.test((el.name || el.id || el.autocomplete || '').replace(/[-_]/g, '').toLowerCase())); + const user = users[0] || null; + const fill = (el, v) => { + el.focus(); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; + setter.call(el, v); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }; + if (user && ${JSON.stringify(String(entry.username || ""))}) fill(user, ${JSON.stringify(String(entry.username || ""))}); + fill(pw, ${JSON.stringify(String(entry.password))}); + pw.blur(); + return { ok: true, filledUsername: !!user }; + })()`; + try { + const res = await wc.executeJavaScript(script, true); + return res; + } catch (e) { console.error("pw fill failed:", e?.message); return { ok: false, why: "exec-error" }; } +} +function showAddressPicker(show, suggestions) { + if (!addressPicker) return; + if (show) { + if (!suggestions || !suggestions.length) return showAddressPicker(false); + loadOverlays(); + positionAddressPicker(); + win.contentView.removeChildView(addressPicker); + win.contentView.addChildView(addressPicker); + addressPicker.setVisible(true); apVisible = true; + addressPicker.webContents.send("address-suggest", { suggestions }); + } else { addressPicker.setVisible(false); apVisible = false; } +} +// Public view of a download — no DownloadItem refs leak to the renderer. +const downloadsPublic = () => downloads.map((d) => ({ ...d })); +function emitDownloads() { + const pub = downloadsPublic(); + try { chrome?.webContents.send("downloads", pub); } catch {} + if (dlVisible) try { downloadsPop?.webContents.send("downloads", pub); } catch {} +} +// Attach the will-download listener to the SHARED default session. Every tab's +// WebContents inherits it, so we catch downloads regardless of which tab +// initiated them (including anchor clicks with `download`, form posts serving +// attachments, and manual save-as gestures). +function installDownloadTracker() { + session.defaultSession.on("will-download", (_e, item, wc) => { + const url = item.getURL(); + // Downloads initiated from an addon-file tab (the Screenshot editor's + // "Save" hits this via `<a download>` on a blob: URL) go straight to + // Downloads with a uniquified filename — Electron's default is to pop + // a Save As dialog, which the user has no way to answer from inside + // an add-on tab. This matches the ergonomics of the older, sidebar- + // driven saveCapture path. + try { + const addonTab = wc && tabs.find((t) => t.view && t.view.webContents === wc && t.addonId); + if (addonTab) { + const raw = item.getFilename() || "download.bin"; + const safe = raw.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 200) || "download.bin"; + const dlDir = app.getPath("downloads"); + let target = path.join(dlDir, safe); + if (fs.existsSync(target)) { + const ext = path.extname(safe); + const stem = safe.slice(0, safe.length - ext.length); + for (let i = 2; i < 10000; i++) { + const cand = path.join(dlDir, `${stem} (${i})${ext}`); + if (!fs.existsSync(cand)) { target = cand; break; } + } + } + try { item.setSavePath(target); } catch {} + } + } catch {} + // Update installer? Route it to a fixed temp path, keep it out of the + // visible downloads list, drive updateDownloadState instead so the chip + // can show "ready to install" and one-click install-and-restart. + const isUpdate = updateAvailable && (url === updateAvailable.setupUrl || url === updateAvailable.portableUrl); + if (isUpdate) { + const dst = path.join(app.getPath("temp"), item.getFilename()); + try { item.setSavePath(dst); } catch {} + updateDownloadTotal = item.getTotalBytes() || 0; + updateDownloadReceived = 0; + item.on("updated", () => { + updateDownloadReceived = item.getReceivedBytes(); + updateDownloadTotal = item.getTotalBytes() || updateDownloadTotal; + emitUpdateAvailable(); + }); + item.once("done", (_ev, state) => { + if (state !== "completed") { + updateDownloadState = "failed"; + console.warn(`[update] silent fetch ${state}`); + emitUpdateAvailable(); + return; + } + // NEVER mark "ready" without verifying the file hashes to what the + // manifest promised. Electron's DownloadItem has been observed to + // fire done/completed on truncated payloads (bad Content-Length, + // CDN cache truncation, mid-stream TLS reset the runtime swallowed), + // and 0.3.31's in-app updater then spawned a half-file as setup — + // NSIS integrity check failed silently and the browser was gone. + const savedPath = item.getSavePath() || dst; + const expected = String(updateAvailable && updateAvailable.setupHash || "").toLowerCase(); + if (!expected) { + updateDownloadState = "failed"; + console.warn(`[update] no manifest hash for ${savedPath} — refusing to arm install`); + try { fs.unlinkSync(savedPath); } catch {} + emitUpdateAvailable(); + return; + } + const crypto = require("node:crypto"); + const hash = crypto.createHash("sha256"); + const rs = fs.createReadStream(savedPath); + rs.on("data", (c) => hash.update(c)); + rs.once("error", (e) => { + updateDownloadState = "failed"; + console.warn(`[update] hash read failed: ${e.message}`); + try { fs.unlinkSync(savedPath); } catch {} + emitUpdateAvailable(); + }); + rs.once("end", () => { + const got = hash.digest("hex").toLowerCase(); + if (got !== expected) { + updateDownloadState = "failed"; + console.warn(`[update] SHA-256 mismatch: got ${got}, want ${expected} — refusing to arm install`); + try { fs.unlinkSync(savedPath); } catch {} + emitUpdateAvailable(); + return; + } + updateDownloadPath = savedPath; + updateDownloadState = "ready"; + console.log(`[update] silent fetch complete + verified: ${savedPath}`); + emitUpdateAvailable(); + }); + }); + return; + } + const id = nextDlId++; + const rec = { + id, + filename: item.getFilename(), + url: item.getURL(), + mime: item.getMimeType(), + total: item.getTotalBytes() || 0, + received: 0, + state: "progressing", // progressing | paused | completed | cancelled | interrupted + savePath: "", + startedAt: Date.now(), + }; + downloads.unshift(rec); + dlItems.set(id, item); + emitDownloads(); + item.on("updated", (_ev, state) => { + rec.state = state; // "progressing" | "interrupted" + rec.received = item.getReceivedBytes(); + rec.total = item.getTotalBytes() || rec.total; + rec.savePath = item.getSavePath() || rec.savePath; + emitDownloads(); + }); + item.once("done", (_ev, state) => { + rec.state = state; // "completed" | "cancelled" | "interrupted" + rec.received = item.getReceivedBytes(); + rec.savePath = item.getSavePath() || rec.savePath; + dlItems.delete(id); + emitDownloads(); + }); + }); +} +function setActive(id) { + const switching = id !== activeId; + activeId = id; + if (popVisible) showPopover(false); // don't carry a stale popover across tabs + if (epVisible) showEnginePicker(false); + if (linkStatusVisible) showLinkStatus(""); // clear any lingering hover pill + // A user action that switches to a different tab (New Tab, Settings, + // address-bar nav that opens elsewhere, tab-strip click) shouldn't leave + // the incoming tab hidden behind a maximized sidebar. Auto-restore the + // sidebar to its pre-max width so the tab is actually visible; the + // user can re-maximize when they're done. + if (switching && sidebarMaximized) setSidebarMaximized(false); + // Show the NEW active tab first, THEN hide the others. Reversing this + // order eliminates the "no tab is visible" frame on switch that made the + // tab strip flash — the compositor always has at least one tab view up. + const target = tabs.find((x) => x.id === id); + if (target) target.view.setVisible(true); + for (const t of tabs) if (t.id !== id) t.view.setVisible(false); + const t = activeTab(); + // Track the last active tab that isn't an add-on-owned page so captureTab + // has a sensible fallback when the user re-triggers the dropdown from + // inside (say) the screenshot editor. + if (t && !t.addonId && !t.settings) lastCapturableTabId = t.id; + if (t?.prov) pushNav(t.prov); + chrome.webContents.send("bcnr-offer", t?.bcnrOffer ? { host: t.bcnrOffer.host, tld: t.bcnrOffer.tld, registry: REGISTRY } : null); + emitTabs(); +} +function emitTabs() { + const t = activeTab(); + const wc = t?.view.webContents; + chrome?.webContents.send("tabs", { + tabs: tabs.map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId, loading: !!x.loading, favicon: x.favicon || null, muted: !!x.muted, group: x.group || null, url: x.url || "" })), + collapsedGroups: [...tabGroupCollapsed], + url: t?.url || "", + loading: !!t?.loading, + canBack: wc ? wc.navigationHistory.canGoBack() : false, + canForward: wc ? wc.navigationHistory.canGoForward() : false, + zoom: zoomPercent(t), + }); +} +function setLoading(tab, on) { if (tab && tab.loading !== on) { tab.loading = on; emitTabs(); } } +// ---- page zoom ---- +// Chrome's preset ladder. Zoom is applied with setZoomFactor, which +// Chromium keys per host for the session — so every tab on the same site +// shares the level, and navigating back to a site restores it, exactly +// like Chrome. Settings and add-on tabs never zoom. +const ZOOM_STEPS = [25, 33, 50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300, 400, 500]; +function zoomPercent(t) { + if (!t || t.settings || t.addonId) return 100; + try { return Math.round(t.view.webContents.getZoomFactor() * 100); } catch { return 100; } +} +function zoomStep(t, dir) { + if (!t || t.settings || t.addonId) return; + const cur = zoomPercent(t); + const next = dir > 0 + ? (ZOOM_STEPS.find((z) => z > cur) ?? ZOOM_STEPS[ZOOM_STEPS.length - 1]) + : ([...ZOOM_STEPS].reverse().find((z) => z < cur) ?? ZOOM_STEPS[0]); + zoomSet(t, next); +} +function zoomSet(t, percent) { + if (!t || t.settings || t.addonId) return; + try { t.view.webContents.setZoomFactor(Math.max(25, Math.min(500, percent)) / 100); } catch {} + emitTabs(); +} +ipcMain.handle("zoom-step", (_e, dir) => zoomStep(activeTab(), Number(dir) > 0 ? 1 : -1)); +ipcMain.handle("zoom-reset", () => zoomSet(activeTab(), 100)); +// Pull the tab's real URL from webContents after Electron navigates, so in-page +// clicks (subpages of a BCNR site, subdomain hops, cross-origin redirects) update +// the address bar. Without this, t.url is only refreshed on programmatic loads — +// navigateTab / the collision switcher — and everything else sticks on the parent. +// Internal bns:// → https:// for display, matching navigateTab's convention that +// https:// is what the user sees regardless of how the bytes were fetched. +function refreshTabUrl(tab) { + if (!tab || tab.prov?.kind === "home") return; // home is loadFile → file://; leave t.url = "" + try { + const raw = tab.view.webContents.getURL(); + if (raw && !raw.startsWith("file:")) tab.url = raw.replace(/^bns:\/\//, "https://"); + } catch {} +} +function loadHome(id) { + const t = tabById(id); if (!t) return; + t.url = ""; t.title = "Theseus"; t.prov = { host: "", kind: "home" }; + t.view.webContents.loadFile("home.html"); + if (id === activeId) pushNav(t.prov); + emitTabs(); +} +// Errors we deliberately ignore (Chromium's own reasons that shouldn't show +// a user-facing error page): +// -3 ERR_ABORTED — navigation superseded by another / user pressed Stop +// -20 ERR_BLOCKED_BY_CLIENT — extension/ad-blocker style cancel +const ERROR_CODE_IGNORE = new Set([-3, -20]); +// Chromium error-code buckets. Keep the ranges narrow — anything unmapped +// falls through to the generic error page. +// -105 ERR_NAME_NOT_RESOLVED +// -102 ERR_CONNECTION_REFUSED +// -101 ERR_CONNECTION_RESET +// -118 ERR_CONNECTION_TIMED_OUT +// -100 ERR_CONNECTION_CLOSED +// -7 ERR_TIMED_OUT +// -21 ERR_NETWORK_CHANGED +const ERROR_UNREACHABLE = new Set([-102, -101, -118, -100, -7, -21]); +function pickErrorKind(code, host) { + if (code === -105) { + // Name didn't resolve. If the host is BCNR-eligible (has a real TLD), + // that also means BCNR had no record — otherwise resolveHost/loadBns + // would have served something. Treat as "not registered" to promote + // the register-on-Sirius action. + return isBnsHost(host) ? "name-not-registered" : "name-unreachable"; + } + if (ERROR_UNREACHABLE.has(code)) return "unreachable"; + if (code <= -200 && code >= -299) return "tls"; // ERR_CERT_* range + return "generic"; +} +// Load the branded error surface for a failed navigation. Keeps t.url = +// the attempted URL so the address bar still shows what the user asked +// for and they can edit + retry; refreshTabUrl already skips file:// so +// the error page's own path never leaks back into the bar. +function loadErrorPage(t, id, { url, code, desc }) { + if (!t) return; + const failedUrl = String(url || t.url || ""); + let host = ""; + try { host = new URL(failedUrl).hostname; } catch {} + const kind = pickErrorKind(code, host); + const q = new URLSearchParams({ + kind, host, url: failedUrl, + code: String(code || ""), desc: String(desc || ""), + }).toString(); + t.internalNav = true; + t.title = host ? "Error — " + host : "Load error"; + t.prov = { host, kind: "error", code, desc }; + t.view.webContents.loadFile(path.join(__dirname, "error.html"), { search: q }) + .catch((e) => console.warn("error page load failed:", e?.message)) + .finally(() => { t.internalNav = false; }); + if (id === activeId) pushNav(t.prov); + emitTabs(); +} +// Scrollbar theme injected into every webContents we own — tabs, chrome, +// sidebar, all the floating popovers, and every add-on panel host. Track +// picks up a subtle neutral grey (works on both dark and light surfaces +// without hardcoding either); thumb is the BCH primary #0AC18E so every +// scroll surface reads as Silent Mode's. `scrollbar-color` is the modern +// standard (Chromium ≥ 121); the ::-webkit- fallback gives us fine control +// over width, radius and hover state on older engines. `!important` on the +// track / thumb wins over per-page overrides so the branding stays visible +// even on sites that theme their own scrollbars — but we deliberately don't +// force `scrollbar-width` so a page that has hidden its scrollbars entirely +// keeps that behaviour. +const SCROLLBAR_CSS = ` + html { scrollbar-color: #0AC18E rgba(120,130,150,0.18); } + ::-webkit-scrollbar { width: 12px; height: 12px; background: rgba(120,130,150,0.18) !important; } + ::-webkit-scrollbar-track { background: rgba(120,130,150,0.18) !important; } + ::-webkit-scrollbar-thumb { background: #0AC18E !important; border-radius: 6px; + border: 2px solid transparent; background-clip: padding-box !important; } + ::-webkit-scrollbar-thumb:hover { background: #14e0a5 !important; background-clip: padding-box !important; } + ::-webkit-scrollbar-corner { background: transparent !important; } +`; +function styleScrollbars(wc) { + if (!wc) return; + const inject = () => { try { wc.insertCSS(SCROLLBAR_CSS); } catch {} }; + wc.on("dom-ready", inject); + // For a wc that's already past dom-ready when we attach (fixed views load + // fast during startup), fire once explicitly. + try { if (!wc.isLoading()) inject(); } catch {} +} +function createTab(initial, opts = {}) { + const id = ++tabSeq; + // Non-settings tabs get home-preload so the built-in home page can round- + // trip its editable-cards state via IPC. IPC handlers reject any call + // whose sender URL isn't our own home.html, so a third-party page sees + // the API's shape but can't act through it. + // Preload picker: settings and add-on-file tabs each need their own IPC + // surface; everything else gets home-preload (superset of a plain web + // page's needs, plus the home-page card wiring). + const preloadPath = opts.settings ? path.join(__dirname, "settings-preload.js") + : opts.addonFile ? path.join(__dirname, "addon-tab-preload.js") + : path.join(__dirname, "home-preload.js"); + const view = new WebContentsView({ webPreferences: { preload: preloadPath } }); + // Explicit solid background: transparent (Electron default) makes the tab + // view flash to whatever's underneath (which can be the just-hidden tab or + // black) between setVisible(true) and the first paint on tab switch. A + // solid ground kills that flash. Colour tracks the system theme so light- + // mode users don't get a dark stub while a page paints. + try { view.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {} + const wc = view.webContents; + styleScrollbars(wc); + try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {} + try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {} + applyFingerprint(wc); + const tab = { id, view, title: opts.settings ? "Settings" : "New Tab", url: "", favicon: null, prov: null, settings: !!opts.settings, muted: false, group: null }; + tabs.push(tab); + win.contentView.addChildView(view); + wc.on("page-title-updated", (_e, title) => { + tab.title = title; emitTabs(); + // Keep the top-of-history title in sync when a page's title loads late. + if (history[0] && tab.url && history[0].url === tab.url) { history[0].title = title; saveHistoryDebounced(); } + }); + // Site favicon → tab icon. Take the first URL Electron emits (usually the + // 32x32 or 16x16 <link rel="icon">). We don't proactively clear on nav — + // mainstream browsers keep the old icon until the new one arrives, which + // avoids a flash on every subpage click. + wc.on("page-favicon-updated", (_e, urls) => { + const next = (urls && urls[0]) || null; + if (tab.favicon !== next) { tab.favicon = next; emitTabs(); } + }); + // Chromium fires found-in-page on every findInPage call + on subsequent + // match walks. Forward to chrome so the find bar shows "N of M". + wc.on("found-in-page", (_e, r) => { + if (tab.id !== activeId) return; + try { chrome?.webContents.send("find-result", { activeMatchOrdinal: r.activeMatchOrdinal, matches: r.matches, finalUpdate: r.finalUpdate }); } catch {} + }); + wc.on("did-navigate", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); }); + wc.on("did-navigate-in-page", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); }); + // Ctrl+wheel / pinch: Chromium only reports the intent on Windows and + // Linux, the zoom itself is up to us. + wc.on("zoom-changed", (_e, dir) => zoomStep(tab, dir === "in" ? 1 : -1)); + wc.on("did-start-loading", () => setLoading(tab, true)); + wc.on("did-stop-loading", () => setLoading(tab, false)); + // Failed loads: NAME_NOT_RESOLVED, CONNECTION_REFUSED, cert errors, etc. + // Show the branded error page instead of Chromium's default "This site + // can't be reached". Skip subframe errors, our own programmatic loads, + // and the couple of Chromium codes that fire on normal user actions + // (Stop / superseded nav / extension cancel). + wc.on("did-fail-load", (_e, code, desc, validatedURL, isMainFrame) => { + if (!isMainFrame) return; + if (tab.internalNav) return; + if (ERROR_CODE_IGNORE.has(code)) return; + loadErrorPage(tab, tab.id, { url: validatedURL || tab.url, code, desc }); + }); + // Firefox / Chrome-style bottom-left link preview: fires with the href + // when the pointer enters/leaves an anchor. Empty string = no hover. + wc.on("update-target-url", (_e, url) => { if (tab.id === activeId) showLinkStatus(url); }); + wc.on("will-navigate", (e, u) => { + try { + // Skip our own programmatic loads. fallbackToWeb calls loadURL("https://<host>/") + // and that host is often a BCNR-registered dotted name — without this guard, + // isBnsHost() would send us right back into navigateTab, canceling the + // fallback (blank-page bug 2026-08-02). + if (tab.internalNav) return; + const parsed = new URL(u); + // Intercept the collision-choose posted by the in-tab "Open with…" page, + // apply the remember flag, set a one-shot transient override so loadBns + // doesn't re-prompt, and route via navigateTab so chrome/prov stay in sync. + if (parsed.protocol === "bns:" && parsed.hostname === "collision-choose") { + e.preventDefault(); + const p = parsed.searchParams; + const target = String(p.get("host") || "").toLowerCase(); + const cTld = String(p.get("tld") || "").toLowerCase(); + const cChoice = p.get("choice"); + const cRem = p.get("remember") || "no"; + const rest = p.get("resturl") || "/"; + if (!target) return; + if (cChoice === "bcnr" || cChoice === "icann") { + rememberCollision(target, cTld, cChoice, cRem); + tab.collisionOverride = cChoice; // one-shot, consumed by loadBns + } + return navigateTab(id, target + rest); + } + if (parsed.protocol === "bns:") return; + if (isBnsHost(parsed.hostname)) { + // Only intercept cross-origin navigations. Same-origin (a form submit + // or a subpage link on the site we're currently on) must go through + // Chromium natively — our navigateTab path calls loadURL(url), which + // is always a GET and drops any POST body. That silently broke + // Startpage (whose in-page search form POSTs to /do/search), and any + // other site that POSTs (logins, comment submits, checkouts, ...). + // The site is already loaded from clearnet, so its subsequent + // navigation belongs to clearnet too — no BCNR re-lookup needed. + let currentHost = ""; + try { currentHost = new URL(wc.getURL()).hostname; } catch {} + if (currentHost === parsed.hostname) return; + // Preserve query + fragment. Dropping them broke every search engine + // that submits via a classic form GET (Google's /search?q=foo lost + // the ?q=, so the results page opened blank). + e.preventDefault(); + navigateTab(id, u.replace(/^[a-z]+:\/\//i, "")); + } + } catch {} + }); + // "You have unsaved changes" confirmation: fires when the page's beforeunload + // handler is trying to keep the user on the page (e.g. an unsent form draft, + // an editor with a dirty document). Show a native confirm; on "Leave", call + // preventDefault to override the block. Applies to both link clicks AND our + // programmatic loads (chip switcher, address-bar navigation). + wc.on("will-prevent-unload", (e) => { + const parent = BrowserWindow.getFocusedWindow() || win; + const choice = dialog.showMessageBoxSync(parent, { + type: "question", + buttons: ["Stay on page", "Leave anyway"], + defaultId: 0, + cancelId: 0, + title: "Unsaved changes", + message: "This page is asking you to stay.", + detail: "You may have unsaved changes that will be lost if you leave.", + }); + if (choice === 1) e.preventDefault(); // Leave anyway -> override the beforeunload + }); + // Links that open a new tab: target="_blank", window.open, Ctrl/middle-click. + wc.setWindowOpenHandler(({ url, disposition }) => { + if (url && url !== "about:blank") createTab(url, { background: disposition === "background-tab" }); + return { action: "deny" }; + }); + // Right-click context menu. + wc.on("context-menu", (_e, p) => { + const items = []; + if (p.linkURL) { + items.push( + { label: "Open link in new tab", click: () => createTab(p.linkURL) }, + { label: "Open link in new background tab", click: () => createTab(p.linkURL, { background: true }) }, + { label: "Open link in new window", click: () => openLinkWindow(p.linkURL) }, + { label: "Copy link address", click: () => clipboard.writeText(p.linkURL) }, + { type: "separator" }, + ); + } + // Image context menu: only when the pointer is actually on an image, and + // we have a src to act on. Save-image-as triggers will-download with no + // preset savePath, so Electron shows the native Save As dialog. + if (p.mediaType === "image" && p.srcURL) { + items.push( + { label: "Open image in new tab", click: () => createTab(p.srcURL) }, + { label: "Save image as…", click: () => wc.downloadURL(p.srcURL) }, + { label: "Copy image", click: () => { try { wc.copyImageAt(p.x, p.y); } catch {} } }, + { label: "Copy image address", click: () => clipboard.writeText(p.srcURL) }, + { type: "separator" }, + ); + } + if (p.isEditable) items.push({ role: "cut" }, { role: "copy" }, { role: "paste" }, { type: "separator" }); + else if (p.selectionText) items.push({ role: "copy" }, { type: "separator" }); + // "Search for …" when text is selected. Label uses a short excerpt so + // a long selection doesn't stretch the menu. Opens in a new foreground + // tab so the current page isn't lost — matches Chrome / Firefox UX. + if (p.selectionText) { + const raw = p.selectionText.replace(/\s+/g, " ").trim(); + if (raw) { + const excerpt = raw.length > 40 ? raw.slice(0, 40) + "…" : raw; + items.push( + { label: `Search for "${excerpt.replace(/&/g, "&&")}"`, click: () => createTab(SEARCH(raw)) }, + { type: "separator" }, + ); + } + } + items.push( + { label: "Back", enabled: wc.navigationHistory.canGoBack(), click: () => wc.navigationHistory.goBack() }, + { label: "Forward", enabled: wc.navigationHistory.canGoForward(), click: () => wc.navigationHistory.goForward() }, + { label: "Reload", click: () => wc.reload() }, + ); + Menu.buildFromTemplate(items).popup(); + }); + layout(); + if (opts.background) { view.setVisible(false); emitTabs(); } + else setActive(id); + if (opts.settings) { + tab.prov = { host: "", kind: "home" }; + const settingsOpts = opts.settingsSection ? { hash: opts.settingsSection } : undefined; + wc.loadFile("settings.html", settingsOpts); + if (id === activeId) pushNav(tab.prov); + emitTabs(); + } else if (opts.addonFile) { + // Same treatment as settings: leave the address bar empty (refreshTabUrl + // skips file:// anyway), title arrives via page-title-updated. loadFile + // takes the query as `search` (Node's url.format shape) without the ?. + tab.prov = { host: "", kind: "home" }; + tab.addonId = opts.addonFile.addonId; + wc.loadFile(opts.addonFile.absPath, opts.addonFile.query ? { search: opts.addonFile.query } : undefined); + if (id === activeId) pushNav(tab.prov); + emitTabs(); + } else if (initial) navigateTab(id, initial); + else loadHome(id); + return id; +} +function closeTab(id) { + const i = tabs.findIndex((t) => t.id === id); + if (i < 0) return; + const [t] = tabs.splice(i, 1); + win.contentView.removeChildView(t.view); + t.view.webContents.destroy?.(); + if (tabs.length === 0) { createTab(); return; } + if (activeId === id) setActive(tabs[Math.max(0, i - 1)].id); + else emitTabs(); +} + +function createWindow() { + chromeReadyDone = false; + overlaysLoaded = false; + // Window ground + chrome view ground both match chrome.html's --bg for the + // active theme. The chrome view used to sit on Chromium's default white + // until chrome.html painted, which is the "white strip over a dark + // window" users saw on a slow launch. + const uiBg = nativeTheme.shouldUseDarkColors ? "#0f1420" : "#e6e8ec"; + win = new BrowserWindow({ + width: 1220, height: 840, title: "Theseus Navigator", backgroundColor: uiBg, + // Taskbar / titlebar icon. Packaged builds ship build/icon.ico as + // extraResource; dev reads the source file directly. + icon: app.isPackaged + ? path.join(process.resourcesPath, "icon.ico") + : path.join(__dirname, "build", "icon.ico"), + }); + chrome = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") } }); + try { chrome.setBackgroundColor(uiBg); } catch {} + win.contentView.addChildView(chrome); + styleScrollbars(chrome.webContents); + chrome.webContents.loadFile("chrome.html"); + // The overlays below are created now (cheap) but their pages load via + // deferOverlayLoad → loadOverlays() after chrome has painted. + // Floating site-info overlay (hidden until the address-bar badge is clicked). + popover = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "popover-preload.js") } }); + try { popover.setBackgroundColor("#00000000"); } catch {} + win.contentView.addChildView(popover); + styleScrollbars(popover.webContents); + deferOverlayLoad(popover, "popover.html"); + popover.setVisible(false); + // Floating engine-picker overlay (custom dropdown with real favicons). + enginePicker = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "engine-picker-preload.js") } }); + try { enginePicker.setBackgroundColor("#00000000"); } catch {} + win.contentView.addChildView(enginePicker); + styleScrollbars(enginePicker.webContents); + deferOverlayLoad(enginePicker, "engine-picker.html"); + enginePicker.setVisible(false); + // Floating downloads panel — shows active + recent downloads. + downloadsPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "downloads-preload.js") } }); + try { downloadsPop.setBackgroundColor("#00000000"); } catch {} + win.contentView.addChildView(downloadsPop); + styleScrollbars(downloadsPop.webContents); + deferOverlayLoad(downloadsPop, "downloads.html"); + downloadsPop.setVisible(false); + // Floating address-bar suggestions dropdown. + addressPicker = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "address-picker-preload.js") } }); + try { addressPicker.setBackgroundColor("#00000000"); } catch {} + win.contentView.addChildView(addressPicker); + styleScrollbars(addressPicker.webContents); + deferOverlayLoad(addressPicker, "address-picker.html"); + addressPicker.setVisible(false); + // Floating password-fill picker. + pwFillPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "pw-fill-preload.js") } }); + try { pwFillPop.setBackgroundColor("#00000000"); } catch {} + win.contentView.addChildView(pwFillPop); + styleScrollbars(pwFillPop.webContents); + deferOverlayLoad(pwFillPop, "pw-fill.html"); + pwFillPop.setVisible(false); + // Link-hover status pill (bottom-left of window). + linkStatus = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "link-status-preload.js") } }); + try { linkStatus.setBackgroundColor("#00000000"); } catch {} + win.contentView.addChildView(linkStatus); + styleScrollbars(linkStatus.webContents); + deferOverlayLoad(linkStatus, "link-status.html"); + linkStatus.setVisible(false); + // Add-on sidebar host. Doesn't loadFile until an add-on panel is opened — + // styleScrollbars hooks dom-ready, which fires per navigation, so every + // panel loaded into this view (Aegis, Screenshot, etc.) picks up the + // brand scrollbar the moment its DOM is ready. + sidebar = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "sidebar-preload.js") } }); + win.contentView.addChildView(sidebar); + styleScrollbars(sidebar.webContents); + sidebar.setVisible(false); + // Add-on approval overlay (approval-modal capability). Transparent view + // over the tab area, loaded once, shown per request. + approvalPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "approval-preload.js") } }); + try { approvalPop.setBackgroundColor("#00000000"); } catch {} + win.contentView.addChildView(approvalPop); + styleScrollbars(approvalPop.webContents); + deferOverlayLoad(approvalPop, "approval.html"); + approvalPop.setVisible(false); + // Everything that isn't needed to paint the toolbar waits for chrome.html. + // dom-ready, NOT did-finish-load: the load event also waits for every + // subresource, and the bookmarks bar pulls its favicons over bns:// — + // a BNS lookup plus a network fetch each. On a slow link that held the + // load event (and with it every restored tab) for seconds while the + // toolbar sat blank. By dom-ready the toolbar's scripts have run and its + // IPC listeners exist, which is all the rest of boot needs. The fallback + // timer covers a chrome.html that fails to load at all. + chrome.webContents.once("dom-ready", onChromeReady); + setTimeout(onChromeReady, 8000); + win.on("resize", layout); + layout(); +} + +async function navigateTab(id, input) { + const t = tabById(id); if (!t) return; + let q = String(input).trim(); + if (!q) return; + // Address bar doubles as a search box: anything that isn't a URL/hostname + // (a bare word, or a phrase with spaces) becomes a web search. + if (!looksLikeUrl(q)) q = SEARCH(q); + const raw = q.replace(/^[a-z]+:\/\//i, ""); + const host = raw.split("/")[0].toLowerCase(); + const rest = raw.slice(host.length) || "/"; + // Reflect the target URL immediately so the address bar doesn't keep + // showing the previous page's URL for the whole load duration. Without + // this, emitTabs below (triggered by setLoading) carries the old t.url + // and the chrome renderer paints it, since we blurred the input on Enter. + t.url = "https://" + host + (rest === "/" ? "" : rest); + setLoading(t, true); // show the loading indicator immediately (covers BNS resolution) + + t.nav = (t.nav || 0) + 1; + // Legacy "also on BCNR" chip state — kept clean; the passive switch is gone + // now that BCNR is priority for every host. + t.bcnrOffer = null; + if (id === activeId) chrome.webContents.send("bcnr-offer", null); + + // BCNR-first for every dotted host. loadBns falls through to https://<host> + // on NXDOMAIN or resolver failure, so clearnet still works. + if (isBnsHost(host)) return loadBns(t, id, host, rest, tldOf(host)); + + // Non-BNS-eligible input: raw IP, localhost, single-label — load direct. + t.url = q.includes("://") ? q : "https://" + q; + await t.view.webContents.loadURL(t.url); + t.prov = { host, kind: "web" }; + if (id === activeId) pushNav(t.prov); + emitTabs(); +} + +// Load a name from BCNR into a tab. Called for every dotted host — BCNR is +// tried first; on NXDOMAIN or resolver failure we always fall through to the +// clearnet (https://<host><rest>) so the user isn't stranded when the chain +// is down or the name isn't registered. +async function loadBns(t, id, host, rest, tld) { + const registry = registryOf(tld); + if (id === activeId) pushNav({ host, kind: "resolving", tld, registry }); + const fallbackToWeb = async (reason) => { + t.url = "https://" + host + (rest === "/" ? "" : rest); + t.internalNav = true; + try { await t.view.webContents.loadURL(t.url); } + catch (e) { console.warn("fallback loadURL failed:", e?.message); } + finally { t.internalNav = false; } + t.prov = { host, kind: "web", note: `fallback: ${reason}`, tld, registry }; + if (id === activeId) pushNav(t.prov); + emitTabs(); + }; + // Strip and capture the one-shot ?_collision=bcnr param that collision-choose + // adds when redirecting back after the user picked BCDN in soft mode. Ignored + // (harmless) if absent. + let urlChoice = null; + try { + const u = new URL(rest, `bns://${host}/`); + if (u.searchParams.has("_collision")) { + urlChoice = u.searchParams.get("_collision"); + u.searchParams.delete("_collision"); + rest = u.pathname + (u.search ? u.search : ""); + } + } catch {} + let entry; + try { entry = await resolveHost(host); } + catch { setLoading(t, false); return fallbackToWeb("BCNR unreachable"); } + // Address-bar display: show https:// even for BCDN sites. Rationale — BCNR + // replaces DNS (name resolution), NOT HTTP (transport). For s3/ip/p records + // the actual delivery IS HTTPS under the hood; for h records the content is + // on-chain (no HTTP at all, but https:// is the least surprising display). + // The BCDN/ICANN badge is the source-of-truth for which registry served us; + // the URL scheme is a convention, kept consistent so users' muscle memory + // holds. (bns:// is an internal Electron protocol implementation detail.) + t.url = "https://" + host + (rest === "/" ? "" : rest); + if (!entry) { setLoading(t, false); return fallbackToWeb("no BCNR record"); } + + // ---- Collision policy (BCNR ↔ ICANN) -------------------------------------- + // BCNR has the name; if the TLD is BCNR-native we're done (whole TLD is BCNR's, + // no collision possible). Otherwise it's a collision candidate — the same name + // *might* also exist on ICANN; the policy decides which to load. + // Full model: SilentMode/Argus/DESIGN-collision-modes.md. + if (!isBcnrNativeTld(tld)) { + // Transient per-tab override (from the chip switcher) — one-shot, consumed here. + const transient = t.collisionOverride; t.collisionOverride = null; + const policy = settings.collisionPolicy || "bcnr-first"; + // Precedence: urlChoice (one-shot from collision-choose) > transient (chip + // switcher) > persistent per-name/per-TLD > global policy. + let choice = urlChoice || transient || overrideFor(host, tld); + if (!choice) { + if (policy === "icann-first") choice = "icann"; + else if (policy === "soft") { + // In-tab full-page "Open with…" prompt (loaded from disk; buttons post + // back through bns://collision-choose/ which serveBns handles above). + setLoading(t, false); + const q = new URLSearchParams({ host, tld, resturl: rest }).toString(); + await t.view.webContents.loadFile(path.join(__dirname, "collision.html"), { search: q }); + t.prov = { host, kind: "resolving", tld, registry }; + if (id === activeId) pushNav(t.prov); + emitTabs(); + return; + } else choice = "bcnr"; // bcnr-first — the default + } + if (choice === "icann") { setLoading(t, false); return fallbackToWeb("collision → ICANN"); } + } + + await t.view.webContents.loadURL(`bns://${host}${rest}`); + // Source badge must mirror what serveBns actually picks — subdomain-with-ip + // routes via the parent's server, not via Sia. See serveBns for the rule. + const _isSub = host !== entry.name; + const src = (_isSub && entry.records.ip) ? "direct server" + : entry.records.h ? "on-chain (chain)" + : entry.records.s3 ? "Sia network" + : entry.records.ip ? "direct server" + : (!_isSub && entry.records.p) ? "mirror" + : entry.records.u ? "redirect" + : "record"; + t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), tld, registry }; + if (id === activeId) pushNav(t.prov); + emitTabs(); +} + +ipcMain.handle("navigate", (_e, input) => navigateTab(activeId, input)); +ipcMain.handle("search", (_e, q) => navigateTab(activeId, SEARCH(q))); +ipcMain.handle("new-tab", () => createTab()); +ipcMain.handle("close-tab", (_e, id) => closeTab(id)); +ipcMain.handle("switch-tab", (_e, id) => setActive(id)); +// Tab context menu backing IPCs. All scoped to a specific tab id so the +// active tab doesn't have to be the one the user right-clicked. +ipcMain.handle("tab-reload", (_e, id) => { const t = tabById(id); if (t) try { t.view.webContents.reload(); } catch {} }); +ipcMain.handle("tab-duplicate", (_e, id) => { + const t = tabById(id); if (!t) return; + const target = t.url || ""; + if (target) createTab(target); else createTab(); +}); +ipcMain.handle("tab-mute", (_e, id, on) => { + const t = tabById(id); if (!t) return false; + const want = typeof on === "boolean" ? on : !t.muted; + try { t.view.webContents.setAudioMuted(want); t.muted = want; emitTabs(); return want; } + catch { return t.muted; } +}); +ipcMain.handle("tab-group", (_e, id, color) => { + const t = tabById(id); if (!t) return null; + // color: null | "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple" + const allowed = new Set(["red","orange","yellow","green","cyan","blue","purple"]); + const next = allowed.has(color) ? color : null; + t.group = next; + // Cluster: move this tab so all same-group tabs sit contiguously. Place + // it right after the LAST existing tab of that group; if there are no + // other members yet, leave it in place. When a tab is removed from a + // group (color === null) we don't reorder — the visual break is enough. + if (next) { + const idx = tabs.indexOf(t); + let insertAfter = -1; + for (let i = 0; i < tabs.length; i++) { + if (i !== idx && tabs[i].group === next) insertAfter = i; + } + if (insertAfter !== -1) { + tabs.splice(idx, 1); + const dst = insertAfter > idx ? insertAfter : insertAfter + 1; + tabs.splice(dst, 0, t); + } + } + emitTabs(); + return t.group; +}); +// Groups get a collapsed/expanded state, per-color, in-memory only (resets +// on relaunch). Flipping this doesn't touch tabs — the renderer just hides +// tabs whose group is collapsed and shows the group chip in their place. +const tabGroupCollapsed = new Set(); // colors currently collapsed +ipcMain.handle("tab-group-toggle", (_e, color) => { + if (!color) return false; + if (tabGroupCollapsed.has(color)) tabGroupCollapsed.delete(color); else tabGroupCollapsed.add(color); + // Piggy-back on emitTabs so the chrome renderer receives the change. + emitTabs(); + return tabGroupCollapsed.has(color); +}); +ipcMain.handle("tab-bookmark", (_e, id) => { + const t = tabById(id); if (!t) return false; + const url = t.url; const title = t.title || url; + if (!url) return false; + if (bookmarks.some((b) => b.url === url)) return true; // already saved + bookmarks.unshift({ url, title, addedAt: Date.now() }); + saveBookmarks(); emitBookmarks(); + return true; +}); +// Native tab context-menu popup. Anchored at the (chrome-view-relative) +// point the renderer sends. Prevents the visible "chrome view expands to +// hold a DOM menu" gap between the tabs and the tab content — the OS-owned +// popup floats above every WebContentsView and doesn't affect layout at +// all. Mirrors the DOM-menu shape: Reload / Duplicate / Group (submenu) / +// Add to Bookmarks / Mute / Close. +ipcMain.handle("tab-context-menu-popup", (e, tabId, rect) => { + if (chrome && e.sender !== chrome.webContents) throw new Error("tab-context-menu-popup: untrusted sender"); + const t = tabById(tabId); + if (!t) return false; + const colorLabels = [ + { id: "red", label: "Red" }, { id: "orange", label: "Orange" }, + { id: "yellow", label: "Yellow" }, { id: "green", label: "Green" }, + { id: "cyan", label: "Cyan" }, { id: "blue", label: "Blue" }, + { id: "purple", label: "Purple" }, + ]; + const groupSubmenu = [ + { label: "None" + (t.group ? "" : " ✓"), click: () => { t.group = null; emitTabs(); } }, + { type: "separator" }, + ...colorLabels.map((c) => ({ + label: c.label + (t.group === c.id ? " ✓" : ""), + click: () => { + t.group = c.id; + // Cluster tabs of the same colour, matching the "tab-group" IPC. + const idx = tabs.indexOf(t); + let insertAfter = -1; + for (let i = 0; i < tabs.length; i++) { + if (i !== idx && tabs[i].group === c.id) insertAfter = i; + } + if (insertAfter !== -1) { + tabs.splice(idx, 1); + const dst = insertAfter > idx ? insertAfter : insertAfter + 1; + tabs.splice(dst, 0, t); + } + emitTabs(); + }, + })), + ]; + const bmDisabled = !t.url; + const template = [ + { label: "Reload", click: () => { try { t.view.webContents.reload(); } catch {} } }, + { label: "Duplicate", click: () => { if (t.url) createTab(t.url); else createTab(); } }, + { label: "Group", submenu: groupSubmenu }, + { label: "Add to Bookmarks", enabled: !bmDisabled, click: () => { + if (bmDisabled) return; + const url = t.url, title = t.title || url; + if (!bookmarks.some((b) => b.url === url)) { + bookmarks.unshift({ url, title, addedAt: Date.now() }); + saveBookmarks(); emitBookmarks(); + } + } }, + { label: t.muted ? "Unmute" : "Mute", click: () => { + try { t.view.webContents.setAudioMuted(!t.muted); t.muted = !t.muted; emitTabs(); } catch {} + } }, + { type: "separator" }, + { label: "Close", click: () => closeTab(t.id) }, + ]; + const popup = Menu.buildFromTemplate(template); + const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 }; + const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0))); + const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0))); + popup.popup({ window: win, x, y }); + return true; +}); +ipcMain.handle("move-tab", (_e, id, targetId, place) => { + const src = tabs.findIndex((t) => t.id === id); + const dst = tabs.findIndex((t) => t.id === targetId); + if (src < 0 || dst < 0 || src === dst) return; + const [t] = tabs.splice(src, 1); + const insertAt = tabs.findIndex((x) => x.id === targetId); + tabs.splice(place === "after" ? insertAt + 1 : insertAt, 0, t); + emitTabs(); +}); +ipcMain.handle("go-home", () => loadHome(activeId)); +// --- Add-on framework ------------------------------------------------------- +// Sidebar toggle + panel switching, driven from the chrome toolbar. `panelId` +// is the namespaced string the loader emits (`<addonId>:<panelId>`) — no +// coercion, main matches it verbatim. +ipcMain.handle("sidebar-toggle", () => { toggleSidebar(); return sidebarVisible; }); +// Drag events stream in from sidebar-preload while the user is holding the +// grip. Delta is px per mousemove; we clamp, layout, and debounce the save. +let _sidebarSaveTimer = null; +ipcMain.handle("sidebar-drag", (_e, deltaPx) => { + const d = Number(deltaPx) || 0; + // Drag-to-resize exits maximize mode — the user is asking for a specific + // width. We snap out of maximize first so the delta lands on the pre-max + // width rather than on the (huge) maximized value. + if (sidebarMaximized) { + sidebarMaximized = false; + sidebarW = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, sidebarPreMaxW || SIDEBAR_W_DEFAULT)); + try { sidebar?.webContents.send("sidebar-max-change", false); } catch {} + } + const next = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, sidebarW + d)); + if (next === sidebarW) return sidebarW; + sidebarW = next; + layout(); + settings.sidebarWidth = sidebarW; + clearTimeout(_sidebarSaveTimer); + _sidebarSaveTimer = setTimeout(saveSettings, 400); + return sidebarW; +}); +ipcMain.handle("sidebar-open", (_e, panelId) => { setSidebar(true, panelId); return sidebarVisible; }); +ipcMain.handle("sidebar-close", () => { setSidebar(false); return false; }); +// Panel-driven sidebar maximize: fills the window with the sidebar (tab +// area shrinks to zero-width), remembering the pre-max width so restore +// returns cleanly. Doesn't persist across sessions — a fresh launch +// always starts at the saved settings.sidebarWidth. Layout runs so tab +// views and other floating popovers reposition against the new bounds. +function setSidebarMaximized(next) { + const wanted = !!next; + if (wanted === sidebarMaximized) return sidebarMaximized; + if (wanted) { + sidebarPreMaxW = sidebarW; + sidebarW = sidebarMaxWidth(); + } else { + sidebarW = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, sidebarPreMaxW || SIDEBAR_W_DEFAULT)); + } + sidebarMaximized = wanted; + layout(); + try { sidebar?.webContents.send("sidebar-max-change", sidebarMaximized); } catch {} + return sidebarMaximized; +} +ipcMain.handle("sidebar-maximize", () => setSidebarMaximized(true)); +ipcMain.handle("sidebar-restore", () => setSidebarMaximized(false)); +ipcMain.handle("sidebar-toggle-max", () => setSidebarMaximized(!sidebarMaximized)); +ipcMain.handle("sidebar-is-max", () => sidebarMaximized); +ipcMain.handle("sidebar-state", () => ({ + visible: sidebarVisible, + active: sidebarActivePanelId, + panels: addonHost ? addonHost.getSidebarPanels() : [], + toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [], +})); +// Toolbar-menu click: chrome sends the {addonId, itemId} of the item the +// user picked. Route to the add-on's registered "menu-select" handler. We +// trust chrome as the sender (same convention as sidebar-toggle et al) — +// it's the only WebContents we ever load chrome.html into. +ipcMain.handle("addon-menu-select", async (e, addonId, itemId) => { + if (!addonHost) throw new Error("addon host not ready"); + if (chrome && e.sender !== chrome.webContents) throw new Error("addon-menu-select: untrusted sender"); + const id = String(addonId || ""); + const iid = String(itemId || ""); + if (!id || !iid) throw new Error("addon-menu-select: addonId and itemId required"); + // Confirm the menu item was actually declared by this add-on — a rogue + // renderer message can't invoke a handler with an item id the manifest + // never listed. + const menu = addonHost.getToolbarMenus().find((m) => m.addonId === id); + if (!menu) throw new Error(`addon-menu-select: no toolbar menu for "${id}"`); + if (!menu.items.find((it) => it.id === iid)) throw new Error(`addon-menu-select: item "${iid}" not declared by "${id}"`); + return addonHost.dispatch(id, "menu-select", { id: iid }, { from: "toolbar-menu" }); +}); +// Toolbar-menu popup — render as a NATIVE OS menu anchored at the button. +// A renderer-DOM popover in chrome.html gets clipped by the chrome view's +// own bounds (height = CHROME_H) and then hidden behind the tab view below +// it. Menu.popup uses an OS window, so it can extend anywhere. +ipcMain.handle("toolbar-menu-popup", async (e, addonId, rect) => { + if (!addonHost) throw new Error("addon host not ready"); + if (chrome && e.sender !== chrome.webContents) throw new Error("toolbar-menu-popup: untrusted sender"); + const id = String(addonId || ""); + const menu = addonHost.getToolbarMenus().find((m) => m.addonId === id); + if (!menu) throw new Error(`toolbar-menu-popup: no menu for "${id}"`); + // Capture the clicked item id here; dispatch runs from the popup's + // `callback` below, AFTER the menu is torn down. Firing synchronously + // in the click handler catches the parent window still non-foreground + // (the OS menu popup is on top), which leaves Chromium's occlusion + // tracker marking the tab view as hidden — WebContents.capturePage() + // then snapshots a blank frame at the correct dimensions (not a 0x0 + // that our retry could catch). Deferring until after callback lets + // focus return to the parent so the compositor is live at capture time. + let picked = null; + const template = menu.items.map((it) => ({ + label: (it.icon ? String(it.icon) + " " : "") + String(it.label || it.id), + click: () => { picked = it.id; }, + })); + const popup = Menu.buildFromTemplate(template); + const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 }; + const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0))); + const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0))); + popup.popup({ window: win, x, y, callback: () => { + try { chrome?.webContents.send("toolbar-menu-closed"); } catch {} + if (!picked) return; // user hit Escape or clicked outside + // Explicitly return foreground to the parent window; on some Windows + // configurations Electron's popup teardown alone leaves the app in a + // "not-quite-foreground" state until the next OS message pump tick. + try { win?.focus(); } catch {} + // Settle before the handler runs. Windows takes several frames to + // restore foreground and un-throttle the compositor; the previous + // 120 ms was too short on slower / higher-latency setups and led to + // blank frames. 250 ms is what the "full page" mode already uses. + // captureTab itself no longer relies on capturePage anyway (it goes + // through CDP Page.captureScreenshot, which forces a fresh composite), + // but the delay still helps addons that do their own DOM work in the + // click handler before capture. + const iid = picked; + setTimeout(() => { + addonHost.dispatch(id, "menu-select", { id: iid }, { from: "toolbar-menu" }) + .catch((err) => console.warn(`[addons] menu-select ${id}.${iid} failed:`, err?.message || err)); + }, 250); + }}); + return true; +}); +// Close the tab a full-tab add-on page lives in. Sender identifies the +// webContents; we match it against our tab list and close that tab only. +// A page hosted elsewhere (or a spoofed sender not in the tab set) gets +// nothing. +ipcMain.handle("addon-tab-close", (e) => { + const senderId = e.sender.id; + const tab = tabs.find((t) => t.view?.webContents?.id === senderId); + if (!tab) return false; + closeTab(tab.id); + return true; +}); +// Read-side of Settings' Add-ons tab. +ipcMain.handle("addons-list", () => { + if (!addonHost) return { installed: [], sidebarPanels: [] }; + const snap = addonHost.snapshot(); + // Mark bundled add-ons so the extensions UI can render them differently + // (Aegis + friends look built-in rather than removable extensions). A + // sibling folder in bundled-addons/ with the same manifest id is proof + // the addon ships with Theseus; seedBundledAddons keeps them in sync. + let bundledIds = new Set(); + try { + for (const e of fs.readdirSync(bundledAddonsDir(), { withFileTypes: true })) { + if (!e.isDirectory()) continue; + try { + const m = JSON.parse(fs.readFileSync(path.join(bundledAddonsDir(), e.name, "addon.json"), "utf8")); + if (m && m.id) bundledIds.add(String(m.id)); + } catch {} + } + } catch {} + snap.installed = snap.installed.map((a) => ({ ...a, bundled: a.id ? bundledIds.has(a.id) : false })); + return snap; +}); +// Toggle an add-on's enabled state. Discovery re-runs so newly-enabled +// add-ons activate immediately and newly-disabled ones drop out — no +// restart required. +ipcMain.handle("addons-set-enabled", (_e, id, enabled) => { + if (!id || typeof id !== "string") return false; + const disabled = new Set(Array.isArray(settings.disabledAddons) ? settings.disabledAddons : []); + if (enabled) disabled.delete(id); else disabled.add(id); + settings.disabledAddons = [...disabled]; + saveSettings(); + // Rebuild the host so state matches settings. + if (addonHost) addonHost.discoverAndActivate(); + // Sidebar may need to close if its current panel came from an add-on we + // just disabled. + const panels = addonHost ? addonHost.getSidebarPanels() : []; + if (sidebarVisible && sidebarActivePanelId && !panels.find((p) => p.panelId === sidebarActivePanelId)) { + sidebarActivePanelId = null; + setSidebar(false); + } + return true; +}); +// Reveal an add-on's folder in the OS file manager — the primary way users +// edit / uninstall add-ons. +ipcMain.handle("addons-reveal", (_e, folder) => { + if (typeof folder !== "string" || !folder) return false; + const norm = path.normalize(folder); + const base = addonsUserDir(); + if (!norm.toLowerCase().startsWith(base.toLowerCase())) return false; // don't leak arbitrary paths + try { shell.showItemInFolder(norm); return true; } catch { return false; } +}); +ipcMain.handle("addons-open-dir", () => { + try { shell.openPath(addonsUserDir()); return true; } catch { return false; } +}); +// Manual "Check for updates" from Settings > Extensions. Runs the same +// checkAndStageUpdates the boot timer runs; returns a snapshot of the +// staged dir so the UI can render "Update to <ver> — restart to apply". +ipcMain.handle("addons-check-updates", async () => { + const stagedDir = addonsStagedDir(); + let report = []; + let skipped = null; + try { + const result = await addonUpdater.checkAndStageUpdates({ + addonsDir: addonsUserDir(), + stagedDir, + pubkeysHex: ADDON_UPDATE_PUBKEYS, + logger: (...a) => console.log("[addons]", ...a), + }); + report = result?.report || []; + skipped = result?.skipped || null; + } catch (e) { console.warn("[addons] check-updates failed:", e?.message || e); } + return { report, skipped, staged: listStagedAddons(stagedDir) }; +}); +ipcMain.handle("addons-list-staged", () => listStagedAddons(addonsStagedDir())); +function listStagedAddons(stagedDir) { + const out = []; + let entries = []; + try { entries = fs.readdirSync(stagedDir, { withFileTypes: true }); } catch { return out; } + for (const de of entries) { + if (!de.isDirectory()) continue; + try { + const m = JSON.parse(fs.readFileSync(path.join(stagedDir, de.name, "addon.json"), "utf8")); + if (m?.id && m?.version) out.push({ id: m.id, name: m.name || m.id, version: m.version, folder: path.join(stagedDir, de.name) }); + } catch {} + } + return out; +} +ipcMain.handle("addons-reload", () => { + if (!addonHost) return false; + addonHost.discoverAndActivate(); + return true; +}); +// --- Add-on messaging + capabilities ----------------------------------------- +// Panel → add-on: the sidebar panel's file:// URL tells us which add-on it +// belongs to (same gate as storage). The add-on's onMessage handler runs in +// main and its return value is the response. +ipcMain.handle("addon-msg", async (e, msg, payload) => { + const id = addonIdForSender(e.sender); + if (!id || !addonHost) throw new Error("not an add-on panel"); + return addonHost.dispatch(id, String(msg), payload, { from: "panel" }); +}); +// Page → add-on: only a real tab whose committed URL matches the add-on's +// page-inject origins may talk to it, and only through messages the add-on +// registered. Origin is "<scheme>://<host>" (bns:// shown as https://). +function tabForSender(sender) { return tabs.find((t) => t.view.webContents === sender) || null; } +function pageOriginOf(url) { + try { + const u = new URL(String(url).replace(/^bns:\/\//i, "https://")); + return u.protocol && u.host ? `${u.protocol}//${u.host}` : null; + } catch { return null; } +} +ipcMain.handle("addon-page-msg", async (e, addonId, msg, payload) => { + const tab = tabForSender(e.sender); + if (!tab || !addonHost) throw new Error("not a page"); + const url = e.sender.getURL(); + const id = String(addonId || ""); + if (!addonHost.pageAllowed(id, url)) throw new Error(`add-on "${id}" is not injected on this page`); + const origin = pageOriginOf(url); + if (!origin) throw new Error("opaque origin"); + return addonHost.dispatch(id, String(msg), payload, { from: "page", origin, tabId: tab.id }); +}); +// Synchronous — the inject preload has to know what to run before the page's +// own scripts start. Decided against the sender's committed URL; the href the +// preload reports is only logged when it disagrees. +// Assigning event.returnValue sends the reply at once, so it is set exactly +// once at the end. +function injectionsForSender(e, href) { + const tab = tabForSender(e.sender); + if (!tab || !addonHost) return []; + const url = e.sender.getURL(); + if (!url || url.startsWith("file:")) return []; + if (href && href !== url) console.log(`[addons] inject: preload href ${href} ≠ committed ${url}`); + const origin = pageOriginOf(url); + const list = addonHost.injectionsFor(url).map((x) => ({ ...x, origin })); + if (list.length) console.log(`[addons] inject ${list.map((x) => x.id).join(",")} into ${origin}`); + return list; +} +ipcMain.on("addon-inject-scripts", (e, href) => { e.returnValue = injectionsForSender(e, href); }); +// Approval overlay. One request at a time; later callers queue behind the +// visible one so two dapps can't race each other for the same click. +let approvalPop = null; +const approvalQueue = []; +let approvalCurrent = null; // { reqId, resolve } +let approvalSeq = 0; +function pumpApproval() { + if (approvalCurrent || !approvalQueue.length || !approvalPop) return; + const next = approvalQueue.shift(); + approvalCurrent = next; + // The overlay's page loads lazily after first paint; a dapp on a restored + // tab can ask for approval before that, so wait for the page rather than + // sending into an empty renderer (the request would silently hang). + overlayReady(approvalPop).then(() => { + if (approvalCurrent !== next) return; + try { + approvalPop.webContents.send("approval-show", next.req); + approvalPop.setVisible(true); + try { win.contentView.removeChildView(approvalPop); win.contentView.addChildView(approvalPop); } catch {} + layout(); + approvalPop.webContents.focus(); + } catch (err) { + approvalCurrent = null; + next.resolve("cancel"); + console.warn("[addons] approval show failed:", err?.message); + } + }); +} +function showApprovalModal(opts, addonId) { + const a = addonHost && addonHost.getInstalled().find((x) => x.manifest && x.manifest.id === addonId); + const req = { + reqId: ++approvalSeq, + addonId, + addonName: a ? a.manifest.name : addonId, + title: String(opts.title || "Approve?"), + body: opts.body == null ? "" : String(opts.body), + origin: opts.origin == null ? "" : String(opts.origin), + rows: Array.isArray(opts.rows) ? opts.rows.map((r) => ({ label: String(r.label ?? ""), value: String(r.value ?? ""), mono: !!r.mono, strong: !!r.strong })) : [], + actions: Array.isArray(opts.actions) ? opts.actions.map((x) => ({ id: String(x.id), label: String(x.label || x.id), primary: !!x.primary, danger: !!x.danger })) : [], + checkbox: opts.checkbox ? { id: String(opts.checkbox.id || "always"), label: String(opts.checkbox.label || "Always allow") } : null, + // Optional dropdown; a non-empty chosen value comes back as "+<id>=<value>". + select: opts.select && Array.isArray(opts.select.options) ? { + id: String(opts.select.id || "choice"), label: String(opts.select.label || ""), + options: opts.select.options.map((o) => ({ value: String(o.value ?? ""), label: String(o.label ?? o.value ?? "") })), + } : null, + }; + return new Promise((resolve) => { + approvalQueue.push({ req, resolve }); + pumpApproval(); + }); +} +ipcMain.handle("approval-pick", (e, reqId, action, checked, extra) => { + if (!approvalPop || e.sender !== approvalPop.webContents) return false; + if (!approvalCurrent || approvalCurrent.req.reqId !== reqId) return false; + const cur = approvalCurrent; + approvalCurrent = null; + approvalPop.setVisible(false); + let result = String(action || "cancel"); + if (result !== "cancel" && checked && cur.req.checkbox) result += "+" + cur.req.checkbox.id; + if (result !== "cancel" && cur.req.select && extra && cur.req.select.options.some((o) => o.value === extra)) { + result += "+" + cur.req.select.id + "=" + extra; + } + cur.resolve(result); + pumpApproval(); + return true; +}); +// --- Add-on storage (origin-gated to <userData>/addons/<id>/...) ------------ +// Add-on HTML pages get storage.get/set/all via sidebar-preload.js. Main +// derives the add-on id from the sender's file:// URL so a page can only +// touch its own store; any file:// outside addons/ returns nothing. +ipcMain.handle("addon-storage-get", (e, key, fallback) => { + const id = addonIdForSender(e.sender); + if (!id) return fallback ?? null; + try { + const raw = JSON.parse(fs.readFileSync(path.join(addonsDataDir(), id + ".json"), "utf8")); + return key in raw ? raw[key] : (fallback ?? null); + } catch { return fallback ?? null; } +}); +ipcMain.handle("addon-storage-set", (e, key, value) => { + const id = addonIdForSender(e.sender); + if (!id) return false; + if (typeof key !== "string" || key.length > 128) return false; + const file = path.join(addonsDataDir(), id + ".json"); + let store = {}; + try { store = JSON.parse(fs.readFileSync(file, "utf8")); } catch {} + store[key] = value; + try { fs.mkdirSync(addonsDataDir(), { recursive: true }); fs.writeFileSync(file, JSON.stringify(store)); return true; } + catch (err) { console.warn(`[addons] storage.set failed for ${id}:`, err?.message); return false; } +}); +ipcMain.handle("addon-storage-all", (e) => { + const id = addonIdForSender(e.sender); + if (!id) return {}; + try { return JSON.parse(fs.readFileSync(path.join(addonsDataDir(), id + ".json"), "utf8")); } + catch { return {}; } +}); +// Error-page actions. All origin-gated to error.html so a third-party page +// that happens to see the API shape (home-preload exposes it on every tab) +// can't drive them. +ipcMain.handle("error-retry", (e, url) => { + if (!isErrorPageSender(e.sender)) return false; + if (typeof url !== "string" || !url) return false; + navigateTab(activeId, url); + return true; +}); +ipcMain.handle("error-home", (e) => { + if (!isErrorPageSender(e.sender)) return false; + loadHome(activeId); + return true; +}); +ipcMain.handle("error-search", (e, text) => { + if (!isErrorPageSender(e.sender)) return false; + const q = String(text || "").trim(); + if (!q) return false; + navigateTab(activeId, SEARCH(q)); + return true; +}); +ipcMain.handle("error-register", (e, host) => { + if (!isErrorPageSender(e.sender)) return false; + const h = String(host || "").trim().toLowerCase(); + if (!h) return false; + // Sirius's registrar UI takes ?prefill=<name>; if it ignores an unknown + // param the user just lands on the form and types it themselves. + const url = "https://sirius.x/register.html?prefill=" + encodeURIComponent(h); + navigateTab(activeId, url); + return true; +}); +// "Did you mean" for the error page. Returns up to `limit` BCNR-registered +// names within a small edit distance of `host`, same TLD only. Uses the +// warm sharedIndex — no network call, no wait — so an offline user still +// gets suggestions if the index warmed at least once. Ranks by ascending +// distance, then alphabetical for a stable list. +function levenshtein(a, b) { + const m = a.length, n = b.length; + if (Math.abs(m - n) > 3) return 4; // early-out, we only care about ≤2 + const prev = new Array(n + 1); for (let j = 0; j <= n; j++) prev[j] = j; + const cur = new Array(n + 1); + for (let i = 1; i <= m; i++) { + cur[0] = i; + for (let j = 1; j <= n; j++) { + const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1; + cur[j] = Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost); + } + for (let j = 0; j <= n; j++) prev[j] = cur[j]; + } + return prev[n]; +} +ipcMain.handle("error-bns-similar", (e, host) => { + if (!isErrorPageSender(e.sender)) return []; + const h = String(host || "").trim().toLowerCase(); + if (!h || !sharedIndex || typeof sharedIndex.keys !== "function") return []; + const tld = tldOf(h); + if (!tld) return []; + const cands = []; + const maxDist = 2; + for (const key of sharedIndex.keys()) { + if (typeof key !== "string") continue; + // Same TLD only — a typo like "games.x" → "game.x" or "gaeme.x" → "game.x". + if (!key.endsWith("." + tld)) continue; + if (key === h) continue; + const d = levenshtein(h, key); + if (d <= maxDist) cands.push({ name: key, dist: d }); + if (cands.length > 200) break; // hard cap so a huge index doesn't stall the render + } + cands.sort((a, b) => (a.dist - b.dist) || a.name.localeCompare(b.name)); + return cands.slice(0, 3).map((c) => c.name); +}); +ipcMain.handle("error-open-external", (e, url) => { + if (!isErrorPageSender(e.sender)) return false; + // Allowlist Silent Mode domains only — no arbitrary external opens from + // a page that visits when things are already going wrong. + const ok = typeof url === "string" && /^https:\/\/(silentmode\.st|silentmode\.bch|sirius\.x|theseus\.x|navigate\.st)(\/|$)/i.test(url); + if (!ok) return false; + try { shell.openExternal(url); return true; } catch { return false; } +}); +// Update chip: user clicked the download button → download the installer +// through Theseus itself. session.downloadURL triggers the same +// will-download handler our own downloads panel listens on, so the file +// lands in the user's Downloads folder AND appears in the in-app +// downloads chip with progress + Show-in-folder. No system browser +// jump, no "why did another browser open?" confusion. +ipcMain.handle("open-update-download", (_e, url) => { + const ok = typeof url === "string" && (url.startsWith("https://dl.silentmode.st/") || url.startsWith("https://silentmode.st/")); + if (!ok) return false; + try { + // The downloads toolbar button spins + shows a progress badge as + // will-download / did-update updates fire, so the user sees the + // transfer without us having to force-open the downloads panel. + session.defaultSession.downloadURL(url); + return true; + } catch (e) { console.warn("update download failed:", e?.message); return false; } +}); +ipcMain.handle("dismiss-update", () => { updateDismissedThisSession = true; emitUpdateAvailable(); return true; }); +// Find-in-page. Chrome renderer's find bar drives this: the bar sends +// `find-in-page` with the query + direction; main proxies to the active +// tab's webContents.findInPage. Chromium raises `found-in-page` on each +// keystroke with the running match count / active match ordinal; we +// forward that back to chrome so the bar can render "3 of 12". +ipcMain.handle("find-in-page", (_e, query, opts = {}) => { + const t = activeTab(); if (!t) return false; + if (typeof query !== "string" || !query) { try { t.view.webContents.stopFindInPage("clearSelection"); } catch {} return false; } + try { + t.view.webContents.findInPage(query, { + forward: opts.forward !== false, + findNext: !!opts.findNext, // false = new query; true = jump next/prev + matchCase: !!opts.matchCase, + }); + return true; + } catch { return false; } +}); +ipcMain.handle("find-stop", () => { + const t = activeTab(); if (!t) return false; + try { t.view.webContents.stopFindInPage("clearSelection"); return true; } catch { return false; } +}); +// Just the app version — no network. Used by Settings > General so the +// user can see which Theseus they're on without clicking "Check for updates". +ipcMain.handle("app-version", () => app.getVersion()); +// Clean relaunch — used by the Aegis card to apply a staged add-on update +// (promotion happens on next boot; this is just how the user gets there). +ipcMain.handle("app-restart", () => { try { app.relaunch(); } catch {} app.quit(); }); +ipcMain.handle("recheck-update", async () => { + // Manual "Check for updates" also un-dismisses any chip the user closed + // in this session — they're actively asking to see the status, so honour + // that. Report current app version too so the UI can render "you're on + // vN.M.O" when no newer build is out. + updateDismissedThisSession = false; + await checkForUpdate(); + return { updateAvailable, currentVersion: app.getVersion() }; +}); +// One-click "Install & restart". Requires the silent pre-fetch to have +// finished (updateDownloadState === "ready"). Launches the setup with +// /S (silent, skips the wizard) and --force-run (electron-builder's NSIS +// convention for "start the app when the install finishes"), then quits +// Theseus so the installer can overwrite it. The user sees the browser +// disappear for a couple of seconds and come back on the new version — +// no manual relaunch needed. Verified E2E on 0.3.33 → 0.3.34 in the +// 2026-09-08 session; the 0.3.37 → 0.3.39 update ran WITHOUT --force-run +// (the flag was dropped in the 0.3.31 rewrite once it was clear /S alone +// installs correctly) and the user reported the missing relaunch — this +// commit puts --force-run back on so the auto-restart is part of the +// standard flow again. --updated stays out: the earlier E2E showed it +// wasn't load-bearing correctness for our NSIS config. +ipcMain.handle("install-update-now", () => { + if (updateDownloadState !== "ready" || !updateDownloadPath) return false; + try { + const p = spawn(updateDownloadPath, ["/S", "--force-run"], { detached: true, stdio: "ignore" }); + p.unref(); + } catch (e) { console.warn("update spawn failed:", e?.message); return false; } + // Give the child a moment to inherit our arguments before we exit. + setTimeout(() => app.quit(), 400); + return true; +}); +// Home page editable cards. Origin-gated to home.html — random pages that +// snoop the preload can't act on the local file. +ipcMain.handle("home-cards-get", (e) => isHomePageSender(e.sender) ? loadHomeCards() : []); +ipcMain.handle("home-cards-set", (e, cards) => { if (!isHomePageSender(e.sender)) return false; if (!Array.isArray(cards)) return false; saveHomeCards(cards); return true; }); +ipcMain.handle("home-cards-reset", (e) => { if (!isHomePageSender(e.sender)) return false; try { fs.unlinkSync(homeCardsFile()); } catch {} return true; }); +ipcMain.handle("go-back", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); }); +ipcMain.handle("go-forward", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); }); +ipcMain.handle("reload", (_e, hard) => { + const wc = activeTab()?.view.webContents; + if (!wc) return; + try { hard ? wc.reloadIgnoringCache() : wc.reload(); } catch {} +}); +ipcMain.handle("stop", () => { const t = activeTab(); try { t?.view.webContents.stop(); } catch {} setLoading(t, false); }); +ipcMain.handle("toggle-tor", () => { torState === "off" ? startTor() : stopTor(); }); +// Open (or focus) the Settings tab. Optional section slug ("passwords", +// "addons", …) lands directly on that sidebar entry when the page loads. +// settings.html watches for a fragment on load and an IPC message when +// already loaded. +function openSettingsTab(section) { + const slug = typeof section === "string" && /^[a-z0-9-]{1,32}$/i.test(section) ? section.toLowerCase() : ""; + const ex = tabs.find((t) => t.settings); + if (ex) { + setActive(ex.id); + if (slug) { try { ex.view.webContents.send("focus-section", slug); } catch {} } + return; + } + createTab(null, { settings: true, settingsSection: slug }); +} +ipcMain.handle("open-settings", (_e, section) => openSettingsTab(section)); +ipcMain.handle("toggle-site-info", (_e, rect) => { + if (popVisible) return showPopover(false); + if (rect) popPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; + showPopover(true); +}); +ipcMain.handle("close-site-info", () => showPopover(false)); +ipcMain.handle("popover-resize", (_e, h) => { popH = Math.max(90, Math.min(380, Math.round(h) || 210)); if (popVisible) positionPopover(); }); + +// ---- Collision-mode: per-tab live switcher + policy control ----------------- +// Flip the active tab between BCNR and ICANN for its current host, optionally +// remembering the choice per-name / per-TLD (like the OS "Open with…" flow). +ipcMain.handle("collision-switch", (_e, arg) => switchRegistry(arg)); +async function switchRegistry(arg) { + const t = activeTab(); if (!t?.prov?.host) return null; + const host = t.prov.host, tld = tldOf(host); + // Accept both "bcdn" (client-facing product name) and "bcnr" (internal key). + const raw = arg?.choice; + const choice = (raw === "bcdn" || raw === "bcnr") ? "bcnr" + : (raw === "icann") ? "icann" + : (t.prov.kind === "ok" ? "icann" : "bcnr"); // flip the current one + // Optional persistent memory ("Always use…" from the popover switcher). + rememberCollision(host, tld, choice, arg?.remember || "no"); + const registry = registryOf(tld); + // DIRECT navigation — bypass navigateTab/loadBns entirely so nothing in the + // collision-decision path can trigger a re-prompt on an explicit user switch. + // The user clicked the switcher; they've made their choice. Just load it. + setLoading(t, true); + t.internalNav = true; + try { + if (choice === "icann") { + t.url = "https://" + host + "/"; + await t.view.webContents.loadURL(t.url); + t.prov = { host, kind: "web", note: "switched to ICANN", tld, registry }; + } else { + t.url = "https://" + host + "/"; // display: https://; internal fetch: bns:// + await t.view.webContents.loadURL(`bns://${host}/`); + const rec = entries.get(host); + const r = rec?.entry?.records || {}; + // Mirror serveBns's subdomain-first-ip rule so the badge does not lie. + const _isSub = rec?.entry?.name && host !== rec.entry.name; + const src = (_isSub && r.ip) ? "direct server" + : r.h ? "on-chain (chain)" + : r.s3 ? "Sia network" + : r.ip ? "direct server" + : (!_isSub && r.p) ? "mirror" + : r.u ? "redirect" + : "record"; + t.prov = { host, kind: "ok", source: src, category: rec?.entry?.category, records: Object.keys(r), tld, registry }; + } + } catch (e) { console.warn("collision-switch load failed:", e?.message); } + finally { t.internalNav = false; setLoading(t, false); } + if (t.id === activeId) pushNav(t.prov); + emitTabs(); +} +// Ariadne's Thread menu — the registry button at the end of the address +// bar. Replaces the BCDN/ICANN segmented chips: one icon that never +// overflows the bar, a native popup with the switch, the per-name / per-TLD +// memory, the collision policy, and a jump to the resolver settings. +ipcMain.handle("registry-menu-popup", (e, rect) => { + if (chrome && e.sender !== chrome.webContents) throw new Error("registry-menu-popup: untrusted sender"); + const t = activeTab(); + const prov = t?.prov || null; + const host = String(prov?.host || ""); + const tld = String(prov?.tld || (host ? tldOf(host) : "") || ""); + const onBcdn = prov?.kind === "ok"; + const onIcann = prov?.kind === "web"; + const isCand = !!tld && (onBcdn || onIcann) && !isBcnrNativeTld(tld); + const current = onBcdn ? "bcnr" : "icann"; + const policyItem = (value, label) => ({ + label, type: "radio", checked: settings.collisionPolicy === value, + click: () => { settings.collisionPolicy = value; saveSettings(); }, + }); + const template = [ + { label: host ? `Ariadne's Thread · ${host}` : "Ariadne's Thread", enabled: false }, + { type: "separator" }, + { label: "Serve from BCDN", type: "radio", checked: onBcdn, enabled: isCand || onBcdn, + click: () => { if (!onBcdn) switchRegistry({ choice: "bcnr", remember: "no" }).catch(() => {}); } }, + { label: "Serve from ICANN", type: "radio", checked: onIcann, enabled: isCand || onIcann, + click: () => { if (!onIcann) switchRegistry({ choice: "icann", remember: "no" }).catch(() => {}); } }, + { label: "Remember", enabled: !!host, submenu: [ + { label: host ? `Always open ${host} this way` : "Always open this site this way", enabled: isCand, + click: () => rememberCollision(host, tld, current, "name") }, + { label: tld ? `Always open .${tld} names this way` : "Always open this TLD this way", enabled: isCand, + click: () => rememberCollision(host, tld, current, "tld") }, + { type: "separator" }, + { label: "Forget remembered choices", click: () => { collisions = { byName: {}, byTld: {} }; saveCollisions(); } }, + ] }, + { type: "separator" }, + { label: "When a name exists on both registries", submenu: [ + policyItem("bcnr-first", "BCDN first"), + policyItem("icann-first", "ICANN first"), + policyItem("soft", "Ask each time"), + ] }, + { type: "separator" }, + { label: "Ariadne's Thread settings…", click: () => openSettingsTab("plugins") }, + ]; + const popup = Menu.buildFromTemplate(template); + const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 }; + const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0))); + const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0))); + popup.popup({ window: win, x, y }); +}); +ipcMain.handle("collision-state", () => ({ + policy: settings.collisionPolicy, + byName: collisions.byName, + byTld: collisions.byTld, + bcnrTlds, +})); +ipcMain.handle("collision-set-policy", (_e, p) => { + if (["bcnr-first", "icann-first", "soft"].includes(p)) { settings.collisionPolicy = p; saveSettings(); } + return settings.collisionPolicy; +}); +ipcMain.handle("collision-reset", () => { collisions = { byName: {}, byTld: {} }; saveCollisions(); return true; }); +// Ariadne's Thread system-wide resolver — installed by AriadneResolver-Setup.exe +// as two Windows Scheduled Tasks ("BNS Resolver Daemon" + "BNS Sia Bridge"). +// Turning them off means non-Theseus browsers stop resolving BCDN names on +// this machine; Theseus itself uses its own in-process resolver so it's +// unaffected. Toggling requires admin (tasks run as SYSTEM) — start/stop go +// through an elevated powershell that UAC-prompts once per action. +// +// As of 0.3.23 Ariadne is NOT bundled inside Theseus. Install / Update stream +// AriadneResolver-Setup-<v>.exe directly from silentmode.st and verify its +// SHA-256 against the on-site releases manifest before spawning it, so +// Ariadne's release cadence is decoupled from ours. +const ARIADNE_TASKS = ["BNS Resolver Daemon", "BNS Sia Bridge"]; +// Inno Setup's AppId + "_is1" is the uninstall registry key. Check both +// native and WOW6432 in case Inno installed either way. +const ARIADNE_UNINSTALL_KEYS = [ + 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{7E7A5F1C-3B4E-4C8A-9E1D-ARIADNERSLVR}_is1', + 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{7E7A5F1C-3B4E-4C8A-9E1D-ARIADNERSLVR}_is1', +]; +// Same manifest the Theseus updater reads (UPDATE_MANIFEST_URL). The copy on +// silentmode.st is a mirror that has lagged behind dl.silentmode.st (2026-09-09: +// it still listed Theseus 0.3.31 while dl had 0.3.44), so the Ariadne "Update" +// button was checking against a stale version list. +const ARIADNE_MANIFEST_URL = "https://dl.silentmode.st/releases-manifest.json"; +const ARIADNE_DL_ORIGIN = "https://dl.silentmode.st"; +const ARIADNE_MANIFEST_TTL_MS = 30 * 60 * 1000; +let ariadneManifestCache = null; // { at:number, entry:{version,filename,sha256,url}|null } + +// GET the releases manifest, find the win-x64 ariadne-resolver entry, return +// {version, filename, sha256, url}. Cached 30 min in-process; opening the +// Settings panel every few seconds does not spam silentmode.st. On any +// failure (offline, 5xx, malformed JSON) returns null and the caller shows +// bundledVersion:null / canUpdate:false gracefully. +function ariadneManifestFetch() { + const now = Date.now(); + if (ariadneManifestCache && now - ariadneManifestCache.at < ARIADNE_MANIFEST_TTL_MS) { + return Promise.resolve(ariadneManifestCache.entry); + } + return new Promise((resolve) => { + const https = require("node:https"); + const req = https.request(ARIADNE_MANIFEST_URL, { + method: "GET", timeout: 8000, + headers: { "user-agent": "TheseusNavigator/ariadne-updater" }, + }, (r) => { + if (r.statusCode !== 200) { r.resume(); ariadneManifestCache = { at: now, entry: null }; return resolve(null); } + const chunks = []; + r.on("data", (c) => chunks.push(c)); + r.on("end", () => { + try { + const j = JSON.parse(Buffer.concat(chunks).toString("utf8")); + const rel = (j.releases || []).find((x) => x.id === "ariadne-resolver" && x.platform === "win-x64"); + if (!rel) { ariadneManifestCache = { at: now, entry: null }; return resolve(null); } + const filename = Object.keys(rel.files || {}).find((f) => /^AriadneResolver-Setup-.*\.exe$/i.test(f)); + if (!filename) { ariadneManifestCache = { at: now, entry: null }; return resolve(null); } + const entry = { version: rel.version, filename, sha256: String(rel.files[filename] || "").toLowerCase(), url: ARIADNE_DL_ORIGIN + "/" + filename }; + ariadneManifestCache = { at: now, entry }; + resolve(entry); + } catch { ariadneManifestCache = { at: now, entry: null }; resolve(null); } + }); + }); + req.on("timeout", () => req.destroy(new Error("manifest timeout"))); + req.on("error", () => { ariadneManifestCache = { at: now, entry: null }; resolve(null); }); + req.end(); + }); +} + +// Stream the .exe to a per-session temp file, hashing as we go. Reject on +// hash mismatch (and delete the file) so a wrong-hash binary is never spawned. +// The SHA-256 is authoritative because the manifest itself is served over +// HTTPS -- silentmode.st TLS -> manifest.json -> hash -> verified .exe. +function ariadneDownloadInstaller(entry) { + return new Promise((resolve, reject) => { + const https = require("node:https"); + const crypto = require("node:crypto"); + const dst = path.join(app.getPath("temp"), `ariadne-${entry.version}-${Date.now()}.exe`); + const req = https.request(entry.url, { + method: "GET", timeout: 60000, + headers: { "user-agent": "TheseusNavigator/ariadne-updater" }, + }, (r) => { + if (r.statusCode !== 200) { r.resume(); return reject(new Error(`download ${entry.url} -> HTTP ${r.statusCode}`)); } + const hash = crypto.createHash("sha256"); + const out = fs.createWriteStream(dst); + r.on("data", (c) => hash.update(c)); + r.pipe(out); + out.on("finish", () => { + const got = hash.digest("hex").toLowerCase(); + if (got !== entry.sha256) { + try { fs.unlinkSync(dst); } catch {} + return reject(new Error(`SHA-256 mismatch: got ${got}, want ${entry.sha256}`)); + } + resolve(dst); + }); + out.on("error", (e) => { try { fs.unlinkSync(dst); } catch {}; reject(e); }); + }); + req.on("timeout", () => req.destroy(new Error("download timeout"))); + req.on("error", reject); + req.end(); + }); +} + +function ariadneQueryState() { + return new Promise((resolve) => { + const { spawn } = require("child_process"); + // One shell round-trip for both bits of info: + // - task states for BNS Resolver Daemon + BNS Sia Bridge + // - installed version + quiet-uninstall string from Inno's registry key + const ps = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", + "$names=@('BNS Resolver Daemon','BNS Sia Bridge');" + + "$names | ForEach-Object { $t = Get-ScheduledTask -TaskName $_ -ErrorAction SilentlyContinue;" + + " if ($t) { \"$_=$($t.State)\" } else { \"$_=MISSING\" } };" + + "$keys=@(" + ARIADNE_UNINSTALL_KEYS.map((k) => "'" + k + "'").join(",") + ");" + + "foreach ($k in $keys) { if (Test-Path $k) { $r=Get-ItemProperty $k; \"__VER__=$($r.DisplayVersion)\"; \"__UNINSTALL__=$($r.QuietUninstallString)\"; break } }" + ], { windowsHide: true }); + let out = ""; + ps.stdout.on("data", (d) => { out += d; }); + ps.on("close", async () => { + const lines = out.trim().split(/\r?\n/).filter(Boolean); + const map = Object.fromEntries(lines.map((l) => { const i = l.lastIndexOf("="); return [l.slice(0, i), l.slice(i + 1)]; })); + const primary = map["BNS Resolver Daemon"]; + const installedVersion = map.__VER__ || null; + const quietUninstall = map.__UNINSTALL__ || null; + const latest = await ariadneManifestFetch(); + const latestVersion = latest ? latest.version : null; + const canUpdate = !!(installedVersion && latestVersion && cmpVersions(latestVersion, installedVersion) > 0); + let state; + if (!primary || primary === "MISSING") state = installedVersion ? "stopped" : "not-installed"; + else if (primary === "Running") state = "running"; + else state = "stopped"; + // The "bundledVersion" field name is kept for renderer compatibility -- + // it now carries the latest version advertised by silentmode.st's + // releases manifest, not a version physically bundled with Theseus. + resolve({ state, installedVersion, bundledVersion: latestVersion, canUpdate, hasUninstaller: !!quietUninstall }); + }); + ps.on("error", () => resolve({ state: "not-installed", installedVersion: null, bundledVersion: null, canUpdate: false, hasUninstaller: false })); + }); +} +function cmpVersions(a, b) { + const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0); + const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0); + const n = Math.max(pa.length, pb.length); + for (let i = 0; i < n; i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d < 0 ? -1 : 1; } + return 0; +} +function ariadneSetState(on) { + return new Promise((resolve, reject) => { + const { spawn } = require("child_process"); + // Off = stop AND disable, on = enable AND start. The daemon task has an + // at-startup trigger, so a plain Stop-ScheduledTask came back on the + // next reboot and "Turn off" silently didn't stick. + // + // The inner script goes across as -EncodedCommand (base64 UTF-16LE). The + // previous version embedded it in a double-quoted string on the OUTER + // powershell's command line, which interpolated `$t` to nothing before + // the elevated shell ever saw it — the elevated shell got + // `foreach ( in ...)`, failed to parse, and the outer shell still exited + // 0, so the toggle reported success while doing nothing. + const inner = on + ? `$ok = $true +foreach ($t in 'BNS Resolver Daemon','BNS Sia Bridge') { + try { Enable-ScheduledTask -TaskName $t -ErrorAction Stop | Out-Null; Start-ScheduledTask -TaskName $t -ErrorAction Stop } + catch { if ($t -eq 'BNS Resolver Daemon') { $ok = $false } } +} +if ($ok) { exit 0 } else { exit 2 }` + : `$ok = $true +foreach ($t in 'BNS Resolver Daemon','BNS Sia Bridge') { + try { Stop-ScheduledTask -TaskName $t -ErrorAction Stop } catch {} + try { Disable-ScheduledTask -TaskName $t -ErrorAction Stop | Out-Null } + catch { if ($t -eq 'BNS Resolver Daemon') { $ok = $false } } +} +if ($ok) { exit 0 } else { exit 2 }`; + const encoded = Buffer.from(inner, "utf16le").toString("base64"); + // -PassThru + exit $p.ExitCode: the elevated shell's exit code (0 ok, + // 2 = daemon task missing) reaches us; a declined UAC prompt makes + // Start-Process throw, which exits the outer shell non-zero. + const ps = spawn("powershell.exe", ["-NoProfile", "-Command", + `$p = Start-Process powershell -Verb RunAs -Wait -WindowStyle Hidden -PassThru -ArgumentList '-NoProfile','-NonInteractive','-EncodedCommand','${encoded}'; exit $p.ExitCode`], { windowsHide: true }); + ps.on("close", (code) => { + if (code === 0) resolve(true); + else if (code === 2) reject(new Error("the 'BNS Resolver Daemon' scheduled task is missing — reinstall Ariadne's Thread")); + else reject(new Error("UAC declined or task failed (exit " + code + ")")); + }); + ps.on("error", (e) => reject(e)); + }); +} +// Fetch manifest -> download+verify AriadneResolver-Setup-<v>.exe -> spawn +// Inno silently+elevated (/VERYSILENT /SUPPRESSMSGBOXES /NORESTART). The +// downloaded .exe is deleted whether the install succeeds or fails, so a +// wrong-hash abort never leaves a suspect binary behind. +async function ariadneInstall() { + const entry = await ariadneManifestFetch(); + if (!entry) throw new Error("could not reach silentmode.st releases manifest"); + const exePath = await ariadneDownloadInstaller(entry); + const { spawn } = require("child_process"); + return new Promise((resolve, reject) => { + // -PassThru + exit $p.ExitCode so Inno's own exit code reaches us; a bare + // -Wait always returned 0 and a failed silent install looked like success. + const ps = spawn("powershell.exe", ["-NoProfile", "-Command", + `$p = Start-Process -FilePath '${exePath.replace(/'/g, "''")}' -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' -Verb RunAs -Wait -PassThru; exit $p.ExitCode` + ], { windowsHide: true }); + const cleanup = () => { try { fs.unlinkSync(exePath); } catch {} }; + ps.on("close", (code) => { cleanup(); code === 0 ? resolve(true) : reject(new Error("installer exited " + code)); }); + ps.on("error", (e) => { cleanup(); reject(e); }); + }); +} +// Run Inno's own quiet uninstaller. Reads the QuietUninstallString from the +// registry (already ends in / VERYSILENT) and spawns it elevated. +function ariadneUninstall() { + const { spawn } = require("child_process"); + return new Promise((resolve, reject) => { + // Read the uninstall string fresh — a stale cache could point at a moved + // file. Extract the path (may be quoted) + any trailing args. + const cmd = `$keys=@(${ARIADNE_UNINSTALL_KEYS.map((k) => "'" + k + "'").join(",")});foreach ($k in $keys){if(Test-Path $k){$r=Get-ItemProperty $k;Write-Host $r.QuietUninstallString;break}}`; + const ps = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", cmd], { windowsHide: true }); + let out = ""; + ps.stdout.on("data", (d) => { out += d; }); + ps.on("close", () => { + const uninstallCmd = out.trim(); + if (!uninstallCmd) return reject(new Error("Ariadne uninstaller not registered")); + // uninstallCmd typically: "C:\Program Files (x86)\...\unins000.exe" /VERYSILENT + // Spawn it elevated. Inno's silent uninstall respects /VERYSILENT so no UI. + const runCmd = `$p = Start-Process -FilePath 'cmd.exe' -ArgumentList '/c ${uninstallCmd.replace(/'/g, "''")} /SUPPRESSMSGBOXES /NORESTART' -Verb RunAs -Wait -PassThru; exit $p.ExitCode`; + const ps2 = spawn("powershell.exe", ["-NoProfile", "-Command", runCmd], { windowsHide: true }); + ps2.on("close", (code) => code === 0 ? resolve(true) : reject(new Error("uninstaller exited " + code))); + ps2.on("error", reject); + }); + ps.on("error", reject); + }); +} +// Update = run the bundled installer over the top. Inno Setup detects the +// same AppId and upgrades in place. Same UAC dance as install. +const ariadneUpdate = ariadneInstall; +ipcMain.handle("ariadne-state", () => ariadneQueryState().catch(() => ({ state: "not-installed", installedVersion: null, bundledVersion: null, canUpdate: false, hasUninstaller: false }))); +ipcMain.handle("ariadne-toggle", (_e, on) => ariadneSetState(!!on).catch((e) => ({ ok: false, error: e?.message || String(e) }))); +ipcMain.handle("ariadne-install", () => ariadneInstall().then(() => ({ ok: true })).catch((e) => ({ ok: false, error: e?.message || String(e) }))); +ipcMain.handle("ariadne-update", () => ariadneUpdate().then(() => ({ ok: true })).catch((e) => ({ ok: false, error: e?.message || String(e) }))); +ipcMain.handle("ariadne-uninstall", () => ariadneUninstall().then(() => ({ ok: true })).catch((e) => ({ ok: false, error: e?.message || String(e) }))); +// Storage: clear right now (any subset). "history" also drops the saved-session file. +// ---- Password vault ------------------------------------------------------- +// The vault lives at userData/passwords.vault (encrypted). Unlock state is +// held in this main-process closure only — never sent to a renderer except +// in the explicit response to password-get(id). Cleared on quit alongside +// the other storage clears (see before-quit hook). +const vaultFile = () => path.join(app.getPath("userData"), "passwords.vault"); +let vaultState = null; // { key, purposeRoot, entries, _salt, _iters } +// Imports live in a SEPARATE encrypted file (design §3.2) so a bug in one +// vault can't destroy the other, and so an attacker holding the primary +// purposeRoot in RAM never yields the imports' seeds/WIFs. Same master +// password, different KDF salt = disjoint AES keys. +const importsFile = () => path.join(app.getPath("userData"), "wallet-imports.enc"); +let importsState = null; // { key, accounts, _salt, _iters } +let importsUnlockPw = null; // held only if we may need to write the file this session +const vaultOk = () => ({ ok: true }); +const vaultErr = (m) => ({ ok: false, err: String(m) }); + +ipcMain.handle("password-status", () => ({ + setup: fs.existsSync(vaultFile()), + unlocked: !!vaultState, +})); + +ipcMain.handle("password-setup", async (_e, { masterPassword, seedSource }) => { + try { + if (!masterPassword || String(masterPassword).length < 4) return vaultErr("master password too short"); + if (fs.existsSync(vaultFile())) return vaultErr("vault already exists"); + const v = await loadVaultLib(); + let purposeRootHex, messengerRootHex; + if (seedSource && seedSource.kind === "mnemonic" && seedSource.mnemonic) { + // Same seed, two purpose roots — one for password derivation, one for + // the Nostr messaging identity. Storing both means Hermes can bind to + // the vault so the user never re-enters the mnemonic. Different HKDF + // info strings keep the two subtrees cryptographically disjoint. + const seed = await v.bip39ToSeed(String(seedSource.mnemonic)); + purposeRootHex = v.bytesToHex(await v.seedToPurposeRoot(seed, "passwords/0")); + messengerRootHex = v.bytesToHex(await v.seedToPurposeRoot(seed, "messenger/0")); + } else { + // Independent random seed — 32 bytes of purposeRoot directly. No mnemonic + // means no messenger root; Hermes will fall back to its own mnemonic entry. + const root = require("node:crypto").webcrypto.getRandomValues(new Uint8Array(32)); + purposeRootHex = v.bytesToHex(root); + } + vaultState = await v.createVault(vaultFile(), masterPassword, purposeRootHex, + messengerRootHex ? { messengerRootHex } : {}); + emitPwAvailability(); + return vaultOk(); + } catch (e) { return vaultErr(e?.message || e); } +}); + +ipcMain.handle("password-unlock", async (_e, masterPassword) => { + try { + if (!fs.existsSync(vaultFile())) return vaultErr("no vault"); + const v = await loadVaultLib(); + vaultState = await v.unlockVault(vaultFile(), masterPassword); + // Same master password unlocks wallet-imports.enc when it exists. A + // mismatched password wouldn't get us here (the primary decrypt would + // have thrown), so this second decrypt is guaranteed to succeed with + // the same input — differ only in the salt. + importsState = null; + if (fs.existsSync(importsFile())) { + try { importsState = await v.unlockImports(importsFile(), masterPassword); } + catch (ie) { console.error("[imports] unlock failed:", ie?.message); } + } + importsUnlockPw = masterPassword; + emitPwAvailability(); + return { ok: true, entries: v.listMetadata(vaultState) }; + } catch (e) { return vaultErr(e?.message || e); } +}); + +ipcMain.handle("password-lock", () => { + vaultState = null; + importsState = null; + importsUnlockPw = null; + emitPwAvailability(); + return true; +}); + +ipcMain.handle("password-list", async () => { + if (!vaultState) return { ok: false, err: "locked" }; + const v = await loadVaultLib(); + return { ok: true, entries: v.listMetadata(vaultState) }; +}); + +ipcMain.handle("password-get", async (_e, id) => { + if (!vaultState) return vaultErr("locked"); + try { + const v = await loadVaultLib(); + const password = await v.resolvePassword(vaultState, id); + return { ok: true, password }; + } catch (e) { return vaultErr(e?.message || e); } +}); + +ipcMain.handle("password-add", async (_e, spec) => { + if (!vaultState) return vaultErr("locked"); + try { + const v = await loadVaultLib(); + const entry = v.newEntry(spec || {}); + vaultState.entries.push(entry); + await v.saveVault(vaultFile(), vaultState); + return { ok: true, id: entry.id, entries: v.listMetadata(vaultState) }; + } catch (e) { return vaultErr(e?.message || e); } +}); + +ipcMain.handle("password-update", async (_e, id, patch) => { + if (!vaultState) return vaultErr("locked"); + try { + const v = await loadVaultLib(); + const e = vaultState.entries.find((x) => x.id === id); + if (!e) return vaultErr("no such entry"); + // Whitelist mutable fields; never let the renderer overwrite id/addedAt. + for (const k of ["domain", "username", "literal", "generated"]) if (patch && k in patch) e[k] = patch[k]; + // Switching between literal and generated: drop the other field. + if (patch && "literal" in patch) delete e.generated; + if (patch && "generated" in patch) delete e.literal; + await v.saveVault(vaultFile(), vaultState); + return { ok: true, entries: v.listMetadata(vaultState) }; + } catch (e) { return vaultErr(e?.message || e); } +}); + +ipcMain.handle("password-remove", async (_e, id) => { + if (!vaultState) return vaultErr("locked"); + try { + const v = await loadVaultLib(); + vaultState.entries = vaultState.entries.filter((x) => x.id !== id); + await v.saveVault(vaultFile(), vaultState); + return { ok: true, entries: v.listMetadata(vaultState) }; + } catch (e) { return vaultErr(e?.message || e); } +}); + +ipcMain.handle("password-generate", async (_e, { domain, username = "", version = 1, rules } = {}) => { + if (!vaultState) return vaultErr("locked"); + try { + const v = await loadVaultLib(); + const password = await v.derivePassword(vaultState.purposeRoot, { domain, username, version, rules }); + return { ok: true, password }; + } catch (e) { return vaultErr(e?.message || e); } +}); + +// ---- wallet imports (DESIGN-wallet-multi-account-amendment.md §3.2/§3.3) --- +// Read-only listing — safe for any renderer, does not leak seeds/WIFs. +ipcMain.handle("wallet-imports-list", async () => { + if (!vaultState) return vaultErr("locked"); + const v = await loadVaultLib(); + const entries = importsState ? v.listImportsMetadata(importsState) : []; + return { ok: true, entries }; +}); + +// Add an import. Add-ons (Aegis) call this via api.vault.imports.add(spec). +// The seed/WIF stay in main-process memory — never re-emitted to renderers. +// The caller is expected to have already derived cashaddr client-side; we +// store it verbatim, and Aegis's safety-net check re-derives on load and +// warns on mismatch (design §6). +ipcMain.handle("wallet-imports-add", async (_e, spec) => { + if (!vaultState) return vaultErr("locked"); + if (!importsUnlockPw) return vaultErr("locked"); + try { + if (!spec || typeof spec !== "object") throw new Error("spec required"); + const kind = String(spec.kind || ""); + if (kind !== "seed" && kind !== "wif") throw new Error(`unknown kind: ${kind}`); + const cashaddr = String(spec.cashaddr || "").trim(); + if (!cashaddr) throw new Error("cashaddr required (caller derives)"); + const label = String(spec.label || "").trim().slice(0, 120); + if (!label) throw new Error("label required"); + const category = String(spec.category || "").trim().slice(0, 40) || "operational"; + const source = String(spec.source || "").trim().slice(0, 500); + const v = await loadVaultLib(); + if (!importsState) { + importsState = await v.createImports(importsFile(), importsUnlockPw); + } + // Choose a URL-safe id: user-provided or derived from the label. Collision- + // safe: append a short suffix if it already exists. + const rawId = String(spec.id || label).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "wallet"; + let id = rawId, n = 1; + while (importsState.accounts[id]) { n++; id = `${rawId}-${n}`; } + const rec = { + kind, cashaddr, label, category, source, + createdAt: Date.now(), + }; + if (kind === "seed") { + if (!spec.seed || !spec.path) throw new Error("seed and path required for kind=seed"); + rec.seed = String(spec.seed); + rec.path = String(spec.path); + } else { + if (!spec.wif) throw new Error("wif required for kind=wif"); + rec.wif = String(spec.wif); + } + importsState.accounts[id] = rec; + await v.saveImports(importsFile(), importsState); + return { ok: true, id, entries: v.listImportsMetadata(importsState) }; + } catch (e) { return vaultErr(e?.message || e); } +}); + +ipcMain.handle("wallet-imports-remove", async (_e, id) => { + if (!vaultState || !importsState) return vaultErr("locked"); + try { + if (!importsState.accounts[id]) return vaultErr("no such import"); + delete importsState.accounts[id]; + const v = await loadVaultLib(); + await v.saveImports(importsFile(), importsState); + return { ok: true, entries: v.listImportsMetadata(importsState) }; + } catch (e) { return vaultErr(e?.message || e); } +}); + +// Signer material for one import — only for add-ons that already have +// vault-derive-equivalent trust. NEVER called from a page renderer directly; +// gated by addon-msg the same way api.vault.derive is. +ipcMain.handle("wallet-imports-signer", async (_e, id) => { + if (!vaultState || !importsState) return vaultErr("locked"); + try { + const v = await loadVaultLib(); + const signer = v.getImportSigner(importsState, id); + return { ok: true, signer }; + } catch (e) { return vaultErr(e?.message || e); } +}); + +ipcMain.handle("clear-browsing-data", async (_e, opts) => { + const o = opts || {}; + await clearBrowsingData({ cookies: !!o.cookies, cache: !!o.cache, storage: !!o.storage }); + if (o.history) await clearHistoryNow(); + return true; +}); +ipcMain.handle("search-engines", () => ({ engines: allEngines(), current: settings.searchEngine })); +ipcMain.handle("set-search-engine", (_e, id) => { + if (allEngines().some((e) => e.id === id)) { settings.searchEngine = id; saveSettings(); emitEngines(); } + return settings.searchEngine; +}); +ipcMain.handle("add-engine", (_e, eng) => { + // A custom engine needs a name and a URL template containing "%s". Adding + // installs it in both the settings list AND the toolbar dropdown. + if (eng && eng.name && eng.url && String(eng.url).includes("%s")) { + const id = "custom-" + Date.now().toString(36); + settings.customEngines = [...(settings.customEngines || []), + { id, name: String(eng.name).slice(0, 40), sym: String(eng.sym || "🔍").slice(0, 4), url: String(eng.url).slice(0, 400) }]; + // Custom engines are auto-installed and enabled. + settings.enabledEngines = [...new Set([...(settings.enabledEngines || DEFAULT_ENABLED), id])]; + settings.searchEngine = id; // select the one just added + saveSettings(); emitEngines(); + } + return { engines: allEngines(), current: settings.searchEngine }; +}); +ipcMain.handle("remove-engine", (_e, id) => { + // Drop a custom engine entirely — from customEngines and any list that + // referenced it. + settings.customEngines = (settings.customEngines || []).filter((e) => e.id !== id); + settings.enabledEngines = (settings.enabledEngines || DEFAULT_ENABLED).filter((x) => x !== id); + if (settings.searchEngine === id) settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo"; + saveSettings(); emitEngines(); + return { engines: allEngines(), current: settings.searchEngine }; +}); +// Right-click "Remove from list": drops a built-in from installedEngines AND +// enabledEngines so it goes back to the catalog. For custom engines this +// aliases to remove-engine (they don't live in installedEngines). +ipcMain.handle("remove-from-list", (_e, id) => { + if ((settings.customEngines || []).some((e) => e.id === id)) { + settings.customEngines = (settings.customEngines || []).filter((e) => e.id !== id); + } else if (SEARCH_ENGINES[id]) { + settings.installedEngines = (settings.installedEngines || DEFAULT_ENABLED).filter((x) => x !== id); + } else { + return { engines: allEngines(), current: settings.searchEngine }; + } + settings.enabledEngines = (settings.enabledEngines || DEFAULT_ENABLED).filter((x) => x !== id); + if (settings.enabledEngines.length === 0) settings.enabledEngines = ["duckduckgo"]; // never empty + if (settings.searchEngine === id) settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo"; + saveSettings(); emitEngines(); + return { engines: allEngines(), current: settings.searchEngine }; +}); +// Enable/disable a built-in engine. Enabling from the catalog also INSTALLS it +// (adds to installedEngines). Disabling only removes it from enabledEngines — +// it stays in installedEngines so the row remains visible with the toggle off. +ipcMain.handle("set-engine-enabled", (_e, id, on) => { + if (SEARCH_ENGINES[id]) { + let installed = (settings.installedEngines || DEFAULT_ENABLED).slice(); + let enabled = (settings.enabledEngines || DEFAULT_ENABLED).filter((x) => x !== id); + if (on) { + if (!installed.includes(id)) installed.push(id); + enabled.push(id); + } + settings.installedEngines = installed; + settings.enabledEngines = enabled.length ? enabled : ["duckduckgo"]; // never empty + if (!enabledEnginesList().some((e) => e.id === settings.searchEngine)) + settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo"; + saveSettings(); emitEngines(); + } + return { engines: allEngines(), current: settings.searchEngine }; +}); +ipcMain.handle("set-engine-order", (_e, ids) => { + if (Array.isArray(ids)) { settings.engineOrder = ids.filter((x) => typeof x === "string"); saveSettings(); emitEngines(); } + return { engines: allEngines(), current: settings.searchEngine }; +}); +// The custom dropdown overlay (real favicons). +ipcMain.handle("toggle-engine-picker", (_e, rect) => { + if (epVisible) return showEnginePicker(false); + if (rect) epPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; + showEnginePicker(true); +}); +ipcMain.handle("close-engine-picker", () => showEnginePicker(false)); +ipcMain.handle("ep-resize", (_e, h) => { epH = Math.max(80, Math.min(440, Math.round(h) || 320)); if (epVisible) positionEnginePicker(); }); +ipcMain.handle("pick-engine", (_e, id) => { + if (enabledEnginesList().some((e) => e.id === id)) { settings.searchEngine = id; saveSettings(); emitEngines(); } + showEnginePicker(false); +}); +ipcMain.handle("picker-open-settings", () => { + showEnginePicker(false); + const focus = (t) => { try { t.view.webContents.send("focus-section", "search"); } catch {} }; + const ex = tabs.find((t) => t.settings); + if (ex) { setActive(ex.id); focus(ex); return; } + const id = createTab(null, { settings: true }); + const t = tabById(id); + if (t) t.view.webContents.once("did-finish-load", () => focus(t)); +}); +// ---- Downloads -------------------------------------------------------------- +ipcMain.handle("downloads-get", () => downloadsPublic()); +ipcMain.handle("toggle-downloads", (_e, rect) => { + if (dlVisible) return showDownloads(false); + if (rect) dlPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; + showDownloads(true); +}); +ipcMain.handle("close-downloads", () => showDownloads(false)); +ipcMain.handle("downloads-resize", (_e, h) => { dlH = Math.max(80, Math.min(480, Math.round(h) || 240)); if (dlVisible) positionDownloads(); }); +// Address-bar suggestions: show/hide, forward arrow-keys to the dropdown. +ipcMain.handle("suggest-address", (_e, query, rect) => { + const suggestions = historySearch(query); + if (!suggestions.length) { showAddressPicker(false); return; } + if (rect) { apPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; apW = Math.max(280, Math.round(rect.w || 520)); } + showAddressPicker(true, suggestions); +}); +ipcMain.handle("close-address-picker", () => showAddressPicker(false)); +ipcMain.handle("address-picker-resize", (_e, h) => { + apH = Math.max(40, Math.min(400, Math.round(h) || 60)); + if (apVisible) positionAddressPicker(); +}); +// Right-side X on a picker row: drop that URL from history without +// navigating. Sender-URL-gated to our own address-picker.html. +ipcMain.handle("address-forget", (e, url) => { + try { const u = e.sender.getURL() || ""; if (!/address-picker\.html/i.test(u)) return false; } catch { return false; } + if (typeof url !== "string" || !url) return false; + const before = history.length; + history = history.filter((h) => h.url !== url); + if (history.length === before) return false; + saveHistoryDebounced(); + // Re-run the current query so the picker rerenders without the removed row. + return true; +}); +ipcMain.handle("address-pick", (_e, url) => { + showAddressPicker(false); + if (!url) return; + const u = String(url); + // Push the picked URL to the chrome renderer directly so the address bar + // shows the full URL immediately. The tabs event's focus guard + // (document.activeElement !== $("url")) + // skips its value overwrite while the URL input still has DOM focus, and + // clicking a WebContentsView sibling doesn't always deliver the blur to + // the chrome renderer in time — user was left staring at their 3-letter + // typed query while the picked URL loaded behind it. + try { chrome?.webContents.send("address-picked", u); } catch {} + navigateTab(activeId, u); +}); +// Password fill — chip in the toolbar opens a picker of matching credentials +// for the current site. Clicking a match injects the fill script into the +// active tab. Whole flow is user-initiated; no page-load DOM watchers yet. +ipcMain.handle("toggle-pw-fill", async (_e, rect) => { + if (pwfVisible) return showPwFill(false); + const t = activeTab(); const host = t?.prov?.host || ""; + const matches = pwMatchesForHost(host); + if (!matches.length) return showPwFill(false); + if (rect) pwfPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; + showPwFill(true, matches); +}); +ipcMain.handle("close-pw-fill", () => showPwFill(false)); +ipcMain.handle("link-status-resize", (_e, w, h) => { + linkStatusW = Math.max(60, Math.min(2000, Math.round(w) || 100)); + linkStatusH = Math.max(20, Math.min(60, Math.round(h) || 22)); + if (linkStatusVisible) positionLinkStatus(); +}); +ipcMain.handle("pw-fill-resize", (_e, h) => { + pwfH = Math.max(60, Math.min(300, Math.round(h) || 80)); + if (pwfVisible) positionPwFill(); +}); +ipcMain.handle("pw-fill-pick", async (_e, id) => { + showPwFill(false); + if (!vaultState) return { ok: false, err: "locked" }; + try { + const v = await loadVaultLib(); + const entry = vaultState.entries.find((x) => x.id === id); + if (!entry) return { ok: false, err: "no such entry" }; + const password = await v.resolvePassword(vaultState, id); + return await pwFillIntoActiveTab({ username: entry.username, password }); + } catch (e) { return { ok: false, err: e?.message || String(e) }; } +}); +// The chrome sends arrow-up/down/enter through so the picker can move its +// selection cursor without stealing focus from the address input. +ipcMain.handle("address-cursor", (_e, dir) => { + if (apVisible && addressPicker) try { addressPicker.webContents.send("address-cursor", dir); } catch {} +}); +ipcMain.handle("download-open", (_e, id) => { + const d = downloads.find((x) => x.id === id); + if (d?.state === "completed" && d.savePath) shell.openPath(d.savePath).catch(() => {}); +}); +ipcMain.handle("download-show", (_e, id) => { + const d = downloads.find((x) => x.id === id); + if (d?.savePath) { try { shell.showItemInFolder(d.savePath); } catch {} } +}); +ipcMain.handle("download-cancel", (_e, id) => { + const item = dlItems.get(id); if (item) { try { item.cancel(); } catch {} } +}); +// Only clear finished downloads; a progressing one is cancelled first. +ipcMain.handle("download-clear", (_e, id) => { + const i = downloads.findIndex((x) => x.id === id); + if (i < 0) return; + if (downloads[i].state === "progressing") { const item = dlItems.get(id); if (item) { try { item.cancel(); } catch {} } } + downloads.splice(i, 1); dlItems.delete(id); + emitDownloads(); +}); +ipcMain.handle("downloads-clear-all", () => { + // Keep any still-progressing ones; drop everything else. + for (let i = downloads.length - 1; i >= 0; i--) if (downloads[i].state !== "progressing") downloads.splice(i, 1); + emitDownloads(); +}); +// OpenSearch "scan": add the search engine the current page advertises. +ipcMain.handle("add-detected-engine", () => { + const d = activeTab()?.detected; + if (d && d.url && d.url.includes("%s")) { + const id = "custom-" + Date.now().toString(36); + settings.customEngines = [...(settings.customEngines || []), { id, name: d.name.slice(0, 40), sym: "🔍", url: d.url.slice(0, 400) }]; + settings.searchEngine = id; + saveSettings(); emitEngines(); + } + showEnginePicker(false); +}); +ipcMain.handle("bookmarks-get", () => bookmarks); +ipcMain.handle("bookmark-add", (_e, bm) => { + if (bm && bm.url && !bookmarks.some((b) => b.url === bm.url)) { + const entry = { title: bm.title || bm.url, url: bm.url }; + if (bm.favicon) entry.favicon = String(bm.favicon).slice(0, 2048); + bookmarks.push(entry); + saveBookmarks(); emitBookmarks(); + } + return bookmarks; +}); +// Update fields on an existing bookmark, identified by URL. Used by the +// inline title editor (window.prompt is disabled in Electron BrowserViews, +// so the renderer builds its own modal and calls this). Merges partial +// updates — omitted fields stay as they are, and blank strings are +// rejected for title so a bad edit can't wipe the label. +ipcMain.handle("bookmark-update", (_e, url, patch) => { + if (typeof url !== "string" || !url || !patch || typeof patch !== "object") return bookmarks; + const bm = bookmarks.find((b) => b.url === url); + if (!bm) return bookmarks; + if (typeof patch.title === "string" && patch.title.trim()) bm.title = patch.title.trim().slice(0, 200); + if (typeof patch.favicon === "string" && patch.favicon) bm.favicon = patch.favicon.slice(0, 2048); + saveBookmarks(); emitBookmarks(); + return bookmarks; +}); +ipcMain.handle("bookmark-remove", (_e, url) => { + bookmarks = bookmarks.filter((b) => b.url !== url); + saveBookmarks(); emitBookmarks(); + return bookmarks; +}); +// Reorder: pull `fromUrl` out of the list and reinsert it before or after +// `targetUrl`. Renderer picks the side by which half of the target chip the +// pointer is on, same convention the tab strip uses. A missing entry or a +// self-drop is a no-op, so noisy drag events don't corrupt the list. +ipcMain.handle("bookmark-move", (_e, fromUrl, targetUrl, place) => { + if (typeof fromUrl !== "string" || typeof targetUrl !== "string" || fromUrl === targetUrl) return bookmarks; + const from = bookmarks.findIndex((b) => b.url === fromUrl); + if (from < 0) return bookmarks; + const [moved] = bookmarks.splice(from, 1); + let to = bookmarks.findIndex((b) => b.url === targetUrl); + if (to < 0) { bookmarks.splice(from, 0, moved); return bookmarks; } + if (place === "after") to += 1; + bookmarks.splice(to, 0, moved); + saveBookmarks(); emitBookmarks(); + return bookmarks; +}); +ipcMain.handle("settings-get", () => settings); +ipcMain.handle("settings-set", (_e, key, val) => { + if (key in SETTINGS_DEFAULTS) { settings[key] = val; saveSettings(); } + if (key === "webrtcMode") applyWebRTCPolicy(); + if (key === "theme") applyTheme(); + if (key === "backgroundThrottle") applyThrottle(); + if (["timezoneMode", "timezoneValue", "languageMode", "languageSpoof", "languageValue", + "locationMode", "locationRegion", "locationLat", "locationLon", "hideMediaDevices"].includes(key)) { applyFingerprintAll(); applyAcceptLanguage(); } + // Push updates chrome-side reactively so toolbar-size changes (urlBarSize, + // searchBoxSize) take effect without a relaunch. Whole settings object + // fits in one message and chrome only cares about a handful of fields. + try { chrome?.webContents.send("settings-update", settings); } catch {} + return settings; +}); +ipcMain.handle("set-chrome-height", (_e, h) => { + const next = Math.max(74, Math.min(260, Math.round(h) || 84)); + if (next !== CHROME_H) { CHROME_H = next; layout(); } +}); +ipcMain.handle("switch-to-bcnr", () => { + const t = activeTab(); if (!t || !t.bcnrOffer) return; + const { host, rest, tld } = t.bcnrOffer; + t.bcnrOffer = null; + chrome.webContents.send("bcnr-offer", null); + return loadBns(t, activeId, host, rest || "/", tld); +}); + +// ---- Hermes messages panel ------------------------------------------------- +// A separate BrowserWindow (opens on Ctrl+Shift+M anywhere in Theseus). Uses +// the wallet mnemonic to derive the Nostr identity in-memory only — the seed +// and secret key are never written to disk. The panel process holds one +// WebSocket per relay in HERMES_DEFAULT_RELAYS for the receive subscription, +// and opens a per-send WebSocket for publishes. +const HERMES_DEFAULT_RELAYS = ["wss://nos.lol"]; +const HERMES_INBOX_LIMIT = 200; + +let hermesWin = null; +// State when initialised: { skHex, pkHex, npub, inbox: [], relays: Map<url, { ws, ready }> } +// skHex is held instead of the raw Uint8Array so it can be Buffer-restored per operation +// (nostr-tools expects Uint8Array; converting on demand keeps the surface easier to reason about). +let hermesState = null; + +const hOk = (o = {}) => ({ ok: true, ...o }); +const hErr = (e) => ({ ok: false, err: String((e && e.message) || e) }); + +function hermesEmit(channel, payload) { + if (hermesWin && !hermesWin.isDestroyed()) hermesWin.webContents.send(channel, payload); +} +function hermesRecord(msg) { + hermesState.inbox.push(msg); + if (hermesState.inbox.length > HERMES_INBOX_LIMIT) { + hermesState.inbox.splice(0, hermesState.inbox.length - HERMES_INBOX_LIMIT); + } + hermesEmit("hermes-message", msg); +} +// Pushed on every relay connect/disconnect so the pill in the panel reflects +// reality without polling. Cheap; sent to the renderer whenever a socket +// transitions ready/not-ready. +function hermesEmitStatus() { + if (!hermesState) return; + let connected = 0; + for (const s of hermesState.relays.values()) if (s.ready) connected++; + hermesEmit("hermes-status-update", { + relaysConnected: connected, + relaysTotal: hermesState.relays.size, + }); +} + +// Reverse-resolve a pkHex → .bch name by scanning the (cached) BNS index. +// Populates senderName in the inbox so incoming DMs render as +// "alice.bch · 12ab…9f" instead of a naked hex string. Cache is per-hermesState +// (dropped on hermes-close) so a re-init starts clean. +async function hermesReverseResolve(pkHex) { + if (!hermesState) return null; + if (!hermesState._pkToName) hermesState._pkToName = new Map(); + if (hermesState._pkToName.has(pkHex)) return hermesState._pkToName.get(pkHex); + try { + const R = await getResolver(); + const H = await loadHermesLib(); + const idx = await R.buildIndex({ WebSocket }); + for (const [name, entry] of idx) { + const raw = entry && entry.records && entry.records.np; + if (typeof raw !== "string" || !raw.trim()) continue; + try { + const hex = H.parseNpRecord(raw); + if (hex === pkHex) { + hermesState._pkToName.set(pkHex, name); + return name; + } + } catch { /* malformed np — skip */ } + } + } catch { /* chain unreachable — leave unresolved this round */ } + hermesState._pkToName.set(pkHex, null); // negative-cache so we don't re-scan every message + return null; +} + +// One receive subscription per relay. If the socket dies we resurrect it on a +// backoff — a locked / logged-out state tears them all down cleanly. +async function hermesConnectRelay(url) { + const H = await loadHermesLib(); + const state = { ws: null, ready: false, subId: "hermes-inbox" }; + const open = () => { + if (!hermesState) return; // torn down while reconnecting + const ws = new WebSocket(url); + state.ws = ws; + ws.on("open", () => { + state.ready = true; + hermesEmitStatus(); + ws.send(JSON.stringify(["REQ", state.subId, { kinds: [1059], "#p": [hermesState.pkHex] }])); + }); + ws.on("message", async (buf) => { + let msg; try { msg = JSON.parse(buf.toString()); } catch { return; } + if (msg[0] !== "EVENT" || msg[1] !== state.subId) return; + try { + const sk = Buffer.from(hermesState.skHex, "hex"); + const opened = H.unwrapChat({ receiverSk: sk, wrap: msg[2] }); + // Dedupe: a wrap arriving from multiple relays produces the same rumor id + // (kind:14 hash), but since we don't expose the rumor id here we dedupe + // on (senderPkHex, text, createdAt) which is sufficient for MVP. + const key = opened.senderPkHex + "\0" + opened.createdAt + "\0" + opened.text; + if (hermesState._seen && hermesState._seen.has(key)) return; + hermesState._seen && hermesState._seen.add(key); + // Reverse-resolve pk → name off the wrap decrypt path (fire-and-forget + // wouldn't work — we need the name in the record we push). Await here; + // the cache short-circuits after the first miss per pk. + const senderName = await hermesReverseResolve(opened.senderPkHex); + hermesRecord({ + senderPkHex: opened.senderPkHex, + senderName, + text: opened.text, + createdAt: opened.createdAt, + }); + } catch { /* unwrap failure = not for us, or malformed — drop silently */ } + }); + ws.on("close", () => { + state.ready = false; + hermesEmitStatus(); + // Attempt reconnect if we're still supposed to be running. + if (hermesState && hermesState.relays.get(url) === state) { + setTimeout(open, 3000); + } + }); + ws.on("error", () => { /* close handler will retry */ }); + }; + open(); + return state; +} + +function hermesTeardown() { + if (!hermesState) return; + for (const state of hermesState.relays.values()) { + try { state.ws?.close(1000); } catch {} + } + hermesState = null; +} + +ipcMain.handle("hermes-status", () => { + if (!hermesState) return hOk({ ready: false }); + let connected = 0; + for (const s of hermesState.relays.values()) if (s.ready) connected++; + return hOk({ + ready: true, + npub: hermesState.npub, + pkHex: hermesState.pkHex, + relaysConnected: connected, + relaysTotal: hermesState.relays.size, + }); +}); + +// Init modes: +// { mnemonic: "..." } — derive from BIP-39 mnemonic (typed by user) +// { useVault: true } — reuse the password vault's messenger root; no re-entry +// required. Available iff vault is unlocked AND was set +// up from a mnemonic (so messengerRoot was persisted). +ipcMain.handle("hermes-init", async (_e, opts = {}) => { + try { + hermesTeardown(); + const H = await loadHermesLib(); + let sk, pkHex, npub; + if (opts.useVault) { + if (!vaultState || !vaultState.messengerRoot) { + return hErr("password vault is locked or was set up without a mnemonic"); + } + ({ sk, pkHex, npub } = H.nostrKeyFromRoot(vaultState.messengerRoot)); + } else { + const mnemonic = opts.mnemonic; + if (!mnemonic || typeof mnemonic !== "string" || mnemonic.trim().split(/\s+/).length < 12) { + return hErr("enter a 12- or 24-word mnemonic"); + } + ({ sk, pkHex, npub } = await H.nostrKeyFromMnemonic(mnemonic.trim())); + } + hermesState = { + skHex: Buffer.from(sk).toString("hex"), + pkHex, npub, + inbox: [], + relays: new Map(), + _seen: new Set(), + }; + for (const url of HERMES_DEFAULT_RELAYS) { + hermesState.relays.set(url, await hermesConnectRelay(url)); + } + // Give sockets a beat to open before reporting connected count. + await new Promise((r) => setTimeout(r, 400)); + let connected = 0; + for (const s of hermesState.relays.values()) if (s.ready) connected++; + return hOk({ npub, pkHex, source: opts.useVault ? "vault" : "mnemonic", + relaysConnected: connected, relaysTotal: hermesState.relays.size }); + } catch (e) { hermesTeardown(); return hErr(e); } +}); + +// Cheap query: "is the vault-bound sign-in path available right now?" Panel +// uses this to decide whether to show the 'Use password vault' button. +ipcMain.handle("hermes-can-use-vault", () => hOk({ + available: !!(vaultState && vaultState.messengerRoot), +})); + +ipcMain.handle("hermes-close", () => { hermesTeardown(); return hOk(); }); + +ipcMain.handle("hermes-inbox", () => { + if (!hermesState) return hErr("not initialised"); + return hOk({ messages: hermesState.inbox.slice() }); +}); + +// Send: `to` is either a 64-char hex pubkey or a .bch name. Names are resolved +// via the portable resolver (getResolver above), the `np` record parsed, then +// wrapped + published to each relay listed in `nr` (falling back to defaults). +ipcMain.handle("hermes-send", async (_e, { to, text } = {}) => { + if (!hermesState) return hErr("not initialised"); + if (!to || !text) return hErr("to and text required"); + try { + const H = await loadHermesLib(); + let recipientPkHex; let relays = HERMES_DEFAULT_RELAYS.slice(); + const trimmed = String(to).trim(); + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + recipientPkHex = trimmed.toLowerCase(); + } else if (trimmed.startsWith("npub1")) { + recipientPkHex = H.parseNpRecord(trimmed); + } else { + const R = await getResolver(); + const name = R.normalizeName(trimmed); + const entry = await R.resolveName(name, { WebSocket }); + if (!entry) return hErr(`no on-chain registration for ${name}`); + const parsed = H.parseHermesRecords(entry, { defaultRelays: HERMES_DEFAULT_RELAYS }); + recipientPkHex = parsed.npPk; + relays = parsed.relays; + } + + const sk = Buffer.from(hermesState.skHex, "hex"); + const wrap = H.wrapChat({ senderSk: sk, recipientPkHex, text }); + + const publishOne = (url) => new Promise((resolve) => { + const ws = new WebSocket(url); + let settled = false; + const done = (r) => { if (settled) return; settled = true; try { ws.close(1000); } catch {} resolve(r); }; + const t = setTimeout(() => done({ url, ok: false, detail: "timeout" }), 8000); + ws.on("open", () => ws.send(JSON.stringify(["EVENT", wrap]))); + ws.on("message", (buf) => { + let msg; try { msg = JSON.parse(buf.toString()); } catch { return; } + if (msg[0] === "OK" && msg[1] === wrap.id) { + clearTimeout(t); + done({ url, ok: !!msg[2], detail: msg[3] || "" }); + } + }); + ws.on("error", (e) => done({ url, ok: false, detail: "ws error: " + e.message })); + }); + + const results = await Promise.all(relays.map(publishOne)); + const accepted = results.filter((r) => r.ok).length; + return hOk({ recipientPkHex, accepted, total: results.length, results }); + } catch (e) { return hErr(e); } +}); + +// ---- "Open link in new window" ---- +// A standalone page window: same session (cookies, the bns:// protocol, the +// session-wide bcnr preload), Theseus's fingerprint + WebRTC policy, no +// toolbar. Loads BCNR-first like a tab does: a dotted host with a BCNR +// record goes over bns://, anything else over clearnet. Collision names +// follow the configured policy without the "Open with…" interstitial. +// Add-on page bridges (the wallet inject) are tab-scoped and don't run here. +const linkWindows = new Set(); +async function targetUrlFor(input) { + let u; + try { u = new URL(String(input).includes("://") ? String(input) : "https://" + String(input)); } catch { return null; } + if (u.protocol !== "http:" && u.protocol !== "https:") return u.href; + const host = u.hostname.toLowerCase(); + if (!isBnsHost(host)) return u.href; + let entry = null; + try { entry = await resolveHost(host); } catch {} + if (!entry) return u.href; + const policy = settings.collisionPolicy || "bcnr-first"; + if (!isBcnrNativeTld(tldOf(host)) && policy === "icann-first") return u.href; + return `bns://${host}${u.pathname}${u.search}${u.hash}`; +} +async function openLinkWindow(input) { + const target = await targetUrlFor(input); + if (!target) return null; + const w = new BrowserWindow({ + width: 1100, height: 760, title: "Theseus Navigator", + backgroundColor: nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff", + icon: app.isPackaged ? path.join(process.resourcesPath, "icon.ico") : path.join(__dirname, "build", "icon.ico"), + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, + }); + w.setMenuBarVisibility(false); + const wc = w.webContents; + styleScrollbars(wc); + try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {} + try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {} + applyFingerprint(wc); + wc.on("page-title-updated", (_e, title) => { try { w.setTitle(title ? `${title} — Theseus` : "Theseus Navigator"); } catch {} }); + // Cross-host navigations inside the window stay BCNR-first too. Same-host + // ones go through Chromium untouched (form POSTs must survive). + wc.on("will-navigate", (e, u) => { + try { + const p = new URL(u); + if (p.protocol !== "http:" && p.protocol !== "https:") return; + if (!isBnsHost(p.hostname)) return; + let cur = ""; try { cur = new URL(wc.getURL()).hostname; } catch {} + if (cur === p.hostname) return; + e.preventDefault(); + targetUrlFor(u).then((t) => { if (t) wc.loadURL(t).catch(() => {}); }); + } catch {} + }); + // Popups from a page here go to the main window's tabs when it exists — + // one place for tabs — otherwise to another plain window. + wc.setWindowOpenHandler(({ url }) => { + if (url && url !== "about:blank") { + if (win && !win.isDestroyed()) { createTab(url); try { win.focus(); } catch {} } + else openLinkWindow(url); + } + return { action: "deny" }; + }); + wc.on("context-menu", (_e, p) => { + const items = []; + const haveMain = !!win && !win.isDestroyed(); + if (p.linkURL) { + items.push( + { label: "Open link in new tab", enabled: haveMain, click: () => { createTab(p.linkURL); try { win.focus(); } catch {} } }, + { label: "Open link in new window", click: () => openLinkWindow(p.linkURL) }, + { label: "Copy link address", click: () => clipboard.writeText(p.linkURL) }, + { type: "separator" }, + ); + } + if (p.isEditable) items.push({ role: "cut" }, { role: "copy" }, { role: "paste" }, { type: "separator" }); + else if (p.selectionText) items.push({ role: "copy" }, { type: "separator" }); + items.push( + { label: "Back", enabled: wc.navigationHistory.canGoBack(), click: () => wc.navigationHistory.goBack() }, + { label: "Forward", enabled: wc.navigationHistory.canGoForward(), click: () => wc.navigationHistory.goForward() }, + { label: "Reload", click: () => wc.reload() }, + ); + Menu.buildFromTemplate(items).popup({ window: w }); + }); + linkWindows.add(w); + w.on("closed", () => linkWindows.delete(w)); + wc.loadURL(target).catch(() => {}); + return w; +} + +function openHermesWindow() { + if (hermesWin && !hermesWin.isDestroyed()) { + hermesWin.focus(); return; + } + hermesWin = new BrowserWindow({ + width: 720, height: 620, title: "Messages — Theseus", + backgroundColor: "#0b0e14", + webPreferences: { + preload: path.join(__dirname, "messages-preload.js"), + contextIsolation: true, nodeIntegration: false, + }, + }); + hermesWin.setMenuBarVisibility(false); + hermesWin.loadFile("messages.html"); + hermesWin.on("closed", () => { hermesWin = null; }); +} + +ipcMain.handle("hermes-open", () => { openHermesWindow(); return { ok: true }; }); + +// --- BCNR provider (window.bcnr) — read-only surface. See +// DESIGN-integrated-wallet.md §3. Every method reuses the resolver Theseus +// already runs; nothing about the user leaks, so no origin/permission gate. +// A malicious page can call these; the worst it learns is what the chain +// says publicly, which it could equally get through Argus. The preload that +// exposes these lives at bcnr-preload.js and is installed session-wide +// inside whenReady below. +// +// B.2b: eTLD+1 origin binding. The read methods below don't need the origin +// — chain data is public — so they don't compute it. Instead pages that +// want to see how Theseus will bucket their permissions can call +// `window.bcnr.getOrigin()` (handler further down). B.3's write methods +// (signMessage/sendPayment/registerName) will call `callerOrigin(event)` at +// entry and check the result against wallet-permissions.json. +const { originOf } = require("./bcnr-origin.js"); +function callerOrigin(event) { + try { return originOf(event.sender.getURL(), { bcnrTlds }); } + catch { return null; } +} +// wallet-permissions.json — per-origin (eTLD+1) grants for B.3's write +// methods. Scaffolded in B.2b so B.3 doesn't have to touch main.js's +// on-disk conventions. Shape is intentionally open — B.3 will define the +// concrete decision values ("always" | "once" | "never", amount caps, +// expiries) as each write method lands. +let walletPermissions = {}; +const walletPermissionsFile = () => path.join(app.getPath("userData"), "wallet-permissions.json"); +function loadWalletPermissions() { + try { + if (fs.existsSync(walletPermissionsFile())) { + const raw = JSON.parse(fs.readFileSync(walletPermissionsFile(), "utf8")); + if (raw && typeof raw === "object") walletPermissions = raw; + } + } catch (e) { console.error("wallet-permissions load failed:", e.message); } +} +function saveWalletPermissions() { + try { fs.writeFileSync(walletPermissionsFile(), JSON.stringify(walletPermissions, null, 2)); } + catch (e) { console.error("wallet-permissions save failed:", e.message); } +} +// B.3 will use these. Kept here so the storage owner is one place. +function getWalletPermission(origin, method) { + if (!origin || !method) return null; + return walletPermissions[origin]?.[method] ?? null; +} +function setWalletPermission(origin, method, value) { + if (!origin || !method) return; + if (!walletPermissions[origin]) walletPermissions[origin] = {}; + walletPermissions[origin][method] = value; + saveWalletPermissions(); +} +void getWalletPermission; void setWalletPermission; // silence unused-in-B.2b +function serializeEntry(entry) { + if (!entry) return null; + // Explicit whitelist — records/category/txid/height are the on-chain facts + // the design's `resolveName` promises. `updatedTxid` is included because it + // shifts every UPD and lets `getRecordVersion` distinguish a REG-only entry + // from one that has been updated in place. + return { + name: entry.name, + category: entry.category, + records: entry.records ?? {}, + txid: entry.txid, + height: entry.height, + updatedTxid: entry.updatedTxid ?? null, + }; +} +ipcMain.handle("bcnr:resolveName", async (_e, name) => { + if (typeof name !== "string" || !name) return null; + try { return serializeEntry(await resolveHost(name)); } + catch { return null; } +}); +ipcMain.handle("bcnr:isRegistered", async (_e, name) => { + if (typeof name !== "string" || !name) return false; + try { return (await resolveHost(name)) != null; } + catch { return false; } +}); +ipcMain.handle("bcnr:getBcnrTlds", () => bcnrTlds.slice()); +// Diagnostic — returns the eTLD+1 permission origin Theseus computes for the +// caller. Same value B.3's write methods will gate on. No leak: a page can +// already read its own location.href; this just tells it how Theseus buckets +// its permissions (so a dApp dev can see that pay.foo.bch and blog.foo.bch +// share one grant). +ipcMain.handle("bcnr:getOrigin", (e) => callerOrigin(e)); +ipcMain.handle("bcnr:getRecordVersion", async (_e, name) => { + if (typeof name !== "string" || !name) return null; + try { + const entry = await resolveHost(name); + if (!entry) return null; + // The pair (updatedTxid ?? txid, height) uniquely identifies which reveal + // a dApp is looking at. dApps poll this cheaply and re-fetch records only + // when it changes. + return { + txid: entry.updatedTxid ?? entry.txid, + regTxid: entry.txid, + height: entry.height, + }; + } catch { return null; } +}); + +// App-level keyboard shortcuts. One `web-contents-created` hook covers +// every WebContents (chrome, tab views, overlays) without per-view wiring. +// Reload/hard-reload always target the active tab, regardless of which +// view received the key (URL bar focused, overlay focused, etc.), so the +// user's mental model matches every browser they've ever used. +app.on("web-contents-created", (_event, wc) => { + wc.on("before-input-event", (e, input) => { + if (input.type !== "keyDown") return; + // Ctrl+Shift+M -> Messages panel + if (input.control && input.shift && (input.key === "M" || input.key === "m")) { + openHermesWindow(); + return e.preventDefault(); + } + // Ctrl+B -> toggle add-on sidebar (matches the VS Code convention). + // Silently no-ops if no add-on has registered a sidebar panel yet. + if (input.control && !input.shift && !input.alt && (input.key === "B" || input.key === "b")) { + toggleSidebar(); + return e.preventDefault(); + } + // Zoom: Ctrl + / Ctrl = / numpad + zoom in, Ctrl - / numpad - zoom out, + // Ctrl 0 reset. Always targets the active tab, whichever view has focus. + if (input.control && !input.alt) { + const k = input.key, code = input.code; + if (k === "+" || k === "=" || code === "NumpadAdd") { zoomStep(activeTab(), 1); return e.preventDefault(); } + if (k === "-" || k === "_" || code === "NumpadSubtract") { zoomStep(activeTab(), -1); return e.preventDefault(); } + if ((k === "0" || code === "Numpad0") && !input.shift) { zoomSet(activeTab(), 100); return e.preventDefault(); } + } + // Find-in-page: Ctrl+F opens the find bar in chrome. Chrome renderer + // owns the UI (input, next/prev, count, close); it drives the active + // tab's webContents.findInPage via IPC (find-in-page / find-stop). + const isF = input.key === "F" || input.key === "f"; + if (input.control && !input.shift && !input.alt && isF) { + try { chrome?.webContents.send("find-open"); } catch {} + return e.preventDefault(); + } + // DevTools: F12 or Ctrl+Shift+I toggles Chromium DevTools on the active + // TAB. Position follows the devToolsDock setting: "bottom" docks under + // the tab (Chrome's own default, matches most web-dev muscle memory); + // "sidebar" docks on the right; "two-sidebars" also docks on the right + // but leaves the add-on sidebar in place — Electron's mode:right shares + // the right edge with whatever we've already positioned there. Users + // who prefer a detached window can still drag out from inside the + // DevTools frontend itself. + const isI = input.key === "I" || input.key === "i"; + if (input.key === "F12" || (input.control && input.shift && isI)) { + const t = activeTab(); + if (t) { + try { + const twc = t.view.webContents; + if (twc.isDevToolsOpened()) twc.closeDevTools(); + else { + const dock = settings.devToolsDock === "sidebar" ? "right" + : settings.devToolsDock === "two-sidebars" ? "right" + : "bottom"; + twc.openDevTools({ mode: dock }); + } + } catch {} + } + return e.preventDefault(); + } + // Reload: F5 or Ctrl+R soft; Ctrl+F5 or Ctrl+Shift+R hard (bypass cache). + // Chromium's built-in accelerators are unreliable once the app menu is + // null (Menu.setApplicationMenu(null) above), and none of them cover + // the hard variants anyway — so we wire all four explicitly. + const isR = input.key === "R" || input.key === "r"; + const isF5 = input.key === "F5"; + if (isF5 || (input.control && isR)) { + const t = activeTab(); + if (t && !t.settings) { + const hard = (input.control && input.shift && isR) || (input.control && isF5); + try { + if (hard) t.view.webContents.reloadIgnoringCache(); + else t.view.webContents.reload(); + } catch {} + } + return e.preventDefault(); + } + }); +}); + +// THESEUS_NO_AUTOSTART lets a test harness reuse serveBns/resolveHost without +// launching the full UI (see dev/selftest.js). Normal `npm start` is unchanged. +if (!process.env.THESEUS_NO_AUTOSTART) { + app.whenReady().then(() => { + Menu.setApplicationMenu(null); // drop the native File/Edit/View/Help menu bar + loadSettings(); + applyTheme(); + loadBookmarks(); + loadHistory(); + loadCollisions(); + loadWalletPermissions(); + applyPermissions(); + applyEmbedCookieShim(); + applyAcceptLanguage(); + applyClientHintsSpoof(); + // Session-wide preload for `window.bcnr` — runs BEFORE per-WebContentsView + // preloads (home/settings/popover/etc.), which stack on top of it. Must be + // called before any tab is created; whenReady runs before createWindow(). + try { + const bcnrPreload = path.join(__dirname, "bcnr-preload.js"); + // Add-on page-inject bridges ride the same session-wide slot; the + // preload asks main which (if any) apply to the tab it runs in. + const injectPreload = path.join(__dirname, "addon-inject-preload.js"); + const existing = session.defaultSession.getPreloads(); + const wanted = [bcnrPreload, injectPreload].filter((p) => !existing.includes(p)); + if (wanted.length) session.defaultSession.setPreloads([...existing, ...wanted]); + } catch (err) { console.warn("[bcnr] setPreloads failed:", err?.message ?? err); } + protocol.handle("bns", serveBns); + installDownloadTracker(); + initAddons(); + // Kick off signed add-on update polling 30 s after boot so it never + // slows launch. Any staged update lands in <userData>/addons-updates- + // staged/, and promoteStagedUpdates() picks it up on the NEXT initAddons. + // Empty PUBKEYS_HEX (the shipping default until an operator ceremonies a + // key in) short-circuits inside checkAndStageUpdates — no HTTP is made. + setTimeout(() => { + addonUpdater.checkAndStageUpdates({ + addonsDir: addonsUserDir(), + stagedDir: addonsStagedDir(), + pubkeysHex: ADDON_UPDATE_PUBKEYS, + logger: (...a) => console.log("[addons]", ...a), + }).catch(() => {}); + }, 30_000); + createWindow(); + // Multi-source BNS warm-up so the first .bch page opens near-instantly and + // stays fresh for as long as the browser is running. Every source runs in + // parallel — none of them can block a navigation. Source 1 starts now + // (chrome.html's bookmark favicons are bns:// fetches, so the index has + // to be warm by the time the toolbar asks for them); 2–4 are kicked off + // from onChromeReady() once the toolbar has painted. + // 1) sync: load the on-disk snapshot (user cache > bundled). + // sharedIndex is set BEFORE the first restored tab navigates. + // 2) async, continuous: startBnsPolling() opens one electrum connection + // every 30 s, fetches the beacon history (one call), and only pulls + // the tx bodies we don't already have. Merges into currentSnapshotState + // and persists — so on every page navigation the sharedIndex is at + // most 30 s old with zero user-visible latency. + // 3) async, one-shot: refresh the on-disk snapshot from the operator's + // Sia mirror. Wins the NEXT boot, not this one — after a long idle + // period the browser resumes from a snapshot fresher than the poll + // could catch up on quickly. + // 4) fallback: ensureIndex() still exists for the very first launch + // where the bundled snapshot is absent AND the poll hasn't landed + // yet — a full-walk build. + bnsWarm = warmFromSnapshot().catch(() => null); + // Cheap update check: fetch the releases manifest and, if a newer + // version is out, surface a chip in the toolbar. No auto-install — + // clicking the chip opens the download URL. First check runs from + // onChromeReady(); recheck every 6h so a browser left running for days + // catches updates without a relaunch. + setInterval(() => checkForUpdate().catch(() => {}), 6 * 60 * 60 * 1000); + // Home cards are also polled from a remote URL — brand copy updates then + // reach every install without a browser release. User's local edits stay + // authoritative (loadHomeCards checks them first). + setInterval(() => refreshRemoteHomeCards().catch(() => {}), HOME_CARDS_REFRESH_MS); + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); + }); + app.on("before-quit", async (e) => { + // Auto-clear per user settings. saveSession() runs first so restoreSession + // still works UNLESS the user asked to drop history — in which case we + // wipe the session file too so the next launch is genuinely blank. + saveSession(); + stopTor(); + stopBnsPolling(); // silence the background delta refresh before exit + vaultState = null; // drop the in-memory vault key + purposeRoot + try { + await clearBrowsingData({ + cookies: settings.clearCookiesOnQuit, + cache: settings.clearCacheOnQuit, + storage: settings.clearStorageOnQuit, + }); + if (settings.clearHistoryOnQuit) { + try { fs.unlinkSync(sessionFile()); } catch {} + } + } catch (err) { console.error("before-quit clear failed:", err?.message); } + }); + app.on("window-all-closed", () => { stopTor(); if (process.platform !== "darwin") app.quit(); }); +} + +module.exports = { serveBns, resolveHost, isBnsHost, nativeTld, dualTld, registryOf, openLinkWindow }; diff --git a/messages-preload.js b/messages-preload.js new file mode 100644 index 00000000..36aa3873 --- /dev/null +++ b/messages-preload.js @@ -0,0 +1,25 @@ +// Renderer bridge for the Messages panel. +// All hermes-* IPC returns { ok, ... } | { ok: false, err }. +const { contextBridge, ipcRenderer } = require("electron"); + +contextBridge.exposeInMainWorld("hermes", { + status: () => ipcRenderer.invoke("hermes-status"), + canUseVault: () => ipcRenderer.invoke("hermes-can-use-vault"), + init: (mnemonic) => ipcRenderer.invoke("hermes-init", { mnemonic }), + initFromVault: () => ipcRenderer.invoke("hermes-init", { useVault: true }), + close: () => ipcRenderer.invoke("hermes-close"), + inbox: () => ipcRenderer.invoke("hermes-inbox"), + send: (to, text) => ipcRenderer.invoke("hermes-send", { to, text }), + // Server-push: new decrypted message arrived. Callback gets { senderPkHex, senderName, text, createdAt }. + onMessage: (cb) => { + const wrapped = (_e, msg) => cb(msg); + ipcRenderer.on("hermes-message", wrapped); + return () => ipcRenderer.removeListener("hermes-message", wrapped); + }, + // Server-push: relay connection state changed. Callback gets { relaysConnected, relaysTotal }. + onStatus: (cb) => { + const wrapped = (_e, s) => cb(s); + ipcRenderer.on("hermes-status-update", wrapped); + return () => ipcRenderer.removeListener("hermes-status-update", wrapped); + }, +}); diff --git a/messages.html b/messages.html new file mode 100644 index 00000000..bd95e195 --- /dev/null +++ b/messages.html @@ -0,0 +1,242 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Messages — Theseus</title> +<style> + :root{ + --bg:#0b0e14; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09); + --ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; + --ok:#4fd1a5; --err:#f6768a; --warn:#ffc75f; + } + @media (prefers-color-scheme: light) { + :root{ + --bg:#f6f8fb; --panel:#ffffff; --panel2:#f0f4f9; --line:rgba(0,0,0,.10); + --ink:#1a1f28; --mut:#3c4453; --dim:#697280; + /* Darker acid for light backgrounds — ~5.5:1 on white. */ + --acid: #0AC18E; + } + } + *{box-sizing:border-box} + html,body{height:100%} + body{ + margin:0; background:var(--bg); color:var(--ink); + font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; + display:flex; flex-direction:column; + } + header{ + padding:12px 16px; border-bottom:1px solid var(--line); + background:rgba(11,14,20,.9); display:flex; align-items:center; gap:12px; + flex-wrap:wrap; + } + header h1{font-size:14px;font-weight:600;margin:0;letter-spacing:.2px} + header .mark{font-size:20px} + #ident{ + color:var(--mut); font-family:ui-monospace,SFMono-Regular,Menlo,monospace; + font-size:12px; margin-left:auto; user-select:text; + } + #ident b{color:var(--acid); font-weight:500} + #status{ + font-size:11px; padding:2px 8px; border-radius:999px; + background:var(--panel); color:var(--mut); + } + #status.ok{color:var(--ok); background:rgba(79,209,165,.12)} + #status.err{color:var(--err); background:rgba(246,118,138,.12)} + + main{display:grid; grid-template-columns:1fr; gap:0; flex:1; min-height:0} + #setup, #app{padding:16px; overflow:auto} + #setup{display:none; max-width:520px; margin:auto; align-self:center} + #app{display:none; grid-template-rows:1fr auto; gap:12px} + body.mode-setup #setup{display:block} + body.mode-app #app{display:grid} + + #inbox{ + overflow:auto; background:var(--panel); border:1px solid var(--line); + border-radius:10px; padding:8px; + } + .msg{padding:8px 10px; border-radius:8px; margin-bottom:6px; background:var(--panel2)} + .msg .from{font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11px; color:var(--mut); margin-bottom:3px} + .msg .from b{color:var(--acid); font-weight:500} + .msg .body{white-space:pre-wrap; word-break:break-word} + .msg .time{float:right; color:var(--dim); font-size:11px} + .empty{color:var(--dim); text-align:center; padding:24px 0} + + #compose{display:grid; grid-template-columns:200px 1fr auto; gap:8px} + input, textarea, button{ + font:inherit; color:var(--ink); background:var(--panel); + border:1px solid var(--line); border-radius:8px; padding:10px 12px; + } + textarea{resize:vertical; min-height:44px; max-height:200px} + input:focus, textarea:focus{outline:none; border-color:var(--acid)} + button{cursor:pointer; background:var(--acid); color:#0a0d13; font-weight:600; border-color:transparent} + button:hover{filter:brightness(1.05)} + button:disabled{opacity:.5; cursor:default} + + .lede{color:var(--mut); font-size:13px; margin:0 0 12px} + #setup label{display:block; margin-bottom:12px; color:var(--mut); font-size:12px} + #setup textarea{width:100%; margin-top:6px; min-height:80px} + .row{display:flex; gap:8px; align-items:center; margin-top:8px} + #setupErr{color:var(--err); font-size:12px; min-height:1em; margin-top:8px} + #sendErr{color:var(--err); font-size:12px; min-height:1em; margin-top:4px} + .warn{ + font-size:12px; color:var(--warn); padding:8px 10px; + background:rgba(255,199,95,.08); border:1px solid rgba(255,199,95,.24); + border-radius:8px; margin-bottom:12px; + } + code.inline{ + font-family:ui-monospace,SFMono-Regular,Menlo,monospace; + background:rgba(255,255,255,.05); padding:1px 5px; border-radius:4px; font-size:12px; + } +</style> +</head> +<body class="mode-setup"> + +<header> + <span class="mark">⛓️✉️</span> + <h1>Hermes Messages</h1> + <span id="status">connecting…</span> + <span id="ident"></span> +</header> + +<main> + + <section id="setup"> + <p class="lede">Hermes derives its Nostr identity at + <code class="inline">silentmode/messenger/0</code> — a purpose disjoint from your + wallet's transaction key, so a compromised relay never yields tx-signing power. + Nothing is stored on disk beyond the vault; close the panel and the identity is dropped.</p> + <div class="warn">This is a proof build. Use a wallet you don't hold funds in.</div> + + <div id="vaultBind" style="display:none; margin-bottom:16px"> + <div class="row"> + <button id="btnVault">Sign in with password vault</button> + <span style="color:var(--mut); font-size:12px">recommended — no mnemonic to re-type</span> + </div> + <div style="text-align:center; color:var(--dim); font-size:12px; margin:14px 0">— or —</div> + </div> + + <label> + Mnemonic (12 or 24 words) + <textarea id="mnemonic" placeholder="word word word …" autocomplete="off" spellcheck="false"></textarea> + </label> + <div class="row"> + <button id="btnInit">Open messages</button> + </div> + <div id="setupErr"></div> + </section> + + <section id="app"> + <div id="inbox"><div class="empty">no messages yet — subscribed to inbox…</div></div> + <div> + <div id="compose"> + <input id="to" placeholder="to (e.g. alice.bch or 64-char hex pk)" autocomplete="off"> + <textarea id="text" placeholder="message" rows="1"></textarea> + <button id="btnSend">Send</button> + </div> + <div id="sendErr"></div> + </div> + </section> + +</main> + +<script> +const $ = (id) => document.getElementById(id); +const setMode = (m) => { document.body.className = "mode-" + m; }; +const setStatus = (text, cls = "") => { + const el = $("status"); + el.textContent = text; + el.className = cls; +}; +const shortPk = (pk) => pk ? pk.slice(0, 8) + "…" + pk.slice(-4) : ""; +// Hermes messaging is a .bch-first product — the default TLD is noise in the +// chat list. Strip it for display; other TLDs stay (they disambiguate). +const displayName = (name) => name && name.endsWith(".bch") ? name.slice(0, -4) : name; + +async function refreshInbox() { + const res = await hermes.inbox(); + if (!res.ok) return; + const box = $("inbox"); + if (!res.messages.length) { + box.innerHTML = '<div class="empty">no messages yet — subscribed to inbox…</div>'; + return; + } + box.innerHTML = ""; + for (const m of res.messages) { + const el = document.createElement("div"); + el.className = "msg"; + const from = m.senderName ? `<b>${displayName(m.senderName)}</b> · ${shortPk(m.senderPkHex)}` : shortPk(m.senderPkHex); + const when = new Date(m.createdAt * 1000).toLocaleTimeString(); + el.innerHTML = `<div class="from">${from}<span class="time">${when}</span></div>` + + `<div class="body"></div>`; + el.querySelector(".body").textContent = m.text; // never innerHTML for user content + box.appendChild(el); + } + box.scrollTop = box.scrollHeight; +} + +function afterInit(res) { + if (!res.ok) { $("setupErr").textContent = "error: " + res.err; return false; } + $("ident").innerHTML = "your npub: <b>" + res.npub.slice(0, 12) + "…" + res.npub.slice(-6) + "</b>" + + (res.source ? " <span style='color:var(--dim); font-size:11px'>(" + res.source + ")</span>" : ""); + setStatus(res.relaysConnected + "/" + res.relaysTotal + " relays", + res.relaysConnected > 0 ? "ok" : ""); + setMode("app"); + refreshInbox(); + return true; +} + +$("btnInit").addEventListener("click", async () => { + const mnemonic = $("mnemonic").value.trim(); + if (!mnemonic) { $("setupErr").textContent = "enter a mnemonic"; return; } + $("btnInit").disabled = true; + $("setupErr").textContent = ""; + const res = await hermes.init(mnemonic); + $("btnInit").disabled = false; + afterInit(res); +}); + +$("btnVault").addEventListener("click", async () => { + $("btnVault").disabled = true; + $("setupErr").textContent = ""; + const res = await hermes.initFromVault(); + $("btnVault").disabled = false; + afterInit(res); +}); + +$("btnSend").addEventListener("click", async () => { + const to = $("to").value.trim(); + const text = $("text").value.trim(); + if (!to || !text) { $("sendErr").textContent = "to and message required"; return; } + $("btnSend").disabled = true; + $("sendErr").textContent = ""; + const res = await hermes.send(to, text); + $("btnSend").disabled = false; + if (!res.ok) { $("sendErr").textContent = "error: " + res.err; return; } + $("sendErr").textContent = "sent to " + res.accepted + "/" + res.total + " relay(s) — recipient pk " + shortPk(res.recipientPkHex); + $("text").value = ""; +}); + +hermes.onMessage(() => refreshInbox()); +hermes.onStatus((s) => { + setStatus(s.relaysConnected + "/" + s.relaysTotal + " relays", + s.relaysConnected > 0 ? "ok" : "err"); +}); + +// Query status on load: maybe already initialised from a previous open. +hermes.status().then(async (r) => { + if (r && r.ok && r.ready) { + $("ident").innerHTML = "your npub: <b>" + r.npub.slice(0, 12) + "…" + r.npub.slice(-6) + "</b>"; + setStatus(r.relaysConnected + "/" + r.relaysTotal + " relays", "ok"); + setMode("app"); + refreshInbox(); + return; + } + setStatus("locked", ""); + // Show the vault sign-in shortcut iff the vault is currently unlocked AND + // was set up from a mnemonic (so it carries the messenger root). + const v = await hermes.canUseVault(); + if (v && v.ok && v.available) $("vaultBind").style.display = "block"; +}); +</script> +</body> +</html> diff --git a/nsis/make-icons.mjs b/nsis/make-icons.mjs new file mode 100644 index 00000000..05da783f --- /dev/null +++ b/nsis/make-icons.mjs @@ -0,0 +1,142 @@ +// One-shot script: render site-theseus-x/assets/favicon.svg to the icon +// files electron-builder wants — a 512x512 PNG (main icon) and a multi- +// resolution .ico (Windows taskbar / installer / shortcut icon). +// +// Not in the runtime app bundle; run manually when the brand changes: +// node nsis/make-icons.mjs +// Outputs build/icon.png and build/icon.ico (both gitignored regenerated). +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; +import pngToIco from "png-to-ico"; + +// Minimal 24-bit uncompressed BMP writer. Sharp can produce RGB raw pixels +// but not BMP output; NSIS's MUI wizard branding needs BMP. +function writeBmp24(outPath, width, height, rgb) { + const rowBytes = width * 3; + const rowPad = (4 - (rowBytes % 4)) % 4; + const stride = rowBytes + rowPad; + const pixelBytes = stride * height; + const fileSize = 14 + 40 + pixelBytes; + const buf = Buffer.alloc(fileSize); + // BITMAPFILEHEADER + buf.write("BM", 0); + buf.writeUInt32LE(fileSize, 2); + buf.writeUInt32LE(0, 6); + buf.writeUInt32LE(54, 10); + // BITMAPINFOHEADER + buf.writeUInt32LE(40, 14); + buf.writeInt32LE(width, 18); + buf.writeInt32LE(height, 22); + buf.writeUInt16LE(1, 26); + buf.writeUInt16LE(24, 28); + buf.writeUInt32LE(0, 30); + buf.writeUInt32LE(pixelBytes, 34); + buf.writeInt32LE(2835, 38); // 72 DPI + buf.writeInt32LE(2835, 42); + buf.writeUInt32LE(0, 46); + buf.writeUInt32LE(0, 50); + // Pixel rows bottom-up, BGR order. + for (let y = 0; y < height; y++) { + const srcRow = (height - 1 - y) * width * 3; + const dstRow = 54 + y * stride; + for (let x = 0; x < width; x++) { + buf[dstRow + x * 3] = rgb[srcRow + x * 3 + 2]; // B + buf[dstRow + x * 3 + 1] = rgb[srcRow + x * 3 + 1]; // G + buf[dstRow + x * 3 + 2] = rgb[srcRow + x * 3]; // R + } + // trailing padding bytes are already 0-filled by alloc() + } + fs.writeFileSync(outPath, buf); +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// SVG source lives in the theseus.x standalone site so the browser icon +// and the site brand stay in lockstep. +const SVG = path.join(__dirname, "..", "..", "site-theseus-x", "assets", "favicon.svg"); +// Icons drop into build/, which is gitignored (regenerated artifacts). +// electron-builder picks them up from there via package.json. +const OUT_DIR = path.join(__dirname, "..", "build"); +const INSTALLER_SIDEBAR = path.join(OUT_DIR, "installerSidebar.bmp"); +const UNINSTALLER_SIDEBAR = path.join(OUT_DIR, "uninstallerSidebar.bmp"); +const INSTALLER_HEADER = path.join(OUT_DIR, "installerHeader.bmp"); +const PNG_512 = path.join(OUT_DIR, "icon.png"); +const ICO = path.join(OUT_DIR, "icon.ico"); +const TMP_DIR = path.join(OUT_DIR, ".icon-tmp"); +const ICO_SIZES = [16, 24, 32, 48, 64, 128, 256]; + +if (!fs.existsSync(SVG)) throw new Error("SVG not found at " + SVG); +console.log("source:", SVG); + +// Main 512x512 PNG for macOS / linux / electron-builder auto-derived Windows. +await sharp(SVG).resize(512, 512).png().toFile(PNG_512); +console.log("wrote", PNG_512); + +// Write individual sized PNGs to a temp dir, then feed their paths to +// png-to-ico so it preserves each as a distinct image inside the .ico. +fs.mkdirSync(TMP_DIR, { recursive: true }); +const paths = []; +for (const size of ICO_SIZES) { + const p = path.join(TMP_DIR, `icon-${size}.png`); + await sharp(SVG).resize(size, size).png().toFile(p); + paths.push(p); +} +const ico = await pngToIco(paths); +fs.writeFileSync(ICO, ico); +console.log("wrote", ICO, `(${ICO_SIZES.length} sizes)`); + +// Clean up the temp PNGs. +for (const p of paths) fs.unlinkSync(p); +fs.rmdirSync(TMP_DIR); + +// ---- NSIS wizard branding ---------------------------------------------- +// MUI2's Welcome/Finish sidebar is 164×314 BMP (no alpha). Compose the +// compass mark centered on the Silent Mode dark background so the wizard +// stops shouting "default Electron installer" at the user. +async function makeSidebar(outFile, W, H, markSize) { + const bg = { r: 11, g: 14, b: 20, alpha: 1 }; + const canvas = sharp({ + create: { width: W, height: H, channels: 3, background: bg }, + }); + const markX = Math.floor((W - markSize) / 2); + const markY = Math.floor((H - markSize) / 2) - Math.floor(H * 0.08); // slight nudge up + const markPng = await sharp(SVG).resize(markSize, markSize).png().toBuffer(); + // "Theseus Navigator" wordmark rendered as SVG text so we don't need a font file. + const wordmarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W - 20}" height="60"> + <text x="50%" y="24" text-anchor="middle" fill="#e7eaf1" + font-family="Segoe UI, Arial, sans-serif" font-weight="600" font-size="18">Theseus</text> + <text x="50%" y="48" text-anchor="middle" fill="#d6ff3d" + font-family="Segoe UI, Arial, sans-serif" font-weight="600" font-size="14" letter-spacing="1">NAVIGATOR</text> + </svg>`; + const wordmarkPng = await sharp(Buffer.from(wordmarkSvg)).png().toBuffer(); + await canvas + .composite([ + { input: markPng, top: markY, left: markX }, + { input: wordmarkPng, top: markY + markSize + 14, left: 10 }, + ]) + .toColorspace("srgb") + .raw({ depth: "uchar" }) + .toBuffer({ resolveWithObject: true }) + .then(({ data, info }) => writeBmp24(outFile, info.width, info.height, data)); + console.log("wrote", outFile); +} +// NSIS MUI2 sidebar: 164x314. Uninstaller reuses the same asset. +await makeSidebar(INSTALLER_SIDEBAR, 164, 314, 96); +fs.copyFileSync(INSTALLER_SIDEBAR, UNINSTALLER_SIDEBAR); +console.log("wrote", UNINSTALLER_SIDEBAR); +// NSIS header (top strip, 150x57) — small compass on dark, right-aligned so +// it doesn't compete with the page title on the left. +async function makeHeader(outFile) { + const W = 150, H = 57; + const bg = { r: 11, g: 14, b: 20, alpha: 1 }; + const mark = await sharp(SVG).resize(40, 40).png().toBuffer(); + await sharp({ create: { width: W, height: H, channels: 3, background: bg } }) + .composite([{ input: mark, top: 8, left: W - 48 }]) + .toColorspace("srgb") + .raw({ depth: "uchar" }) + .toBuffer({ resolveWithObject: true }) + .then(({ data, info }) => writeBmp24(outFile, info.width, info.height, data)); + console.log("wrote", outFile); +} +await makeHeader(INSTALLER_HEADER); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..705018b8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5991 @@ +{ + "name": "theseus-navigator", + "version": "0.3.22", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "theseus-navigator", + "version": "0.3.22", + "dependencies": { + "@bitcoinerlab/secp256k1": "^1.2.0", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", + "@scure/bip32": "^2.0.1", + "bip32": "^4.0.0", + "bip39": "^3.1.0", + "bitcoinjs-lib": "^6.1.7", + "ecpair": "^2.1.0", + "fetch-socks": "^1.3.3", + "nostr-tools": "^2.10.4", + "psl": "^1.15.0", + "socks-proxy-agent": "^10.1.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "electron": "^33.0.0", + "electron-builder": "^25.1.8" + } + }, + "node_modules/@bitcoinerlab/secp256k1": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.2.0.tgz", + "integrity": "sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^1.7.0" + } + }, + "node_modules/@bitcoinerlab/secp256k1/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@bitcoinerlab/secp256k1/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.1.tgz", + "integrity": "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/rebuild": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.6.1.tgz", + "integrity": "sha512-f6596ZHpEq/YskUd8emYvOUne89ij8mQgjYFA5ru25QwbrRO+t1SImofdDv7kKOuWCmVOuU5tvfkbgGxIl3E/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "fs-extra": "^10.0.0", + "got": "^11.7.0", + "node-abi": "^3.45.0", + "node-api-version": "^0.2.0", + "node-gyp": "^9.0.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^6.0.5", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/@electron/rebuild/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/rebuild/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/rebuild/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/rebuild/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", + "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.7", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "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/@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/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/fs/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "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/@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/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "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" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.10.tgz", + "integrity": "sha512-Ev4jj3D7Bo+O0GPD2NMvJl+PGiBAfS7pUGawntBNpCbxtpncfUixqFj9z9Jme7V7s3LBGqsWZZP54fxBX3JKJw==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-25.1.8.tgz", + "integrity": "sha512-pCqe7dfsQFBABC1jeKZXQWhGcCPF3rPCXDdfqVKjIeWBcXzyC1iOWZdfFhGl+S9MyE/k//DFmC6FzuGAUudNDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.1", + "@electron/rebuild": "3.6.1", + "@electron/universal": "2.0.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.9", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chromium-pickle-js": "^0.2.0", + "config-file-ts": "0.2.8-rc1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "25.1.7", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.0", + "resedit": "^1.7.0", + "sanitize-filename": "^1.6.3", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "25.1.8", + "electron-builder-squirrel-windows": "25.1.8" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "dev": true, + "license": "ISC" + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base-x": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", + "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bech32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", + "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", + "license": "MIT" + }, + "node_modules/bip174": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.1.1.tgz", + "integrity": "sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/bip32": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bip32/-/bip32-4.0.0.tgz", + "integrity": "sha512-aOGy88DDlVUhspIXJN+dVEtclhIsfAUppD43V0j40cPTld3pv/0X/MlrZSZ6jowIaQQzFwP8M6rFU2z2mVYjDQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "@scure/base": "^1.1.1", + "typeforce": "^1.11.5", + "wif": "^2.0.6" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bip32/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/bip32/node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/bip39": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz", + "integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==", + "license": "ISC", + "dependencies": { + "@noble/hashes": "^1.2.0" + } + }, + "node_modules/bip39/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/bitcoinjs-lib": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.7.tgz", + "integrity": "sha512-tlf/r2DGMbF7ky1MgUqXHzypYHakkEnm0SZP23CJKIqNY/5uNAnMbFhMJdhjrL/7anfb/U8+AlpdjPWjPnAalg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bech32": "^2.0.0", + "bip174": "^2.1.1", + "bs58check": "^3.0.1", + "typeforce": "^1.11.3", + "varuint-bitcoin": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/bitcoinjs-lib/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/bluebird-lst": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", + "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.5" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/bs58": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", + "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", + "license": "MIT", + "dependencies": { + "base-x": "^4.0.0" + } + }, + "node_modules/bs58check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz", + "integrity": "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^5.0.0" + } + }, + "node_modules/bs58check/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "25.1.7", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-25.1.7.tgz", + "integrity": "sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.10", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", + "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cipher-base": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", + "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "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" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "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/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts": { + "version": "0.2.8-rc1", + "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz", + "integrity": "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.3.12", + "typescript": "^5.4.3" + } + }, + "node_modules/config-file-ts/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/config-file-ts/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-25.1.8.tgz", + "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ecpair": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ecpair/-/ecpair-2.1.0.tgz", + "integrity": "sha512-cL/mh3MtJutFOvFc27GPZE2pWL3a3k4YvzUWEOvilnfZVlH3Jwgx/7d6tlD7/75tNk8TG2m+7Kgtz0SI1tWcqw==", + "license": "MIT", + "dependencies": { + "randombytes": "^2.1.0", + "typeforce": "^1.18.0", + "wif": "^2.0.6" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "33.4.11", + "resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz", + "integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-25.1.8.tgz", + "integrity": "sha512-poRgAtUHHOnlzZnc9PK4nzG53xh74wj2Jy7jkTrqZ0MWPoHGh1M2+C//hGeYdA+4K8w4yiVCNYoLXF7ySj2Wig==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "dmg-builder": "25.1.8", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-25.1.8.tgz", + "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "25.1.8", + "archiver": "^5.3.1", + "builder-util": "25.1.7", + "fs-extra": "^10.1.0" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "25.1.7", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", + "integrity": "sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fetch-socks": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/fetch-socks/-/fetch-socks-1.3.3.tgz", + "integrity": "sha512-jI22jzTf7x3GAcUoZARwuxKBPotb3PFkemVWqxlF2qV55FhRytRJrn95HeLoUMq2vkIbY3aQqNYytvZKqUoMaQ==", + "license": "MIT", + "dependencies": { + "socks": "^2.8.7", + "undici": ">=7" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/get-caller-file": { + "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.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/hash-base/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hash-base/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/hash-base/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/hash-base/node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "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" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/make-fetch-happen/node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-api-version/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", + "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^12.13 || ^14.13 || >=16" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nostr-tools": { + "version": "2.24.2", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.24.2.tgz", + "integrity": "sha512-nVf8tvsDPsZvvtT5csRTrMrdrhTId8wl3IgDHt1BWCuL6J+IlmBOW68SCl7SQ6K0PoxenpLPHSKLo4yj49JybA==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT" + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/require-directory": { + "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" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ripemd160": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.1.0.tgz", + "integrity": "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "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==", + "dev": true, + "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/string-width-cjs": { + "name": "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==", + "dev": true, + "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/strip-ansi": { + "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" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "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" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-buffer/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typeforce": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz", + "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.8.0.tgz", + "integrity": "sha512-ubshXMXwF3MQIMF1y/WxZdNBnjEKeSg2wF5mcGUtU55YTw34tnVVpKRlLf7ruDXZ5344KokPVX4RBx1wJm64Bw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/varuint-bitcoin": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-1.1.2.tgz", + "integrity": "sha512-4EVb+w4rx+YfVM32HQX42AbbT7/1f5zwAYhIujKXKk8NQK+JfRVl3pqT3hjNn/L+RstigmGGKVwHA/P0wgITZw==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.1" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wif": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/wif/-/wif-2.0.6.tgz", + "integrity": "sha512-HIanZn1zmduSF+BQhkE+YXIbEiH0xPr1012QbFEGB0xsKqJii0/SqJjyn8dFv6y36kOznMgMB+LGcbZTJ1xACQ==", + "license": "MIT", + "dependencies": { + "bs58check": "<3.0.0" + } + }, + "node_modules/wif/node_modules/base-x": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", + "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/wif/node_modules/bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "license": "MIT", + "dependencies": { + "base-x": "^3.0.2" + } + }, + "node_modules/wif/node_modules/bs58check": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-2.1.2.tgz", + "integrity": "sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==", + "license": "MIT", + "dependencies": { + "bs58": "^4.0.0", + "create-hash": "^1.1.0", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..e7736f73 --- /dev/null +++ b/package.json @@ -0,0 +1,146 @@ +{ + "name": "theseus-navigator", + "version": "0.3.45", + "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", + "author": "Silent Mode", + "main": "main.js", + "type": "commonjs", + "scripts": { + "start": "electron .", + "resolve-test": "node resolve-test.mjs", + "dist": "electron-builder --win nsis portable" + }, + "dependencies": { + "@bch-wc2/interfaces": "^0.0.16", + "@bitauth/libauth": "^3.1.0-next.8", + "@bitcoinerlab/secp256k1": "^1.2.0", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", + "@scure/bip32": "^2.0.1", + "@wizardconnect/core": "^0.2.4", + "@wizardconnect/wallet": "^0.2.3", + "bip32": "^4.0.0", + "bip39": "^3.1.0", + "bitcoinjs-lib": "^6.1.7", + "ecpair": "^2.1.0", + "eventemitter3": "^5.0.1", + "fetch-socks": "^1.3.3", + "isomorphic-ws": "^5.0.0", + "lossless-json": "^4.0.2", + "nostr-tools": "^2.10.4", + "psl": "^1.15.0", + "socks-proxy-agent": "^10.1.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "electron": "^33.0.0", + "electron-builder": "^25.1.8" + }, + "build": { + "appId": "st.silentmode.theseus", + "productName": "Theseus Navigator", + "artifactName": "TheseusNavigator-${version}-${arch}.${ext}", + "directories": { + "output": "dist-public" + }, + "asar": true, + "files": [ + "main.js", + "preload.js", + "chrome.html", + "home.html", + "settings.html", + "settings-preload.js", + "popover.html", + "popover-preload.js", + "engine-picker.html", + "engine-picker-preload.js", + "downloads.html", + "downloads-preload.js", + "address-picker.html", + "address-picker-preload.js", + "pw-fill.html", + "pw-fill-preload.js", + "home-preload.js", + "error.html", + "error-preload.js", + "sidebar-preload.js", + "approval-preload.js", + "approval.html", + "addon-inject-preload.js", + "addon-tab-preload.js", + "addons-host.js", + "addon-updater.js", + "addon-update-pubkeys.js", + "link-status.html", + "link-status-preload.js", + "collision.html", + "collision-preload.js", + "messages.html", + "messages-preload.js", + "bcnr-preload.js", + "bcnr-origin.js", + "lib/**/*", + "package.json", + "node_modules/**/*", + "!**/*.md", + "!**/*.map", + "!tor${/*}", + "!dist-public${/*}", + "!resolve-test.mjs", + "!*PROMPT.md" + ], + "extraResources": [ + { + "from": "tor", + "to": "tor" + }, + { + "from": "../Argus/src/lib/resolver-web.js", + "to": "resolver-web.mjs" + }, + { + "from": "../Argus/src/lib/password-vault.js", + "to": "password-vault.mjs" + }, + { + "from": "lib/hermes.js", + "to": "lib/hermes.mjs" + }, + { + "from": "snapshots/bns-name-snapshot.json", + "to": "bns-name-snapshot.json" + }, + { + "from": "bundled-addons", + "to": "bundled-addons" + }, + { + "from": "build/icon.ico", + "to": "icon.ico" + } + ], + "win": { + "icon": "build/icon.ico", + "target": [ + "nsis", + "portable" + ] + }, + "nsis": { + "oneClick": false, + "perMachine": false, + "allowToChangeInstallationDirectory": true, + "artifactName": "TheseusNavigator-Setup-${version}.${ext}", + "installerIcon": "build/icon.ico", + "uninstallerIcon": "build/icon.ico", + "installerHeaderIcon": "build/icon.ico", + "installerSidebar": "build/installerSidebar.bmp", + "uninstallerSidebar": "build/uninstallerSidebar.bmp", + "installerHeader": "build/installerHeader.bmp" + }, + "portable": { + "artifactName": "TheseusNavigator-${version}-portable.${ext}" + } + } +} diff --git a/popover-preload.js b/popover-preload.js new file mode 100644 index 00000000..a373f96b --- /dev/null +++ b/popover-preload.js @@ -0,0 +1,9 @@ +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("pop", { + onData: (cb) => ipcRenderer.on("site-info", (_e, d) => cb(d)), + close: () => ipcRenderer.invoke("close-site-info"), + resize: (h) => ipcRenderer.invoke("popover-resize", h), + // Live switcher: flip the active tab between BCNR and ICANN for this host, + // optionally remembering the choice per-name / per-TLD. + switchTo: (choice, remember) => ipcRenderer.invoke("collision-switch", { choice, remember }), +}); diff --git a/popover.html b/popover.html new file mode 100644 index 00000000..e4d3e8bb --- /dev/null +++ b/popover.html @@ -0,0 +1,131 @@ +<!doctype html> +<html><head><meta charset="utf-8"> +<style> + :root { color-scheme: light dark; font-family: system-ui, sans-serif; } + html, body { margin: 0; background: transparent; } + .card { display: flex; flex-direction: column; background: #1c222c; + border: 1px solid #ffffff26; border-radius: 8px; box-shadow: 0 12px 34px #000c; overflow: hidden; color: #e7eaf1; } + .close { position: absolute; top: 6px; right: 8px; border: none; background: transparent; color: #8b98a9; + cursor: pointer; font-size: 13px; border-radius: 5px; padding: 1px 6px; z-index: 2; } + .close:hover { background: #ffffff14; color: #fff; } + /* Firefox-style hero: big lock/shield + status + host */ + .hero { display: flex; gap: 12px; padding: 15px 16px 13px; align-items: flex-start; } + .hero .ico { flex: none; width: 26px; height: 26px; display: grid; place-items: center; } + .hero .ico svg { width: 22px; height: 22px; fill: none; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; } + .hero .ico .kh { fill: #1c222c; stroke: none; } + .hero.secure .ico svg { stroke: #4fd1a5; } .hero.secure .ico .bdy { fill: #4fd1a5; stroke: none; } + .hero.warn .ico svg { stroke: #f6ad55; } + .hero.neutral .ico svg { stroke: #9aa6b6; } .hero.neutral .ico .bdy { fill: #9aa6b6; stroke: none; } + .hero .status { font-weight: 600; font-size: 13.5px; } + .hero .host { color: #b9c2d0; font-size: 12px; margin-top: 2px; font-family: ui-monospace, monospace; word-break: break-all; } + .hero .sub { color: #8b98a9; font-size: 12px; margin-top: 7px; line-height: 1.5; } + .details { border-top: 1px solid #ffffff12; padding: 11px 16px 13px; display: grid; grid-template-columns: auto 1fr; gap: 7px 14px; font-size: 12px; } + .details .k { color: #7f8aa0; } .details .v { color: #e7eaf1; } + .details .v code { font-family: ui-monospace, monospace; color: #bfeae4; word-break: break-all; } + /* Collision switcher — only shown when the host could be BCNR OR ICANN */ + .switcher { border-top: 1px solid #ffffff12; padding: 10px 16px 12px; font-size: 12px; } + .switcher .title { color: #7f8aa0; margin-bottom: 6px; } + .switcher .row { display: flex; gap: 6px; } + .switcher button { flex: 1; background: transparent; color: #e7eaf1; border: 1px solid #ffffff26; border-radius: 6px; padding: 6px 8px; cursor: pointer; font-size: 12px; } + .switcher button:hover { background: #ffffff10; } + .switcher button.active { background: #f5c51820; border-color: #f5c518; color: #f5c518; cursor: default; } + .switcher button.active.icann { background: #4c9eff20; border-color: #4c9eff; color: #4c9eff; } + .switcher .rem { display: flex; gap: 10px; margin-top: 8px; color: #7f8aa0; } + .switcher .rem label { display: flex; gap: 4px; align-items: center; cursor: pointer; } + .switcher .rem input { accent-color: #f5c518; } + /* light theme (placed last so it wins over the dark base rules) */ + @media (prefers-color-scheme: light) { + .card { background: #ffffff; border-color: rgba(0,0,0,.15); color: #1a1f28; } + .close { color: #697280; } .close:hover { background: rgba(0,0,0,.06); } + .hero .host { color: #3c4453; } .hero .sub { color: #697280; } + .details { border-top-color: rgba(0,0,0,.10); } .details .k { color: #7b8494; } .details .v { color: #1a1f28; } + .details .v code { color: #0a7d73; } + .hero.neutral .ico svg { stroke: #697280; } .hero.neutral .ico .bdy { fill: #697280; } + .hero .ico .kh { fill: #ffffff; } + .switcher { border-top-color: rgba(0,0,0,.10); } + .switcher .title, .switcher .rem { color: #7b8494; } + .switcher button { color: #1a1f28; border-color: rgba(0,0,0,.15); } + .switcher button:hover { background: rgba(0,0,0,.05); } + .switcher button.active { background: #f5c51830; } + .switcher button.active.icann { background: #4c9eff30; } + } +</style></head> +<body> + <div class="card"> + <button class="close" id="x" title="Close">✕</button> + <div class="hero" id="hero"> + <div class="ico" id="ico"></div> + <div><div class="status" id="status"></div><div class="host" id="host"></div><div class="sub" id="sub"></div></div> + </div> + <div class="details" id="details"></div> + <div class="switcher" id="switcher" style="display:none"> + <div class="title">Open this name with</div> + <div class="row"> + <button id="sw-bcnr" data-choice="bcnr">⛓ BCDN</button> + <button id="sw-icann" data-choice="icann" class="icann">🌐 ICANN</button> + </div> + <div class="rem"> + <label><input type="radio" name="sw-rem" value="no" checked> Just this once</label> + <label><input type="radio" name="sw-rem" value="name"> Remember for <b id="sw-host"></b></label> + <label><input type="radio" name="sw-rem" value="tld"> All <b id="sw-tld"></b></label> + </div> + </div> + </div> +<script> + const $ = (id) => document.getElementById(id); + $("x").onclick = () => window.pop.close(); + const esc = (s) => String(s || "").replace(/</g, "&lt;"); + const LOCK = `<svg viewBox="0 0 24 24"><path class="shk" fill="none" d="M8 10.5 V8 a4 4 0 0 1 8 0 V10.5"/><rect class="bdy" x="5.5" y="10.5" width="13" height="9" rx="2.4"/><circle class="kh" cx="12" cy="15" r="1.4"/></svg>`; + const WARN = `<svg viewBox="0 0 24 24"><path d="M12 3 L22 20 H2 Z"/><path d="M12 9 V14 M12 17 v.4"/></svg>`; + const GLOBE = `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12 h18 M12 3 a14 14 0 0 1 0 18 a14 14 0 0 1 0 -18"/></svg>`; + + window.pop.onData((d) => { + const hero = $("hero"); + let cls = "hero secure", ico = LOCK, status = "Connection secure", sub = "", details = ""; + const row = (k, v) => `<span class="k">${k}</span><span class="v">${v}</span>`; + $("host").textContent = (d && d.host) || ""; + + if (!d || d.kind === "home") { + cls = "hero neutral"; ico = GLOBE; status = "Theseus"; $("host").textContent = ""; + sub = "The decentralized-web navigator. It resolves BCDN names straight from the blockchain."; + } else if (d.kind === "resolving") { + cls = "hero neutral"; ico = GLOBE; status = "Resolving…"; sub = "Looking this name up on the Bitcoin Cash chain."; + } else if (d.kind === "nxdomain") { + cls = "hero warn"; ico = WARN; status = "Not registered"; sub = "This name is not registered on BCDN (the on-chain registry)."; + } else if (d.kind === "error") { + cls = "hero warn"; ico = WARN; status = "Resolution error"; sub = esc(d.error || "The name could not be resolved."); + } else if (d.kind === "web") { + cls = "hero neutral"; ico = LOCK; status = "Connection"; sub = "A standard web page, resolved through ICANN DNS."; + details = row("Registry", "ICANN"); + } else { // ok — a resolved BCNR name + status = "Connection secure"; + sub = "Served over an encrypted connection from the <b>decentralized web</b> — no host, no registrar, no certificate authority."; + details = row("Served from", esc(d.source) || "chain") + + row("Registry", "BCDN — Bitcoin Cash Domain Names") + + (d.records && d.records.length ? row("Records", `<code>${esc(d.records.join(", "))}</code>`) : "") + + (d.category ? row("Certificate", `<code>${esc(String(d.category).slice(0, 20))}…</code>`) : ""); + } + hero.className = cls; $("ico").innerHTML = ico; $("status").textContent = status; $("sub").innerHTML = sub; + $("details").innerHTML = details; $("details").style.display = details ? "grid" : "none"; + // ---- collision switcher: only visible when this host is a collision candidate + // (loaded via BCNR or ICANN under a non-BCNR-native TLD) so flipping is meaningful. + const sw = $("switcher"), isCand = d && d.host && d.tld && (d.kind === "ok" || d.kind === "web") && !d.bcnrNativeTld; + if (isCand) { + const active = d.kind === "ok" ? "bcnr" : "icann"; + $("sw-bcnr").classList.toggle("active", active === "bcnr"); + $("sw-icann").classList.toggle("active", active === "icann"); + $("sw-host").textContent = d.host; + $("sw-tld").textContent = "." + d.tld; + sw.style.display = "block"; + } else { sw.style.display = "none"; } + requestAnimationFrame(() => { try { window.pop.resize(document.querySelector(".card").offsetHeight); } catch (e) {} }); + }); + // Live switcher — click the OTHER registry to flip the tab; the active one is a no-op. + for (const id of ["sw-bcnr", "sw-icann"]) $(id).onclick = (e) => { + const b = e.currentTarget; if (b.classList.contains("active")) return; + const rem = document.querySelector('input[name="sw-rem"]:checked')?.value || "no"; + window.pop.switchTo(b.dataset.choice, rem); + window.pop.close(); + }; +</script> +</body></html> diff --git a/preload.js b/preload.js new file mode 100644 index 00000000..8da50ec3 --- /dev/null +++ b/preload.js @@ -0,0 +1,99 @@ +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("theseus", { + navigate: (input) => ipcRenderer.invoke("navigate", input), + search: (q) => ipcRenderer.invoke("search", q), + newTab: () => ipcRenderer.invoke("new-tab"), + closeTab: (id) => ipcRenderer.invoke("close-tab", id), + switchTab: (id) => ipcRenderer.invoke("switch-tab", id), + moveTab: (id, targetId, place) => ipcRenderer.invoke("move-tab", id, targetId, place), + suggestAddress: (query, rect) => ipcRenderer.invoke("suggest-address", query, rect), + closeAddressPicker: () => ipcRenderer.invoke("close-address-picker"), + addressCursor: (dir) => ipcRenderer.invoke("address-cursor", dir), + togglePwFill: (rect) => ipcRenderer.invoke("toggle-pw-fill", rect), + onPwAvailability: (cb) => ipcRenderer.on("pw-availability", (_e, d) => cb(d)), + // Update chip: chrome subscribes to update-available; clicking the chip's + // download button opens the URL in the system browser; ✕ dismisses for + // the current session. + onUpdateAvailable: (cb) => ipcRenderer.on("update-available", (_e, d) => cb(d)), + openUpdateDownload: (url) => ipcRenderer.invoke("open-update-download", url), + installUpdateNow: () => ipcRenderer.invoke("install-update-now"), + dismissUpdate: () => ipcRenderer.invoke("dismiss-update"), + goHome: () => ipcRenderer.invoke("go-home"), + back: () => ipcRenderer.invoke("go-back"), + forward: () => ipcRenderer.invoke("go-forward"), + reload: (hard) => ipcRenderer.invoke("reload", !!hard), + stop: () => ipcRenderer.invoke("stop"), + toggleTor: () => ipcRenderer.invoke("toggle-tor"), + openSettings: (section) => ipcRenderer.invoke("open-settings", section), + getSettings: () => ipcRenderer.invoke("settings-get"), + setSetting: (key, val) => ipcRenderer.invoke("settings-set", key, val), + onSettingsUpdate: (cb) => ipcRenderer.on("settings-update", (_e, d) => cb(d)), + // Find-in-page. main.js fires 'find-open' on Ctrl+F; chrome renderer + // owns the bar UI and drives findInPage / stopFindInPage via these + // wrappers. Match count / active ordinal comes back through onFindResult. + onFindOpen: (cb) => ipcRenderer.on("find-open", () => cb()), + findInPage: (query, opts) => ipcRenderer.invoke("find-in-page", query, opts || {}), + findStop: () => ipcRenderer.invoke("find-stop"), + onFindResult: (cb) => ipcRenderer.on("find-result", (_e, d) => cb(d)), + toggleSiteInfo: (rect) => ipcRenderer.invoke("toggle-site-info", rect), + switchToBcnr: () => ipcRenderer.invoke("switch-to-bcnr"), + setChromeHeight: (h) => ipcRenderer.invoke("set-chrome-height", h), + getSearchEngines: () => ipcRenderer.invoke("search-engines"), + setSearchEngine: (id) => ipcRenderer.invoke("set-search-engine", id), + addEngine: (eng) => ipcRenderer.invoke("add-engine", eng), + removeEngine: (id) => ipcRenderer.invoke("remove-engine", id), + toggleEnginePicker: (rect) => ipcRenderer.invoke("toggle-engine-picker", rect), + onEngines: (cb) => ipcRenderer.on("engines", (_e, d) => cb(d)), + getBookmarks: () => ipcRenderer.invoke("bookmarks-get"), + addBookmark: (bm) => ipcRenderer.invoke("bookmark-add", bm), + updateBookmark: (url, patch) => ipcRenderer.invoke("bookmark-update", url, patch), + removeBookmark: (url) => ipcRenderer.invoke("bookmark-remove", url), + moveBookmark: (fromUrl, targetUrl, place) => ipcRenderer.invoke("bookmark-move", fromUrl, targetUrl, place), + onBookmarks: (cb) => ipcRenderer.on("bookmarks", (_e, d) => cb(d)), + onNav: (cb) => ipcRenderer.on("nav", (_e, d) => cb(d)), + onTor: (cb) => ipcRenderer.on("tor", (_e, d) => cb(d)), + onTabs: (cb) => ipcRenderer.on("tabs", (_e, d) => cb(d)), + tabReload: (id) => ipcRenderer.invoke("tab-reload", id), + tabDuplicate: (id) => ipcRenderer.invoke("tab-duplicate", id), + tabMute: (id, on) => ipcRenderer.invoke("tab-mute", id, on), + tabGroup: (id, color) => ipcRenderer.invoke("tab-group", id, color), + tabGroupToggle: (color) => ipcRenderer.invoke("tab-group-toggle", color), + tabBookmark: (id) => ipcRenderer.invoke("tab-bookmark", id), + onAddressPicked: (cb) => ipcRenderer.on("address-picked", (_e, url) => cb(url)), + onBcnrOffer: (cb) => ipcRenderer.on("bcnr-offer", (_e, d) => cb(d)), + // Collision-mode (BCNR ↔ ICANN) live switcher for the active tab + collisionSwitch: (arg) => ipcRenderer.invoke("collision-switch", arg), + collisionState: () => ipcRenderer.invoke("collision-state"), + // Downloads — the toolbar button subscribes to `downloads` to update its + // badge, and toggleDownloads opens/closes the floating panel. + getDownloads: () => ipcRenderer.invoke("downloads-get"), + toggleDownloads: (rect) => ipcRenderer.invoke("toggle-downloads", rect), + onDownloads: (cb) => ipcRenderer.on("downloads", (_e, d) => cb(d)), + // Add-on sidebar: toggle, open on a specific panel, close, or subscribe + // to state (visibility + which panel is active + the panel list). The + // extension dock in chrome.html renders one button per panel and calls + // openSidebar / closeSidebar accordingly. + toggleSidebar: () => ipcRenderer.invoke("sidebar-toggle"), + openSidebar: (panelId) => ipcRenderer.invoke("sidebar-open", panelId), + closeSidebar: () => ipcRenderer.invoke("sidebar-close"), + sidebarState: () => ipcRenderer.invoke("sidebar-state"), + onSidebarState: (cb) => ipcRenderer.on("sidebar-state", (_e, d) => cb(d)), + // Toolbar-menu (dropdown from an add-on's dock icon): dispatch the picked + // item id to the add-on's "menu-select" handler. + addonMenuSelect: (addonId, itemId) => ipcRenderer.invoke("addon-menu-select", addonId, itemId), + // Pop the dock-icon dropdown as a NATIVE menu. Renderer-side DOM popovers + // get clipped by chrome.html's own WebContentsView height (CHROME_H) and + // then covered by the tab view below it — a native Menu.popup escapes that + // layering entirely. Pass the button's viewport-rect so main can anchor. + toolbarMenuPopup: (addonId, rect) => ipcRenderer.invoke("toolbar-menu-popup", addonId, rect), + tabContextMenuPopup: (tabId, rect) => ipcRenderer.invoke("tab-context-menu-popup", tabId, rect), + // Ariadne's Thread menu behind the registry button at the end of the + // address bar (BCDN/ICANN switch, remembered choices, policy, settings). + registryMenuPopup: (rect) => ipcRenderer.invoke("registry-menu-popup", rect), + // Page zoom on the active tab: step +1 / -1 along Chrome's ladder, or reset to 100 %. + zoomStep: (dir) => ipcRenderer.invoke("zoom-step", dir), + zoomReset: () => ipcRenderer.invoke("zoom-reset"), + // Chrome listens so it can drop the "active" tint when the native menu + // dismisses (Esc, outside click, item picked — main fires all three). + onToolbarMenuClosed: (cb) => ipcRenderer.on("toolbar-menu-closed", () => cb()), +}); diff --git a/pw-fill-preload.js b/pw-fill-preload.js new file mode 100644 index 00000000..967d2a02 --- /dev/null +++ b/pw-fill-preload.js @@ -0,0 +1,6 @@ +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("pwfill", { + onMatches: (cb) => ipcRenderer.on("pw-matches", (_e, d) => cb(d)), + pick: (id) => ipcRenderer.invoke("pw-fill-pick", id), + resize: (h) => ipcRenderer.invoke("pw-fill-resize", h), +}); diff --git a/pw-fill.html b/pw-fill.html new file mode 100644 index 00000000..5402ad77 --- /dev/null +++ b/pw-fill.html @@ -0,0 +1,41 @@ +<!doctype html> +<html><head><meta charset="utf-8"> +<style> + :root { color-scheme: light dark; font-family: system-ui, sans-serif; } + html, body { margin: 0; background: transparent; } + .menu { background: #1c222c; border: 1px solid #ffffff26; border-radius: 10px; box-shadow: 0 12px 34px #000c; overflow: hidden; color: #e7eaf1; } + .hdr { font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: #8b98a9; padding: 10px 14px 6px; } + .item { display: flex; align-items: center; gap: 10px; padding: 9px 14px; cursor: pointer; font-size: 13px; } + .item:hover { background: #ffffff12; } + .item .ic { font-size: 14px; } + .item .txt { flex: 1; overflow: hidden; } + .item .u { color: #e7eaf1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .item .d { color: #7f8aa0; font-size: 11.5px; } + .empty { padding: 14px; color: #7f8aa0; font-size: 12.5px; text-align: center; } + @media (prefers-color-scheme: light) { + .menu { background: #ffffff; border-color: rgba(0,0,0,.15); color: #1a1f28; } + .item:hover { background: rgba(0,0,0,.05); } + .item .u { color: #1a1f28; } .item .d, .hdr, .empty { color: #7b8494; } + } +</style></head> +<body> + <div class="menu"> + <div class="hdr">Fill password for this site</div> + <div id="list"></div> + </div> +<script> + const $ = (id) => document.getElementById(id); + const esc = (s) => String(s || "").replace(/</g, "&lt;"); + function report() { requestAnimationFrame(() => { try { window.pwfill.resize(document.querySelector(".menu").offsetHeight); } catch (e) {} }); } + window.pwfill.onMatches((data) => { + const list = $("list"); + const matches = data.matches || []; + if (!matches.length) { list.innerHTML = `<div class="empty">No saved credentials for this site.</div>`; report(); return; } + list.innerHTML = matches.map((m) => + `<div class="item" data-id="${esc(m.id)}"><span class="ic">🔑</span><span class="txt"><div class="u">${esc(m.username || "(no username)")}</div><div class="d">${esc(m.domain)}</div></span></div>` + ).join(""); + list.querySelectorAll(".item").forEach((el) => el.onclick = () => window.pwfill.pick(el.dataset.id)); + report(); + }); +</script> +</body></html> diff --git a/scripts/generate-update-keypair.mjs b/scripts/generate-update-keypair.mjs new file mode 100644 index 00000000..917a1160 --- /dev/null +++ b/scripts/generate-update-keypair.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +// Generate an Ed25519 keypair for signing add-on updates. +// +// node scripts/generate-update-keypair.mjs <out-dir> +// +// Writes: +// <out-dir>/addon-update-key.pem — PKCS#8 PEM private key (keep secret) +// Prints: +// <64-char hex pubkey> — copy into addon-update-pubkeys.js +// +// The out-dir MUST NOT be inside the repo. Recommended: a machine-local +// ops folder outside git, e.g. `%USERPROFILE%\.silentmode\ops`. + +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import process from "node:process"; + +const outDir = process.argv[2]; +if (!outDir) { + console.error("usage: node scripts/generate-update-keypair.mjs <out-dir>"); + process.exit(2); +} +fs.mkdirSync(outDir, { recursive: true }); +const keyPath = path.join(outDir, "addon-update-key.pem"); +if (fs.existsSync(keyPath)) { + console.error(`refuse to overwrite existing key at ${keyPath}`); + process.exit(1); +} + +const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); +fs.writeFileSync(keyPath, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 }); + +// Extract the 32-byte raw pubkey from the SPKI DER. +const der = publicKey.export({ type: "spki", format: "der" }); +const raw = der.subarray(der.length - 32); +const hex = raw.toString("hex"); + +console.log(`private key written to ${keyPath}`); +console.log(`public key (paste into addon-update-pubkeys.js PUBKEYS_HEX):`); +console.log(` "${hex}"`); diff --git a/scripts/sign-addon-update.mjs b/scripts/sign-addon-update.mjs new file mode 100644 index 00000000..12d2b2a5 --- /dev/null +++ b/scripts/sign-addon-update.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Package and sign an add-on update, ready to publish behind an updateURL. +// +// node scripts/sign-addon-update.mjs <addon-dir> <base-url> <private-key.pem> [--out <dir>] +// +// - <addon-dir> path to the local addon folder to publish (must contain addon.json) +// - <base-url> canonical URL prefix where the tarball will be hosted; +// the tarball is placed at <base-url>/<id>-<version>.tar.gz +// - <private-key.pem> PKCS#8 PEM (as written by generate-update-keypair.mjs) +// - --out <dir> output dir (default: ./out/); tarball + updates.json entry are written there +// +// Produces: +// <out>/<id>-<version>.tar.gz — the payload the client will fetch +// <out>/<id>-<version>.entry.json — one signed entry (append to your live updates.json) +// +// Publish flow: +// 1. scp <out>/<id>-<version>.tar.gz to your web root at <base-url> +// 2. update the live updates.json for that add-on to include this entry +// (prepend it — the client picks the highest advertised version) +// 3. wait for existing installs to fetch on their next boot's +30s tick + +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { execFileSync } from "node:child_process"; +import process from "node:process"; +import os from "node:os"; + +const SIG_DOMAIN = "silentmode.addon-update-v1"; + +function die(msg) { console.error("error: " + msg); process.exit(1); } + +const args = process.argv.slice(2); +const outFlagIdx = args.indexOf("--out"); +const outDir = outFlagIdx >= 0 ? args.splice(outFlagIdx, 2)[1] : path.resolve("out"); +const [addonDir, baseUrl, keyPath] = args; +if (!addonDir || !baseUrl || !keyPath) { + console.error("usage: node scripts/sign-addon-update.mjs <addon-dir> <base-url> <private-key.pem> [--out <dir>]"); + process.exit(2); +} + +const addonJsonPath = path.join(addonDir, "addon.json"); +if (!fs.existsSync(addonJsonPath)) die(`no addon.json in ${addonDir}`); +const manifest = JSON.parse(fs.readFileSync(addonJsonPath, "utf8")); +const { id, version } = manifest; +if (!id || !version) die("addon.json missing id or version"); + +const keyPem = fs.readFileSync(keyPath, "utf8"); +let privateKey; +try { privateKey = crypto.createPrivateKey({ key: keyPem, format: "pem" }); } +catch (e) { die(`bad private key at ${keyPath}: ${e.message}`); } +if (privateKey.asymmetricKeyType !== "ed25519") die("private key must be Ed25519"); + +fs.mkdirSync(outDir, { recursive: true }); +const tarName = `${id}-${version}.tar.gz`; +const tarPath = path.join(outDir, tarName); + +// Tar the CONTENTS of the addon folder (not the folder itself) so entries +// live at the archive root: `addon.json`, `editor.html`, etc. The client +// extracts into a fresh dir and expects to find addon.json directly there. +try { + // On Windows, Git-Bash tar (MSYS2) both mistakes drive letters for + // host:file remote-archive syntax AND mangles backslashes on its way to + // tar's argv. --force-local kills the first, forward-slash paths kill + // the second. Win10 built-in bsdtar and GNU tar both accept the flag. + const posix = (p) => p.replace(/\\/g, "/"); + execFileSync("tar", ["--force-local", "-c", "-z", "-f", posix(tarPath), "-C", posix(path.resolve(addonDir)), "."], { stdio: "inherit" }); +} catch (e) { die(`tar failed: ${e.message}`); } + +const tarBytes = fs.readFileSync(tarPath); +const sha256 = crypto.createHash("sha256").update(tarBytes).digest("hex"); +const url = baseUrl.replace(/\/+$/, "") + "/" + tarName; + +const canonical = `${SIG_DOMAIN}|${id}|${version}|${sha256}`; +const sig = crypto.sign(null, Buffer.from(canonical), privateKey).toString("base64"); + +const entry = { version, url, sha256, sig }; +const entryPath = path.join(outDir, `${id}-${version}.entry.json`); +fs.writeFileSync(entryPath, JSON.stringify(entry, null, 2) + "\n"); + +console.log(`tarball: ${tarPath} (${tarBytes.length} bytes, ${sha256})`); +console.log(`entry: ${entryPath}`); +console.log(""); +console.log("To publish, host the tarball at " + url + " and add this entry to updates.json:"); +console.log(JSON.stringify(entry, null, 2)); diff --git a/settings-preload.js b/settings-preload.js new file mode 100644 index 00000000..20babb6d --- /dev/null +++ b/settings-preload.js @@ -0,0 +1,52 @@ +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("cfg", { + get: () => ipcRenderer.invoke("settings-get"), + set: (key, value) => ipcRenderer.invoke("settings-set", key, value), + engines: () => ipcRenderer.invoke("search-engines"), + addEngine: (eng) => ipcRenderer.invoke("add-engine", eng), + removeEngine: (id) => ipcRenderer.invoke("remove-engine", id), + setEngineEnabled: (id, on) => ipcRenderer.invoke("set-engine-enabled", id, on), + setEngineOrder: (ids) => ipcRenderer.invoke("set-engine-order", ids), + removeFromList: (id) => ipcRenderer.invoke("remove-from-list", id), + // Storage: wipe browsing data on demand. Pass any subset of + // { cookies, cache, storage, history }. + clearBrowsingData: (opts) => ipcRenderer.invoke("clear-browsing-data", opts), + // Password vault. All calls return { ok, ... } | { ok: false, err }. + // Renderers never see the seed / vault key / master password past setup/ + // unlock; get() returns plaintext only in explicit response to a user click. + pwStatus: () => ipcRenderer.invoke("password-status"), + pwSetup: (masterPassword, seedSource) => ipcRenderer.invoke("password-setup", { masterPassword, seedSource }), + pwUnlock: (masterPassword) => ipcRenderer.invoke("password-unlock", masterPassword), + pwLock: () => ipcRenderer.invoke("password-lock"), + pwList: () => ipcRenderer.invoke("password-list"), + pwGet: (id) => ipcRenderer.invoke("password-get", id), + pwAdd: (entry) => ipcRenderer.invoke("password-add", entry), + pwUpdate: (id, patch) => ipcRenderer.invoke("password-update", id, patch), + pwRemove: (id) => ipcRenderer.invoke("password-remove", id), + pwGenerate: (spec) => ipcRenderer.invoke("password-generate", spec), + // Main asks settings to jump to a specific sidebar section (e.g. from the + // engine picker's "Search settings…" click). Emits the section id string. + onFocusSection: (cb) => ipcRenderer.on("focus-section", (_e, section) => cb(section)), + // Collision-mode: BCNR/ICANN policy + per-name/per-TLD overrides + collisionState: () => ipcRenderer.invoke("collision-state"), + setCollisionPolicy: (p) => ipcRenderer.invoke("collision-set-policy", p), + resetCollisions: () => ipcRenderer.invoke("collision-reset"), + ariadneState: () => ipcRenderer.invoke("ariadne-state"), + ariadneToggle: (on) => ipcRenderer.invoke("ariadne-toggle", !!on), + ariadneInstall: () => ipcRenderer.invoke("ariadne-install"), + ariadneUpdate: () => ipcRenderer.invoke("ariadne-update"), + ariadneUninstall: () => ipcRenderer.invoke("ariadne-uninstall"), + // Manual "Check for updates" — un-dismisses any existing chip and re- + // fetches the release manifest. Returns { updateAvailable, currentVersion }. + recheckUpdate: () => ipcRenderer.invoke("recheck-update"), + appVersion: () => ipcRenderer.invoke("app-version"), + restartApp: () => ipcRenderer.invoke("app-restart"), + // Add-ons management (Settings > Add-ons tab). + listAddons: () => ipcRenderer.invoke("addons-list"), + setAddonEnabled: (id, enabled) => ipcRenderer.invoke("addons-set-enabled", id, !!enabled), + revealAddon: (folder) => ipcRenderer.invoke("addons-reveal", folder), + openAddonsDir: () => ipcRenderer.invoke("addons-open-dir"), + reloadAddons: () => ipcRenderer.invoke("addons-reload"), + checkAddonUpdates: () => ipcRenderer.invoke("addons-check-updates"), + listStagedAddonUpdates: () => ipcRenderer.invoke("addons-list-staged"), +}); diff --git a/settings.html b/settings.html new file mode 100644 index 00000000..b7e66bb2 --- /dev/null +++ b/settings.html @@ -0,0 +1,1401 @@ +<!doctype html> +<html lang="en"> +<head><meta charset="utf-8"><title>Theseus — Settings</title> +<style> + /* --acid-text: color used for text/inline emphasis. In dark mode it + equals --acid (bright acid green, ~10:1 on the dark ground). Light + mode overrides to #253A49 (BCH dark navy) which is ~12:1 on white, + because the BCH-teal --acid (#0AC18E) only manages ~2.9:1 as text. + --acid still drives fills, borders, and background tints where a + brand-green splash is wanted. */ + :root{ --bg:#0b0e14; --panel:#141a24; --line:rgba(255,255,255,.09); + --ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --acid-text:#d6ff3d; } + *{box-sizing:border-box} + body{margin:0;height:100vh;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,-apple-system,Segoe UI,Roboto,sans-serif} + .app{display:flex;height:100vh} + /* left sidebar menu */ + .side{flex:none;width:220px;background:#0e131c;border-right:1px solid var(--line);padding:20px 12px;display:flex;flex-direction:column;gap:2px} + .brand{font-weight:700;color:var(--acid-text);font-size:15px;padding:4px 12px 16px} + .side a{display:block;padding:9px 12px;border-radius:8px;color:var(--mut);text-decoration:none;font-size:14px;cursor:pointer} + .side a:hover{background:#ffffff0a;color:var(--ink)} + .side a.active{background:rgb(from var(--acid) r g b / .12);color:var(--acid-text)} + /* content */ + .content{flex:1;overflow-y:auto;padding:2.4rem 2.4rem 4rem} + section{max-width:640px} + h1{font-size:1.4rem;margin:0 0 .2rem} + .lede{color:var(--mut);margin:0 0 1.8rem;font-size:13.5px} + h2.sub{font-size:11.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);margin:1.8rem 0 .2rem;border-top:1px solid var(--line);padding-top:1.4rem} + .subd{color:var(--mut);margin:0 0 1rem;font-size:13px} + .row{display:flex;align-items:center;gap:16px;background:var(--panel);border:1px solid var(--line); + border-radius:12px;padding:14px 18px;margin-bottom:10px} + .row .txt{flex:1} + .row .t{font-weight:600} + .row .d{color:var(--mut);font-size:13px;margin-top:2px} + /* control column — mode select + optional value field on the same row so the + dropdown menus don't get cut off underneath, wraps only when narrow */ + .ctl{display:flex;flex-direction:row;flex-wrap:wrap;gap:6px;align-items:center;justify-content:flex-end;flex:none;max-width:60%} + .ctl .coords{display:flex;gap:6px} + select,.ctl input{background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px;outline:none;min-width:140px} + select:focus,.ctl input:focus{border-color:#4b7bec} + .ctl input{width:170px} .ctl .coords input{width:92px;min-width:auto} + /* Chromium's native <select> popup uses the page's color-scheme; when it's + "light dark" (both accepted) Chromium picks by the OS, so a dark-theme app + on a light OS shows a light popup. main.js's applyTheme() maps to + nativeTheme.themeSource, which drives prefers-color-scheme — so pinning + color-scheme via that media query keeps the popup in sync automatically. */ + :root { color-scheme: dark; } + @media (prefers-color-scheme: light) { :root { color-scheme: light; --acid: #0AC18E; --acid-text: #253A49; } } + /* Explicit option styling — Chromium respects it in the popup on Windows. */ + select option { background: #1b2330; color: var(--ink); } + @media (prefers-color-scheme: light) { select option { background: #f1f3f7; color: #1a1f28; } } + code{font-family:ui-monospace,monospace;font-size:12px;background:#0e131b;border:1px solid var(--line);border-radius:5px;padding:1px 5px;color:#bfeae4} + .addeng{display:flex;gap:6px} .addeng input{flex:1;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:13px;outline:none} + .addeng input:focus{border-color:#4b7bec} + .btn{border:1px solid color-mix(in srgb, var(--acid) 33%, transparent);background:rgb(from var(--acid) r g b / .12);color:var(--acid-text);border-radius:8px;padding:8px 14px;font-size:13px;cursor:pointer} + .btn:hover{background:rgb(from var(--acid) r g b / .22)} + /* segmented control — small toggle used elsewhere (kept for reuse) */ + .segseg{display:inline-flex;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:2px;gap:2px} + .segseg .seg{background:transparent;color:var(--mut);border:none;border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer;font:inherit;line-height:1.2} + .segseg .seg:hover{color:var(--ink)} + .segseg .seg.on{background:#1c2432;color:var(--ink);box-shadow:inset 0 0 0 1px var(--line)} + /* theme cards — three visual previews (System / Light / Dark) */ + .themeCards{display:flex;gap:14px;margin:6px 0 4px;flex-wrap:wrap} + .themeCards .tc{background:transparent;border:2px solid var(--line);border-radius:10px;padding:10px; + display:flex;flex-direction:column;align-items:center;gap:8px;cursor:pointer;color:var(--ink); + font:inherit;font-size:13px;min-width:150px;transition:border-color .12s} + .themeCards .tc:hover{border-color:#4b7bec55} + .themeCards .tc.on{border-color:#4b7bec;box-shadow:0 0 0 1px #4b7bec inset} + .themeCards .mock{width:130px;height:82px;border-radius:6px;overflow:hidden;display:block;position:relative; + border:1px solid rgba(255,255,255,.08)} + .themeCards .mock .mm-chrome{position:absolute;left:0;right:0;top:0;height:22px;display:block} + .themeCards .mock .mm-chrome::before{content:"";position:absolute;left:8px;top:6px;width:8px;height:8px;border-radius:50%;background:#f6768a} + .themeCards .mock .mm-chrome::after {content:"";position:absolute;left:22px;top:6px;width:8px;height:8px;border-radius:50%;background:#f6c15c;box-shadow:14px 0 0 #6ec27d} + .themeCards .mock .mm-body{position:absolute;left:0;right:0;top:22px;bottom:0;display:block} + .themeCards .mock-light .mm-chrome{background:#e9ecf2} + .themeCards .mock-light .mm-body {background:#ffffff;background-image:linear-gradient(#0000000c 1px,transparent 1px);background-size:100% 12px;background-position:0 10px} + .themeCards .mock-dark .mm-chrome{background:#141b28} + .themeCards .mock-dark .mm-body {background:#0b0e14;background-image:linear-gradient(#ffffff10 1px,transparent 1px);background-size:100% 12px;background-position:0 10px} + .themeCards .mock-system .mm-chrome{background:linear-gradient(90deg,#141b28 0 50%,#e9ecf2 50% 100%)} + .themeCards .mock-system .mm-body{background:linear-gradient(90deg,#0b0e14 0 50%,#ffffff 50% 100%)} + .themeCards .tc-label{font-weight:500;color:var(--ink)} + .polrow{display:flex;align-items:center;gap:10px;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer} + .polrow:hover{background:#141c28} + .polrow input{accent-color:var(--acid)} + .pmuted{color:var(--mut)} + .ceng{display:flex;align-items:center;gap:8px;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin-bottom:6px;font-size:13px} + .ceng .cs{font-size:14px;flex:none} + /* engine checklist */ + .eng{display:flex;align-items:center;gap:10px;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin-bottom:6px;font-size:13px} + .eng .eic{width:18px;height:18px;flex:none;display:grid;place-items:center} + .eng .eic .ei{width:16px;height:16px;border-radius:3px} .eng .eic .es{font-size:14px} + .eng .enm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .eng .cx{cursor:pointer;color:var(--dim);border:none;background:transparent;font-size:13px} .eng .cx:hover{color:#f6768a} + .eng{cursor:default} + .eng .grip{flex:none;color:var(--dim);cursor:grab;font-size:14px;line-height:1;padding:0 2px;user-select:none} + .eng .grip:active{cursor:grabbing} + .eng.dragging{opacity:.45} + .eng.over{border-color:var(--acid);box-shadow:0 -2px 0 var(--acid) inset} + .eng.off{opacity:.55} + .eng.off .enm{color:var(--mut)} + .ehdr{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);margin:14px 0 6px;padding-top:2px} + .ehdr:first-child{margin-top:0} + /* right-click context menu for an engine row */ + .ctxmenu{position:fixed;z-index:9999;background:#1c222c;border:1px solid var(--line);border-radius:8px; + box-shadow:0 12px 34px #000c;padding:4px;min-width:180px;font-size:13px;color:var(--ink)} + .ctxmenu .mi{padding:7px 12px;border-radius:5px;cursor:pointer;white-space:nowrap} + .ctxmenu .mi:hover{background:#ffffff10} + .ctxmenu .mi.danger{color:#f6768a} + .ctxmenu .mi.danger:hover{background:rgba(246,118,138,.12)} + @media (prefers-color-scheme: light){ + .ctxmenu{background:#ffffff;border-color:rgba(0,0,0,.15)} + .ctxmenu .mi:hover{background:rgba(0,0,0,.05)} + } + /* engine catalog panel — appears under the enabled list when "+ Add" is clicked */ + .engcat{margin-top:6px;padding:12px 12px 10px;background:#0e131c;border:1px solid var(--line);border-radius:10px} + .engcat .cat{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;font-size:13px} + .engcat .cat:hover{background:#141b26} + .engcat .cat .eic{width:18px;height:18px;flex:none;display:grid;place-items:center} + .engcat .cat .eic .ei{width:16px;height:16px;border-radius:3px} .engcat .cat .eic .es{font-size:14px} + .engcat .cat .enm{flex:1} + .engcat .cat .kind{font-size:10.5px;letter-spacing:.05em;color:var(--dim);padding:1px 6px;border:1px solid var(--line);border-radius:999px} + .engcat .cat .add{background:transparent;border:1px solid var(--line);color:var(--ink);border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer} + .engcat .cat .add:hover{background:rgb(from var(--acid) r g b / .14);border-color:color-mix(in srgb, var(--acid) 33%, transparent);color:var(--acid-text)} + .engcat .cempty2{color:var(--dim);font-size:12.5px;padding:6px 8px} + .sw.sm{width:38px;height:22px} .sw.sm .knob{width:15px;height:15px} .sw.sm input:checked + .track .knob{transform:translateX(16px)} + .ceng .cn{font-weight:600} .ceng .cu{color:var(--dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1} + .ceng .cx{cursor:pointer;color:#8b98a9;border:none;background:transparent;font-size:13px} .ceng .cx:hover{color:#f6768a} + .cempty{color:var(--dim);font-size:12.5px} + /* toggle */ + .sw{position:relative;width:46px;height:26px;flex:none;cursor:pointer} + .sw input{opacity:0;width:0;height:0} + .track{position:absolute;inset:0;background:#2b3444;border:1px solid var(--line);border-radius:999px;transition:.15s} + .knob{position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#8b98a9;transition:.15s} + .sw input:checked + .track{background:rgb(from var(--acid) r g b / .25);border-color:color-mix(in srgb, var(--acid) 33%, transparent)} + .sw input:checked + .track .knob{transform:translateX(20px);background:var(--acid)} + .note{color:var(--dim);font-size:12.5px;margin-top:1.4rem;border-top:1px solid var(--line);padding-top:1rem} + /* light theme (placed last so these win over the dark base rules) */ + @media (prefers-color-scheme: light){ + :root{ --bg:#F8FDFF; --panel:#ffffff; --line:rgba(37,58,73,.15); --ink:#253A49; --mut:#4a5262; --dim:#7b8494; } + .side{ background:#f1f5f8; border-right-color:rgba(37,58,73,.10); } + .side a{ color:#4a5262; } + .side a:hover{ background:rgba(37,58,73,.06); color:#253A49; } + /* Every dark-hardcoded card / control needs a light equivalent. All the + #10151f / #0e131c / #1b2330 tones map to a subtle off-white so they + still read as cards against the #F8FDFF page, with rgba(37,58,73,X) + hover tints keyed to BCH dark for consistent depth. */ + .polrow{ background:#f4f8fb; color:#253A49; } + .polrow:hover{ background:#eaf0f5; } + .polrow input:checked ~ span, + .polrow:has(input:checked){ background:rgb(from var(--acid) r g b / .10); border-color:color-mix(in srgb, var(--acid) 35%, transparent); } + .segseg{ background:#eef1f6; } + .segseg .seg.on{ background:#ffffff; color:#253A49; box-shadow:inset 0 0 0 1px rgba(37,58,73,.12); } + .engcat{ background:#f4f8fb; } + .engcat .cat:hover{ background:#eaf0f5; } + select, .ctl input, .addeng input{ background:#f1f5f8; color:#253A49; } + select option{ background:#ffffff; color:#253A49; } + .track{ background:#d3dae3; } + .knob{ background:#8a93a2; } + code, .ceng, .eng{ background:#eef2f6; color:#253A49; } + .themeCards .tc{ background:#ffffff; border-color:rgba(37,58,73,.15); } + .themeCards .tc:hover{ border-color:rgba(37,58,73,.30); } + .ctxmenu{ background:#ffffff; border-color:rgba(37,58,73,.15); color:#253A49; } + .ctxmenu .mi:hover{ background:rgba(37,58,73,.06); } + } +</style></head> +<body> +<div class="app"> + <nav class="side"> + <div class="brand">⛓ Theseus</div> + <a data-sec="general" class="active">General</a> + <a data-sec="search">Search</a> + <a data-sec="passwords">Passwords</a> + <a data-sec="performance">Performance</a> + <a data-sec="privacy">Privacy</a> + <a data-sec="plugins">Plug-ins</a> + <a data-sec="addons">Extensions</a> + </nav> + <div class="content"> + <!-- GENERAL --> + <section id="general"> + <h1>General</h1> + <p class="lede">Changes apply immediately and are saved for next time.</p> + <h2 class="sub" style="border-top:0;padding-top:0;margin-top:0">Startup</h2> + <div class="row"> + <div class="txt"><div class="t">Open previous windows and tabs</div><div class="d">Restore the tabs from your last session when Theseus starts.</div></div> + <label class="sw"><input type="checkbox" id="restoreSession"><span class="track"><span class="knob"></span></span></label> + </div> + <div class="row" style="flex-direction:column;align-items:stretch;gap:8px"> + <div class="txt"> + <div class="t">Updates</div> + <div class="d">Theseus already checks the release manifest at boot and every 6h. Click below to check right now.</div> + </div> + <div id="updStatus" class="pmuted" style="font-size:12.5px">Loading…</div> + <div style="display:flex;gap:8px;flex-wrap:wrap"> + <button id="updCheck" class="btn">Check for updates</button> + </div> + </div> + <h2 class="sub">Appearance</h2> + <p class="subd">Choose a theme for the browser. <b>System</b> follows your operating system's light/dark setting.</p> + <div class="themeCards" id="theme" role="radiogroup" aria-label="Theme"> + <button type="button" class="tc" data-val="system" role="radio" aria-checked="false"> + <span class="mock mock-system"><span class="mm-chrome"></span><span class="mm-body"></span></span> + <span class="tc-label">System</span> + </button> + <button type="button" class="tc" data-val="light" role="radio" aria-checked="false"> + <span class="mock mock-light"><span class="mm-chrome"></span><span class="mm-body"></span></span> + <span class="tc-label">Light</span> + </button> + <button type="button" class="tc" data-val="dark" role="radio" aria-checked="false"> + <span class="mock mock-dark"><span class="mm-chrome"></span><span class="mm-body"></span></span> + <span class="tc-label">Dark</span> + </button> + </div> + + <h2 class="sub" style="margin-top:1.8rem">Developer tools</h2> + <p class="subd">Where the Chromium DevTools panel appears when you press <b>F12</b> or <b>Ctrl+Shift+I</b>. You can always drag it out to a separate window from inside the panel.</p> + <div class="row" style="flex-direction:column;align-items:stretch;gap:10px"> + <div id="devToolsDockList" style="display:flex;flex-direction:column;gap:6px"> + <label class="polrow"><input type="radio" name="devToolsDock" value="bottom"><span><b>Bottom panel</b> <span class="pmuted">— docked under the tab, like Chrome's default. Best for wide screens.</span></span></label> + <label class="polrow"><input type="radio" name="devToolsDock" value="sidebar"><span><b>Right sidebar</b> <span class="pmuted">— DevTools takes the right side. If an add-on sidebar is open, it hides while DevTools is up.</span></span></label> + <label class="polrow"><input type="radio" name="devToolsDock" value="two-sidebars"><span><b>Two sidebars</b> <span class="pmuted">— DevTools opens on the right, and the add-on sidebar stays where it is. The two share the right area.</span></span></label> + </div> + </div> + + <h2 class="sub" style="margin-top:1.8rem">Registries</h2> + <p class="subd">How Theseus picks between the <b>BCNR / BCDN</b> and <b>ICANN / IANA</b>, when a name exists in both.</p> + <div class="row" style="flex-direction:column;align-items:stretch;gap:10px"> + <div class="txt"><div class="t">Collision policy</div> + <div class="d">A name only exists in both registries when its TLD isn't BCNR-unique (e.g. <code>.de</code>). BCNR-unique TLDs (that only exist on BCNR) never conflict.</div></div> + <div id="policyList" style="display:flex;flex-direction:column;gap:6px"> + <label class="polrow"><input type="radio" name="collisionPolicy" value="bcnr-first"><span><b>BCDN first</b> <span class="pmuted">— BCDN wins conflicts; falls back to ICANN for anything BCDN doesn't have.</span></span></label> + <label class="polrow"><input type="radio" name="collisionPolicy" value="icann-first"><span><b>ICANN first</b> <span class="pmuted">— ICANN wins conflicts; BCDN fills gaps for non-ICANN TLDs.</span></span></label> + <label class="polrow"><input type="radio" name="collisionPolicy" value="soft"><span><b>Ask each time</b> <span class="pmuted">— an "Open with…" prompt on conflict, remembered per name or per TLD.</span></span></label> + </div> + </div> + <div class="row" style="flex-direction:column;align-items:stretch;gap:8px"> + <div class="txt"><div class="t">Remembered choices</div> + <div class="d">"Always use …" picks you made from the switcher or the prompt. Reset them to be asked again.</div></div> + <div id="colSummary" class="pmuted" style="font-size:12.5px"></div> + <div><button id="resetCollisions" class="btn">Reset remembered choices</button></div> + </div> + </section> + <!-- PLUG-INS --> + <section id="plugins" hidden> + <h1>Plug-ins</h1> + <p class="lede">Silent Mode components that live alongside Theseus — the system-wide resolver and the built-in wallet. Each ships with its own install / update / on-off controls.</p> + <div class="row" style="flex-direction:column;align-items:stretch;gap:8px"> + <div class="txt"> + <div class="t">System-wide resolver — Ariadne's Thread</div> + <div class="d"> + A local daemon that resolves BCDN names for <b>every browser on this machine</b> + (Chrome, Edge, Firefox, etc.), not just Theseus. Turning it off means non-Theseus + browsers stop resolving <code>.bch</code> / <code>.x</code> / other BCNR TLDs; + Theseus keeps working either way, since it has its own built-in resolver. + </div> + </div> + <div id="ariadneStatus" class="pmuted" style="font-size:12.5px">checking…</div> + <div style="display:flex;gap:8px;flex-wrap:wrap"> + <button id="ariadneStart" class="btn" hidden>Turn on</button> + <button id="ariadneStop" class="btn" hidden>Turn off</button> + <button id="ariadneInstall" class="btn" hidden>Install</button> + <button id="ariadneUpdate" class="btn" hidden>Update</button> + <button id="ariadneUninstall" class="btn" hidden style="color:#f6768a;border-color:rgba(246,118,138,.35)">Uninstall</button> + <button id="ariadneRefresh" class="btn">Refresh</button> + </div> + <div id="ariadneMissing" class="pmuted" style="font-size:12px" hidden> + Ariadne's Thread isn't installed on this machine. Click <b>Install</b> above + to run the bundled installer, or grab it manually from + <a href="https://silentmode.st/tools/">silentmode.st/tools</a>. + </div> + </div> + <div class="row" style="flex-direction:column;align-items:stretch;gap:8px"> + <div class="txt"> + <div class="t">Built-in wallet — Aegis</div> + <div class="d"> + Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) that ships in the box. + Bundled with Theseus, but updates are delivered <b>over-the-air</b>: new signed + versions land without a Theseus release. Runs entirely inside this browser — + never system-wide. + </div> + </div> + <div id="aegisStatus" class="pmuted" style="font-size:12.5px">Loading…</div> + <div style="display:flex;gap:8px;flex-wrap:wrap"> + <button id="aegisCheck" class="btn">Check for updates</button> + <button id="aegisApply" class="btn" hidden>Restart to apply update</button> + </div> + </div> + </section> + <!-- SEARCH --> + <section id="search" hidden> + <h1>Search</h1> + <p class="lede">Pick what your address bar and the toolbar dropdown search with.</p> + <div class="row"> + <div class="txt"><div class="t">Default search engine</div><div class="d">Used when you type into the address bar.</div></div> + <div class="ctl"><select id="searchEngine"></select></div> + </div> + <div class="row" style="flex-direction:column;align-items:stretch;gap:10px"> + <div class="txt" style="display:flex;align-items:center;justify-content:space-between;gap:12px"> + <div> + <div class="t">Additional search engines</div> + <div class="d">These appear in the toolbar dropdown. Drag to reorder. Toggle off to move an engine back to the catalog.</div> + </div> + <button id="engAddBtn" class="btn" type="button">+ Add search engine</button> + </div> + <div id="engineList"></div> + <!-- Catalog: hidden until "Add" is clicked. Three tiers, top to bottom: + 1. curated built-ins the user hasn't enabled (tier="catalog") + 2. wider bank filtered by a search box (tier="extra") + 3. custom-URL form --> + <div id="engineCatalog" class="engcat" hidden> + <div class="ehdr">Add from catalog</div> + <div id="catalogList"></div> + <div class="ehdr" style="display:flex;align-items:center;justify-content:space-between;gap:8px"> + <span>Discover more engines</span> + <input id="engineFilter" type="search" placeholder="Filter by name…" autocomplete="off" + style="background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:12px;min-width:auto;width:170px"> + </div> + <div id="extraList"></div> + <div class="ehdr">Or add a custom URL</div> + <div class="addeng"> + <input id="engSym" placeholder="🔍" style="max-width:52px;text-align:center;flex:none"> + <input id="engName" placeholder="Name (e.g. My SearXNG)"> + <input id="engUrl" placeholder="https://example.com/search?q=%s"> + <button id="engAdd" class="btn">Add</button> + </div> + </div> + </div> + </section> + <!-- PASSWORDS --> + <section id="passwords" hidden> + <h1>Passwords</h1> + <p class="lede">Local password vault. Set once, unlocked with a master password. Encrypted at rest; nothing leaves your machine.</p> + <!-- State A: no vault yet — set up --> + <div id="pwSetup" hidden> + <div class="row" style="flex-direction:column;align-items:stretch;gap:10px"> + <div class="txt"><div class="t">Master password</div> + <div class="d">Used to unlock the vault every session. This is separate from your Ariadne wallet passphrase — memorize it, we can't recover it.</div></div> + <div class="addeng"><input id="pwSetupPw1" type="password" placeholder="Master password"><input id="pwSetupPw2" type="password" placeholder="Confirm"></div> + </div> + <div class="row" style="flex-direction:column;align-items:stretch;gap:10px"> + <div class="txt"><div class="t">Seed for deterministic passwords</div> + <div class="d">The "Generate" button in an entry derives a password from this seed. Same seed on another device → same passwords for the same site + username.</div></div> + <div style="display:flex;flex-direction:column;gap:6px"> + <label class="polrow"><input type="radio" name="pwSeedSource" value="mnemonic" checked><span><b>Use my Ariadne wallet mnemonic</b> <span class="pmuted">— unified identity, one seed to back up</span></span></label> + <label class="polrow"><input type="radio" name="pwSeedSource" value="generate"><span><b>Generate a new independent seed</b> <span class="pmuted">— isolated from any BCH funds</span></span></label> + </div> + <textarea id="pwSetupMnemonic" placeholder="12 or 24 BIP39 words separated by spaces" rows="3" style="background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:13px;font-family:ui-monospace,monospace;outline:none;resize:vertical"></textarea> + <div class="pmuted" style="font-size:12px">The mnemonic is used only to derive the password-purpose subtree (m/1381'/0'). It is not stored — only the derived subtree key is persisted, encrypted with your master password.</div> + </div> + <div class="row" style="justify-content:flex-end"> + <button id="pwSetupBtn" class="btn" type="button">Create vault</button> + </div> + </div> + <!-- State B: vault exists but locked --> + <div id="pwLocked" hidden> + <div class="row"> + <div class="txt"><div class="t">Unlock vault</div><div class="d">Enter your master password to view or add entries.</div></div> + <div class="ctl" style="align-items:stretch"><input id="pwUnlockPw" type="password" placeholder="Master password"><button id="pwUnlockBtn" class="btn" type="button">Unlock</button></div> + </div> + <div id="pwUnlockErr" class="pmuted" style="color:#f6768a;font-size:12.5px;margin-top:4px" hidden></div> + </div> + <!-- State C: vault unlocked --> + <div id="pwUnlocked" hidden> + <div class="row" style="justify-content:space-between"> + <div class="txt"><div class="t">Your passwords</div><div class="d">Reveal, copy, or edit any entry. The vault re-locks when Theseus quits.</div></div> + <button id="pwLockBtn" class="btn" type="button">Lock now</button> + </div> + <div id="pwList"></div> + <h2 class="sub">Add an entry</h2> + <div class="row" style="flex-direction:column;align-items:stretch;gap:8px"> + <div class="addeng"><input id="pwAddDomain" placeholder="Site (e.g. github.com)"><input id="pwAddUser" placeholder="Username or email"></div> + <div style="display:flex;flex-direction:column;gap:6px"> + <label class="polrow"><input type="radio" name="pwAddKind" value="generated" checked><span><b>Generate deterministically</b> <span class="pmuted">— derived from your seed; same across devices</span></span></label> + <label class="polrow"><input type="radio" name="pwAddKind" value="literal"><span><b>Paste an existing password</b> <span class="pmuted">— for legacy accounts you already set elsewhere</span></span></label> + </div> + <input id="pwAddLiteral" type="password" placeholder="Paste password" hidden> + <div class="addeng"><button id="pwAddPreview" class="btn" type="button" style="flex:none">Preview</button><input id="pwAddPreviewOut" readonly placeholder="preview appears here" style="font-family:ui-monospace,monospace"></div> + <div style="display:flex;justify-content:flex-end"><button id="pwAddBtn" class="btn" type="button">Save entry</button></div> + </div> + <div class="note">There is no autofill yet (phase 2). Copy the password from an entry and paste it into the site.</div> + </div> + </section> + <!-- NAMING --> + <!-- Registries moved into General (above). --> + + <!-- PERFORMANCE --> + <section id="performance" hidden> + <h1>Performance</h1> + <p class="lede">Keep Theseus light on resources.</p> + <div class="row"> + <div class="txt"><div class="t">Throttle inactive tabs</div><div class="d">Background and inactive tabs use far less CPU. Recommended.</div></div> + <label class="sw"><input type="checkbox" id="backgroundThrottle"><span class="track"><span class="knob"></span></span></label> + </div> + </section> + <!-- PRIVACY --> + <section id="privacy" hidden> + <h1>Privacy</h1> + <p class="lede">Reduce tracking and lock down device access.</p> + <div class="row"> + <div class="txt"><div class="t">WebRTC IP policy</div> + <div class="d">Controls which IP addresses WebRTC may reveal — the same thing the "WebRTC Network Limiter" extension does, built in. <b>Public interface only</b> hides your local IP; <b>Disable non-proxied UDP</b> is strongest. Tor forces the strongest automatically.</div></div> + <div class="ctl"><select id="webrtcMode"> + <option value="default">Allow all (default)</option> + <option value="public_only">Public interface only</option> + <option value="public_private">Public + private interfaces</option> + <option value="disable_udp">Disable non-proxied UDP</option> + </select></div> + </div> + <div class="row"> + <div class="txt"><div class="t">Block camera</div><div class="d">Deny camera by default — also hides its name from fingerprinting.</div></div> + <label class="sw"><input type="checkbox" id="blockCamera"><span class="track"><span class="knob"></span></span></label> + </div> + <div class="row"> + <div class="txt"><div class="t">Block microphone</div><div class="d">Deny microphone by default — also hides its name from fingerprinting.</div></div> + <label class="sw"><input type="checkbox" id="blockMicrophone"><span class="track"><span class="knob"></span></span></label> + </div> + <div class="row"> + <div class="txt"><div class="t">Hide media devices</div><div class="d">Blank the labels and IDs of all cameras, microphones <b>and speakers</b> from <code>enumerateDevices()</code> — closes a WebRTC fingerprinting leak, like Firefox.</div></div> + <label class="sw"><input type="checkbox" id="hideMediaDevices"><span class="track"><span class="knob"></span></span></label> + </div> + + <h2 class="sub">Anti-fingerprinting</h2> + <p class="subd">For each: <b>Show</b> (real), <b>Hide</b> (neutral value), <b>Spoof</b> (a decoy), or <b>Manual</b> (set your own).</p> + <div class="row"> + <div class="txt"><div class="t">Timezone</div><div class="d">What sites read via JavaScript (Intl / Date).</div></div> + <div class="ctl"> + <select id="timezoneMode"><option value="show">Show real</option><option value="hide">Hide (UTC)</option><option value="spoof">Spoof (auto)</option><option value="manual">Manual…</option></select> + <!-- Native <select> instead of an <input list=""> datalist — the + datalist popup was flaky in Electron and never rendered on some + displays; a real select is unambiguous. --> + <select id="timezoneValue" hidden> + <option value="UTC">UTC</option> + <option value="Europe/London">Europe/London</option> + <option value="Europe/Berlin">Europe/Berlin</option> + <option value="Europe/Paris">Europe/Paris</option> + <option value="Europe/Madrid">Europe/Madrid</option> + <option value="Europe/Rome">Europe/Rome</option> + <option value="Europe/Moscow">Europe/Moscow</option> + <option value="America/New_York">America/New_York</option> + <option value="America/Chicago">America/Chicago</option> + <option value="America/Denver">America/Denver</option> + <option value="America/Los_Angeles">America/Los_Angeles</option> + <option value="America/Sao_Paulo">America/Sao_Paulo</option> + <option value="America/Mexico_City">America/Mexico_City</option> + <option value="America/Toronto">America/Toronto</option> + <option value="Asia/Tokyo">Asia/Tokyo</option> + <option value="Asia/Shanghai">Asia/Shanghai</option> + <option value="Asia/Seoul">Asia/Seoul</option> + <option value="Asia/Kolkata">Asia/Kolkata</option> + <option value="Asia/Dubai">Asia/Dubai</option> + <option value="Asia/Singapore">Asia/Singapore</option> + <option value="Asia/Bangkok">Asia/Bangkok</option> + <option value="Australia/Sydney">Australia/Sydney</option> + <option value="Australia/Perth">Australia/Perth</option> + <option value="Africa/Nairobi">Africa/Nairobi</option> + <option value="Africa/Cairo">Africa/Cairo</option> + <option value="Africa/Johannesburg">Africa/Johannesburg</option> + <option value="__other__">Other…</option> + </select> + <input id="timezoneValueOther" type="text" placeholder="IANA zone, e.g. America/Anchorage" hidden> + </div> + </div> + <div class="row"> + <div class="txt"><div class="t">Language</div><div class="d">navigator.language and the Accept-Language header. <b>Spoof</b> reports a major world language; <b>Manual</b> lets you type any locale.</div></div> + <div class="ctl"> + <select id="languageMode"><option value="show">Show real</option><option value="hide">Hide (en-US)</option><option value="spoof">Spoof (choose)</option><option value="manual">Manual…</option></select> + <select id="languageSpoof" hidden> + <option value="en-US">English</option> + <option value="zh-CN">Chinese (中文)</option> + <option value="es-ES">Spanish (Español)</option> + <option value="hi-IN">Hindi (हिन्दी)</option> + <option value="ar">Arabic (العربية)</option> + <option value="pt-BR">Portuguese (Português)</option> + <option value="ru-RU">Russian (Русский)</option> + <option value="fr-FR">French (Français)</option> + <option value="de-DE">German (Deutsch)</option> + <option value="ja-JP">Japanese (日本語)</option> + </select> + <!-- Native <select> (same reason as timezone above). --> + <select id="languageValue" hidden> + <option value="en-US">English (US) — en-US</option> + <option value="en-GB">English (UK) — en-GB</option> + <option value="zh-CN">Chinese (Simplified) — zh-CN</option> + <option value="zh-TW">Chinese (Traditional) — zh-TW</option> + <option value="es-ES">Spanish (Spain) — es-ES</option> + <option value="es-MX">Spanish (Mexico) — es-MX</option> + <option value="pt-BR">Portuguese (Brazil) — pt-BR</option> + <option value="pt-PT">Portuguese (Portugal) — pt-PT</option> + <option value="fr-FR">French — fr-FR</option> + <option value="de-DE">German — de-DE</option> + <option value="it-IT">Italian — it-IT</option> + <option value="nl-NL">Dutch — nl-NL</option> + <option value="ru-RU">Russian — ru-RU</option> + <option value="ja-JP">Japanese — ja-JP</option> + <option value="ko-KR">Korean — ko-KR</option> + <option value="hi-IN">Hindi — hi-IN</option> + <option value="ar">Arabic — ar</option> + <option value="tr-TR">Turkish — tr-TR</option> + <option value="pl-PL">Polish — pl-PL</option> + <option value="uk-UA">Ukrainian — uk-UA</option> + <option value="sv-SE">Swedish — sv-SE</option> + <option value="fi-FI">Finnish — fi-FI</option> + <option value="el-GR">Greek — el-GR</option> + <option value="he-IL">Hebrew — he-IL</option> + <option value="vi-VN">Vietnamese — vi-VN</option> + <option value="th-TH">Thai — th-TH</option> + <option value="id-ID">Indonesian — id-ID</option> + <option value="__other__">Other…</option> + </select> + <input id="languageValueOther" type="text" placeholder="BCP-47 tag, e.g. cs-CZ" hidden> + </div> + </div> + <div class="row"> + <div class="txt"><div class="t">Location</div><div class="d"><b>Hide</b> denies geolocation; <b>Spoof</b> reports a chosen region; <b>Manual</b> reports exact coordinates.</div></div> + <div class="ctl"> + <select id="locationMode"><option value="show">Show real</option><option value="hide">Hide (block)</option><option value="spoof">Spoof (region)</option><option value="manual">Manual…</option></select> + <select id="locationRegion" hidden> + <option value="europe">Europe</option> + <option value="asia">Asia</option> + <option value="north_america">North America</option> + <option value="south_america">South America</option> + <option value="africa">Africa</option> + <option value="middle_east">Middle East</option> + <option value="australia">Australia</option> + </select> + <!-- Manual location = pick a city from a native select; its lat/lon + are looked up client-side (see CITIES below) and written to + locationLat / locationLon settings, which drive the + navigator.geolocation override in main.js. The raw lat/lon + inputs used to sit here but nobody types coordinates by hand; + the city picker gives the same override with a single click. --> + <select id="locationCity" hidden> + <option value="">Pick a city…</option> + <option value="london">London</option> + <option value="berlin">Berlin</option> + <option value="paris">Paris</option> + <option value="madrid">Madrid</option> + <option value="rome">Rome</option> + <option value="moscow">Moscow</option> + <option value="istanbul">Istanbul</option> + <option value="dubai">Dubai</option> + <option value="mumbai">Mumbai</option> + <option value="singapore">Singapore</option> + <option value="bangkok">Bangkok</option> + <option value="shanghai">Shanghai</option> + <option value="tokyo">Tokyo</option> + <option value="seoul">Seoul</option> + <option value="sydney">Sydney</option> + <option value="new_york">New York</option> + <option value="los_angeles">Los Angeles</option> + <option value="chicago">Chicago</option> + <option value="toronto">Toronto</option> + <option value="mexico_city">Mexico City</option> + <option value="sao_paulo">São Paulo</option> + <option value="buenos_aires">Buenos Aires</option> + <option value="cairo">Cairo</option> + <option value="nairobi">Nairobi</option> + <option value="johannesburg">Johannesburg</option> + <option value="__other__">Other…</option> + </select> + <div id="locationOther" class="coords" hidden> + <input id="locationLatOther" type="number" step="0.0001" placeholder="lat" style="width:100px"> + <input id="locationLonOther" type="number" step="0.0001" placeholder="lon" style="width:100px"> + </div> + </div> + </div> + <div class="note">These reduce tracking and hide your IP, but a custom browser can still be fingerprinted. For maximum anonymity, use the Tor Browser.</div> + + <h2 class="sub">Storage</h2> + <p class="subd">By default Theseus keeps <b>nothing</b> across sessions — everything toggled on here is wiped when you quit. Untoggle a bucket to keep it (e.g. cookies to stay signed in on trusted sites).</p> + <div class="row"> + <div class="txt"><div class="t">Clear cookies on quit</div><div class="d">Drops session + persistent cookies. You'll sign in again next launch.</div></div> + <label class="sw"><input type="checkbox" id="clearCookiesOnQuit"><span class="track"><span class="knob"></span></span></label> + </div> + <div class="row"> + <div class="txt"><div class="t">Clear HTTP cache on quit</div><div class="d">Drops cached images / scripts / stylesheets. Sites re-download; small disk win.</div></div> + <label class="sw"><input type="checkbox" id="clearCacheOnQuit"><span class="track"><span class="knob"></span></span></label> + </div> + <div class="row"> + <div class="txt"><div class="t">Clear site storage on quit</div><div class="d">Drops localStorage, IndexedDB, service workers, and the cache API. Web-app state resets.</div></div> + <label class="sw"><input type="checkbox" id="clearStorageOnQuit"><span class="track"><span class="knob"></span></span></label> + </div> + <div class="row"> + <div class="txt"><div class="t">Clear history on quit</div><div class="d">Drops navigation history + the saved-tabs session file (overrides "Reopen previous tabs").</div></div> + <label class="sw"><input type="checkbox" id="clearHistoryOnQuit"><span class="track"><span class="knob"></span></span></label> + </div> + <div class="row" style="justify-content:flex-end"> + <button id="clearNow" class="btn" type="button">Clear all now</button> + </div> + <div class="note">There is no persistent password manager — passwords are never stored to disk regardless of these toggles. Use a dedicated password manager (Bitwarden, KeePass, etc.).</div> + </section> + + <!-- ADD-ONS --> + <section id="addons" hidden> + <h1>Extensions</h1> + <p class="lede">Small modules that add capabilities to Theseus. Extensions live as folders under + <code style="background:transparent;border:none;padding:0" id="addonsPathHint">%APPDATA%\Theseus Navigator\addons\</code>. Drop a folder in, restart, it's live. + Bundled reference extensions (like the Notepad) are copied there on first run — you can edit or remove them + without losing anything the browser needs.</p> + <div class="row" style="justify-content:flex-end;gap:8px"> + <button id="addonsCheckUpdates" class="btn" type="button">Check for updates</button> + <button id="addonsReload" class="btn" type="button">Reload</button> + <button id="addonsOpenDir" class="btn" type="button">Open extensions folder</button> + </div> + <div id="addonsUpdStatus" class="pmuted" style="font-size:12.5px;margin-top:6px;text-align:right">—</div> + <h2 class="sub" style="border-top:0;padding-top:0;margin-top:1.5rem">Installed</h2> + <div id="addonsList"><div class="d" style="color:var(--dim)">Loading…</div></div> + <div class="note">Extensions run with full app access — treat installing one like installing an unsigned executable. + Only load extensions whose source you trust.</div> + </section> + </div> +</div> +<script> + const C = window.cfg; + // sidebar navigation + // "naming" is intentionally absent: the Registries section was folded + // into General in 0.3.21. Leaving the id in this list threw a + // TypeError inside showSection (getElementById("naming") → null), + // which broke every click after General/Search/Passwords. + const sections = ["general", "search", "passwords", "performance", "privacy", "plugins", "addons"]; + function showSection(sec) { + if (!sections.includes(sec)) return; + document.querySelectorAll(".side a").forEach((x) => x.classList.toggle("active", x.dataset.sec === sec)); + for (const s of sections) document.getElementById(s).hidden = (s !== sec); + } + document.querySelectorAll(".side a").forEach((a) => a.onclick = () => showSection(a.dataset.sec)); + // Main-process asks us to jump to a section (e.g. picker's "Search settings…" + // click routes to the Search section instead of the General default). + if (C && C.onFocusSection) C.onFocusSection((sec) => showSection(sec)); + // Land on the section named by `settings.html#<sec>` when opened via main + // (e.g. Aegis's "Set up vault" button → open-settings with "passwords"). + try { + const initSec = String(location.hash || "").replace(/^#/, "").toLowerCase(); + if (initSec) showSection(initSec); + } catch {} + + const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices", + "clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit"]; + C.get().then((s) => { + for (const k of TOGGLES) { + const el = document.getElementById(k); if (!el) continue; + el.checked = !!s[k]; + el.addEventListener("change", () => C.set(k, el.checked)); + } + // search engines: default picker + custom-engine list + add form + const sel = document.getElementById("searchEngine"); + const esc = (s) => String(s || "").replace(/</g, "&lt;"); + // Try the primary favicon URL (Google's /s2/favicons — set in main.js); + // fall back to DuckDuckGo's icons.duckduckgo.com service if that 404s, + // then to the emoji sym as final fallback so the row is never blank. + const engIcon = (e) => { + let alt = ""; + try { + const u = new URL(e.favicon || "https://x"); + const domain = u.searchParams.get("domain") || u.hostname; + if (domain && domain !== "x") alt = `https://icons.duckduckgo.com/ip3/${domain}.ico`; + } catch {} + const sym = (e.sym || "🔍").replace(/'/g, "&#39;"); + const fallbackChain = alt + ? `this.onerror=null;this.src='${alt}';this.setAttribute('data-tried','1');this.onerror=function(){this.replaceWith(Object.assign(document.createElement('span'),{className:'es',textContent:'${sym}'}))};` + : `this.replaceWith(Object.assign(document.createElement('span'),{className:'es',textContent:'${sym}'}))`; + return e.favicon + ? `<img class="ei" src="${esc(e.favicon)}" onerror="${fallbackChain}">` + : `<span class="es">${sym}</span>`; + }; + const ENGINE_KINDS = [ + { key: "search", label: "Search engines" }, + { key: "llm", label: "AI answer engines" }, + ]; + function renderEngines(d) { + // default engine: pick from the ENABLED engines (those shown in the dropdown) + const enabled = d.engines.filter((e) => e.enabled); + // group the dropdown too, so search and LLM don't intermix + sel.innerHTML = ENGINE_KINDS.map(({ key, label }) => { + const opts = enabled.filter((e) => (e.kind || "search") === key) + .map((e) => `<option value="${e.id}">${(e.sym ? e.sym + " " : "")}${esc(e.name)}</option>`).join(""); + return opts ? `<optgroup label="${label}">${opts}</optgroup>` : ""; + }).join(""); + sel.value = d.current; + // Main list = engines the user has INSTALLED. The toggle only flips + // enabled/disabled — the row STAYS. Right-click a row → "Remove from + // list" is what actually removes an engine (back to the catalog for + // built-ins, permanently for customs). + const list = document.getElementById("engineList"); + const installed = d.engines.filter((e) => e.installed); + const rowFor = (e) => `<div class="eng${e.enabled ? "" : " off"}" data-id="${e.id}" data-kind="${e.kind || "search"}" data-builtin="${e.builtin ? 1 : 0}" draggable="true">` + + `<span class="grip" title="Drag to reorder">⠿</span>` + + `<span class="eic">${engIcon(e)}</span><span class="enm">${esc(e.name)}</span>` + + `<label class="sw sm" title="${e.enabled ? "Turn off" : "Turn on"}"><input type="checkbox" data-id="${e.id}" ${e.enabled ? "checked" : ""}><span class="track"><span class="knob"></span></span></label>` + + `</div>`; + list.innerHTML = ENGINE_KINDS.map(({ key, label }) => { + const rows = installed.filter((e) => (e.kind || "search") === key).map(rowFor).join(""); + if (!rows) return ""; + return `<div class="ehdr">${label}</div>${rows}`; + }).join(""); + // Two catalog panes, split by tier — filtered by !installed now, not + // !enabled (a toggled-off engine stays in the enabled list, not here): + // catalog — curated first-class built-ins the user hasn't installed + // extra — wider bank, filtered live by the "Discover more" search box + const cat = document.getElementById("catalogList"); + const extra = document.getElementById("extraList"); + const filterInput = document.getElementById("engineFilter"); + const catRow = (e) => `<div class="cat" data-id="${e.id}">` + + `<span class="eic">${engIcon(e)}</span>` + + `<span class="enm">${esc(e.name)}</span>` + + `<span class="kind">${(e.kind || "search") === "llm" ? "AI" : "Search"}</span>` + + `<button class="add" data-add="${e.id}">+ Add</button></div>`; + const uninstalled = d.engines.filter((e) => e.builtin && !e.installed); + const catalogOff = uninstalled.filter((e) => (e.tier || "catalog") === "catalog"); + const extraOff = uninstalled.filter((e) => e.tier === "extra"); + if (cat) { + cat.innerHTML = catalogOff.length + ? catalogOff.map(catRow).join("") + : `<div class="cempty2">All curated engines are already in your list. Discover more below or add a custom URL.</div>`; + cat.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines)); + } + if (extra) { + const paintExtras = (q) => { + const filt = String(q || "").trim().toLowerCase(); + const shown = filt ? extraOff.filter((e) => e.name.toLowerCase().includes(filt)) : extraOff; + extra.innerHTML = shown.length + ? shown.map(catRow).join("") + : `<div class="cempty2">${filt ? "No engines match that filter." : "All discoverable engines are already in your list."}</div>`; + extra.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines)); + }; + paintExtras(filterInput ? filterInput.value : ""); + if (filterInput && !filterInput.dataset.wired) { + filterInput.dataset.wired = "1"; + filterInput.addEventListener("input", () => paintExtras(filterInput.value)); + } + } + // Toggle: pure on/off in the enabled set — the row stays visible either way. + list.querySelectorAll('input[type="checkbox"]').forEach((cb) => cb.onchange = () => C.setEngineEnabled(cb.dataset.id, cb.checked).then(renderEngines)); + // Right-click any row → context menu with "Remove from list" (moves a + // built-in back to the catalog; deletes a custom entirely). + list.querySelectorAll(".eng").forEach((row) => { + row.addEventListener("contextmenu", (e) => { + e.preventDefault(); + openEngineMenu(row, e.clientX, e.clientY); + }); + }); + // drag-and-drop reorder — same-kind only (dropping a Search engine into + // the LLM section would just re-group visually on next render, so we + // reject cross-kind drags outright). + let dragId = null; + let dragKind = null; + const sameKind = (row) => row.dataset.kind === dragKind; + list.querySelectorAll(".eng").forEach((row) => { + row.addEventListener("dragstart", (e) => { dragId = row.dataset.id; dragKind = row.dataset.kind; e.dataTransfer.effectAllowed = "move"; row.classList.add("dragging"); }); + row.addEventListener("dragend", () => { row.classList.remove("dragging"); list.querySelectorAll(".eng").forEach((r) => r.classList.remove("over")); }); + row.addEventListener("dragover", (e) => { + if (!sameKind(row)) { e.dataTransfer.dropEffect = "none"; return; } + e.preventDefault(); e.dataTransfer.dropEffect = "move"; + if (row.dataset.id !== dragId) row.classList.add("over"); + }); + row.addEventListener("dragleave", () => row.classList.remove("over")); + row.addEventListener("drop", (e) => { + e.preventDefault(); row.classList.remove("over"); + if (!dragId || dragId === row.dataset.id || !sameKind(row)) return; + const ids = [...list.querySelectorAll(".eng")].map((el) => el.dataset.id); + const from = ids.indexOf(dragId), to = ids.indexOf(row.dataset.id); + ids.splice(from, 1); ids.splice(to, 0, dragId); + C.setEngineOrder(ids).then(renderEngines); + }); + }); + } + // Floating right-click menu for an engine row. Only one open at a time. + let ctxOpen = null; + function closeEngineMenu() { if (ctxOpen) { ctxOpen.remove(); ctxOpen = null; } } + function openEngineMenu(row, x, y) { + closeEngineMenu(); + const id = row.dataset.id; + const builtin = row.dataset.builtin === "1"; + const m = document.createElement("div"); + m.className = "ctxmenu"; + m.innerHTML = `<div class="mi danger" data-act="remove">Remove from list</div>`; + document.body.appendChild(m); + // Position, keeping the menu inside the viewport. + const rect = m.getBoundingClientRect(); + const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight; + m.style.left = Math.min(x, vw - rect.width - 6) + "px"; + m.style.top = Math.min(y, vh - rect.height - 6) + "px"; + m.querySelector('[data-act="remove"]').onclick = () => { + closeEngineMenu(); + const call = builtin ? C.removeFromList(id) : C.removeEngine(id); + call.then(renderEngines); + }; + ctxOpen = m; + setTimeout(() => { + const off = (ev) => { if (!m.contains(ev.target)) { closeEngineMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } }; + const esc = (ev) => { if (ev.key === "Escape") { closeEngineMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } }; + document.addEventListener("mousedown", off); + document.addEventListener("keydown", esc); + }, 0); + } + sel.onchange = () => C.set("searchEngine", sel.value); + // appearance (theme) — three visual cards: system | light | dark. Any + // unrecognised saved value falls back to "system" (follow the OS). + const th = document.getElementById("theme"); + let themeValue = ["system", "light", "dark"].includes(s.theme) ? s.theme : "system"; + const paintTheme = () => th.querySelectorAll(".tc").forEach((b) => { + const on = b.dataset.val === themeValue; + b.classList.toggle("on", on); + b.setAttribute("aria-checked", on ? "true" : "false"); + }); + paintTheme(); + th.querySelectorAll(".tc").forEach((b) => b.onclick = () => { + themeValue = b.dataset.val; paintTheme(); C.set("theme", themeValue); + }); + // WebRTC IP policy + const wm = document.getElementById("webrtcMode"); + wm.value = s.webrtcMode || "public_only"; + wm.onchange = () => C.set("webrtcMode", wm.value); + // Toolbar sizing dropdowns removed — the toolbar drag-handles do the + // same job in-place. The stored urlBarSize / searchBoxSize + the drag- + // set widthPx settings still exist in main; they just have no UI here. + document.getElementById("engAdd").onclick = () => { + const name = document.getElementById("engName").value.trim(); + const url = document.getElementById("engUrl").value.trim(); + const sym = document.getElementById("engSym").value.trim(); + if (!name || !url.includes("%s")) { alert("Enter a name and a URL containing %s (where the query goes)."); return; } + C.addEngine({ name, url, sym }).then((d) => { + document.getElementById("engName").value = ""; document.getElementById("engUrl").value = ""; document.getElementById("engSym").value = ""; + renderEngines(d); + }); + }; + // "+ Add search engine" toggles the catalog panel below the enabled list. + const catBtn = document.getElementById("engAddBtn"); + const catBox = document.getElementById("engineCatalog"); + if (catBtn && catBox) { + catBtn.onclick = () => { + const open = catBox.hidden; + catBox.hidden = !open; + catBtn.textContent = open ? "− Hide catalog" : "+ Add search engine"; + }; + } + C.engines().then(renderEngines); + // anti-fingerprinting mode selectors, with value field(s) shown on "manual" + const bind = (mode, showValIf, apply) => { + const m = document.getElementById(mode); + m.value = s[mode] || "show"; + apply(m.value === "manual"); + m.addEventListener("change", () => { C.set(mode, m.value); apply(m.value === "manual"); }); + }; + // Value fields: <select> for tz + lang manual mode, <select> city for + // location manual mode. Each has an "Other…" sentinel at the end that + // reveals a text input so the user can enter a value not in the built-in + // list (any IANA zone, any BCP-47 locale). selectWithOther handles the + // round-trip: if a saved value isn't in the predefined options, "Other" + // is auto-selected on load and the input pre-fills with that value. + function selectWithOther(selectId, otherInputId, settingsKey = selectId) { + const sel = document.getElementById(selectId); + const inp = document.getElementById(otherInputId); + const saved = s[settingsKey] ?? ""; + const known = new Set([...sel.options].map((o) => o.value).filter((v) => v && v !== "__other__")); + const isCustom = saved && !known.has(saved); + sel.value = isCustom ? "__other__" : saved; + if (isCustom) inp.value = saved; + const applyVis = () => { inp.hidden = sel.value !== "__other__"; }; + applyVis(); + sel.addEventListener("change", () => { + if (sel.value === "__other__") { applyVis(); setTimeout(() => inp.focus(), 0); return; } + C.set(settingsKey, sel.value); applyVis(); + }); + inp.addEventListener("change", () => { const v = String(inp.value).trim(); if (v) C.set(settingsKey, v); }); + return sel; + } + const tzV = selectWithOther("timezoneValue", "timezoneValueOther"); + const lgV = selectWithOther("languageValue", "languageValueOther"); + const tzOther = document.getElementById("timezoneValueOther"); + const lgOther = document.getElementById("languageValueOther"); + // "Manual" mode reveals both the select AND the Other input (if Other was picked). + const applyTzVis = (manual) => { tzV.hidden = !manual; tzOther.hidden = !manual || tzV.value !== "__other__"; }; + bind("timezoneMode", null, applyTzVis); + // language: fixed dropdown when spoofing (top 10), broader dropdown when manual + const lngMode = document.getElementById("languageMode"); + const lngSpoof = document.getElementById("languageSpoof"); + lngSpoof.value = s.languageSpoof || "en-US"; + lngSpoof.addEventListener("change", () => C.set("languageSpoof", lngSpoof.value)); + const applyLng = () => { + lngSpoof.hidden = lngMode.value !== "spoof"; + lgV.hidden = lngMode.value !== "manual"; + lgOther.hidden = lngMode.value !== "manual" || lgV.value !== "__other__"; + }; + lngMode.value = s.languageMode || "show"; + applyLng(); + lngMode.addEventListener("change", () => { C.set("languageMode", lngMode.value); applyLng(); }); + // location: region dropdown when spoofing, city dropdown when manual. + // Manual writes lat/lon into settings via the CITIES map below; the raw + // coord inputs were dropped from the UI (nobody types them by hand). + const locMode = document.getElementById("locationMode"); + const locRegion = document.getElementById("locationRegion"); + const locCity = document.getElementById("locationCity"); + locRegion.value = s.locationRegion || "europe"; + locRegion.addEventListener("change", () => C.set("locationRegion", locRegion.value)); + // Location manual reveals the city select; picking "Other…" reveals a + // lat/lon pair, whose values are pushed to locationLat/Lon settings. + const locOther = document.getElementById("locationOther"); + const locLatOther = document.getElementById("locationLatOther"); + const locLonOther = document.getElementById("locationLonOther"); + const applyLoc = () => { + locRegion.hidden = locMode.value !== "spoof"; + locCity.hidden = locMode.value !== "manual"; + locOther.hidden = locMode.value !== "manual" || locCity.value !== "__other__"; + }; + locMode.value = s.locationMode || "show"; + applyLoc(); + locMode.addEventListener("change", () => { C.set("locationMode", locMode.value); applyLoc(); }); + locCity.addEventListener("change", applyLoc); + // Prefill Other lat/lon inputs from saved settings. + locLatOther.value = s.locationLat ?? ""; + locLonOther.value = s.locationLon ?? ""; + const saveOtherCoords = () => { + const la = String(locLatOther.value).trim(); + const lo = String(locLonOther.value).trim(); + if (la !== "") C.set("locationLat", la); + if (lo !== "") C.set("locationLon", lo); + C.set("locationCity", "__other__"); + }; + locLatOther.addEventListener("change", saveOtherCoords); + locLonOther.addEventListener("change", saveOtherCoords); + // If the previously-saved city was "__other__", set the select to it so the + // Other inputs stay visible on reload. + if (s.locationCity === "__other__") locCity.value = "__other__"; + // Picking a city writes its lat/lon to settings — that's what + // navigator.geolocation returns to pages once "manual" mode is active. + const CITIES = { + london:[51.5074,-0.1278], berlin:[52.52,13.405], paris:[48.8566,2.3522], + madrid:[40.4168,-3.7038], rome:[41.9028,12.4964], moscow:[55.7558,37.6173], + istanbul:[41.0082,28.9784], dubai:[25.2048,55.2708], mumbai:[19.076,72.8777], + singapore:[1.3521,103.8198], bangkok:[13.7563,100.5018], shanghai:[31.2304,121.4737], + tokyo:[35.6762,139.6503], seoul:[37.5665,126.978], sydney:[-33.8688,151.2093], + new_york:[40.7128,-74.006], los_angeles:[34.0522,-118.2437], chicago:[41.8781,-87.6298], + toronto:[43.6532,-79.3832], mexico_city:[19.4326,-99.1332], sao_paulo:[-23.5505,-46.6333], + buenos_aires:[-34.6037,-58.3816], cairo:[30.0444,31.2357], nairobi:[-1.2921,36.8219], + johannesburg:[-26.2041,28.0473], + }; + // Restore prior city selection when possible by matching stored lat/lon. + const restoreCity = () => { + const la = Number(s.locationLat), lo = Number(s.locationLon); + for (const [key, [x, y]] of Object.entries(CITIES)) + if (Math.abs(x - la) < 0.01 && Math.abs(y - lo) < 0.01) { locCity.value = key; return; } + }; + restoreCity(); + locCity.addEventListener("change", () => { + const c = CITIES[locCity.value]; if (!c) return; + C.set("locationLat", String(c[0])); + C.set("locationLon", String(c[1])); + C.set("locationCity", locCity.value); // save the pick so we can restore it later + }); + + // Storage: "Clear all now" wipes everything the toggles cover, without + // waiting for quit. Confirm first — this signs the user out of everything. + const clrBtn = document.getElementById("clearNow"); + if (clrBtn) clrBtn.onclick = async () => { + if (!confirm("Clear cookies, cache, site storage, and history now?\n\nYou'll be signed out of everything and open tabs won't be restored.")) return; + clrBtn.disabled = true; clrBtn.textContent = "Clearing…"; + try { + await C.clearBrowsingData({ cookies: true, cache: true, storage: true, history: true }); + clrBtn.textContent = "Cleared ✓"; + } catch (e) { clrBtn.textContent = "Clear failed"; console.error(e); } + setTimeout(() => { clrBtn.textContent = "Clear all now"; clrBtn.disabled = false; }, 1600); + }; + + // ---- Naming section: BCNR/ICANN collision policy + remembered choices ---- + function refreshCollisions() { + C.collisionState().then((cs) => { + // pick the current radio + document.querySelectorAll('input[name="collisionPolicy"]').forEach((r) => { r.checked = (r.value === cs.policy); }); + const nn = Object.keys(cs.byName || {}).length, tn = Object.keys(cs.byTld || {}).length; + const bc = (cs.bcnrTlds || []).length; + document.getElementById("colSummary").textContent = + `Remembered: ${nn} name${nn === 1 ? "" : "s"}, ${tn} TLD${tn === 1 ? "" : "s"}. ` + + `BCNR-native TLDs on chain: ${bc}.`; + }).catch(() => {}); + } + refreshCollisions(); + document.querySelectorAll('input[name="collisionPolicy"]').forEach((r) => { + r.addEventListener("change", () => { if (r.checked) C.setCollisionPolicy(r.value).then(refreshCollisions); }); + }); + document.getElementById("resetCollisions").onclick = () => { + C.resetCollisions().then(refreshCollisions); + }; + + // ---- Developer tools dock position -------------------------------------- + // The active radio reflects the current setting; changing it just calls + // C.set — the F12 handler in main.js reads settings.devToolsDock each + // time DevTools opens, so no restart is needed for the choice to apply. + const devToolsDock = s.devToolsDock || "bottom"; + document.querySelectorAll('input[name="devToolsDock"]').forEach((r) => { + r.checked = (r.value === devToolsDock); + r.addEventListener("change", () => { if (r.checked) C.set("devToolsDock", r.value); }); + }); + + // ---- Ariadne's Thread system-wide resolver toggle ----------------------- + // Ariadne runs as two elevated Scheduled Tasks ("BNS Resolver Daemon" + + // "BNS Sia Bridge"). Toggling requires admin — main spawns an elevated + // powershell for start/stop, which prompts UAC once per action. + const arStatus = document.getElementById("ariadneStatus"); + const arStart = document.getElementById("ariadneStart"); + const arStop = document.getElementById("ariadneStop"); + const arInstall = document.getElementById("ariadneInstall"); + const arUpdate = document.getElementById("ariadneUpdate"); + const arUninstall = document.getElementById("ariadneUninstall"); + const arRefresh = document.getElementById("ariadneRefresh"); + const arMissing = document.getElementById("ariadneMissing"); + async function refreshAriadne() { + arStatus.textContent = "checking…"; + arStart.hidden = arStop.hidden = arInstall.hidden = arUpdate.hidden = arUninstall.hidden = arMissing.hidden = true; + try { + const r = await C.ariadneState(); + const verSuffix = r.installedVersion ? ` (v${r.installedVersion})` : ""; + const bundledSuffix = r.bundledVersion ? ` (bundled v${r.bundledVersion})` : ""; + if (r.state === "running") { + arStatus.innerHTML = `Status: <b style="color:var(--acid-text)">running</b>${verSuffix} — every browser on this machine resolves BCDN names.`; + arStop.hidden = false; arUninstall.hidden = !r.hasUninstaller; + if (r.canUpdate) arUpdate.hidden = false; + } else if (r.state === "stopped") { + arStatus.innerHTML = `Status: <b>stopped</b>${verSuffix} — only Theseus resolves BCDN names; other browsers won't.`; + arStart.hidden = false; arUninstall.hidden = !r.hasUninstaller; + if (r.canUpdate) arUpdate.hidden = false; + } else { + arStatus.innerHTML = `Status: <b>not installed</b> on this machine.${bundledSuffix}`; + arInstall.hidden = false; + arMissing.hidden = false; + } + } catch (e) { arStatus.textContent = "Status check failed: " + (e?.message || e); } + } + // Wrap each async action so the button disables during work + always + // refreshes state after (success OR failure), so the UI never lies. + function wire(btn, action, busyLabel) { + btn.onclick = async () => { + const orig = btn.textContent; + btn.disabled = true; btn.textContent = busyLabel; + try { await action(); } catch {} + finally { btn.disabled = false; btn.textContent = orig; refreshAriadne(); } + }; + } + wire(arStart, () => C.ariadneToggle(true), "Starting…"); + wire(arStop, () => C.ariadneToggle(false), "Stopping…"); + wire(arInstall, () => C.ariadneInstall(), "Installing… (accept the UAC prompt)"); + wire(arUpdate, () => C.ariadneUpdate(), "Updating… (accept the UAC prompt)"); + wire(arUninstall, () => C.ariadneUninstall(), "Uninstalling… (accept the UAC prompt)"); + arRefresh.onclick = refreshAriadne; + refreshAriadne(); + + // ---- Aegis update card -------------------------------------------------- + // Aegis is a bundled add-on but its updates are shipped OTA via the signed + // add-on update endpoint, so users can get new wallet versions without + // updating Theseus. The same addons-check-updates IPC the Extensions page + // uses is filtered for the aegis id so this card only speaks for Aegis. + // Legacy id "bchwallet" is also matched for upgrades from pre-rename + // installs (0.3.x); post-migration the entry lives at id="aegis". + const aegisStatus = document.getElementById("aegisStatus"); + const aegisCheck = document.getElementById("aegisCheck"); + const aegisApply = document.getElementById("aegisApply"); + const isAegis = (a) => a && (a.id === "aegis" || a.id === "bchwallet"); + // addons-list returns {installed:[...], sidebarPanels:[...]} + // addons-check-updates returns {report, skipped, staged:[...]} + // addons-list-staged returns a flat [...]. Normalise both call sites. + async function refreshAegis() { + aegisApply.hidden = true; + try { + const [listRes, stagedRes] = await Promise.all([ + C.listAddons ? C.listAddons() : Promise.resolve({}), + C.listStagedAddonUpdates ? C.listStagedAddonUpdates() : Promise.resolve([]), + ]); + const installed = (listRes && listRes.installed) || []; + const staged = Array.isArray(stagedRes) ? stagedRes : []; + const cur = installed.find(isAegis); + const upd = staged.find(isAegis); + if (!cur) { aegisStatus.textContent = "Aegis isn't installed. Reinstall Theseus to add it back, or open Settings > Extensions to load it manually."; return; } + const curVer = cur.version || "?"; + if (upd && upd.version) { + aegisStatus.innerHTML = `<b style="color:var(--acid-text)">v${upd.version}</b> staged — restart Theseus to switch to it. You're on v${curVer}.`; + aegisApply.hidden = false; + } else { + aegisStatus.textContent = `You're on v${curVer}. Updates arrive over-the-air; the next check runs at boot and every 6h.`; + } + } catch (e) { aegisStatus.textContent = "Status check failed: " + (e?.message || e); } + } + aegisCheck.onclick = async () => { + const orig = aegisCheck.textContent; + aegisCheck.disabled = true; aegisCheck.textContent = "Checking…"; + aegisStatus.textContent = "Checking…"; + try { + const res = await (C.checkAddonUpdates ? C.checkAddonUpdates() : Promise.resolve({})); + const staged = (res && Array.isArray(res.staged)) ? res.staged : []; + const upd = staged.find(isAegis); + if (!upd) { aegisStatus.textContent = "You're on the latest Aegis."; } + // refresh will populate everything else + toggle the Apply button + refreshAegis(); + } catch (e) { aegisStatus.textContent = "Check failed: " + (e?.message || e); } + finally { aegisCheck.disabled = false; aegisCheck.textContent = orig; } + }; + aegisApply.onclick = () => { if (C && C.restartApp) C.restartApp(); }; + refreshAegis(); + + // ---- Manual "Check for updates" ----------------------------------------- + // Fires an IPC that un-dismisses any lingering session chip and hits the + // release manifest. Renders either "you're on the latest (vN.M.O)" or + // "vX.Y.Z is available — the update chip in the toolbar will offer it". + const updStatus = document.getElementById("updStatus"); + // Show the current version by default so the user doesn't have to + // click "Check for updates" just to find out which Theseus they're on. + if (C && C.appVersion) C.appVersion().then((v) => { + if (v) updStatus.textContent = `You're on v${v}.`; + }).catch(() => {}); + const updCheck = document.getElementById("updCheck"); + updCheck.onclick = async () => { + const orig = updCheck.textContent; + updCheck.disabled = true; updCheck.textContent = "Checking…"; + updStatus.textContent = "Checking…"; + try { + const r = await C.recheckUpdate(); + if (r && r.updateAvailable && r.updateAvailable.version) { + updStatus.innerHTML = `<b style="color:var(--acid-text)">v${r.updateAvailable.version}</b> is available — the update chip in the toolbar will offer it.`; + } else { + updStatus.textContent = `You're on the latest (v${(r && r.currentVersion) || "?"}).`; + } + } catch (e) { updStatus.textContent = "Check failed: " + (e?.message || e); } + finally { updCheck.disabled = false; updCheck.textContent = orig; } + }; + + // ---- Passwords section: three states (setup / locked / unlocked) --------- + // The vault lives in main.js — this UI just calls IPC. No plaintext ever + // sits in this DOM except the value produced by a specific Show/Copy click. + const pwSetupEl = document.getElementById("pwSetup"); + const pwLockedEl = document.getElementById("pwLocked"); + const pwUnlockedEl = document.getElementById("pwUnlocked"); + const pwListEl = document.getElementById("pwList"); + function pwShow(which) { + pwSetupEl.hidden = which !== "setup"; + pwLockedEl.hidden = which !== "locked"; + pwUnlockedEl.hidden = which !== "unlocked"; + } + async function pwRefresh() { + const st = await C.pwStatus(); + if (!st.setup) return pwShow("setup"); + if (!st.unlocked) return pwShow("locked"); + pwShow("unlocked"); + const res = await C.pwList(); + renderPwList(res.ok ? res.entries : []); + } + function renderPwList(entries) { + if (!entries.length) { + pwListEl.innerHTML = `<div class="cempty" style="padding:12px 0">No entries yet — add one below.</div>`; + return; + } + pwListEl.innerHTML = entries.map((e) => `<div class="eng" data-id="${esc(e.id)}">` + + `<span class="eic"><img class="ei" src="https://icons.duckduckgo.com/ip3/${esc(e.domain)}.ico" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'es',textContent:'🔑'}))"></span>` + + `<span class="enm"><b>${esc(e.domain)}</b> <span class="pmuted">· ${esc(e.username || "—")}</span> <span class="pmuted" style="font-size:11px">· ${e.kind === "generated" ? "generated" : "pasted"}</span></span>` + + `<button class="cx pwShow" title="Show + copy">👁</button>` + + `<button class="cx pwDel" title="Remove">✕</button>` + + `</div>`).join(""); + pwListEl.querySelectorAll(".pwShow").forEach((b) => b.onclick = async (ev) => { + const id = ev.target.closest(".eng").dataset.id; + const res = await C.pwGet(id); + if (!res.ok) return alert("Couldn't read: " + res.err); + try { await navigator.clipboard.writeText(res.password); } + catch { /* browser may block clipboard in dev — fall through to a prompt */ prompt("Password (copy manually):", res.password); return; } + b.textContent = "copied ✓"; setTimeout(() => (b.textContent = "👁"), 1600); + }); + pwListEl.querySelectorAll(".pwDel").forEach((b) => b.onclick = async (ev) => { + const id = ev.target.closest(".eng").dataset.id; + if (!confirm("Remove this entry?")) return; + await C.pwRemove(id); pwRefresh(); + }); + } + // Setup — create vault + document.querySelectorAll('input[name="pwSeedSource"]').forEach((r) => r.addEventListener("change", () => { + document.getElementById("pwSetupMnemonic").hidden = document.querySelector('input[name="pwSeedSource"]:checked').value !== "mnemonic"; + })); + document.getElementById("pwSetupBtn").onclick = async () => { + const p1 = document.getElementById("pwSetupPw1").value; + const p2 = document.getElementById("pwSetupPw2").value; + if (!p1 || p1.length < 8) return alert("Master password must be at least 8 characters."); + if (p1 !== p2) return alert("Passwords don't match."); + const kind = document.querySelector('input[name="pwSeedSource"]:checked').value; + const seedSource = kind === "mnemonic" + ? { kind: "mnemonic", mnemonic: document.getElementById("pwSetupMnemonic").value } + : { kind: "generate" }; + if (kind === "mnemonic" && !seedSource.mnemonic.trim()) return alert("Paste your mnemonic or switch to 'Generate a new independent seed'."); + const res = await C.pwSetup(p1, seedSource); + if (!res.ok) return alert("Setup failed: " + res.err); + // Vault created AND unlocked by main. Clear the setup fields. + document.getElementById("pwSetupPw1").value = ""; + document.getElementById("pwSetupPw2").value = ""; + document.getElementById("pwSetupMnemonic").value = ""; + pwRefresh(); + }; + // Unlock + document.getElementById("pwUnlockBtn").onclick = async () => { + const err = document.getElementById("pwUnlockErr"); + err.hidden = true; + const pw = document.getElementById("pwUnlockPw").value; + const res = await C.pwUnlock(pw); + if (!res.ok) { err.textContent = res.err; err.hidden = false; return; } + document.getElementById("pwUnlockPw").value = ""; + pwRefresh(); + }; + document.getElementById("pwUnlockPw").addEventListener("keydown", (e) => { if (e.key === "Enter") document.getElementById("pwUnlockBtn").click(); }); + // Lock + document.getElementById("pwLockBtn").onclick = async () => { await C.pwLock(); pwRefresh(); }; + // Add-entry form: toggle literal input; wire preview + save + document.querySelectorAll('input[name="pwAddKind"]').forEach((r) => r.addEventListener("change", () => { + const kind = document.querySelector('input[name="pwAddKind"]:checked').value; + document.getElementById("pwAddLiteral").hidden = kind !== "literal"; + document.getElementById("pwAddPreview").hidden = kind !== "generated"; + document.getElementById("pwAddPreviewOut").hidden = kind !== "generated"; + })); + document.getElementById("pwAddPreview").onclick = async () => { + const domain = document.getElementById("pwAddDomain").value.trim(); + const username = document.getElementById("pwAddUser").value.trim(); + if (!domain) return alert("Enter a site."); + const res = await C.pwGenerate({ domain, username }); + if (!res.ok) return alert("Preview failed: " + res.err); + document.getElementById("pwAddPreviewOut").value = res.password; + }; + document.getElementById("pwAddBtn").onclick = async () => { + const domain = document.getElementById("pwAddDomain").value.trim(); + const username = document.getElementById("pwAddUser").value.trim(); + if (!domain) return alert("Enter a site."); + const kind = document.querySelector('input[name="pwAddKind"]:checked').value; + const spec = { domain, username }; + if (kind === "literal") { + const lit = document.getElementById("pwAddLiteral").value; + if (!lit) return alert("Paste the password to save."); + spec.literal = lit; + } + const res = await C.pwAdd(spec); + if (!res.ok) return alert("Add failed: " + res.err); + document.getElementById("pwAddDomain").value = ""; + document.getElementById("pwAddUser").value = ""; + document.getElementById("pwAddLiteral").value = ""; + document.getElementById("pwAddPreviewOut").value = ""; + pwRefresh(); + }; + // Initial state — decide which panel to show now, and every time the user + // switches to Passwords in the sidebar (so a lock elsewhere is reflected). + pwRefresh(); + document.querySelector('.side a[data-sec="passwords"]').addEventListener("click", pwRefresh); + }); + + // ---- Add-ons management ---- + const addonsList = document.getElementById("addonsList"); + // Per-addon update state, keyed by addon id. Populated by loadAddonUpdates() + // (staged: from listStagedAddonUpdates, background-polled) and by the + // manual "Check for updates" button (report: fresh per-addon status). + // renderAddons reads both when drawing each card so the update line lives + // inside the card — no separate "Pending updates" strip at the top. + const addonUpdates = { staged: {}, report: {} }; + function updateLineFor(a) { + const st = addonUpdates.staged[a.id]; + const rep = addonUpdates.report[a.id]; + if (st) { + return '<div class="d" style="color:var(--acid);margin-top:4px">↻ Update <b>v' + escapeHtml(st.version) + '</b> staged — restart Theseus to apply.</div>'; + } + if (!rep) return ""; + let msg = "", cls = "color:var(--dim)"; + switch (rep.status) { + case "up-to-date": msg = "Up to date."; break; + case "no-update-url": return ""; // don't clutter cards that never opted in + case "fetch-failed": msg = "Update check failed — " + escapeHtml(rep.detail || "network"); cls = "color:#f6768a"; break; + case "signature-invalid": msg = "Endpoint offered v" + escapeHtml(rep.newVer || "?") + " with a BAD signature — rejected."; cls = "color:#f6768a"; break; + case "sha256-mismatch": msg = "Endpoint offered v" + escapeHtml(rep.newVer || "?") + " but the tarball hash didn't match."; cls = "color:#f6768a"; break; + case "extract-failed": msg = "v" + escapeHtml(rep.newVer || "?") + " downloaded but wouldn't extract — " + escapeHtml(rep.detail || ""); cls = "color:#f6768a"; break; + case "manifest-mismatch": msg = "Extracted manifest didn't match signed values."; cls = "color:#f6768a"; break; + case "already-staged": msg = "↻ v" + escapeHtml(rep.newVer || "?") + " already staged — restart to apply."; cls = "color:var(--acid)"; break; + case "staged": msg = "↻ Staged v" + escapeHtml(rep.newVer || "?") + " — restart to apply."; cls = "color:var(--acid)"; break; + default: msg = escapeHtml(rep.status || "unknown"); + } + return '<div class="d" style="' + cls + ';margin-top:4px">' + msg + '</div>'; + } + function renderAddons(snap) { + const items = (snap && snap.installed) || []; + if (!items.length) { + addonsList.innerHTML = '<div class="d" style="color:var(--dim)">No extensions installed. Drop a folder into the extensions directory to install one.</div>'; + return; + } + // Split into built-in (ship with Theseus, reseeded on version bump) vs + // sideloaded (dropped in by the user). Built-in ones can still be + // disabled — the toggle just means "load me at startup" — but they + // can't be permanently deleted since reseedBundledAddons runs on the + // next launch. Rendering the two groups apart makes that clear. + const bundled = items.filter((a) => a.bundled); + const sideloaded = items.filter((a) => !a.bundled); + const rowFor = (a, isBundled) => { + if (a.error) { + return '<div class="row"><div class="txt"><div class="t">⚠ Load failed <span style="color:var(--dim);font-weight:400">' + escapeHtml(a.folder) + '</span></div><div class="d" style="color:#f6768a">' + escapeHtml(a.error) + '</div></div><div><button class="btn" data-reveal="' + escapeAttr(a.folder) + '">Show folder</button></div></div>'; + } + const caps = (a.capabilities || []).length ? '<span style="color:var(--dim);font-size:11.5px;margin-left:8px">' + a.capabilities.map(escapeHtml).join(", ") + '</span>' : ""; + const builtInBadge = isBundled + ? '<span style="display:inline-block;font-size:10px;letter-spacing:.05em;padding:1px 6px;border-radius:3px;background:rgba(214,255,61,.14);color:var(--acid);font-weight:700;margin-left:6px;vertical-align:middle">BUILT-IN</span>' + : ""; + // Manifest icons may be an emoji ("🛡"), a "•" fallback, or a + // data: URI to a branded SVG/PNG — render as <img> for URIs, + // as escaped text otherwise so the emoji case keeps working. + const iconHtml = /^data:image\//i.test(a.icon || "") + ? '<img src="' + escapeAttr(a.icon) + '" alt="" style="width:16px;height:16px;vertical-align:middle;margin-right:4px">' + : escapeHtml(a.icon || "•"); + return '<div class="row"><div class="txt"><div class="t">' + iconHtml + ' ' + escapeHtml(a.name) + builtInBadge + ' <span style="color:var(--dim);font-weight:400">v' + escapeHtml(a.version) + '</span>' + caps + '</div><div class="d">' + escapeHtml(a.description || "") + (a.author ? ' <span style="color:var(--dim)">— ' + escapeHtml(a.author) + '</span>' : '') + '</div>' + updateLineFor(a) + '</div><div style="display:flex;gap:8px;align-items:center"><button class="btn" data-reveal="' + escapeAttr(a.folder) + '">Show folder</button><label class="sw"><input type="checkbox" data-toggle="' + escapeAttr(a.id) + '" ' + (a.enabled ? "checked" : "") + '><span class="track"><span class="knob"></span></span></label></div></div>'; + }; + const sectionHeader = (label, hint) => + '<div class="d" style="color:var(--dim);margin:12px 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.04em">' + escapeHtml(label) + '</div>' + + (hint ? '<div class="d" style="color:var(--dim);margin:-4px 0 8px">' + escapeHtml(hint) + '</div>' : ''); + let html = ""; + if (bundled.length) html += sectionHeader("Built into Theseus", "Ship with every Theseus install. Disabling one hides its UI but the folder stays; a fresh install of a newer Theseus reseeds it.") + + bundled.map((a) => rowFor(a, true)).join(""); + if (sideloaded.length) html += sectionHeader("Sideloaded", sideloaded.length && bundled.length ? "Third-party or hand-installed. Remove by deleting the folder." : "") + + sideloaded.map((a) => rowFor(a, false)).join(""); + addonsList.innerHTML = html; + addonsList.querySelectorAll('input[data-toggle]').forEach((cb) => { + cb.addEventListener("change", async () => { + await C.setAddonEnabled(cb.dataset.toggle, cb.checked); + loadAddons(); + }); + }); + addonsList.querySelectorAll('button[data-reveal]').forEach((btn) => { + btn.addEventListener("click", () => C.revealAddon(btn.dataset.reveal)); + }); + } + function escapeHtml(s) { return String(s || "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" })[c]); } + function escapeAttr(s) { return escapeHtml(s); } + async function loadAddons() { + try { renderAddons(await C.listAddons()); } + catch (e) { addonsList.textContent = "Failed to load extensions: " + (e?.message || e); } + } + // Refresh the staged-updates map from the background poll. Whatever came + // back through listStagedAddonUpdates is what promoteStagedUpdates will + // pick up on the next launch — the per-card badge reflects exactly that. + async function loadAddonUpdates() { + let staged = []; + try { staged = await C.listStagedAddonUpdates(); } catch { staged = []; } + addonUpdates.staged = {}; + for (const s of (staged || [])) addonUpdates.staged[s.id] = { version: s.version, name: s.name }; + } + document.getElementById("addonsReload").addEventListener("click", async () => { + await C.reloadAddons(); await loadAddonUpdates(); loadAddons(); + }); + document.getElementById("addonsOpenDir").addEventListener("click", () => C.openAddonsDir()); + document.querySelector('.side a[data-sec="addons"]').addEventListener("click", async () => { + await loadAddonUpdates(); loadAddons(); + }); + // Plug-ins tab: refresh Ariadne's live daemon state on every visit so it + // doesn't display stale "checking…" text if the background poll finished + // while another section was open. Aegis card loads on page-init and its + // "Check for updates" button is user-initiated, so we don't auto-poll it + // (that would fire a network request every tab-switch). + document.querySelector('.side a[data-sec="plugins"]').addEventListener("click", () => { + const ar = document.getElementById("ariadneRefresh"); + if (ar) ar.click(); + }); + + // (Old separate "Pending updates" strip lived here. Per-card update + // line is now painted by updateLineFor() inside each addon row — + // loadAddonUpdates() populates addonUpdates.staged before renderAddons.) + document.getElementById("addonsCheckUpdates").addEventListener("click", async () => { + const btn = document.getElementById("addonsCheckUpdates"); + const status = document.getElementById("addonsUpdStatus"); + btn.disabled = true; const orig = btn.textContent; btn.textContent = "Checking…"; + status.innerHTML = ""; + try { + const res = await C.checkAddonUpdates(); + // Back-compat: some callers still pass a bare array. Normalize. + const report = Array.isArray(res) ? [] : (res?.report || []); + const skipped = Array.isArray(res) ? null : (res?.skipped || null); + const staged = Array.isArray(res) ? res : (res?.staged || []); + if (skipped === "no-pubkeys") { + status.textContent = "Update endpoint disabled — no operator pubkey baked into this build."; + } else { + // Fold the per-addon report into addonUpdates.report so the + // per-card update line reflects the freshest check. A summary at + // the top counts staged/updated vs. up-to-date, but the per-addon + // detail lives on each card. + addonUpdates.report = {}; + for (const r of report) addonUpdates.report[r.id] = r; + const stagedNow = report.filter((r) => r.status === "staged" || r.status === "already-staged").length; + const failed = report.filter((r) => ["fetch-failed","signature-invalid","sha256-mismatch","extract-failed","manifest-mismatch"].includes(r.status)).length; + if (stagedNow) { + status.textContent = stagedNow + " update" + (stagedNow > 1 ? "s" : "") + " staged; restart Theseus to apply."; + } else if (failed) { + status.textContent = failed + " failed — see the extension card" + (failed > 1 ? "s" : "") + " below."; + } else if (!report.length) { + status.textContent = "No extensions with an update endpoint."; + } else { + status.textContent = "All extensions up to date."; + } + } + await loadAddonUpdates(); + loadAddons(); + } catch (e) { + status.textContent = "Check failed: " + (e?.message || e); + } finally { + btn.disabled = false; btn.textContent = orig; + } + }); + // Populate on first paint so the tab is ready when the user clicks in. + (async () => { await loadAddonUpdates(); loadAddons(); })(); +</script> +</body> +</html> diff --git a/sidebar-preload.js b/sidebar-preload.js new file mode 100644 index 00000000..598a14ea --- /dev/null +++ b/sidebar-preload.js @@ -0,0 +1,97 @@ +// Preload shared by every add-on sidebar panel. Exposes a small, safe +// surface to the add-on's HTML. Main-side handlers derive the add-on +// identity from the sender's URL (the panel is always loaded from +// somewhere inside <userData>/addons/<id>/), so a random page that +// happens to see the API shape can't touch another add-on's storage. +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("silentmode", { + storage: { + get: (key, fallback = null) => ipcRenderer.invoke("addon-storage-get", key, fallback), + set: (key, value) => ipcRenderer.invoke("addon-storage-set", key, value), + all: () => ipcRenderer.invoke("addon-storage-all"), + }, + // Ask main which panel is currently visible — panels may want to + // suspend expensive work when hidden. + onVisibility: (cb) => ipcRenderer.on("sidebar-visibility", (_e, visible) => cb(!!visible)), + // Call into the add-on's activate() context: resolves with whatever the + // matching api.onMessage handler returned (or rejects with its error). + invoke: (msg, payload) => ipcRenderer.invoke("addon-msg", String(msg), payload), + // Events the add-on pushes via api.emit while this panel is open. + on: (msg, cb) => ipcRenderer.on("addon-event", (_e, name, payload) => { if (name === msg) cb(payload); }), + // Sidebar sizing controls a panel may want (e.g. the screenshot editor + // wants a full-window canvas). Widen fills the window minus a thin strip + // for the tab area behind it; restore returns to the pre-widen width. + sidebar: { + maximize: () => ipcRenderer.invoke("sidebar-maximize"), + restore: () => ipcRenderer.invoke("sidebar-restore"), + toggleMax:() => ipcRenderer.invoke("sidebar-toggle-max"), + isMax: () => ipcRenderer.invoke("sidebar-is-max"), + onMaxChange: (cb) => ipcRenderer.on("sidebar-max-change", (_e, max) => cb(!!max)), + // Close the sidebar entirely. Same IPC the toolbar dock icon toggles + // — the panel/editor can offer an explicit X button so users don't + // have to hunt for the dock icon just to hide the panel. + close: () => ipcRenderer.invoke("sidebar-close"), + }, +}); + +// Panel picker strip was here — a 32-px tab bar injected at the top of +// every panel to switch between registered extensions. Removed: the +// toolbar extension dock (per-extension buttons + puzzle dropdown at +// narrow widths) is the canonical switcher now, and doubling that inside +// the sidebar wasted vertical space and made narrow panels feel cramped. + +// Sidebar resize grip. Injected into every panel automatically so panel +// authors don't have to reinvent it. A thin strip along the LEFT edge +// (the boundary between the tab area and the sidebar) accepts mousedown +// and streams drag deltas to main until mouseup. Main clamps the width +// to [200, 800] and persists it in settings.sidebarWidth. +window.addEventListener("DOMContentLoaded", () => { + const grip = document.createElement("div"); + grip.setAttribute("aria-label", "Resize sidebar"); + // A visible-at-rest separator. Fully transparent used to hide the seam + // between the tab area and the sidebar completely — users couldn't tell + // where one ended and the other began. A mid-gray at moderate alpha + // reads on every panel background (dark and light) without competing + // for attention. Hover / drag ramps to acid so the grab affordance + // still stands out. + const IDLE_BG = "rgba(140,150,170,.55)"; + const HOVER_BG = "rgba(214,255,61,.35)"; + const ACTIVE_BG = "rgba(214,255,61,.55)"; + grip.style.cssText = [ + "position:fixed", "left:0", "top:0", "bottom:0", + "width:2px", "cursor:col-resize", "z-index:2147483647", + "background:" + IDLE_BG, + // A wider invisible hit target sits over the visible strip so + // dragging still catches a 5-px slack — visible line stays a + // clean 2 px. + "box-shadow:2px 0 0 0 transparent", + ].join(";"); + // A subtle overlay expands the pointer-catch zone without widening + // the visible band. Same click-through element, wider hit box. + grip.style.setProperty("outline", "2px solid transparent", "important"); + grip.style.setProperty("outline-offset", "1px", "important"); + grip.addEventListener("mouseenter", () => { grip.style.background = HOVER_BG; }); + grip.addEventListener("mouseleave", () => { if (!dragging) grip.style.background = IDLE_BG; }); + document.body.appendChild(grip); + let dragging = false; + grip.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; + e.preventDefault(); + dragging = true; + document.body.style.userSelect = "none"; + grip.style.background = ACTIVE_BG; + }); + window.addEventListener("mousemove", (e) => { + if (!dragging) return; + // Moving cursor LEFT = grow sidebar width. movementX is negative left. + if (e.movementX !== 0) ipcRenderer.invoke("sidebar-drag", -e.movementX); + }); + const stop = () => { + if (!dragging) return; + dragging = false; + document.body.style.userSelect = ""; + grip.style.background = IDLE_BG; + }; + window.addEventListener("mouseup", stop); + window.addEventListener("mouseleave", stop); +}); diff --git a/snapshots/bns-name-snapshot.json b/snapshots/bns-name-snapshot.json new file mode 100644 index 00000000..d78ec82b --- /dev/null +++ b/snapshots/bns-name-snapshot.json @@ -0,0 +1 @@ +{"schema":"bns-name-snapshot/1","beacon":"e1cdbba719436f49acb4bf245b44df875c6d6b405eda46a9d3e736d138102f3b","root":"c37b8596829a40748a2cfbfc4add5d1f70aaca59b891ad3dc2fc1fbc54e414ba","asOfHeight":null,"asOfTxid":null,"history":[{"tx_hash":"6d9553a87cc45cf79f80f2bed077f20db798cbb4fc55cd4640606bf218c0fb8d","height":315692},{"tx_hash":"8339ed19b3bf05f8a371fb45a26fa92dad0271b512cb9a77e8b0bb07ca70838f","height":315692},{"tx_hash":"5ad9de448c73ed7f15e8b60d69cb2e029ca51fd6bc7ae88db50e9a29e49b714b","height":315699},{"tx_hash":"8cd1c3b4376aba2faa2a70e0c54e423df7683bf7c330a064af5f5ea9f1fdfb0b","height":315700},{"tx_hash":"d713a9374a2d3ddb02c4a776ee652ee630999fbe16c9f97faf3efb0fcea1a15a","height":315701},{"tx_hash":"06089872b61169cf9d1c7e19da6cf96eac441caa493f1b725ffac8f5e4c196b4","height":315711},{"tx_hash":"ba9fe976640a6b62bd65f67f9042a0f948050ce2cf35f290cbdd011a4238f472","height":315723},{"tx_hash":"f79bfdc56bd181e9d0526307a77ad464f618fb7cf89ae52467e5f9ae9d99aeb0","height":315727},{"tx_hash":"89c78a7d7a9b740353d99ab04b8b5a88ef2487bd3aa1e9da73a5bf398d980cce","height":315804},{"tx_hash":"93eaed3dc254e831e4908984f7e0adc95b1868dc9d4c7e417a48affe88c396e8","height":315804},{"tx_hash":"0629f5641444304db87d5a2c0dc287843296629a96695f0aa394af32ca9a4fdb","height":315808},{"tx_hash":"80bdbef4530d9f3c25627a1e9d908af342821c89eff66ba7a05d8158cb76fad2","height":315892},{"tx_hash":"6002f520e0de398e8541a70097739a7d36640456216391341628b10974be4610","height":316045},{"tx_hash":"628501df5bd89b994a956119edabe30d161a575a53107aa936962287e37e6429","height":316240},{"tx_hash":"037dc3dbbb6c823f8d554b75450975d81688aad80444679d823e2a9bd9d58ed2","height":316242},{"tx_hash":"969c0891d0ca85c37bbbf5b06436905cceda9b6e941e950ff4463d20dc116772","height":316242},{"tx_hash":"674def31f04925e5cc42337c811329136b08ab68692a10118cbcd5933e9a49df","height":316254},{"tx_hash":"6c07d5cce792b6349ec4b12bdb8b5948fa43762a9ea12dae0e48959070a3dd3b","height":316254},{"tx_hash":"8da2cc4ad2d49a94d964566518cf1c24e6de4e83761aa3557df8669348f0e052","height":316254},{"tx_hash":"96ae5a51193b71ed92e5f3c0fba50b0b407ec7e40908cbe1986e08e8b16f90e7","height":316254},{"tx_hash":"ca243c067a9bc4ad11b88c78f06b2e2a4489dda1a33f901eb2e63a6a20029c45","height":316254},{"tx_hash":"e390e8ac4b0d956657de6aa70931499e0fd22aa2bb59e19bcde07e8c04dc1d53","height":316254},{"tx_hash":"a80045e865967b3abff7a764a0fe16304b9c919605ccaf925f5d9f41f3a2d50a","height":316256},{"tx_hash":"28007bd8d1c54967e17e6eba5fe2fe0daa481be8a48cf39177a8d9e82feed201","height":316262},{"tx_hash":"3947b4a1a2905915dc638def281c4eee90da86f6ba2cf7769708a1cd06e672ac","height":316262},{"tx_hash":"7076fd08e3115403b55dd14c7b6ac843a2e2c3015931f91c5479185f31f0dc49","height":316277},{"tx_hash":"bf43bddd9f163e7ccf0b980cd2ddee0470765dd0ad6f424e986ad46a689d24c2","height":316415},{"tx_hash":"cb8c9002b4c1f032a1da448f8e8b445c32bf41851208d6d11d4cbbf583ee2175","height":316609},{"tx_hash":"9b71620c7d1bf3cfed69d035ffff5576382cc28ff8f523321ed0a0f1d4764852","height":316691},{"tx_hash":"1b1beb7d4ece128a043e9ae31d40fbaa76f0626f97d267b6448d372bc825b5ec","height":316724},{"tx_hash":"e25a21357e0ee2c7f4e9e769d99ec375d7388695c89f4517153de0405e92656d","height":316760},{"tx_hash":"f5a34ec7b995541f2f9b10f85d30b29cc76ebf75960e794a9001a68b16f6b3e9","height":316983},{"tx_hash":"4911e21829714e41d5676d161769fda4e40e8c5745c755ade473cfbda6c65952","height":317009},{"tx_hash":"685b8cb240ff19342c4a228a4adc4bea1776c64ae0cab581d3e5d0800d9a1454","height":317009},{"tx_hash":"e1b3e41f3ee15d7252a696522891ce6ecf2138da42bdc2571cc6e31772cf3924","height":317009},{"tx_hash":"e7efa170ce7f5a414a3ff1b2226d8b72a14664e484a864ad58bdac938ed74df7","height":317009},{"tx_hash":"178140fc4305e9fda9afe2e544f3dcf0183af22c275b143dad33bac6ecc61b4b","height":317010},{"tx_hash":"2ae4f2fe2deadbac836bb220af311c052d4f24df8d900c2b0d24fb626bce8fb5","height":317010},{"tx_hash":"985f0970ecd6d70b2f2d2553337150ed18a3e6e6cb88918f592d68d64f05ce37","height":317010},{"tx_hash":"a6d7a16d6ce68f7a100a6b133eb49df42541d5da4128c1c983aa7230e1d36d7a","height":317010},{"tx_hash":"6656b915febd6e6d801f1d3e19e6c87cd9acaada95c6060236f4b096d81012eb","height":317144},{"tx_hash":"e0c6536daa85f17d1719587e1c257db0c296636f6372cab6552577a3dad6c05e","height":317562},{"tx_hash":"29147bc053fced6672f83d16351756848aadbf9faffa0934d9b3d6c2b49bc970","height":317563},{"tx_hash":"539454166d97734cd21c595408e9cf38dab9a6ed8b447f2b7f7645239de3ea38","height":317563},{"tx_hash":"e630ba09d3822aeec1ac341cb390c3b172f9e80621914e6ec135a2e15db0698f","height":317563},{"tx_hash":"e3f6b84a3ad8c60c3087acbeb9aa8284efbc4f64fbe9709c84ba2c73546d25b0","height":317575},{"tx_hash":"e8a75795cd1feb07dce71d4621af5034266e61031c30d0403890546fae40841b","height":317575},{"tx_hash":"f3c97f7c706e7bbc497ae11e0891cf05cf8058375dd4a8659979f6ef09e670a9","height":317865},{"tx_hash":"aa6bcb88f5abbf3fa36c3527049d4d90ae6e52a3eb938546b633864175cc4ba2","height":318703},{"tx_hash":"fca23a6c612560c64b8e03e270e3396cf1f267f7a7071449acb64c61e5dae9c5","height":318703},{"tx_hash":"7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b","height":319662},{"tx_hash":"29fb60110478b0509513047ef3d52886e5abb2e70ca4b51e2d13a11d55c9d207","height":319699},{"tx_hash":"7af6ff492f81bee6d8549be620975e267c0270a8f37e6c800ceab14e2e0c11c7","height":320030},{"tx_hash":"25ae9044a406eec718138de1fcde267618cb02de8dc8f49a95ee88f4077afa1a","height":321201},{"tx_hash":"992dd62690ec58ba4d2d5e0019ffd2c1d07fc96e53600f1e4eb239382f33ec4b","height":321225},{"tx_hash":"8d1cc7b619b003982bb957693bf7177cc492c3a8a1e4c29ca81d683bf9b3e902","height":321228},{"tx_hash":"15bec52981fa82f9fa1c966600204112b7478a821f8384e0c8fecaea375215e9","height":321235},{"tx_hash":"85b1ece01c7910d5a17b1b28fa40f4c3680a3153b1960b332f9b8689ff7861a4","height":321235},{"tx_hash":"f671540f0204449ca4b6912ad7df11e07f719940264ff67889599478e7424969","height":321236},{"tx_hash":"f13c533db23ed457388ac78020ba38f240fca69d6f4c05f953741dbc6a88e1f8","height":321251},{"tx_hash":"f31163ff649fb7fc699898cfd7342c6dfffe3aaea2780ff1e8bf3858ef0b57e1","height":321251},{"tx_hash":"287135dbd30b4ff2977f0d0fb2b1bcfcfac6e09d9f441381ae6927ec6731c75a","height":321252},{"tx_hash":"7817c0d41c05d9ffc0ce0d8f45b752f4969fb3546f6c49e85174273e286c06f4","height":321252},{"tx_hash":"028e0dacd49bd164c2f209f1e2708b5d371082570b9cd91cb8595aeb21012672","height":321260},{"tx_hash":"eef83f0ba76e634e33228c7d91d40cb598e9213192b5138c4549f3a315b85ed6","height":321260},{"tx_hash":"c0914f248d64161e648f07f7890c0d3c87b3acce72a578e19277515fe09288b9","height":321264},{"tx_hash":"f7c030d44183614ffaf33758326c0908565f1e7f462384121ef0d3ca36a71c03","height":321270},{"tx_hash":"25f15012eb8f52d0fb950aa502ddde65f5352e736859fcb94d4f665015deae44","height":321282},{"tx_hash":"52d126b356e36fc9d87f81fa06148fada01820b344d4dedeeb4fd5fba970dc5b","height":321282},{"tx_hash":"3867797854d67a6dda6876bc4c4a8f2826f9175bf094f2fa3eb32d2c35cbe481","height":321284},{"tx_hash":"41ba0b575947ab4ddcae525cfc383a4f9f071d102c8ad7e4fd49c6f379854dcd","height":321334},{"tx_hash":"4fa572b2337ec7844364bfe9fea613b2f8283aba7d7dd46ede73c807c2834987","height":321334},{"tx_hash":"caddeb9bf65867a7ec28f4428b2c542095ab3d5faee9d8a4efbf681e9c7fe2cb","height":321334}],"txs":{"6d9553a87cc45cf79f80f2bed077f20db798cbb4fc55cd4640606bf218c0fb8d":{"blockhash":"000000008ab8d3be03c80f65d5892d63add289aa19cae05e8632ab407eedfc97","blocktime":1784755791,"confirmations":5737,"hash":"6d9553a87cc45cf79f80f2bed077f20db798cbb4fc55cd4640606bf218c0fb8d","hex":"0200000001dc01f89c5d17d80ef9ef865ec886cb7773ede05121adf3e6e2261b3bfa1ae33e0000000064412475e15fd21c25965c7260345ec7eb03bfede0393d60d2900fbfcb344f60163d6b465986bb33583121d41a7694b04259b5a149eb47416b98224f36537d5fd1f2412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e8030000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000776a4c74424e53317b226f223a22524547222c226e223a2268656c6c6f222c2272223a7b2268223a223c68313e68656c6c6f2e6263683c2f68313e3c703e54686973207061676520697320686f73746564206f6e2074686520426974636f696e204361736820626c6f636b636861696e2e3c2f703e227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac1a750f00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":381,"time":1784755791,"txid":"6d9553a87cc45cf79f80f2bed077f20db798cbb4fc55cd4640606bf218c0fb8d","version":2,"vin":[{"scriptSig":{"asm":"2475e15fd21c25965c7260345ec7eb03bfede0393d60d2900fbfcb344f60163d6b465986bb33583121d41a7694b04259b5a149eb47416b98224f36537d5fd1f2[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"412475e15fd21c25965c7260345ec7eb03bfede0393d60d2900fbfcb344f60163d6b465986bb33583121d41a7694b04259b5a149eb47416b98224f36537d5fd1f2412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"3ee31afa3b1b26e2e6f3ad2151e0ed7377cb86c85e86eff90ed8175d9cf801dc","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2268656c6c6f222c2272223a7b2268223a223c68313e68656c6c6f2e6263683c2f68313e3c703e54686973207061676520697320686f73746564206f6e2074686520426974636f696e204361736820626c6f636b636861696e2e3c2f703e227d7d","hex":"6a4c74424e53317b226f223a22524547222c226e223a2268656c6c6f222c2272223a7b2268223a223c68313e68656c6c6f2e6263683c2f68313e3c703e54686973207061676520697320686f73746564206f6e2074686520426974636f696e204361736820626c6f636b636861696e2e3c2f703e227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.01013018}]},"8339ed19b3bf05f8a371fb45a26fa92dad0271b512cb9a77e8b0bb07ca70838f":{"blockhash":"000000008ab8d3be03c80f65d5892d63add289aa19cae05e8632ab407eedfc97","blocktime":1784755791,"confirmations":5737,"hash":"8339ed19b3bf05f8a371fb45a26fa92dad0271b512cb9a77e8b0bb07ca70838f","hex":"02000000028dfbc018f26b604046cd55fcb4cb98b70df277d0bef2809ff75cc47ca853956d00000000644113408ed02914fca8dae97cb77f72737d7bf001a8b64c73bc9a52745c7d90817e8e0b2ebce83df199c0d936c6c42d915cbdb2eac84a98c21c7aa68a930e4f41f6412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d000000008dfbc018f26b604046cd55fcb4cb98b70df277d0bef2809ff75cc47ca853956d030000006441c31b2c434eb254680fd3176ee46544d946d1ebb91ca40fc9e203cb99fbdcc94c317d1cf467b3115f345196671a83b707258fae15df1df46235c7e8e806dc8cf6412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000041ef8dfbc018f26b604046cd55fcb4cb98b70df277d0bef2809ff75cc47ca853956d600568656c6c6f76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000776a4c74424e53317b226f223a22524547222c226e223a2268656c6c6f222c2272223a7b2268223a223c68313e68656c6c6f2e6263683c2f68313e3c703e54686973207061676520697320686f73746564206f6e2074686520426974636f696e204361736820626c6f636b636861696e2e3c2f703e227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac8f700f00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":562,"time":1784755791,"txid":"8339ed19b3bf05f8a371fb45a26fa92dad0271b512cb9a77e8b0bb07ca70838f","version":2,"vin":[{"scriptSig":{"asm":"13408ed02914fca8dae97cb77f72737d7bf001a8b64c73bc9a52745c7d90817e8e0b2ebce83df199c0d936c6c42d915cbdb2eac84a98c21c7aa68a930e4f41f6[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4113408ed02914fca8dae97cb77f72737d7bf001a8b64c73bc9a52745c7d90817e8e0b2ebce83df199c0d936c6c42d915cbdb2eac84a98c21c7aa68a930e4f41f6412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"6d9553a87cc45cf79f80f2bed077f20db798cbb4fc55cd4640606bf218c0fb8d","vout":0},{"scriptSig":{"asm":"c31b2c434eb254680fd3176ee46544d946d1ebb91ca40fc9e203cb99fbdcc94c317d1cf467b3115f345196671a83b707258fae15df1df46235c7e8e806dc8cf6[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41c31b2c434eb254680fd3176ee46544d946d1ebb91ca40fc9e203cb99fbdcc94c317d1cf467b3115f345196671a83b707258fae15df1df46235c7e8e806dc8cf6412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"6d9553a87cc45cf79f80f2bed077f20db798cbb4fc55cd4640606bf218c0fb8d","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"6d9553a87cc45cf79f80f2bed077f20db798cbb4fc55cd4640606bf218c0fb8d","nft":{"capability":"none","commitment":"68656c6c6f"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2268656c6c6f222c2272223a7b2268223a223c68313e68656c6c6f2e6263683c2f68313e3c703e54686973207061676520697320686f73746564206f6e2074686520426974636f696e204361736820626c6f636b636861696e2e3c2f703e227d7d","hex":"6a4c74424e53317b226f223a22524547222c226e223a2268656c6c6f222c2272223a7b2268223a223c68313e68656c6c6f2e6263683c2f68313e3c703e54686973207061676520697320686f73746564206f6e2074686520426974636f696e204361736820626c6f636b636861696e2e3c2f703e227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.01011855}]},"5ad9de448c73ed7f15e8b60d69cb2e029ca51fd6bc7ae88db50e9a29e49b714b":{"blockhash":"000000004847a85711989561e519e178eb25337f2169b07edc22f44007e430ed","blocktime":1784759308,"confirmations":5730,"hash":"5ad9de448c73ed7f15e8b60d69cb2e029ca51fd6bc7ae88db50e9a29e49b714b","hex":"02000000011f1bc54a5508d588662bc0f151790d80bfaf687961673e3dd19ad3fb264bb6550000000064410f6c7730ba877ec92ae655dd269974736b094f312b06e2fb30187c77dbbcc758deea0048e14580d619012238bfe8480b731b8b3dd5fef53ad6343a25f814e8d0412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000048ef1f1bc54a5508d588662bc0f151790d80bfaf687961673e3dd19ad3fb264bb655600c636f696e737065637472756d76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000536a4c50424e53317b226f223a22524547222c226e223a22636f696e737065637472756d222c2272223a7b2275223a2268747470733a2f2f636f696e737065637472756d2e6475636b646e732e6f72672f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac471f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":392,"time":1784759308,"txid":"5ad9de448c73ed7f15e8b60d69cb2e029ca51fd6bc7ae88db50e9a29e49b714b","version":2,"vin":[{"scriptSig":{"asm":"0f6c7730ba877ec92ae655dd269974736b094f312b06e2fb30187c77dbbcc758deea0048e14580d619012238bfe8480b731b8b3dd5fef53ad6343a25f814e8d0[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"410f6c7730ba877ec92ae655dd269974736b094f312b06e2fb30187c77dbbcc758deea0048e14580d619012238bfe8480b731b8b3dd5fef53ad6343a25f814e8d0412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"55b64b26fbd39ad13d3e67617968afbf800d7951f1c02b6688d508554ac51b1f","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"55b64b26fbd39ad13d3e67617968afbf800d7951f1c02b6688d508554ac51b1f","nft":{"capability":"none","commitment":"636f696e737065637472756d"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22636f696e737065637472756d222c2272223a7b2275223a2268747470733a2f2f636f696e737065637472756d2e6475636b646e732e6f72672f227d7d","hex":"6a4c50424e53317b226f223a22524547222c226e223a22636f696e737065637472756d222c2272223a7b2275223a2268747470733a2f2f636f696e737065637472756d2e6475636b646e732e6f72672f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008007}]},"8cd1c3b4376aba2faa2a70e0c54e423df7683bf7c330a064af5f5ea9f1fdfb0b":{"blockhash":"00000000100b0064ecd715ee7dd815d7b0aefa8bd7c2b2f5affc46361b4378eb","blocktime":1784759560,"confirmations":5729,"hash":"8cd1c3b4376aba2faa2a70e0c54e423df7683bf7c330a064af5f5ea9f1fdfb0b","hex":"02000000024b719be4299a0eb58de87abcd61fa59c022ecb690db6e8157fed738c44ded95a000000006441f53a6914de0c8d702741246a2a0f907c4604a293644eb3692bab78599e15f420baceb5655a1af158a192b6e858c3d90f695104b2b5482aa304851d04be82d89b412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d000000001f1bc54a5508d588662bc0f151790d80bfaf687961673e3dd19ad3fb264bb6550100000064419945f40ab93e725573ac40dffb0c168b922d95fb1f09e82afde3652cd59f31437e69becf5e6ca88e582e6a592cfa53afbc0dfe0e78ffa5bc36c6cd851a7b4705412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000048ef1f1bc54a5508d588662bc0f151790d80bfaf687961673e3dd19ad3fb264bb655600c636f696e737065637472756d76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000007a6a4c77424e53317b226f223a22555044222c226e223a22636f696e737065637472756d222c2272223a7b2270223a2268747470733a2f2f636f696e737065637472756d2e6475636b646e732e6f7267222c2275223a2268747470733a2f2f636f696e737065637472756d2e6475636b646e732e6f72672f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac0e440f00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":572,"time":1784759560,"txid":"8cd1c3b4376aba2faa2a70e0c54e423df7683bf7c330a064af5f5ea9f1fdfb0b","version":2,"vin":[{"scriptSig":{"asm":"f53a6914de0c8d702741246a2a0f907c4604a293644eb3692bab78599e15f420baceb5655a1af158a192b6e858c3d90f695104b2b5482aa304851d04be82d89b[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41f53a6914de0c8d702741246a2a0f907c4604a293644eb3692bab78599e15f420baceb5655a1af158a192b6e858c3d90f695104b2b5482aa304851d04be82d89b412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"5ad9de448c73ed7f15e8b60d69cb2e029ca51fd6bc7ae88db50e9a29e49b714b","vout":0},{"scriptSig":{"asm":"9945f40ab93e725573ac40dffb0c168b922d95fb1f09e82afde3652cd59f31437e69becf5e6ca88e582e6a592cfa53afbc0dfe0e78ffa5bc36c6cd851a7b4705[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"419945f40ab93e725573ac40dffb0c168b922d95fb1f09e82afde3652cd59f31437e69becf5e6ca88e582e6a592cfa53afbc0dfe0e78ffa5bc36c6cd851a7b4705412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"55b64b26fbd39ad13d3e67617968afbf800d7951f1c02b6688d508554ac51b1f","vout":1}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"55b64b26fbd39ad13d3e67617968afbf800d7951f1c02b6688d508554ac51b1f","nft":{"capability":"none","commitment":"636f696e737065637472756d"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a22636f696e737065637472756d222c2272223a7b2270223a2268747470733a2f2f636f696e737065637472756d2e6475636b646e732e6f7267222c2275223a2268747470733a2f2f636f696e737065637472756d2e6475636b646e732e6f72672f227d7d","hex":"6a4c77424e53317b226f223a22555044222c226e223a22636f696e737065637472756d222c2272223a7b2270223a2268747470733a2f2f636f696e737065637472756d2e6475636b646e732e6f7267222c2275223a2268747470733a2f2f636f696e737065637472756d2e6475636b646e732e6f72672f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.01000462}]},"d713a9374a2d3ddb02c4a776ee652ee630999fbe16c9f97faf3efb0fcea1a15a":{"blockhash":"00000000e756dfa0f105033812789a469a29b53dd084dfb29295fe812482bacd","blocktime":1784761050,"confirmations":5728,"hash":"d713a9374a2d3ddb02c4a776ee652ee630999fbe16c9f97faf3efb0fcea1a15a","hex":"02000000020bfbfdf1a95e5faf64a030c3f73b68f73d424ec5e0702aaa2fba6a37b4c3d18c000000006441d7bbe8d2cdb7234feb6de8425fe8de5f9bcd8f81d25e07ee0f2832c4b8fa5b66da0aafdbb9925724971dd50ebeae0eb9d85775f2cb7ac4da81b2a8a27523ea4c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d000000004b719be4299a0eb58de87abcd61fa59c022ecb690db6e8157fed738c44ded95a030000006441f015f20c7bda88fe635ca4a5ab3d3949d0c6939fafce66f4471c0373d019b08f652d9540838e2fbc33b18dcc3c3dba4739db0c90aa4f556eca9a49c9e31c4be5412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000048ef1f1bc54a5508d588662bc0f151790d80bfaf687961673e3dd19ad3fb264bb655600c636f696e737065637472756d76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000696a4c66424e53317b226f223a22555044222c226e223a22636f696e737065637472756d222c2272223a7b226970223a223139352e3138342e3234372e313036222c2270223a2268747470733a2f2f636f696e737065637472756d2e6475636b646e732e6f7267227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88acc31a0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":555,"time":1784761050,"txid":"d713a9374a2d3ddb02c4a776ee652ee630999fbe16c9f97faf3efb0fcea1a15a","version":2,"vin":[{"scriptSig":{"asm":"d7bbe8d2cdb7234feb6de8425fe8de5f9bcd8f81d25e07ee0f2832c4b8fa5b66da0aafdbb9925724971dd50ebeae0eb9d85775f2cb7ac4da81b2a8a27523ea4c[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41d7bbe8d2cdb7234feb6de8425fe8de5f9bcd8f81d25e07ee0f2832c4b8fa5b66da0aafdbb9925724971dd50ebeae0eb9d85775f2cb7ac4da81b2a8a27523ea4c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"8cd1c3b4376aba2faa2a70e0c54e423df7683bf7c330a064af5f5ea9f1fdfb0b","vout":0},{"scriptSig":{"asm":"f015f20c7bda88fe635ca4a5ab3d3949d0c6939fafce66f4471c0373d019b08f652d9540838e2fbc33b18dcc3c3dba4739db0c90aa4f556eca9a49c9e31c4be5[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41f015f20c7bda88fe635ca4a5ab3d3949d0c6939fafce66f4471c0373d019b08f652d9540838e2fbc33b18dcc3c3dba4739db0c90aa4f556eca9a49c9e31c4be5412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"5ad9de448c73ed7f15e8b60d69cb2e029ca51fd6bc7ae88db50e9a29e49b714b","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"55b64b26fbd39ad13d3e67617968afbf800d7951f1c02b6688d508554ac51b1f","nft":{"capability":"none","commitment":"636f696e737065637472756d"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a22636f696e737065637472756d222c2272223a7b226970223a223139352e3138342e3234372e313036222c2270223a2268747470733a2f2f636f696e737065637472756d2e6475636b646e732e6f7267227d7d","hex":"6a4c66424e53317b226f223a22555044222c226e223a22636f696e737065637472756d222c2272223a7b226970223a223139352e3138342e3234372e313036222c2270223a2268747470733a2f2f636f696e737065637472756d2e6475636b646e732e6f7267227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00006851}]},"06089872b61169cf9d1c7e19da6cf96eac441caa493f1b725ffac8f5e4c196b4":{"blockhash":"0000000026345017f3b7410856287e5eb716d3002795544e05e67f4552e5f6e3","blocktime":1784765492,"confirmations":5718,"hash":"06089872b61169cf9d1c7e19da6cf96eac441caa493f1b725ffac8f5e4c196b4","hex":"02000000025aa1a1ce0ffb3eaf7ff9c916be9f9930e62e65ee76a7c402db3d2d4a37a913d700000000644190bb44e1b6e9e4c93bdbc330f0709802db8e560325001a7be7a08c5b51dcab762af3fda88841ac1b1824311f5fd9ea7fc7dacb58197efd9052aac86fa061ff0d412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d000000000bfbfdf1a95e5faf64a030c3f73b68f73d424ec5e0702aaa2fba6a37b4c3d18c03000000644147a05e91ced016159db6c72f2811cc37227da32952838ec14582256a88a03f598a1f4eb8fbda853c61eca8e105d7b817c4dac7cca58465861706269c4d4e9a05412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000048ef1f1bc54a5508d588662bc0f151790d80bfaf687961673e3dd19ad3fb264bb655600c636f696e737065637472756d76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000b26a4caf424e53317b226f223a22555044222c226e223a22636f696e737065637472756d222c2272223a7b226970223a223139352e3138342e3234372e313036222c2270223a2268747470733a2f2f636f696e737065637472756d2e6475636b646e732e6f7267222c22746c73223a2238346534636436396437613038306662633863343739383137613461616134326534653938373038633063613966656264656162613361373164363632646131227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac413f0f00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":628,"time":1784765492,"txid":"06089872b61169cf9d1c7e19da6cf96eac441caa493f1b725ffac8f5e4c196b4","version":2,"vin":[{"scriptSig":{"asm":"90bb44e1b6e9e4c93bdbc330f0709802db8e560325001a7be7a08c5b51dcab762af3fda88841ac1b1824311f5fd9ea7fc7dacb58197efd9052aac86fa061ff0d[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4190bb44e1b6e9e4c93bdbc330f0709802db8e560325001a7be7a08c5b51dcab762af3fda88841ac1b1824311f5fd9ea7fc7dacb58197efd9052aac86fa061ff0d412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"d713a9374a2d3ddb02c4a776ee652ee630999fbe16c9f97faf3efb0fcea1a15a","vout":0},{"scriptSig":{"asm":"47a05e91ced016159db6c72f2811cc37227da32952838ec14582256a88a03f598a1f4eb8fbda853c61eca8e105d7b817c4dac7cca58465861706269c4d4e9a05[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4147a05e91ced016159db6c72f2811cc37227da32952838ec14582256a88a03f598a1f4eb8fbda853c61eca8e105d7b817c4dac7cca58465861706269c4d4e9a05412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"8cd1c3b4376aba2faa2a70e0c54e423df7683bf7c330a064af5f5ea9f1fdfb0b","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"55b64b26fbd39ad13d3e67617968afbf800d7951f1c02b6688d508554ac51b1f","nft":{"capability":"none","commitment":"636f696e737065637472756d"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a22636f696e737065637472756d222c2272223a7b226970223a223139352e3138342e3234372e313036222c2270223a2268747470733a2f2f636f696e737065637472756d2e6475636b646e732e6f7267222c22746c73223a2238346534636436396437613038306662633863343739383137613461616134326534653938373038633063613966656264656162613361373164363632646131227d7d","hex":"6a4caf424e53317b226f223a22555044222c226e223a22636f696e737065637472756d222c2272223a7b226970223a223139352e3138342e3234372e313036222c2270223a2268747470733a2f2f636f696e737065637472756d2e6475636b646e732e6f7267222c22746c73223a2238346534636436396437613038306662633863343739383137613461616134326534653938373038633063613966656264656162613361373164363632646131227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00999233}]},"ba9fe976640a6b62bd65f67f9042a0f948050ce2cf35f290cbdd011a4238f472":{"blockhash":"000000000d6d37c6e284c3fdd8bfbe4e0009afac4a86302a8804554166ac8645","blocktime":1784772671,"confirmations":5706,"hash":"ba9fe976640a6b62bd65f67f9042a0f948050ce2cf35f290cbdd011a4238f472","hex":"02000000018daf8b6819fede6833468a31610f9b5098ee9f47836f19fe93f00111a91a91c7000000006441841eedb11ca4a25d564f5499df591db11cb11be62c219a7b1fadbe5c2f90729f5a63fd873f4fb6f118d120154bd9e03d307de76c35fc7bcfeb589131f0dd64f3412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000043ef8daf8b6819fede6833468a31610f9b5098ee9f47836f19fe93f00111a91a91c760077369617465737476a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000003e6a3c424e53317b226f223a22524547222c226e223a2273696174657374222c2272223a7b227333223a22626e732f7369612d746573742e68746d6c227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac611f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":366,"time":1784772671,"txid":"ba9fe976640a6b62bd65f67f9042a0f948050ce2cf35f290cbdd011a4238f472","version":2,"vin":[{"scriptSig":{"asm":"841eedb11ca4a25d564f5499df591db11cb11be62c219a7b1fadbe5c2f90729f5a63fd873f4fb6f118d120154bd9e03d307de76c35fc7bcfeb589131f0dd64f3[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41841eedb11ca4a25d564f5499df591db11cb11be62c219a7b1fadbe5c2f90729f5a63fd873f4fb6f118d120154bd9e03d307de76c35fc7bcfeb589131f0dd64f3412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"c7911aa91101f093fe196f83479fee98509b0f61318a463368defe19688baf8d","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"c7911aa91101f093fe196f83479fee98509b0f61318a463368defe19688baf8d","nft":{"capability":"none","commitment":"73696174657374"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2273696174657374222c2272223a7b227333223a22626e732f7369612d746573742e68746d6c227d7d","hex":"6a3c424e53317b226f223a22524547222c226e223a2273696174657374222c2272223a7b227333223a22626e732f7369612d746573742e68746d6c227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008033}]},"f79bfdc56bd181e9d0526307a77ad464f618fb7cf89ae52467e5f9ae9d99aeb0":{"blockhash":"0000000041c8d90366263e4822777d6d7f4fbdfebfe5b2d1a00f52f4d423caed","blocktime":1784774313,"confirmations":5702,"hash":"f79bfdc56bd181e9d0526307a77ad464f618fb7cf89ae52467e5f9ae9d99aeb0","hex":"02000000013570305d9893bb91c3648e7605a01041c7efa2eb2f26d0e96cc5de225f247308000000006441b276d1496077fa58a19e379c613b5daad5cc7276982adbdfb1cd5274ebebfc21f9ef8deb76463fee268457b54270d640ace815b3e103391787bddd5ca97d610a412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000043ef3570305d9893bb91c3648e7605a01041c7efa2eb2f26d0e96cc5de225f247308600764657669616e7476a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000396a37424e53317b226f223a22524547222c226e223a2264657669616e74222c2272223a7b227333223a22626e732f64657669616e742f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac661f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":361,"time":1784774313,"txid":"f79bfdc56bd181e9d0526307a77ad464f618fb7cf89ae52467e5f9ae9d99aeb0","version":2,"vin":[{"scriptSig":{"asm":"b276d1496077fa58a19e379c613b5daad5cc7276982adbdfb1cd5274ebebfc21f9ef8deb76463fee268457b54270d640ace815b3e103391787bddd5ca97d610a[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41b276d1496077fa58a19e379c613b5daad5cc7276982adbdfb1cd5274ebebfc21f9ef8deb76463fee268457b54270d640ace815b3e103391787bddd5ca97d610a412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"0873245f22dec56ce9d0262feba2efc74110a005768e64c391bb93985d307035","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"0873245f22dec56ce9d0262feba2efc74110a005768e64c391bb93985d307035","nft":{"capability":"none","commitment":"64657669616e74"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2264657669616e74222c2272223a7b227333223a22626e732f64657669616e742f227d7d","hex":"6a37424e53317b226f223a22524547222c226e223a2264657669616e74222c2272223a7b227333223a22626e732f64657669616e742f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008038}]},"89c78a7d7a9b740353d99ab04b8b5a88ef2487bd3aa1e9da73a5bf398d980cce":{"blockhash":"00000000720408d7db25b5603488141e0a734119493b497dcb88d592a39bd49d","blocktime":1784822546,"confirmations":5625,"hash":"89c78a7d7a9b740353d99ab04b8b5a88ef2487bd3aa1e9da73a5bf398d980cce","hex":"0200000001458d759f15020651a8c79212f31ccadc890d1ba8ead6b1e23752fc0ba3134707000000006441aa427e818f6ff24812e3b8324fca776fecae649ec9395b8737ea43ac3561fc340455cd57ebbf1cbc0ed4090ab6f1e7ba3c1397e503e4e7b22d6ef38c2fb32e6b412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000043ef458d759f15020651a8c79212f31ccadc890d1ba8ead6b1e23752fc0ba313470760077468657365757376a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000896a4c86424e53317b226f223a22524547222c226e223a2274686573657573222c2272223a7b2268223a223c68313e546865736575733c2f68313e3c703e412062726f77736572207468617420666f6c6c6f777320746865207468726561642e2042792053696c656e74204d6f64652c20612044657669616e742070726f6a6563742e3c2f703e227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac161f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":441,"time":1784822546,"txid":"89c78a7d7a9b740353d99ab04b8b5a88ef2487bd3aa1e9da73a5bf398d980cce","version":2,"vin":[{"scriptSig":{"asm":"aa427e818f6ff24812e3b8324fca776fecae649ec9395b8737ea43ac3561fc340455cd57ebbf1cbc0ed4090ab6f1e7ba3c1397e503e4e7b22d6ef38c2fb32e6b[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41aa427e818f6ff24812e3b8324fca776fecae649ec9395b8737ea43ac3561fc340455cd57ebbf1cbc0ed4090ab6f1e7ba3c1397e503e4e7b22d6ef38c2fb32e6b412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"074713a30bfc5237e2b1d6eaa81b0d89dcca1cf31292c7a8510602159f758d45","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"074713a30bfc5237e2b1d6eaa81b0d89dcca1cf31292c7a8510602159f758d45","nft":{"capability":"none","commitment":"74686573657573"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2274686573657573222c2272223a7b2268223a223c68313e546865736575733c2f68313e3c703e412062726f77736572207468617420666f6c6c6f777320746865207468726561642e2042792053696c656e74204d6f64652c20612044657669616e742070726f6a6563742e3c2f703e227d7d","hex":"6a4c86424e53317b226f223a22524547222c226e223a2274686573657573222c2272223a7b2268223a223c68313e546865736575733c2f68313e3c703e412062726f77736572207468617420666f6c6c6f777320746865207468726561642e2042792053696c656e74204d6f64652c20612044657669616e742070726f6a6563742e3c2f703e227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00007958}]},"93eaed3dc254e831e4908984f7e0adc95b1868dc9d4c7e417a48affe88c396e8":{"blockhash":"00000000720408d7db25b5603488141e0a734119493b497dcb88d592a39bd49d","blocktime":1784822546,"confirmations":5625,"hash":"93eaed3dc254e831e4908984f7e0adc95b1868dc9d4c7e417a48affe88c396e8","hex":"0200000001c8eba21acb3a6b6d7b5f1eaf36395b99651caa5f0ff1b1978dc87d98e89730b10000000064419c6fad37094af534b30eb2fadad970a064889b09d4f0eeedee436b0d6dabd8bb26ca2bd2e6d3307e10c5937b696c2f2c936f28bea62ff63700459a6197a11b5a412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000046efc8eba21acb3a6b6d7b5f1eaf36395b99651caa5f0ff1b1978dc87d98e89730b1600a73696c656e746d6f646576a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000007f6a4c7c424e53317b226f223a22524547222c226e223a2273696c656e746d6f6465222c2272223a7b2268223a223c68313e53696c656e74204d6f64653c2f68313e3c703e446563656e7472616c697a656420696e7465726e65742070726f64756374732e20412044657669616e74206469766973696f6e2e3c2f703e227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac1d1f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":434,"time":1784822546,"txid":"93eaed3dc254e831e4908984f7e0adc95b1868dc9d4c7e417a48affe88c396e8","version":2,"vin":[{"scriptSig":{"asm":"9c6fad37094af534b30eb2fadad970a064889b09d4f0eeedee436b0d6dabd8bb26ca2bd2e6d3307e10c5937b696c2f2c936f28bea62ff63700459a6197a11b5a[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"419c6fad37094af534b30eb2fadad970a064889b09d4f0eeedee436b0d6dabd8bb26ca2bd2e6d3307e10c5937b696c2f2c936f28bea62ff63700459a6197a11b5a412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"b13097e8987dc88d97b1f10f5faa1c65995b3936af1e5f7b6d6b3acb1aa2ebc8","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"b13097e8987dc88d97b1f10f5faa1c65995b3936af1e5f7b6d6b3acb1aa2ebc8","nft":{"capability":"none","commitment":"73696c656e746d6f6465"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2273696c656e746d6f6465222c2272223a7b2268223a223c68313e53696c656e74204d6f64653c2f68313e3c703e446563656e7472616c697a656420696e7465726e65742070726f64756374732e20412044657669616e74206469766973696f6e2e3c2f703e227d7d","hex":"6a4c7c424e53317b226f223a22524547222c226e223a2273696c656e746d6f6465222c2272223a7b2268223a223c68313e53696c656e74204d6f64653c2f68313e3c703e446563656e7472616c697a656420696e7465726e65742070726f64756374732e20412044657669616e74206469766973696f6e2e3c2f703e227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00007965}]},"0629f5641444304db87d5a2c0dc287843296629a96695f0aa394af32ca9a4fdb":{"blockhash":"0000000003df00155225fcdf5f4a393ec363e71e1437708fa03045c7377187a5","blocktime":1784824699,"confirmations":5621,"hash":"0629f5641444304db87d5a2c0dc287843296629a96695f0aa394af32ca9a4fdb","hex":"020000000129862b7bfc7e7280314b0364be3679b1c778e444de1d21ea5477b6af1ef176be000000006441510a5ea8d1cd508e4720c717e519cf984619836858c05b4c7e7560a53e20833421eae607268586aa0ff5c4e5bfd5435e61e8f90d087408c40902f7b3c33709e4412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000040ef29862b7bfc7e7280314b0364be3679b1c778e444de1d21ea5477b6af1ef176be60046172676f76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000007d6a4c7a424e53317b226f223a22524547222c226e223a226172676f222c2272223a7b2268223a223c68313e4172676f3c2f68313e3c703e54686520686f7374206e6574776f726b206f66207468652053696c656e74204d6f646520737461636b2e20437265776564206279204172676f6e617574732e3c2f703e227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac251f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":426,"time":1784824699,"txid":"0629f5641444304db87d5a2c0dc287843296629a96695f0aa394af32ca9a4fdb","version":2,"vin":[{"scriptSig":{"asm":"510a5ea8d1cd508e4720c717e519cf984619836858c05b4c7e7560a53e20833421eae607268586aa0ff5c4e5bfd5435e61e8f90d087408c40902f7b3c33709e4[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41510a5ea8d1cd508e4720c717e519cf984619836858c05b4c7e7560a53e20833421eae607268586aa0ff5c4e5bfd5435e61e8f90d087408c40902f7b3c33709e4412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"be76f11eafb67754ea211dde44e478c7b17936be64034b3180727efc7b2b8629","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"be76f11eafb67754ea211dde44e478c7b17936be64034b3180727efc7b2b8629","nft":{"capability":"none","commitment":"6172676f"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a226172676f222c2272223a7b2268223a223c68313e4172676f3c2f68313e3c703e54686520686f7374206e6574776f726b206f66207468652053696c656e74204d6f646520737461636b2e20437265776564206279204172676f6e617574732e3c2f703e227d7d","hex":"6a4c7a424e53317b226f223a22524547222c226e223a226172676f222c2272223a7b2268223a223c68313e4172676f3c2f68313e3c703e54686520686f7374206e6574776f726b206f66207468652053696c656e74204d6f646520737461636b2e20437265776564206279204172676f6e617574732e3c2f703e227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00007973}]},"80bdbef4530d9f3c25627a1e9d908af342821c89eff66ba7a05d8158cb76fad2":{"blockhash":"000000000be11a4954d56a1f65aa6e30d5d364bbb27b6766f7500ebb90327248","blocktime":1784875825,"confirmations":5537,"hash":"80bdbef4530d9f3c25627a1e9d908af342821c89eff66ba7a05d8158cb76fad2","hex":"0200000002e896c388feaf487a417e4c9ddc68185bc9ade0f7848990e431e854c23dedea93000000006441d4cf05d96ba633ec2f3a0956b511f7dab70dcf9b70cefd86a7f14a0f1c66e9b3030d80f63e8ba7833e14e9f5f1176ea03766f2e004f037ef8eb77df2bb5cc284412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d00000000c8eba21acb3a6b6d7b5f1eaf36395b99651caa5f0ff1b1978dc87d98e89730b10100000064414b62bfaa2de50eeaa4fc4b488cd5c75f1851129937c8d36884c63c6e484fea4e469bbdbc19cebc2849c064e6342e458d2d8333e8d017e1870f25b8ff29ac4cc7412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000046efc8eba21acb3a6b6d7b5f1eaf36395b99651caa5f0ff1b1978dc87d98e89730b1600a73696c656e746d6f646576a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000003f6a3d424e53317b226f223a22555044222c226e223a2273696c656e746d6f6465222c2272223a7b227333223a22626e732f73696c656e746d6f64652f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac1cf30e00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":511,"time":1784875825,"txid":"80bdbef4530d9f3c25627a1e9d908af342821c89eff66ba7a05d8158cb76fad2","version":2,"vin":[{"scriptSig":{"asm":"d4cf05d96ba633ec2f3a0956b511f7dab70dcf9b70cefd86a7f14a0f1c66e9b3030d80f63e8ba7833e14e9f5f1176ea03766f2e004f037ef8eb77df2bb5cc284[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41d4cf05d96ba633ec2f3a0956b511f7dab70dcf9b70cefd86a7f14a0f1c66e9b3030d80f63e8ba7833e14e9f5f1176ea03766f2e004f037ef8eb77df2bb5cc284412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"93eaed3dc254e831e4908984f7e0adc95b1868dc9d4c7e417a48affe88c396e8","vout":0},{"scriptSig":{"asm":"4b62bfaa2de50eeaa4fc4b488cd5c75f1851129937c8d36884c63c6e484fea4e469bbdbc19cebc2849c064e6342e458d2d8333e8d017e1870f25b8ff29ac4cc7[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"414b62bfaa2de50eeaa4fc4b488cd5c75f1851129937c8d36884c63c6e484fea4e469bbdbc19cebc2849c064e6342e458d2d8333e8d017e1870f25b8ff29ac4cc7412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"b13097e8987dc88d97b1f10f5faa1c65995b3936af1e5f7b6d6b3acb1aa2ebc8","vout":1}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"b13097e8987dc88d97b1f10f5faa1c65995b3936af1e5f7b6d6b3acb1aa2ebc8","nft":{"capability":"none","commitment":"73696c656e746d6f6465"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a2273696c656e746d6f6465222c2272223a7b227333223a22626e732f73696c656e746d6f64652f227d7d","hex":"6a3d424e53317b226f223a22555044222c226e223a2273696c656e746d6f6465222c2272223a7b227333223a22626e732f73696c656e746d6f64652f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.0097974}]},"6002f520e0de398e8541a70097739a7d36640456216391341628b10974be4610":{"blockhash":"000000002a779d893a7195ca710bf8f1ea07904ed788715646d9dc3389042097","blocktime":1784966713,"confirmations":5384,"hash":"6002f520e0de398e8541a70097739a7d36640456216391341628b10974be4610","hex":"0200000001bf7d2c8b85933138535436e3c321ed169b271dd8c0ef42fb7c21602efcad0b0600000000644165055190a603abda2e36578ddc571937521f2aaff4fecee1a3294de773d13a12ab77ed9e2962643e4dd3334f1700bcd7d22e4f10b1db4b4947fa60de87bae6e5412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000044efbf7d2c8b85933138535436e3c321ed169b271dd8c0ef42fb7c21602efcad0b06600864656d6f2e70327076a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000a66a4ca3424e53317b226f223a22524547222c226e223a2264656d6f2e703270222c2272223a7b2268223a223c68313e64656d6f2e7032703c2f68313e3c703e41206e616d65206f6e2061204e455720746f702d6c6576656c20646f6d61696e20e280942073616d6520426974636f696e20436173682072656769737472792c20646966666572656e7420544c442e204d756c74692d544c4420776f726b732e3c2f703e227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88acf81e0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":471,"time":1784966713,"txid":"6002f520e0de398e8541a70097739a7d36640456216391341628b10974be4610","version":2,"vin":[{"scriptSig":{"asm":"65055190a603abda2e36578ddc571937521f2aaff4fecee1a3294de773d13a12ab77ed9e2962643e4dd3334f1700bcd7d22e4f10b1db4b4947fa60de87bae6e5[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4165055190a603abda2e36578ddc571937521f2aaff4fecee1a3294de773d13a12ab77ed9e2962643e4dd3334f1700bcd7d22e4f10b1db4b4947fa60de87bae6e5412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"060badfc2e60217cfb42efc0d81d279b16ed21c3e3365453383193858b2c7dbf","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"060badfc2e60217cfb42efc0d81d279b16ed21c3e3365453383193858b2c7dbf","nft":{"capability":"none","commitment":"64656d6f2e703270"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2264656d6f2e703270222c2272223a7b2268223a223c68313e64656d6f2e7032703c2f68313e3c703e41206e616d65206f6e2061204e455720746f702d6c6576656c20646f6d61696e20e280942073616d6520426974636f696e20436173682072656769737472792c20646966666572656e7420544c442e204d756c74692d544c4420776f726b732e3c2f703e227d7d","hex":"6a4ca3424e53317b226f223a22524547222c226e223a2264656d6f2e703270222c2272223a7b2268223a223c68313e64656d6f2e7032703c2f68313e3c703e41206e616d65206f6e2061204e455720746f702d6c6576656c20646f6d61696e20e280942073616d6520426974636f696e20436173682072656769737472792c20646966666572656e7420544c442e204d756c74692d544c4420776f726b732e3c2f703e227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00007928}]},"628501df5bd89b994a956119edabe30d161a575a53107aa936962287e37e6429":{"blockhash":"000000003a6f334b110b24d5ae4ded168b649c1699ced1c4e9bfc3915dba4dad","blocktime":1785083158,"confirmations":5189,"hash":"628501df5bd89b994a956119edabe30d161a575a53107aa936962287e37e6429","hex":"02000000010597af44f3e3ed826f449c49e3f74fa43d76b2473de63c679e25d26fc5db293b00000000644100a7fc7b8e0d81c890f1d4c11fd34e96e1a56151b2ed540509f5c76c17587c435611be90ecace251f11b15bf79d4250b5b01f377fd09949747483ec1112ac429412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000046ef0597af44f3e3ed826f449c49e3f74fa43d76b2473de63c679e25d26fc5db293b600a7379736b79706f2e646576a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000886a4c85424e53317b226f223a22524547222c226e223a227379736b79706f2e6465222c2272223a7b2268223a223c68313e7379736b79706f2e646520e28094206c697665206f6e2042434e523c2f68313e3c703e4120646563656e7472616c697a6564206e616d6520756e64657220746865202e6465206e616d6573706163652e3c2f703e227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac141f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":443,"time":1785083158,"txid":"628501df5bd89b994a956119edabe30d161a575a53107aa936962287e37e6429","version":2,"vin":[{"scriptSig":{"asm":"00a7fc7b8e0d81c890f1d4c11fd34e96e1a56151b2ed540509f5c76c17587c435611be90ecace251f11b15bf79d4250b5b01f377fd09949747483ec1112ac429[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4100a7fc7b8e0d81c890f1d4c11fd34e96e1a56151b2ed540509f5c76c17587c435611be90ecace251f11b15bf79d4250b5b01f377fd09949747483ec1112ac429412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"3b29dbc56fd2259e673ce63d47b2763da44ff7e3499c446f82ede3f344af9705","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"3b29dbc56fd2259e673ce63d47b2763da44ff7e3499c446f82ede3f344af9705","nft":{"capability":"none","commitment":"7379736b79706f2e6465"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a227379736b79706f2e6465222c2272223a7b2268223a223c68313e7379736b79706f2e646520e28094206c697665206f6e2042434e523c2f68313e3c703e4120646563656e7472616c697a6564206e616d6520756e64657220746865202e6465206e616d6573706163652e3c2f703e227d7d","hex":"6a4c85424e53317b226f223a22524547222c226e223a227379736b79706f2e6465222c2272223a7b2268223a223c68313e7379736b79706f2e646520e28094206c697665206f6e2042434e523c2f68313e3c703e4120646563656e7472616c697a6564206e616d6520756e64657220746865202e6465206e616d6573706163652e3c2f703e227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00007956}]},"037dc3dbbb6c823f8d554b75450975d81688aad80444679d823e2a9bd9d58ed2":{"blockhash":"000000001da098446e8c5084c7476c556f35488d68ff2bc0255e614d2d0b8972","blocktime":1785084563,"confirmations":5187,"hash":"037dc3dbbb6c823f8d554b75450975d81688aad80444679d823e2a9bd9d58ed2","hex":"02000000011037182a76836662ad9fa7be8e8b10e97010e3df439af15bbace802ff28bbf0c0000000064418e67b17a56ff1c0adb0e21122f54bd45d1ebb9afe9dcd82694bff1958fb32ac93b1a5875181d3528fc953c846926969ba1091a87873be8805aea018102303901412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000049ef1037182a76836662ad9fa7be8e8b10e97010e3df439af15bbace802ff28bbf0c600d706f7574616b696469732e646576a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000456a43424e53317b226f223a22524547222c226e223a22706f7574616b696469732e6465222c2272223a7b227333223a22626e732f706f7574616b696469732e64652f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac541f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":379,"time":1785084563,"txid":"037dc3dbbb6c823f8d554b75450975d81688aad80444679d823e2a9bd9d58ed2","version":2,"vin":[{"scriptSig":{"asm":"8e67b17a56ff1c0adb0e21122f54bd45d1ebb9afe9dcd82694bff1958fb32ac93b1a5875181d3528fc953c846926969ba1091a87873be8805aea018102303901[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"418e67b17a56ff1c0adb0e21122f54bd45d1ebb9afe9dcd82694bff1958fb32ac93b1a5875181d3528fc953c846926969ba1091a87873be8805aea018102303901412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"0cbf8bf22f80ceba5bf19a43dfe31070e9108b8ebea79fad626683762a183710","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"0cbf8bf22f80ceba5bf19a43dfe31070e9108b8ebea79fad626683762a183710","nft":{"capability":"none","commitment":"706f7574616b696469732e6465"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22706f7574616b696469732e6465222c2272223a7b227333223a22626e732f706f7574616b696469732e64652f227d7d","hex":"6a43424e53317b226f223a22524547222c226e223a22706f7574616b696469732e6465222c2272223a7b227333223a22626e732f706f7574616b696469732e64652f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.0000802}]},"969c0891d0ca85c37bbbf5b06436905cceda9b6e941e950ff4463d20dc116772":{"blockhash":"000000001da098446e8c5084c7476c556f35488d68ff2bc0255e614d2d0b8972","blocktime":1785084563,"confirmations":5187,"hash":"969c0891d0ca85c37bbbf5b06436905cceda9b6e941e950ff4463d20dc116772","hex":"020000000229647ee387229636a97a10535a571a160de3abed1961954a999bd85bdf018562000000006441960d950f5154f58b2384bf1d85ead0ce4a7d01fd9f7a57c971c342fef711f1e2027adc480d6c68989de8385b60abefddab680902eae6671766fccbea35f666bf412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d00000000bf7d2c8b85933138535436e3c321ed169b271dd8c0ef42fb7c21602efcad0b0601000000644152228a9a72cc1dfdb3423dd417fccc06deca3b55ceb9f0a37ee7bf93480900f3d55f432482712e24db51145ceb09661bca02d7f89a448cd499b8afdb88a8e2f6412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000046ef0597af44f3e3ed826f449c49e3f74fa43d76b2473de63c679e25d26fc5db293b600a7379736b79706f2e646576a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000003f6a3d424e53317b226f223a22555044222c226e223a227379736b79706f2e6465222c2272223a7b227333223a22626e732f7379736b79706f2e64652f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac0e080000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":511,"time":1785084563,"txid":"969c0891d0ca85c37bbbf5b06436905cceda9b6e941e950ff4463d20dc116772","version":2,"vin":[{"scriptSig":{"asm":"960d950f5154f58b2384bf1d85ead0ce4a7d01fd9f7a57c971c342fef711f1e2027adc480d6c68989de8385b60abefddab680902eae6671766fccbea35f666bf[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41960d950f5154f58b2384bf1d85ead0ce4a7d01fd9f7a57c971c342fef711f1e2027adc480d6c68989de8385b60abefddab680902eae6671766fccbea35f666bf412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"628501df5bd89b994a956119edabe30d161a575a53107aa936962287e37e6429","vout":0},{"scriptSig":{"asm":"52228a9a72cc1dfdb3423dd417fccc06deca3b55ceb9f0a37ee7bf93480900f3d55f432482712e24db51145ceb09661bca02d7f89a448cd499b8afdb88a8e2f6[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4152228a9a72cc1dfdb3423dd417fccc06deca3b55ceb9f0a37ee7bf93480900f3d55f432482712e24db51145ceb09661bca02d7f89a448cd499b8afdb88a8e2f6412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"060badfc2e60217cfb42efc0d81d279b16ed21c3e3365453383193858b2c7dbf","vout":1}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"3b29dbc56fd2259e673ce63d47b2763da44ff7e3499c446f82ede3f344af9705","nft":{"capability":"none","commitment":"7379736b79706f2e6465"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a227379736b79706f2e6465222c2272223a7b227333223a22626e732f7379736b79706f2e64652f227d7d","hex":"6a3d424e53317b226f223a22555044222c226e223a227379736b79706f2e6465222c2272223a7b227333223a22626e732f7379736b79706f2e64652f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00002062}]},"674def31f04925e5cc42337c811329136b08ab68692a10118cbcd5933e9a49df":{"blockhash":"000000000acff2e43131dfcfbaa06e915bcb17a4b1c068f0109063dc5015ed14","blocktime":1785091564,"confirmations":5175,"hash":"674def31f04925e5cc42337c811329136b08ab68692a10118cbcd5933e9a49df","hex":"02000000013bdda3709095480eae2da19e2a7643fa48598bdb2bb1c49e34b692e7ccd5076c000000006441899b9ea235c5287bd60de4ad0661efff93c2da90964d90cf1271be190c2dfdbeb268fd3acad09c668ddc5a56d3cf8877cf8718b8227d1c784e9d1697c8c19fdc412102e0c5a8d0936ebf7baee293a8adeec11511858f9862989f07b57e914c91880f6cfeffffff05e3760100000000001976a9143789c7d10fef21b214d8e0ee332c6b321373673588ace8030000000000004def3bdda3709095480eae2da19e2a7643fa48598bdb2bb1c49e34b692e7ccd5076c6011736d746573742d6463353536332e70327076a9143789c7d10fef21b214d8e0ee332c6b321373673588ac0000000000000000426a40424e53317b226f223a22524547222c226e223a22736d746573742d6463353536332e703270222c2272223a7b226970223a223230332e302e3131332e39227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac10270000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":414,"time":1785091564,"txid":"674def31f04925e5cc42337c811329136b08ab68692a10118cbcd5933e9a49df","version":2,"vin":[{"scriptSig":{"asm":"899b9ea235c5287bd60de4ad0661efff93c2da90964d90cf1271be190c2dfdbeb268fd3acad09c668ddc5a56d3cf8877cf8718b8227d1c784e9d1697c8c19fdc[ALL|FORKID] 02e0c5a8d0936ebf7baee293a8adeec11511858f9862989f07b57e914c91880f6c","hex":"41899b9ea235c5287bd60de4ad0661efff93c2da90964d90cf1271be190c2dfdbeb268fd3acad09c668ddc5a56d3cf8877cf8718b8227d1c784e9d1697c8c19fdc412102e0c5a8d0936ebf7baee293a8adeec11511858f9862989f07b57e914c91880f6c"},"sequence":4294967294,"txid":"6c07d5cce792b6349ec4b12bdb8b5948fa43762a9ea12dae0e48959070a3dd3b","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qqmcn373plhjrvs5mrswuvevdvepxum8x58jhawqxs"],"asm":"OP_DUP OP_HASH160 3789c7d10fef21b214d8e0ee332c6b3213736735 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9143789c7d10fef21b214d8e0ee332c6b321373673588ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00095971},{"n":1,"scriptPubKey":{"addresses":["bchtest:qqmcn373plhjrvs5mrswuvevdvepxum8x58jhawqxs"],"asm":"OP_DUP OP_HASH160 3789c7d10fef21b214d8e0ee332c6b3213736735 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9143789c7d10fef21b214d8e0ee332c6b321373673588ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"6c07d5cce792b6349ec4b12bdb8b5948fa43762a9ea12dae0e48959070a3dd3b","nft":{"capability":"none","commitment":"736d746573742d6463353536332e703270"}},"value":0.00001},{"n":2,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22736d746573742d6463353536332e703270222c2272223a7b226970223a223230332e302e3131332e39227d7d","hex":"6a40424e53317b226f223a22524547222c226e223a22736d746573742d6463353536332e703270222c2272223a7b226970223a223230332e302e3131332e39227d7d","type":"nulldata"},"value":0},{"n":3,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":4,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.0001}]},"6c07d5cce792b6349ec4b12bdb8b5948fa43762a9ea12dae0e48959070a3dd3b":{"blockhash":"000000000acff2e43131dfcfbaa06e915bcb17a4b1c068f0109063dc5015ed14","blocktime":1785091564,"confirmations":5175,"hash":"6c07d5cce792b6349ec4b12bdb8b5948fa43762a9ea12dae0e48959070a3dd3b","hex":"0200000001cd95149d6f87ee0097632343b33af5a7017c5e5b42a1cf93445ab19699405f5f000000006441978eeeb09c4e4c509862840481d29d852ad1d1bbd6f817cc558b79da93ac895b943bbbc9398f66a8d5bb3354bbc3952ccfe6efd6faf2b01b6a41691f8d35684d412102e0c5a8d0936ebf7baee293a8adeec11511858f9862989f07b57e914c91880f6cfeffffff05d1a50100000000001976a9143789c7d10fef21b214d8e0ee332c6b321373673588ace80300000000000049efcd95149d6f87ee0097632343b33af5a7017c5e5b42a1cf93445ab19699405f5f600d736d746573742d64633535363376a9143789c7d10fef21b214d8e0ee332c6b321373673588ac0000000000000000476a45424e53317b226f223a22524547222c226e223a22736d746573742d646335353633222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e7374227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac10270000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":415,"time":1785091564,"txid":"6c07d5cce792b6349ec4b12bdb8b5948fa43762a9ea12dae0e48959070a3dd3b","version":2,"vin":[{"scriptSig":{"asm":"978eeeb09c4e4c509862840481d29d852ad1d1bbd6f817cc558b79da93ac895b943bbbc9398f66a8d5bb3354bbc3952ccfe6efd6faf2b01b6a41691f8d35684d[ALL|FORKID] 02e0c5a8d0936ebf7baee293a8adeec11511858f9862989f07b57e914c91880f6c","hex":"41978eeeb09c4e4c509862840481d29d852ad1d1bbd6f817cc558b79da93ac895b943bbbc9398f66a8d5bb3354bbc3952ccfe6efd6faf2b01b6a41691f8d35684d412102e0c5a8d0936ebf7baee293a8adeec11511858f9862989f07b57e914c91880f6c"},"sequence":4294967294,"txid":"5f5f409996b15a4493cfa1425b5e7c01a7f53ab34323639700ee876f9d1495cd","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qqmcn373plhjrvs5mrswuvevdvepxum8x58jhawqxs"],"asm":"OP_DUP OP_HASH160 3789c7d10fef21b214d8e0ee332c6b3213736735 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9143789c7d10fef21b214d8e0ee332c6b321373673588ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00107985},{"n":1,"scriptPubKey":{"addresses":["bchtest:qqmcn373plhjrvs5mrswuvevdvepxum8x58jhawqxs"],"asm":"OP_DUP OP_HASH160 3789c7d10fef21b214d8e0ee332c6b3213736735 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9143789c7d10fef21b214d8e0ee332c6b321373673588ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"5f5f409996b15a4493cfa1425b5e7c01a7f53ab34323639700ee876f9d1495cd","nft":{"capability":"none","commitment":"736d746573742d646335353633"}},"value":0.00001},{"n":2,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22736d746573742d646335353633222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e7374227d7d","hex":"6a45424e53317b226f223a22524547222c226e223a22736d746573742d646335353633222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e7374227d7d","type":"nulldata"},"value":0},{"n":3,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":4,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.0001}]},"8da2cc4ad2d49a94d964566518cf1c24e6de4e83761aa3557df8669348f0e052":{"blockhash":"000000000acff2e43131dfcfbaa06e915bcb17a4b1c068f0109063dc5015ed14","blocktime":1785091564,"confirmations":5175,"hash":"8da2cc4ad2d49a94d964566518cf1c24e6de4e83761aa3557df8669348f0e052","hex":"0200000002459c02206a3ae6b21e903fa3a1dd89442a2e6bf0788cb811adc49b7a063c24ca010000006441d2d1da11d562d98c3d5784f5b98c4c9a73820f577b85195370bee3399cee2447bf0e9e29939c418608c6abd3dea046c04e15584fc76af4a90d12f4f94b5803c541210396ee3627796f80ae449adc144d22a72cf7b50be2bc80b67afc2a7b0df02eb997feffffff459c02206a3ae6b21e903fa3a1dd89442a2e6bf0788cb811adc49b7a063c24ca000000006441569b0491d1cebdfc2ba97a4cae7622e3654fbcd13d04cbfd35da49ae3ccf16c45970a45a128690a074f5cb71937bfb459f4ef7af5147df26126c1f7c5d37a5dd41210396ee3627796f80ae449adc144d22a72cf7b50be2bc80b67afc2a7b0df02eb997feffffff0470720100000000001976a9146deb153cb494c0f83814a68bd472d0f9d7c30d6288ace8030000000000004def531ddc048c7ee0cd9be159bba22ad20f9e493109a76ade5766950d4bace890e36011736d746573742d6662653863642e70327076a9146deb153cb494c0f83814a68bd472d0f9d7c30d6288ac0000000000000000546a4c51424e53317b226f223a22555044222c226e223a22736d746573742d6662653863642e703270222c2272223a7b2268223a223c68313e68656c6c6f2066726f6d2074686520636861696e3c2f68313e227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac00000000","locktime":0,"size":539,"time":1785091564,"txid":"8da2cc4ad2d49a94d964566518cf1c24e6de4e83761aa3557df8669348f0e052","version":2,"vin":[{"scriptSig":{"asm":"d2d1da11d562d98c3d5784f5b98c4c9a73820f577b85195370bee3399cee2447bf0e9e29939c418608c6abd3dea046c04e15584fc76af4a90d12f4f94b5803c5[ALL|FORKID] 0396ee3627796f80ae449adc144d22a72cf7b50be2bc80b67afc2a7b0df02eb997","hex":"41d2d1da11d562d98c3d5784f5b98c4c9a73820f577b85195370bee3399cee2447bf0e9e29939c418608c6abd3dea046c04e15584fc76af4a90d12f4f94b5803c541210396ee3627796f80ae449adc144d22a72cf7b50be2bc80b67afc2a7b0df02eb997"},"sequence":4294967294,"txid":"ca243c067a9bc4ad11b88c78f06b2e2a4489dda1a33f901eb2e63a6a20029c45","vout":1},{"scriptSig":{"asm":"569b0491d1cebdfc2ba97a4cae7622e3654fbcd13d04cbfd35da49ae3ccf16c45970a45a128690a074f5cb71937bfb459f4ef7af5147df26126c1f7c5d37a5dd[ALL|FORKID] 0396ee3627796f80ae449adc144d22a72cf7b50be2bc80b67afc2a7b0df02eb997","hex":"41569b0491d1cebdfc2ba97a4cae7622e3654fbcd13d04cbfd35da49ae3ccf16c45970a45a128690a074f5cb71937bfb459f4ef7af5147df26126c1f7c5d37a5dd41210396ee3627796f80ae449adc144d22a72cf7b50be2bc80b67afc2a7b0df02eb997"},"sequence":4294967294,"txid":"ca243c067a9bc4ad11b88c78f06b2e2a4489dda1a33f901eb2e63a6a20029c45","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qpk7k9fukj2vp7pczjngh4rj6rua0scdvgwfm9lu3d"],"asm":"OP_DUP OP_HASH160 6deb153cb494c0f83814a68bd472d0f9d7c30d62 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9146deb153cb494c0f83814a68bd472d0f9d7c30d6288ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00094832},{"n":1,"scriptPubKey":{"addresses":["bchtest:qpk7k9fukj2vp7pczjngh4rj6rua0scdvgwfm9lu3d"],"asm":"OP_DUP OP_HASH160 6deb153cb494c0f83814a68bd472d0f9d7c30d62 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9146deb153cb494c0f83814a68bd472d0f9d7c30d6288ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"e390e8ac4b0d956657de6aa70931499e0fd22aa2bb59e19bcde07e8c04dc1d53","nft":{"capability":"none","commitment":"736d746573742d6662653863642e703270"}},"value":0.00001},{"n":2,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a22736d746573742d6662653863642e703270222c2272223a7b2268223a223c68313e68656c6c6f2066726f6d2074686520636861696e3c2f68313e227d7d","hex":"6a4c51424e53317b226f223a22555044222c226e223a22736d746573742d6662653863642e703270222c2272223a7b2268223a223c68313e68656c6c6f2066726f6d2074686520636861696e3c2f68313e227d7d","type":"nulldata"},"value":0},{"n":3,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006}]},"96ae5a51193b71ed92e5f3c0fba50b0b407ec7e40908cbe1986e08e8b16f90e7":{"blockhash":"000000000acff2e43131dfcfbaa06e915bcb17a4b1c068f0109063dc5015ed14","blocktime":1785091564,"confirmations":5175,"hash":"96ae5a51193b71ed92e5f3c0fba50b0b407ec7e40908cbe1986e08e8b16f90e7","hex":"0200000002df499a3e93d5bc8c11102a6968ab086b132913817c3342cce52549f031ef4d670100000064418cd073912e544393e1237a8fbf30f0d6b40a97e9b480598eef83537f2e7946641331f45985dadb58cca783327f9cc8497aabc8d88342b7eee765be394d9b1b15412102e0c5a8d0936ebf7baee293a8adeec11511858f9862989f07b57e914c91880f6cfeffffffdf499a3e93d5bc8c11102a6968ab086b132913817c3342cce52549f031ef4d67000000006441810a481525bca4d6df63d81c51bff6b795f6aee178e845dfe7f65fd2263b8d60163234a78ff6d94eb6db4eeef3a70d34b18a82dc09d4da9d58553967ae5c7dc7412102e0c5a8d0936ebf7baee293a8adeec11511858f9862989f07b57e914c91880f6cfeffffff0470720100000000001976a9143789c7d10fef21b214d8e0ee332c6b321373673588ace8030000000000004def3bdda3709095480eae2da19e2a7643fa48598bdb2bb1c49e34b692e7ccd5076c6011736d746573742d6463353536332e70327076a9143789c7d10fef21b214d8e0ee332c6b321373673588ac0000000000000000546a4c51424e53317b226f223a22555044222c226e223a22736d746573742d6463353536332e703270222c2272223a7b2268223a223c68313e68656c6c6f2066726f6d2074686520636861696e3c2f68313e227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac00000000","locktime":0,"size":539,"time":1785091564,"txid":"96ae5a51193b71ed92e5f3c0fba50b0b407ec7e40908cbe1986e08e8b16f90e7","version":2,"vin":[{"scriptSig":{"asm":"8cd073912e544393e1237a8fbf30f0d6b40a97e9b480598eef83537f2e7946641331f45985dadb58cca783327f9cc8497aabc8d88342b7eee765be394d9b1b15[ALL|FORKID] 02e0c5a8d0936ebf7baee293a8adeec11511858f9862989f07b57e914c91880f6c","hex":"418cd073912e544393e1237a8fbf30f0d6b40a97e9b480598eef83537f2e7946641331f45985dadb58cca783327f9cc8497aabc8d88342b7eee765be394d9b1b15412102e0c5a8d0936ebf7baee293a8adeec11511858f9862989f07b57e914c91880f6c"},"sequence":4294967294,"txid":"674def31f04925e5cc42337c811329136b08ab68692a10118cbcd5933e9a49df","vout":1},{"scriptSig":{"asm":"810a481525bca4d6df63d81c51bff6b795f6aee178e845dfe7f65fd2263b8d60163234a78ff6d94eb6db4eeef3a70d34b18a82dc09d4da9d58553967ae5c7dc7[ALL|FORKID] 02e0c5a8d0936ebf7baee293a8adeec11511858f9862989f07b57e914c91880f6c","hex":"41810a481525bca4d6df63d81c51bff6b795f6aee178e845dfe7f65fd2263b8d60163234a78ff6d94eb6db4eeef3a70d34b18a82dc09d4da9d58553967ae5c7dc7412102e0c5a8d0936ebf7baee293a8adeec11511858f9862989f07b57e914c91880f6c"},"sequence":4294967294,"txid":"674def31f04925e5cc42337c811329136b08ab68692a10118cbcd5933e9a49df","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qqmcn373plhjrvs5mrswuvevdvepxum8x58jhawqxs"],"asm":"OP_DUP OP_HASH160 3789c7d10fef21b214d8e0ee332c6b3213736735 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9143789c7d10fef21b214d8e0ee332c6b321373673588ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00094832},{"n":1,"scriptPubKey":{"addresses":["bchtest:qqmcn373plhjrvs5mrswuvevdvepxum8x58jhawqxs"],"asm":"OP_DUP OP_HASH160 3789c7d10fef21b214d8e0ee332c6b3213736735 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9143789c7d10fef21b214d8e0ee332c6b321373673588ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"6c07d5cce792b6349ec4b12bdb8b5948fa43762a9ea12dae0e48959070a3dd3b","nft":{"capability":"none","commitment":"736d746573742d6463353536332e703270"}},"value":0.00001},{"n":2,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a22736d746573742d6463353536332e703270222c2272223a7b2268223a223c68313e68656c6c6f2066726f6d2074686520636861696e3c2f68313e227d7d","hex":"6a4c51424e53317b226f223a22555044222c226e223a22736d746573742d6463353536332e703270222c2272223a7b2268223a223c68313e68656c6c6f2066726f6d2074686520636861696e3c2f68313e227d7d","type":"nulldata"},"value":0},{"n":3,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006}]},"ca243c067a9bc4ad11b88c78f06b2e2a4489dda1a33f901eb2e63a6a20029c45":{"blockhash":"000000000acff2e43131dfcfbaa06e915bcb17a4b1c068f0109063dc5015ed14","blocktime":1785091564,"confirmations":5175,"hash":"ca243c067a9bc4ad11b88c78f06b2e2a4489dda1a33f901eb2e63a6a20029c45","hex":"0200000001531ddc048c7ee0cd9be159bba22ad20f9e493109a76ade5766950d4bace890e3000000006441782be56becca19981b0127053d6de505b33088788b82431ad62ccce82f72f1bad2cc56df65e33201b6572be0703970b61627a5d10117b6030719d8aac9b34c5d41210396ee3627796f80ae449adc144d22a72cf7b50be2bc80b67afc2a7b0df02eb997feffffff05e3760100000000001976a9146deb153cb494c0f83814a68bd472d0f9d7c30d6288ace8030000000000004def531ddc048c7ee0cd9be159bba22ad20f9e493109a76ade5766950d4bace890e36011736d746573742d6662653863642e70327076a9146deb153cb494c0f83814a68bd472d0f9d7c30d6288ac0000000000000000426a40424e53317b226f223a22524547222c226e223a22736d746573742d6662653863642e703270222c2272223a7b226970223a223230332e302e3131332e39227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac10270000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":414,"time":1785091564,"txid":"ca243c067a9bc4ad11b88c78f06b2e2a4489dda1a33f901eb2e63a6a20029c45","version":2,"vin":[{"scriptSig":{"asm":"782be56becca19981b0127053d6de505b33088788b82431ad62ccce82f72f1bad2cc56df65e33201b6572be0703970b61627a5d10117b6030719d8aac9b34c5d[ALL|FORKID] 0396ee3627796f80ae449adc144d22a72cf7b50be2bc80b67afc2a7b0df02eb997","hex":"41782be56becca19981b0127053d6de505b33088788b82431ad62ccce82f72f1bad2cc56df65e33201b6572be0703970b61627a5d10117b6030719d8aac9b34c5d41210396ee3627796f80ae449adc144d22a72cf7b50be2bc80b67afc2a7b0df02eb997"},"sequence":4294967294,"txid":"e390e8ac4b0d956657de6aa70931499e0fd22aa2bb59e19bcde07e8c04dc1d53","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qpk7k9fukj2vp7pczjngh4rj6rua0scdvgwfm9lu3d"],"asm":"OP_DUP OP_HASH160 6deb153cb494c0f83814a68bd472d0f9d7c30d62 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9146deb153cb494c0f83814a68bd472d0f9d7c30d6288ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00095971},{"n":1,"scriptPubKey":{"addresses":["bchtest:qpk7k9fukj2vp7pczjngh4rj6rua0scdvgwfm9lu3d"],"asm":"OP_DUP OP_HASH160 6deb153cb494c0f83814a68bd472d0f9d7c30d62 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9146deb153cb494c0f83814a68bd472d0f9d7c30d6288ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"e390e8ac4b0d956657de6aa70931499e0fd22aa2bb59e19bcde07e8c04dc1d53","nft":{"capability":"none","commitment":"736d746573742d6662653863642e703270"}},"value":0.00001},{"n":2,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22736d746573742d6662653863642e703270222c2272223a7b226970223a223230332e302e3131332e39227d7d","hex":"6a40424e53317b226f223a22524547222c226e223a22736d746573742d6662653863642e703270222c2272223a7b226970223a223230332e302e3131332e39227d7d","type":"nulldata"},"value":0},{"n":3,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":4,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.0001}]},"e390e8ac4b0d956657de6aa70931499e0fd22aa2bb59e19bcde07e8c04dc1d53":{"blockhash":"000000000acff2e43131dfcfbaa06e915bcb17a4b1c068f0109063dc5015ed14","blocktime":1785091564,"confirmations":5175,"hash":"e390e8ac4b0d956657de6aa70931499e0fd22aa2bb59e19bcde07e8c04dc1d53","hex":"02000000019213a7f0ac5629efc3bbbbb4a4358189ac5deab48346ea4afc29244aa7d13fa6000000006441fde72be63ddaa57f0123677a31a7814bc7247bbea66de8f911da1c251ee98cf7287c6c094588c7f8ec549cbdefd84e628d89cccef46b261101fd8dbc73582baf41210396ee3627796f80ae449adc144d22a72cf7b50be2bc80b67afc2a7b0df02eb997feffffff05d1a50100000000001976a9146deb153cb494c0f83814a68bd472d0f9d7c30d6288ace80300000000000049ef9213a7f0ac5629efc3bbbbb4a4358189ac5deab48346ea4afc29244aa7d13fa6600d736d746573742d66626538636476a9146deb153cb494c0f83814a68bd472d0f9d7c30d6288ac0000000000000000476a45424e53317b226f223a22524547222c226e223a22736d746573742d666265386364222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e7374227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac10270000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":415,"time":1785091564,"txid":"e390e8ac4b0d956657de6aa70931499e0fd22aa2bb59e19bcde07e8c04dc1d53","version":2,"vin":[{"scriptSig":{"asm":"fde72be63ddaa57f0123677a31a7814bc7247bbea66de8f911da1c251ee98cf7287c6c094588c7f8ec549cbdefd84e628d89cccef46b261101fd8dbc73582baf[ALL|FORKID] 0396ee3627796f80ae449adc144d22a72cf7b50be2bc80b67afc2a7b0df02eb997","hex":"41fde72be63ddaa57f0123677a31a7814bc7247bbea66de8f911da1c251ee98cf7287c6c094588c7f8ec549cbdefd84e628d89cccef46b261101fd8dbc73582baf41210396ee3627796f80ae449adc144d22a72cf7b50be2bc80b67afc2a7b0df02eb997"},"sequence":4294967294,"txid":"a63fd1a74a2429fc4aea4683b4ea5dac898135a4b4bbbbc3ef2956acf0a71392","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qpk7k9fukj2vp7pczjngh4rj6rua0scdvgwfm9lu3d"],"asm":"OP_DUP OP_HASH160 6deb153cb494c0f83814a68bd472d0f9d7c30d62 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9146deb153cb494c0f83814a68bd472d0f9d7c30d6288ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00107985},{"n":1,"scriptPubKey":{"addresses":["bchtest:qpk7k9fukj2vp7pczjngh4rj6rua0scdvgwfm9lu3d"],"asm":"OP_DUP OP_HASH160 6deb153cb494c0f83814a68bd472d0f9d7c30d62 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9146deb153cb494c0f83814a68bd472d0f9d7c30d6288ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"a63fd1a74a2429fc4aea4683b4ea5dac898135a4b4bbbbc3ef2956acf0a71392","nft":{"capability":"none","commitment":"736d746573742d666265386364"}},"value":0.00001},{"n":2,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22736d746573742d666265386364222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e7374227d7d","hex":"6a45424e53317b226f223a22524547222c226e223a22736d746573742d666265386364222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e7374227d7d","type":"nulldata"},"value":0},{"n":3,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":4,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.0001}]},"a80045e865967b3abff7a764a0fe16304b9c919605ccaf925f5d9f41f3a2d50a":{"blockhash":"00000000b002d4f82b902d04a2f35d363b9397da1a8df02a19406503bfd60af4","blocktime":1785094226,"confirmations":5173,"hash":"a80045e865967b3abff7a764a0fe16304b9c919605ccaf925f5d9f41f3a2d50a","hex":"02000000028f8370ca07bbb0e8779acb12b57102ad2da96fa245fb71a3f805bfb319ed3983000000006441a9e45293390b9fe3c960b34e7fe35ad175310d93effccd36285407d2d5aea2e0cdeffd94d83db24ebdd13bb41c2f15ac966f53b163e764da5f8c0d1a4b097741412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d00000000cd95149d6f87ee0097632343b33af5a7017c5e5b42a1cf93445ab19699405f5f0100000064419db358beb621903e205a9bcc805e11b0ff0edffa4edf58168663f0b67a3b04f18b5ef66d245154e0170714ca126dd8c4b2fff361eec2a38af59d1699f7315171412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000041ef8dfbc018f26b604046cd55fcb4cb98b70df277d0bef2809ff75cc47ca853956d600568656c6c6f76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000356a33424e53317b226f223a22555044222c226e223a2268656c6c6f222c2272223a7b227333223a22626e732f68656c6c6f2f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88acfd560b00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":496,"time":1785094226,"txid":"a80045e865967b3abff7a764a0fe16304b9c919605ccaf925f5d9f41f3a2d50a","version":2,"vin":[{"scriptSig":{"asm":"a9e45293390b9fe3c960b34e7fe35ad175310d93effccd36285407d2d5aea2e0cdeffd94d83db24ebdd13bb41c2f15ac966f53b163e764da5f8c0d1a4b097741[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41a9e45293390b9fe3c960b34e7fe35ad175310d93effccd36285407d2d5aea2e0cdeffd94d83db24ebdd13bb41c2f15ac966f53b163e764da5f8c0d1a4b097741412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"8339ed19b3bf05f8a371fb45a26fa92dad0271b512cb9a77e8b0bb07ca70838f","vout":0},{"scriptSig":{"asm":"9db358beb621903e205a9bcc805e11b0ff0edffa4edf58168663f0b67a3b04f18b5ef66d245154e0170714ca126dd8c4b2fff361eec2a38af59d1699f7315171[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"419db358beb621903e205a9bcc805e11b0ff0edffa4edf58168663f0b67a3b04f18b5ef66d245154e0170714ca126dd8c4b2fff361eec2a38af59d1699f7315171412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"5f5f409996b15a4493cfa1425b5e7c01a7f53ab34323639700ee876f9d1495cd","vout":1}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"6d9553a87cc45cf79f80f2bed077f20db798cbb4fc55cd4640606bf218c0fb8d","nft":{"capability":"none","commitment":"68656c6c6f"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a2268656c6c6f222c2272223a7b227333223a22626e732f68656c6c6f2f227d7d","hex":"6a33424e53317b226f223a22555044222c226e223a2268656c6c6f222c2272223a7b227333223a22626e732f68656c6c6f2f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00743165}]},"28007bd8d1c54967e17e6eba5fe2fe0daa481be8a48cf39177a8d9e82feed201":{"blockhash":"0000000006f1943e4e9cad915ebbbf668c33e31467df45ec0b1d47938bf79bf2","blocktime":1785097347,"confirmations":5167,"hash":"28007bd8d1c54967e17e6eba5fe2fe0daa481be8a48cf39177a8d9e82feed201","hex":"0200000002ac72e606cda1089776f72cbaf686da90ee4e1c28ef8d63dc155990a2a1b4473901000000644157f7060d7d316d038993db403800702ae73940a2bbed9d29be3e33f42eae02cdcbb64c535fd2b6a29edb18c500544721aa02dee8e39d449476ebd77aaa77d102412103eda7646fc05ab86f86d604691c273cd99c245ad78a43e8f4ea58204a579c0463feffffffac72e606cda1089776f72cbaf686da90ee4e1c28ef8d63dc155990a2a1b44739000000006441e7431ae2453d0d1c17db476a560bae0cef20f20be1b0fa7e109ac3af7cabb6bd45ab592e53a662c0f6cbf67b9045dcabe7ae0d270b90ac8ada914dbc746921c8412103eda7646fc05ab86f86d604691c273cd99c245ad78a43e8f4ea58204a579c0463feffffff040eb70000000000001976a9141ba8103e229f4554f6e3c4756e4d4cb5f55189b788ace8030000000000004def42de6152f115ff51813844c57909b12d8efbfc47a636d06b0740ae113306ece960117569746573742d6532303537392e70327076a9141ba8103e229f4554f6e3c4756e4d4cb5f55189b788ac0000000000000000576a4c54424e53317b226f223a22555044222c226e223a227569746573742d6532303537392e703270222c2272223a7b2268223a223c68313e6d696e7465642066726f6d207468652062726f777365723c2f68313e227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac00000000","locktime":0,"size":542,"time":1785097347,"txid":"28007bd8d1c54967e17e6eba5fe2fe0daa481be8a48cf39177a8d9e82feed201","version":2,"vin":[{"scriptSig":{"asm":"57f7060d7d316d038993db403800702ae73940a2bbed9d29be3e33f42eae02cdcbb64c535fd2b6a29edb18c500544721aa02dee8e39d449476ebd77aaa77d102[ALL|FORKID] 03eda7646fc05ab86f86d604691c273cd99c245ad78a43e8f4ea58204a579c0463","hex":"4157f7060d7d316d038993db403800702ae73940a2bbed9d29be3e33f42eae02cdcbb64c535fd2b6a29edb18c500544721aa02dee8e39d449476ebd77aaa77d102412103eda7646fc05ab86f86d604691c273cd99c245ad78a43e8f4ea58204a579c0463"},"sequence":4294967294,"txid":"3947b4a1a2905915dc638def281c4eee90da86f6ba2cf7769708a1cd06e672ac","vout":1},{"scriptSig":{"asm":"e7431ae2453d0d1c17db476a560bae0cef20f20be1b0fa7e109ac3af7cabb6bd45ab592e53a662c0f6cbf67b9045dcabe7ae0d270b90ac8ada914dbc746921c8[ALL|FORKID] 03eda7646fc05ab86f86d604691c273cd99c245ad78a43e8f4ea58204a579c0463","hex":"41e7431ae2453d0d1c17db476a560bae0cef20f20be1b0fa7e109ac3af7cabb6bd45ab592e53a662c0f6cbf67b9045dcabe7ae0d270b90ac8ada914dbc746921c8412103eda7646fc05ab86f86d604691c273cd99c245ad78a43e8f4ea58204a579c0463"},"sequence":4294967294,"txid":"3947b4a1a2905915dc638def281c4eee90da86f6ba2cf7769708a1cd06e672ac","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qqd6syp7y205248ku0z82mjdfj6l25vfkuplg8ghas"],"asm":"OP_DUP OP_HASH160 1ba8103e229f4554f6e3c4756e4d4cb5f55189b7 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9141ba8103e229f4554f6e3c4756e4d4cb5f55189b788ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00046862},{"n":1,"scriptPubKey":{"addresses":["bchtest:qqd6syp7y205248ku0z82mjdfj6l25vfkuplg8ghas"],"asm":"OP_DUP OP_HASH160 1ba8103e229f4554f6e3c4756e4d4cb5f55189b7 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9141ba8103e229f4554f6e3c4756e4d4cb5f55189b788ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"e9ec063311ae40076bd036a647fcfb8e2db10979c544388151ff15f15261de42","nft":{"capability":"none","commitment":"7569746573742d6532303537392e703270"}},"value":0.00001},{"n":2,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a227569746573742d6532303537392e703270222c2272223a7b2268223a223c68313e6d696e7465642066726f6d207468652062726f777365723c2f68313e227d7d","hex":"6a4c54424e53317b226f223a22555044222c226e223a227569746573742d6532303537392e703270222c2272223a7b2268223a223c68313e6d696e7465642066726f6d207468652062726f777365723c2f68313e227d7d","type":"nulldata"},"value":0},{"n":3,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006}]},"3947b4a1a2905915dc638def281c4eee90da86f6ba2cf7769708a1cd06e672ac":{"blockhash":"0000000006f1943e4e9cad915ebbbf668c33e31467df45ec0b1d47938bf79bf2","blocktime":1785097347,"confirmations":5167,"hash":"3947b4a1a2905915dc638def281c4eee90da86f6ba2cf7769708a1cd06e672ac","hex":"020000000142de6152f115ff51813844c57909b12d8efbfc47a636d06b0740ae113306ece9000000006441061cd245c3bea2c255922e81737f4dc69d656adf143b02d1b651653fe93763c82ce20459d098569049fe85ec2a9c21be82a2a767d6c387b38be23e63f0d73091412103eda7646fc05ab86f86d604691c273cd99c245ad78a43e8f4ea58204a579c0463feffffff0584bb0000000000001976a9141ba8103e229f4554f6e3c4756e4d4cb5f55189b788ace8030000000000004def42de6152f115ff51813844c57909b12d8efbfc47a636d06b0740ae113306ece960117569746573742d6532303537392e70327076a9141ba8103e229f4554f6e3c4756e4d4cb5f55189b788ac0000000000000000306a2e424e53317b226f223a22524547222c226e223a227569746573742d6532303537392e703270222c2272223a7b7d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac10270000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":396,"time":1785097347,"txid":"3947b4a1a2905915dc638def281c4eee90da86f6ba2cf7769708a1cd06e672ac","version":2,"vin":[{"scriptSig":{"asm":"061cd245c3bea2c255922e81737f4dc69d656adf143b02d1b651653fe93763c82ce20459d098569049fe85ec2a9c21be82a2a767d6c387b38be23e63f0d73091[ALL|FORKID] 03eda7646fc05ab86f86d604691c273cd99c245ad78a43e8f4ea58204a579c0463","hex":"41061cd245c3bea2c255922e81737f4dc69d656adf143b02d1b651653fe93763c82ce20459d098569049fe85ec2a9c21be82a2a767d6c387b38be23e63f0d73091412103eda7646fc05ab86f86d604691c273cd99c245ad78a43e8f4ea58204a579c0463"},"sequence":4294967294,"txid":"e9ec063311ae40076bd036a647fcfb8e2db10979c544388151ff15f15261de42","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qqd6syp7y205248ku0z82mjdfj6l25vfkuplg8ghas"],"asm":"OP_DUP OP_HASH160 1ba8103e229f4554f6e3c4756e4d4cb5f55189b7 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9141ba8103e229f4554f6e3c4756e4d4cb5f55189b788ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00048004},{"n":1,"scriptPubKey":{"addresses":["bchtest:qqd6syp7y205248ku0z82mjdfj6l25vfkuplg8ghas"],"asm":"OP_DUP OP_HASH160 1ba8103e229f4554f6e3c4756e4d4cb5f55189b7 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9141ba8103e229f4554f6e3c4756e4d4cb5f55189b788ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"e9ec063311ae40076bd036a647fcfb8e2db10979c544388151ff15f15261de42","nft":{"capability":"none","commitment":"7569746573742d6532303537392e703270"}},"value":0.00001},{"n":2,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a227569746573742d6532303537392e703270222c2272223a7b7d7d","hex":"6a2e424e53317b226f223a22524547222c226e223a227569746573742d6532303537392e703270222c2272223a7b7d7d","type":"nulldata"},"value":0},{"n":3,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":4,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.0001}]},"7076fd08e3115403b55dd14c7b6ac843a2e2c3015931f91c5479185f31f0dc49":{"blockhash":"0000000018188f2563fa684e76f6e05689bcc3031e2f76321f71a6dd3d2aac7f","blocktime":1785106263,"confirmations":5152,"hash":"7076fd08e3115403b55dd14c7b6ac843a2e2c3015931f91c5479185f31f0dc49","hex":"0200000001c36204c94f30822940781f3f5bd1ba0efb0a761ed9df28fc92c0d06324d934b10000000064416cbf538b40f52c7eb508350f2177249a377046c6a893a77c5247be66241955f18f50518ea5efaeac73e5d931fae2fbfeb5ad43c75caec7a260a692988b0da85f412102c542a276d5943344b50aa35904b909637d85160cdab6edbb594ef5126ef568aafeffffff0588bb0000000000001976a91436834fc97ca922dd2de245977115a29367ef724e88ace8030000000000004befc36204c94f30822940781f3f5bd1ba0efb0a761ed9df28fc92c0d06324d934b1600f66696e616c2d39633765362e70327076a91436834fc97ca922dd2de245977115a29367ef724e88ac00000000000000002e6a2c424e53317b226f223a22524547222c226e223a2266696e616c2d39633765362e703270222c2272223a7b7d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac10270000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":392,"time":1785106263,"txid":"7076fd08e3115403b55dd14c7b6ac843a2e2c3015931f91c5479185f31f0dc49","version":2,"vin":[{"scriptSig":{"asm":"6cbf538b40f52c7eb508350f2177249a377046c6a893a77c5247be66241955f18f50518ea5efaeac73e5d931fae2fbfeb5ad43c75caec7a260a692988b0da85f[ALL|FORKID] 02c542a276d5943344b50aa35904b909637d85160cdab6edbb594ef5126ef568aa","hex":"416cbf538b40f52c7eb508350f2177249a377046c6a893a77c5247be66241955f18f50518ea5efaeac73e5d931fae2fbfeb5ad43c75caec7a260a692988b0da85f412102c542a276d5943344b50aa35904b909637d85160cdab6edbb594ef5126ef568aa"},"sequence":4294967294,"txid":"b134d92463d0c092fc28dfd91e760afb0ebad15b3f1f78402982304fc90462c3","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qqmgxn7f0j5j9hfdufzewug452fk0mmjfcrlsn94fd"],"asm":"OP_DUP OP_HASH160 36834fc97ca922dd2de245977115a29367ef724e OP_EQUALVERIFY OP_CHECKSIG","hex":"76a91436834fc97ca922dd2de245977115a29367ef724e88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00048008},{"n":1,"scriptPubKey":{"addresses":["bchtest:qqmgxn7f0j5j9hfdufzewug452fk0mmjfcrlsn94fd"],"asm":"OP_DUP OP_HASH160 36834fc97ca922dd2de245977115a29367ef724e OP_EQUALVERIFY OP_CHECKSIG","hex":"76a91436834fc97ca922dd2de245977115a29367ef724e88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"b134d92463d0c092fc28dfd91e760afb0ebad15b3f1f78402982304fc90462c3","nft":{"capability":"none","commitment":"66696e616c2d39633765362e703270"}},"value":0.00001},{"n":2,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2266696e616c2d39633765362e703270222c2272223a7b7d7d","hex":"6a2c424e53317b226f223a22524547222c226e223a2266696e616c2d39633765362e703270222c2272223a7b7d7d","type":"nulldata"},"value":0},{"n":3,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":4,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.0001}]},"bf43bddd9f163e7ccf0b980cd2ddee0470765dd0ad6f424e986ad46a689d24c2":{"blockhash":"00000000734a1ae27ebe8a0818ed4940358357ee9d567ae3e98d710c05437d8d","blocktime":1785189104,"confirmations":5014,"hash":"bf43bddd9f163e7ccf0b980cd2ddee0470765dd0ad6f424e986ad46a689d24c2","hex":"02000000013f931edf1994cf00c1b907fc349a6fab0a132886bb952e1f9abedc0314c59462000000006441cd2cee0019f9f8044a4e38e2742ed5dd589a650514bba4456345e8e06872baf1366857728027757ed055e7d6593e1a3f45cd3c03dfa2d37089b4b969d023dd104121030ff148bd8022b22da677bd3e0024bd3b040979005bde66b816f429c9fa3c27f3feffffff04db7e0100000000001976a914ebd581180eda8d7ba25d03f365e33afebf9c906188ace8030000000000004def3f931edf1994cf00c1b907fc349a6fab0a132886bb952e1f9abedc0314c5946260117172746573742d6532313937302e6e617676a9144afa006894d13aa96440136a532b74e908d4db8988ac00000000000000004b6a49424e53317b226f223a22524547222c226e223a227172746573742d6532313937302e6e6176222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e7374227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac00000000","locktime":0,"size":389,"time":1785189104,"txid":"bf43bddd9f163e7ccf0b980cd2ddee0470765dd0ad6f424e986ad46a689d24c2","version":2,"vin":[{"scriptSig":{"asm":"cd2cee0019f9f8044a4e38e2742ed5dd589a650514bba4456345e8e06872baf1366857728027757ed055e7d6593e1a3f45cd3c03dfa2d37089b4b969d023dd10[ALL|FORKID] 030ff148bd8022b22da677bd3e0024bd3b040979005bde66b816f429c9fa3c27f3","hex":"41cd2cee0019f9f8044a4e38e2742ed5dd589a650514bba4456345e8e06872baf1366857728027757ed055e7d6593e1a3f45cd3c03dfa2d37089b4b969d023dd104121030ff148bd8022b22da677bd3e0024bd3b040979005bde66b816f429c9fa3c27f3"},"sequence":4294967294,"txid":"6294c51403dcbe9a1f2e95bb8628130aab6f9a34fc07b9c100cf9419df1e933f","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qr4atqgcpmdg67azt5plxe0r8tltl8ysvy9506553k"],"asm":"OP_DUP OP_HASH160 ebd581180eda8d7ba25d03f365e33afebf9c9061 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914ebd581180eda8d7ba25d03f365e33afebf9c906188ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00098011},{"n":1,"scriptPubKey":{"addresses":["bchtest:qp905qrgjngn42tygqfk55etwn5s34xm3ytpjdcs8w"],"asm":"OP_DUP OP_HASH160 4afa006894d13aa96440136a532b74e908d4db89 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144afa006894d13aa96440136a532b74e908d4db8988ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"6294c51403dcbe9a1f2e95bb8628130aab6f9a34fc07b9c100cf9419df1e933f","nft":{"capability":"none","commitment":"7172746573742d6532313937302e6e6176"}},"value":0.00001},{"n":2,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a227172746573742d6532313937302e6e6176222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e7374227d7d","hex":"6a49424e53317b226f223a22524547222c226e223a227172746573742d6532313937302e6e6176222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e7374227d7d","type":"nulldata"},"value":0},{"n":3,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006}]},"cb8c9002b4c1f032a1da448f8e8b445c32bf41851208d6d11d4cbbf583ee2175":{"blockhash":"000000003a16ee2f58e797452a24edd030efaa50b6fdcd7e4dbeb8e49062c9f7","blocktime":1785306514,"confirmations":4820,"hash":"cb8c9002b4c1f032a1da448f8e8b445c32bf41851208d6d11d4cbbf583ee2175","hex":"02000000017b3f1dee1c239ac62bf9d4f4a9bf913006818f1f4b49ee441cd639d20230d9cd00000000644159b201f67bcf5f9bd52d33c9874a87830420cc55e06b2d4b8a9694e3f2bb4cb021242ae3a9e42ada0650c5a1b7fdcbcc7cdffde4f0a45fad3168abfe58dc02b8412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000048ef7b3f1dee1c239ac62bf9d4f4a9bf913006818f1f4b49ee441cd639d20230d9cd600c64657669616e742e7465737476a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000436a41424e53317b226f223a22524547222c226e223a2264657669616e742e74657374222c2272223a7b227333223a22626e732f64657669616e742d746573742f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac562a0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":376,"time":1785306514,"txid":"cb8c9002b4c1f032a1da448f8e8b445c32bf41851208d6d11d4cbbf583ee2175","version":2,"vin":[{"scriptSig":{"asm":"59b201f67bcf5f9bd52d33c9874a87830420cc55e06b2d4b8a9694e3f2bb4cb021242ae3a9e42ada0650c5a1b7fdcbcc7cdffde4f0a45fad3168abfe58dc02b8[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4159b201f67bcf5f9bd52d33c9874a87830420cc55e06b2d4b8a9694e3f2bb4cb021242ae3a9e42ada0650c5a1b7fdcbcc7cdffde4f0a45fad3168abfe58dc02b8412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"cdd93002d239d61c44ee494b1f8f81063091bfa9f4d4f92bc69a231cee1d3f7b","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"cdd93002d239d61c44ee494b1f8f81063091bfa9f4d4f92bc69a231cee1d3f7b","nft":{"capability":"none","commitment":"64657669616e742e74657374"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2264657669616e742e74657374222c2272223a7b227333223a22626e732f64657669616e742d746573742f227d7d","hex":"6a41424e53317b226f223a22524547222c226e223a2264657669616e742e74657374222c2272223a7b227333223a22626e732f64657669616e742d746573742f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00010838}]},"9b71620c7d1bf3cfed69d035ffff5576382cc28ff8f523321ed0a0f1d4764852":{"blockhash":"00000000138ae1bb0f81f5003764413b6ab9a3e49e89a26c4aabd80e229946d6","blocktime":1785356044,"confirmations":4738,"hash":"9b71620c7d1bf3cfed69d035ffff5576382cc28ff8f523321ed0a0f1d4764852","hex":"0200000001c15e018fae720d55e6321ec8d09bc47cc0a4783528f3eb56fb332316b4cc9a5e0000000064412161146f174ee2518da52c1c1954d9649fb0159e6a7c5fffcdc677b3c9f231e6502c74bc6ee2ce2c637a5ee5fa89fd3506a92b2f445e3385f172560f1c0ceee9412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e8030000000000004fefc15e018fae720d55e6321ec8d09bc47cc0a4783528f3eb56fb332316b4cc9a5e601372656c65617365732e73696c656e746d6f646576a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000005f6a4c5c424e53317b226f223a22524547222c226e223a2272656c65617365732e73696c656e746d6f6465222c2272223a7b227333223a22626e732f73696c656e746d6f64652f72656c65617365732d6d616e69666573742e6a736f6e227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac341f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":411,"time":1785356044,"txid":"9b71620c7d1bf3cfed69d035ffff5576382cc28ff8f523321ed0a0f1d4764852","version":2,"vin":[{"scriptSig":{"asm":"2161146f174ee2518da52c1c1954d9649fb0159e6a7c5fffcdc677b3c9f231e6502c74bc6ee2ce2c637a5ee5fa89fd3506a92b2f445e3385f172560f1c0ceee9[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"412161146f174ee2518da52c1c1954d9649fb0159e6a7c5fffcdc677b3c9f231e6502c74bc6ee2ce2c637a5ee5fa89fd3506a92b2f445e3385f172560f1c0ceee9412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"5e9accb4162333fb56ebf3283578a4c07cc49bd0c81e32e6550d72ae8f015ec1","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"5e9accb4162333fb56ebf3283578a4c07cc49bd0c81e32e6550d72ae8f015ec1","nft":{"capability":"none","commitment":"72656c65617365732e73696c656e746d6f6465"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2272656c65617365732e73696c656e746d6f6465222c2272223a7b227333223a22626e732f73696c656e746d6f64652f72656c65617365732d6d616e69666573742e6a736f6e227d7d","hex":"6a4c5c424e53317b226f223a22524547222c226e223a2272656c65617365732e73696c656e746d6f6465222c2272223a7b227333223a22626e732f73696c656e746d6f64652f72656c65617365732d6d616e69666573742e6a736f6e227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00007988}]},"1b1beb7d4ece128a043e9ae31d40fbaa76f0626f97d267b6448d372bc825b5ec":{"blockhash":"0000000012d6c6efdd04c74e422edc2c7cacefd0a0f095cedd49e6b8aa90f672","blocktime":1785373262,"confirmations":4705,"hash":"1b1beb7d4ece128a043e9ae31d40fbaa76f0626f97d267b6448d372bc825b5ec","hex":"02000000019478fdcd8cd0dc061aa85eafe315ac2c76d39b3f55e6f138a0fa0440b82886e00000000064414410c984d6e5e9111662a4d1e803b6e76ee9a219658cd87c9223b87a3c1eb794382726dabbcc346b67393e433966f9aa17a7b3d0f594e545ec202dd4d0e24e2c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000042ef9478fdcd8cd0dc061aa85eafe315ac2c76d39b3f55e6f138a0fa0440b82886e0600666617563657476a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000376a35424e53317b226f223a22524547222c226e223a22666175636574222c2272223a7b227333223a22626e732f6661756365742f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac691f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":358,"time":1785373262,"txid":"1b1beb7d4ece128a043e9ae31d40fbaa76f0626f97d267b6448d372bc825b5ec","version":2,"vin":[{"scriptSig":{"asm":"4410c984d6e5e9111662a4d1e803b6e76ee9a219658cd87c9223b87a3c1eb794382726dabbcc346b67393e433966f9aa17a7b3d0f594e545ec202dd4d0e24e2c[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"414410c984d6e5e9111662a4d1e803b6e76ee9a219658cd87c9223b87a3c1eb794382726dabbcc346b67393e433966f9aa17a7b3d0f594e545ec202dd4d0e24e2c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"e08628b84004faa038f1e6553f9bd3762cac15e3af5ea81a06dcd08ccdfd7894","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"e08628b84004faa038f1e6553f9bd3762cac15e3af5ea81a06dcd08ccdfd7894","nft":{"capability":"none","commitment":"666175636574"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22666175636574222c2272223a7b227333223a22626e732f6661756365742f227d7d","hex":"6a35424e53317b226f223a22524547222c226e223a22666175636574222c2272223a7b227333223a22626e732f6661756365742f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008041}]},"e25a21357e0ee2c7f4e9e769d99ec375d7388695c89f4517153de0405e92656d":{"blockhash":"00000000192a457eef7023854c5fd01afe2673ae08a0192181e40d82af1ed639","blocktime":1785394464,"confirmations":4669,"hash":"e25a21357e0ee2c7f4e9e769d99ec375d7388695c89f4517153de0405e92656d","hex":"02000000010ca51bbb69dde4b44d026ff1f89d10a5451db0fc8ee88d3e6d0f3b14320ed7840000000064415074df9ddeca995d6002f390813b89e221c6ada7bd364596ccc3a6509ad3de4ffe5e0ed839f98c306b6e924d0f1bc9d3e0ea7c286701acf1b90b6a4eca71e53d412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000042ef0ca51bbb69dde4b44d026ff1f89d10a5451db0fc8ee88d3e6d0f3b14320ed784600667616d652e7876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000356a33424e53317b226f223a22524547222c226e223a2267616d652e78222c2272223a7b227333223a22626e732f67616d652f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac6b1f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":356,"time":1785394464,"txid":"e25a21357e0ee2c7f4e9e769d99ec375d7388695c89f4517153de0405e92656d","version":2,"vin":[{"scriptSig":{"asm":"5074df9ddeca995d6002f390813b89e221c6ada7bd364596ccc3a6509ad3de4ffe5e0ed839f98c306b6e924d0f1bc9d3e0ea7c286701acf1b90b6a4eca71e53d[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"415074df9ddeca995d6002f390813b89e221c6ada7bd364596ccc3a6509ad3de4ffe5e0ed839f98c306b6e924d0f1bc9d3e0ea7c286701acf1b90b6a4eca71e53d412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"84d70e32143b0f6d3e8de88efcb01d45a5109df8f16f024db4e4dd69bb1ba50c","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"84d70e32143b0f6d3e8de88efcb01d45a5109df8f16f024db4e4dd69bb1ba50c","nft":{"capability":"none","commitment":"67616d652e78"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2267616d652e78222c2272223a7b227333223a22626e732f67616d652f227d7d","hex":"6a33424e53317b226f223a22524547222c226e223a2267616d652e78222c2272223a7b227333223a22626e732f67616d652f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008043}]},"f5a34ec7b995541f2f9b10f85d30b29cc76ebf75960e794a9001a68b16f6b3e9":{"blockhash":"000000000ff4d60deb92c5b1a4f27ee84959560062ff93e932376b125dc9b37f","blocktime":1785530481,"confirmations":4446,"hash":"f5a34ec7b995541f2f9b10f85d30b29cc76ebf75960e794a9001a68b16f6b3e9","hex":"02000000012da4353d07aa3b76d28ec735abce0872bea97192fc6fb9d29b18788214070afe000000006441f1f8ed04bb2fde7f5b5a46ab869f47d0ae9643a4ff5dcbc1e2c27fe02391a47edfd9d3db566a075770a056ea9c33466d3b71210faba615c102014fafaba28cdd412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000040ef2da4353d07aa3b76d28ec735abce0872bea97192fc6fb9d29b18788214070afe6004746c647376a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000516a4c4e424e53317b226f223a22524547222c226e223a22746c6473222c2272223a7b22746c6473223a22626368207032702064657669616e74206e61762073696120646762206e7420736d206c6d227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac511f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":382,"time":1785530481,"txid":"f5a34ec7b995541f2f9b10f85d30b29cc76ebf75960e794a9001a68b16f6b3e9","version":2,"vin":[{"scriptSig":{"asm":"f1f8ed04bb2fde7f5b5a46ab869f47d0ae9643a4ff5dcbc1e2c27fe02391a47edfd9d3db566a075770a056ea9c33466d3b71210faba615c102014fafaba28cdd[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41f1f8ed04bb2fde7f5b5a46ab869f47d0ae9643a4ff5dcbc1e2c27fe02391a47edfd9d3db566a075770a056ea9c33466d3b71210faba615c102014fafaba28cdd412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"fe0a07148278189bd2b96ffc9271a9be7208ceab35c78ed2763baa073d35a42d","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"fe0a07148278189bd2b96ffc9271a9be7208ceab35c78ed2763baa073d35a42d","nft":{"capability":"none","commitment":"746c6473"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22746c6473222c2272223a7b22746c6473223a22626368207032702064657669616e74206e61762073696120646762206e7420736d206c6d227d7d","hex":"6a4c4e424e53317b226f223a22524547222c226e223a22746c6473222c2272223a7b22746c6473223a22626368207032702064657669616e74206e61762073696120646762206e7420736d206c6d227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008017}]},"4911e21829714e41d5676d161769fda4e40e8c5745c755ade473cfbda6c65952":{"blockhash":"00000000060fb8fc2b76e5cfcd0b1ae25db374d0f365c595e4cb47c4efe450e7","blocktime":1785544803,"confirmations":4420,"hash":"4911e21829714e41d5676d161769fda4e40e8c5745c755ade473cfbda6c65952","hex":"020000000186842054f66178ad7f2142a6f9fdc11ef7c411773b0899dfb82c680fd1867433000000006441f8b7519289788f028b95fd5fc40853d4a4beed853ac370eac9ecde175cb66e610f292f8c8b9c6c556702bccda349c34d7a134ef1353c0c687859c68e82bc1a31412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000040ef86842054f66178ad7f2142a6f9fdc11ef7c411773b0899dfb82c680fd18674336004746f746576a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000336a31424e53317b226f223a22524547222c226e223a22746f7465222c2272223a7b227333223a22626e732f746f74652f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac6f1f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":352,"time":1785544803,"txid":"4911e21829714e41d5676d161769fda4e40e8c5745c755ade473cfbda6c65952","version":2,"vin":[{"scriptSig":{"asm":"f8b7519289788f028b95fd5fc40853d4a4beed853ac370eac9ecde175cb66e610f292f8c8b9c6c556702bccda349c34d7a134ef1353c0c687859c68e82bc1a31[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41f8b7519289788f028b95fd5fc40853d4a4beed853ac370eac9ecde175cb66e610f292f8c8b9c6c556702bccda349c34d7a134ef1353c0c687859c68e82bc1a31412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"337486d10f682cb8df99083b7711c4f71ec1fdf9a642217fad7861f654208486","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"337486d10f682cb8df99083b7711c4f71ec1fdf9a642217fad7861f654208486","nft":{"capability":"none","commitment":"746f7465"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22746f7465222c2272223a7b227333223a22626e732f746f74652f227d7d","hex":"6a31424e53317b226f223a22524547222c226e223a22746f7465222c2272223a7b227333223a22626e732f746f74652f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008047}]},"685b8cb240ff19342c4a228a4adc4bea1776c64ae0cab581d3e5d0800d9a1454":{"blockhash":"00000000060fb8fc2b76e5cfcd0b1ae25db374d0f365c595e4cb47c4efe450e7","blocktime":1785544803,"confirmations":4420,"hash":"685b8cb240ff19342c4a228a4adc4bea1776c64ae0cab581d3e5d0800d9a1454","hex":"02000000019c74b73d8e6a4a0bd83408de9f7708132adba4ebbeacb7174a02059955aa8793000000006441eeeabacdd3c99b283132e8be1b582ed976ac772d64ea72a3f020e728d0e9553155824d05f1dcb43fd56fb2dbd26f83239b7b29d48d3b1b9fc01b370f738c30c5412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000043ef9c74b73d8e6a4a0bd83408de9f7708132adba4ebbeacb7174a02059955aa8793600774366d6f64656c76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000396a37424e53317b226f223a22524547222c226e223a2274366d6f64656c222c2272223a7b227333223a22626e732f74366d6f64656c2f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac661f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":361,"time":1785544803,"txid":"685b8cb240ff19342c4a228a4adc4bea1776c64ae0cab581d3e5d0800d9a1454","version":2,"vin":[{"scriptSig":{"asm":"eeeabacdd3c99b283132e8be1b582ed976ac772d64ea72a3f020e728d0e9553155824d05f1dcb43fd56fb2dbd26f83239b7b29d48d3b1b9fc01b370f738c30c5[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41eeeabacdd3c99b283132e8be1b582ed976ac772d64ea72a3f020e728d0e9553155824d05f1dcb43fd56fb2dbd26f83239b7b29d48d3b1b9fc01b370f738c30c5412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"9387aa559905024a17b7acbeeba4db2a1308779fde0834d80b4a6a8e3db7749c","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"9387aa559905024a17b7acbeeba4db2a1308779fde0834d80b4a6a8e3db7749c","nft":{"capability":"none","commitment":"74366d6f64656c"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2274366d6f64656c222c2272223a7b227333223a22626e732f74366d6f64656c2f227d7d","hex":"6a37424e53317b226f223a22524547222c226e223a2274366d6f64656c222c2272223a7b227333223a22626e732f74366d6f64656c2f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008038}]},"e1b3e41f3ee15d7252a696522891ce6ecf2138da42bdc2571cc6e31772cf3924":{"blockhash":"00000000060fb8fc2b76e5cfcd0b1ae25db374d0f365c595e4cb47c4efe450e7","blocktime":1785544803,"confirmations":4420,"hash":"e1b3e41f3ee15d7252a696522891ce6ecf2138da42bdc2571cc6e31772cf3924","hex":"0200000001390eca0a71aa9e1f767b488104dd29387e1bb98cd4dc671b173ec20af197824500000000644110a809fe6b75f4249bcc0ea00991b8c4069b16f170b5c8b5a8f69ce7918b40e3704be13ccb7f57f1c21013a4a4e29ac5905d1c39ecddda1c057de6e1e507eed6412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000047ef390eca0a71aa9e1f767b488104dd29387e1bb98cd4dc671b173ec20af1978245600b626974636f696e6361736876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000c26a4cbf424e53317b226f223a22524547222c226e223a22626974636f696e63617368222c2272223a7b2268223a223c626f6479206267636f6c6f723d2330303020746578743d236666663e3c7374796c653e683120627b636f6c6f723a233041433138457d627b636f6c6f723a234638307d3c2f7374796c653e3c63656e7465723e3c62723e3c68313e426974636f696e203c623e436173683c2f623e3c2f68313e3c68323e6973206566666563746976656c79203c623e426974636f696e227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88acd91e0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":502,"time":1785544803,"txid":"e1b3e41f3ee15d7252a696522891ce6ecf2138da42bdc2571cc6e31772cf3924","version":2,"vin":[{"scriptSig":{"asm":"10a809fe6b75f4249bcc0ea00991b8c4069b16f170b5c8b5a8f69ce7918b40e3704be13ccb7f57f1c21013a4a4e29ac5905d1c39ecddda1c057de6e1e507eed6[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4110a809fe6b75f4249bcc0ea00991b8c4069b16f170b5c8b5a8f69ce7918b40e3704be13ccb7f57f1c21013a4a4e29ac5905d1c39ecddda1c057de6e1e507eed6412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"458297f10ac23e171b67dcd48cb91b7e3829dd0481487b761f9eaa710aca0e39","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"458297f10ac23e171b67dcd48cb91b7e3829dd0481487b761f9eaa710aca0e39","nft":{"capability":"none","commitment":"626974636f696e63617368"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22626974636f696e63617368222c2272223a7b2268223a223c626f6479206267636f6c6f723d2330303020746578743d236666663e3c7374796c653e683120627b636f6c6f723a233041433138457d627b636f6c6f723a234638307d3c2f7374796c653e3c63656e7465723e3c62723e3c68313e426974636f696e203c623e436173683c2f623e3c2f68313e3c68323e6973206566666563746976656c79203c623e426974636f696e227d7d","hex":"6a4cbf424e53317b226f223a22524547222c226e223a22626974636f696e63617368222c2272223a7b2268223a223c626f6479206267636f6c6f723d2330303020746578743d236666663e3c7374796c653e683120627b636f6c6f723a233041433138457d627b636f6c6f723a234638307d3c2f7374796c653e3c63656e7465723e3c62723e3c68313e426974636f696e203c623e436173683c2f623e3c2f68313e3c68323e6973206566666563746976656c79203c623e426974636f696e227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00007897}]},"e7efa170ce7f5a414a3ff1b2226d8b72a14664e484a864ad58bdac938ed74df7":{"blockhash":"00000000060fb8fc2b76e5cfcd0b1ae25db374d0f365c595e4cb47c4efe450e7","blocktime":1785544803,"confirmations":4420,"hash":"e7efa170ce7f5a414a3ff1b2226d8b72a14664e484a864ad58bdac938ed74df7","hex":"020000000177e93c90b61aebb4bd800e76a3805090f54435fd012c3e18f9923e3e597a17c500000000644182257710e3e9e4fe3238fdcff3d26f163498361cc761bd1b3a50818908fd9b8716c4234c7e0c30e9c376a1cce9f33372b2100bfaf662e2a79b0788a498b5a16c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000049ef77e93c90b61aebb4bd800e76a3805090f54435fd012c3e18f9923e3e597a17c5600d626974636f696e636173682e7876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000436a41424e53317b226f223a22524547222c226e223a22626974636f696e636173682e78222c2272223a7b227333223a22626e732f626974636f696e636173682f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac561f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":377,"time":1785544803,"txid":"e7efa170ce7f5a414a3ff1b2226d8b72a14664e484a864ad58bdac938ed74df7","version":2,"vin":[{"scriptSig":{"asm":"82257710e3e9e4fe3238fdcff3d26f163498361cc761bd1b3a50818908fd9b8716c4234c7e0c30e9c376a1cce9f33372b2100bfaf662e2a79b0788a498b5a16c[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4182257710e3e9e4fe3238fdcff3d26f163498361cc761bd1b3a50818908fd9b8716c4234c7e0c30e9c376a1cce9f33372b2100bfaf662e2a79b0788a498b5a16c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"c5177a593e3e92f9183e2c01fd3544f5905080a3760e80bdb4eb1ab6903ce977","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"c5177a593e3e92f9183e2c01fd3544f5905080a3760e80bdb4eb1ab6903ce977","nft":{"capability":"none","commitment":"626974636f696e636173682e78"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22626974636f696e636173682e78222c2272223a7b227333223a22626e732f626974636f696e636173682f227d7d","hex":"6a41424e53317b226f223a22524547222c226e223a22626974636f696e636173682e78222c2272223a7b227333223a22626e732f626974636f696e636173682f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008022}]},"178140fc4305e9fda9afe2e544f3dcf0183af22c275b143dad33bac6ecc61b4b":{"blockhash":"00000000051a84e6bb6b7e7ae3b826ed1f889ab29acd070eadb1e0d26aa8c1f9","blocktime":1785545343,"confirmations":4419,"hash":"178140fc4305e9fda9afe2e544f3dcf0183af22c275b143dad33bac6ecc61b4b","hex":"02000000015e47c915336d1a784d9117bb88e0d64a7ff2dc5f68888fc62f01114d669b6f37000000006441d79223f812e5bf5b548b5819f9d476bd353e7a5659136d3fabfa7215ca7cbf0c16c0537b4313dd1beafe30740828b5d7e5e51893574e4c7c5da07e7c8e1eff53412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000046ef5e47c915336d1a784d9117bb88e0d64a7ff2dc5f68888fc62f01114d669b6f37600a63726f7373636861696e76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000003f6a3d424e53317b226f223a22524547222c226e223a2263726f7373636861696e222c2272223a7b227333223a22626e732f63726f7373636861696e2f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac5d1f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":370,"time":1785545343,"txid":"178140fc4305e9fda9afe2e544f3dcf0183af22c275b143dad33bac6ecc61b4b","version":2,"vin":[{"scriptSig":{"asm":"d79223f812e5bf5b548b5819f9d476bd353e7a5659136d3fabfa7215ca7cbf0c16c0537b4313dd1beafe30740828b5d7e5e51893574e4c7c5da07e7c8e1eff53[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41d79223f812e5bf5b548b5819f9d476bd353e7a5659136d3fabfa7215ca7cbf0c16c0537b4313dd1beafe30740828b5d7e5e51893574e4c7c5da07e7c8e1eff53412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"376f9b664d11012fc68f88685fdcf27f4ad6e088bb17914d781a6d3315c9475e","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"376f9b664d11012fc68f88685fdcf27f4ad6e088bb17914d781a6d3315c9475e","nft":{"capability":"none","commitment":"63726f7373636861696e"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2263726f7373636861696e222c2272223a7b227333223a22626e732f63726f7373636861696e2f227d7d","hex":"6a3d424e53317b226f223a22524547222c226e223a2263726f7373636861696e222c2272223a7b227333223a22626e732f63726f7373636861696e2f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008029}]},"2ae4f2fe2deadbac836bb220af311c052d4f24df8d900c2b0d24fb626bce8fb5":{"blockhash":"00000000051a84e6bb6b7e7ae3b826ed1f889ab29acd070eadb1e0d26aa8c1f9","blocktime":1785545343,"confirmations":4419,"hash":"2ae4f2fe2deadbac836bb220af311c052d4f24df8d900c2b0d24fb626bce8fb5","hex":"020000000116d91c6bfffd5cee008902a9d2e75bafb68ad03d517b875dfe843c1f2131da2f0000000064415bc547af24c3f962a8e165588783ad627b29f2a46300e56bf8a991b3cd702255706d2a75203dfd9c48a225b6dab896b91a2e3aff938dae54d8c1a52bd80ecbc9412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000044ef16d91c6bfffd5cee008902a9d2e75bafb68ad03d517b875dfe843c1f2131da2f6008706f74696461656176a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000003b6a39424e53317b226f223a22524547222c226e223a22706f746964616561222c2272223a7b227333223a22626e732f706f7469646165612f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac631f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":364,"time":1785545343,"txid":"2ae4f2fe2deadbac836bb220af311c052d4f24df8d900c2b0d24fb626bce8fb5","version":2,"vin":[{"scriptSig":{"asm":"5bc547af24c3f962a8e165588783ad627b29f2a46300e56bf8a991b3cd702255706d2a75203dfd9c48a225b6dab896b91a2e3aff938dae54d8c1a52bd80ecbc9[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"415bc547af24c3f962a8e165588783ad627b29f2a46300e56bf8a991b3cd702255706d2a75203dfd9c48a225b6dab896b91a2e3aff938dae54d8c1a52bd80ecbc9412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"2fda31211f3c84fe5d877b513dd08ab6af5be7d2a9028900ee5cfdff6b1cd916","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"2fda31211f3c84fe5d877b513dd08ab6af5be7d2a9028900ee5cfdff6b1cd916","nft":{"capability":"none","commitment":"706f746964616561"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22706f746964616561222c2272223a7b227333223a22626e732f706f7469646165612f227d7d","hex":"6a39424e53317b226f223a22524547222c226e223a22706f746964616561222c2272223a7b227333223a22626e732f706f7469646165612f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008035}]},"985f0970ecd6d70b2f2d2553337150ed18a3e6e6cb88918f592d68d64f05ce37":{"blockhash":"00000000051a84e6bb6b7e7ae3b826ed1f889ab29acd070eadb1e0d26aa8c1f9","blocktime":1785545343,"confirmations":4419,"hash":"985f0970ecd6d70b2f2d2553337150ed18a3e6e6cb88918f592d68d64f05ce37","hex":"0200000001b472c87b5ca5dd310206d01295d2d4596e25a8eeea8480a04bb85e34e97eca18000000006441e0066f420fa7cd19984c8c7d7cf3a152291514825a253779c422debb39753bca808a1ad0921b08943b5743ce42747313c7fe0459d51ba8a85342b504ff24f6f0412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000044efb472c87b5ca5dd310206d01295d2d4596e25a8eeea8480a04bb85e34e97eca186008616e7468656d757376a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000003b6a39424e53317b226f223a22524547222c226e223a22616e7468656d7573222c2272223a7b227333223a22626e732f616e7468656d75732f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac631f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":364,"time":1785545343,"txid":"985f0970ecd6d70b2f2d2553337150ed18a3e6e6cb88918f592d68d64f05ce37","version":2,"vin":[{"scriptSig":{"asm":"e0066f420fa7cd19984c8c7d7cf3a152291514825a253779c422debb39753bca808a1ad0921b08943b5743ce42747313c7fe0459d51ba8a85342b504ff24f6f0[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41e0066f420fa7cd19984c8c7d7cf3a152291514825a253779c422debb39753bca808a1ad0921b08943b5743ce42747313c7fe0459d51ba8a85342b504ff24f6f0412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"18ca7ee9345eb84ba08084eaeea8256e59d4d29512d0060231dda55c7bc872b4","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"18ca7ee9345eb84ba08084eaeea8256e59d4d29512d0060231dda55c7bc872b4","nft":{"capability":"none","commitment":"616e7468656d7573"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22616e7468656d7573222c2272223a7b227333223a22626e732f616e7468656d75732f227d7d","hex":"6a39424e53317b226f223a22524547222c226e223a22616e7468656d7573222c2272223a7b227333223a22626e732f616e7468656d75732f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008035}]},"a6d7a16d6ce68f7a100a6b133eb49df42541d5da4128c1c983aa7230e1d36d7a":{"blockhash":"00000000051a84e6bb6b7e7ae3b826ed1f889ab29acd070eadb1e0d26aa8c1f9","blocktime":1785545343,"confirmations":4419,"hash":"a6d7a16d6ce68f7a100a6b133eb49df42541d5da4128c1c983aa7230e1d36d7a","hex":"02000000011803cd1b2d726b772366752cdd797f33d4bbf96f145891927c793f41a4e98cf300000000644160251a1d4a2721305fd9a98c8eebc53ddb350e1250182ab4d17e5a4c2dadf08b914390876c7b527001ec40fb92077479a7b2a5bc6fa28930f24bd06a62a88e99412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000046ef1803cd1b2d726b772366752cdd797f33d4bbf96f145891927c793f41a4e98cf3600a617274656d6973696f6e76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000003f6a3d424e53317b226f223a22524547222c226e223a22617274656d6973696f6e222c2272223a7b227333223a22626e732f617274656d6973696f6e2f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac5d1f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":370,"time":1785545343,"txid":"a6d7a16d6ce68f7a100a6b133eb49df42541d5da4128c1c983aa7230e1d36d7a","version":2,"vin":[{"scriptSig":{"asm":"60251a1d4a2721305fd9a98c8eebc53ddb350e1250182ab4d17e5a4c2dadf08b914390876c7b527001ec40fb92077479a7b2a5bc6fa28930f24bd06a62a88e99[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4160251a1d4a2721305fd9a98c8eebc53ddb350e1250182ab4d17e5a4c2dadf08b914390876c7b527001ec40fb92077479a7b2a5bc6fa28930f24bd06a62a88e99412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"f38ce9a4413f797c929158146ff9bbd4337f79dd2c756623776b722d1bcd0318","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"f38ce9a4413f797c929158146ff9bbd4337f79dd2c756623776b722d1bcd0318","nft":{"capability":"none","commitment":"617274656d6973696f6e"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22617274656d6973696f6e222c2272223a7b227333223a22626e732f617274656d6973696f6e2f227d7d","hex":"6a3d424e53317b226f223a22524547222c226e223a22617274656d6973696f6e222c2272223a7b227333223a22626e732f617274656d6973696f6e2f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008029}]},"6656b915febd6e6d801f1d3e19e6c87cd9acaada95c6060236f4b096d81012eb":{"blockhash":"000000006743b2722c3e08264d30ca37610f34f4f9a576ad620499034e247ece","blocktime":1785627729,"confirmations":4285,"hash":"6656b915febd6e6d801f1d3e19e6c87cd9acaada95c6060236f4b096d81012eb","hex":"0200000002524876d4f1a0d01e3223f5f88fc22c387655ffff35d069edcff31b7d0c62719b0000000064411ecf221edca51bb24a5461ac3aec1c270181186a6e8aff15c3421050b3efe87fcaa382aea7a5c4441ae5567c657766c153e81f2ca8a371782ec587e1350f808c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d000000004b1bc6ecc6ba33ad3d145b272cf23a18f0dcf344e5e2afa9fde90543fc408117030000006441707ace2c25704efbecef15c8a2b39a02018d7dea91644ef39ea5f4f9301ad023bf0f45e7c816faed3e9e6ec41a42f41d65f173e684bb213d7f79968888857fd9412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e8030000000000004fefc15e018fae720d55e6321ec8d09bc47cc0a4783528f3eb56fb332316b4cc9a5e601372656c65617365732e73696c656e746d6f646576a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000686a4c65424e53317b226f223a22555044222c226e223a2272656c65617365732e73696c656e746d6f6465222c2272223a7b2275223a2268747470733a2f2f646c2e73696c656e746d6f64652e73742f72656c65617365732d6d616e69666573742e6a736f6e227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88acd31a0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":561,"time":1785627729,"txid":"6656b915febd6e6d801f1d3e19e6c87cd9acaada95c6060236f4b096d81012eb","version":2,"vin":[{"scriptSig":{"asm":"1ecf221edca51bb24a5461ac3aec1c270181186a6e8aff15c3421050b3efe87fcaa382aea7a5c4441ae5567c657766c153e81f2ca8a371782ec587e1350f808c[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"411ecf221edca51bb24a5461ac3aec1c270181186a6e8aff15c3421050b3efe87fcaa382aea7a5c4441ae5567c657766c153e81f2ca8a371782ec587e1350f808c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"9b71620c7d1bf3cfed69d035ffff5576382cc28ff8f523321ed0a0f1d4764852","vout":0},{"scriptSig":{"asm":"707ace2c25704efbecef15c8a2b39a02018d7dea91644ef39ea5f4f9301ad023bf0f45e7c816faed3e9e6ec41a42f41d65f173e684bb213d7f79968888857fd9[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41707ace2c25704efbecef15c8a2b39a02018d7dea91644ef39ea5f4f9301ad023bf0f45e7c816faed3e9e6ec41a42f41d65f173e684bb213d7f79968888857fd9412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"178140fc4305e9fda9afe2e544f3dcf0183af22c275b143dad33bac6ecc61b4b","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"5e9accb4162333fb56ebf3283578a4c07cc49bd0c81e32e6550d72ae8f015ec1","nft":{"capability":"none","commitment":"72656c65617365732e73696c656e746d6f6465"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a2272656c65617365732e73696c656e746d6f6465222c2272223a7b2275223a2268747470733a2f2f646c2e73696c656e746d6f64652e73742f72656c65617365732d6d616e69666573742e6a736f6e227d7d","hex":"6a4c65424e53317b226f223a22555044222c226e223a2272656c65617365732e73696c656e746d6f6465222c2272223a7b2275223a2268747470733a2f2f646c2e73696c656e746d6f64652e73742f72656c65617365732d6d616e69666573742e6a736f6e227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00006867}]},"e0c6536daa85f17d1719587e1c257db0c296636f6372cab6552577a3dad6c05e":{"blockhash":"0000000029d3f776cdd2219801540d3fd2890215cbdae2cef47790285fcd2215","blocktime":1785879475,"confirmations":3867,"hash":"e0c6536daa85f17d1719587e1c257db0c296636f6372cab6552577a3dad6c05e","hex":"0200000001076e99c26879aec6ba8dd77ca211a1d831345e4d2dec8bca560d2f08cbb02ec6000000006441c32ce8899f0b8bcd726dae0657b704ef14ec0614c4c0abe033343eef99e9754ac8d06f86879aee9b22592ad1a36d6a3dfadf7261ce899a5dd864b07480d7f9f1412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000043ef076e99c26879aec6ba8dd77ca211a1d831345e4d2dec8bca560d2f08cbb02ec660077270632e64676276a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000266a24424e53317b226f223a22524547222c226e223a227270632e646762222c2272223a7b7d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac791f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":342,"time":1785879475,"txid":"e0c6536daa85f17d1719587e1c257db0c296636f6372cab6552577a3dad6c05e","version":2,"vin":[{"scriptSig":{"asm":"c32ce8899f0b8bcd726dae0657b704ef14ec0614c4c0abe033343eef99e9754ac8d06f86879aee9b22592ad1a36d6a3dfadf7261ce899a5dd864b07480d7f9f1[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41c32ce8899f0b8bcd726dae0657b704ef14ec0614c4c0abe033343eef99e9754ac8d06f86879aee9b22592ad1a36d6a3dfadf7261ce899a5dd864b07480d7f9f1412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"c62eb0cb082f0d56ca8bec2d4d5e3431d8a111a27cd78dbac6ae7968c2996e07","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"c62eb0cb082f0d56ca8bec2d4d5e3431d8a111a27cd78dbac6ae7968c2996e07","nft":{"capability":"none","commitment":"7270632e646762"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a227270632e646762222c2272223a7b7d7d","hex":"6a24424e53317b226f223a22524547222c226e223a227270632e646762222c2272223a7b7d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008057}]},"29147bc053fced6672f83d16351756848aadbf9faffa0934d9b3d6c2b49bc970":{"blockhash":"0000000077950ee0083a39eba59b6b4b4b6af9735fda57299b2acdba2ebf79b8","blocktime":1785880574,"confirmations":3866,"hash":"29147bc053fced6672f83d16351756848aadbf9faffa0934d9b3d6c2b49bc970","hex":"02000000019535264fb27a1379df66450d37ce3d0350f94ff26b2b4dbe3bc9036da21dc505000000006441bce78f1040689e2e51d342bd49356c3691d6c1274209ce2f82cf9459d1867738959ebe3d16b1eb43fa706207d4c7e11897b2ba745b77dae0e1c332490845ca0c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000043ef9535264fb27a1379df66450d37ce3d0350f94ff26b2b4dbe3bc9036da21dc50560077573642e64676276a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000266a24424e53317b226f223a22524547222c226e223a227573642e646762222c2272223a7b7d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac791f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":342,"time":1785880574,"txid":"29147bc053fced6672f83d16351756848aadbf9faffa0934d9b3d6c2b49bc970","version":2,"vin":[{"scriptSig":{"asm":"bce78f1040689e2e51d342bd49356c3691d6c1274209ce2f82cf9459d1867738959ebe3d16b1eb43fa706207d4c7e11897b2ba745b77dae0e1c332490845ca0c[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41bce78f1040689e2e51d342bd49356c3691d6c1274209ce2f82cf9459d1867738959ebe3d16b1eb43fa706207d4c7e11897b2ba745b77dae0e1c332490845ca0c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"05c51da26d03c93bbe4d2b6bf24ff950033dce370d4566df79137ab24f263595","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"05c51da26d03c93bbe4d2b6bf24ff950033dce370d4566df79137ab24f263595","nft":{"capability":"none","commitment":"7573642e646762"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a227573642e646762222c2272223a7b7d7d","hex":"6a24424e53317b226f223a22524547222c226e223a227573642e646762222c2272223a7b7d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008057}]},"539454166d97734cd21c595408e9cf38dab9a6ed8b447f2b7f7645239de3ea38":{"blockhash":"0000000077950ee0083a39eba59b6b4b4b6af9735fda57299b2acdba2ebf79b8","blocktime":1785880574,"confirmations":3866,"hash":"539454166d97734cd21c595408e9cf38dab9a6ed8b447f2b7f7645239de3ea38","hex":"020000000162739d6bbb1e7b949f3c927f823563c3a8a5a41c1953af9803394996dd6684ff000000006441f65fd60315a65459f46a14c37e9bd111d8931f05de78b02d24b7337dbc5d91a333840f8f55928c91e6797a204880ff413949a78b0c613a7b5f92e033cadaa1e9412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000046ef62739d6bbb1e7b949f3c927f823563c3a8a5a41c1953af9803394996dd6684ff600a77616c6c65742e64676276a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000296a27424e53317b226f223a22524547222c226e223a2277616c6c65742e646762222c2272223a7b7d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac731f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":348,"time":1785880574,"txid":"539454166d97734cd21c595408e9cf38dab9a6ed8b447f2b7f7645239de3ea38","version":2,"vin":[{"scriptSig":{"asm":"f65fd60315a65459f46a14c37e9bd111d8931f05de78b02d24b7337dbc5d91a333840f8f55928c91e6797a204880ff413949a78b0c613a7b5f92e033cadaa1e9[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41f65fd60315a65459f46a14c37e9bd111d8931f05de78b02d24b7337dbc5d91a333840f8f55928c91e6797a204880ff413949a78b0c613a7b5f92e033cadaa1e9412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"ff8466dd9649390398af53191ca4a5a8c36335827f923c9f947b1ebb6b9d7362","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"ff8466dd9649390398af53191ca4a5a8c36335827f923c9f947b1ebb6b9d7362","nft":{"capability":"none","commitment":"77616c6c65742e646762"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2277616c6c65742e646762222c2272223a7b7d7d","hex":"6a27424e53317b226f223a22524547222c226e223a2277616c6c65742e646762222c2272223a7b7d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008051}]},"e630ba09d3822aeec1ac341cb390c3b172f9e80621914e6ec135a2e15db0698f":{"blockhash":"0000000077950ee0083a39eba59b6b4b4b6af9735fda57299b2acdba2ebf79b8","blocktime":1785880574,"confirmations":3866,"hash":"e630ba09d3822aeec1ac341cb390c3b172f9e80621914e6ec135a2e15db0698f","hex":"0200000001e9895ea61ce830b9e02bc1ade86f5c1bb57a1fb046bc5fddeb588f08266a77870000000064416cd5184100a4383ae4b69cebdd5d9a56c072b35edfcc4d4f1a5f2ca33f18eb07116866d7ee1380b777d0e6e4ff95efd193b6358aebce10c65c7a028832c000c8412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000044efe9895ea61ce830b9e02bc1ade86f5c1bb57a1fb046bc5fddeb588f08266a77876008646f63732e64676276a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000276a25424e53317b226f223a22524547222c226e223a22646f63732e646762222c2272223a7b7d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac771f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":344,"time":1785880574,"txid":"e630ba09d3822aeec1ac341cb390c3b172f9e80621914e6ec135a2e15db0698f","version":2,"vin":[{"scriptSig":{"asm":"6cd5184100a4383ae4b69cebdd5d9a56c072b35edfcc4d4f1a5f2ca33f18eb07116866d7ee1380b777d0e6e4ff95efd193b6358aebce10c65c7a028832c000c8[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"416cd5184100a4383ae4b69cebdd5d9a56c072b35edfcc4d4f1a5f2ca33f18eb07116866d7ee1380b777d0e6e4ff95efd193b6358aebce10c65c7a028832c000c8412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"87776a26088f58ebdd5fbc46b01f7ab51b5c6fe8adc12be0b930e81ca65e89e9","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"87776a26088f58ebdd5fbc46b01f7ab51b5c6fe8adc12be0b930e81ca65e89e9","nft":{"capability":"none","commitment":"646f63732e646762"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22646f63732e646762222c2272223a7b7d7d","hex":"6a25424e53317b226f223a22524547222c226e223a22646f63732e646762222c2272223a7b7d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008055}]},"e3f6b84a3ad8c60c3087acbeb9aa8284efbc4f64fbe9709c84ba2c73546d25b0":{"blockhash":"000000004605851fdc3c5a786e12121a1e90885f3c995027931b773b3358ae11","blocktime":1785885522,"confirmations":3854,"hash":"e3f6b84a3ad8c60c3087acbeb9aa8284efbc4f64fbe9709c84ba2c73546d25b0","hex":"02000000028f69b05de1a235c16e4e912106e8f972b1c390b31c34acc1ee2a82d309ba30e600000000644178754b9cd4d7b99c52ebd053fe3479df34420a744bd5de236a3f718106dc704530f7bb93392c791f2c5240b8e1d5a87a6bf9afdce4f2bc6854a35edec4287e1f412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d000000009535264fb27a1379df66450d37ce3d0350f94ff26b2b4dbe3bc9036da21dc5050100000064416abc7d3b051d287fdf90707e39ebceec19bf2ed116b1668a1f274a9837f5eac816e20e8bb4cf6280d0b8199b680bc411a46cd2158ebdd318175b2cba7b65859c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000044efe9895ea61ce830b9e02bc1ade86f5c1bb57a1fb046bc5fddeb588f08266a77876008646f63732e64676276a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000005b6a4c58424e53317b226f223a22555044222c226e223a22646f63732e646762222c2272223a7b2268223a223c68313e44696769427974652e582057616c6c65743c2f68313e3c703e646f637320636f6d696e672e3c2f703e227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac82f70600000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":537,"time":1785885522,"txid":"e3f6b84a3ad8c60c3087acbeb9aa8284efbc4f64fbe9709c84ba2c73546d25b0","version":2,"vin":[{"scriptSig":{"asm":"78754b9cd4d7b99c52ebd053fe3479df34420a744bd5de236a3f718106dc704530f7bb93392c791f2c5240b8e1d5a87a6bf9afdce4f2bc6854a35edec4287e1f[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4178754b9cd4d7b99c52ebd053fe3479df34420a744bd5de236a3f718106dc704530f7bb93392c791f2c5240b8e1d5a87a6bf9afdce4f2bc6854a35edec4287e1f412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"e630ba09d3822aeec1ac341cb390c3b172f9e80621914e6ec135a2e15db0698f","vout":0},{"scriptSig":{"asm":"6abc7d3b051d287fdf90707e39ebceec19bf2ed116b1668a1f274a9837f5eac816e20e8bb4cf6280d0b8199b680bc411a46cd2158ebdd318175b2cba7b65859c[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"416abc7d3b051d287fdf90707e39ebceec19bf2ed116b1668a1f274a9837f5eac816e20e8bb4cf6280d0b8199b680bc411a46cd2158ebdd318175b2cba7b65859c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"05c51da26d03c93bbe4d2b6bf24ff950033dce370d4566df79137ab24f263595","vout":1}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"87776a26088f58ebdd5fbc46b01f7ab51b5c6fe8adc12be0b930e81ca65e89e9","nft":{"capability":"none","commitment":"646f63732e646762"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a22646f63732e646762222c2272223a7b2268223a223c68313e44696769427974652e582057616c6c65743c2f68313e3c703e646f637320636f6d696e672e3c2f703e227d7d","hex":"6a4c58424e53317b226f223a22555044222c226e223a22646f63732e646762222c2272223a7b2268223a223c68313e44696769427974652e582057616c6c65743c2f68313e3c703e646f637320636f6d696e672e3c2f703e227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00456578}]},"e8a75795cd1feb07dce71d4621af5034266e61031c30d0403890546fae40841b":{"blockhash":"000000004605851fdc3c5a786e12121a1e90885f3c995027931b773b3358ae11","blocktime":1785885522,"confirmations":3854,"hash":"e8a75795cd1feb07dce71d4621af5034266e61031c30d0403890546fae40841b","hex":"020000000238eae39d2345767f2b7f448beda6b9da38cfe90854591cd24c73976d16549453000000006441b0bee4dc80f916a014504f7f1b6d50dd8ae920e69642cabab4a774c386625e9cf90949bd0b79a3dde2a6d2368b4561fdba7b9728ad4fcc3790c7b44a764b5391412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d000000005ec0d6daa3772555b6ca72636f6396c2b07d251c7e5819177df185aa6d53c6e0030000006441176b08b273e912f821598d985cbcb2d9ab5561ba70b3a5e5c1d6eb8e35b2d4c1f4fbd63b559482b3176f237e1a16ef6fe988aaa4a10fe644540db48868fa46db412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000046ef62739d6bbb1e7b949f3c927f823563c3a8a5a41c1953af9803394996dd6684ff600a77616c6c65742e64676276a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000446a42424e53317b226f223a22555044222c226e223a2277616c6c65742e646762222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e7374227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac1c1b0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":516,"time":1785885522,"txid":"e8a75795cd1feb07dce71d4621af5034266e61031c30d0403890546fae40841b","version":2,"vin":[{"scriptSig":{"asm":"b0bee4dc80f916a014504f7f1b6d50dd8ae920e69642cabab4a774c386625e9cf90949bd0b79a3dde2a6d2368b4561fdba7b9728ad4fcc3790c7b44a764b5391[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41b0bee4dc80f916a014504f7f1b6d50dd8ae920e69642cabab4a774c386625e9cf90949bd0b79a3dde2a6d2368b4561fdba7b9728ad4fcc3790c7b44a764b5391412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"539454166d97734cd21c595408e9cf38dab9a6ed8b447f2b7f7645239de3ea38","vout":0},{"scriptSig":{"asm":"176b08b273e912f821598d985cbcb2d9ab5561ba70b3a5e5c1d6eb8e35b2d4c1f4fbd63b559482b3176f237e1a16ef6fe988aaa4a10fe644540db48868fa46db[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41176b08b273e912f821598d985cbcb2d9ab5561ba70b3a5e5c1d6eb8e35b2d4c1f4fbd63b559482b3176f237e1a16ef6fe988aaa4a10fe644540db48868fa46db412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"e0c6536daa85f17d1719587e1c257db0c296636f6372cab6552577a3dad6c05e","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"ff8466dd9649390398af53191ca4a5a8c36335827f923c9f947b1ebb6b9d7362","nft":{"capability":"none","commitment":"77616c6c65742e646762"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a2277616c6c65742e646762222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e7374227d7d","hex":"6a42424e53317b226f223a22555044222c226e223a2277616c6c65742e646762222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e7374227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.0000694}]},"f3c97f7c706e7bbc497ae11e0891cf05cf8058375dd4a8659979f6ef09e670a9":{"blockhash":"0000000025b945a4fda4b6b82a687422d8fd957ee718e679fa0e378bb55d7d80","blocktime":1786058037,"confirmations":3564,"hash":"f3c97f7c706e7bbc497ae11e0891cf05cf8058375dd4a8659979f6ef09e670a9","hex":"02000000017e7501defcb85cd788475d747fc4a78785a44e6cb4d07ab011d26ccd5ab395f5000000006441cb063377b1bb9db60ea2a489c3bbc6e04a88416a97da53e5627769455f1e7bd28637d0e7f37d70b5f033b4fd1c700a466a8503a25accafae2ae6b312e3fd8a4c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000046ef7e7501defcb85cd788475d747fc4a78785a44e6cb4d07ab011d26ccd5ab395f5600a64696769627974652e7876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000003d6a3b424e53317b226f223a22524547222c226e223a2264696769627974652e78222c2272223a7b227333223a22626e732f64696769627974652f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac5f1f0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":368,"time":1786058037,"txid":"f3c97f7c706e7bbc497ae11e0891cf05cf8058375dd4a8659979f6ef09e670a9","version":2,"vin":[{"scriptSig":{"asm":"cb063377b1bb9db60ea2a489c3bbc6e04a88416a97da53e5627769455f1e7bd28637d0e7f37d70b5f033b4fd1c700a466a8503a25accafae2ae6b312e3fd8a4c[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41cb063377b1bb9db60ea2a489c3bbc6e04a88416a97da53e5627769455f1e7bd28637d0e7f37d70b5f033b4fd1c700a466a8503a25accafae2ae6b312e3fd8a4c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"f595b35acd6cd211b07ad0b46c4ea48587a7c47f745d4788d75cb8fcde01757e","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"f595b35acd6cd211b07ad0b46c4ea48587a7c47f745d4788d75cb8fcde01757e","nft":{"capability":"none","commitment":"64696769627974652e78"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2264696769627974652e78222c2272223a7b227333223a22626e732f64696769627974652f227d7d","hex":"6a3b424e53317b226f223a22524547222c226e223a2264696769627974652e78222c2272223a7b227333223a22626e732f64696769627974652f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00008031}]},"aa6bcb88f5abbf3fa36c3527049d4d90ae6e52a3eb938546b633864175cc4ba2":{"blockhash":"000000005b9e85ddb8f50cb1822d68b3fc2240335a76f85b372c253d9ff0cc02","blocktime":1786563607,"confirmations":2726,"hash":"aa6bcb88f5abbf3fa36c3527049d4d90ae6e52a3eb938546b633864175cc4ba2","hex":"02000000056d65925e40e03d1517459fc8958638d775c39ed969e7e9f4c7e20e7e35215ae2000000006441c78c203ef6b26216700e654ee52c15904e52bfa48329e1cb2ad387826862b8d2847e7ecee2968f4e5d59300f9c63d39befc7054c1d83af79ef2b2421212c74ca412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d00000000e9895ea61ce830b9e02bc1ade86f5c1bb57a1fb046bc5fddeb588f08266a77870100000064413b6515d8f0026990f39cafdb5e29c5a00d13ca01988a446597c66df4f7ba989db24490f6e7ec2c55b15cf61be037c75e37a09c868b3cea15dd4905726eed2c99412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d000000008f69b05de1a235c16e4e912106e8f972b1c390b31c34acc1ee2a82d309ba30e603000000644112c076ffbd85e7d399b20913bd150a23883d4dad8ace83667141aa1adf2c23e517df9413c114eca820a1cba8c509967355efc8f0c5c29ec1570965fa247c1cda412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000062739d6bbb1e7b949f3c927f823563c3a8a5a41c1953af9803394996dd6684ff010000006441522a8c94fc02b7721b9a8b722c4f9be8149de7c5c188107a3eb11c72d022cf942c62bcc732314e420b84f60a2cd4b2197b043c3c8d023c1711ac2a880b9eebd9412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d00000000b0256d54732cba849c70e9fb644fbcef8482aab9beac87300cc6d83a4ab8f6e30300000064415966a6532dddf1c26c70e55c31b3bbb1ea54c00c6ab7785b0ae95b1f6206739079f72b9115cea7d6935b3207633734943fee8da96f531868156d62a70f6428b7412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000042ef0ca51bbb69dde4b44d026ff1f89d10a5451db0fc8ee88d3e6d0f3b14320ed784600667616d652e7876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000836a4c80424e53317b226f223a22555044222c226e223a2267616d652e78222c2272223a7b226970223a223133302e31322e3234322e3834222c22746c73223a2236616635663763623337313565336238316262616262353164303934376363383631316133653636616437626337393430613434376332663636646565633230227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac932f0700000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":998,"time":1786563607,"txid":"aa6bcb88f5abbf3fa36c3527049d4d90ae6e52a3eb938546b633864175cc4ba2","version":2,"vin":[{"scriptSig":{"asm":"c78c203ef6b26216700e654ee52c15904e52bfa48329e1cb2ad387826862b8d2847e7ecee2968f4e5d59300f9c63d39befc7054c1d83af79ef2b2421212c74ca[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41c78c203ef6b26216700e654ee52c15904e52bfa48329e1cb2ad387826862b8d2847e7ecee2968f4e5d59300f9c63d39befc7054c1d83af79ef2b2421212c74ca412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"e25a21357e0ee2c7f4e9e769d99ec375d7388695c89f4517153de0405e92656d","vout":0},{"scriptSig":{"asm":"3b6515d8f0026990f39cafdb5e29c5a00d13ca01988a446597c66df4f7ba989db24490f6e7ec2c55b15cf61be037c75e37a09c868b3cea15dd4905726eed2c99[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"413b6515d8f0026990f39cafdb5e29c5a00d13ca01988a446597c66df4f7ba989db24490f6e7ec2c55b15cf61be037c75e37a09c868b3cea15dd4905726eed2c99412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"87776a26088f58ebdd5fbc46b01f7ab51b5c6fe8adc12be0b930e81ca65e89e9","vout":1},{"scriptSig":{"asm":"12c076ffbd85e7d399b20913bd150a23883d4dad8ace83667141aa1adf2c23e517df9413c114eca820a1cba8c509967355efc8f0c5c29ec1570965fa247c1cda[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4112c076ffbd85e7d399b20913bd150a23883d4dad8ace83667141aa1adf2c23e517df9413c114eca820a1cba8c509967355efc8f0c5c29ec1570965fa247c1cda412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"e630ba09d3822aeec1ac341cb390c3b172f9e80621914e6ec135a2e15db0698f","vout":3},{"scriptSig":{"asm":"522a8c94fc02b7721b9a8b722c4f9be8149de7c5c188107a3eb11c72d022cf942c62bcc732314e420b84f60a2cd4b2197b043c3c8d023c1711ac2a880b9eebd9[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41522a8c94fc02b7721b9a8b722c4f9be8149de7c5c188107a3eb11c72d022cf942c62bcc732314e420b84f60a2cd4b2197b043c3c8d023c1711ac2a880b9eebd9412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"ff8466dd9649390398af53191ca4a5a8c36335827f923c9f947b1ebb6b9d7362","vout":1},{"scriptSig":{"asm":"5966a6532dddf1c26c70e55c31b3bbb1ea54c00c6ab7785b0ae95b1f6206739079f72b9115cea7d6935b3207633734943fee8da96f531868156d62a70f6428b7[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"415966a6532dddf1c26c70e55c31b3bbb1ea54c00c6ab7785b0ae95b1f6206739079f72b9115cea7d6935b3207633734943fee8da96f531868156d62a70f6428b7412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"e3f6b84a3ad8c60c3087acbeb9aa8284efbc4f64fbe9709c84ba2c73546d25b0","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"84d70e32143b0f6d3e8de88efcb01d45a5109df8f16f024db4e4dd69bb1ba50c","nft":{"capability":"none","commitment":"67616d652e78"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a2267616d652e78222c2272223a7b226970223a223133302e31322e3234322e3834222c22746c73223a2236616635663763623337313565336238316262616262353164303934376363383631316133653636616437626337393430613434376332663636646565633230227d7d","hex":"6a4c80424e53317b226f223a22555044222c226e223a2267616d652e78222c2272223a7b226970223a223133302e31322e3234322e3834222c22746c73223a2236616635663763623337313565336238316262616262353164303934376363383631316133653636616437626337393430613434376332663636646565633230227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00470931}]},"fca23a6c612560c64b8e03e270e3396cf1f267f7a7071449acb64c61e5dae9c5":{"blockhash":"000000005b9e85ddb8f50cb1822d68b3fc2240335a76f85b372c253d9ff0cc02","blocktime":1786563607,"confirmations":2726,"hash":"fca23a6c612560c64b8e03e270e3396cf1f267f7a7071449acb64c61e5dae9c5","hex":"0200000005a24bcc75418633b6468593eba3526eae904d9d0427356ca33fbfabf588cb6baa000000006441dc5df9b546059b2d95aa80ecb20dc70820376a116d0d61f3ed317edfc64874b27150ecc191e73f1a03fdb8142b11d772551b30da3548b7550c3f0c8eb8ae7dfe412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d000000001b8440ae6f54903840d0301c03616e263450af21461de7dc07eb1fcd9557a7e80300000064412defe203d08856bd262ef137dd303eb0402eefd7a80f2fc9647e21158400e5a081325ab4dfda9af4d91bd981e22a3d317f14c65128af80317543df89c70623ee412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d00000000a970e609eff6799965a8d45d375880cf05cf91081ee17a49bc7b6e707c7fc9f3030000006441de1d202e43e89d32d321684668c76bf2cc3fcfe8198266bb469fda7f2eb6789c940e80eb4235c9addffbe9d16c55f6a5f8337f7b801c3569b8f951474754bd02412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d000000007e7501defcb85cd788475d747fc4a78785a44e6cb4d07ab011d26ccd5ab395f50100000064411050c2911e8e0d04a2456e216ca41be2d1af0237e326792b64e8e22455cedfd1566361c9321459e049a1102c1ed94b04045f60ae5951d323f7f8cfcc8c2f8041412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000084718b668226d07aab71d2f54b04a40978d4bfa75a481f9fbfafcbdeae70f3cc000000006441bab4e0ebc090de3c3873688f41fc4adc1362f5146598a636e616a120392a77b2ea46783f6c8c04f51b70014af4c2682b54e1627e066afb424a65053599935929412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000042ef0ca51bbb69dde4b44d026ff1f89d10a5451db0fc8ee88d3e6d0f3b14320ed784600667616d652e7876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000946a4c91424e53317b226f223a22555044222c226e223a2267616d652e78222c2272223a7b226970223a223133302e31322e3234322e3834222c22746c73223a2236616635663763623337313565336238316262616262353164303934376363383631316133653636616437626337393430613434376332663636646565633230222c227333223a22626e732f67616d652f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac520b0400000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":1015,"time":1786563607,"txid":"fca23a6c612560c64b8e03e270e3396cf1f267f7a7071449acb64c61e5dae9c5","version":2,"vin":[{"scriptSig":{"asm":"dc5df9b546059b2d95aa80ecb20dc70820376a116d0d61f3ed317edfc64874b27150ecc191e73f1a03fdb8142b11d772551b30da3548b7550c3f0c8eb8ae7dfe[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41dc5df9b546059b2d95aa80ecb20dc70820376a116d0d61f3ed317edfc64874b27150ecc191e73f1a03fdb8142b11d772551b30da3548b7550c3f0c8eb8ae7dfe412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"aa6bcb88f5abbf3fa36c3527049d4d90ae6e52a3eb938546b633864175cc4ba2","vout":0},{"scriptSig":{"asm":"2defe203d08856bd262ef137dd303eb0402eefd7a80f2fc9647e21158400e5a081325ab4dfda9af4d91bd981e22a3d317f14c65128af80317543df89c70623ee[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"412defe203d08856bd262ef137dd303eb0402eefd7a80f2fc9647e21158400e5a081325ab4dfda9af4d91bd981e22a3d317f14c65128af80317543df89c70623ee412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"e8a75795cd1feb07dce71d4621af5034266e61031c30d0403890546fae40841b","vout":3},{"scriptSig":{"asm":"de1d202e43e89d32d321684668c76bf2cc3fcfe8198266bb469fda7f2eb6789c940e80eb4235c9addffbe9d16c55f6a5f8337f7b801c3569b8f951474754bd02[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41de1d202e43e89d32d321684668c76bf2cc3fcfe8198266bb469fda7f2eb6789c940e80eb4235c9addffbe9d16c55f6a5f8337f7b801c3569b8f951474754bd02412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"f3c97f7c706e7bbc497ae11e0891cf05cf8058375dd4a8659979f6ef09e670a9","vout":3},{"scriptSig":{"asm":"1050c2911e8e0d04a2456e216ca41be2d1af0237e326792b64e8e22455cedfd1566361c9321459e049a1102c1ed94b04045f60ae5951d323f7f8cfcc8c2f8041[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"411050c2911e8e0d04a2456e216ca41be2d1af0237e326792b64e8e22455cedfd1566361c9321459e049a1102c1ed94b04045f60ae5951d323f7f8cfcc8c2f8041412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"f595b35acd6cd211b07ad0b46c4ea48587a7c47f745d4788d75cb8fcde01757e","vout":1},{"scriptSig":{"asm":"bab4e0ebc090de3c3873688f41fc4adc1362f5146598a636e616a120392a77b2ea46783f6c8c04f51b70014af4c2682b54e1627e066afb424a65053599935929[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41bab4e0ebc090de3c3873688f41fc4adc1362f5146598a636e616a120392a77b2ea46783f6c8c04f51b70014af4c2682b54e1627e066afb424a65053599935929412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"ccf370aedecbafbf9f1f485aa7bfd47809a4044bf5d271ab7ad02682668b7184","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"84d70e32143b0f6d3e8de88efcb01d45a5109df8f16f024db4e4dd69bb1ba50c","nft":{"capability":"none","commitment":"67616d652e78"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a2267616d652e78222c2272223a7b226970223a223133302e31322e3234322e3834222c22746c73223a2236616635663763623337313565336238316262616262353164303934376363383631316133653636616437626337393430613434376332663636646565633230222c227333223a22626e732f67616d652f227d7d","hex":"6a4c91424e53317b226f223a22555044222c226e223a2267616d652e78222c2272223a7b226970223a223133302e31322e3234322e3834222c22746c73223a2236616635663763623337313565336238316262616262353164303934376363383631316133653636616437626337393430613434376332663636646565633230222c227333223a22626e732f67616d652f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00265042}]},"7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b":{"blockhash":"00000000000016a10ca441a6ff43c77851519206d5993fba8001ebd04982f11e","blocktime":1787074805,"confirmations":1767,"hash":"7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b","hex":"0200000002f6e00a2b7862d04da8493c70211b9380010b790666e6fa68460fa4c7904e61e0000000006441a0d54b756d4542683e02ce587c326d35c4d2601aaa181f868750683d2adc3298e329e8c0fb9eefffff9c77ed2aa8e10cbc40bbcf4759b9da6c5531dcc0bd8d91412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d00000000f6e00a2b7862d04da8493c70211b9380010b790666e6fa68460fa4c7904e61e0010000006441218a2de8e67ee05193d1f1924ea5e31af18199c097f245039b6f594c66cc42cdfc82ea39f5351462109c422dac46023d33a9da9c444c4afdf0d98c59ce71c49d412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000042eff6e00a2b7862d04da8493c70211b9380010b790666e6fa68460fa4c7904e61e060066865726d657376a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000826a4c7f424e53317b226f223a22524547222c226e223a226865726d6573222c2272223a7b226e70223a2266326339323531393463353331633763333938303137653335623233393664663634613631613631336161356661646562646631663439393066323236376634222c226e72223a227773733a2f2f6e6f732e6c6f6c227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88acf2091b00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":574,"time":1787074805,"txid":"7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b","version":2,"vin":[{"scriptSig":{"asm":"a0d54b756d4542683e02ce587c326d35c4d2601aaa181f868750683d2adc3298e329e8c0fb9eefffff9c77ed2aa8e10cbc40bbcf4759b9da6c5531dcc0bd8d91[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41a0d54b756d4542683e02ce587c326d35c4d2601aaa181f868750683d2adc3298e329e8c0fb9eefffff9c77ed2aa8e10cbc40bbcf4759b9da6c5531dcc0bd8d91412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"e0614e90c7a40f4668fae66606790b0180931b21703c49a84dd062782b0ae0f6","vout":0},{"scriptSig":{"asm":"218a2de8e67ee05193d1f1924ea5e31af18199c097f245039b6f594c66cc42cdfc82ea39f5351462109c422dac46023d33a9da9c444c4afdf0d98c59ce71c49d[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41218a2de8e67ee05193d1f1924ea5e31af18199c097f245039b6f594c66cc42cdfc82ea39f5351462109c422dac46023d33a9da9c444c4afdf0d98c59ce71c49d412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"e0614e90c7a40f4668fae66606790b0180931b21703c49a84dd062782b0ae0f6","vout":1}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"e0614e90c7a40f4668fae66606790b0180931b21703c49a84dd062782b0ae0f6","nft":{"capability":"none","commitment":"6865726d6573"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a226865726d6573222c2272223a7b226e70223a2266326339323531393463353331633763333938303137653335623233393664663634613631613631336161356661646562646631663439393066323236376634222c226e72223a227773733a2f2f6e6f732e6c6f6c227d7d","hex":"6a4c7f424e53317b226f223a22524547222c226e223a226865726d6573222c2272223a7b226e70223a2266326339323531393463353331633763333938303137653335623233393664663634613631613631336161356661646562646631663439393066323236376634222c226e72223a227773733a2f2f6e6f732e6c6f6c227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.01772018}]},"29fb60110478b0509513047ef3d52886e5abb2e70ca4b51e2d13a11d55c9d207":{"blockhash":"00000000000013cb57d37b74fd12aef25bd362b628aa4ed5df27b46aaddcf543","blocktime":1787097178,"confirmations":1730,"hash":"29fb60110478b0509513047ef3d52886e5abb2e70ca4b51e2d13a11d55c9d207","hex":"02000000024ed0c357f89105a784fcd97f4ae182630665fb3f3eb0d537fcbd5cf02ed73805000000006441a9fb14660b0569bc2ecb02834287336d31dd77307c93f822fd12d93ff48cdee3d1d5a22afc78d3234924218ee16da4f20f24d33fb6f95747590a94c40620df4f412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d000000004ed0c357f89105a784fcd97f4ae182630665fb3f3eb0d537fcbd5cf02ed73805010000006441c279629baddb98cf48c8213f2e75d9f4d16dcebaa6962136cd8a9fb74025a60dc22693388608a699e6ba4e59eab7a0533c03e2aeddafddd5e9ce89141295f2f9412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e8030000000000004aef4ed0c357f89105a784fcd97f4ae182630665fb3f3eb0d537fcbd5cf02ed73805600e636f696e737065637472756d2e7876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000002d6a2b424e53317b226f223a22524547222c226e223a22636f696e737065637472756d2e78222c2272223a7b7d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ace4001b00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":497,"time":1787097178,"txid":"29fb60110478b0509513047ef3d52886e5abb2e70ca4b51e2d13a11d55c9d207","version":2,"vin":[{"scriptSig":{"asm":"a9fb14660b0569bc2ecb02834287336d31dd77307c93f822fd12d93ff48cdee3d1d5a22afc78d3234924218ee16da4f20f24d33fb6f95747590a94c40620df4f[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41a9fb14660b0569bc2ecb02834287336d31dd77307c93f822fd12d93ff48cdee3d1d5a22afc78d3234924218ee16da4f20f24d33fb6f95747590a94c40620df4f412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"0538d72ef05cbdfc37d5b03e3ffb65066382e14a7fd9fc84a70591f857c3d04e","vout":0},{"scriptSig":{"asm":"c279629baddb98cf48c8213f2e75d9f4d16dcebaa6962136cd8a9fb74025a60dc22693388608a699e6ba4e59eab7a0533c03e2aeddafddd5e9ce89141295f2f9[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41c279629baddb98cf48c8213f2e75d9f4d16dcebaa6962136cd8a9fb74025a60dc22693388608a699e6ba4e59eab7a0533c03e2aeddafddd5e9ce89141295f2f9412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"0538d72ef05cbdfc37d5b03e3ffb65066382e14a7fd9fc84a70591f857c3d04e","vout":1}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"0538d72ef05cbdfc37d5b03e3ffb65066382e14a7fd9fc84a70591f857c3d04e","nft":{"capability":"none","commitment":"636f696e737065637472756d2e78"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22636f696e737065637472756d2e78222c2272223a7b7d7d","hex":"6a2b424e53317b226f223a22524547222c226e223a22636f696e737065637472756d2e78222c2272223a7b7d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.017697}]},"7af6ff492f81bee6d8549be620975e267c0270a8f37e6c800ceab14e2e0c11c7":{"blockhash":"0000000000000c632fa04e926cca5aa0726729729e86bba2c8c4a6973c6ea8a2","blocktime":1787294644,"confirmations":1399,"hash":"7af6ff492f81bee6d8549be620975e267c0270a8f37e6c800ceab14e2e0c11c7","hex":"020000000207d2c9551da1132d1eb5a40ce7b2abe58628d5f37e04139550b078041160fb29000000006441ba1cb364f7f4c7f1bc3bee05fe55e95b085d022156d78e032972a9b7b96693290f3ae9c9256c09160aa14117bb0603a6fbe2325742156af4f3f4ebf76e03be36412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000047876d280dd1fb5a6591d5aaf9333db523f9337be7bb5aa439c61732e7a8c986030000006441e988c0892bd21b7d9ab43f7527cbf8105d21d37ecc58fcdf009e6f256f16a83ac0cf873927e2d9111cdadd470cd43b3b4dced39ccbaea9c8517d30a815208b57412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e8030000000000004aef4ed0c357f89105a784fcd97f4ae182630665fb3f3eb0d537fcbd5cf02ed73805600e636f696e737065637472756d2e7876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000456a43424e53317b226f223a22555044222c226e223a22636f696e737065637472756d2e78222c2272223a7b227333223a22626e732f636f696e737065637472756d2f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88acaec61a00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":521,"time":1787294644,"txid":"7af6ff492f81bee6d8549be620975e267c0270a8f37e6c800ceab14e2e0c11c7","version":2,"vin":[{"scriptSig":{"asm":"ba1cb364f7f4c7f1bc3bee05fe55e95b085d022156d78e032972a9b7b96693290f3ae9c9256c09160aa14117bb0603a6fbe2325742156af4f3f4ebf76e03be36[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41ba1cb364f7f4c7f1bc3bee05fe55e95b085d022156d78e032972a9b7b96693290f3ae9c9256c09160aa14117bb0603a6fbe2325742156af4f3f4ebf76e03be36412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"29fb60110478b0509513047ef3d52886e5abb2e70ca4b51e2d13a11d55c9d207","vout":0},{"scriptSig":{"asm":"e988c0892bd21b7d9ab43f7527cbf8105d21d37ecc58fcdf009e6f256f16a83ac0cf873927e2d9111cdadd470cd43b3b4dced39ccbaea9c8517d30a815208b57[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41e988c0892bd21b7d9ab43f7527cbf8105d21d37ecc58fcdf009e6f256f16a83ac0cf873927e2d9111cdadd470cd43b3b4dced39ccbaea9c8517d30a815208b57412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"86c9a8e73217c639a45abbe77b33f923b53d33f9aad591655afbd10d286d8747","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"0538d72ef05cbdfc37d5b03e3ffb65066382e14a7fd9fc84a70591f857c3d04e","nft":{"capability":"none","commitment":"636f696e737065637472756d2e78"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a22636f696e737065637472756d2e78222c2272223a7b227333223a22626e732f636f696e737065637472756d2f227d7d","hex":"6a43424e53317b226f223a22555044222c226e223a22636f696e737065637472756d2e78222c2272223a7b227333223a22626e732f636f696e737065637472756d2f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.01754798}]},"25ae9044a406eec718138de1fcde267618cb02de8dc8f49a95ee88f4077afa1a":{"blockhash":"000000000007d17f11b0de2e1b750d7631fa7ba13169364d5466f5c32f5d37ad","blocktime":1787997563,"confirmations":228,"hash":"25ae9044a406eec718138de1fcde267618cb02de8dc8f49a95ee88f4077afa1a","hex":"0200000002b496c1e4f5c8fa5f721b3f49aa1c44ac6ef96cda197e1c9dcf6911b6729808060000000064416c6e5fd741a153e9dbf8909bd1489115a821e709b47a6b3f8cba82b6bf28906b0755f6ac3036aed3b8851bf24a0fe4e9e678c34fd78ec1ed7d6a0eff7c168230412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d00000000c7110c2e4eb1ea0c806c7ef3a870027c265e9720e69b54d8e6be812f49fff67a0300000064414169f6a281c14684d9184a6371b2a6c037125af42d74b16bbee4d18989d491996f46ffd67a4e22f0588379a50caf4aa1b4763734cf4379f1328f46ab3c11635c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000048ef1f1bc54a5508d588662bc0f151790d80bfaf687961673e3dd19ad3fb264bb655600c636f696e737065637472756d76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000004a6a48424e53317b226f223a22555044222c226e223a22636f696e737065637472756d222c2272223a7b2275223a2268747470733a2f2f636f696e2d737065637472756d2e636f6d227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac49c21a00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":524,"time":1787997563,"txid":"25ae9044a406eec718138de1fcde267618cb02de8dc8f49a95ee88f4077afa1a","version":2,"vin":[{"scriptSig":{"asm":"6c6e5fd741a153e9dbf8909bd1489115a821e709b47a6b3f8cba82b6bf28906b0755f6ac3036aed3b8851bf24a0fe4e9e678c34fd78ec1ed7d6a0eff7c168230[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"416c6e5fd741a153e9dbf8909bd1489115a821e709b47a6b3f8cba82b6bf28906b0755f6ac3036aed3b8851bf24a0fe4e9e678c34fd78ec1ed7d6a0eff7c168230412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"06089872b61169cf9d1c7e19da6cf96eac441caa493f1b725ffac8f5e4c196b4","vout":0},{"scriptSig":{"asm":"4169f6a281c14684d9184a6371b2a6c037125af42d74b16bbee4d18989d491996f46ffd67a4e22f0588379a50caf4aa1b4763734cf4379f1328f46ab3c11635c[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"414169f6a281c14684d9184a6371b2a6c037125af42d74b16bbee4d18989d491996f46ffd67a4e22f0588379a50caf4aa1b4763734cf4379f1328f46ab3c11635c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"7af6ff492f81bee6d8549be620975e267c0270a8f37e6c800ceab14e2e0c11c7","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"55b64b26fbd39ad13d3e67617968afbf800d7951f1c02b6688d508554ac51b1f","nft":{"capability":"none","commitment":"636f696e737065637472756d"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a22636f696e737065637472756d222c2272223a7b2275223a2268747470733a2f2f636f696e2d737065637472756d2e636f6d227d7d","hex":"6a48424e53317b226f223a22555044222c226e223a22636f696e737065637472756d222c2272223a7b2275223a2268747470733a2f2f636f696e2d737065637472756d2e636f6d227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.01753673}]},"992dd62690ec58ba4d2d5e0019ffd2c1d07fc96e53600f1e4eb239382f33ec4b":{"blockhash":"00000000000dac57ef50c83cdbd408dd6db0bb55c84cbe66200dbc1464136d5d","blocktime":1788011341,"confirmations":204,"hash":"992dd62690ec58ba4d2d5e0019ffd2c1d07fc96e53600f1e4eb239382f33ec4b","hex":"0200000002a970e609eff6799965a8d45d375880cf05cf91081ee17a49bc7b6e707c7fc9f3000000006441bd18ca58447116b03cfb0ec396cf4da11321018afba46cf0aa6f62ccdac81f7e75edb5ea6912f8dc9f3004ab2d4185c7d24c7691aef4b0b69750366fdc060be4412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d000000008b19793817c618fb9d76a3de46afbaad1d93785a695c1b41328edc29a4d19bfa030000006441c3fd113a77398b33a8c38b91be9d310a18e2d02ab1db3eab7c5e5c137d0b13b6ac5318d49dd67671f948dd146c7a0eea6873b8c3641aa1b97f614987e867a7e4412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000046ef7e7501defcb85cd788475d747fc4a78785a44e6cb4d07ab011d26ccd5ab395f5600a64696769627974652e7876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000896a4c86424e53317b226f223a22555044222c226e223a2264696769627974652e78222c2272223a7b226970223a223139352e3138342e3234372e313036222c22746c73223a2235633337346666323632633065646663316464653538633661383931386430643433336239633739333430633339373265333164383263386439346265353330227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88acf3631a00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":585,"time":1788011341,"txid":"992dd62690ec58ba4d2d5e0019ffd2c1d07fc96e53600f1e4eb239382f33ec4b","version":2,"vin":[{"scriptSig":{"asm":"bd18ca58447116b03cfb0ec396cf4da11321018afba46cf0aa6f62ccdac81f7e75edb5ea6912f8dc9f3004ab2d4185c7d24c7691aef4b0b69750366fdc060be4[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41bd18ca58447116b03cfb0ec396cf4da11321018afba46cf0aa6f62ccdac81f7e75edb5ea6912f8dc9f3004ab2d4185c7d24c7691aef4b0b69750366fdc060be4412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"f3c97f7c706e7bbc497ae11e0891cf05cf8058375dd4a8659979f6ef09e670a9","vout":0},{"scriptSig":{"asm":"c3fd113a77398b33a8c38b91be9d310a18e2d02ab1db3eab7c5e5c137d0b13b6ac5318d49dd67671f948dd146c7a0eea6873b8c3641aa1b97f614987e867a7e4[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41c3fd113a77398b33a8c38b91be9d310a18e2d02ab1db3eab7c5e5c137d0b13b6ac5318d49dd67671f948dd146c7a0eea6873b8c3641aa1b97f614987e867a7e4412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"fa9bd1a429dc8e32411b5c695a78931dadbaaf46dea3769dfb18c6173879198b","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"f595b35acd6cd211b07ad0b46c4ea48587a7c47f745d4788d75cb8fcde01757e","nft":{"capability":"none","commitment":"64696769627974652e78"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a2264696769627974652e78222c2272223a7b226970223a223139352e3138342e3234372e313036222c22746c73223a2235633337346666323632633065646663316464653538633661383931386430643433336239633739333430633339373265333164383263386439346265353330227d7d","hex":"6a4c86424e53317b226f223a22555044222c226e223a2264696769627974652e78222c2272223a7b226970223a223139352e3138342e3234372e313036222c22746c73223a2235633337346666323632633065646663316464653538633661383931386430643433336239633739333430633339373265333164383263386439346265353330227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.01729523}]},"8d1cc7b619b003982bb957693bf7177cc492c3a8a1e4c29ca81d683bf9b3e902":{"blockhash":"00000000000012eeab597dc790315b8ceaf537c4efc3830f448ae4f630a7841f","blocktime":1788013051,"confirmations":201,"hash":"8d1cc7b619b003982bb957693bf7177cc492c3a8a1e4c29ca81d683bf9b3e902","hex":"02000000013153e2a8d1bf0314eabf4e683740acf2f139f1b9758b49c1f0b787e70d99dc100000000064417f8370f88db6a1cc53ef0cfb41a0b75781c2b6352db24e49816a3825989968db449eb9ffdfa11376b7e6bee5d6e36640449234d51d84be6c017f71becd6529c741210382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63feffffff05084e0f00000000001976a91488553853176342b8401507e8a9c4fa48347ead9488ace80300000000000047ef3153e2a8d1bf0314eabf4e683740acf2f139f1b9758b49c1f0b787e70d99dc10600b706f74696465612e61736d76a91488553853176342b8401507e8a9c4fa48347ead9488ac00000000000000002a6a28424e53317b226f223a22524547222c226e223a22706f74696465612e61736d222c2272223a7b7d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac10270000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":384,"time":1788013051,"txid":"8d1cc7b619b003982bb957693bf7177cc492c3a8a1e4c29ca81d683bf9b3e902","version":2,"vin":[{"scriptSig":{"asm":"7f8370f88db6a1cc53ef0cfb41a0b75781c2b6352db24e49816a3825989968db449eb9ffdfa11376b7e6bee5d6e36640449234d51d84be6c017f71becd6529c7[ALL|FORKID] 0382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63","hex":"417f8370f88db6a1cc53ef0cfb41a0b75781c2b6352db24e49816a3825989968db449eb9ffdfa11376b7e6bee5d6e36640449234d51d84be6c017f71becd6529c741210382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63"},"sequence":4294967294,"txid":"10dc990de787b7f0c1498b75b9f139f1f2ac4037684ebfea1403bfd1a8e25331","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzy92wznza359wzqz5r732wylfyrgl4djsl2agdp0p"],"asm":"OP_DUP OP_HASH160 88553853176342b8401507e8a9c4fa48347ead94 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a91488553853176342b8401507e8a9c4fa48347ead9488ac","reqSigs":1,"type":"pubkeyhash"},"value":0.01003016},{"n":1,"scriptPubKey":{"addresses":["bchtest:qzy92wznza359wzqz5r732wylfyrgl4djsl2agdp0p"],"asm":"OP_DUP OP_HASH160 88553853176342b8401507e8a9c4fa48347ead94 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a91488553853176342b8401507e8a9c4fa48347ead9488ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"10dc990de787b7f0c1498b75b9f139f1f2ac4037684ebfea1403bfd1a8e25331","nft":{"capability":"none","commitment":"706f74696465612e61736d"}},"value":0.00001},{"n":2,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22706f74696465612e61736d222c2272223a7b7d7d","hex":"6a28424e53317b226f223a22524547222c226e223a22706f74696465612e61736d222c2272223a7b7d7d","type":"nulldata"},"value":0},{"n":3,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":4,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.0001}]},"15bec52981fa82f9fa1c966600204112b7478a821f8384e0c8fecaea375215e9":{"blockhash":"000000000006276522cd61e0e11ed388aa57a127554fe7dcb9c90aaa339b2cba","blocktime":1788019372,"confirmations":194,"hash":"15bec52981fa82f9fa1c966600204112b7478a821f8384e0c8fecaea375215e9","hex":"0200000002a81b45291b859d8f7b902163732e4f9a80a5526a1303eacbf497b0f18cb150bf000000006441702cbf3156120411f1c06fc672f6e319c8c808dd136300de80aa934566f5fd56c91db957160d9dafbfc43241a2ad53192672280016a8b519b615045d135bd35f412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000002e9b3f93b681da89cc2e4a1a8c392c47c17f73b6957b92b9803b019b6c71c8d040000006441940a54fd73f0b42e2555d3139ff26b88eed71566900e5a898a4541d2d998fa9160ae363ab4593e57e3370442a546ec0f9d89ee97eef9c28f847effd3c3940869412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000044efa81b45291b859d8f7b902163732e4f9a80a5526a1303eacbf497b0f18cb150bf60087369726975732e7876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000276a25424e53317b226f223a22524547222c226e223a227369726975732e78222c2272223a7b7d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88acfa450000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":485,"time":1788019372,"txid":"15bec52981fa82f9fa1c966600204112b7478a821f8384e0c8fecaea375215e9","version":2,"vin":[{"scriptSig":{"asm":"702cbf3156120411f1c06fc672f6e319c8c808dd136300de80aa934566f5fd56c91db957160d9dafbfc43241a2ad53192672280016a8b519b615045d135bd35f[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41702cbf3156120411f1c06fc672f6e319c8c808dd136300de80aa934566f5fd56c91db957160d9dafbfc43241a2ad53192672280016a8b519b615045d135bd35f412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"bf50b18cf1b097f4cbea03136a52a5809a4f2e736321907b8f9d851b29451ba8","vout":0},{"scriptSig":{"asm":"940a54fd73f0b42e2555d3139ff26b88eed71566900e5a898a4541d2d998fa9160ae363ab4593e57e3370442a546ec0f9d89ee97eef9c28f847effd3c3940869[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41940a54fd73f0b42e2555d3139ff26b88eed71566900e5a898a4541d2d998fa9160ae363ab4593e57e3370442a546ec0f9d89ee97eef9c28f847effd3c3940869412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"8d1cc7b619b003982bb957693bf7177cc492c3a8a1e4c29ca81d683bf9b3e902","vout":4}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"bf50b18cf1b097f4cbea03136a52a5809a4f2e736321907b8f9d851b29451ba8","nft":{"capability":"none","commitment":"7369726975732e78"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a227369726975732e78222c2272223a7b7d7d","hex":"6a25424e53317b226f223a22524547222c226e223a227369726975732e78222c2272223a7b7d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00017914}]},"85b1ece01c7910d5a17b1b28fa40f4c3680a3153b1960b332f9b8689ff7861a4":{"blockhash":"000000000006276522cd61e0e11ed388aa57a127554fe7dcb9c90aaa339b2cba","blocktime":1788019372,"confirmations":194,"hash":"85b1ece01c7910d5a17b1b28fa40f4c3680a3153b1960b332f9b8689ff7861a4","hex":"0200000002e9155237eacafec8e084831f828a47b71241200066961cfaf982fa8129c5be15000000006441d2606b452016653f7bc7bc6d3f61555a8473f3f39cefef4d2c45e9b47bb59c4653257732eff8c6a8dca9ccae547057674e629e0d7638eb0176a1fa1ba877c7e6412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d00000000a81b45291b859d8f7b902163732e4f9a80a5526a1303eacbf497b0f18cb150bf0100000064411f0b10cfa37f180cdb71147b5699e3f45db0bf1079198ff8674ad96b2569c1a08a86ca21a3d49a41071592befad44398125da8255d127f352d29fc3ce64c2178412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000044efa81b45291b859d8f7b902163732e4f9a80a5526a1303eacbf497b0f18cb150bf60087369726975732e7876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000003a6a38424e53317b226f223a22555044222c226e223a227369726975732e78222c2272223a7b227333223a22626e732f7369726975732e78227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88acb6371a00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":504,"time":1788019372,"txid":"85b1ece01c7910d5a17b1b28fa40f4c3680a3153b1960b332f9b8689ff7861a4","version":2,"vin":[{"scriptSig":{"asm":"d2606b452016653f7bc7bc6d3f61555a8473f3f39cefef4d2c45e9b47bb59c4653257732eff8c6a8dca9ccae547057674e629e0d7638eb0176a1fa1ba877c7e6[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41d2606b452016653f7bc7bc6d3f61555a8473f3f39cefef4d2c45e9b47bb59c4653257732eff8c6a8dca9ccae547057674e629e0d7638eb0176a1fa1ba877c7e6412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"15bec52981fa82f9fa1c966600204112b7478a821f8384e0c8fecaea375215e9","vout":0},{"scriptSig":{"asm":"1f0b10cfa37f180cdb71147b5699e3f45db0bf1079198ff8674ad96b2569c1a08a86ca21a3d49a41071592befad44398125da8255d127f352d29fc3ce64c2178[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"411f0b10cfa37f180cdb71147b5699e3f45db0bf1079198ff8674ad96b2569c1a08a86ca21a3d49a41071592befad44398125da8255d127f352d29fc3ce64c2178412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"bf50b18cf1b097f4cbea03136a52a5809a4f2e736321907b8f9d851b29451ba8","vout":1}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"bf50b18cf1b097f4cbea03136a52a5809a4f2e736321907b8f9d851b29451ba8","nft":{"capability":"none","commitment":"7369726975732e78"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a227369726975732e78222c2272223a7b227333223a22626e732f7369726975732e78227d7d","hex":"6a38424e53317b226f223a22555044222c226e223a227369726975732e78222c2272223a7b227333223a22626e732f7369726975732e78227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.01718198}]},"f671540f0204449ca4b6912ad7df11e07f719940264ff67889599478e7424969":{"blockhash":"00000000000008d2ea27a1fc927946b4f1c3e19c15a1138d70156c92c40210bc","blocktime":1788019683,"confirmations":193,"hash":"f671540f0204449ca4b6912ad7df11e07f719940264ff67889599478e7424969","hex":"0200000002a46178ff89869b2f330b96b153310a68c3f440fa281b7ba1d510791ce0ecb18500000000644167f476f5af67c1d8b14f96f9f51ba13d97a40bbd2929f919fd909d83b2bffc08311e51bd1b0f0afc7c1e1bcaa2c5ecece827fd5bf0d43a9f67c11247b9beae05412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d00000000e9155237eacafec8e084831f828a47b71241200066961cfaf982fa8129c5be150300000064414abe03ba08c76550303afdb3b444961120b9c3a6d6d891b09ff628d64711afe6953943b85f5b28b06b8c1d7f1e3b48355b32d2ef53dac8cbf8a58e21785e904f412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000044efa81b45291b859d8f7b902163732e4f9a80a5526a1303eacbf497b0f18cb150bf60087369726975732e7876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000003b6a39424e53317b226f223a22555044222c226e223a227369726975732e78222c2272223a7b227333223a22626e732f7369726975732e782f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88aca8410000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":505,"time":1788019683,"txid":"f671540f0204449ca4b6912ad7df11e07f719940264ff67889599478e7424969","version":2,"vin":[{"scriptSig":{"asm":"67f476f5af67c1d8b14f96f9f51ba13d97a40bbd2929f919fd909d83b2bffc08311e51bd1b0f0afc7c1e1bcaa2c5ecece827fd5bf0d43a9f67c11247b9beae05[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4167f476f5af67c1d8b14f96f9f51ba13d97a40bbd2929f919fd909d83b2bffc08311e51bd1b0f0afc7c1e1bcaa2c5ecece827fd5bf0d43a9f67c11247b9beae05412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"85b1ece01c7910d5a17b1b28fa40f4c3680a3153b1960b332f9b8689ff7861a4","vout":0},{"scriptSig":{"asm":"4abe03ba08c76550303afdb3b444961120b9c3a6d6d891b09ff628d64711afe6953943b85f5b28b06b8c1d7f1e3b48355b32d2ef53dac8cbf8a58e21785e904f[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"414abe03ba08c76550303afdb3b444961120b9c3a6d6d891b09ff628d64711afe6953943b85f5b28b06b8c1d7f1e3b48355b32d2ef53dac8cbf8a58e21785e904f412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"15bec52981fa82f9fa1c966600204112b7478a821f8384e0c8fecaea375215e9","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"bf50b18cf1b097f4cbea03136a52a5809a4f2e736321907b8f9d851b29451ba8","nft":{"capability":"none","commitment":"7369726975732e78"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a227369726975732e78222c2272223a7b227333223a22626e732f7369726975732e782f227d7d","hex":"6a39424e53317b226f223a22555044222c226e223a227369726975732e78222c2272223a7b227333223a22626e732f7369726975732e782f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00016808}]},"f13c533db23ed457388ac78020ba38f240fca69d6f4c05f953741dbc6a88e1f8":{"blockhash":"0000000000000df2878ca045fe4a2de86894f6c79ac01264d435031141ac4c67","blocktime":1788027160,"confirmations":178,"hash":"f13c533db23ed457388ac78020ba38f240fca69d6f4c05f953741dbc6a88e1f8","hex":"020000000202e9b3f93b681da89cc2e4a1a8c392c47c17f73b6957b92b9803b019b6c71c8d01000000644176dfa17b76568bfc5ac038efdeb83acb42f9ed31e52c35002055ef8ffa643fe62fab76997b45450ba613b9fdfd452ab3c6ad710c01ac470d1de52e87999cfe1041210382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c6300000000e1570bef5838bfe8f10f78a2ae3afeff6d2c34d7cf989869fcb79f64ff6311f30300000064410508f3cf9dea02bc7c1bee543f62053924d1dd2a06b3ebb57ed54a75d8068b59f5bca32dec91e0aca310bd78eddf6354b386df87b616712050f4b501419cc1fb41210382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c630000000004e80300000000000047ef3153e2a8d1bf0314eabf4e683740acf2f139f1b9758b49c1f0b787e70d99dc10600b706f74696465612e61736d76a91488553853176342b8401507e8a9c4fa48347ead9488ac00000000000000003d6a3b424e53317b226f223a22555044222c226e223a22706f74696465612e61736d222c2272223a7b226970223a2238312e33312e3231302e3635227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88acfd410f00000000001976a91488553853176342b8401507e8a9c4fa48347ead9488ac00000000","locktime":0,"size":510,"time":1788027160,"txid":"f13c533db23ed457388ac78020ba38f240fca69d6f4c05f953741dbc6a88e1f8","version":2,"vin":[{"scriptSig":{"asm":"76dfa17b76568bfc5ac038efdeb83acb42f9ed31e52c35002055ef8ffa643fe62fab76997b45450ba613b9fdfd452ab3c6ad710c01ac470d1de52e87999cfe10[ALL|FORKID] 0382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63","hex":"4176dfa17b76568bfc5ac038efdeb83acb42f9ed31e52c35002055ef8ffa643fe62fab76997b45450ba613b9fdfd452ab3c6ad710c01ac470d1de52e87999cfe1041210382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63"},"sequence":0,"txid":"8d1cc7b619b003982bb957693bf7177cc492c3a8a1e4c29ca81d683bf9b3e902","vout":1},{"scriptSig":{"asm":"0508f3cf9dea02bc7c1bee543f62053924d1dd2a06b3ebb57ed54a75d8068b59f5bca32dec91e0aca310bd78eddf6354b386df87b616712050f4b501419cc1fb[ALL|FORKID] 0382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63","hex":"410508f3cf9dea02bc7c1bee543f62053924d1dd2a06b3ebb57ed54a75d8068b59f5bca32dec91e0aca310bd78eddf6354b386df87b616712050f4b501419cc1fb41210382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63"},"sequence":0,"txid":"f31163ff649fb7fc699898cfd7342c6dfffe3aaea2780ff1e8bf3858ef0b57e1","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzy92wznza359wzqz5r732wylfyrgl4djsl2agdp0p"],"asm":"OP_DUP OP_HASH160 88553853176342b8401507e8a9c4fa48347ead94 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a91488553853176342b8401507e8a9c4fa48347ead9488ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"10dc990de787b7f0c1498b75b9f139f1f2ac4037684ebfea1403bfd1a8e25331","nft":{"capability":"none","commitment":"706f74696465612e61736d"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a22706f74696465612e61736d222c2272223a7b226970223a2238312e33312e3231302e3635227d7d","hex":"6a3b424e53317b226f223a22555044222c226e223a22706f74696465612e61736d222c2272223a7b226970223a2238312e33312e3231302e3635227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzy92wznza359wzqz5r732wylfyrgl4djsl2agdp0p"],"asm":"OP_DUP OP_HASH160 88553853176342b8401507e8a9c4fa48347ead94 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a91488553853176342b8401507e8a9c4fa48347ead9488ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00999933}]},"f31163ff649fb7fc699898cfd7342c6dfffe3aaea2780ff1e8bf3858ef0b57e1":{"blockhash":"0000000000000df2878ca045fe4a2de86894f6c79ac01264d435031141ac4c67","blocktime":1788027160,"confirmations":178,"hash":"f31163ff649fb7fc699898cfd7342c6dfffe3aaea2780ff1e8bf3858ef0b57e1","hex":"020000000102e9b3f93b681da89cc2e4a1a8c392c47c17f73b6957b92b9803b019b6c71c8d000000006441c982ac2a76f8b801078308c6d1c37867255a0b0d88fec7e930920d106674c2fa27cb3331456613c8e40c7676a3bc83daf6fd4e4faa6cf6afc19aa4d94fb85fcf41210382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c630000000004e80300000000000048ef02e9b3f93b681da89cc2e4a1a8c392c47c17f73b6957b92b9803b019b6c71c8d600c706f7469646165612e61736d76a91488553853176342b8401507e8a9c4fa48347ead9488ac00000000000000003e6a3c424e53317b226f223a22524547222c226e223a22706f7469646165612e61736d222c2272223a7b226970223a2238312e33312e3231302e3635227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac54460f00000000001976a91488553853176342b8401507e8a9c4fa48347ead9488ac00000000","locktime":0,"size":371,"time":1788027160,"txid":"f31163ff649fb7fc699898cfd7342c6dfffe3aaea2780ff1e8bf3858ef0b57e1","version":2,"vin":[{"scriptSig":{"asm":"c982ac2a76f8b801078308c6d1c37867255a0b0d88fec7e930920d106674c2fa27cb3331456613c8e40c7676a3bc83daf6fd4e4faa6cf6afc19aa4d94fb85fcf[ALL|FORKID] 0382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63","hex":"41c982ac2a76f8b801078308c6d1c37867255a0b0d88fec7e930920d106674c2fa27cb3331456613c8e40c7676a3bc83daf6fd4e4faa6cf6afc19aa4d94fb85fcf41210382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63"},"sequence":0,"txid":"8d1cc7b619b003982bb957693bf7177cc492c3a8a1e4c29ca81d683bf9b3e902","vout":0}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzy92wznza359wzqz5r732wylfyrgl4djsl2agdp0p"],"asm":"OP_DUP OP_HASH160 88553853176342b8401507e8a9c4fa48347ead94 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a91488553853176342b8401507e8a9c4fa48347ead9488ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"8d1cc7b619b003982bb957693bf7177cc492c3a8a1e4c29ca81d683bf9b3e902","nft":{"capability":"none","commitment":"706f7469646165612e61736d"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22706f7469646165612e61736d222c2272223a7b226970223a2238312e33312e3231302e3635227d7d","hex":"6a3c424e53317b226f223a22524547222c226e223a22706f7469646165612e61736d222c2272223a7b226970223a2238312e33312e3231302e3635227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzy92wznza359wzqz5r732wylfyrgl4djsl2agdp0p"],"asm":"OP_DUP OP_HASH160 88553853176342b8401507e8a9c4fa48347ead94 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a91488553853176342b8401507e8a9c4fa48347ead9488ac","reqSigs":1,"type":"pubkeyhash"},"value":0.01001044}]},"287135dbd30b4ff2977f0d0fb2b1bcfcfac6e09d9f441381ae6927ec6731c75a":{"blockhash":"00000000000f0ac4a3666cb941ec31c81ce4379ed5c4832e8668864967aa104d","blocktime":1788028379,"confirmations":177,"hash":"287135dbd30b4ff2977f0d0fb2b1bcfcfac6e09d9f441381ae6927ec6731c75a","hex":"0200000002e1570bef5838bfe8f10f78a2ae3afeff6d2c34d7cf989869fcb79f64ff6311f3000000006441432a08943d0b3aae4777d80cfaa9420c97c687370eb273c0451aa6be95ad5e7fb7fd4e84a52cf9abe01d37c2ea18ff5319a61c541f333775d623067a12605f1e41210382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c6300000000f8e1886abc1d7453f9054c6f9da6fc40f238ba2080c78a3857d43eb23d533cf1030000006441d7846bdc758ffb0f26ab12eb7557da14766a474e6555e54ba9d205d30778eeb07f2cf2c78fa807b97eb2cc3ef835c1ce4a3cb990f2c6d4dea17904b98bb0492241210382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c630000000004e80300000000000048ef02e9b3f93b681da89cc2e4a1a8c392c47c17f73b6957b92b9803b019b6c71c8d600c706f7469646165612e61736d76a91488553853176342b8401507e8a9c4fa48347ead9488ac00000000000000003f6a3d424e53317b226f223a22555044222c226e223a22706f7469646165612e61736d222c2272223a7b227333223a22626e732f706f7469646165612f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88aca33d0f00000000001976a91488553853176342b8401507e8a9c4fa48347ead9488ac00000000","locktime":0,"size":513,"time":1788028379,"txid":"287135dbd30b4ff2977f0d0fb2b1bcfcfac6e09d9f441381ae6927ec6731c75a","version":2,"vin":[{"scriptSig":{"asm":"432a08943d0b3aae4777d80cfaa9420c97c687370eb273c0451aa6be95ad5e7fb7fd4e84a52cf9abe01d37c2ea18ff5319a61c541f333775d623067a12605f1e[ALL|FORKID] 0382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63","hex":"41432a08943d0b3aae4777d80cfaa9420c97c687370eb273c0451aa6be95ad5e7fb7fd4e84a52cf9abe01d37c2ea18ff5319a61c541f333775d623067a12605f1e41210382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63"},"sequence":0,"txid":"f31163ff649fb7fc699898cfd7342c6dfffe3aaea2780ff1e8bf3858ef0b57e1","vout":0},{"scriptSig":{"asm":"d7846bdc758ffb0f26ab12eb7557da14766a474e6555e54ba9d205d30778eeb07f2cf2c78fa807b97eb2cc3ef835c1ce4a3cb990f2c6d4dea17904b98bb04922[ALL|FORKID] 0382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63","hex":"41d7846bdc758ffb0f26ab12eb7557da14766a474e6555e54ba9d205d30778eeb07f2cf2c78fa807b97eb2cc3ef835c1ce4a3cb990f2c6d4dea17904b98bb0492241210382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63"},"sequence":0,"txid":"f13c533db23ed457388ac78020ba38f240fca69d6f4c05f953741dbc6a88e1f8","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzy92wznza359wzqz5r732wylfyrgl4djsl2agdp0p"],"asm":"OP_DUP OP_HASH160 88553853176342b8401507e8a9c4fa48347ead94 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a91488553853176342b8401507e8a9c4fa48347ead9488ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"8d1cc7b619b003982bb957693bf7177cc492c3a8a1e4c29ca81d683bf9b3e902","nft":{"capability":"none","commitment":"706f7469646165612e61736d"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a22706f7469646165612e61736d222c2272223a7b227333223a22626e732f706f7469646165612f227d7d","hex":"6a3d424e53317b226f223a22555044222c226e223a22706f7469646165612e61736d222c2272223a7b227333223a22626e732f706f7469646165612f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzy92wznza359wzqz5r732wylfyrgl4djsl2agdp0p"],"asm":"OP_DUP OP_HASH160 88553853176342b8401507e8a9c4fa48347ead94 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a91488553853176342b8401507e8a9c4fa48347ead9488ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00998819}]},"7817c0d41c05d9ffc0ce0d8f45b752f4969fb3546f6c49e85174273e286c06f4":{"blockhash":"00000000000f0ac4a3666cb941ec31c81ce4379ed5c4832e8668864967aa104d","blocktime":1788028379,"confirmations":177,"hash":"7817c0d41c05d9ffc0ce0d8f45b752f4969fb3546f6c49e85174273e286c06f4","hex":"0200000002f8e1886abc1d7453f9054c6f9da6fc40f238ba2080c78a3857d43eb23d533cf100000000644138b76c1b6d5c1d63a55eedd181c90e99b993afbf5f6b19eb259524ff9312d79cbc67f5478c47499f4390edda8e3e0d4829b058d7e72676287ef29e5fe7ab9d4a41210382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63000000005ac73167ec2769ae8113449f9de0c6fafcbcb1b20f0d7f97f24f0bd3db3571280300000064410a56304f677076c6048fc3ec2ead3e2acd92bcdb721a451730cf9ad80823e3f886a76eeef8f65a60e7d4b85077d8c48b0c09319a2be6959cb8ce5f2ce4ff11f641210382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c630000000004e80300000000000047ef3153e2a8d1bf0314eabf4e683740acf2f139f1b9758b49c1f0b787e70d99dc10600b706f74696465612e61736d76a91488553853176342b8401507e8a9c4fa48347ead9488ac00000000000000003e6a3c424e53317b226f223a22555044222c226e223a22706f74696465612e61736d222c2272223a7b227333223a22626e732f706f7469646165612f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac4b390f00000000001976a91488553853176342b8401507e8a9c4fa48347ead9488ac00000000","locktime":0,"size":511,"time":1788028379,"txid":"7817c0d41c05d9ffc0ce0d8f45b752f4969fb3546f6c49e85174273e286c06f4","version":2,"vin":[{"scriptSig":{"asm":"38b76c1b6d5c1d63a55eedd181c90e99b993afbf5f6b19eb259524ff9312d79cbc67f5478c47499f4390edda8e3e0d4829b058d7e72676287ef29e5fe7ab9d4a[ALL|FORKID] 0382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63","hex":"4138b76c1b6d5c1d63a55eedd181c90e99b993afbf5f6b19eb259524ff9312d79cbc67f5478c47499f4390edda8e3e0d4829b058d7e72676287ef29e5fe7ab9d4a41210382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63"},"sequence":0,"txid":"f13c533db23ed457388ac78020ba38f240fca69d6f4c05f953741dbc6a88e1f8","vout":0},{"scriptSig":{"asm":"0a56304f677076c6048fc3ec2ead3e2acd92bcdb721a451730cf9ad80823e3f886a76eeef8f65a60e7d4b85077d8c48b0c09319a2be6959cb8ce5f2ce4ff11f6[ALL|FORKID] 0382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63","hex":"410a56304f677076c6048fc3ec2ead3e2acd92bcdb721a451730cf9ad80823e3f886a76eeef8f65a60e7d4b85077d8c48b0c09319a2be6959cb8ce5f2ce4ff11f641210382043e7088fd332d326019438abfbded1eb56e3f5c814b39c460d97d0ed18c63"},"sequence":0,"txid":"287135dbd30b4ff2977f0d0fb2b1bcfcfac6e09d9f441381ae6927ec6731c75a","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzy92wznza359wzqz5r732wylfyrgl4djsl2agdp0p"],"asm":"OP_DUP OP_HASH160 88553853176342b8401507e8a9c4fa48347ead94 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a91488553853176342b8401507e8a9c4fa48347ead9488ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"10dc990de787b7f0c1498b75b9f139f1f2ac4037684ebfea1403bfd1a8e25331","nft":{"capability":"none","commitment":"706f74696465612e61736d"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a22706f74696465612e61736d222c2272223a7b227333223a22626e732f706f7469646165612f227d7d","hex":"6a3c424e53317b226f223a22555044222c226e223a22706f74696465612e61736d222c2272223a7b227333223a22626e732f706f7469646165612f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzy92wznza359wzqz5r732wylfyrgl4djsl2agdp0p"],"asm":"OP_DUP OP_HASH160 88553853176342b8401507e8a9c4fa48347ead94 OP_EQUALVERIFY OP_CHECKSIG","hex":"76a91488553853176342b8401507e8a9c4fa48347ead9488ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00997707}]},"028e0dacd49bd164c2f209f1e2708b5d371082570b9cd91cb8595aeb21012672":{"blockhash":"0000000000000db0e1f1464526c7ebc939b23741b71e93cc3c7272cd3811b30a","blocktime":1788033000,"confirmations":169,"hash":"028e0dacd49bd164c2f209f1e2708b5d371082570b9cd91cb8595aeb21012672","hex":"02000000029e23051ab823221cc5ef698b5554d6d11a3e5a07de7093f21186314f01c3abe30000000064416b9cd8116a439be7e9c67793f67f8dfa1d96230058e35a45e71887bc32f442334ce04039680e7d7d3200ae5fef2fc1c1217a39f75eb0b161ec476021fe3a8249412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d000000009e23051ab823221cc5ef698b5554d6d11a3e5a07de7093f21186314f01c3abe3010000006441ec03431598875abef03b8e311e8d72b0aeaa6b40420bf07a758b0391fafb4466514f615e49a6e2c77b007cf5149b9c1498d5ee9ee99e7a9a310d39504ef5434c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000045ef9e23051ab823221cc5ef698b5554d6d11a3e5a07de7093f21186314f01c3abe3600964657669616e742e7876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000004d6a4b424e53317b226f223a22524547222c226e223a2264657669616e742e78222c2272223a7b2270223a2268747470733a2f2f74657374636869706e65742e6475636b646e732e6f7267227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88aca1061a00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":524,"time":1788033000,"txid":"028e0dacd49bd164c2f209f1e2708b5d371082570b9cd91cb8595aeb21012672","version":2,"vin":[{"scriptSig":{"asm":"6b9cd8116a439be7e9c67793f67f8dfa1d96230058e35a45e71887bc32f442334ce04039680e7d7d3200ae5fef2fc1c1217a39f75eb0b161ec476021fe3a8249[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"416b9cd8116a439be7e9c67793f67f8dfa1d96230058e35a45e71887bc32f442334ce04039680e7d7d3200ae5fef2fc1c1217a39f75eb0b161ec476021fe3a8249412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"e3abc3014f318611f29370de075a3e1ad1d654558b69efc51c2223b81a05239e","vout":0},{"scriptSig":{"asm":"ec03431598875abef03b8e311e8d72b0aeaa6b40420bf07a758b0391fafb4466514f615e49a6e2c77b007cf5149b9c1498d5ee9ee99e7a9a310d39504ef5434c[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41ec03431598875abef03b8e311e8d72b0aeaa6b40420bf07a758b0391fafb4466514f615e49a6e2c77b007cf5149b9c1498d5ee9ee99e7a9a310d39504ef5434c412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"e3abc3014f318611f29370de075a3e1ad1d654558b69efc51c2223b81a05239e","vout":1}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"e3abc3014f318611f29370de075a3e1ad1d654558b69efc51c2223b81a05239e","nft":{"capability":"none","commitment":"64657669616e742e78"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2264657669616e742e78222c2272223a7b2270223a2268747470733a2f2f74657374636869706e65742e6475636b646e732e6f7267227d7d","hex":"6a4b424e53317b226f223a22524547222c226e223a2264657669616e742e78222c2272223a7b2270223a2268747470733a2f2f74657374636869706e65742e6475636b646e732e6f7267227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.01705633}]},"eef83f0ba76e634e33228c7d91d40cb598e9213192b5138c4549f3a315b85ed6":{"blockhash":"0000000000000db0e1f1464526c7ebc939b23741b71e93cc3c7272cd3811b30a","blocktime":1788033000,"confirmations":169,"hash":"eef83f0ba76e634e33228c7d91d40cb598e9213192b5138c4549f3a315b85ed6","hex":"02000000024bfff21aa26fa9aca5ddcc948c68e1ed0cf2b390ffe770f6e55bf6bf0ed56d4b00000000644186765afc636e0a3945f03f9d8b5cd1b1b2cef43dd31251bb3497d7644d8a1659b16780d4246c771c0b0f180c39dd6ec02e5b93823d7cc6808a80863845dbf356412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d00000000694942e77894598978f64f264099717fe011dfd72a91b6a49c4404020f5471f60300000064414a92fdcd2fccdbbd783ab35341797d6429bf90841b90bd6580c58c604151da382798291a8dff59e56c4c66418e74e5f5648803d463853102666b0fd161d198b9412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000047ef4bfff21aa26fa9aca5ddcc948c68e1ed0cf2b390ffe770f6e55bf6bf0ed56d4b600b64657669616e742e636f6d76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000506a4c4d424e53317b226f223a22524547222c226e223a2264657669616e742e636f6d222c2272223a7b2270223a2268747470733a2f2f74657374636869706e65742e6475636b646e732e6f7267227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac66600000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":529,"time":1788033000,"txid":"eef83f0ba76e634e33228c7d91d40cb598e9213192b5138c4549f3a315b85ed6","version":2,"vin":[{"scriptSig":{"asm":"86765afc636e0a3945f03f9d8b5cd1b1b2cef43dd31251bb3497d7644d8a1659b16780d4246c771c0b0f180c39dd6ec02e5b93823d7cc6808a80863845dbf356[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4186765afc636e0a3945f03f9d8b5cd1b1b2cef43dd31251bb3497d7644d8a1659b16780d4246c771c0b0f180c39dd6ec02e5b93823d7cc6808a80863845dbf356412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"4b6dd50ebff65be5f670e7ff90b3f20cede1688c94ccdda5aca96fa21af2ff4b","vout":0},{"scriptSig":{"asm":"4a92fdcd2fccdbbd783ab35341797d6429bf90841b90bd6580c58c604151da382798291a8dff59e56c4c66418e74e5f5648803d463853102666b0fd161d198b9[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"414a92fdcd2fccdbbd783ab35341797d6429bf90841b90bd6580c58c604151da382798291a8dff59e56c4c66418e74e5f5648803d463853102666b0fd161d198b9412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"f671540f0204449ca4b6912ad7df11e07f719940264ff67889599478e7424969","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"4b6dd50ebff65be5f670e7ff90b3f20cede1688c94ccdda5aca96fa21af2ff4b","nft":{"capability":"none","commitment":"64657669616e742e636f6d"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a2264657669616e742e636f6d222c2272223a7b2270223a2268747470733a2f2f74657374636869706e65742e6475636b646e732e6f7267227d7d","hex":"6a4c4d424e53317b226f223a22524547222c226e223a2264657669616e742e636f6d222c2272223a7b2270223a2268747470733a2f2f74657374636869706e65742e6475636b646e732e6f7267227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00024678}]},"c0914f248d64161e648f07f7890c0d3c87b3acce72a578e19277515fe09288b9":{"blockhash":"00000000000005b02b9334018a16a988a70637b73ef6bcdd3594595bba4e09b2","blocktime":1788033906,"confirmations":165,"hash":"c0914f248d64161e648f07f7890c0d3c87b3acce72a578e19277515fe09288b9","hex":"020000000272260121eb5a59b81cd99c0b578210375d8b70e2f109f2c264d19bd4ac0d8e020000000064411c1482f6bd449301cbb6625075e77c3827a1464498cec5a20e938033fa4c167dcb50dcd29e2aafab25a937fa1e3ff9c495bb2b8b36bb676590499ea3c748e809412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000072260121eb5a59b81cd99c0b578210375d8b70e2f109f2c264d19bd4ac0d8e02030000006441650de8b6afd95278eda8e8e2ed9d7a77a81f6fa9fd80c63e871964d96b952769c4630c1babbe0bd40ef0171999bd53675f1b61e5ededc4195b7fb00febb30b02412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000045ef9e23051ab823221cc5ef698b5554d6d11a3e5a07de7093f21186314f01c3abe3600964657669616e742e7876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000003d6a3b424e53317b226f223a22555044222c226e223a2264657669616e742e78222c2272223a7b227333223a22626e732f64657669616e742e782f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac4c021a00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":508,"time":1788033906,"txid":"c0914f248d64161e648f07f7890c0d3c87b3acce72a578e19277515fe09288b9","version":2,"vin":[{"scriptSig":{"asm":"1c1482f6bd449301cbb6625075e77c3827a1464498cec5a20e938033fa4c167dcb50dcd29e2aafab25a937fa1e3ff9c495bb2b8b36bb676590499ea3c748e809[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"411c1482f6bd449301cbb6625075e77c3827a1464498cec5a20e938033fa4c167dcb50dcd29e2aafab25a937fa1e3ff9c495bb2b8b36bb676590499ea3c748e809412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"028e0dacd49bd164c2f209f1e2708b5d371082570b9cd91cb8595aeb21012672","vout":0},{"scriptSig":{"asm":"650de8b6afd95278eda8e8e2ed9d7a77a81f6fa9fd80c63e871964d96b952769c4630c1babbe0bd40ef0171999bd53675f1b61e5ededc4195b7fb00febb30b02[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41650de8b6afd95278eda8e8e2ed9d7a77a81f6fa9fd80c63e871964d96b952769c4630c1babbe0bd40ef0171999bd53675f1b61e5ededc4195b7fb00febb30b02412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"028e0dacd49bd164c2f209f1e2708b5d371082570b9cd91cb8595aeb21012672","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"e3abc3014f318611f29370de075a3e1ad1d654558b69efc51c2223b81a05239e","nft":{"capability":"none","commitment":"64657669616e742e78"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a2264657669616e742e78222c2272223a7b227333223a22626e732f64657669616e742e782f227d7d","hex":"6a3b424e53317b226f223a22555044222c226e223a2264657669616e742e78222c2272223a7b227333223a22626e732f64657669616e742e782f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.01704524}]},"f7c030d44183614ffaf33758326c0908565f1e7f462384121ef0d3ca36a71c03":{"blockhash":"00000000000004107dc5c67bb4d0f83f8b4db5887489b78d87398a4b2bee51ac","blocktime":1788037557,"confirmations":159,"hash":"f7c030d44183614ffaf33758326c0908565f1e7f462384121ef0d3ca36a71c03","hex":"0200000002b98892e05f517792e178a572ceacb3873c0d0c89f7078f641e16648d244f91c00000000064419fadb5d3af0df814fe8e23ac3fbca14b816cef11044d8562aa622dc9df25bd341e803c415777f2adc515dcfd1917fbaf296ca7b53ce46999b3d182d09d48a663412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d00000000d65eb815a3f349458c13b5923121e998b50cd4917d8c22334e636ea70b3ff8ee03000000644199c2b2754525caebf58ce87ea2226800ed0d2ff13b0826e69aaa08fb2f4b633200c8199bff9268f385d01a71bb29e4a5418e729eb8ff9ee21ccd2a7dac67a1c9412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000045ef9e23051ab823221cc5ef698b5554d6d11a3e5a07de7093f21186314f01c3abe3600964657669616e742e7876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000616a4c5e424e53317b226f223a22555044222c226e223a2264657669616e742e78222c2272223a7b2270223a2268747470733a2f2f6e617669676174652e73742f64657669616e742f222c227333223a22626e732f64657669616e742e782f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88aced5b0000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":544,"time":1788037557,"txid":"f7c030d44183614ffaf33758326c0908565f1e7f462384121ef0d3ca36a71c03","version":2,"vin":[{"scriptSig":{"asm":"9fadb5d3af0df814fe8e23ac3fbca14b816cef11044d8562aa622dc9df25bd341e803c415777f2adc515dcfd1917fbaf296ca7b53ce46999b3d182d09d48a663[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"419fadb5d3af0df814fe8e23ac3fbca14b816cef11044d8562aa622dc9df25bd341e803c415777f2adc515dcfd1917fbaf296ca7b53ce46999b3d182d09d48a663412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"c0914f248d64161e648f07f7890c0d3c87b3acce72a578e19277515fe09288b9","vout":0},{"scriptSig":{"asm":"99c2b2754525caebf58ce87ea2226800ed0d2ff13b0826e69aaa08fb2f4b633200c8199bff9268f385d01a71bb29e4a5418e729eb8ff9ee21ccd2a7dac67a1c9[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4199c2b2754525caebf58ce87ea2226800ed0d2ff13b0826e69aaa08fb2f4b633200c8199bff9268f385d01a71bb29e4a5418e729eb8ff9ee21ccd2a7dac67a1c9412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"eef83f0ba76e634e33228c7d91d40cb598e9213192b5138c4549f3a315b85ed6","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"e3abc3014f318611f29370de075a3e1ad1d654558b69efc51c2223b81a05239e","nft":{"capability":"none","commitment":"64657669616e742e78"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a2264657669616e742e78222c2272223a7b2270223a2268747470733a2f2f6e617669676174652e73742f64657669616e742f222c227333223a22626e732f64657669616e742e782f227d7d","hex":"6a4c5e424e53317b226f223a22555044222c226e223a2264657669616e742e78222c2272223a7b2270223a2268747470733a2f2f6e617669676174652e73742f64657669616e742f222c227333223a22626e732f64657669616e742e782f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00023533}]},"25f15012eb8f52d0fb950aa502ddde65f5352e736859fcb94d4f665015deae44":{"blockhash":"00000000000006232ed73cc4194373980710ebd20a7b10c5fa07db96857bedc4","blocktime":1788044204,"confirmations":147,"hash":"25f15012eb8f52d0fb950aa502ddde65f5352e736859fcb94d4f665015deae44","hex":"0200000002031ca736cad3f01e128423467f1e5f5608096c325837f3fa4f618341d430c0f70000000064419cd1271020fcac06052bbe250becbe25476c3dbb3702318e3dc8d97ecb97969299305db55c03e3c5d6032258ebd73fa11291d3e0d654f5632e96a7bb1e6cfaa1412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d00000000031ca736cad3f01e128423467f1e5f5608096c325837f3fa4f618341d430c0f7030000006441087a40df6cc030a8ebace551be41a6e8ca9062700d3ddd5fb66cb0f6eff35f910afca322196541ed560ead2fc43bace20b33ef37c3b214e4d4f6de7c7336e0d5412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000045ef9e23051ab823221cc5ef698b5554d6d11a3e5a07de7093f21186314f01c3abe3600964657669616e742e7876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000526a4c4f424e53317b226f223a22555044222c226e223a2264657669616e742e78222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e73742f617070732f64657669616e742f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac83570000000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":529,"time":1788044204,"txid":"25f15012eb8f52d0fb950aa502ddde65f5352e736859fcb94d4f665015deae44","version":2,"vin":[{"scriptSig":{"asm":"9cd1271020fcac06052bbe250becbe25476c3dbb3702318e3dc8d97ecb97969299305db55c03e3c5d6032258ebd73fa11291d3e0d654f5632e96a7bb1e6cfaa1[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"419cd1271020fcac06052bbe250becbe25476c3dbb3702318e3dc8d97ecb97969299305db55c03e3c5d6032258ebd73fa11291d3e0d654f5632e96a7bb1e6cfaa1412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"f7c030d44183614ffaf33758326c0908565f1e7f462384121ef0d3ca36a71c03","vout":0},{"scriptSig":{"asm":"087a40df6cc030a8ebace551be41a6e8ca9062700d3ddd5fb66cb0f6eff35f910afca322196541ed560ead2fc43bace20b33ef37c3b214e4d4f6de7c7336e0d5[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41087a40df6cc030a8ebace551be41a6e8ca9062700d3ddd5fb66cb0f6eff35f910afca322196541ed560ead2fc43bace20b33ef37c3b214e4d4f6de7c7336e0d5412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"f7c030d44183614ffaf33758326c0908565f1e7f462384121ef0d3ca36a71c03","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"e3abc3014f318611f29370de075a3e1ad1d654558b69efc51c2223b81a05239e","nft":{"capability":"none","commitment":"64657669616e742e78"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a2264657669616e742e78222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e73742f617070732f64657669616e742f227d7d","hex":"6a4c4f424e53317b226f223a22555044222c226e223a2264657669616e742e78222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e73742f617070732f64657669616e742f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.00022403}]},"52d126b356e36fc9d87f81fa06148fada01820b344d4dedeeb4fd5fba970dc5b":{"blockhash":"00000000000006232ed73cc4194373980710ebd20a7b10c5fa07db96857bedc4","blocktime":1788044204,"confirmations":147,"hash":"52d126b356e36fc9d87f81fa06148fada01820b344d4dedeeb4fd5fba970dc5b","hex":"0200000002d65eb815a3f349458c13b5923121e998b50cd4917d8c22334e636ea70b3ff8ee000000006441ab09c1c68211145e9a4237dc8f95bfc13bc0329b7e982f01cfb0328547ad158a0b5cca3ae01ead9162e1401d92d1ad488ae9546a0033b3bd10616c9b4a9d2593412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d00000000b98892e05f517792e178a572ceacb3873c0d0c89f7078f641e16648d244f91c003000000644122410bf3194174873832c539820a758a4f562c2ad2403f06d5eb681997efe84b689d2fe448c3dcd4d856da18c87c0c85904f93f9418443b2e7f02045f2fba758412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000047ef4bfff21aa26fa9aca5ddcc948c68e1ed0cf2b390ffe770f6e55bf6bf0ed56d4b600b64657669616e742e636f6d76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000546a4c51424e53317b226f223a22555044222c226e223a2264657669616e742e636f6d222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e73742f617070732f64657669616e742f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88acdefd1900000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":533,"time":1788044204,"txid":"52d126b356e36fc9d87f81fa06148fada01820b344d4dedeeb4fd5fba970dc5b","version":2,"vin":[{"scriptSig":{"asm":"ab09c1c68211145e9a4237dc8f95bfc13bc0329b7e982f01cfb0328547ad158a0b5cca3ae01ead9162e1401d92d1ad488ae9546a0033b3bd10616c9b4a9d2593[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41ab09c1c68211145e9a4237dc8f95bfc13bc0329b7e982f01cfb0328547ad158a0b5cca3ae01ead9162e1401d92d1ad488ae9546a0033b3bd10616c9b4a9d2593412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"eef83f0ba76e634e33228c7d91d40cb598e9213192b5138c4549f3a315b85ed6","vout":0},{"scriptSig":{"asm":"22410bf3194174873832c539820a758a4f562c2ad2403f06d5eb681997efe84b689d2fe448c3dcd4d856da18c87c0c85904f93f9418443b2e7f02045f2fba758[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4122410bf3194174873832c539820a758a4f562c2ad2403f06d5eb681997efe84b689d2fe448c3dcd4d856da18c87c0c85904f93f9418443b2e7f02045f2fba758412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"c0914f248d64161e648f07f7890c0d3c87b3acce72a578e19277515fe09288b9","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"4b6dd50ebff65be5f670e7ff90b3f20cede1688c94ccdda5aca96fa21af2ff4b","nft":{"capability":"none","commitment":"64657669616e742e636f6d"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a2264657669616e742e636f6d222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e73742f617070732f64657669616e742f227d7d","hex":"6a4c51424e53317b226f223a22555044222c226e223a2264657669616e742e636f6d222c2272223a7b2275223a2268747470733a2f2f73696c656e746d6f64652e73742f617070732f64657669616e742f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.0170339}]},"3867797854d67a6dda6876bc4c4a8f2826f9175bf094f2fa3eb32d2c35cbe481":{"blockhash":"000000000009dbc8200cc069ba4420ef4574e9c6c85adf7e8aafdd61f68acc5d","blocktime":1788046648,"confirmations":145,"hash":"3867797854d67a6dda6876bc4c4a8f2826f9175bf094f2fa3eb32d2c35cbe481","hex":"0200000002d640f282e353c15f6ca344477e008952688f8a60fa221d3df355b27639e7700a000000006441fbe652ada770c164492dc40b22dc0117009cc25bc3e63f9276a752ed7bee440933958a204fd6ad1e4f1e38b5546aa56de2e56fa582304c073a99580e5bf39a87412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d000000005bdc70a9fbd54febdeded444b32018a0ad8f1406fa817fd8c96fe356b326d1520300000064411bd1feb79a68c05e9207ce4dc7e9e141f6df95f7644d13aa6e1d86679eba8bfeea7dc371e8484de22104e0f594b8f04813b262fb7715828698cb3230b4fa2309412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000048efd640f282e353c15f6ca344477e008952688f8a60fa221d3df355b27639e7700a600c616e7468656d75732e64657876a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000766a4c73424e53317b226f223a22524547222c226e223a22616e7468656d75732e646578222c2272223a7b2270223a2268747470733a2f2f6e617669676174652e73742f64657669616e742f616e7468656d75732f222c227333223a22626e732f64657669616e742e782f616e7468656d75732f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac751c1a00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":568,"time":1788046648,"txid":"3867797854d67a6dda6876bc4c4a8f2826f9175bf094f2fa3eb32d2c35cbe481","version":2,"vin":[{"scriptSig":{"asm":"fbe652ada770c164492dc40b22dc0117009cc25bc3e63f9276a752ed7bee440933958a204fd6ad1e4f1e38b5546aa56de2e56fa582304c073a99580e5bf39a87[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41fbe652ada770c164492dc40b22dc0117009cc25bc3e63f9276a752ed7bee440933958a204fd6ad1e4f1e38b5546aa56de2e56fa582304c073a99580e5bf39a87412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"0a70e73976b255f33d1d22fa608a8f685289007e4744a36c5fc153e382f240d6","vout":0},{"scriptSig":{"asm":"1bd1feb79a68c05e9207ce4dc7e9e141f6df95f7644d13aa6e1d86679eba8bfeea7dc371e8484de22104e0f594b8f04813b262fb7715828698cb3230b4fa2309[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"411bd1feb79a68c05e9207ce4dc7e9e141f6df95f7644d13aa6e1d86679eba8bfeea7dc371e8484de22104e0f594b8f04813b262fb7715828698cb3230b4fa2309412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"52d126b356e36fc9d87f81fa06148fada01820b344d4dedeeb4fd5fba970dc5b","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"0a70e73976b255f33d1d22fa608a8f685289007e4744a36c5fc153e382f240d6","nft":{"capability":"none","commitment":"616e7468656d75732e646578"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22616e7468656d75732e646578222c2272223a7b2270223a2268747470733a2f2f6e617669676174652e73742f64657669616e742f616e7468656d75732f222c227333223a22626e732f64657669616e742e782f616e7468656d75732f227d7d","hex":"6a4c73424e53317b226f223a22524547222c226e223a22616e7468656d75732e646578222c2272223a7b2270223a2268747470733a2f2f6e617669676174652e73742f64657669616e742f616e7468656d75732f222c227333223a22626e732f64657669616e742e782f616e7468656d75732f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.01711221}]},"41ba0b575947ab4ddcae525cfc383a4f9f071d102c8ad7e4fd49c6f379854dcd":{"blockhash":"00000000000010ba3fc4059f821f71b035c0f8dc3b42af5f63ad68297b13e339","blocktime":1788077196,"confirmations":95,"hash":"41ba0b575947ab4ddcae525cfc383a4f9f071d102c8ad7e4fd49c6f379854dcd","hex":"02000000021c859f139654b1fe311132b9f8178ab694dec6e18f6cabc635e87142681f1e030000000064410540247b237aa5fd5f88827bae9825c955b64ce738c3e6c6cd79dcba06368f80a915fbe039bbabcb78425d3635dd3f648648d0dc0e1ea3a36c01b5e4289133dd412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d000000001c859f139654b1fe311132b9f8178ab694dec6e18f6cabc635e87142681f1e030100000064417b2184c90cb3e8200e739176f00a0011cd69415ef75eacce70c23616493d148e2236e09303653fa713b56a93e38134d656d0b64d1bd68d30347ba15fbd067d3d412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000044ef1c859f139654b1fe311132b9f8178ab694dec6e18f6cabc635e87142681f1e036008656c65637472756d76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000916a4c8e424e53317b226f223a22524547222c226e223a22656c65637472756d222c2272223a7b22656c223a227773733a2f2f636f696e737065637472756d2e6475636b646e732e6f72673a35303031317c3139352e3138342e3234372e313036207773733a2f2f636869706e65742e6263682e6e696e6a613a35303030347c3133352e3134382e3233362e323434227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac0f391a00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":591,"time":1788077196,"txid":"41ba0b575947ab4ddcae525cfc383a4f9f071d102c8ad7e4fd49c6f379854dcd","version":2,"vin":[{"scriptSig":{"asm":"0540247b237aa5fd5f88827bae9825c955b64ce738c3e6c6cd79dcba06368f80a915fbe039bbabcb78425d3635dd3f648648d0dc0e1ea3a36c01b5e4289133dd[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"410540247b237aa5fd5f88827bae9825c955b64ce738c3e6c6cd79dcba06368f80a915fbe039bbabcb78425d3635dd3f648648d0dc0e1ea3a36c01b5e4289133dd412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"031e1f684271e835c6ab6c8fe1c6de94b68a17f8b9321131feb15496139f851c","vout":0},{"scriptSig":{"asm":"7b2184c90cb3e8200e739176f00a0011cd69415ef75eacce70c23616493d148e2236e09303653fa713b56a93e38134d656d0b64d1bd68d30347ba15fbd067d3d[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"417b2184c90cb3e8200e739176f00a0011cd69415ef75eacce70c23616493d148e2236e09303653fa713b56a93e38134d656d0b64d1bd68d30347ba15fbd067d3d412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"031e1f684271e835c6ab6c8fe1c6de94b68a17f8b9321131feb15496139f851c","vout":1}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"031e1f684271e835c6ab6c8fe1c6de94b68a17f8b9321131feb15496139f851c","nft":{"capability":"none","commitment":"656c65637472756d"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22524547222c226e223a22656c65637472756d222c2272223a7b22656c223a227773733a2f2f636f696e737065637472756d2e6475636b646e732e6f72673a35303031317c3139352e3138342e3234372e313036207773733a2f2f636869706e65742e6263682e6e696e6a613a35303030347c3133352e3134382e3233362e323434227d7d","hex":"6a4c8e424e53317b226f223a22524547222c226e223a22656c65637472756d222c2272223a7b22656c223a227773733a2f2f636f696e737065637472756d2e6475636b646e732e6f72673a35303031317c3139352e3138342e3234372e313036207773733a2f2f636869706e65742e6263682e6e696e6a613a35303030347c3133352e3134382e3233362e323434227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.01718543}]},"4fa572b2337ec7844364bfe9fea613b2f8283aba7d7dd46ede73c807c2834987":{"blockhash":"00000000000010ba3fc4059f821f71b035c0f8dc3b42af5f63ad68297b13e339","blocktime":1788077196,"confirmations":95,"hash":"4fa572b2337ec7844364bfe9fea613b2f8283aba7d7dd46ede73c807c2834987","hex":"0200000003d2fa76cb58815da0a76bf6ef891c8242f38a909d1e7a62253c9f0d53f4bebd80000000006441dd77907d37d763b0682d55133a0ff8a66b3239821c3274114793f871acbfcde7989b21c7f8d775146ba461208e0d9f028ac3d878cbd81364290463e2bacc6404412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d00000000d640f282e353c15f6ca344477e008952688f8a60fa221d3df355b27639e7700a010000006441f8139513e04ac99931669a01be4b916d1a8c78b68e47be13bfe3d8274968abb61a97a3c5a092a4cd7c0a5330c7d68a9c7e934a2e0e3567d2d58de0cb098fbb1e412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000081e4cb352c2db33efaf294f05b17f926288f4a4cbc7668da6d7ad654787967380300000064419b3ee9e8de1c8df2fc553da98d578cc6e960691b0dc2bf8c82867a07836a668e052438bb7779fda5a0ebc330f7d3e0bdf1a1c95a495edc4d8eef9536b469a3c5412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000046efc8eba21acb3a6b6d7b5f1eaf36395b99651caa5f0ff1b1978dc87d98e89730b1600a73696c656e746d6f646576a914b2e8b418849e8e3b34247864940a3429d92085fe88ac0000000000000000936a4c90424e53317b226f223a22555044222c226e223a2273696c656e746d6f6465222c2272223a7b22656c223a227773733a2f2f636f696e737065637472756d2e6475636b646e732e6f72673a35303031317c3139352e3138342e3234372e313036207773733a2f2f636869706e65742e6263682e6e696e6a613a35303030347c3133352e3134382e3233362e323434227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88acd3461a00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":736,"time":1788077196,"txid":"4fa572b2337ec7844364bfe9fea613b2f8283aba7d7dd46ede73c807c2834987","version":2,"vin":[{"scriptSig":{"asm":"dd77907d37d763b0682d55133a0ff8a66b3239821c3274114793f871acbfcde7989b21c7f8d775146ba461208e0d9f028ac3d878cbd81364290463e2bacc6404[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41dd77907d37d763b0682d55133a0ff8a66b3239821c3274114793f871acbfcde7989b21c7f8d775146ba461208e0d9f028ac3d878cbd81364290463e2bacc6404412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"80bdbef4530d9f3c25627a1e9d908af342821c89eff66ba7a05d8158cb76fad2","vout":0},{"scriptSig":{"asm":"f8139513e04ac99931669a01be4b916d1a8c78b68e47be13bfe3d8274968abb61a97a3c5a092a4cd7c0a5330c7d68a9c7e934a2e0e3567d2d58de0cb098fbb1e[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41f8139513e04ac99931669a01be4b916d1a8c78b68e47be13bfe3d8274968abb61a97a3c5a092a4cd7c0a5330c7d68a9c7e934a2e0e3567d2d58de0cb098fbb1e412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"0a70e73976b255f33d1d22fa608a8f685289007e4744a36c5fc153e382f240d6","vout":1},{"scriptSig":{"asm":"9b3ee9e8de1c8df2fc553da98d578cc6e960691b0dc2bf8c82867a07836a668e052438bb7779fda5a0ebc330f7d3e0bdf1a1c95a495edc4d8eef9536b469a3c5[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"419b3ee9e8de1c8df2fc553da98d578cc6e960691b0dc2bf8c82867a07836a668e052438bb7779fda5a0ebc330f7d3e0bdf1a1c95a495edc4d8eef9536b469a3c5412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"3867797854d67a6dda6876bc4c4a8f2826f9175bf094f2fa3eb32d2c35cbe481","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"b13097e8987dc88d97b1f10f5faa1c65995b3936af1e5f7b6d6b3acb1aa2ebc8","nft":{"capability":"none","commitment":"73696c656e746d6f6465"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a2273696c656e746d6f6465222c2272223a7b22656c223a227773733a2f2f636f696e737065637472756d2e6475636b646e732e6f72673a35303031317c3139352e3138342e3234372e313036207773733a2f2f636869706e65742e6263682e6e696e6a613a35303030347c3133352e3134382e3233362e323434227d7d","hex":"6a4c90424e53317b226f223a22555044222c226e223a2273696c656e746d6f6465222c2272223a7b22656c223a227773733a2f2f636f696e737065637472756d2e6475636b646e732e6f72673a35303031317c3139352e3138342e3234372e313036207773733a2f2f636869706e65742e6263682e6e696e6a613a35303030347c3133352e3134382e3233362e323434227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.01722067}]},"caddeb9bf65867a7ec28f4428b2c542095ab3d5faee9d8a4efbf681e9c7fe2cb":{"blockhash":"00000000000010ba3fc4059f821f71b035c0f8dc3b42af5f63ad68297b13e339","blocktime":1788077196,"confirmations":95,"hash":"caddeb9bf65867a7ec28f4428b2c542095ab3d5faee9d8a4efbf681e9c7fe2cb","hex":"0200000002874983c207c873de6ed47d7dba3a28f8b213a6fee9bf644384c77e33b272a54f00000000644195f09fd7be676acb906483038218379ca5138c8825f2d916d00f6c792c287b8c9921e1471b9ebc58366b0fa245c1679ad02efedcc5a725a4b46a7e9a6033837f412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d00000000874983c207c873de6ed47d7dba3a28f8b213a6fee9bf644384c77e33b272a54f030000006441174e79e315e01c24f11f3a282842b2b4a890609c354dac23c886e0dd86ba7ade5c0a45a6baef57a9c03ef6252dbc71dc7ede9962d536bc7c624b81d91c95ab59412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d0000000004e80300000000000046efc8eba21acb3a6b6d7b5f1eaf36395b99651caa5f0ff1b1978dc87d98e89730b1600a73696c656e746d6f646576a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000000000003f6a3d424e53317b226f223a22555044222c226e223a2273696c656e746d6f6465222c2272223a7b227333223a22626e732f73696c656e746d6f64652f227d7d58020000000000001976a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac7b421a00000000001976a914b2e8b418849e8e3b34247864940a3429d92085fe88ac00000000","locktime":0,"size":511,"time":1788077196,"txid":"caddeb9bf65867a7ec28f4428b2c542095ab3d5faee9d8a4efbf681e9c7fe2cb","version":2,"vin":[{"scriptSig":{"asm":"95f09fd7be676acb906483038218379ca5138c8825f2d916d00f6c792c287b8c9921e1471b9ebc58366b0fa245c1679ad02efedcc5a725a4b46a7e9a6033837f[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"4195f09fd7be676acb906483038218379ca5138c8825f2d916d00f6c792c287b8c9921e1471b9ebc58366b0fa245c1679ad02efedcc5a725a4b46a7e9a6033837f412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"4fa572b2337ec7844364bfe9fea613b2f8283aba7d7dd46ede73c807c2834987","vout":0},{"scriptSig":{"asm":"174e79e315e01c24f11f3a282842b2b4a890609c354dac23c886e0dd86ba7ade5c0a45a6baef57a9c03ef6252dbc71dc7ede9962d536bc7c624b81d91c95ab59[ALL|FORKID] 02817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d","hex":"41174e79e315e01c24f11f3a282842b2b4a890609c354dac23c886e0dd86ba7ade5c0a45a6baef57a9c03ef6252dbc71dc7ede9962d536bc7c624b81d91c95ab59412102817a308db60d949f5a0543085f6757578dab169d4381934e997cbaef52c34c0d"},"sequence":0,"txid":"4fa572b2337ec7844364bfe9fea613b2f8283aba7d7dd46ede73c807c2834987","vout":3}],"vout":[{"n":0,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"tokenData":{"amount":"0","category":"b13097e8987dc88d97b1f10f5faa1c65995b3936af1e5f7b6d6b3acb1aa2ebc8","nft":{"capability":"none","commitment":"73696c656e746d6f6465"}},"value":0.00001},{"n":1,"scriptPubKey":{"asm":"OP_RETURN 424e53317b226f223a22555044222c226e223a2273696c656e746d6f6465222c2272223a7b227333223a22626e732f73696c656e746d6f64652f227d7d","hex":"6a3d424e53317b226f223a22555044222c226e223a2273696c656e746d6f6465222c2272223a7b227333223a22626e732f73696c656e746d6f64652f227d7d","type":"nulldata"},"value":0},{"n":2,"scriptPubKey":{"addresses":["bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl"],"asm":"OP_DUP OP_HASH160 4f3977a5ec7b7479542c280eca78cd15a9bc6b5c OP_EQUALVERIFY OP_CHECKSIG","hex":"76a9144f3977a5ec7b7479542c280eca78cd15a9bc6b5c88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.000006},{"n":3,"scriptPubKey":{"addresses":["bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu"],"asm":"OP_DUP OP_HASH160 b2e8b418849e8e3b34247864940a3429d92085fe OP_EQUALVERIFY OP_CHECKSIG","hex":"76a914b2e8b418849e8e3b34247864940a3429d92085fe88ac","reqSigs":1,"type":"pubkeyhash"},"value":0.01720955}]}}} \ No newline at end of file diff --git a/test-installer.wsb b/test-installer.wsb new file mode 100644 index 00000000..3a2e3681 --- /dev/null +++ b/test-installer.wsb @@ -0,0 +1,14 @@ +<Configuration> + <VGpu>Disable</VGpu> + <Networking>Default</Networking> + <MappedFolders> + <MappedFolder> + <HostFolder>D:\Dev\SilentMode\TheseusNavigator\dist-public</HostFolder> + <SandboxFolder>C:\Users\WDAGUtilityAccount\Desktop\installer</SandboxFolder> + <ReadOnly>true</ReadOnly> + </MappedFolder> + </MappedFolders> + <LogonCommand> + <Command>cmd.exe /c start "" "C:\Users\WDAGUtilityAccount\Desktop\installer\TheseusNavigator-Setup-0.1.0.exe"</Command> + </LogonCommand> +</Configuration>