diff --git a/server/crates/arbiter-server/src/db/mod.rs b/server/crates/arbiter-server/src/db/mod.rs index b20d45f..da564ba 100644 --- a/server/crates/arbiter-server/src/db/mod.rs +++ b/server/crates/arbiter-server/src/db/mod.rs @@ -123,6 +123,10 @@ pub async fn create_pool(url: Option<&str>) -> Result DatabasePool { .await .expect("Failed to create test database pool") } + +#[cfg(test)] +mod tests { + use super::*; + use diesel::{ExpressionMethods as _, dsl::insert_into}; + use diesel_async::RunQueryDsl; + + /// `operator.id` references `operator_identity(id)`. Without `PRAGMA foreign_keys = ON` + /// on the pooled connection, SQLite accepts a share row for an operator that does not exist. + #[tokio::test] + async fn pooled_connections_enforce_foreign_keys() { + let pool = create_test_pool().await; + let mut conn = pool.get().await.unwrap(); + + let result = insert_into(schema::operator::table) + .values(( + schema::operator::id.eq(4242), + schema::operator::share.eq(vec![0u8; 32]), + schema::operator::share_nonce.eq(vec![0u8; 24]), + schema::operator::share_salt.eq(vec![0u8; 32]), + )) + .execute(&mut conn) + .await; + + assert!( + result.is_err(), + "insert with a dangling operator_identity reference was accepted" + ); + } +} diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index 5d05ca4..9b34bc3 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -352,16 +352,17 @@ impl Engine { mod tests { use alloy::primitives::{Address, Bytes, U256, address}; use chrono::{Duration, Utc}; - use diesel::{SelectableHelper, insert_into}; + use diesel::{ExpressionMethods as _, SelectableHelper, insert_into}; use diesel_async::RunQueryDsl; use rstest::rstest; use crate::db::{ - self, DatabaseConnection, + self, DatabaseConnection, models, models::{ EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp, }, + schema, schema::{evm_basic_grant, evm_transaction_log}, }; use crate::evm::policies::{ @@ -403,10 +404,82 @@ mod tests { } } + /// Creates the parent chain a fresh `evm_wallet_access` row needs under foreign-key + /// enforcement (a root key, an aead-encrypted secret, a wallet, and a client) and + /// returns the new access row's id. + async fn seed_wallet_access(conn: &mut DatabaseConnection) -> i32 { + let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table) + .values(&models::NewRootKeyHistory { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + root_key_encryption_nonce: vec![0u8; 24], + data_encryption_nonce: vec![0u8; 24], + schema_version: 1, + salt: vec![0u8; 16], + }) + .returning(schema::root_key_history::id) + .get_result(conn) + .await + .unwrap(); + + let aead_id: i32 = insert_into(schema::aead_encrypted::table) + .values(&models::NewAeadEncrypted { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + current_nonce: vec![0u8; 24], + schema_version: 1, + associated_root_key_id: root_key_id, + created_at: Utc::now().into(), + }) + .returning(schema::aead_encrypted::id) + .get_result(conn) + .await + .unwrap(); + + let wallet_id: EvmWalletId = insert_into(schema::evm_wallet::table) + .values(( + schema::evm_wallet::address.eq(rand::random::<[u8; 20]>().to_vec()), + schema::evm_wallet::aead_encrypted_id.eq(aead_id), + )) + .returning(schema::evm_wallet::id) + .get_result(conn) + .await + .unwrap(); + + let metadata_id: i32 = insert_into(schema::client_metadata::table) + .values(schema::client_metadata::name.eq("test")) + .returning(schema::client_metadata::id) + .get_result(conn) + .await + .unwrap(); + + let client_id: i32 = insert_into(schema::program_client::table) + .values(( + 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(conn) + .await + .unwrap(); + + insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(wallet_id), + schema::evm_wallet_access::client_id.eq(client_id), + )) + .returning(schema::evm_wallet_access::id) + .get_result(conn) + .await + .unwrap() + } + async fn insert_basic_grant( conn: &mut DatabaseConnection, shared: &SharedGrantSettings, ) -> EvmBasicGrant { + let wallet_access_id = seed_wallet_access(conn).await; + #[expect( clippy::cast_possible_truncation, clippy::cast_possible_wrap, @@ -415,7 +488,7 @@ mod tests { )] insert_into(evm_basic_grant::table) .values(NewEvmBasicGrant { - wallet_access_id: shared.wallet_access_id, + wallet_access_id, chain_id: shared.chain.into(), valid_from: shared.valid_from.map(SqliteTimestamp), valid_until: shared.valid_until.map(SqliteTimestamp), @@ -579,7 +652,7 @@ mod tests { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id: basic_grant.id, - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id: basic_grant.wallet_access_id, chain_id: CHAIN_ID.into(), eth_value: super::utils::u256_to_bytes(U256::ZERO).to_vec(), signed_at: SqliteTimestamp(Utc::now()), diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs index b9deb99..4d2cfa5 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs @@ -1,11 +1,12 @@ use super::{EtherTransfer, Settings}; use crate::{ db::{ - self, DatabaseConnection, + self, DatabaseConnection, models, models::{ EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp, }, + schema, schema::{evm_basic_grant, evm_transaction_log}, }, evm::{ @@ -19,7 +20,7 @@ use crate::{ use alloy::primitives::{Address, Bytes, U256, address}; use chrono::{Duration, Utc}; -use diesel::{SelectableHelper, insert_into}; +use diesel::{ExpressionMethods as _, SelectableHelper, insert_into}; use diesel_async::RunQueryDsl; const WALLET_ACCESS_ID: i32 = 1; @@ -45,10 +46,82 @@ fn ctx(to: Address, value: U256) -> EvalContext { } } +/// Creates the parent chain a fresh `evm_wallet_access` row needs under foreign-key +/// enforcement (a root key, an aead-encrypted secret, a wallet, and a client) and returns +/// the new access row's id. +async fn seed_wallet_access(conn: &mut DatabaseConnection) -> i32 { + let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table) + .values(&models::NewRootKeyHistory { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + root_key_encryption_nonce: vec![0u8; 24], + data_encryption_nonce: vec![0u8; 24], + schema_version: 1, + salt: vec![0u8; 16], + }) + .returning(schema::root_key_history::id) + .get_result(conn) + .await + .unwrap(); + + let aead_id: i32 = insert_into(schema::aead_encrypted::table) + .values(&models::NewAeadEncrypted { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + current_nonce: vec![0u8; 24], + schema_version: 1, + associated_root_key_id: root_key_id, + created_at: Utc::now().into(), + }) + .returning(schema::aead_encrypted::id) + .get_result(conn) + .await + .unwrap(); + + let wallet_id: EvmWalletId = insert_into(schema::evm_wallet::table) + .values(( + schema::evm_wallet::address.eq(rand::random::<[u8; 20]>().to_vec()), + schema::evm_wallet::aead_encrypted_id.eq(aead_id), + )) + .returning(schema::evm_wallet::id) + .get_result(conn) + .await + .unwrap(); + + let metadata_id: i32 = insert_into(schema::client_metadata::table) + .values(schema::client_metadata::name.eq("test")) + .returning(schema::client_metadata::id) + .get_result(conn) + .await + .unwrap(); + + let client_id: i32 = insert_into(schema::program_client::table) + .values(( + 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(conn) + .await + .unwrap(); + + insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(wallet_id), + schema::evm_wallet_access::client_id.eq(client_id), + )) + .returning(schema::evm_wallet_access::id) + .get_result(conn) + .await + .unwrap() +} + async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicGrant { + let wallet_access_id = seed_wallet_access(conn).await; + insert_into(evm_basic_grant::table) .values(NewEvmBasicGrant { - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id, chain_id: CHAIN_ID.into(), valid_from: None, valid_until: None, @@ -161,7 +234,7 @@ async fn evaluate_passes_when_volume_within_limit() { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id, - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id: basic.wallet_access_id, chain_id: CHAIN_ID.into(), eth_value: utils::u256_to_bytes(U256::from(500u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), @@ -203,7 +276,7 @@ async fn evaluate_rejects_volume_over_limit() { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id, - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id: basic.wallet_access_id, chain_id: CHAIN_ID.into(), eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), @@ -246,7 +319,7 @@ async fn evaluate_passes_at_exactly_volume_limit() { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id, - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id: basic.wallet_access_id, chain_id: CHAIN_ID.into(), eth_value: utils::u256_to_bytes(U256::from(900u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs index f2c02b3..4f6afd0 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs @@ -1,8 +1,9 @@ use super::{Settings, TokenTransfer}; use crate::{ db::{ - self, DatabaseConnection, + self, DatabaseConnection, models, models::{EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, SqliteTimestamp}, + schema, schema::evm_basic_grant, }, evm::{ @@ -20,7 +21,7 @@ use alloy::{ sol_types::SolCall, }; use chrono::{Duration, Utc}; -use diesel::{SelectableHelper, insert_into}; +use diesel::{ExpressionMethods as _, SelectableHelper, insert_into}; use diesel_async::RunQueryDsl; // DAI on Ethereum mainnet — present in the static token registry @@ -58,10 +59,82 @@ fn ctx(to: Address, calldata: Bytes) -> EvalContext { } } +/// Creates the parent chain a fresh `evm_wallet_access` row needs under foreign-key +/// enforcement (a root key, an aead-encrypted secret, a wallet, and a client) and returns +/// the new access row's id. +async fn seed_wallet_access(conn: &mut DatabaseConnection) -> i32 { + let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table) + .values(&models::NewRootKeyHistory { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + root_key_encryption_nonce: vec![0u8; 24], + data_encryption_nonce: vec![0u8; 24], + schema_version: 1, + salt: vec![0u8; 16], + }) + .returning(schema::root_key_history::id) + .get_result(conn) + .await + .unwrap(); + + let aead_id: i32 = insert_into(schema::aead_encrypted::table) + .values(&models::NewAeadEncrypted { + ciphertext: vec![0u8; 32], + tag: vec![0u8; 16], + current_nonce: vec![0u8; 24], + schema_version: 1, + associated_root_key_id: root_key_id, + created_at: Utc::now().into(), + }) + .returning(schema::aead_encrypted::id) + .get_result(conn) + .await + .unwrap(); + + let wallet_id: EvmWalletId = insert_into(schema::evm_wallet::table) + .values(( + schema::evm_wallet::address.eq(rand::random::<[u8; 20]>().to_vec()), + schema::evm_wallet::aead_encrypted_id.eq(aead_id), + )) + .returning(schema::evm_wallet::id) + .get_result(conn) + .await + .unwrap(); + + let metadata_id: i32 = insert_into(schema::client_metadata::table) + .values(schema::client_metadata::name.eq("test")) + .returning(schema::client_metadata::id) + .get_result(conn) + .await + .unwrap(); + + let client_id: i32 = insert_into(schema::program_client::table) + .values(( + 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(conn) + .await + .unwrap(); + + insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(wallet_id), + schema::evm_wallet_access::client_id.eq(client_id), + )) + .returning(schema::evm_wallet_access::id) + .get_result(conn) + .await + .unwrap() +} + async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicGrant { + let wallet_access_id = seed_wallet_access(conn).await; + insert_into(evm_basic_grant::table) .values(NewEvmBasicGrant { - wallet_access_id: WALLET_ACCESS_ID, + wallet_access_id, chain_id: CHAIN_ID.into(), valid_from: None, valid_until: None, @@ -77,6 +150,27 @@ async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicG .unwrap() } +/// `evm_token_transfer_log.log_id` references the shared `evm_transaction_log` table, so +/// tests recording a token transfer need a real row there to point at. +async fn insert_transaction_log( + conn: &mut DatabaseConnection, + basic: &EvmBasicGrant, + eth_value: U256, +) -> i32 { + insert_into(schema::evm_transaction_log::table) + .values(models::NewEvmTransactionLog { + grant_id: basic.id, + wallet_access_id: basic.wallet_access_id, + chain_id: CHAIN_ID.into(), + eth_value: utils::u256_to_bytes(eth_value).to_vec(), + signed_at: SqliteTimestamp(Utc::now()), + }) + .returning(schema::evm_transaction_log::id) + .get_result(conn) + .await + .unwrap() +} + fn make_settings(target: Option
, max_volume: Option) -> Settings { Settings { token_contract: DAI, @@ -241,10 +335,11 @@ async fn evaluate_passes_volume_at_exact_limit() { .unwrap(); // Record a past transfer of 900, with current transfer 100 => exactly 1000 limit - insert_into(db::schema::evm_token_transfer_log::table) - .values(db::models::NewEvmTokenTransferLog { + let log_id = insert_transaction_log(&mut conn, &basic, U256::from(900u64)).await; + insert_into(schema::evm_token_transfer_log::table) + .values(models::NewEvmTokenTransferLog { grant_id, - log_id: 0, + log_id, chain_id: CHAIN_ID.into(), token_contract: DAI.to_vec(), recipient_address: RECIPIENT.to_vec(), @@ -285,10 +380,11 @@ async fn evaluate_rejects_volume_over_limit() { .await .unwrap(); - insert_into(db::schema::evm_token_transfer_log::table) - .values(db::models::NewEvmTokenTransferLog { + let log_id = insert_transaction_log(&mut conn, &basic, U256::from(1_000u64)).await; + insert_into(schema::evm_token_transfer_log::table) + .values(models::NewEvmTokenTransferLog { grant_id, - log_id: 0, + log_id, chain_id: CHAIN_ID.into(), token_contract: DAI.to_vec(), recipient_address: RECIPIENT.to_vec(), diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 2c5ba2d..7e4cb13 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -21,8 +21,8 @@ use arbiter_server::{ }; use arbiter_server::actors::vault::Bootstrap; use arbiter_server::db::schema::{ - aead_encrypted, evm_basic_grant, evm_wallet, evm_wallet_access, operator_identity, - proposal_one_off_transaction_result, recovery_operator_identity, + aead_encrypted, arbiter_settings, evm_basic_grant, evm_wallet, evm_wallet_access, + operator_identity, proposal_one_off_transaction_result, recovery_operator_identity, }; use diesel::{ExpressionMethods, QueryDsl, insert_into}; use diesel_async::RunQueryDsl; @@ -82,14 +82,22 @@ async fn insert_active_wakeup(db: &db::DatabasePool, operator_id: OperatorIdenti .unwrap(); } +/// Requires the vault to already be bootstrapped: it reads the root key row `Bootstrap` +/// creates so the aead-encrypted wallet secret has a real parent to reference. async fn insert_evm_wallet(db: &db::DatabasePool) -> i32 { let mut conn = db.get().await.unwrap(); + let root_key_id: i32 = arbiter_settings::table + .select(arbiter_settings::root_key_id) + .first::>(&mut conn) + .await + .unwrap() + .expect("vault must be bootstrapped before creating an aead-encrypted row"); let aead_id: i32 = insert_into(aead_encrypted::table) .values(( aead_encrypted::current_nonce.eq(vec![0u8; 4]), aead_encrypted::ciphertext.eq(vec![0u8; 32]), aead_encrypted::tag.eq(vec![0u8; 16]), - aead_encrypted::associated_root_key_id.eq(0i32), + aead_encrypted::associated_root_key_id.eq(root_key_id), )) .returning(aead_encrypted::id) .get_result::(&mut conn) @@ -143,11 +151,16 @@ async fn create_proposal_returns_id() { .await .unwrap(); + let key = authn::SigningKey::generate(); + let operator_id = register_operator(&db, &key.public_key()).await; + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: 42 }), - initiator_id: OperatorIdentityId::from_raw(1), + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), + initiator_id: operator_id, ttl_secs: None, }) .await @@ -170,12 +183,14 @@ async fn create_proposal_caps_the_ttl() { let key = authn::SigningKey::generate(); let op = register_operator(&db, &key.public_key()).await; + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; let create = async |ttl: u32| { actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: 1 }), + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), initiator_id: op, ttl_secs: Some(ttl), }) @@ -429,7 +444,9 @@ async fn query_pending_reports_a_tally_per_proposal() { }; let mut ids = Vec::new(); - for client_id in 1..=3 { + for _ in 1..=3 { + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; let id = actors .proposal_manager .ask(CreateProposal {