diff --git a/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md b/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md index 5131e4534e66..3b1534c15bc7 100644 --- a/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md +++ b/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md @@ -15,7 +15,7 @@ In Aztec, creating a note involves two steps: Without delivery, the recipient won't know the note exists or be able to access its contents, even though the note hash is onchain. -## The `.deliver()` Method +## The `.deliver()` method When you create a note using state variables like `PrivateMutable`, `PrivateSet`, `BalanceSet`, or `SinglePrivateMutable`, the creation methods return a `NoteMessage` or `MaybeNoteMessage` object. A message contains arbitrary information emitted from a contract - currently this includes notes and private events, though developers may define other message types in the future. You must call `.deliver()` on this object to send the message (containing the note) to the recipient. @@ -43,21 +43,23 @@ Aztec provides three delivery modes that offer different tradeoffs between cost, This delivery method encrypts messages without constraints and emits them via an oracle call as offchain effects, rather than through the protocol's log stream (which would post data to Ethereum blobs). With offchain delivery, you must manually handle both message transmission and processing. -#### How It Works +#### How it works Offchain messages bypass Aztec's default private log infrastructure entirely: 1. **Message emission**: The contract encrypts the message (without constraints) and emits it via an oracle call. This creates an "offchain effect" that is included in the transaction but not posted to L1. -2. **Manual extraction**: When the transaction is sent, you must extract the offchain message from the transaction's offchain effects (available via `provenTx.offchainEffects` in aztec.js). +2. **Extraction**: When the transaction is sent, you read the emitted messages from the send result (`offchainMessages` in aztec.js), or extract them from a proven transaction's offchain effects. -3. **Manual delivery**: You deliver the message through your own channel - Signal, cloud storage, QR codes, peer-to-peer networks, etc. +3. **Manual delivery**: You deliver the message through your own channel - a claim link, QR code, messaging service, cloud storage, etc. -4. **Manual processing**: The recipient calls `process_message` on the target contract (as an unconstrained function), passing the ciphertext and message context. This decrypts the message and processes it (e.g., adding notes to the PXE database). +4. **Receipt and processing**: The recipient calls the `offchain_receive` utility function on the contract that emitted the message (this function is generated automatically by the `#[aztec]` macro). The message is stored in a local inbox and processed during private state sync once the transaction that emitted it is found onchain. -The PXE cannot automatically discover offchain messages during private state sync because they are not in the log stream that nodes load from Ethereum blobs. **You are responsible for implementing both the delivery mechanism and ensuring the recipient processes the message.** +The PXE cannot automatically discover offchain messages during private state sync because they are not in the log stream that nodes load from Ethereum blobs. **You are responsible for implementing the delivery mechanism and ensuring the recipient receives the message.** -#### When to Use +See [Offchain message delivery](./offchain_message_delivery.md) for the complete workflow, including sender and recipient code, message processing details, and how to test it from Noir. + +#### When to use - **Use when:** The sender is incentivized to deliver correctly (e.g., sending to yourself, payment for goods/services where recipient must receive the note to complete the transaction) - **Costs:** Zero delivery fees (no blob space), zero proving time overhead @@ -66,7 +68,7 @@ The PXE cannot automatically discover offchain messages during private state syn This is expected to be the most common delivery method when you don't need constrained delivery guarantees, as it completely eliminates blob space costs. -#### Example Use Cases +#### Example use cases - Change notes when transferring tokens (you're sending to yourself) - Payments where the recipient won't provide goods/services without the note @@ -79,45 +81,36 @@ self.storage.balances.at(sender).add(change_amount) .deliver(MessageDelivery::offchain()); ``` -:::info TODO -This section will be updated with a complete TypeScript example showing how to extract offchain messages from transaction effects and manually deliver them once the API in Aztec.js is finalized. The full workflow example will make the offchain delivery pattern clearer. -::: - -#### JavaScript Implementation +#### JavaScript implementation -When using offchain delivery, extract and manually deliver messages in your application: +When using offchain delivery, extract the emitted messages from the send result and deliver them to the recipient in your application. The recipient hands each message to their wallet via the auto-generated `offchain_receive` utility function: ```typescript -import { MessageContext } from "@aztec/stdlib/logs" - -// Prove transaction and get offchain effects -const txProvingResult = await wallet.pxe.proveTx(txRequest); -const provenTx = new ProvenTx( - wallet.node, - await txProvingResult.toTx(), - txProvingResult.getOffchainEffects(), - txProvingResult.stats, -); - -// Extract offchain message -const offchainEffects = provenTx.offchainEffects; -const ciphertext = offchainEffects[0].data.slice(2); - -// Send tx -const sentTx = provenTx.send() -const tx = await sentTx.wait() -const txHash = await sentTx.getTxHash() - -// Deliver via your chosen channel (e.g., send to recipient via Signal, cloud storage, etc.). This is what you'd have to implement -await deliverViaMyChannel(ciphertext, recipient); - -// Recipient processes the message -const txEffect = await aztecNode.getTxEffect(txHash); -const messageContext = MessageContext.fromTxEffectAndRecipient(txEffect, recipient); -await contract.methods.process_message(ciphertext, messageContext.toNoirStruct()).simulate(); +// Sender: send the transaction and extract the emitted offchain messages +const { receipt, offchainMessages } = await token.methods + .transfer_in_private_with_offchain_delivery(alice, bob, amount, 0) + .send({ from: alice }); + +const messageForBob = offchainMessages.find((msg) => msg.recipient.equals(bob)); + +// Deliver via your chosen channel (e.g. a claim link, QR code, messaging service). +// This is what you'd have to implement. +await deliverViaMyChannel(messageForBob, receipt.txHash, recipient); + +// Recipient: receive the message so it gets processed during sync +await token.methods + .offchain_receive([ + { + ciphertext: messageForBob.payload, + recipient: bob, + tx_hash: receipt.txHash.hash, + anchor_block_timestamp: messageForBob.anchorBlockTimestamp, + }, + ]) + .simulate({ from: bob }); ``` -See the [aztec.js documentation](../../aztec-js/index.md) for more details on accessing transaction effects. +See [Offchain message delivery](./offchain_message_delivery.md) for the full workflow. ### `MessageDelivery::onchain_unconstrained()` @@ -151,7 +144,7 @@ self.storage.balances.at(recipient).add(amount) .deliver(MessageDelivery::onchain_constrained()); ``` -## Choosing a Delivery Mode +## Choosing a delivery mode Ask yourself: **"Is the sender incentivized to deliver this note correctly?"** @@ -204,11 +197,11 @@ MessageDelivery::onchain_unconstrained().via_address_derived_secret() Unconstrained delivery exposes `via_non_interactive_handshake()`, `via_interactive_handshake()` and `via_address_derived_secret()`. Constrained delivery exposes only `via_non_interactive_handshake()` and `via_interactive_handshake()`, since an address-derived secret cannot back constrained delivery. -## Note Discovery and the Sender +## Note discovery and the sender When a note is delivered, recipients need to discover it among all the encrypted logs on the network. Aztec.nr uses a **tagging system** that requires computing a shared secret between the sender and recipient. -### Who is the "Sender"? +### Who is the "sender"? The "sender" for note discovery is **not the contract calling `.deliver()`**. Instead, it's the **account contract** that initiated the transaction. @@ -216,13 +209,13 @@ When your wallet submits a transaction, it tells PXE which address to use as the **Example:** If Alice uses her account contract to call a token contract that mints tokens to Bob, the "sender for tags" is Alice's account contract address, not the token contract address. -### Discovering Notes from Unknown Senders +### Discovering notes from unknown senders When the tag is derived from an address-based shared secret, you cannot compute it for a sender you haven't registered in advance, so you cannot receive those notes from an unknown sender. Handshake protocols let the two parties agree on the secret another way and lift this restriction. See [You cannot receive address-derived tagged notes from an unknown sender](../../foundational-topics/advanced/storage/note_discovery.md#you-cannot-receive-address-derived-tagged-notes-from-an-unknown-sender) in the note discovery documentation for the approaches and workarounds. -## Delivering to Someone Other Than the Note Owner +## Delivering to someone other than the note owner You can deliver a note to an address other than the note's owner using `.deliver_to()`: @@ -239,9 +232,9 @@ self.storage.balances.at(owner).add(amount) - Game servers that track all note creation and then quickly serve you the game state (results in better UX) - Analytics or monitoring services -## Code Examples +## Code examples -### Private Token Transfer +### Private token transfer ```rust #[external("private")] @@ -258,7 +251,7 @@ fn transfer(amount: u128, sender: AztecAddress, recipient: AztecAddress) { } ``` -### Admin Initialization +### Admin initialization ```rust #[external("private")] diff --git a/docs/docs-developers/docs/aztec-nr/framework-description/offchain_message_delivery.md b/docs/docs-developers/docs/aztec-nr/framework-description/offchain_message_delivery.md new file mode 100644 index 000000000000..aa6147692c11 --- /dev/null +++ b/docs/docs-developers/docs/aztec-nr/framework-description/offchain_message_delivery.md @@ -0,0 +1,118 @@ +--- +title: Offchain message delivery +tags: [storage, concepts, notes] +description: Deliver notes and events to recipients through your own channel with offchain message delivery, avoiding data availability costs entirely. +sidebar_position: 4 +--- + +Offchain message delivery lets you send notes and events to recipients without posting any data onchain. The encrypted message is returned to the sender's application, which transports it to the recipient through its own channel: a link, a QR code, a direct message, or any other medium. This eliminates data availability (DA) costs and adds zero proving time, at the cost of the application handling transport itself. + +This page walks through the complete workflow. For guidance on when offchain delivery is the right choice compared to the onchain modes, see [Note delivery](./note_delivery.md). + +## Overview + +An offchain-delivered message goes through five stages: + +1. **Emission**: The contract delivers a note or event with `MessageDelivery::offchain()`. The message is encrypted to the recipient and emitted as an offchain effect, which is part of the transaction's execution output but never posted onchain. +2. **Extraction**: After sending the transaction, the sender's application reads the emitted messages from the send result (`offchainMessages`). +3. **Transport**: The application delivers each message to its recipient through a channel of its choosing. This step is entirely the application's responsibility. +4. **Receipt**: The recipient hands the message to their wallet by calling the `offchain_receive` utility function on the contract that emitted it. This function is generated automatically by the `#[aztec]` macro, so every Aztec.nr contract has it. +5. **Processing**: The message sits in a local inbox until the transaction that emitted it is found onchain. During private state sync, the wallet decrypts the message, validates the resulting notes and events against onchain data, and adds them to its database. + +Because processing validates the message against the onchain transaction, a malformed or dishonest message cannot trick the recipient: a message that cannot be decrypted or validated is simply ignored. + +## Emitting offchain messages from a contract + +Choose offchain delivery by passing `MessageDelivery::offchain()` to `.deliver()` (for notes) or `.deliver_to()` (for events). The token contract's `transfer_in_private_with_offchain_delivery` function delivers both resulting balance notes and a `Transfer` event offchain: + +#include_code transfer_in_private_with_offchain_delivery /noir-projects/noir-contracts/contracts/app/token_contract/src/main.nr rust + +Nothing else changes in the contract: the same notes and events are created, only their delivery mechanism differs. + +Under the hood, `MessageDelivery::offchain()` encrypts the message without constraints and emits it via the [`deliver_offchain_message`](pathname:///aztec-nr-api/#api_ref_version/noir_aztec/messages/offchain_messages/fn.deliver_offchain_message) function. See the [`MessageDelivery`](pathname:///aztec-nr-api/#api_ref_version/noir_aztec/messages/message_delivery/struct.MessageDelivery) API reference for details on guarantees, costs, and privacy. + +## Extracting messages on the sender side + +In `aztec.js`, the result of sending a transaction includes the offchain messages emitted during execution. Each message carries the encrypted `payload`, the `recipient` address, the emitting `contractAddress`, and the `anchorBlockTimestamp` of the transaction: + +```typescript +const { receipt, offchainMessages } = await token.methods + .transfer_in_private_with_offchain_delivery(alice, bob, amount, 0) + .send({ from: alice }); + +// Find the message addressed to the recipient +const messageForBob = offchainMessages.find((msg) => msg.recipient.equals(bob)); +``` + +If you manage proving manually, you can extract the messages from a proven transaction's offchain effects with `extractOffchainOutput`: + +```typescript +import { extractOffchainOutput } from "@aztec/aztec.js/contracts"; + +const { offchainMessages } = extractOffchainOutput( + provenTx.offchainEffects, + provenTx.data.constants.anchorBlockHeader.globalVariables.timestamp, +); +``` + +## Transporting messages to the recipient + +The recipient needs four pieces of information to receive a message: + +- the message payload (ciphertext) +- the recipient address +- the hash of the transaction that emitted the message +- the anchor block timestamp + +How you get these to the recipient is up to your application. Common patterns include encoding them into a claim link or QR code that the recipient opens, sending them through a messaging service, or storing them where the recipient can fetch them. + +:::warning Back up your messages +Offchain messages are not stored onchain, so they cannot be recovered from the network. Until the recipient has processed a message, losing it means the recipient cannot decrypt the corresponding note contents (the note itself still exists onchain). +::: + +## Receiving messages + +The recipient calls the auto-generated `offchain_receive` utility function on the contract that emitted the message. Utility functions run locally and do not create a transaction, so this call is free: + +```typescript +await token.methods + .offchain_receive([ + { + ciphertext: messageForBob.payload, + recipient: bob, + tx_hash: receipt.txHash.hash, + anchor_block_timestamp: messageForBob.anchorBlockTimestamp, + }, + ]) + .simulate({ from: bob }); +``` + +`offchain_receive` accepts up to 16 messages per call. It stores them in a persistent inbox scoped to each message's recipient; processing happens later, during sync. + +## How messages are processed + +Once received, messages are managed by an inbox that the wallet syncs against (see [`receive`](pathname:///aztec-nr-api/#api_ref_version/noir_aztec/messages/processing/offchain/fn.receive) in the API reference): + +- **Transaction resolution**: A message is only processed once the transaction identified by its `tx_hash` is found onchain. This provides the context needed to validate the notes and events the message contains. Until then, the message waits in the inbox. +- **Reorg safety**: Messages remain in the inbox even after processing. If a reorg reverts the transaction that emitted a message, its effects are rolled back; when the transaction is mined again, the message is reprocessed automatically without needing to be delivered again. +- **Expiry**: Messages are evicted from the inbox once they expire, which happens 26 hours after their anchor block timestamp (the maximum transaction lifetime of 24 hours plus a 2 hour tolerance). A transaction can no longer be included after that point, so an unresolved message can never become processable. + +## Current limitations + +- **Senders must self-deliver their own messages.** Messages addressed to the sender (such as the change note in a token transfer) are not processed automatically yet. Call `offchain_receive` for them just like you would on the recipient side. +- **Messages must be bound to a transaction.** Messages without an associated transaction hash are not processed in the current version; they sit in the inbox until they expire. +- **Batch size**: A single `offchain_receive` call accepts at most 16 messages. Split larger sets into multiple calls. + +## Testing offchain delivery from Noir + +The TXE test environment buffers offchain messages emitted during calls and exposes them via `env.offchain_messages()`. Feed them back through `offchain_receive` to complete the delivery loop inside a test: + +#include_code token_transfer_offchain_delivery_test /noir-projects/noir-contracts/contracts/app/token_contract/src/test/transfer_in_private_with_offchain_delivery.nr rust + +`env.offchain_messages()` returns up to 64 messages, so drain it into batches of at most 16 when a test emits more messages than one `offchain_receive` call accepts. + +## Next steps + +- Compare delivery modes and their guarantees in [Note delivery](./note_delivery.md) +- Learn how onchain-delivered notes are found in [Note discovery](../../foundational-topics/advanced/storage/note_discovery.md) +- Browse the [`MessageDelivery`](pathname:///aztec-nr-api/#api_ref_version/noir_aztec/messages/message_delivery/struct.MessageDelivery) API reference diff --git a/noir-projects/aztec-nr/aztec/src/messages/offchain_messages.nr b/noir-projects/aztec-nr/aztec/src/messages/offchain_messages.nr index 8b0dee3239f7..8c92091eedc4 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/offchain_messages.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/offchain_messages.nr @@ -9,8 +9,8 @@ pub global OFFCHAIN_MESSAGE_IDENTIFIER: Field = /// Emits a message that will be delivered offchain rather than through the data availability layer. /// /// Sends data through an alternative app-specific channel without incurring data availability (DA) costs. After -/// receiving the message, the recipient is expected to call the `process_message` function implemented on the contract -/// that originally emitted the message. +/// receiving the message, the recipient is expected to call the `offchain_receive` utility function (injected by the +/// `#[aztec]` macro) on the contract that originally emitted the message. /// /// # Example use case /// diff --git a/noir-projects/noir-contracts/contracts/app/token_contract/src/test/transfer_in_private_with_offchain_delivery.nr b/noir-projects/noir-contracts/contracts/app/token_contract/src/test/transfer_in_private_with_offchain_delivery.nr index 6d497b7e0f65..bef6fbc22048 100644 --- a/noir-projects/noir-contracts/contracts/app/token_contract/src/test/transfer_in_private_with_offchain_delivery.nr +++ b/noir-projects/noir-contracts/contracts/app/token_contract/src/test/transfer_in_private_with_offchain_delivery.nr @@ -3,6 +3,7 @@ use crate::Token; use aztec::test::helpers::authwit::add_private_authwit_from_call; use generic_proxy_contract::GenericProxy; +// docs:start:token_transfer_offchain_delivery_test #[test] unconstrained fn transfer_in_private_with_offchain_delivery_updates_both_balances() { let (env, token_contract_address, owner, recipient, mint_amount) = @@ -39,6 +40,7 @@ unconstrained fn transfer_in_private_with_offchain_delivery_updates_both_balance ); utils::check_private_balance(env, token_contract_address, recipient, transfer_amount); } +// docs:end:token_transfer_offchain_delivery_test #[test(should_fail_with = "Assertion failed: Invalid authwit nonce. When 'from' and 'msg_sender' are the same, 'authwit_nonce' must be zero")] unconstrained fn transfer_in_private_with_offchain_delivery_failure_on_behalf_of_self_non_zero_nonce() {