WIP: feat-shamir (old) #103

Draft
CleverWild wants to merge 66 commits from feat-shamir into main
9 changed files with 358 additions and 66 deletions
Showing only changes of commit 6e21505a7f - Show all commits

View File

@@ -109,6 +109,7 @@ create table if not exists evm_wallet_access (
id integer not null primary key,
wallet_id integer not null references evm_wallet (id) on delete cascade,
client_id integer not null references program_client (id) on delete cascade,
revoked_at integer, -- unix timestamp when revoked, null = still active
created_at integer not null default(unixepoch ('now'))
) STRICT;

View File

@@ -226,6 +226,7 @@ impl EvmActor {
.select(models::EvmWalletAccess::as_select())
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
.filter(schema::evm_wallet_access::client_id.eq(client_id))
.filter(schema::evm_wallet_access::revoked_at.is_null())
.first(&mut conn)
.await
.optional()
@@ -261,6 +262,7 @@ impl EvmActor {
.select(models::EvmWalletAccess::as_select())
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
.filter(schema::evm_wallet_access::client_id.eq(client_id))
.filter(schema::evm_wallet_access::revoked_at.is_null())
.first(&mut conn)
.await
.optional()
@@ -323,11 +325,19 @@ impl EvmActor {
) -> Result<(), Error> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
// Revives a previously revoked row instead of conflicting on it forever:
// `uniq_wallet_access` is a unique index on `(wallet_id, client_id)`.
insert_into(schema::evm_wallet_access::table)
.values((
schema::evm_wallet_access::wallet_id.eq(EvmWalletId::from_raw(settings.wallet_id)),
schema::evm_wallet_access::client_id.eq(settings.client_id),
))
.on_conflict((
schema::evm_wallet_access::wallet_id,
schema::evm_wallet_access::client_id,
))
.do_update()
.set(schema::evm_wallet_access::revoked_at.eq(None::<models::SqliteTimestamp>))
.execute(&mut conn)
.await
.map_err(DatabaseError::from)?;

View File

@@ -164,7 +164,11 @@ pub async fn create_test_pool() -> DatabasePool {
#[cfg(test)]
mod tests {
use super::*;
use diesel::{ExpressionMethods as _, dsl::insert_into};
use diesel::{
ExpressionMethods as _,
dsl::insert_into,
result::{DatabaseErrorKind, Error as DieselError},
};
use diesel_async::RunQueryDsl;
/// `operator.id` references `operator_identity(id)`. Without `PRAGMA foreign_keys = ON`
@@ -184,9 +188,18 @@ mod tests {
.execute(&mut conn)
.await;
// Specifically a foreign-key violation, not any error: a `NOT NULL` failure or a
// renamed column would also make `result.is_err()` true without proving the pragma
// is what rejected the insert.
assert!(
result.is_err(),
"insert with a dangling operator_identity reference was accepted"
matches!(
result,
Err(DieselError::DatabaseError(
DatabaseErrorKind::ForeignKeyViolation,
_
))
),
"expected a foreign-key violation for a dangling operator_identity reference, got {result:?}"
);
}
}

View File

@@ -263,19 +263,22 @@ pub struct EvmWallet {
#[view(
NewEvmWalletAccess,
derive(Insertable),
omit(id, created_at),
omit(id, created_at, revoked_at),
attributes_with = "deriveless"
)]
#[view(
CoreEvmWalletAccess,
derive(Insertable),
omit(created_at),
omit(created_at, revoked_at),
attributes_with = "deriveless"
)]
pub struct EvmWalletAccess {
pub id: i32,
pub wallet_id: EvmWalletId,
pub client_id: i32,
// Grants, transaction logs, and persistent-grant proposals reference this row
// `on delete restrict`, so revocation cannot delete it -- it marks it revoked instead.
pub revoked_at: Option<SqliteTimestamp>,
pub created_at: SqliteTimestamp,
}

View File

@@ -135,6 +135,7 @@ diesel::table! {
id -> Integer,
wallet_id -> Integer,
client_id -> Integer,
revoked_at -> Nullable<Integer>,
created_at -> Integer,
}
}

View File

