Compare commits

..

1 Commits

Author SHA1 Message Date
2f69ad0361 feat: show debug banner on testnet or debug mode, add TESTNET tag
All checks were successful
check / check (push) Successful in 13s
Display the red debug banner when on a testnet OR when DEBUG is enabled.
When on a testnet, a 'TESTNET' label is shown on the far right side of
the banner. The banner label shows the network name when not in debug
mode, and 'DEBUG / INSECURE' when debug is on.

closes #140
2026-03-01 11:25:18 -08:00
41 changed files with 503 additions and 3362 deletions

View File

@@ -6,4 +6,4 @@ jobs:
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/cibuild - run: docker build .

View File

@@ -1,15 +1,13 @@
# node:22-slim (22.x LTS), 2026-02-24 # node:22-slim (22.x LTS), 2026-02-24
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare yarn@1.22.22 --activate
WORKDIR /app WORKDIR /app
# script/bootstrap installs all prerequisites (make via apt here; node
# is already in the base image, yarn comes via corepack) and runs
# yarn install --frozen-lockfile. Dependency manifests are copied first
# so the bootstrap layer is cached until they change.
COPY script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN script/bootstrap RUN yarn install --frozen-lockfile
COPY . . COPY . .

View File

@@ -1,38 +1,25 @@
.PHONY: bootstrap setup install test lint fmt fmt-check check docker hooks build clean dev .PHONY: install test lint fmt fmt-check check docker hooks build clean dev
# Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
# of README.md).
bootstrap:
@script/bootstrap
setup:
@script/setup
install: install:
@yarn install @yarn install
test: test:
@script/test @echo "Running tests..."
@timeout 30 yarn run test 2>&1
lint: lint:
@script/lint @echo "Linting..."
@yarn run lint 2>&1
fmt: fmt:
@script/fmt @echo "Formatting..."
@yarn run fmt 2>&1
fmt-check: fmt-check:
@script/fmt-check @echo "Checking formatting..."
@yarn run fmt-check 2>&1
check: check: test lint fmt-check
@script/check
docker:
@script/docker
hooks:
@script/install-precommit
build: build:
@echo "Building extension..." @echo "Building extension..."
@@ -44,3 +31,15 @@ clean:
dev: dev:
@echo "Building in watch mode..." @echo "Building in watch mode..."
@yarn run build --watch 2>&1 @yarn run build --watch 2>&1
docker:
@docker build -t autistmask .
hooks:
@echo "Installing pre-commit hook..."
@mkdir -p .git/hooks
@echo '#!/usr/bin/env bash' > .git/hooks/pre-commit
@echo 'set -euo pipefail' >> .git/hooks/pre-commit
@echo 'make check' >> .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "Pre-commit hook installed."

View File

