Files
2026-08-09 07:39:31 +02:00

206 lines
12 KiB
Markdown

# AGENTS.md
Notes for AI agents working in this repository. Human-facing product docs live in
[README.md](README.md); user-visible changes go in [CHANGELOG.md](CHANGELOG.md).
## What this app is
Q-Wallets is a **Qortal Q-App**: a React SPA that is published to QDN and rendered
inside the Qortal core UI (in an iframe). It is a multi-chain wallet front-end for
QORT, BTC, LTC, DOGE, DGB, RVN and ARRR.
Consequences that shape almost every design decision:
- **There is no backend and no build-time API.** Every privileged operation
(balances, transactions, sending, encryption, QDN publish) goes through the global
`qortalRequest({ action: ... })` bridge provided by the host. It is declared in
[src/global.d.ts](src/global.d.ts) and used **without importing anything**.
- **No private keys ever live in this codebase.** Signing happens in Qortal core.
- **The host controls theme, language and base path** via `window._qdnTheme`,
`window._qdnLang`, `window._qdnBase`, and `postMessage` events
(`NAVIGATE_TO_PATH`, `THEME_CHANGED`, `LANGUAGE_CHANGED`) — see
[src/hooks/useIframeListener.tsx](src/hooks/useIframeListener.tsx).
- **Persistent user data is localStorage + encrypted QDN**, never a server.
- Running the app outside Qortal core is only partially useful: `npm run dev` boots
the UI, but anything touching `qortalRequest` fails. Prefer verifying behaviour
with unit tests over trying to run the app.
Actions currently used: `GET_USER_ACCOUNT`, `GET_USER_WALLET`, `GET_WALLET_BALANCE`,
`GET_USER_WALLET_TRANSACTIONS`, `SEND_COIN`, `GET_PRIMARY_NAME`, `OPEN_USER_LOOKUP`,
`ENCRYPT_DATA`, `DECRYPT_DATA`, `PUBLISH_QDN_RESOURCE`, `FETCH_QDN_RESOURCE`,
`GET_NODE_INFO`, `GET_NODE_STATUS`, `IS_USING_PUBLIC_NODE`,
`GET_CROSSCHAIN_SERVER_INFO`, `SET_CURRENT_FOREIGN_SERVER`, `GET_ARRR_SYNC_STATUS`.
## Commands
| Task | Command |
| -------------- | ------------------------------------------- |
| Dev server | `npm run dev` |
| Type-check | `npx tsc -b` (also the first half of build) |
| Build | `npm run build` |
| Tests (agents) | `npx vitest run [path]` |
| Tests (watch) | `npm test` |
| Coverage | `npm run test:coverage` |
| Lint | `npm run lint` / `npm run lint:fix` |
| Format | `npm run format` / `npm run format:check` |
- **`npm test` is `vitest` in watch mode** — in an interactive TTY it will not
exit. Always use `npx vitest run` when running tests non-interactively.
- `npm run build` = `tsc -b && vite build`, then copies `CHANGELOG.md` into `dist/`
(the in-app changelog viewer fetches it at runtime).
- CI ([.github/workflows/npm_tests.yml](.github/workflows/npm_tests.yml)) runs only
`npm test` on PRs to `master` and `feature/**`. Lint/format are **not** gated by
CI, so run them yourself before finishing.
## Stack
React 19 + TypeScript 5.8 (strict) · Vite 6 · MUI 7 (+ `@mui/lab`, `@toolpad/core`) ·
Emotion · Jotai · react-router 7 · react-i18next · `qapp-core` (Qortal Q-App SDK) ·
Vitest 4 + Testing Library.
## Layout
```text
src/
main.tsx React root: theme provider → wallet context → router
AppWrapper.tsx qapp-core <GlobalProvider> (appName, publicSalt, auth)
AppLayout.tsx Nav drawer, node status polling, session context, QDN startup sync
routes/Routes.tsx createBrowserRouter, basename = window._qdnBase
pages/<coin>/ One self-contained page per coin (qort, btc, ltc, doge, dgb, rvn, arrr)
components/
WalletWorkspace/ Shared wallet shell: summary card, receive QR, address-book panel,
sync card, transaction lists (largest shared component)
AddressBook/ Dialog, table, add/edit form, delete confirm, avatar palette
ExternalSendForm.tsx Shared send dialog body for the 6 non-QORT coins
FeeManager.tsx Fee selection UI (low/medium/high/custom)
NameText.tsx Renders Qortal names (handles invisible-character warnings)
utils/ addressBookStorage, addressBookQDN, addressValidation,
qortalNodeApi, maxSendable, invisibleCharacters, Types
hooks/ useRecommendedFees, useIframeListener
state/global/ Jotai atoms (theme, QORT transaction filters)
contexts/ walletContext (address, name, avatar, node info)
common/ constants.ts, functions.ts
i18n/ i18n.ts, processors.ts, locales/<lang>/core.json (11 languages)
styles/ theme/, page-styles.tsx (styled MUI dialogs/cards/tables)
test/ setup.ts (global mocks), test-utils.tsx (custom render)
```
## Architecture notes
**Session / auth.** `useAuth()` from `qapp-core` in `AppLayout` is the source of
truth for `address`/`name`/`avatarUrl`; it is mirrored into `walletContext` for the
rest of the tree. `useGlobal()` is used on the QORT page for the auth name.
**Coin pages are deliberately duplicated.** The six non-QORT pages
([btc](src/pages/btc/index.tsx), [ltc](src/pages/ltc/index.tsx),
[doge](src/pages/doge/index.tsx), [dgb](src/pages/dgb/index.tsx),
[rvn](src/pages/rvn/index.tsx), [arrr](src/pages/arrr/index.tsx)) are ~700-line
near-copies differing in fee constant, address validator, decimals and a few
coin-specific quirks (ARRR adds sync status and foreign-server selection). **A fix
in one almost always has to be applied to the other five** — grep across
`src/pages/*/index.tsx` before declaring a change complete. The QORT page
([src/pages/qort/index.tsx](src/pages/qort/index.tsx), ~5.7k lines) is the outlier:
it resolves addresses to primary names (module-level `addressToPrimaryName` cache +
`RequestQueueWithPromise(10)`), supports transaction-type filters persisted in a
Jotai `atomWithStorage`, and owns its own address-book sync UI.
**Address book storage** ([src/utils/addressBookStorage.ts](src/utils/addressBookStorage.ts)):
localStorage, one key per coin, scoped by the logged-in account address
(`q-wallets-addressbook-<accountScope>-<coin>`), with migration from the older
unscoped keys. `setAddressBookAccountScope()` must be set before reads. Mutations
dispatch the `ADDRESS_BOOK_STORAGE_EVENT` window event so open panels refresh.
Entries carry `favorite`, `favoriteAt` and `sortOrder` for pinning/drag reorder.
**Address book QDN sync** ([src/utils/addressBookQDN.ts](src/utils/addressBookQDN.ts)):
entries are base64-encoded, `ENCRYPT_DATA`-encrypted and published as
`DOCUMENT_PRIVATE` under identifier `q-wallets-addressbook-<coin>`. Startup sync runs
from `AppLayout` for all coins. Three extra localStorage flags drive the sync UI:
`...-sync-required`, `...-sync-baseline` (signature of the last clean state) and
`...-published` (last published hash, used to tell "QDN unavailable" from "nothing
published yet"). All QDN failures are swallowed and logged — localStorage keeps
working.
**Validation.** Per-coin regexes in
[src/utils/addressValidation.ts](src/utils/addressValidation.ts). The QORT pattern
`/^Q[1-9A-HJ-NP-Za-km-z]{33}$/` is additionally duplicated in
[AddressFormDialog.tsx](src/components/AddressBook/AddressFormDialog.tsx) and the
QORT page. Qortal names are also checked for invisible/spoofing characters via
[invisibleCharacters.ts](src/utils/invisibleCharacters.ts).
**Fees & max send.** `useRecommendedFees` fetches publisher-provided estimates
(with timeouts); per-coin fallback fee constants live in `common/constants.ts`.
`calculateMaxSendable` works in integer satoshis and holds back
`SEND_MAX_SAFETY_BUFFER_SATS` (1000) to avoid float-boundary "insufficient funds"
rejections from the host — do not "simplify" it back to float math.
## Conventions
- **Prettier is enforced through ESLint** (`prettier/prettier: error`): single
quotes, 80-col print width, 2-space indent, semicolons, `es5` trailing commas, LF.
Run `npm run format` before finishing.
- **TS is strict with `noUnusedLocals` / `noUnusedParameters`** — a leftover import
fails the build, not just the lint. Several past commits are just "Remove unused
import"; don't add to them.
- **No path aliases in app code.** `@/` resolves only in
[vitest.config.ts](vitest.config.ts); `tsconfig` has no `paths`. Application
imports are relative (`../../utils/...`).
- **Use the shared constants** in [src/common/constants.ts](src/common/constants.ts)
instead of literals: `EMPTY_STRING`, `ONE_SPACE`, `TIME_*`, `QORT_1_UNIT`,
`ADDRESSBOOK_*`, per-coin `*_FEE`.
- **MUI v7 API**: use `slots` / `slotProps` (`slotProps.input`, `slotProps.htmlInput`,
`slots.transition`), not the deprecated `TransitionComponent` / `inputProps` /
`InputProps` forms.
- **`sx` object keys are kept alphabetically sorted**, with theme-callback values
(`(t: Theme) => ...`) for anything mode-dependent. Reusable styled components live
in [src/styles/page-styles.tsx](src/styles/page-styles.tsx) — prefer them over new
one-off dialogs.
- Components are exported function declarations or typed `React.FC` consts; utility
modules export arrow consts (a recent commit converted exported functions to
consts — follow the local file's style).
## i18n
**Read [.agents/skills/i18n/SKILL.md](.agents/skills/i18n/SKILL.md) before writing or
editing any user-visible string.** Summary:
- Single namespace `core`; locale files are eagerly glob-imported in
[src/i18n/i18n.ts](src/i18n/i18n.ts), so **adding a key means adding it to all 11
files** under [src/i18n/locales/](src/i18n/locales/) (ar, de, en, es, et, fr, it,
ja, pt, ru, zh) — never by hand: use
`python3 .agents/skills/i18n/i18n_add_keys.py <patch.json>`, then
`i18n_apply_translations.py` to translate and `--audit` to verify.
- Legacy keys are stored **lowercase** and capitalized at the call site via custom
post-processors: `t('core:key', { postProcess: 'capitalizeFirstChar' })` (also
`capitalizeAll`, `capitalizeFirstWord`). Newer nested blocks (`wallet`, `send`,
`filters`, `address_book_ui`, `app`) are stored in natural case with no
post-processor — match the block you are editing.
- Never hardcode user-visible English in components.
## Testing
- Vitest + React Testing Library, `jsdom`, globals enabled.
[src/test/setup.ts](src/test/setup.ts) mocks `localStorage`, `navigator.clipboard`
and the whole `qapp-core` module (`Coin`, `useGlobal`, `RequestQueueWithPromise`).
- Tests go in a `__tests__/` folder next to the source, named `[filename].test.ts(x)`
(see [.agents/commands/WRITE_TESTS.md](.agents/commands/WRITE_TESTS.md)).
- `qortalRequest` is a bare global: stub it per test with
`vi.stubGlobal('qortalRequest', vi.fn())`.
- **Pitfall — mocked `useTranslation` must return a stable `t`.** Define
`const t = (k: string) => k` _outside_ the `vi.mock` factory. An inline
`t: (k) => k` gets a new identity every render; components whose effects depend on
`t` (e.g. the QORT name-search effect in `AddressFormDialog`) then loop
synchronously, blocking the event loop so even `--testTimeout` never fires and the
run hangs with no output.
- Prefer `@testing-library/user-event` and role/label queries; the custom render in
[src/test/test-utils.tsx](src/test/test-utils.tsx) wraps components in a MUI theme
and a real i18n instance when you need one.
## Release
Version lives in `package.json`. Pushing to `master` runs
[.github/workflows/release.yml](.github/workflows/release.yml): build → zip `dist/`
→ GitHub release `v<version>`. **The job fails if that tag already exists**, so bump
the version and add a `CHANGELOG.md` entry in the same PR as any release-worthy
change. Work happens on `feature/**` branches merged into `master` by PR; commit
subjects are short and imperative ("Fix overlapping window", "Add tests").