Two regressions in 0.26.0.
The PIN was still asked after the wallet had painted. pinGateOnLoad() ran
after render() and was not awaited, so the interface appeared — balances and
all — and the pad then opened on top of a panel the user could already read.
It is now awaited before the first render, and `pinGateBlocked` keeps the
chrome hidden for as long as the PIN is owed: render() returns early into a
closed door, state pushes cannot paint around it, and Settings is covered too
(unlike the vault lock, where Settings must stay reachable to create a PIN in
the first place, here it would just be a way around the gate). Cancelling
leaves the door shut with a retry rather than falling through to an open
wallet.
The pad threw digits away mid-entry. render() fires on every state push —
balance polls fire it every couple of seconds — and renderLockScreen()
rebuilt body.innerHTML unconditionally, replacing the pad and its buffer
under the user's fingers. The reset was timed to the poll, not to the
keypress count, which is why it looked like "after two digits". All three
branches are now keyed on body.dataset.mode and rebuild only when the mode
actually changes; the master-password and first-run forms had the same defect
and were erasing half-typed input the same way.
Also: every pad was wired through getElementById, so two pads alive at once
(a load gate plus a transaction gate) were duplicate ids and the second
setupPinPad bound the first pad's buttons. All four are now scoped to their
own container, only one PIN prompt can be open at a time, and setupPinPad
carries a note saying why it must never be handed a global lookup.
Reported: after a restart Aegis opens without asking for a PIN, then demands
one as soon as you click something. That came from the only control being
requirePinForSending — safeStorage remembers the master password, so the
wallet reopens unlocked, and the PIN prompt then ambushes the first action.
Asking at the door is coherent. Asking nothing is coherent. Asking once the
user is already inside is not.
"Ask for PIN" is now four independent triggers, because wanting one at
startup and again per transaction is a normal combination:
- On each browser restart (compares a per-process boot id; a timestamp
cannot tell a restart from a long idle)
- On each wallet launch (hide/show — one panel load is one launch)
- Every 6 hours
- Each transaction
With none ticked, an open wallet is never interrupted again. The decision is
made host-side: the panel reloads on every hide/show and must not be the
thing that remembers a gate was cleared. A clearance is only recorded after
the panel has actually decrypted the PIN blob, which is proof rather than a
claim, and ticking a trigger does not fire it retroactively.
restart defaults ON for a wallet that otherwise reopens fully unlocked, and
transaction inherits the old requirePinForSending so nobody loses a gate they
had chosen. Removing the PIN clears every trigger and the clearance record.
Every history row and Explorer button on an imported chipnet wallet opened
https://chipnet.imaginary.cash/tx/… , whose web interface has been returning
502 for weeks. chain-bch.js was moved to our own explorer and this adapter
was missed — which is most chipnet wallets in practice, since a keystore bulk
import produces imported ones.
Points at https://aegis.x/explorer/chipnet/ like the HD adapter. The host's
Electrum endpoint on :50004 is a separate thing and is still in use.
Three separate reasons the Refresh button looked dead:
- wallet.js refresh() returned immediately when state.scanning was set, so a
manual press during a background poll did nothing at all. A forced refresh
now awaits the in-flight pass and then does real work; background polls
still yield. scanning is only ever written inside doRefresh, which only
refresh() calls, so scanning implies a pending inflight to wait on.
- The adapters catch their own fetch failures onto state.error instead of
rejecting, so awaiting refresh() proved nothing and refreshChain reported
ok:true for a wallet that had just failed against a dead server. It now
reads the snapshot back.
- Success changed only a tooltip. Balances that were already current left the
screen identical, which is indistinguishable from a broken button. It now
flashes a result and says how many wallets were refreshed, or how many
failed and why.
Version bumped once for this batch; not published yet.
The coin drilldown header carried "← Back" on its left and ✕ on its right.
They were bound to the same handler and carried the same tooltip, so the row
spent its left edge on a duplicate of the control at its right edge. ✕ stays;
the title now starts at the left edge, which .wctitle already handled via
flex: 1.
Switching network on the new header chips moved the selected wallet but left
the list below it alone. In the addresses drilldown stripView.groupKey pins
"<chain>:<network>", so the header read chipnet while the rows underneath
were still mainnet addresses.
The chip now retargets that groupKey when the drilldown belongs to the chain
being switched, and leaves a drilldown on any other chain alone. The coins
view already keyed off activeNetworkByChain and needed nothing.
Switching also lands on whichever wallet that network was last left on
instead of the first in list order, which means row clicks have to record
that — otherwise picking a wallet, leaving the network and coming back
returned somewhere else.
The header read bottom-up. The balance sat under a row of connection detail,
and the network you were on was reported in that status row while the control
that could actually change it was a chip row buried in the picker's coin
drilldown — two places for one idea, with the useful half two clicks deep and
only reachable from inside a wallet list.
Reordered to match what people open the panel for:
1. wallet label
2. balance and portfolio
3. network selector
4. connection status and the per-wallet verbs (Explorer, Faucet)
The selector and the indicator are now the same control: a chip per network
this coin has wallets on, the active one marked, always present while a
wallet is selected. Switching picks a wallet on that network and keeps the
picker's per-chain network memory in step, so the two views cannot disagree.
A single-network chain still renders its one chip. It is the indicator the
status row used to carry, and hiding it would make the header height jump as
you move between chains. A testnet whose label already says so (Chipnet
testnet) no longer also gets a TEST tag; Nile and Sepolia still do.
A seed import offered exactly one prefilled path and no alternatives, so a
seed from a wallet on a different path derived a valid but empty address and
reported a 0 balance. Nothing distinguished "wrong path" from "empty wallet",
which is why repeated imports of a funded wallet all looked identical.
Both real cases hit it. Bitcoin.com derives mainnet BCH from coin type 0' —
its Copay lineage predates the 145' split — while Aegis prefilled
m/44'/145'/0'/0/0. And chipnet tooling derives from 145', while Aegis
prefilled BIP44's testnet 1'.
- A preset dropdown per coin and network, each entry naming the wallets that
path belongs to, with the free-text field kept for anything unlisted.
- "Check which path has my funds" derives each candidate and asks Electrum
for balance and history, five addresses deep per candidate so a wallet
whose first address is spent clean is still found. Results are listed with
balances and a one-click Use; when nothing matches, it says so instead of
implying the wallet is empty.
- The chipnet IMPORT prefill becomes 145'. The vault-derived chipnet account
path stays m/44'/1'/0' — changing that would move the addresses of wallets
that already exist.
The scan derives, queries and returns counts; it stores nothing, and the
mnemonic crosses the same boundary importWallet already uses.
Reported as "WizardConnect pairing is done only via Mainnet, chipnet is not
available on UI". Chipnet pairing works and is not filtered anywhere: the
Connect pane reads state.wallets unfiltered, wcPairableWallets() keys only on
chain/phase/eligibility, and startForWallet registers every BCH wallet
whatever its network. The profile even holds a live wc:// URI against
bch-chipnet-0, so a chipnet wallet has already paired.
What it actually was: the picker listed wallets in registry order, and a bulk
keystore import put fifteen unpairable chipnet WIF wallets ahead of the two
chipnet HD wallets that pair fine. Every visible row was a greyed-out
"can't pair" testnet entry, so the list read as "chipnet is unsupported"
while the working options sat at positions 17 and 19 of 19.
Group the select instead: "Can pair" first, "Cannot pair" after. The reasons
stay visible — they were asked for, and a wallet greyed out with no
explanation reads as broken — but they no longer hide the wallets that work.
Only a WIF import is ineligible, because hdwalletv1 hands the dapp an xpub
and one key is not a key tree. Promote to HD (0.11.0) remains the fix for
those.
Getting a key back out of Aegis only worked for wallets it derived itself.
Every imported adapter's recovery() returned xprv:null with "Recovery lives
in the source of the import", so the wallets most likely to need exporting
were the ones that refused, and the vault-derived ones handed over an xprv
behind nothing but an approval click.
There is one gate now, and it is enforced in the host. revealSecret takes the
master password and verifies it with vault.lifecycle.unlock before it reads
anything; the panel obtains that password either by decrypting the PIN blob,
which wraps exactly it, or by asking. Both routes end at the same proof, so
the host never takes the panel's word for authorisation. The old
recovery({reveal:true}) path is gone and all six Settings buttons route here.
Three wrong PINs switch to the master password rather than dead-ending, and
those attempts still count toward the existing 15-minute lockout, so a
fumbled PIN costs nothing and a guessed one gains nothing. Being locked out
of the PIN also falls through to the password: the lockout exists to stop PIN
guessing, not to lock an owner out of their own key.
"Use PIN to show secret keys" defaults ON, unlike the send flag — a send is
already fronted by an approval overlay, whereas a revealed key is
irreversible the moment it is on screen. Turning it off moves the prompt to
the master password. There is deliberately no setting that reveals a key
without asking for anything.
It is NOT called a recovery phrase, because Aegis has none to show. An import
stores mnemonicToSeedHex(words) and discards the words, vault wallets are
HKDF(vault root, purpose) and never had words, and password-vault.js is
explicit that the seed is never persisted. So each form names itself — WIF,
private key (hex), wallet seed (hex), wallet key (hex) — and says where it
can actually be restored. Someone who writes down what this shows believing
it is twelve words has backed up nothing, which is the one outcome this
screen has to prevent.
A WizardConnect pairing could land on an address the user had never seen.
Pairing took the selected wallet when it qualified and otherwise the first
pairable one in list order, so with the selected wallet ineligible (a WIF
import has no xpub) it silently fell through to whichever wallet happened to
be first. The approval named that wallet by label only, which does not help
when the label is one Aegis generated.
Three changes, one idea: the wallet a purpose uses should be something you
said, not something that fell out of list order.
- Roles. A wallet can be nominated for payments and/or for WizardConnect,
set from its manage modal and badged on its row. A role that points at a
removed wallet reads back as null instead of being trusted, so a stale
pointer can never quietly redirect a payment.
- The pairing approval asks. With more than one candidate it offers a
dropdown of them, each labelled with its own short address; with only one
it shows that wallet's full address. The rows are static, so naming a
wallet above a select the user can change would contradict itself — hence
one or the other, never both. The panel's own Connect pane now follows the
same precedence, because two rules for "which wallet" is how a pairing
surprises someone.
- Send gets a From row listing the wallets on this coin and network, with
balances. It switches the panel selection rather than carrying a separate
source: planSend and send resolve the wallet host-side from that, and a
second notion of "current" would let the form and the approval disagree.
Payments also becomes the opening selection when nothing has been picked yet,
which is what nominating it is for.
No Theseus release needed — approvalModal has supported a select row all
along, and the pick comes back as "allow+wallet=<id>", validated against the
options offered.
Went looking for a performance fix and found a correctness bug underneath it.
getTx() slims each transaction on the way into the cache, and the slim shape
kept only { value, scriptHex } — dropping vout.tokenData. That field is where
the server reports CashTokens. It is NOT in scriptPubKey.hex, which Fulcrum
returns with the token prefix already stripped. So the classify pass fetched
one transaction per UTXO, looked for a prefix that was never there, and
concluded "no token" every single time.
Measured against a real chipnet wallet (130 UTXOs, 91 of them token-bearing):
the old pass cost 54 requests for that address and found 0 tokens; sampling
12 of those transactions, exactly 0 had a scriptPubKey starting with the
token prefix. On the faucet-fed address with 28,289 UTXOs it was ~28k
requests, still finding nothing — which is what made the wallet look hung.
So HD BCH wallets have never shown CashTokens. 0.15.0 fixed the imported
adapter, which reads token_data off listunspent, and I took the HD path's
silence for an empty wallet.
Now: when the server negotiated protocol >= 1.5, listunspent carries
token_data and a UTXO WITHOUT it is definitively not token-bearing, so the
whole set is classified from the one call we already make — 1 request instead
of 28,289. Below 1.5 the fallback fetches parents as before but reads
vout.tokenData (present even at 1.4) and only decodes a prefix as a last
resort, so it is correct now too.
Both routes are reconciled onto one shape. They speak different dialects:
the decoder yields a numeric capability (0/1/2) labelled
immutable/mutable/minting, while Electrum sends a string and calls 0 "none".
Left alone, an identical UTXO would have described itself differently
depending on which server answered. The decoder's vocabulary wins, and the
Certificates pane treats immutable as the quiet default so only capabilities
that change what the holder can do get a tag.
txCache is versioned and dropped once: entries written by the old shape carry
no token information, and an absent field cannot be told apart from "no
token", so a warm cache on a pre-1.5 server would have reported a token
wallet as empty.
Verified against the live wallet: one request, 91 token UTXOs, 46 categories,
17 assets, 51 certificates — matching what the panel reports — with
capability labels normalised (5 immutable, 28 mutable, 18 minting).
Assets was one list holding two different kinds of thing. A fungible balance
answers "how much do I have"; a non-fungible one answers "which ones do I
hold", and they want different rows — an amount against a symbol, versus a
commitment and a capability. On a real chipnet wallet that meant 46 rows
where 32 of them were only ever going to say "1 NFT".
Split by what each category actually holds, so a category carrying both
appears in both — which is honest, because it really does hold both. Assets
counts categories; Certificates counts individual certificates, since "3"
should mean three things you hold rather than three groups.
Certificate rows carry the commitment, because it is the only thing that
distinguishes two certificates of the same category, and a MINTING or MUTABLE
tag, because "can still issue others" versus "is fixed" is worth seeing
without opening anything. A `none` capability is left unlabelled rather than
adding noise to every row.
"Certificates", not "NFTs": these are membership, licence and record tokens,
and NFT carries collectible-market baggage that misdescribes them. Ids and
data keys stay `nft` — that is the CashTokens protocol field, so it is
protocol name inside and product name on screen.
Two things caught while building it. `draw({})` was overwriting the "no
assets" empty state with an empty string, which is now the common case since
purely-non-fungible categories no longer appear there. And certificate rows
printed the category twice, as the title and again on the right — the right
column now only carries it when the row has a real name to lead with.
Metadata is looked up for every category, not just the fungible ones, so
naming a token also names its certificates.
The Receive tab carried two stacked toggle rows: Receive/Consolidate above
Address & QR/Assets. That is a lot of chrome for a 380px panel, and the two
rows were not the same kind of thing — Address and Assets are views of the
wallet, while consolidating is an action on its UTXO set. Consolidate now
sits with the other address actions, beside "Next unused address", with the
sibling count on the button.
The old toggle was also the only way out of the consolidate view, so that
view gains its own "← Back to address". Entering still resets the inline host
so the preview is costed fresh, which is what the toggle did.
The Send tab keeps its Send/Consolidate toggle: there, consolidating really
is an alternative way to send, so a toggle is the right shape.
Removed the two now-dead [data-rcv-mode] blocks rather than leaving selectors
that match nothing.
Verified by real visibility rather than the hidden attribute — a child of a
hidden parent keeps hidden=false, which made a first check look like both
panes were showing at once. rcvNormal and the address pane go away while the
consolidate pane shows, and Back restores them.
Assets listed but every row read as a hex string. Three separate reasons.
**Both default registries were dead.** Checked 2026-09-29:
raw.githubusercontent.com/cashonize/registry/main/bcmr.json returns 404, and
bcmr.salemkode.com does not resolve. So no token resolved a name on any
chain, mainnet included — and the panel's `.catch(() => {})` meant the
failure was completely silent, indistinguishable from a token nobody has
registered. Replaced with OpenTokenRegistry, which answers and carries
chipnet identities. A dead registry is now reported instead of swallowed:
the reply says whether any registry answered, and the hint says so.
**The tokens in question publish no metadata at all.** Not a wallet problem
and not fixable by any registry list: their genesis transactions carry no
OP_RETURN whatsoever, so there is no BCMR authchain to follow and no
registry entry to find. Their names exist only inside the app that minted
them. So a token can now be named locally, per category, stored under
bcmr/local/<cat> and taking precedence over any registry — with a YOURS tag
so a self-assigned name is never mistaken for a published one. Clearing both
fields removes it and lets a registry entry show through again. Optional
decimals, because a raw fungible amount with no scale is its own kind of
wrong: 15000 became "150 GMX" once told there were two.
**The unnamed row printed the category twice**, once as the name fallback and
once as the sub-line. Unnamed rows now show the category as the identity and
"unnamed token · N UTXOs" beneath it.
The header comment also promised a bundled static registry fallback for
well-known tokens. There is no such directory and no load path for one; the
comment is gone rather than left to mislead.
Verified against the real module: local names beat registry entries, a
category with only a local name resolves instead of returning null, clearing
restores the registry value, out-of-range decimals are dropped, and the new
default registry resolves a real chipnet identity (OTRC, 6 decimals). In the
panel: naming a token repaints it with the tag and rescales the amount, and a
non-numeric decimals entry is refused with the modal left open.
Two changes to the same idea: one way to do each thing, and the same shape
for every coin.
**Assets is no longer buried under the QR.** The Receive body stacked
address → QR → assets in one column, so in a 380px panel the token list sat
permanently below the fold behind 200px of QR — and a coin holding no tokens
simply lost a section, which made every coin look structurally different.
Address & QR and Assets are now peers behind a persistent chip row. The count
rides on the chip, and a wallet with nothing says "No assets held by this
wallet" instead of the section vanishing. That mattered more than it sounds:
a chipnet wallet with 46 CashToken categories was indistinguishable from an
empty one until 0.15.0, because the only signal was an absent card.
The choice persists across re-renders and wallet switches — comparing token
balances between wallets should not throw you back to the QR on every click.
Switching back to Address redraws the QR, since a canvas sized while hidden
comes out blank and renderReceive only repaints when the address changes.
**The coin row's ▾ network dropdown is gone.** The coin view already carries
real network chips (data-browse-net: they filter the wallet list and show a
per-network count), so the popover was a second control for one job — and the
only one that hid its options behind a click. Chips are the single network
control now and the ticker is plain text again.
Verified in the panel: chips switch panes and track their own state, the
count shows, the empty state reads correctly, the QR survives the round trip,
and no .wchev / .wcname.wswitchable remain in the DOM.
Aegis negotiated a flat protocol "1.4". Measured against Fulcrum 2.1.0 on
chipnet, for a wallet holding CashTokens:
asked "1.4" -> negotiated 1.4 -> 39 utxos, 0 with token_data
asked ["1.4","1.5.3"] -> negotiated 1.5.3 -> 130 utxos, 91 with token_data
So 1.4 did not merely omit the `token_data` field — Fulcrum left the
token-bearing outputs out of listunspent altogether. Ninety-one UTXOs were
invisible to the wallet, along with the BCH sitting in them. That is a
balance-correctness bug, not only a missing Assets card, and it applied to
the HD path too, since lib/wallet.js reads the same listunspent.
Now a [min, max] range: a modern server picks 1.5.3, an older one still
settles on 1.4, so nothing that worked before stops working. The negotiated
version is recorded on the client for diagnosis.
Second half: the imported BCH adapter had no CashToken code at all — it never
set tokenBalances and snapshot() never exposed it, so the panel's Assets card
was hidden for every WIF import however many tokens the address held. A
chipnet test wallet with 46 categories showed nothing. It now aggregates from
the server's own token_data, which costs one call for the whole set rather
than the per-UTXO transaction fetch the HD path uses, and emits the same
serialised shape the panel already reads. Verified against that wallet: 130
UTXOs, 46 categories, 17 fungible, 32 with NFTs, JSON-clean.
Found because the user said their asset "uses a different asset category" and
suggested checking with the explorer. It is ordinary CashTokens; the wallet
simply could not see them. My earlier conclusion that the empty Assets card
was correct came from probing a single address that genuinely holds no tokens
and generalising from it.
Importing a Bitcoin.com seed showed a balance of zero. Two causes, one mine.
Mine: a Copay / Bitcoin.com backup QR is "1|<words>|<network>|<ACCOUNT
path>|…", and 0.13.2 dropped that path straight into a box whose contents are
derived as a LEAF. Deriving the account node itself yields an address the
wallet has never used — for the standard BIP39 vector,
qr96x72dpwrjmg8gtmfemmdhn6u8aqdgnvn4fp2906 instead of
qqyx49mu0kkn9ftfj6hje6g2wfer34yfnq5tahq3q6. An address with no history, so:
zero. An account path now extends to /0/0 instead of being derived in place.
The deeper one: a seed import mounted on the single-address adapter, which
watches exactly one scripthash. Bitcoin.com, Electron Cash and the rest
spread funds across a whole BIP44 account, so even with the right leaf the
balance only shows if it all happens to sit on the first receive address.
A seed import is an HD wallet and now mounts as one — the same BchWallet the
vault-derived wallets use, whose WalletKeys walks receive AND change to a gap
limit of 20. That is what actually finds the money, and it brings real spend
support to seed imports as a side effect.
WIF imports are unchanged: one key is one address, nothing to scan.
Existing seed imports are picked up without re-importing. accountPath is
stored in either shape — older imports kept the full leaf, the QR carries the
account — and the mount trims both to the last hardened element before
handing it to WalletKeys. The stale display address stored at import time is
irrelevant, since the panel reads the address off the adapter's snapshot.
Mounting now reads the signer up front to decide which adapter to use. A
locked vault still mounts watch-only from the stored address rather than
failing, and the WC manager gets its own copy of the root because it keeps a
live reference for the per-URI relay-identity HKDF.
Auto-detect already worked — the backend returns
detectedLanguage:{language,confidence} and the panel put it in the
status line. But that only appeared after a translation had already
run, in small text, away from the control that raised the question. You
could not tell what "Auto-detect" had decided before committing to it.
The first row of the source select now says "Auto-detect · German", and
it says so while you are still typing. Detection runs on its own via
LibreTranslate's /detect, debounced 700 ms and gated at 12 characters,
because a detector given two words is guessing and firing per keystroke
would pound a public mirror for nothing. It chains the same mirror
fallback as translation, so a dead primary does not make detection look
broken while translating still works.
Confidence below 60 renders as "German?" rather than silently asserting
a coin-flip. The Google backend has no detect-only route, so there
doDetect returns null instead of burning a request, and the label is
filled from the translation response — which every backend returns
anyway, so a skipped or failed detect is never worse than before.
Detection retires when a source is named explicitly, comes back on
returning to Auto-detect, and is wiped by clear. A right-click
selection schedules one too, since that text arrives with no keystroke.
Verified against the real mirror (de/fr/ja at 100/100/90%) and in the
harness: short text fires nothing, long text fires once, four rapid
edits debounce to one call, and every transition above lands.
Also adds the xray removal script used to take the old engine off all
three exits now that they run sing-box.
A real export read "1|buffalo body coyote poem …" and was rejected: the
classifier only accepted a bare phrase, so a version marker in front of the
words was enough to make a valid seed look like junk.
Wallets wrap the phrase in their own envelope — a version number, sometimes
a derivation path, pipe- or comma-separated, sometimes JSON. Rather than
guess which wallet produced it, the payload is now split on every run of
non-letters (spaces survive, since they separate the words) and any
BIP39-shaped run in the pieces is taken as the phrase. A derivation path
found anywhere in the original string is carried over too.
Being tolerant of the wrapper does not loosen what counts as a phrase: the
pieces must still be 12/15/18/21/24 words of 3-8 lowercase letters, so a URL
or an address breaks into single-word pieces and matches nothing. Verified
that the phishing-URL and address cases still fill no field.
The path only lands in the box when the box is empty. That field is
prefilled with the coin's default and may have been edited, and silently
changing which addresses get derived is what lost funds look like; if a path
is already there and differs, the message names both and leaves the choice
to the user. The unrecognised-payload preview also grew to 90 characters,
since 48 truncated the evidence needed to tell what a rejected QR actually
contained.
chipnet.imaginary.cash's web explorer is returning 502, so every "Explorer"
button on a chipnet wallet led to a Bad Gateway. Its Electrum endpoint on
:50004 is a separate service and is fine — balances, history and the send
path were never affected, which is worth stating because a dead explorer
link looks like a dead wallet.
The explorer now resolves transactions as well as addresses, so it can back
both links rather than half of them. One search box: 64 hex characters is
treated as a transaction id, anything else goes to the cashaddr decoder so
it can complain precisely. A transaction shows confirmations, total out,
size and timestamp, its inputs as links to the parent transactions, and its
outputs as links to the receiving addresses — in-page, without a reload.
Inputs deliberately show no amount: Electrum does not resolve it, and
fetching every parent transaction to display one number is not worth the
round-trips.
Aegis's chipnet explorerTx/explorerAddr now point there. Same chain data,
read over the same Electrum connection the wallet already trusts, and a page
we can fix ourselves the next time one breaks. Mainnet is untouched.
Import accepts a picture of a QR code. The file is decoded in the panel by
the vendored jsQR, drawn to a canvas: no upload, no network, no camera.
Image, not camera, on purpose. Theseus sets blockCamera + hideMediaDevices
by default and hides device labels from fingerprinting; a camera scanner
would fail silently for everyone until they turned that off globally, and a
wallet should not be the reason a privacy browser gives up the camera. A
photo or screenshot of the code needs no permission at all.
What comes back is classified, never trusted. A QR is opaque to the person
holding it, and "scan this to restore your wallet" is a working phish, so
the decode only chooses which field to fill:
BIP39-shaped (12/15/18/21/24 lowercase words) -> mnemonic field
WIF or 32-byte hex -> private key field
anything else -> nothing is filled; the
decoded text is shown so
the user can see it was a
URL, an address, or junk
Nothing auto-submits. The user reads what landed in the box and presses
Import, and the host handler still does the real validation.
jsQR 1.4.0 is vendored at lib/jsqr.js under Apache-2.0 with its LICENSE
beside it, unmodified and unminified — code that touches seed phrases should
be auditable in the shipped add-on, not an opaque blob. It is 57 KB gzipped.
Verified by round-tripping through the shipped path: Aegis's own encoder
builds the QR, it is rasterised to a real PNG File, and decodeQrFile() reads
it back byte-exact; an image with no code returns null rather than throwing;
and driving the actual file input fills the mnemonic for a seed, fills the
key field for a WIF, and leaves every field untouched for a phishing URL.
Navigation base, the way Firefox does about:preferences#privacy: the
address bar follows the Settings page (theseus://settings/privacy) and
the hash mirrors it, so every page has a link; a page can have
sub-pages (theseus://settings/privacy/exceptions) with a breadcrumb and
a back arrow; open-settings and theseus:// links accept the two-level
slug.
Privacy now reads top-down: a "Theseus is on guard" card (Shield and
its running total, cookie pop-ups answered, Tor state, version), then
Tracking protection with the Shield and Cookie Pop-ups cards moved here
from Performance and a Manage exceptions sub-page listing the sites
each add-on was told to leave alone (remove to protect again), then
Device access, Anti-fingerprinting, Network (Tor switch and the VPN
panel) and Browsing data. Performance is about resources again.
Both selects grow a "Frequently used" optgroup above "All languages",
holding up to ten entries ranked by how often each language has actually
been translated to or from.
Counted on a successful translation, not on a dropdown change: picking
your way down the list looking for something would otherwise rank every
language you skimmed past as highly as the ones you work in. A
detected source counts too — with Auto-detect on you never pick that
language explicitly, but it is one you read.
Ties break on the language's display name so the order is stable rather
than dependent on object key order. Counts live in the existing uiState,
so they persist through the saveUi/loadState path already there, and the
group only appears once there is something to put in it.
Verified in the harness: one translation records exactly one use for the
target and one for the detected source; the cap holds at ten with
fifteen tracked; and a pre-seeded profile comes back with its group,
target and zoom restored, Auto-detect still first in the source select.
Three bugs, all found by driving the add-on in a real Theseus and
watching the egress IP rather than reasoning about it.
1. The generated config used pre-1.11 schema. `sniff` on an inbound and
the `block` outbound type were deprecated in sing-box 1.11 and
REMOVED in 1.13, so 1.14.1 refused the whole file and exited 1.
Routing is now a bare `final`; rule `action` semantics changed in
1.12 and the explicit inbound→outbound rule was never needed.
2. xray-core 26.3.27's REALITY would not complete a handshake with a
sing-box client — and, after ruling out keys (three derivations, a
fresh pair used verbatim), shortIds (explicit and empty), clock skew,
dest reachability, TLS 1.3/X25519 on the dest, and xtls-rprx-vision,
not with a correctly configured xray client either. sing-box against
sing-box works first try. The exits now run sing-box, which is what
the add-on already ships to every client, so there is no longer a
cross-implementation surface at all. Migration script included; it
keeps the port, the SNI and the existing uuid pool and only changes
the Reality keypair.
Worth recording separately: xray's REALITY inbound field is `dest`,
not sing-box's `target`. That was wrong too, independently.
3. leaseEndpoint cached the full vless URL. The Reality key and short id
live inside that URL, so re-keying an exit left every client failing
against a stale copy for the whole 24h lease. It now caches only the
uuid and rebuilds the URL from the current catalogue entry, so a
re-key takes effect as soon as the catalogue refreshes.
Verified in Theseus over CDP: baseline 80.187.100.105, tunnel up
81.31.210.65 (the sm-1 exit), off restores the baseline, and sm-3 is
correctly refused to a free-tier caller.
Found by driving the add-on in a real Theseus rather than reasoning
about it. turnOn() failed with "Silent Mode · 1 is not yet configured
(ready)" — resolveEndpoint required the catalogue entry to carry a
vless URL, but the gateway catalogue deliberately ships none, because a
vless URL is the credential and that endpoint is public. The add-on
predates the key-issuer and was never taught to ask for a lease.
It now POSTs to /api/vpn/session for any ready exit that has no URL of
its own, and caches the lease under its serverId until a minute before
expiry. Baked-in and subscription entries still use their own URL and
never hit the network.
Shield and Cookie Pop-ups are settings more than tools, so their
switches, the cookie mode, the counters and "Update rules" now sit in
Settings › Performance under a Protections heading, driven through the
add-ons' own message handlers (Settings-only IPC). Each card opens the
add-on's panel for the per-site details, and each panel links back to
Settings. The two add-ons start hidden from the toolbar's extension
row (manifest dock:"hidden", honoured once so a user who shows them
keeps them); "Show hidden" on the row brings them back.
theseus://settings and theseus://settings/<section> are now addresses,
so any page or note can link to a Settings page.
Also: a Settings or add-on tab that the user navigates elsewhere stops
counting as that tab, otherwise "open Settings" kept focusing a tab
that no longer showed Settings.
The 380px panel is the wrong shape for anything that needs room. This opens
the wallet as a full Theseus tab, with the sidebar's own furniture — identity,
network, balance, section nav, wallet list — laid out as a left rail and the
tab body given to the selected section.
It is the SAME panel.html, loaded with ?surface=web. No second wallet, no
second copy of 4,600 lines to drift apart. That works because an add-on's own
tab is handed a window.silentmode with the same invoke/on surface as the
sidebar, and addon-msg dispatches it as from:"panel" with the add-on identity
derived from the file:// sender — so every existing handler, including the
panel-only ones, works there untouched. The whole change is a CSS grid behind
one attribute plus a chip to open it.
Details that needed care:
- The QR is drag-sized against a 380px panel and the size is remembered.
Given a 720px column it filled the page, so it is capped on this surface
only; the stored sidebar preference is left exactly as the user set it.
- #drop (the coin picker sheet) is fixed-position and sized for the panel;
it is pinned to the rail instead of covering the window.
- Content columns are capped at 720px so forms and lists keep the measure
the sidebar already tuned, rather than stretching across a monitor.
- Under 900px the grid falls back to the stacked layout, so a narrow window
degrades to what the sidebar already does.
- The "open full screen" chip hides itself on the full-screen surface, so it
cannot open a second copy of itself.
Everything is scoped to [data-surface="web"], so the sidebar is byte-for-byte
unchanged. Verified both surfaces, the narrow fallback (by exercising the
real media rule) and zero horizontal overflow.
The aegis.x/app URL still needs a main.js route in Theseus; this ships the
destination over the add-on channel first.
A bundled add-on that answers cookie consent dialogs, rejecting all but
the essentials by default (or accepting, if the user prefers the banner
simply gone), so pages open without one. Built on DuckDuckGo's
autoconsent (MPL-2.0): its rule bundle covers hundreds of consent
managers, and a reject-button heuristic handles unknown banners in
reject mode. The library runs through the page-inject slot in every
http(s) frame's isolated world; the add-on hands each frame the user's
settings and the rules, and counts what was handled per site for the
panel, which also excludes a site with one click.
build-inject.js assembles inject.js from the library in node_modules
plus the glue, and copies the compact rules and licence into the add-on
so a rule update can ship through the add-on channel.
Host side: page-inject scripts get theseus.evalInPage for the few rules
that need the page's own JavaScript (they already reach the page via
contextBridge, so no new trust tier), and api.tabs is open to
page-inject add-ons as well as request-filter ones.
Theseus had no content blocking at all. Shield blocks requests to known
tracking and advertising hosts on every site, using EasyList and
EasyPrivacy through Ghostery's adblocker engine (the matcher those lists
are written for). The lists ship inside the add-on so blocking works
from the first launch, offline; the compiled engine is cached under the
add-on's data dir (a 22 ms load instead of a 500 ms parse), and the
lists refresh from their publishers about once a day.
The panel shows what was stopped on the current page, a one-click
allow for the site, the global switch, the running total and the rule
versions with an "Update now". Network filters only for now: a blocked
request never leaves the browser, but leftover empty ad boxes are not
hidden yet.
Host side: a "request-filter" capability. Chromium allows one
onBeforeRequest listener per session, so main owns it and consults the
add-ons' filters; a top-level navigation is never blocked, only http(s)
subresources are offered. api.tabs (active tab and a change event) lets
the panel show per-site numbers without seeing page content.
Three complaints, one cause between the first two.
A line of a PDF is rarely one run. pdf.js splits it wherever the file does
— a font change, a kerning adjustment, a colour change — so a heading can
be three spans and an invoice line ten. Replacing the span under the
cursor covered a fragment and left the rest of the line standing, which is
exactly what a replacement that looks like a copy laid over the original
is. A run is now the whole visual line: the spans that share its baseline
and sit close enough to be spacing rather than a second column, with the
spaces the geometry implies put back between them.
And that line is edited on the page. The dialog that used to hold a copy
of the words is gone: the cover goes down first, carrying the line's own
words at the page's own size and colour, and the caret opens on it. The
cover keeps its words hidden while you type, so the original never shows
through the thing covering it. Escape with nothing changed lifts the cover
again and leaves the page as it was found — no mark, no undo step.
The rotate grip was a square like the resize handles, wearing the open
hand that means drag-the-page. It is a disc with a turning arrow now, and
a cursor drawn to match, since no standard cursor means turn. The inline
style that was defeating the stylesheet is gone with it.
Ctrl and the wheel zoom, about the pointer rather than the top-left, so
the words you were reading stay where they were. A plain wheel still
scrolls.
Records what the gateway routes do, the four things that still have to
happen on the servers before the dropdown can light up (UUID pool per
inbound, the pool file, X-Forwarded-For on the vhost, deploy-from-repo),
and — deliberately at the same prominence — that expiry is bookkeeping
rather than enforcement until xray's handler API is wired per box.
Also flags the per-client byte cap as an unverified cheaper mitigation,
marked as needing a test rather than written up as though it works.
Drops the meridian ellipse so the globe is just a round field, and
spends the space that frees on the glyphs: tiles grow from 11.6 to 13.4
units and the A from font-size 10 to 12.4, with the 文 strokes
thickened to match. Checked the same way as last time — rasterized at
true 16px and 18px and magnified nearest-neighbour — against three
candidates (plain globe with tiles, letters bare on the globe, and a
single tile). Tiles won: bare glyphs on blue lose too much edge
contrast at 16px, and the two-tile arrangement is what reads as two
scripts rather than one word.
Same mark in the manifest and in the panel header.
A band starts from the space between things, so that a drag across words
still selects words to copy. On a page that is wall-to-wall text there is
no such space, and the gesture simply did nothing — which is how it failed
on a real document at 276% zoom, where every candidate starting point had
a line of text under it.
Alt-drag starts a band anywhere, text or not. The select hint says so.
Select, Text, Highlight, Draw, Sign — labelled, in that order, at the head
of the toolbar. Each one leads a kit rather than hiding it: the shapes sit
behind Draw, underline and strike-through behind Highlight, and nothing is
offered twice.
Draw arms the pen, which is what Draw means when there is one button for
it. The names drop out below 1180px, where five of them would start
pushing the zoom and colour controls off the end.
Auto-translate: changing either language only called saveUi(), so the
pane below kept showing the previous language's result with nothing to
say it was stale — you had to notice and press Translate. Both selects
now re-run the translation, guarded on empty input and on
source === target (which would only echo the input back).
Text size: the panes were 13.5px, small for reading a paragraph in a
language you don't know well, which is the entire job. Base is now
15px, driven by a --tsize custom property so both panes stay matched.
Zoom: −/+ either side of a percentage in the header, 70–220% in steps
of 10, clamped with the buttons disabling at each end. Click the
percentage to reset. Ctrl/Cmd with +, - or 0 does the same from either
pane. Persists per-machine alongside the language choice.
Icon: a globe with an A tile and a 文 tile, replacing the 🌐 emoji that
was indistinguishable from every other globe in the dock. Drawn against
the 16px and 18px rasterizations rather than at a comfortable size —
the first pass used a font glyph for 文 and it turned to grey mush at
16px, so the strokes are hand-drawn paths thick enough to survive. Also
drops the hardcoded icon in registerSidebarPanel, which would otherwise
shadow the manifest's mark.
Verified in a harness with a stubbed host API: language change fires
exactly one request, the two guards fire none, zoom clamps and persists,
and the layout holds at sidebar width.
A wallet imported from a single private key cannot do WizardConnect, and no
amount of work inside Aegis changes that: the handshake ships BIP32 xpubs so
the dapp derives addresses without further round-trips, and a lone key has no
chain code to build one from. Manufacturing a parent whose child equals a
given key means inverting HMAC-SHA512, and even solved for index 0 the dapp's
next index lands elsewhere. The way out is to stop being a single-key wallet.
Promote derives a fresh wallet from the vault on the import's own network and
sweeps the key into it, after which WizardConnect works — and so does every
other thing that assumes a key tree. It reuses plan() + signAndBroadcast(),
the same pair the consolidate flow already spends through, rather than
growing a second money path.
Three things it deliberately does not do:
- The preview costs the sweep by planning a send-max to the wallet's OWN
address, so cancelling leaves nothing behind. Same inputs, same single
P2PKH output, so the fee is identical to the real sweep.
- The imported key is kept, not deleted. The sweep is unconfirmed when the
call returns and anyone holding the old address can still pay into it;
removing the key there would strand those coins. Removal stays a separate
step the user takes once the balance reads zero.
- A promote that fails in plan() takes the just-created wallet back out,
since nothing was broadcast. Past that point the wallet is kept even on
error, because a transaction may already be on the wire and its
destination has to stay visible.
BCH only: the BTC/DGB/ETH/TRX/SOL imported adapters still throw "read-only"
from plan(), and the refusal now names the chain instead of failing vaguely.
Wallet creation is lifted out of the addWallet handler into
createVaultWallet so promote builds its destination through exactly the same
purpose allocation, legacy-purpose carry-over and id numbering as the Add
flow, instead of a near-copy sitting next to a transfer.
Select does what selecting does in every editor people already know.
Double-click bare page and a caret opens there; double-click the document's
own words and they open for replacement; what is selected copies with
Ctrl+C, pastes with Ctrl+V and goes with Del. Nothing was taken away — a
drag on empty page still gathers an area, and a drag that starts on words
still selects words to copy.
Type text and Edit text were two buttons for one question the click already
answers. They are one Text tool: land on the document's own words and it
offers to replace them, land anywhere else and it starts new text. The
words light up under the cursor so which is which is visible before
clicking, not after.
The toolbar says what it is for. Select, Text and Sign are labelled and set
apart; the drawing kit and the markup kit are their own groups. Sign gets a
pen icon over a signature rather than a squiggle that could have been
anything.
Signatures take ink — black, blue, red, green — chosen while drawing and
kept with the signature, because people sign in a particular colour and it
belongs to the signature, not to whichever swatch was armed. And they turn:
a grip above the box, free rotation, Shift to snap to 15°, for the signing
line that is not square to the page.
Two faults the tests found, both invisible by eye:
The rotate grip was drawn in the right place and could not be grabbed —
the selection bar floats directly above a mark, which is exactly where the
grip sits, and it swallowed every click. The bar now stands clear of it.
Undo would not undo a first rotation. Restoring a mark with Object.assign
copies the keys the original HAD, so a property the drag introduced
survived the restore; the journal then recorded the rotated state as the
state to go back to. Restoring now forgets keys the original never had,
which fixes every future property with the same shape.
Also: building a document from pictures or joins refuses to start a second
one on top of the first, and says so rather than failing quietly.
Drag on empty page and a rubber band gathers every mark it sweeps over.
Touching counts rather than enclosing: a band you have to draw right
around a long arrow is a band you draw twice. Shift-click adds or removes
one, Ctrl+A takes the page.
What the group can then do is move, restyle, duplicate and delete, each as
a single undo step — six marks deleted is one thing the user did, so it
has to be one thing to undo. The selection bar offers only what is true of
every member: a group of shapes gets Fill, a group of stamps gets size and
weight, a mixed group gets neither, and none of them gets Edit, which
needs one mark to put a caret in.
No resize handles on a group. Stretching a mixed selection means deciding
what a stretch does to text, which scales by font size, and to a line,
which has no box at all; until there is an answer worth defending,
offering the grip would promise something this cannot keep.
Each member is outlined as well as the group, because a band that caught
one more mark than you meant is worth seeing before you press Delete.
A group lives on one page. A mark carries its page, and PDF user space
means nothing across two of them, so a band selects within the page it
was drawn on.
One trap found on the way: starting the band on a press means calling
preventDefault, which is also what gives a form field its caret — so the
band now keeps its hands off the annotation layer, and typing into a PDF
form still works.
Three things the editor made you work around.
Text was typed into a dialog and then placed, so you chose a size and a
weight for words you could not see against the page they were going on.
The click now opens a caret where you clicked, in the font, size and
colour the words will have, with the style bar over it; double-clicking a
stamp reopens it in place. Lining a CSS line box up with a PDF baseline is
measured from the font's own metrics, not guessed.
A signature lived in a single slot. There was nowhere to keep initials as
well as a name, nowhere to change the one you had, and reaching for the
tool again simply stamped the first one — which is the same fault three
times: one slot. It is a library now, with redraw, rename and delete, and
the choice is made when the tool is picked up, so placing stays one click.
An existing single signature is carried into it rather than dropped.
The mark you had just drawn could be resized by its handles and not moved
by its middle, because only the select tool let marks be hit-tested at
all. The SELECTED mark now takes a press whatever tool is armed. A press
anywhere else still draws, and an unfilled shape is still grabbed by its
outline — the same rule select has always followed.
Also: words default to dark ink rather than highlighter yellow, which was
unreadable on white and is now impossible to miss, since you watch
yourself type it.
0.3.56 was cut from the Aegis line (WizardConnect auto-detection, Aegis
0.8.x, PDF Editor and VPN updates) on top of 0.3.55; 0.3.57 carries that
plus the install-as-app feature and the two main-process crash fixes.
Eight decimals of tail sat exactly where the eye lands. The hero now shows
two decimals once there is a whole part, because the whole units already
carry the significant digits, and digs past leading zeros otherwise so
amounts under 1 keep roughly four significant figures:
1204.23234145 -> 1,204.23 0.23234145 -> 0.2323
0.00012345 -> 0.0001234 0.00000001 -> 0.00000001
A flat "two decimal places" rule is the obvious version of this and it is
wrong: it renders 0.00042 BCH as 0.00 and the wallet reads as empty. The
rule here never collapses a non-zero balance to zeros, and it truncates
rather than rounds, so the figure shown is never more than the wallet holds
and sending the displayed amount always clears. An ellipsis marks the cut,
hover gives the exact figure, clicking pins full precision and is
remembered. Only this hero abbreviates — Send, MAX, history and the wallet
strip still call fmtBig, so every number acted on is exact.
Also repairs the regexes in the same function, which lost their backslashes
when the earlier commit was scripted: /^(-?)(\d+)(\.\d+)?$/ had become
/^(-?)(d+)(.d+)?$/, matching the letter "d". Still valid JS, so it parsed
cleanly and every balance quietly fell through to unformatted text — the
grouping and the dimmed decimals introduced in bb639a9 had never once run.
Caught by testing the function body extracted from the file rather than a
transcription of it.
The network chip sat on its own row holding a single chip and ~300px of dead
space, while the connection state and the per-wallet actions sat on a second
row underneath the balance. Two thin rows sandwiching the number people open
the panel for pushed it into the middle of the header. They are now one row
above it: [network] [state] ......... [Explorer] [Faucet].
Neither half belongs in Settings. The status line is not a hostname label —
it is also where "connecting…", "· syncing", "· <n> unconfirmed" and the
walletd setup prompt are written, so filing it away would hide an unconfirmed
balance and an instruction. Explorer and Faucet are verbs on the selected
wallet rather than configuration, which is why 0.9.2 lifted them out of the
Receive card in the first place.
Only the hostname may shrink. Left to itself the flexbox took width off the
network chip first and clipped "Chipnet testnet" to "Chipnet" — the one label
on that row that must never be ambiguous, since it says whether the next
action moves real money.
First pass of the wallet-app restyle. The panel led with a 22px balance that
carried no more weight than the labels around it, so the one number people
open the sidebar to read had to be hunted for. It is now the hero: 32px,
thousands-grouped, with the fractional part dimmed so magnitude reads before
precision. The ticker and the fiat conversion sit on its baseline as
annotations rather than peers, and wrap to their own line intact once the
amount outgrows a narrow panel.
The Receive/Send/History/Settings bar used an underline for the active tab,
which read as browser chrome and all but vanished at sidebar widths — a 2px
rule under a short label is easy to miss. It is a segmented pill bar now,
active section filled in the accent. The hover tint derives from --ink so it
shows in the light theme too, where a white overlay was invisible.
No charts yet: there is no price history behind Coin-Spectrum to draw, so
nothing here fakes a trend line.
Every chipnet wallet in the picker read "can't pair" with the reason
nowhere on screen: the explanatory note only rendered when NOTHING could
pair, so one working mainnet wallet hid it entirely. The cause now rides
on the row itself ("can't pair (WIF import)") and the note appears
whenever any wallet is blocked. A single private key has no chain code,
so there is no xpub for the handshake to send — the text now says that
and points at the two ways out.
Seed-imported wallets stored the full leaf path (m/44'/1'/0'/0/0) in
accountPath, because that is what derived the one address the strip
shows. WizardConnect was handed that as the BIP44 *account* node and
would derive m/44'/1'/0'/0/0/<branch>/<i>: a tree the user holds no keys
in. Pairing looked healthy and every sign request failed with "no path
for input". Same class as the 0.9.7 chipnet mismatch, one level down.
A dapp drops its pairing code from the DOM once connected, so the
commonest empty scan is a page that is already paired. Sending that user
to "open the Connect dialog" points at a dialog the dapp will not show
again; the message now names the existing pairing instead.
Each .warow was its own grid container, so the `auto` amount column
resolved independently per row: a row holding 0.3066756 got a narrower
column than one holding 0.43789841, and the copy button therefore landed
at a different x on every line. Column widths have to be SHARED to line
up, and nothing was sharing them.
The columns are now declared once on a .walist wrapper and every row
inherits them via `grid-template-columns: subgrid`. Column gaps moved to
the parent, since a subgrid takes its gutters in the subgridded axis from
the grid it inherits and would have ignored them on the row. A
@supports fallback pins the amount column to a fixed 104px for any host
without subgrid, which keeps the copy column straight there too.
Measured across six rows with four different amount lengths, at 260,
320, 400 and 520px wide: copy left edge, amount right edge, ticker left
edge and menu right edge all have 0px spread, with no row overflow and
no clipped amount.
0.9.8 claimed this area was verified, but the check only looked for cell
overlap WITHIN each row and never compared the same column ACROSS rows —
which is exactly the defect it missed.
The main-world bridge was pushed into every https page as a text script.
Sites that enforce Trusted Types refuse that and report the attempt to
their CSP endpoint — Google's sign-in pages among them, which then have
every reason to call the browser insecure. No dapp lives on those
origins: a static list of the big enforcing sites is skipped outright,
any other origin that rejects the bridge once is remembered and skipped
from then on, and where Trusted Types exist unenforced a policy keeps
the assignment clean.
The row's grid template still described the pre-0.9.2 layout — six
columns including an address cell that no longer exists — while the row
renders five. Every cell therefore sat one column left of where it
belonged: copy landed in the label's space, the amount in the old label
column, and the ⋯ in the amount column instead of the edge. That is the
whole cause of "the copy button collides with the amount" and "the last
edit button is not on the edge".
Now five columns for five cells: icon · label · copy · amount · menu,
8px gaps. The label is the flexible one, left-aligned, so it takes the
slack and truncates rather than squeezing the number. Verified at 520,
400 and 300px: no cell overlap at any width, no row overflow, and the
amount never clips — only long labels give way.
The copy glyph was the 📋 emoji, which has no glyph in this platform's
font stack and rendered as a tofu box that read like a stray character
stuck to the balance. Replaced with an inline SVG in both the row and
the address card. Both confirmation flashes now swap innerHTML rather
than textContent, which would have deleted the SVG and left a blank
square.
Address card: the address and its copy button share one row, so the
button sits at the end of the value it copies. The address clamps to two
lines and truncates beyond that instead of growing the card in a narrow
sidebar.
The QR is always visible and the panel is drag-resizable from a grip
under it (pointer events, arrow keys as a non-mouse path, clamped
90–420px, capped against the panel's own width so a size set on a wide
sidebar cannot overflow a narrow one, persisted). That replaces the
0.9.2 show/hide toggle — a size set once beats a binary, and it frees
the row Copy was sharing with a QR button.
drawQr was setting cv.style.width/height, which would have reset the
panel to its intrinsic size on every redraw — i.e. every time the
address changed. It now sets only the backing store and re-asserts the
user's size after drawing.
"Next unused address" is correct at the adapter level on both mainnet
and chipnet (tested: the index advances and the address changes), so the
reported failure is elsewhere. The handler was discarding the error and
flashing a bare "Failed", which is why there was nothing to diagnose; it
now surfaces the real message.
A chipnet BCH wallet derives from m/44'/1'/0', but the WizardConnect
registration fell back to a hardcoded m/44'/145'/0' whenever the entry
had no explicit accountPath — which is the normal case for a wallet
created through the UI. Mainnet's default happens to be that same
literal, so only chipnet was affected.
The consequence was worse than a failed pairing. Pairing SUCCEEDED, the
handshake carried xpubs for an unrelated key tree, and the dapp then
derived addresses this wallet does not own:
wallet's real chipnet address : bchtest:qpezx8qkwpjd4e6pd5aang0ve6fctpjvg5ckp2lwu7
address from the WC xpub : bchtest:qzvpe3w9rqnszk6mntnef6zv6v94zmfvpc49qkxml4
So the dapp saw an empty stranger's wallet, and anything it built spent
inputs the wallet could not match — signing would fail with "no path for
input". Silent, and only reachable on testnet.
Both registration sites (vault-derived and imported) now take the path
from adapter.snapshot().accountPath, which is by construction the tree
the wallet actually derives its addresses from.
Two wrong turns worth recording. defaultAccountPathFor() takes the coin
CONFIG object, not a chain string, so passing entry.chain returned null
and would have stopped WizardConnect registering at all — strictly worse
than the bug being fixed. chainMeta().defaultAccountPath was no better:
BCH has no coinType in COINS, so it is null for every BCH network. The
adapter is the only component that resolves this correctly, which is why
it is now the source.
The Custom box validated for a vless:// prefix and rejected anything
else, so a provider's subscription URL — the thing most people are
handed — got "paste a vless:// URL first" with no hint that the
Subscription card two sections down was what it wanted. Now an
http(s):// paste in that box is detected and routed to the subscription
importer, the placeholder says both are accepted, and the dropdown
option reads "Custom — vless:// or subscription URL".
Also renames the three bundled entries' status from "coming-soon" to
"awaiting-key-issuer". The exits exist and are running xray; what is
missing is a way to hand a client credentials without shipping a shared
secret. DESIGN.md now records why that list stays empty, since this is
the second time the shortcut looked attractive: a vless:// URL is the
credential, so writing one into the tarball (immutable, mirrored) or
onto a public Sia object (mutable but world-readable) are the same
category of mistake. Per-session minting is the fix, because then no
shared credential exists to leak.
Parser verified against plain-text, standard-base64 and url-safe
unpadded-base64 subscription bodies; an HTML error page correctly
yields zero entries instead of a JSON parse crash.