@@ -42,29 +42,6 @@ Load the extension:
- **Firefox**: Navigate to `about:debugging#/runtime/this-firefox`, click "Load - **Firefox**: Navigate to `about:debugging#/runtime/this-firefox`, click "Load
Temporary Add-on", and select `dist/firefox/manifest.json`. Temporary Add-on", and select `dist/firefox/manifest.json`.
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them. We
provide:
- `script/bootstrap` — install all dependencies (pinned node via nvm if needed,
yarn via corepack, `yarn install --frozen-lockfile`)
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
git pre-commit hook
- `script/projectname` — print the project name (used for the Docker image tag)
- `script/test` — run the test suite (jest)
- `script/lint` — run the linter
- `script/fmt` — format all files (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .`
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
- `script/install-precommit` — install the git pre-commit hook
## Rationale ## Rationale
Common popular EVM wallets have become bloated with swap UIs, portfolio Common popular EVM wallets have become bloated with swap UIs, portfolio

View File

@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-07-06 last_modified: 2026-02-22
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -34,46 +34,10 @@ style conventions are in separate documents:
every file before committing. There are zero exceptions to this rule. every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets: - Every repo with software must have a root `Makefile` with these targets:
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes), `make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`), `make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile `make hooks` (installs pre-commit hook). A model Makefile is at
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.) - Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single instead of invoking the underlying tools directly. The Makefile is the single
@@ -93,83 +57,11 @@ style conventions are in separate documents:
as a build step so the build fails if the branch is not green. For non-server as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build `make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. Dockerfiles install development stage before the final image is assembled.
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs `docker build .`) on push. Since the runs `docker build .` on push. Since the Dockerfile already runs `make check`,
Dockerfile already runs `make check`, a successful build implies all checks a successful build implies all checks pass.
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -177,11 +69,9 @@ style conventions are in separate documents:
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown, Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`. HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local - Pre-commit hook: `make check` if local testing is possible, otherwise
testing is not possible in the repo, `script/precommit` may skip `script/test` `make lint && make fmt-check`. The Makefile should provide a `make hooks`
and run only `script/lint` and `script/fmt-check`. The hook is installed by target to install the pre-commit hook.
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard - All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
@@ -192,42 +82,6 @@ style conventions are in separate documents:
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the - `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile. Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes. - Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary - `make check` must not modify any files in the repo. Tests may use temporary
@@ -244,13 +98,6 @@ style conventions are in separate documents:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo. a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name. - Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`. - Never force-push to `main`.
@@ -274,76 +121,12 @@ style conventions are in separate documents:
- Dockerized web services listen on port 8080 by default, overridable with - Dockerized web services listen on port 8080 by default, overridable with
`PORT`. `PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections: - `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose, - **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example: category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives "µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime." git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block. - **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist? - **Rationale**: Why does this exist?
- **Design**: How is the program structured? - **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put - **TODO**: Update meticulously, even between commits. When planning, put
@@ -361,14 +144,8 @@ style conventions are in separate documents:
- Use SemVer. - Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in - Database migrations live in `internal/db/migrations/` and must be embedded in
the binary. the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
- `000_migration.sql` — contains ONLY the creation of the migrations Post-1.0.0: add new migration files.
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation - All repos should have an `.editorconfig` enforcing the project's indentation
settings. settings.
@@ -398,9 +175,6 @@ style conventions are in separate documents:
- `README.md`, `.git`, `.gitignore`, `.editorconfig` - `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo) - `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile` - `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore` - `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml` - `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`

56
TODO.md
View File

@@ -1,56 +0,0 @@
# Workflow
- branch (from `main`)
- do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work)
- merge to `main` if the branch is not protected, otherwise open a PR
- push
# Status
pre-1.0. Tagged v0.1.0 on 2026-02-27. Active development on branch
feat/issue-144-settings-about (another agent working as of 2026-07-06). Full
policy file set present; make check on main not verified.
# Next Step
Land feat/issue-144-settings-about: finish the settings About well (build info,
app name and repo link, release date, version click easter egg, git info derived
inside Docker), resolve the untracked scripts/ directory (commit or gitignore),
get review, merge to main.
# Completed Steps
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section
- 2026-03-01: About well in settings with build info and easter egg (in flight
on feature branch); USD display suppressed on testnets (#142); estimated USD
for ETH in approve-tx view (#141).
- Sepolia testnet support (#137); etherscan links go to token-specific URLs
(#136).
- Transaction detail improvements: Type field and on-chain details (#130),
txid-first reordering (#133), swap display corrections (#128), expanded
confirm-tx warnings (#118).
- Dark mode theme setting (Light/Dark/System) with contrast fixes (#126);
timestamps include timezone offset (#120); layout shift audit, reserved space
for error messages (#124).
- Copy-flash visual feedback with timing tune (#113, #121); cross-wallet-type
duplicate detection (#115).
- 2026-02-27: v0.1.0 tagged.
- 2026-02-24: Initial scaffolding: popup UI, BIP-39 wallet creation via
ethers.js, wallet persistence, real ETH balances over RPC, ENS forward and
reverse resolution.
# Future Steps
- Verify main passes make check after the feature branch merges (not verified
2026-07-06 because an agent was active in the tree); fix anything red. main
must always be green.
- Prune stale branches: dozens of merged local and remote feature branches
remain (fix/_, feature/_, tx-\*); delete merged ones locally and on origin.
- Continue the issue backlog toward a feature-complete wallet, then cut further
tags as milestones land.
- Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
input validation) before any 1.0rc tag.

View File

@@ -1,143 +0,0 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present. Node is
# used directly if installed; otherwise it is installed at a pinned
# version via nvm (installing nvm itself first, from a hash-verified
# release archive, never curl | sh).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-07
NODE_VERSION="22.17.0"
NVM_VERSION="0.40.3"
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
YARN_VERSION="1.22.22"
PKGMGR=""
SUDO=""
APT_UPDATED=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt)
if [ -z "$APT_UPDATED" ]; then
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
APT_UPDATED=1
fi
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2"
;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# nvm is a bash script; run a command in a bash with nvm loaded
nvm_sh() {
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
}
ensure_nvm() {
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
# nvm prerequisites; nvm itself requires bash
if missing bash; then pkg_install bash bash bash bash; fi
if missing curl; then pkg_install curl curl curl curl; fi
if missing git; then pkg_install git git git git; fi
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/nvm.tar.gz" \
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
mkdir -p "$HOME/.nvm"
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
rm -rf "$tmp"
}
ensure_node() {
if ! missing node; then return 0; fi
ensure_nvm
nvm_sh "nvm install $NODE_VERSION"
}
ensure_yarn() {
if ! missing yarn; then return 0; fi
if ! missing corepack; then
corepack enable
corepack prepare "yarn@$YARN_VERSION" --activate
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
corepack prepare yarn@$YARN_VERSION --activate"
else
npm install -g "yarn@$YARN_VERSION"
fi
}
install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
yarn install --frozen-lockfile"
else
yarn install --frozen-lockfile
fi
}
main() {
cd "$ROOT"
if missing make; then pkg_install gnumake make make make; fi
if missing git; then pkg_install git git git git; fi
ensure_node
ensure_yarn
install_js_deps
echo "bootstrap complete"
}
main "$@"

View File

@@ -1,14 +0,0 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"

View File

@@ -1,13 +0,0 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs make check, so
# a successful build implies all checks pass.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build .
}
main "$@"

View File

@@ -1,14 +0,0 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"

View File

@@ -1,13 +0,0 @@
#!/bin/sh
# script/fmt: format all files (writes).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Formatting..."
yarn run fmt 2>&1
}
main "$@"

View File

@@ -1,14 +0,0 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only). Same scope as
# script/fmt, but fails instead of writing.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Checking formatting..."
yarn run fmt-check 2>&1
}
main "$@"

View File

@@ -1,16 +0,0 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook"
chmod +x "$hook"
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"

View File

@@ -1,13 +0,0 @@
#!/bin/sh
# script/lint: run the linter.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Linting..."
yarn run lint 2>&1
}
main "$@"

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/check"
}
main "$@"

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "autistmask"
}
main "$@"

View File

@@ -1,13 +0,0 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies and the git pre-commit hook.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"

View File

@@ -1,13 +0,0 @@
#!/bin/sh
# script/test: run the test suite.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Running tests..."
timeout 30 yarn run test 2>&1
}
main "$@"

View File

@@ -1,161 +0,0 @@
#!/usr/bin/env node
// Apply enrichment data to tokenList.js: add name + url fields to each token.
import { readFileSync, writeFileSync } from "fs";
const enrichment = JSON.parse(
readFileSync("scripts/token-enrichment.json", "utf8"),
);
// Well-known URLs for major tokens not in ethereum-lists.
// These are official project websites verified from public sources.
const EXTRA_URLS = {
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": "https://weth.io",
"0xdAC17F958D2ee523a2206206994597C13D831ec7": "https://tether.to",
"0xB8c77482e45F1F44dE1745F52C74426C631bDD52": "https://www.bnbchain.org",
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": "https://www.circle.com/usdc",
"0xdC035D45d973E3EC169d2276DDab16f1e407384F": "https://sky.money",
"0x2AF5D2aD76741191D15Dfe7bF6aC92d4Bd912Ca3": "https://www.bitfinex.com",
"0x514910771AF9Ca656af840dff83E8264EcF986CA": "https://chain.link",
"0x4c9EDD5852cd905f086C759E8383e09bff1E68B3": "https://ethena.fi",
"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d":
"https://www.worldlibertyfinancial.com",
"0x6B175474E89094C44Da98b954EedeAC495271d0F": "https://makerdao.com",
"0x6c3ea9036406852006290770BEdFcAbA0e23A0e8":
"https://www.paypal.com/us/digital-wallet/manage-money/crypto/pyusd",
"0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE": "https://shibatoken.com",
"0xdA5e1988097297dCdc1f90D4dFE7909e847CBeF6":
"https://www.worldlibertyfinancial.com",
"0xA0b73E1Ff0B80914AB6fe0444E65848C4C34450b": "https://crypto.com",
"0x582d872A1B094FC48F5DE31D3B73F2D9bE47def1": "https://ton.org",
"0x68749665FF8D2d112Fa859AA293F07A622782F38": "https://gold.tether.to",
"0x196C20DA81Fbc324EcdF55501e95Ce9f0bD84d14": "https://polkadot.network",
"0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984": "https://uniswap.org",
"0x45804880De22913dAFE09f4980848ECE6EcbAf78":
"https://www.paxos.com/paxgold",
"0x3c3a81e81dc49A522A592e7622A7E711c06bf354": "https://www.mantle.xyz",
"0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9": "https://aave.com",
"0x75231F58b43240C9718Dd58B4967c5114342a86c": "https://www.okx.com",
"0x6982508145454Ce325dDbE47a25d4ec3d2311933": "https://www.pepe.vip",
"0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599": "https://wbtc.network",
"0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84": "https://lido.fi",
"0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32": "https://lido.fi",
"0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72": "https://ens.domains",
"0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0": "https://polygon.technology",
"0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2": "https://makerdao.com",
"0xD533a949740bb3306d119CC777fa900bA034cd52": "https://curve.fi",
"0x111111111117dC0aa78b770fA6A738034120C302": "https://1inch.io",
"0x163f8C2467924be0ae7B5347228CABF260318753": "https://worldcoin.org",
"0xba100000625a3754423978a60c9317c58a424e3D": "https://balancer.fi",
"0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F": "https://synthetix.io",
"0x1a7e4e63778B4f12a199C062f3eFdD288afCBce8": "https://www.euler.finance",
"0xBBbbCA6A901c926F240b89EacB641d8Aec7AEafD": "https://loopring.org",
"0xC669928185DbCE49d2230CC9B0979BE6DC797957": "https://bitstable.finance",
"0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0": "https://lido.fi",
"0xBe9895146f7AF43049ca1c1AE358B0541Ea49704":
"https://www.coinbase.com/cbeth",
"0xfAbA6f8e4a5E8Ab82F62fe7C39859FA577269BE3": "https://ondo.finance",
"0x0C10bF8FcB7Bf5412187A595ab97a3609160b5c6": "https://decentraland.org",
"0x6De037ef9aD2725EB40118Bb1702EBb27e4Aeb24": "https://www.render.com",
"0x4a220E6096B25EADb88358cb44068A3248254675": "https://www.quant.network",
"0xba5BDe662c17e2aDFF1075610382B9B691296350": "https://superrare.com",
"0x15D4c048F83bd7e37d49eA4C83a07267Ec4203dA": "https://galxe.com",
"0xc00e94Cb662C3520282E6f5717214004A7f26888": "https://compound.finance",
"0xE41d2489571d322189246DaFA5ebDe1F4699F498": "https://0x.org",
"0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e": "https://yearn.fi",
"0x6fB3e0A217407EFFf7Ca062D46c26E5d60a14d69": "https://iotex.io",
"0xD31a59c85aE9D8edEFec411D448f90841571b89c": "https://solana.com",
"0xa1faa113cbE53436Df28FF0aEe54275c13B40975": "https://alphafinance.io",
"0xF57e7e7C23978C3cAEC3C3548E3D615c346e79fF": "https://immutable.com",
"0x0f5D2fB29fb7d3CFeE444a200298f468908cC942": "https://decentraland.org",
"0x3845badAde8e6dFF049820680d1F14bD3903a5d0": "https://www.sandbox.game",
"0x4d224452801ACEd8B2F0aebE155379bb5D594381": "https://apecoin.com",
"0x6810e776880C02933D47DB1b9fc05908e5386b96": "https://gnosis.io",
"0x5f98805A4E8be255a32880FDeC7F6728C6568bA0": "https://www.liquity.org",
"0xd26114cd6EE289AccF82350c8d8487fedB8A0C07": "https://omg.network",
"0x0D8775F648430679A709E98d2b0Cb6250d2887EF":
"https://basicattentiontoken.org",
"0xc944E90C64B2c07662A292be6244BDf05Cda44a7": "https://thegraph.com",
"0x1985365e9f78359a9B6AD760e32412f4a445E862": "https://augur.net",
"0x4E15361FD6b4BB609Fa63C81A2be19d873717870": "https://fantom.foundation",
"0x853d955aCEf822Db058eb8505911ED77F175b99e": "https://frax.finance",
"0x0000000000085d4780B73119b644AE5ecd22b376": "https://www.trueusd.com",
"0x0cEC1A9154Ff802e7934Fc916Ed7Ca50bDE6844e": "https://polymesh.network",
"0x956F47F50A910163D8BF957Cf5846D573E7f87CA": "https://fei.money",
"0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0": "https://frax.finance",
"0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E": "https://curve.fi",
"0x0100546F2cD4C9D97f798fFC9755E47865FF7Ee6": "https://alchemix.fi",
"0x808507121B80c02388fAd14726482e061B8da827": "https://pendle.finance",
"0xCa14007Eff0dB1f8135f4C25B34De49AB0d42766": "https://starknet.io",
"0x8457CA5040ad67fdebbCC8EdCE889A335Bc0fbFB": "https://alt.layer",
"0x4C19596f5aAfF459fA38B0f7eD92F11AE6543784": "https://www.truflation.com",
"0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1": "https://arbitrum.io",
"0x912CE59144191C1204E64559FE8253a0e49E6548": "https://arbitrum.io",
"0x6985884C4392D348587B19cb9eAAf157F13271cd": "https://layerzero.network",
"0xFe0c30065B384F05761f15d0CC899D4F9F9Cc0eB": "https://ethfi.org",
"0x18084fbA666a33d37592fA2633fD49a74DD93a88": "https://tbtc.network",
"0x9D39A5DE30e57443BfF2A8307A4256c8797A3497": "https://suilend.fi",
"0x5283D291DBCF85356A21bA090E6db59121208b44": "https://blur.io",
"0x43Dfc4159D86F3A37A5A4B3D4580b888ad7d4DDd": "https://dydx.exchange",
"0xCf0C122c6b73ff809C693DB761e7BaeBe62b6a2E": "https://floki.com",
"0xe53EC727dbDEB9E2d5456c3be40cFF031AB40A55": "https://superfarm.com",
"0x32353A6C91143bfd6C7d363B546e62a9A2489A20": "https://www.agora.finance",
"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c": "https://www.euro-coin.com",
"0x1a03eF3d1a80b5f214e468ACD97F55714E049393": "https://gemspad.io",
"0x7122985656e38BDC0302Db86685bb972b145bD3C": "https://stone.fi",
"0xa35923162C49cF95e6BF26623385eb431ad920D3": "https://www.turbo-eth.com",
"0xdBdb4d16EdA451D0503b854CF79D55697F90c8DF": "https://alchemix.fi",
"0x08c32b0726C5684024ea6e141C50aDe9690bBdcc": "https://stratis.io",
"0x65Ef703f5594D2573eb71Aaf55BC0CB548492df4": "https://multichain.org",
"0x1151CB3d861920e07a38e03eEAd12C32178567F6": "https://bonk.com",
"0x090185f2135308BaD17527004364eBcC2D37e5F6": "https://spell.club",
"0xAf5191B0De278C7286d6C7CC6ab6BB8A73bA2Cd6": "https://stargate.finance",
};
// Apply extra URLs where enrichment has none
for (const [addr, url] of Object.entries(EXTRA_URLS)) {
const e = enrichment[addr];
if (e && !e.url) {
e.url = url;
}
}
// Read original tokenList.js
const original = readFileSync("src/shared/tokenList.js", "utf8");
// Replace each token block: add name and url after decimals
let result = original;
let replacements = 0;
for (const [addr, data] of Object.entries(enrichment)) {
// Escape special regex characters in the address
const escaped = addr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Match the token block: { address: "...", symbol: "...", decimals: N, }
// The closing }, may have trailing whitespace
const blockRe = new RegExp(
`(\\{\\s*address:\\s*"${escaped}",\\s*symbol:\\s*"[^"]+",\\s*decimals:\\s*\\d+),?(\\s*\\})`,
);
const match = result.match(blockRe);
if (!match) {
console.error(`WARNING: Could not find block for ${addr}`);
continue;
}
// Build replacement: original fields + name + url
const nameEscaped = data.name.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const urlEscaped = data.url.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const replacement =
match[1] +
`,\n name: "${nameEscaped}",` +
`\n url: "${urlEscaped}",` +
match[2];
result = result.replace(blockRe, replacement);
replacements++;
}
console.error(`Applied ${replacements} enrichments to tokenList.js`);
writeFileSync("src/shared/tokenList.js", result);
console.error("Done!");

