diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b99530dcb..cbb7280cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,33 @@ 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) + +### Fixes + +- Fix type inference for `method.returns(Type)`, to require a matching return signature https://github.com/o1-labs/o1js/pull/1653 +- Fix `Struct.empty()` returning a garbage object when one of the base types doesn't support `empty()` https://github.com/o1-labs/o1js/pull/1657 + +## [1.2.0](https://github.com/o1-labs/o1js/compare/4a17de857...6a1012162) - 2024-05-14 + +### Added + +- **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 +- `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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 850880db1c..46a6d9a97b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ To ensure consistency within the o1js ecosystem and ease review and use by our t - `npm install ` works and is all that is needed to use the package. - o1js must be listed as a [peer dependency](https://docs.npmjs.com/cli/v9/configuring-npm/package-json#peerdependencies). - If applicable, the package must work both on the web and in NodeJS. -- The package is created using the [zkApp CLI](https://github.com/o1-labs/zkapp-cli) (recommended). +- The package is created using the [zkApp CLI](https://www.npmjs.com/package/zkapp-cli) (recommended). If you did not create the package using the zkApp CLI, follow these guidelines for code consistency: - Use TypeScript, and export types from `d.ts` files. We suggest that you base your tsconfig on the [tsconfig.json](./tsconfig.json) that o1js uses. - Code must be auto-formatted with [prettier](https://prettier.io/). We encourage you to use [.prettierrc.cjs](./.prettierrc.cjs), the same prettier config as o1js. diff --git a/README.md b/README.md index 637a852691..20a280ceae 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ The easiest way to write zk programs is using o1js. o1js is a TypeScript library for [zk-SNARKs](https://minaprotocol.com/blog/what-are-zk-snarks) and zkApps. You can use o1js to write zk smart contracts based on zero-knowledge proofs for the Mina Protocol. -o1js is automatically included when you create a project using the [Mina zkApp CLI](https://github.com/o1-labs/zkapp-cli). +o1js is automatically included when you create a project using the [zkApp CLI](https://www.npmjs.com/package/zkapp-cli). ## Learn More 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": [ diff --git a/src/bindings b/src/bindings index 539c70c242..86bbd1ce75 160000 --- a/src/bindings +++ b/src/bindings @@ -1 +1 @@ -Subproject commit 539c70c242749f5d75e61548e47a7b72037bc216 +Subproject commit 86bbd1ce753b48a53ad5289c7e3d1912a3e8fc5c diff --git a/src/examples/simple-zkapp.ts b/src/examples/simple-zkapp.ts index 337b3a4762..349dfbd3d4 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/examples/zkapps/dex/erc20.ts b/src/examples/zkapps/dex/erc20.ts index 586b301faa..9438bb09b3 100644 --- a/src/examples/zkapps/dex/erc20.ts +++ b/src/examples/zkapps/dex/erc20.ts @@ -13,7 +13,6 @@ import { TokenContract, AccountUpdateForest, Struct, - TransactionVersion, } from 'o1js'; export { Erc20Like, TrivialCoin }; @@ -79,10 +78,8 @@ class TrivialCoin extends TokenContract implements Erc20Like { // make account non-upgradable forever this.account.permissions.set({ ...Permissions.default(), - setVerificationKey: { - auth: Permissions.impossible(), - txnVersion: TransactionVersion.current(), - }, + setVerificationKey: + Permissions.VerificationKey.impossibleDuringCurrentVersion(), setPermissions: Permissions.impossible(), access: Permissions.proofOrSignature(), }); diff --git a/src/examples/zkapps/dex/upgradability.ts b/src/examples/zkapps/dex/upgradability.ts index 4034f2e4a8..1e1f8e8560 100644 --- a/src/examples/zkapps/dex/upgradability.ts +++ b/src/examples/zkapps/dex/upgradability.ts @@ -1,12 +1,5 @@ import { expect } from 'expect'; -import { - AccountUpdate, - Mina, - Permissions, - PrivateKey, - UInt64, - TransactionVersion, -} from 'o1js'; +import { AccountUpdate, Mina, Permissions, PrivateKey, UInt64 } from 'o1js'; import { getProfiler } from '../../utils/profiler.js'; import { TokenContract, addresses, createDex, keys, tokenIds } from './dex.js'; @@ -446,10 +439,8 @@ async function upgradeabilityTests({ withVesting }: { withVesting: boolean }) { let update = AccountUpdate.createSigned(addresses.dex); update.account.permissions.set({ ...Permissions.initial(), - setVerificationKey: { - auth: Permissions.impossible(), - txnVersion: TransactionVersion.current(), - }, + setVerificationKey: + Permissions.VerificationKey.impossibleDuringCurrentVersion(), }); }); await tx.prove(); diff --git a/src/examples/zkapps/voting/dummy-contract.ts b/src/examples/zkapps/voting/dummy-contract.ts index 006cacc15a..87aaf9e05e 100644 --- a/src/examples/zkapps/voting/dummy-contract.ts +++ b/src/examples/zkapps/voting/dummy-contract.ts @@ -6,7 +6,6 @@ import { method, DeployArgs, Permissions, - TransactionVersion, } from 'o1js'; export class DummyContract extends SmartContract { @@ -19,10 +18,6 @@ export class DummyContract extends SmartContract { editState: Permissions.proofOrSignature(), editActionState: Permissions.proofOrSignature(), setPermissions: Permissions.proofOrSignature(), - setVerificationKey: { - auth: Permissions.signature(), - txnVersion: TransactionVersion.current(), - }, incrementNonce: Permissions.proofOrSignature(), }); this.sum.set(Field(0)); diff --git a/src/examples/zkapps/voting/membership.ts b/src/examples/zkapps/voting/membership.ts index 178d911826..3210fb39cb 100644 --- a/src/examples/zkapps/voting/membership.ts +++ b/src/examples/zkapps/voting/membership.ts @@ -11,7 +11,6 @@ import { provablePure, AccountUpdate, Provable, - TransactionVersion, } from 'o1js'; import { Member } from './member.js'; import { ParticipantPreconditions } from './preconditions.js'; @@ -76,10 +75,7 @@ export class Membership_ extends SmartContract { editState: Permissions.proofOrSignature(), editActionState: Permissions.proofOrSignature(), setPermissions: Permissions.proofOrSignature(), - setVerificationKey: { - auth: Permissions.proofOrSignature(), - txnVersion: TransactionVersion.current(), - }, + setVerificationKey: Permissions.VerificationKey.proofOrSignature(), incrementNonce: Permissions.proofOrSignature(), }); } diff --git a/src/examples/zkapps/voting/voting.ts b/src/examples/zkapps/voting/voting.ts index 3a90071bf4..f68157cdfd 100644 --- a/src/examples/zkapps/voting/voting.ts +++ b/src/examples/zkapps/voting/voting.ts @@ -11,7 +11,6 @@ import { provablePure, AccountUpdate, Provable, - TransactionVersion, } from 'o1js'; import { Member } from './member.js'; @@ -103,10 +102,7 @@ export class Voting_ extends SmartContract { editState: Permissions.proofOrSignature(), editActionState: Permissions.proofOrSignature(), incrementNonce: Permissions.proofOrSignature(), - setVerificationKey: { - auth: Permissions.none(), - txnVersion: TransactionVersion.current(), - }, + setVerificationKey: Permissions.VerificationKey.none(), setPermissions: Permissions.proofOrSignature(), }); this.accumulatedVotes.set(Reducer.initialActionState); diff --git a/src/examples/zkapps/zkapp-self-update.ts b/src/examples/zkapps/zkapp-self-update.ts index 53c27eb7bc..e0ef735e8a 100644 --- a/src/examples/zkapps/zkapp-self-update.ts +++ b/src/examples/zkapps/zkapp-self-update.ts @@ -9,7 +9,6 @@ import { Mina, AccountUpdate, Provable, - TransactionVersion, } from 'o1js'; class SelfUpdater extends SmartContract { @@ -17,10 +16,8 @@ class SelfUpdater extends SmartContract { super.init(); this.account.permissions.set({ ...Permissions.default(), - setVerificationKey: { - auth: Permissions.proof(), - txnVersion: TransactionVersion.current(), - }, + setVerificationKey: + Permissions.VerificationKey.proofDuringCurrentVersion(), }); } diff --git a/src/index.ts b/src/index.ts index 42c4614ef6..0252a528bd 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,16 +64,13 @@ 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'; export { + type ProofBase, Proof, DynamicProof, SelfProof, @@ -126,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_ = { @@ -138,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/account-update.ts b/src/lib/mina/account-update.ts index cfb21532da..52222c569c 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, @@ -152,6 +151,18 @@ const False = () => Bool(false); * documentation on those methods to learn more. */ type Permission = Types.AuthRequired; + +class VerificationKeyPermission { + constructor(public auth: Permission, public txnVersion: UInt32) {} + + // TODO this class could be made incompatible with a plain object (breaking change) + // private _ = undefined; + + static withCurrentVersion(perm: Permission) { + return new VerificationKeyPermission(perm, TransactionVersion.current()); + } +} + let Permission = { /** * Modification is impossible. @@ -197,6 +208,64 @@ let Permission = { signatureNecessary: False(), signatureSufficient: True(), }), + + /** + * Special Verification key permissions. + * + * The difference to normal permissions is that `Permission.proof` and `Permission.impossible` are replaced by less restrictive permissions: + * - `impossible` is replaced by `impossibleDuringCurrentVersion` + * - `proof` is replaced by `proofDuringCurrentVersion` + * + * The issue is that a future hardfork which changes the proof system could mean that old verification keys can no longer + * be used to verify proofs in the new proof system, and the zkApp would have to be redeployed to adapt the verification key. + * + * Having either `impossible` or `proof` would mean that these zkApps can't be upgraded after this hypothetical hardfork, and would become unusable. + * + * Such a future hardfork would manifest as an increment in the "transaction version" of zkApps, which you can check with {@link TransactionVersion.current()}. + * + * The `impossibleDuringCurrentVersion` and `proofDuringCurrentVersion` have an additional `txnVersion` field. + * These permissions follow the same semantics of not upgradable, or only upgradable with proofs, + * _as long as_ the current transaction version is the same as the one on the permission. + * + * Once the current transaction version is higher than the one on the permission, the permission is treated as `signature`, + * and the zkApp can be redeployed with a signature of the original account owner. + */ + VerificationKey: { + /** + * Modification is impossible, as long as the network accepts the current {@link TransactionVersion}. + * + * After a hardfork that increments the transaction version, the permission is treated as `signature`. + */ + impossibleDuringCurrentVersion: () => + VerificationKeyPermission.withCurrentVersion(Permission.impossible()), + + /** + * Modification is always permitted + */ + none: () => VerificationKeyPermission.withCurrentVersion(Permission.none()), + + /** + * Modification is permitted by zkapp proofs only; as long as the network accepts the current {@link TransactionVersion}. + * + * After a hardfork that increments the transaction version, the permission is treated as `signature`. + */ + proofDuringCurrentVersion: () => + VerificationKeyPermission.withCurrentVersion(Permission.proof()), + + /** + * Modification is permitted by signatures only, using the private key of the zkapp account + */ + signature: () => + VerificationKeyPermission.withCurrentVersion(Permission.signature()), + + /** + * Modification is permitted by zkapp proofs or signatures + */ + proofOrSignature: () => + VerificationKeyPermission.withCurrentVersion( + Permission.proofOrSignature() + ), + }, }; // TODO: we could replace the interface below if we could bridge annotations from OCaml @@ -242,10 +311,7 @@ interface Permissions extends Permissions_ { * key associated with the circuit tied to this account. Effectively * "upgradeability" of the smart contract. */ - setVerificationKey: { - auth: Permission; - txnVersion: UInt32; - }; + setVerificationKey: VerificationKeyPermission; /** * The {@link Permission} corresponding to the ability to set the zkapp uri @@ -283,6 +349,7 @@ interface Permissions extends Permissions_ { } let Permissions = { ...Permission, + /** * Default permissions are: * @@ -311,10 +378,7 @@ let Permissions = { receive: Permission.none(), setDelegate: Permission.signature(), setPermissions: Permission.signature(), - setVerificationKey: { - auth: Permission.signature(), - txnVersion: TransactionVersion.current(), - }, + setVerificationKey: Permission.VerificationKey.signature(), setZkappUri: Permission.signature(), editActionState: Permission.proof(), setTokenSymbol: Permission.signature(), @@ -330,10 +394,7 @@ let Permissions = { receive: Permission.none(), setDelegate: Permission.signature(), setPermissions: Permission.signature(), - setVerificationKey: { - auth: Permission.signature(), - txnVersion: TransactionVersion.current(), - }, + setVerificationKey: Permission.VerificationKey.signature(), setZkappUri: Permission.signature(), editActionState: Permission.signature(), setTokenSymbol: Permission.signature(), @@ -350,10 +411,7 @@ let Permissions = { access: Permission.none(), setDelegate: Permission.none(), setPermissions: Permission.none(), - setVerificationKey: { - auth: Permission.signature(), - txnVersion: TransactionVersion.current(), - }, + setVerificationKey: Permission.VerificationKey.none(), setZkappUri: Permission.none(), editActionState: Permission.none(), setTokenSymbol: Permission.none(), @@ -369,10 +427,8 @@ let Permissions = { access: Permission.impossible(), setDelegate: Permission.impossible(), setPermissions: Permission.impossible(), - setVerificationKey: { - auth: Permission.signature(), - txnVersion: TransactionVersion.current(), - }, + setVerificationKey: + Permission.VerificationKey.impossibleDuringCurrentVersion(), setZkappUri: Permission.impossible(), editActionState: Permission.impossible(), setTokenSymbol: Permission.impossible(), 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 0000000000..882e86efc4 --- /dev/null +++ b/src/lib/mina/actions/offchain-contract.unit-test.ts @@ -0,0 +1,222 @@ +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), +}); + +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) { + // 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 + let totalSupplyOption = await offchainState.fields.totalSupply.get(); + let totalSupply = totalSupplyOption.orElse(0n); + + offchainState.fields.totalSupply.update({ + from: totalSupplyOption, + to: 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); + + /** + * Update both accounts atomically. + * + * This is safe, because both updates will only be accepted if both previous balances are still correct. + */ + offchainState.fields.accounts.update(from, { + from: fromOption, + to: fromBalance.sub(amount), + }); + offchainState.fields.accounts.update(to, { + from: toOption, + to: toBalance.add(amount), + }); + } + + @method.returns(UInt64) + async getSupply() { + return (await offchainState.fields.totalSupply.get()).orElse(0n); + } + + @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 Local = await Mina.LocalBlockchain({ proofsEnabled }); +Mina.setActiveInstance(Local); + +let [sender, receiver, contractAccount, other] = 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 () => { + // 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() + .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 check({ expectedSupply: 1000n, expectedSenderBalance: 1000n }); + +// transfer (should succeed) + +console.time('transfer'); +await Mina.transaction(sender, async () => { + await contract.transfer(sender, receiver, UInt64.from(100)); +}) + .sign([sender.key]) + .prove() + .send(); +console.timeEnd('transfer'); + +console.time('more transfers'); +Local.setProofsEnabled(false); // we run these without proofs to save time + +await Mina.transaction(sender, async () => { + // 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)); + + // 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() + .send(); +console.timeEnd('more transfers'); + +// settle +Local.setProofsEnabled(proofsEnabled); + +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 check({ expectedSupply: 1555n, expectedSenderBalance: 900n }); + +// test helper + +async function check({ + expectedSupply, + expectedSenderBalance, +}: { + expectedSupply: bigint; + expectedSenderBalance: 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(); + let balanceOther = (await contract.getBalance(other)).toBigInt(); + + console.log('balance (sender)', balanceSender); + console.log('balance (recv)', balanceReceiver); + assert.strictEqual(balanceSender + balanceReceiver + balanceOther, supply); + assert.strictEqual(balanceSender, expectedSenderBalance); +} 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 0000000000..f4cfa6d009 --- /dev/null +++ b/src/lib/mina/actions/offchain-state-rollup.ts @@ -0,0 +1,373 @@ +import { Proof, ZkProgram } from '../../proof-system/zkprogram.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'; +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, + LinearizedAction, + LinearizedActionList, + MerkleLeaf, + updateMerkleMap, +} 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() +) {} + +/** + * 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, + // 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( + { + maxActionsPerBatch, + maxActionsPerUpdate, + }: { + maxActionsPerBatch: 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); + + // linearize actions into a flat MerkleList, so we don't process an insane amount of dummy actions + let linearActions = LinearizedActionList.empty(); + + for (let i = 0; i < maxActionsPerBatch; i++) { + 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 at once for the actions of each account update + let root = stateA.root; + let intermediateRoot = root; + + let intermediateUpdates: { key: Field; value: Field }[] = []; + let intermediateTree = Unconstrained.witness(() => tree.get().clone()); + + let isValidUpdate = Bool(true); + + linearActions.forEach(maxActionsPerBatch, (element, isDummy) => { + let { action, isCheckPoint } = element; + let { key, value, usesPreviousValue, previousValue } = action; + + // merkle witness + let witness = Provable.witness( + MerkleMapWitness, + () => + new MerkleMapWitness(intermediateTree.get().getWitness(key.toBigInt())) + ); + + // previous value at the key + let actualPreviousValue = Provable.witness(Field, () => + intermediateTree.get().getLeaf(key.toBigInt()) + ); + + // 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, '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 + 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 intermediate root if this wasn't a dummy action + intermediateRoot = Provable.if(isDummy, intermediateRoot, newRoot); + + // 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); + + // 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; + + 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.toConstant()); + }); + } + // otherwise, we have to roll back the intermediate tree (TODO: inefficient) + else { + intermediateTree.set(tree.get().clone()); + } + intermediateUpdates = []; + } + }); + }); + + return { root, actionState: actions.currentHash }; +} + +/** + * This program represents a proof that we can go from OffchainStateCommitments A -> B + */ +function OffchainStateRollup({ + // 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', + 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( + { maxActionsPerBatch, 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( + { maxActionsPerBatch, 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(); + + // 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()) { + // 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, + }); + let proof = await RollupProof.dummy(inputState, finalState, 2, 15); + return { proof, tree, nProofs: 0 }; + } + + // base proof + let slice = sliceActions(iterator, maxActionsPerBatch); + let proof = await offchainStateRollup.firstBatch( + inputState, + slice, + Unconstrained.from(tree) + ); + + // 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( + inputState, + slice, + Unconstrained.from(tree), + proof + ); + } + + return { proof, tree, nProofs }; + }, + }; +} + +// 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(); +} 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 0000000000..61d7d3d032 --- /dev/null +++ b/src/lib/mina/actions/offchain-state-serialization.ts @@ -0,0 +1,325 @@ +/** + * 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, 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'; +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'; +import { Option } from '../../provable/option.js'; + +export { + toKeyHash, + toAction, + fromActionWithoutHashes, + MerkleLeaf, + LinearizedAction, + LinearizedActionList, + ActionList, + fetchMerkleLeaves, + fetchMerkleMap, + updateMerkleMap, + 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, + keyType, + valueType, + key, + value, + previousValue, +}: { + prefix: Field; + keyType: KeyType; + valueType: Actionable; + key: KeyType extends undefined ? undefined : K; + value: V; + previousValue?: Option; +}): 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 + ? Provable.if( + previousValue.isSome, + Poseidon.hashPacked(valueType, previousValue.value), + Field(0) + ) + : Field(0); + let valueHash = Poseidon.hashPacked(valueType, value); + + return [ + ...valueType.toFields(value), + ...padding, + usesPreviousValue, + previousValueHash, + 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, + usesPreviousValue: Bool, + previousValue: Field, + prefix: Unconstrained.provableWithEmpty([]), +}) { + static fromAction(action: Field[]) { + 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, + }); + } + + /** + * 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.usesPreviousValue.toField(), + action.previousValue, + 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 +) {} + +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?: { + 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() + ); + + let merkleMap = new MerkleTree(256); + let valueMap = new Map(); + + 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(); + } + } +} diff --git a/src/lib/mina/actions/offchain-state.ts b/src/lib/mina/actions/offchain-state.ts new file mode 100644 index 0000000000..c0582b4e35 --- /dev/null +++ b/src/lib/mina/actions/offchain-state.ts @@ -0,0 +1,463 @@ +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, 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'; +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. + * + * 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 + ): Promise; +}; + +type OffchainStateContract = SmartContract & { + offchainState: State; +}; + +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 { + // 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 }; + }; + + 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); + let optionType = Option(type); + + return { + overwrite(value) { + // serialize into action + let action = toAction({ + prefix, + keyType: undefined, + valueType: type, + key: undefined, + value: type.fromValue(value), + }); + + // push action on account update + let update = contract().self; + update.body.actions = Actions.pushEvent(update.body.actions, action); + }, + + update({ from, to }) { + // serialize into action + let action = toAction({ + prefix, + keyType: undefined, + valueType: type, + key: undefined, + value: type.fromValue(to), + previousValue: optionType.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); + return await get(key, type); + }, + }; + } + + function map( + index: number, + keyType: Actionable, + valueType: Actionable + ): OffchainMap { + const prefix = Field(index); + let optionType = Option(valueType); + + return { + overwrite(key, value) { + // serialize into action + let action = toAction({ + prefix, + keyType, + valueType, + key, + value: valueType.fromValue(value), + }); + + // push action on account update + let update = contract().self; + update.body.actions = Actions.pushEvent(update.body.actions, action); + }, + + update(key, { from, to }) { + // serialize into action + let action = toAction({ + prefix, + keyType, + valueType, + key, + value: valueType.fromValue(to), + previousValue: optionType.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); + }, + }; + } + + 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, or none if it doesn't exist yet. + */ + get(): Promise>; + + /** + * 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(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) { + 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>; + + /** + * 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(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 = + | { 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 0000000000..8be11506fb --- /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/precondition.ts b/src/lib/mina/precondition.ts index 87cf5e0dfc..f276748493 100644 --- a/src/lib/mina/precondition.ts +++ b/src/lib/mina/precondition.ts @@ -19,6 +19,7 @@ import { ZkappUri, } from '../../bindings/mina-transaction/transaction-leaves.js'; import type { Types } from '../../bindings/mina-transaction/types.js'; +import type { Permissions } from './account-update.js'; import { ZkappStateLength } from './mina-instance.js'; export { @@ -615,6 +616,8 @@ type UpdateValueOriginal = { type UpdateValue = { [K in keyof Update_]: K extends 'zkappUri' | 'tokenSymbol' ? string + : K extends 'permissions' + ? Permissions : Update_[K]['value']; }; diff --git a/src/lib/mina/state.ts b/src/lib/mina/state.ts index 2501c6dd3b..d6c96980e5 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/test/dynamic-call.unit-test.ts b/src/lib/mina/test/dynamic-call.unit-test.ts new file mode 100644 index 0000000000..97ef19d9d3 --- /dev/null +++ b/src/lib/mina/test/dynamic-call.unit-test.ts @@ -0,0 +1,92 @@ +/** + * Tests that shows we can call a subcontract dynamically based on the address, + * as long as its signature matches the signature our contract was compiled against. + * + * In other words, the exact implementation/constraints of zkApp methods we call are not hard-coded in the caller contract. + */ +import { + Bool, + UInt64, + SmartContract, + method, + PublicKey, + Mina, +} from '../../../index.js'; + +type Subcontract = SmartContract & { + submethod(a: UInt64, b: UInt64): Promise; +}; + +// two implementations with same signature of the called method, but different provable logic + +class SubcontractA extends SmartContract implements Subcontract { + @method.returns(Bool) + async submethod(a: UInt64, b: UInt64): Promise { + return a.greaterThan(b); + } +} + +class SubcontractB extends SmartContract implements Subcontract { + @method.returns(Bool) + async submethod(a: UInt64, b: UInt64): Promise { + return a.mul(b).equals(UInt64.from(42)); + } +} + +// caller contract that calls the subcontract + +class Caller extends SmartContract { + @method + async call(a: UInt64, b: UInt64, address: PublicKey) { + const subcontract = new Caller.Subcontract(address); + await subcontract.submethod(a, b); + } + + // subcontract to call. this property is changed below + // TODO: having to set this property is a hack, it would be nice to pass the contract as parameter + static Subcontract: new (...args: any) => Subcontract = SubcontractA; +} + +// TEST BELOW + +// setup + +let Local = await Mina.LocalBlockchain({ proofsEnabled: true }); +Mina.setActiveInstance(Local); + +let [sender, callerAccount, aAccount, bAccount] = Local.testAccounts; + +await SubcontractA.compile(); +await SubcontractB.compile(); +await Caller.compile(); + +let caller = new Caller(callerAccount); +let a = new SubcontractA(aAccount); +let b = new SubcontractB(bAccount); + +await Mina.transaction(sender, async () => { + await caller.deploy(); + await a.deploy(); + await b.deploy(); +}) + .sign([callerAccount.key, aAccount.key, bAccount.key, sender.key]) + .send(); + +// subcontract A call + +let x = UInt64.from(10); +let y = UInt64.from(5); + +await Mina.transaction(sender, () => caller.call(x, y, aAccount)) + .prove() + .sign([sender.key]) + .send(); + +// subcontract B call + +Caller.Subcontract = SubcontractB; + +await Mina.transaction(sender, () => caller.call(x, y, bAccount)) + .prove() + .sign([sender.key]) + .send(); diff --git a/src/lib/mina/zkapp.ts b/src/lib/mina/zkapp.ts index cea652cffb..84e73cc286 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, @@ -21,7 +20,6 @@ import { cloneCircuitValue, FlexibleProvablePure, InferProvable, - provable, } from '../provable/types/struct.js'; import { Provable, @@ -32,7 +30,6 @@ import * as Encoding from '../../bindings/lib/encoding.js'; import { HashInput, Poseidon, - ProvableHashable, hashConstant, isHashable, packToFields, @@ -47,7 +44,6 @@ import { analyzeMethod, compileProgram, Empty, - emptyValue, getPreviousProofsForProver, methodArgumentsToConstant, methodArgumentTypesAndValues, @@ -56,7 +52,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 +75,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; @@ -169,12 +171,14 @@ function method( * } * ``` */ -method.returns = function ( - returnType: Provable -) { +method.returns = function < + K extends string, + T extends SmartContract, + R extends Provable +>(returnType: R) { return function decorateMethod( target: T & { - [k in K]: (...args: any) => Promise; + [k in K]: (...args: any) => Promise>; }, methodName: K & string & keyof T, descriptor: PropertyDescriptor @@ -770,9 +774,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 +1178,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 +1241,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, @@ -1612,13 +1326,23 @@ function diffRecursive( let { transaction, index, accountUpdate: input } = inputData; diff(transaction, index, prover.toPretty(), input.toPretty()); // TODO - let inputChildren = accountUpdateLayout()!.get(input)!.children.mutable!; - let proverChildren = accountUpdateLayout()!.get(prover)!.children.mutable!; + let proverChildren = accountUpdateLayout()?.get(prover)?.children.mutable; + if (proverChildren === undefined) return; + + // collect input children + let inputChildren: AccountUpdate[] = []; + let callDepth = input.body.callDepth; + for (let i = index; i < transaction.accountUpdates.length; i++) { + let update = transaction.accountUpdates[i]; + if (update.body.callDepth <= callDepth) break; + if (update.body.callDepth === callDepth + 1) inputChildren.push(update); + } + let nChildren = inputChildren.length; for (let i = 0; i < nChildren; i++) { - let inputChild = inputChildren[i].mutable; + let inputChild = inputChildren[i]; let child = proverChildren[i].mutable; - if (!inputChild || !child) return; + if (!child) return; diffRecursive(child, { transaction, index, accountUpdate: inputChild }); } } diff --git a/src/lib/proof-system/sideloaded.unit-test.ts b/src/lib/proof-system/sideloaded.unit-test.ts index 5f397b443d..88bb40fca8 100644 --- a/src/lib/proof-system/sideloaded.unit-test.ts +++ b/src/lib/proof-system/sideloaded.unit-test.ts @@ -5,7 +5,7 @@ import { Void, ZkProgram, } from './zkprogram.js'; -import { Field, Struct } from '../../index.js'; +import { Field, SmartContract, Struct, method } from '../../index.js'; import { it, describe, before } from 'node:test'; import { expect } from 'expect'; @@ -124,6 +124,17 @@ const sideloadedProgram2 = ZkProgram({ }, }); +export class SideloadedSmartContract extends SmartContract { + @method async setValue( + value: Field, + proof: SampleSideloadedProof, + vk: VerificationKey + ) { + proof.verify(vk); + proof.publicInput.assertEquals(value); + } +} + describe('sideloaded', async () => { let program1Vk = (await program1.compile()).verificationKey; let program2Vk = (await program2.compile()).verificationKey; @@ -211,4 +222,8 @@ describe('sideloaded', async () => { expect(tag1).not.toStrictEqual(tag2); }); + + it('should compile with SmartContracts', async () => { + await SideloadedSmartContract.compile(); + }); }); diff --git a/src/lib/proof-system/zkprogram.ts b/src/lib/proof-system/zkprogram.ts index 43857b9cb2..0e00f61d1b 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'; @@ -44,6 +43,7 @@ import { prefixes } from '../../bindings/crypto/constants.js'; // public API export { + ProofBase, Proof, DynamicProof, SelfProof, 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}. diff --git a/src/lib/provable/crypto/poseidon.ts b/src/lib/provable/crypto/poseidon.ts index 49a514282f..c3425f0f66 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 5265cbff47..c8db5b8e03 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/group.ts b/src/lib/provable/group.ts index 10a30e3fc7..5584e7d59d 100644 --- a/src/lib/provable/group.ts +++ b/src/lib/provable/group.ts @@ -352,6 +352,10 @@ class Group { fields: [x.x, x.y], }; } + + static empty() { + return Group.zero; + } } // internal helpers @@ -360,10 +364,6 @@ function isConstant(g: Group) { return g.x.isConstant() && g.y.isConstant(); } -function toTuple(g: Group): [0, FieldVar, FieldVar] { - return [0, g.x.value, g.y.value]; -} - function toProjective(g: Group) { return Pallas.fromAffine({ x: g.x.toBigInt(), diff --git a/src/lib/provable/merkle-list.ts b/src/lib/provable/merkle-list.ts index 6871573682..6144b42e53 100644 --- a/src/lib/provable/merkle-list.ts +++ b/src/lib/provable/merkle-list.ts @@ -191,6 +191,29 @@ 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( + `Expected MerkleList to have at most ${length} elements, but it has more.` + ); + } + startIterating(): MerkleListIterator { let merkleArray = MerkleListIterator.createFromList(this.Constructor); return merkleArray.startIterating(this); @@ -373,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() { diff --git a/src/lib/provable/merkle-tree.ts b/src/lib/provable/merkle-tree.ts index d818d9af6e..090d244471 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 0000000000..372bcd5f6c --- /dev/null +++ b/src/lib/provable/option.ts @@ -0,0 +1,117 @@ +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 }; + +type Option = { isSome: Bool; value: T } & { + assertSome(message?: string): T; + orElse(defaultValue: T | V): T; +}; + +type OptionOrValue = + | { isSome: boolean | Bool; value: T | V } + | T + | V + | undefined; + +/** + * 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: A +): Provable< + Option, InferValue>, + InferValue | undefined +> & { + 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; + + // 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); // type-cast here is ok, matches implementation + 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, + strictType, + this.value, + strictType.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(strictType) }) + : new Option_({ + isSome: Bool(true), + value: strictType.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: OptionOrValue) { + return new Option_(Super.fromValue(value)); + } + }; +} diff --git a/src/lib/provable/scalar.ts b/src/lib/provable/scalar.ts index 2277b304d3..fe4602fbd1 100644 --- a/src/lib/provable/scalar.ts +++ b/src/lib/provable/scalar.ts @@ -317,6 +317,10 @@ class Scalar implements ShiftedScalar { static fromJSON(x: string) { return Scalar.from(SignableFq.fromJSON(x)); } + + static empty() { + return Scalar.from(0n); + } } // internal helpers diff --git a/src/lib/provable/string.ts b/src/lib/provable/string.ts index 87c085573e..2a52e33cdf 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 579dce8236..ec02667fc3 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 ab48c0dddf..5554aafc81 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'; @@ -17,6 +18,8 @@ import { Bool } from '../bool.js'; import assert from 'assert/strict'; import { FieldType } from '../core/fieldvar.js'; import { From } from '../../../bindings/lib/provable-generic.js'; +import { Group } from '../group.js'; +import { modifiedField } from '../types/fields.js'; let type = provable({ nested: { a: Number, b: Boolean }, @@ -84,6 +87,26 @@ expect(jsValue).toEqual({ expect(type.fromValue(jsValue)).toEqual(value); +// empty +let empty = type.empty(); +expect(empty).toEqual({ + nested: { a: 0, b: false }, + other: '', + pk: PublicKey.empty(), + bool: new Bool(false), + uint: [UInt32.zero, UInt32.zero], +}); + +// empty with Group +expect(provable({ value: Group }).empty()).toEqual({ value: Group.zero }); + +// fails with a clear error on input without an empty method +const FieldWithoutEmpty = modifiedField({}); +delete (FieldWithoutEmpty as any).empty; +expect(() => provable({ value: FieldWithoutEmpty }).empty()).toThrow( + 'Expected `empty()` method on anonymous type object' +); + // check await Provable.runAndCheck(() => { type.check(value); @@ -119,7 +142,7 @@ class MyStructPure extends Struct({ uint: [UInt32, UInt32], }) {} -// Struct.from() works on both js and provable inputs +// Struct.fromValue() works on both js and provable inputs let myStructInput = { nested: { a: Field(1), b: 2n }, diff --git a/src/lib/provable/test/test-utils.ts b/src/lib/provable/test/test-utils.ts index 208fdccc68..6ff8535330 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 c251509bb5..60e9d23c30 100644 --- a/src/lib/provable/types/struct.ts +++ b/src/lib/provable/types/struct.ts @@ -1,7 +1,6 @@ import { Field, Bool, Scalar, Group } from '../wrapped.js'; import { provable, - provablePure, provableTuple, HashInput, NonMethods, @@ -13,7 +12,7 @@ import type { IsPure, } from './provable-derivers.js'; import { Provable } from '../provable.js'; -import { Proof } from '../../proof-system/zkprogram.js'; +import { DynamicProof, Proof } from '../../proof-system/zkprogram.js'; import { ProvablePure } from './provable-intf.js'; import { From, InferValue } from '../../../bindings/lib/provable-generic.js'; @@ -21,8 +20,6 @@ import { From, InferValue } from '../../../bindings/lib/provable-generic.js'; export { ProvableExtended, ProvablePureExtended, - provable, - provablePure, Struct, FlexibleProvable, FlexibleProvablePure, @@ -295,6 +292,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; @@ -310,7 +310,7 @@ function cloneCircuitValue(obj: T): T { if (isPrimitive(obj)) { return obj; } - if (obj instanceof Proof) { + if (obj instanceof Proof || obj instanceof DynamicProof) { return obj; } diff --git a/src/lib/provable/types/unconstrained.ts b/src/lib/provable/types/unconstrained.ts index 89fb648b8d..a6b4baf519 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()) @@ -110,11 +110,12 @@ and Provable.asProver() blocks, which execute outside the proof. }); } - static provable: Provable> & { + static provable: Provable, Unconstrained> & { toInput: (x: Unconstrained) => { fields?: Field[]; packed?: [Field, number][]; }; + empty: () => Unconstrained; } = { sizeInFields: () => 0, toFields: () => [], @@ -124,5 +125,24 @@ 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< + Unconstrained, + Unconstrained + > & { + 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 745532b000..58eeefc9fb 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'; diff --git a/src/mina-signer/mina-signer.ts b/src/mina-signer/mina-signer.ts index b5de51d847..d6b9793a43 100644 --- a/src/mina-signer/mina-signer.ts +++ b/src/mina-signer/mina-signer.ts @@ -460,9 +460,8 @@ class Client { * @param privateKey The private key used to sign the transaction * @returns A string with the resulting payload for /construction/combine. */ - rosettaCombinePayload(signingPayload: string, privateKey: Json.PrivateKey) { - let parsedPayload = JSON.parse(signingPayload); - return JSON.stringify(Rosetta.rosettaCombinePayload(parsedPayload, privateKey, this.network)); + rosettaCombinePayload(signingPayload: Rosetta.UnsignedPayload, privateKey: Json.PrivateKey) { + return Rosetta.rosettaCombinePayload(signingPayload, privateKey, this.network); } /** diff --git a/src/mina-signer/package-lock.json b/src/mina-signer/package-lock.json index ce9e20b7ed..ce6ed4b280 100644 --- a/src/mina-signer/package-lock.json +++ b/src/mina-signer/package-lock.json @@ -1,12 +1,12 @@ { "name": "mina-signer", - "version": "3.0.6", + "version": "3.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mina-signer", - "version": "3.0.6", + "version": "3.0.7", "license": "Apache-2.0", "dependencies": { "blakejs": "^1.2.1", diff --git a/src/mina-signer/package.json b/src/mina-signer/package.json index 39013917fd..a3fadbd44b 100644 --- a/src/mina-signer/package.json +++ b/src/mina-signer/package.json @@ -1,7 +1,7 @@ { "name": "mina-signer", "description": "Node API for signing transactions on various networks for Mina Protocol", - "version": "3.0.6", + "version": "3.0.7", "type": "module", "scripts": { "build": "tsc -p ../../tsconfig.mina-signer.json", diff --git a/src/mina-signer/src/rosetta.ts b/src/mina-signer/src/rosetta.ts index 012b23fcb1..cdd82bc4f0 100644 --- a/src/mina-signer/src/rosetta.ts +++ b/src/mina-signer/src/rosetta.ts @@ -192,7 +192,7 @@ function rosettaCombinePayload( network: NetworkId ) { let signature = signTransaction( - unsignedPayload.unsigned_transaction, + JSON.parse(unsignedPayload.unsigned_transaction), privateKey, network ); @@ -282,7 +282,7 @@ function rosettaTransactionToSignedCommand({ } type UnsignedPayload = { - unsigned_transaction: UnsignedTransaction; + unsigned_transaction: string; payloads: unknown[]; }; diff --git a/src/mina-signer/tests/rosetta.test.ts b/src/mina-signer/tests/rosetta.test.ts index 2527f526ea..8b8d691822 100644 --- a/src/mina-signer/tests/rosetta.test.ts +++ b/src/mina-signer/tests/rosetta.test.ts @@ -8,7 +8,7 @@ describe('Rosetta', () => { const rosettaUnsignedTxn: UnsignedTransaction = { "randomOracleInput": "0000000333E1F14C6155B706D4EA12CF70685B8DCD3342A8B36A27CC3EB61B5871F9219E33E1F14C6155B706D4EA12CF70685B8DCD3342A8B36A27CC3EB61B5871F9219E33E1F14C6155B706D4EA12CF70685B8DCD3342A8B36A27CC3EB61B5871F9219E000002570242F000000000008000000000000000C00000007FFFFFFFC00000000000000000000000000000000000000000000000000000000000000000000E0000000000000000014D677000000000", "signerInput": { "prefix": ["33E1F14C6155B706D4EA12CF70685B8DCD3342A8B36A27CC3EB61B5871F9219E", "33E1F14C6155B706D4EA12CF70685B8DCD3342A8B36A27CC3EB61B5871F9219E", "33E1F14C6155B706D4EA12CF70685B8DCD3342A8B36A27CC3EB61B5871F9219E"], "suffix": ["0000000000000007FFFFFFFC00000006000000000000000200000000001E8480", "0000000003800000000000000000000000000000000000000000000000000000", "00000000000000000000000000000000000000000000000001DCD65000000000"] }, "payment": { "to": "B62qqQomCgjaKhayN79wWqDNsSJKFaZjrkuCp8Kcrt36ubXb14XHU2X", "from": "B62qqQomCgjaKhayN79wWqDNsSJKFaZjrkuCp8Kcrt36ubXb14XHU2X", "fee": "1000000", "token": "wSHV2S4qX9jFsLjQo8r1BsMLH2ZRKsZx6EJd1sbozGPieEC4Jf", "nonce": "1", "memo": null, "amount": "1000000000", "valid_until": null }, "stakeDelegation": null }; const rosettaUnsignedPayload = { - unsigned_transaction: rosettaUnsignedTxn, + unsigned_transaction: JSON.stringify(rosettaUnsignedTxn), payloads: [ { account_identifier: { @@ -101,10 +101,10 @@ describe('Rosetta', () => { }); it('generates valid combine payload', () => { - const combinePayload = client.rosettaCombinePayload(JSON.stringify(rosettaUnsignedPayload), privateKey); + const combinePayload = client.rosettaCombinePayload(rosettaUnsignedPayload, privateKey); const expectedCombinePayload = { network_identifier: { blockchain: 'mina', network: 'mainnet' }, - unsigned_transaction: rosettaUnsignedTxn, + unsigned_transaction: JSON.stringify(rosettaUnsignedTxn), signatures: [ { hex_bytes: mainnetSignatureHex, @@ -118,6 +118,6 @@ describe('Rosetta', () => { } ] }; - expect(combinePayload).toBe(JSON.stringify(expectedCombinePayload)); + expect(JSON.stringify(combinePayload)).toBe(JSON.stringify(expectedCombinePayload)); }); });