Skip to content

refactor: forbid globally scoped capsules#21614

Open
mverzilli wants to merge 44 commits intomartin/scoped-capsulesfrom
martin/capsule-arrays-scope-up-1
Open

refactor: forbid globally scoped capsules#21614
mverzilli wants to merge 44 commits intomartin/scoped-capsulesfrom
martin/capsule-arrays-scope-up-1

Conversation

@mverzilli
Copy link
Contributor

@mverzilli mverzilli commented Mar 16, 2026

Depends on #21588

Uses the new scoped functionalities of capsules from as many places in the codebase as possible

Note: this PR makes the distinction between stored capsules and volatile arrays even more painfully clear, as capsules used just for rpc comms between Aztec.nr and PXE don't really need a scope but at the same time would be better off not being global to remove concurrency constraints

Closes F-459

@mverzilli mverzilli changed the title Martin/capsule arrays scope up 1 refactor: default to scoped capsule arrays Mar 16, 2026
@mverzilli mverzilli marked this pull request as ready for review March 16, 2026 17:15
@mverzilli mverzilli requested a review from nventuro as a code owner March 16, 2026 17:15
@mverzilli mverzilli removed the request for review from nventuro March 16, 2026 17:15
Comment on lines +118 to +119
let inbox: CapsuleArray<PendingOffchainMsg> =
CapsuleArray::at(contract_address, OFFCHAIN_INBOX_SLOT, msg.recipient);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not super elegant but it doesn't seem like a costly abstraction, so leaving it like that

node,
contractStore,
noteStore,
() => keyStore.getAccounts(),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to kill the "ALL_SCOPES" idea and get rid of this

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I support you in this endeavor. We got to ALL_SCOPES through "evolution" and not through designing.

// Resolve ALL_SCOPES to actual registered accounts, since sync_state must be called once per account.
const scopeAddresses = scopes === 'ALL_SCOPES' ? await this.getRegisteredAccounts() : scopes;

await Promise.all([
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ideally we could get rid of plural scopes throughout the systems, which would let us simplify this and the code below

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"get rid of plural scopes throughout the system" is too much, there are legitimate use cases (such as getting notes scoped to multiple accounts at once).

Comment on lines 143 to +148
let context_resolution_requests: CapsuleArray<Field> =
CapsuleArray::at_global_scope(address, OFFCHAIN_CONTEXT_REQUESTS_SLOT);
CapsuleArray::at(contract_address, OFFCHAIN_CONTEXT_REQUESTS_SLOT, scope);
let resolved_contexts: CapsuleArray<Option<MessageTxContext>> =
CapsuleArray::at_global_scope(address, OFFCHAIN_CONTEXT_RESPONSES_SLOT);
CapsuleArray::at(contract_address, OFFCHAIN_CONTEXT_RESPONSES_SLOT, scope);
let ready_to_process: CapsuleArray<OffchainMessageWithContext> =
CapsuleArray::at_global_scope(address, OFFCHAIN_READY_MESSAGES_SLOT);
CapsuleArray::at(contract_address, OFFCHAIN_READY_MESSAGES_SLOT, scope);
Copy link
Contributor Author

@mverzilli mverzilli Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These could technically be global scope capsules if we left the scope param out of resolve_message_contexts, but given there is no legacy here I preferred to scope them and gain some extra isolation points.

We should eventually replace this with volatile arrays which are the right idea to use here

@mverzilli mverzilli requested review from nventuro and removed request for nventuro March 17, 2026 12:04

/// Receives offchain messages into this contract's offchain inbox for subsequent processing.
///
/// Each message is routed to the inbox scoped to its `recipient` field.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is one decision open to debate, I went for this initially because I had the "scoped sync_state" refactor ahead of me, but now I'm wondering if offchain_receive shouldn't explicitly receive an array of scopes and place each message in each scope regardless of who is each message's specific recipient

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see scopes as an implementation detail of PXE. The message was sent to X, it is processed as received by X. How it is stored in the db etc feels divorced from this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't messages generally destined for 1 scope?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, each message has one recipient, but as we've been discussing in other chats, there's a bit of chaos to tame here:

  1. offchain_receive is a utility
  2. it needs to be run with from, which will become its scope
  3. but it can receive multiple messages, each with its own recipient
  4. that opens the door to multiple possible behaviors. if the recipients != scope, what do we do? ignore? throw? route them to their inbox? something else?

moreover, there's the concept of additionalScopes when running private functions:

  1. Will we ever want to support additionalScopes from utilities as well?
  2. How does that behave in combination with message recipients (same questions as above)?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it a problem to have multiple recipients? Are we not allowed to store something in a scope that is not currently open?

To clarify what I mean, the from in an utility fn is a mistake, it's just a simple way to specify the scopes to use. We use from to not expose this complexity to the app, and the wallet does from -> scopes. But that's just a shortcut.

Conceptually there is no problem in procesing multiple messages to different recipients, though if they are not in scope then msg processing may fail (e.g. we may not be able to read their keys).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no inherent problem, just a degree of freedom that I haven't seen in other of our contract APIs so I thought worth explicitly mentioning


pub(crate) unconstrained fn process_private_event_msg(
contract_address: AztecAddress,
recipient: AztecAddress,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another debatable change: I removed the recipient params from all process_*_msg variants, though it might in the future be useful to have access to that info when processing. I chose to remove them nevertheless because it's not used, YMMV.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recipient is the scope here so you should always have that info during processing just by the fact that you are able to load the thing from the correct scope, no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, correct. When I wrote this I wasn't 100% sure that always the scope is the recipient

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoa I'm not sure about this. Why does the scope pop up here? It is because of the capsule oracle? If so this is news conceptually - the fact that the scope is the recipient used to be a pxe detail, but now this leaks into nr.

@mverzilli mverzilli changed the title refactor: default to scoped capsule arrays refactor: forbid globally scoped capsules Mar 19, 2026

/// Receives offchain messages into this contract's offchain inbox for subsequent processing.
///
/// Each message is routed to the inbox scoped to its `recipient` field.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't messages generally destined for 1 scope?

pub(crate) unconstrained fn get_private_logs(contract_address: AztecAddress) -> CapsuleArray<PendingTaggedLog> {
///
/// Note that eventually it would make sense to make this function accept an array of addresses as of scopes, since it
/// might be useful to integrate all private logs known to a set of accounts. However, this function is currently only
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

integrate all private logs known to a set of accounts

WDYM by this? Loading logs "into" multiple scopes at once?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, maybe this is too much philosophizing on my end. This function as is just fetches logs, one could imagine a version of this that fetches logs from multiple scopes at once and does something with them, but currently there's no use for that. Maybe I should just remove this comment which ends up adding more noise than clarity


pub(crate) unconstrained fn process_private_event_msg(
contract_address: AztecAddress,
recipient: AztecAddress,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recipient is the scope here so you should always have that info during processing just by the fact that you are able to load the thing from the correct scope, no?

process_custom_message,
message_plaintext,
message_context,
message_context.recipient,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somewhat unrelated but above we have this comment:

        // Real messages would also have a recipient, a concept which is currently half-supported in `TestEnvironment`
        // as events are properly scoped by recipient, but notes use a global scope instead. We therefore simply set
        // the zero address as the recipient if one is not supplied, which PXE accepts as a scope despite this not
        // being a registered account.

The zero address seems to act here as somewhat of a global scope. Do you know if there is some plan to purge that? And do you know if the notes truly still use global scope? I am worried that we will forget about this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will review that comment. I think notes no longer use global scope, especially after this change

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should remove that recipient=ZERO line and make sure we're using a proper recipient. I'm surprised this hasn't caused issues so far.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool cool, please link the issues here then. Thanks

.simulate({ from: recipient });

// Force a block so PXE re-syncs and processes the offchain-delivered message
await forceEmptyBlock();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this indicate that the current offchain_receive flow is cumbersome in the real world as we might want to have a flow in which we would want to fully process an offchain message and then we would need to wait for a new block? Shouldn't we have the ability to trigger re-sync at the same anchor block somehow exposed?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see there is already this issue for it. Would link it in the comment of forceEmptyBlock

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do. There's actually a new oracle to invalidate the cache for the given contract+scope combination, I just need to use it for this hack to be gone.

Comment on lines +603 to +604
// At the same time, we don't want allow any global scope access other than where backwards compatibility forces
// us to, so we need scope to be here artificially.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// At the same time, we don't want allow any global scope access other than where backwards compatibility forces
// us to, so we need scope to be here artificially.
// At the same time, we don't want to allow any global scope access other than where backwards compatibility forces
// us to. Hence the scope here is "artifical".

The wording seemed cumbersome.

node,
contractStore,
noteStore,
() => keyStore.getAccounts(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I support you in this endeavor. We got to ALL_SCOPES through "evolution" and not through designing.

Base automatically changed from martin/default-to-scoped-capsules to martin/scoped-capsules March 23, 2026 11:31
@@ -141,6 +143,7 @@ pub unconstrained fn do_sync_state<CustomMessageHandlerEnv>(
process_custom_message,
message_ciphertext,
pending_tagged_log.context,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe for an upcoming refactor:

  • pending_tagged_log.context is a MessageContext.
  • MessageContext has a recipient.
  • The recipient is, by definition, the scope, because that's what we're filtering by in the get_private_logs call above

This is a remnant from before introducing scope to sync_state, when get_private_logs was expected to include messages aimed at different recipients.

Carrying the recipient through this introduces some noise, since now process_message_ciphertext will have the scope or the recipient to pick from.

-> We should remove recipient from MessageContext.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decided to tackle said refactor now, because it impacts oracle interfaces, so it's better to include it in the next release to avoid unnecessary version bumps


/// Receives offchain messages into this contract's offchain inbox for subsequent processing.
///
/// Each message is routed to the inbox scoped to its `recipient` field.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see scopes as an implementation detail of PXE. The message was sent to X, it is processed as received by X. How it is stored in the db etc feels divorced from this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants