12 KiB
AGENTS.md
Notes for AI agents working in this repository. Human-facing product docs live in README.md; user-visible changes go in 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 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, andpostMessageevents (NAVIGATE_TO_PATH,THEME_CHANGED,LANGUAGE_CHANGED) — see 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 devboots the UI, but anything touchingqortalRequestfails. 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 testisvitestin watch mode — in an interactive TTY it will not exit. Always usenpx vitest runwhen running tests non-interactively.npm run build=tsc -b && vite build, then copiesCHANGELOG.mdintodist/(the in-app changelog viewer fetches it at runtime).- CI (.github/workflows/npm_tests.yml) runs only
npm teston PRs tomasterandfeature/**. 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
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, ltc,
doge, dgb,
rvn, arrr) 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, ~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):
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):
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. The QORT pattern
/^Q[1-9A-HJ-NP-Za-km-z]{33}$/ is additionally duplicated in
AddressFormDialog.tsx and the
QORT page. Qortal names are also checked for invisible/spoofing characters via
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,es5trailing commas, LF. Runnpm run formatbefore 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;tsconfighas nopaths. Application imports are relative (../../utils/...). - Use the shared constants in 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 deprecatedTransitionComponent/inputProps/InputPropsforms. sxobject 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 — prefer them over new one-off dialogs.- Components are exported function declarations or typed
React.FCconsts; 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 before writing or editing any user-visible string. Summary:
- Single namespace
core; locale files are eagerly glob-imported in src/i18n/i18n.ts, so adding a key means adding it to all 11 files under src/i18n/locales/ (ar, de, en, es, et, fr, it, ja, pt, ru, zh) — never by hand: usepython3 .agents/skills/i18n/i18n_add_keys.py <patch.json>, theni18n_apply_translations.pyto translate and--auditto verify. - Legacy keys are stored lowercase and capitalized at the call site via custom
post-processors:
t('core:key', { postProcess: 'capitalizeFirstChar' })(alsocapitalizeAll,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 mockslocalStorage,navigator.clipboardand the wholeqapp-coremodule (Coin,useGlobal,RequestQueueWithPromise). - Tests go in a
__tests__/folder next to the source, named[filename].test.ts(x)(see .agents/commands/WRITE_TESTS.md). qortalRequestis a bare global: stub it per test withvi.stubGlobal('qortalRequest', vi.fn()).- Pitfall — mocked
useTranslationmust return a stablet. Defineconst t = (k: string) => koutside thevi.mockfactory. An inlinet: (k) => kgets a new identity every render; components whose effects depend ont(e.g. the QORT name-search effect inAddressFormDialog) then loop synchronously, blocking the event loop so even--testTimeoutnever fires and the run hangs with no output. - Prefer
@testing-library/user-eventand role/label queries; the custom render in 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: 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").