Initial commit
This commit is contained in:
commit
6fb6141015
131 changed files with 31451 additions and 0 deletions
76
BROWSER-PROMPT.md
Normal file
76
BROWSER-PROMPT.md
Normal 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
450
DESIGN-integrated-wallet.md
Normal 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
209
DESIGN-password-manager.md
Normal 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.
|
||||
436
DESIGN-wallet-multi-account-amendment.md
Normal file
436
DESIGN-wallet-multi-account-amendment.md
Normal 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
121
GOTCHAS.md
Normal 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
91
PACKAGING-PROMPT.md
Normal 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
43
PENDING.md
Normal 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
27
README.md
Normal 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
94
RELEASE-HANDOFF.md
Normal 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
215
ROADMAP-identity-wallet.md
Normal 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.
|
||||
105
SESSION-PROMPT-identity-wallet.md
Normal file
105
SESSION-PROMPT-identity-wallet.md
Normal 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
36
addon-inject-preload.js
Normal 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
20
addon-tab-preload.js
Normal 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
21
addon-update-pubkeys.js
Normal 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
290
addon-updater.js
Normal 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
616
addons-host.js
Normal 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 };
|
||||
9
address-picker-preload.js
Normal file
9
address-picker-preload.js
Normal 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
66
address-picker.html
Normal 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, "<").replace(/"/g, """);
|
||||
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
9
approval-preload.js
Normal 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
96
approval.html
Normal 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) => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[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
103
bcnr-origin.js
Normal 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
27
bcnr-preload.js
Normal 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"),
|
||||
});
|
||||
16
bundled-addons/aegis/addon.json
Normal file
16
bundled-addons/aegis/addon.json
Normal 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://*/*"]
|
||||
}
|
||||
}
|
||||
9
bundled-addons/aegis/electrum-servers.json
Normal file
9
bundled-addons/aegis/electrum-servers.json
Normal 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"
|
||||
]
|
||||
1922
bundled-addons/aegis/index.js
Normal file
1922
bundled-addons/aegis/index.js
Normal file
File diff suppressed because it is too large
Load diff
83
bundled-addons/aegis/lib/base58check.js
Normal file
83
bundled-addons/aegis/lib/base58check.js
Normal 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 };
|
||||
};
|
||||
102
bundled-addons/aegis/lib/cashaddr.js
Normal file
102
bundled-addons/aegis/lib/cashaddr.js
Normal 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 };
|
||||
146
bundled-addons/aegis/lib/chain-bch-imported.js
Normal file
146
bundled-addons/aegis/lib/chain-bch-imported.js
Normal 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 };
|
||||
};
|
||||
156
bundled-addons/aegis/lib/chain-bch.js
Normal file
156
bundled-addons/aegis/lib/chain-bch.js
Normal 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 };
|
||||
};
|
||||
531
bundled-addons/aegis/lib/chain-btc.js
Normal file
531
bundled-addons/aegis/lib/chain-btc.js
Normal 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 };
|
||||
};
|
||||
451
bundled-addons/aegis/lib/chain-dgb.js
Normal file
451
bundled-addons/aegis/lib/chain-dgb.js
Normal 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 };
|
||||
};
|
||||
361
bundled-addons/aegis/lib/chain-eth.js
Normal file
361
bundled-addons/aegis/lib/chain-eth.js
Normal 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 };
|
||||
};
|
||||
182
bundled-addons/aegis/lib/chain-sia.js
Normal file
182
bundled-addons/aegis/lib/chain-sia.js
Normal 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 };
|
||||
};
|
||||
473
bundled-addons/aegis/lib/chain-sol.js
Normal file
473
bundled-addons/aegis/lib/chain-sol.js
Normal 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 };
|
||||
};
|
||||
353
bundled-addons/aegis/lib/chain-tron.js
Normal file
353
bundled-addons/aegis/lib/chain-tron.js
Normal 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 };
|
||||
};
|
||||
10
bundled-addons/aegis/lib/dgb/core/address.d.ts
vendored
Normal file
10
bundled-addons/aegis/lib/dgb/core/address.d.ts
vendored
Normal 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;
|
||||
53
bundled-addons/aegis/lib/dgb/core/address.js
Normal file
53
bundled-addons/aegis/lib/dgb/core/address.js
Normal 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
|
||||
5
bundled-addons/aegis/lib/dgb/core/descriptor.d.ts
vendored
Normal file
5
bundled-addons/aegis/lib/dgb/core/descriptor.d.ts
vendored
Normal 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;
|
||||
18
bundled-addons/aegis/lib/dgb/core/descriptor.js
Normal file
18
bundled-addons/aegis/lib/dgb/core/descriptor.js
Normal 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
|
||||
8
bundled-addons/aegis/lib/dgb/core/hd.d.ts
vendored
Normal file
8
bundled-addons/aegis/lib/dgb/core/hd.d.ts
vendored
Normal 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 };
|
||||
24
bundled-addons/aegis/lib/dgb/core/hd.js
Normal file
24
bundled-addons/aegis/lib/dgb/core/hd.js
Normal 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
|
||||
6
bundled-addons/aegis/lib/dgb/core/index.d.ts
vendored
Normal file
6
bundled-addons/aegis/lib/dgb/core/index.d.ts
vendored
Normal 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';
|
||||
7
bundled-addons/aegis/lib/dgb/core/index.js
Normal file
7
bundled-addons/aegis/lib/dgb/core/index.js
Normal 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
|
||||
13
bundled-addons/aegis/lib/dgb/core/network.d.ts
vendored
Normal file
13
bundled-addons/aegis/lib/dgb/core/network.d.ts
vendored
Normal 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"];
|
||||
};
|
||||
74
bundled-addons/aegis/lib/dgb/core/network.js
Normal file
74
bundled-addons/aegis/lib/dgb/core/network.js
Normal 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
|
||||
1
bundled-addons/aegis/lib/dgb/core/package.json
Normal file
1
bundled-addons/aegis/lib/dgb/core/package.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"type":"module","main":"./index.js"}
|
||||
4
bundled-addons/aegis/lib/dgb/core/seed.d.ts
vendored
Normal file
4
bundled-addons/aegis/lib/dgb/core/seed.d.ts
vendored
Normal 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>;
|
||||
17
bundled-addons/aegis/lib/dgb/core/seed.js
Normal file
17
bundled-addons/aegis/lib/dgb/core/seed.js
Normal 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
|
||||
27
bundled-addons/aegis/lib/dgb/core/wif.d.ts
vendored
Normal file
27
bundled-addons/aegis/lib/dgb/core/wif.d.ts
vendored
Normal 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 };
|
||||
72
bundled-addons/aegis/lib/dgb/core/wif.js
Normal file
72
bundled-addons/aegis/lib/dgb/core/wif.js
Normal 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
|
||||
4
bundled-addons/aegis/lib/dgb/psbt/build.d.ts
vendored
Normal file
4
bundled-addons/aegis/lib/dgb/psbt/build.d.ts
vendored
Normal 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;
|
||||
56
bundled-addons/aegis/lib/dgb/psbt/build.js
Normal file
56
bundled-addons/aegis/lib/dgb/psbt/build.js
Normal 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
|
||||
3
bundled-addons/aegis/lib/dgb/psbt/index.d.ts
vendored
Normal file
3
bundled-addons/aegis/lib/dgb/psbt/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './types.js';
|
||||
export * from './build.js';
|
||||
export * from './sign.js';
|
||||
4
bundled-addons/aegis/lib/dgb/psbt/index.js
Normal file
4
bundled-addons/aegis/lib/dgb/psbt/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './types.js';
|
||||
export * from './build.js';
|
||||
export * from './sign.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
1
bundled-addons/aegis/lib/dgb/psbt/package.json
Normal file
1
bundled-addons/aegis/lib/dgb/psbt/package.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"type":"module","main":"./index.js"}
|
||||
9
bundled-addons/aegis/lib/dgb/psbt/sign.d.ts
vendored
Normal file
9
bundled-addons/aegis/lib/dgb/psbt/sign.d.ts
vendored
Normal 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;
|
||||
25
bundled-addons/aegis/lib/dgb/psbt/sign.js
Normal file
25
bundled-addons/aegis/lib/dgb/psbt/sign.js
Normal 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
|
||||
23
bundled-addons/aegis/lib/dgb/psbt/types.d.ts
vendored
Normal file
23
bundled-addons/aegis/lib/dgb/psbt/types.d.ts
vendored
Normal 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;
|
||||
}
|
||||
2
bundled-addons/aegis/lib/dgb/psbt/types.js
Normal file
2
bundled-addons/aegis/lib/dgb/psbt/types.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export {};
|
||||
//# sourceMappingURL=types.js.map
|
||||
143
bundled-addons/aegis/lib/eip712.js
Normal file
143
bundled-addons/aegis/lib/eip712.js
Normal 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 };
|
||||
};
|
||||
131
bundled-addons/aegis/lib/electrum.js
Normal file
131
bundled-addons/aegis/lib/electrum.js
Normal 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 };
|
||||
};
|
||||
66
bundled-addons/aegis/lib/keys.js
Normal file
66
bundled-addons/aegis/lib/keys.js
Normal 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 };
|
||||
};
|
||||
98
bundled-addons/aegis/lib/prices.js
Normal file
98
bundled-addons/aegis/lib/prices.js
Normal 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; },
|
||||
};
|
||||
};
|
||||
29
bundled-addons/aegis/lib/sia/keys.js
Normal file
29
bundled-addons/aegis/lib/sia/keys.js
Normal 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 };
|
||||
};
|
||||
142
bundled-addons/aegis/lib/sia/sia.js
Normal file
142
bundled-addons/aegis/lib/sia/sia.js
Normal 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 };
|
||||
};
|
||||
201
bundled-addons/aegis/lib/sia/wallet.js
Normal file
201
bundled-addons/aegis/lib/sia/wallet.js
Normal 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 };
|
||||
};
|
||||
42
bundled-addons/aegis/lib/sia/walletd.js
Normal file
42
bundled-addons/aegis/lib/sia/walletd.js
Normal 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 };
|
||||
};
|
||||
220
bundled-addons/aegis/lib/sol-spl.js
Normal file
220
bundled-addons/aegis/lib/sol-spl.js
Normal 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,
|
||||
};
|
||||
};
|
||||
122
bundled-addons/aegis/lib/tx.js
Normal file
122
bundled-addons/aegis/lib/tx.js
Normal 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 };
|
||||
};
|
||||
243
bundled-addons/aegis/lib/wallet.js
Normal file
243
bundled-addons/aegis/lib/wallet.js
Normal 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 };
|
||||
};
|
||||
95
bundled-addons/aegis/lib/wc-sign.js
Normal file
95
bundled-addons/aegis/lib/wc-sign.js
Normal 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 };
|
||||
196
bundled-addons/aegis/lib/wc.js
Normal file
196
bundled-addons/aegis/lib/wc.js
Normal 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 };
|
||||
};
|
||||
420
bundled-addons/aegis/panel.html
Normal file
420
bundled-addons/aegis/panel.html
Normal 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=…&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>
|
||||
1158
bundled-addons/aegis/panel.js
Normal file
1158
bundled-addons/aegis/panel.js
Normal file
File diff suppressed because it is too large
Load diff
218
bundled-addons/aegis/qr.js
Normal file
218
bundled-addons/aegis/qr.js
Normal 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);
|
||||
506
bundled-addons/aegis/wallet-inject.js
Normal file
506
bundled-addons/aegis/wallet-inject.js
Normal 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);
|
||||
}
|
||||
10
bundled-addons/notepad/addon.json
Normal file
10
bundled-addons/notepad/addon.json
Normal 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"]
|
||||
}
|
||||
13
bundled-addons/notepad/index.js
Normal file
13
bundled-addons/notepad/index.js
Normal 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");
|
||||
},
|
||||
};
|
||||
100
bundled-addons/notepad/note.html
Normal file
100
bundled-addons/notepad/note.html
Normal 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>
|
||||
11
bundled-addons/screenshot/addon.json
Normal file
11
bundled-addons/screenshot/addon.json
Normal 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"
|
||||
}
|
||||
164
bundled-addons/screenshot/editor.css
Normal file
164
bundled-addons/screenshot/editor.css
Normal 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>
|
||||
148
bundled-addons/screenshot/editor.html
Normal file
148
bundled-addons/screenshot/editor.html
Normal 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>
|
||||
763
bundled-addons/screenshot/editor.js
Normal file
763
bundled-addons/screenshot/editor.js
Normal 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();
|
||||
162
bundled-addons/screenshot/index.js
Normal file
162
bundled-addons/screenshot/index.js
Normal 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");
|
||||
},
|
||||
};
|
||||
115
bundled-addons/screenshot/panel-preload.js
Normal file
115
bundled-addons/screenshot/panel-preload.js
Normal 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);
|
||||
});
|
||||
})()
|
||||
547
bundled-addons/screenshot/panel.html
Normal file
547
bundled-addons/screenshot/panel.html
Normal 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, """);
|
||||
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
1556
chrome.html
Normal file
File diff suppressed because it is too large
Load diff
7
collision-preload.js
Normal file
7
collision-preload.js
Normal 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
170
collision.html
Normal 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
44
dev/README.md
Normal 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
68
dev/bcnr-selftest.js
Normal 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
22
dev/list-tlds.mjs
Normal 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
72
dev/origin-selftest.mjs
Normal 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
65
dev/probe-site.mjs
Normal 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
21
dev/registry-decide.mjs
Normal 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
9
dev/rescheck.mjs
Normal 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
82
dev/selftest.js
Normal 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
7
dev/show-tlds-bch.mjs
Normal 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
32
dev/test-directip.mjs
Normal 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
34
dev/test-our-indexer.mjs
Normal 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
Loading…
Add table
Reference in a new issue