Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental offchain storage pt 1 #1630

Merged
merged 45 commits into from
May 15, 2024

Conversation

mitschabaude
Copy link
Member

@mitschabaude mitschabaude commented May 2, 2024

MVP design of an offchain state implementation.

Design

There are currently two types of offchain state: OffchainState.Field (a single field of state) and OffchainState.Map (a key-value map).

  • All offchain state is stored in a single Merkle map (Merkle tree of size 256)
  • There are no practical limits to the number of state fields and maps
  • you can use (pure) provable types of size up to ~100 field elements (~size of an action) for field and map values. (Map keys have unlimited size, since they don't need to be part of the action.)
  • Fields support field.get() and field.set(value) in a contract
  • Maps support map.get(key) and map.set(key, value)

To use offchain state, a smart contract developer must

  • declare an OffchainState
  • call offchainState.compile() and offchainState.setContractInstance() in the setup phase
  • add a specific onchain state field manually. This might seem brittle, but is somewhat type-safe because setContractInstance() requires the contract to have that state field.
  • manually add a settle() method and call it periodically to settle state
    • state is only available for get() after it was settled
    • the settle() implementation is trivial using the tools OffchainState provides
    • settling also involves calling createSettlementProof() outside the contract first, which is also simple from the user point of view

Some design decisions:

  • All info required to use offchain state is available from actions. No extra events or other external data store.
  • Currently, the entire merkle tree is recovered on the fly by each user, from fetched actions.
    • Pro: there is no extra service to set up to distribute the merkle tree
    • Con: This scales badly, especially with how naive the MVP implementation is
    • Con: The slowness of the archive node is a major bottleneck to being able to settle and use state
    • To me this seems like a good baseline design, to which more advanced storage and distribution mechanisms can be added later

Caveats / Future work

While writing the example, I realized that the pure set() API, which instructs offchain settlement to blindly overwrite a current value with the new one, is a major footgun and probably only useful for specific scenarios (e.g. each user of a contract is empowered to manage their own slice of state and overwrite it without taking into account old state. example: registration as a voter), but fails as a solution for the majority of use cases.

Q: Should we rename set() to overwrite() to be more honest about its limitations?

An extension that can be made easily is to add an update() method which not only takes the new state, but also the old state to replace. It only succeeds if the old state is correct, i.e. it acts exactly the same as a precondition for onchain state.

  • update() can be implemented such that it fails atomically on an entire account update. E.g., you make two state updates, and both fail if one of the old states is invalid.
    • This lets you properly implement a transfer() method, for example. The method which dispatches the transfer will only dispatch valid pairs of to/from balance changes. If one of them is outdated, both fail, so there's no way to cause an invalid balance change.
    • A variant of this, which reuses the implementation, would also enable map entries that can only be modified once. E.g. nullifiers. We just hard-code the "old state" hash to the initial 0 value

With update(), what we arrive at is a clean extension of the onchain state API to offchain state of arbitrary size. It completely solves the size problem. However, it doesn't fully solve the concurrency problem:

  • for state that is per-user, like individual account balances, concurrency is not a dealbreaker. A user has to sequence their own individual interactions - fine.
  • however, the current design does NOT solve the problem of shared state that is modified concurrently. A good example is the token supply in our example: The current design can only change supply (= mint or burn) one block at a time.

One idea to improve shared state handling is to introduce user-defined (or predefined) commutative actions. For example, an action could say "add this number to the state" instead of "change the state to this value".

  • This would solve the token supply use case example
  • I think this can be shipped as an extension to the current API, where commutative actions are additional inputs to the OffchainState.Field / OffchainState.Map declarations
  • the implementation is more involved and comes with an overhead because it means the reducer has to interact with state values, not just value hashes. it also has to disambiguate different state fields. (Efficiency-wise, both of those factors should be dominated by the Merkle hashing per action that is already done)

The final design should probably be more similar to protokit's runtime modules: A client/server like model where arbitrary computation is run in an offchain reducer to go from the previous to the next set of states, in one atomic update.

@mitschabaude mitschabaude marked this pull request as ready for review May 14, 2024 06:17
Copy link
Member Author

@mitschabaude mitschabaude left a comment

Choose a reason for hiding this comment

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

Some other changes I forgot to mention in the description!

Reducer,
} from './lib/mina/zkapp.js';
export { SmartContract, method, declareMethods } from './lib/mina/zkapp.js';
export { Reducer } from './lib/mina/actions/reducer.js';
Copy link
Member Author