View File

@@ -1,87 +0,0 @@
#!/usr/bin/env node
// Temporary script: fetch name + homepage URL for every token in tokenList.js
// Phase 1: CoinGecko /coins/list (1 bulk call) for names — gets 100%
// Phase 2: ethereum-lists GitHub repo for project URLs — gets ~15%
import { readFileSync, writeFileSync } from "fs";
const src = readFileSync("src/shared/tokenList.js", "utf8");
const tokens = [];
const re =
/\{\s*address:\s*"(0x[a-fA-F0-9]{40})",\s*symbol:\s*"([^"]+)",\s*decimals:\s*(\d+)/g;
let m;
while ((m = re.exec(src)) !== null) {
tokens.push({ address: m[1], symbol: m[2], decimals: parseInt(m[3]) });
}
console.error(`Parsed ${tokens.length} tokens`);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// --- Phase 1: bulk names from CoinGecko ---
console.error("Fetching CoinGecko coins list...");
const listResp = await fetch(
"https://api.coingecko.com/api/v3/coins/list?include_platform=true",
);
if (!listResp.ok) throw new Error(`CoinGecko list HTTP ${listResp.status}`);
const coins = await listResp.json();
const addrMap = new Map();
for (const coin of coins) {
const eth = (coin.platforms?.ethereum || "").toLowerCase().trim();
if (eth && eth.startsWith("0x")) {
addrMap.set(eth, { name: coin.name, id: coin.id });
}
}
console.error(`CoinGecko map: ${addrMap.size} Ethereum tokens`);
const enriched = new Map();
for (const t of tokens) {
const info = addrMap.get(t.address.toLowerCase());
enriched.set(t.address, { name: info?.name || t.symbol, url: "" });
}
console.error(
`Matched ${[...enriched.values()].filter((e) => e.name).length}/${tokens.length} names`,
);
// --- Phase 2: ethereum-lists GitHub for URLs ---
console.error("Fetching URLs from ethereum-lists GitHub...");
const CONCURRENT = 10;
let urlsFound = 0;
for (let i = 0; i < tokens.length; i += CONCURRENT) {
const batch = tokens.slice(i, i + CONCURRENT);
await Promise.allSettled(
batch.map(async (t) => {
try {
const r = await fetch(
`https://raw.githubusercontent.com/ethereum-lists/tokens/master/tokens/eth/${t.address}.json`,
);
if (!r.ok) return;
const data = await r.json();
const website = (data.website || "").trim();
if (website && website !== "https://") {
enriched.get(t.address).url = website;
urlsFound++;
}
} catch {
// ignore
}
}),
);
if (i % 100 === 0) {
console.error(
` GitHub: ${Math.min(i + CONCURRENT, tokens.length)}/${tokens.length}, ${urlsFound} URLs`,
);
}
await sleep(200);
}
console.error(`Phase 2 done: ${urlsFound} URLs from ethereum-lists`);
// --- Output ---
const output = {};
for (const [addr, e] of enriched) {
output[addr] = { name: e.name, url: e.url };
}
writeFileSync("scripts/token-enrichment.json", JSON.stringify(output, null, 2));
console.error(`Done! ${tokens.length} names, ${urlsFound} URLs.`);

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,6 @@
const { DEFAULT_RPC_URL } = require("../shared/constants"); const { DEFAULT_RPC_URL } = require("../shared/constants");
const { SUPPORTED_CHAIN_IDS, networkByChainId } = require("../shared/networks"); const { SUPPORTED_CHAIN_IDS, networkByChainId } = require("../shared/networks");
const { onChainSwitch } = require("../shared/chainSwitch");
const { getBytes } = require("ethers"); const { getBytes } = require("ethers");
const { const {
state, state,
@@ -346,8 +345,12 @@ async function handleRpc(method, params, origin) {
return { result: null }; return { result: null };
} }
if (SUPPORTED_CHAIN_IDS.has(chainId)) { if (SUPPORTED_CHAIN_IDS.has(chainId)) {
// Switch to the requested network
const target = networkByChainId(chainId); const target = networkByChainId(chainId);
await onChainSwitch(target.id); state.networkId = target.id;
state.rpcUrl = target.defaultRpcUrl;
state.blockscoutUrl = target.defaultBlockscoutUrl;
await saveState();
broadcastChainChanged(target.chainId); broadcastChainChanged(target.chainId);
return { result: null }; return { result: null };
} }

View File

@@ -10,14 +10,7 @@ const {
} = require("../shared/state"); } = require("../shared/state");
const { refreshPrices } = require("../shared/prices"); const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances"); const { refreshBalances } = require("../shared/balances");
const { const { $, showView } = require("./views/helpers");
$,
showView,
setRenderMain,
pushCurrentView,
goBack,
clearViewStack,
} = require("./views/helpers");
const { applyTheme } = require("./theme"); const { applyTheme } = require("./theme");
const home = require("./views/home"); const home = require("./views/home");
@@ -65,42 +58,15 @@ async function doRefreshAndRender() {
const ctx = { const ctx = {
renderWalletList, renderWalletList,
doRefreshAndRender, doRefreshAndRender,
showAddWalletView: () => { showAddWalletView: () => addWallet.show(),
pushCurrentView(); showAddressDetail: () => addressDetail.show(),
addWallet.show(); showAddressToken: () => addressToken.show(),
}, showAddTokenView: () => addToken.show(),
showAddressDetail: () => { showConfirmTx: (txInfo) => confirmTx.show(txInfo),
pushCurrentView(); showReceive: () => receive.show(),
addressDetail.show(); showTransactionDetail: (tx) => transactionDetail.show(tx),
}, showSettingsView: () => settings.show(),
showAddressToken: () => { showSettingsAddTokenView: () => settingsAddToken.show(),
pushCurrentView();
addressToken.show();
},
showAddTokenView: () => {
pushCurrentView();
addToken.show();
},
showConfirmTx: (txInfo) => {
pushCurrentView();
confirmTx.show(txInfo);
},
showReceive: () => {
pushCurrentView();
receive.show();
},
showTransactionDetail: (tx) => {
pushCurrentView();
transactionDetail.show(tx);
},
showSettingsView: () => {
pushCurrentView();
settings.show();
},
showSettingsAddTokenView: () => {
pushCurrentView();
settingsAddToken.show();
},
}; };
// Views that can be fully re-rendered from persisted state. // Views that can be fully re-rendered from persisted state.
@@ -206,9 +172,6 @@ function fallbackView() {
} }
async function init() { async function init() {
await loadState();
applyTheme(state.theme);
const net = currentNetwork(); const net = currentNetwork();
if (DEBUG || net.isTestnet) { if (DEBUG || net.isTestnet) {
const banner = document.createElement("div"); const banner = document.createElement("div");
@@ -225,6 +188,9 @@ async function init() {
document.body.prepend(banner); document.body.prepend(banner);
} }
await loadState();
applyTheme(state.theme);
// Auto-default active address // Auto-default active address
if ( if (
state.activeAddress === null && state.activeAddress === null &&
@@ -254,15 +220,13 @@ async function init() {
.getElementById("view-settings") .getElementById("view-settings")
.classList.contains("hidden") .classList.contains("hidden")
) { ) {
goBack(); renderWalletList();
showView("main");
return; return;
} }
pushCurrentView();
settings.show(); settings.show();
}); });
setRenderMain(renderWalletList);
welcome.init(ctx); welcome.init(ctx);
addWallet.init(ctx); addWallet.init(ctx);
home.init(ctx); home.init(ctx);

View File