@@ -381,6 +381,7 @@ mod tests {
id: WALLET_ACCESS_ID,
wallet_id: EvmWalletId::from_raw(5),
client_id: 20,
revoked_at: None,
created_at: SqliteTimestamp(Utc::now()),
},
chain: CHAIN_ID,
@@ -478,6 +479,9 @@ mod tests {
conn: &mut DatabaseConnection,
shared: &SharedGrantSettings,
) -> EvmBasicGrant {
// The seeded id deliberately wins over `shared.wallet_access_id`: every other field
// below is read from `shared`, but a caller-supplied access id would almost never
// reference a row that actually exists under foreign-key enforcement.
let wallet_access_id = seed_wallet_access(conn).await;
#[expect(

View File

@@ -35,6 +35,7 @@ fn ctx(to: Address, value: U256) -> EvalContext {
id: WALLET_ACCESS_ID,
wallet_id: EvmWalletId::from_raw(10),
client_id: 20,
revoked_at: None,
created_at: SqliteTimestamp(Utc::now()),
},
chain: CHAIN_ID,

View File

@@ -48,6 +48,7 @@ fn ctx(to: Address, calldata: Bytes) -> EvalContext {
id: WALLET_ACCESS_ID,
wallet_id: EvmWalletId::from_raw(10),
client_id: 20,
revoked_at: None,
created_at: SqliteTimestamp(Utc::now()),
},
chain: CHAIN_ID,

View File

@@ -177,20 +177,7 @@ impl OperatorSession {
entries: Vec<NewEvmWalletAccess>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(async |conn| {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::insert_into(evm_wallet_access::table)
.values(&entry)
.on_conflict_do_nothing()
.execute(&mut *conn)
.await?;
}
Result::<_, Error>::Ok(())
})
.await?;
grant_wallet_access(&mut conn, entries).await?;
Ok(())
}
@@ -211,6 +198,7 @@ impl OperatorSession {
use crate::db::schema::evm_wallet_access;
let mut conn = self.props.db.get().await?;
let access_entries = evm_wallet_access::table
.filter(evm_wallet_access::revoked_at.is_null())
.select(EvmWalletAccess::as_select())
.load::<_>(&mut conn)
.await?;
@@ -218,16 +206,47 @@ impl OperatorSession {
}
}
/// Deletes access rows by their own id. The wire carries `WalletAccessEntry.id` values, so
/// filtering by `wallet_id` here would revoke every client's access to that wallet.
/// Grants access, reviving a previously revoked row rather than leaving it shadowed:
/// `uniq_wallet_access` is a unique index on `(wallet_id, client_id)`, so a plain insert
/// would conflict forever on a row that was revoked but never deleted.
pub(crate) async fn grant_wallet_access(
conn: &mut crate::db::DatabaseConnection,
entries: Vec<NewEvmWalletAccess>,
) -> Result<(), diesel::result::Error> {
use crate::db::{models::SqliteTimestamp, schema::evm_wallet_access};
conn.transaction(async |conn| {
for entry in entries {
diesel::insert_into(evm_wallet_access::table)
.values(&entry)
.on_conflict((evm_wallet_access::wallet_id, evm_wallet_access::client_id))
.do_update()
.set(evm_wallet_access::revoked_at.eq(None::<SqliteTimestamp>))
.execute(&mut *conn)
.await?;
}
Ok(())
})
.await
}
/// Marks access rows revoked by their own id rather than deleting them. The wire carries
/// `WalletAccessEntry.id` values, so filtering by `wallet_id` here would revoke every
/// client's access to that wallet. Deleting is not an option: `evm_basic_grant`,
/// `evm_transaction_log`, and `proposal_persistent_grant` all reference this row
/// `on delete restrict`, so an access that was ever granted, signed with, or proposed
/// against can never be deleted -- only marked revoked.
pub(crate) async fn revoke_wallet_access(
conn: &mut crate::db::DatabaseConnection,
ids: &[i32],
) -> Result<usize, diesel::result::Error> {
use crate::db::schema::evm_wallet_access;
use crate::db::{models::SqliteTimestamp, schema::evm_wallet_access};
diesel::delete(evm_wallet_access::table)
diesel::update(evm_wallet_access::table)
.filter(evm_wallet_access::id.eq_any(ids))
.filter(evm_wallet_access::revoked_at.is_null())
.set(evm_wallet_access::revoked_at.eq(SqliteTimestamp::now()))
.execute(conn)
.await
}
@@ -385,18 +404,15 @@ impl OperatorSession {
#[cfg(test)]
mod tests {
use super::revoke_wallet_access;
use super::{grant_wallet_access, revoke_wallet_access};
use crate::db::{self, models, schema};
use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into};
use diesel_async::RunQueryDsl;
/// Two clients share one wallet. Revoking one access row must leave the other alone.
#[tokio::test]
async fn revoking_one_access_leaves_the_other_client_alone() {
let pool = db::create_test_pool().await;
let mut conn = pool.get().await.unwrap();
/// Inserts a fresh root key, an aead-encrypted wallet secret, and the wallet itself.
/// Returns the wallet's id and its 20-byte address.
async fn seed_wallet(conn: &mut db::DatabaseConnection) -> (models::EvmWalletId, Vec<u8>) {
let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory {
ciphertext: vec![0u8; 32],
@@ -407,7 +423,7 @@ mod tests {
salt: vec![0u8; 16],
})
.returning(schema::root_key_history::id)
.get_result(&mut conn)
.get_result(conn)
.await
.unwrap();
@@ -421,81 +437,323 @@ mod tests {
created_at: chrono::Utc::now().into(),
})
.returning(schema::aead_encrypted::id)
.get_result(&mut conn)
.get_result(conn)
.await
.unwrap();
let address = rand::random::<[u8; 20]>().to_vec();
let wallet_id: models::EvmWalletId = insert_into(schema::evm_wallet::table)
.values((
schema::evm_wallet::address.eq(vec![0u8; 20]),
schema::evm_wallet::address.eq(address.clone()),
schema::evm_wallet::aead_encrypted_id.eq(aead_id),
))
.returning(schema::evm_wallet::id)
.get_result(&mut conn)
.get_result(conn)
.await
.unwrap();
let metadata_id: i32 = insert_into(schema::client_metadata::table)
(wallet_id, address)
}
async fn seed_client_metadata(conn: &mut db::DatabaseConnection) -> i32 {
insert_into(schema::client_metadata::table)
.values(schema::client_metadata::name.eq("test"))
.returning(schema::client_metadata::id)
.get_result(&mut conn)
.get_result(conn)
.await
.unwrap();
.unwrap()
}
let first_client: i32 = insert_into(schema::program_client::table)
/// Inserts a `program_client` row under the given `client_metadata` row, keyed by its
/// own random public key.
async fn seed_client(conn: &mut db::DatabaseConnection, metadata_id: i32) -> i32 {
insert_into(schema::program_client::table)
.values((
schema::program_client::public_key.eq(vec![1u8; 32]),
schema::program_client::public_key.eq(rand::random::<[u8; 32]>().to_vec()),
schema::program_client::metadata_id.eq(metadata_id),
))
.returning(schema::program_client::id)
.get_result(&mut conn)
.get_result(conn)
.await
.unwrap();
.unwrap()
}
let second_client: i32 = insert_into(schema::program_client::table)
.values((
schema::program_client::public_key.eq(vec![2u8; 32]),
schema::program_client::metadata_id.eq(metadata_id),
))
.returning(schema::program_client::id)
.get_result(&mut conn)
.await
.unwrap();
let first_access: i32 = insert_into(schema::evm_wallet_access::table)
/// Inserts an access row directly, for fixtures that need one to already exist.
/// Production code grants access through [`grant_wallet_access`].
async fn insert_wallet_access(
conn: &mut db::DatabaseConnection,
wallet_id: models::EvmWalletId,
client_id: i32,
) -> i32 {
insert_into(schema::evm_wallet_access::table)
.values((
schema::evm_wallet_access::wallet_id.eq(wallet_id),
schema::evm_wallet_access::client_id.eq(first_client),
schema::evm_wallet_access::client_id.eq(client_id),
))
.returning(schema::evm_wallet_access::id)
.get_result(&mut conn)
.get_result(conn)
.await
.unwrap();
.unwrap()
}
let _second_access: i32 = insert_into(schema::evm_wallet_access::table)
.values((
schema::evm_wallet_access::wallet_id.eq(wallet_id),
schema::evm_wallet_access::client_id.eq(second_client),
))
.returning(schema::evm_wallet_access::id)
.get_result(&mut conn)
.await
.unwrap();
/// Two clients share one wallet. Revoking one access row must leave the other alone,
/// and must mark the row revoked rather than deleting it.
#[tokio::test]
async fn revoking_one_access_leaves_the_other_client_alone() {
let pool = db::create_test_pool().await;
let mut conn = pool.get().await.unwrap();
let (wallet_id, _address) = seed_wallet(&mut conn).await;
let metadata_id = seed_client_metadata(&mut conn).await;
let first_client = seed_client(&mut conn, metadata_id).await;
let second_client = seed_client(&mut conn, metadata_id).await;
let first_access = insert_wallet_access(&mut conn, wallet_id, first_client).await;
let _second_access = insert_wallet_access(&mut conn, wallet_id, second_client).await;
let removed = revoke_wallet_access(&mut conn, &[first_access])
.await
.unwrap();
assert_eq!(removed, 1);
let survivors: Vec<i32> = schema::evm_wallet_access::table
let active: Vec<i32> = schema::evm_wallet_access::table
.filter(schema::evm_wallet_access::revoked_at.is_null())
.select(schema::evm_wallet_access::client_id)
.load(&mut conn)
.await
.unwrap();
assert_eq!(
survivors,
active,
vec![second_client],
"revoking one access row removed another client's access"
);
let total: i64 = schema::evm_wallet_access::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(
total, 2,
"revoking an access row must mark it revoked, not delete it"
);
}
/// The bug this round fixes: once an access has been used for a grant, a signed
/// transaction, or a proposed persistent grant, three tables reference
/// `evm_wallet_access` `on delete restrict`, so deleting the row is no longer possible
/// once foreign keys are enforced. Revoking must still succeed by marking it revoked.
#[tokio::test]
async fn revoking_an_access_with_grant_log_and_proposal_succeeds() {
use crate::db::proposal::{Proposal as _, persistent_grant, persistent_grant::PersistentGrant};
let pool = db::create_test_pool().await;
let mut conn = pool.get().await.unwrap();
let (wallet_id, _address) = seed_wallet(&mut conn).await;
let metadata_id = seed_client_metadata(&mut conn).await;
let client_id = seed_client(&mut conn, metadata_id).await;
let access_id = insert_wallet_access(&mut conn, wallet_id, client_id).await;
// A grant against this access...
let grant_id: i32 = insert_into(schema::evm_basic_grant::table)
.values(models::NewEvmBasicGrant {
wallet_access_id: access_id,
chain_id: 1u64.into(),
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit_count: None,
rate_limit_window_secs: None,
revoked_at: None,
})
.returning(schema::evm_basic_grant::id)
.get_result(&mut conn)
.await
.unwrap();
// ...a signed transaction against that grant...
insert_into(schema::evm_transaction_log::table)
.values(models::NewEvmTransactionLog {
grant_id,
wallet_access_id: access_id,
chain_id: 1u64.into(),
eth_value: vec![0u8; 32],
signed_at: models::SqliteTimestamp(chrono::Utc::now()),
})
.execute(&mut conn)
.await
.unwrap();
// ...and a persistent-grant proposal that named this access before it was voted on.
let operator_id: models::OperatorIdentityId =
insert_into(schema::operator_identity::table)
.values(schema::operator_identity::public_key.eq(rand::random::<[u8; 32]>().to_vec()))
.returning(schema::operator_identity::id)
.get_result(&mut conn)
.await
.unwrap();
let proposal_id: models::ProposalId = insert_into(schema::proposal::table)
.values(&models::NewProposal {
kind: db::proposal::ProposalKindTag::ApprovePersistentGrant,
initiator_id: operator_id,
expires_at: models::SqliteTimestamp(chrono::Utc::now() + chrono::Duration::days(1)),
})
.returning(schema::proposal::id)
.get_result(&mut conn)
.await
.unwrap();
PersistentGrant::insert(
proposal_id,
&persistent_grant::Settings {
wallet_access_id: access_id,
chain_id: 1,
valid_from_secs: None,
valid_until_secs: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,
specific: persistent_grant::Specific::EtherTransfer {
targets: vec![[0u8; 20]],
limit: persistent_grant::VolumeLimit {
max_volume: [0u8; 32],
window_secs: 3600,
},
},
},
&mut conn,
)
.await
.unwrap();
// Before this round's fix, this would fail with a foreign-key violation.
let removed = revoke_wallet_access(&mut conn, &[access_id]).await.unwrap();
assert_eq!(removed, 1);
let revoked_at: Option<models::SqliteTimestamp> = schema::evm_wallet_access::table
.find(access_id)
.select(schema::evm_wallet_access::revoked_at)
.first(&mut conn)
.await
.unwrap();
assert!(
revoked_at.is_some(),
"the row must be marked revoked, not deleted"
);
}
/// A revoked access must no longer resolve through the lookup `shared_analyze_transaction`
/// and `client_sign_transaction` share -- otherwise the SDK client keeps signing after
/// the operator believes it has been cut off.
#[tokio::test]
async fn revoked_access_no_longer_authorizes_signing() {
use crate::actors::{
GlobalActors,
evm::{EvmActor, SignTransactionError},
vault::Vault,
};
use alloy::{
consensus::TxEip1559,
eips::eip2930::AccessList,
primitives::{Address, Bytes, TxKind, U256},
};
use kameo::actor::Spawn as _;
let pool = db::create_test_pool().await;
let mut conn = pool.get().await.unwrap();
let (wallet_id, address) = seed_wallet(&mut conn).await;
let metadata_id = seed_client_metadata(&mut conn).await;
let client_id = seed_client(&mut conn, metadata_id).await;
let access_id = insert_wallet_access(&mut conn, wallet_id, client_id).await;
revoke_wallet_access(&mut conn, &[access_id]).await.unwrap();
drop(conn);
let vault = Vault::spawn(
Vault::new(pool.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap(),
);
let mut evm_actor = EvmActor::new(vault, pool.clone());
let transaction = TxEip1559 {
chain_id: 1,
nonce: 0,
gas_limit: 21_000,
max_fee_per_gas: 0,
max_priority_fee_per_gas: 0,
to: TxKind::Call(Address::ZERO),
value: U256::ZERO,
input: Bytes::new(),
access_list: AccessList::default(),
};
let wallet_address = Address::from_slice(&address);
// Both lookups resolve access the same way; both must reject the revoked row before
// ever touching the vault (neither call bootstraps one).
let analyze_result = evm_actor
.shared_analyze_transaction(client_id, wallet_address, transaction.clone())
.await;
assert!(
matches!(analyze_result, Err(SignTransactionError::WalletNotFound)),
"a revoked access must not authorize shared_analyze_transaction: {analyze_result:?}"
);
let sign_result = evm_actor
.client_sign_transaction(client_id, wallet_address, transaction)
.await;
assert!(
matches!(sign_result, Err(SignTransactionError::WalletNotFound)),
"a revoked access must not authorize client_sign_transaction: {sign_result:?}"
);
}
/// Re-granting a revoked access must restore it rather than silently doing nothing:
/// `uniq_wallet_access` is a unique index on `(wallet_id, client_id)`, so a plain insert
/// would conflict on the revoked row forever.
#[tokio::test]
async fn regranting_a_revoked_access_restores_it() {
let pool = db::create_test_pool().await;
let mut conn = pool.get().await.unwrap();
let (wallet_id, _address) = seed_wallet(&mut conn).await;
let metadata_id = seed_client_metadata(&mut conn).await;
let client_id = seed_client(&mut conn, metadata_id).await;
let access_id = insert_wallet_access(&mut conn, wallet_id, client_id).await;
revoke_wallet_access(&mut conn, &[access_id]).await.unwrap();
grant_wallet_access(
&mut conn,
vec![models::NewEvmWalletAccess {
wallet_id,
client_id,
}],
)
.await
.unwrap();
let revoked_at: Option<models::SqliteTimestamp> = schema::evm_wallet_access::table
.find(access_id)
.select(schema::evm_wallet_access::revoked_at)
.first(&mut conn)
.await
.unwrap();
assert!(
revoked_at.is_none(),
"re-granting a revoked access must clear revoked_at"
);
let total: i64 = schema::evm_wallet_access::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(
total, 1,
"re-granting a revoked access must revive the existing row, not add a second one"
);
}
}