Choose a reason for hiding this comment

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

moved Reducer code to a separate file, zkapp.ts is unreadable enough without it

Copy link
Member Author

Choose a reason for hiding this comment

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

no new code here, just moved from zkapp.ts

Comment on lines +78 to +79
function State<A>(defaultValue?: A): State<A> {
return createState<A>(defaultValue);
Copy link
Member Author

Choose a reason for hiding this comment

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

State() now accepts passing in a default value, which is used to initialize it by init()!

this is very handy when there's no other reason to override init(), like in our offchain state example

Comment on lines +780 to +787
// for all explicitly declared states, set them to their default value
let stateKeys = getLayout(this.constructor as typeof SmartContract).keys();
for (let key of stateKeys) {
let state = this[key as keyof this] as InternalStateType<any> | undefined;
if (state !== undefined && state.defaultValue !== undefined) {
state.set(state.defaultValue);
}
}
Copy link
Member Author

Choose a reason for hiding this comment

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

new logic in init() that uses default values for all state fields that have one

Comment on lines +194 to +205
/**
* Iterate through the list in a fixed number of steps any apply a given callback on each element.
*
* Proves that the iteration traverses the entire list.
* Once past the last element, dummy elements will be passed to the callback.
*
* Note: There are no guarantees about the contents of dummy elements, so the callback is expected
* to handle the `isDummy` flag separately.
*/
forEach(
length: number,
callback: (element: T, isDummy: Bool, i: number) => void
Copy link
Member Author

Choose a reason for hiding this comment

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

API for iterating a MerkleList without having to explicitly instantiate an iterator

this is safer than .next() since it does assertAtEnd() for you

Copy link
Member Author

Choose a reason for hiding this comment

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

This fixes two pain points I always had when dealing with MerkleTrees:

  • can't clone them
  • can't just get a leaf, need to figure out how to use getNode()

Copy link
Member Author

Choose a reason for hiding this comment

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

A nice new Option type!

Comment on lines -24 to -25
provable,
provablePure,
Copy link
Member Author

Choose a reason for hiding this comment

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

I always find re-exports confusing, removed them here

Copy link
Member Author

Choose a reason for hiding this comment

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

using Unconstrained as part of a MerkleList Struct element (ex: MerkleLeaf in this PR) used to fail in a very mysterious way, because the Merkle list attempted to use .empty() and Struct deeply walked the Unconstrained without finding anything.

These changes add a clear error message, and an easier way to declare the empty type alongside an Unconstrained type

@@ -18,14 +18,13 @@ const doProofs = true;
const beforeGenesis = UInt64.from(Date.now());

class SimpleZkapp extends SmartContract {
@state(Field) x = State<Field>();
@state(Field) x = State(initialState);

events = { update: Field, payout: UInt64, payoutReceiver: PublicKey };

@method
async init() {
Copy link
Member

Choose a reason for hiding this comment

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

We should be able to remove this method entirely now, I think?

Copy link
Member Author

Choose a reason for hiding this comment

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

no because this example uses the fact that it's a @method and affects provedState

}

// base proof
console.time('batch 0');
Copy link
Member

Choose a reason for hiding this comment

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

note to remove these before we merge

Copy link
Member Author

Choose a reason for hiding this comment

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

@Trivo25 I removed it in the next PR, ok? #1652

@mitschabaude mitschabaude merged commit 89c59c7 into main May 15, 2024
14 checks passed
@mitschabaude mitschabaude deleted the feature/experimental-offchain-state branch May 15, 2024 08:29
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.

None yet

2 participants