@@ -1,4 +1,4 @@
const { $, showFlash, goBack } = require("./helpers"); const { $, showView, showFlash } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
@@ -59,12 +59,7 @@ function init(ctx) {
}); });
await saveState(); await saveState();
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
// Pop the stack (back to address detail) and re-render it ctx.showAddressDetail();
// so the newly added token is visible immediately.
if (state.viewStack.length > 0) {
state.viewStack.pop();
}
require("./addressDetail").show();
} catch (e) { } catch (e) {
const detail = e.shortMessage || e.message || String(e); const detail = e.shortMessage || e.message || String(e);
log.errorf("Token lookup failed for", contractAddr, detail); log.errorf("Token lookup failed for", contractAddr, detail);
@@ -74,9 +69,7 @@ function init(ctx) {
} }
}); });
$("btn-add-token-back").addEventListener("click", () => { $("btn-add-token-back").addEventListener("click", ctx.showAddressDetail);
goBack();
});
} }
module.exports = { init, show }; module.exports = { init, show };

View File

@@ -1,4 +1,4 @@
const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers"); const { $, showView, showFlash } = require("./helpers");
const { const {
generateMnemonic, generateMnemonic,
hdWalletFromMnemonic, hdWalletFromMnemonic,
@@ -143,7 +143,6 @@ async function importMnemonic(ctx) {
state.wallets.push(wallet); state.wallets.push(wallet);
state.hasWallet = true; state.hasWallet = true;
await saveState(); await saveState();
clearViewStack();
ctx.renderWalletList(); ctx.renderWalletList();
showView("main"); showView("main");
@@ -199,7 +198,6 @@ async function importPrivateKey(ctx) {
}); });
state.hasWallet = true; state.hasWallet = true;
await saveState(); await saveState();
clearViewStack();
ctx.renderWalletList(); ctx.renderWalletList();
showView("main"); showView("main");
@@ -251,7 +249,6 @@ async function importXprvKey(ctx) {
state.wallets.push(wallet); state.wallets.push(wallet);
state.hasWallet = true; state.hasWallet = true;
await saveState(); await saveState();
clearViewStack();
ctx.renderWalletList(); ctx.renderWalletList();
showView("main"); showView("main");
@@ -300,7 +297,12 @@ function init(ctx) {
// Back button // Back button
$("btn-add-wallet-back").addEventListener("click", () => { $("btn-add-wallet-back").addEventListener("click", () => {
goBack(); if (!state.hasWallet) {
showView("welcome");
} else {
ctx.renderWalletList();
showView("main");
}
}); });
} }

View File

