diff --git a/noir-projects/aztec-nr/aztec/src/macros/aztec.nr b/noir-projects/aztec-nr/aztec/src/macros/aztec.nr index 30aaa434c197..ed1792b93bc2 100644 --- a/noir-projects/aztec-nr/aztec/src/macros/aztec.nr +++ b/noir-projects/aztec-nr/aztec/src/macros/aztec.nr @@ -340,6 +340,10 @@ comptime fn generate_contract_library_method_compute_note_hash_and_nullifier() - ) -> Option { $if_note_type_id_match_statements else { + aztec::protocol::logging::warn_log_format( + "[aztec-nr] Unknown note type id {0}. Skipping note.", + [note_type_id], + ); Option::none() } } diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr index 7e93d7d896ba..94dc215fad39 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr @@ -33,9 +33,9 @@ pub global MAX_OFFCHAIN_MESSAGES_PER_RECEIVE_CALL: u32 = 16; /// Tolerance added to the `MAX_TX_LIFETIME` cap for message expiration. global TX_EXPIRATION_TOLERANCE: u64 = 7200; // 2 hours -/// Maximum time-to-live for a tx-bound offchain message: `MAX_TX_LIFETIME + TX_EXPIRATION_CAP_TOLERANCE`. +/// Maximum time-to-live for a tx-bound offchain message. /// -/// After `received_at + MAX_MSG_TTL`, the message is evicted regardless of the sender-provided expiration. +/// After `anchor_block_timestamp + MAX_MSG_TTL`, the message is evicted from the inbox. global MAX_MSG_TTL: u64 = MAX_TX_LIFETIME + TX_EXPIRATION_TOLERANCE; /// A function that manages offchain-delivered messages for processing during sync. @@ -63,8 +63,8 @@ pub struct OffchainMessage { pub recipient: AztecAddress, /// The hash of the transaction that produced this message. `Option::none` indicates a tx-less message. pub tx_hash: Option, - /// The timestamp after which this message can be evicted from the inbox. - pub expiration_timestamp: u64, + /// Anchor block timestamp at message emission. + pub anchor_block_timestamp: u64, } /// An offchain message awaiting processing (or re-processing) in the inbox. @@ -80,14 +80,11 @@ struct PendingOffchainMsg { recipient: AztecAddress, /// The hash of the transaction that produced this message. A value of 0 indicates a tx-less message. tx_hash: Field, - /// The timestamp after which this message can be evicted from the inbox (as provided by the sender). - expiration_timestamp: u64, - /// The timestamp at which this message was received (i.e. added to the inbox via `receive`). + /// Anchor block timestamp at message emission. /// - /// Used to cap the effective expiration of messages with an associated transaction: since a tx can only live for - /// `MAX_TX_LIFETIME`, we don't need to keep the message around longer than `received_at + MAX_MSG_TTL`, - /// regardless of the sender-provided `expiration_timestamp`. - received_at: u64, + /// Used to compute the effective expiration: messages are evicted after + /// `anchor_block_timestamp + MAX_MSG_TTL`. + anchor_block_timestamp: u64, } /// Delivers offchain messages to the given contract's offchain inbox for subsequent processing. @@ -100,9 +97,8 @@ struct PendingOffchainMsg { /// Messages are processed when their originating transaction is found onchain (providing the context needed to /// validate resulting notes and events). /// -/// Messages are kept in the inbox until their expiration timestamp is reached. For messages with an associated -/// transaction, the effective expiration is capped to `received_at + MAX_MSG_TTL`, since there's no point in -/// keeping a message around longer than its originating transaction could possibly live. +/// Messages are kept in the inbox until they expire. The effective expiration is +/// `anchor_block_timestamp + MAX_MSG_TTL`. /// /// Processing order is not guaranteed. pub unconstrained fn receive( @@ -110,7 +106,6 @@ pub unconstrained fn receive( messages: BoundedVec, ) { let inbox: CapsuleArray = CapsuleArray::at(contract_address, OFFCHAIN_INBOX_SLOT); - let now = UtilityContext::new().timestamp(); let mut i = 0; let messages_len = messages.len(); while i < messages_len { @@ -125,8 +120,7 @@ pub unconstrained fn receive( ciphertext: msg.ciphertext, recipient: msg.recipient, tx_hash, - expiration_timestamp: msg.expiration_timestamp, - received_at: now, + anchor_block_timestamp: msg.anchor_block_timestamp, }, ); i += 1; @@ -191,8 +185,8 @@ pub unconstrained fn sync_inbox(address: AztecAddress) -> CapsuleArray anchor_block_timestamp + MAX_MSG_TTL`), we remove it + // from the inbox. // // Note: the loop runs backwards because it might call `inbox.remove(j)` to purge expired messages and we also // need to align it with `resolved_contexts.get(j)`. Going from last to first simplifies the algorithm as @@ -202,20 +196,7 @@ pub unconstrained fn sync_inbox(address: AztecAddress) -> CapsuleArray effective_expiration { @@ -259,12 +240,12 @@ mod test { }; /// Creates an `OffchainMessage` with dummy ciphertext/recipient. - fn make_msg(tx_hash: Option, expiration_timestamp: u64) -> OffchainMessage { + fn make_msg(tx_hash: Option, anchor_block_timestamp: u64) -> OffchainMessage { OffchainMessage { ciphertext: BoundedVec::new(), recipient: AztecAddress::from_field(42), tx_hash, - expiration_timestamp, + anchor_block_timestamp, } } @@ -289,109 +270,41 @@ mod test { } #[test] - unconstrained fn tx_bound_msg_expires_by_sender_timestamp() { - let env = TestEnvironment::new(); - let receive_time = advance_by(env, 10); - // Msg expiration attribute is well before the lifetime cap, so it is the effective expiration. - let sender_expiration_offset = 100; - - // Receive message with expiration at receive_time + offset. - env.utility_context(|context| { - let mut msgs: BoundedVec = BoundedVec::new(); - msgs - .push( - make_msg( - Option::some(random()), - receive_time + sender_expiration_offset, - ), - ); - receive(context.this_address(), msgs); - }); - - // Advance past sender expiration but before the lifetime cap. - let _now = advance_by(env, sender_expiration_offset + 1); - - env.utility_context(|context| { - let address = context.this_address(); - let result = sync_inbox(address); - let inbox: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_INBOX_SLOT); - - assert_eq(result.len(), 0); // context is None, not ready - assert_eq(inbox.len(), 0); // expired, removed - }); - } - - #[test] - unconstrained fn tx_bound_msg_expires_by_lifetime_cap() { + unconstrained fn tx_bound_msg_expires_after_max_msg_ttl() { let env = TestEnvironment::new(); - let receive_time = advance_by(env, 10); + let anchor_ts = advance_by(env, 10); env.utility_context(|context| { let mut msgs: BoundedVec = BoundedVec::new(); - // Sender's expiration is far in the future, so the standard TX lifetime cap kicks in. - msgs.push(make_msg(Option::some(random()), receive_time + 10 * MAX_MSG_TTL)); + msgs.push(make_msg(Option::some(random()), anchor_ts)); receive(context.this_address(), msgs); }); - // Advance past the lifetime cap. + // Advance past anchor_ts + MAX_MSG_TTL. let _now = advance_by(env, MAX_MSG_TTL + 1); - env.utility_context(|context| { - let address = context.this_address(); - let result = sync_inbox(address); - let inbox: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_INBOX_SLOT); - - assert_eq(result.len(), 0); - assert_eq(inbox.len(), 0); // capped expiration exceeded, removed - }); - } - - #[test] - unconstrained fn tx_bound_msg_not_expired_sender_timestamp_binding() { - let env = TestEnvironment::new(); - let receive_time = advance_by(env, 10); - // Sender expiration is in the future and below the cap, so it is the binding expiration. - let sender_expiration_offset = 100; - - env.utility_context(|context| { - let mut msgs: BoundedVec = BoundedVec::new(); - msgs - .push( - make_msg( - Option::some(random()), - receive_time + sender_expiration_offset, - ), - ); - receive(context.this_address(), msgs); - }); - - // Advance, but not past sender expiration. - let _now = advance_by(env, sender_expiration_offset - 1); - env.utility_context(|context| { let address = context.this_address(); let result = sync_inbox(address); let inbox: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_INBOX_SLOT); assert_eq(result.len(), 0); // context is None, not ready - assert_eq(inbox.len(), 1); // not expired, stays + assert_eq(inbox.len(), 0); // expired, removed }); } #[test] - unconstrained fn tx_bound_msg_not_expired_lifetime_cap_binding() { + unconstrained fn tx_bound_msg_not_expired_before_max_msg_ttl() { let env = TestEnvironment::new(); - - // Sender expiration is way beyond the cap, but `now` is still before the cap. - let receive_time = advance_by(env, 10); + let anchor_ts = advance_by(env, 10); env.utility_context(|context| { let mut msgs: BoundedVec = BoundedVec::new(); - msgs.push(make_msg(Option::some(random()), receive_time + 10 * MAX_MSG_TTL)); + msgs.push(make_msg(Option::some(random()), anchor_ts)); receive(context.this_address(), msgs); }); - // Advance, but not past the lifetime cap. + // Advance, but not past anchor_ts + MAX_MSG_TTL. let _now = advance_by(env, 100); env.utility_context(|context| { @@ -405,64 +318,37 @@ mod test { } #[test] - unconstrained fn tx_less_msg_expires_by_sender_timestamp() { + unconstrained fn tx_less_msg_expires_after_max_msg_ttl() { let env = TestEnvironment::new(); - let receive_time = advance_by(env, 10); - let sender_expiration_offset = 100; + let anchor_ts = advance_by(env, 10); env.utility_context(|context| { let mut msgs: BoundedVec = BoundedVec::new(); - msgs.push(make_msg(Option::none(), receive_time + sender_expiration_offset)); + msgs.push(make_msg(Option::none(), anchor_ts)); receive(context.this_address(), msgs); }); - let _now = advance_by(env, sender_expiration_offset + 1); - - env.utility_context(|context| { - let address = context.this_address(); - let result = sync_inbox(address); - let inbox: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_INBOX_SLOT); - - assert_eq(result.len(), 0); - assert_eq(inbox.len(), 0); // expired, removed - }); - } - - #[test] - unconstrained fn tx_less_msg_with_large_expiration_is_not_capped() { - let env = TestEnvironment::new(); - let receive_time = advance_by(env, 10); - - env.utility_context(|context| { - let mut msgs: BoundedVec = BoundedVec::new(); - msgs.push(make_msg(Option::none(), receive_time + 10 * MAX_MSG_TTL)); - receive(context.this_address(), msgs); - }); - - // Advance past what the lifetime cap would be for a tx-bound message. + // Advance past anchor_ts + MAX_MSG_TTL. let _now = advance_by(env, MAX_MSG_TTL + 1); - // The message is tx-less, so the lifetime cap does not apply and the sender's large - // expiration is trusted. Even though `now` is past what the cap would be for a tx-bound - // message, the message stays. env.utility_context(|context| { let address = context.this_address(); let result = sync_inbox(address); let inbox: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_INBOX_SLOT); assert_eq(result.len(), 0); // context is None, not ready - assert_eq(inbox.len(), 1); // tx-less, not capped, stays + assert_eq(inbox.len(), 0); // expired, removed }); } #[test] unconstrained fn unresolved_tx_stays_in_inbox() { let env = TestEnvironment::new(); - let receive_time = advance_by(env, 10); + let anchor_ts = advance_by(env, 10); env.utility_context(|context| { let mut msgs: BoundedVec = BoundedVec::new(); - msgs.push(make_msg(Option::some(random()), receive_time + MAX_MSG_TTL)); + msgs.push(make_msg(Option::some(random()), anchor_ts)); receive(context.this_address(), msgs); }); @@ -481,24 +367,25 @@ mod test { #[test] unconstrained fn multiple_messages_mixed_expiration() { let env = TestEnvironment::new(); - let receive_time = advance_by(env, 10); + let anchor_ts = advance_by(env, 10); let survivor_tx_hash = random(); env.utility_context(|context| { let address = context.this_address(); let mut msgs: BoundedVec = BoundedVec::new(); - // Message 0: tx-bound, will expire after 50s. - msgs.push(make_msg(Option::some(random()), receive_time + 50)); - // Message 1: tx-bound, will expire after 50_000s (the survivor). - msgs.push(make_msg(Option::some(survivor_tx_hash), receive_time + 50_000)); - // Message 2: tx-less, will expire after 50s. - msgs.push(make_msg(Option::none(), receive_time + 50)); + // Message 0: tx-bound, anchor_ts in the past so it expires at + // anchor_ts + MAX_MSG_TTL. We set anchor to 0 so it expires quickly. + msgs.push(make_msg(Option::some(random()), 0)); + // Message 1: tx-bound, anchor_ts is recent so it survives. + msgs.push(make_msg(Option::some(survivor_tx_hash), anchor_ts)); + // Message 2: tx-less, anchor_ts=0 so it also expires. + msgs.push(make_msg(Option::none(), 0)); receive(address, msgs); }); - // Advance past 50s but before 50_000s. - let _now = advance_by(env, 51); + // Advance past MAX_MSG_TTL for anchor_ts=0, but not for anchor_ts=anchor_ts. + let _now = advance_by(env, MAX_MSG_TTL); env.utility_context(|context| { let address = context.this_address(); @@ -506,7 +393,8 @@ mod test { let inbox: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_INBOX_SLOT); assert_eq(result.len(), 0); // all contexts are None - // Only message 1 should remain: messages 0 and 2 expired. + // Message 0 expired (anchor=0), message 1 survived (anchor=anchor_ts), + // message 2 expired (anchor=0). assert_eq(inbox.len(), 1); assert_eq(inbox.get(0).tx_hash, survivor_tx_hash); }); @@ -521,11 +409,11 @@ mod test { // In TXE, tx hashes equal Fr(blockNumber), so Fr(1) is the tx effect from block 1. // We use this as a "known resolvable" tx hash. let known_tx_hash: Field = 1; - let receive_time = advance_by(env, 10); + let anchor_ts = advance_by(env, 10); env.utility_context(|context| { let mut msgs: BoundedVec = BoundedVec::new(); - msgs.push(make_msg(Option::some(known_tx_hash), receive_time + MAX_MSG_TTL)); + msgs.push(make_msg(Option::some(known_tx_hash), anchor_ts)); receive(context.this_address(), msgs); }); diff --git a/noir-projects/noir-contracts/contracts/test/note_hash_and_nullifier/note_hash_and_nullifier_contract/src/test.nr b/noir-projects/noir-contracts/contracts/test/note_hash_and_nullifier/note_hash_and_nullifier_contract/src/test.nr index c20909854926..dac447b00f50 100644 --- a/noir-projects/noir-contracts/contracts/test/note_hash_and_nullifier/note_hash_and_nullifier_contract/src/test.nr +++ b/noir-projects/noir-contracts/contracts/test/note_hash_and_nullifier/note_hash_and_nullifier_contract/src/test.nr @@ -63,3 +63,20 @@ unconstrained fn returns_none_for_empty_packed_note() { assert(result.is_none()); } + +#[test] +unconstrained fn returns_none_for_unknown_note_type_id() { + let packed_note = BoundedVec::from_array([42]); + + let result = NoteHashAndNullifier::test_compute_note_hash_and_nullifier( + packed_note, + AztecAddress::zero(), + 0, + 0xdeadbeef, + AztecAddress::zero(), + 0, + 0, + ); + + assert(result.is_none()); +} diff --git a/yarn-project/aztec.js/src/contract/batch_call.test.ts b/yarn-project/aztec.js/src/contract/batch_call.test.ts index 75d6ed0a3293..23c0ea5e9418 100644 --- a/yarn-project/aztec.js/src/contract/batch_call.test.ts +++ b/yarn-project/aztec.js/src/contract/batch_call.test.ts @@ -1,13 +1,32 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { FunctionCall, FunctionSelector, FunctionType } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { ExecutionPayload, TxSimulationResult, UtilityExecutionResult } from '@aztec/stdlib/tx'; +import { + ExecutionPayload, + OFFCHAIN_MESSAGE_IDENTIFIER, + type OffchainEffect, + TxSimulationResult, + UtilityExecutionResult, +} from '@aztec/stdlib/tx'; import { type MockProxy, mock } from 'jest-mock-extended'; import type { Wallet } from '../wallet/wallet.js'; import { BatchCall } from './batch_call.js'; +function mockTxSimResult(overrides: { anchorBlockTimestamp?: bigint; offchainEffects?: OffchainEffect[] } = {}) { + const txSimResult = mock(); + Object.defineProperty(txSimResult, 'offchainEffects', { value: overrides.offchainEffects ?? [] }); + Object.defineProperty(txSimResult, 'publicInputs', { + value: { + constants: { + anchorBlockHeader: { globalVariables: { timestamp: overrides.anchorBlockTimestamp ?? 0n } }, + }, + }, + }); + return txSimResult; +} + function createUtilityExecutionPayload( functionName: string, args: Fr[], @@ -114,12 +133,11 @@ describe('BatchCall', () => { const privateReturnValues = [Fr.random(), Fr.random()]; const publicReturnValues = [Fr.random()]; - const txSimResult = mock(); + const txSimResult = mockTxSimResult(); txSimResult.getPrivateReturnValues.mockReturnValue({ nested: [{ values: privateReturnValues }], } as any); txSimResult.getPublicReturnValues.mockReturnValue([{ values: publicReturnValues }] as any); - Object.defineProperty(txSimResult, 'offchainEffects', { value: [] }); // Mock wallet.batch to return both utility results and simulateTx result wallet.batch.mockResolvedValue([ @@ -236,6 +254,61 @@ describe('BatchCall', () => { expect(results[0].offchainMessages).toEqual([]); }); + // This is not a great test, mostly because it is very synthetic, mocking too much stuff around. I wanted something + // that exercised the offchain effects processing side of things in the case of batches, and this is more or less + // what matches how things are done in this suite, but the fact that we need to resort to mocking so much seems + // like a smell. We should revisit when we have more time to rethink the suite. + it('should extract offchain messages with anchor block timestamp from mixed batch', async () => { + const contractAddress = await AztecAddress.random(); + const recipient = await AztecAddress.random(); + const msgPayload = [Fr.random(), Fr.random()]; + const anchorBlockTimestamp = 1234567890n; + + batchCall = new BatchCall(wallet, [ + createUtilityExecutionPayload('getBalance', [Fr.random()], contractAddress), + createPrivateExecutionPayload('transfer', [Fr.random()], contractAddress, 1), + createPrivateExecutionPayload('transfer', [Fr.random()], contractAddress, 1), + ]); + + const offchainEffects: OffchainEffect[] = [ + { + data: [OFFCHAIN_MESSAGE_IDENTIFIER, recipient.toField(), ...msgPayload], + contractAddress: contractAddress, + }, + ]; + + const txSimResult = mockTxSimResult({ anchorBlockTimestamp, offchainEffects }); + txSimResult.getPrivateReturnValues.mockReturnValue({ + nested: [{ values: [Fr.random()] }, { values: [Fr.random()] }], + } as any); + + const utilityResult = UtilityExecutionResult.random(); + wallet.batch.mockResolvedValue([ + { name: 'executeUtility', result: utilityResult }, + { name: 'simulateTx', result: txSimResult }, + ] as any); + + const results = await batchCall.simulate({ from: await AztecAddress.random() }); + + expect(results).toHaveLength(3); + + // Utility result has empty offchain output + expect(results[0].offchainMessages).toEqual([]); + expect(results[0].offchainEffects).toEqual([]); + + // Private results carry the offchain messages from the tx simulation + for (const idx of [1, 2]) { + expect(results[idx].offchainMessages).toHaveLength(1); + expect(results[idx].offchainMessages[0]).toEqual({ + recipient, + payload: msgPayload, + contractAddress: contractAddress, + anchorBlockTimestamp, + }); + expect(results[idx].offchainEffects).toEqual([]); + } + }); + it('should handle only private/public calls using wallet.batch with simulateTx', async () => { const contractAddress1 = await AztecAddress.random(); const contractAddress2 = await AztecAddress.random(); @@ -248,12 +321,11 @@ describe('BatchCall', () => { const privateReturnValues = [Fr.random()]; const publicReturnValues = [Fr.random()]; - const txSimResult = mock(); + const txSimResult = mockTxSimResult(); txSimResult.getPrivateReturnValues.mockReturnValue({ nested: [{ values: privateReturnValues }], } as any); txSimResult.getPublicReturnValues.mockReturnValue([{ values: publicReturnValues }] as any); - Object.defineProperty(txSimResult, 'offchainEffects', { value: [] }); wallet.batch.mockResolvedValue([{ name: 'simulateTx', result: txSimResult }] as any); diff --git a/yarn-project/aztec.js/src/contract/batch_call.ts b/yarn-project/aztec.js/src/contract/batch_call.ts index 62a2a27ee59f..8fd0ef605d0f 100644 --- a/yarn-project/aztec.js/src/contract/batch_call.ts +++ b/yarn-project/aztec.js/src/contract/batch_call.ts @@ -134,7 +134,10 @@ export class BatchCall extends BaseContractInteraction { results[callIndex] = { result: rawReturnValues ? decodeFromAbi(call.returnTypes, rawReturnValues) : [], - ...extractOffchainOutput(simulatedTx.offchainEffects, simulatedTx.publicInputs.expirationTimestamp), + ...extractOffchainOutput( + simulatedTx.offchainEffects, + simulatedTx.publicInputs.constants.anchorBlockHeader.globalVariables.timestamp, + ), }; }); } diff --git a/yarn-project/aztec.js/src/contract/contract.test.ts b/yarn-project/aztec.js/src/contract/contract.test.ts index 50394dc23226..49072e780641 100644 --- a/yarn-project/aztec.js/src/contract/contract.test.ts +++ b/yarn-project/aztec.js/src/contract/contract.test.ts @@ -13,6 +13,7 @@ import type { TxSimulationResult, UtilityExecutionResult, } from '@aztec/stdlib/tx'; +import { OFFCHAIN_MESSAGE_IDENTIFIER } from '@aztec/stdlib/tx'; import { type MockProxy, mock } from 'jest-mock-extended'; @@ -228,6 +229,41 @@ describe('Contract Class', () => { expect(result).toBe(42n); }); + it('should extract offchain messages with anchor block timestamp on simulate', async () => { + const recipient = await AztecAddress.random(); + const msgPayload = [Fr.random(), Fr.random()]; + const anchorBlockTimestamp = 9999n; + + const txSimResult = mock(); + txSimResult.getPrivateReturnValues.mockReturnValue({ nested: [{ values: [] }] } as any); + Object.defineProperty(txSimResult, 'offchainEffects', { + value: [ + { + data: [OFFCHAIN_MESSAGE_IDENTIFIER, recipient.toField(), ...msgPayload], + contractAddress, + }, + ], + }); + Object.defineProperty(txSimResult, 'publicInputs', { + value: { + constants: { anchorBlockHeader: { globalVariables: { timestamp: anchorBlockTimestamp } } }, + }, + }); + + wallet.simulateTx.mockResolvedValue(txSimResult); + + const fooContract = Contract.at(contractAddress, defaultArtifact, wallet); + const result = await fooContract.methods.bar(1, 2).simulate({ from: account.getAddress() }); + + expect(result.offchainMessages).toHaveLength(1); + expect(result.offchainMessages[0]).toEqual({ + recipient, + payload: msgPayload, + contractAddress, + anchorBlockTimestamp, + }); + }); + it('allows nullish values for Option parameters', () => { const fooContract = Contract.at(contractAddress, defaultArtifact, wallet); diff --git a/yarn-project/aztec.js/src/contract/contract_function_interaction.ts b/yarn-project/aztec.js/src/contract/contract_function_interaction.ts index 9b95675d3c6b..451dfdc13c12 100644 --- a/yarn-project/aztec.js/src/contract/contract_function_interaction.ts +++ b/yarn-project/aztec.js/src/contract/contract_function_interaction.ts @@ -169,7 +169,7 @@ export class ContractFunctionInteraction extends BaseContractInteraction { const returnValue = rawReturnValues ? decodeFromAbi(this.functionDao.returnTypes, rawReturnValues) : []; const offchainOutput = extractOffchainOutput( simulatedTx.offchainEffects, - simulatedTx.publicInputs.expirationTimestamp, + simulatedTx.publicInputs.constants.anchorBlockHeader.globalVariables.timestamp, ); if (options.includeMetadata || options.fee?.estimateGas) { diff --git a/yarn-project/aztec.js/src/contract/deploy_method.test.ts b/yarn-project/aztec.js/src/contract/deploy_method.test.ts new file mode 100644 index 000000000000..e17ae3c1ffbd --- /dev/null +++ b/yarn-project/aztec.js/src/contract/deploy_method.test.ts @@ -0,0 +1,106 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import { type ContractArtifact, FunctionType } from '@aztec/stdlib/abi'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { ContractInstanceWithAddress } from '@aztec/stdlib/contract'; +import { Gas } from '@aztec/stdlib/gas'; +import { PublicKeys } from '@aztec/stdlib/keys'; +import { OFFCHAIN_MESSAGE_IDENTIFIER, type OffchainEffect, type TxSimulationResult } from '@aztec/stdlib/tx'; + +import { type MockProxy, mock } from 'jest-mock-extended'; + +import type { Wallet } from '../wallet/wallet.js'; +import type { ContractBase } from './contract_base.js'; +import { DeployMethod } from './deploy_method.js'; + +describe('DeployMethod', () => { + let wallet: MockProxy; + + const artifact: ContractArtifact = { + name: 'TestContract', + functions: [ + { + name: 'constructor', + isInitializer: true, + functionType: FunctionType.PRIVATE, + isOnlySelf: false, + isStatic: false, + debugSymbols: '', + parameters: [], + returnTypes: [], + errorTypes: {}, + bytecode: Buffer.alloc(8, 0xfa), + verificationKey: Buffer.alloc(4064).toString('base64'), + }, + { + name: 'public_dispatch', + isInitializer: false, + isStatic: false, + functionType: FunctionType.PUBLIC, + isOnlySelf: false, + parameters: [{ name: 'selector', type: { kind: 'field' }, visibility: 'public' }], + returnTypes: [], + errorTypes: {}, + bytecode: Buffer.alloc(8, 0xfb), + debugSymbols: '', + }, + ], + nonDispatchPublicFunctions: [], + outputs: { structs: {}, globals: {} }, + fileMap: {}, + storageLayout: {}, + }; + + beforeEach(() => { + wallet = mock(); + wallet.registerContract.mockResolvedValue({} as ContractInstanceWithAddress); + wallet.getContractClassMetadata.mockResolvedValue({ isContractClassPubliclyRegistered: true } as any); + wallet.getContractMetadata.mockResolvedValue({ isContractPubliclyDeployed: true } as any); + }); + + it('should extract offchain messages with anchor block timestamp on simulate', async () => { + const recipient = await AztecAddress.random(); + const contractAddress = await AztecAddress.random(); + const msgPayload = [Fr.random(), Fr.random()]; + const anchorBlockTimestamp = 42000n; + + const offchainEffects: OffchainEffect[] = [ + { + data: [OFFCHAIN_MESSAGE_IDENTIFIER, recipient.toField(), ...msgPayload], + contractAddress, + }, + ]; + + const txSimResult = mock(); + Object.defineProperty(txSimResult, 'offchainEffects', { value: offchainEffects }); + Object.defineProperty(txSimResult, 'publicInputs', { + value: { + constants: { anchorBlockHeader: { globalVariables: { timestamp: anchorBlockTimestamp } } }, + }, + }); + Object.defineProperty(txSimResult, 'stats', { value: {} }); + Object.defineProperty(txSimResult, 'gasUsed', { + value: { totalGas: Gas.empty(), teardownGas: Gas.empty() }, + }); + + wallet.simulateTx.mockResolvedValue(txSimResult); + + const deployMethod = new DeployMethod( + PublicKeys.default(), + wallet, + artifact, + (instance, w) => ({ instance, wallet: w }) as unknown as ContractBase, + [], + ); + + const result = await deployMethod.simulate({ from: await AztecAddress.random() }); + + expect(result.offchainMessages).toHaveLength(1); + expect(result.offchainMessages[0]).toEqual({ + recipient, + payload: msgPayload, + contractAddress, + anchorBlockTimestamp, + }); + expect(result.offchainEffects).toEqual([]); + }); +}); diff --git a/yarn-project/aztec.js/src/contract/deploy_method.ts b/yarn-project/aztec.js/src/contract/deploy_method.ts index b0bfced88225..ea3a84f28dda 100644 --- a/yarn-project/aztec.js/src/contract/deploy_method.ts +++ b/yarn-project/aztec.js/src/contract/deploy_method.ts @@ -425,7 +425,10 @@ export class DeployMethod extends ); return { stats: simulatedTx.stats!, - ...extractOffchainOutput(simulatedTx.offchainEffects, simulatedTx.publicInputs.expirationTimestamp), + ...extractOffchainOutput( + simulatedTx.offchainEffects, + simulatedTx.publicInputs.constants.anchorBlockHeader.globalVariables.timestamp, + ), result: undefined, estimatedGas: { gasLimits, teardownGasLimits }, }; diff --git a/yarn-project/aztec.js/src/contract/interaction_options.test.ts b/yarn-project/aztec.js/src/contract/interaction_options.test.ts index 05b7edfdf9ff..71bb49634ac9 100644 --- a/yarn-project/aztec.js/src/contract/interaction_options.test.ts +++ b/yarn-project/aztec.js/src/contract/interaction_options.test.ts @@ -5,7 +5,7 @@ import { OFFCHAIN_MESSAGE_IDENTIFIER, type OffchainEffect } from '@aztec/stdlib/ import { extractOffchainOutput } from './interaction_options.js'; describe('extractOffchainOutput', () => { - const expirationTimestamp = 1234567890n; + const anchorBlockTimestamp = 1234567890n; const makeEffect = (data: Fr[], contractAddress?: AztecAddress): OffchainEffect => ({ data, @@ -23,14 +23,14 @@ describe('extractOffchainOutput', () => { ); it('returns empty output for empty input', () => { - const result = extractOffchainOutput([], expirationTimestamp); + const result = extractOffchainOutput([], anchorBlockTimestamp); expect(result.offchainEffects).toEqual([]); expect(result.offchainMessages).toEqual([]); }); it('keeps non-message effects as-is', () => { const effects = [makeEffect([Fr.random(), Fr.random()]), makeEffect([Fr.random()])]; - const result = extractOffchainOutput(effects, expirationTimestamp); + const result = extractOffchainOutput(effects, anchorBlockTimestamp); expect(result.offchainEffects).toEqual(effects); expect(result.offchainMessages).toEqual([]); }); @@ -41,7 +41,7 @@ describe('extractOffchainOutput', () => { const contractAddress = await AztecAddress.random(); const effect = await makeMessageEffect(recipient, payload, contractAddress); - const result = extractOffchainOutput([effect], expirationTimestamp); + const result = extractOffchainOutput([effect], anchorBlockTimestamp); expect(result.offchainEffects).toEqual([]); expect(result.offchainMessages).toHaveLength(1); @@ -49,7 +49,7 @@ describe('extractOffchainOutput', () => { recipient, payload, contractAddress, - expirationTimestamp, + anchorBlockTimestamp, }); }); @@ -58,7 +58,7 @@ describe('extractOffchainOutput', () => { const plainEffect2 = makeEffect([Fr.random(), Fr.random()]); const messageEffect = await makeMessageEffect(); - const result = extractOffchainOutput([plainEffect1, messageEffect, plainEffect2], expirationTimestamp); + const result = extractOffchainOutput([plainEffect1, messageEffect, plainEffect2], anchorBlockTimestamp); expect(result.offchainEffects).toEqual([plainEffect1, plainEffect2]); expect(result.offchainMessages).toHaveLength(1); @@ -68,7 +68,7 @@ describe('extractOffchainOutput', () => { const msg1 = await makeMessageEffect(); const msg2 = await makeMessageEffect(); - const result = extractOffchainOutput([msg1, msg2], expirationTimestamp); + const result = extractOffchainOutput([msg1, msg2], anchorBlockTimestamp); expect(result.offchainEffects).toEqual([]); expect(result.offchainMessages).toHaveLength(2); @@ -76,7 +76,7 @@ describe('extractOffchainOutput', () => { it('does not treat an effect as a message if data has only the identifier (no recipient)', () => { const effect = makeEffect([OFFCHAIN_MESSAGE_IDENTIFIER]); - const result = extractOffchainOutput([effect], expirationTimestamp); + const result = extractOffchainOutput([effect], anchorBlockTimestamp); expect(result.offchainEffects).toEqual([effect]); expect(result.offchainMessages).toEqual([]); diff --git a/yarn-project/aztec.js/src/contract/interaction_options.ts b/yarn-project/aztec.js/src/contract/interaction_options.ts index e81af07bd37e..cbfd1436ed7a 100644 --- a/yarn-project/aztec.js/src/contract/interaction_options.ts +++ b/yarn-project/aztec.js/src/contract/interaction_options.ts @@ -147,8 +147,8 @@ export type OffchainMessage = { payload: Fr[]; /** The contract that emitted the message. */ contractAddress: AztecAddress; - /** The timestamp by which this message expires and can be evicted from the inbox. */ - expirationTimestamp: bigint; + /** Anchor block timestamp at message emission. */ + anchorBlockTimestamp: bigint; }; /** Groups all unproven outputs from private execution that are returned to the client. */ @@ -164,7 +164,7 @@ export type OffchainOutput = { * Effects whose data starts with `OFFCHAIN_MESSAGE_IDENTIFIER` are parsed as messages and removed * from the effects array. */ -export function extractOffchainOutput(effects: OffchainEffect[], txExpirationTimestamp: bigint): OffchainOutput { +export function extractOffchainOutput(effects: OffchainEffect[], anchorBlockTimestamp: bigint): OffchainOutput { const offchainEffects: OffchainEffect[] = []; const offchainMessages: OffchainMessage[] = []; @@ -174,7 +174,7 @@ export function extractOffchainOutput(effects: OffchainEffect[], txExpirationTim recipient: AztecAddress.fromField(effect.data[1]), payload: effect.data.slice(2), contractAddress: effect.contractAddress, - expirationTimestamp: txExpirationTimestamp, + anchorBlockTimestamp, }); } else { offchainEffects.push(effect); diff --git a/yarn-project/end-to-end/src/e2e_offchain_payment.test.ts b/yarn-project/end-to-end/src/e2e_offchain_payment.test.ts index 13f86baca305..16a61cde6b7b 100644 --- a/yarn-project/end-to-end/src/e2e_offchain_payment.test.ts +++ b/yarn-project/end-to-end/src/e2e_offchain_payment.test.ts @@ -58,18 +58,23 @@ describe('e2e_offchain_payment', () => { } async function forceReorg(block: BlockNumber) { + // Pause sync as soon as the block is checkpointed so finalization doesn't race ahead + // of the rollback target. Without this, the archiver can finalize past the target block + // between the retryUntil returning and pauseSync executing. await retryUntil( async () => { const tips = await aztecNode.getL2Tips(); - return tips.checkpointed.block.number >= block; + if (tips.checkpointed.block.number >= block) { + await aztecNodeAdmin.pauseSync(); + return true; + } + return false; }, 'checkpointed block', 30, 1, ); - await aztecNodeAdmin.pauseSync(); - await cheatCodes.eth.reorg(1); await aztecNodeAdmin.rollbackTo(Number(block) - 1); expect(await aztecNode.getBlockNumber()).toBe(Number(block) - 1); @@ -101,7 +106,7 @@ describe('e2e_offchain_payment', () => { ciphertext: messageForBob!.payload, recipient: bob, tx_hash: receipt.txHash.hash, - expiration_timestamp: messageForBob!.expirationTimestamp, + anchor_block_timestamp: messageForBob!.anchorBlockTimestamp, }, ]) .simulate({ from: bob }); @@ -133,7 +138,10 @@ describe('e2e_offchain_payment', () => { const txBlockNumber = receipt.blockNumber!; const txHash = provenTx.getTxHash(); - const { offchainMessages } = extractOffchainOutput(provenTx.offchainEffects, provenTx.data.expirationTimestamp); + const { offchainMessages } = extractOffchainOutput( + provenTx.offchainEffects, + provenTx.data.constants.anchorBlockHeader.globalVariables.timestamp, + ); const messageForBob = offchainMessages.find(msg => msg.recipient.equals(bob)); expect(messageForBob).toBeTruthy(); @@ -144,7 +152,7 @@ describe('e2e_offchain_payment', () => { ciphertext: messageForBob!.payload, recipient: bob, tx_hash: txHash.hash, - expiration_timestamp: messageForBob!.expirationTimestamp, + anchor_block_timestamp: messageForBob!.anchorBlockTimestamp, }, ]) .simulate({ from: bob }); diff --git a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.test.ts b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.test.ts index 2b2c4cc5e300..33ea07257f65 100644 --- a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.test.ts +++ b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.test.ts @@ -16,9 +16,13 @@ import { ExecutionPayload, GlobalVariables, NestedProcessReturnValues, + OFFCHAIN_MESSAGE_IDENTIFIER, + type OffchainEffect, PrivateExecutionResult, + Tx, TxEffect, TxHash, + TxProvingResult, TxSimulationResult, } from '@aztec/stdlib/tx'; @@ -177,4 +181,58 @@ describe('BaseWallet', () => { }, ]); }); + + it('should extract offchain messages with anchor block timestamp on sendTx', async () => { + pxe = mock(); + node = mock(); + const wallet = new BasicWallet(pxe, node); + const from = await AztecAddress.random(); + + const recipient = await AztecAddress.random(); + const contractAddress = await AztecAddress.random(); + const msgPayload = [Fr.random(), Fr.random()]; + const anchorBlockTimestamp = 55555n; + + const offchainEffects: OffchainEffect[] = [ + { + data: [OFFCHAIN_MESSAGE_IDENTIFIER, recipient.toField(), ...msgPayload], + contractAddress, + }, + ]; + + // Mock the proven tx returned by pxe.proveTx + const provenTx = mock(); + provenTx.getOffchainEffects.mockReturnValue(offchainEffects); + Object.defineProperty(provenTx, 'publicInputs', { + value: { + constants: { anchorBlockHeader: { globalVariables: { timestamp: anchorBlockTimestamp } } }, + }, + }); + + const mockTx = mock(); + mockTx.getTxHash.mockReturnValue(TxHash.random()); + provenTx.toTx.mockResolvedValue(mockTx); + + // Mock dependencies for completeFeeOptions and createTxExecutionRequestFromPayloadAndFee + node.getCurrentMinFees.mockResolvedValue(new GasFees(2, 2)); + node.getNodeInfo.mockResolvedValue({ ...mock(), l1ChainId: 1, rollupVersion: 1 }); + pxe.getSyncedBlockHeader.mockResolvedValue(BlockHeader.empty()); + wallet.mockAccount.createTxExecutionRequest.mockResolvedValue(mock()); + pxe.proveTx.mockResolvedValue(provenTx); + node.getTxEffect.mockResolvedValue(undefined); + node.sendTx.mockResolvedValue(); + + const payload = new ExecutionPayload([await makeFunctionCall(FunctionType.PRIVATE, false, 'transfer')], [], []); + + const result = await wallet.sendTx(payload, { from, wait: 'NO_WAIT' }); + + expect(result.offchainMessages).toHaveLength(1); + expect(result.offchainMessages[0]).toEqual({ + recipient, + payload: msgPayload, + contractAddress, + anchorBlockTimestamp, + }); + expect(result.offchainEffects).toEqual([]); + }); }); 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 d25ae8ebcc5e..8e065f0db345 100644 --- a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts +++ b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts @@ -390,7 +390,7 @@ export abstract class BaseWallet implements Wallet { const provenTx = await this.pxe.proveTx(txRequest, this.scopesFrom(opts.from, opts.additionalScopes)); const offchainOutput = extractOffchainOutput( provenTx.getOffchainEffects(), - provenTx.publicInputs.expirationTimestamp, + provenTx.publicInputs.constants.anchorBlockHeader.globalVariables.timestamp, ); const tx = await provenTx.toTx(); const txHash = tx.getTxHash();