Skip to content

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

Open
citizen-stig wants to merge 9 commits intonikolai/accounts-refactor-part-2from
nikolai/accounts-refactor-part-3
Open

Accounts Refactor PR 3: Addinng more call messages to accounts module#2773
citizen-stig wants to merge 9 commits intonikolai/accounts-refactor-part-2from
nikolai/accounts-refactor-part-3

Conversation

@citizen-stig
Copy link
Copy Markdown
Member

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

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 V1 signers declare a target_address.
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 (fresh V0 user → sender is their default → matches).

  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.

--

  • 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
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

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

.unwrap_or(false))
}

/// Returns `true` if `credential_id` is authorized to act as `address`.
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.

🟡 Stale doc comment on is_authorized_for describes reversed precedence order

The doc comment on is_authorized_for at crates/module-system/module-implementations/sov-accounts/src/capabilities.rs:65-71 still describes the old precedence: "if credential_id has a legacy mapping in accounts, that mapping is authoritative". However, the implementation was reordered to check account_owners first (line 78-83), then accounts (line 85-87), then canonical fallback (line 89). Critically, an account_owners entry of false now short-circuits and denies authorization, overriding both the legacy map and canonical fallback. The doc also doesn't mention the new false-as-denial semantics at all.

This violates the AGENTS.md rule: "Keep docs/comments in sync with behavior." A developer reading this doc would incorrectly believe the legacy mapping is always authoritative, which matters for reasoning about the security-sensitive revocation feature.

(Refers to lines 65-71)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@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 on lines +205 to 219
fn ensure_credential_not_authorized(
&self,
address: &S::Address,
credential: &CredentialId,
state: &mut impl StateReader<User>,
) -> anyhow::Result<()> {
anyhow::ensure!(
self.account_owners
.get(&AccountOwnerKey::new(*address, *new_credential_id), state)
!self
.account_owners
.get(&AccountOwnerKey::new(*address, *credential), state)
.context("Failed to read account owner")?
.is_none(),
.unwrap_or(false),
"CredentialId already authorized for this address"
);
Ok(())
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.

(Refers to lines 205-220)

🚩 ensure_credential_not_authorized only checks account_owners, not full is_authorized_for

The ensure_credential_not_authorized function at crates/module-system/module-implementations/sov-accounts/src/call.rs:205-220 only checks account_owners state, not the full authorization chain (legacy map + canonical address). This means AddCredentialToAddress and RotateCredentialOnAddress (for the new credential) will succeed even if the credential is already implicitly authorized via canonical address or legacy mapping. The result is a redundant explicit true in account_owners. This is benign for add (already authorized → still authorized), but creates an important side-effect: if the user later calls RemoveCredentialFromAddress, the written false will override the canonical fallback, effectively revoking implicit authorization. This asymmetry is documented in the README ("revoking the last credential leaves the address unspendable") and seems intentional, but could surprise users who add their own canonical credential and later remove it.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 59 to +62
Ok(self
.account_owners
.get(&AccountOwnerKey::new(*address, *credential_id), state)?
.is_some())
.unwrap_or(false))
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.

📝 Info: Semantic change: is_authorized now returns false for Some(false) entries

The change from .is_some() to .unwrap_or(false) at crates/module-system/module-implementations/sov-accounts/src/capabilities.rs:62 is a meaningful behavioral change. Previously .is_some() would return true for ANY value in account_owners (including a hypothetical false). Now .unwrap_or(false) correctly distinguishes Some(true) from Some(false). This is essential for the revoke feature, since revoke_credential writes false to account_owners. The resolve_sender Some(requested) path at crates/module-system/sov-capabilities/src/lib.rs:73 relies on this to reject revoked credentials targeting a specific address. In the old code this was safe because account_owners only ever stored true, but now both values are possible.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 80 to 101
}
Ok(requested)
}
None => Ok(self.accounts.resolve_sender_address(
&auth_data.default_address,
&auth_data.credential_id,
state,
)?),
None => {
let resolved = self.accounts.resolve_sender_address(
&auth_data.default_address,
&auth_data.credential_id,
state,
)?;
if !self
.accounts
.is_authorized_for(&resolved, &auth_data.credential_id, state)?
{
anyhow::bail!(
"credential {} not authorized for resolved address {}",
auth_data.credential_id,
resolved,
);
}
Ok(resolved)
}
}
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.

(Refers to lines 69-101)

📝 Info: Asymmetry between Some and None paths in resolve_sender

The Some(requested) path at crates/module-system/sov-capabilities/src/lib.rs:70-81 uses is_authorized (checks only account_owners), while the None path at lines 83-101 uses is_authorized_for (checks account_owners → legacy → canonical). This asymmetry is intentional: target_address = Some(X) requires an explicit account_owners authorization (the user is specifically requesting a non-default address), while target_address = None uses the full fallback chain for the resolver's default. This was the behavior before this PR for the Some path; only the None path gained a new check.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +83 to 101
None => {
let resolved = self.accounts.resolve_sender_address(
&auth_data.default_address,
&auth_data.credential_id,
state,
)?;
if !self
.accounts
.is_authorized_for(&resolved, &auth_data.credential_id, state)?
{
anyhow::bail!(
"credential {} not authorized for resolved address {}",
auth_data.credential_id,
resolved,
);
}
Ok(resolved)
}
}
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.

📝 Info: New authorization check on resolve_sender None path is a consensus-breaking change

The added is_authorized_for check at lines 89-98 means V0 transactions (no target_address) can now be rejected if the credential has been explicitly revoked via account_owners[(resolved, credential)] = false. Previously this path unconditionally succeeded. For existing rollups, this is safe as long as no false entries exist in account_owners before upgrade (they can't, since revoke_credential is new). After upgrade, the check only triggers for credentials that have been explicitly revoked via the new RemoveCredentialFromAddress or RotateCredentialOnAddress calls. The CHANGELOG correctly flags this as a consensus-breaking change.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant