diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr index 4eec04094f52..c5165f594d43 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr @@ -132,21 +132,22 @@ struct UtilityContextOptions { /// ```noir /// env.call_private_opts(from, CallPrivateOptions::new().with_additional_scopes([other]), call); /// ``` -pub struct CallPrivateOptions { +pub struct CallPrivateOptions { additional_scopes: [AztecAddress; N], + authorized_utility_call_targets: [AztecAddress; T], } -impl CallPrivateOptions<0> { +impl CallPrivateOptions<0, 0> { /// Creates default `CallPrivateOptions`. /// /// The default values are the same as if using [`TestEnvironment::call_private`] instead of /// [`TestEnvironment::call_private_opts`]. pub fn new() -> Self { - CallPrivateOptions { additional_scopes: [] } + CallPrivateOptions { additional_scopes: [], authorized_utility_call_targets: [] } } } -impl CallPrivateOptions { +impl CallPrivateOptions { /// Grants access to secrets of additional accounts. /// /// By default only the secrets that belong to the `from` account can be accessed: this function lets contracts @@ -155,10 +156,27 @@ impl CallPrivateOptions { /// Example usage includes accounts withdrawing from an escrow, which may require accessing the escrow's own notes /// and secrets. pub fn with_additional_scopes( - _self: Self, + self, additional_scopes: [AztecAddress; N_2], - ) -> CallPrivateOptions { - CallPrivateOptions { additional_scopes } + ) -> CallPrivateOptions { + CallPrivateOptions { + additional_scopes, + authorized_utility_call_targets: self.authorized_utility_call_targets, + } + } + + /// Authorizes cross-contract utility calls to the given target contracts during this call. + /// + /// By default, cross-contract utility calls are denied. This method lets the test author specify which target + /// contracts are allowed to be called via utility functions during execution. + pub fn with_authorized_utility_call_targets( + self, + targets: [AztecAddress; T_2], + ) -> CallPrivateOptions { + CallPrivateOptions { + additional_scopes: self.additional_scopes, + authorized_utility_call_targets: targets, + } } } @@ -169,21 +187,22 @@ impl CallPrivateOptions { /// ```noir /// env.view_private_opts(from, ViewPrivateOptions::new().with_additional_scopes([other]), call); /// ``` -pub struct ViewPrivateOptions { +pub struct ViewPrivateOptions { additional_scopes: [AztecAddress; S], + authorized_utility_call_targets: [AztecAddress; T], } -impl ViewPrivateOptions<0> { +impl ViewPrivateOptions<0, 0> { /// Creates default `ViewPrivateOptions`. /// /// The default values are the same as if using [`TestEnvironment::view_private`] instead of /// [`TestEnvironment::view_private_opts`]. pub fn new() -> Self { - ViewPrivateOptions { additional_scopes: [] } + ViewPrivateOptions { additional_scopes: [], authorized_utility_call_targets: [] } } } -impl ViewPrivateOptions { +impl ViewPrivateOptions { /// Grants access to secrets of additional accounts. /// /// By default only the secrets that belong to the `from` account can be accessed: this function lets contracts @@ -192,10 +211,27 @@ impl ViewPrivateOptions { /// Example usage includes accounts querying an escrow's balance, which may require accessing the escrow's own /// notes. pub fn with_additional_scopes( - _self: Self, + self, additional_scopes: [AztecAddress; S2], - ) -> ViewPrivateOptions { - ViewPrivateOptions { additional_scopes } + ) -> ViewPrivateOptions { + ViewPrivateOptions { + additional_scopes, + authorized_utility_call_targets: self.authorized_utility_call_targets, + } + } + + /// Authorizes cross-contract utility calls to the given target contracts during this call. + /// + /// By default, cross-contract utility calls are denied. This method lets the test author specify which target + /// contracts are allowed to be called via utility functions during execution. + pub fn with_authorized_utility_call_targets( + self, + targets: [AztecAddress; T_2], + ) -> ViewPrivateOptions { + ViewPrivateOptions { + additional_scopes: self.additional_scopes, + authorized_utility_call_targets: targets, + } } } @@ -207,6 +243,40 @@ struct EventDiscoveryOptions { contract_address: Option, } +/// Configuration for [`TestEnvironment::execute_utility_opts`]. +/// +/// Constructed by calling [`ExecuteUtilityOptions::new`] and then chaining methods: +/// +/// ```noir +/// env.execute_utility_opts( +/// ExecuteUtilityOptions::new().with_authorized_utility_call_targets([target]), +/// SomeContract::at(addr).some_utility_function(), +/// ); +/// ``` +pub struct ExecuteUtilityOptions { + authorized_utility_call_targets: [AztecAddress; T], +} + +impl ExecuteUtilityOptions<0> { + /// Creates default `ExecuteUtilityOptions`. + pub fn new() -> Self { + ExecuteUtilityOptions { authorized_utility_call_targets: [] } + } +} + +impl ExecuteUtilityOptions { + /// Authorizes cross-contract utility calls to the given target contracts during this call. + /// + /// By default, cross-contract utility calls are denied. This method lets the test author specify which target + /// contracts are allowed to be called via utility functions during execution. + pub fn with_authorized_utility_call_targets( + _self: Self, + targets: [AztecAddress; T_2], + ) -> ExecuteUtilityOptions { + ExecuteUtilityOptions { authorized_utility_call_targets: targets } + } +} + /// Configuration values for [`TestEnvironment::deploy_opts`]. Meant to be used by calling `new` and then chaining /// methods setting each value, e.g.: /// ```noir @@ -629,10 +699,10 @@ impl TestEnvironment { } /// Variant of `call_private` which allows specifying multiple configuration values via `CallPrivateOptions`. - pub unconstrained fn call_private_opts( + pub unconstrained fn call_private_opts( _self: Self, from: AztecAddress, - opts: CallPrivateOptions, + opts: CallPrivateOptions, call: PrivateCall, ) -> T where @@ -646,6 +716,7 @@ impl TestEnvironment { hash_args(call.args), /*is_static=*/ false, opts.additional_scopes, + opts.authorized_utility_call_targets, ); T::deserialize(serialized_return_values) @@ -670,10 +741,10 @@ impl TestEnvironment { } /// Variant of `view_private` which allows specifying multiple configuration values via `ViewPrivateOptions`. - pub unconstrained fn view_private_opts( + pub unconstrained fn view_private_opts( _self: Self, from: AztecAddress, - opts: ViewPrivateOptions, + opts: ViewPrivateOptions, call: PrivateStaticCall, ) -> T where @@ -687,6 +758,7 @@ impl TestEnvironment { hash_args(call.args), /*is_static=*/ true, opts.additional_scopes, + opts.authorized_utility_call_targets, ); T::deserialize(serialized_return_values) @@ -701,12 +773,32 @@ impl TestEnvironment { /// let contract_addr = env.deploy("SampleContract").without_initializer(); /// let return_value = env.execute_utility(SampleContract::at(contract_addr).sample_utility_function()); /// ``` - pub unconstrained fn execute_utility(_self: Self, call: UtilityCall) -> T + pub unconstrained fn execute_utility( + self: Self, + call: UtilityCall, + ) -> T where T: Deserialize, { - let serialized_return_values = - txe_oracles::execute_utility_function(call.target_contract, call.selector, call.args); + self.execute_utility_opts(ExecuteUtilityOptions::new(), call) + } + + /// Variant of `execute_utility` which allows specifying configuration values via `ExecuteUtilityOptions`, such as + /// authorizing cross-contract utility calls. + pub unconstrained fn execute_utility_opts( + _self: Self, + opts: ExecuteUtilityOptions, + call: UtilityCall, + ) -> T + where + T: Deserialize, + { + let serialized_return_values = txe_oracles::execute_utility_function( + call.target_contract, + call.selector, + call.args, + opts.authorized_utility_call_targets, + ); T::deserialize(serialized_return_values) } diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr index 8ab6ecb42db4..ed82665af037 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr @@ -36,7 +36,7 @@ pub unconstrained fn deploy( ContractInstance::deserialize(instance_fields) } -pub unconstrained fn private_call_new_flow( +pub unconstrained fn private_call_new_flow( from: Option, contract_address: AztecAddress, function_selector: FunctionSelector, @@ -44,6 +44,7 @@ pub unconstrained fn private_call_new_flow( args_hash: Field, is_static_call: bool, additional_scopes: [AztecAddress; S], + authorized_utility_call_targets: [AztecAddress; T], ) -> [Field; N] { private_call_new_flow_oracle( from, @@ -53,6 +54,7 @@ pub unconstrained fn private_call_new_flow( args_hash, is_static_call, additional_scopes, + authorized_utility_call_targets, ) } @@ -68,12 +70,18 @@ pub unconstrained fn public_call_new_flow( public_call_new_flow_oracle(from, contract_address, calldata, is_static_call) } -pub unconstrained fn execute_utility_function( +pub unconstrained fn execute_utility_function( contract_address: AztecAddress, function_selector: FunctionSelector, args: [Field; M], + authorized_utility_call_targets: [AztecAddress; T], ) -> [Field; N] { - execute_utility_function_oracle(contract_address, function_selector, args) + execute_utility_function_oracle( + contract_address, + function_selector, + args, + authorized_utility_call_targets, + ) } #[oracle(aztec_txe_getNextBlockNumber)] @@ -209,7 +217,7 @@ pub unconstrained fn add_account(secret: Field) -> TestAccount {} pub unconstrained fn add_authwit(address: AztecAddress, message_hash: Field) {} #[oracle(aztec_txe_privateCallNewFlow)] -unconstrained fn private_call_new_flow_oracle( +unconstrained fn private_call_new_flow_oracle( _from: Option, _contract_address: AztecAddress, _function_selector: FunctionSelector, @@ -217,6 +225,7 @@ unconstrained fn private_call_new_flow_oracle [Field; N] {} #[oracle(aztec_txe_publicCallNewFlow)] @@ -228,10 +237,11 @@ unconstrained fn public_call_new_flow_oracle( ) -> [Field; N] {} #[oracle(aztec_txe_executeUtilityFunction)] -unconstrained fn execute_utility_function_oracle( +unconstrained fn execute_utility_function_oracle( contract_address: AztecAddress, function_selector: FunctionSelector, args: [Field; M], + authorized_utility_call_targets: [AztecAddress; T], ) -> [Field; N] {} #[oracle(aztec_txe_setTopLevelTXEContext)] diff --git a/noir-projects/noir-contracts/contracts/test/nested_utility_contract/src/main.nr b/noir-projects/noir-contracts/contracts/test/nested_utility_contract/src/main.nr index c5a5810d10de..01b6a503a800 100644 --- a/noir-projects/noir-contracts/contracts/test/nested_utility_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/test/nested_utility_contract/src/main.nr @@ -3,6 +3,8 @@ use aztec::macros::aztec; +mod test; + #[aztec] pub contract NestedUtility { use aztec::macros::functions::external; diff --git a/noir-projects/noir-contracts/contracts/test/nested_utility_contract/src/test.nr b/noir-projects/noir-contracts/contracts/test/nested_utility_contract/src/test.nr new file mode 100644 index 000000000000..51fe8fc5c221 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/test/nested_utility_contract/src/test.nr @@ -0,0 +1,72 @@ +use crate::NestedUtility; +use aztec::{ + protocol::address::AztecAddress, + test::helpers::test_environment::{CallPrivateOptions, ExecuteUtilityOptions, TestEnvironment}, +}; + +unconstrained fn setup() -> (TestEnvironment, AztecAddress, AztecAddress, AztecAddress) { + let mut env = TestEnvironment::new(); + let account = env.create_light_account(); + let addr_a = env.deploy("NestedUtility").without_initializer(); + let addr_b = env.deploy("NestedUtility").without_initializer(); + (env, account, addr_a, addr_b) +} + +#[test] +unconstrained fn same_contract_utility_call_from_utility_succeeds() { + let (env, _, addr_a, _) = setup(); + + let result: Field = env.execute_utility(NestedUtility::at(addr_a).pow_utility(2, 10)); + assert_eq(result, 1024); +} + +#[test] +unconstrained fn same_contract_utility_call_from_private_succeeds() { + let (env, account, addr_a, _) = setup(); + + let result: Field = + env.call_private(account, NestedUtility::at(addr_a).pow_private(2, 10)); + assert_eq(result, 1024); +} + +#[test(should_fail_with = "Cross-contract utility call denied")] +unconstrained fn cross_contract_utility_call_from_utility_denied_by_default() { + let (env, _, addr_a, addr_b) = setup(); + + let _: Field = env.execute_utility( + NestedUtility::at(addr_a).delegate_pow_utility(addr_b, 2, 3), + ); +} + +#[test(should_fail_with = "Cross-contract utility call denied")] +unconstrained fn cross_contract_utility_call_from_private_denied_by_default() { + let (env, account, addr_a, addr_b) = setup(); + + let _: Field = env.call_private( + account, + NestedUtility::at(addr_a).delegate_pow_private(addr_b, 2, 3), + ); +} + +#[test] +unconstrained fn cross_contract_utility_call_from_utility_succeeds_with_authorization() { + let (env, _, addr_a, addr_b) = setup(); + + let result: Field = env.execute_utility_opts( + ExecuteUtilityOptions::new().with_authorized_utility_call_targets([addr_b]), + NestedUtility::at(addr_a).delegate_pow_utility(addr_b, 2, 3), + ); + assert_eq(result, 8); +} + +#[test] +unconstrained fn cross_contract_utility_call_from_private_succeeds_with_authorization() { + let (env, account, addr_a, addr_b) = setup(); + + let result: Field = env.call_private_opts( + account, + CallPrivateOptions::new().with_authorized_utility_call_targets([addr_b]), + NestedUtility::at(addr_a).delegate_pow_private(addr_b, 2, 3), + ); + assert_eq(result, 8); +} diff --git a/yarn-project/pxe/src/hooks/execution_hooks.ts b/yarn-project/pxe/src/hooks/execution_hooks.ts index e8ad86e762f2..7e4a3423f762 100644 --- a/yarn-project/pxe/src/hooks/execution_hooks.ts +++ b/yarn-project/pxe/src/hooks/execution_hooks.ts @@ -35,3 +35,14 @@ export interface ExecutionHooks { /** Called when a contract attempts a cross-contract utility call. */ authorizeUtilityCall: AuthorizeUtilityCall; } + +/** + * Builds an {@link ExecutionHooks} from individually-constructed hook callbacks. Returns `undefined` + * when every field is absent, so callers can unconditionally pass the result as `hooks`. + */ +export function composeHooks(partial: Partial): ExecutionHooks | undefined { + if (Object.values(partial).every(v => v === undefined)) { + return undefined; + } + return partial as ExecutionHooks; +} diff --git a/yarn-project/pxe/src/hooks/index.ts b/yarn-project/pxe/src/hooks/index.ts index 77be368eb7b8..b9ceb80c404e 100644 --- a/yarn-project/pxe/src/hooks/index.ts +++ b/yarn-project/pxe/src/hooks/index.ts @@ -4,3 +4,4 @@ export type { UtilityCallAuthorizationResponse, } from './authorize_utility_call.js'; export type { ExecutionHooks } from './execution_hooks.js'; +export { composeHooks } from './execution_hooks.js'; diff --git a/yarn-project/txe/src/oracle/interfaces.ts b/yarn-project/txe/src/oracle/interfaces.ts index 773e69d5a9df..7aaf8e94c95a 100644 --- a/yarn-project/txe/src/oracle/interfaces.ts +++ b/yarn-project/txe/src/oracle/interfaces.ts @@ -71,12 +71,14 @@ export interface ITxeExecutionOracle { isStaticCall: boolean, additionalScopes: AztecAddress[], jobId: string, + authorizedUtilityCallTargets: AztecAddress[], ): Promise<{ returnValues: Fr[]; offchainEffects: Fr[][] }>; executeUtilityFunction( targetContractAddress: AztecAddress, functionSelector: FunctionSelector, args: Fr[], jobId: string, + authorizedUtilityCallTargets: AztecAddress[], ): Promise; publicCallNewFlow( from: AztecAddress | undefined, diff --git a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts index ff53702ddd8b..d7592c6a8899 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts @@ -15,12 +15,14 @@ import { CapsuleService, CapsuleStore, type ContractStore, + type ExecutionHooks, NoteStore, ORACLE_VERSION_MAJOR, PrivateEventStore, RecipientTaggingStore, SenderAddressBookStore, SenderTaggingStore, + composeHooks, enrichPublicSimulationError, } from '@aztec/pxe/server'; import { @@ -326,6 +328,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl isStaticCall: boolean = false, additionalScopes: AztecAddress[] = [], jobId: string, + authorizedUtilityCallTargets: AztecAddress[], ) { this.logger.verbose( `Executing external function ${await this.contractStore.getDebugFunctionName(targetContractAddress, functionSelector)}@${targetContractAddress} isStaticCall=${isStaticCall}`, @@ -410,6 +413,9 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl simulator, messageContextService: this.stateMachine.messageContextService, l2TipsStore: this.stateMachine.node, + hooks: composeHooks({ + authorizeUtilityCall: this.buildAuthorizeUtilityCallHook('private', authorizedUtilityCallTargets), + }), }); // Note: This is a slight modification of simulator.run without any of the checks. Maybe we should modify simulator.run with a boolean value to skip checks. @@ -712,6 +718,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl functionSelector: FunctionSelector, args: Fr[], jobId: string, + authorizedUtilityCallTargets: AztecAddress[], ) { const artifact = await this.contractStore.getFunctionArtifact(targetContractAddress, functionSelector); if (!artifact) { @@ -742,10 +749,15 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl returnTypes: [], }); - return this.executeUtilityCall(call, await this.keyStore.getAccounts(), jobId); + return this.executeUtilityCall(call, await this.keyStore.getAccounts(), jobId, authorizedUtilityCallTargets); } - private async executeUtilityCall(call: FunctionCall, scopes: AztecAddress[], jobId: string): Promise { + private async executeUtilityCall( + call: FunctionCall, + scopes: AztecAddress[], + jobId: string, + authorizedUtilityCallTargets: AztecAddress[] = [], + ): Promise { const entryPointArtifact = await this.contractStore.getFunctionArtifactWithDebugMetadata(call.to, call.selector); if (entryPointArtifact.functionType !== FunctionType.UTILITY) { throw new Error(`Cannot run ${entryPointArtifact.functionType} function as utility`); @@ -779,6 +791,9 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl jobId, scopes, simulator, + hooks: composeHooks({ + authorizeUtilityCall: this.buildAuthorizeUtilityCallHook('utility', authorizedUtilityCallTargets), + }), }); const acirExecutionResult = await simulator .executeUserCircuit(toACVMWitness(0, call.args), entryPointArtifact, new Oracle(oracle).toACIRCallback()) @@ -811,4 +826,17 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl const header = await this.stateMachine.node.getBlockHeader('latest'); return header ? header.globalVariables.blockNumber : BlockNumber.ZERO; } + + private buildAuthorizeUtilityCallHook( + callerContext: 'private' | 'utility', + authorizedTargets: AztecAddress[], + ): ExecutionHooks['authorizeUtilityCall'] | undefined { + if (authorizedTargets.length === 0) { + return undefined; + } + return req => + Promise.resolve({ + authorized: req.callerContext === callerContext && authorizedTargets.some(t => t.equals(req.target)), + }); + } } diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index b3ea3e915a7c..61e66d730368 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -1352,6 +1352,7 @@ export class RPCTranslator { foreignArgsHash: ForeignCallSingle, foreignIsStaticCall: ForeignCallSingle, foreignAdditionalScopes: ForeignCallArray, + foreignAuthorizedUtilityCallTargets: ForeignCallArray, ) { const from = fromSingle(foreignFromIsSome).toBool() ? addressFromSingle(foreignFromValue) : undefined; const targetContractAddress = addressFromSingle(foreignTargetContractAddress); @@ -1360,6 +1361,9 @@ export class RPCTranslator { const argsHash = fromSingle(foreignArgsHash); const isStaticCall = fromSingle(foreignIsStaticCall).toBool(); const additionalScopes = fromArray(foreignAdditionalScopes).map(field => AztecAddress.fromField(field)); + const authorizedUtilityCallTargets = fromArray(foreignAuthorizedUtilityCallTargets).map(field => + AztecAddress.fromField(field), + ); const returnValues = await this.stateHandler.withTopLevelCallTracking(async () => { const { returnValues, offchainEffects } = await this.handlerAsTxe().privateCallNewFlow( @@ -1371,6 +1375,7 @@ export class RPCTranslator { isStaticCall, additionalScopes, this.stateHandler.getCurrentJob(), + authorizedUtilityCallTargets, ); // Private execution collects offchain effects inside PXE's PrivateExecutionOracle rather than @@ -1402,10 +1407,14 @@ export class RPCTranslator { foreignTargetContractAddress: ForeignCallSingle, foreignFunctionSelector: ForeignCallSingle, foreignArgs: ForeignCallArray, + foreignAuthorizedUtilityCallTargets: ForeignCallArray, ) { const targetContractAddress = addressFromSingle(foreignTargetContractAddress); const functionSelector = FunctionSelector.fromField(fromSingle(foreignFunctionSelector)); const args = fromArray(foreignArgs); + const authorizedUtilityCallTargets = fromArray(foreignAuthorizedUtilityCallTargets).map(field => + AztecAddress.fromField(field), + ); const returnValues = await this.stateHandler.withTopLevelCallTracking(async () => { const returnValues = await this.handlerAsTxe().executeUtilityFunction( @@ -1413,6 +1422,7 @@ export class RPCTranslator { functionSelector, args, this.stateHandler.getCurrentJob(), + authorizedUtilityCallTargets, ); // TODO(F-335): Avoid doing the following call here.