diff --git a/docs/docs-developers/docs/aztec-js/how_to_test.md b/docs/docs-developers/docs/aztec-js/how_to_test.md index 9b9ccfba7f95..0f5526ab25ba 100644 --- a/docs/docs-developers/docs/aztec-js/how_to_test.md +++ b/docs/docs-developers/docs/aztec-js/how_to_test.md @@ -85,7 +85,7 @@ Use this to set up state preconditions, reproduce production bugs against pinned ### Fast-forwarding a contract update -`fastForwardContractUpdate` returns a `SimulationOverrides` object that simulates a deployed instance as if it had already been upgraded to a new contract class. The new class must already be registered on chain. The cheat mirrors a real `pxe.updateContract` followed by waiting out the upgrade delay: the instance's `currentContractClassId` is bumped, and the `ContractInstanceRegistry`'s delayed-public-mutable storage is rewritten to look like the upgrade was scheduled in the past. +`fastForwardContractUpdate` returns a `SimulationOverrides` object that simulates a deployed instance as if it had already been upgraded to a new contract class. The new class must already be registered on chain. The cheat mirrors a real onchain upgrade followed by waiting out the upgrade delay: the override instance's `currentContractClassId` is bumped, and the `ContractInstanceRegistry`'s delayed-public-mutable storage is rewritten to look like the upgrade was scheduled in the past. ```typescript import { fastForwardContractUpdate } from '@aztec/aztec.js'; diff --git a/docs/docs-developers/docs/aztec-nr/framework-description/calling_contracts.md b/docs/docs-developers/docs/aztec-nr/framework-description/calling_contracts.md index 9fee15fee393..5e906feb526d 100644 --- a/docs/docs-developers/docs/aztec-nr/framework-description/calling_contracts.md +++ b/docs/docs-developers/docs/aztec-nr/framework-description/calling_contracts.md @@ -67,3 +67,46 @@ self.enqueue(Token::at(token_address).mint_to_public(recipient, amount)); :::info Public functions execute after all private execution completes. Return values are not available in the private context. Learn more about [call types](../../foundational-topics/call_types.md). ::: + +## Utility calls + +Utility functions can call other utility functions. These calls run entirely offchain in PXE and are never proven, so no guarantees are made on the correctness of their results. + +### Utility-to-utility calls + +From a utility function, use `self.call()` as with other call types: + +```rust +let balance = self.call(Token::at(token_address).get_balance_of(owner)); +``` + +To call a utility function of your own contract, use the `self.call_self` stubs instead: + +```rust +self.call_self.my_utility_function(args) +``` + +### Private-to-utility calls + +Private functions can also call utility functions, through `self.utility`. The call executes unconstrained code, so it must be wrapped in `unsafe`, and its result is not proven: use it to inform logic, never as an input to a constrained assertion without validating it first. + +```rust +// Safety: result is unconstrained +let balance = unsafe { self.utility.call(Token::at(token_address).get_balance_of(owner)) }; +``` + +The same-contract stubs are available here as `self.utility.call_self`: + +```rust +unsafe { self.utility.call_self.my_utility_function(args) } +``` + +Public functions cannot call utility functions: they run on the sequencer, which has no access to private state or PXE. + +### Cross-contract authorization + +A utility call to the calling contract itself always succeeds. A call to a _different_ contract can expose that contract's private state to the caller, so PXE asks the wallet to authorize it before proceeding. If the wallet denies the call, or no `authorizeUtilityCall` hook is configured, the simulation fails with `Cross-contract utility call denied`. See [execution hooks](../../foundational-topics/pxe/execution_hooks.md#authorizeutilitycall) for how wallets handle these requests, and for authorizing targets in Noir tests with `with_authorized_utility_call_targets`. + +### `msg_sender` in nested utility calls + +A utility function called from another contract can read the calling contract's address via `self.msg_sender()`, mirroring private and public functions. A utility function invoked directly by an application has no caller: `self.msg_sender()` panics, and `self.context.maybe_msg_sender()` returns `Option::none()`. Within a simulation PXE takes this value from the actual call graph, but utility execution as a whole is unconstrained, so contracts must not rely on it as a security guarantee. diff --git a/docs/docs-developers/docs/aztec-nr/framework-description/contract_upgrades.md b/docs/docs-developers/docs/aztec-nr/framework-description/contract_upgrades.md index 9dd1db5c37fd..cd1ffb3de45c 100644 --- a/docs/docs-developers/docs/aztec-nr/framework-description/contract_upgrades.md +++ b/docs/docs-developers/docs/aztec-nr/framework-description/contract_upgrades.md @@ -5,6 +5,17 @@ tags: [contracts] description: Understand contract upgrade patterns in Aztec and how to implement upgradeable contracts. --- +:::warning[Upgrades are not yet well supported by the framework] +Contract upgrades are currently a low-level protocol feature with minimal framework support. Before relying on them, be aware that: + +- Upgrades are hard to execute correctly, and need lots of careful testing. +- State migrations are non-trivial and also require extensive testing. The new implementation must be compatible with all existing state, including public storage, private notes, and initialization status. +- The `#[aztec]` macros are not at the moment built with upgrades in mind. Macro-generated code makes assumptions that upgrades can violate (for example, initialization checks in the new implementation can make all functions of already-deployed instances permanently uncallable), so users may need to drop the macros until support is added. +- There is no additional tooling at the moment to help with upgrades, such as detection of storage layout compatibility between the old and new implementations. + +All in all, this feature is not well supported by the framework as of now, and should only be used with extreme care. +::: + Each contract instance refers to a contract class ID for its code. Upgrading a contract's implementation involves updating its current class ID to a new class ID, while retaining the original class ID for address verification. ## Original class ID @@ -134,11 +145,3 @@ const updatedContract = UpdatedContract.at(contract.address, wallet); ``` If you try to register a contract artifact that doesn't match the current contract class, the registration will fail. - -### Security considerations - -1. **Access control**: Implement proper access controls for upgrade functions. Consider using `set_update_delay` to customize the delay for your security requirements. - -2. **State compatibility**: Ensure the new implementation is compatible with existing state. Maintain the same storage layout to prevent data corruption. - -3. **Testing**: Test upgrades thoroughly in a development environment. Verify all existing functionality works with the new implementation. diff --git a/docs/docs-developers/docs/aztec-nr/framework-description/functions/how_to_define_functions.md b/docs/docs-developers/docs/aztec-nr/framework-description/functions/how_to_define_functions.md index 2d266e8a12e3..da818aa15447 100644 --- a/docs/docs-developers/docs/aztec-nr/framework-description/functions/how_to_define_functions.md +++ b/docs/docs-developers/docs/aztec-nr/framework-description/functions/how_to_define_functions.md @@ -49,7 +49,7 @@ Public functions operate on public state, similar to EVM contracts. They can wri Create offchain query functions using the `#[external("utility")]` annotation with `unconstrained`. -Utility functions are standalone unconstrained functions that cannot be called from private or public functions. They are meant to be called by _applications_ to perform auxiliary tasks like querying contract state or processing offchain messages. Example: +Utility functions are unconstrained functions that applications call to perform auxiliary tasks, like querying contract state or processing offchain messages. Their execution is never proven, so no guarantees are made on the correctness of their results. They can also be called from other utility functions and from private functions. See [utility calls](../calling_contracts.md#utility-calls). Public functions cannot call them. Example: #include_code get_counter /docs/examples/contracts/counter_contract/src/main.nr rust diff --git a/docs/docs-developers/docs/foundational-topics/accounts/keys.md b/docs/docs-developers/docs/foundational-topics/accounts/keys.md index 05983463f73d..7e40d8e4896a 100644 --- a/docs/docs-developers/docs/foundational-topics/accounts/keys.md +++ b/docs/docs-developers/docs/foundational-topics/accounts/keys.md @@ -66,7 +66,7 @@ The nullifier hiding key (`nhk`) — sometimes referred to in older documentatio |---------|----------|----------------| | Private (constrained) | `context.request_nhk_app(owner_npk_m_hash)` | Called on `&mut PrivateContext` | | Unconstrained | `get_nhk_app(owner_npk_m_hash)` | `use aztec::keys::getters::get_nhk_app` | -| TypeScript (master key) | `deriveMasterNullifierHidingKey(secretKey)` | `import { deriveMasterNullifierHidingKey } from '@aztec/aztec.js/keys'` | +| TypeScript (master key) | `deriveMasterNullifierHidingSecretKey(secretKey)` | `import { deriveMasterNullifierHidingSecretKey } from '@aztec/aztec.js/keys'` | | TypeScript (app-siloed) | `computeAppNullifierHidingKey(nhkM, app)` | `import { computeAppNullifierHidingKey } from '@aztec/aztec.js/keys'` | To get the owner's master nullifier public key hash (needed as input): @@ -75,7 +75,7 @@ To get the owner's master nullifier public key hash (needed as input): let owner_npk_m_hash = get_public_keys(owner).npk_m_hash; ``` -`PublicKeys` exposes the nullifier, outgoing-viewing, and tagging keys directly as their hashes; only `ivpk_m` is held as a Grumpkin point (it is required as a point for address derivation and encrypt-to-address). +`PublicKeys` exposes the nullifier, outgoing-viewing, tagging, message-signing, and fallback keys as their hashes; only `ivpk_m` is held as a Grumpkin point (it is required as a point for address derivation and encrypt-to-address). :::warning Do not compute nullifier keys by hand or derive custom blinding factors. The protocol kernel validates that `nhk_app` derives correctly from the master key — a hand-rolled value will fail verification. @@ -159,7 +159,7 @@ The `Ovpk` (outgoing viewing key) and `Tpk` (tagging key) exist in the protocol' ::: :::note -The `Mspk` (master message-signing key) and `Fbpk` (master fallback key) hash slots exist in `PublicKeys` and participate in the address derivation above, but there is **no canonical derivation path for them yet**. `deriveKeys(secretKey)` stamps the canonical default hashes (`DEFAULT_MSPK_M_HASH`, `DEFAULT_FBPK_M_HASH`) into every account, so today they contribute no per-account entropy. When a real derivation lands in a future release, the address derived from the same secret will change — see the migration notes. +The `Mspk` (master message-signing key) and `Fbpk` (master fallback key) are reserved for future protocol features (message signing and account recovery, respectively). Their public keys are derived per account and participate in the address derivation above. Unlike the other privacy keys, their **secret keys are held by the wallet, not the PXE**: the PXE receives only the corresponding public keys, since it is not trusted to hold these secrets. ::: ## Key Management @@ -215,9 +215,9 @@ Aztec's multi-key architecture is fundamental to its privacy and security model: | **Incoming Viewing** (`Ivpk_m`) | Decrypt received notes | No | No | Protocol (PXE) | | **Outgoing Viewing** (`Ovpk_m`) | Reserved | N/A | No | Protocol (PXE) | | **Tagging** (`Tpk_m`) | Reserved | N/A | No | Protocol (PXE) | -| **Message-Signing** (`Mspk_m`) | Reserved | N/A | No | Protocol (PXE) | -| **Fallback** (`Fbpk_m`) | Reserved | N/A | No | Protocol (PXE) | -| **Signing** | Transaction authorization | N/A | Yes | Application | +| **Message-Signing** (`Mspk_m`) | Reserved | N/A | No | Application (Wallet) | +| **Fallback** (`Fbpk_m`) | Reserved | N/A | No | Application (Wallet) | +| **Signing** | Transaction authorization | N/A | Yes | Account Contract (Wallet) | **Key takeaways:** diff --git a/docs/docs-developers/docs/foundational-topics/call_types.md b/docs/docs-developers/docs/foundational-topics/call_types.md index 563f438e9c08..0e2863599192 100644 --- a/docs/docs-developers/docs/foundational-topics/call_types.md +++ b/docs/docs-developers/docs/foundational-topics/call_types.md @@ -187,11 +187,13 @@ Public functions can be called either directly in a public context (as shown abo ### Utility -Contract functions marked with `#[external("utility")]` cannot be called as part of a transaction. They are only invoked by applications that interact with contracts for: +Contract functions marked with `#[external("utility")]` are never proven as part of a transaction, even when called from a private function. They are invoked by applications that interact with contracts for: - **State queries**: Reading from both private and public state via an offchain client - **Local state management**: Modifying contract-related PXE state (e.g., processing logs in Aztec.nr) +Utility functions can also be called from other utility functions, and from private functions as unconstrained code. Calls that cross a contract boundary require wallet authorization. See [utility calls](../aztec-nr/framework-description/calling_contracts.md#utility-calls) for how to make them. + Since utility execution is unconstrained and relies heavily on oracle calls, no guarantees are made on the correctness of results. However, you can verify that the bytecode being executed is correct, since a contract's address includes a commitment to all of its utility functions. ### aztec.js diff --git a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md index 3dddf2759679..dbdc302c3d4c 100644 --- a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md +++ b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md @@ -105,3 +105,7 @@ A general-purpose hook for *custom*, caller-defined requests. A contract reaches Pass a `resolveCustomRequest` hook when [creating the PXE](#configuring-hooks). It receives a `CustomRequest` with the issuing contract's address and class ID, the request `kind`, and the opaque `payload`, and returns the response. Because any contract can issue a request, the hook should check both the `kind` and the issuing contract before answering, dispatching on `kind` to the matching resolver. When the hook is absent, the request cannot be served and simulation fails. + +### Example: interactive handshakes + +The `HandshakeRegistry`'s `interactive_handshake` uses this hook to obtain the recipient's signed authorization. The payload carries what the signer needs to decide: who the recipient is, the handshake being authorized, and the chain context, but never the sender. The response carries what the registry needs to verify the recipient's signature in-circuit. diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index a35174ba279b..f6a414ca1048 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -9,6 +9,33 @@ Aztec is in active development. Each version may introduce breaking changes that ## TBD +### [PXE] `pxe.updateContract` removed and `pxe.registerContract` no longer takes an artifact + +Registering classes and instances are now separate, unvalidated operations. `registerContractClass(artifact)` registers a class, `registerContract(instance)` registers an instance and no longer takes an artifact. `registerContract` does not check that PXE knows the contract's artifact: a missing artifact surfaces only when the contract is later simulated. + +**Migration:** + +- `pxe.registerContract` now takes the instance directly (its address preimage) and returns the derived address. Register the class separately via `registerContractClass`: + +```diff +- await pxe.registerContract({ instance, artifact }); ++ await pxe.registerContractClass(artifact); ++ await pxe.registerContract(instance); +``` + + If you were calling it without an artifact, just drop the wrapping object: `pxe.registerContract({ instance })` becomes `pxe.registerContract(instance)`. The `wallet.registerContract(instance, artifact?, secretKeyOrKeys?)` convenience is unchanged and performs both registrations for you. + +- To make a new class's code available after an onchain upgrade, register the new artifact instead of calling `updateContract`: + +```diff +- await pxe.updateContract(address, newArtifact); ++ await pxe.registerContractClass(newArtifact); +``` + + The new class is used automatically once the upgrade takes effect on chain; no further PXE action is needed. Registering it beforehand is harmless: until the update activates, the node still resolves the contract's current class to the previous one, so it keeps running its old code. + +- `pxe.getContractInstance(address)` and `wallet.getContractMetadata(address).instance` now return the contract's **address preimage**, which no longer includes `currentContractClassId`. + ### [Aztec.js] `AccountWithSecretKey` removed, read account keys from the `AccountManager` or PXE `AccountWithSecretKey` was a thin wrapper that bundled an account's transaction signer with its master secret key, used mainly to print or export the secret. It has been removed, and `AccountManager.getAccount()` now returns the plain `Account` signer. The wrapper's extra methods are no longer available on that value: @@ -27,9 +54,11 @@ Aztec is in active development. Each version may introduce breaking changes that + const secretKey = accountManager.getSecretKey(); ``` -To do what `AccountWithSecretKey` was meant for (exporting an account into a separate PXE or wallet), account registration now also accepts a full set of master secret keys instead of only a single seed. `wallet.registerContract(instance, artifact?, secretKeyOrKeys?)` and `pxe.registerAccount(secretKeyOrKeys, partialAddress)` take either an `Fr` (as before) or a `MasterSecretKeys` object (exported from `@aztec/aztec.js/keys`), and `pxe.getAccountSecretKeys(address)` returns those keys. These keys guard the account's privacy, so they should never be exposed to applications. +To do what `AccountWithSecretKey` was meant for (exporting an account into a separate PXE or wallet), account registration accepts a full set of master secret keys instead of only a single seed. `wallet.registerContract(instance, artifact?, secretKeyOrKeys?)` takes either an `Fr` seed (as before) or a `MasterSecretKeys` object (exported from `@aztec/aztec.js/keys`), for an account whose privacy keys were generated independently rather than from one seed. + +The PXE never receives the seed nor the message-signing and fallback secret keys: it is not trusted to hold them. The wallet derives the account's privacy keys and passes the PXE only the four privacy secret keys (nullifier-hiding, incoming-viewing, outgoing-viewing, tagging) plus the message-signing and fallback *public* keys. Accordingly, `pxe.getAccountSecretKeys(address)` returns only those four privacy secret keys. -**Impact**: Importing `AccountWithSecretKey`, or calling `getSecretKey()`/`getEncryptionSecret()` on the result of `getAccount()`, no longer compiles. The signer `getAccount()` returns is otherwise unchanged, and passing a single `Fr` to the registration methods keeps working. +**Impact**: Importing `AccountWithSecretKey`, or calling `getSecretKey()`/`getEncryptionSecret()` on the result of `getAccount()`, no longer compiles. The signer `getAccount()` returns is otherwise unchanged, and passing a single `Fr` or a `MasterSecretKeys` to `wallet.registerContract` keeps working. ### [PXE] Unconstrained delivery defaults to a non-interactive handshake for external recipients diff --git a/docs/docs-developers/docs/tutorials/js_tutorials/wallet-extension/04-accounts.md b/docs/docs-developers/docs/tutorials/js_tutorials/wallet-extension/04-accounts.md index 6589e3b8999d..9bf49abb8755 100644 --- a/docs/docs-developers/docs/tutorials/js_tutorials/wallet-extension/04-accounts.md +++ b/docs/docs-developers/docs/tutorials/js_tutorials/wallet-extension/04-accounts.md @@ -44,7 +44,7 @@ export function deriveSigningKey(secretKey: Fr): GrumpkinScalar { return sha512ToGrumpkinScalar([secretKey, GeneratorIndex.IVSK_M]); } -export function deriveMasterNullifierHidingKey(secretKey: Fr): GrumpkinScalar { +export function deriveMasterNullifierHidingSecretKey(secretKey: Fr): GrumpkinScalar { return sha512ToGrumpkinScalar([secretKey, GeneratorIndex.NHK_M]); } ``` diff --git a/docs/examples/webapp-tutorial/src/fees.ts b/docs/examples/webapp-tutorial/src/fees.ts index 98b85724821d..da671010ba60 100644 --- a/docs/examples/webapp-tutorial/src/fees.ts +++ b/docs/examples/webapp-tutorial/src/fees.ts @@ -30,7 +30,8 @@ export async function getSponsoredFPCContract() { */ export async function registerSponsoredFPC(pxe: PXE) { const contract = await getSponsoredFPCContract(); - await pxe.registerContract(contract); + await pxe.registerContractClass(contract.artifact); + await pxe.registerContract(contract.instance); return contract.instance.address; } // docs:end:register-fpc diff --git a/docs/netlify.toml b/docs/netlify.toml index 241dc40985ba..85218dce1f64 100644 --- a/docs/netlify.toml +++ b/docs/netlify.toml @@ -902,7 +902,7 @@ [[redirects]] # PXE: cross-contract utility call denied by execution hook from = "/errors/11" - to = "/developers/docs/aztec-nr/debugging#cross-contract-utility-call-denied" + to = "/developers/docs/aztec-nr/framework-description/calling_contracts#cross-contract-authorization" [[redirects]] # Incompatible oracle version between test and test environment diff --git a/noir-projects/aztec-nr/aztec/src/contract_self/contract_self_private.nr b/noir-projects/aztec-nr/aztec/src/contract_self/contract_self_private.nr index 57a95da52b9e..8b948a232440 100644 --- a/noir-projects/aztec-nr/aztec/src/contract_self/contract_self_private.nr +++ b/noir-projects/aztec-nr/aztec/src/contract_self/contract_self_private.nr @@ -436,11 +436,24 @@ pub struct PrivateUtilityCalls { impl PrivateUtilityCalls { /// Makes a utility contract call from a private function. /// - /// Note: only same-contract utility calls are currently supported. See TODO(F-29). + /// The call executes unconstrained code, so it must be wrapped in `unsafe`, and its result is not part of any + /// circuit proof: use it to inform logic, never as an input to a constrained assertion without validating it + /// first. + /// + /// The callee observes this contract's address as its `msg_sender`: see the `msg_sender` docs on + /// [`ContractSelfUtility`](crate::contract_self::ContractSelfUtility). + /// + /// To call one of this contract's own utility functions, prefer the type-safe `self.utility.call_self` stubs. + /// + /// ## Authorization + /// + /// A call to this contract itself always succeeds. A call to a different contract can expose that contract's + /// private state to the caller, so it requires explicit authorization. A call that is not authorized fails. /// /// # Example /// ```noir - /// unsafe { self.utility.call(Token::at(self.address).get_balance_of(owner)) } + /// // Safety: result is unconstrained + /// unsafe { self.utility.call(Token::at(address).get_balance_of(owner)) } /// ``` pub unconstrained fn call(_self: Self, call: UtilityCall) -> T where diff --git a/noir-projects/aztec-nr/aztec/src/contract_self/contract_self_utility.nr b/noir-projects/aztec-nr/aztec/src/contract_self/contract_self_utility.nr index 7d8b7904cca9..3c1a5287511d 100644 --- a/noir-projects/aztec-nr/aztec/src/contract_self/contract_self_utility.nr +++ b/noir-projects/aztec-nr/aztec/src/contract_self/contract_self_utility.nr @@ -75,7 +75,17 @@ impl ContractSelfUtility { /// Makes a utility contract call from another utility function. /// - /// Note: only same-contract utility calls are currently supported. See TODO(F-29). + /// The call runs entirely offchain in PXE and is never proven, so no guarantees are made on the correctness of + /// its result. + /// + /// The callee observes this contract's address as its `msg_sender`: see [`ContractSelfUtility::msg_sender`]. + /// + /// To call one of this contract's own utility functions, prefer the type-safe `self.call_self` stubs. + /// + /// ## Authorization + /// + /// A call to this contract itself always succeeds. A call to a different contract can expose that contract's + /// private state to the caller, so it requires explicit authorization. A call that is not authorized fails. /// /// # Example /// ```noir diff --git a/noir-projects/aztec-nr/aztec/src/messages/logs/event.nr b/noir-projects/aztec-nr/aztec/src/messages/logs/event.nr index 21e0bfa45d78..463cd44866e8 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/logs/event.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/logs/event.nr @@ -62,8 +62,8 @@ where /// Decodes the plaintext from a private event message (i.e. one of type [`PRIVATE_EVENT_MSG_TYPE_ID`]). /// -/// Returns `None` if `msg_content` has too few fields. This plaintext is meant to have originated -/// from [`encode_private_event_message`]. +/// Returns `None` if `msg_content` is too short to contain the reserved fields. This plaintext is meant to have +/// originated from [`encode_private_event_message`]. /// /// Note that while [`encode_private_event_message`] returns a fixed-size array, this function takes a [`BoundedVec`] /// instead. This is because when decoding we're typically processing runtime-sized plaintexts, more specifically, @@ -72,7 +72,7 @@ pub(crate) unconstrained fn decode_private_event_message( msg_metadata: u64, msg_content: BoundedVec, ) -> Option<(EventSelector, Field, BoundedVec)> { - if msg_content.len() <= PRIVATE_EVENT_MSG_PLAINTEXT_RESERVED_FIELDS_LEN { + if msg_content.len() < PRIVATE_EVENT_MSG_PLAINTEXT_RESERVED_FIELDS_LEN { Option::none() } else { let event_type_id = EventSelector::from_field(msg_metadata as Field); @@ -93,14 +93,14 @@ pub(crate) unconstrained fn decode_private_event_message( mod test { use crate::{ - event::event_interface::EventInterface, + event::{event_interface::EventInterface, EventSelector}, messages::{ encoding::decode_message, logs::event::{decode_private_event_message, encode_private_event_message}, msg_type::PRIVATE_EVENT_MSG_TYPE_ID, }, }; - use crate::protocol::traits::Serialize; + use crate::protocol::traits::{FromField, Serialize}; use crate::test::mocks::mock_event::MockEvent; global VALUE: Field = 7; @@ -125,6 +125,32 @@ mod test { assert_eq(serialized_event, BoundedVec::from_array(event.serialize())); } + #[derive(Serialize)] + struct EmptyEvent {} + + impl EventInterface for EmptyEvent { + fn get_event_type_id() -> EventSelector { + EventSelector::from_field(90) + } + } + + #[test] + unconstrained fn encode_decode_empty_event() { + let message_plaintext = encode_private_event_message(EmptyEvent {}, RANDOMNESS); + + let (msg_type_id, msg_metadata, msg_content) = + decode_message(BoundedVec::from_array(message_plaintext)).unwrap(); + + assert_eq(msg_type_id, PRIVATE_EVENT_MSG_TYPE_ID); + + let (event_type_id, randomness, serialized_event) = + decode_private_event_message(msg_metadata, msg_content).unwrap(); + + assert_eq(event_type_id, EmptyEvent::get_event_type_id()); + assert_eq(randomness, RANDOMNESS); + assert_eq(serialized_event.len(), 0); + } + #[test] unconstrained fn decode_empty_content_returns_none() { let empty = BoundedVec::new(); @@ -132,8 +158,12 @@ mod test { } #[test] - unconstrained fn decode_with_only_reserved_fields_returns_none() { - let content = BoundedVec::from_array([0]); - assert(decode_private_event_message(0, content).is_none()); + unconstrained fn decode_succeeds_with_only_reserved_fields() { + let content = BoundedVec::from_array([RANDOMNESS]); + + let (_, randomness, serialized_event) = decode_private_event_message(0, content).unwrap(); + + assert_eq(randomness, RANDOMNESS); + assert_eq(serialized_event.len(), 0); } } diff --git a/noir-projects/aztec-nr/aztec/src/messages/logs/note.nr b/noir-projects/aztec-nr/aztec/src/messages/logs/note.nr index 7750d0d630b8..33ed07529884 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/logs/note.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/logs/note.nr @@ -77,8 +77,8 @@ where /// Decodes the plaintext from a private note message (i.e. one of type [`PRIVATE_NOTE_MSG_TYPE_ID`]). /// -/// Returns `None` if `msg_content` has too few fields. This plaintext is meant to have originated -/// from [`encode_private_note_message`]. +/// Returns `None` if `msg_content` is too short to contain the reserved fields. This plaintext is meant to have +/// originated from [`encode_private_note_message`]. /// /// Note that while [`encode_private_note_message`] returns a fixed-size array, this function takes a [`BoundedVec`] /// instead. This is because when decoding we're typically processing runtime-sized plaintexts, more specifically, @@ -87,7 +87,7 @@ pub(crate) unconstrained fn decode_private_note_message( msg_metadata: u64, msg_content: BoundedVec, ) -> Option<(Field, AztecAddress, Field, Field, BoundedVec)> { - if msg_content.len() <= PRIVATE_NOTE_MSG_PLAINTEXT_RESERVED_FIELDS_LEN { + if msg_content.len() < PRIVATE_NOTE_MSG_PLAINTEXT_RESERVED_FIELDS_LEN { Option::none() } else { let note_type_id = msg_metadata as Field; // TODO: make note type id not be a full field @@ -117,7 +117,7 @@ mod test { }, note::note_interface::NoteType, }; - use crate::protocol::{address::AztecAddress, traits::{FromField, Packable}}; + use crate::protocol::{address::AztecAddress, traits::{FromField, Packable, ToField}}; use crate::test::mocks::mock_note::MockNote; global VALUE: Field = 7; @@ -197,6 +197,32 @@ mod test { let _ = encode_private_note_message(note, OWNER, STORAGE_SLOT, RANDOMNESS); } + #[derive(Packable)] + struct EmptyNote {} + + impl NoteType for EmptyNote { + fn get_id() -> Field { + 0 + } + } + + #[test] + unconstrained fn encode_decode_empty_note() { + let encoded = encode_private_note_message(EmptyNote {}, OWNER, STORAGE_SLOT, RANDOMNESS); + let (msg_type_id, msg_metadata, msg_content) = decode_message(BoundedVec::from_array(encoded)).unwrap(); + + assert_eq(msg_type_id, PRIVATE_NOTE_MSG_TYPE_ID); + + let (note_type_id, owner, storage_slot, randomness, packed_note) = + decode_private_note_message(msg_metadata, msg_content).unwrap(); + + assert_eq(note_type_id, EmptyNote::get_id()); + assert_eq(owner, OWNER); + assert_eq(storage_slot, STORAGE_SLOT); + assert_eq(randomness, RANDOMNESS); + assert_eq(packed_note.len(), 0); + } + #[test] unconstrained fn decode_empty_content_returns_none() { let empty = BoundedVec::new(); @@ -204,8 +230,20 @@ mod test { } #[test] - unconstrained fn decode_with_only_reserved_fields_returns_none() { - let content = BoundedVec::from_array([0, 0, 0]); + unconstrained fn decode_with_fewer_than_reserved_fields_returns_none() { + let content = BoundedVec::from_array([0, 0]); assert(decode_private_note_message(0, content).is_none()); } + + #[test] + unconstrained fn decode_succeeds_with_only_reserved_fields() { + let content = BoundedVec::from_array([OWNER.to_field(), STORAGE_SLOT, RANDOMNESS]); + + let (_, owner, storage_slot, randomness, packed_note) = decode_private_note_message(0, content).unwrap(); + + assert_eq(owner, OWNER); + assert_eq(storage_slot, STORAGE_SLOT); + assert_eq(randomness, RANDOMNESS); + assert_eq(packed_note.len(), 0); + } } diff --git a/noir-projects/aztec-nr/aztec/src/standard_addresses.nr b/noir-projects/aztec-nr/aztec/src/standard_addresses.nr index f7747e063047..cb4192f78203 100644 --- a/noir-projects/aztec-nr/aztec/src/standard_addresses.nr +++ b/noir-projects/aztec-nr/aztec/src/standard_addresses.nr @@ -2,17 +2,17 @@ use protocol_types::{address::AztecAddress, traits::FromField}; pub global STANDARD_AUTH_REGISTRY_ADDRESS: AztecAddress = AztecAddress::from_field( - 0x186fbbe15f50d01d67c471d5ed8b8be43b8826e77147cb0084302804e336e5d2, + 0x0e7d3c56a185c40e4ce459eee03075b7dee2e9dc8f860157063afb3fde5ce097, ); pub global STANDARD_MULTI_CALL_ENTRYPOINT_ADDRESS: AztecAddress = AztecAddress::from_field( - 0x10a91e72a359e7441d4af9e418f14862abe994348970909d484ff2dad1594a58, + 0x03264f902d92f27dd0719cd6f5cc9c9d03ec6f5a48482e2158ebc2886e997210, ); pub global STANDARD_PUBLIC_CHECKS_ADDRESS: AztecAddress = AztecAddress::from_field( - 0x1f6f565db58df81c2bbb8e9932fa92d2d1f537887a06e2cb965184f77a5235d2, + 0x05b4a7bf960bac46cc0c22aa6ee5b663a928787c9e7410fcf99e78182f634c0b, ); pub global STANDARD_HANDSHAKE_REGISTRY_ADDRESS: AztecAddress = AztecAddress::from_field( - 0x18233660a8797d74f63318c4223332e23b63f570da11fb142993ebc802a96728, + 0x26f11691c7efea98db7eb7b4460cbc3f75db19772db2c2dbb8978979bcc5e388, ); diff --git a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/Nargo.toml b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/Nargo.toml index 9ce353b45066..5cf4d1363709 100644 --- a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/Nargo.toml +++ b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/Nargo.toml @@ -6,3 +6,4 @@ type = "contract" [dependencies] aztec = { path = "../../../../aztec-nr/aztec" } +schnorr = { tag = "v0.4.0", git = "https://github.com/noir-lang/schnorr" } diff --git a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/main.nr b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/main.nr index 2d718a4b271b..79405196e69b 100644 --- a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/main.nr @@ -1,5 +1,6 @@ use aztec::macros::aztec; mod handshake_note; +mod recipient_signature; mod sync; mod test; @@ -9,38 +10,57 @@ use sync::handshake_registry_sync; /// signature). See [`HandshakeRegistry::non_interactive_handshake`]. global NON_INTERACTIVE_HANDSHAKE: u8 = 1; -/// Registry for the constrained-delivery handshake protocol. +/// Handshake type identifier for interactive handshakes (recipient-signed). See +/// [`HandshakeRegistry::interactive_handshake`]. +global INTERACTIVE_HANDSHAKE: u8 = 2; + +/// Registry for the message-delivery handshake protocol. /// /// The registry establishes a master shared-secret point `S` between a sender and a recipient and stores one current -/// note for each `(recipient, sender)` pair. `S` is independent of delivery mode: the recipient should derive per-mode -/// tags -/// from it when scanning. The raw `S` never leaves the registry: app contracts call -/// [`HandshakeRegistry::non_interactive_handshake`] to receive the secret already siloed to the caller, call -/// [`HandshakeRegistry::get_app_siloed_secrets`] offchain for an existing handshake, and use -/// [`HandshakeRegistry::validate_handshake`] to check app-siloed secrets against the current stored handshake. The -/// private surfaces silo against `msg_sender()`, so a contract can only obtain or validate secrets siloed to itself. -/// Re-handshaking does not revoke already-started constrained-delivery sequences; it only replaces the registry note -/// used -/// for [validation][`HandshakeRegistry::validate_handshake`]. +/// note for each `(recipient, sender)` pair. `S` is independent of delivery mode: the recipient should derive +/// per-mode tags from it when scanning, whether the delivery is constrained or unconstrained. The raw `S` never +/// leaves the registry: app contracts call [`HandshakeRegistry::non_interactive_handshake`] to receive the secret +/// already siloed to the caller, call [`HandshakeRegistry::get_app_siloed_secrets`] offchain for an existing +/// handshake, and use [`HandshakeRegistry::validate_handshake`] to check app-siloed secrets against the current +/// stored handshake. All of these functions silo the secrets they return or check to the calling contract +/// (`msg_sender()`), so a contract can only obtain or validate secrets siloed to itself. Re-handshaking does not +/// revoke already-started constrained-delivery sequences; it only replaces the registry note used for +/// [validation][`HandshakeRegistry::validate_handshake`]. /// -/// Currently only implements the non-interactive flow (see [`HandshakeRegistry::non_interactive_handshake`]). +/// A handshake can be established two ways: [`HandshakeRegistry::non_interactive_handshake`], where the sender acts +/// alone and announces the handshake in a log the recipient later discovers, and +/// [`HandshakeRegistry::interactive_handshake`], which instead has the recipient sign the handshake at establishment +/// time and emits no announcement (the recipient already learns `eph_pk` while signing). Both store the same note. #[aztec(::aztec::macros::AztecConfig::new().custom_sync_state(crate::handshake_registry_sync))] pub contract HandshakeRegistry { - use crate::{handshake_note::HandshakeNote, NON_INTERACTIVE_HANDSHAKE, sync::get_all_handshakes}; + use crate::{ + handshake_note::HandshakeNote, INTERACTIVE_HANDSHAKE, NON_INTERACTIVE_HANDSHAKE, + recipient_signature::RecipientSignature, sync::get_all_handshakes, + }; use aztec::{ keys::{ecdh_shared_secret::derive_ecdh_shared_secret, ephemeral::generate_positive_ephemeral_key_pair}, - macros::{functions::external, storage::storage}, + macros::{functions::{external, internal}, storage::storage}, messages::{ delivery::{handshake::{AppSiloedHandshakeSecrets, HandshakePage, MAX_HANDSHAKES_PER_PAGE}, MessageDelivery}, encryption::{aes128::AES128, message_encryption::MessageEncryption}, }, - oracle::random::random, + oracle::{random::random, resolve_custom_request::resolve_custom_request}, protocol::{ - address::AztecAddress, constants::DOM_SEP__NON_INTERACTIVE_HANDSHAKE_LOG_TAG, hash::compute_log_tag, - traits::ToField, + address::AztecAddress, + constants::DOM_SEP__NON_INTERACTIVE_HANDSHAKE_LOG_TAG, + hash::{compute_log_tag, sha256_to_field}, + traits::{Deserialize, ToField}, }, state_vars::{Map, Owned, PrivateMutable}, }; + use std::embedded_curve_ops::EmbeddedCurvePoint; + + /// Request kind the registry passes to + /// [`resolve_custom_request`](aztec::oracle::resolve_custom_request::resolve_custom_request) to obtain a + /// recipient's interactive-handshake signature. + global INTERACTIVE_HANDSHAKE_REQUEST_KIND: Field = sha256_to_field( + "HANDSHAKE_REGISTRY::INTERACTIVE_HANDSHAKE_REQUEST".as_bytes(), + ); #[storage] struct Storage { @@ -49,67 +69,59 @@ pub contract HandshakeRegistry { handshakes: Map, Context>, Context>, } - /// Performs a non-interactive handshake from `sender` to `recipient` and returns the [app-siloed handshake - /// secrets][AppSiloedHandshakeSecrets] - /// for the calling contract. - /// - /// The handshake establishes a single shared secret per `(recipient, sender)` pair, independent of delivery - /// mode: the recipient should derive per-mode tags from it when scanning. The handshake note is always stored - /// onchain. + /// Performs a non-interactive handshake from `sender` to `recipient`. /// - /// Generates a fresh ephemeral key pair `(eph_sk, eph_pk)`, computes the raw ECDH shared secret point - /// `S = eph_sk * recipient_address_point`, and produces three effects: + /// Returns the [app-siloed handshake secrets][AppSiloedHandshakeSecrets] for the calling contract. The handshake + /// establishes a single shared secret per `(recipient, sender)` pair, independent of delivery mode: the recipient + /// should derive per-mode tags from it when scanning. The recipient does not participate; they discover the + /// handshake later by scanning the log announced here. See [`HandshakeRegistry::interactive_handshake`] for the + /// variant that requires the recipient to sign. /// - /// 1. Inserts or replaces the current [`HandshakeNote`] owned by `sender`, holding the raw point `S`. - /// 2. Emits an encrypted private log under a recipient-keyed tag with payload `[eph_pk.x]`. The - /// recipient discovers handshakes addressed to them by scanning their tag and recovers `S` from `eph_pk` - /// via their own ECDH (`recipient_isk * eph_pk`). `eph_pk.y` is fixed positive by the - /// [`generate_positive_ephemeral_key_pair`] convention, so only `eph_pk.x` is transmitted. - /// 3. Returns the app-siloed handshake secrets for `msg_sender()`, allowing the caller to fold "handshake + first - /// tag" into one call without a second hop into the registry. The sender-only secret derives from a fresh - /// random secret (retained in the note), so the recipient, who only sees `eph_pk`, cannot reconstruct it. + /// The announcement reveals that the recipient was contacted: its log tag is derived from the recipient's + /// address alone, so a non-recipient can at most learn that a handshake was done with the recipient, not who + /// initiated it nor the contents of any message that follows. /// /// # Panics - /// If `recipient` is not a valid curve point. There are no upstream side effects in this call frame to - /// protect, and a fallback would insert a permanent note recording a handshake with an invalid recipient, - /// polluting registry state. + /// If `recipient` is not a valid curve point. #[external("private")] fn non_interactive_handshake(sender: AztecAddress, recipient: AztecAddress) -> AppSiloedHandshakeSecrets { - let recipient_point = recipient.to_address_point().expect(f"recipient address is not on the curve"); - - let (eph_sk, eph_pk) = generate_positive_ephemeral_key_pair(); - let s_raw = derive_ecdh_shared_secret(eph_sk, recipient_point.inner); - - // Safety: an uncooperative sender could pick a non-random value, but the sender-only secret only protects - // the sender's own ability to advance their sequence, so a bad value only harms themselves. It is a fresh - // random secret known only to the sender, which will be folded into constrained-delivery nullifiers. - let sender_secret = unsafe { random() }; - let note = HandshakeNote::new(s_raw, NON_INTERACTIVE_HANDSHAKE, recipient, sender_secret); - - // The recipient is not involved in this note's delivery: they discover the handshake via the encrypted log - // emitted below, not via the sender's note. We deliver onchain unconstrained rather than offchain so the - // note is discoverable via normal PXE sync. This is a self-send (the note is owned by and delivered to - // `sender`), so the wallet would resolve to an address-derived secret anyway. Pinning that derivation here at - // compile time lets this contract, the handshake registry itself, skip the default flow's runtime registry - // check, which would otherwise embed the registry's own address in its bytecode and make its deploy address - // depend on itself. - self.storage.handshakes.at(recipient).at(sender).initialize_or_replace(|_| note).deliver( - MessageDelivery::onchain_unconstrained().with_sender(sender).via_address_derived_secret(), - ); + let (eph_pk, s_raw) = self.internal._generate_shared_secret(recipient); + let secrets = self.internal._store(sender, recipient, s_raw, NON_INTERACTIVE_HANDSHAKE); + // Announce the handshake: an encrypted private log under the recipient-keyed tag carrying `[eph_pk.x]`, so + // the recipient can discover the handshake by scanning their tag and recover `S` via their own ECDH. let log_tag = compute_log_tag( recipient.to_field(), DOM_SEP__NON_INTERACTIVE_HANDSHAKE_LOG_TAG, ); - let ciphertext = AES128::encrypt([eph_pk.x], recipient, self.context.this_address()); self.context.emit_private_log_unsafe(log_tag, BoundedVec::from_array(ciphertext)); - note.app_siloed_secrets_for(self.msg_sender()) + secrets } - /// Asserts that `secrets` match the stored handshake from `sender` to `recipient`, for the caller - /// (`msg_sender()`). + /// Performs an interactive handshake from `sender` to `recipient`. + /// + /// Returns the [app-siloed handshake secrets][AppSiloedHandshakeSecrets] for the calling contract. Like + /// [`HandshakeRegistry::non_interactive_handshake`], but the recipient must authorize the handshake. The registry + /// requests the recipient's signature over the freshly generated ephemeral key via + /// [`resolve_custom_request`](aztec::oracle::resolve_custom_request::resolve_custom_request), and verifies it + /// in-circuit (see [`HandshakeRegistry::_request_and_verify_signature`]). The signature commits only + /// to the ephemeral key and chain context, never to `sender`, so the recipient authorizes the handshake without + /// learning who initiated it. No announcement is emitted, since the recipient already learns `eph_pk` while + /// signing. + /// + /// # Panics + /// If `recipient` is not a valid curve point, if the returned public keys do not derive `recipient`'s address, or + /// if the signature does not verify. + #[external("private")] + fn interactive_handshake(sender: AztecAddress, recipient: AztecAddress) -> AppSiloedHandshakeSecrets { + let (eph_pk, s_raw) = self.internal._generate_shared_secret(recipient); + self.internal._request_and_verify_signature(recipient, eph_pk); + self.internal._store(sender, recipient, s_raw, INTERACTIVE_HANDSHAKE) + } + + /// Asserts that `secrets` match the stored handshake from `sender` to `recipient`, for the caller (`msg_sender()`). /// /// Apps that receive handshake secrets from an untrusted source call this once to validate those secrets /// against the registry's latest stored handshake. @@ -126,8 +138,8 @@ pub contract HandshakeRegistry { // `PrivateMutable::get_note` proves the current note by nullifying and recreating it. Deliver the // replacement to the sender so later validation calls can prove the same current handshake again. As in - // `non_interactive_handshake`, the tag derivation is fixed to address-derived so the registry does not - // reference its own address when re-tagging its own note. + // `_store`, the tag derivation is fixed to address-derived so the registry does not reference its own address + // when re-tagging its own note. replacement_note_message.deliver(MessageDelivery::onchain_unconstrained().via_address_derived_secret()); } @@ -137,7 +149,7 @@ pub contract HandshakeRegistry { /// or the sender-only secret; a different caller receives different app-siloed values for the same registry note. /// Siloing by `self.msg_sender()` means a hostile contract cannot read another app's siloed secrets: whatever /// arguments a caller passes, it only ever learns values siloed to its own address. Contracts should still call - /// [HandshakeRegistry::validate_handshake] when they need a constrained proof that supplied app-siloed secrets + /// [`HandshakeRegistry::validate_handshake`] when they need a constrained proof that supplied app-siloed secrets /// match the current handshake. #[external("utility")] unconstrained fn get_app_siloed_secrets( @@ -170,4 +182,75 @@ pub contract HandshakeRegistry { } HandshakePage { items, total_count } } + + /// Generates the ephemeral key and shared secret for `recipient`. + /// + /// Returns `(eph_pk, s_raw)`: the ephemeral public key announced to the recipient, and the ECDH shared secret + /// `S = eph_sk * recipient_point`. + /// + /// # Panics + /// If `recipient` is not a valid curve point. + #[internal("private")] + fn _generate_shared_secret(recipient: AztecAddress) -> (EmbeddedCurvePoint, EmbeddedCurvePoint) { + let recipient_point = recipient.to_address_point().expect(f"recipient address is not on the curve"); + + let (eph_sk, eph_pk) = generate_positive_ephemeral_key_pair(); + let s_raw = derive_ecdh_shared_secret(eph_sk, recipient_point.inner); + (eph_pk, s_raw) + } + + /// Inserts or replaces the current [`HandshakeNote`] owned by `sender` for `recipient` and returns the + /// app-siloed secrets for the caller (`msg_sender()`). + #[internal("private")] + fn _store( + sender: AztecAddress, + recipient: AztecAddress, + s_raw: EmbeddedCurvePoint, + handshake_type: u8, + ) -> AppSiloedHandshakeSecrets { + // Safety: an uncooperative sender could pick a non-random value, but the sender-only secret only protects + // the sender's own ability to advance their sequence, so a bad value only harms themselves. It is a fresh + // random secret known only to the sender, which will be folded into constrained-delivery nullifiers. + let sender_secret = unsafe { random() }; + let note = HandshakeNote::new(s_raw, handshake_type, recipient, sender_secret); + + // We deliver onchain unconstrained rather than offchain so the note is discoverable via normal PXE sync. This + // is a self-send (the note is owned by and delivered to `sender`), so the wallet would resolve to an + // address-derived secret anyway. Pinning that derivation here at compile time lets this contract, the + // handshake registry itself, skip the default flow's runtime registry check, which would otherwise embed the + // registry's own address in its bytecode and make its deploy address depend on itself. + self.storage.handshakes.at(recipient).at(sender).initialize_or_replace(|_| note).deliver( + MessageDelivery::onchain_unconstrained().with_sender(sender).via_address_derived_secret(), + ); + + note.app_siloed_secrets_for(self.msg_sender()) + } + + /// Requests and verifies the recipient's handshake authorization. + /// + /// Fetches the recipient's response through a + /// [`resolve_custom_request`](aztec::oracle::resolve_custom_request::resolve_custom_request) oracle call. The + /// request payload carries `recipient` plus the chain context and `eph_pk.x`; it never carries `sender`, so the + /// recipient authorizes the handshake without learning who initiated it. The response deserializes into a + /// [`RecipientSignature`], which contains all the data needed to verify the signature in-circuit. + /// + /// # Panics + /// If the returned public keys do not derive `recipient`'s address, the signing key does not match, or the + /// signature does not verify. + #[internal("private")] + fn _request_and_verify_signature(recipient: AztecAddress, eph_pk: EmbeddedCurvePoint) { + let payload = [recipient.to_field(), self.context.chain_id(), self.context.version(), eph_pk.x]; + // Safety: the response is unconstrained, but `RecipientSignature::verify` fully constrains it below: it binds + // the returned public keys to the recipient's address and verifies the signature over an in-circuit message. + let response = RecipientSignature::deserialize( + unsafe { resolve_custom_request(INTERACTIVE_HANDSHAKE_REQUEST_KIND, payload) }, + ); + response.verify( + recipient, + self.context.chain_id(), + self.context.version(), + self.context.this_address(), + eph_pk.x, + ); + } } diff --git a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/recipient_signature.nr b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/recipient_signature.nr new file mode 100644 index 000000000000..9e8ab5192c6d --- /dev/null +++ b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/recipient_signature.nr @@ -0,0 +1,179 @@ +use aztec::{ + protocol::{ + address::{AztecAddress, PartialAddress}, + constants::DOM_SEP__INTERACTIVE_HANDSHAKE_SIGNATURE, + hash::poseidon2_hash_with_separator, + public_keys::{hash_public_key, PublicKeys}, + traits::{Deserialize, ToField}, + }, + utils::point::point_from_x_coord_and_sign, +}; +use std::embedded_curve_ops::EmbeddedCurveScalar; + +/// The recipient's signed authorization for an interactive handshake. +/// +/// Returned by the interactive-handshake +/// [`resolve_custom_request`](aztec::oracle::resolve_custom_request::resolve_custom_request) oracle call. It is how +/// the sender asserts that the recipient received the ephemeral key correctly and will be able to receive messages +/// properly, and it carries everything needed to verify that in-circuit. +#[derive(Deserialize)] +pub struct RecipientSignature { + /// The recipient's master public keys. With `partial_address` they derive the recipient's address, which is how + /// [`verify`][Self::verify] binds them to `recipient`. + pub public_keys: PublicKeys, + /// The recipient's partial address, combined with `public_keys` to reproduce the recipient's address. + pub partial_address: PartialAddress, + /// The x-coordinate of the recipient's master message-signing public key. `PublicKeys` exposes this key only as + /// its `mspk_m_hash`, so the point travels separately and is reconstructed on-curve, then tied back to that hash. + pub mspk_x: Field, + /// Whether that key's y-coordinate is positive, used to pick the correct y when reconstructing the point from + /// `mspk_x`. + pub mspk_y_is_positive: bool, + /// The schnorr signature over the handshake message: its response scalar `s` and challenge `e`. + pub signature: (EmbeddedCurveScalar, EmbeddedCurveScalar), +} + +impl RecipientSignature { + /// Verifies that `recipient` authorized a handshake using ephemeral key `eph_pk.x` on this chain. + /// + /// # Panics + /// If the returned public keys do not derive `recipient`'s address, `mspk_x` is not on the curve, the signing + /// key does not match the address-committed `mspk_m_hash`, or the signature does not verify. Note that an + /// off-curve `ivpk_m` fails inside the embedded-curve addition in [`AztecAddress::compute`] rather than with + /// this method's error messages. + pub fn verify( + self, + recipient: AztecAddress, + chain_id: Field, + version: Field, + registry: AztecAddress, + eph_pk_x: Field, + ) { + // Bind the returned keys to the recipient's address. + assert_eq( + recipient, + AztecAddress::compute(self.public_keys, self.partial_address), + "handshake public keys do not match recipient", + ); + + // Reconstruct the signing key on-curve and tie it to the authenticated hash. + let mspk = point_from_x_coord_and_sign(self.mspk_x, self.mspk_y_is_positive).expect( + f"mspk_x is not a valid curve x-coordinate", + ); + assert( + hash_public_key(mspk) == self.public_keys.mspk_m_hash, + "handshake signature key does not match recipient", + ); + + // At this point `mspk` is proven to be the recipient's message-signing key, so a valid signature over the + // recomputed message can only come from the recipient. The message commits to the ephemeral key and chain + // context, never to the sender, so the recipient authorizes without learning who initiated it. + let message = poseidon2_hash_with_separator( + [chain_id, version, registry.to_field(), eph_pk_x], + DOM_SEP__INTERACTIVE_HANDSHAKE_SIGNATURE, + ); + assert(schnorr::verify_signature(mspk, self.signature, message), "invalid recipient handshake signature"); + } +} + +mod test { + use super::RecipientSignature; + use aztec::protocol::{ + address::{AztecAddress, PartialAddress}, + point::EmbeddedCurvePoint, + public_keys::{IvpkM, PublicKeys}, + traits::{FromField, ToField}, + }; + use std::embedded_curve_ops::EmbeddedCurveScalar; + + #[test] + unconstrained fn interactive_signature_verifies_valid_fixture() { + let (response, recipient, chain_id, version, registry, eph_pk_x) = interactive_handshake_fixture(); + response.verify(recipient, chain_id, version, registry, eph_pk_x); + } + + #[test(should_fail_with = "handshake public keys do not match recipient")] + unconstrained fn interactive_signature_rejects_wrong_recipient() { + let (response, recipient, chain_id, version, registry, eph_pk_x) = interactive_handshake_fixture(); + let wrong_recipient = AztecAddress::from_field(recipient.to_field() + 1); + response.verify(wrong_recipient, chain_id, version, registry, eph_pk_x); + } + + #[test(should_fail_with = "handshake signature key does not match recipient")] + unconstrained fn interactive_signature_rejects_wrong_signing_key() { + let (mut response, recipient, chain_id, version, registry, eph_pk_x) = interactive_handshake_fixture(); + // x = 1 is the generator's x-coordinate: a valid curve point, but not the recipient's signing key. + response.mspk_x = 1; + response.verify(recipient, chain_id, version, registry, eph_pk_x); + } + + #[test(should_fail_with = "mspk_x is not a valid curve x-coordinate")] + unconstrained fn interactive_signature_rejects_off_curve_signing_key() { + let (mut response, recipient, chain_id, version, registry, eph_pk_x) = interactive_handshake_fixture(); + // x = 3 is a non-residue for the curve, so no point has this x-coordinate. + response.mspk_x = 3; + response.verify(recipient, chain_id, version, registry, eph_pk_x); + } + + #[test(should_fail_with = "invalid recipient handshake signature")] + unconstrained fn interactive_signature_rejects_tampered_signature() { + let (mut response, recipient, chain_id, version, registry, eph_pk_x) = interactive_handshake_fixture(); + response.signature.0.lo += 1; + response.verify(recipient, chain_id, version, registry, eph_pk_x); + } + + #[test(should_fail_with = "invalid recipient handshake signature")] + unconstrained fn interactive_signature_rejects_wrong_eph_pk() { + let (response, recipient, chain_id, version, registry, eph_pk_x) = interactive_handshake_fixture(); + response.verify(recipient, chain_id, version, registry, eph_pk_x + 1); + } + + #[test(should_fail_with = "invalid recipient handshake signature")] + unconstrained fn interactive_signature_rejects_wrong_registry() { + let (response, recipient, chain_id, version, registry, eph_pk_x) = interactive_handshake_fixture(); + let wrong_registry = AztecAddress::from_field(registry.to_field() + 1); + response.verify(recipient, chain_id, version, wrong_registry, eph_pk_x); + } + + #[test(should_fail_with = "invalid recipient handshake signature")] + unconstrained fn interactive_signature_rejects_wrong_chain_context() { + let (response, recipient, chain_id, version, registry, eph_pk_x) = interactive_handshake_fixture(); + response.verify(recipient, chain_id + 1, version, registry, eph_pk_x); + } + + // A real interactive-handshake response fixture generated offline: recipient keys, partial address, + // message-signing key, and a genuine schnorr signature over `[chain_id, version, registry, eph_pk_x]`. + unconstrained fn interactive_handshake_fixture() -> (RecipientSignature, AztecAddress, Field, Field, AztecAddress, Field) { + let public_keys = PublicKeys { + npk_m_hash: 0x1b6146272d5dcb12676fa83f74475545fee7e36b4cb93e0e1ae4b2098f442de2, + ivpk_m: IvpkM { + inner: EmbeddedCurvePoint { + x: 0x03e89026e063059e6069e82a81a3e16d28f94a900ed08dadbd8407bf472d16c4, + y: 0x0c8a8b9ab39ea7563eb22a85905408a061e09bde12ff788a84e772b3e6af2130, + }, + }, + ovpk_m_hash: 0x279e44ade5739fdbdb91ea20489a20183fd6f38ddefafff8748bcfd8f72ad195, + tpk_m_hash: 0x124b66b8152df6debefebd7561f43ad0179f908b3b7b573963efb1685c80b0fc, + mspk_m_hash: 0x0e5480b4d52b9630b06e8d0e2ca8932457099274d522bb8ea6a60c1d7da871bf, + fbpk_m_hash: 0x26dbd06da054ead46dcf2232ac07be3ab5839d8287ac0f4ed69f62eb7ffaa2d0, + }; + let response = RecipientSignature { + public_keys, + partial_address: PartialAddress::from_field(0x0fedcba987654321), + mspk_x: 0x22d507026547e1bd19b507a234a689833ab1604c00ff093574c44aea7176cfd5, + mspk_y_is_positive: true, + signature: ( + EmbeddedCurveScalar { lo: 0xd88c1b23cafce3c85432b3393c8f1975, hi: 0x04f3972857d4b92ef44c2ea1d15e6abb }, + EmbeddedCurveScalar { lo: 0x01b6f5567970c74d567270c239f235ab, hi: 0x0db0cf5e2cfba9c07df49a357b6dcb2c }, + ), + }; + let recipient = AztecAddress::from_field( + 0x1b94108a8172e103629879215c11f224522aa1bd0e4d50505c041c500f82d15b, + ); + let chain_id = 0x7a69; + let version = 1; + let registry = AztecAddress::from_field(0x0abcabcabcabcabc); + let eph_pk_x = 0x2468ace2468ace; + (response, recipient, chain_id, version, registry, eph_pk_x) + } +} diff --git a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr index 5a6d1dbbac8b..3cc72d5cc988 100644 --- a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr +++ b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr @@ -82,8 +82,8 @@ unconstrained fn non_interactive_handshake_stores_handshake_for_sender_and_recip } // The DH-direct flow lifts `recipient` to a curve point and fails loud if the address has no -// corresponding point. We deliberately do not replicate AES128's "king of the hill" fallback here; see -// the doc comment on [`HandshakeRegistry::non_interactive_handshake`] for the rationale. +// corresponding point. We deliberately do not replicate AES128's "king of the hill" fallback here: a fallback +// would insert a permanent note recording a handshake with an invalid recipient, polluting registry state. #[test(should_fail_with = "recipient address is not on the curve")] unconstrained fn handshake_to_invalid_recipient_panics() { let (env, registry_address, sender, _, _) = setup(); @@ -96,6 +96,25 @@ unconstrained fn handshake_to_invalid_recipient_panics() { let _ = env.call_private(sender, registry.non_interactive_handshake(sender, invalid_recipient)); } +#[test(should_fail_with = "recipient address is not on the curve")] +unconstrained fn interactive_handshake_to_invalid_recipient_panics() { + let (env, registry_address, sender, _, _) = setup(); + let registry = HandshakeRegistry::at(registry_address); + + let invalid_recipient = AztecAddress::from_field(3); + let _ = env.call_private(sender, registry.interactive_handshake(sender, invalid_recipient)); +} + +// `interactive_handshake` must obtain the recipient's signature from a `resolveCustomRequest` resolver before +// storing anything. The TXE test environment configures no such resolver, so the call fails at the resolver hop. +#[test(should_fail_with = "no resolveCustomRequest hook is configured")] +unconstrained fn interactive_handshake_requires_a_resolver() { + let (env, registry_address, sender, _, recipient) = setup(); + let registry = HandshakeRegistry::at(registry_address); + + let _ = env.call_private(sender, registry.interactive_handshake(sender, recipient)); +} + // The handshake tag depends only on the recipient, so multiple senders posting to // the same recipient land under the same log tag. #[test] diff --git a/noir-projects/noir-contracts/pinned-standard-contracts.tar.gz b/noir-projects/noir-contracts/pinned-standard-contracts.tar.gz index 4c440bec6a5c..256ac97ad4c7 100644 Binary files a/noir-projects/noir-contracts/pinned-standard-contracts.tar.gz and b/noir-projects/noir-contracts/pinned-standard-contracts.tar.gz differ diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr b/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr index de3e12a645b0..9038fccf2cac 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr @@ -737,6 +737,9 @@ pub global DOM_SEP__CONSTRAINED_MSG_SENDER_SECRET: u32 = 1182889476; /// Domain separator for non-interactive handshake log tags emitted by the handshake registry contract. Used by /// [`crate::hash::compute_log_tag`]. pub global DOM_SEP__NON_INTERACTIVE_HANDSHAKE_LOG_TAG: u32 = 4046403018; +/// Domain separator for the message a recipient signs to authorize an interactive handshake, hashed via +/// [`crate::hash::poseidon2_hash_with_separator`] and verified in-circuit by the handshake registry contract. +pub global DOM_SEP__INTERACTIVE_HANDSHAKE_SIGNATURE: u32 = 3098455647; /// Domain separator for private log tags. /// /// Used by [`crate::hash::compute_siloed_private_log_first_field`]. diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/constants_tests.nr b/noir-projects/noir-protocol-circuits/crates/types/src/constants_tests.nr index 34a798c977ec..5bb00174a099 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/constants_tests.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/constants_tests.nr @@ -14,15 +14,15 @@ use crate::{ DOM_SEP__CONTRACT_ADDRESS_V2, DOM_SEP__CONTRACT_CLASS_ID, DOM_SEP__ECDH_FIELD_MASK, DOM_SEP__ECDH_SUBKEY, DOM_SEP__EVENT_COMMITMENT, DOM_SEP__EVENT_LOG_TAG, DOM_SEP__FBSK_M, DOM_SEP__FUNCTION_ARGS, DOM_SEP__INITIALIZATION_NULLIFIER, DOM_SEP__INITIALIZER, - DOM_SEP__IVSK_M, DOM_SEP__MERKLE_HASH, DOM_SEP__MESSAGE_NULLIFIER, DOM_SEP__MSSK_M, - DOM_SEP__NHK_M, DOM_SEP__NON_INTERACTIVE_HANDSHAKE_LOG_TAG, - DOM_SEP__NOTE_COMPLETION_LOG_TAG, DOM_SEP__NOTE_HASH, DOM_SEP__NOTE_HASH_NONCE, - DOM_SEP__NOTE_NULLIFIER, DOM_SEP__NULLIFIER_MERKLE, DOM_SEP__OVSK_M, - DOM_SEP__PARTIAL_ADDRESS, DOM_SEP__PARTIAL_NOTE_COMMITMENT, - DOM_SEP__PARTIAL_NOTE_VALIDITY_COMMITMENT, DOM_SEP__PRIVATE_FUNCTION_LEAF, - DOM_SEP__PRIVATE_INITIALIZATION_NULLIFIER, DOM_SEP__PRIVATE_LOG_FIRST_FIELD, - DOM_SEP__PRIVATE_TX_HASH, DOM_SEP__PROTOCOL_CONTRACTS, DOM_SEP__PUBLIC_BYTECODE, - DOM_SEP__PUBLIC_CALLDATA, DOM_SEP__PUBLIC_DATA_MERKLE, + DOM_SEP__INTERACTIVE_HANDSHAKE_SIGNATURE, DOM_SEP__IVSK_M, DOM_SEP__MERKLE_HASH, + DOM_SEP__MESSAGE_NULLIFIER, DOM_SEP__MSSK_M, DOM_SEP__NHK_M, + DOM_SEP__NON_INTERACTIVE_HANDSHAKE_LOG_TAG, DOM_SEP__NOTE_COMPLETION_LOG_TAG, + DOM_SEP__NOTE_HASH, DOM_SEP__NOTE_HASH_NONCE, DOM_SEP__NOTE_NULLIFIER, + DOM_SEP__NULLIFIER_MERKLE, DOM_SEP__OVSK_M, DOM_SEP__PARTIAL_ADDRESS, + DOM_SEP__PARTIAL_NOTE_COMMITMENT, DOM_SEP__PARTIAL_NOTE_VALIDITY_COMMITMENT, + DOM_SEP__PRIVATE_FUNCTION_LEAF, DOM_SEP__PRIVATE_INITIALIZATION_NULLIFIER, + DOM_SEP__PRIVATE_LOG_FIRST_FIELD, DOM_SEP__PRIVATE_TX_HASH, DOM_SEP__PROTOCOL_CONTRACTS, + DOM_SEP__PUBLIC_BYTECODE, DOM_SEP__PUBLIC_CALLDATA, DOM_SEP__PUBLIC_DATA_MERKLE, DOM_SEP__PUBLIC_INITIALIZATION_NULLIFIER, DOM_SEP__PUBLIC_KEYS_HASH, DOM_SEP__PUBLIC_LEAF_SLOT, DOM_SEP__PUBLIC_STORAGE_MAP_SLOT, DOM_SEP__PUBLIC_TX_HASH, DOM_SEP__RETRIEVED_BYTECODES_MERKLE, DOM_SEP__SALTED_INITIALIZATION_HASH, @@ -145,7 +145,7 @@ impl HashedValueTester::new(); + let mut tester = HashedValueTester::<77, 70>::new(); // ----------------- // Domain separators @@ -194,6 +194,10 @@ fn hashed_values_match_derived() { DOM_SEP__NON_INTERACTIVE_HANDSHAKE_LOG_TAG, "non_interactive_handshake_log_tag", ); + tester.assert_dom_sep_matches_derived( + DOM_SEP__INTERACTIVE_HANDSHAKE_SIGNATURE, + "interactive_handshake_signature", + ); tester.assert_dom_sep_matches_derived(DOM_SEP__MESSAGE_NULLIFIER, "message_nullifier"); tester.assert_dom_sep_matches_derived(DOM_SEP__PRIVATE_FUNCTION_LEAF, "private_function_leaf"); tester.assert_dom_sep_matches_derived(DOM_SEP__PUBLIC_BYTECODE, "public_bytecode"); diff --git a/yarn-project/aztec.js/src/api/keys.ts b/yarn-project/aztec.js/src/api/keys.ts index 8ee31409207f..748d0662a19e 100644 --- a/yarn-project/aztec.js/src/api/keys.ts +++ b/yarn-project/aztec.js/src/api/keys.ts @@ -3,6 +3,6 @@ export { computeAppNullifierHidingKey, deriveKeys, deriveMasterIncomingViewingSecretKey, - deriveMasterNullifierHidingKey, + deriveMasterNullifierHidingSecretKey, } from '@aztec/stdlib/keys'; export { generatePublicKey } from '../utils/pub_key.js'; diff --git a/yarn-project/aztec.js/src/contract/contract.test.ts b/yarn-project/aztec.js/src/contract/contract.test.ts index 9609755d1d6c..9c65f669c088 100644 --- a/yarn-project/aztec.js/src/contract/contract.test.ts +++ b/yarn-project/aztec.js/src/contract/contract.test.ts @@ -1,10 +1,6 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { - CompleteAddress, - type ContractInstanceWithAddress, - getContractClassFromArtifact, -} from '@aztec/stdlib/contract'; +import { CompleteAddress } from '@aztec/stdlib/contract'; import type { TxExecutionRequest, TxReceipt, UtilityExecutionResult } from '@aztec/stdlib/tx'; import { OFFCHAIN_MESSAGE_IDENTIFIER } from '@aztec/stdlib/tx'; @@ -21,7 +17,6 @@ describe('Contract Class', () => { let contractAddress: AztecAddress; let account: MockProxy; let accountAddress: CompleteAddress; - let contractInstance: ContractInstanceWithAddress; const mockTxRequest = { type: 'TxRequest' } as any as TxExecutionRequest; const mockTxReceipt = { type: 'TxReceipt' } as any as TxReceipt; @@ -40,17 +35,11 @@ describe('Contract Class', () => { account = mock(); accountAddress = await CompleteAddress.random(); account.getCompleteAddress.mockReturnValue(accountAddress); - const contractClass = await getContractClassFromArtifact(testContractArtifact); - contractInstance = { - address: contractAddress, - currentContractClassId: contractClass.id, - originalContractClassId: contractClass.id, - } as ContractInstanceWithAddress; wallet = mock(); wallet.simulateTx.mockResolvedValue(mockTxSimulationResultWithAppOffset); account.createTxExecutionRequest.mockResolvedValue(mockTxRequest); - wallet.registerContract.mockResolvedValue(contractInstance); + wallet.registerContract.mockResolvedValue(undefined); wallet.sendTx.mockResolvedValue({ receipt: mockTxReceipt, offchainEffects: [], offchainMessages: [] }); wallet.executeUtility.mockResolvedValue(mockUtilityResultValue); }); diff --git a/yarn-project/aztec.js/src/contract/deploy_method.test.ts b/yarn-project/aztec.js/src/contract/deploy_method.test.ts index ae1bc9e9b531..b01bff410452 100644 --- a/yarn-project/aztec.js/src/contract/deploy_method.test.ts +++ b/yarn-project/aztec.js/src/contract/deploy_method.test.ts @@ -1,6 +1,6 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { type ContractInstanceWithAddress, getContractInstanceFromInstantiationParams } from '@aztec/stdlib/contract'; +import { getContractInstanceFromInstantiationParams } from '@aztec/stdlib/contract'; import { Gas } from '@aztec/stdlib/gas'; import { OFFCHAIN_MESSAGE_IDENTIFIER, type OffchainEffect } from '@aztec/stdlib/tx'; @@ -16,7 +16,7 @@ describe('DeployMethod', () => { let wallet: MockProxy; beforeEach(() => { wallet = mock(); - wallet.registerContract.mockResolvedValue({} as ContractInstanceWithAddress); + wallet.registerContract.mockResolvedValue(undefined); wallet.getContractClassMetadata.mockResolvedValue({ isContractClassPubliclyRegistered: true } as any); wallet.getContractMetadata.mockResolvedValue({ isContractPubliclyDeployed: true } as any); }); diff --git a/yarn-project/aztec.js/src/contract/fastforward_contract_update.ts b/yarn-project/aztec.js/src/contract/fastforward_contract_update.ts index 40e9d19aefb1..f3b2186c9690 100644 --- a/yarn-project/aztec.js/src/contract/fastforward_contract_update.ts +++ b/yarn-project/aztec.js/src/contract/fastforward_contract_update.ts @@ -11,7 +11,7 @@ import { SimulationOverrides } from '@aztec/stdlib/tx'; /** * Builds `SimulationOverrides` that simulate a deployed instance as if it had already been upgraded to a - * new contract class. Mirrors a real on-chain upgrade (`pxe.updateContract` followed by waiting out the delay): + * new contract class. Mirrors a real on-chain upgrade (scheduling the new class and waiting out the delay): * * - `publicStorage` rewrites the `ContractInstanceRegistry`'s delayed-public-mutable storage so the AVM's * `UpdateCheck` resolves to the new class id. diff --git a/yarn-project/aztec.js/src/wallet/wallet.test.ts b/yarn-project/aztec.js/src/wallet/wallet.test.ts index ddcdc5719d6b..f17de294832d 100644 --- a/yarn-project/aztec.js/src/wallet/wallet.test.ts +++ b/yarn-project/aztec.js/src/wallet/wallet.test.ts @@ -7,7 +7,7 @@ import { EventSelector, FunctionCall, FunctionSelector, FunctionType } from '@az import { AuthWitness } from '@aztec/stdlib/auth-witness'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { BlockHash } from '@aztec/stdlib/block'; -import type { ContractInstanceWithAddress } from '@aztec/stdlib/contract'; +import type { ContractInstancePreimageWithAddress } from '@aztec/stdlib/contract'; import { PublicKeys } from '@aztec/stdlib/keys'; import { DroppedTxReceipt, @@ -134,29 +134,17 @@ describe('WalletSchema', () => { fileMap: {}, storageLayout: {}, }; - const mockInstance: ContractInstanceWithAddress = { + const mockInstance: ContractInstancePreimageWithAddress = { address: await AztecAddress.random(), version: 2, salt: Fr.random(), deployer: await AztecAddress.random(), - currentContractClassId: Fr.random(), originalContractClassId: Fr.random(), initializationHash: Fr.random(), immutablesHash: Fr.random(), publicKeys: PublicKeys.default(), }; - const result = await context.client.registerContract(mockInstance, mockArtifact, Fr.random()); - expect(result).toEqual({ - address: expect.any(AztecAddress), - currentContractClassId: expect.any(Fr), - deployer: expect.any(AztecAddress), - initializationHash: expect.any(Fr), - immutablesHash: expect.any(Fr), - originalContractClassId: expect.any(Fr), - publicKeys: expect.any(PublicKeys), - salt: expect.any(Fr), - version: 2, - }); + await context.client.registerContract(mockInstance, mockArtifact, Fr.random()); }); it('registerContractClass', async () => { @@ -335,12 +323,11 @@ describe('WalletSchema', () => { returnTypes: [], }); - const mockInstance: ContractInstanceWithAddress = { + const mockInstance: ContractInstancePreimageWithAddress = { address: address2, version: 2, salt: Fr.random(), deployer: await AztecAddress.random(), - currentContractClassId: Fr.random(), originalContractClassId: Fr.random(), initializationHash: Fr.random(), immutablesHash: Fr.random(), @@ -397,10 +384,7 @@ describe('WalletSchema', () => { expect(results[4]).toEqual({ name: 'registerSender', result: expect.any(AztecAddress) }); expect(results[5]).toEqual({ name: 'getAddressBook', result: expect.any(Array) }); expect(results[6]).toEqual({ name: 'getAccounts', result: expect.any(Array) }); - expect(results[7]).toEqual({ - name: 'registerContract', - result: expect.objectContaining({ address: expect.any(AztecAddress) }), - }); + expect(results[7]).toEqual({ name: 'registerContract', result: undefined }); expect(results[8]).toEqual({ name: 'simulateTx', result: expect.any(TxSimulationResultWithAppOffset) }); expect(results[9]).toEqual({ name: 'executeUtility', result: expect.any(UtilityExecutionResult) }); expect(results[10]).toEqual({ name: 'profileTx', result: expect.any(TxProfileResult) }); @@ -471,19 +455,7 @@ class MockWallet implements Wallet { return [{ alias: 'account1', item: await AztecAddress.random() }]; } - async registerContract(_instanceData: any, _artifact?: any, _secretKey?: Fr): Promise { - return { - version: 2, - address: await AztecAddress.random(), - currentContractClassId: Fr.random(), - deployer: await AztecAddress.random(), - initializationHash: Fr.random(), - immutablesHash: Fr.random(), - originalContractClassId: Fr.random(), - publicKeys: await PublicKeys.random(), - salt: Fr.random(), - }; - } + async registerContract(_instanceData: any, _artifact?: any, _secretKey?: Fr): Promise {} async registerContractClass(_artifact: any): Promise {} diff --git a/yarn-project/aztec.js/src/wallet/wallet.ts b/yarn-project/aztec.js/src/wallet/wallet.ts index b8e3ad22d074..40b1f444c132 100644 --- a/yarn-project/aztec.js/src/wallet/wallet.ts +++ b/yarn-project/aztec.js/src/wallet/wallet.ts @@ -11,7 +11,12 @@ import { } from '@aztec/stdlib/abi'; import { AuthWitness } from '@aztec/stdlib/auth-witness'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { type ContractInstanceWithAddress, ContractInstanceWithAddressSchema } from '@aztec/stdlib/contract'; +import { + type ContractInstancePreimage, + ContractInstancePreimageSchema, + type ContractInstancePreimageWithAddress, + ContractInstancePreimageWithAddressSchema, +} from '@aztec/stdlib/contract'; import { Gas, ManaUsageEstimate } from '@aztec/stdlib/gas'; import type { MasterSecretKeys } from '@aztec/stdlib/keys'; import { refineTxHashAndRange } from '@aztec/stdlib/logs'; @@ -234,8 +239,8 @@ export enum ContractInitializationStatus { * Contract metadata including deployment and registration status. */ export type ContractMetadata = { - /** The contract instance */ - instance?: ContractInstanceWithAddress; + /** The contract instance preimage and address. */ + instance?: ContractInstancePreimageWithAddress; /** Whether the contract has been initialized. */ initializationStatus: ContractInitializationStatus; /** Whether the contract instance is publicly deployed on-chain */ @@ -281,10 +286,10 @@ export type Wallet = { getAddressBook(): Promise[]>; getAccounts(): Promise[]>; registerContract( - instance: ContractInstanceWithAddress, + instance: ContractInstancePreimage, artifact?: ContractArtifact, secretKeyOrKeys?: Fr | MasterSecretKeys, - ): Promise; + ): Promise; /** * Registers a contract class artifact in the local PXE without binding it to any instance. * Useful for simulation flows that need the artifact available locally before any on-chain @@ -413,7 +418,7 @@ export const PublicEventSchema: z.ZodType> = zodFor - z.object({ + const namesAndReturns = names.map(name => { + const returnType = getSchemaReturnType(methodSchemas[name]); + return z.object({ name: z.literal(name), - result: getSchemaReturnType(methodSchemas[name]), - }), - ); + // void-returning methods serialize to a missing `result` key over JSON-RPC, so their field must be optional: + // value-returning methods keep it required so a dropped result is still caught. + result: returnType instanceof z.ZodVoid ? returnType.optional() : returnType, + }); + }); // Type assertion needed because discriminatedUnion expects a tuple type [T, T, ...T[]] // but we're building the array dynamically. The runtime behavior is correct. diff --git a/yarn-project/cli-wallet/src/cmds/check_tx.ts b/yarn-project/cli-wallet/src/cmds/check_tx.ts index 08649e61a5ef..55d354d677ab 100644 --- a/yarn-project/cli-wallet/src/cmds/check_tx.ts +++ b/yarn-project/cli-wallet/src/cmds/check_tx.ts @@ -197,21 +197,22 @@ type ContractArtifactWithClassId = ContractArtifact & { classId: Fr }; async function getKnownArtifacts(wallet: CLIWallet): Promise { const knownContractAddresses = await wallet.getContracts(); - const knownContracts = ( - await Promise.all(knownContractAddresses.map(contractAddress => wallet.getContractMetadata(contractAddress))) - ).map(contractMetadata => contractMetadata.instance); - const classIds = [...new Set(knownContracts.map(contract => contract?.currentContractClassId))]; + const knownContracts = await Promise.all( + knownContractAddresses.map(contractAddress => wallet.getContractMetadata(contractAddress)), + ); + const classIdFor = (metadata: (typeof knownContracts)[number]): Fr | undefined => + metadata.updatedContractClassId ?? metadata.instance?.originalContractClassId; + const classIds = [...new Set(knownContracts.map(classIdFor))]; const knownArtifacts = ( await Promise.all(classIds.map(classId => (classId ? wallet.getContractArtifact(classId) : undefined))) ).map((artifact, index) => (artifact ? { ...artifact, classId: classIds[index] } : undefined)); const map: Record = {}; - for (const instance of knownContracts) { - if (instance) { - const artifact = knownArtifacts.find(a => - a?.classId?.equals(instance.currentContractClassId), - ) as ContractArtifactWithClassId; + for (const metadata of knownContracts) { + const classId = classIdFor(metadata); + if (metadata.instance && classId) { + const artifact = knownArtifacts.find(a => a?.classId?.equals(classId)) as ContractArtifactWithClassId; if (artifact) { - map[instance.address.toString()] = artifact; + map[metadata.instance.address.toString()] = artifact; } } } diff --git a/yarn-project/cli-wallet/src/cmds/simulate.ts b/yarn-project/cli-wallet/src/cmds/simulate.ts index aec161b4ab33..4319746817da 100644 --- a/yarn-project/cli-wallet/src/cmds/simulate.ts +++ b/yarn-project/cli-wallet/src/cmds/simulate.ts @@ -44,7 +44,8 @@ export async function simulate( if (!metadata.instance) { return undefined; } - const artifact = await wallet.getContractArtifact(metadata.instance.currentContractClassId); + const classId = metadata.updatedContractClassId ?? metadata.instance.originalContractClassId; + const artifact = await wallet.getContractArtifact(classId); return artifact; }, log, diff --git a/yarn-project/cli-wallet/src/utils/wallet.ts b/yarn-project/cli-wallet/src/utils/wallet.ts index ffb0832a1110..865705f7038d 100644 --- a/yarn-project/cli-wallet/src/utils/wallet.ts +++ b/yarn-project/cli-wallet/src/utils/wallet.ts @@ -68,7 +68,8 @@ export class CLIWallet extends BaseWallet { private async registerAuthRegistry(): Promise { const { instance, artifact } = await getStandardAuthRegistry(); - await this.pxe.registerContract({ instance, artifact }); + await this.pxe.registerContractClass(artifact); + await this.pxe.registerContract(instance); } /** diff --git a/yarn-project/constants/src/constants.gen.ts b/yarn-project/constants/src/constants.gen.ts index 7c99d784153d..4c1a1b5b6ceb 100644 --- a/yarn-project/constants/src/constants.gen.ts +++ b/yarn-project/constants/src/constants.gen.ts @@ -526,7 +526,9 @@ export enum DomainSeparator { UNCONSTRAINED_MSG_LOG_TAG = 1485357192, CONSTRAINED_MSG_LOG_TAG = 3715244738, CONSTRAINED_MSG_NULLIFIER = 3723577546, + CONSTRAINED_MSG_SENDER_SECRET = 1182889476, NON_INTERACTIVE_HANDSHAKE_LOG_TAG = 4046403018, + INTERACTIVE_HANDSHAKE_SIGNATURE = 3098455647, PRIVATE_LOG_FIRST_FIELD = 2769976252, PUBLIC_LEAF_SLOT = 1247650290, PUBLIC_STORAGE_MAP_SLOT = 4015149901, diff --git a/yarn-project/end-to-end/src/automine/accounts/keys.test.ts b/yarn-project/end-to-end/src/automine/accounts/keys.test.ts index 84741d202d5f..9344ce63d63e 100644 --- a/yarn-project/end-to-end/src/automine/accounts/keys.test.ts +++ b/yarn-project/end-to-end/src/automine/accounts/keys.test.ts @@ -10,7 +10,7 @@ import { siloNullifier } from '@aztec/stdlib/hash'; import { computeAppNullifierHidingKey, computeAppSecretKey, - deriveMasterNullifierHidingKey, + deriveMasterNullifierHidingSecretKey, deriveMasterOutgoingViewingSecretKey, derivePublicKeyFromSecretKey, hashPublicKey, @@ -74,8 +74,8 @@ describe('automine/accounts/keys', () => { // Creates a note, asserts 0 nullified notes. Destroys the note, scans all blocks for matching // nullifiers derived from nhk_app and asserts exactly 1 nullified note. it('nhk_app and contract address are enough to detect note nullification', async () => { - const masterNullifierHidingKey = deriveMasterNullifierHidingKey(secret); - const nhkApp = await computeAppNullifierHidingKey(masterNullifierHidingKey, testContract.address); + const masterNullifierHidingSecretKey = deriveMasterNullifierHidingSecretKey(secret); + const nhkApp = await computeAppNullifierHidingKey(masterNullifierHidingSecretKey, testContract.address); const noteValue = 5; const noteStorageSlot = 12; diff --git a/yarn-project/end-to-end/src/automine/card_game.test.ts b/yarn-project/end-to-end/src/automine/card_game.test.ts index 59f1e0449cc8..39a09a2e1de7 100644 --- a/yarn-project/end-to-end/src/automine/card_game.test.ts +++ b/yarn-project/end-to-end/src/automine/card_game.test.ts @@ -1,7 +1,7 @@ import { generateSchnorrAccounts } from '@aztec/accounts/testing'; import { AztecAddress } from '@aztec/aztec.js/addresses'; import type { GrumpkinScalar } from '@aztec/aztec.js/fields'; -import { computeAppNullifierHidingKey, deriveMasterNullifierHidingKey } from '@aztec/aztec.js/keys'; +import { computeAppNullifierHidingKey, deriveMasterNullifierHidingSecretKey } from '@aztec/aztec.js/keys'; import type { Logger } from '@aztec/aztec.js/log'; import type { Wallet } from '@aztec/aztec.js/wallet'; import { toBufferLE } from '@aztec/foundation/bigint-buffer'; @@ -68,7 +68,7 @@ describe('automine/card_game', () => { let teardown: () => Promise; let wallet: TestWallet; - let masterNullifierHidingKeys: GrumpkinScalar[]; + let masterNullifierHidingSecretKeys: GrumpkinScalar[]; let firstPlayer: AztecAddress; let secondPlayer: AztecAddress; @@ -78,8 +78,8 @@ describe('automine/card_game', () => { const getPackedCards = async (accountIndex: number, seed: bigint): Promise => { // First we get the app nullifier hiding key for the account - const masterNullifierHidingKey = masterNullifierHidingKeys[accountIndex]; - const appNullifierHidingKey = await computeAppNullifierHidingKey(masterNullifierHidingKey, contract.address); + const masterNullifierHidingSecretKey = masterNullifierHidingSecretKeys[accountIndex]; + const appNullifierHidingKey = await computeAppNullifierHidingKey(masterNullifierHidingSecretKey, contract.address); // Then we compute the mix from it and hash it to get the random bytes the same way as in the contract const mix = appNullifierHidingKey.toBigInt() + seed; const randomBytes = sha256(toBufferLE(mix, 32)); @@ -106,7 +106,7 @@ describe('automine/card_game', () => { } [firstPlayer, secondPlayer, thirdPlayer] = players.map(p => p.address); - masterNullifierHidingKeys = players.map(({ secret }) => deriveMasterNullifierHidingKey(secret)); + masterNullifierHidingSecretKeys = players.map(({ secret }) => deriveMasterNullifierHidingSecretKey(secret)); }); beforeEach(async () => { diff --git a/yarn-project/end-to-end/src/automine/contracts/contract_updates.parallel.test.ts b/yarn-project/end-to-end/src/automine/contracts/contract_updates.parallel.test.ts index 527a695c3366..fcd28dbfe791 100644 --- a/yarn-project/end-to-end/src/automine/contracts/contract_updates.parallel.test.ts +++ b/yarn-project/end-to-end/src/automine/contracts/contract_updates.parallel.test.ts @@ -186,12 +186,16 @@ describe('automine/contracts/contract_updates', () => { ).rejects.toThrow('New update delay is too low'); }); - // Tries to register the instance against UpdatedContract.artifact before the upgrade window passes; - // expects the PXE to reject with a class mismatch error. - it('should not allow to instantiate a contract with an updated class before the update happens', async () => { - await expect(wallet.registerContract(instance, UpdatedContract.artifact)).rejects.toThrow( - 'Could not update contract to a class different from the current one', - ); + it('permits registering the updated artifact before the update, but still runs the original class', async () => { + // Registration performs no validation, so the updated artifact can be registered before the upgrade activates. + await expect(wallet.registerContract(instance, UpdatedContract.artifact)).resolves.not.toThrow(); + + // The node still resolves the contract's current class to the original until the update is enacted, so a call + // against the updated class's (no-arg) set_public_value selector does not resolve against the deployed class. + const updatedContract = UpdatedContract.at(contract.address, wallet); + await expect( + updatedContract.methods.set_public_value().simulate({ from: defaultAccountAddress }), + ).rejects.toThrow(); }); // UpdatableContract's `set_public_value(Field)` and UpdatedContract's `set_public_value()` diff --git a/yarn-project/end-to-end/src/automine/contracts/deploy/deploy_method.parallel.test.ts b/yarn-project/end-to-end/src/automine/contracts/deploy/deploy_method.parallel.test.ts index 8c109ec687ae..65e8b2d6c4a7 100644 --- a/yarn-project/end-to-end/src/automine/contracts/deploy/deploy_method.parallel.test.ts +++ b/yarn-project/end-to-end/src/automine/contracts/deploy/deploy_method.parallel.test.ts @@ -55,7 +55,7 @@ describe('automine/contracts/deploy/deploy_method', () => { ); // docs:start:verify_deployment const metadata = await wallet.getContractMetadata(contract.address); - const classMetadata = await wallet.getContractClassMetadata(metadata.instance!.currentContractClassId); + const classMetadata = await wallet.getContractClassMetadata(metadata.instance!.originalContractClassId); const isPublished = classMetadata.isContractClassPubliclyRegistered; // docs:end:verify_deployment expect(isPublished).toBeTrue(); diff --git a/yarn-project/end-to-end/src/spartan/block_capacity.test.ts b/yarn-project/end-to-end/src/spartan/block_capacity.test.ts index 8a3fcbc5c3e2..c40fbc6db502 100644 --- a/yarn-project/end-to-end/src/spartan/block_capacity.test.ts +++ b/yarn-project/end-to-end/src/spartan/block_capacity.test.ts @@ -371,9 +371,12 @@ describe('block capacity benchmark', () => { logger.info('BenchmarkingContract deployed', { address: benchmarkContract.address.toString() }); // Register benchmark contract with all other wallets - const benchMetadata = await wallets[0].getContractMetadata(benchmarkContract.address); + const benchInstance = await wallets[0].getContractMetadata(benchmarkContract.address).then(m => ({ + ...m.instance!, + currentContractClassId: m.instance!.originalContractClassId, + })); await Promise.all( - wallets.slice(1).map(wallet => wallet.registerContract(benchMetadata.instance!, BenchmarkingContract.artifact)), + wallets.slice(1).map(wallet => wallet.registerContract(benchInstance, BenchmarkingContract.artifact)), ); logger.info('Benchmark contract registered with all wallets'); }); @@ -410,10 +413,11 @@ describe('block capacity benchmark', () => { logger.info('TokenContract deployed', { address: tokenContract.address.toString() }); // Register token contract with all other wallets - const tokenMetadata = await wallets[0].getContractMetadata(tokenContract.address); - await Promise.all( - wallets.slice(1).map(wallet => wallet.registerContract(tokenMetadata.instance!, TokenContract.artifact)), - ); + const tokenInstance = await wallets[0].getContractMetadata(tokenContract.address).then(m => ({ + ...m.instance!, + currentContractClassId: m.instance!.originalContractClassId, + })); + await Promise.all(wallets.slice(1).map(wallet => wallet.registerContract(tokenInstance, TokenContract.artifact))); logger.info('Token contract registered with all wallets'); // Mint tokens publicly to each account (enough for TX_COUNT transfers). diff --git a/yarn-project/end-to-end/src/test-wallet/worker_wallet.ts b/yarn-project/end-to-end/src/test-wallet/worker_wallet.ts index df93dceafde1..054e6b6d3536 100644 --- a/yarn-project/end-to-end/src/test-wallet/worker_wallet.ts +++ b/yarn-project/end-to-end/src/test-wallet/worker_wallet.ts @@ -29,7 +29,7 @@ import type { PXEConfig } from '@aztec/pxe/config'; import type { ContractArtifact, EventMetadataDefinition, FunctionCall } from '@aztec/stdlib/abi'; import type { AuthWitness } from '@aztec/stdlib/auth-witness'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; -import type { ContractInstanceWithAddress } from '@aztec/stdlib/contract'; +import type { ContractInstancePreimage } from '@aztec/stdlib/contract'; import type { ExecutionPayload, TxProfileResult, UtilityExecutionResult } from '@aztec/stdlib/tx'; import { Tx } from '@aztec/stdlib/tx'; @@ -158,11 +158,7 @@ export class WorkerWallet implements Wallet { return this.call('getAccounts'); } - registerContract( - instance: ContractInstanceWithAddress, - artifact?: ContractArtifact, - secretKey?: Fr, - ): Promise { + registerContract(instance: ContractInstancePreimage, artifact?: ContractArtifact, secretKey?: Fr): Promise { return this.call('registerContract', instance, artifact, secretKey); } diff --git a/yarn-project/key-store/src/account_privacy_keys.ts b/yarn-project/key-store/src/account_privacy_keys.ts new file mode 100644 index 000000000000..45c1f265902b --- /dev/null +++ b/yarn-project/key-store/src/account_privacy_keys.ts @@ -0,0 +1,24 @@ +import type { GrumpkinScalar } from '@aztec/foundation/curves/grumpkin'; +import type { PublicKey } from '@aztec/stdlib/keys'; + +/** + * The four master privacy secret keys the key store holds for an account: the nullifier-hiding, incoming-viewing, + * outgoing-viewing, and tagging keys. + */ +export type AccountPrivacySecretKeys = { + masterNullifierHidingSecretKey: GrumpkinScalar; + masterIncomingViewingSecretKey: GrumpkinScalar; + masterOutgoingViewingSecretKey: GrumpkinScalar; + masterTaggingSecretKey: GrumpkinScalar; +}; + +/** + * The keys needed to register an account: the four privacy secret keys the key store holds, plus the *public* + * message-signing and fallback keys. The message-signing and fallback secret keys are withheld from the key store (and + * hence from PXE, which embeds it), since it is not trusted to hold them: only their public keys are needed (to + * reconstruct the account's address). + */ +export type AccountPrivacyKeys = AccountPrivacySecretKeys & { + masterMessageSigningPublicKey: PublicKey; + masterFallbackPublicKey: PublicKey; +}; diff --git a/yarn-project/key-store/src/index.ts b/yarn-project/key-store/src/index.ts index 7969b66bac46..e8b6769a09b0 100644 --- a/yarn-project/key-store/src/index.ts +++ b/yarn-project/key-store/src/index.ts @@ -1 +1,2 @@ +export * from './account_privacy_keys.js'; export * from './key_store.js'; diff --git a/yarn-project/key-store/src/key_store.test.ts b/yarn-project/key-store/src/key_store.test.ts index f9821f5a1726..8b91fd89ee85 100644 --- a/yarn-project/key-store/src/key_store.test.ts +++ b/yarn-project/key-store/src/key_store.test.ts @@ -2,21 +2,18 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { GrumpkinScalar } from '@aztec/foundation/curves/grumpkin'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { type MasterSecretKeys, deriveKeys, derivePublicKeyFromSecretKey, hashPublicKey } from '@aztec/stdlib/keys'; +import { PublicKey, deriveKeys, derivePublicKeyFromSecretKey, hashPublicKey } from '@aztec/stdlib/keys'; +import type { AccountPrivacySecretKeys } from './account_privacy_keys.js'; import { KeyStore } from './key_store.js'; -/** Picks the six master secret keys out of the full `deriveKeys` output. */ -function masterSecretKeysOf(derived: Awaited>): MasterSecretKeys { - return { - masterNullifierHidingKey: derived.masterNullifierHidingKey, - masterIncomingViewingSecretKey: derived.masterIncomingViewingSecretKey, - masterOutgoingViewingSecretKey: derived.masterOutgoingViewingSecretKey, - masterTaggingSecretKey: derived.masterTaggingSecretKey, - masterMessageSigningSecretKey: derived.masterMessageSigningSecretKey, - masterFallbackSecretKey: derived.masterFallbackSecretKey, - }; -} +/** The four privacy secret keys the key store holds and returns from `getAccountSecretKeys`. */ +const PRIVACY_SECRET_KEY_NAMES = [ + 'masterNullifierHidingSecretKey', + 'masterIncomingViewingSecretKey', + 'masterOutgoingViewingSecretKey', + 'masterTaggingSecretKey', +] as const satisfies readonly (keyof AccountPrivacySecretKeys)[]; describe('KeyStore', () => { it('Adds account and returns keys', async () => { @@ -25,22 +22,24 @@ describe('KeyStore', () => { // Arbitrary fixed values const sk = new Fr(8923n); const keys = await deriveKeys(sk); - const derivedMasterNullifierPublicKey = await derivePublicKeyFromSecretKey(keys.masterNullifierHidingKey); - const computedMasterNullifierPublicKeyHash = await hashPublicKey(derivedMasterNullifierPublicKey); + const derivedMasterNullifierHidingPublicKey = await derivePublicKeyFromSecretKey( + keys.masterNullifierHidingSecretKey, + ); + const computedMasterNullifierHidingPublicKeyHash = await hashPublicKey(derivedMasterNullifierHidingPublicKey); const computedMasterIncomingViewingPublicKeyHash = await hashPublicKey(keys.publicKeys.ivpkM); const partialAddress = new Fr(243523n); - const { address: accountAddress } = await keyStore.addAccount(sk, partialAddress); + const { address: accountAddress } = await keyStore.addAccount(keys, partialAddress); expect(accountAddress.toString()).toMatchInlineSnapshot( `"0x0a3120bded2afb430e67e4bdb5326a673fbfd95642b6ea7f80d0cc958aac3940"`, ); const { pkMHash: returnedNpkMHash } = await keyStore.getKeyValidationRequest( - computedMasterNullifierPublicKeyHash, + computedMasterNullifierHidingPublicKeyHash, await AztecAddress.random(), // Address is random because we are not interested in the app secret key here ); - expect(returnedNpkMHash.equals(computedMasterNullifierPublicKeyHash)).toBe(true); + expect(returnedNpkMHash.equals(computedMasterNullifierHidingPublicKeyHash)).toBe(true); const masterIncomingViewingPublicKey = await keyStore.getMasterIncomingViewingPublicKey(accountAddress); expect(masterIncomingViewingPublicKey.equals(keys.publicKeys.ivpkM)).toBe(true); @@ -58,13 +57,13 @@ describe('KeyStore', () => { const appAddress = AztecAddress.fromBigIntUnsafe(624n); const { pkMHash: obtainedNpkMHash, skApp: appNullifierHidingKey } = await keyStore.getKeyValidationRequest( - computedMasterNullifierPublicKeyHash, + computedMasterNullifierHidingPublicKeyHash, appAddress, ); expect(appNullifierHidingKey.toString()).toMatchInlineSnapshot( `"0x165cc265d187ed42f0e3f5adbb5a0055a77e205daeb68dd1735796ee402e502f"`, ); - expect(obtainedNpkMHash).toEqual(computedMasterNullifierPublicKeyHash); + expect(obtainedNpkMHash).toEqual(computedMasterNullifierHidingPublicKeyHash); const appOutgoingViewingSecretKey = await keyStore.getAppOutgoingViewingSecretKey(accountAddress, appAddress); expect(appOutgoingViewingSecretKey.toString()).toMatchInlineSnapshot( @@ -78,8 +77,10 @@ describe('KeyStore', () => { ); // Manages to find master nullifier hiding key for the pk_m hash - const masterNullifierHidingKey = await keyStore.getMasterSecretKey(computedMasterNullifierPublicKeyHash); - expect(masterNullifierHidingKey.equals(keys.masterNullifierHidingKey)).toBe(true); + const masterNullifierHidingSecretKey = await keyStore.getMasterSecretKey( + computedMasterNullifierHidingPublicKeyHash, + ); + expect(masterNullifierHidingSecretKey.equals(keys.masterNullifierHidingSecretKey)).toBe(true); // Manages to find master incoming viewing secret key for the pk_m hash const masterIncomingViewingSecretKeyFromPublicKey = await keyStore.getMasterSecretKey( @@ -88,38 +89,33 @@ describe('KeyStore', () => { expect(masterIncomingViewingSecretKeyFromPublicKey.equals(keys.masterIncomingViewingSecretKey)).toBe(true); }); - it('registers an account from master secret keys, matching seed-based registration', async () => { + it('exports the privacy secret keys it was registered with', async () => { const keyStore = new KeyStore(await openTmpStore('test')); - const sk = new Fr(8923n); - const partialAddress = new Fr(243523n); - const secretKeys = masterSecretKeysOf(await deriveKeys(sk)); + const privacyKeys = await deriveKeys(new Fr(8923n)); + const { address } = await keyStore.addAccount(privacyKeys, new Fr(243523n)); - // Registering with the keys derived from a secret must yield the same complete address as registering with the - // secret directly: the address is a pure function of the (derived) public keys and the partial address. - const fromSecretKey = await keyStore.addAccount(sk, partialAddress); - const fromKeys = await keyStore.addAccount(secretKeys, partialAddress); - expect(fromKeys.equals(fromSecretKey)).toBe(true); + const exported = await keyStore.getAccountSecretKeys(address); + for (const name of PRIVACY_SECRET_KEY_NAMES) { + expect(exported[name].equals(privacyKeys[name])).toBe(true); + } }); - it('exports the master secret keys it was registered with', async () => { + it('rejects registering an account with secret key resulting in infinity public keys', async () => { const keyStore = new KeyStore(await openTmpStore('test')); - const secretKeys = masterSecretKeysOf(await deriveKeys(new Fr(8923n))); - const { address } = await keyStore.addAccount(secretKeys, new Fr(243523n)); + const privacyKeys = await deriveKeys(new Fr(8923n)); + privacyKeys.masterIncomingViewingSecretKey = GrumpkinScalar.ZERO; - const exported = await keyStore.getAccountSecretKeys(address); - for (const name of Object.keys(secretKeys) as (keyof MasterSecretKeys)[]) { - expect(exported[name].equals(secretKeys[name])).toBe(true); - } + await expect(keyStore.addAccount(privacyKeys, new Fr(243523n))).rejects.toThrow('masterIncomingViewingPublicKey'); }); - it('rejects registering an account with a zero secret key', async () => { + it('rejects registering an account with an infinity message-signing or fallback public key', async () => { const keyStore = new KeyStore(await openTmpStore('test')); - const secretKeys = masterSecretKeysOf(await deriveKeys(new Fr(8923n))); - secretKeys.masterIncomingViewingSecretKey = GrumpkinScalar.ZERO; + const privacyKeys = await deriveKeys(new Fr(8923n)); + privacyKeys.masterMessageSigningPublicKey = PublicKey.INFINITY; - await expect(keyStore.addAccount(secretKeys, new Fr(243523n))).rejects.toThrow('masterIncomingViewingSecretKey'); + await expect(keyStore.addAccount(privacyKeys, new Fr(243523n))).rejects.toThrow('masterMessageSigningPublicKey'); }); }); diff --git a/yarn-project/key-store/src/key_store.ts b/yarn-project/key-store/src/key_store.ts index 61937b08076e..b714e3af031a 100644 --- a/yarn-project/key-store/src/key_store.ts +++ b/yarn-project/key-store/src/key_store.ts @@ -11,20 +11,76 @@ import { KeyValidationRequest } from '@aztec/stdlib/kernel'; import { KEY_PREFIXES, type KeyPrefix, - type MasterSecretKeys, type PublicKey, + PublicKeys, computeAppSecretKey, - deriveKeys, - deriveKeysFromMasterSecretKeys, derivePublicKeyFromSecretKey, hashPublicKey, } from '@aztec/stdlib/keys'; +import type { AccountPrivacyKeys, AccountPrivacySecretKeys } from './account_privacy_keys.js'; + /** Maps a key prefix to the storage suffix for the corresponding master secret key. */ function secretKeyStorageSuffix(prefix: KeyPrefix): string { return prefix === 'n' ? 'nhk_m' : `${prefix}sk_m`; } +/** + * Computes the public counterparts of an account's four privacy secret keys and assembles its {@link PublicKeys} struct + * (used to derive the address), from the {@link AccountPrivacyKeys} passed to {@link KeyStore.addAccount}. + * + * The message-signing and fallback keys are already supplied as public keys, since the key store never holds their + * secrets. + */ +async function completeAccountKeys(keys: AccountPrivacyKeys) { + const { + masterNullifierHidingSecretKey, + masterIncomingViewingSecretKey, + masterOutgoingViewingSecretKey, + masterTaggingSecretKey, + masterMessageSigningPublicKey, + masterFallbackPublicKey, + } = keys; + + const masterNullifierHidingPublicKey = await derivePublicKeyFromSecretKey(masterNullifierHidingSecretKey); + const masterIncomingViewingPublicKey = await derivePublicKeyFromSecretKey(masterIncomingViewingSecretKey); + const masterOutgoingViewingPublicKey = await derivePublicKeyFromSecretKey(masterOutgoingViewingSecretKey); + const masterTaggingPublicKey = await derivePublicKeyFromSecretKey(masterTaggingSecretKey); + + for (const [name, publicKey] of Object.entries({ + masterNullifierHidingPublicKey, + masterIncomingViewingPublicKey, + masterOutgoingViewingPublicKey, + masterTaggingPublicKey, + masterMessageSigningPublicKey, + masterFallbackPublicKey, + })) { + if (publicKey.isInfinite) { + throw new Error(`Cannot register an account with an infinity ${name}.`); + } + } + + const publicKeys = new PublicKeys( + await hashPublicKey(masterNullifierHidingPublicKey), + masterIncomingViewingPublicKey, + await hashPublicKey(masterOutgoingViewingPublicKey), + await hashPublicKey(masterTaggingPublicKey), + await hashPublicKey(masterMessageSigningPublicKey), + await hashPublicKey(masterFallbackPublicKey), + ); + + return { + masterNullifierHidingSecretKey, + masterIncomingViewingSecretKey, + masterOutgoingViewingSecretKey, + masterTaggingSecretKey, + masterNullifierHidingPublicKey, + masterOutgoingViewingPublicKey, + masterTaggingPublicKey, + publicKeys, + }; +} + /** * Used for managing keys. Can hold keys of multiple accounts. */ @@ -38,37 +94,22 @@ export class KeyStore { this.#keys = database.openMap('key_store'); } - /** - * Creates a new account from a randomly generated secret key. - * @returns A promise that resolves to the newly created account's CompleteAddress. - */ - public createAccount(): Promise { - const sk = Fr.random(); - const partialAddress = Fr.random(); - return this.addAccount(sk, partialAddress); - } - /** * Adds an account to the key store. * - * The account's privacy keys may be provided either as a single master secret key, from which all master keys are - * derived, or as the full set of master private secret keys supplied directly (e.g. for an account whose privacy keys - * were generated independently rather than from one seed). + * The key store holds the four privacy secret keys (nullifier-hiding, incoming-viewing, outgoing-viewing, tagging), + * but only the *public* message-signing and fallback keys: their secret keys are withheld, since the key store (and + * PXE, which embeds it) is not trusted to hold them. The public keys are still needed to reconstruct the account's + * address, which commits to all six master public keys. * - * @param secretKeyOrKeys - The account's master secret key, or its full set of master secret keys. + * @param keys - The account's privacy keys: four secret keys plus the message-signing and fallback public keys. * @param partialAddress - The partial address of the account. * @returns The account's complete address. - * @throws If any of the provided secret keys is zero. + * @throws If any of the account's six master public keys would be the point at infinity. */ - public async addAccount( - secretKeyOrKeys: Fr | MasterSecretKeys, - partialAddress: PartialAddress, - ): Promise { - const derivedKeys = - secretKeyOrKeys instanceof Fr - ? await deriveKeys(secretKeyOrKeys) - : await deriveKeysFromMasterSecretKeys(this.#assertNonZeroSecretKeys(secretKeyOrKeys)); - return this.#storeDerivedKeys(derivedKeys, partialAddress); + public async addAccount(keys: AccountPrivacyKeys, partialAddress: PartialAddress): Promise { + const accountKeys = await completeAccountKeys(keys); + return this.#storeAccountKeys(accountKeys, partialAddress); } /** @@ -142,7 +183,7 @@ export class KeyStore { * Gets the master nullifier public key for a given account. * @throws If the account does not exist in the key store. */ - public async getMasterNullifierPublicKey(account: AztecAddress): Promise { + public async getMasterNullifierHidingPublicKey(account: AztecAddress): Promise { return Point.fromBuffer(await this.#getMasterKeyBuffer(account, 'npk_m')); } @@ -170,38 +211,6 @@ export class KeyStore { return Point.fromBuffer(await this.#getMasterKeyBuffer(account, 'tpk_m')); } - /** - * Retrieves the master message-signing public key. - * @throws If the account does not exist in the key store. - */ - public async getMasterMessageSigningPublicKey(account: AztecAddress): Promise { - return Point.fromBuffer(await this.#getMasterKeyBuffer(account, 'mspk_m')); - } - - /** - * Retrieves the master fallback public key. - * @throws If the account does not exist in the key store. - */ - public async getMasterFallbackPublicKey(account: AztecAddress): Promise { - return Point.fromBuffer(await this.#getMasterKeyBuffer(account, 'fbpk_m')); - } - - /** - * Retrieves the master message-signing secret key. - * @throws If the account does not exist in the key store. - */ - public async getMasterMessageSigningSecretKey(account: AztecAddress): Promise { - return GrumpkinScalar.fromBuffer(await this.#getMasterKeyBuffer(account, 'mssk_m')); - } - - /** - * Retrieves the master fallback secret key. - * @throws If the account does not exist in the key store. - */ - public async getMasterFallbackSecretKey(account: AztecAddress): Promise { - return GrumpkinScalar.fromBuffer(await this.#getMasterKeyBuffer(account, 'fbsk_m')); - } - /** * Retrieves master incoming viewing secret key. * @throws If the account does not exist in the key store. @@ -211,19 +220,19 @@ export class KeyStore { } /** - * Retrieves all six master secret keys for an account. Paired with {@link addAccount}, this allows exporting an - * account's privacy keys, e.g. to re-register it on another PXE. + * Retrieves the four privacy secret keys the key store holds for an account. Paired with {@link addAccount}, this + * allows exporting an account's privacy secret keys, e.g. to re-register it on another PXE. The message-signing and + * fallback secret keys are not held by the key store and so are not returned. * * @throws If the account does not exist in the key store. */ - public async getAccountSecretKeys(account: AztecAddress): Promise { + public async getAccountSecretKeys(account: AztecAddress): Promise { + const [nhkM, ivskM, ovskM, tskM] = await this.#getMasterKeyBuffers(account, ['nhk_m', 'ivsk_m', 'ovsk_m', 'tsk_m']); return { - masterNullifierHidingKey: GrumpkinScalar.fromBuffer(await this.#getMasterKeyBuffer(account, 'nhk_m')), - masterIncomingViewingSecretKey: GrumpkinScalar.fromBuffer(await this.#getMasterKeyBuffer(account, 'ivsk_m')), - masterOutgoingViewingSecretKey: GrumpkinScalar.fromBuffer(await this.#getMasterKeyBuffer(account, 'ovsk_m')), - masterTaggingSecretKey: GrumpkinScalar.fromBuffer(await this.#getMasterKeyBuffer(account, 'tsk_m')), - masterMessageSigningSecretKey: GrumpkinScalar.fromBuffer(await this.#getMasterKeyBuffer(account, 'mssk_m')), - masterFallbackSecretKey: GrumpkinScalar.fromBuffer(await this.#getMasterKeyBuffer(account, 'fbsk_m')), + masterNullifierHidingSecretKey: GrumpkinScalar.fromBuffer(nhkM), + masterIncomingViewingSecretKey: GrumpkinScalar.fromBuffer(ivskM), + masterOutgoingViewingSecretKey: GrumpkinScalar.fromBuffer(ovskM), + masterTaggingSecretKey: GrumpkinScalar.fromBuffer(tskM), }; } @@ -322,63 +331,47 @@ export class KeyStore { throw new Error(`Could not find key prefix.`); } - /** Throws if any of the provided master secret keys is zero, which would derive the point at infinity. */ - #assertNonZeroSecretKeys(secretKeys: MasterSecretKeys): MasterSecretKeys { - for (const [name, secretKey] of Object.entries(secretKeys)) { - if (secretKey.isZero()) { - throw new Error(`Cannot register an account with a zero ${name}.`); - } - } - return secretKeys; - } - /** - * Persists a fully derived set of account keys and returns the resulting complete address. + * Persists a completed set of account keys and returns the resulting complete address. */ - async #storeDerivedKeys( - derivedKeys: Awaited>, + async #storeAccountKeys( + accountKeys: Awaited>, partialAddress: PartialAddress, ): Promise { const { - masterNullifierHidingKey, + masterNullifierHidingSecretKey, masterIncomingViewingSecretKey, masterOutgoingViewingSecretKey, masterTaggingSecretKey, - masterMessageSigningSecretKey, - masterFallbackSecretKey, - masterNullifierPublicKey, + masterNullifierHidingPublicKey, masterOutgoingViewingPublicKey, masterTaggingPublicKey, - masterMessageSigningPublicKey, - masterFallbackPublicKey, publicKeys, - } = derivedKeys; + } = accountKeys; const completeAddress = await CompleteAddress.fromPublicKeysAndPartialAddress(publicKeys, partialAddress); const { address: account } = completeAddress; - // The kernel cannot check that nhpk/ovpk/tpk are on-curve or non-infinity, so the PXE/key-store - // must guarantee it before persistence. By design, derivation from non-zero secret keys produces points - // that are on the curve and not at infinity. + // completeAccountKeys has already guaranteed these master public keys are non-infinity, which the kernel cannot + // check but the address relies on. // The npk/ovpk/tpk hashes are already in publicKeys; ivpk_m_hash is computed for indexing. const masterIncomingViewingPublicKeyHash = await hashPublicKey(publicKeys.ivpkM); await this.#db.transactionAsync(async () => { - // Naming of keys is as follows ${account}-${n/iv/ov/t/ms/fb}${sk/pk}_m + // Naming of keys is as follows ${account}-${n/iv/ov/t}${sk/pk}_m. + // + // The message-signing and fallback keys are not stored: their secret keys are withheld from the key store, and + // their public keys are only needed transiently to compute the address (they live in the AddressStore). await this.#keys.set(`${account.toString()}-ivsk_m`, masterIncomingViewingSecretKey.toBuffer()); await this.#keys.set(`${account.toString()}-ovsk_m`, masterOutgoingViewingSecretKey.toBuffer()); await this.#keys.set(`${account.toString()}-tsk_m`, masterTaggingSecretKey.toBuffer()); - await this.#keys.set(`${account.toString()}-nhk_m`, masterNullifierHidingKey.toBuffer()); - await this.#keys.set(`${account.toString()}-mssk_m`, masterMessageSigningSecretKey.toBuffer()); - await this.#keys.set(`${account.toString()}-fbsk_m`, masterFallbackSecretKey.toBuffer()); + await this.#keys.set(`${account.toString()}-nhk_m`, masterNullifierHidingSecretKey.toBuffer()); - await this.#keys.set(`${account.toString()}-npk_m`, masterNullifierPublicKey.toBuffer()); + await this.#keys.set(`${account.toString()}-npk_m`, masterNullifierHidingPublicKey.toBuffer()); await this.#keys.set(`${account.toString()}-ivpk_m`, publicKeys.ivpkM.toBuffer()); await this.#keys.set(`${account.toString()}-ovpk_m`, masterOutgoingViewingPublicKey.toBuffer()); await this.#keys.set(`${account.toString()}-tpk_m`, masterTaggingPublicKey.toBuffer()); - await this.#keys.set(`${account.toString()}-mspk_m`, masterMessageSigningPublicKey.toBuffer()); - await this.#keys.set(`${account.toString()}-fbpk_m`, masterFallbackPublicKey.toBuffer()); // We store pk_m_hash under `account-{n/iv/ov/t}pk_m_hash` key to be able to obtain address and key prefix // using the #getKeyPrefixAndAccount function later on @@ -396,12 +389,24 @@ export class KeyStore { * @throws If the account does not exist in the key store. */ async #getMasterKeyBuffer(account: AztecAddress, suffix: string): Promise { - const buffer = await this.#db.transactionAsync(() => this.#keys.getAsync(`${account.toString()}-${suffix}`)); - if (!buffer) { + const [buffer] = await this.#getMasterKeyBuffers(account, [suffix]); + return buffer; + } + + /** + * Fetches multiple stored master key buffers for an account in a single transaction, returning them in the order of + * the requested storage suffixes. + * @throws If any of the keys is missing (i.e. the account does not exist in the key store). + */ + async #getMasterKeyBuffers(account: AztecAddress, suffixes: string[]): Promise { + const buffers = await this.#db.transactionAsync(() => + Promise.all(suffixes.map(suffix => this.#keys.getAsync(`${account.toString()}-${suffix}`))), + ); + if (!buffers.every((buffer): buffer is Buffer => buffer !== undefined)) { throw new Error( `Account ${account.toString()} does not exist. Registered accounts: ${await this.getAccounts()}.`, ); } - return buffer; + return buffers; } } diff --git a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts index d471834578c5..f7bffe2afae6 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts @@ -24,7 +24,8 @@ import { TxHash } from '@aztec/stdlib/tx'; import { type MockProxy, mock } from 'jest-mock-extended'; import type { BlockSynchronizerConfig } from '../config/index.js'; -import type { ContractSyncService } from '../contract_sync/contract_sync_service.js'; +import type { ContractClassService } from '../contract/contract_class_service.js'; +import type { ContractSyncService } from '../contract/contract_sync_service.js'; import { AnchorBlockStore } from '../storage/anchor_block_store/anchor_block_store.js'; import { FactStore } from '../storage/fact_store/fact_store.js'; import { FactCollectionKey, FactCollectionTypeKey } from '../storage/fact_store/fact_store_keys.js'; @@ -50,6 +51,7 @@ describe('BlockSynchronizer', () => { let getBlock: NodeGetBlockMock; let blockStream: MockProxy; let contractSyncService: MockProxy; + let contractClassService: MockProxy; const TestSynchronizer = class extends BlockSynchronizer { protected override createBlockStream(): L2BlockStream { @@ -67,6 +69,7 @@ describe('BlockSynchronizer', () => { factStore, tipsStore, contractSyncService, + contractClassService, config, ); }; @@ -127,6 +130,7 @@ describe('BlockSynchronizer', () => { privateEventStore = new PrivateEventStore(store); factStore = new FactStore(store); contractSyncService = mock(); + contractClassService = mock(); synchronizer = createSynchronizer(); }); @@ -165,6 +169,15 @@ describe('BlockSynchronizer', () => { expect(obtainedHeader.equals(block.header)).toBe(true); }); + it('wipes the contract sync and contract class caches when the anchor block changes', async () => { + const block = await L2Block.random(BlockNumber(1)); + await serveBlockDataByHash(block); + await synchronizer.handleBlockStreamEvent(await proposedEvent(block)); + + expect(contractSyncService.wipe).toHaveBeenCalled(); + expect(contractClassService.wipe).toHaveBeenCalled(); + }); + it('updates anchor block on a reorg', async () => { const reorgBlock = await L2Block.random(BlockNumber(3)); await serveBlock(reorgBlock); @@ -796,6 +809,7 @@ describe('BlockSynchronizer', () => { factStore, tipsStore, contractSyncService, + contractClassService, { syncChainTip: 'proposed' }, ); }); diff --git a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts index 5675dee647ec..5b1e2a9b9285 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts @@ -8,7 +8,8 @@ import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import type { BlockHeader } from '@aztec/stdlib/tx'; import type { BlockSynchronizerConfig } from '../config/index.js'; -import type { ContractSyncService } from '../contract_sync/contract_sync_service.js'; +import type { ContractClassService } from '../contract/contract_class_service.js'; +import type { ContractSyncService } from '../contract/contract_sync_service.js'; import type { AnchorBlockStore } from '../storage/anchor_block_store/index.js'; import type { FactStore } from '../storage/fact_store/fact_store.js'; import type { NoteStore } from '../storage/note_store/index.js'; @@ -35,6 +36,7 @@ export class BlockSynchronizer implements L2BlockStreamEventHandler { private readonly factStore: FactStore, private readonly l2TipsStore: L2TipsKVStore, private readonly contractSyncService: ContractSyncService, + private readonly contractClassService: ContractClassService, private readonly config: Partial = {}, bindings?: LoggerBindings, ) { @@ -177,6 +179,12 @@ export class BlockSynchronizer implements L2BlockStreamEventHandler { // Therefore, we clear the contract synchronization cache here such that the sync is re-triggered upon new // execution. this.contractSyncService.wipe(); + + // The contract class service keeps a per-block cache - since updating our anchor means it is very unlikely we'd + // ever re-simulate at past anchors, we wipe its cache to prevent runaway memory growth on very long-lived PXE + // instances. + this.contractClassService.wipe(); + this.log.verbose(`Updated pxe last block to ${blockHeader.getBlockNumber()}`, blockHeader.toInspect()); await this.anchorBlockStore.setHeader(blockHeader); } diff --git a/yarn-project/pxe/src/contract/contract_class_service.test.ts b/yarn-project/pxe/src/contract/contract_class_service.test.ts new file mode 100644 index 000000000000..c38fa53890ce --- /dev/null +++ b/yarn-project/pxe/src/contract/contract_class_service.test.ts @@ -0,0 +1,120 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import { ProtocolContractAddress } from '@aztec/protocol-contracts'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import { BlockHash } from '@aztec/stdlib/block'; +import type { ContractInstanceWithAddress } from '@aztec/stdlib/contract'; +import type { AztecNode } from '@aztec/stdlib/interfaces/client'; +import type { BlockHeader } from '@aztec/stdlib/tx'; + +import { mock } from 'jest-mock-extended'; + +import type { ContractStore } from '../storage/contract_store/contract_store.js'; +import { ContractClassService } from './contract_class_service.js'; + +describe('ContractClassService', () => { + let node: ReturnType>; + let contractStore: ReturnType>; + let service: ContractClassService; + + const address = AztecAddress.fromBigIntUnsafe(0x1234n); + const originalClassId = new Fr(0xaaaan); + + const anchorWithHash = (hash: BlockHash): BlockHeader => { + const header = mock(); + header.hash.mockResolvedValue(hash); + return header; + }; + + beforeEach(() => { + node = mock(); + contractStore = mock(); + contractStore.getContractInstance.mockResolvedValue({ + address, + originalContractClassId: originalClassId, + } as ContractInstanceWithAddress); + service = new ContractClassService(node, contractStore); + }); + + it('resolves the node-reported current class at the anchor block', async () => { + const hash = new BlockHash(new Fr(1n)); + const currentClassId = new Fr(0xbbbbn); + node.getContract.mockResolvedValue({ currentContractClassId: currentClassId } as ContractInstanceWithAddress); + + expect(await service.getCurrentClassId(address, anchorWithHash(hash))).toEqual(currentClassId); + expect(node.getContract).toHaveBeenCalledWith(address, hash); + }); + + it('resolves the node-reported current class even when no local instance is registered', async () => { + const currentClassId = new Fr(0xbbbbn); + contractStore.getContractInstance.mockResolvedValue(undefined); + node.getContract.mockResolvedValue({ currentContractClassId: currentClassId } as ContractInstanceWithAddress); + + expect(await service.getCurrentClassId(address, anchorWithHash(new BlockHash(new Fr(1n))))).toEqual(currentClassId); + expect(contractStore.getContractInstance).not.toHaveBeenCalled(); + }); + + it('returns different classes for different anchors and does not bleed across them', async () => { + const hashA = new BlockHash(new Fr(1n)); + const hashB = new BlockHash(new Fr(2n)); + const classAtA = new Fr(0xa1n); + const classAtB = new Fr(0xb2n); + node.getContract.mockImplementation((_addr, refBlock) => + Promise.resolve({ + currentContractClassId: (refBlock as BlockHash).equals(hashA) ? classAtA : classAtB, + } as ContractInstanceWithAddress), + ); + + expect(await service.getCurrentClassId(address, anchorWithHash(hashA))).toEqual(classAtA); + expect(await service.getCurrentClassId(address, anchorWithHash(hashB))).toEqual(classAtB); + }); + + it('caches per (address, anchor) so the node is queried once per anchor', async () => { + const hash = new BlockHash(new Fr(1n)); + node.getContract.mockResolvedValue({ currentContractClassId: new Fr(7n) } as ContractInstanceWithAddress); + + await service.getCurrentClassId(address, anchorWithHash(hash)); + await service.getCurrentClassId(address, anchorWithHash(hash)); + + expect(node.getContract).toHaveBeenCalledTimes(1); + }); + + it('re-queries after wipe', async () => { + const hash = new BlockHash(new Fr(1n)); + node.getContract.mockResolvedValue({ currentContractClassId: new Fr(7n) } as ContractInstanceWithAddress); + + await service.getCurrentClassId(address, anchorWithHash(hash)); + service.wipe(); + await service.getCurrentClassId(address, anchorWithHash(hash)); + + expect(node.getContract).toHaveBeenCalledTimes(2); + }); + + it('falls back to the original class when the node does not know the instance', async () => { + node.getContract.mockResolvedValue(undefined); + + expect(await service.getCurrentClassId(address, anchorWithHash(new BlockHash(new Fr(1n))))).toEqual( + originalClassId, + ); + }); + + it('short-circuits protocol contracts to their original class without querying the node', async () => { + const protocolAddress = ProtocolContractAddress.ContractInstanceRegistry; + const protocolClassId = new Fr(0xccccn); + contractStore.getContractInstance.mockResolvedValue({ + address: protocolAddress, + originalContractClassId: protocolClassId, + } as ContractInstanceWithAddress); + + expect(await service.getCurrentClassId(protocolAddress, anchorWithHash(new BlockHash(new Fr(1n))))).toEqual( + protocolClassId, + ); + expect(node.getContract).not.toHaveBeenCalled(); + }); + + it('returns undefined when neither the node nor the store knows the instance', async () => { + contractStore.getContractInstance.mockResolvedValue(undefined); + node.getContract.mockResolvedValue(undefined); + + expect(await service.getCurrentClassId(address, anchorWithHash(new BlockHash(new Fr(1n))))).toBeUndefined(); + }); +}); diff --git a/yarn-project/pxe/src/contract/contract_class_service.ts b/yarn-project/pxe/src/contract/contract_class_service.ts new file mode 100644 index 000000000000..457f964c994e --- /dev/null +++ b/yarn-project/pxe/src/contract/contract_class_service.ts @@ -0,0 +1,83 @@ +import type { Fr } from '@aztec/foundation/curves/bn254'; +import { isProtocolContract } from '@aztec/protocol-contracts'; +import type { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { AztecNode } from '@aztec/stdlib/interfaces/client'; +import type { BlockHeader } from '@aztec/stdlib/tx'; + +import type { ContractStore } from '../storage/contract_store/contract_store.js'; + +/** + * Resolves the contract class id that an address runs at a given anchor block, as tracked by the chain. + * + * PXE does not store a contract's current class id: it is mutable, chain-derived state that changes when a contract is + * upgraded and can be undone by a reorg. Instead this service asks the node for the current class at an anchor block, + * falling back to a local instance if no upgrades were scheduled. + */ +export class ContractClassService { + /** + * Class ids are cached per `(address, anchorHash)`. This avoid unnecessary network roundtrips in scenarios where + * multiple executions are done on the same anchor block (e.g. simulation followed by witgen), or when the same + * contract is invoked multiple times in an execution (e.g. authwit checks). + * It also means the callers don't need to worry about caching this service's return values, simplifying callsites. + */ + #cache: Map> = new Map(); + + constructor( + private node: AztecNode, + private contractStore: ContractStore, + ) {} + + /** + * Returns the class id that corresponds to `address` as of `anchorBlockHeader`, or `undefined` if no instance is + * registered for `address`. A missing instance is an absence the caller decides how to handle, not an error; genuine + * failures (e.g. the node being unreachable) still throw. + */ + async getCurrentClassId(address: AztecAddress, anchorBlockHeader: BlockHeader): Promise { + // Protocol contracts are not in the registry and cannot be upgraded, so their original class is always current. + // Short-circuiting avoids a node round-trip. + if (isProtocolContract(address)) { + const instance = await this.contractStore.getContractInstance(address); + return instance?.originalContractClassId; + } + + const key = `${address.toString()}:${(await anchorBlockHeader.hash()).toString()}`; + let promise = this.#cache.get(key); + if (!promise) { + promise = (async () => { + // The node resolves the current class from the same scheduled value change the AVM enforces against the public + // data tree. If the contract was upgraded the node returns a non-undefined instance; an undefined result means + // no upgrade happened (or the node has no record of it, e.g. it was never publicly deployed), so the original + // class is current. + + const nodeInstance = await this.node.getContract(address, await anchorBlockHeader.hash()); + + if (nodeInstance) { + return nodeInstance.currentContractClassId; + } else { + return (await this.contractStore.getContractInstance(address))?.originalContractClassId; + } + })().catch(err => { + this.#cache.delete(key); + throw err; + }); + this.#cache.set(key, promise); + } + + const classId = await promise; + if (classId === undefined) { + // Don't memoize a missing instance: it may be registered later in this PXE (e.g. via contract sync + // mid-execution), and a cached miss would then hide it. Only successful resolutions stay cached. + this.#cache.delete(key); + } + return classId; + } + + /** + * Clears the cache. + * + * This is not required for correctness, only to limit how much memory the cache uses. The cache is resilient against + * reorgs etc. as it is based on block hashes, not block numbers. */ + wipe(): void { + this.#cache.clear(); + } +} diff --git a/yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts b/yarn-project/pxe/src/contract/contract_sync_service.test.ts similarity index 82% rename from yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts rename to yarn-project/pxe/src/contract/contract_sync_service.test.ts index 97f4f4177f6e..f574ea9b2c0e 100644 --- a/yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts +++ b/yarn-project/pxe/src/contract/contract_sync_service.test.ts @@ -3,7 +3,6 @@ import { createLogger } from '@aztec/foundation/log'; import { executeTimeout } from '@aztec/foundation/timer'; import { FunctionCall, FunctionSelector, FunctionType } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import type { ContractInstanceWithAddress } from '@aztec/stdlib/contract'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import { makeBlockHeader } from '@aztec/stdlib/testing'; @@ -12,11 +11,13 @@ import { mock } from 'jest-mock-extended'; import type { ContractStore } from '../storage/contract_store/contract_store.js'; import type { NoteStore } from '../storage/note_store/note_store.js'; +import type { ContractClassService } from './contract_class_service.js'; import { ContractSyncService, MAX_CONCURRENT_SCOPE_SYNCS } from './contract_sync_service.js'; describe('ContractSyncService', () => { let aztecNode: ReturnType>; let contractStore: ReturnType>; + let contractClassService: ReturnType>; let noteStore: ReturnType>; let service: ContractSyncService; let utilityExecutor: jest.Mock<(call: FunctionCall, scopes: AztecAddress[]) => Promise>; @@ -48,22 +49,22 @@ describe('ContractSyncService', () => { }), ), ); - contractStore.getContractInstance.mockResolvedValue({ - currentContractClassId: classId, - originalContractClassId: classId, - address: contractAddress, - } as ContractInstanceWithAddress); + contractClassService = mock(); + contractClassService.getCurrentClassId.mockResolvedValue(classId); aztecNode = mock(); - // verifyCurrentClassId reads the instance from the node at the anchor block; returning undefined causes - // readCurrentClassId to fall back to the local originalContractClassId, which matches so verification passes. - aztecNode.getContract.mockResolvedValue(undefined); noteStore = mock(); // syncNoteNullifiers returns early when no notes noteStore.getNotes.mockResolvedValue([]); - service = new ContractSyncService(aztecNode, contractStore, noteStore, createLogger('test:contract-sync')); + service = new ContractSyncService( + aztecNode, + contractStore, + contractClassService, + noteStore, + createLogger('test:contract-sync'), + ); }); describe('ensureContractSynced', () => { @@ -251,53 +252,6 @@ describe('ContractSyncService', () => { }); }); - describe('class ID verification deduplication', () => { - const contract2 = AztecAddress.fromBigIntUnsafe(300n); - - it('verifies class ID only once per contract across scope batches', async () => { - await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); - await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeB]); - expectVerifiedContracts(contractAddress); - }); - - it('verifies class ID separately for different contracts', async () => { - await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); - await service.ensureContractSynced(contract2, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); - expectVerifiedContracts(contractAddress, contract2); - }); - - it('re-verifies class ID after wipe', async () => { - await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); - service.wipe(); - await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeB]); - expectVerifiedContracts(contractAddress, contractAddress); - }); - - it('re-verifies class ID after discardStaged', async () => { - await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); - await service.discardStaged(jobId); - await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); - expectVerifiedContracts(contractAddress, contractAddress); - }); - - it('re-verifies class ID after verification failure', async () => { - contractStore.getContractInstance.mockRejectedValueOnce(new Error('node unavailable')); - await expect( - service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]), - ).rejects.toThrow('node unavailable'); - - await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); - expectVerifiedContracts(contractAddress, contractAddress); - }); - - it('does not re-verify class ID when only scope cache is invalidated', async () => { - await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); - service.invalidateContractForScopes(contractAddress, [scopeA]); - await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); - expectVerifiedContracts(contractAddress); - }); - }); - describe('multi-scope sync batching', () => { it('batches nullifier sync across all unsynced scopes', async () => { await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [ @@ -465,14 +419,6 @@ describe('ContractSyncService', () => { } }; - /** Asserts that class ID verification was triggered for each contract address in the given sequence. */ - const expectVerifiedContracts = (...addresses: AztecAddress[]) => { - expect(contractStore.getContractInstance).toHaveBeenCalledTimes(addresses.length); - for (let i = 0; i < addresses.length; i++) { - expect(contractStore.getContractInstance).toHaveBeenNthCalledWith(i + 1, addresses[i]); - } - }; - const expectNoSync = () => expect(utilityExecutor).not.toHaveBeenCalled(); /** Yields to the macrotask queue, draining all pending microtasks (semaphore acquires/releases) in between. */ diff --git a/yarn-project/pxe/src/contract_sync/contract_sync_service.ts b/yarn-project/pxe/src/contract/contract_sync_service.ts similarity index 76% rename from yarn-project/pxe/src/contract_sync/contract_sync_service.ts rename to yarn-project/pxe/src/contract/contract_sync_service.ts index 6e669bb06b09..2d65336ddac9 100644 --- a/yarn-project/pxe/src/contract_sync/contract_sync_service.ts +++ b/yarn-project/pxe/src/contract/contract_sync_service.ts @@ -10,7 +10,8 @@ import type { StagedStore } from '../job_coordinator/job_coordinator.js'; import { NoteService } from '../notes/note_service.js'; import type { ContractStore } from '../storage/contract_store/contract_store.js'; import type { NoteStore } from '../storage/note_store/note_store.js'; -import { syncScope, verifyCurrentClassId } from './helpers.js'; +import type { ContractClassService } from './contract_class_service.js'; +import { syncScope } from './helpers.js'; /** * Maximum number of scope syncs running concurrently within a single sync call. Sized to trade off parallelism @@ -19,8 +20,8 @@ import { syncScope, verifyCurrentClassId } from './helpers.js'; export const MAX_CONCURRENT_SCOPE_SYNCS = 5; /** - * Service for syncing the private state of contracts and verifying that the PXE holds the current class artifact. - * It uses a cache to avoid redundant sync operations - the cache is wiped when the anchor block changes. + * Service for syncing the private state of contracts. It uses a cache to avoid redundant sync operations - the cache + * is wiped when the anchor block changes. * * TODO: The StagedStore naming is broken here. Figure out a better name. */ @@ -32,19 +33,16 @@ export class ContractSyncService implements StagedStore { // The value is a promise that resolves when the contract is synced. private syncedContracts: Map> = new Map(); - // Tracks class ID verification per contract. Keyed by contract address only (no scope), since - // class ID verification is scope-independent. Cleared on wipe/discard. - private classIdVerificationCache: Map> = new Map(); - constructor( private aztecNode: AztecNode, private contractStore: ContractStore, + private contractClassService: ContractClassService, private noteStore: NoteStore, private log: Logger, ) {} /** - * Ensures a contract's private state is synchronized and that the PXE holds the current class artifact. + * Ensures a contract's private state is synchronized. * Uses a cache to avoid redundant sync operations - the cache is wiped when the anchor block changes. * @param contractAddress - The address of the contract to sync. * @param functionToInvokeAfterSync - The function selector that will be called after sync (used to validate it's @@ -61,7 +59,15 @@ export class ContractSyncService implements StagedStore { scopes: AztecAddress[], ): Promise { this.#startSyncIfNeeded(contractAddress, scopes, anchorBlockHeader, jobId, scope => - syncScope(contractAddress, this.contractStore, functionToInvokeAfterSync, utilityExecutor, scope), + syncScope( + contractAddress, + this.contractStore, + this.contractClassService, + anchorBlockHeader, + functionToInvokeAfterSync, + utilityExecutor, + scope, + ), ); await this.#awaitSync(contractAddress, scopes); @@ -79,7 +85,6 @@ export class ContractSyncService implements StagedStore { wipe(): void { this.log.debug(`Wiping contract sync cache (${this.syncedContracts.size} entries)`); this.syncedContracts.clear(); - this.classIdVerificationCache.clear(); } commit(_jobId: string): Promise { @@ -90,15 +95,13 @@ export class ContractSyncService implements StagedStore { // We clear the synced contracts cache here because, when the job is discarded, any associated database writes from // the sync are also undone. this.syncedContracts.clear(); - this.classIdVerificationCache.clear(); return Promise.resolve(); } /** * For each unsynced scope, creates a promise that waits on: - * 1. Class ID verification (cached per contract, scope-independent). - * 2. Note nullifier sync (shared, batched across all unsynced scopes). - * 3. Per-scope sync (individual, semaphore-bounded). + * 1. Note nullifier sync (shared, batched across all unsynced scopes). + * 2. Per-scope sync (individual, semaphore-bounded). */ #startSyncIfNeeded( contractAddress: AztecAddress, @@ -114,7 +117,6 @@ export class ContractSyncService implements StagedStore { this.log.debug(`Syncing contract ${contractAddress} for ${scopesToSync.length} scope(s)`); - const verifyPromise = this.#verifyClassId(contractAddress, anchorBlockHeader); const syncNullifiersPromise = this.#syncNoteNullifiers(contractAddress, anchorBlockHeader, jobId, scopesToSync); // We build a new semaphore for each sync call, so it rate-limits the scopes within that single call. We do @@ -123,11 +125,7 @@ export class ContractSyncService implements StagedStore { for (const scope of scopesToSync) { const key = toKey(contractAddress, scope); - const promise = Promise.all([ - verifyPromise, - syncNullifiersPromise, - runBounded(syncSlot, () => syncScopeFn(scope)), - ]) + const promise = Promise.all([syncNullifiersPromise, runBounded(syncSlot, () => syncScopeFn(scope))]) .then(() => {}) .catch(err => { this.syncedContracts.delete(key); @@ -137,23 +135,6 @@ export class ContractSyncService implements StagedStore { } } - /** Verifies the local class ID matches the on-chain value (cached, evicts on failure so retries re-verify). */ - #verifyClassId(contractAddress: AztecAddress, anchorBlockHeader: BlockHeader): Promise { - const contractKey = contractAddress.toString(); - const cached = this.classIdVerificationCache.get(contractKey); - if (cached) { - return cached; - } - const promise = verifyCurrentClassId(contractAddress, this.aztecNode, this.contractStore, anchorBlockHeader).catch( - err => { - this.classIdVerificationCache.delete(contractKey); - throw err; - }, - ); - this.classIdVerificationCache.set(contractKey, promise); - return promise; - } - /** Syncs note nullifiers across all unsynced scopes in a single batched call. */ async #syncNoteNullifiers( contractAddress: AztecAddress, diff --git a/yarn-project/pxe/src/contract/helpers.ts b/yarn-project/pxe/src/contract/helpers.ts new file mode 100644 index 000000000000..c6f7871f3d40 --- /dev/null +++ b/yarn-project/pxe/src/contract/helpers.ts @@ -0,0 +1,35 @@ +import { isProtocolContract } from '@aztec/protocol-contracts'; +import type { FunctionCall, FunctionSelector } from '@aztec/stdlib/abi'; +import type { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { BlockHeader } from '@aztec/stdlib/tx'; + +import type { ContractStore } from '../storage/contract_store/contract_store.js'; +import type { ContractClassService } from './contract_class_service.js'; + +export async function syncScope( + contractAddress: AztecAddress, + contractStore: ContractStore, + contractClassService: ContractClassService, + anchorBlockHeader: BlockHeader, + functionToInvokeAfterSync: FunctionSelector | null, + utilityExecutor: (privateSyncCall: FunctionCall, scopes: AztecAddress[]) => Promise, + scope: AztecAddress, +) { + // Protocol contracts don't have private state to sync + if (isProtocolContract(contractAddress)) { + return; + } + + const classId = await contractClassService.getCurrentClassId(contractAddress, anchorBlockHeader); + if (!classId) { + throw new Error(`Cannot sync contract ${contractAddress}: its instance is not registered nor published.`); + } + const syncStateFunctionCall = await contractStore.getFunctionCall('sync_state', [scope], contractAddress, classId); + if (functionToInvokeAfterSync && functionToInvokeAfterSync.equals(syncStateFunctionCall.selector)) { + throw new Error( + 'Forbidden `sync_state` invocation. `sync_state` can only be invoked by PXE, manual execution can lead to inconsistencies.', + ); + } + + await utilityExecutor(syncStateFunctionCall, [scope]); +} diff --git a/yarn-project/pxe/src/contract_function_simulator/anchored_contract_data.ts b/yarn-project/pxe/src/contract_function_simulator/anchored_contract_data.ts new file mode 100644 index 000000000000..f3beeb5f6d4d --- /dev/null +++ b/yarn-project/pxe/src/contract_function_simulator/anchored_contract_data.ts @@ -0,0 +1,74 @@ +import type { Fr } from '@aztec/foundation/curves/bn254'; +import type { FunctionArtifactWithContractName, FunctionSelector } from '@aztec/stdlib/abi'; +import type { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { ContractInstancePreimageWithAddress } from '@aztec/stdlib/contract'; +import type { BlockHeader, ContractOverrides } from '@aztec/stdlib/tx'; + +import type { ContractClassService } from '../contract/contract_class_service.js'; +import type { ContractStore } from '../storage/contract_store/contract_store.js'; + +/** + * Per-run view of contract data for a single simulation, bound to its anchor block. + * + * The {@link ContractStore} is pure class-id-keyed storage and has no notion of which class an address runs. This + * class bridges that gap: it resolves an address to its current class id (via the {@link ContractClassService} + * at the run's anchor block) and then serves artifacts from the store. It is also the single place contract + * overrides are applied — when an address is overridden, both its instance and its class id come from the override + * rather than the chain, so a simulation can execute different bytecode at that address. Overrides are only set for + * `simulateTx` (which skips the kernels), so the override path never reaches proving. + */ +export class AnchoredContractData { + constructor( + private readonly store: ContractStore, + private readonly contractClassService: ContractClassService, + private readonly anchorBlockHeader: BlockHeader, + private readonly overrides?: ContractOverrides, + ) {} + + /** Returns the address preimage of the instance at `address`, from the override if any, else from storage. */ + getContractInstance(address: AztecAddress): Promise { + const override = this.overrides?.[address.toString()]; + if (override) { + return Promise.resolve(override.instance); + } + return this.store.getContractInstance(address); + } + + /** + * Resolves the class id `address` runs in this simulation: the override's class if overridden, else resolved against + * the chain at the anchor block. + */ + getCurrentClassId(address: AztecAddress): Promise { + const override = this.overrides?.[address.toString()]; + if (override) { + return Promise.resolve(override.instance.currentContractClassId); + } + return this.contractClassService.getCurrentClassId(address, this.anchorBlockHeader); + } + + async getFunctionArtifact( + address: AztecAddress, + selector: FunctionSelector, + ): Promise { + const classId = await this.getCurrentClassId(address); + return classId ? this.store.getFunctionArtifact(classId, selector) : undefined; + } + + async getFunctionArtifactWithDebugMetadata( + address: AztecAddress, + selector: FunctionSelector, + ): Promise { + const classId = await this.getCurrentClassId(address); + return classId ? this.store.getFunctionArtifactWithDebugMetadata(classId, selector) : undefined; + } + + async getDebugContractName(address: AztecAddress): Promise { + const classId = await this.getCurrentClassId(address); + return classId ? this.store.getDebugContractName(classId) : undefined; + } + + async getDebugFunctionName(address: AztecAddress, selector: FunctionSelector): Promise { + const classId = await this.getCurrentClassId(address); + return classId ? this.store.getDebugFunctionName(classId, selector) : `${address}:${selector}`; + } +} diff --git a/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts b/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts index 9d4f3a1367a1..b7394b9ee147 100644 --- a/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts +++ b/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts @@ -80,6 +80,7 @@ import { MerkleTreeId } from '@aztec/stdlib/trees'; import { BlockHeader, CallContext, + type ContractOverrides, HashedValues, type OffchainEffect, PrivateExecutionResult, @@ -90,7 +91,8 @@ import { getFinalMinRevertibleSideEffectCounter, } from '@aztec/stdlib/tx'; -import type { ContractSyncService } from '../contract_sync/contract_sync_service.js'; +import type { ContractClassService } from '../contract/contract_class_service.js'; +import type { ContractSyncService } from '../contract/contract_sync_service.js'; import type { ExecutionHooks } from '../hooks/index.js'; import type { TxResolverService } from '../messages/tx_resolver_service.js'; import type { AddressStore } from '../storage/address_store/address_store.js'; @@ -104,6 +106,7 @@ import type { PrivateEventStore } from '../storage/private_event_store/private_e import type { RecipientTaggingStore } from '../storage/tagging_store/recipient_tagging_store.js'; import type { SenderTaggingStore } from '../storage/tagging_store/sender_tagging_store.js'; import type { TaggingSecretSourcesStore } from '../storage/tagging_store/tagging_secret_sources_store.js'; +import { AnchoredContractData } from './anchored_contract_data.js'; import type { BenchmarkedNode } from './benchmarked_node.js'; import { ExecutionNoteCache } from './execution_note_cache.js'; import { ExecutionTaggingIndexCache } from './execution_tagging_index_cache.js'; @@ -131,6 +134,9 @@ export type ContractSimulatorRunOpts = { /** Args for ContractFunctionSimulator constructor. */ export type ContractFunctionSimulatorArgs = { contractStore: ContractStore; + contractClassService: ContractClassService; + /** Per-simulation contract overrides. */ + overrides?: ContractOverrides; noteStore: NoteStore; keyStore: KeyStore; addressStore: AddressStore; @@ -154,6 +160,8 @@ export type ContractFunctionSimulatorArgs = { export class ContractFunctionSimulator { private readonly log: Logger; private readonly contractStore: ContractStore; + private readonly contractClassService: ContractClassService; + private readonly overrides: ContractOverrides | undefined; private readonly noteStore: NoteStore; private readonly keyStore: KeyStore; private readonly addressStore: AddressStore; @@ -172,6 +180,8 @@ export class ContractFunctionSimulator { constructor(args: ContractFunctionSimulatorArgs) { this.contractStore = args.contractStore; + this.contractClassService = args.contractClassService; + this.overrides = args.overrides; this.noteStore = args.noteStore; this.keyStore = args.keyStore; this.addressStore = args.addressStore; @@ -208,10 +218,23 @@ export class ContractFunctionSimulator { const contractAddress = request.origin; - const entryPointArtifact = await this.contractStore.getFunctionArtifactWithDebugMetadata( + const anchoredContractData = new AnchoredContractData( + this.contractStore, + this.contractClassService, + anchorBlockHeader, + this.overrides, + ); + + const entryPointArtifact = await anchoredContractData.getFunctionArtifactWithDebugMetadata( contractAddress, request.functionSelector, ); + if (!entryPointArtifact) { + throw new Error( + `Cannot run function ${request.functionSelector} on ${contractAddress}: the contract is not registered. ` + + `Register it via wallet.registerContract(...).`, + ); + } if (entryPointArtifact.functionType !== FunctionType.PRIVATE) { throw new Error(`Cannot run ${entryPointArtifact.functionType} function as private`); @@ -249,7 +272,7 @@ export class ContractFunctionSimulator { executionCache: HashedValuesCache.create(request.argsOfCalls), noteCache, taggingIndexCache, - contractStore: this.contractStore, + anchoredContractData, noteStore: this.noteStore, keyStore: this.keyStore, addressStore: this.addressStore, @@ -333,7 +356,20 @@ export class ContractFunctionSimulator { scopes: AztecAddress[], jobId: string, ): Promise<{ result: Fr[]; offchainEffects: OffchainEffect[] }> { - const entryPointArtifact = await this.contractStore.getFunctionArtifactWithDebugMetadata(call.to, call.selector); + const anchoredContractData = new AnchoredContractData( + this.contractStore, + this.contractClassService, + anchorBlockHeader, + this.overrides, + ); + + const entryPointArtifact = await anchoredContractData.getFunctionArtifactWithDebugMetadata(call.to, call.selector); + if (!entryPointArtifact) { + throw new Error( + `Cannot run function ${call.selector} on ${call.to}: the contract is not registered. ` + + `Register it via wallet.registerContract(...).`, + ); + } if (entryPointArtifact.functionType !== FunctionType.UTILITY) { throw new Error(`Cannot run ${entryPointArtifact.functionType} function as utility`); @@ -353,7 +389,7 @@ export class ContractFunctionSimulator { authWitnesses: authwits, capsules: [], anchorBlockHeader, - contractStore: this.contractStore, + anchoredContractData, noteStore: this.noteStore, keyStore: this.keyStore, addressStore: this.addressStore, diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts index 70518a08e4f7..4074b81d786e 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts @@ -24,7 +24,7 @@ import { FunctionSelector, NoteSelector } from '@aztec/stdlib/abi'; import { PublicDataWrite, RevertCode } from '@aztec/stdlib/avm'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { BlockHash } from '@aztec/stdlib/block'; -import type { ContractInstance, PartialAddress } from '@aztec/stdlib/contract'; +import type { ContractInstancePreimage, PartialAddress } from '@aztec/stdlib/contract'; import type { GasFees } from '@aztec/stdlib/gas'; import { KeyValidationRequest } from '@aztec/stdlib/kernel'; import type { PublicKeys } from '@aztec/stdlib/keys'; @@ -86,7 +86,7 @@ export type { AppTaggingSecretKind, BlockHash, BlockHeader, - ContractInstance, + ContractInstancePreimage, MembershipWitness, MessageContext, NullifierMembershipWitness, @@ -354,7 +354,7 @@ const PUBLIC_KEYS: TypeMapping = STRUCT([ { name: 'fbpkMHash', type: FIELD }, ]); -export const CONTRACT_INSTANCE: TypeMapping = STRUCT([ +export const CONTRACT_INSTANCE: TypeMapping = STRUCT([ { name: 'salt', type: FIELD }, { name: 'deployer', type: AZTEC_ADDRESS }, // Note that the nr side of this struct does not contain the current class, only original diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts index ecc96f28037d..54e0fa1e6a74 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts @@ -14,7 +14,8 @@ import { BlockHeader, CallContext, HashedValues, TxContext, TxExecutionRequest } import { jest } from '@jest/globals'; import { mock } from 'jest-mock-extended'; -import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; +import type { ContractClassService } from '../../contract/contract_class_service.js'; +import type { ContractSyncService } from '../../contract/contract_sync_service.js'; import type { TxResolverService } from '../../messages/tx_resolver_service.js'; import { ORACLE_VERSION_MAJOR, ORACLE_VERSION_MINOR } from '../../oracle_version.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; @@ -28,6 +29,7 @@ import type { PrivateEventStore } from '../../storage/private_event_store/privat import type { RecipientTaggingStore } from '../../storage/tagging_store/recipient_tagging_store.js'; import type { SenderTaggingStore } from '../../storage/tagging_store/sender_tagging_store.js'; import type { TaggingSecretSourcesStore } from '../../storage/tagging_store/tagging_secret_sources_store.js'; +import { AnchoredContractData } from '../anchored_contract_data.js'; import { ContractFunctionSimulator } from '../contract_function_simulator.js'; import { TransientArrayService } from '../transient_array_service.js'; import { buildACIRCallback } from './acir_callback.js'; @@ -37,6 +39,7 @@ describe('Oracle Version Check test suite', () => { const simulator = new WASMSimulator(); let contractStore: ReturnType>; + let contractClassService: ReturnType>; let noteStore: ReturnType>; let keyStore: ReturnType>; let addressStore: ReturnType>; @@ -94,16 +97,20 @@ describe('Oracle Version Check test suite', () => { originalContractClassId: new Fr(42), address: contractAddress, } as ContractInstanceWithAddress); - contractStore.getFunctionArtifactWithDebugMetadata.mockImplementation(async (address, selector) => { - const artifact = await contractStore.getFunctionArtifact(address, selector); + contractStore.getFunctionArtifactWithDebugMetadata.mockImplementation(async (classId, selector) => { + const artifact = await contractStore.getFunctionArtifact(classId, selector); if (!artifact) { - throw new Error(`Function not found: ${selector.toString()} in contract ${address}`); + throw new Error(`Function not found: ${selector.toString()} in contract class ${classId}`); } return { ...artifact, debug: undefined }; }); + contractClassService = mock(); + contractClassService.getCurrentClassId.mockResolvedValue(new Fr(42)); + acirSimulator = new ContractFunctionSimulator({ contractStore, + contractClassService, noteStore, keyStore, addressStore, @@ -207,7 +214,7 @@ describe('Oracle Version Check test suite', () => { authWitnesses: [], capsules: [], anchorBlockHeader, - contractStore, + anchoredContractData: new AnchoredContractData(contractStore, contractClassService, anchorBlockHeader), noteStore, keyStore, addressStore, diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts index a17064072dd1..d414c71f1d2b 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts @@ -52,8 +52,9 @@ import { jest } from '@jest/globals'; import { Matcher, type MatcherCreator, type MockProxy, mock } from 'jest-mock-extended'; import { toFunctionSelector } from 'viem'; -import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; -import { syncScope } from '../../contract_sync/helpers.js'; +import type { ContractClassService } from '../../contract/contract_class_service.js'; +import type { ContractSyncService } from '../../contract/contract_sync_service.js'; +import { syncScope } from '../../contract/helpers.js'; import type { TxResolverService } from '../../messages/tx_resolver_service.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; import type { CapsuleStore } from '../../storage/capsule_store/capsule_store.js'; @@ -103,6 +104,7 @@ describe('Private Execution test suite', () => { const simulator = new WASMSimulator(); let contractStore: MockProxy; + let contractClassService: MockProxy; let noteStore: MockProxy; let addressStore: MockProxy; let keyStore: MockProxy; @@ -248,14 +250,15 @@ describe('Private Execution test suite', () => { const ownerPartialAddress = Fr.random(); ownerCompleteAddress = await CompleteAddress.fromSecretKeyAndPartialAddress(ownerSk, ownerPartialAddress); - ({ masterNullifierHidingKey: ownerNhkM, masterIncomingViewingSecretKey: ownerIvskM } = await deriveKeys(ownerSk)); + ({ masterNullifierHidingSecretKey: ownerNhkM, masterIncomingViewingSecretKey: ownerIvskM } = + await deriveKeys(ownerSk)); const recipientPartialAddress = Fr.random(); recipientCompleteAddress = await CompleteAddress.fromSecretKeyAndPartialAddress( recipientSk, recipientPartialAddress, ); - ({ masterNullifierHidingKey: recipientNhkM, masterIncomingViewingSecretKey: recipientIvskM } = + ({ masterNullifierHidingSecretKey: recipientNhkM, masterIncomingViewingSecretKey: recipientIvskM } = await deriveKeys(recipientSk)); const senderForTagsPartialAddress = Fr.random(); @@ -263,7 +266,7 @@ describe('Private Execution test suite', () => { senderForTagsSk, senderForTagsPartialAddress, ); - ({ masterNullifierHidingKey: senderForTagsNhkM, masterIncomingViewingSecretKey: senderForTagsIvskM } = + ({ masterNullifierHidingSecretKey: senderForTagsNhkM, masterIncomingViewingSecretKey: senderForTagsIvskM } = await deriveKeys(senderForTagsSk)); owner = ownerCompleteAddress.address; @@ -280,6 +283,12 @@ describe('Private Execution test suite', () => { ws = await NativeWorldStateService.tmp(); fork = await ws.fork(); contractStore = mock(); + contractClassService = mock(); + // No upgrades in these tests: an address resolves to a class id whose string matches the address, so the + // address-keyed `contracts` map and store mocks below keep working when keyed by the resolved class id. + contractClassService.getCurrentClassId.mockImplementation(address => + Promise.resolve(Fr.fromHexString(address.toString())), + ); noteStore = mock(); noteStore.getNotes.mockResolvedValue([]); addressStore = mock(); @@ -300,7 +309,15 @@ describe('Private Execution test suite', () => { contractSyncService.ensureContractSynced.mockImplementation( async (contractAddress, functionToInvokeAfterSync, utilityExecutor, _anchorBlockHeader, _jobId, scopes) => { for (const scope of scopes) { - await syncScope(contractAddress, contractStore, functionToInvokeAfterSync, utilityExecutor, scope); + await syncScope( + contractAddress, + contractStore, + contractClassService, + _anchorBlockHeader, + functionToInvokeAfterSync, + utilityExecutor, + scope, + ); } }, ); @@ -458,6 +475,7 @@ describe('Private Execution test suite', () => { acirSimulator = new ContractFunctionSimulator({ contractStore, + contractClassService, noteStore, keyStore, addressStore, @@ -710,7 +728,7 @@ describe('Private Execution test suite', () => { expect( contractStore.getFunctionArtifact.mock.calls.some( - ([addr, sel]) => addr.equals(childAddress) && sel.equals(childSelector), + ([classId, sel]) => classId.toString() === childAddress.toString() && sel.equals(childSelector), ), ).toBe(true); expect(result.nestedExecutionResults).toHaveLength(1); @@ -737,7 +755,12 @@ describe('Private Execution test suite', () => { contractAddress: parentAddress, }); - expect(contractStore.getFunctionCall).toHaveBeenCalledWith('sync_state', [owner], childAddress); + expect(contractStore.getFunctionCall).toHaveBeenCalledWith( + 'sync_state', + [owner], + childAddress, + expect.anything(), + ); }); }); diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts index 5f325f341314..8e70f85d4175 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts @@ -7,7 +7,6 @@ import { FunctionSelector } from '@aztec/stdlib/abi'; import type { AuthWitness } from '@aztec/stdlib/auth-witness'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { L2TipsProvider } from '@aztec/stdlib/block'; -import { SerializableContractInstance } from '@aztec/stdlib/contract'; import { Gas, GasFees, GasSettings } from '@aztec/stdlib/gas'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import { AppTaggingSecret, AppTaggingSecretKind } from '@aztec/stdlib/logs'; @@ -16,7 +15,7 @@ import { type BlockHeader, CallContext, type Capsule, TxContext } from '@aztec/s import { jest } from '@jest/globals'; import { mock } from 'jest-mock-extended'; -import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; +import type { ContractSyncService } from '../../contract/contract_sync_service.js'; import type { ResolveCustomRequest } from '../../hooks/resolve_custom_request.js'; import type { ResolveTaggingSecretStrategy, @@ -26,7 +25,6 @@ import type { TxResolverService } from '../../messages/tx_resolver_service.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; import { CapsuleService } from '../../storage/capsule_store/capsule_service.js'; import type { CapsuleStore } from '../../storage/capsule_store/capsule_store.js'; -import type { ContractStore } from '../../storage/contract_store/contract_store.js'; import { FactService } from '../../storage/fact_store/index.js'; import type { FactStore } from '../../storage/fact_store/index.js'; import type { NoteStore } from '../../storage/note_store/note_store.js'; @@ -34,6 +32,7 @@ import type { PrivateEventStore } from '../../storage/private_event_store/privat import type { RecipientTaggingStore } from '../../storage/tagging_store/recipient_tagging_store.js'; import type { SenderTaggingStore } from '../../storage/tagging_store/sender_tagging_store.js'; import type { TaggingSecretSourcesStore } from '../../storage/tagging_store/tagging_secret_sources_store.js'; +import type { AnchoredContractData } from '../anchored_contract_data.js'; import { ExecutionNoteCache } from '../execution_note_cache.js'; import { ExecutionTaggingIndexCache } from '../execution_tagging_index_cache.js'; import { HashedValuesCache } from '../hashed_values_cache.js'; @@ -119,7 +118,7 @@ describe('PrivateExecutionOracle', () => { }); it('resolves a non-interactive-handshake strategy', async () => { - const { oracle } = await makeHookedOracle({ strategy: { type: 'non-interactive-handshake' } }); + const { oracle } = makeHookedOracle({ strategy: { type: 'non-interactive-handshake' } }); await expect(oracle.resolveTaggingStrategy(sender, recipient, AppTaggingSecretKind.CONSTRAINED)).resolves.toEqual( { @@ -129,7 +128,7 @@ describe('PrivateExecutionOracle', () => { }); it('resolves an address-derived strategy to the unconstrained secret', async () => { - const { oracle } = await makeHookedOracle({ strategy: { type: 'address-derived' } }); + const { oracle } = makeHookedOracle({ strategy: { type: 'address-derived' } }); const secret = Fr.random(); jest.spyOn(oracle, 'getAppTaggingSecret').mockResolvedValue(Option.some(secret)); @@ -140,7 +139,7 @@ describe('PrivateExecutionOracle', () => { it('app-silos a raw arbitrary-secret point before handing it to the contract', async () => { const point = await Point.random(); - const { oracle } = await makeHookedOracle({ strategy: { type: 'arbitrary-secret', secret: point } }); + const { oracle } = makeHookedOracle({ strategy: { type: 'arbitrary-secret', secret: point } }); const expected = await AppTaggingSecret.computeDirectional(point, contractAddress, recipient); await expect( @@ -149,7 +148,7 @@ describe('PrivateExecutionOracle', () => { }); it('overrides a hooked non-interactive handshake on an unconstrained self-send with an address-derived secret', async () => { - const { oracle } = await makeHookedOracle({ + const { oracle } = makeHookedOracle({ strategy: { type: 'non-interactive-handshake' }, keyStore: makeKeyStore({ ownsRecipient: true }), }); @@ -162,7 +161,7 @@ describe('PrivateExecutionOracle', () => { }); it('keeps a hooked non-interactive handshake under constrained delivery even when the wallet owns the recipient', async () => { - const { oracle } = await makeHookedOracle({ + const { oracle } = makeHookedOracle({ strategy: { type: 'non-interactive-handshake' }, keyStore: makeKeyStore({ ownsRecipient: true }), }); @@ -174,7 +173,7 @@ describe('PrivateExecutionOracle', () => { it('passes the correct message context to the hook', async () => { const contractClassId = Fr.random(); - const { oracle, resolveTaggingSecretStrategy } = await makeHookedOracle({ + const { oracle, resolveTaggingSecretStrategy } = makeHookedOracle({ strategy: { type: 'non-interactive-handshake' }, contractClassId, }); @@ -190,7 +189,7 @@ describe('PrivateExecutionOracle', () => { }); }); - const makeHookedOracle = async ({ + const makeHookedOracle = ({ strategy, contractClassId = Fr.random(), keyStore = makeKeyStore({ ownsRecipient: false }), @@ -200,10 +199,9 @@ describe('PrivateExecutionOracle', () => { keyStore?: KeyStore; }) => { const resolveTaggingSecretStrategy = jest.fn().mockResolvedValue(strategy); - const oracle = makeOracle({ hooks: { resolveTaggingSecretStrategy }, keyStore }); - jest - .spyOn(oracle, 'getContractInstance') - .mockResolvedValue(await SerializableContractInstance.random({ currentContractClassId: contractClassId })); + const anchoredContractData = mock(); + anchoredContractData.getCurrentClassId.mockResolvedValue(contractClassId); + const oracle = makeOracle({ hooks: { resolveTaggingSecretStrategy }, keyStore, anchoredContractData }); return { oracle, resolveTaggingSecretStrategy }; }; @@ -218,11 +216,10 @@ describe('PrivateExecutionOracle', () => { it('relays the request to the hook with the issuing contract context and returns its result', async () => { const result = [Fr.random(), Fr.random()]; const resolveCustomRequest = jest.fn().mockResolvedValue(result); - const oracle = makeOracle({ hooks: { resolveCustomRequest } }); const contractClassId = Fr.random(); - jest - .spyOn(oracle, 'getContractInstance') - .mockResolvedValue(await SerializableContractInstance.random({ currentContractClassId: contractClassId })); + const anchoredContractData = mock(); + anchoredContractData.getCurrentClassId.mockResolvedValue(contractClassId); + const oracle = makeOracle({ hooks: { resolveCustomRequest }, anchoredContractData }); const kind = Fr.random(); const payload = [Fr.random(), Fr.random(), Fr.random()]; @@ -250,7 +247,7 @@ describe('PrivateExecutionOracle', () => { executionCache: HashedValuesCache.create([]), noteCache: new ExecutionNoteCache(Fr.ZERO), taggingIndexCache: new ExecutionTaggingIndexCache(), - contractStore: mock(), + anchoredContractData: mock(), noteStore: mock(), keyStore: mock(), addressStore: mock(), diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts index 7440d28e8e74..cc08e3b25e2e 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts @@ -197,8 +197,8 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP if (!hook) { throw new Error('Cannot serve a request: no resolveCustomRequest hook is configured'); } - const { currentContractClassId } = await this.getContractInstance(this.contractAddress); - return hook({ contractAddress: this.contractAddress, contractClassId: currentContractClassId, kind, payload }); + const contractClassId = await this.#getCurrentContractClassId(this.contractAddress); + return hook({ contractAddress: this.contractAddress, contractClassId, kind, payload }); } /** @@ -237,16 +237,28 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP return { type: 'non-interactive-handshake' }; } - const { currentContractClassId } = await this.getContractInstance(this.contractAddress); + const contractClassId = await this.#getCurrentContractClassId(this.contractAddress); return hook({ contractAddress: this.contractAddress, - contractClassId: currentContractClassId, + contractClassId, sender, recipient, deliveryMode, }); } + /** + * Resolves the class id the given contract runs at this simulation's anchor block. The instance no longer carries + * its class id (it is chain-derived), so it is resolved via the anchored contract data instead. + */ + async #getCurrentContractClassId(address: AztecAddress): Promise { + const classId = await this.anchoredContractData.getCurrentClassId(address); + if (classId === undefined) { + throw new Error(`Cannot resolve the current contract class for ${address}`); + } + return classId; + } + /** Whether this is an unconstrained delivery to one of the wallet's own accounts (a self-send). */ #isUnconstrainedSelfSend(recipient: AztecAddress, deliveryMode: AppTaggingSecretKind) { return deliveryMode === AppTaggingSecretKind.UNCONSTRAINED @@ -641,10 +653,16 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP this.scopes, ); - const targetArtifact = await this.contractStore.getFunctionArtifactWithDebugMetadata( + const targetArtifact = await this.anchoredContractData.getFunctionArtifactWithDebugMetadata( targetContractAddress, functionSelector, ); + if (!targetArtifact) { + throw new Error( + `Cannot call ${targetContractAddress}:${functionSelector}: the contract is not registered. ` + + `Register it via wallet.registerContract(...).`, + ); + } const derivedTxContext = this.txContext.clone(); @@ -661,7 +679,7 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP executionCache: this.executionCache, noteCache: this.noteCache, taggingIndexCache: this.taggingIndexCache, - contractStore: this.contractStore, + anchoredContractData: this.anchoredContractData, noteStore: this.noteStore, keyStore: this.keyStore, addressStore: this.addressStore, @@ -765,7 +783,7 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP } public getDebugFunctionName() { - return this.contractStore.getDebugFunctionName(this.contractAddress, this.callContext.functionSelector); + return this.anchoredContractData.getDebugFunctionName(this.contractAddress, this.callContext.functionSelector); } protected override get callerContext() { diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts index a71b982830e6..eb8389417f53 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts @@ -33,7 +33,8 @@ import { BlockHeader, CallContext, Capsule, GlobalVariables, TxHash } from '@azt import { mock } from 'jest-mock-extended'; import type { _MockProxy } from 'jest-mock-extended/lib/Mock.js'; -import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; +import type { ContractClassService } from '../../contract/contract_class_service.js'; +import type { ContractSyncService } from '../../contract/contract_sync_service.js'; import { TxResolverService } from '../../messages/tx_resolver_service.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; import { CapsuleService } from '../../storage/capsule_store/capsule_service.js'; @@ -46,6 +47,7 @@ import type { PrivateEventStore } from '../../storage/private_event_store/privat import type { RecipientTaggingStore } from '../../storage/tagging_store/recipient_tagging_store.js'; import type { SenderTaggingStore } from '../../storage/tagging_store/sender_tagging_store.js'; import type { TaggingSecretSourcesStore } from '../../storage/tagging_store/tagging_secret_sources_store.js'; +import { AnchoredContractData } from '../anchored_contract_data.js'; import { ContractFunctionSimulator } from '../contract_function_simulator.js'; import { EphemeralArrayService } from '../ephemeral_array_service.js'; import { BoundedVec } from '../noir-structs/bounded_vec.js'; @@ -60,6 +62,7 @@ describe('Utility Execution test suite', () => { const simulator = new WASMSimulator(); let contractStore: ReturnType>; + let contractClassService: ReturnType>; let noteStore: ReturnType>; let keyStore: ReturnType>; let addressStore: ReturnType>; @@ -85,6 +88,8 @@ describe('Utility Execution test suite', () => { beforeEach(async () => { contractStore = mock(); + contractClassService = mock(); + contractClassService.getCurrentClassId.mockResolvedValue(new Fr(42)); noteStore = mock(); keyStore = mock(); addressStore = mock(); @@ -119,6 +124,7 @@ describe('Utility Execution test suite', () => { }); acirSimulator = new ContractFunctionSimulator({ contractStore, + contractClassService, noteStore, keyStore, addressStore, @@ -746,7 +752,7 @@ describe('Utility Execution test suite', () => { authWitnesses: [], capsules: [], anchorBlockHeader, - contractStore, + anchoredContractData: new AnchoredContractData(contractStore, contractClassService, anchorBlockHeader), noteStore, keyStore, addressStore, diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts index 4fe4a8417d01..625c85470e1f 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts @@ -20,7 +20,7 @@ import { type FunctionCall, FunctionSelector } from '@aztec/stdlib/abi'; import type { AuthWitness } from '@aztec/stdlib/auth-witness'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { BlockHash, type L2TipsProvider } from '@aztec/stdlib/block'; -import type { CompleteAddress, ContractInstance, PartialAddress } from '@aztec/stdlib/contract'; +import type { CompleteAddress, ContractInstancePreimageWithAddress, PartialAddress } from '@aztec/stdlib/contract'; import { siloNullifier } from '@aztec/stdlib/hash'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import type { KeyValidationRequest } from '@aztec/stdlib/kernel'; @@ -38,8 +38,8 @@ import { type TxHash, } from '@aztec/stdlib/tx'; +import type { ContractSyncService } from '../../contract/contract_sync_service.js'; import { createContractLogger, logContractMessage, stripAztecnrLogPrefix } from '../../contract_logging.js'; -import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; import { EventService } from '../../events/event_service.js'; import type { UtilityCallAuthorizationRequest } from '../../hooks/authorize_utility_call.js'; import type { ExecutionHooks } from '../../hooks/index.js'; @@ -49,13 +49,13 @@ import { NoteService } from '../../notes/note_service.js'; import { ORACLE_VERSION_MAJOR } from '../../oracle_version.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; import type { CapsuleService } from '../../storage/capsule_store/capsule_service.js'; -import type { ContractStore } from '../../storage/contract_store/contract_store.js'; import { FactCollectionKey, FactCollectionTypeKey, anchoredTipBlockNumbers } from '../../storage/fact_store/index.js'; import type { FactService, OriginBlock } from '../../storage/fact_store/index.js'; import type { NoteStore } from '../../storage/note_store/note_store.js'; import type { PrivateEventStore } from '../../storage/private_event_store/private_event_store.js'; import type { RecipientTaggingStore } from '../../storage/tagging_store/recipient_tagging_store.js'; import type { TaggingSecretSourcesStore } from '../../storage/tagging_store/tagging_secret_sources_store.js'; +import type { AnchoredContractData } from '../anchored_contract_data.js'; import { EphemeralArrayService } from '../ephemeral_array_service.js'; import { BoundedVec } from '../noir-structs/bounded_vec.js'; import type { EmbeddedCurvePoint } from '../noir-structs/embedded_curve_point.js'; @@ -83,7 +83,7 @@ export type UtilityExecutionOracleArgs = { authWitnesses: AuthWitness[]; capsules: Capsule[]; // TODO(#12425): Rename to transientCapsules anchorBlockHeader: BlockHeader; - contractStore: ContractStore; + anchoredContractData: AnchoredContractData; noteStore: NoteStore; keyStore: KeyStore; addressStore: AddressStore; @@ -126,7 +126,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra protected readonly authWitnesses: AuthWitness[]; protected readonly capsules: Capsule[]; protected readonly anchorBlockHeader: BlockHeader; - protected readonly contractStore: ContractStore; + protected readonly anchoredContractData: AnchoredContractData; protected readonly noteStore: NoteStore; protected readonly keyStore: KeyStore; protected readonly addressStore: AddressStore; @@ -151,7 +151,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra this.authWitnesses = args.authWitnesses; this.capsules = args.capsules; this.anchorBlockHeader = args.anchorBlockHeader; - this.contractStore = args.contractStore; + this.anchoredContractData = args.anchoredContractData; this.noteStore = args.noteStore; this.keyStore = args.keyStore; this.addressStore = args.addressStore; @@ -379,8 +379,8 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra * @param address - Address. * @returns A contract instance. */ - public async getContractInstance(address: AztecAddress): Promise { - const instance = await this.contractStore.getContractInstance(address); + public async getContractInstance(address: AztecAddress): Promise { + const instance = await this.anchoredContractData.getContractInstance(address); if (!instance) { throw new Error(`No contract instance found for address ${address.toString()}`); } @@ -535,7 +535,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra // to re-use jobId as instanceId here as executions of different PXE jobs are isolated. this.contractLogger = await createContractLogger( this.contractAddress, - addr => this.contractStore.getDebugContractName(addr), + addr => this.anchoredContractData.getDebugContractName(addr), 'user', { instanceId: this.jobId }, ); @@ -552,7 +552,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra // to re-use jobId as instanceId here as executions of different PXE jobs are isolated. this.aztecnrLogger = await createContractLogger( this.contractAddress, - addr => this.contractStore.getDebugContractName(addr), + addr => this.anchoredContractData.getDebugContractName(addr), 'aztecnr', { instanceId: this.jobId }, ); @@ -944,23 +944,35 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra functionSelector: FunctionSelector, args: Fr[], ): Promise { - const targetArtifact = await this.contractStore.getFunctionArtifactWithDebugMetadata( + const targetArtifact = await this.anchoredContractData.getFunctionArtifactWithDebugMetadata( targetContractAddress, functionSelector, ); + if (!targetArtifact) { + throw new Error( + `Cannot call ${targetContractAddress}:${functionSelector}: the contract is not registered. ` + + `Register it via wallet.registerContract(...).`, + ); + } if (!targetContractAddress.equals(this.contractAddress)) { // Standard handshake registry reads are authorized by default; every other cross-contract call needs the hook. if (!(await isStandardHandshakeRegistryUtilityRead(targetContractAddress, functionSelector))) { - const [callerInstance, targetInstance] = await Promise.all([ - this.getContractInstance(this.contractAddress), - this.getContractInstance(targetContractAddress), + const [callerClassId, targetClassId] = await Promise.all([ + this.anchoredContractData.getCurrentClassId(this.contractAddress), + this.anchoredContractData.getCurrentClassId(targetContractAddress), ]); + if (!callerClassId || !targetClassId) { + throw new Error( + `Cannot authorize utility call from ${this.contractAddress} to ${targetContractAddress}: ` + + `${!callerClassId ? this.contractAddress : targetContractAddress} is not registered.`, + ); + } const request: UtilityCallAuthorizationRequest = { caller: this.contractAddress, - callerClassId: callerInstance.currentContractClassId, + callerClassId, target: targetContractAddress, - targetClassId: targetInstance.currentContractClassId, + targetClassId, functionSelector, functionName: targetArtifact.name, args, @@ -1005,7 +1017,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra authWitnesses: this.authWitnesses, capsules: this.capsules, anchorBlockHeader: this.anchorBlockHeader, - contractStore: this.contractStore, + anchoredContractData: this.anchoredContractData, noteStore: this.noteStore, keyStore: this.keyStore, addressStore: this.addressStore, diff --git a/yarn-project/pxe/src/contract_function_simulator/proxied_contract_data_source.ts b/yarn-project/pxe/src/contract_function_simulator/proxied_contract_data_source.ts deleted file mode 100644 index f7f9ce20da5f..000000000000 --- a/yarn-project/pxe/src/contract_function_simulator/proxied_contract_data_source.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { type FunctionSelector, findFunctionArtifactBySelector } from '@aztec/stdlib/abi'; -import type { AztecAddress } from '@aztec/stdlib/aztec-address'; -import type { ContractOverrides } from '@aztec/stdlib/tx'; - -import type { ContractStore } from '../storage/contract_store/contract_store.js'; - -/* - * Proxy generator for a ContractStore that allows overriding contract instances at given addresses, - * so the contract function simulator can execute different bytecode on certain addresses. An example - * use case would be overriding your own account contract so that valid signatures don't have to be - * provided while simulating. - * - * Function artifact lookups for an overridden address are routed via the override-instance's - * `currentContractClassId` rather than the underlying ContractStore's address→class mapping — - * `ContractStore.getFunctionArtifact` calls `this.getContractInstance` internally, which the proxy - * cannot intercept (the binding sees the raw target, not the proxy). The target class must be - * registered ahead of time via `pxe.registerContractClass(...)`. - */ -export class ProxiedContractStoreFactory { - static create(contractStore: ContractStore, overrides?: ContractOverrides) { - if (!overrides) { - return contractStore; - } - - return new Proxy(contractStore, { - get(target, prop: keyof ContractStore) { - if (prop === 'getContractInstance') { - return (address: AztecAddress) => { - const override = overrides[address.toString()]; - return override ? Promise.resolve(override.instance) : target.getContractInstance(address); - }; - } - if (prop === 'getFunctionArtifact' || prop === 'getFunctionArtifactWithDebugMetadata') { - return async (contractAddress: AztecAddress, selector: FunctionSelector) => { - const override = overrides[contractAddress.toString()]; - if (!override) { - return (target[prop] as Function).call(target, contractAddress, selector); - } - const artifact = await target.getContractArtifact(override.instance.currentContractClassId); - if (!artifact) { - throw new Error( - `No artifact registered for override class ${override.instance.currentContractClassId} ` + - `at ${contractAddress}. Register it via pxe.registerContractClass(...) before simulating.`, - ); - } - const fn = await findFunctionArtifactBySelector(artifact, selector); - if (!fn) { - throw new Error( - `Function with selector ${selector} not found in stub artifact for overridden contract at ${contractAddress}.`, - ); - } - return { ...fn, contractName: artifact.name }; - }; - } - const value = Reflect.get(target, prop); - return typeof value === 'function' ? value.bind(target) : value; - }, - }) satisfies ContractStore; - } -} diff --git a/yarn-project/pxe/src/contract_sync/helpers.ts b/yarn-project/pxe/src/contract_sync/helpers.ts deleted file mode 100644 index b651d210280a..000000000000 --- a/yarn-project/pxe/src/contract_sync/helpers.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { isProtocolContract } from '@aztec/protocol-contracts'; -import type { FunctionCall, FunctionSelector } from '@aztec/stdlib/abi'; -import type { AztecAddress } from '@aztec/stdlib/aztec-address'; -import type { ContractInstance } from '@aztec/stdlib/contract'; -import type { AztecNode } from '@aztec/stdlib/interfaces/client'; -import type { BlockHeader } from '@aztec/stdlib/tx'; - -import type { ContractStore } from '../storage/contract_store/contract_store.js'; - -/** - * Read the current class id of a contract as of the given block, as seen by the AztecNode. The node resolves it from - * the same scheduled value change the AVM enforces against the public data tree. If the node has no record of the - * instance (e.g. it was never publicly deployed), the original class id from the local instance is used. - * @param contractAddress - The address of the contract to read the class id for. - * @param instance - The local instance of the contract, used as a fallback when the node doesn't know it. - * @param aztecNode - The Aztec node to query. - * @param header - The header of the block at which to resolve the current class id. - * @returns The current class id. - */ -export async function readCurrentClassId( - contractAddress: AztecAddress, - instance: ContractInstance, - aztecNode: AztecNode, - header: BlockHeader, -) { - const nodeInstance = await aztecNode.getContract(contractAddress, await header.hash()); - // If the contract was upgraded then the node WILL know of that and return a non-undefined instance. An undefined - // result therefore means that no upgrade has happened, and therefore that the original class is the current one. - return nodeInstance?.currentContractClassId ?? instance.originalContractClassId; -} - -export async function syncScope( - contractAddress: AztecAddress, - contractStore: ContractStore, - functionToInvokeAfterSync: FunctionSelector | null, - utilityExecutor: (privateSyncCall: FunctionCall, scopes: AztecAddress[]) => Promise, - scope: AztecAddress, -) { - // Protocol contracts don't have private state to sync - if (isProtocolContract(contractAddress)) { - return; - } - - const syncStateFunctionCall = await contractStore.getFunctionCall('sync_state', [scope], contractAddress); - if (functionToInvokeAfterSync && functionToInvokeAfterSync.equals(syncStateFunctionCall.selector)) { - throw new Error( - 'Forbidden `sync_state` invocation. `sync_state` can only be invoked by PXE, manual execution can lead to inconsistencies.', - ); - } - - await utilityExecutor(syncStateFunctionCall, [scope]); -} - -/** - * Verify that the current class id of a contract obtained from AztecNode is the same as the one in contract data - * provider (i.e. PXE's own storage). - * @param contractAddress - The address of the contract to verify. - * @param aztecNode - The Aztec node to query for storage. - * @param contractStore - The contract store to fetch the local instance from. - * @param header - The header of the block at which to verify the current class id. - */ -export async function verifyCurrentClassId( - contractAddress: AztecAddress, - aztecNode: AztecNode, - contractStore: ContractStore, - header: BlockHeader, -) { - const instance = await contractStore.getContractInstance(contractAddress); - if (!instance) { - throw new Error(`No contract instance found for address ${contractAddress.toString()}`); - } - - const currentClassId = await readCurrentClassId(contractAddress, instance, aztecNode, header); - if (!instance.currentContractClassId.equals(currentClassId)) { - throw new Error( - `Contract ${contractAddress} is outdated, current class id is ${currentClassId}, local class id is ${instance.currentContractClassId}`, - ); - } -} diff --git a/yarn-project/pxe/src/debug/pxe_debug_utils.ts b/yarn-project/pxe/src/debug/pxe_debug_utils.ts index 0ab3c64bf348..69156f484cf8 100644 --- a/yarn-project/pxe/src/debug/pxe_debug_utils.ts +++ b/yarn-project/pxe/src/debug/pxe_debug_utils.ts @@ -5,8 +5,8 @@ import type { NoteDao } from '@aztec/stdlib/note'; import type { ContractOverrides } from '@aztec/stdlib/tx'; import type { BlockSynchronizer } from '../block_synchronizer/block_synchronizer.js'; +import type { ContractSyncService } from '../contract/contract_sync_service.js'; import type { ContractFunctionSimulator } from '../contract_function_simulator/contract_function_simulator.js'; -import type { ContractSyncService } from '../contract_sync/contract_sync_service.js'; import type { NotesFilter } from '../notes_filter.js'; import type { AnchorBlockStore } from '../storage/index.js'; import type { NoteStore } from '../storage/note_store/note_store.js'; diff --git a/yarn-project/pxe/src/entrypoints/server/index.ts b/yarn-project/pxe/src/entrypoints/server/index.ts index 7fac75907e37..c4a96d12cd9f 100644 --- a/yarn-project/pxe/src/entrypoints/server/index.ts +++ b/yarn-project/pxe/src/entrypoints/server/index.ts @@ -1,3 +1,5 @@ +export { type AccountPrivacyKeys, type AccountPrivacySecretKeys } from '@aztec/key-store'; + export * from '../../notes_filter.js'; export * from '../../pxe.js'; export * from '../../config/index.js'; @@ -8,4 +10,6 @@ export { NoteService } from '../../notes/note_service.js'; export { ORACLE_VERSION_MAJOR, ORACLE_VERSION_MINOR } from '../../oracle_version.js'; export { type PXECreationOptions } from '../pxe_creation_options.js'; export { JobCoordinator } from '../../job_coordinator/job_coordinator.js'; -export { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; +export { ContractSyncService } from '../../contract/contract_sync_service.js'; +export { ContractClassService } from '../../contract/contract_class_service.js'; +export { AnchoredContractData } from '../../contract_function_simulator/anchored_contract_data.js'; diff --git a/yarn-project/pxe/src/error_enriching.ts b/yarn-project/pxe/src/error_enriching.ts index 78343df6a213..86866d0f15f1 100644 --- a/yarn-project/pxe/src/error_enriching.ts +++ b/yarn-project/pxe/src/error_enriching.ts @@ -3,7 +3,9 @@ import { resolveAssertionMessageFromRevertData, resolveOpcodeLocations } from '@ import { FunctionSelector } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { type SimulationError, isNoirCallStackUnresolved } from '@aztec/stdlib/errors'; +import type { BlockHeader } from '@aztec/stdlib/tx'; +import type { ContractClassService } from './contract/contract_class_service.js'; import type { ContractStore } from './storage/contract_store/contract_store.js'; /** @@ -11,7 +13,13 @@ import type { ContractStore } from './storage/contract_store/contract_store.js'; * can be found in the PXE database * @param err - The error to enrich. */ -export async function enrichSimulationError(err: SimulationError, contractStore: ContractStore, logger: Logger) { +export async function enrichSimulationError( + err: SimulationError, + contractStore: ContractStore, + contractClassService: ContractClassService, + anchorHeader: BlockHeader, + logger: Logger, +) { // Maps contract addresses to the set of function selectors that were in error. // Map and Set do reference equality for their keys instead of value equality, so we store the string // representation to get e.g. different contract address objects with the same address value to match. @@ -30,13 +38,14 @@ export async function enrichSimulationError(err: SimulationError, contractStore: await Promise.all( [...mentionedFunctions.entries()].map(async ([contractAddress, fnSelectors]) => { const parsedContractAddress = AztecAddress.fromStringUnsafe(contractAddress); - const contract = await contractStore.getContract(parsedContractAddress); - if (contract) { - err.enrichWithContractName(parsedContractAddress, contract.name); + const classId = await contractClassService.getCurrentClassId(parsedContractAddress, anchorHeader); + const artifact = classId && (await contractStore.getContractArtifact(classId)); + if (artifact) { + err.enrichWithContractName(parsedContractAddress, artifact.name); // Map from function selector to function name. It uses a stringified key for the same reason as mentionedFunctions. const selectorToNameMap: Map = new Map(); await Promise.all( - contract.functions.map(async fn => { + artifact.functions.map(async fn => { const selector = await FunctionSelector.fromNameAndParameters(fn); selectorToNameMap.set(selector.toString(), fn.name); }), @@ -51,7 +60,7 @@ export async function enrichSimulationError(err: SimulationError, contractStore: ); } else { logger.warn( - `Could not find function artifact in contract ${contract.name} for function '${fnSelector}' when enriching error callstack`, + `Could not find function artifact in contract ${artifact.name} for function '${fnSelector}' when enriching error callstack`, ); } } @@ -64,7 +73,13 @@ export async function enrichSimulationError(err: SimulationError, contractStore: ); } -export async function enrichPublicSimulationError(err: SimulationError, contractStore: ContractStore, logger: Logger) { +export async function enrichPublicSimulationError( + err: SimulationError, + contractStore: ContractStore, + contractClassService: ContractClassService, + anchorHeader: BlockHeader, + logger: Logger, +) { const callStack = err.getCallStack(); const originalFailingFunction = callStack[callStack.length - 1]; @@ -72,7 +87,8 @@ export async function enrichPublicSimulationError(err: SimulationError, contract throw new Error(`Original failing function not found when enriching public simulation, missing callstack`); } - const artifact = await contractStore.getPublicFunctionArtifact(originalFailingFunction.contractAddress); + const classId = await contractClassService.getCurrentClassId(originalFailingFunction.contractAddress, anchorHeader); + const artifact = classId && (await contractStore.getPublicFunctionArtifact(classId)); if (!artifact) { throw new Error( `Artifact not found when enriching public simulation error. Contract address: ${originalFailingFunction.contractAddress}.`, @@ -84,7 +100,7 @@ export async function enrichPublicSimulationError(err: SimulationError, contract err.setOriginalMessage(err.getOriginalMessage() + `${assertionMessage}`); } - const debugInfo = await contractStore.getPublicFunctionDebugMetadata(originalFailingFunction.contractAddress); + const debugInfo = await contractStore.getPublicFunctionDebugMetadata(classId); const noirCallStack = err.getNoirCallStack(); if (debugInfo) { @@ -102,6 +118,6 @@ export async function enrichPublicSimulationError(err: SimulationError, contract ); } } - await enrichSimulationError(err, contractStore, logger); + await enrichSimulationError(err, contractStore, contractClassService, anchorHeader, logger); } } diff --git a/yarn-project/pxe/src/notes/note_service.test.ts b/yarn-project/pxe/src/notes/note_service.test.ts index 6f454e85f7bf..193c7ddb3b7f 100644 --- a/yarn-project/pxe/src/notes/note_service.test.ts +++ b/yarn-project/pxe/src/notes/note_service.test.ts @@ -7,6 +7,7 @@ import { BlockHash, randomDataInBlock } from '@aztec/stdlib/block'; import type { CompleteAddress } from '@aztec/stdlib/contract'; import { computeUniqueNoteHash, siloNoteHash, siloNullifier } from '@aztec/stdlib/hash'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; +import { deriveKeys } from '@aztec/stdlib/keys'; import { NoteDao, NoteStatus } from '@aztec/stdlib/note'; import { makeBlockHeader } from '@aztec/stdlib/testing'; import { MerkleTreeId } from '@aztec/stdlib/trees'; @@ -42,7 +43,7 @@ describe('NoteService', () => { contractAddress = await AztecAddress.random(); - recipient = await keyStore.addAccount(new Fr(69), Fr.random()); + recipient = await keyStore.addAccount(await deriveKeys(new Fr(69)), Fr.random()); const notes = await noteStore.getNotes({ contractAddress, scopes: [recipient.address] }, 'test'); expect(notes).toHaveLength(0); @@ -180,8 +181,8 @@ describe('NoteService', () => { }); it('should search for notes from all accounts', async () => { - await keyStore.addAccount(Fr.random(), Fr.random()); - await keyStore.addAccount(Fr.random(), Fr.random()); + await keyStore.addAccount(await deriveKeys(Fr.random()), Fr.random()); + await keyStore.addAccount(await deriveKeys(Fr.random()), Fr.random()); expect(await keyStore.getAccounts()).toHaveLength(3); diff --git a/yarn-project/pxe/src/private_kernel/private_kernel_oracle.test.ts b/yarn-project/pxe/src/private_kernel/private_kernel_oracle.test.ts index 5ebbbe505cb7..62dbc74e585a 100644 --- a/yarn-project/pxe/src/private_kernel/private_kernel_oracle.test.ts +++ b/yarn-project/pxe/src/private_kernel/private_kernel_oracle.test.ts @@ -12,6 +12,7 @@ import { BlockHeader } from '@aztec/stdlib/tx'; import { mock } from 'jest-mock-extended'; +import type { ContractClassService } from '../contract/contract_class_service.js'; import type { ContractStore } from '../storage/contract_store/contract_store.js'; import { PrivateKernelOracle } from './private_kernel_oracle.js'; @@ -21,7 +22,13 @@ describe('PrivateKernelOracle', () => { beforeEach(() => { node = mock(); - oracle = new PrivateKernelOracle(mock(), mock(), node, BlockHeader.empty()); + oracle = new PrivateKernelOracle( + mock(), + mock(), + mock(), + node, + BlockHeader.empty(), + ); }); describe('getUpdatedClassIdHints', () => { diff --git a/yarn-project/pxe/src/private_kernel/private_kernel_oracle.ts b/yarn-project/pxe/src/private_kernel/private_kernel_oracle.ts index cce48341341e..80611037d46f 100644 --- a/yarn-project/pxe/src/private_kernel/private_kernel_oracle.ts +++ b/yarn-project/pxe/src/private_kernel/private_kernel_oracle.ts @@ -22,30 +22,45 @@ import type { NullifierMembershipWitness } from '@aztec/stdlib/trees'; import type { BlockHeader } from '@aztec/stdlib/tx'; import type { VerificationKeyAsFields } from '@aztec/stdlib/vks'; +import type { ContractClassService } from '../contract/contract_class_service.js'; +import { AnchoredContractData } from '../contract_function_simulator/anchored_contract_data.js'; import type { ContractStore } from '../storage/contract_store/contract_store.js'; /** * Provides functionality needed by the private kernel for interacting with our state trees. */ export class PrivateKernelOracle { + private readonly anchoredContractData: AnchoredContractData; + constructor( private contractStore: ContractStore, + contractClassService: ContractClassService, private keyStore: KeyStore, private node: AztecNode, private blockHeader: BlockHeader, - ) {} + ) { + // Kernels never use contract overrides (those are confined to simulations, which skip proving), so this view is + // built without them. + this.anchoredContractData = new AnchoredContractData(contractStore, contractClassService, blockHeader); + } /** Retrieves the preimage of a contract address from the registered contract instances db. */ public async getContractAddressPreimage( address: AztecAddress, ): Promise { - const instance = await this.contractStore.getContractInstance(address); + const instance = await this.anchoredContractData.getContractInstance(address); if (!instance) { throw new Error(`Contract instance not found when getting address preimage. Contract address: ${address}.`); } + // Local instance existence was checked above, so resolution below cannot come back empty. + const currentContractClassId = await this.anchoredContractData.getCurrentClassId(address); + if (!currentContractClassId) { + throw new Error(`Could not resolve the current class id for registered contract ${address}.`); + } return { saltedInitializationHash: await computeSaltedInitializationHash(instance), ...instance, + currentContractClassId, }; } @@ -114,8 +129,12 @@ export class PrivateKernelOracle { } /** Use debug data to get the function name corresponding to a selector. */ - public getDebugFunctionName(contractAddress: AztecAddress, selector: FunctionSelector): Promise { - return this.contractStore.getDebugFunctionName(contractAddress, selector); + public async getDebugFunctionName( + contractAddress: AztecAddress, + selector: FunctionSelector, + ): Promise { + const classId = await this.anchoredContractData.getCurrentClassId(contractAddress); + return classId ? this.contractStore.getDebugFunctionName(classId, selector) : undefined; } /** diff --git a/yarn-project/pxe/src/pxe.test.ts b/yarn-project/pxe/src/pxe.test.ts index cf14cbeaad43..92a3ab96f260 100644 --- a/yarn-project/pxe/src/pxe.test.ts +++ b/yarn-project/pxe/src/pxe.test.ts @@ -23,8 +23,13 @@ import { GENESIS_CHECKPOINT_HEADER_HASH, } from '@aztec/stdlib/block'; import { emptyChainConfig } from '@aztec/stdlib/config'; -import { CompleteAddress, getContractClassFromArtifact } from '@aztec/stdlib/contract'; +import { + CompleteAddress, + SerializableContractInstancePreimage, + getContractClassFromArtifact, +} from '@aztec/stdlib/contract'; import type { AztecNode, AztecNodeDebug, BlockResponse } from '@aztec/stdlib/interfaces/client'; +import { deriveKeys } from '@aztec/stdlib/keys'; import { randomContractArtifact, randomContractInstanceWithAddress, @@ -114,9 +119,9 @@ describe('PXE', () => { }, 120_000); it('registers an account and returns it as an account only and not as a recipient', async () => { - const randomSecretKey = Fr.random(); + const privacyKeys = await deriveKeys(Fr.random()); const randomPartialAddress = Fr.random(); - const completeAddress = await pxe.registerAccount(randomSecretKey, randomPartialAddress); + const completeAddress = await pxe.registerAccount(privacyKeys, randomPartialAddress); // Check that the account is correctly registered using the getAccounts and getRecipients methods const accounts = await pxe.getRegisteredAccounts(); @@ -132,15 +137,15 @@ describe('PXE', () => { }); it('does not throw when registering the same account twice (just ignores the second attempt)', async () => { - const randomSecretKey = Fr.random(); + const privacyKeys = await deriveKeys(Fr.random()); const randomPartialAddress = Fr.random(); - await pxe.registerAccount(randomSecretKey, randomPartialAddress); - await pxe.registerAccount(randomSecretKey, randomPartialAddress); + await pxe.registerAccount(privacyKeys, randomPartialAddress); + await pxe.registerAccount(privacyKeys, randomPartialAddress); }); it('does not add a keystore account to the sender address book when registered as a sender', async () => { - const { address } = await pxe.registerAccount(Fr.random(), Fr.random()); + const { address } = await pxe.registerAccount(await deriveKeys(Fr.random()), Fr.random()); await pxe.registerTaggingSecretSource({ kind: 'address-derived', sender: address }); const senders = await pxe.getTaggingSecretSources({ kind: 'address-derived' }); const senderAddresses = senders.map(s => s.sender.toString()); @@ -212,7 +217,7 @@ describe('PXE', () => { it('successfully adds a contract', async () => { const contracts = await Promise.all([randomDeployedContract(), randomDeployedContract()]); for (const contract of contracts) { - await pxe.registerContract(contract); + await pxe.registerContract(contract.instance); } const expectedContractAddresses = contracts.map(contract => contract.instance.address); @@ -223,7 +228,9 @@ describe('PXE', () => { it('preloads the standard multi-call entrypoint on creation', async () => { const { instance: expectedInstance, artifact: expectedArtifact } = await getStandardMultiCallEntrypoint(); const instance = await pxe.getContractInstance(STANDARD_MULTI_CALL_ENTRYPOINT_ADDRESS); - expect(instance).toEqual(expectedInstance); + expect(instance).toEqual( + new SerializableContractInstancePreimage(expectedInstance).withAddress(expectedInstance.address), + ); const artifact = await pxe.getContractArtifact(expectedInstance.currentContractClassId); expect(artifact).toEqual(expectedArtifact); @@ -238,47 +245,32 @@ describe('PXE', () => { await pxe.registerContractClass(artifact); expect(await pxe.getContractArtifact(contractClassId)).toEqual(artifact); - await pxe.registerContract({ instance }); - expect(await pxe.getContractInstance(instance.address)).toEqual(instance); - }); - - it('refuses to register a class with a mismatched address', async () => { - const artifact = randomContractArtifact(); - const contractClass = await getContractClassFromArtifact(artifact); - const contractClassId = contractClass.id; - const instance = await randomContractInstanceWithAddress({ contractClassId }); - await expect( - pxe.registerContract({ - instance: { - ...instance, - address: await AztecAddress.random(), - }, - artifact, - }), - ).rejects.toThrow(/Added a contract in which the address does not match the contract instance./); + await pxe.registerContract(instance); + expect(await pxe.getContractInstance(instance.address)).toEqual( + new SerializableContractInstancePreimage(instance).withAddress(instance.address), + ); }); - it('refuses to register a contract with a class that has not been registered', async () => { + it('registers an instance and returns its derived address without checking the class is present', async () => { + // Registration performs no validation and ignores any caller-supplied address: it derives the address from the + // preimage and stores the instance. A missing class only surfaces when the contract is later simulated. const instance = await randomContractInstanceWithAddress(); - await expect(pxe.registerContract({ instance })).rejects.toThrow(/Artifact not found when registering an instance/); + await expect(pxe.registerContract(instance)).resolves.toEqual(instance.address); + expect(await pxe.getContractInstance(instance.address)).toEqual( + new SerializableContractInstancePreimage(instance).withAddress(instance.address), + ); }); - it('refuses to register a contract with an artifact with mismatching class id', async () => { + it('does not call registerContractFunctionSignatures for classes without public functions', async () => { const artifact = randomContractArtifact(); - const instance = await randomContractInstanceWithAddress(); - await expect(pxe.registerContract({ instance, artifact })).rejects.toThrow(/Artifact does not match/i); - }); - - it('does not call registerContractFunctionSignatures for contracts without public functions', async () => { - const { artifact, instance } = await randomDeployedContract(); nodeDebug.registerContractFunctionSignatures.mockClear(); - await pxe.registerContract({ artifact, instance }); + await pxe.registerContractClass(artifact); expect(nodeDebug.registerContractFunctionSignatures).not.toHaveBeenCalled(); }); - it('calls registerContractFunctionSignatures for contracts with public functions', async () => { + it('calls registerContractFunctionSignatures for classes with public functions', async () => { const artifact = randomContractArtifact(); artifact.functions = [ { @@ -294,11 +286,9 @@ describe('PXE', () => { debugSymbols: '', }, ]; - const contractClass = await getContractClassFromArtifact(artifact); - const instance = await randomContractInstanceWithAddress({ contractClassId: contractClass.id }); nodeDebug.registerContractFunctionSignatures.mockClear(); - await pxe.registerContract({ artifact, instance }); + await pxe.registerContractClass(artifact); expect(nodeDebug.registerContractFunctionSignatures).toHaveBeenCalledWith(['my_public_fn()']); }); @@ -360,7 +350,7 @@ describe('PXE', () => { }); // Read when PXE resolves the current class id of a contract instance at the anchor block. Returning undefined - // makes readCurrentClassId fall back to the local instance's originalContractClassId. + // makes the contract class service fall back to the local instance's originalContractClassId. node.getContract.mockResolvedValue(undefined); // Used to sync private logs from the node - the return array needs to have the same length as the number of tags @@ -372,9 +362,7 @@ describe('PXE', () => { const contractClass = await getContractClassFromArtifact(TestContractArtifact); const contractClassId = contractClass.id; const contractInstance = await randomContractInstanceWithAddress({ contractClassId }); - await pxe.registerContract({ - instance: contractInstance, - }); + await pxe.registerContract(contractInstance); contractAddress = contractInstance.address; eventSelector = EventSelector.random(); diff --git a/yarn-project/pxe/src/pxe.ts b/yarn-project/pxe/src/pxe.ts index 74ff97b6da06..0e443ad9e45f 100644 --- a/yarn-project/pxe/src/pxe.ts +++ b/yarn-project/pxe/src/pxe.ts @@ -6,6 +6,7 @@ import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundatio import { SerialQueue } from '@aztec/foundation/queue'; import { Timer } from '@aztec/foundation/timer'; import { KeyStore } from '@aztec/key-store'; +import type { AccountPrivacyKeys, AccountPrivacySecretKeys } from '@aztec/key-store'; import type { AztecAsyncKVStore } from '@aztec/kv-store'; import { type ProtocolContractsProvider, protocolContractNames } from '@aztec/protocol-contracts'; import type { CircuitSimulator } from '@aztec/simulator/client'; @@ -13,6 +14,7 @@ import { type ContractArtifact, EventSelector, FunctionCall, + type FunctionSelector, FunctionType, decodeFunctionSignature, } from '@aztec/stdlib/abi'; @@ -21,10 +23,11 @@ import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import { GENESIS_BLOCK_HEADER_HASH, type L2TipsProvider } from '@aztec/stdlib/block'; import { CompleteAddress, + type ContractInstancePreimage, + type ContractInstancePreimageWithAddress, type ContractInstanceWithAddress, type PartialAddress, computeContractAddressFromInstance, - getContractClassFromArtifact, } from '@aztec/stdlib/contract'; import { SimulationError } from '@aztec/stdlib/errors'; import type { AztecNode, AztecNodeDebug, PrivateKernelProver } from '@aztec/stdlib/interfaces/client'; @@ -33,7 +36,6 @@ import type { PrivateKernelExecutionProofOutput, PrivateKernelTailCircuitPublicInputs, } from '@aztec/stdlib/kernel'; -import type { MasterSecretKeys } from '@aztec/stdlib/keys'; import { BlockHeader, type ContractOverrides, @@ -56,15 +58,14 @@ import { inspect } from 'util'; import { BlockSynchronizer } from './block_synchronizer/index.js'; import type { PXEConfig } from './config/index.js'; +import { ContractClassService } from './contract/contract_class_service.js'; +import { ContractSyncService } from './contract/contract_sync_service.js'; import { BenchmarkedNodeFactory } from './contract_function_simulator/benchmarked_node.js'; import { ContractFunctionSimulator, generateSimulatedProvingResult, } from './contract_function_simulator/contract_function_simulator.js'; -import { ProxiedContractStoreFactory } from './contract_function_simulator/proxied_contract_data_source.js'; import { displayDebugLogs } from './contract_logging.js'; -import { ContractSyncService } from './contract_sync/contract_sync_service.js'; -import { readCurrentClassId } from './contract_sync/helpers.js'; import { PXEDebugUtils } from './debug/pxe_debug_utils.js'; import { enrichPublicSimulationError, enrichSimulationError } from './error_enriching.js'; import { PrivateEventFilterValidator } from './events/private_event_filter_validator.js'; @@ -225,6 +226,7 @@ export class PXE { private addressStore: AddressStore, private privateEventStore: PrivateEventStore, private contractSyncService: ContractSyncService, + private contractClassService: ContractClassService, private txResolver: TxResolverService, private l2TipsStore: L2TipsProvider, private simulator: CircuitSimulator, @@ -293,9 +295,11 @@ export class PXE { l2TipsStore, factStore, } = openPxeStores(store, initialBlockHash); + const contractClassService = new ContractClassService(node, contractStore); const contractSyncService = new ContractSyncService( node, contractStore, + contractClassService, noteStore, createLogger('pxe:contract_sync', bindings), ); @@ -310,6 +314,7 @@ export class PXE { factStore, l2TipsStore, contractSyncService, + contractClassService, config, bindings, ); @@ -346,6 +351,7 @@ export class PXE { addressStore, privateEventStore, contractSyncService, + contractClassService, txResolver, l2TipsStore, simulator, @@ -377,10 +383,10 @@ export class PXE { // Internal methods #getSimulatorForTx(overrides?: { contracts?: ContractOverrides }) { - const proxyContractStore = ProxiedContractStoreFactory.create(this.contractStore, overrides?.contracts); - return new ContractFunctionSimulator({ - contractStore: proxyContractStore, + contractStore: this.contractStore, + contractClassService: this.contractClassService, + overrides: overrides?.contracts, noteStore: this.noteStore, keyStore: this.keyStore, addressStore: this.addressStore, @@ -399,6 +405,30 @@ export class PXE { }); } + /** Best-effort debug function name for the class `address` runs at `anchorBlockHeader`. Used for log display only. */ + async #getDebugFunctionName( + address: AztecAddress, + selector: FunctionSelector, + anchorBlockHeader: BlockHeader, + ): Promise { + try { + const classId = await this.contractClassService.getCurrentClassId(address, anchorBlockHeader); + return classId ? await this.contractStore.getDebugFunctionName(classId, selector) : `${address}:${selector}`; + } catch { + return `${address}:${selector}`; + } + } + + /** Best-effort debug contract name for the class `address` runs at `anchorBlockHeader`. Used for log display only. */ + async #getDebugContractName(address: AztecAddress, anchorBlockHeader: BlockHeader): Promise { + try { + const classId = await this.contractClassService.getCurrentClassId(address, anchorBlockHeader); + return classId ? await this.contractStore.getDebugContractName(classId) : undefined; + } catch { + return undefined; + } + } + #contextualizeError(err: Error, ...context: string[]): Error { let contextStr = ''; if (context.length > 0) { @@ -462,7 +492,14 @@ export class PXE { async #registerPreloadedContracts() { const contracts = await this.preloadedContractsProvider.getPreloadedContracts(); - await Promise.all(contracts.map(({ instance, artifact }) => this.registerContract({ instance, artifact }))); + await Promise.all( + contracts.map(async ({ instance, artifact }) => { + if (artifact) { + await this.registerContractClass(artifact); + } + await this.registerContract(instance); + }), + ); this.log.verbose(`Registered preloaded contracts in pxe`, { contracts: contracts.map(({ instance }) => instance.address.toString()), }); @@ -508,7 +545,7 @@ export class PXE { return result; } catch (err) { if (err instanceof SimulationError) { - await enrichSimulationError(err, this.contractStore, this.log); + await enrichSimulationError(err, this.contractStore, this.contractClassService, anchorBlockHeader, this.log); } throw err; } @@ -543,7 +580,8 @@ export class PXE { return { result, offchainEffects }; } catch (err) { if (err instanceof SimulationError) { - await enrichSimulationError(err, this.contractStore, this.log); + const anchorBlockHeader = await this.anchorBlockStore.getBlockHeader(); + await enrichSimulationError(err, this.contractStore, this.contractClassService, anchorBlockHeader, this.log); } throw err; } @@ -567,7 +605,14 @@ export class PXE { } catch (err) { if (err instanceof SimulationError) { try { - await enrichPublicSimulationError(err, this.contractStore, this.log); + const anchorBlockHeader = await this.anchorBlockStore.getBlockHeader(); + await enrichPublicSimulationError( + err, + this.contractStore, + this.contractClassService, + anchorBlockHeader, + this.log, + ); } catch (enrichErr) { this.log.error(`Failed to enrich public simulation error: ${enrichErr}`); } @@ -594,7 +639,13 @@ export class PXE { anchorBlockHeader: BlockHeader, config: PrivateKernelExecutionProverConfig, ): Promise> { - const kernelOracle = new PrivateKernelOracle(this.contractStore, this.keyStore, this.node, anchorBlockHeader); + const kernelOracle = new PrivateKernelOracle( + this.contractStore, + this.contractClassService, + this.keyStore, + this.node, + anchorBlockHeader, + ); const kernelTraceProver = new PrivateKernelExecutionProver( kernelOracle, proofCreator, @@ -638,11 +689,11 @@ export class PXE { } /** - * Returns the contract instance for a given address, if it's registered in the PXE. + * Returns the address preimage of the contract instance at a given address, if it's registered in the PXE. * @param address - The contract address. - * @returns The contract instance if found, undefined otherwise. + * @returns The contract instance address preimage if found, undefined otherwise. */ - public getContractInstance(address: AztecAddress): Promise { + public getContractInstance(address: AztecAddress): Promise { return this.contractStore.getContractInstance(address); } @@ -658,24 +709,20 @@ export class PXE { /** * Registers a user account in PXE. * - * The account's privacy keys may be provided either as a single master secret key, from which all keys are derived, - * or as the full set of master secret keys (for an account whose privacy keys were generated independently rather - * than from one seed). + * PXE holds the account's four privacy secret keys but only the *public* message-signing and fallback keys: their + * secret keys are withheld, since PXE is not trusted to hold them. * * Does nothing if the account is already registered. * - * The keys can be retrieved later with {@link getAccountSecretKeys}. + * The four privacy secret keys can be retrieved later with {@link getAccountSecretKeys}. * - * @param secretKeyOrKeys - The account's master secret key, or its full set of master secret keys. + * @param keys - The account's privacy keys: four secret keys plus the message-signing and fallback public keys. * @param partialAddress - The partial address of the account contract corresponding to the account being registered. * @returns The complete address of the account. */ - public async registerAccount( - secretKeyOrKeys: Fr | MasterSecretKeys, - partialAddress: PartialAddress, - ): Promise { + public async registerAccount(keys: AccountPrivacyKeys, partialAddress: PartialAddress): Promise { const accounts = await this.keyStore.getAccounts(); - const accountCompleteAddress = await this.keyStore.addAccount(secretKeyOrKeys, partialAddress); + const accountCompleteAddress = await this.keyStore.addAccount(keys, partialAddress); if (accounts.some(a => a.equals(accountCompleteAddress.address))) { this.log.info(`Account:\n "${accountCompleteAddress.address.toString()}"\n already registered.`); return accountCompleteAddress; @@ -689,7 +736,8 @@ export class PXE { } /** - * Retrieves the master privacy secret keys of a registered account. + * Retrieves the four privacy secret keys PXE holds for a registered account (nullifier-hiding, incoming-viewing, + * outgoing-viewing, tagging). * * These do NOT grant control over an account's assets, e.g. they are insufficient to impersonate the account or to * spend notes, but they _do_ guard the account's privacy. Parties that have knowledge of these keys can decrypt all @@ -701,7 +749,7 @@ export class PXE { * * @throws If the account is not registered. */ - public getAccountSecretKeys(account: AztecAddress): Promise { + public getAccountSecretKeys(account: AztecAddress): Promise { return this.keyStore.getAccountSecretKeys(account); } @@ -854,98 +902,33 @@ export class PXE { */ public async registerContractClass(artifact: ContractArtifact): Promise { const contractClassId = await this.contractStore.addContractArtifact(artifact); + // Publishing the artifact's public function signatures to the node is part of "registering a class", so that + // node-side debugging works regardless of which entrypoint (registerContractClass or registerContract) was used. + const publicFunctionSignatures = artifact.functions + .filter(fn => fn.functionType === FunctionType.PUBLIC) + .map(fn => decodeFunctionSignature(fn.name, fn.parameters)); + if (publicFunctionSignatures.length > 0) { + await this.nodeDebug?.registerContractFunctionSignatures(publicFunctionSignatures); + } this.log.info(`Added contract class ${artifact.name} with id ${contractClassId}`); } /** - * Adds deployed contracts to the PXE. Deployed contract information is used to access the - * contract code when simulating local transactions. This is automatically called by aztec.js when - * deploying a contract. Dapps that wish to interact with contracts already deployed should register - * these contracts in their users' PXE through this method. + * Registers a deployed contract instance so its private state can be synced and its functions simulated. The + * artifact for the class the instance runs must be registered separately via {@link registerContractClass}, before + * or after this call; registration performs no validation, so a missing or mismatched artifact only surfaces when + * the contract is later simulated. This is automatically called by aztec.js when deploying a contract. * - * @param contract - A contract instance to register, with an optional artifact which can be omitted if the contract class has already been registered. - */ - public async registerContract(contract: { instance: ContractInstanceWithAddress; artifact?: ContractArtifact }) { - const { instance } = contract; - let { artifact } = contract; - - if (artifact) { - // If the user provides an artifact, validate it against the expected class id and register it - const contractClass = await getContractClassFromArtifact(artifact); - if (!contractClass.id.equals(instance.currentContractClassId)) { - throw new Error( - `Artifact does not match expected class id (computed ${contractClass.id} but instance refers to ${instance.currentContractClassId})`, - ); - } - const computedAddress = await computeContractAddressFromInstance(instance); - if (!computedAddress.equals(instance.address)) { - throw new Error('Added a contract in which the address does not match the contract instance.'); - } - - await this.contractStore.addContractArtifact(artifact, contractClass); - - const publicFunctionSignatures = artifact.functions - .filter(fn => fn.functionType === FunctionType.PUBLIC) - .map(fn => decodeFunctionSignature(fn.name, fn.parameters)); - if (publicFunctionSignatures.length > 0) { - await this.nodeDebug?.registerContractFunctionSignatures(publicFunctionSignatures); - } - } else { - // Otherwise, make sure there is an artifact already registered for that class id - artifact = await this.contractStore.getContractArtifact(instance.currentContractClassId); - if (!artifact) { - throw new Error( - `Artifact not found when registering an instance. Contract class: ${instance.currentContractClassId}.`, - ); - } - } - - await this.contractStore.addContractInstance(instance); - this.log.info( - `Added contract ${artifact.name} at ${instance.address.toString()} with class ${instance.currentContractClassId}`, - ); - } - - /** - * Updates a deployed contract in the PXE. This is used to update the contract artifact when - * an update has happened, so the new code can be used in the simulation of local transactions. - * This is called by aztec.js when instantiating a contract in a given address with a mismatching artifact. - * @param contractAddress - The address of the contract to update. - * @param artifact - The updated artifact for the contract. - * @throws If the artifact's contract class is not found in the PXE or if the contract class is different from - * the current one (current one from the point of view of the node to which the PXE is connected). + * @param instance - The address preimage of the contract instance to register. The address is derived from it. + * @returns The address of the registered instance. */ - public updateContract(contractAddress: AztecAddress, artifact: ContractArtifact): Promise { - // We disable concurrently updating contracts to avoid concurrently syncing with the node, or changing a contract's - // class while we're simulating it. + public registerContract(instance: ContractInstancePreimage): Promise { + // Run inside the job queue so we can't race a concurrent simulation while writing the instance to the store. return this.#putInJobQueue(async () => { - const currentInstance = await this.contractStore.getContractInstance(contractAddress); - if (!currentInstance) { - throw new Error(`Instance not found when updating a contract. Contract address: ${contractAddress}.`); - } - const contractClass = await getContractClassFromArtifact(artifact); - await this.#maybeSync(); - - const header = await this.anchorBlockStore.getBlockHeader(); - - const currentClassId = await readCurrentClassId(contractAddress, currentInstance, this.node, header); - if (!contractClass.id.equals(currentClassId)) { - throw new Error('Could not update contract to a class different from the current one.'); - } - - const publicFunctionSignatures = artifact.functions - .filter(fn => fn.functionType === FunctionType.PUBLIC) - .map(fn => decodeFunctionSignature(fn.name, fn.parameters)); - if (publicFunctionSignatures.length > 0) { - await this.nodeDebug?.registerContractFunctionSignatures(publicFunctionSignatures); - } - - currentInstance.currentContractClassId = contractClass.id; - await Promise.all([ - this.contractStore.addContractArtifact(artifact, contractClass), - this.contractStore.addContractInstance(currentInstance), - ]); - this.log.info(`Updated contract ${artifact.name} at ${contractAddress.toString()} to class ${contractClass.id}`); + const address = await computeContractAddressFromInstance(instance); + await this.contractStore.addContractInstance({ ...instance, address }); + this.log.info(`Added contract at ${address}`, { address }); + return address; }); } @@ -1207,7 +1190,7 @@ export class PXE { if (skipKernels) { ({ publicInputs, executionSteps } = await generateSimulatedProvingResult( privateExecutionResult, - (addr, sel) => this.contractStore.getDebugFunctionName(addr, sel), + (addr, sel) => this.#getDebugFunctionName(addr, sel, anchorBlockHeader), this.node, )); } else { @@ -1234,7 +1217,7 @@ export class PXE { publicOutput = await this.#simulatePublicCalls(simulatedTx, skipFeeEnforcement, overrides); publicSimulationTime = publicSimulationTimer.ms(); if (publicOutput?.debugLogs?.length) { - await displayDebugLogs(publicOutput.debugLogs, addr => this.contractStore.getDebugContractName(addr)); + await displayDebugLogs(publicOutput.debugLogs, addr => this.#getDebugContractName(addr, anchorBlockHeader)); } } diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/ContractStore.json b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/ContractStore.json index 8635b76c8b38..5e03537306b8 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/ContractStore.json +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/ContractStore.json @@ -22,7 +22,7 @@ "contracts_instances": [ { "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000065", - "value": "020000000000000000000000000000000000000000000000000000000000000049000000000000000000000000000000000000000000000000000000000000004f00000000000000000000000000000000000000000000000000000000000000530000000000000000000000000000000000000000000000000000000000000059000000000000000000000000000000000000000000000000000000000000006100000000000000000000000000000000000000000000000000000000000000670000000000000000000000000000000000000000000000000000000000000029000000000000000000000000000000000000000000000000000000000000002f0000000000000000000000000000000000000000000000000000000000000035000000000000000000000000000000000000000000000000000000000000003b000000000000000000000000000000000000000000000000000000000000004300000000000000000000000000000000000000000000000000000000000000470000000000000000000000000000000000000000000000000000000000000049" + "value": "020000000000000000000000000000000000000000000000000000000000000049000000000000000000000000000000000000000000000000000000000000004f0000000000000000000000000000000000000000000000000000000000000059000000000000000000000000000000000000000000000000000000000000006100000000000000000000000000000000000000000000000000000000000000670000000000000000000000000000000000000000000000000000000000000029000000000000000000000000000000000000000000000000000000000000002f0000000000000000000000000000000000000000000000000000000000000035000000000000000000000000000000000000000000000000000000000000003b000000000000000000000000000000000000000000000000000000000000004300000000000000000000000000000000000000000000000000000000000000470000000000000000000000000000000000000000000000000000000000000049" } ] } diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/KeyStore.json b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/KeyStore.json index 4be9c6942743..40fb9179bf65 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/KeyStore.json +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/KeyStore.json @@ -1,13 +1,5 @@ { "key_store": [ - { - "key": "utf8:0x2369ead09116908cedc1642d5e0c714ef288ecbc838e4d434f800a408914ce44-fbpk_m", - "value": "2e1447f8b5728044d6faa6ef33ec052d9717e07422b3df50c5c36b697c62014e1909a35f1689616a4d8ec82cf4937a7f71a8a5300297164d4f2ae78f09abd2eb" - }, - { - "key": "utf8:0x2369ead09116908cedc1642d5e0c714ef288ecbc838e4d434f800a408914ce44-fbsk_m", - "value": "0131aa657a85631cd51a91fccac2e4096b2f48896f18b532b9ef2b1047549467" - }, { "key": "utf8:0x2369ead09116908cedc1642d5e0c714ef288ecbc838e4d434f800a408914ce44-ivpk_m", "value": "19e95c05f09cb86d1a6257572f9082ca15d4a02373c4d8c9cee23dc61a4c2a882f9017d51bca6616102d8a1d4dba89e4ce12881a2414576f01d2fec9492814a2" @@ -20,14 +12,6 @@ "key": "utf8:0x2369ead09116908cedc1642d5e0c714ef288ecbc838e4d434f800a408914ce44-ivsk_m", "value": "1fb01c42d1aaa2662041b899c77cb19e08192193acc5a94405f1b43c974eba7a" }, - { - "key": "utf8:0x2369ead09116908cedc1642d5e0c714ef288ecbc838e4d434f800a408914ce44-mspk_m", - "value": "02c6c6b46363b53b21bcb7b769ef4056093d9bbdafef36824fd29c95028524be0a92ac571df7db2b0e0ce5932a5b00cb8a804812cbd5a1b7bf9dfdaf517c65e0" - }, - { - "key": "utf8:0x2369ead09116908cedc1642d5e0c714ef288ecbc838e4d434f800a408914ce44-mssk_m", - "value": "05c064b04e55b134964811d72377da31279d9788813011113322f80a21efbb91" - }, { "key": "utf8:0x2369ead09116908cedc1642d5e0c714ef288ecbc838e4d434f800a408914ce44-nhk_m", "value": "2dd30220767969b10044b1322585bb4c4df4e0c5d9b4d7b3045a879a8e3e91d2" diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json index ae6b0b935c10..b4ad55b3b0ad 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json @@ -1,5 +1,5 @@ { - "schemaVersion": 10, + "schemaVersion": 11, "stores": [ { "name": "capsules", diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/pxe_db_compatibility.test.ts b/yarn-project/pxe/src/storage/backwards_compatibility_tests/pxe_db_compatibility.test.ts index f73454ba189c..0cfe79e80992 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/pxe_db_compatibility.test.ts +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/pxe_db_compatibility.test.ts @@ -21,7 +21,8 @@ import { createStoreSpy } from './store_spy.js'; // detect whether the list of stores PXE uses changed, and whether snapshot test for each element in said list exists. expect.extend({ toMatchFile }); const __dirname = dirname(fileURLToPath(import.meta.url)); -const PRE_DERIVED_MESSAGE_AND_FALLBACK_KEYS_PXE_SCHEMA_VERSION = 9; +// The last schema in which the key store still persisted the message-signing and fallback secret keys. +const PRE_MESSAGE_AND_FALLBACK_SECRET_KEY_REMOVAL_PXE_SCHEMA_VERSION = 10; /** * Asserts that `value` matches the per-store snapshot file `__snapshots__/.json`. Each store gets its own file @@ -156,7 +157,7 @@ async function collectOpenedStores() { * class and compares the resulting bytes to a committed per-store snapshot. */ describe('PXE storage compatibility test suite', () => { - it('resets schema 9 key-store rows that cannot fetch derived message-signing keys', async () => { + it('wipes key-store rows written before the message-signing and fallback secret keys were removed', async () => { const account = AztecAddress.fromStringUnsafe('0x0b3683ee9df3ed6ed7027145bd6093f783b0bb4d8354501d906db7bb8cb58ea3'); const dataDirectory = await mkdtemp(join(tmpdir(), 'pxe-schema-reset-')); const config = { @@ -166,7 +167,11 @@ describe('PXE storage compatibility test suite', () => { }; try { - const oldStore = await createStore('pxe_data', PRE_DERIVED_MESSAGE_AND_FALLBACK_KEYS_PXE_SCHEMA_VERSION, config); + const oldStore = await createStore( + 'pxe_data', + PRE_MESSAGE_AND_FALLBACK_SECRET_KEY_REMOVAL_PXE_SCHEMA_VERSION, + config, + ); try { await oldStore .openMap('key_store') @@ -181,9 +186,8 @@ describe('PXE storage compatibility test suite', () => { const currentStore = await createStore('pxe_data', PXE_DATA_SCHEMA_VERSION, config); try { const keyStore = new KeyStore(currentStore); - if (await keyStore.hasAccount(account)) { - await keyStore.getMasterMessageSigningPublicKey(account); - } + // Opening a below-current-version DB triggers DatabaseVersionManager to wipe it, so the account written under + // the old schema is gone and the new code never reads its now-incompatible rows. await expect(keyStore.hasAccount(account)).resolves.toBe(false); } finally { await currentStore.close(); diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts b/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts index 8a904505bd34..49734a0d4228 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts @@ -15,7 +15,7 @@ import { BlockHash, Body, GENESIS_BLOCK_HEADER_HASH, L2Block } from '@aztec/stdl import { Checkpoint, L1PublishedData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; import { CompleteAddress, SerializableContractInstance } from '@aztec/stdlib/contract'; import { GasFees } from '@aztec/stdlib/gas'; -import { PublicKey, PublicKeys } from '@aztec/stdlib/keys'; +import { PublicKey, PublicKeys, deriveKeys } from '@aztec/stdlib/keys'; import { AppTaggingSecret, ContractClassLog, @@ -255,7 +255,7 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ name: 'KeyStore', writeToStore: async kvStore => { const keyStore = new KeyStore(kvStore); - await keyStore.addAccount(new Fr(2n), new Fr(3n)); + await keyStore.addAccount(await deriveKeys(new Fr(2n)), new Fr(3n)); }, snapshotStore: async kvStore => ({ key_store: await snapshotMap(kvStore.openMap('key_store')), diff --git a/yarn-project/pxe/src/storage/contract_store/contract_store.test.ts b/yarn-project/pxe/src/storage/contract_store/contract_store.test.ts index 4b5253b863b6..c4d36bdfb243 100644 --- a/yarn-project/pxe/src/storage/contract_store/contract_store.test.ts +++ b/yarn-project/pxe/src/storage/contract_store/contract_store.test.ts @@ -1,9 +1,13 @@ import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { BenchmarkingContractArtifact } from '@aztec/noir-test-contracts.js/Benchmarking'; import { TestContractArtifact } from '@aztec/noir-test-contracts.js/Test'; -import { FunctionType } from '@aztec/stdlib/abi'; +import { FunctionSelector, FunctionType } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { SerializableContractInstance, getContractClassFromArtifact } from '@aztec/stdlib/contract'; +import { + SerializableContractInstance, + SerializableContractInstancePreimage, + getContractClassFromArtifact, +} from '@aztec/stdlib/contract'; import { jest } from '@jest/globals'; @@ -35,11 +39,12 @@ describe('ContractStore', () => { ); }); - it('stores a contract instance', async () => { + it('stores a contract instance as its address preimage', async () => { const address = await AztecAddress.random(); const instance = (await SerializableContractInstance.random()).withAddress(address); await contractStore.addContractInstance(instance); - await expect(contractStore.getContractInstance(address)).resolves.toEqual(instance); + const expected = new SerializableContractInstancePreimage(instance).withAddress(address); + await expect(contractStore.getContractInstance(address)).resolves.toEqual(expected); }); it('reconstructs contract class with correct preimage fields', async () => { @@ -61,6 +66,41 @@ describe('ContractStore', () => { } }); + describe('function artifact resolution', () => { + const artifact = BenchmarkingContractArtifact; + + it('returns undefined when the class artifact is not registered', async () => { + const classId = (await getContractClassFromArtifact(artifact)).id; + const selector = await FunctionSelector.fromSignature('not_a_real_function()'); + + await expect(contractStore.getFunctionArtifact(classId, selector)).resolves.toBeUndefined(); + await expect(contractStore.getFunctionArtifactWithDebugMetadata(classId, selector)).resolves.toBeUndefined(); + }); + + it('throws when the selector is absent from a registered artifact', async () => { + const classId = await contractStore.addContractArtifact(artifact); + const missingSelector = await FunctionSelector.fromSignature('not_a_real_function()'); + // Inconsistency: the artifact is present but lacks the selector, so the registered artifact does not match the + // resolved class id. That is not a normal "not found", so it throws rather than returning undefined. + await expect(contractStore.getFunctionArtifact(classId, missingSelector)).rejects.toThrow( + 'does not match the class id', + ); + await expect(contractStore.getFunctionArtifactWithDebugMetadata(classId, missingSelector)).rejects.toThrow( + 'does not match the class id', + ); + }); + + it('returns the function artifact when the selector is present', async () => { + const classId = await contractStore.addContractArtifact(artifact); + const fn = artifact.functions.find(f => f.functionType === FunctionType.PRIVATE)!; + const selector = await FunctionSelector.fromNameAndParameters(fn.name, fn.parameters); + await expect(contractStore.getFunctionArtifact(classId, selector)).resolves.toMatchObject({ + name: fn.name, + contractName: artifact.name, + }); + }); + }); + it('skips KV write on cache hit', async () => { const kvStore = await openTmpStore('contract_store_cache_test'); const store = new ContractStore(kvStore); diff --git a/yarn-project/pxe/src/storage/contract_store/contract_store.ts b/yarn-project/pxe/src/storage/contract_store/contract_store.ts index 5b3e9efdf97f..f58d88e43e45 100644 --- a/yarn-project/pxe/src/storage/contract_store/contract_store.ts +++ b/yarn-project/pxe/src/storage/contract_store/contract_store.ts @@ -6,7 +6,6 @@ import type { MembershipWitness } from '@aztec/foundation/trees'; import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store'; import { type ContractArtifact, - type FunctionAbi, type FunctionArtifactWithContractName, FunctionCall, type FunctionDebugMetadata, @@ -23,8 +22,8 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { type ContractClassIdPreimage, type ContractClassWithId, - type ContractInstanceWithAddress, - SerializableContractInstance, + type ContractInstancePreimageWithAddress, + SerializableContractInstancePreimage, getContractClassFromArtifact, } from '@aztec/stdlib/contract'; @@ -112,9 +111,6 @@ export class ContractStore { // TODO: Update it to be LRU cache so that it doesn't keep all the data all the time. #contractArtifactCache: Map = new Map(); - /** Map from contract address to contract class id (avoids KV round-trip on hot path). */ - #contractClassIdMap: Map = new Map(); - #store: AztecAsyncKVStore; #contractArtifacts: AztecAsyncMap; #contractClassData: AztecAsyncMap; @@ -131,9 +127,10 @@ export class ContractStore { /** * Registers a new contract artifact and its corresponding class data. - * IMPORTANT: This method does not verify that the provided artifact matches the class data or that the class id matches the artifact. - * It is the caller's responsibility to ensure the consistency and correctness of the provided data. - * This is done to avoid redundant, expensive contract class computations + * + * IMPORTANT: This method does not verify that the provided artifact matches the class data or that the class id + * matches the artifact. It is the caller's responsibility to ensure the consistency and correctness of the provided + * data. This is done to avoid redundant, expensive contract class computations. */ public async addContractArtifact( contract: ContractArtifact, @@ -168,31 +165,17 @@ export class ContractStore { return contractClass.id; } - async addContractInstance(contract: ContractInstanceWithAddress): Promise { + async addContractInstance(contract: ContractInstancePreimageWithAddress): Promise { await this.#store.transactionAsync(async () => { await this.#contractInstances.set( contract.address.toString(), - new SerializableContractInstance(contract).toBuffer(), + new SerializableContractInstancePreimage(contract).toBuffer(), ); }); - - this.#contractClassIdMap.set(contract.address.toString(), contract.currentContractClassId); } // Private getters - async #getContractClassId(contractAddress: AztecAddress): Promise { - const key = contractAddress.toString(); - if (!this.#contractClassIdMap.has(key)) { - const instance = await this.getContractInstance(contractAddress); - if (!instance) { - return; - } - this.#contractClassIdMap.set(key, instance.currentContractClassId); - } - return this.#contractClassIdMap.get(key); - } - async #getPrivateFunctionTreeForClassId(classId: Fr): Promise { if (!this.#privateFunctionTrees.has(classId.toString())) { const artifact = await this.getContractArtifact(classId); @@ -205,11 +188,6 @@ export class ContractStore { return this.#privateFunctionTrees.get(classId.toString())!; } - async #getArtifactByAddress(contractAddress: AztecAddress): Promise { - const classId = await this.#getContractClassId(contractAddress); - return classId && this.getContractArtifact(classId); - } - // Public getters getContractsAddresses(): Promise { @@ -219,11 +197,11 @@ export class ContractStore { }); } - /** Returns a contract instance for a given address. */ - public getContractInstance(contractAddress: AztecAddress): Promise { + /** Returns the address preimage of a given address. */ + public getContractInstance(contractAddress: AztecAddress): Promise { return this.#store.transactionAsync(async () => { const contract = await this.#contractInstances.getAsync(contractAddress.toString()); - return contract && SerializableContractInstance.fromBuffer(contract).withAddress(contractAddress); + return contract && SerializableContractInstancePreimage.fromBuffer(contract).withAddress(contractAddress); }); } @@ -262,82 +240,69 @@ export class ContractStore { return { ...classData, packedBytecode }; } - public async getContract( - address: AztecAddress, - ): Promise<(ContractInstanceWithAddress & ContractArtifact) | undefined> { - const instance = await this.getContractInstance(address); - if (!instance) { - return; - } - const artifact = await this.getContractArtifact(instance.currentContractClassId); - if (!artifact) { - return; - } - return { ...instance, ...artifact }; - } - /** - * Retrieves the artifact of a specified function within a given contract. + * Retrieves the artifact of a specified function within a given contract class. * - * @param contractAddress - The AztecAddress representing the contract containing the function. + * @param contractClassId - The id of the contract class containing the function. * @param selector - The function selector. - * @returns The corresponding function's artifact as an object. + * @returns The corresponding function's artifact as an object, or `undefined` if the class is not registered. */ public async getFunctionArtifact( - contractAddress: AztecAddress, + contractClassId: Fr, selector: FunctionSelector, ): Promise { - const artifact = await this.#getArtifactByAddress(contractAddress); + const artifact = await this.getContractArtifact(contractClassId); if (!artifact) { return undefined; } - const fn = await findFunctionArtifactBySelector(artifact, selector); - return fn && { ...fn, contractName: artifact.name }; + const fn = assertSelectorInArtifact(artifact, await findFunctionArtifactBySelector(artifact, selector), { + contractClassId, + selector, + }); + return { ...fn, contractName: artifact.name }; } + /** + * Same as {@link getFunctionArtifact} but with debug metadata attached. Returns `undefined` when the class artifact + * is not registered. + */ public async getFunctionArtifactWithDebugMetadata( - contractAddress: AztecAddress, + contractClassId: Fr, selector: FunctionSelector, - ): Promise { - const artifact = await this.getFunctionArtifact(contractAddress, selector); + ): Promise { + const artifact = await this.getContractArtifact(contractClassId); if (!artifact) { - throw new Error(`Function artifact not found for contract ${contractAddress} and selector ${selector}.`); + return undefined; } - const debug = await this.getFunctionDebugMetadata(contractAddress, selector); + const fn = assertSelectorInArtifact(artifact, await findFunctionArtifactBySelector(artifact, selector), { + contractClassId, + selector, + }); return { - ...artifact, - debug, + ...fn, + contractName: artifact.name, + debug: getFunctionDebugMetadata(artifact, fn), }; } - public async getPublicFunctionArtifact( - contractAddress: AztecAddress, - ): Promise { - const artifact = await this.#getArtifactByAddress(contractAddress); + public async getPublicFunctionArtifact(contractClassId: Fr): Promise { + const artifact = await this.getContractArtifact(contractClassId); const fn = artifact && artifact.functions.find(f => f.functionType === FunctionType.PUBLIC); return fn && { ...fn, contractName: artifact.name }; } - public async getFunctionAbi( - contractAddress: AztecAddress, - selector: FunctionSelector, - ): Promise { - const artifact = await this.#getArtifactByAddress(contractAddress); - return artifact && (await findFunctionAbiBySelector(artifact, selector)); - } - /** - * Retrieves the debug metadata of a specified function within a given contract. + * Retrieves the debug metadata of a specified function within a given contract class. * - * @param contractAddress - The AztecAddress representing the contract containing the function. + * @param contractClassId - The id of the contract class containing the function. * @param selector - The function selector. * @returns The corresponding function's debug metadata, or undefined. */ public async getFunctionDebugMetadata( - contractAddress: AztecAddress, + contractClassId: Fr, selector: FunctionSelector, ): Promise { - const artifact = await this.#getArtifactByAddress(contractAddress); + const artifact = await this.getContractArtifact(contractClassId); if (!artifact) { return undefined; } @@ -345,10 +310,8 @@ export class ContractStore { return fn && getFunctionDebugMetadata(artifact, fn); } - public async getPublicFunctionDebugMetadata( - contractAddress: AztecAddress, - ): Promise { - const artifact = await this.#getArtifactByAddress(contractAddress); + public async getPublicFunctionDebugMetadata(contractClassId: Fr): Promise { + const artifact = await this.getContractArtifact(contractClassId); const fn = artifact && artifact.functions.find(f => f.functionType === FunctionType.PUBLIC); return fn && getFunctionDebugMetadata(artifact, fn); } @@ -368,28 +331,34 @@ export class ContractStore { return tree?.getFunctionMembershipWitness(selector); } - public async getDebugContractName(contractAddress: AztecAddress) { - const artifact = await this.#getArtifactByAddress(contractAddress); + public async getDebugContractName(contractClassId: Fr) { + const artifact = await this.getContractArtifact(contractClassId); return artifact?.name; } - public async getDebugFunctionName(contractAddress: AztecAddress, selector: FunctionSelector) { - const artifact = await this.#getArtifactByAddress(contractAddress); + public async getDebugFunctionName(contractClassId: Fr, selector: FunctionSelector) { + const artifact = await this.getContractArtifact(contractClassId); const fn = artifact && (await findFunctionAbiBySelector(artifact, selector)); - return `${artifact?.name ?? contractAddress}:${fn?.name ?? selector}`; + return `${artifact?.name ?? contractClassId}:${fn?.name ?? selector}`; } - public async getFunctionCall(functionName: string, args: any[], to: AztecAddress): Promise { - const contract = await this.getContract(to); - if (!contract) { + public async getFunctionCall( + functionName: string, + args: any[], + to: AztecAddress, + contractClassId: Fr, + ): Promise { + const artifact = await this.getContractArtifact(contractClassId); + if (!artifact) { throw new Error( - `Unknown contract ${to}: register it by calling wallet.registerContract(...).\nSee docs for context: https://docs.aztec.network/errors/14`, + `No artifact registered for contract class ${contractClassId} (contract ${to}): register it by calling ` + + `wallet.registerContract(...).\nSee docs for context: https://docs.aztec.network/errors/14`, ); } - const functionDao = contract.functions.find(f => f.name === functionName); + const functionDao = artifact.functions.find(f => f.name === functionName); if (!functionDao) { - throw new Error(`Unknown function ${functionName} in contract ${contract.name}.`); + throw new Error(`Unknown function ${functionName} in contract ${artifact.name}.`); } const selector = await FunctionSelector.fromNameAndParameters(functionDao.name, functionDao.parameters); @@ -406,3 +375,27 @@ export class ContractStore { }); } } + +/** + * Narrows the result of a selector lookup against a registered artifact. A missing class artifact is reported as + * `undefined` by the callers (an absence they can handle), but a selector that is absent from an artifact we *do* have + * is exceptional and throws rather than returning undefined. We cannot tell from here why the selector is missing: it + * may be that no such function exists in the contract (a wrong selector), or that the registered artifact does not + * correspond to the resolved class id (registration performs no validation, so a mismatched artifact is possible). The + * error names both possibilities. + */ +function assertSelectorInArtifact( + artifact: ContractArtifact, + fn: T | undefined, + context: { contractClassId: Fr; selector: FunctionSelector }, +): T { + if (!fn) { + throw new Error( + `Function with selector ${context.selector} not found in the registered artifact for contract class ` + + `${context.contractClassId} (${artifact.name}). Either no function with this selector exists in the ` + + `contract, or the registered artifact does not match the class id (re-register it via ` + + `wallet.registerContract(...)).`, + ); + } + return fn; +} diff --git a/yarn-project/pxe/src/storage/metadata.ts b/yarn-project/pxe/src/storage/metadata.ts index 0e14087c71aa..7a2e20431042 100644 --- a/yarn-project/pxe/src/storage/metadata.ts +++ b/yarn-project/pxe/src/storage/metadata.ts @@ -1 +1 @@ -export const PXE_DATA_SCHEMA_VERSION = 10; +export const PXE_DATA_SCHEMA_VERSION = 11; diff --git a/yarn-project/standard-contracts/src/standard_contract_data.ts b/yarn-project/standard-contracts/src/standard_contract_data.ts index ca01c4fe5de8..98960df510f9 100644 --- a/yarn-project/standard-contracts/src/standard_contract_data.ts +++ b/yarn-project/standard-contracts/src/standard_contract_data.ts @@ -20,21 +20,21 @@ export const StandardContractSalt: Record = { }; export const StandardContractAddress: Record = { - AuthRegistry: AztecAddress.fromStringUnsafe('0x186fbbe15f50d01d67c471d5ed8b8be43b8826e77147cb0084302804e336e5d2'), + AuthRegistry: AztecAddress.fromStringUnsafe('0x0e7d3c56a185c40e4ce459eee03075b7dee2e9dc8f860157063afb3fde5ce097'), MultiCallEntrypoint: AztecAddress.fromStringUnsafe( - '0x10a91e72a359e7441d4af9e418f14862abe994348970909d484ff2dad1594a58', + '0x03264f902d92f27dd0719cd6f5cc9c9d03ec6f5a48482e2158ebc2886e997210', ), - PublicChecks: AztecAddress.fromStringUnsafe('0x1f6f565db58df81c2bbb8e9932fa92d2d1f537887a06e2cb965184f77a5235d2'), + PublicChecks: AztecAddress.fromStringUnsafe('0x05b4a7bf960bac46cc0c22aa6ee5b663a928787c9e7410fcf99e78182f634c0b'), HandshakeRegistry: AztecAddress.fromStringUnsafe( - '0x18233660a8797d74f63318c4223332e23b63f570da11fb142993ebc802a96728', + '0x26f11691c7efea98db7eb7b4460cbc3f75db19772db2c2dbb8978979bcc5e388', ), }; export const StandardContractClassId: Record = { - AuthRegistry: Fr.fromString('0x1684ac4f8c250f8ecc1c7629a344fa36002f5130d3b1bbe6e2c0e73e14640f0a'), - MultiCallEntrypoint: Fr.fromString('0x003a615f16c5401ef184876baf2aa54f67ff693672a03134cc8920cfb58957c7'), - PublicChecks: Fr.fromString('0x086e79a9008498c03a83dd1e9b69b6cbe8d1fafe31b2a775d0b6610dac7d681c'), - HandshakeRegistry: Fr.fromString('0x1007fb490420810f54215128260e808c7ab2b1dc4c1801156833d4720696bf31'), + AuthRegistry: Fr.fromString('0x26418de27e7959352849f02436843dbdd033d69fa51a4bc8a60833c8b6fca502'), + MultiCallEntrypoint: Fr.fromString('0x19770ce1522e502ad5bcbc28e4d4affe96d1eeed61f366c87bc1e7214e44a567'), + PublicChecks: Fr.fromString('0x014a933a6759d143181575cf8bb9092f297ad9f6dceef20d5b0ba61369456a9b'), + HandshakeRegistry: Fr.fromString('0x187a5ae492f5eaf202d69290eb70b1f07315160434ba816a1ce22627bad4b6a1'), }; export const StandardContractClassIdPreimage: Record< @@ -42,23 +42,23 @@ export const StandardContractClassIdPreimage: Record< { artifactHash: Fr; privateFunctionsRoot: Fr; publicBytecodeCommitment: Fr } > = { AuthRegistry: { - artifactHash: Fr.fromString('0x175607a9b31428948f4041db7d4e1667492292fb7850f8138c1d3cf5ffde8b34'), + artifactHash: Fr.fromString('0x1d2d505dc0a133d307dd6e121f9adda51718c5c61bf9239a277e3745897da935'), privateFunctionsRoot: Fr.fromString('0x17b584350f4c3ccafd8f688729afb9feab8976114fb40012e9dee65022c072a4'), publicBytecodeCommitment: Fr.fromString('0x2545f39893766508ce37bb5cea5e4dcab04c6f7f79f3089b1c076876e9d268b2'), }, MultiCallEntrypoint: { - artifactHash: Fr.fromString('0x23af448187164c7d5e6f392346958e688690370515359de8909f3f05f2e26760'), + artifactHash: Fr.fromString('0x1516cf4369b08c199f52a71fa2205bd13c51a53759f4a96571c48310bf026592'), privateFunctionsRoot: Fr.fromString('0x0e68dfbb256e80b08b3aef47aca1f2669e97a9c6259787893c1223ac083ad5d5'), publicBytecodeCommitment: Fr.fromString('0x0ce4c618c3ed7f3a20410e618c06bb701e150af7fe28a3e92f68e7733809f33e'), }, PublicChecks: { - artifactHash: Fr.fromString('0x2f3869d9d89bf7b428da7f8c620aea11acc022cc0c7222544cc841e5ceedd8dc'), + artifactHash: Fr.fromString('0x04bdf54dbb0a90ccdd5659ad29b1b8206724e651c1d60f8046a0771496d26211'), privateFunctionsRoot: Fr.fromString('0x202860adb1b8975971eeaf571aaaa88a27f4035290d58532ae7d60b0dfaad54c'), publicBytecodeCommitment: Fr.fromString('0x013c4f854a5c87c9daf86c5f9bc07a42c2a061f1d924a5b3564ec7edc8e18cb7'), }, HandshakeRegistry: { - artifactHash: Fr.fromString('0x080bd5c37240f9425fea5c87b844a67ffda312dbf58c58288b7ba6b4d5db7663'), - privateFunctionsRoot: Fr.fromString('0x14fed008fa16ae6e45b7561aa0101d0d97953e2fd86caf7aaa603f14807797a8'), + artifactHash: Fr.fromString('0x1c4cdc2834480eed826aeece9b85ffb7bdfe751a01bc76220e3fa12527a9c932'), + privateFunctionsRoot: Fr.fromString('0x02a4dba36389845b8ef0108562f7536d3284f07ca678558fc3c3bce3b24ee821'), publicBytecodeCommitment: Fr.fromString('0x0ce4c618c3ed7f3a20410e618c06bb701e150af7fe28a3e92f68e7733809f33e'), }, }; @@ -92,11 +92,17 @@ export const StandardContractPrivateFunctions: Record< ], PublicChecks: [], HandshakeRegistry: [ + { + selector: FunctionSelector.fromField( + Fr.fromString('0x0000000000000000000000000000000000000000000000000000000019f8b409'), + ), + vkHash: Fr.fromString('0x25d87da668c7c52b9c521579f191bb02486b3598d1ec0063927d5c0af2608daf'), + }, { selector: FunctionSelector.fromField( Fr.fromString('0x00000000000000000000000000000000000000000000000000000000db548fcf'), ), - vkHash: Fr.fromString('0x238417677997f9e919cd828f7bd156701e447f3d7c8881586f2277df607036d5'), + vkHash: Fr.fromString('0x0f59fe525df9ff4a400e33dec33fd1dc07e25d95f860734a35b909085cf07809'), }, { selector: FunctionSelector.fromField( diff --git a/yarn-project/stdlib/src/contract/contract_address.ts b/yarn-project/stdlib/src/contract/contract_address.ts index 8511fda5cb9d..5a6ff4462b50 100644 --- a/yarn-project/stdlib/src/contract/contract_address.ts +++ b/yarn-project/stdlib/src/contract/contract_address.ts @@ -6,7 +6,7 @@ import { type FunctionAbi, FunctionSelector, encodeArguments } from '../abi/inde import type { AztecAddress } from '../aztec-address/index.js'; import { computeVarArgsHash } from '../hash/hash.js'; import { computeAddress } from '../keys/index.js'; -import type { ContractInstance } from './interfaces/contract_instance.js'; +import type { ContractInstance, ContractInstancePreimage } from './interfaces/contract_instance.js'; import type { PartialAddress } from './partial_address.js'; // TODO(@spalladino): Review all generator indices in this file @@ -22,7 +22,7 @@ import type { PartialAddress } from './partial_address.js'; */ export async function computeContractAddressFromInstance( instance: - | ContractInstance + | ContractInstancePreimage | ({ originalContractClassId: Fr; saltedInitializationHash: Fr } & Pick), ): Promise { const partialAddress = await computePartialAddress(instance); diff --git a/yarn-project/stdlib/src/contract/contract_instance.test.ts b/yarn-project/stdlib/src/contract/contract_instance.test.ts index 2fbab0a8c70b..5506dcccd59a 100644 --- a/yarn-project/stdlib/src/contract/contract_instance.test.ts +++ b/yarn-project/stdlib/src/contract/contract_instance.test.ts @@ -1,4 +1,4 @@ -import { SerializableContractInstance } from './contract_instance.js'; +import { SerializableContractInstance, SerializableContractInstancePreimage } from './contract_instance.js'; describe('ContractInstance', () => { it('can serialize and deserialize an instance', async () => { @@ -6,3 +6,11 @@ describe('ContractInstance', () => { expect(SerializableContractInstance.fromBuffer(instance.toBuffer())).toEqual(instance); }); }); + +describe('ContractInstancePreimage', () => { + it('round-trips the preimage layout', async () => { + const instance = await SerializableContractInstance.random(); + const preimage = new SerializableContractInstancePreimage(instance); + expect(SerializableContractInstancePreimage.fromBuffer(preimage.toBuffer())).toEqual(preimage); + }); +}); diff --git a/yarn-project/stdlib/src/contract/contract_instance.ts b/yarn-project/stdlib/src/contract/contract_instance.ts index 7be8330eeea5..139d5c5ef101 100644 --- a/yarn-project/stdlib/src/contract/contract_instance.ts +++ b/yarn-project/stdlib/src/contract/contract_instance.ts @@ -18,7 +18,12 @@ import { computeInitializationHash, computeInitializationHashFromEncodedArgs, } from './contract_address.js'; -import type { ContractInstance, ContractInstanceWithAddress } from './interfaces/contract_instance.js'; +import type { + ContractInstance, + ContractInstancePreimage, + ContractInstancePreimageWithAddress, + ContractInstanceWithAddress, +} from './interfaces/contract_instance.js'; const VERSION = 2 as const; @@ -115,6 +120,62 @@ export class SerializableContractInstance { } } +export class SerializableContractInstancePreimage { + public readonly version = VERSION; + public readonly salt: Fr; + public readonly deployer: AztecAddress; + public readonly originalContractClassId: Fr; + public readonly initializationHash: Fr; + public readonly immutablesHash: Fr; + public readonly publicKeys: PublicKeys; + + constructor(instance: ContractInstancePreimage) { + if (instance.version !== VERSION) { + throw new Error(`Unexpected contract class version ${instance.version}`); + } + this.salt = instance.salt; + this.deployer = instance.deployer; + this.originalContractClassId = instance.originalContractClassId; + this.initializationHash = instance.initializationHash; + this.immutablesHash = instance.immutablesHash; + this.publicKeys = instance.publicKeys; + } + + public toBuffer() { + return serializeToBuffer( + numToUInt8(this.version), + this.salt, + this.deployer, + this.originalContractClassId, + this.initializationHash, + this.immutablesHash, + this.publicKeys, + ); + } + + /** Returns a copy of this object with its address included. */ + withAddress(address: AztecAddress): ContractInstancePreimageWithAddress { + return { ...this, address }; + } + + static fromBuffer(bufferOrReader: Buffer | BufferReader): SerializableContractInstancePreimage { + const reader = BufferReader.asReader(bufferOrReader); + const version = reader.readUInt8(); + if (version !== VERSION) { + throw new Error(`Unexpected contract instance preimage version ${version}`); + } + return new SerializableContractInstancePreimage({ + version: VERSION, + salt: reader.readObject(Fr), + deployer: reader.readObject(AztecAddress), + originalContractClassId: reader.readObject(Fr), + initializationHash: reader.readObject(Fr), + immutablesHash: reader.readObject(Fr), + publicKeys: reader.readObject(PublicKeys), + }); + } +} + /** * Generates a Contract Instance from some instantiation params. * @param artifact - The account contract build artifact. diff --git a/yarn-project/stdlib/src/contract/interfaces/contract_instance.ts b/yarn-project/stdlib/src/contract/interfaces/contract_instance.ts index 2d992dbac99c..86a537abde76 100644 --- a/yarn-project/stdlib/src/contract/interfaces/contract_instance.ts +++ b/yarn-project/stdlib/src/contract/interfaces/contract_instance.ts @@ -9,19 +9,19 @@ import { schemas, zodFor } from '../../schemas/index.js'; const VERSION = 2 as const; /** - * A contract instance is a concrete deployment of a contract class. It always references a contract class, - * which dictates what code it executes when called. It has state (both private and public), as well as an - * address that acts as its identifier. It can be called into. It may have encryption and nullifying public keys. + * The address preimage of a contract instance: the immutable data that hashes to its address, i.e. everything a + * contract deployment commits to. + * + * Note that `originaContractClassId` is not necessarily the instance's _current_ class id, in case of upgrades: that + * one is not part of the preimage and is instead derived from chain state. */ -export interface ContractInstance { +export interface ContractInstancePreimage { /** Version identifier. Initially one, bumped for any changes to the contract instance struct. */ version: typeof VERSION; /** User-generated pseudorandom value for uniqueness. */ salt: Fr; /** Optional deployer address or zero if this was a universal deploy. */ deployer: AztecAddress; - /** Identifier of the contract class for this instance. */ - currentContractClassId: Fr; /** Identifier of the original (at deployment) contract class for this instance */ originalContractClassId: Fr; /** Hash of the selector and arguments to the constructor. */ @@ -32,14 +32,24 @@ export interface ContractInstance { publicKeys: PublicKeys; } +/** + * The address preimage plus the current contract class id, which is chain-tracked state derived from the + * ContractInstanceRegistry (it equals the original class id unless the contract has been upgraded). + */ +export interface ContractInstance extends ContractInstancePreimage { + /** Identifier of the contract class this instance currently runs (as of some block). */ + currentContractClassId: Fr; +} + +export type ContractInstancePreimageWithAddress = ContractInstancePreimage & { address: AztecAddress }; + export type ContractInstanceWithAddress = ContractInstance & { address: AztecAddress }; -export const ContractInstanceSchema = zodFor()( +export const ContractInstancePreimageSchema = zodFor()( z.object({ version: z.literal(VERSION), salt: schemas.Fr, deployer: schemas.AztecAddress, - currentContractClassId: schemas.Fr, originalContractClassId: schemas.Fr, initializationHash: schemas.Fr, immutablesHash: schemas.Fr, @@ -47,6 +57,14 @@ export const ContractInstanceSchema = zodFor()( }), ); +export const ContractInstanceSchema = zodFor()( + ContractInstancePreimageSchema.and(z.object({ currentContractClassId: schemas.Fr })), +); + +export const ContractInstancePreimageWithAddressSchema = zodFor()( + ContractInstancePreimageSchema.and(z.object({ address: schemas.AztecAddress })), +); + export const ContractInstanceWithAddressSchema = zodFor()( ContractInstanceSchema.and(z.object({ address: schemas.AztecAddress })), ); diff --git a/yarn-project/stdlib/src/keys/derivation.ts b/yarn-project/stdlib/src/keys/derivation.ts index 9437e5d36d75..d15aaf8b281a 100644 --- a/yarn-project/stdlib/src/keys/derivation.ts +++ b/yarn-project/stdlib/src/keys/derivation.ts @@ -11,8 +11,11 @@ import { PublicKey, hashPublicKey } from './public_key.js'; import { PublicKeys } from './public_keys.js'; import { getKeyGenerator } from './utils.js'; -export function computeAppNullifierHidingKey(masterNullifierHidingKey: GrumpkinScalar, app: AztecAddress): Promise { - return computeAppSecretKey(masterNullifierHidingKey, app, 'n'); // 'n' is the key prefix for nullifier hiding key +export function computeAppNullifierHidingKey( + masterNullifierHidingSecretKey: GrumpkinScalar, + app: AztecAddress, +): Promise { + return computeAppSecretKey(masterNullifierHidingSecretKey, app, 'n'); // 'n' is the key prefix for nullifier hiding key } export function computeAppSecretKey(skM: GrumpkinScalar, app: AztecAddress, keyPrefix: KeyPrefix): Promise { @@ -27,7 +30,7 @@ export async function computeOvskApp(ovsk: GrumpkinScalar, app: AztecAddress): P return GrumpkinScalar.fromBuffer(ovskAppFr.toBuffer()); } -export function deriveMasterNullifierHidingKey(secretKey: Fr): GrumpkinScalar { +export function deriveMasterNullifierHidingSecretKey(secretKey: Fr): GrumpkinScalar { return sha512ToGrumpkinScalar([secretKey, DomainSeparator.NHK_M]); } @@ -93,6 +96,11 @@ export async function computeAddressSecret(preaddress: Fr, ivsk: Fq) { } export function derivePublicKeyFromSecretKey(secretKey: Fq): Promise { + // 0 * G is the point at infinity. The WASM encodes infinity with an out-of-field x coordinate that Point cannot + // deserialize, so return the point directly instead of calling into it. + if (secretKey.isZero()) { + return Promise.resolve(PublicKey.INFINITY); + } return Grumpkin.mul(Grumpkin.generator, secretKey); } @@ -100,7 +108,7 @@ export function derivePublicKeyFromSecretKey(secretKey: Fq): Promise * The six master secret keys that fully define an account's privacy keys. */ export type MasterSecretKeys = { - masterNullifierHidingKey: GrumpkinScalar; + masterNullifierHidingSecretKey: GrumpkinScalar; masterIncomingViewingSecretKey: GrumpkinScalar; masterOutgoingViewingSecretKey: GrumpkinScalar; masterTaggingSecretKey: GrumpkinScalar; @@ -117,7 +125,7 @@ export function deriveKeys(secretKey: Fr) { // First we derive master secret/hiding keys - we use sha512 here because this derivation will never take place // in a circuit return deriveKeysFromMasterSecretKeys({ - masterNullifierHidingKey: deriveMasterNullifierHidingKey(secretKey), + masterNullifierHidingSecretKey: deriveMasterNullifierHidingSecretKey(secretKey), masterIncomingViewingSecretKey: deriveMasterIncomingViewingSecretKey(secretKey), masterOutgoingViewingSecretKey: deriveMasterOutgoingViewingSecretKey(secretKey), masterTaggingSecretKey: sha512ToGrumpkinScalar([secretKey, DomainSeparator.TSK_M]), @@ -133,7 +141,7 @@ export function deriveKeys(secretKey: Fr) { */ export async function deriveKeysFromMasterSecretKeys(secretKeys: MasterSecretKeys) { const { - masterNullifierHidingKey, + masterNullifierHidingSecretKey, masterIncomingViewingSecretKey, masterOutgoingViewingSecretKey, masterTaggingSecretKey, @@ -141,7 +149,7 @@ export async function deriveKeysFromMasterSecretKeys(secretKeys: MasterSecretKey masterFallbackSecretKey, } = secretKeys; - const masterNullifierPublicKey = await derivePublicKeyFromSecretKey(masterNullifierHidingKey); + const masterNullifierHidingPublicKey = await derivePublicKeyFromSecretKey(masterNullifierHidingSecretKey); const masterIncomingViewingPublicKey = await derivePublicKeyFromSecretKey(masterIncomingViewingSecretKey); const masterOutgoingViewingPublicKey = await derivePublicKeyFromSecretKey(masterOutgoingViewingSecretKey); const masterTaggingPublicKey = await derivePublicKeyFromSecretKey(masterTaggingSecretKey); @@ -153,7 +161,7 @@ export async function deriveKeysFromMasterSecretKeys(secretKeys: MasterSecretKey // store can persist them under `${account}-{n|ov|t|ms|fb}pk_m` (only their hashes live in publicKeys). // The ivpk_m point isn't returned separately because it already lives in publicKeys.ivpkM. const publicKeys = new PublicKeys( - await hashPublicKey(masterNullifierPublicKey), + await hashPublicKey(masterNullifierHidingPublicKey), masterIncomingViewingPublicKey, await hashPublicKey(masterOutgoingViewingPublicKey), await hashPublicKey(masterTaggingPublicKey), @@ -162,13 +170,13 @@ export async function deriveKeysFromMasterSecretKeys(secretKeys: MasterSecretKey ); return { - masterNullifierHidingKey, + masterNullifierHidingSecretKey, masterIncomingViewingSecretKey, masterOutgoingViewingSecretKey, masterTaggingSecretKey, masterMessageSigningSecretKey, masterFallbackSecretKey, - masterNullifierPublicKey, + masterNullifierHidingPublicKey, masterOutgoingViewingPublicKey, masterTaggingPublicKey, masterMessageSigningPublicKey, diff --git a/yarn-project/txe/src/bin/oracle_test_server.ts b/yarn-project/txe/src/bin/oracle_test_server.ts index cab50880235f..149fa20a40db 100644 --- a/yarn-project/txe/src/bin/oracle_test_server.ts +++ b/yarn-project/txe/src/bin/oracle_test_server.ts @@ -6,7 +6,7 @@ import { createOracleTestRpcServer } from '../oracle/test-resolver/index.js'; /** * Starts an HTTP RPC server that resolves oracle foreign calls using auto-synthesized fixture scenarios. Used by - * `nargo test --oracle-resolver` to run `#[auto_serialization_test]` serialization tests against a dedicated resolver. + * `nargo test --oracle-resolver` to run `#[generate_oracle_tests]` serialization tests against a dedicated resolver. * Logs fixture coverage on shutdown. */ async function main() { diff --git a/yarn-project/txe/src/oracle/interfaces.ts b/yarn-project/txe/src/oracle/interfaces.ts index be74010ecf3f..284a348bea4a 100644 --- a/yarn-project/txe/src/oracle/interfaces.ts +++ b/yarn-project/txe/src/oracle/interfaces.ts @@ -43,7 +43,7 @@ export interface IAvmExecutionOracle { getContractInstanceClassId(address: AztecAddress): Promise<{ member: Fr; exists: boolean }>; getContractInstanceInitializationHash(address: AztecAddress): Promise<{ member: Fr; exists: boolean }>; getContractInstanceImmutablesHash(address: AztecAddress): Promise<{ member: Fr; exists: boolean }>; - returndataSize(): Promise; + returndataSize(): Promise; returndataCopy(rdOffset: number, copySize: number): Promise; call(l2Gas: number, daGas: number, address: AztecAddress, argsLength: number, args: Fr[]): Promise; staticCall(l2Gas: number, daGas: number, address: AztecAddress, argsLength: number, args: Fr[]): Promise; diff --git a/yarn-project/txe/src/oracle/test-resolver/default_fixtures.ts b/yarn-project/txe/src/oracle/test-resolver/default_fixtures.ts index d1faca753e9a..002d980618ad 100644 --- a/yarn-project/txe/src/oracle/test-resolver/default_fixtures.ts +++ b/yarn-project/txe/src/oracle/test-resolver/default_fixtures.ts @@ -27,7 +27,7 @@ import type { OracleTestScenario } from './resolver.js'; /** * Synthesizes fixture scenarios for every oracle in the registry whose parameters and return are built from scalars, * arrays/bounded vecs of them, or `Option`s of them. The matching Noir serialization tests are auto-generated by - * `#[auto_serialization_test]` from the same seed/scenario convention. + * `#[generate_oracle_tests]` from the same seed/scenario convention. */ export function synthesizeDefaultFixtures( registry: Record, diff --git a/yarn-project/txe/src/oracle/test-resolver/resolver.ts b/yarn-project/txe/src/oracle/test-resolver/resolver.ts index 511c0513fdae..acc959004a5d 100644 --- a/yarn-project/txe/src/oracle/test-resolver/resolver.ts +++ b/yarn-project/txe/src/oracle/test-resolver/resolver.ts @@ -53,7 +53,7 @@ export class OracleTestResolver { /** * Builds a resolver whose fixtures are the default scenarios synthesized from the registry's type mappings. The - * matching Noir serialization tests are auto-generated by `#[auto_serialization_test]` from the same convention. + * matching Noir serialization tests are auto-generated by `#[generate_oracle_tests]` from the same convention. */ static fromRegistry(registry: Record, logger?: Logger): OracleTestResolver { return new OracleTestResolver(registry, synthesizeDefaultFixtures(registry), logger); diff --git a/yarn-project/txe/src/oracle/txe_oracle_public_context.ts b/yarn-project/txe/src/oracle/txe_oracle_public_context.ts index 67fc7ecf6efc..a0786c6f1f28 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_public_context.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_public_context.ts @@ -5,7 +5,7 @@ import type { ContractStore } from '@aztec/pxe/server'; import { PublicDataWrite } from '@aztec/stdlib/avm'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { L2Block } from '@aztec/stdlib/block'; -import type { ContractInstanceWithAddress } from '@aztec/stdlib/contract'; +import type { ContractInstancePreimageWithAddress } from '@aztec/stdlib/contract'; import { computePublicDataTreeLeafSlot, siloNoteHash, siloNullifier } from '@aztec/stdlib/hash'; import { MerkleTreeId, @@ -130,7 +130,8 @@ export class TXEOraclePublicContext implements IAvmExecutionOracle { } getContractInstanceClassId(address: AztecAddress): Promise<{ member: Fr; exists: boolean }> { - return this.getContractInstanceMember(address, i => i.currentContractClassId); + // TXE has no contract updates, so the current class always equals the original. + return this.getContractInstanceMember(address, i => i.originalContractClassId); } getContractInstanceInitializationHash(address: AztecAddress): Promise<{ member: Fr; exists: boolean }> { @@ -143,7 +144,7 @@ export class TXEOraclePublicContext implements IAvmExecutionOracle { private async getContractInstanceMember( address: AztecAddress, - accessor: (instance: ContractInstanceWithAddress) => Fr, + accessor: (instance: ContractInstancePreimageWithAddress) => Fr, ): Promise<{ member: Fr; exists: boolean }> { const instance = await this.contractStore.getContractInstance(address); if (!instance) { @@ -152,7 +153,7 @@ export class TXEOraclePublicContext implements IAvmExecutionOracle { return { member: accessor(instance), exists: true }; } - returndataSize(): Promise { + returndataSize(): Promise { throw new Error( 'Contract calls are forbidden inside a `TestEnvironment::public_context`, use `public_call` instead', ); diff --git a/yarn-project/txe/src/oracle/txe_oracle_registry.ts b/yarn-project/txe/src/oracle/txe_oracle_registry.ts index c6ad7ee96425..3e1d90509638 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_registry.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_registry.ts @@ -414,7 +414,7 @@ export const TXE_ORACLE_REGISTRY = { params: [{ name: 'message', type: ARRAY(FIELD) }], }), - aztec_avm_returndataSize: makeEntry({ returnType: FIELD }), + aztec_avm_returndataSize: makeEntry({ returnType: U32 }), aztec_avm_returndataCopy: makeEntry({ params: [ 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 69e415f999d1..9c86619698ab 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 @@ -12,6 +12,7 @@ import { TestDateProvider } from '@aztec/foundation/timer'; import type { KeyStore } from '@aztec/key-store'; import { AddressStore, + AnchoredContractData, CapsuleService, CapsuleStore, type ContractStore, @@ -70,7 +71,7 @@ import { PrivateToPublicAccumulatedData, PublicCallRequest, } from '@aztec/stdlib/kernel'; -import { hashPublicKey } from '@aztec/stdlib/keys'; +import { deriveKeys, hashPublicKey } from '@aztec/stdlib/keys'; import type { PrivateLog } from '@aztec/stdlib/logs'; import { L1Actor, L1ToL2Message, L2Actor } from '@aztec/stdlib/messaging'; import { ChonkProof } from '@aztec/stdlib/proofs'; @@ -324,7 +325,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl await this.contractStore.addContractInstance(instance); await this.contractStore.addContractArtifact(artifact); - const completeAddress = await this.keyStore.addAccount(secret, partialAddress); + const completeAddress = await this.keyStore.addAccount(await deriveKeys(secret), partialAddress); await this.accountStore.setAccount(completeAddress.address, completeAddress); await this.addressStore.addCompleteAddress(completeAddress); this.logger.debug(`Created account ${completeAddress.address}`); @@ -334,7 +335,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl async createAccount(secret: Fr) { // This is a foot gun ! - const completeAddress = await this.keyStore.addAccount(secret, secret); + const completeAddress = await this.keyStore.addAccount(await deriveKeys(secret), secret); await this.accountStore.setAccount(completeAddress.address, completeAddress); await this.addressStore.addCompleteAddress(completeAddress); this.logger.debug(`Created account ${completeAddress.address}`); @@ -416,11 +417,18 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl authorizedUtilityCallTargets: AztecAddress[], gasSettings: GasSettings, ) { + const blockHeader = await this.stateMachine.anchorBlockStore.getBlockHeader(); + const anchoredContractData = new AnchoredContractData( + this.contractStore, + this.stateMachine.contractClassService, + blockHeader, + ); + this.logger.verbose( - `Executing external function ${await this.contractStore.getDebugFunctionName(targetContractAddress, functionSelector)}@${targetContractAddress} isStaticCall=${isStaticCall}`, + `Executing external function ${await anchoredContractData.getDebugFunctionName(targetContractAddress, functionSelector)}@${targetContractAddress} isStaticCall=${isStaticCall}`, ); - const artifact = await this.contractStore.getFunctionArtifact(targetContractAddress, functionSelector); + const artifact = await anchoredContractData.getFunctionArtifact(targetContractAddress, functionSelector); if (!artifact) { const message = functionSelector.equals(await FunctionSelector.fromSignature('verify_private_authwit(Field)')) ? 'Found no account contract artifact for a private authwit check - use `create_contract_account` instead of `create_light_account` for authwit support.' @@ -435,7 +443,6 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl await this.executeUtilityCall(call, { scopes: execScopes, jobId }); }; - const blockHeader = await this.stateMachine.anchorBlockStore.getBlockHeader(); await this.stateMachine.contractSyncService.ensureContractSynced( targetContractAddress, functionSelector, @@ -476,7 +483,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl executionCache: HashedValuesCache.create([new HashedValues(args, argsHash)]), noteCache, taggingIndexCache, - contractStore: this.contractStore, + anchoredContractData, noteStore: this.noteStore, keyStore: this.keyStore, addressStore: this.addressStore, @@ -547,7 +554,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl // We pass the non-zero minRevertibleSideEffectCounter to make sure the side effects are split correctly. const { publicInputs } = await generateSimulatedProvingResult( result, - (addr, sel) => this.contractStore.getDebugFunctionName(addr, sel), + (addr, sel) => anchoredContractData.getDebugFunctionName(addr, sel), this.stateMachine.node, minRevertibleSideEffectCounter, ); @@ -606,7 +613,13 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl } else if (!processedTx.revertCode.isOK()) { if (processedTx.revertReason) { try { - await enrichPublicSimulationError(processedTx.revertReason, this.contractStore, this.logger); + await enrichPublicSimulationError( + processedTx.revertReason, + this.contractStore, + this.stateMachine.contractClassService, + await this.stateMachine.anchorBlockStore.getBlockHeader(), + this.logger, + ); // eslint-disable-next-line no-empty } catch {} throw new Error(`Contract execution has reverted: ${processedTx.revertReason.getMessage()}`); @@ -657,16 +670,21 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl isStaticCall: boolean, gasSettings: GasSettings, ) { + const anchorBlockHeader = await this.stateMachine.anchorBlockStore.getBlockHeader(); + const anchoredContractData = new AnchoredContractData( + this.contractStore, + this.stateMachine.contractClassService, + anchorBlockHeader, + ); + this.logger.verbose( - `Executing public function ${await this.contractStore.getDebugFunctionName(targetContractAddress, FunctionSelector.fromField(calldata[0]))}@${targetContractAddress} isStaticCall=${isStaticCall}`, + `Executing public function ${await anchoredContractData.getDebugFunctionName(targetContractAddress, FunctionSelector.fromField(calldata[0]))}@${targetContractAddress} isStaticCall=${isStaticCall}`, ); const blockNumber = await this.getNextBlockNumber(); const txContext = new TxContext(this.chainId, this.version, gasSettings); - const anchorBlockHeader = await this.stateMachine.anchorBlockStore.getBlockHeader(); - const calldataHash = await computeCalldataHash(calldata); const calldataHashedValues = new HashedValues(calldata, calldataHash); @@ -759,7 +777,13 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl } else if (!processedTx.revertCode.isOK()) { if (processedTx.revertReason) { try { - await enrichPublicSimulationError(processedTx.revertReason, this.contractStore, this.logger); + await enrichPublicSimulationError( + processedTx.revertReason, + this.contractStore, + this.stateMachine.contractClassService, + anchorBlockHeader, + this.logger, + ); // eslint-disable-next-line no-empty } catch {} throw new Error(`Contract execution has reverted: ${processedTx.revertReason.getMessage()}`); @@ -808,13 +832,19 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl jobId: string, authorizedUtilityCallTargets: AztecAddress[], ) { - const artifact = await this.contractStore.getFunctionArtifact(targetContractAddress, functionSelector); + const blockHeader = await this.stateMachine.anchorBlockStore.getBlockHeader(); + const anchoredContractData = new AnchoredContractData( + this.contractStore, + this.stateMachine.contractClassService, + blockHeader, + ); + + const artifact = await anchoredContractData.getFunctionArtifact(targetContractAddress, functionSelector); if (!artifact) { throw new Error(`Cannot call ${functionSelector} as there is no artifact found at ${targetContractAddress}.`); } // Sync notes before executing utility function to discover notes from previous transactions - const blockHeader = await this.stateMachine.anchorBlockStore.getBlockHeader(); await this.stateMachine.contractSyncService.ensureContractSynced( targetContractAddress, functionSelector, @@ -854,7 +884,17 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl authorizedUtilityCallTargets = [], }: { from?: AztecAddress; scopes: AztecAddress[]; jobId: string; authorizedUtilityCallTargets?: AztecAddress[] }, ): Promise { - const entryPointArtifact = await this.contractStore.getFunctionArtifactWithDebugMetadata(call.to, call.selector); + const anchorBlockHeader = await this.stateMachine.anchorBlockStore.getBlockHeader(); + const anchoredContractData = new AnchoredContractData( + this.contractStore, + this.stateMachine.contractClassService, + anchorBlockHeader, + ); + + const entryPointArtifact = await anchoredContractData.getFunctionArtifactWithDebugMetadata(call.to, call.selector); + if (!entryPointArtifact) { + throw new Error(`Cannot run function ${call.selector} on ${call.to}: the contract is not registered.`); + } if (entryPointArtifact.functionType !== FunctionType.UTILITY) { throw new Error(`Cannot run ${entryPointArtifact.functionType} function as utility`); } @@ -865,7 +905,6 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl }); try { - const anchorBlockHeader = await this.stateMachine.anchorBlockStore.getBlockHeader(); const simulator = new WASMSimulator(); const utilityExecutor = async (syncCall: FunctionCall, execScopes: AztecAddress[]) => { await this.executeUtilityCall(syncCall, { scopes: execScopes, jobId }); @@ -880,7 +919,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl authWitnesses: [], capsules: [], anchorBlockHeader, - contractStore: this.contractStore, + anchoredContractData, noteStore: this.noteStore, keyStore: this.keyStore, addressStore: this.addressStore, diff --git a/yarn-project/txe/src/oracle/txe_oracle_version.ts b/yarn-project/txe/src/oracle/txe_oracle_version.ts index f5cacf4c4208..19351a2cbc7b 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_version.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_version.ts @@ -14,4 +14,4 @@ export const TXE_ORACLE_VERSION_MINOR = 2; * - TXE_ORACLE_VERSION_MAJOR (and reset MINOR to 0) for breaking changes, or * - TXE_ORACLE_VERSION_MINOR for additive changes (new oracle method added). */ -export const TXE_ORACLE_INTERFACE_HASH = '118698345ea9d368ca82ea574a051e72f4df0b85454539ade14838a934976b62'; +export const TXE_ORACLE_INTERFACE_HASH = 'f6694961673ada551f57f5c09fa04cd566b175e63b7b965343bd4cf02b3cbdf6'; diff --git a/yarn-project/txe/src/state_machine/index.ts b/yarn-project/txe/src/state_machine/index.ts index 3356a0c6a5fc..4f724f3f8130 100644 --- a/yarn-project/txe/src/state_machine/index.ts +++ b/yarn-project/txe/src/state_machine/index.ts @@ -3,7 +3,13 @@ import { TestCircuitVerifier } from '@aztec/bb-prover/test'; import { CheckpointNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { createLogger } from '@aztec/foundation/log'; -import { type AnchorBlockStore, type ContractStore, ContractSyncService, type NoteStore } from '@aztec/pxe/server'; +import { + type AnchorBlockStore, + ContractClassService, + type ContractStore, + ContractSyncService, + type NoteStore, +} from '@aztec/pxe/server'; import { TxResolverService } from '@aztec/pxe/simulator'; import { L2Block, type L2TipsProvider } from '@aztec/stdlib/block'; import { Checkpoint, L1PublishedData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; @@ -29,6 +35,7 @@ export class TXEStateMachine { public archiver: TXEArchiver, public anchorBlockStore: AnchorBlockStore, public contractSyncService: ContractSyncService, + public contractClassService: ContractClassService, public txResolver: TxResolverService, ) {} @@ -68,16 +75,26 @@ export class TXEStateMachine { log, }); + const contractClassService = new ContractClassService(node, contractStore); const contractSyncService = new ContractSyncService( node, contractStore, + contractClassService, noteStore, createLogger('txe:contract_sync'), ); const txResolver = new TxResolverService(node); - return new this(node, synchronizer, archiver, anchorBlockStore, contractSyncService, txResolver); + return new this( + node, + synchronizer, + archiver, + anchorBlockStore, + contractSyncService, + contractClassService, + txResolver, + ); } /** Returns an {@link L2TipsProvider} backed by this node's chain tips. */ diff --git a/yarn-project/txe/src/txe_session.ts b/yarn-project/txe/src/txe_session.ts index 5b1aa064b844..b72b46b2b346 100644 --- a/yarn-project/txe/src/txe_session.ts +++ b/yarn-project/txe/src/txe_session.ts @@ -7,6 +7,7 @@ import { openEphemeralStore } from '@aztec/kv-store/lmdb-v2'; import { AddressStore, AnchorBlockStore, + AnchoredContractData, CapsuleService, CapsuleStore, ContractStore, @@ -731,6 +732,11 @@ export class TXESession implements TXESessionStateHandler { const utilityExecutor = this.utilityExecutorForContractSync(anchorBlock); const transientArrayService = new TransientArrayService(); + const anchoredContractData = new AnchoredContractData( + this.contractStore, + this.stateMachine.contractClassService, + anchorBlock!, + ); const taggingSecretStrategy = this.taggingSecretStrategy; this.oracleHandler = new TXEPrivateExecutionOracle({ argsHash: Fr.ZERO, @@ -743,7 +749,7 @@ export class TXESession implements TXESessionStateHandler { executionCache: new HashedValuesCache(), noteCache, taggingIndexCache, - contractStore: this.contractStore, + anchoredContractData, noteStore: this.noteStore, keyStore: this.keyStore, addressStore: this.addressStore, @@ -841,7 +847,11 @@ export class TXESession implements TXESessionStateHandler { authWitnesses: [], capsules: [], anchorBlockHeader, - contractStore: this.contractStore, + anchoredContractData: new AnchoredContractData( + this.contractStore, + this.stateMachine.contractClassService, + anchorBlockHeader, + ), noteStore: this.noteStore, keyStore: this.keyStore, addressStore: this.addressStore, @@ -937,7 +947,18 @@ export class TXESession implements TXESessionStateHandler { private utilityExecutorForContractSync(anchorBlock: any) { return async (call: FunctionCall, scopes: AztecAddress[]) => { - const entryPointArtifact = await this.contractStore.getFunctionArtifactWithDebugMetadata(call.to, call.selector); + const anchoredContractData = new AnchoredContractData( + this.contractStore, + this.stateMachine.contractClassService, + anchorBlock!, + ); + const entryPointArtifact = await anchoredContractData.getFunctionArtifactWithDebugMetadata( + call.to, + call.selector, + ); + if (!entryPointArtifact) { + throw new Error(`Cannot run function ${call.selector} on ${call.to}: the contract is not registered.`); + } if (entryPointArtifact.functionType !== FunctionType.UTILITY) { throw new Error(`Cannot run ${entryPointArtifact.functionType} function as utility`); } @@ -954,7 +975,7 @@ export class TXESession implements TXESessionStateHandler { authWitnesses: [], capsules: [], anchorBlockHeader: anchorBlock!, - contractStore: this.contractStore, + anchoredContractData, noteStore: this.noteStore, keyStore: this.keyStore, addressStore: this.addressStore, diff --git a/yarn-project/txe/src/utils/txe_public_contract_data_source.ts b/yarn-project/txe/src/utils/txe_public_contract_data_source.ts index 1a4c5b36ca88..316a9753a561 100644 --- a/yarn-project/txe/src/utils/txe_public_contract_data_source.ts +++ b/yarn-project/txe/src/utils/txe_public_contract_data_source.ts @@ -36,7 +36,8 @@ export class TXEPublicContractDataSource implements ContractDataSource { async getContract(address: AztecAddress): Promise { const instance = await this.contractStore.getContractInstance(address); - return instance && { ...instance, address }; + // TXE has no contract updates, so the current class always equals the original. + return instance && { ...instance, address, currentContractClassId: instance.originalContractClassId }; } getContractClassIds(): Promise { @@ -44,12 +45,13 @@ export class TXEPublicContractDataSource implements ContractDataSource { } async getContractArtifact(address: AztecAddress): Promise { - const instance = await this.contractStore.getContractInstance(address); - return instance && this.contractStore.getContractArtifact(instance.currentContractClassId); + const instance = await this.getContract(address); + return instance && this.contractStore.getContractArtifact(instance.originalContractClassId); } async getDebugFunctionName(address: AztecAddress, selector: FunctionSelector): Promise { - return await this.contractStore.getDebugFunctionName(address, selector); + const instance = await this.getContract(address); + return instance && this.contractStore.getDebugFunctionName(instance.originalContractClassId, selector); } registerContractFunctionSignatures(_signatures: []): Promise { diff --git a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts index 8dd8273ec61e..932d602c0301 100644 --- a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts +++ b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts @@ -41,12 +41,7 @@ import { } from '@aztec/stdlib/abi'; import type { AuthWitness } from '@aztec/stdlib/auth-witness'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { - type ContractInstanceWithAddress, - type NodeInfo, - computePartialAddress, - getContractClassFromArtifact, -} from '@aztec/stdlib/contract'; +import { type ContractInstancePreimage, type NodeInfo, computePartialAddress } from '@aztec/stdlib/contract'; import { SimulationError } from '@aztec/stdlib/errors'; import { Gas, GasFees, GasSettings, ManaUsageEstimate } from '@aztec/stdlib/gas'; import { @@ -54,7 +49,7 @@ import { computeSiloedPublicInitializationNullifier, } from '@aztec/stdlib/hash'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; -import type { MasterSecretKeys } from '@aztec/stdlib/keys'; +import { type MasterSecretKeys, deriveKeys, deriveKeysFromMasterSecretKeys } from '@aztec/stdlib/keys'; import { BlockHeader, ExecutionPayload, @@ -353,41 +348,36 @@ export abstract class BaseWallet implements Wallet { } async registerContract( - instance: ContractInstanceWithAddress, + instance: ContractInstancePreimage, artifact?: ContractArtifact, secretKeyOrKeys?: Fr | MasterSecretKeys, - ): Promise { - const existingInstance = await this.pxe.getContractInstance(instance.address); - - if (existingInstance) { - // Instance already registered in the wallet - if (artifact) { - const thisContractClass = await getContractClassFromArtifact(artifact); - if (!thisContractClass.id.equals(existingInstance.currentContractClassId)) { - // wallet holds an outdated version of this contract - await this.pxe.updateContract(instance.address, artifact); - instance.currentContractClassId = thisContractClass.id; - } - } - // If no artifact provided, we just use the existing registration - } else { - // Instance not registered yet - if (!artifact) { - // Try to get the artifact from the wallet's contract class storage - artifact = await this.pxe.getContractArtifact(instance.currentContractClassId); - if (!artifact) { - throw new Error( - `Cannot register contract at ${instance.address.toString()}: artifact is required but not provided, and wallet does not have the artifact for contract class ${instance.currentContractClassId.toString()}`, - ); - } - } - await this.pxe.registerContract({ artifact, instance }); + ): Promise { + // Classes and instances are registered independently: register the artifact (if provided) then the instance. + // Neither call validates that the artifact matches the class the instance runs, a missing artifact only surfaces + // when the contract is later simulated. + if (artifact) { + await this.pxe.registerContractClass(artifact); } + const contractAddress = await this.pxe.registerContract(instance); if (secretKeyOrKeys) { - await this.pxe.registerAccount(secretKeyOrKeys, await computePartialAddress(instance)); + // PXE never receives the account seed (from which the message-signing/fallback secret keys could be re-derived): + // the wallet derives the keys here. Of these, PXE only reads and stores the four privacy secret keys and the + // message-signing and fallback *public* keys — it never touches the message-signing or fallback secret keys. + // + // Since PXE recomputes the address from those keys, we assert it matches the instance's address: a mismatch means + // the provided keys don't correspond to this account. + const derivedKeys = + secretKeyOrKeys instanceof Fr + ? await deriveKeys(secretKeyOrKeys) + : await deriveKeysFromMasterSecretKeys(secretKeyOrKeys); + const { address } = await this.pxe.registerAccount(derivedKeys, await computePartialAddress(instance)); + if (!address.equals(contractAddress)) { + throw new Error( + `Registered account address ${address.toString()} does not match contract instance address ${contractAddress.toString()}: the provided keys do not correspond to this account.`, + ); + } } - return instance; } registerContractClass(artifact: ContractArtifact): Promise { @@ -566,7 +556,11 @@ export abstract class BaseWallet implements Wallet { if (!instance) { return undefined; } - const artifact = await this.pxe.getContractArtifact(instance.currentContractClassId); + // Contract names are class-stable (an upgrade preserves the contract name), so the original class artifact is a + // sufficient source for the display name without resolving the current class against the node. + // TODO: if a contract were to be upgraded and its original artifact never registered, then this would fail and we'd + // want to fallback to the current class. + const artifact = await this.pxe.getContractArtifact(instance.originalContractClassId); return artifact?.name; } diff --git a/yarn-project/wallets/src/embedded/embedded_wallet.ts b/yarn-project/wallets/src/embedded/embedded_wallet.ts index 9a1d62607e83..7616c047c59e 100644 --- a/yarn-project/wallets/src/embedded/embedded_wallet.ts +++ b/yarn-project/wallets/src/embedded/embedded_wallet.ts @@ -26,12 +26,12 @@ import type { Logger } from '@aztec/foundation/log'; import type { AztecAsyncKVStore } from '@aztec/kv-store'; import type { PXEConfig, PXECreationOptions } from '@aztec/pxe/client/lazy'; import type { PXE } from '@aztec/pxe/server'; -import type { ContractArtifact, EventMetadataDefinition, FunctionCall } from '@aztec/stdlib/abi'; +import type { EventMetadataDefinition, FunctionCall } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { type ContractInstanceWithAddress, getContractClassFromArtifact } from '@aztec/stdlib/contract'; +import { getContractClassFromArtifact } from '@aztec/stdlib/contract'; import { GasSettings } from '@aztec/stdlib/gas'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; -import { type MasterSecretKeys, deriveSigningKey } from '@aztec/stdlib/keys'; +import { deriveSigningKey } from '@aztec/stdlib/keys'; import { type ContractOverrides, ExecutionPayload, @@ -263,17 +263,6 @@ export class EmbeddedWallet extends BaseWallet { return super.getPrivateEvents(eventDef, eventFilter); } - public override async registerContract( - instance: ContractInstanceWithAddress, - artifact?: ContractArtifact, - secretKeyOrKeys?: Fr | MasterSecretKeys, - ): Promise { - // registerContract may call pxe.updateContract under the hood, which depends on a fresh anchor - // block to verify the current class id from the node. - await this.pxe.sync(); - return super.registerContract(instance, artifact, secretKeyOrKeys); - } - /** * Hashes and registers the stub class for every supported account type with PXE, populating * stubClassIds. Called on wallet initialization.