-
Notifications
You must be signed in to change notification settings - Fork 143
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
Sequence Events RFC #265
Comments
Sequence Events RFCPart 1: MotivationLike events, sequence events represent arbitrary information that can be passed along with a zkApp transaction. However, they have one additional feature: they are committed to on chain. We call that commitment the "sequence state"; it is a field on every zkApp account. Roughly, the sequence state is a hash (Merkle list) of the entire history of sequence events that have been posted to the account so far. Like with other account fields, you can set a precondition on the sequence state. Being able to set this precondition means that you can use previously emitted sequence events inside your smart contract method. To do this, you pass in a list of sequence events and an old sequence state hash, and recompute the new (current) sequence state hash inside the circuit. Then, you set this new sequence state hash as a precondition. Thus, you prove that the sequence event list you have is actually the same list of sequence events that were posted on-chain -- in the same order -- since the old sequence state hash. @mrmr1993 designed this as a clever mechanism to circumvent the problems with concurrent state updates in zkApps. Let's state the problem first: Say we have a zkApp that wants to update its state in each user interaction, such that the new state depends on the old state plus the interaction inputs. (This probably applies to most zkApps!) The naive way is to directly update on-chain state in every interaction. To compute the new state, however, we have to use the current state. To prove that we're really using the current state that's on-chain, we have to set a precondition on the current state. The problem: If multiple users send their transactions to the same block, without knowing from each other, they will set the same precondition on the current state, and both will also update that state. Thus, every transaction after the first one has an outdated precondition on it, and is rejected. Let's picture this with a simple example, where the
Two users sent an "increment" transaction, but the on-chain state was only incremented once! Here's how we can solve this: Assume that we can represent our state updates as sequence events, such that the order in which those updates are applied doesn't matter (this is typically possible; more on that later). Then, we could write our smart contract so that normal users do not update on-chain state. They just emit a sequence event instead. No precondition, and no conflicts with other transactions!
The two user transactions are not conflicting -- both get applied, and they end up creating an account with two "increment" events. (Note: To simplify the explanation, here I am just picturing sequence events as "events that are stored on chain". This is not precisely what happens, but is a reasonable abstraction, since we can prove that certain events were stored on chain.) The idea here is that the on-chain state is slightly lagging behind the state implied by the current list of events. To bring the on-chain state up to date with the list of events, we have another smart contract method, which we could call the "rollup method".
Let's add that rollup transaction to the picture above:
Now the on-chain state reflects all the updates that happened so far! 🎉 The rough idea is that anyone would be able to send rollup transactions, so it's censorship-resistant; but probably the zkApp developer would want to set up some service that monitors the chain and makes sure to update state from time to time. Note: If two people would send rollup transactions at the same time, one of them would fail. But this doesn't matter, since no data is lost -- all the data about actual state updates is in the list of events, which can always be added without conflicts. Another note: The pictures above may make it seem like someone could now run the rollup transaction again, using the two existing events as inputs again to update the state to the corrupted value of 4. However, it's not hard to prevent this in the real implementation. |
Explicitly: by burning an appState entry to keep track of the most-recently-applied sequence event state, and adding that into the precondition. Great write up, thanks so much @mitschabaude! |
Sequence Events RFCPart 2: APIA bare-bones sequence events API could look like this: // property that has to be set on `SmartContract`
sequenceEvent: AsFieldElements<T>;
// emit a sequence event
this.emitSequenceEvent(event: T): void;
// compute the new sequence state (i.e., a hash) from a previous state and an array of events
this.updateSequenceState(stateHash: Field, events: T[]): Field;
// fetch array of arrays of sequence events (inside the circuit, so pre-fetched and not async)
// each of the arrays are the sequence events emitted by one party.
// they have to be fed to `updateSequenceState` one by one
this.getSequenceEvents({ fromStateHash: Field, toStateHash?: Field }): T[][]; Disclaimer: I didn't implement that API, and will explain why in a minute.
In the current implementation (#274), I decided to skip exposing this low-level API, and instead reached for a slightly higher level of abstraction. The reason is that when I implemented the "rollup transaction" described above, it was much harder than anticipated. The complication is that the number of sequence events you want to roll up in one transaction should be dynamic, so you can be flexible with how frequently that method is called. Also, you might have several smart contract methods which emit different numbers of events. So you end up with a computation that's dynamic-size in two dimensions, which has to be fit into a static circuit. I think most users just wouldn't have an idea on how to do this -- which means we need to give them a function which abstracts it away: which takes the dynamic-size events list and handles rolling them up. However, part of the rolling up logic is user-defined. E.g., in the example with the "increment" events above, rolling up meant to count the increment events. But that's application-specific, so users need a general-purpose framework for defining this kind of update logic themselves. Here's what I came up with (already implemented): On a
Here's the code for our "counter" / "increment" example: class CounterZkapp extends SmartContract {
stateUpdate = SmartContract.StateUpdate({
state: Field,
update: Field,
apply(state: Field, _update: Field) {
return state.add(1);
},
});
// ...
} In this particular case, the value of the The this.stateUpdate.emit(update: U): void;
this.stateUpdate.applyUpdates({ state: S, stateHash: Field, updates: U[][] }): { state: S, stateHash: Field });
A fully-fledged example is here: https://github.com/o1-labs/snarkyjs/blob/fc4dcf7ad5293e6f89ec5f8f4560e5d2a09bf48e/src/examples/state_update_rollup.ts Further remarks:
this.stateUpdate.getUpdates({ fromStateHash: Field, toStateHash?: Field }): U[][];
|
Thanks Gregor! Some questions on pt 1 CC @mrmr1993
Makes sense. |
very well written! some questions/thoughts on part 1:
|
Thanks @mitschabaude ! This is a great write-up and I really like your StateUpdate abstraction. But I think we should take it even further! What we have here is really close to the Elm Architecture-style state management. This is a really nice way to deal with state machines in an application and have really nice properties, so it makes sense that it comes up when we're trying to model our state machine here! By the way, what we really have is a This should be familiar to TypeScript/JavaScript web developers through its instantiation via the Redux library or more "recently" (does this age me?) via the Not only is it important to call this out in documentation because it will help people reason about how to model their state-update logic, but I think we should try to push the API to feel more like Elm/useReducer/Redux/etc. This actually doesn't meaningfully change what you've proposed too much. NamingSomething that immediately falls out is the nomenclature:
We're missing a nice way to define the Commutativity PropertyWhat's unique here and slightly different than a typical Elm Architecture implementation is that actions are deferred from being reduced immediately against the state and that you typically want your state machine to allow actions to commute ie (given a left-associative update With a quick google, I haven't found any prior art for any "Commutative Elm Architecture" or "Reducers with Commutative Actions", but I think this doesn't actually impact the way the API is defined -- it's just something developers need to think about when they're designing their state and reduce logic. Instantiation and dispatchRough proposal: reducer = SmartContract.Reducer({
stateType: Field,
actionType: Field,
initialState: new Field(0), // see below, this assumes we do state management
reduce: (state: Field, _update: Field) {
return state.add(1);
}
})
//...
this.reducer.dispatch(action: Action): void ; Fetching and reducingAs far as managing fetching and reducing the actions (ie "settling the rollup"): In some ways, I really like the "rollup" nomenclature because it truly is performing a similar settling operation as any rollup, but in other ways there will be a lot of different rollups floating around given that a lot of the time the smart contract will be a zk-rollup on another dimension simultaneously. Alternatively, we can just stick with the Elm Architecture / Redux / useReducer world and say something like The other question that comes to my mind is: To what extent should we manage the state for people? What you've proposed above is explicitly not managing it at all. The developer needs to decide where to store the state if anywhere and remember which actions they have processed so far. Alternatively, we could manage the state for the developer -- potentially via some mechanism where you can say if you want it in on-chain state, off-chain via a merkle root, or somewhere else. I haven't fully thought this through, but I think this would make it a lot easier to use this tool in their zkApps. |
@bkase I love the idea of using "reducer" terminology! Re: should we manage state -- when thinking more about the use cases for sequence events, I discovered what feels like an important use case, that isn't covered well by having a single Namely, there's the case that
Example: Let's say a simplified poker zkApp creates events of the form So, there are two points here:
Basically, the list of events themselves is the state in this use case, and it's important to reason over that list of events inside smart contracts, so we have to use sequence events instead of normal events. As another point, there might be other smart contract methods that run an entirely different query on the list of events, so that the
To offer such flexibility, we should only declare the event type, i.e. Here's how that could look in a full example -- the counter example we already discussed. const INCREMENT = Field.one;
class CounterZkapp extends SmartContract {
reducer = SmartContract.Reducer({ actionType: Field });
@state(Field) counter = State<Field>();
// we also need the stateHash in on-chain state to know where in the action history we are
@state(Field) stateHash = State<Field>();
@method incrementCounter() {
this.reducer.dispatch(INCREMENT);
}
@method rollupIncrements() {
// get on-chain state & state hash, add preconditions for them
let counter = this.counter.get();
this.counter.assertEquals(counter);
let stateHash = this.stateHash.get();
this.stateHash.assertEquals(stateHash);
// fetch list of actions
let actions = this.reducer.getActions({ fromStateHash: stateHash });
// state type
let stateType = Field;
// reduce takes not only the actions and initial state / hash as parameters (as before),
// but also the stateType and reduce callback
let { state: newCounter, stateHash: newStateHash } = reducer.reduce(
actions,
stateType,
(state: Field, _action: Field) => {
return state.add(1);
},
{ state: counter, stateHash }
);
// store the new state / stateHash on chain
this.counter.set(newCounter);
this.stateHash.set(newStateHash);
}
} Note: In this example, we do have just one For reference, this is the proposed API: reducer = SmartContract.Reducer({ actionType: AsFieldElements<A> });
this.reducer.dispatch(action: A): void;
this.reducer.reduce<S>(
actions: A[][],
stateType: AsFieldElements<S>,
reduce: (state: S, action: A) => S,
initial: { state: S, stateHash: Field }
): { state: S, stateHash: Field };
// to be implement later
this.reducer.getActions({ fromStateHash?: Field, toStateHash?: Field }): A[][]; The only part I'm not very confident about is the naming of the Maybe it should be |
Just to clarify, the first two txns modify the account state immediately. They update the "sequence state" on the account, which is a hash of all sequence events so far; so the fact that these two events happened will be forever recorded on chain, even if there is no rollup tx at all. Not sure if that answers your questions -- I think we don't really depend on the txns being in the mempool. We do depend on being able to fetch the list of events from somewhere, to be able to rollup. We could fetch recent events from the Mina node, but they probably will get unavailable after some time (don't know the technical details). Or, we could fetch them from an archive node, where they will be available forever; or keep track of them on our own.
I don't think they are, currently
Excellent question! Event-emitting txns don't have a precondition on them, so they can't become invalid. But the rollup txn has a precondition on the current sequence state, so it's valid to ask: What happens if more event-emitting txns sneak in while the rollup txn is being prepared -- will the rollup txn have an outdated precondition? The answer is no, but requires digging into the details: The on-chain sequence state field actually stores sequence state hashes for the 5 most recent slots that contained sequence events, and the sequence state precondition is accepted if it matches any of these 5. So, say that an account has the following sequence state hashes at the end of a slot: The first of the new events will shift the on-chain sequence state to the right, and add a new element, which represents the current slot: Now, if another new event gets submitted within the same slot, it doesn't shift the states to the right again, but also updates the first entry: So the state So, what do we do if we want to create a rollup txn?
It only ever makes sense to refer to the sequence event state at the end of a completed block, because that's when the order of sequence events has been fixed. It doesn't make sense to target the block that's currently being constructed, and guess the order that the events in there will be applied.
Yes, if we do it like in my code example in the last post, no events can be lost -- since the smart contract method forces us to start at the
That's a good point. Maybe we want to encourage a framing where the pending events are also considered part of the current state, and UIs display it as such, because it's basically guaranteed that they will eventually end up in on-chain state. Not sure if that's smart though... |
I really like the latest version of this 👍 . I like |
I like this idea and I think it's pretty safe as you said. Depends on the application, but for most the existence of a sequence event is good enough I think. |
No description provided.
The text was updated successfully, but these errors were encountered: