From 9fe48d7f41a12c9401a98049be43351d8e569e91 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 7 Feb 2024 15:10:39 -0800 Subject: [PATCH 01/59] refactor(mina.ts, mina-instance.ts): rename TransactionId interface to PendingTransaction A simple rename from `TransactionId` to `PendingTransaction`. This rename provides better clarity on what is returned after sending a transaction. --- src/lib/mina.ts | 8 ++++---- src/lib/mina/mina-instance.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 22c83fe9bb..50b5a7c083 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -53,7 +53,7 @@ export { LocalBlockchain, currentTransaction, Transaction, - TransactionId, + PendingTransaction, activeInstance, setActiveInstance, transaction, @@ -88,7 +88,7 @@ setActiveInstance({ }, }); -interface TransactionId { +interface PendingTransaction { isSuccess: boolean; wait(options?: { maxAttempts?: number; interval?: number }): Promise; hash(): string | undefined; @@ -128,7 +128,7 @@ type Transaction = { /** * Sends the {@link Transaction} to the network. */ - send(): Promise; + send(): Promise; }; const Transaction = { @@ -387,7 +387,7 @@ function LocalBlockchain({ getNetworkState() { return networkState; }, - async sendTransaction(txn: Transaction): Promise { + async sendTransaction(txn: Transaction): Promise { txn.sign(); let zkappCommandJson = ZkappCommand.toJSON(txn.transaction); diff --git a/src/lib/mina/mina-instance.ts b/src/lib/mina/mina-instance.ts index dbaba81b33..5d1529d645 100644 --- a/src/lib/mina/mina-instance.ts +++ b/src/lib/mina/mina-instance.ts @@ -4,7 +4,7 @@ import type { Field } from '../field.js'; import { UInt64, UInt32 } from '../int.js'; import type { PublicKey, PrivateKey } from '../signature.js'; -import type { Transaction, TransactionId } from '../mina.js'; +import type { Transaction, PendingTransaction } from '../mina.js'; import type { Account } from './account.js'; import type { NetworkValue } from '../precondition.js'; import type * as Fetch from '../fetch.js'; @@ -91,7 +91,7 @@ interface Mina { * @deprecated use {@link getNetworkConstants} */ accountCreationFee(): UInt64; - sendTransaction(transaction: Transaction): Promise; + sendTransaction(transaction: Transaction): Promise; fetchEvents: ( publicKey: PublicKey, tokenId?: Field, From 16553064dd711edf88faf208aac5bc31bae53fc2 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 7 Feb 2024 16:11:13 -0800 Subject: [PATCH 02/59] refactor(fetch.ts): add generic type support to FetchResponse and related functions to improve type safety --- src/lib/fetch.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index f1d39ef8b8..87616ddad7 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -211,7 +211,7 @@ async function fetchAccountInternal( } type FetchConfig = { timeout?: number }; -type FetchResponse = { data: any; errors?: any }; +type FetchResponse = { data: TDataResponse; errors?: any }; type FetchError = { statusCode: number; statusText: string; @@ -1213,7 +1213,7 @@ function removeJsonQuotes(json: string) { } // TODO it seems we're not actually catching most errors here -async function makeGraphqlRequest( +async function makeGraphqlRequest( query: string, graphqlEndpoint = networkConfig.minaEndpoint, fallbackEndpoints: string[], @@ -1241,7 +1241,7 @@ async function makeGraphqlRequest( body, signal: controller.signal, }); - return checkResponseStatus(response); + return checkResponseStatus(response); } finally { clearTimeouts(); } @@ -1284,9 +1284,11 @@ async function makeGraphqlRequest( ]; } -async function checkResponseStatus( +async function checkResponseStatus( response: Response -): Promise<[FetchResponse, undefined] | [undefined, FetchError]> { +): Promise< + [FetchResponse, undefined] | [undefined, FetchError] +> { if (response.ok) { let jsonResponse = await response.json(); if (jsonResponse.errors && jsonResponse.errors.length > 0) { @@ -1308,7 +1310,7 @@ async function checkResponseStatus( } as FetchError, ]; } - return [jsonResponse as FetchResponse, undefined]; + return [jsonResponse as FetchResponse, undefined]; } else { return [ undefined, From 168a546578742d4ba21770c6c6a2cb49052fc3d9 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 7 Feb 2024 18:37:53 -0800 Subject: [PATCH 03/59] feat(fetch.ts, mina.ts): add missing properties to PendingTransaction PendingTransaction adds the `data` and `errors` property in the Network version. We now specify the properties inside `PendingTransaction` by making data be return type of sendZkApp which is a `SendZkAppResponse`. Also, this fixes a current bug where the `txnId` can be undefined when executing the polling for requests. --- src/lib/fetch.ts | 26 ++++++++++++++++++++------ src/lib/mina.ts | 35 +++++++++++++++++++++++++++-------- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index 87616ddad7..78f85b1145 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -1,7 +1,7 @@ import 'isomorphic-fetch'; import { Field } from './core.js'; import { UInt32, UInt64 } from './int.js'; -import { Actions, TokenId } from './account_update.js'; +import { Actions, TokenId, ZkappCommand } from './account_update.js'; import { PublicKey, PrivateKey } from './signature.js'; import { NetworkValue } from './precondition.js'; import { Types } from '../bindings/mina-transaction/types.js'; @@ -29,6 +29,7 @@ export { fetchTransactionStatus, TransactionStatus, EventActionFilterOptions, + SendZkAppResponse, getCachedAccount, getCachedNetwork, getCachedActions, @@ -488,15 +489,17 @@ const lastBlockQuery = `{ } }`; +type FailureReasonResponse = { + failures: string[]; + index: number; +}[]; + type LastBlockQueryFailureCheckResponse = { bestChain: { transactions: { zkappCommands: { hash: string; - failureReason: { - failures: string[]; - index: number; - }[]; + failureReason: FailureReasonResponse; }[]; }; }[]; @@ -688,6 +691,17 @@ async function fetchTransactionStatus( */ type TransactionStatus = 'INCLUDED' | 'PENDING' | 'UNKNOWN'; +type SendZkAppResponse = { + sendZkapp: { + zkapp: { + hash: string; + id: string; + zkappCommand: ZkappCommand; + failureReasons: FailureReasonResponse; + }; + }; +}; + /** * Sends a zkApp command (transaction) to the specified GraphQL endpoint. */ @@ -696,7 +710,7 @@ function sendZkapp( graphqlEndpoint = networkConfig.minaEndpoint, { timeout = defaultTimeout } = {} ) { - return makeGraphqlRequest( + return makeGraphqlRequest( sendZkappQuery(json), graphqlEndpoint, networkConfig.minaFallbackEndpoints, diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 50b5a7c083..4a4fa7660c 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -88,12 +88,6 @@ setActiveInstance({ }, }); -interface PendingTransaction { - isSuccess: boolean; - wait(options?: { maxAttempts?: number; interval?: number }): Promise; - hash(): string | undefined; -} - type Transaction = { /** * Transaction structure used to describe a state transition on the Mina blockchain. @@ -131,6 +125,17 @@ type Transaction = { send(): Promise; }; +type PendingTransaction = Pick< + Transaction, + 'transaction' | 'toJSON' | 'toPretty' +> & { + isSuccess: boolean; + wait(options?: { maxAttempts?: number; interval?: number }): Promise; + hash(): string; + data?: Fetch.SendZkAppResponse; + errors?: string[]; +}; + const Transaction = { fromJSON(json: Types.Json.ZkappCommand): Transaction { let transaction = ZkappCommand.fromJSON(json); @@ -517,6 +522,9 @@ function LocalBlockchain({ }); return { isSuccess: true, + transaction: txn.transaction, + toJSON: txn.toJSON, + toPretty: txn.toPretty, wait: async (_options?: { maxAttempts?: number; interval?: number; @@ -784,7 +792,7 @@ function Network( `getNetworkState: Could not fetch network state from graphql endpoint ${minaGraphqlEndpoint} outside of a transaction.` ); }, - async sendTransaction(txn: Transaction) { + async sendTransaction(txn: Transaction): Promise { txn.sign(); verifyTransactionLimits(txn.transaction); @@ -811,6 +819,9 @@ function Network( isSuccess, data: response?.data, errors, + transaction: txn.transaction, + toJSON: txn.toJSON, + toPretty: txn.toPretty, async wait(options?: { maxAttempts?: number; interval?: number }) { if (!isSuccess) { console.warn( @@ -829,6 +840,13 @@ function Network( reject: (err: Error) => void | Error ) => { let txId = response?.data?.sendZkapp?.zkapp?.hash; + if (!txId) { + return reject( + new Error( + `Transaction failed.\nCould not find the transaction hash.` + ) + ); + } let res; try { res = await Fetch.checkZkappTransaction(txId); @@ -862,7 +880,8 @@ function Network( return new Promise(executePoll); }, hash() { - return response?.data?.sendZkapp?.zkapp?.hash; + // TODO: compute this + return response?.data?.sendZkapp?.zkapp?.hash!; }, }; }, From a48ec9a3a92e02ec0e08d4bfcc593a92d5d2162a Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 7 Feb 2024 18:47:35 -0800 Subject: [PATCH 04/59] feat(fetch.ts): add type for `lastBlockQuery` --- src/lib/fetch.ts | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index 78f85b1145..a96783e57e 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -432,7 +432,7 @@ function accountCacheKey( * Fetches the last block on the Mina network. */ async function fetchLastBlock(graphqlEndpoint = networkConfig.minaEndpoint) { - let [resp, error] = await makeGraphqlRequest( + let [resp, error] = await makeGraphqlRequest( lastBlockQuery, graphqlEndpoint, networkConfig.minaFallbackEndpoints @@ -451,6 +451,43 @@ async function fetchLastBlock(graphqlEndpoint = networkConfig.minaEndpoint) { return network; } +type EpochData = { + ledger: { + hash: string; + totalCurrency: string; + }; + seed: string; + startCheckpoint: string; + lockCheckpoint: string; + epochLength: string; +}; + +type LastBlockQueryResponse = { + bestChain: { + protocolState: { + blockchainState: { + snarkedLedgerHash: string; + stagedLedgerHash: string; + date: string; + utcDate: string; + stagedLedgerProofEmitted: boolean; + }; + previousStateHash: string; + consensusState: { + blockHeight: string; + slotSinceGenesis: string; + slot: string; + nextEpochData: EpochData; + stakingEpochData: EpochData; + epochCount: string; + minWindowDensity: string; + totalCurrency: string; + epoch: string; + }; + }; + }[]; +}; + const lastBlockQuery = `{ bestChain(maxLength: 1) { protocolState { From 25bd926c1e1c3f97b80e4fe72f758f357b18e141 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 7 Feb 2024 18:49:40 -0800 Subject: [PATCH 05/59] feat(fetch.ts): add type for `lastBlockQueryFailure` --- src/lib/fetch.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index a96783e57e..ab7326c944 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -560,13 +560,14 @@ async function fetchLatestBlockZkappStatus( blockLength: number, graphqlEndpoint = networkConfig.minaEndpoint ) { - let [resp, error] = await makeGraphqlRequest( - lastBlockQueryFailureCheck(blockLength), - graphqlEndpoint, - networkConfig.minaFallbackEndpoints - ); + let [resp, error] = + await makeGraphqlRequest( + lastBlockQueryFailureCheck(blockLength), + graphqlEndpoint, + networkConfig.minaFallbackEndpoints + ); if (error) throw Error(`Error making GraphQL request: ${error.statusText}`); - let bestChain = resp?.data as LastBlockQueryFailureCheckResponse; + let bestChain = resp?.data; if (bestChain === undefined) { throw Error( 'Failed to fetch the latest zkApp transaction status. The response data is undefined.' From d1056373607be84580335cac911cf0e8cb71cb77 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 7 Feb 2024 18:55:17 -0800 Subject: [PATCH 06/59] feat(fetch.ts): add response type for 'transactionStatus' --- src/lib/fetch.ts | 43 +++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index ab7326c944..e2dde219c8 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -695,6 +695,10 @@ function parseEpochData({ }; } +type TransactionStatusQueryResponse = { + transactionStatus: TransactionStatus; +}; + const transactionStatusQuery = (txId: string) => `query { transactionStatus(zkappTransaction:"${txId}") }`; @@ -706,7 +710,7 @@ async function fetchTransactionStatus( txId: string, graphqlEndpoint = networkConfig.minaEndpoint ): Promise { - let [resp, error] = await makeGraphqlRequest( + let [resp, error] = await makeGraphqlRequest( transactionStatusQuery(txId), graphqlEndpoint, networkConfig.minaFallbackEndpoints @@ -791,24 +795,27 @@ function sendZkappQuery(json: string) { } `; } -type FetchedEvents = { - blockInfo: { - distanceFromMaxBlockHeight: number; - globalSlotSinceGenesis: number; - height: number; - stateHash: string; - parentHash: string; - chainStatus: string; - }; - eventData: { - transactionInfo: { - hash: string; - memo: string; - status: string; +type EventQueryResponse = { + events: { + blockInfo: { + distanceFromMaxBlockHeight: number; + globalSlotSinceGenesis: number; + height: number; + stateHash: string; + parentHash: string; + chainStatus: string; }; - data: string[]; + eventData: { + transactionInfo: { + hash: string; + memo: string; + status: string; + }; + data: string[]; + }[]; }[]; }; + type FetchedActions = { blockInfo: { distanceFromMaxBlockHeight: number; @@ -933,7 +940,7 @@ async function fetchEvents( 'fetchEvents: Specified GraphQL endpoint is undefined. Please specify a valid endpoint.' ); const { publicKey, tokenId } = accountInfo; - let [response, error] = await makeGraphqlRequest( + let [response, error] = await makeGraphqlRequest( getEventsQuery( publicKey, tokenId ?? TokenId.toBase58(TokenId.default), @@ -943,7 +950,7 @@ async function fetchEvents( networkConfig.archiveFallbackEndpoints ); if (error) throw Error(error.statusText); - let fetchedEvents = response?.data.events as FetchedEvents[]; + let fetchedEvents = response?.data.events; if (fetchedEvents === undefined) { throw Error( `Failed to fetch events data. Account: ${publicKey} Token: ${tokenId}` From 82c491318aa3a3e3d114072fc11b461150cead31 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 7 Feb 2024 19:00:34 -0800 Subject: [PATCH 07/59] refactor(fetch.ts): remove temporary fix for fetching event/action data from any block at the best tip This change was made because the issue https://github.com/o1-labs/Archive-Node-API/issues/7 has been resolved, making the temporary fix redundant. --- src/lib/fetch.ts | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index e2dde219c8..50edbe9e40 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -957,25 +957,6 @@ async function fetchEvents( ); } - // TODO: This is a temporary fix. We should be able to fetch the event/action data from any block at the best tip. - // Once https://github.com/o1-labs/Archive-Node-API/issues/7 is resolved, we can remove this. - // If we have multiple blocks returned at the best tip (e.g. distanceFromMaxBlockHeight === 0), - // then filter out the blocks at the best tip. This is because we cannot guarantee that every block - // at the best tip will have the correct event data or guarantee that the specific block data will not - // fork in anyway. If this happens, we delay fetching event data until another block has been added to the network. - let numberOfBestTipBlocks = 0; - for (let i = 0; i < fetchedEvents.length; i++) { - if (fetchedEvents[i].blockInfo.distanceFromMaxBlockHeight === 0) { - numberOfBestTipBlocks++; - } - if (numberOfBestTipBlocks > 1) { - fetchedEvents = fetchedEvents.filter((event) => { - return event.blockInfo.distanceFromMaxBlockHeight !== 0; - }); - break; - } - } - return fetchedEvents.map((event) => { let events = event.eventData.map(({ data, transactionInfo }) => { return { @@ -1028,25 +1009,6 @@ async function fetchActions( }; } - // TODO: This is a temporary fix. We should be able to fetch the event/action data from any block at the best tip. - // Once https://github.com/o1-labs/Archive-Node-API/issues/7 is resolved, we can remove this. - // If we have multiple blocks returned at the best tip (e.g. distanceFromMaxBlockHeight === 0), - // then filter out the blocks at the best tip. This is because we cannot guarantee that every block - // at the best tip will have the correct action data or guarantee that the specific block data will not - // fork in anyway. If this happens, we delay fetching action data until another block has been added to the network. - let numberOfBestTipBlocks = 0; - for (let i = 0; i < fetchedActions.length; i++) { - if (fetchedActions[i].blockInfo.distanceFromMaxBlockHeight === 0) { - numberOfBestTipBlocks++; - } - if (numberOfBestTipBlocks > 1) { - fetchedActions = fetchedActions.filter((action) => { - return action.blockInfo.distanceFromMaxBlockHeight !== 0; - }); - break; - } - } - let actionsList: { actions: string[][]; hash: string }[] = []; // correct for archive node sending one block too many if ( From e77dd4f48670108ba36b0185b9c6ee8ec6647052 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 7 Feb 2024 19:01:54 -0800 Subject: [PATCH 08/59] feat(fetch.ts): add type for 'getActions' --- src/lib/fetch.ts | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index 50edbe9e40..b362762aa7 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -816,17 +816,19 @@ type EventQueryResponse = { }[]; }; -type FetchedActions = { - blockInfo: { - distanceFromMaxBlockHeight: number; - }; - actionState: { - actionStateOne: string; - actionStateTwo: string; - }; - actionData: { - accountUpdateId: string; - data: string[]; +type ActionQueryResponse = { + actions: { + blockInfo: { + distanceFromMaxBlockHeight: number; + }; + actionState: { + actionStateOne: string; + actionStateTwo: string; + }; + actionData: { + accountUpdateId: string; + data: string[]; + }[]; }[]; }; @@ -993,13 +995,13 @@ async function fetchActions( actionStates, tokenId = TokenId.toBase58(TokenId.default), } = accountInfo; - let [response, error] = await makeGraphqlRequest( + let [response, error] = await makeGraphqlRequest( getActionsQuery(publicKey, actionStates, tokenId), graphqlEndpoint, networkConfig.archiveFallbackEndpoints ); if (error) throw Error(error.statusText); - let fetchedActions = response?.data.actions as FetchedActions[]; + let fetchedActions = response?.data.actions; if (fetchedActions === undefined) { return { error: { From fa4208c551bca84de5106583a7f87238666ac2d5 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 7 Feb 2024 19:42:02 -0800 Subject: [PATCH 09/59] refactor(graphql.ts): add a graphql module under mina dir Adds graphql.ts which is a module to hold all response type and graphql query resources when interacting with the mina daemon graphql. We remove these from fetch.ts as fetch was starting to get bloated and harder to read. This aims to be a refactor that offers a cleaner seperation of concerns between the graphql resources and the actual requests being sent --- src/index.ts | 2 +- src/lib/fetch.ts | 365 ++------------------------------- src/lib/mina.ts | 13 +- src/lib/mina/graphql.ts | 369 ++++++++++++++++++++++++++++++++++ src/lib/mina/mina-instance.ts | 3 +- 5 files changed, 401 insertions(+), 351 deletions(-) create mode 100644 src/lib/mina/graphql.ts diff --git a/src/index.ts b/src/index.ts index 485d2dcfdf..d927fff65f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,7 +72,7 @@ export { TransactionVersion, } from './lib/account_update.js'; -export type { TransactionStatus } from './lib/fetch.js'; +export type { TransactionStatus } from './lib/mina/graphql.js'; export { fetchAccount, fetchLastBlock, diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index b362762aa7..b74003242b 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -15,6 +15,25 @@ import { parseFetchedAccount, PartialAccount, } from './mina/account.js'; +import { + type LastBlockQueryResponse, + type GenesisConstants, + type LastBlockQueryFailureCheckResponse, + type FetchedBlock, + type TransactionStatus, + type TransactionStatusQueryResponse, + type EventQueryResponse, + type ActionQueryResponse, + type EventActionFilterOptions, + type SendZkAppResponse, + sendZkappQuery, + lastBlockQuery, + lastBlockQueryFailureCheck, + transactionStatusQuery, + getEventsQuery, + getActionsQuery, + genesisConstantsQuery, +} from './mina/graphql.js'; export { fetchAccount, @@ -27,9 +46,6 @@ export { markActionsToBeFetched, fetchMissingData, fetchTransactionStatus, - TransactionStatus, - EventActionFilterOptions, - SendZkAppResponse, getCachedAccount, getCachedNetwork, getCachedActions, @@ -42,13 +58,13 @@ export { setArchiveGraphqlEndpoint, setArchiveGraphqlFallbackEndpoints, setLightnetAccountManagerEndpoint, - sendZkappQuery, sendZkapp, removeJsonQuotes, fetchEvents, fetchActions, Lightnet, type GenesisConstants, + type ActionStatesStringified, }; type NetworkConfig = { @@ -220,16 +236,6 @@ type FetchError = { type ActionStatesStringified = { [K in keyof ActionStates]: string; }; -type GenesisConstants = { - genesisTimestamp: string; - coinbase: number; - accountCreationFee: number; - epochDuration: number; - k: number; - slotDuration: number; - slotsPerEpoch: number; -}; - // Specify 5min as the default timeout const defaultTimeout = 5 * 60 * 1000; @@ -451,111 +457,6 @@ async function fetchLastBlock(graphqlEndpoint = networkConfig.minaEndpoint) { return network; } -type EpochData = { - ledger: { - hash: string; - totalCurrency: string; - }; - seed: string; - startCheckpoint: string; - lockCheckpoint: string; - epochLength: string; -}; - -type LastBlockQueryResponse = { - bestChain: { - protocolState: { - blockchainState: { - snarkedLedgerHash: string; - stagedLedgerHash: string; - date: string; - utcDate: string; - stagedLedgerProofEmitted: boolean; - }; - previousStateHash: string; - consensusState: { - blockHeight: string; - slotSinceGenesis: string; - slot: string; - nextEpochData: EpochData; - stakingEpochData: EpochData; - epochCount: string; - minWindowDensity: string; - totalCurrency: string; - epoch: string; - }; - }; - }[]; -}; - -const lastBlockQuery = `{ - bestChain(maxLength: 1) { - protocolState { - blockchainState { - snarkedLedgerHash - stagedLedgerHash - date - utcDate - stagedLedgerProofEmitted - } - previousStateHash - consensusState { - blockHeight - slotSinceGenesis - slot - nextEpochData { - ledger {hash totalCurrency} - seed - startCheckpoint - lockCheckpoint - epochLength - } - stakingEpochData { - ledger {hash totalCurrency} - seed - startCheckpoint - lockCheckpoint - epochLength - } - epochCount - minWindowDensity - totalCurrency - epoch - } - } - } -}`; - -type FailureReasonResponse = { - failures: string[]; - index: number; -}[]; - -type LastBlockQueryFailureCheckResponse = { - bestChain: { - transactions: { - zkappCommands: { - hash: string; - failureReason: FailureReasonResponse; - }[]; - }; - }[]; -}; - -const lastBlockQueryFailureCheck = (length: number) => `{ - bestChain(maxLength: ${length}) { - transactions { - zkappCommands { - hash - failureReason { - failures - index - } - } - } - } -}`; - async function fetchLatestBlockZkappStatus( blockLength: number, graphqlEndpoint = networkConfig.minaEndpoint @@ -608,48 +509,6 @@ async function checkZkappTransaction(txnId: string, blockLength = 20) { }; } -type FetchedBlock = { - protocolState: { - blockchainState: { - snarkedLedgerHash: string; // hash-like encoding - stagedLedgerHash: string; // hash-like encoding - date: string; // String(Date.now()) - utcDate: string; // String(Date.now()) - stagedLedgerProofEmitted: boolean; // bool - }; - previousStateHash: string; // hash-like encoding - consensusState: { - blockHeight: string; // String(number) - slotSinceGenesis: string; // String(number) - slot: string; // String(number) - nextEpochData: { - ledger: { - hash: string; // hash-like encoding - totalCurrency: string; // String(number) - }; - seed: string; // hash-like encoding - startCheckpoint: string; // hash-like encoding - lockCheckpoint: string; // hash-like encoding - epochLength: string; // String(number) - }; - stakingEpochData: { - ledger: { - hash: string; // hash-like encoding - totalCurrency: string; // String(number) - }; - seed: string; // hash-like encoding - startCheckpoint: string; // hash-like encoding - lockCheckpoint: string; // hash-like encoding - epochLength: string; // String(number) - }; - epochCount: string; // String(number) - minWindowDensity: string; // String(number) - totalCurrency: string; // String(number) - epoch: string; // String(number) - }; - }; -}; - function parseFetchedBlock({ protocolState: { blockchainState: { snarkedLedgerHash, utcDate }, @@ -695,14 +554,6 @@ function parseEpochData({ }; } -type TransactionStatusQueryResponse = { - transactionStatus: TransactionStatus; -}; - -const transactionStatusQuery = (txId: string) => `query { - transactionStatus(zkappTransaction:"${txId}") -}`; - /** * Fetches the status of a transaction. */ @@ -723,27 +574,6 @@ async function fetchTransactionStatus( return txStatus as TransactionStatus; } -/** - * INCLUDED: A transaction that is on the longest chain - * - * PENDING: A transaction either in the transition frontier or in transaction pool but is not on the longest chain - * - * UNKNOWN: The transaction has either been snarked, reached finality through consensus or has been dropped - * - */ -type TransactionStatus = 'INCLUDED' | 'PENDING' | 'UNKNOWN'; - -type SendZkAppResponse = { - sendZkapp: { - zkapp: { - hash: string; - id: string; - zkappCommand: ZkappCommand; - failureReasons: FailureReasonResponse; - }; - }; -}; - /** * Sends a zkApp command (transaction) to the specified GraphQL endpoint. */ @@ -762,161 +592,6 @@ function sendZkapp( ); } -// TODO: Decide an appropriate response structure. -function sendZkappQuery(json: string) { - return `mutation { - sendZkapp(input: { - zkappCommand: ${removeJsonQuotes(json)} - }) { - zkapp { - hash - id - failureReason { - failures - index - } - zkappCommand { - memo - feePayer { - body { - publicKey - } - } - accountUpdates { - body { - publicKey - useFullCommitment - incrementNonce - } - } - } - } - } -} -`; -} -type EventQueryResponse = { - events: { - blockInfo: { - distanceFromMaxBlockHeight: number; - globalSlotSinceGenesis: number; - height: number; - stateHash: string; - parentHash: string; - chainStatus: string; - }; - eventData: { - transactionInfo: { - hash: string; - memo: string; - status: string; - }; - data: string[]; - }[]; - }[]; -}; - -type ActionQueryResponse = { - actions: { - blockInfo: { - distanceFromMaxBlockHeight: number; - }; - actionState: { - actionStateOne: string; - actionStateTwo: string; - }; - actionData: { - accountUpdateId: string; - data: string[]; - }[]; - }[]; -}; - -type EventActionFilterOptions = { - to?: UInt32; - from?: UInt32; -}; - -const getEventsQuery = ( - publicKey: string, - tokenId: string, - filterOptions?: EventActionFilterOptions -) => { - const { to, from } = filterOptions ?? {}; - let input = `address: "${publicKey}", tokenId: "${tokenId}"`; - if (to !== undefined) { - input += `, to: ${to}`; - } - if (from !== undefined) { - input += `, from: ${from}`; - } - return `{ - events(input: { ${input} }) { - blockInfo { - distanceFromMaxBlockHeight - height - globalSlotSinceGenesis - stateHash - parentHash - chainStatus - } - eventData { - transactionInfo { - hash - memo - status - } - data - } - } -}`; -}; -const getActionsQuery = ( - publicKey: string, - actionStates: ActionStatesStringified, - tokenId: string, - _filterOptions?: EventActionFilterOptions -) => { - const { fromActionState, endActionState } = actionStates ?? {}; - let input = `address: "${publicKey}", tokenId: "${tokenId}"`; - if (fromActionState !== undefined) { - input += `, fromActionState: "${fromActionState}"`; - } - if (endActionState !== undefined) { - input += `, endActionState: "${endActionState}"`; - } - return `{ - actions(input: { ${input} }) { - blockInfo { - distanceFromMaxBlockHeight - } - actionState { - actionStateOne - actionStateTwo - } - actionData { - accountUpdateId - data - } - } -}`; -}; -const genesisConstantsQuery = `{ - genesisConstants { - genesisTimestamp - coinbase - accountCreationFee - } - daemonStatus { - consensusConfiguration { - epochDuration - k - slotDuration - slotsPerEpoch - } - } - }`; - /** * Asynchronously fetches event data for an account from the Mina Archive Node GraphQL API. * @async diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 4a4fa7660c..a110fc3776 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -45,6 +45,11 @@ import { } from './mina/mina-instance.js'; import { SimpleLedger } from './mina/transaction-logic/ledger.js'; import { assert } from './gadgets/common.js'; +import { + type EventActionFilterOptions, + type SendZkAppResponse, + sendZkappQuery, +} from './mina/graphql.js'; export { createTransaction, @@ -132,7 +137,7 @@ type PendingTransaction = Pick< isSuccess: boolean; wait(options?: { maxAttempts?: number; interval?: number }): Promise; hash(): string; - data?: Fetch.SendZkAppResponse; + data?: SendZkAppResponse; errors?: string[]; }; @@ -298,7 +303,7 @@ function newTransaction(transaction: ZkappCommand, proofsEnabled?: boolean) { return ZkappCommand.toPretty(self.transaction); }, toGraphqlQuery() { - return Fetch.sendZkappQuery(self.toJSON()); + return sendZkappQuery(self.toJSON()); }, async send() { try { @@ -902,7 +907,7 @@ function Network( async fetchEvents( publicKey: PublicKey, tokenId: Field = TokenId.default, - filterOptions: Fetch.EventActionFilterOptions = {} + filterOptions: EventActionFilterOptions = {} ) { let pubKey = publicKey.toBase58(); let token = TokenId.toBase58(tokenId); @@ -1123,7 +1128,7 @@ async function sendTransaction(txn: Transaction) { async function fetchEvents( publicKey: PublicKey, tokenId: Field, - filterOptions: Fetch.EventActionFilterOptions = {} + filterOptions: EventActionFilterOptions = {} ) { return await activeInstance.fetchEvents(publicKey, tokenId, filterOptions); } diff --git a/src/lib/mina/graphql.ts b/src/lib/mina/graphql.ts new file mode 100644 index 0000000000..a5ca944842 --- /dev/null +++ b/src/lib/mina/graphql.ts @@ -0,0 +1,369 @@ +import { ActionStatesStringified, removeJsonQuotes } from '../fetch.js'; +import { UInt32 } from '../int.js'; +import { ZkappCommand } from '../account_update.js'; + +export { + type EpochData, + type LastBlockQueryResponse, + type GenesisConstants, + type FailureReasonResponse, + type LastBlockQueryFailureCheckResponse, + type FetchedBlock, + type TransactionStatus, + type TransactionStatusQueryResponse, + type EventQueryResponse, + type ActionQueryResponse, + type EventActionFilterOptions, + type SendZkAppResponse, + getEventsQuery, + getActionsQuery, + sendZkappQuery, + transactionStatusQuery, + lastBlockQuery, + lastBlockQueryFailureCheck, + genesisConstantsQuery, +}; + +type GenesisConstants = { + genesisTimestamp: string; + coinbase: number; + accountCreationFee: number; + epochDuration: number; + k: number; + slotDuration: number; + slotsPerEpoch: number; +}; + +type EpochData = { + ledger: { + hash: string; + totalCurrency: string; + }; + seed: string; + startCheckpoint: string; + lockCheckpoint: string; + epochLength: string; +}; + +type LastBlockQueryResponse = { + bestChain: { + protocolState: { + blockchainState: { + snarkedLedgerHash: string; + stagedLedgerHash: string; + date: string; + utcDate: string; + stagedLedgerProofEmitted: boolean; + }; + previousStateHash: string; + consensusState: { + blockHeight: string; + slotSinceGenesis: string; + slot: string; + nextEpochData: EpochData; + stakingEpochData: EpochData; + epochCount: string; + minWindowDensity: string; + totalCurrency: string; + epoch: string; + }; + }; + }[]; +}; + +type FailureReasonResponse = { + failures: string[]; + index: number; +}[]; + +type LastBlockQueryFailureCheckResponse = { + bestChain: { + transactions: { + zkappCommands: { + hash: string; + failureReason: FailureReasonResponse; + }[]; + }; + }[]; +}; + +type FetchedBlock = { + protocolState: { + blockchainState: { + snarkedLedgerHash: string; // hash-like encoding + stagedLedgerHash: string; // hash-like encoding + date: string; // String(Date.now()) + utcDate: string; // String(Date.now()) + stagedLedgerProofEmitted: boolean; // bool + }; + previousStateHash: string; // hash-like encoding + consensusState: { + blockHeight: string; // String(number) + slotSinceGenesis: string; // String(number) + slot: string; // String(number) + nextEpochData: { + ledger: { + hash: string; // hash-like encoding + totalCurrency: string; // String(number) + }; + seed: string; // hash-like encoding + startCheckpoint: string; // hash-like encoding + lockCheckpoint: string; // hash-like encoding + epochLength: string; // String(number) + }; + stakingEpochData: { + ledger: { + hash: string; // hash-like encoding + totalCurrency: string; // String(number) + }; + seed: string; // hash-like encoding + startCheckpoint: string; // hash-like encoding + lockCheckpoint: string; // hash-like encoding + epochLength: string; // String(number) + }; + epochCount: string; // String(number) + minWindowDensity: string; // String(number) + totalCurrency: string; // String(number) + epoch: string; // String(number) + }; + }; +}; + +/** + * INCLUDED: A transaction that is on the longest chain + * + * PENDING: A transaction either in the transition frontier or in transaction pool but is not on the longest chain + * + * UNKNOWN: The transaction has either been snarked, reached finality through consensus or has been dropped + * + */ +type TransactionStatus = 'INCLUDED' | 'PENDING' | 'UNKNOWN'; + +type TransactionStatusQueryResponse = { + transactionStatus: TransactionStatus; +}; + +type SendZkAppResponse = { + sendZkapp: { + zkapp: { + hash: string; + id: string; + zkappCommand: ZkappCommand; + failureReasons: FailureReasonResponse; + }; + }; +}; + +type EventQueryResponse = { + events: { + blockInfo: { + distanceFromMaxBlockHeight: number; + globalSlotSinceGenesis: number; + height: number; + stateHash: string; + parentHash: string; + chainStatus: string; + }; + eventData: { + transactionInfo: { + hash: string; + memo: string; + status: string; + }; + data: string[]; + }[]; + }[]; +}; + +type ActionQueryResponse = { + actions: { + blockInfo: { + distanceFromMaxBlockHeight: number; + }; + actionState: { + actionStateOne: string; + actionStateTwo: string; + }; + actionData: { + accountUpdateId: string; + data: string[]; + }[]; + }[]; +}; + +type EventActionFilterOptions = { + to?: UInt32; + from?: UInt32; +}; + +const transactionStatusQuery = (txId: string) => `query { + transactionStatus(zkappTransaction:"${txId}") + }`; + +const getEventsQuery = ( + publicKey: string, + tokenId: string, + filterOptions?: EventActionFilterOptions +) => { + const { to, from } = filterOptions ?? {}; + let input = `address: "${publicKey}", tokenId: "${tokenId}"`; + if (to !== undefined) { + input += `, to: ${to}`; + } + if (from !== undefined) { + input += `, from: ${from}`; + } + return `{ + events(input: { ${input} }) { + blockInfo { + distanceFromMaxBlockHeight + height + globalSlotSinceGenesis + stateHash + parentHash + chainStatus + } + eventData { + transactionInfo { + hash + memo + status + } + data + } + } +}`; +}; + +const getActionsQuery = ( + publicKey: string, + actionStates: ActionStatesStringified, + tokenId: string, + _filterOptions?: EventActionFilterOptions +) => { + const { fromActionState, endActionState } = actionStates ?? {}; + let input = `address: "${publicKey}", tokenId: "${tokenId}"`; + if (fromActionState !== undefined) { + input += `, fromActionState: "${fromActionState}"`; + } + if (endActionState !== undefined) { + input += `, endActionState: "${endActionState}"`; + } + return `{ + actions(input: { ${input} }) { + blockInfo { + distanceFromMaxBlockHeight + } + actionState { + actionStateOne + actionStateTwo + } + actionData { + accountUpdateId + data + } + } +}`; +}; + +const genesisConstantsQuery = `{ + genesisConstants { + genesisTimestamp + coinbase + accountCreationFee + } + daemonStatus { + consensusConfiguration { + epochDuration + k + slotDuration + slotsPerEpoch + } + } + }`; + +const lastBlockQuery = `{ + bestChain(maxLength: 1) { + protocolState { + blockchainState { + snarkedLedgerHash + stagedLedgerHash + date + utcDate + stagedLedgerProofEmitted + } + previousStateHash + consensusState { + blockHeight + slotSinceGenesis + slot + nextEpochData { + ledger {hash totalCurrency} + seed + startCheckpoint + lockCheckpoint + epochLength + } + stakingEpochData { + ledger {hash totalCurrency} + seed + startCheckpoint + lockCheckpoint + epochLength + } + epochCount + minWindowDensity + totalCurrency + epoch + } + } + } +}`; + +const lastBlockQueryFailureCheck = (length: number) => `{ + bestChain(maxLength: ${length}) { + transactions { + zkappCommands { + hash + failureReason { + failures + index + } + } + } + } +}`; + +// TODO: Decide an appropriate response structure. +function sendZkappQuery(json: string) { + return `mutation { + sendZkapp(input: { + zkappCommand: ${removeJsonQuotes(json)} + }) { + zkapp { + hash + id + failureReason { + failures + index + } + zkappCommand { + memo + feePayer { + body { + publicKey + } + } + accountUpdates { + body { + publicKey + useFullCommitment + incrementNonce + } + } + } + } + } +} +`; +} diff --git a/src/lib/mina/mina-instance.ts b/src/lib/mina/mina-instance.ts index 5d1529d645..0d7b8029f0 100644 --- a/src/lib/mina/mina-instance.ts +++ b/src/lib/mina/mina-instance.ts @@ -9,6 +9,7 @@ import type { Account } from './account.js'; import type { NetworkValue } from '../precondition.js'; import type * as Fetch from '../fetch.js'; import type { NetworkId } from '../../mina-signer/src/TSTypes.js'; +import { type EventActionFilterOptions } from '././../mina/graphql.js'; export { Mina, @@ -95,7 +96,7 @@ interface Mina { fetchEvents: ( publicKey: PublicKey, tokenId?: Field, - filterOptions?: Fetch.EventActionFilterOptions + filterOptions?: EventActionFilterOptions ) => ReturnType; fetchActions: ( publicKey: PublicKey, From c861ff78394c0012db240b54134a16e9b47dfad8 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Thu, 8 Feb 2024 10:52:20 -0800 Subject: [PATCH 10/59] feat(mina.ts): add IncludedTransaction type to handle transactions that have been included in the blockchain This new type extends the PendingTransaction type with an additional 'isIncluded' boolean property. This will allow us to easily distinguish between pending and included transactions in our code. --- src/lib/mina.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index a110fc3776..59a232804d 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -141,6 +141,13 @@ type PendingTransaction = Pick< errors?: string[]; }; +type IncludedTransaction = Pick< + PendingTransaction, + 'transaction' | 'toJSON' | 'toPretty' | 'hash' | 'data' | 'errors' +> & { + isIncluded: boolean; +}; + const Transaction = { fromJSON(json: Types.Json.ZkappCommand): Transaction { let transaction = ZkappCommand.fromJSON(json); From 4a9af9d95975b63bb92b184a27cab5879f742516 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Thu, 8 Feb 2024 11:32:25 -0800 Subject: [PATCH 11/59] feat(mina.ts): modify wait function to return IncludedTransaction --- src/lib/mina.ts | 194 +++++++++++++++++++++++++++++++----------------- 1 file changed, 126 insertions(+), 68 deletions(-) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 59a232804d..c583c65d8e 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -135,7 +135,10 @@ type PendingTransaction = Pick< 'transaction' | 'toJSON' | 'toPretty' > & { isSuccess: boolean; - wait(options?: { maxAttempts?: number; interval?: number }): Promise; + wait(options?: { + maxAttempts?: number; + interval?: number; + }): Promise; hash(): string; data?: SendZkAppResponse; errors?: string[]; @@ -148,6 +151,26 @@ type IncludedTransaction = Pick< isIncluded: boolean; }; +function createIncludedTransaction( + isIncluded: boolean, + { + transaction, + toJSON, + toPretty, + hash, + data, + }: Omit +): IncludedTransaction { + return { + isIncluded, + transaction, + toJSON, + toPretty, + hash, + data, + }; +} + const Transaction = { fromJSON(json: Types.Json.ZkappCommand): Transaction { let transaction = ZkappCommand.fromJSON(json); @@ -532,19 +555,12 @@ function LocalBlockchain({ }); } }); - return { + + const pendingTransaction = { isSuccess: true, transaction: txn.transaction, toJSON: txn.toJSON, toPretty: txn.toPretty, - wait: async (_options?: { - maxAttempts?: number; - interval?: number; - }) => { - console.log( - 'Info: Waiting for inclusion in a block is not supported for LocalBlockchain.' - ); - }, hash: (): string => { const message = 'Info: Txn Hash retrieving is not supported for LocalBlockchain.'; @@ -552,6 +568,23 @@ function LocalBlockchain({ return message; }, }; + + const wait = async (_options?: { + maxAttempts?: number; + interval?: number; + }) => { + console.log( + 'Info: Waiting for inclusion in a block is not supported for LocalBlockchain.' + ); + return Promise.resolve( + createIncludedTransaction(true, pendingTransaction) + ); + }; + + return { + ...pendingTransaction, + wait, + }; }, async transaction(sender: DeprecatedFeePayerSpec, f: () => void) { // bad hack: run transaction just to see whether it creates proofs @@ -827,75 +860,100 @@ function Network( let attempts = 0; let interval: number; - return { + const pendingTransaction = { isSuccess, data: response?.data, errors, transaction: txn.transaction, toJSON: txn.toJSON, toPretty: txn.toPretty, - async wait(options?: { maxAttempts?: number; interval?: number }) { - if (!isSuccess) { - console.warn( - 'Transaction.wait(): returning immediately because the transaction was not successful.' - ); - return; - } - // default is 45 attempts * 20s each = 15min - // the block time on berkeley is currently longer than the average 3-4min, so its better to target a higher block time - // fetching an update every 20s is more than enough with a current block time of 3min - maxAttempts = options?.maxAttempts ?? 45; - interval = options?.interval ?? 20000; - - const executePoll = async ( - resolve: () => void, - reject: (err: Error) => void | Error - ) => { - let txId = response?.data?.sendZkapp?.zkapp?.hash; - if (!txId) { - return reject( - new Error( - `Transaction failed.\nCould not find the transaction hash.` - ) - ); - } - let res; - try { - res = await Fetch.checkZkappTransaction(txId); - } catch (error) { - isSuccess = false; - return reject(error as Error); - } - attempts++; - if (res.success) { - isSuccess = true; - return resolve(); - } else if (res.failureReason) { - isSuccess = false; - return reject( - new Error( - `Transaction failed.\nTransactionId: ${txId}\nAttempts: ${attempts}\nfailureReason(s): ${res.failureReason}` - ) - ); - } else if (maxAttempts && attempts === maxAttempts) { - isSuccess = false; - return reject( - new Error( - `Exceeded max attempts.\nTransactionId: ${txId}\nAttempts: ${attempts}\nLast received status: ${res}` - ) - ); - } else { - setTimeout(executePoll, interval, resolve, reject); - } - }; - - return new Promise(executePoll); - }, hash() { // TODO: compute this return response?.data?.sendZkapp?.zkapp?.hash!; }, }; + + const wait = async (options?: { + maxAttempts?: number; + interval?: number; + }) => { + if (!isSuccess) { + console.warn( + 'Transaction.wait(): returning immediately because the transaction was not successful.' + ); + const includedTransaction = createIncludedTransaction( + false, + pendingTransaction + ); + includedTransaction.errors = errors; + return includedTransaction; + } + // default is 45 attempts * 20s each = 15min + // the block time on berkeley is currently longer than the average 3-4min, so its better to target a higher block time + // fetching an update every 20s is more than enough with a current block time of 3min + maxAttempts = options?.maxAttempts ?? 45; + interval = options?.interval ?? 20000; + + const executePoll = async ( + resolve: (i: IncludedTransaction) => void, + reject: (i: IncludedTransaction) => void + ) => { + let txId = response?.data?.sendZkapp?.zkapp?.hash; + if (!txId) { + const includedTransaction = createIncludedTransaction( + false, + pendingTransaction + ); + includedTransaction.errors = [ + `Transaction failed.\nCould not find the transaction hash.`, + ]; + return reject(includedTransaction); + } + let res; + try { + res = await Fetch.checkZkappTransaction(txId); + } catch (error) { + const includedTransaction = createIncludedTransaction( + false, + pendingTransaction + ); + includedTransaction.errors = [(error as Error).message]; + return reject(includedTransaction); + } + attempts++; + if (res.success) { + return resolve(createIncludedTransaction(true, pendingTransaction)); + } else if (res.failureReason) { + // isSuccess = false; + const includedTransaction = createIncludedTransaction( + false, + pendingTransaction + ); + includedTransaction.errors = [ + `Transaction failed.\nTransactionId: ${txId}\nAttempts: ${attempts}\nfailureReason(s): ${res.failureReason}`, + ]; + return reject(includedTransaction); + } else if (maxAttempts && attempts === maxAttempts) { + const includedTransaction = createIncludedTransaction( + false, + pendingTransaction + ); + includedTransaction.errors = [ + `Exceeded max attempts.\nTransactionId: ${txId}\nAttempts: ${attempts}\nLast received status: ${res}`, + ]; + return reject(includedTransaction); + } else { + setTimeout(executePoll, interval, resolve, reject); + } + }; + + return new Promise(executePoll); + }; + + return { + ...pendingTransaction, + wait, + }; }, async transaction(sender: DeprecatedFeePayerSpec, f: () => void) { let tx = createTransaction(sender, f, 0, { From 89d2bcce14aa0e132ea73ba716d28aa4aeb41573 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Thu, 8 Feb 2024 11:51:05 -0800 Subject: [PATCH 12/59] refactor(mina.ts): simplify transaction status polling logic --- src/lib/mina.ts | 131 ++++++++++++++++++++---------------------------- 1 file changed, 54 insertions(+), 77 deletions(-) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index c583c65d8e..db210a8af3 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -152,17 +152,18 @@ type IncludedTransaction = Pick< }; function createIncludedTransaction( - isIncluded: boolean, { transaction, toJSON, toPretty, hash, data, - }: Omit + }: Omit, + errors?: string[] ): IncludedTransaction { return { - isIncluded, + isIncluded: errors === undefined, + errors, transaction, toJSON, toPretty, @@ -576,9 +577,7 @@ function LocalBlockchain({ console.log( 'Info: Waiting for inclusion in a block is not supported for LocalBlockchain.' ); - return Promise.resolve( - createIncludedTransaction(true, pendingTransaction) - ); + return Promise.resolve(createIncludedTransaction(pendingTransaction)); }; return { @@ -855,11 +854,7 @@ function Network( errors = response.errors; } - let isSuccess = errors === undefined; - let maxAttempts: number; - let attempts = 0; - let interval: number; - + const isSuccess = errors === undefined; const pendingTransaction = { isSuccess, data: response?.data, @@ -873,81 +868,63 @@ function Network( }, }; + const pollTransactionStatus = async ( + txId: string, + maxAttempts: number, + interval: number, + attempts: number = 0 + ): Promise => { + let res: Awaited>; + try { + res = await Fetch.checkZkappTransaction(txId); + if (res.success) { + return createIncludedTransaction(pendingTransaction); + } else if (res.failureReason) { + return createIncludedTransaction(pendingTransaction, [ + `Transaction failed.\nTransactionId: ${txId}\nAttempts: ${attempts}\nfailureReason(s): ${res.failureReason}`, + ]); + } + } catch (error) { + return createIncludedTransaction(pendingTransaction, [ + (error as Error).message, + ]); + } + + if (maxAttempts && attempts >= maxAttempts) { + return createIncludedTransaction(pendingTransaction, [ + `Exceeded max attempts.\nTransactionId: ${txId}\nAttempts: ${attempts}\nLast received status: ${res}`, + ]); + } + + await new Promise((resolve) => setTimeout(resolve, interval)); + return pollTransactionStatus(txId, maxAttempts, interval, attempts + 1); + }; + const wait = async (options?: { maxAttempts?: number; interval?: number; - }) => { + }): Promise => { if (!isSuccess) { - console.warn( - 'Transaction.wait(): returning immediately because the transaction was not successful.' - ); - const includedTransaction = createIncludedTransaction( - false, - pendingTransaction + return createIncludedTransaction( + pendingTransaction, + pendingTransaction.errors ); - includedTransaction.errors = errors; - return includedTransaction; } + // default is 45 attempts * 20s each = 15min // the block time on berkeley is currently longer than the average 3-4min, so its better to target a higher block time // fetching an update every 20s is more than enough with a current block time of 3min - maxAttempts = options?.maxAttempts ?? 45; - interval = options?.interval ?? 20000; - - const executePoll = async ( - resolve: (i: IncludedTransaction) => void, - reject: (i: IncludedTransaction) => void - ) => { - let txId = response?.data?.sendZkapp?.zkapp?.hash; - if (!txId) { - const includedTransaction = createIncludedTransaction( - false, - pendingTransaction - ); - includedTransaction.errors = [ - `Transaction failed.\nCould not find the transaction hash.`, - ]; - return reject(includedTransaction); - } - let res; - try { - res = await Fetch.checkZkappTransaction(txId); - } catch (error) { - const includedTransaction = createIncludedTransaction( - false, - pendingTransaction - ); - includedTransaction.errors = [(error as Error).message]; - return reject(includedTransaction); - } - attempts++; - if (res.success) { - return resolve(createIncludedTransaction(true, pendingTransaction)); - } else if (res.failureReason) { - // isSuccess = false; - const includedTransaction = createIncludedTransaction( - false, - pendingTransaction - ); - includedTransaction.errors = [ - `Transaction failed.\nTransactionId: ${txId}\nAttempts: ${attempts}\nfailureReason(s): ${res.failureReason}`, - ]; - return reject(includedTransaction); - } else if (maxAttempts && attempts === maxAttempts) { - const includedTransaction = createIncludedTransaction( - false, - pendingTransaction - ); - includedTransaction.errors = [ - `Exceeded max attempts.\nTransactionId: ${txId}\nAttempts: ${attempts}\nLast received status: ${res}`, - ]; - return reject(includedTransaction); - } else { - setTimeout(executePoll, interval, resolve, reject); - } - }; - - return new Promise(executePoll); + const maxAttempts = options?.maxAttempts ?? 45; + const interval = options?.interval ?? 20000; + const txId = response?.data?.sendZkapp?.zkapp?.hash; + + if (!txId) { + return createIncludedTransaction( + pendingTransaction, + pendingTransaction.errors + ); + } + return pollTransactionStatus(txId, maxAttempts, interval); }; return { From 50a7e5ce42da019acf9985b69ed349033bb1c851 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Thu, 8 Feb 2024 11:52:27 -0800 Subject: [PATCH 13/59] fix(run_live.ts): change types from Mina.TransactionId to Mina.PendingTransaction --- src/examples/zkapps/dex/run_live.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/examples/zkapps/dex/run_live.ts b/src/examples/zkapps/dex/run_live.ts index 256ba5f87d..7c5093fe41 100644 --- a/src/examples/zkapps/dex/run_live.ts +++ b/src/examples/zkapps/dex/run_live.ts @@ -35,7 +35,7 @@ const network = Mina.Network({ }); Mina.setActiveInstance(network); -let tx, pendingTx: Mina.TransactionId, balances, oldBalances; +let tx, pendingTx: Mina.PendingTransaction, balances, oldBalances; // compile contracts & wait for fee payer to be funded const senderKey = useCustomLocalNetwork @@ -285,7 +285,7 @@ async function ensureFundedAccount(privateKeyBase58: string) { return { senderKey, sender }; } -function logPendingTransaction(pendingTx: Mina.TransactionId) { +function logPendingTransaction(pendingTx: Mina.PendingTransaction) { if (!pendingTx.isSuccess) throw Error('transaction failed'); console.log( 'tx sent: ' + From 08ab94f42d05a31ab2ad44ac57685c69ebc69f0d Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Thu, 8 Feb 2024 11:55:03 -0800 Subject: [PATCH 14/59] feat(mina.ts): add IncludedTransaction to exports to allow access to this type from other modules --- src/lib/mina.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index db210a8af3..55f7d1ace7 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -59,6 +59,7 @@ export { currentTransaction, Transaction, PendingTransaction, + IncludedTransaction, activeInstance, setActiveInstance, transaction, From 9f6a8b414a8f2ab4db68aae699bf7fa94e45c42d Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Thu, 8 Feb 2024 12:48:19 -0800 Subject: [PATCH 15/59] refactor(mina.ts): simplify IncludedTransaction type definition using Omit utility type --- src/lib/mina.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 55f7d1ace7..a74058c7ae 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -145,10 +145,7 @@ type PendingTransaction = Pick< errors?: string[]; }; -type IncludedTransaction = Pick< - PendingTransaction, - 'transaction' | 'toJSON' | 'toPretty' | 'hash' | 'data' | 'errors' -> & { +type IncludedTransaction = Omit & { isIncluded: boolean; }; From cd3808e5ca3d059cd26ae4234b72cad93f8d6074 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Mon, 12 Feb 2024 14:49:37 -0800 Subject: [PATCH 16/59] feat(mina.ts): add sendOrThrowIfError method to Transaction type This method sends the Transaction to the network and throws an error if internal errors are detected. This provides a more robust way of handling transaction failures. --- src/lib/mina.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index a74058c7ae..bd00d8f47c 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -129,6 +129,11 @@ type Transaction = { * Sends the {@link Transaction} to the network. */ send(): Promise; + + /** + * Sends the {@link Transaction} to the network, unlike the standard send(), this function will throw an error if internal errors are detected. + */ + sendOrThrowIfError(): Promise; }; type PendingTransaction = Pick< @@ -341,6 +346,9 @@ function newTransaction(transaction: ZkappCommand, proofsEnabled?: boolean) { throw prettifyStacktrace(error); } }, + async sendOrThrowIfError() { + return await sendOrThrowIfError(self); + }, }; return self; } @@ -1162,6 +1170,16 @@ async function sendTransaction(txn: Transaction) { return await activeInstance.sendTransaction(txn); } +async function sendOrThrowIfError(txn: Transaction) { + const pendingTransaction = await sendTransaction(txn); + if (!pendingTransaction.isSuccess) { + throw Error( + `Transaction failed: ${JSON.stringify(pendingTransaction.errors)}` + ); + } + return pendingTransaction; +} + /** * @return A list of emitted events associated to the given public key. */ From 2329353c2a9c9e0f012d3d5398082d4f88c22ab4 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Mon, 12 Feb 2024 15:23:13 -0800 Subject: [PATCH 17/59] feat(mina.ts): add RejectedTransaction type and waitOrThrowIfError --- src/lib/mina.ts | 100 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 20 deletions(-) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index bd00d8f47c..815c04d0be 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -144,29 +144,54 @@ type PendingTransaction = Pick< wait(options?: { maxAttempts?: number; interval?: number; - }): Promise; + }): Promise; + waitOrThrowIfError(options?: { + maxAttempts?: number; + interval?: number; + }): Promise; hash(): string; data?: SendZkAppResponse; errors?: string[]; }; -type IncludedTransaction = Omit & { - isIncluded: boolean; +type IncludedTransaction = Pick< + PendingTransaction, + 'transaction' | 'toJSON' | 'toPretty' | 'hash' | 'data' +> & { + status: 'included'; +}; + +type RejectedTransaction = Pick< + PendingTransaction, + 'transaction' | 'toJSON' | 'toPretty' | 'hash' | 'data' +> & { + status: 'rejected'; + errors: string[]; }; -function createIncludedTransaction( +function createIncludedOrRejectedTransaction( { transaction, + data, toJSON, toPretty, hash, - data, - }: Omit, + }: Omit, errors?: string[] -): IncludedTransaction { +): IncludedTransaction | RejectedTransaction { + if (errors !== undefined) { + return { + status: 'rejected', + errors, + transaction, + toJSON, + toPretty, + hash, + data, + }; + } return { - isIncluded: errors === undefined, - errors, + status: 'included', transaction, toJSON, toPretty, @@ -583,12 +608,27 @@ function LocalBlockchain({ console.log( 'Info: Waiting for inclusion in a block is not supported for LocalBlockchain.' ); - return Promise.resolve(createIncludedTransaction(pendingTransaction)); + return Promise.resolve( + createIncludedOrRejectedTransaction(pendingTransaction) + ); + }; + + const waitOrThrowIfError = async (_options?: { + maxAttempts?: number; + interval?: number; + }) => { + console.log( + 'Info: Waiting for inclusion in a block is not supported for LocalBlockchain.' + ); + return Promise.resolve( + createIncludedOrRejectedTransaction(pendingTransaction) + ); }; return { ...pendingTransaction, wait, + waitOrThrowIfError, }; }, async transaction(sender: DeprecatedFeePayerSpec, f: () => void) { @@ -879,25 +919,25 @@ function Network( maxAttempts: number, interval: number, attempts: number = 0 - ): Promise => { + ): Promise => { let res: Awaited>; try { res = await Fetch.checkZkappTransaction(txId); if (res.success) { - return createIncludedTransaction(pendingTransaction); + return createIncludedOrRejectedTransaction(pendingTransaction); } else if (res.failureReason) { - return createIncludedTransaction(pendingTransaction, [ + return createIncludedOrRejectedTransaction(pendingTransaction, [ `Transaction failed.\nTransactionId: ${txId}\nAttempts: ${attempts}\nfailureReason(s): ${res.failureReason}`, ]); } } catch (error) { - return createIncludedTransaction(pendingTransaction, [ + return createIncludedOrRejectedTransaction(pendingTransaction, [ (error as Error).message, ]); } if (maxAttempts && attempts >= maxAttempts) { - return createIncludedTransaction(pendingTransaction, [ + return createIncludedOrRejectedTransaction(pendingTransaction, [ `Exceeded max attempts.\nTransactionId: ${txId}\nAttempts: ${attempts}\nLast received status: ${res}`, ]); } @@ -909,9 +949,9 @@ function Network( const wait = async (options?: { maxAttempts?: number; interval?: number; - }): Promise => { + }): Promise => { if (!isSuccess) { - return createIncludedTransaction( + return createIncludedOrRejectedTransaction( pendingTransaction, pendingTransaction.errors ); @@ -925,7 +965,7 @@ function Network( const txId = response?.data?.sendZkapp?.zkapp?.hash; if (!txId) { - return createIncludedTransaction( + return createIncludedOrRejectedTransaction( pendingTransaction, pendingTransaction.errors ); @@ -933,9 +973,25 @@ function Network( return pollTransactionStatus(txId, maxAttempts, interval); }; + const waitOrThrowIfError = async (options?: { + maxAttempts?: number; + interval?: number; + }): Promise => { + const pendingTransaction = await wait(options); + if (pendingTransaction.status === 'rejected') { + `Transaction failed with errors: ${JSON.stringify( + pendingTransaction.errors, + null, + 2 + )}`; + } + return pendingTransaction; + }; + return { ...pendingTransaction, wait, + waitOrThrowIfError, }; }, async transaction(sender: DeprecatedFeePayerSpec, f: () => void) { @@ -1172,9 +1228,13 @@ async function sendTransaction(txn: Transaction) { async function sendOrThrowIfError(txn: Transaction) { const pendingTransaction = await sendTransaction(txn); - if (!pendingTransaction.isSuccess) { + if (pendingTransaction.errors) { throw Error( - `Transaction failed: ${JSON.stringify(pendingTransaction.errors)}` + `Transaction failed with errors: ${JSON.stringify( + pendingTransaction.errors, + null, + 2 + )}` ); } return pendingTransaction; From 6e49d78f8b5d9aa1b640d3029705c82b37cb5351 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Mon, 12 Feb 2024 15:24:21 -0800 Subject: [PATCH 18/59] feat(mina.ts): add RejectedTransaction to exports --- src/lib/mina.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 815c04d0be..91cbee418a 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -60,6 +60,7 @@ export { Transaction, PendingTransaction, IncludedTransaction, + RejectedTransaction, activeInstance, setActiveInstance, transaction, From baa33d3b9db122e64ba1943399da3bf719eb6b5f Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Mon, 12 Feb 2024 15:26:12 -0800 Subject: [PATCH 19/59] fix(mina.ts): throw error when transaction is rejected to handle transaction failure properly --- src/lib/mina.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 91cbee418a..b2a93628d5 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -980,11 +980,13 @@ function Network( }): Promise => { const pendingTransaction = await wait(options); if (pendingTransaction.status === 'rejected') { - `Transaction failed with errors: ${JSON.stringify( - pendingTransaction.errors, - null, - 2 - )}`; + throw Error( + `Transaction failed with errors: ${JSON.stringify( + pendingTransaction.errors, + null, + 2 + )}` + ); } return pendingTransaction; }; From b06cfcf9d625db8b59da856900a92931a5f6b4aa Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Tue, 13 Feb 2024 10:52:04 -0800 Subject: [PATCH 20/59] refactor(graphql.ts): rename import from 'account_update.js' to 'account-update.js' to maintain file naming consistency --- src/lib/mina/graphql.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/mina/graphql.ts b/src/lib/mina/graphql.ts index a5ca944842..6b7002f7af 100644 --- a/src/lib/mina/graphql.ts +++ b/src/lib/mina/graphql.ts @@ -1,6 +1,6 @@ import { ActionStatesStringified, removeJsonQuotes } from '../fetch.js'; import { UInt32 } from '../int.js'; -import { ZkappCommand } from '../account_update.js'; +import { ZkappCommand } from '../account-update.js'; export { type EpochData, From 8cf9bafb84209848c3a732c5f69b154f2da4bccf Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Tue, 13 Feb 2024 15:33:20 -0800 Subject: [PATCH 21/59] refactor(fetch.ts, account.ts, graphql.ts): move FetchedAccount type and accountQuery from account.ts to graphql.ts --- src/lib/fetch.ts | 11 ++-- src/lib/mina/account.ts | 140 ++++++++-------------------------------- src/lib/mina/graphql.ts | 95 +++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 119 deletions(-) diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index ecd2224391..a2b4ea0f62 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -9,8 +9,6 @@ import { ActionStates } from './mina.js'; import { LedgerHash, EpochSeed, StateHash } from './base58-encodings.js'; import { Account, - accountQuery, - FetchedAccount, fillPartialAccount, parseFetchedAccount, PartialAccount, @@ -26,6 +24,7 @@ import { type ActionQueryResponse, type EventActionFilterOptions, type SendZkAppResponse, + type FetchedAccount, sendZkappQuery, lastBlockQuery, lastBlockQueryFailureCheck, @@ -33,6 +32,7 @@ import { getEventsQuery, getActionsQuery, genesisConstantsQuery, + accountQuery, } from './mina/graphql.js'; export { @@ -200,16 +200,15 @@ async function fetchAccountInternal( config?: FetchConfig ) { const { publicKey, tokenId } = accountInfo; - let [response, error] = await makeGraphqlRequest( + let [response, error] = await makeGraphqlRequest( accountQuery(publicKey, tokenId ?? TokenId.toBase58(TokenId.default)), graphqlEndpoint, networkConfig.minaFallbackEndpoints, config ); if (error !== undefined) return { account: undefined, error }; - let fetchedAccount = (response as FetchResponse).data - .account as FetchedAccount | null; - if (fetchedAccount === null) { + let fetchedAccount = response?.data; + if (!fetchedAccount) { return { account: undefined, error: { diff --git a/src/lib/mina/account.ts b/src/lib/mina/account.ts index af77941426..ea99bb52b1 100644 --- a/src/lib/mina/account.ts +++ b/src/lib/mina/account.ts @@ -11,11 +11,11 @@ import { } from '../../bindings/mina-transaction/gen/transaction.js'; import { jsLayout } from '../../bindings/mina-transaction/gen/js-layout.js'; import { ProvableExtended } from '../circuit-value.js'; +import { FetchedAccount } from './graphql.js'; -export { FetchedAccount, Account, PartialAccount }; -export { newAccount, accountQuery, parseFetchedAccount, fillPartialAccount }; +export { Account, PartialAccount }; +export { newAccount, parseFetchedAccount, fillPartialAccount }; -type AuthRequired = Types.Json.AuthRequired; type Account = Types.Account; const Account = Types.Account; @@ -34,117 +34,31 @@ type PartialAccount = Omit, 'zkapp'> & { zkapp?: Partial; }; -// TODO auto-generate this type and the query -type FetchedAccount = { - publicKey: string; - token: string; - nonce: string; - balance: { total: string }; - tokenSymbol: string | null; - receiptChainHash: string | null; - timing: { - initialMinimumBalance: string | null; - cliffTime: string | null; - cliffAmount: string | null; - vestingPeriod: string | null; - vestingIncrement: string | null; - }; - permissions: { - editState: AuthRequired; - access: AuthRequired; - send: AuthRequired; - receive: AuthRequired; - setDelegate: AuthRequired; - setPermissions: AuthRequired; - setVerificationKey: { - auth: AuthRequired; - txnVersion: string; - }; - setZkappUri: AuthRequired; - editActionState: AuthRequired; - setTokenSymbol: AuthRequired; - incrementNonce: AuthRequired; - setVotingFor: AuthRequired; - setTiming: AuthRequired; - } | null; - delegateAccount: { publicKey: string } | null; - votingFor: string | null; - zkappState: string[] | null; - verificationKey: { verificationKey: string; hash: string } | null; - actionState: string[] | null; - provedState: boolean | null; - zkappUri: string | null; -}; -const accountQuery = (publicKey: string, tokenId: string) => `{ - account(publicKey: "${publicKey}", token: "${tokenId}") { - publicKey - token - nonce - balance { total } - tokenSymbol - receiptChainHash - timing { - initialMinimumBalance - cliffTime - cliffAmount - vestingPeriod - vestingIncrement - } - permissions { - editState - access - send - receive - setDelegate - setPermissions - setVerificationKey { - auth - txnVersion - } - setZkappUri - editActionState - setTokenSymbol - incrementNonce - setVotingFor - setTiming - } - delegateAccount { publicKey } - votingFor - zkappState - verificationKey { - verificationKey - hash - } - actionState - provedState - zkappUri - } -} -`; - // convert FetchedAccount (from graphql) to Account (internal representation both here and in Mina) -function parseFetchedAccount({ - publicKey, - nonce, - zkappState, - balance, - permissions, - timing: { - cliffAmount, - cliffTime, - initialMinimumBalance, - vestingIncrement, - vestingPeriod, - }, - delegateAccount, - receiptChainHash, - actionState, - token, - tokenSymbol, - verificationKey, - provedState, - zkappUri, -}: FetchedAccount): Account { +function parseFetchedAccount({ account }: FetchedAccount): Account { + const { + publicKey, + nonce, + zkappState, + balance, + permissions, + timing: { + cliffAmount, + cliffTime, + initialMinimumBalance, + vestingIncrement, + vestingPeriod, + }, + delegateAccount, + receiptChainHash, + actionState, + token, + tokenSymbol, + verificationKey, + provedState, + zkappUri, + } = account; + let hasZkapp = zkappState !== null || verificationKey !== null || diff --git a/src/lib/mina/graphql.ts b/src/lib/mina/graphql.ts index 6b7002f7af..cdb9de9f57 100644 --- a/src/lib/mina/graphql.ts +++ b/src/lib/mina/graphql.ts @@ -1,6 +1,7 @@ import { ActionStatesStringified, removeJsonQuotes } from '../fetch.js'; import { UInt32 } from '../int.js'; import { ZkappCommand } from '../account-update.js'; +import { Types } from '../../bindings/mina-transaction/types.js'; export { type EpochData, @@ -15,6 +16,7 @@ export { type ActionQueryResponse, type EventActionFilterOptions, type SendZkAppResponse, + type FetchedAccount, getEventsQuery, getActionsQuery, sendZkappQuery, @@ -22,6 +24,52 @@ export { lastBlockQuery, lastBlockQueryFailureCheck, genesisConstantsQuery, + accountQuery, +}; + +type AuthRequired = Types.Json.AuthRequired; +// TODO auto-generate this type and the query +type FetchedAccount = { + account: { + publicKey: string; + token: string; + nonce: string; + balance: { total: string }; + tokenSymbol: string | null; + receiptChainHash: string | null; + timing: { + initialMinimumBalance: string | null; + cliffTime: string | null; + cliffAmount: string | null; + vestingPeriod: string | null; + vestingIncrement: string | null; + }; + permissions: { + editState: AuthRequired; + access: AuthRequired; + send: AuthRequired; + receive: AuthRequired; + setDelegate: AuthRequired; + setPermissions: AuthRequired; + setVerificationKey: { + auth: AuthRequired; + txnVersion: string; + }; + setZkappUri: AuthRequired; + editActionState: AuthRequired; + setTokenSymbol: AuthRequired; + incrementNonce: AuthRequired; + setVotingFor: AuthRequired; + setTiming: AuthRequired; + } | null; + delegateAccount: { publicKey: string } | null; + votingFor: string | null; + zkappState: string[] | null; + verificationKey: { verificationKey: string; hash: string } | null; + actionState: string[] | null; + provedState: boolean | null; + zkappUri: string | null; + }; }; type GenesisConstants = { @@ -367,3 +415,50 @@ function sendZkappQuery(json: string) { } `; } + +const accountQuery = (publicKey: string, tokenId: string) => `{ + account(publicKey: "${publicKey}", token: "${tokenId}") { + publicKey + token + nonce + balance { total } + tokenSymbol + receiptChainHash + timing { + initialMinimumBalance + cliffTime + cliffAmount + vestingPeriod + vestingIncrement + } + permissions { + editState + access + send + receive + setDelegate + setPermissions + setVerificationKey { + auth + txnVersion + } + setZkappUri + editActionState + setTokenSymbol + incrementNonce + setVotingFor + setTiming + } + delegateAccount { publicKey } + votingFor + zkappState + verificationKey { + verificationKey + hash + } + actionState + provedState + zkappUri + } +} +`; From 4060d89400df95ee8085575d0d5bdea62aa661b5 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Tue, 13 Feb 2024 16:26:45 -0800 Subject: [PATCH 22/59] feat(fetch.ts, graphql.ts): add GenesisConstants type to handle genesis constants data --- src/lib/fetch.ts | 13 +++++++++++-- src/lib/mina/graphql.ts | 28 +++++++++++++++++----------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index a2b4ea0f62..ae6b35136c 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -15,7 +15,7 @@ import { } from './mina/account.js'; import { type LastBlockQueryResponse, - type GenesisConstants, + type GenesisConstantsResponse, type LastBlockQueryFailureCheckResponse, type FetchedBlock, type TransactionStatus, @@ -276,6 +276,15 @@ let actionsToFetch = {} as Record< graphqlEndpoint: string; } >; +type GenesisConstants = { + genesisTimestamp: string; + coinbase: number; + accountCreationFee: number; + epochDuration: number; + k: number; + slotDuration: number; + slotsPerEpoch: number; +}; let genesisConstantsCache = {} as Record; function markAccountToBeFetched( @@ -748,7 +757,7 @@ async function fetchActions( async function fetchGenesisConstants( graphqlEndpoint = networkConfig.minaEndpoint ): Promise { - let [resp, error] = await makeGraphqlRequest( + let [resp, error] = await makeGraphqlRequest( genesisConstantsQuery, graphqlEndpoint, networkConfig.minaFallbackEndpoints diff --git a/src/lib/mina/graphql.ts b/src/lib/mina/graphql.ts index cdb9de9f57..4e91b45122 100644 --- a/src/lib/mina/graphql.ts +++ b/src/lib/mina/graphql.ts @@ -6,7 +6,7 @@ import { Types } from '../../bindings/mina-transaction/types.js'; export { type EpochData, type LastBlockQueryResponse, - type GenesisConstants, + type GenesisConstantsResponse, type FailureReasonResponse, type LastBlockQueryFailureCheckResponse, type FetchedBlock, @@ -72,16 +72,6 @@ type FetchedAccount = { }; }; -type GenesisConstants = { - genesisTimestamp: string; - coinbase: number; - accountCreationFee: number; - epochDuration: number; - k: number; - slotDuration: number; - slotsPerEpoch: number; -}; - type EpochData = { ledger: { hash: string; @@ -177,6 +167,22 @@ type FetchedBlock = { }; }; +type GenesisConstantsResponse = { + genesisConstants: { + genesisTimestamp: string; + coinbase: string; + accountCreationFee: string; + }; + daemonStatus: { + consensusConfiguration: { + epochDuration: string; + k: string; + slotDuration: string; + slotsPerEpoch: string; + }; + }; +}; + /** * INCLUDED: A transaction that is on the longest chain * From f3906d2cd348c3726a7b2bcd2cb407426ee5e851 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 14 Feb 2024 11:18:19 -0800 Subject: [PATCH 23/59] feat(fetch.ts, graphql.ts): add fetchCurrentSlot function and CurrentSlotResponse type to fetch current slot data from the server This change allows the application to fetch the current slot data from the server, which is necessary for the application's functionality. --- src/lib/fetch.ts | 19 +++++++++++++++++++ src/lib/mina/graphql.ts | 26 ++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index ae6b35136c..9cbc1abdff 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -25,6 +25,7 @@ import { type EventActionFilterOptions, type SendZkAppResponse, type FetchedAccount, + type CurrentSlotResponse, sendZkappQuery, lastBlockQuery, lastBlockQueryFailureCheck, @@ -33,12 +34,14 @@ import { getActionsQuery, genesisConstantsQuery, accountQuery, + currentSlotQuery, } from './mina/graphql.js'; export { fetchAccount, fetchLastBlock, fetchGenesisConstants, + fetchCurrentSlot, checkZkappTransaction, parseFetchedAccount, markAccountToBeFetched, @@ -465,6 +468,22 @@ async function fetchLastBlock(graphqlEndpoint = networkConfig.minaEndpoint) { return network; } +async function fetchCurrentSlot(graphqlEndpoint = networkConfig.minaEndpoint) { + let [resp, error] = await makeGraphqlRequest( + currentSlotQuery, + graphqlEndpoint, + networkConfig.minaFallbackEndpoints + ); + if (error) throw Error(`Error making GraphQL request: ${error.statusText}`); + let bestChain = resp?.data?.bestChain; + if (!bestChain || bestChain.length === 0) { + throw Error( + 'Failed to fetch the current slot. The response data is undefined.' + ); + } + return bestChain[0].protocolState.consensusState.slot; +} + async function fetchLatestBlockZkappStatus( blockLength: number, graphqlEndpoint = networkConfig.minaEndpoint diff --git a/src/lib/mina/graphql.ts b/src/lib/mina/graphql.ts index 4e91b45122..53f91e8836 100644 --- a/src/lib/mina/graphql.ts +++ b/src/lib/mina/graphql.ts @@ -17,14 +17,16 @@ export { type EventActionFilterOptions, type SendZkAppResponse, type FetchedAccount, + type CurrentSlotResponse, getEventsQuery, getActionsQuery, sendZkappQuery, transactionStatusQuery, - lastBlockQuery, lastBlockQueryFailureCheck, - genesisConstantsQuery, accountQuery, + currentSlotQuery, + genesisConstantsQuery, + lastBlockQuery, }; type AuthRequired = Types.Json.AuthRequired; @@ -183,6 +185,16 @@ type GenesisConstantsResponse = { }; }; +type CurrentSlotResponse = { + bestChain: Array<{ + protocolState: { + consensusState: { + slot: number; + }; + }; + }>; +}; + /** * INCLUDED: A transaction that is on the longest chain * @@ -468,3 +480,13 @@ const accountQuery = (publicKey: string, tokenId: string) => `{ } } `; + +const currentSlotQuery = `{ + bestChain(maxLength: 1) { + protocolState { + consensusState { + slot + } + } + } +}`; From bc351638ff1787a8731ed03730a978d165be6de2 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Thu, 15 Feb 2024 11:06:57 -0800 Subject: [PATCH 24/59] feat(tests): add transaction-flow test suite for zkApp This commit introduces a new test suite for the zkApp transaction flow. The tests cover various scenarios including local and remote tests, transaction verification, zkApp deployment, method calls, event emission and fetching, and action rollups. The tests are designed to ensure the correct behavior of the zkApp and its interaction with the Mina protocol. --- src/tests/transaction-flow.ts | 291 ++++++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 src/tests/transaction-flow.ts diff --git a/src/tests/transaction-flow.ts b/src/tests/transaction-flow.ts new file mode 100644 index 0000000000..936df0ff4f --- /dev/null +++ b/src/tests/transaction-flow.ts @@ -0,0 +1,291 @@ +import { + AccountUpdate, + Provable, + Field, + Lightnet, + Mina, + PrivateKey, + Struct, + PublicKey, + SmartContract, + State, + state, + method, + Reducer, + fetchAccount, + TokenId, +} from 'o1js'; +import assert from 'node:assert'; + +/** + * currentSlot: + * - Remote: not implemented, throws + * - Local: implemented + */ + +class Event extends Struct({ pub: PublicKey, value: Field }) {} + +class SimpleZkapp extends SmartContract { + @state(Field) x = State(); + @state(Field) counter = State(); + @state(Field) actionState = State(); + + reducer = Reducer({ actionType: Field }); + + events = { + complexEvent: Event, + simpleEvent: Field, + }; + + init() { + super.init(); + this.x.set(Field(2)); + this.counter.set(Field(0)); + this.actionState.set(Reducer.initialActionState); + } + + @method incrementCounter() { + this.reducer.dispatch(Field(1)); + } + + @method rollupIncrements() { + let counter = this.counter.get(); + this.counter.requireEquals(counter); + let actionState = this.actionState.get(); + this.actionState.requireEquals(actionState); + + const endActionState = this.account.actionState.getAndRequireEquals(); + + let pendingActions = this.reducer.getActions({ + fromActionState: actionState, + endActionState, + }); + + let { state: newCounter, actionState: newActionState } = + this.reducer.reduce( + pendingActions, + Field, + (state: Field, _action: Field) => { + return state.add(1); + }, + { state: counter, actionState } + ); + + // update on-chain state + this.counter.set(newCounter); + this.actionState.set(newActionState); + } + + @method update(y: Field, publicKey: PublicKey) { + this.emitEvent('complexEvent', { + pub: publicKey, + value: y, + }); + this.emitEvent('simpleEvent', y); + let x = this.x.getAndRequireEquals(); + this.x.set(x.add(y)); + } +} + +async function testLocalAndRemote( + f: (...args: any[]) => Promise, + ...args: any[] +) { + console.log('⌛ Performing local test'); + Mina.setActiveInstance(Local); + const localResponse = await f(...args); + + console.log('⌛ Performing remote test'); + Mina.setActiveInstance(Remote); + const networkResponse = await f(...args); + + if (localResponse !== undefined && networkResponse !== undefined) { + assert.strictEqual( + JSON.stringify(localResponse), + JSON.stringify(networkResponse) + ); + } + console.log('✅ Test passed'); +} + +async function sendAndVerifyTransaction(transaction: Mina.Transaction) { + await transaction.prove(); + const pendingTransaction = await transaction.send(); + assert(pendingTransaction.hash() !== undefined); + const includedTransaction = await pendingTransaction.wait(); + assert(includedTransaction.status === 'included'); +} + +const transactionFee = 100_000_000; + +let Local = Mina.LocalBlockchain(); +const Remote = Mina.Network({ + mina: 'http://localhost:8080/graphql', + archive: 'http://localhost:8282 ', + lightnetAccountManager: 'http://localhost:8181', +}); + +// First set active instance to remote so we can sync up accounts between remote and local ledgers +Mina.setActiveInstance(Remote); + +const senderKey = (await Lightnet.acquireKeyPair()).privateKey; +const sender = senderKey.toPublicKey(); +const zkAppKey = (await Lightnet.acquireKeyPair()).privateKey; +const zkAppAddress = zkAppKey.toPublicKey(); + +// Same balance as remote ledger +const balance = (1550n * 10n ** 9n).toString(); +Local.addAccount(sender, balance); +Local.addAccount(zkAppAddress, balance); + +console.log('Compiling the smart contract.'); +const { verificationKey } = await SimpleZkapp.compile(); +const zkApp = new SimpleZkapp(zkAppAddress); +console.log(''); + +console.log('Testing network auxiliary functions do not throw'); +await testLocalAndRemote(async () => { + try { + await Mina.transaction({ sender, fee: transactionFee }, () => { + Mina.getNetworkConstants(); + Mina.getNetworkState(); + Mina.getNetworkId(); + Mina.getProofsEnabled(); + }); + } catch (error) { + assert.ifError(error); + } +}); +console.log(''); + +console.log( + `Test 'fetchAccount', 'getAccount', and 'hasAccount' match behavior using publicKey: ${zkAppAddress.toBase58()}` +); +await testLocalAndRemote(async () => { + try { + await fetchAccount({ publicKey: zkAppAddress }); // Must call fetchAccount to populate internal account cache + const account = Mina.getAccount(zkAppAddress); + return { + publicKey: account.publicKey, + nonce: account.nonce, + hasAccount: Mina.hasAccount(zkAppAddress), + }; + } catch (error) { + assert.ifError(error); + } +}); +console.log(''); + +console.log('Test deploying zkApp for public key ' + zkAppAddress.toBase58()); +await testLocalAndRemote(async () => { + try { + const transaction = await Mina.transaction( + { sender, fee: transactionFee }, + () => { + zkApp.deploy({ verificationKey }); + } + ); + transaction.sign([senderKey, zkAppKey]); + await sendAndVerifyTransaction(transaction); + } catch (error) { + assert.ifError(error); + } +}); +console.log(''); + +console.log("Test calling 'update' method on zkApp does not throw"); +await testLocalAndRemote(async () => { + try { + const transaction = await Mina.transaction( + { sender, fee: transactionFee }, + () => { + zkApp.update(Field(1), PrivateKey.random().toPublicKey()); + } + ); + transaction.sign([senderKey, zkAppKey]); + await sendAndVerifyTransaction(transaction); + await Mina.fetchEvents(zkAppAddress, TokenId.default); + } catch (error) { + assert.ifError(error); + } +}); +console.log(''); + +console.log("Test specifying 'invalid_fee_access' throws"); +await testLocalAndRemote(async () => { + let errorWasThrown = false; + try { + const transaction = await Mina.transaction( + { sender, fee: transactionFee }, + () => { + AccountUpdate.fundNewAccount(zkAppAddress); + zkApp.update(Field(1), PrivateKey.random().toPublicKey()); + } + ); + transaction.sign([senderKey, zkAppKey]); + await sendAndVerifyTransaction(transaction); + } catch (error) { + errorWasThrown = true; + } + assert(errorWasThrown); +}); +console.log(''); + +console.log('Test emitting and fetching actions do not throw'); +await testLocalAndRemote(async () => { + try { + const transaction = await Mina.transaction( + { sender, fee: transactionFee }, + () => { + zkApp.incrementCounter(); + } + ); + transaction.sign([senderKey, zkAppKey]); + await sendAndVerifyTransaction(transaction); + } catch (error) { + assert.ifError(error); + } + + try { + const transaction = await Mina.transaction( + { sender, fee: transactionFee }, + () => { + zkApp.rollupIncrements(); + } + ); + transaction.sign([senderKey, zkAppKey]); + await sendAndVerifyTransaction(transaction); + } catch (error) { + assert.ifError(error); + } + + try { + const transaction = await Mina.transaction( + { sender, fee: transactionFee }, + () => { + zkApp.incrementCounter(); + zkApp.incrementCounter(); + zkApp.incrementCounter(); + zkApp.incrementCounter(); + zkApp.incrementCounter(); + } + ); + transaction.sign([senderKey, zkAppKey]); + await sendAndVerifyTransaction(transaction); + } catch (error) { + assert.ifError(error); + } + + try { + const transaction = await Mina.transaction( + { sender, fee: transactionFee }, + () => { + zkApp.rollupIncrements(); + } + ); + transaction.sign([senderKey, zkAppKey]); + await sendAndVerifyTransaction(transaction); + } catch (error) { + assert.ifError(error); + } +}); From d0e304366165d8e9bc144f102a9d08b5463bfd4c Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Thu, 15 Feb 2024 11:07:51 -0800 Subject: [PATCH 25/59] feat(run-ci-live-tests.sh): add transaction-flow test to CI live tests to increase test coverage --- run-ci-live-tests.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/run-ci-live-tests.sh b/run-ci-live-tests.sh index ae0b85787d..3d6a53de9c 100755 --- a/run-ci-live-tests.sh +++ b/run-ci-live-tests.sh @@ -19,6 +19,8 @@ HELLO_WORLD_PROC=$! DEX_PROC=$! ./run src/examples/fetch-live.ts --bundle | add_prefix "FETCH" & FETCH_PROC=$! +./run src/tests/transaction-flow.ts --bundle | add_prefix "TRANSACTION_FLOW" & +TRANSACTION_FLOW_PROC=$! # Wait for each process and capture their exit statuses FAILURE=0 @@ -43,6 +45,13 @@ if [ $? -ne 0 ]; then echo "" FAILURE=1 fi +wait $TRANSACTION_FLOW_PROC +if [ $? -ne 0 ]; then + echo "" + echo "TRANSACTION_FLOW test failed." + echo "" + FAILURE=1 +fi # Exit with failure if any process failed if [ $FAILURE -ne 0 ]; then From cff8b4d4b23a989d782db66287e56fed515f0b78 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Thu, 15 Feb 2024 16:41:55 -0800 Subject: [PATCH 26/59] feat(mina.ts): use 'hashZkAppCommand' to hash transaction --- src/lib/mina.ts | 12 +++++------- src/snarky.d.ts | 1 + 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 356eb160d4..26ecc56bcc 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -1,4 +1,4 @@ -import { Ledger } from '../snarky.js'; +import { Ledger, Test } from '../snarky.js'; import { Field } from './core.js'; import { UInt32, UInt64 } from './int.js'; import { PrivateKey, PublicKey } from './signature.js'; @@ -590,16 +590,14 @@ function LocalBlockchain({ } }); + const hash = Test.transactionHash.hashZkAppCommand(txn.toJSON()); const pendingTransaction = { isSuccess: true, transaction: txn.transaction, toJSON: txn.toJSON, toPretty: txn.toPretty, hash: (): string => { - const message = - 'Info: Txn Hash retrieving is not supported for LocalBlockchain.'; - console.log(message); - return message; + return hash; }, }; @@ -903,6 +901,7 @@ function Network( } const isSuccess = errors === undefined; + const hash = Test.transactionHash.hashZkAppCommand(txn.toJSON()); const pendingTransaction = { isSuccess, data: response?.data, @@ -911,8 +910,7 @@ function Network( toJSON: txn.toJSON, toPretty: txn.toPretty, hash() { - // TODO: compute this - return response?.data?.sendZkapp?.zkapp?.hash!; + return hash; }, }; diff --git a/src/snarky.d.ts b/src/snarky.d.ts index 2c47b9dc63..c156774dca 100644 --- a/src/snarky.d.ts +++ b/src/snarky.d.ts @@ -693,6 +693,7 @@ declare const Test: { serializeCommon(common: string): { data: Uint8Array }; hashPayment(payment: string): string; hashPaymentV1(payment: string): string; + hashZkAppCommand(command: string): string; }; }; From 06ecf3e0d5ae535d429bf1047679b0b5cd057111 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Thu, 15 Feb 2024 16:44:03 -0800 Subject: [PATCH 27/59] feat(submodule): update mina to b9ed54 and o1js-bindings to 4c847f --- src/bindings | 2 +- src/mina | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bindings b/src/bindings index 7c9feffb58..c2072cd398 160000 --- a/src/bindings +++ b/src/bindings @@ -1 +1 @@ -Subproject commit 7c9feffb589deed29ce5606a135aeca5515c3a90 +Subproject commit c2072cd398fc10a58974b2dcf4921ef2212779da diff --git a/src/mina b/src/mina index a5c7f667a5..b9ed54f1d0 160000 --- a/src/mina +++ b/src/mina @@ -1 +1 @@ -Subproject commit a5c7f667a5008c15243f28921505c3930a4fdf35 +Subproject commit b9ed54f1d0c1b98d14116474efcf9d05c1cc5138 From ec34710393f12d74af03f0ed9dc683dc46c76c91 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Fri, 16 Feb 2024 09:48:59 -0800 Subject: [PATCH 28/59] refactor(fetch.ts, graphql.ts): move removeJsonQuotes function from fetch.ts to graphql.ts --- src/lib/fetch.ts | 7 ------- src/lib/mina/graphql.ts | 11 +++++++++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index 9cbc1abdff..db731ff833 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -62,7 +62,6 @@ export { setArchiveGraphqlFallbackEndpoints, setLightnetAccountManagerEndpoint, sendZkapp, - removeJsonQuotes, fetchEvents, fetchActions, Lightnet, @@ -931,12 +930,6 @@ function updateActionState(actions: string[][], actionState: Field) { return Actions.updateSequenceState(actionState, actionHash); } -// removes the quotes on JSON keys -function removeJsonQuotes(json: string) { - let cleaned = JSON.stringify(JSON.parse(json), null, 2); - return cleaned.replace(/\"(\S+)\"\s*:/gm, '$1:'); -} - // TODO it seems we're not actually catching most errors here async function makeGraphqlRequest( query: string, diff --git a/src/lib/mina/graphql.ts b/src/lib/mina/graphql.ts index 53f91e8836..586a9ed83a 100644 --- a/src/lib/mina/graphql.ts +++ b/src/lib/mina/graphql.ts @@ -1,6 +1,6 @@ -import { ActionStatesStringified, removeJsonQuotes } from '../fetch.js'; +import { type ActionStatesStringified, removeJsonQuotes } from '../fetch.js'; import { UInt32 } from '../int.js'; -import { ZkappCommand } from '../account-update.js'; +import { type ZkappCommand } from '../account-update.js'; import { Types } from '../../bindings/mina-transaction/types.js'; export { @@ -27,8 +27,15 @@ export { currentSlotQuery, genesisConstantsQuery, lastBlockQuery, + removeJsonQuotes, }; +// removes the quotes on JSON keys +function removeJsonQuotes(json: string) { + let cleaned = JSON.stringify(JSON.parse(json), null, 2); + return cleaned.replace(/\"(\S+)\"\s*:/gm, '$1:'); +} + type AuthRequired = Types.Json.AuthRequired; // TODO auto-generate this type and the query type FetchedAccount = { From 166a501e361725b05a35494a9012270d10c18d76 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Fri, 16 Feb 2024 09:50:57 -0800 Subject: [PATCH 29/59] refactor(transaction-flow.ts): replace let with const for immutability This change is done to ensure that variables that are not reassigned are declared as constants, improving code readability and preventing accidental reassignments. --- src/tests/transaction-flow.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tests/transaction-flow.ts b/src/tests/transaction-flow.ts index 936df0ff4f..1afa23bbf3 100644 --- a/src/tests/transaction-flow.ts +++ b/src/tests/transaction-flow.ts @@ -49,19 +49,19 @@ class SimpleZkapp extends SmartContract { } @method rollupIncrements() { - let counter = this.counter.get(); + const counter = this.counter.get(); this.counter.requireEquals(counter); - let actionState = this.actionState.get(); + const actionState = this.actionState.get(); this.actionState.requireEquals(actionState); const endActionState = this.account.actionState.getAndRequireEquals(); - let pendingActions = this.reducer.getActions({ + const pendingActions = this.reducer.getActions({ fromActionState: actionState, endActionState, }); - let { state: newCounter, actionState: newActionState } = + const { state: newCounter, actionState: newActionState } = this.reducer.reduce( pendingActions, Field, @@ -82,7 +82,7 @@ class SimpleZkapp extends SmartContract { value: y, }); this.emitEvent('simpleEvent', y); - let x = this.x.getAndRequireEquals(); + const x = this.x.getAndRequireEquals(); this.x.set(x.add(y)); } } @@ -118,7 +118,7 @@ async function sendAndVerifyTransaction(transaction: Mina.Transaction) { const transactionFee = 100_000_000; -let Local = Mina.LocalBlockchain(); +const Local = Mina.LocalBlockchain(); const Remote = Mina.Network({ mina: 'http://localhost:8080/graphql', archive: 'http://localhost:8282 ', From 0a3f4a057fab5939a62b1db99efbb3b75d87da78 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Fri, 16 Feb 2024 09:58:07 -0800 Subject: [PATCH 30/59] refactor(fetch.unit-test.ts): replace Fetch.removeJsonQuotes with removeJsonQuotes --- src/lib/fetch.unit-test.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/lib/fetch.unit-test.ts b/src/lib/fetch.unit-test.ts index 49f0c22120..c1a4eb422d 100644 --- a/src/lib/fetch.unit-test.ts +++ b/src/lib/fetch.unit-test.ts @@ -1,5 +1,4 @@ -import { shutdown } from '../index.js'; -import * as Fetch from './fetch.js'; +import { removeJsonQuotes } from './mina/graphql.js'; import { expect } from 'expect'; console.log('testing regex helpers'); @@ -22,7 +21,7 @@ expected = `{ ] }`; -actual = Fetch.removeJsonQuotes(input); +actual = removeJsonQuotes(input); expect(actual).toEqual(expected); input = `{ @@ -55,7 +54,7 @@ expected = `{ ] }`; -actual = Fetch.removeJsonQuotes(input); +actual = removeJsonQuotes(input); expect(actual).toEqual(expected); input = `{ @@ -74,7 +73,7 @@ expected = `{ Date: "2 May 2016 23:59:59" }`; -actual = Fetch.removeJsonQuotes(input); +actual = removeJsonQuotes(input); expect(actual).toEqual(expected); input = `{ @@ -93,7 +92,7 @@ expected = `{ Phone: "1234567890", Date: "2 May 2016 23:59:59" }`; -actual = Fetch.removeJsonQuotes(input); +actual = removeJsonQuotes(input); expect(actual).toEqual(expected); @@ -114,9 +113,8 @@ expected = `{ Date: "2 May 2016 23:59:59" }`; -actual = Fetch.removeJsonQuotes(input); +actual = removeJsonQuotes(input); expect(actual).toEqual(expected); console.log('regex tests complete 🎉'); -shutdown(); From f1ec38f40cbb77d19948462752572614686e922b Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Fri, 16 Feb 2024 09:59:03 -0800 Subject: [PATCH 31/59] refactor(graphql.ts): modify import statements --- src/lib/mina/graphql.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/mina/graphql.ts b/src/lib/mina/graphql.ts index 586a9ed83a..45600d3932 100644 --- a/src/lib/mina/graphql.ts +++ b/src/lib/mina/graphql.ts @@ -1,6 +1,6 @@ -import { type ActionStatesStringified, removeJsonQuotes } from '../fetch.js'; import { UInt32 } from '../int.js'; -import { type ZkappCommand } from '../account-update.js'; +import type { ZkappCommand } from '../account-update.js'; +import type { ActionStatesStringified } from '../fetch.js'; import { Types } from '../../bindings/mina-transaction/types.js'; export { From 6b4c68c73f1358090eea4153b6b432a39b7ee5bf Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Fri, 16 Feb 2024 16:17:52 -0800 Subject: [PATCH 32/59] refactor(fetch.ts): rename 'txnId' to 'transactionHash' --- src/lib/fetch.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index db731ff833..60010f3679 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -503,11 +503,14 @@ async function fetchLatestBlockZkappStatus( return bestChain; } -async function checkZkappTransaction(txnId: string, blockLength = 20) { +async function checkZkappTransaction( + transactionHash: string, + blockLength = 20 +) { let bestChainBlocks = await fetchLatestBlockZkappStatus(blockLength); for (let block of bestChainBlocks.bestChain) { for (let zkappCommand of block.transactions.zkappCommands) { - if (zkappCommand.hash === txnId) { + if (zkappCommand.hash === transactionHash) { if (zkappCommand.failureReason !== null) { let failureReason = zkappCommand.failureReason .reverse() From 1ede8948eca8aff19ee63718d2fafd116d2c1d34 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Fri, 16 Feb 2024 16:19:16 -0800 Subject: [PATCH 33/59] refactor(mina.ts): improve error handling and transaction status checking 1. Change error handling in createIncludedOrRejectedTransaction function to check for non-empty error array instead of undefined. 2. Remove redundant sendOrThrowIfError function and integrate its functionality into send function. 3. Improve error handling in LocalBlockchain and Network functions to collect and handle errors more effectively. 4. Refactor pollTransactionStatus function to use transaction hash instead of response data. 5. Remove unnecessary console logs and comments. --- src/lib/mina.ts | 117 +++++++++++++++++++++++------------------------- 1 file changed, 56 insertions(+), 61 deletions(-) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 26ecc56bcc..54a63c5c29 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -179,9 +179,9 @@ function createIncludedOrRejectedTransaction( toPretty, hash, }: Omit, - errors?: string[] + errors: string[] ): IncludedTransaction | RejectedTransaction { - if (errors !== undefined) { + if (errors.length > 0) { return { status: 'rejected', errors, @@ -365,15 +365,15 @@ function newTransaction(transaction: ZkappCommand, proofsEnabled?: boolean) { return sendZkappQuery(self.toJSON()); }, async send() { + return await sendTransaction(self); + }, + async sendOrThrowIfError() { try { return await sendTransaction(self); } catch (error) { throw prettifyStacktrace(error); } }, - async sendOrThrowIfError() { - return await sendOrThrowIfError(self); - }, }; return self; } @@ -513,6 +513,8 @@ function LocalBlockchain({ } } + let isSuccess = true; + const errors: string[] = []; try { ledger.applyJsonTransaction( JSON.stringify(zkappCommandJson), @@ -520,17 +522,15 @@ function LocalBlockchain({ JSON.stringify(networkState) ); } catch (err: any) { - try { - // reverse errors so they match order of account updates - // TODO: label updates, and try to give precise explanations about what went wrong - let errors = JSON.parse(err.message); - err.message = invalidTransactionError(txn.transaction, errors, { + // reverse errors so they match order of account updates + // TODO: label updates, and try to give precise explanations about what went wrong + errors.push( + invalidTransactionError(txn.transaction, JSON.parse(err.message), { accountCreationFee: defaultNetworkConstants.accountCreationFee.toString(), - }); - } finally { - throw err; - } + }) + ); + isSuccess = false; } // fetches all events from the transaction and stores them @@ -592,7 +592,8 @@ function LocalBlockchain({ const hash = Test.transactionHash.hashZkAppCommand(txn.toJSON()); const pendingTransaction = { - isSuccess: true, + isSuccess, + errors, transaction: txn.transaction, toJSON: txn.toJSON, toPretty: txn.toPretty, @@ -605,11 +606,11 @@ function LocalBlockchain({ maxAttempts?: number; interval?: number; }) => { - console.log( - 'Info: Waiting for inclusion in a block is not supported for LocalBlockchain.' - ); return Promise.resolve( - createIncludedOrRejectedTransaction(pendingTransaction) + createIncludedOrRejectedTransaction( + pendingTransaction, + pendingTransaction.errors + ) ); }; @@ -617,11 +618,20 @@ function LocalBlockchain({ maxAttempts?: number; interval?: number; }) => { - console.log( - 'Info: Waiting for inclusion in a block is not supported for LocalBlockchain.' - ); + if (pendingTransaction.errors.length > 0) { + throw Error( + `Transaction failed with errors: ${JSON.stringify( + pendingTransaction.errors, + null, + 2 + )}` + ); + } return Promise.resolve( - createIncludedOrRejectedTransaction(pendingTransaction) + createIncludedOrRejectedTransaction( + pendingTransaction, + pendingTransaction.errors + ) ); }; @@ -888,19 +898,14 @@ function Network( verifyTransactionLimits(txn.transaction); let [response, error] = await Fetch.sendZkapp(txn.toJSON()); - let errors: any[] | undefined; + let errors: string[] = []; if (response === undefined && error !== undefined) { - console.log('Error: Failed to send transaction', error); - errors = [error]; + errors = [JSON.stringify(error)]; } else if (response && response.errors && response.errors.length > 0) { - console.log( - 'Error: Transaction returned with errors', - JSON.stringify(response.errors, null, 2) - ); - errors = response.errors; + response?.errors.forEach((e: any) => errors.push(JSON.stringify(e))); } - const isSuccess = errors === undefined; + const isSuccess = errors.length === 0; const hash = Test.transactionHash.hashZkAppCommand(txn.toJSON()); const pendingTransaction = { isSuccess, @@ -915,19 +920,22 @@ function Network( }; const pollTransactionStatus = async ( - txId: string, + transactionHash: string, maxAttempts: number, interval: number, attempts: number = 0 ): Promise => { let res: Awaited>; try { - res = await Fetch.checkZkappTransaction(txId); + res = await Fetch.checkZkappTransaction(transactionHash); if (res.success) { - return createIncludedOrRejectedTransaction(pendingTransaction); + return createIncludedOrRejectedTransaction( + pendingTransaction, + pendingTransaction.errors + ); } else if (res.failureReason) { return createIncludedOrRejectedTransaction(pendingTransaction, [ - `Transaction failed.\nTransactionId: ${txId}\nAttempts: ${attempts}\nfailureReason(s): ${res.failureReason}`, + `Transaction failed.\nTransactionId: ${transactionHash}\nAttempts: ${attempts}\nfailureReason(s): ${res.failureReason}`, ]); } } catch (error) { @@ -938,12 +946,17 @@ function Network( if (maxAttempts && attempts >= maxAttempts) { return createIncludedOrRejectedTransaction(pendingTransaction, [ - `Exceeded max attempts.\nTransactionId: ${txId}\nAttempts: ${attempts}\nLast received status: ${res}`, + `Exceeded max attempts.\nTransactionId: ${transactionHash}\nAttempts: ${attempts}\nLast received status: ${res}`, ]); } await new Promise((resolve) => setTimeout(resolve, interval)); - return pollTransactionStatus(txId, maxAttempts, interval, attempts + 1); + return pollTransactionStatus( + transactionHash, + maxAttempts, + interval, + attempts + 1 + ); }; const wait = async (options?: { @@ -962,15 +975,11 @@ function Network( // fetching an update every 20s is more than enough with a current block time of 3min const maxAttempts = options?.maxAttempts ?? 45; const interval = options?.interval ?? 20000; - const txId = response?.data?.sendZkapp?.zkapp?.hash; - - if (!txId) { - return createIncludedOrRejectedTransaction( - pendingTransaction, - pendingTransaction.errors - ); - } - return pollTransactionStatus(txId, maxAttempts, interval); + return pollTransactionStatus( + pendingTransaction.hash(), + maxAttempts, + interval + ); }; const waitOrThrowIfError = async (options?: { @@ -1228,20 +1237,6 @@ async function sendTransaction(txn: Transaction) { return await activeInstance.sendTransaction(txn); } -async function sendOrThrowIfError(txn: Transaction) { - const pendingTransaction = await sendTransaction(txn); - if (pendingTransaction.errors) { - throw Error( - `Transaction failed with errors: ${JSON.stringify( - pendingTransaction.errors, - null, - 2 - )}` - ); - } - return pendingTransaction; -} - /** * @return A list of emitted events associated to the given public key. */ From 76574c2335e9bf9161d0d0d4f8db33d42f35f85d Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Fri, 16 Feb 2024 16:22:55 -0800 Subject: [PATCH 34/59] refactor(transaction-flow.ts): modify tests to test throwing methods --- src/tests/transaction-flow.ts | 112 +++++++++++++++++++++------------- 1 file changed, 71 insertions(+), 41 deletions(-) diff --git a/src/tests/transaction-flow.ts b/src/tests/transaction-flow.ts index 1afa23bbf3..b21a3e7eff 100644 --- a/src/tests/transaction-flow.ts +++ b/src/tests/transaction-flow.ts @@ -108,12 +108,18 @@ async function testLocalAndRemote( console.log('✅ Test passed'); } -async function sendAndVerifyTransaction(transaction: Mina.Transaction) { +async function sendAndVerifyTransaction( + transaction: Mina.Transaction, + throwOnFail = false +) { await transaction.prove(); - const pendingTransaction = await transaction.send(); - assert(pendingTransaction.hash() !== undefined); - const includedTransaction = await pendingTransaction.wait(); - assert(includedTransaction.status === 'included'); + if (throwOnFail) { + const pendingTransaction = await transaction.sendOrThrowIfError(); + return await pendingTransaction.waitOrThrowIfError(); + } else { + const pendingTransaction = await transaction.send(); + return await pendingTransaction.wait(); + } } const transactionFee = 100_000_000; @@ -145,16 +151,14 @@ console.log(''); console.log('Testing network auxiliary functions do not throw'); await testLocalAndRemote(async () => { - try { + await assert.doesNotReject(async () => { await Mina.transaction({ sender, fee: transactionFee }, () => { Mina.getNetworkConstants(); Mina.getNetworkState(); Mina.getNetworkId(); Mina.getProofsEnabled(); }); - } catch (error) { - assert.ifError(error); - } + }); }); console.log(''); @@ -162,7 +166,7 @@ console.log( `Test 'fetchAccount', 'getAccount', and 'hasAccount' match behavior using publicKey: ${zkAppAddress.toBase58()}` ); await testLocalAndRemote(async () => { - try { + await assert.doesNotReject(async () => { await fetchAccount({ publicKey: zkAppAddress }); // Must call fetchAccount to populate internal account cache const account = Mina.getAccount(zkAppAddress); return { @@ -170,15 +174,13 @@ await testLocalAndRemote(async () => { nonce: account.nonce, hasAccount: Mina.hasAccount(zkAppAddress), }; - } catch (error) { - assert.ifError(error); - } + }); }); console.log(''); console.log('Test deploying zkApp for public key ' + zkAppAddress.toBase58()); await testLocalAndRemote(async () => { - try { + await assert.doesNotReject(async () => { const transaction = await Mina.transaction( { sender, fee: transactionFee }, () => { @@ -187,15 +189,15 @@ await testLocalAndRemote(async () => { ); transaction.sign([senderKey, zkAppKey]); await sendAndVerifyTransaction(transaction); - } catch (error) { - assert.ifError(error); - } + }); }); console.log(''); -console.log("Test calling 'update' method on zkApp does not throw"); +console.log( + "Test calling successful 'update' method does not throw with throwOnFail is false" +); await testLocalAndRemote(async () => { - try { + await assert.doesNotReject(async () => { const transaction = await Mina.transaction( { sender, fee: transactionFee }, () => { @@ -203,16 +205,56 @@ await testLocalAndRemote(async () => { } ); transaction.sign([senderKey, zkAppKey]); - await sendAndVerifyTransaction(transaction); + const includedTransaction = await sendAndVerifyTransaction(transaction); + assert(includedTransaction.status === 'included'); await Mina.fetchEvents(zkAppAddress, TokenId.default); - } catch (error) { - assert.ifError(error); - } + }); +}); +console.log(''); + +console.log( + "Test calling successful 'update' method does not throw with throwOnFail is true" +); +await testLocalAndRemote(async () => { + await assert.doesNotReject(async () => { + const transaction = await Mina.transaction( + { sender, fee: transactionFee }, + () => { + zkApp.update(Field(1), PrivateKey.random().toPublicKey()); + } + ); + transaction.sign([senderKey, zkAppKey]); + const includedTransaction = await sendAndVerifyTransaction( + transaction, + true + ); + assert(includedTransaction.status === 'included'); + await Mina.fetchEvents(zkAppAddress, TokenId.default); + }); }); console.log(''); -console.log("Test specifying 'invalid_fee_access' throws"); +console.log( + "Test calling failing 'update' expecting 'invalid_fee_access' does not throw with throwOnFail is false" +); await testLocalAndRemote(async () => { + const transaction = await Mina.transaction( + { sender, fee: transactionFee }, + () => { + AccountUpdate.fundNewAccount(zkAppAddress); + zkApp.update(Field(1), PrivateKey.random().toPublicKey()); + } + ); + transaction.sign([senderKey, zkAppKey]); + const rejectedTransaction = await sendAndVerifyTransaction(transaction); + assert(rejectedTransaction.status === 'rejected'); +}); +console.log(''); + +console.log( + "Test calling failing 'update' expecting 'invalid_fee_access' does throw with throwOnFail is true" +); +await testLocalAndRemote(async (skip: string) => { let errorWasThrown = false; try { const transaction = await Mina.transaction( @@ -223,7 +265,7 @@ await testLocalAndRemote(async () => { } ); transaction.sign([senderKey, zkAppKey]); - await sendAndVerifyTransaction(transaction); + await sendAndVerifyTransaction(transaction, true); } catch (error) { errorWasThrown = true; } @@ -234,7 +276,7 @@ console.log(''); console.log('Test emitting and fetching actions do not throw'); await testLocalAndRemote(async () => { try { - const transaction = await Mina.transaction( + let transaction = await Mina.transaction( { sender, fee: transactionFee }, () => { zkApp.incrementCounter(); @@ -242,12 +284,8 @@ await testLocalAndRemote(async () => { ); transaction.sign([senderKey, zkAppKey]); await sendAndVerifyTransaction(transaction); - } catch (error) { - assert.ifError(error); - } - try { - const transaction = await Mina.transaction( + transaction = await Mina.transaction( { sender, fee: transactionFee }, () => { zkApp.rollupIncrements(); @@ -255,12 +293,8 @@ await testLocalAndRemote(async () => { ); transaction.sign([senderKey, zkAppKey]); await sendAndVerifyTransaction(transaction); - } catch (error) { - assert.ifError(error); - } - try { - const transaction = await Mina.transaction( + transaction = await Mina.transaction( { sender, fee: transactionFee }, () => { zkApp.incrementCounter(); @@ -272,12 +306,8 @@ await testLocalAndRemote(async () => { ); transaction.sign([senderKey, zkAppKey]); await sendAndVerifyTransaction(transaction); - } catch (error) { - assert.ifError(error); - } - try { - const transaction = await Mina.transaction( + transaction = await Mina.transaction( { sender, fee: transactionFee }, () => { zkApp.rollupIncrements(); From 9b78cc0a31a93336516b47e44606375c2a961b06 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Mon, 19 Feb 2024 12:59:29 -0800 Subject: [PATCH 35/59] refactor(fetch.ts): simplify failureReason mapping in checkZkappTransaction function --- src/lib/fetch.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index 60010f3679..73d324770d 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -515,9 +515,7 @@ async function checkZkappTransaction( let failureReason = zkappCommand.failureReason .reverse() .map((failure) => { - return ` AccountUpdate #${ - failure.index - } failed. Reason: "${failure.failures.join(', ')}"`; + return [failure.failures.map((failureItem) => failureItem)]; }); return { success: false, From 23b32ec35281c7f1177ef42a5e06c0557d148cd7 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Mon, 19 Feb 2024 13:02:38 -0800 Subject: [PATCH 36/59] refactor(errors.ts): improve error handling for fee payer and account updates This commit improves the error handling logic for fee payer and account updates. It checks if the number of errors matches the number of account updates. If there are more, then the fee payer has an error. This check is necessary because the fee payer error is not included in network transaction errors and is always present (even if empty) in the local transaction errors. --- src/lib/mina/errors.ts | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/lib/mina/errors.ts b/src/lib/mina/errors.ts index 261005dfbc..16d2ab2d85 100644 --- a/src/lib/mina/errors.ts +++ b/src/lib/mina/errors.ts @@ -60,23 +60,26 @@ function invalidTransactionError( ): string { let errorMessages = []; let rawErrors = JSON.stringify(errors); + let n = transaction.accountUpdates.length; + let accountUpdateErrors = errors.slice(1, n + 1); - // handle errors for fee payer - let errorsForFeePayer = errors[0]; - for (let [error] of errorsForFeePayer) { - let message = ErrorHandlers[error as keyof typeof ErrorHandlers]?.({ - transaction, - accountUpdateIndex: NaN, - isFeePayer: true, - ...additionalContext, - }); - if (message) errorMessages.push(message); + // Check if the number of errors match the number of account updates. If there are more, then the fee payer has an error. + // We do this check because the fee payer error is not included in network transaction errors and is always present (even if empty) in the local transaction errors. + if (accountUpdateErrors.length === n) { + let errorsForFeePayer = errors[0]; + for (let [error] of errorsForFeePayer) { + let message = ErrorHandlers[error as keyof typeof ErrorHandlers]?.({ + transaction, + accountUpdateIndex: NaN, + isFeePayer: true, + ...additionalContext, + }); + if (message) errorMessages.push(message); + } } - // handle errors for each account update - let n = transaction.accountUpdates.length; - for (let i = 0; i < n; i++) { - let errorsForUpdate = errors[i + 1]; + for (let i = 0; i < accountUpdateErrors.length; i++) { + let errorsForUpdate = accountUpdateErrors[i]; for (let [error] of errorsForUpdate) { let message = ErrorHandlers[error as keyof typeof ErrorHandlers]?.({ transaction, From aac4e3434e6ac027eb507db9f5cf483d6c3835c0 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Mon, 19 Feb 2024 13:06:36 -0800 Subject: [PATCH 37/59] refactor(mina.ts): simplify error handling in sendOrThrowIfError, LocalBlockchain and Network functions --- src/lib/mina.ts | 82 ++++++++++++++++++++++++------------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 54a63c5c29..bff81a08d2 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -153,7 +153,7 @@ type PendingTransaction = Pick< }): Promise; hash(): string; data?: SendZkAppResponse; - errors?: string[]; + errors: string[]; }; type IncludedTransaction = Pick< @@ -368,11 +368,15 @@ function newTransaction(transaction: ZkappCommand, proofsEnabled?: boolean) { return await sendTransaction(self); }, async sendOrThrowIfError() { - try { - return await sendTransaction(self); - } catch (error) { - throw prettifyStacktrace(error); + const pendingTransaction = await sendTransaction(self); + if (pendingTransaction.errors.length > 0) { + throw Error( + `Transaction failed with errors:\n- ${pendingTransaction.errors.join( + '\n- ' + )}` + ); } + return pendingTransaction; }, }; return self; @@ -524,12 +528,12 @@ function LocalBlockchain({ } catch (err: any) { // reverse errors so they match order of account updates // TODO: label updates, and try to give precise explanations about what went wrong - errors.push( - invalidTransactionError(txn.transaction, JSON.parse(err.message), { - accountCreationFee: - defaultNetworkConstants.accountCreationFee.toString(), - }) - ); + const errorMessages = JSON.parse(err.message); + const error = invalidTransactionError(txn.transaction, errorMessages, { + accountCreationFee: + defaultNetworkConstants.accountCreationFee.toString(), + }); + errors.push(error); isSuccess = false; } @@ -591,7 +595,10 @@ function LocalBlockchain({ }); const hash = Test.transactionHash.hashZkAppCommand(txn.toJSON()); - const pendingTransaction = { + const pendingTransaction: Omit< + PendingTransaction, + 'wait' | 'waitOrThrowIfError' + > = { isSuccess, errors, transaction: txn.transaction, @@ -606,11 +613,9 @@ function LocalBlockchain({ maxAttempts?: number; interval?: number; }) => { - return Promise.resolve( - createIncludedOrRejectedTransaction( - pendingTransaction, - pendingTransaction.errors - ) + return createIncludedOrRejectedTransaction( + pendingTransaction, + pendingTransaction.errors ); }; @@ -618,20 +623,9 @@ function LocalBlockchain({ maxAttempts?: number; interval?: number; }) => { - if (pendingTransaction.errors.length > 0) { - throw Error( - `Transaction failed with errors: ${JSON.stringify( - pendingTransaction.errors, - null, - 2 - )}` - ); - } - return Promise.resolve( - createIncludedOrRejectedTransaction( - pendingTransaction, - pendingTransaction.errors - ) + return createIncludedOrRejectedTransaction( + pendingTransaction, + pendingTransaction.errors ); }; @@ -907,7 +901,10 @@ function Network( const isSuccess = errors.length === 0; const hash = Test.transactionHash.hashZkAppCommand(txn.toJSON()); - const pendingTransaction = { + const pendingTransaction: Omit< + PendingTransaction, + 'wait' | 'waitOrThrowIfError' + > = { isSuccess, data: response?.data, errors, @@ -929,13 +926,18 @@ function Network( try { res = await Fetch.checkZkappTransaction(transactionHash); if (res.success) { - return createIncludedOrRejectedTransaction( - pendingTransaction, - pendingTransaction.errors - ); + return createIncludedOrRejectedTransaction(pendingTransaction, []); } else if (res.failureReason) { + const error = invalidTransactionError( + txn.transaction, + res.failureReason, + { + accountCreationFee: + defaultNetworkConstants.accountCreationFee.toString(), + } + ); return createIncludedOrRejectedTransaction(pendingTransaction, [ - `Transaction failed.\nTransactionId: ${transactionHash}\nAttempts: ${attempts}\nfailureReason(s): ${res.failureReason}`, + error, ]); } } catch (error) { @@ -989,10 +991,8 @@ function Network( const pendingTransaction = await wait(options); if (pendingTransaction.status === 'rejected') { throw Error( - `Transaction failed with errors: ${JSON.stringify( - pendingTransaction.errors, - null, - 2 + `Transaction failed with errors:\n${pendingTransaction.errors.join( + '\n' )}` ); } From 111bae1ea0a9101506c145f7d612f15574870aeb Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Mon, 19 Feb 2024 13:07:13 -0800 Subject: [PATCH 38/59] refactor(precondition.test.ts, token.test.ts): replace send() with sendOrThrowIfError() for better error handling This change ensures that any errors during the send operation are immediately thrown, improving debugging and error tracking. --- src/lib/precondition.test.ts | 32 +++++++++++++++++++++----------- src/lib/token.test.ts | 12 ++++++------ 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/lib/precondition.test.ts b/src/lib/precondition.test.ts index 07d0243d68..1228a2ec1f 100644 --- a/src/lib/precondition.test.ts +++ b/src/lib/precondition.test.ts @@ -238,7 +238,7 @@ describe('preconditions', () => { precondition().assertEquals(p.add(1) as any); AccountUpdate.attachToTransaction(zkapp.self); }); - await tx.sign([feePayerKey]).send(); + await tx.sign([feePayerKey]).sendOrThrowIfError(); }).rejects.toThrow(/unsatisfied/); } }); @@ -251,7 +251,7 @@ describe('preconditions', () => { precondition().requireEquals(p.add(1) as any); AccountUpdate.attachToTransaction(zkapp.self); }); - await tx.sign([feePayerKey]).send(); + await tx.sign([feePayerKey]).sendOrThrowIfError(); }).rejects.toThrow(/unsatisfied/); } }); @@ -263,7 +263,7 @@ describe('preconditions', () => { precondition().assertEquals(p.not()); AccountUpdate.attachToTransaction(zkapp.self); }); - await expect(tx.sign([feePayerKey]).send()).rejects.toThrow( + await expect(tx.sign([feePayerKey]).sendOrThrowIfError()).rejects.toThrow( /unsatisfied/ ); } @@ -276,7 +276,7 @@ describe('preconditions', () => { precondition().requireEquals(p.not()); AccountUpdate.attachToTransaction(zkapp.self); }); - await expect(tx.sign([feePayerKey]).send()).rejects.toThrow( + await expect(tx.sign([feePayerKey]).sendOrThrowIfError()).rejects.toThrow( /unsatisfied/ ); } @@ -288,7 +288,9 @@ describe('preconditions', () => { zkapp.account.delegate.assertEquals(publicKey); AccountUpdate.attachToTransaction(zkapp.self); }); - await expect(tx.sign([feePayerKey]).send()).rejects.toThrow(/unsatisfied/); + await expect(tx.sign([feePayerKey]).sendOrThrowIfError()).rejects.toThrow( + /unsatisfied/ + ); }); it('unsatisfied requireEquals should be rejected (public key)', async () => { @@ -297,7 +299,9 @@ describe('preconditions', () => { zkapp.account.delegate.requireEquals(publicKey); AccountUpdate.attachToTransaction(zkapp.self); }); - await expect(tx.sign([feePayerKey]).send()).rejects.toThrow(/unsatisfied/); + await expect(tx.sign([feePayerKey]).sendOrThrowIfError()).rejects.toThrow( + /unsatisfied/ + ); }); it('unsatisfied assertBetween should be rejected', async () => { @@ -307,7 +311,7 @@ describe('preconditions', () => { precondition().assertBetween(p.add(20), p.add(30)); AccountUpdate.attachToTransaction(zkapp.self); }); - await expect(tx.sign([feePayerKey]).send()).rejects.toThrow( + await expect(tx.sign([feePayerKey]).sendOrThrowIfError()).rejects.toThrow( /unsatisfied/ ); } @@ -320,7 +324,7 @@ describe('preconditions', () => { precondition().requireBetween(p.add(20), p.add(30)); AccountUpdate.attachToTransaction(zkapp.self); }); - await expect(tx.sign([feePayerKey]).send()).rejects.toThrow( + await expect(tx.sign([feePayerKey]).sendOrThrowIfError()).rejects.toThrow( /unsatisfied/ ); } @@ -331,7 +335,9 @@ describe('preconditions', () => { zkapp.currentSlot.assertBetween(UInt32.from(20), UInt32.from(30)); AccountUpdate.attachToTransaction(zkapp.self); }); - await expect(tx.sign([feePayerKey]).send()).rejects.toThrow(/unsatisfied/); + await expect(tx.sign([feePayerKey]).sendOrThrowIfError()).rejects.toThrow( + /unsatisfied/ + ); }); it('unsatisfied currentSlot.requireBetween should be rejected', async () => { @@ -339,7 +345,9 @@ describe('preconditions', () => { zkapp.currentSlot.requireBetween(UInt32.from(20), UInt32.from(30)); AccountUpdate.attachToTransaction(zkapp.self); }); - await expect(tx.sign([feePayerKey]).send()).rejects.toThrow(/unsatisfied/); + await expect(tx.sign([feePayerKey]).sendOrThrowIfError()).rejects.toThrow( + /unsatisfied/ + ); }); // TODO: is this a gotcha that should be addressed? @@ -351,7 +359,9 @@ describe('preconditions', () => { zkapp.requireSignature(); AccountUpdate.attachToTransaction(zkapp.self); }); - expect(() => tx.sign([zkappKey, feePayerKey]).send()).toThrow(); + expect(() => + tx.sign([zkappKey, feePayerKey]).sendOrThrowIfError() + ).toThrow(); }); }); diff --git a/src/lib/token.test.ts b/src/lib/token.test.ts index e6c2ee1f5b..69a20a522f 100644 --- a/src/lib/token.test.ts +++ b/src/lib/token.test.ts @@ -326,7 +326,7 @@ describe('Token', () => { tokenZkapp.requireSignature(); }) ).sign([zkAppBKey, feePayerKey, tokenZkappKey]); - await expect(tx.send()).rejects.toThrow(); + await expect(tx.sendOrThrowIfError()).rejects.toThrow(); }); }); @@ -394,7 +394,7 @@ describe('Token', () => { }) ).sign([zkAppBKey, feePayerKey, tokenZkappKey]); - await expect(tx.send()).rejects.toThrow(); + await expect(tx.sendOrThrowIfError()).rejects.toThrow(); }); test('should error if sender sends more tokens than they have', async () => { @@ -418,7 +418,7 @@ describe('Token', () => { tokenZkapp.requireSignature(); }) ).sign([zkAppBKey, feePayerKey, tokenZkappKey]); - await expect(tx.send()).rejects.toThrow(); + await expect(tx.sendOrThrowIfError()).rejects.toThrow(); }); }); }); @@ -579,9 +579,9 @@ describe('Token', () => { }); AccountUpdate.attachToTransaction(tokenZkapp.self); }); - await expect(tx.sign([feePayerKey]).send()).rejects.toThrow( - /Update_not_permitted_access/ - ); + await expect( + tx.sign([feePayerKey]).sendOrThrowIfError() + ).rejects.toThrow(/Update_not_permitted_access/); }); }); }); From 9c214e58b880e096cc80e6a296262acb3ced04f6 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Mon, 19 Feb 2024 13:08:14 -0800 Subject: [PATCH 39/59] refactor(transaction-flow.ts): remove outdated comments about currentSlot implementation status to avoid confusion --- src/tests/transaction-flow.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/tests/transaction-flow.ts b/src/tests/transaction-flow.ts index b21a3e7eff..46da5aa209 100644 --- a/src/tests/transaction-flow.ts +++ b/src/tests/transaction-flow.ts @@ -17,12 +17,6 @@ import { } from 'o1js'; import assert from 'node:assert'; -/** - * currentSlot: - * - Remote: not implemented, throws - * - Local: implemented - */ - class Event extends Struct({ pub: PublicKey, value: Field }) {} class SimpleZkapp extends SmartContract { From 023d31d57f9394f88b78feacb0f9d958287e8dab Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Mon, 19 Feb 2024 13:33:59 -0800 Subject: [PATCH 40/59] feat(mina.ts): add detailed comments for PendingTransaction, IncludedTransaction, and RejectedTransaction types This commit adds comprehensive comments to the PendingTransaction, IncludedTransaction, and RejectedTransaction types in mina.ts. The comments provide detailed explanations of the properties and methods of these types, as well as examples of their usage. This will improve code readability and maintainability by providing clear documentation for developers working with these types. --- src/lib/mina.ts | 126 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index bff81a08d2..ce6adcd978 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -138,36 +138,162 @@ type Transaction = { sendOrThrowIfError(): Promise; }; +/** + * Represents a transaction that has been submitted to the blockchain but has not yet reached a final state. + * The `PendingTransaction` type extends certain functionalities from the base `Transaction` type, + * adding methods to monitor the transaction's progress towards being finalized (either included in a block or rejected). + */ type PendingTransaction = Pick< Transaction, 'transaction' | 'toJSON' | 'toPretty' > & { + /** + * @property {boolean} isSuccess Indicates whether the transaction was successfully sent to the Mina daemon. + * It does not guarantee inclusion in a block. A value of `true` means the transaction was accepted by the Mina daemon for processing. + * However, the transaction may still be rejected later during the finalization process if it fails to be included in a block. + * Use `.wait()` or `.waitOrThrowIfError()` methods to determine the final state of the transaction. + * + * @example + * ```ts + * if (pendingTransaction.isSuccess) { + * console.log('Transaction sent successfully to the Mina daemon.'); + * try { + * await pendingTransaction.waitOrThrowIfError(); + * console.log('Transaction was included in a block.'); + * } catch (error) { + * console.error('Transaction was rejected or failed to be included in a block:', error); + * } + * } else { + * console.error('Failed to send transaction to the Mina daemon.'); + * } + * ``` + */ isSuccess: boolean; + + /** + * Waits for the transaction to be finalized and returns the result. + * + * @param {Object} [options] Configuration options for polling behavior. + * @param {number} [options.maxAttempts] The maximum number of attempts to check the transaction status. + * @param {number} [options.interval] The interval, in milliseconds, between status checks. + * @returns {Promise} A promise that resolves to the transaction's final state. + * + * * @example + * ```ts + * const finalState = await pendingTransaction.wait({ maxAttempts: 5, interval: 1000 }); + * console.log(finalState.status); // 'included' or 'rejected' + * ``` + */ wait(options?: { maxAttempts?: number; interval?: number; }): Promise; + + /** + * Similar to `wait`, but throws an error if the transaction is rejected or if it fails to finalize within the given attempts. + * + * @param {Object} [options] Configuration options for polling behavior. + * @param {number} [options.maxAttempts] The maximum number of polling attempts. + * @param {number} [options.interval] The time interval, in milliseconds, between each polling attempt. + * @returns {Promise} A promise that resolves to the transaction's final state or throws an error. + * + * * @example + * ```ts + * try { + * const finalState = await pendingTransaction.waitOrThrowIfError({ maxAttempts: 10, interval: 2000 }); + * console.log('Transaction included in a block.'); + * } catch (error) { + * console.error('Transaction rejected or failed to finalize:', error); + * } + * ``` + */ waitOrThrowIfError(options?: { maxAttempts?: number; interval?: number; }): Promise; + + /** + * Generates and returns the transaction hash as a string identifier. + * + * @returns {string} The hash of the transaction. + * + * * @example + * ```ts + * const txHash = pendingTransaction.hash(); + * console.log(`Transaction hash: ${txHash}`); + * ``` + */ hash(): string; + + /** + * Optional. Contains response data from a ZkApp transaction submission. + * + * @property {SendZkAppResponse} [data] The response data from the transaction submission. + */ data?: SendZkAppResponse; + + /** + * An array of error messages related to the transaction processing. + * + * @property {string[]} errors Descriptive error messages if the transaction encountered issues during processing. + * + * * @example + * ```ts + * if (!pendingTransaction.isSuccess && pendingTransaction.errors.length > 0) { + * console.error(`Transaction errors: ${pendingTransaction.errors.join(', ')}`); + * } + * ``` + */ errors: string[]; }; +/** + * Represents a transaction that has been successfully included in a block. + */ type IncludedTransaction = Pick< PendingTransaction, 'transaction' | 'toJSON' | 'toPretty' | 'hash' | 'data' > & { + /** + * @property {string} status The final status of the transaction, indicating successful inclusion in a block. + * + * @example + * ```ts + * const includedTx: IncludedTransaction = await pendingTransaction.wait(); + * if (includedTx.status === 'included') { + * console.log(`Transaction ${includedTx.hash()} included in a block.`); + * } + * ``` + */ status: 'included'; }; +/** + * Represents a transaction that has been rejected and not included in a blockchain block. + */ type RejectedTransaction = Pick< PendingTransaction, 'transaction' | 'toJSON' | 'toPretty' | 'hash' | 'data' > & { + /** + * @property {string} status The final status of the transaction, specifically indicating that it has been rejected. + * + * * @example + * ```ts + * const rejectedTx: RejectedTransaction = await pendingTransaction.wait(); + * if (rejectedTx.status === 'rejected') { + * console.error(`Transaction ${rejectedTx.hash()} was rejected.`); + * rejectedTx.errors.forEach((error, i) => { + * console.error(`Error ${i + 1}: ${error}`); + * }); + * } + * ``` + */ status: 'rejected'; + + /** + * @property {string[]} errors An array of error messages detailing the reasons for the transaction's rejection. + */ errors: string[]; }; From 671c35e488263edc5be4adce0bdfa05e3a42e06d Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Mon, 19 Feb 2024 14:01:09 -0800 Subject: [PATCH 41/59] docs(mina.ts): update comments for better clarity and add examples Improve the documentation of the Transaction type in mina.ts by providing more detailed descriptions and adding examples for better understanding. This will help developers understand the purpose and usage of each method more clearly. --- src/lib/mina.ts | 51 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index ce6adcd978..7bc52a1803 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -96,51 +96,84 @@ setActiveInstance({ }, }); +/** + * Defines the structure and operations associated with a transaction. + * This type encompasses methods for serializing the transaction, signing it, generating proofs, + * and submitting it to the network. + */ type Transaction = { /** * Transaction structure used to describe a state transition on the Mina blockchain. */ transaction: ZkappCommand; /** - * Returns a JSON representation of the {@link Transaction}. + * Serializes the transaction to a JSON string. + * @returns A string representation of the {@link Transaction}. */ toJSON(): string; /** - * Returns a pretty-printed JSON representation of the {@link Transaction}. + * Produces a pretty-printed JSON representation of the {@link Transaction}. + * @returns A formatted string representing the transaction in JSON. */ toPretty(): any; /** - * Returns the GraphQL query for the Mina daemon. + * Constructs the GraphQL query string used for submitting the transaction to a Mina daemon. + * @returns The GraphQL query string for the {@link Transaction}. */ toGraphqlQuery(): string; /** * Signs all {@link AccountUpdate}s included in the {@link Transaction} that require a signature. - * * {@link AccountUpdate}s that require a signature can be specified with `{AccountUpdate|SmartContract}.requireSignature()`. - * * @param additionalKeys The list of keys that should be used to sign the {@link Transaction} + * @returns The {@link Transaction} instance with all required signatures applied. + * @example + * ```ts + * const signedTx = transaction.sign([userPrivateKey]); + * console.log('Transaction signed successfully.'); + * ``` */ sign(additionalKeys?: PrivateKey[]): Transaction; /** - * Generates proofs for the {@link Transaction}. - * + * Initiates the proof generation process for the {@link Transaction}. This asynchronous operation is + * crucial for zero-knowledge-based transactions, where proofs are required to validate state transitions. * This can take some time. + * @example + * ```ts + * await transaction.prove(); + * ``` */ prove(): Promise<(Proof | undefined)[]>; /** - * Sends the {@link Transaction} to the network. + * Submits the {@link Transaction} to the network. This method asynchronously sends the transaction + * for processing and returns a {@link PendingTransaction} instance, which can be used to monitor its progress. + * @returns A promise that resolves to a {@link PendingTransaction} instance representing the submitted transaction. + * @example + * ```ts + * const pendingTransaction = await transaction.send(); + * console.log('Transaction sent successfully to the Mina daemon.'); + * ``` */ send(): Promise; /** * Sends the {@link Transaction} to the network, unlike the standard send(), this function will throw an error if internal errors are detected. + * @throws {Error} If the transaction fails to be sent to the Mina daemon or if it encounters errors during processing. + * @example + * ```ts + * try { + * const pendingTransaction = await transaction.sendOrThrowIfError(); + * console.log('Transaction sent successfully to the Mina daemon.'); + * } catch (error) { + * console.error('Transaction failed with errors:', error); + * } + * ``` */ sendOrThrowIfError(): Promise; }; /** * Represents a transaction that has been submitted to the blockchain but has not yet reached a final state. - * The `PendingTransaction` type extends certain functionalities from the base `Transaction` type, + * The {@link PendingTransaction} type extends certain functionalities from the base {@link Transaction} type, * adding methods to monitor the transaction's progress towards being finalized (either included in a block or rejected). */ type PendingTransaction = Pick< From c8d1d1401331ea86ee07845246c99a9b9e1f60cd Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Mon, 19 Feb 2024 14:01:32 -0800 Subject: [PATCH 42/59] style(mina.ts): remove unnecessary comment lines --- src/lib/mina.ts | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 7bc52a1803..ea0b9404b0 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -185,7 +185,6 @@ type PendingTransaction = Pick< * It does not guarantee inclusion in a block. A value of `true` means the transaction was accepted by the Mina daemon for processing. * However, the transaction may still be rejected later during the finalization process if it fails to be included in a block. * Use `.wait()` or `.waitOrThrowIfError()` methods to determine the final state of the transaction. - * * @example * ```ts * if (pendingTransaction.isSuccess) { @@ -205,16 +204,14 @@ type PendingTransaction = Pick< /** * Waits for the transaction to be finalized and returns the result. - * * @param {Object} [options] Configuration options for polling behavior. * @param {number} [options.maxAttempts] The maximum number of attempts to check the transaction status. * @param {number} [options.interval] The interval, in milliseconds, between status checks. * @returns {Promise} A promise that resolves to the transaction's final state. - * - * * @example + * @example * ```ts - * const finalState = await pendingTransaction.wait({ maxAttempts: 5, interval: 1000 }); - * console.log(finalState.status); // 'included' or 'rejected' + * const transaction = await pendingTransaction.wait({ maxAttempts: 5, interval: 1000 }); + * console.log(transaction.status); // 'included' or 'rejected' * ``` */ wait(options?: { @@ -224,16 +221,14 @@ type PendingTransaction = Pick< /** * Similar to `wait`, but throws an error if the transaction is rejected or if it fails to finalize within the given attempts. - * * @param {Object} [options] Configuration options for polling behavior. * @param {number} [options.maxAttempts] The maximum number of polling attempts. * @param {number} [options.interval] The time interval, in milliseconds, between each polling attempt. * @returns {Promise} A promise that resolves to the transaction's final state or throws an error. - * - * * @example + * @example * ```ts * try { - * const finalState = await pendingTransaction.waitOrThrowIfError({ maxAttempts: 10, interval: 2000 }); + * const transaction = await pendingTransaction.waitOrThrowIfError({ maxAttempts: 10, interval: 2000 }); * console.log('Transaction included in a block.'); * } catch (error) { * console.error('Transaction rejected or failed to finalize:', error); @@ -247,10 +242,8 @@ type PendingTransaction = Pick< /** * Generates and returns the transaction hash as a string identifier. - * * @returns {string} The hash of the transaction. - * - * * @example + * @example * ```ts * const txHash = pendingTransaction.hash(); * console.log(`Transaction hash: ${txHash}`); @@ -269,8 +262,7 @@ type PendingTransaction = Pick< * An array of error messages related to the transaction processing. * * @property {string[]} errors Descriptive error messages if the transaction encountered issues during processing. - * - * * @example + * @example * ```ts * if (!pendingTransaction.isSuccess && pendingTransaction.errors.length > 0) { * console.error(`Transaction errors: ${pendingTransaction.errors.join(', ')}`); @@ -289,7 +281,6 @@ type IncludedTransaction = Pick< > & { /** * @property {string} status The final status of the transaction, indicating successful inclusion in a block. - * * @example * ```ts * const includedTx: IncludedTransaction = await pendingTransaction.wait(); @@ -310,8 +301,7 @@ type RejectedTransaction = Pick< > & { /** * @property {string} status The final status of the transaction, specifically indicating that it has been rejected. - * - * * @example + * @example * ```ts * const rejectedTx: RejectedTransaction = await pendingTransaction.wait(); * if (rejectedTx.status === 'rejected') { From 9ca996bf21b81bd31e7a2ff8b6bddd8c71ee2bf6 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Mon, 19 Feb 2024 14:39:14 -0800 Subject: [PATCH 43/59] refactor: replace 'send' method with 'sendOrThrowIfError' in dex/run.ts, dex/upgradability.ts, account-update.unit-test.ts, and caller.unit-test.ts for better error handling --- src/examples/zkapps/dex/run.ts | 24 +++++++++++++++--------- src/examples/zkapps/dex/upgradability.ts | 18 +++++++++--------- src/lib/account-update.unit-test.ts | 2 +- src/lib/caller.unit-test.ts | 2 +- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/examples/zkapps/dex/run.ts b/src/examples/zkapps/dex/run.ts index ca831647e3..d7367bfc42 100644 --- a/src/examples/zkapps/dex/run.ts +++ b/src/examples/zkapps/dex/run.ts @@ -236,7 +236,9 @@ async function main({ withVesting }: { withVesting: boolean }) { (USER_DX * oldBalances.total.lqXY) / oldBalances.dex.X ); } else { - await expect(tx.send()).rejects.toThrow(/Update_not_permitted_timing/); + await expect(tx.sendOrThrowIfError()).rejects.toThrow( + /Update_not_permitted_timing/ + ); } /** @@ -251,14 +253,14 @@ async function main({ withVesting }: { withVesting: boolean }) { }); await tx.prove(); tx.sign([keys.user2]); - await expect(tx.send()).rejects.toThrow(/Overflow/); + await expect(tx.sendOrThrowIfError()).rejects.toThrow(/Overflow/); console.log('supplying with insufficient tokens (should fail)'); tx = await Mina.transaction(addresses.user, () => { dex.supplyLiquidityBase(UInt64.from(1e9), UInt64.from(1e9)); }); await tx.prove(); tx.sign([keys.user]); - await expect(tx.send()).rejects.toThrow(/Overflow/); + await expect(tx.sendOrThrowIfError()).rejects.toThrow(/Overflow/); /** * - Resulting operation will overflow the SC’s receiving token by type or by any other applicable limits; @@ -277,7 +279,7 @@ async function main({ withVesting }: { withVesting: boolean }) { ); }); await tx.prove(); - await tx.sign([feePayerKey, keys.tokenY]).send(); + await tx.sign([feePayerKey, keys.tokenY]).sendOrThrowIfError(); console.log('supply overflowing liquidity'); await expect(async () => { tx = await Mina.transaction(addresses.tokenX, () => { @@ -288,7 +290,7 @@ async function main({ withVesting }: { withVesting: boolean }) { }); await tx.prove(); tx.sign([keys.tokenX]); - await tx.send(); + await tx.sendOrThrowIfError(); }).rejects.toThrow(); /** @@ -315,7 +317,7 @@ async function main({ withVesting }: { withVesting: boolean }) { dex.supplyLiquidity(UInt64.from(10)); }); await tx.prove(); - await expect(tx.sign([keys.tokenX]).send()).rejects.toThrow( + await expect(tx.sign([keys.tokenX]).sendOrThrowIfError()).rejects.toThrow( /Update_not_permitted_balance/ ); @@ -342,7 +344,9 @@ async function main({ withVesting }: { withVesting: boolean }) { }); await tx.prove(); tx.sign([keys.user]); - await expect(tx.send()).rejects.toThrow(/Source_minimum_balance_violation/); + await expect(tx.sendOrThrowIfError()).rejects.toThrow( + /Source_minimum_balance_violation/ + ); // another slot => now it should work Local.incrementGlobalSlot(1); @@ -451,7 +455,7 @@ async function main({ withVesting }: { withVesting: boolean }) { }); await tx.prove(); tx.sign([keys.user, keys.user2]); - await expect(tx.send()).rejects.toThrow( + await expect(tx.sendOrThrowIfError()).rejects.toThrow( /Account_balance_precondition_unsatisfied/ ); @@ -486,7 +490,9 @@ async function main({ withVesting }: { withVesting: boolean }) { dex.redeemLiquidity(UInt64.from(1n)); }); await tx.prove(); - await expect(tx.sign([keys.user2]).send()).rejects.toThrow(/Overflow/); + await expect(tx.sign([keys.user2]).sendOrThrowIfError()).rejects.toThrow( + /Overflow/ + ); [oldBalances, balances] = [balances, getTokenBalances()]; /** diff --git a/src/examples/zkapps/dex/upgradability.ts b/src/examples/zkapps/dex/upgradability.ts index 6511e64aa3..4db73e648e 100644 --- a/src/examples/zkapps/dex/upgradability.ts +++ b/src/examples/zkapps/dex/upgradability.ts @@ -122,9 +122,9 @@ async function atomicActionsTest({ withVesting }: { withVesting: boolean }) { }); await tx.prove(); - await expect(tx.sign([feePayerKey, keys.dex]).send()).rejects.toThrow( - /Cannot update field 'delegate'/ - ); + await expect( + tx.sign([feePayerKey, keys.dex]).sendOrThrowIfError() + ).rejects.toThrow(/Cannot update field 'delegate'/); console.log('changing delegate permission back to normal'); @@ -184,9 +184,9 @@ async function atomicActionsTest({ withVesting }: { withVesting: boolean }) { fieldUpdate.requireSignature(); }); await tx.prove(); - await expect(tx.sign([feePayerKey, keys.dex]).send()).rejects.toThrow( - /Cannot update field 'delegate'/ - ); + await expect( + tx.sign([feePayerKey, keys.dex]).sendOrThrowIfError() + ).rejects.toThrow(/Cannot update field 'delegate'/); /** * # Atomic Actions 3 @@ -458,9 +458,9 @@ async function upgradeabilityTests({ withVesting }: { withVesting: boolean }) { modifiedDex.deploy(); // cannot deploy new VK because its forbidden }); await tx.prove(); - await expect(tx.sign([feePayerKey, keys.dex]).send()).rejects.toThrow( - /Cannot update field 'verificationKey'/ - ); + await expect( + tx.sign([feePayerKey, keys.dex]).sendOrThrowIfError() + ).rejects.toThrow(/Cannot update field 'verificationKey'/); console.log('trying to invoke modified swap method'); // method should still be valid since the upgrade was forbidden diff --git a/src/lib/account-update.unit-test.ts b/src/lib/account-update.unit-test.ts index 845cd25a48..2b85457d05 100644 --- a/src/lib/account-update.unit-test.ts +++ b/src/lib/account-update.unit-test.ts @@ -120,7 +120,7 @@ function createAccountUpdate() { AccountUpdate.fundNewAccount(feePayer); }); tx.sign(); - await expect(tx.send()).rejects.toThrow( + await expect(tx.sendOrThrowIfError()).rejects.toThrow( 'Check signature: Invalid signature on fee payer for key' ); } diff --git a/src/lib/caller.unit-test.ts b/src/lib/caller.unit-test.ts index 2fed8f00d1..01eef352d3 100644 --- a/src/lib/caller.unit-test.ts +++ b/src/lib/caller.unit-test.ts @@ -27,6 +27,6 @@ let tx = await Mina.transaction(privateKey, () => { }); // according to this test, the child doesn't get token permissions -await expect(tx.send()).rejects.toThrow( +await expect(tx.sendOrThrowIfError()).rejects.toThrow( 'can not use or pass on token permissions' ); From d2322e0b86e77bf67c3a4002be38bc10a0570843 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Mon, 19 Feb 2024 14:43:02 -0800 Subject: [PATCH 44/59] refactor(run.ts): replace send() with sendOrThrowIfError() for better error handling This change ensures that any errors that occur during the sending of transactions are immediately thrown and can be handled appropriately. --- src/examples/zkapps/hello-world/run.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/examples/zkapps/hello-world/run.ts b/src/examples/zkapps/hello-world/run.ts index 272b4ea8f0..36f714601c 100644 --- a/src/examples/zkapps/hello-world/run.ts +++ b/src/examples/zkapps/hello-world/run.ts @@ -27,7 +27,7 @@ txn = await Mina.transaction(feePayer1.publicKey, () => { AccountUpdate.fundNewAccount(feePayer1.publicKey); zkAppInstance.deploy(); }); -await txn.sign([feePayer1.privateKey, zkAppPrivateKey]).send(); +await txn.sign([feePayer1.privateKey, zkAppPrivateKey]).sendOrThrowIfError(); const initialState = Mina.getAccount(zkAppAddress).zkapp?.appState?.[0].toString(); @@ -45,7 +45,7 @@ txn = await Mina.transaction(feePayer1.publicKey, () => { zkAppInstance.update(Field(4), adminPrivateKey); }); await txn.prove(); -await txn.sign([feePayer1.privateKey]).send(); +await txn.sign([feePayer1.privateKey]).sendOrThrowIfError; currentState = Mina.getAccount(zkAppAddress).zkapp?.appState?.[0].toString(); @@ -70,7 +70,7 @@ try { zkAppInstance.update(Field(16), wrongAdminPrivateKey); }); await txn.prove(); - await txn.sign([feePayer1.privateKey]).send(); + await txn.sign([feePayer1.privateKey]).sendOrThrowIfError; } catch (err: any) { handleError(err, 'Account_delegate_precondition_unsatisfied'); } @@ -91,7 +91,7 @@ try { zkAppInstance.update(Field(30), adminPrivateKey); }); await txn.prove(); - await txn.sign([feePayer1.privateKey]).send(); + await txn.sign([feePayer1.privateKey]).sendOrThrowIfError; } catch (err: any) { handleError(err, 'assertEquals'); } @@ -118,7 +118,7 @@ try { } ); await txn.prove(); - await txn.sign([feePayer1.privateKey]).send(); + await txn.sign([feePayer1.privateKey]).sendOrThrowIfError; } catch (err: any) { handleError(err, 'assertEquals'); } @@ -134,7 +134,7 @@ txn2 = await Mina.transaction({ sender: feePayer2.publicKey, fee: '2' }, () => { zkAppInstance.update(Field(16), adminPrivateKey); }); await txn2.prove(); -await txn2.sign([feePayer2.privateKey]).send(); +await txn2.sign([feePayer2.privateKey]).sendOrThrowIfError; currentState = Mina.getAccount(zkAppAddress).zkapp?.appState?.[0].toString(); @@ -151,7 +151,7 @@ txn3 = await Mina.transaction({ sender: feePayer3.publicKey, fee: '1' }, () => { zkAppInstance.update(Field(256), adminPrivateKey); }); await txn3.prove(); -await txn3.sign([feePayer3.privateKey]).send(); +await txn3.sign([feePayer3.privateKey]).sendOrThrowIfError; currentState = Mina.getAccount(zkAppAddress).zkapp?.appState?.[0].toString(); @@ -174,7 +174,7 @@ try { } ); await txn4.prove(); - await txn4.sign([feePayer4.privateKey]).send(); + await txn4.sign([feePayer4.privateKey]).sendOrThrowIfError; } catch (err: any) { handleError(err, 'assertEquals'); } From 88fa9e206a7fcb87b437f506ff398f266e982bfb Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Mon, 19 Feb 2024 15:05:07 -0800 Subject: [PATCH 45/59] feat(mina.ts): add error handling for JSON parsing in LocalBlockchain function This change adds a try-catch block to handle any errors that may occur during JSON parsing of error messages. It also provides a fallback error message in case the parsing fails, ensuring that an error message is always available. --- src/lib/mina.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 6f41c3e470..b1892462f8 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -676,15 +676,23 @@ function LocalBlockchain({ JSON.stringify(networkState) ); } catch (err: any) { - // reverse errors so they match order of account updates - // TODO: label updates, and try to give precise explanations about what went wrong - const errorMessages = JSON.parse(err.message); - const error = invalidTransactionError(txn.transaction, errorMessages, { - accountCreationFee: - defaultNetworkConstants.accountCreationFee.toString(), - }); - errors.push(error); isSuccess = false; + 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 From c4aefd4829519a6c461c61d0c89ba2d82eb444e7 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Mon, 19 Feb 2024 16:18:25 -0800 Subject: [PATCH 46/59] fix(run.ts): correct function call syntax for sendOrThrowIfError method --- src/examples/zkapps/hello-world/run.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/examples/zkapps/hello-world/run.ts b/src/examples/zkapps/hello-world/run.ts index 36f714601c..da37518357 100644 --- a/src/examples/zkapps/hello-world/run.ts +++ b/src/examples/zkapps/hello-world/run.ts @@ -45,7 +45,7 @@ txn = await Mina.transaction(feePayer1.publicKey, () => { zkAppInstance.update(Field(4), adminPrivateKey); }); await txn.prove(); -await txn.sign([feePayer1.privateKey]).sendOrThrowIfError; +await txn.sign([feePayer1.privateKey]).sendOrThrowIfError(); currentState = Mina.getAccount(zkAppAddress).zkapp?.appState?.[0].toString(); @@ -70,7 +70,7 @@ try { zkAppInstance.update(Field(16), wrongAdminPrivateKey); }); await txn.prove(); - await txn.sign([feePayer1.privateKey]).sendOrThrowIfError; + await txn.sign([feePayer1.privateKey]).sendOrThrowIfError(); } catch (err: any) { handleError(err, 'Account_delegate_precondition_unsatisfied'); } @@ -91,7 +91,7 @@ try { zkAppInstance.update(Field(30), adminPrivateKey); }); await txn.prove(); - await txn.sign([feePayer1.privateKey]).sendOrThrowIfError; + await txn.sign([feePayer1.privateKey]).sendOrThrowIfError(); } catch (err: any) { handleError(err, 'assertEquals'); } @@ -118,7 +118,7 @@ try { } ); await txn.prove(); - await txn.sign([feePayer1.privateKey]).sendOrThrowIfError; + await txn.sign([feePayer1.privateKey]).sendOrThrowIfError(); } catch (err: any) { handleError(err, 'assertEquals'); } @@ -134,7 +134,7 @@ txn2 = await Mina.transaction({ sender: feePayer2.publicKey, fee: '2' }, () => { zkAppInstance.update(Field(16), adminPrivateKey); }); await txn2.prove(); -await txn2.sign([feePayer2.privateKey]).sendOrThrowIfError; +await txn2.sign([feePayer2.privateKey]).sendOrThrowIfError(); currentState = Mina.getAccount(zkAppAddress).zkapp?.appState?.[0].toString(); @@ -151,7 +151,7 @@ txn3 = await Mina.transaction({ sender: feePayer3.publicKey, fee: '1' }, () => { zkAppInstance.update(Field(256), adminPrivateKey); }); await txn3.prove(); -await txn3.sign([feePayer3.privateKey]).sendOrThrowIfError; +await txn3.sign([feePayer3.privateKey]).sendOrThrowIfError(); currentState = Mina.getAccount(zkAppAddress).zkapp?.appState?.[0].toString(); @@ -174,7 +174,7 @@ try { } ); await txn4.prove(); - await txn4.sign([feePayer4.privateKey]).sendOrThrowIfError; + await txn4.sign([feePayer4.privateKey]).sendOrThrowIfError(); } catch (err: any) { handleError(err, 'assertEquals'); } From 97470486c138165134f6a1ba48397c633428dfd1 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Tue, 20 Feb 2024 09:39:17 -0800 Subject: [PATCH 47/59] refactor(transaction): move transaction implementations to new module --- src/index.ts | 6 + src/lib/mina.ts | 453 +--------------------------------- src/lib/mina/graphql.ts | 9 + src/lib/mina/transaction.ts | 468 ++++++++++++++++++++++++++++++++++++ 4 files changed, 496 insertions(+), 440 deletions(-) create mode 100644 src/lib/mina/transaction.ts diff --git a/src/index.ts b/src/index.ts index 3df9a61116..865bb01d05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,12 @@ export { } from './lib/provable-types/merkle-list.js'; export * as Mina from './lib/mina.js'; +export { + type Transaction, + type PendingTransaction, + type IncludedTransaction, + type RejectedTransaction, +} from './lib/mina/transaction.js'; export type { DeployArgs } from './lib/zkapp.js'; export { SmartContract, diff --git a/src/lib/mina.ts b/src/lib/mina.ts index b1892462f8..a92d7c5d6b 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -3,29 +3,23 @@ import { Field } from './core.js'; import { UInt32, UInt64 } from './int.js'; import { PrivateKey, PublicKey } from './signature.js'; import { - addMissingProofs, - addMissingSignatures, - FeePayerUnsigned, ZkappCommand, AccountUpdate, ZkappPublicInput, TokenId, - CallForest, Authorization, Actions, Events, dummySignature, - AccountUpdateLayout, } from './account-update.js'; import * as Fetch from './fetch.js'; -import { assertPreconditionInvariants, NetworkValue } from './precondition.js'; -import { cloneCircuitValue, toConstant } from './circuit-value.js'; -import { Empty, JsonProof, Proof, verify } from './proof-system.js'; +import { NetworkValue } from './precondition.js'; +import { cloneCircuitValue } from './circuit-value.js'; +import { JsonProof, verify } from './proof-system.js'; import { invalidTransactionError } from './mina/errors.js'; import { Types, TypesBigint } from '../bindings/mina-transaction/types.js'; import { Account } from './mina/account.js'; import { TransactionCost, TransactionLimits } from './mina/constants.js'; -import { Provable } from './provable.js'; import { prettifyStacktrace } from './errors.js'; import { Ml } from './ml/conversion.js'; import { @@ -33,7 +27,7 @@ import { verifyAccountUpdateSignature, } from '../mina-signer/src/sign-zkapp-command.js'; import { NetworkId } from '../mina-signer/src/types.js'; -import { FetchMode, currentTransaction } from './mina/transaction-context.js'; +import { currentTransaction } from './mina/transaction-context.js'; import { activeInstance, setActiveInstance, @@ -46,14 +40,18 @@ import { } from './mina/mina-instance.js'; import { SimpleLedger } from './mina/transaction-logic/ledger.js'; import { assert } from './gadgets/common.js'; +import { type EventActionFilterOptions } from './mina/graphql.js'; import { - type EventActionFilterOptions, - type SendZkAppResponse, - sendZkappQuery, -} from './mina/graphql.js'; + type Transaction, + type PendingTransaction, + type IncludedTransaction, + type RejectedTransaction, + createTransaction, + newTransaction, + createIncludedOrRejectedTransaction, +} from './mina/transaction.js'; export { - createTransaction, BerkeleyQANet, Network, LocalBlockchain, @@ -74,7 +72,6 @@ export { getNetworkConstants, getNetworkState, accountCreationFee, - sendTransaction, fetchEvents, fetchActions, getActions, @@ -96,261 +93,6 @@ setActiveInstance({ }, }); -/** - * Defines the structure and operations associated with a transaction. - * This type encompasses methods for serializing the transaction, signing it, generating proofs, - * and submitting it to the network. - */ -type Transaction = { - /** - * Transaction structure used to describe a state transition on the Mina blockchain. - */ - transaction: ZkappCommand; - /** - * Serializes the transaction to a JSON string. - * @returns A string representation of the {@link Transaction}. - */ - toJSON(): string; - /** - * Produces a pretty-printed JSON representation of the {@link Transaction}. - * @returns A formatted string representing the transaction in JSON. - */ - toPretty(): any; - /** - * Constructs the GraphQL query string used for submitting the transaction to a Mina daemon. - * @returns The GraphQL query string for the {@link Transaction}. - */ - toGraphqlQuery(): string; - /** - * Signs all {@link AccountUpdate}s included in the {@link Transaction} that require a signature. - * {@link AccountUpdate}s that require a signature can be specified with `{AccountUpdate|SmartContract}.requireSignature()`. - * @param additionalKeys The list of keys that should be used to sign the {@link Transaction} - * @returns The {@link Transaction} instance with all required signatures applied. - * @example - * ```ts - * const signedTx = transaction.sign([userPrivateKey]); - * console.log('Transaction signed successfully.'); - * ``` - */ - sign(additionalKeys?: PrivateKey[]): Transaction; - /** - * Initiates the proof generation process for the {@link Transaction}. This asynchronous operation is - * crucial for zero-knowledge-based transactions, where proofs are required to validate state transitions. - * This can take some time. - * @example - * ```ts - * await transaction.prove(); - * ``` - */ - prove(): Promise<(Proof | undefined)[]>; - /** - * Submits the {@link Transaction} to the network. This method asynchronously sends the transaction - * for processing and returns a {@link PendingTransaction} instance, which can be used to monitor its progress. - * @returns A promise that resolves to a {@link PendingTransaction} instance representing the submitted transaction. - * @example - * ```ts - * const pendingTransaction = await transaction.send(); - * console.log('Transaction sent successfully to the Mina daemon.'); - * ``` - */ - send(): Promise; - - /** - * Sends the {@link Transaction} to the network, unlike the standard send(), this function will throw an error if internal errors are detected. - * @throws {Error} If the transaction fails to be sent to the Mina daemon or if it encounters errors during processing. - * @example - * ```ts - * try { - * const pendingTransaction = await transaction.sendOrThrowIfError(); - * console.log('Transaction sent successfully to the Mina daemon.'); - * } catch (error) { - * console.error('Transaction failed with errors:', error); - * } - * ``` - */ - sendOrThrowIfError(): Promise; -}; - -/** - * Represents a transaction that has been submitted to the blockchain but has not yet reached a final state. - * The {@link PendingTransaction} type extends certain functionalities from the base {@link Transaction} type, - * adding methods to monitor the transaction's progress towards being finalized (either included in a block or rejected). - */ -type PendingTransaction = Pick< - Transaction, - 'transaction' | 'toJSON' | 'toPretty' -> & { - /** - * @property {boolean} isSuccess Indicates whether the transaction was successfully sent to the Mina daemon. - * It does not guarantee inclusion in a block. A value of `true` means the transaction was accepted by the Mina daemon for processing. - * However, the transaction may still be rejected later during the finalization process if it fails to be included in a block. - * Use `.wait()` or `.waitOrThrowIfError()` methods to determine the final state of the transaction. - * @example - * ```ts - * if (pendingTransaction.isSuccess) { - * console.log('Transaction sent successfully to the Mina daemon.'); - * try { - * await pendingTransaction.waitOrThrowIfError(); - * console.log('Transaction was included in a block.'); - * } catch (error) { - * console.error('Transaction was rejected or failed to be included in a block:', error); - * } - * } else { - * console.error('Failed to send transaction to the Mina daemon.'); - * } - * ``` - */ - isSuccess: boolean; - - /** - * Waits for the transaction to be finalized and returns the result. - * @param {Object} [options] Configuration options for polling behavior. - * @param {number} [options.maxAttempts] The maximum number of attempts to check the transaction status. - * @param {number} [options.interval] The interval, in milliseconds, between status checks. - * @returns {Promise} A promise that resolves to the transaction's final state. - * @example - * ```ts - * const transaction = await pendingTransaction.wait({ maxAttempts: 5, interval: 1000 }); - * console.log(transaction.status); // 'included' or 'rejected' - * ``` - */ - wait(options?: { - maxAttempts?: number; - interval?: number; - }): Promise; - - /** - * Similar to `wait`, but throws an error if the transaction is rejected or if it fails to finalize within the given attempts. - * @param {Object} [options] Configuration options for polling behavior. - * @param {number} [options.maxAttempts] The maximum number of polling attempts. - * @param {number} [options.interval] The time interval, in milliseconds, between each polling attempt. - * @returns {Promise} A promise that resolves to the transaction's final state or throws an error. - * @example - * ```ts - * try { - * const transaction = await pendingTransaction.waitOrThrowIfError({ maxAttempts: 10, interval: 2000 }); - * console.log('Transaction included in a block.'); - * } catch (error) { - * console.error('Transaction rejected or failed to finalize:', error); - * } - * ``` - */ - waitOrThrowIfError(options?: { - maxAttempts?: number; - interval?: number; - }): Promise; - - /** - * Generates and returns the transaction hash as a string identifier. - * @returns {string} The hash of the transaction. - * @example - * ```ts - * const txHash = pendingTransaction.hash(); - * console.log(`Transaction hash: ${txHash}`); - * ``` - */ - hash(): string; - - /** - * Optional. Contains response data from a ZkApp transaction submission. - * - * @property {SendZkAppResponse} [data] The response data from the transaction submission. - */ - data?: SendZkAppResponse; - - /** - * An array of error messages related to the transaction processing. - * - * @property {string[]} errors Descriptive error messages if the transaction encountered issues during processing. - * @example - * ```ts - * if (!pendingTransaction.isSuccess && pendingTransaction.errors.length > 0) { - * console.error(`Transaction errors: ${pendingTransaction.errors.join(', ')}`); - * } - * ``` - */ - errors: string[]; -}; - -/** - * Represents a transaction that has been successfully included in a block. - */ -type IncludedTransaction = Pick< - PendingTransaction, - 'transaction' | 'toJSON' | 'toPretty' | 'hash' | 'data' -> & { - /** - * @property {string} status The final status of the transaction, indicating successful inclusion in a block. - * @example - * ```ts - * const includedTx: IncludedTransaction = await pendingTransaction.wait(); - * if (includedTx.status === 'included') { - * console.log(`Transaction ${includedTx.hash()} included in a block.`); - * } - * ``` - */ - status: 'included'; -}; - -/** - * Represents a transaction that has been rejected and not included in a blockchain block. - */ -type RejectedTransaction = Pick< - PendingTransaction, - 'transaction' | 'toJSON' | 'toPretty' | 'hash' | 'data' -> & { - /** - * @property {string} status The final status of the transaction, specifically indicating that it has been rejected. - * @example - * ```ts - * const rejectedTx: RejectedTransaction = await pendingTransaction.wait(); - * if (rejectedTx.status === 'rejected') { - * console.error(`Transaction ${rejectedTx.hash()} was rejected.`); - * rejectedTx.errors.forEach((error, i) => { - * console.error(`Error ${i + 1}: ${error}`); - * }); - * } - * ``` - */ - status: 'rejected'; - - /** - * @property {string[]} errors An array of error messages detailing the reasons for the transaction's rejection. - */ - errors: string[]; -}; - -function createIncludedOrRejectedTransaction( - { - transaction, - data, - toJSON, - toPretty, - hash, - }: Omit, - errors: string[] -): IncludedTransaction | RejectedTransaction { - if (errors.length > 0) { - return { - status: 'rejected', - errors, - transaction, - toJSON, - toPretty, - hash, - data, - }; - } - return { - status: 'included', - transaction, - toJSON, - toPretty, - hash, - data, - }; -} - const Transaction = { fromJSON(json: Types.Json.ZkappCommand): Transaction { let transaction = ZkappCommand.fromJSON(json); @@ -366,171 +108,6 @@ function reportGetAccountError(publicKey: string, tokenId: string) { } } -function createTransaction( - feePayer: DeprecatedFeePayerSpec, - f: () => unknown, - numberOfRuns: 0 | 1 | undefined, - { - fetchMode = 'cached' as FetchMode, - isFinalRunOutsideCircuit = true, - proofsEnabled = true, - } = {} -): Transaction { - if (currentTransaction.has()) { - throw new Error('Cannot start new transaction within another transaction'); - } - let feePayerSpec: { - sender?: PublicKey; - feePayerKey?: PrivateKey; - fee?: number | string | UInt64; - memo?: string; - nonce?: number; - }; - if (feePayer === undefined) { - feePayerSpec = {}; - } else if (feePayer instanceof PrivateKey) { - feePayerSpec = { feePayerKey: feePayer, sender: feePayer.toPublicKey() }; - } else if (feePayer instanceof PublicKey) { - feePayerSpec = { sender: feePayer }; - } else { - feePayerSpec = feePayer; - if (feePayerSpec.sender === undefined) - feePayerSpec.sender = feePayerSpec.feePayerKey?.toPublicKey(); - } - let { feePayerKey, sender, fee, memo = '', nonce } = feePayerSpec; - - let transactionId = currentTransaction.enter({ - sender, - layout: new AccountUpdateLayout(), - fetchMode, - isFinalRunOutsideCircuit, - numberOfRuns, - }); - - // run circuit - // we have this while(true) loop because one of the smart contracts we're calling inside `f` might be calling - // SmartContract.analyzeMethods, which would be running its methods again inside `Provable.constraintSystem`, which - // would throw an error when nested inside `Provable.runAndCheck`. So if that happens, we have to run `analyzeMethods` first - // and retry `Provable.runAndCheck(f)`. Since at this point in the function, we don't know which smart contracts are involved, - // we created that hack with a `bootstrap()` function that analyzeMethods sticks on the error, to call itself again. - try { - let err: any; - while (true) { - if (err !== undefined) err.bootstrap(); - try { - if (fetchMode === 'test') { - Provable.runUnchecked(() => { - f(); - Provable.asProver(() => { - let tx = currentTransaction.get(); - tx.layout.toConstantInPlace(); - }); - }); - } else { - f(); - } - break; - } catch (err_) { - if ((err_ as any)?.bootstrap) err = err_; - else throw err_; - } - } - } catch (err) { - currentTransaction.leave(transactionId); - throw err; - } - - let accountUpdates = currentTransaction - .get() - .layout.toFlatList({ mutate: true }); - - try { - // check that on-chain values weren't used without setting a precondition - for (let accountUpdate of accountUpdates) { - assertPreconditionInvariants(accountUpdate); - } - } catch (err) { - currentTransaction.leave(transactionId); - throw err; - } - - let feePayerAccountUpdate: FeePayerUnsigned; - if (sender !== undefined) { - // if senderKey is provided, fetch account to get nonce and mark to be signed - let nonce_; - let senderAccount = getAccount(sender, TokenId.default); - - if (nonce === undefined) { - nonce_ = senderAccount.nonce; - } else { - nonce_ = UInt32.from(nonce); - senderAccount.nonce = nonce_; - Fetch.addCachedAccount(senderAccount); - } - feePayerAccountUpdate = AccountUpdate.defaultFeePayer(sender, nonce_); - if (feePayerKey !== undefined) - feePayerAccountUpdate.lazyAuthorization!.privateKey = feePayerKey; - if (fee !== undefined) { - feePayerAccountUpdate.body.fee = - fee instanceof UInt64 ? fee : UInt64.from(String(fee)); - } - } else { - // otherwise use a dummy fee payer that has to be filled in later - feePayerAccountUpdate = AccountUpdate.dummyFeePayer(); - } - - let transaction: ZkappCommand = { - accountUpdates, - feePayer: feePayerAccountUpdate, - memo, - }; - - currentTransaction.leave(transactionId); - return newTransaction(transaction, proofsEnabled); -} - -function newTransaction(transaction: ZkappCommand, proofsEnabled?: boolean) { - let self: Transaction = { - transaction, - sign(additionalKeys?: PrivateKey[]) { - self.transaction = addMissingSignatures(self.transaction, additionalKeys); - return self; - }, - async prove() { - let { zkappCommand, proofs } = await addMissingProofs(self.transaction, { - proofsEnabled, - }); - self.transaction = zkappCommand; - return proofs; - }, - toJSON() { - let json = ZkappCommand.toJSON(self.transaction); - return JSON.stringify(json); - }, - toPretty() { - return ZkappCommand.toPretty(self.transaction); - }, - toGraphqlQuery() { - return sendZkappQuery(self.toJSON()); - }, - async send() { - return await sendTransaction(self); - }, - async sendOrThrowIfError() { - const pendingTransaction = await sendTransaction(self); - if (pendingTransaction.errors.length > 0) { - throw Error( - `Transaction failed with errors:\n- ${pendingTransaction.errors.join( - '\n- ' - )}` - ); - } - return pendingTransaction; - }, - }; - return self; -} - /** * A mock Mina blockchain running locally and useful for testing. */ @@ -1391,10 +968,6 @@ function accountCreationFee() { return activeInstance.accountCreationFee(); } -async function sendTransaction(txn: Transaction) { - return await activeInstance.sendTransaction(txn); -} - /** * @return A list of emitted events associated to the given public key. */ diff --git a/src/lib/mina/graphql.ts b/src/lib/mina/graphql.ts index 45600d3932..9cff4b2217 100644 --- a/src/lib/mina/graphql.ts +++ b/src/lib/mina/graphql.ts @@ -404,6 +404,15 @@ const lastBlockQueryFailureCheck = (length: number) => `{ } } } + stateHash + protocolState { + consensusState { + blockHeight + epoch + slotSinceGenesis + } + previousStateHash + } } }`; diff --git a/src/lib/mina/transaction.ts b/src/lib/mina/transaction.ts new file mode 100644 index 0000000000..43274c98c8 --- /dev/null +++ b/src/lib/mina/transaction.ts @@ -0,0 +1,468 @@ +import { + ZkappCommand, + AccountUpdate, + ZkappPublicInput, + AccountUpdateLayout, + FeePayerUnsigned, + addMissingSignatures, + TokenId, + addMissingProofs, +} from '../account-update.js'; +import { Field } from '../core.js'; +import { PrivateKey, PublicKey } from '../signature.js'; +import { UInt32, UInt64 } from '../int.js'; +import { Empty, Proof } from '../proof-system.js'; +import { currentTransaction } from './transaction-context.js'; +import { Provable } from '../provable.js'; +import { assertPreconditionInvariants } from '../precondition.js'; +import { Account } from './account.js'; +import { + type DeprecatedFeePayerSpec, + activeInstance, +} from './mina-instance.js'; +import * as Fetch from '../fetch.js'; +import { type SendZkAppResponse, sendZkappQuery } from './graphql.js'; +import { type FetchMode } from './transaction-context.js'; + +export { + type Transaction, + type PendingTransaction, + type IncludedTransaction, + type RejectedTransaction, + createTransaction, + sendTransaction, + newTransaction, + getAccount, + createIncludedOrRejectedTransaction, +}; + +/** + * Defines the structure and operations associated with a transaction. + * This type encompasses methods for serializing the transaction, signing it, generating proofs, + * and submitting it to the network. + */ +type Transaction = { + /** + * Transaction structure used to describe a state transition on the Mina blockchain. + */ + transaction: ZkappCommand; + /** + * Serializes the transaction to a JSON string. + * @returns A string representation of the {@link Transaction}. + */ + toJSON(): string; + /** + * Produces a pretty-printed JSON representation of the {@link Transaction}. + * @returns A formatted string representing the transaction in JSON. + */ + toPretty(): any; + /** + * Constructs the GraphQL query string used for submitting the transaction to a Mina daemon. + * @returns The GraphQL query string for the {@link Transaction}. + */ + toGraphqlQuery(): string; + /** + * Signs all {@link AccountUpdate}s included in the {@link Transaction} that require a signature. + * {@link AccountUpdate}s that require a signature can be specified with `{AccountUpdate|SmartContract}.requireSignature()`. + * @param additionalKeys The list of keys that should be used to sign the {@link Transaction} + * @returns The {@link Transaction} instance with all required signatures applied. + * @example + * ```ts + * const signedTx = transaction.sign([userPrivateKey]); + * console.log('Transaction signed successfully.'); + * ``` + */ + sign(additionalKeys?: PrivateKey[]): Transaction; + /** + * Initiates the proof generation process for the {@link Transaction}. This asynchronous operation is + * crucial for zero-knowledge-based transactions, where proofs are required to validate state transitions. + * This can take some time. + * @example + * ```ts + * await transaction.prove(); + * ``` + */ + prove(): Promise<(Proof | undefined)[]>; + /** + * Submits the {@link Transaction} to the network. This method asynchronously sends the transaction + * for processing and returns a {@link PendingTransaction} instance, which can be used to monitor its progress. + * @returns A promise that resolves to a {@link PendingTransaction} instance representing the submitted transaction. + * @example + * ```ts + * const pendingTransaction = await transaction.send(); + * console.log('Transaction sent successfully to the Mina daemon.'); + * ``` + */ + send(): Promise; + + /** + * Sends the {@link Transaction} to the network, unlike the standard send(), this function will throw an error if internal errors are detected. + * @throws {Error} If the transaction fails to be sent to the Mina daemon or if it encounters errors during processing. + * @example + * ```ts + * try { + * const pendingTransaction = await transaction.sendOrThrowIfError(); + * console.log('Transaction sent successfully to the Mina daemon.'); + * } catch (error) { + * console.error('Transaction failed with errors:', error); + * } + * ``` + */ + sendOrThrowIfError(): Promise; +}; + +/** + * Represents a transaction that has been submitted to the blockchain but has not yet reached a final state. + * The {@link PendingTransaction} type extends certain functionalities from the base {@link Transaction} type, + * adding methods to monitor the transaction's progress towards being finalized (either included in a block or rejected). + */ +type PendingTransaction = Pick< + Transaction, + 'transaction' | 'toJSON' | 'toPretty' +> & { + /** + * @property {boolean} isSuccess Indicates whether the transaction was successfully sent to the Mina daemon. + * It does not guarantee inclusion in a block. A value of `true` means the transaction was accepted by the Mina daemon for processing. + * However, the transaction may still be rejected later during the finalization process if it fails to be included in a block. + * Use `.wait()` or `.waitOrThrowIfError()` methods to determine the final state of the transaction. + * @example + * ```ts + * if (pendingTransaction.isSuccess) { + * console.log('Transaction sent successfully to the Mina daemon.'); + * try { + * await pendingTransaction.waitOrThrowIfError(); + * console.log('Transaction was included in a block.'); + * } catch (error) { + * console.error('Transaction was rejected or failed to be included in a block:', error); + * } + * } else { + * console.error('Failed to send transaction to the Mina daemon.'); + * } + * ``` + */ + isSuccess: boolean; + + /** + * Waits for the transaction to be finalized and returns the result. + * @param {Object} [options] Configuration options for polling behavior. + * @param {number} [options.maxAttempts] The maximum number of attempts to check the transaction status. + * @param {number} [options.interval] The interval, in milliseconds, between status checks. + * @returns {Promise} A promise that resolves to the transaction's final state. + * @example + * ```ts + * const transaction = await pendingTransaction.wait({ maxAttempts: 5, interval: 1000 }); + * console.log(transaction.status); // 'included' or 'rejected' + * ``` + */ + wait(options?: { + maxAttempts?: number; + interval?: number; + }): Promise; + + /** + * Similar to `wait`, but throws an error if the transaction is rejected or if it fails to finalize within the given attempts. + * @param {Object} [options] Configuration options for polling behavior. + * @param {number} [options.maxAttempts] The maximum number of polling attempts. + * @param {number} [options.interval] The time interval, in milliseconds, between each polling attempt. + * @returns {Promise} A promise that resolves to the transaction's final state or throws an error. + * @example + * ```ts + * try { + * const transaction = await pendingTransaction.waitOrThrowIfError({ maxAttempts: 10, interval: 2000 }); + * console.log('Transaction included in a block.'); + * } catch (error) { + * console.error('Transaction rejected or failed to finalize:', error); + * } + * ``` + */ + waitOrThrowIfError(options?: { + maxAttempts?: number; + interval?: number; + }): Promise; + + /** + * Generates and returns the transaction hash as a string identifier. + * @returns {string} The hash of the transaction. + * @example + * ```ts + * const txHash = pendingTransaction.hash(); + * console.log(`Transaction hash: ${txHash}`); + * ``` + */ + hash(): string; + + /** + * Optional. Contains response data from a ZkApp transaction submission. + * + * @property {SendZkAppResponse} [data] The response data from the transaction submission. + */ + data?: SendZkAppResponse; + + /** + * An array of error messages related to the transaction processing. + * + * @property {string[]} errors Descriptive error messages if the transaction encountered issues during processing. + * @example + * ```ts + * if (!pendingTransaction.isSuccess && pendingTransaction.errors.length > 0) { + * console.error(`Transaction errors: ${pendingTransaction.errors.join(', ')}`); + * } + * ``` + */ + errors: string[]; +}; + +/** + * Represents a transaction that has been successfully included in a block. + */ +type IncludedTransaction = Pick< + PendingTransaction, + 'transaction' | 'toJSON' | 'toPretty' | 'hash' | 'data' +> & { + /** + * @property {string} status The final status of the transaction, indicating successful inclusion in a block. + * @example + * ```ts + * const includedTx: IncludedTransaction = await pendingTransaction.wait(); + * if (includedTx.status === 'included') { + * console.log(`Transaction ${includedTx.hash()} included in a block.`); + * } + * ``` + */ + status: 'included'; +}; + +/** + * Represents a transaction that has been rejected and not included in a blockchain block. + */ +type RejectedTransaction = Pick< + PendingTransaction, + 'transaction' | 'toJSON' | 'toPretty' | 'hash' | 'data' +> & { + /** + * @property {string} status The final status of the transaction, specifically indicating that it has been rejected. + * @example + * ```ts + * const rejectedTx: RejectedTransaction = await pendingTransaction.wait(); + * if (rejectedTx.status === 'rejected') { + * console.error(`Transaction ${rejectedTx.hash()} was rejected.`); + * rejectedTx.errors.forEach((error, i) => { + * console.error(`Error ${i + 1}: ${error}`); + * }); + * } + * ``` + */ + status: 'rejected'; + + /** + * @property {string[]} errors An array of error messages detailing the reasons for the transaction's rejection. + */ + errors: string[]; +}; + +function createTransaction( + feePayer: DeprecatedFeePayerSpec, + f: () => unknown, + numberOfRuns: 0 | 1 | undefined, + { + fetchMode = 'cached' as FetchMode, + isFinalRunOutsideCircuit = true, + proofsEnabled = true, + } = {} +): Transaction { + if (currentTransaction.has()) { + throw new Error('Cannot start new transaction within another transaction'); + } + let feePayerSpec: { + sender?: PublicKey; + feePayerKey?: PrivateKey; + fee?: number | string | UInt64; + memo?: string; + nonce?: number; + }; + if (feePayer === undefined) { + feePayerSpec = {}; + } else if (feePayer instanceof PrivateKey) { + feePayerSpec = { feePayerKey: feePayer, sender: feePayer.toPublicKey() }; + } else if (feePayer instanceof PublicKey) { + feePayerSpec = { sender: feePayer }; + } else { + feePayerSpec = feePayer; + if (feePayerSpec.sender === undefined) + feePayerSpec.sender = feePayerSpec.feePayerKey?.toPublicKey(); + } + let { feePayerKey, sender, fee, memo = '', nonce } = feePayerSpec; + + let transactionId = currentTransaction.enter({ + sender, + layout: new AccountUpdateLayout(), + fetchMode, + isFinalRunOutsideCircuit, + numberOfRuns, + }); + + // run circuit + // we have this while(true) loop because one of the smart contracts we're calling inside `f` might be calling + // SmartContract.analyzeMethods, which would be running its methods again inside `Provable.constraintSystem`, which + // would throw an error when nested inside `Provable.runAndCheck`. So if that happens, we have to run `analyzeMethods` first + // and retry `Provable.runAndCheck(f)`. Since at this point in the function, we don't know which smart contracts are involved, + // we created that hack with a `bootstrap()` function that analyzeMethods sticks on the error, to call itself again. + try { + let err: any; + while (true) { + if (err !== undefined) err.bootstrap(); + try { + if (fetchMode === 'test') { + Provable.runUnchecked(() => { + f(); + Provable.asProver(() => { + let tx = currentTransaction.get(); + tx.layout.toConstantInPlace(); + }); + }); + } else { + f(); + } + break; + } catch (err_) { + if ((err_ as any)?.bootstrap) err = err_; + else throw err_; + } + } + } catch (err) { + currentTransaction.leave(transactionId); + throw err; + } + + let accountUpdates = currentTransaction + .get() + .layout.toFlatList({ mutate: true }); + + try { + // check that on-chain values weren't used without setting a precondition + for (let accountUpdate of accountUpdates) { + assertPreconditionInvariants(accountUpdate); + } + } catch (err) { + currentTransaction.leave(transactionId); + throw err; + } + + let feePayerAccountUpdate: FeePayerUnsigned; + if (sender !== undefined) { + // if senderKey is provided, fetch account to get nonce and mark to be signed + let nonce_; + let senderAccount = getAccount(sender, TokenId.default); + + if (nonce === undefined) { + nonce_ = senderAccount.nonce; + } else { + nonce_ = UInt32.from(nonce); + senderAccount.nonce = nonce_; + Fetch.addCachedAccount(senderAccount); + } + feePayerAccountUpdate = AccountUpdate.defaultFeePayer(sender, nonce_); + if (feePayerKey !== undefined) + feePayerAccountUpdate.lazyAuthorization!.privateKey = feePayerKey; + if (fee !== undefined) { + feePayerAccountUpdate.body.fee = + fee instanceof UInt64 ? fee : UInt64.from(String(fee)); + } + } else { + // otherwise use a dummy fee payer that has to be filled in later + feePayerAccountUpdate = AccountUpdate.dummyFeePayer(); + } + + let transaction: ZkappCommand = { + accountUpdates, + feePayer: feePayerAccountUpdate, + memo, + }; + + currentTransaction.leave(transactionId); + return newTransaction(transaction, proofsEnabled); +} + +function newTransaction(transaction: ZkappCommand, proofsEnabled?: boolean) { + let self: Transaction = { + transaction, + sign(additionalKeys?: PrivateKey[]) { + self.transaction = addMissingSignatures(self.transaction, additionalKeys); + return self; + }, + async prove() { + let { zkappCommand, proofs } = await addMissingProofs(self.transaction, { + proofsEnabled, + }); + self.transaction = zkappCommand; + return proofs; + }, + toJSON() { + let json = ZkappCommand.toJSON(self.transaction); + return JSON.stringify(json); + }, + toPretty() { + return ZkappCommand.toPretty(self.transaction); + }, + toGraphqlQuery() { + return sendZkappQuery(self.toJSON()); + }, + async send() { + return await sendTransaction(self); + }, + async sendOrThrowIfError() { + const pendingTransaction = await sendTransaction(self); + if (pendingTransaction.errors.length > 0) { + throw Error( + `Transaction failed with errors:\n- ${pendingTransaction.errors.join( + '\n- ' + )}` + ); + } + return pendingTransaction; + }, + }; + return self; +} + +async function sendTransaction(txn: Transaction) { + return await activeInstance.sendTransaction(txn); +} + +/** + * @return The account data associated to the given public key. + */ +function getAccount(publicKey: PublicKey, tokenId?: Field): Account { + return activeInstance.getAccount(publicKey, tokenId); +} + +function createIncludedOrRejectedTransaction( + { + transaction, + data, + toJSON, + toPretty, + hash, + }: Omit, + errors: string[] +): IncludedTransaction | RejectedTransaction { + if (errors.length > 0) { + return { + status: 'rejected', + errors, + transaction, + toJSON, + toPretty, + hash, + data, + }; + } + return { + status: 'included', + transaction, + toJSON, + toPretty, + hash, + data, + }; +} From 62eade25c33987d2281af933f2f165877c58b5c1 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Tue, 20 Feb 2024 10:01:34 -0800 Subject: [PATCH 48/59] feat(local-blockchain): seperate local-blockchain into it's own module --- src/lib/mina.ts | 709 +------------------------------ src/lib/mina/local-blockchain.ts | 389 +++++++++++++++++ src/lib/mina/mina-instance.ts | 346 ++++++++++++++- 3 files changed, 741 insertions(+), 703 deletions(-) create mode 100644 src/lib/mina/local-blockchain.ts diff --git a/src/lib/mina.ts b/src/lib/mina.ts index a92d7c5d6b..054dde392c 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -1,31 +1,13 @@ -import { Ledger, Test } from '../snarky.js'; +import { Test } from '../snarky.js'; import { Field } from './core.js'; import { UInt32, UInt64 } from './int.js'; -import { PrivateKey, PublicKey } from './signature.js'; -import { - ZkappCommand, - AccountUpdate, - ZkappPublicInput, - TokenId, - Authorization, - Actions, - Events, - dummySignature, -} from './account-update.js'; +import { PublicKey } from './signature.js'; +import { ZkappCommand, TokenId, Authorization } from './account-update.js'; import * as Fetch from './fetch.js'; -import { NetworkValue } from './precondition.js'; -import { cloneCircuitValue } from './circuit-value.js'; -import { JsonProof, verify } from './proof-system.js'; import { invalidTransactionError } from './mina/errors.js'; -import { Types, TypesBigint } from '../bindings/mina-transaction/types.js'; +import { Types } from '../bindings/mina-transaction/types.js'; import { Account } from './mina/account.js'; -import { TransactionCost, TransactionLimits } from './mina/constants.js'; import { prettifyStacktrace } from './errors.js'; -import { Ml } from './ml/conversion.js'; -import { - transactionCommitments, - verifyAccountUpdateSignature, -} from '../mina-signer/src/sign-zkapp-command.js'; import { NetworkId } from '../mina-signer/src/types.js'; import { currentTransaction } from './mina/transaction-context.js'; import { @@ -33,13 +15,15 @@ import { setActiveInstance, Mina, defaultNetworkConstants, + reportGetAccountError, + verifyTransactionLimits, + defaultNetworkState, + filterGroups, type FeePayerSpec, type DeprecatedFeePayerSpec, type ActionStates, type NetworkConstants, } from './mina/mina-instance.js'; -import { SimpleLedger } from './mina/transaction-logic/ledger.js'; -import { assert } from './gadgets/common.js'; import { type EventActionFilterOptions } from './mina/graphql.js'; import { type Transaction, @@ -50,11 +34,12 @@ import { newTransaction, createIncludedOrRejectedTransaction, } from './mina/transaction.js'; +import { LocalBlockchain } from './mina/local-blockchain.js'; export { BerkeleyQANet, - Network, LocalBlockchain, + Network, currentTransaction, Transaction, PendingTransaction, @@ -100,369 +85,6 @@ const Transaction = { }, }; -function reportGetAccountError(publicKey: string, tokenId: string) { - if (tokenId === TokenId.toBase58(TokenId.default)) { - return `getAccount: Could not find account for public key ${publicKey}`; - } else { - return `getAccount: Could not find account for public key ${publicKey} with the tokenId ${tokenId}`; - } -} - -/** - * A mock Mina blockchain running locally and useful for testing. - */ -function LocalBlockchain({ - proofsEnabled = true, - enforceTransactionLimits = true, - networkId = 'testnet' as NetworkId, -} = {}) { - const slotTime = 3 * 60 * 1000; - const startTime = Date.now(); - const genesisTimestamp = UInt64.from(startTime); - const ledger = Ledger.create(); - let networkState = defaultNetworkState(); - let minaNetworkId: NetworkId = networkId; - - function addAccount(publicKey: PublicKey, balance: string) { - ledger.addAccount(Ml.fromPublicKey(publicKey), balance); - } - - let testAccounts: { - publicKey: PublicKey; - privateKey: PrivateKey; - }[] = []; - - for (let i = 0; i < 10; ++i) { - let MINA = 10n ** 9n; - const largeValue = 1000n * MINA; - const k = PrivateKey.random(); - const pk = k.toPublicKey(); - addAccount(pk, largeValue.toString()); - testAccounts.push({ privateKey: k, publicKey: pk }); - } - - const events: Record = {}; - const actions: Record< - string, - Record - > = {}; - - return { - getNetworkId: () => minaNetworkId, - proofsEnabled, - /** - * @deprecated use {@link Mina.getNetworkConstants} - */ - accountCreationFee: () => defaultNetworkConstants.accountCreationFee, - 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; - }, - async sendTransaction(txn: Transaction): Promise { - txn.sign(); - - let zkappCommandJson = ZkappCommand.toJSON(txn.transaction); - let commitments = transactionCommitments( - TypesBigint.ZkappCommand.fromJSON(zkappCommandJson), - minaNetworkId - ); - - 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 isSuccess = true; - const errors: string[] = []; - try { - ledger.applyJsonTransaction( - JSON.stringify(zkappCommandJson), - defaultNetworkConstants.accountCreationFee.toString(), - JSON.stringify(networkState) - ); - } catch (err: any) { - isSuccess = false; - 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(), - }); - } - }); - - const hash = Test.transactionHash.hashZkAppCommand(txn.toJSON()); - const pendingTransaction: Omit< - PendingTransaction, - 'wait' | 'waitOrThrowIfError' - > = { - isSuccess, - errors, - transaction: txn.transaction, - toJSON: txn.toJSON, - toPretty: txn.toPretty, - hash: (): string => { - return hash; - }, - }; - - const wait = async (_options?: { - maxAttempts?: number; - interval?: number; - }) => { - return createIncludedOrRejectedTransaction( - pendingTransaction, - pendingTransaction.errors - ); - }; - - const waitOrThrowIfError = async (_options?: { - maxAttempts?: number; - interval?: number; - }) => { - return createIncludedOrRejectedTransaction( - pendingTransaction, - pendingTransaction.errors - ); - }; - - return { - ...pendingTransaction, - wait, - waitOrThrowIfError, - }; - }, - async transaction(sender: DeprecatedFeePayerSpec, f: () => void) { - // bad hack: run transaction just to see whether it creates proofs - // if it doesn't, this is the last chance to run SmartContract.runOutsideCircuit, which is supposed to run only once - // TODO: this has obvious holes if multiple zkapps are involved, but not relevant currently because we can't prove with multiple account updates - // and hopefully with upcoming work by Matt we can just run everything in the prover, and nowhere else - let tx = createTransaction(sender, f, 0, { - isFinalRunOutsideCircuit: false, - proofsEnabled: this.proofsEnabled, - fetchMode: 'test', - }); - let hasProofs = tx.transaction.accountUpdates.some( - Authorization.hasLazyProof - ); - return 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?.[publicKey.toBase58()]?.[TokenId.toBase58(tokenId)] ?? []; - }, - 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; - }, - }; -} -// assert type compatibility without preventing LocalBlockchain to return additional properties / methods -LocalBlockchain satisfies (...args: any) => Mina; - /** * Represents the Mina blockchain running on a real network */ @@ -1011,317 +633,6 @@ function dummyAccount(pubkey?: PublicKey): Account { return dummy; } -function defaultNetworkState(): NetworkValue { - let epochData: NetworkValue['stakingEpochData'] = { - ledger: { hash: Field(0), totalCurrency: UInt64.zero }, - seed: Field(0), - startCheckpoint: Field(0), - lockCheckpoint: Field(0), - epochLength: UInt32.zero, - }; - return { - snarkedLedgerHash: Field(0), - blockchainLength: UInt32.zero, - minWindowDensity: UInt32.zero, - totalCurrency: UInt64.zero, - globalSlotSinceGenesis: UInt32.zero, - stakingEpochData: epochData, - nextEpochData: cloneCircuitValue(epochData), - }; -} - -async function verifyAccountUpdate( - account: Account, - accountUpdate: AccountUpdate, - publicInput: ZkappPublicInput, - transactionCommitments: { commitment: bigint; fullCommitment: bigint }, - proofsEnabled: boolean, - networkId: NetworkId -): Promise { - // check that that top-level updates have mayUseToken = No - // (equivalent check exists in the Mina node) - if ( - accountUpdate.body.callDepth === 0 && - !AccountUpdate.MayUseToken.isNo(accountUpdate).toBoolean() - ) { - throw Error( - 'Top-level account update can not use or pass on token permissions. Make sure that\n' + - 'accountUpdate.body.mayUseToken = AccountUpdate.MayUseToken.No;' - ); - } - - let perm = account.permissions; - - // check if addMissingSignatures failed to include a signature - // due to a missing private key - if (accountUpdate.authorization === dummySignature()) { - let pk = PublicKey.toBase58(accountUpdate.body.publicKey); - throw Error( - `verifyAccountUpdate: Detected a missing signature for (${pk}), private key was missing.` - ); - } - // we are essentially only checking if the update is empty or an actual update - function includesChange( - val: T | string | null | (string | null)[] - ): boolean { - if (Array.isArray(val)) { - return !val.every((v) => v === null); - } else { - return val !== null; - } - } - - function permissionForUpdate(key: string): Types.AuthRequired { - switch (key) { - case 'appState': - return perm.editState; - case 'delegate': - return perm.setDelegate; - case 'verificationKey': - return perm.setVerificationKey.auth; - case 'permissions': - return perm.setPermissions; - case 'zkappUri': - return perm.setZkappUri; - case 'tokenSymbol': - return perm.setTokenSymbol; - case 'timing': - return perm.setTiming; - case 'votingFor': - return perm.setVotingFor; - case 'actions': - return perm.editActionState; - case 'incrementNonce': - return perm.incrementNonce; - case 'send': - return perm.send; - case 'receive': - return perm.receive; - default: - throw Error(`Invalid permission for field ${key}: does not exist.`); - } - } - - let accountUpdateJson = accountUpdate.toJSON(); - const update = accountUpdateJson.body.update; - - let errorTrace = ''; - - let isValidProof = false; - let isValidSignature = false; - - // we don't check if proofs aren't enabled - if (!proofsEnabled) isValidProof = true; - - if (accountUpdate.authorization.proof && proofsEnabled) { - try { - let publicInputFields = ZkappPublicInput.toFields(publicInput); - - let proof: JsonProof = { - maxProofsVerified: 2, - proof: accountUpdate.authorization.proof!, - publicInput: publicInputFields.map((f) => f.toString()), - publicOutput: [], - }; - - let verificationKey = account.zkapp?.verificationKey?.data; - assert( - verificationKey !== undefined, - 'Account does not have a verification key' - ); - - isValidProof = await verify(proof, verificationKey); - if (!isValidProof) { - throw Error( - `Invalid proof for account update\n${JSON.stringify(update)}` - ); - } - } catch (error) { - errorTrace += '\n\n' + (error as Error).stack; - isValidProof = false; - } - } - - if (accountUpdate.authorization.signature) { - // checking permissions and authorization for each account update individually - try { - isValidSignature = verifyAccountUpdateSignature( - TypesBigint.AccountUpdate.fromJSON(accountUpdateJson), - transactionCommitments, - networkId - ); - } catch (error) { - errorTrace += '\n\n' + (error as Error).stack; - isValidSignature = false; - } - } - - let verified = false; - - function checkPermission(p0: Types.AuthRequired, field: string) { - let p = Types.AuthRequired.toJSON(p0); - if (p === 'None') return; - - if (p === 'Impossible') { - throw Error( - `Transaction verification failed: Cannot update field '${field}' because permission for this field is '${p}'` - ); - } - - if (p === 'Signature' || p === 'Either') { - verified ||= isValidSignature; - } - - if (p === 'Proof' || p === 'Either') { - verified ||= isValidProof; - } - - if (!verified) { - throw Error( - `Transaction verification failed: Cannot update field '${field}' because permission for this field is '${p}', but the required authorization was not provided or is invalid. - ${errorTrace !== '' ? 'Error trace: ' + errorTrace : ''}\n\n` - ); - } - } - - // goes through the update field on a transaction - Object.entries(update).forEach(([key, value]) => { - if (includesChange(value)) { - let p = permissionForUpdate(key); - checkPermission(p, key); - } - }); - - // checks the sequence events (which result in an updated sequence state) - if (accountUpdate.body.actions.data.length > 0) { - let p = permissionForUpdate('actions'); - checkPermission(p, 'actions'); - } - - if (accountUpdate.body.incrementNonce.toBoolean()) { - let p = permissionForUpdate('incrementNonce'); - checkPermission(p, 'incrementNonce'); - } - - // this checks for an edge case where an account update can be approved using proofs but - // a) the proof is invalid (bad verification key) - // and b) there are no state changes initiate so no permissions will be checked - // however, if the verification key changes, the proof should still be invalid - if (errorTrace && !verified) { - throw Error( - `One or more proofs were invalid and no other form of authorization was provided.\n${errorTrace}` - ); - } -} - -function verifyTransactionLimits({ accountUpdates }: ZkappCommand) { - let eventElements = { events: 0, actions: 0 }; - - let authKinds = accountUpdates.map((update) => { - eventElements.events += countEventElements(update.body.events); - eventElements.actions += countEventElements(update.body.actions); - let { isSigned, isProved, verificationKeyHash } = - update.body.authorizationKind; - return { - isSigned: isSigned.toBoolean(), - isProved: isProved.toBoolean(), - verificationKeyHash: verificationKeyHash.toString(), - }; - }); - // insert entry for the fee payer - authKinds.unshift({ - isSigned: true, - isProved: false, - verificationKeyHash: '', - }); - let authTypes = filterGroups(authKinds); - - /* - np := proof - n2 := signedPair - n1 := signedSingle - - formula used to calculate how expensive a zkapp transaction is - - 10.26*np + 10.08*n2 + 9.14*n1 < 69.45 - */ - let totalTimeRequired = - TransactionCost.PROOF_COST * authTypes.proof + - TransactionCost.SIGNED_PAIR_COST * authTypes.signedPair + - TransactionCost.SIGNED_SINGLE_COST * authTypes.signedSingle; - - let isWithinCostLimit = totalTimeRequired < TransactionCost.COST_LIMIT; - - let isWithinEventsLimit = - eventElements.events <= TransactionLimits.MAX_EVENT_ELEMENTS; - let isWithinActionsLimit = - eventElements.actions <= TransactionLimits.MAX_ACTION_ELEMENTS; - - let error = ''; - - if (!isWithinCostLimit) { - // TODO: we should add a link to the docs explaining the reasoning behind it once we have such an explainer - error += `Error: The transaction is too expensive, try reducing the number of AccountUpdates that are attached to the transaction. -Each transaction needs to be processed by the snark workers on the network. -Certain layouts of AccountUpdates require more proving time than others, and therefore are too expensive. - -${JSON.stringify(authTypes)} -\n\n`; - } - - if (!isWithinEventsLimit) { - error += `Error: The account updates in your transaction are trying to emit too much event data. The maximum allowed number of field elements in events is ${TransactionLimits.MAX_EVENT_ELEMENTS}, but you tried to emit ${eventElements.events}.\n\n`; - } - - if (!isWithinActionsLimit) { - error += `Error: The account updates in your transaction are trying to emit too much action data. The maximum allowed number of field elements in actions is ${TransactionLimits.MAX_ACTION_ELEMENTS}, but you tried to emit ${eventElements.actions}.\n\n`; - } - - if (error) throw Error('Error during transaction sending:\n\n' + error); -} - -function countEventElements({ data }: Events) { - return data.reduce((acc, ev) => acc + ev.length, 0); -} - -type AuthorizationKind = { isProved: boolean; isSigned: boolean }; - -const isPair = (a: AuthorizationKind, b: AuthorizationKind) => - !a.isProved && !b.isProved; - -function filterPairs(xs: AuthorizationKind[]): { - xs: { isProved: boolean; isSigned: boolean }[]; - pairs: number; -} { - if (xs.length <= 1) return { xs, pairs: 0 }; - if (isPair(xs[0], xs[1])) { - let rec = filterPairs(xs.slice(2)); - return { xs: rec.xs, pairs: rec.pairs + 1 }; - } else { - let rec = filterPairs(xs.slice(1)); - return { xs: [xs[0]].concat(rec.xs), pairs: rec.pairs }; - } -} - -function filterGroups(xs: AuthorizationKind[]) { - let pairs = filterPairs(xs); - xs = pairs.xs; - - let singleCount = 0; - let proofCount = 0; - - xs.forEach((t) => { - if (t.isProved) proofCount++; - else singleCount++; - }); - - return { - signedPair: pairs.pairs, - signedSingle: singleCount, - proof: proofCount, - }; -} - async function waitForFunding(address: string): Promise { let attempts = 0; let maxAttempts = 30; diff --git a/src/lib/mina/local-blockchain.ts b/src/lib/mina/local-blockchain.ts new file mode 100644 index 0000000000..cefed43f8a --- /dev/null +++ b/src/lib/mina/local-blockchain.ts @@ -0,0 +1,389 @@ +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 } from '../../snarky.js'; +import { Field } from '../core.js'; +import { UInt32, UInt64 } from '../int.js'; +import { PrivateKey, PublicKey } from '../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 { Types, TypesBigint } from '../../bindings/mina-transaction/types.js'; +import { invalidTransactionError } from './errors.js'; +import { + Transaction, + PendingTransaction, + createIncludedOrRejectedTransaction, + createTransaction, +} from './transaction.js'; +import { + type DeprecatedFeePayerSpec, + type ActionStates, + Mina, + defaultNetworkConstants, + reportGetAccountError, + defaultNetworkState, + verifyTransactionLimits, + verifyAccountUpdate, +} from './mina-instance.js'; + +export { LocalBlockchain }; +/** + * A mock Mina blockchain running locally and useful for testing. + */ +function LocalBlockchain({ + proofsEnabled = true, + enforceTransactionLimits = true, + networkId = 'testnet' as NetworkId, +} = {}) { + const slotTime = 3 * 60 * 1000; + const startTime = Date.now(); + const genesisTimestamp = UInt64.from(startTime); + const ledger = Ledger.create(); + let networkState = defaultNetworkState(); + let minaNetworkId: NetworkId = networkId; + + function addAccount(publicKey: PublicKey, balance: string) { + ledger.addAccount(Ml.fromPublicKey(publicKey), balance); + } + + let testAccounts: { + publicKey: PublicKey; + privateKey: PrivateKey; + }[] = []; + + for (let i = 0; i < 10; ++i) { + let MINA = 10n ** 9n; + const largeValue = 1000n * MINA; + const k = PrivateKey.random(); + const pk = k.toPublicKey(); + addAccount(pk, largeValue.toString()); + testAccounts.push({ privateKey: k, publicKey: pk }); + } + + const events: Record = {}; + const actions: Record< + string, + Record + > = {}; + + return { + getNetworkId: () => minaNetworkId, + proofsEnabled, + /** + * @deprecated use {@link Mina.getNetworkConstants} + */ + accountCreationFee: () => defaultNetworkConstants.accountCreationFee, + 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; + }, + async sendTransaction(txn: Transaction): Promise { + txn.sign(); + + let zkappCommandJson = ZkappCommand.toJSON(txn.transaction); + let commitments = transactionCommitments( + TypesBigint.ZkappCommand.fromJSON(zkappCommandJson), + minaNetworkId + ); + + 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 isSuccess = true; + const errors: string[] = []; + try { + ledger.applyJsonTransaction( + JSON.stringify(zkappCommandJson), + defaultNetworkConstants.accountCreationFee.toString(), + JSON.stringify(networkState) + ); + } catch (err: any) { + isSuccess = false; + 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(), + }); + } + }); + + const hash = Test.transactionHash.hashZkAppCommand(txn.toJSON()); + const pendingTransaction: Omit< + PendingTransaction, + 'wait' | 'waitOrThrowIfError' + > = { + isSuccess, + errors, + transaction: txn.transaction, + toJSON: txn.toJSON, + toPretty: txn.toPretty, + hash: (): string => { + return hash; + }, + }; + + const wait = async (_options?: { + maxAttempts?: number; + interval?: number; + }) => { + return createIncludedOrRejectedTransaction( + pendingTransaction, + pendingTransaction.errors + ); + }; + + const waitOrThrowIfError = async (_options?: { + maxAttempts?: number; + interval?: number; + }) => { + return createIncludedOrRejectedTransaction( + pendingTransaction, + pendingTransaction.errors + ); + }; + + return { + ...pendingTransaction, + wait, + waitOrThrowIfError, + }; + }, + async transaction(sender: DeprecatedFeePayerSpec, f: () => void) { + // bad hack: run transaction just to see whether it creates proofs + // if it doesn't, this is the last chance to run SmartContract.runOutsideCircuit, which is supposed to run only once + // TODO: this has obvious holes if multiple zkapps are involved, but not relevant currently because we can't prove with multiple account updates + // and hopefully with upcoming work by Matt we can just run everything in the prover, and nowhere else + let tx = createTransaction(sender, f, 0, { + isFinalRunOutsideCircuit: false, + proofsEnabled: this.proofsEnabled, + fetchMode: 'test', + }); + let hasProofs = tx.transaction.accountUpdates.some( + Authorization.hasLazyProof + ); + return 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?.[publicKey.toBase58()]?.[TokenId.toBase58(tokenId)] ?? []; + }, + 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; + }, + }; +} +// assert type compatibility without preventing LocalBlockchain to return additional properties / methods +LocalBlockchain satisfies (...args: any) => Mina; diff --git a/src/lib/mina/mina-instance.ts b/src/lib/mina/mina-instance.ts index 92e4ac68c1..6cc1d85d7b 100644 --- a/src/lib/mina/mina-instance.ts +++ b/src/lib/mina/mina-instance.ts @@ -1,15 +1,29 @@ /** * This module holds the global Mina instance and its interface. */ -import type { Field } from '../field.js'; +import { + ZkappCommand, + TokenId, + Events, + ZkappPublicInput, + AccountUpdate, + dummySignature, +} from '../account-update.js'; +import { Field } from '../core.js'; import { UInt64, UInt32 } from '../int.js'; -import type { PublicKey, PrivateKey } from '../signature.js'; +import { PublicKey, PrivateKey } from '../signature.js'; +import { JsonProof, verify } from '../proof-system.js'; +import { verifyAccountUpdateSignature } from '../../mina-signer/src/sign-zkapp-command.js'; +import { TransactionCost, TransactionLimits } from './constants.js'; +import { cloneCircuitValue } from '../circuit-value.js'; +import { assert } from '../gadgets/common.js'; +import { Types, TypesBigint } from '../../bindings/mina-transaction/types.js'; +import type { EventActionFilterOptions } from '././../mina/graphql.js'; +import type { NetworkId } from '../../mina-signer/src/types.js'; import type { Transaction, PendingTransaction } from '../mina.js'; import type { Account } from './account.js'; import type { NetworkValue } from '../precondition.js'; import type * as Fetch from '../fetch.js'; -import { type EventActionFilterOptions } from '././../mina/graphql.js'; -import type { NetworkId } from '../../mina-signer/src/types.js'; export { Mina, @@ -21,6 +35,11 @@ export { activeInstance, setActiveInstance, ZkappStateLength, + reportGetAccountError, + defaultNetworkState, + verifyTransactionLimits, + verifyAccountUpdate, + filterGroups, }; const defaultAccountCreationFee = 1_000_000_000; @@ -138,3 +157,322 @@ function setActiveInstance(m: Mina) { function noActiveInstance(): never { throw Error('Must call Mina.setActiveInstance first'); } + +function reportGetAccountError(publicKey: string, tokenId: string) { + if (tokenId === TokenId.toBase58(TokenId.default)) { + return `getAccount: Could not find account for public key ${publicKey}`; + } else { + return `getAccount: Could not find account for public key ${publicKey} with the tokenId ${tokenId}`; + } +} + +function defaultNetworkState(): NetworkValue { + let epochData: NetworkValue['stakingEpochData'] = { + ledger: { hash: Field(0), totalCurrency: UInt64.zero }, + seed: Field(0), + startCheckpoint: Field(0), + lockCheckpoint: Field(0), + epochLength: UInt32.zero, + }; + return { + snarkedLedgerHash: Field(0), + blockchainLength: UInt32.zero, + minWindowDensity: UInt32.zero, + totalCurrency: UInt64.zero, + globalSlotSinceGenesis: UInt32.zero, + stakingEpochData: epochData, + nextEpochData: cloneCircuitValue(epochData), + }; +} + +function verifyTransactionLimits({ accountUpdates }: ZkappCommand) { + let eventElements = { events: 0, actions: 0 }; + + let authKinds = accountUpdates.map((update) => { + eventElements.events += countEventElements(update.body.events); + eventElements.actions += countEventElements(update.body.actions); + let { isSigned, isProved, verificationKeyHash } = + update.body.authorizationKind; + return { + isSigned: isSigned.toBoolean(), + isProved: isProved.toBoolean(), + verificationKeyHash: verificationKeyHash.toString(), + }; + }); + // insert entry for the fee payer + authKinds.unshift({ + isSigned: true, + isProved: false, + verificationKeyHash: '', + }); + let authTypes = filterGroups(authKinds); + + /* + np := proof + n2 := signedPair + n1 := signedSingle + + formula used to calculate how expensive a zkapp transaction is + + 10.26*np + 10.08*n2 + 9.14*n1 < 69.45 + */ + let totalTimeRequired = + TransactionCost.PROOF_COST * authTypes.proof + + TransactionCost.SIGNED_PAIR_COST * authTypes.signedPair + + TransactionCost.SIGNED_SINGLE_COST * authTypes.signedSingle; + + let isWithinCostLimit = totalTimeRequired < TransactionCost.COST_LIMIT; + + let isWithinEventsLimit = + eventElements.events <= TransactionLimits.MAX_EVENT_ELEMENTS; + let isWithinActionsLimit = + eventElements.actions <= TransactionLimits.MAX_ACTION_ELEMENTS; + + let error = ''; + + if (!isWithinCostLimit) { + // TODO: we should add a link to the docs explaining the reasoning behind it once we have such an explainer + error += `Error: The transaction is too expensive, try reducing the number of AccountUpdates that are attached to the transaction. +Each transaction needs to be processed by the snark workers on the network. +Certain layouts of AccountUpdates require more proving time than others, and therefore are too expensive. + +${JSON.stringify(authTypes)} +\n\n`; + } + + if (!isWithinEventsLimit) { + error += `Error: The account updates in your transaction are trying to emit too much event data. The maximum allowed number of field elements in events is ${TransactionLimits.MAX_EVENT_ELEMENTS}, but you tried to emit ${eventElements.events}.\n\n`; + } + + if (!isWithinActionsLimit) { + error += `Error: The account updates in your transaction are trying to emit too much action data. The maximum allowed number of field elements in actions is ${TransactionLimits.MAX_ACTION_ELEMENTS}, but you tried to emit ${eventElements.actions}.\n\n`; + } + + if (error) throw Error('Error during transaction sending:\n\n' + error); +} + +function countEventElements({ data }: Events) { + return data.reduce((acc, ev) => acc + ev.length, 0); +} + +function filterGroups(xs: AuthorizationKind[]) { + let pairs = filterPairs(xs); + xs = pairs.xs; + + let singleCount = 0; + let proofCount = 0; + + xs.forEach((t) => { + if (t.isProved) proofCount++; + else singleCount++; + }); + + return { + signedPair: pairs.pairs, + signedSingle: singleCount, + proof: proofCount, + }; +} + +async function verifyAccountUpdate( + account: Account, + accountUpdate: AccountUpdate, + publicInput: ZkappPublicInput, + transactionCommitments: { commitment: bigint; fullCommitment: bigint }, + proofsEnabled: boolean, + networkId: NetworkId +): Promise { + // check that that top-level updates have mayUseToken = No + // (equivalent check exists in the Mina node) + if ( + accountUpdate.body.callDepth === 0 && + !AccountUpdate.MayUseToken.isNo(accountUpdate).toBoolean() + ) { + throw Error( + 'Top-level account update can not use or pass on token permissions. Make sure that\n' + + 'accountUpdate.body.mayUseToken = AccountUpdate.MayUseToken.No;' + ); + } + + let perm = account.permissions; + + // check if addMissingSignatures failed to include a signature + // due to a missing private key + if (accountUpdate.authorization === dummySignature()) { + let pk = PublicKey.toBase58(accountUpdate.body.publicKey); + throw Error( + `verifyAccountUpdate: Detected a missing signature for (${pk}), private key was missing.` + ); + } + // we are essentially only checking if the update is empty or an actual update + function includesChange( + val: T | string | null | (string | null)[] + ): boolean { + if (Array.isArray(val)) { + return !val.every((v) => v === null); + } else { + return val !== null; + } + } + + function permissionForUpdate(key: string): Types.AuthRequired { + switch (key) { + case 'appState': + return perm.editState; + case 'delegate': + return perm.setDelegate; + case 'verificationKey': + return perm.setVerificationKey.auth; + case 'permissions': + return perm.setPermissions; + case 'zkappUri': + return perm.setZkappUri; + case 'tokenSymbol': + return perm.setTokenSymbol; + case 'timing': + return perm.setTiming; + case 'votingFor': + return perm.setVotingFor; + case 'actions': + return perm.editActionState; + case 'incrementNonce': + return perm.incrementNonce; + case 'send': + return perm.send; + case 'receive': + return perm.receive; + default: + throw Error(`Invalid permission for field ${key}: does not exist.`); + } + } + + let accountUpdateJson = accountUpdate.toJSON(); + const update = accountUpdateJson.body.update; + + let errorTrace = ''; + + let isValidProof = false; + let isValidSignature = false; + + // we don't check if proofs aren't enabled + if (!proofsEnabled) isValidProof = true; + + if (accountUpdate.authorization.proof && proofsEnabled) { + try { + let publicInputFields = ZkappPublicInput.toFields(publicInput); + + let proof: JsonProof = { + maxProofsVerified: 2, + proof: accountUpdate.authorization.proof!, + publicInput: publicInputFields.map((f) => f.toString()), + publicOutput: [], + }; + + let verificationKey = account.zkapp?.verificationKey?.data; + assert( + verificationKey !== undefined, + 'Account does not have a verification key' + ); + + isValidProof = await verify(proof, verificationKey); + if (!isValidProof) { + throw Error( + `Invalid proof for account update\n${JSON.stringify(update)}` + ); + } + } catch (error) { + errorTrace += '\n\n' + (error as Error).stack; + isValidProof = false; + } + } + + if (accountUpdate.authorization.signature) { + // checking permissions and authorization for each account update individually + try { + isValidSignature = verifyAccountUpdateSignature( + TypesBigint.AccountUpdate.fromJSON(accountUpdateJson), + transactionCommitments, + networkId + ); + } catch (error) { + errorTrace += '\n\n' + (error as Error).stack; + isValidSignature = false; + } + } + + let verified = false; + + function checkPermission(p0: Types.AuthRequired, field: string) { + let p = Types.AuthRequired.toJSON(p0); + if (p === 'None') return; + + if (p === 'Impossible') { + throw Error( + `Transaction verification failed: Cannot update field '${field}' because permission for this field is '${p}'` + ); + } + + if (p === 'Signature' || p === 'Either') { + verified ||= isValidSignature; + } + + if (p === 'Proof' || p === 'Either') { + verified ||= isValidProof; + } + + if (!verified) { + throw Error( + `Transaction verification failed: Cannot update field '${field}' because permission for this field is '${p}', but the required authorization was not provided or is invalid. + ${errorTrace !== '' ? 'Error trace: ' + errorTrace : ''}\n\n` + ); + } + } + + // goes through the update field on a transaction + Object.entries(update).forEach(([key, value]) => { + if (includesChange(value)) { + let p = permissionForUpdate(key); + checkPermission(p, key); + } + }); + + // checks the sequence events (which result in an updated sequence state) + if (accountUpdate.body.actions.data.length > 0) { + let p = permissionForUpdate('actions'); + checkPermission(p, 'actions'); + } + + if (accountUpdate.body.incrementNonce.toBoolean()) { + let p = permissionForUpdate('incrementNonce'); + checkPermission(p, 'incrementNonce'); + } + + // this checks for an edge case where an account update can be approved using proofs but + // a) the proof is invalid (bad verification key) + // and b) there are no state changes initiate so no permissions will be checked + // however, if the verification key changes, the proof should still be invalid + if (errorTrace && !verified) { + throw Error( + `One or more proofs were invalid and no other form of authorization was provided.\n${errorTrace}` + ); + } +} + +type AuthorizationKind = { isProved: boolean; isSigned: boolean }; + +const isPair = (a: AuthorizationKind, b: AuthorizationKind) => + !a.isProved && !b.isProved; + +function filterPairs(xs: AuthorizationKind[]): { + xs: { isProved: boolean; isSigned: boolean }[]; + pairs: number; +} { + if (xs.length <= 1) return { xs, pairs: 0 }; + if (isPair(xs[0], xs[1])) { + let rec = filterPairs(xs.slice(2)); + return { xs: rec.xs, pairs: rec.pairs + 1 }; + } else { + let rec = filterPairs(xs.slice(1)); + return { xs: [xs[0]].concat(rec.xs), pairs: rec.pairs }; + } +} From 1aa462be5f0dbab64d53d2337ab766b5fcb7c3b3 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 21 Feb 2024 10:32:39 -0800 Subject: [PATCH 49/59] refactor(mina.ts): move transaction validation functions to separate file --- src/lib/mina.ts | 10 +- src/lib/mina/local-blockchain.ts | 4 +- src/lib/mina/mina-instance.ts | 338 ------------------------ src/lib/mina/transaction-validation.ts | 350 +++++++++++++++++++++++++ 4 files changed, 359 insertions(+), 343 deletions(-) create mode 100644 src/lib/mina/transaction-validation.ts diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 054dde392c..2503e4181f 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -15,10 +15,6 @@ import { setActiveInstance, Mina, defaultNetworkConstants, - reportGetAccountError, - verifyTransactionLimits, - defaultNetworkState, - filterGroups, type FeePayerSpec, type DeprecatedFeePayerSpec, type ActionStates, @@ -34,6 +30,12 @@ import { newTransaction, createIncludedOrRejectedTransaction, } from './mina/transaction.js'; +import { + reportGetAccountError, + verifyTransactionLimits, + defaultNetworkState, + filterGroups, +} from './mina/transaction-validation.js'; import { LocalBlockchain } from './mina/local-blockchain.js'; export { diff --git a/src/lib/mina/local-blockchain.ts b/src/lib/mina/local-blockchain.ts index cefed43f8a..a5f9920f1d 100644 --- a/src/lib/mina/local-blockchain.ts +++ b/src/lib/mina/local-blockchain.ts @@ -26,11 +26,13 @@ import { type ActionStates, Mina, defaultNetworkConstants, +} from './mina-instance.js'; +import { reportGetAccountError, defaultNetworkState, verifyTransactionLimits, verifyAccountUpdate, -} from './mina-instance.js'; +} from './transaction-validation.js'; export { LocalBlockchain }; /** diff --git a/src/lib/mina/mina-instance.ts b/src/lib/mina/mina-instance.ts index 6cc1d85d7b..be0080bab5 100644 --- a/src/lib/mina/mina-instance.ts +++ b/src/lib/mina/mina-instance.ts @@ -1,23 +1,9 @@ /** * This module holds the global Mina instance and its interface. */ -import { - ZkappCommand, - TokenId, - Events, - ZkappPublicInput, - AccountUpdate, - dummySignature, -} from '../account-update.js'; import { Field } from '../core.js'; import { UInt64, UInt32 } from '../int.js'; import { PublicKey, PrivateKey } from '../signature.js'; -import { JsonProof, verify } from '../proof-system.js'; -import { verifyAccountUpdateSignature } from '../../mina-signer/src/sign-zkapp-command.js'; -import { TransactionCost, TransactionLimits } from './constants.js'; -import { cloneCircuitValue } from '../circuit-value.js'; -import { assert } from '../gadgets/common.js'; -import { Types, TypesBigint } from '../../bindings/mina-transaction/types.js'; import type { EventActionFilterOptions } from '././../mina/graphql.js'; import type { NetworkId } from '../../mina-signer/src/types.js'; import type { Transaction, PendingTransaction } from '../mina.js'; @@ -35,11 +21,6 @@ export { activeInstance, setActiveInstance, ZkappStateLength, - reportGetAccountError, - defaultNetworkState, - verifyTransactionLimits, - verifyAccountUpdate, - filterGroups, }; const defaultAccountCreationFee = 1_000_000_000; @@ -157,322 +138,3 @@ function setActiveInstance(m: Mina) { function noActiveInstance(): never { throw Error('Must call Mina.setActiveInstance first'); } - -function reportGetAccountError(publicKey: string, tokenId: string) { - if (tokenId === TokenId.toBase58(TokenId.default)) { - return `getAccount: Could not find account for public key ${publicKey}`; - } else { - return `getAccount: Could not find account for public key ${publicKey} with the tokenId ${tokenId}`; - } -} - -function defaultNetworkState(): NetworkValue { - let epochData: NetworkValue['stakingEpochData'] = { - ledger: { hash: Field(0), totalCurrency: UInt64.zero }, - seed: Field(0), - startCheckpoint: Field(0), - lockCheckpoint: Field(0), - epochLength: UInt32.zero, - }; - return { - snarkedLedgerHash: Field(0), - blockchainLength: UInt32.zero, - minWindowDensity: UInt32.zero, - totalCurrency: UInt64.zero, - globalSlotSinceGenesis: UInt32.zero, - stakingEpochData: epochData, - nextEpochData: cloneCircuitValue(epochData), - }; -} - -function verifyTransactionLimits({ accountUpdates }: ZkappCommand) { - let eventElements = { events: 0, actions: 0 }; - - let authKinds = accountUpdates.map((update) => { - eventElements.events += countEventElements(update.body.events); - eventElements.actions += countEventElements(update.body.actions); - let { isSigned, isProved, verificationKeyHash } = - update.body.authorizationKind; - return { - isSigned: isSigned.toBoolean(), - isProved: isProved.toBoolean(), - verificationKeyHash: verificationKeyHash.toString(), - }; - }); - // insert entry for the fee payer - authKinds.unshift({ - isSigned: true, - isProved: false, - verificationKeyHash: '', - }); - let authTypes = filterGroups(authKinds); - - /* - np := proof - n2 := signedPair - n1 := signedSingle - - formula used to calculate how expensive a zkapp transaction is - - 10.26*np + 10.08*n2 + 9.14*n1 < 69.45 - */ - let totalTimeRequired = - TransactionCost.PROOF_COST * authTypes.proof + - TransactionCost.SIGNED_PAIR_COST * authTypes.signedPair + - TransactionCost.SIGNED_SINGLE_COST * authTypes.signedSingle; - - let isWithinCostLimit = totalTimeRequired < TransactionCost.COST_LIMIT; - - let isWithinEventsLimit = - eventElements.events <= TransactionLimits.MAX_EVENT_ELEMENTS; - let isWithinActionsLimit = - eventElements.actions <= TransactionLimits.MAX_ACTION_ELEMENTS; - - let error = ''; - - if (!isWithinCostLimit) { - // TODO: we should add a link to the docs explaining the reasoning behind it once we have such an explainer - error += `Error: The transaction is too expensive, try reducing the number of AccountUpdates that are attached to the transaction. -Each transaction needs to be processed by the snark workers on the network. -Certain layouts of AccountUpdates require more proving time than others, and therefore are too expensive. - -${JSON.stringify(authTypes)} -\n\n`; - } - - if (!isWithinEventsLimit) { - error += `Error: The account updates in your transaction are trying to emit too much event data. The maximum allowed number of field elements in events is ${TransactionLimits.MAX_EVENT_ELEMENTS}, but you tried to emit ${eventElements.events}.\n\n`; - } - - if (!isWithinActionsLimit) { - error += `Error: The account updates in your transaction are trying to emit too much action data. The maximum allowed number of field elements in actions is ${TransactionLimits.MAX_ACTION_ELEMENTS}, but you tried to emit ${eventElements.actions}.\n\n`; - } - - if (error) throw Error('Error during transaction sending:\n\n' + error); -} - -function countEventElements({ data }: Events) { - return data.reduce((acc, ev) => acc + ev.length, 0); -} - -function filterGroups(xs: AuthorizationKind[]) { - let pairs = filterPairs(xs); - xs = pairs.xs; - - let singleCount = 0; - let proofCount = 0; - - xs.forEach((t) => { - if (t.isProved) proofCount++; - else singleCount++; - }); - - return { - signedPair: pairs.pairs, - signedSingle: singleCount, - proof: proofCount, - }; -} - -async function verifyAccountUpdate( - account: Account, - accountUpdate: AccountUpdate, - publicInput: ZkappPublicInput, - transactionCommitments: { commitment: bigint; fullCommitment: bigint }, - proofsEnabled: boolean, - networkId: NetworkId -): Promise { - // check that that top-level updates have mayUseToken = No - // (equivalent check exists in the Mina node) - if ( - accountUpdate.body.callDepth === 0 && - !AccountUpdate.MayUseToken.isNo(accountUpdate).toBoolean() - ) { - throw Error( - 'Top-level account update can not use or pass on token permissions. Make sure that\n' + - 'accountUpdate.body.mayUseToken = AccountUpdate.MayUseToken.No;' - ); - } - - let perm = account.permissions; - - // check if addMissingSignatures failed to include a signature - // due to a missing private key - if (accountUpdate.authorization === dummySignature()) { - let pk = PublicKey.toBase58(accountUpdate.body.publicKey); - throw Error( - `verifyAccountUpdate: Detected a missing signature for (${pk}), private key was missing.` - ); - } - // we are essentially only checking if the update is empty or an actual update - function includesChange( - val: T | string | null | (string | null)[] - ): boolean { - if (Array.isArray(val)) { - return !val.every((v) => v === null); - } else { - return val !== null; - } - } - - function permissionForUpdate(key: string): Types.AuthRequired { - switch (key) { - case 'appState': - return perm.editState; - case 'delegate': - return perm.setDelegate; - case 'verificationKey': - return perm.setVerificationKey.auth; - case 'permissions': - return perm.setPermissions; - case 'zkappUri': - return perm.setZkappUri; - case 'tokenSymbol': - return perm.setTokenSymbol; - case 'timing': - return perm.setTiming; - case 'votingFor': - return perm.setVotingFor; - case 'actions': - return perm.editActionState; - case 'incrementNonce': - return perm.incrementNonce; - case 'send': - return perm.send; - case 'receive': - return perm.receive; - default: - throw Error(`Invalid permission for field ${key}: does not exist.`); - } - } - - let accountUpdateJson = accountUpdate.toJSON(); - const update = accountUpdateJson.body.update; - - let errorTrace = ''; - - let isValidProof = false; - let isValidSignature = false; - - // we don't check if proofs aren't enabled - if (!proofsEnabled) isValidProof = true; - - if (accountUpdate.authorization.proof && proofsEnabled) { - try { - let publicInputFields = ZkappPublicInput.toFields(publicInput); - - let proof: JsonProof = { - maxProofsVerified: 2, - proof: accountUpdate.authorization.proof!, - publicInput: publicInputFields.map((f) => f.toString()), - publicOutput: [], - }; - - let verificationKey = account.zkapp?.verificationKey?.data; - assert( - verificationKey !== undefined, - 'Account does not have a verification key' - ); - - isValidProof = await verify(proof, verificationKey); - if (!isValidProof) { - throw Error( - `Invalid proof for account update\n${JSON.stringify(update)}` - ); - } - } catch (error) { - errorTrace += '\n\n' + (error as Error).stack; - isValidProof = false; - } - } - - if (accountUpdate.authorization.signature) { - // checking permissions and authorization for each account update individually - try { - isValidSignature = verifyAccountUpdateSignature( - TypesBigint.AccountUpdate.fromJSON(accountUpdateJson), - transactionCommitments, - networkId - ); - } catch (error) { - errorTrace += '\n\n' + (error as Error).stack; - isValidSignature = false; - } - } - - let verified = false; - - function checkPermission(p0: Types.AuthRequired, field: string) { - let p = Types.AuthRequired.toJSON(p0); - if (p === 'None') return; - - if (p === 'Impossible') { - throw Error( - `Transaction verification failed: Cannot update field '${field}' because permission for this field is '${p}'` - ); - } - - if (p === 'Signature' || p === 'Either') { - verified ||= isValidSignature; - } - - if (p === 'Proof' || p === 'Either') { - verified ||= isValidProof; - } - - if (!verified) { - throw Error( - `Transaction verification failed: Cannot update field '${field}' because permission for this field is '${p}', but the required authorization was not provided or is invalid. - ${errorTrace !== '' ? 'Error trace: ' + errorTrace : ''}\n\n` - ); - } - } - - // goes through the update field on a transaction - Object.entries(update).forEach(([key, value]) => { - if (includesChange(value)) { - let p = permissionForUpdate(key); - checkPermission(p, key); - } - }); - - // checks the sequence events (which result in an updated sequence state) - if (accountUpdate.body.actions.data.length > 0) { - let p = permissionForUpdate('actions'); - checkPermission(p, 'actions'); - } - - if (accountUpdate.body.incrementNonce.toBoolean()) { - let p = permissionForUpdate('incrementNonce'); - checkPermission(p, 'incrementNonce'); - } - - // this checks for an edge case where an account update can be approved using proofs but - // a) the proof is invalid (bad verification key) - // and b) there are no state changes initiate so no permissions will be checked - // however, if the verification key changes, the proof should still be invalid - if (errorTrace && !verified) { - throw Error( - `One or more proofs were invalid and no other form of authorization was provided.\n${errorTrace}` - ); - } -} - -type AuthorizationKind = { isProved: boolean; isSigned: boolean }; - -const isPair = (a: AuthorizationKind, b: AuthorizationKind) => - !a.isProved && !b.isProved; - -function filterPairs(xs: AuthorizationKind[]): { - xs: { isProved: boolean; isSigned: boolean }[]; - pairs: number; -} { - if (xs.length <= 1) return { xs, pairs: 0 }; - if (isPair(xs[0], xs[1])) { - let rec = filterPairs(xs.slice(2)); - return { xs: rec.xs, pairs: rec.pairs + 1 }; - } else { - let rec = filterPairs(xs.slice(1)); - return { xs: [xs[0]].concat(rec.xs), pairs: rec.pairs }; - } -} diff --git a/src/lib/mina/transaction-validation.ts b/src/lib/mina/transaction-validation.ts new file mode 100644 index 0000000000..afe67d0451 --- /dev/null +++ b/src/lib/mina/transaction-validation.ts @@ -0,0 +1,350 @@ +/** + * This module holds the global Mina instance and its interface. + */ +import { + ZkappCommand, + TokenId, + Events, + ZkappPublicInput, + AccountUpdate, + dummySignature, +} from '../account-update.js'; +import { Field } from '../core.js'; +import { UInt64, UInt32 } from '../int.js'; +import { PublicKey } from '../signature.js'; +import { JsonProof, verify } from '../proof-system.js'; +import { verifyAccountUpdateSignature } from '../../mina-signer/src/sign-zkapp-command.js'; +import { TransactionCost, TransactionLimits } from './constants.js'; +import { cloneCircuitValue } from '../circuit-value.js'; +import { assert } from '../gadgets/common.js'; +import { Types, TypesBigint } from '../../bindings/mina-transaction/types.js'; +import type { NetworkId } from '../../mina-signer/src/types.js'; +import type { Account } from './account.js'; +import type { NetworkValue } from '../precondition.js'; + +export { + reportGetAccountError, + defaultNetworkState, + verifyTransactionLimits, + verifyAccountUpdate, + filterGroups, +}; + +function reportGetAccountError(publicKey: string, tokenId: string) { + if (tokenId === TokenId.toBase58(TokenId.default)) { + return `getAccount: Could not find account for public key ${publicKey}`; + } else { + return `getAccount: Could not find account for public key ${publicKey} with the tokenId ${tokenId}`; + } +} + +function defaultNetworkState(): NetworkValue { + let epochData: NetworkValue['stakingEpochData'] = { + ledger: { hash: Field(0), totalCurrency: UInt64.zero }, + seed: Field(0), + startCheckpoint: Field(0), + lockCheckpoint: Field(0), + epochLength: UInt32.zero, + }; + return { + snarkedLedgerHash: Field(0), + blockchainLength: UInt32.zero, + minWindowDensity: UInt32.zero, + totalCurrency: UInt64.zero, + globalSlotSinceGenesis: UInt32.zero, + stakingEpochData: epochData, + nextEpochData: cloneCircuitValue(epochData), + }; +} + +function verifyTransactionLimits({ accountUpdates }: ZkappCommand) { + let eventElements = { events: 0, actions: 0 }; + + let authKinds = accountUpdates.map((update) => { + eventElements.events += countEventElements(update.body.events); + eventElements.actions += countEventElements(update.body.actions); + let { isSigned, isProved, verificationKeyHash } = + update.body.authorizationKind; + return { + isSigned: isSigned.toBoolean(), + isProved: isProved.toBoolean(), + verificationKeyHash: verificationKeyHash.toString(), + }; + }); + // insert entry for the fee payer + authKinds.unshift({ + isSigned: true, + isProved: false, + verificationKeyHash: '', + }); + let authTypes = filterGroups(authKinds); + + /* + np := proof + n2 := signedPair + n1 := signedSingle + + formula used to calculate how expensive a zkapp transaction is + + 10.26*np + 10.08*n2 + 9.14*n1 < 69.45 + */ + let totalTimeRequired = + TransactionCost.PROOF_COST * authTypes.proof + + TransactionCost.SIGNED_PAIR_COST * authTypes.signedPair + + TransactionCost.SIGNED_SINGLE_COST * authTypes.signedSingle; + + let isWithinCostLimit = totalTimeRequired < TransactionCost.COST_LIMIT; + + let isWithinEventsLimit = + eventElements.events <= TransactionLimits.MAX_EVENT_ELEMENTS; + let isWithinActionsLimit = + eventElements.actions <= TransactionLimits.MAX_ACTION_ELEMENTS; + + let error = ''; + + if (!isWithinCostLimit) { + // TODO: we should add a link to the docs explaining the reasoning behind it once we have such an explainer + error += `Error: The transaction is too expensive, try reducing the number of AccountUpdates that are attached to the transaction. +Each transaction needs to be processed by the snark workers on the network. +Certain layouts of AccountUpdates require more proving time than others, and therefore are too expensive. + +${JSON.stringify(authTypes)} +\n\n`; + } + + if (!isWithinEventsLimit) { + error += `Error: The account updates in your transaction are trying to emit too much event data. The maximum allowed number of field elements in events is ${TransactionLimits.MAX_EVENT_ELEMENTS}, but you tried to emit ${eventElements.events}.\n\n`; + } + + if (!isWithinActionsLimit) { + error += `Error: The account updates in your transaction are trying to emit too much action data. The maximum allowed number of field elements in actions is ${TransactionLimits.MAX_ACTION_ELEMENTS}, but you tried to emit ${eventElements.actions}.\n\n`; + } + + if (error) throw Error('Error during transaction sending:\n\n' + error); +} + +function countEventElements({ data }: Events) { + return data.reduce((acc, ev) => acc + ev.length, 0); +} + +function filterGroups(xs: AuthorizationKind[]) { + let pairs = filterPairs(xs); + xs = pairs.xs; + + let singleCount = 0; + let proofCount = 0; + + xs.forEach((t) => { + if (t.isProved) proofCount++; + else singleCount++; + }); + + return { + signedPair: pairs.pairs, + signedSingle: singleCount, + proof: proofCount, + }; +} + +async function verifyAccountUpdate( + account: Account, + accountUpdate: AccountUpdate, + publicInput: ZkappPublicInput, + transactionCommitments: { commitment: bigint; fullCommitment: bigint }, + proofsEnabled: boolean, + networkId: NetworkId +): Promise { + // check that that top-level updates have mayUseToken = No + // (equivalent check exists in the Mina node) + if ( + accountUpdate.body.callDepth === 0 && + !AccountUpdate.MayUseToken.isNo(accountUpdate).toBoolean() + ) { + throw Error( + 'Top-level account update can not use or pass on token permissions. Make sure that\n' + + 'accountUpdate.body.mayUseToken = AccountUpdate.MayUseToken.No;' + ); + } + + let perm = account.permissions; + + // check if addMissingSignatures failed to include a signature + // due to a missing private key + if (accountUpdate.authorization === dummySignature()) { + let pk = PublicKey.toBase58(accountUpdate.body.publicKey); + throw Error( + `verifyAccountUpdate: Detected a missing signature for (${pk}), private key was missing.` + ); + } + // we are essentially only checking if the update is empty or an actual update + function includesChange( + val: T | string | null | (string | null)[] + ): boolean { + if (Array.isArray(val)) { + return !val.every((v) => v === null); + } else { + return val !== null; + } + } + + function permissionForUpdate(key: string): Types.AuthRequired { + switch (key) { + case 'appState': + return perm.editState; + case 'delegate': + return perm.setDelegate; + case 'verificationKey': + return perm.setVerificationKey.auth; + case 'permissions': + return perm.setPermissions; + case 'zkappUri': + return perm.setZkappUri; + case 'tokenSymbol': + return perm.setTokenSymbol; + case 'timing': + return perm.setTiming; + case 'votingFor': + return perm.setVotingFor; + case 'actions': + return perm.editActionState; + case 'incrementNonce': + return perm.incrementNonce; + case 'send': + return perm.send; + case 'receive': + return perm.receive; + default: + throw Error(`Invalid permission for field ${key}: does not exist.`); + } + } + + let accountUpdateJson = accountUpdate.toJSON(); + const update = accountUpdateJson.body.update; + + let errorTrace = ''; + + let isValidProof = false; + let isValidSignature = false; + + // we don't check if proofs aren't enabled + if (!proofsEnabled) isValidProof = true; + + if (accountUpdate.authorization.proof && proofsEnabled) { + try { + let publicInputFields = ZkappPublicInput.toFields(publicInput); + + let proof: JsonProof = { + maxProofsVerified: 2, + proof: accountUpdate.authorization.proof!, + publicInput: publicInputFields.map((f) => f.toString()), + publicOutput: [], + }; + + let verificationKey = account.zkapp?.verificationKey?.data; + assert( + verificationKey !== undefined, + 'Account does not have a verification key' + ); + + isValidProof = await verify(proof, verificationKey); + if (!isValidProof) { + throw Error( + `Invalid proof for account update\n${JSON.stringify(update)}` + ); + } + } catch (error) { + errorTrace += '\n\n' + (error as Error).stack; + isValidProof = false; + } + } + + if (accountUpdate.authorization.signature) { + // checking permissions and authorization for each account update individually + try { + isValidSignature = verifyAccountUpdateSignature( + TypesBigint.AccountUpdate.fromJSON(accountUpdateJson), + transactionCommitments, + networkId + ); + } catch (error) { + errorTrace += '\n\n' + (error as Error).stack; + isValidSignature = false; + } + } + + let verified = false; + + function checkPermission(p0: Types.AuthRequired, field: string) { + let p = Types.AuthRequired.toJSON(p0); + if (p === 'None') return; + + if (p === 'Impossible') { + throw Error( + `Transaction verification failed: Cannot update field '${field}' because permission for this field is '${p}'` + ); + } + + if (p === 'Signature' || p === 'Either') { + verified ||= isValidSignature; + } + + if (p === 'Proof' || p === 'Either') { + verified ||= isValidProof; + } + + if (!verified) { + throw Error( + `Transaction verification failed: Cannot update field '${field}' because permission for this field is '${p}', but the required authorization was not provided or is invalid. + ${errorTrace !== '' ? 'Error trace: ' + errorTrace : ''}\n\n` + ); + } + } + + // goes through the update field on a transaction + Object.entries(update).forEach(([key, value]) => { + if (includesChange(value)) { + let p = permissionForUpdate(key); + checkPermission(p, key); + } + }); + + // checks the sequence events (which result in an updated sequence state) + if (accountUpdate.body.actions.data.length > 0) { + let p = permissionForUpdate('actions'); + checkPermission(p, 'actions'); + } + + if (accountUpdate.body.incrementNonce.toBoolean()) { + let p = permissionForUpdate('incrementNonce'); + checkPermission(p, 'incrementNonce'); + } + + // this checks for an edge case where an account update can be approved using proofs but + // a) the proof is invalid (bad verification key) + // and b) there are no state changes initiate so no permissions will be checked + // however, if the verification key changes, the proof should still be invalid + if (errorTrace && !verified) { + throw Error( + `One or more proofs were invalid and no other form of authorization was provided.\n${errorTrace}` + ); + } +} + +type AuthorizationKind = { isProved: boolean; isSigned: boolean }; + +const isPair = (a: AuthorizationKind, b: AuthorizationKind) => + !a.isProved && !b.isProved; + +function filterPairs(xs: AuthorizationKind[]): { + xs: { isProved: boolean; isSigned: boolean }[]; + pairs: number; +} { + if (xs.length <= 1) return { xs, pairs: 0 }; + if (isPair(xs[0], xs[1])) { + let rec = filterPairs(xs.slice(2)); + return { xs: rec.xs, pairs: rec.pairs + 1 }; + } else { + let rec = filterPairs(xs.slice(1)); + return { xs: [xs[0]].concat(rec.xs), pairs: rec.pairs }; + } +} From cdad58f8b6986cbafcc143074f8abe6d2646d541 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 21 Feb 2024 10:41:17 -0800 Subject: [PATCH 50/59] refactor(mina.ts, local-blockchain.ts, transaction.ts): change hash method to hash property for better readability and simplicity --- src/lib/mina.ts | 6 ++---- src/lib/mina/local-blockchain.ts | 4 +--- src/lib/mina/transaction.ts | 8 ++++---- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 2503e4181f..2c7c7696bd 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -268,11 +268,9 @@ function Network( data: response?.data, errors, transaction: txn.transaction, + hash, toJSON: txn.toJSON, toPretty: txn.toPretty, - hash() { - return hash; - }, }; const pollTransactionStatus = async ( @@ -337,7 +335,7 @@ function Network( const maxAttempts = options?.maxAttempts ?? 45; const interval = options?.interval ?? 20000; return pollTransactionStatus( - pendingTransaction.hash(), + pendingTransaction.hash, maxAttempts, interval ); diff --git a/src/lib/mina/local-blockchain.ts b/src/lib/mina/local-blockchain.ts index a5f9920f1d..299130ee67 100644 --- a/src/lib/mina/local-blockchain.ts +++ b/src/lib/mina/local-blockchain.ts @@ -264,11 +264,9 @@ function LocalBlockchain({ isSuccess, errors, transaction: txn.transaction, + hash, toJSON: txn.toJSON, toPretty: txn.toPretty, - hash: (): string => { - return hash; - }, }; const wait = async (_options?: { diff --git a/src/lib/mina/transaction.ts b/src/lib/mina/transaction.ts index 43274c98c8..ccf84c5cd6 100644 --- a/src/lib/mina/transaction.ts +++ b/src/lib/mina/transaction.ts @@ -181,15 +181,15 @@ type PendingTransaction = Pick< }): Promise; /** - * Generates and returns the transaction hash as a string identifier. - * @returns {string} The hash of the transaction. + * Returns the transaction hash as a string identifier. + * @property {string} The hash of the transaction. * @example * ```ts - * const txHash = pendingTransaction.hash(); + * const txHash = pendingTransaction.hash; * console.log(`Transaction hash: ${txHash}`); * ``` */ - hash(): string; + hash: string; /** * Optional. Contains response data from a ZkApp transaction submission. From 0fdac1865de21f3ab52634554d3abc2d5a436d96 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 21 Feb 2024 10:48:05 -0800 Subject: [PATCH 51/59] refactor(errors.ts): simplify error handling logic by removing accountUpdateErrors variable --- src/lib/mina/errors.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/lib/mina/errors.ts b/src/lib/mina/errors.ts index 16d2ab2d85..bdbfd16c04 100644 --- a/src/lib/mina/errors.ts +++ b/src/lib/mina/errors.ts @@ -61,12 +61,11 @@ function invalidTransactionError( let errorMessages = []; let rawErrors = JSON.stringify(errors); let n = transaction.accountUpdates.length; - let accountUpdateErrors = errors.slice(1, n + 1); // Check if the number of errors match the number of account updates. If there are more, then the fee payer has an error. // We do this check because the fee payer error is not included in network transaction errors and is always present (even if empty) in the local transaction errors. - if (accountUpdateErrors.length === n) { - let errorsForFeePayer = errors[0]; + if (errors.length > n) { + let errorsForFeePayer = errors.shift() ?? []; for (let [error] of errorsForFeePayer) { let message = ErrorHandlers[error as keyof typeof ErrorHandlers]?.({ transaction, @@ -78,8 +77,8 @@ function invalidTransactionError( } } - for (let i = 0; i < accountUpdateErrors.length; i++) { - let errorsForUpdate = accountUpdateErrors[i]; + for (let i = 0; i < errors.length; i++) { + let errorsForUpdate = errors[i]; for (let [error] of errorsForUpdate) { let message = ErrorHandlers[error as keyof typeof ErrorHandlers]?.({ transaction, From f1ffe88373f173b0e68ffef9fa0632a28f1bfb3c Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 21 Feb 2024 10:49:42 -0800 Subject: [PATCH 52/59] feat(local-blockchain.ts): add error handling for rejected transactions This change throws an error when a transaction is rejected, providing more detailed feedback about the transaction failure. --- src/lib/mina/local-blockchain.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/lib/mina/local-blockchain.ts b/src/lib/mina/local-blockchain.ts index 299130ee67..06dbf66911 100644 --- a/src/lib/mina/local-blockchain.ts +++ b/src/lib/mina/local-blockchain.ts @@ -283,10 +283,15 @@ function LocalBlockchain({ maxAttempts?: number; interval?: number; }) => { - return createIncludedOrRejectedTransaction( - pendingTransaction, - pendingTransaction.errors - ); + const pendingTransaction = await wait(_options); + if (pendingTransaction.status === 'rejected') { + throw Error( + `Transaction failed with errors:\n${pendingTransaction.errors.join( + '\n' + )}` + ); + } + return pendingTransaction; }; return { From b13b3e8d26c8f7e20b65723d03d62ff7005a0eb2 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 21 Feb 2024 10:55:31 -0800 Subject: [PATCH 53/59] chore(bindings): update bindings submodule to a7ade --- src/bindings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bindings b/src/bindings index 53a286f664..a7ade0db48 160000 --- a/src/bindings +++ b/src/bindings @@ -1 +1 @@ -Subproject commit 53a286f664b04f40e85f304397ec5edd1c3c07d6 +Subproject commit a7ade0db4879afeb603c246c967098e0ca6170b5 From 463768ab8b7abe95896b237870cb8ed3613f5c55 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 21 Feb 2024 11:06:12 -0800 Subject: [PATCH 54/59] refactor(transaction-flow.ts): replace try-catch block with assert.rejects for cleaner error handling --- src/tests/transaction-flow.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/tests/transaction-flow.ts b/src/tests/transaction-flow.ts index 46da5aa209..90a3721d74 100644 --- a/src/tests/transaction-flow.ts +++ b/src/tests/transaction-flow.ts @@ -248,9 +248,8 @@ console.log(''); console.log( "Test calling failing 'update' expecting 'invalid_fee_access' does throw with throwOnFail is true" ); -await testLocalAndRemote(async (skip: string) => { - let errorWasThrown = false; - try { +await testLocalAndRemote(async () => { + await assert.rejects(async () => { const transaction = await Mina.transaction( { sender, fee: transactionFee }, () => { @@ -260,10 +259,7 @@ await testLocalAndRemote(async (skip: string) => { ); transaction.sign([senderKey, zkAppKey]); await sendAndVerifyTransaction(transaction, true); - } catch (error) { - errorWasThrown = true; - } - assert(errorWasThrown); + }); }); console.log(''); From 638f42a0993598cc0bcaffaf226b7bce70272952 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 21 Feb 2024 11:17:56 -0800 Subject: [PATCH 55/59] refactor(mina.ts, transaction.ts): move transaction function from mina.ts to transaction.ts --- src/lib/mina.ts | 53 +----------------------------------- src/lib/mina/transaction.ts | 54 +++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 52 deletions(-) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 2c7c7696bd..6c9d054665 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -7,7 +7,6 @@ import * as Fetch from './fetch.js'; import { invalidTransactionError } from './mina/errors.js'; import { Types } from '../bindings/mina-transaction/types.js'; import { Account } from './mina/account.js'; -import { prettifyStacktrace } from './errors.js'; import { NetworkId } from '../mina-signer/src/types.js'; import { currentTransaction } from './mina/transaction-context.js'; import { @@ -28,6 +27,7 @@ import { type RejectedTransaction, createTransaction, newTransaction, + transaction, createIncludedOrRejectedTransaction, } from './mina/transaction.js'; import { @@ -456,57 +456,6 @@ function BerkeleyQANet(graphqlEndpoint: string) { return Network(graphqlEndpoint); } -/** - * Construct a smart contract transaction. Within the callback passed to this function, - * you can call into the methods of smart contracts. - * - * ``` - * let tx = await Mina.transaction(sender, () => { - * myZkapp.update(); - * someOtherZkapp.someOtherMethod(); - * }); - * ``` - * - * @return A transaction that can subsequently be submitted to the chain. - */ -function transaction(sender: FeePayerSpec, f: () => void): Promise; -function transaction(f: () => void): Promise; -/** - * @deprecated It's deprecated to pass in the fee payer's private key. Pass in the public key instead. - * ``` - * // good - * Mina.transaction(publicKey, ...); - * Mina.transaction({ sender: publicKey }, ...); - * - * // deprecated - * Mina.transaction(privateKey, ...); - * Mina.transaction({ feePayerKey: privateKey }, ...); - * ``` - */ -function transaction( - sender: DeprecatedFeePayerSpec, - f: () => void -): Promise; -function transaction( - senderOrF: DeprecatedFeePayerSpec | (() => void), - fOrUndefined?: () => void -): Promise { - let sender: DeprecatedFeePayerSpec; - let f: () => void; - try { - if (fOrUndefined !== undefined) { - sender = senderOrF as DeprecatedFeePayerSpec; - f = fOrUndefined; - } else { - sender = undefined; - f = senderOrF as () => void; - } - return activeInstance.transaction(sender, f); - } catch (error) { - throw prettifyStacktrace(error); - } -} - /** * Returns the public key of the current transaction's sender account. * diff --git a/src/lib/mina/transaction.ts b/src/lib/mina/transaction.ts index ccf84c5cd6..9d74aa9bb6 100644 --- a/src/lib/mina/transaction.ts +++ b/src/lib/mina/transaction.ts @@ -8,6 +8,7 @@ import { TokenId, addMissingProofs, } from '../account-update.js'; +import { prettifyStacktrace } from '../errors.js'; import { Field } from '../core.js'; import { PrivateKey, PublicKey } from '../signature.js'; import { UInt32, UInt64 } from '../int.js'; @@ -18,6 +19,7 @@ import { assertPreconditionInvariants } from '../precondition.js'; import { Account } from './account.js'; import { type DeprecatedFeePayerSpec, + type FeePayerSpec, activeInstance, } from './mina-instance.js'; import * as Fetch from '../fetch.js'; @@ -33,6 +35,7 @@ export { sendTransaction, newTransaction, getAccount, + transaction, createIncludedOrRejectedTransaction, }; @@ -425,6 +428,57 @@ function newTransaction(transaction: ZkappCommand, proofsEnabled?: boolean) { return self; } +/** + * Construct a smart contract transaction. Within the callback passed to this function, + * you can call into the methods of smart contracts. + * + * ``` + * let tx = await Mina.transaction(sender, () => { + * myZkapp.update(); + * someOtherZkapp.someOtherMethod(); + * }); + * ``` + * + * @return A transaction that can subsequently be submitted to the chain. + */ +function transaction(sender: FeePayerSpec, f: () => void): Promise; +function transaction(f: () => void): Promise; +/** + * @deprecated It's deprecated to pass in the fee payer's private key. Pass in the public key instead. + * ``` + * // good + * Mina.transaction(publicKey, ...); + * Mina.transaction({ sender: publicKey }, ...); + * + * // deprecated + * Mina.transaction(privateKey, ...); + * Mina.transaction({ feePayerKey: privateKey }, ...); + * ``` + */ +function transaction( + sender: DeprecatedFeePayerSpec, + f: () => void +): Promise; +function transaction( + senderOrF: DeprecatedFeePayerSpec | (() => void), + fOrUndefined?: () => void +): Promise { + let sender: DeprecatedFeePayerSpec; + let f: () => void; + try { + if (fOrUndefined !== undefined) { + sender = senderOrF as DeprecatedFeePayerSpec; + f = fOrUndefined; + } else { + sender = undefined; + f = senderOrF as () => void; + } + return activeInstance.transaction(sender, f); + } catch (error) { + throw prettifyStacktrace(error); + } +} + async function sendTransaction(txn: Transaction) { return await activeInstance.sendTransaction(txn); } From e12d29e84caaba2f88b9dd8672057322c589c46d Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 21 Feb 2024 11:20:02 -0800 Subject: [PATCH 56/59] refactor(run-live.ts): replace hash() method with hash property for better readability and performance fix(run-live.ts): correct the usage of hash in console.log statements to ensure accurate transaction hash display --- src/examples/zkapps/dex/run-live.ts | 6 ++++-- src/examples/zkapps/hello-world/run-live.ts | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/examples/zkapps/dex/run-live.ts b/src/examples/zkapps/dex/run-live.ts index 84b6b945e1..554b77b85b 100644 --- a/src/examples/zkapps/dex/run-live.ts +++ b/src/examples/zkapps/dex/run-live.ts @@ -290,8 +290,10 @@ function logPendingTransaction(pendingTx: Mina.PendingTransaction) { console.log( 'tx sent: ' + (useCustomLocalNetwork - ? `file://${os.homedir()}/.cache/zkapp-cli/lightnet/explorer//index.html?target=transaction&hash=${pendingTx.hash()}` - : `https://minascan.io/berkeley/tx/${pendingTx.hash()}?type=zk-tx`) + ? `file://${os.homedir()}/.cache/zkapp-cli/lightnet/explorer//index.html?target=transaction&hash=${ + pendingTx.hash + }` + : `https://minascan.io/berkeley/tx/${pendingTx.hash}?type=zk-tx`) ); } diff --git a/src/examples/zkapps/hello-world/run-live.ts b/src/examples/zkapps/hello-world/run-live.ts index 241b4d2b16..8fa166e632 100644 --- a/src/examples/zkapps/hello-world/run-live.ts +++ b/src/examples/zkapps/hello-world/run-live.ts @@ -59,11 +59,11 @@ let transaction = await Mina.transaction( transaction.sign([senderKey, zkAppKey]); console.log('Sending the transaction.'); let pendingTx = await transaction.send(); -if (pendingTx.hash() !== undefined) { +if (pendingTx.hash !== undefined) { console.log(`Success! Deploy transaction sent. Your smart contract will be deployed as soon as the transaction is included in a block. -Txn hash: ${pendingTx.hash()}`); +Txn hash: ${pendingTx.hash}`); } console.log('Waiting for transaction inclusion in a block.'); await pendingTx.wait({ maxAttempts: 90 }); @@ -77,11 +77,11 @@ transaction = await Mina.transaction({ sender, fee: transactionFee }, () => { await transaction.sign([senderKey]).prove(); console.log('Sending the transaction.'); pendingTx = await transaction.send(); -if (pendingTx.hash() !== undefined) { +if (pendingTx.hash !== undefined) { console.log(`Success! Update transaction sent. Your smart contract state will be updated as soon as the transaction is included in a block. -Txn hash: ${pendingTx.hash()}`); +Txn hash: ${pendingTx.hash}`); } console.log('Waiting for transaction inclusion in a block.'); await pendingTx.wait({ maxAttempts: 90 }); From 39645aafb61eb3f8801f7387e1e3cb3dfa6c94a6 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 21 Feb 2024 12:03:52 -0800 Subject: [PATCH 57/59] docs(CHANGELOG.md): update changelog with recent fixes and changes - Add fix for parity between `Mina.LocalBlockchain` and `Mina.Network` to ensure consistent behaviors - Include changes to `TransactionId`, `transaction.send()`, and `transaction.wait()` for better error handling and transaction state representation --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dcfa822e1..e95816993a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed - Mitigate security hazard of deploying token contracts https://github.com/o1-labs/o1js/issues/1439 +- Fixed parity between `Mina.LocalBlockchain` and `Mina.Network` to have the same behaviors https://github.com/o1-labs/o1js/pull/1422 + - Changed `TransactionId` to `Transaction`. Additionally added `PendingTransaction` and `RejectedTransaction` types to better represent the state of a transaction. + - Changed `transaction.send()` to contain errors in the returned `Transaction` object, instead of throwing them. Added `transaction.sendOrThrowIfError` to throw the error if the transaction was not successful. + - Changed `transaction.wait()` to contain errors in the returned `Transaction` object, instead of throwing them. Added `transaction.waitOrThrowIfError` to throw the error if the transaction was not successful. ## [0.16.1](https://github.com/o1-labs/o1js/compare/834a44002...3b5f7c7) From 5ea85acd6d2b21e4c46c85f84494a1809449aedf Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 21 Feb 2024 12:06:10 -0800 Subject: [PATCH 58/59] refactor(mina.ts, mina-instance.ts): move function definitions from mina.ts to mina-instance.ts for better code organization and maintainability --- src/lib/mina.ts | 114 +++++----------------------------- src/lib/mina/mina-instance.ts | 106 +++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 98 deletions(-) diff --git a/src/lib/mina.ts b/src/lib/mina.ts index 6c9d054665..309fc42e10 100644 --- a/src/lib/mina.ts +++ b/src/lib/mina.ts @@ -10,14 +10,26 @@ import { Account } from './mina/account.js'; import { NetworkId } from '../mina-signer/src/types.js'; import { currentTransaction } from './mina/transaction-context.js'; import { - activeInstance, - setActiveInstance, - Mina, - defaultNetworkConstants, type FeePayerSpec, type DeprecatedFeePayerSpec, type ActionStates, type NetworkConstants, + activeInstance, + setActiveInstance, + Mina, + defaultNetworkConstants, + currentSlot, + getAccount, + hasAccount, + getBalance, + getNetworkId, + getNetworkConstants, + getNetworkState, + accountCreationFee, + fetchEvents, + fetchActions, + getActions, + getProofsEnabled, } from './mina/mina-instance.js'; import { type EventActionFilterOptions } from './mina/graphql.js'; import { @@ -482,100 +494,6 @@ Mina.transaction(sender, // <-- pass in sender's public key here return sender; } -/** - * @return The current slot number, according to the active Mina instance. - */ -function currentSlot(): UInt32 { - return activeInstance.currentSlot(); -} - -/** - * @return The account data associated to the given public key. - */ -function getAccount(publicKey: PublicKey, tokenId?: Field): Account { - return activeInstance.getAccount(publicKey, tokenId); -} - -/** - * Checks if an account exists within the ledger. - */ -function hasAccount(publicKey: PublicKey, tokenId?: Field): boolean { - return activeInstance.hasAccount(publicKey, tokenId); -} - -/** - * @return The current Mina network ID. - */ -function getNetworkId() { - return activeInstance.getNetworkId(); -} - -/** - * @return Data associated with the current Mina network constants. - */ -function getNetworkConstants() { - return activeInstance.getNetworkConstants(); -} - -/** - * @return Data associated with the current state of the Mina network. - */ -function getNetworkState() { - return activeInstance.getNetworkState(); -} - -/** - * @return The balance associated to the given public key. - */ -function getBalance(publicKey: PublicKey, tokenId?: Field) { - return activeInstance.getAccount(publicKey, tokenId).balance; -} - -/** - * Returns the default account creation fee. - * @deprecated use {@link Mina.getNetworkConstants} - */ -function accountCreationFee() { - return activeInstance.accountCreationFee(); -} - -/** - * @return A list of emitted events associated to the given public key. - */ -async function fetchEvents( - publicKey: PublicKey, - tokenId: Field, - filterOptions: EventActionFilterOptions = {} -) { - return await activeInstance.fetchEvents(publicKey, tokenId, filterOptions); -} - -/** - * @return A list of emitted sequencing actions associated to the given public key. - */ -async function fetchActions( - publicKey: PublicKey, - actionStates?: ActionStates, - tokenId?: Field -) { - return await activeInstance.fetchActions(publicKey, actionStates, tokenId); -} - -/** - * @return A list of emitted sequencing actions associated to the given public key. - */ -function getActions( - publicKey: PublicKey, - actionStates?: ActionStates, - tokenId?: Field -) { - return activeInstance.getActions(publicKey, actionStates, tokenId); -} - -function getProofsEnabled() { - return activeInstance.proofsEnabled; -} - function dummyAccount(pubkey?: PublicKey): Account { let dummy = Types.Account.empty(); if (pubkey) dummy.publicKey = pubkey; diff --git a/src/lib/mina/mina-instance.ts b/src/lib/mina/mina-instance.ts index be0080bab5..324dfefaeb 100644 --- a/src/lib/mina/mina-instance.ts +++ b/src/lib/mina/mina-instance.ts @@ -21,6 +21,18 @@ export { activeInstance, setActiveInstance, ZkappStateLength, + currentSlot, + getAccount, + hasAccount, + getBalance, + getNetworkId, + getNetworkConstants, + getNetworkState, + accountCreationFee, + fetchEvents, + fetchActions, + getActions, + getProofsEnabled, }; const defaultAccountCreationFee = 1_000_000_000; @@ -138,3 +150,97 @@ function setActiveInstance(m: Mina) { function noActiveInstance(): never { throw Error('Must call Mina.setActiveInstance first'); } + +/** + * @return The current slot number, according to the active Mina instance. + */ +function currentSlot(): UInt32 { + return activeInstance.currentSlot(); +} + +/** + * @return The account data associated to the given public key. + */ +function getAccount(publicKey: PublicKey, tokenId?: Field): Account { + return activeInstance.getAccount(publicKey, tokenId); +} + +/** + * Checks if an account exists within the ledger. + */ +function hasAccount(publicKey: PublicKey, tokenId?: Field): boolean { + return activeInstance.hasAccount(publicKey, tokenId); +} + +/** + * @return The current Mina network ID. + */ +function getNetworkId() { + return activeInstance.getNetworkId(); +} + +/** + * @return Data associated with the current Mina network constants. + */ +function getNetworkConstants() { + return activeInstance.getNetworkConstants(); +} + +/** + * @return Data associated with the current state of the Mina network. + */ +function getNetworkState() { + return activeInstance.getNetworkState(); +} + +/** + * @return The balance associated to the given public key. + */ +function getBalance(publicKey: PublicKey, tokenId?: Field) { + return activeInstance.getAccount(publicKey, tokenId).balance; +} + +/** + * Returns the default account creation fee. + * @deprecated use {@link Mina.getNetworkConstants} + */ +function accountCreationFee() { + return activeInstance.accountCreationFee(); +} + +/** + * @return A list of emitted events associated to the given public key. + */ +async function fetchEvents( + publicKey: PublicKey, + tokenId: Field, + filterOptions: EventActionFilterOptions = {} +) { + return await activeInstance.fetchEvents(publicKey, tokenId, filterOptions); +} + +/** + * @return A list of emitted sequencing actions associated to the given public key. + */ +async function fetchActions( + publicKey: PublicKey, + actionStates?: ActionStates, + tokenId?: Field +) { + return await activeInstance.fetchActions(publicKey, actionStates, tokenId); +} + +/** + * @return A list of emitted sequencing actions associated to the given public key. + */ +function getActions( + publicKey: PublicKey, + actionStates?: ActionStates, + tokenId?: Field +) { + return activeInstance.getActions(publicKey, actionStates, tokenId); +} + +function getProofsEnabled() { + return activeInstance.proofsEnabled; +} From 069921c7dd21e174855dd2554c1a2e2264b27ba8 Mon Sep 17 00:00:00 2001 From: Martin Minkov Date: Wed, 21 Feb 2024 12:18:14 -0800 Subject: [PATCH 59/59] docs(CHANGELOG.md): move transaction changes to 'Breaking changes' section The changes related to transaction handling in `Mina.LocalBlockchain` and `Mina.Network` are significant and can potentially break existing implementations. Therefore, they have been moved from the 'Fixed' section to a new 'Breaking changes' section to highlight their importance and potential impact. --- CHANGELOG.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e95816993a..8669e21a4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased](https://github.com/o1-labs/o1js/compare/3b5f7c7...HEAD) +### Breaking changes + +- Fixed parity between `Mina.LocalBlockchain` and `Mina.Network` to have the same behaviors https://github.com/o1-labs/o1js/pull/1422 + - Changed the `TransactionId` type to `Transaction`. Additionally added `PendingTransaction` and `RejectedTransaction` types to better represent the state of a transaction. + - `transaction.send()` no longer throws an error if the transaction was not successful for `Mina.LocalBlockchain` and `Mina.Network`. Instead, it returns a `PendingTransaction` object that contains the error. Use `transaction.sendOrThrowIfError` to throw the error if the transaction was not successful. + - `transaction.wait()` no longer throws an error if the transaction was not successful for `Mina.LocalBlockchain` and `Mina.Network`. Instead, it returns either a `IncludedTransaction` or `RejectedTransaction`. Use `transaction.waitOrThrowIfError` to throw the error if the transaction was not successful. + - `transaction.hash()` is no longer a function, it is now a property that returns the hash of the transaction. + ### Added - Support for custom network identifiers other than `mainnet` or `testnet` https://github.com/o1-labs/o1js/pull/1444 @@ -31,10 +39,6 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed - Mitigate security hazard of deploying token contracts https://github.com/o1-labs/o1js/issues/1439 -- Fixed parity between `Mina.LocalBlockchain` and `Mina.Network` to have the same behaviors https://github.com/o1-labs/o1js/pull/1422 - - Changed `TransactionId` to `Transaction`. Additionally added `PendingTransaction` and `RejectedTransaction` types to better represent the state of a transaction. - - Changed `transaction.send()` to contain errors in the returned `Transaction` object, instead of throwing them. Added `transaction.sendOrThrowIfError` to throw the error if the transaction was not successful. - - Changed `transaction.wait()` to contain errors in the returned `Transaction` object, instead of throwing them. Added `transaction.waitOrThrowIfError` to throw the error if the transaction was not successful. ## [0.16.1](https://github.com/o1-labs/o1js/compare/834a44002...3b5f7c7)