Skip to content

Implement TX kernel support for faucet callbacks #2432

@PhilippGackstatter

Description

@PhilippGackstatter

This issue is about implementing the tx kernel support for asset callbacks, i.e. the ability for faucets to implement blocklists or allowlists and future similar functionality.

Callback Storage

How should callbacks be stored on-chain? This was explored in #2328 (comment), but this issue refines that approach some more.

The basis of the proposal is still to have a list of well-known callbacks and assign each of them an index, for instance:

  • 0 -> procedure called when an asset is added to an account's vault, i.e. on_asset_added_to_account.
  • 1 -> procedure called when an asset is added to a note, i.e. on_asset_added_to_note.

Storage Map

This is the approach from #2328 (comment), which proposes to store callbacks in a storage list (#2176) in the faucet like this:

[
  // on_asset_added_to_account
  0x0000000000000000000000000000000000000000000000000000000000000000
  // on_asset_added_to_note
  0x497690421c9bb611b124c489557dbd65a8729db802a86199500749ec97b0342d
]

This works overall, but has the disadvantage that storage lists or maps require an extra call from the client to the node to fetch the witness for it.

Value Storage Slot Approach

This approach avoids the extra call from the client to the node with the following overall approach based on @bobbinth's idea: Callback index 0 (e.g. "on_asset_added_to_account") is assigned to 8 bits in a value storage slot. These 8 bits are interpreted as the index of an account procedure in the faucet's account code that is the callback. This means two levels of indirection.

In this approach, we standardize one value-storage slot at the protocol level, for example miden::protocol::faucet::callbacks.

It consists of four felts where the 8 least significant bits of felt 0 are callback index 0, the next 8 bits of felt 0 are callback index 1, and so on. To visualize the layout of this word:

0: [ zero bit | enabled_callbacks (7 bits) | idx=6  | idx=5  | idx=4  | idx=3  | idx=2  | idx=1  | idx=0  ]
1: [ zero bit | enabled_callbacks (7 bits) | idx=13 | idx=12 | idx=11 | idx=10 | idx=9  | idx=8  | idx=7  ]
2: [ zero bit | enabled_callbacks (7 bits) | idx=20 | idx=19 | idx=18 | idx=17 | idx=16 | idx=15 | idx=14 ]
3: [ zero bit | enabled_callbacks (7 bits) | idx=27 | idx=26 | idx=25 | idx=24 | idx=23 | idx=22 | idx=21 ]

The most significant bit is set to zero to guarantee it is a valid felt, and so this makes the most significant byte unusable as an index. So, one value storage slot has enough room for 28 callback indices. If we need more, we can add miden::protocol::asset::callbacks2 in the future.

Each index is 8 bits, which means it can point to any of the 256 possible account procedures. This is good because it means we don't rely on account code having a certain layout, e.g. auth procedure at index 0, "on_asset_added_to_account" callback at index 1, whether account procedures are sorted or unsorted by MAST root, etc.

Each callback index has exactly 8 bits, which is exactly the max possible number of procedures in an account. Unlike the map approach, we cannot set a root to the empty word to signal that the callback isn't enabled and shouldn't be called by the kernel (for example to signal that a faucet does not implement on_asset_added_to_account).

Instead, the remaining 7 bits of the most significant byte can be used for this purpose. These enabled_callbacks bits determine whether the callback at the corresponding bit index is enabled or not. For instance, if 0b0001_0010 is the most significant byte of element 2 in the storage slot, then callbacks with indices 15 and 18 are enabled, the remaining callbacks in that felt aren't.

In terms of upgradability, it would be easy to enforce that if the total number of standardized callbacks is currently 7, then elements 1, 2 and 3 of the storage slot need to be 0. That way, if we increase the number of standardized callbacks to 8, accounts will not have that callback unintentionally enabled.

Migrating from on_asset_added_to_account = idx 0 to on_asset_added_to_account_v2 = idx 10 can be done by setting the appropriate bits to 0 and 1, respectively.

Since value slots are part of storage slot headers, the callbacks are loaded into memory when the foreign account is loaded, and no further lazy loading of data should be necessary.

The downside of this approach are the two levels of indirection, which make this conceptually more complicated. Since this can be abstracted away from the user behind nicer abstractions, this is not really an issue that affects users. Secondly, it will require quite some bit manipulation in the kernel, but that should also not be a big issue as long as it is well tested.

Integrated Account Code Approach

