This file provides guidance for AI coding assistants (e.g., OpenAI Codex CLI, Claude Code, GitHub Copilot Chat) working with code in this repository. It mirrors the content of CLAUDE.md with model‑neutral language and a few clarifications useful to any assistant.
Rotki is a privacy-focused crypto portfolio management and tax reporting application with:
- Python backend (Flask API, accounting engine, blockchain interactions)
- Vue.js/TypeScript frontend (Electron desktop app + web interface)
- Rust service (Colibri - performance-critical components)
- Node.js 22+, pnpm 10+
- Python 3.14t
- Rust (stable toolchain)
- uv (https://docs.astral.sh/uv/)
# Install JS dependencies at repo root
pnpm install
# (Python) Create/sync virtual env via uv
uv sync
# Run full development environment (frontend + backend + colibri)
pnpm dev
# Run web-only development (no Electron)
pnpm dev:web# Run backend server
uv run python -m rotkehlchen --api-port 4242 --websockets-port 4333
# Run all backend tests
uv run pytest
# Run specific test file
uv run pytest rotkehlchen/tests/api/test_assets.py
# Run specific test
uv run pytest rotkehlchen/tests/api/test_assets.py::test_add_user_asset
# Filter tests with -k
uv run pytest -k add_user_asset
# Lint Python code
uv run make lint
# Format Python code
uv run make formatIMPORTANT: All frontend commands should be run from the frontend/ directory, NOT frontend/app/
# Navigate to frontend directory first
cd frontend
# Install dependencies
pnpm install --frozen-lockfile
# Lint and fix
pnpm run lint:fix
# Run the app
pnpm run dev
# Clean frontend modules
pnpm run clean:modules
# Build frontend
pnpm run build
# Run tests
pnpm run test:unit
# Type check
pnpm run typecheckpnpm dev can start Electron with Chromium's remote debugging endpoint open, so an external CDP
client (Chrome DevTools, connectOverCDP from Playwright or Puppeteer, an MCP devtools server) can
inspect and drive the running app. It is off unless you ask for it.
Set the port in frontend/app/.env.development.local, which is gitignored:
DEBUGGER_PORT=9222pnpm dev then forwards --remote-debugging-port=9222 to the Electron child and logs
starting rotki with args: --remote-debugging-port=9222 at startup. Attach on
http://127.0.0.1:9222.
- Do not put it in
frontend/app/.env: that file is tracked, so the value would apply to everyone. - Electron only.
pnpm dev:webdrops the flag, because in web mode there is no Electron child to pass it to. - It is not allocated per dev instance the way the dev/REST/proxy/colibri ports are, so two
instances started with the same
DEBUGGER_PORTcollide and the second Electron comes up without a debugger. Give each instance its own value if you run more than one at a time. Anything outside 13000-22995 (the instance port block) and clear of 9229 (node--inspect) works; 9222 is Chromium's own default.
The wiring, if you need to change it: getDebuggerPort() in
frontend/scripts/dev/prerequisites.ts reads the variable, startDevServer() in
frontend/scripts/dev/services.ts appends the flag, and frontend/app/scripts/serve.ts passes it
to the spawned Electron process.
An MCP-based assistant can reach the same endpoint through a stdio devtools server:
{
"mcpServers": {
"electron-devtools": {
"type": "stdio",
"command": "npx",
"args": ["chrome-devtools-mcp@latest", "--browserUrl", "http://127.0.0.1:9222"]
}
}
}Keep that configuration outside the repository (user-level assistant settings, or an untracked
.mcp.json above the checkout) so it is not imposed on contributors who do not use the tool.
The frontend gates are slow, and CI is slower than a development machine. Budget against a slow-CI baseline (roughly 4x a fast 16-core workstation), not against your own warm-cache run. Automated assistants must pass an explicit timeout on these commands rather than relying on a short default.
| Command | Allow at least |
|---|---|
pnpm run lint:file <paths>, pnpm run lint:file:check <paths>, pnpm run lint-staged |
2 minutes (usually the default) |
pnpm run test:unit <path> narrowed to one file or folder |
5 minutes |
pnpm run lint, pnpm run lint:all, pnpm run typecheck, pnpm run build, pnpm run test:unit (full suite), pnpm install |
10 minutes |
pnpm run test:e2e, pnpm run test:e2e:shards, pnpm run electron:build, docker builds |
run in the background instead. The unsharded e2e suite takes ~20 minutes locally and the sharded one ~7, both of which outlast any foreground timeout. |
Reference measurements (2026-08-09), so a later reader can tell whether this has drifted. The frontend gates run as separate steps of one "Frontend checks" job (3m50s-5m10s including install), so the CI column names the step rather than a whole job:
| Command | 16-core workstation, warm cache | CI (ubuntu-latest, 4 cores) |
|---|---|---|
pnpm run lint |
54s (multithreaded; 159s before --concurrency=4) |
ESLint step: 110-154s (208-226s before --concurrency=4) |
pnpm run typecheck |
56s | Typecheck step: 59-86s |
pnpm run build |
Build step (build:app): 12-17s |
|
pnpm run test:unit (full) |
114s | "Frontend unit tests / vitest" job: 483-519s |
pnpm run lint:style |
2s | Stylelint step: 2s |
pnpm run check:linked-keys |
1s | Linked i18n keys step: 1s |
pnpm run test:e2e (full, unsharded) |
~20 minutes | 240-637s per shard, across four shards |
pnpm run test:e2e:shards (full, four local shards) |
7.1 minutes, 806 MB peak per shard | not used; CI shards across runners |
Runner speed varies by about 1.4x between runs, and it moves every step together, so compare a step against its own range rather than reading one slow run as a regression.
The Typecheck and Build steps are pull_request-only (github.event_name != 'push'), so a push
straight to a branch never typechecks. ESLint, Stylelint and the i18n key check run on every event.
A command that hits its timeout has not failed, it has been cut off. Re-run it with a larger timeout or in the background. Do not report the truncation as a result, and do not retry at the default timeout on the assumption it was a fluke.
cd colibri
cargo build
cargo run -- --database ../data/global.db --port 4343rotkehlchen/- Main Python packageapi/- REST API and WebSocket handlerschain/- Blockchain integrations (Ethereum, Bitcoin, L2s)exchanges/- Exchange integrations (Binance, Kraken, etc.)accounting/- Tax calculation and accounting logicdb/- Database layer with SQLiteexternalapis/- External service integrationsglobaldb/- Global assets database managementhistory/- Transaction and event history handling
frontend/app/- Vue.js Electron applicationsrc/- Application source codemodules/- Feature modules. Each module groups its components, composables, Pinia stores, and types together (e.g.modules/balances/,modules/dashboard/,modules/settings/). Themodules/core/andmodules/shell/parents hold cross-cutting building blocks.pages/- Route pageslayouts/- Top-level layout componentslocales/- i18n translation filesrouter/- Route definitionsplugins/- Vue/Pinia plugin setup
electron/- Electron main process codetests/unit/- Unit-test setup, fixtures, and mocks (specs themselves are co-located next to the source files insrc/)tests/e2e/- Playwright E2E specs, page objects, and helpers
- API Communication: Frontend communicates with backend via REST API (port 4242) and WebSockets (port 4333)
- Database: SQLite for user data, separate global database for assets
- State Management: Pinia stores in frontend, coordinated with backend state
- Event System: History events are the core abstraction for all blockchain/exchange activities
- Plugin Architecture: Modular design for adding new blockchains and exchanges
- All frontend commands should be run from
frontend/directory, NOTfrontend/app/ - The main application code is in
frontend/app/src/ - New composables and components go under
modules/<domain>/(components inmodules/<domain>/components/), not in a top-levelcomposables/orcomponents/directory. The codebase is organised by module, and new code follows that structure.
- Split complex logic: Break down large templates and script logic into smaller, focused composables
- Component decomposition: Split large components into smaller, reusable sub-components
- Logical separation: Each composable should have a single, well-defined responsibility
- Maintainability focus: Prioritize code readability and maintainability over brevity
- Leave code cleaner than you found it: When touching a file, fix any existing lint warnings in the code you modify. The goal is to steadily reduce the overall warning count over time.
- Avoid type assertions (
as): Prefer type guards,instanceof,satisfies, or discriminated unions overascasts. Type assertions bypass the compiler and hide bugs.Cast-free ways out of the usual traps: build a// ✅ Correct — type guard function isUser(value: unknown): value is User { return typeof value === 'object' && value !== null && 'id' in value; } // ✅ Correct — satisfies (validates shape without widening) const config = { timeout: 5000 } satisfies Partial<Config>; // ✅ Correct — discriminated union if (event.type === 'trade') { /* event is narrowed */ } // ❌ Incorrect — type assertion const user = response.data as User; const element = event.target as HTMLInputElement;
new Map<string, X>(Object.entries(obj))and use.get(key)for dynamic lookups instead ofas Record<string, X>; useReflect.get(obj, key)to read a dynamic field; in unit tests usecreateMock<T>()(see Testing below) instead of a double cast. Keep the assertions that are genuinely unavoidable, one per boundary, each with a one-line justification. Never launder a type throughanyto dodge the lint rule; that is worse than a documentedas. - No barrel files: do not create or extend
index.tsre-export modules. The@rotki/max-dependenciesrule caps a file at 20 imports, and a barrel is not the way to satisfy it. Reduce real imports instead (drop unused ones, or fold a genuinely cohesive group behind one domain composable), or leave the file as it is. max-linescaps.tsfiles at 400 lines (error, from@rotki/eslint-config; specs,.d.ts, and scripts are exempt). The cap is binding: no non-spec.tsfile inapp/srccurrently exceeds it. A file at the ceiling needs to be split before it can grow, so plan the split rather than shaving lines. The rule does not apply to.vuefiles, so nothing stops an SFC from growing without limit. See the SFC size guidance below.@rotki/composable-return-readonly: a composable returning a writableRefmust wrap it inreadonly(). When the ref is intentionally writable because a consumer binds it withv-model,readonly()would break the binding, so prefix the returned name withmodelinstead (for exampleprivacybecomesmodelPrivacy) and the rule skips it. Do not reach foreslint-disable; the existing suppressions are debt to convert, not precedent.
- Use VueUse utilities for reactive state management
- IMPORTANT: Use
get()andset()from VueUse instead of.valuewhen working with refs - Floating Promises: Never use
voidfor floating promises. Instead, usestartPromise()from@shared/utils:// ✅ Correct import { startPromise } from '@shared/utils'; startPromise(someAsyncFunction()); // ❌ Incorrect void someAsyncFunction();
- Always use explicit types for refs:
ref<boolean>(false)instead ofref(false) - Always use explicit types for computed:
computed<boolean>(() => ...)instead ofcomputed(() => ...) - Always return explicit types from functions:
function getName(): string { ... } - Always type reactive variables:
const isLoading = ref<boolean>(false) - Always type computed properties:
const fullName = computed<string>(() => ...) - If a ref type can be undefined and the default value is undefined, Don't explicitly put it as type or default value:
const newId = ref<number>() - Always use
{ useScope: 'global' }parameter foruseI18n():const { t } = useI18n({ useScope: 'global' }); - Never pass
tas a parameter to a composable.useI18nis auto-imported and available everywhere, so a composable that needs translations calls it internally. Passingtin is unnecessary coupling. - Use
as constobjects instead of TypeScript enums for new types, with the type derived from the object. Enums are a legacy pattern here (they tree-shake poorly and infer badly). Existing enums stay as they are unless you are already reworking that file extensively.
// ✅ Correct - Explicit typing with VueUse get/set
import { get, set } from '@vueuse/shared';
const isVisible = ref<boolean>(true);
const count = ref<number>(0);
const items = ref<string[]>([]);
const user = ref<User>();
const { t } = useI18n({ useScope: 'global' });
const isEven = computed<boolean>(() => get(count) % 2 === 0);
const formattedName = computed<string>(() => `${get(firstName)} ${get(lastName)}`);
const newId = ref<number>(); // this newId type is number | undefined.
function getUserById(id: number): User | undefined {
return get(users).find(user => user.id === id) || undefined;
}
function updateCount(newValue: number): void {
set(count, newValue);
}
async function fetchData(): Promise<ApiResponse> {
return await $fetch('/api/data');
}// ❌ Incorrect - Missing explicit types
const isVisible = ref(true);
const count = ref(0);
const items = ref([]);
const user = ref();
const { t } = useI18n();
const isEven = computed(() => count.value % 2 === 0);
const formattedName = computed(() => `${firstName.value} ${lastName.value}`);
const newId = ref<number | undefined>(undefined);
function getUserById(id: number) {
return users.value.find(user => user.id === id) || undefined;
}
async function fetchData() {
return await $fetch('/api/data');
}- VueUse utilities like
get(),set(),toRefs(),computed()etc. are auto-imported - Use Pinia for state management - stores live alongside their feature module as
frontend/app/src/modules/<feature>/use-*-store.ts(e.g.modules/balances/use-balances-store.ts) - TypeScript is strict - ensure proper typing
- Imports
- Definitions (
defineProps,defineEmits, etc.) - I18n & vue-router
- Reactive state variables
- Pinia stores
- Composables
- Computed properties
- Methods
- Watchers
- Lifecycle hooks
- Exposed methods
- Prefer destructured props with inline defaults over
withDefaults:// ✅ Preferred (Vue 3.5+) const { title, count = 0, disabled = false } = defineProps<{ title: string; count?: number; disabled?: boolean; }>(); // ❌ Legacy — avoid in new code const props = withDefaults(defineProps<{ title: string; count?: number; disabled?: boolean; }>(), { count: 0, disabled: false, });
- For mutable default values (arrays, objects), use a factory function:
const { items = () => [], filters = () => ({}) } = defineProps<{ items?: string[]; filters?: Record<string, string>; }>();
- Reactive prop passing rules — destructured props are reactive in
computed(),watch(), and templates, but they are notRef<T>objects. When passing to composables or watchers:const { category, chains, address } = defineProps<{ category: string; chains: string[]; address: string; }>(); // ✅ watch() — wrap in getter watch(() => category, (val) => { ... }); // ✅ MaybeRefOrGetter<T> param — pass as getter const helper = useMyComposable(() => category); // ✅ MaybeRef<T> or Ref<T> param — wrap with toRef const data = useOtherComposable(toRef(() => chains)); // ✅ computed() — use directly (reactive) const label = computed<string>(() => `${category}-${address}`); // ❌ Don't use toRef when a getter suffices (MaybeRefOrGetter) const helper = useMyComposable(toRef(() => category)); // ❌ Don't use toRefs(props) — destructure directly const props = defineProps<{ ... }>(); const { category } = toRefs(props);
- When authoring composables, accept
MaybeRefOrGetter<T>for maximum flexibility and usetoValue()internally to normalize inputs toValue()handles plain values, refs, and getter functions uniformly- Use
onWatcherCleanup()(Vue 3.5+) instead ofonCleanupcallback parameter for cleaner extraction into helper functions
- Use the typed tuple syntax for emit definitions:
const emit = defineEmits<{ 'update:msg': [msg: string]; 'delete': [id: number]; }>();
- Use
defineModelfor all v-model bindings instead of manual prop + emit:// ✅ Correct — defineModel const modelValue = defineModel<string>({ required: true }); const selected = defineModel<number>('selected'); const filters = defineModel<Filters>('filters', { default: () => ({}) }); // ❌ Incorrect — manual prop + emit for v-model const props = defineProps<{ modelValue: string }>(); const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
- Use
useTemplateReffor typed template refs:// ✅ Correct — Vue 3.5+ import { useTemplateRef } from 'vue'; const formRef = useTemplateRef<InstanceType<typeof MyForm>>('formRef'); // ❌ Incorrect — old pattern const formRef = ref<InstanceType<typeof MyForm>>();
<MyForm ref="formRef" />
- Use
$attrsin templates instead ofuseAttrs
The median SFC in app/src is 75 lines and 90% are under 230. Treat ~200 lines as the point to
start splitting and ~300 as the point where you need a reason not to. Unlike .ts files,
.vue files are not covered by the 400-line max-lines rule, so nothing will stop a component from
growing without limit: this is a judgment call the linter does not make for you.
Split along the grain, not to hit a number:
- Logic goes to a composable. Move
<script setup>state and behaviour into ause-*.tsin the same module folder and keep the SFC as wiring. This is the highest-value split, because a composable can be unit-tested directly while template logic cannot. Order what remains as described in "Setup Script Organization (Preferred Order)" above. - Template regions go to child components under
modules/<domain>/components/, when a region is independently meaningful: a row, a summary card, one step of a form.
Signals you are already past the point of splitting: the file needs more than 20 imports (the
@rotki/max-dependencies ceiling), vue/max-template-depth or vue/max-props starts warning,
<script setup> is longer than the template, or one file handles more than one concern (for example
a table plus its edit dialog plus its filters).
Do not split for its own sake. A child component used once that only forwards props adds indirection without reducing complexity; extract the logic instead.
- State definitions
- Computed getters
- Actions
- Optional watchers
Stores are state-only and synchronous. No async/await actions and no API calls inside a
store. A store holds refs, computed getters, and synchronous setters (for example
setSummary(...)). The async fetching that writes into the store belongs in a separate composable
that calls the store's setter. For example use-data-issues-inbox-store.ts holds counts plus an
actionableCount getter and setSummary(), while use-data-issues-summary.ts does the async
refreshSummary() and calls store.setSummary(...). This keeps stores predictable, testable, and
free of side effects.
- Use Tailwind CSS for all styling
- Scoped CSS modules (
<style module>) should only be used for VueTransitionGroupanimations - Do not use scoped SCSS with BEM naming conventions
- For the localization files (en.json, es.json, etc.), the keys should be ordered alphabetically.
- Avoid dynamic keys for translations, as they can break the linter.
Vitest with Vue Test Utils for units and components, Playwright for e2e. This section is the single
home for how to write a frontend test. See "Fast checks via pnpm scripts" immediately below for how
to run one, and ## Testing Strategy for running e2e.
- Co-located next to the source file they test, never in a separate
tests/directory. The.spec.tstakes the name of the file it tests:src/modules/balances/use-balances-store.ts src/modules/balances/use-balances-store.spec.ts - Shared fixtures, mocks and setup stay in
frontend/app/tests/unit/, imported through@test/*. - Test descriptions follow the
it('should ...')pattern.
Component tests are not optional here, and this is the single most important thing in this
section. Measured 2026-08-17 against the coverage push in rotki#11252: .ts files sit at
~86% line coverage, .vue files at ~34%. Covering every remaining line of every .ts file would
still leave the project short of its 70% target, so the uncovered lines that matter are logic living
inside SFCs. Roughly 256 of ~875 specs already mount a component; follow them. Re-measure with
pnpm run test:unit rather than trusting these figures indefinitely.
Extract before you test. A component holding a hundred lines of script is the hardest thing to
test and the thing the SFC size guidance above already says to split. Move the logic into a
use-*.ts beside it, test that directly with no mounting or stubs, and let the component spec cover
the wiring that remains. Testing a fat component first pins its current shape: the spec becomes an
obstacle to the extraction that should have happened first. accounts/management/AccountForm.vue
is the standing example of the cost, with 317 script lines now held in place by a spec.
Name the seam, then test only that. Before writing assertions, decide what this component promises its parent, and put it in a comment at the top of the spec. In practice the seam is some combination of:
- what it renders for a given set of props (
data-testidqueries, not classes or child internals), - what it emits, and with what payload (
wrapper.emitted('update:modelValue')), - what it calls on an injected composable or store, and
- what it disables or enables, which for a form is usually the real validity contract.
modules/auth/login/LoginForm.spec.ts is a good model: it states the seam in a header comment,
types its mocks off the real composables (ReturnType<typeof useSavedProfiles>) so a drift in a
dependency fails the typecheck instead of silently passing a test, and explains which binding the
tests exist to catch.
Do not assert implementation detail. No reaching into wrapper.vm for internal refs, no
asserting the class list of a child component. Those tests break on every refactor and catch nothing.
A mount-only test is worse than no test. mount() alone lights up most of a component's lines,
because v8 counts template lines, so it moves the coverage number while proving nothing. If the only
assertion is that the component rendered, either write a real assertion or skip the file. Pure
presentational wrappers with no logic are legitimately not worth testing.
Unmount what you mount. A spec that leaves wrappers alive leaks reactive instances. Where the
module under test holds a shared module-level ref, leaked components keep reacting to it and their
effects run during later tests, surfacing as calls on a mock the failing test never touched. Track
the wrappers and unmount them in afterEach; ~148 specs already do.
Mocking notes.
- A component rendered inside
RuiDialog(or any teleporting overlay) is not inside the wrapper. Stub the dialog with a pass-through (template: '<div v-if="modelValue"><slot /></div>') so its contents can be queried. happy-domreports every element as zero-height, so anything driven by measured layout (useVirtualList,useElementSize,useFocusWithin) does not behave as it does in a browser. Stub it and assert the configuration you pass it, then check the real behaviour in the app.
- Re-run the full
pnpm run typecheckafter editing any.spec.ts, even if you typechecked earlier in the change. The "Frontend lint" CI job runspnpm run build, andvue-tsc --buildtype-checks specs too, so a greentest:unitplus a greenlint:file:checkdoes not prove the spec compiles. Type your mocks (vi.fn<(id: number) => void>()) so they stay assignable to the callback or DOM method they stand in for. - Narrow types with an assertion, never an
if: anif (!result.ok) { expect(...) }passes silently when the branch is wrong, because the body never runs. Importassertfromvitest, assert the discriminant (which also narrows the following lines), then expect:import { assert, describe, expect, it } from 'vitest'; const result = await useDataIssuesApi().listIssues(payload); assert(!result.ok); // fails loudly, and narrows to the error variant expect(result.error.type).toBe('not-found');
- Shared test helpers live in
frontend/app/tests/unit/and are imported through the@test/*alias (defined intsconfig.vitest.json):createMock<T>(overrides?)from@test/utils/create-mockis the generic stubber. Use it for branded, class, or large types where the test only reads a few fields, instead of aas unknown as Tdouble cast. It returns aProxy, so proxies do not deep-equal: build a real object when you needtoEqual.⚠️ The proxy answers every property, so anything you did not set reads back as an auto-stub, which is truthy. A boolean you omit therefore behaves astrue: leavingisComposingoff a mockedKeyboardEventmakes every key look like it arrived mid-IME composition. Set the booleans your code under test reads, explicitly.mockTfrom@test/i18nis the echo translator.useI18nis globally mocked intests/unit/setup-files/setup.tsto use it, so composables callinguseI18n()gettfor free.- Other fixtures:
@test/mocks/file,@test/utils/create-pinia,@test/utils/events.
- Test selectors: use
data-testid, in components and in queries alike.data-cyis gone from the codebase; do not reintroduce it.@rotki/ui-libraryexposes no test-id attribute of its own, so adata-testidon aRui*component falls through$attrsonto its root element. - e2e: never locate a row by index. Anchoring a history-events assertion on row position makes the test pass for the wrong reason as soon as ordering or fixture data shifts. Anchor on fixture content. Counting rendered rows in a virtualized list asserts the overscan, not the data.
A passing test is not evidence that the code works; it is evidence that the test passed. Before citing a suite as proof, break the thing it covers and watch it go red. Revert the fix, invert the condition, or delete the guard, run the spec, then restore. This costs one extra run and is the only thing that separates a test from a decoration.
- Break the specific line, not the feature. Deleting a whole function fails everything and tells you nothing about which test was doing the work.
- Check the right tests failed, and only those. If fewer fail than you expected, the surplus tests are not testing what their names claim.
- A negative control that passes is a finding. It means the test cannot fail, so it is not pinning the behaviour. Re-aim it rather than shrugging. The usual cause is an assertion that agrees with both the correct and the broken behaviour: asserting a value the buggy path also produces, or hovering the row the keyboard had already moved to.
- One control proves one test. It says nothing about its neighbours.
- Assertions of the form "was not called" and "emitted nothing" are the likeliest to pass for the wrong reason, because an exception thrown mid-handler produces exactly that. Control them first.
pnpm run test:unit, pnpm run lint:file:check, pnpm run lint:style and pnpm run typecheck
are not the whole set. pnpm run knip runs in CI and no local lint script invokes it, so an
export used only inside its own file passes every local gate and fails the PR. Run it before pushing.
All checks are run from the frontend/ directory through the workspace scripts — do not invoke the underlying tools directly. To avoid running everything on every iteration, prefer these script-only paths:
-
Vitest — narrow to a file or folder (already path-aware):
pnpm run test:unit src/modules/dashboard/edit-snapshot/EditSnapshotDialog.spec.ts pnpm run test:unit src/modules/dashboard/edit-snapshot/ # whole folder pnpm run test:unit:watch src/modules/<feature>/<file>.spec.ts
Positional args are forwarded to
vitest run --coverage. No-- --runseparator needed. -
Lint only what you've staged:
git add <paths> pnpm run lint-staged # runs eslint on staged files; stylelint on staged .vue/.scss
This is the canonical fast lint loop and only touches files in the git index. The same hook runs automatically on commit (via Husky), so passing it locally first means the commit won't bounce.
-
Lint specific files (the fast path when they are not staged yet):
pnpm run lint:file <paths> # eslint --fix on those files pnpm run lint:file:check <paths> # report-only, does not mutate
Use
lint:file:checkwhen a fix would be destructive to inspect, for example on locale files, where the unused-keys autofix deletes keys. -
Lint the whole project (full pass):
pnpm run lint # report-only pnpm run lint:fix # auto-fix what it can
-
Never invoke the tools directly.
pnpm exec eslint,pnpm exec vitest, andpnpm exec vue-tscrun with whatever working directory you happen to be in, which silently breaks CWD-relative rule options and config resolution, and manufactures failures that are not real. One concrete case: runningeslint app/src/locales/en.jsonfromfrontend/appmade@rotki/no-unused-i18n-keysresolve itssrcoption to a nonexistentapp/app/src, so it reported all ~3563 keys as unused. The scripts pin the working directory tofrontend/. If no script covers what you need, add one tofrontend/package.jsonrather than reaching forpnpm exec. -
Stylelint is wired the same way:
pnpm run lint-stagedcovers staged.vue/.scss;pnpm run lint:styledoes the full pass. -
Typecheck — incremental, project-wide:
pnpm run typecheck # vue-tsc --build --force; reuses the .tsbuildinfo cache once warmedvue-tsc --buildhas no per-file mode, but after the first cold run subsequent passes only re-check files affected by your change (usually a few seconds). For instant single-file feedback, rely on Volar in the IDE — it uses the sametsconfig.
Typical fast pre-commit loop, all from frontend/:
pnpm run test:unit src/modules/<feature>/<file>.spec.ts # only your spec
git add src/modules/<feature>/<file>.ts src/modules/<feature>/<file>.spec.ts
pnpm run lint-staged # only your files
pnpm run typecheck # incrementalThe contribution guide can be seen here: https://docs.rotki.com/contribution-guides/contribute-as-developer.html
- To add an exchange you will need to add the new exchange under the
exchanges/directory. A nice example is bitfinxex.py - For each exchange you need to implement the basic method of the
ExchangeInterfacesuperclass:- Authentication for the api key/secret whatever the exchange API uses.
- Fetch balances from the exchange
- Fetch deposits/withdrawals (also called asset movements) and trades.
- You will need to create some tests with mocked data
When adding a new setting to the frontend, follow these steps:
Settings are declared once in the settings registry, the single source of truth for a setting's
storage channel, value type, search row and scroll target. SettingsItem and the settings search both
derive from it - there is no hand-maintained search list.
The anchor is the DOM scroll-to target for the settings search. Add one to SettingsHighlightIds
(keep alphabetical). Several settings may share one anchor (a composite, e.g. amount format).
export const SettingsHighlightIds = {
// ... existing entries (keep alphabetically sorted)
MY_NEW_SETTING: 'setting-my-new-setting',
} as const;If the setting needs a new category on its page, also add a SettingsCategoryIds entry.
Add the key to its channel slice (settings-registry-{general,frontend,session,accounting}.ts). The
channel builder validates the wire key against that channel's settings type. Give it the anchor, and -
for a value setting that should appear in search - a search block (title/keywords are msg.$t-branded
i18n keys; category must be declared in SEARCH_CATEGORIES, see step 5):
myNewSetting: general('myNewSetting', {
anchor: SettingsHighlightIds.MY_NEW_SETTING,
search: {
category: SettingsCategoryIds.GENERAL,
titleKey: msg.$t('my_setting.title'),
keywords: [msg.$t('my_setting.subtitle')],
},
}),For a composite anchor (several keys, one highlight) put the search block on ONE representative key.
Use SettingsOption as a wrapper — it handles debounced updates, success/error messages, and API calls.
It accepts setting (backend), frontendSetting, or sessionSetting to target the right store.
<template>
<SettingsOption setting="myNewSetting" :error-message="t('my_setting.error')">
<template #title>{{ t('my_setting.title') }}</template>
<template #default="{ error, success, updateImmediate }">
<RuiSwitch
v-model="value"
:success-messages="success"
:error-messages="error"
@update:model-value="updateImmediate($event)"
/>
</template>
</SettingsOption>
</template>Wrap it in a SettingsItem and pass setting-key — the DOM id is derived from that key's registry
anchor, so the scroll target is single-sourced (no hardcoded :id):
<SettingsItem setting-key="myNewSetting">
<template #title>{{ t('my_setting.title') }}</template>
<template #subtitle>{{ t('my_setting.subtitle') }}</template>
<MyNewSetting />
</SettingsItem>A composite item (several settings under one shared anchor) passes the representative key, e.g.
<SettingsItem setting-key="currency">. An action/info row with no registry value (purge, change
password, db version) declares its settingsActions key instead — <SettingsItem action-key="purgeData">,
or :id="anchorId('rpcNodes')" on a bare section element that is not a SettingsItem. No template
restates a SettingsHighlightIds value.
Search rows are derived; there is no hand-maintained list. A value setting with a search block
(step 2) is surfaced automatically. Otherwise:
- a category new to search → add a
SEARCH_CATEGORIESentry insettings-search-catalog.ts(its tab + header title; setflat: truefor a page whose settings are not nested under a category heading); - a row with no registry value (an action, an info row, or a categoryless section) → add an entry to
settingsActionsinsettings-actions.ts(ananchor+titleKey/keywords; acategoryrow nests under a header, atabrow sits on its tab), then reference it from the template withaction-key="<key>"or:id="anchorId('<key>')".
Create a category component wrapping <SettingCategory> + <SettingsItem>s, then in the page file
(e.g. frontend/app/src/pages/settings/general/index.vue) add the category id to the navigation array
and render the component with :id="SettingsCategoryIds.MY_CATEGORY".
Add all labels, subtitles, and error messages to frontend/app/src/locales/en.json (keys must be
alphabetically sorted). A key referenced only from the registry/catalog must be branded with
msg.$t(...) so the unused-key lint rule counts it as used.
| Purpose | File |
|---|---|
| Highlight (anchor) & category IDs | frontend/app/src/modules/settings/setting-highlight-ids.ts |
| Setting registry (source of truth) | frontend/app/src/modules/settings/settings-registry.ts + settings-registry-<channel>.ts |
| Registry builders & types | frontend/app/src/modules/settings/settings-channels.ts |
| Search category headers | frontend/app/src/modules/settings/settings-search-catalog.ts |
Search action/info rows + anchorId |
frontend/app/src/modules/settings/settings-actions.ts |
| Search deriver | frontend/app/src/modules/settings/use-settings-search.ts |
| Highlight/scroll logic | frontend/app/src/modules/settings/use-settings-highlight.ts |
| i18n key branding helper | frontend/app/src/message-key.ts (msg.$t) |
| Setting update wrapper | frontend/app/src/modules/settings/controls/SettingsOption.vue |
Setting layout wrapper (setting-key/action-key → id) |
frontend/app/src/modules/settings/controls/SettingsItem.vue |
| Category visual grouping | frontend/app/src/modules/settings/SettingCategory.vue |
| Settings pages | frontend/app/src/pages/settings/*/index.vue |
Routing uses the typed, file-based router (unplugin-vue-router). Route names are generated from the file path (e.g. pages/balances/blockchain/index.vue -> '/balances/blockchain/'); do not add custom name: overrides. Navigate by typed name: router.push({ name: '/balances/blockchain/' }) or <RouterLink :to="{ name: '/balances/blockchain/' }">. The drawer, the global search palette and sub-page tab bars are all derived from route meta - there is no route registry to maintain.
Add navigation for a page by declaring nav in its definePage:
definePage({
meta: {
nav: {
labelKey: 'navigation_menu.balances_sub.blockchain_balances', // i18n key (required)
icon: 'lu-blockchain', // RuiIcons (required)
parent: '/balances/', // logical parent: drawer nesting + search breadcrumb
order: 10, // order among siblings
section: 1, // drawer top-level section (dividers between sections); omit for sub-items
drawer: 'balances-blockchain', // present => shown in the drawer; value is the entry's data-key test selector
searchable: false, // optional: exclude from the (default-on) search palette
keywords: ['some.i18n.alias'], // optional: extra i18n keys the search palette matches
},
},
});Rules of thumb:
- Drawer entry: set
drawer(the test id). Top-level items also setsection+order; sub-items setparent+order(the parent route must also have anav).useNavigationMenubuilds the tree. - Search palette: every route with
navis searchable automatically (the superset of the drawer) unlesssearchable: false. The breadcrumb is thenav.labelKeyof the route named byparent.useRouteSearchbuilds it;GlobalSearch.vuerenders it. - Sub-page tab bars: use
useChildNavTabs('/parent/')when the bar is every child of a parent (e.g. price-manager), oruseNavTabs(['/a/', '/b/'])for a specific subset/order (e.g. asset-manager/more). Both live inuse-nav-tabs.ts. - Settings tabs: each tab's route/label/icon comes from its page
nav; the per-setting search rows are derived from the settings registry +settings-search-catalog.ts(no hand-maintained per-tab list).
| Purpose | File |
|---|---|
nav meta type |
frontend/app/src/types/router.d.ts |
| Drawer derivation | frontend/app/src/modules/shell/layout/use-navigation-menu.ts |
| Search derivation | frontend/app/src/modules/shell/layout/use-route-search.ts |
| Tab bars from meta | frontend/app/src/modules/shell/layout/use-nav-tabs.ts |
As an example decoder, we can look at MakerDAO.
It needs to contain a class that inherits from the DecoderInterface and is named ModulenameDecoder.
Note: If your new decoder decodes an airdrop's claiming event and this airdrop is present in the data repo airdrop index with has_decoder as false, please update that also.
When you need to check a contract ABI, use Sourcify's repository URL format:
https://repo.sourcify.dev/<chainID>/<contract_address>.
For example, for address 0x3337286E850cf01B8A8B6094574f0dd6a2108B16 on chain ID 1, check https://repo.sourcify.dev/1/0x3337286E850cf01B8A8B6094574f0dd6a2108B16.
For raw ABI data, read the verified metadata JSON at https://repo.sourcify.dev/contracts/full_match/<chainID>/<contract_address>/metadata.json and use output.abi.
- Prefer
addresses_to_decoders()over genericdecoding_rules()whenever a protocol emits identifiable logs from known contract addresses. - Use
decoding_rules()only as a last resort when no reliable address/topic/input selector scoping exists.
Why:
decoding_rules()are evaluated for every log in every transaction on that chain, which increases per-log decoding overhead.addresses_to_decoders()restricts execution to logs from relevant protocol contracts, reducing unnecessary rule invocations and improving decode throughput.- Narrow-scoped decoders also reduce false positives and make behavior easier to reason about.
- When creating an
ActionItemfrom a log handler, prefer using data already available in the currentcontext.tx_logfor matching fields (amount,asset,location_label,to_address) whenever possible. - Do not iterate
context.all_logsjust to rediscover transfer data if the current log already provides the same amount/address relation. - Only scan
context.all_logswhen correlating multiple distinct logs is strictly required.
Why:
- Reduces per-transaction work and decoder complexity.
- Avoids introducing fragile cross-log assumptions.
- Keeps action-item transformations deterministic and easier to review.
If a chain cannot be queried via explorer/indexer APIs (etherscanscan / routescan / blockscout etc ), do not stop. Use this fallback flow:
- Add a public RPC node for the chain in the test via
*_manager_connect_at_start+WeightedNode(NodeName(...)). - Query tx/receipt/logs from RPC directly (not from explorer APIs).
- If internal txs are not needed for the specific decoder path, patch:
rotkehlchen.chain.evm.transactions.EvmTransactions._query_and_save_internal_transactions_for_range_or_parent_hashto return[]. - Keep the test focused on decoded events from logs/transfers.
- Prefer deterministic assertions and exact tx-hash regression tests.
- Do not block on explorer availability; only ask the user if RPC data is insufficient.
Example intent: Base currently may fail on explorer/indexer paths; use https://mainnet.base.org until indexers recover.
It needs to implement a method called counterparties() which returns a list of counterparties that can be associated with the transactions of this module. Most of the time these are protocol names like uniswap-v1, makerdao_dsr, etc.
These are defined in the constants.py file.
The addresses_to_decoders() method maps any contract addresses that are identified in the transaction with the specific decoding function that can decode it. This is optional.
The decoding_rules() define any functions that should simply be used for all decoding so long as this module is active. This is optional.
The enricher_rules() define any functions that would be used as long as this module is active to analyze already existing decoded events and enrich them with extra information we can decode thanks to this module. This is optional.
In very simple terms, the way the decoding works is that we go through all the transactions of the user and we apply all decoders to each transaction event that touches a tracked address. The first decoder that matches creates a decoded event.
The event creation consists of creating a HistoryBaseEntry. These are the most basic form of events in rotki and are used everywhere. The fields as far as decoded transactions are concerned are explained below:
group_identifieris always the transaction hash. This identifies history events in the same transaction.sequence_indexis the order of the event in the transaction. Many times this is the log index, but decoders tend to play with this to make events appear in a specific way.assetis the asset involved in the event.balanceis the balance of the involved asset.timestampis the Unix timestamp in milliseconds.locationis the location. Almost alwaysLocation.BLOCKCHAINunless we got a specific location for the protocol of the transaction.location_labelis the initiator of the transaction.notesis the human-readable description to be seen by the user for the transaction.event_typeis the main type of the event. (see next section)event_subtypeis the subtype of the event. (see next section)counterpartyis the counterparty/target of the transaction. For transactions that interact with protocols, we tend to use theCPT_XXXconstants here.
Each combination of event type and subtype and counterparty creates a new unique event type. This is important as they are all treated differently in many parts of rotki, including the accounting. But most importantly this is what determines how they appear in the UI!
The mapping of these HistoryEvents types, subtypes, and categories is done in rotkehlchen/accounting/constants.py.
- Source of truth: copy exact on-chain/API
0x...hex. - Validation step (during implementation): convert with
bytes.fromhex(hex_str.removeprefix('0x'))and verify round-trip:hex_str = value.removeprefix('0x')const = bytes.fromhex(hex_str)assert const.hex() == hex_str.lower()
- Final committed form: all event topics, method selectors, hashes, and byte signatures must be byte literals (for example
TOPIC: Final = b'\xdc\xbc\x1c\x05...\xd7'). - Do not keep
bytes.fromhex(...)in final constant definitions. - If useful for readability, keep the canonical
0x...value as a comment next to the byte literal. - Don't put assets as constants. If you need a constant just use the asset identifier as a string and compare against it.
rotki has two SQLite databases that each carry a version and an ordered list of upgrade scripts: the user DB and the global DB. The same rule applies to both — when you need a schema change (add/alter a table, add a settings row, backfill data) do not blindly create a new upgrade and bump the version. First check whether the latest existing upgrade has already been released:
- Find the highest
vN_vN+1.pyupgrade (the one matching the current version constant — see the per-DB table below). - Check whether that upgrade is already released. Read its docstring (it says e.g. "This happened in 1.44" / "This upgrade takes place in v1.44.0") and compare against the latest released rotki version (
docs/changelog.rst/ git tags). - If the latest upgrade is already released → create a new
vN_vN+1.py, bump the version constant, register it in the upgrades list, add a test, and (global DB only) extend theLiteralintests/utils/globaldb.py::patch_for_globaldb_upgrade_to. - If the latest upgrade is still unreleased (same upcoming version as your change) → add your step to that existing upgrade instead of creating a new one. Do NOT bump the version and do NOT add a new upgrade file. Add an assertion for your change to that upgrade's existing test rather than a separate test.
Per-DB locations:
| User DB | Global DB | |
|---|---|---|
| Version constant | ROTKEHLCHEN_DB_VERSION in rotkehlchen/db/settings.py |
GLOBAL_DB_VERSION in rotkehlchen/globaldb/utils.py |
| Upgrade scripts | rotkehlchen/db/upgrades/vN_vN+1.py |
rotkehlchen/globaldb/upgrades/vN_vN+1.py |
| Upgrades list | UPGRADES_LIST in rotkehlchen/db/upgrade_manager.py |
UPGRADES_LIST in rotkehlchen/globaldb/upgrades/manager.py |
| Fresh-create schema | rotkehlchen/db/schema.py |
rotkehlchen/globaldb/schema.py |
Always keep the fresh-create schema (schema.py) in sync with the upgrade so newly created DBs match the upgraded state.
Global DB only — extra hazard: the global DB also ships as a packaged file (rotkehlchen/data/global.db) in the rotki/data submodule, which is only regenerated/bumped per release. Bumping GLOBAL_DB_VERSION ahead of that packaged DB makes its version (e.g. 17) mismatch the code (e.g. 18), which breaks soft_reset_assets_list/hard_reset_assets_list (version-equality guard) and the packaged-DB consistency tests — exactly the kind of "tests fail on develop" caused by an unmerged data-repo change. This is the strongest reason to fold into the unreleased upgrade rather than create a premature new version.
When editing backend Python and tests, follow these preferences unless explicitly told otherwise:
- Prefer narrow exceptions.
- Do not use
except Exception. - Catch the concrete error type used by the surrounding code path (e.g.
RemoteErrorfor rpc/multicall).
- Prefer inline one-time assignment via walrus operator.
- If a variable is used only once in a small local scope, inline it with
:=instead of introducing a standalone line. - Apply this especially in tests for constants like
timestamp,amount_str,gas_str,user_address. - Example:
location_label=(user_address := ethereum_accounts[0]).
- Avoid unnecessary temporary locals.
- If a value is only used once and readability is preserved, inline it.
- Keep code compact and avoid “setup variable blocks” in tests.
- Keep existing codebase idioms first.
- Match nearby file style even if generic Python style differs.
- For rotki tests, prefer concise expected-event construction with inline assignments where practical.
When a user mentions the "accounting refactor", "accounting buckets", "balance buckets", or issue/PR #12204, assume they are referring to the historical balance engine in rotkehlchen/tasks/historical_balances.py and its event_metrics bucket tracking, not the legacy accounting pot/cost-basis code unless they explicitly say cost basis or PnL accounting.
Key files:
rotkehlchen/tasks/historical_balances.py— bucket derivation and event metric generation.rotkehlchen/balances/historical.py— historical balance query/read API.rotkehlchen/db/schema.py—event_metricsschema.rotkehlchen/db/history_events.py— stale marker invalidation when events change.rotkehlchen/tests/unit/test_historical_balances.pyandrotkehlchen/tests/api/test_historical_balances.py— primary tests.
- Uses pytest, with concurrency via rotkehlchen.concurrency tasks (threads)
- Extensive fixtures in
rotkehlchen/tests/fixtures/ - Mock external APIs for deterministic tests
- Database fixtures for integration testing
- Make sure that all EVM addresses constant literals you add in the code are properly checksummed. The output of to_checksum_address() is what they should be. Do not use string_to_evm_address(). This does not checksum the address.
- Do not VCR tests. Let the human developers do it. That means do not put
@pytest.mark.vcron any decoder tests you write. - For Etherscan use the api key from ETHERSCAN_API_KEY env variable and use etherscan v2. It's as v1 but using https://api.etherscan.io/v2/api?chainid=${chainid} . As described here: https://docs.etherscan.io/etherscan-v2. If there is no API key in the env variable then prompt the user for it when you need to query etherscan.
How to write a frontend test is documented once, under "Testing" in the Frontend Development Guidelines above. That covers unit specs, component specs (including why they are mandatory and how to test a component that is not pure display), negative controls and the gates. Do not restate it here; a second copy drifts from the first.
Layout only:
- Unit and component specs are co-located in
frontend/app/src/; fixtures, mocks and setup are infrontend/app/tests/unit/. - Playwright e2e lives in
frontend/app/tests/e2e/:specs/,pages/for page objects,helpers/.
The backend is single-user, so one Playwright run is pinned to workers: 1 and takes about
twenty minutes. pnpm run test:e2e:shards starts several complete stacks side by side instead -
each with its own starling supervisor, rpc mock, preview server, port block and data directory -
and gives each one a shard of the suite. The whole suite takes about seven minutes across four
shards, at roughly 800 MB peak per shard.
cd frontend
pnpm run test:e2e:shards # four shards
pnpm run test:e2e:shards -n 2 # two
pnpm run test:e2e:shards -n 2 tests/e2e/specs/app/tag-manager.spec.ts # only these specs
pnpm run test:e2e:shards -n 2 -- --grep @slow # raw playwright flagsSpec paths are positional, so no -- is needed for them; pnpm forwards arguments verbatim.
A -- separates flags meant for Playwright rather than for the runner.
Useful to know:
- Shard N takes the port block at offset
N * 10, so the base block stays free and a plainpnpm test:e2ecan run alongside a sharded one. - Every shard is served the same bundle, built once with an empty
VITE_BACKEND_URLso it addresses the backend same-origin; each preview server proxies/api/,/ws/and/colibri/to its own shard's starling. Those proxy keys are anchored regexes on purpose: a bare/apialso matches the/api-<hash>.jschunk and the app renders a blank page. - Each shard starts from a pristine copy of the packaged global database, so manual prices and other global-database residue cannot leak between runs.
- Per-shard output lands in
.e2e/shard-N/logs/shard.log; the terminal only gets summary and failure lines unless--verboseis passed. The blob reports are merged into oneplaywright-reportat the end.
# Package for distribution (requires proper environment setup)
python package.pypyproject.toml- Python project configuration, linting rulesfrontend/app/package.json- Frontend dependencies and scriptsMakefile- Common development tasks.github/workflows/- CI/CD pipelines
- Run backend tests directly with
uv run pytest - Frontend uses strict TypeScript - ensure types are properly defined
- Follow existing code patterns - the codebase has established conventions
- Use the existing test infrastructure - comprehensive fixtures are available
- WebSocket messages follow specific format - check
api/websockets/typedefs.py - For all python backend constants make sure to use the
Finaltype specifier. - Never iterate over
cursor.execute(...).fetchall().fetchall()already walks the cursor and materializes every row into a list, so a followingforloop iterates the data a second time. When you only need a single pass, iterate the cursor directly:for row in cursor.execute(...):. Reservefetchall()for when you genuinely need the materialized list (e.g. to reuse it, get its length, or assert on it in a test). The same applies to a write cursor — prefer a read cursor (self.conn.read_ctx()) for plainSELECTs.
- Commits should be just to the point, not too long and not too short.
- Commit titles should not exceed 50 characters.
- Give a description of what the commit does in a short title. If more information is needed, then add a blank line and afterward elaborate with as much information as needed.
- Commits should do one thing; if two commits both do the same thing, that's a good sign they should be combined.
- Do not add Co-Authored-By entries for any AI tool.
- Do not add Co-Authored-By entries for any AI tool.
- Frontend build fails: Run
pnpm run clean:modulesthenpnpm install --frozen-lockfile - Backend test failures: Re-run the relevant test directly with
uv run pytest - WebSocket connection issues: Check ports 4242 (API) and 4333 (WS) are free
When reviewing rotki code, follow these principles to avoid false positives:
- Do NOT assume error handling is missing without tracing function implementations
- Do NOT assume validation is missing without checking type definitions
- Do NOT assume patterns are incorrect without understanding project conventions
- ALWAYS trace function calls to their actual implementations
request_get()- HTTP wrapper that handles all errors internallyglobaldb_get_*()- Database functions with built-in error handlingget_or_create_evm_asset()- Asset creation with validation- Functions from
rotkehlchen.utils.networkgenerally handle errors
ChainID- Enum type with compile-time validationChecksumEvmAddress- Validated on constructionAsset- Type-safe asset representationTimestampMS- Type-safe timestamp- Any
Finaltyped constants are immutable
For each issue identified:
- Provide exact code line showing the problem
- Trace the full code path to verify issue exists
- Check if utility functions handle the concern
- Verify type system doesn't provide guarantees
- Explain why existing code doesn't mitigate it
- Flagging missing try-catch around
request_get() - Suggesting ChainID validation when it's an enum
- Assuming KeyError isn't caught without checking try-except blocks
- Recommending error handling that exists in called functions
When reviewing code changes, follow this systematic approach:
- Check for pending review comments first - Look for any unresolved comments from repository maintainers or other reviewers
- Line-by-line examination - Read every changed line carefully, don't skim or make assumptions
- Error handling verification:
- Check if dictionary/list access handles KeyError/IndexError
- Verify API responses handle missing or malformed data
- Ensure error paths have appropriate logging
- Code efficiency:
- Look for unnecessary comparisons or redundant operations
- Check for optimizable conditions
- Verify no unnecessary loops or repeated calculations
- Logging completeness:
- Error conditions should be logged with context
- Empty else/except blocks should explain why they're empty
- Style and formatting:
- Check spacing in strings and error messages
- Verify consistent formatting with project standards
- Edge case analysis:
- What happens with empty inputs?
- How are None/null values handled?
- Are array bounds checked?
- Test coverage - Verify new functionality has appropriate tests
- EVM addresses MUST be checksummed: ✅ CORRECT: '0x5A0b54D5dc17e0AadC383d2db43B0a0D3E029c4c' ❌ WRONG: '0x5a0b54d5dc17e0aadc383d2db43b0a0d3e029c4c'
- If you see "Invalid XXX account in DB" it's almost certain the address is not checksummed. Always checksum addresses you use with to_checksum_address
string_to_evm_address()is just a no-op typing function. It will not checksum the literal argument to a checksummed evm address. That means you should make sure to only give checksummed EVM address literals to it