@@ -8,12 +8,13 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
truncateMiddle, truncateMiddle,
renderAddressHtml,
attachCopyHandlers,
goBack,
pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state"); const {
state,
currentAddress,
saveState,
currentNetwork,
} = require("../../shared/state");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices"); const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
const { const {
fetchRecentTransactions, fetchRecentTransactions,
@@ -32,6 +33,17 @@ const { getSignerForAddress } = require("../../shared/wallet");
let ctx; let ctx;
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
function etherscanAddressLink(address) {
return `${currentNetwork().explorerUrl}/address/${address}`;
}
function show() { function show() {
state.selectedToken = null; state.selectedToken = null;
const wallet = state.wallets[state.selectedWallet]; const wallet = state.wallets[state.selectedWallet];
@@ -49,18 +61,22 @@ function show() {
img.style.imageRendering = "pixelated"; img.style.imageRendering = "pixelated";
img.style.borderRadius = "50%"; img.style.borderRadius = "50%";
blockieEl.appendChild(img); blockieEl.appendChild(img);
const addrTitle = addressTitle(addr.address, state.wallets); $("address-dot").innerHTML = addressDotHtml(addr.address);
$("address-line").innerHTML = renderAddressHtml(addr.address, { $("address-full").dataset.full = addr.address;
title: addrTitle, $("address-full").textContent = addr.address;
ensName: addr.ensName, const addrLink = etherscanAddressLink(addr.address);
}); $("address-etherscan-link").innerHTML =
$("address-line").dataset.full = addr.address; `<a href="${addrLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
attachCopyHandlers($("address-line"));
const usdTotal = formatUsd(getAddressValueUsd(addr)); const usdTotal = formatUsd(getAddressValueUsd(addr));
$("address-usd-total").innerHTML = usdTotal || "&nbsp;"; $("address-usd-total").innerHTML = usdTotal || "&nbsp;";
const ensEl = $("address-ens"); const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element if (addr.ensName) {
ensEl.classList.add("hidden"); ensEl.innerHTML =
addressDotHtml(addr.address) + escapeHtml(addr.ensName);
ensEl.classList.remove("hidden");
} else {
ensEl.classList.add("hidden");
}
$("address-balances").innerHTML = balanceLinesForAddress( $("address-balances").innerHTML = balanceLinesForAddress(
addr, addr,
state.trackedTokens, state.trackedTokens,
@@ -247,9 +263,18 @@ function renderTransactions(txs) {
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
$("address-full").addEventListener("click", () => {
const addr = $("address-full").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback($("address-full"));
}
});
$("btn-address-back").addEventListener("click", () => { $("btn-address-back").addEventListener("click", () => {
goBack(); ctx.renderWalletList();
showView("main");
}); });
$("btn-send").addEventListener("click", () => { $("btn-send").addEventListener("click", () => {
@@ -267,7 +292,6 @@ function init(_ctx) {
$("send-token-static").classList.add("hidden"); $("send-token-static").classList.add("hidden");
updateSendBalance(); updateSendBalance();
resetSendValidation(); resetSendValidation();
pushCurrentView();
showView("send"); showView("send");
}); });
@@ -297,7 +321,6 @@ function init(_ctx) {
$("btn-export-privkey").addEventListener("click", () => { $("btn-export-privkey").addEventListener("click", () => {
moreDropdown.classList.add("hidden"); moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg"); moreBtn.classList.remove("bg-fg", "text-bg");
pushCurrentView();
const wallet = state.wallets[state.selectedWallet]; const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress]; const addr = wallet.addresses[state.selectedAddress];
const blockieEl = $("export-privkey-jazzicon"); const blockieEl = $("export-privkey-jazzicon");
@@ -311,9 +334,9 @@ function init(_ctx) {
blockieEl.appendChild(bImg); blockieEl.appendChild(bImg);
$("export-privkey-title").textContent = $("export-privkey-title").textContent =
wallet.name + " \u2014 Address " + (state.selectedAddress + 1); wallet.name + " \u2014 Address " + (state.selectedAddress + 1);
const exportAddrContainer = $("export-privkey-dot").parentElement; $("export-privkey-dot").innerHTML = addressDotHtml(addr.address);
exportAddrContainer.innerHTML = renderAddressHtml(addr.address); $("export-privkey-address").textContent = addr.address;
attachCopyHandlers(exportAddrContainer); $("export-privkey-address").dataset.full = addr.address;
$("export-privkey-password").value = ""; $("export-privkey-password").value = "";
$("export-privkey-flash").textContent = ""; $("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden"; $("export-privkey-flash").style.visibility = "hidden";
@@ -367,10 +390,19 @@ function init(_ctx) {
} }
}); });
$("export-privkey-address").addEventListener("click", () => {
const full = $("export-privkey-address").dataset.full;
if (full) {
navigator.clipboard.writeText(full);
showFlash("Copied!");
flashCopyFeedback($("export-privkey-address"));
}
});
$("btn-export-privkey-back").addEventListener("click", () => { $("btn-export-privkey-back").addEventListener("click", () => {
$("export-privkey-value").textContent = ""; $("export-privkey-value").textContent = "";
$("export-privkey-password").value = ""; $("export-privkey-password").value = "";
goBack(); show();
}); });
} }

View File

@@ -11,12 +11,13 @@ const {
escapeHtml, escapeHtml,
truncateMiddle, truncateMiddle,
balanceLine, balanceLine,
renderAddressHtml,
attachCopyHandlers,
goBack,
pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state"); const {
state,
currentAddress,
saveState,
currentNetwork,
} = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { const {
formatUsd, formatUsd,
@@ -38,6 +39,21 @@ const makeBlockie = require("ethereum-blockies-base64");
let ctx; let ctx;
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
function etherscanAddressLink(address) {
return `${currentNetwork().explorerUrl}/address/${address}`;
}
function etherscanTokenLink(tokenContract, holderAddress) {
return `https://etherscan.io/token/${tokenContract}?a=${holderAddress}`;
}
function isoDate(timestamp) { function isoDate(timestamp) {
const d = new Date(timestamp * 1000); const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0"); const pad = (n) => String(n).padStart(2, "0");
@@ -141,16 +157,18 @@ function show() {
blockieEl.appendChild(img); blockieEl.appendChild(img);
// Address line // Address line
const addrTitle = addressTitle(addr.address, state.wallets); $("address-token-dot").innerHTML = addressDotHtml(addr.address);
$("address-token-line").innerHTML = renderAddressHtml(addr.address, { $("address-token-full").dataset.full = addr.address;
title: addrTitle, $("address-token-full").textContent = addr.address;
ensName: addr.ensName, const addrLink =
}); tokenId !== "ETH"
$("address-token-line").dataset.full = addr.address; ? etherscanTokenLink(tokenId, addr.address)
attachCopyHandlers($("address-token-line")); : etherscanAddressLink(addr.address);
$("address-token-etherscan-link").innerHTML =
`<a href="${addrLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
// USD total for this token only // USD total for this token only
const usdVal = price ? amount * price : null; const usdVal = price ? amount * price : 0;
const usdStr = formatUsd(usdVal); const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = usdStr || "&nbsp;"; $("address-token-usd-total").innerHTML = usdStr || "&nbsp;";
@@ -187,9 +205,15 @@ function show() {
? knownToken.decimals ? knownToken.decimals
: null; : null;
const tokenHolders = tb && tb.holders != null ? tb.holders : null; const tokenHolders = tb && tb.holders != null ? tb.holders : null;
const dot = addressDotHtml(tokenId);
const tokenLink = `${currentNetwork().explorerUrl}/token/${escapeHtml(tokenId)}`;
const projectUrl = knownToken && knownToken.url ? knownToken.url : null; const projectUrl = knownToken && knownToken.url ? knownToken.url : null;
let infoHtml = `<div class="font-bold mb-2">Contract Address</div>`; let infoHtml = `<div class="font-bold mb-2">Contract Address</div>`;
infoHtml += `<div class="mb-2">${renderAddressHtml(tokenId)}</div>`; infoHtml +=
`<div class="flex items-center mb-2">${dot}` +
`<span class="break-all underline decoration-dashed cursor-pointer" id="address-token-contract-copy" data-copy="${escapeHtml(tokenId)}">${escapeHtml(tokenId)}</span>` +
`<a href="${tokenLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>` +
`</div>`;
if (tokenName) if (tokenName)
infoHtml += `<div class="mb-1"><span class="text-muted">Name:</span> ${tokenName}</div>`; infoHtml += `<div class="mb-1"><span class="text-muted">Name:</span> ${tokenName}</div>`;
if (tokenSymbol) if (tokenSymbol)
@@ -201,7 +225,6 @@ function show() {
if (projectUrl) if (projectUrl)
infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`; infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`;
contractInfo.innerHTML = infoHtml; contractInfo.innerHTML = infoHtml;
attachCopyHandlers(contractInfo);
contractInfo.classList.remove("hidden"); contractInfo.classList.remove("hidden");
} else { } else {
contractInfo.innerHTML = ""; contractInfo.innerHTML = "";
@@ -323,6 +346,15 @@ function renderTransactions(txs) {
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
$("address-token-full").addEventListener("click", () => {
const addr = $("address-token-full").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback($("address-token-full"));
}
});
$("address-token-contract-info").addEventListener("click", (e) => { $("address-token-contract-info").addEventListener("click", (e) => {
const copyEl = e.target.closest("[data-copy]"); const copyEl = e.target.closest("[data-copy]");
if (copyEl) { if (copyEl) {
@@ -333,7 +365,7 @@ function init(_ctx) {
}); });
$("btn-address-token-back").addEventListener("click", () => { $("btn-address-token-back").addEventListener("click", () => {
goBack(); ctx.showAddressDetail();
}); });
$("btn-address-token-send").addEventListener("click", () => { $("btn-address-token-send").addEventListener("click", () => {
@@ -360,14 +392,28 @@ function init(_ctx) {
$("send-token").classList.add("hidden"); $("send-token").classList.add("hidden");
let staticHtml = `<div class="font-bold">${escapeHtml(currentSymbol)}</div>`; let staticHtml = `<div class="font-bold">${escapeHtml(currentSymbol)}</div>`;
if (tokenId !== "ETH") { if (tokenId !== "ETH") {
staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`; const dot = addressDotHtml(tokenId);
const link = `${currentNetwork().explorerUrl}/token/${tokenId}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
staticHtml +=
`<div class="flex items-center text-xs">${dot}` +
`<span class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(tokenId)}">${escapeHtml(tokenId)}</span>` +
extLink +
`</div>`;
} }
$("send-token-static").innerHTML = staticHtml; $("send-token-static").innerHTML = staticHtml;
$("send-token-static").classList.remove("hidden"); $("send-token-static").classList.remove("hidden");
attachCopyHandlers($("send-token-static")); // Attach copy handler for the contract address
const copyEl = $("send-token-static").querySelector("[data-copy]");
if (copyEl) {
copyEl.addEventListener("click", () => {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(copyEl);
});
}
updateSendBalance(); updateSendBalance();
resetSendValidation(); resetSendValidation();
pushCurrentView();
showView("send"); showView("send");
}); });

View File

@@ -1,12 +1,11 @@
const { const {
$, $,
addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
showView, showView,
showError, showError,
hideError, hideError,
renderAddressHtml,
attachCopyHandlers,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers"); const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers");
@@ -18,11 +17,28 @@ const uniswap = require("../../shared/uniswap");
const runtime = const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime; typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
const erc20Iface = new Interface(ERC20_ABI); const erc20Iface = new Interface(ERC20_ABI);
function approvalAddressHtml(address) { function approvalAddressHtml(address) {
const dot = addressDotHtml(address);
const link = `${currentNetwork().explorerUrl}/address/${address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const title = addressTitle(address, state.wallets); const title = addressTitle(address, state.wallets);
return renderAddressHtml(address, { title }); let html = "";
if (title) {
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
html += `<div class="break-all">${escapeHtml(address)}${extLink}</div>`;
} else {
html += `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
}
return html;
} }
function formatTxValue(val) { function formatTxValue(val) {
@@ -37,6 +53,10 @@ function tokenLabel(address) {
return t ? t.symbol : null; return t ? t.symbol : null;
} }
function etherscanTokenLink(address) {
return `${currentNetwork().explorerUrl}/token/${address}`;
}
// Try to decode calldata using known ABIs. // Try to decode calldata using known ABIs.
// Returns { name, description, details } or null. // Returns { name, description, details } or null.
function decodeCalldata(data, toAddress) { function decodeCalldata(data, toAddress) {
@@ -215,6 +235,10 @@ function showTxApproval(details) {
toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`; toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`;
} }
toHtml += approvalAddressHtml(toAddr); toHtml += approvalAddressHtml(toAddr);
if (symbol) {
const link = etherscanTokenLink(toAddr);
toHtml = toHtml.replace("</div>", "") + ""; // approvalAddressHtml already has etherscan link
}
$("approve-tx-to").innerHTML = toHtml; $("approve-tx-to").innerHTML = toHtml;
} else { } else {
$("approve-tx-to").innerHTML = escapeHtml("(contract creation)"); $("approve-tx-to").innerHTML = escapeHtml("(contract creation)");
@@ -242,9 +266,12 @@ function showTxApproval(details) {
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`; detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address) { if (d.address) {
if (d.isToken) { if (d.isToken) {
const tLink = etherscanTokenLink(d.address);
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address) || "Unknown token")}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address) || "Unknown token")}</div>`;
detailsHtml += approvalAddressHtml(d.address);
} else {
detailsHtml += approvalAddressHtml(d.address);
} }
detailsHtml += approvalAddressHtml(d.address);
} else { } else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
} }
@@ -268,7 +295,6 @@ function showTxApproval(details) {
hideError("approve-tx-error"); hideError("approve-tx-error");
showView("approve-tx"); showView("approve-tx");
attachCopyHandlers("view-approve-tx");
} }
function decodeHexMessage(hex) { function decodeHexMessage(hex) {
@@ -366,7 +392,6 @@ function showSignApproval(details) {
$("btn-approve-sign").classList.remove("text-muted"); $("btn-approve-sign").classList.remove("text-muted");
showView("approve-sign"); showView("approve-sign");
attachCopyHandlers("view-approve-sign");
} }
function show(id) { function show(id) {
@@ -394,7 +419,6 @@ function show(id) {
$("approve-address").innerHTML = approvalAddressHtml( $("approve-address").innerHTML = approvalAddressHtml(
state.activeAddress, state.activeAddress,
); );
attachCopyHandlers("view-approve-site");
$("approve-remember").checked = state.rememberSiteChoice; $("approve-remember").checked = state.rememberSiteChoice;
}); });
} }

View File

@@ -17,10 +17,8 @@ const {
showFlash, showFlash,
flashCopyFeedback, flashCopyFeedback,
addressTitle, addressTitle,
addressDotHtml,
escapeHtml, escapeHtml,
renderAddressHtml,
attachCopyHandlers,
goBack,
} = require("./helpers"); } = require("./helpers");
const { state, currentNetwork } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
@@ -36,6 +34,13 @@ const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
const txStatus = require("./txStatus"); const txStatus = require("./txStatus");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
let pendingTx = null; let pendingTx = null;
function restore() { function restore() {
@@ -45,6 +50,14 @@ function restore() {
} }
} }
function etherscanTokenLink(address) {
return `${currentNetwork().explorerUrl}/token/${address}`;
}
function etherscanAddressLink(address) {
return `${currentNetwork().explorerUrl}/address/${address}`;
}
function blockieHtml(address) { function blockieHtml(address) {
const src = makeBlockie(address); const src = makeBlockie(address);
return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`; return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
@@ -52,10 +65,22 @@ function blockieHtml(address) {
function confirmAddressHtml(address, ensName, title) { function confirmAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address); const blockie = blockieHtml(address);
return ( const dot = addressDotHtml(address);
`<div class="mb-1">${blockie}</div>` + const link = etherscanAddressLink(address);
renderAddressHtml(address, { title, ensName }) const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
); let html = `<div class="mb-1">${blockie}</div>`;
if (title) {
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
}
if (ensName) {
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
}
html +=
`<div class="flex items-center">${title || ensName ? "" : dot}` +
`<span class="break-all">${escapeHtml(address)}</span>` +
extLink +
`</div>`;
return html;
} }
function valueWithUsd(text, usdAmount) { function valueWithUsd(text, usdAmount) {
@@ -82,12 +107,23 @@ function show(txInfo) {
// Token contract section (ERC-20 only) // Token contract section (ERC-20 only)
const tokenSection = $("confirm-token-section"); const tokenSection = $("confirm-token-section");
if (isErc20) { if (isErc20) {
$("confirm-token-contract").innerHTML = renderAddressHtml( const dot = addressDotHtml(txInfo.token);
txInfo.token, const link = etherscanTokenLink(txInfo.token);
{}, $("confirm-token-contract").innerHTML =
); `<div class="flex items-center">${dot}` +
`<span class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(txInfo.token)}">${escapeHtml(txInfo.token)}</span>` +
`<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>` +
`</div>`;
tokenSection.classList.remove("hidden"); tokenSection.classList.remove("hidden");
attachCopyHandlers(tokenSection); // Attach click-to-copy on the contract address
const copyEl = tokenSection.querySelector("[data-copy]");
if (copyEl) {
copyEl.onclick = () => {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(copyEl);
};
}
} else { } else {
tokenSection.classList.add("hidden"); tokenSection.classList.add("hidden");
} }
@@ -207,7 +243,6 @@ function show(txInfo) {
$("confirm-fee-amount").textContent = "Estimating..."; $("confirm-fee-amount").textContent = "Estimating...";
state.viewData = { pendingTx: txInfo }; state.viewData = { pendingTx: txInfo };
showView("confirm-tx"); showView("confirm-tx");
attachCopyHandlers("view-confirm-tx");
// Reset async warnings to hidden (space always reserved, no layout shift) // Reset async warnings to hidden (space always reserved, no layout shift)
$("confirm-recipient-warning").style.visibility = "hidden"; $("confirm-recipient-warning").style.visibility = "hidden";
@@ -356,7 +391,7 @@ function init(ctx) {
}); });
$("btn-confirm-back").addEventListener("click", () => { $("btn-confirm-back").addEventListener("click", () => {
goBack(); showView("send");
}); });
} }