I also considered whether we can represent the callback indices somehow as the indices of the procedures in account code. For example, "on_asset_added_to_account" must be at index 1, "on_asset_added_to_note" must be at index 2, etc. Disabled callbacks can be set to empty words, which would lead to some otherwise unnecessary padding in the account code. It is problematic that if we add a new callback at index 3 later, the account code will likely have a non-empty MAST root stored at this location, and so the callback would be unintentionally enabled. Hence, I don't think this is a good approach.

Conclusion

My favorite option would now be the value storage slot approach.

Callback Interfaces

We also need to define what the procedure signature of each callback looks like. For now, this only deals with two callbacks and more can be added later.

Asset Added To Account

#! Inputs:  [account_id, ASSET_KEY, ASSET_VALUE]
#! Outputs: [PROCESSED_ASSET_KEY, PROCESSED_ASSET_VALUE]
#!
#! Where:
#! - account_id is the ID of the account to whose vault the asset is being added.
#! - ASSET* is the asset that is being added.
#! - PROCESSED_ASSET* is the asset after being processed by the callback. It is the asset that will
#!   eventually be added to the account's vault.
pub proc on_asset_added_to_account

The processed asset probably isn't very useful for now as I believe it must always be identical to the passed-in asset. That's because the foreign account has no way of splitting a part of the asset off and moving it to a note, or minting additional tokens, etc. If it doesn't return the original asset, it would violate asset preservation rules. I assume we'll want faucets to have this ability eventually, so it may make sense to already add it. We can also start simpler - either approach shouldn't matter much for the implementation complexity.

The main open question is whether a reference to the account's vault should also be passed, as mentioned in approach 3. I think this would be a bit of a break to our existing pattern, where foreign account's do not get access to the native account's data, except for its account ID, and access is by calling ("pull") instead of data being passed to it ("push"). E.g. the native account ID can also be accessed by calling miden::protocol::native_account::get_id. Calling any other native account procedure would currently fail with ERR_FOREIGN_ACCOUNT_CONTEXT_AGAINST_NATIVE_ACCOUNT. If we do want to make the vault accessible to the foreign account, the established pattern would be to make it accessible through a kernel procedure, e.g. native_account::get_asset, or by allowing foreign accounts to call native account procedures (tbd in terms of security).

This would give the native account the control of whether to have a procedure in its interface that allows or disallows access in more controlled ways, e.g. checking whether an account owner is over 18 by calling a procedure of the appropriate standardized "identity interface", instead of granting access to the entire vault unconditionally.

Asset Added To Note

#! Inputs:  [note_idx, ASSET_KEY, ASSET_VALUE]
#! Outputs: [PROCESSED_ASSET_KEY, PROCESSED_ASSET_VALUE]
#!
#! Where:
#! - note_idx is the index of the note to which the asset is being added.
#! - ASSET* is the asset that is being added.
#! - PROCESSED_ASSET* is the asset after being processed by the callback. It is the asset that will
#!   eventually be added to the note's assets.
pub proc on_asset_added_to_note

The note index is useful because the foreign account can call miden::protocol::output_note::get_assets and some other getters to inspect the other assets of the note. Later it may also be useful to add data to the note's attachment as discussed elsewhere (currently restricted to when active account = native account).

I'm not as deep into the requirements of these callbacks, so I appreciate any input on this cc @onurinanc @MCarlomagno.

Standard Storage Slot

We'll probably also want at least a "standard storage slot" (like TokenMetadata) that makes this callback storage slot nicely accessible. I'm not sure yet how faucet owners would mutate the callbacks storage slot after initial creation, so an account component that allows for mutation may not be necessary right away. Instead, a standard storage FaucetCallbacks slot may be enough for now. But I haven't thought about this a lot.

Other Thoughts

  • One of the bigger follow-ups from the callback workstream more broadly will probably be thinking about what APIs we can allow foreign accounts to safely access. For instance, faucet::{mint, burn} are currently restricted to the native account, but it should be safe to allow faucet A to mint more of its own assets during a callback, since it can define its own logic.
  • merge, split, compare (see Expand asset layout from one to two words #2328) and other such asset procedures aren't really callbacks, they are rather the "asset interface". So putting them into the same slot as the callbacks may be most efficient, but doesn't feel right on a conceptual level. Moving them to a separate storage slot may be fine, too.

Metadata

Metadata

Labels

kernelsRelated to transaction, batch, or block kernels

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions