diff --git a/contracts/contract-factory/src/lib.rs b/contracts/contract-factory/src/lib.rs index 4ab212d..38391b8 100644 --- a/contracts/contract-factory/src/lib.rs +++ b/contracts/contract-factory/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, vec, xdr::ToXdr, Address, Bytes, BytesN, - Env, Symbol, Val, Vec, + contract, contracterror, contractimpl, contracttype, symbol_short, vec, xdr::ToXdr, Address, + Bytes, BytesN, Env, Symbol, Val, Vec, }; const DEPLOYED_CONTRACT: Symbol = symbol_short!("DEPLOYED"); @@ -13,12 +13,35 @@ const INSTANCE_EXTEND_TO: u32 = 30 * DAY_IN_LEDGERS; #[contract] pub struct ContractFactory; +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum FactoryError { + DeploymentFailed = 1, + InnerCallFailed = 2, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct ContractDeploymentArgs { - wasm_hash: BytesN<32>, - salt: BytesN<32>, - constructor_args: Vec, + pub wasm_hash: BytesN<32>, + pub salt: BytesN<32>, + pub constructor_args: Vec, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractCall { + pub target: Address, + pub function: Symbol, + pub args: Vec, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeployAndCallResult { + pub address: Address, + pub results: Vec, } #[contracttype] @@ -76,8 +99,31 @@ impl ContractFactory { contract_id } + fn predict_and_check_deployed( + env: &Env, + deployment_args: &ContractDeploymentArgs, + ) -> (Address, bool) { + let tentative_contract_id = Self::get_deployed_address( + env, + deployment_args.salt.clone(), + deployment_args.wasm_hash.clone(), + deployment_args.constructor_args.clone(), + ); + let is_deployed = env + .try_invoke_contract::( + &tentative_contract_id, + &Symbol::new(env, "is_deployed"), + Vec::new(env), + ) + .is_ok(); + (tentative_contract_id, is_deployed) + } + /// Deploys a contract on behalf of the `ContractFactory` contract. - pub fn deploy(env: &Env, deployment_args: ContractDeploymentArgs) -> Address { + pub fn deploy( + env: &Env, + deployment_args: ContractDeploymentArgs, + ) -> Result { Self::extend_instance_ttl(env); let ContractDeploymentArgs { wasm_hash, @@ -86,39 +132,85 @@ impl ContractFactory { } = deployment_args; let derived_salt = Self::derive_salt(env, salt, &wasm_hash, &constructor_args); - Self::deploy_and_emit(env, derived_salt, wasm_hash, constructor_args) + Ok(Self::deploy_and_emit( + env, + derived_salt, + wasm_hash, + constructor_args, + )) } /// Deploys a contract on behalf of the `ContractFactory` contract. /// If the contract is already deployed at the deterministic address, returns it. - pub fn deploy_idempotent(env: &Env, deployment_args: ContractDeploymentArgs) -> Address { + pub fn deploy_idempotent( + env: &Env, + deployment_args: ContractDeploymentArgs, + ) -> Result { Self::extend_instance_ttl(env); + let (tentative_contract_id, is_deployed) = + Self::predict_and_check_deployed(env, &deployment_args); + + if is_deployed { + return Ok(tentative_contract_id); + } + let ContractDeploymentArgs { wasm_hash, salt, constructor_args, } = deployment_args; - let tentative_contract_id = Self::get_deployed_address( + let derived_salt = Self::derive_salt(env, salt, &wasm_hash, &constructor_args); + Ok(Self::deploy_and_emit( env, - salt.clone(), - wasm_hash.clone(), - constructor_args.clone(), - ); - let is_deployed = env - .try_invoke_contract::( - &tentative_contract_id, - &Symbol::new(env, "is_deployed"), - Vec::new(env), - ) - .is_ok(); + derived_salt, + wasm_hash, + constructor_args, + )) + } - if is_deployed { - return tentative_contract_id; + /// Idempotently deploys a contract and then dispatches a sequence of inner + /// contract calls, returning the deployed address alongside the raw return + /// value of each inner call. + /// + /// If any inner call reverts, `FactoryError::InnerCallFailed` is returned + /// and the whole transaction is rolled back by the host. + pub fn deploy_idempotent_and_call( + env: &Env, + deployment_args: ContractDeploymentArgs, + calls: Vec, + ) -> Result { + Self::extend_instance_ttl(env); + + let (tentative_contract_id, is_deployed) = + Self::predict_and_check_deployed(env, &deployment_args); + + let address = if is_deployed { + tentative_contract_id + } else { + let ContractDeploymentArgs { + wasm_hash, + salt, + constructor_args, + } = deployment_args; + let derived_salt = Self::derive_salt(env, salt, &wasm_hash, &constructor_args); + Self::deploy_and_emit(env, derived_salt, wasm_hash, constructor_args) + }; + + let mut results: Vec = Vec::new(env); + for call in calls.iter() { + let result = env + .try_invoke_contract::( + &call.target, + &call.function, + call.args.clone(), + ) + .map_err(|_| FactoryError::InnerCallFailed)? + .map_err(|_| FactoryError::InnerCallFailed)?; + results.push_back(result); } - let derived_salt = Self::derive_salt(env, salt, &wasm_hash, &constructor_args); - Self::deploy_and_emit(env, derived_salt, wasm_hash, constructor_args) + Ok(DeployAndCallResult { address, results }) } /// Uploads the contract WASM and deploys it on behalf of the `ContractFactory` contract. @@ -127,11 +219,16 @@ impl ContractFactory { wasm_bytes: Bytes, salt: BytesN<32>, constructor_args: Vec, - ) -> Address { + ) -> Result { Self::extend_instance_ttl(env); let wasm_hash = env.deployer().upload_contract_wasm(wasm_bytes); let derived_salt = Self::derive_salt(env, salt, &wasm_hash, &constructor_args); - Self::deploy_and_emit(env, derived_salt, wasm_hash, constructor_args) + Ok(Self::deploy_and_emit( + env, + derived_salt, + wasm_hash, + constructor_args, + )) } pub fn get_deployed_address( diff --git a/contracts/contract-factory/src/test.rs b/contracts/contract-factory/src/test.rs index 034a6ba..82cb3a1 100644 --- a/contracts/contract-factory/src/test.rs +++ b/contracts/contract-factory/src/test.rs @@ -2,34 +2,82 @@ extern crate std; -use soroban_sdk::{testutils::Address as _, vec, Address, BytesN, Env, IntoVal, Val, Vec}; +use soroban_sdk::{ + contract, contractimpl, testutils::Address as _, vec, Address, BytesN, Env, IntoVal, Symbol, + Val, Vec, +}; use crate::test_constants::SMART_ACCOUNT_WASM; -use crate::{ContractDeploymentArgs, ContractFactory, ContractFactoryClient}; +use crate::{ + ContractCall, ContractDeploymentArgs, ContractFactory, ContractFactoryClient, FactoryError, +}; + +// ============================================================================ +// PoC inner-call target used for auth propagation tests +// ============================================================================ + +#[contract] +pub struct PocCallee; + +#[contractimpl] +impl PocCallee { + pub fn ping(_env: Env) -> u32 { + 42 + } + + pub fn greet(_env: Env, caller: Address) -> Address { + caller.require_auth(); + caller + } + + pub fn boom(_env: Env) -> u32 { + panic!("intentional failure") + } + + pub fn greet_two(_env: Env, first: Address, second: Address) -> Address { + first.require_auth(); + second.require_auth(); + first + } +} + +// ============================================================================ +// Helpers +// ============================================================================ fn create_factory_client<'a>(e: &Env) -> ContractFactoryClient<'a> { let address = e.register(ContractFactory, ()); ContractFactoryClient::new(e, &address) } -// Helper function to create a mock salt fn create_mock_salt(e: &Env, value: u8) -> BytesN<32> { let mut bytes = [0u8; 32]; - bytes[0] = value; // Make it unique + bytes[0] = value; BytesN::from_array(e, &bytes) } +fn default_deployment(e: &Env, salt_byte: u8) -> ContractDeploymentArgs { + let wasm_bytes = soroban_sdk::Bytes::from_slice(e, SMART_ACCOUNT_WASM); + let wasm_hash = e.deployer().upload_contract_wasm(wasm_bytes); + ContractDeploymentArgs { + wasm_hash, + salt: create_mock_salt(e, salt_byte), + constructor_args: Vec::::new(e), + } +} + +// ============================================================================ +// Existing tests +// ============================================================================ + #[test] fn test_get_deployed_address_without_deployment() { let e = Env::default(); let client = create_factory_client(&e); - let salt = create_mock_salt(&e, 1); - let wasm_bytes = soroban_sdk::Bytes::from_slice(&e, SMART_ACCOUNT_WASM); - let wasm_hash = e.deployer().upload_contract_wasm(wasm_bytes); - let constructor_args: Vec = vec![&e]; - - let predicted_address = client.get_deployed_address(&salt, &wasm_hash, &constructor_args); + let args = default_deployment(&e, 1); + let predicted_address = + client.get_deployed_address(&args.salt, &args.wasm_hash, &args.constructor_args); assert_ne!(predicted_address, Address::generate(&e)); } @@ -39,14 +87,13 @@ fn test_different_salts_produce_different_addresses() { let e = Env::default(); let client = create_factory_client(&e); - let salt1 = create_mock_salt(&e, 1); - let salt2 = create_mock_salt(&e, 2); - let wasm_bytes = soroban_sdk::Bytes::from_slice(&e, SMART_ACCOUNT_WASM); - let wasm_hash = e.deployer().upload_contract_wasm(wasm_bytes); - let constructor_args: Vec = vec![&e]; + let args1 = default_deployment(&e, 1); + let args2 = default_deployment(&e, 2); - let address1 = client.get_deployed_address(&salt1, &wasm_hash, &constructor_args); - let address2 = client.get_deployed_address(&salt2, &wasm_hash, &constructor_args); + let address1 = + client.get_deployed_address(&args1.salt, &args1.wasm_hash, &args1.constructor_args); + let address2 = + client.get_deployed_address(&args2.salt, &args2.wasm_hash, &args2.constructor_args); assert_ne!(address1, address2); } @@ -74,13 +121,10 @@ fn test_same_salt_produces_same_address() { let e = Env::default(); let client = create_factory_client(&e); - let salt = create_mock_salt(&e, 1); - let wasm_bytes = soroban_sdk::Bytes::from_slice(&e, SMART_ACCOUNT_WASM); - let wasm_hash = e.deployer().upload_contract_wasm(wasm_bytes); - let constructor_args: Vec = vec![&e]; + let args = default_deployment(&e, 1); - let address1 = client.get_deployed_address(&salt, &wasm_hash, &constructor_args); - let address2 = client.get_deployed_address(&salt, &wasm_hash, &constructor_args); + let address1 = client.get_deployed_address(&args.salt, &args.wasm_hash, &args.constructor_args); + let address2 = client.get_deployed_address(&args.salt, &args.wasm_hash, &args.constructor_args); assert_eq!(address1, address2); } @@ -90,23 +134,17 @@ fn test_address_prediction_before_and_after_deployment() { let e = Env::default(); let client = create_factory_client(&e); - let salt = create_mock_salt(&e, 42); - - let wasm_bytes = soroban_sdk::Bytes::from_slice(&e, SMART_ACCOUNT_WASM); - let wasm_hash = e.deployer().upload_contract_wasm(wasm_bytes); - let constructor_args: Vec = vec![&e]; + let args = default_deployment(&e, 42); - let predicted_address = client.get_deployed_address(&salt, &wasm_hash, &constructor_args); + let predicted_address = + client.get_deployed_address(&args.salt, &args.wasm_hash, &args.constructor_args); - let deployed_address = client.deploy(&ContractDeploymentArgs { - wasm_hash: wasm_hash.clone(), - salt: salt.clone(), - constructor_args: constructor_args.clone(), - }); + let deployed_address = client.deploy(&args); assert_eq!(predicted_address, deployed_address); - let predicted_address_after = client.get_deployed_address(&salt, &wasm_hash, &constructor_args); + let predicted_address_after = + client.get_deployed_address(&args.salt, &args.wasm_hash, &args.constructor_args); assert_eq!(predicted_address, predicted_address_after); } @@ -115,34 +153,18 @@ fn test_deploy_idempotency() { let e = Env::default(); let client = create_factory_client(&e); - let wasm_bytes = soroban_sdk::Bytes::from_slice(&e, SMART_ACCOUNT_WASM); - let wasm_hash = e.deployer().upload_contract_wasm(wasm_bytes); - let salt = create_mock_salt(&e, 1); - let constructor_args: Vec = vec![&e]; - - let predicted_address = client.get_deployed_address(&salt, &wasm_hash, &constructor_args); + let args = default_deployment(&e, 1); - let deployed_address1 = client.deploy(&ContractDeploymentArgs { - wasm_hash: wasm_hash.clone(), - salt, - constructor_args: constructor_args.clone(), - }); + let predicted_address = + client.get_deployed_address(&args.salt, &args.wasm_hash, &args.constructor_args); - let salt_copy = create_mock_salt(&e, 1); + let deployed_address1 = client.deploy(&args); assert_eq!(deployed_address1, predicted_address); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let wasm_bytes = soroban_sdk::Bytes::from_slice(&e, SMART_ACCOUNT_WASM); - let wasm_hash = e.deployer().upload_contract_wasm(wasm_bytes); - let salt = create_mock_salt(&e, 1); - let constructor_args: Vec = vec![&e]; - - client.deploy(&ContractDeploymentArgs { - wasm_hash, - salt, - constructor_args, - }) + let args = default_deployment(&e, 1); + client.deploy(&args) })); assert!( @@ -151,7 +173,7 @@ fn test_deploy_idempotency() { ); let predicted_address_after = - client.get_deployed_address(&salt_copy, &wasm_hash, &constructor_args); + client.get_deployed_address(&args.salt, &args.wasm_hash, &args.constructor_args); assert_eq!(predicted_address, predicted_address_after); assert_eq!(deployed_address1, predicted_address_after); } @@ -168,6 +190,296 @@ fn test_upload_and_deploy() { let deployed_address = client.upload_and_deploy(&wasm_bytes, &salt, &constructor_args); - // Verify that deployment actually worked by checking the address is valid assert!(!deployed_address.to_string().is_empty()); } + +// ============================================================================ +// deploy_idempotent_and_call +// ============================================================================ + +#[test] +fn test_deploy_idempotent_and_call_no_calls() { + let e = Env::default(); + let client = create_factory_client(&e); + + let args = default_deployment(&e, 10); + let predicted_address = + client.get_deployed_address(&args.salt, &args.wasm_hash, &args.constructor_args); + + let calls: Vec = Vec::new(&e); + let result = client.deploy_idempotent_and_call(&args, &calls); + + assert_eq!(result.address, predicted_address); + assert_eq!(result.results.len(), 0); +} + +#[test] +fn test_deploy_idempotent_and_call_multiple_calls_no_auth() { + let e = Env::default(); + let client = create_factory_client(&e); + let callee = e.register(PocCallee, ()); + + let args = default_deployment(&e, 11); + let predicted_address = + client.get_deployed_address(&args.salt, &args.wasm_hash, &args.constructor_args); + + let calls: Vec = vec![ + &e, + ContractCall { + target: callee.clone(), + function: Symbol::new(&e, "ping"), + args: Vec::new(&e), + }, + ContractCall { + target: callee.clone(), + function: Symbol::new(&e, "ping"), + args: Vec::new(&e), + }, + ]; + + let result = client.deploy_idempotent_and_call(&args, &calls); + + assert_eq!(result.address, predicted_address); + assert_eq!(result.results.len(), 2); + let r0: u32 = result.results.get(0).unwrap().into_val(&e); + let r1: u32 = result.results.get(1).unwrap().into_val(&e); + assert_eq!(r0, 42); + assert_eq!(r1, 42); +} + +#[test] +fn test_deploy_idempotent_and_call_inner_auth_succeeds_when_authorized() { + // PoC that `require_auth` inside an inner call dispatched by the factory + // is subject to Soroban's normal auth rules — it fails without any + // authorization and succeeds once the caller's auth is made available. + let e = Env::default(); + let client = create_factory_client(&e); + let callee = e.register(PocCallee, ()); + + let args = default_deployment(&e, 20); + let authorizer = Address::generate(&e); + + let calls: Vec = vec![ + &e, + ContractCall { + target: callee.clone(), + function: Symbol::new(&e, "greet"), + args: vec![&e, authorizer.to_val()], + }, + ]; + + // `require_auth` fires from inside an inner call whose parent + // (`deploy_idempotent_and_call`) did *not* itself require auth from + // `authorizer`. That makes it a non-root authorization in the auth tree, + // so the permissive helper is needed for mock_all_auths to accept it. + e.mock_all_auths_allowing_non_root_auth(); + + let result = client.deploy_idempotent_and_call(&args, &calls); + + assert_eq!(result.results.len(), 1); + let returned: Address = result.results.get(0).unwrap().into_val(&e); + assert_eq!(returned, authorizer); +} + +#[test] +fn test_deploy_idempotent_and_call_inner_auth_fails_without_auth() { + let e = Env::default(); + let client = create_factory_client(&e); + let callee = e.register(PocCallee, ()); + + let args = default_deployment(&e, 21); + let authorizer = Address::generate(&e); + + let calls: Vec = vec![ + &e, + ContractCall { + target: callee.clone(), + function: Symbol::new(&e, "greet"), + args: vec![&e, authorizer.to_val()], + }, + ]; + + // Without any auth mocks the inner require_auth must fail, and the error + // must surface as the typed FactoryError — not as a host-level abort. + let err = client + .try_deploy_idempotent_and_call(&args, &calls) + .expect_err("inner call with unauthorized require_auth must fail") + .expect("error must decode to FactoryError"); + assert_eq!(err, FactoryError::InnerCallFailed); +} + +#[test] +fn test_deploy_idempotent_and_call_reverts_when_inner_panics() { + let e = Env::default(); + let client = create_factory_client(&e); + let callee = e.register(PocCallee, ()); + + let args = default_deployment(&e, 30); + let calls: Vec = vec![ + &e, + ContractCall { + target: callee.clone(), + function: Symbol::new(&e, "boom"), + args: Vec::new(&e), + }, + ]; + + let err = client + .try_deploy_idempotent_and_call(&args, &calls) + .expect_err("panicking inner call must surface as error") + .expect("error must decode to FactoryError"); + assert_eq!(err, FactoryError::InnerCallFailed); +} + +// ============================================================================ +// PoC — end-to-end transaction requiring auth from an external account C +// ============================================================================ +// +// These tests model a real transaction flow: +// +// - An off-chain actor C (some Stellar account address) must authorize a +// contract call that is *not* made directly from C but rather dispatched +// as an inner call by the factory. +// +// - The user (or orchestrator) submits a single top-level invocation +// `factory.deploy_idempotent_and_call(args, [greet(C)])`. The +// transaction envelope carries C's authorization entry. +// +// - At host-call time, when `PocCallee::greet(C)` runs, `C.require_auth()` +// must find a matching authorization provided by C. The host otherwise +// aborts the whole transaction. +// +// Testing strategy: +// We use `env.mock_all_auths_allowing_non_root_auth()`, which permits the +// host to synthesize auth for `require_auth` calls made in sub-invocations +// (C's auth is "non-root" because the factory call is the root and the +// factory itself does not require C's auth). To *prove* that C's auth was +// actually consumed — not silently skipped — each test asserts on +// `env.auths()`, which lists every (address, invocation) pair whose auth +// was required during the last contract call. + +#[test] +fn poc_tx_requires_auth_from_external_account_c() { + let e = Env::default(); + let client = create_factory_client(&e); + let callee = e.register(PocCallee, ()); + + // The external account C. In a production flow this would be an Ed25519 + // (or custom-account) address whose owner signs an auth entry off-chain. + let c = Address::generate(&e); + + let args = default_deployment(&e, 100); + let calls: Vec = vec![ + &e, + ContractCall { + target: callee.clone(), + function: Symbol::new(&e, "greet"), + args: vec![&e, c.to_val()], + }, + ]; + + // Allow non-root auth since C.require_auth() fires inside a sub-call of + // the factory, and the factory itself does not require C's auth at its + // top-level entrypoint. + e.mock_all_auths_allowing_non_root_auth(); + + let result = client.deploy_idempotent_and_call(&args, &calls); + + // Functional assertion: the call returned C as the greeted address. + let returned: Address = result.results.get(0).unwrap().into_val(&e); + assert_eq!(returned, c); + + // Auth assertion: env.auths() confirms C's authorization was required for + // exactly the greet() invocation, which is the contract behavior we care + // about in a real transaction. + let auths = e.auths(); + assert!( + auths.iter().any(|(addr, invocation)| { + addr == &c + && invocation.function + == soroban_sdk::testutils::AuthorizedFunction::Contract(( + callee.clone(), + Symbol::new(&e, "greet"), + vec![&e, c.to_val()], + )) + }), + "expected C ({:?}) to authorize greet; got auths = {:?}", + c, + auths + ); +} + +#[test] +fn poc_tx_without_c_auth_is_rejected() { + // Same setup as the happy-path PoC, but with no auth mocks configured: + // the host must abort the whole transaction because C.require_auth() + // inside the inner call has nothing to match against. We assert that + // failure surfaces as our typed FactoryError. + let e = Env::default(); + let client = create_factory_client(&e); + let callee = e.register(PocCallee, ()); + let c = Address::generate(&e); + + let args = default_deployment(&e, 101); + let calls: Vec = vec![ + &e, + ContractCall { + target: callee.clone(), + function: Symbol::new(&e, "greet"), + args: vec![&e, c.to_val()], + }, + ]; + + let err = client + .try_deploy_idempotent_and_call(&args, &calls) + .expect_err("missing C auth must fail the transaction") + .expect("error must decode to FactoryError"); + assert_eq!(err, FactoryError::InnerCallFailed); +} + +#[test] +fn poc_tx_requires_auth_from_two_distinct_external_accounts() { + // Two-account variant: both C1 and C2 must authorize the inner call. + // Proves auth is tracked per-address, not globally: omitting either + // account's entry from env.auths() would mean the corresponding + // require_auth was not enforced. + let e = Env::default(); + let client = create_factory_client(&e); + let callee = e.register(PocCallee, ()); + + let c1 = Address::generate(&e); + let c2 = Address::generate(&e); + + let args = default_deployment(&e, 102); + let calls: Vec = vec![ + &e, + ContractCall { + target: callee.clone(), + function: Symbol::new(&e, "greet_two"), + args: vec![&e, c1.to_val(), c2.to_val()], + }, + ]; + + e.mock_all_auths_allowing_non_root_auth(); + let result = client.deploy_idempotent_and_call(&args, &calls); + assert_eq!(result.results.len(), 1); + + let auths = e.auths(); + let expected_fn = soroban_sdk::testutils::AuthorizedFunction::Contract(( + callee.clone(), + Symbol::new(&e, "greet_two"), + vec![&e, c1.to_val(), c2.to_val()], + )); + assert!( + auths + .iter() + .any(|(addr, inv)| addr == &c1 && inv.function == expected_fn), + "C1 auth missing" + ); + assert!( + auths + .iter() + .any(|(addr, inv)| addr == &c2 && inv.function == expected_fn), + "C2 auth missing" + ); +}