Skip to content

Accounts Refactor PR 3: Addinng more call messages to accounts module#2773

Merged
citizen-stig merged 8 commits into
theodore/multisig-upgradefrom
nikolai/accounts-refactor-part-3
May 12, 2026
Merged

Accounts Refactor PR 3: Addinng more call messages to accounts module#2773
citizen-stig merged 8 commits into
theodore/multisig-upgradefrom
nikolai/accounts-refactor-part-3

Conversation

@citizen-stig

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

Copy link
Copy Markdown
Member

Description

sov_accounts::CallMessage becomes generic in S: Spec and gains three variants:

  • AddCredentialToAddress { address, credential } — authorize a new credential on an owned address.
  • RemoveCredentialFromAddress { address, credential } — revoke.
  • RotateCredentialOnAddress { address, old_credential, new_credential } — atomic swap (functionally Remove + Add, but one tx, one signing round for a multisig).

InsertCredentialId semantics are unchanged. All three new variants share one rule: context.sender() == message.address.

Why

#2770 added the account_owners map but only wrote to it at genesis and through InsertCredentialId.
PR 2 made V0 and V1 signers declare a address_override.
Without this PR, a multisig that rotates its keys can't keep its address — there's no way to add a new authorized credential or revoke the old one.
Rotate is the primitive that makes M1 → M2 a single operator-friendly tx instead of two.

Tradeoffs / design calls worth reviewing

  1. Ownership check simplified to context.sender() == address instead of the plan's "caller's credential authorizes for address" lookup. Context doesn't expose sender_credential_id(), and PR 2's resolver already proves the caller controls sender — so the simpler rule is equally safe and subsumes the "bootstrap" case.
  2. Revocation writes account_owners[(addr, cred)] = false, not a delete. false is a durable revocation that blocks the stateless canonical fallback (credential.into() == address). Without it, removing a credential from its own default address would be silently re-authorized by the fallback. The storage model flips from "present-means-authorized" to "explicit true/false override".
  3. No legacy check on Add. A credential with a pre-upgrade accounts[c] = { addr } entry can be added to a different new-model address. V0 routing still uses legacy; V1+target=Some uses the new map. This avoids making PRs 3/4 useless on upgraded chains before PR 5's DrainLegacyAccounts runs.
  4. No orphan guard on Remove/Rotate. Revoking the last credential is allowed; caller is responsible. Apps that want a guard add it at their own layer.
  5. No events, no mirror maps. Deferred to PR 5 (along with enumeration queries).

Wire / consensus

  • CallMessage schema grows → chain hash changes. Bundled with PR 2's chain-hash bump (no new grace period needed if PR 2's override is still active).
  • V0 and V1 tx formats unchanged in this PR.
  • mock_da.sqlite resync fixture regenerated.

Adjacent changes (outside sov-accounts)

  • sov-capabilities/src/lib.rs: when auth_data.address_override is None, the resolver now explicitly calls is_authorized_for() on the canonical default address. This is what gives the new false revocation its end-to-end teeth — without this check, a revoked credential would still pass authorization via its canonical-address fallback.
  • TypeScript serializer.ts: serializeUnsignedTx() now auto-wraps plain unsigned-tx inputs as { V0: input } unless already versioned (new isVersionedUnsignedTx() guard). Wire format itself is unchanged.
  • TypeScript signer/schema tests updated for the V0 wrapping, new uniqueness: { generation } nesting, and MultiAddressMultiAddressEvmSolana rename.
  • sov-address/src/evm/address.rs: two roundtrip tests for credential ↔ address conversion (credential_from_evm_address_stays_standard, credential_from_native_hash_stays_standard).

--

  • 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 tests added

Docs

Docstrings are updated

@citizen-stig
citizen-stig force-pushed the nikolai/accounts-refactor-part-2 branch from 920da01 to 736575b Compare April 22, 2026 12:25
@citizen-stig
citizen-stig force-pushed the nikolai/accounts-refactor-part-3 branch from a7156ff to b48875f Compare April 22, 2026 12:27
@citizen-stig
citizen-stig marked this pull request as ready for review April 22, 2026 13:12

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 1 potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

