Skip to content

Accounts Refactor PR 1: Adding account -> [credential_id] mapping#2770

Merged
citizen-stig merged 21 commits into
theodore/multisig-upgradefrom
nikolai/accounts-refactor-part-1
May 6, 2026
Merged

Accounts Refactor PR 1: Adding account -> [credential_id] mapping#2770
citizen-stig merged 21 commits into
theodore/multisig-upgradefrom
nikolai/accounts-refactor-part-1

Conversation

@citizen-stig

@citizen-stig citizen-stig commented Apr 21, 2026

Copy link
Copy Markdown
Member

Description

PR 1 of the accounts refactor series. Replaces the legacy credential→address index with an explicit authorization map and an offline data migration, so the new code path is the only authoritative one. Existing chains drain pre-upgrade state in-place via the migration binary before booting this code; nothing in the production read path consults the legacy map anymore.

What changed

Module

  • New state: account_owners: StateMap<(address, credential_id), bool> — the authorization set.
  • New module sov_accounts::migrations — two-phase API (collect_legacy_account_entries + apply_legacy_account_migration) operators wire into their own migration tooling.
  • Genesis and InsertCredentialId write account_owners only.
  • New capabilities: is_authorized(addr, cred) and is_authorized_for(addr, cred). is_authorized_for consults canonical-address + account_owners only — the legacy map is not read.
  • hyperlane-solana-register uses authorize_credential directly.
  • account_owners value is bool instead of () because of rockbound limitations: Refactoring for more explicit sentinel values handling rockbound#50

Removed

  • Accounts::resolve_sender_address and Accounts::resolve_sender_address_read_only — both became no-op pass-throughs after the layer-1 drop; the pass-through is now inlined at the two sov-capabilities call sites and the two Runtime::resolve_address impls.
  • get_account RPC and the Response<Addr> type — the legacy map is no longer queryable from production code.
  • 3 dead resolve tests in sov-accounts/tests/integration/main.rs.

Tombstone

Accounts::accounts is renamed to _accounts and kept as a pub(crate) tombstone. It is not removed: it is the first #[state] field, so removing it would shift the discriminants of enable_custom_account_mappings (1→0) and account_owners (2→1) and corrupt their on-disk data on every chain that has been upgraded through this code. Only crate::migrations may touch it.

Migration tooling

  • examples/demo-rollup/src/migrations/legacy_accounts.rs — new legacy-accounts-migrate CLI binary, modelled on mockda-to-celestia-migrate. Iterates the legacy prefix via NOMT, writes each (credential_id, addr) pair to account_owners as (addr, credential_id) -> true, deletes the source row, and commits at the current head version. NOMT-only; JMT backends are not supported.
  • examples/test-data/genesis/migration-test/accounts.json + README — 6 diverse fixture entries (treasury, lone non-canonical sov1, lone non-canonical EVM, sentinel) for end-to-end testing.

Verification REST API

  • GET /modules/accounts/authorizations/{address}/{credential_id}{"authorized": bool} (reads account_owners).
  • GET /modules/accounts/legacy/{credential_id}{"legacy_address": addr | null} (reads the tombstone so operators can confirm the source map is drained). Was done manually and them removed

Operator upgrade procedure

Existing chains with pre-upgrade accounts entries must run the migration before booting this binary.

  1. Stop the running node.
  2. legacy-accounts-migrate --rollup-config-path … --dry-run — inspect entries_migrated and confirm pre_state_root != post_state_root if there were any entries.
  3. Rerun without --dry-run to commit.
  4. Restart the node.
  5. Optional verification via the REST endpoints (see examples/test-data/genesis/migration-test/README.md).

The migration is idempotent: a second run reports entries_migrated: 0 with the state root unchanged.

Trade-offs / behavioral deltas

  • InsertCredentialId(X) from sender A no longer makes X route to A. It only authorizes X for A in account_owners. When X later signs a V0 tx, the resolver returns X.into() (stateless canonical). Fund the canonical address if X needs to draw gas from A.
  • The "no hijacking" guard is narrower. exit_if_credential_exists checks (sender, credential) in account_owners instead of global credential uniqueness. Authorizing someone else's credential for your own address now succeeds — harmless, since they still need the private key to sign.
  • is_authorized_for no longer consults the legacy map. Operators that skip legacy-accounts-migrate on a chain with pre-upgrade entries will silently lose access for those credentials. This is the intended trade-off: one explicit operator step in exchange for a single authoritative read path.
  • Canonical-address authorization is no longer suppressed. Pre-upgrade, accounts[C] = A was authoritative: is_authorized_for(X, C) returned strictly A == X and bypassed the canonical fallback. Post-upgrade, is_authorized_for(X, C) is true iff X == credential_id.into::<S::Address>() or account_owners[(X, C)] = true, with no suppression. The migration writes account_owners[(A, C)] = true for each legacy row, but for any row where A != canonical(credential_id), the credential gains authorization for canonical(credential_id) after migration in addition to keeping authorization for A; the canonical fallback is computed, not stored, so it cannot be revoked through account_owners. Intended — the simpler "canonical OR explicit" model is the design target. The migration-test fixture exercises this case (multiple credentials sharing one address; one credential bound to an EVM-format address). See sov-accounts/README.md for operator guidance.
  • get_account RPC is gone. Tooling that queried (credential_id) → address in the legacy map must move off it.

Backward compatibility

  • On-chain state: pre-upgrade accounts entries are no longer authoritative. Operators must run legacy-accounts-migrate.
  • Wire: AccountConfig, CallMessage, and tx schema unchanged.
  • Client-visible regressions: get_account removed; Response<Addr> removed.

Known gaps / deferred

  • Runtime::resolve_address trait method (sov-modules-api/src/runtime/mod.rs:157) has no callers in the workspace and both impls are forced default_address pass-throughs. TODO comments at all three sites point to the final PR of the accounts-refactor stack as the place to drop it.
  • The migration binary round-trips chain_state.true_slot_number to satisfy the historical-state DB invariant that User-namespace writes must be paired with Kernel-namespace writes (sov-db/src/historical_state.rs:288). The state root is unchanged by the round-trip; the proper long-term fix is to relax that invariant for the migration commit path.
  • account_2 is preserved in the integration-test fixture with #[allow(dead_code)] for the upcoming target_address PR.

  • Will be done when merging to dev I have updated `CHANGELOG.md` with a new entry if my PR makes any breaking changes or fixes a bug. If my PR removes a feature or changes its behavior, I provide help for users on how to migrate to the new behavior.
  • I have carefully reviewed all my `Cargo.toml` changes before opening the PRs. (Are all new dependencies necessary? Is any module dependency leaked into the full-node (hint: it shouldn't)?)

Linked Issues

Testing

  • New unit tests in sov-accounts/src/migrations.rs: apply_migration_moves_entries_to_owners, apply_migration_empty_input, apply_migration_is_safe_to_repeat.
  • Existing sov-accounts integration tests updated for the new model (get_account/Response references removed; resolve-no-op tests dropped).
  • End-to-end manual test against a populated mock-rollup DB:
    • legacy-accounts-migrate reports entries_migrated: 6, pre/post state roots differ.
    • All 6 fixture (address, credential) pairs return {"authorized": true} post-migration.
    • All 6 fixture credentials return {"legacy_address": null} post-migration.
    • A random unrelated credential against a fixture address returns {"authorized": false}.
  • Broader sweep (per the testing guideline for auth-touching changes): sov-accounts, sov-capabilities, sov-hyperlane-register-module, sov-ethereum, integration-tests — all green.

Docs

sov-accounts/README.md rewritten for the post-migration model (two relations: stateless canonical and account_owners), plus an operator upgrade-procedure section and a tombstone explanation. The migration-test fixture has its own README documenting the end-to-end flow.

@citizen-stig citizen-stig changed the title PR 1: Adding account -> [credential_id] mapping Accounts Refactor PR 1: Adding account -> [credential_id] mapping Apr 22, 2026
Comment thread crates/module-system/module-implementations/sov-accounts/src/call.rs Outdated
Comment thread crates/module-system/module-implementations/sov-accounts/src/call.rs Outdated
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@theodorebugnet theodorebugnet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that this is going to be part of a hard fork upgrade, do we actually need to keep the legacy mapping going forward? The PR description mentions a "DrainLegacyAccounts migration" as follow-up changes, but the code goes to great lengths to define proper handling for the legacy map.

If we're gonna keep the legacy map around that makes sense, but just checking what the plan is because if we're gonna have a migration to remove it then IMO this PR can be massively simplified, as the intermediate steps on the theodore/multisig-upgrade branch don't need to be backwards compatible, as long as we finish adding the migration before we merge this into dev.

@citizen-stig

Copy link
Copy Markdown
Member Author

Given that this is going to be part of a hard fork upgrade, do we actually need to keep the legacy mapping going forward? The PR description mentions a "DrainLegacyAccounts migration" as follow-up changes, but the code goes to great lengths to define proper handling for the legacy map.

That's good question. I kept backwards compatibility to make a change less painful for Zeta's testnet/mainnet, because it is used by hyperlane register module.

But that's good point and worth considering a full removal, but then we need to a migration. But maybe migration is simpler long-term. Let's discuss it on daily.

Comment thread crates/module-system/module-implementations/sov-accounts/README.md Outdated
Comment thread crates/module-system/module-implementations/sov-accounts/README.md Outdated
Comment thread crates/module-system/module-implementations/sov-accounts/README.md Outdated
Comment thread crates/utils/sov-test-utils/src/runtime/macros.rs

@theodorebugnet theodorebugnet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocker for this PR, but I wonder if the migration can be made truly rollup-generic, and then maybe we can have a standalone sov-migrations collection of either binaries or libraries, to make it as easy as possible for any rollup to set up its migration?

Right now, rollup-starter will also need to define a similar migration (esp. if we want to test the upgrade using the starter, which we will going forward IMO), and it'd need to copy a lot of the code currently implemented in demo-rollup.

I think it'd be really great if we could:

  1. Make the migration helper generic on the entire rollup, if possible. This might be cumbersome because there are a lot of types (Runtime, Storage, etc.).
  2. Make a sov-migrations library, or similar.
  3. Rollups (including demo-rollup) just define a thin binary that imports the migration with their own STF types in the generics and runs it, with minimal boilerplate. Similar to how sov-build right now can be invoked by 10-line build scripts or sov-soak-testing-lib can be used to set up soak tests with very little boilerplate.

@citizen-stig

Copy link
Copy Markdown
Member Author

but I wonder if the migration can be made truly rollup-generic, and then maybe we can have a standalone sov-migrations collection of either binaries or libraries, to make it as easy as possible for any rollup to set up its migration?

I think it should be double to make migration fully generic and actual rollup just runs a single function with concrete types. I will do this in the last PR.

For sov-migrations, I think this can be done as a separate issue after this workstream is done

@citizen-stig
citizen-stig merged commit ef63098 into theodore/multisig-upgrade May 6, 2026
51 of 56 checks passed
@citizen-stig
citizen-stig deleted the nikolai/accounts-refactor-part-1 branch May 6, 2026 10:11
@github-actions github-actions Bot locked and limited conversation to collaborators May 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants