diff --git a/CHANGELOG.md b/CHANGELOG.md index 7847fcdb5..3215c7dd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,20 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added +- `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` +- `Unconstrained.provableWithEmpty()` to create an unconstrained provable type with a known `empty()` value https://github.com/o1-labs/o1js/pull/1630 - `Permissions.VerificationKey`, a namespace for verification key permissions https://github.com/o1-labs/o1js/pull/1639 - Includes more accurate names for the `impossible` and `proof` permissions for verification keys, which are now called `impossibleDuringCurrentVersion` and `proofDuringCurrentVersion` respectively. +### Changed + +- `State()` now optionally accepts an initial value as input parameter https://github.com/o1-labs/o1js/pull/1630 + - Example: `@state(Field) x = State(Field(1));` + - Initial values will be set in the default `init()` method + - You no longer need a custom `init()` method to set initial values + ### Fixes - Fix absolute imports which prevented compilation in some TS projects that used o1js https://github.com/o1-labs/o1js/pull/1628 diff --git a/src/bindings b/src/bindings index 51933ebdc..03241cd44 160000 --- a/src/bindings +++ b/src/bindings @@ -1 +1 @@ -Subproject commit 51933ebdcc57604f3ba621c10a7e148b0587ab25 +Subproject commit 03241cd44f895249fc28dda7bd133479f2ef0de2 diff --git a/src/examples/simple-zkapp.ts b/src/examples/simple-zkapp.ts index 337b3a476..349dfbd3d 100644 --- a/src/examples/simple-zkapp.ts +++ b/src/examples/simple-zkapp.ts @@ -18,14 +18,13 @@ const doProofs = true; const beforeGenesis = UInt64.from(Date.now()); class SimpleZkapp extends SmartContract { - @state(Field) x = State(); + @state(Field) x = State(initialState); events = { update: Field, payout: UInt64, payoutReceiver: PublicKey }; @method async init() { super.init(); - this.x.set(initialState); } @method.returns(Field) diff --git a/src/index.ts b/src/index.ts index 7678fe5dc..f58de8997 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,11 @@ export type { FlexibleProvablePure, InferProvable, } from './lib/provable/types/struct.js'; -export { provable, provablePure, Struct } from './lib/provable/types/struct.js'; +export { + provable, + provablePure, +} from './lib/provable/types/provable-derivers.js'; +export { Struct } from './lib/provable/types/struct.js'; export { Unconstrained } from './lib/provable/types/unconstrained.js'; export { Provable } from './lib/provable/provable.js'; export { @@ -48,6 +52,7 @@ export { Gadgets } from './lib/provable/gadgets/gadgets.js'; export { Types } from './bindings/mina-transaction/types.js'; export { MerkleList, MerkleListIterator } from './lib/provable/merkle-list.js'; +export { Option } from './lib/provable/option.js'; export * as Mina from './lib/mina/mina.js'; export { @@ -59,12 +64,8 @@ export { type PendingTransactionPromise, } from './lib/mina/transaction.js'; export type { DeployArgs } from './lib/mina/zkapp.js'; -export { - SmartContract, - method, - declareMethods, - Reducer, -} from './lib/mina/zkapp.js'; +export { SmartContract, method, declareMethods } from './lib/mina/zkapp.js'; +export { Reducer } from './lib/mina/actions/reducer.js'; export { state, State, declareState } from './lib/mina/state.js'; export type { JsonProof } from './lib/proof-system/zkprogram.js'; diff --git a/src/lib/mina/account-update.ts b/src/lib/mina/account-update.ts index 79a92a758..52222c569 100644 --- a/src/lib/mina/account-update.ts +++ b/src/lib/mina/account-update.ts @@ -1,10 +1,9 @@ import { cloneCircuitValue, FlexibleProvable, - provable, - provablePure, StructNoJson, } from '../provable/types/struct.js'; +import { provable, provablePure } from '../provable/types/provable-derivers.js'; import { memoizationContext, memoizeWitness, diff --git a/src/lib/mina/actions/offchain-contract.unit-test.ts b/src/lib/mina/actions/offchain-contract.unit-test.ts new file mode 100644 index 000000000..547f4e57f --- /dev/null +++ b/src/lib/mina/actions/offchain-contract.unit-test.ts @@ -0,0 +1,167 @@ +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 assert from 'assert'; + +const offchainState = OffchainState({ + accounts: OffchainState.Map(PublicKey, UInt64), + totalSupply: OffchainState.Field(UInt64), +}); + +class StateProof extends offchainState.Proof {} + +// example contract that interacts with offchain state + +class ExampleContract extends SmartContract { + // TODO could have sugar for this like + // @OffchainState.commitment offchainState = OffchainState.Commitment(); + @state(OffchainStateCommitments) offchainState = State( + OffchainStateCommitments.empty() + ); + + @method + 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 + let totalSupply = await offchainState.fields.totalSupply.get(); + offchainState.fields.totalSupply.set(totalSupply.add(amountToMint)); + } + + @method + async transfer(from: PublicKey, to: PublicKey, amount: UInt64) { + let fromOption = await offchainState.fields.accounts.get(from); + let fromBalance = fromOption.assertSome('sender account exists'); + + let toOption = await offchainState.fields.accounts.get(to); + 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! + */ + offchainState.fields.accounts.set(from, fromBalance.sub(amount)); + offchainState.fields.accounts.set(to, toBalance.add(amount)); + } + + @method.returns(UInt64) + async getSupply() { + return await offchainState.fields.totalSupply.get(); + } + + @method.returns(UInt64) + async getBalance(address: PublicKey) { + return (await offchainState.fields.accounts.get(address)).orElse(0n); + } + + @method + async settle(proof: StateProof) { + await offchainState.settle(proof); + } +} + +// test code below + +// setup +const proofsEnabled = true; + +const Local = await Mina.LocalBlockchain({ proofsEnabled }); +Mina.setActiveInstance(Local); + +let [sender, receiver, contractAccount] = Local.testAccounts; +let contract = new ExampleContract(contractAccount); +offchainState.setContractInstance(contract); + +if (proofsEnabled) { + console.time('compile program'); + await offchainState.compile(); + console.timeEnd('compile program'); + console.time('compile contract'); + await ExampleContract.compile(); + console.timeEnd('compile contract'); +} + +// deploy and create first account + +console.time('deploy'); +await Mina.transaction(sender, async () => { + await contract.deploy(); +}) + .sign([sender.key, contractAccount.key]) + .prove() + .send(); +console.timeEnd('deploy'); + +// create first account + +console.time('create account'); +await Mina.transaction(sender, async () => { + await contract.createAccount(sender, UInt64.from(1000)); +}) + .sign([sender.key]) + .prove() + .send(); +console.timeEnd('create account'); + +// settle + +console.time('settlement proof 1'); +let proof = await offchainState.createSettlementProof(); +console.timeEnd('settlement proof 1'); + +console.time('settle 1'); +await Mina.transaction(sender, () => contract.settle(proof)) + .sign([sender.key]) + .prove() + .send(); +console.timeEnd('settle 1'); + +// check balance and supply +await checkAgainstSupply(1000n); + +// transfer + +console.time('transfer'); +await Mina.transaction(sender, () => + contract.transfer(sender, receiver, UInt64.from(100)) +) + .sign([sender.key]) + .prove() + .send(); +console.timeEnd('transfer'); + +// settle + +console.time('settlement proof 2'); +proof = await offchainState.createSettlementProof(); +console.timeEnd('settlement proof 2'); + +console.time('settle 2'); +await Mina.transaction(sender, () => contract.settle(proof)) + .sign([sender.key]) + .prove() + .send(); +console.timeEnd('settle 2'); + +// check balance and supply +await checkAgainstSupply(1000n); + +// test helper + +async function checkAgainstSupply(expectedSupply: bigint) { + let supply = (await contract.getSupply()).toBigInt(); + assert.strictEqual(supply, expectedSupply); + + let balanceSender = (await contract.getBalance(sender)).toBigInt(); + let balanceReceiver = (await contract.getBalance(receiver)).toBigInt(); + + console.log('balance (sender)', balanceSender); + console.log('balance (recv)', balanceReceiver); + assert.strictEqual(balanceSender + balanceReceiver, supply); +} diff --git a/src/lib/mina/actions/offchain-state-rollup.ts b/src/lib/mina/actions/offchain-state-rollup.ts new file mode 100644 index 000000000..077cbd9fb --- /dev/null +++ b/src/lib/mina/actions/offchain-state-rollup.ts @@ -0,0 +1,280 @@ +import { Proof, ZkProgram } from '../../proof-system/zkprogram.js'; +import { 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'; +import { MerkleTree, MerkleWitness } from '../../provable/merkle-tree.js'; +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 { MerkleMap } from '../../provable/merkle-map.js'; +import { getProofsEnabled } from '../mina.js'; + +export { OffchainStateRollup, OffchainStateCommitments }; + +class ActionIterator extends MerkleListIterator.create( + ActionList.provable, + (hash: Field, actions: ActionList) => + Actions.updateSequenceState(hash, actions.hash), + // we don't have to care about the initial hash here because we will just step forward + Actions.emptyActionState() +) {} + +class OffchainStateCommitments extends Struct({ + // this should just be a MerkleTree type that carries the full tree as aux data + root: Field, + // TODO: make zkprogram support auxiliary data in public inputs + // actionState: ActionIterator.provable, + actionState: Field, +}) { + static empty() { + let emptyMerkleRoot = new MerkleMap().getRoot(); + return new OffchainStateCommitments({ + root: emptyMerkleRoot, + actionState: Actions.emptyActionState(), + }); + } +} + +const TREE_HEIGHT = 256; +class MerkleMapWitness extends MerkleWitness(TREE_HEIGHT) {} + +// TODO: it would be nice to abstract the logic for proving a chain of state transition proofs + +/** + * Common logic for the proof that we can go from OffchainStateCommitments A -> B + */ +function merkleUpdateBatch( + { + maxUpdatesPerBatch, + maxActionsPerUpdate, + }: { + maxUpdatesPerBatch: number; + maxActionsPerUpdate: number; + }, + stateA: OffchainStateCommitments, + actions: ActionIterator, + tree: Unconstrained +): OffchainStateCommitments { + // this would be unnecessary if the iterator could just be the public input + 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()) + ); + + // 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 }; +} + +/** + * This program represents a proof that we can go from OffchainStateCommitments A -> B + */ +function OffchainStateRollup({ + maxUpdatesPerBatch = 2, + maxActionsPerUpdate = 2, +} = {}) { + let offchainStateRollup = ZkProgram({ + name: 'merkle-map-rollup', + publicInput: OffchainStateCommitments, + publicOutput: OffchainStateCommitments, + methods: { + /** + * `firstBatch()` creates the initial proof A -> B + */ + firstBatch: { + // [actions, tree] + privateInputs: [ActionIterator.provable, Unconstrained.provable], + + async method( + stateA: OffchainStateCommitments, + actions: ActionIterator, + tree: Unconstrained + ): Promise { + return merkleUpdateBatch( + { maxUpdatesPerBatch, maxActionsPerUpdate }, + stateA, + actions, + tree + ); + }, + }, + /** + * `nextBatch()` takes an existing proof A -> B, adds its own logic to prove B -> B', so that the output is a proof A -> B' + */ + nextBatch: { + // [actions, tree, proof] + privateInputs: [ + ActionIterator.provable, + Unconstrained.provable, + SelfProof, + ], + + async method( + stateA: OffchainStateCommitments, + actions: ActionIterator, + tree: Unconstrained, + recursiveProof: Proof< + OffchainStateCommitments, + OffchainStateCommitments + > + ): Promise { + recursiveProof.verify(); + + // in the recursive case, the recursive proof's initial state has to match this proof's initial state + Provable.assertEqual( + OffchainStateCommitments, + recursiveProof.publicInput, + stateA + ); + + // the state we start with + let stateB = recursiveProof.publicOutput; + + return merkleUpdateBatch( + { maxUpdatesPerBatch, maxActionsPerUpdate }, + stateB, + actions, + tree + ); + }, + }, + }, + }); + + let RollupProof = ZkProgram.Proof(offchainStateRollup); + + let isCompiled = false; + + return { + Proof: RollupProof, + program: offchainStateRollup, + + async compile() { + if (isCompiled) return; + let result = await offchainStateRollup.compile(); + isCompiled = true; + return result; + }, + + async prove(tree: MerkleTree, actions: MerkleList>) { + assert(tree.height === TREE_HEIGHT, 'Tree height must match'); + if (getProofsEnabled()) await this.compile(); + // 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, + }); + + // if proofs are disabled, create a dummy proof and final state, and return + if (!getProofsEnabled()) { + tree = merkleUpdateOutside(actions, tree, { + maxUpdatesPerBatch, + maxActionsPerUpdate, + }); + let finalState = new OffchainStateCommitments({ + root: tree.getRoot(), + actionState: iterator.hash, + }); + let proof = await RollupProof.dummy(inputState, finalState, 2, 15); + return { proof, tree }; + } + + // base proof + console.time('batch 0'); + let proof = await offchainStateRollup.firstBatch( + inputState, + iterator, + 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'); + } + + console.time(`batch ${i}`); + proof = await offchainStateRollup.nextBatch( + inputState, + iterator, + Unconstrained.from(tree), + proof + ); + console.timeEnd(`batch ${i}`); + } + + return { proof, tree }; + }, + }; +} + +// TODO: do we have to repeat the merkle updates outside the circuit? + +function merkleUpdateOutside( + actions: MerkleList>, + tree: MerkleTree, + { maxUpdatesPerBatch = 10, maxActionsPerUpdate = 5 } = {} +) { + tree = tree.clone(); + + actions.forEach(maxUpdatesPerBatch, (actionsList, isDummy) => { + if (isDummy.toBoolean()) return; + + actionsList.forEach(maxActionsPerUpdate, ({ key, value }, isDummy) => { + if (isDummy.toBoolean()) return; + + tree.setLeaf(key.toBigInt(), value); + }); + }); + + return tree; +} diff --git a/src/lib/mina/actions/offchain-state-serialization.ts b/src/lib/mina/actions/offchain-state-serialization.ts new file mode 100644 index 000000000..455a1e7f0 --- /dev/null +++ b/src/lib/mina/actions/offchain-state-serialization.ts @@ -0,0 +1,206 @@ +/** + * This defines a custom way to serialize various kinds of offchain state into an action. + * + * There is a special trick of including Merkle map (keyHash, valueHash) pairs _at the end_ of each action. + * Thanks to the properties of Poseidon, this enables us to compute the action hash cheaply + * if we only need to prove that (key, value) are part of it. + */ + +import { ProvablePure } from '../../provable/types/provable-intf.js'; +import { + Poseidon, + ProvableHashable, + hashWithPrefix, + packToFields, + salt, +} from '../../provable/crypto/poseidon.js'; +import { Field } 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'; +import { Unconstrained } from '../../provable/types/unconstrained.js'; +import { MerkleList } from '../../provable/merkle-list.js'; +import * as Mina from '../mina.js'; +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'; + +export { + toKeyHash, + toAction, + fromActionWithoutHashes, + MerkleLeaf, + ActionList, + fetchMerkleLeaves, + fetchMerkleMap, + Actionable, +}; + +type Action = [...Field[], Field, Field]; +type Actionable = ProvableHashable & ProvablePure; + +function toKeyHash | undefined>( + prefix: Field, + keyType: KeyType, + key: KeyType extends undefined ? undefined : K +): Field { + 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 { + let valueSize = valueType.sizeInFields(); + let padding = valueSize % 2 === 0 ? [] : [Field(0)]; + + let keyHash = hashPackedWithPrefix([prefix, Field(0)], keyType, key); + let valueHash = Poseidon.hashPacked(valueType, value); + return [...valueType.toFields(value), ...padding, keyHash, valueHash]; +} + +function fromActionWithoutHashes( + valueType: Actionable, + action: Field[] +): V { + let valueSize = valueType.sizeInFields(); + let paddingSize = valueSize % 2 === 0 ? 0 : 1; + assert(action.length === valueSize + paddingSize, 'invalid action size'); + + let value = valueType.fromFields(action.slice(0, valueSize)); + valueType.check(value); + + return value; +} + +function hashPackedWithPrefix | undefined>( + prefix: [Field, Field], + type: Type, + value: Type extends undefined ? undefined : T +) { + // hash constant prefix + let state = Poseidon.initialState(); + state = Poseidon.update(state, prefix); + + // hash value if a type was passed in + if (type !== undefined) { + let input = type.toInput(value as T); + let packed = packToFields(input); + state = Poseidon.update(state, packed); + } + return state[0]; +} + +/** + * This represents a custom kind of action which includes a Merkle map key and value in its serialization, + * and doesn't represent the rest of the action's field elements in provable code. + */ +class MerkleLeaf extends Struct({ + key: Field, + value: 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 }); + } + + /** + * 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: MerkleLeaf) { + 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, [action.key, action.value])[0]; + } +} + +function pushAction(actionsHash: Field, action: MerkleLeaf) { + return hashWithPrefix(prefixes.sequenceEvents, [ + actionsHash, + MerkleLeaf.hash(action), + ]); +} + +class ActionList extends MerkleList.create( + MerkleLeaf, + pushAction, + Actions.empty().hash +) {} + +async function fetchMerkleLeaves( + contract: { address: PublicKey; tokenId: Field }, + config?: { + fromActionState?: Field; + endActionState?: Field; + } +): Promise>> { + class MerkleActions extends MerkleList.create( + ActionList.provable, + (hash: Field, actions: ActionList) => + Actions.updateSequenceState(hash, actions.hash), + // if no "start" action hash was specified, this means we are fetching the entire history of actions, which started from the empty action state hash + // otherwise we are only fetching a part of the history, which starts at `fromActionState` + config?.fromActionState ?? Actions.emptyActionState() + ) {} + + let result = await Mina.fetchActions( + contract.address, + config, + contract.tokenId + ); + if ('error' in result) throw Error(JSON.stringify(result)); + + // convert string-Fields back into the original action type + let merkleLeafs = result.map((event) => + event.actions.map((action) => MerkleLeaf.fromAction(action.map(Field))) + ); + return MerkleActions.from(merkleLeafs.map((a) => ActionList.fromReverse(a))); +} + +// TODO this should be `updateMerkleMap`, and we should call it on every get() and settle() +/** + * Recreate Merkle tree from fetched actions. + * + * We also deserialize a keyHash -> value map from the leaves. + */ +async function fetchMerkleMap( + contract: { address: PublicKey; tokenId: Field }, + endActionState?: Field +): Promise<{ merkleMap: MerkleTree; valueMap: Map }> { + let result = await Mina.fetchActions( + contract.address, + { endActionState }, + contract.tokenId + ); + 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 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()); + } + + return { merkleMap, valueMap }; +} diff --git a/src/lib/mina/actions/offchain-state.ts b/src/lib/mina/actions/offchain-state.ts new file mode 100644 index 000000000..afe1d5005 --- /dev/null +++ b/src/lib/mina/actions/offchain-state.ts @@ -0,0 +1,367 @@ +import { InferProvable } from '../../provable/types/struct.js'; +import { + Actionable, + fetchMerkleLeaves, + fetchMerkleMap, + fromActionWithoutHashes, + toAction, + toKeyHash, +} from './offchain-state-serialization.js'; +import { Field } from '../../provable/wrapped.js'; +import { Proof } from '../../proof-system/zkprogram.js'; +import { + OffchainStateCommitments, + OffchainStateRollup, +} from './offchain-state-rollup.js'; +import { Option } from '../../provable/option.js'; +import { InferValue } from '../../../bindings/lib/provable-generic.js'; +import { SmartContract } from '../zkapp.js'; +import { assert } from '../../provable/gadgets/common.js'; +import { State } from '../state.js'; +import { Actions } from '../account-update.js'; +import { Provable } from '../../provable/provable.js'; +import { Poseidon } from '../../provable/crypto/poseidon.js'; +import { smartContractContext } from '../smart-contract-context.js'; +import { MerkleTree, MerkleWitness } from '../../provable/merkle-tree.js'; + +export { OffchainState, OffchainStateCommitments }; + +type OffchainState = { + /** + * The individual fields of the offchain state. + * + * ```ts + * const state = OffchainState({ totalSupply: OffchainState.Field(UInt64) }); + * + * state.fields.totalSupply.set(UInt64.from(100)); + * + * let supply = await state.fields.totalSupply.get(); + * ``` + */ + readonly fields: { + [K in keyof Config]: OffchainStateIntf; + }; + + /** + * Set the contract that this offchain state is connected with. + * + * This tells the offchain state about the account to fetch data from and modify, and lets it handle actions and onchain state. + */ + setContractInstance( + contract: SmartContract & { offchainState: State } + ): void; + + /** + * Compile the offchain state ZkProgram. + */ + compile(): Promise; + + /** + * Create a proof that updates the commitments to offchain state: Merkle root and action state. + */ + createSettlementProof(): Promise< + Proof + >; + + /** + * The custom proof class for state settlement proofs, that have to be passed into the settling method. + */ + Proof: typeof Proof; + + /** + * Settle the offchain state. + */ + settle( + proof: Proof + ): Promise; +}; + +type OffchainStateContract = SmartContract & { + offchainState: State; +}; + +const MerkleWitness256 = MerkleWitness(256); + +function OffchainState< + const Config extends { [key: string]: OffchainStateKind } +>(config: Config): OffchainState { + // setup internal state of this "class" + let internal = { + _contract: undefined as OffchainStateContract | undefined, + _merkleMap: undefined as MerkleTree | undefined, + _valueMap: undefined as Map | undefined, + + get contract() { + assert( + internal._contract !== undefined, + 'Must call `setContractAccount()` first' + ); + return internal._contract; + }, + }; + const onchainActionState = async () => { + let actionState = (await internal.contract.offchainState.fetch()) + ?.actionState; + assert(actionState !== undefined, 'Could not fetch action state'); + return actionState; + }; + + const merkleMaps = async () => { + if (internal._merkleMap !== undefined && internal._valueMap !== undefined) { + return { merkleMap: internal._merkleMap, valueMap: internal._valueMap }; + } + let actionState = await onchainActionState(); + let { merkleMap, valueMap } = await fetchMerkleMap( + internal.contract, + actionState + ); + internal._merkleMap = merkleMap; + internal._valueMap = valueMap; + return { merkleMap, valueMap }; + }; + + const notImplemented = (): any => assert(false, 'Not implemented'); + + let rollup = OffchainStateRollup(); + + function contract() { + let ctx = smartContractContext.get(); + assert( + ctx !== null, + 'Offchain state methods must be called within a contract method' + ); + assert( + ctx.this.constructor === internal.contract.constructor, + 'Offchain state methods can only be called on the same contract that you called setContractInstance() on' + ); + return ctx.this as OffchainStateContract; + } + + /** + * generic get which works for both fields and maps + */ + async function get(key: Field, valueType: Actionable) { + // get onchain merkle root + let stateRoot = contract().offchainState.getAndRequireEquals().root; + + // witness the actual value + const optionType = Option(valueType); + let value = await Provable.witnessAsync(optionType, async () => { + let { valueMap } = await merkleMaps(); + let valueFields = valueMap.get(key.toBigInt()); + if (valueFields === undefined) { + return optionType.none(); + } + let value = fromActionWithoutHashes(valueType, valueFields); + return optionType.from(value); + }); + + // witness a merkle witness + let witness = await Provable.witnessAsync(MerkleWitness256, async () => { + let { merkleMap } = await merkleMaps(); + return new MerkleWitness256(merkleMap.getWitness(key.toBigInt())); + }); + + // anchor the value against the onchain root and passed in key + // we also allow the value to be missing, in which case the map must contain the 0 element + let valueHash = Provable.if( + value.isSome, + Poseidon.hashPacked(valueType, value.value), + Field(0) + ); + let actualKey = witness.calculateIndex(); + let actualRoot = witness.calculateRoot(valueHash); + key.assertEquals(actualKey, 'key mismatch'); + stateRoot.assertEquals(actualRoot, 'root mismatch'); + + return value; + } + + function field( + index: number, + type: Actionable + ): OffchainField { + const prefix = Field(index); + + return { + set(value) { + // serialize into action + let action = toAction( + prefix, + undefined, + type, + undefined, + type.fromValue(value) + ); + + // push action on account update + let update = contract().self; + update.body.actions = Actions.pushEvent(update.body.actions, action); + }, + update: notImplemented, + 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()); + }, + }; + } + + function map( + index: number, + keyType: Actionable, + valueType: Actionable + ): OffchainMap { + const prefix = Field(index); + + return { + set(key, value) { + // serialize into action + let action = toAction( + prefix, + keyType, + valueType, + key, + valueType.fromValue(value) + ); + + // push action on account update + let update = contract().self; + update.body.actions = Actions.pushEvent(update.body.actions, action); + }, + update: notImplemented, + async get(key) { + let keyHash = toKeyHash(prefix, keyType, key); + return await get(keyHash, valueType); + }, + }; + } + + return { + setContractInstance(contract) { + internal._contract = contract; + }, + + async compile() { + await rollup.compile(); + }, + + async createSettlementProof() { + let { merkleMap } = await merkleMaps(); + + // fetch pending actions + let actionState = await onchainActionState(); + let actions = await fetchMerkleLeaves(internal.contract, { + fromActionState: actionState, + }); + + let result = await rollup.prove(merkleMap, actions); + + // update internal merkle maps as well + // TODO make this not insanely recompute everything + // - take new tree from `result` + // - update value map in `prove()`, or separately based on `actions` + let { merkleMap: newMerkleMap, valueMap: newValueMap } = + await fetchMerkleMap(internal.contract); + internal._merkleMap = newMerkleMap; + internal._valueMap = newValueMap; + + return result.proof; + }, + + Proof: rollup.Proof, + + async settle(proof) { + // verify the proof + proof.verify(); + + // check that proof moves state forward from the one currently stored + let state = contract().offchainState.getAndRequireEquals(); + Provable.assertEqual(OffchainStateCommitments, state, proof.publicInput); + + // require that proof uses the correct pending actions + contract().account.actionState.requireEquals( + proof.publicOutput.actionState + ); + + // update the state + contract().offchainState.set(proof.publicOutput); + }, + + fields: Object.fromEntries( + Object.entries(config).map(([key, kind], i) => [ + key, + kind.kind === 'offchain-field' + ? field(i, kind.type) + : map(i, kind.keyType, kind.valueType), + ]) + ) as any, + }; +} + +OffchainState.Map = OffchainMap; +OffchainState.Field = OffchainField; + +// type helpers + +type Any = Actionable; + +function OffchainField(type: T) { + return { kind: 'offchain-field' as const, type }; +} +type OffchainField = { + /** + * Get the value of the field. + */ + 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. + * If no previous value is present, the `from` value is ignored and the update applied unconditionally. + */ + update(update: { from: T | TValue; to: T | TValue }): void; +}; + +function OffchainMap(key: K, value: V) { + return { kind: 'offchain-map' as const, keyType: key, valueType: value }; +} +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. + * + * 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. + */ + update(key: K, update: { from: V | VValue; to: V | VValue }): void; +}; + +type OffchainStateKind = + | { kind: 'offchain-field'; type: Any } + | { kind: 'offchain-map'; keyType: Any; valueType: Any }; + +type OffchainStateIntf = Kind extends { + kind: 'offchain-field'; + type: infer T; +} + ? OffchainField, InferValue> + : Kind extends { + kind: 'offchain-map'; + keyType: infer K; + valueType: infer V; + } + ? OffchainMap, InferProvable, InferValue> + : never; diff --git a/src/lib/mina/actions/reducer.ts b/src/lib/mina/actions/reducer.ts new file mode 100644 index 000000000..8be11506f --- /dev/null +++ b/src/lib/mina/actions/reducer.ts @@ -0,0 +1,317 @@ +import { Field } from '../../provable/wrapped.js'; +import { Actions } from '../account-update.js'; +import { + FlexibleProvablePure, + InferProvable, +} from '../../provable/types/struct.js'; +import { provable } from '../../provable/types/provable-derivers.js'; +import { Provable } from '../../provable/provable.js'; +import { ProvableHashable } from '../../provable/crypto/poseidon.js'; +import * as Mina from '../mina.js'; +import { ProvablePure } from '../../provable/types/provable-intf.js'; +import { MerkleList } from '../../provable/merkle-list.js'; +import type { SmartContract } from '../zkapp.js'; + +export { Reducer, getReducer }; + +const Reducer: (< + T extends FlexibleProvablePure, + A extends InferProvable = InferProvable +>(reducer: { + actionType: T; +}) => ReducerReturn) & { + initialActionState: Field; +} = Object.defineProperty( + function (reducer: any) { + // we lie about the return value here, and instead overwrite this.reducer with + // a getter, so we can get access to `this` inside functions on this.reducer (see constructor) + return reducer; + }, + 'initialActionState', + { get: Actions.emptyActionState } +) as any; + +type Reducer = { + actionType: FlexibleProvablePure; +}; + +type ReducerReturn = { + /** + * Dispatches an {@link Action}. Similar to normal {@link Event}s, + * {@link Action}s can be stored by archive nodes and later reduced within a {@link SmartContract} method + * to change the state of the contract accordingly + * + * ```ts + * this.reducer.dispatch(Field(1)); // emits one action + * ``` + * + * */ + dispatch(action: Action): void; + /** + * Reduces a list of {@link Action}s, similar to `Array.reduce()`. + * + * ```ts + * let pendingActions = this.reducer.getActions({ + * fromActionState: actionState, + * }); + * + * let newState = this.reducer.reduce( + * pendingActions, + * Field, // the state type + * (state: Field, _action: Field) => { + * return state.add(1); + * }, + * initialState // initial state + * ); + * ``` + * + */ + reduce( + actions: MerkleList>, + stateType: Provable, + reduce: (state: State, action: Action) => State, + initial: State, + options?: { + maxUpdatesWithActions?: number; + maxActionsPerUpdate?: number; + skipActionStatePrecondition?: boolean; + } + ): State; + /** + * Perform circuit logic for every {@link Action} in the list. + * + * This is a wrapper around {@link reduce} for when you don't need `state`. + */ + forEach( + actions: MerkleList>, + reduce: (action: Action) => void, + options?: { + maxUpdatesWithActions?: number; + maxActionsPerUpdate?: number; + skipActionStatePrecondition?: boolean; + } + ): void; + /** + * Fetches the list of previously emitted {@link Action}s by this {@link SmartContract}. + * ```ts + * let pendingActions = this.reducer.getActions({ + * fromActionState: actionState, + * }); + * ``` + * + * The final action state can be accessed on `pendingActions.hash`. + * ```ts + * let endActionState = pendingActions.hash; + * ``` + * + * If the optional `endActionState` is provided, the list of actions will be fetched up to that state. + * In that case, `pendingActions.hash` is guaranteed to equal `endActionState`. + */ + getActions({ + fromActionState, + endActionState, + }?: { + fromActionState?: Field; + endActionState?: Field; + }): MerkleList>; + /** + * Fetches the list of previously emitted {@link Action}s by zkapp {@link SmartContract}. + * ```ts + * let pendingActions = await zkapp.reducer.fetchActions({ + * fromActionState: actionState, + * }); + * ``` + */ + fetchActions({ + fromActionState, + endActionState, + }?: { + fromActionState?: Field; + endActionState?: Field; + }): Promise; +}; + +function getReducer(contract: SmartContract): ReducerReturn { + let reducer: Reducer = ((contract as any)._ ??= {}).reducer; + if (reducer === undefined) + throw Error( + 'You are trying to use a reducer without having declared its type.\n' + + `Make sure to add a property \`reducer\` on ${contract.constructor.name}, for example: +class ${contract.constructor.name} extends SmartContract { + reducer = Reducer({ actionType: Field }); +}` + ); + return { + dispatch(action: A) { + let accountUpdate = contract.self; + let eventFields = reducer.actionType.toFields(action); + accountUpdate.body.actions = Actions.pushEvent( + accountUpdate.body.actions, + eventFields + ); + }, + + reduce( + actionLists: MerkleList>, + stateType: Provable, + reduce: (state: S, action: A) => S, + state: S, + { + maxUpdatesWithActions = 32, + maxActionsPerUpdate = 1, + skipActionStatePrecondition = false, + } = {} + ): S { + Provable.asProver(() => { + if (actionLists.data.get().length > maxUpdatesWithActions) { + throw Error( + `reducer.reduce: Exceeded the maximum number of lists of actions, ${maxUpdatesWithActions}. + Use the optional \`maxUpdatesWithActions\` argument to increase this number.` + ); + } + }); + + if (!skipActionStatePrecondition) { + // the actionList.hash is the hash of all actions in that list, appended to the previous hash (the previous list of historical actions) + // this must equal one of the action states as preconditions to build a chain to that we only use actions that were dispatched between the current on chain action state and the initialActionState + contract.account.actionState.requireEquals(actionLists.hash); + } + + const listIter = actionLists.startIterating(); + + for (let i = 0; i < maxUpdatesWithActions; i++) { + let { element: merkleActions, isDummy } = listIter.Unsafe.next(); + let actionIter = merkleActions.startIterating(); + let newState = state; + + if (maxActionsPerUpdate === 1) { + // special case with less work, because the only action is a dummy iff merkleActions is a dummy + let action = Provable.witness( + reducer.actionType, + () => + actionIter.data.get()[0]?.element ?? + actionIter.innerProvable.empty() + ); + let emptyHash = actionIter.Constructor.emptyHash; + let finalHash = actionIter.nextHash(emptyHash, action); + finalHash = Provable.if(isDummy, emptyHash, finalHash); + + // note: this asserts nothing in the isDummy case, because `actionIter.hash` is not well-defined + // but it doesn't matter because we're also skipping all state and action state updates in that case + actionIter.hash.assertEquals(finalHash); + + newState = reduce(newState, action); + } else { + for (let j = 0; j < maxActionsPerUpdate; j++) { + let { element: action, isDummy } = actionIter.Unsafe.next(); + newState = Provable.if( + isDummy, + stateType, + newState, + reduce(newState, action) + ); + } + // note: this asserts nothing about the iterated actions if `MerkleActions` is a dummy + // which doesn't matter because we're also skipping all state and action state updates in that case + actionIter.assertAtEnd(); + } + + state = Provable.if(isDummy, stateType, state, newState); + } + + // important: we check that by iterating, we actually reached the claimed final action state + listIter.assertAtEnd(); + + return state; + }, + + forEach( + actionLists: MerkleList>, + callback: (action: A) => void, + config + ) { + const stateType = provable(null); + this.reduce( + actionLists, + stateType, + (_, action) => { + callback(action); + return null; + }, + null, + config + ); + }, + + getActions(config?: { + fromActionState?: Field; + endActionState?: Field; + }): MerkleList> { + const Action = reducer.actionType; + const emptyHash = Actions.empty().hash; + const nextHash = (hash: Field, action: A) => + Actions.pushEvent({ hash, data: [] }, Action.toFields(action)).hash; + + class ActionList extends MerkleList.create( + Action as unknown as ProvableHashable, + nextHash, + emptyHash + ) {} + + class MerkleActions extends MerkleList.create( + ActionList.provable, + (hash: Field, actions: ActionList) => + Actions.updateSequenceState(hash, actions.hash), + // if no "start" action hash was specified, this means we are fetching the entire history of actions, which started from the empty action state hash + // otherwise we are only fetching a part of the history, which starts at `fromActionState` + // TODO does this show that `emptyHash` should be part of the instance, not the class? that would make the provable representation bigger though + config?.fromActionState ?? Actions.emptyActionState() + ) {} + + let actions = Provable.witness(MerkleActions.provable, () => { + let actionFields = Mina.getActions( + contract.address, + config, + contract.tokenId + ); + // convert string-Fields back into the original action type + let actions = actionFields.map((event) => + event.actions.map((action) => + (reducer.actionType as ProvablePure).fromFields( + action.map(Field) + ) + ) + ); + return MerkleActions.from( + actions.map((a) => ActionList.fromReverse(a)) + ); + }); + // note that we don't have to assert anything about the initial action state here, + // because it is taken directly and not witnessed + if (config?.endActionState !== undefined) { + actions.hash.assertEquals(config.endActionState); + } + return actions; + }, + + async fetchActions(config?: { + fromActionState?: Field; + endActionState?: Field; + }): Promise { + let result = await Mina.fetchActions( + contract.address, + config, + contract.tokenId + ); + if ('error' in result) { + throw Error(JSON.stringify(result)); + } + return result.map((event) => + // putting our string-Fields back into the original action type + event.actions.map((action) => + (reducer.actionType as ProvablePure).fromFields(action.map(Field)) + ) + ); + }, + }; +} diff --git a/src/lib/mina/state.ts b/src/lib/mina/state.ts index 2501c6dd3..d6c96980e 100644 --- a/src/lib/mina/state.ts +++ b/src/lib/mina/state.ts @@ -12,7 +12,12 @@ import { ProvablePure } from '../provable/types/provable-intf.js'; // external API export { State, state, declareState }; // internal API -export { assertStatePrecondition, cleanStatePrecondition }; +export { + assertStatePrecondition, + cleanStatePrecondition, + getLayout, + InternalStateType, +}; /** * Gettable and settable state that can be checked for equality. @@ -70,8 +75,8 @@ type State = { */ fromAppState(appState: Field[]): A; }; -function State(): State { - return createState(); +function State(defaultValue?: A): State { + return createState(defaultValue); } /** @@ -162,7 +167,7 @@ function state(stateType: FlexibleProvablePure) { */ function declareState( SmartContract: T, - states: Record> + states: Record> ) { for (let key in states) { let CircuitValue = states[key]; @@ -181,11 +186,15 @@ type StateAttachedContract = { cachedVariable?: A; }; -type InternalStateType = State & { _contract?: StateAttachedContract }; +type InternalStateType = State & { + _contract?: StateAttachedContract; + defaultValue?: A; +}; -function createState(): InternalStateType { +function createState(defaultValue?: T): InternalStateType { return { _contract: undefined as StateAttachedContract | undefined, + defaultValue, set(state: T) { if (this._contract === undefined) @@ -363,7 +372,7 @@ function getLayoutPosition({ function getLayout(scClass: typeof SmartContract) { let sc = smartContracts.get(scClass); - if (sc === undefined) throw Error('bug'); + if (sc === undefined) return new Map(); if (sc.layout === undefined) { let layout = new Map(); sc.layout = layout; diff --git a/src/lib/mina/zkapp.ts b/src/lib/mina/zkapp.ts index 786b81a17..c4ecefb14 100644 --- a/src/lib/mina/zkapp.ts +++ b/src/lib/mina/zkapp.ts @@ -7,7 +7,6 @@ import { Body, Events, Permissions, - Actions, TokenId, ZkappCommand, zkAppProver, @@ -20,8 +19,6 @@ import { import { cloneCircuitValue, FlexibleProvablePure, - InferProvable, - provable, } from '../provable/types/struct.js'; import { Provable, @@ -32,7 +29,6 @@ import * as Encoding from '../../bindings/lib/encoding.js'; import { HashInput, Poseidon, - ProvableHashable, hashConstant, isHashable, packToFields, @@ -47,7 +43,6 @@ import { analyzeMethod, compileProgram, Empty, - emptyValue, getPreviousProofsForProver, methodArgumentsToConstant, methodArgumentTypesAndValues, @@ -56,7 +51,12 @@ import { sortMethodArguments, } from '../proof-system/zkprogram.js'; import { PublicKey } from '../provable/crypto/signature.js'; -import { assertStatePrecondition, cleanStatePrecondition } from './state.js'; +import { + InternalStateType, + assertStatePrecondition, + cleanStatePrecondition, + getLayout, +} from './state.js'; import { inAnalyze, inCheckedComputation, @@ -74,10 +74,11 @@ import { } from './smart-contract-context.js'; import { assertPromise } from '../util/assert.js'; import { ProvablePure } from '../provable/types/provable-intf.js'; -import { MerkleList } from '../provable/merkle-list.js'; +import { getReducer, Reducer } from './actions/reducer.js'; +import { provable } from '../provable/types/provable-derivers.js'; // external API -export { SmartContract, method, DeployArgs, declareMethods, Reducer }; +export { SmartContract, method, DeployArgs, declareMethods }; const reservedPropNames = new Set(['_methods', '_']); type AsyncFunction = (...args: any) => Promise; @@ -770,9 +771,21 @@ super.init(); // let accountUpdate = this.newSelf(); // this would emulate the behaviour of init() being a @method this.account.provedState.requireEquals(Bool(false)); let accountUpdate = this.self; + + // set all state fields to 0 for (let i = 0; i < ZkappStateLength; i++) { AccountUpdate.setValue(accountUpdate.body.update.appState[i], Field(0)); } + + // for all explicitly declared states, set them to their default value + let stateKeys = getLayout(this.constructor as typeof SmartContract).keys(); + for (let key of stateKeys) { + let state = this[key as keyof this] as InternalStateType | undefined; + if (state !== undefined && state.defaultValue !== undefined) { + state.set(state.defaultValue); + } + } + AccountUpdate.attachToTransaction(accountUpdate); } @@ -1162,291 +1175,6 @@ super.init(); } } -type Reducer = { - actionType: FlexibleProvablePure; -}; - -type ReducerReturn = { - /** - * Dispatches an {@link Action}. Similar to normal {@link Event}s, - * {@link Action}s can be stored by archive nodes and later reduced within a {@link SmartContract} method - * to change the state of the contract accordingly - * - * ```ts - * this.reducer.dispatch(Field(1)); // emits one action - * ``` - * - * */ - dispatch(action: Action): void; - /** - * Reduces a list of {@link Action}s, similar to `Array.reduce()`. - * - * ```ts - * let pendingActions = this.reducer.getActions({ - * fromActionState: actionState, - * }); - * - * let newState = this.reducer.reduce( - * pendingActions, - * Field, // the state type - * (state: Field, _action: Field) => { - * return state.add(1); - * }, - * initialState // initial state - * ); - * ``` - * - */ - reduce( - actions: MerkleList>, - stateType: Provable, - reduce: (state: State, action: Action) => State, - initial: State, - options?: { - maxUpdatesWithActions?: number; - maxActionsPerUpdate?: number; - skipActionStatePrecondition?: boolean; - } - ): State; - /** - * Perform circuit logic for every {@link Action} in the list. - * - * This is a wrapper around {@link reduce} for when you don't need `state`. - */ - forEach( - actions: MerkleList>, - reduce: (action: Action) => void, - options?: { - maxUpdatesWithActions?: number; - maxActionsPerUpdate?: number; - skipActionStatePrecondition?: boolean; - } - ): void; - /** - * Fetches the list of previously emitted {@link Action}s by this {@link SmartContract}. - * ```ts - * let pendingActions = this.reducer.getActions({ - * fromActionState: actionState, - * }); - * ``` - * - * The final action state can be accessed on `pendingActions.hash`. - * ```ts - * let endActionState = pendingActions.hash; - * ``` - * - * If the optional `endActionState` is provided, the list of actions will be fetched up to that state. - * In that case, `pendingActions.hash` is guaranteed to equal `endActionState`. - */ - getActions({ - fromActionState, - endActionState, - }?: { - fromActionState?: Field; - endActionState?: Field; - }): MerkleList>; - /** - * Fetches the list of previously emitted {@link Action}s by zkapp {@link SmartContract}. - * ```ts - * let pendingActions = await zkapp.reducer.fetchActions({ - * fromActionState: actionState, - * }); - * ``` - */ - fetchActions({ - fromActionState, - endActionState, - }?: { - fromActionState?: Field; - endActionState?: Field; - }): Promise; -}; - -function getReducer(contract: SmartContract): ReducerReturn { - let reducer: Reducer = ((contract as any)._ ??= {}).reducer; - if (reducer === undefined) - throw Error( - 'You are trying to use a reducer without having declared its type.\n' + - `Make sure to add a property \`reducer\` on ${contract.constructor.name}, for example: -class ${contract.constructor.name} extends SmartContract { - reducer = { actionType: Field }; -}` - ); - return { - dispatch(action: A) { - let accountUpdate = contract.self; - let eventFields = reducer.actionType.toFields(action); - accountUpdate.body.actions = Actions.pushEvent( - accountUpdate.body.actions, - eventFields - ); - }, - - reduce( - actionLists: MerkleList>, - stateType: Provable, - reduce: (state: S, action: A) => S, - state: S, - { - maxUpdatesWithActions = 32, - maxActionsPerUpdate = 1, - skipActionStatePrecondition = false, - } = {} - ): S { - Provable.asProver(() => { - if (actionLists.data.get().length > maxUpdatesWithActions) { - throw Error( - `reducer.reduce: Exceeded the maximum number of lists of actions, ${maxUpdatesWithActions}. - Use the optional \`maxUpdatesWithActions\` argument to increase this number.` - ); - } - }); - - if (!skipActionStatePrecondition) { - // the actionList.hash is the hash of all actions in that list, appended to the previous hash (the previous list of historical actions) - // this must equal one of the action states as preconditions to build a chain to that we only use actions that were dispatched between the current on chain action state and the initialActionState - contract.account.actionState.requireEquals(actionLists.hash); - } - - const listIter = actionLists.startIterating(); - - for (let i = 0; i < maxUpdatesWithActions; i++) { - let { element: merkleActions, isDummy } = listIter.Unsafe.next(); - let actionIter = merkleActions.startIterating(); - let newState = state; - - if (maxActionsPerUpdate === 1) { - // special case with less work, because the only action is a dummy iff merkleActions is a dummy - let action = Provable.witness( - reducer.actionType, - () => - actionIter.data.get()[0]?.element ?? - actionIter.innerProvable.empty() - ); - let emptyHash = actionIter.Constructor.emptyHash; - let finalHash = actionIter.nextHash(emptyHash, action); - finalHash = Provable.if(isDummy, emptyHash, finalHash); - - // note: this asserts nothing in the isDummy case, because `actionIter.hash` is not well-defined - // but it doesn't matter because we're also skipping all state and action state updates in that case - actionIter.hash.assertEquals(finalHash); - - newState = reduce(newState, action); - } else { - for (let j = 0; j < maxActionsPerUpdate; j++) { - let { element: action, isDummy } = actionIter.Unsafe.next(); - newState = Provable.if( - isDummy, - stateType, - newState, - reduce(newState, action) - ); - } - // note: this asserts nothing about the iterated actions if `MerkleActions` is a dummy - // which doesn't matter because we're also skipping all state and action state updates in that case - actionIter.assertAtEnd(); - } - - state = Provable.if(isDummy, stateType, state, newState); - } - - // important: we check that by iterating, we actually reached the claimed final action state - listIter.assertAtEnd(); - - return state; - }, - - forEach( - actionLists: MerkleList>, - callback: (action: A) => void, - config - ) { - const stateType = provable(null); - this.reduce( - actionLists, - stateType, - (_, action) => { - callback(action); - return null; - }, - null, - config - ); - }, - - getActions(config?: { - fromActionState?: Field; - endActionState?: Field; - }): MerkleList> { - const Action = reducer.actionType; - const emptyHash = Actions.empty().hash; - const nextHash = (hash: Field, action: A) => - Actions.pushEvent({ hash, data: [] }, Action.toFields(action)).hash; - - class ActionList extends MerkleList.create( - Action as unknown as ProvableHashable, - nextHash, - emptyHash - ) {} - - class MerkleActions extends MerkleList.create( - ActionList.provable, - (hash: Field, actions: ActionList) => - Actions.updateSequenceState(hash, actions.hash), - // if no "start" action hash was specified, this means we are fetching the entire history of actions, which started from the empty action state hash - // otherwise we are only fetching a part of the history, which starts at `fromActionState` - // TODO does this show that `emptyHash` should be part of the instance, not the class? that would make the provable representation bigger though - config?.fromActionState ?? Actions.emptyActionState() - ) {} - - let actions = Provable.witness(MerkleActions.provable, () => { - let actionFields = Mina.getActions( - contract.address, - config, - contract.tokenId - ); - // convert string-Fields back into the original action type - let actions = actionFields.map((event) => - event.actions.map((action) => - (reducer.actionType as ProvablePure).fromFields( - action.map(Field) - ) - ) - ); - return MerkleActions.from( - actions.map((a) => ActionList.fromReverse(a)) - ); - }); - // note that we don't have to assert anything about the initial action state here, - // because it is taken directly and not witnessed - if (config?.endActionState !== undefined) { - actions.hash.assertEquals(config.endActionState); - } - return actions; - }, - - async fetchActions(config?: { - fromActionState?: Field; - endActionState?: Field; - }): Promise { - let result = await Mina.fetchActions( - contract.address, - config, - contract.tokenId - ); - if ('error' in result) { - throw Error(JSON.stringify(result)); - } - return result.map((event) => - // putting our string-Fields back into the original action type - event.actions.map((action) => - (reducer.actionType as ProvablePure).fromFields(action.map(Field)) - ) - ); - }, - }; -} - function selfAccountUpdate(zkapp: SmartContract, methodName?: string) { let body = Body.keepAll(zkapp.address, zkapp.tokenId); let update = new (AccountUpdate as any)(body, {}, true) as AccountUpdate; @@ -1510,23 +1238,6 @@ function declareMethods( } } -const Reducer: (< - T extends FlexibleProvablePure, - A extends InferProvable = InferProvable ->(reducer: { - actionType: T; -}) => ReducerReturn) & { - initialActionState: Field; -} = Object.defineProperty( - function (reducer: any) { - // we lie about the return value here, and instead overwrite this.reducer with - // a getter, so we can get access to `this` inside functions on this.reducer (see constructor) - return reducer; - }, - 'initialActionState', - { get: Actions.emptyActionState } -) as any; - const ProofAuthorization = { setKind( { body, id }: AccountUpdate, diff --git a/src/lib/proof-system/zkprogram.ts b/src/lib/proof-system/zkprogram.ts index d834e38d8..0e00f61d1 100644 --- a/src/lib/proof-system/zkprogram.ts +++ b/src/lib/proof-system/zkprogram.ts @@ -18,9 +18,8 @@ import { InferProvable, ProvablePureExtended, Struct, - provable, - provablePure, } from '../provable/types/struct.js'; +import { provable, provablePure } from '../provable/types/provable-derivers.js'; import { Provable } from '../provable/provable.js'; import { assert, prettifyStacktracePromise } from '../util/errors.js'; import { snarkContext } from '../provable/core/provable-context.js'; diff --git a/src/lib/provable/crypto/poseidon.ts b/src/lib/provable/crypto/poseidon.ts index 49a514282..c3425f0f6 100644 --- a/src/lib/provable/crypto/poseidon.ts +++ b/src/lib/provable/crypto/poseidon.ts @@ -28,7 +28,7 @@ export { }; type Hashable = { toInput: (x: T) => HashInput; empty: () => T }; -type ProvableHashable = Provable & Hashable; +type ProvableHashable = Provable & Hashable; class Sponge { #sponge: unknown; diff --git a/src/lib/provable/gadgets/elliptic-curve.ts b/src/lib/provable/gadgets/elliptic-curve.ts index 5265cbff4..c8db5b8e0 100644 --- a/src/lib/provable/gadgets/elliptic-curve.ts +++ b/src/lib/provable/gadgets/elliptic-curve.ts @@ -15,7 +15,7 @@ import { affineDouble, } from '../../../bindings/crypto/elliptic-curve.js'; import { Bool } from '../bool.js'; -import { provable } from '../types/struct.js'; +import { provable } from '../types/provable-derivers.js'; import { assertPositiveInteger } from '../../../bindings/crypto/non-negative.js'; import { arrayGet, assertNotVectorEquals } from './basic.js'; import { sliceField3 } from './bit-slices.js'; diff --git a/src/lib/provable/merkle-list.ts b/src/lib/provable/merkle-list.ts index 687157368..bb1173837 100644 --- a/src/lib/provable/merkle-list.ts +++ b/src/lib/provable/merkle-list.ts @@ -191,6 +191,27 @@ class MerkleList implements MerkleListBase { return new this.Constructor({ hash: this.hash, data }); } + /** + * Iterate through the list in a fixed number of steps any apply a given callback on each element. + * + * Proves that the iteration traverses the entire list. + * Once past the last element, dummy elements will be passed to the callback. + * + * Note: There are no guarantees about the contents of dummy elements, so the callback is expected + * to handle the `isDummy` flag separately. + */ + forEach( + length: number, + callback: (element: T, isDummy: Bool, i: number) => void + ) { + let iter = this.startIterating(); + for (let i = 0; i < length; i++) { + let { element, isDummy } = iter.Unsafe.next(); + callback(element, isDummy, i); + } + iter.assertAtEnd(); + } + startIterating(): MerkleListIterator { let merkleArray = MerkleListIterator.createFromList(this.Constructor); return merkleArray.startIterating(this); diff --git a/src/lib/provable/merkle-tree.ts b/src/lib/provable/merkle-tree.ts index d818d9af6..090d24447 100644 --- a/src/lib/provable/merkle-tree.ts +++ b/src/lib/provable/merkle-tree.ts @@ -42,6 +42,17 @@ class MerkleTree { } } + /** + * Return a new MerkleTree with the same contents as this one. + */ + clone() { + let newTree = new MerkleTree(this.height); + for (let [level, nodes] of Object.entries(this.nodes)) { + newTree.nodes[level as any as number] = { ...nodes }; + } + return newTree; + } + /** * Returns a node which lives at a given index and level. * @param level Level of the node. @@ -52,6 +63,15 @@ class MerkleTree { return this.nodes[level]?.[index.toString()] ?? this.zeroes[level]; } + /** + * Returns a leaf at a given index. + * @param index Index of the leaf. + * @returns The data of the leaf. + */ + getLeaf(key: bigint) { + return this.getNode(0, key); + } + /** * Returns the root of the [Merkle Tree](https://en.wikipedia.org/wiki/Merkle_tree). * @returns The root of the Merkle Tree. @@ -149,7 +169,7 @@ class MerkleTree { } /** - * The {@link BaseMerkleWitness} class defines a circuit-compatible base class for [Merkle Witness'](https://computersciencewiki.org/index.php/Merkle_proof). + * The {@link BaseMerkleWitness} class defines a circuit-compatible base class for [Merkle Witness](https://computersciencewiki.org/index.php/Merkle_proof). */ class BaseMerkleWitness extends CircuitValue { static height: number; diff --git a/src/lib/provable/option.ts b/src/lib/provable/option.ts new file mode 100644 index 000000000..e214f99ae --- /dev/null +++ b/src/lib/provable/option.ts @@ -0,0 +1,71 @@ +import { emptyValue } from '../proof-system/zkprogram.js'; +import { Provable } from './provable.js'; +import { Struct } from './types/struct.js'; +import { Bool } from './wrapped.js'; + +export { Option }; + +type Option = { isSome: Bool; value: T } & { + assertSome(message?: string): T; + orElse(defaultValue: T | V): T; +}; + +/** + * Define an optional version of a provable type. + * + * @example + * ```ts + * class OptionUInt64 extends Option(UInt64) {} + * + * // create an optional UInt64 + * let some = OptionUInt64.from(5n); + * let none = OptionUInt64.none(); + * + * // get back a UInt64 + * let five: UInt64 = some.assertSome('must have a value'); + * let zero: UInt64 = none.orElse(0n); // specify a default value + * ``` + */ +function Option( + type: Provable +): Provable< + Option, + // TODO make V | undefined the value type + { isSome: boolean; value: V } +> & { + from(value?: T): Option; + none(): Option; +} { + const Super = Struct({ isSome: Bool, value: type }); + return class Option_ extends Super { + orElse(defaultValue: T | V): T { + return Provable.if( + this.isSome, + type, + this.value, + type.fromValue(defaultValue) + ); + } + + assertSome(message?: string): T { + this.isSome.assertTrue(message); + return this.value; + } + + 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) }); + } + static none() { + return Option_.from(undefined); + } + + static fromFields(fields: any[], aux?: any): Option_ { + return new Option_(Super.fromFields(fields, aux)); + } + static fromValue(value: { isSome: boolean | Bool; value: V | T }) { + return new Option_(Super.fromValue(value)); + } + }; +} diff --git a/src/lib/provable/string.ts b/src/lib/provable/string.ts index 87c085573..2a52e33cd 100644 --- a/src/lib/provable/string.ts +++ b/src/lib/provable/string.ts @@ -2,7 +2,8 @@ import { Bool, Field } from './wrapped.js'; import { Provable } from './provable.js'; import { Poseidon } from './crypto/poseidon.js'; import { Gadgets } from './gadgets/gadgets.js'; -import { Struct, provable } from './types/struct.js'; +import { Struct } from './types/struct.js'; +import { provable } from './types/provable-derivers.js'; export { Character, CircuitString }; diff --git a/src/lib/provable/test/arithmetic.unit-test.ts b/src/lib/provable/test/arithmetic.unit-test.ts index 579dce823..ec02667fc 100644 --- a/src/lib/provable/test/arithmetic.unit-test.ts +++ b/src/lib/provable/test/arithmetic.unit-test.ts @@ -7,7 +7,7 @@ import { } from '../../testing/equivalent.js'; import { Field } from '../wrapped.js'; import { Gadgets } from '../gadgets/gadgets.js'; -import { provable } from '../types/struct.js'; +import { provable } from '../types/provable-derivers.js'; import { assert } from '../gadgets/common.js'; let Arithmetic = ZkProgram({ diff --git a/src/lib/provable/test/struct.unit-test.ts b/src/lib/provable/test/struct.unit-test.ts index ab48c0ddd..5fae21d65 100644 --- a/src/lib/provable/test/struct.unit-test.ts +++ b/src/lib/provable/test/struct.unit-test.ts @@ -1,4 +1,5 @@ -import { provable, Struct } from '../types/struct.js'; +import { Struct } from '../types/struct.js'; +import { provable } from '../types/provable-derivers.js'; import { Unconstrained } from '../types/unconstrained.js'; import { UInt32 } from '../int.js'; import { PrivateKey, PublicKey } from '../crypto/signature.js'; diff --git a/src/lib/provable/test/test-utils.ts b/src/lib/provable/test/test-utils.ts index 208fdccc6..6ff853533 100644 --- a/src/lib/provable/test/test-utils.ts +++ b/src/lib/provable/test/test-utils.ts @@ -6,7 +6,7 @@ import { assert } from '../gadgets/common.js'; import { Bytes } from '../wrapped-classes.js'; import { CurveAffine } from '../../../bindings/crypto/elliptic-curve.js'; import { simpleMapToCurve } from '../gadgets/elliptic-curve.js'; -import { provable } from '../types/struct.js'; +import { provable } from '../types/provable-derivers.js'; export { foreignField, diff --git a/src/lib/provable/types/struct.ts b/src/lib/provable/types/struct.ts index 9cce8f932..587b2efa3 100644 --- a/src/lib/provable/types/struct.ts +++ b/src/lib/provable/types/struct.ts @@ -21,8 +21,6 @@ import { From, InferValue } from '../../../bindings/lib/provable-generic.js'; export { ProvableExtended, ProvablePureExtended, - provable, - provablePure, Struct, FlexibleProvable, FlexibleProvablePure, @@ -295,6 +293,9 @@ function cloneCircuitValue(obj: T): T { if (obj.constructor !== undefined && 'clone' in obj.constructor) { return (obj as any).constructor.clone(obj); } + if ('clone' in obj && typeof obj.clone === 'function') { + return (obj as any).clone(obj); + } // built-in JS datatypes with custom cloning strategies if (Array.isArray(obj)) return obj.map(cloneCircuitValue) as any as T; diff --git a/src/lib/provable/types/unconstrained.ts b/src/lib/provable/types/unconstrained.ts index 89fb648b8..03782310a 100644 --- a/src/lib/provable/types/unconstrained.ts +++ b/src/lib/provable/types/unconstrained.ts @@ -115,6 +115,7 @@ and Provable.asProver() blocks, which execute outside the proof. fields?: Field[]; packed?: [Field, number][]; }; + empty: () => Unconstrained; } = { sizeInFields: () => 0, toFields: () => [], @@ -124,5 +125,21 @@ and Provable.asProver() blocks, which execute outside the proof. toValue: (t) => t, fromValue: (t) => t, toInput: () => ({}), + empty: (): any => { + throw Error('There is no default empty value for Unconstrained.'); + }, }; + + static provableWithEmpty(empty: T): Provable> & { + toInput: (x: Unconstrained) => { + fields?: Field[]; + packed?: [Field, number][]; + }; + empty: () => Unconstrained; + } { + return { + ...Unconstrained.provable, + empty: () => Unconstrained.from(empty), + }; + } } diff --git a/src/lib/testing/equivalent.ts b/src/lib/testing/equivalent.ts index 745532b00..58eeefc9f 100644 --- a/src/lib/testing/equivalent.ts +++ b/src/lib/testing/equivalent.ts @@ -6,7 +6,7 @@ import { Provable } from '../provable/provable.js'; import { deepEqual } from 'node:assert/strict'; import { Bool, Field } from '../provable/wrapped.js'; import { AnyFunction, Tuple } from '../util/types.js'; -import { provable } from '../provable/types/struct.js'; +import { provable } from '../provable/types/provable-derivers.js'; import { assert } from '../provable/gadgets/common.js'; import { synchronousRunners } from '../provable/core/provable-context.js';