View File

@@ -1,4 +1,4 @@
const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers"); const { $, showView, showFlash } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
@@ -21,7 +21,7 @@ function init(_ctx) {
$("btn-delete-wallet-back").addEventListener("click", () => { $("btn-delete-wallet-back").addEventListener("click", () => {
deleteWalletIndex = null; deleteWalletIndex = null;
goBack(); ctx.showSettingsView();
}); });
$("btn-delete-wallet-confirm").addEventListener("click", async () => { $("btn-delete-wallet-confirm").addEventListener("click", async () => {
@@ -77,7 +77,6 @@ function init(_ctx) {
state.selectedWallet = null; state.selectedWallet = null;
state.selectedAddress = null; state.selectedAddress = null;
state.activeAddress = null; state.activeAddress = null;
clearViewStack();
await saveState(); await saveState();
showView("welcome"); showView("welcome");
} else { } else {
@@ -87,14 +86,8 @@ function init(_ctx) {
state.activeAddress = state.activeAddress =
state.wallets[0].addresses[0]?.address || null; state.wallets[0].addresses[0]?.address || null;
await saveState(); await saveState();
// Reset stack to [main] so Settings back goes home.
// Use require() lazily to avoid circular dependency
// (settings.js requires deleteWallet.js).
clearViewStack();
state.viewStack.push("main");
ctx.renderWalletList(); ctx.renderWalletList();
const settings = require("./settings"); ctx.showSettingsView();
settings.show();
showFlash("Wallet deleted."); showFlash("Wallet deleted.");
} }
}); });

View File

@@ -75,43 +75,6 @@ function showView(name) {
} }
} }
// Callback to re-render the main/home view when navigating back to it.
// Set once by index.js via setRenderMain().
let _renderMain = null;
function setRenderMain(fn) {
_renderMain = fn;
}
// Push the current view onto the navigation stack so goBack() can
// return to it. Call this before any forward navigation.
function pushCurrentView() {
if (state.currentView) {
state.viewStack.push(state.currentView);
}
}
// Pop the navigation stack and show the previous view. If the stack
// is empty, fall back to the main (home) view.
function goBack() {
let target;
if (state.viewStack.length > 0) {
target = state.viewStack.pop();
} else {
target = "main";
}
if (target === "main" && _renderMain) {
_renderMain();
}
showView(target);
}
// Clear the entire navigation stack (used when resetting to root,
// e.g. after adding or deleting a wallet).
function clearViewStack() {
state.viewStack = [];
}
let flashTimer = null; let flashTimer = null;
function clearFlash() { function clearFlash() {
@@ -253,9 +216,21 @@ function addressTitle(address, wallets) {
// Render an address with color dot, optional ENS name, optional title, // Render an address with color dot, optional ENS name, optional title,
// and optional truncation. Title and ENS are shown as bold labels above // and optional truncation. Title and ENS are shown as bold labels above
// the full address. // the full address.
// Delegates to renderAddressHtml for consistent output.
function formatAddressHtml(address, ensName, maxLen, title) { function formatAddressHtml(address, ensName, maxLen, title) {
return renderAddressHtml(address, { title, ensName, maxLen }); const dot = addressDotHtml(address);
const displayAddr = maxLen ? truncateMiddle(address, maxLen) : address;
if (title || ensName) {
let html = "";
if (title) {
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
}
if (ensName) {
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
}
html += `<div class="break-all">${escapeHtml(displayAddr)}</div>`;
return html;
}
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(displayAddr)}</span></div>`;
} }
function isoDate(timestamp) { function isoDate(timestamp) {
@@ -314,91 +289,6 @@ function timeAgo(timestamp) {
return years + " year" + (years !== 1 ? "s" : "") + " ago"; return years + " year" + (years !== 1 ? "s" : "") + " ago";
} }
// Shared external-link icon SVG used across all views.
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
function etherscanAddressUrl(address) {
return `${currentNetwork().explorerUrl}/address/${address}`;
}
function etherscanLinkHtml(url) {
return (
`<a href="${url}" target="_blank" rel="noopener" ` +
`class="inline-flex items-center">${EXT_ICON}</a>`
);
}
// Render a copyable text span with dashed underline affordance.
// The caller must attach click handlers via attachCopyHandlers() or
// manually wire up [data-copy] elements after inserting the HTML.
function copyableHtml(text, extraClass) {
const cls =
"underline decoration-dashed cursor-pointer" +
(extraClass ? " " + extraClass : "");
return `<span class="${cls}" data-copy="${escapeHtml(text)}">${escapeHtml(text)}</span>`;
}
// Attach click-to-copy handlers to all [data-copy] elements within
// a container. Safe to call multiple times on the same container.
function attachCopyHandlers(container) {
const root =
typeof container === "string"
? document.getElementById(container)
: container;
if (!root) return;
root.querySelectorAll("[data-copy]").forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
}
// Unified address rendering.
//
// Produces consistent HTML for any Ethereum address:
// • Color dot
// • Optional title (e.g. "Wallet 1 — Address 2") shown bold above address
// • Optional ENS name shown bold above address
// • Full address (or truncated via maxLen) with dashed-underline click-to-copy
// • Etherscan external link icon
//
// Options object:
// title — wallet title string (from addressTitle)
// ensName — ENS name string
// maxLen — if set, truncate address display (min 32 chars enforced)
// noLink — if true, omit etherscan link
//
// After inserting the returned HTML into the DOM, call
// attachCopyHandlers() on the parent to wire up click-to-copy.
function renderAddressHtml(address, opts) {
const { title, ensName, maxLen, noLink } = opts || {};
const dot = addressDotHtml(address);
const displayAddr = maxLen ? truncateMiddle(address, maxLen) : address;
const link = etherscanAddressUrl(address);
const extLink = noLink ? "" : etherscanLinkHtml(link);
let html = "";
if (title) {
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
}
if (ensName) {
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
}
if (title || ensName) {
html += `<div class="flex items-center">${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
} else {
html += `<div class="flex items-center">${dot}${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
}
return html;
}
function flashCopyFeedback(el) { function flashCopyFeedback(el) {
if (!el) return; if (!el) return;
el.classList.remove("copy-flash-fade"); el.classList.remove("copy-flash-fade");
@@ -417,10 +307,6 @@ module.exports = {
showError, showError,
hideError, hideError,
showView, showView,
setRenderMain,
pushCurrentView,
goBack,
clearViewStack,
showFlash, showFlash,
flashCopyFeedback, flashCopyFeedback,
balanceLine, balanceLine,
@@ -430,12 +316,6 @@ module.exports = {
escapeHtml, escapeHtml,
addressTitle, addressTitle,
formatAddressHtml, formatAddressHtml,
renderAddressHtml,
copyableHtml,
attachCopyHandlers,
etherscanAddressUrl,
etherscanLinkHtml,
EXT_ICON,
truncateMiddle, truncateMiddle,
isoDate, isoDate,
timeAgo, timeAgo,

View File

@@ -10,11 +10,13 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
truncateMiddle, truncateMiddle,
renderAddressHtml,
attachCopyHandlers,
pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentAddress } = require("../../shared/state"); const {
state,
saveState,
currentAddress,
currentNetwork,
} = require("../../shared/state");
const { const {
updateSendBalance, updateSendBalance,
renderSendTokenSelect, renderSendTokenSelect,
@@ -72,12 +74,28 @@ function renderTotalValue() {
} }
} }
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
function renderActiveAddress() { function renderActiveAddress() {
const el = $("active-address-display"); const el = $("active-address-display");
if (!el) return; if (!el) return;
if (state.activeAddress) { if (state.activeAddress) {
el.innerHTML = renderAddressHtml(state.activeAddress); const addr = state.activeAddress;
attachCopyHandlers(el); const dot = addressDotHtml(addr);
const link = `${currentNetwork().explorerUrl}/address/${addr}`;
el.innerHTML =
`<span class="underline decoration-dashed cursor-pointer" id="active-addr-copy">${dot}${escapeHtml(addr)}</span>` +
`<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
$("active-addr-copy").addEventListener("click", (e) => {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback(e.currentTarget);
});
} else { } else {
el.textContent = ""; el.textContent = "";
} }
@@ -382,7 +400,6 @@ function init(ctx) {
renderSendTokenSelect(addr); renderSendTokenSelect(addr);
updateSendBalance(); updateSendBalance();
resetSendValidation(); resetSendValidation();
pushCurrentView();
showView("send"); showView("send");
}); });

View File

