Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ The `SolanaRegistration<S>` module is a Sovereign SDK module that implements the

- **Selective Message Handling**: Only processes messages from the configured Solana domain and trusted program ID
- **Account Linking**: Associates Solana embedded wallets with rollup addresses via the `sov-accounts` module
- **Duplicate Prevention**: Rejects attempts to register an embedded wallet that's already linked to a different address
Comment thread
citizen-stig marked this conversation as resolved.
- **Admin Controls**: Allows configuration updates via admin-only calls
- **Fallback to Warp**: Non-Solana messages are forwarded to the underlying `Warp` module

Expand Down Expand Up @@ -132,7 +131,6 @@ pub enum Event<S: Spec> {

The module defines several error types:

- `AlreadyRegistered`: The embedded public key is already linked to a different address
- `InvalidBodyLength`: The message body doesn't contain exactly 64 bytes
- `ExtractPubKey`: Failed to parse public keys from the message body
- `AdminNotFound`: Admin address not configured
Expand All @@ -146,9 +144,8 @@ See `src/lib.rs:196-213` for the `handle` implementation:
2. Verify the sender matches the trusted Solana program ID
3. Extract the two 32-byte public keys from the message body
4. Convert the payer public key to a rollup address
5. Use `sov-accounts` to resolve or create the address-credential mapping
6. Reject if the embedded wallet is already linked to a different address
7. Emit a `UserRegistered` event
5. Authorize the embedded credential to act as the payer's address by recording `(payer, embedded)` in `sov-accounts`
6. Emit a `UserRegistered` event

---

Expand Down Expand Up @@ -372,4 +369,3 @@ cargo test
```

Program tests are located in `solana/program-tests/src/tests.rs`.

Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,6 @@ where
pub enum SolanaRegistrationError {
#[error("Core module error: {0}")]
CoreModuleError(#[from] CoreModuleError),
#[error("Embedded pubkey already registered to different address. Attempted: {attempted_address}, Registered: {registered_address}")]
AlreadyRegistered {
attempted_address: String,
registered_address: String,
},
#[error("Invalid body length. Expected {expected}, found {found}")]
InvalidBodyLength { expected: usize, found: usize },
#[error("Failed to extract public key from body")]
Expand Down Expand Up @@ -297,26 +292,19 @@ where
let (user_pubkey, embedded_pubkey) = self.unpack_body(body.as_ref())?;
let credential_id = CredentialId::from(embedded_pubkey);
let address = S::Address::try_from(&user_pubkey).map_err(CoreModuleError::from)?;
let resolved_address = self
.accounts
.resolve_sender_address(&address, &credential_id, state)

self.accounts
.authorize_credential(&address, &credential_id, state)
.map_err(CoreModuleError::state_write)?;

if address != resolved_address {
Err(SolanaRegistrationError::AlreadyRegistered {
attempted_address: address.to_string(),
registered_address: resolved_address.to_string(),
})
} else {
self.emit_event(
state,
Event::UserRegistered {
address,
credential_id,
},
);
Ok(())
}
self.emit_event(
state,
Event::UserRegistered {
address,
credential_id,
},
);
Ok(())
}

pub fn admin(
Expand Down
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use sov_hyperlane_integration::{CallMessage, Ism, Recipient};
use sov_hyperlane_register_module::{
CallMessage as RegistrationCallMessage, SolanaDeployment, SolanaRegistration,
};
use sov_modules_api::prelude::UnwrapInfallible;
use sov_modules_api::{Base58Address, CredentialId, HexString, SafeVec, Spec};
use sov_test_utils::{AsUser, TransactionTestCase};

Expand All @@ -23,16 +24,20 @@ fn test_user_is_registered_correctly() {
let route_id = register_basic_warp_route(&mut runner, &admin);

let payer = [1u8; 32];
let payer_addr = <S as Spec>::Address::from(payer);
let embedded = [2u8; 32];
let credential = CredentialId::from(embedded);
let body = [payer, embedded].concat();
let valid_message = make_valid_message(0, route_id, HexString::new(body));
let message = HexString::new(SafeVec::try_from(valid_message.encode().0).unwrap());
let credential = CredentialId::from(embedded);

// Sanity check, ensure account definently doesnt already exist
runner.query_state(|state| {
let account = sov_accounts::Accounts::default().get_account(credential, state);
assert!(matches!(account, sov_accounts::Response::AccountEmpty));
assert!(
!sov_accounts::Accounts::<S>::default()
.is_explicitly_authorized(&payer_addr, &credential, state)
.unwrap_infallible(),
"Embedded credential should not yet be authorized for the payer address"
);
});

runner.execute_transaction(TransactionTestCase {
Expand All @@ -41,34 +46,36 @@ fn test_user_is_registered_correctly() {
message,
}),
assert: Box::new(move |result, _| {
let receipt = &result.tx_receipt;
assert!(
result.tx_receipt.is_successful(),
"Recipient was not registered successfully"
"Recipient was not registered successfully: {receipt:?}"
);

assert_eq!(
result.events.last().unwrap(),
&TestRuntimeEvent::SolanaRegister(
sov_hyperlane_register_module::Event::UserRegistered {
address: <S as Spec>::Address::from(payer),
credential_id: CredentialId::from(embedded),
address: payer_addr,
credential_id: credential,
}
)
);
}),
});

runner.query_state(|state| {
let account = sov_accounts::Accounts::default().get_account(credential, state);
assert!(matches!(
account,
sov_accounts::Response::AccountExists { .. }
));
assert!(
sov_accounts::Accounts::<S>::default()
.is_explicitly_authorized(&payer_addr, &credential, state)
.unwrap_infallible(),
"Embedded credential should be authorized for the payer address after registration"
);
});
}

#[test]
fn test_errors_if_user_already_registered() {
fn test_two_payers_registering_same_embedded_both_succeed() {
let SetupParams {
mut runner,
admin,
Expand All @@ -77,47 +84,64 @@ fn test_errors_if_user_already_registered() {
} = setup();
let route_id = register_basic_warp_route(&mut runner, &admin);

let payer = [1u8; 32];
let payer_a = [1u8; 32];
let payer_a_addr = <S as Spec>::Address::from(payer_a);
let payer_b = [3u8; 32];
let payer_b_addr = <S as Spec>::Address::from(payer_b);
let embedded = [2u8; 32];
let body = [payer, embedded].concat();
let valid_message = make_valid_message(0, route_id, HexString::new(body));
let message = HexString::new(SafeVec::try_from(valid_message.encode().0).unwrap());
let credential = CredentialId::from(embedded);

// First registration: (payer_a, embedded).
let body_a = [payer_a, embedded].concat();
let msg_a = make_valid_message(0, route_id, HexString::new(body_a));
let envelope_a = HexString::new(SafeVec::try_from(msg_a.encode().0).unwrap());
runner.execute_transaction(TransactionTestCase {
input: user.create_plain_message::<RT, Mailbox<S>>(CallMessage::Process {
metadata: HexString::new(SafeVec::new()),
message: message.clone(),
message: envelope_a,
}),
assert: Box::new(move |result, _| {
let receipt = &result.tx_receipt;
assert!(
result.tx_receipt.is_successful(),
"Recipient was not registered successfully"
"First registration should succeed: {receipt:?}"
);
}),
});

// payer is different so will try to register to different address
let payer = [3u8; 32];
let embedded = [2u8; 32];
let body = [payer, embedded].concat();
let valid_message = make_valid_message(1, route_id, HexString::new(body));
let message = HexString::new(SafeVec::try_from(valid_message.encode().0).unwrap());

// Same embedded credential, different payer — many-to-many authorization is allowed.
let body_b = [payer_b, embedded].concat();
let msg_b = make_valid_message(1, route_id, HexString::new(body_b));
let envelope_b = HexString::new(SafeVec::try_from(msg_b.encode().0).unwrap());
runner.execute_transaction(TransactionTestCase {
input: user.create_plain_message::<RT, Mailbox<S>>(CallMessage::Process {
metadata: HexString::new(SafeVec::new()),
message,
message: envelope_b,
}),
assert: Box::new(move |result, _| match result.tx_receipt {
sov_rollup_interface::stf::TxEffect::Reverted(contents) => {
assert_eq!(
contents.reason.to_string(),
"Embedded pubkey already registered to different address. Attempted: CktRuQ2mttgRGkXJtyksdKHjUdc2C4TgDzyB98oEzy8, Registered: 4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi".to_string()
);
}
_ => panic!("Registration should have reverted: {:?}", result.tx_receipt),
assert: Box::new(move |result, _| {
let receipt = &result.tx_receipt;
assert!(
result.tx_receipt.is_successful(),
"Second registration with same embedded but different payer should also succeed: {receipt:?}"
);
}),
});

runner.query_state(|state| {
let accounts = sov_accounts::Accounts::<S>::default();
assert!(
accounts
.is_explicitly_authorized(&payer_a_addr, &credential, state)
.unwrap_infallible(),
"First (payer_a, embedded) authorization should be recorded"
);
assert!(
accounts
.is_explicitly_authorized(&payer_b_addr, &credential, state)
.unwrap_infallible(),
"Second (payer_b, embedded) authorization should also be recorded"
);
});
}

#[test]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
use sov_accounts::{Accounts, CallMessage as AccountsCallMessage};
use sov_address::{EthereumAddress, EvmCryptoSpec};
use sov_eip712_auth::{
Eip712Authenticator, Eip712AuthenticatorInput, Eip712AuthenticatorTrait, SchemaProvider,
Expand All @@ -22,7 +21,6 @@ use sov_rollup_interface::da::RelevantBlobs;
use sov_rollup_interface::stf::{TxEffect, TxReceiptContents};
use sov_test_utils::runtime::genesis::optimistic::HighLevelOptimisticGenesisConfig;
use sov_test_utils::runtime::{TestRunner, ValueSetter};
use sov_test_utils::TransactionTestCase;
use sov_test_utils::{generate_runtime, EncodeCall, TestStorage, TestUser, TEST_DEFAULT_MAX_FEE};
use sov_value_setter::CallMessage;

Expand Down Expand Up @@ -274,28 +272,34 @@ fn correct_signature_is_accepted() {

#[test]
fn test_multisig_signature_verification() {
use sov_test_utils::AsUser;
let (mut runner, admin) = setup();

// First, create and register a multisig
// Build the multisig first so we can seed genesis with its canonical address
// funded. After the accounts refactor, `resolve_sender_address` no longer
// writes an `accounts` entry, so the multisig must either be a canonical-
// address owner with gas, or be covered by a legacy mapping. Here we pick
// the canonical path.
let multisig_keys = [
TestPrivateKey::generate(),
TestPrivateKey::generate(),
TestPrivateKey::generate(),
];

// Create the multisig and register it
let multisig = Multisig::new(2, multisig_keys.iter().map(|k| k.pub_key()).collect());
let multisig_credential_id =
multisig.credential_id::<<<S as Spec>::CryptoSpec as CryptoSpec>::Hasher>();
runner.execute_transaction(TransactionTestCase {
input: admin.create_plain_message::<RT, Accounts<S>>(
AccountsCallMessage::InsertCredentialId(multisig_credential_id),
),
assert: Box::new(move |result, _state| {
assert!(result.tx_receipt.is_successful());
}),
});
let multisig_user =
TestUser::generate_with_default_balance().add_credential_id(multisig_credential_id);

// The multisig is the sender of every `SetValue` tx below, so ValueSetter's
// admin must be the multisig canonical address for the successful-path
// assertions to actually reach ValueSetter.
let multisig_address = multisig_user.address();
let genesis_config = HighLevelOptimisticGenesisConfig::generate()
.add_accounts_with_default_balance(2)
.add_accounts(vec![multisig_user]);
let module_config = sov_value_setter::ValueSetterConfig {
admin: multisig_address,
};
let genesis = GenesisConfig::from_minimal_config(genesis_config.clone().into(), module_config);
let mut runner = TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default());

// 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();
Expand Down
70 changes: 63 additions & 7 deletions crates/module-system/module-implementations/sov-accounts/README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,71 @@
# `sov-accounts` module

The `sov-accounts` module is responsible for managing accounts on the rollup.
The `sov-accounts` module resolves transaction credentials to rollup
addresses and records which credentials may act for which addresses.

### The `sov-accounts` module offers the following functionality

### The `sov-accounts` module offers the following functionality:
1. A credential has a deterministic canonical address, computed as `credential_id.into::<S::Address>()`.
This relation is stateless: using the canonical address does not require an account entry to be written.

1. When a sender sends their first message, the `sov-accounts` module will create a new address by deriving it from the sender's credential.
The module will then add a mapping between the credential id and the address to its state. For all subsequent messages that include the sender's credential,
the module will retrieve the sender's address from the mapping and pass it along with the original message to an intended module.
2. Legacy state can contain an explicit `credential_id -> address` mapping.
This map is used for credential-only resolution and the `get_account` query.

1. It is possible to add new credential to a given address using the `CallMessage::InsertCredentialId(..)` message.
3. It is possible to authorize another credential for the caller's address using
the `CallMessage::InsertCredentialId(..)` message.
This writes an `account_owners` authorization, not a credential-indexed account entry.

1. It is possible to query the `sov-accounts` module using the `get_account` method and get the account corresponding to the given credential id.
4. It is possible to query the `sov-accounts` module using the `get_account`
method and get the legacy/custom mapped address corresponding to the given credential id.

## Credential and Address Relations

The module has three credential/address relations.
They answer different questions and should not be treated as interchangeable.

### Stateless canonical address

```text
credential_id -> credential_id.into::<S::Address>()
```

This is the default address for a credential. It is deterministic and requires no state write.
If a credential has no explicit credential-indexed state mapping,
this canonical address is the natural fallback for credential-only routing.

### Credential-to-account map

```text
accounts[credential_id] = Account { addr }
```

This legacy/custom state map records the primary address explicitly associated with a credential.
A credential has at most one primary address in this map,
while one address may be the primary address for many credentials.

This map is used when callers only know a `CredentialId` and need an address:
`resolve_sender_address`, `get_account`, and legacy/custom account mappings all depend on this credential-indexed lookup.
New `InsertCredentialId` calls do not write this map.
`get_account` reports entries from this explicit map;
it does not mean that every possible stateless canonical address or explicit authorization has a stored account entry.

### Account-credential authorization map

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

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 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,
because it is keyed by `(address, credential_id)` and cannot efficiently answer
"which address is this credential mapped to?" with a single point lookup.
New `InsertCredentialId` calls write this relation.

In short: routing from only a credential uses the explicit credential-to-account
map or the stateless canonical fallback.
Callers that need to verify whether a known address may be used with a credential should use the combined
`is_authorized_for` check: legacy/custom mapping, stateless canonical address, or `account_owners`.
Loading
Loading