diff --git a/.github/actions/live-tests-shared/action.yml b/.github/actions/live-tests-shared/action.yml index 8bd6d166af..15576fbfe5 100644 --- a/.github/actions/live-tests-shared/action.yml +++ b/.github/actions/live-tests-shared/action.yml @@ -29,7 +29,7 @@ runs: cat profiling.md >> $GITHUB_STEP_SUMMARY shell: bash - name: Upload Mina logs - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 continue-on-error: true if: always() with: diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 249a6fa31e..b234c94a07 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -3,7 +3,6 @@ on: push: branches: - main - - berkeley - develop pull_request: workflow_dispatch: {} diff --git a/.github/workflows/build-action.yml b/.github/workflows/build-action.yml index be92cbb906..b01291da79 100644 --- a/.github/workflows/build-action.yml +++ b/.github/workflows/build-action.yml @@ -3,7 +3,6 @@ on: push: branches: - main - - berkeley - develop pull_request: workflow_dispatch: {} @@ -68,7 +67,7 @@ jobs: - name: Execute E2E tests run: npm run test:e2e - name: Upload E2E test artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 continue-on-error: true if: always() with: @@ -95,9 +94,10 @@ jobs: npm ci npm run build - name: Publish to NPM if version has changed - uses: JS-DevTools/npm-publish@v1 + uses: JS-DevTools/npm-publish@v3 with: token: ${{ secrets.NPM_TOKEN }} + strategy: upgrade env: INPUT_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -121,9 +121,10 @@ jobs: npm ci npm run prepublishOnly - name: Publish to NPM if version has changed - uses: JS-DevTools/npm-publish@v1 + uses: JS-DevTools/npm-publish@v3 with: token: ${{ secrets.NPM_TOKEN }} package: './src/mina-signer/package.json' + strategy: upgrade env: INPUT_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 734b1e1d4f..1d266fa3c9 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -9,19 +9,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v2 - + uses: actions/checkout@v4 - name: Setup Node - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: - node-version: '16' - + node-version: '18' - name: Run typedoc run: | git submodule update --init --recursive npm ci npx typedoc --tsconfig tsconfig.node.json src/index.ts - - name: Deploy uses: peaceiris/actions-gh-pages@v3 with: diff --git a/.github/workflows/live-tests.yml b/.github/workflows/live-tests.yml index 85dee868cf..e3d40ce2f9 100644 --- a/.github/workflows/live-tests.yml +++ b/.github/workflows/live-tests.yml @@ -3,22 +3,18 @@ on: push: branches: - main - - berkeley - - develop pull_request: branches: - main - - berkeley - - develop workflow_dispatch: {} jobs: - berkeley: + master: timeout-minutes: 45 runs-on: ubuntu-latest services: mina-local-network: - image: o1labs/mina-local-network:berkeley-latest-lightnet + image: o1labs/mina-local-network:master-latest-lightnet env: NETWORK_TYPE: 'single-node' PROOF_LEVEL: 'none' @@ -36,4 +32,28 @@ jobs: - name: Use shared steps for live testing jobs uses: ./.github/actions/live-tests-shared with: - mina-branch-name: berkeley + mina-branch-name: master + compatible: + timeout-minutes: 45 + runs-on: ubuntu-latest + services: + mina-local-network: + image: o1labs/mina-local-network:compatible-latest-lightnet + env: + NETWORK_TYPE: 'single-node' + PROOF_LEVEL: 'none' + ports: + - 3085:3085 + - 5432:5432 + - 8080:8080 + - 8181:8181 + - 8282:8282 + volumes: + - /tmp:/root/logs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Use shared steps for live testing jobs + uses: ./.github/actions/live-tests-shared + with: + mina-branch-name: compatible diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd852abc0..43a940efa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,45 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased](https://github.com/o1-labs/o1js/compare/ed198f305...HEAD) +### Breaking changes + +- Fixed a vulnerability in `OffchainState` where it didn't store the `IndexedMerkleTree` length onchain and left it unconstrained https://github.com/o1-labs/o1js/pull/1676 + +### Added + +- A warning about the current reducer API limitations, as well as a mention of active work to mitigate them was added to doc comments and examples https://github.com/o1-labs/o1js/pull/1728 + +- `ForeignField`-based representation of scalars via `ScalarField` https://github.com/o1-labs/o1js/pull/1705 +- Introduced new V2 methods for nullifier operations: `isUnusedV2()`, `assertUnusedV2()`, and `setUsedV2()` https://github.com/o1-labs/o1js/pull/1715 +- `Experimental.BatchReducer` to reduce actions in batches https://github.com/o1-labs/o1js/pull/1676 + - Avoids the account update limit + - Handles arbitrary numbers of pending actions thanks to recursive validation of the next batch +- Add conditional versions of all preconditions: `.requireEqualsIf()` https://github.com/o1-labs/o1js/pull/1676 +- `AccountUpdate.createIf()` to conditionally add an account update to the current transaction https://github.com/o1-labs/o1js/pull/1676 +- `IndexedMerkleMap.setIf()` to set a key-value pair conditionally https://github.com/o1-labs/o1js/pull/1676 +- `Provable.assertEqualIf()` to conditionally assert that two values are equal https://github.com/o1-labs/o1js/pull/1676 +- Add `offchainState.setContractClass()` which enables us to declare the connected contract at the top level, without creating a contract instance https://github.com/o1-labs/o1js/pull/1676 + - This is enough to call `offchainState.compile()` +- More low-level methods to interact with `MerkleList` https://github.com/o1-labs/o1js/pull/1676 + - `popIfUnsafe()`, `toArrayUnconstrained()` and `lengthUnconstrained()` + +### Changed + +- Improve error message when o1js global state is accessed in an invalid way https://github.com/o1-labs/o1js/pull/1676 +- Start developing an internal framework for local zkapp testing https://github.com/o1-labs/o1js/pull/1676 +- Internally upgrade o1js to TypeScript 5.4 https://github.com/o1-labs/o1js/pull/1676 + +### Deprecated + +- Deprecated `Nullifier.isUnused()`, `Nullifier.assertUnused()`, and `Nullifier.setUsed()` methods https://github.com/o1-labs/o1js/pull/1715 +- `createEcdsa`, `createForeignCurve`, `ForeignCurve` and `EcdsaSignature` deprecated in favor of `V2` versions due to a security vulnerability found in the current implementation https://github.com/o1-labs/o1js/pull/1703 +- Deprecate `AccountUpdate.defaultAccountUpdate()` in favor of `AccountUpdate.default()` https://github.com/o1-labs/o1js/pull/1676 + ### Fixed +- Fix reversed order of account updates when using `TokenContract.approveAccountUpdates()` https://github.com/o1-labs/o1js/pull/1722 +- Fixed the static `check()` method in Struct classes to properly handle inheritance, preventing issues with under-constrained circuits. Added error handling to avoid using Struct directly as a field type. https://github.com/o1-labs/o1js/pull/1707 +- Fixed that `Option` could not be used as `@state` or event https://github.com/o1-labs/o1js/pull/1736 - Address potential incorrect token inheritance handling with `MayUseToken` in AccountUpdates https://github.com/o1-labs/o1js/pull/1716 - Modified isParentsOwnToken() to return false when inheritFromParent is true, preventing potential misuse of token inheritance. - Added check() method to ensure parentsOwnToken and inheritFromParent flags are not both set to true simultaneously. @@ -35,7 +72,6 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Deprecated - `MerkleMap.computeRootAndKey()` deprecated in favor of `MerkleMap.computeRootAndKeyV2()` due to a potential issue of computing hash collisions in key indicies https://github.com/o1-labs/o1js/pull/1694 -- `createEcdsa`, `createForeignCurve`, `ForeignCurve` and `EcdsaSignature` deprecated in favor of `V2` versions due to a security vulnerability found in the current implementation https://github.com/o1-labs/o1js/pull/1703 ## [1.3.1](https://github.com/o1-labs/o1js/compare/1ad7333e9e...40c597775) - 2024-06-11 diff --git a/README-dev.md b/README-dev.md index b243108747..f00bd9c88b 100644 --- a/README-dev.md +++ b/README-dev.md @@ -82,29 +82,33 @@ o1js uses these types to ensure that the constants used in the protocol are cons ## Development -### Branch Compatibility +### Branching Policy -If you work on o1js, create a feature branch off of one of these base branches. It's encouraged to submit your work-in-progress as a draft PR to raise visibility! When working with submodules and various interconnected parts of the stack, ensure you are on the correct branches that are compatible with each other. +| o1js base branches | Is default? | +| ------------------ | ----------- | +| main | **Yes** | +| develop | No | -#### How to Use the Branches +When you start your work on o1js, please create the feature branch off of one of the above base branches. +It's encouraged to submit your work-in-progress as a draft PR to raise visibility! +When working with submodules and various interconnected parts of the stack, ensure you are on the correct branches that are compatible with each other. **Default to `main` as the base branch**. -The other base branches (`berkeley` and `develop`) are used only in specific scenarios where you want to adapt o1js to changes in the sibling repos on those other branches. Even then, consider whether it is feasible to land your changes to `main` and merge to `berkeley` and `develop` afterwards. Only changes in `main` will ever be released, so anything in the other branches has to be backported and reconciled with `main` eventually. +Other base branches (currently `develop` only) are used in specific scenarios where you want to adapt o1js to changes in the sibling repos on those other branches. Even then, consider whether it is feasible to land your changes to `main` and merge to `develop` afterwards. Only changes in `main` will ever be released, so anything in other branches has to be backported and reconciled with the `main` branch eventually. -| Repository | mina -> o1js -> o1js-bindings | -| ---------- | -------------------------------- | -| Branches | o1js-main -> main -> main | -| | berkeley -> berkeley -> berkeley | -| | develop -> develop -> develop | +#### Relationship Between Repositories and Branches -- `o1js-main`: The `o1js-main` branch in the Mina repository corresponds to the `main` branch in both o1js and o1js-bindings repositories. This branch is where stable releases and ramp-up features are maintained. The `o1js-main` branch runs in parallel to the Mina `berkeley` branch and does not have a subset or superset relationship with it. The branching structure is as follows (<- means direction to merge): +| Repository | o1js → | o1js-bindings → | mina | +| ---------- | ----------- | -------------------- | ---------- | +| Branches | main | main | compatible | +| | develop | develop | develop | - - `develop` <- `o1js-main` <- `current testnet` - Typically, the current Testnet often corresponds to the rampup branch. +Where: -- `berkeley`: The `berkeley` branch is maintained across all three repositories. This branch is used for features and updates specific to the Berkeley release of the project. +- `compatible`: This is the [Mina repository](https://github.com/MinaProtocol/mina) branch. It corresponds to the `main` branch in both o1js and o1js-bindings repositories. This branch is where stable releases and soft-fork features are maintained. -- `develop`: The `develop` branch is also maintained across all three repositories. It is used for ongoing development, testing new features, and integration work. +- `develop`: This branch is maintained across all three repositories. It is used for ongoing (next hard-fork) development, testing new features and integration work. ### Running Tests @@ -193,7 +197,7 @@ docker run --rm --pull=missing -it \ -p 8080:8080 \ -p 8181:8181 \ -p 8282:8282 \ - o1labs/mina-local-network:o1js-main-latest-lightnet + o1labs/mina-local-network:compatible-latest-lightnet ``` See the [Docker Hub repository](https://hub.docker.com/r/o1labs/mina-local-network) for more information. diff --git a/package-lock.json b/package-lock.json index 34ff4d87ea..ff3996809d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "typedoc": "^0.25.8", "typedoc-plugin-markdown": "^4.0.0-next.53", "typedoc-plugin-merge-modules": "^5.1.0", - "typescript": "5.1" + "typescript": "^5.4.5" }, "engines": { "node": ">=18.14.0" @@ -6985,9 +6985,9 @@ } }, "node_modules/typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "dev": true, "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index b968d1bd75..95fa8c6759 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "typedoc": "^0.25.8", "typedoc-plugin-markdown": "^4.0.0-next.53", "typedoc-plugin-merge-modules": "^5.1.0", - "typescript": "5.1" + "typescript": "^5.4.5" }, "dependencies": { "blakejs": "1.2.1", diff --git a/src/examples/nullifier.ts b/src/examples/nullifier.ts index 2ef4413c46..55c0dbdfdc 100644 --- a/src/examples/nullifier.ts +++ b/src/examples/nullifier.ts @@ -28,10 +28,10 @@ class PayoutOnlyOnce extends SmartContract { ); // we compute the current root and make sure the entry is set to 0 (= unused) - nullifier.assertUnused(nullifierWitness, nullifierRoot); + nullifier.assertUnusedV2(nullifierWitness, nullifierRoot); // we set the nullifier to 1 (= used) and calculate the new root - let newRoot = nullifier.setUsed(nullifierWitness); + let newRoot = nullifier.setUsedV2(nullifierWitness); // we update the on-chain root this.nullifierRoot.set(newRoot); diff --git a/src/examples/zkapps/dex/dex-with-actions.ts b/src/examples/zkapps/dex/dex-with-actions.ts index 898127df44..bb0fe5583f 100644 --- a/src/examples/zkapps/dex/dex-with-actions.ts +++ b/src/examples/zkapps/dex/dex-with-actions.ts @@ -2,7 +2,11 @@ * This DEX implementation differs from ./dex.ts in two ways: * - More minimal & realistic; stuff designed only for testing protocol features was removed * - Uses an async pattern with actions that lets users claim funds later and reduces account updates - */ + * + * Warning: The reducer API in o1js is currently not safe to use in production applications. The `reduce()` + * method breaks if more than the hard-coded number (default: 32) of actions are pending. Work is actively + * in progress to mitigate this limitation. + */ import { Account, AccountUpdate, diff --git a/src/examples/zkapps/reducer/actions-as-merkle-list-iterator.ts b/src/examples/zkapps/reducer/actions-as-merkle-list-iterator.ts index 284a5a56c3..0f7b7c4a23 100644 --- a/src/examples/zkapps/reducer/actions-as-merkle-list-iterator.ts +++ b/src/examples/zkapps/reducer/actions-as-merkle-list-iterator.ts @@ -4,7 +4,11 @@ * * This is mainly intended as an example for using `Iterator` and `MerkleList`, but it might also be useful as * a blueprint for processing actions in a custom and more explicit way. - */ + * + * Warning: The reducer API in o1js is currently not safe to use in production applications. The `reduce()` + * method breaks if more than the hard-coded number (default: 32) of actions are pending. Work is actively + * in progress to mitigate this limitation. + */ import { Field, Mina, diff --git a/src/examples/zkapps/reducer/actions-as-merkle-list.ts b/src/examples/zkapps/reducer/actions-as-merkle-list.ts index a2a608b3e2..1eb813dbb5 100644 --- a/src/examples/zkapps/reducer/actions-as-merkle-list.ts +++ b/src/examples/zkapps/reducer/actions-as-merkle-list.ts @@ -4,7 +4,11 @@ * * This is mainly intended as an example for using `MerkleList`, but it might also be useful as * a blueprint for processing actions in a custom and more explicit way. - */ + * + * Warning: The reducer API in o1js is currently not safe to use in production applications. The `reduce()` + * method breaks if more than the hard-coded number (default: 32) of actions are pending. Work is actively + * in progress to mitigate this limitation. + */ import { Bool, Mina, diff --git a/src/examples/zkapps/reducer/map.ts b/src/examples/zkapps/reducer/map.ts index c4c2cb23ac..fd0d404944 100644 --- a/src/examples/zkapps/reducer/map.ts +++ b/src/examples/zkapps/reducer/map.ts @@ -22,6 +22,10 @@ In this example, the keys are public keys, and the values are arbitrary field el This utilizes the `Reducer` as an append online list of actions, which are then looked at to find the value corresponding to a specific key. +Warning: The reducer API in o1js is currently not safe to use in production applications. The reduce() +method breaks if more than the hard-coded number (default: 32) of actions are pending. Work is actively +in progress to mitigate this limitation. + ```ts // js diff --git a/src/examples/zkapps/reducer/reducer-composite.ts b/src/examples/zkapps/reducer/reducer-composite.ts index a45686f515..5ae49e5559 100644 --- a/src/examples/zkapps/reducer/reducer-composite.ts +++ b/src/examples/zkapps/reducer/reducer-composite.ts @@ -1,3 +1,11 @@ +/** + * This example demonstrates a pattern to use actions for concurrent state updates. + * + * Warning: The reducer API in o1js is currently not safe to use in production applications. The `reduce()` + * method breaks if more than the hard-coded number (default: 32) of actions are pending. Work is actively + * in progress to mitigate this limitation. + */ + import { Field, state, diff --git a/src/examples/zkapps/voting/demo.ts b/src/examples/zkapps/voting/demo.ts index 948a773cc7..1a5244f963 100644 --- a/src/examples/zkapps/voting/demo.ts +++ b/src/examples/zkapps/voting/demo.ts @@ -1,5 +1,12 @@ -// used to do a dry run, without tests -// ./run ./src/examples/zkapps/voting/demo.ts + +/* + * used to do a dry run, without tests + * ./run ./src/examples/zkapps/voting/demo.ts + * + * Warning: The reducer API in o1js is currently not safe to use in production applications. The `reduce()` + * method breaks if more than the hard-coded number (default: 32) of actions are pending. Work is actively + * in progress to mitigate this limitation. + */ import { Mina, AccountUpdate, PrivateKey, UInt64, Reducer, Bool } from 'o1js'; import { VotingApp, VotingAppParams } from './factory.js'; diff --git a/src/examples/zkapps/voting/membership.ts b/src/examples/zkapps/voting/membership.ts index 3210fb39cb..ea487a6a83 100644 --- a/src/examples/zkapps/voting/membership.ts +++ b/src/examples/zkapps/voting/membership.ts @@ -1,3 +1,8 @@ +/* + * Warning: The reducer API in o1js is currently not safe to use in production applications. The `reduce()` + * method breaks if more than the hard-coded number (default: 32) of actions are pending. Work is actively + * in progress to mitigate this limitation. + */ import { Field, SmartContract, diff --git a/src/examples/zkapps/voting/voting.ts b/src/examples/zkapps/voting/voting.ts index f68157cdfd..eb42e5020f 100644 --- a/src/examples/zkapps/voting/voting.ts +++ b/src/examples/zkapps/voting/voting.ts @@ -1,3 +1,8 @@ +/* + * Warning: The reducer API in o1js is currently not safe to use in production applications. The `reduce()` + * method breaks if more than the hard-coded number (default: 32) of actions are pending. Work is actively + * in progress to mitigate this limitation. + */ import { Field, SmartContract, diff --git a/src/examples/zkprogram/program-with-input.ts b/src/examples/zkprogram/program-with-input.ts index 3e420d6c08..8d2fc29e02 100644 --- a/src/examples/zkprogram/program-with-input.ts +++ b/src/examples/zkprogram/program-with-input.ts @@ -35,7 +35,7 @@ MyProgram.publicOutputType satisfies Provable; let MyProof = ZkProgram.Proof(MyProgram); -console.log('program digest', MyProgram.digest()); +console.log('program digest', await MyProgram.digest()); console.log('compiling MyProgram...'); let { verificationKey } = await MyProgram.compile(); diff --git a/src/examples/zkprogram/program.ts b/src/examples/zkprogram/program.ts index 8ab507a388..4bce8c3dc8 100644 --- a/src/examples/zkprogram/program.ts +++ b/src/examples/zkprogram/program.ts @@ -36,7 +36,7 @@ MyProgram.publicOutputType satisfies typeof Field; let MyProof = ZkProgram.Proof(MyProgram); -console.log('program digest', MyProgram.digest()); +console.log('program digest', await MyProgram.digest()); console.log('compiling MyProgram...'); let { verificationKey } = await MyProgram.compile(); diff --git a/src/index.ts b/src/index.ts index 3778236825..f4d03badfc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ export { EcdsaSignature, EcdsaSignatureV2, } from './lib/provable/crypto/foreign-ecdsa.js'; +export { ScalarField } from './lib/provable/scalar-field.js'; export { Poseidon, TokenSymbol, @@ -138,6 +139,9 @@ 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'; +import * as BatchReducer_ from './lib/mina/actions/batch-reducer.js'; +import { Actionable } from './lib/mina/actions/offchain-state-serialization.js'; +import { InferProvable } from './lib/provable/types/struct.js'; export { Experimental }; const Experimental_ = { @@ -168,6 +172,40 @@ namespace Experimental { * - `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 {} + + // batch reducer + + /** + * A reducer to process actions in fixed-size batches. + * + * ```ts + * let batchReducer = new BatchReducer({ actionType: Action, batchSize: 5 }); + * + * // in contract: concurrent dispatching of actions + * batchReducer.dispatch(action); + * + * // reducer logic + * // outside contract: prepare a list of { batch, proof } objects which cover all pending actions + * let batches = await batchReducer.prepareBatches(); + * + * // in contract: process a single batch + * // create one transaction that does this for each batch! + * batchReducer.processBatch({ batch, proof }, (action, isDummy) => { + * // ... + * }); + * ``` + */ + export class BatchReducer< + ActionType extends Actionable, + BatchSize extends number = number, + Action = InferProvable + > extends BatchReducer_.BatchReducer {} + + /** + * Provable type that represents a batch of actions. + */ + export let ActionBatch = BatchReducer_.ActionBatch; + export type ActionBatch = BatchReducer_.ActionBatch; } Error.stackTraceLimit = 100000; diff --git a/src/lib/mina/account-update.ts b/src/lib/mina/account-update.ts index df512221aa..c07390b21f 100644 --- a/src/lib/mina/account-update.ts +++ b/src/lib/mina/account-update.ts @@ -1035,9 +1035,24 @@ class AccountUpdate implements Types.AccountUpdate { return new AccountUpdateTree({ accountUpdate, id, children }); } + /** + * @deprecated Use {@link AccountUpdate.default} instead. + */ static defaultAccountUpdate(address: PublicKey, tokenId?: Field) { + return AccountUpdate.default(address, tokenId); + } + + /** + * Create an account update from a public key and an optional token id. + * + * **Important**: This method is different from `AccountUpdate.create()`, in that it really just creates the account update object. + * It does not attach the update to the current transaction or smart contract. + * Use this method for lower-level operations with account updates. + */ + static default(address: PublicKey, tokenId?: Field) { return new AccountUpdate(Body.keepAll(address, tokenId)); } + static dummy() { let dummy = new AccountUpdate(Body.dummy()); dummy.label = 'Dummy'; @@ -1083,6 +1098,22 @@ class AccountUpdate implements Types.AccountUpdate { } return accountUpdate; } + + /** + * Create an account update that is added to the transaction only if a condition is met. + * + * See {@link AccountUpdate.create} for more information. In this method, you can pass in + * a condition that determines whether the account update should be added to the transaction. + */ + static createIf(condition: Bool, publicKey: PublicKey, tokenId?: Field) { + return AccountUpdate.create( + // if the condition is false, we use an empty public key, which causes the account update to be ignored + // as a dummy when building the transaction + Provable.if(condition, publicKey, PublicKey.empty()), + tokenId + ); + } + /** * Attach account update to the current transaction * -- if in a smart contract, to its children diff --git a/src/lib/mina/actions/action-types.ts b/src/lib/mina/actions/action-types.ts new file mode 100644 index 0000000000..604e344f51 --- /dev/null +++ b/src/lib/mina/actions/action-types.ts @@ -0,0 +1,100 @@ +import { MerkleList } from '../../provable/merkle-list.js'; +import { Field } from '../../provable/wrapped.js'; +import { InferProvable } from '../../provable/types/struct.js'; +import { Actionable } from './offchain-state-serialization.js'; +import { Actions } from '../account-update.js'; +import { Hashed } from '../../provable/packed.js'; +import { hashWithPrefix } from '../../provable/crypto/poseidon.js'; +import { prefixes } from '../../../bindings/crypto/constants.js'; + +export { MerkleActions, MerkleActionHashes, HashedAction, FlatActions }; +export { emptyActionState, emptyActionsHash }; + +const emptyActionsHash = Actions.empty().hash; +const emptyActionState = Actions.emptyActionState(); + +/** + * Provable representation of actions and their three levels of Merkleization. + */ +type MerkleActions = MerkleList>>; + +function MerkleActions>( + actionType: A, + fromActionState?: Field +) { + return MerkleList.create( + MerkleActionList(actionType).provable, + (hash, actions) => + hashWithPrefix(prefixes.sequenceEvents, [hash, actions.hash]), + fromActionState ?? emptyActionState + ); +} +MerkleActions.fromFields = actionFieldsToMerkleList; + +type MerkleActionList = MerkleList>; + +function MerkleActionList>(actionType: A) { + return MerkleList.create( + HashedAction(actionType).provable, + (hash, action) => + hashWithPrefix(prefixes.sequenceEvents, [hash, action.hash]), + emptyActionsHash + ); +} + +type HashedAction = Hashed; + +function HashedAction>(actionType: A) { + return Hashed.create(actionType as Actionable>, (action) => + hashWithPrefix(prefixes.event, actionType.toFields(action)) + ); +} + +function actionFieldsToMerkleList( + actionType: Actionable, + fields: bigint[][][], + fromActionState?: bigint +) { + const HashedActionT = HashedAction(actionType); + const MerkleActionListT = MerkleActionList(actionType); + const MerkleActionsT = MerkleActions( + actionType, + fromActionState ? Field(fromActionState) : undefined + ); + let actions = fields.map((event) => + event.map((action) => actionType.fromFields(action.map(Field))) + ); + let hashes = actions.map((as) => as.map((a) => HashedActionT.hash(a))); + return MerkleActionsT.from(hashes.map((h) => MerkleActionListT.from(h))); +} + +/** + * Simplified representation of actions where we don't use inner action lists but + * only their hashes, which are plain Field elements. + */ +type MerkleActionHashes = MerkleList; + +function MerkleActionHashes(fromActionState?: Field) { + return MerkleList.create( + Field, + (hash, actionsHash) => + hashWithPrefix(prefixes.sequenceEvents, [hash, actionsHash]), + fromActionState ?? emptyActionState + ); +} + +/** + * Provable representation of a flat list of actions. + * + * If the amount of logic per action is heavy, it is usually good to flatten the nested actions + * list into a single list like this one. + */ +type FlatActions = MerkleList>; + +function FlatActions>(actionType: A) { + const HashedAction = Hashed.create( + actionType as Actionable>, + (action) => hashWithPrefix(prefixes.event, actionType.toFields(action)) + ); + return MerkleList.create(HashedAction.provable); +} diff --git a/src/lib/mina/actions/batch-reducer-program.unit-test.ts b/src/lib/mina/actions/batch-reducer-program.unit-test.ts new file mode 100644 index 0000000000..dedbf2f8a8 --- /dev/null +++ b/src/lib/mina/actions/batch-reducer-program.unit-test.ts @@ -0,0 +1,79 @@ +import { + BatchReducer, + actionStackProgram, + proveActionStack, +} from './batch-reducer.js'; +import { Field } from '../../../index.js'; +import { expect } from 'expect'; +import { describe, it } from 'node:test'; +import { Actions as ActionsBigint } from '../../../bindings/mina-transaction/transaction-leaves-bigint.js'; + +// analyze program with different number of actions +for (let actionsPerProof of [10, 30, 100, 300, 1000]) { + let program = actionStackProgram(actionsPerProof); + console.log({ + actionsPerProof, + summary: (await program.analyzeMethods()).proveChunk.summary(), + }); +} + +function randomActionHashes(n: number) { + let actions: bigint[] = []; + for (let i = 0; i < n; i++) { + actions[i] = Field.random().toBigInt(); + } + return actions; +} + +function randomActionWitnesses(n: number) { + let hashes = randomActionHashes(n); + let witnesses: { hash: bigint; stateBefore: bigint }[] = []; + let state = BatchReducer.initialActionState.toBigInt(); + for (let hash of hashes) { + witnesses.push({ hash, stateBefore: state }); + state = ActionsBigint.updateSequenceState(state, hash); + } + return { witnesses, endActionState: Field(state) }; +} + +let stackProgram = actionStackProgram(100); + +console.time('compile stack prover'); +await stackProgram.compile(); +console.timeEnd('compile stack prover'); + +await describe('action stack prover', async () => { + let startActionState = BatchReducer.initialActionState; + + await it('does 1 action', async () => { + let { witnesses, endActionState } = randomActionWitnesses(1); + + console.time('prove'); + let { isEmpty, proof } = await proveActionStack( + endActionState, + witnesses, + stackProgram + ); + console.timeEnd('prove'); + + expect(isEmpty.toBoolean()).toBe(false); + expect(proof.publicInput).toEqual(endActionState); + expect(proof.publicOutput.actions).toEqual(startActionState); + }); + + await it('does 250 actions', async () => { + let { witnesses, endActionState } = randomActionWitnesses(250); + + console.time('prove'); + let { isEmpty, proof } = await proveActionStack( + endActionState, + witnesses, + stackProgram + ); + console.timeEnd('prove'); + + expect(isEmpty.toBoolean()).toBe(false); + expect(proof.publicInput).toEqual(endActionState); + expect(proof.publicOutput.actions).toEqual(startActionState); + }); +}); diff --git a/src/lib/mina/actions/batch-reducer.ts b/src/lib/mina/actions/batch-reducer.ts new file mode 100644 index 0000000000..b02351c25e --- /dev/null +++ b/src/lib/mina/actions/batch-reducer.ts @@ -0,0 +1,829 @@ +import { Proof, SelfProof } from '../../proof-system/zkprogram.js'; +import { Bool, Field } from '../../provable/wrapped.js'; +import { SmartContract } from '../zkapp.js'; +import { assert, assertDefined } from '../../util/assert.js'; +import { Constructor, From } from '../../../bindings/lib/provable-generic.js'; +import { Struct, InferProvable } from '../../provable/types/struct.js'; +import { Provable } from '../../provable/provable.js'; +import { Actionable } from './offchain-state-serialization.js'; +import { prefixes } from '../../../bindings/crypto/constants.js'; +import { Actions } from '../account-update.js'; +import { contract } from '../smart-contract-context.js'; +import { State } from '../state.js'; +import { Option } from '../../provable/option.js'; +import { PublicKey } from '../../provable/crypto/signature.js'; +import { fetchActions, getProofsEnabled } from '../mina-instance.js'; +import { ZkProgram } from '../../proof-system/zkprogram.js'; +import { Unconstrained } from '../../provable/types/unconstrained.js'; +import { hashWithPrefix as hashWithPrefixBigint } from '../../../mina-signer/src/poseidon-bigint.js'; +import { Actions as ActionsBigint } from '../../../bindings/mina-transaction/transaction-leaves-bigint.js'; +import { + FlatActions, + HashedAction, + MerkleActionHashes, + MerkleActions, + emptyActionState, +} from './action-types.js'; + +// external API +export { BatchReducer, ActionBatch }; + +// internal API +export { actionStackProgram, proveActionStack }; + +/** + * A reducer to process actions in fixed-size batches. + * + * ```ts + * let batchReducer = new BatchReducer({ actionType: Action, batchSize: 5 }); + * + * // in contract: concurrent dispatching of actions + * batchReducer.dispatch(action); + * + * // reducer logic + * // outside contract: prepare a list of { batch, proof } objects which cover all pending actions + * let batches = await batchReducer.prepareBatches(); + * + * // in contract: process a single batch + * // create one transaction that does this for each batch! + * batchReducer.processBatch({ batch, proof }, (action, isDummy) => { + * // ... + * }); + * ``` + */ +class BatchReducer< + ActionType extends Actionable, + BatchSize extends number = number, + Action = InferProvable +> { + batchSize: BatchSize; + actionType: Actionable; + Batch: ReturnType; + + program: ActionStackProgram; + BatchProof: typeof Proof; + + maxUpdatesFinalProof: number; + maxActionsPerUpdate: number; + + constructor({ + actionType, + batchSize, + maxUpdatesPerProof = 300, + maxUpdatesFinalProof = 100, + maxActionsPerUpdate = Math.min(batchSize, 5), + }: { + /** + * The provable type of actions submitted by this reducer. + */ + actionType: ActionType; + + /** + * The number of actions in a batch. The idea is to process one batch per transaction, by calling `processBatch()`. + * + * The motivation for processing actions in small batches is to work around the protocol limit on the number of account updates. + * If every action should result in an account update, then you have to set the batch size low enough to not exceed the limit. + * + * If transaction limits are no concern, the `batchSize` could be set based on amount of logic you do per action. + * A smaller batch size will make proofs faster, but you might need more individual transactions as more batches are needed to process all pending actions. + */ + batchSize: BatchSize; + + /** + * The maximum number of action lists (= all actions on an account update) to process in a single recursive proof, in `prepareBatches()`. + * + * Default: 300, which will take up about 9000 constraints. + * + * The current default should be sensible for most applications, but here are some trade-offs to consider when changing it: + * + * - Using a smaller number means a smaller circuit, so recursive proofs will be faster. + * - Using a bigger number means you'll need fewer recursive proofs in the case a lot of actions are pending. + * + * So, go lower if you expect very few actions, and higher if you expect a lot of actions. + * (Note: A larger circuit causes longer compilation and proof times for your zkApp even if you _never_ need a recursive proof) + */ + maxUpdatesPerProof?: number; + + /** + * The maximum number of action lists (= all actions on an account update) to process inside `processBatch()`, + * i.e. in your zkApp method. + * + * Default: 100, which will take up about 3000 constraints. + * + * The current default should be sensible for most applications, but here are some trade-offs to consider when changing it: + * + * - Using a smaller number means a smaller circuit, so proofs of your method will be faster. + * - Using a bigger number means it's more likely that you can prove _all_ actions in the method call and won't need a recursive proof. + * + * So, go lower if you expect very few actions, and higher if you expect a lot of actions. + */ + maxUpdatesFinalProof?: number; + + /** + * The maximum number of actions dispatched in any of the zkApp methods on the contract. + * + * Note: This number just has to be an upper bound of the actual maximum, but if it's the precise number, + * fewer constraints will be used. (The overhead of a higher number is fairly small though.) + * + * A restriction is that the number has to be less or equal than the `batchSize`. + * The reason is that actions in one account update are always processed together, so if you'd have more actions in one than the batch size, we couldn't process them at all. + * + * By default, this is set to `Math.min(batchSize, 5)` which should be sensible for most applications. + */ + maxActionsPerUpdate?: number; + }) { + this.batchSize = batchSize; + this.actionType = actionType as Actionable; + this.Batch = ActionBatch(this.actionType); + + this.maxUpdatesFinalProof = maxUpdatesFinalProof; + this.program = actionStackProgram(maxUpdatesPerProof); + this.BatchProof = ZkProgram.Proof(this.program); + + assert( + maxActionsPerUpdate <= batchSize, + 'Invalid maxActionsPerUpdate, must be smaller than the batch size because we process entire updates at once.' + ); + this.maxActionsPerUpdate = maxActionsPerUpdate; + } + + static get initialActionState() { + return emptyActionState; + } + static get initialActionStack() { + return emptyActionState; + } + + _contract?: BatchReducerContract; + _contractClass?: BatchReducerContractClass; + + contractClass(): BatchReducerContractClass { + return assertDefined( + this._contractClass, + 'Contract instance or class must be set before calling this method' + ); + } + + contract(): BatchReducerContract { + let Contract = this.contractClass(); + return contract(Contract); + } + + /** + * Set the smart contract instance this reducer is connected with. + * + * Note: This is a required step before using `dispatch()`, `proveNextBatch()` or `processNextBatch()`. + */ + setContractInstance(contract: BatchReducerContract) { + this._contract = contract; + this._contractClass = contract.constructor as BatchReducerContractClass; + } + + /** + * Set the smart contract class this reducer is connected with. + * + * Note: You can use either this method or `setContractInstance()` before calling `compile()`. + * However, `setContractInstance()` is required for `proveNextBatch()`. + */ + setContractClass(contractClass: BatchReducerContractClass) { + this._contractClass = contractClass; + } + + /** + * Submit an action. + */ + dispatch(action: From) { + let update = this.contract().self; + let fields = this.actionType.toFields(this.actionType.fromValue(action)); + update.body.actions = Actions.pushEvent(update.body.actions, fields); + } + + /** + * Conditionally submit an action. + */ + dispatchIf(condition: Bool, action: From) { + let update = this.contract().self; + let fields = this.actionType.toFields(this.actionType.fromValue(action)); + let newActions = Actions.pushEvent(update.body.actions, fields); + update.body.actions = Provable.if( + condition, + Actions, + newActions, + update.body.actions + ); + } + + /** + * Process a batch of actions which was created by `prepareBatches()`. + * + * **Important**: The callback exposes the action's value along with an `isDummy` flag. + * This is necessary because we process a dynamically-sized list in a fixed number of steps. + * Dummies will be passed to your callback once the actual actions are exhausted. + * + * Make sure to write your code to account for dummies. For example, when sending MINA from your contract for every action, + * you probably want to zero out the balance decrease in the `isDummy` case: + * ```ts + * processBatch({ batch, proof }, (action, isDummy) => { + * // ... other logic ... + * + * let amountToSend = Provable.if(isDummy, UInt64.zero, action.amount); + * this.balance.subInPlace(amountToSend); + * }); + * ``` + * + * **Warning**: Don't call `processBatch()` on two _different_ batches within the same method. The second call + * would override the preconditions set by the first call, which would leave the method insecure. + * To process more actions per method call, increase the `batchSize`. + */ + processBatch( + { + batch, + proof, + }: { + batch: ActionBatch; + proof: Proof; + }, + callback: (action: Action, isDummy: Bool, i: number) => void + ): void { + let { actionType, batchSize } = this; + let contract = this.contract(); + + // step 0. validate onchain states + + let { + useOnchainStack, + processedActionState, + onchainActionState, + onchainStack, + } = batch; + let useNewStack = useOnchainStack.not(); + + // we definitely need to know the processed action state, because we will update it + contract.actionState.requireEquals(processedActionState); + + // only require the onchain stack if we use it + contract.actionStack.requireEqualsIf(useOnchainStack, onchainStack); + + // only require the onchain action state if we are recomputing the stack (otherwise, the onchain stack is known to be valid) + contract.account.actionState.requireEqualsIf( + useNewStack, + onchainActionState + ); + + // step 1. continue the proof that pops pending onchain actions to build up the final stack + + let { isRecursive } = batch; + proof.verifyIf(isRecursive); + + // if the proof is valid, it has to start from onchain action state + Provable.assertEqualIf( + isRecursive, + Field, + proof.publicInput, + onchainActionState + ); + + // the final piece of the proof either starts from the onchain action state + an empty stack, + // or from the previous proof output + let initialState = { actions: onchainActionState, stack: emptyActionState }; + let startState = Provable.if( + isRecursive, + ActionStackState, + proof.publicOutput, + initialState + ); + + // finish creating the new stack + let stackingResult = actionStackChunk( + this.maxUpdatesFinalProof, + startState, + batch.witnesses + ); + + // step 2. pick the correct stack of actions to process + + // if we use the new stack, make sure it's correct: it has to go all the way back + // from `onchainActionState` to `processedActionState` + Provable.assertEqualIf( + useNewStack, + Field, + stackingResult.actions, + processedActionState + ); + + let stackToUse = Provable.if( + useOnchainStack, + onchainStack, + stackingResult.stack + ); + + // our input hint gives us the actual actions contained in this stack + let { stack } = batch; + stack = stack.clone(); // defend against this code running twice + stack.hash.assertEquals(stackToUse); + + // invariant: from this point on, the stack contains actual pending action lists in their correct (reversed) order + + // step 3. pop off the actions we want to process from the stack + + // we should take as many actions as possible, within the constraints that: + // - we process entire lists (= account updates) at once + // - we process at most `this.batchSize` actions + // - we can't process more than the stack contains + let nActionLists = Unconstrained.witness(() => { + let lists = stack.toArrayUnconstrained().get(); + let n = 0; + let totalSize = 0; + for (let list of lists.reverse()) { + totalSize += list.lengthUnconstrained().get(); + if (totalSize > batchSize) break; + n++; + } + return n; + }); + + // linearize the stack into a flat list which contains exactly the actions we process + let flatActions = FlatActions(actionType).empty(); + + for (let i = 0; i < batchSize; i++) { + // note: we allow the prover to pop off as many actions as they want (up to `batchSize`) + // if they pop off less than possible, it doesn't violate our invariant that the stack contains pending actions in correct order + let shouldPop = Provable.witness(Bool, () => i < nActionLists.get()); + let actionList = stack.popIfUnsafe(shouldPop); + + // if we didn't pop, must guarantee that the action list is empty + actionList = Provable.if( + shouldPop, + stack.innerProvable, + actionList, + stack.innerProvable.empty() + ); + + // push all actions to the flat list + actionList.forEach(this.maxActionsPerUpdate, (action, isDummy) => { + flatActions.pushIf(isDummy.not(), action); + }); + + // if we pop, we also update the processed action state + let nextActionState = Actions.updateSequenceState( + processedActionState, + actionList.hash + ); + processedActionState = Provable.if( + shouldPop, + nextActionState, + processedActionState + ); + } + + // step 4. run user logic on the actions + + const HashedActionT = HashedAction(actionType); + const emptyHashedAction = HashedActionT.empty(); + + flatActions.forEach(batchSize, (hashedAction, isDummy, i) => { + // we make it easier to write the reducer code by making sure dummy actions have dummy values + hashedAction = Provable.if( + isDummy, + HashedActionT.provable, + emptyHashedAction, + hashedAction + ); + + // note: only here, we do the work of unhashing the action + callback(hashedAction.unhash(), isDummy, i); + }); + + // step 5. update the onchain processed action state and stack + + contract.actionState.set(processedActionState); + contract.actionStack.set(stack.hash); + } + + /** + * Compile the recursive action stack prover. + */ + async compile() { + return await this.program.compile(); + } + + /** + * Create a proof which returns the next actions batch(es) to process and helps guarantee their correctness. + */ + async prepareBatches(): Promise< + { proof: ActionStackProof; batch: ActionBatch }[] + > { + let { batchSize, actionType } = this; + let contract = assertDefined( + this._contract, + 'Contract instance must be set before proving actions' + ); + let fromActionState = assertDefined( + await contract.actionState.fetch(), + 'Could not fetch action state' + ).toBigInt(); + + // TODO witnesses is just a dumbed down representation of `actions`, we could compute them from actions + let { endActionState, witnesses, actions } = await fetchActionWitnesses( + contract, + fromActionState, + this.actionType + ); + + // if there are no pending actions, there is no need to call the reducer + if (witnesses.length === 0) return []; + + let { proof, isRecursive, finalWitnesses } = await provePartialActionStack( + endActionState, + witnesses, + this.program, + this.maxUpdatesFinalProof + ); + + // create the stack from full actions + let stack = MerkleActions(actionType).fromReverse( + actions.toArrayUnconstrained().get() + ); + + let batches: ActionBatch[] = []; + let baseHint = { + isRecursive, + onchainActionState: Field(endActionState), + witnesses: finalWitnesses, + }; + + // for the remaining batches, trace the steps of the zkapp method + // in updating processedActionState, stack, onchainStack + let stackArray = stack.toArrayUnconstrained().get(); + let processedActionState = Field(fromActionState); + let onchainStack = Field(0); // incorrect, but not used in the first batch + let useOnchainStack = Bool(false); + let i = stackArray.length - 1; + + // add batches as long as we haven't emptied the stack + while (i >= 0) { + batches.push({ + ...baseHint, + useOnchainStack, + processedActionState, + onchainStack, + stack: stack.clone(), + }); + + // pop off actions as long as we can fit them in a batch + let currentBatchSize = 0; + while (i >= 0) { + currentBatchSize += stackArray[i].lengthUnconstrained().get(); + if (currentBatchSize > batchSize) break; + let actionList = stack.pop(); + processedActionState = Actions.updateSequenceState( + processedActionState, + actionList.hash + ); + i--; + } + onchainStack = stack.hash; + useOnchainStack = Bool(true); + } + + // sanity check: we should have put all actions in batches + stack.isEmpty().assertTrue(); + + return batches.map((batch) => ({ proof, batch })); + } +} + +type BatchReducerContract = SmartContract & { + reducer?: undefined; + actionState: State; + actionStack: State; +}; +type BatchReducerContractClass = typeof SmartContract & + Constructor; + +// hints for the batch reducer + +/** + * Inputs to a single call of `processBatch()`. + * + * `proveBatches()` will prepare as many of these as we need to catch up with the chain. + */ +type ActionBatch = { + /** + * Whether to use the onchain stack or the new one we compute. + */ + useOnchainStack: Bool; + + /** + * Current onchain fields, kept track of externally for robustness. + * + * Note: + * - If `useOnchainStack = true`, the `onchainActionState` doesn't have to be correct (we only need it to prove validity of a new stack). + * - If `useOnchainStack = false`, the `onchainStack` doesn't have to be correct as we don't use it. + */ + processedActionState: Field; + onchainActionState: Field; + onchainStack: Field; + + /** + * The stack of actions to process. + * + * Note: this is either the current onchain stack or the new stack, + witnesses which contain the actual actions. + */ + stack: MerkleActions; + + /** + * Whether a recursive proof was needed to compute the stack, or not. + */ + isRecursive: Bool; + + /** + * Witnesses needed to finalize the stack computation. + */ + witnesses: Unconstrained; +}; + +function ActionBatch>(actionType: A) { + return Struct({ + useOnchainStack: Bool, + processedActionState: Field, + onchainActionState: Field, + onchainStack: Field, + stack: MerkleActions(actionType).provable, + isRecursive: Bool, + witnesses: Unconstrained.provableWithEmpty([]), + }); +} + +// helper for fetching actions + +async function fetchActionWitnesses( + contract: { address: PublicKey; tokenId: Field }, + fromActionState: bigint, + actionType: Actionable +) { + let result = await fetchActions( + contract.address, + { fromActionState: Field(fromActionState) }, + contract.tokenId + ); + if ('error' in result) throw Error(JSON.stringify(result)); + + let actionFields = result.map(({ actions }) => + actions.map((action) => action.map(BigInt)).reverse() + ); + let actions = MerkleActions.fromFields( + actionType, + actionFields, + fromActionState + ); + + let actionState = fromActionState; + let witnesses: ActionWitnesses = []; + + let hashes = actionFields.map((actions) => + actions.reduce(pushAction, ActionsBigint.empty().hash) + ); + for (let actionsHash of hashes) { + witnesses.push({ hash: actionsHash, stateBefore: actionState }); + actionState = ActionsBigint.updateSequenceState(actionState, actionsHash); + } + return { endActionState: actionState, witnesses, actions }; +} + +function pushAction(actionsHash: bigint, action: bigint[]): bigint { + return hashWithPrefixBigint(prefixes.sequenceEvents, [ + actionsHash, + hashWithPrefixBigint(prefixes.event, action), + ]); +} + +// recursive action stacking proof + +/** + * Prove that a list of actions can be stacked in reverse order. + * + * Does not process reversing of all input actions - instead, we leave a final chunk of actions unprocessed. + * The final chunk will be done in the smart contract which also verifies the proof. + */ +async function provePartialActionStack( + endActionState: bigint, + witnesses: ActionWitnesses, + program: ActionStackProgram, + finalChunkSize: number +) { + let finalActionsChunk = witnesses.slice(0, finalChunkSize); + let remainingActions = witnesses.slice(finalChunkSize); + + let { isEmpty, proof } = await proveActionStack( + endActionState, + remainingActions, + program + ); + return { + proof, + isRecursive: isEmpty.not(), + finalWitnesses: Unconstrained.from(finalActionsChunk), + }; +} + +async function proveActionStack( + endActionState: bigint | Field, + actions: ActionWitnesses, + program: ActionStackProgram +): Promise<{ + isEmpty: Bool; + proof: ActionStackProof; +}> { + endActionState = Field(endActionState); + let { maxUpdatesPerProof } = program; + const ActionStackProof = ZkProgram.Proof(program); + + let n = actions.length; + let isEmpty = Bool(n === 0); + + // compute the final stack up front: actions in reverse order + let stack = MerkleActionHashes().empty(); + for (let action of [...actions].reverse()) { + if (action === undefined) continue; + stack.push(Field(action.hash)); + } + + // if proofs are disabled, return a dummy proof + if (!getProofsEnabled()) { + let startActionState = actions[0]?.stateBefore ?? endActionState; + let proof = await ActionStackProof.dummy( + endActionState, + { actions: Field(startActionState), stack: stack.hash }, + 1, + 14 + ); + return { isEmpty, proof }; + } + + // split actions in chunks of `maxUpdatesPerProof` each + let chunks: Unconstrained[] = []; + let nChunks = Math.ceil(n / maxUpdatesPerProof); + + for (let i = 0, k = 0; i < nChunks; i++) { + let batch: ActionWitnesses = []; + for (let j = 0; j < maxUpdatesPerProof; j++, k++) { + batch[j] = actions[k]; + } + chunks[i] = Unconstrained.from(batch); + } + + // dummy proof; will be returned if there are no actions + let proof = await ActionStackProof.dummy( + Field(0), + { actions: emptyActionState, stack: emptyActionState }, + 1, + 14 + ); + + for (let i = nChunks - 1; i >= 0; i--) { + let isRecursive = Bool(i < nChunks - 1); + proof = await program.proveChunk( + endActionState, + proof, + isRecursive, + chunks[i] + ); + } + // sanity check + proof.publicOutput.stack.assertEquals(stack.hash, 'Stack hash mismatch'); + + return { isEmpty, proof }; +} + +/** + * Intermediate result of popping from a list of actions and stacking them in reverse order. + */ +class ActionStackState extends Struct({ + actions: Field, + stack: Field, +}) {} + +type ActionStackProof = Proof; +type ActionWitnesses = ({ hash: bigint; stateBefore: bigint } | undefined)[]; + +class OptionActionWitness extends Option( + Struct({ hash: Field, stateBefore: Field }) +) {} + +type ActionStackProgram = { + name: string; + publicInputType: typeof Field; + publicOutputType: typeof ActionStackState; + + compile(): Promise<{ verificationKey: { data: string; hash: Field } }>; + + proveChunk( + input: Field, + proofSoFar: ActionStackProof, + isRecursive: Bool, + actionWitnesses: Unconstrained + ): Promise; + + maxUpdatesPerProof: number; +}; + +/** + * Process a chunk of size `maxUpdatesPerProof` from the input actions, + * stack them in reverse order. + */ +function actionStackChunk( + maxUpdatesPerProof: number, + startState: ActionStackState, + witnesses: Unconstrained +): ActionStackState { + // we pop off actions from the input merkle list (= input.actions + actionHashes), + // and push them onto a new merkle list + let stack = MerkleActionHashes(startState.stack).empty(); + let actions = startState.actions; + + for (let i = maxUpdatesPerProof - 1; i >= 0; i--) { + let { didPop, state, hash } = pop(actions, i, witnesses); + stack.pushIf(didPop, hash); + actions = state; + } + + return new ActionStackState({ actions, stack: stack.hash }); +} + +/** + * Create program that pops actions from a hash list and pushes them to a new list in reverse order. + */ +function actionStackProgram(maxUpdatesPerProof: number) { + let program = ZkProgram({ + name: 'action-stack-prover', + + // input: actions to pop from + publicInput: Field, + + // output: actions after popping, and the new stack + publicOutput: ActionStackState, + + methods: { + proveChunk: { + privateInputs: [ + SelfProof, + Bool, + Unconstrained.provableWithEmpty([]), + ], + + async method( + input: Field, + proofSoFar: ActionStackProof, + isRecursive: Bool, + witnesses: Unconstrained + ): Promise { + // make this proof extend proofSoFar + proofSoFar.verifyIf(isRecursive); + Provable.assertEqualIf( + isRecursive, + Field, + input, + proofSoFar.publicInput + ); + let initialState = { actions: input, stack: emptyActionState }; + let startState = Provable.if( + isRecursive, + ActionStackState, + proofSoFar.publicOutput, + initialState + ); + + return actionStackChunk(maxUpdatesPerProof, startState, witnesses); + }, + }, + }, + }); + return Object.assign(program, { maxUpdatesPerProof }); +} + +/** + * Proves: "Here are some actions that got me from the new state to the current state" + * + * Can also return a None option if there are no actions or the prover chooses to skip popping an action. + */ +function pop( + state: Field, + i: number, + witnesses: Unconstrained +): { didPop: Bool; state: Field; hash: Field } { + let { isSome, value: witness } = Provable.witness( + OptionActionWitness, + () => witnesses.get()[i] + ); + let impliedState = Actions.updateSequenceState( + witness.stateBefore, + witness.hash + ); + Provable.assertEqualIf(isSome, Field, impliedState, state); + return { + didPop: isSome, + state: Provable.if(isSome, witness.stateBefore, state), + hash: witness.hash, + }; +} diff --git a/src/lib/mina/actions/batch-reducer.unit-test.ts b/src/lib/mina/actions/batch-reducer.unit-test.ts new file mode 100644 index 0000000000..98b35e3bb3 --- /dev/null +++ b/src/lib/mina/actions/batch-reducer.unit-test.ts @@ -0,0 +1,259 @@ +/** + * Example implementation of a MINA airdrop that allows concurrent withdrawals. + */ +import { + AccountUpdate, + Bool, + Experimental, + Field, + method, + Poseidon, + Provable, + PublicKey, + SmartContract, + State, + state, + UInt64, + assert, +} from '../../../index.js'; +import { + TestInstruction, + expectBalance, + testLocal, + transaction, +} from '../test/test-contract.js'; +const { IndexedMerkleMap, BatchReducer } = Experimental; + +const MINA = 1_000_000_000n; +const AMOUNT = 10n * MINA; + +class MerkleMap extends IndexedMerkleMap(10) {} + +// set up reducer +let batchReducer = new BatchReducer({ + actionType: PublicKey, + + // artificially low batch size to test batch splitting more easily + batchSize: 3, + + // artificially low max pending action lists we process per proof, to test recursive proofs + // the default is 100 in the final (zkApp) proof, and 300 per recursive proof + // these could be set even higher (at the cost of larger proof times in the case of few actions) + maxUpdatesFinalProof: 4, + maxUpdatesPerProof: 4, +}); +class Batch extends batchReducer.Batch {} +class BatchProof extends batchReducer.BatchProof {} + +/** + * Contract that manages airdrop claims. + * + * WARNING: This airdrop design is UNSAFE against attacks by users that set their permissions such that sending them MINA is impossible. + * A single such user which claims the airdrop will cause the reducer to be deadlocked forever. + * Workarounds exist but they require too much code which this example is not about. + * + * THIS IS JUST FOR TESTING. BE CAREFUL ABOUT REDUCER DEADLOCKS IN PRODUCTION CODE! + */ +class UnsafeAirdrop extends SmartContract { + // Batch reducer related + @state(Field) + actionState = State(BatchReducer.initialActionState); + @state(Field) + actionStack = State(BatchReducer.initialActionStack); + + // Merkle map related + @state(Field) + eligibleRoot = State(eligible.root); + @state(Field) + eligibleLength = State(eligible.length); + + /** + * Claim an airdrop. + */ + @method + async claim() { + let address = this.sender.getUnconstrained(); + + // ensure that the MINA account already exists and that the sender knows its private key + let au = AccountUpdate.createSigned(address); + au.body.useFullCommitment = Bool(true); // ensures the signature attests to the entire transaction + + batchReducer.dispatch(address); + } + + /** + * Go through pending claims and pay them out. + * + * Note: This two-step process is necessary so that multiple users can claim concurrently. + */ + @method.returns(MerkleMap.provable) + async settleClaims(batch: Batch, proof: BatchProof) { + // witness merkle map and require that it matches the onchain root + let eligibleMap = Provable.witness(MerkleMap.provable, () => + eligible.clone() + ); + this.eligibleRoot.requireEquals(eligibleMap.root); + this.eligibleLength.requireEquals(eligibleMap.length); + + // process claims by reducing actions + batchReducer.processBatch({ batch, proof }, (address, isDummy) => { + // check whether the claim is valid = exactly contained in the map + let addressKey = key(address); + let isValidField = eligibleMap.getOption(addressKey).orElse(0n); + isValidField = Provable.if(isDummy, Field(0), isValidField); + let isValid = Bool.Unsafe.fromField(isValidField); // not unsafe, because only bools can be put in the map + + // if the claim is valid, zero out the account in the map + eligibleMap.setIf(isValid, addressKey, 0n); + + // if the claim is valid, send 100 MINA to the account + let amount = Provable.if(isValid, UInt64.from(AMOUNT), UInt64.zero); + let update = AccountUpdate.createIf(isValid, address); + update.balance.addInPlace(amount); + this.balance.subInPlace(amount); + }); + + // update the onchain root and action state pointer + this.eligibleRoot.set(eligibleMap.root); + this.eligibleLength.set(eligibleMap.length); + + // return the updated eligible map + return eligibleMap; + } +} + +/** + * How to map an address to a map key. + */ +function key(address: PublicKey) { + return Poseidon.hash(address.toFields()); +} + +// TEST BELOW + +const eligible = new MerkleMap(); + +await testLocal( + UnsafeAirdrop, + { proofsEnabled: 'both', batchReducer }, + ({ + contract, + accounts: { sender }, + newAccounts: { alice, bob, charlie, danny, eve }, + Local, + }): TestInstruction[] => { + // create a new map of accounts that are eligible for the airdrop + eligible.overwrite(new MerkleMap()); + + // for every eligible account, we store 1 in the map, representing TRUE + // eve is not eligible, the others are + [alice, bob, charlie, danny].forEach((address) => + eligible.insert(key(address), 1n) + ); + let newEligible = eligible; // for tracking updates to the eligible map + + return [ + // preparation: sender funds the contract with 100 MINA + transaction('fund contract', async () => { + AccountUpdate.createSigned(sender).send({ + to: contract.address, + amount: 100n * MINA, + }); + }), + + // preparation: create user accounts + transaction('create accounts', async () => { + for (let address of [alice, bob, charlie, danny, eve]) { + AccountUpdate.create(address); // empty account update causes account creation + } + AccountUpdate.fundNewAccount(sender, 5); + }), + + // first 4 accounts claim + // (we skip proofs here because they're not interesting for this test) + () => Local.setProofsEnabled(false), + transaction.from(alice)('alice claims', () => contract.claim()), + transaction.from(bob)('bob claims', () => contract.claim()), + transaction.from(eve)('eve claims', () => contract.claim()), + transaction.from(charlie)('charlie claims', () => contract.claim()), + () => Local.resetProofsEnabled(), + + // settle claims, 1 + async () => { + let batches = await batchReducer.prepareBatches(); + + // should cause 2 batches because we have 4 claims and batchSize is 3 + assert(batches.length === 2, 'two batches'); + + // should not cause a recursive proof because onchain action processing was set to handle 4 actions + assert( + batches[0].batch.isRecursive.toBoolean() === false, + 'not recursive' + ); + + return batches.flatMap(({ batch, proof }, i) => [ + // we create one transaction for each batch + transaction(`settle claims 1-${i}`, async () => { + newEligible = await contract.settleClaims(batch, proof); + }), + // after each transaction, we update our local merkle map + () => eligible.overwrite(newEligible), + ]); + }, + + expectBalance(alice, AMOUNT), + expectBalance(bob, AMOUNT), + expectBalance(eve, 0n), // eve was not eligible + expectBalance(charlie, AMOUNT), + expectBalance(danny, 0n), // danny didn't claim yet + + // more claims + final settling + // we submit the same claim 9 times to cause 2 recursive proofs + // and to check that double claims are rejected + () => Local.setProofsEnabled(false), + ...Array.from({ length: 9 }, () => + transaction.from(danny)('danny claims 9x', () => contract.claim()) + ), + () => Local.resetProofsEnabled(), + + // settle claims, 2 + async () => { + console.time('recursive batch proof 2x'); + let batches = await batchReducer.prepareBatches(); + console.timeEnd('recursive batch proof 2x'); + + // should cause 9/3 === 3 batches + assert(batches.length === 3, 'three batches'); + + // should have caused a recursive proof (2 actually) because ceil(9/4) = 3 proofs are needed (one of them done as part of the zkApp) + assert( + batches[0].batch.isRecursive.toBoolean() === true, + 'is recursive' + ); + + return batches.flatMap(({ batch, proof }, i) => [ + // we create one transaction for each batch + transaction(`settle claims 2-${i}`, async () => { + newEligible = await contract.settleClaims(batch, proof); + }), + // after each transaction, we update our local merkle map + () => eligible.overwrite(newEligible), + ]); + }, + + expectBalance(alice, AMOUNT), + expectBalance(bob, AMOUNT), + expectBalance(eve, 0n), + expectBalance(charlie, AMOUNT), + expectBalance(danny, AMOUNT), // only danny's first claim was fulfilled + + // no more claims to settle + async () => { + let batches = await batchReducer.prepareBatches(); + + // sanity check that batchReducer doesn't create transactions if there is nothing to reduce + assert(batches.length === 0, 'no more claims to settle'); + }, + ]; + } +); diff --git a/src/lib/mina/actions/offchain-contract.unit-test.ts b/src/lib/mina/actions/offchain-contract.unit-test.ts index 0398a14ee9..f5516b8f3c 100644 --- a/src/lib/mina/actions/offchain-contract.unit-test.ts +++ b/src/lib/mina/actions/offchain-contract.unit-test.ts @@ -1,15 +1,12 @@ import { SmartContract, method, - Mina, state, PublicKey, UInt64, Experimental, } from '../../../index.js'; -import assert from 'assert'; - -const proofsEnabled = true; +import { expectState, testLocal, transaction } from '../test/test-contract.js'; const { OffchainState } = Experimental; @@ -86,135 +83,73 @@ class ExampleContract extends SmartContract { } } -// 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); +// connect contract to offchain state +offchainState.setContractClass(ExampleContract); -if (proofsEnabled) { - console.time('compile program'); - await offchainState.compile(); - console.timeEnd('compile program'); - console.time('compile contract'); - await ExampleContract.compile(); - console.timeEnd('compile contract'); -} +// test code below -// 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); -} +await testLocal( + ExampleContract, + { proofsEnabled: true, offchainState }, + ({ accounts: { sender, receiver, other }, contract, Local }) => [ + // create first account + transaction('create account', async () => { + // first call (should succeed) + await contract.createAccount(sender, UInt64.from(1000)); + + // second call (should fail) + await contract.createAccount(sender, UInt64.from(2000)); + }), + + // settle + async () => { + console.time('settlement proof 1'); + let proof = await offchainState.createSettlementProof(); + console.timeEnd('settlement proof 1'); + + return transaction('settle 1', () => contract.settle(proof)); + }, + + // check balance and supply + expectState(offchainState.fields.totalSupply, 1000n), + expectState(offchainState.fields.accounts, [sender, 1000n]), + expectState(offchainState.fields.accounts, [receiver, undefined]), + + // transfer (should succeed) + transaction('transfer', () => + contract.transfer(sender, receiver, UInt64.from(100)) + ), + + // we run some calls without proofs to save time + () => Local.setProofsEnabled(false), + + // more transfers that should fail + transaction('more transfers', async () => { + // (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)); + }), + + // settle + async () => { + Local.resetProofsEnabled(); + console.time('settlement proof 2'); + let proof = await offchainState.createSettlementProof(); + console.timeEnd('settlement proof 2'); + + return transaction('settle 2', () => contract.settle(proof)); + }, + + // check balance and supply + expectState(offchainState.fields.totalSupply, 1555n), + expectState(offchainState.fields.accounts, [sender, 900n]), + expectState(offchainState.fields.accounts, [receiver, 100n]), + ] +); diff --git a/src/lib/mina/actions/offchain-state-rollup.ts b/src/lib/mina/actions/offchain-state-rollup.ts index c64e0a789d..133d5d75bb 100644 --- a/src/lib/mina/actions/offchain-state-rollup.ts +++ b/src/lib/mina/actions/offchain-state-rollup.ts @@ -40,14 +40,16 @@ class ActionIterator extends MerkleListIterator.create( class OffchainStateCommitments extends Struct({ // this should just be a MerkleTree type that carries the full tree as aux data root: Field, + length: Field, // TODO: make zkprogram support auxiliary data in public inputs // actionState: ActionIterator.provable, actionState: Field, }) { static emptyFromHeight(height: number) { - let emptyMerkleRoot = new (IndexedMerkleMap(height))().root; + let emptyMerkleTree = new (IndexedMerkleMap(height))(); return new OffchainStateCommitments({ - root: emptyMerkleRoot, + root: emptyMerkleTree.root, + length: emptyMerkleTree.length, actionState: Actions.emptyActionState(), }); } @@ -97,9 +99,10 @@ function merkleUpdateBatch( } actions.assertAtEnd(); - // tree must match the public Merkle root; the method operates on the tree internally + // tree must match the public Merkle root and length; the method operates on the tree internally // TODO: this would be simpler if the tree was the public input directly stateA.root.assertEquals(tree.root); + stateA.length.assertEquals(tree.length); let intermediateTree = tree.clone(); let isValidUpdate = Bool(true); @@ -108,13 +111,8 @@ function merkleUpdateBatch( let { action, isCheckPoint } = element; let { key, value, usesPreviousValue, previousValue } = action; - // make sure that if this is a dummy action, we use the canonical dummy (key, value) pair - key = Provable.if(isDummy, Field(0n), key); - value = Provable.if(isDummy, Field(0n), value); - - // set (key, value) in the intermediate tree - // note: this just works if (key, value) is a (0,0) dummy, because the value at the 0 key will always be 0 - let actualPreviousValue = intermediateTree.set(key, value); + // set (key, value) in the intermediate tree - if the action is not a dummy + let actualPreviousValue = intermediateTree.setIf(isDummy.not(), key, value); // if an expected previous value was provided, check whether it matches the actual previous value // otherwise, the entire update in invalidated @@ -132,7 +130,11 @@ function merkleUpdateBatch( intermediateTree.overwriteIf(isCheckPoint, tree); }); - return { root: tree.root, actionState: actions.currentHash }; + return { + root: tree.root, + length: tree.length, + actionState: actions.currentHash, + }; } /** @@ -253,6 +255,7 @@ function OffchainStateRollup({ let iterator = actions.startIterating(); let inputState = new OffchainStateCommitments({ root: tree.root, + length: tree.length, actionState: iterator.currentHash, }); @@ -276,6 +279,7 @@ function OffchainStateRollup({ let finalState = new OffchainStateCommitments({ root: tree.root, + length: tree.length, actionState: iterator.hash, }); let proof = await RollupProof.dummy(inputState, finalState, 2, 15); @@ -289,7 +293,7 @@ function OffchainStateRollup({ // update tree root/length again, they aren't mutated :( // TODO: this shows why the full tree should be the public output tree.root = proof.publicOutput.root; - tree.length = Field(tree.data.get().sortedLeaves.length); + tree.length = proof.publicOutput.length; // recursive proofs let nProofs = 1; @@ -307,7 +311,7 @@ function OffchainStateRollup({ // update tree root/length again, they aren't mutated :( tree.root = proof.publicOutput.root; - tree.length = Field(tree.data.get().sortedLeaves.length); + tree.length = proof.publicOutput.length; } return { proof, tree, nProofs }; diff --git a/src/lib/mina/actions/offchain-state.ts b/src/lib/mina/actions/offchain-state.ts index 18a5064415..56a1d0d1ff 100644 --- a/src/lib/mina/actions/offchain-state.ts +++ b/src/lib/mina/actions/offchain-state.ts @@ -14,18 +14,26 @@ import { OffchainStateRollup, } from './offchain-state-rollup.js'; import { Option, OptionOrValue } from '../../provable/option.js'; -import { InferValue } from '../../../bindings/lib/provable-generic.js'; +import { + Constructor, + 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 { contract } from '../smart-contract-context.js'; import { IndexedMerkleMap } from '../../provable/merkle-tree-indexed.js'; +import { assertDefined } from '../../util/assert.js'; +// external API export { OffchainState, OffchainStateCommitments }; +// internal API +export { OffchainField, OffchainMap }; + type OffchainState = { /** * The individual fields of the offchain state. @@ -33,7 +41,7 @@ type OffchainState = { * ```ts * const state = OffchainState({ totalSupply: OffchainState.Field(UInt64) }); * - * state.fields.totalSupply.set(UInt64.from(100)); + * state.fields.totalSupply.overwrite(UInt64.from(100)); * * let supply = await state.fields.totalSupply.get(); * ``` @@ -47,9 +55,15 @@ type OffchainState = { * * 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; + setContractInstance(contract: OffchainStateContract): void; + + /** + * Set the smart contract class that this offchain state is connected with. + * + * This is an alternative for `setContractInstance()` which lets you compile offchain state without having a contract instance. + * However, you must call `setContractInstance()` before calling `createSettlementProof()`. + */ + setContractClass(contract: OffchainStateContractClass): void; /** * Compile the offchain state ZkProgram. @@ -102,6 +116,8 @@ type OffchainState = { type OffchainStateContract = SmartContract & { offchainState: State; }; +type OffchainStateContractClass = typeof SmartContract & + Constructor; /** * Offchain state for a `SmartContract`. @@ -176,15 +192,15 @@ function OffchainState< // setup internal state of this "class" let internal = { _contract: undefined as OffchainStateContract | undefined, + _contractClass: undefined as OffchainStateContractClass | undefined, _merkleMap: undefined as IndexedMerkleMapN | undefined, _valueMap: undefined as Map | undefined, get contract() { - assert( - internal._contract !== undefined, + return assertDefined( + internal._contract, 'Must call `setContractInstance()` first' ); - return internal._contract; }, }; const onchainActionState = async () => { @@ -215,17 +231,20 @@ function OffchainState< maxActionsPerUpdate, }); - 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' + function getContract(): OffchainStateContract { + let Contract = assertDefined( + internal._contractClass, + 'Must call `setContractInstance()` or `setContractClass()` first' ); - return ctx.this as OffchainStateContract; + return contract(Contract); + } + + function maybeContract() { + try { + return getContract(); + } catch { + return internal.contract; + } } /** @@ -233,14 +252,15 @@ function OffchainState< */ async function get(key: Field, valueType: Actionable) { // get onchain merkle root - let stateRoot = contract().offchainState.getAndRequireEquals().root; + let state = maybeContract().offchainState.getAndRequireEquals(); // witness the merkle map & anchor against the onchain root let map = await Provable.witnessAsync( IndexedMerkleMapN.provable, async () => (await merkleMaps()).merkleMap ); - map.root.assertEquals(stateRoot, 'root mismatch'); + map.root.assertEquals(state.root, 'root mismatch'); + map.length.assertEquals(state.length, 'length mismatch'); // get the value hash let valueHash = map.getOption(key); @@ -275,6 +295,8 @@ function OffchainState< let optionType = Option(type); return { + _type: type, + overwrite(value) { // serialize into action let action = toAction({ @@ -286,7 +308,7 @@ function OffchainState< }); // push action on account update - let update = contract().self; + let update = getContract().self; update.body.actions = Actions.pushEvent(update.body.actions, action); }, @@ -302,7 +324,7 @@ function OffchainState< }); // push action on account update - let update = contract().self; + let update = getContract().self; update.body.actions = Actions.pushEvent(update.body.actions, action); }, @@ -322,6 +344,9 @@ function OffchainState< let optionType = Option(valueType); return { + _keyType: keyType, + _valueType: valueType, + overwrite(key, value) { // serialize into action let action = toAction({ @@ -333,7 +358,7 @@ function OffchainState< }); // push action on account update - let update = contract().self; + let update = getContract().self; update.body.actions = Actions.pushEvent(update.body.actions, action); }, @@ -349,7 +374,7 @@ function OffchainState< }); // push action on account update - let update = contract().self; + let update = getContract().self; update.body.actions = Actions.pushEvent(update.body.actions, action); }, @@ -363,6 +388,11 @@ function OffchainState< return { setContractInstance(contract) { internal._contract = contract; + internal._contractClass = + contract.constructor as OffchainStateContractClass; + }, + setContractClass(contractClass) { + internal._contractClass = contractClass; }, async compile() { @@ -399,16 +429,16 @@ function OffchainState< proof.verify(); // check that proof moves state forward from the one currently stored - let state = contract().offchainState.getAndRequireEquals(); + let state = getContract().offchainState.getAndRequireEquals(); Provable.assertEqual(OffchainStateCommitments, state, proof.publicInput); // require that proof uses the correct pending actions - contract().account.actionState.requireEquals( + getContract().account.actionState.requireEquals( proof.publicOutput.actionState ); // update the state - contract().offchainState.set(proof.publicOutput); + getContract().offchainState.set(proof.publicOutput); }, fields: Object.fromEntries( @@ -438,6 +468,8 @@ function OffchainField(type: T) { return { kind: 'offchain-field' as const, type }; } type OffchainField = { + _type: Provable; + /** * Get the value of the field, or none if it doesn't exist yet. */ @@ -467,6 +499,9 @@ function OffchainMap(key: K, value: V) { return { kind: 'offchain-map' as const, keyType: key, valueType: value }; } type OffchainMap = { + _keyType: Provable; + _valueType: Provable; + /** * Get the value for this key, or none if it doesn't exist. */ diff --git a/src/lib/mina/actions/reducer.ts b/src/lib/mina/actions/reducer.ts index 8be11506fb..0118e0028e 100644 --- a/src/lib/mina/actions/reducer.ts +++ b/src/lib/mina/actions/reducer.ts @@ -65,6 +65,9 @@ type ReducerReturn = { * ); * ``` * + * Warning: The reducer API in o1js is currently not safe to use in production applications. The `reduce()` + * method breaks if more than the hard-coded number (default: 32) of actions are pending. Work is actively + * in progress to mitigate this limitation. */ reduce( actions: MerkleList>, diff --git a/src/lib/mina/local-blockchain.ts b/src/lib/mina/local-blockchain.ts index 7c7eacbc30..2a80874c32 100644 --- a/src/lib/mina/local-blockchain.ts +++ b/src/lib/mina/local-blockchain.ts @@ -60,6 +60,10 @@ namespace TestPublicKey { TestPublicKey(PrivateKey.random()) ) as never; } + + export function fromBase58(base58: string): TestPublicKey { + return TestPublicKey(PrivateKey.fromBase58(base58)); + } } /** @@ -99,6 +103,7 @@ async function LocalBlockchain({ string, Record > = {}; + const originalProofsEnabled = proofsEnabled; return { getNetworkId: () => 'testnet' as NetworkId, @@ -419,6 +424,9 @@ async function LocalBlockchain({ setProofsEnabled(newProofsEnabled: boolean) { this.proofsEnabled = newProofsEnabled; }, + resetProofsEnabled() { + this.proofsEnabled = originalProofsEnabled; + }, }; } // assert type compatibility without preventing LocalBlockchain to return additional properties / methods diff --git a/src/lib/mina/precondition.ts b/src/lib/mina/precondition.ts index f276748493..78e0d2bc66 100644 --- a/src/lib/mina/precondition.ts +++ b/src/lib/mina/precondition.ts @@ -15,12 +15,14 @@ import { } from '../provable/crypto/poseidon.js'; import { PublicKey } from '../provable/crypto/signature.js'; import { + ActionState, Actions, 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'; +import { assertInternal } from '../util/errors.js'; export { preconditions, @@ -180,6 +182,15 @@ function Network(accountUpdate: AccountUpdate): Network { ); return network.globalSlotSinceGenesis.requireEquals(slot); }, + requireEqualsIf(condition: Bool, value: UInt64) { + let { genesisTimestamp, slotTime } = Mina.getNetworkConstants(); + let slot = timestampToGlobalSlot( + value, + `Timestamp precondition unsatisfied: the timestamp can only equal numbers of the form ${genesisTimestamp} + k*${slotTime},\n` + + `i.e., the genesis timestamp plus an integer number of slots.` + ); + return network.globalSlotSinceGenesis.requireEqualsIf(condition, slot); + }, requireBetween(lower: UInt64, upper: UInt64) { let [slotLower, slotUpper] = timestampToGlobalSlotRange(lower, upper); return network.globalSlotSinceGenesis.requireBetween( @@ -255,8 +266,17 @@ let unimplementedPreconditions: LongKey[] = [ 'network.nextEpochData.seed', ]; -type BaseType = 'UInt64' | 'UInt32' | 'Field' | 'Bool' | 'PublicKey'; -let baseMap = { UInt64, UInt32, Field, Bool, PublicKey }; +let baseMap = { UInt64, UInt32, Field, Bool, PublicKey, ActionState }; + +function getProvableType(layout: { type: string; checkedTypeName?: string }) { + let typeName = layout.checkedTypeName ?? layout.type; + let type = baseMap[typeName as keyof typeof baseMap]; + assertInternal( + type !== undefined, + `Unknown precondition base type ${typeName}` + ); + return type; +} function preconditionClass( layout: Layout, @@ -267,24 +287,18 @@ function preconditionClass( if (layout.type === 'option') { // range condition if (layout.optionType === 'closedInterval') { - let lower = layout.inner.entries.lower.type as BaseType; - let baseType = baseMap[lower]; + let baseType = getProvableType(layout.inner.entries.lower); return preconditionSubClassWithRange( accountUpdate, baseKey, - baseType as any, + baseType, context ); } // value condition else if (layout.optionType === 'flaggedOption') { - let baseType = baseMap[layout.inner.type as BaseType]; - return preconditionSubclass( - accountUpdate, - baseKey, - baseType as any, - context - ); + let baseType = getProvableType(layout.inner); + return preconditionSubclass(accountUpdate, baseKey, baseType, context); } } else if (layout.type === 'array') { return {}; // not applicable yet, TODO if we implement state @@ -326,6 +340,15 @@ function preconditionSubClassWithRange< }; } +function defaultLower(fieldType: any) { + assertInternal(fieldType === UInt32 || fieldType === UInt64); + return (fieldType as typeof UInt32 | typeof UInt64).zero; +} +function defaultUpper(fieldType: any) { + assertInternal(fieldType === UInt32 || fieldType === UInt64); + return (fieldType as typeof UInt32 | typeof UInt64).MAXINT(); +} + function preconditionSubclass< K extends LongKey, U extends FlatPreconditionValue[K] @@ -338,6 +361,7 @@ function preconditionSubclass< if (fieldType === undefined) { throw Error(`this.${longKey}: fieldType undefined`); } + let obj = { get() { if (unimplementedPreconditions.includes(longKey)) { @@ -375,6 +399,36 @@ function preconditionSubclass< setPath(accountUpdate.body.preconditions, longKey, value); } }, + requireEqualsIf(condition: Bool, value: U) { + context.constrained.add(longKey); + let property = getPath( + accountUpdate.body.preconditions, + longKey + ) as AnyCondition; + assertInternal('isSome' in property); + property.isSome = condition; + if ('lower' in property.value && 'upper' in property.value) { + property.value.lower = Provable.if( + condition, + fieldType, + value, + defaultLower(fieldType) as U + ); + property.value.upper = Provable.if( + condition, + fieldType, + value, + defaultUpper(fieldType) as U + ); + } else { + property.value = Provable.if( + condition, + fieldType, + value, + fieldType.empty() + ); + } + }, requireNothing() { let property = getPath( accountUpdate.body.preconditions, @@ -383,13 +437,8 @@ function preconditionSubclass< if ('isSome' in property) { property.isSome = Bool(false); if ('lower' in property.value && 'upper' in property.value) { - if (fieldType === UInt64) { - property.value.lower = UInt64.zero as U; - property.value.upper = UInt64.MAXINT() as U; - } else if (fieldType === UInt32) { - property.value.lower = UInt32.zero as U; - property.value.upper = UInt32.MAXINT() as U; - } + property.value.lower = defaultLower(fieldType) as U; + property.value.upper = defaultUpper(fieldType) as U; } else { property.value = fieldType.empty(); } @@ -588,6 +637,7 @@ type PreconditionSubclassType = { get(): U; getAndRequireEquals(): U; requireEquals(value: U): void; + requireEqualsIf(condition: Bool, value: U): void; requireNothing(): void; }; type PreconditionSubclassRangeType = PreconditionSubclassType & { diff --git a/src/lib/mina/smart-contract-context.ts b/src/lib/mina/smart-contract-context.ts index 2920c26d42..94f6b28a76 100644 --- a/src/lib/mina/smart-contract-context.ts +++ b/src/lib/mina/smart-contract-context.ts @@ -2,8 +2,14 @@ import type { SmartContract } from './zkapp.js'; import type { AccountUpdate, AccountUpdateLayout } from './account-update.js'; import { Context } from '../util/global-context.js'; import { currentTransaction } from './transaction-context.js'; +import { assert } from '../util/assert.js'; -export { smartContractContext, SmartContractContext, accountUpdateLayout }; +export { + smartContractContext, + SmartContractContext, + accountUpdateLayout, + contract, +}; type SmartContractContext = { this: SmartContract; @@ -23,3 +29,17 @@ function accountUpdateLayout() { return layout; } + +function contract( + expectedConstructor?: new (...args: any) => S +): S { + let ctx = smartContractContext.get(); + assert(ctx !== null, 'This method must be called within a contract method'); + if (expectedConstructor !== undefined) { + assert( + ctx.this.constructor === expectedConstructor, + `This method must be called on a ${expectedConstructor.name} contract` + ); + } + return ctx.this as S; +} diff --git a/src/lib/mina/state.ts b/src/lib/mina/state.ts index c3ddacb65d..c4f0dc05f3 100644 --- a/src/lib/mina/state.ts +++ b/src/lib/mina/state.ts @@ -8,6 +8,7 @@ import { Account } from './account.js'; import { Provable } from '../provable/provable.js'; import { Field } from '../provable/wrapped.js'; import { ProvablePure } from '../provable/types/provable-intf.js'; +import { Bool } from '../provable/bool.js'; // external API export { State, state, declareState }; @@ -59,6 +60,13 @@ type State = { * by adding a precondition which the verifying Mina node will check before accepting this transaction. */ requireEquals(a: A): void; + /** + * Require that the on-chain state has to equal the given state if the provided condition is true. + * + * If the condition is false, this is a no-op. + * If the condition is true, this adds a precondition that the verifying Mina node will check before accepting this transaction. + */ + requireEqualsIf(condition: Bool, a: A): void; /** * **DANGER ZONE**: Override the error message that warns you when you use `.get()` without adding a precondition. */ @@ -228,11 +236,30 @@ function createState(defaultValue?: T): InternalStateType { this._contract.wasConstrained = true; }, + requireEqualsIf(condition: Bool, state: T) { + if (this._contract === undefined) + throw Error( + 'requireEqualsIf can only be called when the State is assigned to a SmartContract @state.' + ); + let layout = getLayoutPosition(this._contract); + let stateAsFields = this._contract.stateType.toFields(state); + let accountUpdate = this._contract.instance.self; + stateAsFields.forEach((stateField, i) => { + let state = + accountUpdate.body.preconditions.account.state[layout.offset + i]; + state.isSome = condition; + state.value = Provable.if(condition, stateField, Field(0)); + }); + this._contract.wasConstrained = true; + }, + requireNothing() { if (this._contract === undefined) throw Error( 'requireNothing can only be called when the State is assigned to a SmartContract @state.' ); + // TODO: this should ideally reset any previous precondition, + // by setting each relevant state field to { isSome: false, value: Field(0) } this._contract.wasConstrained = true; }, diff --git a/src/lib/mina/test/test-contract.ts b/src/lib/mina/test/test-contract.ts new file mode 100644 index 0000000000..564b811eef --- /dev/null +++ b/src/lib/mina/test/test-contract.ts @@ -0,0 +1,319 @@ +/** + * Framework for testing Mina smart contracts against a local Mina instance. + */ +import { SmartContract } from '../zkapp.js'; +import * as Mina from '../mina.js'; +import { + OffchainField, + OffchainMap, + OffchainState, +} from '../actions/offchain-state.js'; +import assert from 'assert'; +import { Option } from '../../provable/option.js'; +import { BatchReducer } from '../actions/batch-reducer.js'; +import { PrivateKey, PublicKey } from '../../provable/crypto/signature.js'; + +export { + testLocal, + transaction, + deploy, + expectState, + expectBalance, + TestInstruction, +}; + +type LocalBlockchain = Awaited>; + +async function testLocal( + Contract: typeof SmartContract & (new (...args: any) => S), + { + proofsEnabled, + offchainState, + batchReducer, + autoDeploy = true, + }: { + proofsEnabled: boolean | 'both'; + offchainState?: OffchainState; + batchReducer?: BatchReducer; + autoDeploy?: boolean; + }, + callback: (input: { + accounts: Record; + newAccounts: Record; + contract: S; + Local: LocalBlockchain; + }) => TestInstruction[] +): Promise { + // instance-independent setup: compile programs + + offchainState?.setContractClass(Contract as any); + batchReducer?.setContractClass(Contract as any); + + if (proofsEnabled) { + if (offchainState !== undefined) { + console.time('compile program'); + await offchainState.compile(); + console.timeEnd('compile program'); + } + if (batchReducer !== undefined) { + console.time('compile reducer'); + await batchReducer.compile(); + console.timeEnd('compile reducer'); + } + console.time('compile contract'); + await Contract.compile(); + console.timeEnd('compile contract'); + } + + // how to execute this test against a particular local Mina instance + + async function execute(Local: LocalBlockchain) { + Mina.setActiveInstance(Local); + + // set up accounts and connect contract to offchain state, reducer + + let [sender, contractAccount] = Local.testAccounts; + + let originalAccounts: Record = { + sender, + contractAccount, + }; + let i = 2; + let accounts: Record = new Proxy( + originalAccounts, + { + get(accounts, name: string) { + if (name in accounts) return accounts[name]; + let account = Local.testAccounts[i++]; + assert(account !== undefined, 'ran out of test accounts'); + accounts[name] = account; + return account; + }, + } + ); + + let newAccounts: Record = new Proxy( + {}, + { + get(accounts, name: string) { + if (name in accounts) return newAccounts[name]; + let account = Mina.TestPublicKey.random(); + newAccounts[name] = account; + return account; + }, + } + ); + + let contract = new Contract(contractAccount); + offchainState?.setContractInstance(contract as any); + batchReducer?.setContractInstance(contract as any); + + // run test setup to return instructions + let instructions = callback({ + accounts, + newAccounts, + contract: contract as S, + Local, + }); + + // deploy is the implicit first instruction (can be disabled with autoDeploy = false) + // TODO: figure out if the contract is already deployed on Mina instance, + // and only deploy if it's not + if (autoDeploy) instructions.unshift(deploy()); + + // run instructions + let spec = { localInstance: Local, contractClass: Contract }; + + for (let instruction of instructions) { + await runInstruction(spec, instruction); + } + } + + // create local instance and execute test + // if proofsEnabled is 'both', run the test with AND without proofs + + console.log(); + let Local = await Mina.LocalBlockchain({ proofsEnabled: false }); + + if (proofsEnabled === 'both' || proofsEnabled === false) { + if (proofsEnabled === 'both') console.log('(without proofs)'); + await execute(Local); + } + + if (proofsEnabled === 'both' || proofsEnabled === true) { + if (proofsEnabled === 'both') console.log('\n(with proofs)'); + Local = await Mina.LocalBlockchain({ proofsEnabled: true }); + await execute(Local); + } + + return Local; +} + +async function runInstruction( + spec: { + localInstance: LocalBlockchain; + contractClass: typeof SmartContract; + }, + instruction: TestInstruction +): Promise { + let { localInstance, contractClass: Contract } = spec; + let [sender, contractAccount] = localInstance.testAccounts; + + if (typeof instruction === 'function') { + let maybe = await instruction(); + if (maybe !== undefined) { + if (!Array.isArray(maybe)) maybe = [maybe]; + for (let instruction of maybe) await runInstruction(spec, instruction); + } + } else if (instruction.type === 'transaction') { + console.time(instruction.label); + let feepayer = instruction.sender ?? sender; + let signers = [feepayer.key, ...(instruction.signers ?? [])]; + let tx = await Mina.transaction(feepayer, instruction.callback); + await assertionWithTrace(instruction.trace, async () => { + // console.log(instruction.label, tx.toPretty()); + await tx.sign(signers).prove(); + await tx.send(); + }); + console.timeEnd(instruction.label); + } else if (instruction.type === 'deploy') { + let { options, trace } = instruction; + let account = options?.account ?? contractAccount; + let contract = options?.contract ?? Contract; + let instance = + contract instanceof SmartContract ? contract : new contract(account); + + await runInstruction(spec, { + type: 'transaction', + label: 'deploy', + callback: () => instance.deploy(), + trace, + sender, + signers: [account.key], + }); + } else if (instruction.type === 'expect-state') { + let { state, expected, trace, label } = instruction; + if ('_type' in state) { + let type = state._type; + await assertionWithTrace(trace, async () => { + let actual = Option(type).toValue(await state.get()); + assert.deepStrictEqual(actual, expected, label); + }); + } else if ('_valueType' in state) { + let [key, value] = expected; + let type = state._valueType; + await assertionWithTrace(trace, async () => { + let actual = Option(type).toValue(await state.get(key)); + assert.deepStrictEqual(actual, value, label); + }); + } + } else if (instruction.type === 'expect-balance') { + let { address, expected, label, trace } = instruction; + await assertionWithTrace(trace, () => { + let actual = Mina.getBalance(address).toBigInt(); + assert.deepStrictEqual(actual, expected, label); + }); + } else { + throw new Error('Unknown test instruction type'); + } +} + +// types and helper structures + +type MaybePromise = T | Promise; + +type BaseInstruction = { type: string; trace?: string; label?: string }; + +type TestInstruction = + | ((...args: any) => MaybePromise) + | (BaseInstruction & + ( + | { + type: 'transaction'; + label: string; + callback: () => Promise; + sender?: Mina.TestPublicKey; + signers?: PrivateKey[]; + } + | { + type: 'deploy'; + options?: { + contract?: typeof SmartContract | SmartContract; + account?: Mina.TestPublicKey; + }; + } + | { type: 'expect-state'; state: State; expected: Expected } + | { type: 'expect-balance'; address: PublicKey; expected: bigint } + )); + +// transaction-like instructions + +function transaction( + label: string, + callback: () => Promise +): TestInstruction { + let trace = Error().stack?.slice(5); + return { type: 'transaction', label, callback, trace }; +} +transaction.from = + (sender: Mina.TestPublicKey) => + (label: string, callback: () => Promise): TestInstruction => { + let trace = Error().stack?.slice(5); + return { type: 'transaction', label, callback, sender, trace }; + }; + +function deploy(options?: { + contract?: SmartContract; + account?: Mina.TestPublicKey; +}): TestInstruction { + let trace = Error().stack?.slice(5); + return { type: 'deploy', options, trace }; +} + +// assertion-like instructions + +function expectState( + state: S, + expected: Expected, + message?: string +): TestInstruction { + let trace = Error().stack?.slice(5); + return { type: 'expect-state', state, expected, trace, label: message }; +} + +function expectBalance( + address: PublicKey | string, + expected: bigint, + message?: string +): TestInstruction { + let trace = Error().stack?.slice(5); + return { + type: 'expect-balance', + address: + typeof address === 'string' ? PublicKey.fromBase58(address) : address, + expected, + trace, + label: message, + }; +} + +type State = OffchainField | OffchainMap; + +type Expected = S extends OffchainField + ? V | undefined + : S extends OffchainMap + ? [K, V | undefined] + : never; + +// error helper + +async function assertionWithTrace(trace: string | undefined, fn: () => any) { + try { + await fn(); + } catch (err: any) { + if (trace !== undefined) { + err.message += `\n\nAssertion was created here:${trace}\n\nError was thrown from here:`; + } + throw Error(err.message); + } +} diff --git a/src/lib/mina/token/token-contract.ts b/src/lib/mina/token/token-contract.ts index 1a9845d51a..676f303a11 100644 --- a/src/lib/mina/token/token-contract.ts +++ b/src/lib/mina/token/token-contract.ts @@ -13,11 +13,7 @@ import { DeployArgs, SmartContract } from '../zkapp.js'; import { TokenAccountUpdateIterator } from './forest-iterator.js'; import { tokenMethods } from './token-methods.js'; -export { TokenContract }; - -// it's fine to have this restriction, because the protocol also has a limit of ~20 -// TODO find out precise protocol limit -const MAX_ACCOUNT_UPDATES = 20; +export { TokenContract, TokenContractV2 }; /** * Base token contract which @@ -27,6 +23,10 @@ const MAX_ACCOUNT_UPDATES = 20; abstract class TokenContract extends SmartContract { // change default permissions - important that token contracts use an access permission + /** The maximum number of account updates using the token in a single + * transaction that this contract supports. */ + static MAX_ACCOUNT_UPDATES = 20; + /** * Deploys a {@link TokenContract}. * @@ -96,8 +96,8 @@ abstract class TokenContract extends SmartContract { this.deriveTokenId() ); - // iterate through the forest and apply user-defined logc - for (let i = 0; i < MAX_ACCOUNT_UPDATES; i++) { + // iterate through the forest and apply user-defined logic + for (let i = 0; i < (this.constructor as typeof TokenContract).MAX_ACCOUNT_UPDATES; i++) { let { accountUpdate, usesThisToken } = iterator.next(); callback(accountUpdate, usesThisToken); } @@ -105,7 +105,7 @@ abstract class TokenContract extends SmartContract { // prove that we checked all updates iterator.assertFinished( `Number of account updates to approve exceed ` + - `the supported limit of ${MAX_ACCOUNT_UPDATES}.\n` + `the supported limit of ${(this.constructor as typeof TokenContract).MAX_ACCOUNT_UPDATES}.\n` ); // skip hashing our child account updates in the method wrapper @@ -179,11 +179,20 @@ abstract class TokenContract extends SmartContract { } } +/** Version of `TokenContract` with the precise number of `MAX_ACCOUNT_UPDATES` + * + * The value of 20 in `TokenContract` was a rough upper limit, the precise upper + * bound is 9. + */ +abstract class TokenContractV2 extends TokenContract { + static MAX_ACCOUNT_UPDATES = 9; +} + function toForest( updates: (AccountUpdate | AccountUpdateTree)[] ): AccountUpdateForest { let trees = updates.map((a) => a instanceof AccountUpdate ? a.extractTree() : a ); - return AccountUpdateForest.from(trees); + return AccountUpdateForest.fromReverse(trees); } diff --git a/src/lib/provable/crypto/nullifier.ts b/src/lib/provable/crypto/nullifier.ts index 29a714006d..266c489fa9 100644 --- a/src/lib/provable/crypto/nullifier.ts +++ b/src/lib/provable/crypto/nullifier.ts @@ -96,17 +96,31 @@ class Nullifier extends Struct({ return Poseidon.hash(Group.toFields(this.public.nullifier)); } + /** + * @deprecated This method uses the deprecated {@link MerkleMapWitness.computeRootAndKey} which may be vulnerable to hash collisions in key indices. Use {@link isUnusedV2} instead, which utilizes the safer {@link MerkleMapWitness.computeRootAndKeyV2} method. + */ + isUnused(witness: MerkleMapWitness, root: Field) { + let [newRoot, key] = witness.computeRootAndKey(Field(0)); + key.assertEquals(this.key()); + let isUnused = newRoot.equals(root); + + let isUsed = witness.computeRootAndKey(Field(1))[0].equals(root); + // prove that our Merkle witness is correct + isUsed.or(isUnused).assertTrue(); + return isUnused; // if this is false, `isUsed` is true because of the check before + } + /** * Returns the state of the Nullifier. * * @example * ```ts * // returns a Bool based on whether or not the nullifier has been used before - * let isUnused = nullifier.isUnused(); + * let isUnused = nullifier.isUnusedV2(); * ``` */ - isUnused(witness: MerkleMapWitness, root: Field) { - let [newRoot, key] = witness.computeRootAndKey(Field(0)); + isUnusedV2(witness: MerkleMapWitness, root: Field) { + let [newRoot, key] = witness.computeRootAndKeyV2(Field(0)); key.assertEquals(this.key()); let isUnused = newRoot.equals(root); @@ -116,32 +130,50 @@ class Nullifier extends Struct({ return isUnused; // if this is false, `isUsed` is true because of the check before } + /** + * @deprecated This method uses the deprecated {@link MerkleMapWitness.computeRootAndKey} which may be vulnerable to hash collisions in key indices. Use {@link assertUnusedV2} instead, which utilizes the safer {@link MerkleMapWitness.computeRootAndKeyV2} method. + */ + assertUnused(witness: MerkleMapWitness, root: Field) { + let [impliedRoot, key] = witness.computeRootAndKey(Field(0)); + this.key().assertEquals(key); + impliedRoot.assertEquals(root); + } + /** * Checks if the Nullifier has been used before. * * @example * ```ts * // asserts that the nullifier has not been used before, throws an error otherwise - * nullifier.assertUnused(); + * nullifier.assertUnusedV2(); * ``` */ - assertUnused(witness: MerkleMapWitness, root: Field) { - let [impliedRoot, key] = witness.computeRootAndKey(Field(0)); + assertUnusedV2(witness: MerkleMapWitness, root: Field) { + let [impliedRoot, key] = witness.computeRootAndKeyV2(Field(0)); this.key().assertEquals(key); impliedRoot.assertEquals(root); } + /** + * @deprecated This method uses the deprecated {@link MerkleMapWitness.computeRootAndKey} which may be vulnerable to hash collisions in key indices. Use {@link setUsedV2} instead, which utilizes the safer {@link MerkleMapWitness.computeRootAndKeyV2} method. + */ + setUsed(witness: MerkleMapWitness) { + let [newRoot, key] = witness.computeRootAndKey(Field(1)); + key.assertEquals(this.key()); + return newRoot; + } + /** * Sets the Nullifier, returns the new Merkle root. * * @example * ```ts * // calculates the new root of the Merkle tree in which the nullifier is set to used - * let newRoot = nullifier.setUsed(witness); + * let newRoot = nullifier.setUsedV2(witness); * ``` */ - setUsed(witness: MerkleMapWitness) { - let [newRoot, key] = witness.computeRootAndKey(Field(1)); + setUsedV2(witness: MerkleMapWitness) { + let [newRoot, key] = witness.computeRootAndKeyV2(Field(1)); key.assertEquals(this.key()); return newRoot; } diff --git a/src/lib/provable/gadgets/foreign-field.ts b/src/lib/provable/gadgets/foreign-field.ts index e0561cf69f..611189c18a 100644 --- a/src/lib/provable/gadgets/foreign-field.ts +++ b/src/lib/provable/gadgets/foreign-field.ts @@ -739,7 +739,7 @@ class Sum { function assertLessThan(x: Field3, y: bigint | Field3) { let y_ = Field3.from(y); - // constant case + // constant case, y = constant, x = constant if (Field3.isConstant(x) && Field3.isConstant(y_)) { assert( @@ -749,7 +749,7 @@ function assertLessThan(x: Field3, y: bigint | Field3) { return; } - // case of one variable, one constant + // case of y = constant, x = variable if (Field3.isConstant(y_)) { y = typeof y === 'bigint' ? y : Field3.toBigint(y); @@ -764,7 +764,7 @@ function assertLessThan(x: Field3, y: bigint | Field3) { return; } - // case of two variables + // case of two variables or x = constant and y = variable // we compute z = y - x - 1 and check that z \in [0, 2^3l), which implies x < y as above // we use modulo 0 here, which means we're proving: diff --git a/src/lib/provable/merkle-list.ts b/src/lib/provable/merkle-list.ts index 6144b42e53..dbebac0c02 100644 --- a/src/lib/provable/merkle-list.ts +++ b/src/lib/provable/merkle-list.ts @@ -186,6 +186,39 @@ class MerkleList implements MerkleListBase { return element; } + /** + * Low-level, minimal version of `pop()` which lets the _caller_ decide whether there is an element to pop. + * + * I.e. this proves: + * - If the input condition is true, this returns the last element and removes it from the list. + * - If the input condition is false, the list is unchanged and the return value is garbage. + * + * Note that if the caller passes `true` but the list is empty, this will fail. + * If the caller passes `false` but the list is non-empty, this succeeds and just doesn't pop off an element. + */ + popIfUnsafe(shouldPop: Bool) { + let { previousHash, element } = Provable.witness( + WithHash(this.innerProvable), + () => { + let dummy = { + previousHash: this.hash, + element: this.innerProvable.empty(), + }; + if (!shouldPop.toBoolean()) return dummy; + let [value, ...data] = this.data.get(); + this.data.set(data); + return value ?? dummy; + } + ); + + let nextHash = this.nextHash(previousHash, element); + let currentHash = Provable.if(shouldPop, nextHash, this.hash); + this.hash.assertEquals(currentHash); + this.hash = Provable.if(shouldPop, previousHash, this.hash); + + return element; + } + clone(): MerkleList { let data = Unconstrained.witness(() => [...this.data.get()]); return new this.Constructor({ hash: this.hash, data }); @@ -224,6 +257,16 @@ class MerkleList implements MerkleListBase { return merkleArray.startIteratingFromLast(this); } + toArrayUnconstrained(): Unconstrained { + return Unconstrained.witness(() => + [...this.data.get()].reverse().map((x) => x.element) + ); + } + + lengthUnconstrained(): Unconstrained { + return Unconstrained.witness(() => this.data.get().length); + } + /** * Create a Merkle list type * diff --git a/src/lib/provable/merkle-tree-indexed.ts b/src/lib/provable/merkle-tree-indexed.ts index 262e788dfc..f9f90ce714 100644 --- a/src/lib/provable/merkle-tree-indexed.ts +++ b/src/lib/provable/merkle-tree-indexed.ts @@ -308,6 +308,22 @@ class IndexedMerkleMapBase { return new OptionField({ isSome: keyExists, value: self.value }); } + /** + * Perform an insertion or update, if the enabling condition is true. + * + * If the condition is false, we instead set the 0 key to the value 0. + * This is the initial value and for typical uses of `IndexedMerkleMap`, it is guaranteed to be a no-op because the 0 key is never used. + * + * **Warning**: Only use this method if you are sure that the 0 key is not used in your application. + * Otherwise, you might accidentally overwrite a valid key-value pair. + */ + setIf(condition: Bool | boolean, key: Field | bigint, value: Field | bigint) { + return this.set( + Provable.if(Bool(condition), Field(key), Field(0n)), + Provable.if(Bool(condition), Field(value), Field(0n)) + ); + } + /** * Get a value from a key. * diff --git a/src/lib/provable/option.ts b/src/lib/provable/option.ts index cb9d9b3048..cfd324e07f 100644 --- a/src/lib/provable/option.ts +++ b/src/lib/provable/option.ts @@ -2,7 +2,7 @@ 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 { provable, ProvableInferPureFrom } from './types/provable-derivers.js'; import { Bool } from './wrapped.js'; export { Option, OptionOrValue }; @@ -36,7 +36,8 @@ type OptionOrValue = */ function Option>( type: A -): Provable< +): ProvableInferPureFrom< + A, Option, InferValue>, InferValue | undefined > & diff --git a/src/lib/provable/packed.ts b/src/lib/provable/packed.ts index 401416161d..aa59809ff0 100644 --- a/src/lib/provable/packed.ts +++ b/src/lib/provable/packed.ts @@ -183,6 +183,7 @@ class Hashed { hash?: (t: T) => Field ): typeof Hashed & { provable: ProvableHashable>; + empty(): Hashed; } { let _hash = hash ?? ((t: T) => Poseidon.hashPacked(type, t)); diff --git a/src/lib/provable/provable.ts b/src/lib/provable/provable.ts index 07e4dbdc7f..58f98565b7 100644 --- a/src/lib/provable/provable.ts +++ b/src/lib/provable/provable.ts @@ -117,6 +117,12 @@ const Provable = { * ``` */ assertEqual, + /** + * Asserts that two values are equal, if an enabling condition is true. + * + * If the condition is false, the assertion is skipped. + */ + assertEqualIf, /** * Checks if two elements are equal. * @example @@ -387,6 +393,15 @@ function switch_>( return (type as Provable).fromFields(fields, aux); } +function assertEqualIf< + A extends Provable, + T extends InferProvable = InferProvable +>(enabled: Bool, type: A, x: T, y: T) { + // if the condition is disabled, we check the trivial identity x === x instead + let xOrY = ifExplicit(enabled, type, y, x); + assertEqual(type, x, xOrY); +} + function isConstant(type: Provable, x: T): boolean { return type.toFields(x).every((x) => x.isConstant()); } diff --git a/src/lib/provable/scalar-field.ts b/src/lib/provable/scalar-field.ts new file mode 100644 index 0000000000..91c085a025 --- /dev/null +++ b/src/lib/provable/scalar-field.ts @@ -0,0 +1,47 @@ +import { Fq } from '../../bindings/crypto/finite-field.js'; +import { ForeignField, createForeignField } from './foreign-field.js'; +import { field3ToShiftedScalar } from './gadgets/native-curve.js'; +import { Provable } from './provable.js'; +import { Scalar } from './scalar.js'; + +export { ScalarField }; + +/** + * ForeignField representing the scalar field of Pallas and the base field of Vesta + */ +class ScalarField extends createForeignField(Fq.modulus) { + /** + * Provable method to convert a {@link ScalarField} into a {@link Scalar} + */ + public toScalar(): Scalar { + return ScalarField.toScalar(this); + } + + public static toScalar(field: ForeignField) { + if (field.modulus !== Fq.modulus) { + throw new Error( + 'Only ForeignFields with Fq modulus are convertable into a scalar' + ); + } + const field3 = field.value; + const shiftedScalar = field3ToShiftedScalar(field3); + return Scalar.fromShiftedScalar(shiftedScalar); + } + + /** + * Converts this {@link Scalar} into a {@link ScalarField} + */ + static fromScalar(s: Scalar): ScalarField { + if (s.lowBit.isConstant() && s.high254.isConstant()) { + return new ScalarField(s.toBigInt()); + } + const field = Provable.witness(ScalarField.provable, () => { + return s.toBigInt(); + }); + const foreignField = new ScalarField(field); + const scalar = foreignField.toScalar(); + Provable.assertEqual(Scalar, s, scalar); + + return foreignField; + } +} diff --git a/src/lib/provable/scalar.ts b/src/lib/provable/scalar.ts index fe4602fbd1..dc96672f94 100644 --- a/src/lib/provable/scalar.ts +++ b/src/lib/provable/scalar.ts @@ -50,6 +50,13 @@ class Scalar implements ShiftedScalar { return new Scalar(lowBit, high254); } + /** + * Provable method to convert a {@link ShiftedScalar} to a {@link Scalar}. + */ + static fromShiftedScalar(s: ShiftedScalar) { + return new Scalar(s.lowBit, s.high254); + } + /** * Provable method to convert a {@link Field} into a {@link Scalar}. * diff --git a/src/lib/provable/test/scalar.test.ts b/src/lib/provable/test/scalar.test.ts index bf0f717d21..1d06866d6d 100644 --- a/src/lib/provable/test/scalar.test.ts +++ b/src/lib/provable/test/scalar.test.ts @@ -1,4 +1,4 @@ -import { Field, Provable, Scalar } from 'o1js'; +import { Field, Provable, Scalar, ScalarField } from 'o1js'; describe('scalar', () => { describe('scalar', () => { @@ -33,6 +33,18 @@ describe('scalar', () => { }); }); + describe('toScalarField / fromScalarField', () => { + it('should return the same', async () => { + const s = Scalar.random(); + await Provable.runAndCheck(() => { + const scalar = Provable.witness(Scalar, () => s); + const scalarField = ScalarField.fromScalar(scalar); + const scalar2 = scalarField.toScalar(); + Provable.assertEqual(scalar, scalar2); + }); + }); + }); + describe('random', () => { it('two different calls should be different', async () => { await Provable.runAndCheck(() => { diff --git a/src/lib/provable/test/struct.unit-test.ts b/src/lib/provable/test/struct.unit-test.ts index 5554aafc81..8aaef12fe8 100644 --- a/src/lib/provable/test/struct.unit-test.ts +++ b/src/lib/provable/test/struct.unit-test.ts @@ -265,4 +265,16 @@ await tx.prove(); // assert that prover got the target string expect(gotTargetString).toEqual(true); +// Having `Struct` as a property is not allowed +class InvalidStruct extends Struct({ + inner: Struct, +}) {} + +expect(() => { + let invalidStruct = new InvalidStruct({ + inner: MyStruct.empty(), + }); + InvalidStruct.check(invalidStruct); +}).toThrow(); + console.log('provable types work as expected! 🎉'); diff --git a/src/lib/provable/types/provable-derivers.ts b/src/lib/provable/types/provable-derivers.ts index b417b23163..8df54c7c0f 100644 --- a/src/lib/provable/types/provable-derivers.ts +++ b/src/lib/provable/types/provable-derivers.ts @@ -17,6 +17,7 @@ import { GenericHashInput } from '../../../bindings/lib/generic.js'; // external API export { ProvableExtended, + ProvableInferPureFrom, provable, provablePure, provableTuple, @@ -50,6 +51,9 @@ type ProvablePureExtended = ProvablePure< type InferProvable = GenericInferProvable; type InferredProvable = GenericInferredProvable; type IsPure = GenericIsPure; +type ProvableInferPureFrom = IsPure extends true + ? ProvablePure + : Provable; type HashInput = GenericHashInput; const HashInput = createHashInput(); diff --git a/src/lib/provable/types/struct.ts b/src/lib/provable/types/struct.ts index 60e9d23c30..96d962d4e6 100644 --- a/src/lib/provable/types/struct.ts +++ b/src/lib/provable/types/struct.ts @@ -129,6 +129,8 @@ type AnyConstructor = Constructor; * Again, it's important to note that this doesn't enable you to prove anything about the `fullName` string. * From the circuit point of view, it simply doesn't exist! * + * @note Ensure you do not use or extend `Struct` as a type directly. Instead, always call it as a function to construct a type. `Struct` is not a valid provable type itself, types created with `Struct(...)` are. + * * @param type Object specifying the layout of the `Struct` * @param options Advanced option which allows you to force a certain order of object keys * @returns Class which you can extend diff --git a/src/lib/testing/equivalent.ts b/src/lib/testing/equivalent.ts index c4a713c90b..5c5ae13a78 100644 --- a/src/lib/testing/equivalent.ts +++ b/src/lib/testing/equivalent.ts @@ -5,7 +5,7 @@ import { test, Random } from '../testing/property.js'; 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 { AnyTuple, Tuple } from '../util/types.js'; import { provable } from '../provable/types/provable-derivers.js'; import { assert } from '../provable/gadgets/common.js'; import { synchronousRunners } from '../provable/core/provable-context.js'; @@ -84,9 +84,11 @@ type FuncSpec, Out1, In2 extends Tuple, Out2> = { to: ToSpec; }; +type AnyTupleFunction = (...args: AnyTuple) => any; + type SpecFromFunctions< - F1 extends AnyFunction, - F2 extends AnyFunction + F1 extends AnyTupleFunction, + F2 extends AnyTupleFunction > = FuncSpec, ReturnType, Parameters, ReturnType>; function id(x: T) { diff --git a/src/lib/util/assert.ts b/src/lib/util/assert.ts index f996b4bede..c80fce37bb 100644 --- a/src/lib/util/assert.ts +++ b/src/lib/util/assert.ts @@ -1,4 +1,4 @@ -export { assert, assertPromise }; +export { assert, assertPromise, assertDefined }; function assert(stmt: boolean, message?: string): asserts stmt { if (!stmt) { @@ -10,3 +10,14 @@ function assertPromise(value: Promise, message?: string): Promise { assert(value instanceof Promise, message ?? 'Expected a promise'); return value; } + +/** + * Assert that the value is not undefined, return the value. + */ +function assertDefined( + value: T | undefined, + message = 'Input value is undefined.' +): T { + if (value === undefined) throw Error(message); + return value as T; +} diff --git a/src/lib/util/errors.ts b/src/lib/util/errors.ts index b832c7be6b..dfa09fb47a 100644 --- a/src/lib/util/errors.ts +++ b/src/lib/util/errors.ts @@ -4,7 +4,7 @@ export { prettifyStacktrace, prettifyStacktracePromise, assert, - assertDefined, + assert as assertInternal, }; /** @@ -281,14 +281,3 @@ function assert( ): asserts condition { if (!condition) throw Bug(message); } - -/** - * Assert that the value is not undefined, return the value. - */ -function assertDefined( - value: T | undefined, - message = 'Input value is undefined.' -): T { - if (value !== undefined) throw Bug(message); - return value as T; -} diff --git a/src/lib/util/global-context.ts b/src/lib/util/global-context.ts index e07561fc53..ae7e7edebf 100644 --- a/src/lib/util/global-context.ts +++ b/src/lib/util/global-context.ts @@ -4,7 +4,7 @@ namespace Context { export type id = number; export type t = (() => Context | undefined) & { - data: { context: Context; id: id }[]; + data: { context: Context; id: id; trace?: string }[]; allowsNesting: boolean; get(): Context; @@ -84,14 +84,27 @@ function enter(t: Context.t, context: C): Context.id { throw Error(contextConflictMessage); } let id = Math.random(); - t.data.push({ context, id }); + let trace = Error().stack?.slice(5); + t.data.push({ context, id, trace }); return id; } function leave(t: Context.t, id: Context.id): C { let current = t.data.pop(); if (current === undefined) throw Error(contextConflictMessage); - if (current.id !== id) throw Error(contextConflictMessage); + if (current.id !== id) { + let message = contextConflictMessage; + let expected = t.data.find((c) => c.id === id); + if (expected?.trace) { + message += `\n\nWe wanted to leave the global context entered here:${expected.trace}`; + if (current.trace) { + message += `\n\nBut we actually would have left the global context entered here:${current.trace}`; + message += `\n\nOur first recommendation is to check for a missing 'await' in the second stack trace.`; + } + message += `\n\n`; + } + throw Error(message); + } return current.context; } @@ -103,6 +116,14 @@ function get(t: Context.t): C { // FIXME there are many common scenarios where this error occurs, which weren't expected when this was written // it should list them and help to resolve them -let contextConflictMessage = - "It seems you're running multiple provers concurrently within" + - ' the same JavaScript thread, which, at the moment, is not supported and would lead to bugs.'; +let contextConflictMessage = `The global context managed by o1js reached an inconsistent state. This could be caused by one of the following reasons: + +- You are missing an 'await' somewhere, which causes a new global context to be entered before we finished the last one. + +- You are importing two different instances of o1js, which leads to inconsistent tracking of the global context in one of those instances. + - This is a common problem in projects that use o1js as part of a UI! + +- You are running multiple async operations concurrently, which conflict in using the global context. + - Running async o1js operations (like proving) in parallel is not supported! Try running everything serially. + +Investigate the stack traces below for more hints about the problem.`; diff --git a/tests/vk-regression/vk-regression.json b/tests/vk-regression/vk-regression.json index c665480dfb..6dbad49ac5 100644 --- a/tests/vk-regression/vk-regression.json +++ b/tests/vk-regression/vk-regression.json @@ -84,7 +84,7 @@ } }, "Dex": { - "digest": "2da291661586d3ba6e5efb2f1e00e26b253d64b84ac31fe6968af4e4915743cc", + "digest": "1ed56246c01d984270b8baccc1e94ba9c1dcc2852fa12323fa09a4ea9b8e243a", "methods": { "approveBase": { "rows": 13232, @@ -92,15 +92,15 @@ }, "supplyLiquidityBase": { "rows": 2841, - "digest": "5d5cc7d49f62624f43210c9bd3b279e7" + "digest": "26f29fba6f5bb72a58ff7bd5f1f38a5c" }, "swapX": { "rows": 1512, - "digest": "5a3224358f3a0e2013b9b37f3c845f3a" + "digest": "c9cc2dd606b458fcab809c7fbf9d5b07" }, "swapY": { "rows": 1512, - "digest": "76f7ba5efbc259abe5836ac32701f2a3" + "digest": "6c43e323161d2f005183cb2319bdfd52" }, "burnLiquidity": { "rows": 704, @@ -108,8 +108,8 @@ } }, "verificationKey": { - "data": "AAAquFdEgAiP0gVQOFC1AYSsV9ylHwU1kj9trP0Iz00FP8zx9+7n59XMLqpjue1wA4VfgD2aXaC4seFCHAfaZwUkB+uHOnxXH7vN8sUeDQi50gWdXzRlzSS1jsT9t+XsQwHNWgMQp04pKmF+0clYz1zwOO95BwHGcQ/olrSYW4tbJCzCu0+M5beMUxHl3qo9fsP2UE6wUyrUH+bkM1NQAsAz0p0Kf7RXT4K2tC3hCxybh9Cj1ZLfvzg03OR4HBo61jF6ax6ymlATB4YBL0ETiEPTE/Qk1zGWUSL2UB6aY45/LlfTLCKlyLq7cR3HOucFfBncVfzI7D8j5n4wVqY+vAI4cf+Yv7iVRLbeFcycXtsuPQntgBzKa/mcqcWuVM7p2SYRrtKdX8EKvOO6NhfLx4x0atAi8pKf+vZR76LSP4iOA8hwXvk6MNvPt1fxCS96ZAKuAzZnAcK+MH1OcKeLj+EHtZmf40WRb3AEG5TWRKuD6DT5noDclZsE8ROZKUSOKAUGIBvt7MpzOWPPchmnromWEevmXo3GoPUZCKnWX6ZLAtJwAszLUgiVS8rx3JnLXuXrtcVFto5FFQhwSHZyzuYZAESQ41MinvLrL2khnyyCuxmgT01VK3dRmB8lbPGoxk8vRTtYg8WX6ueD6x4GUuApNA8YdY/0D9FZcTy0OxP+4wduvC7XICgbTBTdkLN57FdxSKP/IMK5/VNmSxUR7ugVNqDtkCXWhCrJYDo2sPyDzNqbExPmG3u6t74md5KoqHYCJ5M/KjfmCc2/EsnV7Mhax350ZtrXdzh/HWIWzEZKKxcbERFbRtf+fkMOOLNpNov1FEFvKOU612vDOIbrVHeBN9mwuepUrJctcfgLc0Mi3Sxs3+NA0I74qm5ktjmplDwgUtKzIs3IrVFv6b1pg/J32HmwNzJZw2fYzpFE1LDjBSK/SX3axwMy5yEd8+jl4uAdQZpa9UQQIHu1Y1ZMgJSDDicXz6D1bZMA1Q2/lU+8AYbldgQVmlLq/lzr63krX+AMFiv0/bJ4Yce5U+7QWHIapHg0CrAm48T3mJ1eQYs/OzBCM0bfzvuVtFU/SrKFNhb9F46Y/H3eWpz2d5Cr05vDD3lVzcQAJ183VU5rXeXL+UsuVkkSto2fGFExQqa4rckrOxEzPkQ4IDUSOqIOTuisgOj56kN+9hduXXhizBDRjiT59l19FcR35ItoigIxtMfkv3rdlCOeBVI93oVl5esiH8AvYGHhulWIvrNfKol3Viir41zv4qMBOcQg8+ygqjwqREU5+qiYeJlQ2AtT0/PVeZWg4mHC39uz1Lld3N2hyyxRo+Z0nC/8220uuf9gAnQ+JFixgyYW0NowUtuFj+uYAV9Dh/Zpe4LyAOkU0kBW4CEuOxNr+gz+9h0BoPfBHlMuuQAUc5L8uMunJC7uBKZiL+/tT1ZGfyIuqU47fEP9Hghxmip8v7gpf+4wB0MVUUwav9QRe9g88ER1HcJPqYb4EIOc2kbYSX75bT0mAFqR8lwZrj6lbQtNS0QQboG5fzoyYGi8YnSXhC2T5fFDpGJ319GHUsna58o5wk8LMwKWNTxq+FN6XiRgu0BFOrtG6MtT1OxYE9Dti6WatGDsWv+KMLDHjxUK1bhiSRnvkWYNcnuDJ0Ry+PRGHNUijVU0SbchntC2JHdhwKbwIofwKHE8HhvlK8FgQ1VOLDioA26UFzr23LpCTqwSJ7/sAqttNGcPR8MSeeR9TQvXNYQPKrA7Gh720X+7LD6BuHdy4vkcr9EKBU0ccUJ2ABBiyPdji+AgEbUCL/wrp6/GX8pui5YJGWx3XmIFj/RnYS2Je5FZ7w74JclD3XhLUo5Dhpq5RznHplpLB9mNdZdm5269US/XCgC/ZKyUxW3+0ajdBY1cLzF6qglitaYTp3MVUENVOkACM2RyKw6jIK2Leq3qLp6AUz21VXj4WznZcdI8MXqT9v8HxjXbAI9dtbhLRZRpJmu/129vrVmwSTHvsVoA7vXyYh/iO3ZMcy+D1x+HZU6Q/oDYCicqOPHxpSc9QGehmNyeGzI//524Gz3RudkU7s6MPdLWqZrieRTnWsTIrCDieu4ValfP8BFz7asYUv0t9jMWpv3yjbY7c5h8N/m7IUXwTQCzFpjPV7HC72BjVwPaYqh5/oAQsSNcv5I3c2GsCGj5C4hFFoT7eWfVtu/6ibQl0COhRDsegnOBtZ7NGfybI8IIO/4yrgel92bypb3eSxeMvdE5wzURluGDkBVVIACD8C5W1MzqrejUiiTfc3mkLhQ0xKRRhT0qqkmYWlbGN5hmMOA9YaYx8OFTgMys1WbzdidWgEkyvvdkWctGlges6eg/lJE61tJ8wGxvJfKtpyDW/2MRvsnO1+2EXIQ2eV3hkxg=", - "hash": "18322186253476237171695574009698612475632242619179587729286448937845718496034" + "data": "AAAquFdEgAiP0gVQOFC1AYSsV9ylHwU1kj9trP0Iz00FP8zx9+7n59XMLqpjue1wA4VfgD2aXaC4seFCHAfaZwUkB+uHOnxXH7vN8sUeDQi50gWdXzRlzSS1jsT9t+XsQwHNWgMQp04pKmF+0clYz1zwOO95BwHGcQ/olrSYW4tbJCzCu0+M5beMUxHl3qo9fsP2UE6wUyrUH+bkM1NQAsAz0p0Kf7RXT4K2tC3hCxybh9Cj1ZLfvzg03OR4HBo61jF6ax6ymlATB4YBL0ETiEPTE/Qk1zGWUSL2UB6aY45/LlfTLCKlyLq7cR3HOucFfBncVfzI7D8j5n4wVqY+vAI4cf+Yv7iVRLbeFcycXtsuPQntgBzKa/mcqcWuVM7p2SYRrtKdX8EKvOO6NhfLx4x0atAi8pKf+vZR76LSP4iOA8hwXvk6MNvPt1fxCS96ZAKuAzZnAcK+MH1OcKeLj+EHtZmf40WRb3AEG5TWRKuD6DT5noDclZsE8ROZKUSOKAUGIBvt7MpzOWPPchmnromWEevmXo3GoPUZCKnWX6ZLAtJwAszLUgiVS8rx3JnLXuXrtcVFto5FFQhwSHZyzuYZAHYRlNaRh9gbonUumhMwjYoNMCkJe2/fcuDnlq6RkpM3J+AdtxyYUZQfIcolhNEqSxn80Iu98ZOhI69IGNwtXyJuvC7XICgbTBTdkLN57FdxSKP/IMK5/VNmSxUR7ugVNqDtkCXWhCrJYDo2sPyDzNqbExPmG3u6t74md5KoqHYCJ5M/KjfmCc2/EsnV7Mhax350ZtrXdzh/HWIWzEZKKxcbERFbRtf+fkMOOLNpNov1FEFvKOU612vDOIbrVHeBN9mwuepUrJctcfgLc0Mi3Sxs3+NA0I74qm5ktjmplDwgUtKzIs3IrVFv6b1pg/J32HmwNzJZw2fYzpFE1LDjBSK/SX3axwMy5yEd8+jl4uAdQZpa9UQQIHu1Y1ZMgJSDDicXz6D1bZMA1Q2/lU+8AYbldgQVmlLq/lzr63krX+AMeZxKhAhBBRc9208Y2phRSSxjTE2tuhy9QQlwmAFh6w6azBTDyh6qP/8orVheox5xzYGAynSpx6cQN5QKN/ldI3lVzcQAJ183VU5rXeXL+UsuVkkSto2fGFExQqa4rckrOxEzPkQ4IDUSOqIOTuisgOj56kN+9hduXXhizBDRjiT59l19FcR35ItoigIxtMfkv3rdlCOeBVI93oVl5esiH8AvYGHhulWIvrNfKol3Viir41zv4qMBOcQg8+ygqjwqREU5+qiYeJlQ2AtT0/PVeZWg4mHC39uz1Lld3N2hyyxRo+Z0nC/8220uuf9gAnQ+JFixgyYW0NowUtuFj+uYAV9Dh/Zpe4LyAOkU0kBW4CEuOxNr+gz+9h0BoPfBHlMuuQAUc5L8uMunJC7uBKZiL+/tT1ZGfyIuqU47fEP9Hghxmip8v7gpf+4wB0MVUUwav9QRe9g88ER1HcJPqYb4EIOc2kbYSX75bT0mAFqR8lwZrj6lbQtNS0QQboG5fzoyYGi8YnSXhC2T5fFDpGJ319GHUsna58o5wk8LMwKWNTxq+FN6XiRgu0BFOrtG6MtT1OxYE9Dti6WatGDsWv+KMLDHjxUK1bhiSRnvkWYNcnuDJ0Ry+PRGHNUijVU0SbchntC2JHdhwKbwIofwKHE8HhvlK8FgQ1VOLDioA26UFzr23LpCTqwSJ7/sAqttNGcPR8MSeeR9TQvXNYQPKrA7Gh720X+7LD6BuHdy4vkcr9EKBU0ccUJ2ABBiyPdji+AgEbUCL/wrp6/GX8pui5YJGWx3XmIFj/RnYS2Je5FZ7w74JclD3XhLUo5Dhpq5RznHplpLB9mNdZdm5269US/XCgC/ZKyUxW3+0ajdBY1cLzF6qglitaYTp3MVUENVOkACM2RyKw6jIK2Leq3qLp6AUz21VXj4WznZcdI8MXqT9v8HxjXbAI9dtbhLRZRpJmu/129vrVmwSTHvsVoA7vXyYh/iO3ZMcy+D1x+HZU6Q/oDYCicqOPHxpSc9QGehmNyeGzI//524Gz3RudkU7s6MPdLWqZrieRTnWsTIrCDieu4ValfP8BFz7asYUv0t9jMWpv3yjbY7c5h8N/m7IUXwTQCzFpjPV7HC72BjVwPaYqh5/oAQsSNcv5I3c2GsCGj5C4hFFoT7eWfVtu/6ibQl0COhRDsegnOBtZ7NGfybI8IIO/4yrgel92bypb3eSxeMvdE5wzURluGDkBVVIACD8C5W1MzqrejUiiTfc3mkLhQ0xKRRhT0qqkmYWlbGN5hmMOA9YaYx8OFTgMys1WbzdidWgEkyvvdkWctGlges6eg/lJE61tJ8wGxvJfKtpyDW/2MRvsnO1+2EXIQ2eV3hkxg=", + "hash": "11995787401471485847112327267221844756507727092913123363897384059516663907637" } }, "Group Primitive": {