Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

# MULTISIG UPGRADE
Temporary section for maintaining breaking changes from individual PRs, which will be consolidated into a single changelog entry when the feature branch is merged into `dev`.
- **Breaking change (code, wire, consensus)**: `sov_accounts::CallMessage` gains three variants — `AddCredentialToAddress { address, credential }`, `RemoveCredentialFromAddress { address, credential }`, and `RotateCredentialOnAddress { address, old_credential, new_credential }` — and is now generic in `S: Spec`. The enum's wire format changes, which changes the chain hash. All three new variants require `context.sender() == address` (callers can only modify credentials on the address they are currently signing as); `InsertCredentialId` semantics are unchanged. `Rotate` is functionally equivalent to a `Remove` + `Add` collapsed into a single call for key rotation. There is no orphan guard on remove.
- #2688 **Breaking change(code, state, consensus)**: The transaction formats have changed in this version. This is a consensus breaking change and requires coordinating an upgrade using `--stop-at-rollup-height`, and using sov-rollup-manager for full resyncs from this point onwards for existing rollups. This fixes Credential ID malleability that allowed the same set of signed bytes to be replayed for different credentials, across V0 and V1 (multisig) transactions and with different V1 multisig parameters.
* The on-chain transaction data has changed, and multisig transactions now explicitly include the multisig id as part of the signed bytes.
* Previous signatures are no longer valid. Clients will need to upgrade to our latest SDKs to be able to sign transactions in the new format.
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/full-node/sov-rollup-apis/src/endpoints/simulate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ impl<S: Spec, R: Runtime<S>> SovereignSimulate<S, R> {
credential_id,
default_address: credential_id.into(),
credentials: Credentials::new(credential_id),
address: None,
}
}

Expand Down
3 changes: 2 additions & 1 deletion crates/fuzz/fuzz_targets/accounts_call_random.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ use sov_test_utils::storage::SimpleStorageManager;
use sov_test_utils::TestStorageSpec;

type S = sov_test_utils::TestSpec;
type FuzzInput<'a> = (&'a [u8], Vec<(Context<S>, CallMessage<S>)>);

// Check arbitrary, random calls
fuzz_target!(|input: (&[u8], Vec<(Context<S>, CallMessage)>)| {
fuzz_target!(|input: FuzzInput| {
let storage_manager = SimpleStorageManager::<TestStorageSpec>::new();
let storage = storage_manager.create_storage();
let mut state = StateCheckpoint::new(storage, &MockKernel::<S>::default(), None);
Expand Down
6 changes: 4 additions & 2 deletions crates/fuzz/fuzz_targets/accounts_parse_call_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
use libfuzzer_sys::fuzz_target;
use sov_accounts::CallMessage;

fuzz_target!(|input: CallMessage| {
type S = sov_test_utils::TestSpec;

fuzz_target!(|input: CallMessage<S>| {
let json = serde_json::to_vec(&input).unwrap();
let msg = serde_json::from_slice::<CallMessage>(&json).unwrap();
let msg = serde_json::from_slice::<CallMessage<S>>(&json).unwrap();
assert_eq!(input, msg);
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
use libfuzzer_sys::fuzz_target;
use sov_accounts::CallMessage;

type S = sov_test_utils::TestSpec;

fuzz_target!(|input: &[u8]| {
serde_json::from_slice::<CallMessage>(input).ok();
serde_json::from_slice::<CallMessage<S>>(input).ok();
});
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,13 @@ pub fn sign_utx_in_place<S: Spec, RT: Runtime<S>>(
}

/// Signs a V1 (multisig) unsigned transaction with the given private key using EIP712.
/// The credential_address is computed from the provided multisig.
/// The credential_address is computed from the provided multisig. `target_address` is
/// forwarded into the signed `UnsignedTransactionV1`; pass `None` to match pre-target
/// routing or `Some(X)` to sign for a specific target account.
pub fn sign_utx_v1_in_place<S: Spec, RT: Runtime<S>>(
utx: &UnsignedTransactionV0<RT, S>,
multisig: &Multisig<<S::CryptoSpec as CryptoSpec>::PublicKey>,
target_address: Option<S::Address>,
private_key: &<S::CryptoSpec as CryptoSpec>::PrivateKey,
) -> <S::CryptoSpec as CryptoSpec>::Signature {
let schema = TestSchemaProvider::get_schema();
Expand All @@ -199,6 +202,7 @@ pub fn sign_utx_v1_in_place<S: Spec, RT: Runtime<S>>(
uniqueness: utx.uniqueness,
details: utx.details.clone(),
credential_address,
target_address,
});
let utx_bytes = borsh::to_vec(&utx_enum).expect("Failed to serialize unsigned transaction");
let eip712_signing_data = schema
Expand Down Expand Up @@ -299,16 +303,20 @@ fn test_multisig_signature_verification() {

// Generate a signature from a random private key that's not part of the multisig. We'll use this in some of the test cases.
let random_private_key = TestPrivateKey::generate();
// The multisig credential is authorized for `admin.address()` by the
// InsertCredentialId tx above. Target that address so the multisig tx pays
// gas from admin's balance; the multisig's own default address is unfunded.
let target = Some(admin.address());
let make_multisig_tx = |generation| {
let utx = create_utx_with_generation::<S, RT>(encode_message::<_, RT>(), generation);
let signatures = multisig_keys
.iter()
.map(|key| sign_utx_v1_in_place(&utx, &multisig, key))
.map(|key| sign_utx_v1_in_place(&utx, &multisig, target, key))
.collect::<Vec<_>>();
let random_signature = sign_utx_v1_in_place(&utx, &multisig, &random_private_key);
let random_signature = sign_utx_v1_in_place(&utx, &multisig, target, &random_private_key);

(
utx.to_multisig_tx(multisig.clone()),
utx.to_multisig_tx(multisig.clone(), target),
signatures,
random_signature,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ serde = { workspace = true }
serde_with = { workspace = true }

sov-modules-api = { workspace = true }
sov-chain-state = { workspace = true }
sov-state = { workspace = true }

arbitrary = { workspace = true, optional = true }

[dev-dependencies]
sov-accounts = { path = ".", features = ["native"] }
sov-bank = { workspace = true }
tempfile = { workspace = true }
strum = { workspace = true }
sov-modules-api = { workspace = true, features = ["test-utils"] }
Expand All @@ -43,6 +45,7 @@ arbitrary = [
]
native = [
"sov-accounts/native",
"sov-chain-state/native",
"sov-modules-api/native",
"sov-state/native",
]
Expand Down
24 changes: 19 additions & 5 deletions crates/module-system/module-implementations/sov-accounts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ addresses and records which credentials may act for which addresses.
the `CallMessage::InsertCredentialId(..)` message. This writes an
`account_owners` authorization, not a credential-indexed account entry.

1. It is possible to explicitly authorize a credential for the caller's own
address with `CallMessage::AddCredentialToAddress { address, credential }`,
revoke such an authorization with
`CallMessage::RemoveCredentialFromAddress { address, credential }`, and
atomically swap one credential for another with
`CallMessage::RotateCredentialOnAddress { address, old_credential, new_credential }`.
All three calls require `message.address == context.sender()`: callers can
only modify credentials on the address they are currently signing as. The
V1 signing path's `target_address` field lets a caller signing with a
credential authorized for multiple addresses select which one to act as.
There is no orphan guard on remove — revoking the last credential leaves
the address unspendable via `account_owners`.

1. It is possible to query the `sov-accounts` module using the `get_account`
method and get the legacy/custom mapped account corresponding to the given
credential id.
Expand Down Expand Up @@ -56,13 +69,14 @@ authorization has a stored account entry.
### Account-credential authorization map

```text
account_owners[(address, credential_id)] = true
account_owners[(address, credential_id)] = true | false
```

This state map records authorization. A present entry means the credential is
authorized to sign transactions that execute as the given address. The key is
the exact `(address, credential_id)` pair, so this relation does not provide a
credential-only lookup by itself.
This state map records authorization overrides. `true` means the credential is
authorized to sign transactions that execute as the given address. `false`
explicitly revokes fallback authorization for that pair, including stateless
canonical fallback. The key is the exact `(address, credential_id)` pair, so
this relation does not provide a credential-only lookup by itself.

This relation answers "may this credential act as this address?" once the target
address is known. It is not a replacement for the credential-to-account map,
Expand Down
Loading
Loading