Initial commit

This commit is contained in:
Local Dev 2026-09-12 00:07:42 +02:00
commit 6fb6141015
131 changed files with 31451 additions and 0 deletions

76
BROWSER-PROMPT.md Normal file
View file

@ -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.

450
DESIGN-integrated-wallet.md Normal file
View file

@ -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 `<userData>/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<Entry | null>
bcnr.isRegistered(name: string): Promise<boolean>
bcnr.getBcnrTlds(): Promise<string[]> // ['bch','p2p','nav',...]
bcnr.getRecordVersion(name: string): Promise<number> // for cache invalidation
```
### 3.2 Identity (permissioned; first call → modal)
```ts
bcnr.requestAccount(): Promise<string | null> // returns cashaddr or null
bcnr.getAccounts(): Promise<string[]> // [] 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<string,string>, userPrompt?: string }):
Promise<{ txid: string, category: string }>
bcnr.updateName({ name: string, records: Record<string,string>, 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 `<Uint8Array: 0x..>`
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: <shipped png>`
- `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 `<userData>/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 `<userData>/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)

209
DESIGN-password-manager.md Normal file
View file

@ -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: <hex> },
iv: <12-byte hex>,
ciphertext: <hex>, // 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: <ciphertext>, // 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=<zero>, 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: `<userData>/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: <plaintext> } (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.

View file

@ -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.

121
GOTCHAS.md Normal file
View file

@ -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.

91
PACKAGING-PROMPT.md Normal file
View file

@ -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.

43
PENDING.md Normal file
View file

@ -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.

27
README.md Normal file
View file

@ -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.

94
RELEASE-HANDOFF.md Normal file
View file

@ -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.

215
ROADMAP-identity-wallet.md Normal file
View file

@ -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.

View file

@ -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.

36
addon-inject-preload.js Normal file
View file

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

20
addon-tab-preload.js Normal file
View file

@ -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"),
});

21
addon-update-pubkeys.js Normal file
View file

@ -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
],
};

290
addon-updater.js Normal file
View file

@ -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,
};

616
addons-host.js Normal file
View file

@ -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 };

View file

@ -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),
});

66
address-picker.html Normal file
View file

@ -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>

9
approval-preload.js Normal file
View file

@ -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 || "")),
});

96
approval.html Normal file
View file

@ -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>

103
bcnr-origin.js Normal file
View file

@ -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 };

27
bcnr-preload.js Normal file
View file

@ -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"),
});

View file

@ -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://*/*"]
}
}

View file

@ -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"
]

File diff suppressed because it is too large Load diff

View file

@ -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 };
};

View file

@ -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 };

View file

@ -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 };
};

View file

@ -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 };
};

View file

@ -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 };
};

View file

@ -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 };
};

View file

@ -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 };
};

View file

@ -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 };
};

View file

@ -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 };
};

View file

@ -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 };
};

View file

@ -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;

View file

@ -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

View file

@ -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;

View file

@ -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

View file

@ -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 };

View file

@ -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

View file

@ -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';

View file

@ -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

View file

@ -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"];
};

View file

@ -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

View file

@ -0,0 +1 @@
{"type":"module","main":"./index.js"}

View file

@ -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>;

View file

@ -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

View file

@ -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 };

View file

@ -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

View file

@ -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;

View file

@ -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

View file

@ -0,0 +1,3 @@
export * from './types.js';
export * from './build.js';
export * from './sign.js';

View file

@ -0,0 +1,4 @@
export * from './types.js';
export * from './build.js';
export * from './sign.js';
//# sourceMappingURL=index.js.map

View file

@ -0,0 +1 @@
{"type":"module","main":"./index.js"}

View file

@ -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;

View file

@ -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

View file

@ -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;
}

View file

@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=types.js.map

View file

@ -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 };
};

View file

@ -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 };
};

View file

@ -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 };
};

View file

@ -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; },
};
};

View file

@ -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 };
};

View file

@ -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 };
};

View file

@ -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 };
};

View file

@ -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 };
};

View file

@ -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,
};
};

View file

@ -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 };
};

View file

@ -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 };
};

View file

@ -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 };

View file

@ -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 };
};

View file

@ -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>

File diff suppressed because it is too large Load diff

218
bundled-addons/aegis/qr.js Normal file
View file

@ -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);

View file

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

View file

@ -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"]
}

View file

@ -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");
},
};

View file

@ -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>

View file

@ -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"
}

View file

@ -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>

View file

@ -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>

View file

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

View file

@ -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");
},
};

View file

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

View file

@ -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>

1556
chrome.html Normal file

File diff suppressed because it is too large Load diff

7
collision-preload.js Normal file
View file

@ -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); },
});

170
collision.html Normal file
View file

@ -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>

44
dev/README.md Normal file
View file

@ -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.

68
dev/bcnr-selftest.js Normal file
View file

@ -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 {}
});

22
dev/list-tlds.mjs Normal file
View file

@ -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(", ") || "—"}`);
}
}

72
dev/origin-selftest.mjs Normal file
View file

@ -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);

65
dev/probe-site.mjs Normal file
View file

@ -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);

21
dev/registry-decide.mjs Normal file
View file

@ -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);

9
dev/rescheck.mjs Normal file
View file

@ -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);

82
dev/selftest.js Normal file
View file

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

7
dev/show-tlds-bch.mjs Normal file
View file

@ -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));

32
dev/test-directip.mjs Normal file
View file

@ -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);

34
dev/test-our-indexer.mjs Normal file
View file

@ -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);

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