@citizen-stig
citizen-stig force-pushed the nikolai/accounts-refactor-part-3 branch from 3c9bfcf to 22aac29 Compare April 22, 2026 13:32
devin-ai-integration[bot]

This comment was marked as resolved.

@citizen-stig
citizen-stig force-pushed the nikolai/accounts-refactor-part-3 branch from 22aac29 to bbe926a Compare April 22, 2026 13:47
Comment thread crates/module-system/sov-capabilities/src/lib.rs Outdated
Comment thread crates/module-system/sov-capabilities/src/lib.rs Outdated
@citizen-stig

Copy link
Copy Markdown
Member Author

New trait discussion:

Context

PR #2773 made is_authorized_for a mandatory gate on the V0 tx pre-exec
path at crates/module-system/sov-capabilities/src/lib.rs:83-101.
is_authorized_for's third layer (canonical fallback) compares
S::Address::from(credential_id) == address.

Pre-fix, the generic blanket
impl From for MultiAddress
in sov-address/src/lib.rs unconditionally produced
Self::Standard(Address::from(value)). But EVM txs resolve to
MultiAddress::Vm(EthereumAddress) via the authenticator at
sov-evm/src/authenticate.rs:222 (S::Address::from_vm_address(eth_addr)).
Two different enum variants — never equal. Result: every V0 EVM tx got
rejected with
credential 0x…15d34…6a65 not authorized for resolved address 0x15d34…6A65,
producing ~204 failures in the last CI run (nextest + nextest_all_features
on commit bbe926a, job 72515492442).

Before PR 3, this mismatch was latent — is_authorized_for wasn't on any
production hot path. V0 skipped authorization entirely; V1 only used Layer 1
(is_authorized, no canonical fallback). PR 3 wired is_authorized_for in,
surfacing the latent bug.

What HEAD already has

Commit 71e96cf ("Attempt to fix one:") at the current branch HEAD implements
what the predecessor plan (swift-foraging-storm.md) called Option D:

  • New trait TryDecodeCredentialId at
    crates/module-system/sov-address/src/lib.rs:190-192.
  • Bound added to the generic blanket:
    impl<VmAddress: TryDecodeCredentialId> From for MultiAddress
    at sov-address/src/lib.rs:49-56, which now returns Self::Vm(vm) on Some
    or falls back to Self::Standard(Address::from(value)).
  • Impl for EthereumAddress with the 12-zero-prefix check at
    sov-address/src/evm/address.rs:148-161.
  • Demo-stf's From for MultiAddressEvmSolana at
    examples/demo-rollup/stf/stf-declaration/src/address.rs:55-67 calls
    EthereumAddress::try_decode_credential_id(value).
  • Two new unit tests in sov-address/src/evm/address.rs at lines 173-190:
    credential_from_evm_address_roundtrips_to_vm_multi_address and
    credential_from_native_hash_stays_standard.

Status verified locally:

  • cargo check -p sov-address --all-features → clean.
  • Only MultiAddress (MultiAddressEvm) is used in production;
    MultiAddressEvmSolana has a direct From impl that bypasses the new bound.
    No other VmAddress types are plugged into the generic.
  • Trace: for EVM credential with 12-zero prefix,
    S::Address::from(cred) now returns MultiAddressEvmSolana::Evm(eth_addr),
    which matches S::Address::from_vm_address(eth_addr) produced by the
    authenticator. Layer-3 check passes. ✓

The red CI is stale — it ran on bbe926a (before the fix). HEAD
71e96cf has not been pushed / re-CI'd yet.

The three realistic options

Option 1 — Keep current (TryDecodeCredentialId trait)

The fix at HEAD. Already committed, compiles, tracing confirms correct.

Pros

  • Fixes the bug exactly where it lives — the MultiAddress routing
    layer. Each VmAddress owns its own decoding knowledge.
  • Preserves Spec::Address: From (the trait bound at
    crates/module-system/sov-modules-api/src/module/spec.rs:62, replicated in
    configurable_spec.rs:114,138,171). Zero framework-level churn.
  • Extensible: future VmAddress types (Solana, Cosmos, …) just impl the trait.
  • Two unit tests already land alongside the fix, covering both branches.
  • MultiAddressEvmSolana can be tightened to reuse the same
    EthereumAddress::try_decode_credential_id(...) — already done at
    demo-rollup/stf/stf-declaration/src/address.rs:62.

Cons

  • One more public trait on sov-address for a narrow decoding concern.
    Currently only one impl exists (EthereumAddress).
  • Logical duplication: HyperlaneAddress::from_sender at
    hyperlane/src/lib.rs:260-272 already performs the identical 12-zero
    check for Hyperlane wire encoding. Two independent code paths; drift
    possible if rules evolve. Mitigation: the trait's doc comment at
    sov-address/src/lib.rs:183-189 documents the encoding invariant.

Option 2 — Inline check in sov-address + use HyperlaneAddress::from_sender in demo-stf

Drop the new trait. Inline a ~3-line bytes[..12] == [0u8; 12] check directly
inside the generic blanket in sov-address/src/lib.rs, with a comment pointing
at HyperlaneAddress::from_sender as the canonical implementation. In demo-stf,
call EthereumAddress::from_sender(HexHash::from(cred.0)) directly (the import
is already present at stf-declaration/src/address.rs:14).

