From 467f0a06c4bc0a960a8f197a6ea88277e556d9d5 Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 13:47:37 +0200 Subject: [PATCH 01/29] increase updates per batch in rollup --- src/lib/mina/actions/offchain-state-rollup.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/mina/actions/offchain-state-rollup.ts b/src/lib/mina/actions/offchain-state-rollup.ts index 077cbd9fb3..b362703fb7 100644 --- a/src/lib/mina/actions/offchain-state-rollup.ts +++ b/src/lib/mina/actions/offchain-state-rollup.ts @@ -105,7 +105,9 @@ function merkleUpdateBatch( * This program represents a proof that we can go from OffchainStateCommitments A -> B */ function OffchainStateRollup({ - maxUpdatesPerBatch = 2, + // 1 actions uses about 7.5k constraints + // we can fit 7 * 7.5k = 52.5k constraints in one method next to the proof verification + maxUpdatesPerBatch = 3, maxActionsPerUpdate = 2, } = {}) { let offchainStateRollup = ZkProgram({ From 7346f0c06bce060a0d45b0e384399707134f0042 Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 16:11:36 +0200 Subject: [PATCH 02/29] better assertAtEnd error message --- src/lib/provable/merkle-list.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/lib/provable/merkle-list.ts b/src/lib/provable/merkle-list.ts index bb1173837e..6144b42e53 100644 --- a/src/lib/provable/merkle-list.ts +++ b/src/lib/provable/merkle-list.ts @@ -209,7 +209,9 @@ class MerkleList implements MerkleListBase { let { element, isDummy } = iter.Unsafe.next(); callback(element, isDummy, i); } - iter.assertAtEnd(); + iter.assertAtEnd( + `Expected MerkleList to have at most ${length} elements, but it has more.` + ); } startIterating(): MerkleListIterator { @@ -394,8 +396,11 @@ class MerkleListIterator implements MerkleListIteratorBase { this.currentHash = Provable.if(condition, this.hash, this.currentHash); } - assertAtEnd() { - return this.currentHash.assertEquals(this.hash); + assertAtEnd(message?: string) { + return this.currentHash.assertEquals( + this.hash, + message ?? 'Merkle list iterator is not at the end' + ); } isAtStart() { From da7737319cb41ffa735d22970c7661c10a217a0c Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 16:12:48 +0200 Subject: [PATCH 03/29] linearize actions before processing, to avoid spending constraints on dummies --- src/lib/mina/actions/offchain-state-rollup.ts | 159 ++++++++++-------- 1 file changed, 92 insertions(+), 67 deletions(-) diff --git a/src/lib/mina/actions/offchain-state-rollup.ts b/src/lib/mina/actions/offchain-state-rollup.ts index b362703fb7..8e10b1fbad 100644 --- a/src/lib/mina/actions/offchain-state-rollup.ts +++ b/src/lib/mina/actions/offchain-state-rollup.ts @@ -48,10 +48,10 @@ class MerkleMapWitness extends MerkleWitness(TREE_HEIGHT) {} */ function merkleUpdateBatch( { - maxUpdatesPerBatch, + maxActionsPerBatch, maxActionsPerUpdate, }: { - maxUpdatesPerBatch: number; + maxActionsPerBatch: number; maxActionsPerUpdate: number; }, stateA: OffchainStateCommitments, @@ -62,41 +62,47 @@ function merkleUpdateBatch( actions.currentHash.assertEquals(stateA.actionState); let root = stateA.root; - // TODO: would be more efficient to linearize the actions first and then iterate over them, - // so we don't do the merkle lookup `maxActionsPerUpdate` times every time - // update merkle root for each action - for (let i = 0; i < maxUpdatesPerBatch; i++) { - actions.next().forEach(maxActionsPerUpdate, ({ key, value }, isDummy) => { - // merkle witness - let witness = Provable.witness( - MerkleMapWitness, - () => new MerkleMapWitness(tree.get().getWitness(key.toBigInt())) - ); - - // previous value at the key - let previousValue = Provable.witness(Field, () => - tree.get().getLeaf(key.toBigInt()) - ); + // linearize actions into a flat MerkleList, so we don't process an insane amount of dummy actions + let linearActions = ActionList.empty(); - // prove that the witness is correct, by comparing the implied root and key - // note: this just works if the (key, value) is a (0,0) dummy, because the value at the 0 key will always be 0 - witness.calculateIndex().assertEquals(key); - witness.calculateRoot(previousValue).assertEquals(root); - - // store new value in at the key - let newRoot = witness.calculateRoot(value); - - // update the tree, outside the circuit (this should all be part of a better merkle tree API) - Provable.asProver(() => { - // ignore dummy value - if (isDummy.toBoolean()) return; - tree.get().setLeaf(key.toBigInt(), value); - }); - - // update root - root = Provable.if(isDummy, root, newRoot); + for (let i = 0; i < maxActionsPerBatch; i++) { + actions.next().forEach(maxActionsPerUpdate, (action, isDummy) => { + linearActions.pushIf(isDummy.not(), action); }); } + actions.assertAtEnd(); + + // update merkle root for each action + linearActions.forEach(maxActionsPerBatch, ({ key, value }, isDummy) => { + // merkle witness + let witness = Provable.witness( + MerkleMapWitness, + () => new MerkleMapWitness(tree.get().getWitness(key.toBigInt())) + ); + + // previous value at the key + let previousValue = Provable.witness(Field, () => + tree.get().getLeaf(key.toBigInt()) + ); + + // prove that the witness is correct, by comparing the implied root and key + // note: this just works if the (key, value) is a (0,0) dummy, because the value at the 0 key will always be 0 + witness.calculateIndex().assertEquals(key); + witness.calculateRoot(previousValue).assertEquals(root); + + // store new value in at the key + let newRoot = witness.calculateRoot(value); + + // update the tree, outside the circuit (this should all be part of a better merkle tree API) + Provable.asProver(() => { + // ignore dummy value + if (isDummy.toBoolean()) return; + tree.get().setLeaf(key.toBigInt(), value); + }); + + // update root + root = Provable.if(isDummy, root, newRoot); + }); return { root, actionState: actions.currentHash }; } @@ -105,10 +111,13 @@ function merkleUpdateBatch( * This program represents a proof that we can go from OffchainStateCommitments A -> B */ function OffchainStateRollup({ - // 1 actions uses about 7.5k constraints - // we can fit 7 * 7.5k = 52.5k constraints in one method next to the proof verification - maxUpdatesPerBatch = 3, - maxActionsPerUpdate = 2, + // 1 action uses about 7.5k constraints + // we can fit at most 7 * 7.5k = 52.5k constraints in one method next to proof verification + // => we use `maxActionsPerBatch = 6` to safely stay below the constraint limit + // the second parameter `maxActionsPerUpdate` only weakly affects # constraints, but has to be <= `maxActionsPerBatch` + // => so we set it to the same value + maxActionsPerBatch = 6, + maxActionsPerUpdate = 6, } = {}) { let offchainStateRollup = ZkProgram({ name: 'merkle-map-rollup', @@ -128,7 +137,7 @@ function OffchainStateRollup({ tree: Unconstrained ): Promise { return merkleUpdateBatch( - { maxUpdatesPerBatch, maxActionsPerUpdate }, + { maxActionsPerBatch, maxActionsPerUpdate }, stateA, actions, tree @@ -168,7 +177,7 @@ function OffchainStateRollup({ let stateB = recursiveProof.publicOutput; return merkleUpdateBatch( - { maxUpdatesPerBatch, maxActionsPerUpdate }, + { maxActionsPerBatch, maxActionsPerUpdate }, stateB, actions, tree @@ -199,15 +208,8 @@ function OffchainStateRollup({ // clone the tree so we don't modify the input tree = tree.clone(); - let n = actions.data.get().length; - let nBatches = Math.ceil(n / maxUpdatesPerBatch); - - // if there are no actions, we still need to create a valid proof for the empty transition - if (n === 0) nBatches = 1; - // input state let iterator = actions.startIterating(); - let inputState = new OffchainStateCommitments({ root: tree.getRoot(), actionState: iterator.currentHash, @@ -215,10 +217,7 @@ function OffchainStateRollup({ // if proofs are disabled, create a dummy proof and final state, and return if (!getProofsEnabled()) { - tree = merkleUpdateOutside(actions, tree, { - maxUpdatesPerBatch, - maxActionsPerUpdate, - }); + tree = merkleUpdateOutside(actions, tree); let finalState = new OffchainStateCommitments({ root: tree.getRoot(), actionState: iterator.hash, @@ -229,25 +228,23 @@ function OffchainStateRollup({ // base proof console.time('batch 0'); + let slice = sliceActions(iterator, maxActionsPerBatch); let proof = await offchainStateRollup.firstBatch( inputState, - iterator, + slice, Unconstrained.from(tree) ); console.timeEnd('batch 0'); // recursive proofs - for (let i = 1; i < nBatches; i++) { - // update iterator (would be nice if the method call would just return the updated one) - iterator.currentHash = proof.publicOutput.actionState; - for (let j = 0; j < maxUpdatesPerBatch; j++) { - iterator._updateIndex('next'); - } + for (let i = 1; ; i++) { + if (iterator.isAtEnd().toBoolean()) break; console.time(`batch ${i}`); + let slice = sliceActions(iterator, maxActionsPerBatch); proof = await offchainStateRollup.nextBatch( inputState, - iterator, + slice, Unconstrained.from(tree), proof ); @@ -259,21 +256,49 @@ function OffchainStateRollup({ }; } +// from a nested list of actions, create a slice (iterator) starting at `index` that has at most `batchSize` actions in it\ +// also moves the original iterator forward to start after the slice +function sliceActions(actions: ActionIterator, batchSize: number) { + class ActionListsList extends MerkleList.create( + ActionList.provable, + (hash: Field, actions: ActionList) => + Actions.updateSequenceState(hash, actions.hash), + actions.currentHash + ) {} + + let slice = ActionListsList.empty(); + let totalSize = 0; + + while (true) { + // stop if we reach the end of the list + if (actions.isAtEnd().toBoolean()) break; + + let nextList = actions.data.get()[actions._index('next')].element; + let nextSize = nextList.data.get().length; + assert( + nextSize <= batchSize, + 'Actions in one update exceed maximum batch size' + ); + if (totalSize + nextSize > batchSize) break; + + let nextMerkleList = actions.next(); + slice.push(nextMerkleList); + totalSize += nextSize; + } + + return slice.startIterating(); +} + // TODO: do we have to repeat the merkle updates outside the circuit? function merkleUpdateOutside( actions: MerkleList>, - tree: MerkleTree, - { maxUpdatesPerBatch = 10, maxActionsPerUpdate = 5 } = {} + tree: MerkleTree ) { tree = tree.clone(); - actions.forEach(maxUpdatesPerBatch, (actionsList, isDummy) => { - if (isDummy.toBoolean()) return; - - actionsList.forEach(maxActionsPerUpdate, ({ key, value }, isDummy) => { - if (isDummy.toBoolean()) return; - + actions.data.get().forEach(({ element: actionsList }) => { + actionsList.data.get().forEach(({ element: { key, value } }) => { tree.setLeaf(key.toBigInt(), value); }); }); From 0ba482c90a273b936b6fd94866eb03f2ab5c035a Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 16:57:03 +0200 Subject: [PATCH 04/29] add two more fields to merkle leaves --- .../actions/offchain-state-serialization.ts | 64 +++++++++++++++---- src/lib/mina/actions/offchain-state.ts | 18 +++--- 2 files changed, 59 insertions(+), 23 deletions(-) diff --git a/src/lib/mina/actions/offchain-state-serialization.ts b/src/lib/mina/actions/offchain-state-serialization.ts index 455a1e7f08..277a42afab 100644 --- a/src/lib/mina/actions/offchain-state-serialization.ts +++ b/src/lib/mina/actions/offchain-state-serialization.ts @@ -14,7 +14,7 @@ import { packToFields, salt, } from '../../provable/crypto/poseidon.js'; -import { Field } from '../../provable/wrapped.js'; +import { Field, Bool } from '../../provable/wrapped.js'; import { assert } from '../../provable/gadgets/common.js'; import { prefixes } from '../../../bindings/crypto/constants.js'; import { Struct } from '../../provable/types/struct.js'; @@ -48,19 +48,41 @@ function toKeyHash | undefined>( return hashPackedWithPrefix([prefix, Field(0)], keyType, key); } -function toAction | undefined>( - prefix: Field, - keyType: KeyType, - valueType: Actionable, - key: KeyType extends undefined ? undefined : K, - value: V -): Action { +function toAction | undefined>({ + prefix, + keyType, + valueType, + key, + value, + previousValue, +}: { + prefix: Field; + keyType: KeyType; + valueType: Actionable; + key: KeyType extends undefined ? undefined : K; + value: V; + previousValue?: V; +}): Action { let valueSize = valueType.sizeInFields(); let padding = valueSize % 2 === 0 ? [] : [Field(0)]; let keyHash = hashPackedWithPrefix([prefix, Field(0)], keyType, key); + + let usesPreviousValue = Bool(previousValue !== undefined).toField(); + let previousValueHash = + previousValue !== undefined + ? Poseidon.hashPacked(valueType, previousValue) + : Field(0); let valueHash = Poseidon.hashPacked(valueType, value); - return [...valueType.toFields(value), ...padding, keyHash, valueHash]; + + return [ + ...valueType.toFields(value), + ...padding, + usesPreviousValue, + previousValueHash, + keyHash, + valueHash, + ]; } function fromActionWithoutHashes( @@ -102,13 +124,22 @@ function hashPackedWithPrefix | undefined>( class MerkleLeaf extends Struct({ key: Field, value: Field, + usesPreviousValue: Bool, + previousValue: Field, prefix: Unconstrained.provableWithEmpty([]), }) { static fromAction(action: Field[]) { - assert(action.length >= 2, 'invalid action size'); - let [key, value] = action.slice(-2); - let prefix = Unconstrained.from(action.slice(0, -2)); - return new MerkleLeaf({ key, value, prefix }); + assert(action.length >= 4, 'invalid action size'); + let [usesPreviousValue_, previousValue, key, value] = action.slice(-4); + let usesPreviousValue = usesPreviousValue_.assertBool(); + let prefix = Unconstrained.from(action.slice(0, -4)); + return new MerkleLeaf({ + usesPreviousValue, + previousValue, + key, + value, + prefix, + }); } /** @@ -122,7 +153,12 @@ class MerkleLeaf extends Struct({ let init = salt(prefixes.event) as [Field, Field, Field]; return Poseidon.update(init, prefix); }); - return Poseidon.update(preHashState, [action.key, action.value])[0]; + return Poseidon.update(preHashState, [ + action.usesPreviousValue.toField(), + action.previousValue, + action.key, + action.value, + ])[0]; } } diff --git a/src/lib/mina/actions/offchain-state.ts b/src/lib/mina/actions/offchain-state.ts index afe1d5005e..c0cfc0f0d8 100644 --- a/src/lib/mina/actions/offchain-state.ts +++ b/src/lib/mina/actions/offchain-state.ts @@ -186,13 +186,13 @@ function OffchainState< return { set(value) { // serialize into action - let action = toAction( + let action = toAction({ prefix, - undefined, - type, - undefined, - type.fromValue(value) - ); + keyType: undefined, + valueType: type, + key: undefined, + value: type.fromValue(value), + }); // push action on account update let update = contract().self; @@ -218,13 +218,13 @@ function OffchainState< return { set(key, value) { // serialize into action - let action = toAction( + let action = toAction({ prefix, keyType, valueType, key, - valueType.fromValue(value) - ); + value: valueType.fromValue(value), + }); // push action on account update let update = contract().self; From 4b1a34f9041b5900102ba1d6bf5fb9b915c13fe7 Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 18:29:15 +0200 Subject: [PATCH 05/29] restructure rollup proof to commit merkle tree updates atomically, at checkpoints --- src/lib/mina/actions/offchain-state-rollup.ts | 75 +++++++++++++++---- .../actions/offchain-state-serialization.ts | 38 ++++++++++ 2 files changed, 98 insertions(+), 15 deletions(-) diff --git a/src/lib/mina/actions/offchain-state-rollup.ts b/src/lib/mina/actions/offchain-state-rollup.ts index 8e10b1fbad..93b312d316 100644 --- a/src/lib/mina/actions/offchain-state-rollup.ts +++ b/src/lib/mina/actions/offchain-state-rollup.ts @@ -1,5 +1,5 @@ import { Proof, ZkProgram } from '../../proof-system/zkprogram.js'; -import { Field } from '../../provable/wrapped.js'; +import { Bool, Field } from '../../provable/wrapped.js'; import { Unconstrained } from '../../provable/types/unconstrained.js'; import { MerkleList, MerkleListIterator } from '../../provable/merkle-list.js'; import { Actions } from '../../../bindings/mina-transaction/transaction-leaves.js'; @@ -8,7 +8,12 @@ import { Struct } from '../../provable/types/struct.js'; import { SelfProof } from '../../proof-system/zkprogram.js'; import { Provable } from '../../provable/provable.js'; import { assert } from '../../provable/gadgets/common.js'; -import { ActionList, MerkleLeaf } from './offchain-state-serialization.js'; +import { + ActionList, + LinearizedAction, + LinearizedActionList, + MerkleLeaf, +} from './offchain-state-serialization.js'; import { MerkleMap } from '../../provable/merkle-map.js'; import { getProofsEnabled } from '../mina.js'; @@ -60,24 +65,51 @@ function merkleUpdateBatch( ): OffchainStateCommitments { // this would be unnecessary if the iterator could just be the public input actions.currentHash.assertEquals(stateA.actionState); - let root = stateA.root; // linearize actions into a flat MerkleList, so we don't process an insane amount of dummy actions - let linearActions = ActionList.empty(); + let linearActions = LinearizedActionList.empty(); for (let i = 0; i < maxActionsPerBatch; i++) { - actions.next().forEach(maxActionsPerUpdate, (action, isDummy) => { - linearActions.pushIf(isDummy.not(), action); - }); + let inner = actions.next().startIterating(); + let isAtEnd = Bool(false); + for (let i = 0; i < maxActionsPerUpdate; i++) { + let { element: action, isDummy } = inner.Unsafe.next(); + let isCheckPoint = inner.isAtEnd(); + [isAtEnd, isCheckPoint] = [ + isAtEnd.or(isCheckPoint), + isCheckPoint.and(isAtEnd.not()), + ]; + linearActions.pushIf( + isDummy.not(), + new LinearizedAction({ action, isCheckPoint }) + ); + } + inner.assertAtEnd( + `Expected at most ${maxActionsPerUpdate} actions per account update.` + ); } actions.assertAtEnd(); - // update merkle root for each action - linearActions.forEach(maxActionsPerBatch, ({ key, value }, isDummy) => { + // update merkle root at once for the actions of each account update + let root = stateA.root; + let intermediateRoot = root; + + type TreeUpdate = { key: Field; value: Field }; + let intermediateUpdates: TreeUpdate[] = []; + let intermediateTree = Unconstrained.witness(() => tree.get().clone()); + + linearActions.forEach(maxActionsPerBatch, (element, isDummy) => { + let { + action: { key, value }, + isCheckPoint, + } = element; + // Provable.log({ key, value, isEndOfUpdate, isDummy }); + // merkle witness let witness = Provable.witness( MerkleMapWitness, - () => new MerkleMapWitness(tree.get().getWitness(key.toBigInt())) + () => + new MerkleMapWitness(intermediateTree.get().getWitness(key.toBigInt())) ); // previous value at the key @@ -88,20 +120,33 @@ function merkleUpdateBatch( // prove that the witness is correct, by comparing the implied root and key // note: this just works if the (key, value) is a (0,0) dummy, because the value at the 0 key will always be 0 witness.calculateIndex().assertEquals(key); - witness.calculateRoot(previousValue).assertEquals(root); + witness.calculateRoot(previousValue).assertEquals(intermediateRoot); // store new value in at the key let newRoot = witness.calculateRoot(value); + // update root + intermediateRoot = Provable.if(isDummy, intermediateRoot, newRoot); + root = Provable.if(isCheckPoint, intermediateRoot, root); + // intermediateRoot = Provable.if(isCheckPoint, root, intermediateRoot); + // update the tree, outside the circuit (this should all be part of a better merkle tree API) Provable.asProver(() => { // ignore dummy value if (isDummy.toBoolean()) return; - tree.get().setLeaf(key.toBigInt(), value); - }); - // update root - root = Provable.if(isDummy, root, newRoot); + intermediateTree.get().setLeaf(key.toBigInt(), value); + intermediateUpdates.push({ key, value }); + + let isEnd = isCheckPoint.toBoolean(); + + if (isEnd) { + intermediateUpdates.forEach(({ key, value }) => { + tree.get().setLeaf(key.toBigInt(), value); + }); + intermediateUpdates = []; + } + }); }); return { root, actionState: actions.currentHash }; diff --git a/src/lib/mina/actions/offchain-state-serialization.ts b/src/lib/mina/actions/offchain-state-serialization.ts index 277a42afab..83ddafeaea 100644 --- a/src/lib/mina/actions/offchain-state-serialization.ts +++ b/src/lib/mina/actions/offchain-state-serialization.ts @@ -31,6 +31,8 @@ export { toAction, fromActionWithoutHashes, MerkleLeaf, + LinearizedAction, + LinearizedActionList, ActionList, fetchMerkleLeaves, fetchMerkleMap, @@ -175,6 +177,42 @@ class ActionList extends MerkleList.create( Actions.empty().hash ) {} +class LinearizedAction extends Struct({ + action: MerkleLeaf, + /** + * Whether this action is the last in an account update. + * In a linearized sequence of actions, this value determines the points at which we commit an atomic update to the Merkle tree. + */ + isCheckPoint: Bool, +}) { + /** + * A custom method to hash an action which only hashes the key and value in provable code. + * Therefore, it only proves that the key and value are part of the action, and nothing about + * the rest of the action. + */ + static hash({ action, isCheckPoint }: LinearizedAction) { + let preHashState = Provable.witnessFields(3, () => { + let prefix = action.prefix.get(); + let init = salt(prefixes.event) as [Field, Field, Field]; + return Poseidon.update(init, prefix); + }); + return Poseidon.update(preHashState, [ + // pack two bools into 1 field + action.usesPreviousValue.toField().add(isCheckPoint.toField().mul(2)), + action.previousValue, + action.key, + action.value, + ])[0]; + } +} + +class LinearizedActionList extends MerkleList.create( + LinearizedAction, + (hash: Field, action: LinearizedAction) => + Poseidon.hash([hash, LinearizedAction.hash(action)]), + Actions.empty().hash +) {} + async function fetchMerkleLeaves( contract: { address: PublicKey; tokenId: Field }, config?: { From 5651b2b9b21fc7aadace907f04c63114abe24d17 Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 18:49:51 +0200 Subject: [PATCH 06/29] bool.implies() --- src/lib/provable/bool.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/lib/provable/bool.ts b/src/lib/provable/bool.ts index 31c9080f09..3e3a74024c 100644 --- a/src/lib/provable/bool.ts +++ b/src/lib/provable/bool.ts @@ -91,6 +91,22 @@ class Bool { return this.not().and(new Bool(y).not()).not(); } + /** + * Whether this Bool implies another Bool `y`. + * + * This is the same as `x.not().or(y)`: if `x` is true, then `y` must be true for the implication to be true. + * + * @example + * ```ts + * let isZero = x.equals(0); + * let lessThan10 = x.lessThan(10); + * assert(isZero.implies(lessThan10), 'x = 0 implies x < 10'); + * ``` + */ + implies(y: Bool | boolean): Bool { + return this.not().or(y); + } + /** * Proves that this {@link Bool} is equal to `y`. * @param y a {@link Bool}. From 65f256058101608e472b730b67dc10fe10511ca6 Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 18:51:54 +0200 Subject: [PATCH 07/29] support actions with preconditions in rollup --- src/lib/mina/actions/offchain-state-rollup.ts | 51 ++++++++++++------- src/lib/provable/types/unconstrained.ts | 2 +- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/lib/mina/actions/offchain-state-rollup.ts b/src/lib/mina/actions/offchain-state-rollup.ts index 93b312d316..020d3e753e 100644 --- a/src/lib/mina/actions/offchain-state-rollup.ts +++ b/src/lib/mina/actions/offchain-state-rollup.ts @@ -98,12 +98,12 @@ function merkleUpdateBatch( let intermediateUpdates: TreeUpdate[] = []; let intermediateTree = Unconstrained.witness(() => tree.get().clone()); + let isValidUpdate = Bool(true); + linearActions.forEach(maxActionsPerBatch, (element, isDummy) => { - let { - action: { key, value }, - isCheckPoint, - } = element; - // Provable.log({ key, value, isEndOfUpdate, isDummy }); + let { action, isCheckPoint } = element; + let { key, value, usesPreviousValue, previousValue } = action; + // Provable.log({ key, value, isCheckPoint, isDummy }); // merkle witness let witness = Provable.witness( @@ -113,22 +113,32 @@ function merkleUpdateBatch( ); // previous value at the key - let previousValue = Provable.witness(Field, () => + let actualPreviousValue = Provable.witness(Field, () => tree.get().getLeaf(key.toBigInt()) ); - // prove that the witness is correct, by comparing the implied root and key + // prove that the witness and `actualPreviousValue` is correct, by comparing the implied root and key // note: this just works if the (key, value) is a (0,0) dummy, because the value at the 0 key will always be 0 witness.calculateIndex().assertEquals(key); - witness.calculateRoot(previousValue).assertEquals(intermediateRoot); + witness.calculateRoot(actualPreviousValue).assertEquals(intermediateRoot); + + // if an expected previous value was provided, check whether it matches the actual previous value + // otherwise, the entire update in invalidated + let matchesPreviousValue = actualPreviousValue.equals(previousValue); + let isValidAction = usesPreviousValue.implies(matchesPreviousValue); + isValidUpdate = isValidUpdate.and(isValidAction); // store new value in at the key let newRoot = witness.calculateRoot(value); - // update root + // update intermediate root if this wasn't a dummy action intermediateRoot = Provable.if(isDummy, intermediateRoot, newRoot); - root = Provable.if(isCheckPoint, intermediateRoot, root); - // intermediateRoot = Provable.if(isCheckPoint, root, intermediateRoot); + + // at checkpoints, update the root, if the entire update was valid + root = Provable.if(isCheckPoint.and(isValidUpdate), intermediateRoot, root); + // at checkpoints, reset intermediate values + isValidUpdate = Provable.if(isCheckPoint, Bool(true), isValidUpdate); + intermediateRoot = Provable.if(isCheckPoint, root, intermediateRoot); // update the tree, outside the circuit (this should all be part of a better merkle tree API) Provable.asProver(() => { @@ -138,13 +148,18 @@ function merkleUpdateBatch( intermediateTree.get().setLeaf(key.toBigInt(), value); intermediateUpdates.push({ key, value }); - let isEnd = isCheckPoint.toBoolean(); - - if (isEnd) { - intermediateUpdates.forEach(({ key, value }) => { - tree.get().setLeaf(key.toBigInt(), value); - }); - intermediateUpdates = []; + if (isCheckPoint.toBoolean()) { + // if the update was valid, apply the intermediate updates to the actual tree + if (isValidUpdate.toBoolean()) { + intermediateUpdates.forEach(({ key, value }) => { + tree.get().setLeaf(key.toBigInt(), value); + }); + intermediateUpdates = []; + } + // otherwise, we have to roll back the intermediate tree (TODO: inefficient) + else { + intermediateTree.set(tree.get().clone()); + } } }); }); diff --git a/src/lib/provable/types/unconstrained.ts b/src/lib/provable/types/unconstrained.ts index 03782310a1..dcf5b1c833 100644 --- a/src/lib/provable/types/unconstrained.ts +++ b/src/lib/provable/types/unconstrained.ts @@ -93,7 +93,7 @@ and Provable.asProver() blocks, which execute outside the proof. /** * Create an `Unconstrained` from a witness computation. */ - static witness(compute: () => T) { + static witness(compute: () => T): Unconstrained { return witness( Unconstrained.provable, () => new Unconstrained(true, compute()) From 6f7059ca3064984bdedf5cd78ca83de3d8c055a0 Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 20:25:30 +0200 Subject: [PATCH 08/29] implement update --- src/lib/mina/actions/offchain-state.ts | 36 ++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/lib/mina/actions/offchain-state.ts b/src/lib/mina/actions/offchain-state.ts index c0cfc0f0d8..b7e8636ab8 100644 --- a/src/lib/mina/actions/offchain-state.ts +++ b/src/lib/mina/actions/offchain-state.ts @@ -198,7 +198,23 @@ function OffchainState< let update = contract().self; update.body.actions = Actions.pushEvent(update.body.actions, action); }, - update: notImplemented, + + update({ from, to }) { + // serialize into action + let action = toAction({ + prefix, + keyType: undefined, + valueType: type, + key: undefined, + value: type.fromValue(to), + previousValue: type.fromValue(from), + }); + + // push action on account update + let update = contract().self; + update.body.actions = Actions.pushEvent(update.body.actions, action); + }, + async get() { let key = toKeyHash(prefix, undefined, undefined); let optionValue = await get(key, type); @@ -230,7 +246,23 @@ function OffchainState< let update = contract().self; update.body.actions = Actions.pushEvent(update.body.actions, action); }, - update: notImplemented, + + update(key, { from, to }) { + // serialize into action + let action = toAction({ + prefix, + keyType, + valueType, + key, + value: valueType.fromValue(to), + previousValue: valueType.fromValue(from), + }); + + // push action on account update + let update = contract().self; + update.body.actions = Actions.pushEvent(update.body.actions, action); + }, + async get(key) { let keyHash = toKeyHash(prefix, keyType, key); return await get(keyHash, valueType); From 39bb623088358b9e43d98454b795c3e07dd85fc9 Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 20:31:21 +0200 Subject: [PATCH 09/29] some fixes, happy path not working yet --- .../actions/offchain-contract.unit-test.ts | 27 ++++++++++++------- src/lib/mina/actions/offchain-state-rollup.ts | 17 +++++++++--- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/lib/mina/actions/offchain-contract.unit-test.ts b/src/lib/mina/actions/offchain-contract.unit-test.ts index 547f4e57ff..4b4e4a034d 100644 --- a/src/lib/mina/actions/offchain-contract.unit-test.ts +++ b/src/lib/mina/actions/offchain-contract.unit-test.ts @@ -25,10 +25,15 @@ class ExampleContract extends SmartContract { async createAccount(address: PublicKey, amountToMint: UInt64) { offchainState.fields.accounts.set(address, amountToMint); - // TODO `totalSupply` easily gets into a wrong state here on concurrent calls. - // and using `.update()` doesn't help either + // TODO using `update()` on the total supply means that this method + // can only be called once every settling cycle let totalSupply = await offchainState.fields.totalSupply.get(); offchainState.fields.totalSupply.set(totalSupply.add(amountToMint)); + // TODO this fails, because `from` does not yield the correct value hash if the field was not set before + // offchainState.fields.totalSupply.update({ + // from: totalSupply, + // to: totalSupply.add(amountToMint), + // }); } @method @@ -40,14 +45,18 @@ class ExampleContract extends SmartContract { let toBalance = toOption.orElse(0n); /** - * FIXME using `set()` here is completely insecure, a sender can easily double-spend by sending multiple transactions, - * which will all use the same initial balance. - * Even using a naive version of `update()` would give a double-spend opportunity, because the updates are not rejected atomically: - * if the `to` update gets accepted but the `from` update fails, it's a double-spend - * => properly implementing this needs a version of `update()` that rejects all state actions in one update if any of them fails! + * Update both accounts atomically. + * + * This is safe, because both updates will only be accepted if both previous balances are still correct. */ - offchainState.fields.accounts.set(from, fromBalance.sub(amount)); - offchainState.fields.accounts.set(to, toBalance.add(amount)); + offchainState.fields.accounts.update(from, { + from: fromBalance, + to: fromBalance.sub(amount), + }); + offchainState.fields.accounts.update(to, { + from: toBalance, + to: toBalance.add(amount), + }); } @method.returns(UInt64) diff --git a/src/lib/mina/actions/offchain-state-rollup.ts b/src/lib/mina/actions/offchain-state-rollup.ts index 020d3e753e..d41d039c0a 100644 --- a/src/lib/mina/actions/offchain-state-rollup.ts +++ b/src/lib/mina/actions/offchain-state-rollup.ts @@ -103,7 +103,6 @@ function merkleUpdateBatch( linearActions.forEach(maxActionsPerBatch, (element, isDummy) => { let { action, isCheckPoint } = element; let { key, value, usesPreviousValue, previousValue } = action; - // Provable.log({ key, value, isCheckPoint, isDummy }); // merkle witness let witness = Provable.witness( @@ -114,8 +113,17 @@ function merkleUpdateBatch( // previous value at the key let actualPreviousValue = Provable.witness(Field, () => - tree.get().getLeaf(key.toBigInt()) + intermediateTree.get().getLeaf(key.toBigInt()) ); + Provable.log({ + key, + value, + isCheckPoint, + isDummy, + usesPreviousValue, + previousValue, + actualPreviousValue, + }); // prove that the witness and `actualPreviousValue` is correct, by comparing the implied root and key // note: this just works if the (key, value) is a (0,0) dummy, because the value at the 0 key will always be 0 @@ -137,6 +145,7 @@ function merkleUpdateBatch( // at checkpoints, update the root, if the entire update was valid root = Provable.if(isCheckPoint.and(isValidUpdate), intermediateRoot, root); // at checkpoints, reset intermediate values + let wasValidUpdate = isValidUpdate; isValidUpdate = Provable.if(isCheckPoint, Bool(true), isValidUpdate); intermediateRoot = Provable.if(isCheckPoint, root, intermediateRoot); @@ -150,16 +159,16 @@ function merkleUpdateBatch( if (isCheckPoint.toBoolean()) { // if the update was valid, apply the intermediate updates to the actual tree - if (isValidUpdate.toBoolean()) { + if (wasValidUpdate.toBoolean()) { intermediateUpdates.forEach(({ key, value }) => { tree.get().setLeaf(key.toBigInt(), value); }); - intermediateUpdates = []; } // otherwise, we have to roll back the intermediate tree (TODO: inefficient) else { intermediateTree.set(tree.get().clone()); } + intermediateUpdates = []; } }); }); From 889ca27901e2ab4a6d698c2d7feb3ba19ec204ed Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 20:59:24 +0200 Subject: [PATCH 10/29] update from has to be an option! working happy path --- .../mina/actions/offchain-contract.unit-test.ts | 4 ++-- .../mina/actions/offchain-state-serialization.ts | 9 +++++++-- src/lib/mina/actions/offchain-state.ts | 16 ++++++++-------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/lib/mina/actions/offchain-contract.unit-test.ts b/src/lib/mina/actions/offchain-contract.unit-test.ts index 4b4e4a034d..6b6b30c7f4 100644 --- a/src/lib/mina/actions/offchain-contract.unit-test.ts +++ b/src/lib/mina/actions/offchain-contract.unit-test.ts @@ -50,11 +50,11 @@ class ExampleContract extends SmartContract { * This is safe, because both updates will only be accepted if both previous balances are still correct. */ offchainState.fields.accounts.update(from, { - from: fromBalance, + from: fromOption, to: fromBalance.sub(amount), }); offchainState.fields.accounts.update(to, { - from: toBalance, + from: toOption, to: toBalance.add(amount), }); } diff --git a/src/lib/mina/actions/offchain-state-serialization.ts b/src/lib/mina/actions/offchain-state-serialization.ts index 83ddafeaea..4f79843fa7 100644 --- a/src/lib/mina/actions/offchain-state-serialization.ts +++ b/src/lib/mina/actions/offchain-state-serialization.ts @@ -25,6 +25,7 @@ import { PublicKey } from '../../provable/crypto/signature.js'; import { Provable } from '../../provable/provable.js'; import { Actions } from '../account-update.js'; import { MerkleTree } from '../../provable/merkle-tree.js'; +import { Option } from '../../provable/option.js'; export { toKeyHash, @@ -63,7 +64,7 @@ function toAction | undefined>({ valueType: Actionable; key: KeyType extends undefined ? undefined : K; value: V; - previousValue?: V; + previousValue?: Option; }): Action { let valueSize = valueType.sizeInFields(); let padding = valueSize % 2 === 0 ? [] : [Field(0)]; @@ -73,7 +74,11 @@ function toAction | undefined>({ let usesPreviousValue = Bool(previousValue !== undefined).toField(); let previousValueHash = previousValue !== undefined - ? Poseidon.hashPacked(valueType, previousValue) + ? Provable.if( + previousValue.isSome, + Poseidon.hashPacked(valueType, previousValue.value), + Field(0) + ) : Field(0); let valueHash = Poseidon.hashPacked(valueType, value); diff --git a/src/lib/mina/actions/offchain-state.ts b/src/lib/mina/actions/offchain-state.ts index b7e8636ab8..2fad29dde6 100644 --- a/src/lib/mina/actions/offchain-state.ts +++ b/src/lib/mina/actions/offchain-state.ts @@ -120,8 +120,6 @@ function OffchainState< return { merkleMap, valueMap }; }; - const notImplemented = (): any => assert(false, 'Not implemented'); - let rollup = OffchainStateRollup(); function contract() { @@ -207,7 +205,7 @@ function OffchainState< valueType: type, key: undefined, value: type.fromValue(to), - previousValue: type.fromValue(from), + previousValue: from, }); // push action on account update @@ -255,7 +253,7 @@ function OffchainState< valueType, key, value: valueType.fromValue(to), - previousValue: valueType.fromValue(from), + previousValue: from, }); // push action on account update @@ -355,9 +353,10 @@ type OffchainField = { * Update the value of the field, while requiring a specific previous value. * * If the previous value does not match, the update will not be applied. - * If no previous value is present, the `from` value is ignored and the update applied unconditionally. + * + * Note that the previous value is an option: to require that the field was not set before, use `Option.none()`. */ - update(update: { from: T | TValue; to: T | TValue }): void; + update(update: { from: Option; to: T | TValue }): void; }; function OffchainMap(key: K, value: V) { @@ -376,9 +375,10 @@ type OffchainMap = { * Update the value of the field, while requiring a specific previous value. * * If the previous value does not match, the update will not be applied. - * If no previous value is present, the `from` value is ignored and the update applied unconditionally. + * + * Note that the previous value is an option: to require that the field was not set before, use `Option.none()`. */ - update(key: K, update: { from: V | VValue; to: V | VValue }): void; + update(key: K, update: { from: Option; to: V | VValue }): void; }; type OffchainStateKind = From 9a85efc323f86881a75d194205173def01a0f9fc Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 21:07:12 +0200 Subject: [PATCH 11/29] make field.get return an option as well --- .../mina/actions/offchain-contract.unit-test.ts | 16 ++++++++-------- src/lib/mina/actions/offchain-state-rollup.ts | 9 --------- src/lib/mina/actions/offchain-state.ts | 12 +++++++----- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/src/lib/mina/actions/offchain-contract.unit-test.ts b/src/lib/mina/actions/offchain-contract.unit-test.ts index 6b6b30c7f4..2e9dfdc3a2 100644 --- a/src/lib/mina/actions/offchain-contract.unit-test.ts +++ b/src/lib/mina/actions/offchain-contract.unit-test.ts @@ -27,13 +27,13 @@ class ExampleContract extends SmartContract { // TODO using `update()` on the total supply means that this method // can only be called once every settling cycle - let totalSupply = await offchainState.fields.totalSupply.get(); - offchainState.fields.totalSupply.set(totalSupply.add(amountToMint)); - // TODO this fails, because `from` does not yield the correct value hash if the field was not set before - // offchainState.fields.totalSupply.update({ - // from: totalSupply, - // to: totalSupply.add(amountToMint), - // }); + let totalSupplyOption = await offchainState.fields.totalSupply.get(); + let totalSupply = totalSupplyOption.orElse(0n); + + offchainState.fields.totalSupply.update({ + from: totalSupplyOption, + to: totalSupply.add(amountToMint), + }); } @method @@ -61,7 +61,7 @@ class ExampleContract extends SmartContract { @method.returns(UInt64) async getSupply() { - return await offchainState.fields.totalSupply.get(); + return (await offchainState.fields.totalSupply.get()).orElse(0n); } @method.returns(UInt64) diff --git a/src/lib/mina/actions/offchain-state-rollup.ts b/src/lib/mina/actions/offchain-state-rollup.ts index d41d039c0a..d934ba38f0 100644 --- a/src/lib/mina/actions/offchain-state-rollup.ts +++ b/src/lib/mina/actions/offchain-state-rollup.ts @@ -115,15 +115,6 @@ function merkleUpdateBatch( let actualPreviousValue = Provable.witness(Field, () => intermediateTree.get().getLeaf(key.toBigInt()) ); - Provable.log({ - key, - value, - isCheckPoint, - isDummy, - usesPreviousValue, - previousValue, - actualPreviousValue, - }); // prove that the witness and `actualPreviousValue` is correct, by comparing the implied root and key // note: this just works if the (key, value) is a (0,0) dummy, because the value at the 0 key will always be 0 diff --git a/src/lib/mina/actions/offchain-state.ts b/src/lib/mina/actions/offchain-state.ts index 2fad29dde6..1de44ffeff 100644 --- a/src/lib/mina/actions/offchain-state.ts +++ b/src/lib/mina/actions/offchain-state.ts @@ -215,9 +215,7 @@ function OffchainState< async get() { let key = toKeyHash(prefix, undefined, undefined); - let optionValue = await get(key, type); - // for fields that are not in the map, we return the default value -- similar to onchain state - return optionValue.orElse(type.empty()); + return await get(key, type); }, }; } @@ -342,13 +340,15 @@ function OffchainField(type: T) { } type OffchainField = { /** - * Get the value of the field. + * Get the value of the field, or none if it doesn't exist yet. */ - get(): Promise; + get(): Promise>; + /** * Set the value of the field. */ set(value: T | TValue): void; + /** * Update the value of the field, while requiring a specific previous value. * @@ -367,10 +367,12 @@ type OffchainMap = { * Get the value for this key, or none if it doesn't exist. */ get(key: K): Promise>; + /** * Set the value for this key. */ set(key: K, value: V | VValue): void; + /** * Update the value of the field, while requiring a specific previous value. * From f2f62d445ceb0ec2ef36a05691b37037891dc6be Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 22:35:31 +0200 Subject: [PATCH 12/29] make option infer from the class so that it works --- src/lib/provable/option.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/lib/provable/option.ts b/src/lib/provable/option.ts index e214f99ae4..bf6bf12424 100644 --- a/src/lib/provable/option.ts +++ b/src/lib/provable/option.ts @@ -1,15 +1,18 @@ +import { InferValue } from '../../bindings/lib/provable-generic.js'; import { emptyValue } from '../proof-system/zkprogram.js'; import { Provable } from './provable.js'; -import { Struct } from './types/struct.js'; +import { InferProvable, Struct } from './types/struct.js'; import { Bool } from './wrapped.js'; -export { Option }; +export { Option, OptionOrValue }; type Option = { isSome: Bool; value: T } & { assertSome(message?: string): T; orElse(defaultValue: T | V): T; }; +type OptionOrValue = Option | { isSome: boolean; value: V }; + /** * Define an optional version of a provable type. * @@ -26,17 +29,20 @@ type Option = { isSome: Bool; value: T } & { * let zero: UInt64 = none.orElse(0n); // specify a default value * ``` */ -function Option( - type: Provable +function Option>( + type: A ): Provable< - Option, + Option, InferValue>, // TODO make V | undefined the value type - { isSome: boolean; value: V } + { isSome: boolean; value: InferValue } > & { - from(value?: T): Option; - none(): Option; + from(value?: InferProvable): Option, InferValue>; + none(): Option, InferValue>; } { - const Super = Struct({ isSome: Bool, value: type }); + type T = InferProvable; + type V = InferValue; + + const Super = Struct({ isSome: Bool, value: type as Provable }); return class Option_ extends Super { orElse(defaultValue: T | V): T { return Provable.if( From b213035838ab5089bf2f88304e0d72d914d3f843 Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 22:35:39 +0200 Subject: [PATCH 13/29] unconstrained tweak --- src/lib/provable/types/unconstrained.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/provable/types/unconstrained.ts b/src/lib/provable/types/unconstrained.ts index dcf5b1c833..a6b4baf519 100644 --- a/src/lib/provable/types/unconstrained.ts +++ b/src/lib/provable/types/unconstrained.ts @@ -110,7 +110,7 @@ and Provable.asProver() blocks, which execute outside the proof. }); } - static provable: Provable> & { + static provable: Provable, Unconstrained> & { toInput: (x: Unconstrained) => { fields?: Field[]; packed?: [Field, number][]; @@ -130,7 +130,10 @@ and Provable.asProver() blocks, which execute outside the proof. }, }; - static provableWithEmpty(empty: T): Provable> & { + static provableWithEmpty(empty: T): Provable< + Unconstrained, + Unconstrained + > & { toInput: (x: Unconstrained) => { fields?: Field[]; packed?: [Field, number][]; From 742d66227aa67a95c7ac5a8d6da0db02693dfb70 Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 22:50:49 +0200 Subject: [PATCH 14/29] figure out option type --- src/lib/provable/option.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/provable/option.ts b/src/lib/provable/option.ts index bf6bf12424..8f51f1cb12 100644 --- a/src/lib/provable/option.ts +++ b/src/lib/provable/option.ts @@ -1,4 +1,4 @@ -import { InferValue } from '../../bindings/lib/provable-generic.js'; +import { From, InferValue } from '../../bindings/lib/provable-generic.js'; import { emptyValue } from '../proof-system/zkprogram.js'; import { Provable } from './provable.js'; import { InferProvable, Struct } from './types/struct.js'; @@ -11,7 +11,7 @@ type Option = { isSome: Bool; value: T } & { orElse(defaultValue: T | V): T; }; -type OptionOrValue = Option | { isSome: boolean; value: V }; +type OptionOrValue = { isSome: boolean | Bool; value: V | T }; /** * Define an optional version of a provable type. @@ -36,6 +36,9 @@ function Option>( // TODO make V | undefined the value type { isSome: boolean; value: InferValue } > & { + fromValue: ( + value: From<{ isSome: typeof Bool; value: A }> + ) => Option, InferValue>; from(value?: InferProvable): Option, InferValue>; none(): Option, InferValue>; } { From 956b66b0f93d867800fe0a864382fee30c14e47d Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 22:59:44 +0200 Subject: [PATCH 15/29] more flexible option input type --- src/lib/mina/actions/offchain-state.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/lib/mina/actions/offchain-state.ts b/src/lib/mina/actions/offchain-state.ts index 1de44ffeff..2c1058352f 100644 --- a/src/lib/mina/actions/offchain-state.ts +++ b/src/lib/mina/actions/offchain-state.ts @@ -13,7 +13,7 @@ import { OffchainStateCommitments, OffchainStateRollup, } from './offchain-state-rollup.js'; -import { Option } from '../../provable/option.js'; +import { Option, OptionOrValue } from '../../provable/option.js'; import { InferValue } from '../../../bindings/lib/provable-generic.js'; import { SmartContract } from '../zkapp.js'; import { assert } from '../../provable/gadgets/common.js'; @@ -180,6 +180,7 @@ function OffchainState< type: Actionable ): OffchainField { const prefix = Field(index); + let optionType = Option(type); return { set(value) { @@ -205,7 +206,7 @@ function OffchainState< valueType: type, key: undefined, value: type.fromValue(to), - previousValue: from, + previousValue: optionType.fromValue(from), }); // push action on account update @@ -223,9 +224,10 @@ function OffchainState< function map( index: number, keyType: Actionable, - valueType: Actionable + valueType: Actionable ): OffchainMap { const prefix = Field(index); + let optionType = Option(valueType); return { set(key, value) { @@ -251,7 +253,7 @@ function OffchainState< valueType, key, value: valueType.fromValue(to), - previousValue: from, + previousValue: optionType.fromValue(from), }); // push action on account update @@ -342,7 +344,7 @@ type OffchainField = { /** * Get the value of the field, or none if it doesn't exist yet. */ - get(): Promise>; + get(): Promise>; /** * Set the value of the field. @@ -356,7 +358,7 @@ type OffchainField = { * * Note that the previous value is an option: to require that the field was not set before, use `Option.none()`. */ - update(update: { from: Option; to: T | TValue }): void; + update(update: { from: OptionOrValue; to: T | TValue }): void; }; function OffchainMap(key: K, value: V) { @@ -380,7 +382,10 @@ type OffchainMap = { * * Note that the previous value is an option: to require that the field was not set before, use `Option.none()`. */ - update(key: K, update: { from: Option; to: V | VValue }): void; + update( + key: K, + update: { from: OptionOrValue; to: V | VValue } + ): void; }; type OffchainStateKind = From a8d0b79c2bc99330259f4a114a0a92f87a311dfe Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 23:02:55 +0200 Subject: [PATCH 16/29] proper out-of-snark merkle update logic --- .../actions/offchain-contract.unit-test.ts | 14 ++++- .../actions/offchain-state-serialization.ts | 62 +++++++++++++++---- 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/lib/mina/actions/offchain-contract.unit-test.ts b/src/lib/mina/actions/offchain-contract.unit-test.ts index 2e9dfdc3a2..cd0ce1d359 100644 --- a/src/lib/mina/actions/offchain-contract.unit-test.ts +++ b/src/lib/mina/actions/offchain-contract.unit-test.ts @@ -111,7 +111,11 @@ console.timeEnd('deploy'); console.time('create account'); await Mina.transaction(sender, async () => { + // first call (should succeed) await contract.createAccount(sender, UInt64.from(1000)); + + // second call (should fail) + await contract.createAccount(sender, UInt64.from(2000)); }) .sign([sender.key]) .prove() @@ -137,9 +141,13 @@ await checkAgainstSupply(1000n); // transfer console.time('transfer'); -await Mina.transaction(sender, () => - contract.transfer(sender, receiver, UInt64.from(100)) -) +await Mina.transaction(sender, async () => { + // first call (should succeed) + await contract.transfer(sender, receiver, UInt64.from(100)); + + // second call (should fail) + await contract.transfer(sender, receiver, UInt64.from(200)); +}) .sign([sender.key]) .prove() .send(); diff --git a/src/lib/mina/actions/offchain-state-serialization.ts b/src/lib/mina/actions/offchain-state-serialization.ts index 4f79843fa7..61d7d3d032 100644 --- a/src/lib/mina/actions/offchain-state-serialization.ts +++ b/src/lib/mina/actions/offchain-state-serialization.ts @@ -37,6 +37,7 @@ export { ActionList, fetchMerkleLeaves, fetchMerkleMap, + updateMerkleMap, Actionable, }; @@ -265,21 +266,60 @@ async function fetchMerkleMap( ); if ('error' in result) throw Error(JSON.stringify(result)); - let leaves = result - .map((event) => - event.actions - .map((action) => MerkleLeaf.fromAction(action.map(Field))) - .reverse() - ) - .flat(); + let leaves = result.map((event) => + event.actions + .map((action) => MerkleLeaf.fromAction(action.map(Field))) + .reverse() + ); let merkleMap = new MerkleTree(256); let valueMap = new Map(); - for (let leaf of leaves) { - merkleMap.setLeaf(leaf.key.toBigInt(), leaf.value); - valueMap.set(leaf.key.toBigInt(), leaf.prefix.get()); - } + updateMerkleMap(leaves, merkleMap, valueMap); return { merkleMap, valueMap }; } + +function updateMerkleMap( + updates: MerkleLeaf[][], + tree: MerkleTree, + valueMap?: Map +) { + let intermediateTree = tree.clone(); + + for (let leaves of updates) { + let isValidUpdate = true; + let updates: { key: bigint; value: bigint; fullValue: Field[] }[] = []; + + for (let leaf of leaves) { + let { key, value, usesPreviousValue, previousValue, prefix } = + MerkleLeaf.toValue(leaf); + + // the update is invalid if there is an unsatisfied precondition + let isValidAction = + !usesPreviousValue || + intermediateTree.getLeaf(key).toBigInt() === previousValue; + + if (!isValidAction) { + isValidUpdate = false; + + break; + } + + // update the intermediate tree, save updates for final tree + intermediateTree.setLeaf(key, Field(value)); + updates.push({ key, value, fullValue: prefix.get() }); + } + + if (isValidUpdate) { + // if the update was valid, we can commit the updates + for (let { key, value, fullValue } of updates) { + tree.setLeaf(key, Field(value)); + if (valueMap) valueMap.set(key, fullValue); + } + } else { + // if the update was invalid, we have to roll back the intermediate tree + intermediateTree = tree.clone(); + } + } +} From bda7dcedafba79db18448a816ae775aae551f030 Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 23:25:21 +0200 Subject: [PATCH 17/29] fix proofs disabled case --- src/lib/mina/actions/offchain-state-rollup.ts | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/src/lib/mina/actions/offchain-state-rollup.ts b/src/lib/mina/actions/offchain-state-rollup.ts index d934ba38f0..236c24a1fc 100644 --- a/src/lib/mina/actions/offchain-state-rollup.ts +++ b/src/lib/mina/actions/offchain-state-rollup.ts @@ -13,6 +13,7 @@ import { LinearizedAction, LinearizedActionList, MerkleLeaf, + updateMerkleMap, } from './offchain-state-serialization.js'; import { MerkleMap } from '../../provable/merkle-map.js'; import { getProofsEnabled } from '../mina.js'; @@ -94,8 +95,7 @@ function merkleUpdateBatch( let root = stateA.root; let intermediateRoot = root; - type TreeUpdate = { key: Field; value: Field }; - let intermediateUpdates: TreeUpdate[] = []; + let intermediateUpdates: { key: Field; value: Field }[] = []; let intermediateTree = Unconstrained.witness(() => tree.get().clone()); let isValidUpdate = Bool(true); @@ -277,7 +277,22 @@ function OffchainStateRollup({ // if proofs are disabled, create a dummy proof and final state, and return if (!getProofsEnabled()) { - tree = merkleUpdateOutside(actions, tree); + // convert actions to nested array + let actionsList = actions.data + .get() + .map(({ element: actionsList }) => + actionsList.data + .get() + .map(({ element }) => element) + // TODO reverse needed because of bad internal merkle list representation + .reverse() + ) + // TODO reverse needed because of bad internal merkle list representation + .reverse(); + + // update the tree outside the circuit + updateMerkleMap(actionsList, tree); + let finalState = new OffchainStateCommitments({ root: tree.getRoot(), actionState: iterator.hash, @@ -316,7 +331,7 @@ function OffchainStateRollup({ }; } -// from a nested list of actions, create a slice (iterator) starting at `index` that has at most `batchSize` actions in it\ +// from a nested list of actions, create a slice (iterator) starting at `index` that has at most `batchSize` actions in it. // also moves the original iterator forward to start after the slice function sliceActions(actions: ActionIterator, batchSize: number) { class ActionListsList extends MerkleList.create( @@ -348,20 +363,3 @@ function sliceActions(actions: ActionIterator, batchSize: number) { return slice.startIterating(); } - -// TODO: do we have to repeat the merkle updates outside the circuit? - -function merkleUpdateOutside( - actions: MerkleList>, - tree: MerkleTree -) { - tree = tree.clone(); - - actions.data.get().forEach(({ element: actionsList }) => { - actionsList.data.get().forEach(({ element: { key, value } }) => { - tree.setLeaf(key.toBigInt(), value); - }); - }); - - return tree; -} From cd9a68ab05ad3a1f5aa46067729dabf42ca81b9c Mon Sep 17 00:00:00 2001 From: Gregor Date: Tue, 14 May 2024 23:26:19 +0200 Subject: [PATCH 18/29] remove debug logs --- src/lib/mina/actions/offchain-state-rollup.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/lib/mina/actions/offchain-state-rollup.ts b/src/lib/mina/actions/offchain-state-rollup.ts index 236c24a1fc..ce5b47e813 100644 --- a/src/lib/mina/actions/offchain-state-rollup.ts +++ b/src/lib/mina/actions/offchain-state-rollup.ts @@ -302,20 +302,17 @@ function OffchainStateRollup({ } // base proof - console.time('batch 0'); let slice = sliceActions(iterator, maxActionsPerBatch); let proof = await offchainStateRollup.firstBatch( inputState, slice, Unconstrained.from(tree) ); - console.timeEnd('batch 0'); // recursive proofs for (let i = 1; ; i++) { if (iterator.isAtEnd().toBoolean()) break; - console.time(`batch ${i}`); let slice = sliceActions(iterator, maxActionsPerBatch); proof = await offchainStateRollup.nextBatch( inputState, @@ -323,7 +320,6 @@ function OffchainStateRollup({ Unconstrained.from(tree), proof ); - console.timeEnd(`batch ${i}`); } return { proof, tree }; From 49cf11a1264f229846d1e211b48bdba55271dabf Mon Sep 17 00:00:00 2001 From: Gregor Date: Wed, 15 May 2024 10:35:10 +0200 Subject: [PATCH 19/29] set -> overwrite plus scary doccomments --- .../actions/offchain-contract.unit-test.ts | 2 +- src/lib/mina/actions/offchain-state.ts | 38 ++++++++++++------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/lib/mina/actions/offchain-contract.unit-test.ts b/src/lib/mina/actions/offchain-contract.unit-test.ts index cd0ce1d359..908369760e 100644 --- a/src/lib/mina/actions/offchain-contract.unit-test.ts +++ b/src/lib/mina/actions/offchain-contract.unit-test.ts @@ -23,7 +23,7 @@ class ExampleContract extends SmartContract { @method async createAccount(address: PublicKey, amountToMint: UInt64) { - offchainState.fields.accounts.set(address, amountToMint); + offchainState.fields.accounts.overwrite(address, amountToMint); // TODO using `update()` on the total supply means that this method // can only be called once every settling cycle diff --git a/src/lib/mina/actions/offchain-state.ts b/src/lib/mina/actions/offchain-state.ts index 2c1058352f..56e6254dc8 100644 --- a/src/lib/mina/actions/offchain-state.ts +++ b/src/lib/mina/actions/offchain-state.ts @@ -183,7 +183,7 @@ function OffchainState< let optionType = Option(type); return { - set(value) { + overwrite(value) { // serialize into action let action = toAction({ prefix, @@ -230,7 +230,7 @@ function OffchainState< let optionType = Option(valueType); return { - set(key, value) { + overwrite(key, value) { // serialize into action let action = toAction({ prefix, @@ -346,19 +346,24 @@ type OffchainField = { */ get(): Promise>; - /** - * Set the value of the field. - */ - set(value: T | TValue): void; - /** * Update the value of the field, while requiring a specific previous value. * * If the previous value does not match, the update will not be applied. * - * Note that the previous value is an option: to require that the field was not set before, use `Option.none()`. + * Note that the previous value is an option: to require that the field was not set before, use `Option(type).none()` or `undefined`. */ update(update: { from: OptionOrValue; to: T | TValue }): void; + + /** + * Set the value of the field to the given value, without taking into account the previous value. + * + * **Warning**: if this is performed by multiple zkapp calls concurrently (between one call to `settle()` and the next), + * calls that are applied later will simply overwrite and ignore whatever changes were made by earlier calls. + * + * This behaviour can imply a security risk in many applications, so use `overwrite()` with caution. + */ + overwrite(value: T | TValue): void; }; function OffchainMap(key: K, value: V) { @@ -370,22 +375,27 @@ type OffchainMap = { */ get(key: K): Promise>; - /** - * Set the value for this key. - */ - set(key: K, value: V | VValue): void; - /** * Update the value of the field, while requiring a specific previous value. * * If the previous value does not match, the update will not be applied. * - * Note that the previous value is an option: to require that the field was not set before, use `Option.none()`. + * Note that the previous value is an option: to require that the field was not set before, use `Option(type).none()` or `undefined`. */ update( key: K, update: { from: OptionOrValue; to: V | VValue } ): void; + + /** + * Set the value for this key to the given value, without taking into account the previous value. + * + * **Warning**: if the same key is modified by multiple zkapp calls concurrently (between one call to `settle()` and the next), + * calls that are applied later will simply overwrite and ignore whatever changes were made by earlier calls. + * + * This behaviour can imply a security risk in many applications, so use `overwrite()` with caution. + */ + overwrite(key: K, value: V | VValue): void; }; type OffchainStateKind = From c18fbe66f512cd13d74b0265c3627f4a4c5e7916 Mon Sep 17 00:00:00 2001 From: Gregor Date: Wed, 15 May 2024 11:42:14 +0200 Subject: [PATCH 20/29] export offchain state, add doccomments --- src/index.ts | 14 ++++++ src/lib/mina/actions/offchain-state-rollup.ts | 8 ++++ src/lib/mina/actions/offchain-state.ts | 47 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/src/index.ts b/src/index.ts index f58de89974..0252a528bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -128,6 +128,7 @@ export { setNumberOfWorkers } from './lib/proof-system/workers.js'; // experimental APIs import { memoizeWitness } from './lib/provable/provable.js'; +import * as OffchainState_ from './lib/mina/actions/offchain-state.js'; export { Experimental }; const Experimental_ = { @@ -140,6 +141,19 @@ const Experimental_ = { */ namespace Experimental { export let memoizeWitness = Experimental_.memoizeWitness; + + // offchain state + export let OffchainState = OffchainState_.OffchainState; + + /** + * Commitments that keep track of the current state of an offchain Merkle tree constructed from actions. + * Intended to be stored on-chain. + * + * Fields: + * - `root`: The root of the current Merkle tree + * - `actionState`: The hash pointing to the list of actions that have been applied to form the current Merkle tree + */ + export class OffchainStateCommitments extends OffchainState_.OffchainStateCommitments {} } Error.stackTraceLimit = 100000; diff --git a/src/lib/mina/actions/offchain-state-rollup.ts b/src/lib/mina/actions/offchain-state-rollup.ts index ce5b47e813..985eb2816b 100644 --- a/src/lib/mina/actions/offchain-state-rollup.ts +++ b/src/lib/mina/actions/offchain-state-rollup.ts @@ -28,6 +28,14 @@ class ActionIterator extends MerkleListIterator.create( Actions.emptyActionState() ) {} +/** + * Commitments that keep track of the current state of an offchain Merkle tree constructed from actions. + * Intended to be stored on-chain. + * + * Fields: + * - `root`: The root of the current Merkle tree + * - `actionState`: The hash pointing to the list of actions that have been applied to form the current Merkle tree + */ class OffchainStateCommitments extends Struct({ // this should just be a MerkleTree type that carries the full tree as aux data root: Field, diff --git a/src/lib/mina/actions/offchain-state.ts b/src/lib/mina/actions/offchain-state.ts index 56e6254dc8..c0582b4e35 100644 --- a/src/lib/mina/actions/offchain-state.ts +++ b/src/lib/mina/actions/offchain-state.ts @@ -70,6 +70,24 @@ type OffchainState = { /** * Settle the offchain state. + * + * Use this in a contract method as follows: + * + * @example + * ```ts + * class StateProof extends offchainState.Proof {} + * + * // ... + * + * class MyContract extends SmartContract { + * \@method + * async settle(proof: StateProof) { + * await offchainState.settle(proof); + * } + * } + * ``` + * + * The `StateProof` can be created by calling `offchainState.createSettlementProof()`. */ settle( proof: Proof @@ -82,6 +100,35 @@ type OffchainStateContract = SmartContract & { const MerkleWitness256 = MerkleWitness(256); +/** + * Offchain state for a `SmartContract`. + * + * ```ts + * // declare your offchain state + * + * const offchainState = OffchainState({ + * accounts: OffchainState.Map(PublicKey, UInt64), + * totalSupply: OffchainState.Field(UInt64), + * }); + * + * // use it in a contract, by adding an onchain state field of type `OffchainStateCommitments` + * + * class MyContract extends SmartContract { + * \@state(OffchainStateCommitments) offchainState = State( + * OffchainStateCommitments.empty() + * ); + * + * // ... + * } + * + * // set the contract instance + * + * let contract = new MyContract(address); + * offchainState.setContractInstance(contract); + * ``` + * + * See the individual methods on `offchainState` for more information on usage. + */ function OffchainState< const Config extends { [key: string]: OffchainStateKind } >(config: Config): OffchainState { From 39307c1d03b29dffc1c47343e609d6260988cf46 Mon Sep 17 00:00:00 2001 From: Gregor Date: Wed, 15 May 2024 12:01:42 +0200 Subject: [PATCH 21/29] failing, more complex test --- .../actions/offchain-contract.unit-test.ts | 56 +++++++++++++++---- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/src/lib/mina/actions/offchain-contract.unit-test.ts b/src/lib/mina/actions/offchain-contract.unit-test.ts index 908369760e..92279b937d 100644 --- a/src/lib/mina/actions/offchain-contract.unit-test.ts +++ b/src/lib/mina/actions/offchain-contract.unit-test.ts @@ -1,10 +1,19 @@ -import { OffchainState, OffchainStateCommitments } from './offchain-state.js'; -import { PublicKey } from '../../provable/crypto/signature.js'; -import { UInt64 } from '../../provable/int.js'; -import { SmartContract, method } from '../zkapp.js'; -import { Mina, State, state } from '../../../index.js'; +import { + SmartContract, + method, + Mina, + State, + state, + PublicKey, + UInt64, + Experimental, +} from '../../../index.js'; import assert from 'assert'; +const proofsEnabled = true; + +const { OffchainState, OffchainStateCommitments } = Experimental; + const offchainState = OffchainState({ accounts: OffchainState.Map(PublicKey, UInt64), totalSupply: OffchainState.Field(UInt64), @@ -78,12 +87,11 @@ class ExampleContract extends SmartContract { // test code below // setup -const proofsEnabled = true; const Local = await Mina.LocalBlockchain({ proofsEnabled }); Mina.setActiveInstance(Local); -let [sender, receiver, contractAccount] = Local.testAccounts; +let [sender, receiver, contractAccount, other] = Local.testAccounts; let contract = new ExampleContract(contractAccount); offchainState.setContractInstance(contract); @@ -96,6 +104,8 @@ if (proofsEnabled) { console.timeEnd('compile contract'); } +Local.setProofsEnabled(false); + // deploy and create first account console.time('deploy'); @@ -136,7 +146,7 @@ await Mina.transaction(sender, () => contract.settle(proof)) console.timeEnd('settle 1'); // check balance and supply -await checkAgainstSupply(1000n); +await check({ expectedSupply: 1000n, expectedSenderBalance: 1000n }); // transfer @@ -145,16 +155,33 @@ await Mina.transaction(sender, async () => { // first call (should succeed) await contract.transfer(sender, receiver, UInt64.from(100)); - // second call (should fail) + // more calls that should all fail + // (these are enough to need two proof steps during settlement) await contract.transfer(sender, receiver, UInt64.from(200)); + await contract.transfer(sender, receiver, UInt64.from(300)); + await contract.transfer(sender, receiver, UInt64.from(400)); + await contract.transfer(sender, receiver, UInt64.from(500)); }) .sign([sender.key]) .prove() .send(); console.timeEnd('transfer'); +// create another account + +console.time('create account 2'); +await Mina.transaction(sender, async () => { + await contract.createAccount(other, UInt64.from(500)); +}) + .sign([sender.key]) + .prove() + .send(); +console.timeEnd('create account 2'); + // settle +Local.setProofsEnabled(true); + console.time('settlement proof 2'); proof = await offchainState.createSettlementProof(); console.timeEnd('settlement proof 2'); @@ -167,11 +194,17 @@ await Mina.transaction(sender, () => contract.settle(proof)) console.timeEnd('settle 2'); // check balance and supply -await checkAgainstSupply(1000n); +await check({ expectedSupply: 1500n, expectedSenderBalance: 900n }); // test helper -async function checkAgainstSupply(expectedSupply: bigint) { +async function check({ + expectedSupply, + expectedSenderBalance, +}: { + expectedSupply: bigint; + expectedSenderBalance: bigint; +}) { let supply = (await contract.getSupply()).toBigInt(); assert.strictEqual(supply, expectedSupply); @@ -181,4 +214,5 @@ async function checkAgainstSupply(expectedSupply: bigint) { console.log('balance (sender)', balanceSender); console.log('balance (recv)', balanceReceiver); assert.strictEqual(balanceSender + balanceReceiver, supply); + assert.strictEqual(balanceSender, expectedSenderBalance); } From fc646d3d4a8a9ad1425f4620b1e54110040596ab Mon Sep 17 00:00:00 2001 From: Gregor Date: Wed, 15 May 2024 12:53:14 +0200 Subject: [PATCH 22/29] fix: convert field elements to constants when moving out of circuit --- src/lib/mina/actions/offchain-state-rollup.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/lib/mina/actions/offchain-state-rollup.ts b/src/lib/mina/actions/offchain-state-rollup.ts index 985eb2816b..f4cfa6d009 100644 --- a/src/lib/mina/actions/offchain-state-rollup.ts +++ b/src/lib/mina/actions/offchain-state-rollup.ts @@ -126,8 +126,10 @@ function merkleUpdateBatch( // prove that the witness and `actualPreviousValue` is correct, by comparing the implied root and key // note: this just works if the (key, value) is a (0,0) dummy, because the value at the 0 key will always be 0 - witness.calculateIndex().assertEquals(key); - witness.calculateRoot(actualPreviousValue).assertEquals(intermediateRoot); + witness.calculateIndex().assertEquals(key, 'key mismatch'); + witness + .calculateRoot(actualPreviousValue) + .assertEquals(intermediateRoot, 'root mismatch'); // if an expected previous value was provided, check whether it matches the actual previous value // otherwise, the entire update in invalidated @@ -153,14 +155,14 @@ function merkleUpdateBatch( // ignore dummy value if (isDummy.toBoolean()) return; - intermediateTree.get().setLeaf(key.toBigInt(), value); + intermediateTree.get().setLeaf(key.toBigInt(), value.toConstant()); intermediateUpdates.push({ key, value }); if (isCheckPoint.toBoolean()) { // if the update was valid, apply the intermediate updates to the actual tree if (wasValidUpdate.toBoolean()) { intermediateUpdates.forEach(({ key, value }) => { - tree.get().setLeaf(key.toBigInt(), value); + tree.get().setLeaf(key.toBigInt(), value.toConstant()); }); } // otherwise, we have to roll back the intermediate tree (TODO: inefficient) @@ -306,7 +308,7 @@ function OffchainStateRollup({ actionState: iterator.hash, }); let proof = await RollupProof.dummy(inputState, finalState, 2, 15); - return { proof, tree }; + return { proof, tree, nProofs: 0 }; } // base proof @@ -318,8 +320,10 @@ function OffchainStateRollup({ ); // recursive proofs + let nProofs = 1; for (let i = 1; ; i++) { if (iterator.isAtEnd().toBoolean()) break; + nProofs++; let slice = sliceActions(iterator, maxActionsPerBatch); proof = await offchainStateRollup.nextBatch( @@ -330,7 +334,7 @@ function OffchainStateRollup({ ); } - return { proof, tree }; + return { proof, tree, nProofs }; }, }; } From eafddc6943da9365657606e0104dcae0dd9087fb Mon Sep 17 00:00:00 2001 From: Gregor Date: Wed, 15 May 2024 13:02:54 +0200 Subject: [PATCH 23/29] change and fix test --- .../actions/offchain-contract.unit-test.ts | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/src/lib/mina/actions/offchain-contract.unit-test.ts b/src/lib/mina/actions/offchain-contract.unit-test.ts index 92279b937d..50a860dcdf 100644 --- a/src/lib/mina/actions/offchain-contract.unit-test.ts +++ b/src/lib/mina/actions/offchain-contract.unit-test.ts @@ -104,8 +104,6 @@ if (proofsEnabled) { console.timeEnd('compile contract'); } -Local.setProofsEnabled(false); - // deploy and create first account console.time('deploy'); @@ -148,39 +146,38 @@ console.timeEnd('settle 1'); // check balance and supply await check({ expectedSupply: 1000n, expectedSenderBalance: 1000n }); -// transfer +// transfer (should succeed) console.time('transfer'); await Mina.transaction(sender, async () => { - // first call (should succeed) await contract.transfer(sender, receiver, UInt64.from(100)); - - // more calls that should all fail - // (these are enough to need two proof steps during settlement) - await contract.transfer(sender, receiver, UInt64.from(200)); - await contract.transfer(sender, receiver, UInt64.from(300)); - await contract.transfer(sender, receiver, UInt64.from(400)); - await contract.transfer(sender, receiver, UInt64.from(500)); }) .sign([sender.key]) .prove() .send(); console.timeEnd('transfer'); -// create another account +console.time('more transfers'); +Local.setProofsEnabled(false); // we run these without proofs to save time -console.time('create account 2'); await Mina.transaction(sender, async () => { - await contract.createAccount(other, UInt64.from(500)); + // more transfers that should fail + // (these are enough to need two proof steps during settlement) + await contract.transfer(sender, receiver, UInt64.from(200)); + await contract.transfer(sender, receiver, UInt64.from(300)); + await contract.transfer(sender, receiver, UInt64.from(400)); + await contract.transfer(sender, receiver, UInt64.from(500)); + + // create another account (should succeed) + await contract.createAccount(other, UInt64.from(555)); }) .sign([sender.key]) .prove() .send(); -console.timeEnd('create account 2'); +console.timeEnd('more transfers'); // settle - -Local.setProofsEnabled(true); +Local.setProofsEnabled(proofsEnabled); console.time('settlement proof 2'); proof = await offchainState.createSettlementProof(); @@ -194,7 +191,7 @@ await Mina.transaction(sender, () => contract.settle(proof)) console.timeEnd('settle 2'); // check balance and supply -await check({ expectedSupply: 1500n, expectedSenderBalance: 900n }); +await check({ expectedSupply: 1555n, expectedSenderBalance: 900n }); // test helper @@ -210,9 +207,10 @@ async function check({ let balanceSender = (await contract.getBalance(sender)).toBigInt(); let balanceReceiver = (await contract.getBalance(receiver)).toBigInt(); + let balanceOther = (await contract.getBalance(other)).toBigInt(); console.log('balance (sender)', balanceSender); console.log('balance (recv)', balanceReceiver); - assert.strictEqual(balanceSender + balanceReceiver, supply); + assert.strictEqual(balanceSender + balanceReceiver + balanceOther, supply); assert.strictEqual(balanceSender, expectedSenderBalance); } From 5ae2efdade698aabef825aa0e0382b41f2e305de Mon Sep 17 00:00:00 2001 From: Gregor Date: Wed, 15 May 2024 13:45:36 +0200 Subject: [PATCH 24/29] further tweak option type --- src/lib/provable/option.ts | 65 ++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/src/lib/provable/option.ts b/src/lib/provable/option.ts index 8f51f1cb12..b4b34e0190 100644 --- a/src/lib/provable/option.ts +++ b/src/lib/provable/option.ts @@ -1,7 +1,8 @@ -import { From, InferValue } from '../../bindings/lib/provable-generic.js'; +import { InferValue } from '../../bindings/lib/provable-generic.js'; import { emptyValue } from '../proof-system/zkprogram.js'; import { Provable } from './provable.js'; import { InferProvable, Struct } from './types/struct.js'; +import { provable } from './types/provable-derivers.js'; import { Bool } from './wrapped.js'; export { Option, OptionOrValue }; @@ -11,7 +12,11 @@ type Option = { isSome: Bool; value: T } & { orElse(defaultValue: T | V): T; }; -type OptionOrValue = { isSome: boolean | Bool; value: V | T }; +type OptionOrValue = + | { isSome: boolean | Bool; value: T | V } + | T + | V + | undefined; /** * Define an optional version of a provable type. @@ -33,26 +38,55 @@ function Option>( type: A ): Provable< Option, InferValue>, - // TODO make V | undefined the value type - { isSome: boolean; value: InferValue } + InferValue | undefined > & { - fromValue: ( - value: From<{ isSome: typeof Bool; value: A }> - ) => Option, InferValue>; - from(value?: InferProvable): Option, InferValue>; + fromValue( + value: + | { isSome: boolean | Bool; value: InferProvable | InferValue } + | InferProvable + | InferValue + | undefined + ): Option, InferValue>; + from( + value?: InferProvable | InferValue + ): Option, InferValue>; none(): Option, InferValue>; } { type T = InferProvable; type V = InferValue; + let strictType: Provable = type; - const Super = Struct({ isSome: Bool, value: type as Provable }); + // construct a provable with a JS type of `T | undefined` + const PlainOption: Provable< + { isSome: Bool; value: T }, + { isSome: boolean; value: V } + > = provable({ isSome: Bool, value: strictType }); + + const RawOption = { + ...PlainOption, + + toValue({ isSome, value }: { isSome: Bool; value: T }) { + return isSome.toBoolean() ? strictType.toValue(value) : undefined; + }, + + fromValue(value: OptionOrValue) { + if (value === undefined) + return { isSome: Bool(false), value: emptyValue(strictType) }; + // TODO: this isn't 100% robust. We would need recursive type validation on any nested objects to make it work + if (typeof value === 'object' && 'isSome' in value) + return PlainOption.fromValue(value as any); + return { isSome: Bool(true), value: strictType.fromValue(value) }; + }, + }; + + const Super = Struct(RawOption); return class Option_ extends Super { orElse(defaultValue: T | V): T { return Provable.if( this.isSome, - type, + strictType, this.value, - type.fromValue(defaultValue) + strictType.fromValue(defaultValue) ); } @@ -63,8 +97,11 @@ function Option>( static from(value?: V | T) { return value === undefined - ? new Option_({ isSome: Bool(false), value: emptyValue(type) }) - : new Option_({ isSome: Bool(true), value: type.fromValue(value) }); + ? new Option_({ isSome: Bool(false), value: emptyValue(strictType) }) + : new Option_({ + isSome: Bool(true), + value: strictType.fromValue(value), + }); } static none() { return Option_.from(undefined); @@ -73,7 +110,7 @@ function Option>( static fromFields(fields: any[], aux?: any): Option_ { return new Option_(Super.fromFields(fields, aux)); } - static fromValue(value: { isSome: boolean | Bool; value: V | T }) { + static fromValue(value: OptionOrValue) { return new Option_(Super.fromValue(value)); } }; From 036d798655c867f8202b4f312e9f77a16461d01d Mon Sep 17 00:00:00 2001 From: Gregor Date: Wed, 15 May 2024 13:46:02 +0200 Subject: [PATCH 25/29] tweak test --- src/lib/mina/actions/offchain-contract.unit-test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib/mina/actions/offchain-contract.unit-test.ts b/src/lib/mina/actions/offchain-contract.unit-test.ts index 50a860dcdf..882e86efc4 100644 --- a/src/lib/mina/actions/offchain-contract.unit-test.ts +++ b/src/lib/mina/actions/offchain-contract.unit-test.ts @@ -32,7 +32,11 @@ class ExampleContract extends SmartContract { @method async createAccount(address: PublicKey, amountToMint: UInt64) { - offchainState.fields.accounts.overwrite(address, amountToMint); + // setting `from` to `undefined` means that the account must not exist yet + offchainState.fields.accounts.update(address, { + from: undefined, + to: amountToMint, + }); // TODO using `update()` on the total supply means that this method // can only be called once every settling cycle @@ -166,10 +170,12 @@ await Mina.transaction(sender, async () => { await contract.transfer(sender, receiver, UInt64.from(200)); await contract.transfer(sender, receiver, UInt64.from(300)); await contract.transfer(sender, receiver, UInt64.from(400)); - await contract.transfer(sender, receiver, UInt64.from(500)); // create another account (should succeed) await contract.createAccount(other, UInt64.from(555)); + + // create existing account again (should fail) + await contract.createAccount(receiver, UInt64.from(333)); }) .sign([sender.key]) .prove() From 41244454e3a49b11cb999c491fcd4bd07b5d2de9 Mon Sep 17 00:00:00 2001 From: Gregor Date: Wed, 15 May 2024 13:48:37 +0200 Subject: [PATCH 26/29] minor comment --- src/lib/provable/option.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/provable/option.ts b/src/lib/provable/option.ts index b4b34e0190..372bcd5f6c 100644 --- a/src/lib/provable/option.ts +++ b/src/lib/provable/option.ts @@ -74,7 +74,7 @@ function Option>( return { isSome: Bool(false), value: emptyValue(strictType) }; // TODO: this isn't 100% robust. We would need recursive type validation on any nested objects to make it work if (typeof value === 'object' && 'isSome' in value) - return PlainOption.fromValue(value as any); + return PlainOption.fromValue(value as any); // type-cast here is ok, matches implementation return { isSome: Bool(true), value: strictType.fromValue(value) }; }, }; From 6a10121623f650a19324cf62c8f5db5210b8c66b Mon Sep 17 00:00:00 2001 From: Gregor Date: Wed, 15 May 2024 13:49:40 +0200 Subject: [PATCH 27/29] 1.2.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9bbd0480be..97fb5f146c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "o1js", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "o1js", - "version": "1.1.0", + "version": "1.2.0", "license": "Apache-2.0", "dependencies": { "blakejs": "1.2.1", diff --git a/package.json b/package.json index 347810c150..8ad4033e20 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "o1js", "description": "TypeScript framework for zk-SNARKs and zkApps", - "version": "1.1.0", + "version": "1.2.0", "license": "Apache-2.0", "homepage": "https://github.com/o1-labs/o1js/", "keywords": [ From b958aa119e1a76e133df738a52ea6aef83923432 Mon Sep 17 00:00:00 2001 From: Gregor Date: Wed, 15 May 2024 13:56:13 +0200 Subject: [PATCH 28/29] changelog --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3215c7dd3d..6bbe615548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,15 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm _Security_ in case of vulnerabilities. --> -## [Unreleased](https://github.com/o1-labs/o1js/compare/4a17de857...HEAD) +## [Unreleased](https://github.com/o1-labs/o1js/compare/6a1012162...HEAD) + +## [1.2.0](https://github.com/o1-labs/o1js/compare/4a17de857...6a1012162) - 2024-05-14 ### Added +- **Offchain storage MVP** exported under `Experimental.OffchainStorage` https://github.com/o1-labs/o1js/pull/1630 https://github.com/o1-labs/o1js/pull/1652 + - allows you to store any number of fields and key-value maps on your zkApp + - implemented using actions which define an offchain Merkle tree - `Option` for defining an optional version of any provable type https://github.com/o1-labs/o1js/pull/1630 - `MerkleTree.clone()` and `MerkleTree.getLeaf()`, new convenience methods for merkle trees https://github.com/o1-labs/o1js/pull/1630 - `MerkleList.forEach()`, a simple and safe way for iterating over a `MerkleList` From 0d10ff2e97a76a52afd24bf3138d908961673740 Mon Sep 17 00:00:00 2001 From: Gregor Date: Wed, 15 May 2024 14:01:42 +0200 Subject: [PATCH 29/29] changelog fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bbe615548..29d1dcc956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added -- **Offchain storage MVP** exported under `Experimental.OffchainStorage` https://github.com/o1-labs/o1js/pull/1630 https://github.com/o1-labs/o1js/pull/1652 +- **Offchain state MVP** exported under `Experimental.OffchainState` https://github.com/o1-labs/o1js/pull/1630 https://github.com/o1-labs/o1js/pull/1652 - allows you to store any number of fields and key-value maps on your zkApp - implemented using actions which define an offchain Merkle tree - `Option` for defining an optional version of any provable type https://github.com/o1-labs/o1js/pull/1630