The whole-field carve-out for allowedSites/deniedSites was false:
src/background/index.js pushes an approved/denied hostname onto them
in place, and the Settings revoke button filters one out in place from
a different page — the exact membership-vs-leaf pattern that made a
whole-field wallets diff unsafe, on a security-relevant field. A stale
page's save could resurrect a just-revoked permission or wipe one just
granted elsewhere. Both are now merged by address key and then by
hostname (mergeSiteMap()), the same way wallets merge by identity.
networkEndpoints gets the same per-key treatment for its lesser,
non-security version of the same race. tokenHolderCache stays
whole-field, correctly this time: nothing in src/ ever writes an entry
into it.
mergeListByIdentity() also had no floor of its own: two wallets
sharing walletIdentity()'s empty-fallback identity collapsed into one
via a Map, and mergeWallet() discarded the losing side's
encryptedSecret outright when there was no shared baseline to diff
against. Not reachable from today's UI, but the merge should not rely
solely on call-site discipline elsewhere. A same-identity collision
within `ours`, or between an unmatched `theirs` and a colliding
`ours`, is now detected and both records are kept rather than one
silently dropped.
New tests in tests/stateMerge.test.js, confirmed failing against the
prior state.js (stashed the fix, reran full suite: 3 red, 832 green;
restored, all 835 green):
- a dApp approval survives a stale Settings page revoking an unrelated
site
- a revoked site permission stays revoked against a stale page's later
save
- two independently created wallets with a colliding identity both
survive, encryptedSecret included
make check: 835/835 tests, test-verify-build 39/39, check-censored
clean, lint stage ran fresh in the pinned container (not CACHED),
prettier clean. No containers left running.
backgroundRefresh() mutates state.wallets in place (addr.balance/ensName/
tokenBalances via refreshBalances()), so a whole-field diff on `wallets`
marked the entire array "changed" the moment any balance moved and wrote
back background's own copy -- loaded before its multi-second network
round trip -- clobbering a wallet another page added, or resurrecting one
another page deleted, in that window. That is DoD item 2 on the issue,
still unmet by the prior whole-field merge.
`wallets` is now merged structurally: by wallet identity (xpub for
hd/xprv wallets, address for key wallets, both already enforced unique),
then by address identity within each wallet. A leaf background actually
changed applies on top of storage's current copy; membership added or
removed by another page applies independently, since it no longer
collides with `wallets` as a single field. Every other persisted field
stays a whole-field diff -- no code path mutates them the way
backgroundRefresh() mutates wallets, so there is no matching defect to
fix there.
Every extension page (the toolbar popup, a dApp approval window, the
background's backgroundRefresh()) holds its own in-memory `state`, loaded
once, and showView() saves on every navigation. saveState() wrote the
entire state blob, so any second page that saved overwrote whatever
another page had written since -- a whole wallet, name, addresses and
encrypted secret included, with no attacker and no unusual input.
saveState() now re-reads storage, diffs the persisted fields against a
deep-cloned baseline snapshot taken at this page's last
loadState()/saveState(), and writes only the fields that differ. Every
other field is carried forward from storage in its loaded-and-normalized
shape (normalizePersisted(), shared with loadState()), so a legacy or
malformed record a load has always self-healed in memory keeps getting
written back even on a save that touched something unrelated.
showView() fires saveState() without awaiting it, so two saves from the
SAME page can be in flight at once; a FIFO queue serializes them.
Deliberately not done, a documented deviation from the plan on the
issue: the live `state` of a field this page does not own is not
rehydrated from what another page wrote, only the persisted record is.
Adopting a concurrently-written value into `state` reintroduced the same
clobber one page later, under the fire-and-forget saveState() calling
convention every view uses -- caught red by tests/txStatus.test.js.
Two writers of the same field still resolve last-writer-wins, documented
at the merge point.
tests/stateMerge.test.js covers both required cases against the real
state.js and showView(): a save from a page loaded before a wallet was
added elsewhere, and the approval-window reproduction from the issue.
Both were confirmed failing against the prior full-blob write before
this fix landed.
A user who forgot their password but held their recovery phrase was permanently
locked out: deletion was password-gated and re-importing the phrase was refused
as a duplicate. Their only escape was destroying extension storage through
browser internals, taking every other wallet with it.
DeleteWallet gains an "I have lost my password" route that destroys the stored
secret after the wallet's name is typed back. No password gate was added:
requiring one to discard a secret protects nothing, since an attacker who wants
destruction can uninstall the extension, and the only person it stops is the
legitimate user who lost it. The screen is excluded from RESTORABLE_VIEWS and
registers an onViewLeave cleanup.
Deletion was chosen over re-import because a key wallet is duplicate-checked by
address rather than xpub, so an xpub-only relaxation would leave that user
still wedged; because re-import makes the user retype their recovery phrase
into a live popup merely to change a password; and because it reaches no end
state that delete-then-import plus scanForAddresses() does not. The attacker
argument did not decide it — re-import clears the "no worse than the phrase
alone" bar.
All three AddWallet password hints now state the password cannot be recovered
or reset and name that mode's only backup, the xprv mode correctly claiming no
recovery phrase. deleteAddress.js no longer tells the user that deleting a
wallet asks for a password, which this change made false.
The typed confirmation collapses internal whitespace on both sides: a wallet
renamed with two spaces displays with one, so the string a user could see and
type could never match, making the confirmation untypable on the one screen
whose purpose is un-wedging a stuck user.
Measured, not reasoned, after review found the first reserve twice too large
and pushing the Import button below the fold: #btn-add-wallet-confirm bottom
628.13 -> 580.13 at 360x600, scrollHeight 636 -> 600, hint box 48px identical
across all three tabs and on re-entry. make check 40 suites / 828 tests,
test-e2e 55/55, test-e2e-firefox 8/8.
A hostile ERC-20's symbol() reached an innerHTML string unescaped, and neither
manifest declared default-src, so an attacker deploying a token with 1,000+
holders and airdropping one unit could render a full-viewport cross-origin
iframe over the wallet's own UI, on screens where the user types their
password.
escapeHtml is now a pure string replace over & < > " ' — the old version
round-tripped through textContent, which escapes neither quote, while already
being used inside data-copy="...". All 19 files in src/popup/views/ were
audited: beyond the reported symbol site, the explorer-supplied directionLabel
in all three transaction lists, wallet.name, addr.ensName, the blockie data:
URIs and two ad-hoc quote-only escapes were also unescaped. Explorer URLs now
go through one helper that percent-encodes the path segment.
Both manifests add default-src 'self', frame-src 'none', form-action 'none' and
base-uri 'none'. Three loosenings are pinned in tests/manifest.test.js and
justified in README.md: style-src 'unsafe-inline' (39 static style attributes;
Firefox implements neither style-src-attr nor 'unsafe-hashes'), img-src data:
(blockies), connect-src https: http: (user-configurable RPC).
Note frame-src 'none' blocks a frame loading, not the element existing, so the
zero-iframe assertion is a claim about the escaping alone; the test asserts the
element count and the literal rendered text separately, taking the count before
any click an overlay could intercept.
Verified: make check 39 suites / 811 tests, test-e2e 55/55 including the
WebAssembly-under-CSP assertion, test-e2e-firefox 8/8, zero CSP violations
asserted rather than merely unobserved. Reverting only balanceLine's
interpolation reproduces the attack as 2 iframes on the address screen.
Both methods answered from the module-level state singleton, which the MV3
worker never populates, so a cold worker reported mainnet 0x1 to a page whose
user was on Sepolia.
They now answer from getState(), the per-call detached storage read the other
read handlers already use. An earlier revision of this fix used loadState()
instead and was rejected in review: it replaces the whole singleton, and these
methods are page-callable with no connection gate (inpage.js sends eth_chainId
on every page load), so a load landing inside backgroundRefresh()'s network
round trip detached the address objects being mutated in place — persisting
pre-refresh balances while still stamping lastBalanceRefresh, letting a polling
page suppress background refreshes indefinitely.
The test stub now structured-clones on get and set, as chrome.storage.local
does. The aliasing stub it replaces was independently measured to hide this
defect class entirely: with the aliasing get restored and the defective handler
in place, the suite passes 794/794.
Verified failing first three ways: a plain singleton read fails the three
cold-worker cases; the rejected loadState() revision fails only the new
mid-refresh case ("1.5" expected, "0" received); moving saveState() ahead of
refreshBalances() fails that case and only it.
decodeCalldata consulted only the 512-entry bundled list and defaulted to 18
decimals, so a transfer of 5,000 units of a 6-decimal token rendered
"Amount 0.0000" and the user confirmed a drain reading zero. The same
understatement applied to approve, where an unbounded allowance also rendered
0.0000.
Decimals now resolve from the bundled list, then trackedTokens, then the
address's explorer-reported entry, with uint8 validation and a refusal when
sources for one contract disagree. When no source knows the scale, no
formatUnits call is reached at all: the line renders raw base units with an
explicit "decimals unknown" warning, and the same string reaches
pendingTxDetails.amount so the status screens carry no formatted figure either.
Verified failing first two independent ways: restoring the old
`token ? token.decimals : 18` fails 6 of 15 new tests with the unknown case
reporting "0.0000"; making the resolver return 18 rather than null on the
unknown path fails a different 6, spanning resolver and render levels.
wallet_switchEthereumChain was answered for any origin at all, with no
connection check and no prompt, so any page could move the active chain and
clear the [TESTNET] banner under a user who believed they were on Sepolia. It
now takes the same allowedSites check the signing methods take and returns 4100
for an unconnected origin.
The handler also awaits loadState() before it reads or moves the network. The
MV3 worker populates nothing at module scope, so a worker revived by the page's
own message held DEFAULT_STATE: the same-chain check compared against the wrong
network, and the save wrote empty wallets, empty allowedSites and default
endpoints over the user's stored profile, destroying every wallet in the
extension. Also fixes#316.
Endpoints are now remembered per network in a persisted networkEndpoints map,
so a user running a local or private node no longer loses that url permanently
to a public endpoint on every switch. A stored map must be an actual object; a
primitive previously survived the load and made every switch fall back to the
public default with no self-healing.
Verified failing first: dropping only the added loadState() fails exactly the
two cold-worker cases; reverting only the type guard fails exactly the string
and number cases. Reverting both source files to next gives 12 failed / 751
passed.
The send screen was built from the indexer's decimals while the transfer was
encoded from the contract's decimals() read at signing time, with nothing
comparing them. A token whose scales disagree moved 10^12 times the approved
amount.
The displayed scale is now carried on pendingTx from the same tokenBalances
entry the amount, balance and symbol were rendered from, and both encode sites
use it. transferAmount.js refuses rather than falling back when the two scales
disagree or either is unusable.
Adds the first end-to-end coverage of the popup's own Send -> ConfirmTx ->
Sign & Send path; #btn-confirm-send had never been clicked by any test.
The provider rebuilt every rejection as a bare Error carrying only a message,
so a dApp checking err.code === 4001 saw undefined and could not tell a user's
deliberate refusal from a failure. Well-behaved sites therefore showed an error
or retried instead of accepting the refusal. The code was produced correctly
and did cross the extension boundary; it was lost in the last hop.
Rejections now reach the page as a ProviderRpcError carrying code, and data
where present. The code is passed through verbatim rather than matched against
a whitelist, so a code added upstream later needs no change here. An error that
genuinely has no code stays a plain Error with no code property at all, rather
than advertising code: undefined -- 'code' in err is what a careful dApp asks.
Messages are unchanged for every path, verified byte-for-byte against the
previous provider across every background error shape.
The end-to-end assertion that printed the observed code now requires it.
Seven bundled tokens were filtered as spoofs at their own address, so a user
holding FRAX, TON, REUSD, EURE, MSUSD, MUSD or JPYC could not see or spend the
one the wallet happened not to pick.
The known-symbol table is derived from the bundled token list, first-wins in
market-cap order, so a symbol that appears twice silently condemned its second
contract. Both are real tokens from the same fetch and neither is stale --
three pairs are one issuer's old and new contract, four are unrelated issuers
sharing a ticker. Picking a winner would have been guessing, and dropping the
ambiguous symbols would have ended spoof filtering for those tickers entirely.
The table now maps a symbol to the set of addresses that legitimately bear it.
A contract outside the set is still a spoof, so the check is not weakened: a
third contract bearing any of the seven shared tickers is refused, and that is
tested. The filter decides what is fake, not what is worth holding, so a legacy
contract stays in the set -- it still holds real balances.
A test walks the whole bundled list asserting no token is filtered at its own
address, which is the guard whose absence let this ship.
A token calling itself " ETH " missed the known-symbol table entirely, so the
spoof check reported it was not a spoof -- while HTML collapsed the whitespace
and displayed it as ETH next to the user's real ETH. One space defeated the
filter.
The symbol is now folded before the lookup: NFKC, remove what paints nothing,
trim, uppercase. The rule is "remove what paints nothing"; the Unicode classes
are how that is spelled, which is why U+007F is named separately -- it is a
control, reached by no class, and measures identical to no character at all.
Every width in the module comment was measured in the pinned browser rather
than reasoned about, and the boundary is pinned from both sides: widening to
all control characters fails the visible-controls test, narrowing back fails
the invisible-characters test. Two default-ignorable code points do paint a
box and are folded anyway, which can only hide a token that does not resemble
the symbol it folds to -- the harmless direction, recorded rather than glossed.
Confusables that are distinct letters, bidi reordering and interior whitespace
are knowingly left open and asserted open by tests.
Verification compared the signed artifact against the dApp's request object.
For every field the dApp omitted -- normally nonce, gas limit and all the fee
fields, since the popup filled them in -- the number the user actually read on
screen was verified by nothing, and only absolute ceilings stood behind it.
The transaction is now populated in the background before the approval window
opens, and that populated object is both what the popup displays and what the
signed artifact is verified against. Every consequential field becomes an
equality comparison; the ceilings remain as a backstop. Population failing
means no approval and no window, and the error goes to the requesting page --
earlier than before, where the same estimate failed after the password had been
typed.
The account is pinned too: `from` is compared against the address named at
approval time rather than whichever address is active at signing, so switching
accounts mid-flow refuses instead of signing from an account the approval did
not name. The message-signing path had the same defect and gets the same fix.
Nonce selection moves earlier as a consequence; the concurrent-approval case
that follows from it is tracked at #271.
The persisted view stack was restored verbatim. RESTORABLE_VIEWS stopped the
popup opening ONTO a view it will not re-render, but nothing kept such a view
out of the stack, so Back could land on a screen whose content was deliberately
never restored. No secret leaks -- those views are blank precisely because
nothing is restored into them; this is a navigation defect.
loadState() now truncates the stored stack at the first entry outside
RESTORABLE_VIEWS, dropping it and everything above it. Truncating rather than
splicing keeps the result a prefix of what was stored, so every surviving entry
keeps the Back target it had; splicing would silently re-point the entry above
the hole at a different screen. Filtering on load rather than on save is what
makes it retroactive for stacks already in storage, and leaves the live
in-session stack whole, which it should be.
The general case where Back lands on a blank screen even for restorable views,
because goBack() re-renders nothing, is separate and tracked at #268.
A rejected password was reported three different ways depending on which screen
you were on, including the fragment "Wrong password." which is not a sentence.
All six decryptWithPassword call sites now show the same full sentence.
Strings only -- a wrong password still fails closed on every screen and still
resolves no pending approval.
A test pins the invariant per call site: each decryptWithPassword call is walked
out to its enclosing try and forward to that block's catch, and the prose shown
there must equal the canonical sentence. Per-file matching was not enough, since
a file with two call sites kept passing while one of them diverged.
The dust-threshold field was the only validated input in Settings that rejected
without saying anything: the value silently changed back to the stored one with
no explanation. It now flashes "Please enter a whole number of gwei, zero or
greater." alongside the existing resync, matching the idiom the RPC URL field
already uses.
The parse moves to its own module and accepts plain decimal digits only, zero
or greater. Hex and exponent notation are refused rather than accepted: Number()
reads "0x10" as 16 and "1e3" as 1000, neither of which the previous parseInt
produced, and storing a number the user did not type is the same silent
substitution this change exists to remove.
The message must fit one line of the reserved flash area -- a wrapped message
pushes the settings view down, which the No Layout Shift policy forbids. That is
pinned by an end-to-end test measuring the rendered line height and the position
of the elements below it, in a single round trip because the flash clears after
two seconds.
approvalVerify now compares every field of the signed artifact against the
approval, not a subset. Transaction types are allowlisted to 0/1/2 and any
field the module does not check is refused outright, so a future transaction
type cannot smuggle consequential fields past verification -- an EIP-7702
type-4 artifact that delegates the signer's own EOA while matching every
displayed field was accepted before this change. The serialized bytes handed
to broadcastTransaction are compared against the parsed artifact, so the
guarantee covers the bytes that actually go to the node.
Signing failures in the popup are retryable again. To make that safe, an
approval is claimed synchronously before the first await and every path that
resolves or removes one goes through a single chokepoint that refuses a claimed
approval. Without it, closing the approval window, switching the active address
or a late reject would report "User rejected the request." to the dApp while
the broadcast completed -- the user then redoes the transfer at a fresh nonce
and it sends twice.
Failure copy distinguishes the stage reached, so a user is never told to start
again from the site when the first attempt may already have reached the network.
Address rows on Home gain an [x] control, on wallets that derive addresses from
an extended key and hold more than one, opening a DeleteAddress confirmation
screen.
Removal cannot destroy anything: the key material stays. Derivation indices are
not renumbered, so the next "+" derives the next unused index rather than
resurrecting the removed one. The confirmation states the real route back --
delete the whole wallet in Settings, which asks for the password and destroys
the stored recovery phrase, then import it again -- and notes that the scan
which follows only finds addresses with on-chain activity. The copy varies by
wallet type, since an xprv wallet has no recovery phrase.
Removing an address that holds a balance is allowed, with a warning naming no
figure; the funds are at the address on-chain and stay there either way.
Selection and active address move only when the removed address was the one
selected, and site permissions are dropped for it alone.
The state transition shares its address comparison, permission cleanup and
active-changed broadcast with the wallet-level removal.
build.js records which emitted bundles contain src/shared/constants.js, and
constants.js carries a marker constant-folded from DEBUG itself. script/verify-build
cross-checks the two and fails on every way of not knowing, so deleting the
__BUILD_DEBUG__ define now breaks the build instead of shipping a live debug branch.
The password no longer crosses the extension messaging boundary: the popup
decrypts and signs, and sends only the raw signed transaction or the signature.
The background re-derives the signer from the artifact and checks it against
the approval it holds before broadcasting, so it is not a blind relay.
Runs the real popup in a pinned containerized Chrome and fails on any uncaught
page error or console.error. Also fixes the two defects it caught: the missing
showView import in addToken.js and the missing addressDotHtml import in
transactionDetail.js.
closes#150closes#151
Fixes the highest-severity item in the repo: `src/shared/constants.js` had
`const DEBUG = true;`, so `generateMnemonic()` returned the publicly committed
`DEBUG_MNEMONIC` for every wallet created from a build of `main`, and the real
entropy path was dead code in every artifact we could produce.
## What changed
**`build.js`** — `AUTISTMASK_DEBUG` is read from the environment and injected
as a `__BUILD_DEBUG__` entry in the existing esbuild `define` map, next to the
other `__BUILD_*__` defines. Only the exact value `1` enables it; unset, empty,
`true`, or a typo all yield a release build, so the insecure direction requires
a deliberate opt-in and any mistake fails safe. The build prints
`Build mode: release (DEBUG off)` or
`Build mode: DEBUG (INSECURE - hardcoded test mnemonic, do not ship)`.
**`src/shared/constants.js`** — `DEBUG` now uses the same `typeof` guard that
`src/shared/buildInfo.js` already uses for the other build-time defines, and
defaults to `false` when the define is absent (jest, plain `require`).
`DEBUG_MNEMONIC` stays in the tree and stays exported.
**`Makefile`** — new `build-debug` target (`AUTISTMASK_DEBUG=1` + the same
build) so a debug build stays a one-liner for development.
**`README.md`** — new "Debug Builds" subsection under Getting Started, and the
DEBUG Mode Policy section now states that `DEBUG` is build-time-only and spells
out the boundary against the runtime toggle.
**`tests/wallet.test.js`** — new, covering both build modes.
**`TODO.md`** — refreshed in the same commit (details at the bottom).
No new `if (DEBUG)` branch was added and nothing about what DEBUG *does* changed:
still exactly the red banner plus the hardcoded test phrase, per the README
DEBUG Mode Policy and `RULES.md:76-80`.
## The interaction with the #145 settings toggle
This is the subtle part, so spelling out the reasoning.
There are two distinct debug flags in the tree after #145:
1. the compile-time `DEBUG` constant from `constants.js`, and
2. the runtime `debugMode` state flag, which the settings easter egg toggles
and which `settings.js:379` pushes into `log.js` via `setRuntimeDebug()`.
`log.js` merges them: `isDebug()` is `DEBUG || _runtimeDebug`. That merged
value feeds exactly two things — the log level threshold (`log.js:24`) and the
red banner (`views/helpers.js:71`). Making the banner user-toggleable is the
intended behavior of #145, and this PR leaves it alone.
`generateMnemonic()` does **not** consult `isDebug()`. It reads the
compile-time `DEBUG` binding directly. That distinction is what makes a release
build coherent: with `__BUILD_DEBUG__` false, `DEBUG` is false in the bundle,
so no amount of clicking the version ten times and flipping the toggle can
reach `return DEBUG_MNEMONIC`. The user can turn the banner and verbose logging
on in a release build; they cannot turn the hardcoded phrase on.
The failure mode to guard against is someone later "tidying up" the two flags by
routing `wallet.js` through `isDebug()`, which would silently reintroduce this
exact vulnerability with the runtime toggle as the trigger. Three things now
guard that: a comment at the `wallet.js` call site saying it must stay the
compile-time constant and why, the same statement in the README DEBUG Mode
Policy, and a regression test that calls `setRuntimeDebug(true)`, asserts
`isDebug()` is genuinely true, and then asserts `generateMnemonic()` still
returns fresh entropy.
I considered instead making the runtime toggle unavailable in release builds,
but rejected it: that removes a feature #145 deliberately added, and it defends
the wrong boundary. The banner is not the dangerous part; the mnemonic path is,
and that one is already unreachable.
## Verification
`make check` — green, 5 suites, 55 tests, plus lint and fmt-check. It also ran
via the pre-commit hook on the commit itself.
The new tests, per the verification standard in the manager comment on the
issue (not just `a !== b`) — with the flag off: two successive
`generateMnemonic()` calls differ, both pass `isValidMnemonic`, both are 12
words, neither equals `DEBUG_MNEMONIC`, and the result derives a usable HD
wallet (`xpub` + a well-formed first address), so a broken implementation
returning a counter or a truncated phrase would fail. Same assertions again
with the runtime toggle forced on. With the flag on (`__BUILD_DEBUG__` defined
before a `jest.resetModules()` re-require): `DEBUG` is `true` and
`generateMnemonic()` returns `DEBUG_MNEMONIC`, so the debug path is proven
working rather than silently deleted.
Build artifacts — `make build` and `make build-debug` both produce `dist/chrome`
and `dist/firefox` successfully. Grepping the minified bundles for the emitted
`DEBUG` export value across all four bundles (chrome popup, chrome background,
firefox popup, firefox background):
# after make build
$ grep -roh 'DEBUG:![01]' dist/chrome dist/firefox | sort | uniq -c
4 DEBUG:!1
# after make build-debug
$ grep -roh 'DEBUG:![01]' dist/chrome dist/firefox | sort | uniq -c
4 DEBUG:!0
`!1` is minified `false`, `!0` is `true`. Also checked the fail-safe path:
`AUTISTMASK_DEBUG=true make build` prints `Build mode: release (DEBUG off)` and
likewise yields `4 DEBUG:!1`.
One thing a reviewer should know about the grep: the `DEBUG_MNEMONIC` string
literal is still present in the release bundle. That is not a leak of anything
(the phrase is in this public repo already) and it does not mean the branch is
live — esbuild cannot tree-shake a CommonJS `module.exports` object, so the
constant survives while `DEBUG` folds to `false`. The compiled function is
`function PL(){return ML?UL:f_.fromEntropy(globalThis.crypto.getRandomValues(new Uint8Array(16))).phrase}`
where `ML` is the `DEBUG:!1` export. So "the phrase string is absent" is *not*
the right test for a release build; "the exported `DEBUG` is `!1`" is, which is
what I checked.
## `TODO.md` refresh
Per the manager comment: Status rewritten (no branch in flight —
`feat/issue-144-settings-about` landed as #145, scripts-to-rule-them-all landed
as #148, so the `scripts/` question is resolved; `make check` recorded as
verified green on `main` at `23aeae4`); the completed "Verify main passes make
check" Future Step removed; Future Steps rewritten against the #149-#168
backlog in rough priority order, keeping branch pruning (now #167) and the
pre-1.0 security review (noting #149 and #157 are parts of it but it is
broader).
One deliberate deviation to flag rather than bury: the manager asked that Next
Step become this issue. Taken literally against the Workflow section, this
commit *completes* #149, which would normally move it into Completed Steps. I
followed the repo's existing convention for in-flight work instead — the
previous Next Step was phrased as "Land feat/issue-144-settings-about", so Next
Step is now "Land #149 ... PR open, awaiting review", which is accurate until
this merges. Whoever merges should move it to Completed Steps and promote the
first Future Step. Happy to change it if the reviewer prefers the strict
reading.
## Out of scope
`script/lint` being `prettier --check` only and unable to catch undefined
identifiers (#152) — noted in the TODO but not fixed here; I greped for `DEBUG`
consumers by hand rather than relying on lint, as advised. Nothing else in the
DEBUG consumer set (`log.js`, `helpers.js`, `state.js`, `settings.js`) changed
behavior.
Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #169
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
## Summary
Add a new well at the bottom of the settings view displaying application info (license, author, version, build date, git commit hash linked to Gitea), and an easter egg that reveals a debug mode toggle after clicking the version number 10 times.
## Changes
- **`src/popup/index.html`**: Added About well with license, author, version, build date, and linked commit hash. Added hidden debug well with debug mode toggle.
- **`src/popup/views/settings.js`**: Populate About well from build-time constants. Implement version click counter (10 clicks reveals debug well). Wire up debug mode toggle to state and runtime logger.
- **`src/shared/buildInfo.js`** (new): Module exporting build-time constants (`BUILD_VERSION`, `BUILD_LICENSE`, `BUILD_AUTHOR`, `BUILD_COMMIT`, `BUILD_DATE`, `GITEA_COMMIT_URL`) injected by esbuild define.
- **`build.js`**: Read git commit hash (short + full) and version from package.json, pass as esbuild `define` constants. Also reads `GIT_COMMIT_SHORT`/`GIT_COMMIT_FULL` env vars for Docker builds.
- **`Dockerfile`**: Accept `GIT_COMMIT_SHORT` and `GIT_COMMIT_FULL` build args, set as env vars for the build step.
- **`Makefile`**: Pass git commit hashes as Docker build args.
- **`src/shared/state.js`**: Add `debugMode` boolean to state (persisted).
- **`src/shared/log.js`**: Add runtime debug flag that supplements the compile-time `DEBUG` constant. Debug mode toggle updates this flag immediately.
closes#144
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: clawbot <clawbot@sneak.cloud>
Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@eeqj.de>
Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #145
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
## Summary
Fixes the view stack pop bug where pressing Back in Settings (or any view) always returned to Main instead of the previous view.
Closes [issue #134](#134)
## Problem
The popup UI had no navigation stack. Every back button was hardcoded to a specific destination (usually Main). The reported path:
> Main → Address → Transaction → Settings (gear icon) → Back
...would go to Main instead of returning to the Transaction view.
## Solution
Implemented a proper view navigation stack (like iOS) as already described in the README:
- **`viewStack`** array added to persisted state — survives popup close/reopen
- **`pushCurrentView()`** — pushes the current view name onto the stack before any forward navigation
- **`goBack()`** — pops the stack and shows the previous view; falls back to Main if the stack is empty; re-renders the wallet list when returning to Main
- **`clearViewStack()`** — resets the stack for root transitions (e.g., after adding/deleting a wallet)
### What Changed
1. **helpers.js** — Added navigation stack functions (`pushCurrentView`, `goBack`, `clearViewStack`, `setRenderMain`)
2. **state.js** — Added `viewStack` to persisted state
3. **index.js** — All `ctx.show*()` wrappers now push before navigating forward; gear button uses stack for toggle behavior
4. **All view back buttons** — Replaced hardcoded destinations with `goBack()` (settings, addressDetail, addressToken, transactionDetail, send, receive, addToken, confirmTx, addWallet, settingsAddToken, deleteWallet, export-privkey)
5. **Direct `showView()` forward navigations** — Added `pushCurrentView()` calls before `showView("send")` in addressDetail, addressToken, and home; before `showView("export-privkey")` in addressDetail; before `deleteWallet.show()` in settings
6. **Reset-to-root transitions** — `clearViewStack()` called after adding a wallet (all 3 import types), after deleting the last wallet, and after transaction completion (Done button)
### Navigation Paths Verified
- **Main → Settings → Back** → returns to Main ✓
- **Main → Address → Settings → Back** → returns to Address ✓
- **Main → Address → Transaction → Settings → Back** → returns to Transaction ✓ (the reported bug)
- **Main → Address → Token → Send → ConfirmTx → Back → Back → Back → Back** → unwinds correctly through each view back to Main ✓
- **Main → Address → Token → Transaction → Settings → Back** → returns to Transaction ✓
- **Settings → Add Wallet → (add) → Main** → stack cleared, fresh root ✓
- **Settings → Delete Wallet → Back** → returns to Settings ✓
- **Settings → Delete Wallet → (confirm)** → stack reset to [main], settings shown ✓
- **Address → Send → ConfirmTx → (broadcast) → SuccessTx → Done** → stack reset, returns to address context ✓
- **Popup close/reopen** → viewStack persisted, back navigation still works ✓
Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #146
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>