Pros

  • Zero new trait — smallest API surface delta.
  • Demo-stf reuses an existing, already-tested primitive (hyperlane tests at
    hyperlane/src/lib.rs:361-390).
  • Aligns with CLAUDE.local.md preference ("Always prefer the simplest approach
    first").

Cons

  • Splits responsibility across two crates — sov-address has its own inline
    check, demo-stf delegates to hyperlane. The 12-zero invariant now lives in
    two places in source code. Comments help; they don't prevent drift.
  • From semantics in demo-stf would quietly depend on a
    Hyperlane trait — domain leak. An STF reader expects credential-decoding
    logic, not message-passing.
  • The underlying dependency cycle (sov-address ↔ sov-hyperlane) is the root
    reason we can't unify — Option 2 accepts the duplication as the cost.

Option 3 — Replace From for EthereumAddress with TryFrom

Delete the unconditional impl From for EthereumAddress at
sov-address/src/evm/address.rs:45-49 and replace with a TryFrom that rejects
non-EVM-shaped credentials. Change the generic blanket to
impl<VmAddress: TryFrom> From for MultiAddress.

Pros

  • Uses stdlib TryFrom — no new public trait.
  • Fail-closed at the type level: impossible to silently produce a garbage
    EthereumAddress from a non-EVM credential.

Cons

  • Breaks Spec::Address: From at
    sov-modules-api/src/module/spec.rs:62 indirectly. The generic blanket
    can still provide that impl, but only via VmAddress: TryFrom.
    All VmAddress types (e.g. Base58Address at
    sov-modules-api/src/common/address.rs) that today have
    From would gain TryFrom<CredentialId, Error=Infallible>
    via the stdlib blanket — meaning they'd always succeed and MultiAddress
    would never fall back to Standard, breaking the
    "non-EVM-shaped credentials are Standard" invariant.
  • To preserve that invariant, every non-EVM VmAddress type would need a
    bespoke TryFrom with real fallibility. Substantially more
    code than Option 1.
  • Direct call-site fallout: sov-address/src/evm/public_key.rs:365 and the
    internal Self::from(credential_id) at line 156 of the new
    TryDecodeCredentialId impl both need rewriting.
  • Violates the user memory feedback_test_broad_when_changing_auth.md:
    high-violence change across the framework for a narrow, localized bug.

Recommendation

Option 1 — keep the current HEAD 71e96cf and push it to re-run CI.

  • It compiles, traces correctly, preserves framework invariants, and fixes the
    bug where it actually lives.
  • The "extra trait" criticism is mild — one trait, one impl, well-documented.
  • Option 2 trades the trait for duplicated logic across crates; the comment-
    based "source of truth" cross-reference is weaker than shared code.
  • Option 3 has the fatal Spec::Address: From invariant issue
    and requires touching every VmAddress implementor.

Optional small polish inside Option 1: tighten the demo-stf From
impl to fully delegate to try_decode_credential_id (currently it already
does — examples/demo-rollup/stf/stf-declaration/src/address.rs:55-67).
No action needed.

@citizen-stig
citizen-stig force-pushed the nikolai/accounts-refactor-part-2 branch from a887f49 to 30c8c38 Compare May 4, 2026 13:53
@citizen-stig
citizen-stig force-pushed the nikolai/accounts-refactor-part-3 branch from 71e96cf to c14c762 Compare May 4, 2026 13:57

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 1 new potential issue.

View 10 additional findings in Devin Review.

Open in Devin Review

@citizen-stig
citizen-stig force-pushed the nikolai/accounts-refactor-part-2 branch from 3898e2e to 3704b1d Compare May 5, 2026 14:39
@citizen-stig
citizen-stig marked this pull request as draft May 5, 2026 15:38
@citizen-stig
citizen-stig force-pushed the nikolai/accounts-refactor-part-2 branch from f94b261 to ce7b152 Compare May 6, 2026 10:13
@citizen-stig
citizen-stig force-pushed the nikolai/accounts-refactor-part-3 branch from c14c762 to cf6f78e Compare May 7, 2026 14:01
Base automatically changed from nikolai/accounts-refactor-part-2 to theodore/multisig-upgrade May 8, 2026 12:37
@citizen-stig
citizen-stig force-pushed the nikolai/accounts-refactor-part-3 branch from a9fcf15 to 8f1d6ea Compare May 8, 2026 12:45
Comment thread crates/module-system/module-implementations/sov-accounts/src/call.rs Outdated
@theodorebugnet
theodorebugnet force-pushed the theodore/multisig-upgrade branch from 6406ec4 to 6e7d22a Compare May 8, 2026 17:32
@citizen-stig
citizen-stig force-pushed the nikolai/accounts-refactor-part-3 branch from adec893 to f82f4eb Compare May 11, 2026 08:08
Description

  When `address_override` is `None`, `resolve_authorized_sender()` was
  re-checking `auth_data.default_address` through
  `Accounts::is_authorized_for()`. That works when the authenticator's
  default address matches `credential_id.into()`, but it breaks
  authenticators whose default address uses a different address form.

  For `sov-evm` with `MultiAddressEvm`, the authenticator sets
  `default_address` to `Vm(eth_addr)`, while the canonical fallback in
  `is_authorized_for()` is `Standard(credential_id.into())`. Ordinary EVM
  transactions were therefore skipped with "not authorized for resolved
  address" unless an explicit `(vm_address, credential_id)` mapping had
  already been inserted.

  Fix the shared `address_override = None` path to trust the
  authenticator-selected default address unless that exact
  `(address, credential_id)` pair is explicitly denied in `account_owners`.
  Keep `Some(address_override)` strict, and preserve revocation semantics
  for explicit `false` entries.

  This restores the default EVM sender path without changing EVM auth
  semantics or requiring pre-populated account mappings.
@citizen-stig
citizen-stig force-pushed the nikolai/accounts-refactor-part-3 branch from c8fba4e to cefae1f Compare May 11, 2026 10:37
@citizen-stig
citizen-stig marked this pull request as ready for review May 11, 2026 10:37

@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.

LGTM if the typescript comment is addressed!

Comment thread typescript/packages/serializers/src/serializer.ts Outdated
@citizen-stig
citizen-stig merged commit a586c2b into theodore/multisig-upgrade May 12, 2026
28 of 29 checks passed
@citizen-stig
citizen-stig deleted the nikolai/accounts-refactor-part-3 branch May 12, 2026 12:30
@github-actions github-actions Bot locked and limited conversation to collaborators May 12, 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.

2 participants