@@ -5,12 +5,17 @@ const {
flashCopyFeedback, flashCopyFeedback,
formatAddressHtml, formatAddressHtml,
addressTitle, addressTitle,
attachCopyHandlers,
goBack,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress, currentNetwork } = require("../../shared/state"); const { state, currentAddress, currentNetwork } = require("../../shared/state");
const QRCode = require("qrcode"); const QRCode = require("qrcode");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
function show() { function show() {
const addr = currentAddress(); const addr = currentAddress();
const address = addr ? addr.address : ""; const address = addr ? addr.address : "";
@@ -20,8 +25,11 @@ function show() {
? formatAddressHtml(address, ensName, null, title) ? formatAddressHtml(address, ensName, null, title)
: ""; : "";
$("receive-address-block").dataset.full = address; $("receive-address-block").dataset.full = address;
// Etherscan link is now included in formatAddressHtml via renderAddressHtml const net = currentNetwork();
$("receive-etherscan-link").innerHTML = ""; const link = address ? `${net.explorerUrl}/address/${address}` : "";
$("receive-etherscan-link").innerHTML = link
? `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`
: "";
if (address) { if (address) {
QRCode.toCanvas($("receive-qr"), address, { QRCode.toCanvas($("receive-qr"), address, {
width: 200, width: 200,
@@ -54,10 +62,18 @@ function show() {
warningEl.style.visibility = "hidden"; warningEl.style.visibility = "hidden";
} }
showView("receive"); showView("receive");
attachCopyHandlers("view-receive");
} }
function init(ctx) { function init(ctx) {
$("receive-address-block").addEventListener("click", (e) => {
const addr = $("receive-address-block").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback(e.currentTarget);
}
});
$("btn-receive-copy").addEventListener("click", () => { $("btn-receive-copy").addEventListener("click", () => {
const addr = $("receive-address-block").dataset.full; const addr = $("receive-address-block").dataset.full;
if (addr) { if (addr) {
@@ -68,7 +84,11 @@ function init(ctx) {
}); });
$("btn-receive-back").addEventListener("click", () => { $("btn-receive-back").addEventListener("click", () => {
goBack(); if (state.selectedToken) {
ctx.showAddressToken();
} else {
ctx.showAddressDetail();
}
}); });
} }

View File

@@ -3,13 +3,11 @@
const { const {
$, $,
showFlash, showFlash,
addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
renderAddressHtml,
attachCopyHandlers,
goBack,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress } = require("../../shared/state"); const { state, currentAddress, currentNetwork } = require("../../shared/state");
let ctx; let ctx;
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList"); const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList");
@@ -115,6 +113,13 @@ function updateToValidation() {
} }
} }
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
function isSpoofedToken(t) { function isSpoofedToken(t) {
const upper = (t.symbol || "").toUpperCase(); const upper = (t.symbol || "").toUpperCase();
if (!KNOWN_SYMBOLS.has(upper)) return false; if (!KNOWN_SYMBOLS.has(upper)) return false;
@@ -143,12 +148,24 @@ function renderSendTokenSelect(addr) {
function updateSendBalance() { function updateSendBalance() {
const addr = currentAddress(); const addr = currentAddress();
if (!addr) return; if (!addr) return;
const dot = addressDotHtml(addr.address);
const link = `${currentNetwork().explorerUrl}/address/${addr.address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const title = addressTitle(addr.address, state.wallets); const title = addressTitle(addr.address, state.wallets);
$("send-from").innerHTML = renderAddressHtml(addr.address, { let fromHtml = "";
title, if (title) {
ensName: addr.ensName, fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
}); if (addr.ensName) {
attachCopyHandlers($("send-from")); fromHtml += `<div>${escapeHtml(addr.ensName)}</div>`;
}
fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
} else if (addr.ensName) {
fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(addr.ensName)}</div>`;
fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
} else {
fromHtml += `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(addr.address)}</span>${extLink}</div>`;
}
$("send-from").innerHTML = fromHtml;
const token = state.selectedToken || $("send-token").value; const token = state.selectedToken || $("send-token").value;
if (token === "ETH") { if (token === "ETH") {
$("send-balance").textContent = $("send-balance").textContent =
@@ -251,7 +268,11 @@ function init(_ctx) {
$("btn-send-back").addEventListener("click", () => { $("btn-send-back").addEventListener("click", () => {
$("send-token").classList.remove("hidden"); $("send-token").classList.remove("hidden");
$("send-token-static").classList.add("hidden"); $("send-token-static").classList.add("hidden");
goBack(); if (state.selectedToken) {
ctx.showAddressToken();
} else {
ctx.showAddressDetail();
}
}); });
} }

View File

@@ -1,15 +1,7 @@
const { const { $, showView, showFlash, escapeHtml } = require("./helpers");
$,
showView,
showFlash,
escapeHtml,
goBack,
pushCurrentView,
} = require("./helpers");
const { applyTheme } = require("../theme"); const { applyTheme } = require("../theme");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks"); const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
const { onChainSwitch } = require("../../shared/chainSwitch");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
const deleteWallet = require("./deleteWallet"); const deleteWallet = require("./deleteWallet");
@@ -93,7 +85,6 @@ function renderWalletListSettings() {
container.querySelectorAll(".btn-delete-wallet").forEach((btn) => { container.querySelectorAll(".btn-delete-wallet").forEach((btn) => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
const idx = parseInt(btn.dataset.idx, 10); const idx = parseInt(btn.dataset.idx, 10);
pushCurrentView();
deleteWallet.show(idx); deleteWallet.show(idx);
}); });
}); });
@@ -229,9 +220,14 @@ function init(ctx) {
if (networkSelect) { if (networkSelect) {
networkSelect.addEventListener("change", async () => { networkSelect.addEventListener("change", async () => {
const newId = networkSelect.value; const newId = networkSelect.value;
const net = await onChainSwitch(newId); const net = NETWORKS[newId];
if (!net) return;
state.networkId = newId;
state.rpcUrl = net.defaultRpcUrl;
state.blockscoutUrl = net.defaultBlockscoutUrl;
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
await saveState();
showFlash("Switched to " + net.name + "."); showFlash("Switched to " + net.name + ".");
}); });
} }
@@ -290,7 +286,8 @@ function init(ctx) {
); );
$("btn-settings-back").addEventListener("click", () => { $("btn-settings-back").addEventListener("click", () => {
goBack(); ctx.renderWalletList();
showView("main");
}); });
} }

View File

@@ -1,4 +1,4 @@
const { $, showView, showFlash, goBack } = require("./helpers"); const { $, showView, showFlash } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
@@ -84,7 +84,7 @@ function init(_ctx) {
ctx = _ctx; ctx = _ctx;
$("btn-settings-addtoken-back").addEventListener("click", () => { $("btn-settings-addtoken-back").addEventListener("click", () => {
goBack(); ctx.showSettingsView();
}); });
$("btn-settings-addtoken-select").addEventListener("click", async () => { $("btn-settings-addtoken-select").addEventListener("click", async () => {

View File

@@ -6,15 +6,11 @@ const {
showView, showView,
showFlash, showFlash,
flashCopyFeedback, flashCopyFeedback,
addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
isoDate, isoDate,
timeAgo, timeAgo,
renderAddressHtml,
attachCopyHandlers,
copyableHtml,
etherscanLinkHtml,
goBack,
} = require("./helpers"); } = require("./helpers");
const { state, currentNetwork } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
@@ -22,6 +18,13 @@ const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval"); const { decodeCalldata } = require("./approval");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
let ctx; let ctx;
/** /**
@@ -43,17 +46,52 @@ function getTransactionType(tx) {
return "Native ETH Transfer"; return "Native ETH Transfer";
} }
function copyableHtml(text, extraClass) {
const cls =
"underline decoration-dashed cursor-pointer" +
(extraClass ? " " + extraClass : "");
return `<span class="${cls}" data-copy="${escapeHtml(text)}">${escapeHtml(text)}</span>`;
}
function blockieHtml(address) { function blockieHtml(address) {
const src = makeBlockie(address); const src = makeBlockie(address);
return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`; return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
} }
function etherscanLinkHtml(url) {
return (
`<a href="${url}" target="_blank" rel="noopener" ` +
`class="inline-flex items-center"` +
`>${EXT_ICON}</a>`
);
}
function txAddressHtml(address, ensName, title) { function txAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address); const blockie = blockieHtml(address);
return ( const dot = addressDotHtml(address);
`<div class="mb-1">${blockie}</div>` + const link = `${currentNetwork().explorerUrl}/address/${address}`;
renderAddressHtml(address, { title, ensName }) const extLink = etherscanLinkHtml(link);
); let html = `<div class="mb-1">${blockie}</div>`;
if (title) {
html += `<div class="font-bold">${escapeHtml(title)}</div>`;
}
if (ensName) {
html +=
`<div class="flex items-center">${dot}` +
copyableHtml(ensName, "") +
`</div>` +
`<div class="flex items-center">${dot}` +
copyableHtml(address, "break-all") +
extLink +
`</div>`;
} else {
html +=
`<div class="flex items-center">${dot}` +
copyableHtml(address, "break-all") +
extLink +
`</div>`;
}
return html;
} }
function txHashHtml(hash) { function txHashHtml(hash) {
@@ -172,7 +210,17 @@ function render() {
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")"; copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success"; $("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
showView("transaction"); showView("transaction");
attachCopyHandlers("view-transaction");
document
.getElementById("view-transaction")
.querySelectorAll("[data-copy]")
.forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
} }
function showDetailField(sectionId, contentId, value) { function showDetailField(sectionId, contentId, value) {
@@ -307,14 +355,19 @@ async function loadFullTxDetails(txHash, toAddress, isContractCall) {
detailsHtml += `<div class="mb-2">`; detailsHtml += `<div class="mb-2">`;
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`; detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address && d.isToken) { if (d.address && d.isToken) {
// Token entry: show symbol on its own line, then address via shared renderer // Token entry: show symbol on its own line, then dot + address + Etherscan link
const dot = addressDotHtml(d.address);
const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1]; const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1];
if (tokenSymbol) { if (tokenSymbol) {
detailsHtml += `<div class="font-bold">${escapeHtml(tokenSymbol)}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(tokenSymbol)}</div>`;
} }
detailsHtml += renderAddressHtml(d.address); const etherscanUrl = `${currentNetwork().explorerUrl}/token/${d.address}`;
detailsHtml += `<div class="flex items-center">${dot}${copyableHtml(d.address, "break-all")}${etherscanLinkHtml(etherscanUrl)}</div>`;
} else if (d.address) { } else if (d.address) {
detailsHtml += renderAddressHtml(d.address); // Protocol/contract entry: show name + Etherscan link
const dot = addressDotHtml(d.address);
const etherscanUrl = `${currentNetwork().explorerUrl}/address/${d.address}`;
detailsHtml += `<div class="flex items-center">${dot}${copyableHtml(d.value, "break-all")}${etherscanLinkHtml(etherscanUrl)}</div>`;
} else { } else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
} }
@@ -341,7 +394,13 @@ async function loadFullTxDetails(txHash, toAddress, isContractCall) {
// Bind copy handlers for new elements (including raw data now outside section) // Bind copy handlers for new elements (including raw data now outside section)
const copyTargets = [section, rawSection].filter(Boolean); const copyTargets = [section, rawSection].filter(Boolean);
for (const container of copyTargets) { for (const container of copyTargets) {
attachCopyHandlers(container); container.querySelectorAll("[data-copy]").forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
} }
} catch (e) { } catch (e) {
log.errorf("loadCalldata failed:", e.message); log.errorf("loadCalldata failed:", e.message);
@@ -351,7 +410,11 @@ async function loadFullTxDetails(txHash, toAddress, isContractCall) {
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
$("btn-tx-back").addEventListener("click", () => { $("btn-tx-back").addEventListener("click", () => {
goBack(); if (state.selectedToken) {
ctx.showAddressToken();
} else {
ctx.showAddressDetail();
}
}); });
} }

View File

@@ -3,19 +3,24 @@
const { const {
$, $,
showView, showView,
showFlash,
flashCopyFeedback,
addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
renderAddressHtml,
attachCopyHandlers,
copyableHtml,
etherscanLinkHtml,
clearViewStack,
} = require("./helpers"); } = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
let ctx; let ctx;
let elapsedTimer = null; let elapsedTimer = null;
let pollTimer = null; let pollTimer = null;
@@ -32,19 +37,50 @@ function clearTimers() {
} }
function toAddressHtml(address) { function toAddressHtml(address) {
const dot = addressDotHtml(address);
const link = `${currentNetwork().explorerUrl}/address/${address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const title = addressTitle(address, state.wallets); const title = addressTitle(address, state.wallets);
return renderAddressHtml(address, { title }); if (title) {
return (
`<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>` +
`<div class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(address)}">${escapeHtml(address)}</div>` +
extLink
);
}
return `<div class="flex items-center">${dot}<span class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(address)}">${escapeHtml(address)}</span>${extLink}</div>`;
} }
function txHashHtml(hash) { function txHashHtml(hash) {
const link = `${currentNetwork().explorerUrl}/tx/${hash}`; const link = `${currentNetwork().explorerUrl}/tx/${hash}`;
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link); const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
return (
`<span class="underline decoration-dashed cursor-pointer break-all" data-copy="${escapeHtml(hash)}">${escapeHtml(hash)}</span>` +
extLink
);
} }
function blockNumberHtml(blockNumber) { function blockNumberHtml(blockNumber) {
const num = String(blockNumber); const num = String(blockNumber);
const link = `${currentNetwork().explorerUrl}/block/${num}`; const link = `${currentNetwork().explorerUrl}/block/${num}`;
return copyableHtml(num) + etherscanLinkHtml(link); const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
return (
`<span class="underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(num)}">${escapeHtml(num)}</span>` +
extLink
);
}
function attachCopyHandlers(viewId) {
document
.getElementById(viewId)
.querySelectorAll("[data-copy]")
.forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
} }
function showWait(txInfo, txHash) { function showWait(txInfo, txHash) {
@@ -222,16 +258,10 @@ function navigateBack() {
window.close(); window.close();
return; return;
} }
// After a completed transaction, reset the navigation stack
// and go directly to the address view (token or detail).
// Use require() lazily to call show() without the ctx push wrapper.
clearViewStack();
state.viewStack.push("main");
if (state.selectedToken) { if (state.selectedToken) {
state.viewStack.push("address"); ctx.showAddressToken();
require("./addressToken").show();
} else { } else {
require("./addressDetail").show(); ctx.showAddressDetail();
} }
} }

View File

@@ -1,57 +0,0 @@
// Consolidated chain-switch handler.
//
// Every state change required when the active network changes is
// performed here so that callers (settings UI, background
// wallet_switchEthereumChain, future chain additions) all go
// through a single code path.
//
// Adding a new chain (e.g. ETC) requires only a new entry in
// networks.js — no per-caller wiring is needed.
const { networkById } = require("./networks");
const { clearPrices } = require("./prices");
// Switch the active chain and reset all chain-specific cached state.
// Returns the network configuration object for the new chain.
async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state");
const net = networkById(newNetworkId);
// --- core identity ---
state.networkId = net.id;
state.rpcUrl = net.defaultRpcUrl;
state.blockscoutUrl = net.defaultBlockscoutUrl;
// --- price cache ---
// Prices are chain-specific (testnet tokens are worthless,
// ETC has different pricing, etc.).
clearPrices();
// --- balance / refresh state ---
// Reset last-refresh timestamp so the next polling cycle
// triggers an immediate balance refresh on the new chain.
state.lastBalanceRefresh = 0;
// Clear per-address balances and token balances so stale data
// from the previous chain is never displayed while the first
// refresh on the new chain is in flight.
for (const wallet of state.wallets) {
for (const addr of wallet.addresses) {
addr.balance = "0";
addr.tokenBalances = [];
}
}
// --- chain-specific caches ---
// Token holder counts and fraud contract lists are
// chain-specific and must not carry over.
state.tokenHolderCache = {};
state.fraudContracts = [];
await saveState();
return net;
}
module.exports = { onChainSwitch };

View File

@@ -8,13 +8,9 @@ const prices = {};
let lastFetchedAt = 0; let lastFetchedAt = 0;
async function refreshPrices() { async function refreshPrices() {
// Testnet tokens have no real market value — skip price fetching // Testnet tokens have no real market value — skip price fetching.
// and clear any stale mainnet prices so the UI shows no USD values.
const { currentNetwork } = require("./state"); const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) { if (currentNetwork().isTestnet) return;
clearPrices();
return;
}
const now = Date.now(); const now = Date.now();
if (now - lastFetchedAt < PRICE_CACHE_TTL) return; if (now - lastFetchedAt < PRICE_CACHE_TTL) return;
try { try {
@@ -26,19 +22,7 @@ async function refreshPrices() {
} }
} }
// Clear all cached prices and reset the fetch timestamp so the
// next refreshPrices() call will fetch fresh data.
function clearPrices() {
for (const key of Object.keys(prices)) {
delete prices[key];
}
lastFetchedAt = 0;
}
// Return the USD price for a symbol, or null on testnet / unknown.
function getPrice(symbol) { function getPrice(symbol) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
return prices[symbol] || null; return prices[symbol] || null;
} }
@@ -56,8 +40,6 @@ function formatUsd(amount) {
} }
function getAddressValueUsd(addr) { function getAddressValueUsd(addr) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
if (!prices.ETH) return null; if (!prices.ETH) return null;
let total = 0; let total = 0;
const ethBal = parseFloat(addr.balance || "0"); const ethBal = parseFloat(addr.balance || "0");
@@ -72,8 +54,6 @@ function getAddressValueUsd(addr) {
} }
function getWalletValueUsd(wallet) { function getWalletValueUsd(wallet) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
if (!prices.ETH) return null; if (!prices.ETH) return null;
let total = 0; let total = 0;
for (const addr of wallet.addresses) { for (const addr of wallet.addresses) {
@@ -83,8 +63,6 @@ function getWalletValueUsd(wallet) {
} }
function getTotalValueUsd(wallets) { function getTotalValueUsd(wallets) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
if (!prices.ETH) return null; if (!prices.ETH) return null;
let total = 0; let total = 0;
for (const wallet of wallets) { for (const wallet of wallets) {
@@ -96,7 +74,6 @@ function getTotalValueUsd(wallets) {
module.exports = { module.exports = {
prices, prices,
refreshPrices, refreshPrices,
clearPrices,
getPrice, getPrice,
formatUsd, formatUsd,
getAddressValueUsd, getAddressValueUsd,

View File

@@ -38,7 +38,6 @@ const state = {
selectedAddress: null, selectedAddress: null,
selectedToken: null, selectedToken: null,
viewData: {}, viewData: {},
viewStack: [],
}; };
// Return the network configuration for the currently selected network. // Return the network configuration for the currently selected network.
@@ -73,7 +72,6 @@ async function saveState() {
selectedAddress: state.selectedAddress, selectedAddress: state.selectedAddress,
selectedToken: state.selectedToken, selectedToken: state.selectedToken,
viewData: state.viewData, viewData: state.viewData,
viewStack: state.viewStack,
}; };
await storageApi.set({ autistmask: persisted }); await storageApi.set({ autistmask: persisted });
} }
@@ -135,7 +133,6 @@ async function loadState() {
saved.selectedAddress !== undefined ? saved.selectedAddress : null; saved.selectedAddress !== undefined ? saved.selectedAddress : null;
state.selectedToken = saved.selectedToken || null; state.selectedToken = saved.selectedToken || null;
state.viewData = saved.viewData || {}; state.viewData = saved.viewData || {};
state.viewStack = Array.isArray(saved.viewStack) ? saved.viewStack : [];
} }
} }