UNPKG

o1js

Version:

TypeScript framework for zk-SNARKs and zkApps

434 lines (406 loc) 14.8 kB
import { SimpleLedger } from './transaction-logic/ledger.js'; import { Ml } from '../ml/conversion.js'; import { transactionCommitments } from '../../mina-signer/src/sign-zkapp-command.js'; import { Ledger, Test, initializeBindings } from '../../snarky.js'; import { Field } from '../provable/wrapped.js'; import { UInt32, UInt64 } from '../provable/int.js'; import { PrivateKey, PublicKey } from '../provable/crypto/signature.js'; import { Account } from './account.js'; import { ZkappCommand, TokenId, Authorization, Actions, } from './account-update.js'; import { NetworkId } from '../../mina-signer/src/types.js'; import { TupleN } from '../util/types.js'; import { Types, TypesBigint } from '../../bindings/mina-transaction/types.js'; import { invalidTransactionError } from './errors.js'; import { Transaction, PendingTransaction, createTransaction, toTransactionPromise, createIncludedTransaction, createRejectedTransaction, IncludedTransaction, RejectedTransaction, PendingTransactionStatus, PendingTransactionPromise, toPendingTransactionPromise, } from './transaction.js'; import { type FeePayerSpec, type ActionStates, Mina, defaultNetworkConstants, } from './mina-instance.js'; import { reportGetAccountError, defaultNetworkState, verifyTransactionLimits, verifyAccountUpdate, } from './transaction-validation.js'; import { prettifyStacktrace } from '../util/errors.js'; export { LocalBlockchain, TestPublicKey }; type TestPublicKey = PublicKey & { key: PrivateKey; }; function TestPublicKey(key: PrivateKey): TestPublicKey { return Object.assign(PublicKey.fromPrivateKey(key), { key }); } namespace TestPublicKey { export function random<N extends number = 1>( count: N = 1 as never ): N extends 1 ? TestPublicKey : TupleN<TestPublicKey, N> { if (count === 1) return TestPublicKey(PrivateKey.random()) as never; return Array.from({ length: count as number }, () => TestPublicKey(PrivateKey.random()) ) as never; } export function fromBase58(base58: string): TestPublicKey { return TestPublicKey(PrivateKey.fromBase58(base58)); } } /** * A mock Mina blockchain running locally and useful for testing. */ async function LocalBlockchain({ proofsEnabled = true, enforceTransactionLimits = true, } = {}) { await initializeBindings(); const slotTime = 3 * 60 * 1000; const startTime = Date.now(); const genesisTimestamp = UInt64.from(startTime); const ledger = Ledger.create(); let networkState = defaultNetworkState(); function addAccount(publicKey: PublicKey, balance: string) { try { ledger.addAccount(Ml.fromPublicKey(publicKey), balance); } catch (error) { throw prettifyStacktrace(error); } } let testAccounts = [] as never as TupleN<TestPublicKey, 10>; for (let i = 0; i < 10; ++i) { let MINA = 10n ** 9n; const largeValue = 1000n * MINA; const testAccount = TestPublicKey.random(); addAccount(testAccount, largeValue.toString()); testAccounts.push(testAccount); } const events: Record<string, any> = {}; const actions: Record< string, Record<string, { actions: string[][]; hash: string }[]> > = {}; const originalProofsEnabled = proofsEnabled; return { getNetworkId: () => 'testnet' as NetworkId, proofsEnabled, getNetworkConstants() { return { ...defaultNetworkConstants, genesisTimestamp, }; }, currentSlot() { return UInt32.from( Math.ceil((new Date().valueOf() - startTime) / slotTime) ); }, hasAccount(publicKey: PublicKey, tokenId: Field = TokenId.default) { return !!ledger.getAccount( Ml.fromPublicKey(publicKey), Ml.constFromField(tokenId) ); }, getAccount( publicKey: PublicKey, tokenId: Field = TokenId.default ): Account { let accountJson = ledger.getAccount( Ml.fromPublicKey(publicKey), Ml.constFromField(tokenId) ); if (accountJson === undefined) { throw new Error( reportGetAccountError(publicKey.toBase58(), TokenId.toBase58(tokenId)) ); } return Types.Account.fromJSON(accountJson); }, getNetworkState() { return networkState; }, sendTransaction( txn: Transaction<boolean, boolean> ): PendingTransactionPromise { return toPendingTransactionPromise(async () => { let zkappCommandJson = ZkappCommand.toJSON(txn.transaction); let commitments = transactionCommitments( TypesBigint.ZkappCommand.fromJSON(zkappCommandJson), this.getNetworkId() ); if (enforceTransactionLimits) verifyTransactionLimits(txn.transaction); // create an ad-hoc ledger to record changes to accounts within the transaction let simpleLedger = SimpleLedger.create(); for (const update of txn.transaction.accountUpdates) { let authIsProof = !!update.authorization.proof; let kindIsProof = update.body.authorizationKind.isProved.toBoolean(); // checks and edge case where a proof is expected, but the developer forgot to invoke await tx.prove() // this resulted in an assertion OCaml error, which didn't contain any useful information if (kindIsProof && !authIsProof) { throw Error( `The actual authorization does not match the expected authorization kind. Did you forget to invoke \`await tx.prove();\`?` ); } let account = simpleLedger.load(update.body); // the first time we encounter an account, use it from the persistent ledger if (account === undefined) { let accountJson = ledger.getAccount( Ml.fromPublicKey(update.body.publicKey), Ml.constFromField(update.body.tokenId) ); if (accountJson !== undefined) { let storedAccount = Account.fromJSON(accountJson); simpleLedger.store(storedAccount); account = storedAccount; } } // TODO: verify account update even if the account doesn't exist yet, using a default initial account if (account !== undefined) { let publicInput = update.toPublicInput(txn.transaction); await verifyAccountUpdate( account, update, publicInput, commitments, this.proofsEnabled, this.getNetworkId() ); simpleLedger.apply(update); } } let status: PendingTransactionStatus = 'pending'; const errors: string[] = []; try { ledger.applyJsonTransaction( JSON.stringify(zkappCommandJson), defaultNetworkConstants.accountCreationFee.toString(), JSON.stringify(networkState) ); } catch (err: any) { status = 'rejected'; try { const errorMessages = JSON.parse(err.message); const formattedError = invalidTransactionError( txn.transaction, errorMessages, { accountCreationFee: defaultNetworkConstants.accountCreationFee.toString(), } ); errors.push(formattedError); } catch (parseError: any) { const fallbackErrorMessage = err.message || parseError.message || 'Unknown error occurred'; errors.push(fallbackErrorMessage); } } // fetches all events from the transaction and stores them // events are identified and associated with a publicKey and tokenId txn.transaction.accountUpdates.forEach((p, i) => { let pJson = zkappCommandJson.accountUpdates[i]; let addr = pJson.body.publicKey; let tokenId = pJson.body.tokenId; events[addr] ??= {}; if (p.body.events.data.length > 0) { events[addr][tokenId] ??= []; let updatedEvents = p.body.events.data.map((data) => { return { data, transactionInfo: { transactionHash: '', transactionStatus: '', transactionMemo: '', }, }; }); events[addr][tokenId].push({ events: updatedEvents, blockHeight: networkState.blockchainLength, globalSlot: networkState.globalSlotSinceGenesis, // The following fields are fetched from the Mina network. For now, we mock these values out // since networkState does not contain these fields. blockHash: '', parentBlockHash: '', chainStatus: '', }); } // actions/sequencing events // most recent action state let storedActions = actions[addr]?.[tokenId]; let latestActionState_ = storedActions?.[storedActions.length - 1]?.hash; // if there exists no hash, this means we initialize our latest hash with the empty state let latestActionState = latestActionState_ !== undefined ? Field(latestActionState_) : Actions.emptyActionState(); actions[addr] ??= {}; if (p.body.actions.data.length > 0) { let newActionState = Actions.updateSequenceState( latestActionState, p.body.actions.hash ); actions[addr][tokenId] ??= []; actions[addr][tokenId].push({ actions: pJson.body.actions, hash: newActionState.toString(), }); } }); let test = await Test(); const hash = test.transactionHash.hashZkAppCommand(txn.toJSON()); const pendingTransaction: Omit< PendingTransaction, 'wait' | 'safeWait' > = { status, errors, transaction: txn.transaction, hash, toJSON: txn.toJSON, toPretty: txn.toPretty, }; const wait = async (_options?: { maxAttempts?: number; interval?: number; }): Promise<IncludedTransaction> => { const pendingTransaction = await safeWait(_options); if (pendingTransaction.status === 'rejected') { throw Error( `Transaction failed with errors:\n${pendingTransaction.errors.join( '\n' )}` ); } return pendingTransaction; }; const safeWait = async (_options?: { maxAttempts?: number; interval?: number; }): Promise<IncludedTransaction | RejectedTransaction> => { if (status === 'rejected') { return createRejectedTransaction( pendingTransaction, pendingTransaction.errors ); } return createIncludedTransaction(pendingTransaction); }; return { ...pendingTransaction, wait, safeWait, }; }); }, transaction(sender: FeePayerSpec, f: () => Promise<void>) { return toTransactionPromise(async () => { // TODO we run the transaction twice to match the behavior of `Network.transaction` let tx = await createTransaction(sender, f, 0, { isFinalRunOutsideCircuit: false, proofsEnabled: this.proofsEnabled, fetchMode: 'test', }); let hasProofs = tx.transaction.accountUpdates.some( Authorization.hasLazyProof ); return await createTransaction(sender, f, 1, { isFinalRunOutsideCircuit: !hasProofs, proofsEnabled: this.proofsEnabled, }); }); }, applyJsonTransaction(json: string) { return ledger.applyJsonTransaction( json, defaultNetworkConstants.accountCreationFee.toString(), JSON.stringify(networkState) ); }, async fetchEvents(publicKey: PublicKey, tokenId: Field = TokenId.default) { // Return events in reverse chronological order (latest events at the beginning) const reversedEvents = ( events?.[publicKey.toBase58()]?.[TokenId.toBase58(tokenId)] ?? [] ).reverse(); return reversedEvents; }, async fetchActions( publicKey: PublicKey, actionStates?: ActionStates, tokenId: Field = TokenId.default ) { return this.getActions(publicKey, actionStates, tokenId); }, getActions( publicKey: PublicKey, actionStates?: ActionStates, tokenId: Field = TokenId.default ): { hash: string; actions: string[][] }[] { let currentActions = actions?.[publicKey.toBase58()]?.[TokenId.toBase58(tokenId)] ?? []; let { fromActionState, endActionState } = actionStates ?? {}; let emptyState = Actions.emptyActionState(); if (endActionState?.equals(emptyState).toBoolean()) return []; let start = fromActionState?.equals(emptyState).toBoolean() ? undefined : fromActionState?.toString(); let end = endActionState?.toString(); let startIndex = 0; if (start) { let i = currentActions.findIndex((e) => e.hash === start); if (i === -1) throw Error(`getActions: fromActionState not found.`); startIndex = i + 1; } let endIndex: number | undefined; if (end) { let i = currentActions.findIndex((e) => e.hash === end); if (i === -1) throw Error(`getActions: endActionState not found.`); endIndex = i + 1; } return currentActions.slice(startIndex, endIndex); }, addAccount, /** * An array of 10 test accounts that have been pre-filled with * 30000000000 units of currency. */ testAccounts, setGlobalSlot(slot: UInt32 | number) { networkState.globalSlotSinceGenesis = UInt32.from(slot); }, incrementGlobalSlot(increment: UInt32 | number) { networkState.globalSlotSinceGenesis = networkState.globalSlotSinceGenesis.add(increment); }, setBlockchainLength(height: UInt32) { networkState.blockchainLength = height; }, setTotalCurrency(currency: UInt64) { networkState.totalCurrency = currency; }, setProofsEnabled(newProofsEnabled: boolean) { this.proofsEnabled = newProofsEnabled; }, resetProofsEnabled() { this.proofsEnabled = originalProofsEnabled; }, }; } // assert type compatibility without preventing LocalBlockchain to return additional properties / methods LocalBlockchain satisfies (...args: any) => Promise<Mina>;